Open28

ゲーム開発エンジンGodot学習チュートリアル集、成果物、Tipsなど

ピン留めされたアイテム
はる@フルスタックチャンネルはる@フルスタックチャンネル

成果物

今までGodotで作ったゲーム

01 サバイバースタイルゲーム(合計学習時間:20時間)

https://youtu.be/zBFOKBXhZfk

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プラットフォーマーゲームのチュートリアルを作成しました。
ゲーム制作が初めての方でも安心して進められるように、基礎から一歩ずつ丁寧に解説しています。
https://youtu.be/us3BMH7hvyw

はる@フルスタックチャンネルはる@フルスタックチャンネル

https://godotengine.org/

Godotとは何か

Godotは、オープンソースのゲームエンジンで、2Dおよび3Dゲームの開発を支援します。
2014年に初リリースされ、その使いやすさと柔軟性で多くの開発者に支持されています。
特筆すべきは、完全無料であり、ソースコードが公開されているため、誰でも改変や共有が可能です。
Godotはプログラミング初心者から経験豊富な開発者まで、あらゆるレベルのユーザーにとって使いやすい環境を提供しています。

Godotのメリット

  1. 直感的な使いやすさ:
    Godotは初心者にも優しい設計で、シンプルかつ直感的なインターフェースが特徴です。
    シーンベースの構造により、ゲームオブジェクトの管理が容易で、複雑なプロジェクトでも効率的に開発を進められます。
    シーンシステムは、個々のコンポーネントをモジュールとして再利用できるため、プロジェクトの規模が大きくなっても管理がしやすいです。

  2. 軽量かつ高速:
    Godotは非常に軽量で、ロード時間がほとんど発生しません。
    これにより、開発中のストレスが大幅に軽減されます。実行ファイルも軽量で、ほとんどのPCでスムーズに動作します。
    この軽快さは、特にインディーゲーム開発者や小規模なチームにとって大きな魅力です。

  3. 強力な2Dおよび3Dサポート:
    2Dゲームエンジンは特に高評価を受けており、高性能な描画が可能です。
    2D空間でのピクセルパーフェクトなレンダリングは、他のエンジンでは得られない細かい表現が可能です。
    3Dエンジンも着実に進化しており、多くの機能を備えています。最新のアップデートでは、Vulkanサポートが追加され、高度な3Dグラフィックスの表現力が向上しました。

  4. オープンソースと無料:
    Godotは完全に無料であり、ライセンス費用やロイヤリティの心配がありません。
    このため、インディー開発者や学生にも非常に利用しやすいです。
    商用プロジェクトでも追加費用が発生しないため、収益を最大限に確保することができます。
    また、ソースコードが公開されているため、カスタマイズ性が高く、特定のプロジェクトニーズに合わせた調整が可能です。

  5. 多言語サポート:
    Godotは独自のスクリプト言語GDScriptを使用しており、Pythonに似た簡潔な文法が特徴です。
    Pythonに慣れているユーザーにとっては、GDScriptの学習曲線が緩やかで、すぐに使いこなせます。
    また、C#やビジュアルスクリプトもサポートしているため、開発者は自身の得意な言語で開発が可能です。
    さらに、GDNativeを利用することで、C++やRustなどの言語で高性能なコードを書くこともできます。

  6. クロスプラットフォーム対応:
    Windows、Mac、Linux、iOS、Android、HTML5など、さまざまなプラットフォームへのエクスポートが可能です。
    これにより、一度作成したゲームを多くのデバイスで楽しむことができます。
    さらに、最近のアップデートで、コンソール向けのエクスポートも容易になり、より多くのプラットフォームに対応できるようになりました。

勢いが凄い

2024年のGMTK Game Jamにおいて、Godotエンジンは大きな人気を集めており、全体の37%(2,838作品)で使用されています。
これは全ゲームの約3分の1以上を占めており、非常に多くの開発者がGodotを選んでいることがわかります。
Godotエンジンは多くの開発者に支持され、2024年のGMTK Game Jamで顕著な成果を挙げたと考えられます。

参考:
https://x.com/gamemakerstk/status/1826184926393491689

Godotのデメリット

  1. ドキュメントの充実度:
    急速に成長しているため、他の商用エンジンほどドキュメントが充実していないことがあります。
    しかし、コミュニティのサポートが活発で、フォーラムやQ&Aサイトで必要な情報を見つけることができます。
    公式ドキュメントも改善が進んでおり、使いやすさが向上しています。
    特に、最近では日本語のドキュメントも増えてきており、言語の壁を感じることなく学習できます。

  2. 3D機能の成熟度:
    3D機能は進化中ですが、UnityやUnreal Engineに比べるとまだ成熟度に欠ける部分があります。
    特に高度な3Dグラフィックスや物理シミュレーションを必要とするプロジェクトでは、他のエンジンが優位になることがあります。
    しかし、Vulkanサポートの追加や、その他の機能強化により、今後の成長が期待されています。

UnityとGodotの違い

  1. ライセンスとコスト:
    Unityは基本的に無料で利用できますが、収益の高いプロジェクトには有料ライセンスが必要です。
    一方、Godotは完全無料で、どんな規模のプロジェクトでも追加費用がかかりません。
    これにより、収益の大部分を確保できるため、インディー開発者にとって非常に魅力的です。

  2. インターフェースの使いやすさ:
    Unityは機能が豊富ですが、その分インターフェースが複雑で初心者には学習曲線が急です。
    Godotはシンプルで直感的なインターフェースを持ち、初心者でもすぐに使い始められます。
    また、シーンシステムの導入により、複雑なプロジェクトでも効率的に管理が可能です。

  3. スクリプト言語:
    UnityはC#を使用し、これに精通している開発者には魅力的です。
    GodotのGDScriptはPythonに似た簡潔な文法で、初心者にも理解しやすいです。
    また、C#もサポートしているため、Unityからの移行もスムーズです。
    さらに、ビジュアルスクリプトを利用することで、プログラミング経験のないユーザーでも簡単にゲームを作成できます。

  4. オープンソース vs 商用エンジン:
    Godotはオープンソースであり、エンジンの内部構造を自由に閲覧・改変できます。
    Unityは商用エンジンで、その内部構造は公開されていません。
    これにより、Godotはカスタマイズ性が高く、特定のプロジェクトニーズに合わせた調整が可能です。
    また、コミュニティの力で迅速にバグ修正や機能追加が行われるため、常に最新の技術を取り入れることができます。

  5. パフォーマンスと軽量性:
    Godotは軽量で高速に動作するため、低スペックのマシンでも快適に開発が可能です。
    一方、Unityは高機能である反面、動作が重くなることがあります。
    この違いは、開発の快適さに大きく影響します。

  6. カスタマイズ性:
    Godotはオープンソースであり、必要に応じてエンジン自体を改変することができます。
    Unityは商用エンジンであり、カスタマイズの自由度が制限されています。
    Godotのカスタマイズ性は、特定のニーズに合わせたプロジェクトにおいて非常に有用です。

  7. コミュニティのサポート:
    Godotのコミュニティは非常に活発で、フォーラムやQ&Aサイトでのサポートが充実しています。
    Unityも大規模なコミュニティを持っていますが、Godotのコミュニティはオープンソースの精神に基づいており、開発者同士の助け合いが盛んです。

Godot Engine採用ポイント

  1. コミュニティ開発とサポート
    Godotはオープンソースプロジェクトで、公式サポートはありません。トラブル発生時には自己解決が基本で、有料サポート企業の利用も選択肢です。

  2. コンソール対応
    コンソール向けのゲームリリースには外部企業の支援や自社での対応が必要です。W4 Gamesのような企業がポーティングサービスを提供しています。

  3. ライセンスと責任
    GodotはMITライセンスのため自由に利用・改変可能ですが、開発者に法的責任を問うことはできません。自社でのサポート体制を整えることが重要です。

  4. 資金調達と持続可能性
    Godotの開発は寄付で支えられており、寄付やスポンサーシップがプロジェクトの安定に寄与します。

  5. 突然の利用停止リスク
    Godotはオープンソースコミュニティによって支えられており、商用エンジンのような突然のサービス停止リスクは低いです。

GDScriptについて

GDScriptは、Godot開発チームが設計した独自のスクリプト言語で、Pythonを基にしたシンプルで読みやすい文法が特徴です。
ゲーム開発に不要な複雑な構文を排除し、最適化されたパフォーマンスを提供します。
また、C#やビジュアルスクリプトもサポートしているため、多様なプログラミングスタイルに対応できます。
GDScriptは直感的で学びやすく、短期間で習得できるため、初心者にも最適です。

学習チュートリアル

Godotの学習リソースも充実してきており、公式サイトやコミュニティフォーラムには多くのチュートリアルが用意されています。
初心者向けには、シンプルなゲームを作成するチュートリアルが多く、基本的な操作や概念を学ぶのに最適です。
また、上級者向けには高度な機能や最適化技術を学べるリソースもあります。
特にYouTubeやブログなどのオンラインリソースを活用することで、効率的に学習を進めることができます。

以下は、いくつかのおすすめチュートリアルです:

  1. 公式チュートリアル:
    Godotの公式サイトには、初心者向けから上級者向けまで、多様なチュートリアルが揃っています。
    シンプルなゲームを作成しながら、基本的な操作やスクリプトの書き方を学ぶことができます。

  2. GDQuest:
    YouTubeで人気のGDQuestは、Godotの多くの機能を網羅したチュートリアルを提供しています。
    GDScript、2D/3Dゲーム開発、マルチプレイヤー、音声管理、シェーダープログラミングなど、幅広いトピックをカバーしています。

  3. HeartBeast:
    HeartBeastのチュートリアルは、アクションRPGやプラットフォームゲームなど、具体的なジャンルのゲーム開発に焦点を当てています。
    実践的なプロジェクトを通じて、ゲーム開発のノウハウを身につけることができます。

まとめ

Godotは、その使いやすさと柔軟性、そしてコミュニティのサポートにより、特にインディーズ開発者や小規模なチームにとって非常に魅力的な選択肢です。
無料でありながら高機能で、2Dおよび3Dゲームの開発が可能なため、幅広いジャンルのゲームを作成することができます。
もし、あなたがゲーム開発に興味があり、手軽に始めたいと思っているなら、ぜひGodotを試してみてください。
公式サイトやコミュニティフォーラムには、さまざまなリソースやチュートリアルが用意されており、あなたのゲーム開発の旅をサポートしてくれるでしょう。

ゲーム開発の楽しさを感じながら、Godotを使って自分だけのユニークなゲームを作り上げてみませんか?

公式サイトやコミュニティで多くのリソースが提供されているので、すぐに開発を始めることができます。

Godotでのゲーム開発の楽しさをぜひ体験してみてください!!

はる@フルスタックチャンネルはる@フルスタックチャンネル

学習スケジュール

  1. Udemy人気、評価が高いチュートリアルを2、3個実施(丁寧に解説されているため)
  2. Udemy興味がある内容のチュートリアルを5、6個実施
  3. YouTubeチュートリアルをひたすら実施
  4. 公式ドキュメントで理解を深堀り
  5. 自分でチュートリアルを作成して公開
    • 基本的な仕組みのプラッフォーマー、シューティング、パズルを作成する予定
  6. オリジナルゲームを開発してリリース
はる@フルスタックチャンネルはる@フルスタックチャンネル

Udemy

UdemyのGodotチュートリアルの人気の高い順から開始

Create a Complete 2D Survivors Style Game in Godot 4

https://www.udemy.com/course/create-a-complete-2d-arena-survival-roguelike-game-in-godot-4/

おすすめ度:★★★★★
学習時間:20時間

まずは、このチュートリアルを実施するとGodotの基本が学べます。
分かりやすく丁寧に解説されています。
サバイバーローグライクゲームの作り方が学べます。
自動攻撃のアビリティや増加する敵の大群、経験値のドロップと収集、アビリティのアップグレード、シグナルなど学びます。


Create a Complete 2D Platformer in the Godot Engine

https://www.udemy.com/course/create-a-complete-2d-platformer-in-the-godot-engine/

おすすめ度:★☆☆☆☆
学習時間:10時間

上記のFirebelley Gamesのチュートリアルです。
解説は分かりやすいですが、Godot3のチュートリアルとなっているため注意が必要です
Godot3とGodot4は別物です。
アルゴリズムや仕組みを参考にするのがよいです


Build a complete pixel platformer in Godot 4!

https://www.udemy.com/course/build-a-platformer/

おすすめ度:★★☆☆☆
学習時間:20時間

2Dプラットフォーマーの作り方が学べます。
早送りや飛ばしている箇所もあるので、何回も見直す必要があります。
Godotの基本を事前に学んでおいた方がよいです。
キャラクター、レベルデザイン、宝物、敵、コインなど、アクションゲームに必要な仕組みを学べます。


Jumpstart to 2D Game Development: Godot 4 for Beginners

https://www.udemy.com/course/jumpstart-to-2d-game-development-godot-4-for-beginners/

おすすめ度:★★★★★
学習時間:30時間

こちらも丁寧に解説されています。
7つのゲームを作ることによって、かなり理解が深まります。
シンプルなコードで理解しやすいです。


Complete Godot 2D: Develop Your Own 2D Games Using Godot 4

https://www.udemy.com/course/complete-godot-4-game-developer-2d-online-course/

おすすめ度:★★★★★
学習時間:10時間

かなり丁寧に解説されています。
まずは最初にこのチュートリアルをやることをオススメします。
シューティングゲームとプラットフォーマーの基礎を学べることができます。


Master Mobile Game Development with Godot 4

https://www.udemy.com/course/mobile-game-godot/

おすすめ度:★★★★★
学習時間:10時間

上記のKaan Alparと同じチュートリアルです。
Android、iOSのモバイルゲームを作ることができます。
こちらもかなり丁寧に解説されているので、オススメです。
メニューなどのUIやショップで購入機能を実装できます。
Android、iOSでテストや公開方法など、迷うこと無くすすめることができます。

Godot 4 Shaders: Write 2D shaders for your game from scratch

https://www.udemy.com/course/complete-godot-4-2d-shader-course-visual-effects-part-1/

おすすめ度:★★★☆☆
学習時間:6時間

すでに構築済みのプロジェクトにシェーダーを追加していきます。
ダメージエフェクトや炎エフェクト、フラッシュなど学べます。
上級者向けの内容になっていますが、自分のプロジェクトにちょっとしたエフェクトを加えるときの参考になります。

はる@フルスタックチャンネルはる@フルスタックチャンネル

YouTube

Brackeys

UnityからGodotに移行したというアナウンスがありました。
BrackeysがGodotに移行となったので、私も移行という流れです。
https://www.youtube.com/@Brackeys

Heartbeast

https://www.youtube.com/@uheartbeast

GDQuest

https://www.youtube.com/@Gdquest

CyberPotato

https://www.youtube.com/@CyberPotatoDev

DevWorm

https://www.youtube.com/@dev-worm

Godotneers

https://www.youtube.com/@godotneers/

Brett Makes Games

https://www.youtube.com/@brettmakesgames

Battery Acid Dev

https://www.youtube.com/@BatteryAcidDev

Chris' Tutorials

https://www.youtube.com/@ChrisTutorialsYT

BornCG

https://www.youtube.com/@BornCG

Le Lu

Effect関係
https://www.youtube.com/@Le_x_Lu

Kaan Alpar

神チュートリアル
https://www.youtube.com/@KaanAlpar/videos

WisconsiKnight

Tipsが分かりやすい
https://www.youtube.com/@WisconsiKnight

LittleStryker

プラットフォーマー参考に
https://www.youtube.com/@LittleStrykerID/videos

Nathan Hoad

Dialog Managerの学習に
https://www.youtube.com/@nathan_hoad/videos

DashNothing

Tipsが分かりやすい
https://www.youtube.com/@DashNothing/videos

16BitDev

参考になる
https://www.youtube.com/@16bitdev/videos

はる@フルスタックチャンネルはる@フルスタックチャンネル

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を使用することができます。
https://godotshaders.com/

ゲームメーカーズ(godot記事)

https://gamemakers.jp/?s=godot

Easing CheetSheet

Tweenはよく使います。
https://easings.net/en

GDScriptリファレンス

https://docs.godotengine.org/en/stable/tutorials/scripting/gdscript/index.html

Piskel

ドット絵の編集に使用します。
https://www.piskelapp.com/

GameUIDatabase

ゲームUIの参考になります。
https://gameuidatabase.com/index.php

はる@フルスタックチャンネルはる@フルスタックチャンネル

Tips

共通テーマ

UIを設定したテーマを作成して、プロジェクト設定のテーマのカスタムに設定しておく

メニュー

下記のように設置すると、キレイなUIができる
MarginContainer
|- PanelContainer
|- MarginContainer
|- VBoxContainer
|- Label
|- Button

Androidエクスポート

こちらのドキュメント通りに設定
https://docs.godotengine.org/en/stable/tutorials/export/exporting_for_android.html
Java SDK Pathは、jdkをインストールした場所を指定

スマホ傾き検知

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で購入する機能の実装

https://docs.godotengine.org/en/stable/tutorials/platform/android/android_in_app_purchases.html

godot-google-play-billingライブラリを使用
https://github.com/godotengine/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)
		
はる@フルスタックチャンネルはる@フルスタックチャンネル

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)です。PLAYERENEMYという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)

この関数は、新しい弾を生成し、設定を行った後、シーンツリーに追加します。具体的には以下の手順を踏んでいます:

  1. 指定されたkeyに基づいて、新しい弾のインスタンスを生成する。
  2. 生成した弾に対して、方向、寿命、速度を設定する(このsetup関数は別途定義されていると仮定)。
  3. 弾の初期位置を設定する。
  4. 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()
  1. 経路の子ノードを _paths_list に格納します。
  2. 経路の数を出力します。
  3. 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
  1. 指定された敵タイプのシーンをインスタンス化します。
  2. その敵に速度とアニメーションを設定します。
  3. 敵のインスタンスを返します。

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])
  1. ウェーブ数が経路の数で割り切れる場合(つまり、全経路を使い切った場合)、速度を増加し、間隔を短縮します。

start_spawn_timer 関数

スポーンタイマーを開始します。

func start_spawn_timer() -> void:
	spawn_timer.wait_time = _wave_gap
	spawn_timer.start()
  1. タイマーの待機時間を _wave_gap に設定します。
  2. タイマーを開始します。

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
  1. 経路リストの範囲内でランダムなインデックスを生成します。
  2. 直前のインデックスと同じ場合は再生成します。
  3. 最後に使用したインデックスとして _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()
  1. ランダムな経路を取得します。
  2. ランダムな敵タイプを選択します。
  3. 選択した敵タイプのアニメーションとデータを取得します。
  4. 指定された範囲内でランダムな数の敵を生成し、指定した経路に追加します。
  5. 敵の出現間隔を待機します。
  6. ウェーブが完了したら、ウェーブカウントを増加し、次のウェーブまで待機します。
  7. 速度と間隔を更新し、スポーンタイマーを再開始します。

_on_spawn_timer_timeout 関数

タイマーがタイムアウトしたときに呼び出される関数で、新しいウェーブを生成します。

func _on_spawn_timer_timeout():
	spawn_wave()
  1. 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
  1. turn(delta): ミサイルをプレイヤーの方向に回転させます。
  2. 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())
  1. _player_ref が有効かどうかチェックします。
  2. 有効であれば、ミサイルの位置からプレイヤーの位置への角度をラジアンで取得し、度に変換して返します。

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
  1. プレイヤーに対する角度 (angle_to_player) から現在のミサイルの回転角度を引きます。
  2. それに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
  1. get_angle_to_player() を呼び出してプレイヤーに対する角度を取得します。
  2. get_angle_to_turn(angle_to_player) を呼び出してプレイヤーに向かうための回転角度を計算します。
  3. 回転角度の絶対値が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.ynoise_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;
}
  1. 血のテクスチャの取得

    vec3 blood_tex = texture(blood_grad, uv).rgb;
    
    • blood_tex: 血のグラデーションテクスチャをUV座標でサンプリングし、色を取得します。
  2. ダメージ進行度の取得

    float curved_dmg_progress = texture(
        blood_curve, 
        vec2(dmg_progress, 1.0)).r;
    
    • curved_dmg_progress: ダメージ進行度をカーブテクスチャでサンプリングし、進行度に応じた値を取得します。
  3. ノイズテクスチャの取得

    float noise_r = texture(blood_n, uv).r;
    
    • noise_r: ノイズテクスチャをUV座標でサンプリングし、赤チャネルの値を取得します。
  4. 血と元の色の混合

    vec3 damage_tex = mix(blood_tex, c.rgb, noise_r);
    damage_tex.rgb = mix(c.rgb, damage_tex.rgb, curved_dmg_progress);
    
    • damage_tex: 血のテクスチャと元の色をノイズ値に基づいて混合し、さらにダメージ進行度に基づいて再度混合します。
  5. 赤い色味の追加

    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: ダメージの進行度に応じて赤い色味を追加します。
  6. 最終色の設定

    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 関数を呼び出してダメージエフェクトを適用。

このシェーダーにより、敵キャラクターは呼吸アニメーションを行い、ダメージを受けた際にはフラッシュし、視覚的にダメージを受けていることを示します。

はる@フルスタックチャンネルはる@フルスタックチャンネル

ナビゲーション

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() を呼び出します。

まとめ

  1. プレイヤーがアイテムをピックアップすると pick_up.gdon_pickup シグナルが発信されます。
  2. level_map.gd でこのシグナルを受け取り、ピックアップされたアイテムの数をカウントします。
  3. すべてのアイテムがピックアップされたら、on_show_exit シグナルが発信され、exit.gd のゴールが表示されます。
  4. プレイヤーがゴールに到達すると、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() 関数を呼び出して弾を発射します。

全体の流れ

  1. 敵キャラクターがプレイヤーを追跡中にタイマーがタイムアウト:

    • _on_shoot_timer_timeout() が呼び出され、敵が ENEMY_STATE.CHASING 状態であれば shoot() 関数を実行します。
  2. 弾の発射:

    • shoot() 関数でプレイヤーの位置をターゲットとして弾をインスタンス化し、初期化します。
    • 弾をシーンツリーに追加し、発射サウンドを再生します。
  3. 弾の移動と衝突:

    • 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
    • 当たり判定
    • 回復アイテム
はる@フルスタックチャンネルはる@フルスタックチャンネル

プロセスモード

NodeProcessModePROCESS_MODE_ALWAYSに設定すると、そのノードは常に処理を行います。具体的には、SceneTreepausedプロパティの値に関係なく、毎フレームごとにノードの_process_physics_process、および関連する処理関数が呼び出されます。

ProcessModeのオプション

  • PROCESS_MODE_INHERIT: 親ノードのprocess_modeを継承します。ルートノードの場合はPROCESS_MODE_PAUSABLEと同じになります。これは、新しく作成されたノードのデフォルトです。
  • PROCESS_MODE_PAUSABLE: SceneTree.pausedtrueのときに処理を停止します。これはPROCESS_MODE_WHEN_PAUSEDの逆です。
  • PROCESS_MODE_WHEN_PAUSED: SceneTree.pausedtrueのときにのみ処理を行います。これは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に設定されたノードは、以下のような状況でも処理を続けます。

  1. ゲームが一時停止中でも処理を続ける:

    • 通常、ゲームが一時停止されると、PROCESS_MODE_PAUSABLEに設定されたノードは処理を停止します。しかし、PROCESS_MODE_ALWAYSに設定されたノードは、SceneTree.pausedtrueになっても処理を続けます。
  2. 常に呼び出される処理:

    • この設定により、ゲームが一時停止されても必要なアニメーション、音楽、またはその他の持続的なアクションを維持することができます。

具体的な使用シナリオ

  • バックグラウンドミュージックやサウンドエフェクトの再生:

    • ゲームが一時停止している間も、音楽や特定のサウンドエフェクトを継続して再生する場合に有用です。
  • 非ゲーム要素の更新:

    • ユーザーインターフェースやデバッグ情報の表示など、ゲームの進行とは独立して更新される必要がある要素に対して使用されます。
  • ネットワーク通信の管理:

    • ゲームが一時停止している間も、サーバーとの通信を維持する必要がある場合に有効です。

このように、PROCESS_MODE_ALWAYSを使用することで、ゲームが一時停止している間も特定のノードの処理を継続することができます。

マウスフィルター

MouseFilterは、GodotのGUIコントロールでマウスイベントをどのように処理するかを指定するためのプロパティです。以下に各オプションの詳細と、どのような場合に使用するかを解説します。

MouseFilterのオプション

  1. MOUSE_FILTER_STOP:

    • 説明: コントロールはマウス入力イベント(移動、クリック)を受け取り、_gui_input()メソッドを通じて処理します。また、mouse_enteredおよびmouse_exitedシグナルも受け取ります。これらのイベントは自動的に処理済みとしてマークされ、他のコントロールには伝播しません。他のコントロールでのシグナル発火もブロックします。
    • 使用例:
      • ボタンやスライダーなどのインタラクティブなコントロール: ユーザーの操作を確実にキャッチし、他のUI要素への影響を防ぎたい場合に使用します。
      • ポップアップメニューやダイアログ: クリックイベントが他のUI要素に伝播しないようにするために使用します。
  2. MOUSE_FILTER_PASS:

    • 説明: コントロールはマウス入力イベントを受け取り、_gui_input()メソッドを通じて処理します。また、mouse_enteredおよびmouse_exitedシグナルも受け取ります。このコントロールがイベントを処理しなかった場合、親コントロール(存在する場合)がイベントを受け取ります。親コントロールも処理しなかった場合、さらに上のコントロールに伝播します。最終的に、Node._shortcut_input()にイベントが渡されます。
    • 使用例:
      • 重なったUI要素の中で特定の要素に優先的に処理させたい場合: 例えば、ツールチップやカスタムカーソルを表示するためのコントロール。
      • 子コントロールが親コントロールと連動して動作する場合: 例えば、リスト項目の選択とスクロールバーの連動。
  3. MOUSE_FILTER_IGNORE:

    • 説明: コントロールはマウス入力イベントを受け取らず、_gui_input()メソッドも呼び出されません。また、mouse_enteredおよびmouse_exitedシグナルも受け取りません。この設定は他のコントロールがこれらのイベントを受け取ることをブロックしません。無視されたイベントは自動的には処理されません。
    • 使用例:
      • 装飾目的のコントロール: マウス入力を必要としない背景画像や装飾用のUI要素。
      • 透過的なUI要素: マウス入力をブロックせずに、背後の要素にイベントを渡したい場合。

具体例

  1. MOUSE_FILTER_STOPの使用例:

    • ボタン: ボタンをクリックした際に、他の背後にあるコントロールがそのクリックイベントを受け取らないようにする。
      button.mouse_filter = Control.MOUSE_FILTER_STOP
      
  2. MOUSE_FILTER_PASSの使用例:

    • カスタムツールチップ: 特定のエリアにマウスが乗ったときにツールチップを表示し、そのエリアがイベントを処理しない場合は親コントロールに処理を渡す。
      tooltip_area.mouse_filter = Control.MOUSE_FILTER_PASS
      
  3. MOUSE_FILTER_IGNOREの使用例:

    • 背景画像: 背景画像がマウス入力を受け取らず、背後の他のコントロールがイベントを受け取るようにする。
      background_image.mouse_filter = Control.MOUSE_FILTER_IGNORE
      

まとめ

  • MOUSE_FILTER_STOP: コントロールがマウスイベントを完全に処理し、他のコントロールに伝播しないようにする場合に使用します。
  • MOUSE_FILTER_PASS: コントロールがイベントを処理しない場合に、親コントロールに伝播させたい場合に使用します。
  • MOUSE_FILTER_IGNORE: コントロールがマウスイベントを無視し、他のコントロールにイベントを伝播させたい場合に使用します。

これにより、Godotでのマウスイベント処理の制御を細かく設定できるようになります。

はる@フルスタックチャンネルはる@フルスタックチャンネル

ステートマシン

メリット

  1. 可読性の向上:

    • ステートマシンを使用することで、キャラクターの異なる状態(例:待機、移動、ジャンプ、攻撃など)を個別に管理でき、コードの可読性が向上します。
    • 各状態ごとに専用のスクリプトを持たせることで、状態ごとの処理が明確になります。
  2. 保守性の向上:

    • 各状態ごとに処理を分割することで、特定の状態に関する変更が他の状態に影響を与えにくくなります。
    • 新しい状態を追加する場合でも、既存のコードを変更する必要が少なくなります。
  3. バグの発見と修正が容易:

    • ステートマシンでは状態ごとの処理が独立しているため、特定の状態に関するバグの発見と修正が容易です。
    • デバッグがしやすく、バグの影響範囲が限定されます。
  4. 拡張性の向上:

    • 新しい状態や動作を追加する際に、既存のコードを大きく変更することなく、容易に拡張できます。
  5. 再利用性の向上:

    • 状態ごとの処理が独立しているため、他のプロジェクトやキャラクターでも再利用が容易です。

チュートリアル: ベーシックなプレイヤーの動きのステートマシンを作成

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. シーン設定

  1. CharacterBody2DでPlayerを作成し、player.gd をアタッチします。
  2. NodeでStateMachineを Player の子として追加し、state_machine.gd をアタッチします。
  3. Nodeで作成したIdleState, RunStateStateMachine の子として追加し、それぞれ対応するスクリプトをアタッチします。
  4. 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")

効率化のポイント

  1. 初期化の集中化:

    • CharacterBody2Dreadyメソッドでstate_machine.initialize(self, animation_player)を呼び出して、すべての必要な情報を一度に設定しています。
  2. 自動設定:

    • 各ステートがcharacteranimationを直接参照することで、各ステート内での設定の手間を省いています。
  3. ステートの独立性:

    • 各ステートが独立して動作し、他のステートや親ノードに依存しないようになっているため、コードの可読性とメンテナンス性が向上します。

この方法で、各ステートが必要な情報に直接アクセスできるため、コードがシンプルかつ効率的になります。

はる@フルスタックチャンネルはる@フルスタックチャンネル

StringNameString の違いは、主に効率性と用途にあります。

StringName

StringNameString に似たクラスですが、文字列を内部で効率的に管理するための特別な機構が含まれています。主に以下のような特徴があります:

  1. 高速な比較StringName は内部的に文字列をハッシュテーブルで管理しているため、比較操作が非常に高速です。これは、大量の文字列比較が頻繁に行われる状況で特に有用です。

  2. 低いメモリ消費StringName は重複する文字列を効率的に管理するため、同じ文字列が多数存在する場合でもメモリ消費が少なくなります。

  3. 用途: 主にノード名、リソース名、シグナル名などの特定の用途で使用されます。これらの用途では、文字列の比較が頻繁に行われ、パフォーマンスの向上が重要です。

String

String は一般的な文字列クラスで、文字列データを保持し、操作するための多くのメソッドが提供されています。以下のような特徴があります:

  1. 汎用性: 一般的な文字列操作(連結、検索、分割など)に使用されます。

  2. 操作の豊富さ: 文字列の操作に関する多くのメソッドが用意されており、さまざまな文字列処理が可能です。

  3. 用途: テキストデータ全般に使用され、特に頻繁な比較や特定の名前管理が必要ない場合に適しています。

使用例

例えば、シグナルの宣言で StringNameString を使い分けると、以下のようになります:

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の基本的な使い方

  1. シンプルな例: ボタンをクリックした後、ラベルのテキストを変更しますが、変更を次のフレームまで遅延させます。
# 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!"

解説

  1. シーンセットアップ:

    • MainSceneという名前のシーンを作成します。
    • シーンにButtonLabelを追加します。
  2. スクリプトのアタッチ:

    • MainSceneに上記のスクリプトをアタッチします。
  3. _readyメソッド:

    • _readyメソッドはノードがシーンに追加された直後に呼ばれます。このメソッドで、ボタンのpressedシグナルに対して_on_button_pressedメソッドを接続します。
  4. ボタンが押された時の処理:

    • ボタンが押されたときに呼び出される_on_button_pressedメソッドでは、call_deferredを使用して_update_label_textメソッドの呼び出しを次のフレームまで遅延させます。
  5. _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)

解説

  1. シーンセットアップ:

    • Spawnerという名前のシーンを作成します。
  2. スクリプトのアタッチ:

    • Spawnerに上記のスクリプトをアタッチします。
  3. 敵の生成:

    • _readyメソッドで1秒の遅延後にspawn_enemyメソッドをcall_deferredで呼び出します。
  4. 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()

メリット

  1. 簡単な移動と衝突管理:

    • CharacterBody2Dにはmove_and_collidemove_and_slideのようなメソッドがあり、移動と衝突の管理を簡単に行えます。弾丸が壁や敵に当たったときに消えるといった基本的な動作を、比較的少ないコードで実装できます。
  2. 滑らかな衝突判定:

    • 弾丸の衝突判定が滑らかで、特に斜めの壁や動いている物体との衝突判定が精密です。
  3. 移動方向の制御が容易:

    • 弾丸の速度や方向の変更が容易です。例えば、弾丸が特定の方向に移動するように、velocityを簡単に設定できます。

デメリット

  1. パフォーマンスの低下:

    • CharacterBody2Dは物理エンジンでの衝突検出や応答を管理するための多くのオーバーヘッドを持っています。弾丸のように大量に生成されるオブジェクトには向いていません。弾丸が大量に存在するシーンでは、CharacterBody2Dを使用することでパフォーマンスが低下する可能性があります。
  2. 過剰な機能:

    • 弾丸のようなシンプルなオブジェクトに対して、CharacterBody2Dは過剰な機能を持っています。弾丸は単純に直線的に移動し、何かに当たったら消えるという動作が多いため、CharacterBody2Dの重力や摩擦などの特性は不要です。

代替案

  1. RigidBody2D:

    • 弾丸を物理的な動作で制御したい場合にはRigidBody2Dを使用するのが適しています。RigidBody2Dは物理的な挙動(重力や反発など)を持ち、弾丸のようなオブジェクトがリアルに動作するように制御できます。
  2. Area2D:

    • 弾丸が特定の範囲内で衝突を検出するだけの場合にはArea2Dが適しています。Area2Dは物理的な挙動を持たず、衝突検出に特化しています。これにより、パフォーマンスが向上します。

結論

CharacterBody2Dを弾丸オブジェクトに使用することは可能ですが、通常はRigidBody2DArea2Dの方がより適切です。弾丸の挙動が物理エンジンの特性を必要とする場合は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プラットフォーマー

https://youtu.be/us3BMH7hvyw

今回は、Godotを使ってシンプルな2Dプラットフォーマーゲームを作る方法を学びます。
ゲーム制作が初めての方でも安心して進められるように、基礎から一歩ずつ丁寧に解説していきます。
このチュートリアルでは、キャラクターの操作やトラップの設置、スタート、ゴールの設定など、ゲームの基本的な要素を作成します。
進めるうちに、「自分だけのゲーム」を作る楽しさを実感していただけるはずです!
また、チュートリアルが完了した後は、さらにステップアップできるゲームプロジェクトもご用意しています。
今回作ったゲームに、新しいステージやトラップ、プレイヤーアニメーション、スコア機能などを追加して、あなた自身のオリジナルゲームを完成させましょう。
ゲームを作る楽しさは無限大!
ぜひ、このチュートリアルを通して、Godotの魅力に触れ、ゲーム制作の第一歩を踏み出してみてください。

■チュートリアル
https://zenn.dev/hathle/books/godot-platfomer-book

■Godot
https://godotengine.org/

■アセット
https://pixelfrog-assets.itch.io/pixel-adventure-1

■プロジェクト(チュートリアル、ステップアップ)
https://haruyasu.itch.io/godot-2d-platformer

■学習内容
・Godot の基本操作:プロジェクト作成からインターフェースの使い方を学習
・プレイヤーキャラクターの操作:ジャンプ、移動、アニメーションの制御
・トラップの設置:プレイヤーにダメージを与える仕掛けを実装
・スタートとゴールの設定:ゲームの開始地点とクリア条件の設定
・UI:メインメニューとゴール画面表示
・シーン管理:ステージの切り替えやメインメニューの作成

■機能
・プレイヤー移動:左右移動とジャンプ
・アニメーション:プレイヤーのアニメーション(待機、走行、ジャンプ、落下)を作成
・トラップシステム:トラップに触れた際にプレイヤーにダメージを与える機能
・スタートとゴールのシステム:ステージの開始地点とゴール地点の設定
・メインメニューとゴール画面:ゲーム開始とステージクリア後の画面作成