MapCalibrator 开发经验总结,直接作为 prompt 交给 AI 复刻
精华03/19100 浏览开发心得
NanoVG 地图编辑器/调校面板 — 架构模式速查一、核心架构:三文件分离MapData.lua — 纯数据 + 坐标工具 + 持久化(不含任何绘制代码)
MapRenderer.lua — 只负责渲染,从 MapData 读取数据驱动绘制
MapCalibrator.lua — 编辑器 UI 叠加层,修改 MapData 中的数据
关键原则:渲染器不持有数据,编辑器不直接绘制游戏元素。三者通过 MapData 这个"单一数据源"解耦。二、数据表设计模式lua
复制-- 所有可编辑元素统一使用 zone-local 归一化坐标(0~1)
MapData.BUILDINGS = {
{ id = "唯一ID", -- 持久化用,不可变
name = "显示名", -- UI 列表显示
zone = "zoneName", -- 所属区域名
nx = 0.50, ny = 0.30, -- zone 内归一化坐标
drawScale = 1.0, -- 绘制缩放
buildingType = "xxx", -- 类型分派键
-- ...其他业务字段
},
}
-- 第二类元素(景观/装饰)完全同构
MapData.LANDSCAPES = {
{ id = "唯一ID", name = "显示名", zone = "zoneName",
nx = 0.30, ny = 0.65, drawScale = 1.0,
landscapeType = "bamboo", -- 类型分派键
extra = { count = 9 }, -- 类型特有参数放 extra 表
},
}
要点:
- nx/ny 是相对 zone 的 0~1 归一化值,不是绝对像素,方便 zone 整体移动/缩放
- extra 表存放类型特有参数(竹子数量、花色、桥旋转角等),避免主表字段爆炸
- id 用中文可读字符串,兼顾调试和持久化
复制-- 1. 归一化 → 绝对像素
function MapData.GetXxxPos(item)
local z = MapData.ZONES[item.zone]
return z.x + item.nx * z.w, z.y + item.ny * z.h
end
-- 2. 屏幕点 → 地图坐标
function MapData.ScreenToMap(sx, sy, cam, scrW, scrH)
return (sx - scrW * 0.5) / cam.zoom + cam.x,
(sy - scrH * 0.5) / cam.zoom + cam.y
end
-- 3. 命中检测(屏幕坐标输入)
function MapData.HitTestXxx(sx, sy, cam, scrW, scrH)
local mx, my = MapData.ScreenToMap(sx, sy, cam, scrW, scrH)
local hitR = 18 / cam.zoom -- 命中半径随缩放自适应
for i, item in ipairs(MapData.XXX) do
local ix, iy = MapData.GetXxxPos(item)
local dx, dy = mx - ix, my - iy
if dx*dx + dy*dy <= hitR*hitR then return i end
end
return nil
end
-- 4. 实时更新单项
function MapData.UpdateXxx(index, fields)
local item = MapData.XXX[index]
if fields.nx then item.nx = fields.nx end
if fields.ny then item.ny = fields.ny end
if fields.drawScale then item.drawScale = fields.drawScale end
end
四、JSON 覆盖持久化模式lua
复制-- 设计思路:代码内保留默认值,JSON 只存用户修改的覆盖层
-- 好处:代码更新默认值不会被旧存档覆盖,新增元素自动使用默认值
function MapData.SaveLayout()
-- 序列化所有元素的 id + nx + ny + drawScale
local data = { version = 2, buildings = {...}, landscapes = {...} }
local file = File("map_layout.json", FILE_WRITE)
file:WriteString(cjson.encode(data))
file:Close()
end
function MapData.LoadLayout()
-- 反序列化后通过 id 索引表快速查找并覆盖
-- 用 _buildingIdIndex[id] → 数组下标 的惰性索引加速
end
五、编辑器 UI 架构(MapCalibrator)5.1 状态设计lua
复制MapCalibrator.active = false -- 模式开关
MapCalibrator.selectedType = nil -- "building" | "landscape" | nil
MapCalibrator.selectedIdx = nil -- 选中项在对应数组中的索引
MapCalibrator.dragging = false -- 拖拽中
MapCalibrator.dragOffX/Y = 0 -- 拖拽偏移(防止元素跳到鼠标位置)
MapCalibrator.listScrollY = 0 -- 列表滚动
MapCalibrator.collapsedZones = {} -- zone折叠状态 { [zoneName] = bool }
5.2 事件消费模式lua
复制-- 在 main.lua 的输入事件中:
if MapCalibrator.active then
if MapCalibrator.HandleMouseDown(lx, ly, cam, w, h) then
return -- 消费了,不传给相机拖拽
end
end
-- 未消费 → 正常相机拖拽逻辑
-- HandleMouseDown 内部返回规则:
-- UI 区域(工具栏/列表/面板)→ return true(总是消费)
-- 地图区域命中元素 → return true(开始拖拽)
-- 地图区域未命中 → return false(允许相机拖拽穿透)
5.3 拖拽实现(带偏移)lua
复制-- MouseDown 时记录偏移
local itemX, itemY = GetPos(item)
local mouseMapX, mouseMapY = ScreenToMap(lx, ly, cam, w, h)
dragOffX = itemX - mouseMapX -- 关键:不是 0,而是差值
dragOffY = itemY - mouseMapY
-- MouseMove 时应用偏移
local mx, my = ScreenToMap(lx, ly, cam, w, h)
mx = mx + dragOffX -- 加回偏移,元素不会跳
my = my + dragOffY
-- 反算回 zone-local 归一化坐标
local newNx = math.max(0.02, math.min(0.98, (mx - z.x) / z.w))
local newNy = math.max(0.02, math.min(0.98, (my - z.y) / z.h))
5.4 渲染分层lua
复制-- 在 MapRenderer.render() 中:
nvgSave(vg)
-- ...相机变换...
-- 游戏内容渲染...
MapCalibrator.RenderHighlight(vg, cam) -- ① 相机空间内:虚线选框
nvgRestore(vg)
MapCalibrator.Render(vg, w, h, cam, fontSans) -- ② 屏幕空间:工具栏/列表/面板
5.5 折叠分组列表lua
复制-- 渲染逻辑:
for _, zoneName in ipairs(ZONE_ORDER) do
-- 绘制 zone 标题(带 +/- 指示符 + 元素计数)
-- 点击标题 → collapsedZones[zoneName] = not collapsedZones[zoneName]
if not collapsed then
-- 先渲染该 zone 的建筑(圆点标记 + 家族色)
-- 再渲染该 zone 的景观(方块标记 + 绿色系)
end
end
-- 命中检测逻辑必须与渲染逻辑完全同构(同样的遍历顺序和折叠跳过)
5.6 多类型选中的统一处理lua
复制-- 用 selectedType + selectedIdx 二元组标识选中项
-- 通过 getSelectedItem() 统一获取数据,避免到处 if/else
local function getSelectedItem()
if selectedType == "building" then
return MapData.BUILDINGS[selectedIdx], "building"
elseif selectedType == "landscape" then
return MapData.LANDSCAPES[selectedIdx], "landscape"
end
end
-- 滚轮/面板/高亮等操作都通过这个统一接口
-- 只在最终调用 UpdateBuilding/UpdateLandscape 时分派
六、数据驱动渲染的分派表模式lua
复制-- 用类型字符串 → 绘制函数的映射表,替代 if/elseif 链
local LANDSCAPE_DRAW = {
bamboo = function(vg, x, y, s, extra)
drawBambooCluster(vg, x, y, s, extra and extra.count or 7)
end,
pineTree = function(vg, x, y, s) drawPineTree(vg, x, y, s) end,
flowerTree = function(vg, x, y, s, extra)
drawFlowerTree(vg, x, y, s, extra and extra.color)
end,
-- ...
}
-- 渲染时一行搞定
function drawLandscapeSprites(vg, zoneName)
for _, ls in ipairs(MapData.LANDSCAPES) do
if ls.zone == zoneName then
local x, y = MapData.GetLandscapePos(ls)
LANDSCAPE_DRAW[ls.landscapeType](vg, x, y, ls.drawScale, ls.extra)
end
end
end
七、给 AI 的一句话 Prompt 模板请为我的 NanoVG 2D 地图项目添加一个嵌入式调校面板(F1 切换),支持:
- 左侧按区域分组的元素列表,点区域名可折叠/展开
- 支持多种元素类型(建筑+景观),列表中用不同图标/颜色区分
- 在地图上拖拽调整元素位置(带偏移防跳),滚轮调缩放
- 右侧属性面板显示选中项的坐标和缩放值,带 +/- 按钮微调
- 关闭时自动 JSON 持久化,启动时加载覆盖默认值
- 采用三文件架构:数据层(MapData) / 渲染层(MapRenderer) / 编辑器层(MapCalibrator)
- 所有可编辑元素使用 zone-local 归一化坐标(nx/ny 0~1)
- 渲染层中的硬编码绘制调用全部改为数据驱动(分派表模式)
- 编辑器 UI 在屏幕空间绘制,选中高亮在相机空间绘制
输入事件使用消费模式:编辑器返回 true 消费,false 穿透给相机


以上就是完整的模式总结,涵盖数据设计、坐标系统、拖拽交互、折叠列表、持久化、渲染分层六大方面。下次新项目把这段贴给 AI 即可直接复刻。



