同步主站 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

@@ -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);
}