Pulse Game 接入说明书
本说明书面向要在 Web / HTML5 游戏中接入一个已交付 .pulse 音乐包的游戏开发者。
第一次接入请先完成游戏开发者 5 分钟接入,再回到本文处理预加载、事件、部署和错误降级。不确定该读哪份文档时,请从角色导航开始。
它只描述当前已实现、可验证的能力:加载包含内嵌音频的 .pulse、播放、状态切换、参数控制、预加载、事件监听和释放。当前生产接入边界是 @clxgame/pulse-game;Unity、Godot、Unreal 尚没有可作为生产承诺的 runtime/插件。
1. 你会拿到什么
一个 .pulse 文件是一份可运行的互动音乐包,包含:
- 音频 bytes(通常为内嵌 wav/mp3/ogg);
- 状态、片段、layer、转场和参数定义;
- 可选的授权、兼容性 metadata 与开发者接入合同。
游戏侧只应依赖包内 metadata.publicInterface 明确公开的 state ID 和 parameter ID。未列出的状态和参数,以及所有 resource、segment、layer ID,都属于音乐包内部编排,后续内容更新时可能变化。
在开始前,用 Pulse Desktop 打开最终 .pulse,从帮助页复制自动生成的 TypeScript 合同。例如:
export const pulseContract = {
source: "declared",
initialStateId: "exploration",
states: {
exploration: "exploration",
combat: "combat",
},
parameters: {
tension: {
id: "tension",
min: 0,
max: 1,
defaultValue: 0.2,
unit: null,
},
},
} as const;
生成结果还包含 state/parameter 的字面量类型,可直接放进项目的 pulse-contract.ts。每次更新音乐包时,同时更新这份生成文件,不要手写第二套 ID 清单。
接入合同有三条兼容规则:
- 新包明确声明合同时,模块第一个状态就是公开的
initialStateId,参数清单可以为空;SDK 只公开合同允许的 ID,内部 ID 即使存在也不能通过游戏 facade 调用; - 旧包缺少合同时,SDK 会推断全部状态和参数并显示警告,以保证旧内容仍能运行;请先让内容方确认,不要把警告当作正式合同;
- 已声明但损坏或版本不受支持的合同不会退回“全部公开”,因此不会意外泄露内部 ID;加载或代码生成会给出明确错误。
旧包的推断结果只存在于接入工具中,不会在普通保存时被静默写回。必须由 Pulse 设计师在 Builder 的“游戏接入合同”中主动确认并保存。
2. 安装
pnpm add @clxgame/pulse-game
游戏代码从 @clxgame/pulse-game 导入 runtime。除非需要读取 manifest metadata、检查包内容或实现自定义资源管线,否则不需要直接依赖 @clxgame/pulse-engine。
3. 放置与加载 .pulse
把文件作为游戏静态资源发布。例如 Vite/Web 项目可以放在:
public/packs/forest-combat.pulse
它会以 /packs/forest-combat.pulse 提供。
推荐入口会替你完成下载、HTTP 状态检查、严格校验、runtime 创建和音频解码,但不会自动播放:
import {
loadPulseGame,
type PulseGameRuntime,
} from "@clxgame/pulse-game";
export async function loadSceneMusic(
url = "/packs/forest-combat.pulse",
): Promise<PulseGameRuntime> {
return loadPulseGame(url);
}
loadPulseGame() 默认执行严格校验;初始化失败时会自动释放未完成的 runtime。把错误上报到游戏日志/监控,并回退到静态 BGM 或静音即可。只有已有二进制资源管线或自定义 loader 的进阶 host 才需要后文的低阶 factory。
本地开发时从 Pulse Desktop 推送音乐包
如果项目使用 Vite,可以用 @clxgame/pulse-game/vite 的开发插件把
Desktop 写出的 .pulse 暴露给游戏。它只添加本地开发路由,不会进入生产
服务器:
import { createPulseGameLiveSyncPlugin } from "@clxgame/pulse-game/vite";
createPulseGameLiveSyncPlugin({
livePackPath: "/absolute/path/to/live-sync.pulse",
fallbackPackPath: "/absolute/path/to/forest-combat.pulse",
contract: gameSyncContract,
});
gameSyncContract 的字段是完整必填的:协议版本、游戏 ID/名称、adapter、
runtime API 版本、必需 state、必需/可选 parameter 和 capabilities。SDK 会在
TypeScript 编译期检查;Desktop 连接时还会把网络响应当作不可信数据再次严格
校验,缺字段就不会写包。
游戏侧在开发模式轮询 /__pulse-sync/status,版本变化后从
/__pulse-sync/pack 创建一个候选 runtime;只有候选包完成合同校验和预加载
后,才释放旧 runtime。这样错误的编辑不会打断当前音乐。生产模式仍加载固定
的静态 .pulse URL。
同一个开发桥还提供 /__pulse-sync/telemetry。游戏同源页面可以上报只读运行
快照和一小段因果事件,Pulse Desktop 则显示:游戏心跳、当前公开 state、公开
parameter、正在发声的 layer、待执行转场,以及 game signal -> Pulse action -> runtime event。遥测是设计师调试信息,不是新的游戏控制 API;layer ID 也不是
可依赖的稳定接入合同。
在 Pulse Deck 中打开“让游戏驱动本地预览”后,Desktop 会用当前游戏的公开
state/parameter 驱动正在编辑的工程。所有输入都会再次经过 .pulse 的
metadata.publicInterface allowlist 与参数范围校验;信号断开、数据畸形或目标
不匹配时不会继续控制本地引擎。游戏暂停或旁路不会自动停止 Desktop,因此可以
静音游戏标签页里的 Pulse,只听本地工程做 A/B 对比。这里是两路独立输出:如需
只听游戏原始 SFX,还要同时关闭 Desktop 的“让游戏驱动本地预览”。
出于内容保护,/status 和 /pack 只允许游戏同源访问;不能被任意网页跨域
读取。遥测只接受同协议、同主机游戏页面的受限 JSON 上报,并且只有精确白名单
中的 Desktop origin 可以读取;请求体、字段数量、时间戳和事件历史都有上限。
/target 默认也只额外允许 Pulse Desktop 的本地开发/Tauri origin。若你的
Desktop 壳运行在其他可信 origin,可显式传 allowedTargetOrigins,不要使用
通配符 *。
仓库内可以直接运行 pnpm dev:underrun-live 查看完整范例。这个命令同时启动
Pulse Desktop、SDK watcher 和 UNDERRUN;在 Desktop 的 工具 → 游戏联调面板
中先连接游戏,再点 同步到游戏。具体操作见
examples/underrun-pulse。
4. 从用户手势开始播放
浏览器通常禁止在没有用户手势时恢复音频。下载和预加载可以早做,但 play() 必须放在 Start、Continue、点击画面等用户操作之后。
import { pulseContract } from "./pulse-contract";
const music = await loadSceneMusic();
startButton.disabled = false;
startButton.addEventListener("click", async () => {
music.setGameState(pulseContract.initialStateId);
await music.play();
});
在加载页或场景准备阶段先完成 loadSceneMusic(),成功后再启用开始按钮。play() 只负责从用户手势恢复并开始播放;若它失败,music.state.playing 会保持 false。
5. 游戏状态映射
把游戏事件集中映射到生成合同里的 pack state,不要把裸字符串分散在业务代码中。
import { pulseContract } from "./pulse-contract";
const musicStates = {
exploration: pulseContract.states.exploration,
combat: pulseContract.states.combat,
} as const;
function onEncounterStarted() {
music?.setGameState(musicStates.combat, {
transitionMode: "immediate",
});
}
function onEncounterEnded() {
music?.setGameState(musicStates.exploration, {
transitionMode: "nextBar",
});
}
支持的 mode 是:
| mode | 行为 |
|---|---|
immediate | 立即切换,使用 pack 定义的交叉淡入淡出时长或默认值。 |
nextBar | 在下一个小节边界切换。 |
nextEnd | 在当前片段结束时切换。 |
sameTime | 保持当前时间位置切换。 |
不传 transitionMode 时,runtime 优先使用 pack 内对应 transition 的 mode;若 pack 没有这条 transition,则使用 immediate。同一时刻只有一个延迟转场有效,后一次请求会替换前一次 pending 请求。
传入不存在或未公开的 state ID 会同步抛出 RangeError,并保留当前可听状态。因此应在开发环境把它视为合同错误,而不是吞掉。旧包没有显式合同时,为兼容历史内容,推断出的所有状态暂时都视为公开。
trigger()目前是setGameState()的别名,用于兼容接入层命名;当前 P0 没有独立的 stinger/trigger 语义。请不要把它当作通用音效触发器。
6. 参数映射
参数 ID 和数值范围由 pack 定义。未知 parameter ID、NaN 和无穷值会立即抛错;有限数值超出范围时,游戏 runtime 会自动限制到 pack 声明的最小值或最大值。游戏侧仍应按业务含义归一化并适度节流。
const tensionParameter = pulseContract.parameters.tension;
function updateMusicPressure(playerHealth: number, maxHealth: number, enemyCount: number) {
const healthPressure = 1 - playerHealth / maxHealth;
const enemyPressure = Math.min(enemyCount / 6, 1);
const tension = Math.max(0, Math.min(1, Math.max(healthPressure, enemyPressure)));
music?.setGameParameter(tensionParameter.id, tension);
}
对每帧变化的数值设置阈值或节流,避免无意义写入:
let previousTension = -1;
function tickMusic(tension: number) {
if (Math.abs(tension - previousTension) < 0.02) return;
previousTension = tension;
music?.setGameParameter(tensionParameter.id, tension);
}
参数是否有实际听感取决于 pack 的 dspTarget。dspTarget: "none" 可用于游戏状态记录,但不会改变音频。生成合同会带上公开参数的 min、max、defaultValue 和 unit;游戏 UI 与调试工具应复用这些值,不要另抄一份范围。
7. 普通接入不需要额外预加载
loadPulseGame() 返回时已经完成整个包的音频解码,推荐路径不需要再调用预加载方法。
只有使用 createPulseGameRuntime() / createPulseGameRuntimeFromPackage() 的自定义加载流程,才可能需要在自己的加载阶段按公开 state 提前准备资源:
async function prepareCombat() {
await music?.preloadGameState("combat");
}
preloadGameState() 会对未知或未公开的 state ID reject。PulseGameRuntime 不暴露按 resource ID 预载;确实需要底层资源诊断的自定义工具应明确使用 AudioCore,不要把这条依赖带进普通游戏代码。
8. 生命周期与事件
一个场景、关卡或音乐所有者通常只创建一个 runtime。不要在每次游戏事件时重新下载和创建。
const offs: Array<() => void> = [];
function attachMusicDebug(music: PulseGameRuntime) {
offs.push(
music.on("state-change", ({ fromStateId, toStateId }) => {
console.debug("[music] state", fromStateId, "->", toStateId);
}),
music.on("transition-executed", ({ toStateId, mode }) => {
console.debug("[music] transition", mode, "->", toStateId);
}),
);
}
function disposeSceneMusic() {
for (const off of offs.splice(0)) off();
music?.stop();
music?.dispose();
music = null;
}
游戏 facade 只提供 playback-start、playback-stop、state-change、transition-scheduled、transition-executed 和 parameter-change。事件与 runtime.state.parameterValues 都会按接入合同过滤;resource、segment、隐藏参数和底层音频上下文不会出现在普通游戏 API 中。
subscribe() 适合调试 HUD:它只提供精简后的公开 runtime state;生产逻辑更适合订阅明确的 typed event。
9. 可选:数据驱动事件映射
小项目可直接调用 setGameState / setGameParameter。若游戏已有统一事件总线,可使用可选的 mapping helper。Pulse Desktop 帮助页和 buildIntegrationExample() 都会只依据接入合同生成 starter mapping;下面是对应形式:
import { applyGameEvent, type GameEventMapping } from "@clxgame/pulse-game";
import { pulseContract } from "./pulse-contract";
const mappings: GameEventMapping[] = [
{
gameEvent: "encounter:start",
pulseAction: {
type: "setGameState",
stateId: pulseContract.states.combat,
},
},
{
gameEvent: "encounter:end",
pulseAction: {
type: "setGameState",
stateId: pulseContract.states.exploration,
},
},
{
gameEvent: "player:danger",
pulseAction: {
type: "setGameParameter",
parameterId: pulseContract.parameters.tension.id,
value: 0.8,
},
},
];
gameEvents.on((eventName) => {
if (music) applyGameEvent(music, mappings, eventName);
});
未匹配事件是安全的 no-op。示例里的游戏事件名只是占位符,应替换成项目自己的事件名;真正受合同约束的是 pulseAction 里的 state/parameter ID。这个 helper 不携带 transition mode;需要 nextBar、nextEnd 等特殊规则时,直接调用 setGameState。
如果在自定义工具中需要生成同样的合同和示例:
import {
buildIntegrationExample,
buildPulseContractSnippet,
} from "@clxgame/pulse-game";
const generated = buildIntegrationExample(module, {
packageUrl: "/packs/forest-combat.pulse",
});
console.log(generated.contract); // 结构化公开合同
console.log(generated.contractSnippet); // 可复制的 pulseContract.ts
console.log(generated.snippet); // 最小加载与事件示例
console.warn(generated.warnings); // 包括旧包推断警告
// 只需要 TypeScript 合同时:
console.log(buildPulseContractSnippet(module));
10. 静态部署、CDN 与缓存
正式 Web 部署不依赖开发期 /__pulse-sync/* 路由。把 .pulse 一并发布到静态站点或 CDN,并从实际 URL fetch。
- 推荐使用带版本或 hash 的文件名,例如
/packs/forest-combat.v3.pulse; - 更新内容时同步更新游戏配置,避免 CDN 长缓存拿到旧包;
- 跨域 CDN 需要允许游戏域名的 CORS;
- 保持二进制原样传输,不要把
.pulse当 JSON 转码; - 建议为下载失败准备静态 BGM 或无音乐回退。
仓库的 HTML5 示例在 production 下加载 /packs/forest-combat.pulse;只有 pnpm dev:game-live 的开发服务器提供 live-sync 路由。
11. 进阶:读取 manifest 或使用自定义 loader
只在需要读取 license/兼容性 metadata,或音频由游戏自己的资源系统管理时,才安装 core:
pnpm add @clxgame/pulse-game @clxgame/pulse-engine
读取 metadata:
import { loadPulsePackage } from "@clxgame/pulse-engine";
const pack = loadPulsePackage(packageBytes, { validate: true });
console.log(pack.manifest.metadata?.license);
console.log(pack.manifest.metadata?.compatibility);
console.log(pack.manifest.metadata?.publicInterface);
若资源不内嵌在 .pulse 中,而由游戏资源系统提供:
import { createPulseGameRuntime } from "@clxgame/pulse-game";
import {
loadPulsePackage,
type Resource,
type ResourceLoader,
} from "@clxgame/pulse-engine";
class GameAssetLoader implements ResourceLoader {
load(resource: Resource): Promise<ArrayBuffer> {
return gameAssets.loadArrayBuffer(resource.src);
}
}
const pack = loadPulsePackage(packageBytes, { validate: true });
const music = createPulseGameRuntime(pack.module, {
loader: new GameAssetLoader(),
});
metadata.compatibility 目前是交付提示,不会自动阻止 runtime。游戏应在下载/安装阶段决定是否接受 license、codec、最低版本和 feature 要求。不要绕过 PulseGameRuntime 直接用 publicInterface 调底层内部状态;游戏 facade 才负责执行公开边界。
12. 完整 HTML5 联调 Host
仓库内的 HTML5 项目包含合同校验、调试 UI 和 Desktop Live Sync,因此是进阶联调参考,不是应该整段复制的最小代码:
pnpm dev:html5-game
它验证真实 .pulse 和完整生命周期:
download -> strict package load -> ready -> user gesture play
-> setGameState -> setGameParameter -> dispose
为了支持 Live Sync,这个 Host 会读取底层 package 合同并使用低阶 factory。普通游戏仍应使用 loadPulseGame();关键实现见 examples/html5-game/src/main.ts。
Live Sync 属于交付检查,因此比普通 runtime 的旧包兼容更严格:没有经过设计师保存确认的旧包合同会被阻止同步,而不是把推断出的全部内部 ID 直接交给游戏。
13. 上线前检查清单
- 最终
.pulse已声明接入合同,帮助页生成的pulseContract与交付文件来自同一版本; - 若是旧包推断合同,已让内容方逐项确认全部公开 ID,没有忽略警告;
-
loadPulseGame()从最终部署 URL 成功返回; -
play()只在用户手势后调用; - 所有
setGameState/setGameParameter都使用生成合同里的公开 ID; - 参数按业务含义归一化并节流,未知 ID 能在开发期暴露;
- 开发环境记录了加载异常和
transition-executed; - 加载/播放失败有可接受的降级;
- 场景卸载调用
stop()、取消事件监听并dispose(); - happy path 从拿到
.pulse到听到声音不超过 5 分钟。
14. 当前边界
当前可作为生产接入承诺的是 Web Audio / HTML5 host。以下内容不应作为当前包的承诺写入游戏计划:
- Unity、Godot、Unreal 的正式 runtime 或插件;
- 独立 stinger/trigger 模型;
- Wwise/FMOD 级复杂中间件、节点图或完整 DAW;
- runtime 内置订单、授权或 entitlement 校验。
如果接入过程中遇到 state、parameter、资源加载或部署问题,优先记录:pack 版本、state/parameter 合同、loadPulseGame() 的错误、浏览器网络请求和 music.state 快照。这些信息足以定位绝大多数接入故障。