年末年始に向けてスイッチで４人で遊べるゲームがほしい話

## はじめに

本日(12/25(日))はクリスマスの休日ですが皆様いかがお過ごしでしょうか？
コロナ禍で皆で集まるのもまだまだ難しい状況かとは思いますが、年末年始の休暇に向けて**多人数で遊べるゲーム**がほしくないですか？

**ほしいですよね！？**

そこで以下のようなキーワードでググってみることにしました。

- [switch 4人プレイ](https://www.google.com/search?q=switch+4人プレイ)
- [switch パーティ](https://www.google.com/search?q=switch+パーティ)
- [switch 協力プレイ](https://www.google.com/search?q=switch+協力プレイ)
- [switch co-op](https://www.google.com/search?q=switch+co-op)

が、検索結果には以下の様な課題があり、どうにも欲しいものを探すことができませんでした。

- 見たことがあるゲームしか出てこない
- 4人用ではないものも検索される

仕方がないので初心にかえって任天堂の公式サイトを見てみるも、やはり4人用のゲームを探す方法は分かりませんでした。

- [My Nintendo Store](https://store-jp.nintendo.com/software/)


エンジニアとしてはないものは作ればいいのでは？ということで何か作れないか考えることにしました。


## ゲーム一覧の取得

ゲームの一覧が取得できれば、そこから4人用のゲームを探すことはできそうです。
ということで、APIがないか探してみることにしました。

- [グーグル検索：nintendo eshop api](https://www.google.com/search?q=nintendo+eshop+api)

何かいろいろヒットしますが、公式のAPIはなさそうです。
ただ、xml 形式で公開しているデータはありそうでした。

- [switch ゲーム一覧(xml)](https://www.nintendo.co.jp/data/software/xml/switch.xml)

これを見ると**5872本**ものゲームがリリース(予定含む)されていることが分かります。(2022/12/25時点)
宝の山にしか見えないですね！

xml ではなく、json で取得したかったので、api を利用することにしました。
- APIでゲーム一覧取得
  - [nintendo-switch-eshop](https://www.npmjs.com/package/nintendo-switch-eshop)

```js
// ゲーム一覧取得
async function getGames() {
  const games = await getGamesJapan();
  fs.writeFileSync(OUT_FOLDER + 'games.json', JSON.stringify(games), 'utf-8');
  return games;
}
```

## 4人用ゲームの抽出 : 問題編

残念なことに、取得できたゲーム一覧の情報には、プレイ人数が含まれていません。
公式のAPIは存在しないため、プレイ人数をきれいな形で取得するのは難しそうです。

タイトルは分かったので、5872回『商品をさがす』から検索して、ページをチクチク開いて確認すれば目的は達成できそうです。

ただ、楽しい年末年始のためとはいえエンジニアがやる作業ではなさそうです。

もはやこれまででしょうか…


## 4人用ゲームの抽出 : 解決編1

よくよく見るとという項目があります。
```
<LinkURL>/titles/{何かのID}</LinkURL>
````

一方、My Nintendo Store のURLは以下の様な形式です。
このページには「プレイ人数」という項目があります。
```
https://store-jp.nintendo.com/list/software/{何かのID}.html
```

ページをザクザク開いて、その部分を抽出できれば4人用ゲームだけ抽出することができそうです。

- APIで取得できないデータはサイトをスクレイピングして取得
  - [got](https://www.npmjs.com/package/got) : http client
  - [cheerio](https://www.npmjs.com/package/cheerio) : html parser

```js
  // got で page を取得し、cheerio で解析 
  const response = await got(gameUrl(game));
  const $ = cheerio.load(response.body);

  // プレイ人数記載部分を解析し、key, value に格納 (ページによって記載方法が違うためとりあえず)
  const _players = $('table.productDetail--spec tr.productDetail--spec__player')
  _players.each((i, e) => {
    players.push({
      key: $('th', e).text(),
      value: $('td', e).text()
    });
  });
```

## 4人用ゲームの抽出 : 問題編2

驚くことに、プレイ人数にはとてもたくさんの種類がありました。

```
1人
2人
1〜2人
1〜3人
2〜3人
1〜4人
2〜4人
4人
1〜5人
2〜5人
5人
2〜6人
1〜6人
4〜6人
2〜7人
1〜8人
2〜8人
5〜8人
4〜8人
8人
1〜9人
2〜9人
1〜10人
2〜10人
1〜12人
2〜12人
1〜14人
2〜14人
1〜15人
4〜15人
1〜16人
1〜20人
2〜20人
1〜30人
4〜32人
1〜32人
2〜35人
1〜42人
1〜55人
1〜60人
60人
1〜64人
1〜70人
1〜99人
1〜100人
1〜200人
1〜999人
```

## 4人用ゲームの抽出 : 解決編2

厳密な4人用ではなくても4人以上で遊べればよいので、見落としなくすべてのソフトを確認したいですし、さらにはキーワードやタグなんかも指定して検索したくなってきました。

検索結果にヒットしたゲームをすべて購入するわけにもいきません。検索結果をクリックしたら、eshop にいってそのまま詳細を見れたほうがいいでしょう。

ということでそんな感じのUIを作ることにしました。


(そんな感じのUI)
![ss.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/136165/89ffde54-fd09-17eb-9a6c-f8daceb7884d.png)

UI制作には Nuxt.js + Vuetify を利用しました。もはやローコードと言っていい程度のコード量で制作できました。
(Vue.js 単体でもよいのですが、Vuetify を使う設定が面倒だったので Nuxt.js を利用しました。)

- [Nuxt.js](https://nuxtjs.org/)
  - [Vuetify](https://vuetifyjs.com/ja/)
    - [Data tables](https://vuetifyjs.com/ja/components/data-tables/)
    - [Autocompletes](https://vuetifyjs.com/ja/components/autocompletes/)

```vue
<template>
  <div>
    <v-row>
      <v-col cols="2" class="teal lighten-5">
        <v-text-field
          v-model="searchTitle"
          label="タイトル"
          prepend-icon="mdi-magnify"
          hide-details
          dense
        />
        <v-autocomplete
          v-model="selectedMaker"
          :items="makers"
          label="メーカー"
          chips
          small-chips
          clearable
          dense
          class="mt-8"
        />
        <v-autocomplete
          v-model="selectedCategories"
          :items="categories"
          label="カテゴリ"
          multiple
          chips
          small-chips
          deletable-chips
          clearable
          dense
          class="mt-8"
        />
        <v-autocomplete
          v-model="selectedPlayers"
          :items="players"
          label="プレイ人数"
          multiple
          chips
          small-chips
          deletable-chips
          clearable
          dense
          class="mt-8"
        />
        <v-select
          v-model="selectedMinPrice"
          :items="PRICES"
          label="価格(MIN)"
          clearable
          dense
          class="mt-8"
        />
        <v-select
          v-model="selectedMaxPrice"
          :items="PRICES"
          label="価格(MAX)"
          clearable
          dense
          class="mt-4"
        />
      </v-col>
      <v-col cols="10">
        <v-data-table
          :headers="gameHeaders"
          :items="filteredGames"
          :footer-props="{
            'items-per-page-options': [ 200 ]
          }"
          :ites-per-page="200"
          height="calc(100vh - 192px)"
          fixed-header
          dense
        >
          <template #[`item.TitleName`]="{ item }">
            <div class="d-flex __clickable" @click="onShowPage(item)">
              <v-img
                :src="`/images/eshop/games/${item.InitialCode}.jpg`"
                max-width="172"
              />
              <div>
                <div class="text-h6 pa-1">
                  {{ item.TitleName }}
                </div>
                <div class="mt-2">
                  <v-chip
                    v-for="(c, i) in item._categories"
                    :key="i"
                    x-small
                    class="mx-1 mb-1"
                    @click.stop="onClickCategory(c)"
                  >
                    {{ c }}
                  </v-chip>
                </div>
              </div>
            </div>
          </template>
          <template #[`item._players`]="{ item }">
            <div
              v-for="(p, i) in item._players"
              :key="i"
              class="d-flex"
            >
              <div class="__players__key">
                {{ p.key }}
              </div>
              <div class="__players__value">
                {{ p.value }}
              </div>
            </div>
          </template>
        </v-data-table>
      </v-col>
    </v-row>
  </div>
</template>

<script>
import GAMES from '~/assets/eshop/games.json'
import DETAILS from '~/assets/eshop/index.js'

const BASE_URL = 'https://store-jp.nintendo.com/list/software/'

// 最大プレイ人数を取得
function maxPlayerCount (v) {
  const counts = v.replace('人', '').split('〜').map(v => Number(v)).filter(v => !isNaN(v))
  const count = counts.reduce((a, v) => Math.max(a, Number(v)), 0)
  return count
}

export default {
  name: 'GameList',
  data: () => {
    const PRICES = ['500', '1000', '1500', '2000', '3000', '4000', '5000']

    const games = GAMES
      .map((g) => {
        const detail = DETAILS[g.InitialCode]
        g.TitleName = String(g.TitleName)
        g._price = Number(g.Price.replace(/[^0-9a-z]/gi, ''))
        g._categories = detail.categories
        g._players = detail.players
        return g
      })
    const makers = games.map(g => g.MakerName).sort()
    const categories = Array.from(new Set(games.flatMap(g => g._categories))).sort()
    const players = Array.from(new Set(games.flatMap(g => g._players).map(p => p.value))).sort((v0, v1) => {
      // 最大プレイ人数順でソート
      return maxPlayerCount(v0) - maxPlayerCount(v1)
    })
    return {
      PRICES,
      games,
      makers,
      categories,
      players,
      searchTitle: '',
      selectedMaker: null,
      selectedCategories: [],
      selectedPlayers: [],
      selectedMinPrice: '',
      selectedMaxPrice: '',
      selectedGame: null
    }
  },
  computed: {
    gameHeaders () {
      return [
        { text: 'タイトル', value: 'TitleName' },
        { text: 'メーカー', value: 'MakerName' },
        { text: '価格', value: '_price', align: 'end' },
        { text: '発売日', value: 'SalesDate', align: 'end' },
        { text: 'プレイ人数', value: '_players' }
      ]
    },
    filteredGames () {
      let games = this.games

      if (this.selectedMaker) {
        games = games.filter(g => this.selectedMaker === g.MakerName)
      }
      if (this.selectedCategories.length > 0) {
        games = games.filter(g => g._categories.some(c => this.selectedCategories.includes(c)))
      }
      if (this.selectedPlayers.length > 0) {
        games = games.filter(g => g._players.some(p => this.selectedPlayers.includes(p.value)))
      }
      if (this.selectedMinPrice !== '') {
        const price = Number(this.selectedMinPrice)
        games = games.filter(g => g._price >= price)
      }
      if (this.selectedMaxPrice !== '') {
        const price = Number(this.selectedMaxPrice)
        games = games.filter(g => g._price <= price)
      }
      if (this.searchTitle !== '') {
        games = games.filter(g => ~g.TitleName.indexOf(this.searchTitle))
      }
      return games
    },
    showPageUrl () {
      if (!this.selectedGame) {
        return ''
      }
      return BASE_URL + this.selectedGame.LinkURL.replace('/titles/', '') + '.html'
    }
  },
  methods: {
    onShowPage (game) {
      this.selectedGame = game
      window.open(this.showPageUrl, '_blank')
    },
    onClickCategory (c) {
      if (!this.selectedCategories.includes(c)) {
        this.selectedCategories.push(c)
      }
    }
  }
}
</script>
<style lang="scss" scoped>
.__clickable {
  cursor: pointer;
}
.__players__key {
  width: 144px;
}
.__players__value {
  width: 60px;
}
</style>

```


## サムネイル画像について
xml に含まれている `ScreenshotImgURL` をそのまま使うことはできません。
画像への直リンクは禁止されていますし、サムネで表示するには大きすぎる画像なので、リサイズして保存することにしました。

- 画像を取得してリサイズして保存
  - [axios](https://www.npmjs.com/package/axios) : http client
  - [sharp](https://www.npmjs.com/package/sharp) : resize

```js
  // screen shot を取得してリサイズして保存
  const res = await axios.get(game.ScreenshotImgURL, { responseType: 'arraybuffer' });
  const thumbnail = await sharp(res.data)
    .resize(320)
    .jpeg()
    .toBuffer();
  fs.writeFileSync(file, thumbnail, 'binary');
```

## まとめ
とても短いコードで4人用ゲームを探せる様になりました。
個人で利用できればと思いましたが、他の方にも需要あるかと思い本稿を起稿しました。
年末年始のゲームライフ・プログラミングライフの充実の一助となれれば幸いです。

また、switch は素晴らしいインディーズゲームがたくさん出ているプラットフォームだと思います。
これらのゲームが人目に触れず埋もれるのはもったいないので色々見てもらえたらいいなと思います。

## おまけ
また、本稿のプログラムは github にプログラム学習リソースとして公開しています。
よろしければ合わせてご参照ください。
※本稿のプログラムは、あくまで個人で楽しむ範囲でご利用ください。

[SESビジネスプログラマのためのプログラミング練習帳](https://github.com/kaku3/workbooks)/03_switch_eshop