@overworld-engine/devtools
开发期工具:内容静态校验、JSON Schema、事件总线日志与剖析
开发期工具:内容静态校验(对话 / 任务 / 物品 / 成就)、事件总线日志与
事件总线剖析。
所有校验器都是纯函数,只返回问题列表、从不抛出;assertValidContent 供
开发启动脚本与测试在存在 error 时快速失败。
零交叉依赖:结构化输入类型
devtools 校验的是 dialogue / quest / inventory / achievements 包的内容数据,
但不 import 这些包——唯一的运行时依赖是 @overworld-engine/core。输入类型
(DialogueTreeLike、QuestLike、ItemLike、AchievementLike)是在本包内
定义的结构化子集(duck typing):TypeScript 是结构化类型系统,真实的
DialogueTree / QuestDefinition / ItemDefinition / AchievementDefinition
可以直接原样传入,无需转换。
校验 API
import { assertValidContent, formatReport, validateContent } from '@overworld-engine/devtools'
const report = validateContent(
{ dialogues, quests, items, achievements }, // 每个分区都可选
{
effectTypes: effects.types(), // 注册表已知类型,提供才检查
conditionTypes: conditions.types(),
knownEvents: ['player:moved', 'item:added'], // 提供才检查 trigger.event
questStartEffectType: 'quest.start', // 跨分区检查用,默认 'quest.start'
}
)
console.log(formatReport(report)) // 人类可读的多行摘要
if (import.meta.env.DEV) assertValidContent({ dialogues, quests }) // 有 error 即抛出ValidationReport = { issues, errors, warnings, ok },ok 表示没有 error
(warning 不影响)。每条 ValidationIssue 带 severity、source
(如 dialogue:guide-intro)、path(如 nodes.hello.responses.ask.next)与 message。
分区校验器也可单独使用:validateDialogues / validateQuests /
validateItems / validateAchievements。
规则一览
| 分区 | error | warning |
|---|---|---|
| 对话 | 树/节点 id 重复;startNodeId 缺失;next / response.next 指向不存在的节点 | 从 startNodeId 不可达的节点;空 responses: [];endsDialogue 节点上永不生效的 next;未注册的 effect/condition 类型 |
| 任务 | 任务/目标 id 重复;零目标;target < 1;前置/chainNext 指向未知任务;前置循环(A 依赖 B 依赖 A) | 未注册的奖励/前置条件类型;trigger.event 不在已知事件表;autoStart 同时又是别人的 chainNext(双重启动);从未被内容启动的任务 |
| 物品 | id 重复 | 未注册的 useEffects 类型;maxStack < 1 |
| 成就 | id 重复;trigger.count < 1 | 缺失 trigger 字段;未注册的奖励类型;trigger.event 不在已知事件表 |
跨分区(validateContent 独有):对话中 quest.start 类效果的
params.questId 若缺失或不是已知任务 id → error;同时,被这类效果启动的
任务不再报"从未被启动"的 warning。
终止节点语义(与对话引擎一致)
已对照 @overworld-engine/dialogue 引擎实现确认:没有 responses 也没有 next
的节点就是合法的终止节点——advance() 会在此结束对话并计为完成,与
endsDialogue: true 等价,因此校验器不会对它报任何问题。相反,
endsDialogue 节点上的 next 永远不会被走到(advance() 先判
endsDialogue),会得到 warning,且不算作可达性的边。
JSON Schema(外部内容文件)
面向把内容写成 .json 文件的游戏,devtools 导出一组手写的
JSON Schema(draft 2020-12),描述真实的内容类型:DialogueTree /
QuestDefinition / ItemDefinition / AchievementDefinition 与
EffectRef / ConditionRef。
- 单对象:
dialogueTreeSchema、questDefinitionSchema、itemDefinitionSchema、achievementDefinitionSchema、effectRefSchema、conditionRefSchema - 数组(整份内容文件的形状,
$id带-list后缀):dialogueTreesSchema、questDefinitionsSchema、itemDefinitionsSchema、achievementDefinitionsSchema contentBundleSchema:{ dialogues?, quests?, items?, achievements? }整包(与validateContent的输入同形)allContentSchemas:按名字迭代所有 schema;schemaFor('quests')等 直接取某一分区的数组 schema
每个 schema 都自包含(共享形状内嵌在各自的 $defs 里)、带唯一
$id(如 https://overworld.dev/schemas/dialogue-tree.json),可以单独交给
任何校验器或编辑器。
与 ajv 一起使用
devtools 不依赖 ajv——schema 只是普通数据。在你自己的构建/CI 里装
ajv(draft 2020-12 要从 ajv/dist/2020 导入):
import Ajv from 'ajv/dist/2020'
import { schemaFor } from '@overworld-engine/devtools'
import { readFileSync } from 'node:fs'
const ajv = new Ajv({ allErrors: true })
const validate = ajv.compile(schemaFor('quests'))
const quests = JSON.parse(readFileSync('content/quests.json', 'utf8'))
if (!validate(quests)) console.error(validate.errors)编辑器提示($schema)
把 schema 写到磁盘后,在内容 JSON 顶部加 $schema 即可获得编辑器内的
自动补全与即时校验(VS Code 原生支持;additionalProperties: true,多出的
$schema 字段不会校验失败):
// content/quests.json 若是单个对象;数组文件请在 VS Code 的
// json.schemas 设置里把文件模式映射到对应的 *-list schema
{ "$schema": "./schemas/quest-definition.json", "id": "welcome", ... }保真度说明
additionalProperties: true:TypeScript 接口是结构化开放的,内容类型 本来就允许游戏扩展额外字段(如ItemDefinition.metadata),schema 不会 拒绝它们。- number 而非 integer:
target/count/maxStack在 TS 里是number,校验器也只检查>= 1,schema 与之一致。 minimum: 1只加在校验器判为 error 的地方:任务目标target与成就trigger.count(maxStack < 1只是 warning,schema 不硬性限制)。- 成就的
trigger为必填,取值AchievementTrigger | null(oneOf: [trigger, null]),与真实AchievementDefinition一致——null显式表示仅手动解锁。 - 跨对象规则(id 唯一、
next悬空、前置循环……)超出 JSON Schema 能力, 解析后请继续用validateContent检查。
事件日志
import { bindEventLogger, createEventRecorder } from '@overworld-engine/devtools'
import { gameEvents } from '@overworld-engine/core'
// 开发期把总线上的所有事件打到控制台(基于 bus.onAny)
const unbind = bindEventLogger(gameEvents, {
filter: (event) => event.startsWith('quest:'), // 可选
includePayload: true, // 默认 true
log: (line, payload) => console.debug(line, payload), // 默认 console.debug,前缀 [overworld]
})
unbind()
// 测试断言:录制事件序列
const recorder = createEventRecorder(bus)
bus.emit('quest:started', { questId: 'welcome' })
recorder.events // [{ event: 'quest:started', payload: {...}, at: 0 }]
recorder.stop()注意:RecordedEvent.at 是单调递增的序号(0、1、2…),不是
Date.now() 时间戳——这样在假时钟、同毫秒多次 emit 或任何运行环境下,
顺序断言都是确定的。
事件总线剖析(profileBus)
profileBus(bus, options?) 通过包裹 bus.emit(monkey-patch,stop() 时还原)
统计每个事件的发射次数与同步分发耗时——即所有监听器同步执行的时间;
监听器里发起的异步工作不计入。每个事件累计
{ count, totalMs, maxMs, lastMs }(EventStats)。
import { profileBus } from '@overworld-engine/devtools'
import { gameEvents } from '@overworld-engine/core'
const profiler = profileBus(gameEvents)
// ... 跑一段游戏 ...
console.log(profiler.report()) // 按 totalMs 倒序的对齐文本表格
profiler.top(3) // 最贵的 3 个事件(默认按 totalMs)
profiler.top(3, 'count') // 或按发射次数
profiler.reset() // 清空统计,继续剖析
profiler.stop() // 还原原始 emit(幂等)| 成员 | 说明 |
|---|---|
stats() | 每事件统计快照(Record<string, EventStats>,返回副本) |
top(n?, by?) | 前 n(默认 5)个事件,按 'totalMs'(默认)或 'count' 倒序 |
reset() | 清空已收集的统计,剖析继续 |
stop() | 还原原始 emit 并停止收集;幂等 |
report() | 人类可读的对齐表格,按 totalMs 倒序 |
行为细节:
options.now可注入时钟(默认performance.now),测试里注入假时钟即可 得到确定性的耗时断言。- 重复剖析同一总线会链式包裹:第二个 profiler 包住第一个的 emit(并打
warning),两者都正常统计(外层的耗时含内层的少量记账开销)。此时
stop()必须按 LIFO 顺序调用(后启动的先 stop)——乱序 stop 会把一个 过期的 emit 还原回去,导致叠在上面的 profiler 悄悄失效。 - 与
bindEventLogger/createEventRecorder互不干扰:它们走bus.onAny, 剖析包裹的是emit本身。