虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

【Go言語】はじめてのEbitengine【ゲームエンジン】

こんにちは! 虎の穴ラボのA.Mです。

この記事は夏の連載企画の15日目の記事です。
前回はm.mさんによる「画像ファイルをまとめてWebPに変換しよう!」が投稿されました。
次回は原さんによる「もしも転生してWebアプリエンジニアになったら?インフラエンジニアの新たな挑戦」が投稿されます。こちらもぜひご覧ください。

本記事では、Go言語で実装されている2DゲームエンジンであるEbitengineを使用して、何か作ってみたいと思います。

はじめに

以前の記事で、Go言語で作ったゲームを紹介しました。

toranoana-lab.hatenablog.com

toranoana-lab.hatenablog.com

このゲームに対して、以下の2つを実現したいと考えていました。

  • BGMやSEなどの音を追加したい
  • 手軽に遊べるようにしたい(フォントやターミナルのサイズに依存しているのを解消したい)

どうやって実現しようかと考えていたところ、GoはWebAssemblyをサポートしていること思い出し、「そうだ、ブラウザで動かそう!」と思い立ちました。
実際にやってみたところ、以下のようなゲームのコアな部分が完全にOSの機能に依存していたので、がっつり作り直す必要がありました。

  • 画面の描写、操作入力が標準入出力を使用
  • 画面のリフレッシュがclearコマンド
  • 文字装飾(色・背景色・太字の変更など)がエスケープシーケンス

全部自前で実装するのしんどいなぁと思っていたところ、Ebitengineという2Dゲームエンジンを見つけました。
ただ、全部を一度に作り直すのはハードルが高いので、まずはEbitengineに触れてみることにします。
というわけで、今回はEbitengineで簡単な動くものを作ってみたいと思います。

Ebitengineとは

Ebitengineは、Go言語で実装されたオープンソースな2Dゲームエンジンです。 開発者は、星一さんです。

github.com

マルチプラットフォームに対応しているので、Webブラウザ(WebAssembly)だけでなく、デスクトップ(Windows, macOS ,Linux ,FreeBSD)、モバイル (Android, iOS) 、Nintendo Switch™もサポートされています。
商用のゲームで採用されている実績もあるので、安定していると言えます。 また、開発者が日本人なのもあり、日本語のドキュメントもある程度あります。

ブラウザ上で確認できるサンプルも多数あるので、詳細は公式ページを参照してください。

ebitengine.org

開発環境

  • macOS Monterey
  • Go 1.22.5

本記事では、Goがインストール済みである前提で説明します。 環境構築手順については説明しませんので、詳しくは公式ページを参照してください。

インストール - Ebitengine

実行環境の確認

Ebitengineが正常に動作するか確認します。

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

ウィンドウが立ち上がり、回転するGopherくんが表示されればOKです。

まずはHello World

公式のツアーを見ながら、Hello Worldを表示してみます。

Hello, World! - Ebitengine

ディレクトリを作成して初期化します。

mkdir ebitengine-practice
cd ebitengine-practice
# Go Modulesの初期化
go mod init ebitengine-practice

次に、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 tidy

実行すると、以下のように必要なライブラリが追加されます。

$ go mod tidy
go: finding module for package github.com/hajimehoshi/ebiten/v2/ebitenutil
go: finding module for package github.com/hajimehoshi/ebiten/v2
go: found github.com/hajimehoshi/ebiten/v2 in github.com/hajimehoshi/ebiten/v2 v2.7.7
go: found github.com/hajimehoshi/ebiten/v2/ebitenutil in github.com/hajimehoshi/ebiten/v2 v2.7.7

最後に、main.go を実行します。

go run main.go

ウィンドウが立ち上がり、Hello, World! と表示されれば成功です。

コードの説明

Game

type Game struct{}

Gameの構造体を定義します。 Gameはインターフェース(ebiten.Game)であり、ゲームに必要な関数UpdateDrawLayoutを持っています。

Update

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

Updateは、ゲームの論理状態を更新するための関数です。
Updatetickごとに呼び出されます。 tickは論理更新の時間単位で、デフォルト値は1/60秒です。
つまり、Updateメソッドはデフォルトで1秒間に60回呼び出されます。
Hello Worldのサンプルコードでは状態を持っていないので、何もしていません。

Draw

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

Drawは、画面を描画するための関数です。
フレームごとに呼び出されます。フレームはレンダリングの時間単位で、ディスプレイのリフレッシュレートに依存します。
モニターのリフレッシュレートが60Hzの場合、Drawは1秒間に60回呼び出されます。
Hello Worldの例で呼び出しているebitenutil.DebugPrintは、デバッグメッセージをレンダリングするユーティリティ関数です。 Drawが呼び出される度に、screenはクリアされるため、DebugPrintは毎回実行する必要があります。

Layout

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

Layoutは、ゲームの論理的な画面サイズを返す関数です。
このコードでは引数を無視して固定値を返しているので、ウィンドウサイズが変更されてもゲームの画面サイズは変わりません。

main関数の中身

ebiten.SetWindowSize(640, 480)

デスクトップ上のウィンドウサイズを設定しています。
これを呼び出さない場合は、デフォルトのウィンドウサイズが使用されます。

ebiten.SetWindowTitle("Hello, World!")

名前の通り、ウィンドウのタイトルを設定しています。

if err := ebiten.RunGame(&Game{}); err != nil {
    log.Fatal(err)
}

ebiten.RunGameはゲームのメインループを実行する関数です。
引数には ebiten.Gameを実装したGameオブジェクトを渡します。

文字を使ってマップを表示してみる

マップのイメージは以下の通り。 これと同じ構成のダンジョン風のマップを文字(_ | )で実装してみます。コードは以下の通り。

package main

import (
    "log"
    "strings"

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

type Game struct {
    dungeon Dungeon
    player  Player
}

type Player struct {
    RoomX int // プレイヤーがいる部屋のX座標
    RoomY int // プレイヤーがいる部屋のY座標
}

func (p *Player) SetPosition(x int, y int) {
    p.RoomX = x
    p.RoomY = y
}
func (p *Player) Move() {
    p.RoomX++
    if p.RoomX > 6 {
        p.RoomX = 1
    }
}

type Dungeon struct {
    player Player // ダンジョン内にいるプレイヤー
}

// ダンジョンマップ表示
func (d *Dungeon) PrintMap() string {
    var sb strings.Builder

    sb.WriteString("\n")

    for y := 4; y >= 1; y-- {
        // 1列目:壁
        for x := 1; x <= 6; x++ {
            sb.WriteString("__________")
        }
        sb.WriteString("\n")

        // 2列目:見やすくするための空きスペース
        for x := 1; x <= 6; x++ {
            sb.WriteString("|      |")
        }
        sb.WriteString("\n")

        // 3列目:プレイヤー
        for x := 1; x <= 6; x++ {
            sb.WriteString("|  ")
            // 部屋にプレイヤーがいる場合
            if x == d.player.RoomX && y == d.player.RoomY {
                sb.WriteString("● ")
            }
            sb.WriteString("  |")
        }
        sb.WriteString("\n")

        // 4列目:見やすくするための空きスペース
        for x := 1; x <= 6; x++ {
            sb.WriteString("|      |")
        }
        sb.WriteString("\n")

        // 5列目:壁
        for x := 1; x <= 6; x++ {
            sb.WriteString(" ̄ ̄ ̄ ̄ ̄")
        }
        sb.WriteString("\n")
    }

    return sb.String()
}

func (g *Game) Update() error {
    g.dungeon.player.Move()
    return nil
}

func (g *Game) Draw(screen *ebiten.Image) {
    ebitenutil.DebugPrint(screen, g.dungeon.PrintMap())
}

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

func main() {
    ebiten.SetWindowSize(640, 480)
    ebiten.SetWindowTitle("Dungeon")

    p := Player{}
    p.SetPosition(1, 1)
    d := Dungeon{player: p}
    g := Game{dungeon: d}

    if err := ebiten.RunGame(&g); err != nil {
        log.Fatal(err)
    }
}

これを実行すると......

あれ、想定していたものと違いますね...🤔
画面サイズやフォント等も含めて、もう少し手直しが必要そうです。(そもそもDebugPrintを使うべきではないような気もします)

というわけで、若干不完全燃焼ではありますが、今回はここまで。

触ってみた所感

  • ドキュメントやサンプルコードも充実しているので、初心者でも取っ付きやすい
  • Goが入っていればすぐに動かせるので、環境構築が楽
  • ゲームループを自前で作らなくてもいいので、簡単なゲームならすぐ作れそう
  • 以前作ったゲームでは、DrawやUpdateに相当する関数をオブジェクトごとに持っており、どう管理すべきか悩んでいたが、EbitengineではGameインターフェースを実装するだけでいいので、すっきり実装できそう
  • 文字で画面描写するにはちょっとコツが必要そう

まとめ

今回は2DゲームエンジンであるEbitengineを触ってみました。 自前でゲームループや画面描写、リフレッシュなど作る必要がないので、かなり楽にゲームを作れそうです。 本当はWebAssemblyで動かすところまでやってみる予定だったのですが、いろいろ試していたら時間がなくなったので、今回はここまでにしようと思います。
今後はWebAssemblyで動かしてみたり、以前作ったゲームの移植するのをやっていきたいと思います。

採用情報

虎の穴ラボでは一緒に働く仲間を募集中です!
この記事を読んで、興味を持っていただけた方はぜひ弊社の採用情報をご覧ください。
toranoana-lab.co.jp