简介
把你自己的建筑物、道具、帕鲁、音效、效果、技能和菜单加进 Palworld
PalForge 是 Palworld 的基础模组。你可以用它添加自己的建筑物、音乐、效果、帕鲁、道具、模型、 技能和菜单,也可以把它们组合成全新的内容,而且马上就能在游戏里看到结果。把想做的东西写进一个 简短的 Lua 文件,PalForge 就会把它放进世界里。
读完本页你可以做到
- 把自己的建筑物、道具、帕鲁、音效或菜单加进 Palworld。
- 在放下建筑物、捡起道具或捕捉帕鲁时运行自己的代码。
- 让放下的每个建筑物记住自己的数据,下次进游戏时还在。
- 找到你想做的东西对应的页面。
它面向的是什么
PalForge targets SINGLE-PLAYER Palworld. Dedicated servers and co-op guests are not supported and are not tested: there is no replication layer, and the item, spawn and event routes are all client-authoritative. A pack may appear to work for the host and do nothing for anyone else.
(PalForge 面向的是单人游戏的 Palworld。专用服务器和联机客人不受支持,也没有经过测试:这里没有 任何同步层,道具、生成和事件这几条通路全都是客户端权威的。一个内容包可能在房主那边看起来是好的, 在别人那边什么都不发生。)
这是一个有意划定的范围,不是疏忽,而且原因是结构性的,不是偶然的。core/spawn 是从本地玩家
控制器上造出 PalCheatManager 的。花掉一个道具走的是本地生成的
APalWeaponBase::RequestConsumeItem。core/event 里每一个事件来源都是本地的 RegisterHook。这些
东西从来没有对着专用服务器或联机客人跑过,也没有理由指望它们能正常工作。真要支持服务器,那是一个
新的子系统 —— 每次写入都要做权限检查、要有一层 RPC 接缝、要决定内容包的状态归谁所有 —— 而不是对
其中某一项的修补。同样这句话每次启动都会打印在日志里,所以没人必须先在这里找到它。
PalForge 声称能用的每一项能力,都是对着 Palworld v1.0.2.101103 测出来的:要么在真实存档里看到
过,要么是从安装好的二进制自己的声明里读出来的。这个版本号写在 Scripts/palforge/env.lua 的
gameBuild 里,也出现在启动那一行,因为一个不说明自己是在哪个版本上测的框架,等于没给用户任何办法
去区分 “PalForge 坏了” 和 “游戏动了”。这棵树已经在这个缝隙上吃过一次亏:AddItem 实际声明的是
五个参数,而头文件导出里只有四个,因为那份导出比装着的二进制早了整整一个补丁。启动时 PalForge 也会
问正在运行的游戏它是什么版本,把答案记进 env.gameBuildLive。两者对不上时它会点名两边,说一次,
就一行。游戏答不上来时 —— 在模组加载那一刻这很正常,所以这次读取会在第一次世界加载时重试一次 ——
启动那一行写的是 unknown,不是一个猜测。
第一个建筑物
这就是一个完整的内容文件。它给游戏自带的篝火加上新行为:右键点一下,你会得到 5 个木材。
require("palforge.api")
Building{
id = "CampFire",
events = {
onRightClick = function(self, ctx)
Item.get("Wood"):give(5)
end,
},
}这里有三件事在发生。Building{ ... } 声明一个建筑物。"CampFire" 是游戏自己的建筑 id,所以
改变的就是你早就认识的那个篝火。events 是写自己代码的地方,onRightClick 在玩家每次交互时
运行。
把这个文件放在 PalForge 自己的脚本旁边,然后从入口文件加载它。具体的文件夹和加载用的那一行, 快速开始 里有。
你能做出什么
八类东西,每类一个模块。调用模块就能做出一个。
| 你想做的 | 你要声明的内容 | 页面 |
|---|---|---|
| 可放置的建筑物 | 外观、占地格数、要记住的数据,以及 放下 / 使用 / 移除 / 每隔几秒 时运行的代码。 | /docs/api/building |
| 背包里的道具 | 名称、分类、堆叠上限、配方,以及 捡起 / 使用 时运行的代码。 | /docs/api/item |
| 一种生物 | 模型、颜色、技能的 id,以及 出现 / 受伤 / 死亡 / 被捕捉 时运行的代码。 | /docs/api/pal |
| 一个能力 | 主动还是被动、属性、威力,以及由 Lua 计时的冷却。 | /docs/api/skill |
| 一个限时状态 | 持续多久、多久触发一次、怎么叠加、结束时做什么。 | /docs/api/effect |
| 一段音效或音乐 | 一个可以播放的游戏内声音。Audio.bgm 和 Audio.se 会替你固定 kind。 | /docs/api/audio |
| 一个模型 | 模型资源,以及怎么给它上色。帕鲁或建筑物会穿上它。 | /docs/api/mesh |
| 一个面板或按钮 | 用 Palworld 自带的界面组件搭出的控件,带 render 和 update。 | /docs/api/ui |
Player 是唯一一个不用来创建东西的模块。它告诉你玩家在哪里:Player.character()、
Player.coordinate() 和 Player.coordinateOffset(dx, dy, dz) —
/docs/api/player。
编写一个定义
每个模块的用法都一样。调用它来创建,用 get 和 get_all 找已经存在的东西。
local boss = Pal{ id = "example:Boss", name = "Boss" } -- CALL the module to define
Pal.get("ChickenPal") -- an existing one, by id
Pal.get_all() -- every registered oneX{ ... } 是 Lua 里 X({ ... }) 的简写 —— 花括号本身就是参数列表,这是 Lua 里最接近具名参数的
写法。返回的是一个 句柄:一个带着该领域各种操作的对象,所以 :spawn、:give、:play、
:apply 直接就能用。
下面是一个内容多一点的定义:
local api = require("palforge.api")
Item{
id = "Flint",
name = "Flint",
category = "material",
maxStack = 999,
events = {
onObtain = function(item, ctx)
Audio.get("AKE_General_Explosion"):play()
end,
},
}Flint 本来就在游戏里。这个定义给这个 id 加上名称、分类、堆叠上限,以及玩家捡起它时播放的
声音。
require palforge.api 之后,你可以直接用 Pal、Item、Building、Skill、Effect、
Audio、Mesh、UI 和 Player 这些名字,同时它也会返回一张带着同样名字的表。两种写法都能用:
require("palforge.api")
Pal.get("ChickenPal"):spawn(Player.coordinate()) -- the pal arrives a few seconds later
Item.get("Wood"):give(10)这些名字只属于你的模组。它们不会和游戏本体冲突,也不会和别人的模组冲突。
除了调用模块和 get / get_all,还有三个额外的东西。Audio.bgm 和 Audio.se 会替你固定
kind,省得自己写;Effect.activeOn(target) 会返回目标身上当前所有效果的 id。除 Player 以外
的每个模块还提供 X.Class,也就是定义所基于的基类 —— 只有要派生子类时才用得上。
定义一次,使用很多次
X{ ... } 用来创建,X.get(id) 用来查已经存在的东西。处理函数每次事件都会运行,所以在里面要
用 get:
Pal{
id = "ChickenPal",
events = {
onCaptured = function(pal, ctx)
Audio.get("AKE_Arena_Victory_01"):play() -- a play
-- Audio.bgm{ id = "AKE_Arena_Victory_01" } -- would RE-DEFINE on every capture
end,
},
}处理函数会收到什么
第一个参数就是事件发生在谁身上。对帕鲁、道具、技能或效果来说,它是对应的句柄,所以该领域的
操作直接就在手边。对建筑物来说,它是玩家碰到的那一个建筑物:self.actor 是放置出来的 actor,
self.pos 是它的位置,self.state 是你自己的表,self:save() 把这张表写进存档。
Item{
id = "Berries",
events = {
onUse = function(item, ctx) -- item is the Item.Handle
item:give(1) -- so :give is in scope
end,
},
}
Building{
id = "CampFire",
state = { lit = 0 },
events = {
onRightClick = function(self, ctx) -- self is the live instance
self.state.lit = self.state.lit + 1
self:save()
end,
},
}后面的参数由各个领域决定:(pal, ctx)、(item, ctx)、(skill, owner, ctx)、
(effect, target, ctx)、(instance, ctx)。ctx 就是一张普通的表,内容随事件而变 ——
ctx.actor、ctx.itemId、ctx.count 等等。
把一个定义放进另一个里
需要定义的字段,既可以给它一张普通的表,也可以给它一个你已经创建好的定义。两者的检查方式完全 一样:
-- inline
Pal{ id = "example:Boss", mesh = { model = "/Game/Pal/Model/Character/Monster/ChickenPal/SK_ChickenPal.SK_ChickenPal" } }
-- named, declared once and reused
local body = Mesh{
id = "example:BossBody",
model = "/Game/Pal/Model/Character/Monster/ChickenPal/SK_ChickenPal.SK_ChickenPal",
}
Pal{ id = "example:Boss", mesh = body }
Pal{ id = "example:Add", mesh = Mesh.get("example:BossBody") }给你的内容起名字
带冒号的 id,比如 "example:Potion",是你自己的内容。PalForge 会把它转成游戏数据里的行名
example_Potion。不带冒号的 id 是游戏里已经有的 id,比如 Wood、ChickenPal 或 PalBoxV2。
带命名空间的 id,冒号两边都只能是字母、数字或下划线:
local om = require("palforge.core.object_manager")
om.resolve("example:Potion") --> "example_Potion"
om.resolve("Wood") --> "Wood" (literal game id, passed through)
om.resolve("bad pack:Potion") --> nil, "invalid pack id 'bad pack' (letters/digits/_ only)"同一条规则在定义的那一刻就会执行,而不是等到有人来要解析后的名字。
Building{ id = "my-pack:Bench" }(带连字符)会在你写下它的那一行直接报错并点明规则,而不会注册
一个永远够不到游戏里任何一行的 id。
你可以给游戏已有的 id 加上新行为和新的元数据。但要往游戏自己的表里加一行全新的数据 —— 一个新
道具、一个新生物、一个新建筑物 —— 就需要别的工具,因为 Lua 写不了新的行。所以
Item{ id = "example:Potion" } 能正常注册,也能在你的代码里用,但在名为 example_Potion 的行
存在之前,游戏里的任何背包都不会显示它。在那之前,请在游戏已有的 id 上做扩展。
字段写错的时候
每次调用都会对照该领域接受的形状做检查,有问题就当场中止 —— 绝不会只成功一半。未知字段(会附上
你可能想写的名字)、缺少必填字段、类型不对、取值不在允许的列表里、arrayOf 或 mapOf 的元素
不合法、check 没通过,都会报错。每条消息都以 PalForge: 开头,并写明领域和字段:
PalForge: Pal: unknown field "displayName" (did you mean "name"?). Valid fields: id, name, description, skills, mesh, material, color, texture, icon, events, data
PalForge: Pal: field "id" is required (pal id: a game CharacterID ("ChickenPal") or "pack:name")
PalForge: Pal: field "mesh" (Mesh.Spec): field "model" is required (USkeletalMesh / UStaticMesh asset path)
PalForge: Item: field "category" must be one of { "material", "consumable", "equipment", "ammo", "ingredient", "other" }, got "food"想知道某个领域接受什么,不用离开游戏,运行时问一下就行:
local schema = require("palforge.core.schema")
print(schema.help("Pal.Spec")) -- every field, its type, default and meaning
schema.get("Pal.Spec").fields -- the same as a table, for tooling
schema.all() -- every declared spec, in declaration order在编辑器里,同样的信息来自 Scripts/palforge/types.lua,它由 lua5.4 tools/gen-types.lua 生成。
这个文件里只有注解,运行时没有任何东西 require 它;只要 LuaLS 能在工作区里看到它,
Pal{ ... } 就会补全每个字段并带上说明。编辑器设置 讲了做法。
游戏会替你运行什么
建筑物的处理函数不用你自己调用。你在游戏里放下一个建筑物,PalForge 就会注意到,并运行你的代码。
自动到什么程度,各个领域不一样,证据的强度也不一样。每个 api 模块的文件头都写明了它的哪些事件是 活的、以及是什么把它们测出来的:
- Building —— 最完整的一个。
onPlace、onLoad、onRightClick、onRemove、onTick、onWorldReady和onWorldLeft都会在活着的建筑物上运行,每一个建筑物都是一个真正的实例,带着 自己持久化的状态。onBuild也会运行,就在建造完成的那一刻;它到达时建筑物还不存在,所以传给它 的是定义,而不是活着的建筑物。晚一轮才加载进来的建筑物会错过onWorldReady,所以每个建筑物自己 的启动处理要写在onLoad里。永远不触发的是onLeftClick和onBreak这两个,而且那是已经定 下来的结论,不是待办 —— 见下面的提示框。 - Item —— 四个都会运行。
onObtain和onUse来自背包与使用路径;onCraft来自两个地图对象 模型的OnFinishWorkInServer;onDiscard来自RequestDrop_ToServer/RequestDispose_ToServer。后两个是在真实的制作台上制作、从真实的格子里丢弃时实地看到的。:give、:take和:count事后都会把背包再读一遍,所以它们返回的是实测,不是期望。:recipeOf返回你声明的配方,没有就返回游戏自己关于这个 id 的那一行 —— 2026-08-02 在一个存档里确认它跑起来 了,Arrow返回的是Arrow x10, work = 1000.0, from { Stone x2, Wood x2 }。而且道具还能 喂饱和治疗:restores = { satiety = 20, hpRate = 0.25 }会在使用时把两样都写进去,这是在一个 活着的角色身上实测过的。 - Pal —— 五个都会运行。
onCaptured、onDamaged和onDeath来自已经确认的游戏调用。onSpawned挂在PalNPC:OnCompletedInitParam和PalPlayerCharacter:OnCompleteInitializeParameter上,而且已经实测到它会为一只真正新出现的帕鲁 触发:2026-08-02 共 27 次触发,其中 17 次离世界加载八竿子打不着。它在加载期间同样会触发,所以还是 请把这个处理函数写成跑两次也没问题。onTick由一个遍历世界里所有帕鲁的扫描驱动,大约每三秒一次。声明的mesh会在同一个生成通道上自动装上去:你不需要自己调用renderOn。 - Skill —— 四个里有三个会运行。
onActivate由PalActionBase:OnBeginAction运来,是在真实 战斗里看到的;onEquip和onUnequip来自AddPassiveSkill/RemovePassiveSkill。不运行的是onHit,通往它的唯一路子是手动调用:hit(target)。:activate的冷却由 Lua 计时,冷却期间返回false。 - Effect ——
onApply、onTick、onStack和onExpire都会运行。时间由 PalForge 自己按 半秒一次的心跳来数。定义里写上nativeStatus,效果运行期间就会给目标点亮游戏自带的状态异常; 这条路子在加载好的存档里被看着跑通过,游戏那边把状态异常读回来是“有”,随后又读回来是“没有”。 状态异常按游戏自己的规则运行,PalForge 不控制它的强度。 - Audio —— 没有生命周期事件;声音由你自己播放。只给一个名字就够了:1957 条的 AkAudioEvent
目录会把它解析成真正能播出声的资源路径。
Audio.Handle:stop(actor)不是按单个声音来的 —— 它会把 那个 actor 上正在播放的一切都停掉 ——:setVolume出于同样的原因也是按 actor 来的。 - Mesh —— 没有生命周期事件。原版
/Game/...的静态和骨骼网格能用,交给引擎之前会先做类检查;.obj几何体能从磁盘读进来,自带的 PNG 也能导入并按路径缓存 —— 这一条在 2026-08-02 的一次加载 好的存档里被亲眼看着跑通了,改色也一样:一个箱子先变红,再变绿,然后变蓝。 - UI —— 没有生命周期事件,但原生的“界面变了”信号确实存在,而且在 2026-08-02 测到了:
Palworld 建起或拆掉一个画面时,
CommonActivatableWidget:ActivateWidget、PalHUDService:Push和PalHUDService:Close都会触发。:autoRefresh(ms)三个都搭,底下还垫着心跳轮询作为下限。用 Palworld 自带的 UMG 组件搭出控件、并挂进游戏本体的游戏内 UI 根节点,这条路是实地确认过的。 - Player ——
Player.character()、Player.coordinate()和Player.coordinateOffset(dx, dy, dz)。没有事件;它回答玩家在哪里。
这个版本会直接拒绝的一件事
你自己的声音文件没法播放,而且 Audio.Spec.soundFile 现在是定义时的硬错误。 Wwise 那一半已经
有了定论:这个构建上的 AkExternalMediaAsset 和 AkMediaAsset 一个函数都没声明,进程里也没有它们
的任何实例,所以根本不存在外部媒体这条路。引擎原生那一半同样没有导入器 —— USoundWave 没声明 ——
而能不能从 Lua 自己造一个,正是未决项 audio-custom-file-loader 剩下的全部问题。这个字段仍然保留在
声明里,好让错误消息能点它的名。能播放的只有游戏里本来就有的声音,一共 1957 个 —— 目录见
Audio。
本页其余内容今天都能用,只有一个习惯值得养成:碰到游戏的调用返回的是它实测到的结果,所以别假定, 去看那个布尔值。
有三样东西可以写在定义里,你的代码不会因此出错,但没有任何东西会运行它们。把逻辑放到真正会运行的 地方:
- Building 的
onLeftClick和onBreak—— 这是以否定的方式定下来的,不是还没做。所有可能拥有这类 钩子的类的完整函数列表都被读过了,并不存在:PalBuildObject上没有任何点击项,而“摧毁”只以委托 字段的形式存在,RegisterHook没法按路径去挂它。请改用onRightClick,消失则用onRemove,它会 带着reason = "missing"。 - Skill 的
onHit—— 这也是以否定的方式定下来的,而且是结构上的:这个构建声明的三个伤害结构体里, 没有一个字段带着技能 id,所以根本没有东西可以把一次命中关联上去。请从你自己的代码里调用:hit(target),比如在帕鲁的处理函数里、建筑物的onRightClick里,或者一个按键绑定里。 - 没有东西会自动替你刷新界面元素。状态变了就自己调用
:refresh(),或者用:autoRefresh(ms)—— 它搭乘 Palworld 自己的重建信号,并回落到心跳上。
一个更大的例子
一个文件,创建了一个网格、一个状态、一个能力和一个建筑物,并把它们连在一起。
local api = require("palforge.api")
-- 1. A named mesh, declared once so anything can wear it.
local BenchBody = Mesh{
id = "example:BenchBody",
kind = "static",
model = "/Game/Pal/Model/Prop/Architecture/WorkBenchPrimitive/SM_WorkBenchPrimitive.SM_WorkBenchPrimitive",
}
-- 2. A timed status. Its schedule is driven by the shared heartbeat, in Lua.
local WellFed = Effect{
id = "example:WellFed",
name = "Well Fed",
description = "Hands out a berry every ten seconds.",
duration = 60.0,
interval = 10.0,
events = {
onApply = function(effect, target, ctx)
Audio.get("AKE_BGM_Title"):play(target)
end,
onTick = function(effect, target, ctx)
Item.get("Berries"):give(1)
end,
onExpire = function(effect, target, ctx)
Audio.get("AKE_BGM_Title"):stop(target)
end,
},
}
-- 3. A skill. onActivate has a live source in real combat; this one is fired by hand
-- from the building below, and the cooldown is enforced in Lua by :activate.
local Whistle = Skill{
id = "example:Whistle",
name = "Whistle",
kind = "active",
cooldown = 30.0,
events = {
onActivate = function(skill, owner, ctx)
Pal.get("ChickenPal"):spawn(Player.coordinateOffset(200, 0, 0))
end,
},
}
-- 4. The structure. "CampFire" is an EXISTING game build id; this gives it a mesh,
-- per-structure persisted state and a lifecycle.
Building{
id = "CampFire",
name = "Supply Bench",
gridCm = 100,
tickInterval = 20, -- onTick every 20 heartbeats, so every 10 seconds
mesh = BenchBody,
state = { uses = 0 },
events = {
onPlace = function(self, ctx)
self.state.uses = 0
self:save()
end,
onLoad = function(self, ctx)
-- ctx.reconstructed is true when this came back from a saved record
end,
onRightClick = function(self, ctx)
self.state.uses = self.state.uses + 1
self:save()
Item.get("Wood"):give(5)
WellFed:apply(Player.character())
Whistle:activate(ctx.player) -- returns false while cooling down
end,
onTick = function(self, ctx)
if self.state.uses >= 10 then
Item.get("Berries"):give(1)
self.state.uses = 0
self:save()
end
end,
onRemove = function(self, ctx)
WellFed:remove(Player.character())
end,
},
}每一部分都做了什么:
BenchBody以("mesh", "example:BenchBody")注册,并按值嵌进建筑物里。这里必须写明kind: 具名的Mesh{ ... }会单独按Mesh.Spec检查,而Mesh.Spec的kind默认是"skeletal", 这个填好的值会一路跟着它走。"static"这个默认值属于Building.Spec.Mesh,只对直接写在建筑物 里的网格生效。WellFed:apply(target)会开始一次生效:立刻运行onApply,之后每interval秒运行一次onTick,过了duration或调用:remove()时运行onExpire。Whistle:activate(owner)会立刻运行onActivate,除非 30 秒的冷却挡住了它,这时它返回false。tickInterval = 20配上半秒一次的心跳,意味着onTick每十秒运行一次。self:save()把建筑物的state表写进按世界分开的状态文件。onRightClick里的ctx.player是发起交互的角色,ctx.actor是建筑物的 actor。Audio.Handle:stop(actor)不是按单个声音来的。它会把那个 actor 上正在播放的一切都停掉。Item.Handle:give返回的是背包实测到的变化,所以它给出false就意味着什么都没到位。:spawn那一行会在运行之后几秒把帕鲁放进世界,而不是立刻。
CampFire、Wood、Berries 和 ChickenPal 都是 Scripts/palforge/native/*.lua 里真实存在的
id,AKE_BGM_Title 是 palforge.native.audio 定义的声音。注册以 (type, id) 为键,所以定义一个
PalForge 自带的 id,会替换掉原来的注册。
接下来读什么
快速开始
安装 PalForge,摆好一个内容包,让一个定义在游戏里跑起来。
你的第一个内容包
从空文件夹到能玩的东西,一步步做出一个完整的内容包。
定义
规格、严格检查、句柄、嵌套,以及 id 模型的细节。
生命周期
通道、心跳,以及到底是什么触发了你的每个处理函数。
保存的状态
模组保存的东西住在哪里,以及卸载模组会对存档造成什么。
小结
- 添加内容只要写一个 Lua 文件。
Building{ ... }、Item{ ... }、Pal{ ... }和其他模块用法都 一样,而且它们唯一必填的字段都是id。 - 带冒号的 id 是你自己的内容,不带冒号的是游戏里已经有的东西。在已有的 id 上做扩展,是最快在 游戏里看到效果的办法。
- 想运行的代码写在
events里。第一个参数是事件发生在谁身上:建筑物是活着的那一个,其他都是 那个定义的句柄。 - 只要你调用
self:save(),建筑物就会记住你放进state的东西。 - 字段名写错会中止调用,并给出一条以
PalForge:开头的消息,告诉你正确的名字;id 的形状不对, 也会在你写下它的那一行停住。 - 不会触发的处理函数只有三个:以否定方式定下来的 Building
onLeftClick和onBreak,以及 Skill 的onHit。本页能声明的其余每一个都有活的来源。 - PalForge 只面向单人游戏,而且它声称的每一项能力都是对着 Palworld v1.0.2.101103 测出来的。
接下来读 快速开始,安装 PalForge,让你写的文件被加载进来。