PalForge
API 参考

Pal

添加一只在生成、受到伤害或被捕捉时会做出反应的帕鲁

读完本页你可以做到

  • 让帕鲁在被捕捉、被打中、被击杀的那一刻做出反应
  • 帕鲁死亡时播放一段音效,或者开始一个有时限的效果
  • 给帕鲁换一副身体、换一种颜色、换一个大小
  • 在你站着的位置放出一只帕鲁,等级由你决定
  • 游戏运行期间,对你的每一只帕鲁反复运行你自己的代码

创建帕鲁

帕鲁就是游戏里的生物。用游戏里那只生物本来的名字写一句 Pal{ ... },你就会拿到一个对象, 可以用它生成帕鲁、之后再取回它、给它挂上行为。

content/pals.lua
local pal = Pal{
    id          = "ChickenPal",
    name        = "Chicken Pal",
    description = "the one that greets you",
    events = {
        onCaptured = function(pal, ctx)
            Audio.get("AKE_Arena_Victory_01"):play(ctx.actor)
        end,
    },
}

pal:spawn(Player.coordinate())   -- the pal turns up a few seconds later

在游戏里捕捉一只 Chicken Pal,胜利音效就会响起。整个流程就这么多:说清楚你指的是哪只生物, 说清楚该发生什么,剩下的交给 PalForge。

入口有三个:

Pal{ id = "ChickenPal" }    -- define + register, returns a Pal.Handle
Pal.get("SheepBall")        -- a handle for an existing id; never nil
Pal.get_all()               -- every PalForge-registered pal, as handles

Pal.get 永远不会返回 nil。没有人定义过的 id 会拿到一份很薄的定义,够用来 :spawn,但上面 没有任何处理函数。只有 Pal{ ... } 会注册帕鲁,也只有注册过的帕鲁才会被调用处理函数。

Palworld 自带的生物列表在 native/pals 里:

local pals = require("palforge.native.pals")

pals.CATALOG               -- every DT_PalMonsterParameter_Common row id, as strings
pals.get("BlueSkyDragon")  -- a lazy Pal handle for any catalog id, nil for anything else
pals.Chicken               -- the curated ChickenPal demo definition
pals.SheepBall             -- the curated SheepBall demo definition

pals.get(id) 在你第一次取用某个 id 时定义并缓存一个光秃秃的 Pal{ id = id }。所以取用目录里的 id 也就顺带注册了它,事件从此开始送到这个 id——在你写处理函数之前,它们都是空的。

字段

必填的只有 id。不在下面这份列表里的字段,在你调用 Pal{ ... } 的那一刻就会报错,并附带 “你是不是想写……”的提示,所以拼写错误会当场暴露,而不会被悄悄忽略。游戏运行时也能读到同一份 列表:require("palforge.core.schema").help("Pal.Spec")

Prop

Type

没有哪个字段带默认值。有两个字段在读取时会回退:没写 name:name() 返回 id,没写 skills:skillsOf() 返回一个空表。

带冒号的 id 是你自己的:"example:Boss" 对应名为 example_Boss 的游戏数据行。不带冒号的 id 是 游戏自己的,比如 ChickenPal

mesh

网格就是生物身上穿的 3D 模型。可以就地写,也可以用 Mesh{ ... } 定义一次再 传进来。两种写法的校验方式一样,到达渲染器的路径也一样,哪种在文件里顺手就用哪种。

Pal{
    id   = "ChickenPal",
    mesh = {
        kind      = "skeletal",
        model     = "/Game/Pal/Model/Character/Monster/ChickenPal/SK_ChickenPal.SK_ChickenPal",
        animClass = "/Game/Pal/Blueprint/Character/Monster/PalActorBP/ChickenPal/ABP_ChickenPal.ABP_ChickenPal_C",
    },
}

网格的字段有 kindproceduralstaticskeletalobj,默认 skeletal)、model (必填)、animClass(仅 skeletal)、scaleoffsettexturecolormaterialparamsobj 是 procedural 后端的另一个名字。

写好的网格会自己装上去。Pal{ ... } 会把这份定义的 onSpawned 包起来,让 renderOnpal.spawned 频道上、在你自己的处理函数之前,为该 id 的每一只 pawn 运行一次。写下网格, 就是装载的全部:

Pal{
    id   = "ChickenPal",
    mesh = {
        kind      = "skeletal",
        model     = "/Game/Pal/Model/Character/Monster/ChickenPal/SK_ChickenPal.SK_ChickenPal",
        animClass = "/Game/Pal/Blueprint/Character/Monster/PalActorBP/ChickenPal/ABP_ChickenPal.ABP_ChickenPal_C",
    },
}

pal.spawned 是这只 pawn 的 .Mesh 组件最早成为实体的那一刻,所以装载走的是这条频道,而不是 onTick 巡检:网格只需要设置一次,而巡检每三秒都要为它付一次代价。装载走 attachOnce,所以 同一只 pawn 收到第二次生成事件也不会叠上第二个网格;整段都在 pcall 里,所以一个解析不出来的 model 路径只花掉一行日志,绝不会连累这只帕鲁的生命周期。

:renderOn(actor) 仍然要你自己调用的场合是:不是 PalForge 生成的 pawn、通过 onTick 巡检找到 的 pawn,以及 detach 之后重新装上。

material、color 和 texture

material 是完整的覆盖写法。colortexture 是其中两个字段的简写,够用的时候写它们就行。

Prop

Type

两种写法不会混在一起。写了 material,它就被整体采用,顶层的 colortexture 会被忽略。 只有在没写 material 时,简写才会被拼装成一份材质描述。在 :renderOn 内部,这份描述里带的值 会盖过网格上的同名字段,它没带的字段则保留网格自己的值。

-- shorthand: tint the declared mesh red
Pal{
    id    = "ChickenPal",
    mesh  = { kind = "skeletal", model = "/Game/Pal/Model/Character/Monster/ChickenPal/SK_ChickenPal.SK_ChickenPal" },
    color = { r = 1.0, g = 0.2, b = 0.2, a = 1.0 },
}

-- long form: a base material plus parameters, which the shorthands cannot express
Pal{
    id   = "SheepBall",
    mesh = { kind = "skeletal", model = "/Game/Pal/Model/Character/Monster/SheepBall/SK_SheepBall.SK_SheepBall" },
    material = {
        color    = { r = 0.2, g = 0.5, b = 1.0, a = 1.0 },
        texture  = "/Game/Pal/Model/Other/AttackHelicopter/Material/T_AttackHelicopter_B.T_AttackHelicopter_B",
        material = "/Game/.../MI_YourBaseMaterial",
        params   = { scalar = { ["Roughness Add"] = 0.2 } },
    },
}

params 和基础材质路径没有顶层简写。它们只存在于 material 里面。paramsvectorscalartexture 分组,是因为它背后的三个引擎 setter 就是这么分的。

帕鲁自己的 texture(两种写法都算)会按你写的样子原样送到渲染器:可以是一个 /Game/... 纹理 资源,玩家磁盘上不需要任何文件;也可以是你自己的 png 绝对路径。相对于你这个 pack 目录的路径, 只在网格自己的 texture 字段里才会被解析,在这里不会。

这四个字段会送到每一个后端,skeletal 也不例外。材质那部分工作放在 core/mesh/base/renderer.lua 里,每个后端都会调用它,所以写在 kind = "skeletal" 网格旁边的 color,也会通过一个动态材质实例写进那只 pawn 自己的材质插槽。

:renderOn 返回 true 仍然不代表看得出任何变化。它写入的参数名是从运行中的游戏里读出来的真 名字(BaseColorBase TextureNormal Map 等,完整清单在 Mesh),但往 材质没有的名字上写是悄无声息的空操作。颜色变化确实已经被看着落到屏幕上了,只不过对象是一个 建筑物而不是帕鲁(mesh-color-change,2026-08-02:红 → 绿 → 蓝),所以请把 true 读作 “网格换掉了,材质写入跑过了”,并且第一次用眼睛去确认结果。

skills

skills 是这份定义声明的一串 id。光是声明它,不会往任何生物身上装上什么,游戏里也没有任何东西会 替你触发它们。用 :skillsOf() 把这串 id 读回来,再通过 Skill 自己去释放每个 技能。

local boss = Pal{
    id     = "BOSS_ChickenPal",
    name   = "Chicken Boss",
    skills = { "FlameThrower" },
}

-- fire everything this pal owns at the player
for _, id in ipairs(boss:skillsOf()) do
    Skill.get(id):activate(Player.character())
end

想把声明好的这串 id 装到站在世界里的生物身上、让游戏自己带着它们,用 :teachAll(actor) —— 见下面的 teachAll

icon

:iconOf() 会拿这个 id 去查游戏自带的帕鲁图标表——先查 DT_PalCharacterIconDataTable,再查 DT_PalCharacterIconDataTable_Common——读的是 Icon 这一列。这个列名是实测出来的,不是猜的: 在一个加载好的存档里一次性读了四张图标表,帕鲁 674 行里答了 674 行,道具 1207 行里答了 1183 行,建筑物 571 行里答了 567 行,伙伴技能 311 行里答了 311 行。帕鲁和道具用 Icon, 建筑物用 SoftIcon,伙伴技能用 TextureID_8_2B2F889C43EB586246BDB981B6462ACA。返回的永远是 一个 /Game/... 资源路径字符串,绝不是引擎对象。

id 会先被解析再去查,所以 "example:Boss" 是按行的写法 example_Boss 去查的,只要那一行存在 就能查到真表;解析不出来的 id 就按字面去查。只要没查到——没有表、没有行、没有值——你拿到的 都是自己写的 icon,一个都没写就是 nil

行的匹配区分大小写,而且同一只生物的两种写法在这个版本里都真实存在:分发用的蓝图 id 是 SheepBall,这次查询要对上的 DataTable 行是 Sheepball

local pal = Pal{ id = "example:Boss", icon = "/Game/.../T_icon_example_boss" }
pal:iconOf()   -- the row "example_Boss" if PalSchema wrote one, else the declared icon

data

data 会原封不动地复制到定义上,PalForge 不会读它。句柄上也没有读取它的方法,所以想在处理函数 里用它,就把这个表存在 Lua 局部变量里。

local config = { reward = "Wood", amount = 5 }

local pal = Pal{
    id   = "ChickenPal",
    data = config,
    events = {
        onDeath = function(pal, ctx)
            Item.get(config.reward):give(config.amount)
        end,
    },
}

事件

帕鲁的行为就是在这里写的。把处理函数放在 events 下面。每个都是 function(pal, ctx)pal这份定义自己的句柄——就是 Pal{ ... } 返回的那个对象,所以 :renderOn 和各个查询 都在手边;ctx 是一个表,描述刚刚发生了什么。写了下面列表以外的事件名,会在定义时报错,而不是 悄悄什么都不做。

PalForge 监听游戏,把每个事件送上一条具名频道,再找出事件发生在哪只帕鲁身上,然后调用你的 处理函数:

native hook -> event.emit -> channel -> dispatch -> resolve BP class name -> pal:onX(ctx)
钩子频道原生来源ctx状态
onSpawnedpal.spawnedPalNPC:OnCompletedInitParamPalPlayerCharacter:OnCompleteInitializeParameterctx.actorLIVE,已观测到触发;在 world.ready 时启用,绝不在加载时启用
onDamagedpal.damagedPalCharacter:OnDamageReactionctx.actorLIVE
onDeathpal.deathPalCharacter:OnDeadCharacterctx.actorLIVE
onCapturedpal.capturedPalCharacterParameterComponent:SetIsCapturedProcessing,且 started == truectx.actorctx.compLIVE
onTicktick没有对应的游戏事件:core/event 自己去遍历活着的帕鲁ctx.actorctx.countctx.nowLIVE,每 core.event.PAL_SCAN_MS 对每只活着的帕鲁运行一次,默认 3 秒

ctx.actor 是世界里发生了这件事的那只生物。pal.captured 是在帕鲁的参数组件上触发的,所以 actor 是这个组件的持有者,ctx.comp 就是组件本身。onDamaged受到伤害的那只帕鲁身上 触发,永远不会在攻击者身上触发——游戏自己的钩子没有说是谁发起的攻击。

pal.spawned 有两个来源,因为最顺理成章的那一个什么都不带。 PalCharacter:BroadcastOnCompleteInitializeParameter 正是宣告“某个角色参数初始化完成”的那个 函数,钩子也挂得干干净净——可是在另外十条频道都在报告的真实存档里,它被实测为沉默。钩子 能看到的只有 ProcessEvent 真正执行的东西,而发广播的那一方不在其中。所以 PalForge 监听的是 这次广播调用的委托目标PalNPC:OnCompletedInitParam,它在帕鲁自己这一侧,所有帕鲁都会 走到;以及 PalPlayerCharacter:OnCompleteInitializeParameter,它为玩家订阅过的角色触发。两者 都在 2026-07-26 观测到触发。两条来源会按 actor 在一秒的窗口里去重,所以同一只 pawn 同时走到 两边,也只算一次事件。

一个响应捕捉、并回赠玩家东西的处理函数:

local log = require("palforge.utils.log").scope("example")

Pal{
    id   = "SheepBall",
    name = "Sheepball",
    events = {
        onCaptured = function(pal, ctx)
            log.info("captured " .. pal:name() .. ": " .. tostring(ctx.actor))
            Item.get("PalSphere"):give(1)
        end,
    },
}

一个在动手之前先确认生物仍然有效的处理函数:

Pal{
    id = "ChickenPal",
    events = {
        onDamaged = function(pal, ctx)
            local actor = ctx.actor
            if not (actor and actor.IsValid and actor:IsValid()) then return end
            Audio.get("AKE_Pal_Footstep"):play(actor)
        end,
    },
}

onTick 自己跑,大约每三秒一次,对该 id 下每只活着的帕鲁各运行一次。ctx.count 数的是模组 加载以来 500 毫秒心跳的次数,所以拿它来做“偶尔才做一次”的事很方便:

local log = require("palforge.utils.log").scope("example")

Pal{
    id = "ChickenPal",
    events = {
        onTick = function(pal, ctx)
            -- ctx.actor is one live Chicken Pal; this runs once per pal, per sweep
            if ctx.count % 12 == 0 then
                log.info("chicken still here: " .. tostring(ctx.actor))
            end
        end,
    },
}

-- slow the sweep down, or switch it off with 0
require("palforge.core.event").PAL_SCAN_MS = 5000

处理函数可以随意组合,每一个都是可选的:

local log = require("palforge.utils.log").scope("example")

Pal{
    id          = "ChickenPal",
    name        = "Chicken Pal",
    description = "logs every moment of its short life",
    events = {
        onSpawned  = function(pal, ctx) log.info("spawned "  .. tostring(ctx.actor)) end,
        onDamaged  = function(pal, ctx) log.info("damaged "  .. tostring(ctx.actor)) end,
        onDeath    = function(pal, ctx) log.info("died "     .. tostring(ctx.actor)) end,
        onCaptured = function(pal, ctx) log.info("captured " .. tostring(ctx.actor)) end,
    },
}

PalForge 在 native/pals.lua 里自带两只现成的帕鲁——ChickenPalSheepBall——它们的 onCapturedonDamagedonDeath 已经接好了日志输出。捕捉或击杀一只野生个体,这些日志行 就会出现,这是在你的游戏里确认整条链路通不通的最快办法。

处理函数会不会运行,由什么决定

四件事。

  1. 帕鲁靠蓝图类名来识别。 游戏里每只生物都有一个生成出来的类,叫 BP_<Id>_C。PalForge 从 生物身上读到这个名字,把 BP_ChickenPal_C 变成 ChickenPal,再拿这个 id 去查。带命名空间的 定义只要解析后的名字和蓝图 id 相同也能匹配上,所以 Pal{ id = "example:Boss" } 能接住 BP_example_Boss_C
  2. 只有注册过的 id 才收得到事件。 Pal{ ... } 会注册,Pal.get(id) 不会。你从没定义过的 原版帕鲁解析不到任何定义,事件会被丢弃。
  3. 世界准备好之前,什么都不会运行。 游戏钩子会立即返回,直到连续五次每秒一轮的轮询都找到 有效的 PalPlayerCharacter 为止。onTick 的巡检也在等同一道门。
  4. 处理函数运行在 pcall 里,失败会写进日志。 你的处理函数里出错,既不会让游戏崩溃,也不会 让频道停下。PalForge 会捕获它,把频道名、钩子名和错误信息写进日志—— pal.death -> onDeath handler failed: ...。不过出错那一行之后的代码还是不会执行。

监听所有帕鲁,而不只是自己的

你的处理函数只会为你定义过的帕鲁运行。想听到世界里每一只帕鲁的动静,包括原版的,就自己订阅 频道:

local event = require("palforge.core.event")
local log   = require("palforge.utils.log").scope("example")

local sub = event.on("pal.death", function(ctx)
    log.info("some pal died: " .. tostring(ctx.actor))
end)

-- later
sub:unsubscribe()

event.observable(name) 把同一条频道给你一个 Rx observable,方便串接操作符。 event.emit("pal.spawned", { actor = someActor }) 会把你自己造的事件推过整条链路。PalForge 仍然 从 ctx.actor 的蓝图类名反推定义,所以手工造的 ctx 能到达你自己的订阅者,但只有当 actor 真的是 世界里的一只帕鲁时,才会到达某份定义的处理函数。想单独运行一个处理函数,用下面的事件转发方法。

句柄

Pal{ ... }Pal.getPal.get_all 都会返回一个 Pal.Handle。它带有 .id、两个动作、 五个事件转发方法和五个查询方法。

spawn

---@param arg Coord|table|nil
---@return boolean issued
pal:spawn(arg)

参数会在做任何事之前先被分辨清楚:带 .x[1] 的表是坐标,别的表是选项表,一个参数都不传 就是默认放置。

local pal = Pal.get("ChickenPal")

pal:spawn()                                   -- wild, near the player, level 1
pal:spawn(Player.coordinate())                -- at the player's exact position
pal:spawn(Player.coordinateOffset(300, 0, 0)) -- 3 m away on X
pal:spawn({ 12000, -4300, 800 })              -- array coordinate, x/y/z in centimetres
pal:spawn{ at = Player.coordinate(), level = 30 }
pal:spawn{ toPlayer = true, num = 3, level = 20 }   -- three, owned by the player
pal:spawn{ level = 45 }                             -- wild, near the player, level 45

选项表接受 at(坐标)、level(默认 1)、toPlayer(任何为真的值都会把帕鲁送给玩家,而不是 放进世界)和 num(默认 1,只有走 toPlayer 那条路时才会读)。

生成不是立刻发生的

帕鲁会在你调用 :spawn 之后大约 4 到 8 秒才出现。这是游戏自己的节奏,不是 PalForge 加的延迟, 也没有办法让它变成立刻。

所以那个布尔值的意思是调用发出去了,它也不可能表示更多:等生物真正存在的时候,你的代码早就 往下走了。别在下一行去找那个 pawn,也别把 true 当成“已经有一只帕鲁站在那里”。

想对这只帕鲁做出反应,就用 onSpawned 处理函数,它会在生物真正到达时运行。非要自己找的话,请在 十秒以上的时间里反复找。

false 是老老实实的失败:调用被拒绝,或者根本没去尝试 —— id 是空的、坐标不是数字,或者够不到 管理对象。

到达会在几秒之后写进日志,还带着经过的时间,所以你不用在自己代码里加计时也能看清楚发生了什么:

[PalForge.spawn][info] spawn.pal ChickenPal: 1 new PalCharacter in the world 5.9 s after the call (look 15 of 20)

按坐标生成

按坐标生成是“先生成、再搬过去”。游戏自己的调用会忽略你要的位置,把帕鲁扔在玩家旁边,所以 PalForge 会等生物到达,挑出刚才还不在的那一只,再把它传送到你给的点上。

它落得分毫不差。这一轮延后处理会从被移动的那只帕鲁身上把位置读回来,并同时报告这个位置和它与 你所要求的点之间的偏差:

[PalForge.spawn][info] spawn.palAt: placed new pal at (-345296,263050,4153); it reads back (-345296,263050,4153), off by 0

你在游戏里看到的,是调用之后几秒帕鲁先出现在你身边,然后再移动到那个点上。它就是这个形状,没有办法 让生物一开始就出现在坐标处。

直接送给玩家

:spawn{ toPlayer = true } 把生物交给玩家的队伍或仓库,而不是放进世界,而且完全不需要管理对象。 它的 true 只表示调用发出去了,到此为止:Lua 看不进队伍,也看不进仓库,所以没有可以报告的到达, 后面也不会跟一行日志。

管理对象

放进世界的那几条路要走 UPalCheatManager。在客户端上,CheatManagerEnabler 模组会在 PlayerController:ClientRestart 里创建这个对象;在专用服务器上那个钩子从不触发,也没有别的东西会去 创建它。会话里一个都没有时,core/spawn 会自己造一个:它用 StaticConstructObject 构造 PalPlayerController 自己的 CheatClass——那个类为空时依次回退到 /Script/Pal.PalCheatManager/Script/Engine.CheatManager——再把结果挂到控制器上。这个对象会一直留着,所以这件事每个会话只发生 一次,而不是每次生成都发生一次,唯一的那次会记进日志。这就是专用服务器能和客户端走同一条路的原因。

最早的失败,是根本没有玩家控制器:世界还没加载,或者还没连上。这时每条放进世界的路都在够到游戏之前 就返回 false,并警告说找不到管理对象,也造不出来。从 world.ready 里调用可以避开这段时间:

local event = require("palforge.core.event")
local log   = require("palforge.utils.log").scope("example")

event.on("world.ready", function()
    if not Pal.get("ChickenPal"):spawn{ level = 10 } then
        log.warn("the spawn call was not issued")
    end
end)

renderOn

---@param actor any
---@return boolean ok
pal:renderOn(actor)

把这份定义里写好的网格装到一只活着的生物身上,只装一次。渲染器会防止重复叠加,所以对同一个 actor 再调一次也没有害处。当 actor 无效、定义里没有网格,或者那个网格没有 model 时,它什么都 不做并返回 false

通常你不需要调用它。 写了 mesh 的定义会自己在 pal.spawned 上把它装好。这里是手动的 路线:不是 PalForge 生成的 pawn、通过 onTick 巡检找到的 pawn,或者 detach 之后重新装上。 在 onSpawned 里再调一次也没有害处——有那道防重叠的门,第二次调用只会发现网格已经在那里了。

-- reach the sheepballs that were already standing there when the pack loaded
Pal{
    id   = "SheepBall",
    mesh = { kind = "skeletal", model = "/Game/Pal/Model/Character/Monster/SheepBall/SK_SheepBall.SK_SheepBall" },
    events = {
        onTick = function(pal, ctx)
            pal:renderOn(ctx.actor)   -- one-shot per pawn: the dressed ones cost nothing
        end,
    },
}

整份声明都会交给渲染器:kindmodelanimClassscaleoffset 来自网格,随后定义里的 材质描述会覆盖它带的 colortextureparamsmaterialanimClass 会送到 skeletal 后端,后端切换到动画蓝图模式,并在替换之后立刻绑定那个蓝图。换上的骨架如果没有东西驱动,就不会 有动画,还可能从画面上消失,所以要写你换进去的那个模型对应的 ABP_*_C,而不是这只生物出生时 自带的那个。

-- a chicken wearing a sheepball body: the anim blueprint travels with the model
Pal{
    id   = "ChickenPal",
    mesh = {
        kind      = "skeletal",
        model     = "/Game/Pal/Model/Character/Monster/SheepBall/SK_SheepBall.SK_SheepBall",
        animClass = "/Game/Pal/Blueprint/Character/Monster/PalActorBP/SheepBall/ABP_SheepBall.ABP_SheepBall_C",
    },
}

返回 true 表示后端的设置方法执行了——对 skeletal 后端来说,就是模型被设置到了生物自己的组件 上,并且能从那里读回来。至于最终画出来是什么样,这个调用没法告诉你;材质写入有没有落在材质 真正带有的参数上,同样也没法告诉你。

teachAll

---@param actor any   # a live pal or player character
---@return integer taught, integer asked
pal:teachAll(actor)

把这份定义声明的每一个技能都装到一个活着的角色身上,让游戏自己带着它们。:skillsOf() 是作者 写下来的内容,而这个方法是把那份列表送到真正的生物身上的办法。

local Blaze = Pal{
    id     = "FoxMage",
    skills = { "FireBlast", "Legend" },
}

local taught, asked = Blaze:teachAll(somePalActor)
-- 2, 2 when both landed; 1, 2 when only one did

每个 id 走哪条路,取决于游戏把它认作什么,而不是你声明了什么:属于游戏自己主动技能的 id 会被 加进这只生物的装备技能里,其余任何 id 都会以那个名字作为被动技能加上去。完整规则和名字清单见 Skill

返回的是两个数字而不是一个布尔值,这样“只成了一部分”就看得见。技能按声明顺序装上,每次写入之后都会 把角色读回来核对,其中一个失败也不会中断后面的 —— 不该因为一个不认识的 id,就让这只帕鲁丢掉另外四个 技能。两个数字都是 0,说明这份定义一个技能都没声明。

查询

pal:skillsOf()      --> string[]   the declared skill ids, an empty table when none
pal:teachAll(actor) --> integer, integer   how many landed, how many were asked for
pal:mesh()          --> table?     the validated mesh declaration, nil when none
pal:iconOf()        --> string?    the DataTable row's /Game/... path, else the declared icon, else nil
pal:name()          --> string     the declared name, else the id
pal:description()   --> string?    the declared description, or nil
local log = require("palforge.utils.log").scope("example")

for _, pal in ipairs(Pal.get_all()) do
    local m = pal:mesh()
    log.info(string.format("%s (%s) mesh=%s skills=%d",
        pal:name(), pal.id, tostring(m and m.model), #pal:skillsOf()))
end

事件转发方法

:onSpawned(ctx):onDamaged(ctx):onDeath(ctx):onCaptured(ctx):onTick(ctx) 会用 你传进去的 ctx,立刻调用定义里的处理函数。游戏永远不会走这条路——它们存在,是为了让你自己运行 处理函数,比如在测试里,或者在另一个处理函数里。

-- run the death handler now, with a hand-made ctx
Pal.get("ChickenPal"):onDeath({ actor = Player.character() })

实用示例

一只自己换装并宣告登场的帕鲁

content/party_chicken.lua
local log = require("palforge.utils.log").scope("example")

local Fanfare = Audio.se{
    id        = "AKE_CampLevelUp",
    soundId   = "AKE_CampLevelUp",
    soundPath = "/Game/Pal/Sound/Events/SE/UI/CampLevelUp/AKE_CampLevelUp.AKE_CampLevelUp",
}

local Chicken = Pal{
    id          = "ChickenPal",
    name        = "Party Chicken",
    description = "wears a custom skin and announces itself",
    mesh = Mesh{
        id        = "example:party_chicken",
        kind      = "skeletal",
        model     = "/Game/Pal/Model/Character/Monster/ChickenPal/SK_ChickenPal.SK_ChickenPal",
        animClass = "/Game/Pal/Blueprint/Character/Monster/PalActorBP/ChickenPal/ABP_ChickenPal.ABP_ChickenPal_C",
    },
    color = { r = 1.0, g = 0.4, b = 0.1, a = 1.0 },
    events = {
        onSpawned = function(pal, ctx)
            -- the mesh is already on ctx.actor by the time this runs
            log.info("party chicken arrived: " .. tostring(ctx.actor))
            Fanfare:play(ctx.actor)
        end,
    },
}

Chicken:spawn(Player.coordinateOffset(300, 0, 0))

写好的网格会在这个处理函数运行之前就装上去,所以想移动、改名或者重新上色的处理函数,一开始 就能看到身体已经在了。装载本身成没成功,这个处理函数读不到——那在网格的日志里。

音效在文件顶部定义一次,处理函数里只负责播放。写在处理函数里定义,等于每生成一次就重新注册一次 音效。手边没有局部变量时,改用 Audio.get("AKE_CampLevelUp"):play(ctx.actor)

一只死亡时掉落战利品的帕鲁

content/woolly_sheep.lua
local log = require("palforge.utils.log").scope("example")

local Wool = Item.get("Wool")
local Meat = Item.get("Meat")

Pal{
    id          = "SheepBall",
    name        = "Woolly Sheepball",
    description = "hands over wool and meat when it dies",
    events = {
        onDeath = function(pal, ctx)
            Wool:give(3)
            Meat:give(1)
            log.info(pal:name() .. " dropped its loot")
        end,
    },
}

Item.get(...):give(n) 往本地玩家的背包里加东西,并把背包实测到的变化返回给你,所以别假定掉落 一定到位,检查那个布尔值。PalForge 没有把战利品留在帕鲁倒下位置的调用,所以这是玩家直接收到的 奖励,不是走过去捡的一袋东西。道具还能做什么,见 Item

一只被打中就着火的帕鲁

content/singe_chicken.lua
local log = require("palforge.utils.log").scope("example")

local Singe = Effect{
    id          = "example:Singe",
    name        = "Singe",
    description = "burns for six seconds after taking a hit",
    duration    = 6.0,
    interval    = 1.0,
    events = {
        onApply  = function(effect, target, ctx)
            log.info("singe applied by " .. tostring(ctx.source))
        end,
        onTick   = function(effect, target, ctx)
            log.info(string.format("singe tick at %.1fs", ctx.elapsed))
        end,
        onExpire = function(effect, target, ctx)
            log.info("singe over: " .. tostring(ctx.reason))
        end,
    },
}

Pal{
    id   = "ChickenPal",
    name = "Singed Chicken",
    events = {
        onDamaged = function(pal, ctx)
            if not ctx.actor then return end
            if Singe:isActive(ctx.actor) then return end
            Singe:apply(ctx.actor, { source = pal.id })
        end,
    },
}

效果跑在共享的 500 毫秒心跳上,所以它的 onTick 大约每 interval 秒触发一次,直到 duration 用完。ctx.elapsedctx.stacks,以及到期时的 ctx.reason,都来自这套运行时;表里其余的内容 是你传给 :apply 的。见 Effect

一支随时可以召唤的换色小队

content/blue_squad.lua
local log = require("palforge.utils.log").scope("example")

local Squad = Pal{
    id          = "SheepBall",
    name        = "Blue Squad",
    description = "a recolored sheepball unit",
    mesh = {
        kind      = "skeletal",
        model     = "/Game/Pal/Model/Character/Monster/SheepBall/SK_SheepBall.SK_SheepBall",
        animClass = "/Game/Pal/Blueprint/Character/Monster/PalActorBP/SheepBall/ABP_SheepBall.ABP_SheepBall_C",
        scale     = 1.2,
    },
    material = {
        color = { r = 0.2, g = 0.5, b = 1.0, a = 1.0 },
    },
}

-- spread `n` of them in a line to the player's north-east
local function summonSquad(n, level)
    local at = Player.coordinate()
    if not at then
        log.warn("no player coordinate - not in a world yet")
        return false
    end
    for i = 1, n do
        Squad:spawn{ at = { x = at.x + i * 250, y = at.y + 250, z = at.z }, level = level }
    end
    return true
end

summonSquad(5, 20)

-- the party-owned variant: three of them straight into the box
Squad:spawn{ toPlayer = true, num = 3, level = 20 }

每个成员各用一次自己的生成调用,到达的个体都会穿上写好的那套网格——定义里没有任何处理函数, 因为装载自己就骑在 pal.spawned 上。这支小队不会一起出现,也不会立刻出现:每一只都比自己那次 调用晚几秒,所以它们会在接下来的几秒里陆续到位。

限制

  • 生成不是立刻的,:spawn 也没法告诉你它成功了。 帕鲁要 4 到 8 秒后才到,所以那个布尔值说的是 “调用发出去了”。到达写在日志里,处理逻辑该放在 onSpawned
  • Lua 没法凭空造出一只全新的生物。 Pal{ id = ... } 是给游戏里已有的生物加上行为、外观和 元数据;创建数据行本身是 PalSchema 的活。游戏里没有对应 BP_<Id>_C 的 id,处理函数永远不会 被调用,:spawn 也没有东西可生成。
  • onTick 是巡检,不是游戏事件。 core/event 每隔 core.event.PAL_SCAN_MS(默认 3000 毫秒) 遍历一次活着的帕鲁,每只生物调用一次 onTick。用 require("palforge.core.event").PAL_SCAN_MS = 5000 可以放慢它,用 0 可以关掉它。它比 500 毫秒 的心跳慢是故意的,因为这次遍历会碰到游戏里的每一个对象。需要更快或更准的定时器时,用 require("palforge.core.event").every(2000, fn)(会量化到 500 毫秒的 tick),或者用 Effect 的 interval
  • onTick 没有针对单只生物的记忆。 pal 是这份定义的句柄,该 id 下的每只生物用的都是同一个 句柄,所以要按生物记住的东西,请放进你自己的表里,用 ctx.actor 作键。
  • 坏掉的 onTick 会被关掉。 连续失败五次,PalForge 就会记进日志,并在这个会话剩下的时间里 不再调用这份定义的 onTick——该 id 下的所有生物都不再调用,不只是出错的那一只。
  • onSpawned 会为一只真正“新”的帕鲁触发,而且这是实测出来的。 pf_hook pal-spawned-fresh 在 2026-08-02 跑过,把每一次触发都相对 world.ready 打了时间戳:27 次触发,其中 17 次离世界 加载八竿子打不着。 所以这个事件的含义正是 pack 想要的那个含义。它同样会在加载洪峰里触发, 那一刻范围内的每只帕鲁都在同时初始化,所以处理函数仍然必须做到:对一只已经见过的 pawn 再被调用 一次也无害 —— 请把自己的记账挂在 ctx.actor 上,而不是去数触发次数。
  • onSpawned 的来源在 world.ready 时才启用,绝不在加载时启用。 那次初始化广播就发生在 世界加载的帕鲁初始化洪峰里,而在那里的一次触发曾经堵死过共享的 UE4SS 钩子分发,把三个已确认 的钩子一起拖下水。推迟启用保护的不只是这条频道,还有捕捉、伤害和死亡。
  • 世界准备好之前什么都不会运行。 四个游戏钩子都会立即返回,直到连续五次每秒一轮的轮询都找到 有效的 PalPlayerCharacter 为止,onTick 的巡检也在等同一道门。
  • 处理函数出错,这个处理函数就到此为止。 PalForge 在 pcall 里调用你的钩子,并把失败连同 频道名和钩子名一起写进日志,所以 bug 在日志里看得见——但处理函数余下的部分不会运行,频道和 游戏都不会察觉。
  • 放进世界的生成,连尝试都需要先有一个玩家控制器。 它也需要 PalCheatManager,但不必先由别的 东西创建:会话里一个都没有时,core/spawn 会用控制器的 CheatClass 造一个并挂上去,所以专用 服务器能和客户端走同一条路——那里 CheatManagerEnabler 的 ClientRestart 钩子从不运行。如果连 PalPlayerController 都没有,没有东西可以拿来造,每条放进世界的路都在够到游戏之前就返回 falsetoPlayer 那条路这些都不需要。
  • 坐标是生成之后的移动,不是生成位置。 游戏自己的调用会忽略你要求的位置,所以 PalForge 会等生物 到达,挑出新的那一只,再把它传送到你给的点上 —— 落点分毫不差。这一轮的结果只会写进日志,那一行里 带着从帕鲁身上读回来的位置。
  • 写好的网格会在 pal.spawned 上替你装上。 :renderOn(actor) 是给不是从那条频道来的 pawn 准备的手动路线。core.mesh.ENABLED 是一个全局开关,关掉它以后所有装载都变成空操作。材质相关 的字段会送到每一个后端,但往材质没有的参数名上写是悄无声息的空操作,所以装上了并不等于看得 见颜色。
  • Pal.get 不会注册。 只有 Pal{ ... } 会把定义放进注册表,所以 Pal.get 拿到的句柄能做 动作,却永远收不到事件。同一个 id 定义两次,后一份定义会替换前一份,注册表会把这件事写进 日志——两份定义来自不同 pack 时,还会写出双方的名字。

小结

  • Pal{ id = "ChickenPal", ... } 给游戏里的一只生物加上新的行为。必填的字段只有 id
  • 帕鲁身上发生的事写在 events 下面:onSpawnedonDamagedonDeathonCapturedonTick。这五个都是可用的。
  • pal:spawn() 把帕鲁放进世界,帕鲁会在 4 到 8 秒后到。传坐标就能精确放到那个点,传 { toPlayer = true, num = 3 } 就直接送给玩家。那个布尔值说的是调用本身,所以反应写在 onSpawned 里。
  • mesh 改变帕鲁的样子,写好的网格会在 pal.spawned 上替你装上;pal:renderOn(actor) 是手动 路线。
  • 只有你用 Pal{ ... } 定义过的 id 才会被调用处理函数;Pal.get 给你动作,但不给你事件。
  • onTick 对每只活着的帕鲁大约每三秒运行一次,自己不记东西——要按生物记的内容,用 ctx.actor 作键。

接下来读 Mesh,给你的帕鲁一副自己的身体。

On this page