同步主站 Cocos 当前工程:关卡资源、运行脚本与打包工具。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user