20200907のC#に関する記事は6件です。

【Unity】ボタンを押したらハート送りまくる機能を実装する【Particle System】

ボタンを1回押すと1つハートが出てきます。

【やってること】

・ボタンとParticle Systemを連携
・OnButtonDownを検知するたびにParticle SystemをPlay()

【環境】

・Mac OSX El Capitan
・Unity versiton:2018.3.0

【結果】

https://youtu.be/K2ZmyMT1PFo

【作り方】

最終的な配置イメージ
スクリーンショット 2020-09-07 22.36.49.png

①Particle Systemを作成する

・Particle Systemをシーンに配置する
・Loopingのチェックを外す
・Start Speedの値を10にする
・PlayOnAwakeのチェックを外す
・EmissionのRate over Timeを0にする
・EmissionのBurstsを下図のように設定
  ※Burstsはパーティクルがどのタイミングでどれくらい出るかを設定できます。
  今回は押した瞬間に1つ出てきて欲しいので、Time=0、Count=1です。
 スクリーンショット 2020-09-07 22.01.03.png
・Limit Velocity over Lifetimeを下図のように設定
  ※SpeedとDampenの役割についてはこちらのサイトを参考にさせていただきました
   http://tsubakit1.hateblo.jp/entry/2017/05/03/211922
 スクリーンショット 2020-09-07 22.04.19.png
・Color over Lifetimeを下図のように設定
 スクリーンショット 2020-09-07 22.05.24.png

②Particle System用のMaterialを作る

・このハートのPNG画像をアセットに追加、Texture TypeをSprite(2D and UI)に変更する
 ※真っ白だから見えないけど↓ここにハートの画像があります。
 Like.png

・新しいMaterialを作成する
・ShaderをParticles/Standard Unitに変更する
・Render ModeをFadeにする
・ハートの画像をMaterialのAlbedoの□にドラッグ&ドロップする
・このMaterialをParticle SystemのRender内のMaterialに入れる

③押したらハートが出るボタンを作る

・Buttonをシーンに配置する
・FlashLike.csをアタッチする

FlashLike.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class FlashLike : MonoBehaviour
{
    public ParticleSystem likeEffect;
    // Start is called before the first frame update
    void Start()
    {
        likeEffect = likeEffect.GetComponent<ParticleSystem>();
    }

    public void PlayLikeEffect() {
        likeEffect.Play(); 
    }
}

・InspectorからFlashLikeコンポーネントのLikeEffectにParticle Ststemを代入する
・ButtonコンポーネントのOnClickからFlashLike,PlayLikeEffectを選択する
 スクリーンショット 2020-09-07 22.21.52.png

以上!完成!
Limit Velocity over LifetimeやColor Over Lifetimeの値をいじって好きなハートの出し方を探ってみてください!

 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Unity】ニコニコ動画みたいなコメント弾幕機能を作る

入力したコメントが右から左へ流れていくアレを実装します。

【環境】

・Mac OSX El Capitan
・Unity versiton:2018.3.0

【結果】

https://youtu.be/yNzuOzIHNgU

【作り方】

最終的な配置イメージ
スクリーンショット 2020-09-07 21.25.00.png

まずはInputFieldを作成します。
InputFieldコンポーネントのLineTypeをMultiLineNewLineにします。
※SingleLineのままだと日本語入力ができないので注意!

 こちらを参考にさせていただきました。
 http://chnr.hatenablog.com/entry/2015/03/06/011559

次にシーンにTextオブジェクトを作成します。
作成したTextオブジェクトの名前をTextPrefabに変更して下記スクリプトをアタッチします。

TextPrefab.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class TextPrefab : MonoBehaviour
{
    private Vector2 startPos;
    private RectTransform rectTransform;
    public float speed;
    // Start is called before the first frame update
    void Start()
    {

        rectTransform = this.gameObject.GetComponent<RectTransform>();
        float height = Screen.height;
        float MaxHeight = height / 2;
        float MinHeight = -MaxHeight;
        float width = Screen.width;
        float textHeight = rectTransform.sizeDelta.y;
        float textWidth = rectTransform.sizeDelta.x;

        //最初の位置を画面右端にする
        startPos = new Vector2(width / 2 + textWidth/2, Random.Range(MinHeight + textHeight/2, MaxHeight + textHeight / 2));
        rectTransform.localPosition = startPos;
    }

    // Update is called once per frame
    void Update()
    {
        //speedに応じて画面右から左へ流れていく
        transform.Translate(-speed * Time.deltaTime, 0, 0);

        //画面外へ出た場合は自身を削除する
        if (transform.localPosition.x < -Screen.width / 2 - rectTransform.sizeDelta.x/2) {
            Destroy(this.gameObject);
        }
    }
}

TextコンポーネントのRaycastTargetのチェックを外します。
※チェックがついたままだと、テキストがボタンの上を通過する際にボタンが押せなくなるので注意!

シーン上のTextPrefabをプレハブ化し、シーンに残っている方は削除します。

そして、Buttonオブジェクトを作成し、下記のGenerateText.csをアタッチします。

GenerateText.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;


public class GenerateText : MonoBehaviour
{

    public GameObject textPrefab;
    private Text text;
    public InputField inputField;
    public float speed = 200;
    // Start is called before the first frame update
    void Start()
    {
        text = textPrefab.GetComponent<Text>();
        inputField = inputField.GetComponent<InputField>();
    }


    public void GenerateTextPrefab()//テキストプレハブのテキストにインプットフィールドのテキストを代入して生成し、スピードを設定する。
    {
        text.text = inputField.text;
        GameObject newTextObj = (GameObject)Instantiate(textPrefab, transform.parent);
        newTextObj.GetComponent<TextPrefab>().speed = speed;

    }
}

InspectorでTextPrefab,InputFieldを代入。
Speedは任意の値を入れてください。
(だいたい200以上がいい感じです)

ButtonコンポーネントのOnClick()の+ボタンをクリックして新しい項を作成し、自身のButtonオブジェクトを入れます。
プルダウンからGenerateText/GenerateTextPrefabを選択します。
これでボタンを押すとTextPrefabが生成される機能が実装されました。

スクリーンショット 2020-09-07 9.18.24.png

本家のディテールに合わせるなら、文字数に合わせて速度が変えるとよさそう。
あとは文字数に合わせてTextPrefabのサイズを変更するとベター。
実装したら追記します。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

コピペで簡単!NLogをすぐに使えるようにしたい人へ

NLogはWindows界隈で有名なロガーライブラリです。それゆえに情報はたくさん出てくるのですが、とりあえず細かいことはおいておいてすぐにNLogを使いたい!という人向けに超ざっくりと導入手順をまとめました。

インストール方法

NuGetパッケージの管理からNLog.Configをインストールします。

Install-Package NLog.Config

インストールが完了すると、NLog.ConfigというXMLファイルが自動生成されます。
自動生成されたものでとりあえず十分なのでこのまま使います。

ラッパークラスを用意する

このままだとログ出力をするのに各クラスでLoggerクラスのインスタンスを生成しなければならず手間なので、以下のようなラッパークラスを用意します。

LogUtil.cs
public static class LogUtil
{
    private static readonly Logger _logger = LogManager.GetCurrentClassLogger();

    public static void Trace(string message)
    {
        _logger.Trace(message);
    }

    public static void Info(string message)
    {
        _logger.Info(message);
    }

    public static void Debug(string message)
    {
        _logger.Debug(message);
    }

    public static void Warn(string message)
    {
        _logger.Warn(message);
    }

    public static void Error(string message)
    {
        _logger.Error(message);
    }
}

使い方

以下のように呼び出すだけです。

LogUtil.Debug("ログ出力");

参考URL

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[WPF/C#] Prism(6.3.0)のKeepAliveをfalseにすることとIsNavigationTargetでfalseを返すことの違いの実験

もくじ
https://qiita.com/tera1707/items/4fda73d86eded283ec4f

Prism関連
https://qiita.com/tera1707/items/4fda73d86eded283ec4f#prism%E9%96%A2%E9%80%A3wpfxaml

コード
https://github.com/tera1707/WPF-/tree/master/024_PrismSample

やりたいこと

Prismの画面遷移をさせるうえで、
RegionManager.RequestNavigate("リージョン名",・・・);としているが、
その時に、画面のViewModelのクラスのインスタンスを一旦破棄して、再度遷移した際にもう一度作り直したい、というケースがあった。

その際、「インスタンスを破棄」とかでやり方を調べていると、

  • INavigationAwareインターフェースのIsNavigationTargetメソッドを実装して、そいつにfalseを返させる
  • IRegionMemberLifetimeインターフェースのKeepAliveプロパティを実装して、そいつをfalseにする

という二つのやり方が見つかった。

簡単に試したところ、どっちも
画面Aから画面BにRequestNavigateで遷移した後、再度画面Aに戻った際、画面Aのコンストラクタが呼ばれている。

つまり、どちらも画面Aは画面Bに遷移後、一旦破棄されてる??まったく同じことに対してやり方が2通りあるのか?
でもそんなわけがない、何か違いがあるはず、と思ったので、何が違うのか調べてみる。

今回試した結論

結果、下記である、自分の中ではなった。

やること 起きていること
①KeepAliveをfalseにする ViewModelのインスタンスが破棄される。
再度同じ画面にRequestNavigateした際に、ViewModelのインスタンスが再作成されている。
②IsNavigationTargetにfalseを返させる ViewModelのインスタンスは破棄されない
再度同じ画面にRequestNavigateした際は、前の画面のインスタンスは破棄せず保持で、新しいVMのインスタンスを作成し、そっちに遷移する。

つまり、両方ともコンストラクタが呼ばれる、イコール、新しいVMのインスタンスが作成されてるが、KeepAliveをfalseにする方は古い(別画面に遷移後の、元の画面の)インスタンスは破棄されてるが、IsNavigationTargetの戻り値をfalseにする方は、古いインスタンスが実は破棄されてない。

そのため、②のほうは、画面遷移回数が増えると作成される画面1のインスタンスも増えることになるので、おそらくメモリリークにつながる。
⇒画面のインスタンスを毎回破棄して、次回遷移時に再作成したい、という目的のために、IsNavigationTarget をfalseにする、ということを行ってはいけない!

今回やりたい「画面遷移するたびに毎回画面のVMのインスタンスを破棄したい」ということをする場合は、KeepAliveをOFFしてからRequestNavigateで画面遷移させるのが良い。

結論がでるまでに試したこと

実験コード

  • ViewModelのIsNavigationTargetメソッドに、通ったことがわかるようにDebug.WriteLineでログ出力処理を入れた
  • ViewModelのデストラクタに、通ったことがわかるようにDebug.WriteLineでログ出力処理を入れた
  • ViewModelに、自分自身の
    • IsNavigationTargetメソッドの戻り値の値をfalseにできるようにした
    • KeepAliveをfalseにできるようにした
実験コード1.cs
using Microsoft.Practices.Unity;
using Prism.Commands;
using Prism.Mvvm;
using Prism.Regions;
using PrismSample.Views;
using System.Diagnostics;

namespace PrismSample.ViewModels
{
    class UserControl1ViewModel : BindableBase, INavigationAware, IRegionMemberLifetime
    {
        [Dependency]
        public IRegionManager RegionManager { get; set; }
        public DelegateCommand ButtonCommand { get; }
        public DelegateCommand ButtonKeepAliveONCommand { get; }
        public DelegateCommand ButtonKeepAliveOFFCommand { get; }
        public DelegateCommand ButtonIsNavigationTargetONCommand { get; }
        public DelegateCommand ButtonIsNavigationTargetOFFCommand { get; }
        public DelegateCommand LoadedCommand { get; }

        private static int constructorCounter = 0;
        private static int destructorCounter = 0;

        public bool KeepAlive
        {
            get
            {
                Debug.WriteLine("画面1 KeepAlive is " + keepalive);
                return keepalive;
            }
            set
            {
                keepalive = value;
            }
        }
        private bool keepalive = true;
        public bool IsNavigationTargetFlag = true;

        public UserControl1ViewModel()
        {
            constructorCounter++;
            Debug.WriteLine("画面1 コンストラクタ " + constructorCounter + " 個目");

            this.LoadedCommand = new DelegateCommand(() =>
            {
                Debug.WriteLine("画面1 LoadedCommand");
            });

            this.ButtonCommand = new DelegateCommand(() =>
            {
                // Shell.xaml.csで作成したリージョンの名前と、画面のUserControlクラス名を指定して、画面遷移させる。
                // (パラメータを渡すこともできる)
                this.RegionManager.RequestNavigate("RedRegion", nameof(UserControl2), new NavigationParameters($"id=1"));
            });

            this.ButtonKeepAliveONCommand = new DelegateCommand(() => KeepAlive = true );
            this.ButtonKeepAliveOFFCommand = new DelegateCommand(() => KeepAlive = false);

            this.ButtonIsNavigationTargetONCommand = new DelegateCommand(() => IsNavigationTargetFlag = true);
            this.ButtonIsNavigationTargetOFFCommand = new DelegateCommand(() => IsNavigationTargetFlag = false);
        }

        ~UserControl1ViewModel()
        {
            destructorCounter++;
            Debug.WriteLine("画面1 デストラクタ " + destructorCounter + " 回目");
        }

        public bool IsNavigationTarget(NavigationContext navigationContext)
        {
            Debug.WriteLine("画面1 IsNavigationTarget  return value is" + IsNavigationTargetFlag);
            // このメソッドの返す値により、画面のインスタンスを使いまわすかどうか制御できる。
            // true :インスタンスを使いまわす(画面遷移してもコンストラクタ呼ばれない)
            // false:インスタンスを使いまわさない(画面遷移するとコンストラクタ呼ばれる)
            // メソッド実装なし:trueになる
            return IsNavigationTargetFlag;
        }

        public void OnNavigatedFrom(NavigationContext navigationContext)
        {
            // この画面から他の画面に遷移するときの処理
            Debug.WriteLine("画面1 NavigatedFrom");
        }

        public void OnNavigatedTo(NavigationContext navigationContext)
        {
            // 他の画面からこの画面に遷移したときの処理
            Debug.WriteLine("画面1 NavigatedTo");

            // 画面遷移元から、この画面に遷移したときにパラメータを受け取れる。
            string Id = navigationContext.Parameters["id"] as string;
        }
    }
}

実験3.cs
using Microsoft.Practices.Unity;
using Prism.Commands;
using Prism.Mvvm;
using Prism.Regions;
using PrismSample.Views;
using System;
using System.Diagnostics;

namespace PrismSample.ViewModels
{
    class UserControl3ViewModel : BindableBase, INavigationAware
    {
        [Dependency]
        public IRegionManager RegionManager { get; set; }
        public DelegateCommand<object> ButtonCommand { get; }
        public DelegateCommand LoadedCommand { get; }

        public UserControl3ViewModel()
        {
            Debug.WriteLine("画面3 コンストラクタ");

            this.LoadedCommand = new DelegateCommand(() =>
            {
                Debug.WriteLine("画面3 LoadedCommand");
            });

            this.ButtonCommand = new DelegateCommand<object>((param) =>
            {
                var kind = int.Parse((string)param);
                switch (kind)
                {
                    default:
                    case 0: RegionManager.RequestNavigate("RedRegion", nameof(UserControl1), new NavigationParameters($"id=1")); break;
                    case 1: RegionManager.RequestNavigate("RedRegion", nameof(UserControl2), new NavigationParameters($"id=1")); break;
                    case 2: RegionManager.RequestNavigate("RedRegion", nameof(UserControl3), new NavigationParameters($"id=1")); break;
                    case 3: RegionManager.RequestNavigate("BlueRegion", nameof(UserControl1), new NavigationParameters($"id=1")); break;
                    case 4: RegionManager.RequestNavigate("BlueRegion", nameof(UserControl2), new NavigationParameters($"id=1")); break;
                    case 5: RegionManager.RequestNavigate("BlueRegion", nameof(UserControl3), new NavigationParameters($"id=1")); break;
                    case 10: RegionManager.Regions["RedRegion"].RemoveAll(); break;
                    case 11: RegionManager.Regions["BlueRegion"].RemoveAll(); break;
                    case 91: GC.Collect(); break;
                }

            });
        }

        public bool IsNavigationTarget(NavigationContext navigationContext) => false;
        public void OnNavigatedFrom(NavigationContext navigationContext) => Debug.WriteLine("画面3 NavigatedFrom");
        public void OnNavigatedTo(NavigationContext navigationContext) => Debug.WriteLine("画面3 NavigatedTo");
    }
}

実験内容

その上で、

①RequestNavigateする前にKeepAliveをOFFする(IsNavigationTargetはtrueのまま)

  • 画面1で、
    • KeepAliveをOFFしてから別の画面(この場合画面2)にRequestNavigateする。
    • それを何度も繰り返す。

そうすると、下記のようなログが残る。

画面1 コンストラクタ 1 個目
画面1 NavigatedTo
画面1 LoadedCommand
画面1 NavigatedFrom
画面2 コンストラクタ
画面1 KeepAlive is False
画面2 NavigatedTo
画面2 LoadedCommand
画面1 デストラクタ 1 回目
画面2 NavigatedFrom
画面1 コンストラクタ 2 個目
画面1 NavigatedTo
画面1 LoadedCommand
画面1 NavigatedFrom
画面1 KeepAlive is False
画面2 NavigatedTo
画面2 LoadedCommand
画面2 NavigatedFrom
画面1 コンストラクタ 3 個目
画面1 NavigatedTo
画面1 LoadedCommand
画面1 NavigatedFrom
画面1 KeepAlive is False
画面2 NavigatedTo
画面2 LoadedCommand
画面2 NavigatedFrom
画面1 コンストラクタ 4 個目
画面1 NavigatedTo
画面1 LoadedCommand
画面1 NavigatedFrom
画面1 KeepAlive is False
画面2 NavigatedTo
画面2 LoadedCommand
画面1 デストラクタ 2 回目
画面1 デストラクタ 3 回目
画面1 デストラクタ 4 回目
画面2 NavigatedFrom
  • 画面1に遷移したとき、毎回コンストラクタが走る。
  • LoadCommand(Loadedイベント)も走る。
  • 時間経過すると、今表示中の画面1インスタンス以外が、デストラクタが走って回収される。
  • GC.Collect()をすると、時間経過しなくても、その時に表示が終わった画面1のインスタンスがGCに回収される。

②RequestNavigateする前にIsNavigationTargetの戻り値をfalseにする(KeepAliveはtrueのまま)

  • 画面1で、
    • IsNavigationTargetの戻り値をfalseにしてから別の画面にRequestNavigateする。
    • それを何度も繰り返す。

そうすると、下記のようなログが残る。

画面1 コンストラクタ 1 個目
画面1 NavigatedTo
画面1 LoadedCommand
画面1 NavigatedFrom
画面2 コンストラクタ
画面1 KeepAlive is True
画面2 NavigatedTo
画面2 LoadedCommand
画面2 NavigatedFrom
画面1 IsNavigationTarget  return value isFalse
画面1 コンストラクタ 2 個目
画面1 NavigatedTo
画面1 LoadedCommand
画面1 NavigatedFrom
画面1 KeepAlive is True
画面2 NavigatedTo
画面2 LoadedCommand
画面2 NavigatedFrom
画面1 IsNavigationTarget  return value isFalse
画面1 IsNavigationTarget  return value isFalse
画面1 コンストラクタ 3 個目
画面1 NavigatedTo
画面1 LoadedCommand
画面1 NavigatedFrom
画面1 KeepAlive is True
画面2 NavigatedTo
画面2 LoadedCommand
画面2 NavigatedFrom
画面1 IsNavigationTarget  return value isFalse
画面1 IsNavigationTarget  return value isFalse
画面1 IsNavigationTarget  return value isFalse
画面1 コンストラクタ 4 個目
画面1 NavigatedTo
画面1 LoadedCommand
画面1 NavigatedFrom
画面1 KeepAlive is True
画面2 NavigatedTo
画面2 LoadedCommand
画面2 NavigatedFrom
画面1 IsNavigationTarget  return value isFalse
画面1 IsNavigationTarget  return value isFalse
画面1 IsNavigationTarget  return value isFalse
画面1 IsNavigationTarget  return value isFalse
画面1 コンストラクタ 5 個目
画面1 NavigatedTo
画面1 LoadedCommand
  • 結果
    • 画面遷移時、コンストラクタは通らない。(=画面1のインスタンスは使いまわされている)
    • LoadedCommand(Loadedイベント)は実行されている。
    • 画面遷移時、IsNavigationTargetが毎回呼ばれている。
    • IsNavigationTarget をfalseにして画面遷移した回数だけ、IsNavigationTarget が呼ばれるようになってしまう
    • 時間がたっても、デストラクタは呼ばれない。
    • GC.Colect()を実行しても、デストラクタは呼ばれない。
      • =画面1のインスタンスは使いまわされている。
    • 画面のインスタンスが、画面遷移するたびに新しく作られ、さらにそれがGCに回収されないままたくさん残ってしまっている!?

そうなると、画面遷移回数が増えると作成される画面1のインスタンスも増えることになるので、おそらくメモリリークにつながる。
⇒画面のインスタンスを毎回破棄して、次回遷移時に再作成したい、という目的のために、IsNavigationTarget をfalseにする、ということを行ってはいけない!

思ったこと

現状正直なところ、仕事で出会った流用元のコードでprismを使ってて、prismを理解しないまま出てきたとこだけを場渡り的に調べて乗り切っている状態なので(この記事はその時のメモ)、prismの全体的な理解が足りてないために、こういう部分で詰まってしまう感触を感じ出した。

一度しっかりprismの基礎を勉強しなおした方が、結局は急がば回れで早いかもしれない。
(ただ年強するなら6.3.0ではなく新しいのを勉強したいが...)

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unity ML-Agentsでターン制ゲームを作る

機械学習を使って、人間対機械で対局可能なリバーシを作りました。

training_scene.png

前提

環境

  • Unity 2019.4.9f1 (LTS)
  • ML Agents 1.3.0 (Preview)
  • ML Agents Extension 0.0.1 (Preview)
  • Anaconda Python 3.8
  • Windows 10

記事の範囲

  • ML-Agentsをターン制ゲームへ応用できることを実証します。
    • 具体的には、人間対機械で対局可能なリバーシを作成します。
  • リバーシの戦術については、この記事では扱いません。
    • 筆者は、今回初めてリバーシのルールを正確に把握しました。
    • セオリーとして知っているのは四隅や四辺を取ると有利という程度のド素人です。
  • ML-Agentsの基礎については、この記事では扱いません。

このプロジェクトで扱うリバーシのルール

  • 8×8マスの正方形の盤を使用して、黒と白に分かれ石を交互に置いていきます。
    • 黒が先手です。
    • 盤中央には、あらかじめ、各色2個(計4個)の石を市松配置で置いておきます。
  • 新たに置かれる石と同色の石に直線上で挟まれた他色の石は色が反転します。
    • 縦横斜め方向に複数同時に挟むことができます。
  • 挟んで反転可能な石のあるマスにしか石を置けません。
    • 置けるマスがない場合は手番がパスされて相手の手番になります。
  • 両者とも石が置けるマスがなくなったら終局となり、色の多い方が勝者となります。

学習環境の設計

  • マスの基礎状態として「空、黒、白」の3値を正規化し、8×8=64マスをリニアに並べて(0~63)観測させます。
    • マスの状態を5値(空、黒、白、黒可、白可)として観測させる方法も考えられます。
  • 離散アクションスペースとして、石を置くマスのリニアインデックス(0~63)を使います。
  • ルール的に置けないマスをマスクして、行動の無駄を省きます。
    • 行動を制限せず、置けないマスを選んだらマイナス報酬を与えて、ルールから学習させる方法も考えられます。
  • 一手ごとに微かな報酬を加え、勝利した場合は最大報酬、敗北した場合は最低報酬に置き換えます。
    • 「強化学習の報酬は、結果に対して与える」ものなので、取ったマスの価値や一時的なスコアは考慮しません。

アプリの概略設計

目的

  • ML-Agentsのターン制ゲームへの応用を検証します。
    • そのため、ユーザを楽しませるための機能は実装しません。(リバーシ自体は楽しめます。)

クラス構成

  • 論理層 namespace ReversiLogic
    • 論理ゲーム class Reversi
      • 論理盤面の制御、ターンの制御、対局の制御
    • 論理盤面 class Board
      • 論理マスの制御、マスの状態(空、黒、白、黒可、白可)の判定、局面(黒可、白可、終局)の判定、ルールの制御
    • 論理マス class Square
      • マスの基礎状態(空、黒、白)の制御、石を設置した順序の記録
  • 物理層 namespace ReversiGame
    • ローダー class Loader
      • 物理ゲームの構築と制御、モードの制御、コマンドラインの処理
    • 物理ゲーム class Game
      • 物理盤面の構築と制御、ゲームの制御、エージェントとの通信
    • 物理盤面 'class BaordObject'
      • 物理マスの構築と制御、ステータス表示、オプションUIの制御
    • 物理マス class SquareObject
      • 論理マス状態の表示、クリックの受付
    • 確認ダイアログ class Confirm
      • 再初期化などの確認UIの制御
    • リバーシエージェント class ReversiAgent
      • Unity.MLAgents.Agentsのサブクラス

mla-Reversi.png

段階的な実装

  1. 論理層を実装して、ログ出力によって動作を確認します。
  2. 物理層を実装して、論理層の挙動をゲーム画面で確認可能にします。
  3. 物理層を拡張して、人間対人間プレイの挙動を確認可能にします。
  4. エージェントを実装して、機械対機械プレイを可能にします。
  5. 学習を実施します。
  6. 学習結果を取り込んで、人間対機械プレイを可能にします。
  7. スコアの表示や手番の選択など、人間対機械プレイ用のUIを整備します。
  8. 思考の練度を評価し、トレーニングの構成を試行錯誤します。

実装された仕様

機能

  • 人間対機械(プレイモード)と機械対機械 (トレーニングモード)のみで、選択UIはありません。
    • 起動時にモードを切り替えられます。 (コマンドライン引数)
    • 内部機構的には人間対人間も可能です。 (エージェントのパラメータを双方ともBehaviorType.HeuristicOnlyに固定)
  • プレイモード
    • 先手/後手の選択が可能です。 (UIで選択)
    • 次にコマの置けるマスと、コマを置いた順番が表示可能です。 (UIで切り替え)
  • トレーニングモード
    • 画面を更新しません。 (UIで切り替え)
    • 起動時に使用する盤面数を指定できます。 (コマンドライン引数)
  • 次の手番、最後に置いたコマ、現在のコマ数、累積勝敗数が随時表示されます。
  • 棋譜は記録せず、「待った」はできません。

コマンドライン引数

  • -trainer
    • トレーニングモードで起動します。デフォルトはプレイモードです。
  • -width <整数>
    • トレーニング時に横に並べる盤面の数です。デフォルトは7です。
  • -height <整数>
    • トレーニング時に縦に並べる盤面の数です。デフォルトは7です。

なお、エディタでトレーニングする場合のために、TRAINER_TESTシンボルで、コマンドライン引数のシミュレーションが可能です。

思考の評価

  • 私は、勝てることもありますが、負け越してます。(弱すぎて評価不能)
  • 複数の難易度が選べる無料アプリと対局させたところ、最弱レベルに辛勝する程度でした。(弱い)

エージェントのコードと解説

class ReversiAgent リバーシ・エージェント

  • これは、リバーシの思考を担うAgentのサブクラスです。
    • AgentMonoBehaviourのサブクラスです。
  • 一つの物理ゲームに、黒と白を担当する二つのReversiAgentインスタンスが作られます。
  • 本来は、エージェント自身がゲームの進行を制御するのでしょうが、このプロジェクトでは外部で進行が制御され、エージェントは思考のみを担うようになっています。
ReversiAgent.cs
using System.Collections.Generic;
using System;
using UnityEngine;
using Unity.MLAgents;
using Unity.MLAgents.Sensors;
using Unity.MLAgents.Policies;
using ReversiLogic;

namespace ReversiGame {

    /// <summary>リバーシ・エージェント</summary>
    public class ReversiAgent : Agent {

        /// <summary>チーム識別子</summary>
        public enum TeamColor {
            Black = 1,
            White = 0,
        }

        /// <summary>盤面のマス数</summary>
        private const int Size = ReversiLogic.Board.Size;
        /// <summary>物理ゲーム</summary>
        private Game game = null;
        /// <summary>論理ゲーム</summary>
        private Reversi reversi => game.Reversi;
        /// <summary>挙動パラメータ</summary>
        private BehaviorParameters Parameters => _parameters ?? (_parameters = gameObject.GetComponent<BehaviorParameters> ());
        private BehaviorParameters _parameters;
        /// <summary>チームID</summary>
        public int TeamId => Parameters.TeamId;
        /// <summary>チーム</summary>
        public TeamColor Team { get; private set; }
        /// <summary>黒である</summary>
        public bool IsBlack => Team == TeamColor.Black;
        /// <summary>白である</summary>
        public bool IsWhite => Team == TeamColor.White;
        /// <summary>自分が優勢</summary>
        public bool IWinner => (Team == TeamColor.Black && reversi.BlackWin) || (Team == TeamColor.White && reversi.WhiteWin);
        /// <summary>自分が劣勢</summary>
        public bool ILoser => (Team == TeamColor.Black && reversi.WhiteWin) || (Team == TeamColor.White && reversi.BlackWin);

        /// <summary>挙動タイプ</summary>
        public BehaviorType BehaviorType {
            get => Parameters.BehaviorType;
            set => Parameters.BehaviorType = value;
        }

        /// <summary>人間が操作</summary>
        public bool IsHuman {
            get => (Parameters.BehaviorType == BehaviorType.HeuristicOnly);
            set {
                if (reversi.Step == 0 || reversi.IsEnd) {
                    Parameters.BehaviorType = value ? BehaviorType.HeuristicOnly : BehaviorType.InferenceOnly;
                }
            }
        }

        /// <summary>機械が操作 (推論時のみ)</summary>
        public bool IsMachine {
            get => (Parameters.BehaviorType != BehaviorType.HeuristicOnly);
            set {
                if (reversi.Step == 0 || reversi.IsEnd) {
                    Parameters.BehaviorType = value ? BehaviorType.InferenceOnly : BehaviorType.HeuristicOnly;
                }
            }
        }

        /// <summary>チームカラーの入れ替え (チームIDは変更しない)</summary>
        public bool ChangeTeam () {
            if (reversi.Step == 0 || reversi.IsEnd) {
                Team = (Team == TeamColor.Black) ? TeamColor.White : TeamColor.Black;
                return true;
            }
            return false;
        }

        /// <summary>人間と機械の入れ替え</summary>
        public bool ChangeActor () {
            if ((reversi.Step == 0 || reversi.IsEnd) && Parameters.BehaviorType != BehaviorType.Default) {
                Parameters.BehaviorType = (Parameters.BehaviorType == BehaviorType.HeuristicOnly) ? BehaviorType.InferenceOnly : BehaviorType.HeuristicOnly;
                return true;
            }
            return false;
        }

        /// <summary>オブジェクト初期化</summary>
        private void Awake () => Init ();

        /// <summary>初期化</summary>
        public void Init () {
            if (!game) { // 一度だけ
                game = GetComponentInParent<Game> ();
                Team = (TeamColor) TeamId;
            }
        }

        /// <summary>エピソードの開始</summary>
        public override void OnEpisodeBegin () {
            Debug.Log ($"OnEpisodeBegin ({Team}): step={reversi.Step}, turn={(reversi.IsBlackTurn ? "Black" : "White")}, status={reversi.Score.status}");
            if (reversi.Step > 0 && game.State == GameState.Play) { Debug.LogError ("Not Reseted"); }
        }

        /// <summary>環境の観測</summary>
        public override void CollectObservations (VectorSensor sensor) {
            Debug.Log ($"CollectObservations ({Team}): step={reversi.Step}, turn={(reversi.IsBlackTurn ? "Black" : "White")}, status={reversi.Score.status}");
            for (var i = 0; i < Size * Size; i++) {
                sensor.AddObservation ((float) reversi [i].Status / (float) SquareStatus.MaxValue); // 正規化
            }
        }

        /// <summary>行動のマスク</summary>
        public override void CollectDiscreteActionMasks (DiscreteActionMasker actionMasker) {
            Debug.Log ($"CollectDiscreteActionMasks ({Team}): step={reversi.Step}, turn={(reversi.IsBlackTurn ? "Black" : "White")}, status={reversi.Score.status}");
            var actionIndices = new List<int> { };
            for (var i = 0; i < Size * Size; i++) {
                var status = reversi.Status (i);
                if ((IsBlack && !status.BlackEnable ()) || (IsWhite && !status.WhiteEnable ())) { // 自分が置けない場所
                    actionIndices.Add (i);
                }
            }
            actionMasker.SetMask (0, actionIndices);
        }

        // 例外
        public class AgentMismatchException : Exception { }
        public class TeamMismatchException : Exception { }

        /// <summary>行動と報酬の割り当て</summary>
        public override void OnActionReceived (float [] vectorAction) {
            Debug.Log ($"OnActionReceived ({Team}) [{vectorAction [0]}]: step={reversi.Step}, turn={(reversi.IsBlackTurn ? "Black" : "White")}, status={reversi.Score.status}");
            if (IsMachine) {
                try {
                    if (game.TurnAgent != this) throw new AgentMismatchException (); // エージェントの不一致
                    if ((reversi.IsBlackTurn && Team != TeamColor.Black) || (reversi.IsWhiteTurn && Team != TeamColor.White)) throw new TeamMismatchException (); // 手番とチームの不整合
                    var index = Mathf.FloorToInt (vectorAction [0]); // 整数化
                    if (!reversi.Enable (index)) { throw new ArgumentOutOfRangeException (); } // 置けない場所
                    game.Move (index);
                    Debug.Log ($"Moved ({Team}) [{index}]: step={reversi.Step}, turn={(reversi.IsBlackTurn ? "Black" : "White")}, status={reversi.Score.status}");
                    AddReward (-0.0003f); // 継続報酬
                } catch (AgentMismatchException) {
                    EndEpisode ();
                    Debug.LogError ($"Agent mismatch ({Team}): Step={reversi.Step}, Turn={(reversi.IsBlackTurn ? "Black" : "White")}, Status={reversi.Score.status}\n{reversi}");
                } catch (ArgumentOutOfRangeException) {
                    EndEpisode ();
                    Debug.LogWarning ($"DisableMove ({Team}) [{vectorAction [0]}]: step={reversi.Step}, turn={(reversi.IsBlackTurn ? "Black" : "White")}, status={reversi.Score.status}\n{reversi}");
                } catch (TeamMismatchException) {
                    Debug.LogWarning ($"Team mismatch ({Team}): Step={reversi.Step}, Turn={(reversi.IsBlackTurn ? "Black" : "White")}, Status={reversi.Score.status}\n{reversi}");
                } finally {
                    game.TurnAgent = null; // 要求を抹消
                }
            } else {
                Debug.LogError ($"{Team}Agent is not Human: step={reversi.Step}, turn={(reversi.IsBlackTurn ? "Black" : "White")}, status={reversi.Score.status}");
            }
        }

        /// <summary>終局処理と最終的な報酬の割り当て</summary>
        public void OnEnd () {
            Debug.Log ($"AgentOnEnd ({Team}, {(IWinner ? "winner" : ILoser ? "loser" : "draw")}): step={reversi.Step}, turn={(reversi.IsBlackTurn ? "Black" : "White")}, status={reversi.Score.status}");
            if (IsMachine) {
                if (IWinner) {
                    SetReward (1.0f); // 勝利報酬
                } else if (ILoser) {
                    SetReward (-1.0f); // 敗北報酬
                } else {
                    SetReward (0f); // 引き分け報酬
                }
                EndEpisode ();
            } else {
                Debug.LogError ($"{Team}Agent is not Human: step={reversi.Step}, turn={(reversi.IsBlackTurn ? "Black" : "White")}, status={reversi.Score.status}");
            }
        }

    }

}

using ReversiLogic; 論理ゲーム

  • これは、論理ゲームの名前空間です。
    • class Reversiclass Boardclass Squareなどが含まれます。
    • ここに含まれるクラスはMonoBehaviourを継承せず、Unityに依存しません。
  • リバーシのルールや盤面の状態などは、全てこの中にあります。

namespace ReversiGame 物理ゲーム

  • これは、物理ゲームの名前空間で、画面表示とUIが含まれます。
    • class Loaderclass Gameclass Boardclass Squareなどが含まれます。
    • ここに含まれるクラスはMonoBehaviourを継承したUnityコンポーネントです。

enum TeamColor チーム識別子

  • ML-Agentsでは、複数のチームに分かれて対戦や対局が可能なように、エージェントの挙動パラメータにTeamIdという整数値があります。
  • このプロジェクトでは、黒のチームと白のチーム(各ひとつのエージェント)があります。
    • トレーニング時、先手と後手それぞれの担当エージェントのTeamIdは固定されています。これは、バックエンド側で担当チームの交代が行われることを前提にしているためです。
  • これは、TeamIdの数値に合わせたシンボルです。

ChangeTeam () チームカラーの入れ替え (チームIDは変更しない)

  • このプロジェクトでは使用していません。
  • これは、先手と後手(黒と白)を交代する仕組みのひとつで、TeamIdの担当する色を入れ替えます。
    • 「エージェント0 = 白担当、エージェント1 = 黒担当」⇒「エージェント0 = 黒担当、エージェント1 = 白担当」
  • TeamIdTeamColorが一致しない状態が生じます。

ChangeActor () 人間と機械の入れ替え

  • これは、人間と機械の担当する色を入れ替える仕組みで、エージェントの挙動タイプを入れ替えます。
    • 「エージェント黒 = 人間担当、エージェント白 = 機械担当」⇒「エージェント黒 = 機械担当、エージェント白 = 人間担当」
  • エージェントの挙動タイプは、人間操作(HeuristicOnly)、機械推論(InferenceOnly)、機械学習(と推論の自動切り替え Default)の別です。

Init () 初期化

  • 一度だけ必要な初期化を行います。
  • 外部からの使用前に明示的に呼んでいますが、一応、Awake ()からも呼ばれます。

OnEpisodeBegin () エピソードの開始

  • このプロジェクトでの「エピソード」は「一局」に相当します。
  • 開始していない状態で、行動の決定を要求(RequestDecision ())されると、最初に呼び出されます。
  • あるいは、エピソードの終了(EndEpisode ())を行うと中で呼ばれます。
  • 本来はここでゲームを初期化するのですが、このプロジェクトは外部で初期化するため、チェックを行うのみで何もしていません。

CollectObservations () 環境の観測

  • 行動の決定を要求(RequestDecision ())される毎に呼ばれます。
  • 単純に盤面の8×8マスの石の配置状態を観測させます。

CollectDiscreteActionMasks () 行動のマスク

  • 環境の観測後に呼ばれ、選択不能な行動の選択肢をエージェントに示唆します。
  • ここでは、ルール上、石を置けないマスをマスクしています。

OnActionReceived () 行動と報酬の割り当て

  • 決定された行動に基づいて実際に石を打ち、その結果に応じた報酬を出します。
  • このプロジェクトでは、行動(一手)は単一のスカラー値、中身はマスのインデックス(0~63の整数値)で表されます。
  • 行動を整数値で生成するために、エージェントの挙動パラメータVectorAction SpaceTypeに対してDiscreteを指定しています。
  • 処理のほとんどはエラーチェックで、作用としては、決定された行動「石を置く位置(マスのインデックス)」を論理ゲームに渡し、微量の継続報酬を加算するだけです。
    • この継続報酬は、終局に至ると上書きされて捨てられます。
  • 本来は、終局の判定と勝敗に対する報酬の支払い、エピソードの終了などを、ここで処理するのですが、このプロジェクトではOnEnd ()に分離しています。
    • 人間の操作を外部で受け付けて処理している関係で、独立して呼び出せるようにしてあります。

OnEnd () 終局処理と最終的な報酬の割り当て

  • 本来は、OnActionReceived ()の中で行われる処理ですが、人間の操作を外部で受け付けて処理している関係で分離されています。
  • 終局を判定した際に外部から呼び出されます。
  • ここでの報酬は、それまでの報酬を上書きして、単純に勝てば1、負ければ-1になります。

Heuristic () 人間の入力

  • このプロジェクトでは、このメソッドを実装しません。
  • 人間の操作は、エージェントの外で制御されています。

ReversiAgentBehaviorParameters

コメント 2020-09-06 153906.png

トレーニングの構成

ReversiConfig.yaml
behaviors:
  Reversi:
    trainer_type: ppo
    hyperparameters:
      batch_size: 32
      buffer_size: 20480
      learning_rate: 3e-4
      beta: 5.0e-3
      epsilon: 0.2
      lambd: 0.95
      num_epoch: 3
      learning_rate_schedule: linear
    network_settings:
      normalize: false
      hidden_units: 512
      num_layers: 3
      vis_encode_type: simple
    reward_signals:
      extrinsic:
        gamma: 0.99
        strength: 1.0
    keep_checkpoints: 5
    max_steps: 500000
    time_horizon: 48
    summary_freq: 10000
    threaded: false
    self_play:
      save_steps: 20000
      team_change: 100000
      swap_steps: 10000
      window: 10
      play_against_latest_model_ratio: 0.5
      initial_elo: 1200.0

課題

以下のような課題が生じ、対処済み、あるいは、対処中です。

脈略なくOnEpisodeBegin ()が呼ばれる

  • 本来の学習サイクルは以下のようなものです。
    • ターンが廻ってきたエージェントに決定要求RequestDecision ()を行う
    • エピソードが開始されていない場合、エピソードの開始OnEpisodeBegin ()が呼ばれる
      • ここで、対局の初期化を行う
    • 環境の観測CollectObservations ()が呼ばれる
    • 行動のマスクCollectDiscreteActionMasks ()が呼ばれる
    • 行動と報酬の割り当てOnActionReceived ()が呼ばれる
      • 終局していたらエピソードの終結EndEpisode ()を行う
  • しかし、ML-Agentsのバックエンド側からの制御で、サイクルの途中でOnEpisodeBegin ()が呼ばれる場合があります。
    • 既にRequestDecision ()が行われていると、過去のエピソードに対するOnActionReceived ()が呼ばれます。
    • 盤面の初期化後に、過去の局面に対しての行動要求が届くので、不整合が生じます。
対処
  • config.yamlthreadedfalseにすれば、学習サイクル中の割り込みはなくなります。

弱い

  • 私が勝てるのだから弱いのは間違いありません。
対処
  • TensorBoardでモニターしています。
  • トレーニングの構成を見直しています。(研究中)

検討中

以下の公開については検討中です。(全てのリンクは無効です。)

アセットの入手 (GitHub)

ダウンロード ⇒ mla-Reversi.unitypackage
ソースはこちらです。

Android App

AI-Reversi


  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

WPF+ReactivePropertyでのViewModelの初期化処理

ViewModelのコンストラクタでデータベースとかWEBサービスにアクセスしたくない

コンストラクタであんまり重い処理を実行したくない。例外出ても嫌だ。ダイアログでIntaractinNotificationでパラメータもらってデータベースにアクセスしてモデルを更新するとか、コンストラクタ終わったあとなんじゃ?ってわけで。

View

Interaction.Triggers に Loadedイベントに反応するトリガーを記述する。

<Window>
        <i:Interaction.Triggers>
            <i:EventTrigger EventName="Loaded">
                <i:InvokeCommandAction Command="{Binding LoadedCommand}"/>
            </i:EventTrigger>
        </i:Interaction.Triggers>

</Window>

ViewModel

Loaded イベントに反応するコマンドを記述して、イベント処理内でデータベース処理とかする

public class FooViewModel : BindableBase
{
    private FooModel _model {get; set;}
    public ReactiveCommand LoadedCommand { get; set; } = new ReactiveCommand();
    public ReactiveProperty<string> Name {get; set;}
    public FooViewModel
    {
        // イベントに反応する
        LoadedCommand.Subscribe(OnLoaded);
    }
    public void OnLoaded()
    {
        // ここでデータベース処理とか
        using(var db = new AppDbContext())
        {
            _model = db.Foos.FirstOrDefault();
        }
        Name = _model.ObserveProperty(x => x.Name).ToReactiveProperty();
        // プロパティ変更通知しないと新しいReactivePropertyが機能しない
        RaisePropertyChanged(null);
    }
}

Prismを使っているので BindableBaseを利用し、変更通知にRaisePropertyChangedを使う。
Prismでなければ INotifyPropertyChangedの実装を直接使ったり、他の方法を使う。

その他

RaisePropertyChanged(null) の代わりに RaisePropertyChanged() (引数なし)を使ってもViewは更新されない。何故だろう。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む