Godotでリバーシ(オセロ)を作ったらいろいろ勉強になった

2023/07/31に公開

作ったもの

Reversi

バージョンは Godot v4.1.1 です。
リポジトリは以下です。
https://github.com/tkmfujise/Godot-simple_game-examples/tree/main/Reversi

以下のようなクラスで構成しています。


クラス図

Gameシーンが子ノードとしてBoard、更にその子ノードとしてDisk(※別シーン)を持っています。

勉強になった点

ゲームを作ること自体が初心者なので勉強のためリバーシを作ろうとしましたが、思いのほか難しかったです。(制作期間は平日の昼と夜に加えて土日の約1週間でできました)
以下学んだことを列挙します。

  • ドット絵およびスプライトの作成(使用ツール: Pixelorama)
  • マウスクリックした座標を盤上での位置へ変換する
  • 共通変数/型の定義
  • setter の作成
  • リバーシのルールの実装
  • テストコード(使用アドオン: GUT)
  • 全アニメーションの終了に合わせたシグナルの発行
  • CPUの実装

ドット絵およびスプライトの作成

白黒の石(Disk)と緑の盤(Board)の作成は Pixelorama を使いました。
https://github.com/Orama-Interactive/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() を使用します。

src/Game/Board.gd
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 として定義しました。

autoloads/Settings.gd
extends Node

const TILE_SIZE = 64
src/Disk/Disk.gd
func set_location(_location: Vector2i) -> void:
    position = (_location - Vector2i(1, 1)) * Settings.TILE_SIZE
    location = _location
src/Game/Board.gd
func calc_location(postion: Vector2) -> Vector2i:
    return (postion / Settings.TILE_SIZE).ceil()

型の流用: class_name を宣言

共通設定のようなものは上記でよいですが、各ノードで定義した型やenumを参照したい場合があります。(クラス変数的な使い方)

最初 Disk.gd の enum で定義した COLOR を Game.gd から呼び出すにはどうしたらよいかわからなくて、上で書いたように自動読み込みに設定しないといけないのか悩みましたが、
結論としては class_name を宣言すれば参照できるようになりました。

src/Disk/Disk.gd
class_name Disk

enum COLOR { WHITE, BLACK }
src/Game/Game.gd
var player_color : Disk.COLOR

func _ready() -> void:
    initialize(Disk.COLOR.BLACK)

ただ、仕組みはよくわかってないですが単に class_name を宣言しただけで勝手に参照できるようになったので、名前空間はプロジェクト全体で共有してるっぽいです。名前は衝突しないよう注意が必要です。

ある変数の変更に合わせて何か処理をする: setter の作成

  • Disk の位置(location)を変更すると座標(position)を計算して配置する
  • Disk の色を変更すると画像(ここではアニメーションの開始位置)を切り替える

など、ある変数の変更に合わせて何か処理をするには var foo : set = xxx という形で set に関数を指定します。

src/Disk/Disk.gd
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 で枠外のものは捨てるようにしてます。

できたのは以下です。

src/Game/Game.gd
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 というアドオンを使用しました。
https://github.com/bitwes/Gut

インストールしたバージョンは Gut v9.1.0 です。
https://github.com/bitwes/gut/releases

addons ディレクトリに gut フォルダを入れて、「プロジェクト設定」から有効にすると画面下部に「GUT」パネルが表示されるようになります。

ただし、いざ test/unit ディレクトリを作成してテストを書いただけではテストは実行できません。
「GUT」パネルの「Test Directories」にテストを作成したディレクトリを指定する必要があります。これに気づかずだいぶハマりました。


GUTパネル

あと、テストを実行するとウィンドウが立ち上がりますが「プロジェクト設定」で設定したウィンドウサイズのまま表示されるので小さくしている場合は見辛いです。「GUT」パネルの「Exit on Finish」にチェックを入れるとウィンドウがすぐ消えるのでおすすめです。

テストコードは以下のような感じで書いてます。

test/unit/test_Game.gd
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ではストラテジーパターンで作ると書いてあったので、ストラテジーパターンで作ってみました。

https://2dgames.jp/how-to-make-reversi/

src/Game/Game.gd
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()
src/CPU/CPU.gd
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)
src/CPU/EasyCPU.gd
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

https://github.com/tkmfujise/Godot-simple_game-examples/tree/main/Reversi3D

Discussion