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

deltaTimetimeentityIdentity 是全局变量;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 那一页