Engine 中的 JavaScript

Engine 的腳本 API:生命週期、座標系陷阱、建立物件的三種方式,以及如何移動一個物體卻不會穿牆而過。

一句話說明#

每個實體都可以掛上一段 JavaScript 腳本。它在瀏覽器裡、在你的分頁裡執行,作用於即時的場景之上,而且它是標準的腳本 API:BASIC 方言,以及編輯器裡的 Python、C# 和 Rust 檢視,最終都會編譯到同一個介面上。

完整的清單是API 參考文件:538 個成員,分佈在 50 個命名空間裡,每個各自一頁,並附有搜尋功能。這一頁講的是你在閱讀它之前需要知道的事。

生命週期#

function start() {}                     // 只會呼叫一次,在實體啟動時
function update(dt) {}                  // 每一幀都會呼叫,dt 以秒為單位
function fixedUpdate(dt) {}             // 固定時間步,用於物理
function onCollisionEnter(other) {}     // 接觸的第一幀
function onCollisionStay(other) {}      // 持續接觸的每一幀
function onCollisionExit(other) {}      // 接觸剛結束時
function onTriggerEnter(other) {}       // 進入一個觸發區域
function onTriggerExit(other) {}
function onMessage(msg, data, senderId) {}   // 實體之間的訊息傳遞
function onAnimationEvent(name, data) {}
function onDestroy() {}
function main() {}                      // 主控台遊戲的進入點,可使用 async

deltaTimetimeentityIdentity 都是全域變數;getComponent(type) 會讀取擁有這段腳本的實體上的一個元件。

碰撞和觸發在 2D 和 3D 中都會觸發,而且雙方各自會收到自己的接觸法線:other.contact.normal 指向的方向,是從「另一個」實體指向這一個。這正是讓一段腳本能分辨「我踩在它上面」和「它踩在我上面」的方法,一個平台遊戲的踩踏判定讀的是 other.contact.normal.y > 0.5

兩個座標系陷阱#

兩者都會實際耗費不少除錯時間,而且都不會大聲失敗

滑鼠:頁面座標與畫布座標#

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 複製的模板,是你在編輯器裡組合出來的一個普通實體,可以用階層結構裡的眼睛圖示關掉,沒有另外一種格式,也沒有第二個編輯器,所以你看到的就是最後會出現的東西。

// 在 start 時執行一次:缺少的模板應該被明確告知,而不是每一波都悄悄失效
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 });   // 推動一個真正的剛體
} else {
  transform.position.x += vx * dt;                 // 對一個飛行單位來說是合理的做法
}

在沒有剛體的情況下呼叫 physics.setVelocity,只是填了一個沒有人會去整合運算的欄位,實體會直接停在原地不動。這正是為什麼 physics.hasBody 存在,也是為什麼目錄裡每一個會移動的行為都會先問這個問題,而不是假設答案。停止代表要把速度歸零:什麼都不做只會讓剛體繼續滑行下去。

現在,寫入一個位置確實會移動一個物理剛體。 Havok 驅動的是變形資訊,而不是反過來,所以以前對一個動態剛體賦值 transform.position,會在下一步被覆蓋掉:每一次瞬移在一個具有物理效果的實體上,都會無聲地失敗。現在位置代理會重新同步剛體,就跟旋轉代理一直以來的做法一樣。

尋路是一張快照#

pathfinding.createGrid() 會對一個格線做一次性的射線掃描,以呼叫當下的位置為中心。離開那個範圍會回傳 null,而大多數呼叫端接下來會退回走一條穿牆的直線,而且不會有任何提示。

這個格線預設會依照請求的路徑重新置中。setAutoRecenter(false) 可以在一個固定不變的世界裡選擇不這麼做,因為重建格線的代價是一次白費的射線掃描。

console.log 用來記錄。Print 用來畫東西。#

主控台是 console.log / warn / error,跟其他地方一樣。BASIC 的 Print 畫在螢幕上,而且每一幀都會被呼叫,這兩者刻意是分開的兩個指令,把它們搞混正是塞爆主控台的元凶。

腳本在哪裡執行#

在編輯器裡,以及在網頁組建裡,腳本都在瀏覽器中執行。在原生桌面或行動裝置組建裡,它們會在一個內嵌的 QuickJS 執行環境上原封不動地執行,用的是同一份原始碼,而不是移植版本。

腳本在你編輯時會存在瀏覽器自己的資料庫裡,所以一份未儲存的緩衝內容能在重新整理後倖存;它們會與其他所有內容一起被存進專案裡。

限制與常見問題#

  • 帶參數呼叫 scene.pick() 時,參數要用畫布座標。 見上方說明;這是最常見的「地點搞錯」錯誤。
  • game.spawn 產生的是一個基本體,不是一個角色。 請改用 spawnFrom
  • 遊戲中途誕生的實體會被建立一個剛體。 以前物理效果只會在按下 Play 時建立,所以之後生成的任何東西都會無聲地穿過世界,而它手動放置的鄰居卻表現正常。
  • 八個步驟是 AI 隊友的上限,不是你的上限。 一段腳本迴圈沒有這種限制;一個失控的 while 會像在其他地方一樣卡住整個影格。
  • 其他四種語言檢視都是從這一個轉譯出來的。 編輯一個 Python 或 Rust 檢視再切換回來,最終都會經過儲存起來的 JavaScript,原因見BASIC 那一頁