PalForge
API 参考

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() 一直是空的。

完整的定义就是一张表:

content/buildings/bench.lua
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.luaDT_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;想让它做点什么的时候,再加上 stateevents

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, data
PalForge: 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/spatialmath.floor(v / gridCm + 0.5) 对每个轴取整,把结果写成 buildId@qx,qy,qz

WorkBench@1234,-56,78

gridCm 的默认值是 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"materialcolortexture 会叠在 mesh 声明的内容之上:Class:material() 返回声明的 material 表,你用了简写时它就用 color / texture 拼一张出来;而 Class:render() 会让 material 这边逐个字段胜出。

kind 背后的三个后端里,只有两个能真的挂到放好的建筑物上。这里的默认值 static 会加一个 UStaticMeshComponent,用 LoadAssetmodel,失败时退回 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 会把相对的 modeltexture 解析到调用方内容包自己的目录下,但这一步解析挂在 Mesh.Spec:validate 上,而 Building.Spec.Mesh 是一个自带 validate 的派生 spec —— 所以写在内联 building mesh 里的相对路径仍然是相对的,会带着你写的那个字符串在 io.open 处失败。请把 OBJ 声明成具名的 Mesh{ ... },再把那个句柄嵌进来,那条路才带着解析好的路径。

现成的 WorkBenchPalBoxV2 声明的都是 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.nameself.dataself.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 icon

currentColor() 返回 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/meshcore/mesh 记得每个 actor 是被哪个后端穿上的——键是 actor 的 GetFullName(),因为 UE4SS 的句柄每次查询都会新造一个——并把重新染色发回同一个后端。staticproceduralskeletal 三者都会响应:挂 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,两者各自还有对应的支撑属性(SelectedWorldSaveDirectoryNameSelectedWorldName)作为第二次机会;拿到的名字里不是字母、数字、下划线的字符换成 _,前面再加上 w_,什么都读不到时就是 world。文件名就是你传给 PalForge.pack 的那个包 id,所以你的建筑物永远不会待在别的模组也会写的文件里。

WorldGuidWorldSaveNameSaveName 并不在那个类上。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,以及卸载模组时这一切会怎样,都在保存的状态里。

记录里的 stateinst.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 条的隔离上限,而且是按模组文件算的;超过之后从那个模组最旧的开始丢,并在日志里写明丢了多少条、属于哪个模组、为什么。

QuarantinedDropped 都会留着保存记录,所以当建筑物或它的定义回来时,它会连数据一起回来。这张图里没有任何一条转移会删记录。

拿到这些实例

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.stateself.posself.actor 直接就能用。唯一的例外是 onBuild,因为它触发时世界里还没有东西立着。

events = {
    onRightClick = function(self, ctx)   -- self is a Building.Instance
        log.info(self.key .. " at " .. tostring(self.pos.x))
    end,
}
事件频道触发ctx
onPlacebuilding.placeLIVEkey, actor, pos, buildId, player, firstSeen
onLoadbuilding.loadLIVEkey, actor, pos, buildId, reconstructed
onRightClickbuilding.interactLIVEactor, player, buildId
onRemovebuilding.removeLIVEkey, buildId, actor, reason
onTicktickLIVEcount, now
onBuildbuilding.buildLIVE,世界加载完之后buildId, model
onWorldReadyworld.readyLIVE空表
onWorldLeftworld.leftLIVE空表
onLeftClick从不触发
onBreak从不触发

onLeftClickonBreak 可以写,但没有任何地方发出它们,所以它们永远不会运行。这是查清楚之后的否定结论,不是「还没找到」——见下面的 不存在的东西。交互用 onRightClick,建筑物消失用 onRemove

不存在的东西,以及这是怎么查清楚的

这两个缺席的钩子,是靠通读每一个可能拥有它的类的完整函数清单找出来的,所以这是一次测量,而不是一个还没填的坑。

PalBuildObject 上没有点击、命中或者敲打的条目。 它反射出来的 22 个函数已经完整地躺在磁盘上。唯一像输入的条目是交互那一家子——OnBeginInteractBuildingOnTriggerInteractBuildingOnStartTriggerInteractBuildingOnEndTriggerInteractBuilding——那是右键,而且已经是 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 是钩子记录请求时看到的 PalPlayerCharacterctx.firstSeen 恒为 truectx.pos 是 actor 的真实坐标,不是请求里的那个。

onPlace = function(self, ctx)
    self.state.owner = tostring(ctx.player)
    self:save()
end,

onLoad

扫描开始跟踪的每一个建筑物都会触发,包括刚刚触发过 onPlace 的那个。状态来自保存记录时 ctx.reconstructedtrue,全新的建筑物则是 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.stateself.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.idself.nameself.dataself:iconOf() 都在,self.actorself.posself.stateself: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。

content/buildings/sessions.lua
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.getBuilding.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 icon

unlock

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.stateself.actorself.key,也没有 self:save()。这一点只有 onBuild 和真实事件一致,别的都不一致,所以转发器只用来单独测试一个处理函数;其他场合请用真实事件,或者用 :instances() 拿到的实例。

实用示例

关掉游戏也还在的计数器

放一个工作台,用几次,退出游戏,再回来 —— 计数还在。

content/buildings/counter.lua
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。

content/buildings/kiln.lua
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 里自己记下消耗了多少。

交互时生成一只帕鲁的建筑物

content/buildings/summon_post.lua
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.stateself.posself.actor 直接就能用。
  • onPlaceonLoadonRightClickonRemoveonTickonBuildonWorldReadyonWorldLeft 都会运行。onLeftClickonBreak 永远不会,而这是测出来的,不是还没做。
  • 建筑物是靠取整后的世界坐标重新找到的,所以 gridCm 决定了两个建筑物能挨多近。那个键指向的记录里写着拥有它的定义,而它被写进的那个文件本身就是所属模组。
  • 注册一个建筑物就是一次写入:只有注册过的定义会被追踪,只有被追踪的建筑物会被持久化。native/buildings.lua 在你 publish 之前不注册任何东西。
  • Building.get(id):instances() 会给你这个定义此刻立在世界里的全部建筑物,而 self:neighbors(cm) 会给某一个建筑物它周围的那些。

接下来读 Item,了解 :give:take —— 建筑物就是靠它们把东西交给玩家的。

On this page