コンテンツパックを作る
空のモッドフォルダーから、設置して調べると動くビーコンまでを一気に作る
このページでできるようになること
- 自分のモッドフォルダーを Palworld に置いて、読み込ませる
- ビルドメニューの構造物に、調べたときの動きを付ける
- その 1 回の操作で、音を鳴らし、強化を付け、アイテムを配る
- そのうちどれがこのバージョンで実行でき、それぞれがどう結果を報告するかを把握する
- 置いた構造物ごとにカウンターを持たせ、次に遊ぶときも残す
- 何かが起きなかったとき、ログを読んで理由を突き止める
作るもの
BeaconPack という名前のパックです。ビルドメニューにある構造物をひとつ受け持ち、そこに 動きを付けます。
- ビーコンを調べるとクールダウン 30 秒のスキルが発動し、
- スキルは調べたキャラクターに効果音を鳴らし、
- 12 秒間・最大 3 回まで重なる強化を付け、
- ビーコンの位置に守護パルを呼び出します。
- ビーコン自身はアイテムを配り、使用回数を数え、チャージを 1 消費し、その値をワールド ファイルに保存します。
- 一定間隔で動くハンドラーが、離れているあいだにチャージを回復させます。
すべてゲーム内で本当に起こります。驚かないよう先に知っておきたいのはパルのことだけです。すぐには 現れず、操作から数秒あとに到着します。この点は、その箇所でもう一度触れます。
これで Building・Skill・Audio・Effect・Pal・Item のすべてに触れます。パック自体は独立した モッドフォルダーで、同じセッションに読み込まれている PalForge を再利用します。
始める前の前提です。
- PalForge がインストールされ、読み込まれていること。
ue4ss/Mods/PalForge/Scripts/main.luaが動き、UE4SS コンソールに[PalForge.main][info] readyが出ていれば問題ありません。 - パルを呼ぶ処理は、ゲーム自身の管理用オブジェクト
UPalCheatManagerを通ります。PalForge はそれを探し、無ければプレイヤーコントローラー上に自分で作るので、ワールドに入ってさえ いれば呼び出しは発行されます。 - アイテムを配る処理はインベントリー自身の書き込みを通り、実測した結果を返します。
Item.Handle:giveは前後でインベントリーを読み、所持数が本当に増えたときだけtrueです。 - スポーンは即座ではありません。
Pal.Handle:spawnは呼び出しが発行された時点で返り、 クリーチャーが現れるのはその 4〜8 秒後です。このパックはそれを待ちませんし、到着の確認もしません。 Pal を参照してください。
以下のビーコンは、ゲームにすでにある構造物・アイテム・生物を受け持ちます。Lua は既存の id に 動きとメタデータを足せますが、ゲームのデータテーブルに新しい行そのものを追加することは できません。新しいビルドオブジェクト・アイテム・生物の行を publish するのは PalSchema の 仕事です。自分の行ができたときに変わるのは 1 行だけで、それは「応用」の 自分の id に移行するで示します。
パックの構成
パックは、PalForge と同じく Scripts/main.lua を持つ独立した UE4SS モッドフォルダーです。
ue4ss/Mods/BeaconPack/
enabled.txt <- UE4SS's own switch for this mod folder
Scripts/
main.lua <- the entry point UE4SS runs
beacon/
init.lua <- requires every domain module, in dependency order
audio.lua <- the sounds
effects.lua <- the blessing
pals.lua <- the guardian
items.lua <- the reward
skills.lua <- the signal: sound + blessing + guardian
buildings.lua <- the beacon itselfモッドフォルダーの有効化は、お使いの UE4SS の流儀で行ってください。フォルダー内の
enabled.txt でも、mods.txt への 1 行でも構いません。大事なのはその下の形、つまり
コンテンツの種類ごとに 1 ファイルと、それらを require する init.lua です。
PalForge は読み込みの最後に、自分自身を _G.PalForge というグローバルに置きます。
_G.PalForge = {
env = env, -- dev, debug, name, version, gameBuild, multiplayer
api = require("palforge.api"), -- Pal, Item, Building, Skill, Effect, Audio, Mesh, UI, Player
pack = api.pack, -- the SCOPED surface: PalForge.pack("beacon").Item{ ... }
utils = { log, json, file, items },
core = { registry, event, object_manager, spawn, mesh, sound, player, spatial, icons,
uobject, assetpath },
native = require("palforge.native"),
}パックはこのテーブルを読みます。PalForge から必要なものはこれがすべてです。api が
コンテンツを書く相手、utils.log がゲームのコンソールに出すロガー、core.event が
イベントバス、native が Palworld 自身のコンテンツを id ごとに並べたカタログです。
パックは api を直接ではなく PalForge.pack("beacon") に対して書いてください。 返るのは
同じ 9 つのメンバーで、8 つのコンストラクタは包まれており、そこを通した定義はすべて所有者として
beacon を記録します。この所有情報があるかどうかが、id の衝突を「黙って上書き」から「両方の
パック名を挙げたログ 1 行」に変えます。そもそも定義の呼び出しはただの Lua 呼び出しで、誰が
呼んだかの証拠を持たないので、フレームワークが持ち主を言えるのはこの経路だけです。使わなくても
パックは動きます。その場合、コンテンツは所有者なしで登録されるだけです。
local api = _G.PalForge.pack("beacon", { depends = { "otherpack" } })
local Item = api.Item
Item{ id = "beacon:Potion" } -- registered with pack = "beacon"depends(および recommends)は、このパックが値の中で言及してよい名前空間を記録します。
他人の名前空間の id は、宣言してあるか、さもなければ報告されます。包まれない唯一のメンバーが
Player です。何も定義しないので、帰属させるものがありません。
モジュールを require すれば、その中身は登録されます。X{ ... } の呼び出しはファイルの
読み込み中に実行され、定義を PalForge に書き込むので、require そのものが登録です。
パックを作る
モッドフォルダーを作る
ue4ss/Mods/BeaconPack/Scripts/beacon/ を作り、UE4SS の流儀でモッドを有効化します。準備は
これだけです。生成するものもビルドするものもありません。
エントリーポイントを書く
main.lua がするのは 3 つです。require("beacon.audio") が自分のファイルを見つけられる
ように Scripts ディレクトリーを package.path に足すこと、PalForge が読み込まれているか
確かめること、パックを require することです。中身は短く保ちます。コンテンツはモジュール側の
仕事です。
-- BeaconPack — a PalForge content pack, running as its own UE4SS Lua mod.
--
-- Install layout (mirrors PalForge's own):
-- ue4ss/Mods/BeaconPack/Scripts/main.lua <- this file
-- ue4ss/Mods/BeaconPack/Scripts/beacon/*.lua <- the pack's modules
-- Make require() resolve beacon.* relative to this Scripts dir.
local thisDir = debug.getinfo(1, "S").source:match("@?(.*[\\/])") or ""
package.path = thisDir .. "?.lua;" .. thisDir .. "?\\init.lua;" .. package.path
-- PalForge publishes itself on _G.PalForge at the end of its own main.lua.
local forge = _G.PalForge
if not forge then
pcall(print, "[BeaconPack] PalForge is not loaded - this pack does nothing this session")
return
end
local log = forge.utils.log.scope("beacon")
log.info("loading against PalForge v" .. tostring(forge.env.version))
local ok, err = pcall(function() require("beacon") end)
if not ok then
log.err("load failed: " .. tostring(err))
else
log.info("loaded")
endforge.utils.log.scope("beacon") は info / warn / err を持つロガーを返します。
それぞれメッセージを 1 つ受け取ります。出力される行はすべて [PalForge.beacon] と
ログレベル([PalForge.beacon][info]、[PalForge.beacon][warn]、[PalForge.beacon][err])
で始まるので、PalForge 本体と同じやり方でパックのログを絞り込めます。
_G.PalForge は PalForge が読み込まれたあとにだけ、しかも同じ Lua ステートの中にだけ
存在します。パックはその同じステートに、PalForge より後に読み込まれる必要があります。上の
ガードはエラーにせず静かに抜けるので、このメッセージが出たら 2 つのモッドフォルダーの
読み込み順を確認してください。
パックのインデックスを書く
コンテンツの種類ごとに 1 つの require を、依存順に並べます。スキルは効果音・強化・守護パルを 使い、建築はスキルと報酬アイテムを使います。
-- beacon — the pack's modules, one per domain, required in dependency order.
-- Requiring a module IS registering its content: every X{ ... } call inside runs on
-- require and writes the definition into PalForge's object_manager.
local M = {}
M.audio = require("beacon.audio") -- the sound the beacon plays
M.effects = require("beacon.effects") -- the blessing it hands out
M.pals = require("beacon.pals") -- the guardian it calls
M.items = require("beacon.items") -- the reward it pays out
M.skills = require("beacon.skills") -- the signal: sound + blessing + guardian
M.buildings = require("beacon.buildings") -- the structure the player interacts with
return M効果音を宣言する
音は鳴らしたいときに自分で鳴らします。勝手に鳴るものではありません。定義では、ゲーム自身の
サウンドイベント AkAudioEvent を 2 通りで指定します。実際に再生されるのはアセットパスの
soundPath、soundId は名前で引くフォールバックです。
-- beacon.audio — the sounds this pack plays.
local api = _G.PalForge.pack("beacon")
local log = _G.PalForge.utils.log.scope("beacon.audio")
local Audio = api.Audio
local M = {}
-- The beacon's activation sound, named by the pack. Both fields point at the SAME event;
-- the pair is copied out of PalForge's AkAudioEvent catalog (native.audio.CATALOG).
M.Signal = Audio.se{
id = "beacon:Signal",
name = "Beacon Signal",
description = "Played on the interacting character when the beacon fires.",
soundId = "AKE_BuffAura",
soundPath = "/Game/Pal/Sound/Events/SE/System/StatusCondition/AKE_BuffAura.AKE_BuffAura",
}
-- The same thing, looked up by name instead of typed out: native.audio.se returns a ready
-- handle for any catalog name, and nil for a name the catalog does not have.
M.Reward = _G.PalForge.native.audio.se("AKE_CampLevelUp")
if not M.Reward then log.warn("AKE_CampLevelUp is not in the AkAudioEvent catalog") end
return MAudio.se と Audio.bgm は kind を代わりに埋めてくれます。kind は自分のための目印で、
BGM も効果音も再生の呼び出しは同じです。
soundFile(自前の音声ファイル)は定義時に拒否され、未解決項目 audio-custom-file-loader を
名指しするエラーになります。このビルドでは独自音声は再生できません。UE ネイティブ経路には製品
ビルドまで残るインポーターがなく、Wwise 経路はクックが既に用意したメディアを差し替えるだけで、
ファイルを読みません。代わりにゲームのサウンドを名前で指定してください。そして Handle:stop も
Handle:setVolume もサウンド単位ではありません。どちらもそのアクターで鳴っているものすべてに
効くので、特定の 1 つを静かにしたいときは静かなイベントを選んでください。
エフェクトを宣言する
エフェクトは自分のスケジュールを持ちます。:apply(target) が適用を開始し、PalForge が
共有の 500 ms ハートビートで、期限切れか削除まで進めます。
-- beacon.effects — the blessing the beacon hands out.
local api = _G.PalForge.pack("beacon")
local log = _G.PalForge.utils.log.scope("beacon.effects")
local Effect = api.Effect
local Item = api.Item
local M = {}
-- 12 seconds long, ticking every 3, up to 3 stacks. The timing is owned by PalForge; the
-- gameplay is owned by these handlers.
M.Blessing = Effect{
id = "beacon:Blessing",
name = "Beacon Blessing",
description = "A short blessing granted by the beacon.",
duration = 12.0,
interval = 3.0,
stackable = true,
maxStacks = 3,
events = {
onApply = function(effect, target, ctx)
-- ctx carries effect and stacks, plus whatever you passed to :apply.
log.info("blessing applied by " .. tostring(ctx.source))
end,
onStack = function(effect, target, ctx)
log.info("blessing stacked to " .. tostring(ctx.stacks))
end,
onTick = function(effect, target, ctx)
-- One berry per tick, scaled by the stack count. ctx.elapsed is how long this
-- application has been alive, in seconds.
Item.get("Berries"):give(ctx.stacks)
log.info(string.format("blessing tick at %.1fs", ctx.elapsed))
end,
onExpire = function(effect, target, ctx)
-- ctx.reason is "duration", "removed", "target_gone" or "world_left".
log.info("blessing ended: " .. tostring(ctx.reason))
end,
},
}
return Mエフェクトのハンドラーの第 1 引数はこのエフェクトのハンドルなので、:remove や
:timeLeft、:stacksOn がそのまま使えます。:apply(target, ctx) に渡したものは
onApply と onStack から読めます。onTick と onExpire が受け取るのはランタイム自身の
context です(effect、elapsed、stacks、終了時は reason)。
定義の nativeStatus は、エフェクトが動いているあいだゲーム本来の状態異常アイコンを点け、終了
すると消します。ゲームプレイまで一緒に付いてくるわけではないので、エフェクトが実際にすることは
onTick に書いてください。アイコンとダメージは別の話です。
守護パルを宣言する
"Kitsunebi" はゲームにすでにある生物 id なので、この定義は既存の生物に動きを与えるもの
です。mesh は省いてください。パルは自前のモデルを持っているので、mesh を書くのは別の
モデルを着せたいときだけです。
-- beacon.pals — the guardian the beacon calls.
local api = _G.PalForge.pack("beacon")
local log = _G.PalForge.utils.log.scope("beacon.pals")
local Pal = api.Pal
local M = {}
M.Guardian = Pal{
id = "Kitsunebi",
name = "Beacon Guardian",
description = "The creature the beacon calls.",
-- Ids only. Nothing equips them for you; read them back with :skillsOf().
skills = { "beacon:Flare" },
events = {
onSpawned = function(pal, ctx)
log.info("guardian spawned: " .. tostring(ctx.actor))
end,
onDamaged = function(pal, ctx)
log.info("guardian took damage")
end,
onCaptured = function(pal, ctx)
log.info("guardian captured - the beacon lost its keeper")
end,
onDeath = function(pal, ctx)
log.info("guardian died")
end,
},
}
return MPalForge はスポーンした生物のブループリントクラス名からこの定義を見つけます。
BP_Kitsunebi_C から Kitsunebi を取り出して引きます。自分の定義を持たないバニラのパルは
どれにも一致せず、ハンドラーは呼ばれません。
パルには onTick も書けます。PalForge が 3 秒ごとにワールド内の生きたパルを見て回り、
1 体につき 1 回呼びます。ctx.actor がその個体、ctx.count がハートビート番号、ctx.now
が時刻です。同じ id の個体すべてでハンドルは 1 つなので、個体ごとの値は ctx.actor を
キーにして自分で持ってください。間隔は
require("palforge.core.event").PAL_SCAN_MS = 5000 で変えられ、0 にすると止まります。
報酬アイテムを宣言する
-- beacon.items — the reward the beacon pays out.
local api = _G.PalForge.pack("beacon")
local log = _G.PalForge.utils.log.scope("beacon.items")
local Item = api.Item
local M = {}
-- "Ruby" is a real game ItemId. Defining it attaches metadata and handlers to that id; it
-- does not create a new inventory row.
M.Reward = Item{
id = "Ruby",
name = "Ruby",
description = "What the beacon pays out.",
category = "material",
maxStack = 999,
-- Metadata: nothing in PalForge publishes a recipe into the game's crafting tables.
-- Read it back with Item.get("Ruby"):recipeOf().
recipe = {
materials = { Stone = 20, Flint = 5 },
count = 1,
work = 30,
station = "Workbench",
},
events = {
onObtain = function(item, ctx)
log.info("reward obtained x" .. tostring(ctx.count) .. " via " .. tostring(ctx.via))
end,
onCraft = function(item, ctx)
-- Fired when a production finishes at a real machine. ctx.count is absent: the
-- per-craft count lives in the recipe row, not on the hook.
log.info("reward crafted from recipe " .. tostring(ctx.recipeId))
end,
},
}
return Mアイテムのハンドラーは 4 つとも動いています。onObtain にはソースが 2 つあり、1 つのイベントに
まとめられます。ゲーム自身の「アイテムを入手した」ログ(PalPlayerState:AddItemGetLog_ToClient、
ctx.via = "getlog")と、インベントリーへの追加(PalPlayerInventoryData:AddItem_ServerInternal、
ctx.via = "additem")です。両方を有効にしているのは、この 2 つが逆向きに失敗するからで、どちらが
運んだかは ctx.via が言います。onCraft は実際の設備で生産が完了したときに発火します
(ctx.itemId、ctx.recipeId、ctx.via = "convert" | "product")。ctx.count は意図的にありません。
1 回あたりの個数はレシピ行にあり、フックはデータテーブルを読む場所ではないからです。onDiscard は
ドロップまたは破棄で発火します(ctx.itemId、ctx.count、ctx.reason = "drop" | "dispose")。
シグナル(スキル)を宣言する
スキルのハンドラーをゲームが呼ぶことはありません。自分で呼びます。:activate(owner) は
その場で onActivate を走らせ、クールダウン中なら断ります。この「断り」が、ビーコンに必要な
ゲートそのものです。
-- beacon.skills — the signal the beacon fires. :activate runs the handler now and refuses
-- while the skill is still cooling down, so the beacon gets its rate limit for free.
local api = _G.PalForge.pack("beacon")
local log = _G.PalForge.utils.log.scope("beacon.skills")
local Skill = api.Skill
local audio = require("beacon.audio")
local effects = require("beacon.effects")
local pals = require("beacon.pals")
local M = {}
M.Flare = Skill{
id = "beacon:Flare",
name = "Beacon Flare",
description = "Sound, blessing and a called guardian - the beacon's whole payload.",
kind = "active",
element = "fire",
cooldown = 30.0,
power = 25,
events = {
onActivate = function(skill, owner, ctx)
audio.Signal:play(owner) -- on the interacting character
effects.Blessing:apply(owner, { source = ctx.beacon }) -- 12s, stacks to 3
pals.Guardian:spawn{ at = ctx.at, level = 5 } -- arrives seconds later
log.info("flare fired from " .. tostring(ctx.beacon))
end,
},
}
return Mクールダウンは owner ごと、スキル id ごとに記録されるので、2 人のプレイヤーは互いに干渉せず
クールダウンします。ビーコンごとではありません。ここでの owner は ctx.player なので、同じ
プレイヤーが 2 つ目のビーコンを調べても 30 秒が明けるまでは発動しません。
ビーコン本体を宣言する
設置した構造物には、1 つずつ専用のライブオブジェクトが付きます。それが以下のハンドラーの
self です。self.actor が設置されたアクター、self.pos がワールド座標、self.state が
保存される自分用のテーブル、self.key がセーブ内での安定した名前、self:save() が state を
ワールドファイルに書き出します。
-- beacon.buildings — the structure the player interacts with.
local api = _G.PalForge.pack("beacon")
local log = _G.PalForge.utils.log.scope("beacon.buildings")
local Building = api.Building
local items = require("beacon.items")
local skills = require("beacon.skills")
local M = {}
-- "Altar" is a real game BuildObjectId, so the beacon claims a structure that is already in
-- the build menu.
M.Beacon = Building{
id = "Altar",
name = "Signal Beacon",
description = "Interact with it to fire the beacon.",
gridCm = 100,
tickInterval = 4, -- every 4th heartbeat, so roughly every 2 seconds
-- A factory, not a plain table: a plain table is handed to every new instance as the
-- SAME table, and two beacons would then share one counter.
state = function()
return { uses = 0, charges = 3 }
end,
events = {
onPlace = function(self, ctx)
log.info(string.format("beacon placed at %.0f/%.0f/%.0f",
self.pos.x, self.pos.y, self.pos.z))
self:save()
end,
onLoad = function(self, ctx)
log.info(string.format("beacon tracked %s: %d use(s), %d charge(s)",
ctx.reconstructed and "from the save" or "fresh",
self.state.uses, self.state.charges))
end,
onRightClick = function(self, ctx)
if self.state.charges <= 0 then
log.info("beacon is empty - wait for it to recharge")
return
end
-- ctx.player is the character that interacted; ctx.actor is the structure.
local fired = skills.Flare:activate(ctx.player, { beacon = self.key, at = self.pos })
if not fired then
log.info(string.format("beacon cooling down: %.0fs left",
skills.Flare:cooldownLeft(ctx.player)))
return
end
items.Reward:give(3)
self.state.uses = self.state.uses + 1
self.state.charges = self.state.charges - 1
self:save()
end,
onTick = function(self, ctx)
if self.state.charges >= 3 then return end
self.state.charges = self.state.charges + 1
self:setDirty() -- mark it; the file is written by the next :save() or on world-left
if self.state.charges == 3 then
log.info("beacon fully charged at heartbeat " .. tostring(ctx.count))
self:save()
end
end,
onRemove = function(self, ctx)
log.info(string.format("beacon gone [%s] after %d use(s)",
tostring(ctx.reason), self.state.uses))
end,
onWorldLeft = function(self, ctx)
-- The last look at this instance: it is dropped right after, while its saved
-- record survives for the next load.
log.info("beacon going quiet: " .. self.key)
end,
},
}
return M見落としやすい点が 2 つあります。
onTickは、それを書いた定義でだけ動きます。onTickの無い建築は tick の対象リストにも 入りません。tickIntervalは秒ではなくハートビート数です。ハートビートは 500 ms なので、4は およそ 2 秒です。
読み込んでログを見る
両方のモッドを有効にしてゲームを起動し、ワールドに入って Altar を設置し、調べてみます。 UE4SS のコンソールに一部始終が出ます。
[PalForge.main][info] PalForge v0.3.0 starting | game build: declared v1.0.2.101103, live v1.0.2.101103 (PalGameSetting) | dev=true debug=true | dev overlay: Scripts/palforge_dev.lua
[PalForge.main][info] PalForge targets SINGLE-PLAYER Palworld. Dedicated servers and co-op guests are not supported and are not tested: there is no replication layer, and the item, spawn and event routes are all client-authoritative. A pack may appear to work for the host and do nothing for anyone else.
[PalForge.registry][info] initialized (dev=true, debug=true, 17 class(es) registered)
[PalForge.beacon][info] loading against PalForge v0.3.0
[PalForge.beacon][info] loaded
[PalForge.event][info] world ready - building dispatch enabled
[PalForge.beacon.buildings][info] beacon placed at 12345/-6789/420
[PalForge.beacon.buildings][info] beacon tracked fresh: 0 use(s), 3 charge(s)
[PalForge.beacon.effects][info] blessing applied by Altar@123,-68,4
[PalForge.beacon.skills][info] flare fired from Altar@123,-68,4
[PalForge.items][info] give Ruby x3: 0 -> 3 [evidence declared]
[PalForge.beacon.effects][info] blessing tick at 3.0s
[PalForge.spawn][info] spawn.palAt: placed new pal at (12345,-6789,420); it reads back (12345,-6789,420), off by 0登録クラス数とインスタンスキーはセッション次第で変わりますが、行の順番は変わりません。
onActivate は自分のログを出す前に効果音・エフェクト・スポーンを実行するので、先にエフェクトの行が
出て、give の行は :activate が返ってから出ます。
スポーンの行が数秒遅れて最後に来るのは、問題ではなくスポーンという処理の形です。呼び出しは操作の
最中に発行され、パルはそのあとに到着し、配置が報告されるのはそのときです。どの行も実測した内容を
書きます。give の行は前後の所持数を、スポーンの行はパルから読み戻した位置を持ちます。どの段階が
成立して、どれが成立しなかったかが、そのまま見えます。
実行時に起きること
ビーコンの設置は 1 つのイベントではありません。ゲームはアクターが存在する前に
RequestBuild_ToServer のフックで設置を知らせ、同じ 500 ms のハートビートで走るスキャンが
後から本物のアクターを見つけ、ライブオブジェクトを作り、保存し、宣言された mesh をその次の
パスで取り付けます。設置されたフレームで取り付けるとゲームがクラッシュするため、mesh は
待たされます。
設置された構造物には固有の id が無いため、PalForge はビルド id と丸めた座標で名前を付けます。
gridCm = 100 のビーコンなら Altar@123,-68,4 です。これが self.key で、保存レコードも
この名前で格納されます。
インタラクトは短い経路です。OnBeginInteractBuilding のフックが 1 秒以内の連打を無視して
building.interact を送り、PalForge がアクターからライブオブジェクトを見つけて、その
onRightClick を呼びます。
ビーコンの残りの一生も、同じハートビートと同じワールドゲートの上で動きます。
- ワールド準備完了。 1 秒ごとのポーリングで有効な
PalPlayerCharacterを 5 回連続で 見つけるとゲートが開き、その後に最初に終わったスキャンがworld.readyを送って、生きて いる構造物すべてのonWorldReadyを呼びます。ゲートが開くまで建築スキャンは何もしません。 これがロード直後の嵐から距離を取る仕組みです。 - Tick。
LoopAsync(500)がtickを送ります。onTickを書いた定義の生きている構造物 が、それぞれのtickIntervalに従って呼ばれます。ハンドラーが 5 回失敗するとその構造物のonTickは止められ、警告がログに出ます。他は動き続けます。 - 削除。 スキャンで 6 回連続して見つからなかった構造物は
building.removeを送って (つまりonRemoveはまだそれを見られます)破棄され、保存レコードも消えます。 - ワールド退出。 プレイヤーポーンが無効になると
world.leftが送られ、生きている構造物 すべてのonWorldLeftが走り、ワールドファイルが書き出され、ライブオブジェクトは破棄され ます。保存レコードは残り、次回それをonLoadが復元します。
state の保存には 2 つのやり方があります。self:save() は変更ありの印を付けて、今すぐワールド
ファイルを書きます。self:setDirty() は印を付けるだけで、1 秒に 2 回走るようなハンドラーでは
こちらが適切です。書き込みは次の save() かワールド退出時に行われます。
起動後に登録された定義も問題ありません。建築ランタイムはスキャンのたびに、そして設置を 記録する前に定義を読み直すので、PalForge の数分後に読み込まれたパックの構造物もきちんと 追跡されます。
ゲームが動かすもの、自分で動かすもの
ハンドラーを前提に設計する前に、ここを確認してください。
| ドメイン | ゲームが呼ぶもの | 自分で呼ぶもの |
|---|---|---|
| Building | onPlace, onLoad, onRightClick, onRemove, onTick, onBuild, onWorldReady, onWorldLeft | :instances(), :render(), :update(), :unlock() |
| Pal | onSpawned, onDamaged, onDeath, onCaptured, onTick | :spawn()(パルは数秒後に到着します)、:renderOn(actor)、:skillsOf()、:teachAll(actor) |
| Item | onObtain, onUse, onCraft, onDiscard | :count()、:give(n)、:take(n)(消費します。プレイヤーが何かを装備している必要があります)、:iconOf()、:recipeOf() |
| Effect | onApply, onTick, onStack, onExpire(PalForge 自身のスケジュール) | :apply(), :remove(), :isActive(), :stacksOn(), :timeLeft() |
| Skill | なし | :activate(), :hit(), :equip(), :unequip(), :cooldownLeft(), :teach(actor), :forget(actor), :skillsOn(actor) |
| Audio | なし | :play(), :stop() |
書けるけれど決して動かないハンドラーが 2 つあります。Building.onLeftClick と
Building.onBreak です。これは「まだ見つかっていない」ではなく、否定として決着した結果です。
それを持ちうるクラスの関数一覧をすべて読んだ上での話で、PalBuildObject の 22 関数にクリック・
命中・打撃に当たるものはなく、唯一ダメージらしい 1 つはプレイヤーが近くにいなくても 12〜13 秒
ごとに走る劣化タイマーで、破壊はデリゲートのフィールドとしてしか存在せず、RegisterHook は
パスで指定できません。操作には onRightClick を、消えた構造物には ctx.reason = "missing" の
onRemove を使ってください(これは解体とストリーミングでの消滅を区別できません)。どちらも
パック自身の emit のため、そして将来ソースが現れたときの受け皿として宣言だけは通ります。
Building.onBuild は建築が完了したときに走ります。ライブオブジェクトができるまで最大で
スキャン 1 回ぶんの時間があるので、self がライブオブジェクトではなく定義になる唯一の建築
ハンドラーです。self.actor、self.pos、self.state、self:save() はありません。あるのは
ctx.buildId と ctx.model です。同じ合図はセーブを開いたときに既存の構造物すべてでも
発火するため、PalForge はワールドの読み込みが終わってから聞き始めます。設置の処理は
onPlace が安全です。onBuild はまず捨ててよいワールドで試してください。
Building.onWorldReady は、構造物ごとではなくワールドロード時の 1 回だけの合図です。
world.ready はワールドが開いたあと最初に終わったスキャンが送るので、プレイヤーの近くの
構造物はすでに追跡されていて受け取れますが、後のスキャンで出てくるものは受け取れません。
構造物ごとの初期化は onLoad に置いてください。セーブから戻ってきたことは
ctx.reconstructed で分かります。event.on("world.ready", fn) でチャンネルを直接購読する
のでも構いません。
Pal.onSpawned は 3 つのソースに仕掛けられ、いずれもワールドの読み込み後にだけ有効になります。
同じ合図はロード時の嵐の中で既存のパル全部について発火し、起動時に仕掛けると共有フック
ディスパッチを詰まらせたからです。BroadcastOnCompleteInitializeParameter は実際のセーブで
何も運ばないことが計測されたので、実際に効いているのはそこに束ねられたデリゲートの受け手 2 つ、
PalPlayerCharacter:OnCompleteInitializeParameter(プレイヤーが購読したキャラクターにだけ
発火します)と PalNPC:OnCompletedInitParam(パル自身の側に束ねられているので、誰かの購読に
依存しません)です。2 回走っても平気なハンドラーにしてください。
応用
ビーコンに専用の mesh を持たせる
mesh もひとつのコンテンツなので、一度宣言すれば複数の定義から着せられます。モジュールを 追加します。
-- beacon.meshes — named visuals, so a model is declared once and reused by id.
local api = _G.PalForge.pack("beacon")
local Mesh = api.Mesh
local M = {}
-- A structure wears a STATIC mesh. Any UStaticMesh path works; this one ships with the game.
M.Beacon = Mesh{
id = "beacon:BeaconBody",
kind = "static",
model = "/Game/Pal/Model/Other/PalBox/SM_PalBox.SM_PalBox",
scale = 1.0,
color = { r = 1.0, g = 0.6, b = 0.1, a = 1.0 },
}
return Mそのうえで beacon/buildings.lua の先頭で require し
(local meshes = require("beacon.meshes"))、定義の mesh にハンドルを渡します
(gridCm の隣に mesh = meshes.Beacon,)。ハンドルを渡しても、その場に直接書いても
構いません。mesh = meshes.Beacon と mesh = { kind = "static", model = "..." } は
まったく同じように検証されます。
mesh が取り付けられていれば、self.color と self:update() でハンドラーから色を塗り直せ
ます。
onTick = function(self, ctx)
self.color = (self.state.charges >= 3)
and { r = 1.0, g = 0.9, b = 0.3, a = 1.0 }
or { r = 0.4, g = 0.4, b = 0.5, a = 1.0 }
self:update()
end,update() は PalForge が取り付けた mesh の無いアクターでは安全に何もしないので、mesh を
持たない定義で色を変えても失敗はせず、単に何も起きません。
自分の id に移行する
コロンを含む id "beacon:Beacon" は、データテーブルの行名 beacon_Beacon になります。
PalSchema がその行を publish したら、パックの変更は 1 か所だけです。
M.Beacon = Building{
id = "beacon:Beacon", -- your own row instead of "Altar"
name = "Signal Beacon",
-- ... everything else identical
}MOD で追加した建築のテクノロジーは、解決後の id と同じ名前の DT_TechnologyRecipeUnlock
行になります。Handle:unlock() はまさにその行を解放し、構造物がビルドメニューに出るように
します。
_G.PalForge.api.Building.get("beacon:Beacon"):unlock()この呼び出しは PalCheatManager がすでにセッションに存在している必要があるので、
CheatManagerEnabler モッドを入れておいてください。無ければプレイヤーコントローラー上に自分で
組み立てるスポーン経路とは違い、テクノロジー解放はアイテム側のヘルパーの探索を通り、そちらは
チートマネージャを要求するだけで作りません。解放後の確認も行います。その名前のテクノロジー行が
無ければ、呼び出しは発行したが解放するものが無かったとログに出て、unlock() は false を
返します。
デバッグ用モジュールを足す
イベントバスは自由に使えるので、診断用モジュールは数行で書けます。購読のコストは無く、 すべてのチャンネルは何かが送られる前に用意されているので、先に購読しても取りこぼしません。
-- beacon.debug — diagnostics for the pack. Require it from init.lua while developing.
local forge = _G.PalForge
local log = forge.utils.log.scope("beacon.debug")
local event = forge.core.event
local Effect = forge.api.Effect
local Player = forge.api.Player
local M = {}
-- Raw channel traffic, before dispatch resolves anything.
M.subs = {
event.on("building.interact", function(ctx)
log.info("interact: buildId=" .. tostring(ctx.buildId))
end),
event.on("pal.captured", function(ctx)
log.info("captured: " .. tostring(ctx.actor))
end),
}
-- Every 5 seconds: how many beacons are live, and what is on the player.
M.report = event.every(5000, function()
local beacons = forge.api.Building.get("Altar"):instances()
local active = Effect.activeOn(Player.character())
log.info(string.format("%d beacon(s) live, %d effect(s) on the player",
#beacons, #active))
end)
-- Stop everything: for _, s in ipairs(M.subs) do s:unsubscribe() end; M.report:unsubscribe()
return Mevent.every(ms, fn) は 500 ms のハートビートに丸められます。購読はどれも
:unsubscribe() を持つオブジェクトを返します。
動かないとき
定義の呼び出しがエラーになる。 問題はすべてハードエラーなので、呼び出しが中途半端に 成功することはありません。メッセージを読んでください。たいていそこに答えが全部あります。 フィールド名のタイプミス:
PalForge: Building: unknown field "tickinterval" (did you mean "tickInterval"?). Valid fields: id, name, description, gridCm, buildIds, tickInterval, mesh, material, color, texture, icon, state, events, dataevents の中のタイプミス。入れ子のシェイプ名まで出るので、どの宣言を読めばよいかが分かり
ます:
PalForge: Building: field "events" (Building.Spec.Events): unknown field "onRightclick" (did you mean "onRightClick"?). Valid fields: onPlace, onLoad, onRightClick, onRemove, onTick, onWorldReady, onWorldLeft, onBuild, onLeftClick, onBreak必須フィールドの欠落。そのフィールド自身の説明が付きます:
PalForge: Building: field "id" is required (build id: a game BuildObjectId ("PalBoxV2") or "pack:name")そして id 自身の形も、定義の時点で検査されます。名前空間付きの id はデータテーブルの行名
packid_name に解決されるので、両側とも英数字か _ でなければなりません。ハイフンを含む id は
定義も登録も通ったうえで、どこにも解決しません。
PalForge: Building: field "id" is invalid: invalid pack id 'my-pack' in 'my-pack:Bench' (letters/digits/_ only)同じ情報は、当てずっぽうに書く代わりに実行時に読めます。
local schema = require("palforge.core.schema")
print(schema.help("Building.Spec")) -- every field, type, default and meaning
print(schema.help("Building.Spec.Events"))調べても何も起きない。 まず [PalForge.event][info] world ready を探します。建築ランタイム
はこれを待っています。次に構造物が追跡されているか確認します:
#Building.get("Altar"):instances()。空のリストなら、スキャンがアクターと定義を結び付けら
れていません。たいていは Building{ id = ... } の id が、そのゲーム内構造物の id と違うのが
原因です。
パルが出てこない。 まず 8 秒ほど待ってください。スポーンは即座ではなく、いつ到着したかはログに
出ます。それでも現れない場合に潰すべき、もっと手前の失敗はプレイヤーコントローラーの不在です。
無い場合は呼び出しがゲームに届かず、
spawn.palAt: no PalCheatManager and none could be constructed (no player controller yet?)
をログに出します。
ごほうびが届かない。 :give は所持数が増えたのを観測できなかったときに false を返し、
どこで止まったかをログ行が教えてくれます。インベントリーがその場で拒否した、ゲームが知らない
アイテム ID だった、鞄に空きが無かった、のいずれかです。
スキルが 2 回目から発動しない。 cooldown が働いています。:activate はクールダウン中
に false を返します。kind が "passive" のスキル、そしてハンドラー自身が例外を投げた
場合も false です。
エフェクトは tick しているのに何も変わらない。 エフェクトが回すのは自分のスケジュール
であって、ゲームの状態異常システムではありません。実際の効果は onTick に書きます。
同じ id の定義が 2 つある。 最後に走った定義が勝つのは変わりませんが、黙って上書きせず
レジストリがそう言います。別のパック同士なら両方の名前を挙げます:
item 'mypack:Potion' was defined by pack 'mypack' and is being redefined by pack 'other'; the new definition replaces the old one (last-wins)。この行が出るのはパックが自分の名前を言って
いるときだけで、そのための PalForge.pack("beacon") です。定義はロード時に行い、ハンドラーの
中では再定義せず X.get(id) を使ってください。
次に読むもの
Building
ライブオブジェクト、state、スキャン、そして定義の全フィールド。
Lifecycle
チャンネル・ソース・ディスパッチを端から端まで。
Skill
自分でスキルを発動する方法と、クールダウンの持ち方。
Effect
duration・interval・スタックと、ハートビートごとの処理。
Pal
スポーン、mesh、そしてパルのハンドラー全部。
Item
give と take、レシピ、動く 2 つのイベント。
Audio
音が鳴るまでの道筋、カタログ、そして足りないもの。
Mesh
名前付きのビジュアル、バックエンド、マテリアル上書き。
Definitions
どのコンテンツにも共通する 3 つの入口。
Schema
フィールドの形の宣言・検証・読み出し。
Editor setup
spec から生成される、全フィールドの補完。
まとめ
- パックは
Scripts/main.luaを持つ独立したモッドフォルダーです。_G.PalForgeを読み、 自分のモジュールを require すると、その中のX{ ... }が読み込み時にコンテンツを登録します。 - api は
PalForge.apiではなくPalForge.pack("beacon")から取ってください。メンバーは同じ 9 つで、そこを通した定義はどのパックのものかを記録します。 - どのコンテンツでも必須フィールドは
idだけで、その形は定義時に検査されます。コロンを含む id("beacon:Flare")は自分のもの、含まない id("Altar"、"Ruby"、"Kitsunebi")は ゲームにすでにあるものです。どちらの側も英数字か_です。 - 専用のライブオブジェクトを持つのは、設置した建築だけです。
self.stateが自分のテーブルで、self:save()でワールドファイルに書けば次のセッションにも残ります。 - 建築・パル・アイテムのハンドラーはゲームが呼びます。スキルと音は自分で鳴らし、エフェクトは PalForge のスケジュールで動きます。
tickIntervalは秒ではなく 500 ms のハートビート数です。- 定義が間違っていると、フィールド名と正しい候補を示すメッセージで呼び出しが止まります。同じ
一覧は
schema.help("Building.Spec")で実行時にも読めます。
次は Building を読むと、構造物に書ける全フィールドと、ライブオブジェクト でできることが分かります。