前回の記事では、アプリ開発 のざっくり方針を決めました。
smooth-pudding.hatenablog.com
今回はより具体的に仕様の検討を進めていきます。
ここを考えるのが醍醐味ではあるのですが、まずはAIにたたき台を作ってもらいましょう。先程のチャットに続けて以下のように ChatGPT に投げてみます。
3画面ほどの画面遷移で十分遊べるようにしたいです。それぞれの画面の役割の組み合わせの案をいくつか挙げてください。ボタンなどの具体的なコンポーネント の配置などについては含めないでください。
5つの案が返ってきました。
案A : もっとも基本的な構成 案B : 結果画面を省略し、ループするスタイル 案C : 学習性や戦略性を重視した構成 案D : ユーザーが一人で遊ぶことを前提としたコンパクト構成 案E : 複数モード (例 : AI対戦・自由対戦) を想定
補足:構成選びのポイント
最も自然な導線(A・B案): 最低限で動かしたいならこれ。
教育的な側面を重視するなら(C案): Second Best の解説なども載せたい場合。
開発コストを抑えたいなら(D案): 画面遷移の実装を最小限にできる。
今後の拡張を見据えるなら(E案): モード追加や設定の幅がある。
AIの意見としてはAとBが本命のようですね。対戦画面と結果の画面は同じ方がわかりやすそうなので、案Bを採用することにします。状態の管理を簡単にするため、以下のふたつの動線 のみを考えることにします。
ホーム画面 ゲーム画面
ホーム画面 設定画面
では各画面の具体的なデザインも決めていきます。この部分は楽しいので、自分で考えることにします。
ホーム画面
タイトル表示
「対戦開始」ボタン
「設定」ボタン:⚙アイコンがよさそう
色:
ホーム画面案
設定画面
「ホームに戻る」ボタン:🏠アイコンが良さそう
プレイヤーの色の選択
色:
設定画面案
フロントエンドの構成の検討
画面のデザインがざっくり決まったので、React のコンポーネント でどう表現していくか考えていきます。ところが、私は React のコンポーネント にどういったものがあるか分かりません。そこで AI に聞いてみます。
以下を ChatGPT に投げてみます。セカンドベストと言っても伝わらない恐れがあるので、オセロを題材にして聞いてみます。
シンプルなボードゲーム の UI を React で作成しようとしています。ところが、私は React の経験が無いため、どのようなコンポーネント が React に用意されているのか全く分かりません。
そこで、オセロのようなボードゲーム のUIを設計する場合を想定して、実装に使えそうな React のコンポーネント を紹介してください。初心者にもわかりやすい説明になるよう心がけてください。
すると以下のように返ってきました。
目標と前提 マス目のコンポーネント ボードのコンポーネント ゲーム全体のコンポーネント 補足 注文通り、シンプルな例が返ってきました。ボタンを使ってマス目を作り、マス目を並べたものとしてボードを作り、最後にボードの状態を管理するコンポーネント を作っているようです。オセロの場合は入れ子 構造がとてもうまく使えそうですね。
React の簡単な雰囲気は分かりましたが、先程の画面設計を実現するには、html 要素として組み立てていくのは少々無理がありそうです。可能なら、キャンバスの上に画像を配置していくようなことができれば嬉しそうです。
そこで以下のように追加質問してみます。
React でキャンバスの領域を用意して、その上に自由に画像を配置することは可能でしょうか?また、ある画像の上をクリックするイベントを検出して、それに応じて画像の様子を変化させることは可能でしょうか?
もし可能なら、最もシンプルな実現方法を教えてください。
今度は以下のように返ってきました。
できること
import React, { useRef, useEffect, useState } from "react" ;
function CanvasWithImage () {
const canvasRef = useRef ( null ) ;
const [ isAltImage, setIsAltImage] = useState ( false ) ;
const image1 = new Image () ;
const image2 = new Image () ;
image1. src = "https://via.placeholder.com/100x100.png?text=1" ;
image2. src = "https://via.placeholder.com/100x100.png?text=2" ;
useEffect (() => {
const canvas = canvasRef. current;
const ctx = canvas. getContext ( "2d" ) ;
const draw = () => {
ctx. clearRect ( 0 , 0 , canvas. width, canvas. height) ;
const img = isAltImage ? image2 : image1;
img. onload = () => {
ctx. drawImage ( img, 50 , 50 , 100 , 100 ) ;
} ;
if ( img. complete) {
ctx. drawImage ( img, 50 , 50 , 100 , 100 ) ;
}
} ;
draw () ;
} , [ isAltImage]) ;
const handleClick = ( event) => {
const canvas = canvasRef. current;
const rect = canvas. getBoundingClientRect () ;
const x = event. clientX - rect. left;
const y = event. clientY - rect. top;
if ( x >= 50 && x <= 150 && y >= 50 && y <= 150 ) {
setIsAltImage (( prev) => ! prev) ;
}
} ;
return (
canvas
ref = { canvasRef}
width = { 300 }
height = { 300 }
onClick = { handleClick}
style = {{ border : "1px solid black" }}
) ;
}
export default CanvasWithImage;
解説・補足・まとめ キャンバスの上でクリックすると画像が切り替わるコンポーネント を紹介してくれました。ざっくり雰囲気は分かりましたが、useEffect がどういう役割なのか掴みきれないので、追加で質問してみます。
useEffect がどのような関数なのか、丁寧に教えてください。引数にどのようなものを与えれば、どのような振る舞いをするのかが知りたいです。
すぐに返答が返ってきました。
とても良い質問です!React の useEffect は、React コンポーネント の「副作用(=画面の更新以外にやりたい処理)」を扱うための関数 です。React を使う上で必ず知っておくべき重要なフックの1つです。
useEffect とは 基本の形 各パターンの説明 例とまとめ どうやら副作用を制御するための便利関数のようですね。さきほどの CanvasWithImage の例だと、isAltImage という変数を監視して、値が変更されたときに「キャンバスの全体を削除→画像を描画」という処理を走らせる役割を担っているというわけですね。
ちなみに、useEffect というキーワードでググってみたところ、こんな記事もヒットしました。これ以外にもいろいろと hook と呼ばれるものがあり、それぞれ重要な役割を果たしそうです。
qiita.com
実際にボードを表現しようとした場合、コマやボード上の丸の部分もコンポーネント として扱いたくなります。上記の例では画像は単なる画像になっているので、やや扱いが大変そうです。そこで、以下のように質問してみました。
オセロのボードやコマをキャンバス上の画像として表現する際に、ボードやコマをコンポーネント として扱う方法はありますか?
返答は以下のとおりです。
方針
1. drawBoard.js - ボード描画ロジック
export function drawBoard ( ctx) {
ctx. fillStyle = "#006400" ;
ctx. fillRect ( 0 , 0 , 320 , 320 ) ;
ctx. strokeStyle = "black" ;
for ( let i = 0 ; i <= 8 ; i++ ) {
ctx. beginPath () ;
ctx. moveTo ( i * 40 , 0 ) ;
ctx. lineTo ( i * 40 , 320 ) ;
ctx. stroke () ;
ctx. beginPath () ;
ctx. moveTo ( 0 , i * 40 ) ;
ctx. lineTo ( 320 , i * 40 ) ;
ctx. stroke () ;
}
}
2. drawDiscs.js - コマ描画ロジック
export function drawDiscs ( ctx, board) {
for ( let y = 0 ; y < 8 ; y++ ) {
for ( let x = 0 ; x < 8 ; x++ ) {
const value = board[ y * 8 + x] ;
if ( ! value) continue;
ctx. beginPath () ;
ctx. arc ( x * 40 + 20 , y * 40 + 20 , 16 , 0 , Math . PI * 2 ) ;
ctx. fillStyle = value === "black" ? "black" : "white" ;
ctx. fill () ;
}
}
}
3. CanvasBoard.jsx - 状態と描画をつなげる React コンポーネント
import React, { useRef, useEffect, useState } from "react" ;
import { drawBoard } from "./drawBoard" ;
import { drawDiscs } from "./drawDiscs" ;
function CanvasBoard () {
const canvasRef = useRef ( null ) ;
const [ board, setBoard] = useState ( Array ( 64 ) . fill ( null )) ;
const [ turn, setTurn] = useState ( "black" ) ;
const handleClick = ( event) => {
const rect = canvasRef. current. getBoundingClientRect () ;
const x = Math . floor (( event. clientX - rect. left) / 40 ) ;
const y = Math . floor (( event. clientY - rect. top) / 40 ) ;
const index = y * 8 + x;
if ( board[ index]) return;
const newBoard = [ ... board] ;
newBoard[ index] = turn;
setBoard ( newBoard) ;
setTurn ( turn === "black" ? "white" : "black" ) ;
} ;
useEffect (() => {
const canvas = canvasRef. current;
const ctx = canvas. getContext ( "2d" ) ;
drawBoard ( ctx) ;
drawDiscs ( ctx, board) ;
} , [ board]) ;
return (
<canvas
ref= { canvasRef}
width= { 320}
height= { 320}
onClick= { handleClick}
style= { { border : "1px solid black" } }
/ >
) ;
}
export default CanvasBoard;
結論 なるほど、つまり、「コマ」「ボード」というモノで表現するのではなく、「コマを描く」「ボードを描く」というコトで表現すればよいってことですね。この技術をうまく組み合わせれば、ボードの表現はなんとかできそうです。
とりあえず、ボードはキャンバス上の描画でなんとか表現できそうです。ただ画面にはいくつかのコンポーネント がまだあります。もし自分で実装するのであれば、それぞれ何を使うのか確認が必要です。でも今回はAIにコードを書いてもらう予定なので、生成されたものを見て都度調べる作戦で行こうと思います。
バックエンドAPI の設計
めちゃくちゃざっくりフロントエンドの構成が決まりました。次はバックエンドの設計案を作ってもらいます。こちらは後ほど実装するときに渡すので、Cursor を使って Markdown 形式で作ってもらうことにします。
バックエンドの設計についてこれまで決まっていることを整理します。
アプリ全体を Tauri で構成し、バックエンドは Rust で書くこと
Second Best のルール
プレイヤーとAIとの対戦形式にすること
バックエンドとフロントエンドの連携は Pull+Push のハイブリッドで行う
プレイヤーが操作したときのみ invoke () で Rust の関数を呼ぶ (Pull 型)
AIのターンが終わったら emit() で通知し、フロントで反映 (Push 型)
空の作業用フォルダを作成し、Cursor を立ち上げます。secondbest クレートの README.md のうち、ゲームのルールの部分を抜粋し、rules.md として保存しておきます。その上で Agent に以下のように命令します。
Second Best というボードゲーム があり、そのゲームで遊べるアプリを開発しようと思っています。具体的には以下のような仕様です。
アプリ全体を Tauri で構成し、バックエンドは Rust で書く
Second Best のルールは @rules.md の通り
プレイヤーとAIとの対戦形式にする
バックエンドとフロントエンドの連携は Pull+Push のハイブリッドで行う
- プレイヤーが操作したときのみ invoke () で Rust の関数を呼ぶ (Pull 型)
- AIのターンが終わったら emit() で通知し、フロントで反映 (Push 型)
以上の条件の下で、バックエンドのAPI の案を作成し、backend_api .md として出力してください。内部実装を与える必要はなく、それぞれのシグネチャ と役割を簡潔に並べるようにしてください。
@rules.md のところでは、rules.md へのリンクを設定しておきます。ちょうど最近 Claude-4 Sonnet が公開されたので、それで投げてみました。返答は以下のとおりです。
# Second Best Game - Backend API 設計
## 概要
Second Best ゲームの Tauri アプリケーション用バックエンド API 設計書です。
プレイヤーと AI の対戦形式で、Pull+Push ハイブリッド型の通信を採用しています。
## データ構造
### Position
```rust
pub enum Position {
N, NE, E, SE, S, SW, W, NW
}
```
### Player
```rust
pub enum Player {
Black,
White
}
```
### PieceStack
```rust
pub struct PieceStack {
pieces: Vec<Player>, // 最大3個まで、先頭が一番下
}
```
### GameState
```rust
pub struct GameState {
board: HashMap<Position, PieceStack>,
current_player: Player,
turn_phase: TurnPhase,
second_best_available: bool,
winner: Option<Player>,
}
```
### TurnPhase
```rust
pub enum TurnPhase {
WaitingForMove, // 通常の手番待ち
WaitingForSecondBest, // Second Best宣言待ち
WaitingForSecondMove, // Second Best後の代替手待ち
}
```
### MoveAction
```rust
pub enum MoveAction {
Place { position: Position },
Move { from: Position, to: Position },
}
```
## Pull 型 API (invoke 関数)
### ゲーム管理
#### ` new_game() -> GameState`
- 新しいゲームを開始
- 空のボードで Black プレイヤーから開始
#### ` get_game_state() -> GameState`
- 現在のゲーム状態を取得
#### ` get_legal_moves() -> Vec<MoveAction>`
- 現在のプレイヤーが実行可能な合法手を取得
### プレイヤーアクション
#### ` make_move(action: MoveAction) -> Result<GameState, String>`
- プレイヤーの手を実行
- 不正な手の場合はエラーを返す
- 成功時は更新されたゲーム状態を返す
#### ` declare_second_best() -> Result<GameState, String>`
- Second Best 宣言を実行
- 宣言不可能な状況ではエラーを返す
- 成功時は AI に代替手を要求する状態に遷移
#### ` skip_second_best() -> Result<GameState, String>`
- Second Best 宣言をスキップ
- AI のターンに移行
### ゲーム情報
#### ` check_winner() -> Option<Player>`
- 勝利条件をチェックして勝者を返す
#### ` get_position_stack(position: Position) -> PieceStack`
- 指定位置のピーススタックを取得
#### ` can_declare_second_best() -> bool`
- Second Best 宣言が可能かどうかを判定
## Push 型 API (emit イベント)
### AI アクション通知
#### ` ai_move_completed`
```rust
pub struct AiMoveEvent {
action: MoveAction,
new_state: GameState,
}
```
- AI が手を完了した時に発火
- 実行された手と新しいゲーム状態を通知
#### ` ai_second_move_completed`
```rust
pub struct AiSecondMoveEvent {
action: MoveAction,
new_state: GameState,
}
```
- AI が Second Best 後の代替手を完了した時に発火
### ゲーム状態変更通知
#### ` game_over`
```rust
pub struct GameOverEvent {
winner: Option<Player>,
reason: GameOverReason,
}
pub enum GameOverReason {
VerticalLineup,
HorizontalLineup,
NoMoves,
}
```
- ゲーム終了時に発火
#### ` turn_phase_changed`
```rust
pub struct TurnPhaseEvent {
new_phase: TurnPhase,
current_player: Player,
}
```
- ターンフェーズが変更された時に発火
### エラー通知
#### ` ai_error`
```rust
pub struct AiErrorEvent {
message: String,
}
```
- AI 処理中にエラーが発生した時に発火
## 実装フロー
### 通常のプレイヤーターン
1. フロント: ` get_legal_moves()` で合法手を取得
2. フロント: プレイヤーが手を選択
3. フロント: ` make_move(action)` で手を実行
4. バック: AI ターンを開始、` ai_move_completed` イベントを発火
5. フロント: イベントを受信して UI 更新
### Second Best 使用時
1. フロント: AI の手実行後、` can_declare_second_best()` で宣言可能性確認
2. フロント: ` declare_second_best()` または ` skip_second_best()` を実行
3. バック: Second Best 宣言時は AI に代替手を要求
4. バック: ` ai_second_move_completed` イベントを発火
5. フロント: イベントを受信して UI 更新
## 注意事項
- すべての勝利条件チェックは Second Best 処理完了後に実行
- AI の思考時間は適切に制限し、無限ループを防止
- ゲーム状態の整合性は常にバックエンドで保証
- エラーハンドリングは適切に行い、フロントエンドに分かりやすいメッセージを提供
全体的には問題なさそうですが、いくつか修正が必要なポイントがあります。
skip_second_best() は不要。合法な手を実行できたらセカンドベスト宣言は失効するため。
AI によるセカンドベスト宣言を表現できていないので、表現してほしい。
これを Cursor に追加注文してみましょう。
以下の点を修正してください。
skip_second_best() は不要。合法な手を実行できたらセカンドベスト宣言は失効するため。
AI によるセカンドベスト宣言を表現できていないので、表現してほしい。
すると以下のように修正されました。
# Second Best Game - Backend API 設計
## 概要
Second Best ゲームの Tauri アプリケーション用バックエンド API 設計書です。
プレイヤーと AI の対戦形式で、Pull+Push ハイブリッド型の通信を採用しています。
## データ構造
### Position
```rust
pub enum Position {
N, NE, E, SE, S, SW, W, NW
}
```
### Player
```rust
pub enum Player {
Black,
White
}
```
### PieceStack
```rust
pub struct PieceStack {
pieces: Vec<Player>, // 最大3個まで、先頭が一番下
}
```
### GameState
```rust
pub struct GameState {
board: HashMap<Position, PieceStack>,
current_player: Player,
turn_phase: TurnPhase,
second_best_available: bool,
winner: Option<Player>,
}
```
### TurnPhase
```rust
pub enum TurnPhase {
WaitingForMove, // 通常の手番待ち
WaitingForSecondBest, // Second Best宣言待ち
WaitingForSecondMove, // Second Best後の代替手待ち
}
```
### MoveAction
```rust
pub enum MoveAction {
Place { position: Position },
Move { from: Position, to: Position },
}
```
## Pull 型 API (invoke 関数)
### ゲーム管理
#### ` new_game() -> GameState`
- 新しいゲームを開始
- 空のボードで Black プレイヤーから開始
#### ` get_game_state() -> GameState`
- 現在のゲーム状態を取得
#### ` get_legal_moves() -> Vec<MoveAction>`
- 現在のプレイヤーが実行可能な合法手を取得
### プレイヤーアクション
#### ` make_move(action: MoveAction) -> Result<GameState, String>`
- プレイヤーの手を実行
- 不正な手の場合はエラーを返す
- 成功時は更新されたゲーム状態を返す
- 合法な手が実行されると Second Best 宣言は失効
#### ` declare_second_best() -> Result<GameState, String>`
- Second Best 宣言を実行
- 宣言不可能な状況ではエラーを返す
- 成功時は相手に代替手を要求する状態に遷移
### ゲーム情報
#### ` check_winner() -> Option<Player>`
- 勝利条件をチェックして勝者を返す
#### ` get_position_stack(position: Position) -> PieceStack`
- 指定位置のピーススタックを取得
#### ` can_declare_second_best() -> bool`
- Second Best 宣言が可能かどうかを判定
## Push 型 API (emit イベント)
### AI アクション通知
#### ` ai_move_completed`
```rust
pub struct AiMoveEvent {
action: MoveAction,
new_state: GameState,
}
```
- AI が手を完了した時に発火
- 実行された手と新しいゲーム状態を通知
#### ` ai_second_best_declared`
```rust
pub struct AiSecondBestEvent {
new_state: GameState,
}
```
- AI が Second Best 宣言を行った時に発火
- プレイヤーに代替手の選択を要求
#### ` ai_second_move_completed`
```rust
pub struct AiSecondMoveEvent {
action: MoveAction,
new_state: GameState,
}
```
- AI が Second Best 後の代替手を完了した時に発火
### ゲーム状態変更通知
#### ` game_over`
```rust
pub struct GameOverEvent {
winner: Option<Player>,
reason: GameOverReason,
}
pub enum GameOverReason {
VerticalLineup,
HorizontalLineup,
NoMoves,
}
```
- ゲーム終了時に発火
#### ` turn_phase_changed`
```rust
pub struct TurnPhaseEvent {
new_phase: TurnPhase,
current_player: Player,
}
```
- ターンフェーズが変更された時に発火
### エラー通知
#### ` ai_error`
```rust
pub struct AiErrorEvent {
message: String,
}
```
- AI 処理中にエラーが発生した時に発火
## 実装フロー
### 通常のプレイヤーターン
1. フロント: ` get_legal_moves()` で合法手を取得
2. フロント: プレイヤーが手を選択
3. フロント: ` make_move(action)` で手を実行
4. バック: AI ターンを開始、` ai_move_completed` イベントを発火
5. フロント: イベントを受信して UI 更新
### プレイヤーによる Second Best 使用時
1. フロント: AI の手実行後、` can_declare_second_best()` で宣言可能性確認
2. フロント: ` declare_second_best()` を実行
3. バック: AI に代替手を要求、` ai_second_move_completed` イベントを発火
4. フロント: イベントを受信して UI 更新
### AI による Second Best 使用時
1. バック: プレイヤーの手実行後、AI が Second Best 宣言を決定
2. バック: ` ai_second_best_declared` イベントを発火
3. フロント: イベントを受信し、プレイヤーに代替手選択を促す
4. フロント: プレイヤーが代替手を選択、` make_move(action)` で実行
5. フロント: 通常通り AI ターンに移行
## 注意事項
- すべての勝利条件チェックは Second Best 処理完了後に実行
- AI の思考時間は適切に制限し、無限ループを防止
- ゲーム状態の整合性は常にバックエンドで保証
- エラーハンドリングは適切に行い、フロントエンドに分かりやすいメッセージを提供
- 合法な手が実行されると Second Best 宣言は自動的に失効
これでひとまず大丈夫そうですね。もしあとでダメそうなことが分かったら、そのときに修正することにします。
第二回まとめ
今回は長めになっちゃいましたね。ゲームの方向性がかなり具体的になってきて、イメージが明確になってきました。そろそろAIにお願いして形のあるものを作っていけそうな予感がします。
次回はいよいよ Tauri のプロジェクトを作って、少しずつ実装に入っていきたいなと思っています。
ではまた。
続き↓
smooth-pudding.hatenablog.com