ゲーム開発エンジンGodot学習チュートリアル集、成果物、Tipsなど
成果物
今までGodotで作ったゲーム
01 サバイバースタイルゲーム(合計学習時間:20時間)
02 プラットフォーマー(合計学習時間:40時間)
プレイヤー、敵、コイン、宝箱など複雑なプラッフォーマー
03 シューティング(合計学習時間:45時間)
シンプルなシューティング
04 プラットフォーマー(合計学習時間:50時間)
シンプルなプラッフォーマー
05 モバイルゲーム(合計学習時間:60時間)
Google Playで課金機能も実装
エラーなくリリースするところまで可能
06 ドラッグゲーム(合計学習時間:65時間)
Rigidbodyを中心としたゲーム
07 神経衰弱(合計学習時間:70時間)
神経衰弱ロジック実装
08 プラッフォーマー(合計学習時間:77時間)
09 倉庫番(合計学習時間:80時間)
10 シューティング(合計学習時間:85時間)
リソースを使用して敵を簡単に配置
11 シェーダー(合計学習時間:90時間)
ダメージエフェクト、フラッシュ、炎エフェクトを作りながらシェーダーを学習
12 追跡ゲーム(合計学習時間:94時間)
ナビゲーションを学習
13 アクションゲーム(合計学習時間:100時間)
14 アクションゲーム(合計学習時間:100時間以降~)
アクション、スキルツリーや会話システムなど、今まで学習したことを搭載
15 【超入門】ゲーム制作の楽しさを体験!Godotで作る2Dプラットフォーマー
Godotを使用したシンプルな2Dプラットフォーマーゲームのチュートリアルを作成しました。
ゲーム制作が初めての方でも安心して進められるように、基礎から一歩ずつ丁寧に解説しています。
Godotとは何か
Godotは、オープンソースのゲームエンジンで、2Dおよび3Dゲームの開発を支援します。
2014年に初リリースされ、その使いやすさと柔軟性で多くの開発者に支持されています。
特筆すべきは、完全無料であり、ソースコードが公開されているため、誰でも改変や共有が可能です。
Godotはプログラミング初心者から経験豊富な開発者まで、あらゆるレベルのユーザーにとって使いやすい環境を提供しています。
Godotのメリット
-
直感的な使いやすさ:
Godotは初心者にも優しい設計で、シンプルかつ直感的なインターフェースが特徴です。
シーンベースの構造により、ゲームオブジェクトの管理が容易で、複雑なプロジェクトでも効率的に開発を進められます。
シーンシステムは、個々のコンポーネントをモジュールとして再利用できるため、プロジェクトの規模が大きくなっても管理がしやすいです。 -
軽量かつ高速:
Godotは非常に軽量で、ロード時間がほとんど発生しません。
これにより、開発中のストレスが大幅に軽減されます。実行ファイルも軽量で、ほとんどのPCでスムーズに動作します。
この軽快さは、特にインディーゲーム開発者や小規模なチームにとって大きな魅力です。 -
強力な2Dおよび3Dサポート:
2Dゲームエンジンは特に高評価を受けており、高性能な描画が可能です。
2D空間でのピクセルパーフェクトなレンダリングは、他のエンジンでは得られない細かい表現が可能です。
3Dエンジンも着実に進化しており、多くの機能を備えています。最新のアップデートでは、Vulkanサポートが追加され、高度な3Dグラフィックスの表現力が向上しました。 -
オープンソースと無料:
Godotは完全に無料であり、ライセンス費用やロイヤリティの心配がありません。
このため、インディー開発者や学生にも非常に利用しやすいです。
商用プロジェクトでも追加費用が発生しないため、収益を最大限に確保することができます。
また、ソースコードが公開されているため、カスタマイズ性が高く、特定のプロジェクトニーズに合わせた調整が可能です。 -
多言語サポート:
Godotは独自のスクリプト言語GDScriptを使用しており、Pythonに似た簡潔な文法が特徴です。
Pythonに慣れているユーザーにとっては、GDScriptの学習曲線が緩やかで、すぐに使いこなせます。
また、C#やビジュアルスクリプトもサポートしているため、開発者は自身の得意な言語で開発が可能です。
さらに、GDNativeを利用することで、C++やRustなどの言語で高性能なコードを書くこともできます。 -
クロスプラットフォーム対応:
Windows、Mac、Linux、iOS、Android、HTML5など、さまざまなプラットフォームへのエクスポートが可能です。
これにより、一度作成したゲームを多くのデバイスで楽しむことができます。
さらに、最近のアップデートで、コンソール向けのエクスポートも容易になり、より多くのプラットフォームに対応できるようになりました。
勢いが凄い
2024年のGMTK Game Jamにおいて、Godotエンジンは大きな人気を集めており、全体の37%(2,838作品)で使用されています。
これは全ゲームの約3分の1以上を占めており、非常に多くの開発者がGodotを選んでいることがわかります。
Godotエンジンは多くの開発者に支持され、2024年のGMTK Game Jamで顕著な成果を挙げたと考えられます。
参考:
Godotのデメリット
-
ドキュメントの充実度:
急速に成長しているため、他の商用エンジンほどドキュメントが充実していないことがあります。
しかし、コミュニティのサポートが活発で、フォーラムやQ&Aサイトで必要な情報を見つけることができます。
公式ドキュメントも改善が進んでおり、使いやすさが向上しています。
特に、最近では日本語のドキュメントも増えてきており、言語の壁を感じることなく学習できます。 -
3D機能の成熟度:
3D機能は進化中ですが、UnityやUnreal Engineに比べるとまだ成熟度に欠ける部分があります。
特に高度な3Dグラフィックスや物理シミュレーションを必要とするプロジェクトでは、他のエンジンが優位になることがあります。
しかし、Vulkanサポートの追加や、その他の機能強化により、今後の成長が期待されています。
UnityとGodotの違い
-
ライセンスとコスト:
Unityは基本的に無料で利用できますが、収益の高いプロジェクトには有料ライセンスが必要です。
一方、Godotは完全無料で、どんな規模のプロジェクトでも追加費用がかかりません。
これにより、収益の大部分を確保できるため、インディー開発者にとって非常に魅力的です。 -
インターフェースの使いやすさ:
Unityは機能が豊富ですが、その分インターフェースが複雑で初心者には学習曲線が急です。
Godotはシンプルで直感的なインターフェースを持ち、初心者でもすぐに使い始められます。
また、シーンシステムの導入により、複雑なプロジェクトでも効率的に管理が可能です。 -
スクリプト言語:
UnityはC#を使用し、これに精通している開発者には魅力的です。
GodotのGDScriptはPythonに似た簡潔な文法で、初心者にも理解しやすいです。
また、C#もサポートしているため、Unityからの移行もスムーズです。
さらに、ビジュアルスクリプトを利用することで、プログラミング経験のないユーザーでも簡単にゲームを作成できます。 -
オープンソース vs 商用エンジン:
Godotはオープンソースであり、エンジンの内部構造を自由に閲覧・改変できます。
Unityは商用エンジンで、その内部構造は公開されていません。
これにより、Godotはカスタマイズ性が高く、特定のプロジェクトニーズに合わせた調整が可能です。
また、コミュニティの力で迅速にバグ修正や機能追加が行われるため、常に最新の技術を取り入れることができます。 -
パフォーマンスと軽量性:
Godotは軽量で高速に動作するため、低スペックのマシンでも快適に開発が可能です。
一方、Unityは高機能である反面、動作が重くなることがあります。
この違いは、開発の快適さに大きく影響します。 -
カスタマイズ性:
Godotはオープンソースであり、必要に応じてエンジン自体を改変することができます。
Unityは商用エンジンであり、カスタマイズの自由度が制限されています。
Godotのカスタマイズ性は、特定のニーズに合わせたプロジェクトにおいて非常に有用です。 -
コミュニティのサポート:
Godotのコミュニティは非常に活発で、フォーラムやQ&Aサイトでのサポートが充実しています。
Unityも大規模なコミュニティを持っていますが、Godotのコミュニティはオープンソースの精神に基づいており、開発者同士の助け合いが盛んです。
Godot Engine採用ポイント
-
コミュニティ開発とサポート
Godotはオープンソースプロジェクトで、公式サポートはありません。トラブル発生時には自己解決が基本で、有料サポート企業の利用も選択肢です。 -
コンソール対応
コンソール向けのゲームリリースには外部企業の支援や自社での対応が必要です。W4 Gamesのような企業がポーティングサービスを提供しています。 -
ライセンスと責任
GodotはMITライセンスのため自由に利用・改変可能ですが、開発者に法的責任を問うことはできません。自社でのサポート体制を整えることが重要です。 -
資金調達と持続可能性
Godotの開発は寄付で支えられており、寄付やスポンサーシップがプロジェクトの安定に寄与します。 -
突然の利用停止リスク
Godotはオープンソースコミュニティによって支えられており、商用エンジンのような突然のサービス停止リスクは低いです。
GDScriptについて
GDScriptは、Godot開発チームが設計した独自のスクリプト言語で、Pythonを基にしたシンプルで読みやすい文法が特徴です。
ゲーム開発に不要な複雑な構文を排除し、最適化されたパフォーマンスを提供します。
また、C#やビジュアルスクリプトもサポートしているため、多様なプログラミングスタイルに対応できます。
GDScriptは直感的で学びやすく、短期間で習得できるため、初心者にも最適です。
学習チュートリアル
Godotの学習リソースも充実してきており、公式サイトやコミュニティフォーラムには多くのチュートリアルが用意されています。
初心者向けには、シンプルなゲームを作成するチュートリアルが多く、基本的な操作や概念を学ぶのに最適です。
また、上級者向けには高度な機能や最適化技術を学べるリソースもあります。
特にYouTubeやブログなどのオンラインリソースを活用することで、効率的に学習を進めることができます。
以下は、いくつかのおすすめチュートリアルです:
-
公式チュートリアル:
Godotの公式サイトには、初心者向けから上級者向けまで、多様なチュートリアルが揃っています。
シンプルなゲームを作成しながら、基本的な操作やスクリプトの書き方を学ぶことができます。 -
GDQuest:
YouTubeで人気のGDQuestは、Godotの多くの機能を網羅したチュートリアルを提供しています。
GDScript、2D/3Dゲーム開発、マルチプレイヤー、音声管理、シェーダープログラミングなど、幅広いトピックをカバーしています。 -
HeartBeast:
HeartBeastのチュートリアルは、アクションRPGやプラットフォームゲームなど、具体的なジャンルのゲーム開発に焦点を当てています。
実践的なプロジェクトを通じて、ゲーム開発のノウハウを身につけることができます。
まとめ
Godotは、その使いやすさと柔軟性、そしてコミュニティのサポートにより、特にインディーズ開発者や小規模なチームにとって非常に魅力的な選択肢です。
無料でありながら高機能で、2Dおよび3Dゲームの開発が可能なため、幅広いジャンルのゲームを作成することができます。
もし、あなたがゲーム開発に興味があり、手軽に始めたいと思っているなら、ぜひGodotを試してみてください。
公式サイトやコミュニティフォーラムには、さまざまなリソースやチュートリアルが用意されており、あなたのゲーム開発の旅をサポートしてくれるでしょう。
ゲーム開発の楽しさを感じながら、Godotを使って自分だけのユニークなゲームを作り上げてみませんか?
公式サイトやコミュニティで多くのリソースが提供されているので、すぐに開発を始めることができます。
Godotでのゲーム開発の楽しさをぜひ体験してみてください!!
学習スケジュール
- Udemy人気、評価が高いチュートリアルを2、3個実施(丁寧に解説されているため)
- Udemy興味がある内容のチュートリアルを5、6個実施
- YouTubeチュートリアルをひたすら実施
- 公式ドキュメントで理解を深堀り
- 自分でチュートリアルを作成して公開
- 基本的な仕組みのプラッフォーマー、シューティング、パズルを作成する予定
- オリジナルゲームを開発してリリース
Udemy
UdemyのGodotチュートリアルの人気の高い順から開始
Create a Complete 2D Survivors Style Game in Godot 4
おすすめ度:★★★★★
学習時間:20時間
まずは、このチュートリアルを実施するとGodotの基本が学べます。
分かりやすく丁寧に解説されています。
サバイバーローグライクゲームの作り方が学べます。
自動攻撃のアビリティや増加する敵の大群、経験値のドロップと収集、アビリティのアップグレード、シグナルなど学びます。
Create a Complete 2D Platformer in the Godot Engine
おすすめ度:★☆☆☆☆
学習時間:10時間
上記のFirebelley Gamesのチュートリアルです。
解説は分かりやすいですが、Godot3のチュートリアルとなっているため注意が必要です
Godot3とGodot4は別物です。
アルゴリズムや仕組みを参考にするのがよいです
Build a complete pixel platformer in Godot 4!
おすすめ度:★★☆☆☆
学習時間:20時間
2Dプラットフォーマーの作り方が学べます。
早送りや飛ばしている箇所もあるので、何回も見直す必要があります。
Godotの基本を事前に学んでおいた方がよいです。
キャラクター、レベルデザイン、宝物、敵、コインなど、アクションゲームに必要な仕組みを学べます。
Jumpstart to 2D Game Development: Godot 4 for Beginners
おすすめ度:★★★★★
学習時間:30時間
こちらも丁寧に解説されています。
7つのゲームを作ることによって、かなり理解が深まります。
シンプルなコードで理解しやすいです。
Complete Godot 2D: Develop Your Own 2D Games Using Godot 4
おすすめ度:★★★★★
学習時間:10時間
かなり丁寧に解説されています。
まずは最初にこのチュートリアルをやることをオススメします。
シューティングゲームとプラットフォーマーの基礎を学べることができます。
Master Mobile Game Development with Godot 4
おすすめ度:★★★★★
学習時間:10時間
上記のKaan Alparと同じチュートリアルです。
Android、iOSのモバイルゲームを作ることができます。
こちらもかなり丁寧に解説されているので、オススメです。
メニューなどのUIやショップで購入機能を実装できます。
Android、iOSでテストや公開方法など、迷うこと無くすすめることができます。
Godot 4 Shaders: Write 2D shaders for your game from scratch
おすすめ度:★★★☆☆
学習時間:6時間
すでに構築済みのプロジェクトにシェーダーを追加していきます。
ダメージエフェクトや炎エフェクト、フラッシュなど学べます。
上級者向けの内容になっていますが、自分のプロジェクトにちょっとしたエフェクトを加えるときの参考になります。
YouTube
Brackeys
UnityからGodotに移行したというアナウンスがありました。
BrackeysがGodotに移行となったので、私も移行という流れです。
Heartbeast
GDQuest
CyberPotato
DevWorm
Godotneers
Brett Makes Games
Battery Acid Dev
Chris' Tutorials
BornCG
Le Lu
Effect関係
Kaan Alpar
神チュートリアル
WisconsiKnight
Tipsが分かりやすい
LittleStryker
プラットフォーマー参考に
Nathan Hoad
Dialog Managerの学習に
DashNothing
Tipsが分かりやすい
16BitDev
参考になる
Godot開発での感想
- 開発の流れはUnityと似ているので、Unityでゲーム開発したことがある方は、Godotでの開発は難しくなさそう
- 起動時間、ロード時間がほぼ無く、ファイルが軽いので、最高
- エディターはすぐにエラーが分かるので、起動時のミスが少ない
- エラー内容が分かりやすいので、デバッグしやすい
- Unityのようにどこを修正したらよいか分からなくなることがあまりない
- mainに組み込む必要がなく、シーン毎にテストができる
- モバイルゲームも作れる
- Androidにはエラーなくリリースできるところまでたどり着けた
- Gooogle Payの課金機能も実装できる
- Unityより断然作りやすい
- 50時間以上Godotを触るとUnityには戻れなくなる
- UIは綺麗につくることは可能
- 自動起動にgame_manager、sound_manager、signal_maangerなどmanager系を設定しておくとやりやすい
- signal_managerにsignalを集中せさせておくことで、シンプルになる
- プラッフォーマーゲームもシンプルに構築できる
Tipsサイト
Godot Shaders
Shaderのサンプルが掲載されているので、気になるShaderを使用することができます。
ゲームメーカーズ(godot記事)
Easing CheetSheet
Tweenはよく使います。
GDScriptリファレンス
Piskel
ドット絵の編集に使用します。
GameUIDatabase
ゲームUIの参考になります。
Tips
共通テーマ
UIを設定したテーマを作成して、プロジェクト設定のテーマのカスタムに設定しておく
メニュー
下記のように設置すると、キレイなUIができる
MarginContainer
|- PanelContainer
|- MarginContainer
|- VBoxContainer
|- Label
|- Button
Androidエクスポート
こちらのドキュメント通りに設定
スマホ傾き検知
get_accelerometerで傾きを取得
左に傾けると最大-10、右に傾けると最大10
func _physics_process(_delta: float) -> void:
velocity.y += gravity
if velocity.y > max_fall_velocity:
velocity.y = max_fall_velocity
if use_accelerometer:
var mobile_input = Input.get_accelerometer()
velocity.x = mobile_input.x * accelerometer_speed
else:
var direction = Input.get_axis("move_left", "move_right")
if direction:
velocity.x = direction * speed
else:
velocity.x = move_toward(velocity.x, 0, speed)
move_and_slide()
Save & Load
"user://highscore.save"でデータを保持することができる
file.get_var()はfile.store_varした順番で取得することができる
var score: int = 0
var highscore: int = 0
var save_file_path = "user://highscore.save"
func _ready() -> void:
hud.set_score(0)
func _on_player_died():
hud.visible = false
if score > highscore:
highscore = score
save_score()
player_died.emit(score, highscore)
func save_score():
var file = FileAccess.open(save_file_path, FileAccess.WRITE)
file.store_var(highscore)
file.close()
func load_score():
if FileAccess.file_exists(save_file_path):
var file = FileAccess.open(save_file_path, FileAccess.READ)
highscore = file.get_var()
file.close()
else:
highscore = 0
ポーズ
ModeをAlwaysにする必要あり
func appear():
visible = true
var tween = get_tree().create_tween()
tween.set_pause_mode(Tween.TWEEN_PAUSE_PROCESS)
tween.tween_property(self, "modulate:a", 1.0, fade_duration)
return tween
func _on_game_pause_game():
get_tree().paused = true
screens.pause_game()
一時停止
await get_tree().create_timer(0.75).timeout
スマホ最小化対応
func _ready() -> void:
DisplayServer.window_set_window_event_callback(_on_window_event)
func _on_window_event(event):
match event:
DisplayServer.WINDOW_EVENT_FOCUS_IN:
print("Focus in")
DisplayServer.WINDOW_EVENT_FOCUS_OUT:
print("Focus Out")
DisplayServer.WINDOW_EVENT_CLOSE_REQUEST:
get_tree().quit()
SFX
ModeはAlways
func jump():
velocity.y = jump_velocity
SoundFX.play("Jump")
extends Node
var sounds = {
"Click": load("res://assets/sound/Click.wav"),
"Fall": load("res://assets/sound/Fall.wav"),
"Jump": load("res://assets/sound/Jump.wav"),
}
@onready var sound_players = get_children()
func play(sound_name):
var sound_to_play = sounds[sound_name]
for sound_player in sound_players:
if !sound_player.playing:
sound_player.stream = sound_to_play
sound_player.play()
return
IAP(Android Google Play)
キャラクター購入などGoogle Playで購入する機能の実装
godot-google-play-billingライブラリを使用
extends Node
signal unlock_new_skin
var google_payment = null
var new_skin_sku = "new_player_skin"
var new_skin_token = ""
func _ready() -> void:
if Engine.has_singleton("GodotGooglePlayBilling"):
google_payment = Engine.get_singleton("GodotGooglePlayBilling")
MyUtility.add_log_msg("Android IAP support is enabled")
google_payment.connected.connect(_on_connected)
google_payment.startConnection()
google_payment.connect_error.connect(_on_connect_error)
google_payment.disconnected.connect(_on_disconnected)
google_payment.sku_details_query_completed.connect(_on_sku_details_query_completed)
google_payment.sku_details_query_error.connect(_on_sku_details_query_error)
google_payment.purchases_updated.connect(_on_purchases_updated)
google_payment.purchase_error.connect(_on_purchase_error)
google_payment.purchase_acknowledged.connect(_on_purchase_acknowledged)
google_payment.purchase_acknowledgement_error.connect(_on_purchase_acknowledgement_error)
google_payment.query_purchases_response.connect(_on_query_purchases_response)
google_payment.purchase_consumed.connect(_on_purchase_consumed)
google_payment.purchase_consumption_error.connect(_on_purchase_consumption_error)
else:
MyUtility.add_log_msg("Android IAP support is not available")
func purchase_skin():
if google_payment:
var response = google_payment.purchase(new_skin_sku)
MyUtility.add_log_msg("Purchase attempted, response " + str(response.status))
if response.status != OK:
MyUtility.add_log_msg("Error purchasing skin")
func reset_purchases():
if google_payment:
if !new_skin_token.is_empty():
google_payment.consumePurchases(new_skin_token)
func _on_connected():
MyUtility.add_log_msg("Connected")
google_payment.querySkuDetails([new_skin_sku], "inapp")
func _on_connect_error(response_id, debug_msg):
MyUtility.add_log_msg("Connect error, response id: " + str(response_id) + " debug msg: " + debug_msg)
func _on_disconnected():
MyUtility.add_log_msg("Disconnected")
func _on_sku_details_query_completed(skus):
MyUtility.add_log_msg("Sku details query completeld")
for sku in skus:
MyUtility.add_log_msg("Sku:")
MyUtility.add_log_msg(str(sku))
google_payment.queryPurchases("inapp")
func _on_sku_details_query_error(response_id, error_message, skus):
MyUtility.add_log_msg("Sku query error, response id: " + str(response_id) + ", message: " + str(error_message) + ", skus: " + str(skus))
func _on_purchases_updated(purchases):
if purchases.size() > 0:
var purchase = purchases[0]
var purchase_sku = purchase["skus"][0]
MyUtility.add_log_msg("Purchased item with sku: " + purchase_sku)
if purchase_sku == new_skin_sku:
new_skin_token = purchase.purchase_token
google_payment.acknowledgePurchase(purchase.purchase_token)
func _on_purchase_error(response_id, error_message):
MyUtility.add_log_msg("Purchase error, response id: " + str(response_id) + " error msg: " + error_message)
func _on_purchase_acknowledged(purchase_token):
MyUtility.add_log_msg("Purchase acknowledged successfully")
if !new_skin_token.is_empty():
if new_skin_token == purchase_token:
MyUtility.add_log_msg("Unlocking new skin")
unlock_new_skin.emit()
func _on_purchase_acknowledgement_error(response_id, error_message, purchase_token):
MyUtility.add_log_msg("Purchase acknowledgement error, response id: " + str(response_id) + ", message: " + str(error_message) + ", token: " + str(purchase_token))
func _on_query_purchases_response(query_result):
if query_result.status == OK:
MyUtility.add_log_msg("Query purchases was successful")
var purchases = query_result.purchases
var purchase = purchases[0]
var purchase_sku = purchase["skus"][0]
if new_skin_sku == purchase_sku:
new_skin_token = purchase.purchase_token
if !purchase.is_acknowledged:
google_payment.acknowledgePurchase(purchase.purchase_token)
else:
unlock_new_skin.emit()
MyUtility.add_log_msg("Unlocking new skin because it was purchased previously")
else:
MyUtility.add_log_msg("Query purchases failed")
func _on_purchase_consumed(purchase_token):
MyUtility.add_log_msg("Purchase consumed successfully")
func _on_purchase_consumption_error(response_id, error_message, purchase_token):
MyUtility.add_log_msg("Purchase consumption error, response id: " + str(response_id) + ", message: " + str(error_message) + ", token: " + str(purchase_token))
キャラクターをドラッグする
extends RigidBody2D
enum ANIMAL_STATE {
READY,
DRAG,
RELEASE
}
var _state: ANIMAL_STATE = ANIMAL_STATE.READY
func _physics_process(delta: float) -> void:
update(delta)
func set_new_state(new_state: ANIMAL_STATE):
_state = new_state
if _state == ANIMAL_STATE.RELEASE:
freeze = false
elif _state == ANIMAL_STATE.DRAG:
pass
func detect_release():
if _state == ANIMAL_STATE.DRAG:
if Input.is_action_just_released("drag"):
set_new_state(ANIMAL_STATE.RELEASE)
return true
return false
func update_drag():
if detect_release():
return
var gmp = get_global_mouse_position()
position = gmp
func update(delta):
match _state:
ANIMAL_STATE.DRAG:
update_drag()
func die():
SignalManager.on_animal_died.emit()
queue_free()
func _on_visible_on_screen_notifier_2d_screen_exited() -> void:
die()
func _on_input_event(viewport: Node, event: InputEvent, shape_idx: int) -> void:
if _state == ANIMAL_STATE.READY and event.is_action_pressed("drag"):
set_new_state(ANIMAL_STATE.DRAG)
アセット集
Kenny
itch
効果音
2DプラッフォーマーTips
キャラクター移動
シンプルな移動
extends CharacterBody2D
class_name Player
@onready var sprite_2d: Sprite2D = $Sprite2D
@onready var animation_player: AnimationPlayer = $AnimationPlayer
const GRAVITY: float = 1000.0
const RUN_SPEED: float = 120.0
const MAX_FALL: float = 400.0
const HURT_TIME: float = 0.3
const JUMP_VELOCITY: float = -400.0
enum PLAYER_STATE {
IDLE,
RUN,
JUMP,
FALL,
HURT
}
var _state: PLAYER_STATE = PLAYER_STATE.IDLE
func _physics_process(delta: float) -> void:
if !is_on_floor():
velocity.y += GRAVITY * delta
get_input()
move_and_slide()
calculate_states()
func get_input():
velocity.x = 0
if Input.is_action_pressed("left"):
velocity.x = -RUN_SPEED
sprite_2d.flip_h = true
elif Input.is_action_pressed("right"):
velocity.x = RUN_SPEED
sprite_2d.flip_h = false
if Input.is_action_just_pressed("jump") and is_on_floor():
velocity.y = JUMP_VELOCITY
velocity.y = clampf(velocity.y, JUMP_VELOCITY, MAX_FALL)
func calculate_states():
if _state == PLAYER_STATE.HURT:
return
if is_on_floor():
if velocity.x == 0:
set_state(PLAYER_STATE.IDLE)
else:
set_state(PLAYER_STATE.RUN)
else:
if velocity.y > 0:
set_state(PLAYER_STATE.FALL)
else:
set_state(PLAYER_STATE.JUMP)
func set_state(new_state: PLAYER_STATE):
if new_state == _state:
return
_state = new_state
match _state:
PLAYER_STATE.IDLE:
animation_player.play("idle")
PLAYER_STATE.RUN:
animation_player.play("run")
PLAYER_STATE.JUMP:
animation_player.play("jump")
PLAYER_STATE.FALL:
animation_player.play("fall")
敵横移動
左右に移動
壁や穴がある場合はフリップ
enemy_base.gd
extends CharacterBody2D
class_name EnemyBase
enum FACING {
LEFT = -1,
RIGHT = 1
}
const OFF_SCREEN_KILL_ME: float = 1000.0
@export var default_facing: FACING = FACING.LEFT
@export var points: int = 1
@export var speed: float = 30.0
var _gravity: float = 800.0
var _facing: FACING = default_facing
var _player_ref: Player
var _dying: bool = false
func _ready() -> void:
_player_ref = get_tree().get_nodes_in_group(GameManager.GROUP_PLAYER)[0]
func _physics_process(delta: float) -> void:
fallen_off()
func fallen_off():
if global_position.y > OFF_SCREEN_KILL_ME:
queue_free()
func die():
if _dying:
return
_dying = true
SignalManager.on_enemy_hit.emit(points, global_position)
set_physics_process(false)
hide()
queue_free()
func _on_visible_on_screen_notifier_2d_screen_entered() -> void:
pass # Replace with function body.
func _on_visible_on_screen_notifier_2d_screen_exited() -> void:
pass # Replace with function body.
snail.gd
extends EnemyBase
@onready var floor_detection: RayCast2D = $FloorDetection
@onready var animated_sprite_2d: AnimatedSprite2D = $AnimatedSprite2D
func _physics_process(delta: float) -> void:
super._physics_process(delta)
if !is_on_floor():
velocity.y += _gravity * delta
else:
velocity.x = speed * _facing
move_and_slide()
if is_on_floor():
if is_on_wall() or !floor_detection.is_colliding():
flip_me()
func flip_me():
animated_sprite_2d.flip_h = !animated_sprite_2d.flip_h
floor_detection.position.x = floor_detection.position.x * -1
if _facing == FACING.LEFT:
_facing = FACING.RIGHT
else:
_facing = FACING.LEFT
敵ジャンプ
プレイヤーに向かってジャンプ
frog.gd
extends EnemyBase
@onready var animated_sprite_2d: AnimatedSprite2D = $AnimatedSprite2D
@onready var jump_timer: Timer = $JumpTimer
const JUMP_VELOCITY: Vector2 = Vector2(100, -150)
const JUMP_WAIT_RANGE: Vector2 = Vector2(2.5, 5.0)
var _jump: bool = false
var _seen_player: bool = false
func _ready() -> void:
super._ready()
start_timer()
func _physics_process(delta: float) -> void:
super._physics_process(delta)
if !is_on_floor():
velocity.y += _gravity * delta
else:
velocity.x = 0
animated_sprite_2d.play("idle")
apply_jump()
move_and_slide()
flip_me()
func apply_jump():
if !is_on_floor():
return
if !_seen_player or !_jump:
return
velocity = JUMP_VELOCITY
if !animated_sprite_2d.flip_h:
velocity.x = velocity.x * -1
_jump = false
animated_sprite_2d.play("jump")
start_timer()
func flip_me():
if _player_ref.global_position.x > global_position.x and !animated_sprite_2d.flip_h:
animated_sprite_2d.flip_h = true
elif _player_ref.global_position.x < global_position.x and animated_sprite_2d.flip_h:
animated_sprite_2d.flip_h = false
func start_timer():
jump_timer.wait_time = randf_range(JUMP_WAIT_RANGE.x, JUMP_WAIT_RANGE.y)
jump_timer.start()
func _on_jump_timer_timeout() -> void:
_jump = true
func _on_visible_on_screen_notifier_2d_screen_entered() -> void:
_seen_player = true
敵フライ
プレイヤーに向かって飛ぶ
eagle.gd
extends EnemyBase
@onready var animated_sprite_2d: AnimatedSprite2D = $AnimatedSprite2D
@onready var player_detector: RayCast2D = $PlayerDetector
@onready var direction_timer: Timer = $DirectionTimer
const FLY_SPEED: Vector2 = Vector2(35, 15)
var _fly_direction: Vector2 = Vector2.ZERO
func _physics_process(delta: float) -> void:
super._physics_process(delta)
velocity = _fly_direction
move_and_slide()
func set_and_flip():
var x_dir = sign(_player_ref.global_position.x - global_position.x)
if x_dir > 0:
animated_sprite_2d.flip_h = true
else:
animated_sprite_2d.flip_h = false
_fly_direction = Vector2(x_dir, 1) * FLY_SPEED
func fly_to_player():
set_and_flip()
direction_timer.start()
func _on_visible_on_screen_notifier_2d_screen_entered() -> void:
animated_sprite_2d.play("fly")
fly_to_player()
func _on_direction_timer_timeout() -> void:
fly_to_player()
弾
object_maker.gd
extends Node
enum BULLET_KEY {
PLAYER,
ENEMY
}
const BULLETS = {
BULLET_KEY.PLAYER: preload("res://scenes/bullets/bullet_player/bullet_player.tscn"),
BULLET_KEY.ENEMY: preload("res://scenes/bullets/bullet_enemy/bullet_enemy.tscn"),
}
func add_child_deferred(child_to_add):
get_tree().root.add_child(child_to_add)
func call_add_child(child_to_add):
call_deferred("add_child_deferred", child_to_add)
func create_bullet(speed: float, direction: Vector2, start_pos: Vector2, life_span: float, key: BULLET_KEY):
var new_b = BULLETS[key].instantiate()
new_b.setup(direction, life_span, speed)
new_b.global_position = start_pos
call_add_child(new_b)
このコードは、Godotエンジンでシューティングゲームの弾を生成し、シーンに追加する機能を実装しています。以下に、各部分の詳細な説明をします。
enum BULLET_KEY
enum BULLET_KEY {
PLAYER,
ENEMY
}
これは、弾の種類(プレイヤーの弾か敵の弾か)を示すための列挙型(enum)です。PLAYER
とENEMY
という2つの値を持ちます。
const BULLETS
const BULLETS = {
BULLET_KEY.PLAYER: preload("res://scenes/bullets/bullet_player/bullet_player.tscn"),
BULLET_KEY.ENEMY: preload("res://scenes/bullets/bullet_enemy/bullet_enemy.tscn"),
}
これは、弾の種類ごとに対応するシーンを事前にロード(preload)しておくための定数辞書です。プレイヤーの弾と敵の弾それぞれに対応するシーンファイルを指定しています。
add_child_deferred関数
func add_child_deferred(child_to_add):
get_tree().root.add_child(child_to_add)
この関数は、引数で与えられたノードをシーンツリーのルートに追加します。add_child
を直接呼ぶのではなく、call_deferred
を使うための間接的な関数として定義されています。
call_add_child関数
func call_add_child(child_to_add):
call_deferred("add_child_deferred", child_to_add)
この関数は、call_deferred
メソッドを使用して、add_child_deferred
関数を後で呼び出すように設定します。call_deferred
は、現在の処理が完了した後に指定したメソッドを呼び出すために使用されます。
create_bullet関数
func create_bullet(speed: float, direction: Vector2, start_pos: Vector2, life_span: float, key: BULLET_KEY):
var new_b = BULLETS[key].instantiate()
new_b.setup(direction, life_span, speed)
new_b.global_position = start_pos
call_add_child(new_b)
この関数は、新しい弾を生成し、設定を行った後、シーンツリーに追加します。具体的には以下の手順を踏んでいます:
- 指定された
key
に基づいて、新しい弾のインスタンスを生成する。 - 生成した弾に対して、方向、寿命、速度を設定する(この
setup
関数は別途定義されていると仮定)。 - 弾の初期位置を設定する。
-
call_add_child
関数を呼び出し、生成した弾をシーンに追加する。
call_deferredの使い方
call_deferred
は、特定のメソッドを即座に呼び出すのではなく、現在の処理が完了した後(例えば、フレームの終わり)に呼び出すために使用されます。これにより、以下のような状況で役立ちます:
- ノードの削除や追加などの操作を、現在のシーンツリーの状態を変更せずに安全に行いたい場合。
- メソッドの呼び出し順序を制御し、意図しない副作用を避けるために処理を遅延させたい場合。
このコードでは、弾の生成と追加を安全に行うためにcall_deferred
が使用されています。これにより、現在のシーンツリーの状態を変更せず、処理が完了した後に弾が追加されます。
シューター
Shooterノードを追加するだけで、弾を撃てる
shooter.gd
extends Node2D
@onready var sound: AudioStreamPlayer2D = $Sound
@onready var shoot_timer: Timer = $ShootTimer
@export var speed: float = 50.0
@export var life_span: float = 10.0
@export var bullet_key: ObjectMaker.BULLET_KEY
@export var shoot_delay: float = 0.7
var _can_shoot: bool = true
func _ready() -> void:
shoot_timer.wait_time = shoot_delay
func shoot(direction: Vector2):
if !_can_shoot:
return
_can_shoot = false
SoundManager.play_clip(sound, SoundManager.SOUND_LASER)
ObjectMaker.create_bullet(speed, direction, global_position, life_span, bullet_key)
shoot_timer.start()
func _on_shoot_timer_timeout() -> void:
_can_shoot = true
player.gd
# 追加
@onready var shooter: Node2D = $Shooter
func _physics_process(delta: float) -> void:
if !is_on_floor():
velocity.y += GRAVITY * delta
get_input()
move_and_slide()
calculate_states()
update_debug_label()
if Input.is_action_just_pressed("shoot"):
shoot()
func shoot():
if sprite_2d.flip_h:
shooter.shoot(Vector2.LEFT)
else:
shooter.shoot(Vector2.RIGHT)
敵がやられたらフルーツをピックアップ
fruit_pick_up.gd
extends Area2D
@onready var animated_sprite_2d: AnimatedSprite2D = $AnimatedSprite2D
const FRUITS: Array = ["melon", "kiwi", "banana", "cherry"]
const GRAVITY: float = 160.0
const JUMP: float = -80.0
const POINTS: int = 2
var _start_y: float
var _speed_y: float = JUMP
var _stopped: bool = false
func _ready() -> void:
_start_y = global_position.y
animated_sprite_2d.play(FRUITS.pick_random())
func _process(delta: float) -> void:
if _stopped:
return
position.y += _speed_y * delta
_speed_y += GRAVITY * delta
if global_position.y > _start_y:
_stopped = true
func kill_me():
set_process(false)
hide()
queue_free()
func _on_life_timer_timeout() -> void:
kill_me()
func _on_area_entered(area: Area2D) -> void:
print("Pickup collected")
SignalManager.on_pickup_hit.emit(POINTS)
kill_me()
object_maker.gd
extends Node
enum BULLET_KEY {
PLAYER,
ENEMY
}
enum SCENE_KEY {
EXPLOSION,
PICKUP
}
const BULLETS = {
BULLET_KEY.PLAYER: preload("res://scenes/bullets/bullet_player/bullet_player.tscn"),
BULLET_KEY.ENEMY: preload("res://scenes/bullets/bullet_enemy/bullet_enemy.tscn"),
}
const SIMPLE_SCENES = {
SCENE_KEY.EXPLOSION: preload("res://scenes/enemy_explosion/enemy_explosion.tscn"),
SCENE_KEY.PICKUP: preload("res://scenes/fruit_pick_up/fruit_pick_up.tscn")
}
func add_child_deferred(child_to_add):
get_tree().root.add_child(child_to_add)
func call_add_child(child_to_add):
call_deferred("add_child_deferred", child_to_add)
func create_bullet(speed: float, direction: Vector2, start_pos: Vector2, life_span: float, key: BULLET_KEY):
var new_b = BULLETS[key].instantiate()
new_b.setup(direction, life_span, speed)
new_b.global_position = start_pos
call_add_child(new_b)
func create_simple_scene(start_pos: Vector2, key: SCENE_KEY):
var new_exp = SIMPLE_SCENES[key].instantiate()
new_exp.global_position = start_pos
call_add_child(new_exp)
enemy_base.gd
extends CharacterBody2D
class_name EnemyBase
enum FACING {
LEFT = -1,
RIGHT = 1
}
const OFF_SCREEN_KILL_ME: float = 1000.0
@export var default_facing: FACING = FACING.LEFT
@export var points: int = 1
@export var speed: float = 30.0
var _gravity: float = 800.0
var _facing: FACING = default_facing
var _player_ref: Player
var _dying: bool = false
func _ready() -> void:
_player_ref = get_tree().get_nodes_in_group(GameManager.GROUP_PLAYER)[0]
func _physics_process(delta: float) -> void:
fallen_off()
func fallen_off():
if global_position.y > OFF_SCREEN_KILL_ME:
queue_free()
func die():
if _dying:
return
_dying = true
SignalManager.on_enemy_hit.emit(points, global_position)
ObjectMaker.create_simple_scene(global_position, ObjectMaker.SCENE_KEY.EXPLOSION)
ObjectMaker.create_simple_scene(global_position, ObjectMaker.SCENE_KEY.PICKUP)
set_physics_process(false)
hide()
queue_free()
func _on_visible_on_screen_notifier_2d_screen_entered() -> void:
pass # Replace with function body.
func _on_visible_on_screen_notifier_2d_screen_exited() -> void:
pass # Replace with function body.
func _on_hit_box_area_entered(area: Area2D) -> void:
print("Enemy hit: ", area)
die()
Moving Platform
moving_platform.gd
extends AnimatableBody2D
@export var p1: Marker2D
@export var p2: Marker2D
@export var speed: float = 50.0
var _time_to_move: float = 0.0
var _tween: Tween
func _ready() -> void:
set_time_to_move()
set_moving()
func _exit_tree() -> void:
_tween.kill()
func set_time_to_move():
var delta = p1.global_position.distance_to(p2.global_position)
_time_to_move = delta / speed
func set_moving():
_tween = get_tree().create_tween()
_tween.set_loops(0)
_tween.tween_property(self, "global_position", p1.global_position, _time_to_move)
_tween.tween_property(self, "global_position", p2.global_position, _time_to_move)
Spinning Spikes
Node
|- Path2D
|- BallSpikes
ball_spikes.gd
extends PathFollow2D
@export var speed: float = 0.2
func _process(delta: float) -> void:
progress_ratio = progress_ratio + delta * speed
パララックスバックグラウンド
背景画像を読み込んで、自動でセットアップ
scrolling_bg.gd
extends ParallaxBackground
var BG_FILES = {
1: [
preload("res://assets/backgrounds/game_background_1/layers/sky.png"),
preload("res://assets/backgrounds/game_background_1/layers/clouds_1.png"),
preload("res://assets/backgrounds/game_background_1/layers/clouds_2.png"),
preload("res://assets/backgrounds/game_background_1/layers/clouds_3.png"),
preload("res://assets/backgrounds/game_background_1/layers/clouds_4.png"),
preload("res://assets/backgrounds/game_background_1/layers/rocks_1.png"),
preload("res://assets/backgrounds/game_background_1/layers/rocks_2.png")
],
2: [
preload("res://assets/backgrounds/game_background_2/layers/sky.png"),
preload("res://assets/backgrounds/game_background_2/layers/birds.png"),
preload("res://assets/backgrounds/game_background_2/layers/clouds_1.png"),
preload("res://assets/backgrounds/game_background_2/layers/clouds_2.png"),
preload("res://assets/backgrounds/game_background_2/layers/clouds_3.png"),
preload("res://assets/backgrounds/game_background_2/layers/pines.png"),
preload("res://assets/backgrounds/game_background_2/layers/rocks_1.png"),
preload("res://assets/backgrounds/game_background_2/layers/rocks_2.png"),
preload("res://assets/backgrounds/game_background_2/layers/rocks_3.png")
],
3: [
preload("res://assets/backgrounds/game_background_3/layers/sky.png"),
preload("res://assets/backgrounds/game_background_3/layers/clouds_1.png"),
preload("res://assets/backgrounds/game_background_3/layers/clouds_2.png"),
preload("res://assets/backgrounds/game_background_3/layers/ground_1.png"),
preload("res://assets/backgrounds/game_background_3/layers/ground_2.png"),
preload("res://assets/backgrounds/game_background_3/layers/ground_3.png"),
preload("res://assets/backgrounds/game_background_3/layers/plant.png"),
preload("res://assets/backgrounds/game_background_3/layers/rocks.png")
],
4: [
preload("res://assets/backgrounds/game_background_4/layers/sky.png"),
preload("res://assets/backgrounds/game_background_4/layers/clouds_1.png"),
preload("res://assets/backgrounds/game_background_4/layers/clouds_2.png"),
preload("res://assets/backgrounds/game_background_4/layers/ground.png"),
preload("res://assets/backgrounds/game_background_4/layers/rocks.png")
]
}
@export_range(1, 4) var level_number: int = 1
@export var mirror_x: float = 1440.0
@export var sprite_offset: Vector2 = Vector2(0, -540)
@export var sprite_scale: Vector2 = Vector2(0.75, 0.75)
func _ready() -> void:
add_backgrounds()
func get_increment() -> float:
return 1.0 / BG_FILES[level_number].size()
func get_sprite(t: Texture2D) -> Sprite2D:
var sprite = Sprite2D.new()
sprite.texture = t
sprite.scale = sprite_scale
sprite.offset = sprite_offset
return sprite
func add_layer(t: Texture2D, time_offset: float):
var sprite = get_sprite(t)
var par_la = ParallaxLayer.new()
par_la.motion_mirroring = Vector2(mirror_x, 0)
par_la.motion_scale = Vector2(time_offset, 1)
par_la.add_child(sprite)
add_child(par_la)
func add_backgrounds():
var inc = get_increment()
var time_offset = inc
var files_list = BG_FILES[level_number]
for index in range(files_list.size()):
var bg_file = files_list[index]
if index == 0:
add_layer(bg_file, 1)
else:
add_layer(bg_file, time_offset)
time_offset += inc
ParallaxBackground
ノードを拡張し、レベルごとに異なる背景を設定しています。各レベルの背景は複数のレイヤーで構成され、それぞれのレイヤーは異なる動きを持っています。
BG_FILES定数
var BG_FILES = {
1: [
// 各背景レイヤーのテクスチャファイルを事前にロード(preload)しています
preload("res://assets/backgrounds/game_background_1/layers/sky.png"),
preload("res://assets/backgrounds/game_background_1/layers/clouds_1.png"),
// 省略
],
// 他のレベルも同様に定義されています
}
これは各レベルの背景画像のリストを格納した辞書です。各レベルごとに背景レイヤーのテクスチャファイルを事前にロードして、配列に格納しています。
エクスポート変数
@export_range(1, 4) var level_number: int = 1
@export var mirror_x: float = 1440.0
@export var sprite_offset: Vector2 = Vector2(0, -540)
@export var sprite_scale: Vector2 = Vector2(0.75, 0.75)
@export
アノテーションを使って、エディターで設定できる変数を定義しています。これにより、背景レベル、スプライトのオフセット、スプライトのスケール、X軸方向のミラーリングを設定できます。
_ready関数
func _ready() -> void:
add_backgrounds()
シーンが準備完了したときに呼び出され、背景レイヤーを追加するadd_backgrounds
関数を呼び出します。
get_increment関数
func get_increment() -> float:
return 1.0 / BG_FILES[level_number].size()
背景レイヤーの数に基づいて時間オフセットの増分を計算します。これにより、各レイヤーの動きの速度を均等に分割できます。
get_sprite関数
func get_sprite(t: Texture2D) -> Sprite2D:
var sprite = Sprite2D.new()
sprite.texture = t
sprite.scale = sprite_scale
sprite.offset = sprite_offset
return sprite
指定されたテクスチャを持つ新しいSprite2D
ノードを作成し、スプライトのスケールとオフセットを設定して返します。
add_layer関数
func add_layer(t: Texture2D, time_offset: float):
var sprite = get_sprite(t)
var par_la = ParallaxLayer.new()
par_la.motion_mirroring = Vector2(mirror_x, 0)
par_la.motion_scale = Vector2(time_offset, 1)
par_la.add_child(sprite)
add_child(par_la)
新しいパララックスレイヤーを作成し、指定されたテクスチャでスプライトを設定します。次に、ミラーリングと動きのスケールを設定し、パララックスレイヤーにスプライトを追加してから、シーンにパララックスレイヤーを追加します。
add_backgrounds関数
func add_backgrounds():
var inc = get_increment()
var time_offset = inc
var files_list = BG_FILES[level_number]
for index in range(files_list.size()):
var bg_file = files_list[index]
if index == 0:
add_layer(bg_file, 1)
else:
add_layer(bg_file, time_offset)
time_offset += inc
背景レイヤーを追加するメインの関数です。まず、増分を取得し、時間オフセットを初期化します。次に、指定されたレベルの背景レイヤーのリストを取得します。各レイヤーについて、最初のレイヤーは時間オフセットを1に設定し、それ以降のレイヤーは計算された時間オフセットを使用して追加します。
このスクリプトにより、複数のレイヤーで構成されたパララックス背景が自動的に生成され、それぞれのレイヤーが異なる速度で動くようになります。これにより、視覚的に奥行きのある背景を簡単に作成できます。
2DプラッフォーマーTips
プレイヤーに追従するカメラ
プレイヤーとカメラのpositionを一致させる
level_base.gd
extends Node2D
@onready var player_cam: Camera2D = $PlayerCam
@onready var player: CharacterBody2D = $Player
func _ready() -> void:
#Engine.time_scale = 1
get_tree().paused = false
func _physics_process(delta: float) -> void:
player_cam.position = player.position
カメラシェイク
プレイヤーがダメージを受けたときに、カメラシェイク
player_cam.gd
extends Camera2D
@onready var shake_timer: Timer = $ShakeTimer
@export var shake_amount: float = 5.0
func _ready() -> void:
set_process(false)
SignalManager.on_player_hit.connect(on_player_hit)
func _process(delta: float) -> void:
offset = get_random_offset()
func get_random_offset():
return Vector2(
randf_range(-shake_amount, shake_amount),
randf_range(-shake_amount, shake_amount),
)
func shake():
set_process(true)
shake_timer.start()
func on_player_hit(_lives: int):
shake()
func _on_shake_timer_timeout() -> void:
set_process(false)
offset = Vector2.ZERO
マネージャー系
下記のようなマネージャーはsingletonsフォルダにまとめる
自動読み込みに設定
signal_manager.gd
extends Node
signal on_enemy_hit(points: int, enemy_position: Vector2)
signal on_pickup_hit(points: int)
signal on_boss_killed(points: int)
signal on_player_hit(lives: int)
signal on_level_complete
signal on_game_over
signal on_score_updated
SignalManagerのシグナルをemitして、connectで受け取って処理をする
player.gd
func reduce_lives():
_lives -= 1
SignalManager.on_player_hit.emit(_lives)
if _lives <= 0:
SignalManager.on_game_over.emit()
set_physics_process(false)
return false
return true
hud.gd
func _ready() -> void:
_hearts = hb_hearts.get_children()
SignalManager.on_level_complete.connect(on_level_complete)
SignalManager.on_game_over.connect(on_game_over)
SignalManager.on_player_hit.connect(on_player_hit)
SignalManager.on_score_updated.connect(on_score_updated)
func on_game_over():
vb_game_over.visible = true
show_hud()
HUD
画面はすべてHUDのControlにまとめる
ゲームシーンでCanvasLayerの下にHUDを配置する
スクリプトでvisibleをon/offして表示を切り分ける
hud.gd
extends Control
@onready var color_rect: ColorRect = $ColorRect
@onready var vb_level_complete: VBoxContainer = $ColorRect/VB_LevelComplete
@onready var vb_game_over: VBoxContainer = $ColorRect/VB_GameOver
@onready var hb_hearts: HBoxContainer = $MC/HB/HB_Hearts
@onready var score_label: Label = $MC/HB/ScoreLabel
var _hearts: Array
func _ready() -> void:
_hearts = hb_hearts.get_children()
SignalManager.on_level_complete.connect(on_level_complete)
SignalManager.on_game_over.connect(on_game_over)
SignalManager.on_player_hit.connect(on_player_hit)
SignalManager.on_score_updated.connect(on_score_updated)
func _process(delta: float) -> void:
if vb_level_complete.visible:
if Input.is_action_just_pressed("jump"):
GameManager.load_next_level_scene()
if vb_game_over.visible:
if Input.is_action_just_pressed("jump"):
GameManager.load_main_scene()
func show_hud():
#Engine.time_scale = 0
get_tree().paused = true
color_rect.visible = true
func on_score_updated():
score_label.text = str(ScoreManager.get_score()).lpad(5, "0")
func on_player_hit(lives: int):
for life in range(_hearts.size()):
_hearts[life].visible = lives > life
func on_game_over():
vb_game_over.visible = true
show_hud()
func on_level_complete():
vb_level_complete.visible = true
show_hud()
倉庫番
自動タイル設置
TileMapのタイルをJSONデータをもとにスクリプトから設置
level_data.json
{
"1": {
"tiles": {
"Floor": [
{
"coord": {
"x": 6,
"y": 3
}
}
],
"Walls": [
{
"coord": {
"x": 6,
"y": 3
}
},
],
"Targets": [
{
"coord": {
"x": 7,
"y": 4
}
},
],
"TargetBoxes": [],
"Boxes": [
{
"coord": {
"x": 7,
"y": 6
}
}
]
},
"player_start": {
"x": 8,
"y": 7
}
},
}
game_data.gd
extends Node
const LEVEL_DATA_PATH: String = "res://data/level_data.json"
const TILE_SIZE: int = 32
var _level_data: Dictionary = {}
func _ready() -> void:
load_level_data()
func load_level_data():
var file = FileAccess.open(LEVEL_DATA_PATH, FileAccess.READ)
_level_data = JSON.parse_string(file.get_as_text())
func get_data_for_level(level_num: String) -> Dictionary:
return _level_data[level_num]
level.gd
extends Node2D
@onready var tile_map: TileMap = $TileMap
@onready var player: AnimatedSprite2D = $Player
@onready var camera_2d: Camera2D = $Camera2D
const FLOOR_LAYER = 0
const WALL_LAYER = 1
const TARGET_LAYER = 2
const BOX_LAYER = 3
const SOURCE_ID = 0
const LAYER_KEY_FLOOR = "Floor"
const LAYER_KEY_WALLS = "Walls"
const LAYER_KEY_TARGETS = "Targets"
const LAYER_KEY_TARGET_BOXES = "TargetBoxes"
const LAYER_KEY_BOXES = "Boxes"
const LAYER_MAP = {
LAYER_KEY_FLOOR: FLOOR_LAYER,
LAYER_KEY_WALLS: WALL_LAYER,
LAYER_KEY_TARGETS: TARGET_LAYER,
LAYER_KEY_TARGET_BOXES: BOX_LAYER,
LAYER_KEY_BOXES: BOX_LAYER
}
func _ready() -> void:
setup_level()
func get_atlas_coord_for_layer_name(layer_name: String) -> Vector2i:
match layer_name:
LAYER_KEY_FLOOR:
return Vector2i(randi_range(3, 8), 0)
LAYER_KEY_WALLS:
return Vector2i(2, 0)
LAYER_KEY_TARGETS:
return Vector2i(9, 0)
LAYER_KEY_TARGET_BOXES:
return Vector2i(0, 0)
LAYER_KEY_BOXES:
return Vector2i(1, 0)
return Vector2i.ZERO
func add_tile(tile_coord: Dictionary, layer_name: String):
var layer_number = LAYER_MAP[layer_name]
var coord_vec: Vector2i = Vector2i(tile_coord.x, tile_coord.y)
var atlas_vec = get_atlas_coord_for_layer_name(layer_name)
tile_map.set_cell(layer_number, coord_vec, SOURCE_ID, atlas_vec)
func add_layer_tiles(layer_tiles, layer_name: String):
for tile_coord in layer_tiles:
add_tile(tile_coord.coord, layer_name)
func setup_level():
tile_map.clear()
var level_data = GameData.get_data_for_level("24")
var level_tiles = level_data.tiles
var player_start = level_data.player_start
for layer_name in LAYER_MAP.keys():
add_layer_tiles(level_tiles[layer_name], layer_name)
ベストスコア
score_manager.gd
extends Node
const SCORE_FILE: String = "user://scores.dat"
const NO_BEST: int = 10000
var _best_scores: Dictionary = {}
func _ready() -> void:
load_scores()
func load_scores():
if !FileAccess.file_exists(SCORE_FILE):
return
var file = FileAccess.open(SCORE_FILE, FileAccess.READ)
_best_scores = JSON.parse_string(file.get_as_text())
func save_scores():
var file = FileAccess.open(SCORE_FILE, FileAccess.WRITE)
file.store_string(JSON.stringify(_best_scores))
func has_level_score(level: String):
return level in _best_scores
func get_level_best_scores(level: String):
if has_level_score(level):
return _best_scores[level]
return NO_BEST
func score_in_new_best(level: String, moves: int):
if !has_level_score(level):
return true
if get_level_best_scores(level) > moves:
return true
return false
func level_completed(level: String, moves: int):
if score_in_new_best(level, moves):
_best_scores[level] = moves
save_scores()
level.gd
func check_game_state():
for t in tile_map.get_used_cells(TARGET_LAYER):
if !cell_is_box(t):
return
game_over_ui.show()
hud.hide()
ScoreSync.level_completed(GameManager.get_level_selected(), _total_moves)
シューティング
敵ウェーブ
wave_manager.gd
extends Node2D
const ANIM_FRAMES = {
GameData.ENEMY_TYPE.ZIPPER: ["zipper_1", "zipper_2", "zipper_3"],
GameData.ENEMY_TYPE.BIO: ["biomech_1", "biomech_2", "biomech_3"],
GameData.ENEMY_TYPE.BOMBER: ["bomber_1", "bomber_2", "bomber_3"],
}
const ENEMY_SCENES = {
GameData.ENEMY_TYPE.ZIPPER: preload("res://scenes/enemies/enemy_zipper.tscn"),
GameData.ENEMY_TYPE.BIO: preload("res://scenes/enemies/enemy_bio.tscn"),
GameData.ENEMY_TYPE.BOMBER: preload("res://scenes/enemies/enemy_bomber.tscn"),
}
@onready var paths: Node2D = $Paths
var _paths_list: Array = []
func _ready() -> void:
_paths_list = paths.get_children()
spawn_wave()
func create_enemy(speed: float, anim_name: String, en_type: GameData.ENEMY_TYPE):
var new_en = ENEMY_SCENES[en_type].instantiate()
new_en.setup(speed, anim_name)
return new_en
func spawn_wave():
var path = _paths_list.pick_random()
var en_type = GameData.ENEMY_TYPE.values().pick_random()
var anim = ANIM_FRAMES[en_type].pick_random()
for num in range(4):
path.add_child(create_enemy(0.2, anim, en_type))
await get_tree().create_timer(1).timeout
func _on_spawn_timer_timeout() -> void:
spawn_wave()
敵ウェーブチューニング
敵ごとに設定を調整
wave_manager.gd
extends Node2D
const ANIM_FRAMES = {
GameData.ENEMY_TYPE.ZIPPER: ["zipper_1", "zipper_2", "zipper_3"],
GameData.ENEMY_TYPE.BIO: ["biomech_1", "biomech_2", "biomech_3"],
GameData.ENEMY_TYPE.BOMBER: ["bomber_1", "bomber_2", "bomber_3"],
}
const ENEMY_SCENES = {
GameData.ENEMY_TYPE.ZIPPER: preload("res://scenes/enemies/enemy_zipper.tscn"),
GameData.ENEMY_TYPE.BIO: preload("res://scenes/enemies/enemy_bio.tscn"),
GameData.ENEMY_TYPE.BOMBER: preload("res://scenes/enemies/enemy_bomber.tscn")
}
const ENEMY_DATA = {
GameData.ENEMY_TYPE.ZIPPER: { "speed": 0.10, "gap": 0.6, "min": 6, "max": 10},
GameData.ENEMY_TYPE.BIO: { "speed": 0.08, "gap": 0.7, "min": 6, "max": 8},
GameData.ENEMY_TYPE.BOMBER: { "speed": 0.07, "gap": 1.0, "min": 2, "max": 4},
}
@onready var paths = $Paths
@onready var spawn_timer = $SpawnTimer
var _paths_list: Array = []
var _speed_factor: float = 1.0
var _wave_count: int = 0
var _last_path_index: int = -1
var _wave_gap: float = 6.0
# Called when the node enters the scene tree for the first time.
func _ready():
_paths_list = paths.get_children()
print(len(_paths_list))
spawn_wave()
func create_enemy(speed: float, anim_name:String, en_type: GameData.ENEMY_TYPE):
var new_en = ENEMY_SCENES[en_type].instantiate()
new_en.setup(speed, anim_name)
return new_en
func update_speeds() -> void:
if _wave_count % len(_paths_list) == 0 and _wave_count != 0:
_speed_factor *= 1.05
_wave_gap *= 0.97
print("update_speeds(): _wave_count:%s _speed_factor:%s _wave_gap:%s" % [
_wave_count, _speed_factor, _wave_gap])
func start_spawn_timer() -> void:
spawn_timer.wait_time = _wave_gap
spawn_timer.start()
func get_random_path_index() -> int:
var index = randi_range(0, len(_paths_list)-1)
while index == _last_path_index:
index = randi_range(0, len(_paths_list)-1)
_last_path_index = index
return index
func spawn_wave() -> void:
var path = _paths_list[get_random_path_index()]
var en_type = GameData.ENEMY_TYPE.values().pick_random()
var anim = ANIM_FRAMES[en_type].pick_random()
var spawn_data = ENEMY_DATA[en_type]
print("\nspawn_wave()\n_last_path_index:", _last_path_index)
print("spawn_data:", spawn_data)
for num in range(randi_range(spawn_data.min, spawn_data.max)):
path.add_child(create_enemy(spawn_data.speed * _speed_factor, anim, en_type))
await get_tree().create_timer(spawn_data.gap).timeout
print("wave() spawned, waiting:", _wave_gap)
_wave_count += 1
await get_tree().create_timer(_wave_gap).timeout
update_speeds()
start_spawn_timer()
func _on_spawn_timer_timeout():
spawn_wave()
定数の定義
-
ANIM_FRAMES
: 各敵タイプに対応するアニメーションフレームのリストを定義しています。 -
ENEMY_SCENES
: 各敵タイプに対応するシーンをプリロードしています。 -
ENEMY_DATA
: 各敵タイプに対する速度、出現間隔、最小および最大数のデータを定義しています。
変数の定義と初期化
-
_paths_list
: 敵が進む経路のリストを格納します。 -
_speed_factor
: 敵の速度の倍率を格納します。初期値は1.0
です。 -
_wave_count
: 出現するウェーブのカウントを格納します。 -
_last_path_index
: 最後に使用された経路のインデックスを格納します。初期値は-1
です。 -
_wave_gap
: ウェーブ間の間隔を格納します。初期値は6.0
秒です。
_ready
関数
シーンツリーにノードが追加されたときに呼び出される関数です。
func _ready():
_paths_list = paths.get_children()
print(len(_paths_list))
spawn_wave()
- 経路の子ノードを
_paths_list
に格納します。 - 経路の数を出力します。
-
spawn_wave()
を呼び出して最初のウェーブを出現させます。
create_enemy
関数
敵のインスタンスを作成し、速度とアニメーションを設定します。
func create_enemy(speed: float, anim_name: String, en_type: GameData.ENEMY_TYPE):
var new_en = ENEMY_SCENES[en_type].instantiate()
new_en.setup(speed, anim_name)
return new_en
- 指定された敵タイプのシーンをインスタンス化します。
- その敵に速度とアニメーションを設定します。
- 敵のインスタンスを返します。
update_speeds
関数
ウェーブごとに敵の速度とウェーブ間の間隔を更新します。
func update_speeds() -> void:
if _wave_count % len(_paths_list) == 0 and _wave_count != 0:
_speed_factor *= 1.05
_wave_gap *= 0.97
print("update_speeds(): _wave_count:%s _speed_factor:%s _wave_gap:%s" % [
_wave_count, _speed_factor, _wave_gap])
- ウェーブ数が経路の数で割り切れる場合(つまり、全経路を使い切った場合)、速度を増加し、間隔を短縮します。
start_spawn_timer
関数
スポーンタイマーを開始します。
func start_spawn_timer() -> void:
spawn_timer.wait_time = _wave_gap
spawn_timer.start()
- タイマーの待機時間を
_wave_gap
に設定します。 - タイマーを開始します。
get_random_path_index
関数
ランダムな経路インデックスを取得します。
func get_random_path_index() -> int:
var index = randi_range(0, len(_paths_list) - 1)
while index == _last_path_index:
index = randi_range(0, len(_paths_list) - 1)
_last_path_index = index
return index
- 経路リストの範囲内でランダムなインデックスを生成します。
- 直前のインデックスと同じ場合は再生成します。
- 最後に使用したインデックスとして
_last_path_index
に格納します。
spawn_wave
関数
敵のウェーブを生成します。
func spawn_wave() -> void:
var path = _paths_list[get_random_path_index()]
var en_type = GameData.ENEMY_TYPE.values().pick_random()
var anim = ANIM_FRAMES[en_type].pick_random()
var spawn_data = ENEMY_DATA[en_type]
print("\nspawn_wave()\n_last_path_index:", _last_path_index)
print("spawn_data:", spawn_data)
for num in range(randi_range(spawn_data.min, spawn_data.max)):
path.add_child(create_enemy(spawn_data.speed * _speed_factor, anim, en_type))
await get_tree().create_timer(spawn_data.gap).timeout
print("wave() spawned, waiting:", _wave_gap)
_wave_count += 1
await get_tree().create_timer(_wave_gap).timeout
update_speeds()
start_spawn_timer()
- ランダムな経路を取得します。
- ランダムな敵タイプを選択します。
- 選択した敵タイプのアニメーションとデータを取得します。
- 指定された範囲内でランダムな数の敵を生成し、指定した経路に追加します。
- 敵の出現間隔を待機します。
- ウェーブが完了したら、ウェーブカウントを増加し、次のウェーブまで待機します。
- 速度と間隔を更新し、スポーンタイマーを再開始します。
_on_spawn_timer_timeout
関数
タイマーがタイムアウトしたときに呼び出される関数で、新しいウェーブを生成します。
func _on_spawn_timer_timeout():
spawn_wave()
-
spawn_wave()
を呼び出して新しいウェーブを生成します。
このロジックにより、敵がランダムな経路と間隔でウェーブごとに出現し、ウェーブが進むごとに敵の速度が増加し、ウェーブ間の間隔が短縮されます。
発射
base_bullet.gd
extends Area2D
var _direction: Vector2 = Vector2.UP
var _speed: float = 200.0
var _damage: int = 10
func _process(delta: float) -> void:
position += _direction * _speed * delta
func setup(pos: Vector2, dir: Vector2, sp: float, dmg: int):
_direction = dir
_speed = sp
_damage = dmg
global_position = pos
func blow_up(area: Node2D):
pass
func _on_visible_on_screen_notifier_2d_screen_exited() -> void:
queue_free()
func _on_area_entered(area: Area2D) -> void:
blow_up(area)
player.gd
extends Area2D
class_name Player
@onready var sprite_2d: Sprite2D = $Sprite2D
@onready var animation_player: AnimationPlayer = $AnimationPlayer
@export var bullet_scene: PackedScene
@export var speed: float = 250.0
@export var bullet_speed: float = 250.0
@export var bullet_damage: int = 10
@export var bullet_direction: Vector2 = Vector2.UP
const MARGIN: float = 32.0
var _upper_left: Vector2
var _lower_right: Vector2
func _ready() -> void:
var vp = get_viewport_rect()
_lower_right = Vector2(
vp.size.x - MARGIN,
vp.size.y - MARGIN
)
_upper_left = Vector2(MARGIN, MARGIN)
func _process(delta: float) -> void:
var input = get_input()
global_position += input * delta * speed
global_position = global_position.clamp(
_upper_left,
_lower_right
)
if Input.is_action_just_pressed("shoot"):
shoot()
func get_input() -> Vector2:
var v = Vector2(
Input.get_axis("left", "right"),
Input.get_axis("up", "down")
)
if v.x != 0:
animation_player.play("turn")
if v.x > 0:
sprite_2d.flip_h = true
else:
sprite_2d.flip_h = false
else:
animation_player.play("fly")
return v.normalized()
func shoot():
var bullet = bullet_scene.instantiate()
bullet.setup(
global_position,
bullet_direction,
bullet_speed,
bullet_damage
)
get_tree().root.add_child(bullet)
ホーミングミサイル
プレイヤーに追跡するミサイル
homing_missile.gd
extends Area2D
const ROTATION_SPEED: float = 200.0
const SPEED: float = 100.0
var _player_ref: Player
func _ready() -> void:
_player_ref = get_tree().get_first_node_in_group(GameData.GROUP_PLAYER)
func _process(delta: float) -> void:
turn(delta)
position += transform.x.normalized() * SPEED * delta
func get_angle_to_player():
if !is_instance_valid(_player_ref):
return 0.0
return rad_to_deg((_player_ref.global_position - global_position).angle())
func get_angle_to_turn(angle_to_player: float):
return fmod((angle_to_player - global_rotation_degrees + 180.0), 360.0) - 180.0
func turn(delta: float):
var angle_to_player = get_angle_to_player()
var angle_to_turn = get_angle_to_turn(angle_to_player)
if abs(angle_to_turn) < 180.0:
rotation_degrees += sign(angle_to_turn) * delta * ROTATION_SPEED
else:
rotation_degrees += sign(angle_to_turn) * -1 * delta * ROTATION_SPEED
このホーミングミサイルのロジックは、プレイヤーに向かって追尾する動作を実装しています。以下に、その主要な部分と動作の流れを解説します。
変数の定義
-
ROTATION_SPEED
(回転速度): ミサイルが1秒間に回転する角度を度単位で表します。値は200.0
です。 -
SPEED
(速度): ミサイルの移動速度を表します。値は100.0
です。 -
_player_ref
: プレイヤーの参照を格納する変数です。
_ready
関数
ゲームが開始されたときに最初に呼び出される関数です。この中で、プレイヤーのノードを取得して _player_ref
に設定しています。
func _ready() -> void:
_player_ref = get_tree().get_first_node_in_group(GameData.GROUP_PLAYER)
_process
関数
毎フレーム呼び出される関数で、ミサイルの回転と移動を行います。
func _process(delta: float) -> void:
turn(delta)
position += transform.x.normalized() * SPEED * delta
-
turn(delta)
: ミサイルをプレイヤーの方向に回転させます。 -
position += transform.x.normalized() * SPEED * delta
: ミサイルを前進させます。
get_angle_to_player
関数
プレイヤーに対する角度を計算します。プレイヤーが有効でない場合は 0.0
を返します。
func get_angle_to_player():
if !is_instance_valid(_player_ref):
return 0.0
return rad_to_deg((_player_ref.global_position - global_position).angle())
-
_player_ref
が有効かどうかチェックします。 - 有効であれば、ミサイルの位置からプレイヤーの位置への角度をラジアンで取得し、度に変換して返します。
get_angle_to_turn
関数
プレイヤーに向かうために必要な回転角度を計算します。
func get_angle_to_turn(angle_to_player: float):
return fmod((angle_to_player - global_rotation_degrees + 180.0), 360.0) - 180.0
- プレイヤーに対する角度 (
angle_to_player
) から現在のミサイルの回転角度を引きます。 - それに180度を足し、360度で剰余を取り、さらに180度を引くことで、最小の回転角度を計算します。
turn
関数
ミサイルをプレイヤーの方向に回転させます。
func turn(delta: float):
var angle_to_player = get_angle_to_player()
var angle_to_turn = get_angle_to_turn(angle_to_player)
if abs(angle_to_turn) < 180.0:
rotation_degrees += sign(angle_to_turn) * delta * ROTATION_SPEED
else:
rotation_degrees += sign(angle_to_turn) * -1 * delta * ROTATION_SPEED
-
get_angle_to_player()
を呼び出してプレイヤーに対する角度を取得します。 -
get_angle_to_turn(angle_to_player)
を呼び出してプレイヤーに向かうための回転角度を計算します。 - 回転角度の絶対値が180度未満であれば、その方向に回転します。そうでなければ逆方向に回転します。
このロジックにより、ミサイルは毎フレームごとにプレイヤーの方向に向かって適切な角度で回転し、その後一定の速度で前進します。これにより、プレイヤーを追尾するホーミングミサイルが実現されています。
カメラシェイク
shake_cam.gd
extends Camera2D
@onready var timer: Timer = $Timer
const SHAKE_RANGE: Vector2 = Vector2(-3.0, 3.0)
var _shake_offset: Vector2 = Vector2.ZERO
func _ready() -> void:
_shake_offset = offset
SignalManager.on_player_hit.connect(on_player_hit)
set_process(false)
func _process(delta: float) -> void:
offset = Vector2(
_shake_offset.x + get_random_shake_amount(),
_shake_offset.y + get_random_shake_amount()
)
func get_random_shake_amount():
return randf_range(SHAKE_RANGE.x, SHAKE_RANGE.y)
func on_player_hit(_v: int):
set_process(true)
timer.start()
func _on_timer_timeout() -> void:
set_process(false)
offset = _shake_offset
シューティング
リソース
wave_1.tres、wave_2.tresのリソースを作成して、wave_list.tresにまとめる
まとめたリソースをwave_managerでコールする
extends Resource
class_name WaveResource
@export var enemy_type: GameData.ENEMY_TYPE
@export var speed: float
@export var gap: float
@export var min: float
@export var max: float
wave_list_resource.gd
extends Resource
class_name WaveListResource
@export var waves: Array[WaveResource]
@export var wave_gap: float
@export var speed_factor: float
var _current_wave: int = 0
func get_next_wave() -> WaveResource:
if _current_wave == waves.size():
_current_wave = 0
var index = _current_wave
_current_wave += 1
return waves[index]
wave_manager_res.gd
extends Node2D
var wave_list: WaveListResource = preload("res://wave_resources/wave_list.tres")
const ANIM_FRAMES = {
GameData.ENEMY_TYPE.ZIPPER: ["zipper_1", "zipper_2", "zipper_3"],
GameData.ENEMY_TYPE.BIO: ["biomech_1", "biomech_2", "biomech_3"],
GameData.ENEMY_TYPE.BOMBER: ["bomber_1", "bomber_2", "bomber_3"],
}
const ENEMY_SCENES = {
GameData.ENEMY_TYPE.ZIPPER: preload("res://scenes/enemies/enemy_zipper.tscn"),
GameData.ENEMY_TYPE.BIO: preload("res://scenes/enemies/enemy_bio.tscn"),
GameData.ENEMY_TYPE.BOMBER: preload("res://scenes/enemies/enemy_bomber.tscn")
}
@onready var paths = $Paths
@onready var spawn_timer = $SpawnTimer
var _paths_list: Array = []
var _wave_count: int = 0
var _last_path_index: int = -1
# Called when the node enters the scene tree for the first time.
func _ready():
_paths_list = paths.get_children()
print(len(_paths_list))
spawn_wave()
func create_enemy(speed: float, anim_name:String, en_type: GameData.ENEMY_TYPE):
var new_en = ENEMY_SCENES[en_type].instantiate()
new_en.setup(speed, anim_name)
return new_en
func update_speeds() -> void:
if _wave_count % len(_paths_list) == 0 and _wave_count != 0:
wave_list.speed_factor *= 1.05
wave_list.wave_gap *= 0.97
print("update_speeds(): _wave_count:%s _speed_factor:%s _wave_gap:%s" % [
_wave_count, wave_list.speed_factor, wave_list.wave_gap])
func start_spawn_timer() -> void:
spawn_timer.wait_time = wave_list.wave_gap
spawn_timer.start()
func get_random_path_index() -> int:
var index = randi_range(0, len(_paths_list)-1)
while index == _last_path_index:
index = randi_range(0, len(_paths_list)-1)
_last_path_index = index
return index
func spawn_wave() -> void:
var path = _paths_list[get_random_path_index()]
var wave_res: WaveResource = wave_list.get_next_wave()
var en_type = wave_res.enemy_type
var anim = ANIM_FRAMES[en_type].pick_random()
print("\nspawn_wave()\n_last_path_index:", _last_path_index)
print("wave_res:", wave_res)
for num in range(randi_range(wave_res.min, wave_res.max)):
path.add_child(create_enemy(wave_res.speed * wave_list.speed_factor, anim, en_type))
await get_tree().create_timer(wave_res.gap).timeout
print("wave() spawned, waiting:", wave_list.wave_gap)
_wave_count += 1
await get_tree().create_timer(wave_list.wave_gap).timeout
update_speeds()
start_spawn_timer()
func _on_spawn_timer_timeout():
spawn_wave()
ウェーブで敵が出現するロジックをリソース管理を通じて実装しています。リソースを使用することで、ウェーブの設定を外部ファイルで管理しやすくしています。以下に、その主要な部分と動作の流れを解説します。
WaveResource クラス
WaveResource
クラスは、ウェーブごとに敵のタイプ、速度、出現間隔、最小・最大数を定義するためのリソースです。
extends Resource
class_name WaveResource
@export var enemy_type: GameData.ENEMY_TYPE
@export var speed: float
@export var gap: float
@export var min: float
@export var max: float
-
enemy_type
: 敵のタイプを指定します。 -
speed
: 敵の速度を指定します。 -
gap
: 敵の出現間隔を指定します。 -
min
: 出現する敵の最小数を指定します。 -
max
: 出現する敵の最大数を指定します。
WaveListResource クラス
WaveListResource
クラスは、複数の WaveResource
を管理し、ウェーブ間の間隔や速度の倍率を設定するためのリソースです。
extends Resource
class_name WaveListResource
@export var waves: Array[WaveResource]
@export var wave_gap: float
@export var speed_factor: float
var _current_wave: int = 0
func get_next_wave() -> WaveResource:
if _current_wave == waves.size():
_current_wave = 0
var index = _current_wave
_current_wave += 1
return waves[index]
-
waves
: 複数のWaveResource
を格納する配列です。 -
wave_gap
: ウェーブ間の間隔を指定します。 -
speed_factor
: 敵の速度の倍率を指定します。 -
_current_wave
: 現在のウェーブのインデックスを管理します。 -
get_next_wave()
: 次のウェーブを取得します。すべてのウェーブが終わったら最初に戻ります。
WaveManager クラス
WaveManager
クラスは、ゲーム内でウェーブを管理し、敵を生成するロジックを実装します。
extends Node2D
var wave_list: WaveListResource = preload("res://wave_resources/wave_list.tres")
const ANIM_FRAMES = {
GameData.ENEMY_TYPE.ZIPPER: ["zipper_1", "zipper_2", "zipper_3"],
GameData.ENEMY_TYPE.BIO: ["biomech_1", "biomech_2", "biomech_3"],
GameData.ENEMY_TYPE.BOMBER: ["bomber_1", "bomber_2", "bomber_3"],
}
const ENEMY_SCENES = {
GameData.ENEMY_TYPE.ZIPPER: preload("res://scenes/enemies/enemy_zipper.tscn"),
GameData.ENEMY_TYPE.BIO: preload("res://scenes/enemies/enemy_bio.tscn"),
GameData.ENEMY_TYPE.BOMBER: preload("res://scenes/enemies/enemy_bomber.tscn")
}
@onready var paths = $Paths
@onready var spawn_timer = $SpawnTimer
var _paths_list: Array = []
var _wave_count: int = 0
var _last_path_index: int = -1
# Called when the node enters the scene tree for the first time.
func _ready():
_paths_list = paths.get_children()
print(len(_paths_list))
spawn_wave()
func create_enemy(speed: float, anim_name: String, en_type: GameData.ENEMY_TYPE):
var new_en = ENEMY_SCENES[en_type].instantiate()
new_en.setup(speed, anim_name)
return new_en
func update_speeds() -> void:
if _wave_count % len(_paths_list) == 0 and _wave_count != 0:
wave_list.speed_factor *= 1.05
wave_list.wave_gap *= 0.97
print("update_speeds(): _wave_count:%s _speed_factor:%s _wave_gap:%s" % [
_wave_count, wave_list.speed_factor, wave_list.wave_gap])
func start_spawn_timer() -> void:
spawn_timer.wait_time = wave_list.wave_gap
spawn_timer.start()
func get_random_path_index() -> int:
var index = randi_range(0, len(_paths_list) - 1)
while index == _last_path_index:
index = randi_range(0, len(_paths_list) - 1)
_last_path_index = index
return index
func spawn_wave() -> void:
var path = _paths_list[get_random_path_index()]
var wave_res: WaveResource = wave_list.get_next_wave()
var en_type = wave_res.enemy_type
var anim = ANIM_FRAMES[en_type].pick_random()
print("\nspawn_wave()\n_last_path_index:", _last_path_index)
print("wave_res:", wave_res)
for num in range(randi_range(wave_res.min, wave_res.max)):
path.add_child(create_enemy(wave_res.speed * wave_list.speed_factor, anim, en_type))
await get_tree().create_timer(wave_res.gap).timeout
print("wave() spawned, waiting:", wave_list.wave_gap)
_wave_count += 1
await get_tree().create_timer(wave_list.wave_gap).timeout
update_speeds()
start_spawn_timer()
func _on_spawn_timer_timeout():
spawn_wave()
主要な関数の解説
-
_ready()
: ノードがシーンツリーに追加されたときに呼び出され、経路リストを初期化し、最初のウェーブを生成します。 -
create_enemy()
: 敵のインスタンスを作成し、速度とアニメーションを設定します。 -
update_speeds()
: ウェーブごとに速度の倍率とウェーブ間の間隔を更新します。 -
start_spawn_timer()
: スポーンタイマーを開始します。 -
get_random_path_index()
: ランダムな経路インデックスを取得します。 -
spawn_wave()
: 新しいウェーブを生成し、敵を指定された経路に追加します。 -
_on_spawn_timer_timeout()
: タイマーがタイムアウトしたときに次のウェーブを生成します。
このロジックにより、ウェーブごとに異なる敵タイプ、速度、出現間隔を持つ敵がランダムな経路に生成され、ウェーブが進むごとに速度と間隔が調整されます。これにより、ゲームの進行に応じた難易度調整が実現されています。
シェーダー
Mix
shader_type canvas_item;
uniform sampler2D sampled_texture;
void vertex() {
}
void fragment() {
vec3 zombie_tex = COLOR.rgb * vec3(vec2(0.7), 0.8);
vec3 zebra_tex = texture(sampled_texture, UV).rgb;
COLOR.rgb = mix(zombie_tex, zebra_tex, 0.12);
}
炎
shader_type canvas_item;
uniform sampler2D noise: repeat_enable;
uniform vec4 fire_c_a: source_color = vec4(1.0, 1.0, 0.0, 1.0);
uniform vec4 fire_c_b: source_color = vec4(1.0, 0.2, 0.0, 1.0);
void fragment() {
vec4 noise_tex = texture(noise, UV + TIME * 0.5);
float n_scale = 0.05;
vec4 heat_tex = texture(TEXTURE, vec2(UV.x + noise_tex.r * n_scale - n_scale / 2.0, UV.y));
vec4 fire_c = mix(fire_c_a, fire_c_b, noise_tex.r);
float heat_fire_ratio = step(noise_tex.r, UV.y) * UV.y + 0.2;
COLOR.rgb = mix(heat_tex, fire_c, heat_fire_ratio).rgb;
COLOR.a = heat_tex.a;
}
ノイズテクスチャを使用して炎の揺らぎを表現し、2つの色を混ぜて炎の色の変化を表現しています。以下に、このシェーダーの主要な部分とその動作を解説します。
シェーダーの定義とユニフォーム変数
shader_type canvas_item;
uniform sampler2D noise: repeat_enable;
uniform vec4 fire_c_a: source_color = vec4(1.0, 1.0, 0.0, 1.0);
uniform vec4 fire_c_b: source_color = vec4(1.0, 0.2, 0.0, 1.0);
-
shader_type canvas_item;
: このシェーダーがキャンバスアイテム(2D 描画用)であることを指定します。 -
uniform sampler2D noise: repeat_enable;
: ノイズテクスチャを指定します。repeat_enable
はテクスチャの繰り返しを有効にします。 -
uniform vec4 fire_c_a: source_color = vec4(1.0, 1.0, 0.0, 1.0);
: 炎の色A(黄色)。 -
uniform vec4 fire_c_b: source_color = vec4(1.0, 0.2, 0.0, 1.0);
: 炎の色B(オレンジ)。
fragment
関数
この関数は、各フラグメント(ピクセル)の色を計算します。
void fragment() {
vec4 noise_tex = texture(noise, UV + TIME * 0.5);
float n_scale = 0.05;
vec4 heat_tex = texture(TEXTURE, vec2(UV.x + noise_tex.r * n_scale - n_scale / 2.0, UV.y));
vec4 fire_c = mix(fire_c_a, fire_c_b, noise_tex.r);
float heat_fire_ratio = step(noise_tex.r, UV.y) * UV.y + 0.2;
COLOR.rgb = mix(heat_tex, fire_c, heat_fire_ratio).rgb;
COLOR.a = heat_tex.a;
}
ノイズテクスチャの取得と変換
vec4 noise_tex = texture(noise, UV + TIME * 0.5);
-
noise_tex
: ノイズテクスチャをUV座標と時間を使ってサンプリングします。これにより、時間経過に伴ってノイズが動きます。
熱テクスチャの取得とスケーリング
float n_scale = 0.05;
vec4 heat_tex = texture(TEXTURE, vec2(UV.x + noise_tex.r * n_scale - n_scale / 2.0, UV.y));
-
n_scale
: ノイズのスケーリング係数。 -
heat_tex
: 元のテクスチャを取得し、ノイズテクスチャの赤チャネルを使ってX方向にオフセットします。
炎の色の計算
vec4 fire_c = mix(fire_c_a, fire_c_b, noise_tex.r);
-
fire_c
: ノイズテクスチャの赤チャネルを使って、炎の色Aと炎の色Bを混ぜます。
熱と炎の比率の計算
float heat_fire_ratio = step(noise_tex.r, UV.y) * UV.y + 0.2;
-
heat_fire_ratio
:UV.y
がnoise_tex.r
より大きい場合に1になるステップ関数を使い、炎の揺らぎを表現します。この比率により、炎の色が上に行くほど変化します。
最終色の計算
COLOR.rgb = mix(heat_tex, fire_c, heat_fire_ratio).rgb;
COLOR.a = heat_tex.a;
-
COLOR.rgb
: 熱テクスチャと炎の色をheat_fire_ratio
を使って混ぜます。これにより、炎の色が時間とともに変化します。 -
COLOR.a
: 元のテクスチャのアルファ値を使用します。
まとめ
このシェーダーは、ノイズテクスチャを使って時間とともに動く炎の揺らぎを表現し、2つの色(黄色とオレンジ)を混ぜてリアルな炎の色の変化を実現します。さらに、炎の揺らぎをUV座標とノイズテクスチャの赤チャネルを使って計算し、動きのあるリアルな炎を描画しています。
ダメージ
shader_type canvas_item;
group_uniforms progress_dmg_taken;
uniform sampler2D blood_n: repeat_enable;
uniform sampler2D blood_grad: repeat_enable;
uniform sampler2D blood_curve;
uniform float dmg_progress: hint_range(0.0, 1.0) = 0.0;
group_uniforms;
void progress_dmg_taken(inout vec3 c, vec2 uv) {
vec3 blood_tex = texture(blood_grad, uv).rgb;
float curved_dmg_progress = texture(
blood_curve,
vec2(dmg_progress, 1.0)).r;
float noise_r = texture(blood_n, uv).r;
vec3 damage_tex = mix(blood_tex, c.rgb, noise_r);
damage_tex.rgb = mix(c.rgb, damage_tex.rgb, curved_dmg_progress);
vec3 red_tint = vec3(0.0, -0.1, -0.1).rgb;
damage_tex.rgb = mix(
damage_tex.rgb,
damage_tex.rgb + red_tint,
smoothstep(0.5, 1.0, dmg_progress));
c.rgb = damage_tex.rgb;
}
void fragment() {
progress_dmg_taken(COLOR.rgb, UV);
}
使い方
heroSprite.material.set("shader_parameter/dmg_progress", 1.0 - hitpoints / max_hitpoints);
キャラクターがダメージを受けた際のエフェクトを表現しています。主に血のテクスチャとダメージの進行度に応じて色を変化させることで、リアルなダメージ効果を実現します。以下に、このシェーダーの主要な部分とその動作を解説します。
シェーダーの定義とユニフォーム変数
shader_type canvas_item;
group_uniforms progress_dmg_taken;
uniform sampler2D blood_n: repeat_enable;
uniform sampler2D blood_grad: repeat_enable;
uniform sampler2D blood_curve;
uniform float dmg_progress: hint_range(0.0, 1.0) = 0.0;
group_uniforms;
-
shader_type canvas_item;
: このシェーダーがキャンバスアイテム(2D 描画用)であることを指定します。 -
uniform sampler2D blood_n: repeat_enable;
: ノイズテクスチャ。repeat_enable
はテクスチャの繰り返しを有効にします。 -
uniform sampler2D blood_grad: repeat_enable;
: 血のグラデーションテクスチャ。 -
uniform sampler2D blood_curve;
: ダメージ進行度に応じたカーブテクスチャ。 -
uniform float dmg_progress: hint_range(0.0, 1.0) = 0.0;
: ダメージの進行度(0.0から1.0の範囲)。
progress_dmg_taken
関数
この関数は、ダメージエフェクトを進行させるためのロジックを含んでいます。
void progress_dmg_taken(inout vec3 c, vec2 uv) {
vec3 blood_tex = texture(blood_grad, uv).rgb;
float curved_dmg_progress = texture(
blood_curve,
vec2(dmg_progress, 1.0)).r;
float noise_r = texture(blood_n, uv).r;
vec3 damage_tex = mix(blood_tex, c.rgb, noise_r);
damage_tex.rgb = mix(c.rgb, damage_tex.rgb, curved_dmg_progress);
vec3 red_tint = vec3(0.0, -0.1, -0.1).rgb;
damage_tex.rgb = mix(
damage_tex.rgb,
damage_tex.rgb + red_tint,
smoothstep(0.5, 1.0, dmg_progress));
c.rgb = damage_tex.rgb;
}
-
血のテクスチャの取得
vec3 blood_tex = texture(blood_grad, uv).rgb;
-
blood_tex
: 血のグラデーションテクスチャをUV座標でサンプリングし、色を取得します。
-
-
ダメージ進行度の取得
float curved_dmg_progress = texture( blood_curve, vec2(dmg_progress, 1.0)).r;
-
curved_dmg_progress
: ダメージ進行度をカーブテクスチャでサンプリングし、進行度に応じた値を取得します。
-
-
ノイズテクスチャの取得
float noise_r = texture(blood_n, uv).r;
-
noise_r
: ノイズテクスチャをUV座標でサンプリングし、赤チャネルの値を取得します。
-
-
血と元の色の混合
vec3 damage_tex = mix(blood_tex, c.rgb, noise_r); damage_tex.rgb = mix(c.rgb, damage_tex.rgb, curved_dmg_progress);
-
damage_tex
: 血のテクスチャと元の色をノイズ値に基づいて混合し、さらにダメージ進行度に基づいて再度混合します。
-
-
赤い色味の追加
vec3 red_tint = vec3(0.0, -0.1, -0.1).rgb; damage_tex.rgb = mix( damage_tex.rgb, damage_tex.rgb + red_tint, smoothstep(0.5, 1.0, dmg_progress));
-
red_tint
: 赤い色味を少し追加するためのベクトル。 -
damage_tex.rgb
: ダメージの進行度に応じて赤い色味を追加します。
-
-
最終色の設定
c.rgb = damage_tex.rgb;
- 最終的な色を設定します。
fragment
関数
この関数は、各フラグメント(ピクセル)の色を計算します。
void fragment() {
progress_dmg_taken(COLOR.rgb, UV);
}
-
progress_dmg_taken(COLOR.rgb, UV);
:progress_dmg_taken
関数を呼び出して、フラグメントの色を更新します。
まとめ
このシェーダーは、ダメージを受けた際のビジュアルエフェクトを実装しています。ノイズテクスチャと血のグラデーションテクスチャを使用して、ダメージの進行度に応じた色の変化を表現しています。ダメージが進行するにつれて、赤い色味が強調され、よりリアルなダメージ表現が可能となります。
呼吸とフラッシュ、ダメージ
characters.gdshader
void breath(inout vec2 v, float f) {
float m = 0.02;
float breath_anim = sin(TIME * f) * m;
v *= 1.0 + breath_anim;
}
group_uniforms flash;
uniform float flash: hint_range(0.0, 0.8);
uniform vec3 flash_color: source_color;
group_uniforms;
void flash_on_hit(inout vec3 c) {
c.rgb = mix(c.rgb, flash_color.rgb, flash);
}
zombie.gdshader
shader_type canvas_item;
#include "res://shaders/debug_utils.gdshaderinc"
#include "res://shaders/characters.gdshaderinc"
#define BREATHING_PACE 4.0
group_uniforms zebra_zombie_tex;
uniform sampler2D second_texture;
group_uniforms;
group_uniforms taking_damage;
uniform sampler2D burn_gradient;
uniform sampler2D noise;
uniform float hp_left: hint_range(0, 1);
group_uniforms;
void apply_zebra_zombie_tex(inout vec3 c, vec2 uv, sampler2D tex) {
vec4 original_tex = texture(tex, uv);
vec4 second_tex = texture(second_texture, uv);
original_tex.rgb *= vec3(0.7, 0.8, 0.7);
vec3 mixed_zombie_zebra = mix(original_tex.rgb, second_tex.rgb, 0.15);
c.rgb = mixed_zombie_zebra.rgb;
}
void zombie_taking_damage_fragment(inout vec4 c, vec2 uv) {
float n_r = texture(noise, uv).r;
float ty = uv.y + mix(-2.0, 1.0, hp_left) + n_r;
vec4 grad_tex = texture(burn_gradient, vec2(uv.x, ty)).rgba;
float mix_ratio = clamp(ty, 0, 1);
c = vec4(mix(grad_tex.rgb, c.rgb, mix_ratio), min(grad_tex.a, c.a));
}
void zombie_taking_damage_vertex(inout vec2 v) {
float scale = 1.0 + smoothstep(0.6, 1.0, 1.0 - hp_left);
v *= scale;
}
void vertex() {
breath(VERTEX, BREATHING_PACE);
zombie_taking_damage_vertex(VERTEX);
}
void fragment() {
apply_zebra_zombie_tex(COLOR.rgb, UV, TEXTURE);
flash_on_hit(COLOR.rgb);
zombie_taking_damage_fragment(COLOR, UV);
#ifdef DEBUG_MODE_ON
debug_draw_dots_at_vertices(UV, COLOR, TEXTURE);
#endif
}
使い方
func set_hp_left_shader(val: float):
hp_left = val;
zombieSprite.material.set("shader_parameter/hp_left", val);
func damage_effect():
var amount_of_damage = (hitpoints / max_hitpoints) + 0.3;
var tween_amount = amount_of_damage if(hitpoints > 0.0) else 0.0
var tween_anim_time = 0.3 if(hitpoints > 0.0) else 0.4
var tween = get_tree().create_tween()
tween.tween_method(set_hp_left_shader, hp_left, tween_amount, tween_anim_time)
敵キャラクターが呼吸するアニメーションやダメージを受けた際のフラッシュ効果をシェーダーで実装しています。以下に、主要な部分と動作の流れを解説します。
characters.gdshader
breath
関数
この関数は、頂点を呼吸のように膨張・収縮させるアニメーションを実装しています。
void breath(inout vec2 v, float f) {
float m = 0.02;
float breath_anim = sin(TIME * f) * m;
v *= 1.0 + breath_anim;
}
-
v
: 頂点の位置を表すベクトル。 -
f
: 呼吸のペースを制御する周波数。 -
m
: 呼吸の強さを制御する係数。 -
breath_anim
: サイン波で呼吸のアニメーションを計算。 - 頂点の位置
v
を膨張・収縮させる。
flash_on_hit
関数
この関数は、フラッシュ効果を実装しています。
group_uniforms flash;
uniform float flash: hint_range(0.0, 0.8);
uniform vec3 flash_color: source_color;
group_uniforms;
void flash_on_hit(inout vec3 c) {
c.rgb = mix(c.rgb, flash_color.rgb, flash);
}
-
flash
: フラッシュの強さを制御するための浮動小数点数(0.0から0.8の範囲)。 -
flash_color
: フラッシュの色。 -
c
: カラーを表すベクトル。元の色とフラッシュ色を混ぜることでフラッシュ効果を実現。
zombie.gdshader
このシェーダーは、呼吸アニメーションやダメージを受けた際のエフェクトを実装しています。
shader_type canvas_item;
#include "res://shaders/debug_utils.gdshaderinc"
#include "res://shaders/characters.gdshaderinc"
#define BREATHING_PACE 4.0
group_uniforms zebra_zombie_tex;
uniform sampler2D second_texture;
group_uniforms;
group_uniforms taking_damage;
uniform sampler2D burn_gradient;
uniform sampler2D noise;
uniform float hp_left: hint_range(0, 1);
group_uniforms;
-
BREATHING_PACE
: 呼吸のペースを定義する定数。 -
second_texture
: セカンドテクスチャ。 -
burn_gradient
: ダメージを受けた際のグラデーションテクスチャ。 -
noise
: ノイズテクスチャ。 -
hp_left
: 残りの体力を0から1の範囲で示す。
apply_zebra_zombie_tex
関数
この関数は、元のテクスチャとセカンドテクスチャを混ぜて表示します。
void apply_zebra_zombie_tex(inout vec3 c, vec2 uv, sampler2D tex) {
vec4 original_tex = texture(tex, uv);
vec4 second_tex = texture(second_texture, uv);
original_tex.rgb *= vec3(0.7, 0.8, 0.7);
vec3 mixed_zombie_zebra = mix(original_tex.rgb, second_tex.rgb, 0.15);
c.rgb = mixed_zombie_zebra.rgb;
}
-
original_tex
: 元のテクスチャを取得。 -
second_tex
: セカンドテクスチャを取得。 -
original_tex.rgb
を調整し、second_tex
と混ぜる。
zombie_taking_damage_fragment
関数
この関数は、ダメージを受けた際のエフェクトをフラグメントシェーダーで実装します。
void zombie_taking_damage_fragment(inout vec4 c, vec2 uv) {
float n_r = texture(noise, uv).r;
float ty = uv.y + mix(-2.0, 1.0, hp_left) + n_r;
vec4 grad_tex = texture(burn_gradient, vec2(uv.x, ty)).rgba;
float mix_ratio = clamp(ty, 0, 1);
c = vec4(mix(grad_tex.rgb, c.rgb, mix_ratio), min(grad_tex.a, c.a));
}
-
n_r
: ノイズテクスチャの赤チャネルの値。 -
ty
: ノイズと体力に基づいて計算されたY座標。 -
grad_tex
: グラデーションテクスチャ。 -
mix_ratio
: グラデーションと元の色を混ぜる比率。
zombie_taking_damage_vertex
関数
この関数は、ダメージを受けた際の頂点のスケールを調整します。
void zombie_taking_damage_vertex(inout vec2 v) {
float scale = 1.0 + smoothstep(0.6, 1.0, 1.0 - hp_left);
v *= scale;
}
-
scale
: 体力に基づいて計算されたスケール。 - 頂点の位置
v
をスケールします。
vertex
関数
頂点シェーダーのメイン関数です。
void vertex() {
breath(VERTEX, BREATHING_PACE);
zombie_taking_damage_vertex(VERTEX);
}
-
breath
関数を呼び出して呼吸アニメーションを適用。 -
zombie_taking_damage_vertex
関数を呼び出してダメージエフェクトを適用。
fragment
関数
フラグメントシェーダーのメイン関数です。
void fragment() {
apply_zebra_zombie_tex(COLOR.rgb, UV, TEXTURE);
flash_on_hit(COLOR.rgb);
zombie_taking_damage_fragment(COLOR, UV);
#ifdef DEBUG_MODE_ON
debug_draw_dots_at_vertices(UV, COLOR, TEXTURE);
#endif
}
-
apply_zebra_zombie_tex
関数を呼び出してテクスチャを適用。 -
flash_on_hit
関数を呼び出してフラッシュ効果を適用。 -
zombie_taking_damage_fragment
関数を呼び出してダメージエフェクトを適用。
このシェーダーにより、敵キャラクターは呼吸アニメーションを行い、ダメージを受けた際にはフラッシュし、視覚的にダメージを受けていることを示します。
ナビゲーション
NavigationAgent
npc.gd
extends CharacterBody2D
const SPEED: float = 160.0
@export var patrol_points: NodePath
@onready var sprite_2d: Sprite2D = $Sprite2D
@onready var nav_agent: NavigationAgent2D = $NavAgent
@onready var label: Label = $Label
var _waypoints: Array = []
var _current_wp: int = 0
func _ready() -> void:
set_physics_process(false)
create_wp()
call_deferred("set_physics_process", true)
func set_temp_target():
nav_agent.target_position = Vector2(3104, 2064)
func create_wp():
for c in get_node(patrol_points).get_children():
_waypoints.append(c.global_position)
func _physics_process(delta: float) -> void:
if Input.is_action_just_pressed("set_target"):
nav_agent.target_position = get_global_mouse_position()
update_navigation()
process_patrolling()
set_label()
func update_navigation():
if !nav_agent.is_navigation_finished():
var next_path_position: Vector2 = nav_agent.get_next_path_position()
sprite_2d.look_at(next_path_position)
velocity = global_position.direction_to(next_path_position) * SPEED
move_and_slide()
func navigate_wp():
if _current_wp >= len(_waypoints):
_current_wp = 0
nav_agent.target_position = _waypoints[_current_wp]
_current_wp += 1
func process_patrolling():
if nav_agent.is_navigation_finished():
navigate_wp()
func set_label():
var s = "DONE:%s\n" % nav_agent.is_navigation_finished()
s += "REACH:%s\n" % nav_agent.is_target_reachable()
s += "REACHED:%s\n" % nav_agent.is_target_reached()
s += "TARGET:%s\n" % nav_agent.target_position
label.text = s
プレイヤー検知
npc.gd
extends CharacterBody2D
const SPEED: float = 160.0
@export var patrol_points: NodePath
@onready var sprite_2d: Sprite2D = $Sprite2D
@onready var nav_agent: NavigationAgent2D = $NavAgent
@onready var label: Label = $Label
@onready var player_detect: Node2D = $PlayerDetect
@onready var ray_cast_2d: RayCast2D = $PlayerDetect/RayCast2D
var _waypoints: Array = []
var _current_wp: int = 0
var _player_ref: Player
func _ready() -> void:
set_physics_process(false)
create_wp()
_player_ref = get_tree().get_first_node_in_group("player")
call_deferred("set_physics_process", true)
func set_temp_target():
nav_agent.target_position = Vector2(3104, 2064)
func create_wp():
for c in get_node(patrol_points).get_children():
_waypoints.append(c.global_position)
func _physics_process(delta: float) -> void:
if Input.is_action_just_pressed("set_target"):
nav_agent.target_position = get_global_mouse_position()
raycast_to_player()
update_navigation()
process_patrolling()
set_label()
func get_fov_angle():
var direction = global_position.direction_to(_player_ref.global_position)
var dot_p = direction.dot(velocity.normalized())
if dot_p >= -1.0 and dot_p <= 1.0:
return rad_to_deg(acos(dot_p))
return 0.0
func player_in_fov():
return get_fov_angle() < 60.0
func raycast_to_player():
player_detect.look_at(_player_ref.global_position)
func player_detected():
var c = ray_cast_2d.get_collider()
if c != null:
return c.is_in_group("player")
return false
func update_navigation():
if !nav_agent.is_navigation_finished():
var next_path_position: Vector2 = nav_agent.get_next_path_position()
sprite_2d.look_at(next_path_position)
velocity = global_position.direction_to(next_path_position) * SPEED
move_and_slide()
func navigate_wp():
if _current_wp >= len(_waypoints):
_current_wp = 0
nav_agent.target_position = _waypoints[_current_wp]
_current_wp += 1
func process_patrolling():
if nav_agent.is_navigation_finished():
navigate_wp()
func set_label():
var s = "Done:%s\n" % nav_agent.is_navigation_finished()
s += "Reach:%s\n" % nav_agent.is_target_reachable()
s += "Reached:%s\n" % nav_agent.is_target_reached()
s += "Target:%s\n" % nav_agent.target_position
s += "PlayerDetected:%s\n" % player_in_fov()
s += "Is In FOV:%s\n" % player_detected()
s += "FVO:%.f\n" % get_fov_angle()
label.text = s
パトロール、追跡、サーチ
npc.gd
extends CharacterBody2D
const FOV = {
ENEMY_STATE.PATROLLING: 60.0,
ENEMY_STATE.CHASING: 120.0,
ENEMY_STATE.SEARCHING: 100.0,
}
const SPEED = {
ENEMY_STATE.PATROLLING: 60.0,
ENEMY_STATE.CHASING: 100.0,
ENEMY_STATE.SEARCHING: 80.0,
}
enum ENEMY_STATE {
PATROLLING,
CHASING,
SEARCHING
}
@export var patrol_points: NodePath
@onready var warning: Sprite2D = $Warning
@onready var sprite_2d: Sprite2D = $Sprite2D
@onready var nav_agent: NavigationAgent2D = $NavAgent
@onready var label: Label = $Label
@onready var player_detect: Node2D = $PlayerDetect
@onready var ray_cast_2d: RayCast2D = $PlayerDetect/RayCast2D
@onready var gasp_sound: AudioStreamPlayer2D = $GaspSound
@onready var animation_player: AnimationPlayer = $AnimationPlayer
var _waypoints: Array = []
var _current_wp: int = 0
var _player_ref: Player
var _state: ENEMY_STATE = ENEMY_STATE.PATROLLING
func _ready() -> void:
set_physics_process(false)
create_wp()
_player_ref = get_tree().get_first_node_in_group("player")
call_deferred("set_physics_process", true)
func set_temp_target():
nav_agent.target_position = Vector2(3104, 2064)
func create_wp():
for c in get_node(patrol_points).get_children():
_waypoints.append(c.global_position)
func _physics_process(delta: float) -> void:
if Input.is_action_just_pressed("set_target"):
nav_agent.target_position = get_global_mouse_position()
raycast_to_player()
update_state()
update_movement()
update_navigation()
set_label()
func get_fov_angle():
var direction = global_position.direction_to(_player_ref.global_position)
var dot_p = direction.dot(velocity.normalized())
if dot_p >= -1.0 and dot_p <= 1.0:
return rad_to_deg(acos(dot_p))
return 0.0
func player_in_fov():
return get_fov_angle() < FOV[_state]
func raycast_to_player():
player_detect.look_at(_player_ref.global_position)
func player_detected():
var c = ray_cast_2d.get_collider()
if c != null:
return c.is_in_group("player")
return false
func can_see_player():
return player_in_fov() and player_detected()
func update_navigation():
if !nav_agent.is_navigation_finished():
var next_path_position: Vector2 = nav_agent.get_next_path_position()
sprite_2d.look_at(next_path_position)
velocity = global_position.direction_to(next_path_position) * SPEED[_state]
move_and_slide()
func navigate_wp():
if _current_wp >= len(_waypoints):
_current_wp = 0
nav_agent.target_position = _waypoints[_current_wp]
_current_wp += 1
func set_nav_to_player():
nav_agent.target_position = _player_ref.global_position
func process_patrolling():
if nav_agent.is_navigation_finished():
navigate_wp()
func process_chasing():
set_nav_to_player()
func process_searching():
if nav_agent.is_navigation_finished():
set_state(ENEMY_STATE.PATROLLING)
func update_movement():
match _state:
ENEMY_STATE.PATROLLING:
process_patrolling()
ENEMY_STATE.SEARCHING:
process_searching()
ENEMY_STATE.CHASING:
process_chasing()
func set_state(new_state: ENEMY_STATE):
if new_state == _state:
return
if _state == ENEMY_STATE.SEARCHING:
warning.hide()
if new_state == ENEMY_STATE.SEARCHING:
warning.show()
elif new_state == ENEMY_STATE.CHASING:
gasp_sound.play()
animation_player.play("alert")
elif new_state == ENEMY_STATE.PATROLLING:
animation_player.play("RESET")
_state = new_state
func update_state():
var new_state = _state
var can_see = can_see_player()
if can_see:
new_state = ENEMY_STATE.CHASING
elif !can_see and new_state == ENEMY_STATE.CHASING:
new_state = ENEMY_STATE.SEARCHING
set_state(new_state)
func set_label():
var s = "Done:%s\n" % nav_agent.is_navigation_finished()
s += "Reached:%s\n" % nav_agent.is_target_reached()
s += "Target:%s\n" % nav_agent.target_position
s += "PlayerDetected:%s\n" % player_detected()
s += "FOV:%.2f %s\n" % [get_fov_angle(), ENEMY_STATE.keys()[_state]]
s += "Speed:%s %s\n" % [player_in_fov(), SPEED[_state]]
label.text = s
敵キャラクターがプレイヤーを追跡、サーチ、パトロールする動作を実装しています。以下に、各部分の解説を行います。
定数と状態の定義
const FOV = {
ENEMY_STATE.PATROLLING: 60.0,
ENEMY_STATE.CHASING: 120.0,
ENEMY_STATE.SEARCHING: 100.0,
}
const SPEED = {
ENEMY_STATE.PATROLLING: 60.0,
ENEMY_STATE.CHASING: 100.0,
ENEMY_STATE.SEARCHING: 80.0,
}
enum ENEMY_STATE {
PATROLLING,
CHASING,
SEARCHING
}
-
FOV
: 各状態における視野角 (Field of View) を定義します。 -
SPEED
: 各状態における移動速度を定義します。 -
ENEMY_STATE
: 敵の状態を列挙型で定義します(パトロール、追跡、サーチ)。
ノードと変数の初期化
@export var patrol_points: NodePath
@onready var warning: Sprite2D = $Warning
@onready var sprite_2d: Sprite2D = $Sprite2D
@onready var nav_agent: NavigationAgent2D = $NavAgent
@onready var label: Label = $Label
@onready var player_detect: Node2D = $PlayerDetect
@onready var ray_cast_2d: RayCast2D = $PlayerDetect/RayCast2D
@onready var gasp_sound: AudioStreamPlayer2D = $GaspSound
@onready var animation_player: AnimationPlayer = $AnimationPlayer
var _waypoints: Array = []
var _current_wp: int = 0
var _player_ref: Player
var _state: ENEMY_STATE = ENEMY_STATE.PATROLLING
- 各ノードを初期化します(パトロールポイント、警告スプライト、ナビゲーションエージェント、ラベル、プレイヤー検出ノード、レイキャスト、音声プレイヤー、アニメーションプレイヤー)。
- パトロール用のウェイポイントリストと現在のウェイポイントインデックスを初期化します。
- プレイヤーの参照と現在の敵の状態を初期化します。
_ready
関数
func _ready() -> void:
set_physics_process(false)
create_wp()
_player_ref = get_tree().get_first_node_in_group("player")
call_deferred("set_physics_process", true)
- 初期化時に物理プロセスを一時停止し、ウェイポイントを作成します。
- プレイヤーの参照を取得し、物理プロセスを再開します。
ウェイポイントの作成
func create_wp():
for c in get_node(patrol_points).get_children():
_waypoints.append(c.global_position)
- パトロールポイントの子ノードを取得し、それらのグローバル位置をウェイポイントリストに追加します。
_physics_process
関数
func _physics_process(delta: float) -> void:
if Input.is_action_just_pressed("set_target"):
nav_agent.target_position = get_global_mouse_position()
raycast_to_player()
update_state()
update_movement()
update_navigation()
set_label()
- マウスクリックでターゲット位置を設定できるデバッグ機能。
- プレイヤーに対するレイキャストを行い、状態を更新し、移動とナビゲーションを更新し、ラベルを設定します。
フィールドオブビュー (FOV) の計算
func get_fov_angle():
var direction = global_position.direction_to(_player_ref.global_position)
var dot_p = direction.dot(velocity.normalized())
if dot_p >= -1.0 and dot_p <= 1.0:
return rad_to_deg(acos(dot_p))
return 0.0
- プレイヤーへの方向を計算し、その方向と敵の速度ベクトルの間の角度を計算して視野角を取得します。
プレイヤー検出のためのレイキャスト
func raycast_to_player():
player_detect.look_at(_player_ref.global_position)
func player_detected():
var c = ray_cast_2d.get_collider()
if c != null:
return c.is_in_group("player")
return false
func can_see_player():
return player_in_fov() and player_detected()
- プレイヤーの位置にレイキャストを向け、プレイヤーが検出されているかを確認します。
- 視野内にプレイヤーがいるかと、プレイヤーがレイキャストで検出されているかを確認します。
ナビゲーションの更新
func update_navigation():
if !nav_agent.is_navigation_finished():
var next_path_position: Vector2 = nav_agent.get_next_path_position()
sprite_2d.look_at(next_path_position)
velocity = global_position.direction_to(next_path_position) * SPEED[_state]
move_and_slide()
- ナビゲーションが終了していない場合、次のパス位置に向けて移動します。
ウェイポイントのナビゲーション
func navigate_wp():
if _current_wp >= len(_waypoints):
_current_wp = 0
nav_agent.target_position = _waypoints[_current_wp]
_current_wp += 1
- 現在のウェイポイントインデックスを更新し、次のウェイポイントにナビゲーションを設定します。
プレイヤーへのナビゲーション
func set_nav_to_player():
nav_agent.target_position = _player_ref.global_position
- プレイヤーの位置にナビゲーションターゲットを設定します。
各状態の処理
func process_patrolling():
if nav_agent.is_navigation_finished():
navigate_wp()
func process_chasing():
set_nav_to_player()
func process_searching():
if nav_agent.is_navigation_finished():
set_state(ENEMY_STATE.PATROLLING)
- パトロール中はナビゲーションが終了したら次のウェイポイントに移動します。
- 追跡中はプレイヤーにナビゲーションターゲットを設定します。
- サーチ中はナビゲーションが終了したらパトロール状態に戻ります。
状態の更新とラベルの設定
func update_movement():
match _state:
ENEMY_STATE.PATROLLING:
process_patrolling()
ENEMY_STATE.SEARCHING:
process_searching()
ENEMY_STATE.CHASING:
process_chasing()
func set_state(new_state: ENEMY_STATE):
if new_state == _state:
return
if _state == ENEMY_STATE.SEARCHING:
warning.hide()
if new_state == ENEMY_STATE.SEARCHING:
warning.show()
elif new_state == ENEMY_STATE.CHASING:
gasp_sound.play()
animation_player.play("alert")
elif new_state == ENEMY_STATE.PATROLLING:
animation_player.play("RESET")
_state = new_state
func update_state():
var new_state = _state
var can_see = can_see_player()
if can_see:
new_state = ENEMY_STATE.CHASING
elif !can_see and new_state == ENEMY_STATE.CHASING:
new_state = ENEMY_STATE.SEARCHING
set_state(new_state)
func set_label():
var s = "Done:%s\n" % nav_agent.is_navigation_finished()
s += "Reached:%s\n" % nav_agent.is_target_reached()
s += "Target:%s\n" % nav_agent.target_position
s += "PlayerDetected:%s\n" % player_detected()
s += "FOV:%.2f %s\n" % [get_fov_angle(), ENEMY_STATE.keys()[_state]]
s += "Speed:%s %s\n" % [player_in_fov(), SPEED[_state]]
label.text = s
-
update_movement()
: 現在の状態に応じた処理を呼び出します。 -
set_state(new_state)
: 状態を変更し、それに応じたエフェクト(警告表示、音声再生、アニメーション再生)を行います。 -
update_state()
: プレイヤーが視野内にいるかどうかに基づいて状態を更新します。 -
set_label()
: デバッグ用のラベルを更新します。
まとめ
このコードにより、敵キャラクターはプレイヤーを追跡、サーチ、パトロールする動作を実装しています。敵はプレイヤーを視野内で検出し、適切な状態に応じた動作を行います。
アイテムピックアップ
アイテムをすべてピックアップしたら、ゴールが表示される
exit.gd
extends Area2D
func _ready() -> void:
hide()
SignalManager.on_show_exit.connect(on_show_exit)
func on_show_exit():
set_deferred("monitoring", true)
show()
func _on_body_entered(body: Node2D) -> void:
SignalManager.on_exit.emit()
pick_up.gd
extends Area2D
@onready var sound: AudioStreamPlayer2D = $Sound
@onready var animation_player: AnimationPlayer = $AnimationPlayer
func play_sound():
sound.stream = SoundManager.get_random_pickup_sound()
sound.play()
func _on_body_entered(body: Node2D) -> void:
set_deferred("monitoring", false)
SignalManager.on_pickup.emit()
animation_player.play("vanish")
play_sound()
func _on_sound_finished() -> void:
queue_free()
signal_manager.gd
extends Node
signal on_pickup
signal on_show_exit
signal on_exit
``
level_map.gd
```gdscript
extends Node2D
@onready var pick_ups: Node = $PickUps
var _pickups_count: int = 0
var _collected: int = 0
func _ready() -> void:
_pickups_count = pick_ups.get_children().size()
SignalManager.on_pickup.connect(on_pickup)
SignalManager.on_exit.connect(on_exit)
func on_exit():
print("Game Over")
func check_show_exit():
if _collected == _pickups_count:
SignalManager.on_show_exit.emit()
print("check_show_exit")
func on_pickup():
print("on_pickup")
_collected += 1
check_show_exit()
このコードは、Godotエンジンを使って、プレイヤーがすべてのアイテムをピックアップした後にゴールを表示する仕組みを実装しています。各スクリプトの役割と動作を解説します。
exit.gd
このスクリプトはゴール(出口)を管理します。
extends Area2D
func _ready() -> void:
hide()
SignalManager.on_show_exit.connect(on_show_exit)
func on_show_exit():
set_deferred("monitoring", true)
show()
func _on_body_entered(body: Node2D) -> void:
SignalManager.on_exit.emit()
-
hide()
: 初期状態でゴールを非表示にします。 -
SignalManager.on_show_exit.connect(on_show_exit)
:on_show_exit
シグナルに接続して、ゴールを表示する準備をします。 -
on_show_exit()
: シグナルが発信されたときにゴールを表示し、監視を開始します。 -
_on_body_entered(body: Node2D)
: プレイヤーがゴールに到達したときにon_exit
シグナルを発信します。
pick_up.gd
このスクリプトはアイテムのピックアップを管理します。
extends Area2D
@onready var sound: AudioStreamPlayer2D = $Sound
@onready var animation_player: AnimationPlayer = $AnimationPlayer
func play_sound():
sound.stream = SoundManager.get_random_pickup_sound()
sound.play()
func _on_body_entered(body: Node2D) -> void:
set_deferred("monitoring", false)
SignalManager.on_pickup.emit()
animation_player.play("vanish")
play_sound()
func _on_sound_finished() -> void:
queue_free()
-
play_sound()
: アイテムをピックアップしたときの音を再生します。 -
_on_body_entered(body: Node2D)
: プレイヤーがアイテムをピックアップしたときに、アイテムの監視を停止し、on_pickup
シグナルを発信し、消失アニメーションを再生し、音を再生します。 -
_on_sound_finished()
: サウンドの再生が終了したら、アイテムをシーンから削除します。
signal_manager.gd
このスクリプトはシグナルを定義します。
extends Node
signal on_pickup
signal on_show_exit
signal on_exit
-
signal on_pickup
: アイテムがピックアップされたときに発信されるシグナル。 -
signal on_show_exit
: すべてのアイテムがピックアップされたときにゴールを表示するためのシグナル。 -
signal on_exit
: プレイヤーがゴールに到達したときに発信されるシグナル。
level_map.gd
このスクリプトはレベル全体を管理し、アイテムのピックアップ状態を監視します。
extends Node2D
@onready var pick_ups: Node = $PickUps
var _pickups_count: int = 0
var _collected: int = 0
func _ready() -> void:
_pickups_count = pick_ups.get_children().size()
SignalManager.on_pickup.connect(on_pickup)
SignalManager.on_exit.connect(on_exit)
func on_exit():
print("Game Over")
func check_show_exit():
if _collected == _pickups_count:
SignalManager.on_show_exit.emit()
print("check_show_exit")
func on_pickup():
print("on_pickup")
_collected += 1
check_show_exit()
-
_ready()
: レベルが準備されたときにアイテムの数を数え、シグナルに接続します。 -
on_exit()
: プレイヤーがゴールに到達したときに「Game Over」と出力します。 -
check_show_exit()
: すべてのアイテムがピックアップされたかをチェックし、そうであればon_show_exit
シグナルを発信します。 -
on_pickup()
: アイテムがピックアップされたときに_collected
を増加させ、check_show_exit()
を呼び出します。
まとめ
- プレイヤーがアイテムをピックアップすると
pick_up.gd
でon_pickup
シグナルが発信されます。 -
level_map.gd
でこのシグナルを受け取り、ピックアップされたアイテムの数をカウントします。 - すべてのアイテムがピックアップされたら、
on_show_exit
シグナルが発信され、exit.gd
のゴールが表示されます。 - プレイヤーがゴールに到達すると、
on_exit
シグナルが発信され、「Game Over」と表示されます。
この仕組みにより、プレイヤーがすべてのアイテムをピックアップした後にゴールが表示されるようになります。
シューティング
敵がプレイヤーを追跡中に発射する
bullet.gd
extends Area2D
var BOOM: PackedScene = preload("res://scenes/boom/boom.tscn")
const SPEED: float = 250.0
var _dir_of_travel: Vector2 = Vector2.ZERO
var _target_position: Vector2 = Vector2.ZERO
func _ready() -> void:
look_at(_target_position)
func _process(delta: float) -> void:
position += SPEED * delta * _dir_of_travel
func init(target: Vector2, start_pos: Vector2):
_target_position = target
_dir_of_travel = start_pos.direction_to(target)
global_position = start_pos
func create_boom():
var b = BOOM.instantiate()
b.global_position = global_position
get_tree().root.add_child(b)
queue_free()
func _on_timer_timeout() -> void:
create_boom()
func _on_body_entered(body: Node2D) -> void:
create_boom()
boom.gd
extends AnimatedSprite2D
func _on_audio_stream_player_2d_finished() -> void:
queue_free()
npc.gd
const BULET: PackedScene = preload("res://scenes/bullet/bullet.tscn")
func shoot():
var target = _player_ref.global_position
var b = BULET.instantiate()
b.init(target, global_position)
get_tree().root.add_child(b)
SoundManager.play_laser(gasp_sound)
func _on_shoot_timer_timeout() -> void:
if _state != ENEMY_STATE.CHASING:
return
shoot()
敵キャラクターがプレイヤーを追跡中に弾を発射する仕組みを実装しています。以下に各スクリプトの役割と動作を解説します。
bullet.gd
このスクリプトは弾(バレット)を管理します。
extends Area2D
var BOOM: PackedScene = preload("res://scenes/boom/boom.tscn")
const SPEED: float = 250.0
var _dir_of_travel: Vector2 = Vector2.ZERO
var _target_position: Vector2 = Vector2.ZERO
func _ready() -> void:
look_at(_target_position)
func _process(delta: float) -> void:
position += SPEED * delta * _dir_of_travel
func init(target: Vector2, start_pos: Vector2):
_target_position = target
_dir_of_travel = start_pos.direction_to(target)
global_position = start_pos
func create_boom():
var b = BOOM.instantiate()
b.global_position = global_position
get_tree().root.add_child(b)
queue_free()
func _on_timer_timeout() -> void:
create_boom()
func _on_body_entered(body: Node2D) -> void:
create_boom()
-
BOOM
: 弾が爆発したときに表示するエフェクトのシーンをプリロードします。 -
SPEED
: 弾の移動速度を設定します。 -
_dir_of_travel
: 弾が移動する方向をベクトルで保持します。 -
_target_position
: 弾が向かうターゲットの位置を保持します。
関数の説明
-
_ready()
: 弾の初期化時にターゲットの方向を向くように設定します。 -
_process(delta: float)
: フレームごとに弾を移動させます。 -
init(target: Vector2, start_pos: Vector2)
: 弾のターゲット位置と開始位置を設定し、移動方向を計算します。 -
create_boom()
: 爆発エフェクトを作成し、弾をシーンから削除します。 -
_on_timer_timeout()
: タイマーがタイムアウトしたときに爆発を作成します。 -
_on_body_entered(body: Node2D)
: 弾が何かに当たったときに爆発を作成します。
boom.gd
このスクリプトは爆発エフェクトを管理します。
extends AnimatedSprite2D
func _on_audio_stream_player_2d_finished() -> void:
queue_free()
-
_on_audio_stream_player_2d_finished()
: 爆発のアニメーションとサウンドが終了したときにエフェクトをシーンから削除します。
npc.gd
このスクリプトは敵キャラクターが弾を発射する機能を管理します。
const BULET: PackedScene = preload("res://scenes/bullet/bullet.tscn")
func shoot():
var target = _player_ref.global_position
var b = BULET.instantiate()
b.init(target, global_position)
get_tree().root.add_child(b)
SoundManager.play_laser(gasp_sound)
func _on_shoot_timer_timeout() -> void:
if _state != ENEMY_STATE.CHASING:
return
shoot()
-
BULET
: 弾のシーンをプリロードします。
関数の説明
-
shoot()
:- プレイヤーの位置をターゲットとして取得します。
- 弾をインスタンス化し、初期化します。
- 弾をシーンツリーに追加します。
- サウンドを再生します。
-
_on_shoot_timer_timeout()
:- 敵がプレイヤーを追跡している状態(
ENEMY_STATE.CHASING
)であればshoot()
関数を呼び出して弾を発射します。
- 敵がプレイヤーを追跡している状態(
全体の流れ
-
敵キャラクターがプレイヤーを追跡中にタイマーがタイムアウト:
-
_on_shoot_timer_timeout()
が呼び出され、敵がENEMY_STATE.CHASING
状態であればshoot()
関数を実行します。
-
-
弾の発射:
-
shoot()
関数でプレイヤーの位置をターゲットとして弾をインスタンス化し、初期化します。 - 弾をシーンツリーに追加し、発射サウンドを再生します。
-
-
弾の移動と衝突:
-
bullet.gd
の_process(delta: float)
関数で弾がフレームごとに移動します。 - 弾が何かに当たった場合(
_on_body_entered
)やタイマーがタイムアウトした場合(_on_timer_timeout
)に爆発エフェクトを作成し、弾をシーンから削除します。
-
このようにして、敵キャラクターがプレイヤーを追跡中に弾を発射し、弾がターゲットに向かって進み、衝突した場合に爆発エフェクトが表示される仕組みが実現されています。
physics_processを遅らせる
physics_processでエラーが発生する場合
await get_tree().physics_frame
で遅らせる
func _ready() -> void:
set_physics_process(false)
create_wp()
_player_ref = get_tree().get_first_node_in_group("player")
#call_deferred("set_physics_process", true)
call_deferred("late_setup")
func late_setup():
await get_tree().physics_frame
await get_tree().create_timer(0.3).timeout
call_deferred("set_physics_process", true)
チュートリアル作成開始
新規にゲームを作るときに参考になるように、なるべく分かりやすいコードになるように作成する
- 2Dプラットフォーマー
- プレイヤー移動
- WSAD
- ダブルジャンプ
- 敵移動
- 当たり判定
- スコア
- HP
- レベルデザイン
- UI
- タイルマップ
- ダメージシェーディング
- プレイヤー移動
- シューティング
- パララックスバックグラウンド
- 弾
- プレイヤー
- パスで移動する敵
- HP
- 当たり判定
- 回復アイテム
プロセスモード
Node
のProcessMode
をPROCESS_MODE_ALWAYS
に設定すると、そのノードは常に処理を行います。具体的には、SceneTree
のpaused
プロパティの値に関係なく、毎フレームごとにノードの_process
、_physics_process
、および関連する処理関数が呼び出されます。
ProcessMode
のオプション
-
PROCESS_MODE_INHERIT
: 親ノードのprocess_mode
を継承します。ルートノードの場合はPROCESS_MODE_PAUSABLE
と同じになります。これは、新しく作成されたノードのデフォルトです。 -
PROCESS_MODE_PAUSABLE
:SceneTree.paused
がtrue
のときに処理を停止します。これはPROCESS_MODE_WHEN_PAUSED
の逆です。 -
PROCESS_MODE_WHEN_PAUSED
:SceneTree.paused
がtrue
のときにのみ処理を行います。これはPROCESS_MODE_PAUSABLE
の逆です。 -
PROCESS_MODE_ALWAYS
: 常に処理を行います。SceneTree.paused
を無視して処理を続けます。これはPROCESS_MODE_DISABLED
の逆です。 -
PROCESS_MODE_DISABLED
: 処理を完全に無効にします。SceneTree.paused
を無視して処理を行いません。これはPROCESS_MODE_ALWAYS
の逆です。
PROCESS_MODE_ALWAYS
の使用例
extends Node2D
func _ready():
set_process(true)
set_process_mode(Node2D.PROCESS_MODE_ALWAYS)
func _process(delta):
print("Processing every frame, regardless of paused state")
動作の影響
PROCESS_MODE_ALWAYS
に設定されたノードは、以下のような状況でも処理を続けます。
-
ゲームが一時停止中でも処理を続ける:
- 通常、ゲームが一時停止されると、
PROCESS_MODE_PAUSABLE
に設定されたノードは処理を停止します。しかし、PROCESS_MODE_ALWAYS
に設定されたノードは、SceneTree.paused
がtrue
になっても処理を続けます。
- 通常、ゲームが一時停止されると、
-
常に呼び出される処理:
- この設定により、ゲームが一時停止されても必要なアニメーション、音楽、またはその他の持続的なアクションを維持することができます。
具体的な使用シナリオ
-
バックグラウンドミュージックやサウンドエフェクトの再生:
- ゲームが一時停止している間も、音楽や特定のサウンドエフェクトを継続して再生する場合に有用です。
-
非ゲーム要素の更新:
- ユーザーインターフェースやデバッグ情報の表示など、ゲームの進行とは独立して更新される必要がある要素に対して使用されます。
-
ネットワーク通信の管理:
- ゲームが一時停止している間も、サーバーとの通信を維持する必要がある場合に有効です。
このように、PROCESS_MODE_ALWAYS
を使用することで、ゲームが一時停止している間も特定のノードの処理を継続することができます。
マウスフィルター
MouseFilter
は、GodotのGUIコントロールでマウスイベントをどのように処理するかを指定するためのプロパティです。以下に各オプションの詳細と、どのような場合に使用するかを解説します。
MouseFilter
のオプション
-
MOUSE_FILTER_STOP
:-
説明: コントロールはマウス入力イベント(移動、クリック)を受け取り、
_gui_input()
メソッドを通じて処理します。また、mouse_entered
およびmouse_exited
シグナルも受け取ります。これらのイベントは自動的に処理済みとしてマークされ、他のコントロールには伝播しません。他のコントロールでのシグナル発火もブロックします。 -
使用例:
- ボタンやスライダーなどのインタラクティブなコントロール: ユーザーの操作を確実にキャッチし、他のUI要素への影響を防ぎたい場合に使用します。
- ポップアップメニューやダイアログ: クリックイベントが他のUI要素に伝播しないようにするために使用します。
-
説明: コントロールはマウス入力イベント(移動、クリック)を受け取り、
-
MOUSE_FILTER_PASS
:-
説明: コントロールはマウス入力イベントを受け取り、
_gui_input()
メソッドを通じて処理します。また、mouse_entered
およびmouse_exited
シグナルも受け取ります。このコントロールがイベントを処理しなかった場合、親コントロール(存在する場合)がイベントを受け取ります。親コントロールも処理しなかった場合、さらに上のコントロールに伝播します。最終的に、Node._shortcut_input()
にイベントが渡されます。 -
使用例:
- 重なったUI要素の中で特定の要素に優先的に処理させたい場合: 例えば、ツールチップやカスタムカーソルを表示するためのコントロール。
- 子コントロールが親コントロールと連動して動作する場合: 例えば、リスト項目の選択とスクロールバーの連動。
-
説明: コントロールはマウス入力イベントを受け取り、
-
MOUSE_FILTER_IGNORE
:-
説明: コントロールはマウス入力イベントを受け取らず、
_gui_input()
メソッドも呼び出されません。また、mouse_entered
およびmouse_exited
シグナルも受け取りません。この設定は他のコントロールがこれらのイベントを受け取ることをブロックしません。無視されたイベントは自動的には処理されません。 -
使用例:
- 装飾目的のコントロール: マウス入力を必要としない背景画像や装飾用のUI要素。
- 透過的なUI要素: マウス入力をブロックせずに、背後の要素にイベントを渡したい場合。
-
説明: コントロールはマウス入力イベントを受け取らず、
具体例
-
MOUSE_FILTER_STOP
の使用例:-
ボタン: ボタンをクリックした際に、他の背後にあるコントロールがそのクリックイベントを受け取らないようにする。
button.mouse_filter = Control.MOUSE_FILTER_STOP
-
ボタン: ボタンをクリックした際に、他の背後にあるコントロールがそのクリックイベントを受け取らないようにする。
-
MOUSE_FILTER_PASS
の使用例:-
カスタムツールチップ: 特定のエリアにマウスが乗ったときにツールチップを表示し、そのエリアがイベントを処理しない場合は親コントロールに処理を渡す。
tooltip_area.mouse_filter = Control.MOUSE_FILTER_PASS
-
カスタムツールチップ: 特定のエリアにマウスが乗ったときにツールチップを表示し、そのエリアがイベントを処理しない場合は親コントロールに処理を渡す。
-
MOUSE_FILTER_IGNORE
の使用例:-
背景画像: 背景画像がマウス入力を受け取らず、背後の他のコントロールがイベントを受け取るようにする。
background_image.mouse_filter = Control.MOUSE_FILTER_IGNORE
-
背景画像: 背景画像がマウス入力を受け取らず、背後の他のコントロールがイベントを受け取るようにする。
まとめ
-
MOUSE_FILTER_STOP
: コントロールがマウスイベントを完全に処理し、他のコントロールに伝播しないようにする場合に使用します。 -
MOUSE_FILTER_PASS
: コントロールがイベントを処理しない場合に、親コントロールに伝播させたい場合に使用します。 -
MOUSE_FILTER_IGNORE
: コントロールがマウスイベントを無視し、他のコントロールにイベントを伝播させたい場合に使用します。
これにより、Godotでのマウスイベント処理の制御を細かく設定できるようになります。
ステートマシン
メリット
-
可読性の向上:
- ステートマシンを使用することで、キャラクターの異なる状態(例:待機、移動、ジャンプ、攻撃など)を個別に管理でき、コードの可読性が向上します。
- 各状態ごとに専用のスクリプトを持たせることで、状態ごとの処理が明確になります。
-
保守性の向上:
- 各状態ごとに処理を分割することで、特定の状態に関する変更が他の状態に影響を与えにくくなります。
- 新しい状態を追加する場合でも、既存のコードを変更する必要が少なくなります。
-
バグの発見と修正が容易:
- ステートマシンでは状態ごとの処理が独立しているため、特定の状態に関するバグの発見と修正が容易です。
- デバッグがしやすく、バグの影響範囲が限定されます。
-
拡張性の向上:
- 新しい状態や動作を追加する際に、既存のコードを大きく変更することなく、容易に拡張できます。
-
再利用性の向上:
- 状態ごとの処理が独立しているため、他のプロジェクトやキャラクターでも再利用が容易です。
チュートリアル: ベーシックなプレイヤーの動きのステートマシンを作成
1. ステートマシンのベースクラス
まず、ステートマシンと状態のベースクラスを作成します。
state_machine.gd
extends Node
class_name StateMachine
@export var current_state: State
var states: Dictionary = {}
func initialize(character: CharacterBody2D, animation: AnimationPlayer):
for child in get_children():
if child is State:
states[child.name] = child
child.transitioned.connect(on_child_transitioned)
child.player = character
child.animation = animation
else:
push_warning("ステートが存在しません")
if current_state:
current_state.enter()
else:
push_warning("初期状態が見つかりません")
func _process(delta):
if current_state:
current_state.update(delta)
func _physics_process(delta):
if current_state:
current_state.physics_update(delta)
func on_child_transitioned(new_state_name):
var new_state = states.get(new_state_name)
if new_state != null:
if new_state != current_state:
current_state.exit()
new_state.enter()
current_state = new_state
else:
push_warning("指定したステートが存在しません")
state.gd
extends Node
class_name State
signal transitioned(new_state_name: StringName)
var player: CharacterBody2D
var animation: AnimationPlayer
var x_input = 0
func enter() -> void:
pass
func exit() -> void:
pass
func update(delta: float) -> void:
pass
func physics_update(delta: float) -> void:
x_input = player.get_axis_raw("left", "right")
2. プレイヤーのスクリプト
プレイヤーの基本的な動きを実装します。
player.gd
extends CharacterBody2D
class_name Player
@onready var sprite_2d: Sprite2D = $Sprite2D
@onready var animation_player: AnimationPlayer = $AnimationPlayer
@onready var state_machine: StateMachine = $StateMachine
@export_group("move")
@export var move_speed: float = 200.0
var gravity = ProjectSettings.get_setting("physics/2d/default_gravity")
var facing_dir: int = 1
var facing_right: bool = true
func _ready() -> void:
state_machine.initialize(self, animation_player)
func _physics_process(delta):
if !is_on_floor():
velocity.y += gravity * delta
velocity.y = min(velocity.y, 500.0)
move_and_slide()
func set_vel(x_velocity, y_velocity):
velocity = Vector2(x_velocity, y_velocity)
flip_controller(x_velocity)
func flip():
facing_dir *= -1
facing_right = !facing_right
sprite_2d.flip_h = !sprite_2d.flip_h
func flip_controller(_x: float):
if _x > 0 and !facing_right:
flip()
elif _x < 0 and facing_right:
flip()
func get_axis_raw(negative_action: String, positive_action: String) -> float:
var value = 0.0
if Input.is_action_pressed(negative_action):
value -= 1.0
if Input.is_action_pressed(positive_action):
value += 1.0
return value
3. 各状態のスクリプト
idle_state.gd
extends State
class_name IdleState
func enter() -> void:
super.enter()
player.velocity = Vector2.ZERO
animation.play("idle")
func exit() -> void:
super.exit()
func update(delta: float) -> void:
super.update(delta)
func physics_update(delta: float) -> void:
super.physics_update(delta)
if x_input != 0:
transitioned.emit("RunState")
run_state.gd
extends State
class_name RunState
func enter() -> void:
super.enter()
animation.play("run")
func exit() -> void:
super.exit()
func update(delta: float) -> void:
super.update(delta)
func physics_update(delta: float) -> void:
super.physics_update(delta)
player.set_vel(x_input * player.move_speed, player.velocity.y)
if x_input == 0:
transitioned.emit("IdleState")
4. シーン設定
-
CharacterBody2D
でPlayerを作成し、player.gd
をアタッチします。 -
Node
でStateMachineをPlayer
の子として追加し、state_machine.gd
をアタッチします。 -
Node
で作成したIdleState
,RunState
をStateMachine
の子として追加し、それぞれ対応するスクリプトをアタッチします。 -
StateMachine
ノードのcurrent_state
プロパティにIdleState
ノードを設定します。
5. アニメーションの設定
各状態に対応するアニメーション(idle
, run
)を設定し、AnimationPlayer
に追加します。
結論
このようにステートマシンを使用することで、プレイヤーキャラクターの動きを簡潔に管理でき、追加の機能や状態を簡単に実装できます。
状態ごとにスクリプトを分けることで、コードの可読性と保守性が向上し、デバッグや拡張が容易になります。
敵キャラ ステートマシン
enemy_test.gd、enemy_test_state.gd、enemy_test_state_machine.gdは全敵共通のコードになります
enemy_test.gd
extends CharacterBody2D
class_name EnemyTest
@onready var animation_player: AnimationPlayer = $AnimationPlayer
@onready var enemy_test_state_machine: EnemyTestStateMachine = $EnemyTestStateMachine
var gravity = ProjectSettings.get_setting("physics/2d/default_gravity")
func _ready():
enemy_test_state_machine.initialize(self, animation_player)
func _physics_process(delta):
if !is_on_floor():
velocity.y += gravity * delta
velocity.y = min(velocity.y, 500.0)
enemy_test_state_machine.gd
extends Node
class_name EnemyTestStateMachine
@export var current_state: EnemyTestState
var states: Dictionary = {}
func initialize(character: CharacterBody2D, animation: AnimationPlayer):
for child in get_children():
if child is EnemyTestState:
states[child.name] = child
child.transitioned.connect(on_child_transitioned)
child.character = character
child.animation = animation
else:
push_warning("ステートが存在しません")
if current_state:
current_state.enter()
else:
push_warning("初期状態が見つかりません")
func _process(delta):
if current_state:
current_state.update(delta)
func _physics_process(delta):
if current_state:
current_state.physics_update(delta)
func on_child_transitioned(new_state_name):
var new_state = states.get(new_state_name)
if new_state != null:
if new_state != current_state:
current_state.exit()
new_state.enter()
current_state = new_state
else:
push_warning("指定したステートが存在しません")
enemy_test.gd
extends Node
class_name EnemyTestState
signal transitioned(new_state_name: StringName)
var character: CharacterBody2D
var animation: AnimationPlayer
var state_timer: float
func enter() -> void:
pass
func exit() -> void:
pass
func update(delta: float) -> void:
pass
func physics_update(delta: float) -> void:
state_timer -= delta
enemy_test_1.gd
extends EnemyTest
func _ready():
super._ready()
func _physics_process(delta):
super._physics_process(delta)
move_and_slide()
enemy_test_2.gd
extends EnemyTest
func _ready():
super._ready()
func _physics_process(delta):
super._physics_process(delta)
move_and_slide()
test1_idle_state.gd
extends EnemyTestState
class_name Test1IdleState
func enter() -> void:
super.enter()
animation.play("idle")
character.velocity = Vector2.ZERO
state_timer = 4.0
func exit() -> void:
super.exit()
func update(delta: float) -> void:
super.update(delta)
func physics_update(delta: float) -> void:
super.physics_update(delta)
if state_timer < 0:
transitioned.emit("Test1RunState")
test1_run_state.gd
extends EnemyTestState
class_name Test1RunState
func enter() -> void:
super.enter()
animation.play("run")
state_timer = 5.0
func exit() -> void:
super.exit()
func update(delta: float) -> void:
super.update(delta)
func physics_update(delta: float) -> void:
super.physics_update(delta)
character.velocity.x = -10
if state_timer < 0:
transitioned.emit("Test1IdleState")
test2_idle_state.gd
extends EnemyTestState
class_name Test2IdleState
func enter() -> void:
super.enter()
animation.play("idle")
character.velocity = Vector2.ZERO
state_timer = 2.0
func exit() -> void:
super.exit()
func update(delta: float) -> void:
super.update(delta)
func physics_update(delta: float) -> void:
super.physics_update(delta)
if state_timer < 0:
transitioned.emit("Test2RunState")
test2_run_state.gd
extends EnemyTestState
class_name Test2RunState
func enter() -> void:
super.enter()
animation.play("run")
state_timer = 3.0
func exit() -> void:
super.exit()
func update(delta: float) -> void:
super.update(delta)
func physics_update(delta: float) -> void:
super.physics_update(delta)
character.velocity.x = -10
if state_timer < 0:
transitioned.emit("Test2IdleState")
効率化のポイント
-
初期化の集中化:
-
CharacterBody2D
のready
メソッドでstate_machine.initialize(self, animation_player)
を呼び出して、すべての必要な情報を一度に設定しています。
-
-
自動設定:
- 各ステートが
character
やanimation
を直接参照することで、各ステート内での設定の手間を省いています。
- 各ステートが
-
ステートの独立性:
- 各ステートが独立して動作し、他のステートや親ノードに依存しないようになっているため、コードの可読性とメンテナンス性が向上します。
この方法で、各ステートが必要な情報に直接アクセスできるため、コードがシンプルかつ効率的になります。
StringName
と String
の違いは、主に効率性と用途にあります。
StringName
StringName
は String
に似たクラスですが、文字列を内部で効率的に管理するための特別な機構が含まれています。主に以下のような特徴があります:
-
高速な比較:
StringName
は内部的に文字列をハッシュテーブルで管理しているため、比較操作が非常に高速です。これは、大量の文字列比較が頻繁に行われる状況で特に有用です。 -
低いメモリ消費:
StringName
は重複する文字列を効率的に管理するため、同じ文字列が多数存在する場合でもメモリ消費が少なくなります。 -
用途: 主にノード名、リソース名、シグナル名などの特定の用途で使用されます。これらの用途では、文字列の比較が頻繁に行われ、パフォーマンスの向上が重要です。
String
String
は一般的な文字列クラスで、文字列データを保持し、操作するための多くのメソッドが提供されています。以下のような特徴があります:
-
汎用性: 一般的な文字列操作(連結、検索、分割など)に使用されます。
-
操作の豊富さ: 文字列の操作に関する多くのメソッドが用意されており、さまざまな文字列処理が可能です。
-
用途: テキストデータ全般に使用され、特に頻繁な比較や特定の名前管理が必要ない場合に適しています。
使用例
例えば、シグナルの宣言で StringName
と String
を使い分けると、以下のようになります:
signal transitioned(new_state_name: StringName, animation_name: String)
-
new_state_name: StringName
: 新しい状態名は、頻繁に比較されることが予想されるため、StringName
を使用して効率的に管理します。 -
animation_name: String
: アニメーション名は、汎用的な文字列として使用され、特に頻繁な比較が必要ないためString
を使用します。
まとめ
-
StringName
は、効率的な文字列比較と低いメモリ消費を目的とした特別なクラス。 -
String
は、一般的な文字列操作をサポートする汎用クラス。 - 適材適所でこれらを使い分けることで、パフォーマンスとメモリ効率を向上させることができます。
@exportと@onready
Godotのスクリプトでは、@exportと@onreadyの違いに注意する必要があります。@onreadyは、ノードが完全に初期化された後に実行されるコードであり、シーンのロード完了後に子ノードを取得します。これに対して、@exportはインスペクタで変数を設定可能にするためのもので、ノードのインスタンスが生成された直後に実行されます。
@exportを使用してインスペクタで変数を設定することはできますが、ノードのツリー全体がロードされる前にアクセスしようとすると、まだノードが存在しないためnullが返されることがあります。
call_deferred
call_deferred
メソッドは、Godotで特定のメソッドの呼び出しを次のフレームまで遅延させるために使用されます。これにより、他のノードやシグナル接続が確立される前に実行されることを防ぎます。この機能は、ノードがシーンに追加された直後に行いたい初期化処理などで非常に役立ちます。
以下に、call_deferred
を用いた簡単なチュートリアルを提供します。
call_deferred
の基本的な使い方
- シンプルな例: ボタンをクリックした後、ラベルのテキストを変更しますが、変更を次のフレームまで遅延させます。
# MainScene.gd
extends Control
@onready var button: Button = $Button
@onready var label: Label = $Label
func _ready():
button.connect("pressed", self, "_on_button_pressed")
func _on_button_pressed():
call_deferred("_update_label_text")
func _update_label_text():
label.text = "Button was pressed!"
解説
-
シーンセットアップ:
-
MainScene
という名前のシーンを作成します。 - シーンに
Button
とLabel
を追加します。
-
-
スクリプトのアタッチ:
-
MainScene
に上記のスクリプトをアタッチします。
-
-
_readyメソッド:
-
_ready
メソッドはノードがシーンに追加された直後に呼ばれます。このメソッドで、ボタンのpressed
シグナルに対して_on_button_pressed
メソッドを接続します。
-
-
ボタンが押された時の処理:
- ボタンが押されたときに呼び出される
_on_button_pressed
メソッドでは、call_deferred
を使用して_update_label_text
メソッドの呼び出しを次のフレームまで遅延させます。
- ボタンが押されたときに呼び出される
-
_update_label_textメソッド:
- 実際にラベルのテキストを更新するメソッドです。
call_deferred
によって遅延されて実行されます。
- 実際にラベルのテキストを更新するメソッドです。
この方法によって、他の処理がすべて完了した後にテキストの変更が行われるため、競合や未初期化の問題を防ぐことができます。
call_deferred
の応用
以下に、call_deferred
を使用してノードの追加を遅延させる例を示します。
# Spawner.gd
extends Node2D
@export var enemy_scene: PackedScene
func _ready():
# 1秒後に敵を生成する
await get_tree().create_timer(1.0).timeout
call_deferred("spawn_enemy")
func spawn_enemy():
var enemy = enemy_scene.instantiate()
enemy.position = Vector2(200, 200)
add_child(enemy)
解説
-
シーンセットアップ:
-
Spawner
という名前のシーンを作成します。
-
-
スクリプトのアタッチ:
-
Spawner
に上記のスクリプトをアタッチします。
-
-
敵の生成:
-
_ready
メソッドで1秒の遅延後にspawn_enemy
メソッドをcall_deferred
で呼び出します。
-
-
spawn_enemyメソッド:
-
enemy_scene
からインスタンスを生成し、その位置を設定してシーンに追加します。
-
これにより、1秒後に敵がシーンに追加されます。この方法を使うと、特定の条件が満たされた後に処理を遅延させて実行することができます。
このように、call_deferred
はGodotで初期化やシグナル接続が完了した後に処理を行いたい場合に非常に便利です。シーンのノードが正しく初期化され、シグナルが確実に接続されてから処理を実行できるようにするために、積極的に活用しましょう。
Dialogue Manager
Dialogue Managerを使用してSpeech Balloonを実装しました。
Dialogue Manager
を使用して、キャラクターが話す際に表示される「Speech Balloon(吹き出し)」を実装する方法について詳しく解説します。
コードの各部分を説明し、全体的な流れを理解できるようにします。
1. プロジェクトのセットアップ
まず、Node2D
を親ノードとして持つ Speech Balloon
シーンを作成します。これには、以下の子ノードが含まれます。
-
Control
(バルーン全体を管理) -
NinePatchRect
(背景) -
RichTextLabel
(キャラクター名を表示) -
DialogueLabel
(実際のダイアログテキストを表示) -
TextureRect
(ピンの表示) -
RichTextLabel
(テキストサイズを測るためのラベル群) -
AudioStreamPlayer
(話している時の音)
これらのノードはそれぞれ @onready
変数で参照され、スクリプト内で使用されます。
extends Node2D
@export var default_color: Color = Color.DIM_GRAY
@onready var balloon: Control = %Balloon
@onready var background: NinePatchRect = %Background
@onready var character_label: RichTextLabel = %CharacterLabel
@onready var dialogue_label: DialogueLabel = %DialogueLabel
@onready var pin_down: TextureRect = %PinDown
@onready var small_test: RichTextLabel = $SmallTest
@onready var medium_test: RichTextLabel = $MediumTest
@onready var large_test: RichTextLabel = $LargeTest
@onready var indicator: TextureRect = %Indicator
@onready var talk_sound: AudioStreamPlayer = $TalkSound
const DialogueLabel = preload("res://addons/dialogue_manager/dialogue_label.gd")
var resource: DialogueResource
var temporary_game_states: Array = []
var is_waiting_for_input: bool = false
var character: Node2D
var target: DialogueMarker
var dialogue_line: DialogueLine
var camera: Camera2D
var player: Player
2. 初期設定
_ready
関数では、必要なノードやプレイヤーを取得し、最初に表示されるべきではないUI要素を非表示にします。
func _ready() -> void:
player = get_tree().get_first_node_in_group("player")
indicator.hide()
small_test.modulate.a = 0
medium_test.modulate.a = 0
large_test.modulate.a = 0
camera = get_tree().get_first_node_in_group("camera")
hide()
-
indicator.hide()
:インジケータ(入力待ちの表示)を隠します。 -
camera
は、Camera2D
ノードを取得して、バルーンの位置を計算するのに使います。
3. フレームごとのバルーンの位置更新
_physics_process
関数は、毎フレームバルーンの位置を更新します。
これは、キャラクターの位置が移動した場合でも、バルーンが正しい位置に表示されるようにするためです。
func _physics_process(delta: float) -> void:
position_balloon()
4. ダイアログの開始と進行
ダイアログを開始するには、start
関数を使用します。次のダイアログラインへ進むには next
関数を使用します。
func start(dialogue_resource: DialogueResource, title: String, extra_game_states: Array = []) -> void:
player.can_move = false
temporary_game_states = [self] + extra_game_states
set_waiting_for_input(false)
resource = dialogue_resource
set_dialogue_line(await resource.get_next_dialogue_line(title, temporary_game_states))
-
player.can_move = false
:ダイアログ中はプレイヤーの動きを止めます。 -
set_waiting_for_input(false)
:初期状態では、入力待ちでない状態に設定します。
5. ダイアログラインの設定
set_dialogue_line
関数で、各ダイアログラインを設定し、バルーンのサイズや位置を決定します。
func set_dialogue_line(next_dialogue_line: DialogueLine) -> void:
if next_dialogue_line == null:
player.can_move = true
queue_free()
return
set_waiting_for_input(false)
dialogue_line = next_dialogue_line
# バルーンのサイズを初期化
balloon.size = Vector2.ZERO
var next_position = Vector2(30, 20)
# キャラクター名の表示設定
if dialogue_line.character.is_empty():
character_label.hide()
else:
character_label.show()
character_label.text = dialogue_line.character
character_label.position = next_position
next_position.y += character_label.size.y
# ダイアログテキストの設定
dialogue_label.modulate.a = 0
dialogue_label.dialogue_line = dialogue_line
dialogue_label.position = next_position
# テキストサイズを計測するためのテストラベルにテキストを設定
small_test.text = dialogue_line.text
medium_test.text = dialogue_line.text
large_test.text = dialogue_line.text
await get_tree().process_frame # レイアウト更新のためにフレームを待つ
# テキストの高さに基づいて最適なバルーンサイズを計算
var best_size: Vector2
var shortest_height: float = INF
for label in [small_test, medium_test, large_test]:
var content_height = label.get_content_height()
if content_height < shortest_height:
shortest_height = content_height
best_size = Vector2(label.size.x, content_height)
# 計算した最適なサイズをダイアログラベルに適用
dialogue_label.size = best_size
next_position.y += best_size.y + 15
# バルーンのサイズを設定(マージンを追加)
balloon.size = best_size + Vector2(20, 60)
# 対象キャラクターの設定
target = null
if not dialogue_line.character.is_empty():
var markers: Array[Node] = get_tree().get_nodes_in_group("dialogue_markers").filter(
func(marker: DialogueMarker): return marker.character_name == dialogue_line.character
)
if markers.size() > 0:
target = markers[0] as DialogueMarker
character = target.owner
# 背景色の設定
if target:
background.modulate = target.color
else:
background.modulate = default_color
# ピンの色を背景色に合わせる
pin_down.modulate = background.modulate
# バルーンの位置を設定
position_balloon()
# バルーンを表示
show()
# ダイアログテキストを表示し、タイピングアニメーションを再生
dialogue_label.modulate.a = 1
dialogue_label.type_out()
await dialogue_label.finished_typing
# メッセージ入力待ち状態にする
set_waiting_for_input(true)
balloon.focus_mode = Control.FOCUS_ALL
balloon.grab_focus()
-
サイズの計算:テストラベル(
small_test
,medium_test
,large_test
)を使い、ダイアログに最適なバルーンのサイズを決定します。 - ターゲットの設定:キャラクターに基づいてバルーンの位置や背景色を設定します。
6. バルーンの位置設定
position_balloon
関数は、バルーンの位置を計算し、画面に正しく配置します。
func position_balloon() -> void:
if is_instance_valid(target) and is_instance_valid(character):
var screen_position = (target.global_position - camera.position) * camera.zoom + get_viewport_rect().size / 2
pin_down.visible = true
balloon.position = screen_position - Vector2(balloon.size.x / 4, balloon.size.y + 23)
pin_down.position = screen_position - Vector2(0, 25)
indicator.position = screen_position + Vector2(balloon.size.x / 2 + balloon.size.x / 4 - 30, -50)
else:
pin_down.visible = false
var viewport_size: Vector2i = get_viewport_rect().size
balloon.position = Vector2(viewport_size.x / 2 - balloon.size.x / 2, viewport_size.y / 2 + (viewport_size.y * 0.3))
indicator.position = Vector2(viewport_size.x / 2 + balloon.size.x / 2 - 25, viewport_size.y / 2 + (viewport_size.y * 0.3)
+ balloon.size.y - 25)
- ターゲットが存在する場合:ターゲットの位置に基づいてバルーンを配置します。
- ターゲットが存在しない場合:画面のデフォルトの位置に配置します。
7. 入力処理とサウンド
_on_balloon_gui_input
関数では、ユーザーが指定のキーを押した場合に次のダイアログラインへ進むように設定されています。
また、_on_dialogue_label_spoke
で文字が表示されるたびに音を鳴らすことができます。
func _on_balloon_gui_input(event: InputEvent) -> void:
if not get_waiting_for_input(): return
if Input.is_action_just_pressed("jump"):
next(dialogue_line.next_id)
func _on_dialogue_label_spoke(letter: String, letter_index: int, speed: float) -> void:
if not letter in [".", " "]:
talk_sound.pitch_scale = randf_range(0.99, 1.01)
talk_sound.play()
-
_on_balloon_gui_input
: 「jump」キーが押されたら、次のダイアログラインへ進みます。 -
_on_dialogue_label_spoke
: 各文字が表示されるたびにランダムなピッチで話し声のようなサウンドを再生します。
最後に
このチュートリアルでは、Dialogue Manager
を使用して、キャラクターが話す際に表示される「Speech Balloon」を実装する手順を説明しました。このシステムを使えば、対話型のゲームでリアルタイムにダイアログを表示し、プレイヤーとNPCの間で自然な会話を演出することができます。
CharacterBody2D
CharacterBody2D
は、主にプレイヤーキャラクターやNPCなど、重力や物理的な衝突に対応しながら移動するオブジェクトを制御するためのノードです。bullet
のような弾丸オブジェクトをCharacterBody2D
で作成する場合、次のようなメリットとデメリットがあります。
extends CharacterBody2D
var speed = 10
var direction = Vector2.RIGHT
func _ready() -> void:
direction = Vector2.RIGHT.rotated(global_rotation)
func _process(delta: float) -> void:
velocity = direction * speed
var collision = move_and_collide(velocity)
if collision:
queue_free()
メリット
-
簡単な移動と衝突管理:
-
CharacterBody2D
にはmove_and_collide
やmove_and_slide
のようなメソッドがあり、移動と衝突の管理を簡単に行えます。弾丸が壁や敵に当たったときに消えるといった基本的な動作を、比較的少ないコードで実装できます。
-
-
滑らかな衝突判定:
- 弾丸の衝突判定が滑らかで、特に斜めの壁や動いている物体との衝突判定が精密です。
-
移動方向の制御が容易:
- 弾丸の速度や方向の変更が容易です。例えば、弾丸が特定の方向に移動するように、
velocity
を簡単に設定できます。
- 弾丸の速度や方向の変更が容易です。例えば、弾丸が特定の方向に移動するように、
デメリット
-
パフォーマンスの低下:
-
CharacterBody2D
は物理エンジンでの衝突検出や応答を管理するための多くのオーバーヘッドを持っています。弾丸のように大量に生成されるオブジェクトには向いていません。弾丸が大量に存在するシーンでは、CharacterBody2D
を使用することでパフォーマンスが低下する可能性があります。
-
-
過剰な機能:
- 弾丸のようなシンプルなオブジェクトに対して、
CharacterBody2D
は過剰な機能を持っています。弾丸は単純に直線的に移動し、何かに当たったら消えるという動作が多いため、CharacterBody2D
の重力や摩擦などの特性は不要です。
- 弾丸のようなシンプルなオブジェクトに対して、
代替案
-
RigidBody2D
:- 弾丸を物理的な動作で制御したい場合には
RigidBody2D
を使用するのが適しています。RigidBody2D
は物理的な挙動(重力や反発など)を持ち、弾丸のようなオブジェクトがリアルに動作するように制御できます。
- 弾丸を物理的な動作で制御したい場合には
-
Area2D
:- 弾丸が特定の範囲内で衝突を検出するだけの場合には
Area2D
が適しています。Area2D
は物理的な挙動を持たず、衝突検出に特化しています。これにより、パフォーマンスが向上します。
- 弾丸が特定の範囲内で衝突を検出するだけの場合には
結論
CharacterBody2D
を弾丸オブジェクトに使用することは可能ですが、通常はRigidBody2D
やArea2D
の方がより適切です。弾丸の挙動が物理エンジンの特性を必要とする場合はRigidBody2D
を使用し、単純な衝突検出のみが必要な場合はArea2D
を使用すると、パフォーマンスと実装の効率が良くなります。
作成したゲームをNext.jsで公開
作成したゲームをNext.jsのアプリケーションで表示して、遊べるようにします。
Godot
まずは、最小限の構成でGodotプロジェクトを作成します。
プレイヤーが横移動するだけになります。
player.gd
extends CharacterBody2D
class_name Player
@onready var animated_sprite_2d: AnimatedSprite2D = $AnimatedSprite2D
var GRAVITY = ProjectSettings.get_setting("physics/2d/default_gravity")
const RUN_SPEED: float = 180.0
var input_vector: Vector2 = Vector2.ZERO
enum PLAYER_STATE {
IDLE,
RUN,
}
var _state: PLAYER_STATE = PLAYER_STATE.IDLE
func _physics_process(delta: float) -> void:
if !is_on_floor():
velocity.y += GRAVITY * delta
get_input()
move_and_slide()
calculate_states()
func get_input():
velocity.x = 0
if Input.is_action_pressed("left"):
velocity.x = -RUN_SPEED
animated_sprite_2d.flip_h = true
elif Input.is_action_pressed("right"):
velocity.x = RUN_SPEED
animated_sprite_2d.flip_h = false
func calculate_states():
if is_on_floor():
if velocity.x == 0:
set_state(PLAYER_STATE.IDLE)
else:
set_state(PLAYER_STATE.RUN)
func set_state(new_state: PLAYER_STATE):
_state = new_state
match _state:
PLAYER_STATE.IDLE:
animated_sprite_2d.play("idle")
PLAYER_STATE.RUN:
animated_sprite_2d.play("run")
html形式でエクスポートします。
設定はデフォルトで問題ありません。
エクスポートしたファイルはこのようになります。
Next.js
Next.jsのプロジェクトを作成します。
npx create-next-app@latest . --typescript --tailwind --eslint
public/testフォルダにエクスポートしたファイルを格納します。
page.tsx
import GodotApp from "@/components/test/GodotApp"
const TestPage = () => {
return (
<div className="px-2 max-w-screen-xl mx-auto my-10">
<GodotApp url="/test/test.html" />
</div>
)
}
export default TestPage
コンポーネントを作成します。
components/test/GodotApp.tsx
"use client"
import { useRef } from "react"
import { Button } from "@/components/ui/button"
type GodotAppProps = {
url: string
portrait?: boolean
}
const GodotApp = ({ url }: GodotAppProps) => {
const appRef = useRef<HTMLIFrameElement>(null)
const focusApp = () => {
appRef.current?.requestFullscreen()
}
return (
<div className="flex flex-col items-center justify-center space-y-10">
<div className="relative w-full" style={{ paddingBottom: "56.25%" }}>
<iframe
ref={appRef}
src={url}
className="absolute top-0 left-0 w-full h-full"
autoFocus={false}
/>
</div>
<Button variant="outline" onClick={focusApp} className="text-black">
フルスクリーン
</Button>
</div>
)
}
export default GodotApp
実行します。
npm run dev
これでNext.jsのアプリケーションにGodotで作成したゲームを表示することができました。
HTML形式になっているので、Splashを変更したりなどカスタマイズはしやすいです。
【超入門】ゲーム制作の楽しさを体験!Godotで作る2Dプラットフォーマー
今回は、Godotを使ってシンプルな2Dプラットフォーマーゲームを作る方法を学びます。
ゲーム制作が初めての方でも安心して進められるように、基礎から一歩ずつ丁寧に解説していきます。
このチュートリアルでは、キャラクターの操作やトラップの設置、スタート、ゴールの設定など、ゲームの基本的な要素を作成します。
進めるうちに、「自分だけのゲーム」を作る楽しさを実感していただけるはずです!
また、チュートリアルが完了した後は、さらにステップアップできるゲームプロジェクトもご用意しています。
今回作ったゲームに、新しいステージやトラップ、プレイヤーアニメーション、スコア機能などを追加して、あなた自身のオリジナルゲームを完成させましょう。
ゲームを作る楽しさは無限大!
ぜひ、このチュートリアルを通して、Godotの魅力に触れ、ゲーム制作の第一歩を踏み出してみてください。
■チュートリアル
■Godot
■アセット
■プロジェクト(チュートリアル、ステップアップ)
■学習内容
・Godot の基本操作:プロジェクト作成からインターフェースの使い方を学習
・プレイヤーキャラクターの操作:ジャンプ、移動、アニメーションの制御
・トラップの設置:プレイヤーにダメージを与える仕掛けを実装
・スタートとゴールの設定:ゲームの開始地点とクリア条件の設定
・UI:メインメニューとゴール画面表示
・シーン管理:ステージの切り替えやメインメニューの作成
■機能
・プレイヤー移動:左右移動とジャンプ
・アニメーション:プレイヤーのアニメーション(待機、走行、ジャンプ、落下)を作成
・トラップシステム:トラップに触れた際にプレイヤーにダメージを与える機能
・スタートとゴールのシステム:ステージの開始地点とゴール地点の設定
・メインメニューとゴール画面:ゲーム開始とステージクリア後の画面作成