Pal
添加一只在生成、受到伤害或被捕捉时会做出反应的帕鲁
读完本页你可以做到
- 让帕鲁在被捕捉、被打中、被击杀的那一刻做出反应
- 帕鲁死亡时播放一段音效,或者开始一个有时限的效果
- 给帕鲁换一副身体、换一种颜色、换一个大小
- 在你站着的位置放出一只帕鲁,等级由你决定
- 游戏运行期间,对你的每一只帕鲁反复运行你自己的代码
创建帕鲁
帕鲁就是游戏里的生物。用游戏里那只生物本来的名字写一句 Pal{ ... },你就会拿到一个对象,
可以用它生成帕鲁、之后再取回它、给它挂上行为。
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 handlesPal.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 definitionpals.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",
},
}网格的字段有 kind(procedural、static、skeletal 或 obj,默认 skeletal)、model
(必填)、animClass(仅 skeletal)、scale、offset、texture、color、material 和
params。obj 是 procedural 后端的另一个名字。
写好的网格会自己装上去。Pal{ ... } 会把这份定义的 onSpawned 包起来,让 renderOn 在
pal.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 是完整的覆盖写法。color 和 texture 是其中两个字段的简写,够用的时候写它们就行。
Prop
Type
两种写法不会混在一起。写了 material,它就被整体采用,顶层的 color 和 texture 会被忽略。
只有在没写 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 里面。params 按 vector、
scalar、texture 分组,是因为它背后的三个引擎 setter 就是这么分的。
帕鲁自己的 texture(两种写法都算)会按你写的样子原样送到渲染器:可以是一个 /Game/... 纹理
资源,玩家磁盘上不需要任何文件;也可以是你自己的 png 绝对路径。相对于你这个 pack 目录的路径,
只在网格自己的 texture 字段里才会被解析,在这里不会。
这四个字段会送到每一个后端,skeletal 也不例外。材质那部分工作放在
core/mesh/base/renderer.lua 里,每个后端都会调用它,所以写在 kind = "skeletal" 网格旁边的
color,也会通过一个动态材质实例写进那只 pawn 自己的材质插槽。
:renderOn 返回 true 仍然不代表看得出任何变化。它写入的参数名是从运行中的游戏里读出来的真
名字(BaseColor、Base Texture、Normal 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 icondata
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 | 状态 |
|---|---|---|---|---|
onSpawned | pal.spawned | PalNPC:OnCompletedInitParam 和 PalPlayerCharacter:OnCompleteInitializeParameter | ctx.actor | LIVE,已观测到触发;在 world.ready 时启用,绝不在加载时启用 |
onDamaged | pal.damaged | PalCharacter:OnDamageReaction | ctx.actor | LIVE |
onDeath | pal.death | PalCharacter:OnDeadCharacter | ctx.actor | LIVE |
onCaptured | pal.captured | PalCharacterParameterComponent:SetIsCapturedProcessing,且 started == true | ctx.actor、ctx.comp | LIVE |
onTick | tick | 没有对应的游戏事件:core/event 自己去遍历活着的帕鲁 | ctx.actor、ctx.count、ctx.now | LIVE,每 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 里自带两只现成的帕鲁——ChickenPal 和 SheepBall——它们的
onCaptured、onDamaged 和 onDeath 已经接好了日志输出。捕捉或击杀一只野生个体,这些日志行
就会出现,这是在你的游戏里确认整条链路通不通的最快办法。
处理函数会不会运行,由什么决定
四件事。
- 帕鲁靠蓝图类名来识别。 游戏里每只生物都有一个生成出来的类,叫
BP_<Id>_C。PalForge 从 生物身上读到这个名字,把BP_ChickenPal_C变成ChickenPal,再拿这个 id 去查。带命名空间的 定义只要解析后的名字和蓝图 id 相同也能匹配上,所以Pal{ id = "example:Boss" }能接住BP_example_Boss_C。 - 只有注册过的 id 才收得到事件。
Pal{ ... }会注册,Pal.get(id)不会。你从没定义过的 原版帕鲁解析不到任何定义,事件会被丢弃。 - 世界准备好之前,什么都不会运行。 游戏钩子会立即返回,直到连续五次每秒一轮的轮询都找到
有效的
PalPlayerCharacter为止。onTick的巡检也在等同一道门。 - 处理函数运行在
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.get 和 Pal.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,
},
}整份声明都会交给渲染器:kind、model、animClass、scale 和 offset 来自网格,随后定义里的
材质描述会覆盖它带的 color、texture、params 和 material。animClass 会送到 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 nillocal 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() })实用示例
一只自己换装并宣告登场的帕鲁
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)。
一只死亡时掉落战利品的帕鲁
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。
一只被打中就着火的帕鲁
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.elapsed、ctx.stacks,以及到期时的 ctx.reason,都来自这套运行时;表里其余的内容
是你传给 :apply 的。见 Effect。
一支随时可以召唤的换色小队
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都没有,没有东西可以拿来造,每条放进世界的路都在够到游戏之前就返回false。toPlayer那条路这些都不需要。 - 坐标是生成之后的移动,不是生成位置。 游戏自己的调用会忽略你要求的位置,所以 PalForge 会等生物 到达,挑出新的那一只,再把它传送到你给的点上 —— 落点分毫不差。这一轮的结果只会写进日志,那一行里 带着从帕鲁身上读回来的位置。
- 写好的网格会在
pal.spawned上替你装上。:renderOn(actor)是给不是从那条频道来的 pawn 准备的手动路线。core.mesh.ENABLED是一个全局开关,关掉它以后所有装载都变成空操作。材质相关 的字段会送到每一个后端,但往材质没有的参数名上写是悄无声息的空操作,所以装上了并不等于看得 见颜色。 Pal.get不会注册。 只有Pal{ ... }会把定义放进注册表,所以Pal.get拿到的句柄能做 动作,却永远收不到事件。同一个 id 定义两次,后一份定义会替换前一份,注册表会把这件事写进 日志——两份定义来自不同 pack 时,还会写出双方的名字。
小结
Pal{ id = "ChickenPal", ... }给游戏里的一只生物加上新的行为。必填的字段只有id。- 帕鲁身上发生的事写在
events下面:onSpawned、onDamaged、onDeath、onCaptured和onTick。这五个都是可用的。 pal:spawn()把帕鲁放进世界,帕鲁会在 4 到 8 秒后到。传坐标就能精确放到那个点,传{ toPlayer = true, num = 3 }就直接送给玩家。那个布尔值说的是调用本身,所以反应写在onSpawned里。mesh改变帕鲁的样子,写好的网格会在pal.spawned上替你装上;pal:renderOn(actor)是手动 路线。- 只有你用
Pal{ ... }定义过的 id 才会被调用处理函数;Pal.get给你动作,但不给你事件。 onTick对每只活着的帕鲁大约每三秒运行一次,自己不记东西——要按生物记的内容,用ctx.actor作键。
接下来读 Mesh,给你的帕鲁一副自己的身体。