○前置き
この記事はQiita Advent Calendar2024にて投稿予定だったものです。折角なので投稿するだけしとこうかなと。
さて、ここではFoundryVTTのmodule開発のざっくりとした情報と解説用に作ったモジュールを共有しようと思います。
実際に作ってみたものがこれ
特に用途があって作った訳ではないので実用性はないですが、Module作成を始める方の参考になれば。
コードについては改変含めて自由にご利用ください。
○そもそもFoundryVTTって何?
FoundryVTTはTRPGを行うための海外のセッションツールの1つで、日本ならココフォリアやユドナリウム等が主流?なのかな。
自分はたいたい竹流氏のどどんとふで育った身なので他ツールはあまり詳しくありませんが...。
他のツールとの違いとしては戦闘機能やダイスロールの自動化が挙げられます。敵のコマをターゲットして、呪文を選んでボタンを押すだけで攻撃ロールからダメージロール、ACとの比べ合いにセーヴィング・スローまで全て自動で行うことも可能です。
FoundryVTT自体の機能としてはDnD5eやPathfinder等の海外TRPG用の機能はFoundryVTT側で標準実装されており、十分なくらいですが、独自で作りたいギミックがある場合やハウスルールが少し複雑なとき、moduleとして自作すると面倒な処理を自動化できるわけです。
○FoundryVTTの内部構造
FoundryVTTは、内部的にElectronというデスクトップアプリケーションフレームワークを使用して動作しています。
ElectronはChromium(Google Chromeと同じエンジン)を使用しており、FoundryVTTは実質的にブラウザの一部として動作しています。
つまりFoundryVTT内で動作するmoduleはJavaScript、HTML、CSSをベースに構築されるので、かなり自由にmoduleを作れる訳です。
○開発環境
-
FoundryVTT本体
FoundryVTTがインストールされている必要があります(ライセンス必須)。
バージョンに応じてAPI仕様が異なるため、注意が必要。
今回はVersion11 Build315
で作成。 -
テキストエディタ/IDE
推奨:VSCode(Visual Studio Code) -
ブラウザ開発ツール
FoundryVTTはブラウザとして動作しているため、ブラウザの開発者ツール(Chrome DevToolsなど)でデバッグが可能。 -
Node.js
moduleはブラウザ環境で直接動作するため必須ではないが、あると便利。
○FoundryVTTの拡張機能
実際にmodule開発を行う前に、moduleとmacroの違いについてを説明します。
FoundryVTTにはユーザーが完全に独自で作成できるmoduleの他にツール内で完結するmacro機能が備わっています。できることならmacro機能で完結させたほうが安全なので、比較表を作ってみました。
機能 | Macro | Module |
---|---|---|
開発難易度 | 低い | 高い |
実行場所 | 特定のセッションやアクション内で動作する | セッション全体やシステム全体に機能を追加する |
導入方法 | セッション内でコードを記述 | 専用フォルダに配置後有効化 |
実行範囲 | 個別のアクションやイベント | システム全体または広範囲 |
自由度 | 制限あり | 高い |
更新・管理 | 比較的容易 | 専用の知識が必要 |
JavaScript利用範囲 | FoundryVTT APIに限定 | Node.jsモジュールやブラウザのAPIをフル活用可能 |
Macroの場合は、外部のJavaScriptライブラリやブラウザネイティブなAPIの利用は制限されますが、チャットへのメッセージ送信、アクターのステータス変更、シーンの制御等なら比較的簡単に実装することができます。
ただ、あくまでMacroはFoundryVTT内の機能のひとつであり、複雑なものを作る場合や独自のUIを追加するとなると、moduleのほうが圧倒的に楽です。
○Module開発の基本構造
開発する前にmoduleのディレクトリ構造を解説していきます。
以下はよくある典型的なmoduleの構造です。
/my-module
│
├── module.json
├── scripts/
│ ├── main.js
│ └── utils.js
├── styles/
│ └── styles.css
└── templates/
└── my-template.html
各ファイルを解説していきます。
必須ファイル
- module.json
- Moduleの情報を定義するJSONファイル。名前、バージョン、依存関係などを記載します。
- scripts/main.js
- Moduleのエントリーポイント。主なロジックやイベントリスナーを記述します。
補助ファイル
- scripts/utils.js
- ユーティリティ関数や共通処理を記述するファイル。
- styles/styles.css
- 見た目をカスタマイズするためのスタイルシート。
- templates/my-template.html
- カスタムHTMLテンプレートを定義します。
○Module開発
では早速、module開発を行っていきます。
Version11 Build315
にて開発をしますが、Versionが変わるごとにAPIの仕様がかなり変わるので注意が必要です。
今回はアクターリストを表示し、選択したアクターの画像を画面左上に表示するモジュールを作成します。
ざっくり書いていきます。
{
"name": "actor-image-display",
"title": "Actor Image Display",
"description": "ワールドのアクターリストを表示し、選択したアクターの画像を画面左上に表示するモジュール",
"version": "1.0.0",
"author": "skyhelper",
"compatibility": {
"minimum": "11",
"verified": "11.315"
},
"dependencies": [
{
"name": "socketlib",
"type": "module"
}
],
"scripts": ["scripts/main.js"],
"styles": ["styles/style.css"],
"socket": true
}
//初期化
Hooks.once("init", () => {
//モジュールの設定に登録
game.settings.register("actor-image-display", "shortcutPermission", {
name: "ショートカット利用可能な権限",
hint: "ショートカットを利用できる最小限のプレイヤー権限を設定します。",
scope: "world",
config: true,
type: Number,
default: 2, // Player
choices: {
1: "Observer",
2: "Player",
3: "Trusted Player",
4: "Assistant GM",
5: "Game Master"
}
});
//キーバインディングの登録
game.keybindings.register("actor-image-display", "openActorSelector", {
name: "アクター選択ダイアログを開く",
hint: "このショートカットキーを使用してアクター選択ダイアログを開きます。",
editable: [{ key: "F9" }], // デフォルトのキー
onDown: () => {
// ショートカットキーが押されたときの処理
const shortcutPermission = game.settings.get("actor-image-display", "shortcutPermission");
if (game.user.role >= shortcutPermission) {
showActorSelector();
return true;
}
return false;
},
reservedModifiers: [],
precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL
});
});
// socketlibにこのモジュールを登録
let displayImageSocket;
Hooks.once("socketlib.ready", () => {
displayImageSocket = socketlib.registerModule("actor-image-display");
displayImageSocket.register("updateActorImage", updateActorImage);
});
// アクターの画像を更新する関数
function updateActorImage(actorImg) {
const imageContainer = document.getElementById("selected-actor-image");
if (actorImg) {
imageContainer.innerHTML = `<img src="${actorImg}" alt="Actor Image" />`;
} else {
imageContainer.innerHTML = ""; // Clear image
}
}
// アクター画像を表示するためのコンテナを作成
Hooks.on("ready", () => {
// Create and append the image container
const imageContainer = document.createElement("div");
imageContainer.id = "selected-actor-image";
document.body.appendChild(imageContainer);
});
// アクター選択ダイアログを表示する関数
function showActorSelector() {
const actorList = game.actors.contents.map(actor => ({
id: actor.id,
name: actor.name,
img: actor.img
}));
actorList.push({ id: "none", name: "画像を表示しない", img: null });
// ダイアログの中身(HTML)
const dialogContent = `
<form>
<div class="form-group">
<label for="actor-list">アクターを選択してください:</label>
<select id="actor-list">
${actorList.map(actor => `<option value="${actor.id}">${actor.name}</option>`).join("")}
</select>
</div>
</form>
`;
// ダイアログを表示
new Dialog({
title: "アクター画像選択",
content: dialogContent,
buttons: {
ok: {
label: "決定",
callback: (html) => {
const selectedActorId = html.find("#actor-list").val();
const selectedActor = actorList.find(actor => actor.id === selectedActorId);
if (selectedActor) {
// socketlibを使用してすべてのクライアントに画像を更新するように指示
displayImageSocket.executeForEveryone("updateActorImage", selectedActor.img);
}
}
},
cancel: {
label: "キャンセル"
}
},
default: "ok"
}).render(true);
}
#selected-actor-image {
position: fixed;
top: 100px;
left: 150px;
z-index: 1000;
}
#selected-actor-image img {
max-width: 100px;
max-height: 100px;
border-radius: 5px;
border: 2px solid white;
}
module.json
情報定義用のJson
{
"//":" モジュールの内部名(英数字とハイフンのみ)",
"name": "actor-image-display",
"//":" モジュールの表示名(ユーザーに表示される名称)",
"title": "Actor Image Display",
"//":" モジュールの説明",
"description": "ワールドのアクターリストを表示し、選択したアクターの画像を画面左上に表示するモジュール",
"//":" モジュールのバージョン番号",
"version": "1.0.0",
"//":" 作成者の名前またはハンドルネーム",
"author": "skyhelper",
"compatibility": {
"//":" FoundryVTTの最低互換バージョン",
"minimum": "11",
"//":" 動作確認済みのFoundryVTTバージョン",
"verified": "11.315"
},
"dependencies": [
{
"//":" 依存モジュールの名前",
"name": "socketlib",
"//":" 依存するパッケージの種類(moduleが一般的)",
"type": "module"
}
],
"//":" モジュールで使用するJavaScriptファイル",
"scripts": ["scripts/main.js"],
"//":" モジュールで使用するCSSファイル",
"styles": ["styles/style.css"],
"//":" ソケット通信を有効化する設定",
"socket": true
}
特にそこまで話すこともないのですが、Socketとsocketlibあたりは解説しておこうかなと思います。
FoundryVTTでは他のクライアントとの同期はデータベース、特定のイベントフック、チャットメッセージによるフック等でのみ行われます。逆に言えば独自に作成したUIへのインタラクトやショートカットではクライアントに同期されません
そこで使用されるのがSocket通信になります。Socket通信を用いることで同期をリアルタイムに図ることができ、より高度なカスタムイベントを作成することが可能になります。
Socket通信を行うためにはmodule.json
で"socket": true
と明記する必要があり、忘れてしまうと一生動かなくなってしまいます。私は"socket":
の存在を知らなかったので、数時間関係ないコードを直し続けてました…。
"dependencies": [
{
"//":" 依存モジュールの名前",
"name": "socketlib",
"//":" 依存するパッケージの種類(moduleが一般的)",
"type": "module"
}
]
また、dependencies
にてsocketlib
を依存モジュールに設定していますが、これはmanuelVo氏のmoduleでsocket通信を簡単に行えるようにするライブラリです。ここでは詳しく解説しませんが、便利ですので依存モジュールに設定して損はないと思います。
詳しくはこちら
styles/style.css
見た目を整えるためのCSS。
#selected-actor-image {
position: fixed;
top: 100px;
left: 150px;
z-index: 1000;
}
#selected-actor-image img {
max-width: 100px;
max-height: 100px;
border-radius: 5px;
border: 2px solid white;
}
こちらも特に説明はいらないと思いますが、#selected-actor-image
にて画像の表示位置を#selected-actor-image img
にて画像の大きさや枠の設定をしています。
ここらへんを全てFoundryVTT側の設定にいれて自由に配置できるようにしたら結構面白そうだなと作ったあとに思いついたり。
scripts/main.js
メインのコード。今回はここだけで完結してる。
上からざっと解説していきます。
//初期化
Hooks.once("init", () => {
//モジュールの設定に登録
game.settings.register("actor-image-display", "shortcutPermission", {
name: "ショートカット利用可能な権限",
hint: "ショートカットを利用できる最小限のプレイヤー権限を設定します。",
scope: "world",
config: true,
type: Number,
default: 2, // Player
choices: {
1: "Observer",
2: "Player",
3: "Trusted Player",
4: "Assistant GM",
5: "Game Master"
}
});
//キーバインディングの登録
game.keybindings.register("actor-image-display", "openActorSelector", {
name: "アクター選択ダイアログを開く",
hint: "このショートカットキーを使用してアクター選択ダイアログを開きます。",
editable: [{ key: "F9" }], // デフォルトのキー
onDown: () => {
// ショートカットキーが押されたときの処理
const shortcutPermission = game.settings.get("actor-image-display", "shortcutPermission");
if (game.user.role >= shortcutPermission) {
showActorSelector();
return true;
}
return false;
},
reservedModifiers: [],
precedence: CONST.KEYBINDING_PRECEDENCE.NORMAL
});
});
イベントとHooks
Initイベントで実行しています。Hooks.onceはその名の通りイベントが発火したときに1回だけ実行されるものです。
イベントとHooksについて、よく使うものだけ以下に記載します。
- イベント
- "init", ()
- FoundryVTTの初期化プロセスの早い段階で発火。ゲームデータやインターフェースを利用できないため、基本的にはモジュール設定やキーバインディング設定などを登録したりする。
- "setup", ()
- initイベント後のゲームデータが利用可能になったタイミングで発火。インターフェースはまだ利用できない。
- "ready", ()
- FoundryVTTの初期化が完全に終了し、すべてのモジュールやユーザーインターフェースが利用可能になったタイミングで発火。代々の初期化処理はここで行うイメージ。
- "renderActorSheet", (app, html, data)
- アクターシートがレンダリングされたタイミングで発火。ActorSheetの部分をItemSheet等に置き換えることができる。シートのカスタマイズや表示内容の変更等に使える。
- "updateActor", (actor, data, options, userId)
- アクターデータが更新されたときに発火する。こちらもActor部分をItem等に置き換えることができる。データの変更に応じて特定の処理を実行したい場合に使用できるため、結構使う。
- "createActor", (actor, data, options, userId)
- アクターデータが作成されたときに発火する。こちらもActor部分をItem等に置き換えることができる。新規データの作成に応じて初期化処理や通知を行いたい場合に使用する。既存モジュールで例えるならToken Mold(NPCに形容詞を付けるモジュール)
- "deleteActor", (actor, data, options, userId)
- アクターデータが削除されたときに発火する。こちらもActor部分をItem等に置き換えることができる。データの削除に伴うクリーンアップ処理や通知を行いたい場合に使用する。
- "controlToken", (token, controlled)
- トークンが選択されたときに発火する。トークンの選択状態に応じてUIを更新したり、特定の機能を切り替えたりする。
- Hooksのメソッド
- Hooks.on(event, callback)
- 特定のイベントが発生するたびにコールバック関数を実行する。何度でも実行される。
- Hooks.once(event, callback)
- 特定のイベントが発生した最初の1回だけコールバック関数を実行する。
- Hooks.call(event, ...args)
- 指定したイベントを発火させ、すべてのリスナーを実行する。登録されたすべてのコールバック関数を順番に呼び出す。
- Hooks.callAll(event, ...args)
- Hooks.call と同様にイベントを発火させるが、エラーが発生しても残りのリスナーが実行され続ける。
- Hooks.off(event, callback)
- 指定したイベントとコールバック関数のリスナーを解除する。
- Hooks.offAll(event)
- 指定したイベントに登録された全てのリスナーを解除する。
game.settings.register
game.settings.register
では設定項目にactor-image-display
を追加するものです。
プルダウンメニューでPermission設定を行えるようにしています。ここでのPermissionはFoundryVTTが提供しているPermissionと比較するものです。
また、設定した値をshortcutPermission
に格納し、いつでも参照できるようにしています。
scopeとconfigだけ詳しく解説します。
scope
scopeは、その設定がどの範囲に適用されるかを指定します。ざっくりと以下の通り。
scope | 説明 |
---|---|
world | ワールド全体で共有される設定。ゲームマスターが設定を行い、全てのプレイヤーに反映されます。 |
client | 各プレイヤーのクライアントごとの設定。各プレイヤーが独自の設定を行うことができます。 |
user | 特定のユーザーに関連する設定。通常はユーザーアカウント単位で記録されますが、複雑な用途で使用します。 |
config
設定画面にこの項目を表示するかどうか。
game.keybindings.register
そのモジュールで使用するキー入力のバインディング設定をFoundryVTT側の設定画面に登録するためのメソッド。
editable: [{ key: "F9" }]
でデフォルトキーを設定し、onDown: () =>
で入力時の処理を記載する。
reservedModifiersとprecedenceについてだけ解説します。
reservedModifiers
reservedModifiersは、ショートカットキーを定義する際に修飾キー(Ctrl, Alt, Shift)を予約するかどうかを設定できます。
reservedModifiers:["Control"]
で editableが editable: [{ key: "F9" }]
ならCtrl
を押した状態でF9
を押すことで実行されます。
また、reservedModifiers:["Shift", "Alt"]
ならShift
とAlt
を同時押しで実行されるようになります。
precedence
precedenceは、同じショートカットキーが他のモジュールやシステムに登録されている場合の優先度を設定します。
- CONST.KEYBINDING_PRECEDENCE.NORMAL
- 通常の優先度。競合がある場合、他のModuleやFoundryVTTシステムの設定に上書きされる可能性があります。
- CONST.KEYBINDING_PRECEDENCE.HIGH
- 高い優先度。競合があった場合、このキーバインディングが優先されます。
余程重要でない限りはCONST.KEYBINDING_PRECEDENCE.NORMAL
に設定することを推奨します。
Hooks.once("socketlib.ready",()=>{
// socketlibにこのモジュールを登録
let displayImageSocket;
Hooks.once("socketlib.ready", () => {
displayImageSocket = socketlib.registerModule("actor-image-display");
displayImageSocket.register("updateActorImage", updateActorImage);
});
socketlibの準備ができたタイミングで発火するイベントです。
コールバック関数内でsocketlibに自身のモジュールとそこで使われる関数を登録しています。
登録した関数をSocketで実行できるので、関係する関数は全て登録したほうが良さそうです。
manuelVo氏のsocketlibドキュメント
Registers a function that can subsequently be called using socketlib. It's important that the registration of a function is done on all connected clients before the function is being called via socketlib. Otherwise the call won't succeed. For this reason it's recommended to register all relevant functions during the socketlib.ready hook, immediatly after socketlib.registerModule has been called.
Dialog
FoundryVTTの標準機能を活用してDialogを表示させる。
// アクター選択ダイアログを表示する関数
function showActorSelector() {
const actorList = game.actors.contents.map(actor => ({
id: actor.id,
name: actor.name,
img: actor.img
}));
actorList.push({ id: "none", name: "画像を表示しない", img: null });
// ダイアログの中身(HTML)
const dialogContent = `
<form>
<div class="form-group">
<label for="actor-list">アクターを選択してください:</label>
<select id="actor-list">
${actorList.map(actor => `<option value="${actor.id}">${actor.name}</option>`).join("")}
</select>
</div>
</form>
`;
// ダイアログを表示
new Dialog({
title: "アクター画像選択",
content: dialogContent,
buttons: {
ok: {
label: "決定",
callback: (html) => {
const selectedActorId = html.find("#actor-list").val();
const selectedActor = actorList.find(actor => actor.id === selectedActorId);
if (selectedActor) {
// socketlibを使用してすべてのクライアントに画像を更新するように指示
displayImageSocket.executeForEveryone("updateActorImage", selectedActor.img);
}
}
},
cancel: {
label: "キャンセル"
}
},
default: "ok"
}).render(true);
}
最後の部分です。ここはキー入力後に呼び出される関数で、const actorList = game.actors.contents.map(actor => ({
で現在のゲームに配置されているアクターの情報を持ってきています。また、最後に{ id: "none", name: "画像を表示しない", img: null }
を挿入して、表示しない選択肢を追加しています。
// ダイアログの中身(HTML)
const dialogContent = `
<form>
<div class="form-group">
<label for="actor-list">アクターを選択してください:</label>
<select id="actor-list">
${actorList.map(actor => `<option value="${actor.id}">${actor.name}</option>`).join("")}
</select>
</div>
</form>
`;
dialogContent
はnew Dialog({})
内のcontent:
の中身です。
ざっくり説明すると、ダイアログの見た目をHTMLで記載するみたいな感じ。
HTMLなので詳しくは説明しません。
// ダイアログを表示
new Dialog({
title: "アクター画像選択",
content: dialogContent,
buttons: {
ok: {
label: "決定",
callback: (html) => {
const selectedActorId = html.find("#actor-list").val();
const selectedActor = actorList.find(actor => actor.id === selectedActorId);
if (selectedActor) {
// socketlibを使用してすべてのクライアントに画像を更新するように指示
displayImageSocket.executeForEveryone("updateActorImage", selectedActor.img);
}
}
},
cancel: {
label: "キャンセル"
}
},
default: "ok"
}).render(true);
}
一通りダイアログを説明すると
- title
- タブに表示される文字
- content
- ダイアログの中身
- buttons
- 標準として実装されているボタンを配置する
といった感じです。まあそのまんまですね。
buttonsでokならコールバック関数を呼び出して、選んだアクターの画像を取り出してsocketlibの機能で他のクライアントに同期してます。
executeForEveryone("updateActorImage", selectedActor.img)
で接続する全てのクライアントに対してupdateActorImage
を実行してる感じです。
詳しくはこちらから。
.render(true)
は新しくレンダリングするためのメソッドです。.render(false)
は既存のアプリケーションを再利用して更新するのに使われます。デフォルトが.render(false)
なので、明記する必要があった訳です。
最後に
色々と書きましたが、気がつけば結構な文量に…。
初めて技術記事?みたいなものを書きましたが意外とそれっぽくなりましたね。
FoundryVTTについての感想ですが
最初から最後まで
情報がなさすぎる!
という感想です。
ほぼ独学というのもあって、情報を見つけるのがめちゃめちゃ大変でした…。特におすすめModule等はそれなりにありましたが、FoundryVTT関連の技術記事が日本語ではおそらくなかったんじゃないかなと。苦労した分どこかに情報を流したら、精力的にModuleが増えてくれないかなって企みが合ったりなかったり。
ただ、FoundryVTTを利用している方々が集まるディスコードサーバー等では毎日頻繁に情報交換等がされているので、そちらならもっと正確な情報が知れるのかな?
ともかく!自力で頑張って学んだことはこんな感じです!少しくらい間違ってたとしても寛容な心で見てほしい。