名称未設定-2

ゲームオブジェクトとその管理の実装について

今回はゲームオブジェクトの管理方法について、実装方法を紹介します。
なお、コードの実装例は Python で行いますが、言語特有の機能は使わず、一般的なプログラム言語で採用されている機能のみ使うので、Python を知らなくても読み解くことは難しくないと思います。

■ゲームオブジェクトとは

アクションゲームやシューティングゲームでは、大量のゲームオブジェクトを管理する仕組みが必要となります。
ゲームオブジェクトとは、ゲームで登場するキャラクターやアイテムなどです。

例えば、シューティングでは以下のゲームオブジェクトが存在します。

* プレイヤー (自機)
* ショット (自弾)
* 敵
* 敵弾
* アイテム

■配列を使った管理方法

通常プレイヤー(自機)は1つしか存在しないので、プレイヤーの表示位置を管理するには、

player_x = 0 # プレイヤーのX座標
player_y = 0 # プレイヤーのY座標

という定義で実装できます。
ですが、他のゲームオブジェクト (ショット・敵・敵弾) はたいてい複数表示されるので、これをそのまま変数名を増やして実装すると次のようになってしまいます。

enemy1_x = 0 # 一体目の敵のX座標
enemy1_y = 0 # 一体目の敵のY座標
enemy2_x = 0 # 二体目の敵のX座標
enemy2_y = 0 # 二体目の敵のY座標
enemy3_x = 0 # 三体目の敵の……
……

敵を増やそうとするたびに、変数を定義するのは大変です。
そこで、配列を使うと1つの変数で大量の敵を管理できるようになります。

enemy_x = [0, 0, 0, 0, 0, 0, 0, 0] # 8体の敵のX座標
enemy_y = [0, 0, 0, 0, 0, 0, 0, 0] # 8体の敵のY座標

ちなみに、Python では上記の書き方はリストとして扱われます。
上記の記述をした場合、例えば、4体目の敵のX座標は、

px = enemy_x[3] # 配列は "0" 始まりなので、添字は "3" で取得

という方法で取得可能です。
さらに、クラスを使えば、X座標とY座標を1つのデータ型として定義することができます。

# 敵クラス
class Enemy:
	def __init__(self):
		# コンストラクタ
		self.x = 0 # X座標
		self.y = 0 # Y座標

# 敵リストを作成
enemy_list = []
for i in range(8): # 8体ぶん生成
	obj = GameObject()
	enemy_list.append(obj)

こちらで実装した方がデータ構造がスッキリする (1つのクラスにデータがおさまる) ので、構造体やクラスが使えないなど、特別な事情がなければこの実装をオススメします。

■配列 vs リスト

ゲームオブジェクトを配列で管理するか、リストで管理するか、という問題があります。
個人的には配列が好みですが、最近はリストによるオーバーヘッドがほとんど無視できるようなので、どちらでも良いのかもしれません。

以下はゲームオブジェクトをリストで管理する方法です。その場合、対象のオブジェクトが必要になった段階で、インスタンスを生成するようにします。

def create_enemy(x, y):
	# 敵を生成
	enemy = Enemy()
	# 座標を設定
	enemy.x = x
	enemy.y = y
	# リストに登録
	enemy_list.append(enemy)

def update_enemies():
	# 敵を動かす
	for enemy in enemy_list:
		enemy.x += 1
		enemy.y += 1

def destroy_enemy(enemy):
	# 敵をリストから削除する
	enemy_list.remove(enemy)

配列で管理する場合、最初に必要な数をあらかじめ確保しておき、未使用の領域を使い回す実装となります。

# 敵クラスの定義
class Enemy:
	def __init__(self):
		self.x = 0
		self.y = 0
		self.exists = false # 未使用にしておく

enemy_list = []
for i in range(8): # 8体確保
	enemy_list.append(Enemy())

def create_enemy(x, y):
	# 敵の生成
	# 未使用の敵を探す
	enemy = None
	for e in enemy_list:
		if e.exists == False:
			# 未使用の領域を見つけた
			enemy = e
			enemy.exists = True # 使用済みフラグを立てる
			break
	
	if enemy is not None:
		# 生成できたのでパラメータを設定
		enemy.x = x
		enemy.y = y

def upate_enemies():
	# 敵の更新
	for enemy in enemy_list:
		if enemy.exists = True:
			# 存在しているので動かす
			enemy.x += 1
			enemy.y += 1

# 敵の削除
enemy.exists = False # 生存フラグを下げるだけ

"exists" というフラグを用意して、未使用の場合はFalse、使用済みの場合はTrueにすることでインスタンスを使い回しています。

配列で管理する方法はリストに比べて以下のデメリットがあります。

* コードが少し長くなる
* あらかじめ確保した最大数を超えると生成されなくなる

ただ、配列で管理することで、以下のメリットがあります。

* 生成数に制限があることでパフォーマンスのピークが見極めやすい
* インスタンス生成時のコストが最初の一回のみであること
* メモリが荒れない (ガベージコレクションがない場合環境のみ)

さらにリスト側のデメリットとして、ガベージコレクションが発生するとそのタイミングに処理負荷が上がるという問題があります。
個人的には、使い慣れた配列の方法を採用しますが、最近はインスタンス生成やガベージコレクションのコストもあまり気にならないとの情報もあるので、リストでも良いのかもしれません。

■ゲームオブジェクトの構造体・クラスにどのような情報を入れるか

先ほど敵をクラス化しましたが、ゲームオブジェクトには以下の情報があると、アクションゲームやシューティングゲームが作りやすいです。

* 存在フラグ (配列で管理する場合のみ)
* 座標、移動量、加速度
* 当たり判定
class GameObject:
	def __init__(self):
		self.exists = False # 存在フラグ
		
		self.x,  self.y  = 0, 0 # 座標
		self.vx, self.vy = 0, 0 # 移動量
		self.ax, self.ay = 0, 0 # 加速度
		
		self.radius = 0 # 半径 (円で判定を行う場合)
		self.width  = 0 # 幅   (矩形で判定を行う場合)
		self.height = 0 # 高さ (矩形で判定を行う場合)

	def move(self):
		# 移動処理
		self.vx += self.ax
		self.vy += self.ay
		self.x  += self.vx
		self.y  += self.vy
	
	def update(self):
		# 更新処理
		# 移動
		move()
	
	def draw(self):
		# 描画
		pass

加速度は重力などで使用します。
なお、定義するパラメータはゲーム内容によって変化するので、必要に応じて柔軟にパラメータを増やしてよいです。

このクラスを継承して、ショットや敵、敵弾、アイテムを実装します。
オブジェクトの更新と衝突処理、描画の流れは以下のようにします。

1. 各種オブジェクトを更新する (移動など)
2. 各種オブジェクトの衝突判定を行う。衝突していたら消滅などの処理を行う
3. 各種オブジェクトを描画する

「更新」「衝突判定」「描画」の3つを分離させるのには理由があります。
例えば、ポーズボタンを押してゲームを一時停止した場合、「更新」と「描画」の処理を通さないようにして「描画」のみ行うようにすることで、全てのゲームオブジェクトを描画したまま停止できるようになります。
何よりも「更新」と「描画」を別々のところで行うというルールを決めておけば、例えば、描画周りで何か不具合があり調査が必要だった場合に「描画」処理を調べるだけで解決できます。もし「更新」処理が「描画」に入っていたら、余計なコードが多くなって調査が難しくなってしまいます。

■ゲームオブジェクト管理クラスの作成

ゲームオブジェクトを配列やリストで管理するにしても、それぞれのオブジェクトの生成処理や更新、描画を毎回書くのは面倒ですし、同じようなコードが重複しているのは保守性が悪いです。
例えば、ショット(自弾)、敵、敵弾、アイテムの更新処理をベタに書くと以下のようになります。

# ショット(自弾)の更新
for shot in shot_list:
	if shot.exists == True:
		shot.update()

# 敵
for enemy in enemy_list:
	if enemy.exists == True:
		enemy.upate()

# 敵弾
for bullet in bullet_list:
	if bullet.exists == True:
		bullet.update()

# アイテム
for item in item_list:
	if item.exists == True:
		item.update()

明らかに同じようなコードができてしまっています。
これを解消するには、ゲームオブジェクト管理クラスを定義し、使用するゲームオブジェクトの型を渡せるようにします。

# ゲームオブジェクト管理
class GameObjectManager:
	def __init__(self, num, obj):
		# コンストラクタ
		self.pool = []
		for i in range(0, num): # 指定の数だけインスタンスを確保
			self.pool.append(obj())
	def create_obj(self):
		# ゲームオブジェクト取り出し
		for obj in self.pool:
			if obj.exists == False:
				obj.exists = True # 見つかったので使用ずみ
				return obj
		return None # 全て使い切っている
	def update(self):
		# 更新
		for obj in self.pool:
			if obj.exists:
				obj.update()
	def draw(self):
		# 描画
		for obj in self.pool:
			if obj.exists:
				obj.draw()

コンストラクタでは、確保するインスタンスの数と、生成するオブジェクトの型を渡します。
もしリストで必要な時にインスタンスを確保するのであれば、num の値は不要で、型情報のみをメンバ変数に保持しておきます。

なお、型のある言語(C++, C#, Javaなど)の場合は、オブジェクトの型を引数で渡すのではなくて、「テンプレートクラス」として宣言する必要があります。

GamaObjectManagerの使い方としては以下の通りです。

# 敵オブジェクトを 32個確保
enemy_mgr = GameObjectManager(32, Enemy)

# 敵の生成
enemy = enemy_mgr.create_obj()

# 敵の更新
enemy_mgr.update()

# 敵の描画
enemy_mgr.draw()

スッキリかけるようになりました。クラス図を描くとこのような感じですね。


各ゲームオブジェクトは GameObject を継承し、GameObjectManager は GameObject を内部に保持 (集約) していることとなります。

■関連する記事

今回の記事は、アクションゲームやシューティングゲームの作成時に基本となるシステムとなります。

この記事が気に入ったらサポートをしてみませんか?