模组
模组是一个 zip:一段 main.lua、几张纹理,加上你需要的方块、命令和事件。每个模组跑在独立的 Lua 状态里,只开基础库,有内存和指令预算。
能做什么、不能做什么
| 能做 | 不能做 |
|---|---|
| 注册方块和命令、读写方块、给物品、传送本地玩家、生成掉落物、监听挖放 / 玩家进出 / tick。 | 自定义模型、流体方块、透明方块管线、客户端 UI、自定义音效。 |
| 读写自己的数据目录(文本文件)。 | 访问网络、文件系统其他位置、执行系统命令、加载其他 Lua 文件。 |
| 在单机 / 主机侧修改世界,客户端通过正常同步看到结果。 | 在客户端执行脚本;脚本不会在客户端跑,也不会跨网络广播消息。 |
这是主机侧脚本:cael.message 只写执行脚本那一端的消息记录,cael.teleport 只传送本地玩家,cael.give 只加到本地背包。客户端只加载方块注册表和纹理,收到 Welcome 后关闭执行。
安装与包结构
把模组包放进 exe 旁边的 mods\:扩展名是 .mod.caelc(本质是 zip)或 .zip。文件夹不是模组,是模组的数据目录;只扫描这一层的普通文件,跳过 . 开头的目录。
每次启动会把每个包解压到 mods\.cache\<id>\,下次启动先清空再解压。解压有路径穿越过滤,总大小上限 256 MB、文件数上限 4096。数据目录 mods\<id>\ 不会被动,放在这里的文件才是持久数据。
hello.mod.caelc zip
main.lua 根目录
textures\
lamp.png
另一种布局:
hello.mod.caelc
hello\ 单个顶层文件夹
main.lua
textures\lamp.png
模组 id 取自包文件名(去掉 .mod.caelc / .zip)。建议用小写 ASCII、数字、下划线,别用空格。包里 main.lua 在根目录或单个顶层文件夹里都行;纹理路径相对于模组根目录。
装完要重启游戏,没有热重载。/mods 可以列出已加载的模组;启动时的控制台窗口会打印 [mod:名字] 日志。
沙箱与预算
- Lua 5.4.7 静态编进 exe,每个模组一个独立
lua_State。 - 只开
base、string、table、math、utf8、coroutine;去掉dofile、loadfile、load、require、package。 - 64 MB 内存上限;每次回调约 20 万条指令预算(40 个 hook × 5000 条)。死循环会被中断并写日志,不会卡死主机。
print被接到模组日志,输出到控制台;cael.log一样。想让玩家看到消息用cael.message(只在本端)。
cael API
所有函数在全局 cael 表里。方块 ID 用命名空间字符串:内置方块是 caelcraft:stone 这种形式,模组方块是 <mod>:<id>。
| 函数 | 返回 / 说明 |
|---|---|
cael.api_version | 数字,当前是 1。 |
cael.mod | 当前模组 id 字符串。 |
get_block(x, y, z) | 方块 ID 字符串;区块没加载或越界返回 nil。 |
set_block(x, y, z, id) | 布尔值;成功返回 true。会走正常的世界修改和同步。 |
message(text) | 写到本端消息记录;不广播给客户端。 |
give(id, count) | 加到本地玩家背包;背包满或方块无效返回 false。 |
player_position() | 返回 x, y, z 三个数字(本地玩家)。 |
teleport(x, y, z) | 传送本地玩家。 |
spawn_drop(x, y, z, id, count) | 在方块坐标生成掉落物;成功返回 true。 |
is_host() | 单机或主机返回 true,客户端返回 false(客户端脚本本来就不执行)。 |
log(text) | 写控制台日志。 |
data_dir() | 返回并创建 mods\<id>\ 的路径字符串。 |
data_read(name) | 读数据目录下的文本;不存在返回 nil。 |
data_write(name, text) | 写文本;返回布尔值。先写 .tmp 再改名。 |
register_block(table) | 注册方块,返回完整 ID 字符串。 |
register_command(name, fn) | 注册命令;fn(args, player) 返回字符串作为回复。 |
注册方块
cael.register_block 接受一个表。内置方块固定 0–14,模组从 15 起追加;注册发生在启动时,运行中不能新增。
| 字段 | 默认 | 说明 |
|---|---|---|
id | 必填 | 不带冒号时自动加上 <mod>:。 |
name | id 的后半段 | 显示名,用于消息和调试。 |
texture | 必填(除非给了三面) | 包内相对路径,如 textures/lamp.png;也可以直接写内置层名,如 stone。 |
top / side / bottom | 回退到 texture | 分别指定三面纹理。 |
solid | true | 是否参与碰撞和射线。 |
opaque | true | 是否走不透明网格;透明方块请用 cutout,不要设 false。 |
cutout | false | alpha < 0.5 的像素在着色器里丢弃;树叶就是这种。 |
hardness | 1.0 | 生存模式挖掘秒数。 |
drop | 自己 | 掉落方块 ID;必须已经注册(内置或更早注册的模组方块)。 |
模组方块纹理层名是 <mod>_<文件名去扩展名>;资源包可以按这个名字覆盖。方块都是立方体,没有自定义模型、朝向或发光。
事件
| 注册 | 回调参数 | 说明 |
|---|---|---|
on_block_break(fn) | e | 方块被挖掉前触发;e.cancelled = true 取消。 |
on_block_place(fn) | e | 方块放下前触发;e.cancelled = true 取消。 |
on_tick(fn) | dt | 大约每 0.05 秒一次,dt 是累计秒数;只在单机 / 主机执行。 |
on_player_join(fn) | e | 玩家加入时触发;e.id、e.player。 |
on_player_leave(fn) | e | 玩家离开时触发;e.id、e.player。 |
方块事件的字段:x、y、z、block(命名空间 ID)、player(名字)、player_id(数字)、cancelled(初始 false)。
事件回调里抛错会打印 traceback 并继续,不会让主机崩掉。事件按模组加载顺序执行;一个模组取消后,后面的模组和默认行为都不再执行。
注册命令
cael.register_command(name, fn):name 不区分大小写,开头的 / 会被去掉;回调收到 args(命令名之后的原始参数)和 player(本地玩家名),返回字符串会显示在消息记录里。
cael.register_command("ruby", function(args, player)
local count = math.max(1, math.min(tonumber(args) or 8, 64))
if cael.give("ruby:block", count) then
return "gave " .. count .. " ruby blocks to " .. player
end
return "inventory full"
end)
命令只在单机 / 主机执行,Tab 不会补全模组命令,/help 也不列。别用内置命令名(help、tp、give 等),主循环会先匹配内置命令。
数据目录
cael.data_dir() 返回 mods\<id>\ 并创建它。data_read(name) / data_write(name, text) 只允许读写这个目录下的相对路径,拒绝 ..、绝对路径和盘符;可以带子目录,写入时会自动建父目录。
数据是本地的,不参与联机同步;每个玩家 / 主机各有一份。JSON、YAML 都只是文本,引擎不解析,格式由模组自己定。
local raw = cael.data_read("state.json")
local count = raw and tonumber(string.match(raw, "(%d+)")) or 0
count = count + 1
cael.data_write("state.json", '{"count":' .. count .. '}')
联机与签名
模组脚本只在单机和主机执行。客户端也加载模组的方块注册表和纹理(这样能显示模组方块),但收到 Welcome 后关闭执行。
连接时 Hello 会带模组签名:模组名 + 每个 main.lua 的哈希。两端不一致会直接拒绝。签名只看名字和脚本内容,贴图不同不会触发不一致;但客户端没有对应纹理时会回退到程序化纹理。
联机双方要装同一套模组包。模组注册的方块 ID 会写进存档和网络协议,缺模组时旧存档里的未知方块会退化成空气。
完整示例
安装包里的 examples\mods\ 有两个可运行的例子,也可以直接下载:hello.mod.caelc、ruby.mod.caelc。复制到 mods\ 后重启。
hello.mod.caelc
cael.log("hello zip demo loaded")
cael.register_block {
id = "hello:lamp",
name = "Hello Lamp",
texture = "textures/lamp.png",
hardness = 0.4,
}
cael.register_command("hello", function(args, player)
cael.message("Hello, " .. player .. "!")
return "hello from a zip mod"
end)
on_player_join(function(e)
cael.message("Welcome, " .. e.player .. "!")
end)
ruby.mod.caelc
local rubies = 0
local raw = cael.data_read("state.json")
if raw then
local count = string.match(raw, '"rubies"%s*:%s*(%d+)')
if count then rubies = tonumber(count) end
end
local function save()
cael.data_write("state.json", '{"rubies":' .. rubies .. '}')
end
cael.register_block {
id = "ruby:block",
name = "Ruby Block",
texture = "textures/ruby_block.png",
hardness = 1.5,
}
cael.register_block {
id = "ruby:ore",
name = "Ruby Ore",
texture = "textures/ruby_ore.png",
hardness = 2.2,
drop = "ruby:block",
}
cael.register_command("ruby", function(args, player)
local count = math.max(1, math.min(tonumber(args) or 8, 64))
if cael.give("ruby:block", count) then
return "gave " .. count .. " ruby blocks to " .. player
end
return "inventory full"
end)
cael.register_command("rubystats", function()
return "rubies found: " .. rubies
end)
on_block_break(function(e)
if e.block == "caelcraft:stone" and math.random() < 0.15 then
rubies = rubies + 1
save()
cael.give("ruby:block", 1)
cael.message("The stone hid a ruby! (total " .. rubies .. ")")
end
end)
打包与测试
把 main.lua 和纹理放进一个目录,压缩成 zip,再把扩展名改成 .mod.caelc。zip 根目录可以是 main.lua,也可以只有一个顶层文件夹;不要套两层。
游戏自带 --modtest:它会生成一个临时 .mod.caelc,验证加载、方块注册、命令、取消破坏、数据读写和死循环预算,跑完自动删除。改模组运行时或 API 后先跑这个。
build\CaelCraft.exe --modtest
启动时控制台会打印每个模组的加载结果和 cael.log 输出;游戏里的消息记录只显示 cael.message。如果模组没加载,先看 mods\.cache\ 有没有解压、main.lua 在不在正确的层级。
常见问题
- 模组没加载:确认是
.mod.caelc/.zip文件,不是文件夹;mods\第一层,重启游戏。 - 纹理不显示:确认路径相对于模组根目录,文件名大小写一致;PNG 能被读取。
- 命令没反应:模组命令只在单机 / 主机执行;客户端不会跑脚本,也不会补全模组命令。
- 联机被拒绝:两端模组名或
main.lua内容不一致;贴图不同不影响签名。 - 方块变空气:旧存档里的模组方块在缺少模组时会被读档逻辑清掉;装回模组再读。
- 死循环:指令预算会中断回调并打日志;检查
while条件和on_tick里的逻辑。
更多见 故障排查 / 模组。