どうもです、タドスケです。
前回の記事で、Phaser と Three.js を併用する実験をしましたが、以下の課題がありました。
- Phaser 関連の処理をクラスにまとめたい
- Phaser で UI を表示するには?
- Phaser 内部で Three.js の要素を直接操作しているのが良くない
ChatGPT に聞きながら色々試したところ、これらの解決策がわかりました!
完成品
- 画面上のボタンをクリックすると、キューブが移動します
- ボタンは Phaser で実装していて、Three.js に重ねて描画しています
コード
コードは以下です。
(ChatGPT に生成させたものを修正しています)
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Three.js and Phaser Demo</title>
<style>
body { margin: 0; background-color: black; }
#phaser-game { position: absolute; top: 0; left: 0; }
</style>
</head>
<body>
<div id="phaser-game"></div>
<script src="https://cdn.jsdelivr.net/npm/three@0.126.1/build/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/phaser@3.55.2/dist/phaser.min.js"></script>
<script src="with_phaser_ui.js"></script>
</body>
</html>
// Three.js でキューブを表示するクラス
class ThreeJSCube {
// コンストラクタ
constructor() {
this.scene = new THREE.Scene();
this.camera = null;
this.renderer = null;
this.cube = null;
this.initialize();
this.setupEventListeners();
}
// 初期化
initialize() {
// シーンの設定
this.scene.background = new THREE.Color(0x000000); // 背景色を黒に設定
// カメラの設定
this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
this.camera.position.z = 5;
// レンダラーの設定
this.renderer = new THREE.WebGLRenderer({ alpha: true });
this.renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(this.renderer.domElement);
// キューブの作成
this.createCube();
// 照明の追加
this.addLights();
// アニメーションの開始
this.animate();
}
// キューブの生成
createCube() {
const geometry = new THREE.BoxGeometry();
const material = new THREE.MeshStandardMaterial({ color: 0x00ff00 });
this.cube = new THREE.Mesh(geometry, material);
this.scene.add(this.cube);
}
// ライトの追加
addLights() {
// 環境光源を追加
const ambientLight = new THREE.AmbientLight(0xffffff, 0.3);
this.scene.add(ambientLight);
// 平行光源を追加
const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
directionalLight.position.set(1, 2, 1);
this.scene.add(directionalLight);
}
// 更新
animate() {
requestAnimationFrame(this.animate.bind(this));
this.renderer.render(this.scene, this.camera);
}
// イベントリスナーの設定
setupEventListeners() {
// キューブの移動イベント
document.addEventListener('cubeMove', (e) => {
const { direction, value } = e.detail;
if (direction === 'y') {
this.cube.position.y += value;
} else if (direction === 'x') {
this.cube.position.x += value;
}
});
}
}
// Phaser で UI を表示するクラス
class PhaserUI {
// ボタンのスタイル
static BUTTON_STYLE = {
fill: '#fff',
backgroundColor: '#666',
padding: 10,
borderColor: '#fff',
borderThickness: 2,
fontFamily: 'Arial',
fontSize: '20px'
};
// ボタンが押されているときのスタイル
static BUTTON_PRESSED_STYLE = { ...PhaserUI.BUTTON_STYLE, backgroundColor: '#999' };
// キューブの移動速度
static MOVE_SPEED = 0.5
// コンストラクタ
constructor(cube) {
this.game = null;
this.initialize();
}
// 初期化
initialize() {
const config = {
type: Phaser.AUTO,
width: 400,
height: 400,
parent: 'phaser-game',
transparent: true,
scene: {
// create() 内の this をこのクラスのインスタンスにする
create: this.create.bind(this)
}
};
this.game = new Phaser.Game(config);
}
// Phaser から呼ばれる初期処理
create() {
this.createButton(100, 100, '上',
() => this.emitCubeMoveEvent('y', PhaserUI.MOVE_SPEED));
this.createButton(100, 150, '下',
() => this.emitCubeMoveEvent('y', -PhaserUI.MOVE_SPEED));
this.createButton(50, 125, '左',
() => this.emitCubeMoveEvent('x', -PhaserUI.MOVE_SPEED));
this.createButton(150, 125, '右',
() => this.emitCubeMoveEvent('x', PhaserUI.MOVE_SPEED));
}
// ボタンを生成する
createButton(x, y, text, action) {
const scene = this.game.scene.scenes[0];
const button = scene.add.text(x, y, text, PhaserUI.BUTTON_STYLE).setInteractive();
button.on('pointerdown', function () {
this.setStyle(PhaserUI.BUTTON_PRESSED_STYLE);
action();
});
button.on('pointerup', function () {
this.setStyle(PhaserUI.BUTTON_STYLE);
});
button.on('pointerout', function () {
this.setStyle(PhaserUI.BUTTON_STYLE);
});
return button;
}
// キューブの移動イベントを発行する
emitCubeMoveEvent(direction, value) {
const event = new CustomEvent('cubeMove', { detail: { direction, value } });
document.dispatchEvent(event);
}
}
// 初期化
const threeJSScene = new ThreeJSCube();
new PhaserUI();
実装のポイント
Phaser 処理のクラス化
前回はうまくいかなかった Phaser 関連処理のクラス化について、以下の部分がキモでした。
...
scene: {
// create() 内の this をこのクラスのインスタンスにする
create: this.create.bind(this)
}
...
.bind(this) を指定しない場合、呼び出される create() 内での this は PhaserUI クラスのインスタンスではなくなってしまうため、create() 内で this.game.~ というようなメンバーアクセスができなくなってしまいます。
UI 表示(=Three.js に Phaser の描画処理を重ねる)
Three.js に Phaser の描画処理を重ねるには、以下の部分がポイントになります。
...
<style>
...
#phaser-game { position: absolute; top: 0; left: 0; }
</style>
...
<body>
<div id="phaser-game"></div>
...
...
const config = {
...
parent: 'phaser-game',
transparent: true,
...
};
...
html 側で Phaser を描画する場所を div で指定します。
div の style 設定で、座標を (0, 0) に設定します。
これは、Three.js の描画開始位置が (0, 0) なのに合わせています。
js 側で、Phaser の初期化時の config で parent と transparent を指定します。
transparent を指定しないと、Phaser の背景(黒色)がキューブに重なってしまいます。
Phaser 内部で直接 Three.js の要素を操作しない
Phaser からはボタンが押されたイベントの発行だけを行い、Three.js 側でイベントを検知してキューブを操作します。
...
// イベントリスナーの設定
setupEventListeners() {
// キューブの移動イベント
document.addEventListener('cubeMove', (e) => {
const { direction, value } = e.detail;
if (direction === 'y') {
this.cube.position.y += value;
} else if (direction === 'x') {
this.cube.position.x += value;
}
});
}
...
// キューブの移動イベントを発行する
emitCubeMoveEvent(direction, value) {
const event = new CustomEvent('cubeMove', { detail: { direction, value } });
document.dispatchEvent(event);
}
...
これで Phaser と Three.js は相互に依存しなくなります。
つまり、Phaser はボタンイベントを誰がどう扱うかを気にする必要はなくなり、Three.js はボタンイベントを誰が発行しているかを気にする必要がなくなります。
極端な話、イベント処理さえ実装しているのであれば、3D 描画や UI を後から別のライブラリに置き換えることもできます。
今後の予定
今回の検証で、Phaser と Three.js の連携ができることがわかったので、これでちょっとした 3D ゲームも作れそうです。
何か簡単に作れそうなサンプルのネタが思いついたら、またやってみます。
コメント