【Ebitengine】OXゲーム作ってみた【環境構築~遊ぶまで】
はじめに
先日ゲーム制作の話になり、「メグとばけもの」がGo(Ebitengine)で作られたゲームと知りました。
すげー!と思ったので、何も知らないけどテンションのままに触ってみました。全人類ゲームを作るために生まれてきているはずなので、インスタで見かけたOXゲームのおもちゃを真似して作ってみます。
※ Goもゲーム制作もほぼ初めてのため、「こんな初心者でもゲーム作れるんだ!」と思って貰えれば幸いです。
Ebitengine とは
特徴
- Goでできたオープンソースの実用的な2Dゲームエンジン
- 描画機能は、「矩形画像から矩形画像へ描画を転送する」のみ
- Windows、macOS、Linux、iOS、Android、Wasm、Nintendo Switch™ ..など、マルチプラットフォームに対応
シンプルかつGoで書かれているので、「見習いPHPerの僕でもいけるんじゃないか?」と思わせてくれる素敵なゲームエンジンです。
制作者様について
様々な記事がありますが、ここでは抜粋して紹介します。
Ebitengineについてわかりやすく解説してあります。読み物としても面白いので一読推奨です。
環境構築
基本的には、公式が分かりやすいのでそこを見た方が早いです。
私の環境
- 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です。
エラーになる場合
もし、エラーになる場合は、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 Worldまでいけたので、実際にEbitengineではどんなゲームが作れるのか、公式のサンプルを確認してみます。
今回はflappyで遊んでみました。
$ go run github.com/hajimehoshi/ebiten/v2/examples/flappy@latest
他にもいくつかサンプルがあるので、是非遊んでみてください。(ページの一番下にあります)
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
に渡してあげるとゲームが動きます。
OXゲームの作成
要件確認
- 入力を受け付ける度、X→O→X→O...と交互に出力する。
- 3x3の9マスで、どちらかの記号が3つ揃ったら終了
- 同じ記号は3つまでしか置けない
- すでに記号があるマスには上書きできない
- 4つ目を置くと、一番古いものから消える
- 4つ目を置く手番になると、一番古いものがどれかわかる
ざっくりとこんなところですかね。
普通のOXゲームの絶対決着つける版です。
一旦今回はコンテキストとか集約はそこまで考えないでおきます。そこまで気が回らないので...。
実装
自分は初めて触るので、とりあえず全てmain.goに書いていきます。
先に最終的な成果物を動かしたい人用。READMEにGIF載せてます。
完成形はこちら
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 .
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
Ebitengine 使ってくださってありがとうございます。
DrawImageOptions
のColorScale.ScaleAlpha
を 1 未満の値で呼ぶと半透明になります。ありがとうございます!
作成中
ColorScale.ScaleAlpha
で0.5指定して上手くいかなくて挫折してしまいました。。おそらく他の要因だったと思うので、もう一度試してみます!