@overworld-engine/editor
游戏内场景编辑器:模板摆放/移动实体、GLTF 模型预览、属性编辑、导出场景 JSON
游戏内场景编辑器雏形:在运行中的游戏里摆放/移动 NPC、建筑、装饰物, 调整属性,一键导出场景 JSON。由三部分组成:
useEditorStore—— 无头工作集(zustand 单例),含实体模板与 JSON 导入/导出,可脱离渲染层单测;<EditorScene>—— 挂在<Canvas>内的 R3F 编辑层(地面拾取、占位网格 + GLTF 模型预览、拖拽移动);<EditorPanel>/<EditorToggle>—— Canvas 外的 DOM 面板(含模板选择器)与悬浮开关按钮。
编辑器关闭时(enabled === false)三者都不渲染任何内容,可以常驻在开发构建里。
快速开始
import { Canvas } from '@react-three/fiber'
import { EditorScene, EditorPanel, EditorToggle } from '@overworld-engine/editor'
<Canvas>
<MyScene />
<EditorScene groundSize={120} y={0} />
</Canvas>
<EditorPanel />
<EditorToggle hotkey="F2" /> {/* 悬浮按钮 + 可选快捷键开关编辑器 */}<EditorScene> 的 props:groundSize(可点击地面边长,默认 100)、
y(地面高度,默认 0)、snap(覆盖项:传入时优先于 store 里的
可调 snap;不传则跟随面板的「吸附」输入,初始 0.5,0 关闭吸附)。
交互
- 放置模式:点击地面,在命中点新建实体(自动吸附网格)。有激活模板时
按模板预填字段(见下文「实体模板」),否则新建当前
placingKind的空白实体。 - 选择模式:点击实体(占位网格或模型)选中(高亮 + 地面光环); Shift+点击加入/移出多选(见下文「多选与对齐」);按住拖拽即可在 XZ 平面 移动(多选时整组移动);点击空地取消选中。整次拖拽只算一步撤销。
- 占位形状:NPC = 胶囊体,建筑 = 立方体,装饰 = 圆柱体;有
modelPath时 显示真实模型(见下文「模型预览」)。 - 面板中可编辑坐标、旋转、缩放、名称、模型路径、碰撞半径,并删除实体。
注意:面板里的旋转以「度」显示,store 中的
rotationY存的是弧度。
多选与对齐
- 多选:Shift+点击实体(3D 视图或面板实体列表均可)加入/移出选择;普通
点击 = 单选;
Ctrl/Cmd+A全选(阻止浏览器默认行为);点击空地清空选择。 store 中选择集是selectedIds: string[];selectedId保留为派生兼容字段 (= 最后选中的 id,空选择时为null),原有单选用法不受影响。 - 整组拖拽:拖拽任意已选中的实体会移动整个选择集 —— 被拖拽实体照常
吸附网格,其余实体按相同增量平移(基于增量的
moveSelectedBy);拖拽未选中 的实体则先单选它(原有行为)。整次组拖拽仍合并为一步撤销。 - 删除 / 复制:工具栏「删除」「复制」与快捷键
Delete/Backspace、Ctrl/Cmd+D作用于整个选择集(removeSelected()/duplicateSelected(), 各为一步撤销;复制把所有克隆体整体偏移[+1, 0, +1]并选中它们)。 - 对齐(≥2 个选中时可用,一步撤销):工具栏「对齐X / 对齐Z」的
min / 中 / max 三键把所选实体在该轴上的坐标统一为选择集的最小值 /
包围范围中点(
(min + max) / 2)/ 最大值 —— 即alignSelected(axis, mode),axis为'x' | 'z',mode为'min' | 'center' | 'max';另一轴坐标不变。 - 均分(≥2 个选中时可用,一步撤销):「均分」的 X / Z 按钮把所选实体按
当前坐标排序(相同坐标保持工作集顺序,排序稳定)后,在最小值与最大值
之间等距分布(
distributeSelected(axis));首尾实体不动。 - 属性面板:恰好选中 1 个实体时照常编辑字段;多选时显示 「已选 N 个实体」提示(字段编辑仅支持单选)。
- 无头 API:
toggleSelect(id)、selectMany(ids)、clearSelection()、moveSelectedBy(dx, dz, options?)(options.transient与拖拽共用同一 burst 机制,commitTransient()合并为一步)。撤销/重做后选择集自动剔除 已不存在的 id(selectedId同步为剩余的最后一个)。
实体模板
游戏可以注册一份放置预设目录,启动时调用一次即可:
import { useEditorStore, type EditorTemplate } from '@overworld-engine/editor'
const templates: EditorTemplate[] = [
{ id: 'bank', label: '银行', kind: 'building',
modelPath: '/models/bank.glb', scale: 2, collisionRadius: 5, name: '银行' },
{ id: 'guide', label: '向导 NPC', kind: 'npc', modelPath: '/models/guide.glb' },
]
useEditorStore.getState().setTemplates(templates)setTemplates(templates)整体替换目录(不进撤销历史;若激活模板已不存在 则自动回退到null)。setActiveTemplate(id | null)激活模板(null= 空白放置;未知 id 无操作)。 激活的同时会把placingKind切换成模板的kind。- 放置模式下,面板的「模板」区(有模板时出现)列出所有
label按钮,外加 一个「空白」按钮(= 不用模板)。 - 放置点击走
addEntityFromTemplate(position):kind / modelPath / scale / collisionRadius / name 取自激活模板,位置取命中点;无激活模板时等价于addEntity({ kind: placingKind, position })。与addEntity一样可撤销。 - 模板只预填实体字段 —— 放下的实体是自包含的,导出 JSON 不引用模板 id,
exportScene/importScene行为不变。
模型预览
实体的 modelPath 非空时,<EditorScene> 用 useLoader(GLTFLoader, url)
(GLTFLoader 来自 three/examples,无新依赖)加载并渲染真实模型代替占位形状:
- 加载中(Suspense)与加载失败(内部错误边界捕获)都回退到占位形状,
坏路径不会让编辑器崩溃;修改
modelPath会重试加载。 - 同一 URL 只加载一次(
useLoader按 URL 缓存),每个实体渲染缓存场景的 克隆体;克隆体与缓存共享几何/材质,卸载时交给 GC 即可(主动 dispose 反而会破坏其他克隆体共享的缓存)。 - 选中高亮对模型实体依然有效:地面光环照常显示,点击模型即可选中 (指针事件挂在包裹 group 上,占位形状与模型走同一条路径)。
撤销 / 重做
- 每个变更操作(
addEntity/removeEntity/updateEntity/duplicate/loadEntities/importScene/clear)在执行前把当前实体快照压入撤销栈, 并清空重做栈;栈上限 100 条,超出丢最旧的。 updateEntity(id, patch, { transient: true })为瞬时更新:只改实体、 不进历史(用于拖拽 / 输入过程中);burst 开始前的快照会被记住,commitTransient()把整个 burst 合并成一步撤销。未提交 burst 时执行任何 非瞬时操作(或 undo/redo)会先自动提交它。undo()/redo()恢复快照;恢复后选择集(selectedIds)自动剔除已 不存在的 id,selectedId同步为剩余的最后一个(全没了则为null)。canUndo/canRedo是与栈同步的布尔状态,UI 可直接订阅。- id 计数器不随撤销回退(与「删除后不复用 id」的约定一致),重做/新增 永远不会产生重复 id。
- 面板工具栏提供「撤销 / 重做」按钮;快捷键(仅编辑器开启时,输入框内不生效):
Ctrl/Cmd+Z撤销,Ctrl/Cmd+Shift+Z或Ctrl/Cmd+Y重做,Ctrl/Cmd+D复制所选,Ctrl/Cmd+A全选,Delete/Backspace删除所选。
复制
duplicate(id) 以相同的 id 计数机制克隆单个实体(npc-1 → npc-4 之类),
位置偏移 [+1, 0, +1],克隆体自动选中,可撤销。duplicateSelected() 对
整个选择集做同样的事:所有克隆体一次生成(一步撤销)并成为新的选择。
面板工具栏的「复制」按钮(有选中时可用)和 Ctrl/Cmd+D 都走
duplicateSelected()。
吸附与网格
snap存放在 store 里(默认 0.5,setSnap(v)修改,0 = 关闭; 负数/非法值按 0 处理),面板工具栏的「吸附」数字输入(步长 0.1)直接改它。<EditorScene snap={...}>传了 prop 时以 prop 为准。showGrid(默认 true,setShowGrid/ 面板「网格」勾选框)控制<EditorScene>里的吸附网格(gridHelper):格距跟随生效的 snap, 分割数上限 200(极小 snap 不会撑爆渲染),略微抬高 0.02 避免与地面 z-fighting。
无头 store
import { useEditorStore } from '@overworld-engine/editor'
const store = useEditorStore.getState()
store.setEnabled(true)
store.setMode('place') // 'select' | 'place'
store.setPlacingKind('building') // 'npc' | 'building' | 'decoration'
store.setTemplates(templates) // 注册放置模板(启动时一次)
store.setActiveTemplate('bank') // 激活模板(null = 空白;同步 placingKind)
store.addEntityFromTemplate([4, 0, -2]) // 放置:字段取自激活模板,位置取参数
const e = store.addEntity({ position: [4, 0, -2], name: '银行' }) // id 自动生成:building-2
store.updateEntity(e.id, { rotationY: Math.PI / 2 })
store.updateEntity(e.id, { scale: 2 }, { transient: true }) // 瞬时:不进历史
store.commitTransient() // 把瞬时 burst 合并成一步撤销
store.duplicate(e.id) // 克隆(偏移 [+1, 0, +1],自动选中)
store.undo(); store.redo() // 撤销 / 重做(见上文)
store.setSnap(1) // 吸附步长(0 = 关)
store.setShowGrid(false) // 网格显隐
store.select(e.id) // 单选;select(null) 取消选中
store.toggleSelect(e.id) // 加入/移出多选
store.selectMany(['npc-1', 'npc-2']) // 整体替换选择集(selectedId = 最后一个)
store.clearSelection() // 清空选择
store.moveSelectedBy(1, -2) // 平移所选(支持 { transient: true })
store.alignSelected('x', 'center') // 对齐:'x' | 'z' × 'min' | 'center' | 'max'
store.distributeSelected('z') // 均分(按当前坐标排序,首尾不动)
store.duplicateSelected() // 复制所选(一步撤销,选中克隆体)
store.removeSelected() // 删除所选(一步撤销)
store.removeEntity(e.id)
store.loadEntities(entities) // 整体替换(重置选中与 id 计数器)
store.clear() // 清空id 按类型递增(npc-1、npc-2、building-1…),loadEntities 会从已有 id
重新播种计数器,不会产生重复 id。
导出 / 导入 JSON
const json = store.exportScene() // 纯函数,也可直接用 exportEntities(entities)
store.importScene(json) // 逆操作(尽力解析,跳过坏条目;根不是对象时抛错)导出形状:
{
"npcs": [{ "id", "modelPath", "position", "rotation", "scale?", "name?" }],
"buildings": [{ "id", "name", "modelPath", "position", "rotation", "scale", "collisionRadius" }],
"decorations": { "tree": { "radius": 0.8, "instances": [{ "position", "rotation?", "scale?" }] } }
}约定:rotation 一律为 [0, rotationY, 0];modelPath 缺省导出为 '';
建筑 name 缺省取 id、collisionRadius 缺省为 2;装饰按 name 分组
(未命名归入 "decoration" 组),组内 radius 取该组最后一个显式的
collisionRadius(缺省 2)。导出 → 导入 → 再导出 结果稳定。
面板的「导出 JSON」按钮会同时复制到剪贴板并下载 .json 文件;
「导入 JSON」从文本框解析,结果(含错误)显示在面板内的状态行上。
与 @overworld-engine/scene 的关系
按架构分层规则,本包不 import @overworld-engine/scene。导出的
npcs / buildings / decorations 分别是 NPCConfig / BuildingConfig /
DecorationInstance 的结构化拷贝,可以直接喂给 <SceneShell>:
const scene = store.exportScene()
<SceneShell npcs={scene.npcs} buildings={scene.buildings}
decorationCollisions={scene.decorations} />已知边界(雏形)
- 只支持 XZ 平面拖拽;旋转/缩放需在面板中输入,没有 3D gizmo。
- 模型预览不做碰撞体可视化,
collisionRadius只是随 JSON 导出的数据。 - 编辑器状态不持久化 —— 产物就是导出的 JSON,不是存档(模板目录同理,
由游戏每次启动时用
setTemplates注册)。