めもめも

このブログに記載の内容は個人の見解であり、必ずしも所属組織の立場、戦略、意見を代表するものではありません。

パソコン風のテキストゲームを簡単に作れる React のゲームエンジン

何の話かと言うと

昔懐かしいパソコン風のテキストゲームを簡単に作れるゲームエンジンを React で作りました。こんな感じのブラウザ上で遊べるゲームが作成できます。

ここから遊べます。)

利用手順

まず、こちらの記事に従って、React の開発環境を準備します。

enakai00.hatenablog.com

作業用ディレクトリーにゲームエンジンをダウンロードして、必要なパッケージをインストールします。

% cd ~/Documents
% git clone https://github.com/enakai00/react_textgame
% cd react_textgame
% yarn install

冒頭の「あるけあるけゲーム」がデフォルトで入っており、下記のコマンドで実行できます。

% yarn start
---- Output ----
Compiled successfully!

You can now view react_textgame in the browser.

  Local:            http://localhost:3000
  On Your Network:  http://192.168.1.56:3000

Note that the development build is not optimized.
To create a production build, use npm run build.

webpack compiled successfully

ゲーム本体はファイル「src/Main.js」に記載されており、このファイルを書き換えるだけでオリジナルのゲームが作成できます。

Main.js の書き方

使用するキーの指定

110行目に下記のような記述があります。ここには、ゲームで使用するキーを並べて指定します。これらのキーの On/Off を検知するハンドラーが用意されて、変数 KeyPress からキーの状態が読み出せるようになります。

   109    // Define keys used in the game.
   110    const keys = ["s", "i", "j", "l", "m"];

たとえば、"s" キーを押していれば、KeyPress["s"] === ture、押していなければ、KeyPress["s"] === false となります。

ゲーム本体の記述

ゲーム本体は、7行目から始まる関数「game」の中に書きます。

     6  // Your code here!
     7  const game = async (screen, refresh, keyPress, exit) => {
...
   105  }

大雑把な構造は次の通りです。

はじめに、ゲーム全体で使用するグローバル変数を定義します。

     8    // Global game variables.
     9    const bike = {x: 0, y: 0, direction: 0, score: 0}
    10    const bikeChar = ["┻", "┣", "┳", "┫"]
    11    const dirVector = [[0, -1], [1, 0], [0, 1], [-1, 0]] // [dx, dy]

その後は、ゲーム内で使用するサブルーチンを用意します。この例では、次のような関数を定義しています。

  • ゲームの初期化(initGame)
  • ゲームオーバー画面(gameover)
  • 自機の移動(moveBike)
  • 障害物の表示(putBlock)

これらはすべて、async キーワードをつけて非同期関数として定義します。関数内では、次の同期処理が利用できます。

  • await refresh() :画面を再描画して、描画が終わるまで待つ。
  • await sleep() :指定時間(ミリ秒単位)何もせずに待つ。
    13    const initGame = async () => {
    14      // Initialize bike status.
    15      bike.x = 20;
...
    47    }
    48
    49    const gameover = async () => {
    50      print(screen, 15, 10, " GAME OVER ", "black", "white");
    51      await refresh();
    52      await sleep(5000);
    53    }
    54
    55    const moveBike = async () => {
    56      if (keyPress["i"]) {
    57        bike.direction = 0;
    58      }
...
    80    }
    81
    82    const putBlock = async () => {
    83      const x = randInt(1, 39);
    84      const y = randInt(2, 22);
    85      if (randInt(0, 2) === 0) {
    86        print(screen, x, y, " ", "yellow", "yellow");
    87      }
    88    }

最後にこれらのサブルーチンを順に呼び出すループをメインループとして用意します。

    91    // main loop
    92    var finished;
    93    while (true) {
    94      finished = false;
    95      await initGame();
    96      while (!finished) {
    97        if (exit.current) return;
    98        await moveBike();
    99        await putBlock();
   100        await refresh();
   101        await sleep(100);
   102      }
   103      await gameover();
   104    }
   105  }

変数 finished でゲームオーバーかどうかの判定を行っており、サブルーチン内部で「finshed = true」に設定するとゲームオーバーになります。個々のサブルーチンは、await を付けて、同期的に順番に実行していきましょう。(昔のパソコンのプログラムは、すべて同期実行でしたから。)

また、画面の左下に「Reset」ボタンの機能が用意されていますが、これを押すと「exit.current=true」がセットされるので、これを検知して関数「game」を終了(return)させる必要があります。97行目はそのための処理になります。同様に、ゲーム開始時に「S」キーが押されるのを待つループの中でも、これを検知して return するようにしてあります。

    34      while (true) {
    35        if (exit.current) return;
    36        if (keyPress["s"]) {
    37          break;
    38        }
    39        await sleep(100);
    40      }


その他の部分は、ボイラープレートなのでそのままにしておいてください。

その他の補助変数・補助関数

その他には、画面描画に関連する、次の補助変数、補助関数が利用できます。

  • 2次元配列 screen

screen[y][x] には、座標 (x, y) に表示する「キャラクター、文字色、背景色」の情報が下記の連想配列として保存されています。(画面サイズはお約束の 40 × 24 です。)

screen[y][x] = {
  char: "", color: "white", bgColor: "black"
};

これを書き換えて、await refresh() を実行すると画面が書き換えられます。逆に、screen[y][x] を読み出せば現在表示されている「キャラクター、文字色、背景色」がわかります。(パソコンで言うところの VRAM みたいなものです。)

  • 関数 print

文字列を表示する際は、screen を個別に書き換えるのは面倒です。print で指定位置から文字列を書き込むことができます。

print(screen, x, y, str, "white", "black");

第1引数は、書き換える配列として screen を指定します。座標 (x, y) から文字列 str を書き込みます。その後ろは文字色と背景色で、省略時は、"white", "black" になります。半角文字は、自動で全角文字に変換されます。

ちなみに、背景色を付けて " "(スペース)を表示すると、冒頭のゲームで使っているような塗りつぶしたブロックが表現できます。

  • 関数 clearScreen

配列 screen を渡すと初期化して画面をクリアします。

clearScreen(screen);

初期状態では、screen[y][x].char === ""(スペースではなく空文字列)になっていますので、" "(スペース)を表示した場所は、初期状態とは別物になります。

  • 関数 randInt

画面表示とは関係ありませんが、ゲームでは必須の整数の乱数が得られます。

randInt(min, max);

min 以上、max 未満の整数値が得られます。

ゲームの公開方法

開発したゲームは、GitHub Pages で公開することができます。下記の手順を参考にしてください。

enakai00.hatenablog.com

バックエンドエンジニアのための(かどうかは本当はよく分からないけど、とにかく書いてみる)React 入門(パート8)

何の話かと言うと

enakai00.hatenablog.com

こちらの続きです。今回は、プレイ中のスコアの情報を追加して、手番とスコアの情報を表示する Dashboard コンポーネントを追加します。これで、リバーシの実装は一旦完成です。ぱちぱちぱち。

スコア情報の追加

まずは、スコア情報を保持する状態変数を Layout コンポーネントに追加します。Layout.js を次のようにアップデートします。

     1  import React, { useState, useRef } from "react";
     2  import { Container, Stack,
     3           Heading, Box, HStack, Button } from "@chakra-ui/react";
     4
     5  import { Board } from "./Board";
     6  import { Dashboard } from "./Dashboard";
     7
     8
     9  export const Layout = (props) => {
    10    const [gameID, setGameID] = useState(new Date().getTime());
    11    const [turn, setTurn] = useState("black");
    12    const [scores, setScores] = useState({black: 2, white: 2});
    13    const freeze = useRef(false);
    14
    15    const restart = () => {
    16      setTurn("black");
    17      setScores({black: 2, white: 2});
    18      setGameID(new Date().getTime());
    19    }
...
    28    const states = {
    29      turn: turn, setTurn: setTurn,
    30      scores: scores, setScores: setScores,
    31      freeze: freeze,
    32    };
    33
    34    const element = (
    35      <Container padding="8">
    36        <Stack spacing="1">
    37          <Heading marginLeft="4" fontSize="3xl">Reversi</Heading>
    38          <Box>
    39            <Board key={gameID} states={states}/>
    40          </Box>
    41          <Box>
    42            <Dashboard states={states}/>
    43          </Box>
 ...

12行目で、黒と白それぞれのスコアを要素とする連想配列を含む状態変数を用意しています。複数の情報を1つの状態変数にまとめたい時は、このような使い方ができます。また、17行目で、リスタート時にスコアを初期値に戻すようにしています。そして、30行目にあるように、この新しい状態変数も states 属性として他のコンポーネントに受け渡すようにします。ここでは、39行目で Board コンポーネントに渡す部分と、42行目で Dashboard コンポーネントに渡す部分があります。Dashboard コンポーネントはこれから用意するもので、現在の番手、スコア、ゲーム終了時のメッセージなどをまとめて表示します。

スコアの計算

スコアの計算処理は、Board コンポーネントの中で行います。新たに手を打つごとに再計算します。Board.js の更新部分は次のとおりです。

...
    58  export const Board = (props) => {
    59    const size = 8;
    60    const fieldRef = useRef(getField(size));
    61    const field = fieldRef.current;
    62
    63    // Unpack states.
    64    const turn = props.states.turn;
    65    const setTurn = props.states.setTurn;
    66    const freeze = props.states.freeze;
    67    const setScores = props.states.setScores;
...
   141        // Animation ends.
   142        await setTurn(opponent[turn]);
   143      }
   144      freeze.current = false;
   145      updateScore();
   146    }
   147
   148    const updateScore = () => {
   149      const newScore = {black: 0, white:0, blank:0};
   150      for (let y = 0; y < size; y++) {
   151        for (let x = 0; x < size; x++) {
   152          newScore[field[y][x]] += 1;
   153        }
   154      }
   155      setScores({black: newScore.black, white: newScore.white});
   156    }

67行目で引数 props からスコアを更新する関数を取り出しています。そして、コマを打つ処理が終わった144行目の直後で、スコアを計算する関数 updateScore を呼び出しています。

148〜156行目がスコアの計算です。盤面上のコマの数を数えてスコアを計算し、setScores で新しい値をセットします。このタイミングで Layout コンポーネント以下の再描画が行われて、Dashborad コンポーネントが計算するスコアが更新されます。

Dashboard コンポーネントの実装

ファイル「src/components/Dashboard.js」を新たに次の内容で作成します。

     1  import React from "react";
     2  import { Stack, HStack, Box } from "@chakra-ui/react";
     3
     4  import black from "../assets/black.png";
     5  import white from "../assets/white.png";
     6
     7
     8  export const Dashboard = (props) => {
     9    // Unpack states.
    10    const turn = props.states.turn;
    11    const scores = props.states.scores;
    12
    13    const imageMap = {black: black, white: white};
    14    const style = {width: "24px", height: "24px"};
    15
    16    let winner = null;
    17    if (scores.black === 0) {
    18      winner = "white";
    19    }
    20    if (scores.white === 0) {
    21      winner = "black";
    22    }
    23    if (scores.black + scores.white === 8*8) {
    24      if (scores.black > scores.white) {
    25        winner = "black";
    26      } else if (scores.white > scores.black) {
    27        winner = "white";
    28      } else {
    29        winner = "tie";
    30      }
    31    }
    32
    33    var message;
    34    switch(winner) {
    35      case "black":
    36      case "white":
    37        message = (
    38          <HStack>
    39            <Box>Winner </Box>
    40            <Box><img src={imageMap[winner]} alt={winner} style={style}/></Box>
    41          </HStack>
    42        );
    43        break;
    44      case "tie":
    45        message = (
    46          <HStack>
    47            <Box>Tie </Box>
    48            <Box><img src={imageMap.black} alt={black} style={style}/></Box>
    49            <Box><img src={imageMap.white} alt={white} style={style}/></Box>
    50          </HStack>
    51        );
    52        break;
    53      default:
    54        message = (
    55          <HStack>
    56            <Box>Turn </Box>
    57            <Box><img src={imageMap[turn]} alt={turn} style={style}/></Box>
    58          </HStack>
    59      );
    60    }
    61
    62    const infoElements = (
    63      <Stack spacing="2">
    64      <Box>
    65        <HStack>
    66          <Box><img src={black} alt="black" style={style}/></Box>
    67          <Box>{scores.black} pieces.</Box>
    68        </HStack>
    69      </Box>
    70      <Box>
    71        <HStack>
    72          <Box><img src={white} alt="white" style={style}/></Box>
    73          <Box>{scores.white} pieces.</Box>
    74        </HStack>
    75      </Box>
    76      <Box>
    77        {message}
    78      </Box>
    79      </Stack>
    80    );
    81
    82    const element = (
    83      <Box style={{marginLeft: 40, marginTop: 10, marginBottom: 10}}>
    84        {infoElements}
    85      </Box>
    86    );
    87
    88    return element;
    89  }

ここは特に新しいテクニックは使っていません。スコアの値を表示すると同時に、勝ち負けを判定して、勝ち負けが決まった場合はその旨のメッセージ、そうでないときは、現在の手番を表示します。ゲームが終了した時の画面は、こんな感じになります。

ここまでの内容は、下記のリポジトリ(v1.0 ブランチ)で公開しています。

github.com

また、ビルドしたバイナリーを GitHub Pages で公開しているので、下記で実際に遊ぶこともできます。

enakai00.github.io

この連載記事は、これで一旦終了です。お疲れさまでした!

バックエンドエンジニアのための(かどうかは本当はよく分からないけど、とにかく書いてみる)React 入門(パート7)

何の話かと言うと

enakai00.hatenablog.com

上記の記事の続きです。前回までに、盤面にコマを置けるようになったので、今回は「手番」の概念を導入して、交互に打ち合えるようにします。また、リバーシのルールに基づいて、コマをめくる機能も実装します。

「手番」の導入

「いまどちらの手番か」という情報を状態変数として用意するわけですが、Layer コンポーネントと Board コンポーネントのどちらで定義するべきでしょうか。これは、この情報をどのコンポーネント内に隠蔽するかに依存します。Board コンポーネント内だけで参照するのであれば、Board コンポーネントで定義すれば十分です。しかしながら、今の場合、Layer コンポーネントは、手番を変更する「Pass ボタン」を持っており、さらに、手番の情報を表示する位置は、Layer コンポーネントで管理する必要があります。そこで、手番の情報は、Layer コンポーネントで定義しておき、Board コンポーネントには props として受け渡すことにします。

というわけで、Layer.js に次の修正を加えます。

     1  import React, { useState, useRef } from "react";
     2  import { Container, Stack,
     3           Heading, Box, HStack, Button } from "@chakra-ui/react";
     4
     5  import { Board } from "./Board";
     6
     7
     8  export const Layout = (props) => {
     9    const [gameID, setGameID] = useState(new Date().getTime());
    10    const [turn, setTurn] = useState("black");
    11    const freeze = useRef(false);
    12
    13    const restart = () => {
    14      setTurn("black");
    15      setGameID(new Date().getTime());
    16    }
    17
    18    const pass = () => {
    19      if (!freeze.current) {
    20        const opponent = {black: "white", white: "black"};
    21        setTurn(opponent[turn]);
    22      }
    23    }
    24
    25    const states = {turn: turn, setTurn: setTurn, freeze: freeze};
    26
    27    const element = (
    28      <Container padding="8">
    29        <Stack spacing="1">
    30          <Heading marginLeft="4" fontSize="3xl">Reversi</Heading>
    31          <Box>
    32            <Board key={gameID} states={states}/>
    33          </Box>
    34          <Box>
    35            Turn: {turn}
    36          </Box>
    37          <Box>
    38            <HStack>
    39              <Button colorScheme="red" size="sm"
    40                      onClick={pass}>Pass</Button>
    41              <Button colorScheme="blue" size="sm"
    42                      onClick={restart}>Restart</Button>
    43            </HStack>
    44          </Box>
    45        </Stack>
    46      </Container>
    47    );
    48
    49    return element;
    50  }

まず、10行目の useState で状態変数 turn を導入しています。"black" / "white" の文字列で現在の手番を表します。また、11 行目にも新たな状態変数 freeze があります。この後、コマを置いて相手のコマをめくっていくアニメーション(?)を実装するので、アニメーション中は余計な操作(さらに別の場所にコマを置くとか、パスするとか)ができないように、この状態変数で制御します。アニメーション中は、freeze.current = true にするという使い方です。

これらの状態変数を使って、「Pass ボタン」の処理を実装したのが、18〜23行目です。freeze.current === false であることを確認して、状態変数 turn を相手側に変更します。Layout コンポーネント内(もしくは、その子コンポーネント)で「手番」を表示しておけば、このタイミングで表示が更新されます。ここでは、34〜36行目でまさにその情報を表示しています。変数 turn の文字列を直接表示していて、ちょっとダサいですが、この部分は次回にアップデートする予定です。

そして、手番の情報、および、状態変数 freeze は、Board コンポーネントでも使用するので、何らかの方法で Board コンポーネントに受け渡す必要があります。ここでは、25 行目で受け渡したいものを連想配列 states にまとめて、32行目で states 属性として渡しています。これにより、Board コンポーネント側では、引数 props でこれらの情報を受け取ることができます。

ここで、states に含めた freeze は(current 属性に現在値を格納した)オブジェクトである点に注意してください。たとえば、freeze の代わりに、freeze.current を states に含めてしまうと、現在値のリテラル(true / false)が渡るため、Board コンポーネント側では、現在値は参照できても、freeze.current に格納された現在値の更新はできなくなってしまいます。useRef で作成される状態変数が、値そのものを直接格納せずに、current 属性として保存するのは、このあたりの事情が関連しています。

ちなみに・・・useState で作った変数 turn についてはどうでしょうか? こちらはオブジェクトではなくリテラル値が格納されていますが、ここは問題ありません。値を更新するための関数 setTurn を同時に渡しているので、Board コンポーネント側では、setTurn を使って turn の値を更新します。setTurn で更新した値は、コンポーネントの再描画が行われて、再度、関数 Board が呼び出されたタイミングで更新後の値が得られます。setTurn 直後に turn の値を参照しても更新は反映されていないので、この点は注意が必要です。(「useState 更新」あたりのキーワードで検索すると情報がいろいろ出てきます。)

あとは、Restart ボタンの処理も少しアップデートしています。14行目にあるように、手番を初期値の "black" にリセットしています。

コマをめくるロジックの実装

それでは、Board コンポーネント側の実装に進みましょう。いよいよ、コマをめくるロジックを実装して、リバーシとして遊ぶことができるようになります。変更部分を抜粋して示すと次のようになります。

     1  import React, { useRef, useState } from "react";
     2  import { Box } from "@chakra-ui/react";
...
    58  export const Board = (props) => {
    59    const size = 8;
    60    const fieldRef = useRef(getField(size));
    61    const field = fieldRef.current;
    62
    63    // Unpack states.
    64    const turn = props.states.turn;
    65    const setTurn = props.states.setTurn;
    66    const freeze = props.states.freeze;
...
    74    const onClick = (x, y) => {
    75      if (!freeze.current) {
    76        move(x, y);
    77      }
    78    }
    79
    80    const move = async (x, y) => {
    81      const sleep = (milliseconds) => {
    82        return new Promise(resolve => setTimeout(resolve, milliseconds))
    83      }
    84      freeze.current = true;
    85
    86      // 8 directions to search.
    87      const dx = [1,-1, 0, 0,-1, 1, 1,-1]
    88      const dy = [0, 0, 1,-1,-1,-1, 1, 1]
    89      const allowed = new Array(8)
    90      allowed.fill(false);
    91
    92      const opponent = {black: "white", white: "black"};
    93
    94      for (let i = 0; i < 8; i++) { // search for 8 directions.
    95        if (field[y][x] !== "blank") { // not a blank cell
    96          break;
    97        }
    98
    99        let search_state = 0; // search starts.
   100        for (let j = 1; j < size; j++) {
   101          const xx = x + j*dx[i];
   102          const yy = y + j*dy[i];
   103          if (xx < 0 || xx >= size || yy < 0 || yy >= size) {
   104            break; // search failed.
   105          }
   106          if (field[yy][xx] === opponent[turn]) {
   107            search_state = 1; // search continues.
   108            continue;
   109          }
   110          if (search_state === 1 && field[yy][xx] === turn) {
   111            search_state = 2; // search succeeded.
   112            break;
   113          }
   114          break; // search failed.
   115        }
   116        if (search_state === 2) {
   117          allowed[i] = true;
   118        }
   119      }
   120
   121      if (allowed.includes(true)) {
   122        // Animation starts.
   123        field[y][x] = turn;
   124        await setDummyState([]);
   125        for (let i = 0; i < 8; i++) {
   126          if (!allowed[i]) {
   127            continue;
   128          }
   129          for (let j = 1; j < size; j++) {
   130            const xx = x + j*dx[i];
   131            const yy = y + j*dy[i];
   132            if (field[yy][xx] === turn) {
   133              break;
   134            }
   135            field[yy][xx] = turn;
   136            await sleep(100);
   137            await setDummyState([]);
   138          }
   139        }
   140        // Animation ends.
   141        await setTurn(opponent[turn]);
   142      }
   143      freeze.current = false;
   144    }
...

64〜66行目で、Layout コンポーネント側で用意した状態変数を引数 props から取り出しています。そして、75行目では、ブランクセルをクリックした際に、関数 move を実行するようにしています。先ほど導入した状態変数 freeze を利用して、freeze.current === false の場合のみ実行します。

80行目以降の関数 move では、座標 (x, y) に現在の手番(turn)のコマが置けるか確認して、置ける場合は、相手のコマをめくっていくアニメーションを実行します。80 行目の async キーワードは、sleep を挟みながらアニメーションを進めていくために必要になります。状態変数の更新に伴うコンポーネントの再描画は非同期に行われるため、アニメーションを実行するには、再描画が終わるの待ってから次に進むという同期化が必要になります。非同期処理/同期処理の詳細は省きますが、関数 move に async キーワードをつけておくと、この関数内では、非同期処理を行う関数の前に await キーワードをつけることで、非同期処理が終わるのを待つことができます。124行目、137行目で、setDummyState に await キーワードをつけており、これにより、盤面の再描画が終わるのを待っています。

なお、sleep 処理の実装についても、同様に await が必要になります。81〜83行目の関数 sleep は、指定の秒数(ミリ秒単位)後に完了する非同期処理を実装しており、たとえば、

await sleep(100);

とすると、100ミリ秒待ってから次に進む sleep 処理として使うことができます。

それでは、ロジックの中身にいきましょう。

84 目で、freeze.current = true にセットして、この後の処理が終わるまで、余計な操作ができないようにします。

次に、コマを置きたい場所 (x, y) から8方向について、相手のコマを挟んでいるかをチェックします。87, 88行目は、8方向を表すベクトルを定義しています。89, 90行目で用意している配列 allowed は、8方向それぞれのチェック結果を格納します。

94〜119行目の for ループで、8方向のそれぞれについてチェックします。チェック方法のロジックは・・・説明すると長くなるので省略させてください。チェック結果は、先ほどの配列 allowed に保存されていて、1方向でもOKであれば、121〜142行目の for ループで、コマをめくる処理を行います。

8方向のそれぞれについて、チェック結果がOKであれば、その方向について、相手のコマをめくっていきます。1枚めくるごとに、100ミリ秒のスリープをいれてから再描画することで(136, 137行目)、1枚ずつめくられていく様子が画面上で見えるようになります。最後に、手番を入れ替えて(141行目)、freeze.current を false に戻して(143行目)完了です。

これでついに、リバーシをゲームとして遊ぶことができるようになりました。ここまでの内容は、次のリポジトリ(v0.4 ブランチ)で確認できます。

github.com

アプリケーションを実行すると、こんな感じになります。

次回予告

ゲームとしてはほぼ完成ですが、手番表示がダサいので、ここを手直しします。また、現在のコマ数や最後の勝ち負け判定メッセージなども表示したいですよね。このあたりの情報をまとめて表示する Dashboard コンポーネントを追加することにしましょう。

パート8はこちらです。

enakai00.hatenablog.com