PalForge
API 参考

UI

用游戏自己的控件声明一块面板,决定它挂在哪里、从玩家那里拿走什么、以及按键归谁

读完本页你可以做到

  • 把面板声明成一棵节点树,文件里的嵌套就是画面上的嵌套
  • 把它挂进游戏自己的游戏内界面、你自己的视口层,或者游戏已经在画的某个画面
  • 让它显示的内容保持最新,并且清楚它最多能旧到什么程度
  • 拿走鼠标指针或者游戏自己的点击模式,同时不弄坏 Esc
  • 声明哪块面板在最上面,以及按键会落到谁身上
  • 往标题画面菜单里加自己的项目,用完再全部收走

第一个面板

界面元素就是你画到画面上的任何东西:一行文字、一个按钮、一整块面板。用 UI{ ... } 描述一个,它会用 Palworld 自带的界面控件搭出来。

说清楚一个元素要搭什么,有两种写法,而且互斥:声明一棵 root 树,或者写一个 render 函数。通常用树。

content/ui/panel.lua
local UI = require("palforge.api.ui")   -- or use the global UI installed by palforge.api
local VBox, Label, Button = UI.VBox, UI.Label, UI.Button

local Supplies = UI{
    id   = "example:Supplies",
    host = "game",                       -- the game's own in-game UI root canvas
    root = VBox{ padding = 12,
        Label{ text = "Supplies" },
        Label{ name = "count", text = function(self) return "Wood x" .. self.wood end },
        Button{ text = "Take one",
                onClick = function(self) self.wood = self.wood - 1 end },
    },
}

Supplies:new{ wood = 10 }:autoMount(nil, 2000)

另一条缝是 render。它把一个宿主面板和一双自由的手交给你,需要跟游戏自己的控件树打交道的元素只能这么写。

local Panel = UI{
    id          = "example:Panel",
    name        = "Example Panel",
    description = "One line of text.",

    render = function(self, root)
        -- build the widget tree under `root`; runs once per mount
        -- return false if you could not build: the element then stays unmounted
    end,

    update  = function(self) end,   -- reflect changed state into the widgets render built
    destroy = function(self) end,   -- remove them again
}

同时声明 root 和 render 会在定义时直接报错。它们是同一个问题的两个答案,而 render 每次挂载正好跑一次,所以两者不管谁先谁后,总会让某个人意外。

要用到别的文件声明的元素,就按它的 id 去查:

UI.get("palforge:Button")   -- a handle for an element defined elsewhere; never nil
UI.get_all()                -- every registered element, as a list of handles

声明一棵树

节点构造器接受一张声明式的表,返回一份不做任何事的数据。在有东西去挂载这棵树之前,一次引擎调用都不会发生 —— 所以一块声明好的面板可以在完全不启动游戏的情况下构建、嵌套和检查。

子节点按位置写。 表的数组部分是子节点,具名部分是字段。为别的代码生成出来的树,也接受 children = { ... },但两者同时写是直接报错,而不是合并。

只有两个字段可以写成函数:text 和 visible。函数就是一条绑定 —— 树构建时用元素实例作参数调用它,每次 :refresh() 都重新求值,只有值变了才写回去。这两个正是一块活面板真正会改的东西;其余字段只写一次,所以对一块没变化的面板做心跳刷新,代价是比较,而不是每个节点一次原生调用。

校验发生在每个调用点,由内往外,所以被指出问题的是你写错的那个节点,而不是包着它的面板。Label{ tetx = "x" } 是一条带「你是不是想写」的错误,而不是一个永远不出声的标签。

content/ui/status.lua
local UI = require("palforge.api.ui")
local Frame, Border, SizeBox, VBox, HBox = UI.Frame, UI.Border, UI.SizeBox, UI.VBox, UI.HBox
local Label, Button, Sprite = UI.Label, UI.Button, UI.Sprite

local Status = UI{
    id   = "example:Status",
    host = "game",
    data = { wood = 0, open = true },

    -- The game's own window chrome, a colour of our own inside it, a fixed size around that.
    root = Frame{
        Border{ color = { 0.10, 0.09, 0.08, 0.98 },
            SizeBox{ width = 420, height = 180,
                VBox{ padding = 12,
                    Label{ text = "Supplies", size = 20, native = true },
                    HBox{
                        Sprite{ icon = "Wood" },
                        Label{ name = "count",
                               text    = function(self) return "x" .. self.wood end,
                               visible = function(self) return self.open end },
                    },
                    Button{ text = "Close", onClick = function(self) self:unmount() end },
                },
            },
        },
    },
}

绑定和 onClick 里的 self 是元素的实例,也就是那个可以挂载的对象本身:一个关闭按钮就是 self:unmount(),一个改变兄弟标签内容的按钮就是对 self 的一次赋值。

声明了 name = "..." 的节点,之后可以用 handle:find("count") 拿到活的控件;树构建之前和撤下之后都是 nil。这是从声明式树里跳出去的命令式出口,从那之后你要负的责任和写 render 时一样。

十一种节点

构造器搭出什么子节点
UI.VBox一个 VerticalBox任意多个
UI.HBox一个 HorizontalBox任意多个
UI.Overlay一个 Overlay,子节点互相叠着任意多个
UI.ScrollBox一个 ScrollBox任意多个
UI.Border一个用你选的颜色画的 Border正好一个
UI.SizeBox一个固定宽高的 SizeBox正好一个
UI.Frame游戏自己的窗口外框 WBP_PalCommonWindow_C正好一个
UI.Label一个 TextBlock,或者游戏自己的 BP_PalTextBlock_C无
UI.Button游戏自己的按钮,点击走它的 CommonButtonBase无
UI.Sprite一个装着贴图的 UImage无
UI.GameWidget游戏自带的任意 Blueprint 控件,按类路径取无

给一个节点塞超过它能装的子节点,会在调用点报错:UI.Label{ ... } 带子节点时会列出哪些节点能装子节点,UI.Border{ a, b } 会告诉你把它们放进 VBox 或 HBox。

每个节点还带下面这五个字段,排在该种类自己的字段后面。其中四个是父级那半边的布局信息:在 UMG 里插槽属于父面板,但「这个放左边」是关于子节点的事实,而且子节点列表是唯一能写下它的地方。

Prop

Type

对齐走 core/signature,所以没有声明对齐的插槽类会记下一条指名道姓的拒绝,而不是抛异常。box、overlay、border 和 size 的插槽都声明了 SetHorizontalAlignment,CanvasPanelSlot 没有 —— 所以给 host = "game" 那棵树的直接子节点写 hAlign 会被拒绝,并说明那个插槽实际声明了什么。

按种类值得知道的字段:

节点字段
Bordercolor = { r, g, b, a },取值 0..1;省略就是 PalForge 自己的深色面板色
SizeBoxwidth、height,单位是 slate —— Sprite 就靠它定尺寸
Frame没有自己的字段。color 会被拒绝:Frame 穿的是游戏自己的窗口贴图,这里没有任何东西能给它上色
Labeltext(可绑定)、size、color、native
Buttontext(可绑定)、onClick、labelAlign
Spritepath 或 icon + from、matchSize、color、opacity
GameWidgetclass(必填)、text + textChild、onClick + clickChild

其中有几条是量出来的结论,不是偏好:

  • Label{ native = true } 搭的是 BP_PalTextBlock_C,一个 UPalTextBlockBase,Palworld 的字体缩放、界面设置联动和本地化都在那里。它会忽略 color(文字颜色由游戏自己的文本样式决定),size 走 UpdateFontSize —— 这是整棵树里唯一一个吃普通 int 而不是结构体的字体调用。这个类在世界里常驻,但标题画面不保证有,所以搭不出原生标签时会退回普通标签,而且这次替换会记进日志。
  • Sprite 没有宽高,这是量出来的而不是漏掉的:这个版本的 UImage 没有声明 SetBrushSize,画刷的 ImageSize 是结构体写入,SetDesiredSizeOverride 吃 FVector2D。所以一张 sprite 要么取贴图自己的像素尺寸(默认的 matchSize),要么交给已经会定尺寸的节点:SizeBox{ width = 48, height = 48, Sprite{ icon = "Wood", matchSize = false } }。
  • Sprite{ icon = "Wood" } 通过 core/icons 到游戏自己的图标 DataTable 里查这个 id,覆盖率是量过的:帕鲁 674/674 行、道具 1183/1207 行、建筑 567/571、伙伴技能 311/311。查不到几乎总是 id 拼错了,而且 id 区分大小写。
  • GameWidget 的 text 在没有 textChild 时什么都不做,onClick 在没有 clickChild 时也什么都不做。这个节点克隆的是它猜不出内部结构的控件,所以两者都只送到你点名的那个子控件,别处都不去。

游戏菜单按钮的标签没法通过它的插槽做左对齐。一次探针读了按钮自己的模板树并报告:它内层的 HorizontalBox_0 待在一个 CanvasPanelSlot 里 —— 六个插槽类里唯一一个没有声明 SetHorizontalAlignment 的(UMG.hpp:350-374)—— 所以 core/signature 每次都正确地拒绝了那个调用,标签一直是居中的。CanvasPanelSlot 确实声明的那个对齐 SetAlignment 吃 FVector2D,而结构体参数正是会在 UE4SS 编组内部炸掉、pcall 又看不见的那种形状。那个尝试过的辅助函数被删掉了 —— 与其每次搭按钮都往日志里写一条拒绝。

Button{ labelAlign = "left" } 是另一种搭法,不是对同一条路的第二次尝试:一个 Overlay 装着被拉满的游戏按钮,上面盖一个我们自己的 TextBlock,可见性设成 ESlateVisibility 的 HitTestInvisible,所以点击全部穿过去落在下面的按钮上;而 OverlaySlot 是声明了对齐调用的。它之所以要显式开启,代价就在这里:文字是我们自己的,按钮的字体、悬停状态和本地化一样都继承不到。

传给 UI 的字段

必填的只有 id。让元素真正有用的是 root 或 render。

Prop

Type

各份副本之间会变的值不要放这里。把它们传给 :new{ label = "OK", onClick = fn },在 render / update / destroy 或绑定里读成 self.label。要让每份副本共享的默认值才用 data。

同一份字段清单可以在游戏里打印出来,而且它和你的编辑器补全读的是同一批字符串:

local schema = require("palforge.core.schema")
print(schema.help("UI.Spec"))         -- every field, its type and meaning
schema.get("UI.Spec").fields          -- the same as a table, for tooling
print(schema.help("UI.Node.Label"))   -- and one per node kind

和 PalForge 每个领域构造器一样,UI 也接受一个可选的第二参数:

UI(spec, { register = false })   -- build the class, hand it back, register nothing
UI(spec, { pack = "mypack" })    -- register under that pack id

-- the same call with the pack filled in for you
local UI = PalForge.pack("mypack").UI

register = false 是目录访问器需要的:一次读取顺手造出的句柄,不能把 id 从还没声明它的整合包手里抢走。pack 让 id 冲突变得可追责 —— 注册仍然是后来者胜,但警告会点名之前的主人和现在的主人,而不是让一个元素悄悄消失。

id 本身在定义时就按 id 解析所要求的形状检查。冒号两边不是字母、数字或下划线的 id,在每一个引擎边界上都解析不到任何东西:它会注册成功,在 UI.get_all() 里看着很健康,然后悄悄地死着。

PalForge: UI "my-pack:Panel": invalid pack id 'my-pack' in 'my-pack:Panel' (letters/digits/_ only)

挂在哪里:host

mount(root) 仍然接受一个显式的 root,并且优先于 host。两者都没有的元素哪儿也挂不上,而且会说出来::lastError() 里带着上一次尝试放弃的原因。

host挂到哪儿
"screen"我们自己的视口层,按 AddToViewport 的 1000 加上声明的 z 叠放
"game"游戏自己的游戏内界面根画布:WBP_PalOverallUILayout 里的 CanvasPanel_Root,它是 UCanvasPanel,因此是唯一一个有真正 ZOrder 的宿主
"layer"游戏自己的路线:通过 BP_AddWidget 压到某个 CommonUI 层上,激活、焦点和输入模式都归动作路由器管
{ widget = "PalUITitleBase", panel = "VerticalBox_0" }任意一个活着的控件类,以及它里面的面板 —— 整合包扩展游戏已有画面的方式
UI{ id = "pack:Hud",   host = "screen", root = ... }
UI{ id = "pack:Panel", host = "game",   root = ... }
UI{ id = "pack:Extra", host = { widget = "PalUITitleBase", panel = "VerticalBox_0" },
    root = ... }

表形式的宿主要写原生基类,不要写蓝图类:查找会匹配子类,所以 "PalPrimaryGameLayoutBase" 扛得住蓝图改名,"WBP_PalOverallUILayout_C" 扛不住。面板先当成已声明的成员去读,读不到再按名字搜 —— 设计器里没勾 "Is Variable" 的控件没有成员,但它仍然在控件树里。

宿主还没起来不算失败。整合包的文件被加载的时刻,标题画面、游戏内布局、往往连玩家控制器都还不存在,所以 mount() 返回 false,元素就一直是下来的状态。:autoMount(nil, ms) 正是为此而设的重试循环。

"screen" 宿主是特意不带压暗层、也不带内嵌外框造出来的:作者没有声明的外框不算构图。想要外框的声明式树自己写一个就是了。

host = "layer" 已声明、已随包发布,但从来没有一次被观测到起作用。它依赖的每一条事实都能从这份安装的 dump 里读到 —— UPrimaryGameLayout 把它的 CommonUI 层注册在一张 TMap<FGameplayTag, UCommonActivatableWidgetContainerBase*> 里,BP_AddWidget 会让游戏去创建、入栈、激活、过渡并注册这个控件 —— 但没有任何一次运行看到 BP_AddWidget 对我们的控件作出应答。它要求 UI.Frame 作为 root,因为只有 Palworld 的 activatable 才能上层,而这条规则是在定义时就拒绝,而不是留到挂载时才失败。声明了它却上不去的元素会带着原因保持未挂载,而这正是 :autoMount 会重试的东西。

能给出结论的那次运行是 pf_hook ui-host-layer,它挂的就是 pf_uiz 里的 LAYER 面板。在它给出结果之前,声明了这个的面板必须另外准备一条上场的路。

已经被观测到的是另一件事:2026-07-27,pf_uidecl 把一棵声明好的树挂进了 PalPrimaryGameLayoutBase.CanvasPanel_Root,插槽是 CanvasPanelSlot,面板在画面上可见。这就是 host = "game" 的承重证据。

render、update 和 destroy

render(self, root) 把你的控件搭在 root 下面。元素每次上画面,mount() 都会调它一次。搭不出来时返回 false,比如你要的那块面板还不存在。元素就留在画面外,之后再挂一次就是重试。返回 nil 算成功,所以一个总能成功的 render 什么都不用返回。

update(self) 把新的值写进 render 已经搭好的控件。调它的是 refresh()。

destroy(self) 撤掉 render 搭的控件。调它的是 unmount(),而且只在元素还在画面上时调。

声明式的树会自己把这三个都填上,其中两个还会和你写的合成。刷新时先跑树的绑定,再跑你的 update,所以你可以覆盖绑定刚写下的值。撤下时先跑你的 destroy,再拆树,所以你那一段仍然能碰到控件。

你永远不用自己记「是不是已经 render 过了」。mount 会替你记,而且还不止:

-- Class:mount in api/ui.lua, abridged
function Class:mount(root)
    if self._mounted then return false end
    if root == nil and self.hostSpec ~= nil then
        -- resolve the declared host; a host that is not up yet is a refusal, not a raise
        local host, reason = tree.host(self.hostSpec, { z = self.zOrder })
        if not host then return refuse(self, reason) end
        self._host, root = host, host.panel
    end
    self._root = root
    if self:render(root) == false then
        self._root = nil
        self:releaseInput()          -- a mount that could not build leaves no cursor behind
        self:releaseHost()           -- and no viewport layer of ours behind either
        return false
    end
    self._mounted = true
    stackPush(self)                  -- in the routing list BEFORE anything that can fail
    self:applyZ()
    self:armInput()
    self:grabInput()
    return true
end

三条缝在你写之前都什么都不做,所以一个元素可以只是一份声明 —— 当这个 id 存在只是为了让别的 mod 能通过 UI.get 找到它时,这很有用。

-- valid: registers "example:Marker" with no behaviour at all
UI{ id = "example:Marker", description = "Reserved id, nothing rendered." }

按名字分工:凡是搭控件的都放进 render 并存到 self 上;凡是往已有控件里写新值的都放进 update;凡是把控件撤走的都放进 destroy。

local widget = require("palforge.native.ui._widget")

local Label = UI{
    id   = "example:Label",
    data = { text = "" },

    render = function(self, screen)
        if not (screen and widget.alive(screen.root)) then return false end
        local ok = pcall(function()
            self.textWidget = widget.text(screen.tree, tostring(self.text), 18)
            widget.addChild(screen.root, self.textWidget)
        end)
        if not ok then self:destroy(); return false end
        return true
    end,

    update = function(self)
        if not widget.alive(self.textWidget) then return false end
        local t = self.textWidget
        return pcall(function() t:SetText(FText(tostring(self.text))) end)
    end,

    destroy = function(self)
        local t = self.textWidget
        self.textWidget = nil
        if not widget.alive(t) then return false end
        return pcall(function() t:RemoveFromParent() end)
    end,
}

mount() 判定元素是否在画面上,靠的是 render 返回了什么,而不是它跑过了这件事。返回 false 会让元素留在画面外,并且忘掉你传的 root,所以之后再调 mount() 就只是重试。你不需要在每次尝试前自己去判断游戏状态。

一个面板,多份副本

UI{ ... } 把你的元素按 id 注册,并返回一个句柄::mount、:refresh、:unmount 都是对它调的。这个句柄本身已经带着一份副本,所以 UI{ ... } 返回的东西可以直接挂。:new(props) 给你另一份带自己状态的副本。

local Badge = UI{
    id     = "example:Badge",
    data   = { label = "Badge", size = 18 },
    render = function(self, root) end,
}

local a = Badge:new{}                  -- a:state().label == "Badge"  (from data)
local b = Badge:new{ label = "Boss" }  -- b:state().label == "Boss", b:state().size == 18

:new 返回的是句柄,不是状态本身:状态在 :state() 后面,句柄上的 a.label 是 nil。那张状态表就是 render / update / destroy 和每一条绑定里的 self,名字按这个顺序解析:

  1. 你传给 :new{ ... } 的字段,加上任何一条缝或 onClick 往 self 上写的东西
  2. 元素的 data 默认值,定义时被复制到类上
  3. 生命周期方法和那三条缝

在绑定里写 self.label = "x" 只会给那一份副本立字段;共享的 data 默认值不受影响。

从外面通过 :state() 读写某份副本的状态:

local st = a:state()
st.label = "Ready"
a:refresh()             -- nothing does this for you

状态里的名字是穿过元素本身解析的,所以下面这些已经被占用了:id、name、description、render、update、destroy、mount、refresh、unmount、isMounted、find、hostSpec、inputMode、backHandler、zOrder、rootNode、keyList、keySet、overrideList、buttonList、buttonSet、overrideButtonList、onKeyPressed、onMousePressed,以及所有下划线开头的字段(_mounted、_root、_tree、_host、_input、_refreshSub、_stackSeq)。

UI.get(id) 每次调用都会造一个新的句柄和一份新的空副本,所以调两次 UI.get 就得到两块可以各自独立上画面的面板:

local one = UI.get("palforge:Button")
local two = UI.get("palforge:Button")   -- a different instance of the same element

对于没人声明过的 id,UI.get 返回的仍然是句柄而不是 nil,而且它造出来的替身带着整套生命周期::mount()、:refresh()、:unmount() 和 :isMounted() 都能解析到,并且安静地什么都不做,因为那三条缝的默认实现是空的。对替身调 mount() 会报告成功,但一个控件也不会搭。查元素这件事,要等到声明它的东西加载之后再做 —— 顺序由 core/registry 决定。

显示、更新、收起

local panel = Panel:new{ title = "Status" }

panel:mount(root)      -- true: render did not report a failure
panel:mount(root)      -- false: already mounted, render is NOT run again
panel:isMounted()      -- true
panel:refresh()        -- true: update() ran
panel:unmount()        -- destroy() runs, then everything the mount took is given back
panel:refresh()        -- false: not mounted, update() does not run
panel:mount(root)      -- true: renders again from scratch
panel:lastError()      -- nil after a success; the reason after a failure

mount() 返回 false 有两种情况:元素已经在画面上,或者 render 返回了 false。两种都不是错误,也都不会留下半块面板。

refresh() 在元素上画面之前什么都不做并返回 false,所以从一个活得比它久的订阅里调它是安全的。

unmount() 先把元素从路由列表里摘掉,排在任何会失败的动作之前:一个正在下场的元素,哪怕它的 destroy() 抛了异常,也不能继续当「按键到不了下面那块面板」的理由。然后它在 pcall 里跑 destroy(),清掉挂载标记和保存的 root,把玩家的输入还回去,把自己造的宿主还回去,并取消 autoRefresh 或 autoMount 的订阅。

unmount() 只会调你写的 destroy,而默认的 destroy 什么都不做。在 render 里把控件存到 self 上却不写 destroy 的元素会漏掉它们:标记清了,之后的 mount() 又搭了第二份,第一份还留在画面上。声明式的 root 没有这个坑 —— 树自己的拆解已经替你装好了。

让画面保持最新

没有任何东西会替整合包调 refresh()。一个元素要么在自己状态变化时自己调 :refresh(),要么选择 :autoRefresh(ms) —— 而 autoRefresh 搭乘的是两样东西,不是一样。 本页早先的版本说过轮询是 PalForge 唯一的驱动;那个说法在同一天被一次测量推翻了,更正就写在这里。

  • 游戏自己的重建信号。 Palworld 建起或拆掉一个画面时,CommonActivatableWidget::ActivateWidget、PalHUDService::Push 和 PalHUDService::Close 都会触发 —— 2026-08-02 按路径布上了 21 个候选,其中三个触发了。native/ui/refresh.lua 会给这三个布钩,惰性地、每次会话至多一次,而刷新会在一次重建之后一拍之内落地。
  • 底下那层心跳,作为下限。 它拆不掉,也不是备用方案。那次运行里 21 个候选中有 18 个一直没有声音,而那是缺少输入—— 操作者打开的是两个画面,不是十八个 —— 所以这三个盖不到的重建,恰恰就是这条下限存在的理由。而且这个信号只在 world.ready 之后才布上,因为 UE4SS 没法注销一个钩子,而 ActivateWidget 会在世界加载风暴里为每一个 activatable 触发;所以标题画面上的元素只靠轮询跑。

于是一块面板无论如何最多旧 ms 毫秒,而在一次画面重建之后大约旧一拍。把 ms 调小,仍然是对前一个数字唯一的杠杆。对一块闲着的面板来说,一拍是特意做得很便宜的 —— 绑定只做比较,只写回变了的东西 —— 所以 ms 调快,代价是比较,不是原生调用,而搭上这个信号也不会把下限拖慢。

办法一 —— 在变化发生的地方刷新。精确,而且什么都没变的时候不花任何代价:

local event = require("palforge.core.event")

event.on("item.obtain", function(ctx)
    if ctx.itemId ~= "Wood" then return end
    local st = panel:state()
    st.wood = (st.wood or 0) + (ctx.count or 1)
    panel:refresh()
end)

办法二 —— 订阅。两个订阅都搭在 event.every 上,它按整拍数 event.TICK_MS(500 ms)计数,所以这条下限的真实周期会向上取整到 500 的倍数;而重建信号在布上的时候,是在这个拍子之外再加一次刷新,而不是取代它。

panel:mount(root)
panel:autoRefresh()        -- refresh every 500 ms
panel:autoRefresh(2000)    -- no-op: a subscription is already installed, returns true

-- the whole lifecycle instead: retry mount() while it is down, refresh it once it is up
panel:autoMount(nil, 2000)

元素没挂载的时候 :autoRefresh(ms) 什么都不做,因为 refresh() 就是什么都不做。:autoMount(root, ms) 就是为此而设:同一个订阅,但元素在下面时它重试 mount(root),上去之后它刷新。每一个活着的 PalForge 界面走的都是这条挂载路径,而且一直失败的重试并不沉默 —— mount() 把原因记在实例上,并且每个不同的原因只记一条日志,所以 :lastError() 随时能回答「我的面板为什么没上去」,日志也不会被一拍一行灌满。

需要注意的细节:

  • autoRefresh 默认 500 ms;autoMount 默认 2000,因为重试循环要的拍子比刷新慢。
  • 两者共用一个订阅位。已经在跑的时候再调一次会返回 true,不会再装一个。要改周期就先 unmount()。
  • unmount() 会取消它,所以它也是停掉 autoMount 重试的办法。
  • 订阅装不上时两者都返回 false;整个调用都有保护。
  • 每一个都只驱动那个句柄背后的那一份副本。另一份副本要自己调一次。

别信这一页,直接问现在到底是什么在驱动刷新。 UI.refreshDriver(ms) 会读取实时的布钩状态,把答案作为数据交回来:

local d = UI.refreshDriver(250)

d.kind     --> "event+poll" when the rebuild signal is armed, "poll" when it is not
d.event    --> the UFunction path(s) armed right now, or nil
d.state    --> "armed" | "waiting" | "refused" | "unavailable" — why kind is what it is
d.staleMs  --> how stale a panel may be when nothing rebuilt a screen (the floor's number)
d.eventStaleMs --> how long after a rebuild the refresh lands: one heartbeat, or nil
d.why      --> one sentence naming the measurement, ready for a log line

kind == "poll" 从来都不是故障状态 —— 它是这个信号出现之前就在跑的东西,而且在标题画面上、在布钩被拒绝的会话里、在根本没有 RegisterHook 的无头测试里,它都是诚实的答案。只用 kind 去说明一份读数可能有多旧,绝不要用它来判断刷新还能不能工作:下限在每一个答案底下都在。

测量本身是 pf_hook ui-update-event,而且它可以再跑:那 18 个一直没有声音的候选 —— 弹窗用的 PalHUDService:ShowCommonUI、回标题用的 RemoveHUD、世界 HUD 那一对、CommonUI 容器的 add/remove —— 从来就没有被给过能触动它们的动作,所以它们仍然点名留在这个钩子里,而不是被划掉。

输入:面板从玩家那里拿走什么

画出一个控件和能点到它是两个问题。游戏握着鼠标捕获的时候 —— 也就是正常游玩时 —— Slate 根本拿不到指针,所以命中测试、z 序和可见性全都无关紧要,任何 mod 里的任何按钮都是死的。手动把它掰回来的办法是按 Esc:游戏自己的菜单会切换输入模式并显示指针。

input拿走什么
"none"默认。 什么都不拿。只有在游戏已经把鼠标交出来的时候(也就是开着菜单时)才点得动。一块 HUD 读数要的正是这个。
"cursor"只要鼠标指针。bShowMouseCursor 是一个能直接读的普通属性,所以能一模一样地还回去。
"clicks"游戏自己的 GameAndMenu 模式:点击能落到控件上,同时玩家还能移动和转视角。
"exclusive"游戏自己的 Menu 模式 —— 一个模态,背包界面声明的就是它。游戏侧的输入停下。

后两个是按游戏放置它们的方式放置的,而这带来一个从声明里就能看见的后果:元素的 root 必须是 UI.Frame{ },因为在这套词汇里,只有 WBP_PalCommonWindow_C 是 Palworld 的 activatable。没有它就声明这两者之一,会在定义时被拒绝。

local UI = require("palforge.api.ui")
local Frame, VBox, Label, Button = UI.Frame, UI.VBox, UI.Label, UI.Button

UI{
    id    = "example:Dialog",
    host  = "game",
    z     = 100,
    input = "clicks",             -- needs the Frame below; refused at define time without it
    root  = Frame{
        VBox{ padding = 16,
            Label{ text = "Pick one", native = true },
            Button{ text = "Close", onClick = function(self) self:unmount() end },
        },
    },
}

Palworld 的每个画面都是 UPalActivatableWidget,它把自己想要的模式当作自己身上的两个字节声明出来(InputConfig、GameMouseCaptureMode);CommonUI 的动作路由器读最上面那个 activatable 的声明,把它应用上去,并在该控件失活时把之前的还原回来。PalForge 在这个控件被激活之前 —— 路由器读这两个字节的唯一时刻 —— 把它们写到 frame 上;而 unmount 是经过路由器把模式还回去,而不是强行改什么。

PalForge 不会对玩家控制器调 SetInputMode,这不是风格选择。两次实机运行这么做过,两次都弄坏了 Esc:第一次游戏自己的菜单关不上,第二次干脆打不开。手写模式会让路由器一直描述一个已经不成立的输入状态;而 Esc 在这个版本里根本不是一个按键 —— 它是同一个路由器对着同一个 activatable 栈解析出来的界面动作。

在这之下还有一层兜底:每一次输入获取都登记在 _G 上,并由 core/poll 的心跳扫过,所以再也没有人能为之负责的那些 —— 元素没了、它的 destroy 抛了异常、或者热重载把整个 Lua 状态带走了 —— 会自己释放。没有主人可问的那种获取,上限是 120 秒。

"clicks" 和 "exclusive" 都没有被观测到起作用过。没有任何一次运行看到动作路由器从我们的控件上读走一份声明。当 root 不是 activatable 时,你拿到的是指针,外加一条说明「控件那一侧的写入没有发生」的注记 —— 所以一次运行能分辨出「走通了游戏的路线」和「这块面板不是 activatable,什么都没拿到」。

backHandler = true 也是一样:它在控件被激活之前给元素的窗口立上 bIsBackHandler,也就是 Palworld 的画面说「Esc 关的是我」的那套机制,并回应 BP_OnHandleBackAction。它要求 UI.Frame 作为 root,没有就在定义时被拒绝,而且它已声明、已随包发布,从来没有一次被观测到起作用。记录答案的那次运行是 pf_hook ui-backhandler。在它给出结果之前,声明了它的面板必须另外准备一条下场的路。

叠放次序与事件路由

同时开着两块面板是常事 —— 一块 HUD 读数,上面压一个对话框 —— 而在没有声明次序的情况下,这由加载器碰巧先读到哪个整合包的文件决定。z 就是那个次序:显式声明,默认 0,越大越靠前。

UI{ id = "pack:Hud",    host = "game", z = 0,   root = ... }
UI{ id = "pack:Dialog", host = "game", z = 100, root = ... }

它决定两件不同的事,值得把哪件是哪件说清楚。

绘制是引擎的事,而且只在引擎本来就有 z 的地方。挂在游戏画布里的树能拿到,因为 UCanvasPanelSlot 是唯一声明了 ZOrder 和 SetZOrder 的插槽类。注入到标题画面 VerticalBox 里的树拿不到 —— box 按子节点顺序叠放,根本没有 z —— 而这件事每个实例只报告一次,既不吞掉也不每次挂载都重复。"screen" 宿主则改由 AddToViewport 叠放,值是 1000 加上声明的 z。

路由是 api/ui 自己的事,而且不管有没有宿主都精确成立:已挂载的元素按 (z,然后挂载顺序) 保存,事件沿着这个顺序走。

  • onKeyPressed 只会送到最上面那个已挂载的元素。只要上面还压着东西,下面那块面板就拿不到按键,哪怕上面那个压根不想要任何按键。这是模态式的读法,而且是故意的:最上面那块面板才是玩家正在看的。
  • onMousePressed 送到最上面那个想要它的元素,然后停下。没有声明 onMousePressed 的元素对一次按下是透明的,而不是挡住它。
  • z 相同的时候,后挂载的排前面 —— 这正是 UMG 在画布上的做法,ZOrder 相同的子节点按加入顺序绘制。
  • 两者都不消费任何东西。 UE4SS 的按键绑定只是观察,不会吞掉。这些按下游戏一个不落地都会收到。「停下」的意思是它不再沿 PalForge 这张列表往下走,仅此而已。

哪些键、哪些鼠标键必须声明出来,这不是官僚主义:一次按下是通过 UE4SS 的 RegisterKeyBind 读到的,而它绑定的是一个具名按键,没有「把所有键都给我」这种形式。只写一半的声明在定义时就是错误 —— 没有 keys 的 onKeyPressed 接不到任何东西,而没有 onKeyPressed 的 keys 会把玩家的一个键绑定整个会话,然后路由到虚无。

UI{ id = "pack:Dialog", host = "game", z = 100,
    keys            = { "INS" },
    onKeyPressed    = function(self, ctx) self:unmount() end,
    overrideButtons = { "middle" },
    onMousePressed  = function(self, ctx) log.info("middle at z " .. ctx.z) end,
    root            = ... }

一个键是不是空的,是问出来的,不是假设出来的。 core/keyboard 从运行中的游戏里读 Palworld 自己的按键配置 —— 就是选项界面在编辑的那个结构体 —— 并逐键作答,所以和玩家自己的绑定冲突的 keys = { "F7" } 会在挂载时被拒绝并点名冲突的操作,而不是无声地武装起来。这正是 F7 第一次的代价:绑定成功了,那是游戏的音量控制,而按键一次也没到过。

override 只买到一样东西:PalForge 不再拒绝。游戏自己的操作照样触发,玩家的按键配置不会被改写,而且它也没法让一个在 UE4SS 之下就被游戏拿走的按下变得能到达。共享一个键意味着两件事都会发生,而你仍然可能什么都收不到。

默认安装下三个鼠标键全都被游戏占用(中键是 DirectAttackOrder),所以只写 buttons 会在武装时被拒绝,overrideButtons 是唯一一个真能让鼠标路由跑起来的列表 —— 并且中键点击同时也会命令你的帕鲁去攻击。"ESCAPE" 无论有没有 override 都按名字拒绝:Esc 是对着 activatable 栈解析的具名界面动作,所以按键绑定只能在旁边观察,永远进不了那条路径。

任何绑定都无法解除。UE4SS 没有解绑按键的办法,所以一个键在一个会话里最多武装一次;已卸载的元素不再收到按键,是因为路由器不再选它,而不是因为绑定没了。

onMousePressed 是一条按 z 路由的全局按下通知。UE4SS 没法告诉我们指针下面是什么,这里也不会消费掉这次按下。要点画面上的某个东西,用的是 Button{ onClick = ... },它走游戏自己的 CommonButtonBase,是真的知道命中了哪个控件的。两者是不同的事件,谁也替代不了谁。

这条规则是公开的,不只是被描述:谁都检查不了的规则就只是传闻。

UI.stack()          -- every MOUNTED element in routing order, one row each
UI.routeKey("INS")  -- who would get INS right now, plus the sentence that says why
UI.routeMouse("middle")
UI.report()         -- printable lines: the stack, the key binds, the grabs, the keymap

UI.routeKey 和 UI.routeMouse 不会向游戏合成任何按下 —— 它们回答的是一次按下会提出的那个问题。无键盘的 headless 测试就是这样证明这条规则的,探针也是这样问「如果 INS 现在到了,谁会拿到」的。

1  pack:Dialog  z=100  seq=2  keys=INS  buttons=middle
2  pack:Hud     z=0    seq=1  keys=INS  buttons=

UI.routeKey("INS")   -> "pack:Dialog", 'key "INS" -> pack:Dialog (z=100)'
UI.routeMouse("mid") -> nil, 'mouse "mid" reached none of the 2 mounted element(s) ...'

UI.report() 交叉了四个来源,它们回答四个不同的问题:栈说一次按下会归谁;按键绑定说一次按下究竟能不能到;获取列表说现在还从玩家那里扣着什么;键位表说 Palworld 在每个键上放了什么 —— 在测过的版本上是 107 行。到达次数为 0 的键会拿前两者去归因,而不是耸耸肩。

所有可以调用的成员

UI{ ... }、UI.get(id) 和 :new(props) 都返回一个 UI.Handle,下面是它的成员。

成员返回值作用
.idstring元素的 id
:new(props)UI.Handle一份全新、可以独立挂载的副本;props 成为它的状态
:mount(root)boolean在 root 下面 render 一次;root 为 nil 时用声明的 host
:refresh()boolean跑树的绑定和 update();挂载之前一直返回 false
:unmount()—先离开路由列表,再跑 destroy(),把输入和宿主还回去,取消轮询
:isMounted()booleanrender 是否报告了成功,且还没有 unmount
:find(name)userdata?某个声明了 name = "..." 的节点对应的活控件;挂载之前是 nil
:lastError()string?上一次挂载尝试放弃的原因,一句话
:autoRefresh(ms)boolean让 refresh() 同时搭乘 Palworld 的重建信号和作为下限的心跳;ms 默认是 500
:autoMount(root, ms)boolean整个生命周期都搭在同一个驱动上:下来时重试 mount,上去后刷新;ms 默认是 2000
:state()table这份副本本身,也就是那几条缝和绑定里的 self
:name()string元素的 name,没有就是 id
:description()string?元素的 description

模块本身还有这些:

成员返回值作用
UI(spec, opts)UI.Handle声明一个元素;opts 是 { register = false, pack = "id" }
UI.get(id)UI.Handle已有的元素,没有则是一个什么都不做的替身;不会是 nil
UI.get_all()UI.Handle[]所有已注册的元素
UI.VBox … UI.GameWidgetUI.Node十一个节点构造器
UI.stack()table[]所有已挂载的元素,按路由顺序
UI.routeKey(name)string?, string那个键会归谁,以及为什么
UI.routeMouse(button)string?, string鼠标键的同一件事
UI.report()string[]栈、按键绑定、未释放的获取,以及当前键位表
UI.refreshDriver(ms)table此刻究竟是什么在刷新一块面板:kind、event、state、staleMs、why
UI.Classtable每个元素都继承的基类

自己造一个画面

你的控件得有地方待。声明式的树按 host 自己找宿主;命令式的 render 则由别人把宿主递给它。两种都用 native/ui/_widget.lua 搭建,那是现成元素用的工具箱。

工具箱里的每个辅助函数都需要一个 WidgetTree —— Unreal 用来装一个画面全部控件的容器 —— 而一个裸的 UUserWidget 没有,因为通常那是引擎从控件编译好的 Blueprint 里造出来的。widget.screen() 自己造出这棵树并挂上去,这就是你能只用 Lua、不编译任何东西就做界面的原因。

local widget = require("palforge.native.ui._widget")

local screen, why = widget.screen()
if not screen then
    print("no screen: " .. tostring(why))
end

-- when it built:
--   screen.widget  the UUserWidget now on the viewport
--   screen.tree    the WidgetTree every primitive constructs into
--   screen.root    the VerticalBox your widgets go under
--   screen.pc      the controller that owns it; the clickable helpers need it

widget.screen(pc, opts) 从不抛异常。它返回 screen 表,或者返回 nil 加一句原因:没有主人、缺少某个 UMG 类,或者 AddToViewport 没生效。pc 默认取 widget.owner() —— PalPlayerController,否则任意 PlayerController,否则 GameInstance,完全没有游戏时是 nil。

Prop

Type

-- a bare vertical stack: no dimmer, no frame, drawn under the default z
local bare = widget.screen(nil, { dim = false, zOrder = 500 })

-- built but not shown; put it up and take it down yourself
local hidden = widget.screen(nil, { show = false })
widget.show(hidden)       -- AddToViewport; true if the widget reports itself shown
widget.hide(hidden)       -- RemoveFromParent; true if it is no longer shown

往里面填的零件:

辅助函数搭出什么
widget.vbox(tree) / widget.hbox(tree)一个 VerticalBox / HorizontalBox
widget.scrollBox(tree) / widget.overlay(tree)一个 ScrollBox / Overlay
widget.border(tree, rgba)一个 Border,画刷颜色是 { r, g, b, a }
widget.sizeBox(tree, w, h)一个 SizeBox;每个 override 既按带 bOverride_ 标志的属性写一次,也走 setter 写一次,因为 UE4SS 的 setter 有时什么都不做
widget.text(tree, str, size, rgba)一个 TextBlock;size 默认 16
widget.palText(tree, str, size)游戏自己的 BP_PalTextBlock_C,或者 nil 加原因
widget.menuButton(tree, pc, label, onClick)游戏自己的一个按钮;返回按钮、它的点击目标和点击路由器的 key
widget.clickableRow(tree, pc, label, onClick, opts)那个按钮上盖一层左对齐文字;第四个返回值是那个 TextBlock
widget.gameFrame(pc, opts)WBP_PalCommonWindow_C,以及你的内容要放进去的 NamedSlot
widget.cloneGameWidget(tree, pc, classPath, opts)任意 Palworld BP 控件;opts = { label, labelChild, clickChild, onClick }
widget.addChild(panel, child)不管 panel 是哪种面板都把 child 放进去,并返回它的插槽
widget.gameUIRoot() / widget.hostPanel(class, panel)游戏自己的游戏内画布 / 任意活着的控件类里的任意面板
widget.alive(w)这个控件还能不能对话;从不抛异常
widget.findByName(w, name)按名字深度优先查找后代控件
widget.releaseClicks(names)按 key 丢掉点击路由器里的登记 —— destroy 调的就是它

widget.addChild 是去问面板它是什么,而不是被告知:它先试 AddChild,因为那是 UPanelWidget 的通用入口,每种面板都会重写它来造自己那种插槽;然后才退回到带类型的 AddChildTo*。它还顺手修掉新建 CanvasPanelSlot 唯一会搞错的那件事 —— 偏移全是 0,所以加进画布的控件占的是 0x0 的盒子,永远画不出来 —— 办法是设 bAutoSize,那是不用结构体参数就能做到这件事的唯一途径。

mount(root) 把 root 原封不动地交给 render,所以一个元素可以把整张 screen 表当作 root,从这一个参数里够到 tree、root 和 pc:

content/ui/stats_panel.lua
local UI     = require("palforge.api.ui")
local Item   = require("palforge.api.item")
local Pal    = require("palforge.api.pal")
local widget = require("palforge.native.ui._widget")

local Stats = UI{
    id   = "example:Stats",
    name = "Stats",
    data = { title = "PalForge", rows = {} },

    render = function(self, screen)
        if not (screen and widget.alive(screen.root)) then return false end
        self.clicks = {}
        local ok = pcall(function()
            local head = widget.text(screen.tree, self.title, 24)
            widget.addChild(screen.root, head)
            for _, row in ipairs(self.rows) do
                local line, _, clickName =
                    widget.clickableRow(screen.tree, screen.pc, row.label, row.onClick)
                widget.addChild(screen.root, line)
                self.clicks[#self.clicks + 1] = clickName
            end
        end)
        if not ok then self:destroy(); return false end
        self.screen = screen
        return true
    end,

    -- Taking the whole screen off the viewport removes every widget under it at once.
    destroy = function(self)
        widget.releaseClicks(self.clicks or {})
        if self.screen then widget.hide(self.screen) end
        self.clicks, self.screen = nil, nil
        return true
    end,
}

local panel = Stats:new{
    title = "PalForge",
    rows  = {
        { label = "Give 10 Wood",    onClick = function() Item.get("Wood"):give(10) end },
        { label = "Spawn a Chikipi", onClick = function() Pal.get("ChickenPal"):spawn() end },
    },
}

local screen = widget.screen()
if screen then panel:mount(screen) end

_widget.lua 在 native/ui 下面,不在 api/ 下面,所以它的辅助函数可能在版本之间无预告地变化。有兼容承诺的是声明式的树;工具箱是当声明说不出你需要的东西时,render 才伸手去够的地方。

现成的元素

PalForge 自带两个可以直接用的元素。两个都会把真正的控件放进游戏自己的界面,而且都注册在整合包 id palforge 下面 —— 所以某个整合包定义了同样的 id 把它替换掉时,冲突会被点名写进日志,而不是悄悄发生。

local ui = require("palforge.native.ui")
ui.Button                                    -- also require("palforge.native.ui.button")
ui.TitleMenu                                 -- also require("palforge.native.ui.title_menu")
ui.widget                                    -- the toolkit, without the underscore module
ui.tree                                      -- what turns a declared node tree into widgets
ui.keys                                      -- the keyboard seam; keys.report() is printable

UI.get("palforge:Button")                    -- the same element, a fresh instance

调 :new{ ... },不要直接挂模块本身的那个值,这样每个使用它的地方都保有自己的标签和自己的回调。

Button

palforge:Button —— 一个可以点的按钮,样式和游戏自己的一致。

local Button = require("palforge.native.ui.button")
local widget = require("palforge.native.ui._widget")

local screen = widget.screen()
local give   = Button:new{
    label   = "Give 10 Wood",
    onClick = function() Item.get("Wood"):give(10) end,
}
if screen then give:mount(screen.root) end

它从你造的那份副本里读两个字段:self.label(默认 "")和 self.onClick。render 把按钮存到 self.widget,点击目标存到 self.invButton,标签控件存到 self.labelWidget,点击路由器的 key 存到 self.clickName。

render 依次做这些事:

  • 找一个 PalPlayerController,没有就返回 false
  • 通过 widget.buttonClass() 搭出游戏自己的按钮。它不是点名一条路径,而是去问世界现在加载了哪个按钮类。排在最前面的是 WBP_CommonButton_C,也就是 Palworld 自带 Mod 菜单用的那个按钮,因为它是唯一一个按名字(而不是靠运气)声明了标签子控件 Text_Main 和点击目标 WBP_PalInvisibleButton 的候选。标题菜单那个类只在标题画面常驻,早先在世界里的尝试就是死在这上面。
  • 用 widget.setButtonText 写标签。它优先用按钮自己声明的 SetText,只有当这个类没有声明 setter 时才去够标签子控件。
  • 把点击目标登记到共享的点击路由器上:在 /Script/CommonUI.CommonButtonBase:HandleButtonClicked 上装一个 RegisterHook,把点击送到拥有那个按钮的回调。点击目标是这个类声明的子控件,没有就是它树里任意一个 CommonButtonBase,再没有就是按钮自己。
  • 用 widget.addChild 把按钮加到 root 上

放置是检查过的,不是假设的。如果 root 根本不收任何子节点,render 会跑自己的 destroy() —— 与其留下一个看不见的按钮连着一个活回调,不如把按钮一起丢掉 —— 然后返回 false。

update 会把当前的 self.label 和当前的 self.onClick 写进那个活按钮,所以两者都能不重建就改:

give:state().label = "Give 50 Wood"
give:refresh()

按钮本身没了时它返回 false。只有缓存的标签控件失效时,它会先把标签子控件重新查一遍。重新登记点击是替换路由器里的那一条,而不是再加一条;清掉 onClick 则是把那条丢掉。

destroy 释放按钮在点击路由器里的登记并调 RemoveFromParent(),所以 unmount() 是真把按钮从画面上撤掉,之后的 mount() 会搭一个新的。

TitleMenu

palforge:TitleMenu —— 往游戏标题画面加项目。这个在真实的标题画面上是能用的。

local TitleMenu = require("palforge.native.ui.title_menu")

local menu = TitleMenu:new{
    entries = {
        { label = "Mods",     onClick = function() openMods() end },
        { label = "Settings", onClick = function() openSettings() end },
    },
}
menu:autoMount(nil, 2000)   -- waits for the title screen, then re-injects when it rebuilds

self.entries 是一个 { label = ..., onClick = ... } 的数组。在碰任何一个项目之前,render 会解析三样东西,缺一个就返回 false:root(你传的 root 如果有效就用它,否则是 FindFirstOf("PalUITitleBase").WidgetTree.RootWidget)、它里面的 VerticalBox_0(标题画面的按钮列),以及一个有效的 PalPlayerController。对每个还没有活按钮的项目,它接着:

  1. 搭一个原生菜单按钮,并用一个 SizeBox 包起来,尺寸从旁边的原生 SizeBox_4 读来 —— 读的是 WidthOverride / HeightOverride 属性,因为 getter 读回来是 nil。没有这个固定宽度,按钮会被拉伸并居中。
  2. 把 SizeBox 加到那一列里,把插槽设成左对齐,上下各留 3 的边距,和游戏自己的项目一致
  3. 把按钮、点击目标、它的 SizeBox 和点击路由器的 key 记在这个项目上

在至少加进去一个项目的那一趟之后,它把 WBP_Title_MenuButton_ExitGame 的父盒子挪到这一列的末尾,好让 Exit Game 留在最后 —— VerticalBox 没有插入操作。

render 只在真的有项目进去时才返回 true,所以在标题画面存在之前尝试的 mount() 会让元素留在画面外,直接重来即可。

update 在同一拍里做两件事。点击目标已经失效的项目会被重新注入 —— 比如回到标题时,标题画面会重建它的控件并悄悄把加上去的按钮一起带走 —— 而仍然活着的项目,则会把当前的 label 和 onClick 写进那个活按钮,两者都没变时就跳过写入。所以改 self.entries 再刷新是一次真正的编辑,而刷新永远不会堆出重复项。

destroy 把每个项目的 SizeBox 从按钮列里移除并释放它的点击路由登记,所以 unmount() 会把菜单取出来,之后的 mount() 重新加一遍。

标题画面上没有世界,也没有玩家角色。在那里调 Item.get("Wood"):give(10) 的项目会失败:give 走的是本地玩家的背包。标题项目只放菜单类操作,凡是碰到游戏玩法的都放到游戏内的面板上。

实用示例

显示计数的面板

一块统计木材拾取数的声明式面板。绘制全部由树负责;item.obtain 的处理器负责动那个数并调 :refresh(),而 :autoMount(nil, 2000) 负责在世界起来后把它放进去,没起来时一直重试。

content/ui/wood_counter.lua
local UI    = require("palforge.api.ui")
local event = require("palforge.core.event")
local Frame, Border, SizeBox, VBox, HBox = UI.Frame, UI.Border, UI.SizeBox, UI.VBox, UI.HBox
local Label, Sprite, Button = UI.Label, UI.Sprite, UI.Button

local Counter = UI{
    id          = "example:Counter",
    name        = "Counter",
    description = "A running count of the Wood you have picked up.",
    host        = "game",
    z           = 10,
    data        = { label = "Wood", count = 0 },

    root = Frame{
        Border{ color = { 0.10, 0.09, 0.08, 0.98 },
            SizeBox{ width = 320, height = 110,
                VBox{ padding = 12,
                    HBox{
                        Sprite{ icon = "Wood" },
                        -- BINDABLE: re-evaluated on every refresh, written back only when
                        -- the value moved.
                        Label{ name = "count", size = 20,
                               text = function(self)
                                   return self.label .. ": " .. self.count
                               end },
                    },
                    Button{ text = "Hide", onClick = function(self) self:unmount() end },
                },
            },
        },
    },
}

local wood = Counter:new{ label = "Wood", count = 0 }

-- The in-game layout does not exist at load, so this retries until it does and refreshes
-- afterwards. mount() records why each attempt gave up; wood:lastError() reads it back.
wood:autoMount(nil, 2000)

-- Move the state, then refresh: nothing refreshes for you.
event.on("item.obtain", function(ctx)
    if ctx.itemId ~= "Wood" then return end
    local st = wood:state()
    st.count = st.count + (ctx.count or 1)
    wood:refresh()
end)

-- Leaving the world drops the widgets; unmount also stops the retry loop, so mount it again
-- from world.ready if you want it back.
event.on("world.left", function() wood:unmount() end)
event.on("world.ready", function() wood:autoMount(nil, 2000) end)

这里心跳做的只是重试挂载:数字在 item.obtain 触发的那一刻就写进去了。当数字的来源背后没有事件时,把 event.on 去掉,给 :autoMount 一个更短的拍子 —— 绑定那时读的就是当前值,代价是最多旧那么多毫秒。

点一下就给道具的按钮

content/ui/give_button.lua
local Button = require("palforge.native.ui.button")
local Item   = require("palforge.api.item")
local widget = require("palforge.native.ui._widget")
local log    = require("palforge.utils.log").scope("give-button")

-- One instance per use site: :new gives this button its own label and callback.
local giveWood = Button:new{
    label   = "Give 10 Wood",
    onClick = function()
        local ok = Item.get("Wood"):give(10)
        log.info("gave wood: " .. tostring(ok))
    end,
}

local screen

-- A screen of our own to host it: screen.root is a VerticalBox, which is exactly what
-- Button's render adds itself to.
local function show()
    if giveWood:isMounted() then return end
    screen = widget.screen(nil, { dim = false })
    if not screen then return end
    if not giveWood:mount(screen.root) then
        widget.hide(screen)
        screen = nil
    end
end

local function hide()
    giveWood:unmount()          -- destroy() removes the button and drops its click handler
    if screen then widget.hide(screen) end
    screen = nil
end

return { show = show, hide = hide }

两个按钮的点击都走同一个钩子,而且都在 destroy 里丢掉自己的路由登记,所以你收起来的元素不会留下任何东西。给道具只有在有活的玩家角色时才成立,所以 Item.get("Wood"):give(10) 属于游戏内的按钮,不属于标题项目。

错误

每个问题都是硬错误,调用绝不会成功一半。

UI{ name = "Panel" }
PalForge: UI: field "id" is required (element id, e.g. "pack:Panel")
UI{ id = "example:Panel", onClick = function() end }
PalForge: UI: unknown field "onClick". Valid fields: id, name, description, root, host,
render, update, destroy, input, backHandler, z, keys, overrideKeys, onKeyPressed, buttons,
overrideButtons, onMousePressed, data

onClick 属于元素的某份副本,或者属于一个 Button 节点,不属于定义本身。

UI{ id = "example:Panel", root = UI.VBox{}, render = function() end }
PalForge: UI "example:Panel" declares BOTH `root` and `render`, and they are two answers to
the same question — a declared tree builds the widgets, and so does render(). Keep the tree
and drop render (self:find("<name>") reaches any node that declared a name), or keep render
and drop the tree.
UI{ id = "example:Panel", root = UI.VBox{}, input = "clicks" }
PalForge: UI "example:Panel" declares input = "clicks", which Palworld carries on an
ACTIVATABLE WIDGET (UPalActivatableWidget.InputConfig / bIsBackHandler, and a CommonUI layer
takes nothing else) — and this element's root is a vbox, which is not one. Wrap the tree in
UI.Frame{ ... }: that builds WBP_PalCommonWindow_C, the game's own window, which IS an
activatable ...

同一条消息也回答 backHandler = true 和 host = "layer",并且接着说明满足这条规则目前已知能换来什么 —— 目前还什么都没有。

UI{ id = "example:Panel", onKeyPressed = function() end }
PalForge: UI "example:Panel" declares onKeyPressed but no `keys`. A press is read through
UE4SS's RegisterKeyBind, which binds ONE named key — there is no way to ask for all of them —
so name the ones you want: keys = { "INS" }.
UI.VBox{ UI.Label{ tetx = "Supplies" } }
PalForge: UI.Label: unknown field "tetx" (did you mean "text"?). Valid fields: text, size,
color, native, name, visible, hAlign, vAlign, padding
UI.Frame{ color = { 1, 0, 0 }, UI.Label{ text = "x" } }
PalForge: UI.Frame: field "color" is invalid: a Frame takes no colour: it wears the GAME's
own window art (WBP_PalCommonWindow_C) and nothing here can tint it ... For a coloured panel
write Border{ color = { r, g, b, a } }; for the game's chrome AROUND a colour of your own,
put the Border inside it — Frame{ Border{ color = {...}, ... } }

相关页面

小结

  • 用 UI{ ... } 描述一个元素。必填的只有 id,而让东西出现在画面上的是 root 或 render,二者只能选一个。
  • 一棵声明式的树就是十一个节点构造器、按位置写的子节点,以及两个可绑定字段(text、visible)—— 它们每次刷新都重新求值,只有值动了才写回去。
  • 没有显式 root 时,host 决定它去哪儿:"screen"、"game"、一张点名活控件类和面板的表,或者 "layer" —— 最后这个已声明、已随包发布,但从没被观测到起作用。
  • input 决定它从玩家那里拿走什么。"clicks" 和 "exclusive" 按 Palworld 自己的方式声明在 UI.Frame 的 root 上,由动作路由器负责应用和还原。PalForge 绝不手写输入模式,而这两个模式同样没有被观测到起作用。
  • z 只在宿主有 z 时决定绘制,但永远决定事件路由:按键落到最上面的元素,鼠标按下落到最上面那个想要它的元素,而且两者都不会被消费。
  • 刷新搭乘两个驱动:Palworld 自己的画面重建信号,以及在它下面兜底的心跳。:autoMount(root, ms) 是每个活着的 PalForge 界面都会走的挂载路径,面板最多旧 ms 毫秒,而当下真正武装的是哪一个,UI.refreshDriver(ms) 会告诉你。

On this page