PiniaとVueを組み合わせたWebゲーム開発のベストプラクティス!ゲームの状態遷移を綺麗に管理

PiniaとVue.jsを組み合わせたWebゲーム開発における状態管理のベストプラクティスを表現したアイキャッチ画像 フロントエンド

Webゲーム開発において、状態管理の複雑さは避けて通れない課題です。
プレイヤーのHPやスコア、現在のシーン、敵の配置……これらが絡み合うたびに、コードは読みにくく、バグを生みやすくなります。

そこで登場するのが、Vue.jsの公式状態管理ライブラリであるPiniaです。
Vue 3との親和性が極めて高く、TypeScriptとの相性も抜群です。

本記事では、PiniaとVueを組み合わせたWebゲーム開発における、状態遷移を綺麗に管理するベストプラクティスを解説します。
具体的には、以下のポイントに焦点を当てます。

  • ゲームのグローバル状態をストアで一元管理する設計指針
  • シーン遷移(タイトル→プレイ→リザルト)を型安全に実装する方法
  • リアクティブな状態変更を活用した、UIとゲームロジックの分離

ゲーム開発の経験が浅い方も、VueやPiniaの基礎から応用まで、論理的に整理してお伝えします。
まずは、なぜPiniaがゲーム開発に適しているのか、その設計思想から見ていきましょう。

  1. はじめに:なぜPiniaがWebゲームの状態管理に適しているのか
    1. Piniaがゲーム開発に適する3つの理由
    2. ゲーム状態管理の特有の課題
    3. Vueとのシナジー
  2. Piniaの基本設計:ストアの構造とゲーム状態の定義方法
    1. ストアの定義とTypeScriptとの統合
    2. ゲーム開発におけるストアの分割戦略
    3. 状態の初期化とリセット
    4. ストア間の連携
    5. まとめ
  3. ゲームシーン遷移を型安全に実装する
    1. シーンの型定義と状態管理
    2. 遷移ルールの厳密化
    3. Vueコンポーネントとの連携
    4. シーン遷移時の副作用処理
    5. 状態マシンとしての拡張
  4. 状態変更のリアクティブ性を活かしたUI連携
    1. リアクティブシステムの仕組み
    2. ストアからUIへのデータフロー
    3. ゲットターの活用と計算の最適化
    4. ウォッチャーによる副作用の分離
    5. パフォーマンスを意識したリアクティブ設計
    6. まとめ
  5. アクションとゲットターの使い分け:ゲームロジックの分離戦略
    1. ゲッターとアクションの責務境界
    2. ゲッターの適切な使い方
    3. アクションの設計原則
    4. 複雑なゲームロジックの分離
    5. 非同期アクションの扱い
    6. コンポーネントからの呼び出し方
  6. 永続化とセーブデータ管理:localStorage連携のベストプラクティス
    1. localStorageの特性と制約
    2. Piniaストアの永続化実装
    3. 自動保存の実装
    4. セーブデータのバージョン管理
    5. エラーハンドリングと容量管理
    6. まとめ
  7. 実践編:ミニゲームを作りながら学ぶPiniaの設計パターン
    1. ゲームの要件整理
    2. ゲームループストアの設計
    3. 敵の出現管理と状態遷移
    4. プレイヤー入力の抽象化
    5. シーン遷移とゲームフローの統合
    6. まとめ
  8. パフォーマンス最適化:大量のゲームオブジェクトを効率的に管理する
    1. リアクティブシステムのオーバーヘッドを理解する
    2. ゲームオブジェクトの分類と管理戦略
    3. 非リアクティブなゲームオブジェクトプール
    4. 空間分割による衝突判定の最適化
    5. レンダリングの分離と最適化
    6. メモリ管理とGCの抑制
    7. まとめ
  9. まとめ:Piniaで実現するスケーラブルなゲーム開発

はじめに:なぜPiniaがWebゲームの状態管理に適しているのか

Vue.jsとPiniaのロゴが組み合わさった、Webゲーム開発の状態管理をイメージしたアイキャッチ画像

Webゲーム開発において、状態管理の設計は成功の鍵を握ります。
プレイヤーの位置情報、敵の行動パターン、スコアやライフの増減、そして現在のゲームシーン……これらのデータが複雑に絡み合う中で、どうすれば保守性の高いコードを維持できるのか。
これは、多くの開発者が直面する本質的な問いです。

Vue.jsのエコシステムにおいて、状態管理の解として長らくVuexが主流でした。
しかしVue 3の登場に伴い、Vueチームは新たな公式状態管理ライブラリとしてPiniaを推奨するようになりました。
PiniaはVuexの後継として設計され、TypeScriptとの親和性、Composition APIとの統合、そして直感的なAPI設計が大きな特徴です。

Piniaがゲーム開発に適する3つの理由

PiniaがWebゲームの状態管理に特に適している理由を、主に3つの観点から整理します。

  • モジュール化されたストア設計:ゲームの各システム(プレイヤー、敵、UI、サウンドなど)を独立したストアとして分離でき、関心事の分離が容易です
  • 完全なTypeScriptサポート:ゲーム状態の型安全性を保ちながら開発でき、リファクタリング時の安心感が大きく異なります
  • 軽量な学習コスト:Vuexに比べてボイラープレートが少なく、小規模なミニゲームから大規模なRPGまでスケールさせやすい設計です

ゲーム状態管理の特有の課題

ゲームの状態管理は、一般的なWebアプリケーションとは異なる複雑さを孕んでいます。
例えば、1フレームあたりに複数の状態変更が発生したり、特定のイベント(敵の出現やアイテム取得)が連鎖的に他の状態を更新したりするケースが頻出します。
このような高頻度かつ相互依存の強い状態遷移を、リアクティブシステムを活用して宣言的に記述できる点が、Piniaの大きな強みです。

Vueとのシナジー

PiniaはVueのリアクティビティシステムの上に構築されています。
そのため、computedやwatchといったVueの機能と自然に連携し、ゲーム画面のUIと内部ロジックをシームレスに結びつけることができます。
プレイヤーのHPが減少した瞬間にUIのゲージが自動で更新されるような動作は、Piniaを使えば驚くほどシンプルに実装できます。

本記事では、これらの利点を活かしながら、実際のゲーム開発現場で役立つベストプラクティスを解説していきます。
まずはPiniaの基本設計から、具体的なコード例を交えて見ていきましょう。

Piniaの基本設計:ストアの構造とゲーム状態の定義方法

Piniaのストア構造を図解した、ゲーム状態管理の設計図風アイキャッチ画像

Piniaを使いこなすためには、まずストアの基本構造を正確に理解することが重要です。
Piniaのストアは、state(状態)、getters(算出プロパティ)、actions(アクション)の3要素で構成されます。
これは、ゲーム開発における「データ」「表示」「操作」を分離する設計思想と自然に対応します。

ストアの定義とTypeScriptとの統合

PiniaのストアはdefineStore関数を使って定義します。
TypeScriptを併用することで、ゲーム状態の型安全性を確保できます。
以下は、シンプルなプレイヤー情報を管理するストアの例です。

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const usePlayerStore = defineStore('player', () => {
  const hp = ref(100)
  const maxHp = ref(100)
  const score = ref(0)
  const level = ref(1)

  const hpPercentage = computed(() => {
    return Math.max(0, Math.min(100, (hp.value / maxHp.value) * 100))
  })

  const isAlive = computed(() => hp.value > 0)

  function takeDamage(amount: number) {
    hp.value = Math.max(0, hp.value - amount)
  }

  function heal(amount: number) {
    hp.value = Math.min(maxHp.value, hp.value + amount)
  }

  function addScore(points: number) {
    score.value += points
  }

  function levelUp() {
    level.value++
    maxHp.value += 20
    hp.value = maxHp.value
  }

  return { hp, maxHp, score, level, hpPercentage, isAlive, takeDamage, heal, addScore, levelUp }
})

この例では、Composition APIスタイル(Setup Stores)を採用しています。
Vueのrefやcomputedをそのまま利用できるため、Vue開発者にとって学習コストはほとんどありません。

ゲーム開発におけるストアの分割戦略

Webゲームでは、状態をどの粒度でストアに分割するかが設計の肝です。
大きすぎるストアは保守性を損ない、小さすぎるストアは管理コストが増大します。
以下のような観点で分割を検討するとよいでしょう。

ストア名 管理する状態 分割理由
player HP、スコア、レベル、所持アイテム プレイヤーに関する状態を一元化
enemy 敵の配置、行動パターン、HP 敵のAIロジックと分離
gameScene 現在のシーン、一時停止状態 ゲームフロー全体の制御
ui ダイアログの表示状態、通知 UIレイヤーの分離
settings 音量、キー設定、言語 ユーザー設定の永続化対象

状態の初期化とリセット

ゲームでは「リトライ」や「ゲームオーバー後の再開」といった、状態を初期値に戻す操作が頻出します。
Piniaでは、ストアの状態をまとめてリセットする仕組みを自前で実装する必要があります。
以下は、初期状態を保持しておき、リセット関数を提供するアプローチです。

const initialState = {
  hp: 100,
  maxHp: 100,
  score: 0,
  level: 1
}

export const usePlayerStore = defineStore('player', () => {
  const hp = ref(initialState.hp)
  const maxHp = ref(initialState.maxHp)
  const score = ref(initialState.score)
  const level = ref(initialState.level)

  function $reset() {
    hp.value = initialState.hp
    maxHp.value = initialState.maxHp
    score.value = initialState.score
    level.value = initialState.level
  }

  return { hp, maxHp, score, level, $reset }
})

$resetという命名はPiniaの慣習に倣ったものですが、任意の名前でも構いません。
重要なのは、ゲームの再開時に確実にクリーンな状態に戻せることです。

ストア間の連携

ゲームでは、異なるストア同士が連携するケースが多々あります。
例えば、プレイヤーが敵を倒した際に、playerストアのスコアを更新しつつ、enemyストアの敵リストから該当の敵を削除するような操作です。
Piniaでは、ストアの中から他のストアを呼び出すことが可能です。

import { useEnemyStore } from './enemy'

export const usePlayerStore = defineStore('player', () => {
  const score = ref(0)

  function defeatEnemy(enemyId: string) {
    const enemyStore = useEnemyStore()
    const enemy = enemyStore.getEnemyById(enemyId)
    if (enemy) {
      score.value += enemy.pointValue
      enemyStore.removeEnemy(enemyId)
    }
  }

  return { score, defeatEnemy }
})

ただし、ストア間の循環参照には注意が必要です。
playerストアがenemyストアを参照し、enemyストアもplayerストアを参照するような構造は、依存関係の複雑化を招きます。
このような場合は、共通のゲームコントローラーストアを設け、そこから各ストアを調整する設計を検討するとよいでしょう。

まとめ

Piniaのストア設計は、ゲーム開発において「何をどこに置くか」という本質的な問いに直面します。
型安全性を保ちつつ、適切な粒度でストアを分割し、初期化とリセットの仕組みを整える。
これらが堅牢なゲーム状態管理の土台となります。
次の章では、この設計を前提に、ゲームシーン遷移を型安全に実装する具体的な手法を解説します。

ゲームシーン遷移を型安全に実装する

ゲームのシーン遷移図が型安全に設計された、フローチャート風のアイキャッチ画像

Webゲームにおいて、シーン遷移は最も重要な制御フローの一つです。
タイトル画面からゲームプレイへ、プレイから一時停止やリザルトへ……これらの遷移が不正な状態を生まないよう、型安全性を担保した設計が求められます。
PiniaとTypeScriptを組み合わせることで、この課題をエレガントに解決できます。

シーンの型定義と状態管理

まず、ゲーム内で取りうるシーンを列挙型(enum)またはユニオン型で定義します。
TypeScriptの型システムを活用することで、存在しないシーンへの遷移をコンパイル時に防ぎます。

type GameScene = 'title' | 'playing' | 'paused' | 'gameover' | 'result'

export const useGameSceneStore = defineStore('gameScene', () => {
  const currentScene = ref<GameScene>('title')
  const previousScene = ref<GameScene>('title')
  const sceneHistory = ref<GameScene[]>([])

  const canPause = computed(() => currentScene.value === 'playing')
  const canResume = computed(() => currentScene.value === 'paused')

  function transitionTo(scene: GameScene) {
    previousScene.value = currentScene.value
    sceneHistory.value.push(currentScene.value)
    currentScene.value = scene
  }

  function goBack() {
    const previous = sceneHistory.value.pop()
    if (previous) {
      currentScene.value = previous
    }
  }

  return { currentScene, previousScene, canPause, canResume, transitionTo, goBack }
})

この設計のポイントは、遷移可能なシーンを型レベルで制限していることです。
transitionTo関数に'title'や'playing'以外の文字列を渡そうとすると、TypeScriptコンパイラが即座にエラーを検出します。

遷移ルールの厳密化

単に型を定義するだけでなく、ビジネスロジックとして遷移の可否を判定することも重要です。
例えば、ゲームオーバー画面から直接プレイ画面に遷移させたくない、といった制約を設けるケースです。

const validTransitions: Record<GameScene, GameScene[]> = {
  title: ['playing'],
  playing: ['paused', 'gameover', 'result'],
  paused: ['playing', 'title'],
  gameover: ['title'],
  result: ['title', 'playing']
}

function transitionTo(scene: GameScene) {
  const validNextScenes = validTransitions[currentScene.value]
  if (!validNextScenes.includes(scene)) {
    console.warn(`不正なシーン遷移: ${currentScene.value} -> ${scene}`)
    return
  }
  previousScene.value = currentScene.value
  sceneHistory.value.push(currentScene.value)
  currentScene.value = scene
}

このように遷移マトリックスを定義することで、実行時にも不正な遷移を防止できます。
ゲームの仕様変更に伴い遷移ルールが複雑化しても、一箇所の定義を修正するだけで対応可能です。

Vueコンポーネントとの連携

定義したシーンストアは、Vueコンポーネント側で簡潔に利用できます。
以下は、シーンに応じて表示する画面を切り替える実装例です。

<script setup lang="ts">
import { useGameSceneStore } from '@/stores/gameScene'
import TitleScreen from '@/components/TitleScreen.vue'
import GameScreen from '@/components/GameScreen.vue'
import PauseScreen from '@/components/PauseScreen.vue'
import GameOverScreen from '@/components/GameOverScreen.vue'
import ResultScreen from '@/components/ResultScreen.vue'

const sceneStore = useGameSceneStore()

const sceneComponents = {
  title: TitleScreen,
  playing: GameScreen,
  paused: PauseScreen,
  gameover: GameOverScreen,
  result: ResultScreen
}
</script>

<template>
  <component :is="sceneComponents[sceneStore.currentScene]" />
</template>

<component :is="...">を使うことで、シーン遷移を宣言的に記述できます。
条件分岐が複雑に入り組むよりも、データ構造としてシーンとコンポーネントの対応を定義する方が、可読性と保守性に優れます。

シーン遷移時の副作用処理

シーンが切り替わる際に、特定の処理を実行したいケースがよくあります。
例えば、プレイ画面から一時停止画面へ遷移した際にBGMを停止させる、といった処理です。
Piniaのストア内でこれを実現するには、遷移関数にフックを設けるアプローチが有効です。

type SceneHook = (from: GameScene, to: GameScene) => void
const beforeHooks: SceneHook[] = []
const afterHooks: SceneHook[] = []

function onBeforeTransition(hook: SceneHook) {
  beforeHooks.push(hook)
}

function onAfterTransition(hook: SceneHook) {
  afterHooks.push(hook)
}

function transitionTo(scene: GameScene) {
  const from = currentScene.value
  beforeHooks.forEach(hook => hook(from, scene))
  previousScene.value = from
  currentScene.value = scene
  afterHooks.forEach(hook => hook(from, scene))
}

onAfterTransition((from, to) => {
  if (to === 'paused') {
    audioStore.pauseBgm()
  }
  if (from === 'paused' && to === 'playing') {
    audioStore.resumeBgm()
  }
})

このフック機構により、シーン遷移の責務と副作用の責務を分離できます。
ストアは遷移の制御に集中し、各システム(音声、入力、ネットワークなど)は必要に応じてフックを登録する。
これが関心事の分離の本質です。

状態マシンとしての拡張

より高度なゲームでは、シーン遷移を有限状態マシン(FSM)としてモデリングすることも有効です。
遷移時にデータを受け渡したり、遷移アニメーションを挟んだりする要件が出てくるためです。

interface SceneTransition {
  from: GameScene
  to: GameScene
  data?: Record<string, unknown>
  duration?: number
}

async function transitionTo(scene: GameScene, data?: Record<string, unknown>) {
  const transition: SceneTransition = { from: currentScene.value, to: scene, data }
  await uiStore.playTransitionAnimation(transition)
  currentScene.value = scene
}

非同期遷移に対応させることで、画面フェードやスライドアニメーションといった演出も自然に統合できます。
ゲームの品質を左右する「遷移の演出」まで、型安全な設計の中に組み込めるのです。

型安全なシーン遷移の設計は、ゲームの規模が大きくなるほどその真価を発揮します。
次の章では、この状態管理を前提に、リアクティブなUI連携のテクニックを深掘りしていきます。

状態変更のリアクティブ性を活かしたUI連携

VueのリアクティブシステムとゲームUIが連携する様子を表現したアイキャッチ画像

Webゲームの開発において、ゲームロジックとUIの連携は常に頭の痛い問題です。
プレイヤーのHPが変化したらHPバーが伸縮し、スコアが増えたら数字がカウントアップする。
これらの動作を手動で管理しようとすると、コードはすぐにスパゲッティ化してしまいます。
PiniaとVueのリアクティブシステムを活用すれば、この煩雑さを根本から解消できます。

リアクティブシステムの仕組み

Vue 3のリアクティブシステムは、Proxyオブジェクトを利用してデータの変更を検知します。
Piniaのストア内で定義したrefやcomputedは、このリアクティブシステムの上に構築されています。
そのため、ストアの状態が変化すると、それを参照しているコンポーネントが自動的に再レンダリングされるのです。
これが、宣言的なUI記述の本質です。

ストアからUIへのデータフロー

ゲームUIの典型的なパターンとして、以下のようなデータフローが挙げられます。

  • ストアの状態が変化する
  • computedで算出された派生値も自動更新される
  • コンポーネントのテンプレートが再評価される
  • 画面が更新される

この流れを意識することで、明示的なDOM操作を排除し、データの流れを一方向に保つことができます。

<script setup lang="ts">
import { usePlayerStore } from '@/stores/player'
import { useEnemyStore } from '@/stores/enemy'

const playerStore = usePlayerStore()
const enemyStore = useEnemyStore()

const nearestEnemy = computed(() => {
  return enemyStore.findNearest(playerStore.positionX, playerStore.positionY)
})

const dangerLevel = computed(() => {
  if (!nearestEnemy.value) return 'safe'
  const distance = nearestEnemy.value.distance
  if (distance < 50) return 'critical'
  if (distance < 150) return 'warning'
  return 'safe'
})
</script>

<template>
  <div class="hud">
    <div class="hp-bar" :style="{ width: playerStore.hpPercentage + '%' }"></div>
    <div class="score">{{ playerStore.score.toLocaleString() }}</div>
    <div class="radar" :class="dangerLevel">
      <span v-if="nearestEnemy">
        敵までの距離: {{ Math.round(nearestEnemy.distance) }}m
      </span>
    </div>
  </div>
</template>

このコードでは、playerStore.hpPercentageやnearestEnemyが変化するたびに、UIが自動的に更新されます。
dangerLevelの算出もリアクティブに行われ、CSSクラスの切り替えが即座に反映されます。

ゲットターの活用と計算の最適化

Piniaのゲッター(またはコンポーネント側のcomputed)は、依存する状態が変化しない限りキャッシュされます。
ゲーム内で頻繁に参照される派生値は、ゲッターとして定義することで無駄な再計算を避けられます。

export const usePlayerStore = defineStore('player', () => {
  const inventory = ref<Item[]>([])
  const equippedWeaponId = ref<string | null>(null)

  const equippedWeapon = computed(() => {
    return inventory.value.find(item => item.id === equippedWeaponId.value)
  })

  const totalAttackPower = computed(() => {
    const base = equippedWeapon.value?.attack ?? 0
    const buffMultiplier = buffs.value.reduce((sum, buff) => sum + buff.attackBonus, 0)
    return base * (1 + buffMultiplier)
  })

  const canUseSkill = computed(() => {
    return mp.value >= (equippedWeapon.value?.skillCost ?? Infinity)
  })

  return { inventory, equippedWeaponId, equippedWeapon, totalAttackPower, canUseSkill }
})

totalAttackPowerは、装備武器やバフが変化したときだけ再計算されます。
毎フレーム呼び出しても、内部で変更検知が働き、パフォーマンスを維持します。

ウォッチャーによる副作用の分離

リアクティブな値の変化を検知して、副作用を実行したいケースがあります。
例えば、プレイヤーのレベルが上がった際にレベルアップエフェクトを再生するような場面です。
Vueのwatch関数を使えば、これを宣言的に記述できます。

<script setup lang="ts">
import { watch } from 'vue'
import { usePlayerStore } from '@/stores/player'

const playerStore = usePlayerStore()

watch(() => playerStore.level, (newLevel, oldLevel) => {
  if (newLevel > oldLevel) {
    effectStore.playLevelUp()
    uiStore.showNotification(`レベルアップ! Lv.${oldLevel} → Lv.${newLevel}`)
  }
})

watch(() => playerStore.hp, (newHp, oldHp) => {
  if (newHp < oldHp) {
    uiStore.showDamageFlash()
    const damage = oldHp - newHp
    floatingTextStore.spawn(`-${damage}`, playerStore.positionX, playerStore.positionY)
  }
})
</script>

watchの第一引数に関数を渡すことで、特定の状態に対する精緻な変更検知が可能です。
ゲームエンジンでいうところの「イベントリスナー」を、Vueのリアクティブシステム上に自然に構築できるのです。

パフォーマンスを意識したリアクティブ設計

ゲーム開発において、リアクティブシステムの使いすぎは逆にパフォーマンスを損なうことがあります。
特に、大量のオブジェクトをリアクティブに管理する際には注意が必要です。

const particles = ref<Particle[]>([])

function spawnParticles(count: number) {
  for (let i = 0; i < count; i++) {
    particles.value.push({
      x: 0,
      y: 0,
      vx: Math.random() * 10 - 5,
      vy: Math.random() * 10 - 5,
      life: 100
    })
  }
}

このように配列に対してpushを繰り返すと、Vueのリアクティブシステムが毎回変更を検知してしまいます。
大量のパーティクルを扱う場合は、一時的にリアクティビティを外してバッチ更新するか、shallowRefを利用して深いリアクティビティを無効化するとよいでしょう。

const particles = shallowRef<Particle[]>([])

function updateParticles() {
  const current = particles.value
  for (const p of current) {
    p.x += p.vx
    p.y += p.vy
    p.life--
  }
  particles.value = current.filter(p => p.life > 0)
}

shallowRefは、参照そのものの変更のみを検知し、配列の中身までは監視しません。
ゲームループ内で毎フレーム更新されるデータには、このような最適化が有効です。

まとめ

PiniaとVueのリアクティブシステムを活用することで、ゲームロジックとUIの連携を宣言的かつ効率的に実装できます。
computedによる派生値の管理、watchによる副作用の分離、そしてshallowRefによるパフォーマンス最適化。
これらを適材適所で使い分けることが、スケーラブルなゲームUI開発の鍵です。
次の章では、アクションとゲッターの使い分けを通じて、ゲームロジックのさらなる分離戦略を探ります。

アクションとゲットターの使い分け:ゲームロジックの分離戦略

Piniaのアクションとゲッターを使い分けるゲームロジックの分離を表現したアイキャッチ画像

Piniaのストアは、state、getters、actionsの3要素で構成されます。
しかし、実際のゲーム開発にあたって、どのロジックをどこに置くべきかという判断は必ずしも自明ではありません。
特にゲームでは、状態の更新、計算、副作用が複雑に絡み合うため、責務の分離が設計の成否を左右します。

ゲッターとアクションの責務境界

まず、それぞれの役割を明確に整理しましょう。

要素 責務 ゲーム開発での例
state 生の状態データを保持 プレイヤーの現在HP、座標
getters 状態から派生する読み取り専用の値を計算 HP割合、装備可能判定
actions 状態を変更する操作を実行 ダメージ処理、アイテム使用

この境界を曖昧にすると、同じロジックがコンポーネントとストアの間を行き来し、テスト困難で保守性の低いコードが生まれます。

ゲッターの適切な使い方

ゲッターは、純粋関数として振る舞うべきです。
同じ入力に対して常に同じ出力を返し、副作用を持たない。
これがゲッターの鉄則です。

export const usePlayerStore = defineStore('player', () => {
  const hp = ref(100)
  const maxHp = ref(100)
  const experience = ref(0)
  const experienceToNext = ref(100)

  const hpPercentage = computed(() => (hp.value / maxHp.value) * 100)
  const isCritical = computed(() => hpPercentage.value <= 20)
  const levelProgress = computed(() => experience.value / experienceToNext.value)
  const canLevelUp = computed(() => experience.value >= experienceToNext.value)

  return { hp, maxHp, experience, experienceToNext, hpPercentage, isCritical, levelProgress, canLevelUp }
})

hpPercentageやisCriticalは、stateの値が変わらなければ結果も変わりません。
これらをゲッターとして定義することで、コンポーネント側では「どう計算するか」を意識せずに「何が必要か」だけを記述できます。

アクションの設計原則

一方、アクションは状態変更の意図を表現する操作として設計します。
ゲーム開発では、単純な値の代入ではなく、複数の状態を一貫して更新するケースが多いです。

function takeDamage(amount: number) {
  const previousHp = hp.value
  hp.value = Math.max(0, hp.value - amount)

  if (hp.value <= 0 && previousHp > 0) {
    triggerGameOver()
  }
}

function useItem(itemId: string) {
  const item = inventory.value.find(i => i.id === itemId)
  if (!item || item.count <= 0) return

  item.count--
  applyItemEffect(item)
  removeFromInventoryIfDepleted(item)
}

takeDamageは、HPの減少だけでなく、ゲームオーバー判定も同時に行います。
useItemは、在庫確認、効果適用、在庫更新という一連の処理をトランザクション的に実行します。
これらをアクションとしてカプセル化することで、コンポーネント側は「何をしたいか」だけを宣言し、「どう実現するか」はストアに委ねられます。

複雑なゲームロジックの分離

ゲームが複雑になると、ストア内に大量のアクションが蓄積し、可読性が低下します。
このような場合、ドメインロジックを別モジュールに切り出すことを検討すべきです。

import { calculateDamage } from '@/domain/combat'
import { applyStatusEffect } from '@/domain/status'

export const useCombatStore = defineStore('combat', () => {
  const attacker = ref<Character | null>(null)
  const defender = ref<Character | null>(null)

  function executeAttack() {
    if (!attacker.value || !defender.value) return

    const damage = calculateDamage(attacker.value, defender.value)
    const effects = applyStatusEffect(attacker.value, defender.value)

    defender.value.hp -= damage
    effects.forEach(effect => activeEffects.value.push(effect))

    logCombatResult(attacker.value, defender.value, damage, effects)
  }

  return { attacker, defender, executeAttack }
})

calculateDamageやapplyStatusEffectは、純粋なドメインロジックとして別ファイルに定義されています。
これにより、ストアは「いつ実行するか(フロー制御)」に集中し、ドメインモジュールは「どう計算するか(ビジネスルール)」に集中します。
関心事の分離が明確になり、単体テストも書きやすくなります。

非同期アクションの扱い

ゲーム開発では、非同期処理も頻出します。
サーバーとの通信、ローカルストレージへの保存、音声ファイルの読み込みなどです。
Piniaのアクションはasync/awaitをそのまま利用できます。

async function saveGame(slotId: number) {
  const saveData = {
    player: playerStore.$state,
    scene: sceneStore.currentScene,
    timestamp: Date.now()
  }

  try {
    isSaving.value = true
    await localStorage.setItem(`save_${slotId}`, JSON.stringify(saveData))
    await syncToCloud(saveData)
    lastSaveSlot.value = slotId
  } catch (error) {
    errorStore.report('SAVE_FAILED', error)
  } finally {
    isSaving.value = false
  }
}

非同期アクションでは、ローディング状態の管理とエラーハンドリングを必ず含めるべきです。
isSavingのようなフラグをstateに持たせることで、UI側で「セーブ中…」といった表示をリアクティブに制御できます。

コンポーネントからの呼び出し方

最後に、コンポーネント側からストアをどう利用するかを整理します。
基本的な原則は、コンポーネントはアクションを呼び出し、ゲッターを参照し、stateを直接変更しないという3点です。

<script setup lang="ts">
import { usePlayerStore } from '@/stores/player'

const playerStore = usePlayerStore()

function onEnemyCollision(enemy: Enemy) {
  playerStore.takeDamage(enemy.attackPower)
}
</script>

<template>
  <div class="status-panel">
    <div class="hp-bar">
      <div class="hp-fill" :style="{ width: playerStore.hpPercentage + '%' }"></div>
    </div>
    <div class="hp-text">{{ playerStore.hp }} / {{ playerStore.maxHp }}</div>
  </div>
</template>

takeDamageはアクションとして呼び出し、hpPercentageはゲッターとして参照しています。
stateであるhpやmaxHpは読み取り専用としてテンプレートに埋め込んでいますが、コンポーネント側からは決して直接代入していません。
この一貫性が、予測可能でデバッグしやすいコードを生み出します。

アクションとゲッターの使い分けを徹底することで、ゲームロジックの責務が明確化し、チーム開発においても各メンバーが迷うことなくコードを書けるようになります。
次の章では、ゲームの進行状況を永続化するlocalStorage連携のベストプラクティスを解説します。

永続化とセーブデータ管理:localStorage連携のベストプラクティス

ブラウザのlocalStorageとPiniaが連携してゲームセーブデータを管理するアイキャッチ画像

Webゲームにおいて、プレイヤーの進行状況を保存する機能は、ユーザー体験の根幹を担います。
ブラウザを閉じてもデータが失われない、複数のセーブスロットを使い分ける、といった要件を実現するには、永続化レイヤーの堅牢な設計が不可欠です。
PiniaとブラウザのlocalStorageを組み合わせることで、比較的シンプルにこの機能を実装できます。

localStorageの特性と制約

まず、localStorageの特性を正しく理解することが重要です。
localStorageはキー・バリューストアであり、文字列のみを保存できます。
各ドメインあたり約5MB〜10MBの容量制限があり、同期的にアクセスされます。
ゲームセーブデータの保存には十分な容量ですが、大量の画像や音声データの保存には向きません。

特性 詳細 ゲーム開発での影響
容量制限 約5MB〜10MB/ドメイン セーブデータは十分、リソースは別途検討
同期API ブロッキング処理 メインスレッドを阻害しない設計が必要
文字列のみ JSONシリアライズ必須 オブジェクトの復元時に型安全性を確保
同一オリジン ドメイン単位で分離 サブドメイン間での共有は不可

Piniaストアの永続化実装

Piniaのストア状態をlocalStorageに保存する最もシンプルな方法は、ストアの$subscribeメソッドを利用することです。
これにより、状態が変化するたびに自動的に永続化処理を実行できます。

import { defineStore } from 'pinia'
import { ref, watch } from 'vue'

const STORAGE_KEY = 'game_save_data'

export const useSaveDataStore = defineStore('saveData', () => {
  const saveSlots = ref<SaveSlot[]>([])
  const autoSaveEnabled = ref(true)
  const lastSavedAt = ref<number | null>(null)

  function loadFromStorage() {
    const raw = localStorage.getItem(STORAGE_KEY)
    if (raw) {
      try {
        const parsed = JSON.parse(raw)
        saveSlots.value = parsed.saveSlots ?? []
        autoSaveEnabled.value = parsed.autoSaveEnabled ?? true
      } catch (e) {
        console.error('セーブデータの読み込みに失敗しました', e)
      }
    }
  }

  function saveToStorage() {
    const payload = {
      saveSlots: saveSlots.value,
      autoSaveEnabled: autoSaveEnabled.value,
      version: '1.0.0'
    }
    localStorage.setItem(STORAGE_KEY, JSON.stringify(payload))
    lastSavedAt.value = Date.now()
  }

  function createSaveSlot(slotName: string, gameState: GameState) {
    const slot: SaveSlot = {
      id: crypto.randomUUID(),
      name: slotName,
      createdAt: Date.now(),
      updatedAt: Date.now(),
      gameState
    }
    saveSlots.value.push(slot)
    saveToStorage()
  }

  function updateSaveSlot(slotId: string, gameState: GameState) {
    const slot = saveSlots.value.find(s => s.id === slotId)
    if (slot) {
      slot.gameState = gameState
      slot.updatedAt = Date.now()
      saveToStorage()
    }
  }

  function deleteSaveSlot(slotId: string) {
    saveSlots.value = saveSlots.value.filter(s => s.id !== slotId)
    saveToStorage()
  }

  loadFromStorage()

  return { saveSlots, autoSaveEnabled, lastSavedAt, createSaveSlot, updateSaveSlot, deleteSaveSlot }
})

この実装では、セーブスロットのCRUD操作のたびにsaveToStorageを呼び出しています。
loadFromStorageはストアの初期化時に一度だけ実行され、ブラウザ再起動後もデータが復元されます。

自動保存の実装

ゲームの進行状況を自動的に保存したい場合、特定のイベント(シーン遷移、レベルアップ、チェックポイント到達など)をトリガーとして保存処理を実行します。

export const useGameStore = defineStore('game', () => {
  const playerStore = usePlayerStore()
  const sceneStore = useGameSceneStore()
  const saveDataStore = useSaveDataStore()

  function checkpointReached() {
    if (!saveDataStore.autoSaveEnabled) return

    const gameState = {
      player: playerStore.$state,
      scene: sceneStore.currentScene,
      timestamp: Date.now()
    }

    const autoSaveSlot = saveDataStore.saveSlots.find(s => s.name === 'auto')
    if (autoSaveSlot) {
      saveDataStore.updateSaveSlot(autoSaveSlot.id, gameState)
    } else {
      saveDataStore.createSaveSlot('auto', gameState)
    }
  }

  return { checkpointReached }
})

checkpointReachedは、ゲーム内のチェックポイント到達時に呼び出されます。
autoセーブスロットが存在すれば更新し、なければ新規作成します。
このように、保存のタイミングをゲームのイベントモデルに統合することで、自然な自動保存機能を実現できます。

セーブデータのバージョン管理

ゲームは継続的にアップデートされます。
その際、セーブデータの構造が変化することもあります。
古いバージョンのセーブデータを新しいバージョンで読み込んだ際に、互換性を保つためのマイグレーション機構が必要です。

const CURRENT_VERSION = '1.2.0'

const migrations: Record<string, (data: any) => any> = {
  '1.0.0': (data) => {
    return {
      ...data,
      saveSlots: data.saveSlots.map((slot: any) => ({
        ...slot,
        gameState: {
          ...slot.gameState,
          settings: slot.gameState.settings ?? { volume: 50, difficulty: 'normal' }
        }
      }))
    }
  },
  '1.1.0': (data) => {
    return {
      ...data,
      saveSlots: data.saveSlots.map((slot: any) => ({
        ...slot,
        gameState: {
          ...slot.gameState,
          achievements: slot.gameState.achievements ?? []
        }
      }))
    }
  }
}

function migrateSaveData(data: any) {
  const dataVersion = data.version ?? '1.0.0'
  const versions = Object.keys(migrations).sort()
  let migrated = data

  for (const version of versions) {
    if (dataVersion < version) {
      migrated = migrations[version](migrated)
    }
  }

  migrated.version = CURRENT_VERSION
  return migrated
}

このマイグレーション機構により、セーブデータの構造変更に伴う破壊的変更を、ユーザーに無縫に適用できます。
各バージョンの差分を小さく保ち、段階的にデータを変換していくアプローチが堅牢です。

エラーハンドリングと容量管理

localStorageへの書き込みは、容量不足やプライベートブラウジングモードなどの理由で失敗することがあります。
また、QuotaExceededErrorが発生した場合、ユーザーに通知する必要があります。

function saveToStorage() {
  const payload = {
    saveSlots: saveSlots.value,
    autoSaveEnabled: autoSaveEnabled.value,
    version: CURRENT_VERSION
  }

  try {
    const serialized = JSON.stringify(payload)
    if (serialized.length > 4 * 1024 * 1024) {
      throw new Error('セーブデータが容量制限に近づいています')
    }
    localStorage.setItem(STORAGE_KEY, serialized)
    lastSavedAt.value = Date.now()
  } catch (e) {
    if (e instanceof Error && e.name === 'QuotaExceededError') {
      errorStore.report('STORAGE_FULL', 'localStorageの容量が不足しています。古いセーブデータを削除してください。')
    } else {
      errorStore.report('SAVE_ERROR', e)
    }
  }
}

書き込み前にシリアライズ後のサイズをチェックし、容量制限の80%程度を閾値として警告を出すことも有効です。
ユーザーにとってデータ消失は最悪の体験です。
事前の警告と適切なエラーメッセージが、信頼性の高いゲーム体験を支えます。

まとめ

localStorageを活用したセーブデータ管理は、Webゲーム開発において必須の機能です。
Piniaのストアと連携させることで、状態の永続化を宣言的かつ型安全に実装できます。
自動保存、バージョン管理、エラーハンドリングという3つの柱を意識することで、プレイヤーの時間と努力を守る堅牢な保存システムを構築できるのです。
次の章では、これまでの知識を総動員して、実際にミニゲームを作りながら設計パターンを学んでいきます。

実践編:ミニゲームを作りながら学ぶPiniaの設計パターン

実際にミニゲームを開発しながらPiniaの設計パターンを学ぶ様子のアイキャッチ画像

これまで解説してきたPiniaの基礎知識を、実際のゲーム開発にどう活かすのか。
理論だけではイメージが湧きにくい方も多いでしょう。
そこで本章では、シンプルなシューティングゲームを題材に、Piniaの設計パターンを実践的に適用していきます。
タイトル画面、プレイ画面、リザルト画面の3シーンを持ち、プレイヤーが敵を撃ち、スコアを競うという構成です。

ゲームの要件整理

まず、ゲームの要件を整理しましょう。

  • タイトル画面でゲーム開始を選択
  • プレイ画面でプレイヤーが上下左右に移動し、スペースキーで弾を発射
  • 敵は画面上部からランダムに出現し、プレイヤーに接近
  • 弾が敵に命中するとスコア加算、敵がプレイヤーに接触するとゲームオーバー
  • リザルト画面でスコア表示とランキング保存

これらの要件を満たすため、以下のストア構成を採用します。

ストア名 責務
gameScene シーン遷移の管理
player プレイヤーの位置、HP、スコア
projectile 弾の発射、移動、衝突判定
enemy 敵の出現、移動、生死管理
gameLoop ゲームループの制御、フレーム更新

ゲームループストアの設計

ゲームの心臓部であるゲームループを、Piniaのストアとして実装します。
requestAnimationFrameを利用し、毎フレームの更新処理を調整します。

export const useGameLoopStore = defineStore('gameLoop', () => {
  const isRunning = ref(false)
  const lastFrameTime = ref(0)
  const deltaTime = ref(0)
  let animationFrameId: number | null = null

  const playerStore = usePlayerStore()
  const projectileStore = useProjectileStore()
  const enemyStore = useEnemyStore()

  function start() {
    if (isRunning.value) return
    isRunning.value = true
    lastFrameTime.value = performance.now()
    tick()
  }

  function stop() {
    isRunning.value = false
    if (animationFrameId !== null) {
      cancelAnimationFrame(animationFrameId)
      animationFrameId = null
    }
  }

  function tick() {
    if (!isRunning.value) return

    const now = performance.now()
    deltaTime.value = (now - lastFrameTime.value) / 1000
    lastFrameTime.value = now

    update(deltaTime.value)

    animationFrameId = requestAnimationFrame(tick)
  }

  function update(dt: number) {
    playerStore.update(dt)
    projectileStore.update(dt)
    enemyStore.update(dt)

    checkCollisions()
  }

  function checkCollisions() {
    for (const projectile of projectileStore.projectiles) {
      for (const enemy of enemyStore.enemies) {
        if (isColliding(projectile, enemy)) {
          projectileStore.remove(projectile.id)
          enemyStore.damage(enemy.id, 1)
          playerStore.addScore(enemy.pointValue)
        }
      }
    }

    for (const enemy of enemyStore.enemies) {
      if (isColliding(enemy, playerStore.hitbox)) {
        playerStore.takeDamage(1)
        enemyStore.remove(enemy.id)
      }
    }
  }

  return { isRunning, deltaTime, start, stop }
})

この設計の重要な点は、ゲームループが各ストアの更新をオーケストレーションしていることです。
player、projectile、enemyの各ストアは自分の責務に集中し、ループストアがそれらを統合します。

敵の出現管理と状態遷移

敵の出現タイミングを管理するストアでは、出現間隔の動的調整を実装します。
スコアが上がるほど敵の出現頻度を増やすことで、難易度カーブを自然に形成できます。

export const useEnemyStore = defineStore('enemy', () => {
  const enemies = ref<Enemy[]>([])
  const spawnTimer = ref(0)
  const baseSpawnInterval = ref(2.0)
  const difficultyMultiplier = ref(1.0)

  const currentSpawnInterval = computed(() => {
    return Math.max(0.3, baseSpawnInterval.value / difficultyMultiplier.value)
  })

  function update(dt: number) {
    spawnTimer.value += dt
    if (spawnTimer.value >= currentSpawnInterval.value) {
      spawn()
      spawnTimer.value = 0
    }

    for (const enemy of enemies.value) {
      enemy.y += enemy.speed * dt
    }

    enemies.value = enemies.value.filter(e => e.y < 600 && e.hp > 0)
  }

  function spawn() {
    const enemy: Enemy = {
      id: crypto.randomUUID(),
      x: Math.random() * 760 + 20,
      y: -30,
      speed: 100 + Math.random() * 50,
      hp: 1,
      pointValue: 100,
      width: 30,
      height: 30
    }
    enemies.value.push(enemy)
  }

  function damage(id: string, amount: number) {
    const enemy = enemies.value.find(e => e.id === id)
    if (enemy) {
      enemy.hp -= amount
    }
  }

  function remove(id: string) {
    enemies.value = enemies.value.filter(e => e.id !== id)
  }

  function increaseDifficulty() {
    difficultyMultiplier.value += 0.1
  }

  function reset() {
    enemies.value = []
    spawnTimer.value = 0
    difficultyMultiplier.value = 1.0
  }

  return { enemies, currentSpawnInterval, update, spawn, damage, remove, increaseDifficulty, reset }
})

difficultyMultiplierを導入することで、ゲームの進行に応じて難易度を滑らかに上昇させることができます。
reset関数はゲーム再開時に呼び出され、クリーンな初期状態を保証します。

プレイヤー入力の抽象化

キーボード入力を直接扱うのではなく、入力状態を抽象化したストアを設けることで、テスト容易性と拡張性を高めます。

export const useInputStore = defineStore('input', () => {
  const keys = ref<Set<string>>(new Set())

  function press(key: string) {
    keys.value.add(key)
  }

  function release(key: string) {
    keys.value.delete(key)
  }

  const isPressed = computed(() => (key: string) => keys.value.has(key))

  function setupListeners() {
    window.addEventListener('keydown', (e) => press(e.key))
    window.addEventListener('keyup', (e) => release(e.key))
  }

  function teardownListeners() {
    window.removeEventListener('keydown', (e) => press(e.key))
    window.removeEventListener('keyup', (e) => release(e.key))
  }

  return { keys, isPressed, setupListeners, teardownListeners }
})

プレイヤーストアは、この入力ストアを参照して移動処理を行います。
将来、ゲームパッド対応やタッチ操作を追加する際も、inputストアのインターフェースを拡張するだけで対応可能です。

export const usePlayerStore = defineStore('player', () => {
  const x = ref(400)
  const y = ref(500)
  const speed = ref(300)
  const hp = ref(3)
  const score = ref(0)
  const isInvincible = ref(false)
  const invincibleTimer = ref(0)

  const inputStore = useInputStore()

  const hitbox = computed(() => ({
    x: x.value - 15,
    y: y.value - 15,
    width: 30,
    height: 30
  }))

  function update(dt: number) {
    if (inputStore.isPressed('ArrowLeft')) x.value -= speed.value * dt
    if (inputStore.isPressed('ArrowRight')) x.value += speed.value * dt
    if (inputStore.isPressed('ArrowUp')) y.value -= speed.value * dt
    if (inputStore.isPressed('ArrowDown')) y.value += speed.value * dt

    x.value = Math.max(20, Math.min(780, x.value))
    y.value = Math.max(20, Math.min(580, y.value))

    if (isInvincible.value) {
      invincibleTimer.value -= dt
      if (invincibleTimer.value <= 0) {
        isInvincible.value = false
      }
    }
  }

  function takeDamage(amount: number) {
    if (isInvincible.value) return
    hp.value -= amount
    isInvincible.value = true
    invincibleTimer.value = 1.5
  }

  function addScore(points: number) {
    score.value += points
  }

  function reset() {
    x.value = 400
    y.value = 500
    hp.value = 3
    score.value = 0
    isInvincible.value = false
    invincibleTimer.value = 0
  }

  return { x, y, speed, hp, score, isInvincible, hitbox, update, takeDamage, addScore, reset }
})

無敵時間の管理もプレイヤーストア内で完結しており、ゲームロジックの凝集性が保たれています。

シーン遷移とゲームフローの統合

最後に、これらのストアを統合してゲームフローを制御します。
シーン遷移ストアが、ゲームの開始から終了までの全体像を把握します。

export const useGameSceneStore = defineStore('gameScene', () => {
  const currentScene = ref<GameScene>('title')
  const gameLoopStore = useGameLoopStore()
  const playerStore = usePlayerStore()
  const enemyStore = useEnemyStore()
  const projectileStore = useProjectileStore()
  const inputStore = useInputStore()
  const saveDataStore = useSaveDataStore()

  function startGame() {
    playerStore.reset()
    enemyStore.reset()
    projectileStore.reset()
    inputStore.setupListeners()
    gameLoopStore.start()
    currentScene.value = 'playing'
  }

  function gameOver() {
    gameLoopStore.stop()
    inputStore.teardownListeners()
    saveDataStore.createSaveSlot('manual', {
      player: playerStore.$state,
      scene: 'gameover'
    })
    currentScene.value = 'gameover'
  }

  function returnToTitle() {
    currentScene.value = 'title'
  }

  watch(() => playerStore.hp, (newHp) => {
    if (newHp <= 0 && currentScene.value === 'playing') {
      gameOver()
    }
  })

  return { currentScene, startGame, gameOver, returnToTitle }
})

watchを使ってプレイヤーのHPを監視し、0になった瞬間にゲームオーバー処理を自動実行します。
これにより、ゲームオーバーの判定ロジックが一箇所に集中し、バグの混入を防げます。

まとめ

このミニゲームの実装を通じて、Piniaの設計パターンがいかに実用的かを確認できたのではないでしょうか。
各ストアが明確な責務を持ち、ゲームループがそれらを統合する。
入力を抽象化し、シーン遷移が全体像を把握する。
この構造は、規模が大きくなっても同じ設計思想を維持できます。
次の章では、より高度なトピックとして、大量のゲームオブジェクトを効率的に管理するパフォーマンス最適化の手法を解説します。

パフォーマンス最適化:大量のゲームオブジェクトを効率的に管理する

大量のゲームオブジェクトをPiniaで効率的に管理する最適化テクニックのアイキャッチ画像

ゲームの規模が大きくなると、管理するオブジェクトの数は爆発的に増加します。
弾幕シューティングの数千発の弾、オープンワールドの数百体のNPC、パーティクルエフェクトの数万個の粒子。
これらをPiniaのリアクティブシステムでそのまま管理しようとすると、フレームレートの低下を招きかねません。
本章では、VueとPiniaの特性を理解した上で、大量のゲームオブジェクトを効率的に管理するテクニックを解説します。

リアクティブシステムのオーバーヘッドを理解する

Vue 3のリアクティブシステムはProxyを利用して、オブジェクトのプロパティアクセスをインターセプトします。
これは非常に強力な仕組みですが、毎フレーム数千回のプロパティアクセスが発生するゲームループでは、オーバーヘッドが無視できません。
特に、配列の要素を頻繁に追加・削除する場合、Vueは差分検出のために内部で多くの処理を行います。

この問題に対処するためには、リアクティブ性が本当に必要なデータと、そうでないデータを区別することが重要です。

ゲームオブジェクトの分類と管理戦略

ゲーム内のオブジェクトは、その性質に応じて管理方法を変えるべきです。

オブジェクトの種類 リアクティブ管理 非リアクティブ管理 理由
プレイヤー状態 必要 不要 UIと頻繁に連携
敵のHPバー表示 必要 不要 画面上に表示される
弾の座標(数千発) 不要 推奨 毎フレーム更新、UI連携不要
パーティクル 不要 推奨 大量かつ寿命が短い
マップ上の静的オブジェクト 不要 推奨 変更頻度が極めて低い

非リアクティブなゲームオブジェクトプール

弾やパーティクルのような大量の一時オブジェクトは、オブジェクトプールパターンと非リアクティブなデータ構造を組み合わせることで、効率的に管理できます。

interface Bullet {
  id: number
  x: number
  y: number
  vx: number
  vy: number
  active: boolean
  lifetime: number
}

export const useBulletSystem = defineStore('bulletSystem', () => {
  const MAX_BULLETS = 5000
  const bullets = new Array<Bullet>(MAX_BULLETS)
  const activeCount = ref(0)
  let nextId = 0

  for (let i = 0; i < MAX_BULLETS; i++) {
    bullets[i] = { id: 0, x: 0, y: 0, vx: 0, vy: 0, active: false, lifetime: 0 }
  }

  function spawn(x: number, y: number, angle: number, speed: number) {
    const bullet = bullets.find(b => !b.active)
    if (!bullet) return

    bullet.id = nextId++
    bullet.x = x
    bullet.y = y
    bullet.vx = Math.cos(angle) * speed
    bullet.vy = Math.sin(angle) * speed
    bullet.active = true
    bullet.lifetime = 3.0
    activeCount.value++
  }

  function update(dt: number) {
    let count = 0
    for (let i = 0; i < MAX_BULLETS; i++) {
      const b = bullets[i]
      if (!b.active) continue

      b.x += b.vx * dt
      b.y += b.vy * dt
      b.lifetime -= dt

      if (b.lifetime <= 0 || b.x < -50 || b.x > 850 || b.y < -50 || b.y > 650) {
        b.active = false
      } else {
        count++
      }
    }
    activeCount.value = count
  }

  function getActiveBullets(): readonly Bullet[] {
    const result: Bullet[] = []
    for (let i = 0; i < MAX_BULLETS; i++) {
      if (bullets[i].active) result.push(bullets[i])
    }
    return result
  }

  return { activeCount, spawn, update, getActiveBullets }
})

この実装では、bullets配列は通常のJavaScript配列として管理し、リアクティブなのはactiveCountだけです。
毎フレーム数千個のオブジェクトをVueのリアクティブシステムに通さず、生のループで更新します。
描画時にはgetActiveBulletsで必要な分だけ取得し、Vueコンポーネントに渡します。

空間分割による衝突判定の最適化

大量のオブジェクトが存在する場合、衝突判定の計算量がO(n²)で増大します。
これを防ぐため、空間分割アルゴリズムの導入を検討すべきです。

interface SpatialGrid {
  cellSize: number
  cells: Map<string, Set<number>>
}

function createSpatialGrid(cellSize: number): SpatialGrid {
  return { cellSize, cells: new Map() }
}

function getCellKey(x: number, y: number, cellSize: number): string {
  const cx = Math.floor(x / cellSize)
  const cy = Math.floor(y / cellSize)
  return `${cx},${cy}`
}

function insertToGrid(grid: SpatialGrid, id: number, x: number, y: number, radius: number) {
  const minCx = Math.floor((x - radius) / grid.cellSize)
  const maxCx = Math.floor((x + radius) / grid.cellSize)
  const minCy = Math.floor((y - radius) / grid.cellSize)
  const maxCy = Math.floor((y + radius) / grid.cellSize)

  for (let cx = minCx; cx <= maxCx; cx++) {
    for (let cy = minCy; cy <= maxCy; cy++) {
      const key = `${cx},${cy}`
      if (!grid.cells.has(key)) grid.cells.set(key, new Set())
      grid.cells.get(key)!.add(id)
    }
  }
}

function queryNearby(grid: SpatialGrid, x: number, y: number, radius: number): Set<number> {
  const result = new Set<number>()
  const cx = Math.floor(x / grid.cellSize)
  const cy = Math.floor(y / grid.cellSize)

  for (let dx = -1; dx <= 1; dx++) {
    for (let dy = -1; dy <= 1; dy++) {
      const key = `${cx + dx},${cy + dy}`
      const cell = grid.cells.get(key)
      if (cell) cell.forEach(id => result.add(id))
    }
  }
  return result
}

この空間分割グリッドにより、衝突判定の対象を近隣のセルに限定できます。
数千個のオブジェクトが存在しても、実際に判定が必要なペアは大幅に削減されます。
ゲームループ内で毎フレームグリッドを再構築し、衝突判定に利用します。

レンダリングの分離と最適化

ゲームロジックの更新と、画面への描画は分離して考えるべきです。
Piniaのストアはゲーム状態の管理に集中し、描画は専用のレンダリングレイヤーに委ねます。

export const useRenderStore = defineStore('render', () => {
  const canvas = ref<HTMLCanvasElement | null>(null)
  const ctx = ref<CanvasRenderingContext2D | null>(null)
  const fps = ref(0)
  let lastTime = performance.now()
  let frameCount = 0

  function init(canvasEl: HTMLCanvasElement) {
    canvas.value = canvasEl
    ctx.value = canvasEl.getContext('2d')
  }

  function render(bulletSystem: ReturnType<typeof useBulletSystem>, playerStore: ReturnType<typeof usePlayerStore>) {
    const context = ctx.value
    if (!context) return

    context.clearRect(0, 0, 800, 600)

    const bullets = bulletSystem.getActiveBullets()
    context.fillStyle = '#ff4444'
    for (const b of bullets) {
      context.beginPath()
      context.arc(b.x, b.y, 3, 0, Math.PI * 2)
      context.fill()
    }

    context.fillStyle = '#44ff44'
    context.beginPath()
    context.arc(playerStore.x, playerStore.y, 15, 0, Math.PI * 2)
    context.fill()

    frameCount++
    const now = performance.now()
    if (now - lastTime >= 1000) {
      fps.value = frameCount
      frameCount = 0
      lastTime = now
    }
  }

  return { canvas, ctx, fps, init, render }
})

Canvas APIを直接使用することで、DOM操作のオーバーヘッドを回避できます。
数千個のオブジェクトをVueのテンプレートでレンダリングするのではなく、一つのCanvas要素にまとめて描画する方が、圧倒的に高速です。

メモリ管理とGCの抑制

JavaScriptのガベージコレクション(GC)は、ゲーム中に発生するとフレームドロップを引き起こします。
オブジェクトの生成と破棄を最小化し、プーリングを徹底することで、GCの発生頻度を抑えることができます。

class ObjectPool<T> {
  private available: T[] = []
  private factory: () => T
  private reset: (obj: T) => void

  constructor(factory: () => T, reset: (obj: T) => void, initialSize: number = 100) {
    this.factory = factory
    this.reset = reset
    for (let i = 0; i < initialSize; i++) {
      this.available.push(factory())
    }
  }

  acquire(): T {
    if (this.available.length > 0) {
      return this.available.pop()!
    }
    return this.factory()
  }

  release(obj: T) {
    this.reset(obj)
    this.available.push(obj)
  }
}

const bulletPool = new ObjectPool<Bullet>(
  () => ({ id: 0, x: 0, y: 0, vx: 0, vy: 0, active: false, lifetime: 0 }),
  (b) => { b.active = false }
)

このプールを使えば、弾の生成時に新しいオブジェクトをnewするのではなく、プールから再利用します。
ゲームの実行中にGCが発生する回数を大幅に減らせます。

まとめ

大量のゲームオブジェクトを管理する際の鍵は、リアクティブ性が本当に必要な箇所を絞り込むことです。
Piniaは強力なツールですが、すべてのデータをリアクティブに管理する必要はありません。
非リアクティブなデータ構造、空間分割、Canvasレンダリング、オブジェクトプーリング。
これらのテクニックを組み合わせることで、数千〜数万のオブジェクトを扱うゲームでも、スムーズな60fpsを維持できるのです。
次の章では、これまでの内容を総括し、Piniaを使ったスケーラブルなゲーム開発の全体像を整理します。

まとめ:Piniaで実現するスケーラブルなゲーム開発

PiniaとVueを組み合わせたスケーラブルなWebゲーム開発の全体像を表現したアイキャッチ画像

本記事を通じて、PiniaとVueを組み合わせたWebゲーム開発の設計思想と実践的なテクニックを解説してきました。
状態管理は、ゲーム開発において最も本質的な課題の一つです。
適切な設計がなければ、コードは複雑化し、バグは増殖し、チームの生産性は低下します。
Piniaは、そのような状況に対して明確な責務分離と型安全性という強力な武器を提供してくれます。

Piniaのストア設計では、state、getters、actionsの3要素を意識的に使い分けることが重要でした。
stateは生のデータを保持し、gettersは純粋な計算を担当し、actionsは状態変更の意図を表現します。
この分離により、ゲームロジックが予測可能でテスト可能な構造を保てます。
特にTypeScriptとの組み合わせにより、コンパイル時に多くの不具合を検出できる点は、大規模なゲーム開発において大きなアドバンテージです。

シーン遷移の型安全な実装は、ゲームのフロー制御を論理的に整理する上で欠かせない手法でした。
有限状態マシンとしてモデリングし、遷移ルールを明示的に定義することで、存在しない状態への遷移や、不正な遷移パターンを排除できます。
さらに、フック機構を導入することで、遷移時の副作用を宣言的に管理できる点も、保守性の高いコードを生み出します。

リアクティブシステムを活かしたUI連携では、Vueの強みを最大限に引き出すアプローチを見てきました。
computedによる派生値のキャッシュ、watchによる副作用の分離、そしてshallowRefによるパフォーマンス最適化。
これらを使い分けることで、ゲーム画面と内部ロジックをシームレスに結びつけつつ、フレームレートを維持できます。

永続化とセーブデータ管理では、localStorageを活用した保存機構の設計と、バージョン管理によるマイグレーションの重要性を学びました。
プレイヤーの時間と努力を守るため、エラーハンドリングと容量管理を徹底することが、信頼性の高いゲーム体験の基盤となります。

実践編のミニゲーム開発では、これらの知識を統合して、ストア間の連携とゲームループの設計を体験しました。
各ストアが明確な責務を持ち、ゲームループが全体をオーケストレーションする構造は、規模が大きくなっても同じ設計思想を維持できます。
入力の抽象化や、シーン遷移とゲームフローの統合は、拡張性と保守性の両立を実現します。

パフォーマンス最適化の章では、リアクティブ性が本当に必要な箇所を絞り込む判断力が求められることを理解しました。
非リアクティブなデータ構造、空間分割アルゴリズム、Canvasレンダリング、オブジェクトプーリング。
これらのテクニックは、大量のゲームオブジェクトを扱う現場でこそ真価を発揮します。

Webゲーム開発は、ブラウザという制約の中で最大の体験を生み出す挑戦です。
Piniaはその挑戦を支える強力な基盤となります。
型安全な状態管理、宣言的なUI連携、モジュール化された設計。
これらを組み合わせることで、小規模なミニゲームから大規模なオンラインゲームまで、スケーラブルに開発を進められるのです。

最後に、設計は常にトレードオフの連続であることを忘れないでください。
完璧な設計は存在しません。
プロジェクトの規模、チームの構成、納期の制約に応じて、適切な粒度と複雑さを見極める判断力が、優れたエンジニアの差異を生みます。
Piniaはその判断力を支えるフレームワークです。
本記事の内容が、皆さんのゲーム開発の一助となれば幸いです。

コメント

タイトルとURLをコピーしました