Engine 中的 JavaScript
Engine 的脚本 API:生命周期、坐标系的陷阱、创建一个对象的三种方式,以及如何在不穿墙的情况下移动一个对象。
一句话概括#
每一个实体都可以携带一段 JavaScript 脚本。它运行在浏览器里,运行在你的标签页中,作用于实时的场景,而且它是标准的脚本 API:BASIC 方言,以及编辑器里的 Python、C# 和 Rust 视图,最终都会编译到同一个底层接口上。
完整的清单是API 参考手册:50 个命名空间下的 538 个成员,每个成员一页,并带有搜索功能。这一页讲的是你在读那份参考手册之前需要知道的东西。
生命周期#
function start() {} // once, when the entity starts
function update(dt) {} // every frame, dt in seconds
function fixedUpdate(dt) {} // fixed timestep, for physics
function onCollisionEnter(other) {} // first frame of contact
function onCollisionStay(other) {} // every frame while touching
function onCollisionExit(other) {} // contact just ended
function onTriggerEnter(other) {} // entered a trigger volume
function onTriggerExit(other) {}
function onMessage(msg, data, senderId) {} // entity-to-entity messaging
function onAnimationEvent(name, data) {}
function onDestroy() {}
function main() {} // console-game entry, async allowed
deltaTime、time、entityId 和 entity 是全局变量;getComponent(type) 会从拥有这段脚本的实体上读取一个组件。
碰撞和触发器在 2D 和 3D 里都会触发,双方各自会收到自己的接触法线:other.contact.normal 指向的方向,是从对方实体指向这一个实体。正是这一点,让一段脚本能分辨出"我落在了它上面"和"它落在了我上面"的区别,一次平台跳跃式的践踏读的是 other.contact.normal.y > 0.5。
两个坐标陷阱#
两个都会实实在在地耗费调试时间,而且都不会大声报错。
鼠标:页面坐标 versus 画布坐标#
input.mousePosition 使用的是页面坐标。scene.pick() 想要的是画布坐标。把一个传给期待另一个的地方,不会报错,而是命中一个偏移了视口在窗口中位置的点,于是一次点击移动会让主角走到一个看起来合理、实际上却错误的地方。
调用不带参数的 scene.pick()(它会瞄准光标所在位置),或者在需要具体数值时使用 input.mouseViewport。
虚拟控件使用的是画布像素#
input.addVirtualJoystick(id, x, y, size) 和 input.addVirtualButton(...) 把它们的中心放在和 mouseViewport 相同的坐标空间里。一个摇杆在经过它的径向死区之后,会返回一个归一化的 X/Y;一个按钮会分别暴露按住、本帧刚按下和本帧刚松开三种状态。它们的指针输入会被捕获并消耗掉,所以对控件的一次触摸不会同时又被上报成一次原始的画布操作。
默认的摇杆是统一的。
input.getJoystickX/Y()会从屏幕上的虚拟摇杆、一个物理手柄,或者键盘(WASD/方向键)里,挑出信号最强的那一个完整向量。所以一个空闲连着的手柄不会因此禁用键盘移动。它的 Y 轴遵循 2D 屏幕空间的约定:向上是-1。
创建一个对象的三种方式,它们并不能互相替代#
| 它创建的是什么 | 存活多久 | |
|---|---|---|
scene.createEntity(def) | 一个已编写的实体:会出现在 Scene Graph 和 Inspector 里 | 整个项目周期,会被保存 |
game.spawn(type, pos) | 一个裸的原始体:没有生命值、没有 AI、没有行为 | 本次游玩会话 |
game.spawnFrom(template, pos, opts) | 一个完整实体的拷贝:组件、物理刚体、标签、层级、缩放,以及编译好的行为堆栈 | 本次游玩会话 |
game.spawn 适合用于碎片和原型,对敌人来说没有用:一个由它驱动的刷怪点只会产出没有行为的球体。spawnFrom 拷贝的模板,是你在编辑器里正常组合出来的一个普通实体,用层级面板的小眼睛就能关掉它:没有单独的格式,没有第二个编辑器,所以你看到的就是最终出现的东西。
// Once, at start: a missing template should say so, not fail every wave silently
if (!game.hasTemplate('Skeleton')) console.warn('no Skeleton template');
const mob = game.spawnFrom('Skeleton', { x, y, z }, { tag: 'Enemy' });
template 可以接受一个 id、一个名字或一个标签。游玩过程中创建的拷贝作用域仅限本次游玩,所以 stop() 会把它们移除,编辑场景永远不会被它们污染。
把一个实体关闭,意味着既隐藏又静默:节点被禁用,脚本停止运行,物理刚体不再响应。只做隐藏的话,会留下一个看不见却依然会打到你的敌人。
game.setEnabled(id, on)是从脚本里驱动它的方式,层级面板上的小眼睛图标是从界面里驱动它的方式。
移动一个对象:推动一个刚体,不要瞬移#
每一帧都去写 transform.position 会穿墙、穿过其他怪物、也会穿过地面。应该驱动速度,但仅在确实存在可驱动的对象时才这样做:
if (physics.hasBody) {
const v = physics.getVelocity();
physics.setVelocity({ x: vx, y: v.y, z: vz }); // pushes a real body
} else {
transform.position.x += vx * dt; // legitimate for a flyer
}
physics.setVelocity 在没有刚体的情况下,只是填了一个没人会去积分的字段,实体就会突然停住不动。这正是 physics.hasBody 存在的原因,也是目录里每一个移动相关的行为都会先问这个问题、而不是假设一个答案的原因。停止意味着把速度清零:什么都不做的话,刚体会继续滑行下去。
现在,写入一个位置确实会移动一个物理刚体了。 Havok 驱动的是变换本身,而不是反过来,所以以前对一个动态刚体赋值
transform.position会在下一步被覆盖掉:每一次瞬移在一个物理实体上都会静默失败。现在这个位置代理会重新同步刚体,就像旋转代理一直以来那样。
寻路是一份快照#
pathfinding.createGrid() 会一次性对一个网格做射线检测,以调用它时所在的位置为中心。离开那个范围会返回 null,而大多数调用者接下来会静默地回退成一条直线,直接穿墙而过。
这个网格默认会在每次请求出行时重新居中。在一个固定不变的世界里,setAutoRecenter(false) 可以选择退出,避免白白付出一次重建所需的射线检测开销。
console.log 用来记录。Print 用来绘制。#
控制台就是 console.log / warn / error,和其他任何地方一样。BASIC 的
Print 会在屏幕上绘制,并且每一帧都会被调用,这两者被刻意设计成两个独立的命令,把它们弄混正是让控制台被一个逐帧循环填满的原因。
脚本运行在哪里#
在编辑器里,以及在一次网页构建中,脚本运行在浏览器里。在一次原生桌面或移动端构建中,它们会在一个内嵌的 QuickJS 运行时上原样运行,用的是同一份源代码,而不是一次移植。
编辑时脚本存放在浏览器自己的数据库里,所以一个未保存的缓冲区能挺过一次刷新;它们会连同其他一切一起被保存进项目中。
限制与常见坑#
scene.pick()带参数时想要的是画布坐标。 见上文;这是最常见的一种"位置搞错了"的 bug。game.spawn造出的是一个原始体,不是一个角色。 请使用spawnFrom。- 游戏中途诞生的实体会被创建物理刚体。 以前物理体只在 Play 时才会构建,所以之后生成的任何东西都会静默地穿过世界地面,而它那些手动摆放的邻居却表现正常。
- 八步是 AI 队友的上限,不是你的。 一个脚本循环没有这样的限制;一个失控的
while依然会像其他地方一样卡死这一帧。 - 另外四种语言视图都是从这一种转译出来的。 编辑一个 Python 或 Rust 视图再切回来,走的都是存下来的那份 JavaScript,原因见BASIC 那一页。