【React】RPG風のゲーム画面を実装する方法

## はじめに
はじめまして、橋田至です。

https://x.com/dall_develop

私は普段受託開発の会社でサラリーマンエンジニアとして勤務しつつ、平日仕事終わりや休日に個人開発を行っております。

今回は自分の自己紹介サイトにてRPG風の画面を作成したので、その作った流れなどを詳しく解説していきます。

## 作ったサイト

https://my-dq-portfolio.vercel.app/

動作画面例:

![Videotogif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/706631/315c068e-457b-173d-11c4-72e3c0bb9727.gif)

GitHub

https://github.com/developerhost/my-dq-portfolio

今回の機能を実装したPR

https://github.com/developerhost/my-dq-portfolio/pull/46

### 使用技術
- TypeScript
- React
- Vite
- Tanstack Router
- tailwind css

## 今回やったこと

ホーム画面から「冒険をする」をクリックするとゲーム画面が表示されるようにしています

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/706631/65893e61-35d3-721e-42cb-67ea4569f1c5.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/706631/24702494-c8fe-cae3-d5f0-20a15b3e35f5.png)


ちなみにゲーム画面は微妙にPCの幅に対応できていないので、スマホではなくPCから開くとちょっと画面に違和感があります。

実装した内容としては以下です

- 壁の判定
  - 主人公は壁を貫通できない
- オブジェクト判定
  - 主人公は壁を貫通できない
  - 隣接するマスに対してAボタンを押すと会話画面が表示される
  - 再度Aボタンを押すと会話が閉じる
- タイル判定
  - 主人公はタイルを通過できる
- 十字キーは長押しに対応し、クリックで長押ししている場合はその間キャラクターが移動する

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/706631/1763fa65-bf31-dc9a-4e07-dca9fc5b3f7c.png)

### 実装の詳細

まずはroom/というページを作成しています

```tsx:src/routes/room/index.lazy.tsx
import { createLazyFileRoute } from '@tanstack/react-router';

import { Tile } from './-components/Tile';
import { useMessage } from './-hooks/useMessage';

import ChatMessage from '@/components/ChatMessage';
import GameController from '@/components/GameController';
import { TILES } from '@/constants';
import { useHeroMovement } from '@/hooks/useHeroMovement';

export const Room = () => {
  const roomMap = [
    [9, 9, 9, 9, 9, 9, 9, 9, 9],
    [9, 4, 5, 8, 8, 8, 0, 6, 9],
    [9, 8, 8, 8, 8, 8, 8, 8, 9],
    [9, 8, 8, 8, 1, 8, 8, 2, 9],
    [9, 9, 9, 9, 9, 9, 9, 9, 9],
  ];

  const initialPosition = { row: 1, col: 6 };

  const { heroPosition, moveHero } = useHeroMovement(initialPosition, roomMap);
  const {
    message,
    handleTileClick,
    handleAButtonPress,
    treasureRedGoldTaken,
    treasureGreenGoldTaken,
  } = useMessage();

  return (
    <div className="min-h-screen bg-black text-white flex flex-col items-center">
      {/* ゲーム画面 */}
      <div className="relative w-full max-w-4xl aspect-video bg-black border-2 border-gray-700">
        {/* タイル表示 */}
        <div className="grid grid-cols-9 gap-0.5 w-full h-full">
          {roomMap.flatMap((row, rowIndex) =>
            row.map((tile, colIndex) => {
              const isHeroPosition =
                rowIndex === heroPosition.row && colIndex === heroPosition.col;
              const isPreviousHeroPosition = roomMap[rowIndex][colIndex] === 0;

              const type = isHeroPosition
                ? TILES.HERO
                : isPreviousHeroPosition
                  ? TILES.FLOOR
                  : tile;

              return (
                <div
                  className="flex items-center justify-center bg-gray-800 border border-gray-700"
                  key={`${rowIndex}-${colIndex}`}
                >
                  <Tile
                    isTreasureGreenGoldTaken={treasureGreenGoldTaken}
                    isTreasureRedGoldTaken={treasureRedGoldTaken}
                    onClick={() => handleTileClick(type)}
                    type={type}
                  />
                </div>
              );
            })
          )}
        </div>
        {/* チャット表示 */}
        {message && (
          <div className="absolute bottom-4 left-1/2 -translate-x-1/2 w-4/5 bg-black bg-opacity-70 p-4 rounded border border-gray-500">
            <ChatMessage message={message} />
          </div>
        )}
      </div>
      <GameController
        moveHero={moveHero}
        onAButtonPress={() => handleAButtonPress(heroPosition, roomMap)}
      />
    </div>
  );
};

export const Route = createLazyFileRoute('/room/')({
  component: Room,
});

```

ここは本当はmapもちゃんとコンポーネントに分割して、TileMapみたいなコンポーネントを作ったほうが良いかなと思いつつ、まだやっていないです。

実装としては

- フロア一つ一つのタイル
- チャット画面
- コントローラー部分

をコンポーネント化しています

そして、hooksパターンを用いてロジック部分はできるだけhooksとして切り出しています。

ここもテストしやすいようにuseStateなどのstateから完全に分離された純粋なロジックだけのTypeScriptファイルに関数を分離し、それに対して単体テストを書くみたいなところまで後々やりたいと思ってます。

一応各コンポーネントの実装の詳細も載せておきます。

タイルコンポーネントでは、roomMapで定義した0~9の数字から対応する1マスの画像をswitch文で表示するということを行っています。

どのタイルがどの数字に対応するのか覚えられないので、roomMapという二次元配列自体をオブジェクト定数を代わりに使用するなどして後々改善したいポイントになります

```tsx:src/routes/room/-components/Tile.tsx
import TileContent from './TileContent';

import Floor from '@/assets/img/tile/floor.svg';
import { TILES } from '@/constants';

interface TileProps {
  isTreasureGreenGoldTaken: boolean;
  isTreasureRedGoldTaken: boolean;
  onClick: () => void;
  type: number;
}

export const Tile = ({
  type,
  onClick,
  isTreasureRedGoldTaken,
  isTreasureGreenGoldTaken,
}: TileProps) => {
  return (
    <div
      className="relative w-full h-full"
      onClick={onClick}
      onKeyDown={(e) => {
        if (e.key === 'Enter' || e.key === ' ') {
          onClick(); // Enterキーまたはスペースキーでクリックをシミュレート
        }
      }}
      role="button"
      tabIndex={0}
    >
      <TileContent
        isTreasureGreenGoldTaken={isTreasureGreenGoldTaken}
        isTreasureRedGoldTaken={isTreasureRedGoldTaken}
        onClick={onClick}
        type={type}
      />
      {type !== TILES.WALL && (
        <img alt="Floor" className="w-full h-full absolute z-0" src={Floor} />
      )}
    </div>
  );
};

```

```tsx:src/routes/room/-components/TileContent.tsx
import Cat from '@/assets/img/character/cat.svg';
import Hero from '@/assets/img/character/hero.svg';
import Murabito from '@/assets/img/character/murabito.svg';
import Bed from '@/assets/img/object/bed.svg';
import Floor from '@/assets/img/tile/floor.svg';
import Wall from '@/assets/img/tile/wall.svg';
import TreasureGreenGold from '@/assets/img/treasure/treasure_green_gold.svg';
import TreasureGreenGoldEmpty from '@/assets/img/treasure/treasure_green_gold_empty.svg';
import TreasureRedGold from '@/assets/img/treasure/treasure_red_gold.svg';
import TreasureRedGoldEmpty from '@/assets/img/treasure/treasure_red_gold_empty.svg';
import { TILES } from '@/constants';

interface TileContentProps {
  isTreasureGreenGoldTaken: boolean;
  isTreasureRedGoldTaken: boolean;
  onClick: () => void;
  type: number;
}

const TileContent = ({
  type,
  isTreasureRedGoldTaken,
  isTreasureGreenGoldTaken,
  onClick,
}: TileContentProps) => {
  const handleKeyDown = (event: React.KeyboardEvent<HTMLImageElement>) => {
    if (event.key === 'Enter' || event.key === ' ') {
      onClick();
    }
  };

  const interactiveProps = {
    role: 'button',
    tabIndex: 0,
    onClick,
    onKeyDown: handleKeyDown,
  };

  switch (type) {
    case TILES.HERO:
      return (
        <img
          alt="Hero"
          className="w-full h-full absolute z-10"
          src={Hero}
          {...interactiveProps}
        />
      );
    case TILES.MURABITO:
      return (
        <img
          alt="Murabito"
          className="w-full h-full absolute z-10"
          src={Murabito}
          {...interactiveProps}
        />
      );
    case TILES.CAT:
      return (
        <img
          alt="Cat"
          className="w-full h-full absolute z-10"
          src={Cat}
          {...interactiveProps}
        />
      );
    case TILES.TREASURE_RED_GOLD:
      return (
        <img
          alt="Treasure Red Gold"
          className="w-full h-full absolute z-10"
          src={isTreasureRedGoldTaken ? TreasureRedGoldEmpty : TreasureRedGold}
          {...interactiveProps}
        />
      );
    case TILES.TREASURE_GREEN_GOLD:
      return (
        <img
          alt="Treasure Green Gold"
          className="w-full h-full absolute z-10"
          src={
            isTreasureGreenGoldTaken
              ? TreasureGreenGoldEmpty
              : TreasureGreenGold
          }
          {...interactiveProps}
        />
      );
    case TILES.BED:
      return (
        <img alt="Bed" className="w-full h-full absolute z-10" src={Bed} />
      );
    case TILES.WALL:
      return <img alt="Wall" src={Wall} />;
    default:
      return <img alt="Floor" className="w-full h-full absolute" src={Floor} />;
  }
};

export default TileContent;

```

`@/hooks/useHeroMovement`では主人公が進むことができるタイルを制御しています。

```ts:@/hooks/useHeroMovement
import { useState } from 'react';

import { useKey } from 'react-use';

import { TILES } from '@/constants';

interface Position {
  col: number;
  row: number;
}

interface DirectionMap {
  ArrowDown: 'ArrowDown';
  ArrowLeft: 'ArrowLeft';
  ArrowRight: 'ArrowRight';
  ArrowUp: 'ArrowUp';
}

type Direction = DirectionMap[keyof DirectionMap];

function canMoveToTile(tile: number): boolean {
  return tile === TILES.HERO || tile === TILES.FLOOR;
}

export function useHeroMovement(
  initialPosition: Position,
  roomMap: number[][]
) {
  const [heroPosition, setHeroPosition] = useState(initialPosition);

  const moveHero = (direction: Direction) => {
    setHeroPosition((prevPosition) => {
      const { row, col } = prevPosition;

      // 0と8のタイルは通行可能
      const moveMap: Record<Direction, Position> = {
        ArrowUp:
          row > 0 && canMoveToTile(roomMap[row - 1][col])
            ? { row: row - 1, col }
            : prevPosition,
        ArrowDown:
          row < roomMap.length - 1 && canMoveToTile(roomMap[row + 1][col])
            ? { row: row + 1, col }
            : prevPosition,
        ArrowLeft:
          col > 0 && canMoveToTile(roomMap[row][col - 1])
            ? { row, col: col - 1 }
            : prevPosition,
        ArrowRight:
          col < roomMap[row].length - 1 && canMoveToTile(roomMap[row][col + 1])
            ? { row, col: col + 1 }
            : prevPosition,
      };

      return moveMap[direction];
    });
  };

  // キーボード入力を監視してキャラクターを移動させる
  useKey('ArrowUp', () => moveHero('ArrowUp'));
  useKey('ArrowDown', () => moveHero('ArrowDown'));
  useKey('ArrowLeft', () => moveHero('ArrowLeft'));
  useKey('ArrowRight', () => moveHero('ArrowRight'));

  return { heroPosition, moveHero };
}

```

`src/routes/room/-hooks/useMessage.ts`では、主人公が話しかけたときにチャットを表示する機能を制御しています。
ここは今主人公の向きを設定できていないため、猫とベッドが隣接するマス目に主人公が存在したり、主人公が初期値にいる場合にバグっぽい挙動が発生する感じになっているので後々修正したいです。

```ts:src/routes/room/-hooks/useMessage.ts
import { useState } from 'react';

import { TILES } from '@/constants';

/**
 * メッセージを表示するためのカスタムフック
 */
export function useMessage() {
  const [message, setMessage] = useState('');
  const [treasureRedGoldTaken, setTreasureRedGoldTaken] = useState(false);
  const [treasureGreenGoldTaken, setTreasureGreenGoldTaken] = useState(false);

  const handleTileClick = (type: number) => {
    switch (type) {
      case TILES.HERO:
        setMessage('こんにちは、私は橋田至です！');
        break;
      case TILES.MURABITO:
        setMessage('村人: ようこそ、冒険者！');
        break;
      case TILES.CAT:
        setMessage('猫: にゃーん');
        break;
      case TILES.TREASURE_RED_GOLD:
        if (treasureRedGoldTaken) {
          setMessage('宝箱は空のようだ');
        } else {
          setMessage('TypeScriptを手に入れた!');
          setTreasureRedGoldTaken(true);
        }
        break;
      case TILES.TREASURE_GREEN_GOLD:
        if (treasureGreenGoldTaken) {
          setMessage('宝箱は空のようだ');
        } else {
          setMessage('Reactを手に入れた!');
          setTreasureGreenGoldTaken(true);
        }
        break;
      default:
        setMessage('');
        break;
    }
  };

  const handleAButtonPress = (
    heroPosition: { col: number; row: number },
    roomMap: number[][]
  ) => {
    if (message) {
      // メッセージがすでに表示されている場合はクリア
      setMessage('');
      return;
    }

    const directions = [
      { rowOffset: -1, colOffset: 0 }, // 上
      { rowOffset: 1, colOffset: 0 }, // 下
      { rowOffset: 0, colOffset: -1 }, // 左
      { rowOffset: 0, colOffset: 1 }, // 右
    ];

    for (const { rowOffset, colOffset } of directions) {
      const neighborRow = heroPosition.row + rowOffset;
      const neighborCol = heroPosition.col + colOffset;

      if (
        neighborRow >= 0 &&
        neighborRow < roomMap.length &&
        neighborCol >= 0 &&
        neighborCol < roomMap[0].length
      ) {
        const tileType = roomMap[neighborRow][neighborCol];
        if (tileType !== TILES.FLOOR && tileType !== TILES.WALL) {
          handleTileClick(tileType);
          return; // 最初に見つかったオブジェクトのメッセージを表示
        }
      }
    }

    setMessage('近くに何もないようだ');
  };

  return {
    message,
    handleTileClick,
    handleAButtonPress,
    treasureRedGoldTaken,
    treasureGreenGoldTaken,
  };
}

```

`GameController`では矢印キーを押したときやコントローラーの矢印ボタンをクリックで長押ししたときに主人公が移動するようにしています。

ここは一番工夫した点で、普通のwebサイトにおいて長押しの機能を実装しているものをほとんど見たことがないし、webにもあんまり情報がなかったので、この実装を解説します。

```tsx:src/components/GameController.tsx
import React from 'react';

import { useLongPress } from '@/hooks/useLongPress';

interface GameControllerProps {
  moveHero: (
    direction: 'ArrowUp' | 'ArrowDown' | 'ArrowLeft' | 'ArrowRight'
  ) => void;
  onAButtonPress: () => void;
}

const GameController: React.FC<GameControllerProps> = ({
  moveHero,
  onAButtonPress,
}) => {
  const handleUp = useLongPress(() => moveHero('ArrowUp'), 100);
  const handleDown = useLongPress(() => moveHero('ArrowDown'), 100);
  const handleLeft = useLongPress(() => moveHero('ArrowLeft'), 100);
  const handleRight = useLongPress(() => moveHero('ArrowRight'), 100);

  return (
    <div className="flex flex-col items-center p-4 bg-gray-700 rounded-lg shadow-lg border-4 border-gray-800 w-full ">
      <div className="flex justify-between items-center w-full">
        {/* 矢印ボタンエリア */}
        <div className="relative w-32 h-32 bg-gray-800 rounded-full border-4 border-gray-800 flex items-center justify-center">
          <button
            aria-label="上"
            className="absolute top-2 left-1/2 -translate-x-1/2 w-8 h-8 bg-gray-300 rounded-md shadow-inner hover:bg-gray-400 active:bg-gray-500"
            {...handleUp}
          />
          <button
            aria-label="下"
            className="absolute bottom-2 left-1/2 -translate-x-1/2 w-8 h-8 bg-gray-300 rounded-md shadow-inner hover:bg-gray-400 active:bg-gray-500"
            {...handleDown}
          />
          <button
            aria-label="左"
            className="absolute left-2 top-1/2 -translate-y-1/2 w-8 h-8 bg-gray-300 rounded-md shadow-inner hover:bg-gray-400 active:bg-gray-500"
            {...handleLeft}
          />
          <button
            aria-label="右"
            className="absolute right-2 top-1/2 -translate-y-1/2 w-8 h-8 bg-gray-300 rounded-md shadow-inner hover:bg-gray-400 active:bg-gray-500"
            {...handleRight}
          />
        </div>

        <button
          aria-label="Aボタン"
          className="ml-8 w-16 h-16 bg-red-800 hover:bg-red-900 active:bg-red-800 rounded-full shadow-md border-4 border-red-900 flex items-center justify-center text-white font-bold text-xl"
          onClick={onAButtonPress}
        >
          A
        </button>
      </div>
    </div>
  );
};

export default GameController;

```

まず、`useLongPress`というカスタムフックをインポートしています。

このフックは、ボタンが長押しされたときに特定のアクションを実行するために使用しています。

`GameController`コンポーネントは、`moveHero`と`onAButtonPress`をプロパティとして受け取り、それぞれのボタンに対応するハンドラーを設定します。

handleUp、handleDown、handleLeft、handleRightは、useLongPressフックを使用して、対応する方向にヒーローを移動させる関数を設定してます。

コンポーネントのレンダリング部分では、矢印ボタンとAボタンが配置されています。

矢印ボタンは、ヒーローを上下左右に移動させるためのもので、それぞれのボタンにhandleUp、handleDown、handleLeft、handleRightが適用されています。Aボタンは、onAButtonPress関数を実行するために使用されます。

```ts:src/hooks/useLongPress.ts
import { useRef } from 'react';

type LongPressSet = {
  onContextMenu: (event: React.MouseEvent) => void;
  onMouseDown: () => void;
  onMouseLeave: () => void;
  onMouseUp: () => void;
  onTouchEnd: () => void;
  onTouchStart: () => void;
};

/**
 * 長押しイベントを処理するためのカスタムフック。
 *
 * @param callback - 長押しが発生したときに呼び出されるコールバック関数。
 * @param ms - 長押しと見なすまでの時間（ミリ秒）。
 * @returns 長押しイベントを処理するためのイベントハンドラを含むオブジェクト。
 */
export const useLongPress = (
  callback: () => void,
  ms: number
): LongPressSet => {
  const timeout = useRef<NodeJS.Timeout | null>(null);

  const start = () => {
    if (timeout.current) return; // すでにタイマーが動作中の場合は何もしない
    timeout.current = setInterval(callback, ms);
  };

  const stop = () => {
    if (timeout.current) {
      clearInterval(timeout.current);
      timeout.current = null;
    }
  };

  const preventContextMenu = (event: React.MouseEvent) => {
    event.preventDefault(); // 右クリックメニューを無効化
  };

  return {
    onMouseDown: start,
    onMouseUp: stop,
    onMouseLeave: stop,
    onTouchStart: start,
    onTouchEnd: stop,
    onContextMenu: preventContextMenu,
  };
};

```

`useLongPress.ts`では、Reactで長押しイベントを処理するためのカスタムフックを定義しています。

このフックは、ユーザーが特定の時間（ミリ秒単位）以上ボタンを押し続けた場合にコールバック関数を呼び出します。

フックは、useRefを使用してタイマーを管理し、複数のイベントハンドラを返します。

まず、useRefを使ってタイマーを保持するためのtimeoutを定義します。

start関数は、タイマーがすでに動作中でない場合に、指定された時間間隔でコールバック関数を呼び出すためのインターバルを設定します。

stop関数は、タイマーが動作中の場合にインターバルをクリアし、timeoutをリセットします。

preventContextMenu関数は、右クリックメニューを無効化するために使用されます。
これは、長押しイベントが右クリックによって中断されないようにするためです。

最後に、`useLongPress`は、onMouseDown、onMouseUp、onMouseLeave、onTouchStart、onTouchEnd、およびonContextMenuの各イベントハンドラを含むオブジェクトを返します。これにより、これらのイベントを処理するためのハンドラをコンポーネントに簡単に適用できます。

useEffectを使うことなく、できるだけ副作用の少なく分かりやすいコードをかけた気がしているのですが、もっとこうしたら簡潔に書けるよなどあったら教えてもらえると嬉しいです。

## 今後のTODO
- [ ] キャラをもっと動いている感じを出す
- [ ] キャラに対して向きを設定する
  - [ ] キャラの横向きや後ろ向きのsvgを作れずに現状断念。お絵かきの才能欲しい
- [ ] キャラの行動を4方向に向けるのではなく、向きに対してのみのアクションにする
- [ ] マップを拡張する
- [ ] オブジェクトを増やす
- [ ] 効果音を追加する
- [ ] ユーザーメニューを追加する


など

元々このサイトを作った目的としては、普段自分がリモートワークで働いていることもあり、一緒に働く開発者やPMなどの人となりが分からず、どういったコミュニケーションを取ればよいか分からなかったり、その人のスキルに合ったレビューなどを行えたほうがみんなが快適に楽しく働けるし、プロジェクト全体の開発効率が上がるなと感じたため、まずは自分から自己開示を行っていきたい。

でもただテキストで自分の情報を載せても面白くない。
せっかくなら遊び心を加えたサイトを作りたいなと思ったという理由があります。

このサイトのおかげで他のエンジニアと交流する際もこのサイトを見せることで話が弾み、作るの楽しいし作ってよかったなと感じています。

未経験者向けのポートフォリオとしてこのリポジトリをフォークして自分用にカスタマイズしてもらって全く問題ないので、ぜひともオリジナリティのある自己紹介サイトをみなさんが作ってくれたらなと思います!

## 最後に

今回の記事ではゲーム画面に焦点を当てて解説しましたが、他のページも頑張って作ったので見てもらえると嬉しいです。

もしこの記事が参考になれば、いいねやGitHubのリポジトリにスターなどしていただけるとモチベーションになるので非常に嬉しいです。

FBやコメントも大歓迎なので、ぜひともよろしくお願いします。

https://github.com/developerhost/my-dq-portfolio

## 関連

https://qiita.com/app_js/items/110f7ee36eeef9790c81

https://qiita.com/app_js/items/71e33180c777453773f6