PalForge
API 参考

Effect

给角色附加的状态。持续回复、持续伤害,或者会自己消失的增益

读完本页你可以做到

  • 一点一点地回复角色,持续多久由你决定
  • 让目标中毒或燃烧,每秒掉一次血
  • 给出一个临时增益,时间到了自己消失
  • 让同一个状态叠成多层,每次重新命中都刷新时间
  • 提前清掉一个状态,或者查它还剩多少时间
  • 在你的效果运行期间,点亮游戏自己的状态异常 —— 真正的中毒或燃烧图标

效果是你给角色附加的状态,对象是玩家或者帕鲁。增益、减益、持续伤害、护盾都算。 你只要写清楚它持续多久、每隔多久做一次事,计时交给 PalForge。

content/regen.lua
local Regen = Effect{
    id       = "example:Regen",
    name     = "Regeneration",
    duration = 10.0,   -- seconds
    interval = 1.0,    -- seconds between onTick calls
    events = {
        onApply  = function(effect, target, ctx) end,
        onTick   = function(effect, target, ctx) end,
        onExpire = function(effect, target, ctx) end,
    },
}

Regen:apply(Player.character())

十秒后它从玩家身上消失,期间 onTick 执行了十次。

效果还可以点亮游戏自己的状态异常 —— 就是 Palworld 在血条上显示的中毒、燃烧、冰冻那些小符号。 在 nativeStatus 里写上一个名字,效果被施加时它就亮起来,效果结束时它就熄灭。除此之外效果实际 要的事,仍然写进处理函数:回复、伤害、增益或者打日志,都放进 onApplyonTickonExpire。名字清单和相关规则见下面的 游戏自己的状态异常

定义一个效果

Effect{ ... } 就能做出一个。返回的是一个句柄,:apply:remove 和各种查询方法都直接 长在上面。

local Effect = require("palforge.api.effect")   -- or the installed global

local Chill = Effect{ id = "example:Chill" }    -- id is the only required field

id 可以是你喜欢的任何名字。效果的 id 不和游戏自身的数据绑定,所以 "example:Chill""Chill" 都能用。pack:name 这种写法是为了让你的 id 不和别的内容包撞车。

写错了,调用会直接报错停下,所以打错一个字不会留下注册了一半的东西:

Effect{ id = "example:Chill", durationSec = 10 }
Effect{ name = "no id" }
Effect{ id = "example:Chill", events = { onRemove = function() end } }
Effect{ id = "example:Chill", stackable = "yes" }
PalForge: Effect: unknown field "durationSec" (did you mean "duration"?). Valid fields: id, name, description, duration, interval, stackable, maxStacks, icon, nativeStatus, events, data
PalForge: Effect: field "id" is required (effect id: a name or "pack:name")
PalForge: Effect: field "events" (Effect.Spec.Events): unknown field "onRemove". Valid fields: onApply, onTick, onStack, onExpire
PalForge: Effect: field "stackable" expects boolean, got string

游戏运行时,你可以把每个字段、它的类型和含义都打印出来:

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

print(schema.help("Effect.Spec"))
print(schema.help("Effect.Spec.Events"))
schema.get("Effect.Spec").fields   -- the same, as a table, for tooling

Spec 的字段

大多数效果只需要三样东西:idduration,如果要反复做点什么就再加一个 interval。 可以传的全部字段在下面。

Prop

Type

durationinterval 的单位都是,可以带小数。data 只在定义上存一份,所以每个 目标共用同一张表;想为每个目标单独记点什么,见 为每个目标保存自己的数据

游戏自己的状态异常

nativeStatus 写的是 Palworld 自己的一个状态异常的名字。声明了它,效果被施加时 PalForge 就会点亮 那个真正的状态异常,效果被移除或到期时再把它熄灭 —— 血条上出现的就是游戏自己会放上去的那个符号, 行为也完全按游戏的规则来。

content/venom.lua
local Venom = Effect{
    id           = "example:Venom",
    name         = "Venom",
    nativeStatus = "Poison",   -- the game's real poison, on for as long as this effect lasts
    duration     = 8.0,
    interval     = 1.0,
    events = {
        onTick = function(effect, target, ctx)
            -- your own gameplay, on top of the ailment
        end,
    },
}

Venom:apply(Player.character())

它是镜像,不是替代。状态异常按游戏的规则运行,它有多强不由 PalForge 决定。PalForge 决定的是 什么时候:把它摘掉的,是你写的 duration

这件事是观测到能用的,在一个加载好的存档里,而且值得说清楚「能用」覆盖了什么。路径是 PalCharacter.StatusComponent——每个角色都带的一个属性——到 UPalStatusComponent:AddStatus(EPalStatusID),一个整数参数,没有结构体也没有 FNamestatus.addAttackUpEPalStatusID 26)加到一个角色身上,游戏通过 GetExecutionStatus 把这个状态异常读回来是「有」,status.remove 摘掉之后再读回来是「没有」。这些签名来自一份比已安装 二进制早一个补丁的转储,而实机的类三处全都对得上;每次调用现在仍然要过 core/signature,实机的类 没有声明就不会发出去。所以 false 的意思是这次调用没有发出去,绝不是目标免疫

名字用的是游戏自己的拼写。整合包最常用的是 PoisonStunSleepBurnFreezeElectricalDarknessWetnessAttackUpDefenseUp。一共有 38 个, require("palforge.core.status").names() 会返回排好序的完整清单。

大小写,以及你此刻在哪一层

nativeStatus 不区分大小写"poison""Poison" 一样能找到 EPalStatusID 5,因为 core/status 在真表旁边还留了一张全小写的映射。这在这里是安全的,理由却不能推广——这套词汇是固定 的、封闭的,而且整份都随代码发出,所以折叠大小写的代价只是一张表,省下的是记住游戏自己怎么大写。

另一层则区分大小写,而且没有任何办法把它变软。指向 DataTable 行或 object_manager 注册键的 id——"pack_Potion"Item.get、图标行查询——必须一字不差,因为那套名字属于游戏,PalForge 手上没有 可以拿来折叠的对照表。

这个名字指的是什么例子大小写
引擎枚举nativeStatus = "poison"、技能的 EPalWazaID不区分
DataTable 行或注册 idItem.get("Wood")"mypack:Potion"区分

状态异常的名字永远不会是行 id,所以 Effect 内部不会跨过这条边界——但你的内容包会,就在你把一个 Item id 写在 nativeStatus 旁边的那一刻。

游戏里没有的名字,会在你写定义的那一刻就被拒绝,而不是过后悄悄被忽略,而且消息里会列出你可能 想写的每一个名字:

PalForge: Effect: field "nativeStatus" not an ailment this build has; the names are AttackUp, Burn, CollectItem, Coma, ControlSP, Darkness, DefenseUp, Drown, DrownCheck, Dying, ...

如果状态异常点不亮 —— 目标不是角色,或者游戏不接受这个调用 —— 效果依然会正常施加,你的处理函数 也照样运行,原因会写进日志。整合包真正依赖的是 PalForge 自己的计时、叠层和处理函数,这些都不依赖 那个图标。

计时是怎么走的

PalForge 有一个每 500 毫秒跳一次的心跳,所有正在运行的效果都跟着它往前走。

一个效果作用在一个目标上,叫做一次施加。它记着已过去的时间、剩余时间和层数。 :apply 开始一次施加;持续时间用完、你把它移除、目标消失、或者世界卸载时,它就结束。

心跳是在第一次有人调用 :apply 时接上的。只是 require 这个模块不花任何代价,也不会加 监听。接的时候一次接两个:tick 用来推进每一次施加,world.left 用来在世界消失时释放 它们。如果那一刻接不上,:apply 返回 false,不会开始任何施加;其他情况都返回 true

一次心跳会对每一次活着的施加按顺序做这些事:

  1. 如果目标已经无效,用 reason = "target_gone" 触发 onExpire,然后丢掉它
  2. 否则给 elapsed 加上 0.5
  3. 给 interval 的累加器加上 0.5,只要累加器达到了 interval 就触发 onTick
  4. 从剩余时间里减去 0.5,减到零时用 reason = "duration" 触发 onExpire

event.TICK_MS500,所以每一步正好推进 0.5 秒。你写的秒数会被对齐到这个刻度上:

你声明的实际发生的
duration = 10.0第 20 次心跳时结束
duration = 0.2第 1 次心跳,也就是 :apply 之后 0.5 秒结束
interval = 1.0每 2 次心跳触发一次 onTickelapsed 是 1.0、2.0、3.0
interval = 0.75elapsed 1.0、1.5、2.5、3.0、4.0 触发 onTick
interval = 0.1一次心跳里连着调用五次 onTick

累加器会留着余数,所以就算 interval 不是 0.5 的倍数,长期看频率仍然是对的,被对齐到刻度上 的只是每次 tick 落下的那一刻。小于 0.5 的 interval 不会变慢,而是在同一帧里触发好几次。

onTick 在检查剩余时间之前处理,所以当 durationinterval 的整数倍时,最后一次 tick 会和 onExpire 落在同一次心跳上:

Effect{ id = "example:Two", duration = 2.0, interval = 1.0, events = {
    onTick   = function(e, target, ctx) print("tick", ctx.elapsed) end,
    onExpire = function(e, target, ctx) print("expire", ctx.reason, ctx.elapsed) end,
} }:apply(Player.character())
tick    1.0
tick    2.0
expire  duration  2.0

一次施加的一生:

onApply 就在 :apply 这次调用里立刻跑,不等下一次心跳。之后的一切都走刻度。

目标,以及怎样算还活着

一个目标上,每个效果 id 只能带一次施加。存放它们的表,键是目标的 GetFullName(),而不是你传进来 的那个句柄。UE4SS 每次查询都会新造一个 userdata 包装,所以指向同一个 pawn(游戏里的角色实体)的两个 引用并不是同一个 Lua 值;用句柄索引的表,会把你的施加记在调用方碰巧拿着的那个包装底下。普通的 Lua 表目标本身身份稳定,就用它自己当键;而一个答不出自己名字的对象则退回用句柄。

目标算活着,有两种情况:它是一张普通的 Lua 表,或者它是引擎数据而 IsValid() 仍然返回 true。每次心跳都会检查所有还在跑的施加,所以 pawn 一旦变成无效,下一拍就用 reason = "target_gone" 结束它身上的施加,并把空掉的桶丢掉。键变成字符串之后,正是这趟清扫在给 这张表兜底:字符串键不会被回收,没有垃圾回收器可以指望——而且这趟清扫本身是更强的保证,它在 pawn 消失的那一拍就跑,而不是等 Lua 下一次回收。

不带目标调用 :apply() 也可以。目标为 nil 的施加会进到一个全局的桶里,它们的处理函数 拿到的 targetnil

local Curse = Effect{ id = "example:Curse", duration = 60.0 }

Curse:apply()                       -- global application, no target
Curse:isActive()                    -- true
Effect.activeOn()                   -- { "example:Curse" }

这个桶也是目标丢失时的去处。世界加载之前 Player.character() 返回 nil,所以在标题 画面调用 MyEffect:apply(Player.character()),效果会悄悄地施加到全局,还会报告成功。 传之前先检查目标。

施加只放在内存里,从不写进存档。在同一个世界里,pawn 身上的施加会在这个 pawn 失效时自己 结束;全局的那一个,或者以普通 Lua 表为键的那些,会一直留到 duration 用完,或者你调用 :remove。世界卸载会把它们一次全部结束,见世界卸载的时候

世界卸载的时候

core/event 盯着玩家 pawn,它一失效就发出 world.left。所有目标上的所有施加,加上全局 桶里的那些,都会用 reason = "world_left" 触发 onExpire 然后被丢掉。之后 Effect.activeOn 返回空,后面的心跳也没有东西可推进了。

"world_left" 是一个单独的 reason,所以处理函数能把世界卸载和持续时间到期、:remove、 目标消失区分开。用它来清理你自己为那个目标记下的东西,而不去跑正常到期时才该跑的玩法。

content/hunger.lua
local event = require("palforge.core.event")
local log   = require("palforge.utils.log").scope("hunger")

local drained = setmetatable({}, { __mode = "k" })   -- target -> your own bookkeeping

local Hunger = Effect{
    id          = "example:Hunger",
    name        = "Hunger",
    description = "Drains while the player is out in the world.",
    duration    = 120.0,
    interval    = 10.0,
    events = {
        onApply = function(effect, target, ctx)
            drained[target] = 0
        end,
        onTick = function(effect, target, ctx)
            drained[target] = (drained[target] or 0) + 1
        end,
        onExpire = function(effect, target, ctx)
            local total = drained[target] or 0
            drained[target] = nil
            if ctx.reason == "world_left" then
                log.info("world unloaded, hunger released at " .. total)
                return                       -- no payout: the world is going away
            end
            log.info(string.format("hunger ended (%s) after %d ticks", ctx.reason, total))
        end,
    },
}

-- Nothing is restored for you. The next world starts empty, so apply again on world.ready.
event.on("world.ready", function()
    local me = Player.character()
    if me then Hunger:apply(me, { source = "world.ready" }) end
end)

return Hunger

施加只活在内存里:不写磁盘,也不会排队等下一个世界。想在换了世界之后把效果找回来,就像 上面那样,从你自己的触发点重新施加一次。

事件

四个处理函数都会真的触发。每个都以 handler(effect, target, ctx) 的形式调用:

  • effect — 这份定义的句柄,所以 :remove:stacksOn:timeLeft 随手就能用
  • target — 传给 :apply 的东西;全局施加时是 nil
  • ctx — 一张普通的表,内容按事件不同

Prop

Type

ctx 上的键:

事件ctx.effectctx.stacksctx.elapsedctx.reason你传给 :apply 的 ctx
onApply效果 id1看得到
onTick效果 id当前层数距 apply 的秒数看不到
onStack效果 id加一之后的层数看得到
onExpire效果 id结束时的层数距 apply 的秒数见下看不到

onExpire 上的 ctx.reason 是这四个字符串之一:

reason原因
"duration"声明的 duration 用完了
"removed"调用了 :remove(target)
"target_gone"目标不再有效
"world_left"世界卸载,所有正在运行的施加都被释放

下面的片段里,logrequire("palforge.utils.log").scope("example")

local Shield = Effect{
    id       = "example:Shield",
    name     = "Shield",
    duration = 12.0,
    interval = 3.0,
    events = {
        onApply = function(effect, target, ctx)
            log.info(string.format("%s up on %s", ctx.effect, tostring(target)))
        end,
        onTick = function(effect, target, ctx)
            log.info(string.format("%.1fs elapsed, %.1fs left",
                ctx.elapsed, effect:timeLeft(target) or -1))
        end,
        onExpire = function(effect, target, ctx)
            if ctx.reason == "target_gone" then return end
            log.info("shield down: " .. ctx.reason)
        end,
    },
}

传入你自己的上下文

:apply 的第二个参数会送到 onApplyonStack。它不会被复制。这两个处理函数拿到的 ctx 是一张只装着 effectstacks 的小表,你的表垫在它后面,所以 ctx.source 会一路 读到你的表里。

local Mark = Effect{
    id       = "example:Mark",
    duration = 20.0,
    events = {
        onApply = function(effect, target, ctx)
            log.info("marked by " .. tostring(ctx.source))   -- reads through __index
            for k in pairs(ctx) do print(k) end              -- prints only: effect, stacks
        end,
    },
}

Mark:apply(pawn, { source = "trap", power = 3 })

所以要按名字取键。用 pairs 遍历 ctx 是看不到它们的。onTickonExpire 也看不到, 这两个自己另建上下文,从不碰你传进去的表。

处理函数里出错的时候

每一次处理函数调用都包在 pcall 里。抛出错误的处理函数不会让心跳停下,不会影响别的效果, 也不会把它自己关掉,但这个错误会被直接丢掉,连日志都不记。想看到它,就自己把有风险的部分 包起来:

onTick = function(effect, target, ctx)
    local ok, err = pcall(function() target:SomethingNative() end)
    if not ok then log.err("tick failed: " .. tostring(err)) end
end,

叠层

对一个已经带着这个效果的目标再次施加,永远不会创建第二次施加。它一定会做两件事,而 stackable 只管第一件:

  1. 如果 stackable 是 true 并且当前层数还没到 maxStacks,就加一层
  2. 把剩余时间重置回声明的完整 duration

然后执行 onStack,而不是 onApply

local Rage = Effect{
    id        = "example:Rage",
    name      = "Rage",
    duration  = 8.0,
    stackable = true,
    maxStacks = 5,
    events = {
        onApply = function(effect, target, ctx) log.info("rage 1") end,
        onStack = function(effect, target, ctx) log.info("rage " .. ctx.stacks) end,
    },
}

Rage:apply(pawn)          -- onApply, stacks = 1, 8.0s left
Rage:apply(pawn)          -- onStack, stacks = 2, back to 8.0s
Rage:apply(pawn)          -- onStack, stacks = 3
Rage:stacksOn(pawn)       --> 3

到了上限之后,:apply 仍然执行 onStack,仍然刷新持续时间,只有层数不再动:

for _ = 1, 20 do Rage:apply(pawn) end
Rage:stacksOn(pawn)       --> 5, and onStack fired 19 times

maxStacks 的默认值是 1。只写 stackable = true,你得到的效果会执行 onStack、会刷新 持续时间,但层数永远停在 1。两个都要写。

不叠层的效果行为一样,只是少了计数器,再施加一次就是刷新持续时间的办法:

local Wet = Effect{
    id       = "example:Wet",
    duration = 6.0,          -- stackable defaults to false
    events = {
        onStack = function(effect, target, ctx)
            log.info("still wet, timer back to 6s, stacks = " .. ctx.stacks)  -- always 1
        end,
    },
}

每个目标、每个效果 id 只有一个层数,而且它不会自己往下掉。不管手上有多少层,一次 onExpire 就把整件事结束。想让层数一层一层地掉,见下面的做法

句柄

Effect{ ... }Effect.get(id)Effect.get_all() 给你的都是一个 Effect.Handle。 操作运行中效果的方法要把目标当参数传进去,nil 表示全局的那个桶;只读取声明内容的方法 不接参数。

方法返回说明
:apply(target, ctx)boolean开始一次施加,或者重新叠一层;只有接不上心跳时才是 false
:remove(target)boolean结束了一次施加就是 true,本来就没有就是 false
:isActive(target)boolean现在是不是正在运行
:stacksOn(target)integer当前层数,没在运行时是 0
:timeLeft(target)number?剩余秒数;效果没有 duration 时是 nil,没在运行时是 0
:name()string声明的 name,没有就是 id
:description()string?声明的 description
:duration()number?声明的 duration
:interval()number?声明的 interval
:iconOf()any?声明的 icon;效果不会去游戏的数据表里查
local Poisoned = Effect.get("Poison")

if not Poisoned:isActive(pawn) then
    Poisoned:apply(pawn)
end

print(Poisoned:stacksOn(pawn))   --> 1
print(Poisoned:timeLeft(pawn))   --> 10.0, then 9.5, 9.0, ...
Poisoned:remove(pawn)            --> true, and onExpire fires with reason "removed"
Poisoned:remove(pawn)            --> false, nothing left to remove

:timeLeftnil0 表示两种不同的情况。nil 表示正在跑,而且没有终点,也就是 声明时没写 duration 的效果。0 表示没在跑。需要分清楚的时候用 :isActive

四个 on* 方法在句柄上也有。调用其中一个,会当场执行声明的处理函数,除此之外什么都不碰: 不启动计时器,不计层数,也不会到期。用它们来复用处理函数的内容,而不是用来驱动效果。

Regen:onTick(pawn, { effect = Regen.id, elapsed = 0, stacks = 1 })  -- just calls the body
Regen:apply(pawn)                                                    -- this is what starts it

查找效果

Effect.get("Poison")        -- an existing definition, else a thin one over that id. Never nil
Effect.get_all()            -- every registered effect, as handles
Effect.activeOn(target)     -- the ids currently live on target, sorted
Effect.Class                -- the base definition class, for subclassing

Effect.get 从不返回 nil。对于没人定义过的 id,你会拿到一个能用的句柄,只是查询结果都是 空的::duration()nil:name() 就是那个 id,它的 :apply 会开始一个没有处理函数、 永远不结束的东西。这只有当成一个标记、再用 :isActive 去查的时候才有用。

同一个 id 定义两次会替换掉注册:最后一次调用胜出,Effect.get 返回最新的定义。你之前拿到 的句柄仍然指着它被创建时的那份定义,所以通过它启动的东西,用的还是旧的计时和旧的处理函数。

想列出一个目标现在带着什么,用 Effect.activeOn(target)。它返回排好序的 id 数组,你再用 Effect.get 把它们打开:

for _, id in ipairs(Effect.activeOn(pawn)) do
    local e = Effect.get(id)
    print(string.format("%-16s %d stack(s), %s",
        e:name(), e:stacksOn(pawn), tostring(e:timeLeft(pawn))))
end
Burn             1 stack(s), 3.5
Rage             4 stack(s), 6.0

清空一个目标身上的全部效果,就是这个循环加上 :remove

local function cleanse(target)
    for _, id in ipairs(Effect.activeOn(target)) do
        Effect.get(id):remove(target)
    end
end

现成的状态异常

native/effects.lua 把游戏声明的每一个状态异常都变成了可以直接施加的效果。没有另一份需要维护的 名单:这份目录就是 nativeStatus 接受的那 38 个名字。

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

effects.CATALOG            --> all 38 names, sorted: { "AttackUp", "Burn", "CollectItem", ... }
effects.get("Sleep")       -- an Effect handle for any of them, built the first time you ask
effects.get("Sleep"):apply(pawn)
effects.get("Nonsense")    --> nil, not a name this build declares

effects.get 返回的句柄带着 nativeStatus,但没有 duration —— 这是它唯一让人意外的地方: 施加之后状态异常就亮着,而且一直亮着。没有任何东西会替你把它摘掉,所以要自己摘。

local sleep = effects.get("Sleep")

sleep:apply(pawn)      -- the game's sleep, on, and staying on
sleep:remove(pawn)     -- off again

其中三个不一样,值得知道是哪三个:PoisonBurnFreeze 写好了计时,所以它们会自己结束。

iddurationintervalnativeStatus
Poison10.01.0"Poison"
Burn5.01.0"Burn"
Freeze3.0"Freeze"
effects.Burn:apply(pawn)
effects.Burn:timeLeft(pawn)   --> 5.0

effects.get("Burn") 给你的就是这个带计时的句柄,而不是另造一个没有 duration 的。这三个是用自己的 id 注册好的,所以 Burn 只有一个,就是带着那五秒的那个。Effect.get("Burn") 找到的也是同一份定义。

三个都没有声明处理函数,因为不需要:状态异常本来就按游戏说的那样起作用,而一个空的处理函数只会在 每次 tick 上白花一点力气。想在它之上加自己的东西 —— 伤害、提示、计数 —— 就用同样的 nativeStatus 配上 onTick,自己声明一个效果,下面的 持续伤害 就是这个写法。

CATALOG 里的名字并不是每一个都是整合包想要的状态异常。这份清单是游戏自己的,所以它也包含内部的 计时器和记账项 —— ControlSPDrownCheckUNKOTimerMoratorium,以及 PalEnhancement 系列。 它们全都照原样提供,而不是被悄悄剔掉:剔掉反而会让人以为不支持,其实只是它们比较古怪而已。整合包 最常用的,是上面 游戏自己的状态异常 里列出的那十个。

为每个目标保存自己的数据

一次施加只保存计时需要的东西:已过去的时间、剩余时间和层数。处理函数拿到的状态就只有 ctx.elapsedctx.stacks,别的都得你自己来。

data 不是放它的地方。它存在定义上,所以这个效果碰到的每个目标共用同一张表,而且 Effect.Handle 没有任何方法能把它读回来。

改成自己维护一张表,用目标做键,并在 onExpire 里把这一条清掉:

local state = setmetatable({}, { __mode = "k" })   -- target -> your table

local Bleed = Effect{
    id       = "example:Bleed",
    duration = 6.0,
    interval = 1.0,
    events = {
        onApply = function(effect, target, ctx)
            state[target] = { total = 0, source = ctx.source }
        end,
        onTick = function(effect, target, ctx)
            local s = state[target]
            if not s then return end
            s.total = s.total + 5 * ctx.stacks
        end,
        onExpire = function(effect, target, ctx)
            local s = state[target]
            state[target] = nil
            if s then log.info(string.format("bleed dealt %d from %s", s.total, tostring(s.source))) end
        end,
    },
}

弱引用键能防止这张表把一个已经消失的 pawn 一直留在内存里。它给不了你的是一个稳定的键:用 UE4SS 句柄索引的表是按 userdata 身份索引的,而 onApply 里的句柄不一定和之后某次 onTick 里的句柄是同 一个 Lua 值——:apply 每次重新施加时,都会用调用方的句柄刷新这次施加保存的那个。如果你的记账需要 挺过一次重新施加,就照运行时的做法,用名字做键:

local uo = require("palforge.core.uobject")

local key = uo.key(target) or target   -- GetFullName, or the value itself for a plain table
state[key] = { total = 0 }

全局的施加以 nil 为键,而 Lua 的表不能用 nil 当键,所以那种情况下如果需要状态,请自己用一个 标记对象。

做法示例

持续回复

给玩家的持续回复,断了就重新挂上。回复本身要你自己写,PalForge 没有血量相关的 API,所以 这一版每秒给玩家一个 Berries,并用注释标出直接回复该写在哪。

content/regen.lua
local event = require("palforge.core.event")
local log   = require("palforge.utils.log").scope("regen")

local Regen = Effect{
    id          = "example:Regen",
    name        = "Regeneration",
    description = "Restores a little every second for ten seconds.",
    duration    = 10.0,
    interval    = 1.0,
    events = {
        onApply = function(effect, target, ctx)
            log.info("regen up for " .. tostring(effect:duration()) .. "s")
        end,
        onTick = function(effect, target, ctx)
            -- Your heal goes here; this is the part PalForge does not provide.
            -- :give always adds to the LOCAL PLAYER's inventory, whatever `target` is.
            Item.get("Berries"):give(1)
        end,
        onExpire = function(effect, target, ctx)
            log.info("regen over after " .. tostring(ctx.elapsed) .. "s: " .. ctx.reason)
        end,
    },
}

-- Re-arm it every 15 seconds, but only while there is a player to arm it on.
event.every(15000, function()
    local me = Player.character()
    if me and not Regen:isActive(me) then
        Regen:apply(me, { source = "campfire" })
    end
end)

return Regen

event.every 也被对齐到和效果计时相同的 500 毫秒心跳上,所以两者不会走偏。

持续伤害

Poison 已经声明了计时,10 秒、每 1 秒一次,也会点亮游戏自己的中毒,但它没有 onTick,所以 不会自己造成伤害。在它旁边定义一个你自己的效果,给 tick 补上内容,然后两个一起施加:游戏负责 显示状态异常,你的效果负责伤害。

content/poison_bite.lua
local effects = require("palforge.native.effects")
local log     = require("palforge.utils.log").scope("poison")

local damage = setmetatable({}, { __mode = "k" })   -- target -> accumulated damage

local Venom = Effect{
    id          = "example:Venom",
    name        = "Venom",
    description = "Ticks damage while it lasts, harder with every stack.",
    duration    = 10.0,
    interval    = 1.0,
    stackable   = true,
    maxStacks   = 3,
    events = {
        onApply = function(effect, target, ctx)
            damage[target] = 0
        end,
        onStack = function(effect, target, ctx)
            log.info("venom deepens to " .. ctx.stacks)
        end,
        onTick = function(effect, target, ctx)
            local perTick = 4 * ctx.stacks
            damage[target] = (damage[target] or 0) + perTick
            -- deal `perTick` to `target` here through whatever call you have
        end,
        onExpire = function(effect, target, ctx)
            local total = damage[target] or 0
            damage[target] = nil
            log.info(string.format("venom ended (%s) after %.1fs, %d total",
                ctx.reason, ctx.elapsed, total))
        end,
    },
}

---Poison a pawn: the game's own ailment plus the effect that carries the damage.
local function poison(pawn)
    if not pawn then return false end
    effects.Poison:apply(pawn)
    return Venom:apply(pawn, { source = "bite" })
end

-- A bitten pal keeps poisoning itself while the venom lasts. Defining "ChickenPal" here
-- REPLACES the curated demo definition in native/pals.lua - one id, one definition.
Pal{
    id   = "ChickenPal",
    name = "Chicken Pal",
    events = {
        onDamaged = function(pal, ctx)
            poison(ctx.actor)
        end,
    },
}

return { effect = Venom, poison = poison }

每次 onDamaged 都重新施加一次,所以毒回到 10 秒,最深叠到三层。等这只鸡最后消失,下一次 心跳会用 reason = "target_gone" 结束这两次施加,累计值也被清掉。

叠层的增益

层数不断累积,每次挨打都刷新,叠到上限时再触发点什么。

content/rage.lua
local effects = require("palforge.native.effects")
local event   = require("palforge.core.event")
local log     = require("palforge.utils.log").scope("rage")

local MAX = 5

local Rage = Effect{
    id          = "example:Rage",
    name        = "Rage",
    description = "Builds with every hit taken and ignites at full stacks.",
    duration    = 8.0,
    interval    = 2.0,
    stackable   = true,
    maxStacks   = MAX,
    events = {
        onApply = function(effect, target, ctx)
            log.info("rage 1/" .. MAX)
        end,
        onStack = function(effect, target, ctx)
            log.info(string.format("rage %d/%d", ctx.stacks, MAX))
            if ctx.stacks == MAX then
                effects.Burn:apply(target)      -- 5s of the native Burn id
            end
        end,
        onTick = function(effect, target, ctx)
            -- apply the per-stack bonus here; ctx.stacks is the current count
        end,
        onExpire = function(effect, target, ctx)
            log.info(string.format("rage fell off at %d stacks (%s)", ctx.stacks, ctx.reason))
        end,
    },
}

-- Any damaged pal builds rage. The duration resets on every hit, so a pal that keeps
-- taking damage keeps the stacks; eight quiet seconds and the whole thing drops at once.
event.on("pal.damaged", function(ctx)
    if ctx.actor then Rage:apply(ctx.actor, { source = "damage" }) end
end)

return Rage

注意 onExpire 不做的事:层数不会一层一层掉。整件事一次结束,当时是几层就是几层。

不要在 onTickonExpire 里开始一次新的施加。这些处理函数运行的时候,运行时正在用 pairs 遍历它存放施加的表,而往正在被遍历的表里加键,在 Lua 里是未定义行为:比如给一个 还没有施加的目标施加、给同一个目标施加另一个效果,或者在某个效果自己的 onExpire 里再施加 它自己,而它的键刚在调用你之前被清掉。在自己的 onTick 里重新施加同一个效果,只是改写已经 存在的那次施加上的字段,:remove 也只是清掉一条,所以这两种都是安全的。世界卸载时的释放 遍历的是同样的表,所以以 reason = "world_left" 触发的 onExpire 也受同一条规则约束。

衰减请交给另一个 tick 订阅者去做。你自己的订阅者跑在那次遍历之外,它运行的时候没有任何 存放施加的表正在被遍历:

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

---Bleed one stack of `effect` off `target` every two seconds.
---Returns the subscription, so call :unsubscribe() on it when you are done.
local function decay(effect, target)
    return event.every(2000, function()
        local n = effect:stacksOn(target)
        if n > 1 then
            effect:remove(target)                                -- fires onExpire
            for _ = 1, n - 1 do effect:apply(target) end          -- rebuild one lower
        end
    end)
end

目标死亡时结束的效果

目标一旦不再有效,那次施加就会结束,但这是兜底,不是死亡钩子:死掉的 pawn 可能还会有效 一阵子,而且这个检查要等到下一次心跳才跑。想让效果在死亡那一刻就消失,把它挂到 pal.death 频道上。

content/frostbite.lua
local effects = require("palforge.native.effects")
local event   = require("palforge.core.event")
local log     = require("palforge.utils.log").scope("frostbite")

local killed = setmetatable({}, { __mode = "k" })   -- pawns whose effects we cleared on death

local Frostbite = Effect{
    id          = "example:Frostbite",
    name        = "Frostbite",
    description = "Slows a captured pal until it thaws.",
    duration    = 30.0,
    interval    = 5.0,
    events = {
        onApply = function(effect, target, ctx)
            log.info("frostbite on " .. tostring(target))
        end,
        onTick = function(effect, target, ctx)
            log.info(string.format("still frozen at %.1fs", ctx.elapsed))
        end,
        onExpire = function(effect, target, ctx)
            local why = killed[target] and "the target died" or ctx.reason
            killed[target] = nil
            log.info("frostbite cleared: " .. why)
        end,
    },
}

-- Freeze plus the long frostbite when a sheepball is caught.
Pal{
    id   = "SheepBall",
    name = "Sheepball",
    events = {
        onCaptured = function(pal, ctx)
            effects.Freeze:apply(ctx.actor)      -- 3s of the game's own freeze
            Frostbite:apply(ctx.actor, { source = "sphere" })
        end,
    },
}

-- Death clears every PalForge effect on the pawn, right away.
event.on("pal.death", function(ctx)
    local pawn = ctx.actor
    if not pawn then return end
    killed[pawn] = true
    for _, id in ipairs(Effect.activeOn(pawn)) do
        Effect.get(id):remove(pawn)
    end
end)

return Frostbite

:remove 报告的 reason 永远是 "removed",所以光看 onExpire 分不出死亡和手动清除。你能 拿到的 reason 只有 "duration""removed""target_gone""world_left" 这四种,也 加不了第五种,所以上面的死亡钩子先把 pawn 记进一张弱引用键的表,再由 onExpire 读回来。

效果很短的话,不挂死亡钩子也是个不错的选择:pawn 消失时变成无效,效果会在一次心跳之内用 reason = "target_gone" 自己结束。

小结

  • Effect{ ... } 做一个状态。必须写的字段只有 id
  • duration 是持续多久,intervalonTick 多久跑一次。都是秒,都会被对齐到 0.5 秒的心跳上。
  • :apply(target) 开始,:remove(target) 提前结束。:isActive:timeLeft:stacksOn 告诉你它现在到哪一步了。
  • 再施加一次会刷新计时;stackablemaxStacks 都写了的话,还会加一层。
  • nativeStatus 会在效果运行期间点亮游戏自己的状态异常——通过 PalCharacter.StatusComponent, 已观测到能用——而且它那 38 个名字不区分大小写,这一点和道具 id 或注册键不同。除此之外效果对角色 做了什么,是你写在 onApplyonTickonExpire 里的代码。
  • 想为每个目标记住的东西,放进你自己的表,在 onExpire 里清掉。需要挺过重新施加的话,用 uo.key(target) 做键。

接下来读 Skill,你就能自己发动一个技能,并给它命中的对象附上效果。

On this page