From c509223e0895f2fd642946979bc5cacff75444a4 Mon Sep 17 00:00:00 2001 From: starmen1265 Date: Wed, 10 Jun 2026 15:49:12 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(world-sketcher):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E6=9C=8D=E5=8A=A1=E5=99=A8=E3=80=81=E6=A0=87?= =?UTF-8?q?=E7=AD=BE=E6=98=BE=E7=A4=BA=E5=92=8CREADME=E9=87=8D=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 server.py + start.bat: 本地保存/加载服务器 - sketch.html: 切换层级时自动绘制元素标签 - L1 显示区域名称+地形标高,L2 显示建筑名称+高度 - 保存优先POST到服务器,加载优先从output/读取 - 命名弹窗区分'地形标高'和'建筑高度' - README 全面重写:双模式启动、文件结构、API端点、高度语义 --- tools/world-sketcher/README.md | 186 +++++++++++++++++++++---------- tools/world-sketcher/server.py | 121 ++++++++++++++++++++ tools/world-sketcher/sketch.html | 145 ++++++++++++++++++++++-- tools/world-sketcher/start.bat | 23 ++++ 4 files changed, 409 insertions(+), 66 deletions(-) create mode 100644 tools/world-sketcher/server.py create mode 100644 tools/world-sketcher/start.bat diff --git a/tools/world-sketcher/README.md b/tools/world-sketcher/README.md index f25957e..a7b1408 100644 --- a/tools/world-sketcher/README.md +++ b/tools/world-sketcher/README.md @@ -3,17 +3,66 @@ > 在场景概念与关卡执行案之间,提供可视化布局沟通工具。 > 用颜色画笔绘制世界布局 → AI 解读 → 双方确认 → 产出执行案。 +--- + ## 快速开始 -1. 双击 `sketch.html` 在浏览器中打开(推荐 Chrome / Edge) -2. 左侧设置场景名称和世界尺寸(米) -3. 选择画笔,在画布上绘制 -4. **双击** 已绘制区域 → 弹出命名对话框: +### 方式一:本地服务器(推荐) + +保存/加载直接读写 `output/` 目录,无需手动下载。 + +1. 双击 `start.bat` +2. 浏览器自动打开 `http://localhost:8765/sketch.html` +3. 开始绘制,点"保存"直接存入 `output/` + +> 需要 Python 3。关闭终端窗口即可停止服务器。 + +### 方式二:直接打开 HTML + +双击 `sketch.html` 在浏览器中打开(推荐 Chrome / Edge)。 + +- 保存时通过浏览器下载 ZIP 文件 +- 加载时通过浏览器文件选择器选择 ZIP + +> **注意**:直接打开 HTML 时加载独立的 `.json` 文件**不会恢复画布位图**——位图数据存储在 ZIP 包内的 PNG 文件中,必须加载 `.zip` 文件才能完整恢复。 + +--- + +## 文件结构 + +``` +world-sketcher/ +├── start.bat ← 启动脚本(双击运行) +├── server.py ← 本地保存/加载服务器 +├── sketch.html ← 主工具(单文件,浏览器运行) +├── README.md +└── output/ ← 保存/加载目录 + ├── 迈皋桥-scene.zip ← 完整场景包(JSON + 所有 PNG) + ├── 迈皋桥-scene.json ← 结构化数据(不含位图) + ├── 迈皋桥-L1-drawing.png # L1 画布像素数据 + ├── 迈皋桥-L1-overview-preview.png # L1 合并预览 + └── 迈皋桥-L2-*-drawing.png # L2 各区域画布数据 +``` + +--- + +## 基本操作 + +1. 左侧设置**场景名称**和**世界尺寸**(米) +2. 选择画笔,在画布上绘制 +3. **双击** 已绘制区域 → 弹出命名对话框: - 输入名称(如"家"、"小卖部") - - 设置**高度**(建筑/区域/不可通行类型时显示,默认 3m) - - 勾选**连通到**其他已命名区域(建立区域间连接关系) + - **L1 区域**:设置**地形标高**(米,可为负值表示洼地) + - **L2 建筑**:设置**建筑高度**(米) + - 勾选**连通到**其他已命名区域(建立连接关系) - 勾选「可进入」→ 创建下级详图(L1 → L2) -5. 保存 ZIP(含 PNG + JSON) +4. 保存 → 存入 `output/`(服务器模式)或浏览器下载(离线模式) + +### 画布标签 + +- **L1 层**:显示区域名称 + 地形标高(如 `棚户区 (标高2m)`) +- **L2 层**:显示建筑名称 + 建筑高度(如 `棚户1 (3m)`) +- 标高为 0 时不显示高度 --- @@ -30,36 +79,30 @@ - L1 画布上,双击已命名的区域并勾选「可进入」 - 左侧元素列表中点击 **"进入 →"** - 面包屑导航点击 **"场景总览"** 返回上级 +- 返回 L1 时,L2 的绘制内容会自动合成回 L1 对应区域 --- ## 画笔图例 -| 画笔 | 颜色 | 用途 | 对应 UE 材质 | 默认高度 | -|------|------|------|-------------|---------| -| 道路 Road | 灰 `#909090` | 道路、马路、小路 | MI_DevGray | — | -| 建筑 Building | 橙 `#E07020` | 建筑物 | MI_DevOrange | 3m | -| 水域 Water | 天蓝 `#40A0D0` | 河流、水塘、水体 | MI_DevSkyBlue | — | -| 植被 Greenery | 绿 `#40B040` | 树木、草地、绿化带 | MI_DevGreen | 2m | -| 不可通行 Blocked | 棕 `#A07040` | 山体、围墙、不可达区域 | MI_DevBrown | 3m | -| 区域 Zone | 红 `#D04040` | 区域边界、地块划分 | MI_DevRed | 3m | -| 橡皮擦 Eraser | — | 擦除 | — | — | - -**L1 层**主要用:区域、道路、水域 -**L2 层**主要用:建筑、道路、植被、水域、不可通行 +| 画笔 | 颜色 | 用途 | 对应 UE 材质 | 可用层级 | 高度含义 | +|------|------|------|-------------|---------|---------| +| 道路 Road | 灰 `#909090` | 道路、马路、小路 | MI_DevGray | L1/L2 | — | +| 建筑 Building | 橙 `#E07020` | 建筑物 | MI_DevOrange | L2 | 建筑高度 | +| 水域 Water | 天蓝 `#40A0D0` | 河流、水塘、水体 | MI_DevSkyBlue | L1/L2 | — | +| 植被 Greenery | 绿 `#40B040` | 树木、草地、绿化带 | MI_DevGreen | L2 | 2m | +| 不可通行 Blocked | 棕 `#A07040` | 山体、围墙、不可达区域 | MI_DevBrown | L1/L2 | 3m | +| 区域 Zone | 红 `#D04040` | 区域边界、地块划分 | MI_DevRed | L1 | 地形标高 | +| 橡皮擦 Eraser | — | 擦除 | — | L1/L2 | — | --- -## 绘图工具 - -| 工具 | 快捷键 | 说明 | -|------|--------|------| -| 画笔 | P | 自由绘制,按住鼠标拖动 | -| 矩形 | R | 绘制矩形区域,拖动定义两角 | -| 橡皮擦 | E | 擦除画布内容 | +## 快捷键 | 操作 | 快捷键 | |------|--------| +| 画笔工具 | P | +| 橡皮擦工具 | E | | 撤销 | Ctrl+Z | | 重做 | Ctrl+Y | | 网格开关 | G | @@ -74,57 +117,87 @@ - **单位**:厘米(与 UE5 一致,1 uu = 1 cm) - 画布顶部 = 世界最大 Y,画布底部 = 世界 Y=0 -状态栏实时显示鼠标位置的世界坐标。 +状态栏实时显示鼠标位置的像素坐标和世界坐标。 --- -## 输出文件 - -保存全部时生成: - -``` -<场景名>-L1-overview.png # L1 画布截图 -<场景名>-scene.json # 完整场景数据(所有层级) -``` +## 输出格式 ### scene.json 结构 ```json { - "scene_name": "城郊小街", + "scene_name": "迈皋桥", "version": "v02", + "created": "2026-06-09T16:46:32", "origin": "SW corner, X+ East, Y+ North", - "brush_legend": { "色值": { "type": "类型", "mi_material": "MI_Dev*" } }, + "brush_legend": { + "#D04040": { "type": "zone", "label": "区域 Zone", "mi_material": "MI_DevRed" } + }, "levels": { "L1": { "world_size_m": [500, 400], "canvas_size_px": [1000, 800], "scale_m_per_px": 0.5, "elements": [ - { "id": "el_001", "type": "zone", "name": "家", "height_m": 3, - "connections": ["el_003"], "has_detail": false }, - { "id": "el_002", "type": "zone", "name": "小卖部", "height_m": 3, - "connections": ["el_003"], "has_detail": false }, - { "id": "el_003", "type": "zone", "name": "主街道", "height_m": 3, - "connections": ["el_001", "el_002", "el_004"], "has_detail": true, "detail_ref": "L2-el_003" } + { + "id": "el_001", "type": "zone", "name": "棚户区", + "height_m": 2, "connections": ["el_002"], + "has_detail": true, "detail_ref": "L2-el_001" + } ], "regions": [ - { "id": "r_001", "type": "zone", "name": "家", - "bounds_world_m": [5.0, 5.0, 12.5, 15.0], "area_px": 15000 } - ] + { + "id": "r_001", "type": "zone", "name": "棚户区", + "bounds_world_m": [5.0, 5.0, 120.0, 95.0], + "area_px": 15000 + } + ], + "image_file": "迈皋桥-L1-drawing.png", + "l1_base_file": null }, - "L2-el_003": { + "L2-el_001": { "world_size_m": [80, 60], "elements": [ - { "id": "el_yyy", "type": "building", "name": "家", "height_m": 3, "connections": [], "has_detail": false } + { "id": "el_010", "type": "building", "name": "棚户1", "height_m": 3 } ], + "image_file": "迈皋桥-L2-el_001-drawing.png", + "l1_base_file": "迈皋桥-L2-el_001-l1base.png", "parent_level": "L1", - "parent_element": "el_003" + "parent_element": "el_001" } } } ``` +### 高度字段语义 + +| 层级 | 元素类型 | `height_m` 含义 | 用途 | +|------|---------|----------------|------| +| L1 | zone(区域) | **地形标高** | 地面海拔相对高度,0=基准面,负值=洼地 | +| L2 | building(建筑) | **建筑高度** | 建筑物垂直高度 | +| L1/L2 | blocked(不可通行) | **障碍高度** | 围墙/山体等垂直尺度 | + +--- + +## 本地服务器 + +`server.py` 提供三个 HTTP 端点,供 `sketch.html` 直接读写 `output/` 目录: + +| 端点 | 方法 | 说明 | +|------|------|------| +| `/list` | GET | 列出 `output/` 下所有 `.json` / `.zip` 文件 | +| `/load/` | GET | 从 `output/` 读取指定文件 | +| `/save` | POST (multipart) | 接收文件并保存到 `output/` | + +服务器运行时,sketch.html 自动检测并使用服务器模式;未运行时自动回退到浏览器下载/文件选择。 + +手动启动: +```bash +python server.py # 默认端口 8765 +python server.py 9000 # 自定义端口 +``` + --- ## 工作流集成 @@ -145,20 +218,21 @@ AI 布局解读文档 (场景名-layout-vXX.md) ### AI 解读流程 -1. 用户在工具中画好布局,命名区域,设置高度和连通关系,保存 ZIP -2. AI 读取 JSON 布局数据(elements + regions + connections + height_m) +1. 用户在工具中画好布局,命名区域,设置标高/高度和连通关系,保存 +2. AI 读取 JSON 中的 elements + regions + connections + height_m 3. AI 交叉参考场景设计案的氛围描述,将 JSON 数据与场景概念建立映射 -4. AI 产出结构化布局解读 Markdown(区域表、连通关系、高度、待确认问题) +4. AI 产出结构化布局解读 Markdown(区域表、连通关系、标高、待确认问题) 5. 用户确认后,策划基于解读文档产出关卡执行案 -> **说明**:PNG 文件保存在 ZIP 中用于画布的保存/加载,AI 分析以 JSON 结构化数据为准,不依赖 PNG 像素分析。 +> PNG 文件用于画布的保存/加载和人工预览。AI 分析以 JSON 结构化数据为准,不依赖 PNG 像素分析。 --- ## 设计原则 -- **强制使用预定义画笔**:无自由拾色器,保证每个像素都映射到已知地形类型 -- **结构化数据优先**:高度和连通关系由用户直接输入,AI 无需从图像推断 +- **预定义画笔**:无自由拾色器,保证每个像素都映射到已知地形类型 +- **结构化数据优先**:标高、高度和连通关系由用户直接输入,AI 无需从图像推断 - **层次化设计**:L1 定大局,L2 定细节,L3 预留 - **人机共读**:PNG 供人视觉确认,JSON 供 AI 和程序化解析 -- **与 Level Builder 一致**:画笔颜色直接映射 MI_Dev* 标识色材质 +- **材质一致**:画笔颜色直接映射 UE5 的 MI_Dev* 标识色材质 +- **离线优先**:单 HTML 文件即可使用,服务器仅为便利增强 diff --git a/tools/world-sketcher/server.py b/tools/world-sketcher/server.py new file mode 100644 index 0000000..1bafa43 --- /dev/null +++ b/tools/world-sketcher/server.py @@ -0,0 +1,121 @@ +""" +World Sketcher 本地保存服务器 +用法: python server.py [port] +然后浏览器打开 http://localhost:/sketch.html +""" + +import http.server, json, os, sys, urllib.parse +from datetime import datetime + +PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8765 +SERVE_DIR = os.path.dirname(os.path.abspath(__file__)) +OUTPUT_DIR = os.path.join(SERVE_DIR, "output") +os.makedirs(OUTPUT_DIR, exist_ok=True) + + +class Handler(http.server.SimpleHTTPRequestHandler): + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=SERVE_DIR, **kwargs) + + # ── POST /save ── 保存文件到 output/ + def do_POST(self): + if self.path != "/save": + self.send_error(404); return + + ctype = self.headers.get("Content-Type", "") + if ctype.startswith("multipart/form-data"): + boundary = ctype.split("boundary=")[-1].encode() + data = self.rfile.read(int(self.headers["Content-Length"])) + + # 简易 multipart 解析 + parts = data.split(b"--" + boundary) + saved = [] + for part in parts: + if not part or part.strip() in (b"", b"--\r\n", b"--"): + continue + header_end = part.find(b"\r\n\r\n") + if header_end < 0: + continue + header = part[:header_end].decode("utf-8", errors="replace") + body = part[header_end + 4:] + if body.endswith(b"\r\n"): + body = body[:-2] + + # 提取文件名 + fname = None + for seg in header.split(";"): + seg = seg.strip() + if seg.startswith("filename="): + fname = seg.split("=", 1)[1].strip('"') + break + if not fname: + continue + + # 安全:只取文件名,禁止路径遍历 + fname = os.path.basename(fname) + fpath = os.path.join(OUTPUT_DIR, fname) + with open(fpath, "wb") as f: + f.write(body) + saved.append(fname) + + resp = json.dumps({"ok": True, "saved": saved}, ensure_ascii=False) + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(resp.encode("utf-8")) + print(f" [{datetime.now():%H:%M:%S}] Saved: {', '.join(saved)}") + else: + self.send_error(400, "Expected multipart/form-data") + + # ── GET 路由 ── + def do_GET(self): + # /list → 列出 output/ 下可用文件 + if self.path == "/list": + files = sorted( + f for f in os.listdir(OUTPUT_DIR) + if f.endswith((".json", ".zip")) + ) + resp = json.dumps({"files": files}, ensure_ascii=False) + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.end_headers() + self.wfile.write(resp.encode("utf-8")) + return + + # /load/ → 从 output/ 读取文件 + if self.path.startswith("/load/"): + fname = os.path.basename(urllib.parse.unquote(self.path[6:], encoding="utf-8")) + fpath = os.path.join(OUTPUT_DIR, fname) + if not os.path.isfile(fpath): + self.send_error(404); return + self.send_response(200) + if fname.endswith(".zip"): + self.send_header("Content-Type", "application/zip") + elif fname.endswith(".json"): + self.send_header("Content-Type", "application/json") + self.end_headers() + with open(fpath, "rb") as f: + self.wfile.write(f.read()) + return + + # 其他 → 静态文件(sketch.html 等) + super().do_GET() + + def log_message(self, format, *args): + # 只显示非静态资源的请求日志 + if "/save" in str(args) or "/list" in str(args) or "/load/" in str(args): + super().log_message(format, *args) + + +if __name__ == "__main__": + with http.server.HTTPServer(("", PORT), Handler) as httpd: + url = f"http://localhost:{PORT}/sketch.html" + print(f"World Sketcher server running") + print(f" URL: {url}") + print(f" Output: {OUTPUT_DIR}") + print(f" Press Ctrl+C to stop.\n") + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nServer stopped.") diff --git a/tools/world-sketcher/sketch.html b/tools/world-sketcher/sketch.html index 8c59c37..368b5ba 100644 --- a/tools/world-sketcher/sketch.html +++ b/tools/world-sketcher/sketch.html @@ -147,7 +147,7 @@ html, body { height: 100%; overflow: hidden; font-family: "Microsoft YaHei", "Se
- +
@@ -174,8 +174,8 @@ html, body { height: 100%; overflow: hidden; font-family: "Microsoft YaHei", "Se