何の話かと言うと
上記の記事の続きです。前回までに、盤面にコマを置けるようになったので、今回は「手番」の概念を導入して、交互に打ち合えるようにします。また、リバーシのルールに基づいて、コマをめくる機能も実装します。
「手番」の導入
「いまどちらの手番か」という情報を状態変数として用意するわけですが、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 ブランチ)で確認できます。
アプリケーションを実行すると、こんな感じになります。
次回予告
ゲームとしてはほぼ完成ですが、手番表示がダサいので、ここを手直しします。また、現在のコマ数や最後の勝ち負け判定メッセージなども表示したいですよね。このあたりの情報をまとめて表示する Dashboard コンポーネントを追加することにしましょう。
パート8はこちらです。