ハノイの塔のゲームを作った。
以下にデプロイしているので、興味があったら遊んでみてほしい。
ソースコード
ソースコードを GitHub で公開している。
設計
以下ゲームの設計について解説する。(このゲームには React とか TypeScript とか色んな要素があるが、設計に絞る)
100 人が同じソフトウェアを作ったとき、その設計は人それぞれで個性が出るところである。メリットもデメリットもある。河原は今回ここに書いた設計が良いかなと思ったが、正解とは限らない。しかし何となく良いと思ったのではなく、考え方がある。それを以下で説明する。
ゲームの状態を考える
まずゲームの状態をイミュータブルに管理することにした。
イミュータブル(Immutable)とは不変という意味で、一旦作ったあとは書き換えられない、ということである。
ゲームの状態をイミュータブルに管理しておき配列に保存しておけば、Forza Motorsport のような巻き戻しの実現が容易になるだろうし、それをハノイの塔に取り入れてみたいと思ったのだ。
Forza Motorsport はプロプライエタリなソフトウェアなので、実際に状態をイミュータブルに管理しているかはわからない。でもイミュータブルじゃないと難しいんじゃないかなと思う。
イミュータブルにすると決めたので、ゲームの状態は一つのイミュータブルなオブジェクトになる。
ゲームの状態は何を持っているのかについて考えた。考えた末に、以下に至った。
レベルはほぼ定数(0
=かんたん、2
=むずかしい、みたいな感じ)なので特に説明することがない。
塔の状態は 2 次元配列で表現することにした。
以下に塔の図と配列の対応を示す。配列は首を右に 90 度傾けて見ればわかりやすいかもしれない。
[
[2, 1, 0],
[],
[]
]
[
[2]
[1]
[0]
]
操作回数は画面に表示されたほうがやりがいがあるかなと思って入れた。これは操作するたびに 1 増えるだけである。
こういったゲームの状態をGame
として以下のように実装する。
export class Game implements IGame {
private constructor(
public readonly level: number,
public readonly towers: number[][],
public readonly count: number
) {}
}
円盤を移動させるルールの落とし込みを考える
まだ円盤を移動させるルールが無い。ルールを実装しなかったら小さい円盤の上に大きい円盤を置ける不正な状態を作り出せてしまい、ゲームソフトとして成立しない。このためルールを実装してやる必要がある。
ルールをどこに実装するかは開発者の自由だ。当然Game
に書くこともできるし、GameManager
のようなクラスを作ることもできる。迷うところだ。
こういうとき 凝集度(Cohesion) が一つの指針になる。
ソフトウェアのモジュールは凝集度を高くすべきと考える人たちがいるのだ。
良いコード/悪いコードで学ぶ設計入門―保守しやすい 成長し続けるコードの書き方
正確にかつ簡潔に伝えるのは難しいのだが、ある責任に関係することは一つの部品にまとめようぜ、という感じである。
この考え方によると、不正な状態を生み出さないようにするルールは状態の近くに書くべきなので、ルールをGame
に実装することにした。
Game
に円盤を移動させるルールを書くのだが、イミュータブルの世界は不変なのでメンバを書き換えない。
不変でありながら状態を変えるという矛盾は、新しいオブジェクトを生成して返すことで解決させる。
export class Game implements IGame {
private constructor(
public readonly level: number,
public readonly towers: number[][],
public readonly count: number
) {}
+
+ canMove(from: number, to: number) {
+ const fromValue = this.towers.at(from)?.at(-1);
+ const toValue = this.towers.at(to)?.at(-1);
+
+ if (fromValue === undefined) {
+ // 移動元に何もなければ移動できない
+ return false;
+ }
+ if (toValue === undefined) {
+ // 移動先に何もなければ移動できる
+ return true;
+ }
+
+ // 移動元よりも移動先の方が大きければ移動できる
+ return fromValue < toValue;
+ }
+
+ move(from: number, to: number) {
+ if (!this.canMove(from, to)) {
+ throw new Error("移動できない");
+ }
+ const towers = structuredClone(this.towers);
+ const value = towers[from].pop()!;
+ towers[to].push(value);
+ return new Game(this.level, towers, this.count + 1);
+ }
}
move(移動元の列, 移動先の列)
で移動させた後の状態が返ってくるようにした。
このメソッドには移動先の円盤のほうが大きければ円盤を移動できるというルールが含まれており、常に正しい状態が返ってくるのだ。
ゲームクリアのルールの落とし込みを考える
ゲームをクリアできたのかわからないとつまらないのでゲームクリアのルールを実装する。円盤の移動と同じ理由でGame
に実装することにした。
export class Game implements IGame {
private constructor(
public readonly level: number,
public readonly towers: number[][],
public readonly count: number
) {}
+
+ get cleared() {
+ // 一番右の塔以外の高さが0だったらクリアとする
+ for (let i = 0; i < this.towers.length - 1; i++) {
+ if (this.towers[i].length >= 1) {
+ return false;
+ }
+ }
+ return true;
+ }
canMove(from: number, to: number) {
const fromValue = this.towers.at(from)?.at(-1);
const toValue = this.towers.at(to)?.at(-1);
if (fromValue === undefined) {
// 移動元に何もなければ移動できない
return false;
}
if (toValue === undefined) {
// 移動先に何もなければ移動できる
return true;
}
// 移動元よりも移動先の方が大きければ移動できる
return fromValue < toValue;
}
move(from: number, to: number) {
if (!this.canMove(from, to)) {
throw new Error("移動できない");
}
const towers = structuredClone(this.towers);
const value = towers[from].pop()!;
towers[to].push(value);
console.log(towers);
return new Game(this.level, towers, this.count + 1);
}
}
初期状態を生成する
移動のルールを実装することで移動後の状態が正しくなるようにしたが、最初の状態が不正だと台無しなので正しい初期状態を生成するメソッドを作成する。
レベルを入力すると初期状態が返ってくるようにしてみた。
export class Game implements IGame {
+ public static readonly LEVELS = 6;
private constructor(
public readonly level: number,
public readonly towers: number[][],
public readonly count: number
) {}
get cleared() {
// 一番右の塔以外の高さが0だったらクリアとする
for (let i = 0; i < this.towers.length - 1; i++) {
if (this.towers[i].length >= 1) {
return false;
}
}
return true;
}
canMove(from: number, to: number) {
const fromValue = this.towers.at(from)?.at(-1);
const toValue = this.towers.at(to)?.at(-1);
if (fromValue === undefined) {
// 移動元に何もなければ移動できない
return false;
}
if (toValue === undefined) {
// 移動先に何もなければ移動できる
return true;
}
// 移動元よりも移動先の方が大きければ移動できる
return fromValue < toValue;
}
move(from: number, to: number) {
if (!this.canMove(from, to)) {
throw new Error("移動できない");
}
const towers = structuredClone(this.towers);
const value = towers[from].pop()!;
towers[to].push(value);
console.log(towers);
return new Game(this.level, towers, this.count + 1);
}
+
+ static create(level: number): IGame {
+ if (level <= -1 || level >= Game.LEVELS) {
+ throw new Error("レベルがおかしい");
+ }
+ if (level === 0) {
+ return new Game(0, [[2, 1, 0], [], []], 0);
+ } else if (level === 1) {
+ return new Game(1, [[3, 2, 1, 0], [], []], 0);
+ } else if (level === 2) {
+ return new Game(2, [[4, 3, 2, 1, 0], [], []], 0);
+ } else if (level === 3) {
+ return new Game(3, [[5, 4, 3, 2, 1, 0], [], []], 0);
+ } else if (level === 4) {
+ return new Game(4, [[6, 5, 4, 3, 2, 1, 0], [], []], 0);
+ }
+ return new Game(5, [[9, 8, 7, 6, 5, 4, 3, 2, 1, 0], [], []], 0);
+ }
}
巻き戻し(IRS)の落とし込みを考える
イミュータブルに設計したきっかけである、巻き戻しを実装する。河原はこの機能を IRS(Infinite Rewind System)と名付けた。
src/App.tsx
でGame
の配列を保持し、操作するたびに配列に追加していけば添字を使って任意の地点に巻き戻せるのだ。
src/App.tsx
は長いので IRS に関する部分だけ抜粋する。
function App() {
// ゲームの履歴
const [games, setGames] = useState<IGame[]>([]);
const [gameIndex, setGameIndex] = useState(-1);
// 現在のゲーム
const currentGame = games[gameIndex];
function handleMove(from: number, to: number) {
if (!games[gameIndex].canMove(from, to)) {
// 円盤を移動できない場合は何もしない
return;
}
let updatedGames = [...games];
if (gameIndex !== games.length - 1) {
// 巻き戻し済みなら、巻き戻しから先の履歴を削除する
updatedGames = updatedGames.slice(0, gameIndex + 1);
}
// 円盤を移動した結果を履歴に追加する
updatedGames = [...updatedGames, currentGame.move(from, to)];
setGames(updatedGames);
setGameIndex(updatedGames.length - 1);
}
// 略
function handleRewind(index: number) {
setGameIndex(index);
}
}
味付けして完成させる
ゲームの心臓部ができたので、その他味付けしてゲームを完成させる。
UI ライブラリには React を使っている。React アプリケーションとしては見どころがないので特に解説しない。
ドラッグアンドドロップ操作で直感的に遊べるようにした。PC とスマートフォン両方に対応させるのは大変なのでdnd-kit
というライブラリを使っている。
このライブラリを使うと超簡単にドラッグアンドドロップ操作を実現できるので、特に解説しない。
今回作ったのは小規模なゲームだが、Forza Motorsport のような超大作のソフトウェアだって小さなものを組み合わせて作られているに違いない。
河原は小さなものを組み合わせて大きなものを作るというソフトウェアの考え方が好きだ。
おわり