PalForge
API リファレンス

Effect

キャラクターに付けるステータス。継続回復、継続ダメージ、時間で切れるバフを作れます

このページでできるようになること

  • キャラクターを少しずつ、好きな長さだけ回復する
  • 毒や炎で、1 秒ごとにダメージを与える
  • 時間がたつと自然に切れる一時的なバフを付ける
  • 同じステータスを重ねがけして、かかり直すたびに時間をリセットする
  • ステータスを途中で消したり、残り時間を調べたりする
  • 自分のエフェクトが動いている間、ゲーム本体の状態異常(本物の毒や炎のアイコン)を点灯させる

エフェクトは、キャラクター(プレイヤーやパル)に付けるステータスです。バフ、デバフ、 継続ダメージ、シールドなどがこれにあたります。どれだけ続くか、どれくらいの間隔で何かを するかを書けば、時間の管理は 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())

10 秒後、onTick を 10 回実行したうえでプレイヤーから外れます。

エフェクトは、ゲーム本体の状態異常(Palworld が体力バーに出す毒・炎・氷のマーク)を点灯させる こともできます。nativeStatus にその名前を書くと、エフェクトが適用された時点で点灯し、 終了した時点で消えます。それ以外にエフェクトが実際に何をするかは、これまでどおりハンドラに 書いてください。回復・ダメージ・バフ・ログ出力は onApply / onTick / onExpire の中です。 名前の一覧と決まりごとは、下の ゲーム本体の状態異常 にあります。

エフェクトを定義する

Effect{ ... } と書けば 1 つ作れます。返ってくるのはハンドルで、: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 のフィールド

たいていのエフェクトに必要なのは 3 つです。idduration、そして繰り返し何かをするなら interval。渡せるものは以下がすべてです。

Prop

Type

durationinterval の単位はで、小数点も使えます。data は定義に 1 つだけ 置かれるため、すべての対象で同じテーブルを共有します。対象ごとに覚えておきたいものは 対象ごとのデータを自分で持つを参照してください。

ゲーム本体の状態異常

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) へ。引数は整数 1 つで、構造体も FName もありません。 status.add がキャラクターに AttackUpEPalStatusID 26)を付け、ゲームは GetExecutionStatus 経由でその状態異常を「有り」と読み返し、status.remove で外すと今度は「無し」と読み返しました。 シグネチャは、インストール済みバイナリより 1 パッチ前に生成されたダンプ由来ですが、実機のクラスは 3 つすべてで一致しました。呼び出しは今も core/signature を通り、実機のクラスが宣言していなければ 拒否されます。つまり false は「呼び出しが発火しなかった」という意味であって、「対象が耐性を 持っている」ではありません。

名前はゲーム本体の綴りです。パックがよく使うのは PoisonStunSleepBurnFreezeElectricalDarknessWetnessAttackUpDefenseUp です。全部で 38 種類あり、 require("palforge.core.status").names() がソート済みの全一覧を返します。

大文字小文字と、いまどの層にいるか

nativeStatus大文字小文字を区別しませんcore/status が本物の表の隣に小文字化した表を 持っているため、"poison" でも "Poison" と同じく EPalStatusID 5 に当たります。ここでそれが 安全なのは一般化できない理由によります。語彙が固定で閉じていて、全部が同梱されているからです。 だから大文字小文字を畳むコストはテーブル 1 つで、ゲーム側の綴り方を覚える手間が省けます。

もう一方の層は大文字小文字を区別し、それを和らげる手段はありません。DataTable の行や object_manager のレジストリキーを指す id — "pack_Potion"Item.get、アイコン行の参照 — は 正確に綴る必要があります。その名前集合はゲームのものであり、PalForge は畳むための対応表を持って いないからです。

名前が指すもの大文字小文字
エンジンの enumnativeStatus = "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 ms ごとに動くハートビートが 1 つあり、動作中のエフェクトはすべて それに合わせて進みます。

1 つの対象で動いている 1 つのエフェクトをアプリケーションと呼びます。経過時間、 残り時間、スタック数を持っています。:apply で始まり、残り時間が尽きたとき、取り除いた とき、対象がいなくなったとき、ワールドが破棄されたときに終わります。

ハートビートへの接続は、どこかで最初に :apply が呼ばれたときに行われます。モジュールを require するだけならコストはかからず、購読者も増えません。接続は 2 つ同時です。すべての アプリケーションを進める tick と、ワールドが消えるときに解放する world.left です。 その時点で接続に失敗した場合、:applyfalse を返してアプリケーションは作られません。 それ以外の呼び出しは true を返します。

1 回のハートビートは、動作中のアプリケーションそれぞれに対して次の順で処理を行います。

  1. 対象がすでに有効でなければ、reason = "target_gone"onExpire を発火して破棄する
  2. そうでなければ elapsed0.5 を加算する
  3. interval のアキュムレータに 0.5 を加算し、interval に達している間 onTick を発火する
  4. 残り時間から 0.5 を引き、0 に達したら reason = "duration"onExpire を発火する

event.TICK_MS500 なので、1 ステップで進むのは正確に 0.5 秒です。指定した秒数は このグリッドに丸められます。

宣言実際に起きること
duration = 10.020 回目のハートビートで終了
duration = 0.21 回目のハートビート、:apply の 0.5 秒後に終了
interval = 1.02 回に 1 回のハートビートで onTickelapsed は 1.0, 2.0, 3.0
interval = 0.75elapsed 1.0, 1.5, 2.5, 3.0, 4.0 で onTick
interval = 0.11 回のハートビートの中で onTick が連続 5 回

アキュムレータは余りを持ち越すため、0.5 の倍数でない interval でも長い目で見た発火の ペースは正しく保たれます。グリッドに丸められるのは、各 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

1 つのアプリケーションの一生は次のとおりです。

onApply は次のハートビートではなく、:apply の呼び出しの中ですぐ実行されます。 それ以降はすべてグリッド上で進みます。

対象と、生きているかどうかの判定

1 つの対象には、エフェクト id ごとに 1 つのアプリケーションが乗ります。それを保持している テーブルのキーは、渡したハンドルではなく対象の GetFullName() です。UE4SS は参照のたびに userdata のラッパーを新しく作るため、同じポーンへの 2 つの参照は同じ Lua の値になりません。 ハンドルで索引するテーブルは、呼び出し側がたまたま持っていたラッパーの下にアプリケーションを しまい込むことになります。素の Lua テーブルの対象は同一性が安定しているので自分自身をキーに し、自分の名前に答えないオブジェクトはハンドルにフォールバックします。

対象が生きているとみなされるのは、素の Lua テーブルである場合か、エンジンのデータで IsValid() がまだ true を返す場合です。ハートビートごとに生きているアプリケーションが全部 確認されるので、ポーンが無効になれば次の拍で reason = "target_gone" として終了し、空になった バケットも捨てられます。キーが文字列になった今、このテーブルの大きさを抑えているのはこの掃除 です。文字列キーは回収されないので頼れる GC はありません。そしてこの掃除のほうが保証としては 強く、Lua が次に回収するときではなく、ポーンが消えたその拍で走ります。

対象なしの :apply() も使えます。対象が nil のアプリケーションは 1 つのワールド全体用の バケットに入り、ハンドラは target = nil を受け取ります。

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()) を呼ぶと、 エフェクトは黙ってワールド全体に適用され、成功が報告されます。渡す前に対象を確認してください。

アプリケーションはメモリ上に保持されるだけで、保存されません。1 つのワールドの中では、ポーンに 付いたアプリケーションはそのポーンが無効になった時点で自然に終わり、ワールド全体のものや素の テーブルをキーにしたものは duration が尽きるか :remove を呼ぶまで残ります。ワールドの破棄は それらを一度にすべて終わらせます。ワールドが破棄されるときを参照して ください。

ワールドが破棄されるとき

core/event はプレイヤーポーンを見張っていて、有効でなくなった瞬間に world.left を emit します。すべての対象のすべてのアプリケーションが、ワールド全体のバケットの分も含めて reason = "world_left"onExpire を発火し、破棄されます。その後 Effect.activeOn は 空を返し、以降のハートビートには進めるものが残りません。

"world_left" は専用の reason なので、ハンドラはワールドの破棄を、duration による終了・ :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

アプリケーションはメモリ上にしかありません。ディスクには何も書かれず、次のワールドへ 持ち越すキューもありません。ワールドをまたいでエフェクトを戻したいときは、上のように 自分のトリガーから掛け直してください。

イベント

4 つのハンドラはすべて実際に発火します。いずれも 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 からの秒数下記参照見えない

onExpirectx.reason は次の 4 つの文字列のいずれかです。

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 の第 2 引数は onApplyonStack に届きます。コピーはされません。これらの ハンドラが受け取る ctxeffectstacks を持つ小さなテーブルで、その後ろに渡した テーブルが控えています。そのため 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 で走査しても見えません。onTickonExpire からも見えません。この 2 つは独自のコンテキストを組み立てるため、渡した テーブルを一切参照しません。

ハンドラの中でエラーが起きたとき

ハンドラの呼び出しはすべて 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,

スタック

すでにそのエフェクトが付いている対象に再度 :apply しても、2 つ目のアプリケーションは 作られません。必ず次の 2 つが起き、stackable が効くのは 1 つ目だけです。

  1. stackable が true でかつ現在のスタック数が maxStacks 未満なら、スタックを 1 増やす
  2. 残り時間を、宣言した duration の満量にリセットする

そのうえで、onApply の代わりに onStack が実行されます。

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

上限に達したあとも、:applyonStack を実行し、残り時間もリセットします。止まるのは カウントだけです。

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

maxStacks の既定値は 1 です。stackable = true だけを書くと、onStack は実行されて 残り時間もリセットされるのに、カウントは 1 から動かないエフェクトになります。両方を 指定してください。

スタックしないエフェクトも、カウンタがないだけで挙動は同じです。もう一度 :apply すれば 残り時間をリセットできます。

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 の組につき 1 つで、自然には減りません。onExpire は 1 回で、 保持しているスタック数に関係なく全体を終わらせます。1 スタックずつ落としたい場合は 下のレシピを参照してください。

ハンドル

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 で判定してください。

4 つの 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.getnil を返すことはありません。誰も定義していない id に対しては、問い合わせが 空のハンドルが返ります。:duration()nil:name() は id です。その :apply は、 ハンドラを持たず永久に続くものを開始します。これは :isActive で確認するマーカーとしてしか 役に立ちません。

同じ id を 2 回定義すると登録が置き換わります。最後の呼び出しが勝ち、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

そのうち 3 つだけは別で、どれかを知っておく価値があります。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 の無い 2 つ目ではありません。 3 つはそれぞれ自分の id で登録済みなので、Burn はひとつだけ、5 秒が入っているものだけです。 Effect.get("Burn") も同じ定義を返します。

3 つともハンドラを宣言していません。必要ないからです。状態異常はゲームが決めたとおりに働きますし、 空のハンドラはティックごとにわずかな処理を増やすだけです。その上に自分のもの — ダメージ、メッセージ、 カウンタ — を載せたいときは、同じ nativeStatusonTick を持つエフェクトを自分で宣言してください。 下の 継続ダメージ がその形です。

CATALOG に並ぶ名前のすべてが、パックの使いたい状態異常というわけではありません。この一覧はゲーム 自身のものなので、内部のタイマーや管理用の値 — ControlSPDrownCheckUNKOTimerMoratoriumPalEnhancement 系 — も含まれます。黙って落とさずすべて出しているのは、外してしまうと「使えない」 ように見えてしまうからで、実際は「風変わり」なだけです。パックがよく使うのは、上の ゲーム本体の状態異常 に挙げた 10 個です。

対象ごとのデータを自分で持つ

アプリケーションが持っているのは、時間管理に必要なものだけです。経過時間、残り時間、 スタック数の 3 つです。ハンドラに渡される状態は ctx.elapsedctx.stacks がすべてで、 それ以外は自分で用意することになります。

data はその置き場所ではありません。これは定義に保持されるため、そのエフェクトを 適用したすべての対象で 1 つのテーブルが共有されますし、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,
    },
}

弱参照キーは、デスポーンしたポーンをこのテーブルが生かし続けるのを防いでくれます。ただし くれないものが 1 つあります。安定したキーです。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 に HP API はありません。そのため以下では 1 秒ごとに Berries を 1 つ渡し、直接回復を 書く位置をコメントで示しています。

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 ms のハートビートに丸められるため、両者がずれることは ありません。

継続ダメージ

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 秒に戻り、最大 3 スタックまで深くなります。 チキンが最終的にデスポーンすると、次のハートビートで両方が 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しないことに注意してください。スタックは 1 つずつ落ちません。そのとき 保持していたカウントのまま、まるごと終了します。

onTickonExpire の中から新しいアプリケーションを開始しないでください。これらの ハンドラは、ランタイムがアプリケーションのテーブルを pairs で走査している最中に実行され ます。走査中のテーブルにキーを追加する :apply は、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

対象が死んだら終わるエフェクト

対象が有効でなくなればアプリケーションは終わりますが、これは死亡フックではなく フォールバックです。死んだポーンがしばらく有効なままのこともありますし、判定は次の ハートビートまで走りません。死亡の瞬間にエフェクトを消したい場合は、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" の 4 つだけで、5 つ目を足すことはできません。だからこそ上の死亡フックは、先にポーンを 弱参照テーブルへ記録し、onExpire でそれを読み返しています。

短いエフェクトなら、死亡フックを付けないという選択も妥当です。デスポーンでポーンが無効になり、 1 ハートビート以内に reason = "target_gone" で自然に終わります。

まとめ

  • ステータスは Effect{ ... } で作ります。必ず書くのは id だけです。
  • duration が続く長さ、intervalonTick の間隔です。どちらも秒で、0.5 秒のハートビートに丸められます。
  • :apply(target) で開始し、:remove(target) で早めに終わらせます。:isActive:timeLeft:stacksOn で状態を調べられます。
  • もう一度 :apply すると残り時間がリセットされ、stackablemaxStacks を両方書いていればスタックも 1 増えます。
  • nativeStatus は、エフェクトが動いている間だけゲーム本体の状態異常を点灯させます(PalCharacter.StatusComponent 経由で動作が観測済み)。その 38 個の名前は大文字小文字を区別せず、アイテム id やレジストリキーとは そこが違います。それ以外に キャラクターへ何をするかは、onApply / onTick / onExpire に書く自分のコードです。
  • 対象ごとに覚えておきたいものは、自分のテーブルに入れて onExpire で消します。再適用をまたぐ 必要があるなら uo.key(target) をキーにしてください。

次は Skill を読むと、自分でアビリティを発動して、当たった相手に エフェクトを付けられます。

On this page