Files
cocos/assets/scripts/controller/PlayerController.ts
刘宇飞 d393302388 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>
2026-06-16 15:30:58 +08:00

608 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}