Posted at

スクリプト制御のアニメーションをデリゲートとジェネリックでまとめる

More than 5 years have passed since last update.


Don't Repeat Yourself

Unity3D でゲームスクリプトを書いていると、こういったスクリプト処理のパターンがよく出てくるかと思います。以下は単純にオブジェクトが一定の時間動き続けるダケというもの


Avatar.cs

using UnityEngine;

// よくある感じのゲームオブジェクト
public class Avatar : MonoBehavior {
public float timeSpan = 1.0f; // インスペクタ側から設定
public float scalar = 10; // インスペクタ側から設定、基本の移動量
private float timer = 0;
private bool isMoving;
private Vector3 startPosition;
private Vector3 endPosition;

void Update () {
if (isMoving) {
timer += Time.deltaTime;
if (timer >= timeSpan) {
timer = 0;
isMoving = false;
} else {
transform.position = Vector3.Lerp (startPosition, endPosition, timer / timeSpan);
}
}
}

void OnGUI () {
Event evt = Event.current;
if (evt.type == EventType.KeyDown) {
switch (evt.keyCode) {
case KeyCode.LeftArrow:
Move (transform.position + new Vector3 (scalar, 0, 0));
break;
// 以下 KeyDown したキーに応じて StartMoving () を呼ぶ処理の列記
default: break;
}
}
}

void StartMoving (Vector3 to) {
isMoving = true;
startPosition = transform.position;
endPosition = to;
}
}


上記の例はやや恣意的ではあるし、もっと構造化して OnGUIGameManager などの管理クラスを作ってそこで握る等になるかと思いますが、単にシーン内でタイマーを使った描画処理を実装するだけでこうコードの量が増えるのは煩わしいし、別のゲームオブジェクトでも似たような処理を書かないといけないとなると面倒くさいかと思います。


処理フローの抽象化

このようなパターンを抽象化すると基本は


  • 予め設計したタイムリミットと、0 からタイムリミットまでを一時的に変動するタイマーがある

  • 遷移開始時と終了時の何らかの型

    の値がある



  • タイマーの現在の値とタイムリミットの比率で変動量を計算する

  • タイマーの現在の値から計算された結果をゲームオブジェクトの何かへ代入する

というフローで一般化できるように思えます


StateTransition

Unity3D で使える C# のラムダ式とジェネリクスでこういったものを作ってみました


StateTransition.cs

using UnityEngine;

using System;
using System.Collections.Generics;

// 0.0〜1.0 の比率から割り出す関数の型のデリゲート
// 別に Func<P, P, float, P> で良いけれど、利用側の宣言が冗長になるので
public delegate P PlotFunc<P> (P start, P to, float rate);

public class StateTransition<P> {
// デフォルトの遷移レイテンシ (200ms)
public static float DEFAULT_TIME_SPAN = 0.2f;

// 初期化以降は変更すべきじゃない系
public float timeSpan { get; private set; }
public PlotFunc<P> Plot { get; private set; }
public Action<P> Reassign { get; private set; }

// バタバタ変わる系
public bool isWorking { get; private set; }
private bool hasSetup;
private float timer;
private P beginProperty;
private P afterProperty;

public StateTransition () {
Plot = (b, e, r) => b; // 常に最初の引数だけ返す
Reassign = (x) => {}; // 引数受け取っても何もしない
timeSpan = DEFAULT_TIME_SPAN;
}

public StateTransision (PlotFunc<P> _plot, Action<P> _reassign, float _timeSpan) {
// コンストラクタのオーバーロードは中略
}

// タイマーがリミットを超えた時に遷移終了し、目標値を返すプロット
public P StepPlot () {
timer += Time.deltaTime;
if (timer >= timeSpan) {
isWorking = false;
return afterProperty;
}
return plot (beginProperty, afterProperty, timer / timeSpan);
}

// タイマーがリミットを超えた時に差し引きし、ループするプロット
public P StepPlot () {
timer += Time.deltaTime;
if (timer >= timeSpan) {
timer -= timeSpan;
}
return plot (beginProperty, afterProperty, timer / timeSpan);
}

// 一方通行の、タイムアップまで遷移
public void OneWay () {
if (isWorking) { Reassign (StepPlot ()); }
}

// Stop () を呼ぶまで遷移し続ける
public void Loop () {
if (isWorking) { Reassign (LoopPlot ()); }
}

public void Start (P begin, P after) {
if (isWorking) { return; } // もうすでに遷移中であった場合割り込みをかけないように
hasSetup = true;
isWorking = true;
timer = 0;
beginProperty = begin;
afterProperty = after;
}

public void Stop () {
isWorking = false;
}

public void Resume () {
if (hasSetup) {
isWorking = true;
} else {
// FIXME 開始と目標までの値が設定されてないので、エラーなり例外なりを
}
}
}


長い! けれどこれ一個で最初のクラスの Update メソッドはこうなります


使い方


PrettyAvatar.cs

using UnityEngine;

using System;
using System.Collections.Generics;

public class PrettyAvator : MonoBehavior {
private StateTransition<Vector3> moving; // 移動する動きに関するメンバーがこれに収まる
public float scalar = 10; // ここは変わらず
public float timeSpan = 1.0f;

void Awake () {
moving = new StateTransition<Vector3> (
Vector3.Lerp, // PlotFunc<Vector3> になるようなメソッドならこう渡せる
(position) => { transform.position = position; },
timeSpan
);
}

void Update () {
moving.OneWay ();
// 内部で isWorking を参照してるのでこれだけで良い。
// ただコードの内部を知らないと勘違いしやすいので
// if (moving.isWorking) {} と囲んだ方が良いか
}

void OnGUI () {
Event evt = Event.current;
if (evt.type == EventType.KeyDown) {
Vector3 p = transform.position;
switch (evt.keyCode) {
case KeyCode.LeftArrow:
moving.Start (p, p + new Vector3(scalar, 0, 0));
break;
// 以下同様
default: break;
}
}
}
}


Update の部分でタイマーを使った動きがシンプルに記述できるようになったので余計な管理がなくなり、ゲームシステムの別の処理に意識を集中できるようになりました。

StateTransition<Quaternion> で回転の制御を静的に定義したり、あるいはカンタンなレンダラーのスクリプト制御に PlotFunc<P>Mathf.Sin を使ってのプロットを打つメソッドを渡すなども便利です。


アピールポイント


  • いちいち Update で細かい処理の実装を書いたり、読んで確認する手間が無くなる
    ++ Awake での初期化で「これはこういう遷移のオブジェクトだ」と静的に記述できる

  • 複数の動きをメンバーに持たせても煩雑にならない (ある遷移が動作していた場合に他の
    遷移が作動しないようなブロッキングも容易かつ割り込みもかけられる)

  • ユーザーの動作に関してはイベントドリブンでない Unity でも書き様と使いようによってはイベントドリブンな書き方ができるようになる


注意点

課題としては ジェネリクスを使う都合 Serializable に出来ない(?) ので、遷移の timeSpan だけ多少冗長になってしまいがちです。

また↑のコードだと Start を呼んでいない Resume を呼んだ場合、何もしないわけですが、デバッグ環境下では例外を投げる等の工夫も必要かと思います。