1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Vue 3でレトロゲームの積みゲー管理帳を作ってみる

1
Last updated at Posted at 2026-08-09

これまで個別に触ってきたVue 3の機能を、そろそろ一つの画面でつなげてみたくなりました。今回は、レトロゲームを登録し、「積み」「プレイ中」「クリア済み」で整理する小さな積みゲー管理帳を作ってみます。

気付くと、まだ始められていないゲームが棚に増えていました。

タイトルだけでなく、今どこまで遊んだかや、次に遊びたい理由も残せたら便利そうです。今回はJavaScriptで、v-modelv-forcomputedv-if、props、emitを組み合わせてみます。

まだ途中段階なので、勘違いなどあれば教えていただけるとうれしいです。

今日やったこと

積みゲーを入力フォームから登録し、カード形式で状態ごとに整理する画面を作ってみます。

棚を見返すと、遊びたいゲームはあるのに「次に何を起動するか」で少し迷います。そこで、タイトル・ハード・状態・状態メモを入力して、自分用の記録として残せる形を考えてみました。

IMG_4935.JPG

キャラクター 「それは衝動買いするからですよ。気になる一本を見つけたときの勢い、なかなか止まりませんね。」

状態メモには「未開封」「序盤で中断中」「説明書を読んでから始めたい」などを書けるようにします。ゲームのセーブデータではありませんが、次に遊ぶときの自分用メモとして使えそうです。

まずは親となるApp.vueで、ゲーム一覧とフォームの入力値を管理します。ハードウェアはメーカーごとにまとめ、ファミリーコンピュータ、スーパーファミコン、メガドライブ、セガサターン、PlayStation、Xbox 360などを選べるようにします。

<script setup>
import { computed, ref } from 'vue'
import GameCard from './components/GameCard.vue'

// メーカーごとにハードをまとめ、選択肢を探しやすくします
const hardwareGroups = [
  {
    label: '任天堂:据え置き機',
    options: ['ファミリーコンピュータ(FC)', 'ファミリーコンピュータ ディスクシステム', 'スーパーファミコン(SFC)', 'NINTENDO 64', 'ニンテンドーゲームキューブ', 'Wii', 'Wii U', 'Nintendo Switch', 'Nintendo Switch 2']
  },
  {
    label: '任天堂:携帯機',
    options: ['ゲーム&ウオッチ', 'ゲームボーイ', 'ゲームボーイカラー', 'ゲームボーイアドバンス', 'ゲームボーイアドバンス SP', 'ゲームボーイミクロ', 'ニンテンドーDS', 'ニンテンドーDSi', 'ニンテンドー3DS', 'Newニンテンドー3DS', 'Nintendo Switch Lite']
  },
  {
    label: 'セガ',
    options: ['SG-1000', 'セガ・マークIII', 'マスターシステム', 'メガドライブ(MD)', 'メガCD', 'スーパー32X', 'セガサターン(SS)', 'ドリームキャスト(DC)', 'ゲームギア']
  },
  {
    label: 'NEC / ハドソン',
    options: ['PCエンジン', 'PCエンジン コアグラフィックス', 'PCエンジン スーパーグラフィックス', 'PCエンジン Duo', 'PCエンジンGT', 'PC-FX']
  },
  {
    label: 'ソニー・インタラクティブエンタテインメント',
    options: ['PlayStation(PS)', 'PlayStation 2(PS2)', 'PlayStation Portable(PSP)', 'PlayStation 3(PS3)', 'PlayStation Vita(PS Vita)', 'PlayStation 4(PS4)', 'PlayStation 5(PS5)']
  },
  {
    label: 'マイクロソフト',
    options: ['Xbox', 'Xbox 360', 'Xbox One', 'Xbox Series X', 'Xbox Series S']
  },
  {
    label: 'その他',
    options: ['NEOGEO', 'NEOGEO CD', 'ワンダースワン', 'ワンダースワンカラー', '3DO REAL', 'Atari 2600', 'Atari Jaguar', 'その他']
  }
]

// 状態フィルターで選べる値です
const statusOptions = ['すべて', '積み', 'プレイ中', 'クリア済み']

// 最初に表示する5件のサンプルゲームです
const games = ref([
  { id: 1, title: '星の迷宮', hardware: 'ファミリーコンピュータ(FC)', status: '積み', note: '箱を見つけたので、説明書も確認したいです' },
  { id: 2, title: '旅するロボット', hardware: 'スーパーファミコン(SFC)', status: 'プレイ中', note: '序盤の町まで進めました' },
  { id: 3, title: '蒼い海の冒険記', hardware: 'メガドライブ(MD)', status: '積み', note: 'まずは操作説明を読んでから始めたいです' },
  { id: 4, title: '月影の探偵団', hardware: 'セガサターン(SS)', status: 'クリア済み', note: 'エンディングまで確認済みです' },
  { id: 5, title: '遺跡の記憶', hardware: 'PlayStation(PS)', status: 'プレイ中', note: '次は地下遺跡を探索するところです' }
])

// フォームとv-modelでつなぐ入力値です
const newTitle = ref('')
const newHardware = ref('ファミリーコンピュータ(FC)')
const newStatus = ref('積み')
const newNote = ref('')

// 一覧を絞り込むために選択する状態です
const selectedStatus = ref('すべて')

// 選択中の状態に合うゲームだけを、画面表示用に取り出します
const filteredGames = computed(() => {
  if (selectedStatus.value === 'すべて') {
    return games.value
  }

  return games.value.filter((game) => game.status === selectedStatus.value)
})

// フォームに入力したゲームをゲーム一覧へ追加します
const addGame = () => {
  if (newTitle.value.trim() === '') {
    return
  }

  games.value.push({
    // 今回は簡単な識別子として、登録時刻をidに使います
    id: Date.now(),
    title: newTitle.value.trim(),
    hardware: newHardware.value,
    status: newStatus.value,
    note: newNote.value.trim()
  })

  // 追加後に次のゲームを入力しやすいよう、フォームを初期化します
  newTitle.value = ''
  newHardware.value = 'ファミリーコンピュータ(FC)'
  newStatus.value = '積み'
  newNote.value = ''
}

// 子コンポーネントから受け取ったidのゲームだけを更新します
const changeStatus = (id) => {
  const targetGame = games.value.find((game) => game.id === id)

  if (!targetGame) {
    return
  }

  // ボタンを押すたびに、状態を次の段階へ切り替えます
  if (targetGame.status === '積み') {
    targetGame.status = 'プレイ中'
  } else if (targetGame.status === 'プレイ中') {
    targetGame.status = 'クリア済み'
  } else {
    targetGame.status = '積み'
  }
}
</script>

<template>
  <main class="game-library">
    <h1>レトロゲーム積みゲー管理帳</h1>
    <p>遊びたいゲームを、今のプレイ状況とメモで整理する一覧です。</p>

    <!-- submit.preventで、送信時の画面再読み込みを防ぎます -->
    <form class="game-form" @submit.prevent="addGame">
      <label for="title">タイトル</label>
      <!-- 入力値をnewTitleへ同期します -->
      <input
        id="title"
        v-model="newTitle"
        type="text"
        placeholder="例:星の迷宮"
        required
      >

      <label for="hardware">ハード</label>
      <!-- optgroupでメーカーごとに選択肢をまとめます -->
      <select id="hardware" v-model="newHardware">
        <optgroup
          v-for="group in hardwareGroups"
          :key="group.label"
          :label="group.label"
        >
          <option
            v-for="hardware in group.options"
            :key="hardware"
            :value="hardware"
          >
            {{ hardware }}
          </option>
        </optgroup>
      </select>

      <label for="new-status">状態</label>
      <select id="new-status" v-model="newStatus">
        <option value="積み">積み</option>
        <option value="プレイ中">プレイ中</option>
        <option value="クリア済み">クリア済み</option>
      </select>

      <label for="note">状態メモ</label>
      <!-- 複数行のメモをnewNoteへ同期します -->
      <textarea
        id="note"
        v-model="newNote"
        placeholder="例:序盤で中断中、次は洞窟を進める"
      ></textarea>

      <button type="submit">ゲームを追加する</button>
    </form>

    <label for="status-filter">表示する状態</label>
    <select id="status-filter" v-model="selectedStatus">
      <option v-for="status in statusOptions" :key="status" :value="status">
        {{ status }}
      </option>
    </select>

    <p>表示中:{{ filteredGames.length }}本</p>

    <section class="game-list">
      <!-- 絞り込み後のゲームをカードとして繰り返し表示します -->
      <GameCard
        v-for="game in filteredGames"
        :key="game.id"
        :game="game"
        @change-status="changeStatus"
      />
    </section>

    <!-- 絞り込み結果が0件のときだけ案内文を表示します -->
    <p v-if="filteredGames.length === 0" class="empty-message">
      この状態のゲームはまだありません。
    </p>
  </main>
</template>

<style scoped>
/* 画面全体の幅を抑え、フォームと一覧を読みやすくします */
.game-library {
  max-width: 800px;
  margin: 0 auto;
  padding: 24px;
}

/* フォーム内の部品を縦に並べます */
.game-form {
  display: grid;
  gap: 8px;
  margin-bottom: 24px;
}

/* ゲームカードを画面幅に合わせて並べます */
.game-list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 16px;
  margin-top: 16px;
}

/* 絞り込み結果がない場合の案内を控えめに表示します */
.empty-message {
  color: #666;
}
</style>

フォームへタイトル・ハード・状態・状態メモを入力して「ゲームを追加する」を押すと、games配列へ新しいデータが加わります。v-forで一覧を作っているため、追加したゲームもカードとして画面に増える形です。

初期データは5件にしています。「積み」が2件、「プレイ中」が2件、「クリア済み」が1件なので、状態フィルターを切り替えたときのcomputedv-forの動きも確認しやすそうです。フォームやイベントの書き方は、Vue.js公式ドキュメントも見返しながら確認したいです。

ゲーム1本分の表示は、GameCard.vueへ分けます。

<script setup>
// 親から受け取るゲーム情報をpropsとして定義します
const props = defineProps({
  game: {
    type: Object,
    required: true
  }
})

// 子から親へ送るイベント名を定義します
const emit = defineEmits(['change-status'])

// ボタン操作時に、親へゲームidを知らせます
const requestStatusChange = () => {
  emit('change-status', props.game.id)
}
</script>

<template>
  <!-- 状態に応じてclass名を追加し、カードの見た目を切り替えます -->
  <article class="game-card" :class="`status-${game.status}`">
    <h2>{{ game.title }}</h2>
    <p>ハード:{{ game.hardware }}</p>
    <p>状態:{{ game.status }}</p>

    <!-- メモがある場合だけ、状態メモを表示します -->
    <p v-if="game.note">メモ:{{ game.note }}</p>
    <p v-else>メモ:なし</p>

    <!-- clickイベントで、親への状態変更依頼を呼び出します -->
    <button type="button" @click="requestStatusChange">
      次の状態へ変更する
    </button>
  </article>
</template>

<style scoped>
/* 1本のゲームを区切って見せるカードの基本スタイルです */
.game-card {
  border: 1px solid #ddd;
  border-radius: 12px;
  padding: 16px;
}

/* :classで付与される状態別の見た目です */
.status-積み {
  border-left: 6px solid #888;
}

.status-プレイ中 {
  border-left: 6px solid #2d8f6f;
}

.status-クリア済み {
  border-left: 6px solid #d29b2f;
}
</style>

image.png

ここをクリックすると動作を確かめられます

親から子へゲーム情報を渡すのがpropsで、カード内のボタン操作を親へ知らせるのがemitです。コンポーネントは、画面の部品を分けて扱うためのものとして、今の自分にはゲームの装備を必要な場所へ付け替えるイメージに少し近く感じます。

つまずいたところ

入力したデータをどこで追加し、状態をどこで更新するかが少し混乱しやすいところでした。

ゲームカードの中に状態変更ボタンがありますが、ゲーム一覧そのものを持っているのは親のApp.vueです。そのため、子のGameCard.vueが直接ゲーム一覧を書き換えるのではなく、親へ「このゲームの状態を変えたい」と伝える形にしています。

キャラクター 「新しいゲームを棚へ入れる処理は、棚全体を持っている親が担当するほうが分かりやすそうですね。」

今の理解では、親はゲーム一覧を管理し、子は受け取った1本のゲームを表示する役割です。子はemitで親に操作を知らせ、親がゲーム一覧を更新します。

また、状態で絞り込む部分はwatchで作るべきか迷いました。今回は「ゲーム一覧と選択中の状態から、画面に出す一覧を作る」だけなので、computedのほうが合いそうに見えています。

今の理解(メモ)

入力値・元データ・表示用データを分けて考えると、少し整理しやすくなりました。

状態フィルターは、ゲーム棚を「積み」「プレイ中」「クリア済み」の箱に分けて眺める感覚に近いです。実際には同じゲーム一覧を持ったまま、条件に合うカードだけを表示しています。

  • refは、フォームの入力値やゲーム一覧のように、画面操作で変化する値を持つために使います
  • v-modelは、入力欄やセレクトボックスとJavaScript側の値を同期するために使います
  • @submit.preventは、フォーム送信時の画面再読み込みを止めて、追加処理を実行するために使います
  • computedは、ゲーム一覧と選択状態から、画面に表示するゲームだけを作るために使います
  • v-forは、ゲームデータやハードウェア選択肢を繰り返し表示するために使います
  • v-ifは、状態メモの有無や絞り込み結果によって表示を切り替えるために使えます
  • propsは親から子へゲーム情報を渡す入口、emitは子から親へ操作を知らせる出口のように見えています

次にやりたいこと

まずは登録と状態変更を確認してから、少しずつ管理帳らしい操作を増やしたいです。

  • タイトル検索を追加します。状態フィルターと検索キーワードを組み合わせ、computedの条件を増やす書き方を試したいです
  • 削除ボタンを追加します。不要になったゲームを一覧から消す操作で、配列更新とv-forの表示変化を確認したいです
  • 状態メモをあとから編集できるようにします。カード表示と入力フォームをどのように切り替えるかを考えたいです
  • watchでゲーム一覧の変更を監視します。まずはconsole.logで変化を確認し、保存処理へつなげられそうか試したいです
  • ローカルストレージへの保存を試します。画面を閉じても入力したゲームが残る仕組みを、少しずつ確認したいです

今日の整理

積みゲーを追加して一覧に表示し、カード操作で状態を更新するまでの流れです。

フォームの入力値はv-modelで管理し、addGame()gamesへ追加します。computedで作ったfilteredGamesv-forでカード表示し、カード側のボタン操作はemitを通じて親のchangeStatus()へ戻る流れです。

おわりに

これまではv-forcomputed、props、emitをそれぞれ別の例で触っていましたが、積みゲー管理帳のような一つの画面に置いてみると、データがどこからどこへ動くのかを少し考えやすくなりました。フォームから入力したデータがカードとして増える流れは、画面の変化も分かりやすそうです。

まだ保存や編集はできない最小構成ですが、レトロゲームを整理する画面なら検索や評価、プレイ記録も追加できそうです。フロント側で「自分が操作すると画面が変わる」感覚をつかむ練習として、少しずつ続けてみたいです。

キャラクター 「積みゲーを増やすのは簡単ですが、遊んだ記録を残すと棚の見え方が少し変わりそうですね。」

学習中の内容なので、勘違いや理解不足もあると思います。もし気になる点があれば教えてもらえるとうれしいです。

 注意事項
 本ブログに掲載している内容は、私個人の見解であり、
 所属する組織の立場や戦略、意見を代表するものではありません。
 あくまでエンジニアとしての経験や考えを発信していますのでご了承ください。

1
0
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?