PalForge

简介

把你自己的建筑物、道具、帕鲁、音效、效果、技能和菜单加进 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::RequestConsumeItemcore/event 里每一个事件来源都是本地的 RegisterHook。这些 东西从来没有对着专用服务器或联机客人跑过,也没有理由指望它们能正常工作。真要支持服务器,那是一个 新的子系统 —— 每次写入都要做权限检查、要有一层 RPC 接缝、要决定内容包的状态归谁所有 —— 而不是对 其中某一项的修补。同样这句话每次启动都会打印在日志里,所以没人必须先在这里找到它。

PalForge 声称能用的每一项能力,都是对着 Palworld v1.0.2.101103 测出来的:要么在真实存档里看到 过,要么是从安装好的二进制自己的声明里读出来的。这个版本号写在 Scripts/palforge/env.luagameBuild 里,也出现在启动那一行,因为一个不说明自己是在哪个版本上测的框架,等于没给用户任何办法 去区分 “PalForge 坏了”“游戏动了”。这棵树已经在这个缝隙上吃过一次亏:AddItem 实际声明的是 五个参数,而头文件导出里只有四个,因为那份导出比装着的二进制早了整整一个补丁。启动时 PalForge 也会 问正在运行的游戏它是什么版本,把答案记进 env.gameBuildLive。两者对不上时它会点名两边,说一次, 就一行。游戏答不上来时 —— 在模组加载那一刻这很正常,所以这次读取会在第一次世界加载时重试一次 —— 启动那一行写的是 unknown,不是一个猜测。

第一个建筑物

这就是一个完整的内容文件。它给游戏自带的篝火加上新行为:右键点一下,你会得到 5 个木材。

Scripts/content/campfire.lua
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.bgmAudio.se 会替你固定 kind/docs/api/audio
一个模型模型资源,以及怎么给它上色。帕鲁或建筑物会穿上它。/docs/api/mesh
一个面板或按钮用 Palworld 自带的界面组件搭出的控件,带 renderupdate/docs/api/ui

Player 是唯一一个不用来创建东西的模块。它告诉你玩家在哪里:Player.character()Player.coordinate()Player.coordinateOffset(dx, dy, dz)/docs/api/player

编写一个定义

每个模块的用法都一样。调用它来创建,用 getget_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 one

X{ ... } 是 Lua 里 X({ ... }) 的简写 —— 花括号本身就是参数列表,这是 Lua 里最接近具名参数的 写法。返回的是一个 句柄:一个带着该领域各种操作的对象,所以 :spawn:give:play:apply 直接就能用。

下面是一个内容多一点的定义:

Scripts/content/flint.lua
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 之后,你可以直接用 PalItemBuildingSkillEffectAudioMeshUIPlayer 这些名字,同时它也会返回一张带着同样名字的表。两种写法都能用:

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.bgmAudio.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.actorctx.itemIdctx.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,比如 WoodChickenPalPalBoxV2。 带命名空间的 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 上做扩展。

字段写错的时候

每次调用都会对照该领域接受的形状做检查,有问题就当场中止 —— 绝不会只成功一半。未知字段(会附上 你可能想写的名字)、缺少必填字段、类型不对、取值不在允许的列表里、arrayOfmapOf 的元素 不合法、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 —— 最完整的一个。onPlaceonLoadonRightClickonRemoveonTickonWorldReadyonWorldLeft 都会在活着的建筑物上运行,每一个建筑物都是一个真正的实例,带着 自己持久化的状态。onBuild 也会运行,就在建造完成的那一刻;它到达时建筑物还不存在,所以传给它 的是定义,而不是活着的建筑物。晚一轮才加载进来的建筑物会错过 onWorldReady,所以每个建筑物自己 的启动处理要写在 onLoad 里。永远不触发的是 onLeftClickonBreak 这两个,而且那是已经定 下来的结论,不是待办 —— 见下面的提示框。
  • Item —— 四个都会运行。onObtainonUse 来自背包与使用路径;onCraft 来自两个地图对象 模型的 OnFinishWorkInServeronDiscard 来自 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 —— 五个都会运行。onCapturedonDamagedonDeath 来自已经确认的游戏调用。 onSpawned 挂在 PalNPC:OnCompletedInitParamPalPlayerCharacter:OnCompleteInitializeParameter 上,而且已经实测到它会为一只真正新出现的帕鲁 触发:2026-08-02 共 27 次触发,其中 17 次离世界加载八竿子打不着。它在加载期间同样会触发,所以还是 请把这个处理函数写成跑两次也没问题。onTick 由一个遍历世界里所有帕鲁的扫描驱动,大约每三秒一次。声明的 mesh 会在同一个生成通道上自动装上去:你不需要自己调用 renderOn
  • Skill —— 四个里有三个会运行。onActivatePalActionBase:OnBeginAction 运来,是在真实 战斗里看到的;onEquiponUnequip 来自 AddPassiveSkill / RemovePassiveSkill。不运行的是 onHit,通往它的唯一路子是手动调用 :hit(target):activate 的冷却由 Lua 计时,冷却期间返回 false
  • Effect —— onApplyonTickonStackonExpire 都会运行。时间由 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:ActivateWidgetPalHUDService:PushPalHUDService:Close 都会触发。:autoRefresh(ms) 三个都搭,底下还垫着心跳轮询作为下限。用 Palworld 自带的 UMG 组件搭出控件、并挂进游戏本体的游戏内 UI 根节点,这条路是实地确认过的。
  • Player —— Player.character()Player.coordinate()Player.coordinateOffset(dx, dy, dz)。没有事件;它回答玩家在哪里。

这个版本会直接拒绝的一件事

你自己的声音文件没法播放,而且 Audio.Spec.soundFile 现在是定义时的硬错误。 Wwise 那一半已经 有了定论:这个构建上的 AkExternalMediaAssetAkMediaAsset 一个函数都没声明,进程里也没有它们 的任何实例,所以根本不存在外部媒体这条路。引擎原生那一半同样没有导入器 —— USoundWave 没声明 —— 而能不能从 Lua 自己造一个,正是未决项 audio-custom-file-loader 剩下的全部问题。这个字段仍然保留在 声明里,好让错误消息能点它的名。能播放的只有游戏里本来就有的声音,一共 1957 个 —— 目录见 Audio

本页其余内容今天都能用,只有一个习惯值得养成:碰到游戏的调用返回的是它实测到的结果,所以别假定, 去看那个布尔值。

有三样东西可以写在定义里,你的代码不会因此出错,但没有任何东西会运行它们。把逻辑放到真正会运行的 地方:

  • Building 的 onLeftClickonBreak —— 这是以否定的方式定下来的,不是还没做。所有可能拥有这类 钩子的类的完整函数列表都被读过了,并不存在:PalBuildObject 上没有任何点击项,而“摧毁”只以委托 字段的形式存在,RegisterHook 没法按路径去挂它。请改用 onRightClick,消失则用 onRemove,它会 带着 reason = "missing"
  • Skill 的 onHit —— 这也是以否定的方式定下来的,而且是结构上的:这个构建声明的三个伤害结构体里, 没有一个字段带着技能 id,所以根本没有东西可以把一次命中关联上去。请从你自己的代码里调用 :hit(target),比如在帕鲁的处理函数里、建筑物的 onRightClick 里,或者一个按键绑定里。
  • 没有东西会自动替你刷新界面元素。状态变了就自己调用 :refresh(),或者用 :autoRefresh(ms) —— 它搭乘 Palworld 自己的重建信号,并回落到心跳上。

一个更大的例子

一个文件,创建了一个网格、一个状态、一个能力和一个建筑物,并把它们连在一起。

Scripts/content/supply_bench.lua
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.Speckind 默认是 "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 那一行会在运行之后几秒把帕鲁放进世界,而不是立刻。

CampFireWoodBerriesChickenPal 都是 Scripts/palforge/native/*.lua 里真实存在的 id,AKE_BGM_Titlepalforge.native.audio 定义的声音。注册以 (type, id) 为键,所以定义一个 PalForge 自带的 id,会替换掉原来的注册。

接下来读什么

小结

  • 添加内容只要写一个 Lua 文件。Building{ ... }Item{ ... }Pal{ ... } 和其他模块用法都 一样,而且它们唯一必填的字段都是 id
  • 带冒号的 id 是你自己的内容,不带冒号的是游戏里已经有的东西。在已有的 id 上做扩展,是最快在 游戏里看到效果的办法。
  • 想运行的代码写在 events 里。第一个参数是事件发生在谁身上:建筑物是活着的那一个,其他都是 那个定义的句柄。
  • 只要你调用 self:save(),建筑物就会记住你放进 state 的东西。
  • 字段名写错会中止调用,并给出一条以 PalForge: 开头的消息,告诉你正确的名字;id 的形状不对, 也会在你写下它的那一行停住。
  • 不会触发的处理函数只有三个:以否定方式定下来的 Building onLeftClickonBreak,以及 Skill 的 onHit。本页能声明的其余每一个都有活的来源。
  • PalForge 只面向单人游戏,而且它声称的每一项能力都是对着 Palworld v1.0.2.101103 测出来的。

接下来读 快速开始,安装 PalForge,让你写的文件被加载进来。

On this page