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; border: Record; } { 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, border: Record): Set { 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, borderMap: Record, ) { 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, borderMap: Record, ): 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, borderMap: Record, ) { 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(); 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(); } } }