♠️

オンラインUNOゲーム開発【Reactでカードアニメーション編】

に公開

はじめに

Reactが好きな人は多いと思います。宣言的で書きやすいですし、周辺ライブラリが充実していて大体何でも作れます。
また、ゲーム開発も好きな人も多いと思います。開発のネタとしてちょうど良いですし、オリジナリティも出しやすいです。

しかし、Reactでゲームを開発したという話はあまり聞きません。さらにオンラインゲームとなるほとんど聞いたことがないです。

そこで本記事では、Phaserなどのゲームエンジンを使わずに、出来るだけReact周辺技術を使ってオンラインUNOゲームにおけるカードを切るアニメーションの作り方を解説します。

以下の動画のようなアニメーションを作ることがゴールとなります。

こちらから直接操作することも出来ます。ぜひカードをクリックしてみてください。
https://675e379efccdafaac8e8b2d8-yubywqqjzf.chromatic.com/?path=/story/components-game-gamefield--discard-card

記事執筆時点でのGitHubレポジトリはこちらです。
https://github.com/nebular-lab/card-animation/tree/747cca69df6e7a8c7b70d8f89ba3c5a108934f72

環境構築

UNOのようなオンラインゲームを作るには、双方向のデータ通信が必要です。そのため、今回はWebSocketを利用することにしました。さらに、フロントエンドの開発を効率的に進めるために、WebSocket APIの通信部分はMock Service Worker(MSW)を使ってモック化しました。

また、Storybookも導入しました。Storybookを使えば、コンポーネントごとに複数のStory(ユースケース)を作成できます。例えば、UNOゲームの主要なアクションである「カードを切る」「カードを引く」「パスをする」に対応したStoryを作成し、それぞれにMockHandlerを設定すると、個別に動作確認をしながら開発が進められます。

Storybookはmswを使ったモックのためのアドオンも提供しているため、スムーズに環境構築することが出来ます。

環境構築の際に参考にした公式ドキュメントと記事を以下に挙げます

https://mswjs.io/docs/basics/handling-websocket-events/
https://mswjs.io/docs/network-behavior/websocket/
https://zenn.dev/rabbit/articles/dd9b04940b93fe

ゲーム状態の同期

オンラインゲームでは、対戦相手とゲームの状態を同期させる方法を検討する必要があります。同期方法はゲームの種類によって異なりますが、本記事で扱うUNOゲームでは、以下の方針を採用します。

  • ゲームの状態(gameState)はサーバーで一元管理する
  • 各プレイヤーは自分のアクション(カードを切る、パスをするなど)をサーバーに送信する
  • サーバーはそのアクションに基づいてgameStateを更新し、全プレイヤーに対して最新の状態とアクション内容を送信する
  • クライアント側では受信したアクションを基にアニメーションを行い、更新されたgameStateをもとにReactの状態を更新する

以下は通信フローを示したシーケンス図です。

ここでのポイントは、アニメーションとReactの状態の更新の発火トリガーを「カードのクリック」ではなく「サーバーからの受信」としている点です。自分のアクションの場合も、他プレイヤーのアクションの場合も、サーバーのデータ受信をトリガーに統一することで、処理を書きやすくしています。

アクションの型定義

プレイヤーがサーバーに送信するアクションを型定義します。ゲーム状態の更新とアニメーションを行うための必要十分な情報を表すことを意識しています。

ちょっとした工夫ですが、各アクションにkindフィールドを持たせてunion型で連結させて型の堅牢性を向上させています。いわゆる「タグ付きユニオン」と呼ばれているものです。ts-patternのようなパターンマッチライブラリとも相性がよいのでおすすめです。

// カードを切る
type Discard = {
    kind: "discard"
    seatId: SeatId
    cardId: CardId
}

// パスをする
type Pass = {
    kind: "pass"
    seatId: SeatId
}

// カードを引く
type Draw = {
    kind: "draw"
    seatId: SeatId
}

// カードの色を選ぶ
type SelectColor = {
    kind: "selectColor"
    seatId: SeatId
    color: Color
}

// 省略

type Action = Discard | Pass | Draw | SelectColor | ...

ゲーム状態の型定義

フロントエンドで扱うゲームの状態を型定義します。対戦相手の手札や山札の情報を隠さなければならない点に注意です。

gameState.ts
type GameState = {
    players: Player[]
    deckCount: number
    topCard: Card
    isClockwise: boolean
    currentSeatId: SeatId
    mySeatId: SeatId
    myCards: Card[]
}

実装

ヘッダーにつけた番号はシーケンス図中の番号に対応しています。

① 切りたいカードをクリックしたときのイベント

先ほど定義したDiscard形式でaction情報をwebSocketで送ります。

discard.ts
export const discard = (action: Discard, webSocket: WebSocket) => {
  webSocket.send(JSON.stringify({ action }));
};

②③ StorybookとMSWでWebSocketAPIをMock

GameFieldコンポーネントのStoryを作成します(最初の動画で表示しているものです)。parametersにMockしたいAPIを記述することで、GameFieldコンポーネントの通信をMockできます。

DiscardCardStoryは、discardActionを受け取ったらあらかじめ用意しておいたupdatedGameStateとactionを返すAPIをMockしています。

gameField.stories.tsx
import { ws } from "msw";
import { match } from "ts-pattern";
import { GameField } from "./gameField";

const meta = {
  component: GameField,
} satisfies Meta<typeof GameField>;

export default meta;

type Story = StoryObj<typeof meta>;

const server = ws.link(MOCK_SERVER_URL);

export const DiscardCard: Story = {
  parameters: {
    msw: {
      handlers: [
        server.addEventListener("connection", (connection) => {
          connection.client.addEventListener("message", (event) => {
            // zodで作ったスキーマを使ってparseして型安全にしています
            const parsedData = receivedActionSchema.safeParse(
              JSON.parse(event.data.toString()),
            );
            if (!parsedData.success) {
              console.error(parsedData.error);
              return;
            }
            // ts-patternのmatch関数を使っていますが、この程度であれば単にif文で十分です。場合分けが多くなってきたときに真価を発揮します。
            match(parsedData.data)
              .with({ action: { kind: "discard" } }, ({ action }) => {
                connection.client.send(
                  JSON.stringify({
                    action,
                    gameState: updatedGameState,
                  }),
                );
              })
              .otherwise(() => {
                console.error("unexpected action");
                return;
              });
          });
        }),
      ],
    },
  },
};

GameFieldコンポーネントとCardFieldコンポーネントを載せますが、説明は割愛します。

gameField.tsx
gameField.tsx
import { seatIds } from "@/common/const";
import { SeatId } from "@/common/type/seat";
import { useGame } from "@/hooks/useGame";

import { ArrowAnimation } from "./arrowAnimation";
import { Card } from "./card";
import { CardField } from "./cardField";
import { PlayerArea } from "./playerArea";
import { Table } from "./table";

const bottom = "absolute inset-x-0 bottom-[-6%] m-auto size-fit";
const leftBottom = "absolute inset-y-0 left-[-8%] top-1/2 m-auto size-fit";
const leftTop = "absolute inset-y-0 -top-1/2 left-[-8%] m-auto size-fit";
const top = "absolute inset-x-0 top-[-6%] m-auto size-fit";
const rightTop = "absolute inset-y-0 -top-1/2 right-[-8%] m-auto size-fit";
const rightBottom = "absolute inset-y-0 right-[-8%] top-1/2 m-auto size-fit";

const classNames = [bottom, leftBottom, leftTop, top, rightTop, rightBottom];

const getSeatClassName = (heroSeatId: SeatId, seatId: SeatId) => {
  const offset = (seatId - heroSeatId + 6) % 6; // 時計回りのオフセットを計算
  return classNames[offset];
};

export const GameField = () => {
  const mySeatId = 1;
  const { socketRef, gameState, myCardRefs, tableBorderRef, topCardRef } =
    useGame({ mySeatId });

  return (
    <div className="relative h-[600px] w-[1024px] select-none bg-gray-800">
      <div className="absolute inset-x-0 top-20 m-auto h-[320px] w-[700px]">
        <Table tableBorderRef={tableBorderRef} />
        <div className="absolute inset-0 -left-1/2 m-auto size-fit">
          <ArrowAnimation isRotate={false} />
        </div>
        <div className="absolute inset-0 -right-1/2 m-auto size-fit">
          <ArrowAnimation isRotate />
        </div>
        {seatIds.map((seatId) => (
          <div key={seatId} className={getSeatClassName(mySeatId, seatId)}>
            <PlayerArea
              player={gameState.players.find(
                (player) => player.seatId === seatId,
              )}
              isTurn={seatId === gameState.currentSeatId}
            />
          </div>
        ))}
      </div>
      <div
        ref={topCardRef}
        className="absolute inset-x-0 top-[200px] m-auto size-fit"
      >
        <Card cardVariant={gameState.topCard} />
      </div>
      <div className="absolute inset-x-0 bottom-10 m-auto">
        <CardField
          cards={gameState.myCards}
          cardRefs={myCardRefs}
          socketRef={socketRef}
          mySeatId={mySeatId}
        />
      </div>
    </div>
  );
};
cardField.tsx
cardField.tsx
import { AnimatePresence, motion } from "motion/react";
import { FC, RefObject } from "react";

import { Card as CardType } from "@/common/type/card";
import { SeatId } from "@/common/type/seat";
import { MyCardRef } from "@/hooks/useGame";
import { discardCard } from "@/lib/action";

import { Card } from "./card";

type Props = {
  cards: CardType[];
  cardRefs: MyCardRef[];
  socketRef: RefObject<WebSocket | null>;
  mySeatId: SeatId;
};

export const CardField: FC<Props> = ({
  cards,
  cardRefs,
  socketRef,
  mySeatId,
}) => {
  return (
    <div className="flex justify-center gap-1">
      <AnimatePresence>
        {cards.map((card) => {
          const discard = () => {
            discardCard(
              {
                kind: "discard",
                cardId: card.id,
                seatId: mySeatId,
              },
              socketRef.current,
            );
          };
          return (
            <motion.div
              key={card.id}
              ref={cardRefs.find((cardRef) => cardRef.id === card.id)?.ref}
              whileHover={{ scale: 1.1 }}
              onClick={discard}
              layout
            >
              <Card cardVariant={card} size="md" hover />
            </motion.div>
          );
        })}
      </AnimatePresence>
    </div>
  );
};

④ WebSocketからの通信を受け取るuseGameカスタムフック

WebSocketからの通信を受け取って、アニメーションを行い、状態の更新を行うカスタムフックです。
「⑤カードを切るアニメーション」でも説明しますが、画面の各要素のDomを保持するためのRefが必要なのでまとめて宣言しています。

useGame.ts
type UseGame = {
  mySeatId: SeatId;
};

export const useGame = ({ mySeatId }: UseGame) => {
  const [gameState, setGameState] = useState<GameState>(initialState);

  // ゲーム画面のDOMを保持するためのRefの定義 アニメーションに使う
  const topCardRef = useRef<HTMLDivElement | null>(null);
  const tableBorderRef = useRef<SVGRectElement | null>(null);
  const myCardRefs = gameState.myCards.map((card) => {
    return {
      id: card.id,
      ref: createRef<HTMLDivElement>(),
    };
  });

  // webSocketAPIに接続
  const socketRef = useRef<WebSocket | null>(null);
  useEffect(() => {
    const webSocket = new WebSocket(MOCK_SERVER_URL);
    socketRef.current = webSocket;
    return () => {
      webSocket.close();
    };
  }, []);

  // WebSocketAPIからの通信をSubscribe
  useEffect(() => {
    if (!socketRef.current) {
      return;
    }
    socketRef.current.onmessage = (event) => {
      const parsedEvent = socketEventSchema.safeParse(JSON.parse(event.data));

      if (!parsedEvent.success) {
        console.error(parsedEvent.error);
        return;
      }

      match(parsedEvent.data)
        .with({ action: { kind: "discard" } }, async ({action,gameState}) => {
          await discardAnimation({action,myCardRef,tableEdgeRef,topCardRef})
          setGameState(gameState) // ⑥ 状態の更新
        })
        .with({ action: { kind: "pass" } }, (event) => {
          //省略
        })
        ... //省略
        .exhaustive();
    };
  }, [myCardRefs, mySeatId]);

  return {
    socketRef,
    gameState,
    myCardRefs,
    topCardRef,
    tableBorderRef,
  };
};

motionを使ったアニメーション

カードを切るアニメーションの説明に入る前に、簡単にmotionを使ったアニメーションの作り方を説明します。

motionでアニメーションを書く方法には大きく2つの方針があります。1つは、motionコンポーネントを使って宣言的に書く方法で、もう1つはanimate関数を使って命令的に書く方法です。

// motionコンポーネントを使って宣言的に書く
<motion.div
  animate={{ x: 100 }}
  transition={{ duration: 0.3, delay: 1, ease: "linear" }}
/>
// animate関数を使って命令的に書く
animate(
  ".box",
  { x: 100 },
  { duration: 0.3, delay: 1, ease: "linear" }
)

この2つの使い分けは難しいですが、個人的には、「無限にループするか否か」と「ユーザーアクションがトリガーか否か」の2×2の4パターンで、以下の表のように決めると大体うまく作れると思います。

ユーザーアクションがトリガーである ユーザーアクションがトリガーでない
無限にループする 宣言的 宣言的
無限にループしない 宣言的 命令的

実装したいカードを切るアニメーションは、無限にループをせず、サーバーからの受信がトリガーなので、animate関数を使って命令的に書くのが良さそうです。

再掲
再掲

この動画のアニメーションの例を挙げると、周り順を表している半回転する矢印や、手番の人を表すライトの点滅は、無限にループしていてサーバーからの受信がトリガーなので、宣言的に書くのが良さそうです。
また、ホバーするとカードが少し大きくなるアニメーションは、ループをせずユーザーアクションがトリガーなので、こちらも宣言的に書くのが良さそうです。

一方、ターンが終わった後にテーブルの淵に沿って光が移動するアニメーションは、ループをせずサーバーからの受信がトリガーなので、命令的に書くのが良さそうです。

ただし、例外もあって、カードを切ったあとに残ったカードが中央に移動するアニメーションはループをせずサーバーからの受信がトリガーですが、motionのAnimatePresenceを使って宣言的に書いています。

⑤ カードを切るアニメーション

では、さっそく「カードを切る」アニメーションの実装方法を説明します。

カードを切るアニメーションは、そのカードのDOMへの参照と、x方向とy方向のピクセル数と、その移動をどのくらいの時間をかけて行うかをもとにanimate関数で実現出来ます。

await animate(
  移動させたいDOM要素,
  { x: x方向の移動ピクセル数, y: y方向の移動ピクセル数 },
  { duration: 移動にかける時間 },
);

DOM要素はrefを使えば取得出来ますし、移動にかける時間も微調整すればよいです。
問題なのは移動ピクセル数を計算する方法ですが、WebAPIのgetBoundingClientRectを使えば計算出来ます。

移動ピクセル数を計算する関数はこのように実装出来ます。

utils.ts
export const domDistance = ({
  from,
  to,
}: {
  from: HTMLElement;
  to: HTMLElement;
}) => {
  const fromRect = from.getBoundingClientRect();
  const toRect = to.getBoundingClientRect();

  const fromCenterX = (fromRect.left + fromRect.right) / 2;
  const fromCenterY = (fromRect.top + fromRect.bottom) / 2;
  const toCenterX = (toRect.left + toRect.right) / 2;
  const toCenterY = (toRect.top + toRect.bottom) / 2;

  const x = toCenterX - fromCenterX;
  const y = toCenterY - fromCenterY;

  return { x, y };
};

カードを切るアニメーションの全体はこのようになります。

animation.ts
export const discardAnimation = async (input: DiscardAnimationInput) => {
  const { action, myCardRefs, mySeatId, tableBorderRef, topCardRef } = input;
  if (action.seatId === mySeatId) {
    const cardRef = myCardRefs.find((card) => card.id === action.cardId)?.ref;

    if (!cardRef || !cardRef.current) {
      console.error("card not found");
      return;
    }
    if (!tableBorderRef.current) {
      console.error("tableBorder not found");
      return;
    }

    if (!topCardRef.current) {
      console.error("deck not found");
      return;
    }

    const { x, y } = domDistance({
      from: cardRef.current,
      to: topCardRef.current,
    });

    // カードを切るアニメーション
    await animate(
      cardRef.current,
      { x, y },
      { duration: 0.3},
    );

    // カードを切った後にテーブルの淵に沿って黄色い円が移動するアニメーション
    // 説明は割愛します。
    await animate(
      tableBorderRef.current,
      { pathOffset: [0.65, 0.8] },
      { duration: 1 },
    );
  } else {
    // 他のプレイヤーがカードを捨てたときのアニメーション
  }
};

ようやく完成です。

おわりに

結構いい感じのアニメーションが作れたかなと思ってます。この記事で説明しきれなかった他のアニメーションの実装方法が気になる方はGithubレポジトリをご覧ください。

実装前はもっと難易度が高いのかなと思っていたのですが、そこまで苦しくなかったです。motionは初めて触りましたが、結構使い勝手は良かったです。

あと気になるのはパフォーマンスの問題です。今はまだ気にななりませんが、canvasを使ったアニメーションに比べると動作が重くなりそうだなと思ってます。

近いうちに「オンラインUNOゲーム開発【サーバー編】」も書こうと思っています。

Discussion