Unity勉強中!あこがれだったプログラマーに今からなろう!

昔あこがれだったプログラマー。今からでもUnityを勉強してみようと思い立ち、チャレンジ開始! Unity勉強メモや、悪戦苦闘な日々の記録です。

Unity経験者によるGodot実践メモ:GameManagerっぽいものについて

ゲームエンジンです。
ゲームエンジンはやっぱり、ゲームを作っていかないと勉強にはならないので、Unityで作成したゲームをGodotで作成しようと思います。
作成するゲームは、これです。

youtube.com

Unityroomの1WSSで作成した、ColorChangePanicをGodotでもう一回最初っから作成してみようと思います。

当時の記憶を呼び覚ましながら、Unityのプロジェクトを確認してみると、
敵はObjectPoolクラスから、EnemyGeneratorクラスにより生成され、
敵自身のEnemyControllerクラスのSetParameter関数とSet EnemyColor関数で初期設定される仕組みです。
生成のタイミングや敵の色、敵の動きの速さを決める設定はGameManagerクラスが管理しています。

ここまで記憶を辿ってみて、いきなりGodotのチュートリアルだけでは越えられない壁が3つある事に気づきました😅

①. ObjectPoolはUnityの組み込み機能で作成されているので、そのままGodotに移植する方法が判らない。
②. GameManagerをGodotで作成する方法が判らない。
③.  UniTaskの様にasync/awaitが可能なのか判らない。
・・・🤣

まぁ、まだ勉強し始めのシステムなので、細かいことは気にせず、とりあえず簡単そうなGameManagerの作り方を考えてみます。

Game Managerといってもやっているのは、各クラスで共用する変数の管理くらいです。
Godotの変数は基本的には、宣言したスクリプトの中でしか使えないprivate変数なのですが、
調べてみると、他クラスで共有できるGlobal変数を作成する方法もあるにはありそうです。

他シーンとデータを共有するリソースという考え方があるようで、それらしい記事を見つけることができました。
1-notes.com

この記事を参考にGameManagerっぽいものを作成してみます。
まず、Unityでいう「空のオブジェクト」に相当するらしい、Node(何の機能もない、ただのNode)を作成し、
その名前をGameManagerにします。

ここに共用する変数を宣言したスクリプトをアタッチします。

extends Node

@export var Set_Color_Change_Span :float = 0.1 #敵の色が変わるスピード

@export var Game_Level :int = 1 #ゲームのレベル

@export var Set_Drop_Down_Speed :float =0.1 #敵が落ちるスピード


var Colors:Array[Vector3] = [Vector3(0.8867924,0.3304556,0.364517),
							Vector3(0.3941794,0.4784876,0.8113207),
							Vector3(0.8679245,0.8367506,0.2824848),
							Vector3(0.3406906,0.8301887,0.4169851),
							Vector3(1,.9992402,0.9858491)] #色のセット

public変数という概念がない様なので、ここでもprivate変数の様に宣言してもいいみたいです。

ちょっと話はそれますが、GDScriptは{ }の代わりに、インデントという空間で境界を判断している仕組みです。
なので、長すぎる行を途中で改行して見やすくすることはできないと誤解していましたが、
今回の例の様に、問題なく途中改行できる様ですね。😃

ここまでできたら、このGameManagerをシーンとして保存します。
そして、Godotエディタからプロジェクトプロジェクト→プロジェクト設定
そして、開いたプロジェクト設定の画面で作成したGamaManagerシーンを登録し、グローバル変数有効にチェックを入れます。


これでGameManagerの変数はGlobal変数として使用可能になった様です。



では、早速実験です。
Area2Dノードをコアノードにして、CollisonSharpe2DとSprite2Dを子ノードにして、Enemyシーンを作成。

試しに、

extends Area2D

var Enemy_Color :int #敵の色
@export var TestInt :int

func _ready():
	print("Set_Drop_Down_Speed ",GameManager.Set_Drop_Down_Speed)#Game Managerの変数の変更前を確認
	GameManager.Set_Drop_Down_Speed=0.2#GameManagerの変数を変更する実験
	_Set_Enemy_Color(TestInt)


func _Set_Enemy_Color(color:int):#intを引数で受け取る。
	Enemy_Color=color
	$Sprite2D.modulate=Color(GameManager.Colors[Enemy_Color].x,GameManager.Colors[Enemy_Color].y,GameManager.Colors[Enemy_Color].z,1)
	print("Set_Drop_Down_Speed ",GameManager.Set_Drop_Down_Speed)#Game Managerの変数が変更できているか確認

というスクリプトを作成し、Area2Dノードにアタッチします。

Unityだと関数側の引数の指定方法は

Set_Enemy_Color(int color)

ですが、Godotの場合は変数名が先なので、慣れるまで間違いそうです😅

func _Set_Enemy_Color(color:int):#intを引数で受け取る。

そして、Godotの場合、スクリプトがアタッチされているノードの子ノードのプロパティは基本的には宣言無しに使用できる様です。

今回の場合、色を変えたいSprite2Dノードは、スクリプトがアタッチされているArea2Dノードの子になっているので、
Area2Dのスクリプトは、Sprite2Dノードのプロパティを変数に取り込んだりせずに使用可能です。

Unityの場合はそれでもGetComponentが必要ですが、Godotの場合は、そのノードのパスさえ判れば問題ない様です。
今回のスクリプト

$Sprite2D.modulate=Color(GameManager.Colors[Enemy_Color].x,GameManager.Colors[Enemy_Color].y,GameManager.Colors[Enemy_Color].z,1)

$Sprite2Dは、get_node("Sprite2D") の略式の記載方法で、シーン内から「Sprite2D」という名前のノードを探してきます。
見つかったパスを使って、Sprite2D→modulate→colorに、先ほど作成してきたGameManagerのColors変数から指定のVector3を受け取って設定する寸法です。

また、同じようにGameManagerの変数を変更することができるか、

	GameManager.Set_Drop_Down_Speed=0.2#GameManagerの変数を変更する実験

Ready関数で実験しています。
変数変更の次の行の

	print("Set_Drop_Down_Speed ",GameManager.Set_Drop_Down_Speed)#Game Managerの変数の変更前を確認

は、UnityでいうDebug .logに当たる処理で、コンソールに指定した文字を書くことができます。

結果ですが、
youtu.be
インスペクターで指定したTestIntの数値に沿って、GameManagerからきちんと色を取得できていている様子です。
GameManagerの変数も問題なく変更することができました。

これで、どうやら、UnityのGameManagerの作成は大丈夫そうです。

Unity経験者によるGodot実践メモ:インプットマップとノードの種類について

Godotの入力の取得の仕方は、UnityのInputSystemにとてもよく似ています。

「プロジェクト設定」の中に「インプットマップ」というものが用意されています。


この図の様に、デフォルトで色々な設定がされています。

この辺り、UnityのInputSystemのUI設定に似たところがあるように思いました。
もちろん、この図の様に自分流のアクションを登録することも簡単にできます。

しかも、スクリプトで利用する時、Unityの様に専用クラスを作ったり、インスタンス化したりという手間は必要ありません。
何も追加しなくても、

func _physics_process(delta):
	if Input.is_action_pressed("move_right"): #このmove rightというのが、インプットマップで設定されたアクション。

この様に簡単に利用できます。

このあたり、Godotは「インプットマップ」を本気で主流にしようという意気込みが感じられました😆


次に、実際にGodotでゲームを作成するにあたって、大元になるノードについて纏めておこうと思います。

Unityではプロジェクトを作成するタイミングで、2Dか3Dかを選びますが、Godotはプロジェクトを作成する時にそのような選択はありません。
ゲームの材料になるシーン、UnityでいうとGameObjectを作成するときに、その元になるノードを選ぶ段階で2Dか3Dか選ぶイメージです。

2Dゲームを作る場合、2Dキャラを作成する元になるノードはArea2Dか、CharacterBody2Dのどちらかをコアに選ぶ様です。

Area2Dは子ノードとして、CollisionShape2Dノードもしくは、 CollisionPolygon2Dノードの様なCollision機能を持つノードが組み込まれることを前提とした作りになっていて、
デフォルトのシグナルとして、area_enteredとarea_exitedというシグナルが設定されています。
このシグナルは、上記のCollisionに他のObjectが侵入すると、area_enteredが発報し、Objectがエリアを離れるとarea_exitedが発報します。

ちょうどUnityの、OnCollisionEnterとOnCollisionExitの様な関係に似ています。

もう一つの選択肢であるCharacterBody2Dノード は、ユーザーが制御することを目的とした物理ボディに特化したノードで、
PhysicsBody2D ノードと、CollisionObject2Dノードを継承しており、キャラクターを移動させるのに便利なmove_and_slide関数を使うことができる様です。


3Dゲームを作る場合はまだ調べきれていませんが、CharacterBody3Dというノードがあるので、これをコアにするイメージみたいです。

次回から、早速、Unityで作成したゲームの移植に挑戦してみようと思います。

Unity経験者によるGodot実践メモ:変数とスクリプト・処理ルーチンについて

GodotとUnityの変数は宣言の仕方がちょっと違います。
Unityであれば、

private int a=3;

の様に、アクセス修飾子、変数の型、変数名と書いて宣言しますが、Godotの場合は、

var c : int	

の様に、varで宣言し、変数名の後ろに型を書きます。
そして、Unityの様にインスペクターで変更できるようにするには、publicではなく、

@export var max_speed:int = 18

の様に、頭に@exportをつけます。
注意しないといけないのは、@exportはあくまでインスペクターから編集できる様になるだけで、Unityのpublicの様に、
これだけで他のクラス(Godotで言えばシーン)と共有できる変数にはなりません。
Godotで他のシーンと変数を共有するには、Globalシーンを作って、そこで宣言したGlobal変数を作るか、引数で他のシーンに渡すかなどの工夫が必要みたいです。

また、Unity経験者にとってちょっとびっくりなのが、list型というのは存在しない様です。
配列型で代用する感じなのでしょうか。
この辺りはこれからゆっくりと学んでいこうと思います。

そして、スクリプトの基本的な構造もかなり違います。
Unityの様なクラス名の宣言はありません。
ファイル名がそのままクラス名になるようで、その名前の宣言すら省略されてしまったということでしょうか。
確かに、Unityでもファイル名とクラス名が違っているとエラーになるので、ならば最初から2箇所に書く必要ない!という事なのかもしれません🤣

最初の1行目に書くのは、このクラスが継承するものになります。

Unityの場合、

awake:GameObjectのインスタンス直後
start:Updateの直前
Update:毎フレーム実行(厳密には違う)
FixedUpdate:一定間隔で実行(厳密には違う)(物理関連の処理タイミング)

という処理順です。

Godotの場合、
_init:ノードのインスタンス直後
_ready:シーン内の全ノードが初期化された直後
_process:毎フレーム実行
_physics_process:一定間隔で実行(物理関連の処理タイミング)

になります。

変数名や関数名には命名規則があり、基本的には関数名は_から始まる様です。
また、単語と単語の間に_をいれ、「array_change_key_case」の様に記載します。
これは絶対ルールではない様ですが、エンジンが作成する関数名などが標準的に、この表記になるので、慣れの問題かもしれません。

スクリプト中のコメントは//ではなく、

# これはコメントです。

の様に#をつけます。

extends CharacterBody3D

@export var speed = 14
@export var fall_acceleration = 75
@export var jump_impulse = 20
@export var bounce_impulse = 16

var target_velocity = Vector3.ZERO

func _physics_process(delta):
	# We create a local variable to store the input direction
	var direction = Vector3.ZERO

	# We check for each move input and update the direction accordingly
	if Input.is_action_pressed("move_right"):
		direction.x = direction.x + 1
	if Input.is_action_pressed("move_left"):
		direction.x = direction.x - 1

最初の行でextednsで継承するクラスを指定します。
ちなみに、このCharacterBody3DはPhysicsBody3Dを継承している様です。

次に変数の宣言があるのは、似たイメージですが、関数の宣言の仕方が違います。
func 関数名:で関数を宣言します。
そして、Unityの様に{} は使いません。
代わりに、インデントを使う様です。

func _physics_process(delta):
	# We create a local variable to store the input direction
	var direction = Vector3.ZERO

この行の頭がインデントという空白で、GDScriptではこのインデントが同じ間はUnityでいう{}の範囲とみなす様です。
このあたり、Pythonを知っている人は馴染み深いそうですが、残念ながら僕はPythonを知らないので、
最初は違和感だらけでした🤣
そして、行の後ろに;も入りません。
楽といえば、楽なんですが、これも慣れるまでは違和感でした。

あと、Unityではpublic関数のように他クラスから使用可能な関数を作れますが、Godotではこういう概念はない様で、
インスタンス化されたシーンのスクリプトにある関数は、rootシーンからのパスが分かれば実行可能な様です。

Unity経験者によるGodot実践メモ:初期インストールと4大要素について

今日からちょっとGodotで遊んでみようと思っています。

Unityは確かに素晴らしいゲームエンジンなのですが、できたゲームがちょっと重い感じがしたので、
(まだ自分のスキルが未熟なだけですが😅)
とにかく軽いと噂のGodotをちょっと覗いてみたくなったわけです🤣

まずはとにもかくにも最初はインストールです。

噂によるとSteam版とstandardと2種類あるそうですが、Steam版は見つけられなかったので、おとなしくStandard版をインストールします。
ありがたいことにMac対応しているので、Mac用のHPからダウンロードです。
godotengine.org
Windows用はこちらです。)

最初の選択肢で、プログラムに使用する言語を選択するイメージです。

Unity経験者としては、C#を使う.NET版にしたいところでしたが、事前に調べた感じでは、GodotをやるのであればGDScriptの方がメリットがあるようですので、おとなしくGDScriptを選びます。
UnityではLTS版を選びたいものですが、Godotにおいては、それもちょっと違うようです。
しかも、どうやらGodot3.*.*とGodot4.*.*で色々と様変わりしているようで、これから学ぶのであれば、これも大人しくしGodot4を選ぶ方が良いようです。

ダウンロードもあっという間、インストールもあっという間です😳

ここからは、あまりしっかり書かれている記事が少なかったので、備忘録として記載しておきますが、
Unityであれば専用フォルダがこの時点で作成されますが、Godotはそれは作成されません。
なので今の段階で、専用フォルダを作成しておくのがお勧めです。

また、GodotはUnity HUBの様な複数のバージョンを管理するツールが存在しません。
なので、MacであればアプリケーションにそのままGodotを放り込みたいところですが、自分でフォルダ分けしてバージョン保管しておいた方が良いです。

僕の場合は、個人フォルダの直下にGodotフォルダを作成し、その中にバージョン数のフォルダを作成して、その中に保存しておくことにしました。

さて、肝心のGodotEngineですが、Unityとは設計が異なるので、何も判らないままではちょっと理解が大変です。
ざっくり調べてみると、Godotには4大要素というものがあり、これを何となくでも良いので理解しておくことが大切な様です。

Godotにおける4大要素とは、
・Nodes(ノード)
・Scenes(シーン)
・Scene Tree(シーンツリー)
・Signals(シグナル)
の4つです。

最初の1つはNodes(ノード)です。
ノードはゲームを構成する最小単位です。
こう書くと、UnityでいうGameObjectの様に思えますが、GameObjectとはちょっと違います。
簡単にいうと、Unityのコンポーネントが独立したイメージでしょうか。

たとえばUnityでCubeというGameObjectを作ると、そこにはデフォルトでTranform、Mesh Filter、Mesh Renderer、Box Colliderといったコンポーネントが搭載されています。
このそれぞれのコンポーネントがノードになります。

そして、ここからがUnityのコンポーネントとGodotのノードの違いですが、Godotはこのノードに子ノードを組み合わせて、目的の機能を作っていきます。


これは公式のチュートリアルで作成したものですが、この1つ1つがノードになります。
ノードにはそれぞれプロパティがあり、スクリプトをアタッチすることもできます。
ただ、Unityとは異なり、1つのノードには1つのスクリプトしかアタッチできない様です。


次はScenes(シーン)
Unityのシーンとはかなり意味が違う場合があります。「違う場合がある」というのは、すぐに分かります。
まず、基本的にGodotのシーンとは、ノードを複数組み合わせたものになります。
先ほどの図のMobはノードの組み合わせ、つまり、Mobシーンになります。
Unityのシーンを想像していると、ここですごく困惑しますよね😅
シーンというよりGameObjectです。

ただ、このままでは「違う場合がある」というか、全く違うものじゃないか!と言われそうですが、

この図の様なシーン。
これにはカメラというノード、Directional Lightというノード、User Interfaceというノードがあります。
こうなってくると、Unityのシーンのイメージに近くなってくるのではないでしょうか。

そして、次の要素であるScene Tree(シーンツリー)の説明にもなるのですが、
Mainシーンには、子シーンとしてPlayerシーンが追加されています。

つまりGodotのシーンとは、UnityのGameObjectであり、UnityのPrefabであり、Unityのシーンでもある様なものみたいです。
そして、このシーンのツリー構造こそが、Scene Tree(シーンツリー)です。


最後のSignals(シグナル)
シグナルとは、何か特定のイベントが起こった時、設定しておいた関数を実行するための仕組みです。
Unityにも欲しい、すごく便利な機能です。
たとえば、先ほどのMainシーンの中にMobTimerというノードがありました。
このTimer、これもUnityに欲しい機能ではありますが、まずはシグナルの説明です。

これはTimerのインスペクターです。


このTimerノードは、0.5秒毎にTimeoutというシグナルを発してくれます。

次に、このTimerのシグナルの設定で、このTimeoutで関数(_on_mob_timer_Timeout関数)に接続します。

これで0.5秒毎に、_on_mob_timer_Timeout関数が実行されます。
Godotでは、たとえば、ボタンもこのシグナルによって実行する関数を接続しています。

このシグナル、スクリプト内でオリジナルのシグナルを作成することもできますし、自分のタイミングでシグナルを発する事もできます。
もちろん、実行する関数をスクリプトを使って変更することもできます。


Godotではノードを組み合わせて目的のシーンを作成し、それらのシーンを組み合わせてゲーム世界やキャラクターを作成していき、
シグナルで各ノードのスクリプトを連動させることで、目的のゲームを完成するわけです。

3D脱出ゲーム:イベントシステムを作成しよう。

今日こそは、ゲームのメイン部分である、イベントの仕組みを作成していきます。
しまづさんの動画で、すでにDrag.csとDrop.cs、それをベースにアイテムをドラッグするためのDragObj.csと
カバンからアイテムをDragするためのDragObjUI.csを作成しているのですが、
単純にアイテムをクリックした時にメッセージ表示やBoolを変化させる、いわゆるゲームイベントの働きをする機能も欲しいと思ったので、
それを作成することにします。

せっかくなので汎用的に色々な機能を持たせたいと思いますので、まずは、代表的なイベントを定義します。
定義したイベントは3つ。
・メッセージを表示するイベント。
・アイテムを取得するイベント。
・flagを操作するイベント。
です。

イベントが決まったので、このイベントを定義するクラス。ObjEvent.csを作成します。

using System;

[Serializable]
public class ObjEvent
{
public enum Type
{
Message,//メッセージを表示するイベント
GetItem,//アイテムを取得するイベント
FlagSet,//Flagを操作するイベント
}

public Type type;//イベントの種類
public Item.Type item;//獲得するアイテムの種類
public String message;//メッセージ

public int True_messageNo;//Trueの時のメッセージNo、もしくは通常のメッセージNo
public int False_messageNo;//falseの時のメッセージNo

public int BoolNo;//FlagのNo
public bool EventOutcome;//イベントの結果でフラグに設定される値
public int SoundNo1;//イベントで鳴らすSEのNo
public int SoundNo2;//イベントで鳴らすSEのNo

public ObjEvent(Type type, Item.Type item,String message,int True_messageNo,int False_messageNo,int BoolNo,bool EventOutcome,int SoundNo1,int SoundNo2)
{
    this.type = type;
    this.item = item;
    this.message = message;
    this.True_messageNo = True_messageNo;
    this.False_messageNo = False_messageNo;
    this.BoolNo = BoolNo;
    this.EventOutcome = EventOutcome;
    this.SoundNo1 = SoundNo1;
    this.SoundNo2 = SoundNo2;
}
}

ObjEventの定義ができたので、次はアイテムにこのObjEventを設定するクラス、EventObj.csを作成します。
名前が似ているのは、センスのなさを感じますが、自分的には、これが一番しっくりきました😅

EventObjは、そのアイテムで発生するイベントを設定し、イベントを起動する働きを持たせます。
ただ、イベントを数多く作るときに、EventObj全部にイベント処理を実行する機能まで持たせるのは、ちょっと無駄なので、
EventObj.csはあくまでイベントの設定と、イベントの起動だけを行い、
イベントの処理は共通クラスのEvent Controllerで行うことにしました。

using UnityEngine;
using Cysharp.Threading.Tasks;

public class EventObj : MonoBehaviour, IEvent //ここに定義していないイベント用にIEventインターフェースも用意
{
[Header("イベント発動に必要な距離")][SerializeField] float EventDistance;
[SerializeField] private Transform SetPos;
[Header("クリックしたときのイベント")][SerializeField] ObjEvent Click_event;
[SerializeField]bool IsSerchEvent=false;//サーチモードでイベントを変えるかどうか
[Header("サーチした時のイベント")][SerializeField] ObjEvent Serch_event;

public void Event()
{
    Transform PlayerTransform=  GameObject.Find("Player").transform;
    GameManager.instance.beforeGameMode=GameManager.instance.gameMode;//ゲームモードを保存

    if(Vector3.Distance(PlayerTransform.position,transform.position)>EventDistance)//イベント発動に必要な距離以下か確認
    {
        if(GameManager.instance.gameMode==GameMode.Type.Normal || GameManager.instance.gameMode==GameMode.Type.Search)
        {
            GameManager.instance.gameMode=GameMode.Type.Message;//ゲームモードをメッセージモードに変更
            MessageManager.instance.ShowGetMessage(2).Forget();
        }
        return;//プレイヤーが近くにいない場合は処理を抜ける。
    }

    if(GameManager.instance.gameMode==GameMode.Type.Normal)
    {
        EventController.instance.Event(Click_event);
    }
    else if(GameManager.instance.gameMode==GameMode.Type.Search)
    {
        if(IsSerchEvent)
        {
            EventController.instance.Event(Serch_event);
        }
        else
        {
            EventController.instance.Event(Click_event);
        }
    }
}
}

EventObj.cs自身は単純なもので、ノーマルモードでクリックした時のイベントとサーチモードでクリックした時の
2つのイベントを格納しておき、それぞれのゲームモードに応じたイベントをEventControllerに送るだけの機能です。
その際、Playerとの距離を確認して、遠すぎる場合は近づくことを促すメッセージを表示するように作成しました。

メッセージ関係は全く別の仕組みのMessageManager.csで制御しています。
この時、メッセージウィンドウの開閉状態で、二重にメッセージを表示させようとするとおかしな動きをするため
ゲームモードをMessagaモードに変更し、この時にはイベント発動ができないように制限しています。

ここに定義しきれないイベントを作成するときに備えて、IEventというEventメソッドを保証するインターフェースも用意して、
次は、イベントを実際に処理するクラスEventContorollerです。

using UnityEngine;
using TMPro;
using DG.Tweening;
using Cysharp.Threading.Tasks;
using System.Threading;
using UnityEngine.UI;
using System;

public class EventController : MonoBehaviour
{
    ObjEvent.Type eventType=ObjEvent.Type.Message;
    Item.Type item;
    String message;

    //Trueの時のメッセージNoもしくは通常のメッセージ
    int True_messageNo;
    int False_messageNo;//falseの時のメッセージNo

    int BoolNo;//FlagのNo
    bool EventOutcome;
    int SoundNo;

    [SerializeField] ZoomPanel zoomPanel;
    public static EventController instance;

    private void Awake() 
    {
        instance=this;
    }

    public void Event(ObjEvent objEvent)
    {
        eventType=objEvent.type;
        switch(eventType)
        {
            case ObjEvent.Type.Message://メッセージを表示するイベント
                    GameManager.instance.beforeGameMode=GameManager.instance.gameMode;
                    GameManager.instance.gameMode=GameMode.Type.Message;
                    if(GameManager.instance.GameFlag[objEvent.BoolNo])
                    {
                        MessageManager.instance.ShowGetMessage(objEvent.True_messageNo).Forget();
                    }
                    else
                    {
                        MessageManager.instance.ShowGetMessage(objEvent.False_messageNo).Forget();
                    }

                break;

            case ObjEvent.Type.GetItem://アイテムを取得するイベント
                    if(GameManager.instance.GameFlag[objEvent.BoolNo])
                    {
                        GameManager.instance.gameMode=GameMode.Type.Message;
                        MessageManager.instance.ShowGetMessage(objEvent.True_messageNo).Forget();
                        return;
                    }

                    var GetItem = ItemGenerator.instance.Spawn(objEvent.item);
                    GameManager.instance.GameFlag[objEvent.BoolNo]=true;

                    //**将来ここで音を鳴らす
                    ItemBox.instance.SetItem(GetItem);
                    zoomPanel.ShowPanel(GetItem);

                    GameManager.instance.gameMode=GameMode.Type.Message;

                    var GetMessage=GetItem.ItemName+"を手に入れた";
                    MessageManager.instance.ShowMessage(GetMessage).Forget();

                break;

            case ObjEvent.Type.FlagSet://Flagを立てるイベント
                    GameManager.instance.beforeGameMode=GameManager.instance.gameMode;
                    GameManager.instance.gameMode=GameMode.Type.Message;
                    if(GameManager.instance.GameFlag[objEvent.BoolNo])
                    {
                        MessageManager.instance.ShowGetMessage(objEvent.True_messageNo).Forget();
                    }
                    else
                    {
                        //**将来ここで音を鳴らす

                        MessageManager.instance.ShowGetMessage(objEvent.False_messageNo).Forget();
                        GameManager.instance.GameFlag[objEvent.BoolNo]=objEvent.EventOutcome;
                    }

                break;

        }
    }
}

テストしながら作成しているので、実はまだ未完成だったりします😅
ただ、これで一応、アイテム取得やメッセージを表示することはできるので、続きはのんびり作成していこうと思っています。

さて、これでイベントシステムまで完成したので、ちょっとここらで一旦休憩して
次は、前々から興味のあったGodotでちょっと遊んでみようと思っています。


3D脱出ゲーム:GameModeの整理をしよう。

今日は、ゲームのメイン部分である、イベントの仕組みを作成していきます。
イベントの定義などを作成していくのですが、まずその前に、昨日の説明の中で唐突に出てきたGameModeを整理しておこうと思います。

ゲーム中、Playerが今どのような状態なのかを区分けすることで、その時にできる事、できない事の判定がしやすくなります。
また、メッセージを表示している最中に、もう一度メッセージを表示しようとすると、メッセージウィンドウの挙動がおかしくなってしまうなどの
動作異常を防止することもできます。

各クラスから参照させたいので、GameModeはGameManager に保持させるようにしています。

現段階で、どれくらいのModeを用意するか、実はまだ決まりきってはいません😅
とりあえず、以下の4つで作成を始めています。
・Normal:
 通常状態。
 移動ができる。
 ItemBoxからアイテムをドラッグして使用できる。

・Search:
 虫眼鏡をクリックして、マウスポインタが虫眼鏡になった状態。通常より詳しく調べるモード。
 移動はできない。
 ItemBoxのアイテムをクリックして拡大表示できる。
 ItemBoxからアイテムをドラッグして使用できる。

・Message:
 メッセージウィンドウが開いている状態。移動や他のクリックで、一つ前のNormalもしくはSearchに戻る。

・Terminal:
 端末操作中の状態。
 移動はできない。
 ItemBoxの操作はできない。

こんな感じです。

そして、イベントの中心的な機能といえば、やはりメッセージ表示。
これは、3Dアクションでも作成したので、その応用というか、ほぼ丸コピーです。

追加機能として、今回はメッセージをスクリプタブルオブジェクト(SO)から取得してくる方法以外に、メッセージを表示させるイベントから
直接メッセージを送り込んで表示することもできるようにしています。
これは、アイテムを取得した時に、Item名付きの「***を手に入れた。」というメッセージを表示させるのに、SOからの定型分だけでは
どうしても都合が悪いと思ったからです。

実際には、SO内のメッセージに変数名を割り当てる方法があるのかもしれませんが、僕は残念ながら、まだその方法を知らないので、
パッと思いついた方法で作成することにしました。

作成するといっても、

logicalbeat.jp

相変わらず、この記事のコピーもじりではありますが😅
ほんと、いつもお世話になっております。🙇

using UnityEngine;
using TMPro;
using DG.Tweening;
using Cysharp.Threading.Tasks;
using System.Threading;
using System;

public class MessageManager : MonoBehaviour
{

    private CancellationTokenSource cts;
    CancellationToken token;
    CancellationToken Destroytoken;

    [SerializeField] TMP_Text MessageText;

    public GameObject MessageImage;
    //
    int message_no=0;

    int NextMessageNo;
    [SerializeField] RectTransform panel;
    private PlayerInput inputSystemSC;

    public static MessageManager instance;
    private void Awake() 
    {
        if(instance==null)
        {
            instance=this;
        }
    }

    private void Start() 
    {
        inputSystemSC=new PlayerInput();
        inputSystemSC.Enable(); 
        cts = new CancellationTokenSource();
        token = cts.Token;
        Destroytoken=this.GetCancellationTokenOnDestroy();
    }


    private async UniTask FadeIn(int nextMessageNo)
    {
        // script上でテキストを更新した場合、TMPの更新が終わっていない場合があるので再生成
        MessageText.ForceMeshUpdate(true);
        TMP_TextInfo textInfo = MessageText.textInfo;
        TMP_CharacterInfo[] charInfos = textInfo.characterInfo;

        // 全ての文字を一度非表示にする(特殊文字の兼ね合いで要素と文字の数が一致しない場合がある)
        for (var i = 0; i < charInfos.Length; i++)
        {
            SetTextAlpha(MessageText, i, 0);
        }

        // charInfosの要素数分ループ
        for (var i = 0; i < charInfos.Length; i++)
        {
            // 空白または改行文字の場合は無視
            if (char.IsWhiteSpace(charInfos[i].character)) continue;

            // 一文字ごとに0.05秒待機
            await UniTask.Delay(50);

            SetTextAlpha(MessageText, i, 255);
        }

        await UniTask.WaitUntil(() => Input.GetMouseButtonDown(0)||
                                      inputSystemSC.Player.Move.ReadValue<Vector2>() != Vector2.zero||
                                      inputSystemSC.Player.Look.ReadValue<Vector2>() != Vector2.zero
                                      , cancellationToken: Destroytoken);

        if(nextMessageNo!=0) //次のメッセージがある際は、もう一回メッセージ取得から繰り返す。
        {
            foreach(Message i in MessageSO.Entity.MessageList)
            {
                if (i.MessageNo != nextMessageNo) continue;
                MessageText.text=i.MessageText+"\n"+"-- Click to close --";
                message_no=i.MessageNo;
                NextMessageNo=i.NextMessageNo;
                //messagetype=i.type;
            }

            FadeIn(NextMessageNo).Forget();
            return;
        }

       //次のメッセージがないなら、メッセージウィンドウを閉じる。
        MessageText.text="";
        await panel.DOScale(new Vector2(1,0.01f), 0.3f).AsyncWaitForCompletion();
        await panel.DOScale(new Vector2(0,0), 0.3f).AsyncWaitForCompletion();
        GameManager.instance.gameMode=GameManager.instance.beforeGameMode;//GameModeを戻す。

        MessageImage.SetActive(false);

    }

    // charIndexで指定した文字の透明度を変更
    private void SetTextAlpha(TMP_Text text, int charIndex, byte alpha)
    {
        
        // charIndex番目の文字のデータ構造体を取得
        TMP_TextInfo textInfo = text.textInfo;
        TMP_CharacterInfo charInfo = textInfo.characterInfo[charIndex];

        // 文字を構成するメッシュ(矩形)を取得
        TMP_MeshInfo meshInfo = textInfo.meshInfo[charInfo.materialReferenceIndex];

        // 矩形なので4頂点
        var rectVerticesNum = 4;

        for (var i = 0; i < rectVerticesNum; ++i)
        {
            // 一文字を構成する矩形の頂点の透明度を変更
            meshInfo.colors32[charInfo.vertexIndex + i].a = alpha;
        }
        
        // 頂点カラーを変更したことを通知
        text.UpdateVertexData(TMP_VertexDataUpdateFlags.Colors32);

    }


    public async UniTask ShowGetMessage(int message_no)//メッセージをSOから取得する際に呼ばれるメソッド。
    {
        // メッセージウィンドウを開く
        MessageImage.SetActive(true);

        await panel.DOScale(new Vector2(1,0.01f), 0.3f).AsyncWaitForCompletion();
        await panel.DOScale(new Vector2(1,1), 0.3f).AsyncWaitForCompletion();
        
        // SOからメッセージを取得
        foreach(Message i in MessageSO.Entity.MessageList)
        {
            if (i.MessageNo != message_no) continue;
            MessageText.text=i.MessageText+"\n"+"-- Click to close --";
            message_no=i.MessageNo;
            NextMessageNo=i.NextMessageNo;
            Debug.Log(NextMessageNo);
        }

        FadeIn(NextMessageNo).Forget();
    }

    public async UniTask ShowMessage(String message)//メッセージ自体を受け取って表示する際に呼ばれるメソッド。
    {
        // メッセージウィンドウを開く
        MessageImage.SetActive(true);

        await panel.DOScale(new Vector2(1,0.01f), 0.3f).AsyncWaitForCompletion();
        await panel.DOScale(new Vector2(1,1), 0.3f).AsyncWaitForCompletion();
        
         MessageText.text=message+"\n"+"-- Click to close --";

        FadeIn(0).Forget();
    }

    private void OnDisable() 
    {
        cts.Cancel();    
    }

    private void OnDestroy() 
    {
        cts.Cancel();    
    }
}

次は今度こそ、イベント本体の作成をしよう😅

3D脱出ゲーム:Zoom機能を改造しよう。

今日はアイテムのZoom機能の改造です。

作成中のゲームはFPS視点で、ある程度は自由に歩き回れるようになりますが、
そうすると、今のZoom機能のままでは色々と問題が出てくる可能性があります。
今までの仕組みでは、下図のようにZoom用のCanvasを置いて、ここにZoom用のObjectを生成して、回転させたりしていました。

この方法では、カメラとZoom用のCanvasの間に、ある程度以上の空間をどうしても確保する必要があります。
どういう事かと言いますと、Zoom用のObjectは実際にCanvas上で回転することがあるため、カメラとCanvasが近すぎると、Objectがカメラの後ろ側にまで達してしまう可能性があります。
また、FPS視点でPlayerが動き回った場合、Canvasとカメラの間に、他の構造体が入ってきてしまう可能性もあります。
どちらの場合も、Zoom用のObjectが構造体の影に隠れたり、見え方がおかしくなったりと、ちょっと都合が悪い。
FPS視点でなければ、今の仕組みで十分なのですが、安易にFSP視点にしようとしたばっかりに、改造する必要が出てきたわけです😅

改造方法については、以前の3Dアクションゲームのミニマップを表示させた仕組みの応用でなんとかなりそうです。

amaniku.hatenablog.com

要は、Zoom用のObjectはCanvasとは別の位置で生成し、それを専用に撮影するカメラを作成して、その映像をRenderTextureで受け取って、Zoom用のCanvas上に設置したRaw Imageに表示させるわけです。

今までの仕組みでは、Zoom用Objectはドラッグすることで回転させる事ができました。
改造後はZoom用ObjectはCanvas上にはないので、直接ドラッグすることはできません。
代わりに、Canvasをドラッグした量に応じて回転させても良かったのですが、自動でゆっくり回転させ続けることにしました。
こうすれば、Zoom用ObjectにはColliderもEventTriggerも必要ないので、ちょっと楽になるかなあと言う思惑です😁

これでZoom用のCanvasは、思いっきりカメラに近づけても、Zoom用のObject問題なくなりました。

Zoom機能の呼び出しですが、今までの仕組みではSlotをクリックして選択状態にしてから、パネル上の虫眼鏡アイコンをクリックすることでZoom Canvasが表示されていました。
昨日の改造で開閉式のウィンドウ型になったItemBoxで同じようにしても良かったのですが、
ゲームシステムの設計で、ノーマルモードとサーチモードを分けてみようと思っていたこともあり、虫眼鏡アイコンは、モード切り替えに使おうと思っていたので、こちらも仕組みを改造することにしました。

まず、虫眼鏡アイコンをクリックすることでサーチモードに切り替えます。
これはGameManagerにenumでGameModeを作成しておき、これを切り替えることで行います。
そして、サーチモードだとわかりやすい様に、マウスポインタを虫眼鏡型に変更することにしました。
マウスポインタの変更はとても簡単に実装できます。

Cursor.SetCursor(cursorTexture, Vector2.zero, CursorMode.Auto);

なんと、たったこれだけです。
cusorTextureにカーソルに指定したいSprite画像を指定すればOKです。
カーソルを戻す時は、このcusorTextureにnullを入れるだけです。

これでサーチモードの演出はできたので、この状態でItemBoxのSlotをクリックすれば、Zoom用CanvasにZoom用Objectを表示するようにします。

まず、Slot.csのClickSlotメソッドにZoom Canvasを制御するZoomPanel.csに自身が格納しているitemを引数として渡します。

class Slot
    public void ClickSlot()
    {
        if(GameManager.instance.gameMode==GameMode.Type.Search)//Game Modeの確認
        {
            if(item == null)//Slotにアイテムがあるか確認
            {
                return;
            }
            zoomPanel.ShowPanel(item);//zoomPnaelクラスのShowPanelにitemを引数で渡す
        }
    }


ShowPanelは渡されたItemからZoom用Objectを生成して、Zoom用Canvasに映し出されている舞台であるzoomObjStageの子として設定します。

class ZoomPanel
    public void ShowPanel(Item item)
    {
        Destroy(zoomObj);
        panel.SetActive(true);//Zoom用の舞台の表示

        GameObject zoomObjPrefab = ItemGenerator.instance.GetZoomItem(item.type);
        zoomObj=Instantiate(zoomObjPrefab, zoomObjStage);
        ItemComment.text=item.ItemComment;//itemの説明表示
        ItemName.text=item.ItemName;//itemの名前表示
        
        cts = new CancellationTokenSource();
        
        token=cts.Token;

        RotateObj(token).Forget();
             
    }

    private async UniTaskVoid RotateObj(CancellationToken token)
    {
        while(true)
        {
            // ここでオブジェクトを回転させる
            zoomObjStage.transform.Rotate(0, 30 * Time.deltaTime, 0);
            
            // 次のフレームまで待機
            await UniTask.Yield(token);
        }
    }

これでZoom機能の改造も終わり。
youtu.be

次は、いよいよゲームのメインになるイベントのシステムを作り込んでいくことにしよう。