Godotでリバーシ(オセロ)を作ったらいろいろ勉強になった
作ったもの
バージョンは Godot v4.1.1 です。
リポジトリは以下です。
以下のようなクラスで構成しています。
クラス図
Gameシーンが子ノードとしてBoard、更にその子ノードとしてDisk(※別シーン)を持っています。
勉強になった点
ゲームを作ること自体が初心者なので勉強のためリバーシを作ろうとしましたが、思いのほか難しかったです。(制作期間は平日の昼と夜に加えて土日の約1週間でできました)
以下学んだことを列挙します。
- ドット絵およびスプライトの作成(使用ツール: Pixelorama)
- マウスクリックした座標を盤上での位置へ変換する
- 共通変数/型の定義
- setter の作成
- リバーシのルールの実装
- テストコード(使用アドオン: GUT)
- 全アニメーションの終了に合わせたシグナルの発行
- CPUの実装
ドット絵およびスプライトの作成
白黒の石(Disk)と緑の盤(Board)の作成は Pixelorama を使いました。
アニメーションの作成(Disk): Pixelorama を使用
ドット絵の作成自体は難しくなかったです。Pixelorama を使ってサイズは 64x64 で作成しました。
白から黒にひっくり返るアニメーションを作りたいので全フレームの半分を作成したら、残りは複製して黒に塗りつぶし真ん中のフレームを軸に左右対称のアニメーションにします。
Pixelorama の編集画面
再生して問題なければ、「ファイル」メニューから「書式を指定してエクスポート」します。
「画像」タブではなく「Spritesheet」タブに切り替えて、「列」に作成したフレーム数を入力して「OK」ボタンを押せば全フレームが入ったpngファイルを書き出せます。
Pixelorama のエクスポート画面
アニメーションの読込み(Disk): AnimatedSprite2D を使用
Godot でアニメーションを読込むには、AnimatedSprite2D クラスを作成して、「インスペクター」タブの「Sprite Frames」で新規作成後に画面下に表示される「スプライトフレーム」パネルから追加します。
読み込む際は「Horizontal」と「Vertical」を指定して、作成したフレームがきちんと分割された状態でフレームを選択します。
選択順がフレーム順になるので、端から順に選択します。
Godotのスプライトフレーム画面
今回は、白→黒と黒→白のアニメーションは同じ画像を流用できるので、白→黒なら白(左端のフレーム)から順に右を選択して「フレームを追加」、黒→白なら黒(右端のフレーム)から順に左を選択して「フレームを追加」することで2つのアニメーションを作成します。
繰り返しのドット絵の読込み(Board): Polygon2D を使用
石(Disk)は上記で読み込めましたが、盤(Board)をどうするかは悩みました。
ひとマス分のドット絵を使って81個のノードを追加するか、それとも8×8になる枠線を引いた1枚絵を背景画像として使って1個のノードにするか。
結論としては、ひとマス分のドット絵を Polygon2D の「Texture」で読み込み「Repeat」を"Enabled"にして、きれいな正方形になるよう線を描くことでBoardを作成しました。
Polygon2Dの編集画面
マウスクリックした座標を盤上での位置へ変換する: get_local_mouse_position を使用
今回作成したプログラムでは位置を2次元配列や1次元配列では管理せず、Diskにlocation(Vector2iクラス)を持たせる形で実装しました。
盤上の左上から順に数字を振って
(1, 1) | (2, 1) | (3, 1) | ... | (8, 1)
(1, 2) | (2, 2) | (3, 2) | ... | (8, 2)
︙
(1, 8) | (2, 8) | (3, 8) | ... | (8, 8)
とします。
マウスでクリックした盤上の位置にDiskを置けるようにするため、func _input(event: InputEvent)
でマウスのクリックイベントを検知して、取得した座標をドット絵のサイズで割って位置を算出します。
その際、event.position
でマウスクリックした座標は取得できましたが、それだと作成したシーンを別のシーンから読み込むと位置がずれる問題がありました。
event.position
はウィンドウの絶対座標になるため、他のシーンから呼び出されてもずれないようにするには相対座標を取得する get_local_mouse_position()
を使用します。
extends Polygon2D
signal clicked(location: Vector2i)
func _input(event: InputEvent) -> void:
if event is InputEventMouseButton && event.is_pressed():
# var location = calc_location(event.position) # ←だとウィンドウの絶対座標になる
var location = calc_location(get_local_mouse_position())
emit_signal("clicked", location)
func calc_location(postion: Vector2) -> Vector2i:
return (postion / Settings.TILE_SIZE).ceil()
共通変数/型の定義
コードを書いていると、ドット絵のサイズを共通で呼び出せる場所に定義したくなったり、変数の定義時の型注釈に他で定義したenumやクラス名を指定したくなったりしましたが、以下のようにして解決しました。
- ドット絵のサイズ等の共通変数の定義: 自動読み込みを使用
- 定義したenumやクラス名を他から使用できるようにする: class_name を宣言
共通変数の定義: プロジェクト設定の「自動読み込み」を使用
スクリプトを作成し、プロジェクト設定の「自動読み込み」に設定することで、共通定義の変数などを読み込めます。
今回は、ドット絵のサイズを1箇所で管理したかったので、TILE_SIZE
として定義しました。
extends Node
const TILE_SIZE = 64
func set_location(_location: Vector2i) -> void:
position = (_location - Vector2i(1, 1)) * Settings.TILE_SIZE
location = _location
func calc_location(postion: Vector2) -> Vector2i:
return (postion / Settings.TILE_SIZE).ceil()
型の流用: class_name を宣言
共通設定のようなものは上記でよいですが、各ノードで定義した型やenumを参照したい場合があります。(クラス変数的な使い方)
最初 Disk.gd の enum
で定義した COLOR
を Game.gd から呼び出すにはどうしたらよいかわからなくて、上で書いたように自動読み込みに設定しないといけないのか悩みましたが、
結論としては class_name
を宣言すれば参照できるようになりました。
class_name Disk
enum COLOR { WHITE, BLACK }
var player_color : Disk.COLOR
func _ready() -> void:
initialize(Disk.COLOR.BLACK)
ただ、仕組みはよくわかってないですが単に class_name
を宣言しただけで勝手に参照できるようになったので、名前空間はプロジェクト全体で共有してるっぽいです。名前は衝突しないよう注意が必要です。
ある変数の変更に合わせて何か処理をする: setter の作成
- Disk の位置(location)を変更すると座標(position)を計算して配置する
- Disk の色を変更すると画像(ここではアニメーションの開始位置)を切り替える
など、ある変数の変更に合わせて何か処理をするには var foo : set = xxx
という形で set
に関数を指定します。
enum COLOR { WHITE, BLACK }
@export var color : COLOR : set = change_color
@export var location = Vector2i(1, 1) : set = set_location
func set_location(_location: Vector2i) -> void:
position = (_location - Vector2i(1, 1)) * Settings.TILE_SIZE
location = _location
func change_color(_color: COLOR) -> void:
color = _color
if color == COLOR.WHITE:
set_animation("white_to_black")
else:
set_animation("black_to_white")
リバーシのルールの実装
色をひっくり返せるからリバーシ。置いた場所から8方向に走査して、置いた石と違う色であれば退避して、同じ色があればそこで終了して、、同じ色が無ければ破棄して、、、走査するのは8×8の枠内で、、、、
といろいろ考えると難しくて、ググってサンプルを探しても他人の書いたコードは難読で。
リバーシという古典的な題材ですが、いくつかコードを見ましたが書き方はいろいろあり、プログラムは書いた人の個性が出るんだなあとしみじみと感じました。
開き直って自分が理解しやすいコードを書きました。
- (1) ある位置から一定方向に移動した位置の配列を取得(
locations_line_from
) - (2) 上記(1)を使って、置いた場所から8方向の位置の配列を取得(
directional_locations_from
) - (3) 上記(2)を使って、置いた場所からひっくり返せる石を取得(
reversible_disks_by
)
というようにします。
上記(1)で取得できる位置は8×8の枠内に収まっている必要がありますが、うまくfor文の中で処理するのもぱっと思いつかなかったので8マス分進ませて最後 out_of_range
で枠外のものは捨てるようにしてます。
できたのは以下です。
const MAX_LOCATION_NUMBER = 8
func reversible_disks_by(disk: Disk) -> Array:
var reversible_others = []
for locations in directional_locations_from(disk.location):
var line = []
for next_location in locations:
var next = get_disk(next_location)
if next && next.color != disk.color:
line.append(next)
elif line && next && next.color == disk.color:
line.append(next)
break
else: line = []; break
if line.size() > 1 && line.pop_back().color == disk.color:
reversible_others = reversible_others + line
return reversible_others
# (5,5) => [
# [(5,4), (5,3), (5,2), (5,1)], # ↑
# [(6,4), (7,3), (8,2)], # ➚
# [(4,4), (3,3), (2,2), (1,1)], # ↖
# [(6,5), (7,5), (8,5)], # →
# [(4,5), (3,5), (2,5), (1,5)], # ←
# [(6,6), (7,7), (8,8)], # ➘
# [(4,6), (3,7), (2,8)], # ↙
# [(5,6), (5,7), (5,8)], # ↓
# ]
func directional_locations_from(base: Vector2i) -> Array[Array]:
return [
locations_line_from(base, 0, -1), # ↑
locations_line_from(base, 1, -1), # ➚
locations_line_from(base, -1, -1), # ↖
locations_line_from(base, 1, 0), # →
locations_line_from(base, -1, 0), # ←
locations_line_from(base, 1, 1), # ➘
locations_line_from(base, -1, 1), # ↙
locations_line_from(base, 0, 1), # ↓
]
# locations_line_from(Vector2i(5, 5), -1, 1) => [(4, 6), (3, 7)...]
func locations_line_from(base: Vector2i, dx: int, dy: int) -> Array:
var arr = []
for i in range(1, MAX_LOCATION_NUMBER + 1):
arr.append(Vector2i(base.x + (dx * i), base.y + (dy * i)))
return arr.filter(func(v): return !out_of_range(v))
func out_of_range(location: Vector2i) -> bool:
return not (
(1 <= location.x and location.x <= MAX_LOCATION_NUMBER) &&
(1 <= location.y and location.y <= MAX_LOCATION_NUMBER)
)
きっと、アルゴリズム的に冗長な部分や、他人が見て理解しにくい部分があると思います。
リバーシ難しいですね。
テストコード
さすがに書かないと不安だったのでテストを書きました。
GUT というアドオンを使用しました。
インストールしたバージョンは Gut v9.1.0 です。
addons ディレクトリに gut フォルダを入れて、「プロジェクト設定」から有効にすると画面下部に「GUT」パネルが表示されるようになります。
ただし、いざ test/unit ディレクトリを作成してテストを書いただけではテストは実行できません。
「GUT」パネルの「Test Directories」にテストを作成したディレクトリを指定する必要があります。これに気づかずだいぶハマりました。
GUTパネル
あと、テストを実行するとウィンドウが立ち上がりますが「プロジェクト設定」で設定したウィンドウサイズのまま表示されるので小さくしている場合は見辛いです。「GUT」パネルの「Exit on Finish」にチェックを入れるとウィンドウがすぐ消えるのでおすすめです。
テストコードは以下のような感じで書いてます。
class Test_reversible_disks_by:
extends GutTest
var Game = load("res://src/Game/Game.tscn")
var DiskScene = load("res://src/Disk/Disk.tscn")
var game = null
func before_each():
game = Game.instantiate()
game.clear_disks()
func disk(x: int, y: int, color: Disk.COLOR) -> Disk:
var disk = DiskScene.instantiate()
disk.location = Vector2i(x, y)
disk.color = color
return disk
func sort_disk(a: Disk, b: Disk) -> bool:
return a.location > b.location
func reversible_test(subject: Array, expectation: Array) -> void:
var subject_disk = disk(subject[0], subject[1], subject[2])
var expectation_disks = []
for arr in expectation:
var e_disk = game.get_disk(Vector2i(arr[0], arr[1]))
expectation_disks.append(e_disk)
var result_disks = game.reversible_disks_by(subject_disk)
for arr in [result_disks, expectation_disks]: arr.sort_custom(sort_disk)
assert_eq(result_disks, expectation_disks)
# | _ | ● | ◯ | _ |
func test_case1():
game.put(Vector2i(2, 1), Disk.COLOR.BLACK)
game.put(Vector2i(3, 1), Disk.COLOR.WHITE)
reversible_test([1, 1, Disk.COLOR.WHITE], [[2, 1]])
reversible_test([1, 1, Disk.COLOR.BLACK], [])
reversible_test([4, 1, Disk.COLOR.WHITE], [])
reversible_test([4, 1, Disk.COLOR.BLACK], [[3, 1]])
# | _ | ● | ◯ | ● | ◯ | _ |
func test_case2():
game.put(Vector2i(2, 1), Disk.COLOR.BLACK)
game.put(Vector2i(3, 1), Disk.COLOR.WHITE)
game.put(Vector2i(4, 1), Disk.COLOR.BLACK)
game.put(Vector2i(5, 1), Disk.COLOR.WHITE)
reversible_test([1, 1, Disk.COLOR.WHITE], [[2, 1]])
reversible_test([1, 1, Disk.COLOR.BLACK], [])
reversible_test([6, 1, Disk.COLOR.WHITE], [])
reversible_test([6, 1, Disk.COLOR.BLACK], [[5, 1]])
# | ◯ | ◯ | ◯ | ◯ | ◯ | ◯ | ◯ |
# | ◯ | ● | ● | ● | ● | ● | ◯ |
# | ◯ | ● | ● | ● | ● | ● | ◯ |
# | ◯ | ● | ● | _ | ● | ● | ◯ |
# | ◯ | ● | ● | ● | ● | ● | ◯ |
# | ◯ | ● | ● | ● | ● | ● | ◯ |
# | ◯ | ◯ | ◯ | ◯ | ◯ | ◯ | ◯ |
func test_case3():
for x in range(1, 8):
for y in range(1, 8):
if x == 1 or x == 7 or y == 1 or y == 7:
game.put(Vector2i(x, y), Disk.COLOR.WHITE)
else:
game.put(Vector2i(x, y), Disk.COLOR.BLACK)
reversible_test([4, 4, Disk.COLOR.BLACK], [])
reversible_test([4, 4, Disk.COLOR.WHITE], [
[2, 2], [4, 2], [6, 2],
[3, 3], [4, 3], [5, 3],
[2, 4], [3, 4], [5, 4], [6, 4],
[3, 5], [4, 5], [5, 5],
[2, 6], [4, 6], [6, 6],
])
関数名が test_
で始まるものだけテストが実行されます。
やっぱテストがあると安心感が違いました。あと、Game#put や Game#clear_disks など普通にテストを意識せず作るときに書いてましたが、テスト書く際にそれらがあってよかったなと。メソッドを細かく定義していた恩恵を感じました。
全アニメーションの終了に合わせたシグナルの発行
全ての Disk がひっくり返ってから次のターンに移りたいなど、アニメーションが全て終わるのを待つのをどうするか、これは単に作ったことが無かったから少し悩んだという程度ですがアニメーション中の Disk を animation_disks
という変数に入れて管理することで実現しました。
_on_disk_animation_finished
で各アニメーションが終わるたびにフレーム番号が開始位置を表す "0" になっているものを animation_disks
から除外して、すべて終わるとシグナルを発行するという流れです。
var animation_disks : Array[Node] = []
func try_reverse_by(disk: Disk) -> void:
for other in reversible_disks_by(disk):
animation_disks.append(other)
other.reverse()
func _on_disk_animation_finished() -> void:
animation_disks = animation_disks.filter(
func(c): return c.frame != 0)
if animation_disks.is_empty():
emit_signal("all_animation_finished")
func _on_all_animation_finished() -> void:
emit_signal_color_count_changed()
take_turn()
CPUの実装
CPUの実装に関して、下記の参考URLではストラテジーパターンで作ると書いてあったので、ストラテジーパターンで作ってみました。
var cpu : CPU
func initialize(_player_color: Disk.COLOR) -> void:
# 中略
setup_cpu(EasyCPU)
# setup_cpu(NormalCPU)
# setup_cpu(HardCPU)
func setup_cpu(level: Object) -> void:
cpu = level.new()
cpu.initialize(self)
func take_turn() -> void:
# 中略
if current_color == cpu.color: cpu.perform()
extends Node
class_name CPU
var color : Disk.COLOR
var game : Game
func initialize(_game: Game) -> void:
game = _game
if _game.player_color == Disk.COLOR.BLACK:
color = Disk.COLOR.WHITE
else: color = Disk.COLOR.BLACK
func perform() -> void:
var location = decide_place()
game.place(location)
extends CPU
class_name EasyCPU
func sort_reversible_count(a: Move, b: Move) -> bool:
return a.reversible_count < b.reversible_count
func decide_place() -> Vector2i:
var moves = placeable_moves()
moves.sort_custom(sort_reversible_count)
return moves[0].location # 取れる Disk の数が最小の位置を返す
decide_place
メソッドを各 XxxCPU クラスで違う実装にすることで指し手を変えるようにしました。
クラス図
最後に
残課題として、置ける場所がなかった場合(パス)の実装と、CPUのレベルを画面から変更できないですが、とりあえずリバーシで遊べるレベルまでできたので満足です。
今回は2Dで作成しましたが、次は3Dで作成しようと考えています。
追記(2023/8/7)
3Dでもリバーシを作ってみました。8〜9割は2Dのコードの流用で動いています。パスも実装しました。
Reversi3D
Discussion