📚
phaserでゲーム開発 第八回
phaserでゲーム開発シリーズ、第八回。
前回の記事はこちら。
今回は コードを役割ごとに整理し、保守性を高めるためのリファクタリング手順を解説します。
ここまでは一つのファイルに全ての処理をまとめていましたが、一定の規模を超え、管理が難しくなってきたため、責務ごとに分割していきたいと思います。
1. リファクタリング前の状態と課題
まず、GameScene.js 1 ファイルに以下の処理をすべて書いていました。
- プレイヤー生成・入力処理
- 敵の生成・行動制御
- 背景や足場の生成
- 攻撃やエフェクト処理
- アニメーション定義
このように 1
ファイルが肥大化して責務が分散すると、コードの見通しが悪くなり、修正時の影響範囲も把握しづらくなります。
2. ディレクトリ構成の改善
以下のように、責務ごとにフォルダを分割します。
src/
scenes/
GameScene.js // シーン全体の管理
entities/
objects/
Player.js // プレイヤークラス
Enemy.js // 敵クラス
effects/
SlashEffect.js // 斬撃エフェクト
ExploadEffect.js // 爆発エフェクト
stage/
Background.js // 背景生成
Platforms.js // 足場生成
utils/
animations.js // アニメーション定義用ヘルパー
改良の狙い
-
entities ...
ゲーム内に登場するキャラクターやエフェクトをまとめる\ - stage ... 背景や足場など環境要素をまとめる\
-
utils ...
共通的な処理(アニメーション登録など)を切り出すことで再利用性を高める
3. クラスごとの責務分離
Player.js
- 入力処理(カーソルキー・攻撃キー)\
- プレイヤーキャラの生成と衝突判定\
- 攻撃処理(斬撃エフェクト生成)\
-
update()メソッドでの移動・アニメーション制御
Player.js
export class Player {
constructor(scene) {
this.scene = scene;
this.sprite = null;
this.cursors = null;
this.attackKey = null;
/**
* プレイヤーが最後に向いていた方向を記録する変数
* 停止時やジャンプ中など、次の見た目を決めるために利用する
*/
this.lastFacing = "right";
/**
* プレイヤーが「止まっているとき」に表示する静止フレームを定義
* 各方向の歩行アニメーションの中央フレームを採用することで、自然に見える
*/
this.idleFrame = {
down: 1,
left: 4,
right: 7,
up: 10,
};
}
static preload(scene) {
// 主人公画像をロード
scene.load.spritesheet("player", "img/character/pipo-charachip001.png", {
frameWidth: 32,
frameHeight: 32,
});
}
/**
* プレイヤーキャラクターを生成し、物理演算や入力処理を設定する
*
* - 矢印キー入力を取得
* - プレイヤースプライトを物理演算付きで生成
* - 当たり判定サイズを調整(足元のみ反応するように設定)
* - タイルマップとの衝突処理を登録
*
*/
create() {
console.log("▼▼▼ [Player][create] ▼▼▼");
const scene = this.scene;
this.cursors = scene.input.keyboard.createCursorKeys();
this.attackKey = scene.input.keyboard.addKey(
Phaser.Input.Keyboard.KeyCodes.SPACE
);
/** プレイヤースプライトを生成(初期位置は X:40, Y:400) */
this.sprite = scene.physics.add
.sprite(40, 555, "player", 0)
.setCollideWorldBounds(true); // 画面外に出ないよう制約
// 当たり判定サイズを調整(キャラ画像32x32に対して足元16x16だけ反応させる)
this.sprite.body.setSize(16, 16).setOffset(8, 16);
// プレイヤーと足場レイヤーの衝突を有効化
scene.physics.add.collider(this.sprite, scene.layer);
console.log("▲▲▲ [Player][create] ▲▲▲");
}
/**
* 攻撃モーションを実行するメソッド
*
* プレイヤーの最後に向いていた方向(右または左)を参照して、
* - エフェクトを出す位置(右側 or 左側)
* - エフェクトの角度
* - 画像の反転(FlipX)
* を切り替える。
*
* これにより、右向きの場合は右振り下ろし、左向きの場合は左振り下ろしの
* 自然なアニメーションが表現できる。
*
*/
attack() {
console.log("▼▼▼ [Player][attack] ▼▼▼");
// エフェクトの位置オフセット(X座標用)
let offsetX;
// エフェクトの角度(斬撃の向き)
let angle;
// エフェクトの水平反転フラグ
let flipX;
// プレイヤーが右向きだった場合
// - エフェクトをプレイヤーの右上に表示する
// - 右方向に自然に見えるように角度を設定する
// - エフェクトの画像を反転して利用する
if (this.lastFacing === "right") {
offsetX = 20;
angle = 0;
flipX = true;
}
// プレイヤーが左向きだった場合
// - エフェクトをプレイヤーの左上に表示する
// - 左方向に自然に見えるように角度を設定する
// - エフェクトの画像は反転せずそのまま使う
if (this.lastFacing === "left") {
offsetX = -20;
angle = 0;
flipX = false;
}
// プレイヤーの位置を基準に、指定したオフセット位置に斬撃エフェクトを生成する
const slash = this.scene.physics.add.sprite(
this.sprite.x + offsetX,
this.sprite.y - 20,
"slash"
);
// 斬撃は重力の影響を受けない
slash.body.setAllowGravity(false);
// 当たり判定サイズ(必要に応じて調整)
slash.body.setSize(80, 80).setOffset(20, 20);
slash.setOrigin(0.5, 0.5);
slash.setScale(0.5);
slash.setAngle(angle);
slash.setFlipX(flipX);
// エフェクトの中心位置(回転の支点)を調整
slash.setOrigin(0.5, 0.5);
// エフェクトのサイズを調整(0.5倍)
slash.setScale(0.5);
// エフェクトの角度を設定(右向き・左向きに応じた値)
slash.setAngle(angle);
// エフェクトの水平反転を設定(右向き時は反転、左向き時は通常)
slash.setFlipX(flipX);
// 斬撃アニメーションを再生
slash.anims.play("slash-effect", true);
// 敵グループと斬撃の当たり判定(当たったら敵を消す)
const hitCallback = (s, enemy) => {
console.log("▼▼▼ [GameScene].js][attack][hitCallback] ▼▼▼");
// 敵が既に破棄済みか確認してから destroy
if (enemy && enemy.active) {
// 敵消滅(必要なら被弾エフェクトをここで再生)
// 敵の位置を基準に、指定したオフセット位置に爆発エフェクトを生成する
const expload = this.scene.physics.add.sprite(
enemy.x,
enemy.y,
"expload"
);
expload.anims.play("expload-effect", true);
enemy.destroy();
// アニメーションが終了したらエフェクトを削除してリソースを解放する
expload.on(Phaser.Animations.Events.ANIMATION_COMPLETE, () => {
expload.destroy();
});
}
console.log("▲▲▲ [GameScene].js][attack][hitCallback] ▲▲▲");
};
// overlap を一時的に作る(斬撃1回につき1回だけ判定させたいなら、このままでOK)
this.scene.physics.add.overlap(
slash,
this.scene.enemys,
hitCallback,
null,
this
);
// アニメーションが終了したらエフェクトを削除してリソースを解放する
slash.on(Phaser.Animations.Events.ANIMATION_COMPLETE, () => {
slash.destroy();
});
console.log("▲▲▲ [Player][attack] ▲▲▲");
}
/**
*
*/
update() {
if (!this.cursors) {
return; // まだ初期化されていない
}
// === 接地判定 ===
// プレイヤーが地面や他のオブジェクトに接しているかどうかを確認
const onFloor =
this.sprite.body.blocked.down || this.sprite.body.touching.down;
// 左右移動
if (this.cursors.left.isDown) {
this.sprite.setVelocityX(-200);
if (onFloor) {
this.sprite.anims.play("player-walk-left", true);
}
this.lastFacing = "left";
} else if (this.cursors.right.isDown) {
this.sprite.setVelocityX(200);
if (onFloor) {
this.sprite.anims.play("player-walk-right", true);
}
this.lastFacing = "right";
} else {
this.sprite.setVelocityX(0);
if (onFloor) {
this.sprite.anims.stop();
}
}
// ジャンプ
if (this.cursors.up.isDown && onFloor) {
this.sprite.setVelocityY(-300);
}
// === 空中の見た目 ===
// 宙に浮いている間は歩行アニメーションを止め、最後に向いていた方向の静止フレームを表示
if (!onFloor) {
this.sprite.anims.stop();
const idle = this.idleFrame[this.lastFacing] ?? this.idleFrame.right;
this.sprite.setFrame(idle);
}
// === 攻撃 ===
if (Phaser.Input.Keyboard.JustDown(this.attackKey)) {
this.attack();
return;
}
}
}
Enemy.js
- 敵キャラの生成と初期化\
- 当たり判定や物理設定\
-
update()メソッドでランダム移動を実装
export class Enemy {
constructor(scene, sprite) {
this.scene = scene;
this.sprite = sprite;
}
static preload(scene) {
// 敵キャラ画像をロード
scene.load.spritesheet("enemy", "img/character/pipo-charachip019b.png", {
frameWidth: 32,
frameHeight: 32,
});
}
initialize() {
// 敵の当たり判定サイズを調整(中心の30x30)
this.sprite.body.setSize(30, 30).setOffset(1, 1);
// 物理特性の設定
this.sprite.setCollideWorldBounds(true);
this.sprite.setBounce(0.2);
this.sprite.setVelocity(Phaser.Math.Between(-100, 100), 20);
// 敵キャラと足場レイヤーの衝突を有効化
this.scene.physics.add.collider(this.sprite, this.scene.layer);
// 敵キャラとプレイヤーの衝突を有効化
this.scene.physics.add.collider(this.sprite, this.scene.player);
}
/**
* 毎フレームの更新処理
*/
update() {
if (!this.sprite) return;
// 2秒に1回くらい方向転換
if (Phaser.Math.Between(0, 100) < 2) {
const speed = 100;
const dir = Phaser.Math.Between(0, 1);
switch (dir) {
case 0:
this.sprite.setVelocity(speed, 0);
this.sprite.anims.play("enemy-walk-right", true);
break;
case 1:
this.sprite.setVelocity(-speed, 0);
this.sprite.anims.play("enemy-walk-left", true);
break;
}
}
}
}
Background.js
- 背景画像のロードと表示
export class Background {
/**
*
* @param {import("../../Scene/GameScene.js").GameScene} scene
*/
static preload(scene) {
// 背景画像をロード
scene.load.image("sky", "img/bg7.jpg");
}
/**
* 背景を作成してゲーム全体に配置する
*
* @param {import("../../Scene/GameScene.js").GameScene} scene
*/
create(scene) {
console.log("▼▼▼ [GameScene].js][createBackground] ▼▼▼");
const bg = scene.add.image(0, 0, "sky").setOrigin(0, 0);
bg.setDisplaySize(
scene.sys.game.config.width,
scene.sys.game.config.height
);
console.log("▲▲▲ [GameScene].js][createBackground] ▲▲▲");
}
}
Platforms.js
- 足場をタイルマップ形式で生成\
- 衝突判定をシーンに登録
Platforms.js
export class Platforms {
/**
*
* @param {import("../../Scene/GameScene.js").GameScene} scene
*/
static preload(scene) {
// 足場画像をロード
scene.load.spritesheet("ground", "img/map_chip/base.png", {
frameWidth: 16,
frameHeight: 16,
});
}
/**
* 足場(タイルマップ)を作成して衝突判定を設定する
*
* @param {import("../../Scene/GameScene.js").GameScene} scene
*/
create(scene) {
console.log("▼▼▼ [Platforms][create] ▼▼▼");
const mapData = [];
// 1~36行目までは空白
for (let y = 0; y < 36; y++) {
mapData[y] = new Array(50).fill(-1);
}
// 足場ブロックを配置
mapData[2].fill(368, 0, 50);
mapData[15].fill(368, 10, 30);
mapData[22].fill(368, 20, 30);
mapData[22].fill(368, 40, 70);
mapData[30].fill(368, 20, 50);
// 最下段(36行目)は全面にブロックを敷く
mapData[36] = new Array(50).fill(368);
// タイルマップを生成
const map = scene.make.tilemap({
data: mapData,
tileWidth: 16,
tileHeight: 16,
});
const tiles = map.addTilesetImage("ground");
scene.layer = map.createLayer(0, tiles, 0, 0);
// -1 以外を衝突対象に設定
scene.layer.setCollisionByExclusion([-1]);
console.log("▲▲▲ [Platforms][create] ▲▲▲");
}
}
animations.js
- 斬撃・爆発などのアニメーションをまとめて登録する static
メソッドを定義
Animation.js
export class Animations {
/**
* アニメーションをまとめて登録する。
* @param {*} scene
*/
static registerAnimations(scene) {
this.#registerSlashEffectAnimation(scene);
this.#registerExploadEffectAnimation(scene);
this.#registerEnemyAnimation(scene);
this.#playerAnimation(scene);
}
/**
* 斬撃エフェクト用のアニメーションを事前に作成するメソッド
*
* preload() でロードしたスプライトシート(slash)を基に、
* 「slash-effect」という名前のアニメーションを登録する。
*
* - 使用するフレームは 0 〜 4 の合計 5枚。
* - フレームレートは 24(1秒間に24コマの速度で再生)。
* - repeat を 0 に設定しているため、1回だけ再生して停止する。
*
* このメソッドを create() 内で呼び出しておくことで、
* 攻撃時に slash.anims.play('slash-effect') を実行できるようになる。
*/
static #registerSlashEffectAnimation(scene) {
scene.anims.create({
key: "slash-effect",
frames: scene.anims.generateFrameNumbers("slash", { start: 0, end: 4 }),
frameRate: 24,
repeat: 0,
});
}
/**
* 爆発エフェクト用のアニメーションを事前に作成するメソッド
*
* preload() でロードしたスプライトシート(slash)を基に、
* 「slash-effect」という名前のアニメーションを登録する。
*
* - 使用するフレームは 0 〜 4 の合計 5枚。
* - フレームレートは 24(1秒間に24コマの速度で再生)。
* - repeat を 0 に設定しているため、1回だけ再生して停止する。
*
* このメソッドを create() 内で呼び出しておくことで、
* 攻撃時に slash.anims.play('slash-effect') を実行できるようになる。
*/
static #registerExploadEffectAnimation(scene) {
scene.anims.create({
key: "expload-effect",
frames: scene.anims.generateFrameNumbers("expload", { start: 0, end: 7 }),
frameRate: 24,
repeat: 0,
});
}
/**
* 敵キャラクターのアニメーションを登録する
* それぞれのキー(down/left/right/up)に対応した歩行アニメーションを定義
* - frames: スプライトシートの何番フレームを使うか(連番指定)
* - frameRate: 1秒間に何枚切り替えるか(数値が大きいほど速く動く)
* - repeat: -1 にすると無限ループで再生
*
* @param {*} scene
*/
static #registerEnemyAnimation(scene) {
scene.anims.create({
key: "enemy-walk-down",
frames: scene.anims.generateFrameNumbers("enemy", { start: 0, end: 2 }),
frameRate: 8,
repeat: -1,
});
scene.anims.create({
key: "enemy-walk-left",
frames: scene.anims.generateFrameNumbers("enemy", { start: 3, end: 5 }),
frameRate: 8,
repeat: -1,
});
scene.anims.create({
key: "enemy-walk-right",
frames: scene.anims.generateFrameNumbers("enemy", { start: 6, end: 8 }),
frameRate: 8,
repeat: -1,
});
scene.anims.create({
key: "enemy-walk-up",
frames: scene.anims.generateFrameNumbers("enemy", { start: 9, end: 11 }),
frameRate: 8,
repeat: -1,
});
}
/**
* プレイヤーのアニメーションを登録する
* - それぞれのキー(down/left/right/up)に対応した歩行アニメーションを定義
* - frames: スプライトシートの何番フレームを使うか(連番指定)
* - frameRate: 1秒間に何枚切り替えるか(数値が大きいほど速く動く)
* - repeat: -1 にすると無限ループで再生
*
* @param {*} scene
*/
static #playerAnimation(scene) {
scene.anims.create({
key: "player-walk-down",
frames: scene.anims.generateFrameNumbers("player", { start: 0, end: 2 }),
frameRate: 8,
repeat: -1,
});
scene.anims.create({
key: "player-walk-left",
frames: scene.anims.generateFrameNumbers("player", { start: 3, end: 5 }),
frameRate: 8,
repeat: -1,
});
scene.anims.create({
key: "player-walk-right",
frames: scene.anims.generateFrameNumbers("player", { start: 6, end: 8 }),
frameRate: 8,
repeat: -1,
});
scene.anims.create({
key: "player-walk-up",
frames: scene.anims.generateFrameNumbers("player", { start: 9, end: 11 }),
frameRate: 8,
repeat: -1,
});
}
}
4. GameScene のシンプル化
リファクタリング後の GameScene.js
では、シーン全体の進行管理だけを記述します。
-
preload()... 各クラスのpreload()を呼び出すだけ -
create()... 背景・足場・プレイヤー・敵を初期化 -
update()...player.update()とenemy.update()を呼ぶだけ
こうすることで、シーンの流れが明確になり、可読性と保守性が大きく向上します。
修正後、このようにかなりスッキリしました。
GameScene.js
import { Player } from "../entities/objects/Player.js";
import { Enemy } from "../entities/objects/Enemy.js";
import { SlashEffect } from "../entities/effects/SlashEffect.js";
import { ExploadEffect } from "../entities/effects/ExploadEffect.js";
import { Background } from "../entities/stage/Background.js";
import { Platforms } from "../entities/stage/Platforms.js";
import { Animations } from "../utils/animations.js";
/**
* ゲームのメインシーンを表すクラス
*
* @extends Phaser.Scene
*/
export class GameScene extends Phaser.Scene {
/** レイヤー */
layer;
/** プレイヤー */
player;
/** 入力キー */
cursors;
/** 攻撃キー */
attackKey;
/** 敵キャラクタ */
enemys;
/**
* シーンのコンストラクタ
* - シーンキーを "GameScene" として登録する
*/
constructor() {
super("GameScene");
}
/**
* アセットの事前読み込みを行う
* - 画像や音声などをロードする
*
* @see https://docs.phaser.io/api-documentation/class/scene
*/
preload() {
this.load.setBaseURL("/assets/");
Background.preload(this);
Platforms.preload(this);
Player.preload(this);
Enemy.preload(this);
SlashEffect.preload(this);
ExploadEffect.preload(this);
}
/**
* シーン初期化時に呼ばれる
*
* @see https://docs.phaser.io/api-documentation/class/scene
*/
create() {
this.physics.world.gravity.y = 300;
Animations.registerAnimations(this);
const background = new Background();
background.create(this);
const platforms = new Platforms();
platforms.create(this);
this.player = new Player(this);
this.player.create();
this.enemys = this.physics.add.group();
// 一定時間ごとに敵を追加するタイマーイベント
this.time.addEvent({
delay: 2000,
callback: this.spawnEnemy,
callbackScope: this,
loop: true,
});
}
/**
* 毎フレーム呼ばれる更新処理
* - プレイヤー操作や物理演算などを記述する
*
* @param {number} [time] 経過時間(ミリ秒)
* @param {number} [delta] 前フレームからの経過時間(ミリ秒)
* @override
*
* @see https://docs.phaser.io/api-documentation/class/scene
*/
update(time, delta) {
this.player.update();
this.enemys.children.iterate((enemySprite) => {
if (!enemySprite) return;
// Enemy クラスのラッパーを持たせている場合
if (enemySprite.controller) {
enemySprite.controller.update();
}
});
}
/**
* 敵を生成する。
*
* @returns
*/
spawnEnemy() {
// 画面に存在できる敵の最大数
const maxEnemies = 5;
// すでに出ている敵が maxEnemies 以上なら生成しない
if (this.enemys.countActive(true) >= maxEnemies) {
return;
}
const x = Phaser.Math.Between(100, 700); // 出現位置Xをランダムに
const y = 50; // 上から落ちてくるように
const enemySprite = this.enemys.create(x, y, "enemy");
// Enemy クラスに初期化を委譲
const enemy = new Enemy(this, enemySprite);
enemy.initialize();
// Sprite に Enemy インスタンスを紐づけておく
enemySprite.controller = enemy;
}
}
5. 今後の拡張を考慮する
- ステージ追加 →
scenes/に新しいシーンを作成\ - 新キャラクターやエフェクト追加 →
entities/
に追加するだけで管理可能\ -
Animationsクラスを拡張 → 共通的に使えるアニメーションを一元管理
まとめ
- 責務ごとにクラスを分割することでコードが整理される
-
GameScene.jsをシンプルに保つことで見通しが良くなる - 将来の拡張に対応しやすい構造が作れる
株式会社ONE WEDGE
【Serverlessで世の中をもっと楽しく】 ONE WEDGEはServerlessシステム開発を中核技術としてWeb系システム開発、AWS/GCPを利用した業務システム・サービス開発、PWAを用いたモバイル開発、Alexaスキル開発など、元気と技術力を武器にお客様に真摯に向き合う価値創造企業です。
Discussion