この記事は サムザップAdventCalendar2022 の12/23の記事になります!
はじめに
株式会社サムザップでUnityエンジニアをしているレオです。(Twitter)
いきなりですが、皆さんはこんな経験ないでしょうか?
開発中のゲームに一時停止機能やスローモーション機能をつけたいと思ってググってみると、Time.timeScaleについての記事がいっぱい出てくると思います。
ここで少し『スターオーシャン2』のバトルを スピード制御 という点に注目して見てみましょう。(20秒ほどでOKです。)
動画で確認できるスピード制御をちょっと書き出してみます。
- 特定のスキル演出中は周りを停止している
- コマンド選択中はUI以外停止している
- UIは常に動いているものもあれば、止まるものもある
- スタン中はキャラのアニメーションが停止している
それ以外にも、世の中にあるゲームでよくあるスピード制御として
- 倍速ボタンを押したら倍速になる
- メニューボタンを押したら一時停止する
- ヘイスト/スロウでキャラのアニメーションが速くなる/遅くなる
- ヒットストップ
- カットイン演出
などもあり、それらが互いに影響し合うことも考えると、バトルにおけるスピード制御はかなり複雑なことがわかってもらえるんじゃないかなと思います。
それに対してTime.timeScaleだけでは無理がありますよね。この記事では、このような複雑なスピード制御をどうすればうまく実現できるのかについて話していければと思います!
本質となるアイデアについて
本記事で伝えたい設計思想において最も重要となるアイデアは、制御したいモノごとにスピードを持たせるということです。
例えばですが、先ほど紹介した『スターオーシャン2』のバトルがこのようなクラス構造になっているとしましょう。これに対して、以下のようなSpeedクラスを考えてみます。
public class Speed
{
private float _speed = 1f;
public float CurrentSpeed => _speed;
public Action<float> OnValueChange;
public void SetSpeed(float speed)
{
_speed = speed;
OnValueChange?.Invoke(speed);
}
}
Speedクラスはスピードの値を1つ持っており、その値が変化するとイベントを発火させるようなシンプルなものです。
それをこのように、それぞれのクラスに持たせてあげます。例えばCharacterControllerであれば以下のような具合です。
public class CharacterController : MonoBehaviour
{
// Speedインスタンスを持たせる
private readonly Speed _speed = new();
// 初期化
public void Initialize()
{
_speed.OnValueChanged = UpdateSpeed;
}
private void UpdateSpeed(float speed)
{
// ここでスピードの更新処理
}
}
ここで、UpdateSpeedの中には具体的にどういう実装を書くのか考えてみましょう。
private Animator _animator;
private SkeletonAnimation _skeletonAnimation;
private ParticleSystem _particle;
private Tween _tween;
private Sequence _sequence;
private PlayableDirector _director;
private void UpdateSpeed(float speed)
{
// Animatorのスピード更新
_animator.speed = speed;
// Spineのスピード更新
_skeletonAnimation.timeScale = speed;
// ParticleSystemのスピード更新
var main = _particle.main;
main.simulationSpeed = speed;
// DOTweenのスピード更新
_tween.timeScale = speed;
_sequence.timeScale = speed;
// Timelineのスピード更新
for (var i = 0; i < _director.playableGraph.GetRootPlayableCount(); i++)
{
_director.playableGraph.GetRootPlayable(i).SetSpeed(speed);
}
}
いろいろなパターンを書いてみましたが、ここで言いたいのは アニメーションや演出を作るクラスには、スピードを設定するための呼び出し口がある ということです。ゲームの見た目を作るために使っているものが何であれ、大体同じようにスピードを変化させることができると思います。
というわけで、このような処理の流れが理解できたら、ここから先はSpeedクラスに対してどのように値をセットすれば良いかということを考えていきましょう!
バトル全体のスピードを制御する
まずはシンプルな倍速と一時停止から考えていきたいと思います。
このようにシングルトンでBattleSpeedControllerを作り、各Speedインスタンスに対してスピードの値を通知してあげれば良さそうです。
まずはBattleSpeedControllerが、各Speedインスタンスへの参照を持つようにするところから実装していきます。
public class BattleSpeedController : SingletonMonoBehaviour<BattleSpeedController>
{
private readonly List<Speed> _speedList = new();
// 購読開始
public void Subscribe(Speed speed)
{
_speedList.Add(speed);
}
// 購読終了
public void Unsubscribe(Speed speed)
{
_speedList.Remove(speed);
}
}
public void Initialize()
{
_speed.OnValueChanged = UpdateSpeed;
// スピードの購読を開始する
BattleSpeedController.Instance.Subscribe(_speed);
}
このようにSubscribeする形でBattleSpeedControllerからスピードの値を受け取れるようにします。
次は、スピードの値を計算して通知する部分を実装をしていきましょう。
private float _battleSpeed = 1f;
private bool _isPause;
// 倍速を設定する
public void SetDoubleSpeed(bool isDoubleSpeed)
{
_battleSpeed = isDoubleSpeed ? 2f : 1f;
Notify();
}
// 一時停止を設定する
public void SetPause(bool isPause)
{
_isPause = isPause;
Notify();
}
// 各Speedインスタンスに通知
private void Notify()
{
var speed = CalculateSpeed();
foreach (var instance in _speedList)
{
instance.SetSpeed(speed);
}
}
// スピードの計算
private float CalculateSpeed(Speed instance)
{
if (_isPause) return 0f;
return _battleSpeed;
}
あとは「倍速ボタンを押したらSetDoubleSpeedを呼び出す」などのようにしてあげれば、バトル全体のスピードを変化させることができます。
- BattleSpeedControllerが複数がパラメータを持っている
- それらを使って最終的なスピードの値を計算している
ということです。
ここからさらに応用していきましょう。最初に挙げたいくつかのスピード制御のうち「特定のスキル演出中は周りを停止する」を実現することを考えてみます。
まずは最終的に通知されるスピード値の「こうなって欲しい」を図に表してみました。
- 今から実行するスキルが持つSpeedインスタンスにはそのままのスピード値
- それ以外の全てのSpeedインスタンスには0f
を通知することができれば、今回やりたいことは実現できそうです。そのような選択的なスピード値の通知を実現するために、Speedインスタンスにフィルターを追加することを考えてみます。
BattleSpeedControllが持つパラメータをさらに増やしつつ、「複数のパラメータのうちどれを計算に用いるか」をSpeedインスタンスごとに選択できるようにしてあげると、今回のようなスピード制御を実現できそうです。
それでは具体的な実装を考えていきましょう。
// スキルによって制御されるスピードを使うかどうか
// これがフィルターの役割を果たす
public bool UseSkillControlledSpeed { get; set; } = true;
private float _battleSpeed = 1f;
private float _skillControlledSpeed = 1f; // ここにパラメータを追加
private bool _isPause;
// スキルによって制御されるスピードを設定する
public void SetSkillControlledSpeed(float speed)
{
_skillControlledSpeed = speed;
Notify();
}
// 各Speedインスタンスに通知
private void Notify()
{
foreach (var instance in _speedList)
{
var speed = CalculateSpeed(instance);
instance.SetSpeed(speed);
}
}
// スピードの計算
private float CalculateSpeed(Speed instance)
{
if (_isPause) return 0f;
var speed = _battleSpeed;
// インスタンスごとにフィルタリングする
if (instance.UseSkillControlledSpeed) speed *= _skillControlledSpeed;
return speed;
}
あとはスキル演出を制御するSkillControllerクラスで、以下のような実装をしてあげれば「特定のスキル演出中は周りを停止する」を実現できます。
public async UniTask Play()
{
// 自身のSpeedインスタンスではフィルタリングするように設定
_speed.UseSkillControlledSpeed = false;
BattleSpeedController.Instance.SetSkillControlledSpeed(0f);
// スキル演出の再生
await PlaySkill();
// 元に戻す
_speed.UseSkillControlledSpeed = true;
BattleSpeedController.Instance.SetSkillControlledSpeed(1f);
}
最初に挙げていた「カットイン演出」も、スピード制御としては同じなので同様の実装で実現できると思います。
- BattleSpeedControllerの複数のパラメータ
- Speedの複数のフィルター
を組み合わせることで、複雑なスピード制御も実現できるというのがポイントです!
- 倍速設定時にアニメーションが倍速になる/ならない
- スキル演出やコマンド選択中にアニメーションを止める/止めない
など、行いたいスピード制御がUIごとに細かく異なっているような複雑な仕様だったとしても、パラメータとフィルターを増やしていくことで競合せずに実現できると思います。
キャラごとのスピードを制御する
最初に挙げたいくつかのスピード制御のうち、まだ触れてないものを改めて列挙してみましょう。
- スタン中はキャラのアニメーションを停止させる
- ヘイスト/スロウでキャラのアニメーションが速くなる/遅くなる
- ヒットストップ
これらはバトル全体ではなくキャラクターに紐づくスピード制御になるので、キャラクター側にうまく実装を加えることで実現していきたいと思います。
これまでのスピード制御は、BattleSpeedControllerからスピードの値を通知してもらう構造になっていました。
- BattleSpeedContollerからのスピード値を受け取るSpeedクラス
- キャラ自身が持つパラメータ
をまとめたCharacterSpeedクラスを作れば実現できそうです。
新たなスピード制御のためにパラメータを増やすという考え方はこれまでと同じで、それを持たせる場所が今回はBattleSpeedControllerではなくキャラクターになった、というように考えると良いです。
では具体的な実装を考えてみましょう。
public class CharacterSpeed
{
private readonly Speed _baseSpeed = new();
private float _animationSpeed; // ここに追加!
public float CurrentSpeed => _baseSpeed.CurrentSpeed * _animationSpeed;
// ベースのスピードだけ反映したい場合もあるのでイベントは2つ用意している
// 例えば、キャラに紐づくエフェクトのスピードなど
public Action<float> OnChangeAnimationSpeed;
public Action<float> OnChangeBaseSpeed;
public void Initialize()
{
_baseSpeed.OnValueChange = baseSpeed =>
{
OnChangeBaseSpeed?.Invoke(baseSpeed);
OnChangeAnimationSpeed?.Invoke(baseSpeed * _animationSpeed);
};
BattleSpeedController.Instance.Subscribe(_baseSpeed);
}
public void SetAnimationSpeed(float speed)
{
_animationSpeed = speed;
OnChangeAnimationSpeed?.Invoke(_baseSpeed.CurrentSpeed * speed);
}
}
あとは「スタンになったらSetAnimationSpeedを呼び出す」などのようにしてあげれば、キャラごとのスピード制御が実現できます。
ヒットストップを例にして考えてみると、以下のような実装になります。
private readonly CharacterSpeed _characterSpeed = new();
public async UniTask PlayHitStop(float delaySeconds)
{
_characterSpeed.SetAnimationSpeed(0f);
await UniTask.Delay(TimeSpan.FromSeconds(delaySeconds));
_characterSpeed.SetAnimationSpeed(1f);
}
Speedクラスの代わりに、先ほど実装したCharacterSpeedクラスを持たせることで、ヒットストップを実現することができます。
ここで少し脱線しますが、『スマブラ』におけるヒットストップに注目してみましょう。まずはこちらの動画を見てみてください。(10秒ほどでOKです。)
よく見てみるとヒットストップの演出が2種類あることがわかります。今話しているキャラごとのスピード制御として実現すべきなのは上の方です。(下はカットイン演出と考えるのが良さそうです。)
その他のテクニック
待機処理について
await UniTask.Delay(TimeSpan.FromSeconds(delaySeconds));
ヒットストップの話で上記のような実装をしていましたが、実はこれには問題があります。
全体のバトルスピードが2倍になっているなら、待機する秒数は半分になって欲しいですが、この実装ではそれを実現できていません。
この問題を改善していきましょう。
// 自分でもSpeedインスタンスを持つ
private readonly Speed _speed = new();
public float BaseSpeed => _speed.CurrentSpeed;
await UniTask.Delay(TimeSpan.FromSeconds(delaySeconds / BattleSpeedController.Instance.BaseSpeed));
このようにすればバトルスピードに応じて待機時間を変化させることができていますが、これでもまだ不十分です。というのもこの実装ではDelay中のバトルスピード変化に対応できていないからです。
var elapsedTime = 0f;
while (elapsedTime < delaySeconds)
{
await UniTask.Yield();
elapsedTime += Time.deltaTime * BattleSpeedController.Instance.BaseSpeed;
}
このように毎フレームでバトルスピードに応じた経過時間を考えることで、バトルスピードに依存した待機処理を実現することができます。
Updateについて
「一定時間が経過したら何かしらの処理を行う」のようなことをやりたい場合は、以下のように実装すれば良いと思います。
private void Update()
{
// この値をdeltaTimeだと見なしてUpdate処理をしていく
var deltaTime = Time.deltaTime * BattleSpeedController.Instance.BaseSpeed
_timeCounter.Update(deltaTime);
}
public class TimeCounter
{
private float _intervalSeconds = 5f;
private float _currentTime;
public void Update(float deltaTime)
{
_currentTime += deltaTime;
if (_currentTime > _intervalSeconds)
{
// 何かしらの処理
}
}
}
最後に
以上になります。Time.timeScaleは手軽ですが、より複雑なスピード制御を実現したいとなると扱いづらいので、本記事で紹介した考え方をぜひ参考にしてもらえればと思います!