Overworld
包参考

@overworld-engine/core

框架核心:类型化事件总线、条件/效果注册表、持久化辅助、存档原语与公共类型

Overworld 框架的最底层。所有系统包只允许依赖本包;跨系统协作全部经由这里提供的机制完成: 类型化事件总线(系统间通信)、条件/效果注册表(数据驱动内容与代码解耦)、 统一的持久化约定(persistOptions + 存档槽位 + 硬化的原子文件原语)、 可注入种子的 RNG(确定性测试)。本包零 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() 在当前 3.2.x 仍双发两者){ 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 配置),测试时必须自建实例
  • 旧注释曾计划在 2.0 移除 interact,但 3.2.x 实际源码仍保留它。新代码只订阅 entity:interact;不要同时订阅两个事件,否则一次交互会处理两次。

条件/效果注册表(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

共享输入锁(inputLock)

inputLock 是无头、引用计数式的全局输入门。scene.Player、交互键、 FollowCamera orbit 与 input.VirtualJoystick 默认查询它,因此打开对话或 模态窗口时不必分别禁用每种输入源:

import { inputLock } from '@overworld-engine/core'

const release = inputLock.acquire('pause-menu')
console.log(inputLock.isLocked()) // true
console.log(inputLock.activeLocks()) // ['pause-menu']

release()
API说明
inputLock与默认 gameEvents 绑定的共享单例
createInputLock(events?)创建隔离实例,适合测试或多游戏实例
acquire(id)持锁并返回幂等 release 函数
release(id)按 id 释放一份持有
isLocked()是否存在任何活跃锁
activeLocks()当前锁 id 列表
subscribe(listener)订阅锁状态
releaseAll()清空全部锁,主要用于 teardown / 恢复

状态变化会在绑定总线上发出 input:lock-changed。React 组件通常使用 input.useKeyboardLayer(..., { lockInput: true }),由生命周期自动释放;命令式 代码必须保留并调用 acquire() 返回的 release。

持久化(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:inventory

OverworldPersistConfig 字段:

字段说明默认
name存储 key,最终为 <prefix>:<name>必填
version持久化形状变化时递增,配合 migrate0
prefixkey 前缀'overworld'
storage存储后端工厂(() => StateStorage)localStorage
partialize挑选值得保存的状态子集
migrate版本迁移函数
onRehydrateStorage透传 zustand 的水合回调

createMemoryStorage() 返回内存存储,用于测试与非浏览器环境;它同时满足 zustand 的 StateStorage(供 persistOptions)与 EnumerableStorage(供 createSaveSlots), 一个实例可以两处共用。

全框架统一约定:各引擎的 persist 配置省略或 false = 不持久化;true = 默认配置 开启;传对象 = 自定义 name / version / storage

存档迁移(defineMigrations)

持久化形状随版本演进时,旧存档需要迁移。zustand 的 persist 在水合时调用一次 migrate(persistedState, fromVersion)——fromVersion 是存档里记录的版本。 defineMigrations 把「每版一个升级步骤」的映射编译成这个 migrate 函数: 按 key 升序执行所有 key 大于 fromVersion 的步骤,把 state 逐级串起来。

import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import { defineMigrations, persistOptions } from '@overworld-engine/core'

const migrate = defineMigrations({
  1: (state) => ({ ...state, gold: state.coins ?? 0 }), // v0 → v1:coins 改名 gold
  2: (state) => ({ ...state, gold: Number(state.gold) }), // v1 → v2:强制数值
})

const useStore = create<State>()(
  persist(initializer, persistOptions({ name: 'wallet', version: 2, migrate }))
)
  • key 是每步产出的目标版本(key 为 2 的步骤把 v1 升到 v2),与 persistOptionsversion 保持一致。
  • 已应用的步骤自动跳过:v0 存档跑 12;v1 存档只跑 2;v2 存档原样返回。
  • key 不必连续({ 2, 5 } 对 v0 存档依次跑 25),始终按数值升序。
  • 纯函数、不抛异常、不改动入参;是否复制由每步自行决定。

内容与存档是两件事:内容包(@overworld-engine/content)热更的是定义, 不写坏存档;但当改动影响持久化状态的含义(重命名 quest id、改 objective id), 用 defineMigrations 迁移旧存档。

云存档 REST 适配器(createRestStorage)

createRestStorage(config) 返回一个异步的 zustand 兼容存储(zustand 的 StateStorage 原生支持 getItem 返回 Promise<string | null>),直接作为 persistOptionsstorage 工厂即可把存档落到远端服务器:

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 末尾多余的 / 会被剔除):

方法语义
GET200 + 响应体为持久化字符串;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必填
fetchfetch 实现(测试注入 / 自定义传输)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 共用;前缀之外的键永不读写删)、 clock(() => number,默认 Date.now,提供 savedAt 时间戳)。
  • 确定性:同 seed 重放需注入 clock(否则 savedAt 写入墙钟时间,重放无法逐字节 复现);引擎值层面无 Math.random
  • EnumerableStorage 是能枚举键的最小同步存储接口(getItem / setItem / removeItem / keys),与 zustand StateStorage 结构兼容; fromWebStorage(storage) 可把 DOM Storage(localStorage / sessionStorage)包装成它。
  • 任意后端:createSaveSlots 只依赖 EnumerableStorage,因此除 localStorage 外, 也能直接架在 @overworld-engine/platformTelegram CloudStorage 云存档适配器 (createTelegramCloudStorage)或 Tauri 文件存档(createTauriFileStorage)之上 —— 它们的同步内存镜像满足 keys() / getItem,槽位由此获得跨设备/落盘能力。这两个 适配器的写回是异步的,返回带 flush(): Promise<void>FlushableStorage: saveTo / clearCurrent 之后若要保证槽位在切后台(app:paused)或关窗前抵达 云端/磁盘,需 await storage.flush()(详见 @overworld-engine/platform 云存档章节)。

存档文件原语(commitSlot / recoverSlot)

createSaveSlots 之上的另一条腿:业务无关的"原子文件 + 轮换备份"原语, 面向桌面/Web 的真实文件系统,而不是 zustand 的 key/value 存储。写入流程 = 写临时文件 → fsync → 读回校验 → 备份轮换 → 原子重命名替换;配合信封校验 (4 字节 magic + 1 字节格式版本 + 4 字节长度 + 32 字节 SHA-256)抓物理层面的 截断/损坏。不含存档头部业务字段(schema_version、rng_roots 等)——那些 是调用方自己的存档格式,core 只回答"这份文件磁盘上是否完整"。

import { commitSlot, recoverSlot, type AtomicFileBackend } from '@overworld-engine/core'

declare const backend: AtomicFileBackend   // 见下方「后端实现」

await commitSlot(backend, 'saves/slot-1', payloadBytes)   // 默认保留 2 份轮换备份
const outcome = await recoverSlot(backend, 'saves/slot-1', {
  isValid: (bytes) => yourOwnHeaderChecksumPasses(bytes),  // 可选:叠加业务级校验
})
if (outcome.result) {
  console.log(`从 ${outcome.result.source} 恢复`, outcome.result.bytes)
} else {
  console.error('全部代都不可用', outcome.failures)
}

AtomicFileBackend

六个无业务语义的原子操作,core 本身不提供实现,只编排协议;真正落地 交给平台后端:

方法语义
writeFile(path, bytes)创建或整体覆写;单独调用不保证落盘
syncFile(path)确保该路径已写入的内容落盘(fsync)
renameFile(from, to)原子替换;to 已存在也整体替换,不产生半份文件
readFile(path)不存在返回 null,不抛错
deleteFile(path)不存在时是 no-op
exists(path)
  • 桌面壳:@overworld-engine/adapters-savefilecreateTauriSaveFileBackend()——真正调用系统 fsync,因为 Tauri 官方 @tauri-apps/plugin-fs 的 JS API 不暴露这个能力,这个包自带一个 Rust Tauri 插件专门做这件事。
  • Web:@overworld-engine/platformcreateWebSaveFileBackend()—— 基于 localStorage,syncFile 是 no-op(浏览器没有 fsync 等价物)。

崩溃安全的核心不变式

commitSlot(backend, path, bytes, { backupCount? })从旧到新的顺序 轮换备份(bak(n-1)→bak(n),再 current→bak1,最后 tmp→current)。因为 renameFile 在文件系统层是单一原子操作(要么完全发生要么完全没发生), 所以无论进程在哪一步被杀,path 永远指向"上一个完整代"或"新的完整代" 之一——不存在半份文件。这个不变式经故障注入测试验证:遍历 commitSlot 内部每一个可能的中断点,断言 recoverSlot 总能拿到一份有效数据(等价于 "连续强制终止写盘 N 次全部可恢复"的验收标准,但确定性、可进 CI)。

recoverSlot

current → backup1 → backup2 → ... 新到旧扫描,返回第一个通过校验的 代,连同它之前每一代的失败原因:

failures[].reason含义
missing该路径不存在
envelope-invalid信封校验失败(magic/版本/长度/SHA-256 任一不符)
validator-rejected物理校验通过,但调用方的 isValid 判定业务上不合法
read-errorreadFile 本身抛错(权限/IO 错误),不是"不存在"

RecoverResult.source'current' 或形如 `backup${n}` 的字符串, 供 UI 展示"从第几代备份恢复"。

完整性信封辅助

commitSlot 内部使用公开的信封原语;自定义后端或导入工具也可以单独使用:

API语义
wrapEnvelope(payload)生成 OWSF magic + 格式版本 + 长度 + SHA-256 + payload
unwrapEnvelope(raw)校验并返回 payload;任何 magic/版本/长度/hash 错误都返回 null
bytesEqual(a, b)字节级比较,用于摘要和写后读回验证

信封只检测物理截断或损坏,不包含你的 schema version、角色名或业务 checksum。 业务合法性仍由 recoverSlot({ isValid }) 的调用方定义。

详见 docs/superpowers/specs/2026-07-24-save-hardening-design.md

可注入种子的 RNG(createSeededRng)

任何需要"可复现随机性"的构造函数(战斗随机数、掉落表、世界生成……)都 应该把 RNG 当可选依赖接受,而不是内部裸调 Math.random()——这样生产 环境用真随机、测试环境用固定种子求确定性结果,同一套脚本每次跑出同样的 结果,可以进 CI。

import { createSeededRng, type RngSource } from '@overworld-engine/core'

function createLootTable(pool: LootEntry[], options?: { rng?: RngSource }) {
  return {
    roll() {
      if (!options?.rng) throw new Error('missing rng')
      return pool[Math.floor(options.rng.next() * pool.length)].id
    },
  }
}

createLootTable(POOL, { rng: { next: Math.random } })   // 生产
createLootTable(POOL, { rng: createSeededRng(1234) })   // 测试:同种子同结果
  • RngSource — 单方法接口 { next(): number },返回 [0, 1)
  • createSeededRng(seed) — mulberry32,零依赖,不追求密码学强度,只保证 同种子同序列。

配合 @overworld-engine/test-kit 的事件录制器(createEventRecorder)和 React hook 挂载原语(renderHook),可以完全脱离渲染层,对整套 store/ 事件/引擎装配做确定性的 app 层集成测试——这正是内核单测和对拍金测都测不 到的那一层(它们不经过 app 层的实际装配代码)。见 @overworld-engine/test-kit

公共类型

/** X/Y/Z 三轴的位置或旋转元组 */
type Vec3 = [number, number, number]

/** 框架已知的世界实体种类 */
type EntityKind = 'npc' | 'building' | 'item' | 'decoration'

/** 世界实体引用 */
interface EntityRef {
  kind: EntityKind
  id: string
}

本页目录