跳转到内容

插件与资源

一个 Estella 游戏就是由插件组合成的 App。每个子系统——物理、音频、瓦片地图、 粒子、UI 等等——都是一个插件,它注册自己的系统,并把运行时 API 暴露为一个资源

在系统里,把你需要的资源列进参数数组;回调会按同样的顺序收到它:

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 是同一契约。下一节把两种形态并排 放在一起。

资源只是 App 持有的一个值,它不必是纯数据。上表里每个子系统 API 都是一个发布成 资源的类实例——Res(Audio) 是一个 AudioAPI,Res(SceneManager) 是一个带 switchTo / load / unloadSceneManagerState。你自己的资源同样可以带方法。

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),拿到的是同一个活实例。

资源在第一次被读取时才真正生成,方式是克隆你传给 defineResource 的默认值。这次 克隆是结构性的,不是全量的:

默认值 每个 App 最终拿到什么
普通对象 / 数组 一份深克隆——每个 App 各自独立的一份。
类实例 同一个对象,读到它的每个 App 共用。
函数字段 按引用共享——方法永远不会在克隆中丢失。

类实例按引用透传是有意为之:把它克隆成一个裸对象会剥掉原型,连带剥掉所有方法。实际 后果是 defineResource(new ScoreState(), 'Score') 会让进程里所有 App 共用同一份 分数——而编辑器同时跑着两个(编辑态与 Play 态)。只要资源持有状态,就优先用上面 null! + insertResource 那种写法。

  • 组件是逐实体的数据。你(在编辑器里或用 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():

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 } });
});