Files
EVEngine/docs/dev/Tilemap设计.md

246 lines
9.7 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Tiled 格式 + 非正交 Tilemap 设计
日期:2026-08-07
状态:已确认(方案 2)
存放:`docs/`(仓库忽略 `docs/dev/superpowers/`,故规格文档放此处)
## 目标
在现有 `map` 模块(正交 JSON 子集 + `TileLayer` / `TileRenderSystem`)上:
1. 支持可用的 **Tiled JSON 导出**(含 `base64` + `zlib`/`gzip` 层数据)
2. 支持投影:**isometric**、**staggered**、**hexagonal**(优先实现经典菱形 isometric)
3. **统一 2D 绘制队列**,使同 `layer` 内 tile 与精灵按脚点 Y(`depthY`)穿插排序
4. 解析 **对象层**(位置/类型等),供脚本挂实体;不自动生成渲染实体
## 非目标(v1)
- 无限地图(`infinite` / chunks)
- 外部 tileset(`.tsx` / `source`)
- TMX XML
- GID 翻转/对角旋转绘制(高位仍剥离,不改 UV)
- `compression: "zstd"`(当前无解码依赖;导出请用 zlib;可后补)
- 粒子 / UI 进入统一 2D 队列
- 视锥裁剪 / chunk 批处理(可后加;宽相候选可用独立模块 [`spatial`](./空间索引模块设计.md))
## 背景(现状)
- `TileConfig` 已能读接近 Tiled 的正交 JSON:`layers` / `tilesets` / `data` 整数数组 / `firstgid` / opacity / offset
- `TileRenderSystem` 仅正交放置:`wx = originX + tx * tileW`
- `RenderSystem` 与 `TileRenderSystem` 分两次绘制,同层无法穿插
- `data` 模块已有 `base64` 编解码与 `zlib`/`gzip`/`deflate`/`lz4` 压缩
## 方案选择
采用 **方案 2:统一 2D 绘制队列**。
| 方案 | 摘要 | 结论 |
|------|------|------|
| 1 双系统各自 Y 排序 | 改动小 | 无法真正 tile↔精灵穿插 |
| **2 统一队列** | sprite + tile 同一 `(layer, depthY)` 排序 | **采用** |
| 3 每格一个 Renderable2D | 排序天然统一 | 实体爆炸,不适合正式地图 |
## 数据模型与投影 API
### `TileLayer::Config` 扩展
在现有字段上增加(缺省 = 正交,兼容现有 `demo.json`):
| 字段 | 含义 | Tiled |
|------|------|-------|
| `orientation` | Orthogonal / Isometric / Staggered / Hexagonal | `orientation` |
| `staggerAxis` | X / Y | `staggeraxis` |
| `staggerIndex` | Odd / Even | `staggerindex` |
| `hexSideLength` | 六边形边长 | `hexsidelength` |
渲染仍使用**轴对齐纹理矩形**;菱形/六边形形状画在贴图内,引擎只改世界坐标与排序键。
### 投影(`map/TileProjection.h`)
纯函数,可单测:
- `tileToWorld(cfg, tx, ty) → (wx, wy)` — 与 Tiled 锚点一致(iso:菱形顶;staggered/hex:包围盒顶)
- `tileToDepthY(cfg, tx, ty) → float` — 脚点 Y,作同层排序键
- orthogonal:`originY + (ty + 1) * tileH`
- isometric:菱形底边中点 Y
- staggered / hex:tile 底边 Y
- `worldToTile(cfg, wx, wy) → (tx, ty)` — 拾取用(取最近格)
脚本暴露:`tileToWorld` / `worldToTile` / `depthY`(或等价命名)。
### 对象层
轻量结构(不必是 ECS Entity):
```text
MapObject { name, type, x, y, width, height, gid? }
```
- 解析 `layers[].type == "objectgroup"`
- **不**自动创建 `Renderable2D`
- 脚本按 `type`/`name` 挂实体,并用对象 `y` 或 `tileToDepthY` 设深度
API(采用 A):
- `Map::loadFromFile` 填充模块内对象缓存
- `Map::getObjectCount()` / `Map::getObject(i)`(或等价只读访问)
- 热重载同 path 时同步刷新对象缓存
## 统一 2D 绘制队列
### `DrawItem2D`
共享结构(`graphics` 侧,或紧邻 `RenderSystem`):
```text
canvas, layer, depthY,
x, y, w, h,
texture / UV 或 solid,
tint, camera, lit 相关字段
```
### 收集
1. **Renderable2D**:`depthY = transform.y + sprite.height * sy`(默认脚点 = 底边;可选 anchor 可后加)
2. **可见非空 tile**:`depthY = tileToDepthY(...)`;位置来自 `tileToWorld`;尺寸为 map/tileset 的 tile 像素尺寸;GID 0 不入队
### 排序(稳定)
`(offscreen canvas 优先) → canvas → layer → depthY → (lit / shader / texture 亲和)`
### 绘制入口
- 帧循环走统一入口(例如 `RenderSystem::render2D`):先收集 sprite + tile,再排序绘制
- `TileRenderSystem::render` 变为向队列贡献 tile,或内联进统一入口;脚本 API 不变
- 粒子 / UI 保持独立 pass
### 脚本约定
- 同层穿插:相同 `layer`,`Transform2D.y` 为脚点世界 Y
- 整层压上/下:用不同 `layer`,不依赖 `depthY`
### 性能(v1)
全量收集非空 tile;视锥裁剪 / chunk 后加。
## Tiled JSON 解码
### Layer `data`
`decodeLayerData(layerObj, expectedCount, outGids, error)`:
1. **整数数组** — 现有路径
2. **`encoding: "base64"` 字符串** — `data::decode("base64", …)`
3. **`compression`**:`zlib` / `gzip` → `data::decompress`;空或无压缩字段则直接解析字节;`zstd` → 明确失败
字节流按小端 `uint32` GID 解析;长度必须等于 `width * height`,否则失败。
### 地图全局
`applyMapGlobals` / `loadMapFile` 读取并写入各 `TileLayer::Config`:
`orientation`, `staggeraxis`, `staggerindex`, `hexsidelength`
多 tile layer 共享同一套投影参数。
### 加载流程
```text
读 JSON
→ 解析 map 全局 + 第一个内嵌 tileset(带 source 的跳过并记录错误/警告)
→ 遍历 layers:
tilelayer → TileLayer + decodeLayerData + draw/offset
objectgroup → 追加 MapObject
其他 → 忽略
→ 绑定 Resource.path / hot-reload
```
失败时:不留下半初始化 layer(`loadMapFile` 回滚已创建实体)。未知 `orientation` → 失败(不静默回退)。
## 错误处理
| 情况 | 行为 |
|------|------|
| JSON 非法 / 非 object | 失败 + error 字符串 |
| base64 / 解压失败 | 失败 |
| GID 数量不匹配 | 失败 |
| `zstd` | 失败,提示改用 zlib |
| 未知 orientation | 失败 |
| 外部 tileset `source` | 跳过该 tileset;若无可用内嵌 tileset 则无贴图(可 solid 调试色) |
## 测试
- 投影:ortho / iso / staggered / hex 的 `tileToWorld` / `worldToTile` / `depthY` 固定样例
- 解码:明文数组 + base64+zlib 往返
- 对象层:最小 JSON 读出 name/type/xy
- 回归:现有 `test/map.cpp` 正交用例
## 主要改动文件(预期)
- `src/modules/map/TileLayer.h` — Config 字段 + 脚本投影 API
- `src/modules/map/TileProjection.h` (+ `.cpp` 若需要)
- `src/modules/map/TileConfig.cpp` — orientation、压缩 data、objectgroup
- `src/modules/map/Map.h` / `Map.cpp` — 对象缓存 API、渲染入口协调
- `src/modules/map/TileSystem.cpp` — 贡献 tile draw items
- `src/modules/graphics/RenderSystem.h` / `.cpp` — 统一队列 + depthY 排序
- `test/map.cpp`(及必要时新测试文件)
## 成功标准
1. Tiled 导出的正交 / isometric / staggered / hexagonal JSON(base64+zlib)可加载并正确放置
2. 同 layer 内精灵与 tile 按脚点 Y 正确遮挡
3. 对象层可枚举;脚本可据此生成实体
4. 现有正交 demo / 测试不回归
## Dual Grid(双网格)
### Tiled 是否支持?
**不原生支持** Oskar Stålberg 式 dual-grid(显示瓦片相对逻辑格半格偏移、由四角采样生成 15 片)。
Tiled 提供的相关能力:
| 能力 | 说明 | 与 dual-grid 关系 |
|------|------|-------------------|
| Terrain / Wang **Corner Set** | 同网格角匹配,2 地形完整集 16 片 | 掩码位数相近,但**无半格偏移**,画在逻辑格上 |
| Terrain Mixed / Blob | 边+角,可达 47 / 256 | 单网格经典 autotile |
| Automapping | 规则替换 | 可手写近似,非内置 dual-grid |
| Major Grid | 每 N 格加粗线 | 仅编辑器辅助,无关 autotile |
推荐工作流:在 Tiled 画**逻辑层**(填充 / 空,任意非 0 GID 或指定 `filledGid`)→ 引擎 `resolveDualGrid` 生成**显示层**。
### 引擎 API
- `DualGrid.h`:`dualGridMaskAt` / `dualGridDefaultFrame` / `dualGridHalfOffset` / `resolveDualGrid`
- 脚本:`Map.resolveDualGrid(logic, display)`、`resolveDualGridFilled`、`dualGridOffsetX/Y`
- 显示层尺寸 `(logicW+1)×(logicH+1)`;复制 `orientation` / `stagger*` / `hexSideLength`
- 半步 origin 偏移(相对逻辑 origin):
| orientation | offset |
|-------------|--------|
| Orthogonal | `(-tileW/2, -tileH/2)` |
| Isometric | `(0, -tileH/2)`(等价逻辑坐标 `(tx-0.5, ty-0.5)`) |
| Staggered / Hexagonal(Y) | `(-tileW/2, -pitchY/2)` |
| Staggered / Hexagonal(X) | `(-pitchX/2, -tileH/2)` |
- 角掩码在**格子索引空间**采样(与投影无关);默认图集为 SpriteCook 式 4×4 frame 表;`useDefaultFrameTable=false` 时 `gid = firstGid + mask`
角位:`TL=1, TR=2, BL=4, BR=8`。mask 0 不绘制。
### 程序化过渡 mask
`generateDualGridMaskAtlas` 从四个角场自动生成 0..15 的完整 mask、平滑 coverage 与
signed-distance field。相同配置和 seed 的结果确定,可按配置缓存;拓扑仍由逻辑 terrain grid
唯一拥有,生成图只是可重建的显示投影。`bandAt` 可从同一 SDF 派生湿沙、浪花、积雪边缘等带状层。
普通 RGBA8 tile 可直接交给 `bakeDualGridTransitionAtlas(A, B, config)`,得到按 mask 行优先排列的
4×4 atlas:frame 0 是 A,frame 15 是 B,中间 14 帧由程序化 mask 混合。这样导入任意 grass、
sand、dirt、water tile 时无需美术提供 A→B 的 autotile atlas;运行时继续使用现有 TileLayer 与
DualGrid frame 选择。海岸等特殊视觉只消费同一 SDF,不改变拓扑或再维护一份地形状态。
测试使用的最小 CC0 SBS 等距素材集位于
`test/assets/map/sbs_isometric_overworld_flat`。从仓库根目录设置
`EVENGINE_DUAL_GRID_FOUR_MATERIAL_PREVIEW` 为目标 BMP 路径并运行
`map.dualGridMask.worldSpaceFourMaterialPreview`,即可生成草地、沙地、石头、水体四材质预览;
默认直接读取随测试打包的三张图集,也可通过对应环境变量替换素材。