うら干物書き

ゲームを作っています。

【Unity】最小と最大の値を管理する構造体を作りたいの

 特定の範囲内の値を設定する場合は、最小値と最大値の2つの変数を管理しなくちゃいけないのが面倒くさい。
 例えば、攻撃するたびに一定の範囲内のダメージがランダム算出される場合とか。
 1種類ぐらいならいいのだけど、種類の数が増えてくるとさすがにごちゃごちゃしてくる。

// ああ、変数がどんどん増えていく……。

[SerializeField]
private float   m_attackMax;
[SerializeField]
private float   m_attackMin;
[SerializeField]
private int     m_elementMax;
[SerializeField]
private int     m_elementMin;
[SerializeField]
private int     m_lifeMax;
[SerializeField]
private int     m_lifeMin;

 Vector2のように、1つの構造体で最小値と最大値を管理できれば少しはすっきりするだろうか。
 今回はそんな構造体づくりのお話です。


この記事にはUnity5.6.1f1を使用しています。

必要最低限の機能のみのシンプル設計

 では早速作っていきますよー。
 まずはシンプルに、最小値最大値を持つだけの構造体をパパっと作ってしまいます。
 int型のRangeIntegerfloat型のRangeFloatを作っていきます。

RangeInteger

[System.Serializable]
public struct RangeInteger
{
    public RangeInteger( int i_value )
    {
        m_minValue  = i_value;
        m_maxValue  = i_value;
    }

    public RangeInteger( int i_min, int i_max )
    {
        m_minValue  = i_min;
        m_maxValue  = i_max;
    }

    [SerializeField]
    private int   m_minValue;
    [SerializeField]
    private int   m_maxValue;


    public int MinValue
    {
        get { return m_minValue; }
        set { m_minValue    = value; }
    }

    public int MaxValue
    {
        get { return m_maxValue; }
        set { m_maxValue    = value; }
    }
}

RangeFloat

[System.Serializable]
public struct RangeFloat
{
    public RangeFloat( float i_value )
    {
        m_minValue  = i_value;
        m_maxValue  = i_value;
    }

    public RangeFloat( float i_min, float i_max )
    {
        m_minValue  = i_min;
        m_maxValue  = i_max;
    }

    [SerializeField]
    private float   m_minValue;
    [SerializeField]
    private float   m_maxValue;

    public float MinValue
    {
        get { return m_minValue; }
        set { m_minValue    = value; }
    }

    public float MaxValue
    {
        get { return m_maxValue; }
        set { m_maxValue    = value; }
    }
}

 んー、ほとんど同じ内容だ。
 ジェネリックを使いたいところだけど、ジェネリック使うとUnityエディタ上で表示されないしなぁー……、残念。
 しかもint型の方は、本当はRangeIntという名前にしようと思ったら、すでにUnityEngine内に同名とクラスがある始末。
 むむむ、先行き不安だ。

 これ使ってみるとこんな感じに。

public class TestComponent : MonoBehaviour
{
    [SerializeField]
    private RangeInteger    m_int;
    [SerializeField]
    private RangeInteger[]  m_ints;
    [SerializeField]
    private RangeFloat      m_float;
    [SerializeField]
    private RangeFloat[]    m_floats;
} // class TestComponent

f:id:urahimono:20170614093516p:plain

 配列で使用してもうまく表示されていますね。

最小値と最大値の関係をしっかりと

 ただこのままだと、m_minValueの方に大きい値を、m_maxValueの方に小さい値を設定できてしまいます。
 設定するわけで気を付けれいい話ではあるのですが、構造体側でサポートしてあげるようにしてみます。

RangeInteger

[System.Serializable]
public struct RangeInteger
{
    public RangeInteger( int i_value )
    {
        m_minValue  = i_value;
        m_maxValue  = i_value;
    }

    public RangeInteger( int i_min, int i_max )
    {
        // 小さい方を基準にします。
        m_minValue  = i_min;
        m_maxValue  = Mathf.Max( i_min, i_max );
    }

    [SerializeField]
    private int   m_minValue;
    [SerializeField]
    private int   m_maxValue;


    public int MinValue
    {
        get { return m_minValue; }
        set
        {
            m_minValue  = Mathf.Min( value, m_maxValue );
        }
    }

    public int MaxValue
    {
        get { return m_maxValue; }
        set
        {
            m_maxValue  = Mathf.Max( value, m_minValue );
        }
    }
}

 RangeFloatは同じような内容の変更なので省略します。
 これでスクリプト上で値を設定する場合は、m_minValueの方が大きくなることも、m_maxValueの方が小さくなることもなくなりました。

他にもいろいろな機能をつけたい

 最低限の機能は備わったのですが、折角なのでもう少しいろいろな機能を付けていきましょう。

インターフェースを作ってみる

 追加したい機能をインターフェースにまとめてみましょう。
 今回追加しようと思っているの以下の項目です。

  • 中央値
  • 最小値と最大値の範囲の長さ
  • 最小値と最大値の範囲内のランダム値

 スクリプトにおこしてみましょう。

public interface IRange<TValue> where TValue : struct
{
    TValue MinValue
    {
        get;
        set;
    }

    TValue MaxValue
    {
        get;
        set;
    }

    TValue MidValue
    {
        get;
    }

    TValue Length
    {
        get;
    }

    TValue RandomValue
    {
        get;
    }
}

 これをRangeIntegerRangeFloatは継承するようにしましょう。

RangeInteger

[System.Serializable]
public struct RangeInteger : IRange<int>
{
    // 中身はそのまま
}

RangeFloat

[System.Serializable]
public struct RangeFloat : IRange<float>
{
    // 中身はそのまま
}

 では各機能を組み込んでいきます。

中央値プロパティを追加するよ

 最小値と最大値の範囲内の真ん中の値を取得するプロパティを作ってみましょう。

RangeInteger

public int MidValue
{
    get
    {
        return m_minValue + ( m_maxValue - m_minValue ) / 2;
    }
}

 int型ため、整数で表せない場合は正確には中央値ではなくなっちゃいますが、こんな感じでしょうか。
 RangeFloatの作りも返り値がfloatになるだけの同じ式でいけるかと。

範囲の長さプロパティを追加するよ

 次に、最小値と最大値の範囲の長さを取得するプロパティを作ってみましょう。

RangeInteger

public int Length
{
    get
    {
        return Mathf.Abs( m_maxValue - m_minValue );
    }
}

 長さなので必ず正数になるかな。
 RangeFloatの作りも、やっぱり同じで大丈夫なはず。

ランダム値プロパティを追加するよ

 次に、最小値と最大値の範囲の中から、ランダムな数値を取得するプロパティを作ってみましょう。

RangeInteger

public int RandomValue
{
    get
    {
        return m_minValue < m_maxValue ? Random.Range( m_minValue, m_maxValue + 1 ) : m_minValue;
    }
}

 ランダムの場合はint型とfloat型でRandom.Range()の挙動が違うため計算式を少し変えます。

RangeFloat

public float RandomValue
{
    get
    {
        return m_minValue < m_maxValue ? Random.Range( m_minValue, m_maxValue ) : m_minValue;
    }
}

 これで大丈夫でしょうか。
 欲しかった機能が大体揃いましたね。

エディタも改良しよう

 さて、これでスクリプト上で使う際に必要な機能は揃いましたが、せっかくなのでエディタ上の見た目も少し変えていきましょう。

エディタ上にて設定範囲を一列で表示したい

 Vector2などの構造体をエディタ上で見てみると、数値の設定が1列表示されています。
 今回作成した構造体も同じような感じにしてみましょう。

RangeEditor

[CustomPropertyDrawer( typeof( RangeInteger ) )]
public class RangeIntEditor : RangeEditor
{

} // class RangeIntEditor

[CustomPropertyDrawer( typeof( RangeFloat ) )]
public class RangeFloatEditor : RangeEditor
{

} // class RangeFloatEditor

public class RangeEditor : PropertyDrawer
{
    private static readonly GUIContent MIN_LABEL    = new GUIContent( "Min" );
    private static readonly GUIContent MAX_LABEL    = new GUIContent( "Max" );

    public override void OnGUI( Rect i_position, SerializedProperty i_property, GUIContent i_label )
    {
        var minProperty = i_property.FindPropertyRelative( "m_minValue" );
        var maxProperty = i_property.FindPropertyRelative( "m_maxValue" );

        i_label = EditorGUI.BeginProperty( i_position, i_label, i_property );

        // プロパティの名前部分を描画。
        Rect contentPosition    = EditorGUI.PrefixLabel( i_position, i_label );

        // MinとMaxの2つのプロパティを表示するので、残りのフィールドを半分こ。
        contentPosition.width  /= 2.0f;

        // Rangeを配列でもっている際は、その分インデントが深くなっている。揃えたいので0に。
        EditorGUI.indentLevel   = 0;

        // 3文字なら、これぐらいの幅があればいいんじゃないかな。
        EditorGUIUtility.labelWidth = 30f;


        EditorGUI.PropertyField( contentPosition, minProperty, MIN_LABEL );

        contentPosition.x   += contentPosition.width;

        EditorGUI.PropertyField( contentPosition, maxProperty, MAX_LABEL );


        EditorGUI.EndProperty();
    }

    public override float GetPropertyHeight( SerializedProperty i_property, GUIContent i_label )
    {
        return EditorGUIUtility.singleLineHeight;
    }

} // class RangeEditor

f:id:urahimono:20170614093706p:plain

 コンパクトに収まりました。

最小値と最大値の関係をしっかりと(エディタ編)

 スクリプト上では最小値と最大値の関係はしっかりさせましたが、エディタ上ではまだ設定できてしまいます。
 それを何とかしましょう。

 今回は最小値を基準として判定を行うように作っていきましょう。

RangeEditor

[CustomPropertyDrawer( typeof( RangeInteger ) )]
public class RangeIntEditor : RangeEditor
{
    protected override void ApplyValue( SerializedProperty i_minProperty, SerializedProperty i_maxProperty )
    {
        // 小さい数値を基準にして、大きい数値が小さい数値より小さくならないようにしてみよう。
        if( i_maxProperty.intValue < i_minProperty.intValue )
        {
            i_maxProperty.intValue = i_minProperty.intValue;
        }
    }

} // class RangeIntEditor

[CustomPropertyDrawer( typeof( RangeFloat ) )]
public class RangeFloatEditor : RangeEditor
{
    protected override void ApplyValue( SerializedProperty i_minProperty, SerializedProperty i_maxProperty )
    {
        // 小さい数値を基準にして、大きい数値が小さい数値より小さくならないようにしてみよう。
        if( i_maxProperty.floatValue < i_minProperty.floatValue )
        {
            i_maxProperty.floatValue = i_minProperty.floatValue;
        }
    }

} // class RangeFloatEditor

public class RangeEditor : PropertyDrawer
{
    private static readonly GUIContent MIN_LABEL    = new GUIContent( "Min" );
    private static readonly GUIContent MAX_LABEL    = new GUIContent( "Max" );

    public override void OnGUI( Rect i_position, SerializedProperty i_property, GUIContent i_label )
    {
        var minProperty = i_property.FindPropertyRelative( "m_minValue" );
        var maxProperty = i_property.FindPropertyRelative( "m_maxValue" );

        ApplyValue( minProperty, maxProperty );

        i_label = EditorGUI.BeginProperty( i_position, i_label, i_property );

        // プロパティの名前部分を描画。
        Rect contentPosition    = EditorGUI.PrefixLabel( i_position, i_label );

        // MinとMaxの2つのプロパティを表示するので、残りのフィールドを半分こ。
        contentPosition.width  /= 2.0f;

        // Rangeを配列でもっている際は、その分インデントが深くなっている。揃えたいので0に。
        EditorGUI.indentLevel   = 0;

        // 3文字なら、これぐらいの幅があればいいんじゃないかな。
        EditorGUIUtility.labelWidth = 30f;


        EditorGUI.PropertyField( contentPosition, minProperty, MIN_LABEL );

        contentPosition.x   += contentPosition.width;

        EditorGUI.PropertyField( contentPosition, maxProperty, MAX_LABEL );


        EditorGUI.EndProperty();
    }

    protected virtual void ApplyValue( SerializedProperty i_minProperty, SerializedProperty i_maxProperty )
    {

    }

    public override float GetPropertyHeight( SerializedProperty i_property, GUIContent i_label )
    {
        return EditorGUIUtility.singleLineHeight;
    }

} // class RangeEditor

f:id:urahimono:20170614093740g:plain

 いい感じですね。

蛇足? MinMaxSliderを使ってみよう

 エディタの機能の中にEditorGUI.MinMaxSlider()というものがあります。
 これを使えば、もっと楽しいエディタになるかも。
 あっ、でも引数にfloat型しか渡せないや……。
 とりあえるfloatのみ使ってみましょう。

RangeEditor

public override void OnGUI( Rect i_position, SerializedProperty i_property, GUIContent i_label )
{
    var minProperty = i_property.FindPropertyRelative( "m_minValue" );
    var maxProperty = i_property.FindPropertyRelative( "m_maxValue" );

    ApplyValue( minProperty, maxProperty );

    i_label = EditorGUI.BeginProperty( i_position, i_label, i_property );

    // プロパティの名前部分を描画。
    Rect contentPosition    = EditorGUI.PrefixLabel( i_position, i_label );

    // MinとMaxの2つのプロパティを表示するので、残りのフィールドを半分こ。
    contentPosition.width  /= 2.0f;

    // Rangeを配列でもっている際は、その分インデントが深くなっている。揃えたいので0に。
    EditorGUI.indentLevel   = 0;

    // 3文字なら、これぐらいの幅があればいいんじゃないかな。
    EditorGUIUtility.labelWidth = 30f;

    contentPosition.height = EditorGUIUtility.singleLineHeight;

    EditorGUI.PropertyField( contentPosition, minProperty, MIN_LABEL );

    contentPosition.x   += contentPosition.width;

    EditorGUI.PropertyField( contentPosition, maxProperty, MAX_LABEL );

    contentPosition.x   -= contentPosition.width;
    contentPosition.y   += EditorGUIUtility.singleLineHeight;
    contentPosition.width *= 2.0f;

    DrawSlider( contentPosition, minProperty, maxProperty );

    EditorGUI.EndProperty();
}


protected virtual void DrawSlider( Rect i_drawPosition, SerializedProperty i_minProperty, SerializedProperty i_maxProperty )
{

}

RangeFloatEditor

protected override void DrawSlider( Rect i_drawPosition, SerializedProperty i_minProperty, SerializedProperty i_maxProperty )
{
    float min = i_minProperty.floatValue;
    float max = i_maxProperty.floatValue;
    EditorGUI.MinMaxSlider( i_drawPosition, ref min, ref max, -100, 100 );

    i_minProperty.floatValue    = min;
    i_maxProperty.floatValue    = max;
}

public override float GetPropertyHeight( SerializedProperty i_property, GUIContent i_label )
{
    return EditorGUIUtility.singleLineHeight * 2.0f;
}

f:id:urahimono:20170614094522p:plain

 楽しくはなりましたが、スライダーの幅を指定するリミット値をどのように設定したものか。
 今回は固定値を使いましたが、どのような値を設定するかわからない以上、リミット値を固定値にするのも問題です。
 エディタ上で設定させるようにすると、設定する項目が4つになってしまい、正直邪魔です。
 うーん、どうしたものか。
 無理やりなんとかしてみましょう。

RangeFloat

[System.Serializable]
public struct RangeFloat : IRange< float >
{
    public RangeFloat( float i_value )
    : this( i_value, i_value )
    {
    }

    public RangeFloat( float i_min, float i_max )
    : this( i_min, i_max, -100.0f, 100.0f )
    {

    }

    public RangeFloat( float i_min, float i_max, float i_minLimit, float i_maxLimit )
    {
        m_minValue  = i_min;
        m_maxValue  = Mathf.Max( i_min, i_max );

        m_minLimitValue = i_minLimit;
        m_maxLimitValue = i_maxLimit;
    }

    [SerializeField]
    private float   m_minValue;
    [SerializeField]
    private float   m_maxValue;
    [SerializeField, HideInInspector]
    private float   m_minLimitValue;
    [SerializeField, HideInInspector]
    private float   m_maxLimitValue;

}

RangeEditor

protected virtual void DrawSlider( Rect i_drawPosition, SerializedProperty i_baseProperty, SerializedProperty i_minProperty, SerializedProperty i_maxProperty )
{

}

RangeFloatEditor

protected override void DrawSlider( Rect i_drawPosition, SerializedProperty i_baseProperty, SerializedProperty i_minProperty, SerializedProperty i_maxProperty )
{
    float min = i_minProperty.floatValue;
    float max = i_maxProperty.floatValue;

    float minLimit  = i_baseProperty.FindPropertyRelative( "m_minLimitValue" ).floatValue;
    float maxLimit  = i_baseProperty.FindPropertyRelative( "m_maxLimitValue" ).floatValue;

    EditorGUI.MinMaxSlider( i_drawPosition, ref min, ref max, minLimit, maxLimit );

    i_minProperty.floatValue    = min;
    i_maxProperty.floatValue    = max;
}

TestComponent

public class TestComponent : MonoBehaviour
{
    [SerializeField]
    private RangeFloat  m_a = new RangeFloat( 0.0f, 0.0f, -100.0f, 100.0f );
    [SerializeField]
    private RangeFloat  m_b = new RangeFloat( 0.0f, 0.0f, -10.0f, 200.0f );
    [SerializeField]
    private RangeFloat  m_c = new RangeFloat( 0.0f, 0.0f, -200.0f, 0.0f );


} // class TestComponent

f:id:urahimono:20170614093929p:plain

 同じ数値を指定ているのに、スライダーの位置が違います。
 成功です。
 ただ、この方法は大技過ぎる……。
 一度設定したリミット値を変更するにはリセットをしないと、シリアライズされた値が更新されないので何とも使い勝手が悪いなぁ。

f:id:urahimono:20170614093942p:plain

 うーん、EditorGUI.MinMaxSlider()は無くてもいいかなぁー……。

感想

 最終的に、EditorGUI.MinMaxSlider()なしの状態が1番汎用性が高い気がしますね。
 これで最小値と最大値を1つの変数で管理できるので、クラスが少しはすっきりするかも。

参考リンク

docs.unity3d.com

unitylab.wiki.fc2.com