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:
25
assets/scripts/level/EntitySpawnDefaults.ts
Normal file
25
assets/scripts/level/EntitySpawnDefaults.ts
Normal 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,
|
||||
};
|
||||
@@ -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": {}
|
||||
185
assets/scripts/level/EntitySpawnPlacement.ts
Normal file
185
assets/scripts/level/EntitySpawnPlacement.ts
Normal 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≈100):Prop ≈ +14px,nProp ≈ -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 含主题站立 Y,vehicle 为 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';
|
||||
}
|
||||
9
assets/scripts/level/EntitySpawnPlacement.ts.meta
Normal file
9
assets/scripts/level/EntitySpawnPlacement.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "20691257-985a-4a52-96da-6696502e60ff",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
366
assets/scripts/level/GridSnapHelper.ts
Normal file
366
assets/scripts/level/GridSnapHelper.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
9
assets/scripts/level/GridSnapHelper.ts.meta
Normal file
9
assets/scripts/level/GridSnapHelper.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "739b26eb-64a3-4dd7-a084-cbe854d98a36",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
69
assets/scripts/level/LevelConfigMerge.ts
Normal file
69
assets/scripts/level/LevelConfigMerge.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
9
assets/scripts/level/LevelConfigMerge.ts.meta
Normal file
9
assets/scripts/level/LevelConfigMerge.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "4a788bad-35f7-49ff-9c2e-129ae9d74c7b",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
282
assets/scripts/level/LevelDatabase.ts
Normal file
282
assets/scripts/level/LevelDatabase.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* 关卡数据库 — 由 Cocos 工程导出(level-prefabs LevelMapData + 编辑器 spawns)
|
||||
*
|
||||
* - 编辑器 ▶ 预览:从 assets/level-data/ 加载(不在 resources bundle,fetch 不可用)
|
||||
* - 主站 / 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);
|
||||
}
|
||||
9
assets/scripts/level/LevelDatabase.ts.meta
Normal file
9
assets/scripts/level/LevelDatabase.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "8e7c7877-6e68-405a-8f42-0219d15a1bb1",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
188
assets/scripts/level/LevelDisplay.ts
Normal file
188
assets/scripts/level/LevelDisplay.ts
Normal 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} 块`);
|
||||
}
|
||||
}
|
||||
9
assets/scripts/level/LevelDisplay.ts.meta
Normal file
9
assets/scripts/level/LevelDisplay.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "81f9083e-1e28-48b0-bf0e-4d3b5b6dce21",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
7
assets/scripts/level/LevelIds.ts
Normal file
7
assets/scripts/level/LevelIds.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/** 与 Unity / 主站 config.js BEGINNING_REAL_LVID 一致;首关 91601 */
|
||||
export const LEVEL_ID_BASE = 91601;
|
||||
|
||||
/** 主站合法关卡 ID:从 BEGINNING_REAL_LVID(91601)起 */
|
||||
export function isGameLevelId(levelID: number): boolean {
|
||||
return Number.isFinite(levelID) && levelID >= LEVEL_ID_BASE;
|
||||
}
|
||||
9
assets/scripts/level/LevelIds.ts.meta
Normal file
9
assets/scripts/level/LevelIds.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "f0858c47-3aeb-4416-997f-637c891c074c",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
56
assets/scripts/level/LevelMapData.ts
Normal file
56
assets/scripts/level/LevelMapData.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
9
assets/scripts/level/LevelMapData.ts.meta
Normal file
9
assets/scripts/level/LevelMapData.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "d4e5f6a7-b8c9-4d0e-1f2a-3b4c5d6e7f8a",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
146
assets/scripts/level/LevelPrefabLoader.ts
Normal file
146
assets/scripts/level/LevelPrefabLoader.ts
Normal 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}`);
|
||||
}
|
||||
9
assets/scripts/level/LevelPrefabLoader.ts.meta
Normal file
9
assets/scripts/level/LevelPrefabLoader.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "18fe4dd5-2cb5-4201-ac1c-88b839c28747",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
13
assets/scripts/level/LevelPrefabPaths.ts
Normal file
13
assets/scripts/level/LevelPrefabPaths.ts
Normal 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}`;
|
||||
}
|
||||
9
assets/scripts/level/LevelPrefabPaths.ts.meta
Normal file
9
assets/scripts/level/LevelPrefabPaths.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "2d4bcfb4-7972-4c8a-9f0e-a9d07948eb94",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
58
assets/scripts/level/LevelPrefabUuidIndex.ts
Normal file
58
assets/scripts/level/LevelPrefabUuidIndex.ts
Normal 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;
|
||||
}
|
||||
9
assets/scripts/level/LevelPrefabUuidIndex.ts.meta
Normal file
9
assets/scripts/level/LevelPrefabUuidIndex.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "cd170235-d6e1-4de2-a497-4d24a9b53f34",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
18
assets/scripts/level/LevelRuntimeContext.ts
Normal file
18
assets/scripts/level/LevelRuntimeContext.ts
Normal 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;
|
||||
}
|
||||
9
assets/scripts/level/LevelRuntimeContext.ts.meta
Normal file
9
assets/scripts/level/LevelRuntimeContext.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "1343d397-b505-4672-b30b-83e1f1628772",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
53
assets/scripts/level/LevelTileLayout.ts
Normal file
53
assets/scripts/level/LevelTileLayout.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
9
assets/scripts/level/LevelTileLayout.ts.meta
Normal file
9
assets/scripts/level/LevelTileLayout.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "22737b72-530f-4caf-8222-1485dde33239",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
96
assets/scripts/level/LevelTileSync.ts
Normal file
96
assets/scripts/level/LevelTileSync.ts
Normal 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);
|
||||
}
|
||||
9
assets/scripts/level/LevelTileSync.ts.meta
Normal file
9
assets/scripts/level/LevelTileSync.ts.meta
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"ver": "4.0.24",
|
||||
"importer": "typescript",
|
||||
"imported": true,
|
||||
"uuid": "84e4a32b-5cd7-475c-9842-fd096a1c72fa",
|
||||
"files": [],
|
||||
"subMetas": {},
|
||||
"userData": {}
|
||||
}
|
||||
@@ -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 Prop),ground=空地(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;
|
||||
}
|
||||
|
||||
472
assets/scripts/level/TileLayout.ts
Normal file
472
assets/scripts/level/TileLayout.ts
Normal 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);
|
||||
}
|
||||
9
assets/scripts/level/TileLayout.ts.meta
Normal file
9
assets/scripts/level/TileLayout.ts.meta
Normal 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
Reference in New Issue
Block a user