Complete Cocos Creator port with level bundles, themes, and tooling.

Adds level prefabs, theme assets, audio, extensions, and deployment scripts for the Unity WebGL migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 15:30:58 +08:00
parent cba5105908
commit d393302388
6248 changed files with 17322729 additions and 11036 deletions

View File

@@ -1,11 +1,25 @@
import { _decorator, Vec3 } from 'cc';
import { Direction, GameState, GridType, Skin } from '../core/Define';
import { _decorator, AudioSource, Vec3 } from 'cc';
import { Direction, GameState, GridType, MoveState, MoverRole, Skin, skinToTheme, themeToSkin } from '../core/Define';
import { EventManager, EventType } from '../core/EventManager';
import { JsBridge } from '../bridge/JsBridge';
import { GameAudio } from '../audio/GameAudio';
import { GameManager } from '../manager/GameManager';
import { Movement } from '../gameplay/Movement';
import { vehicleFollowsLandingTile, canStayMountedAfterLanding, moverRoleForLandingCell, shouldDismountOnTile } from '../gameplay/MoveRules';
import { VehicleController } from './VehicleController';
import { VisualAssets } from '../visual/VisualAssets';
import { PropController } from './PropController';
import { findVehicleAtCell, tryLinkPlayerVehicle } from './RideLink';
import { PlayerAction } from '../visual/PlayerAnimPaths';
import { PlayerActionAnimator } from '../visual/PlayerActionAnimator';
import { scaledJumpArcOffset, scaledMoveSpeed, UNITY_PLAYER_MOVE_SPEED, CELL_PIXEL, PROP_COLLECT_MOVE_PROGRESS, PROP_COLLECT_TOUCH_RADIUS } from '../core/GridConstants';
import { cellToWorldCenter } from '../core/GridCoords';
import { getThemePlayerRideYOffset } from '../theme/ThemeDatabase';
import {
entityWorldPositionForRole,
moverLogicalGridPositionForRole,
worldToMoverCellForRole,
} from '../level/EntitySpawnPlacement';
import { forEachLevelEntityNode } from '../level/TileLayout';
const { ccclass } = _decorator;
@@ -27,14 +41,26 @@ export interface ExternalResult {
isInputEnd: boolean;
}
/**
* 玩家移动(对齐 Unity PlayerController
* - 玩家节点为位移主体
* - 骑乘:仅目标空地联动载具;落砖下车;视觉 Y 抬高
* - 跳跃:只能落到 Jump 砖MoveRules + moveCondition
*/
@ccclass('PlayerController')
export class PlayerController extends Movement {
coins = 0;
private vehicle: VehicleController | null = null;
private sendFinally = false;
private animator: PlayerActionAnimator | null = null;
private sfxSource: AudioSource | null = null;
onLoad() {
this.moverRole = 'player';
this.moveSpeed = scaledMoveSpeed(UNITY_PLAYER_MOVE_SPEED);
this.animator = this.node.getComponent(PlayerActionAnimator)
?? this.node.addComponent(PlayerActionAnimator);
this.sfxSource = this.node.getComponent(AudioSource) ?? this.node.addComponent(AudioSource);
EventManager.register(EventType.LevelInit, this.onLevelInit);
EventManager.register(EventType.InputEnd, this.onInputEnd);
this.coins = 0;
@@ -47,72 +73,452 @@ export class PlayerController extends Movement {
start() {
super.start();
VisualAssets.applyPlayerSprite(this.node, this.direction);
if (this.node.name === 'Player' && GameManager.instance) {
this.callChangeSkin(GameManager.instance.playerSkin);
} else {
this.animator?.setAction(this.animator?.getAction() ?? PlayerAction.Idle);
}
}
override setDirection(dir: Direction) {
super.setDirection(dir);
VisualAssets.applyPlayerSprite(this.node, dir);
// —— 骑乘视觉(逻辑坐标在载具甲板,显示抬高) ——
private rideYOffset(): number {
const gm = GameManager.instance;
const theme = gm?.getCurLevel()?.theme ?? gm?.uiStyle;
return getThemePlayerRideYOffset(theme);
}
private rideDeckAtCell(cell: Vec3): Vec3 {
const gm = GameManager.instance!;
const config = gm.getCurLevel();
const theme = config?.theme ?? gm.uiStyle;
return entityWorldPositionForRole(cell, config ?? undefined, theme, 'vehicle');
}
/** 步行上载具时的落点(甲板 + 骑乘视觉 Y避免到格后再闪一下 */
private rideMountWorldPosition(cell: Vec3): Vec3 {
const deck = this.rideDeckAtCell(cell);
return new Vec3(deck.x, deck.y + this.rideYOffset(), deck.z);
}
private applyMountVisualAtDeck(deck: Vec3) {
this.node.setPosition(deck.x, deck.y + this.rideYOffset(), deck.z);
}
/** 骑乘时逻辑甲板世界坐标(无视觉抬高) */
getRideDeckWorldPosition(): Vec3 {
if (this.vehicle) return this.vehicle.node.position.clone();
const p = this.node.position;
return new Vec3(p.x, p.y, p.z);
}
/** 是否处于「载具跟随」状态(仅 Idle 时锁定到甲板;移动中由 OnMoving 驱动载具) */
private isVehicleLinked(): boolean {
return !!this.vehicle && this.moveState !== MoveState.Moving;
}
snapToRideBase() {
if (!this.vehicle) return;
this.applyMountVisualAtDeck(this.vehicle.node.position);
}
applyRideVisual() {
if (!this.vehicle) return;
this.applyMountVisualAtDeck(this.vehicle.node.position);
}
/** 载具换向/flip 后重对齐骑乘高度 */
syncRideMountVisual() {
this.applyRideVisual();
}
setRideBaseFromVehicle(base: Vec3) {
this.node.setPosition(base);
this.applyRideVisual();
}
private syncVehicleToPlayerBase() {
if (!this.vehicle) return;
const p = this.node.position;
const off = this.rideYOffset();
this.vehicle.node.setPosition(p.x, p.y - off, p.z);
}
// —— 格子采样:骑乘期间始终以载具甲板为逻辑位置 ——
protected override getGridSamplePosition(): Vec3 {
// 优先 committedCell空地格 Y 补偿会让 worldToMoverCell 误判邻格(如 (-1,-1)→(0,0)
if (this.committedCell) {
return cellToWorldCenter(this.committedCell);
}
const gm = GameManager.instance!;
const config = gm.getCurLevel();
const theme = config?.theme ?? gm.uiStyle;
if (this.vehicle) {
return moverLogicalGridPositionForRole(
this.vehicle.node.position,
config ?? undefined,
theme,
'vehicle',
);
}
return moverLogicalGridPositionForRole(
this.node.position,
config ?? undefined,
theme,
'player',
);
}
update(dt: number) {
if (this.isVehicleLinked()) {
this.applyMountVisualAtDeck(this.vehicle!.node.position);
}
super.update(dt);
}
protected override shouldApplyPlayerStandOffset(): boolean {
if (!this.vehicle) return true;
if (this.moveState !== MoveState.Moving) return false;
return !vehicleFollowsLandingTile(this.targetGridType);
}
setRideVehicle(v: VehicleController | null) {
if (this.vehicle && this.vehicle !== v) {
this.vehicle.setPlayer(null);
}
this.vehicle = v;
if (v) {
v.setPlayer(this);
Movement.callEach = true;
v.setDirection(this.direction);
Movement.callEach = false;
}
}
getRideVehicle(): VehicleController | null {
return this.vehicle;
}
// —— 上下车 ——
private unbindVehicle() {
if (!this.vehicle) return;
this.vehicle.setPlayer(null);
this.vehicle = null;
}
private alignWithVehicleBase() {
if (!this.vehicle || !this.committedCell) return;
const deck = this.rideDeckAtCell(this.committedCell);
this.vehicle.node.setPosition(deck);
this.vehicle.shareCommittedCell(this.committedCell);
this.node.setPosition(deck);
this.applyRideVisual();
}
/** Unity CheckIfCurIsRide仅脚下为 Ride 动态格时绑定载具 */
private checkIfCurIsRide() {
if (this.curGrid !== GridType.Ride || !this.committedCell) return;
const gm = GameManager.instance!;
const vc = findVehicleAtCell(this.committedCell)
?? gm.getGameObjectAtCell(this.committedCell)?.getComponent(VehicleController);
if (!vc) return;
this.setRideVehicle(vc);
vc.setPlayer(this);
vc.setDirection(this.direction);
this.alignWithVehicleBase();
}
private onLevelInit = () => {
this.checkRide();
this.sendFinally = false;
this.coins = 0;
this.resetMoveRuntime();
this.unbindVehicle();
this.refreshVisual(PlayerAction.Idle);
this.syncCommittedCellFromPosition();
this.snapMoverToCellStand();
this.checkIfCurIsRide();
tryLinkPlayerVehicle(this);
};
private onInputEnd = () => {
this.externalCallResult(false);
const gm = GameManager.instance!;
this.externalCallResult(gm.allPropsCollected());
};
protected override snapMoverToCellStand() {
const gm = GameManager.instance;
if (!gm || !this.committedCell) return;
const config = gm.getCurLevel();
const theme = config?.theme ?? gm.uiStyle;
const landingLive = gm.calculateGridTypeAtCell(this.committedCell);
if (canStayMountedAfterLanding(landingLive)) {
const deck = this.rideDeckAtCell(this.committedCell);
if (this.vehicle) {
this.vehicle.node.setPosition(deck);
this.vehicle.shareCommittedCell(this.committedCell);
const mount = this.rideMountWorldPosition(this.committedCell);
this.node.setPosition(mount);
this.targetPosition.set(mount);
} else {
const mount = this.rideMountWorldPosition(this.committedCell);
this.node.setPosition(mount);
this.targetPosition.set(mount);
}
return;
}
const follow = !!this.vehicle && vehicleFollowsLandingTile(landingLive);
const deck = entityWorldPositionForRole(this.committedCell, config ?? undefined, theme, 'vehicle');
if (follow) {
this.vehicle!.node.setPosition(deck);
this.vehicle!.shareCommittedCell(this.committedCell);
const mount = this.rideMountWorldPosition(this.committedCell);
this.node.setPosition(mount);
this.targetPosition.set(mount);
return;
}
const pos = entityWorldPositionForRole(this.committedCell, config ?? undefined, theme, 'player');
this.node.setPosition(pos);
this.targetPosition.set(pos);
}
protected override resolveMoveStepOrigin(_targetCell: Vec3, landingGrid: GridType): Vec3 {
if (this.vehicle && vehicleFollowsLandingTile(landingGrid) && this.committedCell) {
return this.rideMountWorldPosition(this.committedCell);
}
// 下车:从骑乘视觉高度平滑移到砖面,勿先 snap 到甲板
if (this.vehicle && shouldDismountOnTile(landingGrid) && this.committedCell) {
return this.rideMountWorldPosition(this.committedCell);
}
return this.node.position.clone();
}
protected override resolveTargetWorldPosition(targetCell: Vec3, landingGrid: GridType): Vec3 {
if (this.vehicle && vehicleFollowsLandingTile(landingGrid)) {
return this.rideMountWorldPosition(targetCell);
}
const gm = GameManager.instance!;
const config = gm.getCurLevel();
const theme = config?.theme ?? gm.uiStyle;
const role: MoverRole = moverRoleForLandingCell('player', landingGrid, !!this.vehicle);
if (!this.vehicle && landingGrid === GridType.Ride) {
return this.rideMountWorldPosition(targetCell);
}
return entityWorldPositionForRole(targetCell, config ?? undefined, theme, role);
}
protected override onRotateComplete() {
if (this.vehicle) {
this.vehicle.setDirection(this.direction);
if (this.committedCell) this.alignWithVehicleBase();
}
}
/** 是否应拾取该道具(移动中需进入格子且接近触碰,避免起步即消失) */
canCollectProp(prop: PropController): boolean {
const propCell = prop.getSpawnCell();
if (!propCell) return false;
const committed = this.getCommittedCell();
if (!this.isMoving()) {
return !!committed && prop.matchesCell(committed);
}
const landing = this.getLandingCell();
if (!landing || !prop.matchesCell(landing)) return false;
const gm = GameManager.instance!;
const config = gm.getCurLevel();
const theme = config?.theme ?? gm.uiStyle;
const sample = worldToMoverCellForRole(this.node.position, config ?? undefined, theme, 'player');
if (!prop.matchesCell(sample)) return false;
if (this.getMoveStepProgress() < PROP_COLLECT_MOVE_PROGRESS) return false;
const touchR = CELL_PIXEL * PROP_COLLECT_TOUCH_RADIUS;
return Vec3.distance(this.node.position, prop.node.position) <= touchR;
}
private collectPropsOnApproach() {
const gm = GameManager.instance;
const level = gm?.curLevel;
if (!level) return;
forEachLevelEntityNode(level, (ch) => {
const prop = ch.getComponent(PropController);
if (!prop || !this.canCollectProp(prop)) return;
prop.collect(this);
});
}
protected onMoveNextSet(isJump: boolean) {
if (isJump && this.targetGridType === GridType.Jump && this.vehicle) {
this.unbindVehicle();
}
this.animator?.setAction(isJump ? PlayerAction.Jump : PlayerAction.Move);
if (isJump && this.targetGridType === GridType.Jump) {
const p = this.node.worldPosition.clone();
p.y += 0.15;
this.targetPosition.set(p);
this.targetPosition.y += scaledJumpArcOffset();
}
}
protected override playMoveAnim() {
this.animator?.setAction(PlayerAction.Move);
}
protected override syncRideAfterMoveStep() {
if (this.vehicle && vehicleFollowsLandingTile(this.targetGridType)) {
this.syncVehicleToPlayerBase();
}
}
onMoving() {
if (this.targetGridType === GridType.None && this.vehicle && !Movement.callEach) {
this.vehicle.setPosition(this.node.worldPosition);
if (this.vehicle && vehicleFollowsLandingTile(this.targetGridType) && !Movement.callEach) {
this.syncVehicleToPlayerBase();
}
this.collectPropsOnApproach();
if (!this.sfxSource) return;
const action = this.animator?.getAction() ?? PlayerAction.Idle;
if (action === PlayerAction.Move) {
const key = vehicleFollowsLandingTile(this.targetGridType) && this.vehicle
? 'vehicleMove' : 'move';
void GameAudio.playSfxOnSource(this.sfxSource, key);
} else if (action === PlayerAction.Jump) {
void GameAudio.playSfxOnSource(this.sfxSource, 'jump');
}
}
protected onMoveFail(isJump: boolean) {
this.externalCallResult(false);
const gm = GameManager.instance!;
if (gm.multMode) {
const other = gm.findNodeByName(this.node.name === 'Player' ? 'Enemy' : 'Player');
other?.getComponent(PlayerController)?.externalCallResult(true);
if (isJump) {
console.log(
`${this.node.name} 无法跳跃:跳跃只能落到 Jump 砖块(当前=${GridType[this.curGrid]} 前方=${GridType[this.nextGrid]}`,
);
} else if (this.curGrid === GridType.Jump) {
console.log(`${this.node.name} 在跳跃砖块上只能跳跃或转向,不能普通前后移动`);
} else {
console.log(
`${this.node.name} 无法移动(当前=${GridType[this.curGrid]} 前方=${GridType[this.nextGrid]} 后方=${GridType[this.lastGrid]}`,
);
}
console.log(`${this.node.name} 无法移动`, isJump);
}
protected onMoveToTarget() {
this.commitLandingCell();
this.finishMoveToTarget(true);
}
/** Unity OnMoveToTarget(bool) — 上下车 / 载具 Ride 注册 */
finishMoveToTarget(sendMsgOnStep0 = true) {
const gm = GameManager.instance!;
this.applyResultAction();
const cell = this.committedCell
?? worldToMoverCellForRole(
this.node.position,
gm.getCurLevel() ?? undefined,
gm.getCurLevel()?.theme ?? gm.uiStyle,
'player',
);
if (this.curGrid === GridType.Ride) {
const obj = GameManager.instance!.getGameObject(this.node.worldPosition);
if (obj) {
this.vehicle = obj.getComponent(VehicleController);
this.vehicle?.setPlayer(this);
if (this.vehicle) this.vehicle.setDirection(this.direction);
const vc = findVehicleAtCell(cell)
?? gm.getGameObjectAtCell(cell)?.getComponent(VehicleController);
if (vc) {
this.setRideVehicle(vc);
vc.setPlayer(this);
vc.setDirection(this.direction);
}
} else if (this.curGrid !== GridType.None && this.vehicle) {
this.vehicle.setPlayer(null);
this.vehicle = null;
this.unbindVehicle();
}
PropController.tryCollectAtCell(cell, this);
if (this.vehicle) {
this.vehicle.setPosition(this.node.worldPosition);
GameManager.instance!.removeObj(this.lastPosition);
GameManager.instance!.addObj(this.node.worldPosition, GridType.Ride, this.vehicle.node);
this.alignWithVehicleBase();
gm.removeObj(this.lastPosition);
const rideCell = this.committedCell ?? cell;
gm.addObjAtCell(rideCell, GridType.Ride, this.vehicle.node);
}
this.externalCall();
if (this.committedCell) {
this.targetGridType = gm.calculateGridTypeAtCell(this.committedCell);
}
if (this.step === 0 && sendMsgOnStep0) {
this.externalCall();
}
}
// —— 外观 / 外部接口 ——
private activeVisualTheme(): string {
const gm = GameManager.instance;
const levelTheme = gm?.getCurLevel()?.theme?.trim();
if (levelTheme) return levelTheme;
if (gm) return skinToTheme(gm.playerSkin);
return 'silu';
}
private visualOpts() {
const gm = GameManager.instance;
const theme = this.activeVisualTheme();
return { ...(gm?.getEntityVisualOptions() ?? {}), theme };
}
private refreshVisual(action = PlayerAction.Idle) {
this.animator?.configure(this.activeVisualTheme(), this.direction);
this.animator?.setAction(action);
}
/** 关卡加载后强制用地图主题(主站 CallChangeSkin 不得覆盖) */
applyLevelTheme(theme: string) {
const t = theme?.trim();
if (!t) return;
const gm = GameManager.instance;
if (gm) gm.playerSkin = themeToSkin(t);
this.animator?.configure(t, this.direction);
this.animator?.setAction(this.animator?.getAction() ?? PlayerAction.Idle, true);
this.reapplyCellStandPosition();
this.vehicle?.refreshIcon();
}
override setDirection(dir: Direction) {
super.setDirection(dir);
this.animator?.setDirection(dir, this.visualOpts());
if (Movement.callEach) return;
Movement.callEach = true;
this.vehicle?.setDirection(dir);
Movement.callEach = false;
}
callChangeSkin(n: number) {
if (n < 0 || n > Skin.sanxing) return;
if (GameManager.instance) GameManager.instance.playerSkin = n as Skin;
const levelTheme = GameManager.instance?.getCurLevel()?.theme?.trim();
if (levelTheme) {
this.applyLevelTheme(levelTheme);
return;
}
this.refreshVisual(this.animator?.getAction() ?? PlayerAction.Idle);
}
playCoinsAudio() {
GameAudio.playSfx('coins', this.node);
}
private applyResultAction() {
const gm = GameManager.instance;
if (!gm) return;
if (gm.gameState === GameState.ResultWin) {
this.animator?.setAction(PlayerAction.Win);
} else if (gm.gameState === GameState.ResultFail) {
this.animator?.setAction(PlayerAction.Fail);
} else {
this.animator?.setAction(PlayerAction.Idle);
}
}
callPlayerInfo() {
@@ -129,85 +535,73 @@ export class PlayerController extends Movement {
addCoins() {
this.coins++;
this.playCoinsAudio();
}
setPosition(pos: Vec3) {
this.node.setPosition(pos);
if (this.vehicle) {
this.setRideBaseFromVehicle(pos);
} else {
this.node.setPosition(pos);
}
}
setTargetGridType(t: GridType) {
this.targetGridType = t;
}
/** 供 VehicleController 同步 */
syncFromVehicle(targetGridType: GridType, isJump: boolean) {
this.targetGridType = targetGridType;
this.onMoveNextSet(isJump);
}
syncMoveToTargetFromVehicle() {
this.onMoveToTarget();
this.finishMoveToTarget(false);
}
externalCall() {
const gm = GameManager.instance!;
const list: ExternalDataList = { direction: this.direction, externalDatas: [] };
const self = gm.worldToCell(this.node.worldPosition);
const sample = this.getGridSamplePosition();
const self = gm.worldToCell(sample);
list.externalDatas.push({
position: { x: self.x, y: self.y, z: 0 },
gridType: this.curGrid,
direction: 'self',
});
for (let d = Direction.North; d <= Direction.West; d++) {
const wp = gm.nextGridPosition(this.node.worldPosition, d);
const wp = gm.nextGridPosition(sample, d);
const cell = gm.worldToCell(wp);
list.externalDatas.push({
position: { x: cell.x, y: cell.y, z: 0 },
gridType: gm.calculateNextGridType(this.node.worldPosition, d),
gridType: gm.calculateNextGridType(sample, d),
direction: gm.getRelativePosition(this.direction, d),
});
}
const json = JSON.stringify(list);
if (gm.multMode) JsBridge.call(`process${this.node.name}`, json);
else JsBridge.call('processData', json);
JsBridge.call('processData', JSON.stringify(list));
}
externalCallResult(isWin: boolean) {
const gm = GameManager.instance!;
if (isWin && (this.node.name === 'Player' || this.node.name === gm.multPlayerRole)) {
/* success audio */
} else if (!isWin && (this.node.name === 'Player' || this.node.name === gm.multPlayerRole)) {
/* fail audio */
}
let myStep = gm.stepNum;
if (gm.multMode) {
switch (gm.multPlayerRole) {
case 'PlayerA1': myStep = gm.stepA1Num; break;
case 'PlayerA2': myStep = gm.stepA2Num; break;
case 'PlayerA3': myStep = gm.stepA3Num; break;
case 'PlayerB1': myStep = gm.stepB1Num; break;
case 'PlayerB2': myStep = gm.stepB2Num; break;
case 'PlayerB3': myStep = gm.stepB3Num; break;
if (this.node.name === 'Player') {
if (isWin) {
GameAudio.playSfx('success', this.node);
this.animator?.setAction(PlayerAction.Win);
} else {
GameAudio.playSfx('fail', this.node);
this.animator?.setAction(PlayerAction.Fail);
}
}
if ((this.node.name === 'Player' || this.node.name === gm.multPlayerRole) && !this.sendFinally) {
const payload: ExternalResult = {
if (this.node.name === 'Player' && !this.sendFinally) {
JsBridge.call('externalResult', JSON.stringify({
isWin,
stepNum: myStep,
stepNum: gm.stepNum,
direction: this.direction,
isInputEnd: gm.isInputEnd,
};
JsBridge.call('externalResult', JSON.stringify(payload));
} satisfies ExternalResult));
this.sendFinally = true;
}
if (gm.gameState !== GameState.Run) return;
gm.setGameState(isWin ? GameState.ResultWin : GameState.ResultFail);
}
private checkRide() {
if (this.curGrid === GridType.Ride) {
const obj = GameManager.instance!.getGameObject(this.node.worldPosition);
if (obj) {
this.vehicle = obj.getComponent(VehicleController);
this.vehicle?.setDirection(this.direction);
this.vehicle?.setPlayer(this);
}
}
}
}