phaser-ui について
phaser-uiは、Phaser 4ゲームのUI開発向けスキルです。ゲームオブジェクトや独立したHUDSceneを使うHUD、操作要素、オーバーレイの実装例に加え、レスポンシブ配置や操作レイヤーの問題も扱います。
主な機能
- 01ゲームオブジェクトによるUI構築
- 02固定スクロール要素とHUDSceneの方式
- 03ヘルスバー、ボタン、ダイアログ、ミニマップの例
- 04DOM入力オーバーレイに対応
- 05レスポンシブ配置と操作深度の解説
活用シーン
- 01ゲーム画面にスコアや体力HUDを追加
- 02ボタン、ダイアログ、プログレスバーを実装
- 03オーバーレイによる操作妨害を調査
- 04ウィンドウサイズに応じるUIを構築
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ yakoub-ai/ phaser4-gamedev --skill phaser-uiインストール後、Claude Code で/phaser-uiを入力して読み込まれたか確認します。
同梱ファイル
SKILL.md と一緒にインストールされる参考資料やスクリプト。
references/hit-test-and-depth.mdpanel-rebuild-patterns.mdui-patterns.md詳細
Phaser 4 UI 開発
Phaser には組み込みの UI ツールキットがありません。Text、Graphics、Image、Container といったゲームオブジェクトから UI を構築するか、並行して動作する HUDScene を使用します。基本的なアプローチは次の 2 つです。
- スクロール固定オブジェクト: ゲームオブジェクトをシーンに追加し、
.setScrollFactor(0)を呼び出して、カメラが移動しても固定されるようにします。 - 並行 HUDScene: ゲームシーンと並行して別のシーンを起動します。そのシーンのカメラは決して移動しないため、その中のすべては本質的に固定されます。複雑な UI にはこれを使用します。
UI 要素では常に .setDepth(100)(またはそれ以上)を呼び出して、ゲームオブジェクトより上に描画されるようにしてください。
ヘルスバー(Graphics ベース)
class HealthBar {
private bar: Phaser.GameObjects.Graphics;
private x: number;
private y: number;
private width = 200;
private height = 20;
private maxHealth: number;
private health: number;
constructor(scene: Phaser.Scene, x: number, y: number, maxHealth: number) {
this.x = x;
this.y = y;
this.maxHealth = this.health = maxHealth;
this.bar = scene.add.graphics().setScrollFactor(0).setDepth(100);
this.draw();
}
setHealth(value: number): void {
this.health = Phaser.Math.Clamp(value, 0, this.maxHealth);
this.draw();
}
private draw(): void {
this.bar.clear();
// Background border
this.bar.fillStyle(0x000000, 0.6);
this.bar.fillRect(this.x - 2, this.y - 2, this.width + 4, this.height + 4);
// Colored fill — green above 50%, yellow above 25%, red below
const ratio = this.health / this.maxHealth;
const color = ratio > 0.5 ? 0x00ff00 : ratio > 0.25 ? 0xffff00 : 0xff0000;
this.bar.fillStyle(color, 1);
this.bar.fillRect(this.x, this.y, this.width * ratio, this.height);
}
destroy(): void {
this.bar.destroy();
}
}
プレイヤーがダメージを受けるか回復するたびに setHealth(newValue) を使用します。
スコアとテキストの表示
// In create():
this.scoreText = this.add.text(16, 16, 'Score: 0', {
fontSize: '24px',
color: '#ffffff',
stroke: '#000000',
strokeThickness: 3,
}).setScrollFactor(0).setDepth(100);
// When score changes:
this.scoreText.setText(`Score: ${score}`);
レスポンシブなレイアウトにするため、すべての HUD テキストを this.scale.width / this.scale.height を基準に配置します(以下のレスポンシブ UI を参照)。
インタラクティブなボタン
方法 1 — テキストをボタンとして使う
const btn = this.add.text(400, 300, 'PLAY', {
fontSize: '32px',
backgroundColor: '#4a4a8a',
padding: { x: 20, y: 10 },
})
.setOrigin(0.5)
.setInteractive({ useHandCursor: true })
.on('pointerover', () => btn.setStyle({ color: '#ffff00' }))
.on('pointerout', () => btn.setStyle({ color: '#ffffff' }))
.on('pointerdown', () => btn.setScale(0.95))
.on('pointerup', () => {
btn.setScale(1);
this.scene.start('GameScene');
});
方法 2 — テクスチャフレームの状態を持つ画像ボタン
const playBtn = this.add.image(400, 300, 'ui', 'btn-play-normal.png')
.setInteractive({ useHandCursor: true });
playBtn.on('pointerover', () => playBtn.setFrame('btn-play-hover.png'));
playBtn.on('pointerout', () => playBtn.setFrame('btn-play-normal.png'));
playBtn.on('pointerdown', () => playBtn.setFrame('btn-play-pressed.png'));
playBtn.on('pointerup', () => {
playBtn.setFrame('btn-play-normal.png');
this.scene.start('GameScene');
});
ダイアログボックス
class DialogBox extends Phaser.GameObjects.Container {
constructor(scene: Phaser.Scene, text: string, onClose: () => void) {
super(scene, scene.scale.width / 2, scene.scale.height / 2);
scene.add.existing(this);
this.setDepth(200).setScrollFactor(0);
const bg = scene.add.graphics();
bg.fillStyle(0x222244, 0.95);
bg.fillRoundedRect(-200, -80, 400, 160, 16);
const label = scene.add.text(0, -30, text, {
fontSize: '20px',
color: '#ffffff',
wordWrap: { width: 360 },
align: 'center',
}).setOrigin(0.5);
const closeBtn = scene.add.text(0, 50, 'OK', {
fontSize: '20px',
backgroundColor: '#4488aa',
padding: { x: 20, y: 8 },
})
.setOrigin(0.5)
.setInteractive({ useHandCursor: true })
.on('pointerup', () => {
this.destroy();
onClose();
});
this.add([bg, label, closeBtn]);
}
}
// Usage:
new DialogBox(this, 'You found a treasure chest!', () => {
// Resume game logic after dialog closes
});
スケールを 0 から 1 にトゥイーンして表示をアニメーション化します。アニメーション版については references/ui-patterns.md を参照してください。
ミニマップ(Graphics ベースのドットマップ)
カメラベースのミニマップには WebGL とレンダーテクスチャが必要です。ほとんどのゲームでは、Graphics オブジェクト上のドットマップの方がシンプルで、Canvas と WebGL の両方で動作します:
class MiniMap {
private gfx: Phaser.GameObjects.Graphics;
private mapX = 10;
private mapY = 10;
private mapW = 150;
private mapH = 100;
private worldW: number;
private worldH: number;
constructor(scene: Phaser.Scene, worldW: number, worldH: number) {
this.worldW = worldW;
this.worldH = worldH;
this.gfx = scene.add.graphics().setScrollFactor(0).setDepth(100);
}
update(entities: Array<{ x: number; y: number; color: number }>): void {
this.gfx.clear();
// Background
this.gfx.fillStyle(0x000000, 0.5);
this.gfx.fillRect(this.mapX, this.mapY, this.mapW, this.mapH);
// Border
this.gfx.lineStyle(2, 0xffffff, 0.8);
this.gfx.strokeRect(this.mapX, this.mapY, this.mapW, this.mapH);
// Entities as colored dots
for (const e of entities) {
const mx = this.mapX + (e.x / this.worldW) * this.mapW;
const my = this.mapY + (e.y / this.worldH) * this.mapH;
this.gfx.fillStyle(e.color, 1);
this.gfx.fillCircle(mx, my, 2);
}
}
destroy(): void {
this.gfx.destroy();
}
}
// In GameScene.update():
this.miniMap.update([
{ x: this.player.x, y: this.player.y, color: 0x00ff00 },
...this.enemies.getChildren().map(e => ({ x: (e as Enemy).x, y: (e as Enemy).y, color: 0xff0000 })),
]);
汎用プログレスバー
ヘルス、XP、ロード進捗、スタミナなど、あらゆる比率の指標に再利用できます:
interface ProgressBarConfig {
x: number;
y: number;
width?: number;
height?: number;
fillColor?: number;
bgColor?: number;
depth?: number;
}
class ProgressBar {
private bar: Phaser.GameObjects.Graphics;
private cfg: Required<ProgressBarConfig>;
constructor(scene: Phaser.Scene, config: ProgressBarConfig) {
this.cfg = {
width: 200, height: 16,
fillColor: 0x00aaff,
bgColor: 0x333333,
depth: 100,
...config,
};
this.bar = scene.add.graphics().setScrollFactor(0).setDepth(this.cfg.depth);
this.setValue(1);
}
setValue(ratio: number): void {
const { x, y, width, height, fillColor, bgColor } = this.cfg;
const clamped = Phaser.Math.Clamp(ratio, 0, 1);
this.bar.clear();
this.bar.fillStyle(bgColor, 0.8);
this.bar.fillRect(x, y, width, height);
this.bar.fillStyle(fillColor, 1);
this.bar.fillRect(x, y, width * clamped, height);
}
destroy(): void {
this.bar.destroy();
}
}
// Examples:
const xpBar = new ProgressBar(scene, { x: 16, y: 50, fillColor: 0xaa00ff });
const loadBar = new ProgressBar(scene, { x: 160, y: 300, width: 320, height: 24, fillColor: 0x00ff88 });
xpBar.setValue(0.65); // 65% XP
loadBar.setValue(value); // from load.on('progress', ...)
パフォーマンスのための BitmapText
Phaser.GameObjects.Text は setText を呼び出すたびにキャンバスへ再描画します。BitmapText はテクスチャアトラス上の UV 座標を差し替えるだけなので、毎フレーム更新される値(スコア、フレームカウンター、ダメージ数値)でははるかに低コストです。
// Preload the font (in PreloaderScene):
this.load.bitmapFont('arcade', 'assets/fonts/arcade.png', 'assets/fonts/arcade.xml');
// Create:
const scoreText = this.add.bitmapText(16, 16, 'arcade', 'Score: 0', 32)
.setScrollFactor(0)
.setDepth(100);
// Update every frame with zero canvas overhead:
scoreText.setText(`Score: ${this.score}`);
BitmapText を使う場面: スコア、タイマー、浮かび上がるダメージ数値、コンボカウンター。Text を使う場面: 長い段落、ダイアログテキスト、システムフォントやカスタム CSS スタイルを使うもの。
this.add.dom() による DOM オーバーレイ
設定フォームやチャット入力など、ネイティブの HTML 入力要素が必要な場合:
// Preload (PreloaderScene or scene preload):
this.load.html('settings-form', 'assets/html/settings.html');
// In create():
const domEl = this.add.dom(400, 300).createFromCache('settings-form');
domEl.addListener('click');
domEl.on('click', (event: Event) => {
const target = event.target as HTMLElement;
if (target.id === 'submit-btn') {
const input = domEl.getChildByName('username') as HTMLInputElement;
console.log('Username:', input.value);
domEl.destroy();
}
});
ゲーム設定で DOM を有効にします:
const config: Phaser.Types.Core.GameConfig = {
// ...
dom: { createContainer: true },
};
注意点: DOM 要素はモバイルや一部のブラウザで z-index の扱いが複雑になります。ネイティブの入力要素を必要としない UI には、Phaser のゲームオブジェクトを優先してください。
Scale Manager によるレスポンシブ UI
キャンバスと HUD の 2 層にわたるレスポンシブサイジングの完全なルール(iOS PWA の横向きバグを回避する
100dvh+ ライブカメラのパターンや、モジュールレベルのGAME_WIDTH定数の固定化を含む)については、skills/phaser-scene/references/scene-patterns.md→ Responsive Sizing: Two Layers を参照してください。その内容を重複して記載しないでください。それが正規の説明です。
HUD 要素のピクセル位置をハードコードしないでください。this.scale.width / this.scale.height を使用し、リサイズをリッスンします:
create(): void {
const { width, height } = this.scale;
this.scoreText = this.add.text(width * 0.02, height * 0.02, 'Score: 0', {
fontSize: `${Math.round(height * 0.04)}px`,
color: '#ffffff',
}).setScrollFactor(0).setDepth(100);
// Re-anchor on resize (e.g. when browser window resizes)
this.scale.on('resize', (gameSize: Phaser.Structs.Size) => {
this.scoreText.setPosition(gameSize.width * 0.02, gameSize.height * 0.02);
});
}
2 つの独立したサイジング層
Phaser の UI には、混在させてはならない 2 つの別個のサイジング上の関心事があります:
- キャンバスのスケール —
ScaleManager(this.scale.width/height)が処理します。ゲームキャンバスがブラウザウィンドウにどのようにマッピングされるかを制御します。 - HUD / オーバーレイの位置 — 作成時に
this.cameras.main.width/heightから取得する必要があります(そしてリサイズ時に更新します)。HUD の位置にモジュールレベルの定数(const GAME_W = 800)を決して使用しないでください。これらは起動時の値で固定され、キャンバスのリサイズに適応しません。
// BAD — module-level constant freezes at boot dimensions:
const OVERLAY_W = 1280;
backdrop.setSize(OVERLAY_W, OVERLAY_H);
// CORRECT — read from live camera:
const { width, height } = this.cameras.main;
backdrop.setSize(width, height);
HUDScene が並行オーバーレイである場合、そのカメラは GameScene のカメラから独立しています。HUDScene 内で this.cameras.main から読み取る HUD の位置は、常に HUD カメラの現在のビューポートを反映します。ウィンドウのリサイズ後でも正しく動作します。
並行シーンとしての HUD(複雑な UI に推奨)
スコアテキストとヘルスバー以上のものが必要な場合は、すべての UI を専用の HUDScene に移動させます。利点: 明確な分離、スクロールファクターの調整が不要、UI に影響を与えずにゲームシーンを一時停止/再開しやすい。
// In GameScene.create():
this.scene.launch('HUDScene');
// Communicate via events:
this.events.emit('healthChanged', this.player.health);
this.events.emit('scoreChanged', this.score);
export class HUDScene extends Phaser.Scene {
private healthBar!: HealthBar;
private scoreText!: Phaser.GameObjects.Text;
constructor() { super({ key: 'HUDScene' }); }
create(): void {
this.healthBar = new HealthBar(this, 16, 16, 100);
this.scoreText = this.add.text(16, 50, 'Score: 0', {
fontSize: '20px', color: '#ffffff',
}).setDepth(100);
const game = this.scene.get('GameScene');
game.events.on('healthChanged', (hp: number) => this.healthBar.setHealth(hp), this);
game.events.on('scoreChanged', (s: number) => this.scoreText.setText(`Score: ${s}`), this);
// Clean up listeners when GameScene shuts down
game.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
game.events.off('healthChanged');
game.events.off('scoreChanged');
}, this);
}
}
HUDScene の起動パターンの全体像とシーン間通信の選択肢については、phaser-scene スキルを参照してください。
ドラッグオーバーレイによるイベント吸収
スワイプ検出やドラッグで閉じるために使用される全画面の不可視ゾーンは、ボタンより高い深度に配置されていると、すべてのポインターイベントを黙って吸収してしまいます。Phaser のデフォルトの topOnly ヒットテストは、その地点で最も深度の高いインタラクティブオブジェクトにのみイベントをルーティングします。
診断: this.input.enableDebug(overlayZone) — デバッグ用の輪郭がビューポート全体を覆い、その深度がボタンの深度より大きい場合、これが原因です。
修正方法(優先順位順):
overlayZone.setDepth(-9999)— ゾーンをすべてのインタラクティブオブジェクトの背面に配置します。- インタラクティブなパネルが開いている間はオーバーレイゾーンを破棄し、閉じたときに再作成します。
- このシーンに限り
this.input.setTopOnly(false)を設定します — 重なり合うすべてのインタラクティブオブジェクトがイベントを受け取れるようになります(他のシーンでの二重発火に注意してください)。
深度と topOnly のセマンティクスの詳細については、references/hit-test-and-depth.md を参照してください。
追加リソース
リファレンスファイル
references/ui-patterns.md— 実運用可能なコンポーネントクラス: アニメーション付き HealthBar、Button クラス、アニメーション付き DialogBox、FloatingText、Panel、InventoryGridreferences/hit-test-and-depth.md— Phaser のtopOnlyヒットテストのセマンティクス、不可視ゾーンの深度パターン、ドラッグオーバーレイによるクリック吸収の診断チェックリスト。ボタンやインタラクティブな子要素が黙って反応しなくなったときに読んでください。references/panel-rebuild-patterns.md— ちらつきなしでパネルのコンテンツをその場で再構築する方法(タブ切り替え、購入時)、初回訪問時のタイプライターダイアログのスキップパターン、クロームを保持するcontainer.list.slice(base)イディオム。コンテンツ変更時にパネルが閉じてから再び開くようにちらつくときに読んでください。