PalForge
API リファレンス

Pal

スポーンしたとき・ダメージを受けたとき・捕まえたときに反応するパルを追加する

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

  • 捕まえた瞬間、殴った瞬間、倒した瞬間にパルを反応させる
  • パルが死んだときに音を鳴らす、時限のエフェクトを始める
  • パルの体・色・大きさを別のものに変える
  • 自分が立っている場所に、好きなレベルのパルを出す
  • 遊んでいる間ずっと、自分のパル全員に自分のコードを繰り返し走らせる

パルを作る

パルはゲームのクリーチャーです。ゲーム側でそのクリーチャーに付いている名前を使って Pal{ ... } と書くと、スポーンしたり、あとから取り出したり、動きを付けたりできるオブジェクトが 返ってきます。

content/pals.lua
local pal = Pal{
    id          = "ChickenPal",
    name        = "Chicken Pal",
    description = "the one that greets you",
    events = {
        onCaptured = function(pal, ctx)
            Audio.get("AKE_Arena_Victory_01"):play(ctx.actor)
        end,
    },
}

pal:spawn(Player.coordinate())   -- the pal turns up a few seconds later

ゲームの中で Chicken Pal を捕まえると、勝利のジングルが鳴ります。流れはこれだけです。どの クリーチャーの話かを書き、何が起きてほしいかを書けば、あとは PalForge がつなぎます。

入口は 3 つあります。

Pal{ id = "ChickenPal" }    -- define + register, returns a Pal.Handle
Pal.get("SheepBall")        -- a handle for an existing id; never nil
Pal.get_all()               -- every PalForge-registered pal, as handles

Pal.get は決して nil を返しません。誰も定義していない id には薄い定義が作られ、:spawn する ぶんには十分ですが、ハンドラは載っていません。登録するのは Pal{ ... } だけで、ハンドラが 呼ばれるのも登録済みのパルだけです。

Palworld 自身のクリーチャー一覧は native/pals にあります。

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

pals.CATALOG               -- every DT_PalMonsterParameter_Common row id, as strings
pals.get("BlueSkyDragon")  -- a lazy Pal handle for any catalog id, nil for anything else
pals.Chicken               -- the curated ChickenPal demo definition
pals.SheepBall             -- the curated SheepBall demo definition

pals.get(id) は最初に呼ばれたときに素の Pal{ id = id } を定義してキャッシュします。つまり カタログの id を取り出すと登録も済み、その id にイベントが届くようになります。ハンドラを書くまでは 中身が空のままです。

Fields

必ず書くのは id だけです。この一覧に無いフィールドを渡すと、Pal{ ... } を呼んだその場で サジェスト付きのエラーになります。打ち間違いが黙って無視されることはありません。同じ一覧は ゲーム実行中に require("palforge.core.schema").help("Pal.Spec") でも読めます。

Prop

Type

デフォルト値を持つフィールドはありません。読み出すときにだけ補われるものが 2 つあります。 name を書かなかったときは :name() が id を返し、skills を書かなかったときは :skillsOf() が空のテーブルを返します。

コロンが入った id は自分のものです。"example:Boss" はゲームのデータ行 example_Boss に 対応します。コロンの無い id は ChickenPal のようなゲーム側の id です。

mesh

メッシュとは、クリーチャーが身にまとう 3D モデルのことです。その場に直接書いてもよいですし、 Mesh{ ... } で一度定義したものを渡してもかまいません。どちらも同じように 検証され、同じ経路でレンダラーに届くので、書きやすい方を選んでください。

Pal{
    id   = "ChickenPal",
    mesh = {
        kind      = "skeletal",
        model     = "/Game/Pal/Model/Character/Monster/ChickenPal/SK_ChickenPal.SK_ChickenPal",
        animClass = "/Game/Pal/Blueprint/Character/Monster/PalActorBP/ChickenPal/ABP_ChickenPal.ABP_ChickenPal_C",
    },
}

メッシュのフィールドは kindprocedural / static / skeletal / obj。既定は skeletal)、 model(必須)、animClass(skeletal のみ)、scaleoffsettexturecolormaterialparams です。obj は procedural バックエンドの別名です。

書いたメッシュは自分で装着されます。Pal{ ... } は定義の onSpawned を包み、その id のポーン 1 体ごとに、あなたのハンドラより先に pal.spawned チャンネルで renderOn を走らせます。 つまり、メッシュを書くことがそのまま装着です。

Pal{
    id   = "ChickenPal",
    mesh = {
        kind      = "skeletal",
        model     = "/Game/Pal/Model/Character/Monster/ChickenPal/SK_ChickenPal.SK_ChickenPal",
        animClass = "/Game/Pal/Blueprint/Character/Monster/PalActorBP/ChickenPal/ABP_ChickenPal.ABP_ChickenPal_C",
    },
}

pal.spawned は、そのポーンの .Mesh コンポーネントが実体になる最も早い瞬間です。装着が onTick の巡回ではなくこのチャンネルに乗っているのはそのためで、メッシュは一度設定すれば 足りるのに、巡回では 3 秒ごとにその代金を払い続けることになるからです。装着は attachOnce を通るので、1 体のポーンに 2 回目のスポーンイベントが来てもメッシュが二重に載ることはなく、 全体が pcall の中にあるので、解決できない model のパスはログ 1 行で済み、パルの ライフサイクルを止めません。

:renderOn(actor) を自分で呼ぶのは、PalForge がスポーンさせていないポーン、onTick の巡回で 見つけたポーン、そして detach のあとに付け直すときです。

material、color、texture

material が完全な上書きです。colortexture はそのうち 2 フィールドの短い書き方で、 それだけで足りるときに使います。

Prop

Type

この 2 つの書き方は混ざりません。material を書いたときはそれがそのまま使われ、トップレベルの color / texture は無視されます。material が無いときにだけ、短い書き方からマテリアルの 記述がまとめられます。:renderOn の中では、その記述が持つ値が同じ名前のメッシュのフィールドに 勝ち、記述が持たないフィールドはメッシュ側の値がそのまま残ります。

-- shorthand: tint the declared mesh red
Pal{
    id    = "ChickenPal",
    mesh  = { kind = "skeletal", model = "/Game/Pal/Model/Character/Monster/ChickenPal/SK_ChickenPal.SK_ChickenPal" },
    color = { r = 1.0, g = 0.2, b = 0.2, a = 1.0 },
}

-- long form: a base material plus parameters, which the shorthands cannot express
Pal{
    id   = "SheepBall",
    mesh = { kind = "skeletal", model = "/Game/Pal/Model/Character/Monster/SheepBall/SK_SheepBall.SK_SheepBall" },
    material = {
        color    = { r = 0.2, g = 0.5, b = 1.0, a = 1.0 },
        texture  = "/Game/Pal/Model/Other/AttackHelicopter/Material/T_AttackHelicopter_B.T_AttackHelicopter_B",
        material = "/Game/.../MI_YourBaseMaterial",
        params   = { scalar = { ["Roughness Add"] = 0.2 } },
    },
}

params とベースマテリアルのパスには、トップレベルの短い書き方がありません。これらは material の中にだけあります。paramsvector / scalar / texture に分かれているのは、 その先にあるエンジン側の 3 つのセッターがそう分かれているからです。

パル側の texture(どちらの書き方も)は、書いたままレンダラーに届きます。プレイヤーのディスクに ファイルを置く必要がない /Game/... のテクスチャアセットか、自分で用意した png の絶対パスです。 パックのディレクトリからの相対パスが解決されるのはメッシュ自身の texture フィールドだけで、 ここではありません。

この 4 つのフィールドは skeletal を含むすべてのバックエンドに届きます。マテリアルの処理は core/mesh/base/renderer.lua にあり、どのバックエンドもそれを呼ぶので、kind = "skeletal" の メッシュに添えて書いた color も、ダイナミックマテリアルインスタンスを通じてそのポーン自身の マテリアルスロットに書き込まれます。

:renderOntrue が言わないのは、見た目が変わったかどうかです。書き込むパラメータ名は 動いているゲームから読み取った実在の名前ですが(BaseColorBase TextureNormal Map など。 一覧は Mesh にあります)、マテリアルが持っていない名前への書き込みは黙って 何もしません。色が変わるところ自体は、パルではなくビルド物の上で画面に乗るのを観測済みです (mesh-color-change、2026-08-02: 赤 → 緑 → 青)。true は「メッシュが差し替わり、マテリアルの 書き込みが走った」と読み、最初の一度は自分の目で結果を確かめてください。

skills

skills は、この定義が宣言する id のリストです。宣言しただけではどのクリーチャーにも何も載りませんし、 ゲーム側がこれを発火させることもありません。:skillsOf() でリストを読み戻し、各スキルは Skill から自分で動かしてください。

local boss = Pal{
    id     = "BOSS_ChickenPal",
    name   = "Chicken Boss",
    skills = { "FlameThrower" },
}

-- fire everything this pal owns at the player
for _, id in ipairs(boss:skillsOf()) do
    Skill.get(id):activate(Player.character())
end

宣言したリストを、ワールドに立っているクリーチャーへ載せてゲーム自身に持たせたいときは :teachAll(actor) を使います。下の teachAll を参照してください。

icon

:iconOf() はゲーム自身のパルアイコンのテーブル(DT_PalCharacterIconDataTable、次に DT_PalCharacterIconDataTable_Common)を id で引き、Icon 列を読みます。この列名は推測では なく実測です。ロード済みのセーブで 4 つのアイコンテーブルをまとめて読んだところ、パルは 674 行中 674 行、アイテムは 1207 行中 1183 行、ビルディングは 571 行中 567 行、パートナー スキルは 311 行中 311 行が答えました。パルとアイテムは Icon、ビルディングは SoftIcon、 パートナースキルは TextureID_8_2B2F889C43EB586246BDB981B6462ACA がその列です。返ってくるのは 常に /Game/... のアセットパスという 1 種類の文字列で、エンジンのオブジェクトではありません。

id は検索の前に解決されるので、"example:Boss" は行の綴りである example_Boss として引かれ、 その行が存在すれば生きたテーブルに届きます。解決できない id はリテラルのまま引かれます。 テーブルが無い・行が無い・値が無い、いずれで外れても、書いておいた icon が返り、書いて いなければ nil が返ります。

行の照合は大文字小文字を区別します。しかもこのビルドでは 1 体のクリーチャーに 2 つの綴りが 実在します。ディスパッチがキーにするブループリント id は SheepBall、この検索が答える DataTable の行は Sheepball です。

local pal = Pal{ id = "example:Boss", icon = "/Game/.../T_icon_example_boss" }
pal:iconOf()   -- the row "example_Boss" if PalSchema wrote one, else the declared icon

data

data は手を加えずに定義へコピーされ、PalForge 側はこれを読みません。ハンドルにアクセサも 無いので、ハンドラの中で使いたいときは Lua のローカル変数に持っておいてください。

local config = { reward = "Wood", amount = 5 }

local pal = Pal{
    id   = "ChickenPal",
    data = config,
    events = {
        onDeath = function(pal, ctx)
            Item.get(config.reward):give(config.amount)
        end,
    },
}

Events

パルに動きを付けるのはここです。ハンドラは events の下にまとめます。どれも function(pal, ctx) の形で、palこの定義自身のハンドルPal{ ... } が返したのと同じ オブジェクトなので、:renderOn や各クエリがその場で使えます)、ctx は今何が起きたかを 表すテーブルです。下の一覧に無いイベント名は、黙って無視されるのではなく定義時のエラーになります。

PalForge はゲームを見張り、起きたことを名前の付いたチャンネルに流し、それが起きたパルを見つけて ハンドラを呼びます。

native hook -> event.emit -> channel -> dispatch -> resolve BP class name -> pal:onX(ctx)
フックチャンネルネイティブソースctx状態
onSpawnedpal.spawnedPalNPC:OnCompletedInitParamPalPlayerCharacter:OnCompleteInitializeParameterctx.actorLIVE。発火を確認済み。ロード時ではなく world.ready で有効化
onDamagedpal.damagedPalCharacter:OnDamageReactionctx.actorLIVE
onDeathpal.deathPalCharacter:OnDeadCharacterctx.actorLIVE
onCapturedpal.capturedPalCharacterParameterComponent:SetIsCapturedProcessingstarted == true のとき)ctx.actorctx.compLIVE
onTicktickゲームのイベントは無し。core/event が自分で生きたパルを見て回るctx.actorctx.countctx.nowLIVE。生きたパル 1 体につき core.event.PAL_SCAN_MS ごと(既定 3 秒)

ctx.actor は、そのイベントが起きたワールド上のクリーチャーです。pal.captured はパルの パラメータコンポーネント側で発火するため、アクターはそのコンポーネントの持ち主、ctx.comp は コンポーネント自身になります。onDamaged はダメージを受けたパルで発火し、攻撃した側では 発火しません。ゲーム側のフックが誰の攻撃かを持っていないためです。

pal.spawned のソースが 2 つあるのは、いちばん素直なソースが何も運ばないからです。 PalCharacter:BroadcastOnCompleteInitializeParameter は「キャラクターがパラメータ初期化を 終えた」ことを知らせる関数で、フックの登録自体は問題なく通ります。それでも、他の 10 本の チャンネルが報告している実セーブの中で、この 1 本は沈黙したまま計測されました。フックが 見られるのは ProcessEvent が実行するものだけで、ブロードキャストする側はそこに現れません。 そこで PalForge が待ち受けるのは、そのブロードキャストが呼び出すデリゲートのターゲット 2 つです。パル自身の側で必ず通る PalNPC:OnCompletedInitParam と、プレイヤーが購読した キャラクターについて発火する PalPlayerCharacter:OnCompleteInitializeParameter です。どちらも 2026-07-26 に発火が観測されています。2 つはアクターごとに 1 秒の窓で重複が除かれるので、 1 体のポーンが両方に届いてもイベントは 1 回です。

捕獲に反応してプレイヤーに何かを返すハンドラです。

local log = require("palforge.utils.log").scope("example")

Pal{
    id   = "SheepBall",
    name = "Sheepball",
    events = {
        onCaptured = function(pal, ctx)
            log.info("captured " .. pal:name() .. ": " .. tostring(ctx.actor))
            Item.get("PalSphere"):give(1)
        end,
    },
}

クリーチャーがまだ有効かを確かめてから触るハンドラです。

Pal{
    id = "ChickenPal",
    events = {
        onDamaged = function(pal, ctx)
            local actor = ctx.actor
            if not (actor and actor.IsValid and actor:IsValid()) then return end
            Audio.get("AKE_Pal_Footstep"):play(actor)
        end,
    },
}

onTick はゲームの操作とは関係なく、およそ 3 秒ごとに、その id の生きたパル 1 体につき 1 回 走ります。ctx.count は mod をロードしてからの 500 ms ハートビートの回数なので、たまにだけ何かを したいときに便利です。

local log = require("palforge.utils.log").scope("example")

Pal{
    id = "ChickenPal",
    events = {
        onTick = function(pal, ctx)
            -- ctx.actor is one live Chicken Pal; this runs once per pal, per sweep
            if ctx.count % 12 == 0 then
                log.info("chicken still here: " .. tostring(ctx.actor))
            end
        end,
    },
}

-- slow the sweep down, or switch it off with 0
require("palforge.core.event").PAL_SCAN_MS = 5000

ハンドラは自由に組み合わせられ、それぞれ任意です。

local log = require("palforge.utils.log").scope("example")

Pal{
    id          = "ChickenPal",
    name        = "Chicken Pal",
    description = "logs every moment of its short life",
    events = {
        onSpawned  = function(pal, ctx) log.info("spawned "  .. tostring(ctx.actor)) end,
        onDamaged  = function(pal, ctx) log.info("damaged "  .. tostring(ctx.actor)) end,
        onDeath    = function(pal, ctx) log.info("died "     .. tostring(ctx.actor)) end,
        onCaptured = function(pal, ctx) log.info("captured " .. tostring(ctx.actor)) end,
    },
}

PalForge には native/pals.lua にすぐ使えるパルが 2 つ入っています(ChickenPalSheepBall)。onCapturedonDamagedonDeath がログ出力につないであるので、野生の個体を 捕まえるか倒せばログ行が出ます。手元のゲームで経路が通っているか確かめるのに一番早い方法です。

ハンドラが走るかどうかを決めるもの

決め手は 4 つです。

  1. パルはブループリントのクラス名で見分けます。 ゲーム内のクリーチャーには BP_<Id>_C という生成クラスが付いています。PalForge はそれをクリーチャーから読み、 BP_ChickenPal_CChickenPal に直して id を引きます。名前空間付きの定義も、解決後の名前が ブループリントの id と一致すればヒットするので、Pal{ id = "example:Boss" }BP_example_Boss_C を捕まえます。
  2. イベントが届くのは登録済みの id だけです。 Pal{ ... } は登録しますが Pal.get(id) は 登録しません。自分で定義していないバニラのパルはどれにも解決されず、イベントは捨てられます。
  3. ワールドの準備が整うまで何も走りません。 ゲーム側のフックは、有効な PalPlayerCharacter を 1 秒間隔で 5 回連続して見つけるまで即座に return します。onTick の 巡回も同じゲートを待ちます。
  4. ハンドラは pcall の中で走り、失敗はログに残ります。 ハンドラ内のエラーはゲームを落とさず、 チャンネルも止めません。PalForge がそれを捕まえ、チャンネル名・フック名・メッセージをログに 書きます(pal.death -> onDeath handler failed: ...)。ただし、そのハンドラの残りの処理は 実行されません。

自分のパル以外も拾う

ハンドラが走るのは自分が定義したパルだけです。バニラも含めてワールド中のパルを拾いたいときは、 チャンネルを自分で購読します。

local event = require("palforge.core.event")
local log   = require("palforge.utils.log").scope("example")

local sub = event.on("pal.death", function(ctx)
    log.info("some pal died: " .. tostring(ctx.actor))
end)

-- later
sub:unsubscribe()

event.observable(name) は同じチャンネルを Rx の observable として返すので、オペレータチェーンに つなげます。event.emit("pal.spawned", { actor = someActor }) は自分で作ったイベントを経路全体に 流します。ただし PalForge は ctx.actor のブループリントクラス名から定義を割り出すため、手作りの ctx は自分の購読先には届きますが、定義側のハンドラに届くのはアクターが本物のワールド上のパルの ときだけです。ハンドラだけを走らせたいときは、後述のイベントフォワーダを使ってください。

Handle

Pal{ ... }Pal.getPal.get_all はいずれも Pal.Handle を返します。ハンドルは .id と、 2 つのアクション、5 つのイベントフォワーダ、5 つのクエリを持ちます。

spawn

---@param arg Coord|table|nil
---@return boolean issued
pal:spawn(arg)

引数はまず仕分けられます。.x[1] を持つテーブルは座標、それ以外のテーブルはオプション テーブル、引数なしは既定の配置です。

local pal = Pal.get("ChickenPal")

pal:spawn()                                   -- wild, near the player, level 1
pal:spawn(Player.coordinate())                -- at the player's exact position
pal:spawn(Player.coordinateOffset(300, 0, 0)) -- 3 m away on X
pal:spawn({ 12000, -4300, 800 })              -- array coordinate, x/y/z in centimetres
pal:spawn{ at = Player.coordinate(), level = 30 }
pal:spawn{ toPlayer = true, num = 3, level = 20 }   -- three, owned by the player
pal:spawn{ level = 45 }                             -- wild, near the player, level 45

オプションテーブルは at(座標)、level(既定 1)、toPlayer(真値ならワールドではなく プレイヤーへ)、num(既定 1。toPlayer の経路でだけ読まれます)を受け取ります。

スポーンは即座ではありません

パルが現れるのは、:spawn を呼んでからおおよそ 4〜8 秒後です。これはゲーム自身のペースであって PalForge が足している待ち時間ではなく、即座にする方法はありません。

したがって戻り値は「呼び出しが発行された」という意味であり、それ以上にはなりえません。 クリーチャーが存在する頃には、あなたのコードはとっくに先へ進んでいます。次の行でポーンを探さないで ください。true を「どこかにパルが立っている」と読まないでください。

パルに反応したいときは、クリーチャーが実際に到着したときに走る onSpawned ハンドラを使ってください。 自分で探すなら、10 秒以上にわたって繰り返し探してください。

false は正直な失敗です。呼び出しが断られたか、そもそも試みられなかったことを表します。id が空、 座標が数値でない、管理用オブジェクトに届かない、といった場合です。

到着は数秒後にログへ報告され、経過時間も付きます。自分のコードに計測を仕込まなくても様子が分かります。

[PalForge.spawn][info] spawn.pal ChickenPal: 1 new PalCharacter in the world 5.9 s after the call (look 15 of 20)

座標を指定したスポーン

座標指定はスポーンのあとに移動を足したものです。ゲーム自身の呼び出しは指定した位置を無視して プレイヤーのそばにパルを落とすので、PalForge はクリーチャーの到着を待ち、さっきまで居なかった 1 体を 選び、指定の点へテレポートさせます。

着地はぴったりです。この遅延処理は動かしたパルから位置を読み戻し、その値と、要求した点からの ずれを報告します。

[PalForge.spawn][info] spawn.palAt: placed new pal at (-345296,263050,4153); it reads back (-345296,263050,4153), off by 0

ゲーム内で見えるのは、呼び出しから数秒後にパルが自分のそばに現れ、それから指定の場所へ移動していく 様子です。これがこの機能の形で、クリーチャーを最初から座標の位置に出現させる方法はありません。

プレイヤーに渡す

:spawn{ toPlayer = true } は、ワールドではなくプレイヤーの手持ちやボックスへクリーチャーを渡します。 管理用オブジェクトは一切不要です。その true は「呼び出しを発行した」で止まります。Lua からは手持ちも ボックスも覗けないので、報告できる到着が無く、あとに続くログ行もありません。

管理用オブジェクト

ワールドへの経路は UPalCheatManager を通ります。クライアントでは CheatManagerEnabler mod が PlayerController:ClientRestart からこのオブジェクトを作りますが、専用サーバーではそのフックが発火 しないため、誰も作りません。そこで core/spawn は、セッションにひとつも無ければ自分で作ります。 PalPlayerController 自身の CheatClassStaticConstructObject で生成し(そのクラスが null の ときは /Script/Pal.PalCheatManager、次に /Script/Engine.CheatManager にフォールバックします)、 できたオブジェクトをコントローラに取り付けます。オブジェクトはそのまま残るので、これはスポーンの たびではなくセッションに一度だけ起こり、その一度はログに記録されます。専用サーバーがクライアントと 同じ経路を通れるのはこのためです。

いちばん早い失敗は、プレイヤーコントローラがそもそも無い場合、つまりワールド未ロードか未接続の ときです。そのときはどのワールド経路もゲームに届く前に false を返し、管理用オブジェクトが見つからず 作成もできなかったと警告します。world.ready から呼べば、この時間帯を避けられます。

local event = require("palforge.core.event")
local log   = require("palforge.utils.log").scope("example")

event.on("world.ready", function()
    if not Pal.get("ChickenPal"):spawn{ level = 10 } then
        log.warn("the spawn call was not issued")
    end
end)

renderOn

---@param actor any
---@return boolean ok
pal:renderOn(actor)

書いておいたメッシュを、生きているクリーチャーに一度だけ付けます。レンダラーが重ね掛けを防ぐので、 同じアクターにもう一度呼んでも害はありません。アクターが有効でないとき、定義にメッシュが無いとき、 そのメッシュに model が無いときは、何もせず false を返します。

通常このメソッドを呼ぶ必要はありません。 mesh を書いた定義は、自分で pal.spawned の タイミングで装着します。こちらは手動の経路です。PalForge がスポーンさせていないポーン、 onTick の巡回で見つけたポーン、detach のあとに付け直す場合に使います。onSpawned から 重ねて呼んでも害はありません。ガードのおかげで、2 回目の呼び出しは既に付いているメッシュを 見つけるだけです。

-- reach the sheepballs that were already standing there when the pack loaded
Pal{
    id   = "SheepBall",
    mesh = { kind = "skeletal", model = "/Game/Pal/Model/Character/Monster/SheepBall/SK_SheepBall.SK_SheepBall" },
    events = {
        onTick = function(pal, ctx)
            pal:renderOn(ctx.actor)   -- one-shot per pawn: the dressed ones cost nothing
        end,
    },
}

書いた内容はまるごとレンダラーに渡されます。kindmodelanimClassscaleoffset は メッシュから渡り、そのあと定義側のマテリアル記述が、自分の持つ colortextureparamsmaterial を上書きします。animClass は skeletal バックエンドまで届き、バックエンドは アニメーションブループリントモードに切り替えてから、差し替えた直後にそのブループリントを バインドします。差し替えたスケルトンを動かすものが無いとアニメーションせず、画面から消えてしまう こともあります。ですから、クリーチャーが元から持っていた ABP_*_C ではなく、差し替えたモデルに 対応する ABP_*_C を書いてください。

-- a chicken wearing a sheepball body: the anim blueprint travels with the model
Pal{
    id   = "ChickenPal",
    mesh = {
        kind      = "skeletal",
        model     = "/Game/Pal/Model/Character/Monster/SheepBall/SK_SheepBall.SK_SheepBall",
        animClass = "/Game/Pal/Blueprint/Character/Monster/PalActorBP/SheepBall/ABP_SheepBall.ABP_SheepBall_C",
    },
}

true は、バックエンドのセッターが走ったという意味です。skeletal バックエンドなら、クリーチャー 自身のコンポーネントにモデルが設定され、そこから読み戻せた、ということです。その結果何が描画 されるかまでは、この呼び出しでは分かりません。マテリアルへの書き込みが、そのマテリアルが実際に 持っているパラメータに当たったかどうかも同じです。

teachAll

---@param actor any   # a live pal or player character
---@return integer taught, integer asked
pal:teachAll(actor)

この定義が宣言しているスキルをすべて、生きているキャラクターに載せてゲーム自身に持たせます。 :skillsOf() は作者が書いた内容で、こちらはそのリストを実在のクリーチャーに届ける手段です。

local Blaze = Pal{
    id     = "FoxMage",
    skills = { "FireBlast", "Legend" },
}

local taught, asked = Blaze:teachAll(somePalActor)
-- 2, 2 when both landed; 1, 2 when only one did

各 id の振り分けは、宣言した内容ではなくゲームがその id を何として知っているかで決まります。 ゲーム自身のアクティブ技である id はそのクリーチャーの装備技に追加され、それ以外の id はその名前の パッシブスキルとして追加されます。規則の全体と名前の一覧は Skill にあります。

戻り値が真偽値ではなく数値 2 つなのは、一部だけ成功した状態をそのまま見せるためです。スキルは宣言順に 載せられ、書き込みごとにキャラクターを読み戻して確認し、1 つ失敗しても残りは続きます。未知の id が 1 つあるせいで他の 4 つの技を失うべきではないからです。両方 0 なら、その定義はスキルを 1 つも宣言して いません。

クエリ

pal:skillsOf()      --> string[]   the declared skill ids, an empty table when none
pal:teachAll(actor) --> integer, integer   how many landed, how many were asked for
pal:mesh()          --> table?     the validated mesh declaration, nil when none
pal:iconOf()        --> string?    the DataTable row's /Game/... path, else the declared icon, else nil
pal:name()          --> string     the declared name, else the id
pal:description()   --> string?    the declared description, or nil
local log = require("palforge.utils.log").scope("example")

for _, pal in ipairs(Pal.get_all()) do
    local m = pal:mesh()
    log.info(string.format("%s (%s) mesh=%s skills=%d",
        pal:name(), pal.id, tostring(m and m.model), #pal:skillsOf()))
end

イベントフォワーダ

:onSpawned(ctx):onDamaged(ctx):onDeath(ctx):onCaptured(ctx):onTick(ctx) は、 渡した ctx をそのまま使って定義側のハンドラをその場で呼びます。ゲームからの経路はここを通りません。 テストや別のハンドラから自分でハンドラを走らせたいときに使います。

-- run the death handler now, with a hand-made ctx
Pal.get("ChickenPal"):onDeath({ actor = Player.character() })

Recipes

自分で着替えて登場を知らせるパル

content/party_chicken.lua
local log = require("palforge.utils.log").scope("example")

local Fanfare = Audio.se{
    id        = "AKE_CampLevelUp",
    soundId   = "AKE_CampLevelUp",
    soundPath = "/Game/Pal/Sound/Events/SE/UI/CampLevelUp/AKE_CampLevelUp.AKE_CampLevelUp",
}

local Chicken = Pal{
    id          = "ChickenPal",
    name        = "Party Chicken",
    description = "wears a custom skin and announces itself",
    mesh = Mesh{
        id        = "example:party_chicken",
        kind      = "skeletal",
        model     = "/Game/Pal/Model/Character/Monster/ChickenPal/SK_ChickenPal.SK_ChickenPal",
        animClass = "/Game/Pal/Blueprint/Character/Monster/PalActorBP/ChickenPal/ABP_ChickenPal.ABP_ChickenPal_C",
    },
    color = { r = 1.0, g = 0.4, b = 0.1, a = 1.0 },
    events = {
        onSpawned = function(pal, ctx)
            -- the mesh is already on ctx.actor by the time this runs
            log.info("party chicken arrived: " .. tostring(ctx.actor))
            Fanfare:play(ctx.actor)
        end,
    },
}

Chicken:spawn(Player.coordinateOffset(300, 0, 0))

書いておいたメッシュはこのハンドラより先に付くので、ポーンを動かす・名前を変える・塗り直す ハンドラは、体が既に付いている状態から始められます。装着そのものが成功したかどうかは、 このハンドラからは読めません。メッシュのログにあります。

サウンドはファイルの先頭で一度だけ定義し、ハンドラからは再生するだけにします。ハンドラの中で 定義すると、スポーンのたびにサウンドを登録し直すことになります。手元にローカル変数が無い場面では、 代わりに Audio.get("AKE_CampLevelUp"):play(ctx.actor) を使ってください。

死亡時にドロップするパル

content/woolly_sheep.lua
local log = require("palforge.utils.log").scope("example")

local Wool = Item.get("Wool")
local Meat = Item.get("Meat")

Pal{
    id          = "SheepBall",
    name        = "Woolly Sheepball",
    description = "hands over wool and meat when it dies",
    events = {
        onDeath = function(pal, ctx)
            Wool:give(3)
            Meat:give(1)
            log.info(pal:name() .. " dropped its loot")
        end,
    },
}

Item.get(...):give(n) はローカルプレイヤーのインベントリに追加し、インベントリが実際にどう動いたかを 返します。ドロップが成立したと決めつけず、戻り値を確かめてください。パルが倒れた場所に戦利品を 置いておく呼び出しは PalForge にはないので、これは拾いに行く袋ではなくプレイヤーが直接受け取る 報酬です。アイテムでできることは Item を参照してください。

殴られると燃え始めるパル

content/singe_chicken.lua
local log = require("palforge.utils.log").scope("example")

local Singe = Effect{
    id          = "example:Singe",
    name        = "Singe",
    description = "burns for six seconds after taking a hit",
    duration    = 6.0,
    interval    = 1.0,
    events = {
        onApply  = function(effect, target, ctx)
            log.info("singe applied by " .. tostring(ctx.source))
        end,
        onTick   = function(effect, target, ctx)
            log.info(string.format("singe tick at %.1fs", ctx.elapsed))
        end,
        onExpire = function(effect, target, ctx)
            log.info("singe over: " .. tostring(ctx.reason))
        end,
    },
}

Pal{
    id   = "ChickenPal",
    name = "Singed Chicken",
    events = {
        onDamaged = function(pal, ctx)
            if not ctx.actor then return end
            if Singe:isActive(ctx.actor) then return end
            Singe:apply(ctx.actor, { source = pal.id })
        end,
    },
}

エフェクトは共有の 500 ms ハートビートで動くので、その onTickduration が尽きるまで およそ interval 秒ごとに発火します。ctx.elapsedctx.stacks、そして期限切れ時の ctx.reason はそのランタイムが入れる値で、それ以外のキーは :apply に渡したものです。詳細は Effect を参照してください。

好きなときに呼び出せる色違い部隊

content/blue_squad.lua
local log = require("palforge.utils.log").scope("example")

local Squad = Pal{
    id          = "SheepBall",
    name        = "Blue Squad",
    description = "a recolored sheepball unit",
    mesh = {
        kind      = "skeletal",
        model     = "/Game/Pal/Model/Character/Monster/SheepBall/SK_SheepBall.SK_SheepBall",
        animClass = "/Game/Pal/Blueprint/Character/Monster/PalActorBP/SheepBall/ABP_SheepBall.ABP_SheepBall_C",
        scale     = 1.2,
    },
    material = {
        color = { r = 0.2, g = 0.5, b = 1.0, a = 1.0 },
    },
}

-- spread `n` of them in a line to the player's north-east
local function summonSquad(n, level)
    local at = Player.coordinate()
    if not at then
        log.warn("no player coordinate - not in a world yet")
        return false
    end
    for i = 1, n do
        Squad:spawn{ at = { x = at.x + i * 250, y = at.y + 250, z = at.z }, level = level }
    end
    return true
end

summonSquad(5, 20)

-- the party-owned variant: three of them straight into the box
Squad:spawn{ toPlayer = true, num = 3, level = 20 }

1 体ずつ別々のスポーン呼び出しを使い、到着した個体はそれぞれ書いておいたメッシュを身にまといます。 装着が pal.spawned に乗っているので、定義側にハンドラは要りません。分隊は一斉にも即座にも 現れません。どの個体も自分の呼び出しから数秒遅れるので、そのあとの数秒のあいだに順に現れます。

Limits

  • スポーンは即座ではなく、:spawn は成功を伝えられません。 パルが現れるのは 4〜8 秒後なので、 戻り値は「呼び出しが発行された」ことについてのものです。到着はログにあり、ハンドラを置く場所は onSpawned です。
  • Lua から新しいクリーチャーは作れません。 Pal{ id = ... } は、ゲームに既にいる クリーチャーに挙動・見た目・メタデータを付けるものです。データ行そのものを作るのは PalSchema の 仕事です。対応する BP_<Id>_C がゲームに無い id はハンドラが呼ばれず、:spawn しても出てくる ものがありません。
  • onTick はゲームのイベントではなく巡回です。 core/eventcore.event.PAL_SCAN_MS(既定 3000 ms)ごとに生きたパルを見て回り、1 体につき 1 回 onTick を 呼びます。require("palforge.core.event").PAL_SCAN_MS = 5000 で遅くでき、0 で止められます。 500 ms のハートビートよりわざと遅くしてあります。この巡回はゲーム内のすべてのオブジェクトに 触れるためです。もっと速く、もっと正確なタイマーが必要なら require("palforge.core.event").every(2000, fn)(500 ms のティックに量子化されます)か、 Effect の interval を使ってください。
  • onTick はクリーチャーごとの記憶を持ちません。 pal はこの定義のハンドルで、その id の どのクリーチャーにも同じハンドルが渡ります。クリーチャーごとに覚えておきたいものは、 ctx.actor をキーにした自前のテーブルに入れてください。
  • 壊れた onTick は止められます。 5 回続けて失敗すると、PalForge はそれをログに書き、その セッションの間その定義の onTick を呼ばなくなります。失敗した 1 体だけでなく、その id の すべてのクリーチャーが対象です。
  • onSpawned は「本当に新しい」パルでも発火します。これは実測済みです。 pf_hook pal-spawned-fresh を 2026-08-02 に走らせ、発火のたびに world.ready からの時刻を記録しました。 発火 27 回、うち 17 回はワールドロードとまったく無関係な時刻です。つまりこのイベントは、 パックが期待するとおりの意味を持ちます。同時に、周囲のパルが一斉に初期化されるロード時の 大量発火でも鳴るので、すでに見たポーンに対して呼ばれても安全なハンドラであることは引き続き 必要です。発火を数えるのではなく、ctx.actor をキーにした自前のテーブルで管理してください。
  • onSpawned のソースはロード時ではなく world.ready で有効化されます。 初期化の ブロードキャストはワールドロード時のパル初期化の大量発火の中で起こり、そこでの発火が一度、 共有の UE4SS フック処理を詰まらせ、確認済みの 3 本のフックまで巻き添えにしました。遅らせて 有効化するのは、このチャンネルだけでなく捕獲・ダメージ・死亡を守るためでもあります。
  • ワールドの準備が整うまで何も走りません。 4 つのゲーム側フックはいずれも、有効な PalPlayerCharacter を 1 秒間隔で 5 回連続して見つけるまで即座に return します。onTick の 巡回も同じゲートを待ちます。
  • ハンドラのエラーは、そのハンドラを終わらせます。 PalForge は pcall の中でフックを呼び、 失敗をチャンネル名とフック名つきでログに書くので、バグはログから見えます。ただしハンドラの 残りは実行されず、チャンネルもゲームもそれに気づきません。
  • ワールドへのスポーンは、試みる前からプレイヤーコントローラを必要とします。 PalCheatManager も必要ですが、誰かが先に作っておく必要はありません。セッションにひとつも無ければ、core/spawn が コントローラの CheatClass からひとつ作って取り付けます。CheatManagerEnabler の ClientRestart フックが動かない専用サーバーでも、クライアントと同じ経路を通れるのはこのためです。作る土台となる PalPlayerController が無い場合は、どのワールド経路もゲームに届く前に false を返します。 toPlayer の経路にはこのどれも必要ありません。
  • 座標指定はスポーン位置の指定ではなく、スポーン後の移動です。 ゲーム側の呼び出しは指定した 位置を無視するため、PalForge はクリーチャーを待ち、新しい 1 体を選び、指定の点へテレポート させます。着地はぴったりです。この処理の結果はログにしか出ず、その行にはパルから読み戻した位置が 入ります。
  • 書いておいたメッシュは pal.spawned で自動的に付きます。 :renderOn(actor) は、そのチャンネル から来ていないポーンに対する手動の経路です。core.mesh.ENABLED はグローバルのキルスイッチで、 これを切るとすべての装着が no-op になります。マテリアル関連のフィールドはすべてのバックエンドに 届きますが、マテリアルが持っていないパラメータ名への書き込みは黙って何もしないので、装着は 色が見えることの保証ではありません。
  • Pal.get は登録しません。 レジストリに定義を入れるのは Pal{ ... } だけなので、Pal.get で 得たハンドルは操作はできてもイベントを受け取れません。同じ id を 2 回定義すると、後の定義が 前の定義を置き換え、レジストリがそれをログに書きます。定義元のパックが違う場合は、両方の名前も 出ます。

まとめ

  • Pal{ id = "ChickenPal", ... } で、ゲームのクリーチャーに新しい動きを付けます。必ず書くのは id だけです。
  • パルに起きたことへの反応は events に書きます。onSpawnedonDamagedonDeathonCapturedonTick の 5 つがあり、どれも動いています。
  • pal:spawn() はパルをワールドに出します。パルが現れるのは 4〜8 秒後です。座標を渡せばその位置に ぴったり置かれ、{ toPlayer = true, num = 3 } を渡せばプレイヤーに届きます。戻り値は呼び出しに ついてのものなので、反応は onSpawned に書いてください。
  • mesh で見た目を変えられます。書いておいたメッシュは pal.spawned で自動的に付き、 pal:renderOn(actor) は手動の経路です。
  • ハンドラが呼ばれるのは Pal{ ... } で定義した id だけです。Pal.get はアクションだけを返し、 イベントは付きません。
  • onTick は生きたパル 1 体につき約 3 秒ごとに走り、記憶を持ちません。覚えておきたいものは ctx.actor をキーにしてください。

次は Mesh を読むと、パルに自分だけの体を持たせられます。

On this page