Skip to main content
Read this first. 这一页给正在帮用户写 cheart 脚本的 AI 读。 完整 API 签名看左边侧栏的其他页;这里只放 你必须知道但容易错 的东西。

环境

  • 语言:BeanShell 3.0(不是 Java 8 也不是 Groovy,有自己的一套规则)
  • 宿主:Minecraft 1.20.1 Forge 47.3+,Java 17 toolchain
  • 脚本位置:用户用 .scripts folder 打开(路径不要硬编码)
  • 热重载.scripts reload [<name>],重载会保留 enabled / 键位 / 属性值
  • 加载顺序_ 开头 → 含 libs → 其他;先加载的脚本可以 bridge.addPublicMethod 把工具发布给后面的

BeanShell 陷阱(AI 最常写错的)

1. 颜色 / 大十六进制字面量是 long,不是 int

BSH 对 0xFF4CAF50 这种无符号值 > Integer.MAX_VALUE 的十六进制自动判成 long。API 的 color 参数全部是 long,直接传就行:
render.rect(x, y, w, h, 0xFF4CAF50);                  // ✓ 直接传
render.rect(x, y, w, h, new Color(255, 100, 0).getRGB()); // ✓ int → long widen 自动
不要写:
int c = 0xFF4CAF50;               // ✗ 溢出,BSH 抛 "cannot assign 4283215696 to int"
int c = (int) 0xFF4CAF50;          // ✗ BSH 的 (int) 超界不做截断,直接抛
比较两个颜色 bit 模式时走位掩码:((long)intVal) & 0xFFFFFFFFL == hexLong

2. 事件值不要用 (Number) / (int) 强转

BSH 对 Object → 抽象类 cast / Object → primitive cast 支持脆弱:
int b = (int) event.get("button");        // ✗ 可能 ClassCastException
Number n = (Number) event.get("x");       // ✗ 同上

int b = event.getInt("button");            // ✓
double x = event.getNumber("x");            // ✓ (返回 double,也能拿 Integer 字段)
ScriptEvent 上的 getInt/getNumber/getBool/getString 都在 Java 侧做 instanceof Number 转型,BSH 不参与。

3. 未类型化变量在 if/else 里定义会成块作用域

if (cond) {
    x = 1;           // 块内新变量,外面看不到
} else {
    x = 2;
}
me.log(x);           // ✗ Undefined argument: x
修:在 if 前先初始化一次:
x = 0;
if (cond) { x = 1; } else { x = 2; }
me.log(x);           // ✓

4. 不能 implements Runnable

BSH 在本客户端不能实现 Java 接口。所有”回调”都用方法名字符串
vm.registerButton("k", "Run", "onClick");         // "onClick" 是脚本里的方法名
void onClick() { me.log("clicked"); }

property.onChange("onValueChange");
me.async("doInBackground");
bridge.addPublicMethod("ns", "exposedFunc");
packet.sendSequenced("buildPacket");

注解

@Module@Command@EventTarget@OnLoad / @OnUnload / @OnEnable / @OnDisable 直接写就行。注解字段支持 name="..."category="..."defaultEnabled=truekey=71(GLFW keycode)等。

时机硬规则

错了会炸 / 行为不一致,没有补救:
API必须在这个事件里原因
me.setRotation(..., moveFix=true)player_updateRotationBus 在这个相位处理,moveFix 只对齐下一帧 motion 包
me.placeBlock(...)player_update旋转 + 放置包要同 tick 发出,不然服务端看到旋转没改就 useItemOn
render.* 绘制render_2drender_screen否则 GuiGraphics 未绑定,静默 no-op
render3d.* 绘制render_3d (post 阶段)pre 阶段世界几何还没渲染完
render3d.clearDepth()render_3d post,画 3D 文字前一次pre 阶段调会清掉游戏主渲染的深度
HttpClient.*me.async(...) 包一下同步阻塞,tick 里调会冻游戏

异步 / 线程安全

  • 脚本事件一律在主线程派发(BeanShell interpreter 单线程执行)
  • me.async("method") → 后台线程跑 method;方法里不能碰 MC 状态(碰了会和主线程竞争崩)
  • 后台拿数据,切回主线程用事件或标志位:
String cached = "";        // 主线程读
boolean ready = false;

void fetch() {
    r = HttpClient.get("...");
    cached = r.body;         // 后台写
    ready = true;
}

@EventTarget(events="tick")
void tick(e) {
    if (ready) {             // 主线程消费
        me.log(cached);
        ready = false;
    }
}
  • 绝不要在 tick / render_2dHttpClient.getThread.sleep、文件 IO、大循环(60fps × 1ms delay = 卡顿)
  • bridge 的 shared map 用 ConcurrentHashMap,跨脚本读写安全

事件取消 / 写回

很多事件可以 cancel 或改参数:
@EventTarget(events="chat")
void onChat(e) {
    if (e.getString("message").startsWith(".")) return;  // cheart 命令透过
    e.put("message", "[cheart] " + e.getString("message")); // 改消息
    // e.cancel();  // 或者完全吞掉
}

@EventTarget(events="send_packet")
void onSend(e) {
    if ("ServerboundSwingPacket".equals(e.getString("type"))) {
        e.cancel();  // 不发
    }
}
字段详见 events-reference

配置持久化(自动)

脚本 module 的这些状态自动存到 cheart 配置、下次启动恢复、.scripts reload 也保留:
  • enabled 状态
  • 键位绑定(key 字段)
  • 所有注册的属性值(slider / boolean / mode / color / list / …)
不自动保存的:脚本里的全局变量(比如 int counter = 0;)—— reload 后回到文件里的初值。需要跨 reload 持久化的状态请塞进属性(vm.registerTextvm.registerSlider 隐藏掉)或者写到 bridge shared map。

常见安全问题

崩溃风险

  • 在非主线程碰 MC:async 回调里直接调 me.getPlayer() / render.* / packet.send → 并发崩
  • 无限循环:BSH 没有超时保护,while (true) 冻死
  • 深度递归:BSH 帧栈小,invokeMethod 递归 > 几百层炸栈
  • 空指针:所有 API get* 方法在目标不存在时返回 null / 0 / false,但脚本自己链式调时要 null check:
    me.getPlayer().getPosition().x    // ✗ 断线时 getPlayer() null
    p = me.getPlayer(); if (p == null) return; pos = p.getPosition();  // ✓
    

服务端检测(防封号)

  • 超 reach 交互useItemOn 距离 > 5 格会被服务端拒绝或记录
    • me.placeBlock 前先检查 pos.distanceTo(player.getPosition()) < 4.5
  • 同 tick 多次发位置包:Blink / TimerHack 类脚本切勿一 tick 发 > 1 个 motion
  • HTTP 请求自己服务器HttpClient.* 请求会暴露 IP(服务器 log 看得到)——不要请求外网泄露玩家数据

自身脚本崩溃不会影响其他脚本

每个事件 handler 都有 try/catch 兜底,单脚本抛异常会被 ScriptLog.error 打出来但不传染。但启动时 @OnLoad 抛异常会标记脚本 FAILED,后续事件不会派发给它。

不要做的事

  1. ❌ 改其他脚本的属性 / 状态(除非通过 bridge)——reload 时序不可控
  2. me.unsafe().invoke(...) 调有副作用的 Forge 内部 API——可能崩服务端或 mod 兼容
  3. ❌ 把密钥 / token 硬编码进脚本——脚本文件用户肉眼可见
  4. bridge.clear()——影响所有脚本的共享数据,慎用
  5. @OnLoad 里发包 / 调 me.getPlayer()——此时玩家可能还没进服务器
  6. ❌ 写文件到 cheart 目录——用 me.unsafe() 配合 java.io.File 写到 <gameDir>/config/yourscript/

顶层对象速查(看详细签名跳详情页)

  • me — 玩家 / 世界 / 动作 / raytrace / 异步
  • moduleManager — 查询 / 开关所有模块
  • inventory — 背包 / 容器 / slot spoof
  • packet — 47 个 serverbound 包工厂 + 发送
  • notify — 右上角通知
  • bridge — 跨脚本共享 / 调用
  • render — 2D 绘制 + world→screen 投影
  • render3d — 3D 世界绘制

包装类速查

可工作的模板

1. 带属性的模块 + tick 事件

@Module(name="Example", category="PLAYER", defaultEnabled=false)
void meta(vm) {
    vm.registerSliderDouble("speed", "Speed", 0.0, 5.0, 2.0, 0.1);
    vm.registerBoolean("notify_toggle", "Notify on Toggle", true);
}

@OnEnable  void onEnable()  {
    if (me.getBool("notify_toggle")) notify.info("Example", "on");
}
@OnDisable void onDisable() {
    if (me.getBool("notify_toggle")) notify.alert("Example", "off");
}

@EventTarget(events="tick")
void tick(event) {
    p = me.getPlayer();
    if (p == null) return;
    speed = me.getNumber("speed");
    // ...
}

2. 2D ESP(render_2d 里画框)

@Module(name="Box ESP", category="VISUAL")
void meta(vm) { vm.registerColor("color", "Color", 0xFF00FF00); }

@EventTarget(events="render_2d")
void onRender(e) {
    self = me.getPlayer();
    if (self == null) return;
    long c = moduleManager.self().getProperty("color").getRGB();
    for (ent : me.getWorld().getPlayerEntities()) {
        if (ent.getId() == self.getId()) continue;
        r = render.worldBoxToScreenEntity(ent);
        if (r == null) continue;
        render.outline(r[0], r[1], r[2]-r[0], r[3]-r[1], 1.0, c);
    }
}

3. 3D 世界文字

@EventTarget(events="render_3d")
void onRender3D(e) {
    if (!render3d.isPost()) return;      // 只在 post 画
    render3d.clearDepth();               // 文字前清深度
    for (ent : me.getWorld().getItemEntities()) {
        p = render3d.interpolatedPos(ent);
        render3d.text("Item", p.x, p.y + 0.8, p.z, 1.5, true);
    }
}

4. Scaffold(注意 player_update + moveFix)

@EventTarget(events="player_update")
void onPU(e) {
    p = me.getPlayer();
    if (p == null) return;
    // find (x, y, z, face)...
    float[] rot = me.getRotation(centerX, centerY, centerZ);
    me.setRotation(rot[0], rot[1], 180f, true);    // moveFix = true
    me.placeBlock(x, y, z, face, "MAIN_HAND");
}

5. 跨脚本工具库

// 文件名:_math_libs.bsh(下划线前缀,先加载)
@OnLoad void reg() {
    bridge.addPublicMethod("math", "distance");
    bridge.addPublicMethod("math", "lerp");
}
double distance(x1, y1, x2, y2) {
    return Math.sqrt((x2-x1)*(x2-x1) + (y2-y1)*(y2-y1));
}
double lerp(a, b, t) { return a + (b - a) * t; }

// 其他脚本:
d = bridge.invokePublicMethod("math", "distance", 0.0, 0.0, 3.0, 4.0);

6. 异步 HTTP + 主线程消费

volatile String pending = null;

@Command(name="weather")
void exec(args) { me.async("fetch"); }

void fetch() {
    r = HttpClient.get("https://wttr.in/?format=3");
    if (r.isOk()) pending = r.body.trim();   // 后台写
}

@EventTarget(events="tick")
void tick(e) {
    if (pending != null) {                    // 主线程消费
        me.chat(pending);
        pending = null;
    }
}

7. 自定义 clickgui(registerScreen)

@Command(name="myui")
void exec(args) { me.openScreen("my_ui"); }

@OnLoad void onLoad() { me.registerScreen("my_ui"); }

@EventTarget(events="render_screen")
void onR(e) {
    render.rect(100, 100, 200, 100, 0xE0101418);
    render.textCentered("Hello", 200, 140, 0xFFFFFFFF);
}

@EventTarget(events="mouse_clicked")
void onClick(e) {
    double mx = e.getNumber("mouse_x"), my = e.getNumber("mouse_y");
    if (mx > 100 && mx < 300 && my > 100 && my < 200) {
        me.log("clicked");
    }
}

@EventTarget(events="close")
void onClose(e) { me.log("closed"); }

调试流程

脚本没按预期工作,按顺序检查:
  1. .scripts list — 状态 ✗ failed 说明加载时崩了,.scripts info <name> 看错误
  2. .scripts events — 事件订阅数为 0 说明 @EventTarget 没生效(可能写错事件名)
  3. 在可疑方法第一行加 me.log("reached") 看是否进入
  4. 运行时错误通过 ScriptLog 输出到控制台(不是聊天),去看 logs/latest.log
  5. ClassCastException 多半是 (int) / (Number) 硬转,改用 event.getInt 类型方法
  6. InterpreterError: cannot assign <number> to type int = 十六进制太大,用 long 接

最后提醒

  • 代码里少写类型声明,BSH 里 double x = 1.5x = 1.5 都能跑但后者在某些块作用域会出问题——看第 3 条陷阱
  • API 调用失败都是软返回,不抛异常;脚本作者需要自己判空
  • 每次改完脚本文件后 .scripts reload <name> 就生效,不用重启游戏
  • 写之前建议先看目标 API 详情页,上面有更完整的签名 / 参数说明