跳转到内容

脚本

defineBehavior 是游戏循环的编排糖。一次调用同时给你两样东西:

  1. 一个组件,持有这个行为的每实体 state——可挂到任意实体,并在编辑器 Details 面板里调。
  2. 一个自动注册的系统,为每个携带该组件的实体驱动 start / update / destroy 生命周期。

这里没有第二套脚本运行时:一个 behavior 完全降解到 Estella 现有的 ECS 之上——还是你已经在用的那套组件、系统和调度——所以它 既快又完全可检视。

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 }); // 每个实例单独覆盖默认值
});

编辑器不运行你的游戏也能认识你的组件:它会求值一个入口模块,然后读回其中声明过的东西。 这个入口默认是 src/components.ts——想放别处,改清单里的 scripts.register

规则是可达性,不是位置。defineBehaviordefineComponent 在调用发生时才注册, 所以没人 import 的模块什么也不会注册。声明想分几个文件都行,从入口把它们接进来:

// src/components.ts —— 编辑器眼中的你的项目
export * from './behaviors/patrol';
export * from './components/health';

入口够不到的行为运行时照样能跑,因为游戏是从 src/main.ts 启动的——这也正是它容易让人困惑的地方。 丢的是编辑器那一侧:细节面板里没有字段(组件仍能无损往返,但显示为未知)、创建实体弹窗的 脚本 分类里不出现、行为树与事件绑定的调色板里也没有 registerAction / registerCondition 的名字。

保存 src/ 下的任何文件都会重新提取一次,所以保存后片刻新字段就会出现在细节面板里。 如果某个组件始终不出现,去看 .esengine/cache/schemas.json:不在里面说明它压根没被够到; 在里面但检视器仍然空着,那是另一个问题。

既然规则是可达性,那么手工新建的 .ts 在入口 import 它之前就是死的。 内容浏览器 → 创建菜单 → 新建脚本… 会把两半都写掉。

选它是什么,编辑器就知道该接进哪个入口——这个区分是你项目的架构,不是菜单上的便利:

种类 它是什么 落到哪里
组件 声明,编辑器不跑游戏也要读 从声明入口重导出——export * from './Health';
系统 行为,由播放域打包并运行 由启动入口 import,取其注册副作用——import './Patrol';

文件还不存在时,对话框就已显示将写入的模块路径与入口将增加的那一行。几点值得知道:

  • 名字是先问的,不像别的创建那样丢给内容浏览器就地重命名,因为脚本的名字不只是 文件名——它同时是导出的标识符,以及每个场景序列化下去的那个字符串。必须是一个 普通的 JS 标识符。
  • 入口取自项目清单(scripts.register / scripts.main),所以挪过入口的项目接的是 它自己那一对,而不是约定俗成的那一对。
  • 文件落在你正浏览的文件夹——前提是该文件夹在源码根内,否则落到源码根。在 assets/ 下遵从“就建在这儿”只会交还一个两个入口都够不到的模块,而这正是此功能 要防的失败。
  • 对话框一关,组件就已在 Add Component 里——创建会立即重新提取 schema,不等文件 监视器。新文件是在内容浏览器里定位而非打开:本编辑器自身没有代码编辑器。

你的脚本由 esbuild 打包,而它会从项目自己的 node_modules 解析依赖——所以装一个库就是 普通的安装:

Terminal window
cd my-game
npm install protobufjs
import { Writer, Reader } from 'protobufjs/minimal';
const packet = Writer.create().uint32(42).string('hello').finish();

它会随游戏发到每一个目标——网页与桌面构建、小游戏包(并降级到该宿主接受的语法)、 原生内容负载,以及编辑器自己的 Play。没有额外步骤,也没有逐平台配置:你 import 了什么, 打包进去的就是什么。

新建的项目自带 package.json。此前创建的老项目可能没有——npm init -y 一次即可,之后 行为完全相同。

任何需要 Node 的东西。 游戏跑在浏览器或小游戏宿主里,两者都没有 fscryptopath 之类,所以伸手去拿它们的包会让构建失败,而不是发出一个坏掉的包:

Could not resolve "crypto" (imported by src/net/sign.ts). "crypto" is a Node
built-in, and a game does not run in Node…

如果这个 import 是你自己写的,改用 Web API(crypto.subtlefetchlocalStorage) 或引擎自己的等价物——文件用 资产、玩家数据用 存档,它们在所有平台都能用。如果伸手的是某个依赖,那修法在 包那一侧:很多包都发布了浏览器安全的入口——上面的 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 帧计时:deltaelapsedframeCountfixedDelta
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); // ……所以要交回去
}
},
});

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 拿到固定步长
},
});

可用的相位包括 StartupPreUpdateUpdatePostUpdate,以及固定三件套 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 执行;maxRepeat0 = 无限。
句柄 pause() / resume() / cancel() / reset() 控制单个计时器(可链式)。
句柄 isActive / elapsed / repeatCount 查看状态。
cancelAll() · activeCount 管理全体。
timeScale 一次性放慢 / 加速所有计时器(0 冻结)。

计时器只在播放模式推进——编辑器编辑态下与其余游戏时间一样保持不动。

对“就是一直动”的运动——子弹、漂移的碎片、旋转体——直接挂内置的 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;当你要一次处理整个查询、或在实体 间协调时,用一个普通系统

钩子签名——entityworldcommands,外加 self——也和游戏 AI 层 的动作与行为树叶子是同一套编程模型,所以在 behavior 和 AI 动作之间搬逻辑很顺手。

编辑器播放期间保存代码时,只要可行就会触发保状态热替换:若组件形状未变、 也没有系统被增、删或改名,存活的世界会被保留,只替换你的函数体。否则编辑器退回 整体重载。这对你的代码的要求:

  • **组件与 behavior 的名字就是身份。**改名属于结构性变更——预期整体重载,该 数据的状态归零。
  • 任何一种重载后,存活实体的 start 都会再跑一次——契合 Estella 的快速重启 语义,所以把 start 当作幂等的初始化来写。

其机制(探针上下文、模式指纹、App.hotSwapSystems)见 应用设置与生命周期

  • 系统 —— 完整的查询词汇、过滤器与调度。
  • ECS 架构 —— 实体、组件,以及系统如何遍历它们。
  • 输入 —— 在系统里读取键盘、指针与手势。