Overworld
包参考

@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/BackspaceCtrl/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+ZCtrl/Cmd+Y 重做, Ctrl/Cmd+D 复制所选,Ctrl/Cmd+A 全选,Delete/Backspace 删除所选。

复制

duplicate(id) 以相同的 id 计数机制克隆单个实体(npc-1npc-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-1npc-2building-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 注册)。

本页目录