Unityで複数スクリプトから変更される状態フラグを制御する方法

Unityでキャラクター制御を作っていると、しゃがみ、照準、ストレイフ、IK、アニメーション停止などのフラグを複数のスクリプトから変更したくなることがあります。

ただ、各処理が直接 bool を書き換えるようにすると、最後に代入した処理だけが勝ってしまい、どの機能が現在の状態を要求しているのか分かりにくくなります。

今回はその対策として作成した ParamDeciderFlg、ParamDeciderBase、ParamRegistDeviceFlg、ParamRegistDeviceBase について見ていきます。

コンテンツ

概要

今回作成した仕組みは、ひとことで言うと「フラグの決定権を1つの変数に直接持たせず、各機能に登録用のデバイスを渡して、最終的な値だけをまとめて決める」ためのものです。

例えば、キャラクターの IsCrouching を考えます。

通常の実装では、以下のように複数の処理が直接 IsCrouching を変更する形になりがちです。

IsCrouching = true;
IsCrouching = false;

この形だと、どの処理がしゃがみ状態を要求しているのかが見えにくくなります。

例えば以下のような状態が起きます。

  • プレイヤー入力がしゃがみを解除したい
  • カバーアクションはしゃがみを維持したい
  • 梯子アクション中は別の理由でしゃがみを無効にしたい
  • ステートマシン側で一時的にIKやエフェクトを切り替えたい

こういった場合、単純な bool だけで管理すると、後から実行された処理に上書きされてしまいます。

スクリプト

ParamDeciderBase

using System;
using System.Collections.Generic;

public abstract class ParamDeciderBase<T, U> where U : ParamRegistDeviceBase<T>
{
	private List<U> m_devices = new();

	private T m_defaultValue;
	private T m_currValue;
	private bool m_dirty = true;

	protected T DefaultValue => m_defaultValue;

	public static implicit operator T(ParamDeciderBase<T, U> decider) => decider.Value;

	public T Value
	{
		get
		{
			if (m_dirty)
			{
				m_currValue = m_devices.Count > 0 ? DecideVal(m_devices) : m_defaultValue;
				m_dirty = false;
			}
			return m_currValue;
		}
	}

	public ParamDeciderBase(T defaultValue)
	{
		m_defaultValue = defaultValue;
		m_currValue    = defaultValue;
	}

	public U CreateDevice()
	{
		U newDevice = CreateDevice(UpdateVal, RemoveDevice);
		m_devices.Add(newDevice);
		UpdateVal();
		return newDevice;	
	}

	private void UpdateVal()
	{
		m_dirty = true;
	}

	protected abstract U CreateDevice(Action updateVal, Action<string> removeDevice);

	protected abstract T DecideVal(IEnumerable<ParamRegistDeviceBase<T>> devices);

	private void RemoveDevice(string instanceId)
	{
		m_devices.RemoveAll(a => a.Id == instanceId);
		UpdateVal();
	}

}

ParamDeciderFlg

using System;
using System.Collections.Generic;
using System.Linq;

public class ParamDeciderFlg : ParamDeciderBase<bool, ParamRegistDeviceFlg>
{
	public enum DecideType
	{
		And,
		Or,
		Last,
	}

	private DecideType m_decideType;

	public ParamDeciderFlg(DecideType decideType, bool defaultVal) : base(defaultVal)
	{
		m_decideType = decideType;
	}

	protected override bool DecideVal(IEnumerable<ParamRegistDeviceBase<bool>> devices)
	{
		switch (m_decideType)
		{
			case DecideType.And:
				return !devices.Any(a => a.Value == false);
			case DecideType.Or:
				return devices.Any(a => a.Value == true);
			case DecideType.Last:
				return devices.LastOrDefault().Value;
			default:
				return DefaultValue;
		}
	}

	protected override ParamRegistDeviceFlg CreateDevice(Action updateVal, Action<string> removeDevice)
	{
		return new(updateVal, removeDevice);
	}
}

ParamRegistDeviceBase

using System;

public abstract class ParamRegistDeviceBase<T>
{
	public string Id => m_id;

	public T Value
	{
		get => m_value;
		set
		{
			m_value = value;
			m_updateValAction?.Invoke();
		}
	}

	private string m_id;
	private T m_value;
	private Action m_updateValAction = null;
	private Action<string> m_disposeAction   = null;

	public ParamRegistDeviceBase(Action updateValAction, Action<string> disposeAction)
	{
		m_id = Guid.NewGuid().ToString();
		m_updateValAction = updateValAction;
		m_disposeAction   = disposeAction;
	}

#if UNITY_EDITOR
	~ParamRegistDeviceBase()
	{
		if (m_disposeAction != null)
		{
			UnityEngine.Debug.LogWarning($"Dispose忘れ: {m_id}");
		}
	}
#endif

	public void Dispose()
	{
		m_disposeAction?.Invoke(Id);
	}
}

ParamRegistDeviceFlg

using System;

public class ParamRegistDeviceFlg : ParamRegistDeviceBase<bool>
{
	public ParamRegistDeviceFlg(Action updateValAction, Action<string> disposeAction) : base(updateValAction, disposeAction)
	{
	}
}

この仕組みの利点

一番大きい利点は、フラグの責任範囲が分かりやすくなることです。

各機能は自分の ParamRegistDeviceFlg だけを操作すればよく、他の機能が何をしているかを直接知る必要がありません。

また、Or 判定にしておけば、どれか1つの機能が true を要求している間は最終結果も true になります。

例えばカバー中にしゃがみが必要な場合、プレイヤー入力側がしゃがみを解除しても、カバー側のデバイスが true であれば IsCrouching は true のままになります。

逆に、カバーを抜けたタイミングでカバー側のデバイスを false にする、または Dispose すれば、他の要求に応じて自然に状態が戻ります。

スクリプト説明

ParamDeciderFlg

ParamDeciderFlg は、複数の登録元から渡された bool を集計して、最終的な値を決めるクラスです。

現在は以下の3種類の決定方法を持っています。

public enum DecideType
{
    And,
    Or,
    Last,
}

それぞれの意味は以下のようになります。

  • And: 登録された値がすべて true のときだけ true
  • Or: 登録された値のどれか1つでも true なら true
  • Last: 最後に登録された値を採用

この中で特に使いやすいのは Or です。

例えば、しゃがみ状態や照準状態などは「どれか1つの機能が必要としているなら有効」と考えたいことが多いためです。

public virtual ParamDeciderFlg IsCrouching { get; } =
    new ParamDeciderFlg(ParamDeciderFlg.DecideType.Or, false);

public virtual ParamDeciderFlg IsStrafing { get; } =
    new ParamDeciderFlg(ParamDeciderFlg.DecideType.Or, false);

このようにしておくと、各機能は IsCrouching や IsStrafing を直接書き換えるのではなく、自分用の登録デバイスを作って、その値だけを変更します。

ParamRegistDeviceFlg

ParamRegistDeviceFlg は、各機能が持つ「自分専用のフラグ操作口」です。

例えばプレイヤー入力側では、ストレイフ入力用のデバイスを作成します。

private ParamRegistDeviceFlg isStrafingDevice = null;

protected virtual void Start()
{
    isStrafingDevice = IsStrafing.CreateDevice();
}

入力があったときは、このデバイスの値だけを変更します

public virtual void StrafeInput()
{
    if (strafeInput.GetButtonDown())
    {
        isStrafingDevice.Value = !isStrafingDevice.Value;
    }
}

ここで重要なのは、Adapter.CC.IsStrafing の最終値を直接代入していない点です。

プレイヤー入力は「自分は今ストレイフを要求しているか」だけを登録し、最終的にストレイフするかどうかは ParamDeciderFlg が判断します。

ParamDeciderBase

ParamDeciderBase は、値を決定するための共通処理を持つ基底クラスです。

主な役割は以下です。

  • 登録されたデバイスのリストを持つ
  • デバイスが追加、削除、更新されたら値を再計算対象にする
  • 値が必要になったタイミングで最終値を決定する
  • デバイスが1つもない場合はデフォルト値を返す

実装上は m_dirty を使って、値が変更された可能性があるときだけ再計算する形にしています。

public T Value
{
    get
    {
        if (m_dirty)
        {
            m_currValue = m_devices.Count > 0 ? DecideVal(m_devices) : m_defaultValue;
            m_dirty = false;
        }
        return m_currValue;
    }
}

また、暗黙変換を用意しているため、利用側では通常の bool に近い感覚で扱えます。

public static implicit operator T(ParamDeciderBase<T, U> decider) => decider.Value;

そのため、以下のように条件式の中でそのまま使えます。

if (IsCrouching)
{
    // しゃがみ中の処理
}

ParamRegistDeviceBase

ParamRegistDeviceBase は、登録デバイス側の共通基底クラスです。

それぞれのデバイスには Guid で作成したIDを持たせています。

m_id = Guid.NewGuid().ToString();

このIDを使って、デバイスの破棄時に ParamDeciderBase 側のリストから自分を削除します。

public void Dispose()
{
    m_disposeAction?.Invoke(Id);
}

これにより、コンポーネントが破棄されたあとも古い要求だけが残り続けることを防ぎます。

例えば OnDestroy では以下のように破棄します。

protected virtual void OnDestroy()
{
    isStrafingDevice?.Dispose();
    isStrafingDevice = null;
}

この Dispose を忘れると、存在しない機能の要求が残り続けてしまうため、エディタ上では警告を出すようにしています。

実例

現在は主に以下のような状態管理で使っています。

  • IsCrouching
  • IsStrafing
  • IsAiming
  • UseSkillIK
  • DisableAnimations
  • 近接攻撃中のトレイル表示フラグ

特に Invector 系のキャラクター制御では、プレイヤー入力、AI、カバー、梯子、ロックオン、射撃、スキル、ステートマシンなど、同じ状態を複数の機能が触る場面が出てきます。

そのため、1つの bool を全員で直接書き換えるよりも、各機能が自分の要求だけを登録し、最後にまとめて判定する形の方が安全になります。

注意点

便利な反面、いくつか注意点もあります。

まず、作成したデバイスは不要になったら必ず Dispose する必要があります。

また、Last は最後に登録されたデバイスの値を採用するため、登録順に意味が出ます。使う場合は、どの処理が優先されるのかを明確にしておく必要があります。

さらに、現在の実装ではスレッドセーフなコレクションにはしていません。Unityのメインスレッド上でキャラクター制御に使う前提であれば問題になりにくいですが、別スレッドから触る用途には向いていません。

まとめ

今回は、複数箇所から変動するフラグを管理するために作成した ParamDeciderFlg、ParamDeciderBase、ParamRegistDeviceFlg、ParamRegistDeviceBase についてまとめました。

単純な bool は扱いやすいですが、ゲーム開発では同じ状態を複数の機能が必要とする場面が多くあります。

そういった場面では、最終的な値を直接代入で決めるのではなく、各機能の要求を集めてから And、Or、Last などのルールで決定する形にすると、後から見ても分かりやすくなります。

しゃがみ、照準、ストレイフ、IK、アニメーション制御など、複数のスクリプトから状態が変わるフラグには、このような管理方法がかなり相性が良いと思います。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

ABOUT US
vuniformity誰でもない人
トレンドの行く末を見守っている
仮名を名乗るエンジニア

ゲーム開発は仕事であり趣味である
プログラムだけでなく多種多様なスキルを数多く持つ

ゲーム開発は
ソーシャルゲームを開発運用の経験アリ
ゲーム以外にも経験アリ
Webサービス保守開発等に携わる

ゲームプレイの主な戦場は
FGO
FEH
MTGA
マビノギ
ここでは主にunityroomで公開しているゲーム作り直しの軌跡を綴っていきます