c509223e08
- 新增 server.py + start.bat: 本地保存/加载服务器 - sketch.html: 切换层级时自动绘制元素标签 - L1 显示区域名称+地形标高,L2 显示建筑名称+高度 - 保存优先POST到服务器,加载优先从output/读取 - 命名弹窗区分'地形标高'和'建筑高度' - README 全面重写:双模式启动、文件结构、API端点、高度语义
1212 lines
53 KiB
HTML
1212 lines
53 KiB
HTML
<!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; }
|
||
#name-height-row { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; font-size: 12px; }
|
||
#name-height-row input { width: 60px; background: #3a3a3a; border: 1px solid #555; color: #eee; padding: 4px 6px; border-radius: 3px; text-align: right; font-size: 12px; }
|
||
#name-connections-row { margin-bottom: 8px; font-size: 12px; }
|
||
#name-connections-row > label { display: block; margin-bottom: 4px; color: var(--text-dim); }
|
||
#name-connections-list { max-height: 100px; overflow-y: auto; background: #3a3a3a; border-radius: 3px; padding: 4px; }
|
||
#name-connections-list label { display: flex; align-items: center; gap: 6px; padding: 2px 4px; cursor: pointer; border-radius: 2px; }
|
||
#name-connections-list label:hover { background: #4a4a4a; }
|
||
#name-connections-list input { accent-color: var(--accent); }
|
||
</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)">✎</button>
|
||
<button class="tbtn tbtn-icon" data-tool="eraser" title="橡皮擦 (E)">☒</button>
|
||
</div>
|
||
<div class="tool-group">
|
||
<button class="tbtn tbtn-icon" id="btn-undo" title="撤销 (Ctrl+Z)">↶</button>
|
||
<button class="tbtn tbtn-icon" id="btn-redo" title="重做 (Ctrl+Y)">↷</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">保存</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 id="name-height-row" style="display:none;">
|
||
<label id="name-height-label" style="color:var(--text-dim);">高度</label>
|
||
<input id="name-height" type="number" step="0.5" value="3">
|
||
<span style="color:var(--text-dim);">m</span>
|
||
</div>
|
||
<div id="name-connections-row" style="display:none;">
|
||
<label>连通到</label>
|
||
<div id="name-connections-list"></div>
|
||
</div>
|
||
<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">
|
||
|
||
<!-- 从 output/ 加载的文件选择弹窗 -->
|
||
<div id="output-modal" style="display:none;position:fixed;z-index:100;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);">
|
||
<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:#2d2d2d;border:1px solid #666;border-radius:8px;padding:16px;min-width:320px;max-height:60vh;display:flex;flex-direction:column;">
|
||
<h4 style="margin-bottom:10px;font-size:13px;color:#ddd;">从 output/ 加载</h4>
|
||
<div id="output-file-list" style="flex:1;overflow-y:auto;max-height:300px;"></div>
|
||
<div style="display:flex;justify-content:flex-end;margin-top:10px;">
|
||
<button class="tbtn" id="output-modal-close">关闭</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<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");
|
||
// 缓存服务器可用性(首次加载时检测)
|
||
this._serverOk = this.serverAvailable();
|
||
},
|
||
|
||
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.drawElementLabels();
|
||
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", () => this.smartLoad());
|
||
document.getElementById("output-modal-close").addEventListener("click", () => {
|
||
document.getElementById("output-modal").style.display = "none";
|
||
});
|
||
},
|
||
|
||
/* ---- 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.drawElementLabels(); 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;
|
||
},
|
||
|
||
/* 默认高度(米),按画笔类型 */
|
||
defaultHeightForType(type) {
|
||
return { building: 3, blocked: 3, zone: 3, greenery: 2, road: 0, water: 0 }[type] || 0;
|
||
},
|
||
|
||
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");
|
||
const heightRow = document.getElementById("name-height-row");
|
||
const heightInp = document.getElementById("name-height");
|
||
const connRow = document.getElementById("name-connections-row");
|
||
const connList = document.getElementById("name-connections-list");
|
||
|
||
inp.value = existing ? existing.name : "";
|
||
chk.checked = existing ? !!existing.hasDetail : false;
|
||
// L2 层不显示"可进入"复选框(L3 未实现)
|
||
chk.parentElement.style.display = this.currentLevel === "L1" ? "" : "none";
|
||
|
||
// 高度输入:building / blocked / zone 类型显示
|
||
const showHeight = ["building", "blocked", "zone"].includes(brushType.id);
|
||
const heightLabel = document.getElementById("name-height-label");
|
||
heightRow.style.display = showHeight ? "flex" : "none";
|
||
if (showHeight) {
|
||
// L1 zone = 地形标高(允许负值);L2 building = 建筑高度
|
||
if (this.currentLevel === "L1" && brushType.id === "zone") {
|
||
heightLabel.textContent = "地形标高";
|
||
heightInp.min = "-10";
|
||
heightInp.value = existing && existing.height_m != null ? existing.height_m : 0;
|
||
} else {
|
||
heightLabel.textContent = "建筑高度";
|
||
heightInp.min = "0";
|
||
heightInp.value = existing && existing.height_m != null ? existing.height_m : this.defaultHeightForType(brushType.id);
|
||
}
|
||
}
|
||
|
||
// 连通多选:列出同层级其他已命名元素
|
||
const d = this.levelData[this.currentLevel];
|
||
const others = d.elements.filter(e => e !== existing && e.name);
|
||
if (others.length > 0) {
|
||
connRow.style.display = "";
|
||
connList.innerHTML = "";
|
||
const existingConns = existing ? (existing.connections || []) : [];
|
||
others.forEach(o => {
|
||
const lbl = document.createElement("label");
|
||
const cb = document.createElement("input");
|
||
cb.type = "checkbox"; cb.value = o.id;
|
||
cb.checked = existingConns.includes(o.id);
|
||
const sw = document.createElement("span");
|
||
sw.className = "el-swatch"; sw.style.background = o.color; sw.style.width = "12px"; sw.style.height = "12px";
|
||
const txt = document.createElement("span"); txt.textContent = o.name;
|
||
lbl.append(cb, sw, txt);
|
||
connList.appendChild(lbl);
|
||
});
|
||
} else {
|
||
connRow.style.display = "none";
|
||
connList.innerHTML = "";
|
||
}
|
||
|
||
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;
|
||
const height_m = showHeight ? parseFloat(heightInp.value) || 0 : null;
|
||
const connIds = Array.from(connList.querySelectorAll("input:checked")).map(cb => cb.value);
|
||
|
||
if (existing) {
|
||
existing.name = name; existing.hasDetail = hasDetail;
|
||
existing.height_m = height_m;
|
||
existing.connections = connIds;
|
||
} else {
|
||
const el = {
|
||
id: "el_"+Date.now(), type: brushType.id, name, color: brushType.hex, px, py,
|
||
hasDetail, detailRef: null,
|
||
height_m, connections: connIds,
|
||
};
|
||
this.levelData[this.currentLevel].elements.push(el);
|
||
if (hasDetail && this.currentLevel === "L1") {
|
||
const l2Id = "L2-" + el.id;
|
||
this.initLevel(l2Id, 80, 60, 800, 600);
|
||
this.levelData[l2Id].parentLevel = "L1";
|
||
this.levelData[l2Id].parentElement = el.id;
|
||
el.detailRef = l2Id;
|
||
}
|
||
}
|
||
|
||
// 双向连接:同步到被连通的元素
|
||
connIds.forEach(cId => {
|
||
const target = d.elements.find(e => e.id === cId);
|
||
if (target) {
|
||
if (!target.connections) target.connections = [];
|
||
const elId = existing ? existing.id : this.levelData[this.currentLevel].elements[this.levelData[this.currentLevel].elements.length - 1].id;
|
||
if (!target.connections.includes(elId)) target.connections.push(elId);
|
||
}
|
||
});
|
||
|
||
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);
|
||
const isL1 = this.currentLevel === "L1";
|
||
d.elements.forEach(el => {
|
||
// L1 只显示区域(zone)名,L2 只显示建筑(building)名
|
||
if (isL1 && el.type !== "zone") return;
|
||
if (!isL1 && el.type !== "building") return;
|
||
|
||
ctx.font = "bold 12px sans-serif";
|
||
// L1: height_m = 地形标高;L2: height_m = 建筑高度
|
||
let label = el.name;
|
||
if (el.height_m != null && el.height_m !== 0) {
|
||
label = isL1
|
||
? `${el.name} (标高${el.height_m}m)`
|
||
: `${el.name} (${el.height_m}m)`;
|
||
}
|
||
const tw = ctx.measureText(label).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(label, 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,
|
||
height_m: el.height_m != null ? el.height_m : null,
|
||
connections: el.connections || [],
|
||
})),
|
||
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" });
|
||
|
||
// 优先 POST 到本地服务器,失败则回退浏览器下载
|
||
const savedToServer = await this.saveToServer(`${name}-scene.zip`, blob);
|
||
if (!savedToServer) {
|
||
this.downloadBlob(blob, `${name}-scene.zip`);
|
||
}
|
||
this.showSaveFeedback(savedToServer ? `已保存到 output/ ✓` : "ZIP 已下载 ✓");
|
||
},
|
||
|
||
/** 尝试通过本地服务器保存到 output/ 目录 */
|
||
async saveToServer(filename, blob) {
|
||
try {
|
||
const fd = new FormData();
|
||
fd.append("file", blob, filename);
|
||
const resp = await fetch("/save", { method: "POST", body: fd });
|
||
return resp.ok;
|
||
} catch { return false; }
|
||
},
|
||
|
||
/** 服务器是否可用 */
|
||
async serverAvailable() {
|
||
try {
|
||
const resp = await fetch("/list", { method: "GET" });
|
||
return resp.ok;
|
||
} catch { return false; }
|
||
},
|
||
|
||
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);
|
||
}
|
||
},
|
||
|
||
/** 弹出 output/ 文件选择列表 */
|
||
async showOutputModal() {
|
||
try {
|
||
const resp = await fetch("/list");
|
||
const { files } = await resp.json();
|
||
const modal = document.getElementById("output-modal");
|
||
const list = document.getElementById("output-file-list");
|
||
list.innerHTML = "";
|
||
if (files.length === 0) {
|
||
list.innerHTML = '<div style="color:#999;font-size:12px;padding:8px;">output/ 目录为空</div>';
|
||
} else {
|
||
files.forEach(f => {
|
||
const row = document.createElement("div");
|
||
row.style.cssText = "padding:6px 8px;cursor:pointer;border-radius:3px;font-size:12px;display:flex;align-items:center;gap:8px;";
|
||
row.addEventListener("mouseenter", () => row.style.background = "#3a3a3a");
|
||
row.addEventListener("mouseleave", () => row.style.background = "");
|
||
const icon = f.endsWith(".zip") ? "📦" : "📄";
|
||
row.innerHTML = `<span>${icon}</span><span style="flex:1;color:#ddd;">${f}</span>`;
|
||
row.addEventListener("click", async () => {
|
||
modal.style.display = "none";
|
||
await this.loadFromServer(f);
|
||
});
|
||
list.appendChild(row);
|
||
});
|
||
}
|
||
modal.style.display = "block";
|
||
} catch {
|
||
alert("无法连接本地服务器,请确认 server.py 正在运行");
|
||
}
|
||
},
|
||
|
||
/** 从服务器 output/ 加载文件 */
|
||
async loadFromServer(filename) {
|
||
try {
|
||
const resp = await fetch("/load/" + encodeURIComponent(filename));
|
||
if (!resp.ok) throw new Error("文件不存在");
|
||
if (filename.endsWith(".zip")) {
|
||
const ab = await resp.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 resp.text();
|
||
await this.restoreScene(JSON.parse(txt), null);
|
||
}
|
||
this.showSaveFeedback(`已加载 ${filename} ✓`);
|
||
} catch (err) {
|
||
alert("加载失败:" + err.message);
|
||
}
|
||
},
|
||
|
||
/** 智能加载:服务器在则从 output/ 读,否则回退浏览器文件选择 */
|
||
async smartLoad() {
|
||
const ok = await this._serverOk;
|
||
if (ok) {
|
||
this.showOutputModal();
|
||
} else {
|
||
document.getElementById("file-input").click();
|
||
}
|
||
},
|
||
|
||
/** 从 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,
|
||
height_m: el.height_m != null ? el.height_m : null,
|
||
connections: el.connections || [],
|
||
})),
|
||
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>
|