feat(world-sketcher): 新增本地服务器、标签显示和README重写

- 新增 server.py + start.bat: 本地保存/加载服务器
- sketch.html: 切换层级时自动绘制元素标签
- L1 显示区域名称+地形标高,L2 显示建筑名称+高度
- 保存优先POST到服务器,加载优先从output/读取
- 命名弹窗区分'地形标高'和'建筑高度'
- README 全面重写:双模式启动、文件结构、API端点、高度语义
This commit is contained in:
2026-06-10 15:49:12 +08:00
parent b8f27221b2
commit c509223e08
4 changed files with 409 additions and 66 deletions
+135 -10
View File
@@ -147,7 +147,7 @@ html, body { height: 100%; overflow: hidden; font-family: "Microsoft YaHei", "Se
<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-save-all">保存</button>
<button class="tbtn" id="btn-load">加载</button>
</div>
</div>
@@ -174,8 +174,8 @@ html, body { height: 100%; overflow: hidden; font-family: "Microsoft YaHei", "Se
<input id="name-input" type="text" placeholder="输入名称(如"家"、"小卖部")">
<label><input type="checkbox" id="name-enterable"> 可进入(有下级详图)</label>
<div id="name-height-row" style="display:none;">
<label style="color:var(--text-dim);">高度</label>
<input id="name-height" type="number" min="0" step="0.5" value="3">
<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;">
@@ -191,6 +191,17 @@ html, body { height: 100%; overflow: hidden; font-family: "Microsoft YaHei", "Se
<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";
@@ -256,6 +267,8 @@ const App = {
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) {
@@ -303,6 +316,7 @@ const App = {
this.renderBrushPalette();
this.updateBreadcrumb();
this.updateElementsList();
this.drawElementLabels();
this.updateScaleDisplay();
this.updateStatus();
},
@@ -356,7 +370,10 @@ const App = {
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());
document.getElementById("btn-load").addEventListener("click", () => this.smartLoad());
document.getElementById("output-modal-close").addEventListener("click", () => {
document.getElementById("output-modal").style.display = "none";
});
},
/* ---- Canvas 事件 ---- */
@@ -619,7 +636,7 @@ const App = {
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();
this.drawGrid(); this.renderBrushPalette(); this.updateBreadcrumb(); this.updateElementsList(); this.drawElementLabels(); this.updateScaleDisplay(); this.updateStatus();
},
compositeL2BackToL1() {
@@ -685,9 +702,19 @@ const App = {
// 高度输入: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) {
heightInp.value = existing && existing.height_m != null ? existing.height_m : this.defaultHeightForType(brushType.id);
// 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);
}
}
// 连通多选:列出同层级其他已命名元素
@@ -791,14 +818,26 @@ const App = {
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";
const tw = ctx.measureText(el.name).width + 8, th = 18;
// 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(el.name, el.px, ty + th/2);
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();
@@ -929,8 +968,31 @@ const App = {
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 已保存 ✓");
// 优先 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) {
@@ -961,6 +1023,69 @@ const App = {
}
},
/** 弹出 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) => {