diff --git a/.claude/skills/world-sketcher/SKILL.md b/.claude/skills/world-sketcher/SKILL.md index c14c2cd..ee2f338 100644 --- a/.claude/skills/world-sketcher/SKILL.md +++ b/.claude/skills/world-sketcher/SKILL.md @@ -42,14 +42,14 @@ tools/world-sketcher/ ## 画笔颜色 → 材质映射 -| 画笔 | 颜色 | 地形 | UE 材质 | -|------|------|------|---------| -| 道路 Road | `#909090` 灰 | 道路 | MI_DevGray | -| 建筑 Building | `#E07020` 橙 | 建筑物 | MI_DevOrange | -| 水域 Water | `#40A0D0` 天蓝 | 河流/水塘 | MI_DevSkyBlue | -| 植被 Greenery | `#40B040` 绿 | 树木/草地 | MI_DevGreen | -| 不可通行 Blocked | `#A07040` 棕 | 山体/围墙 | MI_DevBrown | -| 区域 Zone | `#D04040` 红 | 区域划分 | MI_DevRed | +| 画笔 | 颜色 | 地形 | 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 | --- @@ -71,12 +71,15 @@ tools/world-sketcher/ 5. 进入 L2 后用建筑、道路等画笔细化区域内部 6. 完成后点"保存全部",将文件保存到 `output/` 目录 -### 步骤 3:AI 分析草图 +### 步骤 3:AI 读取布局数据 -1. 读取 PNG 草图(使用 vision 分析) -2. 读取 JSON 伴随数据(scale、elements、层级关系) -3. 交叉参考场景设计案 `场景-v-<名称>.md` 的氛围描述 -4. 产出布局解读文档 +**数据源以 JSON 为准,不依赖 PNG 像素分析。** + +1. 读取 JSON 布局数据(`*-scene.json`):elements(含 `height_m`、`connections`)、regions(含 `bounds_world_m`)、层级关系 +2. 交叉参考场景设计案 `场景-v-<名称>.md` 的氛围描述,将 JSON 区域与场景设计案的区域描述建立映射 +3. 如需视觉确认,可参考预览 PNG(非必须,仅用于核对区域形状) + +> **说明**:PNG 文件仍保存在 ZIP 中用于保存/加载画布,但 AI 分析流程以结构化 JSON 为准。 ### 步骤 4:产出布局解读文档 @@ -99,8 +102,8 @@ tools/world-sketcher/ ## L1 场景总览 ### 识别区域 -| 区域编号 | 名称 | 推测坐标范围 (cm) | 推测尺寸 | 地形类型 | 标识色建议 | -|---------|------|-------------------|---------|---------|-----------| +| 区域编号 | 名称 | 推测坐标范围 (cm) | 推测尺寸 | 地形类型 | 高度 (m) | 连通到 | 标识色建议 | +|---------|------|-------------------|---------|---------|---------|--------|-----------| ### 识别道路/河流 | 编号 | 名称 | 类型 | 走向 | 长度 | @@ -111,7 +114,7 @@ tools/world-sketcher/ ## L2 区域详图(如有) ### <区域名> -| 编号 | 名称 | 类型 | 推测坐标 | 尺寸 | +| 编号 | 名称 | 类型 | 推测坐标 | 尺寸 | 高度 (m) | 连通到 | ## 待确认问题 1. ... @@ -120,6 +123,12 @@ tools/world-sketcher/ - ... ``` +**数据来源说明**: +- 区域坐标、尺寸:来自 JSON 中 `regions[].bounds_world_m`,乘以 100 转为 cm +- 高度:来自 JSON 中 `elements[].height_m`,直接采用(无需 AI 推断) +- 连通关系:来自 JSON 中 `elements[].connections`,直接采用(无需 AI 推断) +- 地形类型和标识色:来自 JSON 中 `elements[].type` → brush_legend 映射 + ### 步骤 5:用户确认 将解读文档呈现给用户,等待用户确认或修正。确认后,该文档作为关卡执行案的前置输入。 @@ -143,3 +152,5 @@ tools/world-sketcher/ - 坐标系与 `zone_config.py` 一致:原点西南角,X+ 东,Y+ 北,单位 cm - AI 解读时如有不确定之处,**必须**列入"待确认问题",不要自行假设 - L3 建筑内部本期不实现,仅预留 `has_interior` 标记 +- **高度 (`height_m`)**:由用户在双击命名时输入,AI 直接采用,不做推断 +- **连通关系 (`connections`)**:由用户在双击命名时勾选,双向同步,AI 直接采用 diff --git a/tools/world-sketcher/README.md b/tools/world-sketcher/README.md index f7da0c1..f25957e 100644 --- a/tools/world-sketcher/README.md +++ b/tools/world-sketcher/README.md @@ -8,9 +8,12 @@ 1. 双击 `sketch.html` 在浏览器中打开(推荐 Chrome / Edge) 2. 左侧设置场景名称和世界尺寸(米) 3. 选择画笔,在画布上绘制 -4. **双击** 已绘制区域 → 为该区域命名 -5. 勾选「可进入」→ 创建下级详图(L1 → L2) -6. 保存 PNG + JSON +4. **双击** 已绘制区域 → 弹出命名对话框: + - 输入名称(如"家"、"小卖部") + - 设置**高度**(建筑/区域/不可通行类型时显示,默认 3m) + - 勾选**连通到**其他已命名区域(建立区域间连接关系) + - 勾选「可进入」→ 创建下级详图(L1 → L2) +5. 保存 ZIP(含 PNG + JSON) --- @@ -32,15 +35,15 @@ ## 画笔图例 -| 画笔 | 颜色 | 用途 | 对应 UE 材质 | -|------|------|------|-------------| -| 道路 Road | 灰 `#909090` | 道路、马路、小路 | MI_DevGray | -| 建筑 Building | 橙 `#E07020` | 建筑物 | MI_DevOrange | -| 水域 Water | 天蓝 `#40A0D0` | 河流、水塘、水体 | MI_DevSkyBlue | -| 植被 Greenery | 绿 `#40B040` | 树木、草地、绿化带 | MI_DevGreen | -| 不可通行 Blocked | 棕 `#A07040` | 山体、围墙、不可达区域 | MI_DevBrown | -| 区域 Zone | 红 `#D04040` | 区域边界、地块划分 | MI_DevRed | -| 橡皮擦 Eraser | — | 擦除 | — | +| 画笔 | 颜色 | 用途 | 对应 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 层**主要用:建筑、道路、植被、水域、不可通行 @@ -89,7 +92,7 @@ ```json { "scene_name": "城郊小街", - "version": "v01", + "version": "v02", "origin": "SW corner, X+ East, Y+ North", "brush_legend": { "色值": { "type": "类型", "mi_material": "MI_Dev*" } }, "levels": { @@ -98,16 +101,25 @@ "canvas_size_px": [1000, 800], "scale_m_per_px": 0.5, "elements": [ - { "id": "el_xxx", "type": "zone", "name": "城郊小街", "has_detail": true, "detail_ref": "L2-el_xxx" } + { "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" } + ], + "regions": [ + { "id": "r_001", "type": "zone", "name": "家", + "bounds_world_m": [5.0, 5.0, 12.5, 15.0], "area_px": 15000 } ] }, - "L2-el_xxx": { + "L2-el_003": { "world_size_m": [80, 60], "elements": [ - { "id": "el_yyy", "type": "building", "name": "家", "has_detail": true } + { "id": "el_yyy", "type": "building", "name": "家", "height_m": 3, "connections": [], "has_detail": false } ], "parent_level": "L1", - "parent_element": "el_xxx" + "parent_element": "el_003" } } } @@ -133,17 +145,20 @@ AI 布局解读文档 (场景名-layout-vXX.md) ### AI 解读流程 -1. 用户在工具中画好布局,保存 PNG + JSON -2. 将 PNG 提供给 AI(Claude vision) -3. AI 分析颜色区域,结合 JSON 中的比例尺和命名信息 -4. AI 产出结构化布局解读 Markdown(区域表、连通关系、待确认问题) +1. 用户在工具中画好布局,命名区域,设置高度和连通关系,保存 ZIP +2. AI 读取 JSON 布局数据(elements + regions + connections + height_m) +3. AI 交叉参考场景设计案的氛围描述,将 JSON 数据与场景概念建立映射 +4. AI 产出结构化布局解读 Markdown(区域表、连通关系、高度、待确认问题) 5. 用户确认后,策划基于解读文档产出关卡执行案 +> **说明**:PNG 文件保存在 ZIP 中用于画布的保存/加载,AI 分析以 JSON 结构化数据为准,不依赖 PNG 像素分析。 + --- ## 设计原则 - **强制使用预定义画笔**:无自由拾色器,保证每个像素都映射到已知地形类型 +- **结构化数据优先**:高度和连通关系由用户直接输入,AI 无需从图像推断 - **层次化设计**:L1 定大局,L2 定细节,L3 预留 -- **人机共读**:PNG 供人和 AI 视觉分析,JSON 供程序化解析 +- **人机共读**:PNG 供人视觉确认,JSON 供 AI 和程序化解析 - **与 Level Builder 一致**:画笔颜色直接映射 MI_Dev* 标识色材质 diff --git a/tools/world-sketcher/sketch.html b/tools/world-sketcher/sketch.html index 4402b31..8c59c37 100644 --- a/tools/world-sketcher/sketch.html +++ b/tools/world-sketcher/sketch.html @@ -85,6 +85,14 @@ html, body { height: 100%; overflow: hidden; font-family: "Microsoft YaHei", "Se #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); } @@ -165,6 +173,15 @@ html, body { height: 100%; overflow: hidden; font-family: "Microsoft YaHei", "Se

命名元素

+ +
@@ -642,6 +659,11 @@ const App = { 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; @@ -651,10 +673,46 @@ const App = { 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); + heightRow.style.display = showHeight ? "flex" : "none"; + if (showHeight) { + 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"); @@ -665,19 +723,39 @@ const App = { 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 }; + 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; - // L2 尺寸延迟到 enterElement 时计算 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); @@ -815,6 +893,8 @@ const App = { 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, @@ -909,6 +989,8 @@ const App = { 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,