Puppet
用于承载外部角色渲染器的 `Puppet` 元素,涵盖后端注册、生命周期与姿态控制
该类继承自 Displayable
自 0.20.0 起可用
Puppet 是舞台上的一个盒子,盒子内部由你注册的后端绘制。引擎负责盒子的外部:位置、所在图层、变换、不透明度,以及它在存档中的条目。盒子内部由你的后端绘制
import {Puppet} from "narraleaf-react";引擎不附带任何渲染器。src、options、命令名与载荷都是不透明的值,引擎原样存储、转发和序列化,因此宿主可以接入自己已有的 2D 模型渲染器、粒子系统或其他渲染器。它绘制出来的角色仍然站在剧本指定的位置、随 Camera 移动、由剧本驱动姿态,并能从存档正确恢复
没有注册后端时,元素依然占据舞台上的位置、参与变换、能够保存与恢复,只是什么都不画。这是正常状态而非崩溃,参见 后端缺失或失败
注册后端
后端注册在 Game 实例上,而不是通过 config 传入:它是带方法的活对象,而 config 会被深合并,也可能被冻结。请在游戏挂载之前注册。插件可以在自己的 register(game) 里调用
下面是一个完整的后端。它的模型是一份 JSON 清单,其中写着一个颜色和一张贴图,覆盖了真实后端必须处理的两件事:解析之后才能确定的一组文件,以及在模型仍在加载时就到达的第一个姿态
import {Game, type PuppetBackend} from "narraleaf-react";
const swatch: PuppetBackend = {
name: "swatch",
mount(container, ctx) {
const box = document.createElement("div");
box.style.width = "100%";
box.style.height = "100%";
box.style.backgroundSize = "contain";
box.style.backgroundRepeat = "no-repeat";
box.style.outline = ctx.options.outline ? "2px solid white" : "none";
container.appendChild(box);
// 清单文件里写着这套文件的其余部分,所以在它到达之前无法解析同级文件。
const loaded = fetch(ctx.resolveSrc(ctx.src))
.then((response) => response.json() as Promise<{colour: string; texture: string}>)
.then((manifest) => {
box.style.backgroundColor = manifest.colour;
box.style.backgroundImage = `url(${ctx.resolveSibling(manifest.texture)})`;
});
return {
ready: () => loaded,
// 收到的是完整状态而非差量。第一个状态在 `ready()` 被调用之前就会到达,
// 所以要等加载完成,而不是画进一个空对象里。
apply: (state) => loaded.then(() => {
box.textContent = [state.motion, state.expression].filter(Boolean).join(" / ");
box.style.opacity = String(state.params.alpha ?? 1);
}),
// 状态不建模的一次性动作。
command(name, payload) {
if (name === "flash") {
box.animate([{filter: "brightness(3)"}, {filter: "none"}], {duration: 200});
} else {
ctx.warn(`swatch: unknown command "${name}"`, payload);
}
},
resize(size) {
box.style.fontSize = `${size.height / 20}px`;
},
dispose() {
box.remove();
},
};
},
};
export const game = new Game();
game.registerPuppetBackend(swatch);再把这个 game 交给 provider:
<GameProviders game={game}>
<Player story={story} onReady={({liveGame}) => liveGame.newGame()} />
</GameProviders>用已被占用的名字注册会替换掉之前的后端
后端调用顺序
引擎按以下顺序调用后端:
mount()返回实例apply()立即以完整的初始状态被调用,早于ready()被调用。第一个姿态在模型仍在加载时就会到达apply()返回的内容结束后,ready()被调用;它 resolve 时元素进入"ready"- 只要元素还在舞台上,
apply()、command()、resize()会继续到来。它们都可能早于ready()的 resolve dispose()结束这一切,可能发生在任何时刻,包括加载过程中。此后引擎不再调用该实例的任何方法,容器会被清空
第 2 步是固定行为,有两种处理方式:
- 保存这个状态,等模型就绪后重新应用一次
- 从
apply()返回一个 promise,等待加载完成。这同时会推迟ready(),让元素在姿态到位之前不会进入"ready"。上面的示例采用这种做法
PuppetInstance 中未标记为可选的成员都要实现。引擎在调用 ready 和 resize 前会检查它们是否存在,因为纯 JavaScript 宿主或被强制转型为 PuppetBackend 的对象可能不满足契约。这些检查并不表示这两个成员可以省略
解析模型包内的文件
一个 2D 角色模型是清单加图集加贴图页,或者模型文件加动作加物理加贴图。有哪些同级文件只有解析完第一个文件才知道,因此由后端自己解析
ctx.resolveSibling(path) 以 src 所在的目录为基准解析路径:
// src: "models/alice/alice.model.json"
ctx.resolveSibling("alice.atlas"); // -> "models/alice/alice.atlas"
ctx.resolveSibling("textures/page-0.png"); // -> "models/alice/textures/page-0.png"
ctx.resolveSibling("../shared/eyes.png"); // -> "models/shared/eyes.png"
ctx.resolveSibling("https://cdn/x.png"); // -> 原样返回;绝对路径优先规则:
.与..会被折叠,越界时钳制在根目录而不是爬出去- 已经是绝对形式的路径(带 scheme、以
/开头、协议相对的//host/…、data URI)原样返回 - 空路径解析为
src本身 \按/读取,返回结果一律使用/
目录是引擎唯一会从 src 里读出的结构,而且只在 resolveSibling 被调用时读取。引擎不知道 src 的格式、内容,也不知道它会牵出哪些文件。如果某个后端的 src 是一个不透明的键而非位置,它没有可供解析的目录,路径会原样返回,此时应改用自己的 options,引擎同样会原样转发
预加载
ctx.resolveSrc 按与图片相同的规则解析资源:data URI 原样返回,其余先查预加载缓存,未命中则原样返回。resolveSibling 最后也会走同一道流程,因此用 scene.preloadImage 预热过的贴图在这里同样命中缓存
Puppet 自身的 src 不会被登记为预加载对象。 它是模型清单而非图片,引擎不知道它会牵出哪些贴图。要预热后端的贴图,请对它们调用 scene.preloadImage;不在缓存中的一律是后端自己去取的普通 URL
创建 Puppet
backend 与 src 是必填,其余都有默认值
const alice = new Puppet({
backend: "swatch",
src: "models/alice/alice.model.json",
size: {width: 900, height: 1200}, // 省略则取舞台尺寸
position: {xalign: 0.3},
motion: "idle",
});把 Puppet 放上舞台的方式与 Image 相同:在场景的动作中引用它,场景就会初始化它
size 是盒子的逻辑尺寸,单位为像素。默认值 null 表示舞台尺寸。后端在盒子内部缩放自己的内容,元素的变换(位置、zoom、scale、rotation)叠加在其上,与图片一致
Puppet 不能更换 src。 后端实例的生命周期与元素在舞台上的时间完全一致。请改用第二个元素
本版本中 Puppet 没有自己的转场。show() 与 hide() 以不透明度淡入淡出整个盒子
从剧本驱动 Puppet
六个可链式动作,按 PuppetState 划出的界线分成两类:
scene.action([
alice.show({duration: 400}),
alice.setMotion("idle"),
alice.setExpression("smile"),
alice.setParam("ParamAngleX", 12),
alice.setSlot("prop", "umbrella"),
alice.command("playMotion", {id: "wave"}), // 剧本直接继续
alice.command("playMotion", {id: "bow"}, {await: true}), // 这一句会等它结束
alice.setExpression(null),
alice.hide({duration: 400}),
]);五个 set* 各写入持久状态的一个字段,然后把完整状态交给后端。读档只需一次 apply 就能恢复,撤销再来一次即可,中间不重放任何东西。params 与 slots 按键合并,因此改动一个参数不会清空其余的
command 发送一次性动作,引擎既不建模也不解释。它不留下任何状态,因此读档不会恢复它,撤销也不会撤回它。只播一次的动作、命中测试、口型同步都用它
不主动要求就不会等待。 {await: true} 是 command 上的显式选项,set* 方法没有对应能力。等待中的命令与其他计时动作一样可以跳过
后端抛出异常、reject 或根本没注册时只会记录日志,不会致命:状态变更依然成立,并在元素下次挂载时被完整应用。发给不在舞台上的 Puppet 的命令会告警并被丢弃
Puppet 状态
Puppet 的持久状态是 {motion, expression, skin, params, slots},也就是存档中关于它的全部内容
apply 收到的是完整状态,不是差量。读档时引擎从存档重建状态并应用一次,而不是重放模型经历过的每一次姿态变化
因此一次性效果走 command(),状态不建模的东西也放在那里。motion、expression、skin 是每个 2D 角色渲染器都有的三个概念;专有的东西放进 params(自由数值)、slots(自由字符串)或命令。这些名字都不属于某个特定渲染器
null 的含义
每个字段都是一次请求,null 表示没有这个请求,而不是「保持现状」。状态是整体应用的,因此被清空的字段会真的清空,读档与撤销也就能重现它记录下的样子
| 字段 | null 的含义 |
|---|---|
motion | 什么都没有在播。模型停在不施加任何动作时的样子:它的设置/静止/绑定姿态,或者在格式没有这种概念时,后端自己的默认 |
expression | 不施加任何表情;脸是动作与皮肤共同决定的样子。要清空这一轨,而不是替换成模型自带的某个叫「neutral」的表情,这样 null 与 "neutral" 才是两回事 |
skin | 模型的默认皮肤,也就是在任何人挑选之前它呈现的那一套 |
slots[id] | 该槽位被清空,这与这个键压根不存在是相同的状态。键之所以还在,是因为 setSlot(id, null) 合并到已有的映射上 |
params[id] | 不存在这种情况:params 里没有 null。映射中没有提到的参数保持模型自己的默认值,因此清空一个参数意味着删掉这个键 |
模型中不存在的名字触发 ctx.warn,而不是抛出异常。从 apply() 抛出会让整个元素进入 "error"
更新版本引擎写入的键在读档时会被原样保留
公共方法
constructor
config: Partial<IPuppetUserConfig> & {backend: string; src: string}
字段:
backend: string- 已注册后端所响应的名字。必填;缺少时构造阶段抛出异常src: string- 交给后端的资源描述符,原样传递。必填options: Record<string, unknown>- 后端专属选项,原样传递。默认{}size: PuppetSize | null- 盒子的逻辑尺寸,单位像素。默认null,表示舞台尺寸layer?: Layer- 参见 LayerclassName?: string- 盒子的类名。它们落在承载盒子position: relative与宽高的那个元素上,也就是交给mount的容器的父元素,而不是容器本身。再往上一层是写入变换的包裹元素,所以在这里用类名设置transform会被逐帧覆盖motion: string | null- 初始动作。属于存档状态,能通过存读档往返expression: string | null- 初始表情skin: string | null- 初始皮肤params: Record<string, number>- 初始数值参数slots: Record<string, string | null>- 初始字符串槽位
以及可显示元素的全部变换属性:position、scale、rotation、opacity 等
const alice = new Puppet({
backend: "swatch",
src: "models/alice/alice.model.json",
options: {outline: true},
motion: "idle",
params: {ParamAngleX: 12},
});getStatus
绘制该 Puppet 的后端当前处于什么状态
- 返回
PuppetStatus- 参见 PuppetStatus
其中两个值值得处理。"missing-backend" 表示没有后端响应 config.backend,"error" 表示后端抛出异常或模型加载失败。这两种情况下元素仍在舞台上、仍参与变换、仍会存档,只是没有被绘制
状态描述的是活的实例,不属于存档:读档会重新挂载,状态从 "unmounted" 重新开始
if (alice.getStatus() === "missing-backend") {
// 本工程依赖的渲染器从未被注册
}onStatusChange
监听该 Puppet 的状态变化,回调收到新状态
listener: (status: PuppetStatus) => void- 以新状态调用- 返回
LiveGameEventToken- dispose 它以停止监听
后端是异步失败的:元素先挂载,模型随后加载成功或失败。要知道渲染器是否起来了,订阅这个事件
const token = alice.onStatusChange((status) => {
if (status === "error") console.warn("Alice is not being drawn");
});useLayer
可链式方法
setMotion
请求一个具名动作,通常是模型稳定下来后循环播放的那个
motion: string | null- 要请求的动作,null表示清空
这是持久状态而非一次性动作:它会被保存,并在模型下次挂载时被完整重新应用。只播一次就结束的动作属于 command。剧本不会等待后端摆出这个姿态
alice.setMotion("idle");setExpression
请求一个具名表情。与动作一样属于持久状态
expression: string | null- 要请求的表情,null表示清空
alice.setExpression("smile");setSkin
请求一套具名皮肤或服装。与动作一样属于持久状态
skin: string | null- 要请求的皮肤,null表示清空
alice.setSkin("winter");setParam
设置一个数值参数,其余参数保持原样
id: string- 参数 idvalue: number- 新值
id 的含义由后端决定:绑定参数、骨骼覆盖、混合权重都可以。引擎只负责记住它、保存它,并在读档时把整张映射交回去
alice.setParam("ParamAngleX", 12);setSlot
设置一个自由字符串槽位,其余槽位保持原样
id: string- 槽位 idvalue: string | null- 新值;null清空该槽位
槽位用于 motion / expression / skin 覆盖不到的具名内容:挂点、替换上去的道具,或某个渲染器自己的叫法
alice.setSlot("prop", "umbrella");command
向后端发送一条引擎既不建模也不解释的命令
name: string- 原样转发payload?: unknown- 原样转发options?: PuppetCommandOptions- 参见 PuppetCommandOptions
用于 PuppetState 不覆盖的部分:只播一次就结束的动作、命中测试、口型同步。这些都不会被保存,因此读档不恢复、撤销不撤回。需要留存的内容请通过上面的 set* 方法写进状态
不主动要求,剧本就不会等待
alice.command("playMotion", {id: "wave"}); // 剧本直接继续
alice.command("playMotion", {id: "bow"}, {await: true}); // 这一句会等它结束继承自可显示元素
可显示元素提供的一切在 Puppet 上原样可用,作用于整个盒子:
pos、scale、scaleX、scaleY、scaleXY、zoom、rotate、opacity、transform、show、hide,以及视觉效果:mask、clip、wipe、filter、backdrop、blend 及其对应方法
后端契约
这些类型不从库的其他部分导入任何东西,因此渲染器可以独立针对它们编写
PuppetBackend
name: string- Puppet 的backend配置所指向的键mount(container: HTMLDivElement, ctx: PuppetMountContext): PuppetInstance- 创建绑定到宿主元素的实例。引擎负责盒子,后端负责盒子内部。实例被 dispose 时容器会被清空
PuppetMountContext
挂载后端时引擎交给它的信息
src: string- Puppet 声明的资源描述符,原样传递。引擎唯一会从中读出的结构是它所在的目录,而且只通过resolveSiblingoptions: Readonly<Record<string, unknown>>- 作者为该后端提供的选项,原样传递size: PuppetSize- 盒子的逻辑尺寸,单位像素。之后的变化通过resize送达resolveSrc(src: string): string- 按照与图片相同的规则解析资源。参见 预加载resolveSibling(relativePath: string): string- 解析相对于该 Puppet 自身src的路径,即同一套文件里的同级文件。参见 解析模型包内的文件warn(message: string, detail?: unknown): void- 报告非致命问题。引擎会记录并保持舞台存活,绝不抛出
PuppetInstance
一个已挂载的模型。引擎持有的只有这个句柄。各成员何时被调用参见 后端调用顺序
ready(): Promise<void>- 模型加载完成且第一帧绘制之后 resolveapply(state: Readonly<PuppetState>): void | Promise<void>- 应用完整状态。挂载时先调用一次,早于ready(),之后每次变化都会调用command(name: string, payload: unknown): void | Promise<void>- 执行具名命令。引擎不解释name与payload。返回 promise 可以让传了{await: true}的调用方等待它;默认不等待resize(size: PuppetSize): void- 盒子尺寸发生变化describe?(): Promise<PuppetDescription>- 可选。向编辑器宿主描述模型dispose(): void- 拆除实例。之后容器会被清空
describe 不受状态限制,mount 与 dispose 之间的任何时刻都可能被调用,包括 ready() resolve 之前,也可能被多次调用。只有加载完成才能描述的后端,请在 describe 内部等待自己的加载。reject 也是安全的:宿主会记录日志并退回到让作者手动输入名字
PuppetState
各字段的空值语义参见 null 的含义
motion: string | null- 当前请求的具名动作,通常是模型稳定下来后循环播放的那个expression: string | null- 当前请求的具名表情skin: string | null- 当前请求的具名皮肤/服装params: Record<string, number>- 自由数值参数slots: Record<string, string | null>- 自由字符串槽位,用于上面三个名字覆盖不到的内容
PuppetCommandOptions
剧本如何对待一次性 command
await?: boolean- 剧本继续之前等待后端完成该命令。默认false
等待中的命令与其他计时动作一样可以跳过
PuppetDescription
模型对编辑器宿主的自述,宿主据此用活实例的数据填充检查器里的下拉框。无法回答的后端不实现 describe,宿主则退回到让作者手动输入名字
motions: string[]expressions: string[]skins: string[]params: {id: string; min: number; max: number; default: number}[]size: PuppetSize | null- 模型自己的画布尺寸,不提供时为null
PuppetSize
width: numberheight: number
逻辑像素
后端缺失或失败
没有注册后端的 Puppet 保留它在舞台上的位置、变换与存档状态,什么都不画。引擎按后端名字告警一次,而不是每个元素告警一次
后端行为异常时同样处理。mount 抛出、模型始终加载不出来、apply reject,都会被记录,舞台继续存活
要对这两种情况作出反应,用 getStatus() 读取活实例,用 onStatusChange() 订阅变化
PuppetStatus
| 值 | 含义 |
|---|---|
unmounted | 元素不在舞台上,或它的组件尚未挂载 |
missing-backend | 元素在舞台上,但没有任何后端响应它的 backend 名字。盒子照常参与变换、图层与存档,什么都不画 |
loading | 后端已挂载,其 ready() 尚未 resolve |
ready | 第一帧已经绘制 |
error | 挂载、应用状态或加载过程中抛出了异常。无论如何舞台都会继续存活 |
Game 上的方法
game.registerPuppetBackend(backend: PuppetBackend): this- 注册一个后端。返回 game 本身,可链式调用game.getPuppetBackend(name: string): PuppetBackend | null- 某个名字下注册的后端,没有则为nullgame.listPuppetBackends(): string[]- 所有已注册后端的名字,按注册顺序