Skip to content

KubeJS 1.20.1 的限制与问题

1.20.1 的 KubeJS 能实现许多逻辑,但它既没有完整的现代 JavaScript 框架,也被人为规定了许多限制。很多看起来像“脚本写错了”的问题,实际是其限制与问题所导致的。

查阅顺序

1

先确认归属

先判断问题更像语法不能解析、类无法加载、参数解析异常,还是某个事件或功能本身没有效果。

2

再对照本页的对应条目

Java.loadClass(...) 先看 class filtercandidate methods 先看重载歧义;number 异常先看 TypeWrapper;世界生成完全无效先看 WorldgenEvents.add()

3

最后回到具体环境

本页描述的是原生 KubeJS 1.20.1 的默认能力。如果整合包额外引入 ClassJSKubeloader 或其他附属模组,应结合它们的行为重新判断。

常见表现

表现 优先检查的方向
Java.loadClass(...) 报错,或 Java.tryLoadClass(...) 返回 null 类不存在,或被 class filter 拦截
class Foo {}、默认参数、...args 等语法不能正常使用 当前 Rhino 语法边界
日志里出现 redeclaration of const,或反复遇到 duplicate const 一类问题 尽量避免把 const 当成起步阶段的默认写法
某个参数明明传了 number,结果行为仍然异常,或者某个需要int的参数传入init,却仍旧显示传入了 number的错误 TypeWrapper 转换
日志里出现 candidate methodscandidate constructors Java 方法或构造函数的重载歧义
传一个 JS 函数给 Java 接口时直接报错 接口适配限制
persistentData 的行为与 Forge / NeoForge 的认知不一致 KubeJS 自己的数据处理和 Loader 的不同
EntityEvents.hurt 里能读到伤害值,但改动无效并报错 1.20.1 中这一事件只提供只读伤害值
世界生成脚本完全没有效果 WorldgenEvents.add()1.20.1 尚未支持

文档与源码可直接对应的限制

类加载与语法边界

Java.loadClass(...) 会受到 class filter 限制

这里的 class filter,可以理解为一套“哪些 Java 类允许暴露给脚本”的过滤规则。Java.loadClass(...) 失败,并不只表示类名写错。

ScriptManager.loadJavaClass(...) 在加载类时会先检查该类是否允许暴露给脚本:

java
if (!isClassAllowed(name)) {
    either = Either.right(true);
}

throw Context.reportRuntimeError(
    (found ? "Failed to load Java class '%s': Class is not allowed by class filter!"
           : "Failed to load Java class '%s': Class could not be found!")
        .formatted(name),
    context
);

过滤器本身也有独立实现:

java
if (denyStrong.contains(s)) {
    return V_DENY;
}

if (allowStrong.contains(s)) {
    return V_ALLOW;
}

因此,Java.tryLoadClass(...) 返回 null 时,至少有两种不同原因:

  • 该类确实不存在。
  • 该类存在,但被 class filter 拒绝。

排查时不应只反复修改包名。

当前语法环境不能按完整现代 JavaScript 理解

原生 KubeJS 使用的是定制 Rhino。它支持一部分较新的语法特性,但不能按浏览器或 Node.js 的完整现代 JavaScript 能力来预设行为。对起步阶段来说,更直接的理解是:不要把现代 JavaScript 语法默认视为全部可用。

class 语法不可用,但 ES5 构造函数可用

在原生 1.20.1 中,脚本侧的 class 语法不应作为默认可用前提。例如:

js
class Foo {}

Rhino 的 token 常量里把 class 归到了 RESERVED

text
public static final int RESERVED;
ConstantValue: int 128

TokenStreamclass 关键字也会落到这一分支:

text
839: aload_2
840: ldc           #139                // String class
...
1396: sipush        128

这段源码说明的是:class 在当前运行时不可以直接按现代 JavaScript 引擎使用的语法去理解与使用。

但这并不影响 ES5 风格的构造函数写法。例如:

js
function Foo(name) {
    this.name = name;
}

const foo = new Foo("example");

这类 function + new 的构造方式,与 class Foo {} 不是同一套语法,因此应分开理解。

默认参数与 ...args 不可用

对于函数语法,也不要把默认参数和 rest 参数当成 KubeJS 原生支持的特性。

此处需要谨记的是:

  • 默认参数不能按现代 JavaScript 的写法使用。
  • ...args 这类 rest 参数也不能按现代 JavaScript 写法使用。

如果你不知道这两种特性对应的是什么:

js
function x(arg = 1) {}
function y(...args) {}

如果脚本需要兼容这一环境,应改写成 ES5 风格的参数处理方式,而不能沿用浏览器或 Node.js 中常见的现代写法。

尽量不要使用 const

你可以注意到很多教程包括本文档都使用了 const 来定义常量,所以此处要说的并非是const本身无法使用,而是其存在一个很常见、也很烦人的问题。

Rhino 的报错文本里本身就有这一条:

text
msg.const.redecl=
    TypeError: redeclaration of const {0}.

实际写脚本时,const 有时会触发你并不预期的重复声明报错,尤其是涉及函数的循环时,所以一般来说用 let 会更稳妥。真遇到 redeclaration of constduplicate const 这类错误时,优先先把相关声明改成 let 或再继续排查。

但不必因为这个问题而过于敏感,不涉及函数循环时可以顺着直觉先使用 const 定义常量。

Java类 操作与参数解析

class、ES5 构造函数、继承与 Java 类不是同一件事

KubeJS内无法继承(extends)类,无法实现(implements)接口。

这条限制说的是“不能在脚本里按 Java 的继承 / 实现模型继续写下去”,并不等于“不能使用已经拿到的 Java 类”。

如果某个类能够被 Java.loadClass(...) 成功加载,通常仍然可以:

  • 访问它的静态字段与静态方法。
  • 使用 new 调用它的构造函数。

例如:

js
new $AABB(0, 0, 0, 1, 1, 1);

new (Java.loadClass("net.minecraft.world.entity.ExperienceOrb"))(
    level,
    x,
    y,
    z,
    10,
);

因此,“不能 class”不应被误解为下面任意一种情况:

  • 不能使用 ES5 风格的构造函数。
  • 不能 new 已成功加载的 Java 类。

但这不等于“可以在 new 的同时顺手定义子类并覆写方法”。

像“先 new 一个 Java 类,再在构造时附带覆写体”的思路,本质上仍然是在脚本侧创建匿名子类并重写方法,而不是普通的构造函数调用;因此同样不支持。换句话说:

  • new 已成功加载的 Java 类(...) 这种普通实例化可以成立。
  • new 已成功加载的 Java 类(...) 后再附带覆写逻辑,这类匿名子类 / override 思路不支持。

很多人会误以为下面这种写法等于“先创建对象,再覆写 Java 方法”:

js
const ExperienceOrb = Java.loadClass("net.minecraft.world.entity.ExperienceOrb");

const orb = new ExperienceOrb(level, x, y, z, 10); 

orb.tick = function () { 
    console.info("custom tick"); 
}; 

但这不会生成一个覆写了 tick() 的 Java 子类。它只是试图在脚本侧给这个对象挂一个同名属性,而不是按 Java 的方式重写原方法,且会直接报错。

真正受限的是脚本侧的 class 语法,以及 Java 风格的 extends / implements而“构造时直接 override” 本质上也属于同一类限制

单个 JS 函数只适合少数回调接口

这里说的不是“把函数传给任何 Java 参数都行”,而是 Rhino 会在少数情况下,把一个 JS 函数临时适配成一个 Java 接口实例。

示意写法如下:

js
someJavaApi(function (value) {
    return value > 0;
});

这类写法成立的前提,是 someJavaApi 对应的那个参数,本来就要求一个“回调型接口”。这里先把它理解成:Java 侧等的,本来就是一个回调位。

InterfaceAdapter.create(...) 里的判断可以把边界说明得更清楚:

java
if (methods.length == 0) {
    throw Context.reportRuntimeError1("msg.no.empty.interface.conversion", ...);
}

if (!firstName.equals(method.getName())) {
    throw Context.reportRuntimeError1("msg.no.function.interface.conversion", ...);
}

对应的消息文本是:

properties
msg.no.function.interface.conversion=
    Cannot convert function to interface {0} since it contains methods with different names

按这段逻辑,可以确定以下规则:

  • 接口没有方法时,不能把 JS 函数转换进去。
  • 接口里有多个不同名字的方法时,不能把 JS 函数转换进去。
  • 只有当接口里需要适配的方法都叫同一个名字时,Rhino 才会继续尝试转换。

对初学者来说,更好记的说法是:单个 JS 函数只适合那种“Java 侧本来就在等一个回调”的接口。

所以,文档里说“这类写法可能成立”,指的其实就是下面这类情况:

  • Java 参数本来就是回调接口。
  • 这个接口通常只有一个需要实现的方法。
  • 少数情况下,即使有多个签名,只要方法名相同,Rhino 也可能继续尝试适配,但这一点目前仍缺少源码外的独立实测确认。

相反,下面这些情况不属于这条路:

  • 空接口。
  • 有多个不同方法名的接口。
  • 把函数赋给一个已经存在的 Java 对象,指望它变成方法覆写。

这一点在 ItemEvents.modification 里尤其容易误判,因为这里拿到的 item 不是“等待你实现的接口参数”,而是“已经创建好的 Java 对象”。

很多人在看到 item 是一个 Java 物品对象后,会自然地写出类似下面这种代码,试图直接覆写某个 Java 方法:

js
ItemEvents.modification((event) => {
    event.modify("minecraft:apple", (item) => {
        item.someMethod = () => {
            return 1;
        };
    });
});

这类写法不可行。原因不是箭头函数本身特殊,而是这里真正想做的事情,其实是“在脚本里直接覆写一个 Java 方法”。ItemEvents.modification 给你的,是一个已经存在的 Java 对象;KubeJS 不会因为你写了 item.someMethod = ...,就替你生成新的 Java 子类,也不会把这段函数自动变成一次合法的方法覆写。如果确实需要定制这类底层行为,通常需要转向 KubeJS Addon 层面的实现。

这里要分清两件事:

  • 修改 KubeJS 已经暴露出来、允许改动的字段或属性。
  • 直接覆写原有 Java 方法。

前者在 ItemEvents.modification 中经常可以做到;后者通常做不到。即使把箭头函数换成 function () {},结论也不会改变。

高频问题 numberTypeWrapper

number 在 KubeJS 里是高频问题,而且最麻烦的地方往往不是“值不对”,而是 Rhino 在挑 Java 方法时,并不会先把它当成明确的 intfloatdouble

从 Rhino 的调用顺序看,这个问题要分成前后两步:

  • NativeJavaMethod.call() 会先调用 findCachedFunction() / findFunction() 去决定选哪一个重载。
  • 真正的参数转换在后面才发生,也就是 Context.jsToJava() 再进入 NativeJavaObject.coerceTypeImpl()

这意味着,像 parseInt(...)Math.floor(...),甚至 Utils.parseInt(...) 这类先做一次整数化的处理,很多时候也只能改数值内容,改不了 Rhino 在挑方法时看到的参数种类。它们回到下一次 Java 调用里时,往往仍然只是一个普通的 number

Rhino 在前面这一步里,会把数值统一看成 number。例如:

  • NativeJavaMethod.scriptSignature()Number 会直接写出 number
  • NativeJavaObject.getJSTypeCode()Number 也统一归到 JSTYPE_NUMBER

真正把参数收窄成 intfloatlong 的逻辑,是后面 coerceTypeImpl() 里的 coerceToNumber()。例如目标类型是 Integer.TYPE 时,它才会继续走 toInteger(...) 这条分支。

因此,最常见也最棘手的情况其实是:

  • 你传进去的明明是要给 int 用的值。
  • 但在 Rhino 选重载的那一步,它仍然只是一个泛化的 number
  • 如果这个 API 同时还有 floatdoublelong、包装类型,或其他相近签名,就很容易直接落到 candidate methods、歧义选择,甚至方法匹配失败。

这也解释了为什么“先转成整数再传”经常没有预期效果。问题不在 Rhino 完全不会转 int,而在于它通常要等到方法已经选定之后,才真正去做这一步;如果前面的重载选择已经出错,后面的 intValue() 根本没有机会救场。

在这之后,TypeWrapper 才是第二层复杂因素。KubeJS 确实注册了多种 wrapper:

java
typeWrappers.registerSimple(IntProvider.class, UtilsJS::intProviderOf);
typeWrappers.registerSimple(NumberProvider.class, UtilsJS::numberProviderOf);
typeWrappers.registerSimple(TemporalAmount.class, UtilsJS::getTemporalAmount);

这表示当目标参数不是普通 int / float,而是 IntProviderNumberProvider 这一类时,number 还会再被 KubeJS 额外改写一次:

java
public static IntProvider intProviderOf(Object o) {
    if (o instanceof Number n) {
        return ConstantInt.m_146483_(n.intValue());
    }
    ...
    return ConstantInt.m_146483_(0);
}

public static NumberProvider numberProviderOf(Object o) {
    if (o instanceof Number n) {
        var f = n.floatValue();
        return UniformGenerator.m_165780_(f, f);
    }
    ...
    return ConstantValue.m_165692_(0);
}

所以,number 相关问题更准确的判断顺序应当是:

  • 这个 API 需要的是普通 Java 数值参数,还是别的数值相关类型。
  • Rhino 在挑方法时,是否会把几个重载都当成“同样接收 number”。
  • 目标参数是否还会触发 TypeWrapper,把值继续改写成 IntProviderNumberProvider 之类的对象。

这一类问题目前仍然没有稳定的通用解法。显式签名调用、改写参数结构、换更明确的 API,这些都值得尝试;但如果根因出在 Rhino 先按 number 选重载,再按 int/float 转值,那么单纯“先转一下整数再传”往往并不够。

真要处理时,通常只剩下几种更实际的办法:

  • 如果问题出在重载,优先用显式签名调用,例如 object["method(int)"](value)object["method(float)"](value)。这一步最直接,因为它绕过了 Rhino 前面的重载猜测。
  • 如果 ProbeJS 已经给出了带引号的重载签名,优先照那个签名写,而不要继续让 Rhino 自己猜。
  • 如果这个 API 本来就有多个数值重载,而且你会反复调用它,最稳妥的办法通常不是继续在脚本里“转类型”,而是包一层只有单一签名的辅助方法,再让 KubeJS 去调用那个辅助方法。
  • 如果目标参数其实不是普通 int / float,而是 IntProviderNumberProvider 之类的类型,就不要再按“普通数字参数”去调,应该直接改用该类型对应的输入形式。

至少按目前这一套实现来看,纯脚本层面并没有一个稳定的“强制把这个值变成 Java int 并提前参与重载选择”的通用手段。这也是为什么 parseInt(...)Utils.parseInt(...) 一类写法经常仍然不够。

出现 candidate methods 时,应先检查重载歧义

Java 的“重载”,是指同一个方法名对应多组不同参数。Rhino 无法判断该选哪一组时,会直接抛出歧义错误。

源码里的报错文本如下:

properties
msg.method.ambiguous=
    The choice of Java method {0}.{1} matching JavaScript argument types ({2}) is ambiguous; candidate methods are: {3}

msg.constructor.ambiguous=
    The choice of Java constructor {0} matching JavaScript argument types ({1}) is ambiguous; candidate constructors are: {2}

因此,日志里出现 candidate methodscandidate constructors 时,优先怀疑的是“同名方法或构造函数太多,Rhino 不知道该选哪一个”。

这时应先尝试显式签名调用:

text
object["方法名(完整参数类型)"](args)

ProbeJS 文档里给过一个直接例子:

text
event.hand["compareTo(net.minecraft.world.InteractionHand)"](arg)

这类写法的作用,是把完整签名直接告诉 Rhino,避免它继续猜测。

需要注意两点:

  • 这一步主要解决“重载歧义”。
  • 它不是万金油。如果真正的问题是 TypeWrapper、参数值本身不对,或 API 本来就不接受当前参数,它仍然可能失败。

构造函数也适用同样的判断逻辑。若日志里出现 candidate constructors,应把它视为与方法重载同类的问题。

KubeJS 的版本行为和自定义的持久化数据

persistentData 不是 Forge / NeoForge 的持久化附件

仓库文档已经说明,persistentData 是 KubeJS 提供的自定义数据存储系统,不是 NBT 数据,也不是 Forge / NeoForge 的附件体系

这里之所以容易让人看混,一个原因就是 KubeJS 里大量 API 名称都不是直接照搬 Minecraft / Forge 原名,而是经过了脚本侧重命名。实体相关源码里就能直接看到:

java
@RemapForJS("setMotion")
public abstract void setDeltaMovement(double x, double y, double z);

Forge 侧实体原本的 getPersistentData(),在脚本里也没有直接保留原名,而是另外映射成了:

java
@RemapForJS("getForgePersistentData")
public abstract CompoundTag getPersistentData();

因此,这一节里的 persistentData 指的是 KubeJS 自己维护的那一套,不应和 Forge 的同名概念混为一谈。

如需对照 Loader 原生机制,可参考 Forge 官方的 ,以及 NeoForge 官方的 。Forge 文档把能力系统放在 BlockEntityEntityItemStackLevelLevelChunk 上;NeoForge 文档则把额外数据拆成 block entitieschunksentities 的 Data Attachments 与 level 的 Saved Data。NeoForge 公开文档没有 1.20.1 分支,这里引用的是最接近的 1.20.5 - 1.20.6 页面。

WithPersistentData 的实现与 mixin 范围来看,原生 KubeJS 1.20.1 里这套 persistentData 主要支持下面几类对象:

  • MinecraftServer
  • ServerLevel
  • Entity

实际使用时还可以再顺手记两点:

  • 玩家属于 Entity,所以玩家也在这一组里。
  • 维度数据对应的是 ServerLevel,不是泛用的 Level

源码中也可以看到,KubeJS 自己维护了独立字段。以实体为例:

java
private CompoundTag kjs$persistentData;

if (kjs$persistentData != null && !kjs$persistentData.m_128456_()) {
    tag.m_128365_("KubeJSPersistentData", kjs$persistentData);
}

服务器对象上也有独立字段:

java
@Unique
private final CompoundTag kjs$persistentData = new CompoundTag();

ServerLevel 这边的实现也说明了,维度数据会按维度 id 挂到服务器这份数据下面:

java
var t = kjs$self().m_46472_().m_135782_().toString();
kjs$persistentData = kjs$self().m_7654_().kjs$getPersistentData().m_128469_(t);
kjs$self().m_7654_().kjs$getPersistentData().m_128365_(t, kjs$persistentData);

因此,更准确的理解是:persistentData 是 KubeJS 提供的一套持久化数据,它会写入存档,但其语义不应直接按 Forge / NeoForge 的持久化机制去套。

EntityEvents.hurt1.20.1 中只能读不能改

LivingEntityHurtEventJS 提供的伤害值是只读字段:

java
private final float amount;

@Info("The amount of damage.")
public float getDamage() {
    return amount;
}

这里只有 getDamage(),没有对应的 setter。因此,在 EntityEvents.hurt 中能读到伤害值,并不表示它也能直接改写伤害。1.21.1 的 KubeJS 7 已优化此问题。

如果目的是改伤害,而不是只读取伤害,那么在 1.20.1 里应改用 Forge 事件总线上的 LivingHurtEvent。Forge 源码里这个事件本身提供了 setAmount(float)

java
public float getAmount() { return amount; }

public void setAmount(float amount) { this.amount = amount; }

如果只是不熟悉 Forge 事件总线本身,可以先对照 Forge 官方的 页面。

最小示例是:

js
// startup_scripts/example_hurt.js
ForgeEvents.onEvent("net.minecraftforge.event.entity.living.LivingHurtEvent", event => {
    /**
     * @type {Internal.livingHurtEvent} // ForgeEvents如果不标注类型的话会没有正常补全。
     */
    const livingHurtEvent = event;
    livingHurtEvent.setAmount(livingHurtEvent.getAmount() * 0.5);
});

这里只做简单示例。进阶内容留到以后。

WorldgenEvents.add()1.20.1 中本身就未完成

这一条可以直接在 AddWorldgenEventJS 中看到:

java
ConsoleJS.STARTUP.error(
    "WorldgenEvents.add() is currently not supported in 1.20 yet! This will be fixed soon, but for now we recommend you comment out worldgen code. Sorry for inconvenience!"
);

而且 addOre(...)addLake(...) 等方法在这一版本里会直接 return。如果世界生成脚本在 1.20.1 完全没有效果,应先排除这一限制。

兼容性提醒

Proxy 不可用

在原生 KubeJS 1.20.1 中,无法使用 Proxy。你的设计不应依赖代理拦截、动态转发或类似能力,再去确认附属模组与额外运行时是否真的提供了这部分支持。

ProbeJS 补全不等于运行时对象本身

ProbeJS 很有用,但它给出的补全常为实例的父类。补全里没有某个方法不等于运行时一定没有;补全里能看到某个签名,也不等于当前参数一定能通过 Rhino 的转换与重载解析。

因此,补全只能帮助你缩小范围,不能替代日志、JSDoc、源码阅读和实际测试。还有看这篇文章。

附属模组会改变本页的前提

如果整合包额外引入了 ClassJSKubeloader 或其他会扩展 Rhino / KubeJS 能力的附属模组,那么本页的很多结论都需要重新判断。它们改变的是运行时边界,而不是原生 KubeJS 1.20.1 本体的默认能力。