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
}
}
范围字面量(..5、1..、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 时:
- 创建一个新的
IRFunction,命名为父函数名/foreach_N - 把循环体降级到这个函数里
- 在调用点发出
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, "面包给你!");
}
编译器生成:
load.mcfunction:scoreboard objectives add open_shop trigger+scoreboard players enable @a open_shop- tick 检查:
execute as @a[scores={open_shop=1..}] run function ns:__trigger_open_shop_dispatch - dispatch 函数:调用处理器,重置分数,重新为该玩家 enable
玩家只需运行 /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 个套件,全绿。
下一步
- CLI:
redscript compile src/main.rs -o dist/mypack/ - entity.tag/untag/has_tag:实体状态机
- random():
execute store result score $x rs run random value 1 6(1.21+) - 命令方块输出(
--target cmdblock):生成.nbt结构体文件,Impulse/Chain/Repeat 方块在 3D 空间物理排列 - 世界对象:隐形盔甲架作为”实例”——
let turret = spawn_object(x, y, z); turret.health = 100;
最后这个相当于在 Minecraft 里做 OOP。每个盔甲架是一个实例,它的分数是字段,foreach (@e[tag=turret]) 遍历所有实例。既荒诞又美妙。