Reactサンプルアプリ開発 プラニングポーカー (11) カードのオープン、次のゲーム

Poker サンプル開発

今回はこれまで覚えたことの復習をしつつ、未実装の機能を実装していく。

  • 親プレイヤーが「オープン」をクリックすると、全プレイヤーの札が開かれ、ポイント数の平均値と中央値が算出される
  • 親プレイヤーが「次のゲーム」をクリックすると、カードの情報が初期値に戻り、新しいゲームが始まる。

まず「オープン」「次のゲーム」の操作ボタンはParentOperationsコンポーネントとして実装し、親プレイヤーだけに表示されるようにしている。

  return (
    <>
      <header className="header">
        <span className="table-name">{props.tableName}</span>
      </header>
      <main>
        {isParent && <ParentOperations bidding={bidding}
          onOpen={handleOpen} onNewGame={handleNewGame} />}
        {showsResults && <GameResults bids={bids} />}
        <Players players={
          players.map((p, idx) => Object.assign(p,
            { onBidChange: (bid: string) => handleBidChange(bid, idx) }))
        } />
      </main>
    </>

TSX内で条件付きでコンポーネントを配置したい場合、上記コード7行目のように、
{真偽値(式) && コンポーネントタグ}
と書くと簡潔に書ける。

ParentOperationsコンポーネントで発生したボタンクリックイベントのハンドリングは属性にハンドラ関数を指定する(8行目)。

handleOpen関数は、TablePage関数コンポーネント内でアロー関数として定義した。

  const handleOpen = () => {
    setTimeout(() => {
      socket.emit("opening");
    }, 0);
  }

他のプレイヤーにブロードキャストしたいため、WebSocketでサーバーにイベントを送出する。ライブラリにはsocket.ioを利用している。
なお、ブロードキャストしたイベントは自分自身にも返ってくる。

サーバー側のFlaskアプリケーションを見てみよう。

@socketio.on("opening")
def handle_opening():
    emit("opened", broadcast=True)

サーバー側ライブラリはFlask-SocketIOを使っており、1行目の@socketio.on("opening")によって、openingという名のイベントを受け付けるハンドラであることを宣言している。
emit関数を使って、接続中のクライアントにopenedイベントを送出するが、ブロードキャスト送信を行うためにbroadcast=Trueを引数指定している点に注意(3行目)。

そしてそのopendedイベントを受信するクライアント側のコード。

  useEffect(() => {
    socket.on("opened", (data: any) => {
      console.log("The parent player has opened cards.");
      const updatedPlayers = players.slice();
      updatedPlayers.forEach(p => p.open = true);
      setPlayers(updatedPlayers);
      setBidding(false);
      setShowsResults(true);
    });
    return () => {
      socket.off("opened");
    }
  }, [players, bidding, showsResults]);

WebSocketのイベントを受信して画面を更新する処理はまさに副作用に他ならないので、React HooksのuseEffectを利用して処理を記述する。
useEffectの第1引数がその処理本体であり、ここではopenedイベントのリスナー登録を行っている(2-9行目)。ステート変数の更新はuseStateで作成した更新関数を利用する必要がある(6-8行目)。

第1引数に渡した関数の処理が依存するステート変数またはプロパティがある場合、第2引数の配列に指定し、依存性についてReactに通知するのがルールである(13行目)。

これらの依存オブジェクトの変更を伴うコンポーネント再レンダリングが発生した場合、Reactは第1引数で渡された関数を再実行する。これによって、playersなどのステート変数が最新のデータを参照していることが保証されるわけだが、上記のコードが複数回実行されると問題が生じる。それは、openedのリスナーが複数個登録されてしまうことだ。

幸い、useEffectの第1引数に渡す関数は後始末用の関数を戻り値として返すことが可能なので、リスナー削除処理をここに記述している(10-12行目)。

これで、openedイベントを受信するとsocket.ioでリスナー登録したコールバック関数が実行されるようになった。ステート変数が更新されると、自動的にReactによるコンポーネントレンダリングが走り、DOMが更新されて画面に反映される。

平均値と中央値の表示はGameResultsコンポーネントとして実装する。
まずはプロパティの型定義。

export type GameResultsProps = {
  bids: string[];
}

平均値と中央値の計算処理自体、このコンポーネントの責務とするのがよさそうなので、プロパティとしては入札値の配列を受け取るようにした。未入札のプレイヤーの入札値は空文字列のため、number[]でなくstring[]にしている。
続いてコンポーネントの定義。

export const GameResults: FunctionComponent<GameResultsProps> = (props) => {
  const points: number[] = props.bids.filter(b => b).map(b => Number(b));
  // 平均値算出
  const calcAvg = (array: number[]): number => {
    const sum = array.reduce((p, c) => p + c, 0);
    const avg = array.length === 0 ? 0 : sum / array.length;
    return Math.round(avg * 10) / 10;
  }
  const avg = calcAvg(points);

  // 中央値算出
  const calcMedian = (array: number[]): number => {
    if (array.length === 0) {
      return 0;
    }
    array.sort((a, b) => a - b);
    const half = Math.floor(array.length / 2);
    const median = array.length % 2 ? array[half] : (array[half - 1] + array[half]) / 2;
    return Math.round(median * 10) / 10;
  }
  const median = calcMedian(points)

TypeScriptなので、ジェネリクスを使ってプロパティの型を FunctionComponent<GameResultsProps>のように指定する(1行目)。
平均値と中央値の算出を行い、TSXから参照できるようにconstで変数に入れておく。

そして関数コンポーネントの関数の戻り値としてTSXを返却する。

  return (
    <div className="game-results">
      <div className="game-result">
        <span className="result-key">平均</span>
        <span className="result-value">{avg}</span>
      </div>
      <div className="game-result">
        <span className="result-key">中央値</span>
        <span className="result-value">{median}</span>
      </div>
    </div>
  )

「次のゲーム」の処理は同じような実装であるため割愛する。

これで概ね動作するようになったので、この先は細かいところを実装したり、リファクタリングをしていこうと思っている。

コメント

  1. […] […]

タイトルとURLをコピーしました