@overworld-engine/core
框架核心:类型化事件总线、条件/效果注册表、持久化辅助、存档槽位与公共类型
Overworld 框架的最底层。所有系统包只允许依赖本包;跨系统协作全部经由这里提供的机制完成:
类型化事件总线(系统间通信)、条件/效果注册表(数据驱动内容与代码解耦)、
统一的持久化约定(persistOptions + 存档槽位)。本包零 UI、零渲染依赖,
可在纯 Node 环境中使用与测试。
事件总线(EventBus)
最小化的类型化发布/订阅总线。Overworld 中所有跨系统通信都走总线而不是直接 import 对方的 store,系统因此保持独立,游戏也可以响应(或合成)任何事件。
框架事件表(OverworldEventMap)
| 事件 | 载荷 |
|---|---|
player:moved | { position: Vec3; distance: number } |
scene:changed | { from: string | null; to: string } |
proximity:enter / proximity:leave | { kind: EntityKind; id: string } |
entity:interact | { kind: EntityKind; id: string } |
interact(已弃用,改用 entity:interact;过渡期 scene 的 interact() 双发两者,2.0 移除) | { kind: EntityKind; id: string } |
dialogue:started | { npcId: string; dialogueId: string } |
dialogue:ended | { npcId: string; dialogueId: string; nodeId: string } |
quest:started / quest:completed | { questId: string } |
quest:objective-progress | { questId; objectiveId; current: number; target: number } |
quest:objective-completed | { questId: string; objectiveId: string } |
item:added / item:removed | { itemId: string; quantity: number; total: number } |
item:used | { itemId: string } |
achievement:unlocked | { achievementId: string } |
tutorial:step-changed | { tutorialId: string; stepId: string; stepIndex: number } |
tutorial:completed | { tutorialId: string } |
游戏通过 declaration merging 扩展事件表,扩展后的事件同样获得完整类型:
declare module '@overworld-engine/core' {
interface OverworldEventMap {
'market:trade': { symbol: string; amount: number }
}
}API
import { EventBus, gameEvents, type OverworldEventMap } from '@overworld-engine/core'
// 全局单例:所有系统的零配置默认总线
const off = gameEvents.on('quest:completed', ({ questId }) => {
console.log('任务完成', questId)
})
gameEvents.emit('quest:started', { questId: 'welcome' })
off() // on/once/onAny 都返回退订函数
// 测试或多实例场景:自建总线,经各引擎的 events 配置注入
const bus = new EventBus<OverworldEventMap>()| 成员 | 说明 |
|---|---|
on(event, fn) | 订阅,返回退订函数 |
once(event, fn) | 单次订阅(触发一次后自动退订),返回退订函数 |
off(event, fn) | 取消订阅 |
onAny(fn) | 订阅全部事件(调试面板、埋点、录制回放),返回退订函数 |
emit(event, payload) | 发布事件 |
listenerCount(event) | 某事件当前监听器数量 |
clear(event?) | 移除某事件的全部监听器;省略参数时清空所有监听(含 onAny) |
行为细节:
- 监听器抛出的异常会被捕获并打日志,不影响同一事件的其他监听器,更不会中断 emit。
- emit 过程中订阅/退订不影响本次分发(分发前会拷贝监听器集合)。
gameEvents是全局单例;各引擎都接受自定义总线(events配置),测试时必须自建实例。
条件/效果注册表(Registry)
注册表把数据驱动的内容与代码解耦:对话选项、任务奖励、物品使用效果等在内容里只写 声明式引用,游戏启动时注册对应的处理函数——框架引擎永不 import 游戏代码。
interface EffectRef {
type: string
params?: Record<string, unknown>
}
interface ConditionRef {
type: string
params?: Record<string, unknown>
negate?: boolean // 取反条件结果
}import {
createConditionRegistry, createEffectRegistry,
runEffects, evaluateConditions,
} from '@overworld-engine/core'
const conditions = createConditionRegistry<GameCtx>()
const effects = createEffectRegistry<GameCtx>()
// 游戏启动时注册处理器
conditions.register('minLevel', (params, ctx) => ctx.player.level >= (params.level as number))
effects.register('wallet.addGold', (params, ctx) => ctx.wallet.add(params.amount as number))
// 内容数据里只写声明式引用
runEffects(effects, [{ type: 'wallet.addGold', params: { amount: 100 } }], gameCtx)
evaluateConditions(conditions, [{ type: 'minLevel', params: { level: 3 } }], gameCtx) // boolean| 成员 | 说明 |
|---|---|
register(type, fn, options?) | 注册处理器;重复注册同名 type 会警告并跳过,传 { override: true } 才替换 |
registerAll(entries, options?) | 批量注册(Record<string, Fn>) |
get(type) / has(type) | 查询处理器 |
unregister(type) | 注销 |
types() | 已注册的全部 type(可交给 @overworld-engine/devtools 做内容校验) |
两个求值辅助函数的语义(全部引擎都遵循):
runEffects(registry, refs, ctx)— 按序执行;未注册的 type 打 warning 跳过, 处理器抛错也只记录日志——内容错误不崩游戏。evaluateConditions(registry, refs, ctx)— AND 语义;空数组/省略为true; 未注册的条件 fail closed(返回false);negate: true对单条结果取反; 处理器抛错视为false。
持久化(persistOptions)
引擎的存档统一走 zustand 的 persist 中间件;persistOptions 把 key 命名、
版本化迁移与可替换的存储后端标准化:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { persistOptions } from '@overworld-engine/core'
const useStore = create<State>()(
persist(initializer, persistOptions({ name: 'inventory', version: 1 }))
)
// 实际存储 key:overworld:inventoryOverworldPersistConfig 字段:
| 字段 | 说明 | 默认 |
|---|---|---|
name | 存储 key,最终为 <prefix>:<name> | 必填 |
version | 持久化形状变化时递增,配合 migrate | 0 |
prefix | key 前缀 | 'overworld' |
storage | 存储后端工厂(() => StateStorage) | localStorage |
partialize | 挑选值得保存的状态子集 | — |
migrate | 版本迁移函数 | — |
onRehydrateStorage | 透传 zustand 的水合回调 | — |
createMemoryStorage() 返回内存存储,用于测试与非浏览器环境;它同时满足 zustand 的
StateStorage(供 persistOptions)与 EnumerableStorage(供 createSaveSlots),
一个实例可以两处共用。
全框架统一约定:各引擎的 persist 配置省略或 false = 不持久化;true = 默认配置
开启;传对象 = 自定义 name / version / storage。
云存档 REST 适配器(createRestStorage)
createRestStorage(config) 返回一个异步的 zustand 兼容存储(zustand 的
StateStorage 原生支持 getItem 返回 Promise<string | null>),直接作为
persistOptions 的 storage 工厂即可把存档落到远端服务器:
import { createRestStorage, persistOptions } from '@overworld-engine/core'
const storage = createRestStorage({
baseUrl: 'https://api.example.com/saves',
headers: () => ({ authorization: `Bearer ${getToken()}` }), // 每次请求都重新求值
})
const useStore = create<State>()(
persist(initializer, persistOptions({ name: 'inventory', storage: () => storage }))
)REST 契约
所有请求的 URL 均为 ${baseUrl}/${keyToPath(key)}(keyToPath 默认
encodeURIComponent;baseUrl 末尾多余的 / 会被剔除):
| 方法 | 语义 |
|---|---|
GET | 200 + 响应体为持久化字符串;404 表示 key 不存在(getItem 解析为 null) |
PUT | 请求体为原始字符串,content-type: text/plain |
DELETE | 删除 key;404 视为成功(幂等) |
其余非 2xx 状态码或网络异常一律交给 onError(默认 console.warn)后吞掉:
getItem 解析为 null,setItem / removeItem 静默 resolve——存档失败永远
不会让游戏崩溃。
写入防抖(debounce)
setItem 按 key 防抖(尾沿触发,debounceMs 默认 300):zustand 每次
setState 都会触发一次写入,防抖把窗口内的连续写合并成一次 PUT,只发送最后
的值,不会打爆服务器。窗口内对同一 key 的 getItem 直接返回缓冲中的待写值
(读不会倒退);removeItem 会取消该 key 的待写并立即发 DELETE。
卸载前 flush
防抖意味着页面关闭瞬间可能还有未发出的写。返回的存储带 flush() 方法
(也有自由函数拼写 flushRestStorage(storage)),强制立即发出全部待写并等待
在途请求完成:
window.addEventListener('beforeunload', () => {
void storage.flush() // 或 flushRestStorage(storage)
})RestStorageConfig 字段:
| 字段 | 说明 | 默认 |
|---|---|---|
baseUrl | 存档端点根 URL | 必填 |
fetch | fetch 实现(测试注入 / 自定义传输) | globalThis.fetch |
headers | 额外请求头;传函数则每次请求重新求值(刷新 token) | — |
keyToPath | 存储 key 到 URL 路径段的映射 | encodeURIComponent |
debounceMs | 按 key 的写入防抖窗口(毫秒) | 300 |
onError | 请求失败回调 (error, op, key),op 为 'get' | 'set' | 'remove' | console.warn |
存档槽位(createSaveSlots)
所有经 persistOptions 持久化的 store 都落在 <prefix>:<name> 键下,这组键的整体就是
live 存档(当前进行中的游戏)。createSaveSlots 在其上提供命名槽位管理:
import { createSaveSlots } from '@overworld-engine/core'
const slots = createSaveSlots() // 默认 localStorage,前缀 overworld
slots.saveTo('slot-1') // 把 live 存档复制进槽位
slots.clearCurrent() // 新游戏:清空 live 存档(槽位不受影响)
slots.loadFrom('slot-1') // 把槽位恢复为 live 存档
slots.listSlots() // [{ slot: 'slot-1', savedAt: 1710000000000 }]| 成员 | 说明 |
|---|---|
snapshot() | 复制当前 live 存档(槽位命名空间之外的全部 <prefix>: 键) |
restore(snapshot) | 用快照替换 live 存档:先删现有 live 键,再写回快照条目 |
saveTo(slot) | 把 live 存档存入命名槽位(覆盖同名槽位),键为 <prefix>:slots:<slot> |
loadFrom(slot) | 恢复槽位到 live 存档;槽位不存在或损坏时返回 false 且不动 live 存档 |
deleteSlot(slot) | 删除槽位(不存在时为 no-op) |
listSlots() | 全部槽位,按保存时间倒序 |
clearCurrent() | 清空 live 存档("新游戏"),槽位不受影响 |
注意事项:
- 水合警告:
restore/loadFrom只改写存储;已完成 hydration 的 zustand store 仍持有内存中的旧状态,需要刷新页面或逐个调用该 store 的persist.rehydrate()才会反映恢复的数据。 SaveSlotsConfig:storage(默认localStorage,非浏览器环境必须显式传入)、prefix(默认'overworld',与persistOptions共用;前缀之外的键永不读写删)。EnumerableStorage是能枚举键的最小同步存储接口(getItem/setItem/removeItem/keys),与 zustandStateStorage结构兼容;fromWebStorage(storage)可把 DOMStorage(localStorage / sessionStorage)包装成它。
公共类型
/** X/Y/Z 三轴的位置或旋转元组 */
type Vec3 = [number, number, number]
/** 框架已知的世界实体种类 */
type EntityKind = 'npc' | 'building' | 'item' | 'decoration'
/** 世界实体引用 */
interface EntityRef {
kind: EntityKind
id: string
}