Go Advent Calendar 2021 (カレンダー2) 22日目の記事です。
#はじめに
「気軽にゲームを作る」をテーマに、2DゲームエンジンEbitenで1つ作品を作ります。
EbitenはシンプルなAPIが特徴の、クロスプラットフォームで動く2Dゲームライブラリです。
詳しい機能や学習に役立つサイトなどは、
公式サイト
や別記事 production-ready なGo製2Dゲームライブラリ Ebiten の紹介 & リンク集(@eihighさん) にありますのでぜひご覧ください。
また、本カレンダー13日目にもEbitenの記事 はじめてのゲームプログラミング(Ebiten)(@hokita222さん)があります。
#プロジェクト構成
go-inovationの構成に準じたものにします。
├ game
│ ├── fonts
│ │ └── mincho.ttf
│ └── se
│ │ └── kane.mp3
│ └── main.go
│ └── mainScene.go
│ └── asset.go
├ main.go
├ go.mod
└ go.sum
#作ったもの
無限年越しそばを作りました。#golang pic.twitter.com/0KGOdJlPeP
— Ichiban Kunio (@ku20298) December 22, 2021
#作る
Ebitenは最低限のAPIを持つので、APIをラップするような形で自分好みに便利な関数を作っていくのが楽しいです。
今回は自前のライブラリ(?) urushiを使います。
まずは、最初に呼び出される、game
ディレクトリ内のゲーム本体をEbiten側にセットするmain.go
です。
package main
import (
"log"
"github.com/hajimehoshi/ebiten/v2"
"github.com/ku20298/soba/game" //ゲームの本体のgameディレクトリをインポート
"github.com/ichibankunio/urushi"
)
func main() {
ebiten.SetWindowSize(360, 640)
mainGame := &game.Game {
UrushiGame: &urushi.Game { //urushiを使うために初期化
State: 0,
},
}
if err := ebiten.RunGame(mainGame); err != nil { //gameのGameインターフェースをebitenにセット
log.Fatal(err)
}
}
次にゲーム本体を作っていきます。
package game
import (
"math/rand"
"time"
"sync"
"github.com/hajimehoshi/ebiten/v2"
"github.com/ichibankunio/urushi"
)
const (
SCREEN_WIDTH = 720
SCREEN_HEIGHT = 1280
SAMPLE_RATE = 44100
)
type Game struct{
UrushiGame *urushi.Game
}
const (
SCENE_MAIN urushi.SceneID = iota
)
func init() {
rand.Seed(time.Now().UnixNano())
}
var once sync.Once
var mainScene *urushi.Scene
func (g *Game) Update() error {
once.Do(func() {//一回だけ呼ばれて、それ以降はスルーされる
g.UrushiGame.AddScene(mainScene)
})
err := g.UrushiGame.Update()
if err != nil {
return err
}
return nil
}
func (g *Game) Draw(screen *ebiten.Image) {
g.UrushiGame.Draw(screen)
}
func (g *Game) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
return SCREEN_WIDTH, SCREEN_HEIGHT
}
mainScene
を作ってUrushiGame
にセットしています。シーンをセットすると、各シーンのUpdate,Drawをurushi側が実行します。これでシーンごとに別のUpdate, Drawを書けるような仕組みにしています。
urushi.Sceneはこんな感じです。
type Scene struct {
Update func(*Game) error
Draw func(*ebiten.Image)
ID SceneID
}
mainSceneの中身を書く前に、フォントと効果音を読み込みます。
```go:game/asset.go
package game
import (
"embed"
"log"
"bytes"
"github.com/hajimehoshi/ebiten/v2/audio/mp3"
"github.com/hajimehoshi/ebiten/v2/audio"
"github.com/ichibankunio/urushi"
"golang.org/x/image/font"
)
//go:embed fonts
var fontsDir embed.FS
//go:embed se
var seDir embed.FS
var (
mainFont font.Face
smallFont font.Face
audioContext *audio.Context
kaneSEByte []byte
)
func init() {
fontByte, err := fontsDir.ReadFile("fonts/mincho.ttf")
if err != nil {
log.Fatal(err)
}
mainFont = urushi.NewFontFromBytes(fontByte, 96)
smallFont = urushi.NewFontFromBytes(fontByte, 48)
}
func init() {
audioContext = audio.NewContext(SAMPLE_RATE)
var err error
kaneSEByte, err = seDir.ReadFile("se/kane.mp3")
if err != nil {
log.Fatal(err)
}
}
func playSE(b []byte) {
s, err := mp3.DecodeWithSampleRate(SAMPLE_RATE, bytes.NewReader(b))
if err != nil {
log.Fatal(err)
}
p, err := audioContext.NewPlayer(s)
if err != nil {
log.Fatal(err)
}
p.Play()
}
Go:embedを使って読み込みます。
フォントはさわらび明朝をサブセットフォントメーカーでサブセットしたもの、効果音はDOVA-SYNDROMEからお借りしました。
最後はにmainScene.go
です。
package game
import (
"image/color"
"math/rand"
"github.com/hajimehoshi/ebiten/v2"
"github.com/ichibankunio/urushi"
)
var score int
type soba struct {
bg *urushi.Sprite
fg *urushi.Sprite
}
func init() {
var sobas [16]*soba
var dots [50]*urushi.Sprite
bgImg := ebiten.NewImage(16, SCREEN_HEIGHT)//まずはebiten.Imageを作る
bgImg.Fill(color.RGBA{150, 150, 150, 255})
fgImg := ebiten.NewImage(12, SCREEN_HEIGHT)
fgImg.Fill(color.RGBA{180, 180, 180, 255})
for i := 0; i < 16; i++ {//そばが横に並ぶように座標を変えながらSpriteを作って代入
sobas[i] = &soba{
// func NewSprite(img *ebiten.Image, x, y float64) *urushi.Sprite
bg: urushi.NewSprite(bgImg, float64(SCREEN_WIDTH/2 + (i-8)*16), 0),
fg: urushi.NewSprite(fgImg, float64(SCREEN_WIDTH/2 + (i-8)*16 + 2), 0),
}
}
dotImg := ebiten.NewImage(4, 4)
dotImg.Fill(color.RGBA{80, 80, 80, 255})
// 麺の上に黒っぽい粒を配置 ランダムに座標を決める
for i := 0; i< 50; i++ {
dots[i] = urushi.NewSprite(dotImg, float64(rand.Intn(len(sobas) * bgImg.Bounds().Dx()) + SCREEN_WIDTH / 2 - len(sobas)/2 * bgImg.Bounds().Dx()), float64(rand.Intn(SCREEN_HEIGHT)))
}
// func NewTxtSpr(txt string, x, y float64, clr color.Color, font font.Face, padUp, padLeft int, hidden bool) *urushi.TxtSpr
titleText := urushi.NewTxtSpr("無限 そば", 0, 500, color.White, mainFont, 0, 0, false)
titleText.SetCenter(SCREEN_WIDTH / 2)
titleText2 := urushi.NewTxtSpr("年越し", 0, 500, color.Black, mainFont, 0, 0, false)
titleText2.SetCenter(SCREEN_WIDTH / 2)
instructionText := urushi.NewTxtSpr("長押しですする", 0, 800, color.White, smallFont, 0, 0, false)
instructionText.SetCenter(SCREEN_WIDTH / 2)
mainScene = &urushi.Scene{
Update: func(g *urushi.Game) error {
if len(ebiten.TouchIDs()) > 0 || ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {//押されている間は粒を上に動かす
for i := range dots {
dots[i].Y -= 2
if dots[i].Y < 0 {
dots[i].Y = SCREEN_HEIGHT
}
}
if score % 1000 == 0 {
playSE(kaneSEByte)
}
score ++
instructionText.Hidden = true
}else {
instructionText.Hidden = false
}
return nil
},
Draw: func(screen *ebiten.Image) {
for _, v := range sobas {
v.bg.Draw(screen)
v.fg.Draw(screen)
}
for _, v := range dots {
v.Draw(screen)
}
titleText.Draw(screen)
titleText2.Draw(screen)
instructionText.Draw(screen)
},
ID: SCENE_MAIN,
}
}
init関数のみのファイルですが、mainSceneの挙動を記述しています。
画像と座標をセットにして、タッチされたかどうかなどを取得できるurushi.Sprite
ebitenのテキスト描画機能に座標の情報やタッチイベントなどを加えた(Text + Spriteな)urushi.TxtSpr
を利用しています。
#まとめ
記事を書いてみることで自分のGoやゲームプログラミングの理解がまだまだだと痛感しました。しかもゲームではない。
Ebitenをラップする一例として見ていただければ幸いです。
クソアプリカレンダーでもEbitenで作ったゲームを紹介しているので見てくれたら嬉しいです。