⌨️

Unityで特定の順番でキー入力したかを判定する

2021/07/09に公開

概要

特定の順番でキーを入力したことを検知する仕組みを作ってみました。
途中で間違えるとリセットされて1から入力しなければいけなくなります。
また、最初の1文字を入力してから一定時間が経つとリセットされるタイムアウト機能もあります。

TypeChecker.cs
https://gist.github.com/nekomimi-daimao/1dece355acf66b84aaaaa185cff6e271

動作させた様子

nekomimiと入力されることを期待。タイムアウトは5秒です。
キー入力そのものは表示されていませんが、こんな感じで入力しています。

  1. nekomi kと入力
  2. neと入力して5秒待機
  3. nekomimiと入力

動作させているサンプルコードはこんな感じです。
コンストラクタの引数として渡しているIProgress<(int numerator, int denominator)>に途中経過を、typeChecker.OnCompleteに完了のイベントをそれぞれ登録しています。

using System;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;

namespace Nekomimi.Daimao
{
    public class TypeCheckerExample : MonoBehaviour, IProgress<(int numerator, int denominator)>
    {
        private static readonly KeyCode[] Command =
        {
            KeyCode.N,
            KeyCode.E,
            KeyCode.K,
            KeyCode.O,
            KeyCode.M,
            KeyCode.I,
            KeyCode.M,
            KeyCode.I,
        };

        [SerializeField]
        private Slider _slider = default;

        [SerializeField]
        private Text _textSlider = default;

        [SerializeField]
        private Text _textComplete = default;

        private void Start()
        {
            var typeChecker = new TypeChecker(Command, 5f, this, this.GetCancellationTokenOnDestroy());
            typeChecker.OnComplete += () =>
            {
                _textComplete.text = "COMPLETE!";
                Debug.Log("COMPLETE!");
            };
        }

        public void Report((int numerator, int denominator) value)
        {
            var (numerator, denominator) = value;
            _slider.maxValue = denominator;
            _slider.value = numerator;
            _textSlider.text = $"{numerator} / {denominator}";

            Debug.Log($"progress {numerator} / {denominator}");
        }
    }
}

ちなみにUniTaskを使っています。v2ですが特に新しい機能は使っていないので、v1でも行けるはず。

TypeCheckerの中身の解説

CurrentPressed

まずは一番かんたんな現在の入力キーを取得するところから行きます。
Unityには標準で今何のキーが押されたかを取得するAPIがないので自作する必要があります。[1]
いくらEnumの配列とはいえ、毎Updateでforループを回すのにはすごい抵抗がありますがこれが最適解です。InputSystemが一新されるらしいですが、このあたりも取れるようになったりするんですかね。
EnumNoneといういずれにも該当しないデフォルト値が切ってあるのはいいですね。マナーです。

/// <summary>
/// <see cref="KeyCodeAlphabet"/>に含まれている現在押されているキーを返す.
/// </summary>
/// <returns><see cref="KeyCode"/></returns>
private static KeyCode CurrentPressed()
{
    if (!Input.anyKeyDown)
    {
        return KeyCode.None;
    }
    // every Update, no LINQ
    foreach (var keyCode in KeyCodeAlphabet)
    {
        if (Input.GetKeyDown(keyCode))
        {
            return keyCode;
        }
    }
    return KeyCode.None;
}

TypeCommandAsync

続いて連続して正しいキーが入力されているかをチェックするやつです。
タイムアウトのカウントダウンを開始する関係で、1文字目だけはこのメソッドの外で判定しています。
あとはKeyCodeのforループを1から始めて、キー入力があったときにそれが期待したものだったらforループを次に進め、違うキーだったらforループそのものを打ち切ります。
すなわち、forループを抜けた時=すべての必要なキーが入力されたときです。
forループの中でwhileループを回すのはこちらも抵抗感がありますが、これもまあ最適解なんじゃないでしょうか……?
gotoを使うことは避けましたが、こういった多重ループを使わざるを得ない場合はgotoのほうが却ってわかりやすいかもしれません。でも使ったことないからイヤ。

/// <summary>
/// キー入力を監視して正しく入力されたかを判定する.
/// 最初の1文字は判定しない.
/// </summary>
/// <param name="progress"><see cref="IProgress{T}"/> (分子/分母)</param>
/// <param name="token"><see cref="CancellationToken"/></param>
/// <returns>true/false = 成功/失敗</returns>
private async UniTask<bool> TypeCommandAsync(
    IProgress<(int numerator, int denominator)> progress, CancellationToken token)
{
    for (var count = 1; count < Command.Length; count++)
    {
        while (true)
        {
            await UniTask.Yield();
            if (token.IsCancellationRequested)
            {
                return false;
            }
            var current = CurrentPressed();
            if (current == KeyCode.None)
            {
                // 何も入力していない場合は判定しない
                continue;
            }

            if (Command[count] != current)
            {
                // 違うキーが入力されたので中断する
                return false;
            }

            // 求めているキーが入力されたので進捗を通知して次のキーに進む
            progress?.Report((count + 1, Command.Length));
            break;
        }
    }
    return !token.IsCancellationRequested;
}

CheckTypeLoop

今回一番悩んだところです。

  • 1文字目からタイムアウトのカウントダウンを開始
  • 別キー入力等で入力が中断されたらタイムアウトもリセット

これに対する対処としてUniTask.WhenAnyで単純な時間経過とタイムアウトの対象になる処理を競争させて、時間経過が先に来たら処理を止める……という感じにしました。CancellationTokenSourceにタイムアウトを設定するのが本来かなと思うのですが、やっぱ気軽にException投げられる[2]のは好きじゃないので、自分はタイムアウトはUniTask.WhenAnyで解決することにしています。

private async UniTaskVoid CheckTypeLoop(IProgress<(int numerator, int denominator)> progress, CancellationToken baseToken)
{
    var first = Command[0];

    while (true)
    {
        await UniTask.Yield();
        if (baseToken.IsCancellationRequested)
        {
            return;
        }
        if (CurrentPressed() != first)
        {
            // 最初の1文字が入力されるまではタイムアウトも以後の判定も行わない
            progress?.Report((0, Command.Length));
            continue;
        }

        // 最初の1文字が入力された
        progress?.Report((1, Command.Length));

        var cancelSource = CancellationTokenSource.CreateLinkedTokenSource(baseToken);
        var token = cancelSource.Token;

        bool result;
        // 1文字目以降のキーが順番に入力されたら完了する
        var type = TypeCommandAsync(progress, token);

        if (TimeoutSecond > 0f)
        {
            // タイムアウトが設定されている場合はカウントする
            var timeout = UniTask.Delay(TimeSpan.FromSeconds(TimeoutSecond), cancellationToken: token);
            var (hasResultLeft, ret) = await UniTask.WhenAny(type, timeout);
            // キー入力のtaskが先に完了してかつそれがtrue
            result = hasResultLeft && ret;
        }
        else
        {
            // タイムアウトを待たない場合はキー入力の結果だけを待つ
            result = await type;
        }

        if (result)
        {
            OnComplete?.Invoke();
        }

        if (!cancelSource.IsCancellationRequested)
        {
            cancelSource.Cancel();
        }
    }
}

コールバック系とか

コンストラクタの引数にIProgressを取るくせに完了のAction OnCompleteは後から登録するのはどうなんだどっちかに統一しろよ、と思わなくもないのですが、たぶん大半のユースケースでは完了だけ取れればいいかなというのと、コンストラクタの引数が多すぎて日和りました。
ちなみにIProgressnullでも動きます。

まとめ

こっそりデバッグ機能を起動するための隠しコマンドメーカーとして作りました。
Inputの仕様周り以外はなにも見ないで作りましたが、これもしかしてタイピングゲームとかのコード参考にしたらよかったのでは……? と作ってから思いました。

「これひょっとしてワンちゃんが書いたのかな〜💕」みたいなコードに晒され続けてここ最近ちょっと「おれがまちがてるか……?」みたいな気持ちになっていましたが、自分で思うきれいなコードを書くことで 『正義』 を取り戻すことができました。こっちのほうがメインですね。
とはいえ、たぶん1〜2週間ほどしたら「これひょっとしてネコちゃんが書いたのかな〜💕」とか言って自分のコードにケチをつけているでしょう。進歩とはそういうものです。

おしまい。

脚注
  1. じょ、冗談じゃ・・・ ↩︎

  2. タイムアウトとキャンセルで同じOperationCanceledException投げてくるC#言語仕様はいったいどういう了見ですかね……? ↩︎

Discussion