1011 lines
35 KiB
TypeScript
1011 lines
35 KiB
TypeScript
import { _decorator, Component, Node, UITransform, Vec3, Layers, Sprite } from 'cc';
|
||
import { cellToWorldCenter, parseTileNodeName } from '../core/GridCoords';
|
||
import { CommonDefine, MoverRole, GameState } from '../core/Define';
|
||
import { GameManager } from '../manager/GameManager';
|
||
import { getTilePivot } from '../visual/TilePivots';
|
||
import { getTileDrawSize, resolveTilePixelSize } from '../visual/TileSizes';
|
||
import { LevelConfig } from './LevelTypes';
|
||
import { getLevelRuntimeContext } from './LevelRuntimeContext';
|
||
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';
|
||
import {
|
||
compareIsoDrawOrder,
|
||
UNITY_SORTING_ORDER,
|
||
tileDrawFrontKey,
|
||
entityDrawFrontKeyWithWalls,
|
||
isShortFloorTile,
|
||
type UnitySortableEntity,
|
||
type UnitySortableTile,
|
||
} from './UnityDrawSort';
|
||
import { resolveTileNameAtCell } from './TileKinds';
|
||
|
||
const { ccclass } = _decorator;
|
||
|
||
const UI_LAYER = Layers.Enum.UI_2D;
|
||
/**
|
||
* Cocos 用 siblingIndex 模拟 Unity SortingOrder + CustomAxis 深度。
|
||
* 逻辑见 UnityDrawSort.ts(对齐 GraphicsSettings + TilemapRenderer Individual)。
|
||
*/
|
||
export const DRAW_SORT_Y_SCALE = 100;
|
||
type DrawKind = 'walkable' | 'wall' | 'actor' | 'pickable' | 'scenery';
|
||
|
||
interface TileDrawEntry {
|
||
node: Node;
|
||
x: number;
|
||
y: number;
|
||
kind: DrawKind;
|
||
tileName: string;
|
||
}
|
||
|
||
interface EntityDrawEntry {
|
||
node: Node;
|
||
x: number;
|
||
y: number;
|
||
kind: DrawKind;
|
||
}
|
||
|
||
type DrawEntry = TileDrawEntry | EntityDrawEntry;
|
||
|
||
function safeNodeName(node: Node | null | undefined): string {
|
||
return (node?.isValid ? node.name : '') || '';
|
||
}
|
||
|
||
function isPlayerEntityName(name: string | undefined | null): boolean {
|
||
if (!name) return false;
|
||
return name === 'Player' || /^Player[AB]\d$/.test(name);
|
||
}
|
||
|
||
function isVehicleEntityName(name: string | undefined | null): boolean {
|
||
if (!name) return false;
|
||
return name === 'Vehicle' || /^Vehicle[AB]\d$/.test(name);
|
||
}
|
||
|
||
function isCoinEntityName(name: string | undefined | null): boolean {
|
||
if (!name) return false;
|
||
return name === 'Prop' || name.startsWith('Prop_');
|
||
}
|
||
|
||
function isActorEntity(node: Node): boolean {
|
||
return isPlayerEntityName(safeNodeName(node))
|
||
|| isVehicleEntityName(safeNodeName(node));
|
||
}
|
||
|
||
/** 可拾取金币 / 道具(PropController) */
|
||
function isPickableProp(node: Node): boolean {
|
||
if (!node?.isValid) return false;
|
||
if (node.getComponent('PropController')) return true;
|
||
return isCoinEntityName(safeNodeName(node));
|
||
}
|
||
|
||
function isEntityDrawNode(node: Node): boolean {
|
||
const n = safeNodeName(node);
|
||
if (!n) return false;
|
||
return isActorEntity(node) || isPickableProp(node)
|
||
|| n === 'PropDecor' || n.startsWith('PropDecor_');
|
||
}
|
||
|
||
function isWalkablePathTile(tileName: string): boolean {
|
||
return tileName === CommonDefine.BlockBase || tileName === CommonDefine.BlockJump;
|
||
}
|
||
|
||
function isWallTileName(tileName: string): boolean {
|
||
return tileName === CommonDefine.BlockWall;
|
||
}
|
||
|
||
function resolveTileName(
|
||
parsed: { layer: 'ground' | 'border'; x: number; y: number },
|
||
config?: LevelConfig,
|
||
): string {
|
||
const key = `${parsed.x},${parsed.y}`;
|
||
return resolveTileNameAtCell(parsed.layer, key, config?.ground, config?.border);
|
||
}
|
||
|
||
/** 分类基于规范化后的瓦片名(贴图名已按层级锁定,避免墙/地属性互串) */
|
||
function classifyTileKind(
|
||
parsed: { layer: 'ground' | 'border'; x: number; y: number },
|
||
config?: LevelConfig,
|
||
): DrawKind {
|
||
const name = resolveTileName(parsed, config);
|
||
if (isWallTileName(name)) return 'wall';
|
||
if (isWalkablePathTile(name)) return 'walkable';
|
||
return 'scenery';
|
||
}
|
||
|
||
function classifyEntityKind(node: Node): DrawKind {
|
||
if (isPickableProp(node)) return 'pickable';
|
||
if (isActorEntity(node)) return 'actor';
|
||
return 'scenery';
|
||
}
|
||
|
||
export interface EntityDrawOrderOptions {
|
||
/** @deprecated 保留字段避免旧调用报错 */
|
||
preferVehicleOverPlayer?: boolean;
|
||
}
|
||
|
||
function unityEntitySortingOrder(node: Node): number {
|
||
const n = safeNodeName(node);
|
||
if (isPlayerEntityName(n)) return UNITY_SORTING_ORDER.player;
|
||
if (isVehicleEntityName(n)) return UNITY_SORTING_ORDER.vehicle;
|
||
if (isPickableProp(node)) {
|
||
const prop = node.getComponent('PropController') as { getSpawnCell?: () => Vec3 | null } | null;
|
||
const cell = prop?.getSpawnCell?.();
|
||
const config = resolveLevelConfig();
|
||
if (cell && !cellHasTile(cell.x, cell.y, config)) {
|
||
return UNITY_SORTING_ORDER.nProp;
|
||
}
|
||
return UNITY_SORTING_ORDER.prop;
|
||
}
|
||
if (n === 'PropDecor' || n.startsWith('PropDecor_')) return UNITY_SORTING_ORDER.prop;
|
||
return UNITY_SORTING_ORDER.nProp;
|
||
}
|
||
|
||
function toUnitySortableTile(tile: TileDrawEntry): UnitySortableTile {
|
||
const centerY = tile.node.position.y;
|
||
let depthY = getNodeBottomY(tile.node);
|
||
if (isWallTileName(tile.tileName)) {
|
||
depthY = getWallSortFrontY(tile);
|
||
} else if (isWalkablePathTile(tile.tileName)) {
|
||
depthY = Math.max(depthY, centerY);
|
||
} else {
|
||
depthY = centerY;
|
||
}
|
||
return {
|
||
cellX: tile.x,
|
||
cellY: tile.y,
|
||
tileName: tile.tileName,
|
||
centerY,
|
||
depthY,
|
||
};
|
||
}
|
||
|
||
function toUnitySortableEntity(entity: EntityDrawEntry): UnitySortableEntity {
|
||
const node = entity.node;
|
||
const cell = entity.kind === 'actor'
|
||
? getActorWallSampleCell(entity, node)
|
||
: { x: entity.x, y: entity.y };
|
||
const depthY = entity.kind === 'actor'
|
||
? getEntitySortBottomY(node)
|
||
: getNodeBottomY(node);
|
||
return {
|
||
sortingOrder: unityEntitySortingOrder(node),
|
||
cellX: cell.x,
|
||
cellY: cell.y,
|
||
centerY: node.position.y,
|
||
depthY,
|
||
isPlayer: isPlayerEntityName(safeNodeName(node)),
|
||
isPickable: entity.kind === 'pickable',
|
||
};
|
||
}
|
||
|
||
/** 等距深度:返回值 > 0 表示 a 应排在 b 之后(更靠前) */
|
||
export { compareIsoDrawOrder };
|
||
|
||
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 parked: DrawKeyed[] = [];
|
||
const live: DrawKeyed[] = [];
|
||
for (const e of entities) {
|
||
const resolved = resolveEntityDrawKey(e, wallTiles, force);
|
||
const item: DrawKeyed = {
|
||
entry: e,
|
||
key: resolved.key,
|
||
name: e.node.name,
|
||
isPlayer: resolved.isPlayer,
|
||
isShortFloor: false,
|
||
};
|
||
if (e.kind === 'actor' && isParkedVehicleNode(e.node)) parked.push(item);
|
||
else live.push(item);
|
||
}
|
||
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 = live.find((k) => k.entry.node === vehicleNode)
|
||
?? parked.find((k) => k.entry.node === vehicleNode);
|
||
if (!v) continue;
|
||
p.key = v.key + 1;
|
||
}
|
||
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: merged.map((k) => k.entry),
|
||
keys: merged.map((k) => k.key),
|
||
};
|
||
}
|
||
|
||
/** 精灵顶边 Y(Tiles 本地) */
|
||
function getNodeTopY(node: Node): number {
|
||
if (!node?.isValid) return 0;
|
||
const ui = resolveSortTransform(node);
|
||
const pos = node.position;
|
||
if (!ui || ui.contentSize.height <= 0) return pos.y;
|
||
const h = ui.contentSize.height * Math.abs(node.scale.y);
|
||
return pos.y + (1 - ui.anchorPoint.y) * h;
|
||
}
|
||
|
||
/** 墙砖立面顶边 Y(WallBlock pivot 偏低,取格心与顶边较大值) */
|
||
function getWallSortFrontY(tile: TileDrawEntry): number {
|
||
const centerY = cellToWorldCenter(new Vec3(tile.x, tile.y, 0)).y;
|
||
return Math.max(centerY, getNodeTopY(tile.node));
|
||
}
|
||
|
||
function resolveActorSortAnchorY(node: Node): number {
|
||
return isVehicleEntityName(safeNodeName(node)) ? DEFAULT_VEHICLE_ANCHOR_Y : DEFAULT_PLAYER_ANCHOR_Y;
|
||
}
|
||
|
||
function resolveActorSortDisplayHeight(node: Node, role: MoverRole, theme?: string): number {
|
||
const ui = resolveSortTransform(node);
|
||
if (ui && ui.contentSize.height > 0) {
|
||
return ui.contentSize.height * Math.abs(node.scale.y);
|
||
}
|
||
const sizes = getEntityDisplaySizes(theme);
|
||
return role === 'vehicle' ? sizes.vehicle.height : sizes.player.height;
|
||
}
|
||
|
||
/** 逻辑格站立点 + 固定参考锚点 → 排序底边 Y */
|
||
function getActorStandBottomY(
|
||
cell: Vec3,
|
||
node: Node,
|
||
role: MoverRole,
|
||
config?: LevelConfig,
|
||
theme?: string,
|
||
): number {
|
||
const stand = entityWorldPositionForRole(cell, config, theme, role);
|
||
const h = resolveActorSortDisplayHeight(node, role, theme);
|
||
return stand.y - resolveActorSortAnchorY(node) * h;
|
||
}
|
||
|
||
/** 关卡内所有砖块/实体并入 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) continue;
|
||
// 进关时实体先 inactive,仍要拉进 Tiles,否则空载载具排不上
|
||
if (!ch.active && !isEntityDrawNode(ch)) continue;
|
||
if (parseTileNodeName(ch.name) || isEntityDrawNode(ch)) {
|
||
pending.push(ch);
|
||
continue;
|
||
}
|
||
if (ch.name === 'Ground' || ch.name === 'Border' || ch.name === 'Tiles') {
|
||
walk(ch);
|
||
}
|
||
}
|
||
};
|
||
walk(levelRoot);
|
||
let moved = false;
|
||
for (const node of pending) {
|
||
if (node.parent === tilesRoot) continue;
|
||
node.getWorldPosition(worldPos);
|
||
node.parent = tilesRoot;
|
||
node.setWorldPosition(worldPos);
|
||
moved = true;
|
||
}
|
||
return moved;
|
||
}
|
||
function dedupeLayerTileDuplicates(levelRoot: Node, tilesRoot: Node) {
|
||
for (const layerName of ['Ground', 'Border'] as const) {
|
||
const layer = levelRoot.getChildByName(layerName);
|
||
if (!layer) continue;
|
||
for (const ch of [...layer.children]) {
|
||
if (!ch?.isValid) continue;
|
||
if (!parseTileNodeName(ch.name)) continue;
|
||
const canon = tilesRoot.getChildByName(ch.name);
|
||
if (canon?.isValid && canon !== ch) {
|
||
ch.destroy();
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 人车共用同一套采样(各自算各自的 key):
|
||
* - 朝镜头:当前格与下一格 frontKey 按进度插值
|
||
* - 远离镜头:钉在当前格,位移结束落地后再改(避免墙切屁股)
|
||
* - 静止:committed / spawn
|
||
* - 骑手在动:载具跟骑手格;载具在动:角色跟载具格
|
||
*/
|
||
function resolveDrawSampleMovement(node: Node): Movement | null {
|
||
const self = node.getComponent(Movement);
|
||
const rider = (node.getComponent('VehicleController') as {
|
||
getPlayer?: () => Movement | null;
|
||
} | null)?.getPlayer?.() ?? null;
|
||
if (rider?.isMoving()) return rider;
|
||
const vehicle = (node.getComponent('PlayerController') as {
|
||
getRideVehicle?: () => Movement | null;
|
||
} | null)?.getRideVehicle?.() ?? null;
|
||
if (vehicle?.isMoving()) return vehicle;
|
||
return self;
|
||
}
|
||
|
||
function sampleCellFromMovement(
|
||
mov: Movement,
|
||
fallback: { x: number; y: number },
|
||
_sortNode: Node,
|
||
): { x: number; y: number } {
|
||
const committed = mov.getCommittedCell();
|
||
const spawn = mov.getSpawnCell();
|
||
if (committed) return { x: committed.x, y: committed.y };
|
||
if (spawn) return { x: spawn.x, y: spawn.y };
|
||
return fallback;
|
||
}
|
||
|
||
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, { x: entity.x, y: entity.y }, node);
|
||
}
|
||
|
||
function resolveSortTransform(node: Node): UITransform | null {
|
||
const rootUi = node.getComponent(UITransform);
|
||
if (rootUi && rootUi.contentSize.height > 0) return rootUi;
|
||
for (const ch of node.children) {
|
||
if (!ch?.isValid) continue;
|
||
const ui = ch.getComponent(UITransform);
|
||
if (ui && ui.contentSize.height > 0) return ui;
|
||
}
|
||
return rootUi;
|
||
}
|
||
|
||
/** 足底中心 Y(Tiles 本地);排序依据底边而非锚点/中心 */
|
||
export function getNodeBottomY(node: Node): number {
|
||
if (!node?.isValid) return 0;
|
||
const ui = resolveSortTransform(node);
|
||
const pos = node.position;
|
||
if (!ui || ui.contentSize.height <= 0) return pos.y;
|
||
const h = ui.contentSize.height * Math.abs(node.scale.y);
|
||
return pos.y - ui.anchorPoint.y * h;
|
||
}
|
||
|
||
/** 画家算法 Order:round(底边Y × DRAW_SORT_Y_SCALE) */
|
||
export function getNodeSortOrder(node: Node): number {
|
||
return Math.round(getNodeBottomY(node) * DRAW_SORT_Y_SCALE);
|
||
}
|
||
|
||
/**
|
||
* 人物/载具排序底边 Y:始终按逻辑格站立点 + 固定参考锚点;
|
||
* 移动中在起止格底边 Y 之间插值(骑乘时跟驱动方 Movement 同步)。
|
||
*/
|
||
function getEntitySortBottomY(node: Node): number {
|
||
if (!isActorEntity(node)) return getNodeBottomY(node);
|
||
const mov = resolveDrawSampleMovement(node) ?? node.getComponent(Movement);
|
||
const gm = GameManager.instance;
|
||
const config = resolveLevelConfig();
|
||
const theme = config?.theme ?? gm?.uiStyle;
|
||
const role: MoverRole = isVehicleEntityName(safeNodeName(node)) ? 'vehicle' : 'player';
|
||
|
||
if (mov?.isMoving()) {
|
||
const from = mov.getCommittedCell();
|
||
const to = mov.getLandingCell();
|
||
if (from && to) {
|
||
const t = mov.getMoveStepProgress();
|
||
const fromY = getActorStandBottomY(from, node, role, config, theme);
|
||
const toY = getActorStandBottomY(to, node, role, config, theme);
|
||
return fromY + (toY - fromY) * t;
|
||
}
|
||
}
|
||
|
||
const cell = mov?.getCommittedCell() ?? mov?.getSpawnCell();
|
||
if (!cell) return getNodeBottomY(node);
|
||
return getActorStandBottomY(cell, node, role, config, theme);
|
||
}
|
||
|
||
/** 实体所在关卡根(Ground/Border/Tiles 的父节点,不是 Tiles 容器本身) */
|
||
export function resolveLevelDrawRoot(from: Node): Node | null {
|
||
let cur: Node | null = from;
|
||
while (cur?.isValid) {
|
||
if (cur.getChildByName('Ground') || cur.getChildByName('Border') || cur.getChildByName('Tiles')) {
|
||
return cur;
|
||
}
|
||
if (/^Level_\d+/.test(cur.name)) return cur;
|
||
cur = cur.parent;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/** 实体逻辑格(spawn / committed;实体互排序用,与砖块比较用底边 Y) */
|
||
function getEntityLogicCell(node: Node): { x: number; y: number } {
|
||
const propCtrl = node.getComponent('PropController') as { getSpawnCell?: () => Vec3 | null } | null;
|
||
const propCell = propCtrl?.getSpawnCell?.();
|
||
if (propCell) return { x: propCell.x, y: propCell.y };
|
||
|
||
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;
|
||
const rider = vehicle?.getPlayer?.() ?? null;
|
||
if (rider) {
|
||
const riderCell = rider.getCommittedCell?.() ?? rider.getSpawnCell?.();
|
||
if (riderCell) return { x: riderCell.x, y: riderCell.y };
|
||
}
|
||
|
||
const gm = GameManager.instance;
|
||
if (gm) {
|
||
const c = gm.worldToCell(node.position);
|
||
return { x: c.x, y: c.y };
|
||
}
|
||
return { x: 0, y: 0 };
|
||
}
|
||
|
||
/** 关卡内实体(可能在 levelRoot 或 Tiles 下) */
|
||
export function forEachLevelEntityNode(levelRoot: Node, fn: (node: Node) => void) {
|
||
if (!levelRoot?.isValid) return;
|
||
for (const ch of levelRoot.children) {
|
||
if (ch?.isValid && isEntityDrawNode(ch)) fn(ch);
|
||
}
|
||
const tiles = levelRoot.getChildByName('Tiles');
|
||
if (!tiles?.isValid) return;
|
||
for (const ch of tiles.children) {
|
||
if (ch?.isValid && isEntityDrawNode(ch)) fn(ch);
|
||
}
|
||
}
|
||
|
||
export function findLevelChildByName(levelRoot: Node, name: string): Node | null {
|
||
if (!levelRoot?.isValid) return null;
|
||
const direct = levelRoot.getChildByName(name);
|
||
if (direct) return direct;
|
||
return levelRoot.getChildByName('Tiles')?.getChildByName(name) ?? null;
|
||
}
|
||
|
||
function resolveLevelConfig(): LevelConfig | undefined {
|
||
return getLevelRuntimeContext()?.getCurLevel()
|
||
?? GameManager.instance?.getCurLevel?.()
|
||
?? undefined;
|
||
}
|
||
|
||
function collectTileEntries(levelRoot: Node, tilesRoot: Node, seen: Set<Node>): TileDrawEntry[] {
|
||
const config = resolveLevelConfig();
|
||
const entries: TileDrawEntry[] = [];
|
||
const pushTile = (node: Node, parsed: { layer: 'ground' | 'border'; x: number; y: number }) => {
|
||
if (seen.has(node)) return;
|
||
seen.add(node);
|
||
const tileName = resolveTileName(parsed, config);
|
||
const kind = classifyTileKind(parsed, config);
|
||
entries.push({
|
||
node,
|
||
x: parsed.x,
|
||
y: parsed.y,
|
||
kind,
|
||
tileName,
|
||
});
|
||
};
|
||
|
||
forEachTileChild(levelRoot, pushTile);
|
||
for (const ch of tilesRoot.children) {
|
||
if (!ch?.isValid || !ch.active || seen.has(ch)) continue;
|
||
const parsed = parseTileNodeName(ch.name);
|
||
if (parsed) pushTile(ch, parsed);
|
||
}
|
||
return entries;
|
||
}
|
||
|
||
function collectEntityEntries(levelRoot: Node, tilesRoot: Node, seen: Set<Node>): EntityDrawEntry[] {
|
||
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({
|
||
node,
|
||
x: cell.x,
|
||
y: cell.y,
|
||
kind: classifyEntityKind(node),
|
||
});
|
||
};
|
||
const walkEntities = (parent: Node) => {
|
||
for (const ch of parent.children) {
|
||
if (!ch?.isValid) continue;
|
||
if (isEntityDrawNode(ch)) {
|
||
pushEntity(ch);
|
||
continue;
|
||
}
|
||
if (ch.name === 'Ground' || ch.name === 'Border' || ch.name === 'Tiles') {
|
||
walkEntities(ch);
|
||
}
|
||
}
|
||
};
|
||
walkEntities(levelRoot);
|
||
return entries;
|
||
}
|
||
|
||
function applyDrawOrder(tilesRoot: Node, entries: DrawEntry[]) {
|
||
const inSort = new Set<Node>();
|
||
const worldPos = new Vec3();
|
||
|
||
for (const { node } of entries) {
|
||
if (!node.isValid) continue;
|
||
inSort.add(node);
|
||
if (isEntityDrawNode(node)) ensureEntityUILayer(node);
|
||
if (node.parent !== tilesRoot) {
|
||
node.getWorldPosition(worldPos);
|
||
node.parent = tilesRoot;
|
||
node.setWorldPosition(worldPos);
|
||
}
|
||
}
|
||
|
||
let head = 0;
|
||
for (const ch of [...tilesRoot.children]) {
|
||
if (!ch.isValid || inSort.has(ch)) continue;
|
||
if (ch.getSiblingIndex() !== head) ch.setSiblingIndex(head);
|
||
head++;
|
||
}
|
||
|
||
// 只调 sibling,不写 z(UI_2D 改 z 会抖)。空载具和周围砖一起改 index,相对层不变
|
||
for (let i = entries.length - 1; i >= 0; i--) {
|
||
const node = entries[i].node;
|
||
if (!node.isValid) continue;
|
||
const idx = head + i;
|
||
if (node.getSiblingIndex() !== idx) node.setSiblingIndex(idx);
|
||
}
|
||
}
|
||
|
||
function drawOrderSignature(entries: DrawEntry[], keys: number[]): string {
|
||
let s = '';
|
||
for (let i = 0; i < entries.length; i++) {
|
||
if (i) s += '\n';
|
||
s += `${entries[i].node.uuid}:${keys[i]}`;
|
||
}
|
||
return s;
|
||
}
|
||
|
||
function finalizeLevelRootOrder(levelRoot: Node, tilesRoot: Node) {
|
||
for (const name of ['Ground', 'Border'] as const) {
|
||
const layer = levelRoot.getChildByName(name);
|
||
if (!layer) continue;
|
||
layer.active = layer.children.length > 0;
|
||
if (layer.children.length === 0) {
|
||
layer.setSiblingIndex(0);
|
||
}
|
||
}
|
||
tilesRoot.setSiblingIndex(levelRoot.children.length - 1);
|
||
tilesRoot.active = true;
|
||
}
|
||
|
||
/**
|
||
* 等距绘制排序:开局 force 全量;起步/落地 dirty 时若顺序未变则跳过。
|
||
*/
|
||
export function sortIsoTiles(levelRoot: Node, force = false) {
|
||
if (!levelRoot?.isValid) return;
|
||
ensureDrawOrderUpdater(levelRoot);
|
||
const tilesRoot = ensureTilesRoot(levelRoot);
|
||
pullDrawablesIntoTiles(levelRoot, tilesRoot);
|
||
dedupeLayerTileDuplicates(levelRoot, tilesRoot);
|
||
const seen = new Set<Node>();
|
||
const tileEntries = collectTileEntries(levelRoot, tilesRoot, seen);
|
||
const entityEntries = collectEntityEntries(levelRoot, tilesRoot, seen);
|
||
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 相同 */
|
||
export function refreshIsoEntityDrawOrder(levelRoot: Node) {
|
||
sortIsoTiles(levelRoot, true);
|
||
}
|
||
|
||
/** @deprecated 与 sortIsoTiles 相同 */
|
||
export function refreshIsoDrawOrder(levelRoot: Node) {
|
||
sortIsoTiles(levelRoot, true);
|
||
}
|
||
|
||
/** @deprecated 与 sortIsoTiles 相同 */
|
||
export function refreshIsoDrawOrderImmediate(levelRoot: Node) {
|
||
sortIsoTiles(levelRoot, true);
|
||
}
|
||
|
||
export function bringEntityNodesToFront(levelRoot: Node, _opts?: EntityDrawOrderOptions) {
|
||
sortIsoTiles(levelRoot, true);
|
||
}
|
||
|
||
export function ensureEntityUILayer(node: Node) {
|
||
const walk = (n: Node) => {
|
||
n.layer = UI_LAYER;
|
||
for (const ch of n.children) walk(ch);
|
||
};
|
||
walk(node);
|
||
}
|
||
|
||
function ensureTilesRoot(levelRoot: Node): Node {
|
||
let tilesRoot = levelRoot.getChildByName('Tiles');
|
||
if (!tilesRoot) {
|
||
tilesRoot = new Node('Tiles');
|
||
tilesRoot.layer = UI_LAYER;
|
||
tilesRoot.parent = levelRoot;
|
||
setupLayerContainer(tilesRoot);
|
||
}
|
||
tilesRoot.setSiblingIndex(levelRoot.children.length - 1);
|
||
return tilesRoot;
|
||
}
|
||
|
||
const TILE_LAYER_NAMES = ['Tiles', 'Ground', 'Border'] as const;
|
||
|
||
function forEachTileChild(levelRoot: Node, fn: (node: Node, parsed: { layer: 'ground' | 'border'; x: number; y: number }) => void) {
|
||
for (const layerName of TILE_LAYER_NAMES) {
|
||
const layer = levelRoot.getChildByName(layerName);
|
||
if (!layer) continue;
|
||
for (const ch of layer.children) {
|
||
if (!ch?.isValid || !ch.active) continue;
|
||
const parsed = parseTileNodeName(ch.name);
|
||
if (!parsed) continue;
|
||
fn(ch, parsed);
|
||
}
|
||
}
|
||
}
|
||
|
||
export function alignTileNode(node: Node, cellX: number, cellY: number, tileName: string, theme?: string) {
|
||
const w = cellToWorldCenter(new Vec3(cellX, cellY, 0));
|
||
let ui = node.getComponent(UITransform);
|
||
if (!ui) ui = node.addComponent(UITransform);
|
||
const spr = node.getComponent(Sprite);
|
||
const source = resolveTilePixelSize(tileName, spr?.spriteFrame ?? null, theme);
|
||
const draw = getTileDrawSize(tileName, source.width, source.height, theme);
|
||
const pivot = getTilePivot(tileName, theme);
|
||
ui.setContentSize(draw.width, draw.height);
|
||
ui.setAnchorPoint(pivot.x, pivot.y);
|
||
node.setPosition(w.x, w.y, 0);
|
||
node.setScale(1, 1, 1);
|
||
}
|
||
|
||
export function setupLayerContainer(layer: Node) {
|
||
let ui = layer.getComponent(UITransform);
|
||
if (!ui) ui = layer.addComponent(UITransform);
|
||
ui.setAnchorPoint(0, 0);
|
||
ui.setContentSize(1, 1);
|
||
layer.setPosition(0, 0, 0);
|
||
}
|
||
|
||
function tileNameFromConfig(
|
||
layer: 'ground' | 'border',
|
||
key: string,
|
||
config: LevelConfig,
|
||
): string {
|
||
return resolveTileNameAtCell(layer, key, config.ground, config.border);
|
||
}
|
||
|
||
export function layoutLevelTiles(levelRoot: Node, config: LevelConfig) {
|
||
if (!levelRoot?.isValid) return;
|
||
const theme = config.theme;
|
||
const ground = levelRoot.getChildByName('Ground');
|
||
const border = levelRoot.getChildByName('Border');
|
||
if (ground) {
|
||
setupLayerContainer(ground);
|
||
for (const ch of ground.children) {
|
||
const parsed = parseTileNodeName(ch.name);
|
||
if (!parsed || parsed.layer !== 'ground') continue;
|
||
const key = `${parsed.x},${parsed.y}`;
|
||
alignTileNode(ch, parsed.x, parsed.y, tileNameFromConfig('ground', key, config), theme);
|
||
}
|
||
}
|
||
if (border) {
|
||
setupLayerContainer(border);
|
||
for (const ch of border.children) {
|
||
const parsed = parseTileNodeName(ch.name);
|
||
if (!parsed || parsed.layer !== 'border') continue;
|
||
const key = `${parsed.x},${parsed.y}`;
|
||
alignTileNode(ch, parsed.x, parsed.y, tileNameFromConfig('border', key, config), theme);
|
||
}
|
||
}
|
||
const tilesRoot = levelRoot.getChildByName('Tiles');
|
||
if (tilesRoot) {
|
||
setupLayerContainer(tilesRoot);
|
||
for (const ch of tilesRoot.children) {
|
||
const parsed = parseTileNodeName(ch.name);
|
||
if (!parsed) continue;
|
||
const key = `${parsed.x},${parsed.y}`;
|
||
const layer = parsed.layer;
|
||
alignTileNode(ch, parsed.x, parsed.y, tileNameFromConfig(layer, key, config), theme);
|
||
}
|
||
}
|
||
sortIsoTiles(levelRoot, true);
|
||
}
|
||
|
||
@ccclass('IsoDrawOrderUpdater')
|
||
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;
|
||
this._lastOrderSig = sig;
|
||
return true;
|
||
}
|
||
|
||
lateUpdate() {
|
||
if (!this.node?.isValid) return;
|
||
const st = GameManager.instance?.gameState;
|
||
if (st === GameState.ResultWin || st === GameState.ResultFail) return;
|
||
const force = this._primeFrames > 0;
|
||
if (force) this._primeFrames -= 1;
|
||
else if (!this._dirty) return;
|
||
this._dirty = false;
|
||
sortIsoTiles(this.node, force);
|
||
}
|
||
}
|
||
|
||
function ensureDrawOrderUpdater(levelRoot: Node) {
|
||
if (!levelRoot.getComponent(IsoDrawOrderUpdater)) {
|
||
levelRoot.addComponent(IsoDrawOrderUpdater);
|
||
}
|
||
}
|
||
|
||
export function markIsoDrawOrderDirty(levelRoot: Node) {
|
||
if (!levelRoot?.isValid) return;
|
||
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);
|
||
}
|