脚本结构与生命周期
编辑器内置对 TypeScript 脚本 的支持。
你可以在资产面板中创建、编辑并运行脚本,用于实现项目的启动流程、场景逻辑、节点交互、动画以及各种辅助功能。
总体上,脚本按用途分为三类:
- 绑定脚本:挂载到场景或场景节点上的脚本,拥有完整生命周期回调
- 辅助脚本:提供工具函数、数据结构、业务逻辑等,被其他脚本导入使用
脚本基础特性
在本引擎中,脚本具有以下通用特性:
1. TypeScript 资产脚本
编辑器支持在 资产(Assets) 面板中直接创建 .ts 脚本文件,你可以:
- 新建 TypeScript 文件
- 在内置编辑器中编写和修改脚本
- 将脚本作为资源与项目一起保存和打包
脚本文件的路径和命名会作为后续导入、绑定时的依据。
2. 所有脚本必须为 ES 模块
所有脚本都 必须 使用 ES Module 规范:
- 使用
import/export组织代码 - 不依赖传统的全局变量或脚本加载顺序
- 可以将功能拆分至多个模块中进行复用
示例:
// assets/scripts/math-utils.ts
export function clamp(x: number, min: number, max: number): number {
return Math.max(min, Math.min(max, x));
}3. 所有脚本都可以导入 zephyr3d 库
无论是启动脚本、绑定脚本还是辅助脚本,都可以直接从 zephyr3d 库中导入引擎 API,用于操作场景、节点、材质、摄像机等。
import { SceneNode, /* ... 其他你需要的类型或类*/ } from "@zephyr3d/scene";4. 支持引入第三方npm包
编辑器支持将npm包引入到项目(借助https://esm.sh)。 一旦引入了npm包,即可在脚本中导入。
脚本分类与使用场景
1. 绑定脚本(Attached Scripts)
绑定脚本是挂载到 场景(Scene) 或 场景中的节点对象(Node) 上的脚本,用来描述这些对象在运行时的行为逻辑。
你可以使用绑定脚本实现:
- 摄像机控制(旋转、缩放、跟随目标等)
- 角色/物体的运动、动画和状态机
- 输入响应(键盘、鼠标、触摸、手柄)
- 触发器、交互逻辑(按钮、开关、UI 交互等)
1.1 基本要求
一个绑定脚本需要:
- 是一个 ES 模块(使用 import / export)
- 默认导出一个类
- 该类实现引擎约定的生命周期方法(名称固定)
典型结构:
// assets/scripts/rotate.ts
import type { SceneNode } from "@zephyr3d/scene";
import { RuntimeScript } from "@zephyr3d/scene";
export default class RotateScript extends RuntimeScript<SceneNode> {
private target?: SceneNode;
private speed: number;
private rotationY: number;
constructor() {
// 构造阶段:实例刚被创建,还未绑定到对象
this.speed = 2;
this.rotationY = 0;
}
async onCreated(): Promise<void> {
// 初始化逻辑:可异步,例如加载配置、资源
}
onAttached(target: SceneNode): void {
// 绑定到具体对象
this.target = target;
}
onUpdate(dt: number): void {
// 每帧更新旋转
this.rotationY += this.speed * dt;
this.target.rotation.fromEulerAngle(0, this.rotationY, 0);;
}
onDetached(): void {
// 从对象上解绑时调用
this.target = undefined;
}
onDestroy(): void {
// 实例销毁前调用:释放资源、取消订阅等
}
}注意
因为脚本生命周期不会超出关联对象的生命周期,因此无需使用弱引用持有关联节点,也不能使用强引用持有关联节点,否则导致脚本与关联节点循环引用而无法释放,造成内存泄漏。
1.2 绑定方式
在编辑器中使用绑定脚本的一般流程:
- 在资产面板中新建脚本文件(例如 rotate.ts)
- 在脚本中实现并导出一个类(如 RotateScript)
- 在编辑器中:
- 选中场景(Scene)或某个节点(Node)
- 在属性面板的 脚本 区域添加脚本组件
- 选择对应脚本文件和导出的类名
- 运行场景后,引擎会自动:
- 创建脚本实例
- 调用生命周期回调(constructor → onCreated → onAttached → onUpdate …)
1.3 一个绑定脚本可以绑定多个对象
重要特性:
单个绑定脚本可以被绑定到 多个对象。如果多个对象具有相同或类似的行为逻辑,你可以复用同一个脚本类:
- 避免重复编写相同逻辑
- 便于统一维护行为
- 脚本内部可以对所有绑定对象进行 批量操作,在一定程度上提升性能
常见的两种模式:
- 每个对象一个实例(最常见)
// 简单示例:脚本负责单个目标对象
export default class RotateScript extends RuntimeScript<SceneNode> {
private target?: SceneNode;
private speed: number;
private rotationY: number;
constructor() {
this.speed = 2;
this.rotationY = 0;
}
onAttached(target: SceneNode) {
this.target = target;
}
onUpdate(dt: number) {
this.rotationY += this.speed * dt;
this.target.rotation.fromEulerAngle(0, this.rotationY, 0);;
}
onDetached() {
this.target = null;
}
}- 一个脚本管理多个对象(共享行为 + 批量更新)
对于某些场景(如几十个粒子节点、成群运动的敌人等),你可以通过自己的管理脚本来批量管理这些对象,从而减少重复逻辑调用:
示例:
// 简单示例:脚本负责多个目标对象
export default class RotateScript extends RuntimeScript<SceneNode> {
// 存储多个关联到此脚本的对象
private targets: Set<SceneNode>;
private speed: number;
private rotationY: number;
constructor() {
this.speed = 2;
this.rotationY = 0;
this.targets = new Set();
}
onAttached(target: SceneNode) {
// 新增关联对象
this.targets.add(target);
}
onUpdate(dt: number) {
// 批量更新
this.rotationY += this.speed * dt;
for (const target of this.targets) {
target.rotation.fromEulerAngle(0, this.rotationY, 0);;
}
}
onDetached(target: SceneNode) {
// 移除关联对象
this.targets.delete(target);
}
}1.4 启动脚本
绑定脚本可以设置为项目的启动脚本,这个脚本会在引擎初始化完毕且在启动场景加载前被绑定到null对象
2. 辅助脚本(Helper Scripts)
辅助脚本不直接参与启动或绑定,而是提供复用的工具逻辑,被其他脚本导入。适合放置:
- 工具函数、数学/几何运算
- 通用业务逻辑(AI、路径规划、状态机等)
- 配置与常量
- 网络请求封装、数据转换等
特点:
- 不需要实现任何生命周期接口
- 不直接挂载到场景或节点上
- 仅通过 import 被其他脚本使用
示例:
// assets/scripts/utils/math-utils.ts
import { Vector3 } from "@zephyr3d/base";
export function moveTowards(
current: Vector3,
target: Vector3,
maxDistanceDelta: number
): Vector3 {
const toVector = Vector3.sub(target, current);
const distance = toVector.magnitude();
if (distance <= maxDistanceDelta) {
return target.clone();
}
return Vector3.add(current, Vector3.scale(toVector, maxDistanceDelta / distance));
}被其他脚本使用:
// assets/scripts/follow-target.ts
import type { SceneNode } from "@zephyr3d/scene";
import { moveTowards } from "./utils/math-utils";
export default class FollowTargetScript extends RuntimeScript<SceneNode> {
private target?: SceneNode;
private follow?: DWeakRef<SceneNode>;
private speed = 5.0;
onAttached(node: SceneNode) {
this.target = node;
this.follow = new DWeakRef();
}
setFollowNode(node: SceneNode) {
this.follow.set(node);
}
onUpdate(dt: number) {
if (!this.follow.get()) {
return;
}
this.target.position = moveTowards(this.target.position, this.follow.get().position, this.speed * dt);
}
}绑定脚本的生命周期
绑定脚本有一套清晰的生命周期回调,使你可以在不同阶段执行不同逻辑。一个绑定脚本实例的典型生命周期如下:
构造函数:实例创建时调用onCreated():初始化方法,支持异步onAttached():脚本附加到对象后调用onUpdate():每帧更新调用onDetached():脚本从对象上解除附加时调用onDestroy():实例销毁前调用
除构造函数外,其余方法均为可选;你可以根据需要只实现其中若干个。
1. 构造函数
- 调用时机:关联对象被创建时,引擎加载此脚本并调用构造函数创建脚本实例。此时脚本还未关联。
- 参数:必须实现为无参数构造。
适合做的事情:
- 初始化成员字段(默认数值、缓存对象等)
不适合做的事情:
- 访问场景或节点(尚未 onAttached)
- 发起复杂的异步流程(推荐在 onCreated() 中进行)
示例:
export class ExampleScript {
private speed: number;
constructor() {
this.speed = 1.0;
}
}2. onCreated()
- 调用时机:实例创建后调用,支持 async。这是推荐进行初始化(尤其是异步初始化)的主要入口。
适合做的事情:
- 异步加载资源(纹理、模型、配置文件等)
- 初始化与具体对象无关的全局或共享状态
- 注册全局事件/消息总线等
示例:
async onCreated(): Promise<void> {
const res = await fetch("https://example.com/config.json");
this.config = await res.json();
}若无需异步,你也可以将 onCreated 写成普通同步函数。
3. onAttached()
- 调用时机:脚本被附加到具体对象(Scene 或 Node)后调用。
适合做的事情:
- 保存对象(目标场景或节点)的引用
- 初始化依赖于对象本身的状态(初始位置、缩放、组件查找等)
- 注册对象级别事件(例如碰撞、点击回调)
- 执行和关联对象有关的异步初始化
示例:
import type { SceneNode } from "@zephyr3d/scene";
onAttached(target: SceneNode): void {
this.target = target;
this.target.position.set(0, 1, 0);
}4. onUpdate()
- 调用时机:成功关联到对象后,每帧调用一次。
- 接收一个 dt 参数,表示当前帧与上一帧之间的时间间隔(秒)。
适合做的事情:
- 动画、移动、旋转等与时间相关的逻辑
- 实时输入检测和响应
- 状态机更新、行为树 tick 等
示例:
onUpdate(dt: number): void {
if (!this.target || !(this.target instanceof SceneNode)) return;
// 简单旋转示例
this.target.rotation.y += this.rotateSpeed * dt;
}性能建议:
- 避免在 onUpdate 中进行大量同步阻塞操作(例如大规模计算、同步网络请求)
5. onDetached()
- 调用时机:脚本从对象上解除绑定时调用(例如脚本组件被移除、节点被销毁等)。
适合做的事情:
- 取消事件订阅(避免内存泄漏)
- 清理对对象的引用
- 撤销一些只在绑定期间生效的修改(如临时材质、状态)
示例:
onDetached(): void {
console.log("Script detached from object");
this.target = undefined;
}6. onDestroy()
- 调用时机:脚本实例即将被销毁前调用。即便脚本已经从对象上解绑,在销毁前仍会执行此回调。
适合做的事情:
- 释放脚本内部持有的资源(缓存的纹理、模型、句柄等)
- 终止未完成的异步任务(如轮询、定时器)
- 取消注册到全局的监听/服务等
示例:
onDestroy(): void {
if (this.timerId != null) {
clearInterval(this.timerId);
this.timerId = null;
}
}示例
下面是一个更完整的绑定脚本示例,可绑定到多个场景节点实现移动动画:
// assets/scripts/move.ts
import type { SceneNode } from "@zephyr3d/scene";
import { Vector3 } from "@zephyr3d/base";
import { RuntimeScript } from "@zephyr3d/scene";
export default class extends RuntimeScript<SceneNode> {
private target?: SceneNode;
private speed: number;
private direction: Vector3;
constructor() {
// 构造阶段:仅初始化简单字段
this.speed = 2.0;
this.direction = new Vector3();
}
async onCreated(): Promise<void> {
console.log("RotateScript created");
}
onAttached(target: SceneNode): void {
// 保存关联对象
this.target = target;
if (target instanceof Node) {
// 将节点放到起始位置
target.position.set(0, 0, 0);
}
}
onUpdate(dt: number): void {
// 沿 direction 方向匀速移动
this.target.position.addBy(Vector3.scale(this.direction, this.speed * dt));
}
onDetached(): void {
console.log("RotateScript detached");
this.target = undefined;
}
onDestroy(): void {
console.log("RotateScript destroyed");
// 在此处做资源释放或全局解注册
}
}在编辑器中,将此脚本绑定到多个节点对象,这些节点都会按照相同逻辑移动。