TypeScriptでスプラトゥーン3ブキ射程2択クイズアプリを作成した


# はじめに

学習を目的としてTypeScriptを使用してシンプルな2択クイズアプリ「スプラトゥーン3 射程クイズゲーム」を開発しました。2つのブキの中から射程が長いと思うブキを選択するゲームです。スマートフォンのブラウザでプレイすることを想定しています。

https://splatoon-buki-quiz.vercel.app/

https://github.com/oda-dot/splatoon-buki-quiz

# 使った技術やツール

主に以下の技術やツールを使用しました。今回はフレームワークの有用性を理解するために、敢えてフレームワークを使用せずに開発を行いました。また、プレイヤー情報やクイズレコードの保存なども行わないためデータベースも使用していません。

- TypeScript
- HTML
- CSS
- Vite
- Vercel

# 環境構築

- gitのインストール
- Githubでリポジトリ作成
	- git cloneできなかったので、以下を行った
		- SSH認証の設定（SSHキーの発行、GithubでSSHキー登録）
		- プロジェクトのディレクトリを、`/OneDrive/ドキュメント`から`/OneDrive配下`に変更（仕組み不明）
- node.jsのインストール
	- Powershellではnodeコマンドが使えるが、VSCodeのGitbashだと使えなかった。環境変数の編集、PC再起動を行うと解決した。
- npmのインストール
- TypeScriptのインストール
	- `npm install -g typescript`
- Viteのインストール
	- `npm create vite@latest`
- tsconfig.jsonを作成
	- Node.jsがtypescriptを認識するために必要な設定ファイル
- VercelとGithubリポジトリを連携

# ゲームの流れ

1. 「スタート」をクリックするとクイズスタート
2. 異なる2つのブキの画像がランダムに表示される
3. 2つのブキのうち、射程が長いと思うブキの画像をクリックする
4. 正解であれば「○」、不正解であれば「✕」が表示され次の問題へ
5. 10問目に回答すると結果画面が表示される

# 主要な機能

## クイズデータの取得と管理

クイズのデータはweapon.jsonから読み込まれ、ランダムに2つのブキが選ばれます。

```ts
function getRandomWeaponsIds() {
    // weapon.jsonから全てのidを取得
    const ids = weaponsData.map(weapon => weapon.id);
    
    // ids配列からランダムにidを選択
    // Math.floor(Math.random() * ids.length)は、0からidsの最大インデックス（もしlengthが5であれば最大インデックスは4）の値をとる
    const randomId1 = ids[Math.floor(Math.random() * ids.length)];
    let randomId2 = ids[Math.floor(Math.random() * ids.length)];
    // 同じidが選ばれないようにする
    while(randomId1 === randomId2) {
        randomId2 = ids[Math.floor(Math.random() * ids.length)];
    }

    // 選択したidに基づいてブキのデータを取得
    weapon1Id = weaponsData.find(weapon => weapon.id === randomId1)?.id ?? 0;
    weapon2Id = weaponsData.find(weapon => weapon.id === randomId2)?.id ?? 0;
    if (!weapon1Id || !weapon2Id) {
        console.error('エラー：ブキのデータ取得');
    }
    return { weapon1Id, weapon2Id };
}
```

## 画面の切り替え

スタート画面、クイズ画面、結果画面はURLを変えずに遷移させたかったので、あらかじめ全ての画面要素をHTMLに記述して、hiddenクラスを使って表示状態を操作しています。

```ts
    // スタートボタンを押したとき
    startButton.addEventListener('click', function() {
        // 画面の切り替え
        startScreen.classList.add(STYLE_HIDDEN);
        quizScreen.classList.remove(STYLE_HIDDEN);
```

## 回答履歴の保持

回答履歴は、回答時にquestionHistory配列に要素を追加することで管理しています。

```ts
// 出題履歴と回答履歴を保持するための配列
// ts(7034)回避のために変数の型を定義する（let questionHistory = [];だと型推論が機能しない）
type QuestionHistory = {
    question: {
        questionNumber: number;
        weapon1Id: number;
        weapon2Id: number;
    },
    result: {
        userAnswer: number,
        correctAnswer: number,
        isAnswerCorrect: boolean;
    }
};
let questionHistory: QuestionHistory[] = [];
```

```ts
// questionHistoryへのデータ追加を行う関数
function addQuestionHistory(questionNumberCount: number, weapon1Id: number, weapon2Id: number, userAnswer: number, correctAnswer: number) {
    questionHistory.push({
        question: {
            questionNumber: questionNumberCount,
            weapon1Id: weapon1Id,
            weapon2Id: weapon2Id,
        },
        result: {
            userAnswer: userAnswer,
            correctAnswer: correctAnswer,
            isAnswerCorrect: userAnswer === correctAnswer
        }
    });
    console.debug(questionHistory);
}
```

# 工夫したレイアウト

## 結果画面

結果は表で表示したかったので、CSSグリッドレイアウトを使用してみました。また、選択したブキがわかるように、ユーザーが選択したブキには背景を、そうでないブキは白黒表示にしています。

```ts
        selectedWeapon.classList.add('selected-weapon-background');

        // ユーザが選択したブキの方だけカラー表示とクラス付与を行う
        if (history.question.weapon1Id !== history.result.userAnswer) {
            img1.style.filter = 'grayscale(100%)';
            img1.style.opacity = '0.5';

            weapon2Cell.appendChild(selectedWeapon);
        } else {
            img2.style.filter = 'grayscale(100%)';
            img2.style.opacity = '0.5';

            weapon1Cell.appendChild(selectedWeapon);
        }
```

```css
/* ぼかした丸を後ろに重ねたい */
.selected-weapon-background {
  z-index: 1;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 25px;
  height: 25px;
  border-radius: 70% 50% 70% 60% / 40% 80% 60% 70%;
  background-color: #EAFF3D;
}
```

## フォント

スプラトゥーンっぽいデザインにしたかったので、カスタムフォントを適用しています。漢字に対応していないフォント、アルファベットのみのフォントなどが混在しているので、Unicodeでフォントを適用する文字を指定しています。

```css
@font-face {
  font-family: 'CustomFont1';
  src: url('src/assets/fonts/CP_Revenge.ttf') format('truetype');
  unicode-range:
     U+30FC, /* 長音記号 */
    U+4E00-9FAF, /* 漢字の範囲 */
    U+0030-0039; /* 数字 0-9 */
}

@font-face {
  font-family: 'CustomFont2';
  src: url('src/assets/fonts/IGAFONT.ttf') format('truetype');
  unicode-range: 
    U+3041-309F, /* ひらがな */
    U+30A1-30FA; /* カタカナ */
  }

@font-face {
    font-family: 'Paintball_Beta_3';
    src: url('src/assets/fonts/Paintball_Beta_3.ttf') format('truetype');
}

/* font-weight:normalを@font-faceに指定しても変わらなかったが、h1など個別に指定すると適用された */
/* テキストに若干の角度をつけて日本語フォントが崩れるのを防ぐ */
/* .text {
  transform: rotate(0.05deg);
} */

h1, h2, p, div, text{
  font-weight: normal;
  transform: rotate(0.05deg);
}

body {
  font-family: 'CustomFont1', 'CustomFont2', 'Paintball_Beta_3',  sans-serif;
}
```

# 感想

- Vercelを使ったデプロイが簡単だった。Githubにプッシュしただけでビルドしたファイルをアップロードもせずに変更が適用されていたときは驚いた。
- 初めてTypeScriptを使ったが変数のnullチェックが面倒に感じた。こういうものなのか、何かよい解決策があるのかわからないままに終わった。
```ts
if (startButton && toTitleButton &&
	    startScreen && quizScreen && resultScreen &&
	    weapon1Element && weapon2Element && weapon1Img && weapon2Img &&
	    questionNumberElement &&
	    answerModal && answerImage)
```
- ディレクトリ構成やコードの分割化などを工夫してコードの可読性を上げたい。実際にデバッグや機能追加する際にストレスに感じた。
- レイアウト、CSSが難しかった。CSSフレームワークを使うと楽になると聞くので次回は採用してみたい。

# 参考文献

- [npmのグローバルインストールとローカルインストール #Node.js - Qiita](https://qiita.com/kijitoraneko/items/175ef29d45d155b3f405)
- [tsconfig.jsonを設定する | TypeScript入門『サバイバルTypeScript』 (typescriptbook.jp)](https://typescriptbook.jp/reference/tsconfig/tsconfig.json-settings)
- [ECMAScript って何かをざっくり知っておく #JavaScript - Qiita](https://qiita.com/kotaro-caffeinism/items/bcb6866f07aae4eed679)
- [TypeScript: TSConfig リファレンス - すべてのTSConfigのオプションのドキュメント (typescriptlang.org)](https://www.typescriptlang.org/ja/tsconfig/#target)
- [tsconfig.jsonのよく使いそうなオプションを理解する](https://zenn.dev/chida/articles/bdbcd59c90e2e1)
- [Node.js ES2015/ES6, ES2016 and ES2017 support](https://node.green/)
- [tsconfig.jsonを書くときはTSConfig Basesを使うと便利](https://qiita.com/munieru_jp/items/a67ac782bbf099d90128)
- [vanillaのTypeScriptでToDoアプリを作成](https://qiita.com/masayasviel/items/577020381900c529a1cc)
- [Viteでvanilla-tsアプリを作成する](https://qiita.com/miruon/items/58b9cc4dbabc3f945ede)
- [【イラスト解説】CSSの positionプロパティについてまとめてみた (youtube.com)](https://www.youtube.com/watch?v=4d4UxPq5kZo)
- [Inkipedia, the Splatoon wiki](https://splatoonwiki.org/wiki/Main_Page)
- [SPAってページ遷移するの？ #JavaScript - Qiita](https://qiita.com/ozaki25/items/41e3af4679c81a204284)
- [TypeScriptの型入門 #TypeScript - Qiita](https://qiita.com/uhyo/items/e2fdef2d3236b9bfe74a)
- [Unicode文字一覧表 - instant tools (m-bsys.com)](https://tools.m-bsys.com/ex/unicode_table.php)
- [CSS: カスケーディングスタイルシート | MDN (mozilla.org)](https://developer.mozilla.org/ja/docs/Web/CSS)
- [日本語Webフォント( M PLUS 1p)がつぶれる＆ジャギる問題を解決する | Blog | 株式会社イロコト | ゲーム･アニメ等のエンタメ系Web制作&運用会社 (irokoto.co.jp)](https://irokoto.co.jp/blog/20200911/post-16)