1110 lines
41 KiB
TypeScript
1110 lines
41 KiB
TypeScript
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 = '1–80';
|
||
|
||
@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 使用 PascalCase;Cocos 组件多为 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 };
|