【Godot】倉庫番の実装サンプルと解説

定番パズルゲームの「倉庫番」を実装したのでサンプルコードとその解説をします。

倉庫番の実装サンプル

プロジェクトファイル

プロジェクトファイルは GitHub の以下のページからダウンロードできます。

なお使用している画像ファイルは OpenGameArt から Kenneyさんの “Sokoban (100+ tiles)” を使わせていただきました。

プロジェクトのデータ構造

プロジェクトのデータ構造は以下のとおりです。

データ構造
gd_sokoban
 +-- assets
 |    +-- fonts: フォントデータ
 |    |    +-- bmpfont.fnt: BMPフォント
 |    |    +-- bmpfont.png: BMPフォント画像
 |    |    +-- bmpfont.tres: BMPフォントリソース
 |    |    +-- mplus-1c-regular.ttf: TTFフォント
 |    |    +-- new_theme.tres: TTFフォントリソース
 |    |    
 |    +-- images: 画像データ 
 |    |    +-- block.png: 壁画像
 |    |    +-- crate.png: 荷物画像
 |    |    +-- crate_white.png: 荷物点滅用画像
 |    |    +-- ground.png: 床画像
 |    |    +-- player.png: プレイヤー画像
 |    |    +-- player_start.png: プレイヤー開始位置
 |    |    +-- point.png: 荷物の目標位置
 |    | 
 |    +-- tile 
 |         +-- new_tileset.tres: タイルセットリソース 
 |    
 +-- src: ソースコード(シーン) 
 |    +-- common: 共通モジュール
 |    |    +-- Common.gd: 共通処理 (現在のステージ数やリプレイ、CanvasLayerの管理)
 |    |    +-- Direction.gd: 方向
 |    |    +-- Field.gd: フィールド管理 (マップの移動できる場所や荷物を動かせるかどうかの判定など)
 |    |    +-- GridObject.gd: グリッド単位で動くオブジェクト管理
 |    |    +-- Point2.gd: 位置情報
 |    |    
 |    +-- crate: 荷物オブジェクト
 |    |    +-- Crate.gd: 荷物オブジェクトスクリプト
 |    |    +-- Crate.tscn: 荷物オブジェクトシーン
 |    |     
 |    +-- level: レベルデータ (ステージ)
 |    |    +-- level00_template.tscn: レベルのテンプレート
 |    |    +-- level01.tscn: レベル1
 |    |    +-- level02.tscn: レベル2
 |    |    +-- level03.tscn: レベル3
 |    |     
 |    +-- player: プレイヤーオブジェクト
 |         +-- Player.gd: プレイヤースクリプト
 |         +-- Player.tscn: プレイヤーシーン
 |    
 +-- Main.gd: メインシーンスクリプト
 +-- Main.tscn: メインシーン
   

レベルデータの説明

レベルデータ(ステージ)は「src/level」に入っています。

レベルごとに level01, level02, level03 というように連番でファイル名を指定しています。

これによりレベル数に対応するシーンファイル名を以下の記述で取得できるようになります。

## レベルシーンのパスを取得する.
func get_level_scene(level:int=0) -> String:
	if level <= 0:
		# 指定がない場合は現在のレベルを使用する.
		level = _level
	
	return "res://src/level/level%02d.tscn"%level

レベルのシーンツリー構造

レベルシーンのツリー構造は Back のタイルマップと、Front のタイルマップから構成されています。

Frontのタイルが重要で、ここに壁や荷物、プレイヤーのスタート地点、荷物の目標となる場所が配置されます。

レベルの読み込み処理について

レベルの読み込みは Main.gd_ready() で行っています。

func _ready() -> void:

	...

	# Frontのタイルマップを取得する.
	var tile_front = level_obj.get_node("./Front")
	
	# フィールドをセットアップする.
	Field.setup(tile_front)

	# Frontタイルの情報からインスタンスを生成する.	
	for j in range(Field.TILE_HEIGHT):
		for i in range(Field.TILE_WIDTH):
			var v = tile_front.get_cell(i, j)
			if _create_obj(i, j, v):
				# 生成したらタイルの情報は消しておく.
				tile_front.set_cell(i, j, Field.eTile.NONE)

読み込んだレベルデータから「Front」タイルマップを取り出し、get_cell() で値を取り出して、それが「プレイヤーの開始地点」だったらPlayerオブジェクトを生成し、「荷物」だったら Crate (タイル) オブジェクトを生成しています。

## タイル情報から生成されるオブジェクトをチェック&生成.
func _create_obj(i:int, j:int, id:int) -> bool:
	match id:
		Field.eTile.START:
			# プレイヤー開始位置.
			_create_player(i, j)
			return true
		Field.eTile.CRATE1, Field.eTile.CRATE2, Field.eTile.CRATE3, Field.eTile.CRATE4:
			# 荷物.
			_create_crate(i, j, id)
			return true
	
	# 生成されていない.
	return false

タイル定数

タイル定数は Field.gd に定義しています。

enum eTile {
	NONE = -1,
	# 通路
	BLANK = 0,
	# 壁
	BLOCK = 1,
	# 荷物
	CRATE1 = 2,
	CRATE2 = 3,
	CRATE3 = 4,
	CRATE4 = 5,
	# 荷物を移動させる場所.
	POINT1 = 6,
	POINT2 = 7,
	POINT3 = 8,
	POINT4 = 9,
	# プレイヤー
	START = 10, # 開始地点
}

GridObject.gd について

GridObject.gd はグリッド単位で動くオブジェクトの基底となるクラスです。

倉庫番では通常、タイル(グリッド)単位でオブジェクトが移動します。

このようなオブジェクトを扱うために共通となるクラスがあるとコード量を減らせるため、GridObject.gd は存在しています。

メンバ変数(プロパティ)に座標を表す “_point” 、方向を表す “_dir” を保持しており、またそれらを設定や取得する関数を用意しています。

extends Area2D

# ===========================
# グリッド(インデックス)座標系で動作するオブジェクトの既定.
# ===========================
class_name GridObject

# ---------------------------------------
# preload.
# ---------------------------------------
const Point2 = preload("res://src/common/Point2.gd")

# ---------------------------------------
# vars.
# ---------------------------------------
var _point = Point2.new()
var _dir:int = Direction.eType.DOWN

# ---------------------------------------
# public functions.
# ---------------------------------------
## グリッド座標系を設定.	
func set_pos(i:int, j:int, is_center:bool) -> void:
  # グリッド座標系をワールド座標に変換して描画座標に反映する.
	position.x = Field.idx_to_world_x(i, is_center)
	position.y = Field.idx_to_world_y(j, is_center)

	# グリッド座標を設定.
	_point.set_xy(i, j)

## 方向を設定する.
func set_dir(dir:int) -> void:
	_dir = dir

## 指定の座標と一致しているかどうか.
func is_same_pos(i:int, j:int) -> bool:
	return _point.equal_xy(i, j)

## グリッド座標系のXを取得する.
func idx_x() -> int:
	return _point.x

## グリッド座標系のYを取得する.
func idx_y() -> int:
	return _point.y

そして、プレイヤーオブジェクトの Player.gd や 荷物オブジェクトの Crate.gd で、このスクリプトを継承しています。

extends GridObject
# ===========================
# プレイヤー.
# ===========================

class_name Player
...
extends GridObject
# ===========================
# 荷物オブジェクト.
# ===========================

class_name Crate
...

プレイヤーについて

プレイヤーの更新について

プレイヤーの更新は独自関数の “proc()” で行っています。

func proc(delta:float) -> void:
	_anim_timer += delta

	# キーの入力判定.
	var is_moving = false
	if Input.is_action_just_pressed("ui_left"):
		_dir = Direction.eType.LEFT
		is_moving = true
	elif Input.is_action_just_pressed("ui_up"):
		_dir = Direction.eType.UP
		is_moving = true
	elif Input.is_action_just_pressed("ui_right"):
		_dir = Direction.eType.RIGHT
		is_moving = true
	elif Input.is_action_just_pressed("ui_down"):
		_dir = Direction.eType.DOWN		
		is_moving = true
	
	if is_moving:
		# 移動する.
		_move()
		
	_spr.frame = _get_anim_id(int(_anim_timer*4)%4)

倉庫番のようなターン制のゲームでは、オブジェクトの更新順が重要となるため、Godot Engine 固有の更新関数 (_process()) をあえて使わない方が都合が良いためこのようにしました。

ただ、今回の倉庫番のようなゲームでは、動くのはプレイヤーだけなので、ここまでする必要はなかったのかもしれません。

プレイヤーの移動処理

プレイヤーの移動処理は _move() で行っています。

グリッド単位で動くゲームの場合、グリッド座標系で移動処理や判定を行い、移動ができたら最終的な座標 (position) に反映させるのがセオリーとなります。

## 移動.
func _move() -> void:
	# 移動先を調べる.
	var prev_dir = _dir # 移動前の向き
	var now = Point2.new(_point.x, _point.y) # 現在の位置
	var next = Point2.new(_point.x, _point.y) # 移動先の位置
	# 移動方向.
	var d = Direction.get_point(_dir)
	next.iadd(d) # 加算.
	
	if Field.is_crate(next.x, next.y):
		# 移動先が荷物.
		if Field.can_move_crate(next.x, next.y, d.x, d.y):
			# 移動できる.
			# 荷物を動かす.
			Field.move_crate(next.x, next.y, d.x, d.y)
			# プレイヤーも動かす.
			set_pos(next.x, next.y, true)
		
			# リプレイデータを追加.
			_add_replay(now, prev_dir, next, _dir)

	elif Field.can_move(next.x, next.y):
		# 移動可能.
		set_pos(next.x, next.y, true)
		
		# リプレイデータを追加.
		_add_replay(now, prev_dir, next, Direction.eType.NONE)

プレイヤーが移動できるかの判定は「移動先」に壁があるかどうか、もしくは荷物があるかどうかで行います。

荷物の場合は、荷物が動かせれば移動できますが、荷物が動かせない場合は移動できないという分岐が必要となります。

荷物オブジェクトについて

荷物オブジェクトは Crate.gd に処理が記載されています。

extends GridObject
# ===========================
# 荷物オブジェクト.
# ===========================
class_name Crate

# ---------------------------------------
# enum.
# ---------------------------------------
enum eType {
	BROWN = Field.eTile.CRATE1,
	RED = Field.eTile.CRATE2,
	BLUE = Field.eTile.CRATE3,
	GREEN = Field.eTile.CRATE4,
}

# ---------------------------------------
# onready.
# ---------------------------------------
onready var _spr = $Sprite
onready var _spr2 = $Sprite2

# ---------------------------------------
# vars.
# ---------------------------------------
var _type = eType.BROWN
var _timer = 0.0

...

# ---------------------------------------
# public functions.
# ---------------------------------------
## 荷物の種類を取得する.
func get_type() -> int:
	return _type

...

_type” という変数にタイル番号をそのまま保持していますので、これを使って荷物を配置する場所の判定ができます。

ちなみに enum で eTile が未定義というエラーが出ていますが、動作上は問題なかったのでこのままとしました。

もしエクスポート時などに問題があるようでしたら、この値を別の数値に置き換える必要があります。

荷物の正解判定

荷物を正しい位置に移動させると荷物は点滅します。

これは見た目上の変化(ゲームの処理順に依存しない)なので _process() で以下のように実装しています。

## アニメーションの更新.
func _process(delta: float) -> void:
	_timer += delta
	
	_spr2.visible = false
	if Field.is_match_crate_type(idx_x(), idx_y(), _type):
		# マッチしているので点滅する.
		_spr2.visible = true
		_spr2.modulate.a = 0.5 * abs(sin(_timer*4))

ここで使用している Field.is_match_crate_type() は Field.gd で以下のように実装しています。

_type は タイルの番号と位置しているので、get_cell() でタイル情報を取得してその色がそれぞれのポイントと一致すれば正しい場所となります。

## 指定の荷物が正しい位置にあるかどうか.
func is_match_crate_type(i:int, j:int, type:int) -> bool:
	var v = get_cell(i, j)
	match v:
		eTile.POINT1:
			return type == eTile.CRATE1 # 茶色.
		eTile.POINT2:
			return type == eTile.CRATE2 # 赤色.
		eTile.POINT3:
			return type == eTile.CRATE3 # 青色.
		eTile.POINT4:
			return type == eTile.CRATE4 # 緑色.
		_:
			return false # マッチしてない.

クリア判定

ステージクリア判定

ステージクリア判定は Field.gdis_stage_clear() で行っています。

## ステージクリアしたかどうか.
func is_stage_clear() -> bool:
	for crate in Common.get_layer("crate").get_children():
		var i = crate.idx_x()
		var j = crate.idx_y()
		var type = crate.get_type()
		if is_match_crate_type(i, j, type) == false:
			return false # 一致していないものがある.
	
	# すべて一致した.
	return true

“crate” レイヤーに荷物オブジェクトがすべて格納されているので、それらすべてが正解であればステージクリアとなります。

ゲームクリア判定

ゲームをクリア(すべてのレベルを完了)しているかどうかは、Common.gdcompleted_all_level() で判定します。

## 最終レベルを終えたかどうか.
func completed_all_level() -> bool:
	return _level > FINAL_LEVEL

“_level” というのがステージ数なので、最終ステージを超えていればゲームクリアです。

リプレイの実装について

リプレイの実装は Common.gd にすべて記載しています。

リプレイデータについて

リプレイの実装方針としては、1手に対する情報を差分で保持しておき、現在の状態にその差分を加味することで実装します。

具体的にはリプレイの1手のデータは、以下のようになっています。

  • player_pos: プレイヤーの移動前の座標
  • player_dir: プレイヤーが移動した方向
  • crate_pos: 荷物の移動前の座標
  • crate_dir: 荷物が移動した方向 (移動しない場合は Direction.eType.NONE)
## リプレイデータ.
class ReplayData:
	var player_pos = Point2.new()
	var player_dir = Direction.eType.NONE
	var crate_pos = Point2.new()
	var crate_dir = Direction.eType.NONE
	func _init(px:int, py:int, dir:int, cx:int=0, cy:int=0, cdir:int=Direction.eType.NONE) -> void:
		player_pos.set_xy(px, py)
		player_dir = dir
		crate_pos.set_xy(cx, cy)
		crate_dir = cdir

リプレイデータには1手戻す「UNDO」と、1手進める「REDO」の2つの処理があります。

UNDOの場合には 1手前の情報 “player_pos” の位置にプレイヤーを戻し、荷物は “crate_pos” に戻します(移動させていたら)。REDOの場合は、現在の位置から “player_dir” の逆方向にプレイヤーを移動させ、荷物も “crate_dir” の逆方向に移動させます(移動していたら)。

荷物は同時に1つしか移動できないので、これらの情報があればリプレイは実装できます。

UNDOの実装について

Common.gd の ReplayMgr の add_undo() が UNDO データの追加です。

	## undoを追加する.
	func add_undo(d:ReplayData, is_clear_redo:bool=true):
		undo_list.append(d)
		if is_clear_redo:
			redo_list.clear() # undoが追加されたらredoは消える.

通常、1手進めた場合、REDOのデータは消えますが、REDOで1手進めた場合は例外的に消さないようにするため、引数でREDOを消すかどうかを指定できるようにしています。

UNDOの実行は “undo_list” から末尾のデータを取り出して、player_pos の値と player_dir の値をそのまま Player オブジェクトに設定します。そして、”crate_dir” が Direction.eType.NONE でなければ荷物も移動したので、荷物も移動させます。

ただ荷物に関しては荷物のインスタンスを取得するためには、現在の位置から取得する必要があるため、過去の位置と方向から現在の位置を計算する処理を入れています。

	## undoを実行する.
	func undo(player:Player):
		if undo_list.empty():
			return # 何もしない.
		
		# 末尾から取り出す.
		var d:ReplayData = undo_list.pop_back()
		player.set_pos(d.player_pos.x, d.player_pos.y, true)
		player.set_dir(d.player_dir)
		if d.crate_dir != Direction.eType.NONE:
			# 荷物の位置も戻す.
			var xprev = d.crate_pos.x
			var yprev = d.crate_pos.y
			var v = Direction.get_point(d.crate_dir)
			var xnow = xprev + v.x
			var ynow = yprev + v.y
			var crate = Field.search_crate(xnow, ynow)
			crate.set_pos(xprev, yprev, true) # 1つ戻す
		
		# redoに追加.
		add_redo(d)

REDOの実装

REDOの実装は、過去の位置から次の位置を割り出す必要があるので、プレイヤーと荷物のそれぞれで次の位置を割り出す計算をしています。

	## redoを実行する.
	func redo(player:Player) -> void:
		if redo_list.empty():
			return # 何もしない.
		
		# 末尾から取り出す.
		var d:ReplayData = redo_list.pop_back()
		var p_vdir = Direction.get_point(d.player_dir)
		var px = d.player_pos.x + p_vdir.x
		var py = d.player_pos.y + p_vdir.y
		player.set_pos(px, py, true)
		player.set_dir(d.player_dir)
		if d.crate_dir != Direction.eType.NONE:
			# 荷物の位置も戻す.
			var xprev = d.crate_pos.x
			var yprev = d.crate_pos.y
			var v = Direction.get_point(d.crate_dir)
			var xnext = xprev + v.x
			var ynext = yprev + v.y
			var crate = Field.search_crate(xprev, yprev)
			crate.set_pos(xnext, ynext, true) # 1つ進める.
		
		# undoに追加 (redoは消さない).
		add_undo(d, false)