はじめに
学習を目的としてTypeScriptを使用してシンプルな2択クイズアプリ「スプラトゥーン3 射程クイズゲーム」を開発しました。2つのブキの中から射程が長いと思うブキを選択するゲームです。スマートフォンのブラウザでプレイすることを想定しています。
使った技術やツール
主に以下の技術やツールを使用しました。今回はフレームワークの有用性を理解するために、敢えてフレームワークを使用せずに開発を行いました。また、プレイヤー情報やクイズレコードの保存なども行わないためデータベースも使用していません。
- TypeScript
- HTML
- CSS
- Vite
- Vercel
環境構築
- gitのインストール
- Githubでリポジトリ作成
- git cloneできなかったので、以下を行った
- SSH認証の設定(SSHキーの発行、GithubでSSHキー登録)
- プロジェクトのディレクトリを、
/OneDrive/ドキュメント
から/OneDrive配下
に変更(仕組み不明)
- git cloneできなかったので、以下を行った
- 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リポジトリを連携
ゲームの流れ
- 「スタート」をクリックするとクイズスタート
- 異なる2つのブキの画像がランダムに表示される
- 2つのブキのうち、射程が長いと思うブキの画像をクリックする
- 正解であれば「○」、不正解であれば「✕」が表示され次の問題へ
- 10問目に回答すると結果画面が表示される
主要な機能
クイズデータの取得と管理
クイズのデータはweapon.jsonから読み込まれ、ランダムに2つのブキが選ばれます。
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クラスを使って表示状態を操作しています。
// スタートボタンを押したとき
startButton.addEventListener('click', function() {
// 画面の切り替え
startScreen.classList.add(STYLE_HIDDEN);
quizScreen.classList.remove(STYLE_HIDDEN);
回答履歴の保持
回答履歴は、回答時にquestionHistory配列に要素を追加することで管理しています。
// 出題履歴と回答履歴を保持するための配列
// ts(7034)回避のために変数の型を定義する(let questionHistory = [];だと型推論が機能しない)
type QuestionHistory = {
question: {
questionNumber: number;
weapon1Id: number;
weapon2Id: number;
},
result: {
userAnswer: number,
correctAnswer: number,
isAnswerCorrect: boolean;
}
};
let questionHistory: QuestionHistory[] = [];
// 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グリッドレイアウトを使用してみました。また、選択したブキがわかるように、ユーザーが選択したブキには背景を、そうでないブキは白黒表示にしています。
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);
}
/* ぼかした丸を後ろに重ねたい */
.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でフォントを適用する文字を指定しています。
@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チェックが面倒に感じた。こういうものなのか、何かよい解決策があるのかわからないままに終わった。
if (startButton && toTitleButton &&
startScreen && quizScreen && resultScreen &&
weapon1Element && weapon2Element && weapon1Img && weapon2Img &&
questionNumberElement &&
answerModal && answerImage)
- ディレクトリ構成やコードの分割化などを工夫してコードの可読性を上げたい。実際にデバッグや機能追加する際にストレスに感じた。
- レイアウト、CSSが難しかった。CSSフレームワークを使うと楽になると聞くので次回は採用してみたい。
参考文献
- npmのグローバルインストールとローカルインストール #Node.js - Qiita
- tsconfig.jsonを設定する | TypeScript入門『サバイバルTypeScript』 (typescriptbook.jp)
- ECMAScript って何かをざっくり知っておく #JavaScript - Qiita
- TypeScript: TSConfig リファレンス - すべてのTSConfigのオプションのドキュメント (typescriptlang.org)
- tsconfig.jsonのよく使いそうなオプションを理解する
- Node.js ES2015/ES6, ES2016 and ES2017 support
- tsconfig.jsonを書くときはTSConfig Basesを使うと便利
- vanillaのTypeScriptでToDoアプリを作成
- Viteでvanilla-tsアプリを作成する
- 【イラスト解説】CSSの positionプロパティについてまとめてみた (youtube.com)
- Inkipedia, the Splatoon wiki
- SPAってページ遷移するの? #JavaScript - Qiita
- TypeScriptの型入門 #TypeScript - Qiita
- Unicode文字一覧表 - instant tools (m-bsys.com)
- CSS: カスケーディングスタイルシート | MDN (mozilla.org)
- 日本語Webフォント( M PLUS 1p)がつぶれる&ジャギる問題を解決する | Blog | 株式会社イロコト | ゲーム・アニメ等のエンタメ系Web制作&運用会社 (irokoto.co.jp)