要約
- 衝突処理中に他のCollisionShap2Dを持つシーンをシーンツリーに追加するとエラーが発生する
- 衝突判定区間で新たな衝突対象のツリー追加を禁止しているようです(結果からの予想)
- 対策はcall_deferred()を使う、または_Process()の中でシーンの生成を行います
環境:Godot4.2+ C#(.Net Framework6.0)
今回はスタックトレースにも触れるので詳細バージョンも付記しておきます。
Godot Engine v4.2.1.stable.mono.official [b09f793f5]
はじめに
例えばシューティングゲームで破壊した敵から新たにアイテムを出現させる、といった状況をイメージしてください。ここでの登場人物は弾、敵、アイテムの3つです。話をシンプルにするため全てArea2D派生のシーンとします。
処理の流れは以下の通りです。
(1)弾の_on_area_entered(Area2D area)が呼ばれる
(2)引数のareaが敵だったので、敵の破壊処理を呼び出す
(3)敵の破壊処理ではアイテムを生成してAddChild()する
(4)敵自身のQueueFree()を行う
実際は多数のクラスに跨っていますが、単純化した疑似コードは以下のようになります。
public calss Bullet : Area2D{
private void _on_area_entered(Area2D area){
Enemy enemy = area as Enemy;
enemy?destroy();
}
}
public calss Enemy : Area2D{
[Export] private PackedScene _Item { get; set; }
public void destroy(){
Item item = _Item.Instantiate() as Item;
GetParent().AddChild(item);//ここで実行時エラー
QueueFree();
}
}
これを実行するとAddChild()の呼出し中に、エンジン内部でエラーが発生します。
NativeCalls.cs:6156 @ void Godot.NativeCalls.godot_icall_3_690(nint, nint, nint, Godot.NativeInterop.godot_bool, int):
Can't change this state while flushing queries.
Use call_deferred() or set_deferred() to change monitoring state instead.
Callable.CallDeferred()による対策
エラー内容を見るとcall_deferred()を使えと書いてあるのでその通りコードを書きなおすと期待通りに動きます。
(ここから実際のコードをコピーしてるので、先の疑似コードとは別のモノになります)
//変更前
public void putItem(Vector2 pos, Item type){
ItemBase item = _ItemBase.Instantiate() as ItemBase;
item.Init(pos, type, _wallBase);
AddChild(item);
}
//変更後
public void putItem(Vector2 pos, Item type){
int t = (type) switch{
(Item.Power) =>1,
(Item.Missile) =>2,
(Item.Bit) =>3,
(_) => 4,
};// CallDeferredの引数にenum型を使用できない為、基本型のintに変換している
Callable callable = new Callable(this, MethodName.putItemImpl);
callable.CallDeferred(pos, t);
}
// CallDeferredにより遅延実行されるメソッド
private void putItemImpl(Vector2 pos, int t){
Item type = (t)switch{
(1) => Item.Power,
(2) => Item.Missile,
(3) => Item.Bit,
(_) => Item.Bonus1,
};
ItemBase item = _ItemBase.Instantiate() as ItemBase;
item.Init(pos, type, _wallBase);
AddChild(item);
}
Callable.CallDeferred()の引数にはユーザー定義のEnumを利用できません。よってEnumをint値に対応付け、受け取った後で再度Enumに戻しています。(既にユーザー定義のEnumを各所で多用していたので、これはちょっと残念でした)
振る舞いからの予想
おおよその振る舞いが理解できたので整理しておきます。以下は概要理解のためのモデル図です。正確ではありませんし、バージョンが変わったら変更されるやもしれません。
エンジンは1フレームを描画処理、衝突判定、(エンジン利用者の書く)_Process()の時間帯に分け、この順に毎フレーム繰返し実行しています。
_on_area_enter()イベントハンドラを通じてエンジン側の処理から利用者側の処理に制御が移りますが、ここは衝突判定用の時間帯であるため新たな衝突対象(Collisionを持つノード)がシーンツリーに組み込まれる事を禁止しているのでしょう。試しにCollisionを持たないSprite2Dだけのノードを生成してシーンツリーに組み込んでみると、問題なく組み込めます。
Callable.CallDeferred()にユーザー定義のEnumが利用できないのは、引数をシリアライズして内部的にコピーし、_Process()の時間帯に遅延実行をかけているのだろうと予想されます。(GodotのVariant typeであればシリアライズが可能なので、ユーザー定義のEnumが利用できない事の理由としても妥当です)
C#Actionによる別解
ここからはC#の言語機能による別解です。
_Readyや_ProcessでCollisonを持つノードをシーンツリーに組み込める事は最初から自明です。(当初エラーメッセージを良く読んでいなかったので、自前で実装していました...)
遅延実行を行う基底クラスをActionで実装しておきます。派生クラスがCollisionを持つシーンの生成要求を受けたらDelayedExecution.delayedExec()で自前の生成メソッドを登録することで、基底側の_Process()が毎フレーム対応してくれます。
各所で使いまわす予定だったのでここでは派生する方式にしましたが、遅延実行を担当するシングルトンを用意し、そこへ依頼してもよいでしょう。
using Godot;
using System;
using System.Collections.Generic;
public partial class DelayedExecution : Node
{
private List<Action> _delayedExecution = new List<Action> { };
public override void _Process(double delta)
{
foreach (Action action in _delayedExecution){
action.Invoke();
}
_delayedExecution.Clear();
}
protected void delayedExec(Action action)
{
_delayedExecution.Add(action);
}
}
using Godot;
using System;
using System.Collections.Generic;
public partial class Exsample : DelayedExecution
{
[Export] private PackedScene _CollisionScene { get; set; }
public void GenerateCollision()
{
delayedExec(GenerateCollisionImpl);
}
private void GenerateCollisionImpl()
{
CollisionScene obj = _CollisionScene.Instantiate() as CollisionScene;
AddChild(obj);
}
}