脚本
defineBehavior 是游戏循环的编排糖。一次调用同时给你两样东西:
- 一个组件,持有这个行为的每实体
state——可挂到任意实体,并在编辑器 Details 面板里调。 - 一个自动注册的系统,为每个携带该组件的实体驱动
start/update/destroy生命周期。
这里没有第二套脚本运行时:一个 behavior 完全降解到 Estella 现有的 ECS 之上——还是你已经在用的那套组件、系统和调度——所以它 既快又完全可检视。
你的第一个 behavior
Section titled “你的第一个 behavior”defineBehavior(name, def) 返回其背后的组件,所以你能像任何组件一样 spawn 或 insert 它。
state 对象成为该组件的每实体数据:
import { defineBehavior, Transform } from 'esengine';
export const Patrol = defineBehavior('Patrol', { state: { speed: 60 }, // 每实体数据,可在 Details 面板里编辑 update(ctx, dt) { const t = ctx.get(Transform); t.position.x += ctx.self.speed * dt; ctx.set(Transform, t); },});这一次调用就注册了更新系统。要把行为放到实体上,insert 它的组件——还能顺便覆盖起始 state:
import { defineSystem, Commands, Transform } from 'esengine';
const spawn = defineSystem([Commands()], (cmds) => { cmds.spawn() .insert(Transform, { position: { x: 0, y: 0, z: 0 } }) .insert(Patrol, { speed: 120 }); // 每个实例单独覆盖默认值});声明放在哪里
Section titled “声明放在哪里”编辑器不运行你的游戏也能认识你的组件:它会求值一个入口模块,然后读回其中声明过的东西。
这个入口默认是 src/components.ts——想放别处,改清单里的 scripts.register。
规则是可达性,不是位置。defineBehavior 和 defineComponent 在调用发生时才注册,
所以没人 import 的模块什么也不会注册。声明想分几个文件都行,从入口把它们接进来:
// src/components.ts —— 编辑器眼中的你的项目export * from './behaviors/patrol';export * from './components/health';入口够不到的行为运行时照样能跑,因为游戏是从 src/main.ts 启动的——这也正是它容易让人困惑的地方。
丢的是编辑器那一侧:细节面板里没有字段(组件仍能无损往返,但显示为未知)、创建实体弹窗的
脚本 分类里不出现、行为树与事件绑定的调色板里也没有 registerAction / registerCondition 的名字。
保存 src/ 下的任何文件都会重新提取一次,所以保存后片刻新字段就会出现在细节面板里。
如果某个组件始终不出现,去看 .esengine/cache/schemas.json:不在里面说明它压根没被够到;
在里面但检视器仍然空着,那是另一个问题。
新建脚本对话框
Section titled “新建脚本对话框”既然规则是可达性,那么手工新建的 .ts 在入口 import 它之前就是死的。
内容浏览器 → 创建菜单 → 新建脚本… 会把两半都写掉。
选它是什么,编辑器就知道该接进哪个入口——这个区分是你项目的架构,不是菜单上的便利:
| 种类 | 它是什么 | 落到哪里 |
|---|---|---|
| 组件 | 声明,编辑器不跑游戏也要读 | 从声明入口重导出——export * from './Health'; |
| 系统 | 行为,由播放域打包并运行 | 由启动入口 import,取其注册副作用——import './Patrol'; |
文件还不存在时,对话框就已显示将写入的模块路径与入口将增加的那一行。几点值得知道:
- 名字是先问的,不像别的创建那样丢给内容浏览器就地重命名,因为脚本的名字不只是 文件名——它同时是导出的标识符,以及每个场景序列化下去的那个字符串。必须是一个 普通的 JS 标识符。
- 入口取自项目清单(
scripts.register/scripts.main),所以挪过入口的项目接的是 它自己那一对,而不是约定俗成的那一对。 - 文件落在你正浏览的文件夹——前提是该文件夹在源码根内,否则落到源码根。在
assets/下遵从“就建在这儿”只会交还一个两个入口都够不到的模块,而这正是此功能 要防的失败。 - 对话框一关,组件就已在 Add Component 里——创建会立即重新提取 schema,不等文件 监视器。新文件是在内容浏览器里定位而非打开:本编辑器自身没有代码编辑器。
使用 npm 包
Section titled “使用 npm 包”你的脚本由 esbuild 打包,而它会从项目自己的 node_modules 解析依赖——所以装一个库就是
普通的安装:
cd my-gamenpm install protobufjsimport { Writer, Reader } from 'protobufjs/minimal';
const packet = Writer.create().uint32(42).string('hello').finish();它会随游戏发到每一个目标——网页与桌面构建、小游戏包(并降级到该宿主接受的语法)、 原生内容负载,以及编辑器自己的 Play。没有额外步骤,也没有逐平台配置:你 import 了什么, 打包进去的就是什么。
新建的项目自带 package.json。此前创建的老项目可能没有——npm init -y 一次即可,之后
行为完全相同。
哪些东西带不进来
Section titled “哪些东西带不进来”任何需要 Node 的东西。 游戏跑在浏览器或小游戏宿主里,两者都没有 fs、crypto、
path 之类,所以伸手去拿它们的包会让构建失败,而不是发出一个坏掉的包:
Could not resolve "crypto" (imported by src/net/sign.ts). "crypto" is a Nodebuilt-in, and a game does not run in Node…如果这个 import 是你自己写的,改用 Web API(crypto.subtle、fetch、localStorage)
或引擎自己的等价物——文件用 资产、玩家数据用
存档,它们在所有平台都能用。如果伸手的是某个依赖,那修法在
包那一侧:很多包都发布了浏览器安全的入口——上面的 protobufjs/minimal 正是——还有些带
browser 字段,esbuild 会自动选用。
而且它要占包体。 依赖是被整个打进去的,而这在上限最紧的地方最要命:微信把小游戏主包 限制在 4MB。构建完成后,体积报告会把包体拆开给你 看——涨上去的库会出现在脚本那一类里。
每个钩子都是可选的;只定义你需要的那些。
| 钩子 | 何时运行 |
|---|---|
start(ctx) |
一次,在该行为的组件首次出现在某实体上的那一帧。 |
update(ctx, dt) |
每帧,对每个携带该行为的实体。dt 是帧间隔(秒)(等同 ctx.time.delta)。 |
destroy(ctx) |
一次,当组件被移除或实体被 despawn 时。 |
export const Enemy = defineBehavior('Enemy', { state: { hp: 100 }, start(ctx) { // 一次性初始化:播种状态、缓存查找、生成血条…… }, update(ctx, dt) { if (ctx.self.hp <= 0) ctx.commands.despawn(ctx.entity); }, destroy(ctx) { // 收尾:不管实体是被杀死还是组件被卸下,都会运行 },});每个钩子都会收到一个 BehaviorContext——通往它所运行实体、以及周围世界的句柄:
| 成员 | 类型 | 说明 |
|---|---|---|
ctx.self |
S |
该行为自己的 state。随意改——修改会持久化。 |
ctx.entity |
Entity |
该实例所挂的实体。 |
ctx.time |
TimeData |
帧计时:delta、elapsed、frameCount、fixedDelta。 |
ctx.input |
InputState |
键盘 / 鼠标 / 触摸 / 手柄(见 输入)。 |
ctx.commands |
Commands |
延迟的 spawn / despawn / insert——update 中调用安全。 |
ctx.world |
World |
完整世界访问,用于跨实体读写。 |
ctx.get(Comp) |
ComponentData |
读取本实体上的另一个组件。 |
ctx.set(Comp, data) |
void |
写入本实体上的一个组件。 |
ctx.has(Comp) |
boolean |
本实体是否拥有 Comp。 |
import { defineBehavior, Transform } from 'esengine';
export const PlayerController = defineBehavior('PlayerController', { state: { speed: 200, facing: 1 }, update(ctx, dt) { const move = (ctx.input.isKeyDown('KeyD') ? 1 : 0) - (ctx.input.isKeyDown('KeyA') ? 1 : 0); if (move !== 0) ctx.self.facing = move; // 改 self 会持久化 if (move !== 0) { const t = ctx.get(Transform); // 拷贝——Transform 由 C++ 支撑 t.position.x += move * ctx.self.speed * dt; ctx.set(Transform, t); // ……所以要交回去 } },});可编辑状态与元数据
Section titled “可编辑状态与元数据”state 对象定义了组件的字段和默认值。传 metadata 来控制这些字段在编辑器里的呈现——范围、
步进、单位、枚举——和任何组件用的字段元数据是同一套:
export const Turret = defineBehavior('Turret', { state: { range: 300, fireRate: 2, target: 0 }, metadata: { fields: { range: { min: 0, unit: 'px', category: 'Targeting' }, fireRate: { min: 0, unit: '/s', category: 'Targeting', tooltip: 'Shots per second.' }, }, }, update(ctx, dt) { /* … */ },});因为状态是一个真正的、可序列化的组件,每实体的覆盖值会随场景保存,并在 Details 面板里实时编辑。
默认情况下生命周期系统在 Schedule.Update 里运行,每帧一次。传一个不同的 schedule 可以在帧
里的别处运行——例如用 Schedule.FixedUpdate 与物理同步推进:
import { defineBehavior, Schedule } from 'esengine';
export const Thruster = defineBehavior('Thruster', { schedule: Schedule.FixedUpdate, state: { force: 500 }, update(ctx) { // 固定节奏——读 ctx.time.fixedDelta 拿到固定步长 },});可用的相位包括 Startup、PreUpdate、Update、PostUpdate,以及固定三件套
FixedPreUpdate / FixedUpdate / FixedPostUpdate。
“2 秒后…” / “每 0.5 秒…“这类逻辑,用内置的计时器资源比手搓累计时间干净。
它随引擎循环走(所以和 setTimeout 不同,会跟游戏一起暂停):
import { defineSystem, Res, TimerRes } from 'esengine';
const arm = defineSystem([Res(TimerRes)], (timers) => { timers.delay(2, () => explode()); // 2 秒后执行一次 const h = timers.interval(0.5, (t) => spawnWave()); // 重复执行 timers.interval(1, (t) => tick(), 3); // 恰好 3 次 h.pause(); h.resume(); h.cancel(); h.reset(); // 句柄控制});| 成员 | 说明 |
|---|---|
delay(seconds, cb) |
seconds 后执行一次;返回 TimerHandle。 |
interval(seconds, cb, maxRepeat?) |
每 seconds 执行;maxRepeat 为 0 = 无限。 |
句柄 pause() / resume() / cancel() / reset() |
控制单个计时器(可链式)。 |
句柄 isActive / elapsed / repeatCount |
查看状态。 |
cancelAll() · activeCount |
管理全体。 |
timeScale |
一次性放慢 / 加速所有计时器(0 冻结)。 |
计时器只在播放模式推进——编辑器编辑态下与其余游戏时间一样保持不动。
匀速运动:Velocity 组件
Section titled “匀速运动:Velocity 组件”对“就是一直动”的运动——子弹、漂移的碎片、旋转体——直接挂内置的 Velocity 组件,
不必手写移动系统。引擎在播放模式下每次 update 把它积分进 Transform:
import { Velocity } from 'esengine';
world.spawn() .insert(Transform, { position: { x: 0, y: 0, z: 0 } }) .insert(Sprite, { texture: bulletTex }) .insert(Velocity, { linear: { x: 240, y: 0, z: 0 }, // 单位/秒 angular: { x: 0, y: 0, z: Math.PI }, // 弧度/秒(z = 2D 自转) });带 RigidBody 的实体会被跳过——物理求解器拥有它们的变换,两者永远不会争抢同一个实体。
Velocity 也是复制感知的:它的字段参与网络复制,联网客户端可以在快照之间做航位推算。
一个 behavior 并不是特例——它编译成恰好一个 defineComponent 加一个 defineSystem。当你的逻辑
天然是每实体的(巡逻、抛射物、拾取物)时,用 defineBehavior;当你要一次处理整个查询、或在实体
间协调时,用一个普通系统。
钩子签名——entity、world、commands,外加 self——也和游戏 AI 层
的动作与行为树叶子是同一套编程模型,所以在 behavior 和 AI 动作之间搬逻辑很顺手。
编辑器播放期间保存代码时,只要可行就会触发保状态热替换:若组件形状未变、 也没有系统被增、删或改名,存活的世界会被保留,只替换你的函数体。否则编辑器退回 整体重载。这对你的代码的要求:
- **组件与 behavior 的名字就是身份。**改名属于结构性变更——预期整体重载,该 数据的状态归零。
- 任何一种重载后,存活实体的
start都会再跑一次——契合 Estella 的快速重启 语义,所以把start当作幂等的初始化来写。
其机制(探针上下文、模式指纹、App.hotSwapSystems)见
应用设置与生命周期。