この記事は 2023/12/15 に App Store でリリースされた iOS 対応モバイルゲーム「もの切り侍」の(事後の)開発ログのうちGodot Engine で作っていく「ロジック」に関する記録である。とはいえ、あまり具体的なコードなどを載せると長くなってしまうので、そのあたりは控えめにしようと思う。
「もの切り侍」は下の App Store のバナーから、無料でダウンロードできるので、興味があればぜひ遊んでみてほしい。
必要な画面の洗い出し
私は割と見た目から入るタイプだ。ゲーム開発も例に漏れず、Godot で「もの切り侍」を作り始めるにあたり、まずは必要な画面の洗い出しからスタートした。その流れで画面遷移も考えた。最終的に変更点は多々あったが、ベースは当初考えたものからさほどブレていないと思う。
開発当初は以下の画面を用意する計画だった。
- スタート画面
- プレイ画面(ポーズ画面含む)
- ステージ選択画面
- 設定画面
最終的には、さらに以下の画面を追加することになった。
- プロローグ画面
- メニュー画面
- クレジット画面
- スコア画面
- 落ち物記録画面
この開発ログでは、当初計画していたゲームの中心となる画面について記録する。
スタート画面とプロローグ画面
カジュアルゲームを追求するならば、スタート画面はむしろ不要ではないかとさえ思っている。がしかし、開発者にとって、スタート画面ほど用意したくなる画面が他にあるだろうか(たぶん、ある)。スタート画面からプレイ画面、設定画面、クレジット画面へそれぞれ遷移できるように考えていたが、世間のモバイルゲームの仕様を見てみると、ただ「スタート」ボタンがあるだけのものが割と多かった。流行にはそれなりに理由があるのだろう、とわけもなく信じて模倣することにした。
次に、スタート画面に少しでも楽しい要素を追加するため、プレイヤーキャラクターの侍を中央に配置して、アニメーションさせた。開発当初から、「スタート」ボタンを押すと侍が刀を振ってポーズを決めるイメージが浮かんでいたので、それをそのまま実装した。実際には、プレイ画面で使用する Player シーンを先に作って、それを流用したという順番である。
その後、カジュアルゲームであるにも関わらず、色々な要素を盛り込み始めた。情熱という名のバカである。盛り込んだ要素の一つがプロローグだ。最初はスタート画面を放置していると数秒後に自動的にプロローグ画面に遷移する仕組みにしていた。しかし、次画面のロードに少しばかり時間が必要になってきたため、「待っている間にどうぞ」と言わんばかりに「プロローグ」ボタンをスタート画面に配置した。
プロローグ画面の構造はいたってシンプルだ。基本的には TextureRect ノードに背景用のパターンテクスチャを適用し、 ScrollContainer ノードにプロローグの文章を適用した Label ノードを入れるという構造だ。自動的に下から上に流れていく仕組みは以下のコードで実装した。
const MAX_SCROLL : int = 5924
func _process(_delta):
if scroll_container.scroll_vertical < MAX_SCROLL:
scroll_container.scroll_vertical += 1
プレイ画面
プレイ画面はゲームの主たるパートなので、ボリューミーだ。Game シーンを最上位として、その下に必要なシーンを追加していった。最終形態はごちゃごちゃと細かいノードを追加しているが、ベースとしては以下のような構成になっている。
- Game: Node
- World: Node2D
- Backgournd: Node2D
- Player: CharacterBody2D
- Obj: RigidBody2D
- UI: Control
- World: Node2D
Background シーン
Background シーンを作成して、それを World シーンのブランチとして追加した。このシーンには、Full Rect の TextureRect ノードに背景のパターンテクスチャをセットし、画面下部にはプレイヤーキャラクターが歩く足場として StaticBody2D ノードをセットした。
細かい演出だが、東から西へ動く太陽と満ち欠けする月を開発中版で追加した。太陽と月のスプライトは、Asprite アプリで自作したものだ。
太陽と月の動き、および日の出、日没、夜の暗がりなどの空の色の変化など、背景で変化する部分はすべて AnimationPlayer にまとめた。
Player シーン
Player シーンを作成して、それをブランチとして World ノードに追加した。このシーンは CharacterBody2D ノードを最上位にして、Sprite2D にドット絵の侍をテクスチャとしてセットしている。
落ち物の的に対して、侍側にも当たり判定エリアが必要なため、Area2D ノードで Hitbox を用意した。ボックスといいながら、線である。それをプレイヤーに視覚的に示すために、HitBox の CollisionShape2D の線の長さと同じ Line2D ノードを用意した。色はイメージカラーの紫っぽい色にした。侍の登場や退場をトリガーに呼び出すメソッドがあったので VisibleOnScreenNotifier2D ノードも追加して、そのシグナルをスクリプト内で利用している。
開発後半で侍にセリフを喋らせる仕様にしたため、吹き出し 💬 を表示する Bubble シーンを作り、それを Player シーンのブランチとして追加した。この Bubble シーンには DialogueManager というプラグインを利用させていただいている。実装に少し苦戦したが、チュートリアルもしっかり用意してくれているので、比較的利用しやすいプラグインだと思う。
少し侍のアクションを派手に演出するため、プレイヤーキャラクターのゴーストエフェクトも追加した。
侍に使用する効果音、例えば、足音やジャンプ、着地の音、刀で切る音などはすべて、ひとつずつ AudioStreamPlayer ノードを追加した。シーンツリーがやや冗長になるが、プレイ中に効果音アセットを都度読み込んでノードにセットするよりは処理が速いと考えたからだ。
画面をタッチして侍がジャンプ、そのままタッチをキープして一定の高さまで上昇、その後画面から指をリリースして当たり判定へ、という操作手順は unhandled_input()
メソッドで実装した。メソッド内には、侍の吹き出し 💬 を閉じる操作も含めている。
func _unhandled_input(_event):
if player_state == PlayerState.IDLING \
and is_on_floor() \
and Input.is_action_just_pressed("touch"):
if speech_state == SpeechState.SAID:
bubble.hide()
elif speech_state == SpeechState.SILENT:
print("Screen Touched")
is_touching = true
hit_box_col_shape.disabled = false # CollisionShape2D of Hitbox node
sfx_jump.play() # AudioStreamPlayer
anim_player.play("jump") # AnimationPlayer
velocity.y = -JUMP_SPEED
player_state = PlayerState.JUMPING
if is_touching\
and not is_on_floor()\
and Input.is_action_just_released("touch"):
print("Screen Released")
is_touching = false
released.emit()
hit_box.hide()
hit_box_col_shape.disabled = true
if player_state == PlayerState.JUMPING:
velocity.y = move_toward(velocity.y, 0, 1600)
Obj シーン
Obj とは、Object の略で、落ち物のためのシーンである。このシーンを World シーンのブランチとして追加した。Obj シーンのルートには、RigidBody2D ノードを採用した。物理計算を自動でしてくれるので、落ち物を画面上部から落下させるのは非常に簡単だった。次に、侍に切られた落ち物を等分して分断させる方法について少し悩んだ。もしかしたら Shader を使いこなせれば簡単に実装できるのかもしれない(いまだに可能かすらわからない)。しかし、私は Shader のスキルがまるでないので、他の方法で進める他なかった。落ち物のスプライトをそのまま分裂させることが難しいのであれば、擬似的にそう見せれば良いと閃いた。つまり、最初から切られているものを綺麗に並べ、まるで切られていないように見せる、という方法だ。以下の動画は、この方法で実装した開発初期のものだ。
擬似的分断の演出は以下の方法で実装することにした。
- 落ち物が侍に切られたら、Obj ノードをフリーズさせる(Freeze プロパティをオンにする)
- Obj シーンを等分した数だけ ObjPiece シーンを Obj ノードの子ノードとして追加する
- 各 ObjPiece ノードを親の Obj ノードのスプライトと重なるように、等間隔に配置する
- 各 ObjPiece ノードには、Obj シーンのスプライトを元に AtlasTexture クラスを使って等分したテクスチャを適用する
- 各 ObjPiece ノードに対して、AtlasTexture の不透明部分から CollisionPolygon2D を生成して子ノードとして適用する
上記のロジックをコーディングしたのが以下のスクリプトである。
Obj ノードのスクリプト:
const PIECE_SCENE : PackedScene = preload("res://obj_base/obj_piece/obj_piece.tscn")
@onready var sprite: Sprite2D = $Sprite2D
func generate_obj_pieces():
var tex_image = sprite.texture
var tex_size = sprite.texture.get_size()
var h_cuts := 12
var v_cuts := 8
var frag_size = Vector2(tex_size.x / h_cuts, tex_size.y / v_cuts)
for i in h_cuts:
for j in v_cuts:
var pce = PIECE_SCENE.instantiate()
var cor = Vector2(frag_size.x * i, frag_size.y * j)
var pos = (cor + frag_size/2 - tex_size/2)
pieces.call_deferred("add_child", pce)
pce.position = pos
pce.call_deferred("set_sprite", tex_image, cor, frag_size)
pce.call_deferred("set_col_poly")
ObjPiece ノードのスクリプト:
func set_sprite(tex_image:Texture2D, frag_pos:Vector2, frag_size:Vector2):
var tex := AtlasTexture.new()
tex.set_atlas(tex_image)
tex.set_region(Rect2(frag_pos, frag_size))
sprite.texture = tex
func set_col_poly():
var polygons = sprite.create_polygons()
if not polygons.is_empty():
for i in polygons.size():
var col_poly := CollisionPolygon2D.new()
col_poly.set_polygon(polygons[i])
col_poly.position -= sprite.texture.get_size() / 2
col_poly.disabled = false
call_deferred("add_child", col_poly)
ObjPiece ノードの子 Sprite ノードのスクリプト:
func create_polygons():
var bitmap = BitMap.new()
bitmap.create_from_image_alpha(texture.get_image())
var rect := Rect2(Vector2.ZERO, texture.get_size())
var polygons = bitmap.opaque_to_polygons(rect)
return polygons
ObjPiece ノードだけ先に落下したり、逆に親の Obj ノードが落ちているのに子の ObjPiece ノードだけ空中に残ったりしてしまうので、かなり悩んだ。結果的に、事前にインスペクターで、 ObjPiece (RigidBody2D) ルートノードの Freeze
をオンにし、かつ Freeze Mode
を Kinematic
にしておく必要があることに気づいた。
最終的には、落ち物に「一本」と「技あり」の当たり判定領域を設け、それぞれで落ち物を等分する数を決めて、スクリプト上で条件分岐させた。「一本」のほうがより細かく切る(より気持ち良い)演出だ。
UI シーン
プレイ画面の UI として、まず HUD を画面下端に配置した。これは Background シーンの侍の足場である StaticBody2D ノードの CollisionShape2D 子ノードの領域と重なるようにしている。この HUD に「一時停止する」「再開する」「設定画面を開く」「ステージ選択画面に移動する」の4つのボタンを設けた。ボタンのデザインは、アイコンのみロイヤリティフリーの素材を使用しているが、それ以外のボタンのデザインやレイアウトは、ノードそれぞれのプロパティを調整するのみにして、シンプルに留めた。
一時停止画面も UI シーンのひとつとして作成した。ColorRect ノードで全画面に半透明の薄い緑色を重ね、Label ノードで「一時停止中」の文字を表示させた。HUD の「一時停止する」ボタン以外は一時停止中でなければ押せない仕様にした。これは誤操作防止策の一つだ。
「ステージ選択画面に移動する」ボタンを押した際、「本当にゲームを中断してステージ選択画面に移動しても良いか」の確認ダイアログを表示させるようにした。最終的なオプションボタンの文言は「はい」と「いいえ」だが、開発初期は、ゲームの雰囲気を出すのに躍起になっていたので、「承知した」「断る」のような文言にしていた。雰囲気よりわかりやすさのほうが優先度が高いと判断してボツにした。
開発終盤にはチュートリアルを追加した。正直、チュートリアルがなくてもすぐにわかるような操作性にしたつもりだったが、いつでもチュートリアルが見られるようにしておく優しさはあっても良いと思った。そういうわけで、一時停止中に画面右上の(?)アイコンを押すとチュートリアルが始まるようにした。
侍が刀を鞘に収め、落ち物が分断されたタイミングで、当たり判定の「一本」または「技あり」が表示されるようにした。特大のフォントサイズから一瞬で標準サイズにアニメーションするようにしていたが、これが開発終盤まで苦しんだメモリリーク問題に関わっていた。結論から言えば、Label ノードの文字を拡大縮小させるなどアニメーションさせたい場合、素直に scale
プロパティの値を変化させるのが正解だ。しかし、私は「文字のサイズを変化させる」=「フォントサイズを変化させる」と考えてしまった。そして AnimationPlayer ノードで、Theme Overrides Font Size
プロパティの値を変化させてしまっていた。詳しい理由は不明だが、このアニメーションが実行されるたびに約 150 MB ずつメモリの消費量が増えていき、いくつかステージをクリアしていくと、必ずゲームがクラッシュするという状態であった。思い込みには要注意である。
ステージクリア後のオプションボタンも実装した。「次へ参る」「再び挑む」「ステージ選択」の3つだ。モバイルカジュアルゲームといえば、通勤通学中の電車やバスでの操作を想定する必要があるだろう。場合によっては片手での操作もありうる。そこで、これらのオプションボタンは画面の下部に配置した。「次へ参る」を押す機会が一番多いはずなので、順番は一番下にもってきた。
ステージ選択画面
ステージ選択画面はプレイ画面とは別物として用意した。この画面を起点に、設定画面、メニュー画面、落物記録画面へ遷移するようにした。メニュー画面にはさらに、スコア画面、クレジット/ライセンス画面への遷移や、Appレビューページへのリンク、ゲームデータ消去機能を実装した。
ステージ選択画面は、当初、ステージ選択用のアイコンを ScrollContainer の中に全200ステージ分用意しており、VisibleOnScreenEnabler2D ノードによって、画面上に表示される前後で、表示 / 非表示を自動で切り替え、コンピュータのリソースを節約するようなロジックで実装していた。しかし、それでも画面をスクロールさせたときのもっさりした動きが気になってしまうレベルだったので、構成を変更した。レベル切り替えボタンを新たに配置し、1 画面内には 1 レベル 20 ステージ分のアイコンしか表示されないように調整した。これにより、もっさり感はなくなった。
プレイ画面同様、このステージ選択画面も若干操作方法に迷う可能性があると判断し、チュートリアルを用意した。右上の(?)アイコンからいつでも表示できる仕組みもプレイ画面のそれと同じである。
設定画面
設定画面のつくり自体は至ってシンプルだ。Container 系のノードを使って、レイアウトを整理しただけである。ボタンのアイコンはロイヤリティフリーの素材を使用している。
ボタンはすべて Toggle Mode
をオンにして、toggled(button_pressed: bool)
シグナルをルートノードのスクリプトに紐づけた。例えば、BGMボタンのシグナルは以下のメソッドに紐づいている。
func _on_background_music_button_toggled(button_pressed):
if button_pressed:
Gamedata.bgm_enabled = true
if Gamedata.sfx_enabled:
enabled_sound_player.play()
bgm_disabled.emit()
else:
Gamedata.bgm_enabled = false
if Gamedata.sfx_enabled:
disabled_sound_player.play()
bgm_enabled.emit()
bgm_button.release_focus()
上記コードのうち、Gamedata
というのは、Godot の Autoload 機能で読み込んでいるスクリプトだ。設定を保存するには、設定ファイルを用意する必要があるので、セーブ/ロード機能をまとめた gamedata.gd スクリプトを作り、Autoload でゲーム開始時に自動で読み込ませるようにした。スクリプトのコードは以下のようになっている。
const OPTIONS_PATH := "user://options.save"
var bgm_enabled := true
var sfx_enabled := true
var background_pattern := true
var ippon_only := false
var speech_bubble_enabled := true
var speech_bubble_auto_hide := false
func save_options():
var options = {
"bgm_enabled": bgm_enabled,
"sfx_enabled": sfx_enabled,
"background_pattern": background_pattern,
"ippon_only": ippon_only,
"speech_bubble_enabled": speech_bubble_enabled,
"speech_bubble_auto_hide": speech_bubble_auto_hide
}
var file = FileAccess.open(OPTIONS_PATH, FileAccess.WRITE)
file.store_var(options)
file.close()
func load_options():
var file = FileAccess.open(OPTIONS_PATH, FileAccess.READ)
if !file: return
var options = file.get_var()
if !options:
file.close()
return
if options.has("bgm_enabled"):
bgm_enabled = options["bgm_enabled"]
if options.has("sfx_enabled"):
sfx_enabled = options["sfx_enabled"]
if options.has("background_pattern"):
background_pattern = options["background_pattern"]
if options.has("ippon_only"):
ippon_only = options["ippon_only"]
if options.has("speech_bubble_enabled"):
speech_bubble_enabled = options["speech_bubble_enabled"]
if options.has("speech_bubble_auto_hide"):
speech_bubble_auto_hide = options["speech_bubble_auto_hide"]
file.close()
おわりに
ということで、この開発ログには「もの切り侍」のロジックに関わる部分を記した。
3D ゲームはわからないが、2D ゲームに関しては、Godot に用意されている様々なクラスを組み合わせれば、実現したいことはだいたいできてしまうのではないか、と思った。しかし、それと同時に、ゲームのアイデアをアルゴリズムに落とし込んで、それを実現するためにドキュメントを読み込んで検証する作業は、時に相当の忍耐が必要になるということもわかった。
この開発ログがどなたかのゲーム開発の一助になれば幸いだ。