同步主站 Cocos 当前工程:关卡资源、运行脚本与打包工具。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 = '1–80';
|
||||
@@ -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;
|
||||
/** 已显示在场上、可以同关原地重置的关卡 ID(adopt 之后才写入) */
|
||||
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: '',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 ViewController:Orthographic 缩放与拖拽 */
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
// 允许备用 1–400 + 练习 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> {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -5,3 +5,14 @@ export const LEVEL_ID_BASE = 91601;
|
||||
export function isGameLevelId(levelID: number): boolean {
|
||||
return Number.isFinite(levelID) && levelID >= LEVEL_ID_BASE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主站练习题批次主题(只覆盖 91601–94000):
|
||||
* LA/LB 91601–92200 silu;LC/LD 92201–92800 chinese;LE/LF 92801–94000 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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,不写 z(UI_2D 改 z 会抖)
|
||||
// 只调 sibling,不写 z(UI_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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/ImageDirection:401×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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user