17
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Go言語でレトロな感じの2Dゲームを作る

Last updated at Posted at 2020-01-22

はじめに

昔Qiitaに、Go言語で作るマリオ風2Dゲームという記事を書きました。これを発展させる形で、昨年の中頃から会社の友人何人かと一緒に、Golangを使ってゲームを作り始めました。

私が昔、洞窟物語などのシンプルな2DのRPGゲームが好きだったこともあり、レトロさや手作り感を感じられるようなゲームの開発を目指してきました。最近になってこのゲーム開発の活動が停留してきたので、この記事でとりあえず現在までで出来たものをまとめてみます。

Githubのリポジトリはここです。

今のところ出来たもの

出来たもののgifをいくつか貼ります。背景画像およびプレーヤー画像などは仮にフリーの素材を用いたものであり、いずれはかっこいいものを自作したいと思ってます。

first.gif
作っているのは、こんな感じの2Dの俯瞰目線のゲームです。白い骸骨がプレーヤーで、青いお化けが敵キャラです。敵キャラの目が赤くなっているときは「追跡モード」で、プレーヤーを追いかけます(それ以外の時は、敵キャラはランダムな方向に移動しています)。

second.gif
骸骨は火の玉を撃って、敵を攻撃できます。

third.gif
ボスっぽいものも実装しました。ボスは8方向に火の玉を出します。下のほうにある緑の棒が、ライフバーです。

コードについて

使ったライブラリ

engoというライブラリを使いました。このライブラリでは、"System"という構造体を使ってエンティティ―(プレーヤー、敵、ボスとか)を作成します。"System"にはデフォルトで、ロード時に呼ばれる"New"関数、毎フレーム呼ばれる"Update"関数、そしてゲームからの削除時に呼ばれる"Remove"関数が用意されています。

こんな感じです。

// SomeSystem なんかのシステム
type SomeSystem struct {
    world        *ecs.World
    texture      *common.Texture
}

func (ss *SomeSystem) New(w *ecs.World) {
    // 新規作成時の処理をここに書く
}

func (ss *SomeSystem) Update(dt float32) {
    // 毎フレーム行う処理をここに書く
}

func (ss *SomeSystem) Remove(entity ecs.BasicEntity) {
    // 削除時の処理をここに書く
}

大変だったところ

ゲーム開発を行ったメンバーは皆、ゲーム制作はもちろんプログラミングやデザインに関しても素人であったので、いろいろ苦労しました。下に、実装において大変だったことをいくつか羅列していきます。

背景の描画

将来的には背景画像を自作したいと考えているのですが、当面はオンライン上で拾ってきた素材画像を使用します。このサイトにあるやつとかです。これらの画像を、細かく切り出して画面に張り付けることで、背景を描画しています。

その際、どの位置にどの画像を張るかの情報を、json形式のファイルに保持するようにしました。こんな感じです。

{
    "meta-data":{
        "id":0,
        "player-initial-positions":{
            "A":{"X":5,"Y":5},
            "B":{"X":26,"Y":32}
        },
        "camera-initial-positions":{
            "A":{"X":300,"Y":200},
            "B":{"X":700,"Y":900}
        },
        "boss-fight":0,
        "spritesheet":"pics/overworld_tileset_grass.png"
    },
    "cell-data":[
        [
            {
                "cell":95,
                "portal":false,
                "obstacle":true,
                "enemy": false
            },{
                "cell":95,
                "portal":false,
                "obstacle":true,
                "enemy": false
            },{
                "cell":95,
                "portal":false,
                "obstacle":true,
                "enemy": false
            },{
                "cell":95,
                "portal":false,
                "obstacle":true,
                "enemy": false
            },{
                "cell":95,
                "portal":false,
                "obstacle":true,
                "enemy": false
            },{
                "cell":95,
                "portal":false,
                "obstacle":true,
                "enemy": false
            },
            [セルの数だけこれが続く...]
        ]
    ]
}

"meta-data"の中でステージのメタ情報(プレーヤーが最初どの位置にいるべきか、ボス戦であるかどうか、など)を保持して、"cell-data"の二重配列の中で、各セルごとの情報(描画するべき画像、通り抜けられるかどうか、敵がいるかどうかなど)を保持します(実物はGithubにあります)。

このjsonファイルを、必要な時に読み込んでいきます。ただし、Go言語でjsonファイルを読み込むのはかなり大変です。jsonファイルと同じ形式の構造体を作っておいてもいいのですが、それだと柔軟にファイルに情報を付け足したりしにくくなるので、"interface{}"として読み取ってます。この部分のコードを一部抜粋すると、こんな感じです。

// jsonファイルの読み込み
file, err := os.Open(stageFileToRead)
if err != nil {
    fmt.Println(err)
}
defer file.Close()
byteValue, _ := ioutil.ReadAll(file)
// 頑張ってjsonファイルを読み込む
var sceneJSON map[string]interface{}
json.Unmarshal([]byte(byteValue), &sceneJSON)
// プレーヤーの初期位置情報を取得
playerInitialPositionX = int(sceneJSON["meta-data"].(map[string]interface{})["player-initial-positions"].(map[string]interface{})["A"].(map[string]interface{})["X"].(float64))
playerInitialPositionY = int(sceneJSON["meta-data"].(map[string]interface{})["player-initial-positions"].(map[string]interface{})["A"].(map[string]interface{})["Y"].(float64))
// カメラ(視点)の初期位置情報を取得
cameraInitialPositionX = int(sceneJSON["meta-data"].(map[string]interface{})["camera-initial-positions"].(map[string]interface{})["A"].(map[string]interface{})["X"].(float64))
cameraInitialPositionY = int(sceneJSON["meta-data"].(map[string]interface{})["camera-initial-positions"].(map[string]interface{})["A"].(map[string]interface{})["Y"].(float64))
// ボス戦かどうかの情報を取得
if int(sceneJSON["meta-data"].(map[string]interface{})["boss-fight"].(float64)) == 1 {
    ifBossFight = true
} else {
    ifBossFight = false
}

見ての通り、力技で頑張っています(Github上のコード)。

現在は、上に挙げた形式のjsonファイルを読み込ませれば、とりあえずステージは作れるようになっています。

シーンの切り替え

プレーヤーが特定の位置(ドアとか)に移動したら、シーンを切り替えて、背景画像を更新したり、敵のエンティティ―を作成したり、場合によってはボス戦を開始します。

これの実装方法に関して結構悩んだのですが、最終的には"intermission"という、シーンの切り替えを表す"System"を作成することにしました。この"System"では、"New"関数で画面に黒い幕を下ろし、"Update"関数でシーン切り替えの処理をして、"Remove"関数で幕を取り外すよう実装してあります。

// New 新規作成
func (is *IntermissionSystem) New(w *ecs.World) {
    is.world = w
    // 画面を黒く覆う
    shadePic, _ = common.LoadedSprite("pics/black_bk.png")
    shadingProgress = 0
    intermissionState = false
}

// Remove 削除時の関数
func (is *IntermissionSystem) Remove(entity ecs.BasicEntity) {
    for _, system := range is.world.Systems() {
        switch sys := system.(type) {
            case *common.RenderSystem:
                // 自分自身のデータを削除
                sys.Remove(entity)
        }
    }
}

// Update 毎フレームの処理
func (is *IntermissionSystem) Update(dt float32) {
    // いろいろやってるけど、長いので省略
    // 新しいステージを読み込んだりしています
    // 詳細はGitHubのリポジトリを見てください
}

Github上の、完全版のコードはこちら

プレーヤーの攻撃

プレーヤーは火の玉を撃てます。この火の玉ですが、単一の画像では面白くないので、大きくなったり小さくなったりして、動きを出すようにしました。そのため、火の玉のエンティティーは自分が作成されてからどれくらいの時間が経ったかの情報を保持して、それに応じて自身の画像を切り替えています。

// 毎フレーム呼ばれる処理
func (bs *BulletSystem) Update(dt float32) {
    // 自身の年齢を更新
    bullet.bulletPicChangeCounter++
    // 自身の年齢に応じて、描画する画像を取得
    bulletPicIndex := bullet.bulletPicChangeCounter / 5
    // 寿命が来たら、ステージから削除
    if bulletPicIndex > 7 {
        bs.Remove(bullet.BasicEntity)
        bulletEntities = removePlayerBullet(bulletEntities, bullet)
        continue
    }
    // 寿命が来てなかったら、画像を更新
    bullet.RenderComponent.Drawable = bulletPics[bulletPicIndex]
}

コード全体はこちらです。

敵の動き

プレーヤーが遠くにいるとき、敵はランダムな動きをします。しかし、プレーヤーが近づいたときには、プレーヤーめがけて移動するようになります。こんな感じの実装です。

// 敵とプレーヤーのチェビシェフ距離
distance := math.Abs(float64(playerInstance.cellX - o.cellX))
if distance < math.Abs(float64(playerInstance.cellY-o.cellY)) {
    distance = math.Abs(float64(playerInstance.cellY - o.cellY))
}
if distance < enragedDistance {
    // 距離が一定以内なら、追跡モードにする
    o.mode = 1
} else {
    // そうでなければ、追跡モードにしない
    o.mode = 0
}

modeが1になっていたら、ランダムな時間にランダムな方向へ移動する動きをやめて、素早くプレーヤーに向かって動くようになります。コード全体はこちらです。

最後に

最近開発がバッタリと止まってしまいました。少しずつでもコーディングを進めて、何とか形にしていきたいです。

17
12
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
17
12

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?