Complete Cocos Creator port with level bundles, themes, and tooling.

Adds level prefabs, theme assets, audio, extensions, and deployment scripts for the Unity WebGL migration.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 15:30:58 +08:00
parent cba5105908
commit d393302388
6248 changed files with 17322729 additions and 11036 deletions

View File

@@ -0,0 +1,25 @@
import { SpawnKind } from './LevelTypes';
import { CELL_PIXEL } from '../core/GridConstants';
import { getEntityCellBox } from '../visual/EntityDisplayRefs';
/** spawns[].scale 为相对默认值的比例1 = 默认大小 */
export function resolveEntityScaleMul(kind: SpawnKind, spawnScale?: number): number {
const mul = spawnScale !== undefined && spawnScale !== null && !Number.isNaN(spawnScale)
? spawnScale
: 1;
return Math.max(0.1, Math.min(4, mul));
}
export function resolveEntityHeight(kind: 'player' | 'vehicle', spawnScale?: number, theme?: string): number {
const box = getEntityCellBox(theme)[kind];
return CELL_PIXEL * box.h * resolveEntityScaleMul(kind, spawnScale);
}
/** 可拾取物等基准缩放spawn scale 倍率用,显示尺寸见 themes-database entityDisplay */
export const ENTITY_BASE_SCALE: Record<SpawnKind, number> = {
player: 1,
vehicle: 1,
prop: 1,
prop_decor: 0.87,
enemy: 1,
};

View File

@@ -2,7 +2,7 @@
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "507007a3-8bd2-41d5-b962-44c38a653bbb",
"uuid": "cc10897c-bd78-42c5-8443-ed54a4b1d461",
"files": [],
"subMetas": {},
"userData": {}

View File

@@ -0,0 +1,185 @@
import { Vec3 } from 'cc';
import { CELL_PIXEL } from '../core/GridConstants';
import { CommonDefine, MoverRole } from '../core/Define';
import { cellToWorldCenter, worldCenterToCell } from '../core/GridCoords';
import {
getThemePropPlacementOffsets,
getThemeMoverEmptyCellYOffset,
getThemeMoverJumpCellYOffset,
getThemePlayerStandYOffset,
DEFAULT_PROP_BLOCK_Y_OFFSET,
DEFAULT_PROP_GROUND_Y_OFFSET,
} from '../theme/ThemeDatabase';
import { getTilePivot } from '../visual/TilePivots';
import { getTileDrawSize } from '../visual/TileSizes';
import { LevelConfig, SpawnConfig } from './LevelTypes';
/** 与 Unity Prop / nProp 一致:砖块上偏高,空地偏低 */
export type PropPlacement = 'block' | 'ground';
const HALF_H = CELL_PIXEL * 0.25;
/** Unity checkPoint 偏移PPU≈100Prop ≈ +14pxnProp ≈ -11px */
export const PROP_BLOCK_Y_OFFSET = DEFAULT_PROP_BLOCK_Y_OFFSET;
export const PROP_GROUND_Y_OFFSET = DEFAULT_PROP_GROUND_Y_OFFSET;
export function cellHasTile(cellX: number, cellY: number, config: LevelConfig | undefined): boolean {
if (!config) return false;
const key = `${cellX},${cellY}`;
const hasGround = !!config.ground?.[key];
const hasBorder = config.border?.[key] !== undefined && config.border?.[key] !== false;
return hasGround || hasBorder;
}
/** 砖块 pivot 对齐格心后,行走面相对格心的抬高量(与 Baseblock 差值用于 JumpBlock */
function tileWalkSurfaceAboveCenter(tileName: string, theme?: string): number {
const draw = getTileDrawSize(tileName, undefined, undefined, theme);
const pivot = getTilePivot(tileName, theme);
return draw.height * (1 - pivot.y);
}
/** JumpBlock 比 Baseblock 更高的行走面补偿px */
export function resolveMoverJumpCellYOffset(
cellX: number,
cellY: number,
config: LevelConfig | undefined,
theme?: string,
): number {
const key = `${cellX},${cellY}`;
const tile = config?.ground?.[key];
if (tile !== CommonDefine.BlockJump) return 0;
const auto = tileWalkSurfaceAboveCenter(CommonDefine.BlockJump, theme)
- tileWalkSurfaceAboveCenter(CommonDefine.BlockBase, theme);
return auto + getThemeMoverJumpCellYOffset(theme);
}
/** 角色/载具JumpBlock 抬高;有普通砖为 0空地/载具格补齐 Baseblock 高度 */
export function resolveMoverCellStandYOffset(
cellX: number,
cellY: number,
config: LevelConfig | undefined,
theme?: string,
): number {
if (!cellHasTile(cellX, cellY, config)) {
return getThemeMoverEmptyCellYOffset(theme);
}
return resolveMoverJumpCellYOffset(cellX, cellY, config, theme);
}
export function entityMoverWorldPosition(
cell: Vec3,
config: LevelConfig | undefined,
theme?: string,
): Vec3 {
const pos = cellToWorldCenter(cell);
pos.y += resolveMoverCellStandYOffset(cell.x, cell.y, config, theme);
return pos;
}
/**
* 从带站立/空地 Y 微调的世界坐标反推逻辑格(与 entityMoverWorldPosition 互逆)。
* 视觉 Y 会干扰等距 worldToCell移动采样须先还原到格心平面。
*/
export function worldToMoverCell(
world: Vec3,
config: LevelConfig | undefined,
theme?: string,
forPlayer = false,
): Vec3 {
let cell = worldCenterToCell(world);
for (let i = 0; i < 4; i++) {
const off = resolveMoverCellStandYOffset(cell.x, cell.y, config, theme);
const stand = forPlayer ? getThemePlayerStandYOffset(theme) : 0;
const adjustedY = world.y - stand - off;
const next = worldCenterToCell(new Vec3(world.x, adjustedY, world.z));
if (next.x === cell.x && next.y === cell.y) break;
cell = next;
}
return cell;
}
/**
* 移动/格子判定用的逻辑采样点(格心,不含主题站立 Y
* @param levelLocal 关卡根节点本地坐标(与砖块 alignTileNode 同一空间)
*/
export function moverLogicalGridPosition(
levelLocal: Vec3,
config: LevelConfig | undefined,
theme?: string,
forPlayer = false,
): Vec3 {
return cellToWorldCenter(worldToMoverCell(levelLocal, config, theme, forPlayer));
}
/** 角色生成/移动位置(含主题站立 Y 微调) */
export function entityPlayerWorldPosition(
cell: Vec3,
config: LevelConfig | undefined,
theme?: string,
): Vec3 {
const pos = entityMoverWorldPosition(cell, config, theme);
pos.y += getThemePlayerStandYOffset(theme);
return pos;
}
/** 角色/载具是否按玩家站立 Y 反推逻辑格(与 worldToMoverCell / entityPlayerWorldPosition 一致) */
export function roleUsesPlayerStandOffset(role: MoverRole): boolean {
return role === 'player';
}
export function worldToMoverCellForRole(
world: Vec3,
config: LevelConfig | undefined,
theme: string | undefined,
role: MoverRole,
): Vec3 {
return worldToMoverCell(world, config, theme, roleUsesPlayerStandOffset(role));
}
export function moverLogicalGridPositionForRole(
levelLocal: Vec3,
config: LevelConfig | undefined,
theme: string | undefined,
role: MoverRole,
): Vec3 {
return moverLogicalGridPosition(
levelLocal,
config,
theme,
roleUsesPlayerStandOffset(role),
);
}
/** 逻辑格 → 世界站立点player 含主题站立 Yvehicle 为 mover 甲板) */
export function entityWorldPositionForRole(
cell: Vec3,
config: LevelConfig | undefined,
theme: string | undefined,
role: MoverRole,
): Vec3 {
return roleUsesPlayerStandOffset(role)
? entityPlayerWorldPosition(cell, config, theme)
: entityMoverWorldPosition(cell, config, theme);
}
export function propWorldYOffset(placement: PropPlacement, theme?: string): number {
const offsets = getThemePropPlacementOffsets(theme);
return placement === 'ground' ? offsets.ground : offsets.block;
}
/** 无 ground/border 视为空地nProp有砖块视为 Prop */
export function resolvePropPlacement(spawn: SpawnConfig, config: LevelConfig): PropPlacement {
const key = `${spawn.x},${spawn.y}`;
const hasGround = !!config.ground?.[key];
const hasBorder = config.border?.[key] !== undefined && config.border?.[key] !== false;
if (spawn.propPlacement === 'ground' || spawn.propPlacement === 'block') {
if (!hasGround && !hasBorder) return 'ground';
return spawn.propPlacement;
}
if (!hasGround && !hasBorder) return 'ground';
return 'block';
}
export function isGroundPropSpawn(spawn: SpawnConfig, config: LevelConfig): boolean {
return spawn.kind === 'prop' && resolvePropPlacement(spawn, config) === 'ground';
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "20691257-985a-4a52-96da-6696502e60ff",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,366 @@
import {
_decorator, Component, Vec3, Graphics, Color, Node, UITransform, Layers, Prefab,
} from 'cc';
import { EDITOR, PREVIEW, BUILD } from 'cc/env';
import {
cellToWorldCenter, tileNodeName, parseTileNodeName, getHalfCellSize,
} from '../core/GridCoords';
import { CommonDefine } from '../core/Define';
import { LevelMapData } from './LevelMapData';
import { alignTileNode, setupLayerContainer, sortIsoTiles } from './TileLayout';
const { ccclass, property, executeInEditMode, executionOrder } = _decorator;
const UI_LAYER = Layers.Enum.UI_2D;
/** 与关卡编辑器 canvas 一致(仅网格线,无填充) */
const GRID_STROKE = new Color(85, 85, 85, 255);
const CELL_STROKE = new Color(74, 158, 255, 255);
const RUNTIME_GRID_NODE_NAMES = new Set(['_GridSnapGizmo']);
/**
* 挂在关卡预制体根节点LevelN。编辑器中
* - 显示与关卡地图编辑面板一致的等距菱形参考格(无灰底,不遮挡主题背景)
* - 拖动 Ground/Border/Tiles 下瓦片时吸附到格子中心
*/
@ccclass('GridSnapHelper')
@executeInEditMode(true)
@executionOrder(-200)
export class GridSnapHelper extends Component {
@property({ displayName: '吸附格子', tooltip: '场景内移动瓦片时自动对齐格子中心' })
snapEnabled = true;
@property({ displayName: '显示参考网格' })
showGrid = false;
@property({ displayName: '网格半径', min: 4, max: 24, step: 1, tooltip: '无地图数据时的备用半径' })
gridRadius = 12;
@property({ displayName: '地图周围额外格数', min: 0, max: 6, step: 1 })
gridPadding = 2;
@property({ displayName: '高亮已有砖块的格子' })
highlightOccupied = true;
@property({ displayName: '吸附时同步节点名' })
syncNodeNames = true;
private _gridHost: Node | null = null;
private _graphics: Graphics | null = null;
private _alignedOnce = false;
/** 仅「编辑器内编辑预制体/场景、未点预览」时为 true */
static isEditingInEditor(): boolean {
return EDITOR && !PREVIEW && !BUILD;
}
/** 预览、构建、真机运行 */
static isRuntime(): boolean {
return !GridSnapHelper.isEditingInEditor();
}
/**
* 实例化前关闭 prefab 内 showGrid仅当 prefab.data 可安全访问时)。
* 编辑器预览若脚本未编译完成,访问 prefab.data 可能抛错;失败时由实例化后 stripBeforePlay 兜底。
*/
static stripBeforePlayFromPrefab(prefab: Prefab) {
try {
const root = prefab.data as Node | null;
if (!root?.isValid) return;
for (const snap of root.getComponentsInChildren(GridSnapHelper)) {
if (!snap?.isValid) continue;
snap.showGrid = false;
snap.enabled = false;
}
} catch (e) {
console.warn('[GridSnapHelper] stripBeforePlayFromPrefab 跳过(将在实例化后清理)', e);
}
}
/** 关卡实例化后立即剥离编辑器网格 */
static stripBeforePlay(levelRoot: Node) {
if (!levelRoot?.isValid) return;
for (const snap of levelRoot.getComponents(GridSnapHelper)) {
if (!snap.isValid) continue;
snap.showGrid = false;
snap.enabled = false;
snap.removeGridGizmo();
}
GridSnapHelper.destroyGridGizmoNodes(levelRoot);
}
/** 预览/构建/真机运行:彻底移除一切网格节点与组件(无条件,由游戏逻辑调用) */
static purgeRuntimeGrids(root: Node) {
if (!root?.isValid) return;
try {
for (const snap of [...root.getComponentsInChildren(GridSnapHelper)]) {
if (!snap?.isValid) continue;
snap.showGrid = false;
snap.enabled = false;
snap.removeGridGizmo();
snap.destroy();
}
GridSnapHelper.destroyGridGizmoNodes(root);
} catch (e) {
console.warn('[GridSnapHelper] purgeRuntimeGrids 部分失败', e);
}
}
/** 递归销毁编辑器参考格节点 */
static destroyGridGizmoNodes(root: Node) {
const walk = (node: Node) => {
for (let i = node.children.length - 1; i >= 0; i--) {
const ch = node.children[i];
if (RUNTIME_GRID_NODE_NAMES.has(ch.name)) {
ch.getComponent(Graphics)?.clear();
ch.destroy();
continue;
}
walk(ch);
}
};
walk(root);
}
static purgeScene(scene: Node) {
GridSnapHelper.purgeRuntimeGrids(scene);
}
/** @deprecated 使用 purgeRuntimeGrids */
static stripRuntimeGrid(levelRoot: Node) {
GridSnapHelper.purgeRuntimeGrids(levelRoot);
}
onLoad() {
if (!GridSnapHelper.isEditingInEditor()) {
this.showGrid = false;
this.enabled = false;
this.removeGridGizmo();
this.destroy();
return;
}
this.ensureGridGraphics();
this.alignAllTiles();
this._alignedOnce = true;
}
onEnable() {
if (!GridSnapHelper.isEditingInEditor()) {
this.showGrid = false;
this.removeGridGizmo();
return;
}
if (!this.showGrid) return;
this.ensureGridGraphics();
if (!this._alignedOnce) this.alignAllTiles();
this.drawReferenceGrid();
}
start() {
if (!GridSnapHelper.isEditingInEditor()) this.removeGridGizmo();
}
removeGridGizmo() {
this.showGrid = false;
const host = this.node.getChildByName('_GridSnapGizmo');
if (host) {
host.getComponent(Graphics)?.clear();
host.destroy();
}
this._gridHost = null;
this._graphics = null;
}
lateUpdate() {
if (!GridSnapHelper.isEditingInEditor()) {
this.removeGridGizmo();
return;
}
if (!this.showGrid) return;
this.drawReferenceGrid();
}
/** 供扩展场景脚本或外部调用 */
snapNode(node: Node | null) {
if (!node || !node.isValid) return;
const layer = this.resolveLayer(node);
if (!layer) return;
const { ground, border } = this.readMapJson();
this.applySnap(node, layer, ground, border);
sortIsoTiles(this.node);
}
private resolveLayer(node: Node): 'ground' | 'border' | null {
let cur: Node | null = node;
while (cur) {
if (cur.name === 'Ground') return 'ground';
if (cur.name === 'Border') return 'border';
if (cur === this.node) break;
cur = cur.parent;
}
const parsed = parseTileNodeName(node.name);
return parsed?.layer ?? null;
}
private readMapJson(): {
ground: Record<string, string>;
border: Record<string, boolean | string>;
} {
const md = this.node.getComponent(LevelMapData);
if (!md) return { ground: {}, border: {} };
try {
return {
ground: JSON.parse(md.groundJson || '{}'),
border: JSON.parse(md.borderJson || '{}'),
};
} catch {
return { ground: {}, border: {} };
}
}
private occupiedKeys(ground: Record<string, string>, border: Record<string, boolean | string>): Set<string> {
return new Set([...Object.keys(ground), ...Object.keys(border)]);
}
/** 编辑器打开预制体时对齐全部瓦片并合并深度排序 */
alignAllTiles() {
const { ground, border } = this.readMapJson();
if (this.snapEnabled) {
this.snapLayerChildren('Ground', 'ground', ground, border);
this.snapLayerChildren('Border', 'border', ground, border);
this.snapLayerChildren('Tiles', null, ground, border);
}
sortIsoTiles(this.node);
}
private snapLayerChildren(
layerName: string,
layer: 'ground' | 'border' | null,
groundMap: Record<string, string>,
borderMap: Record<string, boolean | string>,
) {
const layerNode = this.node.getChildByName(layerName);
if (!layerNode) return;
setupLayerContainer(layerNode);
for (const ch of layerNode.children) {
const parsed = parseTileNodeName(ch.name);
if (!parsed) continue;
const useLayer = layer ?? parsed.layer;
this.applySnap(ch, useLayer, groundMap, borderMap);
}
}
private inferTileName(
layer: 'ground' | 'border',
key: string,
groundMap: Record<string, string>,
borderMap: Record<string, boolean | string>,
): string {
if (layer === 'ground') return groundMap[key] ?? CommonDefine.BlockBase;
const v = borderMap[key];
if (typeof v === 'string') return v;
return 'WallBlock';
}
private applySnap(
node: Node,
layer: 'ground' | 'border',
groundMap: Record<string, string>,
borderMap: Record<string, boolean | string>,
) {
const parsed = parseTileNodeName(node.name);
if (!parsed) return;
const key = `${parsed.x},${parsed.y}`;
const tileName = this.inferTileName(layer, key, groundMap, borderMap);
alignTileNode(node, parsed.x, parsed.y, tileName);
if (this.syncNodeNames) {
const expected = tileNodeName(layer, parsed.x, parsed.y);
if (node.name !== expected) node.name = expected;
}
}
private ensureGridGraphics() {
if (!GridSnapHelper.isEditingInEditor()) return;
let host = this.node.getChildByName('_GridSnapGizmo');
if (!host) {
host = new Node('_GridSnapGizmo');
host.parent = this.node;
}
host.layer = UI_LAYER;
host.setSiblingIndex(0);
let ui = host.getComponent(UITransform);
if (!ui) ui = host.addComponent(UITransform);
ui.setAnchorPoint(0, 0);
ui.setContentSize(1, 1);
let g = host.getComponent(Graphics);
if (!g) g = host.addComponent(Graphics);
this._gridHost = host;
this._graphics = g;
}
private iterGridCells(): { x: number; y: number }[] {
const { ground, border } = this.readMapJson();
const keys = [...Object.keys(ground), ...Object.keys(border)];
if (!keys.length) {
const out: { x: number; y: number }[] = [];
const r = this.gridRadius;
for (let x = -r; x <= r; x++) {
for (let y = -r; y <= r; y++) out.push({ x, y });
}
return out;
}
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const k of keys) {
const [xs, ys] = k.split(',');
const x = parseInt(xs, 10);
const y = parseInt(ys, 10);
if (Number.isNaN(x) || Number.isNaN(y)) continue;
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
}
const pad = this.gridPadding;
const out: { x: number; y: number }[] = [];
for (let x = minX - pad; x <= maxX + pad; x++) {
for (let y = minY - pad; y <= maxY + pad; y++) out.push({ x, y });
}
return out;
}
private traceDiamond(g: Graphics, cx: number, cy: number, halfW: number, halfH: number) {
g.moveTo(cx, cy + halfH);
g.lineTo(cx + halfW, cy);
g.lineTo(cx, cy - halfH);
g.lineTo(cx - halfW, cy);
g.close();
}
private drawReferenceGrid() {
if (!GridSnapHelper.isEditingInEditor() || !this.showGrid) return;
this.ensureGridGraphics();
const g = this._graphics;
if (!g) return;
const { halfW, halfH } = getHalfCellSize();
const cells = this.iterGridCells();
const { ground, border } = this.readMapJson();
const occupied = this.highlightOccupied ? this.occupiedKeys(ground, border) : new Set<string>();
g.clear();
for (const { x, y } of cells) {
const w = cellToWorldCenter(new Vec3(x, y, 0));
const key = `${x},${y}`;
const isOcc = occupied.has(key);
g.lineWidth = isOcc ? 1.5 : 1;
g.strokeColor = isOcc ? CELL_STROKE : GRID_STROKE;
this.traceDiamond(g, w.x, w.y, halfW, halfH);
g.stroke();
}
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "739b26eb-64a3-4dd7-a084-cbe854d98a36",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,69 @@
import { Node } from 'cc';
import { LevelConfig, SpawnConfig } from './LevelTypes';
import { LevelMapData } from './LevelMapData';
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;
if (o && typeof o === 'object' && !Array.isArray(o)) {
return o as Record<string, unknown>;
}
} catch {
/* ignore */
}
return undefined;
}
/**
* Cocos 预制体 LevelMapData 为地图/主题权威levels-database 主要提供 spawns。
*/
export function mergeLevelConfigWithMapData(config: LevelConfig, levelRoot: Node): LevelConfig {
if (!levelRoot?.isValid) return config;
const md = levelRoot.getComponent(LevelMapData);
if (!md) return config;
const prefabGround = parseJsonRecord(md.groundJson) as LevelConfig['ground'];
const prefabBorder = parseJsonRecord(md.borderJson) as LevelConfig['border'];
const prefabTheme = md.theme?.trim();
const border = hasKeys(prefabBorder as Record<string, unknown>)
? prefabBorder
: config.border;
return {
...config,
theme: prefabTheme || config.theme?.trim() || config.theme,
ground: pickGround(
prefabGround as LevelConfig['ground'],
config.ground,
config.spawns ?? [],
),
border,
};
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "4a788bad-35f7-49ff-9c2e-129ae9d74c7b",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,282 @@
/**
* 关卡数据库 — 由 Cocos 工程导出level-prefabs LevelMapData + 编辑器 spawns
*
* - 编辑器 ▶ 预览:从 assets/level-data/ 加载(不在 resources bundlefetch 不可用)
* - 主站 / Web 运行fetch /unity/levels-database.json
*/
import { assetManager, JsonAsset } from 'cc';
import { PREVIEW } from 'cc/env';
import { LevelConfig } from './LevelTypes';
import { LEVEL_ID_BASE } from './LevelIds';
/** assets/level-data/levels-database.json与 .meta 一致,勿打进 resources */
const EDITOR_DB_UUID = '114420ca-c8e3-4204-a040-18282ef5e964';
export interface LevelDatabaseFile {
version: number;
generatedAt?: string;
source?: string;
levelIdBase?: number;
stats?: { total: number; withPrefabTilemap?: number; withBoundaryRing?: number };
levels: Record<string, LevelConfig>;
}
declare global {
interface Window {
/** 主站 scratch-gui 注入:/unity/levels-database.json */
__tfrhLevelsDatabaseUrl?: string;
/** loader 预注入的 JSON可选 */
__tfrhLevelsDatabaseJson?: LevelDatabaseFile;
}
}
let fileCache: LevelDatabaseFile | null = null;
let levelsMap: Record<number, LevelConfig> = {};
let sortedIds: number[] = [];
let loadPromise: Promise<void> | null = null;
function rebuildIndex() {
sortedIds = Object.keys(levelsMap)
.map((k) => parseInt(k, 10))
.filter((n) => !Number.isNaN(n))
.sort((a, b) => a - b);
}
function ingestFile(data: LevelDatabaseFile) {
fileCache = data;
levelsMap = {};
for (const [k, cfg] of Object.entries(data.levels ?? {})) {
const id = parseInt(k, 10);
if (!Number.isNaN(id)) {
levelsMap[id] = { ...cfg, levelID: id };
}
}
rebuildIndex();
}
function resolveRemoteUrl(): string | null {
if (typeof window === 'undefined') return null;
const url = window.__tfrhLevelsDatabaseUrl;
return url?.trim() || null;
}
function validateIngested(): void {
const total = sortedIds.length;
const minId = sortedIds[0] ?? 0;
if (total < 100 || minId < LEVEL_ID_BASE) {
throw new Error(
`关卡库过旧 (${total} 关),请用主站导出的 levels-database.json`
+ '运行 bash tools/sync-level-db.sh 后重新 package-for-project',
);
}
}
function canUseHttpFetch(): boolean {
if (typeof window === 'undefined') return false;
const proto = window.location.protocol;
return proto === 'http:' || proto === 'https:';
}
function fetchCandidates(): string[] {
const out: string[] = [];
const configured = resolveRemoteUrl();
if (configured && /^https?:\/\//i.test(configured)) {
out.push(configured);
}
if (canUseHttpFetch()) {
out.push(new URL('levels-database.json', window.location.href).href);
out.push(new URL('/unity/levels-database.json', window.location.origin).href);
}
return [...new Set(out)];
}
function loadFromRemote(url: string): Promise<void> {
const abs = /^https?:\/\//i.test(url)
? url
: new URL(url, typeof window !== 'undefined' ? window.location.href : 'http://localhost/').href;
return fetch(abs, { credentials: 'same-origin' })
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status} for ${abs}`);
return res.json() as Promise<LevelDatabaseFile>;
})
.then((json) => {
ingestFile(json);
validateIngested();
console.log(
`[LevelDatabase] 已加载 ${sortedIds.length} 关 (`
+ `${sortedIds[0] ?? '?'}${sortedIds[sortedIds.length - 1] ?? '?'})`,
abs,
);
});
}
async function loadFromNetwork(): Promise<void> {
const candidates = fetchCandidates();
if (!candidates.length) {
throw new Error('[LevelDatabase] 当前环境无法 fetch且无预注入关卡库');
}
const errors: unknown[] = [];
for (const url of candidates) {
try {
await loadFromRemote(url);
return;
} catch (e) {
errors.push(e);
}
}
const detail = errors.map((e) => String(e)).join('; ');
throw new Error(
`[LevelDatabase] 无法加载关卡库,已尝试: ${candidates.join(', ')}; ${detail}`,
);
}
/** 编辑器预览packages:// 协议下用 assetManager 读工程内 JSON不进 resources bundle */
function loadFromEditorAsset(): Promise<void> {
return new Promise((resolve, reject) => {
assetManager.loadAny({ uuid: EDITOR_DB_UUID }, (err: Error | null, asset: JsonAsset) => {
if (err || !asset?.json) {
reject(err ?? new Error('assets/level-data/levels-database.json 未找到'));
return;
}
ingestFile(asset.json as LevelDatabaseFile);
validateIngested();
console.log(
`[LevelDatabase] 已加载编辑器关卡库 ${sortedIds.length} 关 (`
+ `${sortedIds[0] ?? '?'}${sortedIds[sortedIds.length - 1] ?? '?'})`,
);
resolve();
});
});
}
async function waitForInjection(maxMs = 2500): Promise<LevelDatabaseFile | null> {
if (typeof window === 'undefined') return null;
const step = 50;
const tries = Math.ceil(maxMs / step);
for (let i = 0; i < tries; i++) {
const json = window.__tfrhLevelsDatabaseJson;
if (json) return json;
await new Promise((r) => setTimeout(r, step));
}
return null;
}
/** 异步加载AppBootstrap 启动时调用) */
export function loadLevelDatabase(): Promise<void> {
if (fileCache) return Promise.resolve();
if (loadPromise) return loadPromise;
loadPromise = (async () => {
try {
if (typeof window !== 'undefined' && window.__tfrhLevelsDatabaseJson) {
ingestFile(window.__tfrhLevelsDatabaseJson);
validateIngested();
console.log(`[LevelDatabase] 已注入 ${sortedIds.length}`);
return;
}
const injected = PREVIEW ? await waitForInjection(500) : null;
if (injected) {
ingestFile(injected);
validateIngested();
console.log(`[LevelDatabase] 已注入 ${sortedIds.length}`);
return;
}
if (!canUseHttpFetch()) {
await loadFromEditorAsset();
return;
}
if (PREVIEW) {
try {
await loadFromEditorAsset();
return;
} catch (e) {
console.warn('[LevelDatabase] 编辑器资源加载失败,尝试 fetch', e);
}
}
await loadFromNetwork();
} catch (e) {
loadPromise = null;
throw e;
}
})();
return loadPromise;
}
export function isLevelDatabaseReady(): boolean {
return fileCache !== null;
}
// --- 查 ---
export function getLevelConfig(levelID: number): LevelConfig | null {
return levelsMap[levelID] ?? null;
}
/** 是否在 Cocos 导出的关卡库中 */
export function hasLevel(levelID: number): boolean {
return levelID in levelsMap;
}
export function getLevelIds(): number[] {
return [...sortedIds];
}
export function getLevelCount(): number {
return sortedIds.length;
}
export const MIN_LEVEL_ID = (): number => sortedIds[0] ?? LEVEL_ID_BASE;
export const MAX_LEVEL_ID = (): number => sortedIds[sortedIds.length - 1] ?? LEVEL_ID_BASE;
export function nextLevelId(cur: number): number {
const i = sortedIds.indexOf(cur);
if (i < 0) return sortedIds[0] ?? cur;
return sortedIds[(i + 1) % sortedIds.length];
}
export function prevLevelId(cur: number): number {
const i = sortedIds.indexOf(cur);
if (i < 0) return sortedIds[0] ?? cur;
return sortedIds[(i - 1 + sortedIds.length) % sortedIds.length];
}
// --- 增改(运行时 / 编辑器脚本) ---
export function setLevel(config: LevelConfig): void {
levelsMap[config.levelID] = { ...config };
if (fileCache) {
fileCache.levels[String(config.levelID)] = levelsMap[config.levelID];
}
rebuildIndex();
}
export function addLevel(config: LevelConfig): void {
setLevel(config);
}
/** 删 */
export function removeLevel(levelID: number): boolean {
if (!(levelID in levelsMap)) return false;
delete levelsMap[levelID];
if (fileCache?.levels) {
delete fileCache.levels[String(levelID)];
}
rebuildIndex();
return true;
}
/** 导出当前内存数据(可写回 JSON 文件) */
export function exportDatabaseJson(): string {
const payload: LevelDatabaseFile = fileCache ?? {
version: 1,
generatedAt: new Date().toISOString(),
source: 'runtime',
levels: {},
};
payload.levels = Object.fromEntries(
sortedIds.map((id) => [String(id), levelsMap[id]]),
);
payload.stats = { total: sortedIds.length };
return JSON.stringify(payload, null, 2);
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "8e7c7877-6e68-405a-8f42-0219d15a1bb1",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,188 @@
import { Node, UITransform, Layers, Sprite } from 'cc';
import { LevelConfig } from './LevelTypes';
import { CommonDefine } from '../core/Define';
import { VisualAssets, normalizeTheme } from '../visual/VisualAssets';
import { layoutLevelTiles, sortIsoTiles } from './TileLayout';
import { LevelTileLayout } from './LevelTileLayout';
import { LevelMapData } from './LevelMapData';
import { GridSnapHelper } from './GridSnapHelper';
import { getThemeBorderDecorKey } from '../theme/ThemeRegistry';
import { centerLevelRoot, syncTileNodesFromConfig } from './LevelTileSync';
import { mergeLevelConfigWithMapData } from './LevelConfigMerge';
const UI_LAYER = Layers.Enum.UI_2D;
function resolveLevelTheme(config: LevelConfig): string {
return normalizeTheme(config.theme || 'silu');
}
/** 关卡预制体在 Canvas 下正确显示:统一 UI 层、按 JSON 对齐格子、刷新贴图 */
export class LevelDisplay {
/** 每关使用 config.theme 独立贴图,与全局 UI 主题无关 */
static async prepare(levelRoot: Node, config: LevelConfig) {
levelRoot.name = `Level_${config.levelID}`;
GridSnapHelper.purgeRuntimeGrids(levelRoot);
this.ensureUILayerTree(levelRoot);
config = mergeLevelConfigWithMapData(config, levelRoot);
const theme = resolveLevelTheme(config);
const tileCount = syncTileNodesFromConfig(levelRoot, config);
layoutLevelTiles(levelRoot, config);
const tileNames = this.collectTileNames(config);
await VisualAssets.preloadLevelTiles(theme, tileNames);
await this.refreshTileSprites(levelRoot, config, theme);
layoutLevelTiles(levelRoot, config);
centerLevelRoot(levelRoot, config);
let layout = levelRoot.getComponent(LevelTileLayout);
if (!layout) layout = levelRoot.addComponent(LevelTileLayout);
layout.setRuntimeConfig(config);
layout.applyLayout();
layout.scheduleOnce(() => {
if (!levelRoot?.isValid) return;
layoutLevelTiles(levelRoot, config);
centerLevelRoot(levelRoot, config);
GridSnapHelper.purgeRuntimeGrids(levelRoot);
}, 0);
this.syncMapDataComponent(levelRoot, config);
GridSnapHelper.purgeRuntimeGrids(levelRoot);
console.log(`[LevelDisplay] 关卡 ${config.levelID} 同步 ${tileCount} 格,地图主题=${theme}`);
}
static syncMapDataComponent(levelRoot: Node, config: LevelConfig) {
const md = levelRoot.getComponent(LevelMapData);
if (!md) return;
md.levelID = config.levelID;
md.theme = resolveLevelTheme(config);
if (config.ground && Object.keys(config.ground).length > 0) {
md.groundJson = JSON.stringify(config.ground);
}
if (config.border && Object.keys(config.border).length > 0) {
md.borderJson = JSON.stringify(config.border);
}
}
static collectTileNames(config: LevelConfig): string[] {
const names = new Set<string>(['Baseblock', 'JumpBlock', 'WallBlock', 'kuai11']);
const decorKey = getThemeBorderDecorKey(config.theme);
if (decorKey) names.add(decorKey);
for (const v of Object.values(config.ground ?? {})) {
const tile = typeof v === 'string' ? v.trim() : (typeof v === 'number' ? String(v) : '');
if (tile) names.add(tile);
}
for (const v of Object.values(config.border ?? {})) {
if (v === true) continue;
const tile = typeof v === 'string' ? v.trim() : (typeof v === 'number' ? String(v) : '');
if (tile) names.add(tile);
}
return Array.from(names);
}
static ensureUILayerTree(root: Node) {
const walk = (node: Node | null | undefined) => {
if (!node?.isValid) return;
node.layer = UI_LAYER;
node.active = true;
let ui = node.getComponent(UITransform);
if (!ui) ui = node.addComponent(UITransform);
if (node === root || node.name === 'Ground' || node.name === 'Border') {
ui.setAnchorPoint(0, 0);
ui.setContentSize(1, 1);
}
for (const ch of node.children) walk(ch);
};
walk(root);
}
static sortIsoLayers(levelRoot: Node) {
sortIsoTiles(levelRoot);
}
private static queueTileSprite(
jobs: Promise<void>[],
node: Node,
tileName: string,
cellX: number,
cellY: number,
theme: string,
) {
jobs.push(VisualAssets.applyNamedTile(node, tileName, 255, cellX, cellY, theme));
}
/** 仅刷新砖块贴图(实体已由 GameController 单独处理) */
static async refreshTiles(levelRoot: Node, config: LevelConfig): Promise<void> {
const theme = resolveLevelTheme(config);
await VisualAssets.preloadLevelTiles(theme, this.collectTileNames(config));
await this.refreshTileSprites(levelRoot, config, theme);
}
static async refreshTileSprites(levelRoot: Node, config: LevelConfig, theme: string) {
const jobs: Promise<void>[] = [];
const processLayer = (layer: Node | null, isBorder: boolean) => {
if (!layer) return;
for (const ch of layer.children) {
if (!ch?.active) continue;
const m = isBorder
? /^b_(-?\d+)_(-?\d+)$/.exec(ch.name)
: /^g_(-?\d+)_(-?\d+)$/.exec(ch.name);
if (!m) continue;
const key = `${m[1]},${m[2]}`;
const cx = parseInt(m[1], 10);
const cy = parseInt(m[2], 10);
let tileName: string;
if (isBorder) {
tileName = config.border?.[key] as string | boolean | undefined;
if (tileName === true || tileName === undefined) tileName = 'WallBlock';
if (typeof tileName !== 'string') tileName = 'WallBlock';
} else {
tileName = config.ground?.[key] ?? CommonDefine.BlockBase;
}
this.queueTileSprite(jobs, ch, tileName, cx, cy, theme);
}
};
processLayer(levelRoot.getChildByName('Ground'), false);
processLayer(levelRoot.getChildByName('Border'), true);
const tiles = levelRoot.getChildByName('Tiles');
if (tiles) {
for (const ch of tiles.children) {
if (!ch.active) continue;
const mg = /^g_(-?\d+)_(-?\d+)$/.exec(ch.name);
const mb = /^b_(-?\d+)_(-?\d+)$/.exec(ch.name);
if (mg) {
const key = `${mg[1]},${mg[2]}`;
this.queueTileSprite(
jobs, ch,
config.ground?.[key] ?? CommonDefine.BlockBase,
parseInt(mg[1], 10), parseInt(mg[2], 10), theme,
);
} else if (mb) {
const key = `${mb[1]},${mb[2]}`;
let tileName = config.border?.[key];
if (tileName === true || tileName === undefined) tileName = 'WallBlock';
if (typeof tileName !== 'string') tileName = 'WallBlock';
this.queueTileSprite(
jobs, ch, tileName,
parseInt(mb[1], 10), parseInt(mb[2], 10), theme,
);
}
}
}
await Promise.all(jobs);
layoutLevelTiles(levelRoot, config);
let n = 0;
const count = (node: Node) => {
if (node.active && node.getComponent(Sprite)?.spriteFrame) n++;
for (const c of node.children) count(c);
};
count(levelRoot);
console.log(`[LevelDisplay] 关卡 ${config.levelID} 地图主题=${theme} 已刷新 ${n}`);
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "81f9083e-1e28-48b0-bf0e-4d3b5b6dce21",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,7 @@
/** 与 Unity / 主站 config.js BEGINNING_REAL_LVID 一致;首关 91601 */
export const LEVEL_ID_BASE = 91601;
/** 主站合法关卡 ID从 BEGINNING_REAL_LVID91601起 */
export function isGameLevelId(levelID: number): boolean {
return Number.isFinite(levelID) && levelID >= LEVEL_ID_BASE;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "f0858c47-3aeb-4416-997f-637c891c074c",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,56 @@
import { _decorator, Component } from 'cc';
const { ccclass, property } = _decorator;
/**
* 挂在关卡预制体根节点(与 Unity LevelN.prefab 同级)。
* 地图碰撞/可走数据;视觉由预制体子节点 Ground、Border 上的 Sprite 呈现。
*/
@ccclass('LevelMapData')
export class LevelMapData extends Component {
@property
levelID = 0;
@property({ multiline: true, displayName: 'Ground JSON' })
groundJson = '{}';
@property({ multiline: true, displayName: 'Border JSON' })
borderJson = '{}';
@property({ displayName: '地图主题 (silu/sanxing/…)' })
theme = 'silu';
parseGround(): Map<string, string> {
return LevelMapData.parseRecord(this.groundJson);
}
parseBorder(): Set<string> {
return LevelMapData.parseBorder(this.borderJson);
}
static parseRecord(json: string): Map<string, string> {
const m = new Map<string, string>();
try {
const o = JSON.parse(json || '{}') as Record<string, string>;
for (const [k, v] of Object.entries(o)) {
if (typeof v === 'string' && v) m.set(k, v);
}
} catch (e) {
console.warn('[LevelMapData] groundJson parse error', e);
}
return m;
}
static parseBorder(json: string): Set<string> {
const s = new Set<string>();
try {
const o = JSON.parse(json || '{}') as Record<string, boolean>;
for (const k of Object.keys(o)) {
if (o[k]) s.add(k);
}
} catch (e) {
console.warn('[LevelMapData] borderJson parse error', e);
}
return s;
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "d4e5f6a7-b8c9-4d0e-1f2a-3b4c5d6e7f8a",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,146 @@
import { assetManager, Prefab, resources } from 'cc';
import { PREVIEW } from 'cc/env';
import { ensureResourcesBundle } from '../core/ResourcesBundle';
import {
getLevelPrefabUuid,
loadLevelPrefabUuidIndex,
parseLevelIdFromPrefabPath,
shouldTryEditorUuidLoad,
} from './LevelPrefabUuidIndex';
const LEVEL_PREFAB_BUNDLE = 'level-prefabs';
let cachedLevelBundle: assetManager.Bundle | null = null;
let levelBundlePromise: Promise<assetManager.Bundle> | null = null;
function loadLevelPrefabBundle(): Promise<assetManager.Bundle> {
if (cachedLevelBundle) return Promise.resolve(cachedLevelBundle);
if (levelBundlePromise) return levelBundlePromise;
levelBundlePromise = new Promise((resolve, reject) => {
assetManager.loadBundle(LEVEL_PREFAB_BUNDLE, (err, bundle) => {
levelBundlePromise = null;
if (err || !bundle) {
reject(err ?? new Error(`bundle "${LEVEL_PREFAB_BUNDLE}" unavailable`));
return;
}
cachedLevelBundle = bundle;
resolve(bundle);
});
});
return levelBundlePromise;
}
function loadPrefabFromBundle(bundle: assetManager.Bundle, path: string): Promise<Prefab> {
return new Promise((resolve, reject) => {
bundle.load(path, Prefab, (err, prefab) => {
if (!err && prefab) resolve(prefab);
else reject(err ?? new Error(`missing prefab: ${path}`));
});
});
}
function loadPrefabFromResources(path: string): Promise<Prefab> {
return new Promise((resolve, reject) => {
resources.load(path, Prefab, (err, prefab) => {
if (!err && prefab) resolve(prefab);
else reject(err ?? new Error(`missing prefab: ${path}`));
});
});
}
function loadPrefabByUuid(uuid: string): Promise<Prefab> {
return new Promise((resolve, reject) => {
assetManager.loadAny({ uuid }, (err: Error | null, asset: Prefab) => {
if (err || !asset) {
reject(err ?? new Error(`missing prefab uuid: ${uuid}`));
return;
}
resolve(asset);
});
});
}
/** bundle / resources 内可能的路径(子目录 level-prefabs/ 或 bundle 根下 LevelN */
function prefabPathCandidates(path: string): string[] {
const trimmed = path.trim();
const base = trimmed.replace(/^level-prefabs\//, '');
// bundle config 路径键为 level-prefabs/LevelN裸 LevelN 会触发 loadAny 解析错误
return [...new Set([trimmed, `level-prefabs/${base}`])];
}
async function loadFirstAvailable(
loader: (p: string) => Promise<Prefab>,
paths: string[],
): Promise<Prefab> {
let lastErr: unknown;
for (const p of paths) {
try {
return await loader(p);
} catch (e) {
lastErr = e;
}
}
throw lastErr ?? new Error(`missing prefab: ${paths[0]}`);
}
async function tryLoadFromEditorUuid(path: string): Promise<Prefab | null> {
if (!shouldTryEditorUuidLoad()) return null;
await loadLevelPrefabUuidIndex();
const levelId = parseLevelIdFromPrefabPath(path);
if (levelId === undefined) return null;
const uuid = getLevelPrefabUuid(levelId);
if (!uuid) return null;
try {
const prefab = await loadPrefabByUuid(uuid);
console.log(`[LevelPrefabLoader] 编辑器 uuid 加载 Level${levelId}`);
return prefab;
} catch (e) {
console.warn(`[LevelPrefabLoader] uuid 加载 Level${levelId} 失败`, e);
return null;
}
}
/** 进关前由 loader 注入:按 levelId 下载对应关卡包 */
async function ensureLevelPackForPath(path: string): Promise<void> {
const levelId = parseLevelIdFromPrefabPath(path);
if (levelId === undefined) return;
const hook = (globalThis as { __tfrhEnsureLevelPack?: (id: number) => Promise<void> }).__tfrhEnsureLevelPack;
if (typeof hook === 'function') await hook(levelId);
cachedLevelBundle = null;
levelBundlePromise = null;
}
/** 优先从 level-prefabs 分包加载;编辑器预览可回退 uuid / resources */
export async function loadLevelPrefab(path: string): Promise<Prefab> {
await ensureResourcesBundle();
await ensureLevelPackForPath(path);
const candidates = prefabPathCandidates(path);
if (PREVIEW) {
const byUuid = await tryLoadFromEditorUuid(path);
if (byUuid?.isValid) return byUuid;
}
let bundleErr: unknown = null;
try {
const bundle = await loadLevelPrefabBundle();
return await loadFirstAvailable((p) => loadPrefabFromBundle(bundle, p), candidates);
} catch (err) {
bundleErr = err;
console.warn('[LevelPrefabLoader] level-prefabs bundle 加载失败', err);
}
if (!PREVIEW) {
const byUuid = await tryLoadFromEditorUuid(path);
if (byUuid?.isValid) return byUuid;
}
console.error(
'[LevelPrefabLoader] 关卡预制体未找到。请确认 bundle-level-prefabs 已标记为 Asset Bundle '
+ `"${LEVEL_PREFAB_BUNDLE}",并运行: python3 tools/bake_cocos_level_prefabs.py`,
bundleErr,
);
throw bundleErr instanceof Error
? bundleErr
: new Error(`missing prefab: ${path}`);
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "18fe4dd5-2cb5-4201-ac1c-88b839c28747",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,13 @@
import { LevelConfig } from './LevelTypes';
/** 关卡预制体在 resources 下的路径(与 Unity Assets/Prefabs/Level/LevelN.prefab 对应) */
function asTrimmedPath(v: unknown): string | undefined {
if (v == null) return undefined;
const s = (typeof v === 'string' ? v : String(v)).trim();
return s || undefined;
}
/** 与 Unity levelPath 一致Level91601.prefab → level-prefabs/Level91601 */
export function getLevelPrefabResourcePath(levelID: number, _config?: LevelConfig | null): string {
return `level-prefabs/Level${levelID}`;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "2d4bcfb4-7972-4c8a-9f0e-a9d07948eb94",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,58 @@
import { assetManager, JsonAsset } from 'cc';
import { PREVIEW } from 'cc/env';
/** assets/level-data/level-prefab-uuids.json烘焙脚本生成编辑器预览按 uuid 加载 prefab */
const EDITOR_UUID_INDEX = 'f7e8d9c0-b1a2-4c3d-9e8f-1a2b3c4d5e6f';
let uuidByLevelId: Record<number, string> | null = null;
let loadPromise: Promise<void> | null = null;
function ingest(data: Record<string, string>) {
const out: Record<number, string> = {};
for (const [k, v] of Object.entries(data ?? {})) {
const id = parseInt(k, 10);
const uuid = typeof v === 'string' ? v.trim() : '';
if (!Number.isNaN(id) && uuid) out[id] = uuid;
}
uuidByLevelId = out;
}
function loadFromEditorAsset(): Promise<void> {
return new Promise((resolve, reject) => {
assetManager.loadAny({ uuid: EDITOR_UUID_INDEX }, (err: Error | null, asset: JsonAsset) => {
if (err || !asset?.json) {
reject(err ?? new Error('level-prefab-uuids.json 未找到'));
return;
}
ingest(asset.json as Record<string, string>);
resolve();
});
});
}
/** 编辑器预览levelId → prefab uuid */
export function loadLevelPrefabUuidIndex(): Promise<void> {
if (uuidByLevelId) return Promise.resolve();
if (loadPromise) return loadPromise;
loadPromise = loadFromEditorAsset().catch((e) => {
console.warn('[LevelPrefabUuidIndex] 加载失败,将仅用 bundle 路径', e);
uuidByLevelId = {};
}).then(() => undefined);
return loadPromise;
}
export function getLevelPrefabUuid(levelId: number): string | undefined {
return uuidByLevelId?.[levelId];
}
/** level-prefabs/Level91601 → 91601 */
export function parseLevelIdFromPrefabPath(path: string): number | undefined {
const m = /Level(\d+)\s*$/.exec(path.trim());
if (!m) return undefined;
const id = parseInt(m[1], 10);
return Number.isNaN(id) ? undefined : id;
}
export function shouldTryEditorUuidLoad(): boolean {
return PREVIEW;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "cd170235-d6e1-4de2-a497-4d24a9b53f34",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -1,47 +1,94 @@
import { Direction } from '../core/Define';
/**
* 关卡注册表 — 统一从 LevelDatabase单一 JSON读取
*/
import { LevelConfig } from './LevelTypes';
import { LEVELS_600 } from './levels-600.generated';
import { LEVEL_ID_BASE } from './LevelIds';
/** 额外关卡(多人等) */
const EXTRA_LEVELS: Record<number, LevelConfig> = {
601: {
levelID: 601,
export { LEVEL_ID_BASE, isGameLevelId } from './LevelIds';
import {
getLevelConfig as dbGet,
hasLevel as dbHas,
getLevelIds as dbIds,
getLevelCount as dbCount,
nextLevelId as dbNext,
prevLevelId as dbPrev,
setLevel as dbSet,
removeLevel as dbRemove,
addLevel as dbAdd,
loadLevelDatabase,
isLevelDatabaseReady,
MIN_LEVEL_ID as dbMin,
MAX_LEVEL_ID as dbMax,
} from './LevelDatabase';
export { loadLevelDatabase, isLevelDatabaseReady };
export function getMinLevelId(): number {
return isLevelDatabaseReady() ? dbMin() : LEVEL_ID_BASE;
}
export function getMaxLevelId(): number {
return isLevelDatabaseReady() ? dbMax() : LEVEL_ID_BASE;
}
/**
* 主站 SendMessage(levelID) → 查 Cocos 关卡库;无条目时按 Level{id}.prefab 加载。
*/
export function resolveLevelConfig(levelID: number): LevelConfig | null {
const cfg = dbGet(levelID);
if (cfg) return cfg;
if (levelID <= 0) return null;
return {
levelID,
boundary: { x: 20, y: 20 },
spawns: [
{ x: 0, y: 0, kind: 'player', playerDirection: Direction.North },
{ x: 6, y: 6, kind: 'prop' },
{ x: -1, y: 2, kind: 'prop' },
],
},
999001: {
levelID: 999001,
boundary: { x: 999, y: 999 },
spawns: [
{ x: -9, y: -9, kind: 'player', playerDirection: Direction.South },
{ x: 9, y: 9, kind: 'player', playerDirection: Direction.North },
{ x: -9, y: -10, kind: 'vehicle', vehicleDirection: Direction.North },
{ x: 9, y: 10, kind: 'vehicle', vehicleDirection: Direction.South },
],
},
};
spawns: [],
cocosPrefab: `level-prefabs/Level${levelID}`,
theme: 'sanxing',
};
}
const allLevels: Record<number, LevelConfig> = {
...LEVELS_600,
...EXTRA_LEVELS,
};
/** @deprecated 请用 getMinLevelId() / getMaxLevelId() */
export let MIN_LEVEL_ID = LEVEL_ID_BASE;
/** @deprecated 请用 getMaxLevelId() */
export let MAX_LEVEL_ID = LEVEL_ID_BASE;
export function refreshLevelIdBounds() {
MIN_LEVEL_ID = getMinLevelId();
MAX_LEVEL_ID = getMaxLevelId();
}
export function getLevelConfig(levelID: number): LevelConfig | null {
return allLevels[levelID] ?? null;
return dbGet(levelID);
}
export function hasLevel(levelID: number): boolean {
return levelID in allLevels;
return dbHas(levelID);
}
export function registerLevel(config: LevelConfig) {
allLevels[config.levelID] = config;
dbSet(config);
}
export function addLevel(config: LevelConfig) {
dbAdd(config);
}
export function removeLevel(levelID: number) {
dbRemove(levelID);
}
export function getLevelCount(): number {
return Object.keys(allLevels).length;
return dbCount();
}
export function getLevelIds(): number[] {
return dbIds();
}
export function nextLevelId(cur: number): number {
return dbNext(cur);
}
export function prevLevelId(cur: number): number {
return dbPrev(cur);
}

View File

@@ -0,0 +1,18 @@
import { Vec3 } from 'cc';
import { LevelConfig } from './LevelTypes';
/** 关卡运行时上下文(避免 TileLayout ↔ GameController 循环依赖) */
export interface LevelRuntimeContext {
getCurLevel(): LevelConfig | null;
worldToCell(world: Vec3): Vec3;
}
let runtimeContext: LevelRuntimeContext | null = null;
export function setLevelRuntimeContext(ctx: LevelRuntimeContext | null): void {
runtimeContext = ctx;
}
export function getLevelRuntimeContext(): LevelRuntimeContext | null {
return runtimeContext;
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "1343d397-b505-4672-b30b-83e1f1628772",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,53 @@
import { _decorator, Component } from 'cc';
import { LevelMapData } from './LevelMapData';
import { layoutLevelTiles } from './TileLayout';
import { LevelConfig } from './LevelTypes';
const { ccclass, executionOrder } = _decorator;
/**
* 挂在 LevelN 根节点:运行时首帧按 levels-database 重新对齐砖块。
* 解决「编辑器 JSON 正确、运行预览叠在一起 / 只显示一块」。
*/
@ccclass('LevelTileLayout')
@executionOrder(-50)
export class LevelTileLayout extends Component {
/** 运行时权威配置(来自 levels-database.json优先于预制体 LevelMapData */
runtimeConfig: LevelConfig | null = null;
start() {
this.applyLayout();
this.scheduleOnce(() => this.applyLayout(), 0);
}
setRuntimeConfig(config: LevelConfig) {
this.runtimeConfig = config;
}
applyLayout() {
const config = this.runtimeConfig ?? this.configFromMapData();
if (!config) return;
layoutLevelTiles(this.node, config);
}
private configFromMapData(): LevelConfig | null {
const md = this.getComponent(LevelMapData);
if (!md) return null;
let ground: LevelConfig['ground'];
let border: LevelConfig['border'];
try {
ground = JSON.parse(md.groundJson || '{}');
border = JSON.parse(md.borderJson || '{}');
} catch {
return null;
}
return {
levelID: md.levelID,
boundary: { x: 0, y: 0 },
spawns: [],
theme: md.theme || 'silu',
ground,
border,
};
}
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "22737b72-530f-4caf-8222-1485dde33239",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -0,0 +1,96 @@
import { Layers, Node, Sprite, UITransform, Vec3 } from 'cc';
import { cellToWorldCenter, parseCellKey, tileNodeName } from '../core/GridCoords';
import { LevelConfig } from './LevelTypes';
import { setupLayerContainer } from './TileLayout';
const UI_LAYER = Layers.Enum.UI_2D;
function ensureLayer(root: Node, name: 'Ground' | 'Border'): Node {
let layer = root.getChildByName(name);
if (!layer) {
layer = new Node(name);
layer.parent = root;
layer.layer = UI_LAYER;
}
setupLayerContainer(layer);
return layer;
}
function ensureTile(layer: Node, layerKind: 'ground' | 'border', x: number, y: number): Node {
const nodeName = tileNodeName(layerKind, x, y);
let node = layer.getChildByName(nodeName);
if (!node) {
node = new Node(nodeName);
node.parent = layer;
node.layer = UI_LAYER;
node.addComponent(UITransform);
node.addComponent(Sprite);
}
node.active = true;
return node;
}
/** 按 levels-database.json 补齐 / 隐藏砖块节点(与关卡编辑器数据一致) */
export function syncTileNodesFromConfig(levelRoot: Node, config: LevelConfig): number {
const ground = ensureLayer(levelRoot, 'Ground');
const border = ensureLayer(levelRoot, 'Border');
const wantGround = new Set(Object.keys(config.ground ?? {}));
const wantBorder = new Set(Object.keys(config.border ?? {}));
let n = 0;
for (const key of wantGround) {
const p = parseCellKey(key);
if (!p) continue;
ensureTile(ground, 'ground', p.x, p.y);
n++;
}
for (const key of wantBorder) {
const p = parseCellKey(key);
if (!p) continue;
ensureTile(border, 'border', p.x, p.y);
n++;
}
for (const ch of ground.children) {
const m = /^g_(-?\d+)_(-?\d+)$/.exec(ch.name);
if (!m) continue;
const key = `${m[1]},${m[2]}`;
ch.active = wantGround.has(key);
}
for (const ch of border.children) {
const m = /^b_(-?\d+)_(-?\d+)$/.exec(ch.name);
if (!m) continue;
const key = `${m[1]},${m[2]}`;
ch.active = wantBorder.has(key);
}
return n;
}
/** 关卡包围盒中心(格子 pivot 落点),用于居中显示 */
export function computeLevelCenterOffset(config: LevelConfig): Vec3 {
const keys = [
...Object.keys(config.ground ?? {}),
...Object.keys(config.border ?? {}),
];
if (!keys.length) return new Vec3(0, 0, 0);
let minX = Infinity;
let maxX = -Infinity;
let minY = Infinity;
let maxY = -Infinity;
for (const key of keys) {
const p = parseCellKey(key);
if (!p) continue;
const w = cellToWorldCenter(new Vec3(p.x, p.y, 0));
minX = Math.min(minX, w.x);
maxX = Math.max(maxX, w.x);
minY = Math.min(minY, w.y);
maxY = Math.max(maxY, w.y);
}
return new Vec3(-(minX + maxX) * 0.5, -(minY + maxY) * 0.5, 0);
}
export function centerLevelRoot(levelRoot: Node, config: LevelConfig) {
const off = computeLevelCenterOffset(config);
levelRoot.setPosition(off.x, off.y, 0);
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "84e4a32b-5cd7-475c-9842-fd096a1c72fa",
"files": [],
"subMetas": {},
"userData": {}
}

View File

@@ -1,4 +1,5 @@
import { Direction } from '../core/Define';
import type { PropPlacement } from './EntitySpawnPlacement';
export type SpawnKind = 'player' | 'vehicle' | 'prop' | 'prop_decor' | 'enemy';
@@ -8,6 +9,32 @@ export interface SpawnConfig {
kind: SpawnKind;
playerDirection?: Direction;
vehicleDirection?: Direction;
/** 相对默认尺寸的比例1 = 默认,可单独调大/调小 */
scale?: number;
/** 贴图路径(相对 assets/resources无 .png可拾取物可单独指定 */
texture?: string;
/**
* 可拾取物高度block=砖块上Unity Propground=空地Unity nProp
* 未指定时按格子是否有 ground/border 推断。
*/
propPlacement?: PropPlacement;
}
/** 关卡级实体贴图(留空则按 theme 自动推断) */
export interface LevelEntityTextures {
playerFront?: string;
playerBack?: string;
vehicleNorth?: string;
vehicleEast?: string;
vehicleSouth?: string;
vehicleWest?: string;
/** @deprecated */
vehicleFront?: string;
/** @deprecated */
vehicleBack?: string;
prop?: string;
/** 空地可拾取物贴图Unity nProp*,留空则从 prop 推导) */
propGround?: string;
}
/** 稀疏地块key 为 "x,y" */
@@ -15,8 +42,17 @@ export interface LevelConfig {
levelID: number;
boundary: { x: number; y: number };
spawns: SpawnConfig[];
/** Ground 层 tile 名 */
/** Unity 原 Prefab 路径(仅存档参考) */
unityPrefab?: string;
/** Cocos resources 路径,默认 level-prefabs/Level{id} */
cocosPrefab?: string;
/** Ground 层 tile 名Baseblock / JumpBlock与 Unity Tilemap Ground 一致) */
ground?: Record<string, string>;
/** Border 阻挡格 */
border?: Record<string, boolean>;
/** Border 阻挡格(与 Unity Tilemap Border 一致) */
/** true 或瓦片名WallBlock、kuai11 等) */
border?: Record<string, boolean | string>;
/** 烘焙/调色板主题silu、sanxing、snow … */
theme?: string;
/** 本关角色/载具/可拾取物贴图(优先于 theme 自动映射) */
entityTextures?: LevelEntityTextures;
}

View File

@@ -0,0 +1,472 @@
import { Node, UITransform, Vec3, Layers, Sprite } from 'cc';
import { cellToWorldCenter, parseTileNodeName } from '../core/GridCoords';
import { CommonDefine } 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';
const UI_LAYER = Layers.Enum.UI_2D;
/**
* 遮挡分类(仅在关卡加载时按格子坐标排序一次)
* - walkable : Baseblock / JumpBlock永不在 actor 之上
* - wall : Border / WallBlock与 actor 按 iso 深度比较
* - actor : 角色 / 载具
* - pickable : 可拾取物Prop永不在墙砖/路径砖之下;玩家仍在其上
* - scenery : 其它
*/
type DrawKind = 'walkable' | 'wall' | 'actor' | 'pickable' | 'scenery';
const DRAW_RANK = {
groundProp: 10,
groundTile: 20,
borderTile: 22,
vehicle: 28,
coin: 30,
propDecor: 30,
player: 40,
} as const;
interface DrawEntry {
node: Node;
x: number;
y: number;
rank: number;
kind: DrawKind;
}
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 === 'WallBlock' || tileName === 'kuai11';
}
function resolveGroundTileName(cellX: number, cellY: number, config?: LevelConfig): string {
return config?.ground?.[`${cellX},${cellY}`] ?? CommonDefine.BlockBase;
}
function classifyTileKind(
parsed: { layer: 'ground' | 'border'; x: number; y: number },
config?: LevelConfig,
): DrawKind {
if (parsed.layer === 'border') return 'wall';
const name = resolveGroundTileName(parsed.x, parsed.y, config);
if (isWalkablePathTile(name)) return 'walkable';
if (isWallTileName(name)) return 'wall';
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 entityRank(node: Node): number {
const n = safeNodeName(node);
if (isPlayerEntityName(n)) return DRAW_RANK.player;
if (isVehicleEntityName(n)) return DRAW_RANK.vehicle;
if (isCoinEntityName(n)) return DRAW_RANK.coin;
if (n === 'PropDecor' || n.startsWith('PropDecor_')) return DRAW_RANK.propDecor;
return DRAW_RANK.groundProp;
}
function tileRank(kind: DrawKind, layer: 'ground' | 'border'): number {
if (kind === 'walkable') return DRAW_RANK.groundTile;
if (kind === 'wall') return DRAW_RANK.borderTile;
return layer === 'border' ? DRAW_RANK.borderTile : DRAW_RANK.groundTile;
}
/** 等距深度:返回值 > 0 表示 a 应排在 b 之后(更靠前) */
export function compareIsoDrawOrder(cellX: number, cellY: number, otherX: number, otherY: number): number {
const ka = cellX + cellY;
const kb = otherX + otherY;
if (ka !== kb) return kb - ka;
return otherX - cellX;
}
function wallFaceSamples(wallX: number, wallY: number): { x: number; y: number }[] {
return [{ x: wallX, y: wallY - 1 }, { x: wallX - 1, y: wallY }];
}
function compareWallActorIso(actor: DrawEntry, wall: DrawEntry): number {
let actorAhead = 0;
for (const face of wallFaceSamples(wall.x, wall.y)) {
const iso = compareIsoDrawOrder(actor.x, actor.y, face.x, face.y);
if (iso < 0) return iso;
if (iso > actorAhead) actorAhead = iso;
}
return actorAhead;
}
function compareDrawEntries(a: DrawEntry, b: DrawEntry): number {
if (a.kind === 'walkable' && b.kind === 'actor') return -1;
if (b.kind === 'walkable' && a.kind === 'actor') return 1;
// 可拾取物:永远在墙砖/路径砖/载具之上;玩家仍在其上
if (a.kind === 'pickable' && b.kind === 'actor') {
return isPlayerEntityName(safeNodeName(b.node)) ? -1 : 1;
}
if (a.kind === 'actor' && b.kind === 'pickable') {
return isPlayerEntityName(safeNodeName(a.node)) ? 1 : -1;
}
if (a.kind === 'pickable' && b.kind !== 'pickable') return 1;
if (b.kind === 'pickable' && a.kind !== 'pickable') return -1;
if (a.kind === 'actor' && b.kind === 'wall') {
const wallIso = compareWallActorIso(a, b);
if (wallIso !== 0) return wallIso;
}
if (a.kind === 'wall' && b.kind === 'actor') {
const wallIso = compareWallActorIso(b, a);
if (wallIso !== 0) return -wallIso;
}
const iso = compareIsoDrawOrder(a.x, a.y, b.x, b.y);
if (iso !== 0) return iso;
return a.rank - b.rank;
}
/** 实体所在关卡根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移动中不重算 */
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 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 mov = node.getComponent('Movement') as {
getCommittedCell?: () => Vec3 | null;
getSpawnCell?: () => Vec3 | null;
} | null;
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);
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>): DrawEntry[] {
const config = resolveLevelConfig();
const entries: DrawEntry[] = [];
const pushTile = (node: Node, parsed: { layer: 'ground' | 'border'; x: number; y: number }) => {
if (seen.has(node)) return;
seen.add(node);
const kind = classifyTileKind(parsed, config);
entries.push({
node,
x: parsed.x,
y: parsed.y,
rank: tileRank(kind, parsed.layer),
kind,
});
};
forEachTileChild(levelRoot, pushTile);
for (const ch of tilesRoot.children) {
if (!ch?.isValid || 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>): DrawEntry[] {
const entries: DrawEntry[] = [];
const pushEntity = (node: Node | null | undefined) => {
if (!node?.isValid || seen.has(node) || !isEntityDrawNode(node)) return;
seen.add(node);
const cell = getEntityLogicCell(node);
entries.push({
node,
x: cell.x,
y: cell.y,
rank: entityRank(node),
kind: classifyEntityKind(node),
});
};
for (const ch of levelRoot.children) pushEntity(ch);
for (const ch of tilesRoot.children) pushEntity(ch);
return entries;
}
function applyDrawOrder(tilesRoot: Node, entries: DrawEntry[]) {
const sorted = entries.slice().sort(compareDrawEntries);
const inSort = new Set<Node>();
for (const { node } of sorted) {
if (!node.isValid) continue;
inSort.add(node);
if (isEntityDrawNode(node)) ensureEntityUILayer(node);
if (node.parent !== tilesRoot) {
const lp = node.position.clone();
node.parent = tilesRoot;
node.setPosition(lp);
}
}
for (let i = 0; i < sorted.length; i++) {
const node = sorted[i].node;
if (!node.isValid) continue;
if (node.getSiblingIndex() !== i) node.setSiblingIndex(i);
}
let tail = sorted.length;
for (const ch of [...tilesRoot.children]) {
if (!ch.isValid || inSort.has(ch)) continue;
if (ch.getSiblingIndex() !== tail) ch.setSiblingIndex(tail);
tail++;
}
}
function finalizeLevelRootOrder(levelRoot: Node, tilesRoot: Node) {
for (const name of ['Ground', 'Border'] as const) {
const layer = levelRoot.getChildByName(name);
if (layer && layer.children.length === 0) {
layer.setSiblingIndex(0);
}
}
tilesRoot.setSiblingIndex(levelRoot.children.length - 1);
}
/**
* 关卡加载 / 重置时按格子坐标排序一次;移动过程中不再改 sibling。
*/
export function sortIsoTiles(levelRoot: Node) {
if (!levelRoot?.isValid) return;
const tilesRoot = ensureTilesRoot(levelRoot);
const seen = new Set<Node>();
const tileEntries = collectTileEntries(levelRoot, tilesRoot, seen);
const entityEntries = collectEntityEntries(levelRoot, tilesRoot, seen);
applyDrawOrder(tilesRoot, [...tileEntries, ...entityEntries]);
finalizeLevelRootOrder(levelRoot, tilesRoot);
}
/** @deprecated 与 sortIsoTiles 相同 */
export function refreshIsoEntityDrawOrder(levelRoot: Node) {
sortIsoTiles(levelRoot);
}
/** @deprecated 与 sortIsoTiles 相同 */
export function refreshIsoDrawOrder(levelRoot: Node) {
sortIsoTiles(levelRoot);
}
/** @deprecated 与 sortIsoTiles 相同 */
export function refreshIsoDrawOrderImmediate(levelRoot: Node) {
sortIsoTiles(levelRoot);
}
export function bringEntityNodesToFront(levelRoot: Node, _opts?: EntityDrawOrderOptions) {
sortIsoTiles(levelRoot);
}
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 = ['Ground', 'Border', 'Tiles'] 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 {
if (layer === 'ground') {
return config.ground?.[key] ?? CommonDefine.BlockBase;
}
let v = config.border?.[key];
if (v === true || v === undefined) return 'WallBlock';
if (typeof v === 'string') return v;
return 'WallBlock';
}
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);
}

View File

@@ -0,0 +1,9 @@
{
"ver": "4.0.24",
"importer": "typescript",
"imported": true,
"uuid": "8d236aa5-d44c-4bae-af60-2346b314e8ba",
"files": [],
"subMetas": {},
"userData": {}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long