🍤

【Ebitengine】OXゲーム作ってみた【環境構築~遊ぶまで】

2024/05/28に公開2

はじめに

先日ゲーム制作の話になり、「メグとばけもの」がGo(Ebitengine)で作られたゲームと知りました。
https://odencat.com/bakemono/ja.html
すげー!と思ったので、何も知らないけどテンションのままに触ってみました。

全人類ゲームを作るために生まれてきているはずなので、インスタで見かけたOXゲームのおもちゃを真似して作ってみます。
※ Goもゲーム制作もほぼ初めてのため、「こんな初心者でもゲーム作れるんだ!」と思って貰えれば幸いです。

Ebitengine とは

特徴

  • Goでできたオープンソースの実用的な2Dゲームエンジン
  • 描画機能は、「矩形画像から矩形画像へ描画を転送する」のみ
  • Windows、macOS、Linux、iOS、Android、Wasm、Nintendo Switch™ ..など、マルチプラットフォームに対応

シンプルかつGoで書かれているので、「見習いPHPerの僕でもいけるんじゃないか?」と思わせてくれる素敵なゲームエンジンです。

https://ebitengine.org/ja/

制作者様について

様々な記事がありますが、ここでは抜粋して紹介します。
Ebitengineについてわかりやすく解説してあります。読み物としても面白いので一読推奨です。

https://zenn.dev/hajimehoshi/articles/2426c6dca8b3b3
https://note.com/hajimehoshi/n/nc09751f2dbf9
https://levtech.jp/media/article/focus/detail_434/

環境構築

基本的には、公式が分かりやすいのでそこを見た方が早いです。
https://ebitengine.org/ja/documents/install.html

私の環境

  • Mac M1
  • go 1.22.3

【Goのインストール】
今回はHomebrewでやってますが、ご自身の環境に合わせてGoを入れてください。

$ brew install go
$ go version
go version go1.22.3 darwin/amd64

※Ebitengine は Go 1.18 以降のバージョンで動作します。

【Cコンパイラのインストール】
まずは下記コマンドを実行して、Cが入っているか確認します。

$ clang
clang: error: no input files

この表示が出ればOKです。
入っていない場合は、clangコマンドの後の指示に従うか、Xcodeを入れてください。
自分は元々Xcodeが入っていました。

補足:CコンパイラはNintendo Switch™の際に必要になるようです。

【環境の確認】
最後に環境構築がうまくいっているか確認します。

$ go run github.com/hajimehoshi/ebiten/v2/examples/rotate@latest

回転するGopherのウィンドウが立ち上がればOKです。

回転するGopher

エラーになる場合

もし、エラーになる場合は、GOROOTやGOTOOLDIRの設定が違うか、Goのモジュールキャッシュの可能性があります。

# ↓こんな感じの場合は go clean -modcache を実行してから試す
go: github.com/hajimehoshi/ebiten/v2/examples/rotate@latest: github.com/hajimehoshi/ebiten/v2@v2.7.4: verifying module: github.com/hajimehoshi/ebiten/v2@v2.7.4: reading https://sum.golang.org/tile/8/0/x101/102: stream error: stream ID 25; INTERNAL_ERROR; received from peer

僕は過去にGoをいろんなものでインストールしていたので、~/.zshrcなり、envファイルのGOROOTを変える必要がありました。
参考までに自分のやつ(brewで入れた場合)

export GOENV_ROOT="$HOME/.goenv"
export PATH="$GOENV_ROOT/bin:$PATH"
eval "$(goenv init -)"
export GOROOT=$(brew --prefix go)/libexec
export GOPATH=$HOME/go
export PATH=$PATH:$GOROOT/bin:$GOPATH/bin

Hello Worldの表示

引き続き公式のチュートリルに沿って、環境設定を行います。
今回はOXゲームを作りたいので、自分はtic-tac-toeという名前で作ってます。

# Create a directory for your game.
$ mkdir tic-tac-toe
$ cd tic-tac-toe

# `go mod init` で go.mod を初期化する.
$ go mod init github.com/dorodango-maker/tic-tac-toe  # dorodango-makerはユーザー名

下記内容のmain.goを追加します。

package main

import (
    "log"

    "github.com/hajimehoshi/ebiten/v2"
    "github.com/hajimehoshi/ebiten/v2/ebitenutil"
)

type Game struct{}

func (g *Game) Update() error {
    return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
    ebitenutil.DebugPrint(screen, "Hello, World!")
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
    return 320, 240
}

func main() {
    ebiten.SetWindowSize(640, 480)
    ebiten.SetWindowTitle("Hello, World!")
    if err := ebiten.RunGame(&Game{}); err != nil {
        log.Fatal(err)
    }
}

依存ライブラリを go.mod に追加します

$ go mod tidy

プログラムを実行してHello Worldを表示します。

$ go run .

Hello Word!

サンプルで遊んでみる

Hello Worldまでいけたので、実際にEbitengineではどんなゲームが作れるのか、公式のサンプルを確認してみます。
今回はflappyで遊んでみました。

$ go run github.com/hajimehoshi/ebiten/v2/examples/flappy@latest

他にもいくつかサンプルがあるので、是非遊んでみてください。(ページの一番下にあります)
https://ebitengine.org/ja/examples/

Ebitengine の書き方

現在のmain.goを見ていきます。

type Game struct{}

func (g *Game) Update() error {
    return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
    ebitenutil.DebugPrint(screen, "Hello, World!")
}

func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
    return 320, 240
}

func main() {
    ebiten.SetWindowSize(640, 480)
    ebiten.SetWindowTitle("Hello, World!")
    if err := ebiten.RunGame(&Game{}); err != nil {
        log.Fatal(err)
    }
}

Ebitengineはebiten.Gameインターフェースを実装した構造体を用意する必要があります。

このインターフェースには

  • Update:値の更新、ボタン、判定、ゲームの進行などの処理。毎ティック(デフォルトで1/60秒ごと)で呼び出される。適当にLog書いたら勢いがすごかった
  • Draw:画面に画像や文字を表示するために毎フレーム呼び出される。
  • Layout:スクリーンサイズの設定。

が定義されていて、これを実装していきます。

これをRunGameに渡してあげるとゲームが動きます。

https://ebitengine.org/en/documents/cheatsheet.html

OXゲームの作成

要件確認

  • 入力を受け付ける度、X→O→X→O...と交互に出力する。
  • 3x3の9マスで、どちらかの記号が3つ揃ったら終了
  • 同じ記号は3つまでしか置けない
  • すでに記号があるマスには上書きできない
  • 4つ目を置くと、一番古いものから消える
  • 4つ目を置く手番になると、一番古いものがどれかわかる

ざっくりとこんなところですかね。
普通のOXゲームの絶対決着つける版です。
一旦今回はコンテキストとか集約はそこまで考えないでおきます。そこまで気が回らないので...。

実装

自分は初めて触るので、とりあえず全てmain.goに書いていきます。

先に最終的な成果物を動かしたい人用。READMEにGIF載せてます。
https://github.com/dorodango-maker/tic-tac-toe

完成形はこちら
package main

import (
    "fmt"
    "image/color"
    "log"

    "github.com/hajimehoshi/ebiten/v2"
    "github.com/hajimehoshi/ebiten/v2/ebitenutil"
    "github.com/hajimehoshi/ebiten/v2/vector"
)

// Symbol - ゲームボード上のシンボル
type Symbol int

const (
    Empty Symbol = iota // 空のマス
    X                   // プレイヤー1のシンボル
    O                   // プレイヤー2のシンボル
    CellSize = 100      // セルのサイズ
    IconSize = 75       // アイコンのサイズ
    GridSize = 3        // グリッドのサイズ
)

// Position - ボード上の位置を表す構造体
type Position struct {
    Row, Col int
}

// Game - ゲームの状態を保持する構造体
type Game struct {
    board [GridSize][GridSize]Symbol // 3x3のボード
    turn Symbol           // 現在のターンのシンボル
    xImg *ebiten.Image    // Xシンボルの画像
    oImg *ebiten.Image    // Oシンボルの画像
    xImgTransparent *ebiten.Image // 半透明のXシンボルの画像
    oImgTransparent *ebiten.Image // 半透明のOシンボルの画像
    winner Symbol         // 勝者のシンボル
    xPositions []Position // Xシンボルの位置
    oPositions []Position // Oシンボルの位置
    oldestPosition Position // 最も古いシンボルの位置
}

// NewGame - ゲームのインスタンスを作成する
func NewGame() *Game {
    xImg, err := loadAndResizeImage("assets/x.png", IconSize, IconSize)
    if err != nil {
        log.Fatalf("failed to load x.png: %v", err)
    }

    oImg, err := loadAndResizeImage("assets/o.png", IconSize, IconSize)
    if err != nil {
        log.Fatalf("failed to load o.png: %v", err)
    }

    xImgTransparent, err := loadAndResizeImage("assets/x_transparent.png", IconSize, IconSize)
    if err != nil {
        log.Fatalf("failed to load x_transparent.png: %v", err)
    }

    oImgTransparent, err := loadAndResizeImage("assets/o_transparent.png", IconSize, IconSize)
    if err != nil {
        log.Fatalf("failed to load o_transparent.png: %v", err)
    }

    return &Game{
        board: [GridSize][GridSize]Symbol{},
        turn: X,
        xImg: xImg,
        oImg: oImg,
        xImgTransparent: xImgTransparent,
        oImgTransparent: oImgTransparent,
        winner: Empty,
        xPositions: []Position{},
        oPositions: []Position{},
        oldestPosition: Position{Row: -1, Col: -1},
    }
}

// loadAndResizeImage - 画像を読み込み、指定されたサイズにリサイズする
func loadAndResizeImage(filePath string, width, height int) (*ebiten.Image, error) {
    img, _, err := ebitenutil.NewImageFromFile(filePath)
    if err != nil {
        return nil, err
    }

    resizedImg := ebiten.NewImage(width, height)
    op := &ebiten.DrawImageOptions{}
    op.GeoM.Scale(float64(width)/float64(img.Bounds().Dx()), float64(height)/float64(img.Bounds().Dy()))
    resizedImg.DrawImage(img, op)

    return resizedImg, nil
}

// Update - ゲームロジックを更新する
func (g *Game) Update() error {
    g.handleWinnerState()
    g.handleGameProgression()
    return nil
}

// handleWinnerState - 勝者が決定した後の状態を処理する
func (g *Game) handleWinnerState() {
    if g.winner != Empty && ebiten.IsMouseButtonPressed(ebiten.MouseButtonRight) {
        g.resetGame()
    }
}

// resetGame - ゲームをリセットする
func (g *Game) resetGame() {
    g.board = [GridSize][GridSize]Symbol{}
    g.turn = X
    g.winner = Empty
    g.resetPositions()
}

// resetPositions - シンボルの位置情報をリセットする
func (g *Game) resetPositions() {
    g.xPositions = []Position{}
    g.oPositions = []Position{}
    g.oldestPosition = Position{Row: -1, Col: -1}
}

// handleGameProgression - ゲームの進行を処理する
func (g *Game) handleGameProgression() {
    g.winner = checkWin(g.board)
    if g.winner != Empty {
        return
    }
    if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {
        g.processPlayerMove()
    }
}

// checkWin - 勝利条件をチェックする
func checkWin(board [GridSize][GridSize]Symbol) Symbol {
    // 横方向の勝利チェック
    for i := 0; i < GridSize; i++ {
        if board[i][0] != Empty && board[i][0] == board[i][1] && board[i][1] == board[i][2] {
            return board[i][0]
        }
    }

    // 縦方向の勝利チェック
    for i := 0; i < GridSize; i++ {
        if board[0][i] != Empty && board[0][i] == board[1][i] && board[1][i] == board[2][i] {
            return board[0][i]
        }
    }

    // 斜め方向の勝利チェック
    if board[0][0] != Empty && board[0][0] == board[1][1] && board[1][1] == board[2][2] {
        return board[0][0]
    }
    if board[0][2] != Empty && board[0][2] == board[1][1] && board[1][1] == board[2][0] {
        return board[0][2]
    }

    return Empty
}

// processPlayerMove - プレイヤーの動きを処理する(シンボルを追加してターンを切り替える)
func (g *Game) processPlayerMove() {
    pos := g.getCursorPosition()
    if g.isValidMove(pos) {
        g.addSymbol(pos)
        g.toggleTurn()
    }
}

// getCursorPosition - カーソルの位置を取得する
func (g *Game) getCursorPosition() Position {
    x, y := ebiten.CursorPosition()
    return Position{Row: y / CellSize, Col: x / CellSize}
}

// isValidMove - シンボルを追加できるかどうかを判定する
func (g *Game) isValidMove(pos Position) bool {
    return pos.Row < GridSize && pos.Col < GridSize && g.board[pos.Row][pos.Col] == Empty && !(g.oldestPosition == pos)
}

// addSymbol - ボードにシンボル(X、O)を追加する
func (g *Game) addSymbol(pos Position) {
    g.updatePositions(g.getCurrentPositions(), pos)
    g.board[pos.Row][pos.Col] = g.turn
}

// getCurrentPositions - 現在のターンのシンボルの位置を取得する
func (g *Game) getCurrentPositions() *[]Position {
    if g.turn == X {
        return &g.xPositions
    }
    return &g.oPositions
}

// updatePositions - シンボルの位置を更新する
func (g *Game) updatePositions(positions *[]Position, pos Position) {
    if len(*positions) == GridSize {
        oldest := (*positions)[0]
        g.board[oldest.Row][oldest.Col] = Empty
        *positions = (*positions)[1:]
        g.oldestPosition = pos
    }
    *positions = append(*positions, pos)
}

// toggleTurn - ターンを切り替える
func (g *Game) toggleTurn() {
    if g.turn == X {
        g.turn = O
    } else {
        g.turn = X
    }
}

// Draw - ゲームの描画を行う
func (g *Game) Draw(screen *ebiten.Image) {
    screen.Fill(color.RGBA{0, 0, 0, 255})
    g.grid(screen)
    g.symbols(screen)
    g.oldestSymbol(screen)
    g.winnerMessage(screen)
}

// grid - グリッドを描画する
func (g *Game) grid(screen *ebiten.Image) {
    for i := 1; i < GridSize; i++ {
        vector.StrokeLine(screen, float32(i*CellSize), 0, float32(i*CellSize), float32(GridSize*CellSize), 2, color.RGBA{255, 255, 255, 255}, false)
        vector.StrokeLine(screen, 0, float32(i*CellSize), float32(GridSize*CellSize), float32(i*CellSize), 2, color.RGBA{255, 255, 255, 255}, false)
    }
}

// symbols - ボード上のシンボルを描画する
func (g *Game) symbols(screen *ebiten.Image) {
    for row := 0; row < GridSize; row++ {
        for col := 0; col < GridSize; col++ {
            op := &ebiten.DrawImageOptions{}
            op.GeoM.Translate(float64(col*CellSize+(CellSize-IconSize)/2), float64(row*CellSize+(CellSize-IconSize)/2))
            if g.board[row][col] == X {
                screen.DrawImage(g.xImg, op)
            } else if g.board[row][col] == O {
                screen.DrawImage(g.oImg, op)
            }
        }
    }
}

// oldestSymbol - 最も古いシンボルを描画する(半透明のシンボルを新たに描画し、最も古いシンボルを削除する)
func (g *Game) oldestSymbol(screen *ebiten.Image) {
    if (g.turn == X && len(g.xPositions) == GridSize) || (g.turn == O && len(g.oPositions) == GridSize) {
        var oldest Position
        var img *ebiten.Image
        if g.turn == X {
            oldest = g.xPositions[0]
            img = g.xImgTransparent
        } else {
            oldest = g.oPositions[0]
            img = g.oImgTransparent
        }
        g.board[oldest.Row][oldest.Col] = Empty
        transparentSymbol(screen, oldest.Row, oldest.Col, img)
        g.oldestPosition = oldest
    }
}

// winnerMessage - 勝者メッセージを描画する
func (g *Game) winnerMessage(screen *ebiten.Image) {
    if g.winner != Empty {
        msg := fmt.Sprintf("Player %d wins! Right-click to reset.", g.winner)
        ebitenutil.DebugPrint(screen, msg)
    }
}

// transparentSymbol - 指定された位置に半透明のシンボルを描画する
func transparentSymbol(screen *ebiten.Image, row, col int, img *ebiten.Image) {
    op := &ebiten.DrawImageOptions{}
    op.GeoM.Translate(float64(col*CellSize+(CellSize-IconSize)/2), float64(row*CellSize+(CellSize-IconSize)/2))
    screen.DrawImage(img, op)
}

// Layout - ウィンドウのレイアウトを設定する
func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
    return 300, 300
}

// main - エントリポイント
func main() {
    ebiten.SetWindowSize(300, 300)
    ebiten.SetWindowTitle("Tic-Tac-Toe")
    if err := ebiten.RunGame(NewGame()); err != nil {
        log.Fatal(err)
    }
}

完成物

ちょっとガバガバな所がありますが、規模が規模なのでご容赦ください...。
記述もベストプラクティスがわからないので、気づいた方はご指摘ください!

1. ボードを作成する

それでは、1から実装していきます。
ここからの行程は、ステップごとにgo run .で確認できるようになっているはずです。

まずはOXゲームのボードから作っていきます。
黒の背景色と、白のグリッドを表示します。
(黒の背景は実行環境の差異が不明のため、追加しています。いらないかも?)

package main

import (
    "image/color"
    "log"

    "github.com/hajimehoshi/ebiten/v2"
    "github.com/hajimehoshi/ebiten/v2/vector"
)

// Symbol - ゲームボード上のシンボル
type Symbol int

const (
    Empty Symbol = iota // 空のマス
    X                   // プレイヤー1のシンボル
    O                   // プレイヤー2のシンボル
    CellSize = 100      // セルのサイズ
    GridSize = 3        // グリッドのサイズ
)

// Game - ゲームの状態を保持する構造体
type Game struct{}

// NewGame - ゲームのインスタンスを作成する
func NewGame() *Game {
    return &Game{}
}

// Update - ゲームの状態を更新する
func (g *Game) Update() error {
    return nil
}

// Draw - ゲームの描画を行う
func (g *Game) Draw(screen *ebiten.Image) {
    screen.Fill(color.RGBA{0, 0, 0, 255})
    g.grid(screen)
}

// grid - グリッドを描画する
func (g *Game) grid(screen *ebiten.Image) {
    for i := 1; i < GridSize; i++ {
        vector.StrokeLine(screen, float32(i*CellSize), 0, float32(i*CellSize), float32(GridSize*CellSize), 2, color.RGBA{255, 255, 255, 255}, false)
        vector.StrokeLine(screen, 0, float32(i*CellSize), float32(GridSize*CellSize), float32(i*CellSize), 2, color.RGBA{255, 255, 255, 255}, false)
    }
}

// Layout - ウィンドウのレイアウトを設定する
func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
    return 300, 300
}

// main - エントリポイント
func main() {
    ebiten.SetWindowSize(300, 300)
    ebiten.SetWindowTitle("Tic-Tac-Toe")
    if err := ebiten.RunGame(NewGame()); err != nil {
        log.Fatal(err)
    }
}

背景色と、グリッドを描画したいため、Drawに書いています。
Updateは使用してないですが、Ebitengineではこれがないとエラーになるため書いておきます。

$ go run .

ボード

2. OXマークを出力する

画面を左クリックしたら、グリッドの中にXマークとOマークを交互に出力するようにします。
画像はドットイラストからお借りしています。

package main

import (
    "image/color"
    "log"

    "github.com/hajimehoshi/ebiten/v2"
+   "github.com/hajimehoshi/ebiten/v2/ebitenutil"
    "github.com/hajimehoshi/ebiten/v2/vector"
)

// Symbol - ゲームボード上のシンボル
type Symbol int

const (
    Empty Symbol = iota // 空のマス
    X                   // プレイヤー1のシンボル
    O                   // プレイヤー2のシンボル
    CellSize = 100      // セルのサイズ
+   IconSize = 75       // アイコンのサイズ
    GridSize = 3        // グリッドのサイズ
)

+ // Position - ボード上の位置を表す構造体
+ type Position struct {
+     Row, Col int
+ }

 // Game - ゲームの状態を保持する構造体
 type Game struct {
+    board [GridSize][GridSize]Symbol // 3x3のボード
+    turn Symbol           // 現在のターンのシンボル
+    xImg *ebiten.Image    // Xシンボルの画像
+    oImg *ebiten.Image    // Oシンボルの画像
}

// NewGame - ゲームのインスタンスを作成する
func NewGame() *Game {
+    xImg, err := loadAndResizeImage("assets/x.png", IconSize, IconSize)
+    if err != nil {
+        log.Fatalf("failed to load x.png: %v", err)
+    }

+    oImg, err := loadAndResizeImage("assets/o.png", IconSize, IconSize)
+    if err != nil {
+        log.Fatalf("failed to load o.png: %v", err)
+    }

    return &Game{
+        board: [GridSize][GridSize]Symbol{},
+        turn: X,
+        xImg: xImg,
+        oImg: oImg,
    }
}

+ // loadAndResizeImage - 画像を読み込み、指定されたサイズにリサイズする
+ func loadAndResizeImage(filePath string, width, height int) (*ebiten.Image, error) {
+     img, _, err := ebitenutil.NewImageFromFile(filePath)
+     if err != nil {
+         return nil, err
+     }

+     resizedImg := ebiten.NewImage(width, height)
+     op := &ebiten.DrawImageOptions{}
+     op.GeoM.Scale(float64(width)/float64(img.Bounds().Dx()), float64(height)/float64(img.Bounds().Dy()))
+     resizedImg.DrawImage(img, op)

+     return resizedImg, nil
+ }

// Update - ゲームロジックを更新する
func (g *Game) Update() error {
+   if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {
+       pos := g.getCursorPosition()
+       if g.isValidMove(pos) {
+           g.addSymbol(pos)
+           g.toggleTurn()
+       }
+   }
    return nil
}

+ // getCursorPosition - カーソルの位置を取得する
+ func (g *Game) getCursorPosition() Position {
+     x, y := ebiten.CursorPosition()
+     return Position{Row: y / CellSize, Col: x / CellSize}
+ }

+ // isValidMove - シンボルを追加できるかどうかを判定する
+ func (g *Game) isValidMove(pos Position) bool {
+     return pos.Row < GridSize && pos.Col < GridSize && g.board[pos.Row][pos.Col] == Empty
+ }

+ // addSymbol - ボードにシンボルを追加する
+ func (g *Game) addSymbol(pos Position) {
+     g.board[pos.Row][pos.Col] = g.turn
+ }

+ // toggleTurn - ターンを切り替える
+ func (g *Game) toggleTurn() {
+     if g.turn == X {
+         g.turn = O
+     } else {
+         g.turn = X
+     }
+ }

// Draw - ゲームの描画を行う
func (g *Game) Draw(screen *ebiten.Image) {
    screen.Fill(color.RGBA{0, 0, 0, 255})
    g.grid(screen)
+   g.symbols(screen)
}

+ // symbols - ボード上のシンボルを描画する
+ func (g *Game) symbols(screen *ebiten.Image) {
+     for row := 0; row < GridSize; row++ {
+         for col := 0; col < GridSize; col++ {
+             op := &ebiten.DrawImageOptions{}
+             op.GeoM.Translate(float64(col*CellSize+(CellSize-IconSize)/2), float64(row*CellSize+(CellSize-IconSize)/2))
+             if g.board[row][col] == X {
+                 screen.DrawImage(g.xImg, op)
+             } else if g.board[row][col] == O {
+                 screen.DrawImage(g.oImg, op)
+             }
+         }
+     }
+ }


表示サイズが大きいので、リサイズしています。

$ go run .

OX表示

3. 勝利条件を設定する

勝利条件を設定します。
3つ同じマークが揃ったら勝利ですので、縦横斜めでそれぞれチェックします。

勝利したことがわかるように、画面にメッセージを表示します。

package main

import (
+   "fmt"
    "image/color"
    "log"

    "github.com/hajimehoshi/ebiten/v2"
    "github.com/hajimehoshi/ebiten/v2/ebitenutil"
    "github.com/hajimehoshi/ebiten/v2/vector"
)

 // Game - ゲームの状態を保持する構造体
type Game struct {
    board  [GridSize][GridSize]Symbol // 3x3のボード
    turn   Symbol           // 現在のターンのシンボル
+   winner Symbol           // 勝者のシンボル
    xImg   *ebiten.Image    // Xシンボルの画像
    oImg   *ebiten.Image    // Oシンボルの画像
}

func NewGame() *Game {
     return &Game{
          board:  [GridSize][GridSize]Symbol{},
          turn:   X,
+         winner: Empty,
          xImg:   xImg,
          oImg:   oImg,
     }
}

func (g *Game) Update() error {
-   if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {
+   if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) && g.winner == Empty {
          pos := g.getCursorPosition()
          if g.isValidMove(pos) {
              g.addSymbol(pos)
+             g.winner = checkWin(g.board)
+             if g.winner == Empty {
                  g.toggleTurn()
+             }
          }
     }
     return nil
}

+ // checkWin - 勝利条件をチェックする
+ func checkWin(board [GridSize][GridSize]Symbol) Symbol {
+     // 横方向の勝利チェック
+     for i := 0; i < GridSize; i++ {
+         if board[i][0] != Empty && board[i][0] == board[i][1] && board[i][1] == board[i][2] {
+             return board[i][0]
+         }
+     }

+     // 縦方向の勝利チェック
+     for i := 0; i < GridSize; i++ {
+         if board[0][i] != Empty && board[0][i] == board[1][i] && board[1][i] == board[2][i] {
+             return board[0][i]
+         }
+     }

+     // 斜め方向の勝利チェック
+     if board[0][0] != Empty && board[0][0] == board[1][1] && board[1][1] == board[2][2] {
+         return board[0][0]
+     }
+     if board[0][2] != Empty && board[0][2] == board[1][1] && board[1][1] == board
+         return board[0][2]
+     }
+ 
+     return Empty
+ }

func (g *Game) Draw(screen *ebiten.Image) {
    screen.Fill(color.RGBA{0, 0, 0, 255})
    g.grid(screen)
    g.symbols(screen)
+   g.winnerMessage(screen)
}

// winnerMessage - 勝者メッセージを描画する
+ func (g *Game) winnerMessage(screen *ebiten.Image) {
+     if g.winner != Empty {
+         msg := fmt.Sprintf("Player %d wins!", g.winner)
+         ebitenutil.DebugPrint(screen, msg)
+     }
+ }

これで基本的なOXゲームになったと思います。

$ go run .

勝利判定

ここまでのコードまとめ
package main

import (
    "fmt"
    "image/color"
    "log"

    "github.com/hajimehoshi/ebiten/v2"
    "github.com/hajimehoshi/ebiten/v2/ebitenutil"
    "github.com/hajimehoshi/ebiten/v2/vector"
)

// Symbol - ゲームボード上のシンボル
type Symbol int

const (
    Empty Symbol = iota // 空のマス
    X                   // プレイヤー1のシンボル
    O                   // プレイヤー2のシンボル
    CellSize = 100      // セルのサイズ
    IconSize = 75       // アイコンのサイズ
    GridSize = 3        // グリッドのサイズ
)

// Position - ボード上の位置を表す構造体
type Position struct {
    Row, Col int
}

// Game - ゲームの状態を保持する構造体
type Game struct {
    board  [GridSize][GridSize]Symbol // 3x3のボード
    turn   Symbol           // 現在のターンのシンボル
    winner Symbol           // 勝者のシンボル
    xImg   *ebiten.Image    // Xシンボルの画像
    oImg   *ebiten.Image    // Oシンボルの画像
}

// NewGame - ゲームのインスタンスを作成する
func NewGame() *Game {
    xImg, err := loadAndResizeImage("assets/x.png", IconSize, IconSize)
    if err != nil {
        log.Fatalf("failed to load x.png: %v", err)
    }

    oImg, err := loadAndResizeImage("assets/o.png", IconSize, IconSize)
    if err != nil {
        log.Fatalf("failed to load o.png: %v", err)
    }

    return &Game{
        board:  [GridSize][GridSize]Symbol{},
        turn:   X,
        winner: Empty,
        xImg:   xImg,
        oImg:   oImg,
    }
}

// loadAndResizeImage - 画像を読み込み、指定されたサイズにリサイズする
func loadAndResizeImage(filePath string, width, height int) (*ebiten.Image, error) {
    img, _, err := ebitenutil.NewImageFromFile(filePath)
    if err != nil {
        return nil, err
    }

    resizedImg := ebiten.NewImage(width, height)
    op := &ebiten.DrawImageOptions{}
    op.GeoM.Scale(float64(width)/float64(img.Bounds().Dx()), float64(height)/float64(img.Bounds().Dy()))
    resizedImg.DrawImage(img, op)

    return resizedImg, nil
}

// Update - ゲームロジックを更新する
func (g *Game) Update() error {
    if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) && g.winner == Empty {
        pos := g.getCursorPosition()
        if g.isValidMove(pos) {
            g.addSymbol(pos)
            g.winner = checkWin(g.board)
            if g.winner == Empty {
                g.toggleTurn()
            }
        }
    }
    return nil
}

// checkWin - 勝利条件をチェックする
func checkWin(board [GridSize][GridSize]Symbol) Symbol {
    // 横方向の勝利チェック
    for i := 0; i < GridSize; i++ {
        if board[i][0] != Empty && board[i][0] == board[i][1] && board[i][1] == board[i][2] {
            return board[i][0]
        }
    }

    // 縦方向の勝利チェック
    for i := 0; i < GridSize; i++ {
        if board[0][i] != Empty && board[0][i] == board[1][i] && board[1][i] == board[2][i] {
            return board[0][i]
        }
    }

    // 斜め方向の勝利チェック
    if board[0][0] != Empty && board[0][0] == board[1][1] && board[1][1] == board[2][2] {
        return board[0][0]
    }
    if board[0][2] != Empty && board[0][2] == board[1][1] && board[1][1] == board[2][0] {
        return board[0][2]
    }

    return Empty
}

// getCursorPosition - カーソルの位置を取得する
func (g *Game) getCursorPosition() Position {
    x, y := ebiten.CursorPosition()
    return Position{Row: y / CellSize, Col: x / CellSize}
}

// isValidMove - シンボルを追加できるかどうかを判定する
func (g *Game) isValidMove(pos Position) bool {
    return pos.Row < GridSize && pos.Col < GridSize && g.board[pos.Row][pos.Col] == Empty
}

// addSymbol - ボードにシンボルを追加する
func (g *Game) addSymbol(pos Position) {
    g.board[pos.Row][pos.Col] = g.turn
}

// toggleTurn - ターンを切り替える
func (g *Game) toggleTurn() {
    if g.turn == X {
        g.turn = O
    } else {
        g.turn = X
    }
}

// Draw - ゲームの描画を行う
func (g *Game) Draw(screen *ebiten.Image) {
    screen.Fill(color.RGBA{0, 0, 0, 255})
    g.grid(screen)
    g.symbols(screen)
    g.winnerMessage(screen)
}

// grid - グリッドを描画する
func (g *Game) grid(screen *ebiten.Image) {
    for i := 1; i < GridSize; i++ {
        vector.StrokeLine(screen, float32(i*CellSize), 0, float32(i*CellSize), float32(GridSize*CellSize), 2, color.RGBA{255, 255, 255, 255}, false)
        vector.StrokeLine(screen, 0, float32(i*CellSize), float32(GridSize*CellSize), float32(i*CellSize), 2, color.RGBA{255, 255, 255, 255}, false)
    }
}

// symbols - ボード上のシンボルを描画する
func (g *Game) symbols(screen *ebiten.Image) {
    for row := 0; row < GridSize; row++ {
        for col := 0; col < GridSize; col++ {
            op := &ebiten.DrawImageOptions{}
            op.GeoM.Translate(float64(col*CellSize+(CellSize-IconSize)/2), float64(row*CellSize+(CellSize-IconSize)/2))
            if g.board[row][col] == X {
                screen.DrawImage(g.xImg, op)
            } else if g.board[row][col] == O {
                screen.DrawImage(g.oImg, op)
            }
        }
    }
}

// winnerMessage - 勝者メッセージを描画する
func (g *Game) winnerMessage(screen *ebiten.Image) {
    if g.winner != Empty {
        msg := fmt.Sprintf("Player %d wins!", g.winner)
        ebitenutil.DebugPrint(screen, msg)
    }
}

// Layout - ウィンドウのレイアウトを設定する
func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
    return 300, 300
}

// main - エントリポイント
func main() {
    ebiten.SetWindowSize(300, 300)
    ebiten.SetWindowTitle("Tic-Tac-Toe")
    if err := ebiten.RunGame(NewGame()); err != nil {
        log.Fatal(err)
    }
}

4. 削除ルール追加

それでは、古いものから消えていくようにします。
一番古いマークがわかるように半透明にしたいのですが中々うまくいかず、
泣く泣くフォトショで半透明の画像を作りました🥲

type Game struct {
    board            [GridSize][GridSize]Symbol // 3x3のボード
    turn             Symbol           // 現在のターンのシンボル
    winner           Symbol           // 勝者のシンボル
    xImg             *ebiten.Image    // Xシンボルの画像
    oImg             *ebiten.Image    // Oシンボルの画像
+   xImgTransparent  *ebiten.Image    // 半透明のXシンボルの画像
+   oImgTransparent  *ebiten.Image    // 半透明のOシンボルの画像
+   xPositions       []Position       // Xシンボルの位置
+   oPositions       []Position       // Oシンボルの位置
+   oldestPosition   Position         // 最も古いシンボルの位置
}

func NewGame() *Game {
    xImg, err := loadAndResizeImage("assets/x.png", IconSize, IconSize)
    if err != nil {
        log.Fatalf("failed to load x.png: %v", err)
    }

    oImg, err := loadAndResizeImage("assets/o.png", IconSize, IconSize)
    if err != nil {
        log.Fatalf("failed to load o.png: %v", err)
    }

+   xImgTransparent, err := loadAndResizeImage("assets/x_transparent.png", IconSize, IconSize)
+   if err != nil {
+       log.Fatalf("failed to load x_transparent.png: %v", err)
+   }

+   oImgTransparent, err := loadAndResizeImage("assets/o_transparent.png", IconSize, IconSize)
+   if err != nil {
+       log.Fatalf("failed to load o_transparent.png: %v", err)
+   }

    return &Game{
        board:           [GridSize][GridSize]Symbol{},
        turn:            X,
        winner:          Empty,
        xImg:            xImg,
        oImg:            oImg,
+       xImgTransparent: xImgTransparent,
+       oImgTransparent: oImgTransparent,
+       xPositions:      []Position{},
+       oPositions:      []Position{},
+       oldestPosition:  Position{Row: -1, Col: -1},
    }
}

func (g *Game) isValidMove(pos Position) bool {
-   return pos.Row < GridSize && pos.Col < GridSize && g.board[pos.Row][pos.Col] == Empty
+   return pos.Row < GridSize && pos.Col < GridSize && g.board[pos.Row][pos.Col] == Empty && !(g.oldestPosition == pos)
}

func (g *Game) addSymbol(pos Position) {
+   g.updatePositions(g.getCurrentPositions(), pos)
    g.board[pos.Row][pos.Col] = g.turn
}

+func (g *Game) getCurrentPositions() *[]Position {
+    if g.turn == X {
+        return &g.xPositions
+    }
+    return &g.oPositions
+}

+func (g *Game) updatePositions(positions *[]Position, pos Position) {
+    if len(*positions) == GridSize {
+        oldest := (*positions)[0]
+        g.board[oldest.Row][oldest.Col] = Empty
+        *positions = (*positions)[1:]
+        g.oldestPosition = pos
+    }
+    *positions = append(*positions, pos)
+}

func (g *Game) Draw(screen *ebiten.Image) {
    screen.Fill(color.RGBA{0, 0, 0, 255})
    g.grid(screen)
    g.symbols(screen)
+   g.oldestSymbol(screen)
    g.winnerMessage(screen)
}

+func (g *Game) oldestSymbol(screen *ebiten.Image) {
+    if (g.turn == X && len(g.xPositions) == GridSize) || (g.turn == O && len(g.oPositions) == GridSize) {
+        var oldest Position
+        var img *ebiten.Image
+        if g.turn == X {
+            oldest = g.xPositions[0]
+            img = g.xImgTransparent
+        } else {
+            oldest = g.oPositions[0]
+            img = g.oImgTransparent
+        }
+        g.board[oldest.Row][oldest.Col] = Empty
+        transparentSymbol(screen, oldest.Row, oldest.Col, img)
+        g.oldestPosition = oldest
+    }
+}

+func transparentSymbol(screen *ebiten.Image, row, col int, img *ebiten.Image) {
+    op := &ebiten.DrawImageOptions{}
+    op.GeoM.Translate(float64(col*CellSize+(CellSize-IconSize)/2), float64(row*CellSize+(CellSize-IconSize)/2))
+    screen.DrawImage(img, op)
+}

削除予定のシンボルになると半透明な画像を描画します。
元々描画されていたシンボルと二重で描画されないように、元のやつは消しておきます。

$ go run .

ルール追加

5. ゲームを続けて遊べるようにする

最後にリセット機能を追加し、ゲームが終わったら、最初からやり直せるようにします。
また、Update()がユーザーの状態を気にしすぎているので分離します。

// Update - ゲームロジックを更新する
func (g *Game) Update() error {
-    if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) && g.winner == Empty {
-        pos := g.getCursorPosition()
-        if g.isValidMove(pos) {
-            g.addSymbol(pos)
-            g.winner = checkWin(g.board)
-            if g.winner == Empty {
-                g.toggleTurn()
-            }
-        }
-    }
+    g.handleWinnerState()
+    g.handleGameProgression()
    return nil
}


// handleWinnerState - 勝者が決定した後の状態を処理する
+func (g *Game) handleWinnerState() {
+    if g.winner != Empty && ebiten.IsMouseButtonPressed(ebiten.MouseButtonRight) {
+        g.resetGame()
+    }
+}

// resetGame - ゲームをリセットする
+func (g *Game) resetGame() {
+    g.board = [GridSize][GridSize]Symbol{}
+    g.turn = X
+    g.winner = Empty
+    g.resetPositions()
+}

// resetPositions - シンボルの位置情報をリセットする
+func (g *Game) resetPositions() {
+    g.xPositions = []Position{}
+    g.oPositions = []Position{}
+    g.oldestPosition = Position{Row: -1, Col: -1}
+}

// handleGameProgression - ゲームの進行を処理する
+func (g *Game) handleGameProgression() {
+    g.winner = checkWin(g.board)
+    if g.winner != Empty {
+        return
+    }
+    if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {
+        g.processPlayerMove()
+    }
+}

// processPlayerMove - プレイヤーの動きを処理する(シンボルを追加してターンを切り替える)
+func (g *Game) processPlayerMove() {
+    pos := g.getCursorPosition()
+    if g.isValidMove(pos) {
+        g.addSymbol(pos)
+        g.toggleTurn()
+    }
+}

// winnerMessage - 勝者メッセージを描画する
func (g *Game) winnerMessage(screen *ebiten.Image) {
    if g.winner != Empty {
-       msg := fmt.Sprintf("Player %d wins!", g.winner)
+       msg := fmt.Sprintf("Player %d wins! Right-click to reset.", g.winner)
        ebitenutil.DebugPrint(screen, msg)
    }
}

一応これで完成になります。

$ go run .

完成

気になる箇所は各々リファクタリングしてください。(できれば教えてください...)
主にゲームとしてのロジックのみ作りましたので、UI等は気が向いたら作ります。

最後に

初めて簡単なゲームを作ってみましたが、Ebitengineはシンプルでわかりやすかったので、普段webアプリ作ってるエンジニアにはおすすめだと思います。
Goの勉強にもなるので、これを見た人はEbitengineデビューしましょう!

Discussion

Hajime HoshiHajime Hoshi

Ebitengine 使ってくださってありがとうございます。

一番古いマークがわかるように半透明にしたいのですが中々うまくいかず、
泣く泣くフォトショで半透明の画像を作りました🥲

DrawImageOptionsColorScale.ScaleAlpha を 1 未満の値で呼ぶと半透明になります。

泥団子職人泥団子職人

ありがとうございます!
作成中ColorScale.ScaleAlphaで0.5指定して上手くいかなくて挫折してしまいました。。
おそらく他の要因だったと思うので、もう一度試してみます!