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 { 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; export interface ExternalData { position: { x: number; y: number; z: number }; gridType: number; direction: string; } export interface ExternalDataList { direction: number; externalDatas: ExternalData[]; } export interface ExternalResult { isWin: boolean; stepNum: number; direction: number; 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; } onDestroy() { EventManager.remove(EventType.LevelInit, this.onLevelInit); EventManager.remove(EventType.InputEnd, this.onInputEnd); } start() { super.start(); if (this.node.name === 'Player' && GameManager.instance) { this.callChangeSkin(GameManager.instance.playerSkin); } else { this.animator?.setAction(this.animator?.getAction() ?? PlayerAction.Idle); } } // —— 骑乘视觉(逻辑坐标在载具甲板,显示抬高) —— 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.sendFinally = false; this.coins = 0; this.resetMoveRuntime(); this.unbindVehicle(); this.refreshVisual(PlayerAction.Idle); this.syncCommittedCellFromPosition(); this.snapMoverToCellStand(); this.checkIfCurIsRide(); tryLinkPlayerVehicle(this); }; private onInputEnd = () => { 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) { 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.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); 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]})`, ); } } 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 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.unbindVehicle(); } PropController.tryCollectAtCell(cell, this); if (this.vehicle) { this.alignWithVehicleBase(); gm.removeObj(this.lastPosition); const rideCell = this.committedCell ?? cell; gm.addObjAtCell(rideCell, GridType.Ride, this.vehicle.node); } 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() { this.externalCall(); } setName(name: string) { this.vehicle?.setName(name); } getCoinsNum() { JsBridge.call('coinsData', JSON.stringify({ coinsNum: this.coins, gameObjectName: this.node.name })); } addCoins() { this.coins++; this.playCoinsAudio(); } setPosition(pos: Vec3) { if (this.vehicle) { this.setRideBaseFromVehicle(pos); } else { this.node.setPosition(pos); } } setTargetGridType(t: GridType) { this.targetGridType = t; } syncFromVehicle(targetGridType: GridType, isJump: boolean) { this.targetGridType = targetGridType; this.onMoveNextSet(isJump); } syncMoveToTargetFromVehicle() { this.finishMoveToTarget(false); } externalCall() { const gm = GameManager.instance!; const list: ExternalDataList = { direction: this.direction, externalDatas: [] }; 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(sample, d); const cell = gm.worldToCell(wp); list.externalDatas.push({ position: { x: cell.x, y: cell.y, z: 0 }, gridType: gm.calculateNextGridType(sample, d), direction: gm.getRelativePosition(this.direction, d), }); } JsBridge.call('processData', JSON.stringify(list)); } externalCallResult(isWin: boolean) { const gm = GameManager.instance!; 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.sendFinally) { JsBridge.call('externalResult', JSON.stringify({ isWin, stepNum: gm.stepNum, direction: this.direction, isInputEnd: gm.isInputEnd, } satisfies ExternalResult)); this.sendFinally = true; } if (gm.gameState !== GameState.Run) return; gm.setGameState(isWin ? GameState.ResultWin : GameState.ResultFail); } }