はじめに
はじめまして、WEB フロントエンドエンジニアの nuintee です。
この度かねてより関心があった Remix に入門する覚悟が出来たので、
その学習過程をアドベントカレンダーにしてみました。
Remix に少しでも興味のある方は、ぜひご覧ください。
フロントエンドの世界 2024 について
「フロントエンドの世界 2024」は普段 Next.js
を書いている筆者が、同じフロントエンドライブラリである Svelte(Kit)
, Remix
,SolidJS
, Qwik(City)
の 4 つにアソート形式で触れ、理解を深めていく様子を収めたアドベントカレンダーです。
もくじ
- はじめに
- フロントエンドの世界 2024 について
- もくじ
- 今回作るモノ
- ディレクトリ構成
- 画面のモック実装
- 共通コンポーネントの追加
- その他のコンポーネント
- コンポーネント呼び出し
- 回答と入力の同期
- カスタムフックの呼び出し
- おわりに
今回作るモノ
今回は PokeAPI を用いたポケモン当てクイズアプリをSvelte
で開発します。
機能的には以下の通りです。
- ランダム出題機能
- 回答判定機能
- ライフ管理
- 制限時間管理
- リザルト機能
ディレクトリ構成
.
├── README.md
├── app
│ ├── entry.client.tsx
│ ├── entry.server.tsx
│ ├── features
│ │ ├── form
│ │ │ └── hooks
│ │ │ └── index.ts
│ │ ├── game
│ │ │ └── cookies
│ │ │ ├── life.server.ts
│ │ │ └── score.server.ts
│ │ ├── pokemon
│ │ │ ├── data
│ │ │ │ └── index.ts
│ │ │ ├── types
│ │ │ │ └── index.ts
│ │ │ └── ui
│ │ │ └── Card
│ │ │ └── index.tsx
│ │ └── time
│ │ └── hooks
│ │ └── index.ts
│ ├── functions
│ │ └── [[path]].ts
│ ├── root.tsx
│ ├── routes
│ │ ├── _index.tsx
│ │ ├── game._index.tsx
│ │ └── game.result.tsx
│ ├── tailwind.css
│ ├── ui
│ │ ├── CharButton
│ │ │ ├── index.tsx
│ │ │ └── type.ts
│ │ ├── Debugger
│ │ │ └── index.tsx
│ │ ├── LifeCounter
│ │ │ └── index.tsx
│ │ ├── Loader
│ │ │ └── index.tsx
│ │ ├── PrimaryButton
│ │ │ ├── index.tsx
│ │ │ └── type.ts
│ │ └── ProgressBar
│ │ ├── index.tsx
│ │ └── type.ts
│ └── utils
│ └── kana
│ └── index.ts
├── functions
│ └── [[path]].ts
├── package.json
├── postcss.config.js
├── public
│ ├── favicon.ico
│ ├── logo-dark.png
│ └── logo-light.png
├── tailwind.config.ts
├── tsconfig.json
├── vite.config.ts
└── wrangler.toml
画面のモック実装
まずは固定値で UI を実装します。
ルート画面
ルート画面はプレイ画面へのリダイレクト処理を追加します。
import { LoaderFunction, redirect } from "@remix-run/node";
export const loader: LoaderFunction = async () => {
// プレイ画面へのリダイレクト
return redirect("/game");
};
リザルト画面
React で慣れ親しんだ JSX 構文でリザルト画面を実装します。
export default function GameResult() {
const score = 3;
return (
<section className="h-full w-full flex items-center justify-center">
<div className="flex flex-col gap-y-4 border p-4 rounded-[10px] border-solid border-[gainsboro]">
<h2 className="text-center text-slate-500">スコア</h2>
<p className="text-center text-2xl">
{score}
<span className="text-sm">点</span>
</p>
{/* PrimaryButton */}
<button
onClick={() => {}}
className="bg-[greenyellow] font-medium max-w-full w-[350px] px-8 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
>
もう一度プレイする
</button>
</div>
</section>
);
}
プレイ画面
同じ要領でプレイ画面も実装します。
この時ページのパスはドット区切りで指定します。
routes/game._index.tsx
(参考: Dot Delimeters)
export default function GamePlay() {
const displayTexts = [
{ char: "カ", index: 1 },
{ char: "ビ", index: 2 },
{ char: "ゴ", index: 3 },
{ char: "ン", index: 4 },
];
// NOTE: ダミー入りの選択肢
const options = [
{ char: "ビ", index: 2 },
{ char: "ゴ", index: 3 },
{ char: "ジ", index: 5 },
{ char: "ゴ", index: 3 },
{ char: "カ", index: 1 },
{ char: "リ", index: 6 },
{ char: "ン", index: 4 },
{ char: "ナ", index: 7 },
];
const pokemon = {
sprites: {
other: {
home: {
front_default:
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/143.png",
},
},
},
};
const lifeCounts = Array.from({ length: 3 }).map((_, i) => i);
return (
<section className="flex flex-col items-center justify-center">
{/* ProgressBar */}
<div className="w-full h-3 bg-[#cbff7e]">
<div
className={`h-full rounded-r-md bg-[yellowgreen] ease-linear`}
style={{ width: `${70}%` }}
></div>
</div>
{/* LifeCounter */}
<div className="flex items-center gap-x-2 px-4 m-4 w-full">
{lifeCounts.map((countId) => (
<div className="text-xl" key={countId}>
❤️
</div>
))}
</div>
<h2 className="flex items-center justify-center text-2xl mt-8">
このポケモンは誰?
</h2>
{/* Card */}
<img
src={pokemon?.sprites.other.home.front_default ?? ""}
alt="ポケモン"
draggable={false}
className="w-[350px] max-w-full h-fit mx-auto"
/>
<div className="flex items-center gap-x-4 h-[70px]">
{displayTexts?.length ? (
<>
<span>名前は</span>
{displayTexts.map((displayText) => (
// CharButton
<button
className="bg-[ghostwhite] px-6 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
onClick={() => {}}
key={`${displayText.char}-${displayText.index}`}
>
{displayText.char}
</button>
))}
<span>です</span>
</>
) : (
<span className="flex items-center justify-center text-gray-400">
👇 クリックで名前を入力して下さい
</span>
)}
</div>
<hr className="w-full" />
<div className="flex items-center gap-x-4 h-[70px] m-2">
{options.map((option) => (
// CharButton
<button
className="bg-[ghostwhite] px-6 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
onClick={() => {}}
key={`${option.char}-${option.index}`}
>
{option.char}
</button>
))}
</div>
{/* PrimaryButton */}
<button
onClick={() => {}}
className="bg-[greenyellow] font-medium max-w-full w-[350px] px-8 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
>
回答する
</button>
</section>
);
}
共通コンポーネントの追加
ui/
配下にアプリケーション全体で使用する共通 UI パーツを追加していきます。
(参考: React - コンポーネントの定義)
CharButton
回答の入力・表示を行うボタンをコンポーネント化します。
// 型定義
import { ReactNode } from "react";
export type CharButtonProps = {
onClick: () => void;
children: ReactNode;
};
// コンポーネント実態
import { CharButtonProps } from "./type";
export const CharButton = ({ onClick, children }: CharButtonProps) => {
return (
<button
className="bg-[ghostwhite] px-6 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
onClick={onClick}
>
{children}
</button>
);
};
PrimaryButton
回答やリトライを担うプライマリーボタンをコンポーネント化します。
import { ReactNode } from "react";
export type PrimaryButtonProps = {
onClick: () => void;
children: ReactNode;
};
import { PrimaryButtonProps } from "./type";
export const PrimaryButton = ({ onClick, children }: PrimaryButtonProps) => {
return (
<button
onClick={onClick}
className="bg-[greenyellow] font-medium max-w-full w-[350px] px-8 py-4 rounded-[10px] hover:opacity-75 active:opacity-50"
>
{children}
</button>
);
};
LifeCounter
残りのライフ表示をコンポーネント化します。
export const LifeCounter = ({ lifeCount }: { lifeCount: number }) => {
const counts = Array.from({ length: lifeCount }).map(
(life, index) => `${life}-${index}`
);
return (
<div className="flex items-center gap-x-2 px-4 m-4 w-full">
{counts.map((countId) => (
<div className="text-xl" key={countId}>
❤️
</div>
))}
</div>
);
};
Loader
ローディング時に表示するアニメーション SVG のコンポーネント化します。
今回はSVG Backgroundsというサイトで生成したRipples
ローダーの色だけ調整してそのまま使用します。
export const Loader = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 200 200">
<circle
fill="none"
strokeOpacity="1"
stroke="#9E9E9E"
strokeWidth=".5"
cx="100"
cy="100"
r="0"
>
<animate
attributeName="r"
calcMode="spline"
dur="2"
values="1;80"
keyTimes="0;1"
keySplines="0 .2 .5 1"
repeatCount="indefinite"
></animate>
<animate
attributeName="stroke-width"
calcMode="spline"
dur="2"
values="0;25"
keyTimes="0;1"
keySplines="0 .2 .5 1"
repeatCount="indefinite"
></animate>
<animate
attributeName="stroke-opacity"
calcMode="spline"
dur="2"
values="1;0"
keyTimes="0;1"
keySplines="0 .2 .5 1"
repeatCount="indefinite"
></animate>
</circle>
</svg>
);
};
ProgressBar
ゲームの制限時間表示をコンポーネント化します。
export type ProgressBarProps = {
value: number;
maxValue: number;
};
import { ProgressBarProps } from "./type";
export const ProgressBar = ({ value, maxValue }: ProgressBarProps) => {
const progress = (value / maxValue) * 100;
const isBellowMax = value < maxValue;
// NOTE: 1000ms分のdelayがあるので-1を終了時間にする
const isAboveMin = value > -1;
const isBetweenRange = isAboveMin && isBellowMax;
return (
<div className="w-full h-3 bg-[#cbff7e]">
<div
// NOTE: 最大値:最大値 = リセット or 終了時なので値は一気に設定する
className={`h-full rounded-r-md bg-[yellowgreen]
ease-linear ${isBetweenRange && "duration-1000"}`}
style={{ width: `${progress}%` }}
></div>
</div>
);
};
Debugger
開発時の値確認用コンポーネントを追加します。
export const Debugger = ({
data,
}: {
data: Record<string, string | number | undefined>;
}) => {
if (process.env.NODE_ENV !== "development") return null;
return (
<div className="absolute top-0 right-0 bg-black bg-opacity-50 text-white">
{Object.keys(data).map((key) => (
<dl key={key} className="flex items-center gap-x-2">
<dt>{key} :</dt>
<dd>{data[key]}</dd>
</dl>
))}
</div>
);
};
その他のコンポーネント
Card
取得したポケモンの画像表示用 img
タグをコンポーネント化します。
alt
が主にポケモンに依存しそうなのでpokemon
というfeature
配下に追加します。
今回のテーマでは無いので雑な設計はご容赦いただけると幸いです 🙇♂️
export const Card = ({ src }: { src: string }) => {
return (
<img
src={src}
alt="ポケモン"
draggable={false}
className="w-[350px] max-w-full h-fit mx-auto"
/>
);
};
コンポーネント呼び出し
ゲーム画面
+ import { CharButton } from "~/ui/CharButton";
+ import { Debugger } from "~/ui/Debugger";
+ import { LifeCounter } from "~/ui/LifeCounter";
+ import { PrimaryButton } from "~/ui/PrimaryButton";
+ import { ProgressBar } from "~/ui/ProgressBar";
+ import { Card } from "~/features/pokemon/ui/Card";
export default function GamePlay() {
+ const timer = 80;
+ const maxTime = 100;
const displayTexts = [
{ char: "カ", index: 1 },
{ char: "ビ", index: 2 },
{ char: "ゴ", index: 3 },
{ char: "ン", index: 4 },
];
// NOTE: ダミー入りの選択肢
const options = [
{ char: "ビ", index: 2 },
{ char: "ゴ", index: 3 },
{ char: "ジ", index: 5 },
{ char: "ゴ", index: 3 },
{ char: "カ", index: 1 },
{ char: "リ", index: 6 },
{ char: "ン", index: 4 },
{ char: "ナ", index: 7 },
];
const pokemon = {
sprites: {
other: {
home: {
front_default:
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/143.png",
},
},
},
};
const lifeCounts = Array.from({ length: 3 }).map((_, i) => i);
return (
<section className="flex flex-col items-center justify-center">
+ <Debugger
+ data={{
+ pokemonAnswer,
+ }}
+ />
{/* ProgressBar */}
- <div className="w-full h-3 bg-[#cbff7e]">
- <div
- className={`h-full rounded-r-md bg-[yellowgreen] -ease-linear`}
- style={{ width: `${70}%` }}
- ></div>
- </div>
+ <ProgressBar value={timer} maxValue={maxTime} />
{/* LifeCounter */}
- <div className="flex items-center gap-x-2 px-4 m-4 w-full">
- {lifeCounts.map((countId) => (
- <div className="text-xl" key={countId}>
- ❤️
- </div>
- ))}
- </div>
+ <LifeCounter lifeCount={lifeCounts} />
<h2 className="flex items-center justify-center text-2xl mt-8">
このポケモンは誰?
</h2>
{/* Card */}
- <img
- src={pokemon?.sprites.other.home.front_default ?? ""}
- alt="ポケモン"
- draggable={false}
- className="w-[350px] max-w-full h-fit mx-auto"
- />
+ <Card src = {pokemon?.sprites.other.home.front_default ?? ""} />
<div className="flex items-center gap-x-4 h-[70px]">
{displayTexts?.length ? (
<>
<span>名前は</span>
{displayTexts.map((displayText) => (
// CharButton
- <button
- className="bg-[ghostwhite] px-6 py-4 rounded-[10px] hover:opacity-75 -active:opacity-50"
- onClick={() => {}}
- key={`${displayText.char}-${displayText.index}`}
- >
- {displayText.char}
- </button>
+ <CharButton
+ onClick={() => {}}
+ key={`${displayText.char}-${displayText.index}`}
+ >
+ {displayText.char}
+ </CharButton>
))}
<span>です</span>
</>
) : (
<span className="flex items-center justify-center text-gray-400">
👇 クリックで名前を入力して下さい
</span>
)}
</div>
<hr className="w-full" />
<div className="flex items-center gap-x-4 h-[70px] m-2">
{options.map((option) => (
// CharButton
- <button
- className="bg-[ghostwhite] px-6 py-4 rounded-[10px] - hover:opacity-75 active:opacity-50"
- onClick={() => {}}
- key={`${option.char}-${option.index}`}
- >
- {option.char}
- </button>
+ <CharButton
+ onClick={() => {}}
+ key={`${option.char}-${option.index}`}
+ >
+ {option.char}
+ </CharButton>
))}
</div>
{/* PrimaryButton */}
- <button
- onClick={() => {}}
- className="bg-[greenyellow] font-medium max-w-full w-- [350px] px-8 py-4 rounded-[10px] hover:opacity-75 - active:opacity-50"
- >
- 回答する
- </button>
+ <PrimaryButton onClick={() => {}}>回答する</PrimaryButton>
</section>
);
}
回答と入力の同期
入力選択肢操作用のカスタムフックuseForm
を作成します。
key 生成用ヘルパー関数
受け取った文字と index
を結合してリスト用のキーを生成する処理を関数化します。
const getFormKey = (displayText: FormType) =>
`${displayText.char}-${displayText.index}`;
回答欄への入力追加
選択肢から入力されたものを削除し、回答欄へ追加する処理を関数化します。
const addDisplayText = useCallback((displayText: FormType) => {
setValues((prev) => {
const removedOptions = prev.options.filter(
(option) => JSON.stringify(option) !== JSON.stringify(displayText)
);
const addedDisplayText = [...prev.displayTexts, displayText];
return {
options: removedOptions,
displayTexts: addedDisplayText,
};
});
}, []);
回答欄からの選択削除
回答欄から選択されたものを削除し、選択肢へ戻す処理を関数化します。
const removeDisplayText = useCallback((displayText: FormType) => {
setValues((prev) => {
const addedOptions = [...prev.options, displayText];
const removedDisplayTexts = prev.displayTexts.filter(
(display) => JSON.stringify(display) !== JSON.stringify(displayText)
);
return {
options: addedOptions,
displayTexts: removedDisplayTexts,
};
});
}, []);
選択肢の初期化
正解の文字列を受け取ってそれを元にランダム化した選択肢を生成する処理を関数化します。
カタカナ生成・加工用のヘルパー関数
utils/
配下に受け取った文字とランダムなカタカナを 3 つ合わせてシャッフルして返すヘルパー関数を定義します。
const generateKatakanaArray = () => {
const startCode = 0x30a1; // 「ァ」から
const endCode = 0x30fa; // 「ヺ」まで
const katakanaArray = [];
for (let code = startCode; code <= endCode; code++) {
katakanaArray.push(String.fromCharCode(code));
}
return katakanaArray;
};
export const getMergedKanas = (chars: string[]) => {
// 上で作成したヘルパー関数
const notIncludedKanas = generateKatakanaArray().filter(
(kana) => !chars.includes(kana)
);
const shuffledKanas = notIncludedKanas.sort(() => 0.5 - Math.random());
const randomPickedKanas = shuffledKanas.slice(0, 3);
return [...randomPickedKanas, ...chars];
};
const initOptions = useCallback((answer: string) => {
const splittedAnswer = answer.split("");
const kanasWithDummy = getMergedKanas(splittedAnswer);
const shuffledAnswerChars = kanasWithDummy.sort(() => 0.5 - Math.random());
const options = shuffledAnswerChars?.map((char, index) => ({
char,
index,
}));
setValues({ options, displayTexts: [] });
}, []);
コード全文
import { useCallback, useState } from "react";
import { getMergedKanas } from "~/utils/kana";
type FormType = {
char: string;
index: number;
};
type FormValues = {
options: FormType[];
displayTexts: FormType[];
};
export const useForm = () => {
const [values, setValues] = useState<FormValues>({
options: [],
displayTexts: [],
});
const getFormKey = (displayText: FormType) =>
`${displayText.char}-${displayText.index}`;
const initOptions = useCallback((answer: string) => {
const splittedAnswer = answer.split("");
const kanasWithDummy = getMergedKanas(splittedAnswer);
const shuffledAnswerChars = kanasWithDummy.sort(() => 0.5 - Math.random());
const options = shuffledAnswerChars?.map((char, index) => ({
char,
index,
}));
setValues({ options, displayTexts: [] });
}, []);
const addDisplayText = useCallback((displayText: FormType) => {
setValues((prev) => {
const removedOptions = prev.options.filter(
(option) => JSON.stringify(option) !== JSON.stringify(displayText)
);
const addedDisplayText = [...prev.displayTexts, displayText];
return {
options: removedOptions,
displayTexts: addedDisplayText,
};
});
}, []);
const removeDisplayText = useCallback((displayText: FormType) => {
setValues((prev) => {
const addedOptions = [...prev.options, displayText];
const removedDisplayTexts = prev.displayTexts.filter(
(display) => JSON.stringify(display) !== JSON.stringify(displayText)
);
return {
options: addedOptions,
displayTexts: removedDisplayTexts,
};
});
}, []);
return {
options: values.options,
displayTexts: values.displayTexts,
initOptions,
addDisplayText,
removeDisplayText,
getFormKey,
};
};
カスタムフックの呼び出し
export default function GamePlay() {
const timer = 80;
const maxTime = 100;
- const displayTexts = [
- { char: "カ", index: 1 },
- { char: "ビ", index: 2 },
- { char: "ゴ", index: 3 },
- { char: "ン", index: 4 },
- ];
- // NOTE: ダミー入りの選択肢
- const options = [
- { char: "ビ", index: 2 },
- { char: "ゴ", index: 3 },
- { char: "ジ", index: 5 },
- { char: "ゴ", index: 3 },
- { char: "カ", index: 1 },
- { char: "リ", index: 6 },
- { char: "ン", index: 4 },
- { char: "ナ", index: 7 },
- ];
const pokemon = {
+ name: "カビゴン",
sprites: {
other: {
home: {
front_default:
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/home/143.png",
},
},
},
};
// NOTE: 非同期で取得する内容
+ const pokemonAnswer = pokemon.name
const lifeCounts = Array.from({ length: 3 }).map((_, i) => i);
+ const {
+ options,
+ displayTexts,
+ initOptions,
+ addDisplayText,
+ removeDisplayText,
+ getFormKey,
+ } = useForm();
+ useEffect(() => {
+ if (!pokemonAnswer) return;
+
+ // 正解を元に選択肢を初期化
+ initOptions(pokemonAnswer);
+ }, [initOptions, pokemonAnswer]);
return (
<section className="flex flex-col items-center justify-center">
<Debugger
data={{
pokemonAnswer,
}}
/>
<ProgressBar value={timer} maxValue={maxTime} />
<LifeCounter lifeCount={life} />
<h2 className="flex items-center justify-center text-2xl mt-8">
このポケモンは誰?
</h2>
<Card src={pokemon?.sprites.other.home.front_default ?? ""} />
<div className="flex items-center gap-x-4 h-[70px]">
{displayTexts?.length ? (
<>
<span>名前は</span>
{displayTexts.map((displayText) => (
<CharButton
+ onClick={() => removeDisplayText(displayText)}
+ key={getFormKey(displayText)}
>
{displayText.char}
</CharButton>
))}
<span>です</span>
</>
) : (
<span className="flex items-center justify-center text-gray-400">
👇 クリックで名前を入力して下さい
</span>
)}
</div>
<hr className="w-full" />
<div className="flex items-center gap-x-4 h-[70px] m-2">
{options.map((option) => (
<CharButton
+ onClick={() => addDisplayText(option)}
+ key={getFormKey(option)}
>
{option.char}
</CharButton>
))}
</div>
<PrimaryButton onClick={() => {}}>回答する</PrimaryButton>
</section>
);
}
おわりに
UI 部分を React でサクッと作りました。
UI 部分という事もあって今回はまだまだ React React していましたが、
Remix
の真髄はこれからなので是非次回をお楽しみに!
また本シリーズを通してお気軽にコメントお待ちしております。
また完走賞も目指しているので是非応援お願いします!
この記事は フロントエンドの世界 Advent Calendar 2024の 10 記事目です。
次の記事はこちら Remix の世界: データ取得と状態管理 #4