插件与资源
一个 Estella 游戏就是由插件组合成的 App。每个子系统——物理、音频、瓦片地图、
粒子、UI 等等——都是一个插件,它注册自己的系统,并把运行时 API 暴露为一个资源。
取用子系统:Res(XxxAPI)
Section titled “取用子系统:Res(XxxAPI)”在系统里,把你需要的资源列进参数数组;回调会按同样的顺序收到它:
import { defineSystem, Res, Audio } from 'esengine';
defineSystem([Res(Audio)], (audio) => { audio.playSFX('assets/hit.wav');});每个子系统的命令式 API 都用同一种方式取用:
| 子系统 | 资源 | 指南 |
|---|---|---|
| 输入 | Res(Input) |
输入 |
| 音频 | Res(Audio) |
音频 |
| 资源 | Res(Assets) |
资源 |
| 瓦片地图 | Res(Tilemaps) |
瓦片地图 |
| 粒子 | Res(Particle) |
粒子 |
| 后处理 | Res(PostProcess) |
后处理 |
| 场景 | Res(SceneManager) |
场景 |
| 补间 | Res(Tween) |
动画 |
| 帧动画 | Res(SpriteAnimation) |
动画 |
| 动画机 | Res(AnimatorController) |
动画 |
| 计时器 | Res(TimerRes) |
脚本 |
| 时间轴 | Res(Timeline) |
时间轴 |
| 预制体 | Res(Prefabs) |
预制体 |
| 导航 / AI | Res(Nav) |
游戏 AI |
| 本地化 | Res(Localization) |
本地化 |
| 相机视图 | Res(CameraView) |
相机 |
| UI 事件 | Res(UIEvents) |
UI |
| 物理 | Res(Physics) —— 来自 esengine/physics |
物理 |
| Spine | Res(Spine) —— 来自 esengine/spine |
Spine |
物理和 Spine 是可选 side-module,按需加载,因此它们的 API 放在子路径上以保持基础
包精简;其余都在主 esengine 包里。上表的基础子系统都随标准 App 附带——无需接线。
有些子系统还按同一范式发布事件资源——内容是本帧事件、当帧读取的资源:
Res(Physics2DEvents)(碰撞接触)、Res(SpineEvents)(动画事件)、
Res(UIEvents)(控件的点击与输入)。
Res(X) 交给系统的是资源本身。ResMut(X) 交给它的是包在资源外面的一个
句柄——.get() 取值、.set(v) 替换、.modify(fn) 原地改动。当系统要替换或
改动资源的状态时用 ResMut——与组件上的 Mut 是同一契约。下一节把两种形态并排
放在一起。
定义你自己的资源
Section titled “定义你自己的资源”资源只是 App 持有的一个值,它不必是纯数据。上表里每个子系统 API 都是一个发布成
资源的类实例——Res(Audio) 是一个 AudioAPI,Res(SceneManager) 是一个带
switchTo / load / unload 的 SceneManagerState。你自己的资源同样可以带方法。
defineResource(默认值, 名字) 创建的是定义——一个身份,不是值本身:
import { defineResource } from 'esengine';
class ScoreState { value = 0; combo = 1;
add(points: number): void { this.value += points * this.combo; }}
export const Score = defineResource<ScoreState>(null!, 'Score');null! 是刻意的占位:真正的实例在插件 build 时插入,于是每个 App 拿到自己的一份。
引擎的每个子系统都是这么接的——SceneManager 声明为
defineResource<SceneManagerState>(null!, 'SceneManager'),它的插件里再
app.insertResource(SceneManager, new SceneManagerState(app))。
import type { App, Plugin } from 'esengine';
export const scorePlugin: Plugin = { name: 'score', build(app: App) { app.insertResource(Score, new ScoreState()); },};之后它和别的资源一样取用:
import { defineSystem, Res, ResMut } from 'esengine';
defineSystem([Res(Score)], (score) => { // score 就是那个 ScoreState 实例 if (score.value > 1000) { /* … */ }});
defineSystem([ResMut(Score)], (score) => { // score 是句柄——要先解包 score.modify((s) => s.add(100));});在 ECS 之外——宿主代码、defineBehavior 的钩子里——用
app.getResource(Score),拿到的是同一个活实例。
默认值的代价
Section titled “默认值的代价”资源在第一次被读取时才真正生成,方式是克隆你传给 defineResource 的默认值。这次
克隆是结构性的,不是全量的:
| 默认值 | 每个 App 最终拿到什么 |
|---|---|
| 普通对象 / 数组 | 一份深克隆——每个 App 各自独立的一份。 |
| 类实例 | 同一个对象,读到它的每个 App 共用。 |
| 函数字段 | 按引用共享——方法永远不会在克隆中丢失。 |
类实例按引用透传是有意为之:把它克隆成一个裸对象会剥掉原型,连带剥掉所有方法。实际
后果是 defineResource(new ScoreState(), 'Score') 会让进程里所有 App 共用同一份
分数——而编辑器同时跑着两个(编辑态与 Play 态)。只要资源持有状态,就优先用上面
null! + insertResource 那种写法。
组件 vs 资源
Section titled “组件 vs 资源”- 组件是逐实体的数据。你(在编辑器里或用
Commands)把它挂到实体上,用Query遍历它。C++ 支撑的组件每次读取都要从 wasm 堆里解码出来,所以你拿到的是一份拷贝 ——改完要交回去,否则这次写入会丢(见脚本)。 - 资源是逐 App 的单例——共享状态与子系统 API——用
Res读取。资源是那个活对象, 从来不是拷贝:在它上面调方法或改字段立刻生效。
子系统 API 作用于某个实体,而这个实体来自一个 Query(或一次 spawn)。这是你在各专题
里会反复看到的写法:
import { defineSystem, Query, Res, SpriteAnimator, Tween, TweenTarget } from 'esengine';
defineSystem([Query(SpriteAnimator), Res(Tween)], (q, tween) => { for (const [entity] of q) { tween.to(entity, TweenTarget.PositionX, 0, 100, 1.0, {}); }});生成实体:Commands
Section titled “生成实体:Commands”要在系统里创建实体并挂组件,取一个 Commands():
import { defineSystem, Commands, Transform, Sprite } from 'esengine';
defineSystem([Commands()], (cmds) => { cmds.spawn() .insert(Transform, { position: { x: 0, y: 0, z: 0 } }) .insert(Sprite, { size: { x: 32, y: 32 } });});