Effect
キャラクターに付けるステータス。継続回復、継続ダメージ、時間で切れるバフを作れます
このページでできるようになること
- キャラクターを少しずつ、好きな長さだけ回復する
- 毒や炎で、1 秒ごとにダメージを与える
- 時間がたつと自然に切れる一時的なバフを付ける
- 同じステータスを重ねがけして、かかり直すたびに時間をリセットする
- ステータスを途中で消したり、残り時間を調べたりする
- 自分のエフェクトが動いている間、ゲーム本体の状態異常(本物の毒や炎のアイコン)を点灯させる
エフェクトは、キャラクター(プレイヤーやパル)に付けるステータスです。バフ、デバフ、 継続ダメージ、シールドなどがこれにあたります。どれだけ続くか、どれくらいの間隔で何かを するかを書けば、時間の管理は PalForge が行います。
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 fieldid は好きな名前で構いません。エフェクトの 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 toolingSpec のフィールド
たいていのエフェクトに必要なのは 3 つです。id、duration、そして繰り返し何かをするなら
interval。渡せるものは以下がすべてです。
Prop
Type
duration と interval の単位は秒で、小数点も使えます。data は定義に 1 つだけ
置かれるため、すべての対象で同じテーブルを共有します。対象ごとに覚えておきたいものは
対象ごとのデータを自分で持つを参照してください。
ゲーム本体の状態異常
nativeStatus には、Palworld 自身の状態異常の名前を書きます。宣言しておくと、エフェクトが
適用された時点で PalForge が本物の状態異常を点灯させ、解除されたときや期限が切れたときに消します。
体力バーに出るマークはゲームが自分で出しているものと同じで、挙動もゲームが決めたとおりです。
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 がキャラクターに AttackUp(EPalStatusID 26)を付け、ゲームは GetExecutionStatus
経由でその状態異常を「有り」と読み返し、status.remove で外すと今度は「無し」と読み返しました。
シグネチャは、インストール済みバイナリより 1 パッチ前に生成されたダンプ由来ですが、実機のクラスは
3 つすべてで一致しました。呼び出しは今も core/signature を通り、実機のクラスが宣言していなければ
拒否されます。つまり false は「呼び出しが発火しなかった」という意味であって、「対象が耐性を
持っている」ではありません。
名前はゲーム本体の綴りです。パックがよく使うのは Poison、Stun、Sleep、Burn、Freeze、
Electrical、Darkness、Wetness、AttackUp、DefenseUp です。全部で 38 種類あり、
require("palforge.core.status").names() がソート済みの全一覧を返します。
大文字小文字と、いまどの層にいるか
nativeStatus は大文字小文字を区別しません。core/status が本物の表の隣に小文字化した表を
持っているため、"poison" でも "Poison" と同じく EPalStatusID 5 に当たります。ここでそれが
安全なのは一般化できない理由によります。語彙が固定で閉じていて、全部が同梱されているからです。
だから大文字小文字を畳むコストはテーブル 1 つで、ゲーム側の綴り方を覚える手間が省けます。
もう一方の層は大文字小文字を区別し、それを和らげる手段はありません。DataTable の行や
object_manager のレジストリキーを指す id — "pack_Potion"、Item.get、アイコン行の参照 — は
正確に綴る必要があります。その名前集合はゲームのものであり、PalForge は畳むための対応表を持って
いないからです。
| 名前が指すもの | 例 | 大文字小文字 |
|---|---|---|
| エンジンの enum | nativeStatus = "poison"、スキルの EPalWazaID | 区別しない |
| DataTable の行やレジストリ id | Item.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 です。
その時点で接続に失敗した場合、:apply は false を返してアプリケーションは作られません。
それ以外の呼び出しは true を返します。
1 回のハートビートは、動作中のアプリケーションそれぞれに対して次の順で処理を行います。
- 対象がすでに有効でなければ、
reason = "target_gone"でonExpireを発火して破棄する - そうでなければ
elapsedに0.5を加算する - interval のアキュムレータに
0.5を加算し、intervalに達している間onTickを発火する - 残り時間から
0.5を引き、0 に達したらreason = "duration"でonExpireを発火する
event.TICK_MS は 500 なので、1 ステップで進むのは正確に 0.5 秒です。指定した秒数は
このグリッドに丸められます。
| 宣言 | 実際に起きること |
|---|---|
duration = 10.0 | 20 回目のハートビートで終了 |
duration = 0.2 | 1 回目のハートビート、:apply の 0.5 秒後に終了 |
interval = 1.0 | 2 回に 1 回のハートビートで onTick。elapsed は 1.0, 2.0, 3.0 |
interval = 0.75 | elapsed 1.0, 1.5, 2.5, 3.0, 4.0 で onTick |
interval = 0.1 | 1 回のハートビートの中で onTick が連続 5 回 |
アキュムレータは余りを持ち越すため、0.5 の倍数でない interval でも長い目で見た発火の ペースは正しく保たれます。グリッドに丸められるのは、各 tick が着地する瞬間だけです。 0.5 未満の interval は遅くなるのではなく、同じフレーム内で何度も発火します。
onTick は残り時間のチェックより先に処理されます。そのため duration が interval の
倍数のときは、最後の 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.01 つのアプリケーションの一生は次のとおりです。
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・対象のデスポーンと区別できます。通常の終了時に走らせる処理は行わず、自分で
記録していたものだけを片付けたいときに使ってください。
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に渡した値。ワールド全体のアプリケーションではnilctx— 素のテーブル。中身はイベントごとに異なります
Prop
Type
ctx に載るキーは次のとおりです。
| イベント | ctx.effect | ctx.stacks | ctx.elapsed | ctx.reason | :apply に渡した ctx |
|---|---|---|---|---|---|
onApply | エフェクト id | 1 | — | — | 見える |
onTick | エフェクト id | 現在のスタック数 | apply からの秒数 | — | 見えない |
onStack | エフェクト id | 加算後のスタック数 | — | — | 見える |
onExpire | エフェクト id | 終了時点のスタック数 | apply からの秒数 | 下記参照 | 見えない |
onExpire の ctx.reason は次の 4 つの文字列のいずれかです。
reason | 原因 |
|---|---|
"duration" | 宣言した duration が尽きた |
"removed" | :remove(target) が呼ばれた |
"target_gone" | 対象が有効でなくなった |
"world_left" | ワールドが破棄され、動作中のアプリケーションがすべて解放された |
以下のスニペットでの log は require("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 引数は onApply と onStack に届きます。コピーはされません。これらの
ハンドラが受け取る ctx は effect と stacks を持つ小さなテーブルで、その後ろに渡した
テーブルが控えています。そのため 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 で走査しても見えません。onTick と
onExpire からも見えません。この 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 つ目だけです。
stackableが true でかつ現在のスタック数がmaxStacks未満なら、スタックを 1 増やす- 残り時間を、宣言した
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上限に達したあとも、:apply は onStack を実行し、残り時間もリセットします。止まるのは
カウントだけです。
for _ = 1, 20 do Rage:apply(pawn) end
Rage:stacksOn(pawn) --> 5, and onStack fired 19 timesmaxStacks の既定値は 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:timeLeft の nil と 0 は別の状況を表します。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 subclassingEffect.get が nil を返すことはありません。誰も定義していない 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))))
endBurn 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 declareseffects.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 つだけは別で、どれかを知っておく価値があります。Poison、Burn、Freeze には手書きの
タイミングが入っているので、自分で切れます。
| id | duration | interval | nativeStatus |
|---|---|---|---|
Poison | 10.0 | 1.0 | "Poison" |
Burn | 5.0 | 1.0 | "Burn" |
Freeze | 3.0 | なし | "Freeze" |
effects.Burn:apply(pawn)
effects.Burn:timeLeft(pawn) --> 5.0effects.get("Burn") が返すのはこの時間付きのハンドルであって、duration の無い 2 つ目ではありません。
3 つはそれぞれ自分の id で登録済みなので、Burn はひとつだけ、5 秒が入っているものだけです。
Effect.get("Burn") も同じ定義を返します。
3 つともハンドラを宣言していません。必要ないからです。状態異常はゲームが決めたとおりに働きますし、
空のハンドラはティックごとにわずかな処理を増やすだけです。その上に自分のもの — ダメージ、メッセージ、
カウンタ — を載せたいときは、同じ nativeStatus と onTick を持つエフェクトを自分で宣言してください。
下の 継続ダメージ がその形です。
CATALOG に並ぶ名前のすべてが、パックの使いたい状態異常というわけではありません。この一覧はゲーム
自身のものなので、内部のタイマーや管理用の値 — ControlSP、DrownCheck、UNKOTimer、Moratorium、
PalEnhancement 系 — も含まれます。黙って落とさずすべて出しているのは、外してしまうと「使えない」
ように見えてしまうからで、実際は「風変わり」なだけです。パックがよく使うのは、上の
ゲーム本体の状態異常 に挙げた 10 個です。
対象ごとのデータを自分で持つ
アプリケーションが持っているのは、時間管理に必要なものだけです。経過時間、残り時間、
スタック数の 3 つです。ハンドラに渡される状態は ctx.elapsed と ctx.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 つ渡し、直接回復を
書く位置をコメントで示しています。
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 Regenevent.every もエフェクトと同じ 500 ms のハートビートに丸められるため、両者がずれることは
ありません。
継続ダメージ
Poison はタイミング(10 秒・1 秒間隔)を宣言済みで、ゲーム本体の毒も点灯させますが、onTick を
持っていないので自前のダメージは出しません。その隣に自分のエフェクトを定義して tick に中身を与え、
両方を適用してください。ゲームは状態異常を表示し、自分のエフェクトがダメージを担当します。
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" で
終了し、累計値もクリアされます。
スタックするバフ
被弾のたびに積み上がって残り時間がリセットされ、上限に達したら何かを起動するバフです。
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 RageonExpire がしないことに注意してください。スタックは 1 つずつ落ちません。そのとき
保持していたカウントのまま、まるごと終了します。
onTick や onExpire の中から新しいアプリケーションを開始しないでください。これらの
ハンドラは、ランタイムがアプリケーションのテーブルを 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
チャンネルに繋いでください。
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が続く長さ、intervalがonTickの間隔です。どちらも秒で、0.5 秒のハートビートに丸められます。:apply(target)で開始し、:remove(target)で早めに終わらせます。:isActive、:timeLeft、:stacksOnで状態を調べられます。- もう一度
:applyすると残り時間がリセットされ、stackableとmaxStacksを両方書いていればスタックも 1 増えます。 nativeStatusは、エフェクトが動いている間だけゲーム本体の状態異常を点灯させます(PalCharacter.StatusComponent経由で動作が観測済み)。その 38 個の名前は大文字小文字を区別せず、アイテム id やレジストリキーとは そこが違います。それ以外に キャラクターへ何をするかは、onApply/onTick/onExpireに書く自分のコードです。- 対象ごとに覚えておきたいものは、自分のテーブルに入れて
onExpireで消します。再適用をまたぐ 必要があるならuo.key(target)をキーにしてください。
次は Skill を読むと、自分でアビリティを発動して、当たった相手に エフェクトを付けられます。