ADVENT CALENDAR 2019

初めての組み込みスクリプティング(C++ / Lua 応用編)

By 檸檬茶(Lemon TEA)

前回の記事では,C++で作成したプログラムにLuaスクリプトを組み込む際の基礎事項を紹介しました. 今回の記事ではそれらを応用し,Altseedを使って作成したゲームにLuaを組み込んでいきます. Luaの組み込み機能をうまく使うと,オブジェクトの動きを制御したり,ステージの動作を制御したりすることができます.

Altseedについて詳しく知りたい方はここをご覧ください.

なお,この記事ではチュートリアルセットを用意しています. Luaスクリプトをゲームに組み込む方法はこのチュートリアルセットを使って説明します. 詳しくは下のリンク先のリポジトリの「Day9」をご参照ください.

https://github.com/GCLemon/ACAC2019

なお,実行環境は以下の通りです.

  • MacOS Catalina 10.15.1

チュートリアルセット内容物

チュートリアルセットはDay9に入っている「Template」です. 以下の内容物があることを確認してください.

Template
    ├─ bin
    │   ├─ resources
    │   │   ├─ Enemy.png
    │   │   ├─ EnemyBullet.png
    │   │   ├─ Player.png
    │   │   └─ PlayerBullet.png
    │   │
    │   ├─ scripts
    │   │   ├─ Enemy.lua
    │   │   └─ State.lua
    │   │
    │   └─ libAltseed_core.dylib
    │
    ├─ lib
    │   └─ libAltseed.a
    │
    ├─ src
    │   ├─ Altseed
    │   │   └─ Altseed.h
    │   │
    │   ├─ Objects
    │   │   ├─ Enemy.cpp
    │   │   ├─ Enemy.hpp
    │   │   ├─ EnemyBullet.cpp
    │   │   ├─ EnemyBullet.hpp
    │   │   ├─ Player.cpp
    │   │   ├─ Player.hpp
    │   │   ├─ PlayerBullet.cpp
    │   │   └─ PlayerBullet.hpp
    │   │
    │   ├─ Scenes
    │   │   ├─ GameScene.cpp
    │   │   └─ GameScene.hpp
    │   │
    │   └─ main.cpp
    │
    └─ build.sh

ダウンロード&解凍をしたら,Templateのディレクトリでbuild.shを実行してください. 実行すると,binのディレクトリに実行ファイルShootingが生成されると思います. これを実行するとゲーム画面が出てきます.

ACAC-2019-12-09(1).png

しかしこの状態では,敵は動きもしないし弾も撃ってきません. (プレイヤーの移動や当たり判定は既に実装済みです. 更に言うとluaL_newstateEnemyStateが初期化されていないため,プログラム終了時にセグフォが発生します.) まずは,この敵の動きをLuaスクリプトで制御できるように,プログラムを作成していきましょう.

敵の動きをスクリプトで制御する

ここではEnemy.cppにプログラムを書き加えていきます. Enemy.cppでは,Enemyクラスの実装を行っています. Enemyクラスの定義部分はEnemy.hppをご参照ください.

コンストラクタの実装

手始めにコンストラクタ(Enemy::Enemy())に処理を書き加えます. Enemyクラスには,敵機の動きを制御する二つのLuaステートEnemyStateEnemyMovementが定義されています. EnemyStateはスクリプトの読み込みに,EnemyMovementはコルーチンの実行に使用します. これらのステートは敵オブジェクトごとに保存されます. まずステートを新規作成してEnemyStateに保存し,スクリプトを読み込みます.

// 敵の動作を制御するステートを作成し,スクリプトをファイルから読み込み
EnemyState = luaL_newstate();
luaL_openlibs(EnemyState);
if(luaL_dofile(EnemyState, "scripts/Enemy.lua"))
{
    // エラーメッセージを出力して強制終了
    printf("%s\n", lua_tostring(EnemyState, -1));
    lua_close(EnemyState);
    exit(0);
}

次に,コルーチンを実行するためのスレッドを生成し,EnemyMovementに保存します.

// コルーチンを実行するためのスレッドを生成
EnemyMovement = lua_newthread(EnemyState);
lua_getglobal(EnemyMovement, "EnemyMovement");

次に,Luaスクリプト内で保存する情報を作成します. 実はLuaスクリプトでは,一旦定義したグローバル変数を「状態」として管理することができます. これを利用し,敵機の状態をLuaで管理します. そのためには,状態を保存する変数を用意する必要があります. ただし,幾多もの独立した変数を扱おうとすると,使おうとしている変数がどのクラスの情報なのかが分からなくなってしまいます. そのため,敵機の情報をテーブルにまとめてしまいます. 具体的には,以下のLuaスクリプトと全く同じことをC++で行います.

-- enemyテーブル(オブジェクトと表記した方が馴染み深かったり?)
enemy = {

    -- メンバ変数
    position = {
        x = ...,
        y = ...
    },

    -- メンバ関数
    launch = function(this)
        ...
    end
}

このテーブルをC++で作成するとき,作成手順を直観的に表すと下図のようになります.

ACAC-2019-12-09(2).png

あとはこれをC++のプログラムに起こすだけです. 以下のプロブラムではlua_pushcfunctionの引数にLaunch関数の関数ポインタを指定していますが,この関数は後ほど実装していきます.

// コルーチンステートにenemyテーブルを定義する
// 敵機に関する情報はLua側で保存するものとする
lua_newtable(EnemyMovement);
lua_newtable(EnemyMovement);
lua_pushnumber(EnemyMovement, GetPosition().X);
lua_setfield(EnemyMovement, -2, "x");
lua_pushnumber(EnemyMovement, GetPosition().Y);
lua_setfield(EnemyMovement, -2, "y");
lua_setfield(EnemyMovement, -2, "position");
lua_pushcfunction(EnemyMovement, &Launch);
lua_setfield(EnemyMovement, -2, "launch");
lua_setglobal(EnemyMovement, "enemy");

これでコンストラクタの実装は完了です.

弾を発射する処理の呼び出しをLua側でできるようにする

次に,Launch関数(int Launch(lua_State* state))を実装していきます. Launch関数は,敵が弾を発射する時に使う関数です. この関数を渡してLua側で使用するlaunch関数は,第一引数に自身のテーブル(this),第二引数に弾を打つときの速度を指定し,戻り値はありません. すなわち,Launch関数の引数になっているLuaステートには,二つのテーブルが入っています. まずは,Luaステートに格納されているテーブルのフィールドを,lua_getfield関数を使ってスタックに展開します.

// 受け取った2つのテーブルについてフィールドを展開する
lua_getfield(state, -2, "position");
lua_getfield(state, -1, "x");
lua_getfield(state, -2, "y");
lua_getfield(state, -4, "x");
lua_getfield(state, -5, "y");

やっていることとしては下図のようになります.

ACAC-2019-12-09(3).png

スタックに入っている内容がわかっているため,スタックから値を取得して敵弾のインスタンスを作成します. 敵弾クラスのコンストラクタの引数には,配置する位置,移動速度を指定します.

shared_ptr<EnemyBullet> enemy_bullet = 
    make_shared<EnemyBullet>(
        Vector2DF(lua_tonumber(state, -4), lua_tonumber(state, -3)),
        Vector2DF(lua_tonumber(state, -2), lua_tonumber(state, -1))
    );

次に,敵弾を追加するレイヤーを探します. Altseedの機能として,現在描画に使われているシーンを取得したり,レイヤーに名前をつけたりする機能があるため,それを利用して,名前からレイヤーを検索する処理を作成します.

// "GameLayer" という名前から敵弾を追加するレイヤーを探す
shared_ptr<Layer2D> game_layer = nullptr;
for(auto layer : Engine::GetCurrentScene()->GetLayers())
    if(layer->GetName() == u"GameLayer")
    {
        // Layer2Dにキャストできることを確認してから代入する.
        auto l = dynamic_pointer_cast<Layer2D>(layer);
        if(l != nullptr) game_layer = l;
    }

ただしこのプログラムは,"GameLayer"の名前がついているレイヤーがただ一つだけあることを前提にしています. レイヤーを検索したら,敵弾を追加します.

// 敵弾を追加する
game_layer->AddObject(enemy_bullet);

敵弾の追加が終わったら,一応スタックの中身を空にします.

// スタックを空にする
lua_pop(state, lua_gettop(state));

戻り値はないため,0を返して終わりです.

// 戻り値なし
return 0;

これで,敵弾発射の処理をLuaで行う準備が完了しました.

Luaで保存されている状態を敵機オブジェクトに反映させる

次に,敵機の更新処理(void Enemy::OnUpdate())を実装していきます. この関数で行うことは,コルーチンの実行と,Luaスクリプトに保存されている状態の反映です. コルーチンの実行はlua_resume関数で行います.

// コルーチンの実行
// この関数を実行した瞬間,EnemyMovementが持つスタックは
// 空になるため,lua_pop関数を実行する必要はない.
lua_resume(EnemyMovement, nullptr, 0);

コルーチンに戻り値はないため,スタックには何も積まれません. 続いてLuaスクリプトに保存されている状態を敵機オブジェクトに反映させます. そのためにはまず,Luaに保存されている状態を読み出す必要があります.

// Luaで保存されている敵機の情報を読み出し
lua_getglobal(EnemyMovement, "enemy");
lua_getfield(EnemyMovement, -1, "position");
lua_getfield(EnemyMovement, -1, "x");
lua_getfield(EnemyMovement, -2, "y");

やっていることとしては下図のようになります.

ACAC-2019-12-09(4).png

スタックに入っている内容がわかっているため,スタックから値を取得して位置情報を反映させます.

// ポジションの設定
Vector2DF position = Vector2DF(
    (float)lua_tonumber(EnemyMovement, -2),
    (float)lua_tonumber(EnemyMovement, -1)
);
SetPosition(position);

これで,Luaスクリプトに保存されている情報を敵機オブジェクトに反映させる処理が完了しました. これらの処理をEnemy::OnUpdate関数に記述しているためフレームごとに実行され,プログラム側で直接位置情報を設定するのと同じように,Luaスクリプトで位置情報を設定することができます.

最後に,出来上がったプログラムを以下に示します.

#include "Enemy.hpp"
#include "EnemyBullet.hpp"
#include "PlayerBullet.hpp"

// Lua 側で弾を打つ関数
int Launch(lua_State* state)
{
    /*
    この関数は2つのテーブルを引数にとり
    第一引数時自身のテーブル(enemy),第二引数に弾を打つ方向(direction)が指定される.

    なお, Lua スクリプトで enemy テーブルは以下のように定義されているものとする.

    enemy = {

        position = {
            x = ...,
            y = ...
        },

        launch = function(this)
            ...
        end
    }
    */

    // 受け取った2つのテーブルについてフィールドを展開する
    lua_getfield(state, -2, "position");
    lua_getfield(state, -1, "x");
    lua_getfield(state, -2, "y");
    lua_getfield(state, -4, "x");
    lua_getfield(state, -5, "y");

    // 受け取ったテーブルの情報から敵弾のインスタンスを作成する.
    shared_ptr<EnemyBullet> enemy_bullet = 
        make_shared<EnemyBullet>(
            Vector2DF(lua_tonumber(state, -4), lua_tonumber(state, -3)),
            Vector2DF(lua_tonumber(state, -2), lua_tonumber(state, -1))
        );

    // "GameLayer" という名前から敵弾を追加するレイヤーを探す
    shared_ptr<Layer2D> game_layer = nullptr;
    for(auto layer : Engine::GetCurrentScene()->GetLayers())
        if(layer->GetName() == u"GameLayer")
        {
            // Layer2Dにキャストできることを確認してから代入する.
            auto l = dynamic_pointer_cast<Layer2D>(layer);
            if(l != nullptr) game_layer = l;
        }

    // 敵弾を追加する
    game_layer->AddObject(enemy_bullet);

    // スタックを空にする
    lua_pop(state, lua_gettop(state));

    // 戻り値なし
    return 0;
}

Enemy::Enemy(Vector2DF position)
{
    // 敵機のテクスチャの設定
    auto texture = Engine::GetGraphics()->CreateTexture2D(u"resources/Enemy.png");
    SetTexture(texture);

    // オブジェクトの中心座標の設定
    Vector2DF texture_size = texture->GetSize().To2DF();
    SetCenterPosition(texture_size * 0.5f);

    // オブジェクトの描画位置の設定
    SetPosition(position);

    // 当たり判定の半径の設定
    Radius = GetTexture()->GetSize().X * 0.5f;

    // 敵の動作を制御するステートを作成し,スクリプトをファイルから読み込み
    EnemyState = luaL_newstate();
    luaL_openlibs(EnemyState);
    if(luaL_dofile(EnemyState, "scripts/Enemy.lua"))
    {
        // エラーメッセージを出力して強制終了
        printf("%s\n", lua_tostring(EnemyState, -1));
        lua_close(EnemyState);
        exit(0);
    }

    // コルーチンを実行するためのスレッドを生成
    EnemyMovement = lua_newthread(EnemyState);
    lua_getglobal(EnemyMovement, "EnemyMovement");

    // コルーチンステートにenemyテーブルを定義する
    // 敵機に関する情報はLua側で保存するものとする
    lua_newtable(EnemyMovement);
    lua_newtable(EnemyMovement);
    lua_pushnumber(EnemyMovement, GetPosition().X);
    lua_setfield(EnemyMovement, -2, "x");
    lua_pushnumber(EnemyMovement, GetPosition().Y);
    lua_setfield(EnemyMovement, -2, "y");
    lua_setfield(EnemyMovement, -2, "position");
    lua_pushcfunction(EnemyMovement, &Launch);
    lua_setfield(EnemyMovement, -2, "launch");
    lua_setglobal(EnemyMovement, "enemy");
}

Enemy::~Enemy()
{
    // 使い終わった Lua ステートは必ず close する
    lua_close(EnemyState);
}

void Enemy::OnUpdate()
{
    // コルーチンの実行
    // この関数を実行した瞬間,EnemyMovementが持つスタックは
    // 空になるため,lua_pop関数を実行する必要はない.
    lua_resume(EnemyMovement, nullptr, 0);

    // Luaで保存されている敵機の情報を読み出し
    lua_getglobal(EnemyMovement, "enemy");
    lua_getfield(EnemyMovement, -1, "position");
    lua_getfield(EnemyMovement, -1, "x");
    lua_getfield(EnemyMovement, -2, "y");

    // ポジションの設定
    Vector2DF position = Vector2DF(
        (float)lua_tonumber(EnemyMovement, -2),
        (float)lua_tonumber(EnemyMovement, -1)
    );
    SetPosition(position);
}

プログラムが完成した状態でbuild.shを実行してください. 敵が上下左右に移動したり,弾を撃ったりしているはずです.

ACAC-2019-12-09(5).png

これができたら,Enemy.luaの内容を好きに書き換えてみてください. ファイルの内容を書き換えるだけで,敵の挙動を簡単に変えることができると思います. ただし,書き換える際は以下の形式に従ってください.

function EnemyMovement()
    while true do
        ...
        coroutine.yield();
        ...
    end
end

C++で実行しているLuaコルーチンは,永遠に処理を終えることがないことを前提としているため,EnemyMovement関数内のwhileループは絶対に取らないでください. また,EnemyMovement関数はコルーチンなので,少なくとも一つはcoroutine.yield()を書いてください.

Tips : スクリプトとの値の受け渡しの手順を説明する際に示している図について

例えば,敵弾をレイヤーに追加する時にスタックから変数の情報を取得する手順を説明する際,以下のような図を示しました. ACAC-2019-12-09(3).png この図は,LuaスクリプトとC++プログラムで値のやり取りをするプログラムを作成する上でとても重要になります. とくにC++とLuaの間でテーブルをやり取りする際,get_field関数でフィールドを読み込む順番を考えないと,スタックのどの位置に何を意味する値が含まれているのかが分からなくなってしまいます.グローバル変数やテーブル中のフィールドをどの順番で読み込み,そうした場合,スタックの内容はどうなっているのかを把握するために,適当でもこの手順図を描いておくと,格段に作業効率が上がります.Luaのようにスタックを使って値をやり取りするようなスクリプトを組み込む場合,必ずこのような図を描いておきましょう.

ステージをスクリプトで制御する

ここでは主にGameScene.cppにプログラムを書き加えていきます. GameScene.cppでは,自機や敵機などの描画を行うクラス(GameSceneクラス)の実装を行っています. GameSceneクラスの定義部分はGameScene.hppをご参照ください.

コンストラクタの実装

GameSceneクラスにはEnemyクラスと同じように,スクリプトを読み込むためのSceneStateと,コルーチンを実行するためのSceneMovementが定義されています. Enemyクラスのコンストラクタと同じようにスクリプトを読み込み,コルーチンを生成します.

// ステージを制御するステートを作成し,スクリプトをファイルから読み込み
StageState = luaL_newstate();
luaL_openlibs(StageState);
if(luaL_dofile(StageState, "scripts/Stage.lua"))
{
    // エラーメッセージを出力して強制終了
    printf("%s\n", lua_tostring(StageState, -1));
    lua_close(StageState);
    exit(0);
}

// コルーチンを実行するためのスレッドを生成
StageMovement = lua_newthread(StageState);
lua_getglobal(StageMovement, "StageMovement");

ゲームシーンの主な仕事は,敵を追加することです. 敵をレイヤーに追加する処理をLua側で呼び出したいため,AddEnemy関数をLua側に公開します.

// 敵を追加する関数を Lua スクリプトに公開
lua_pushcfunction(StageMovement, &AddEnemy);
lua_setglobal(StageMovement, "add_enemy");

敵を配置する処理の呼び出しをLua側でできるようにする

次に,AddEnemy関数(int AddEnemy(lua_State* state))を実装していきます. AddEnemy関数は,敵を配置する時に使う関数です,この関数を渡してLua側で使用するadd_enemy関数は,敵を配置する位置を引数にとり,やはり戻り値はありません. すなわち,AddEnemy関数の引数になっているLuaステートには,テーブルが一つあります. このテーブルについてlua_getfield関数を使ってスタックに展開します. 簡単な例ではありますが,最終的にスタックがどうなっているか,先ほど説明した図を描いて把握しておきましょう.

// 受け取ったテーブルについてフィールドを展開する
lua_getfield(state, -1, "x");
lua_getfield(state, -2, "y");

あとやることとしては,敵弾をレイヤーに追加する操作とほぼ同じになります. 敵のインスタンスを作成し,敵を追加するレイヤーを取得し,取得したレイヤーに敵を追加します. ついでにスタックも空にしておきましょう.

// 受け取ったテーブルの情報から敵機のインスタンスを作成する.
shared_ptr<Enemy> enemy = 
    make_shared<Enemy>(Vector2DF(lua_tonumber(state, -2), lua_tonumber(state, -1)));

// "GameLayer" という名前から敵機を追加するレイヤーを探す
shared_ptr<Layer2D> game_layer = nullptr;
for(auto layer : Engine::GetCurrentScene()->GetLayers())
    if(layer->GetName() == u"GameLayer")
    {
        // Layer2Dにキャストできることを確認してから代入する.
        auto l = dynamic_pointer_cast<Layer2D>(layer);
        if(l != nullptr) game_layer = l;
    }

// レイヤーに敵機を追加する
game_layer->AddObject(enemy);

// スタックを空にする
lua_pop(state, lua_gettop(state));
    
// 戻り値なし
return 0;

C++側でLuaのコルーチンを実行する

次に,シーンの更新処理(void Enemy::OnUpdate())を実装していきます. このスクリプトは,シーンに反映するような内部情報を保持しません. つまり,グローバル変数の読み出しを行う必要がないため,コルーチンを実行するだけでおkです.

// コルーチンの実行
lua_resume(StageMovement, nullptr, 0);

もちろん,シーンに内部状態を持たせても構いません. その場合は,敵機にスクリプトを組み込む時と同じように,コンストラクタでシーンの内部状態を保持するテーブルか何かをLuaで使えるようにし,この関数でグローバル変数の読み出しを行なってください.

最後に,出来上がったプログラムを以下に示します.

#include "GameScene.hpp"

int AddEnemy(lua_State* state)
{
    /*
    この関数はテーブルを引数にとり,敵の配置位置が指定される.

    なお,引数に指定するテーブルは以下のように定義されているものとする.

    position = {
        x = ...,
        y = ...
    }
    */

    // 受け取ったテーブルについてフィールドを展開する
    lua_getfield(state, -1, "x");
    lua_getfield(state, -2, "y");

    // 受け取ったテーブルの情報から敵機のインスタンスを作成する.
    shared_ptr<Enemy> enemy = 
        make_shared<Enemy>(Vector2DF(lua_tonumber(state, -2), lua_tonumber(state, -1)));

    // "GameLayer" という名前から敵機を追加するレイヤーを探す
    shared_ptr<Layer2D> game_layer = nullptr;
    for(auto layer : Engine::GetCurrentScene()->GetLayers())
        if(layer->GetName() == u"GameLayer")
        {
            // Layer2Dにキャストできることを確認してから代入する.
            auto l = dynamic_pointer_cast<Layer2D>(layer);
            if(l != nullptr) game_layer = l;
        }

    // レイヤーに敵機を追加する
    game_layer->AddObject(enemy);

    // スタックを空にする
    lua_pop(state, lua_gettop(state));
    
    // 戻り値なし
    return 0;
}

GameScene::GameScene()
{
    // オブジェクトを表示するためのレイヤーを用意・登録
    GameLayer = make_shared<Layer2D>();
    GameLayer->SetName(u"GameLayer");

    // レイヤーに自機の追加
    GameLayer->AddObject(make_shared<Player>());

    // ステージを制御するステートを作成し,スクリプトをファイルから読み込み
    StageState = luaL_newstate();
    luaL_openlibs(StageState);
    if(luaL_dofile(StageState, "scripts/Stage.lua"))
    {
        // エラーメッセージを出力して強制終了
        printf("%s\n", lua_tostring(StageState, -1));
        lua_close(StageState);
        exit(0);
    }

    // コルーチンを実行するためのスレッドを生成
    StageMovement = lua_newthread(StageState);
    lua_getglobal(StageMovement, "StageMovement");

    // 敵を追加する関数を Lua スクリプトに公開
    lua_pushcfunction(StageMovement, &AddEnemy);
    lua_setglobal(StageMovement, "add_enemy");
}

GameScene::~GameScene()
{
    // 使い終わった Lua ステートは必ず close する
    lua_close(StageState);
}

void GameScene::OnRegistered()
{
    AddLayer(GameLayer);
}

void GameScene::OnUpdated()
{
    // コルーチンの実行
    lua_resume(StageMovement, nullptr, 0);
}

プログラムが完成した状態でbuild.shを実行してください. 時間が経つにつれてどんどん敵が増えていくのがわかります.

ACAC-2019-12-09(6).png

C++/Luaスクリプティングでできないこと

今回示したソースコードの全体像について,敵が弾を撃つときに使用するLaunch関数や,ステージが敵を追加するときに使用するAddEnemy関数は,クラスの外で定義されている,すなわちクラスのインスタンスメンバ関数ではないのです. 実はlua_pushcfunction関数では,クラスに実装されているインスタンスメンバ関数の関数ポインタを指定することができません. というのも,C++にインスタンスメンバ関数を定義する際,いわば第0引数にthisが暗黙的に指定されているからです. lua_pushcfunction関数で指定することができるのは,luaState*型の変数のみをインスタンスにもつ関数です. したがってLua側でゲームの内部状態を変化させたいとき,Luaに渡す関数はクラスに実装されているインスタンスメンバ関数であってはならず,クラスの外で定義された関数の中で,レイヤーを名前で検索するなど,頑張って内部状態を変更する処理を作成する必要があります.

終わりに / 次回予告

今回は,実際にLuaスクリプトをゲームに組み込むサンプルを示してきました. ゲームのみならず,Luaスクリプティングは広い範囲で応用が利きます. これを機に,組み込みスクリプティングに興味を持っていただけたなら幸いです.

次回の13日目の記事では,C++/Luaからは少し道を逸れて,C#のスクリプト環境であるRoslynを弄っていこうと思います. ではまた次回,お会いしましょう.

SHARE THIS POST