JavaScript 腳本入門

什麼是 JS 腳本

腳本是一段隨資源包分發的 JavaScript 程式碼,由遊戲在載入資源時解析,並在執行期間按幀呼叫,用來在原版機制之外渲染模型、文字、貼圖,或播放聲音。

  • 引擎是 Rhino——純 Java 實作的 JavaScript 引擎。你只需要掌握語言本身(變數、函式、條件邏輯)與 Date、Map 等內建物件;瀏覽器環境(HTML/CSS/DOM)完全用不上,IDE 的網頁類補全不要照單全收。
  • Rhino 提供 LiveConnect 互通作業:腳本可以直接呼叫 Java 類別與方法。JCM 的許多內建 API 本身就是暴露給腳本的 Java 類別。
  • JavaScript 與 Java 是兩門不同的語言;會 Java 不是寫腳本的前提,但理解 Java 的型別系統有助於避免互通作業陷阱(見撰寫守則)。
和 C++ 原生腳本的關係

Yanyang 分支在 JS 之外增加了 C++ 原生腳本,兩者共用同一套生命週期與渲染管線,甚至可以在同一條宣告裡混用(見 晏陽擴充 API)。簡單的一次性邏輯用 JS 疊代最快;高解析度逐像素重繪、複雜計算交給 C++。

四類腳本

每類腳本面向一個專門場景,可用的全域物件與回呼略有不同:

類型控制物件典型用途
車輛腳本MTR 載具(整列)車內螢幕、目的地顯示、報站聲音、馬達音
裝飾物腳本MTR 裝飾物方塊動態標識、閘門燈、互動裝置(右鍵事件)
電梯腳本MTR 電梯(轎廂樣式)樓層顯示器、到站鐘
PIDS 腳本JCM 到站螢幕(預設)自訂到站顯示版面配置、動態紋理

同一份腳本程式碼只解析一次;遊戲裡每個具體實例(每列車、每塊螢幕)各自持有獨立的 state 並分別回呼。

註冊腳本

腳本隨資源包載入。先保證資源包能被遊戲識別——在包根目錄放一個 pack.mcmeta:

{
    "pack_format": 13,
    "description": "你的資源包描述"
}

pack_format 對 JCM 腳本不起約束作用(不必隨 MC 版本逐個改),但必須是合法 JSON 數字。

宣告條目

車輛 / 裝飾物 / 電梯腳本宣告在 mtr_custom_resources.json(放在 assets/mtr/ 下);PIDS 腳本宣告在 joban_custom_resources.json(放在 assets/jsblock/ 下)的 pids_images 陣列裡。MTR 4 格式把「內容」與「腳本」分成兩個陣列,內容條目透過 scriptId 引用腳本條目——同一份腳本可被多個車輛複用:

{
    "vehicles": [
        { "id": "demo_sp1900", "name": "Demo SP1900", "scriptId": "demo_lcd" }
    ],
    "vehicleScripts": [
        {
            "id": "demo_lcd",
            "scriptLocations": ["demo:js/sp1900_lcd.js"],
            "prependExpressions": ["print('Hello world')"],
            "input": { "era": 2004 },
            "rateLimit": 0.75,
            "dataFetchMode": "ALL"
        }
    ]
}
欄位含義
scriptLocationsJS 檔案位置陣列(可多個)。自 JCM v2.3 起多數 API 也可直接收字串 id。無副檔名的條目按 Yanyang 規則可能被識別為 C++ 模組——見 混合條目
prependExpressions直接內嵌的 JS 程式碼,在 scriptLocations 之前執行
input任意 JSON 物件,腳本內以 SCRIPT_INPUT 唯讀存取
rateLimit腳本呼叫頻率下限(秒)。低頻邏輯調高它可省效能
dataFetchMode僅車輛腳本:SKIP(預設,不取資料)/ ALL / MANDATORY,詳見 資料取得模式
forceLoad同包內沒有內容引用該腳本時 JCM 預設拒絕載入;置 true 強制載入(跨包共享腳本條目時用)
isScriptControlled裝飾物 / 電梯的內容條目欄位:隱藏原模型,把渲染完全交給腳本

三類腳本的陣列名:vehicleScripts(車輛,內容在 vehicles)、objectScripts(裝飾物,內容在 objects)、liftScripts(電梯,內容在 lifts)。PIDS 沒有 scriptId 一環——直接在 pids_images 的預設物件上寫 scriptFiles / scriptTexts 即被視作腳本預設(鍵名沿用 JCM 慣例,見 PIDS 腳本)。

提示

所有欄位都可省略,但腳本條目至少要有 scriptLocations 或 prependExpressions 之一才會被解析。Yanyang 還接受 NTE 文件範例裡的 script_input 寫法(與 scriptInput 等價),照老文件抄的包不會失效。

MTR 3 / NTE 舊格式

MTR 3 時代的宣告繼續有效:車輛寫在 custom_trains(欄位名 script_files / script_texts / script_input / script_rate_limit),裝飾物寫在 eyecandy.json。舊格式車輛預設按 MANDATORY 模式取數。

生命週期

解析階段

遊戲重新載入資源(啟動或按 F3+T)時,每個腳本在主執行緒執行一遍頂層程式碼,隨後 JCM 記錄其中的 create / render / dispose 函式供執行期呼叫。模型載入、紋理讀取等一次性工作放這裡最合適——但重操作會直接拖慢整個資源重新載入,能省則省。

執行階段

let displaySpeed = 1;            // 解析階段執行

function create(ctx, state, vehicle) {
    state.speed = 0.75;           // 每個實例第一次渲染前執行一次
}

function render(ctx, state, vehicle) {
    state.speed += Timing.delta();  // 每幀(盡力而為)執行
}

function dispose(ctx, state, vehicle) { }   // 實例退場時執行

print(displaySpeed);             // 解析階段執行,輸出 1
參數含義
ctx上下文:提交繪製、播放聲音的「動作」入口,各類型不同(車輛是 VehicleScriptContext……)
state普通 JS 物件,逐實例持久——兩列車各有一份,互不串擾;初始為 {}
wrapper唯讀資訊源:車速、門值、螢幕尺寸、到站清單……各類型不同
  • 非同步執行:執行期回呼發生在專用的 4 條背景執行緒上(解析期仍在主執行緒)。別用 Thread.sleep 或無限迴圈占住執行緒——同一腳本條目固定綁在同一條執行緒上,別的腳本可能也在排隊。
  • 逐幀但不保證逐幀:render 以每幀一次為目標,但上一幀沒跑完就不會重入;幀率波動時別假設「每秒固定 N 次」。需要按真實時間推進的量用 Timing.delta() 縮放。
  • 執行時錯誤:日誌以 [Scripting] Error executing script! 開頭報錯並指明檔案行號;隨後整份腳本暫停 4 秒再試。遊戲內可開除錯覆蓋層直接在螢幕上看到錯誤。
  • 無 JS 執行緒保證:所有回呼函式之外看不到彼此的區域變數——跨呼叫共享的資料放 state。
與 C++ 生命週期的對應

create / render / dispose + 逐實例狀態正是 C++ 原生腳本沿用的模型(見 腳本模型與生命週期),兩種語言可以逐段對譯遷移。

開發輔助

  • 腳本除錯覆蓋層:Mod Menu(Fabric)或模組設定介面(Forge)→ JCM → Configure → 勾選 Script debug mode。螢幕左上角列出每個腳本條目與其實例,實例按耗時標藍(正常)/ 黃(偏慢)/ 紅(慢)。單次執行超過 5 秒會標記為無回應,再等 5 秒直接終止。按 [ / ] 在 [MTR] 與 [JCM] 兩個來源之間切換。
  • 快速重新載入:除錯模式開啟後,Ctrl+R 重新載入 MTR 側腳本(車輛 / 裝飾物 / 電梯),Shift+R 重新載入 JCM 側腳本(PIDS 預設)——不需要完整的 F3+T。三個鍵實際綁定的是「疾跑」「潛行」「JCM 重新載入」鍵,可在按鍵設定改。
  • 日誌:print(...) 與 console.log(...) 輸出到遊戲控制台;解析期錯誤只會在聊天欄提示一句,細節看啟動器的 logs/latest.log。
  • IDE 補全:VSCode 有第三方擴充功能 MTR Scripting Tools(Visual Studio Marketplace / Open VSX 可搜到)為 JCM 腳本類型提供智慧提示;問題請回饋給擴充功能作者。

撰寫守則

語言層面

  • 嚴格模式:不宣告就賦值(x = 1)會直接報錯。全域用 var 或 let,函式內一律 let。
  • const 是函式作用域:Rhino 裡的 const 不像現代 JS 那樣按區塊作用域——在 for 迴圈體裡宣告會在第二次疊代報「重複宣告」。需要區塊作用域語意時用 let。
  • JS 字串 vs Java 字串:MTR/JCM 傳回的多是 Java String。取長度用 str.length();想用 JS 的 .length 屬性,先拼一個空串轉換:(""+str).length。Java List 可用 list[0] 與 for (x of list),但 JS 陣列方法(map 等)不可用。
  • 可空值:傳回型別帶 ? 的方法可能傳回 null。用 if (x != null)、三元、可選鏈 x?.getName() 與空值合併 x?.length() ?? 0 處理;對 null 取屬性或呼叫方法會立刻拋出錯誤。
  • 多載解析:Java 方法按參數型別挑多載,數字預設偏好 double;拿不準時可用簽章後綴直接呼叫:graphics["drawString(java.lang.String,int,int)"]("文字", 15, 60)、new Color["(int,int,int)"](40, 68, 130)。報「method not found」時先懷疑參數型別。
  • 特性範圍:Rhino 不支援所有最新 JS 特性,詳見 Mozilla 的相容表。

行為層面

  • 別阻塞、別無限迴圈:沒有「等一會兒再繼續」這種寫法——把動作計時後放到後續回呼裡做;耗時操作(網路、大檔案)交給 BackgroundWorker。無限迴圈約 10 秒後被強制終止並按執行時錯誤處理。
  • 內部 API 界線:本文件沒寫的方法 / 欄位、MTRClientData 的內部結構、VehicleWrapper.getMtrVehicle() 以下的深層物件都屬內部實作,版本間可能直接變;公開分發的資源包不要依賴它們。
  • 類別存取白名單:預設只放行這些套件——com.lx862.jcm.mod.scripting、com.lx862.mtrscripting.util、com.lx862.mtrscripting.wrapper、java.lang、java.awt、java.text、java.math、java.util、java.time、javax.imageio、sun.java2d、sun.font、sun.awt、org.mtr。玩家可在設定裡解除,但分發的包若要求解除限制,務必在說明裡醒目告知。
  • state 變數加前綴:多個腳本可掛在同一實例上,state 是共享的——用 state.myfx_ratelimit 這類前綴避免撞名。
  • 效能有預算:單幀 16 ms 看似夠用,但玩家可能同時跑幾十個腳本實例。逐幀重繪大紋理前先算總帳:一列 8 節、每節 10 塊螢幕,每幀全量 upload() 就是 80 次紋理搬運——降解析度、跨門複用、降低更新頻率(RateLimit)都是正道。用戶端腳本能做得「夠好」即可,別追求 1:1 復刻。
  • 腳本不是安全邊界:腳本純用戶端執行,任何人都能本機改寫——任何權限判斷(如 hasPermissionLevel)都擋不住有心人,不要依賴它做存取控制。

與 NTE / MTR 3 的關係

JCM 腳本體系受 Nemo Transit Expansion(NTE)啟發,是 NTE 腳本功能在 MTR 4 上的非官方延續,保留了大量同名或等價的相容 API(文件中標「已棄用」的部分)。差異要點:

  • 舊格式註冊照常工作;MTR 3 與 MTR 4 雙棲的腳本可用 Resources.getAddonVersion("mtr")(舊腳本裡的 Resources.getMTRVersion())判斷版本後分支。
  • NTE 裡傳回 void 的 Vector3f 運算現在傳回自身,支援鏈式呼叫;Resources.manager() 在 MTR 4 上傳回 null,相關函式也接受 null。
  • ANTE 風格的相容類別(SoundHelper / TickableSound / GlobalRegister / ComponentUtil / 全域 asJavaArray())自 v2.3 起可用,方法簽章與 ANTE 一致。
  • MTR 4 只把附近的車站 / 線路資料同步給用戶端——腳本查不到遠處資料不是 bug,是刻意省流量;車輛腳本的資料取得三模式見 資料取得模式。

接下來:JS 公用 API —— 所有腳本共用的類別與函式;JS 腳本類型 —— 四類腳本各自的 ctx 與 wrapper。