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。