同步主站 Cocos 当前工程:关卡资源、运行脚本与打包工具。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-12 14:40:34 +08:00
parent ae75fce80b
commit fac515a669
5609 changed files with 27744634 additions and 5653 deletions

View File

@@ -34,7 +34,7 @@ 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 { ensureEntityUILayer, findLevelChildByName, forEachLevelEntityNode, primeIsoDrawOrder, sortIsoTiles } from './level/TileLayout';
import { Movement } from './gameplay/Movement';
import { tryLinkPlayerVehicle, tryLinkVehicleRider } from './controller/RideLink';
import { GridSnapHelper } from './level/GridSnapHelper';
@@ -73,6 +73,8 @@ export class GameController extends Component {
@property({ group: { name: '关卡切换', id: '1' }, type: CCInteger, displayName: '当前关卡', readonly: true })
curLevelID = 1;
/** 编辑器最近一次要求加载的关;过期回传不再通知,避免慢关握手错切 */
private requestedLevelID = 0;
@property({ group: { name: '关卡切换', id: '1' }, displayName: '关卡范围', readonly: true })
levelRangeHint = '180';
@@ -105,8 +107,22 @@ export class GameController extends Component {
private levelLoadSeq: Promise<void> = Promise.resolve();
/** 倍速 1/2/4作用于移动与角色动画 */
gameSpeed = 1;
//终于完成了********PlayerLTH17/2026/9/12
private creating = false;
/** SwitchLevel 排队未完成:拦截并暂存 Player/Vehicle 指令,避免执行按钮 200ms 抢跑 */
private levelBusy = false;
private pendingLevelOps = 0;
private pendingActorMsgs: Array<{ objectName: string; methodName: string; param?: string | number }> = [];
/** 进关完成后的干净场景树,同关 SwitchLevel 按 Unity销毁当前实例再 instantiate 这份缓存 */
private levelSnapshot: Node | null = null;
private levelSnapshotId = 0;
/** 加载中只保留最后一次 SwitchLevel避免连点排队重建 */
private queuedLevelId: number | null = null;
private instantResetGuard = false;
private lastInstantResetAt = 0;
private lastInstantResetId = 0;
/** 已显示在场上、可以同关原地重置的关卡 IDadopt 之后才写入) */
private playableLevelId = 0;
private curConfig: LevelConfig | null = null;
private gridTypes = new Map<string, GridEntry>();
private gridTypesForProps = new Map<string, GridEntry>();
@@ -137,6 +153,7 @@ export class GameController extends Component {
}
onDestroy() {
this.disposeLevelSnapshot();
if (GameController.instance === this) GameController.instance = null;
}
@@ -284,6 +301,7 @@ export class GameController extends Component {
if (typeof window !== 'undefined') {
(window as unknown as { cocosIns?: typeof api }).cocosIns = api;
(window as unknown as { unityInstance?: typeof api }).unityInstance = api;
this.publishLevelReady(true);
}
}
@@ -302,9 +320,19 @@ export class GameController extends Component {
if (c) this.invoke(c, methodName, param);
return;
}
if (this.shouldDeferActorMessage(objectName)) {
if (methodName === 'CallMove' || methodName === 'callMove') {
console.warn('[tfrh-reset] CallMove 被关卡重置拦住,稍后发出', { creating: this.creating, levelBusy: this.levelBusy });
}
this.pendingActorMsgs.push({ objectName, methodName, param });
return;
}
if (methodName === 'CallMove' || methodName === 'callMove') {
console.warn('[tfrh-reset] CallMove 立刻执行', { param, playableLevelId: this.playableLevelId, busy: this.levelBusy });
}
const node = this.findNodeByName(objectName);
if (!node) {
if (objectName === 'Player' && this.creating) return;
if (objectName === 'Player' && (this.creating || this.levelBusy)) return;
console.warn(`SendMessage: 未找到 ${objectName}`);
return;
}
@@ -577,17 +605,37 @@ export class GameController extends Component {
}
for (const ch of [...this.mainLevelEntrance.children]) {
if (!ch || ch.name === 'LineGrid') continue;
if (ch.isValid) ch.destroy();
if (!ch.isValid) continue;
ch.active = false;
ch.removeFromParent();
ch.destroy();
}
} else if (this.curLevel?.isValid) {
this.curLevel.active = false;
this.curLevel.removeFromParent();
this.curLevel.destroy();
}
this.curLevel = null;
this.playableLevelId = 0;
this.syncCurLevelRef();
this.gridTypes.clear();
this.gridTypesForProps.clear();
this.groundCells.clear();
this.borderCells.clear();
this.disposeLevelSnapshot();
}
/** 新关就绪后再卸旧关,避免执行/取消时闪出另一张地图 */
private adoptLevelRoot(levelRoot: Node) {
this.curLevel = levelRoot;
this.playableLevelId = Number(this.curLevelID);
if (!this.mainLevelEntrance?.isValid) return;
for (const ch of [...this.mainLevelEntrance.children]) {
if (!ch?.isValid || ch === levelRoot || ch.name === 'LineGrid') continue;
ch.active = false;
ch.removeFromParent();
ch.destroy();
}
}
confirmSwitchLevel(levelID?: number) {
@@ -610,34 +658,110 @@ export class GameController extends Component {
);
}
this.inputLevel = String(id);
this.requestedLevelID = id;
this.pendingActorMsgs = [];
this.setLevelBusy(true);
const inPlace = this.tryResetCurrentLevelInPlace(id);
console.warn('[tfrh-reset] SwitchLevel', {
id,
playableLevelId: this.playableLevelId,
curLevelID: this.curLevelID,
inPlace,
creating: this.creating,
pendingLevelOps: this.pendingLevelOps,
hasLevel: !!this.curLevel?.isValid,
});
if (inPlace) {
this.setLevelBusy(false);
return;
}
void this.loadLevel(id);
}
loadLevel(levelID: number) {
if (!this.ready && !this.mainLevelEntrance) {
console.warn('[GameController] 尚未初始化');
if (this.pendingLevelOps <= 0) this.setLevelBusy(false);
return;
}
if (!this.mainLevelEntrance) {
console.error('[GameController] mainLevelEntrance 未绑定');
if (this.pendingLevelOps <= 0) this.setLevelBusy(false);
return;
}
void this.enqueueLoadLevel(levelID, false);
}
private enqueueLoadLevel(levelID: number, forceRestart: boolean) {
this.queuedLevelId = levelID;
if (this.tryResetCurrentLevelInPlace(levelID) && this.pendingLevelOps <= 0 && !this.creating) {
this.queuedLevelId = null;
this.setLevelBusy(false);
return this.levelLoadSeq;
}
this.setLevelBusy(true);
if (this.pendingLevelOps > 0) return this.levelLoadSeq;
this.pendingLevelOps++;
this.levelLoadSeq = this.levelLoadSeq
.then(() => this.loadLevelNow(levelID, forceRestart))
.catch((e) => console.error('[GameController] 关卡加载失败', e));
.then(async () => {
while (this.queuedLevelId != null) {
const id = this.queuedLevelId;
this.queuedLevelId = null;
await this.loadLevelNow(id, forceRestart);
}
})
.catch((e) => console.error('[GameController] 关卡加载失败', e))
.finally(() => {
this.pendingLevelOps = Math.max(0, this.pendingLevelOps - 1);
if (this.queuedLevelId != null && this.pendingLevelOps <= 0) {
void this.enqueueLoadLevel(this.queuedLevelId, forceRestart);
return;
}
if (this.pendingLevelOps <= 0) this.setLevelBusy(false);
});
return this.levelLoadSeq;
}
private async loadLevelNow(levelID: number, forceRestart: boolean) {
if (levelID === this.curLevelID && this.curLevel?.isValid && this.curConfig) {
await this.restartCurrentLevel();
return;
private shouldDeferActorMessage(objectName: string) {
if (!this.levelBusy && !this.creating) return false;
return objectName === 'Player' || objectName === 'Vehicle'
|| /^Player[AB]\d$/.test(objectName) || /^Vehicle[AB]\d$/.test(objectName);
}
private setLevelBusy(busy: boolean) {
this.levelBusy = busy;
this.publishLevelReady(!busy);
if (!busy) this.flushPendingActorMsgs();
}
private publishLevelReady(ready: boolean) {
const apply = (target: { __tfrhLevelReady?: boolean; __tfrhLevelBusy?: boolean } | null) => {
if (!target) return;
target.__tfrhLevelBusy = !ready;
target.__tfrhLevelReady = ready;
};
if (typeof window === 'undefined') return;
apply(window as unknown as { __tfrhLevelReady?: boolean; __tfrhLevelBusy?: boolean });
try {
if (window.parent && window.parent !== window) {
apply(window.parent as unknown as { __tfrhLevelReady?: boolean; __tfrhLevelBusy?: boolean });
}
} catch {
/* 跨域 iframe 无法写父页,编辑器仍靠游戏内排队挡住抢跑 */
}
this.destroyCurLevel();
}
private flushPendingActorMsgs() {
if (!this.pendingActorMsgs.length) return;
const queued = this.pendingActorMsgs;
this.pendingActorMsgs = [];
for (const msg of queued) {
this.sendMessage(msg.objectName, msg.methodName, msg.param);
}
}
private async loadLevelNow(levelID: number, _forceRestart: boolean) {
if (this.tryResetCurrentLevelInPlace(levelID)) return;
await this.createNewLevel(levelID);
}
@@ -657,26 +781,139 @@ export class GameController extends Component {
console.log(`[GameController] 已刷新关卡 ${this.curLevelID} 贴图`);
}
/** 重置玩家/道具,保留关卡砖块与背景,避免闪屏 */
private async restartCurrentLevel() {
if (!this.curLevel?.isValid || !this.curConfig || !this.mainLevelEntrance) {
await this.createNewLevel(this.curLevelID);
/**
* 同关执行:只当场拉回出生点,不拆地图。不同 ID 就是不同地图,绝不以偏移折成同一关。
*/
private tryResetCurrentLevelInPlace(levelID: number): boolean {
if (this.creating) return false;
if (Number(levelID) !== Number(this.playableLevelId)) return false;
if (!this.curLevel?.isValid || !this.curConfig) return false;
this.queuedLevelId = null;
this.resetCurrentLevelInPlace();
return true;
}
private resetCurrentLevelInPlace() {
if (this.instantResetGuard) return;
const id = Number(this.playableLevelId);
const now = Date.now();
if (id === this.lastInstantResetId && now - this.lastInstantResetAt < 50) {
this.pendingActorMsgs = [];
this.initData();
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.instantResetGuard = true;
try {
this.restartCurrentLevelSync();
this.lastInstantResetAt = Date.now();
this.lastInstantResetId = id;
} finally {
this.instantResetGuard = false;
}
}
private captureLevelSnapshot(levelRoot: Node) {
this.disposeLevelSnapshot();
if (!levelRoot?.isValid) return;
const snap = instantiate(levelRoot);
snap.active = false;
snap.removeFromParent();
this.levelSnapshot = snap;
this.levelSnapshotId = Number(this.curLevelID);
}
private disposeLevelSnapshot() {
if (this.levelSnapshot?.isValid) this.levelSnapshot.destroy();
this.levelSnapshot = null;
this.levelSnapshotId = 0;
}
/** 对齐 Unity Addressables 二次 Instantiate旧关卸掉缓存树当场克隆出来。 */
private replayFromSnapshot(): boolean {
const snap = this.levelSnapshot;
if (!snap?.isValid || this.levelSnapshotId !== Number(this.curLevelID) || !this.mainLevelEntrance) {
return false;
}
const fresh = instantiate(snap);
if (!fresh?.isValid) return false;
fresh.active = false;
GridSnapHelper.purgeRuntimeGrids(fresh);
fresh.parent = this.mainLevelEntrance;
fresh.setPosition(0, 0, 0);
this.adoptLevelRoot(fresh);
if (this.curConfig) this.applyMapDataFromConfig(this.curConfig);
this.initGridTypes();
this.initData();
EventManager.dispatch(EventType.LevelInit);
this.refreshAllVehicleIcons(fresh);
this.finishLevelDrawOrder(fresh);
this.revealLevelEntities(fresh);
fresh.active = true;
void GameAudio.playBackground(fresh);
GridSnapHelper.purgeRuntimeGrids(fresh);
console.log(`[GameController] 已按快照重建关卡 ${this.curLevelID}`);
return true;
}
private restartCurrentLevelSync() {
const config = this.curConfig;
const levelRoot = this.curLevel;
if (!config || !levelRoot?.isValid) return;
this.resetExistingMoversToSpawn(levelRoot, config);
this.restoreCollectedProps(levelRoot);
this.initData();
EventManager.dispatch(EventType.LevelInit);
this.applyMapDataFromConfig(config);
this.initGridTypes();
this.refreshAllVehicleIcons(levelRoot);
this.externalCallLevelInfo(spawned);
this.syncPlayerAppearanceToLevel(config);
console.log(`[GameController] 已重置关卡 ${this.curLevelID}(无重载)`);
this.finishLevelDrawOrder(levelRoot);
this.revealLevelEntities(levelRoot);
console.log(`[GameController] 已重置关卡 ${this.curLevelID}`);
}
private restoreCollectedProps(levelRoot: Node) {
const walk = (node: Node) => {
if (!node?.isValid) return;
node.getComponent(PropController)?.restoreForRestart();
for (const ch of node.children) walk(ch);
};
walk(levelRoot);
}
private resetExistingMoversToSpawn(levelRoot: Node, config: LevelConfig) {
for (const s of config.spawns ?? []) {
if (s.kind === 'player') {
const pc = findLevelChildByName(levelRoot, 'Player')?.getComponent(PlayerController);
if (!pc) continue;
pc.direction = this.resolveDirection(s.playerDirection) ?? Direction.South;
pc.setSpawnCell(new Vec3(s.x, s.y, 0));
pc.resetToSpawn();
continue;
}
if (s.kind === 'vehicle') {
const vc = findLevelChildByName(levelRoot, 'Vehicle')?.getComponent(VehicleController);
if (!vc) continue;
vc.direction = this.resolveDirection(s.vehicleDirection) ?? Direction.North;
vc.setSpawnCell(new Vec3(s.x, s.y, 0));
vc.resetToSpawn();
}
}
}
private purgePropEntities(levelRoot: Node): Promise<void> {
const toRemove = this.collectDynamicEntities(levelRoot).filter((n) => {
const name = n.name;
return name === 'Prop' || name.startsWith('Prop_')
|| name === 'PropDecor' || name.startsWith('PropDecor_')
|| !!n.getComponent(PropController);
});
for (const ch of toRemove) {
ch.removeFromParent();
ch.destroy();
}
return new Promise((resolve) => {
this.scheduleOnce(() => resolve(), 0);
});
}
/** 主题 entityDisplay 中 Y 偏移/站立补偿变更后,重算实体世界坐标(缩放只改贴图尺寸) */
@@ -796,15 +1033,33 @@ export class GameController extends Component {
const vc = ch.getComponent(VehicleController);
if (vc) tryLinkVehicleRider(vc);
});
// 先排图层再显示,避免载具带着错误 sibling 露出来
sortIsoTiles(levelRoot, true);
// 延迟一帧再强制全量:等 LevelDisplay.scheduleOnce 对齐完砖,开局金币/遮挡一次算准
this.scheduleOnce(() => {
if (levelRoot?.isValid) sortIsoTiles(levelRoot, true);
}, 0);
}
private revealLevelEntities(levelRoot: Node) {
forEachLevelEntityNode(levelRoot, (node) => {
if (node?.isValid && !node.active) node.active = true;
});
}
private finishLevelDrawOrder(levelRoot: Node | null) {
if (!levelRoot?.isValid) return;
sortIsoTiles(levelRoot, true);
primeIsoDrawOrder(levelRoot, 2);
}
async createNewLevel(levelID: number) {
if (this.creating) return;
console.warn('[tfrh-reset] createNewLevel 开始(整关重建)', {
levelID,
playableLevelId: this.playableLevelId,
curLevelID: this.curLevelID,
creating: this.creating,
});
if (this.creating) {
console.warn('[tfrh-reset] createNewLevel 被 creating 挡住,同关执行会走异步队列');
return;
}
this.creating = true;
try {
await ensureRuntimeAssetsForLevel(levelID);
@@ -834,20 +1089,15 @@ export class GameController extends Component {
// 在 instantiate 前无法拦截;先禁用 prefab 内 GridSnapHelper 的 showGrid
GridSnapHelper.stripBeforePlayFromPrefab(prefab);
const levelRoot = instantiate(prefab);
levelRoot.active = false;
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),
@@ -855,6 +1105,14 @@ export class GameController extends Component {
const spawned = await this.spawnAllEntities(levelRoot, runtimeConfig);
await this.finalizeEntityPresentation(levelRoot, runtimeConfig);
this.adoptLevelRoot(levelRoot);
this.curConfig = runtimeConfig;
this.applyMapDataFromConfig(runtimeConfig);
levelRoot.active = true;
const mapTheme = runtimeConfig.theme || 'silu';
await ThemeBackground.apply(this.mainLevelEntrance, mapTheme);
this.syncPlayerAppearanceToLevel(runtimeConfig);
await GameAudio.playBackground(levelRoot);
@@ -863,6 +1121,8 @@ export class GameController extends Component {
this.initData();
EventManager.dispatch(EventType.LevelInit);
this.refreshAllVehicleIcons(levelRoot);
this.finishLevelDrawOrder(levelRoot);
this.revealLevelEntities(levelRoot);
if (this.mainLevelEntrance?.isValid) {
LineGridRenderer.ensure(this.mainLevelEntrance);
}
@@ -991,6 +1251,7 @@ export class GameController extends Component {
const { node, pos, visual } = built;
ensureEntityUILayer(node);
node.setPosition(pos);
node.active = false;
node.parent = parent;
await VisualAssets.setupEntityVisualAsync(
node,
@@ -1093,6 +1354,12 @@ export class GameController extends Component {
* { LevelID, PlayerName, VehicleName }
*/
externalCallLevelInfo(objects: Node[]) {
if (this.requestedLevelID > 0 && Number(this.curLevelID) !== Number(this.requestedLevelID)) {
console.warn(
`[GameController] 跳过过期 externalLevelInfo cur=${this.curLevelID} want=${this.requestedLevelID}`,
);
return;
}
const info: ExternalLevelInfo = {
LevelID: this.curLevelID,
PlayerName: '',

View File

@@ -5,7 +5,6 @@ const CLIPS = {
background: 'audio/Backgroud',
move: 'audio/Move',
jump: 'audio/Jump',
vehicleMove: 'audio/FlyingCarpetMove',
fail: 'audio/Fail',
success: 'audio/Success',
coins: 'audio/GetCoins',
@@ -21,6 +20,17 @@ export class GameAudio {
private static readonly cache = new Map<string, AudioClip>();
private static readonly loading = new Map<string, Promise<AudioClip | null>>();
private static sfxHost: Node | null = null;
private static muted = false;
static isMuted(): boolean {
return GameAudio.muted;
}
/** HUD / JS callMute静音后背景与全部音效都不再出声 */
static setMuted(mute: boolean) {
GameAudio.muted = mute;
GameAudio.applyVolumeToScene();
}
static async preload(): Promise<void> {
await Promise.all(Object.values(CLIPS).map((p) => GameAudio.loadClip(p)));
@@ -64,37 +74,40 @@ export class GameAudio {
src.clip = clip;
src.loop = true;
src.playOnAwake = false;
src.volume = 1;
src.volume = GameAudio.currentVolume();
if (!src.playing) {
src.play();
}
}
static playSfx(key: SfxKey, host?: Node) {
if (GameAudio.muted) return;
void GameAudio.playSfxAsync(key, host);
}
/** 对齐 Unity同一 AudioSource 播放中则不重复触发移动/跳跃音效 */
static async playSfxOnSource(src: AudioSource, key: SfxKey): Promise<boolean> {
if (!src?.node?.isValid || src.playing) return false;
if (GameAudio.muted || !src?.node?.isValid || src.playing) return false;
const clip = await GameAudio.loadClip(CLIPS[key]);
if (!clip) return false;
if (!clip || GameAudio.muted || !src.node?.isValid) return false;
src.clip = clip;
src.loop = false;
src.volume = 1;
src.volume = GameAudio.currentVolume();
src.play();
return true;
}
private static async playSfxAsync(key: SfxKey, host?: Node) {
if (GameAudio.muted) return;
const clip = await GameAudio.loadClip(CLIPS[key]);
if (!clip) return;
if (!clip || GameAudio.muted) return;
const root = host?.isValid ? host : GameAudio.ensureSfxHost();
if (!root?.isValid) return;
const src = root.getComponent(AudioSource) ?? root.addComponent(AudioSource);
src.playOneShot(clip, 1);
src.volume = GameAudio.currentVolume();
src.playOneShot(clip, GameAudio.currentVolume());
}
private static ensureSfxHost(): Node | null {
@@ -116,8 +129,23 @@ export class GameAudio {
static resumeAll() {
const scene = director.getScene();
if (!scene) return;
const vol = GameAudio.currentVolume();
for (const src of scene.getComponentsInChildren(AudioSource)) {
src.volume = vol;
if (src.clip && !src.playing) src.play();
}
}
private static currentVolume(): number {
return GameAudio.muted ? 0 : 1;
}
private static applyVolumeToScene() {
const scene = director.getScene();
if (!scene) return;
const vol = GameAudio.currentVolume();
for (const src of scene.getComponentsInChildren(AudioSource)) {
src.volume = vol;
}
}
}

View File

@@ -233,20 +233,27 @@ export class PlayerController extends Movement {
private onLevelInit = () => {
this.sendFinally = false;
this.coins = 0;
this.animator?.flushCycleWaiters();
this.resetMoveRuntime();
this.unbindVehicle();
this.refreshVisual(PlayerAction.Idle);
this.syncCommittedCellFromPosition();
this.snapMoverToCellStand();
this.resetToSpawn();
this.checkIfCurIsRide();
tryLinkPlayerVehicle(this);
};
private onInputEnd = () => {
const gm = GameManager.instance!;
this.externalCallResult(gm.allPropsCollected());
void this.settleInputEnd();
};
/** 等当前这一步落地并拾取后再判胜负,避免最后一格刚起步就失败 */
private async settleInputEnd() {
await this.waitUntilIdle();
const gm = GameManager.instance;
if (!gm || gm.gameState !== GameState.Run) return;
this.externalCallResult(gm.allPropsCollected());
}
protected override snapMoverToCellStand() {
const gm = GameManager.instance;
if (!gm || !this.committedCell) return;
@@ -357,14 +364,19 @@ export class PlayerController extends Movement {
if (isJump && this.targetGridType === GridType.Jump && this.vehicle) {
this.unbindVehicle();
}
this.animator?.setAction(isJump ? PlayerAction.Jump : PlayerAction.Move);
const next = isJump ? PlayerAction.Jump : PlayerAction.Move;
const keepCycle = this.animator?.getAction() === next;
this.animator?.setAction(next);
if (keepCycle) this.animator?.beginCycle();
if (isJump && this.targetGridType === GridType.Jump) {
this.targetPosition.y += scaledJumpArcOffset();
}
}
protected override playMoveAnim() {
const keepCycle = this.animator?.getAction() === PlayerAction.Move;
this.animator?.setAction(PlayerAction.Move);
if (keepCycle) this.animator?.beginCycle();
}
protected override syncRideAfterMoveStep() {
@@ -381,9 +393,7 @@ export class PlayerController extends Movement {
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);
void GameAudio.playSfxOnSource(this.sfxSource, 'move');
} else if (action === PlayerAction.Jump) {
void GameAudio.playSfxOnSource(this.sfxSource, 'jump');
}
@@ -514,11 +524,15 @@ export class PlayerController extends Movement {
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);
return;
}
if (gm.gameState === GameState.ResultFail) {
this.animator?.setAction(PlayerAction.Fail);
return;
}
// CallMove(n) 尚未走完:保持当前 Move/Jump避免每格切 Idle 重播第 0 帧
if (this.step > 0) return;
this.animator?.setAction(PlayerAction.Idle);
}
callPlayerInfo() {
@@ -561,12 +575,23 @@ export class PlayerController extends Movement {
externalCall() {
const gm = GameManager.instance!;
const list: ExternalDataList = { direction: this.direction, externalDatas: [] };
const sample = this.getGridSamplePosition();
const self = gm.worldToCell(sample);
this.emitProcessData(gm.worldToCell(sample), this.curGrid, sample);
}
/** 按指定逻辑格回传(提前 processData 时用落点,而不是当前脚底) */
private externalCallAtCell(cell: Vec3) {
const gm = GameManager.instance!;
const sample = cellToWorldCenter(cell);
this.emitProcessData(cell, gm.calculateGridTypeAtCell(cell), sample);
}
private emitProcessData(selfCell: Vec3, selfGrid: GridType, sample: Vec3) {
const gm = GameManager.instance!;
const list: ExternalDataList = { direction: this.direction, externalDatas: [] };
list.externalDatas.push({
position: { x: self.x, y: self.y, z: 0 },
gridType: this.curGrid,
position: { x: selfCell.x, y: selfCell.y, z: 0 },
gridType: selfGrid,
direction: 'self',
});
for (let d = Direction.North; d <= Direction.West; d++) {

View File

@@ -58,6 +58,12 @@ export class PropController extends Component {
this.onCollected(player);
}
/** 同关重开:金币只隐藏不销毁,立刻放回格子,避免异步补刷拖过编辑器 200ms */
restoreForRestart() {
this.collected = false;
if (this.node?.isValid) this.node.active = true;
}
private onCollected(player: PlayerController) {
if (this.collected) return;
this.collected = true;
@@ -66,8 +72,6 @@ export class PropController extends Component {
player.addCoins();
const propCell = this.getLogicCell();
gm.removePropAtCell(propCell);
this.node.removeFromParent();
this.node.destroy();
if (gm.allPropsCollected()) {
player.externalCallResult(true);
}

View File

@@ -28,7 +28,10 @@ export class VehicleController extends Movement {
/** 不调用 super.start(),避免 component.start 晚于 LevelInit 绑定后再次用 spawn 方向刷贴图 */
start() {
this.syncCommittedCellFromPosition();
if (!this.getCommittedCell()) {
this.syncCommittedCellFromPosition();
this.markDrawOrderDirty();
}
if (this.player) {
this.syncFacingFromRider();
}

View File

@@ -1,11 +1,24 @@
import {
_decorator, Camera, Component, EventMouse, EventTouch, Input, input, Vec2, view,
_decorator, Camera, Component, EventMouse, EventTouch, Input, input, sys, Vec2, view,
} from 'cc';
import { CAMERA_ORTHO_HALF, DESIGN_WIDTH } from '../core/GridConstants';
import { getEmbeddedOrthoHalf } from '../core/EmbeddedView';
const { ccclass } = _decorator;
/** 与 UIMain 相同165×165 按钮 + 右侧 20px 边距,按 2560 设计宽缩到 960 */
const HUD_BTN = Math.round(165 * DESIGN_WIDTH / 2560);
const HUD_PAD_RIGHT = Math.round(20 * DESIGN_WIDTH / 2560);
const HUD_RIGHT_SLACK = 8;
function markMapViewDirty(): void {
(globalThis as { __tfrhPreserveMapView?: boolean }).__tfrhPreserveMapView = true;
}
function clearMapViewDirty(): void {
(globalThis as { __tfrhPreserveMapView?: boolean }).__tfrhPreserveMapView = false;
}
/** 对齐 Unity ViewControllerOrthographic 缩放与拖拽 */
@ccclass('ViewController')
export class ViewController extends Component {
@@ -19,6 +32,7 @@ export class ViewController extends Component {
private camera: Camera | null = null;
private dragOrigin = new Vec2();
private readonly uiLoc = new Vec2();
private dragging = false;
onLoad() {
@@ -55,12 +69,14 @@ export class ViewController extends Component {
const cam = this.camera;
if (!cam || cam.orthoHeight <= this.minOrtho) return;
cam.orthoHeight = Math.max(this.minOrtho, cam.orthoHeight - this.zoomSpeed);
markMapViewDirty();
}
zoomOut() {
const cam = this.camera;
if (!cam || cam.orthoHeight >= this.maxOrtho) return;
cam.orthoHeight = Math.min(this.maxOrtho, cam.orthoHeight + this.zoomSpeed);
markMapViewDirty();
}
resetZoom() {
@@ -68,30 +84,40 @@ export class ViewController extends Component {
if (!cam) return;
cam.orthoHeight = getEmbeddedOrthoHalf();
cam.node.setPosition(0, 0, cam.node.position.z);
clearMapViewDirty();
}
private usingMouseInput(): boolean {
return !sys.isMobile && sys.hasFeature(sys.Feature.EVENT_MOUSE);
}
private onTouchStart(e: EventTouch) {
if (this.isPointerOnUI(e.getLocation())) return;
// 桌面端鼠标会同时模拟 touch只走 MOUSE_* 以免双倍位移、并过滤非左键
if (this.usingMouseInput()) return;
e.getUILocation(this.uiLoc);
if (this.isPointerOnUI(this.uiLoc)) return;
this.dragging = true;
e.getLocation(this.dragOrigin);
this.dragOrigin.set(this.uiLoc);
}
private onTouchEnd() {
if (this.usingMouseInput()) return;
this.dragging = false;
}
private onTouchMove(e: EventTouch) {
if (!this.dragging) return;
const cur = new Vec2();
e.getLocation(cur);
this.applyDrag(cur);
e.getLocation(this.dragOrigin);
if (this.usingMouseInput() || !this.dragging) return;
e.getUILocation(this.uiLoc);
this.applyDrag(this.uiLoc);
this.dragOrigin.set(this.uiLoc);
}
private onMouseDown(e: EventMouse) {
if (this.isPointerOnUI(new Vec2(e.getLocationX(), e.getLocationY()))) return;
if (e.getButton() !== EventMouse.BUTTON_LEFT) return;
e.getUILocation(this.uiLoc);
if (this.isPointerOnUI(this.uiLoc)) return;
this.dragging = true;
this.dragOrigin.set(e.getLocationX(), e.getLocationY());
this.dragOrigin.set(this.uiLoc);
}
private onMouseUp() {
@@ -100,37 +126,38 @@ export class ViewController extends Component {
private onMouseMove(e: EventMouse) {
if (!this.dragging) return;
this.applyDrag(new Vec2(e.getLocationX(), e.getLocationY()));
this.dragOrigin.set(e.getLocationX(), e.getLocationY());
e.getUILocation(this.uiLoc);
this.applyDrag(this.uiLoc);
this.dragOrigin.set(this.uiLoc);
}
private applyDrag(cur: Vec2) {
if (!this.camera) return;
const delta = cur.subtract(this.dragOrigin);
const dx = cur.x - this.dragOrigin.x;
const dy = cur.y - this.dragOrigin.y;
const ortho = this.camera.orthoHeight;
const { width, height } = view.getVisibleSize();
if (width <= 0 || height <= 0) return;
const worldPerPixelX = (2 * ortho * (width / height)) / width;
const worldPerPixelY = (2 * ortho) / height;
const pos = this.camera.node.position;
let nx = pos.x - delta.x * worldPerPixelX;
let ny = pos.y - delta.y * worldPerPixelY;
let nx = pos.x - dx * worldPerPixelX;
let ny = pos.y - dy * worldPerPixelY;
const limit = ortho - 20;
if (Math.abs(nx) > limit) nx = pos.x;
if (Math.abs(ny) > limit) ny = pos.y;
this.camera.node.setPosition(nx, ny, pos.z);
markMapViewDirty();
}
/** 右侧 UIMain 区域不拖拽镜头(与 UIMain 边距一致) */
/** 仅拦截右侧 HUD 按钮列;坐标为 UI 空间(原点左下,与 visibleSize 一致) */
private isPointerOnUI(loc: Vec2): boolean {
const vis = view.getVisibleSize();
const margin = Math.max(96, (DESIGN_WIDTH * 0.5) * 0.14);
const right = vis.width > DESIGN_WIDTH
? DESIGN_WIDTH * 0.5
: vis.width * 0.5;
return loc.x >= right - margin;
const right = vis.width > 0 ? vis.width : DESIGN_WIDTH;
return loc.x >= right - HUD_BTN - HUD_PAD_RIGHT - HUD_RIGHT_SLACK;
}
}

View File

@@ -28,12 +28,17 @@ export function syncEmbeddedCamerasOrtho(): void {
const halfH = getEmbeddedOrthoHalf();
const scene = director.getScene();
if (!scene) return;
for (const camName of ['UICamera', 'BgCamera', 'Main Camera']) {
for (const camName of ['UICamera', 'BgCamera']) {
const cam = find(camName, scene)?.getComponent(Camera);
if (cam) cam.orthoHeight = halfH;
}
const preserve = !!(globalThis as { __tfrhPreserveMapView?: boolean }).__tfrhPreserveMapView;
const mainNode = find('Main Camera', scene);
if (mainNode) mainNode.setPosition(0, 0, mainNode.position.z);
const mainCam = mainNode?.getComponent(Camera);
if (!preserve) {
if (mainCam) mainCam.orthoHeight = halfH;
if (mainNode) mainNode.setPosition(0, 0, mainNode.position.z);
}
(globalThis as { __tfrhSyncHudOrtho?: () => void }).__tfrhSyncHudOrtho = syncEmbeddedCamerasOrtho;
}

View File

@@ -3,7 +3,7 @@ import {
Direction, GameState, GridType, MoveState, MoverRole, addDirection,
} from '../core/Define';
import { GameManager } from '../manager/GameManager';
import { markIsoDrawOrderDirty, resolveLevelDrawRoot } from '../level/TileLayout';
import { markIsoDrawOrderDirty, resolveLevelDrawRoot, sortIsoTiles } from '../level/TileLayout';
import { scaledMoveSpeed, UNITY_VEHICLE_MOVE_SPEED } from '../core/GridConstants';
import { cellToWorldCenter } from '../core/GridCoords';
import {
@@ -57,6 +57,11 @@ export class Movement extends Component {
protected landingCell: Vec3 | null = null;
private moveStepFrom = new Vec3();
private queue: Promise<void> = Promise.resolve();
private moveWaitWakeups: Array<() => void> = [];
/** resetToSpawn / 重开关时作废进行中的 moveCoroutine避免走两步被拽回出生点再走 */
private resetEpoch = 0;
/** 本步实际位移速度(可按动画一轮时长拉长,避免下一格掐断) */
private stepMoveSpeed = 0;
static callEach = false;
protected getMoverLocalPosition(): Vec3 {
@@ -97,6 +102,7 @@ export class Movement extends Component {
public shareCommittedCell(cell: Vec3) {
if (!this.committedCell) this.committedCell = new Vec3();
this.committedCell.set(cell);
this.markDrawOrderDirty();
}
getCommittedCell(): Vec3 | null {
@@ -166,7 +172,10 @@ export class Movement extends Component {
start() {
this.setDirection(this.direction);
this.syncCommittedCellFromPosition();
if (!this.committedCell) {
this.syncCommittedCellFromPosition();
this.markDrawOrderDirty();
}
}
protected commitLandingCell() {
@@ -199,15 +208,41 @@ export class Movement extends Component {
/** 主题 entityDisplay 变更后重算站立 Y缩放刷新不会自动更新节点坐标 */
reapplyCellStandPosition() {
if (this.moveState === MoveState.Moving) return;
this.snapMoverToCellStand();
this.markDrawOrderDirty();
}
resetMoveRuntime() {
this.resetEpoch++;
this.moveState = MoveState.Idle;
this.moveWait = false;
this.step = 0;
this.stepMoveSpeed = 0;
this.queue = Promise.resolve();
this.wakeMoveWaiters();
}
resetToSpawn() {
const moved = !!(this.spawnCell && this.committedCell
&& (this.committedCell.x !== this.spawnCell.x || this.committedCell.y !== this.spawnCell.y));
const walking = this.moveState !== MoveState.Idle || this.step > 0;
if (moved || walking) {
console.warn(
'[tfrh-reset] 把角色拉回出生点',
this.node.name,
{ from: this.committedCell, spawn: this.spawnCell, walking, step: this.step },
new Error().stack,
);
}
this.resetMoveRuntime();
this.landingCell = null;
if (this.spawnCell) {
if (!this.committedCell) this.committedCell = new Vec3();
this.committedCell.set(this.spawnCell);
}
this.snapMoverToCellStand();
this.markDrawOrderDirty();
}
update(dt: number) {
@@ -215,24 +250,33 @@ export class Movement extends Component {
const pos = this.node.position;
const next = new Vec3();
const speedMul = GameManager.instance?.getGameSpeed() ?? Movement.speedMultiplier;
Vec3.moveTowards(next, pos, this.targetPosition, this.moveSpeed * dt * speedMul);
const speed = this.stepMoveSpeed > 0 ? this.stepMoveSpeed : this.moveSpeed;
Vec3.moveTowards(next, pos, this.targetPosition, speed * dt * speedMul);
this.node.setPosition(next);
this.onMoving();
this.syncRideAfterMoveStep();
this.markDrawOrderDirty();
if (Vec3.distance(next, this.targetPosition) < 0.005) {
this.node.setPosition(this.targetPosition);
this.moveState = MoveState.Idle;
this.moveWait = false;
this.onMoveToTarget();
this.markDrawOrderDirty();
this.applyDrawOrderAfterMove();
this.wakeMoveWaiters();
}
}
private markDrawOrderDirty() {
protected markDrawOrderDirty() {
const root = resolveLevelDrawRoot(this.node);
if (root) markIsoDrawOrderDirty(root);
}
/** 落地后按新格写 sibling */
protected applyDrawOrderAfterMove() {
const root = resolveLevelDrawRoot(this.node);
if (root) sortIsoTiles(root, true);
}
protected syncRideAfterMoveStep() {}
setDirection(dir: Direction) {
@@ -246,8 +290,28 @@ export class Movement extends Component {
/** 转向结束Unity RotateCoroutine 只改朝向,不重新 snap 落点) */
protected onRotateComplete() {}
protected onMoveNextSet(_isJump: boolean) {}
/** 本 CallMove 最后一格已起步landingCell 已确定),可提前 processData */
protected onLastMoveStepReady() {}
protected onMoveFail(_isJump: boolean) {}
protected playMoveAnim() {}
/** 本步位移至少持续该未缩放秒数(用于对齐走路/跳跃一轮) */
protected getMoveStepMinDuration(): number {
return 0;
}
/** 落地后若动画一轮未完,等播完再走下一格 */
protected waitMoveVisualReady(): Promise<void> {
return Promise.resolve();
}
private resolveStepMoveSpeed() {
const minDur = this.getMoveStepMinDuration();
const dist = Vec3.distance(this.moveStepFrom, this.targetPosition);
if (minDur <= 0 || dist < 1e-4) {
this.stepMoveSpeed = this.moveSpeed;
return;
}
this.stepMoveSpeed = Math.min(this.moveSpeed, dist / minDur);
}
/** 本步移动起点(子类可去掉骑乘视觉抬高等) */
protected resolveMoveStepOrigin(_targetCell: Vec3, _landingGrid: GridType): Vec3 {
@@ -303,22 +367,46 @@ export class Movement extends Component {
this.queue = this.queue.then(fn).catch((e) => console.error(e));
}
protected waitUntilIdle(): Promise<void> {
return this.waitUntil(() => this.moveState !== MoveState.Moving && !this.moveWait);
}
private waitUntil(cond: () => boolean): Promise<void> {
if (cond()) return Promise.resolve();
return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled || !cond()) return;
settled = true;
resolve();
};
this.moveWaitWakeups.push(finish);
const tick = () => {
if (cond()) resolve();
if (settled) return;
if (cond()) finish();
else requestAnimationFrame(tick);
};
tick();
});
}
/** 落地当帧唤醒队列,避免再等一帧 rAF */
protected wakeMoveWaiters() {
if (this.moveWaitWakeups.length === 0) return;
const pending = this.moveWaitWakeups;
this.moveWaitWakeups = [];
for (const fn of pending) fn();
}
private async moveCoroutine(n: number, isJump: boolean) {
await this.waitUntil(() => !this.moveWait);
const epoch = this.resetEpoch;
await this.waitUntil(() => !this.moveWait || epoch !== this.resetEpoch);
if (epoch !== this.resetEpoch) return;
const toFront = n > 0;
this.step = Math.abs(n);
const gm = GameManager.instance!;
while (this.step > 0 && gm.gameState === GameState.Run) {
if (epoch !== this.resetEpoch) return;
this.step--;
const r = this.moveNextCheck(isJump, toFront);
if (r === -1) {
@@ -330,9 +418,12 @@ export class Movement extends Component {
this.lastPosition.set(this.getGridSamplePosition());
this.moveState = MoveState.Moving;
this.onMoveNextSet(isJump);
// 起步 dirty角色朝前会切落点载具仍按起点人车可能同帧都要重排
this.markDrawOrderDirty();
await this.waitUntil(() => !this.moveWait);
this.resolveStepMoveSpeed();
if (this.step === 0) this.onLastMoveStepReady();
await this.waitUntil(() => !this.moveWait || epoch !== this.resetEpoch);
if (epoch !== this.resetEpoch) return;
await this.waitMoveVisualReady();
if (epoch !== this.resetEpoch) return;
} else {
this.playMoveAnim();
this.moveState = MoveState.Moving;
@@ -340,13 +431,16 @@ export class Movement extends Component {
if (this.step === 0) {
this.moveState = MoveState.Idle;
this.onMoveToTarget();
this.applyDrawOrderAfterMove();
}
}
}
}
private async rotateCoroutine(n: number) {
await this.waitUntil(() => !this.moveWait);
const epoch = this.resetEpoch;
await this.waitUntil(() => !this.moveWait || epoch !== this.resetEpoch);
if (epoch !== this.resetEpoch) return;
this.moveWait = true;
this.setDirection(addDirection(this.direction, n));
this.moveWait = false;

View File

@@ -1,33 +1,12 @@
import { Node } from 'cc';
import { LevelConfig, SpawnConfig } from './LevelTypes';
import { LevelConfig } from './LevelTypes';
import { LevelMapData } from './LevelMapData';
import { themeForLevelId } from './LevelIds';
function hasKeys(rec?: Record<string, unknown>): boolean {
return !!rec && Object.keys(rec).length > 0;
}
/** 地图是否覆盖玩家/道具/载具 spawn 格(旧预制体常残留错误横向砖块) */
function groundCoversSpawns(ground: Record<string, unknown> | undefined, spawns: SpawnConfig[]): boolean {
if (!ground || !spawns.length) return false;
for (const s of spawns) {
if (s.kind !== 'player' && s.kind !== 'prop' && s.kind !== 'vehicle') continue;
if (!ground[`${s.x},${s.y}`]) return false;
}
return true;
}
/** Cocos 预制体 LevelMapData 优先DB 仅作 spawns 载体时的回退 */
function pickGround(
fromPrefab: LevelConfig['ground'],
fromDb: LevelConfig['ground'],
spawns: SpawnConfig[],
): LevelConfig['ground'] {
if (groundCoversSpawns(fromPrefab, spawns)) return fromPrefab;
if (groundCoversSpawns(fromDb, spawns)) return fromDb;
if (hasKeys(fromPrefab)) return fromPrefab;
return fromDb;
}
function parseJsonRecord(json: string): Record<string, unknown> | undefined {
try {
const o = JSON.parse(json || '{}') as unknown;
@@ -41,7 +20,8 @@ function parseJsonRecord(json: string): Record<string, unknown> | undefined {
}
/**
* Cocos 预制体 LevelMapData 为地图/主题权威levels-database 主要提供 spawns
* 预制体 LevelMapData(编辑器里看到的地图)为地图/主题权威
* levels-database 只提供 spawns / boundary仅当预制体没有地图数据时才回退到库。
*/
export function mergeLevelConfigWithMapData(config: LevelConfig, levelRoot: Node): LevelConfig {
if (!levelRoot?.isValid) return config;
@@ -51,20 +31,23 @@ export function mergeLevelConfigWithMapData(config: LevelConfig, levelRoot: Node
const prefabGround = parseJsonRecord(md.groundJson) as LevelConfig['ground'];
const prefabBorder = parseJsonRecord(md.borderJson) as LevelConfig['border'];
const prefabTheme = md.theme?.trim();
const seriesTheme = themeForLevelId(config.levelID);
const prefabHasMap =
hasKeys(prefabGround as Record<string, unknown>)
|| hasKeys(prefabBorder as Record<string, unknown>);
const border = hasKeys(prefabBorder as Record<string, unknown>)
? prefabBorder
: config.border;
if (!prefabHasMap) {
return {
...config,
theme: seriesTheme || config.theme?.trim() || prefabTheme || 'silu',
};
}
return {
...config,
// DB 主题优先(主站批次权威);预制体仅作缺省回退
theme: config.theme?.trim() || prefabTheme || 'silu',
ground: pickGround(
prefabGround as LevelConfig['ground'],
config.ground,
config.spawns ?? [],
),
border,
theme: seriesTheme || prefabTheme || config.theme?.trim() || 'silu',
ground: hasKeys(prefabGround as Record<string, unknown>) ? prefabGround : config.ground,
// 预制体已写过地图:空边框就是没有边框,不要用库里的旧 border 补上
border: prefabBorder ?? {},
};
}

View File

@@ -112,10 +112,19 @@ function shardKey(shard: { file: string }) {
return shard.file;
}
function practiceLevelCount(ids: number[]): number {
let n = 0;
for (const id of ids) {
if (id >= LEVEL_ID_BASE) n += 1;
}
return n;
}
function validateIndex(index: LevelsDbIndex): void {
if (index.total < 1 || index.min < LEVEL_ID_BASE) {
// 允许备用 1400 + 练习 91601+;不能只看 min备用关会把 min 拉到 1
if (index.total < 100 || index.max < LEVEL_ID_BASE) {
throw new Error(
`关卡库索引无效 (${index.total} 关),请重新 package-for-project`,
`关卡库索引过旧 (${index.total} 关),请重新 package-for-project`,
);
}
}
@@ -132,10 +141,10 @@ function validateIngested(): void {
return;
}
const total = sortedIds.length;
const minId = sortedIds[0] ?? 0;
if (total < 1 || minId < LEVEL_ID_BASE) {
const practice = practiceLevelCount(sortedIds);
if (total < 100 || practice < 100) {
throw new Error(
`关卡库无效 (${total} 关),请用主站导出的 levels-database.json`
`关卡库过旧 (${total} 关),请用主站导出的 levels-database.json`
+ '运行 bash tools/sync-level-db.sh 后重新 package-for-project',
);
}
@@ -175,22 +184,38 @@ function indexFetchCandidates(): string[] {
return [...new Set(out)];
}
function looksLikeJsonText(text: string): boolean {
const t = text.replace(/^\uFEFF/, '').trimStart();
return t.startsWith('{') || t.startsWith('[');
}
async function decodePossiblyBrotliJson(ab: ArrayBuffer, expectBrotli: boolean): Promise<unknown> {
const raw = new TextDecoder().decode(ab);
if (!expectBrotli || looksLikeJsonText(raw)) {
return JSON.parse(raw);
}
if (typeof DecompressionStream === 'undefined') {
throw new Error('浏览器不支持 Brotli 解压');
}
const text = await new Response(ab)
.pipeThrough(new DecompressionStream('brotli'))
.text();
return JSON.parse(text);
}
async function fetchJsonWithBrotli(url: string): Promise<unknown> {
const brUrl = url.replace(/\.json(\?.*)?$/i, '.json.br$1');
try {
const brRes = await fetch(brUrl);
if (brRes.ok && typeof DecompressionStream !== 'undefined') {
const text = await new Response(await brRes.arrayBuffer())
.pipeThrough(new DecompressionStream('brotli'))
.text();
return JSON.parse(text);
const brRes = await fetch(brUrl, { credentials: 'same-origin' });
if (brRes.ok) {
return decodePossiblyBrotliJson(await brRes.arrayBuffer(), true);
}
} catch {
/* fallback */
}
const res = await fetch(url, { credentials: 'same-origin' });
if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`);
return res.json();
return decodePossiblyBrotliJson(await res.arrayBuffer(), false);
}
async function loadIndexFromNetwork(): Promise<void> {

View File

@@ -82,7 +82,7 @@ export class LevelDisplay {
const walk = (node: Node | null | undefined) => {
if (!node?.isValid) return;
node.layer = UI_LAYER;
node.active = true;
if (node !== root) node.active = true;
let ui = node.getComponent(UITransform);
if (!ui) ui = node.addComponent(UITransform);
if (node === root || node.name === 'Ground' || node.name === 'Border') {

View File

@@ -5,3 +5,14 @@ export const LEVEL_ID_BASE = 91601;
export function isGameLevelId(levelID: number): boolean {
return Number.isFinite(levelID) && levelID >= LEVEL_ID_BASE;
}
/**
* 主站练习题批次主题(只覆盖 9160194000
* LA/LB 9160192200 siluLC/LD 9220192800 chineseLE/LF 9280194000 numMan
*/
export function themeForLevelId(levelID: number): string | undefined {
if (levelID >= 91601 && levelID <= 92200) return 'silu';
if (levelID >= 92201 && levelID <= 92800) return 'chinese';
if (levelID >= 92801 && levelID <= 94000) return 'numMan';
return undefined;
}

View File

@@ -2,9 +2,9 @@
* 关卡注册表 — 统一从 LevelDatabase单一 JSON读取
*/
import { LevelConfig } from './LevelTypes';
import { LEVEL_ID_BASE } from './LevelIds';
import { LEVEL_ID_BASE, themeForLevelId } from './LevelIds';
export { LEVEL_ID_BASE, isGameLevelId } from './LevelIds';
export { LEVEL_ID_BASE, isGameLevelId, themeForLevelId } from './LevelIds';
import {
getLevelConfig as dbGet,
hasLevel as dbHas,
@@ -34,16 +34,21 @@ export function getMaxLevelId(): number {
/**
* 主站 SendMessage(levelID) → 查 Cocos 关卡库;无条目时按 Level{id}.prefab 加载。
*/
function applySeriesTheme(cfg: LevelConfig): LevelConfig {
const theme = themeForLevelId(cfg.levelID);
return theme ? { ...cfg, theme } : cfg;
}
export function resolveLevelConfig(levelID: number): LevelConfig | null {
const cfg = dbGet(levelID);
if (cfg) return cfg;
if (cfg) return applySeriesTheme(cfg);
if (levelID <= 0) return null;
return {
levelID,
boundary: { x: 20, y: 20 },
spawns: [],
cocosPrefab: `level-prefabs/Level${levelID}`,
theme: 'sanxing',
theme: themeForLevelId(levelID) ?? 'sanxing',
};
}
@@ -58,7 +63,8 @@ export function refreshLevelIdBounds() {
}
export function getLevelConfig(levelID: number): LevelConfig | null {
return dbGet(levelID);
const cfg = dbGet(levelID);
return cfg ? applySeriesTheme(cfg) : null;
}
export function hasLevel(levelID: number): boolean {

View File

@@ -6,7 +6,7 @@ import { getTilePivot } from '../visual/TilePivots';
import { getTileDrawSize, resolveTilePixelSize } from '../visual/TileSizes';
import { LevelConfig } from './LevelTypes';
import { getLevelRuntimeContext } from './LevelRuntimeContext';
import { worldToMoverCellForRole, entityWorldPositionForRole } from './EntitySpawnPlacement';
import { entityWorldPositionForRole } from './EntitySpawnPlacement';
import { Movement } from '../gameplay/Movement';
import { DEFAULT_PLAYER_ANCHOR_Y, DEFAULT_VEHICLE_ANCHOR_Y, getEntityDisplaySizes } from '../visual/EntityDisplayRefs';
import { cellHasTile } from './EntitySpawnPlacement';
@@ -15,6 +15,7 @@ import {
UNITY_SORTING_ORDER,
tileDrawFrontKey,
entityDrawFrontKeyWithWalls,
isShortFloorTile,
type UnitySortableEntity,
type UnitySortableTile,
} from './UnityDrawSort';
@@ -181,52 +182,235 @@ function toUnitySortableEntity(entity: EntityDrawEntry): UnitySortableEntity {
/** 等距深度:返回值 > 0 表示 a 应排在 b 之后(更靠前) */
export { compareIsoDrawOrder };
function buildSortedDrawOrder(tiles: TileDrawEntry[], entities: EntityDrawEntry[]): {
function isVehicleDrawNode(node: Node): boolean {
return isVehicleEntityName(safeNodeName(node)) || !!node.getComponent('VehicleController');
}
/** 空载具:自身没在走,也没有骑手在走 */
function isParkedVehicleNode(node: Node): boolean {
if (!isVehicleDrawNode(node)) return false;
const self = node.getComponent(Movement);
if (self?.isMoving()) return false;
const rider = (node.getComponent('VehicleController') as {
getPlayer?: () => Movement | null;
} | null)?.getPlayer?.() ?? null;
return !rider?.isMoving();
}
function actorDrawKeyAtCell(
entity: EntityDrawEntry,
cell: { x: number; y: number },
wallTiles: UnitySortableTile[],
): { key: number; isPlayer: boolean } {
const node = entity.node;
const ent = {
sortingOrder: unityEntitySortingOrder(node),
cellX: cell.x,
cellY: cell.y,
centerY: node.position.y,
depthY: getEntitySortBottomY(node),
isPlayer: isPlayerEntityName(safeNodeName(node)),
isPickable: entity.kind === 'pickable',
};
return { key: entityDrawFrontKeyWithWalls(ent, wallTiles), isPlayer: ent.isPlayer };
}
function resolveEntityDrawKey(
entity: EntityDrawEntry,
wallTiles: UnitySortableTile[],
_force: boolean,
): { key: number; isPlayer: boolean } {
if (entity.kind !== 'actor') {
const ent = toUnitySortableEntity(entity);
return { key: entityDrawFrontKeyWithWalls(ent, wallTiles), isPlayer: ent.isPlayer };
}
const mov = resolveDrawSampleMovement(entity.node);
const from = mov?.getCommittedCell();
const to = mov?.isMoving() ? mov.getLandingCell() : null;
if (mov?.isMoving() && from && to) {
const a = actorDrawKeyAtCell(entity, { x: from.x, y: from.y }, wallTiles);
// 远离镜头:整步钉在当前格,落地后再改,否则红墙会切屁股
if (to.x + to.y > from.x + from.y) {
return a;
}
const b = actorDrawKeyAtCell(entity, { x: to.x, y: to.y }, wallTiles);
const t = mov.getMoveStepProgress();
return { key: a.key + (b.key - a.key) * t, isPlayer: a.isPlayer };
}
const cell = from
? { x: from.x, y: from.y }
: getActorWallSampleCell(entity, entity.node);
return actorDrawKeyAtCell(entity, cell, wallTiles);
}
type DrawKeyed = {
entry: DrawEntry;
key: number;
name: string;
isPlayer: boolean;
isShortFloor: boolean;
};
function tileSetSignature(tiles: TileDrawEntry[]): string {
const ids = tiles.map((t) => t.node.uuid);
ids.sort();
return ids.join(',');
}
function sortTilesFrozen(tiles: TileDrawEntry[]): DrawKeyed[] {
const keyed: DrawKeyed[] = tiles.map((t) => ({
entry: t,
key: tileDrawFrontKey(toUnitySortableTile(t)),
name: t.node.name,
isPlayer: false,
isShortFloor: isShortFloorTile(t.tileName),
}));
keyed.sort((a, b) => {
if (a.key !== b.key) return a.key - b.key;
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
});
return keyed;
}
function bindFrozenTiles(frozen: DrawKeyed[], tiles: TileDrawEntry[]): DrawKeyed[] | null {
if (frozen.length !== tiles.length) return null;
const byUuid = new Map<string, TileDrawEntry>();
for (const t of tiles) byUuid.set(t.node.uuid, t);
const out: DrawKeyed[] = [];
for (const f of frozen) {
const t = byUuid.get(f.entry.node.uuid);
if (!t?.node.isValid) return null;
out.push({
entry: t,
key: f.key,
name: f.name,
isPlayer: false,
isShortFloor: f.isShortFloor,
});
}
return out;
}
/** 角色对矮砖永远在后画;载具/其它实体仍按 key。砖与砖不在这里比较。 */
function entityBeforeTile(entity: DrawKeyed, tile: DrawKeyed): boolean {
if (entity.isPlayer && tile.isShortFloor) return false;
if (entity.key !== tile.key) return entity.key < tile.key;
if (entity.isPlayer) return false;
return entity.name < tile.name;
}
function stripStalePlayerOcclusion(entities: EntityDrawEntry[]) {
for (const e of entities) {
const stale = e.node.getChildByName('__WallOcclusion');
if (stale?.isValid) stale.destroy();
}
}
function isKeyedWallTile(item: DrawKeyed): boolean {
const tileName = (item.entry as TileDrawEntry).tileName;
return !!tileName && isWallTileName(tileName);
}
/**
* 合并时角色一旦先插入,后面冻结列表里的矮砖不会再与角色比较,会盖住脚。
* 只把角色挪到后面漏掉的矮砖之上;遇到 frontKey 更大的墙就停,不越过墙。
*/
function placePlayerAfterTrailingShortFloors(merged: DrawKeyed[]) {
const pi = merged.findIndex((k) => k.isPlayer);
if (pi < 0) return;
const playerKey = merged[pi].key;
let insertAfter = pi;
for (let i = pi + 1; i < merged.length; i++) {
const item = merged[i];
if (isKeyedWallTile(item) && item.key > playerKey) break;
if (item.isShortFloor) insertAfter = i;
}
if (insertAfter === pi) return;
const player = merged.splice(pi, 1)[0];
merged.splice(insertAfter, 0, player);
}
function isTileKeyed(item: DrawKeyed): boolean {
return (item.entry as TileDrawEntry).tileName != null;
}
function compareEntityKeyed(a: DrawKeyed, b: DrawKeyed): number {
if (a.key !== b.key) return a.key - b.key;
if (a.isPlayer !== b.isPlayer) return a.isPlayer ? 1 : -1;
if (a.name === b.name) return 0;
return a.name < b.name ? -1 : 1;
}
/**
* 空载具先嵌进冻结砖块,再插走动的人或车。
* 人物插入时穿过空载具继续扫矮砖,避免转角处立面挡腿。
*/
function insertEntityInto(list: DrawKeyed[], entity: DrawKeyed) {
let i = 0;
for (; i < list.length; i++) {
const cur = list[i];
if (isTileKeyed(cur)) {
if (entityBeforeTile(entity, cur)) break;
} else if (entity.isPlayer && isParkedVehicleNode(cur.entry.node)) {
continue;
} else if (compareEntityKeyed(entity, cur) < 0) {
break;
}
}
list.splice(i, 0, entity);
}
function buildSortedDrawOrder(
tiles: TileDrawEntry[],
entities: EntityDrawEntry[],
frozenTiles: DrawKeyed[],
force = false,
): {
entries: DrawEntry[];
keys: number[];
} {
stripStalePlayerOcclusion(entities);
const wallTiles = tiles
.filter((t) => isWallTileName(t.tileName))
.map((t) => toUnitySortableTile(t));
const keyed: { entry: DrawEntry; key: number; name: string; isPlayer: boolean }[] = [];
for (const t of tiles) {
keyed.push({
entry: t,
key: tileDrawFrontKey(toUnitySortableTile(t)),
name: t.node.name,
isPlayer: false,
});
}
const parked: DrawKeyed[] = [];
const live: DrawKeyed[] = [];
for (const e of entities) {
const ent = toUnitySortableEntity(e);
keyed.push({
const resolved = resolveEntityDrawKey(e, wallTiles, force);
const item: DrawKeyed = {
entry: e,
key: entityDrawFrontKeyWithWalls(ent, wallTiles),
key: resolved.key,
name: e.node.name,
isPlayer: ent.isPlayer,
});
isPlayer: resolved.isPlayer,
isShortFloor: false,
};
if (e.kind === 'actor' && isParkedVehicleNode(e.node)) parked.push(item);
else live.push(item);
}
// 正在骑乘:角色 frontKey 高于其载具(同格 typeRank 通常已够;此处兜住贴墙抬升后并列/短暂格子差)
for (const p of keyed) {
for (const p of live) {
if (!p.isPlayer || p.entry.kind !== 'actor') continue;
const ride = (p.entry.node.getComponent('PlayerController') as {
getRideVehicle?: () => { node?: Node } | null;
} | null)?.getRideVehicle?.();
const vehicleNode = ride?.node;
if (!vehicleNode) continue;
const v = keyed.find((k) => k.entry.node === vehicleNode);
if (v && p.key <= v.key) p.key = v.key + 1;
const v = live.find((k) => k.entry.node === vehicleNode)
?? parked.find((k) => k.entry.node === vehicleNode);
if (!v) continue;
p.key = v.key + 1;
}
keyed.sort((a, b) => {
if (a.key !== b.key) return a.key - b.key;
// 同 key角色永远盖过载具避免 Player 名字排在 Vehicle 前反而画在下)
if (a.isPlayer !== b.isPlayer) return a.isPlayer ? 1 : -1;
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
});
parked.sort(compareEntityKeyed);
live.sort(compareEntityKeyed);
const merged = frozenTiles.slice();
for (const item of parked) insertEntityInto(merged, item);
for (const item of live) insertEntityInto(merged, item);
placePlayerAfterTrailingShortFloors(merged);
return {
entries: keyed.map((k) => k.entry),
keys: keyed.map((k) => k.key),
entries: merged.map((k) => k.entry),
keys: merged.map((k) => k.key),
};
}
@@ -272,26 +456,15 @@ function getActorStandBottomY(
return stand.y - resolveActorSortAnchorY(node) * h;
}
/** 移动中反推逻辑格 */
function getEntitySortCell(node: Node): { x: number; y: number } {
const gm = GameManager.instance;
if (!gm) return { x: 0, y: 0 };
const config = resolveLevelConfig();
const theme = config?.theme ?? gm.uiStyle;
const n = safeNodeName(node);
let role: MoverRole = 'player';
if (isVehicleEntityName(n)) role = 'vehicle';
const cell = worldToMoverCellForRole(node.position, config, theme, role);
return { x: cell.x, y: cell.y };
}
/** 关卡内所有砖块/实体并入 Tiles避免 Ground/Border 在 BFS 中整块早于 Tiles 绘制) */
function pullDrawablesIntoTiles(levelRoot: Node, tilesRoot: Node): boolean {
const worldPos = new Vec3();
const pending: Node[] = [];
const walk = (parent: Node) => {
for (const ch of parent.children) {
if (!ch?.isValid || !ch.active) continue;
if (!ch?.isValid) continue;
// 进关时实体先 inactive仍要拉进 Tiles否则空载载具排不上
if (!ch.active && !isEntityDrawNode(ch)) continue;
if (parseTileNodeName(ch.name) || isEntityDrawNode(ch)) {
pending.push(ch);
continue;
@@ -328,12 +501,11 @@ function dedupeLayerTileDuplicates(levelRoot: Node, tilesRoot: Node) {
}
/**
* 移动中采样格(图层用)——人物 / 载具拆开
* - 角色朝屏幕前(落点 x+y 更小):用落点,避免裁腿;朝后用起点
* - 载具朝屏幕前(下/南、右/东):用起点,避免提早浮到前侧地砖上
* - 载具朝屏幕后(上/北、左/西):用落点,否则钉在起点会比落点更靠前,
* 途中会短暂「不被下方砖挡住」,落地写回后又正常
* - 骑乘跟驱动方取格,再按本节点人/车分叉
* 人车共用同一套采样(各自算各自的 key
* - 朝镜头:当前格与下一格 frontKey 按进度插值
* - 远离镜头:钉在当前格,位移结束落地后再改(避免墙切屁股)
* - 静止committed / spawn
* - 骑手在动:载具跟骑手格;载具在动:角色跟载具格
*/
function resolveDrawSampleMovement(node: Node): Movement | null {
const self = node.getComponent(Movement);
@@ -348,40 +520,14 @@ function resolveDrawSampleMovement(node: Node): Movement | null {
return self;
}
function isVehicleSortNode(node: Node): boolean {
return isVehicleEntityName(safeNodeName(node)) || !!node.getComponent('VehicleController');
}
function sampleCellFromMovement(
mov: Movement,
node: Node,
fallback: { x: number; y: number },
_sortNode: Node,
): { x: number; y: number } {
const committed = mov.getCommittedCell();
if (mov.isMoving()) {
const landing = mov.getLandingCell();
if (committed && landing) {
const fromSum = committed.x + committed.y;
const toSum = landing.x + landing.y;
const goingFront = toSum < fromSum;
if (isVehicleSortNode(node)) {
// 载具:朝前钉起点;朝后改落点(与落地静止同层,继续被下方砖挡)
if (goingFront) {
return { x: committed.x, y: committed.y };
}
return { x: landing.x, y: landing.y };
}
// 角色:朝前用落点,朝后用起点
if (goingFront) {
return { x: landing.x, y: landing.y };
}
return { x: committed.x, y: committed.y };
}
if (committed) return { x: committed.x, y: committed.y };
return getEntitySortCell(node);
}
if (committed) return { x: committed.x, y: committed.y };
const spawn = mov.getSpawnCell();
if (committed) return { x: committed.x, y: committed.y };
if (spawn) return { x: spawn.x, y: spawn.y };
return fallback;
}
@@ -389,7 +535,7 @@ function sampleCellFromMovement(
function getActorWallSampleCell(entity: EntityDrawEntry, node: Node): { x: number; y: number } {
const mov = resolveDrawSampleMovement(node);
if (!mov) return { x: entity.x, y: entity.y };
return sampleCellFromMovement(mov, node, { x: entity.x, y: entity.y });
return sampleCellFromMovement(mov, { x: entity.x, y: entity.y }, node);
}
function resolveSortTransform(node: Node): UITransform | null {
@@ -468,6 +614,15 @@ function getEntityLogicCell(node: Node): { x: number; y: number } {
const m = /^Prop_(-?\d+)_(-?\d+)$/.exec(safeNodeName(node));
if (m) return { x: Number(m[1]), y: Number(m[2]) };
const mov = node.getComponent(Movement);
if (mov) {
const own = mov.getCommittedCell?.() ?? mov.getSpawnCell?.();
if (own) {
const sampled = sampleCellFromMovement(mov, { x: own.x, y: own.y }, node);
return { x: sampled.x, y: sampled.y };
}
}
const vehicle = node.getComponent('VehicleController') as {
getPlayer?: () => { getCommittedCell?: () => Vec3 | null; getSpawnCell?: () => Vec3 | null } | null;
} | null;
@@ -477,12 +632,6 @@ function getEntityLogicCell(node: Node): { x: number; y: number } {
if (riderCell) return { x: riderCell.x, y: riderCell.y };
}
const mov = node.getComponent(Movement);
if (mov) {
const cell = mov.getCommittedCell?.() ?? mov.getSpawnCell?.();
if (cell) return { x: cell.x, y: cell.y };
}
const gm = GameManager.instance;
if (gm) {
const c = gm.worldToCell(node.position);
@@ -536,7 +685,7 @@ function collectTileEntries(levelRoot: Node, tilesRoot: Node, seen: Set<Node>):
forEachTileChild(levelRoot, pushTile);
for (const ch of tilesRoot.children) {
if (!ch?.isValid || seen.has(ch)) continue;
if (!ch?.isValid || !ch.active || seen.has(ch)) continue;
const parsed = parseTileNodeName(ch.name);
if (parsed) pushTile(ch, parsed);
}
@@ -547,6 +696,8 @@ function collectEntityEntries(levelRoot: Node, tilesRoot: Node, seen: Set<Node>)
const entries: EntityDrawEntry[] = [];
const pushEntity = (node: Node | null | undefined) => {
if (!node?.isValid || seen.has(node) || !isEntityDrawNode(node)) return;
// 已拾取道具会先 inactive 再销毁;进关角色/载具 inactive 仍要参与排序
if (!node.active && isPickableProp(node)) return;
seen.add(node);
const cell = getEntityLogicCell(node);
entries.push({
@@ -594,7 +745,7 @@ function applyDrawOrder(tilesRoot: Node, entries: DrawEntry[]) {
head++;
}
// 只调 sibling不写 zUI_2D 改 z 会抖)
// 只调 sibling不写 zUI_2D 改 z 会抖)。空载具和周围砖一起改 index相对层不变
for (let i = entries.length - 1; i >= 0; i--) {
const node = entries[i].node;
if (!node.isValid) continue;
@@ -637,17 +788,26 @@ export function sortIsoTiles(levelRoot: Node, force = false) {
const seen = new Set<Node>();
const tileEntries = collectTileEntries(levelRoot, tilesRoot, seen);
const entityEntries = collectEntityEntries(levelRoot, tilesRoot, seen);
const sorted = buildSortedDrawOrder(tileEntries, entityEntries);
const updater = levelRoot.getComponent(IsoDrawOrderUpdater);
const tileSig = tileSetSignature(tileEntries);
let frozen = updater?.getFrozenTiles(tileSig) ?? null;
if (frozen) frozen = bindFrozenTiles(frozen, tileEntries);
if (!frozen) {
frozen = sortTilesFrozen(tileEntries);
updater?.setFrozenTiles(tileSig, frozen);
}
const sorted = buildSortedDrawOrder(tileEntries, entityEntries, frozen, force);
if (force) updater?.resetOrderCache();
const sig = drawOrderSignature(sorted.entries, sorted.keys);
const changed = !updater || updater.noteOrderChanged(sig);
if (!force && !changed) {
finalizeLevelRootOrder(levelRoot, tilesRoot);
updater?.clearDirty();
return;
}
applyDrawOrder(tilesRoot, sorted.entries);
finalizeLevelRootOrder(levelRoot, tilesRoot);
updater?.clearDirty();
}
/** @deprecated 与 sortIsoTiles 相同 */
@@ -775,15 +935,42 @@ export function layoutLevelTiles(levelRoot: Node, config: LevelConfig) {
class IsoDrawOrderUpdater extends Component {
private _dirty = true;
private _lastOrderSig = '';
/** 进关后强制全量的剩余帧数(等 start / 贴图 / 绑乘落稳) */
private _primeFrames = 0;
private _frozenTileSig = '';
private _frozenTiles: DrawKeyed[] = [];
markDirty() {
this._dirty = true;
}
clearDirty() {
this._dirty = false;
this._primeFrames = 0;
}
prime(frames = 2) {
this._primeFrames = Math.max(this._primeFrames, frames);
this._dirty = true;
this._lastOrderSig = '';
this._frozenTileSig = '';
this._frozenTiles = [];
}
resetOrderCache() {
this._lastOrderSig = '';
}
getFrozenTiles(sig: string): DrawKeyed[] | null {
if (!sig || sig !== this._frozenTileSig || this._frozenTiles.length < 1) return null;
return this._frozenTiles;
}
setFrozenTiles(sig: string, tiles: DrawKeyed[]) {
this._frozenTileSig = sig;
this._frozenTiles = tiles;
}
/** @returns true 表示顺序相对缓存有变化 */
noteOrderChanged(sig: string): boolean {
if (sig === this._lastOrderSig) return false;
@@ -795,9 +982,11 @@ class IsoDrawOrderUpdater extends Component {
if (!this.node?.isValid) return;
const st = GameManager.instance?.gameState;
if (st === GameState.ResultWin || st === GameState.ResultFail) return;
if (!this._dirty) return;
const force = this._primeFrames > 0;
if (force) this._primeFrames -= 1;
else if (!this._dirty) return;
this._dirty = false;
sortIsoTiles(this.node, false);
sortIsoTiles(this.node, force);
}
}
@@ -812,3 +1001,10 @@ export function markIsoDrawOrderDirty(levelRoot: Node) {
ensureDrawOrderUpdater(levelRoot);
levelRoot.getComponent(IsoDrawOrderUpdater)?.markDirty();
}
/** 开局/重置后强制排 2 帧,避免第一帧用到未绑乘、未刷贴图的顺序 */
export function primeIsoDrawOrder(levelRoot: Node, frames = 2) {
if (!levelRoot?.isValid) return;
ensureDrawOrderUpdater(levelRoot);
levelRoot.getComponent(IsoDrawOrderUpdater)?.prime(frames);
}

View File

@@ -5,10 +5,12 @@
* frontKey = -(cellX + cellY) * CELL // 更小 x+y → 屏幕更靠下 → 更靠前
* + typeRank // 同对角条带内:地板 < 金币 < 载具 < 角色 < 墙
*
* 移动中采样拆开
* - 角色:朝前落点 / 朝后起点
* - 载具:朝前起点(防浮到前砖上)/ 朝后落点(防途中比落点更靠前而不被下方砖挡)
* 移动中:当前格与下一格的排序值按位移进度插值,落地后再钉新格。
* 载具 rank 低于矮砖,墙砖仍更高所以能挡住载具。
* 贴墙南/西立面格上的实体:抬到该墙之上(露脚)。
* 角色按 frontKey 插入;漏到后面的矮砖补到角色下面,遇到该挡人的墙即停。
* 砖与砖进关后冻结,走路不改。
* 载具与矮砖仍按 frontKey可被镜头方向的矮砖挡住。
* 角色永远盖过同条带载具。
*/
@@ -30,7 +32,6 @@ const CELL = 100;
/** 同对角条带内层级:地板 < 金币 < 载具 < 角色 < 墙立面 */
const RANK_FLOOR = 0;
const RANK_PICKABLE = 20;
const RANK_VEHICLE = 30;
const RANK_PLAYER = 35;
const RANK_WALL = 40;
@@ -42,6 +43,11 @@ export function isWalkableTileName(tileName: string): boolean {
return tileName === CommonDefine.BlockBase || tileName === CommonDefine.BlockJump;
}
/** 矮砖:仅 Baseblock / kuai11。不含 JumpBlock、墙砖 WallBlock。 */
export function isShortFloorTile(tileName: string): boolean {
return tileName === CommonDefine.BlockBase || tileName === 'kuai11';
}
export function unityTileIndividualSortingOrder(cellX: number, cellY: number): number {
return UNITY_SORTING_ORDER.tilemap - cellX - cellY;
}
@@ -107,7 +113,8 @@ function typeRankForTile(tileName: string): number {
function typeRankForEntity(entity: UnitySortableEntity): number {
if (entity.isPickable) return RANK_PICKABLE;
if (entity.isPlayer) return RANK_PLAYER;
return RANK_VEHICLE;
// 载具始终低于矮砖:墙 RANK_WALL 仍更高所以能挡住;移动中若用落点+载具 rank 会漏到矮砖上面
return RANK_FLOOR - 1;
}
/** 越大越靠前(后绘制) */
@@ -132,7 +139,7 @@ export function entityFaceLiftOverWall(
tile: UnitySortableTile,
): number | null {
if (!isWallBlockTileName(tile.tileName)) return null;
const cell = { x: entity.cellX, y: entity.cellY };
const cell = { x: Math.round(entity.cellX), y: Math.round(entity.cellY) };
for (const face of wallFaceSamples(tile.cellX, tile.cellY)) {
if (face.x === cell.x && face.y === cell.y) {
return tileDrawFrontKey(tile);
@@ -169,15 +176,14 @@ export function compareUnityEntityToEntity(a: UnitySortableEntity, b: UnitySorta
}
/**
* 实体 vs 砖:统一用 frontKey。
* (保留函数供外部/测试调用;排序主路径用 build 里的 key
* 实体 vs 砖:载具仍用 frontKey(镜头方向的矮砖可以挡住载具)
* 角色对矮砖Baseblock / kuai11永远在上墙砖仍按 frontKey / 贴墙抬升。
*/
export function compareUnityEntityToTile(
entity: UnitySortableEntity,
tile: UnitySortableTile,
): number {
if (!isWallBlockTileName(tile.tileName)) {
// 地板永在实体下
if (entity.isPlayer && isShortFloorTile(tile.tileName)) {
return 1;
}
const lift = entityFaceLiftOverWall(entity, tile);

View File

@@ -1,6 +1,6 @@
import {
_decorator, Button, Component, director, find,
Label, Node, Sprite, SpriteFrame, UITransform, view, AudioSource, Color, Layers,
Label, Node, Sprite, SpriteFrame, UITransform, view, Color, Layers,
tween, Tween, Vec3, Widget,
} from 'cc';
import { EventManager, EventType } from '../core/EventManager';
@@ -9,7 +9,7 @@ import { ViewController } from '../controller/ViewController';
import { LineGridRenderer } from '../gameplay/LineGridRenderer';
import { Movement } from '../gameplay/Movement';
import { GameAudio } from '../audio/GameAudio';
import { loadThemeCharacterPortrait, loadUIIcon, UIIconKey } from './UIStyleAssets';
import { loadThemeCharacterPortrait, loadUIIcon, loadDirectionIcon, UIIconKey } from './UIStyleAssets';
import {
getThemeHudIconScale, getThemePortraitFlipX, getThemePortraitScale,
} from '../theme/ThemeRegistry';
@@ -31,6 +31,12 @@ const UNITY_PAD_TOP = 20;
const UNITY_PORTRAIT = 194;
const UNITY_PAD_LEFT = 40;
const UNITY_PAD_TOP_PORTRAIT = 28;
/** Unity UIMain/ImageDirection401×176左下锚点距边 31 */
const UNITY_DIR_W = 401;
const UNITY_DIR_H = 176;
const UNITY_DIR_PAD = 31;
/** 左下角 X/Y 罗盘:先隐藏,改好后再打开 */
const SHOW_DIRECTION_GIZMO = false;
const REF_WIDTH = 2560;
const scaleUi = (v: number) => v * (DESIGN_WIDTH / REF_WIDTH);
@@ -42,6 +48,9 @@ const PAD_TOP = Math.round(scaleUi(UNITY_PAD_TOP));
const PORTRAIT_SIZE = Math.round(scaleUi(UNITY_PORTRAIT));
const PAD_LEFT = Math.round(scaleUi(UNITY_PAD_LEFT));
const PAD_TOP_PORTRAIT = Math.round(scaleUi(UNITY_PAD_TOP_PORTRAIT));
const DIR_W = Math.round(scaleUi(UNITY_DIR_W) * 2.5);
const DIR_H = Math.round(scaleUi(UNITY_DIR_H) * 2.5);
const PAD_DIR = Math.round(scaleUi(UNITY_DIR_PAD));
/** 点击时短暂放大再回弹 */
const BTN_POP_SCALE = 1.14;
@@ -68,6 +77,7 @@ export class UIMain extends Component {
private nodePlaySpeed: IconSlot | null = null;
private nodeAudio: IconSlot | null = null;
private portraitSprite: Sprite | null = null;
private directionSprite: Sprite | null = null;
private textLabel: Label | null = null;
private uiBuilt = false;
private readonly buttons: BtnLayout[] = [];
@@ -188,6 +198,7 @@ export class UIMain extends Component {
this.buildTextArea();
this.buildThemePortrait();
this.buildDirectionGizmo();
this.node.setSiblingIndex(this.node.parent!.children.length - 1);
this.layoutPanel();
view.on('canvas-resize', this.layoutPanel, this);
@@ -242,6 +253,78 @@ export class UIMain extends Component {
widget.alignMode = Widget.AlignMode.ON_WINDOW_RESIZE;
}
/** 对齐 Unity UIMain/ImageDirection每关左下角等距 X/Y 罗盘 */
private buildDirectionGizmo() {
const overlay = this.node.parent;
if (!overlay) return;
let root = overlay.getChildByName('ImageDirection');
if (!SHOW_DIRECTION_GIZMO) {
if (root?.isValid) root.active = false;
this.directionSprite = null;
return;
}
if (!root) {
root = new Node('ImageDirection');
root.parent = overlay;
}
root.layer = HUD_LAYER;
root.active = true;
const ui = root.getComponent(UITransform) ?? root.addComponent(UITransform);
ui.setAnchorPoint(0, 0);
ui.setContentSize(DIR_W, DIR_H);
const sprite = root.getComponent(Sprite) ?? root.addComponent(Sprite);
sprite.sizeMode = Sprite.SizeMode.CUSTOM;
sprite.color = new Color(255, 255, 255, 255);
this.directionSprite = sprite;
this.layoutDirectionGizmo();
this.scheduleOnce(() => this.layoutDirectionGizmo(), 0);
void this.refreshDirectionGizmo();
}
private layoutDirectionGizmo() {
const root = this.directionSprite?.node ?? this.node.parent?.getChildByName('ImageDirection');
if (!root?.isValid) return;
const debugBar = this.node.parent?.getChildByName('GameplayDebugBar');
const debugLift = debugBar?.active ? 64 : 0;
const widget = root.getComponent(Widget) ?? root.addComponent(Widget);
widget.isAlignBottom = true;
widget.isAlignLeft = true;
widget.isAlignTop = false;
widget.isAlignRight = false;
widget.left = PAD_DIR;
widget.bottom = PAD_DIR + debugLift;
widget.alignMode = Widget.AlignMode.ON_WINDOW_RESIZE;
widget.updateAlignment();
}
private async refreshDirectionGizmo() {
if (!this.directionSprite) return;
const sf = await loadDirectionIcon();
if (!sf || !this.directionSprite?.isValid) return;
this.applyDirectionSprite(sf);
}
private applyDirectionSprite(sf: SpriteFrame) {
if (!this.directionSprite?.isValid) return;
const root = this.directionSprite.node;
const ui = root.getComponent(UITransform) ?? root.addComponent(UITransform);
const { width: ow, height: oh } = spriteOriginalSize(sf);
const scale = Math.min(DIR_W / Math.max(ow, 1), DIR_H / Math.max(oh, 1));
const w = Math.max(1, Math.round(ow * scale));
const h = Math.max(1, Math.round(oh * scale));
ui.setAnchorPoint(0, 0);
ui.setContentSize(w, h);
this.directionSprite.spriteFrame = sf;
this.directionSprite.sizeMode = Sprite.SizeMode.CUSTOM;
this.layoutDirectionGizmo();
}
private layoutPanel = () => {
syncEmbeddedCamerasOrtho();
this.syncOverlaySize();
@@ -269,6 +352,7 @@ export class UIMain extends Component {
const portrait = this.portraitSprite?.node.parent
?? this.node.parent?.getChildByName('ImageBall');
portrait?.getComponent(Widget)?.updateAlignment();
this.layoutDirectionGizmo();
};
private ensureIconButton(name: string, onClick: () => void): IconSlot {
@@ -487,16 +571,12 @@ export class UIMain extends Component {
}
private applyAudioVolume() {
const vol = this.audioMute ? 0 : 1;
const scene = director.getScene();
if (!scene) return;
for (const src of scene.getComponentsInChildren(AudioSource)) {
src.volume = vol;
}
GameAudio.setMuted(this.audioMute);
}
private onLevelInit = () => {
this.setText('');
this.applyAudioVolume();
this.layoutDirectionGizmo();
};
}

View File

@@ -71,6 +71,13 @@ export function clearUIIconCache() {
frameCache.clear();
}
/** Unity UIMain/ImageDirection等距 X/Y 罗盘resources/textures/ui/ui-direction */
export async function loadDirectionIcon(): Promise<SpriteFrame | null> {
const sf = await loadSpriteAt('textures/ui/ui-direction');
if (!sf) console.warn('[UIStyleAssets] 方向罗盘贴图加载失败');
return sf;
}
/** 地图左上角角色肖像(优先 entities.portrait否则 playerFront */
export async function loadThemeCharacterPortrait(
options: EntityVisualOptions = {},

View File

@@ -148,6 +148,9 @@ export class PlayerActionAnimator extends Component {
private frameTimer = 0;
private loadingGen = 0;
private useSequence = false;
/** 本轮序列是否已走到末帧并回绕(或播完非循环动作) */
private cycleDone = true;
private cycleWaiters: Array<() => void> = [];
/** 主题级统一缩放(按最高序列帧锁定,避免待机/走路切换时重新算 scale */
private lockedUniformScale: number | null = null;
/** 脚点对齐参考帧(与 lockedUniformScale 同源,取最高帧) */
@@ -210,6 +213,8 @@ export class PlayerActionAnimator extends Component {
this.action = action;
this.frameIdx = 0;
this.frameTimer = 0;
this.cycleDone = false;
if (actionChanged) this.flushCycleWaiters();
if (!this.useSequence) {
VisualAssets.applyPlayerSprite(
this.spriteNode, this.direction, { theme: this.theme }, this.scaleMul,
@@ -226,8 +231,63 @@ export class PlayerActionAnimator extends Component {
return this.action;
}
/** 当前动作一轮序列的未缩放时长;帧未就绪时按 4 帧估算 */
getCycleDuration(): number {
const interval = FRAME_INTERVAL[this.action];
if (interval <= 0) return 0;
const n = this.useSequence && this.frames.length > 0 ? this.frames.length : 4;
return n * interval;
}
/** 从当前帧播到本轮结束的未缩放剩余时间 */
getRemainingCycleDuration(): number {
if (!this.useSequence || this.frames.length <= 1) return 0;
if (this.cycleDone) return 0;
const interval = FRAME_INTERVAL[this.action];
if (interval <= 0) return 0;
const currentLeft = Math.max(0, interval - this.frameTimer);
const framesAfter = Math.max(0, this.frames.length - this.frameIdx - 1);
return currentLeft + framesAfter * interval;
}
hasCompletedCycle(): boolean {
if (!this.useSequence || this.frames.length <= 1) return true;
return this.cycleDone;
}
/** 新的一格开始:不重播第 0 帧,只重新计一轮,避免循环移动掐断 */
beginCycle() {
this.cycleDone = this.frames.length <= 1;
if (this.cycleDone) this.flushCycleWaiters();
}
waitUntilCycleComplete(): Promise<void> {
if (this.hasCompletedCycle()) return Promise.resolve();
return new Promise((resolve) => {
this.cycleWaiters.push(resolve);
});
}
flushCycleWaiters() {
if (this.cycleWaiters.length === 0) return;
const pending = this.cycleWaiters;
this.cycleWaiters = [];
for (const fn of pending) fn();
}
onDestroy() {
this.cycleDone = true;
this.flushCycleWaiters();
}
update(dt: number) {
if (!this.useSequence || this.frames.length <= 1) return;
if (!this.useSequence || this.frames.length <= 1) {
if (!this.cycleDone) {
this.cycleDone = true;
this.flushCycleWaiters();
}
return;
}
const interval = FRAME_INTERVAL[this.action];
if (interval <= 0) return;
@@ -242,8 +302,16 @@ export class PlayerActionAnimator extends Component {
|| this.action === PlayerAction.Idle;
if (loop) {
this.frameIdx = (this.frameIdx + 1) % this.frames.length;
if (this.frameIdx === 0) {
this.cycleDone = true;
this.flushCycleWaiters();
}
} else if (this.frameIdx < this.frames.length - 1) {
this.frameIdx++;
if (this.frameIdx >= this.frames.length - 1) {
this.cycleDone = true;
this.flushCycleWaiters();
}
}
this.showFrame(this.frames[this.frameIdx]!);
}
@@ -314,7 +382,9 @@ export class PlayerActionAnimator extends Component {
this.frames = frames;
this.frameIdx = 0;
this.frameTimer = 0;
this.cycleDone = frames.length <= 1;
this.showFrame(frames[0]!);
if (this.cycleDone) this.flushCycleWaiters();
if (sortOnLoad && (action === PlayerAction.Win || action === PlayerAction.Fail)) {
this.refreshResultDrawOrder();
}

View File

@@ -40,13 +40,11 @@ export function resolvePlayerAnimPaths(theme: string | undefined): PlayerAnimPat
idle: `${base}/skin/待机正面`,
move: `${base}/skin/走`,
jump: `${base}/skin/跳`,
fail: key === 'silu' ? `${base}/player/失败正` : undefined,
},
back: {
idle: `${base}/skin/待机背面`,
move: `${base}/skin/走背面`,
jump: `${base}/skin/跳背面`,
fail: key === 'silu' ? `${base}/player/失败反` : undefined,
},
};
default: