9.7 KiB
Tiled 格式 + 非正交 Tilemap 设计
日期:2026-08-07
状态:已确认(方案 2)
存放:docs/(仓库忽略 docs/dev/superpowers/,故规格文档放此处)
目标
在现有 map 模块(正交 JSON 子集 + TileLayer / TileRenderSystem)上:
- 支持可用的 Tiled JSON 导出(含
base64+zlib/gzip层数据) - 支持投影:isometric、staggered、hexagonal(优先实现经典菱形 isometric)
- 统一 2D 绘制队列,使同
layer内 tile 与精灵按脚点 Y(depthY)穿插排序 - 解析 对象层(位置/类型等),供脚本挂实体;不自动生成渲染实体
非目标(v1)
- 无限地图(
infinite/ chunks) - 外部 tileset(
.tsx/source) - TMX XML
- GID 翻转/对角旋转绘制(高位仍剥离,不改 UV)
compression: "zstd"(当前无解码依赖;导出请用 zlib;可后补)- 粒子 / UI 进入统一 2D 队列
- 视锥裁剪 / chunk 批处理(可后加;宽相候选可用独立模块
spatial)
背景(现状)
TileConfig已能读接近 Tiled 的正交 JSON:layers/tilesets/data整数数组 /firstgid/ opacity / offsetTileRenderSystem仅正交放置:wx = originX + tx * tileWRenderSystem与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
- orthogonal:
worldToTile(cfg, wx, wy) → (tx, ty)— 拾取用(取最近格)
脚本暴露:tileToWorld / worldToTile / depthY(或等价命名)。
对象层
轻量结构(不必是 ECS Entity):
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):
canvas, layer, depthY,
x, y, w, h,
texture / UV 或 solid,
tint, camera, lit 相关字段
收集
- Renderable2D:
depthY = transform.y + sprite.height * sy(默认脚点 = 底边;可选 anchor 可后加) - 可见非空 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):
- 整数数组 — 现有路径
encoding: "base64"字符串 —data::decode("base64", …)compression:zlib/gzip→data::decompress;空或无压缩字段则直接解析字节;zstd→ 明确失败
字节流按小端 uint32 GID 解析;长度必须等于 width * height,否则失败。
地图全局
applyMapGlobals / loadMapFile 读取并写入各 TileLayer::Config:
orientation, staggeraxis, staggerindex, hexsidelength
多 tile layer 共享同一套投影参数。
加载流程
读 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 字段 + 脚本投影 APIsrc/modules/map/TileProjection.h(+.cpp若需要)src/modules/map/TileConfig.cpp— orientation、压缩 data、objectgroupsrc/modules/map/Map.h/Map.cpp— 对象缓存 API、渲染入口协调src/modules/map/TileSystem.cpp— 贡献 tile draw itemssrc/modules/graphics/RenderSystem.h/.cpp— 统一队列 + depthY 排序test/map.cpp(及必要时新测试文件)
成功标准
- Tiled 导出的正交 / isometric / staggered / hexagonal JSON(base64+zlib)可加载并正确放置
- 同 layer 内精灵与 tile 按脚点 Y 正确遮挡
- 对象层可枚举;脚本可据此生成实体
- 现有正交 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,即可生成草地、沙地、石头、水体四材质预览;
默认直接读取随测试打包的三张图集,也可通过对应环境变量替换素材。