LoginSignup
1
3

More than 3 years have passed since last update.

初心者がUE4でルービックキューブ風なものを作って強化学習用のライブラリにしたい #4

Last updated at Posted at 2019-11-12

前回に引き続き初心者がUE4を絡めたPythonライブラリを作れないか色々進めていく記事です(ほぼ自分のための備忘録としての面が強い・・)。

最初 : #1
前回 : #3

Pythonスクリプトでのimportなどはできるのか試してみる

PyActorのブループリントにていずれかのPythonモジュールを指定するわけですが、その際に他のScriptフォルダー以下のPythonモジュールをimportできるのか試してみました。
ue.logなどでコンソール出力してみた感じ、どうやらPyActorのブループリントで指定したもののみ実行されるようです。
他のモジュールのimport自体はエラーが発生しないようなのですが、そのモジュールで追加してある関数などを呼び出すとエラーになってしまいます。
pipでインストールしてあるものは問題なく使えるものの、基本的に1つのPyActorに対して1つのモジュールと考えておいたほうが良さそうですね。

ただ、それだと共通の処理などで困ってしまいます。そこで、pipでライブラリが追加されるフォルダにcommonといったフォルダを追加してimportできないか試してみます。

Plugins\UnrealEnginePython\Binaries\Win64のpipでライブラリフォルダが追加されるプラグインディレクトリにcommonというフォルダを追加し、SQLite関係のものの共通処理を書く想定でsqlite_utils.pyというファイルを追加しました。そのファイルに以下のような記述を試しにしておきます。

sqlite_utils.py
def print_test(ue):
    ue.log('sqlite_utils test')

続いてPyActorで指定したモジュールで以下ように記述を追加します。

import unreal_engine as ue

from common import sqlite_utils

sqlite_utils.print_test(ue=ue)

pipで扱われるディレクトリはパスが通っているはずなので、きっとimportできるはず・・・という想定で試しています。
UE4でプレビュー(というよりPlayの方が単語が正しいか・・・)してみます。

image.png

無事importしたモジュールが呼び出されているようです。
複数のモジュールをまたぐPythonの記述が必要な時にはライブラリフォルダにモジュール追加する形で良さそうですね。

また、common.sqlite_utilsモジュールでunreal_engineを直接importせずに、PyActorで指定したモジュールから、関数の引数で渡していますが、これはPyActorで指定したモジュールじゃないとue.logとしても動作してくれませんでした。おそらくPyActorで指定したモジュール以外で、unreal_engineモジュールのimportは使えないのでしょう。
そのため、共通モジュール側でunreal_engineモジュールが必要な場合は今回みたく引数で渡すようにして対応します。

Pythonスクリプトのテストをどうしようか考える

開発上、Pythonスクリプトでテストを書きたいところです。
しかし、前述の通りPyActorのクラス1つにつき1モジュールという制約があり、且つunreal_engineモジュールが絡むものなどはUE4でPlayしないとテストができないので、pytestなんかのテストランナーが使えません。

そこで、イレギュラーな感じですが以下のように進めてみます。

  • 各PyActorで指定したモジュール自体にtest_というプリフィクスでテストの関数を書いていく。
  • 共通モジュールに簡単な自前のテストランナー(凝ったものは作らない)用のコードを用意する。
  • PyActor指定モジュールのトップレベルの箇所に、自前のテストランナーに自身のモジュールを引数で渡すようにし、渡されたテストランナー側でtest_というプリフィクスの関数を一通り実行するようにする。

とりあえず自前のテストランナーから対応していきます。

common.python_test_runner.py
"""各Pythonスクリプトのテストの実行を扱うモジュール。

Notes
-----
- unreal_engine モジュールを絡ませる都合、テスト用のモジュールを分けずに
    運用を行う。
"""

from datetime import datetime
import inspect


def run_tests(ue, target_module):
    """
    対象のモジュールに対して定義されているテストを実行する。

    Parameters
    ----------
    ue : unreal_engine
        各PyActorで指定されたPythonモジュール内でimportされた、
        UnrealEnginePythonライブラリのモジュール。
    target_module : module
        テスト対象のモジュール。モジュール内でtest_というプリフィクスの
        関数が実行される。
    """
    ue.log(
        '%s %sモジュールのテストを開始...'
        % (datetime.now(), target_module.__name__))
    members = inspect.getmembers(target_module)
    for obj_name, obj_val in members:
        if not inspect.isfunction(obj_val):
            continue
        if not obj_name.startswith('test_'):
            continue
        ue.log(
            '%s 対象の関数 : %s' % (datetime.now(), obj_name))
        pre_dt = datetime.now()
        obj_val()
        timedelta = datetime.now() - pre_dt
        ue.log('%s ok. %s秒' % (datetime.now(), timedelta.total_seconds()))

細かいところは後で調整するとして、一旦はシンプルな実装でいいでしょう。
ビルトインのinspectモジュールのgetmembers関数で、引数に指定したモジュールのメンバー要素を取れるのでそれをループで回し、isfunction関数で関数かチェック、且つ関数名がtest_というプリフィクスを持つ場合にのみ処理を流すようにしています。
後はテスト時間や対象のモジュール名・関数名などをコンソール出力する記述のみです。

別途、PyActorで指定するモジュールで、UE4→Pythonライブラリという流れでデータを渡すためのSQLite書き込み用のモジュールを用意します。
そちらに、後で消しますがテストランナーの動作確認用としてaddという加算の関数を用意しました。

to_python_sqlite_writer.py
"""UE4からPythonライブラリ用のSQLiteへのデータの書き込みを扱う
ためのモジュール。
"""

import sys
import importlib
import time
import unittest

import unreal_engine as ue

from common import python_test_runner

importlib.reload(python_test_runner)


def add(a, b):
    time.sleep(1)
    return a + b


def test_add():
    added_val = add(a=1, b=3)
    assert added_val == 4


python_test_runner.run_tests(
    ue=ue, target_module=sys.modules[__name__])

テスト時間の表示確認のため、わざと関数内で1秒スリープさせています。
前述の通り、テストも同一のモジュール内に書くようにしています。
最後にトップレベルの箇所にテストランナーの処理を呼び出しています。自身のモジュールはsys.moduels[__name__]とすることで取れます。

なお、PyActorで指定したモジュールはコード更新後UE4側で自動でリロードされるようにしました(設定のポップアップが出たため)が、共通モジュールのものなどは自動でリロードされないようです。そのため、コード変更が即時で反映されなかったりするため、即時反映のためにimportlibでreloadさせています(最終的には消すかも・・)。

UE4でPlayしてみます。

image.png

テストが流れました。細かい点は必要に応じて後で調整するとして、とりあえずは大丈夫そうですね・・・

テストライブラリでnoseを入れておく

最近プライベートでコードを書くときはテストランナーとかの優秀さでpytestを使うことが多いのですが、今回はassertの関数だけ使えればいい(assert_equalとかassert_raisesとか)ので、楽をするためnoseのテストライブラリを入れておきます。

$ ./python.exe -m pip install --target . nose
Successfully installed nose-1.3.7

UE4上で使えることを確認するため、少し前にテストの検証で書いたコードを調整します。

to_python_sqlite_writer.py
from nose.tools import assert_equal
...
def test_add():
    added_val = add(a=1, b=3)
    assert_equal(added_val, 4)

image.png

問題なく動いているようです。

SQLiteでの読み書きの部分を進めてみる

前回SQLite用にSQLAlchemyを入れて最低限importなどができるところまで対応しましたが、もう少し検証で問題ないことを確認するのと、暫定ファイルの整理などをしていきます。
まずはh5pyなどの検証で使っていたpython_and_h5py_test.pyとそれに紐づくブループリントクラスを削除しておきます。

まずは共通モジュールでの、SQLiteのSQLAlchemyのパスを指定するための文字列を取得する処理を追加します。

common\sqlite_utils.py
"""SQLite関係の共通処理を記述したモジュール。
"""

import os
import sys

DESKTOP_FOLDER_NAME = 'cubicePuzzle3x3'


def get_sqlite_engine_file_path(file_name):
    """
    SQLiteのSQLAlchemy用のエンジン指定用のファイルパスを取得する。

    Parameters
    ----------
    file_name : str
        拡張しを含んだ対象のSQLファイル名。

    Returns
    -------
    sqlite_file_path : str
        SQLite用のエンジン指定用のパスの文字列。
        sqlite:/// から始まり、デスクトップにSQLite用のフォルダが作られる
        形で設定される。

    Notes
    -----
    保存先のフォルダが存在しない場合には生成される。
    """
    dir_path = os.path.join(
        os.environ['HOMEPATH'], 'Desktop', DESKTOP_FOLDER_NAME)
    os.makedirs(dir_path, exist_ok=True)
    sqlite_file_path = 'sqlite:///{dir_path}/{file_name}'.format(
        dir_path=dir_path,
        file_name=file_name,
    )
    return sqlite_file_path

書いていて気づきましたが、これだと共通モジュールに対するテストが書けません(このモジュール自体でueモジュールをimportてきない)。

そのため、共通モジュールのテストを実行するためのモジュールとPyActorのブループリントクラスを追加しておいて、そちら経由で各共通モジュールのテストを実行する形で対応します。

Content\Scripts\run_common_module_tests.py
"""Pythonプラグインのcommonディレクトリ以下のモジュールに対して
テストを実行する。
"""

import sys
import inspect

import unreal_engine as ue

from common import python_test_runner
from common.tests import test_sqlite_utils

NOT_TEST_TARGET_MODULES = [
    sys,
    inspect,
    ue,
    python_test_runner,
]

members = inspect.getmembers(sys.modules[__name__])
for obj_name, obj_val in members:
    if not inspect.ismodule(obj_val):
        continue
    is_in = obj_val in NOT_TEST_TARGET_MODULES
    if is_in:
        continue
    python_test_runner.run_tests(ue=ue, target_module=obj_val)

BP側はBP_RunCommonModuleTestsという名前にしました。

テストを書いていきます。共通モジュールの方は、ueモジュールのimportとかは前述のコードによって不要なのと、モジュールが分かれていても問題が無いため、普通のテストと同じようにテストモジュール用のディレクトリを挟んでtest_<モジュール名>.pyという形で進めていきます。

common\tests\test_sqlite_utils.py
"""sqlite_utils モジュールのテスト用のモジュール。
"""

from nose.tools import assert_equal, assert_true

from common import sqlite_utils


def test_get_sqlite_engine_file_path():
    sqlite_file_path = sqlite_utils.get_sqlite_engine_file_path(
        file_name='test_dbfile.sqlite')
    assert_true(
        sqlite_file_path.startswith('sqlite:///')
    )
    is_in = sqlite_utils.DESKTOP_FOLDER_NAME in sqlite_file_path
    assert_true(is_in)
    assert_true(
        sqlite_file_path.endswith('/test_dbfile.sqlite')
    )

UE4をPlayしてみます。

image.png

大丈夫そうですね。これで共通モジュール側もテストが書けるようになりました。
問題点として、pytestなどで用意されている、特定のモジュールや特定の関数のみのテストが現状できない・・・といったところでしょうか。
この点は、テストが増えてきて結構しんどい感じになってきたら考えます(特定のモジュールだけさくっとテストできるようにしたりなど)。
実装が終わるまで、テスト時間が気にならないレベルのままな可能性も高いですしね・・・

SQLiteのテスト用の挙動の確認と、初期化の処理とPyActorのスクリプトでクラスを挟んだ場合のテストを考える

UE4で作業していて、SQLiteを使ったテストをした際に、Playをもう一度実行してもSQLiteのファイルに対してロックがかかったままというケースが発生しました(途中でエラーになった場合など)。

実際にパッケージングされたときなどは一度アプリを落としてとなったりしそうな気もしますが、開発中はロックを解除するために毎回UE4を再起動したり・・・というのは手間なため、Playのたびに別のSQLiteのファイルになるようにPlayを押したタイミングでの日時情報をファイル名に差し込むようにしておきます。
initializer.pyという名前とPyActorのブループリントを追加します。

Content\Scripts\initializer.py
"""ゲーム開始時などに最初に実行される処理を記述したモジュール。
"""

import json
from datetime import datetime
import os
import sys
import time
import importlib

from nose.tools import assert_equal, assert_true
import unreal_engine as ue

from common import const, file_helper
from common.python_test_runner import run_tests
importlib.reload(const)
importlib.reload(file_helper)


def save_session_data_json():
    """
    1回のゲームセッションにおけるスタート時の情報を保持するための
    JSONファイルの保存を行う。
    """
    session_data_dict = {
        const.SESSION_DATA_KEY_START_DATETIME: str(datetime.now()),
    }
    file_path = file_helper.get_session_json_file_path()
    with open(file_path, mode='w') as f:
        json.dump(session_data_dict, f)
    ue.log('initialized.')


save_session_data_json()


def test_save_session_data_json():
    pre_session_json_file_name = const.SESSION_JSON_FILE_NAME
    const.SESSION_JSON_FILE_NAME = 'test_game_session.json'

    expected_file_path = file_helper.get_session_json_file_path()
    save_session_data_json()
    assert_true(os.path.exists(expected_file_path))
    with open(expected_file_path, 'r') as f:
        json_str = f.read()
    data_dict = json.loads(json_str)
    expected_key_list = [
        const.SESSION_DATA_KEY_START_DATETIME,
    ]
    for key in expected_key_list:
        has_key = key in data_dict
        assert_true(has_key)

    os.remove(expected_file_path)
    const.SESSION_JSON_FILE_NAME = pre_session_json_file_name


run_tests(
    ue=ue,
    target_module=sys.modules[__name__])

ファイル操作用の共通モジュールとそのテストも追加します。

Win64\common\file_helper.py
"""ファイル操作関係の共通処理を記述したモジュール。
"""

import os
import time
import json
from datetime import datetime

from common.const import DESKTOP_FOLDER_NAME
from common import const


def get_desktop_data_dir_path():
    """
    デスクトップのデータ保存用のディレクトリを取得する。

    Returns
    -------
    dir_path : str
        取得されたデスクトップのデータ保存用のディレクトリパス。

    Notes
    -----
    保存先のフォルダが存在しない場合には生成される。
    """
    dir_path = os.path.join(
        os.environ['HOMEPATH'], 'Desktop', DESKTOP_FOLDER_NAME)
    os.makedirs(dir_path, exist_ok=True)
    return dir_path


def get_session_json_file_path():
    """
    1回のゲームセッションにおけるスタート時の情報を保持するための
    JSONファイルのパスを取得する。

    Returns
    -------
    file_path : str
        対象のファイルパス。
    """
    file_path = os.path.join(
        get_desktop_data_dir_path(),
        const.SESSION_JSON_FILE_NAME
    )
    return file_path


def get_session_start_time_str(remove_symbols=True):
    """
    1回のゲームセッション開始時の日時の文字列をJSONファイルから取得する。
    SQLiteのファイル名などに利用される。

    Parameters
    ----------
    remove_symbols : bool, default True
        返却値の文字列から記号を取り除き、半角整数のみの値に
        変換するかどうか。

    Returns
    -------
    session_start_time_str : str
        1回のゲームセッション開始時の日時の文字列。
    """
    time.sleep(0.1)
    file_path = get_session_json_file_path()
    with open(file_path, mode='r') as f:
        data_dict = json.load(f)
    session_start_time_str = str(
        data_dict[const.SESSION_DATA_KEY_START_DATETIME])
    if remove_symbols:
        session_start_time_str = session_start_time_str.replace('-', '')
        session_start_time_str = session_start_time_str.replace('.', '')
        session_start_time_str = session_start_time_str.replace(':', '')
        session_start_time_str = session_start_time_str.replace(' ', '')
    return session_start_time_str

SQLite共通処理用のモジュールも追加しておきます。

Win64\common\sqlite_utils.py
"""SQLite関係の共通処理を記述したモジュール。
"""

import os
import sys

import sqlalchemy
from sqlalchemy.orm import sessionmaker

from common import file_helper


def get_sqlite_engine_file_path(file_name):
    """
    SQLiteのSQLAlchemy用のエンジン指定用のファイルパスを取得する。

    Parameters
    ----------
    file_name : str
        拡張しを含んだ対象のSQLファイル名。

    Returns
    -------
    sqlite_file_path : str
        SQLite用のエンジン指定用のパスの文字列。
        sqlite:/// から始まり、デスクトップにSQLite用のフォルダが作られる
        形で設定される。

    Notes
    -----
    保存先のフォルダが存在しない場合には生成される。
    """
    dir_path = file_helper.get_desktop_data_dir_path()
    sqlite_file_path = 'sqlite:///{dir_path}/{file_name}'.format(
        dir_path=dir_path,
        file_name=file_name,
    )
    return sqlite_file_path


def create_session(sqlite_file_name, declarative_meta):
    """
    SQLiteのセッションを生成する。

    Parameters
    ----------
    sqlite_file_name : str
        対象のSQLiteファイルの名称。
    declarative_meta : DeclarativeMeta
        対象のSQLiteの各テーブルのメタデータを格納したオブジェクト。

    Returns
    -------
    session : Session
        生成されたSQLiteのセッション。
    """
    sqlite_file_path = get_sqlite_engine_file_path(
        file_name=sqlite_file_name)
    engine = sqlalchemy.create_engine(sqlite_file_path, echo=True)
    declarative_meta.metadata.create_all(bind=engine)
    Session = sessionmaker(bind=engine)
    session = Session()
    return session

Win64\common\tests\test_sqlite_utils.py
"""sqlite_utils モジュールのテスト用のモジュール。
"""

import os
from nose.tools import assert_equal, assert_true
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer

from common import sqlite_utils, file_helper


def test_get_sqlite_engine_file_path():
    sqlite_file_path = sqlite_utils.get_sqlite_engine_file_path(
        file_name='test_dbfile.sqlite')
    assert_true(
        sqlite_file_path.startswith('sqlite:///')
    )
    is_in = file_helper.DESKTOP_FOLDER_NAME in sqlite_file_path
    assert_true(is_in)
    assert_true(
        sqlite_file_path.endswith('/test_dbfile.sqlite')
    )


def test_create_session():
    if not os.path.exists(file_helper.get_session_json_file_path()):
        return
    session_start_time_str = file_helper.get_session_start_time_str()
    sqlite_file_name = 'test_%s.sqlite' % session_start_time_str
    expected_file_path = sqlite_utils.get_sqlite_engine_file_path(
        file_name=sqlite_file_name)
    if os.path.exists(expected_file_path):
        os.remove(expected_file_path)

    declarative_meta = declarative_base()

    class TestTable(declarative_meta):
        id = Column(Integer, primary_key=True)
        __tablename__ = 'test_table'

    session = sqlite_utils.create_session(
        sqlite_file_name=sqlite_file_name,
        declarative_meta=declarative_meta)

    test_data = TestTable()
    session.add(instance=test_data)
    session.commit()
    query_result = session.query(TestTable)
    for test_data in query_result:
        assert_true(isinstance(test_data.id, int))

    expected_file_path = expected_file_path.replace('sqlite:///', '')
    assert_true(
        os.path.exists(expected_file_path))

    session.close()
    os.remove(expected_file_path)

initializer.pyを挟んだはいいものの、実際に使うときにはinitializerよりも後にSQLiteに繋ぐモジュールの処理は実行してもらう必要があります。一方で、PyActorでどのモジュールが先に実行されるかは不明です(場合によってはinitializerよりも先に他のモジュールのトップレベルのスクリプトが実行される)。

そこで、initializerの後じゃないと困るものに関してはPyActorでクラスを指定する形で、begin_playメソッド経由でスタートさせるようにします(begin_play経由だと、各モジュールのトップレベルの処理よりも確実に後に実行されるようです。少なくとも現状試した限りでは)。

※以下で1つ暫定のテーブルのモデルを追加してありますが、動作確認後とかに削除します。テストもとりあえずは最低限エラーにならないといった程度のライトなものです。

Content\Scripts\to_python_sqlite_writer.py
"""UE4からPythonライブラリ用のSQLiteへのデータの書き込みを扱う
ためのモジュール。
"""

import sys
import importlib
import time
import unittest
import time

import unreal_engine as ue
from nose.tools import assert_equal, assert_true
import sqlalchemy
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy import Column, Integer, String

from common import python_test_runner, sqlite_utils, file_helper

importlib.reload(python_test_runner)
importlib.reload(sqlite_utils)
declarative_meta = declarative_base()


class ToPythonSqliteWriter:

    class TestTable(declarative_meta):
        id = Column(Integer, primary_key=True)
        name = Column(String(length=256))
        __tablename__ = 'test_table'

    def begin_play(self):
        """
        ゲームPlay開始時に実行される関数。

        Notes
        -----
        各モジュールのトップレベルの処理よりは後に実行される。
        """
        self.session_start_time_str = \
            file_helper.get_session_start_time_str()
        self.SQLITE_FILE_NAME = 'to_python_from_ue4_%s.sqlite'\
            % self.session_start_time_str

        self.session = sqlite_utils.create_session(
            sqlite_file_name=self.SQLITE_FILE_NAME,
            declarative_meta=declarative_meta,
        )
        python_test_runner.run_begin_play_test(
            begin_play_test_func=self.test_begin_play
        )

    def test_begin_play(self):
        assert_equal(
            self.session_start_time_str,
            file_helper.get_session_start_time_str(),
        )
        is_in = 'to_python_from_ue4_' in self.SQLITE_FILE_NAME
        assert_true(is_in)
        is_in = '.sqlite' in self.SQLITE_FILE_NAME
        assert_true(is_in)
        query_result = self.session.query(self.TestTable)


python_test_runner.run_tests(
    ue=ue, target_module=sys.modules[__name__])

また、現在追加したテストランナーが、トップレベルのものしか対応していないので、別途begin_playの特殊なUE4のイベントに絡んだメソッドに対するテストを実行するためのrun_begin_play_testという関数を挟んでおきます。

Win64\common\python_test_runner.py
def run_begin_play_test(begin_play_test_func):
    """
    PyActorで指定したクラスのbegin_playメソッドに対するテストを
    実行する。

    Parameters
    ----------
    begin_play_test_func : function
        対象のbegin_playメソッドのテスト。
    """
    begin_play_test_func()

現状ほぼ内容は無いですが、後で開発中のみ実行するように処理を挟みます(まだそこまでUE4側と連携できていないので)。

生成されたSQLiteファイルを確認してみる

テストが通ることを確認し、デスクトップを確認してみるととりあえずはファイルが生成されていることが分かります。

image.png

内容も少し確認しておきます。
DB Browser for SQLiteをインストールして、内容を確認してみます。

image.png

とりあえずUE4経由でのSQLiteは問題なく動いてくれているようです。

パッケージングされた環境かどうかの値をPython側で取れるようにする

Python側でも、Is Packaged for Distributionのノードの値を取れるようにし、テストなどが開発中のみ実行されるようにしておきます。
ただ、関数呼び出しがuobject経由でしかできない都合、クラスを経由する必要があり、どうしてもinitializerのトップレベルの部分で処理ができません。
まあ無駄な処理を減らす程度のものなのと、どうこうてきるものでもないので、細かいことは気にせずに進めましょうか・・・

PythonからUE4のブループリント上の関数を呼びだす必要があるので、まずはブループリントに対象の関数を追加します。

image.png

シンプルに用意されている関数の値を返却するだけの関数です。
この関数無く、直接用意されている関数(紫ではなく緑のもの)をPythonから呼び出せるのかな?と試しましたが、そんなことはなく弾かれたので普通に用意します。

また、ブループリント上でクラスの指定をしておきます。

image.png

Python側の対応を進めます。
クラスのuobjectを経由する必要があるので、クラスを用意します。

Content\Scripts\initializer.py
class Initializer:

    def begin_play(self):
        """
        ゲームPlay開始時に実行される関数。
        """
        self.is_packaged_for_distribution = \
            self.uobject.isPackagedForDistribution()[0]
        _update_packaged_for_distribution_value(
            is_packaged_for_distribution=self.is_packaged_for_distribution)


def _update_packaged_for_distribution_value(is_packaged_for_distribution):
    """
    配布用にパッケージングされた環境(本番用)かどうかの
    真偽値の値を更新する。

    Parameters
    ----------
    is_packaged_for_distribution : bool
        設定する真偽値。
    """
    file_path = file_helper.get_session_json_file_path()
    if os.path.exists(file_path):
        with open(file_path, mode='r') as f:
          session_data_dict = json.load(f)
    else:
        session_data_dict = {}
    if is_packaged_for_distribution:
        is_packaged_for_distribution_int = 1
    else:
        is_packaged_for_distribution_int = 0
    with open(file_path, mode='w') as f:
        session_data_dict[
            const.SESSION_DATA_KEY_IS_PACKAGED_FOR_DISTRIBUTION] = \
            is_packaged_for_distribution_int
        json.dump(session_data_dict, f)


def test__update_packaged_for_distribution_value():
    pre_session_json_file_name = const.SESSION_JSON_FILE_NAME
    const.SESSION_JSON_FILE_NAME = 'test_game_session.json'
    expected_file_path = file_helper.get_session_json_file_path()

    _update_packaged_for_distribution_value(
        is_packaged_for_distribution=True)
    with open(expected_file_path, mode='r') as f:
        session_data_dict = json.load(f)
    assert_equal(
        session_data_dict[
            const.SESSION_DATA_KEY_IS_PACKAGED_FOR_DISTRIBUTION],
        1
    )

    _update_packaged_for_distribution_value(
        is_packaged_for_distribution=False)
    with open(expected_file_path, mode='r') as f:
        session_data_dict = json.load(f)
    assert_equal(
        session_data_dict[
            const.SESSION_DATA_KEY_IS_PACKAGED_FOR_DISTRIBUTION],
        0
    )

    os.remove(expected_file_path)
    const.SESSION_JSON_FILE_NAME = pre_session_json_file_name

なお、self.uobject.isPackagedForDistribution()[0]という値の取り方をしていますが、ブループリントの関数の返却値が1件だけでもPython側ではタプルで渡されるためにこのようにしています。
UE4上でコンソール出力してもFalseとなっているのに、なんだか分岐がうまくいかない・・と悩むこと数分。
どうやら(False,)みたいなタプル表示ではなくダイレクトにFalseやTrueといった感じでコンソール出力されるようです。これは紛らわしい・・・(せめてコンマを・・・)
型を表示したらtupleになっていて気づきました。

値の取得処理に関しても追加しておきます。

Win64\common\file_helper.py
def get_packaged_for_distribution_bool():
    """
    配布用にパッケージングされた状態かどうかの真偽値を取得する。

    Notes
    -----
    初回起動時の最初のみ、値の保存前で正常に値が取れないタイミングが
    存在する。その場合はFalseが返却される。

    Returns
    -------
    is_packaged_for_distribution : bool
        Trueで配布用にパッケージングされた状態(本番用)、Falseで
        開発用。
    """
    file_path = get_session_json_file_path()
    if not os.path.exists(file_path):
        return False
    with open(file_path, mode='r') as f:
        json_str = f.read()
        if json_str == '':
            session_data_dict = {}
        else:
            session_data_dict = json.loads(json_str)
    has_key = const.SESSION_DATA_KEY_IS_PACKAGED_FOR_DISTRIBUTION \
        in session_data_dict
    if not has_key:
        return False
    is_packaged_for_distribution = session_data_dict[
        const.SESSION_DATA_KEY_IS_PACKAGED_FOR_DISTRIBUTION]
    if is_packaged_for_distribution == 1:
        return True
    return False

Win64\common\tests\test_file_helper.py
def test_get_packaged_for_distribution_bool():
    pre_session_json_file_name = file_helper.const.SESSION_JSON_FILE_NAME
    file_helper.const.SESSION_JSON_FILE_NAME = 'test_game_session.json'
    file_path = file_helper.get_session_json_file_path()
    if os.path.exists(file_path):
        os.remove(file_path)

    # ファイルが存在しない場合の返却値を確認する。
    is_packaged_for_distribution = \
        file_helper.get_packaged_for_distribution_bool()
    assert_false(is_packaged_for_distribution)

    # ファイルは存在するものの、空文字が設定されている場合の
    # 返却値を確認する。
    with open(file_path, 'w') as f:
        f.write('')
    is_packaged_for_distribution = \
        file_helper.get_packaged_for_distribution_bool()
    assert_false(is_packaged_for_distribution)

    # 値に1が設定されている場合の返却値を確認する。
    with open(file_path, 'w') as f:
        session_data_dict = {
            const.SESSION_DATA_KEY_IS_PACKAGED_FOR_DISTRIBUTION: 1,
        }
        json.dump(session_data_dict, f)
    is_packaged_for_distribution = \
        file_helper.get_packaged_for_distribution_bool()
    assert_true(
        is_packaged_for_distribution
    )

    # 値に0が設定されている場合の返却値を確認する。
    with open(file_path, 'w') as f:
        session_data_dict = {
            const.SESSION_DATA_KEY_IS_PACKAGED_FOR_DISTRIBUTION: 0,
        }
        json.dump(session_data_dict, f)
    is_packaged_for_distribution = \
        file_helper.get_packaged_for_distribution_bool()
    assert_false(is_packaged_for_distribution)

    os.remove(file_path)
    file_helper.const.SESSION_JSON_FILE_NAME = pre_session_json_file_name

以下のようにテストランナーの各所に分岐を挟んでおきます。

Win64\common\python_test_runner.py
def run_tests(ue, target_module):
    ...
    is_packaged_for_distribution = \
        file_helper.get_packaged_for_distribution_bool()
    if is_packaged_for_distribution:
        return
    ...

初期表示のランダムな状態の反映を行う

現状、色が揃った状態でスタートするので、これを普通のルービックキューブ的な感じにランダムに回転させておきます。

アニメーションしない即時の回転処理を用意してあるので、そちらを使ってブループリントに関数を追加していきます。
また、環境のリセット処理がOpenAIのGymライブラリでは関数名がresetとなっているので、そちらにならってresetとしておきます(最初だけでなく再度動かす際にも利用します)。

まずは何度回すのかの値を取得します。
NumPyのrandintみたいなものがRandom Integerノードで用意されているようなのでそちらを使います。最小値は0、最大値はMax - 1で設定されるようです。
また、そのままだと0が出たときに最初から面が揃っている・・・みたいなことが発生してしまうので、MAXノードを挟んで最低200回は回転するというようにしておきます。

image.png

ループ中でどの方向に回転させるのかの判定用に真偽値をローカル変数で用意しておきます。
また、ループの先頭で一通りFalseにしておきます。

image.png

まずはXYZどの方向に回転させるのかの真偽値でTrueにするものをランダムに決定します。
Switchノードをはじめて使ってみます。プログラミングに慣れていれば特に悩むことなく使える形でいいですね。

image.png

続いて回転方向。左に回転させるのか右に回転させるのか、もしくは上に回転させるのか下に回転させるのかの対象をランダムに決定します。

image.png

最後に、どの列を回転させるのかといった値を算出します。1~3の値を算出する必要があるため、0~2までの値をRandom Integerノードで出してから1インクリメントしています。

image.png

後は、決定された値に応じてBranchで分岐させて必要な回転を実行させて完了です。

image.png

動かしてみます。

image.png

いい感じに色がばらけました。どんな感じに回転させれば色が全面揃うのかいまいち分かりませんね・・・。

もう一度実行してみます。

image.png

ちゃんとさっきとは異なる感じになっています。大丈夫そうですね。
ループがかなり多い処理ではありますが、私のデスクトップ環境では一瞬で終わるのと、特にFPS維持しないといけないとかでもないですし、そもそも学習で強化学習動かす方はデスクトップでしょうしそれなりに良いスペックのPCの方が大半でしょうから問題ないと判断します。

回転中かどうかの真偽値の取得処理を用意する

アニメーション中に別の回転の回転が実行されると困ります。
最終的にはPython側でエラー制御などしようと思いますが、とりあえずブループリント上での実装を追加し、そちらと連携する形でPythonのコードを書いていきます。

名前はGymライブラリの用語に準じてBP_Actionという名前のPyActorを継承したブループリントで作っていきます。
※Gymライブラリ回りの単語に関しては強化学習入門#1 基本的な用語とGym、PyTorch入門とかをご確認ください。

ブループリントを追加したはいいものの、そういえばレベルに配置されているアクターってどう取得するんだ・・?と思ったところ、公式のドキュメントで書かれていました。

ブループリントでアクタを検索する

Get All Actors Of Classノードでいけそうですので試してみます。

image.png

シンプルにループで回して名前を画面にprintするだけのノードです。

image.png

ちゃんと表示されています。お手軽。

これを使えば、BP_Actionのブループリント内で、回転中のキューブのアクターが存在するかどうかの値を取るための関数が用意できそうです。

BP_CubeBaseの基底クラスのブループリントに、isRotatingという関数を追加します。

各回転方向で対象の回転の真偽値を取得する関数は以前用意していたので、そちらを呼び出しつつ、ローカル変数で真偽値の配列を用意して配列を統合いていきます。
配列に対する配列の追加はAPPENDノードでできるようです(Pythonでいうところのextend的な挙動)。

image.png

一通りの真偽値が配列に追加し終わったら、配列を渡してその配列でいずれかの真偽値がTrueならTrueが返ってくる関数を以前別の箇所で用意して使っていた(そちらは配列が3件とかで指定していましたが・・・)ので、そちらを利用します。

image.png

BP_ActionのブループリントにisAnyCubeRotatingという名前の関数で作っていきます。

image.png

少し前に触れたGet All Actors Of Classを使って配列を取得し、ループで回します。

image.png

ループ中で回転しているキューブがあればTrueのReturn Nodeへ、もし1つも回転しているものが見つからないままループが終わったらFalseのReturn Nodeへ流すようにします。

また、BP_Actionのブループリントで、Pythonのモジュールとクラスの指定をしておきます。

image.png

Python側でaction.pyというモジュールを追加して、処理を書いていきます。
まだPython側から回転の処理などを書いていないので、テストなどは一旦スキップします(機能テスト関係が整備してあればもう書くのですが、整備してないのとUE4関係のものはよく分かっていないので)。また、tickでコンソール出力を試してみます(今のところは必ずFalseが返るはず)。

Content\Scripts\action.py
"""Agentにおける何らかの行動制御に関連した処理を記述したモジュール。
"""

import unreal_engine as ue


class Action:

    def tick(self, delta_time):
        """
        ゲームPlay中、約1フレームごとに実行される関数。

        Parameters
        ----------
        delta_time : float
            前回のtick呼び出し後からの経過秒。
        """
        ue.log(is_any_cube_rotating(action_instance=self))


def is_any_cube_rotating(action_instance):
    """
    いずれかのキューブが回転中かどうかの真偽値を取得する。

    Parameters
    ----------
    action_instance : Action
        uobjectを持ったActionクラスのインスタンス。

    Returns
    ----------
    is_rotating : bool
        いずれかのキューブが回転中であればTrueが設定される。
    """
    is_rotating = action_instance.uobject.isAnyCubeRotating()[0]
    return is_rotating

ログを見るとFalseが出力されていることが分かります。

...
LogPython: False
LogPython: False
LogPython: False
LogPython: False
...

とりあえずは大丈夫そうですね。コンソール出力を消しておいて、どんどん次にいきます。

NumPyを入れておく

hdf5関係をアンインストールした際に、dependenciesとしてインストールされたNumPyも一緒にアンインストールしてしまいましたが、やっぱり使いたいところが出てきたためNumPyのみ入れておきます。

$ ./python.exe -m pip install --target . numpy
Successfully installed numpy-1.17.3

Action関係の制御の実装を進める。

まずはとりあえずActionの番号の割り振りを定義しました。
重複やリストにちゃんと含まれているかなどはチェックされるようにしておきます。

Content\Scripts\action.py
import numpy as np
...
ACTION_ROTATE_X_LEFT_1 = 1
ACTION_ROTATE_X_LEFT_2 = 2
ACTION_ROTATE_X_LEFT_3 = 3
ACTION_ROTATE_X_RIGHT_1 = 4
ACTION_ROTATE_X_RIGHT_2 = 5
ACTION_ROTATE_X_RIGHT_3 = 6
ACTION_ROTATE_Y_UP_1 = 7
ACTION_ROTATE_Y_UP_2 = 8
ACTION_ROTATE_Y_UP_3 = 9
ACTION_ROTATE_Y_DOWN_1 = 10
ACTION_ROTATE_Y_DOWN_2 = 11
ACTION_ROTATE_Y_DOWN_3 = 12
ACTION_ROTATE_Z_UP_1 = 13
ACTION_ROTATE_Z_UP_2 = 14
ACTION_ROTATE_Z_UP_3 = 15
ACTION_ROTATE_Z_DOWN_1 = 16
ACTION_ROTATE_Z_DOWN_2 = 17
ACTION_ROTATE_Z_DOWN_3 = 18

ACTION_LIST = [
    ACTION_ROTATE_X_LEFT_1,
    ACTION_ROTATE_X_LEFT_2,
    ACTION_ROTATE_X_LEFT_3,
    ACTION_ROTATE_X_RIGHT_1,
    ACTION_ROTATE_X_RIGHT_2,
    ACTION_ROTATE_X_RIGHT_3,
    ACTION_ROTATE_Y_UP_1,
    ACTION_ROTATE_Y_UP_2,
    ACTION_ROTATE_Y_UP_3,
    ACTION_ROTATE_Y_DOWN_1,
    ACTION_ROTATE_Y_DOWN_2,
    ACTION_ROTATE_Y_DOWN_3,
    ACTION_ROTATE_Z_UP_1,
    ACTION_ROTATE_Z_UP_2,
    ACTION_ROTATE_Z_UP_3,
    ACTION_ROTATE_Z_DOWN_1,
    ACTION_ROTATE_Z_DOWN_2,
    ACTION_ROTATE_Z_DOWN_3,
]
...
def test_ACTION_LIST():
    assert_equal(
        len(ACTION_LIST), len(np.unique(ACTION_LIST))
    )
    members = inspect.getmembers(sys.modules[__name__])
    for obj_name, obj_val in members:
        if not obj_name.startswith('ACTION_ROTATE_'):
            continue
        assert_true(isinstance(obj_val, int))
        is_in = obj_val in ACTION_LIST
        assert_true(is_in)


python_test_runner.run_tests(
    ue=ue,
    target_module=sys.modules[__name__])

定義した定数名に準じて、BP_Actionに各アクションの関数を追加していきます。
と思ったものの、回転で対象となるキューブのリストを算出する関数がレベルのブループリントに書いてしまっていました。
BP_Actionからその関数を呼び出すのが大分面倒そうな印象があります・・やらかした感が・・・(慣れるまでこの参照ができないという点でついやらかしてしまう・・・)
参考 : レベルブループリントを参照する方法を考える

嘆いていてもしょうがないので、キューブの基底クラスのブループリントに、各回転で対象となるキューブかどうかの真偽値を取得する関数を先に追加していきます(そうすればレベル内のキューブはGet All Actors Of Classノードで取れるので・・)。

関数ライブラリを試してみる

続きを進める前に、assertのヘルパー的なものなどをレベルのブループリントに書いていましたが、それだとBPクラスで使えなかったりで不便に感じる時があるので、調整を考えます。

調べてみると、どうやら関数ライブラリというものがあるようです。

関数ライブラリとは
どこからでもアクセスできる色んな関数を一か所にまとめて持つことができるブループリントです。
通常のブループリントと違い、変数を保持することができず、イベントグラフも存在しません。マクロも作れず、関数のみ作成可能です。
この中に書いた関数は、どのブループリント、それこそアクターやレベル問わず使えるようになります。
[UE4] 関数ライブラリとマクロライブラリの上手な活用

作り方は以下の記事で書かれていました:bow:

UE4 汎用関数の作成(Blueprint Function Library)

慣習として、関数ライブラリのフォルダはどんな名前がいいのでしょう・・・?
動画や書籍で、ブループリントはBluePrints、ファイル名は先頭にBP_と付けましょうとか、マテリアルはMaterialsにしましょうとかそういった慣習が紹介されていましたが、関数ライブラリはどうだったか・・・そもそも関数ライブラリが紹介されていただろうか・・・(アウトプットしていないとすぐ忘れて良くないですね・・・)

とりあえず、仕事ではないので気軽にLibrariesというフォルダ名にしておきましょうか。
ファイル名はLIB_とプリフィクス付けるようにしてみます。

image.png

コンテンツブラウザで新規ファイルを追加し、Blueprints → Blueprint Function Libraryと選択すると作れるようです。

テスト関係を追加していきたいのでLIB_Testingという名前にしました。

image.png

開いてみたらこんな感じのようです。関数とローカル変数のみのごくシンプルな構成のブループリントみたいですね。
移しておきたい関数をこちらに移して、既存のレベルBP上の関数をそちらに差し替えておきます。

ライブラリに追加した関数は、特に何もしないでもそのままレベルのBPやほかのBPクラスから呼び出せるようです。

image.png

これでBPクラス内でのライトな値のチェックなどもやりやすくなりました。

キューブの基底クラスに回転の対象かどうかの真偽値取得の処理を追加していく

前述のように、BP_CubeBaseの基底クラスから回転の対象かどうかの真偽値を取得できるようにし、BP_Actionから呼び出せるようにします。

以下のような真偽値を返す形の関数をXYZで1~3の範囲で作るので、関数を9個追加していきます。

image.png

共通部分は別の関数に切り分けてあります。また、対象の回転で対象となるキューブの位置の種別の配列は定数ですでに以前用意していたのでそちらを使っていきます。

image.png

共通処理の内部では、配列分ループを回して、

image.png

現在のキューブの位置の種別値が、現在のループでのインデックスの配列の値と一致する場合はTrue、ループ終わっても該当するものが無ければFalseを返すようにしています。

細かいところは省きますが、ある程度処理の挙動を確認するためのテストを書いておきます。

image.png

回転の9個分用意ができて、テストも引っかからない形になったので次へ進みます。
試しに1つ、BP_Actionに回転の関数を一つ追加してみます。まずはシンプルなアニメーションしないタイプの回転の方から進めていきます。

まずはキューブのアクターを取得し、ループを回します。

image.png

後は先ほど用意した回転対象かどうかの真偽値での分岐を設けて、Trueなら回転するようにします。

image.png

Python側でも雑に試してみます。

Content\Scripts\action.py
class Action:

    total_delta_time = 0
    is_rotated = False

    def tick(self, delta_time):
        """
        ゲームPlay中、約1フレームごとに実行される関数。

        Parameters
        ----------
        delta_time : float
            前回のtick呼び出し後からの経過秒。
        """
        self.total_delta_time += delta_time
        if self.total_delta_time > 5 and not self.is_rotated:
            self.uobject.rotateXLeftImmediately1()
            self.is_rotated = True

これでとりあえず5秒後くらいに1回即時で回転します。

20191110_2.gif

プレビューしてみましたが大丈夫そうです。
他の回転の処理も同様に組んでいきますが、その前に回転結果のテスト用の関数を、BP_Action側からも参照できるように関数ライブラリ側に移動させておき、レベル側の関数は置換して切り落としておきます。

移動後、対象の回転後の値のチェック用の関数を今回BP_Actionに追加した関数の末尾に追加しておき、プレビューしてチェックに引っかからないことを確認しておきます。

image.png

とりあえず大丈夫そうなので、他の方向の回転なども一通り追加して、Python側から呼び出してみて動作確認しておきます。

20191112_1.gif

Pythonを経由する即時の回転の処理は大丈夫そうです。
次回は、アニメーション付きの回転の処理をPythonに繋いでいく形で作業を進めていきます(記事が長くなってきたので、今回の記事はこの辺りにしておこうと思います)。

気になった点

  • たまに、Pythonスクリプトの更新が反映されないことがありました(UE4再起動すると直る)。Pythonプロセスが起動したままになるので、長時間そのままだと色々不整合的にうまく処理が通らない・・・みたいなケースに悩まされました。importlibとか使っても直らないケースが・・・。起動にそこまで時間がかかるというものでもないですか、少々気になりますね・・・。いい感じの方法無いだろうか・・・(後でUnrealEnginePythonのプラグインのgithubのissueとか漁ってみてもいいかもと考えています・・)

参考ページまとめ

1
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
3