Building
给放置在世界里的建筑物加上行为、存档数据和你自己的模型
读完本页你可以做到
- 让游戏里的工作台、宝箱或机器带上你自己的行为
- 在建筑物被放下、被使用、被拆掉的那一刻运行你自己的代码
- 在每个建筑物上存数字,下次游玩时再找回来
- 给立在世界里的建筑物换上你自己的 3D 模型和颜色
- 让建筑物在你游玩期间按定时器干活
定义一个建筑物
建筑物就是你从建造菜单里选出来、放到世界里的东西:工作台、宝箱、机器、装饰。只要它立在那里,PalForge 就会给它一个专属的运行时对象:自己的坐标、自己的存档数据,还有你写的处理函数。
local api = require("palforge.api") -- also installs the bare globals
local Building = api.Building用 Building 做三件事:
local bench = Building{ id = "example:Bench", name = "Modded Bench" } -- define
local box = Building.get("PalBoxV2") -- look one up
local all = Building.get_all() -- every registered one必须填的字段只有 id。
Building{ id = "WorkBench" }这一次调用,就把这个 id 放进了 PalForge 的监视名单。在世界里寻找建筑物的那次扫描,只从注册表里取定义、别的地方一概不取,所以它跟踪的正好是那些被定义调用注册过的 build id。对没人定义过的 id 调用 Building.get(id),一样会给你一个能调方法的句柄,但它不注册任何东西,所以它的 :instances() 一直是空的。
完整的定义就是一张表:
local bench = Building{
id = "example:Bench",
name = "Modded Bench",
description = "A bench that counts how often it is used.",
gridCm = 100,
tickInterval = 4,
mesh = {
kind = "static",
model = "/Game/Pal/Model/Prop/Architecture/WorkBenchPrimitive/SM_WorkBenchPrimitive.SM_WorkBenchPrimitive",
},
color = { r = 0.8, g = 0.3, b = 0.1, a = 1.0 },
state = function() return { uses = 0 } end,
events = {
onPlace = function(self, ctx) self:save() end,
onRightClick = function(self, ctx)
self.state.uses = self.state.uses + 1
self:save()
end,
},
data = { tier = 2 },
}mesh 就是建好的建筑物身上穿的那个 3D 模型。它可以直接写在定义里,也可以复用一个具名的 Mesh{ ... } 定义。两种写法走同一套检查。
Building{
id = "example:Bench",
mesh = {
kind = "static",
model = "/Game/Pal/Model/Prop/Architecture/WorkBenchPrimitive/SM_WorkBenchPrimitive.SM_WorkBenchPrimitive",
},
}内联的表里不写 kind 时,会填上 Building.Spec.Mesh 的默认值 "static"。
id 的写法
不带冒号的 id 就是游戏里的 BuildObjectId 本身,比如 "WorkBench"、"PalBoxV2"、"ItemChest"。写成 "pack:name" 的 id,会变成游戏数据表里的行名 pack_name。解析出来的这个名字,既是 :unlock() 解锁的对象,也是运行时用来匹配建筑物的名字。
定义不会往建造菜单里加新条目。只靠 Lua 没法往游戏的数据表里加一行,那是 PalSchema 的活。定义做的事,是给一个已经存在的 id 加上行为、存档数据、模型和信息。
id 的形状是在你定义它的那一刻检查的,而不是等到后面有人去找这个建筑物的时候。只要出现冒号,就说明你想写的是带命名空间的形式,于是冒号两边都必须是字母、数字和下划线:
Building{ id = "my-pack:Bench" }
-- PalForge: Building: field "id" is invalid: invalid pack id 'my-pack' in 'my-pack:Bench' (letters/digits/_ only)一个连字符就让这个 id 解析不出来,而解析不出来的 id 无处可去:拼不出 DataTable 的行名,于是图标查询、科技解锁、build id 匹配全都落空,而这个定义还在注册表里看起来一切正常。唯一能让你亲眼看到这件事发生的地方就是定义调用,所以它在那里停下。
buildIds 会在晚一步的引擎边界上被问同样的问题,而且更宽容:解析不出来的条目会被按字面使用,并有一条警告点出这个 id 和 resolve 给的理由。一个对不上任何真实 BuildObjectId 的字面值只是永远匹配不到 actor,那是一个你能追查的静默落空;而把 id 丢掉,则会得到一个注册成功、每次读取都有答案、实际上却已经死掉的定义。
同一个 id 定义两次,后一次会替换前一次。native.buildings 不注册任何东西,所以定义了 WorkBench 的内容包就是它唯一的定义;但如果另一个内容包定义了同一个 id,它会替换掉你的,并且注册表会点名双方的所有者把这次冲突记进日志。
自带的目录
native/buildings.lua 把 DT_BuildObjectDataTable_Common 的全部 498 个行 id 都当作纯数据带着,另外还有两个手写的定义,多带了 mesh 和显示名。
local buildings = require("palforge.native.buildings")
buildings.WorkBench -- curated handle for "WorkBench" (BP_BuildObject_WorkBench_C)
buildings.PalBox -- curated handle for "PalBoxV2"
buildings.get("ItemChest") -- a handle, built on first read and cached; nil for an unknown id
buildings.publish("WorkBench") -- opt IN to tracking, and with it to persistence
buildings.CATALOG -- the 498 ids, as data这个模块不注册任何东西,读一个字段也不会注册任何东西。这一点在这里比在其他任何领域都重要,因为注册一个建筑物不是无害的:扫描会拾起已注册的定义,世界里每一个已经立着的匹配 actor 都会变成被追踪的实例,而被追踪的实例会被写进存档的实体文件。否则,工具提示里读一下 buildings.Stone_Foundation,就会开始给基地里每一块石头地基写记录;一个遍历 CATALOG 的选择器则会把整个基地都持久化下来。
所以从 get(id) 或具名字段拿到的句柄,:unlock() 和 :iconOf() 是真的能答的——两者都是以 id 为起点的表查询;而 :instances() 会一直是空的、什么也不写,直到你调用 buildings.publish(id),或者自己声明 Building{ id = <同一个 id> }。
Spec 字段
下面是能传给 Building{ ... } 的全部内容。大多数建筑物只要 id;想让它做点什么的时候,再加上 state 和 events。
Prop
Type
任何问题都是硬错误,所以一次调用不会只成功一半。字段名写错时,它会告诉你它期待的名字:
Building{ id = "example:Bench", grid = 100 }PalForge: Building: unknown field "grid" (did you mean "gridCm"?). Valid fields: id, name, description, gridCm, buildIds, tickInterval, mesh, material, color, texture, icon, state, events, dataPalForge: Building: field "id" is required (build id: a game BuildObjectId ("PalBoxV2") or "pack:name")
PalForge: Building: field "mesh" (Building.Spec.Mesh): field "model" is required (UStaticMesh asset path, or an OBJ path for the procedural backend)与其背下这张表,不如在游戏运行时把它打印出来:
local schema = require("palforge.core.schema")
print(schema.help("Building.Spec"))
print(schema.help("Building.Spec.Events"))
schema.get("Building.Spec").fields -- the same, as a table, for tooling第二个参数
Building(spec, opts) 接受一个可选的第二张表。它只控制注册,别的什么都不管——两种写法造出来的定义是同一个:
Building(spec) -- define and register
Building(spec, { register = false }) -- build the handle, register nothing
Building(spec, { pack = "mypack" }) -- register with that pack recorded as the owner出于上面说过的原因,register = false 在这个领域里就是「读」和「写」的分界线:注册过的定义会被追踪,被追踪的建筑物会被持久化。句柄照样能用,core/event 根本看不到这个定义,存档里也不会多出任何东西。
pack 是给冲突补上「是谁」的那一项:所有者会记在这次注册上,两个内容包争同一个 id 时警告里会点名,并且写进每一条持久化记录。PalForge.pack("mypack").Building 会替你填上它。
state —— 一张表,或者一个工厂函数
state 是新建筑物一开始带的数据。PalForge 会替你写到磁盘上,下次游玩时再交回给你。它只被读一次:扫描第一次为一个还没有保存记录的建筑物建对象的时候。
state = { uses = 0 } -- a table
state = function() return { uses = 0 } end -- a factory, called per new instance工厂函数被调用时会收到定义类作为参数,所以 state = function(cls) ... end 也能写。如果它抛错,或者返回的不是表,这个建筑物就从一张空表开始。
推荐用工厂函数。写成普通的表时,这张表存在定义上,并按引用交给每一个新建筑物 —— 同一个定义放下的两个建筑物就会共用同一张 state 表,两条保存记录也都指向它。工厂函数则给每个建筑物新建一张表。
状态按 JSON 保存,所以只放字符串、数字、true/false,以及由这些东西嵌套出来的表。恢复出来的建筑物拿到的正是当初保存下来的那条记录,默认的 state 不会被合并进去,所以你在后续版本里新加的字段需要自己兜底:
onLoad = function(self, ctx)
self.state.uses = self.state.uses or 0
self.state.tier = self.state.tier or 1 -- added in a later version of the pack
end,gridCm —— 怎么再次认出同一个建筑物
放好的建筑物自己没有一个稳定的 id,所以 PalForge 把它的世界坐标取整到一个网格单元来识别它。core/spatial 用 math.floor(v / gridCm + 0.5) 对每个轴取整,把结果写成 buildId@qx,qy,qz:
WorkBench@1234,-56,78gridCm 的默认值是 core.spatial.GRID_CM,也就是 100,一米。这个键就是保存记录的名字,所以下次游玩重新找到某个建筑物时,也是靠它把存档数据接回去。单元格小,挨得近的建筑物更容易被区分开;单元格大,则更能容忍两次游玩之间坐标的漂移。坐标落进同一个单元格的两个建筑物会撞键:扫描保留绑定到第一个有效 actor 的那个,跳过第二个。
你在游玩期间,建筑物是靠它的 actor 跟踪的,也就是游戏放进世界里的那个对象,而不是靠那个键。放好的建筑物在两次扫描之间报出的坐标会飘出一个单元格,所以按 actor 跟踪,才不会让一个建筑物变成源源不断的新建筑物。
buildIds —— 认领多个游戏 id
buildIds 是这个定义负责的游戏 build id 列表。它会替换掉默认的 { id },所以还想让 id 本身被匹配到的话,请把它也写进列表:
Building{
id = "example:Chests",
name = "Instrumented Chests",
buildIds = { "ItemChest", "ItemChest_02", "ItemChest_03" },
events = {
onRightClick = function(self, ctx)
log.info("opened " .. self.buildId) -- the id this instance matched
end,
},
}每一项都会经过 object_manager.resolve,所以 "pack:name" 会变成 pack_name,然后被索引到这个定义上。放好的建筑物会把匹配到的那个 id 记在 self.buildId 里,self.id 仍然是定义 id。
匹配按顺序试三种方式:actor 的类名 BP_BuildObject_<Id>_C,然后是 MapObjectModel.BuildObjectId,最后是拿保存记录做坐标匹配。第一种只有在 id 的拼写和蓝图里的拼写一致时才管用 —— 这就是数据表的行写作 Workbench,而现成的定义用 "WorkBench" 的原因。
mesh 和 material
Building.Spec.Mesh 就是 Mesh 里讲的那个形状,只有一处不同:kind 的默认值是 "static",不是 "skeletal"。material、color 和 texture 会叠在 mesh 声明的内容之上:Class:material() 返回声明的 material 表,你用了简写时它就用 color / texture 拼一张出来;而 Class:render() 会让 material 这边逐个字段胜出。
kind 背后的三个后端里,只有两个能真的挂到放好的建筑物上。这里的默认值 static 会加一个 UStaticMeshComponent,用 LoadAsset 找 model,失败时退回 StaticFindObject,设置好资源,再从组件上读回来确认之后才报告成功;所以在缺少这个 setter 的游戏版本上,你会诚实地拿到 false,也不会留下一个没人管的组件。procedural(也可以写成 obj)会从 model 指的磁盘路径读一个 Wavefront OBJ 文件,加一个 ProceduralMeshComponent。两个后端都会关掉碰撞,因为装饰用的碰撞体会挡住游戏放置建筑物时用的射线检测;两个也都会显式设置世界缩放,因为交给 AddComponentByClass 的空变换的缩放是从零开始的。
-- a UE-authored asset, the Building.Spec.Mesh default kind
mesh = {
kind = "static",
model = "/Game/Pal/Model/Other/PalBox/SM_PalBox.SM_PalBox",
scale = 1.0,
offset = { x = 0, y = 0, z = 50 },
},
-- an OBJ file, parsed from disk when the mesh attaches
mesh = {
kind = "procedural",
model = "C:/mods/example/models/bench.obj",
scale = 1.0,
offset = { x = 0, y = 0, z = 50 },
},上面那个绝对 OBJ 路径只在一台机器上是对的。Mesh.Spec 会把相对的 model 或 texture 解析到调用方内容包自己的目录下,但这一步解析挂在 Mesh.Spec:validate 上,而 Building.Spec.Mesh 是一个自带 validate 的派生 spec —— 所以写在内联 building mesh 里的相对路径仍然是相对的,会带着你写的那个字符串在 io.open 处失败。请把 OBJ 声明成具名的 Mesh{ ... },再把那个句柄嵌进来,那条路才带着解析好的路径。
现成的 WorkBench 和 PalBoxV2 声明的都是 static mesh。不过它们要先被注册(buildings.publish(id),或者你自己的定义)才谈得上碰到放好的 actor,之后会在第一次看到 actor 的那次扫描之后,下一次扫描时挂上。
skeletal 换的是 pawn 自己那个 Mesh 组件(ACharacter::Mesh)上的资源。build object 没有这个组件,所以声明了 kind = "skeletal" 的建筑物什么都挂不上,日志会写「这个 actor 多半不是 APalCharacter」,它的 render() 返回 false。这个后端本身在游戏里也还没被确认过:它会过实机的签名检查,设置之后还会把资源读回来比对,所以一个跑了却被忽略的 setter 得到的是 false,不是假装成功——但还没有人亲眼看到什么东西真的换了外形。
运行中的实例
在真正的建筑物立到世界里之前,你定义的东西都还没活起来。放下一个的时候,发生的事情是这样的。
模型是在建筑物出现之后的下一次扫描里才出来的,不是放下的那一刻。给一个还在自我初始化的 actor 加组件,也就是它被放下的那一帧,可能碰到无效的游戏对象,把游戏搞崩;所以建筑物会被标成待处理,等下一次扫描再看到同一个 actor 时才挂上模型。
一次扫描是这样判断自己看到的是什么的:
只有当一个新建筑物在 300 cm 以内匹配上一条同 build id 的放置请求记录时,才会发出 building.place。扫描新跟踪到的每一个建筑物随后都会发出 building.load,包括刚刚发过 building.place 的那个,ctx.reconstructed 会告诉你它是不是来自保存记录。每个键只会建一次建筑物,所以 onPlace 每次放置刚好触发一次。
实例的字段
Prop
Type
定义里声明的东西都能通过类拿到,所以 self.name、self.data、self.gridCm 这些,在放好的建筑物上也解析得出来。
实例的方法
self:save() -- stage state and write the json file now
self:setDirty() -- stage only; written by the next save or on world.left
self:isValid() -- is self.actor still a valid engine object
self:render() -- attach mesh + material once; false without a valid actor or a model
self:update() -- re-tint the live material from self:currentColor()
self:neighbors(cm) -- every OTHER tracked structure within cm of this one
self:mesh() -- the declared mesh table
self:material() -- the declared material, or one built from color / texture
self:currentColor() -- the tint update() will write; override for a state-driven look
self:iconOf() -- DT_BuildObjectIconDataTable lookup, falling back to the declared iconcurrentColor() 返回 self.color,默认就是定义里的染色。想让外观跟着状态走,就在建筑物自己身上设这个字段,然后推上去;如果颜色本来就是状态的函数,也可以在定义类上重写这个方法:
onTick = function(self, ctx)
self.color = self.state.running
and { r = 0.2, g = 0.9, b = 0.3, a = 1.0 }
or { r = 0.4, g = 0.4, b = 0.4, a = 1.0 }
self:update()
end,update() 走的是 core/mesh。core/mesh 记得每个 actor 是被哪个后端穿上的——键是 actor 的 GetFullName(),因为 UE4SS 的句柄每次查询都会新造一个——并把重新染色发回同一个后端。static、procedural、skeletal 三者都会响应:挂 mesh 时没有声明颜色的,共享材质层会当场把动态材质实例造出来。true 表示写入在一个真实的材质实例上执行了。它不是外观真的变了的证据:往一个 Palworld 材质并不带的参数名上写,是悄无声息的空操作,而且到目前为止没有人在游戏里亲眼看到染色生效。false 表示根本没有可写的对象——这个 actor 上没有我们挂的 mesh,或者没有颜色。
neighbors
self:neighbors(radiusCm) 返回这个半径内其他所有被追踪的建筑物——任何定义的都算,不只是自己这一种——形式是运行中的实例。它背后是 core/spatial 的哈希网格,而且这次调用会先把被追踪的实例重新分桶,所以移动过的建筑物也找得到。
onTick = function(self, ctx)
for _, n in ipairs(self:neighbors(350)) do -- everything within 3.5 m
log.info(self.key .. " is next to " .. n.key)
end
end,在定义(而不是实例)上调用时返回空列表,因为那里没有 self.pos;半径不是正数时也返回空列表。索引里只有 PalForge 正在追踪的建筑物,所以一个满是未注册原版 id 的基地会答空。
保存状态
记录存在每个模组一个的 JSON 文件里,而这些文件放在每个存档一个的目录中:
<UE4SS Mods>/PalForge/state/w_1DF0E44B4FDDD6196E30819A899C9009/mypack.json目录名由 core/spatial 决定:它从活着的 PalGameInstance 上读当前选中的存档,先试 GetSelectedWorldSaveDirectoryName,不行再试 GetSelectedWorldName,两者各自还有对应的支撑属性(SelectedWorldSaveDirectoryName、SelectedWorldName)作为第二次机会;拿到的名字里不是字母、数字、下划线的字符换成 _,前面再加上 w_,什么都读不到时就是 world。文件名就是你传给 PalForge.pack 的那个包 id,所以你的建筑物永远不会待在别的模组也会写的文件里。
WorldGuid、WorldSaveName 和 SaveName 并不在那个类上。PalGameInstance 完整的 111 条属性清单里一个都没有,所以基于这三个名字的探测只可能落到 world 这个兜底值——早期会话留下来的唯一产物之所以叫 entities_world.json,就是这个缘故。这里优先用存档目录名而不是显示名,是因为两个存档不可能共用同一个存档文件夹,而玩家自己敲的标签完全可能重名。
文件里每个建筑物的键对应一条记录,另外还有一个隔离区:
{
"palforge": { "format": 3, "mod": "mypack", "save": "w_1DF0E44B4FDDD6196E30819A899C9009",
"forge": "0.3.0", "wrote": 1785646408, "buildings": 1 },
"buildings": {
"WorkBench@1234,-56,78": {
"buildId": "WorkBench",
"def": "mypack:Bench",
"pos": [123400, -5600, 7800],
"state": { "uses": 3 }
}
},
"orphans": {}
}这个键是解析后的 build id 加一个单元格,所以它完全不指明所有者。所有者就是文件名,记录的形状就是头部的 format,两者都不必逐条重复。def 是认领它的定义 id,也正是它让一种很窄的改名情形能自己迁移。位置是整数厘米。布局、包在它之上拿到的 store,以及卸载模组时这一切会怎样,都在保存的状态里。
记录里的 state 和 inst.state 是同一张表,所以就地改它,就足以改变将要写出的内容。但没有东西会自己去写:
self:setDirty()只把这个建筑物所属的模组标脏。开销很小。self:save()标脏之后马上写那一个模组的文件,并返回true,或者false加一个原因。- 离开世界时会把还脏着的全部写掉,然后丢掉运行中的建筑物,保留全部记录。
重要的改动用 save(),高频循环里用 setDirty();不然每个 tick 都 save(),就会每次心跳都写一遍文件。一次写只覆盖发生了改动的那个模组:不会因为你改了东西,就把别人的文件重写一遍。
没有记录会因为「不在」而被删掉。 连续六次进行了枚举的扫描都没看到的建筑物,会以 reason = "missing" 发出 building.remove,但它的记录不是被丢掉,而是被隔离:带着 why = "missing" 挪进 orphans,而下一次再看到那个 Actor 的扫描会把它连同 state 一起取回来。那次扫描只枚举内存里的对象,而出货二进制自己的声明表明 Palworld 是按距离生成和销毁地图对象的,所以「这一遍扫描里没有」不能读成「已经没了」。
本次会话里没有任何定义认领其 build id 的记录也按同样的方式隔离,理由是 why = "unclaimed":原样搬进 orphans,带着条数和它们所属的内容包写进日志,一旦有定义重新认领那个 build id,就立刻搬回来。这一趟检查会在世界打开后等大约 30 秒才跑,好让那些在 world.ready 时、或者懒加载时才定义建筑物的内容包来得及。运行时里唯一会真的销毁记录的,是 4096 条的隔离上限,而且是按模组文件算的;超过之后从那个模组最旧的开始丢,并在日志里写明丢了多少条、属于哪个模组、为什么。
Quarantined 和 Dropped 都会留着保存记录,所以当建筑物或它的定义回来时,它会连数据一起回来。这张图里没有任何一条转移会删记录。
拿到这些实例
local event = require("palforge.core.event")
Building.get("example:Kiln"):instances() -- every live instance of one definition
event.instances() -- every live instance, any definition
event.instances("example:Kiln") -- filtered by definition id or matched build id
event.instanceOfActor(ctx.actor) -- the instance bound to an actor, or nil
event.isWorldReady() -- has the world finished loading扫描跑起来之前 :instances() 都是空的,而扫描需要一个加载完的世界:一个监视每秒查一次有没有有效的 PalPlayerCharacter,连续成功五次之后才放行。
这些东西都能挺过一次热重载。建筑物运行时把定义索引、运行中的实例和 actor 索引放在 _G 上的一张表里,store 把合并后的记录视图放在另一张表里,而 core/reload 在清空模块时会保留 object_manager,所以按下 F9 之后 :instances() 照样有答案,建筑物的钩子也照样在你已有的定义上触发。这两半必须一起在:一次把注册表丢掉的重载,在隔离检查看来就等于所有内容包同时被卸载——大约在按下之后 30 秒。
生命周期事件
处理函数写在 events 下面。第一个参数就是事件发生在其上的那个建筑物,所以 self.state、self.pos 和 self.actor 直接就能用。唯一的例外是 onBuild,因为它触发时世界里还没有东西立着。
events = {
onRightClick = function(self, ctx) -- self is a Building.Instance
log.info(self.key .. " at " .. tostring(self.pos.x))
end,
}| 事件 | 频道 | 触发 | ctx |
|---|---|---|---|
onPlace | building.place | LIVE | key, actor, pos, buildId, player, firstSeen |
onLoad | building.load | LIVE | key, actor, pos, buildId, reconstructed |
onRightClick | building.interact | LIVE | actor, player, buildId |
onRemove | building.remove | LIVE | key, buildId, actor, reason |
onTick | tick | LIVE | count, now |
onBuild | building.build | LIVE,世界加载完之后 | buildId, model |
onWorldReady | world.ready | LIVE | 空表 |
onWorldLeft | world.left | LIVE | 空表 |
onLeftClick | — | 从不触发 | — |
onBreak | — | 从不触发 | — |
onLeftClick 和 onBreak 可以写,但没有任何地方发出它们,所以它们永远不会运行。这是查清楚之后的否定结论,不是「还没找到」——见下面的 不存在的东西。交互用 onRightClick,建筑物消失用 onRemove。
不存在的东西,以及这是怎么查清楚的
这两个缺席的钩子,是靠通读每一个可能拥有它的类的完整函数清单找出来的,所以这是一次测量,而不是一个还没填的坑。
PalBuildObject 上没有点击、命中或者敲打的条目。 它反射出来的 22 个函数已经完整地躺在磁盘上。唯一像输入的条目是交互那一家子——OnBeginInteractBuilding、OnTriggerInteractBuilding、OnStartTriggerInteractBuilding、OnEndTriggerInteractBuilding——那是右键,而且已经是 onRightClick 了。唯一像伤害的条目 OnDamage 曾经是最被看好的候选,但它不是敲打:一次录制到的会话里,它以每个建筑物 12–13 秒的严格节奏触发了 196 次,全程没有任何玩家在附近。t=306.412 放下的工作台在 t=306.933 收到第一次,之后 2250 秒里又收到 180 次也没有坏掉。它是耐久劣化的计时器;把 onLeftClick 接到它上面,就等于让基地里每一个建筑物每十二秒调用一次你的处理函数,永远如此。
破坏只以委托「字段」的形式存在。 PalBuildObject(22 个函数)、PalMapObjectModel(18 个)、PalMapObjectConcreteModelBase(25 个)和 PalNetworkPlayerComponent(77 个)里,没有任何一个带 Destroy / Dismantle / Demolish / Deconstruct / Break 的函数。真正存在的是 PalMapObjectModel:OnDestroyDelegate 和 :OnDisposeDelegateInServer,而 RegisterHook 没法按路径挂一个委托字段。PalBuildObject.OnChangeVisualForDismantle 是拆除的预览显示,不是拆除完成。
所以一个建筑物消失只有一条浮出水面的路:扫描的漏检清扫,表现为 ctx.reason = "missing" 的 onRemove。它分不清是被拆了还是流送出去了,而且晚六次扫描才到。这两个钩子仍然可以声明,是为了让你自己的 emit 能用,也为了将来真找到来源时有个落点;今天没有任何东西发出它们。转储唯一没覆盖到的地方是 BP_BuildObject_<Id>_C 子类里的图表事件——转储只覆盖 /Script/Pal.*。
onPlace
在发现新 actor、并把它和 RequestBuild_ToServer 钩子记下的放置请求匹配上的那次扫描里,触发一次。ctx.player 是钩子记录请求时看到的 PalPlayerCharacter,ctx.firstSeen 恒为 true,ctx.pos 是 actor 的真实坐标,不是请求里的那个。
onPlace = function(self, ctx)
self.state.owner = tostring(ctx.player)
self:save()
end,onLoad
扫描开始跟踪的每一个建筑物都会触发,包括刚刚触发过 onPlace 的那个。状态来自保存记录时 ctx.reconstructed 是 true,全新的建筑物则是 false。每个建筑物各自的初始化写在这里。
onLoad = function(self, ctx)
if ctx.reconstructed then
log.info(self.key .. " restored with " .. tostring(self.state.uses) .. " use(s)")
end
self:render() -- normally unnecessary; the scan renders on its next pass
end,onRightClick
由 PalBuildObject:OnBeginInteractBuilding 驱动。只有 PalCharacter 的子类算作发起交互的一方,所以建筑物之间的交互传不到你这里;对同一个 actor 的重复交互,一秒之内会被忽略。建筑物是从 ctx.actor 找出来的。
onRightClick = function(self, ctx)
Item.get("Wood"):give(1)
log.info(tostring(ctx.player) .. " used " .. self.buildId)
end,onRemove
ctx.reason 是 "missing",也是运行时目前给出的唯一原因。你的处理函数运行时,建筑物还在被跟踪,所以 self.state 和 self.pos 都读得到;记录紧接着就会被删掉。
onRemove = function(self, ctx)
log.info(string.format("%s gone after %d use(s)", self.key, self.state.uses or 0))
end,onTick
心跳是 LoopAsync(500),发布在 tick 频道上,ctx.count 是心跳的编号。只有定义里真的写了 onTick 的建筑物才会进 tick 名单,所以不写就不花任何开销。
tickInterval 是这个计数的除数:ctx.count % tickInterval == 0 时处理函数才运行。
Building{
id = "example:Kiln",
tickInterval = 20, -- 20 heartbeats -> about 10 seconds
events = {
onTick = function(self, ctx)
if not self:isValid() then return end
log.info("tick " .. tostring(ctx.count))
end,
},
}不是整数、或者小于 1 的 tickInterval,会被抬回 1。
onTick 有熔断。每次失败都会记日志并计数;失败五次之后,这个建筑物会被标成坏掉,余下的生命里不再 tick —— 也就是本次游玩剩下的时间,除非它被拆掉又被重新找到。成功一次,计数就清零。处理函数请写得防御一点 —— 碰 self.actor 之前先查 self:isValid()。
onBuild
游戏建完一个 build id 归这个定义认领的建筑物时触发。它最多比放好的建筑物早到一次扫描,而且游戏交过来的是 UPalMapObjectModel 而不是 actor,所以这里的 self 是定义,不是放好的建筑物:self.id、self.name、self.data 和 self:iconOf() 都在,self.actor、self.pos、self.state 和 self:save() 不在。
Building{
id = "example:Bench",
events = {
onBuild = function(self, ctx)
log.info(self.id .. " completed as " .. tostring(ctx.buildId))
end,
},
}onBuild 要等世界加载完之后才开始监听。它的原生钩子 PalPlayerRecordData:OnCompleteBuild_ServerInternal 在世界加载那一波里,对已经存在的每一个建筑物也会触发,而在那里读一个只建了一半的 UPalMapObjectModel,会以 Lua 抓不住的方式崩掉。由此有两件事。世界始终没加载完的那次游玩里,onBuild 一次都不会运行。而在同一次游玩里第二次加载世界时,它已经在监听了,所以那一波里它会被调用。放置检测还是 onPlace 更安全;onBuild 请先在一个可以丢掉的世界里试。
onWorldReady 和 onWorldLeft
两个都会送到每一个运行中的建筑物上,而且都是世界加载这一层的事件,不是每个建筑物各自的事件。
world.ready 来自把 actor 变成运行中建筑物的那次扫描,不是世界打开的那一刻。所以你的处理函数运行时,玩家周围的建筑物已经被跟踪了,它们的 onLoad 也在同一趟里跑完了。世界打开之后最多等一次扫描,也就是 500 ms。
Building{
id = "WorkBench",
name = "Workbench",
state = function() return { uses = 0, sessions = 0 } end,
events = {
onLoad = function(self, ctx)
self.state.uses = self.state.uses or 0 -- per-instance startup
end,
onWorldReady = function(self, ctx)
self.state.sessions = (self.state.sessions or 0) + 1
self:save()
log.info(string.format("%s present at load %d, %d use(s)",
self.key, self.state.sessions, self.state.uses))
end,
},
}onWorldReady 每次加载世界只触发一次,对象是第一次扫描找到的那些建筑物。后面的扫描才流进来的建筑物收不到,你在本次游玩中放下的也收不到。每个建筑物各自的初始化归 onLoad 管,它对扫描跟踪到的每一个建筑物都会触发。第一次扫描完成之前就离开世界,这次通知会被取消,所以只是路过一下的世界不会宣告自己准备好了。
onWorldLeft 在建筑物还活着、还没被丢掉的时候运行,所以那是碰状态的最后机会 —— 不过紧接着世界缓存就会被自动写出去。
直接订阅这些频道
PalForge 就是靠监听这些频道来调用你的处理函数的,你也可以自己监听。适合写横跨很多建筑物的逻辑:
local event = require("palforge.core.event")
event.on("building.place", function(ctx)
log.info("placed " .. tostring(ctx.buildId) .. " -> " .. tostring(ctx.key))
end)
event.observable("building.interact")
:filter(function(ctx) return ctx.buildId == "PalBoxV2" end)
:subscribe(function(ctx) log.info("pal box opened") end)
local sub = event.every(5000, function() log.info("five seconds") end)
sub:unsubscribe()event.every(ms, fn) 和扫描本身一样,会被对齐到 500 ms 的心跳上。
句柄的操作与查询
Building{ ... }、Building.get 和 Building.get_all 都会给你一个 Building.Handle。它代表这个定义:用它读回你声明的内容,也用它拿到从这个定义放下的建筑物。
local box = Building.get("PalBoxV2")
box:name() --> "Pal Box" for the curated definition, else the id
box:description() --> the declared description, or nil
box:gridCm() --> the declared quantum, or nil when the runtime default applies
box:mesh() --> the declared mesh table, or nil
box:iconOf() --> a texture ref from DT_BuildObjectIconDataTable, else the declared iconunlock
Building.get("example:Bench"):unlock()通过 PalCheatManager:UnlockOneTechnology(FName),解锁以解析后的 id(example_Bench)命名的科技行。由 PalSchema 注入的建筑物科技,就是这样进到建造菜单里的。
从来没有人观测到它起作用,而且它在结构上就无法验证。 true 并不表示科技解锁了。它表示两件读得到的事:作弊调用跑完了没有抛异常,并且在活着的 DT_TechnologyRecipeUnlock 里确实存在一行叫那个解析后名字的科技。后一项检查的作用,是不让作弊在一个根本没有科技可解锁的建筑物上「成功」,这也是 Building.get("PalBoxV2"):unlock() 返回 false 的原因(原版 501 个 build id 里只有 115 个有这样一行)。
读不到的是结果。UnlockOneTechnology 什么都不返回,而这个版本上任何地方都没有「这个科技解锁了吗」的访问器——作弊管理器的接口上没有,头文件转储里没有,道具那条桥上也没有。而且它走的正是那条被测出「接受调用然后悄悄什么都不做」的作弊管理器路径,这个调用没法把自己和那种失败区分开。
唯一能定论的办法,是在一个存档里按下去,然后去看建造菜单。那就是声明好的钩子 pf_hook building-unlock:它需要世界和玩家,并且算写操作,因为它会改动玩家的科技状态。
false 还有另外两种来源,各有各的日志行:科技表根本读不出来,以及压根没有可调用的作弊管理器——这条路径只用已经存在的那个,不会自己造一个。
instances、render、update
local kiln = Building.get("example:Kiln")
#kiln:instances() --> how many are placed in this world
kiln:render() --> attach the mesh to every live instance; returns how many attached
kiln:update() --> re-tint every live instance; returns how many were re-tinted这两个数都来自每个建筑物那个方法自己的返回值,不是看调用有没有活着回来。后端拒绝处理的建筑物不算数:mesh 里没有 model、资源解析不出来、kind 没法给 build object 穿衣服、没有颜色可涂、没有可写入的运行中材质实例。所以 :instances() 非空而 render() 返回 0,意思是什么都没挂上,不是没抛异常。
render() 平时用不上 —— 扫描会在第一次看到 actor 之后的下一趟里把 mesh 挂上。只有在游戏运行时改了 mesh() 的返回内容之后,才需要它。
事件转发器
句柄上还有 :onPlace(ctx)、:onLoad(ctx)、:onRightClick(ctx)、:onLeftClick(ctx)、:onBuild(ctx)、:onBreak(ctx)、:onRemove(ctx) 和 :onTick(ctx),你可以自己运行某个处理函数。
转发器调用处理函数时,self 是定义类,不是放好的建筑物。那里没有 self.state、self.actor、self.key,也没有 self:save()。这一点只有 onBuild 和真实事件一致,别的都不一致,所以转发器只用来单独测试一个处理函数;其他场合请用真实事件,或者用 :instances() 拿到的实例。
实用示例
关掉游戏也还在的计数器
放一个工作台,用几次,退出游戏,再回来 —— 计数还在。
local api = require("palforge.api")
local log = require("palforge.utils.log").scope("counter")
local Building = api.Building
return Building{
id = "WorkBench",
name = "Workbench",
gridCm = 100,
state = function() return { uses = 0 } end,
events = {
onLoad = function(self, ctx)
self.state.uses = self.state.uses or 0 -- records saved before the field existed
log.info(string.format("%s reconstructed=%s uses=%d",
self.key, tostring(ctx.reconstructed), self.state.uses))
end,
onRightClick = function(self, ctx)
self.state.uses = self.state.uses + 1
self:save()
log.info(self.key .. " used " .. self.state.uses .. " time(s)")
end,
onRemove = function(self, ctx)
log.info(string.format("%s removed after %d use(s), reason %s",
self.key, self.state.uses or 0, tostring(ctx.reason)))
end,
},
}计数能留下来,是因为 onRightClick 调了 save()。不调的话,改动在内存里照样看得见,也会在下一次有什么东西刷写世界文件时被写出去,但这不是值得依赖的保证。
按定时器消耗道具的机器
右键切换开关。运转时每十秒吃掉一个 Wood,每三个 Wood 产出一个 Charcoal。
local api = require("palforge.api")
local log = require("palforge.utils.log").scope("kiln")
local Building, Item = api.Building, api.Item
local WOOD_PER_CHARCOAL = 3
return Building{
id = "example:Kiln",
name = "Slow Kiln",
description = "Burns wood into charcoal on its own.",
gridCm = 100,
tickInterval = 20, -- 20 heartbeats -> about 10 seconds
state = function() return { wood = 0, charcoal = 0, running = true } end,
events = {
onPlace = function(self, ctx)
log.info("kiln built at " .. self.key)
self:save()
end,
onRightClick = function(self, ctx)
self.state.running = not self.state.running
self.color = self.state.running
and { r = 0.9, g = 0.4, b = 0.1, a = 1.0 }
or { r = 0.3, g = 0.3, b = 0.3, a = 1.0 }
self:update()
self:save()
log.info(self.key .. " running=" .. tostring(self.state.running))
end,
onTick = function(self, ctx)
if not (self.state.running and self:isValid()) then return end
if not Item.get("Wood"):take(1) then return end
self.state.wood = self.state.wood + 1
if self.state.wood >= WOOD_PER_CHARCOAL then
self.state.wood = self.state.wood - WOOD_PER_CHARCOAL
self.state.charcoal = self.state.charcoal + 1
Item.get("Charcoal"):give(1)
log.info(string.format("%s produced charcoal #%d", self.key, self.state.charcoal))
end
self:setDirty() -- staged; the next save or the world unload writes it
end,
},
}:take 走游戏自己的服务器路径来挪动数量,返回的是这次调用有没有执行,不是玩家是不是真有那么多。机器需要真实的收支时,请在 state 里自己记下消耗了多少。
交互时生成一只帕鲁的建筑物
local api = require("palforge.api")
local log = require("palforge.utils.log").scope("summon")
local Building, Pal = api.Building, api.Pal
local COOLDOWN_TICKS = 60 -- heartbeats -> about 30 seconds
return Building{
id = "PalBoxV2",
name = "Pal Box",
gridCm = 100,
mesh = {
kind = "static",
model = "/Game/Pal/Model/Other/PalBox/SM_PalBox.SM_PalBox",
},
state = function() return { summons = 0, cooldown = 0 } end,
events = {
onTick = function(self, ctx)
if (self.state.cooldown or 0) > 0 then
self.state.cooldown = self.state.cooldown - 1
end
end,
onRightClick = function(self, ctx)
if (self.state.cooldown or 0) > 0 then
log.info("on cooldown for " .. self.state.cooldown .. " more tick(s)")
return
end
local at = { x = self.pos.x, y = self.pos.y + 300, z = self.pos.z + 100 }
if Pal.get("ChickenPal"):spawn(at) then -- issued; the pal arrives seconds later
self.state.summons = self.state.summons + 1
self.state.cooldown = COOLDOWN_TICKS
self:save()
log.info(string.format("summon #%d from %s", self.state.summons, self.key))
end
end,
},
}那个 if 在生成调用发出去的当下就会执行它的内容,而帕鲁要好几秒之后才到 —— 所以计数和冷却先动,
生物随后才来。在这里这个顺序是对的:这个台子已经把这次召唤定下来了。native/buildings.lua 不注册
任何东西,所以这就是 PalBoxV2 唯一的定义;这里把 mesh 重新写了一遍,是因为一个定义只带它自己声明
的东西。原版的帕鲁盒子界面照常打开 —— 交互钩子只是看着这次调用,并不会把它吞掉。
小结
- 写
Building{ ... }定义一个建筑物。只有id是必填的,而且它得是游戏里已经有的 id。 - 想被记住的数据放进
state,就地改,然后调self:save()。 - 处理函数写在
events里,第一个参数是放好的建筑物,所以self.state、self.pos和self.actor直接就能用。 onPlace、onLoad、onRightClick、onRemove、onTick、onBuild、onWorldReady和onWorldLeft都会运行。onLeftClick和onBreak永远不会,而这是测出来的,不是还没做。- 建筑物是靠取整后的世界坐标重新找到的,所以
gridCm决定了两个建筑物能挨多近。那个键指向的记录里写着拥有它的定义,而它被写进的那个文件本身就是所属模组。 - 注册一个建筑物就是一次写入:只有注册过的定义会被追踪,只有被追踪的建筑物会被持久化。
native/buildings.lua在你publish之前不注册任何东西。 Building.get(id):instances()会给你这个定义此刻立在世界里的全部建筑物,而self:neighbors(cm)会给某一个建筑物它周围的那些。
接下来读 Item,了解 :give 和 :take —— 建筑物就是靠它们把东西交给玩家的。