unity ECSでanimation

TL;DR

この動画を見ろ youtu.be

とはいえ検索エンジンで日本語でなにかしら引っかかってくれればうれしいので記事を書く

動画のやり方はざっくりこんな感じ

  • まずはCharacterControllerなりでECSで動くオブジェクトを作る
  • animationを動かす用のGameObjectを生成する。GameObjectなのでECSとは何も関係ない
  • GameObjectでanimationを動かす
  • ECS上のEntityの座標が変わってもanimationを動かすGameObjectは位置が変わらないので、Entityの座標をGameObject側に反映するコードを書く
  • entityを削除したときGameObjectも消えるようにしてあげる

「え?それECSで動いてるって言わないじゃん!」

ごもっともです。とはいえ、GameObjectの物理演算(PhysX)とECS上の物理演算は干渉してくれないので、こういう逃げ道を選ぶことも現状だと必要です。animationさせるオブジェクトの数がしれてるならこのやり方でも特に問題なく動くでしょう。

ちゃんとECSベースで動かすためのアセットも紹介されていて、


GPU ECS Animation Baker assetstore.unity.com


Rukhanka - ECS Animation System assetstore.unity.com


などがある。使用感の個人的な好みとしてはRukhankaだが、どれくらい使い物になるかは未知数なのでいつかのタイミングで検証したい

C#でawsのalb用にヘルスチェックを実装する方法

MagicOnionで動くサーバーアプリケーションをデプロイしていて、albのヘルスチェックを通す必要があった。C#で実装自体はやり方が書いてあって learn.microsoft.com

builder.Services.AddGrpcHealthChecks()
    .AddCheck("sample", () => HealthCheckResult.Healthy());
app.MapGrpcHealthChecksService();

でヘルスチェックを実装できることは分かった。ただ、公式ページにはC#でヘルスチェックを呼び出す方法はあったが、AWSのALB(厳密にはターゲットグループ)でヘルスチェックを通すためにどうすれば良いかわからなくてちょっと躓いたのでメモ

単にパスに

/grpc.sample.v1.Health/Check

と指定すればよいだけだった。grpcのヘルスチェックの実装には仕様があり、

github.com

その仕様によるとCheck()を呼び出すことでヘルスチェックができる。ターゲットグループのパスはhogehoge.co/index.htmlの /index.htmlと同等で、通常のwebアプリならヘルスチェックようのエンドポイントを用意してそれを指定すればよいが、grpcの場合は特殊な記法が指定する必要があってこのような記載になっているらしい

行動の細分化と行動可能リストを用いた強化学習

まだまだいろいろと検証しないといけないことが多いのですが、終わった後に記事にしようとしてもおそらく細かいところを覚えていないので、いまわかっている段階でメモ

アジェンダ

  • こんなゲームを作っています
  • 読んだ本
  • 実装など

ゲーム

趣味でゲームを作っています。といってもロジックだけで絵やUIに関してはほとんど手を付けられていませんが

  • カードがあって
  • それを召喚して
  • 同じカードを重ねて進化して
  • 相手のライフを削り切ったら勝ち

みたいなゲームです。

相変わらず完成させるモチベがなく、ふと強化学習をやってみたい気分になり、やってみることにしました。

ただ、強いAIを作ればよいのではなく、パラメータ調整に利用したいので以下のようなこだわりがあります

  • 人間のプレイデータによる教師あり学習は行わない(自動生成ならセーフ)
  • 学習時間はできるだけ短くする

この二つの条件を満たせるように強化学習の仕組みを作ろうとしています

読んだ本

強化学習」を学びたい人が最初に読む本 「強化学習」を学びたい人が最初に読む本 | 伊藤 真 | 工学 | Kindleストア | Amazon

この本をじっくり読みました。これ以外の本は特に読んでません。ちょっと手詰まり感もなきにしもあらずなので、強化学習系の他の本も読みつつ、より賢くしていこうかなと思います

強化学習とは

ゲームにおける強化学習では、実際にゲームが動いている環境と、それを用いて学習するエージェントがあります。

流れとしては

  1. エージェントは環境に対して、今の環境情報(ターン数、HP、盤面情報)を受け取ります
  2. そして、その環境情報をもとに、エージェントが行動を選択します
  3. 行動した選択をもとに、環境は報酬を与えます
  4. エージェントはもらった報酬をもとに、行動が妥当かどうかを学習します

といった形で行い、報酬ベースでエージェント自ら学習していくという特徴があります。

DQN

今回はDQNと呼ばれる手法をメインに採用しました。Q学習のQテーブル、Q値を深層学習に置き換えたものです。

Q値とは何ぞやとなると思うので軽く説明すると、環境が与えられたときに各行動でどれくらい将来的に報酬がもらえるかを返すものです。詳しく説明すると長くなるので書籍を参照してください。

行動の細分化

このDQNを自分が開発しているゲームに適用するにあたって問題になるのは行動の多さです。

例えば、モンスターの攻撃を行うとすると、

どのタイルのモンスターか(タイルの数23通り) * モンスターの行動パターン(攻撃、進化など5通り) * どのタイルの攻撃先モンスター(タイルの数23通り)

23 * 5 * 23 = 2645

で2645 通りになります。これだけでも多いのですが、タイルが複数選択になったりすると、どんどん行動空間が膨れ上がってしまい、学習が現実的ではなくなります。

そこで、モンスターの攻撃のときは

  1. タイルを選択する
  2. 攻撃を選択する
  3. タイルを選択する

という風に格ゲーのコマンドの要領で細分化することで、行動空間を小さくすることにしました。このやり方の場合行動の数は

タイルの数 + 手札の数 + モンスターの行動の数 + ターンエンド(1)

で40通りくらいで済みます

それに合わせて、環境からはタイル選択など直前に行った行動と、「行動可能リスト」というものを与えるようにしました。行動可能リストはその状態で選択できる行動をリスト化したもので、もしモンスターのいるタイルを選択した場合は

  • 「攻撃」
  • 「移動」
  • 「進化」
  • 「ターンエンド(これは色々あって常に選択できるようになっている)」

を行動可能リストとしてエージェントに与えるようにしています。こうすることで、エージェントはどの行動を選択できるのか判断できるようになります

不可能な行動にペナルティを与えるのではだめなのか

こういった強化学習を実装するにあたって、一般的なパターンはエージェントが不可能な行動をしたときに敗北処理を行ったりすることだと思いますが、そのやり方は行っていません

  • 全行動に対して不可能な行動の比率が高いので、ランダムで行動したとき可能な行動を選択できない
  • 魔法カードなどはカード選択後の行動にバリエーション(カードを使うだけ、タイルを選択する等)があり、これを学習させるのは難しい

といった理由からエージェントはランダム行動を行う時も行動可能リストから選んで行うようにしています。

実装

行動の組み合わせを定義する

  • 召喚 手札カード選択→召喚選択→タイル選択
  • 進化 タイル選択→進化選択→手札カード選択
  • 移動 タイル選択→移動選択→タイル選択
  • 攻撃 タイル選択→攻撃選択→タイル選択

という風に定義しました。

HTTPサーバーを立てる

エージェントから環境へアクセスするためのサーバーを立てます。私のゲームはロジックがUnity非依存なので、ASP.NET Coreで鯖を立てました。どの道オンラインゲームで提供するときはUnityを使わずにサーバーを立てたいので、こういう作りにしています。

モンキーテストのプレイデータを学習させる

ランダムに行動したプレイデータをC#で作成し、エージェントに学習させます。単にランダムに行動しても戦いが終わらない(というかデッキが切れて負ける)ので、モンスターの移動選択をしたときは前側のタイルを選択する確率を上げたりして対戦に決着がつくようにしました。また、Q値の更新は通常選択した行動に対してのみ行われますが、モンキーテスト時は行動可能リストにない行動に対するQ値は-99999(敗北時の報酬)を入れて学習させています。

モンキーテストが落ちまくったので直すのが結構大変でした。

通常の強化学習

一定確率でランダム行動するエージェントで、強化学習を行います。モンキーテストのプレイデータを学習させているので、ある程度報酬をもらうように動いてくれます。ターンエンドするだけの相手に対しては安定して勝てるようになりました。現時点で学習にかかっている時間は10分なのでそこそこ上出来かなと思っています。

モンキーテストも同様ですが、タイル選択後の行動数的にターンエンドが選ばれる確率が高いので、ターンエンドが選ばれにくくなるよう実装の工夫が必要です。

今後

魔法カードなどいろんな要素を増やして学習できるか調べようかなと思っています。後、デッキ編成もAIが自分で考えてできるようにしてほしいのですが、それをどのように実装するかは考えているところです。

AWSソリューションアーキテクトアソシエイトに合格しました

ちなみに受かったのは4月なのでもう4か月くらい経ってしまいました。

AWSは業務でゴリゴリに使うことはないですが、EC2とかlambdaを無料枠で使ったりくらいはしたことがある程度の経験です。

AWS認定資格試験テキスト AWS認定ソリューションアーキテクト-アソシエイト https://www.yodobashi.com/produt/100000086601241124/

AWS認定ソリューションアーキテクト-アソシエイト問題集 https://www.yodobashi.com/product/100000009003459352/

を二週間くらいかけて頭から読んで問題を解いたら合格できました。本番解いて思ったのは、思ったよりテキストに載っていない最新の情報が出題されることと、出題されている問題の日本語があやしかったことです。このあたりをカバーしたいならAWSの公式で提供されている模擬問題を解いておけば、万全の状態で試験に臨めるかなと思います。

オートスケールまでのタイムラグをSQSを間に挟むことで対応するような考え方は知らなかったので勉強になりました。

エディタ拡張でScene View上で常に輪郭を表示する

layoutgroupだったり、スクリプトで動的に何かを生成する都合上、表示上はなにもないがサイズが欲しいrectだったりというのが画面レイアウトするうえで出てくることがある。 これらは選択したらrectの輪郭が表示されるが、親のrectからはみ出ていないか確認しながらサイズ調整をしたいので、常に表示されてほしいと思った。

ので。

[InitializeOnLoad]
public class CreateFrameOnSceneView
{
    static CreateFrameOnSceneView()
    {
        SceneView.duringSceneGui += OnGui;
    }
    private static void OnGui(SceneView sceneView)
    {
        var objs = GameObject.FindGameObjectsWithTag("Frame");
        foreach (var obj in objs)
        {
            var v = new Vector3[4];
            obj.GetComponent<RectTransform>().GetWorldCorners(v);
            Handles.color = new Color(0, 0, 0, 1);
            Handles.DrawLines(new Vector3[]{ v[0], v[1], v[1], v[2], v[2], v[3], v[3], v[0]});
        }
    }
}

これでTagに「フレーム」を当てると常に表示されるようになる。

f:id:Piffett:20211201191140p:plain

だいぶ見にくいが、見にくいくらいでちょうどよい。 FindGameObjectsWithTagを高頻度で読んでしまっているのでちょっと不安

MessagePipe使ってみる

メッセージングライブラリのMessagePipeがリリースされました。

qiita.com

github.com

いろいろ書いてあるんですが、動的にいろいろ動かしてみたいのでアクション寄りでいじってみたいと思います。

DIとかUnitaskとか知らない状態で雰囲気でやっているのでご了承ください。

リポジトリはこちらです github.com

f:id:Piffett:20210523195037p:plain

というわけで2Dでいつもの画面を起動します。

MessagePipeには内蔵で軽量のDIコンテナライブラリが付属しているらしいですが、今回は良さげらしいVCointainerを使ってみます。

UniTaskなどいろいろ依存ライブラリがあるので、Packages/manifest.jsonに以下のもろもろを追加します。

{
  "dependencies": {
    "...": "..."
    "com.unity.modules.vr": "1.0.0",
    "com.unity.modules.wind": "1.0.0",
    "com.unity.modules.xr": "1.0.0",
    "jp.hadashikick.vcontainer": "https://github.com/hadashiA/VContainer.git?path=VContainer/Assets/VContainer#1.8.2",
    "com.cysharp.unitask": "https://github.com/Cysharp/UniTask.git?path=src/UniTask/Assets/Plugins/UniTask#2.2.5",
    "com.cysharp.messagepipe": "https://github.com/Cysharp/MessagePipe.git?path=src/MessagePipe.Unity/Assets/Plugins/MessagePipe#1.4.0",
    "com.cysharp.messagepipe.vcontainer": "https://github.com/Cysharp/MessagePipe.git?path=src/MessagePipe.Unity/Assets/Plugins/MessagePipe.VContainer#1.4.0"
  }
}

Scriptsフォルダを作って以下のC#ファイルを用意します。

MainLifetimeScope.cs

using MessagePipe;
using UnityEngine;
using VContainer;
using VContainer.Unity;

public class MainLifetimeScope : LifetimeScope
{

    protected override void Configure(IContainerBuilder builder)
    {
        var options = builder.RegisterMessagePipe(/* configure option */);
        builder.RegisterMessageBroker<TheWorld>(options);
    }
}

public class TheWorld
{
    public float speed;
}

ファイル名の末尾が「LifetimeScope」だとLifetimeScopeから継承した実装のファイルが用意されます。今回はTheWorldという名前のclassを作って、このデータを通知したいと思います。

Scene内に空のGameObjectを使って先ほど作ったMainLifetimeScopeをアタッチします。

f:id:Piffett:20210523204701p:plain

今回はシューティングゲームでも作ろうと思うので、Playerを作ります。これは上下左右に動くだけです。

Player.cs

using UnityEngine;

public class Player : MonoBehaviour
{
    void Update()
    {
        if (Input.GetKey(KeyCode.LeftArrow))
        {
            this.transform.Translate(-0.02f, 0.0f, 0.0f);
        }
        if (Input.GetKey(KeyCode.RightArrow))
        {
            this.transform.Translate(0.02f, 0.0f, 0.0f);
        }
        if (Input.GetKey(KeyCode.UpArrow))
        {
            this.transform.Translate(0.0f, 0.02f, 0.0f);
        }
        if (Input.GetKey(KeyCode.DownArrow))
        {
            this.transform.Translate(0.0f, -0.02f, 0.0f);
        }
    }
}

Enemyを作ります。messagepipeで通知を受け取ったら速度が遅くなるという仕様にしたいと思います。Playerに当たっても特に何も起きません。

Enamy.cs

using MessagePipe;
using UnityEngine;

public class Enemy : MonoBehaviour
{
    float random = 0.0f;
    float speed = 1.0f;
    ISubscriber<TheWorld> OnStop { get; set; }

    private System.IDisposable disposable;
    public void SetUp(ISubscriber<TheWorld> theWorld)
    {
        OnStop = theWorld;
        var d = DisposableBag.CreateBuilder();
        OnStop.Subscribe(ev =>
        {
            speed = ev.stopSpeed;
        }).AddTo(d);

        disposable = d.Build();
        random = Random.Range(-0.01f, 0.01f);
    }

    void Update()
    {
        this.transform.Translate(random * speed, -0.03f * speed, 0.0f);
    }

    private void OnDestroy()
    {
        disposable.Dispose();
    }
}

f:id:Piffett:20210525090417p:plain

EnemyはPlafab化

ふと思い出してBox Collider 2DとRigidBody 2DをPlayerに付けました。

Enemyを上から降らせるやつを作ります。

using MessagePipe;
using System.Collections;
using UnityEngine;
using VContainer;

public class EnemyProducer : MonoBehaviour
{
    [SerializeField] GameObject enemy;
    [Inject]ISubscriber<TheWorld> OnStop { get; set; }
    void Update()
    {
        if(Random.Range(0, 30) == 15)
        {
            var obj = Instantiate(enemy, new Vector3(Random.Range(-2.0f, 2.0f), 4.5f, 0.0f), Quaternion.identity);
            var enemy = obj.GetComponent<Enemy>();
            
            enemy.SetUp(OnStop);

            StartCoroutine(DeleteObj(obj));
        } 
    }

    IEnumerator DeleteObj(GameObject gameObj)
    {
        yield return new WaitForSeconds(3);
        Destroy(gameObj);
    }
}

こんな感じ(画質荒)

f:id:Piffett:20210526052716g:plain

MessagePipeで通知を送るために、当たったらすべてのEnemyを遅くするオブジェクトを作ります。

using MessagePipe;
using UnityEngine;
using VContainer;

public class Stopper : MonoBehaviour
{
    [Inject] IPublisher<TheWorld> stopEvent { get; set; }
    private void OnCollisionEnter2D(Collision2D collision)
    {
        stopEvent.Publish(new TheWorld() { stopTime=1.0f });
    }
}

こいつを上から降らせてもいいんですが、めんどくさいので右下に置きます。 Stopper.csをアタッチします。ついでにColiderとRigidbodyもアタッチします

最後にLifeTimeに通知を送る側と受け取る側のオブジェクトをAuto Inject GameObjectsにいれます。 f:id:Piffett:20210527064648p:plain

そうすると、[Inject]がついているIPublisherとISubscriberがInjectされて、StopperとEnemyProducerはそれぞれTheWorldのISubscriberとIPublisherでデータをやり取りできるようになります。そして、EnemyProducerはISubscriberをEnemyに受け渡しています。

最初はEnemyをLifeTimeのAuto Inject GameObjectsに直接放り込んだら行けるんじゃないかと思っていたのですが、vContainerではそういうやり方はできないみたいです。

動作はこんな感じになります(なんでこんなに色味がおかしいのかはわかってません。どっかでちゃんと編集ソフトの使い方覚えます)

f:id:Piffett:20210527071552g:plain

今思ったんですけどこれだったら別にボタン押して止まる仕様でもよかったですね。最初は頑張ってシューティングゲームを作ろうとしていたので中途半端な形になってしまいました。

UniRxとかをちゃんと使ったことがないので、使いどころはまだわかりかねています。どっかでちゃんと勉強しないとですね。

ではまた

FPSを分析する項目を考える

 ゲームの批評でもしようかなと思った。ビデオゲームの美学を読んで感動したものの、何に感動したのか全部忘れてしまった。それに、批評するほどゲームをやってない。今回は雑に、FPS(TPS含む)あたりの要素を取り上げていきたい。

やってきたゲーム

全てのFPSに共通する撃ち合いに関して

 これは単純に銃を使った撃ち合いを指す。FPSの最も面白い場面が撃ち合いであり、この撃ち合いを有利に進めるために<勝てるポジション取りをしたり、スキルを使ったり道具を使ったりする>というのがFPSの基本である。そして、それぞれのFPSゲーム固有の事情を無視しても差が出るスキルが存在し、キャラコン(WASDをベースとしてキャラクターを思い通りに動かす力)やエイム力、リコイルコントロール(銃の反動を押さえる力)などが含まれる。この中でキャラコンは多少それぞれのゲームの仕様によって事情が変わる。例えばリーンという首を傾ける操作は仕様としてできないゲームが多い。

勝利目標

 何をもってクリアとするかである。要はジャンルである。ジャンルごとに特徴がある。

チームデスマッチ

 単純に撃ち合う。最もシンプルであり、KPM(Kill per minute:1分当たりのキル数)が多いゲームである。チームごとのキル数で相手チームを上回る、あるいは指定のキル数に早く到達することで勝利となる。一般にカジュアルに撃ち合うFPSジャンルとされる。旗取り系も勝手にこれに入れている。

ボム

 片方のチームが爆弾を設置し、目標を破壊する。もう片方は設置を阻止するか、設置された爆弾を解除することで勝利となる。必ずしもキルは必要ではなく、撃ち合いがほとんど生じずに1ラウンドが終了するケースもある。マップ理解が強く求められ、キャラや武器性能が変わらなくてもメタが回りやすい傾向にある。

バトルロワイヤル

 最後まで生き残っていれば勝利となる。最もKPMが低く、一度の撃ち合いのバリューが高い。多くのゲームでは撃ち合いに勝利して相手を倒すと相手の物資を漁ることができる。また、相手を倒したものの別の人に参戦されて倒されたり物資を奪われる<漁夫>という概念が存在する。そのため、撃ち合いの場面を自分で取捨選択することが強く求められる。

FPS,TPS

 これは一人称か三人称かを指す。一人称はそのままだが、三人称であるTPSゲームは視点を個別に動かす手段が存在するケースが多い。そして三人称には一人称と比べて<一方的に敵を視認しやすい>という特徴がある。壁際にポジションを取って視点を動かしたり、丘の上にいたりすると起きる。一人称の場合、丘の上であれば三人称視点同様に有利に撃ち合うことはできるものの、一方的に視認することはない。よって、一人称よりも有利なポジションを如何にとるかが勝敗を左右する。

TtK(Time to kill)

 撃ち合いが始まった時に、どちらかがデスもしくはダウンするまでの時間である。一般的には時間ではなくアサルトライフル系の弾数でカウントされる。ヘッドショットで一発、胴体で3~4発といったケースが多い。Apex Legendsなどは14発近く必要であり、TtKが非常に長いゲームである。TtKによって立ち回りが変わり、特にクリアリング(敵がいないか確認する)に対する労力が異なる。TtKが短いゲームでは<相手より先に銃を撃つ>ことが非常に重要であり、小さいスケールでの<相手がどこにいるかの情報>が重要になる。一方TtKが長いゲームでは、ダメージレースに勝つことが重要になる。ApexLegendsで言うと、先に相手を一人ダウンできるかどうか、あるいは相手に先に攻撃されても、削られたプレイヤーが戦線から逃げることができるかといったことが撃ち合いの勝率に強く作用する。

スキル、ガジェット

 ゲームによって異なるが、相手の位置情報をスキャンしたり、体力を回復したりといった、それ自体は撃ち合いではないが、撃ち合いを有利にする能力のことを指す。特別な銃が使えるといった、それ自体が撃ち合いに近いものも存在する。

情報系

 カメラであったり、スキャン効果によって敵の位置を把握できる能力である。あるエリアに敵がいるかいないかを把握することで立ち回りを有利にするタイプのものや、寸分狂わず相手の位置を把握できることで非常に有利な撃ち合いをできるようにするもので事情が変わってくる。

回復系

 減ったHPを回復したり、一時的にHPを増強したりする。スキルの有効性はTtKと強く相関があり、即死するようなゲームでは有効に使うのが難しい。HyperScapeで全体的に武器を弱体化すると、使われなかったヒールのスキルが使われるようになるというような事例もある。

罠系

 設置し、相手が通過することで何らかの効果を及ぼすようなもの。相手が通過することを把握できるようなケースが多く、この場合はある種の情報系ともいえる。情報系と違い、一度設置してしまえば複雑な操作などが不要である。

移動系

 本来いけないところに行けるようになったり、射線を切って自分の行きたいところに行くといった様々な特徴がある。立ち回りに選択肢を与えるケースが多く、そのスキル自体が直接撃ち合いを有利にするケースは少ない。

投げもの

 グレネードであったり、フラッシュバンなどである。グレネードは相手を強いポジションからどかすのに用いる。フラッシュバンは相手の視界を奪うものであるが、レインボーシックスシージでは自分の動作音を消したり、投げ物を吸収する相手のガジェットを無効化するのに用いられる。

 銃である

サブマシンガンアサルトライフル

 ボタンを長押しすると連射される基本的なタイプである。DPS(damage per second:1秒当たりのダメージ量)が高く、武器によって得意なレンジが異なる。

スナイパー

 単発であり、遠距離武器である。基本的には遠距離が有利な武器ではあるが、一撃必殺の場合はその限りではない。その場合はサブマシンガンアサルトライフルと違って<当たるか当たらないか>の二択になり、当たらない時のデメリットが大きく、チームで削って倒すという方針が立てにくくなる。

ショットガン

 近距離であれば圧倒的な火力を持つ。<撃って下がる>を瞬間的に行って撃ち合うことが多く、この戦術をとることで被ダメージ量を最小限に抑えることができる。純粋な正面での撃ち合いではむしろサブマシンガンにDPSで劣るケースが多い。

あとがき

 というわけで自分の中にあるなんとなくのFPSに関する知識を整理した。気が向いたらそれぞれのFPSの面白さや敷居の高さなどについて語ろうと思う。