Adds level prefabs, theme assets, audio, extensions, and deployment scripts for the Unity WebGL migration. Co-authored-by: Cursor <cursoragent@cursor.com>
367 lines
12 KiB
TypeScript
367 lines
12 KiB
TypeScript
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();
|
||
}
|
||
}
|
||
}
|