feat(world-sketcher): 新增本地服务器、标签显示和README重写
- 新增 server.py + start.bat: 本地保存/加载服务器 - sketch.html: 切换层级时自动绘制元素标签 - L1 显示区域名称+地形标高,L2 显示建筑名称+高度 - 保存优先POST到服务器,加载优先从output/读取 - 命名弹窗区分'地形标高'和'建筑高度' - README 全面重写:双模式启动、文件结构、API端点、高度语义
This commit is contained in:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user