跳到主要内容

模组

模组是一个 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
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>。

模组 API
函数返回 / 说明
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 起追加;注册发生在启动时,运行中不能新增。

register_block 字段
字段默认说明
id必填不带冒号时自动加上 <mod>:。
nameid 的后半段显示名,用于消息和调试。
texture必填(除非给了三面)包内相对路径,如 textures/lamp.png;也可以直接写内置层名,如 stone。
top / side / bottom回退到 texture分别指定三面纹理。
solidtrue是否参与碰撞和射线。
opaquetrue是否走不透明网格;透明方块请用 cutout,不要设 false。
cutoutfalsealpha < 0.5 的像素在着色器里丢弃;树叶就是这种。
hardness1.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(本地玩家名),返回字符串会显示在消息记录里。

命令 / ruby/main.lua
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 都只是文本,引擎不解析,格式由模组自己定。

持久化 / state.json
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​

main.lua / 注册方块、命令和加入提示
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​

main.lua / 方块、掉落和持久化
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 后先跑这个。

终端 / 项目根 / modtest
build\CaelCraft.exe --modtest

启动时控制台会打印每个模组的加载结果和 cael.log 输出;游戏里的消息记录只显示 cael.message。如果模组没加载,先看 mods\.cache\ 有没有解压、main.lua 在不在正确的层级。

常见问题​

  • 模组没加载:确认是 .mod.caelc / .zip 文件,不是文件夹;mods\ 第一层,重启游戏。
  • 纹理不显示:确认路径相对于模组根目录,文件名大小写一致;PNG 能被读取。
  • 命令没反应:模组命令只在单机 / 主机执行;客户端不会跑脚本,也不会补全模组命令。
  • 联机被拒绝:两端模组名或 main.lua 内容不一致;贴图不同不影响签名。
  • 方块变空气:旧存档里的模组方块在缺少模组时会被读档逻辑清掉;装回模组再读。
  • 死循环:指令预算会中断回调并打日志;检查 while 条件和 on_tick 里的逻辑。

更多见 故障排查 / 模组。