Yuzhe's Blog

yuzhes

RedScript:一个以 Minecraft Java 版为编译目标的编程语言

RedScript:以 Minecraft Java 版为编译目标的编程语言

GitHub: bkmashiro/redscript

Minecraft Java 版有一套出乎意料强大的脚本层:mcfunction datapack、计分板、NBT 存储、execute 命令链。有人在里面做出了可运行的 CPU、光线追踪器、排序算法。但写这些代码很痛苦——原始的 .mcfunction 文件,没有变量,没有循环,没有抽象。

所以我写了个编译器。

RedScript 长什么样

// 杀死 10 格内的僵尸,奖励玩家
@tick(rate=20)
fn check_zombies() {
    foreach (z in @e[type=zombie, distance=..10]) {
        kill(z);
    }
}

@on_trigger("claim_reward")
fn handle_claim() {
    give(@s, "minecraft:diamond", 1);
    title(@s, "Zombie Slayer!");
}

这段代码编译成合法的 mcfunction 文件,放进 Minecraft datapack 就能运行。@tick(rate=20) 让函数每秒触发一次(20Hz ÷ 20 = 1Hz)。foreach 循环变成 execute as @e[...] run function 调用。@on_trigger 设置一个计分板 trigger 目标,玩家用 /trigger claim_reward 触发。

有趣的设计决策

实体选择器作为第一类类型

原始 mcfunction 里,实体选择器只是嵌在命令里的字符串:

execute as @e[type=zombie,distance=..5] run kill @s

RedScript 里,它是一个正式的类型,有专属 AST 节点:

interface EntitySelector {
  kind: '@a' | '@e' | '@s' | '@p' | '@r' | '@n'
  filters?: {
    type?: string
    distance?: RangeExpr   // ..5, 1.., 1..10
    tag?: string[]
    notTag?: string[]      // tag=!excluded
    scores?: Record<string, RangeExpr>
    limit?: number
    sort?: 'nearest' | 'furthest' | 'random' | 'arbitrary'
    nbt?: string
  }
}

范围字面量(..51..1..10)是词法器里的独立 token。解析器理解 @e[tag=boss, tag=!excluded, distance=..10] 包括 ! 取反前缀。这意味着类型检查、选择器验证、未来的 IDE 补全都成为可能。

foreach — 把循环体提取成子函数

Minecraft 的 execute 命令只能接一条命令:

execute as @e[type=zombie] run <一条命令>

多行的 foreach 体需要提取成独立的 .mcfunction 文件再调用:

execute as @e[type=zombie] run function mypack:check_zombies/foreach_0

RedScript 自动完成这件事。Lowering 阶段遇到 foreach 时:

  1. 创建一个新的 IRFunction,命名为 父函数名/foreach_N
  2. 把循环体降级到这个函数里
  3. 在调用点发出 execute as <选择器> run function ns:... 的 raw 指令

Codegen 再把子函数写成独立的 .mcfunction 文件。程序员只写 foreach,就能免费得到正确的多命令循环体。

IR(三地址码)

我选择了 TAC(三地址码)而不是 SSA。MC 计分板没有寄存器分配问题——假玩家得分相当于无限寄存器。SSA 的主要收益(减少寄存器压力)在这里不适用,所以更简单的 TAC 更合适。

变量映射到计分板假玩家:

let x: int = 42;
→ scoreboard players set $x rs 42

x = x + y;
→ scoreboard players operation $t0 rs = $x rs
  scoreboard players operation $t0 rs += $y rs
  scoreboard players operation $x rs = $t0 rs

IR 有显式的基本块和跳转指令,Codegen 把它们变成独立的 mcfunction 文件(MC 没有 goto,所以每个基本块变成一个调用下一个块的函数)。

@tick(rate=N) — 软件定时器

@tick 把函数注册到 minecraft:tick 函数标签,每游戏 tick 执行(20Hz)。@tick(rate=N) 更有意思——MC 没有原生定时器,所以编译器生成一个计数器:

# __tick_check.mcfunction(每 tick 运行)
scoreboard players add $__tick_slow_fn rs 1
execute if score $__tick_slow_fn rs matches 20.. run function ns:slow_fn
execute if score $__tick_slow_fn rs matches 20.. run scoreboard players set $__tick_slow_fn rs 0

所以 @tick(rate=20) 触发频率是 1Hz,@tick(rate=100) 是 0.2Hz(每 5 秒),以此类推。

内建命令——编译还是直接透传?

RedScript 里的”函数调用”分两种:

编译函数(用户定义):走完整的 IR 流水线——降级为基本块、优化、再生成代码。

内建命令(say、kill、give、effect、summon 等):这些是宏,直接发出已知的 MC 命令,不走 IR 优化,因为没什么可优化的——它们本来就是单条命令。

还有 raw(cmd) ——原始逃生舱,把字符串直接透传到输出的 .mcfunction,不做任何处理。处理那些选择器解析器暂时还搞不定的复杂 NBT 过滤条件时很有用。

/trigger——无需 OP 权限的玩家输入

这是交互式 datapack 里最有趣的 MC 机制。通常玩家不能修改自己的计分板分数。但 trigger 类型的计分板目标是特殊的:服务器可以为特定玩家 enable 它,玩家就能用 /trigger <目标> 增加自己的分数(然后自动禁用,直到再次 enable)。

这是玩家→datapack 通信的唯一安全通道,不需要给玩家 OP 权限。

@on_trigger("open_shop")
fn handle_shop() {
    give(@s, "minecraft:bread", 3);
    tell(@s, "面包给你!");
}

编译器生成:

玩家只需运行 /trigger open_shop,处理函数就触发了。

编译流水线

源码 (.rs)
  ↓ 词法分析
Token 流
  ↓ 语法分析
AST(Program、FnDecl、Stmt、Expr)
  ↓ Lowering
IR(IRModule、IRFunction、IRBlock、IRInstr)
  ↓ 优化器
优化后的 IR(常量折叠、死代码消除、拷贝传播)
  ↓ 代码生成
datapack/ 目录
  ├── pack.mcmeta
  └── data/
      └── <命名空间>/
          └── function/
              ├── main.mcfunction
              ├── main/foreach_0.mcfunction
              └── ...

164 个测试,6 个套件,全绿。

下一步

最后这个相当于在 Minecraft 里做 OOP。每个盔甲架是一个实例,它的分数是字段,foreach (@e[tag=turret]) 遍历所有实例。既荒诞又美妙。