Files
cocos/assets/scripts/level/GridSnapHelper.ts
刘宇飞 d393302388 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>
2026-06-16 15:30:58 +08:00

367 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
}
}
}