Unityで特定の順番でキー入力したかを判定する
概要
特定の順番でキーを入力したことを検知する仕組みを作ってみました。
途中で間違えるとリセットされて1から入力しなければいけなくなります。
また、最初の1文字を入力してから一定時間が経つとリセットされるタイムアウト機能もあります。
TypeChecker.cs
動作させた様子
nekomimi
と入力されることを期待。タイムアウトは5秒です。
キー入力そのものは表示されていませんが、こんな感じで入力しています。
-
nekomi k
と入力 -
ne
と入力して5秒待機 -
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
が一新されるらしいですが、このあたりも取れるようになったりするんですかね。
Enum
でNone
といういずれにも該当しないデフォルト値が切ってあるのはいいですね。マナーです。
/// <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
は後から登録するのはどうなんだどっちかに統一しろよ、と思わなくもないのですが、たぶん大半のユースケースでは完了だけ取れればいいかなというのと、コンストラクタの引数が多すぎて日和りました。
ちなみにIProgress
はnull
でも動きます。
まとめ
こっそりデバッグ機能を起動するための隠しコマンドメーカーとして作りました。
Input
の仕様周り以外はなにも見ないで作りましたが、これもしかしてタイピングゲームとかのコード参考にしたらよかったのでは……? と作ってから思いました。
「これひょっとしてワンちゃんが書いたのかな〜💕」みたいなコードに晒され続けてここ最近ちょっと「おれがまちがてるか……?」みたいな気持ちになっていましたが、自分で思うきれいなコードを書くことで 『正義』 を取り戻すことができました。こっちのほうがメインですね。
とはいえ、たぶん1〜2週間ほどしたら「これひょっとしてネコちゃんが書いたのかな〜💕」とか言って自分のコードにケチをつけているでしょう。進歩とはそういうものです。
おしまい。
Discussion