Files
cocos/assets/scripts/GameController.ts
2026-06-18 14:07:38 +08:00

1110 lines
41 KiB
TypeScript
Raw 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, Component, Node, Vec3, UITransform, director, Enum, find,
instantiate, CCInteger, Sprite,
} from 'cc';
import { EDITOR, PREVIEW } from 'cc/env';
import { CELL_PIXEL } from './core/GridConstants';
import {
cellToWorld as isoCellToWorld,
cellToWorldCenter,
worldCenterToCell,
} from './core/GridCoords';
import {
CommonDefine, Direction, GameState, GridType, Skin, addDirection, themeToSkin,
} from './core/Define';
import { EventManager, EventType } from './core/EventManager';
import { ExternalLevelInfo, JsBridge } from './bridge/JsBridge';
import { ensureRuntimeAssetsForLevel } from './core/RuntimePack';
import {
hasLevel, getMaxLevelId, getMinLevelId, LEVEL_ID_BASE, resolveLevelConfig,
nextLevelId, prevLevelId,
} from './level/LevelRegistry';
import { LevelConfig, SpawnConfig, SpawnKind } from './level/LevelTypes';
import {
PropPlacement, propWorldYOffset, resolvePropPlacement,
entityWorldPositionForRole, worldToMoverCellForRole,
} from './level/EntitySpawnPlacement';
import { EntityVisualOptions } from './visual/EntityTextureResolver';
import { getLevelPrefabResourcePath } from './level/LevelPrefabPaths';
import { loadLevelPrefab as loadLevelPrefabImpl } from './level/LevelPrefabLoader';
import { PlayerController } from './controller/PlayerController';
import { VehicleController } from './controller/VehicleController';
import { PropController } from './controller/PropController';
import { VisualAssets } from './visual/VisualAssets';
import { resolveThemeId } from './theme/ThemeDatabase';
import { LevelDisplay } from './level/LevelDisplay';
import { mergeLevelConfigWithMapData } from './level/LevelConfigMerge';
import { ensureEntityUILayer, findLevelChildByName, forEachLevelEntityNode, sortIsoTiles } from './level/TileLayout';
import { Movement } from './gameplay/Movement';
import { tryLinkPlayerVehicle, tryLinkVehicleRider } from './controller/RideLink';
import { GridSnapHelper } from './level/GridSnapHelper';
import { LineGridRenderer } from './gameplay/LineGridRenderer';
import { ThemeBackground } from './theme/ThemeBackground';
import { GameAudio } from './audio/GameAudio';
import { reloadThemeDatabase } from './theme/ThemeRegistry';
import { reloadTileDisplayMeta } from './visual/TileDisplayMeta';
import { UIMain } from './ui/UIMain';
import { clearUIIconCache } from './ui/UIStyleAssets';
const { ccclass, property } = _decorator;
interface GridEntry {
type: GridType;
node: Node;
}
/** Web 主站:优先走 loader 注入的 loadLevelPrefab按关下载 + 内存 bundle */
function loadLevelPrefabForRuntime(path: string) {
const hook = (globalThis as { __tfrhLoadLevelPrefab?: typeof loadLevelPrefabImpl }).__tfrhLoadLevelPrefab;
if (typeof hook === 'function') return hook(path);
return loadLevelPrefabImpl(path);
}
/**
* 主站唯一入口组件(原 GameManager + GameController 合并)。
* Inspector填写 inputLevel预览 ▶ 后点 SwitchLevel对齐 Unity TestGame2
*/
@ccclass('GameController')
export class GameController extends Component {
static instance: GameController | null = null;
@property({ group: { name: '关卡切换', id: '1' }, displayName: 'inputLevel' })
inputLevel = '1';
@property({ group: { name: '关卡切换', id: '1' }, type: CCInteger, displayName: '当前关卡', readonly: true })
curLevelID = 1;
@property({ group: { name: '关卡切换', id: '1' }, displayName: '关卡范围', readonly: true })
levelRangeHint = '180';
@property({ group: { name: '关卡切换', id: '1' }, type: CCInteger, displayName: '启动关卡 Initial Level ID' })
initialLevelID = LEVEL_ID_BASE;
@property({ group: { name: '场景', id: '2' }, displayName: 'Main Level Entrance', type: Node })
mainLevelEntrance: Node | null = null;
@property({ group: { name: '场景', id: '2' }, displayName: 'Cur Level', type: Node, readonly: true })
curLevel: Node | null = null;
@property({ group: { name: '高级', id: '3' }, type: Enum(Skin), displayName: 'Player Skin' })
playerSkin: Skin = Skin.Silu;
@property({ group: { name: '高级', id: '3' }, displayName: 'UI Style' })
uiStyle = 'default';
@property({ group: { name: '高级', id: '3' }, displayName: 'inputStyle' })
inputStyle = 'default';
@property({ group: { name: '高级', id: '3' }, displayName: '调试-跳过角色与可拾取物' })
skipPlayerAndProps = false;
gameState: GameState = GameState.Run;
isInputEnd = false;
stepNum = 0;
ready = false;
private levelLoadSeq: Promise<void> = Promise.resolve();
/** 倍速 1/2/4作用于移动与角色动画 */
gameSpeed = 1;
private creating = false;
private curConfig: LevelConfig | null = null;
private gridTypes = new Map<string, GridEntry>();
private gridTypesForProps = new Map<string, GridEntry>();
private groundCells = new Map<string, string>();
private borderCells = new Set<string>();
onLoad() {
if (GameController.instance && GameController.instance !== this) {
this.destroy();
return;
}
GameController.instance = this;
this.levelRangeHint = `${getMinLevelId()}${getMaxLevelId()}`;
this.registerWebApi();
}
start() {
const raw = typeof this.inputLevel === 'string' ? this.inputLevel : String(this.inputLevel ?? '');
if (!raw.trim()) {
this.inputLevel = String(this.curLevelID || this.initialLevelID);
}
}
update() {
if (EDITOR && !PREVIEW) return;
this.resolveMainLevelEntrance();
this.syncCurLevelRef();
}
onDestroy() {
if (GameController.instance === this) GameController.instance = null;
}
// --- Inspector / 扩展按钮 ---
/** Inspector 扩展 SwitchLevel 按钮Unity TestGame2 同款) */
clickSwitchLevel() {
this.switchToInputLevel();
}
/** Inspector上一关按关卡库顺序 */
clickPrevLevel() {
this.prevLevel();
}
/** Inspector下一关 */
clickNextLevel() {
this.nextLevel();
}
// --- 预览 / Inspector 调试(对齐 Unity TestPlayer---
debugMove(n: number) {
this.sendMessage('Player', 'CallMove', n);
}
debugJump() {
this.sendMessage('Player', 'CallJump');
}
debugRotateLeft(n = 1) {
this.sendMessage('Player', 'CallRotateLeft', n);
}
debugRotateRight(n = 1) {
this.sendMessage('Player', 'CallRotateRight', n);
}
debugPlayerInfo() {
this.sendMessage('Player', 'CallPlayerInfo');
}
debugInputEnd() {
this.callSetIsInputEnd(1);
}
debugVehicleMove(n: number) {
this.sendMessage('Vehicle', 'CallMove', n);
}
debugVehicleInfo() {
this.sendMessage('Vehicle', 'CallVehicleInfo');
}
debugResetLevel() {
this.resetLevel();
}
/** 读取 inputLevel 并切换 */
switchToInputLevel() {
if (!this.resolveMainLevelEntrance()) {
console.warn('[GameController] mainLevelEntrance 未绑定,请等 AppBootstrap 完成');
return;
}
this.applyOptionalParams();
this.confirmSwitchLevel();
}
/** Unity: SendMessage('GameController', 'SwitchLevel', levelId) */
SwitchLevel(levelID: number) {
this.confirmSwitchLevel(levelID);
}
switchLevel(levelID: number) {
this.confirmSwitchLevel(levelID);
}
prevLevel() {
if (!this.resolveMainLevelEntrance()) return;
this.confirmSwitchLevel(prevLevelId(this.curLevelID));
}
nextLevel() {
if (!this.resolveMainLevelEntrance()) return;
this.confirmSwitchLevel(nextLevelId(this.curLevelID));
}
markReady() {
this.ready = true;
this.levelRangeHint = `${getMinLevelId()}${getMaxLevelId()}`;
if (typeof window !== 'undefined') {
(window as unknown as { __tfrhCocosReady?: boolean }).__tfrhCocosReady = true;
}
}
onBootstrapReady() {
this.levelRangeHint = `${getMinLevelId()}${getMaxLevelId()}`;
}
private applyOptionalParams() {
const style = (typeof this.inputStyle === 'string' ? this.inputStyle : String(this.inputStyle ?? '')).trim()
|| (typeof this.uiStyle === 'string' ? this.uiStyle : String(this.uiStyle ?? '')).trim();
if (style) {
this.uiStyle = style;
this.changeUIStyle(style);
}
const player = this.findNodeByName('Player');
player?.getComponent(PlayerController)?.callChangeSkin?.(this.playerSkin);
}
resolveMainLevelEntrance(): boolean {
if (this.mainLevelEntrance?.isValid) return true;
const scene = director.getScene();
if (!scene) return false;
for (const p of ['GameRoot/MainLevelEntrance', 'Canvas/GameRoot/MainLevelEntrance', 'MainLevelEntrance']) {
const n = find(p, scene);
if (n?.isValid) {
this.mainLevelEntrance = n;
return true;
}
}
return false;
}
private syncCurLevelRef() {
if (!this.mainLevelEntrance?.isValid) return;
const isLevelRoot = (n: Node) => /^Level_?\d+$/.test(n.name);
const found = this.mainLevelEntrance.children.find((c) => c?.isValid && isLevelRoot(c));
if (found) {
this.curLevel = found;
return;
}
if (this.curLevel?.isValid && this.curLevel.parent === this.mainLevelEntrance) return;
this.curLevel = null;
}
// --- Web 对接 ---
private registerWebApi() {
const api = {
SendMessage: (objectName: string, methodName: string, param?: string | number) => {
this.sendMessage(objectName, methodName, param);
},
};
if (typeof window !== 'undefined') {
(window as unknown as { cocosIns?: typeof api }).cocosIns = api;
(window as unknown as { unityInstance?: typeof api }).unityInstance = api;
}
}
sendMessage(objectName: string, methodName: string, param?: string | number) {
if (objectName === 'GameController' || objectName === 'GameManager') {
if (methodName === 'SwitchLevel' || methodName === 'switchLevel') {
const id = typeof param === 'number' ? param : parseInt(String(param ?? ''), 10);
if (!Number.isNaN(id)) this.SwitchLevel(id);
return;
}
this.invoke(this, methodName, param);
return;
}
if (objectName === 'UIMain') {
const c = this.findNodeByName('UIMain')?.getComponent(UIMain);
if (c) this.invoke(c, methodName, param);
return;
}
const node = this.findNodeByName(objectName);
if (!node) {
if (objectName === 'Player' && this.creating) return;
console.warn(`SendMessage: 未找到 ${objectName}`);
return;
}
for (const comp of node.getComponents(Component)) {
if (this.resolveMethod(comp, methodName)) {
this.invoke(comp, methodName, param);
return;
}
}
console.warn(`SendMessage: ${objectName} 无方法 ${methodName}`);
}
/** Unity SendMessage 使用 PascalCaseCocos 组件多为 camelCase */
private resolveMethod(target: object, methodName: string): string | null {
const rec = target as Record<string, unknown>;
if (typeof rec[methodName] === 'function') return methodName;
if (methodName.length > 0) {
const camel = methodName[0].toLowerCase() + methodName.slice(1);
if (typeof rec[camel] === 'function') return camel;
}
return null;
}
private invoke(target: object, methodName: string, param?: string | number) {
const resolved = this.resolveMethod(target, methodName);
if (!resolved) return;
const fn = (target as Record<string, unknown>)[resolved];
if (typeof fn !== 'function') return;
if (param === undefined) (fn as () => void).call(target);
else (fn as (p: string | number) => void).call(target, param);
}
callSetIsInputEnd(v: number) {
this.isInputEnd = v !== 0;
if (this.isInputEnd) EventManager.dispatch(EventType.InputEnd, this.gameState);
}
changeUIStyle(style: string) {
this.uiStyle = style;
VisualAssets.setTheme(style);
clearUIIconCache();
void VisualAssets.preload(style);
UIMain.findInstance()?.refreshStyle(style);
}
/** Unity GameManager.ResetLevel */
resetLevel() {
void this.enqueueLoadLevel(this.curLevelID, true);
}
getGameSpeed(): number {
return this.gameSpeed;
}
setGameSpeed(speed: number) {
const s = speed > 0 ? speed : 1;
this.gameSpeed = s;
director.globalGameTimeScale = s;
Movement.setSpeedMultiplier(s);
}
callMute() {
UIMain.findInstance()?.setMuted(true);
}
callUnmute() {
UIMain.findInstance()?.setMuted(false);
}
// --- 游戏逻辑(原 GameManager---
setGameState(s: GameState) { this.gameState = s; }
initData() {
this.setGameState(GameState.Run);
this.stepNum = 0;
this.callSetIsInputEnd(0);
}
/** Unity MultMode关卡 id >= 999000 */
isMultMode(): boolean {
return this.curLevelID >= 999000;
}
jsCallCheck(n: number): boolean {
if (this.isInputEnd || this.gameState !== GameState.Run) return false;
this.stepNum += Math.abs(n);
return true;
}
getRelativePosition(given: Direction, input: Direction): string {
const rel = (((input - given) % 4) + 4) % 4;
return ['front', 'right', 'back', 'left'][rel];
}
cellKey(x: number, y: number) { return `${x},${y}`; }
cellToWorld(cell: Vec3): Vec3 { return isoCellToWorld(cell); }
/** 实体与砖块均对齐格子中心tileAnchor 0.5,0.5 */
worldToCell(world: Vec3): Vec3 { return worldCenterToCell(world); }
nextGridPosition(pos: Vec3, dir: Direction): Vec3 {
const c = this.worldToCell(pos);
switch (dir) {
case Direction.North: c.x += 1; break;
case Direction.South: c.x -= 1; break;
case Direction.East: c.y -= 1; break;
case Direction.West: c.y += 1; break;
}
return cellToWorldCenter(c);
}
calculateGridTypeAtCell(cell: Vec3): GridType {
return this.calculateGridType(cellToWorldCenter(cell));
}
/** 仅瓦片层格型(不含动态 Ride 覆盖),用于下车 / 载具跟随判定 */
calculateTileGridAtCell(cell: Vec3): GridType {
const key = this.cellKey(cell.x, cell.y);
if (this.borderCells.has(key)) return GridType.Block;
const g = this.groundCells.get(key);
if (g === CommonDefine.BlockBase) return GridType.Across;
if (g === CommonDefine.BlockJump) return GridType.Jump;
const b = this.curConfig?.boundary;
if (b && (Math.abs(cell.x) >= b.x || Math.abs(cell.y) >= b.y)) return GridType.Boundary;
return GridType.None;
}
calculateNextTileGridAtCell(cell: Vec3, dir: Direction): GridType {
return this.calculateTileGridAtCell(this.offsetCell(cell, dir));
}
calculateLastTileGridAtCell(cell: Vec3, dir: Direction): GridType {
return this.calculateTileGridAtCell(this.offsetCell(cell, addDirection(dir, 2)));
}
private offsetCell(cell: Vec3, dir: Direction): Vec3 {
const c = cell.clone();
switch (dir) {
case Direction.North: c.x += 1; break;
case Direction.South: c.x -= 1; break;
case Direction.East: c.y -= 1; break;
case Direction.West: c.y += 1; break;
}
return c;
}
calculateNextGridTypeAtCell(cell: Vec3, dir: Direction): GridType {
return this.calculateGridTypeAtCell(this.offsetCell(cell, dir));
}
calculateLastGridTypeAtCell(cell: Vec3, dir: Direction): GridType {
return this.calculateGridTypeAtCell(this.offsetCell(cell, addDirection(dir, 2)));
}
calculateGridType(pos: Vec3): GridType {
const cell = this.worldToCell(pos);
const key = this.cellKey(cell.x, cell.y);
const dyn = this.gridTypes.get(key);
if (dyn) return dyn.type;
if (this.borderCells.has(key)) return GridType.Block;
const g = this.groundCells.get(key);
if (g === CommonDefine.BlockBase) return GridType.Across;
if (g === CommonDefine.BlockJump) return GridType.Jump;
const b = this.curConfig?.boundary;
if (b && (Math.abs(cell.x) >= b.x || Math.abs(cell.y) >= b.y)) return GridType.Boundary;
return GridType.None;
}
calculateNextGridType(pos: Vec3, dir: Direction): GridType {
return this.calculateGridType(this.nextGridPosition(pos, dir));
}
calculateLastGridType(pos: Vec3, dir: Direction): GridType {
return this.calculateGridType(this.nextGridPosition(pos, addDirection(dir, 2)));
}
getGameObject(pos: Vec3): Node | null {
const c = this.worldToCell(pos);
return this.getGameObjectAtCell(c);
}
getGameObjectAtCell(cell: Vec3): Node | null {
return this.gridTypes.get(this.cellKey(cell.x, cell.y))?.node ?? null;
}
addObj(pos: Vec3, type: GridType, node: Node) {
this.addObjAtCell(this.worldToCell(pos), type, node);
}
addObjAtCell(cell: Vec3, type: GridType, node: Node) {
this.gridTypes.set(this.cellKey(cell.x, cell.y), { type, node });
}
removeObj(pos: Vec3) {
this.removeObjAtCell(this.worldToCell(pos));
}
removeObjAtCell(cell: Vec3) {
this.gridTypes.delete(this.cellKey(cell.x, cell.y));
}
removeProp(pos: Vec3) {
this.removePropAtCell(this.worldToCell(pos));
}
removePropAtCell(cell: Vec3) {
this.gridTypesForProps.delete(this.cellKey(cell.x, cell.y));
}
countProp(): number {
if (!this.curLevel) return 0;
let n = 0;
this.forEachPropInLevel(this.curLevel, (c) => {
if (c?.isValid && c.active) n++;
});
return n;
}
/** 关卡内是否已拾取全部可收集物(金币等) */
allPropsCollected(): boolean {
return this.countProp() === 0;
}
private forEachPropInLevel(levelRoot: Node, fn: (node: Node) => void): void {
if (!levelRoot?.isValid) return;
const walk = (node: Node) => {
if (!node?.isValid) return;
if (node !== levelRoot) {
const prop = node.getComponent(PropController);
if (prop) {
fn(node);
return;
}
}
for (const ch of node.children) walk(ch);
};
walk(levelRoot);
}
getCurLevel() { return this.curConfig; }
getEntityVisualOptions(spawnTexture?: string): EntityVisualOptions {
return {
theme: this.curConfig?.theme ?? this.uiStyle,
entityTextures: this.curConfig?.entityTextures,
spawnTexture,
};
}
findNodeByName(name: string): Node | null {
const scene = director.getScene();
if (!scene) return null;
return this.findInTree(scene, name);
}
private findInTree(root: Node, name: string): Node | null {
if (root.name === name) return root;
for (const ch of root.children) {
const f = this.findInTree(ch, name);
if (f) return f;
}
return null;
}
destroyCurLevel() {
if (this.mainLevelEntrance?.isValid) {
const grid = this.findInTree(this.mainLevelEntrance, 'LineGrid');
if (grid?.isValid) {
grid.parent = this.mainLevelEntrance;
grid.setPosition(0, 0, 0);
}
for (const ch of [...this.mainLevelEntrance.children]) {
if (!ch || ch.name === 'LineGrid') continue;
if (ch.isValid) ch.destroy();
}
} else if (this.curLevel?.isValid) {
this.curLevel.destroy();
}
this.curLevel = null;
this.syncCurLevelRef();
this.gridTypes.clear();
this.gridTypesForProps.clear();
this.groundCells.clear();
this.borderCells.clear();
}
confirmSwitchLevel(levelID?: number) {
const raw = levelID !== undefined
? String(levelID)
: (typeof this.inputLevel === 'string' ? this.inputLevel : String(this.inputLevel ?? '')).trim();
if (!raw) {
console.warn('[GameController] 关卡 ID 为空');
return;
}
const id = parseInt(raw, 10);
if (Number.isNaN(id) || id <= 0) {
console.warn('[GameController] 关卡 ID 无效:', raw);
return;
}
if (!hasLevel(id)) {
console.warn(
`[GameController] 关卡 ${id} 不在 Cocos 关卡库 (${getMinLevelId()}${getMaxLevelId()})`
+ `仍按 Level${id}.prefab 加载`,
);
}
this.inputLevel = String(id);
void this.loadLevel(id);
}
loadLevel(levelID: number) {
if (!this.ready && !this.mainLevelEntrance) {
console.warn('[GameController] 尚未初始化');
return;
}
if (!this.mainLevelEntrance) {
console.error('[GameController] mainLevelEntrance 未绑定');
return;
}
void this.enqueueLoadLevel(levelID, false);
}
private enqueueLoadLevel(levelID: number, forceRestart: boolean) {
this.levelLoadSeq = this.levelLoadSeq
.then(() => this.loadLevelNow(levelID, forceRestart))
.catch((e) => console.error('[GameController] 关卡加载失败', e));
return this.levelLoadSeq;
}
private async loadLevelNow(levelID: number, forceRestart: boolean) {
if (levelID === this.curLevelID && this.curLevel?.isValid && this.curConfig) {
await this.restartCurrentLevel();
return;
}
this.destroyCurLevel();
await this.createNewLevel(levelID);
}
/** 同关 SwitchLevel只补刷贴图避免无预加载的软重置导致资源错乱 */
private async refreshLevelPresentation() {
const config = this.curConfig;
const levelRoot = this.curLevel;
if (!config || !levelRoot?.isValid) return;
await reloadThemeDatabase();
await reloadTileDisplayMeta();
const tileNames = LevelDisplay.collectTileNames(config);
await VisualAssets.ensureLevelAssetsReady(config, tileNames);
await LevelDisplay.refreshTiles(levelRoot, config);
await VisualAssets.refreshLevelEntityVisuals(levelRoot, config);
this.reapplyThemeEntityPositions(levelRoot, config);
this.syncPlayerAppearanceToLevel(config);
console.log(`[GameController] 已刷新关卡 ${this.curLevelID} 贴图`);
}
/** 重置玩家/道具,保留关卡砖块与背景,避免闪屏 */
private async restartCurrentLevel() {
if (!this.curLevel?.isValid || !this.curConfig || !this.mainLevelEntrance) {
await this.createNewLevel(this.curLevelID);
return;
}
const config = this.curConfig;
const levelRoot = this.curLevel;
const tileNames = LevelDisplay.collectTileNames(config);
await VisualAssets.ensureLevelAssetsReady(config, tileNames);
await this.purgeDynamicEntities(levelRoot);
const spawned = await this.spawnAllEntities(levelRoot, config);
await this.finalizeEntityPresentation(levelRoot, config);
this.initGridTypes();
this.initData();
EventManager.dispatch(EventType.LevelInit);
this.refreshAllVehicleIcons(levelRoot);
this.externalCallLevelInfo(spawned);
this.syncPlayerAppearanceToLevel(config);
console.log(`[GameController] 已重置关卡 ${this.curLevelID}(无重载)`);
}
/** 主题 entityDisplay 中 Y 偏移/站立补偿变更后,重算实体世界坐标(缩放只改贴图尺寸) */
private reapplyThemeEntityPositions(levelRoot: Node, config: LevelConfig) {
const theme = config.theme || 'silu';
for (const s of config.spawns ?? []) {
if (s.kind === 'player') {
const pc = findLevelChildByName(levelRoot, 'Player')?.getComponent(PlayerController);
pc?.reapplyCellStandPosition();
continue;
}
if (s.kind === 'vehicle') {
const vc = findLevelChildByName(levelRoot, 'Vehicle')?.getComponent(VehicleController);
vc?.reapplyCellStandPosition();
continue;
}
if (s.kind === 'prop') {
const node = findLevelChildByName(levelRoot, `Prop_${s.x}_${s.y}`);
if (!node?.isValid) continue;
const cell = new Vec3(s.x, s.y, 0);
const placement = resolvePropPlacement(s, config);
const pos = cellToWorldCenter(cell);
pos.y += propWorldYOffset(placement, theme);
node.setPosition(pos);
}
}
}
private isDynamicEntity(node: Node): boolean {
const n = node.name;
if (n === 'Player' || /^Player[AB]\d$/.test(n)) return true;
if (n === 'Vehicle' || /^Vehicle[AB]\d$/.test(n)) return true;
if (n === 'PropDecor' || n.startsWith('PropDecor_')) return true;
if (n === 'Prop' || n.startsWith('Prop_')) return true;
return !!(
node.getComponent(PlayerController)
|| node.getComponent(VehicleController)
|| node.getComponent(PropController)
);
}
private collectDynamicEntities(levelRoot: Node): Node[] {
const found: Node[] = [];
const walk = (node: Node) => {
if (!node?.isValid) return;
if (node !== levelRoot && this.isDynamicEntity(node)) {
found.push(node);
return;
}
for (const ch of [...node.children]) walk(ch);
};
walk(levelRoot);
return found;
}
/** destroy() 延迟到帧末;先移出场景并等一帧,避免刷新时旧道具与新道具叠在一起 */
private purgeDynamicEntities(levelRoot: Node): Promise<void> {
const toRemove = this.collectDynamicEntities(levelRoot);
for (const ch of toRemove) {
ch.removeFromParent();
ch.destroy();
}
return new Promise((resolve) => {
this.scheduleOnce(() => resolve(), 0);
});
}
private async spawnAllEntities(levelRoot: Node, config: LevelConfig): Promise<Node[]> {
const spawned: Node[] = [];
for (const s of config.spawns ?? []) {
if (this.skipPlayerAndProps && this.isSkippedSpawn(s)) continue;
const node = await this.spawnEntityAsync(levelRoot, s, config);
if (node) spawned.push(node);
}
await this.repairMissingProps(levelRoot, config);
return spawned;
}
/** 刷新后偶发贴图未挂上:补刷缺失或无形的可拾取物 */
private async repairMissingProps(levelRoot: Node, config: LevelConfig): Promise<void> {
const propSpawns = (config.spawns ?? []).filter((s) => s.kind === 'prop' || s.kind === 'prop_decor');
if (!propSpawns.length) return;
for (const s of propSpawns) {
const nodeName = s.kind === 'prop' ? `Prop_${s.x}_${s.y}` : 'PropDecor';
let node = findLevelChildByName(levelRoot, nodeName);
if (!node?.isValid) {
node = await this.spawnEntityAsync(levelRoot, s, config);
}
if (!node?.isValid) continue;
const spr = node.getComponent(Sprite);
if (!spr?.spriteFrame) {
let propPlacement = s.propPlacement;
if (s.kind === 'prop' && !propPlacement) {
propPlacement = resolvePropPlacement(s, config);
}
await VisualAssets.setupEntityVisualAsync(node, s.kind, undefined, {
theme: config.theme || 'silu',
entityTextures: config.entityTextures,
spawnTexture: s.texture,
propPlacement,
}, s.scale);
}
}
}
private async finalizeEntityPresentation(levelRoot: Node, config: LevelConfig): Promise<void> {
const player = findLevelChildByName(levelRoot, 'Player')?.getComponent(PlayerController);
if (player) tryLinkPlayerVehicle(player);
forEachLevelEntityNode(levelRoot, (ch) => {
const vc = ch.getComponent(VehicleController);
if (vc) tryLinkVehicleRider(vc);
});
await VisualAssets.refreshLevelEntityVisuals(levelRoot, config);
forEachLevelEntityNode(levelRoot, (node) => {
const mov = node.getComponent(Movement);
const spawn = mov?.getSpawnCell?.();
if (spawn) mov.shareCommittedCell(spawn);
});
sortIsoTiles(levelRoot);
}
async createNewLevel(levelID: number) {
if (this.creating) return;
this.creating = true;
try {
await ensureRuntimeAssetsForLevel(levelID);
} catch (e) {
console.error('[GameController] 运行时资源加载失败', e);
this.creating = false;
return;
}
const config = resolveLevelConfig(levelID);
if (!config || !this.mainLevelEntrance) {
this.creating = false;
return;
}
this.curLevelID = levelID;
this.curConfig = config;
this.inputLevel = String(levelID);
await reloadThemeDatabase();
await reloadTileDisplayMeta();
VisualAssets.clearNamedTileCache();
VisualAssets.clearPathFrameCache();
const path = getLevelPrefabResourcePath(levelID, config);
try {
const prefab = await loadLevelPrefabForRuntime(path);
await VisualAssets.preload(this.uiStyle);
// 在 instantiate 前无法拦截;先禁用 prefab 内 GridSnapHelper 的 showGrid
GridSnapHelper.stripBeforePlayFromPrefab(prefab);
const levelRoot = instantiate(prefab);
GridSnapHelper.purgeRuntimeGrids(levelRoot);
levelRoot.parent = this.mainLevelEntrance;
levelRoot.setPosition(0, 0, 0);
this.curLevel = levelRoot;
// 预制体 onEnable 可能已画格,挂载后再清一次
GridSnapHelper.purgeRuntimeGrids(this.mainLevelEntrance!);
const runtimeConfig = mergeLevelConfigWithMapData(config, levelRoot);
this.curConfig = runtimeConfig;
this.applyMapDataFromConfig(runtimeConfig);
await LevelDisplay.prepare(levelRoot, runtimeConfig);
const mapTheme = runtimeConfig.theme || 'silu';
await ThemeBackground.apply(this.mainLevelEntrance, mapTheme);
await VisualAssets.ensureLevelAssetsReady(
runtimeConfig,
LevelDisplay.collectTileNames(runtimeConfig),
);
const spawned = await this.spawnAllEntities(levelRoot, runtimeConfig);
await this.finalizeEntityPresentation(levelRoot, runtimeConfig);
this.syncPlayerAppearanceToLevel(runtimeConfig);
await GameAudio.playBackground(levelRoot);
this.initGridTypes();
this.initData();
EventManager.dispatch(EventType.LevelInit);
this.refreshAllVehicleIcons(levelRoot);
if (this.mainLevelEntrance?.isValid) {
LineGridRenderer.ensure(this.mainLevelEntrance);
}
this.externalCallLevelInfo(spawned);
GridSnapHelper.purgeRuntimeGrids(levelRoot);
this.scheduleOnce(() => {
const scene = this.mainLevelEntrance?.scene;
if (scene?.isValid) GridSnapHelper.purgeScene(scene);
}, 0);
console.log(`[GameController] 已加载关卡 ${levelID} (${path})`);
} catch (e) {
console.error(`[GameController] 预制体加载失败 (${path})`, e);
console.error('[GameController] 可运行: python3 tools/bake_cocos_level_prefabs.py');
} finally {
this.creating = false;
this.syncCurLevelRef();
}
}
private applyMapDataFromConfig(config: LevelConfig) {
this.groundCells.clear();
this.borderCells.clear();
if (config.ground) {
for (const [k, v] of Object.entries(config.ground)) this.groundCells.set(k, v);
}
if (config.border) {
for (const k of Object.keys(config.border)) this.borderCells.add(k);
}
}
private initGridTypes() {
this.gridTypes.clear();
this.gridTypesForProps.clear();
if (!this.curLevel?.isValid) return;
const config = this.curConfig ?? undefined;
const theme = config?.theme ?? this.uiStyle;
const vehicles: Node[] = [];
forEachLevelEntityNode(this.curLevel, (c) => {
if (c.name.includes('Vehicle')) vehicles.push(c);
});
for (const v of vehicles) {
const vc = v.getComponent(VehicleController);
const cell = vc?.getCommittedCell()
?? worldToMoverCellForRole(v.position, config, theme, 'vehicle');
this.addObjAtCell(cell, GridType.Ride, v);
}
for (const s of config?.spawns ?? []) {
if (s.kind !== 'vehicle') continue;
const key = this.cellKey(s.x, s.y);
if (this.gridTypes.has(key)) continue;
const node = vehicles.find((v) => {
const c = v.getComponent(VehicleController)?.getCommittedCell();
return c && c.x === s.x && c.y === s.y;
});
if (node) {
this.addObjAtCell(new Vec3(s.x, s.y, 0), GridType.Ride, node);
}
}
forEachLevelEntityNode(this.curLevel, (p) => {
const prop = p.getComponent(PropController);
if (!prop) return;
const spawn = prop.getSpawnCell();
const c = spawn ?? this.worldToCell(p.position);
this.gridTypesForProps.set(this.cellKey(c.x, c.y), {
type: this.calculateGridTypeAtCell(c),
node: p,
});
});
}
private isSkippedSpawn(s: SpawnConfig): boolean {
return s.kind === 'player' || s.kind === 'prop' || s.kind === 'prop_decor' || s.kind === 'vehicle';
}
/** LevelInit 后再刷一次载具贴图,覆盖 component.start / 异步预载的 spawn 方向 */
private refreshAllVehicleIcons(levelRoot: Node) {
if (!levelRoot?.isValid) return;
const refresh = () => {
forEachLevelEntityNode(levelRoot, (ch) => {
ch.getComponent(VehicleController)?.refreshIcon();
});
};
refresh();
this.scheduleOnce(refresh, 0);
}
/** 地图主题优先于主站 CallChangeSkin / uiStyle */
private syncPlayerAppearanceToLevel(config: LevelConfig) {
const theme = resolveThemeId(config.theme || 'silu');
this.playerSkin = themeToSkin(theme);
const player = this.findNodeByName('Player');
player?.getComponent(PlayerController)?.applyLevelTheme(theme);
const vehicle = this.findNodeByName('Vehicle')
?? (() => {
let found: Node | null = null;
if (this.curLevel?.isValid) {
forEachLevelEntityNode(this.curLevel, (c) => {
if (!found && c.getComponent(VehicleController)) found = c;
});
}
return found;
})();
vehicle?.getComponent(VehicleController)?.refreshIcon();
if (this.curLevel?.isValid) {
this.reapplyThemeEntityPositions(this.curLevel, config);
}
UIMain.findInstance()?.refreshStyle(theme);
}
private resolveDirection(raw?: Direction | string): Direction | undefined {
if (raw === undefined || raw === null) return undefined;
if (typeof raw === 'number') return raw as Direction;
const name = String(raw).replace(/^Direction\./, '');
const key = name as keyof typeof Direction;
if (Object.prototype.hasOwnProperty.call(Direction, key)) {
const v = Direction[key];
if (typeof v === 'number') return v as Direction;
}
return undefined;
}
private async spawnEntityAsync(parent: Node, s: SpawnConfig, config: LevelConfig): Promise<Node | null> {
this.removeExistingSpawn(parent, s);
const built = this.buildSpawnEntity(s, config);
if (!built) return null;
const { node, pos, visual } = built;
ensureEntityUILayer(node);
node.setPosition(pos);
node.parent = parent;
await VisualAssets.setupEntityVisualAsync(
node,
s.kind,
visual.direction,
visual.options,
visual.scaleMul,
);
if (s.kind === 'vehicle') {
node.getComponent(VehicleController)?.refreshIcon();
}
return node;
}
private removeExistingSpawn(levelRoot: Node, s: SpawnConfig): void {
for (const n of this.collectDynamicEntities(levelRoot)) {
if (s.kind === 'player' && (n.name.includes('Player') || n.getComponent(PlayerController))) {
n.removeFromParent();
n.destroy();
} else if (s.kind === 'vehicle' && (n.name.includes('Vehicle') || n.getComponent(VehicleController))) {
n.removeFromParent();
n.destroy();
} else if (s.kind === 'prop' && n.name === `Prop_${s.x}_${s.y}`) {
n.removeFromParent();
n.destroy();
} else if (s.kind === 'prop_decor' && (n.name === 'PropDecor' || n.name.startsWith('PropDecor_'))) {
n.removeFromParent();
n.destroy();
}
}
}
private buildSpawnEntity(s: SpawnConfig, config: LevelConfig): {
node: Node;
pos: Vec3;
visual: {
direction?: Direction;
options: EntityVisualOptions;
scaleMul: number;
};
} | null {
const mapTheme = config.theme || 'silu';
const cell = new Vec3(s.x, s.y, 0);
let propPlacement: PropPlacement | undefined;
let pos: Vec3;
if (s.kind === 'player') {
pos = entityWorldPositionForRole(cell, config, mapTheme, 'player');
} else if (s.kind === 'vehicle') {
pos = entityWorldPositionForRole(cell, config, mapTheme, 'vehicle');
} else {
pos = cellToWorldCenter(cell);
if (s.kind === 'prop') {
propPlacement = resolvePropPlacement(s, config);
pos.y += propWorldYOffset(propPlacement, mapTheme);
}
}
let node: Node;
let direction: Direction | undefined;
if (s.kind === 'player') {
node = new Node('Player');
node.addComponent(PlayerController);
const pc = node.getComponent(PlayerController)!;
direction = this.resolveDirection(s.playerDirection) ?? Direction.South;
pc.direction = direction;
pc.setSpawnCell(cell);
} else if (s.kind === 'vehicle') {
node = new Node('Vehicle');
const vc = node.addComponent(VehicleController);
direction = this.resolveDirection(s.vehicleDirection) ?? Direction.North;
vc.direction = direction;
vc.setSpawnCell(cell);
} else if (s.kind === 'prop') {
node = new Node(`Prop_${s.x}_${s.y}`);
const propCtrl = node.addComponent(PropController);
propCtrl.setSpawnCell(cell);
} else if (s.kind === 'prop_decor') {
node = new Node('PropDecor');
} else {
return null;
}
return {
node,
pos,
visual: {
direction,
options: {
theme: mapTheme,
entityTextures: config.entityTextures,
spawnTexture: s.texture,
propPlacement,
},
scaleMul: s.scale ?? 1,
},
};
}
/**
* 关卡加载完成时通知主站(对齐 Unity GameManager.ExternalCallLevelInfo
* 网页侧实现全局函数 externalLevelInfo(json) 接收 JSON
* { LevelID, PlayerName, VehicleName }
*/
externalCallLevelInfo(objects: Node[]) {
const info: ExternalLevelInfo = {
LevelID: this.curLevelID,
PlayerName: '',
VehicleName: '',
};
for (const obj of objects) {
if (obj.name.includes('Player')) {
info.PlayerName = obj.name;
} else if (obj.name.includes('Vehicle')) {
info.VehicleName = obj.name;
}
}
console.log(`[GameController] externalLevelInfo LevelID=${info.LevelID}`);
JsBridge.notifyExternalLevelInfo(info);
}
}
/** 兼容旧代码 `GameManager.instance` */
export { GameController as GameManager };