feat(world-sketcher): 增加高度标注和连通关系,AI分析改为读JSON
- sketch.html: 双击命名弹窗增加高度(m)输入和连通多选 - sketch.html: 新增defaultHeightForType()按画笔类型设默认高度 - sketch.html: 连通关系双向同步,JSON输出增加height_m/connections - sketch.html: restoreScene恢复新字段,保存/加载流程不受影响 - SKILL.md: 步骤3从vision分析PNG改为读取JSON结构化数据 - SKILL.md: 布局解读文档模板增加高度和连通列 - README.md: 画笔图例增加默认高度,更新JSON示例和AI流程说明 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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<XX>-<名称>.md` 的氛围描述
|
||||
4. 产出布局解读文档
|
||||
**数据源以 JSON 为准,不依赖 PNG 像素分析。**
|
||||
|
||||
1. 读取 JSON 布局数据(`*-scene.json`):elements(含 `height_m`、`connections`)、regions(含 `bounds_world_m`)、层级关系
|
||||
2. 交叉参考场景设计案 `场景-v<XX>-<名称>.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 直接采用
|
||||
|
||||
@@ -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* 标识色材质
|
||||
|
||||
@@ -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); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -165,6 +173,15 @@ html, body { height: 100%; overflow: hidden; font-family: "Microsoft YaHei", "Se
|
||||
<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 style="color:var(--text-dim);">高度</label>
|
||||
<input id="name-height" type="number" min="0" 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>
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user