🎶

【Unity】ScenarioFlowによるシナリオ実装#3-4(キャラクターの表示とアニメーション)

2023/06/03に公開

はじめに

こんにちは.伊都アキラです.

前回の記事では,SpriteProviderIAssetLoaderの実装をしてSprite用のDecoderを作成するとともに,背景の表示と遷移を行うプログラムを作成しました.

今回の記事では,キャラクターを表示するプログラムと,キャラクターを画面上で動かすためのアニメーションプログラムを実装していきます.


実行例のワンシーン

キャラクター表示に対する要求

まずは,今回はどのようなキャラクターの表示,及びアニメーションの機能を実現していくかについて説明していきます.

キャラクターの表示については,次のような機能を実装します.

  • 任意のタイミングでキャラクターを表示または非表示にできる
  • 各キャラクターはそれに付加した唯一の名前で管理する
  • キャラクターの絵をいつでも変更できる
  • キャラクターの大きさ,座標,レイヤーなどは自由に変更できる

キャラクターのアニメーションについては,次のような機能を実装します.

  • キャラクターを好きな座標に好きな時間で移動させられる
  • キャラクターが飛び跳ねるようなアニメーションが実行できる
  • 座標を指定するときは,絶対座標または相対座標で指定できる
  • キャラクターの表示,非表示あるいは絵の変更を,透明度を変えることで徐々に行うことができる

どちらも,似たような機能を実現します.
同じような機能を,同期で(一瞬で)実行するか,非同期で(時間をかけて)実行するかの違いくらいしかありません.

唯一の名前によるキャラクター管理

任意のタイミングでキャラクターを場面に追加したり,場面から削除したりするようにしたいわけですが,追加した後にアニメーションを行う対象を指定する際や,削除の対象を指定する際にはキャラクターを識別するためのIDが必要です.

今回は,指定のしやすさを考えて,場面に追加するキャラクター達にはそれぞれ異なる名前を与えることにしましょう.

絶対座標と相対座標

絶対座標と相対座標について,補足しておきます.

絶対座標とは,原点(0, 0)を基に指定する座標です.
一方で,相対座標とは,ある1つの点の座標を基準に指定する座標です.

例えば,アリスが座標(1, 2)にいて赤ずきんが座標(2, 4)にいるとき,

  • アリスの絶対座標は(1, 2)
  • 赤ずきんの絶対座標は(2, 4)
  • 赤ずきんから見たアリスの相対座標は(-1, -2)
  • アリスから見た赤ずきんの相対座標は(1, 2)

のようになります.

絶対座標の指定が可能であればどの座標でも指定できますが,いちいちUnityのシーン上で値を確かめたり,計算して正確な座標を求めたりするのは面倒なので,相対的な座標を指定できた方が良いでしょう.

また,Unityで座標を指定するにはワールド座標とローカル座標という区別がありますが,それとは全く関係のない考え方であることに注意してください.

実装の方針

次に,先に挙げた要求を満たすためのプログラムを作成するための方針について説明していきます.

今回は,以下の5つのクラスを作成します.

Actorクラス

  • キャラクターのデータを持つ.
  • ただのデータオブジェクトであり,メソッドを持たない
  • 画面上に現れるキャラクターそのもの

ActorConfiguratorクラス

  • Actorクラスを受け取り,キャラクターに対する値の設定を行う
  • 例えば,大きさ,座標,画像など

ActorAnimatorクラス

  • Actorクラスを受け取り,キャラクターに対してアニメーションを実行する
  • 例えば,透明度の変更,移動,ジャンプなど

ActorProviderクラス

  • キャラクター,すなわちActorの追加,削除などの管理を行う
  • ActorConfiguratorとActorAnimatorに適切なActorオブジェクトを渡す役割

VectorProviderクラス

  • 座標の指定の際,Vector2やVector3を引数に渡す必要がある
  • 文字列をVector2及びVector3に変換するための処理を行う

ポイント

ここで,Actorクラスそのものが,画面上に現れているキャラクターの実体であると考えてください.
Actorクラスが持つ「透明度」の値を変更すれば,画面上のキャラクターにもその変更が反映されることになります.
このActorオブジェクトActorConfiguratorActorAnimatorに渡すことで,値の設定やアニメーションを行います.

ただ,そうするとそれらのクラスのメソッドの引数にはActorを渡すことになるわけですから,Actor用のDecoderの実装が必要になります.
そこで,その役割はActorProviderが担います.

また,特に移動系のアニメーションを行う時には座標の指定は必須ですから,引数としてはVector2Vector3を使用したいところです.
そこで,Vector用のDecoderを,VectorProviderクラスで実装します.

実装の方針としては,こんなところです.各クラスについてのより詳細な説明については,以降で各クラスの実装を行うときにすることにします.

では,各クラスの実装を行っていきましょう.

Actorクラス

Actor.cs
using UnityEngine;

namespace AliceStandard.Character
{
    public class Actor
    {
        public string Name { get; }
        public GameObject Object { get; }
        public Transform Transform { get; }
        public SpriteRenderer Renderer { get; }

        public Actor(string actorName)
        {
            this.Name = actorName;
            //オブジェクト生成
            var actorObject = new GameObject(actorName);
            this.Object = actorObject;
            this.Transform = actorObject.transform;
            this.Renderer = actorObject.AddComponent<SpriteRenderer>();
        }
    }
}

このクラスの役割はシンプルです.
それは,画面上のキャラクターの情報を保持することです.

このクラスはコンストラクタにキャラクターの名前を受け取り,その名前が付いたゲームオブジェクトをシーン上に生成します.
そして,AddComponent<SpriteRenderer>によって,オブジェクトにスプライトの設定をできるようにします.

また,このクラスが公開しているのはキャラクターの名前オブジェクトそのものとそれに対するTransform,SpriteRendererです.
ここのSpriteRendererに画像を設定すればキャラクターが画面上に表示され,Transformの値を変更すれば,画面上のキャラクターの位置や大きさが変わるでしょう.

なお,TransformとSpriteRendererをわざわざ公開していますが,GameObjectのみを公開すれば,実際はそこからTransformやSpriteRendererを得ることは可能です.
しかし,ここでの「キャラクター」オブジェクトはSpriteRendererを持っているということを保証するため,また,そうすることの利便性からここではそれらの型を公開しています.

ActorConfiguratorクラス

ActorConfigurator
using ScenarioFlow;
using UnityEngine;

namespace AliceStandard.Character
{
    [ScenarioMethod("actor")]
    public class ActorConfigurator : IReflectable
    {
        [ScenarioMethod("spr", "キャラクターのスプライトを設定\nSet the actor's sprite.")]
        public void SetSprite(Actor actor, Sprite sprite)
        {
            actor.Renderer.sprite = sprite;
        }

        [ScenarioMethod("pos", "キャラクターのポジションを設定\nSet the actor's position.")]
        public void SetPosition(Actor actor, Vector3 position)
        {
            actor.Transform.position = position;
        }

        [ScenarioMethod("alpha", "キャラクターの透明度を設定\nSet the actor's alpha.")]
        public void SetAlpha(Actor actor, float alpha)
        {
            var baseColor = actor.Renderer.color;
            actor.Renderer.color = new Color(baseColor.r, baseColor.g, baseColor.b, alpha);
        }

        [ScenarioMethod("scale", "キャラクターの大きさを設定\nSet the actor's scale.")]
        public void SetScale(Actor actor, float scale)
        {
            actor.Transform.localScale = Vector3.one * scale;
        }

        [ScenarioMethod("rotate", "キャラクターの角度を設定\nSet the actor's rotate.")]
        public void SetRotation(Actor actor, float rotate)
        {
            actor.Transform.rotation = Quaternion.Euler(0, 0, rotate);
        }

        [ScenarioMethod("layer", "キャラクターのOrder in Layerを設定\nSet the actor's Order in Layer.")]
        public void SetOrderInLayer(Actor actor, int layer)
        {
            actor.Renderer.sortingOrder = layer;
        }
    }
}

このクラスに関しては,説明することはほとんどないでしょう.
どのメソッドも,どのようなことがしたいのかはコードから理解できるかと思います.

先ほど作成したActorクラスが後悔しているTransformSpriteRendererにアクセスして,各処理を行っていますね.

ActorAnimatorクラス

ActorAnimator.cs
using Cysharp.Threading.Tasks;
using Cysharp.Threading.Tasks.Linq;
using DG.Tweening;
using ScenarioFlow;
using System.Threading;
using UnityEngine;

namespace AliceStandard.Character
{
    [ScenarioMethod("actor")]
    public class ActorAnimator : IReflectable
    {
        //キャラクターの表示/非表示にかける時間
        private float fadeDuration = 0.0f;
        //キャラクターの表示/非表示のEase
        private Ease fadeEase = Ease.Linear;

        //キャラクターのスプライト変更にかける時間
        private float replaceDuration = 0.0f;
        //キャラクターのスプライト変更のEase
        private Ease replaceEase = Ease.Linear;

        //キャラクター移動のEase
        private Ease moveEase = Ease.Linear;
        //キャラクタージャンプのEase
        private Ease jumpEase = Ease.Linear;

        [ScenarioMethod("display", "キャラクターを徐々に表示\nDisplay the actor gradually.")]
        public async UniTask DisplayActorAsync(Actor actor, CancellationToken cancellationToken)
        {
            try
            {
                await actor.Renderer.DOFade(1.0f, fadeDuration).SetEase(fadeEase).ToUniTask(cancellationToken: cancellationToken);
            }
            finally
            {
                var baseColor = actor.Renderer.color;
                actor.Renderer.color = new Color(baseColor.r, baseColor.g, baseColor.b, 1.0f);
            }
        }

        [ScenarioMethod("hide", "キャラクターを徐々に非表示\nHide the actor gradually.")]
        public async UniTask HideActorAsync(Actor actor, CancellationToken cancellationToken)
        {
            try
            {
                await actor.Renderer.DOFade(0.0f, fadeDuration).SetEase(fadeEase).ToUniTask(cancellationToken: cancellationToken);
            }
            finally
            {
                var baseColor = actor.Renderer.color;
                actor.Renderer.color = new Color(baseColor.r, baseColor.g, baseColor.b, 0.0f);
            }
        }

        [ScenarioMethod("fade.durat", "キャラクターの表示/非表示にかける時間を設定\nSet the duration to display or hide an actor.")]
        public void SetFadeDuration(float duration)
        {
            fadeDuration = duration;
        }

        [ScenarioMethod("fade.ease", "キャラクターの表示/非表示のEaseを設定\nSet the ease to display or hide an actor.")]
        public void SetFadeEase(Ease ease)
        {
            fadeEase = ease;
        }

        [ScenarioMethod("replace", "キャラクターのスプライトを徐々に変更\nChange the actor's sprite gradually.")]
        public async UniTask ReplaceActorAsync(Actor actor, Sprite sprite, CancellationToken cancellationToken)
        {
            //z座標の調整
            var zAdjustment = -0.01f;
            //後ろにオブジェクトを複製
            var backRenderer = GameObject.Instantiate<SpriteRenderer>(actor.Renderer);
            backRenderer.gameObject.transform.position = actor.Transform.position + Vector3.forward * zAdjustment;
            //後ろのオブジェクトにスプライトをセット
            backRenderer.sprite = sprite;
            //キャラクターを追従する
            var chaseDisposable = UniTaskAsyncEnumerable.EveryValueChanged(actor, a => a.Transform.position)
                .Subscribe(position => backRenderer.transform.position = position + Vector3.forward * zAdjustment);
            try
            {
                //キャラクターを徐々に透明に
                await actor.Renderer.DOFade(0.0f, replaceDuration).SetEase(replaceEase).ToUniTask(cancellationToken: cancellationToken);
            }
            finally
            {
                //追従終了
                chaseDisposable.Dispose();
                //キャラクターのスプライトをセット
                actor.Renderer.sprite = sprite;
                //キャラクターを表示
                var baseColor = actor.Renderer.color;
                actor.Renderer.color = new Color(baseColor.r, baseColor.g, baseColor.b, 1.0f);
                //後ろのオブジェクトを削除
                GameObject.Destroy(backRenderer.gameObject);
            }
        }

        [ScenarioMethod("replace.durat", "キャラクターのスプライト変更にかけるる時間を設定\nSet the duration to replace an actor's sprite.")]
        public void SetReplaceDuration(float duration)
        {
            replaceDuration = duration;
        }

        [ScenarioMethod("replace.ease", "キャラクターのスプライト変更のEaseを設定\nSet the ease to replace an actor's sprite.")]
        public void  SetReplaceEase(Ease ease)
        {
            replaceEase = ease;
        }

        [ScenarioMethod("move.rel", "相対座標にキャラクターを移動\nMove the actor to the relative position")]
        public UniTask MoveActorToRelativePositionAsync(Actor actor, Vector3 distance, float duration, CancellationToken cancellationToken)
        {
            var goalPosition = actor.Transform.position + distance;
            return MoveActorAsync(actor, goalPosition, duration, cancellationToken);
        }

        [ScenarioMethod("move.abs", "絶対座標にキャラクターを移動\nMove the actor to the absolute position.")]
        public UniTask MoveActorToAbsolutePositionAsync(Actor actor, Vector3 goalPosition, float duration, CancellationToken cancellationToken)
        {
            return MoveActorAsync(actor, goalPosition, duration, cancellationToken);
        }

        [ScenarioMethod("move.ease", "キャラクター移動のEaseを設定\nSet the ease to move an actor.")]
        public void SetMoveEase(Ease ease)
        {
            moveEase = ease;
        }

        [ScenarioMethod("jump.rel", "キャラクターを相対座標にジャンプ移動\nMake the actor jump to the relative position.")]
        public UniTask MakeActorJumpToRelativePositionAsync(Actor actor, Vector3 distance, float jumpPower, float duration, CancellationToken cancellationToken)
        {
            var goalPosition = actor.Transform.position + new Vector3(distance.x, distance.y);
            return MakeActorJumpAsync(actor, goalPosition, jumpPower, duration, cancellationToken);
        }

        [ScenarioMethod("jump.abs", "キャラクターを絶対座標にジャンプ移動\nMake the actor jump to the absolute position.")]
        public UniTask MakeActorJumpToAbsolutePositionAsync(Actor actor, Vector3 goalPosition, float jumpPower, float duration, CancellationToken cancellationToken)
        {
            return MakeActorJumpAsync(actor, goalPosition, jumpPower, duration, cancellationToken);
        }

        [ScenarioMethod("jump.ease", "キャラクターのジャンプ移動のEaseを設定\nSet the ease to make an actor jump.")]
        public void SetJumpEase(Ease ease)
        {
            jumpEase = ease;
        }

        //キャラクターを指定の座標へ移動させる
        private async UniTask MoveActorAsync(Actor actor, Vector3 goalPosition, float duration, CancellationToken cancellationToken)
        {
            try
            {
                await actor.Transform.DOMove(goalPosition, duration).SetEase(moveEase).ToUniTask(cancellationToken: cancellationToken);
            }
            finally
            {
                actor.Transform.position = goalPosition;
            }
        }

        //キャラクターを指定の座標にジャンプ移動させる
        private async UniTask MakeActorJumpAsync(Actor actor, Vector3 goalPosition, float jumpPower, float duration, CancellationToken cancellationToken)
        {
            try
            {
                await actor.Transform.DOJump(goalPosition, jumpPower, 1, duration).SetEase(jumpEase).ToUniTask(cancellationToken: cancellationToken);
            }
            finally
            {
                actor.Transform.position = goalPosition;
            }
        }
    }
}

たくさんのScenarioMethodを実装していますが,これらは表示系,移動系の2つに分けて解説をしたいと思います.

表示系

以下のScenarioMethodが該当します.
名前に関して,接頭辞としてactorが付く点に注意してください.

名前 役割
display キャラクターを表示
hide キャラクターを非表示
fade.durat パラメータ設定
fade.ease パラメータ設定
replace キャラクターの画像を変更
replace.durat パラメータ設定
replace.ease パラメータ設定

重要なのは,display, hide, replaceの三つです.

display & hide

DOFadeによってキャラクターの透明度を徐々に変更し,画面上に表示したり画面上から見えなくしたりします.
この表示または非表示に掛ける時間と,それに設定するEaseは,それぞれfade.duratとface.easeによって変更することができます.

replace

キャラクターの画像を変更します.
ActorConfigurator内のScenarioMethod, "spr"との違いですが,こちらでは前回の背景変更処理でそうしたように,キャラクターの画像を徐々に遷移させます.

その手法は前回と同様で,変更対象のキャラクターの後ろに全く同じオブジェクトを生成し,前のオブジェクトを徐々に透明にすることで,滑らかに画像が切り替わっているかのように見せかけます.

また,画像の変更処理の間,後ろのオブジェクトに前のオブジェクトを追従させているところが重要なポイントです.
なぜこのような処理が必要なのかと言うと,例えばこの画像変更処理とキャラクターを移動させる処理を同時に実行した場合,前のオブジェクトのみ動いてしまい,後ろのオブジェクトが取り残されてしまうからです.

これは2枚のオブジェクトを重ねることで画像の滑らかな遷移を実現していることの,副作用と言えるでしょう.

また,このコードでは座標移動を同時に実行した場合には対応できますが,例えば画像の滑らかな変更と同時に徐々にキャラクターの大きさを変えると言った処理には対応出来ません.
そのような演出をしたければ,またコードを拡張あるいは変更する必要があります.

最後に,オブジェクトの透明度を変更するのにはDOFadeを利用していますが,それに与えるdurationeaseは,display, hideと同様,replace.durat, replace.easeによって設定をすることができます.

移動系

以下のScenarioMethodが対応します.

名前 役割
move.rel キャラクターを移動(相対座標)
move.abs キャラクターを移動(絶対座標)
move.ease パラメータ設定
jump.rel キャラクターをジャンプ移動(相対座標)
jump.abs キャラクターをジャンプ移動(絶対座標)
jump.ease パラメータ設定

move

キャラクターの移動アニメーションを実現するScenarioMethodです.
Transformに対して提供されているDOTweenの拡張メソッドであるDOMoveを利用しています.

DOMoveにはゴール地点時間を指定することができ,例えばDOMove(new Vector3(0, 1, 0), 3.0f)とすれば,対象のオブジェクトを3.0秒で,座標(0, 1, 0)まで移動させるアニメーションが実行されます.

さて,ここではmove.relとmove.absという2つのScenarioMethodを実装していますが,これらの違いは目標地点を絶対座標で指定するか相対座標で指定するか,というところです.

あるキャラクターを現在地点から右に5だけ動かしたいようなときにはmove.relを,現在地点は考慮せず,とにかく原点まで移動させたいといったようなときにはmove.absを使うことになります.

また,移動にかける時間についてはScenarioMethodを呼び出すごとに同時に指定させ,移動に対するEaseについては必要な時だけ変更できるようにしています.

これは,キャラクターを移動させるアニメーションを実行する際は,その移動にかけたい時間は頻繁に変わるだろうが,そのさせ方,つまりEaseは頻繁に変更されないだろう,という考えからです.
この考え方は,次に説明するScenarioMethod, jump.rel, jump.absについても適用しています.

jump

キャラクターをジャンプ移動させるScenarioMethodです.
これで,キャラクターがその場でぴょんと飛び跳ねたり,どこかへ飛んでいったりするようなアニメーションが実現できます.

処理に関する説明の前に,なぜ今回,先に説明したmoveのような単純な移動アニメションだけでなくこのような「ジャンプアニメーション」を実装したかというと,DOTweenがジャンプアニメーションのための拡張メソッドであるDOJumpを提供しているからです.
すなわち,このメソッドを使って簡単にジャンプアニメーションが作れるので,ついでに作ってしまおうということです.

では,処理に関する解説をしていきます.

まず,jump.relとjump.absの違いは,move.relとmove.absと同様,指定するゴール地点を相対座標で指定するか,絶対座標で指定するかということにしかありません.キャラクターの現在地点から見て相対的に移動させたければjump.relを,現在地点を考慮せずに一つの座標を指定したい場合はjump.absを使うことになります.

次に,この処理の核となるDOJumpについて,触れておきます.
このメソッドには,ゴール地点ジャンプの力ジャンプの回数移動にかける秒数を設定することができます.
例えば,DOJump(Vector3.zero, 3, 2, 5.0)とすれば,原点まで5秒かけて,2かいジャンプして移動する(力は3)ようなアニメーションとなります.

今回の実装におけるポイントとしては,DOJumpのジャンプの回数は1で固定しているということです.これは,実際にこのアニメーションを実行する際にはジャンプの回数は1回で済むことがほとんどだろうということからです.

ScenarioMethodを呼び出すときにはゴール地点とジャンプの力,そして時間を指定し,Easeは必要な時に変更できるようにしています.

ActorProviderクラス

ActorProvider.cs
using AliceStandard.Character;
using ScenarioFlow;
using System;
using System.Collections.Generic;
using UnityEngine;

namespace AliceStandard.Decoder
{
    [ScenarioMethod("actor")]
    public class ActorProvider : IReflectable
    {
        private readonly GameObject actorParent = new GameObject("ActorParent");

        private readonly Dictionary<string, Actor> actorDictionary = new Dictionary<string, Actor>();

        [ScenarioMethod("add", "キャラクターを追加する\nAdd the actor.")]
        public void AddActor(string actorName)
        {
            //すでに追加済みでないかチェック
            if (actorDictionary.ContainsKey(actorName))
            {
                throw new ArgumentException($"The actor '{actorName}' exists already.");
            }
            //オブジェクト生成
            var actor = new Actor(actorName);
            //透明にする
            actor.Renderer.color -= Color.black;
            //親オブジェクトを設定
            actor.Renderer.transform.SetParent(actorParent.transform);
            //登録
            actorDictionary.Add(actorName, actor);
        }

        [ScenarioMethod("remove", "キャラクターを削除する\nRemove the actor.")]
        public void RemoveActor(string actorName)
        {
            //存在するキャラクターかチェック
            if (!actorDictionary.ContainsKey(actorName))
            {
                throw new ArgumentException($"The actor '{actorName}' does not exist.");
            }
            //オブジェクト削除
            GameObject.Destroy(actorDictionary[actorName].Object);
            //抹消
            actorDictionary.Remove(actorName);
        }

        [ScenarioMethod("remove.all", "キャラクターをすべて削除する\nRemove all the actors.")]
        public void RemoveAllActors()
        {
            //オブジェクト削除
            foreach(var actor in actorDictionary.Values)
            {
                GameObject.Destroy(actor.Object);
            }
            //全て抹消
            actorDictionary.Clear();
        }

        [Decoder]
        public Actor GetActor(string actorName)
        {
            //存在するキャラクターかチェック
            if (!actorDictionary.ContainsKey(actorName))
            {
                throw new ArgumentException($"The actor '{actorName}' does not exist.");
            }

            return actorDictionary[actorName];
        }
    }
}

このクラスが行っていることとしては,画面上へのキャラクターの追加と画面上からのキャラクターの削除です.
DictionaryでキャラクターのオブジェクトであるActorとキャラクター名を結び付けることで管理をしています.

ポイントとしては,

  • キャラクター追加後,即座に透明にしている
  • キャラクターのオブジェクトは一つの親オブジェクトの子としてまとめている

と言ったところでしょうか.

キャラクターを新たに追加する場合は,

  1. ActorProviderにキャラクターを追加
  2. キャラクターの透明度を変更.オブジェクトを見えなくする
  3. スプライトを変更
  4. 徐々に透明度を変え,表示する

の流れが基本かと思います.
ここで,2番目の,キャラクターを初めの一瞬だけ見えなくするという処理を簡略化するため,キャラクターの追加時は必ず透明度が0になるようにしています.

また,プレイモードで実行をする際に,その時点で追加されているキャラクターのオブジェクトの管理をしやすくするため,一つの親オブジェクトを用意しすべてのキャラクターオブジェクトをそこに子オブジェクトとしてまとめています.

VectorProviderクラス

VectorProvider.cs
using ScenarioFlow;
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEngine;

namespace AliceStandard.Decoder
{
    [ScenarioMethod("vec.mem")]
    public class VectorProvider : IReflectable
    {
        //Vectorの記録
        private readonly Dictionary<string, Func<Vector3>> vectorDictionary = new Dictionary<string, Func<Vector3>>();

        [ScenarioMethod("val", "与えたVector3を記憶\nMemorize the given Vector3.")]
        public void MemorizeVectorFromValue(string vecName, Vector3 vector)
        {
            vectorDictionary[vecName] = () => vector;
        }

        [ScenarioMethod("obj", "GameObjectの座標からVector3を記憶\nMemorize the Vector3 from The position of the GameObject.")]
        public void MemorizeVectorFromObject(string vecName, string objectName)
        {
            //GameObjectを取得
            var vecObj = GameObject.Find(vecName);
            //取得できたかチェック
            if (vecObj == null)
            {
                throw new ArgumentException($"Object '{objectName}' does not exist.");
            }
            //登録
            var resultPosition = vecObj.transform.position;
            vectorDictionary[vecName] = () => resultPosition;
        }

        [ScenarioMethod("bind", "GameObjectの座標への参照を記憶\nMemorize the reference to the position of the GameObject.")]
        public void MemorizeVectorReference(string vecName, string objectName)
        {
            //GameObjectを取得
            var vecObj = GameObject.Find(vecName);
            //取得できたかチェック
            if (vecObj == null)
            {
                throw new ArgumentException($"Object '{objectName}' does not exist.");
            }
            //登録
            vectorDictionary[vecName] = () => vecObj.transform.position;
        }

        [ScenarioMethod("remove", "記憶したVector3を抹消\nRemove the memorized Vector3.")]
        public void RemoveVectorMemory(string vecName)
        {
            //存在するVector3かチェック
            if (!vectorDictionary.ContainsKey(vecName))
            {
                throw new ArgumentException($"Vector memory '{vecName}' does not exit.");
            }
            //抹消
            vectorDictionary.Remove(vecName);
        }

        [ScenarioMethod("clear", "記憶したVector3を全て抹消\nRemove all the memorized Vector3.")]
        public void ClearAllVectorMemories()
        {
            //すべてのVector3を抹消
            vectorDictionary.Clear();
        }

        [Decoder]
        public Vector2 GetVector2(string source)
        {
            return GetVector3(source);
        }

        [Decoder]
        public Vector3 GetVector3(string source)
        {
            //プラス演算子で分割
            return source.Split('+')
                //空白を除く
                .Select(s => s.Trim())
                //Vector3に変換
                .Select(s => ConvertStringWithoutPlusOptIntoVector3(s))
                //和を取る
                .Aggregate((a, b) => a + b);
        }

        //プラス演算子を取り除いた文字列を解析
        private Vector3 ConvertStringWithoutPlusOptIntoVector3(string source)
        {
            //記憶されているVectorかチェック
            if (vectorDictionary.ContainsKey(source))
            {
                return vectorDictionary[source].Invoke();
            }
            //マイナス付きの記憶されたVectorかチェック
            else if (IsMemorizedMinus(source))
            {
                return -vectorDictionary[source.Substring(1)].Invoke();
            }
            else
            {
                return ConvertNumberSetIntoVector3(source);
            }
        }

        //適切な形式の数字の組を解析
        private Vector3 ConvertNumberSetIntoVector3(string source)
        {
            //アンダースコアで分割
            var components = source.Split('_');
            //適切な形式かチェック
            if (components.Length != 2 && components.Length != 3)
            {
                throw new ArgumentException($"Invalid form '{source}'. Pass two or three elements");
            }
            //Vector3を構成する各成分
            //z成分は省略される可能性がある
            var xComponent = 0.0f;
            var yComponent = 0.0f;
            var zComponent = 0.0f;
            //各成分を解析
            foreach(var index in Enumerable.Range(0, components.Length))
            {
                //解析する文字列
                var componentString = components[index];
                //解析後の数字
                var componentFloat = 0.0f;
                //記憶されているVectorか
                var isMemorizedPlus = vectorDictionary.ContainsKey(componentString);
                //記憶されているVectorにマイナスが付いたものか
                var isMemorizedMinus = IsMemorizedMinus(componentString);
                //記憶されたVectorの場合
                if (isMemorizedMinus || isMemorizedPlus)
                {
                    //記憶されたVectorを符号付きで取得
                    var memorizedVector = isMemorizedPlus ?
                        vectorDictionary[componentString].Invoke() :
                        -vectorDictionary[componentString.Substring(1)].Invoke();
                    //適切な成分を割り当て
                    if (index == 0)
                    {
                        componentFloat = memorizedVector.x;
                    }
                    else if (index == 1)
                    {
                        componentFloat = memorizedVector.y;
                    }
                    else
                    {
                        componentFloat = memorizedVector.z;
                    }
                }
                //記憶されていないVectorの場合
                else
                {
                    //Floatに変換
                    componentFloat = float.TryParse(componentString, out var convertedValue) ?
                        convertedValue :
                        throw new ArgumentException($"Invalid form '{source}'. The component can not be converted into a float value.");
                }
                //適切な成分に割り当て
                if (index == 0)
                {
                    xComponent = componentFloat;
                }
                else if (index == 1)
                {
                    yComponent = componentFloat;
                }
                else
                {
                    zComponent = componentFloat;
                }
            }
            //Vectorを生成する
            return new Vector3(xComponent, yComponent, zComponent);
        }

        //記憶されたVectorにマイナスが付いたものか
        private bool IsMemorizedMinus(string source)
        {
            return source.Length > 1 &&
                source.StartsWith('-') &&
                vectorDictionary.ContainsKey(source.Substring(1));
        }
    }
}

このクラスの役割は,文字列をVectorに変換することでした.
ですが,ご覧の通り,とても長いコードになってしまっています.

文字列からVectorに変換すること自体は簡単です.
例えば,「x, y, z」のようにカンマで区切るというルールを設けておけば,正しい形式の文字列が渡されたときにそれを解析するのはすぐにできると思います.

ただ,今回のコードではそのような直接的な数値の入力だけではなく,以下のアイデアを採用しています.

  • よく使うVectorは事前に登録しておき,数値の入力を省けるようにする
  • 登録されたVectorと数値入力を組み合わせることができる
  • 2つのVectorの和を与えることができる

すなわち,単にある規則に従った数値の組をVectorに変換することでも一応任意の座標を指定できるのですが,上にあげている様な便利機能を実現しようとしたために,VectorProviderの実装が長くなってしまっているということです.

現時点ではまだ具体的な書き方の規則を説明をしていないのであまりピンと来ないかもしれませんが,実際にVectorを引数として扱うScenarioMethodを呼び出すとき,この実装で苦労をしたことの恩恵にあずかることができるでしょう.

Vectorの入力規則

数値入力

VectorProviderにおける,一番基本的な数値入力についてです.
今回の実装では,VectorをScenarioMethodの引数として入力するときには,アンダースコア"_" で数値を区切ります.

例えば,(1.0f, -2.0f, 3.0f)を渡したければ,"1.0_-2.0_3.0"のように入力をします.

また,特に2Dではz座標を気にしないことも多いので,z座標の省略が許されます.

例えば,"1.0_2.0"とした場合は,(1.0f, 2.0f, 0.0f)に変換されます.

記憶から入力

前述の通り,今回の実装ではよく使うVectorを事前に登録することができます.
例えば,(0.0f, 0.0f, 0.0f)を"Zero"とのような名前とともに記憶させておくことができます.

(0.0f, 0.0f, 0.0f)を"Zero",(1.0f, 1.0f, 1.0f)を"One"という名前で登録しているとき,
"Zero"と入力すれば(0.0f, 0.0f, 0.0f)が,"One"と入力すれば(1.0f, 1.0f, 1.0f)がVectorとして渡されることになります.

また,記憶されたVectorを使用する際には,先頭にマイナス"-" をつけることで,指定のVectorの符号を反転させることができます.

例えば,
"-One"と入力すれば,(-1, -1, -1)が返されます.

数値と記憶の組み合わせ

記憶させておいたVectorの,一部の成分(x, y, z成分のどれか)だけを利用できます

例えば,(0.0f, 0.0f, 0.0f)を"Zero",(1.0f, 1.0f, 1.0f)を"One"という名前で登録しているとします.

この時,"4.0_One_2.0"と入力すれば,Vectorとしては(4.0f, 1.0f, 2.0f)が渡されます.
Oneはy成分の場所に記述されているので,Oneのy成分のみが抽出されます.

ですから,"Zero_One_Zero"のように入力すれば,(0.0f, 1.0f, 0.0f)が渡されます.

また,注意すべき点としては,ZeroからOneを引いた結果を入力として与えたい場合,"Zero - One"は不適切です.この場合は,"Zero + -One"とします.

2つのVectorの和

2つのVectorの和を引数として与えることが可能です.
これは,上記の規則に従って入力したVectorを,プラス演算子"+" で結合することで実現できます.

例えば,"1.0_2.0_3.0 + -1.0_-2.0_-3.0"のように入力すれば,(0, 0, 0)が返されます.
また,この場合も記憶されたVectorを使うことができ,
"Zero + One_Zero_-One"を渡すと,(1, 0, -1)が渡されます.

Vectorの入力例

Zero: (0.0f, 0.0f, 0.0f)
One: (1.0f, 1.0f, 1.0f)
Two: (2.0f, 2.0f, 2.0f)
とします.

文字列 Vector
0_1_2 (0.0f, 1.0f, 2.0f)
-1.0_2.2_1.4 (-1.0f, 2.2f, 1.4f)
2.0_3.2 (2.0f, 3.2f, 0.0f)
Two (2.0f, 2.0f, 2.0f)
-Two (-2.0f, -2.0f, -2.0f)
1_1_3 + 2.0_-1.0 (3.0f, 0.0f, 3.0f)
One_Two_Zero (1.0f, 2.0f, 0.0f)
Zero + One + Two (3.0f, 3.0f, 3.0f)
One_-Two_0.0 + -Two_One_Zero (-1.0f, -1.0f, 0.0f)

Vectorの登録方法

よく使うVectorを記憶させておく手段として,3つのScenarioMethodを実装しています.

vec.mem.val

任意のVectorを記憶します.
"VecA"と言う名前で"1.0_2.0_3.0"というVectorを登録しておけば,いつでも"VecA"で(1.0f, 2.0f, 3.0f)を渡すことができます.

vec.mem.obj

GameObject.Findによってシーン上からオブジェクトを探し,その座標を記憶します.
例えば,シーン上に"アリス"とという名前のオブジェクトが存在した場合,MemorizeVectorFromObject("Alice", "アリス")とすれば"アリス"の座標が,"Alice"という名前のVectorとして登録されることになります.

vec.mem.bind

これはvec.mem.objと同様,シーン上のオブジェクトの座標を記憶します.
vec.mem.objとの違いは,vec.mem.objは座標そのものを,vec.mem.bindは座標への参照を記憶するとということです.

例えば,"アリス"が(0, 0, 0)にいるときにその座標を記憶しておいたとします.
そしてその後,アリスが(1, 0, 0)に移動したとしましょう.

この時,記憶しておいたVectorを使用するとします.
すると,vec.mem.objで記憶をしていた場合は記憶した時点の座標である(0, 0, 0)が返されます.
しかし,vec.mem.bindで記憶していた場合はアリスというオブジェクト自体を記憶しているのでので,記憶を使用する時点でのアリスの座標,(1, 0, 0)が返されることになります.

キャラクター表示およびアニメーションの実行

GameManagerとソースファイルの準備

では,今回作成したプログラムを実行していきます.
そのために,まずはGameManager.csの中身を書き換えます.

using AliceStandard.Chagacterを追加し,ExcelScenarioPublisherを生成するときに渡すIReflectableの配列に,今回作成したActorAnimatorActorConfiguratorなどを追加します.

GameManager.cs
//追加
using AliceStandard.Character;

public class GameManager : MonoBehaviour
{
    private async UniTaskVoid Start()
    {    
        //------ScenarioPublisherの準備
        var resourcesAssetLoader = new ResourcesAssetLoader();

        var excelScenarioPublisher = new ExcelScenarioPublisher(
            new ScenarioMethodSearcher(
                new IReflectable[]
                {
                    //------使用するScenarioMethodとDecoderをここに
                    //------------Decoder
                    new PrimitiveDecoder(),
                    new AsyncDecoder(cancellationTokenDecoderTokenCodeDecorator),
                    new DOTweenDecoder(),
                    new VectorProvider(),
                    new SpriteProvider(resourcesAssetLoader),
                    new ActorProvider(),
                    //------------
                    //------------ScenarioMethod
                    new LineWriter(lineWriterSettings),
                    new BackgroundAnimator(backgroundAnimatorSettings),
                    new ActorAnimator(),
                    new ActorConfigurator(),
                    //------------
                    //------
                }));
        //------
    }
}
GameManager.cs全文
GameManager.cs
using AliceStandard.Background;
using AliceStandard.Character;
using AliceStandard.Decoder;
using AliceStandard.Line;
using AliceStandard.Loader;
using AliceStandard.Progressor;
using Cysharp.Threading.Tasks;
using ScenarioFlow;
using ScenarioFlow.ExcelFlow;
using ScenarioFlow.TaskFlow;
using System;
using UnityEngine;

public class GameManager : MonoBehaviour
{
    //------各設定

    [SerializeField]
    private LineWriter.Settings lineWriterSettings;

    [SerializeField]
    private KeyProgressor.Settings keyProgressorSettings;

    [SerializeField]
    private ButtonProgressor.Settings buttonProgressorSettings;

    [SerializeField]
    private BackgroundAnimator.Settings backgroundAnimatorSettings;

    //------

    //実行したいソースファイル
    [SerializeField]
    private ExcelAsset excelAsset;

    //Disposable
    private IDisposable[] disposables;

    private async UniTaskVoid Start()
    {
        //CancellationTokenを取得
        var cancellationToken = this.GetCancellationTokenOnDestroy();

        //------Progressorの準備
        var keyProgressor = new KeyProgressor(keyProgressorSettings);
        var buttonProgressor = new ButtonProgressor(buttonProgressorSettings);
        //使用するNextProgressorをここに
        INextProgressor nextProgressor = new CompositeAnyNextProgressor(
            new INextProgressor[]
            {
                keyProgressor,
                buttonProgressor,
            });
        //使用するCancellationProgressorをここに
        ICancellationProgressor cancellationProgressor = new CompositeAnyCancellationProgressor(
            new ICancellationProgressor[]
            {
                keyProgressor,
                buttonProgressor,
            });
        //------

        //------ScenarioBookReaderの準備
        var tokenCodeHolder = new TokenCodeHolder();

        var scenarioBookReader = new ScenarioBookReader(
            new ScenarioTaskExecuterTokenCodeDecorator(
                new ScenarioTaskExecuter(tokenCodeHolder),
                nextProgressor,
                tokenCodeHolder));
        //------
        //------CancellationToken用のDecoderの準備
        var cancellationTokenDecoder = new CancellationTokenDecoder(tokenCodeHolder);

        var cancellationTokenDecoderTokenCodeDecorator = new CancellationTokenDecoderTokenCodeDecorator(
            cancellationTokenDecoder,
            new CancellationProgressorTokenCodeDecorator(cancellationProgressor, tokenCodeHolder),
            tokenCodeHolder);
        //-----

        //------ScenarioPublisherの準備
        var resourcesAssetLoader = new ResourcesAssetLoader();

        var excelScenarioPublisher = new ExcelScenarioPublisher(
            new ScenarioMethodSearcher(
                new IReflectable[]
                {
                    //------使用するScenarioMethodとDecoderをここに
                    //------------Decoder
                    new PrimitiveDecoder(),
                    new AsyncDecoder(cancellationTokenDecoderTokenCodeDecorator),
                    new DOTweenDecoder(),
                    new VectorProvider(),
                    new SpriteProvider(resourcesAssetLoader),
                    new ActorProvider(),
                    //------------
                    //------------ScenarioMethod
                    new LineWriter(lineWriterSettings),
                    new BackgroundAnimator(backgroundAnimatorSettings),
                    new ActorAnimator(),
                    new ActorConfigurator(),
                    //------------
                    //------
                }));
        //------

        //Disposableの登録
        disposables = new IDisposable[]
        {
            cancellationTokenDecoder,
            cancellationTokenDecoderTokenCodeDecorator,
            resourcesAssetLoader,
        };

        //ScenarioBookの作成
        var scenarioBook = excelScenarioPublisher.Publish(excelAsset);
        //ScenarioBookを実行
        await scenarioBookReader.ReadScenarioBookAsync(scenarioBook, cancellationToken);
    }

    //最後にDisposableをDispose
    private void OnDestroy()
    {
        foreach (var disposable in disposables)
        {
            disposable.Dispose();
        }
    }
}

次に,シナリオのソースファイルを準備します.

コピペ用ソースファイル
AliceSample3-4-1
ScenarioMethod	Param1	Param2	Param3	Param4	Param5
<Page>					
sprite.load.all	背景	forced.fluent			
sprite.load.all	アリス	forced.fluent			
sprite.load.all	グレーテル	forced.fluent			
sprite.load.all	ヘンゼル	forced.fluent			
vec.mem.val	LeftWing	-12_0_0			
vec.mem.val	RightWing	12_0_0			
bg.change.immed	広場_昼				
actor.fade.durat	0.8				
actor.fade.ease	InOutQuad				
actor.replace.durat	0.5				
actor.replace.ease	OutQuart				
actor.add	アリス				
vec.mem.bind	アリス	アリス			
actor.spr	アリス	アリス_笑顔			
actor.layer	アリス	1			
actor.display	アリス	serial			
line.write	アリス	こんにちは!	std		
actor.replace	アリス	アリス_通常	paral		
line.write	アリス	私の名前はアリス。	std		
line.write	アリス	ちょっと、位置があっていないかな?	std		
line.write	アリス	絶対座標(0, -1.5)に移動するよ。	std		
actor.pos	アリス	0_-1.5			
actor.replace	アリス	アリス_笑顔	paral		
line.write	アリス	良い感じ!	std		
actor.add	グレーテル				
vec.mem.bind	グレーテル	グレーテル			
actor.spr	グレーテル	グレーテル_通常			
actor.pos	グレーテル	LeftWing_-3_0			
actor.alpha	グレーテル	1			
actor.replace	アリス	アリス_通常	paral		
line.erase	paral				
actor.move.abs	グレーテル	アリス_グレーテル + -4_0	1.5	fluent	
actor.replace	グレーテル	グレーテル_笑顔	paral		
line.write	グレーテル	お姉ちゃん、こんにちは!	std		
actor.replace	アリス	アリス_驚き2	paral		
line.write	アリス	こんちには、グレーテルちゃん……お、おおきくない!?	std		
line.write	グレーテル	そうかなー? じゃあ、小さくなるよ。	std		
actor.scale	グレーテル	0.45			
actor.replace	グレーテル	グレーテル_通常	paral		
line.write	グレーテル	ちょうどよくなったでしょ?	std		
actor.replace	アリス	アリス_通常	paral		
actor.add	ヘンゼル				
vec.mem.bind	ヘンゼル	ヘンゼル			
actor.spr	ヘンゼル	ヘンゼル_怒り			
actor.pos	ヘンゼル	RightWing_-3_0			
actor.rotate	ヘンゼル	-45			
actor.alpha	ヘンゼル	1			
actor.scale	ヘンゼル	0.45			
actor.move.abs	ヘンゼル	アリス_ヘンゼル + 4_0	1.0	fluent	
line.write	ヘンゼル	グレーテル、こんなところにいたのか。 探したぞ!	std		
actor.replace	グレーテル	グレーテル_笑顔	paral		
line.write	グレーテル	あ、ヘンゼルお兄ちゃん! ヘンに傾いているよ!	std		
actor.replace	ヘンゼル	ヘンゼル_驚き	paral		
line.write	ヘンゼル	なんだって!?	std		
actor.rotate	ヘンゼル	0			
actor.replace	ヘンゼル	ヘンゼル_通常	paral		
line.write	ヘンゼル	これで直ったな。	std		
line.write	ヘンゼル	さあ、家に帰ろう。	std		
line.write	グレーテル	お姉ちゃんも一緒にご飯食べよー!	std		
actor.replace	アリス	アリス_驚き2	paral		
actor.jump.rel	アリス	0_0	1	0.2	paral
line.write	アリス	わ、私も!?	std		
line.write	グレーテル	相対座標(-15, 0, 0)へレッツゴー!	paral		
actor.move.rel	アリス	-15_0_0	3	paral	
actor.move.rel	ヘンゼル	-15_0_0	3	paral	
actor.move.rel	グレーテル	-15_0_0	3	fluent	
actor.remove.all					
sprite.clear					
vec.mem.clear					
</Page>					
					

スプライトの準備

上記のソースファイルに書かれたシナリオでは,アリス,ヘンゼル,グレーテルと,3人のキャラクターが登場します.

そのため,この3人の立ち絵を用意しなければなりません.
前回の背景同様,七三ゆきのアトリエより素材を利用させていただきます.

https://nanamiyuki.com/archives/31572

https://nanamiyuki.com/archives/13192

それぞれ,フォルダー名をアリス,ヘンゼル,グレーテルとし,各画像の名前はそのままにしてください.
また,これらの画像を格納するのはResourcesフォルダ直下です(ResourcesAssetLoaderを使用する場合は).


Resourcesフォルダ内


"アリス"フォルダ内

実行結果

ソースファイルをGameManagerに設定し,実行してみましょう.
特に,キャラクターを移動させる際の,絶対座標による移動と相対座標による移動の違いに注目してみてください.


実行シーン


実行シーン

おわりに

今回は,キャラクターの表示とアニメーションを実行するためのプログラムを作成しました.

Vector用のDecoderを提供するためのVectorProviderクラスは長いコードになってしまいましたが,これも前に作成したSpriteProvider同様,一度作成してしまえば様々なところで利用できるものです.
今回で苦労した分,次からVectorを引数として使うScenarioMethodを追加する際には何もする必要がありませんし,そのようなScenarioMethodを実際に呼び出すときに,こだわって実装したことによる恩恵を受けられるでしょう.

また,今回作成したアニメーションはごく最低限のものですが,もしもより高度なアニメーションを追加したければ,Actorを引数に取るようなScenarioMethodを追加すればいくらでもできます.ぜひ,お好みのアニメーションを実装し,追加してみてください.

Discussion