
ゲーム描画のためのデータ構造
ぼっちgamedev Advent Calendar 2018の4日目。
・ゲームで描画するもの
・描画に必要な情報
・描画のためのデータ構造
について書く。
ゲームで描画するもの
ゲームで描画するものは、当然ながらプレイヤーに見せたいもの全てだ。とは言うものの、開発の構想段階から全てのオブジェクトについて把握している訳ではないだろう。初期には考えていなかった機能追加も有り得る。
描画するものの具体的な例を考えてみると以下のようなものがある。
・プレイヤーキャラクター(PC)
・ノンプレイヤーキャラクター(NPC)
・背景画像
・キャラクター立ち絵
・マップ(床や壁など)
・ドロップアイテム
・ミニマップ
・システムUI(メニューやステータスなど)
・エフェクト(呪文詠唱やダメージなど)
・弾やボール
・スコアなどの文字列
これらは画像で表現されることもあれば図形の場合もあり、描画処理もそれぞれ異なる。
描画に必要な情報
改めて描画に必要な情報を考える。描画位置は最低限必要として、他には拡大・縮小・回転などの変形情報、不透明度などが共通のデータとして、一般的なゲームエンジンで扱われている。
その他の情報は、具体例を見て考えていく。
典型的なゲームでは、PCとNPC、マップやアイテムなどは、タイルマップから固定サイズで画像を切り出し、画面に貼り付けることで描画している。そのため、タイルマップへの参照と、タイルの通し番号が必要だろう。あるいはタイル自体への参照を持っていても良い。
背景画像やキャラクター立ち絵は、そのまま画像への参照を持っていて良い。
弾、ボールなどは単純な図形として表現されることがあり、画像を用いない場合はグラフィックスAPIで描画すれば良い。描画位置以外に必要な情報が無い場合、描画対象ごとにrender関数を作ることになる。
文字列の描画には、当然ながらその文字列の情報が必要となる。他には色やアラインメント(右寄せ・左寄せ)などがあれば良いだろう。
描画のためのデータ構造
ここまで考えてきたオブジェクトを、更新可能・描画可能なオブジェクトとして同一視することで、ゲームの中身の実装に集中しやすくなる。そのためのデータ構造を考える。
先に挙げた通り、全ての描画可能オブジェクトが持つべき情報として、オブジェクトの描画位置・変形情報・不透明度がある。まずはこれを1つの構造体にまとめる。
struct Renderable<T> {
position: Point,
scale: f32,
rotate: f32,
opacity: f32,
prop: T,
}
描画したいオブジェクトごとの情報Tを内包するRenderable構造体を定義した。render関数の実装は、例えば以下のように描画基準位置などを設定してから中身のTのdrawを呼び出す方法が考えられる。
trait Drawing {
fn draw(&self, ctx: &RenderContext) {
unimplemented!();
}
}
trait Rendering {
fn render(&self, ctx: &RenderContext) {
unimplemented!();
}
}
impl<T: Drawing> Rendering for Renderable<T> {
fn render(&self, ctx: &RenderContext) {
ctx.setCoordinate(self.position);
ctx.setTransform(self.scale, self.rotate);
ctx.setOpacity(self.opacity);
self.prop.draw(ctx);
}
}
この方法の場合、TはDrawingトレイトを実装していること、すなわちdraw関数を持っていることを制約付けている。具体的なオブジェクト実装は以下のようになる。
struct TileProperty<'a> {
tiles: &'a TileManager,
tile_id: usize,
}
type Tile = Renderable<TileProperty>
impl Drawing for TileProperty {/* 略 */}
struct TextProperty<'a> {
content: String,
color: Color,
align: TextAlign,
font: &'a Font,
font_size: u32,
}
type Text = Renderable<TextProperty>
impl Drawing for TextProperty {/* 略 */}
struct PictureProperty<'a> {
image: &'a Image,
}
type Picture = Renderable<PictureProperty>
impl Drawing for PictureProperty {/* 略 */}
ここまで、オブジェクトを描画する方法を実装してきた。これを用いて、リスポーン可能回数やコイン枚数を持ったプレイヤーキャラクターを実装すると以下のようになる。
use std::ops::Deref;
struct GameObject<T: Rendering, U> {
renderable: T,
state: U,
}
/// これでGameObjectから直接renderを呼び出せるようになる
impl<T: Rendering, U> Deref for GameObject<T, U> {
type Target = T;
fn deref(&self) -> &T {
&self.renderable
}
}
struct CharacterState {
spawn_count: u32,
coin: u32,
}
type Character = GameObject<Tile, CharacterState>
これでゲームデータと描画に係る情報を、データ構造的に完全に分離することが出来た。この分離によってゲームルールのテストが行いやすく、バグの特定が容易になるという利点が得られた。
まとめ
・ゲームに登場するオブジェクトの描画処理は共通している場合がある
・描画処理が異なるオブジェクトを同一視することでゲームの中身に集中
・ゲームデータと描画情報を分離することでテストが行いやすくなる
明日はコントローラ入力感度の調整について書きたい。