儚くも美しく戦う、Mitamatch Operations の物語
Mitamatch Operations
Mitamatch Operations はアサルトリリィ Last Bullet (ラスバレ)におけるレギオンマッチを支援するために作られた Windows Application です。
ラスバレにはデスクトップアプリ(DMM版)が存在しているので、簡単に画面をキャプチャしてリアルタイムに解析することができます。
要素技術
WinUI 3 で作っています。画像認識に OpenCV 4、画像分類に ML.NET を使っています。
開発環境は Visual Studio 17.7.0-preview1 & .NET 8.0.0-preview4 です。
レギオンコンソール
メンバーの役職管理と札管理をする機能です。
オーダーコンソール
オーダー順を組む機能です。
所持オーダーを管理する機能や、オーダー担当を自動で割り当てる機能がついてます。
コントロールダッシュボード
レギオンマッチ支援機能の目玉です。
レギオンマッチのスクリーンショットを解析してリアルタイムで情報を表示する機能です。
現在サポートされているのは:
- 組んだオーダーを読み込んで
- 味方の打ったオーダーを認識し
- 次のオーダーを打つ時間に警告し
- 相手の打ったオーダーを認識し
- 相手オーダーが残り何秒で切れるのかを表示する
といった機能です。
今回はこの機能について詳しく解説していきます。
レギオンマッチ
以下はレギオンマッチの基本的な画面です。
左の青が味方で右の赤が敵です。
レギオンマッチは 9人 vs 9人 で戦います。
攻撃手の前衛が4人、サポートの後衛が5人です。
オーダー
レギオンマッチを有利に進めるためには、オーダーを使います。
オーダーには系統があり、同一系統のオーダーは1回しか使用できません。
また、オーダーは1人1回しか使えません。
しかしながら、「刻戻りのクロノグラフ」というオーダーを使用することで、使用回数をリセットすることが可能です。
これらのオーダーをできるだけ切れ目なく発動し続けることで、レギオンマッチを有利に進めることが可能であり、勝つためには必須です。
ダッシュボード
そこで作ったのがコントロールダッシュボードというわけです。
味方側:
- オーダーの順番を組んでいる(オーダーコンソール)
- 予定しているタイムラインからどれくらいずれているのか知りたい
- 味方オーダーが切れる前に次のオーダーを打つ人にアナウンスしたい
相手側:
- オーダーが切れる時間が表示されないので、オーダーがあと何秒で切れるのか知りたい
手法
OCRとOpenCVとDNNを使います。
味方側
OCRが活躍しました。
オーダーを使ったことを認識する
味方がオーダーを使った時には、文字が表示されます。
この文字が表示される場所をOCRにかけます。
WindowsランタイムAPIには Windows.Media.Ocr という OCR Engine が存在してもうてます。
これを使えばええ。
ただし、この領域にはオーダーを使ったこと以外にもたくさんの情報が表示されます。
加えて、OCRの精度も100%というわけにはいきません。
そこでオーダー情報だけを取得するために正規表現を使います。
OCRの精度が高い部分を正規表現で検知して、オーダーであるべき部分をキャプチャします。
そうして手に入れた OCR結果を正しいオーダーの一覧すべてとの編集距離をとります。
編集距離が一番近いものが使われたオーダーです。
精度は約100%です。
予定タイムラインとのずれを表示する
前回のオーダーを使った時間を記憶しておいて、オーダーが使われた時間との差分をとるだけです。
時間が押しいる場合にはこのオーダーはスキップするということが起こります。
オーダーコンソールでタイムラインを組むときにあらかじめスキップするオーダーを指定することができます。
時間が押している場合にはそのオーダーをタイムラインから外してくれます。
オーダータイムラインの動的な変更
オーダーはただ予定された順番に使えばいいというものではありません。
相手の使ったオーダーに対応して臨機応変に順番を変える必要があります。
順番通りに使われなかった場合には、状況に合わせてその場でタイムラインを再計算します。
相手側
OpenCVとDNNの世界です(OCRもあるよ)。
オーダー準備中、発動の瞬間、オーダー発動中
相手がオーダーを使った時には文字は(自動では)出ません。
したがって、相手がオーダーを使った瞬間をとらえるには画像認識を用います。
相手がオーダーを使った場合にはオーダー準備中のアイコンが出ます。
相手のオーダーが発動した場合はオーダーのアイコンがでかでかと出ます。
相手のオーダー発動中には発動中のアイコンが出ます(準備中と同じ場所に)。
ディープラーニングってやつでなんとかなりませんか?
準備中か発動中の場合は円(アイコン)が存在しているので OpenCV でそれを認識します。
準備中か発動中かを判定するために DNN + ResNET50 によってディープラーニングを行いモデルを作り、Image Classification を行います。同様に発動の瞬間を検知するためにもディープラーニングを用いました。
精度は95%くらいですかね。
相手が使ったオーダーの種類がわからなくていいのか?
実はオーダーは準備にかかる時間によって発動時間が(ほぼ)決まっていて、なんとかの盾を除けば準備時間から発動時間が一意に定まります。
つまり、準備中を検知してから発動を検知するまでの時間を計測すれば、オーダーが切れる時間を知ることができます。
とはいえオーダーの種類まで知るといいことがある
実は相手のオーダーのアイコンをタップすると情報が表示されます。
ということで補助機能として、相手オーダー情報を味方オーダーの手法と同様に OCR にかけて何を発動しているのかを取得できるようにしておきました。
なんらかの理由で準備中から発動までの時間の計測に失敗しても、準備中にタップしておいて情報を読みとっていれば、発動の瞬間には読み取った情報を参照するようにしています。
リアルタイム性との闘い
作り始めた当初は C# には GC があるからメモリの管理なんて適当でええやろと思っていました。
しかし、リアルタイム性のあるアプリで派手に GC が起こるのは致命傷でした。
重い処理が同時に走るのも致命的でした。
結果的に
- スクリーンショットした画像を保存するメモリは自前で管理
- 処理は5個のサブスクライバーに分割して 40 ms 間隔でイベント発火するように調整
- 処理間の依存は Latch で保護
のように、とても難しいプログラミングとなってしまいました。
細かい処理の調整には Reactive Extensions を利用しています。
// 全体のウィンドウキャプチャ
Observable.Interval(TimeSpan.FromMilliseconds(200), _schedulers[0])
.Subscribe(async delegate
{
_captureEvent.Reset(1);
await _capture!.SnapShot();
_captureEvent.Signal();
});
// 相手のオーダー情報を読取る
Observable.Interval(TimeSpan.FromMilliseconds(200), _schedulers[1])
.Subscribe(async delegate
{
// ...
_captureEvent.Wait();
// ...
});
// 味方のオーダー情報を読取る
Observable.Interval(TimeSpan.FromMilliseconds(200), _schedulers[2])
// ReSharper disable once AsyncVoidLambda
.Subscribe(async delegate
{
// ...
_captureEvent.Wait();
// ...
});
// 相手のオーダーの 準備/発動/終了 をスキャンする
Observable.Interval(TimeSpan.FromMilliseconds(200), _schedulers[3])
// ReSharper disable once AsyncVoidLambda
.Subscribe(async delegate
{
// ...
_captureEvent.Wait();
// ...
});
// 味方オーダーの発動前通知を出す
Observable.Interval(TimeSpan.FromMilliseconds(200), _schedulers[4])
.Subscribe(delegate
{
// ...
});
初期化時にあらかじめスケジューラの時間をずらしておきます。
こうしておくことでイベントが 40 ms ごとに発火していきます。
これによって IO の時間が被るのを防いでいます。
_schedulers[3].AdvanceBy(TimeSpan.FromMilliseconds(40));
_schedulers[2].AdvanceBy(TimeSpan.FromMilliseconds(80));
_schedulers[1].AdvanceBy(TimeSpan.FromMilliseconds(120));
_schedulers[0].AdvanceBy(TimeSpan.FromMilliseconds(160));
Discussion