Chapter 14

キャラを動かそう / ゲームのフレームとその周辺

eihigh
eihigh
2025.01.31に更新

今回はとりあえずキャラを動かしてみましょう。その中で、ゲームプログラミングの中核となる非常に重要な概念、フレームについて学びます。

gopherくんをジャンプさせる

gopherくん(gopherくんの画像については前回を参照)をジャンプさせたいので、「クリックしている間はgopherくんを上に、離しているときは下に動かす」プログラムを書いてみました(動きが直線的すぎてジャンプっぽくはないですが、そのあたりは次回)。miniten.IsClicked() 関数は、クリックしている間は真を、そうでない時は偽を返します。

package main

import "github.com/eihigh/miniten"

var y = 0

func main() {
	miniten.Run(draw)
}

func draw() {
	if miniten.IsClicked() {
		y -= 6
	} else {
		y += 6
	}
	miniten.DrawImage("gopher.png", 0, y)
}

実はdraw関数は一度だけでなく、高速で何度も呼び出されます。その度にY座標が増減し、それによってgopherくんが下降・上昇しているように見えるわけです。この辺り、もう少し詳しく説明しましょう。

あまねく動画はパラパラ漫画

ゲームに限らず、あらゆる動画は超高速で切り替わる静止画の集まりです。ノートの端に少しずつ動く絵を描いてページをパラパラとめくると絵が動いているように見えるパラパラ漫画と原理はまったく同じです。この静止画の一枚一枚をフレームと呼びます。ゲームが動いているように見えるのも同じです。驚くべきことに、ゲームはフレームが切り替わるごとに白紙から画面内のすべてを描画し直すのが一般的です。

draw関数

FPSの図

minitenの場合、draw関数がフレームごとに呼び出されます。新しいフレームに切り替えるとき、ゲームエンジンは新しいまっさらなフレームを用意し、draw関数を呼び出してフレームに描画させます。そして、次のフレームに切り替わるときには、前のフレームは捨てられ、新しいフレームが用意されます。この繰り返しです。

先のプログラムでは、draw関数の中で少しずつY座標を変えて画像を描画していたため、画像が動いているように見えるわけですね。

超高速なフレームの描画はゲームプログラミングの大きな特徴です。他の分野のプログラマーの中でも意外と知らない人も多かったりしますのでここで是非覚えておいてください。

まとめ

  • ゲームに限らずあらゆる動画は、素早く切り替わる静止画(フレーム) の集まり。
  • minitenでは、draw関数がフレームごとに呼び出される。
  • 描画する内容をフレームごとに少しずつ変えることで、動いているように見える。

詳しい解説

フレームが切り替わる速度

時間あたりのフレームの枚数をフレームレートと呼びます。フレームレートの単位はFPS(フレーム・パー・セカンド。一秒あたりのフレーム数)です。このフレームレートが高いほど、映像はより滑らかに見えます。

大体のゲームはフレームが切り替わるごとに白紙から画面内のすべてを描画し直しているわけですが、昨今主流の60FPSなら、1フレームの描画に掛けられる時間は僅か 1/60秒 = 0.016秒 ほどです。この時間に間に合わないと、画面がちらついたり、動きがカクカクしたりする、いわゆる「処理落ち・コマ落ち」となります。ゲームのグラフィックスとはこのフレームの制限時間内にいかに描画処理を詰め込むかの戦いです。みなさんもゲームを遊ぶときはぜひ思いを馳せてみてください。

draw関数はフレームを制限時間内に描き切る責任を負うため、draw関数の中の処理は長々と時間をかけてはいけません。それは処理落ち・コマ落ちを引き起こします。

フレームの速さを実感するには、draw関数の中で敢えて fmt.Println を実行してみるといいかもしれません。このプログラムは、フレームごとに「フレーム」とターミナルに表示します。かなりの速さで「フレーム」と表示されまくるのが確認できるはずです。

package main

import (
	"fmt"

	"github.com/eihigh/miniten"
)

func main() {
	miniten.Run(draw)
}

func draw() {
	fmt.Println("フレーム")
}
$ go run .
フレーム
フレーム
フレーム
フレーム
フレーム
フレーム

グローバル変数

もう一つ説明していなかったこととして、変数を関数の外で宣言するグローバル変数があります。

関数の中の変数(グローバル変数と対比してローカル変数という)と同じように、宣言と代入があり、両者を同時に行うこともできます。ただ、文法上の制約で := が使えないので、var を使います。

グローバル変数の宣言・代入・利用
package main

import "fmt"

var x = 42 // var から始める必要があり、:=が使えない

func main() {
	fmt.Println(x) // 42
}

ローカル変数はスコープ(関数など)が終わると消えてしまいますが、グローバル変数はプログラムの起動時から終了までずっと存在し続けます。

以下のプログラムでは、プログラムの起動時に x に0が代入され、フレームごとに1が足されます。x は消えないのでずっと1が足され続けます。少しずつgopherくんを描画する座標を右に変えることで、gopherくんが右に動いていくように見えるわけです。

package main

import "github.com/eihigh/miniten"

var x = 0 // 起動時に0を代入

func main() {
	miniten.Run(draw)
}

func draw() {
	x += 1 // フレームごとに1を足して代入
	miniten.DrawImage("gopher.png", x, 0)
}

逆に、以下のプログラムでは、x は関数の中で宣言されているため、フレームごとに x は消えてまた別の x が0から始まる感じになります。これではgopherくんは動きませんね。

package main

import "github.com/eihigh/miniten"

func main() {
	miniten.Run(draw)
}

func draw() {
	x := 0
	x += 1
	miniten.DrawImage("gopher.png", x, 0) // xは常に1
}