LoginSignup
3
0
記事投稿キャンペーン 「2024年!初アウトプットをしよう」

ReactでSlotMachineを作る(Step3: 仕様追加、リファクタリング実施)

Last updated at Posted at 2024-01-01

前回までの記事の続きです。(シリーズ化予定)

▪️実装Step
Step1:基本編
Step2:Coin機能実装編
Step3: 仕様追加、リファクタリング実施 ←いまここ
Step4:ワイルドカード、セブンカードの追加
Step5:ディレクトリ整理/リファクタリング

成果物

slot_step2.gif

追加仕様
・当たった果物に応じて、コインの獲得枚数が変化する。

ビジネスロジックが複雑化する上で、実装難易度が上がりデバッグにも時間がかかり、開発生産性が低下します。
今回はビジネスロジックの切り出しとJestを使った検証をスコープにします。

ソースコード

ディレクトリ構成
~/develop/react/react_slot_machine$ tree -I node_modules 
.
├── README.md
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
├── src
│   ├── App.tsx
│   ├── components
│   │   ├── Reel.tsx
│   │   ├── SlotMachine.test.tsx
│   │   ├── SlotMachine.tsx
│   │   ├── const.ts
│   │   ├── func.test.ts
│   │   ├── func.ts
│   │   └── type.ts
│   ├── index.tsx
│   ├── logo.svg
│   └── setupTests.ts
├── tsconfig.json
└── yarn.lock

4 directories, 21 files
src/components/const.ts
import {SymbolTile} from "./type";

export const INITIAL_COINS = 100;
export const BET_COINS = 3;

export const CHERRY_ID = "cherry";
export const LEMON_ID = "lemon";
export const ORANGE_ID = "orange";
export const GRAPE_ID = "grape";
export const WATERMELON_ID = "watermelon";

export const CHERRY_SYMBOL = "🍒";
export const LEMON_SYMBOL = "🍋";
export const ORANGE_SYMBOL = "🍊";
export const GRAPE_SYMBOL = "🍇";
export const WATERMELON_SYMBOL = "🍉";

export const SYMBOLS: SymbolTile[] = [
  {
    id: CHERRY_ID,
    label: CHERRY_SYMBOL,
    point: 10,
  },
  {
    id: LEMON_ID,
    label: LEMON_SYMBOL,
    point: 10,
  },
  {
    id: ORANGE_ID,
    label: ORANGE_SYMBOL,
    point: 10,
  },
  {
    id: GRAPE_ID,
    label: GRAPE_SYMBOL,
    point: 20,
  },
  {
    id: WATERMELON_ID,
    label: WATERMELON_SYMBOL,
    point: 30,
  },
];
src/components/type.ts
import {
  CHERRY_ID,
  CHERRY_SYMBOL,
  GRAPE_ID,
  GRAPE_SYMBOL,
  LEMON_ID,
  LEMON_SYMBOL,
  ORANGE_ID,
  ORANGE_SYMBOL,
  WATERMELON_ID,
  WATERMELON_SYMBOL,
} from "./const";

export type SymbolTile = {
  id: SymbolId;
  label: SymbolLabel;
  point: number;
};

export type SymbolId =
  | typeof CHERRY_ID
  | typeof LEMON_ID
  | typeof ORANGE_ID
  | typeof GRAPE_ID
  | typeof WATERMELON_ID;

export type SymbolLabel =
  | typeof CHERRY_SYMBOL
  | typeof LEMON_SYMBOL
  | typeof ORANGE_SYMBOL
  | typeof GRAPE_SYMBOL
  | typeof WATERMELON_SYMBOL;
src/components/SlotMachine.tsx
import React, {useState} from "react";
import {Reel} from "./Reel";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import {BET_COINS, INITIAL_COINS, SYMBOLS} from "./const"; // SymbolTileのインポート
import {SymbolTile} from "./type";
import {spinReels} from "./func";

export const SlotMachine: React.FC = () => {
  const [coins, setCoins] = useState(INITIAL_COINS);
  const [reels, setReels] = useState<SymbolTile[]>(Array(3).fill(SYMBOLS[0])); // SymbolTile型の配列

  const handleSpin = () => {
    const {newReels, win, points} = spinReels(reels, SYMBOLS, BET_COINS);
    setReels(newReels);
    setCoins((prev) => prev - BET_COINS + (win ? points : 0));
  };
  // TODO リファクタリング前
  //   const handleSpin = () => {
  //     const newReels = reels.map(
  //       () => SYMBOLS[Math.floor(Math.random() * SYMBOLS.length)]
  //     );
  //     setReels(newReels);
  //     setCoins((prev) => prev - BET_COINS);
  //     // 当たり判定
  //     if (new Set(newReels.map((tile) => tile.id)).size === 1) {
  //       setCoins((prev) => prev - BET_COINS + newReels[0].point); // 当たりならコインを追加
  //     }
  //   };

  const isWin = new Set(reels.map((tile) => tile.id)).size === 1;

  return (
    <Box sx={{textAlign: "center", marginTop: 4}}>
      <Box>Coinの枚数:{coins}</Box>
      <Box sx={{display: "flex", justifyContent: "center", gap: 2}}>
        {reels.map((tile, index) => (
          <Reel key={index} symbol={tile.label} />
        ))}
      </Box>
      <Button
        variant="contained"
        color="primary"
        style={{marginTop: "16px"}}
        onClick={handleSpin}
        disabled={coins < BET_COINS}
      >
        スピン
      </Button>
      {isWin && <Box sx={{marginTop: 2}}>勝利!</Box>}
    </Box>
  );
};

注目して欲しいのが、handleSpinの箇所です。
ビジネスロジックが複雑化するにつれ、handleSpinも行数が増え検証に時間がかかります。
フロントエンド開発でデバッグは「コード改修→レンダリング→ブラウザ操作→コンポーネント・ログ確認」で実施する事が一般的です。
ただコードが複雑化するにつれて回数も増えるため、デバッグの時間が長時間化します。そのため、ビジネスロジックは外部関数化し、関数はjest等で検証する方が効率的です。

src/components/func.ts
import {SymbolTile} from "./type";

export const spinReels = (
  currentReels: SymbolTile[],
  symbols: SymbolTile[],
  betCoins: number
): {newReels: SymbolTile[]; win: boolean; points: number} => {
  const newReels = currentReels.map(
    () => symbols[Math.floor(Math.random() * symbols.length)]
  );
  const win = new Set(newReels.map((tile) => tile.id)).size === 1;
  const points = win ? newReels[0].point : 0;

  return {newReels, win, points};
};

複雑化しそうなロジックは外部関数化しました。
この後、Jestで本関数の妥当性を検証していきます。

テストコード

今回は外部関数化したコードをJestで検証したいと思います。

src/components/func.test.ts
import {spinReels} from "./func";
import {SymbolTile} from "./type";

describe("spinReels function", () => {
  // テスト用のシンボルデータ
  const testSymbols: SymbolTile[] = [
    {id: "cherry", label: "🍒", point: 10},
    {id: "lemon", label: "🍋", point: 5},
  ];

  test("should return win true and points when all reels match", () => {
    // モック関数を使用して常に同じシンボルを返すようにする
    jest.spyOn(global.Math, "random").mockReturnValue(0);

    const {newReels, win, points} = spinReels(testSymbols, testSymbols, 3);

    expect(win).toBe(true);
    expect(points).toBe(testSymbols[0].point);
    expect(newReels.every((tile) => tile.id === testSymbols[0].id)).toBe(true);

    // モックをリセット
    jest.spyOn(global.Math, "random").mockRestore();
  });

  test("should return win false and points zero when not all reels match", () => {
    // ランダムな結果を得るためにモックを使用しない
    const {win, points} = spinReels(testSymbols, testSymbols, 3);

    expect(win).toBe(false);
    expect(points).toBe(0);
  });
});
実行結果
~/develop/react/react_slot_machine$ yarn test src/components/func.test.ts
 PASS  src/components/func.test.ts
  spinReels function
    ✓ should return win true and points when all reels match (1 ms)
    ✓ should return win false and points zero when not all reels match

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.285 s, estimated 1 s
Ran all test suites matching /src\/components\/func.test.ts/i.

Active Filters: filename /src/components/func.test.ts/
3
0
0

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
3
0