Files
ChildHood/tools/world-sketcher/sketch.html
T
starmen1265 22711cf946 feat(tools): 添加世界布局草图工具 World Layout Sketcher
三级层次化布局绘图 → AI 解读 → 关卡执行案前置输入

工具特性:
- L1 场景总览(道路/水域/区域/不可通行)→ L2 区域详图(建筑/绿地)
- 双层画布架构:L2 底图显示 L1 内容,绘图层独立操作
- L1↔L2 双向像素缩放同步
- 像素扫描提取连通区域(BFS),输出 bounds + 世界坐标
- JSZip 打包下载(JSON + 独立 PNG 像素文件 + 预览图)
- 加载时从 ZIP 恢复完整画布像素数据

集成更新:
- 新增 .claude/skills/world-sketcher/ 技能定义
- game-designer agent 添加 world-sketcher skill + 布局草图前置步骤
- 关卡设计工作流插入 ③a-③c 子流程
2026-06-09 16:49:54 +08:00

1005 lines
44 KiB
HTML
Raw 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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ChildHood — World Layout Sketcher</title>
<script src="https://cdn.jsdelivr.net/npm/jszip@3/dist/jszip.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--sidebar-w: 264px;
--toolbar-h: 40px;
--statusbar-h: 28px;
--bg-dark: #2d2d2d;
--bg-light: #e8e8e8;
--bg-canvas: #f0f0f0;
--text-light: #ddd;
--text-dim: #999;
--accent: #4a9eff;
--border: #444;
}
html, body { height: 100%; overflow: hidden; font-family: "Microsoft YaHei", "Segoe UI", sans-serif; font-size: 13px; background: var(--bg-dark); color: var(--text-light); }
#app { display: flex; height: 100%; }
#sidebar { width: var(--sidebar-w); min-width: var(--sidebar-w); background: var(--bg-dark); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow-y: auto; }
.sidebar-section { padding: 10px 12px; border-bottom: 1px solid var(--border); }
.sidebar-section h3 { font-size: 11px; text-transform: uppercase; color: var(--text-dim); margin-bottom: 8px; letter-spacing: 0.5px; }
#breadcrumb { display: flex; align-items: center; gap: 4px; font-size: 12px; flex-wrap: wrap; }
#breadcrumb span { cursor: pointer; color: var(--accent); }
#breadcrumb span:hover { text-decoration: underline; }
#breadcrumb .sep { color: var(--text-dim); cursor: default; }
#breadcrumb .current { color: var(--text-light); cursor: default; }
#breadcrumb .current:hover { text-decoration: none; }
#scene-name-row { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
#scene-name-row label { font-size: 11px; color: var(--text-dim); white-space: nowrap; }
#scene-name-input { flex: 1; background: #3a3a3a; border: 1px solid #555; color: #eee; padding: 3px 6px; border-radius: 3px; font-size: 12px; }
.world-size-row { display: flex; gap: 8px; align-items: center; margin-bottom: 6px; }
.world-size-row label { font-size: 11px; color: var(--text-dim); min-width: 32px; }
.world-size-row input { width: 60px; background: #3a3a3a; border: 1px solid #555; color: #eee; padding: 3px 6px; border-radius: 3px; text-align: right; font-size: 12px; }
.world-size-row .unit { font-size: 11px; color: var(--text-dim); }
#scale-display { font-size: 11px; color: var(--text-dim); margin-top: 4px; }
#brush-palette { display: flex; flex-direction: column; gap: 3px; }
.brush-item { display: flex; align-items: center; gap: 8px; padding: 5px 8px; border-radius: 4px; cursor: pointer; border: 2px solid transparent; transition: background 0.15s; }
.brush-item:hover { background: #3a3a3a; }
.brush-item.selected { border-color: #fff; background: #3a3a3a; }
.brush-swatch { width: 24px; height: 24px; border-radius: 4px; border: 1px solid #666; flex-shrink: 0; }
.brush-label { font-size: 12px; }
#brush-size-slider { width: 100%; accent-color: var(--accent); }
#brush-size-val { font-size: 11px; color: var(--text-dim); text-align: right; }
#grid-toggle-row { display: flex; align-items: center; gap: 8px; }
#grid-toggle-row input { accent-color: var(--accent); }
#grid-toggle-row label { cursor: pointer; font-size: 12px; }
#elements-list { max-height: 200px; overflow-y: auto; }
#elements-list:empty::before { content: "双击画布上的色块来命名"; color: var(--text-dim); font-size: 11px; font-style: italic; }
.el-item { display: flex; align-items: center; gap: 6px; padding: 4px 6px; border-radius: 3px; cursor: pointer; font-size: 12px; }
.el-item:hover { background: #3a3a3a; }
.el-swatch { width: 14px; height: 14px; border-radius: 3px; flex-shrink: 0; }
.el-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.el-enter { font-size: 10px; color: var(--accent); cursor: pointer; }
.el-enter:hover { text-decoration: underline; }
#workspace { flex: 1; display: flex; flex-direction: column; background: var(--bg-light); }
#toolbar { height: var(--toolbar-h); background: #383838; display: flex; align-items: center; padding: 0 8px; gap: 4px; border-bottom: 1px solid var(--border); }
#toolbar .tool-group { display: flex; gap: 2px; padding: 0 6px; border-right: 1px solid #555; }
#toolbar .tool-group:last-child { border-right: none; }
.tbtn { background: #4a4a4a; color: #ddd; border: 1px solid #555; padding: 4px 10px; border-radius: 3px; cursor: pointer; font-size: 12px; line-height: 1; white-space: nowrap; }
.tbtn:hover { background: #5a5a5a; }
.tbtn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.tbtn:disabled { opacity: 0.4; cursor: default; }
.tbtn-icon { font-size: 14px; padding: 4px 7px; }
#canvas-container { flex: 1; display: flex; align-items: center; justify-content: center; overflow: auto; position: relative; background: #ccc; }
#canvas-wrap { position: relative; box-shadow: 0 2px 12px rgba(0,0,0,0.3); background: #f0f0f0; }
#canvas-wrap canvas { position: absolute; top: 0; left: 0; }
#l1-base-canvas { z-index: 0; }
#drawing-canvas { z-index: 1; cursor: crosshair; }
#grid-canvas { z-index: 2; pointer-events: none; }
#ui-canvas { z-index: 3; pointer-events: none; }
#statusbar { height: var(--statusbar-h); background: #383838; display: flex; align-items: center; padding: 0 12px; gap: 16px; font-size: 11px; color: var(--text-dim); border-top: 1px solid var(--border); }
#status-feedback { color: #4ae060; font-weight: bold; transition: opacity 0.3s; }
#name-modal { display: none; position: fixed; z-index: 100; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); }
#name-dialog { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: var(--bg-dark); border: 1px solid #666; border-radius: 8px; padding: 16px; min-width: 280px; }
#name-dialog h4 { margin-bottom: 10px; font-size: 13px; }
#name-dialog input[type=text] { width: 100%; background: #3a3a3a; border: 1px solid #555; color: #eee; padding: 6px 8px; border-radius: 3px; margin-bottom: 8px; font-size: 13px; }
#name-dialog label { display: flex; align-items: center; gap: 6px; font-size: 12px; margin-bottom: 10px; }
#name-dialog label input { accent-color: var(--accent); }
#name-dialog .btn-row { display: flex; gap: 8px; justify-content: flex-end; }
#name-dialog button { padding: 5px 14px; border-radius: 3px; border: 1px solid #555; cursor: pointer; font-size: 12px; }
#name-dialog .btn-ok { background: var(--accent); color: #fff; border-color: var(--accent); }
#name-dialog .btn-cancel { background: #4a4a4a; color: #ddd; }
</style>
</head>
<body>
<div id="app">
<aside id="sidebar">
<div class="sidebar-section">
<h3>导航 Navigation</h3>
<div id="breadcrumb"><span class="current">场景总览</span></div>
</div>
<div class="sidebar-section">
<h3>场景 Scene</h3>
<div id="scene-name-row">
<label>名称</label>
<input id="scene-name-input" type="text" value="新场景" placeholder="场景名称">
</div>
<div class="world-size-row">
<label>宽</label><input id="world-w" type="number" value="500" min="10" max="10000"> <span class="unit">m</span>
<label>高</label><input id="world-h" type="number" value="400" min="10" max="10000"> <span class="unit">m</span>
</div>
<div id="scale-display">1 px = 50 cm</div>
</div>
<div class="sidebar-section">
<h3>画笔 Brush</h3>
<div id="brush-palette"></div>
<div style="margin-top:8px;">
<label style="font-size:11px;color:var(--text-dim);">粗细 <span id="brush-size-val">8 px</span></label>
<input id="brush-size-slider" type="range" min="1" max="40" value="8">
</div>
</div>
<div class="sidebar-section">
<div id="grid-toggle-row">
<input type="checkbox" id="grid-toggle" checked>
<label for="grid-toggle">网格 Grid</label>
</div>
</div>
<div class="sidebar-section" style="flex:1;">
<h3>元素 Elements</h3>
<div id="elements-list"></div>
</div>
</aside>
<div id="workspace">
<div id="toolbar">
<div class="tool-group">
<button class="tbtn tbtn-icon active" data-tool="pen" title="画笔 (P)">&#9998;</button>
<button class="tbtn tbtn-icon" data-tool="eraser" title="橡皮擦 (E)">&#9746;</button>
</div>
<div class="tool-group">
<button class="tbtn tbtn-icon" id="btn-undo" title="撤销 (Ctrl+Z)">&#8630;</button>
<button class="tbtn tbtn-icon" id="btn-redo" title="重做 (Ctrl+Y)">&#8631;</button>
</div>
<div class="tool-group">
<button class="tbtn" id="btn-clear">清空</button>
</div>
<div class="tool-group">
<button class="tbtn" id="btn-save-all">保存 ZIP</button>
<button class="tbtn" id="btn-load">加载</button>
</div>
</div>
<div id="canvas-container">
<div id="canvas-wrap">
<canvas id="l1-base-canvas"></canvas>
<canvas id="drawing-canvas"></canvas>
<canvas id="grid-canvas"></canvas>
<canvas id="ui-canvas"></canvas>
</div>
</div>
<div id="statusbar">
<span id="status-pos">--</span>
<span id="status-level">L1 场景总览</span>
<span id="status-canvas">1000 × 800</span>
<span id="status-feedback"></span>
</div>
</div>
</div>
<div id="name-modal">
<div id="name-dialog">
<h4>命名元素</h4>
<input id="name-input" type="text" placeholder="输入名称(如"家"、"小卖部")">
<label><input type="checkbox" id="name-enterable"> 可进入(有下级详图)</label>
<div class="btn-row">
<button class="btn-cancel" id="name-cancel">取消</button>
<button class="btn-ok" id="name-ok">确定</button>
</div>
</div>
</div>
<input type="file" id="file-input" accept=".json,.zip" style="display:none">
<script>
"use strict";
/* ============================================================
画笔定义 — levels: L1 / L2 / both
============================================================ */
const BRUSHES = [
{ id: "road", label: "道路 Road", hex: "#909090", mi: "MI_DevGray", levels: "both" },
{ id: "water", label: "水域 Water", hex: "#40A0D0", mi: "MI_DevSkyBlue", levels: "both" },
{ id: "blocked", label: "不可通行 Blocked", hex: "#A07040", mi: "MI_DevBrown", levels: "both" },
{ id: "zone", label: "区域 Zone", hex: "#D04040", mi: "MI_DevRed", levels: "L1" },
{ id: "building", label: "建筑 Building", hex: "#E07020", mi: "MI_DevOrange", levels: "L2" },
{ id: "greenery", label: "植被 Greenery", hex: "#40B040", mi: "MI_DevGreen", levels: "L2" },
{ id: "eraser", label: "橡皮擦 Eraser", hex: null, levels: "both" },
];
const BRUSH_RGB = BRUSHES.filter(b => b.hex).map(b => ({
id: b.id, hex: b.hex, mi: b.mi, levels: b.levels,
r: parseInt(b.hex.substr(1, 2), 16),
g: parseInt(b.hex.substr(3, 2), 16),
b: parseInt(b.hex.substr(5, 2), 16),
}));
/* 每个层级允许扫描输出的画笔类型 */
const SCAN_TYPES = {
L1: ["road", "water", "blocked", "zone"],
L2: ["road", "water", "blocked", "building", "greenery"],
};
/* ============================================================
App
============================================================ */
const App = {
currentBrush: "road",
currentTool: "pen",
brushSize: 8,
gridVisible: true,
currentLevel: "L1",
sceneName: "新场景",
levelData: {},
undoStack: [],
redoStack: [],
maxUndo: 20,
isDrawing: false,
lastX: 0, lastY: 0,
lastClickTime: 0, lastClickX: 0, lastClickY: 0,
defaultL1: { canvasW: 1000, canvasH: 800 },
// DOM
dc: null, gc: null, uc: null, l1c: null, wrap: null, wW: null, wH: null,
/* ---- 初始化 ---- */
init() {
this.dc = document.getElementById("drawing-canvas");
this.gc = document.getElementById("grid-canvas");
this.uc = document.getElementById("ui-canvas");
this.l1c = document.getElementById("l1-base-canvas");
this.wrap = document.getElementById("canvas-wrap");
this.wW = document.getElementById("world-w");
this.wH = document.getElementById("world-h");
this.initToolbar();
this.initCanvasEvents();
this.initKeyboard();
this.initLevel("L1", 500, 400, this.defaultL1.canvasW, this.defaultL1.canvasH);
this.switchToLevel("L1");
},
initLevel(id, wW, wH, cW, cH) {
if (!this.levelData[id]) {
this.levelData[id] = {
worldW: wW, worldH: wH, canvasW: cW, canvasH: cH,
elements: [], imageData: null, l1BaseData: null,
parentLevel: null, parentElement: null,
};
}
},
/* ---- 层级切换 ---- */
switchToLevel(id) {
const d = this.levelData[id];
if (!d) return;
this.currentLevel = id;
this.setupCanvasSize(d.canvasW, d.canvasH);
// 绘图层:L1 填灰色,L2 填透明(让 l1-base-canvas 透出来)
const ctx = this.dc.getContext("2d");
if (d.imageData) {
ctx.putImageData(d.imageData, 0, 0);
} else {
if (id === "L1") {
ctx.fillStyle = "#f0f0f0"; ctx.fillRect(0, 0, d.canvasW, d.canvasH);
} else {
ctx.clearRect(0, 0, d.canvasW, d.canvasH);
}
}
// L1 底图层(L2 模式下显示 L1 缩放内容)
const l1ctx = this.l1c.getContext("2d");
if (d.l1BaseData) {
l1ctx.putImageData(d.l1BaseData, 0, 0);
} else {
l1ctx.clearRect(0, 0, d.canvasW, d.canvasH);
}
this.wW.value = d.worldW;
this.wH.value = d.worldH;
this.undoStack = []; this.redoStack = [];
this.updateUndoButtons();
this.drawGrid();
this.renderBrushPalette();
this.updateBreadcrumb();
this.updateElementsList();
this.updateScaleDisplay();
this.updateStatus();
},
saveCurrentCanvas() {
const d = this.levelData[this.currentLevel];
if (d) d.imageData = this.dc.getContext("2d").getImageData(0, 0, d.canvasW, d.canvasH);
},
setupCanvasSize(w, h) {
[this.l1c, this.dc, this.gc, this.uc].forEach(c => { c.width = w; c.height = h; });
this.wrap.style.width = w + "px";
this.wrap.style.height = h + "px";
},
/* ---- 画笔面板(按层级动态过滤)---- */
renderBrushPalette() {
const palette = document.getElementById("brush-palette");
palette.innerHTML = "";
const isL1 = this.currentLevel === "L1";
const available = BRUSHES.filter(b => b.levels === "both" || b.levels === (isL1 ? "L1" : "L2"));
// 如果当前画笔不在可用列表中,切换到第一个
if (!available.find(b => b.id === this.currentBrush)) {
this.currentBrush = available[0].id;
}
available.forEach(b => {
const div = document.createElement("div");
div.className = "brush-item" + (b.id === this.currentBrush ? " selected" : "");
div.dataset.brush = b.id;
const sw = document.createElement("div");
sw.className = "brush-swatch";
sw.style.background = b.hex || "repeating-conic-gradient(#ccc 0% 25%, #fff 0% 50%) 50%/10px 10px";
const lb = document.createElement("span");
lb.className = "brush-label"; lb.textContent = b.label;
div.append(sw, lb);
div.addEventListener("click", () => { this.currentBrush = b.id; this.renderBrushPalette(); });
palette.appendChild(div);
});
this.dc.style.cursor = this.currentBrush === "eraser" ? "cell" : "crosshair";
},
/* ---- 工具栏 ---- */
initToolbar() {
document.querySelectorAll("[data-tool]").forEach(btn => {
btn.addEventListener("click", () => {
this.currentTool = btn.dataset.tool;
document.querySelectorAll("[data-tool]").forEach(b => b.classList.toggle("active", b.dataset.tool === this.currentTool));
});
});
document.getElementById("btn-undo").addEventListener("click", () => this.undo());
document.getElementById("btn-redo").addEventListener("click", () => this.redo());
document.getElementById("btn-clear").addEventListener("click", () => this.clearCanvas());
document.getElementById("btn-save-all").addEventListener("click", () => this.saveAll());
document.getElementById("btn-load").addEventListener("click", () => document.getElementById("file-input").click());
},
/* ---- Canvas 事件 ---- */
initCanvasEvents() {
const s = this;
this.dc.addEventListener("mousedown", e => s.onMouseDown(e));
this.dc.addEventListener("mousemove", e => s.onMouseMove(e));
this.dc.addEventListener("mouseup", e => s.onMouseUp(e));
this.dc.addEventListener("mouseleave", e => s.onMouseUp(e));
this.dc.addEventListener("contextmenu", e => e.preventDefault());
this.dc.addEventListener("touchstart", e => { e.preventDefault(); s.onMouseDown(s.touchToMouse(e)); });
this.dc.addEventListener("touchmove", e => { e.preventDefault(); s.onMouseMove(s.touchToMouse(e)); });
this.dc.addEventListener("touchend", e => { e.preventDefault(); s.onMouseUp(s.touchToMouse(e)); });
this.wW.addEventListener("change", () => s.onWorldSizeChange());
this.wH.addEventListener("change", () => s.onWorldSizeChange());
document.getElementById("brush-size-slider").addEventListener("input", function() {
s.brushSize = parseInt(this.value);
document.getElementById("brush-size-val").textContent = s.brushSize + " px";
});
document.getElementById("grid-toggle").addEventListener("change", function() {
s.gridVisible = this.checked; s.drawGrid();
});
document.getElementById("scene-name-input").addEventListener("input", function() {
s.sceneName = this.value || "新场景";
});
document.getElementById("file-input").addEventListener("change", e => s.onFileLoad(e));
},
touchToMouse(e) {
const t = e.touches[0] || e.changedTouches[0];
const r = this.dc.getBoundingClientRect();
return { offsetX: t.clientX - r.left, offsetY: t.clientY - r.top };
},
isEraser() { return this.currentBrush === "eraser" || this.currentTool === "eraser"; },
getBrushColor() { const b = BRUSHES.find(b => b.id === this.currentBrush); return b ? b.hex : null; },
onMouseDown(e) {
const p = { x: e.offsetX, y: e.offsetY };
const now = Date.now();
if (now - this.lastClickTime < 400 && Math.abs(p.x - this.lastClickX) < 10 && Math.abs(p.y - this.lastClickY) < 10) {
this.lastClickTime = 0; this.showNameModal(p.x, p.y); return;
}
this.lastClickTime = now; this.lastClickX = p.x; this.lastClickY = p.y;
this.pushUndo(); this.isDrawing = true; this.lastX = p.x; this.lastY = p.y;
this.drawDot(p.x, p.y);
},
onMouseMove(e) {
const p = { x: e.offsetX, y: e.offsetY };
this.updateStatusPos(p.x, p.y);
if (!this.isDrawing) return;
this.drawLine(this.lastX, this.lastY, p.x, p.y);
this.lastX = p.x; this.lastY = p.y;
},
onMouseUp() { this.isDrawing = false; },
drawDot(x, y) {
const ctx = this.dc.getContext("2d");
const erase = this.isEraser();
ctx.globalCompositeOperation = erase ? "destination-out" : "source-over";
ctx.fillStyle = erase ? "rgba(0,0,0,1)" : this.getBrushColor();
ctx.beginPath(); ctx.arc(x, y, this.brushSize / 2, 0, Math.PI * 2); ctx.fill();
ctx.globalCompositeOperation = "source-over";
},
drawLine(x1, y1, x2, y2) {
const ctx = this.dc.getContext("2d");
const erase = this.isEraser();
ctx.globalCompositeOperation = erase ? "destination-out" : "source-over";
ctx.strokeStyle = erase ? "rgba(0,0,0,1)" : this.getBrushColor();
ctx.lineWidth = this.brushSize; ctx.lineCap = "round"; ctx.lineJoin = "round";
ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
ctx.globalCompositeOperation = "source-over";
},
pushUndo() {
const d = this.levelData[this.currentLevel];
this.undoStack.push(this.dc.getContext("2d").getImageData(0, 0, d.canvasW, d.canvasH));
if (this.undoStack.length > this.maxUndo) this.undoStack.shift();
this.redoStack = []; this.updateUndoButtons();
},
undo() {
if (!this.undoStack.length) return;
const d = this.levelData[this.currentLevel];
this.redoStack.push(this.dc.getContext("2d").getImageData(0, 0, d.canvasW, d.canvasH));
this.dc.getContext("2d").putImageData(this.undoStack.pop(), 0, 0);
this.updateUndoButtons();
},
redo() {
if (!this.redoStack.length) return;
const d = this.levelData[this.currentLevel];
this.undoStack.push(this.dc.getContext("2d").getImageData(0, 0, d.canvasW, d.canvasH));
this.dc.getContext("2d").putImageData(this.redoStack.pop(), 0, 0);
this.updateUndoButtons();
},
updateUndoButtons() {
document.getElementById("btn-undo").disabled = !this.undoStack.length;
document.getElementById("btn-redo").disabled = !this.redoStack.length;
},
clearCanvas() {
if (!confirm("清空当前画布?不可撤销。")) return;
const d = this.levelData[this.currentLevel];
const ctx = this.dc.getContext("2d");
if (this.currentLevel === "L1") {
ctx.fillStyle = "#f0f0f0"; ctx.fillRect(0, 0, d.canvasW, d.canvasH);
} else {
ctx.clearRect(0, 0, d.canvasW, d.canvasH);
}
this.undoStack = []; this.redoStack = []; this.updateUndoButtons();
d.elements = []; this.updateElementsList();
},
/* ---- 网格 ---- */
drawGrid() {
const ctx = this.gc.getContext("2d"), d = this.levelData[this.currentLevel];
if (!d) return;
ctx.clearRect(0, 0, d.canvasW, d.canvasH);
if (!this.gridVisible) return;
const s = d.worldW / d.canvasW;
let gs = 1 / s; if (gs < 4) gs *= 5; if (gs < 4) gs *= 5; if (gs < 4) gs *= 4;
const ms = gs * 5;
ctx.strokeStyle = "rgba(0,0,0,0.08)"; ctx.beginPath();
for (let x = gs; x < d.canvasW; x += gs) { ctx.moveTo(Math.round(x)+.5, 0); ctx.lineTo(Math.round(x)+.5, d.canvasH); }
for (let y = gs; y < d.canvasH; y += gs) { ctx.moveTo(0, Math.round(y)+.5); ctx.lineTo(d.canvasW, Math.round(y)+.5); }
ctx.stroke();
ctx.strokeStyle = "rgba(0,0,0,0.18)"; ctx.beginPath();
for (let x = ms; x < d.canvasW; x += ms) { ctx.moveTo(Math.round(x)+.5, 0); ctx.lineTo(Math.round(x)+.5, d.canvasH); }
for (let y = ms; y < d.canvasH; y += ms) { ctx.moveTo(0, Math.round(y)+.5); ctx.lineTo(d.canvasW, Math.round(y)+.5); }
ctx.stroke();
ctx.fillStyle = "rgba(0,0,0,0.3)"; ctx.font = "10px sans-serif";
ctx.textAlign = "left"; ctx.textBaseline = "top";
for (let x = ms; x < d.canvasW; x += ms) ctx.fillText((x*s).toFixed(0)+"m", x+2, 2);
ctx.textAlign = "right"; ctx.textBaseline = "bottom";
for (let y = ms; y < d.canvasH; y += ms) ctx.fillText(((d.canvasH-y)*s).toFixed(0)+"m", d.canvasW-2, y-2);
},
onWorldSizeChange() {
const d = this.levelData[this.currentLevel]; if (!d) return;
d.worldW = Math.max(10, parseInt(this.wW.value) || 500);
d.worldH = Math.max(10, parseInt(this.wH.value) || 400);
this.drawGrid(); this.updateScaleDisplay(); this.updateStatus();
},
updateScaleDisplay() {
const d = this.levelData[this.currentLevel]; if (!d) return;
const s = d.worldW / d.canvasW;
document.getElementById("scale-display").textContent = s >= 1 ? `1 px = ${s.toFixed(1)} m` : `1 px = ${(s*100).toFixed(0)} cm`;
},
updateStatusPos(px, py) {
const d = this.levelData[this.currentLevel]; if (!d) return;
const s = d.worldW / d.canvasW;
document.getElementById("status-pos").textContent =
`像素 (${px}, ${py}) 世界 (${(px*s).toFixed(1)}m, ${((d.canvasH-py)*s).toFixed(1)}m)`;
},
updateStatus() {
const d = this.levelData[this.currentLevel];
document.getElementById("status-level").textContent = this.currentLevel === "L1" ? "L1 场景总览" : `L2 ${this.getLevelDisplayName(this.currentLevel)}`;
document.getElementById("status-canvas").textContent = `${d.canvasW} × ${d.canvasH}`;
},
getLevelDisplayName(id) {
const d = this.levelData[id]; if (!d) return id;
const z = d.elements.find(e => e.type === "zone" && e.name);
return z ? z.name : id;
},
/* ---- 面包屑导航 ---- */
updateBreadcrumb() {
const bc = document.getElementById("breadcrumb"); bc.innerHTML = "";
if (this.currentLevel === "L1") {
bc.innerHTML = '<span class="current">场景总览</span>';
} else {
const l1 = document.createElement("span"); l1.textContent = "场景总览";
l1.addEventListener("click", () => this.navigateToLevel("L1"));
bc.appendChild(l1);
bc.insertAdjacentHTML("beforeend", '<span class="sep"> › </span>');
bc.insertAdjacentHTML("beforeend", `<span class="current">${this.getLevelDisplayName(this.currentLevel)}</span>`);
}
},
navigateToLevel(id) {
if (id === this.currentLevel) return;
if (this.currentLevel.startsWith("L2-") && id === "L1") this.compositeL2BackToL1();
this.saveCurrentCanvas();
this.switchToLevel(id);
},
/* ---- L1↔L2 双向同步 ---- */
findColorBBox(imageData, cw, ch, px, py, tol) {
const d = imageData.data;
const idx = (py * cw + px) * 4;
if (d[idx+3] < 50) return null;
let bm = null;
for (const b of BRUSH_RGB) {
if (Math.abs(d[idx]-b.r) < tol && Math.abs(d[idx+1]-b.g) < tol && Math.abs(d[idx+2]-b.b) < tol) { bm = b; break; }
}
if (!bm) return null;
const vis = new Uint8Array(cw * ch);
const q = [px + py * cw]; vis[q[0]] = 1;
let x0=px, x1=px, y0=py, y1=py;
while (q.length) {
const p = q.pop(), cx = p % cw, cy = (p - cx) / cw;
if (cx < x0) x0 = cx; if (cx > x1) x1 = cx;
if (cy < y0) y0 = cy; if (cy > y1) y1 = cy;
for (const np of [cx>0?p-1:-1, cx<cw-1?p+1:-1, cy>0?p-cw:-1, cy<ch-1?p+cw:-1]) {
if (np < 0 || vis[np]) continue;
const ni = np * 4;
if (Math.abs(d[ni]-bm.r)<tol && Math.abs(d[ni+1]-bm.g)<tol && Math.abs(d[ni+2]-bm.b)<tol && d[ni+3]>=50) {
vis[np] = 1; q.push(np);
}
}
}
const pad = 5;
return { x: Math.max(0, x0-pad), y: Math.max(0, y0-pad), w: Math.min(cw-1, x1+pad) - Math.max(0, x0-pad) + 1, h: Math.min(ch-1, y1+pad) - Math.max(0, y0-pad) + 1 };
},
enterElement(el) {
if (!el.hasDetail || !el.detailRef) return;
this.saveCurrentCanvas();
const l2Id = el.detailRef, l1Data = this.levelData["L1"];
// 动态计算 L2 画布尺寸:取 L1 区域 bbox ×2,不设上限
let cW = 800, cH = 600;
if (l1Data && l1Data.imageData) {
const bbox = this.findColorBBox(l1Data.imageData, l1Data.canvasW, l1Data.canvasH, el.px, el.py, 60);
if (bbox) { cW = Math.max(200, bbox.w * 2); cH = Math.max(150, bbox.h * 2); }
}
// 如果 L2 尚未创建或尺寸不同,更新
if (!this.levelData[l2Id]) {
this.initLevel(l2Id, 80, 60, cW, cH);
this.levelData[l2Id].parentLevel = "L1";
this.levelData[l2Id].parentElement = el.id;
}
const l2d = this.levelData[l2Id];
// 如果已有数据但尺寸变了,以新尺寸为准(仅当尚未绘制时)
if (!l2d.imageData) { l2d.canvasW = cW; l2d.canvasH = cH; }
this.currentLevel = l2Id;
this.setupCanvasSize(l2d.canvasW, l2d.canvasH);
// L2 绘图层:恢复旧数据或填透明
const ctx = this.dc.getContext("2d");
if (l2d.imageData) {
ctx.putImageData(l2d.imageData, 0, 0);
} else {
ctx.clearRect(0, 0, l2d.canvasW, l2d.canvasH);
}
// L1 底图层:裁剪 L1 区域缩放填充
const l1ctx = this.l1c.getContext("2d");
l1ctx.clearRect(0, 0, l2d.canvasW, l2d.canvasH);
if (l1Data && l1Data.imageData) {
const bbox = this.findColorBBox(l1Data.imageData, l1Data.canvasW, l1Data.canvasH, el.px, el.py, 60);
if (bbox) {
const tmp = document.createElement("canvas");
tmp.width = l1Data.canvasW; tmp.height = l1Data.canvasH;
tmp.getContext("2d").putImageData(l1Data.imageData, 0, 0);
l1ctx.drawImage(tmp, bbox.x, bbox.y, bbox.w, bbox.h, 0, 0, l2d.canvasW, l2d.canvasH);
}
}
l2d.l1BaseData = l1ctx.getImageData(0, 0, l2d.canvasW, l2d.canvasH);
this.undoStack = []; this.redoStack = []; this.updateUndoButtons();
this.wW.value = l2d.worldW; this.wH.value = l2d.worldH;
this.drawGrid(); this.renderBrushPalette(); this.updateBreadcrumb(); this.updateElementsList(); this.updateScaleDisplay(); this.updateStatus();
},
compositeL2BackToL1() {
const l2d = this.levelData[this.currentLevel];
if (!l2d || !l2d.parentLevel || !l2d.parentElement) return;
const l1d = this.levelData[l2d.parentLevel]; if (!l1d) return;
const pEl = l1d.elements.find(e => e.id === l2d.parentElement); if (!pEl) return;
const bbox = this.findColorBBox(l1d.imageData, l1d.canvasW, l1d.canvasH, pEl.px, pEl.py, 60);
if (!bbox) return;
this.saveCurrentCanvas();
// 直接把 L2 drawing-canvas(透明背景+用户笔触)缩放回写到 L1
// 透明像素不影响 L1 原内容,只有实际笔触会叠加
const l1tmp = document.createElement("canvas");
l1tmp.width = l1d.canvasW; l1tmp.height = l1d.canvasH;
const l1ctx = l1tmp.getContext("2d");
l1ctx.putImageData(l1d.imageData, 0, 0);
l1ctx.drawImage(this.dc, 0, 0, l2d.canvasW, l2d.canvasH, bbox.x, bbox.y, bbox.w, bbox.h);
l1d.imageData = l1ctx.getImageData(0, 0, l1d.canvasW, l1d.canvasH);
},
/* ---- 元素命名 ---- */
matchBrushColor(r, g, b) {
for (const br of BRUSH_RGB) {
if (Math.abs(r-br.r)<40 && Math.abs(g-br.g)<40 && Math.abs(b-br.b)<40) return br;
}
return null;
},
findElementAt(px, py) {
const d = this.levelData[this.currentLevel];
let best = null, dist = 50;
for (const el of d.elements) {
const d2 = Math.hypot(px-el.px, py-el.py);
if (d2 < dist) { dist = d2; best = el; }
}
return best;
},
showNameModal(px, py) {
const pixel = this.dc.getContext("2d").getImageData(px, py, 1, 1).data;
if (pixel[3] < 50) return;
const brushType = this.matchBrushColor(pixel[0], pixel[1], pixel[2]);
if (!brushType) return;
const existing = this.findElementAt(px, py);
const modal = document.getElementById("name-modal");
const inp = document.getElementById("name-input");
const chk = document.getElementById("name-enterable");
inp.value = existing ? existing.name : "";
chk.checked = existing ? !!existing.hasDetail : false;
// L2 层不显示"可进入"复选框(L3 未实现)
chk.parentElement.style.display = this.currentLevel === "L1" ? "" : "none";
modal.style.display = "block"; inp.focus();
const ok = document.getElementById("name-ok"), cancel = document.getElementById("name-cancel");
const nOk = ok.cloneNode(true), nCancel = cancel.cloneNode(true);
ok.parentNode.replaceChild(nOk, ok); cancel.parentNode.replaceChild(nCancel, cancel);
const cleanup = () => { modal.style.display = "none"; };
nOk.addEventListener("click", () => {
const name = inp.value.trim(); if (!name) { cleanup(); return; }
const hasDetail = chk.checked;
if (existing) { existing.name = name; existing.hasDetail = hasDetail; }
else {
const el = { id: "el_"+Date.now(), type: brushType.id, name, color: brushType.hex, px, py, hasDetail, detailRef: null };
this.levelData[this.currentLevel].elements.push(el);
if (hasDetail && this.currentLevel === "L1") {
const l2Id = "L2-" + el.id;
// L2 尺寸延迟到 enterElement 时计算
this.initLevel(l2Id, 80, 60, 800, 600);
this.levelData[l2Id].parentLevel = "L1";
this.levelData[l2Id].parentElement = el.id;
el.detailRef = l2Id;
}
}
this.updateElementsList(); this.drawElementLabels(); cleanup();
});
nCancel.addEventListener("click", cleanup);
},
/* ---- 元素列表 ---- */
updateElementsList() {
const list = document.getElementById("elements-list");
const d = this.levelData[this.currentLevel];
if (!d) { list.innerHTML = ""; return; }
list.innerHTML = "";
d.elements.forEach(el => {
const div = document.createElement("div"); div.className = "el-item";
div.innerHTML = `<div class="el-swatch" style="background:${el.color}"></div><span class="el-name">${el.name}</span>`;
if (el.hasDetail) {
const enter = document.createElement("span"); enter.className = "el-enter"; enter.textContent = "进入 →";
enter.addEventListener("click", e => { e.stopPropagation(); this.enterElement(el); });
div.appendChild(enter);
}
div.addEventListener("click", () => this.flashElement(el));
list.appendChild(div);
});
},
flashElement(el) {
const ctx = this.uc.getContext("2d"), d = this.levelData[this.currentLevel];
ctx.clearRect(0, 0, d.canvasW, d.canvasH);
ctx.strokeStyle = "#fff"; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(el.px, el.py, 20, 0, Math.PI*2); ctx.stroke();
ctx.strokeStyle = "#000"; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(el.px, el.py, 20, 0, Math.PI*2); ctx.stroke();
setTimeout(() => ctx.clearRect(0, 0, d.canvasW, d.canvasH), 800);
},
drawElementLabels() {
const ctx = this.uc.getContext("2d"), d = this.levelData[this.currentLevel];
ctx.clearRect(0, 0, d.canvasW, d.canvasH);
d.elements.forEach(el => {
ctx.font = "bold 12px sans-serif";
const tw = ctx.measureText(el.name).width + 8, th = 18;
const tx = el.px - tw/2, ty = el.py - 28;
ctx.fillStyle = "rgba(0,0,0,0.7)";
ctx.fillRect(tx, ty, tw, th);
ctx.fillStyle = "#fff"; ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText(el.name, el.px, ty + th/2);
if (el.hasDetail) {
ctx.fillStyle = "rgba(74,158,255,0.8)";
ctx.beginPath(); ctx.arc(el.px + tw/2 - 2, ty + 2, 5, 0, Math.PI*2); ctx.fill();
}
});
},
/* ---- 保存 / 加载 ---- */
downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = filename; a.style.display = "none";
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1000);
},
/** 将 ImageData 转为 base64 PNG */
imageDataToBase64(imageData, w, h) {
const tmp = document.createElement("canvas");
tmp.width = w; tmp.height = h;
tmp.getContext("2d").putImageData(imageData, 0, 0);
return tmp.toDataURL("image/png");
},
/** 从 base64 恢复 ImageData */
base64ToImageData(b64) {
return new Promise(resolve => {
const img = new Image();
img.onload = () => {
const tmp = document.createElement("canvas");
tmp.width = img.width; tmp.height = img.height;
const ctx = tmp.getContext("2d");
ctx.drawImage(img, 0, 0);
resolve(ctx.getImageData(0, 0, tmp.width, tmp.height));
};
img.src = b64;
});
},
async saveAll() {
this.saveCurrentCanvas();
const name = this.sceneName.replace(/\s+/g, "_");
const zip = new JSZip();
const sceneData = {
scene_name: this.sceneName, version: "v02",
created: new Date().toISOString().slice(0, 19),
origin: "SW corner, X+ East, Y+ North",
brush_legend: {},
levels: {}
};
BRUSHES.forEach(b => { if (b.hex) sceneData.brush_legend[b.hex] = { type: b.id, label: b.label, mi_material: b.mi, levels: b.levels }; });
const allowedTypes = this.currentLevel === "L1" ? SCAN_TYPES.L1 : SCAN_TYPES.L2;
for (const [id, d] of Object.entries(this.levelData)) {
const sx = d.worldW / d.canvasW, sy = d.worldH / d.canvasH;
const types = id === "L1" ? SCAN_TYPES.L1 : SCAN_TYPES.L2;
let regions = [];
if (d.imageData) regions = this.scanRegions(d.imageData, d.canvasW, d.canvasH, sx, sy, d.elements, types);
// 像素数据存为独立 PNG 文件,JSON 中只放文件名
let imageFile = null, l1BaseFile = null;
if (d.imageData) {
imageFile = `${name}-${id}-drawing.png`;
const blob = await new Promise(r => {
const tmp = document.createElement("canvas");
tmp.width = d.canvasW; tmp.height = d.canvasH;
tmp.getContext("2d").putImageData(d.imageData, 0, 0);
tmp.toBlob(r, "image/png");
});
zip.file(imageFile, blob);
}
if (d.l1BaseData) {
l1BaseFile = `${name}-${id}-l1base.png`;
const blob = await new Promise(r => {
const tmp = document.createElement("canvas");
tmp.width = d.canvasW; tmp.height = d.canvasH;
tmp.getContext("2d").putImageData(d.l1BaseData, 0, 0);
tmp.toBlob(r, "image/png");
});
zip.file(l1BaseFile, blob);
}
sceneData.levels[id] = {
world_size_m: [d.worldW, d.worldH],
canvas_size_px: [d.canvasW, d.canvasH],
scale_m_per_px: parseFloat(sx.toFixed(4)),
elements: d.elements.map(el => ({
id: el.id, type: el.type, name: el.name, color: el.color, px: el.px, py: el.py,
world_x_m: parseFloat((el.px * sx).toFixed(1)),
world_y_m: parseFloat(((d.canvasH - el.py) * sy).toFixed(1)),
has_detail: !!el.hasDetail, detail_ref: el.detailRef || null,
})),
regions,
image_file: imageFile,
l1_base_file: l1BaseFile,
parent_level: d.parentLevel || null,
parent_element: d.parentElement || null,
};
// 合并预览 PNG(L1 底图 + 绘图层,供人查看)
if (d.imageData) {
const pngBlob = await new Promise(r => {
const tmp = document.createElement("canvas");
tmp.width = d.canvasW; tmp.height = d.canvasH;
const ctx = tmp.getContext("2d");
if (d.l1BaseData) {
const baseTmp = document.createElement("canvas");
baseTmp.width = d.canvasW; baseTmp.height = d.canvasH;
baseTmp.getContext("2d").putImageData(d.l1BaseData, 0, 0);
ctx.drawImage(baseTmp, 0, 0);
}
const drawTmp = document.createElement("canvas");
drawTmp.width = d.canvasW; drawTmp.height = d.canvasH;
drawTmp.getContext("2d").putImageData(d.imageData, 0, 0);
ctx.drawImage(drawTmp, 0, 0);
tmp.toBlob(r, "image/png");
});
const tag = id === "L1" ? "L1-overview" : id;
zip.file(`${name}-${tag}-preview.png`, pngBlob);
}
}
zip.file(`${name}-scene.json`, JSON.stringify(sceneData, null, 2));
const blob = await zip.generateAsync({ type: "blob" });
this.downloadBlob(blob, `${name}-scene.zip`);
this.showSaveFeedback("ZIP 已保存 ✓");
},
showSaveFeedback(msg) {
const el = document.getElementById("status-feedback");
el.textContent = msg; el.style.opacity = "1";
setTimeout(() => { el.style.opacity = "0"; setTimeout(() => { el.textContent = ""; }, 300); }, 2000);
},
async onFileLoad(e) {
const file = e.target.files[0]; if (!file) return;
e.target.value = "";
try {
if (file.name.endsWith(".zip")) {
const ab = await file.arrayBuffer();
const zip = await JSZip.loadAsync(ab);
const jsonFile = Object.keys(zip.files).find(n => n.endsWith("-scene.json"));
if (!jsonFile) { alert("ZIP 中未找到 scene.json"); return; }
const txt = await zip.file(jsonFile).async("string");
await this.restoreScene(JSON.parse(txt), zip);
} else {
const txt = await file.text();
await this.restoreScene(JSON.parse(txt), null);
}
this.showSaveFeedback("加载成功 ✓");
} catch (err) {
alert("加载失败:" + err.message);
}
},
/** 从 PNG Blob 恢复 ImageData */
blobToImageData(blob) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const tmp = document.createElement("canvas");
tmp.width = img.width; tmp.height = img.height;
const ctx = tmp.getContext("2d");
ctx.drawImage(img, 0, 0);
resolve(ctx.getImageData(0, 0, tmp.width, tmp.height));
};
img.onerror = reject;
img.src = URL.createObjectURL(blob);
});
},
async restoreScene(sceneData, zip) {
this.sceneName = sceneData.scene_name || "新场景";
document.getElementById("scene-name-input").value = this.sceneName;
this.levelData = {};
for (const [id, lvl] of Object.entries(sceneData.levels)) {
this.levelData[id] = {
worldW: lvl.world_size_m[0], worldH: lvl.world_size_m[1],
canvasW: lvl.canvas_size_px[0], canvasH: lvl.canvas_size_px[1],
elements: (lvl.elements || []).map(el => ({
id: el.id, type: el.type, name: el.name, color: el.color,
px: el.px, py: el.py, hasDetail: el.has_detail, detailRef: el.detail_ref,
})),
imageData: null, l1BaseData: null,
parentLevel: lvl.parent_level || null, parentElement: lvl.parent_element || null,
};
// 从 ZIP 中的 PNG 文件恢复像素数据
if (zip && lvl.image_file && zip.files[lvl.image_file]) {
const blob = await zip.files[lvl.image_file].async("blob");
this.levelData[id].imageData = await this.blobToImageData(blob);
} else if (lvl.image_data) {
// 向后兼容:旧格式 base64 内嵌
this.levelData[id].imageData = await this.base64ToImageData(lvl.image_data);
}
if (zip && lvl.l1_base_file && zip.files[lvl.l1_base_file]) {
const blob = await zip.files[lvl.l1_base_file].async("blob");
this.levelData[id].l1BaseData = await this.blobToImageData(blob);
} else if (lvl.l1_base_data) {
this.levelData[id].l1BaseData = await this.base64ToImageData(lvl.l1_base_data);
}
}
const first = this.levelData["L1"] ? "L1" : Object.keys(this.levelData)[0];
if (first) this.switchToLevel(first);
},
/* ---- 像素扫描 ---- */
scanRegions(imageData, cw, ch, scaleX, scaleY, elements, allowedTypes) {
const data = imageData.data;
const vis = new Uint8Array(cw * ch);
const regions = [];
let rid = 0;
const step = cw > 600 ? 2 : 1;
for (let y = 0; y < ch; y += step) {
for (let x = 0; x < cw; x += step) {
const pos = y * cw + x;
if (vis[pos]) continue;
const idx = pos * 4;
if (data[idx+3] < 50) { vis[pos] = 1; continue; }
if (data[idx] > 220 && data[idx+1] > 220 && data[idx+2] > 220 && Math.abs(data[idx]-data[idx+1]) < 30 && Math.abs(data[idx+1]-data[idx+2]) < 30) { vis[pos] = 1; continue; }
const brush = this.matchBrushColor(data[idx], data[idx+1], data[idx+2]);
if (!brush || !allowedTypes.includes(brush.id)) { vis[pos] = 1; continue; }
rid++;
const q = [pos]; vis[pos] = rid;
let x0=x, x1=x, y0=y, y1=y, area=0;
while (q.length) {
const cur = q.pop(); area++;
const cx = cur % cw, cy = (cur-cx)/cw;
if (cx<x0) x0=cx; if (cx>x1) x1=cx; if (cy<y0) y0=cy; if (cy>y1) y1=cy;
for (const np of [cx>0?cur-1:-1, cx<cw-1?cur+1:-1, cy>0?cur-cw:-1, cy<ch-1?cur+cw:-1]) {
if (np<0||vis[np]) continue;
const ni=np*4;
if (data[ni+3]<50) { vis[np]=255; continue; }
if (Math.abs(data[ni]-brush.r)<40 && Math.abs(data[ni+1]-brush.g)<40 && Math.abs(data[ni+2]-brush.b)<40) {
vis[np]=rid; q.push(np);
} else vis[np]=255;
}
}
if (area * step * step < 20) continue;
const elName = elements.find(el => el.px>=x0 && el.px<=x1 && el.py>=y0 && el.py<=y1);
regions.push({
id: `r_${String(rid).padStart(3,"0")}`, type: brush.id, color: brush.hex,
name: elName ? elName.name : null,
bounds_px: [x0, y0, x1, y1],
bounds_world_m: [
parseFloat((x0*scaleX).toFixed(1)), parseFloat(((ch-y1)*scaleY).toFixed(1)),
parseFloat((x1*scaleX).toFixed(1)), parseFloat(((ch-y0)*scaleY).toFixed(1)),
],
area_px: area * step * step,
});
}
}
return regions;
},
/* ---- 键盘 ---- */
initKeyboard() {
document.addEventListener("keydown", e => {
if (e.target.tagName === "INPUT") return;
if (e.ctrlKey && e.key === "z") { e.preventDefault(); this.undo(); }
if (e.ctrlKey && e.key === "y") { e.preventDefault(); this.redo(); }
if (e.key === "p" || e.key === "P") { this.currentTool = "pen"; document.querySelectorAll("[data-tool]").forEach(b => b.classList.toggle("active", b.dataset.tool === "pen")); }
if (e.key === "e" || e.key === "E") { this.currentBrush = "eraser"; this.renderBrushPalette(); }
if (e.key === "g" || e.key === "G") { this.gridVisible = !this.gridVisible; document.getElementById("grid-toggle").checked = this.gridVisible; this.drawGrid(); }
});
},
};
window.addEventListener("DOMContentLoaded", () => App.init());
</script>
</body>
</html>