ホーム < ゲームつくろー! < Lua組み込み編


その6 Luaからゲームウィンドウを作ってみる


 前章でLuaからC言語の関数を呼ぶ方法を取り上げました。この章までで、LuaからC言語の関数も、C言語からLuaの関数も呼べるようになり、お互いに情報のやり取りが可能となりました。

 そろそろ具体的な事をしたくなります。そこで、この章ではLuaからゲーム用のウィンドウを作り、そこにラインを引いてみたいと思います。ここまではコンソールウィンドウでしたが、ここからはWin32アプリケーションで黒くないウィンドウができます(笑)。



@ Luaファイルからパラメータテーブルを貰う

 Luaからは直接ウィンドウを作成するWin32 APIは呼べません。ですから、Luaファイルで作成に必要なパラメータを作成し、それをC言語側で受けてウィンドウを作成することにします。

 Luaファイル内にグローバルな値を設定するのも手としてもちろんありなのですが、折角ですからLuaファイル内に初期化関数となるinit関数を設定します。C言語側はそのinit関数から初期化パラメータを受け取ります。init関数はテーブルを作って返すのが簡単かなと思います。例えばこんな感じです:

InitWindow.lua
function init()
    return {
        width = 640,
        height = 480
    };
end

init関数は幅と高さのパラメータをテーブルとして返すとします。Lua側は単純ですね。



A C言語側でテーブルを解析

 C言語側では上のInitWindow.luaファイルを読み込み解析し、Luaステートにinit関数を登録してもらいます。次にinit関数を呼び出し、テーブルをスタックに積んでもらいます。ここまでは特に問題ありません。

 これまでスタックにある数値や文字列の取得は見てきました(lua_tonumber関数、lua_tostring関数)。テーブルにある値を取得するのはちょっと特別で、変数の名前を通す必要があります。これには「lua_getfield関数」を使います:

void lua_getfield(
    lua_State *L,    // Luaステート
    int idx,         // テーブルがあるスタックの番号
    const char *k    // 変数名(=フィールド名)
);

第1引数はスタックを持っているLuaステートです。
第2引数はスタック内のテーブルがあるスタック番号を指定します。
第3引数はテーブル内の変数名(フィールド名)を文字列で渡します。

 テーブルからウィンドウの幅と高さの情報を貰えば、後はそれを設定してウィンドウを作成するだけです。以上を実現するコードの叩き台はこんな感じになりました:

Luaでウィンドウサイズを指定(main.cpp)
#include <windows.h>
#include <tchar.h>
#include <lua.hpp>

TCHAR gName[100] = _T("Luaによるウィンドウ作成サンプルプログラム");

LRESULT CALLBACK WndProc(HWND hWnd, UINT mes, WPARAM wParam, LPARAM lParam) {
    switch(mes) {
    case WM_DESTROY:
        PostQuitMessage(0);
        return 0;
    }
    return DefWindowProc(hWnd, mes, wParam, lParam);
}

int APIENTRY _tWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPTSTR lpCmdLine, int nCmdShow) {
    MSG msg; HWND hWnd;

    lua_State *L = luaL_newstate();
    luaL_openlibs(L);

    // Lua内の初期化関数を呼ぶ(エラー処理を省略しています)
    luaL_dofile(L, "InitWindow.lua");
    lua_getglobal(L, "init");
    lua_pcall(L, 0, 1, 0);  // ここでテーブルがスタックに積まれます

    // テーブルからパラメータ取得
    lua_getfield(L, 1, "width");  // テーブルに含まれる幅値をスタックに積む
    lua_getfield(L, 1, "height"); // テーブルに含まれる高さ値をスタックに積む
    int width = 800, height = 600;
    if (lua_type(L, 2) == LUA_TNUMBER)
        width = (int)lua_tonumber(L, 2);
    if (lua_type(L, 3) == LUA_TNUMBER)
        height = (int)lua_tonumber(L, 3);

    // Luaステートはもういらないので解放
    lua_close(L);

    WNDCLASSEX wcex ={sizeof(WNDCLASSEX), CS_HREDRAW | CS_VREDRAW, WndProc, 0, 0, hInstance, NULL, NULL, (HBRUSH)(COLOR_WINDOW+1), NULL, (TCHAR*)gName, NULL};
    if(!RegisterClassEx(&wcex))
        return 0;

    RECT r = {0, 0, width, height};
    ::AdjustWindowRect(&r, WS_OVERLAPPEDWINDOW, FALSE);
    r.right -= r.left;
    r.bottom -= r.top;
    if(!(hWnd = CreateWindow(gName, gName, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, 0, r.right, r.bottom, NULL, NULL, hInstance, NULL)))
        return 0;

    ShowWindow(hWnd, nCmdShow);

    // メッセージ ループ
    char str[512];
    sprintf_s<512>(str, "Window Width = %d, Window Height = %d", width, height);
    do{
        if( PeekMessage(&msg, NULL, 0, 0, PM_REMOVE) ){
            DispatchMessage(&msg);
        }
        HDC hDC = GetDC(hWnd);
        ::TextOutA(hDC, 0, 0, str, (int)strlen(str));
    }while(msg.message != WM_QUIT);

    return 0;
}

 「Lua内の初期化関数を呼ぶ」というコメントの範囲まででLuaファイル内のinit関数を呼んでいます。これが通ればinit関数が初期化値が入ったテーブルを返し、それがスタックに積まれているはずです

 スタックに積まれているテーブルから、次に欲しいパラメータを貰います。テーブルから値を得るのはlua_getfield関数です。指定のフィールド名な変数があれば、その値がスタックに積まれます。無ければ「nil」が積まれます。これは重要な事で、lua_getfield関数は成功しても失敗しても、スタックにちゃんと何かを積んでくれます。これにより、値があればそれを格納、無ければ無視という判定がスムーズにできるわけです。実際コードではその判定を行って作成するウィンドウサイズを変更しています。

 使い終わったLuaステートはメモリを圧迫するだけなのでさっさと削除です。後は指定されたウィンドウサイズ(クライアントサイズ)でウィンドウを作成して処理完了となります。結果はこんな感じです:

 後はLua側のinit関数が返すテーブルにパラメータを追加し、C言語側でそれに対応すれば、Lua側から作るウィンドウの初期状態を柔軟に変更できるようになります。



B Luaからウィンドウに線を引いてみよう

 では次に、Luaで「ラインオブジェクト」を作り、そこに座標を与えてウィンドウに線を引いてみる事にします。

 LuaからはWin32 APIは呼べませんので、最終的なライン引きはC言語側が行うのですが、今回はLua上でオブジェクトを作り、それに指示した通りのラインを引くという点で、ちょっとオブジェクト指向っぽいLuaになります。

 Lua側でラインオブジェクトを作るのはこんな感じでしょうか:

InitWindow.lua
function initObject()
    window = getWindow();
    obj1 = createObject("Line", 100, 200, 300, 50);
    obj2 = createObject("Line", 80, 30, 250, 250);
    window:setObject(obj1);
    window:setObject(obj2);
end

ん〜…。少しずつ考えていきましょう。

 まず上の実装内で呼び出しているgetWindow、createObject、setObject関数は全部C言語側で設定します。getWindow関数はウィンドウオブジェクトをLua側に渡すとします。ただC++のクラスのオブジェクトは直接渡せませんので、ウィンドウに対して操作する為の関数が入ったテーブルをC言語側で作って渡します。その関数の一つsetObject関数ではウィンドウ上に置くウィジット(丸とか線とかボタンの総称)をウィンドウに登録します。Luaはあらゆる値を関数に渡せるのでLua側は問題ありません。

 ちょっとここでLuaを通さないでラインオブジェクトを登録する事を考えてみます。単純にはこうなると思います:

C言語側なら・・・
Object* obj1 = new Line(100, 200, 300, 50);
Object* obj2 = new Line(80, 30, 250, 250);

objArray.push_back(obj1);
objArray.push_back(obj2);

 つまり、setObject関数を呼んだ時にこのポインタの登録を行う必要があるんです。setObject関数内で具体的なオブジェクトを作るのは芸がありません。やはり「Lua側から登録すべきポインタを渡してもらう」のがスマートです。Lua側でC言語のポインタを直接は作れません。しかし、C言語側からポインタを貰うことはできます。そう、Luaには「LightUserData型」というポインタを保持できる型があるのでした。これです、やりました、いえい(^-^)v

 Lua側コードでのcreateObject関数が呼ばれたら、C言語側でLineオブジェクトを作り、そのポインタを含めたテーブルを作ってLua側に戻します。例えばそのポインタに「pointer」というフィールド名を付けたとして、次に呼ばれるwindow:setObjectメソッドに渡されたラインオブジェクトテーブルからそのpointerを指定し、得られたポインタをC言語側で登録します。後はそれを親クラスのポインタ(Object*)として取り扱えば、オブジェクトの配列(objArray)に登録できるというわけです。

 筋道は見えましたので、ちょっとずつ作りこんでいきます。

 まず、ウィンドウを操作する関数が入ったテーブルを返すC言語側のgetWindow関数はこんな感じです:

getWindow関数(C言語側)
// ウィンドウ操作テーブルをLua側に渡す
int getWindow(lua_State *L) {
    lua_newtable(L);

    // 関数テーブルを作る
    lua_pushcfunction(L, &setObject);
    lua_setfield(L, 1, "setObject");

    return 1;
}

このメソッドがLua側から呼ばれたら、新しいテーブルを1つ作り(lua_newtable)、C言語側のsetObject関数をスタックに積んで(lua_pushcfunction)、lua_setfieldでテーブルに登録します。この段階でスタックにはテーブル1つだけになりますので、戻り値1つとして関数を終わります。

 次にウィンドウに配置できるオブジェクトを生成するcreateObject関数を見てみましょう:

createObject関数(C言語側)
// オブジェクト作成関数
int createObject(lua_State *L) {
    // 引数から作成するオブジェクトを判別
    const char* name = lua_tostring(L, 1);
    Object *object = 0;
    if (strcmp(name, "Line") == 0) {
        // ラインオブジェクト生成
        int x1 = (int)lua_tonumber(L, 2);
        int y1 = (int)lua_tonumber(L, 3);
        int x2 = (int)lua_tonumber(L, 4);
        int y2 = (int)lua_tonumber(L, 5);

        // ラインオブジェクトを作る
        object = new Line(x1, y1, x2, y2);
    }

    lua_pop(L, lua_gettop(L));

    // オブジェクトをテーブルに登録
    lua_newtable(L);
    lua_pushlightuserdata(L, object);
    lua_setfield(L, 1, "pointer");

    printStack(L);

    return 1;
}

 この関数は要はファクトリ関数なんです。Lua側の第1引数には作成するオブジェクトの種類が文字列で入ってきます(という約束にします)。今は"Line"です。Lineの場合第2〜第5引数に線の視点と終点が入ってきます(という約束にします(^-^;)。スタックに積まれているそれら値を(x1, y1) - (x2, y2)にそれぞれ代入し、Lineオブジェクトにさらにその値を引き渡しています。

 オブジェクトが出来たらもう引数は必要ないのでスタックにある値を全部popしてしまいます。続いて戻り値となるテーブルを作成しています。このテーブルには先ほど作成したLineオブジェクトのポインタを登録します。スタックにポインタを乗せるには「lua_pushlightuserdata関数」を用います。この関数の第2引数に渡したvoid*型がスタックに乗ります。次にlua_setfield関数でこのポインタに"pointer"という名前を付けてテーブルに登録します。

 これでcreateObject関数をLua側から呼ぶと、Lineオブジェクトのポインタを保持したテーブルが返ります。このポインタに対してLuaから一切何も直接操作は出来ませんが、次のsetObject関数にLineオブジェクトたるテーブルを渡すとC言語に処理が戻ってきますので、そこでテーブルから再びポインタを取り戻し、処理ができるというわけです。

 setObject関数は次の通りです:

setObject関数(C言語側)
// ウィジットオブジェクトを登録
int setObject(lua_State *L) {
    // 第1引数にはウィンドウオブジェクトテーブル
    // 第2引数には設定したいウィジットのテーブルが入っているはず
    lua_getfield(L, 2, "pointer");
    Object* obj = (Object*)lua_topointer(L, lua_gettop(L));
    objectArray.push_back(obj);

    return 0;
}

 Lua側では「window:setObject(obj1)」とコロンでこの関数を呼んでいます(windowテーブルにsetObject関数が登録されていて、クラスとして扱う場合はコロンを使う約束)。コロンで関数を呼ぶと、第1引数に呼んだ本人であるwindowテーブル、第2引数にobj1テーブルが入ってきます。よって上の関数内では第2引数にあるobj1テーブルから先に登録した"pointer"を取得して(lua_topointer関数を使います)、それをObject型にキャストし、配列に登録しています。


 以上でラインオブジェクトを取得して座標を登録し、それを指定のウィンドウに配置するという処理がLua側で出来ました、もう一度流れを整理してみましょう。下図を御覧下さい:

 C側のgetWindow関数はウィンドウに対する関数が登録されたテーブルをLua側に渡します。上ではsetObject関数を登録していますが、例えばオブジェクトを削除するremoveObject関数などもここで登録するわけです。createObject関数の最大の仕事はLineオブジェクトのポインタをテーブルに登録してLua側に返すことです。このテーブルにLineに関する他の関数(setStart関数、setEnd関数など)も一緒に登録して渡せば、Lineクラスのメソッドとして使えるわけです。windowテーブルに登録してあったsetObject関数をLua側で呼び、ラインオブジェクトのポインタが入ったテーブル(obj1)を渡せば、C側の関数内でそのポインタを取り出せるので、めでたくobjectArrayにそのポインタを登録できるわけです。

 このように、Lua側でC++側のオブジェクトは直接扱うことはできませんが、テーブル≒オブジェクトという扱いをしてそのテーブル毎にオブジェクトポインタを保持しておいて、そのポインタ入りテーブルをC側に戻せば、オブジェクト単位で操作する事ができるんです。上の図で「*obj」というポインタ入りテーブルがC++→Lua→C++とぐるっと回ってきているのに注目すればここはOKです。

 上の例を実現する完全コードを下に挙げましたので、コピペして確かめて見て下さい:

InitWindow.lua(Lua側)
function init()
    return {
        width = 640,
        height = 480
    };
end

function initObject()
    window = getWindow();
    obj1 = createObject("Line", 100, 200, 300, 50);
    obj2 = createObject("Line", 80, 30, 250, 250);
    window:setObject(obj1);
    window:setObject(obj2);
end

main.cpp(C言語側)
#include <windows.h>
#include <tchar.h>
#include <lua.hpp>
#include <vector>

TCHAR gName[100] = _T("Luaによるウィンドウ作成サンプルプログラム");


/////////////////////////////////////////
// クラス

// Object
class Object {
protected:
    char name[16];

public:
    Object() {
        strcpy_s<16>(name, "Object");
    }
    virtual ~Object() {}
    const char* getName() const {return name;}

    virtual void draw(HDC hDC) = 0;
};

// Line
class Line : public Object {
    int x1, y1, x2, y2;

public:
    Line(int x1, int y1, int x2, int y2) :
        x1(x1), y1(y1), x2(x2), y2(y2)
    {
        strcpy_s<16>(name, "Line");
    }
    virtual ~Line() {}

    virtual void draw(HDC hDC) {
        MoveToEx(hDC, x1, y1, NULL);
        LineTo(hDC, x2, y2);
    }
};


/////////////////////////////////////////
// 変数
std::vector<Object*> objectArray;

/////////////////////////////////////////

// ウィジットオブジェクトを登録
int setObject(lua_State *L) {
    // 第1引数にはウィンドウオブジェクトテーブル
    // 第2引数には設定したいウィジットのテーブルが入っているはず
    lua_getfield(L, 2, "pointer");
    Object* obj = (Object*)lua_topointer(L, lua_gettop(L));
    objectArray.push_back(obj);

    return 0;
}

// ウィンドウ操作テーブルをLua側に渡す
int getWindow(lua_State *L) {
    lua_newtable(L);

    // 関数テーブルを作る
    lua_pushcfunction(L, &setObject);
    lua_setfield(L, 1, "setObject");

    return 1;
}

// オブジェクト作成関数
int createObject(lua_State *L) {
    // 引数から作成するオブジェクトを判別
    const char* name = lua_tostring(L, 1);
    Object *object = 0;
    if (strcmp(name, "Line") == 0) {
        // ラインオブジェクト生成
        int x1 = (int)lua_tonumber(L, 2);
        int y1 = (int)lua_tonumber(L, 3);
        int x2 = (int)lua_tonumber(L, 4);
        int y2 = (int)lua_tonumber(L, 5);

        // ラインオブジェクトを作る
        object = new Line(x1, y1, x2, y2);
    }

    lua_pop(L, lua_gettop(L));

    // オブジェクトをテーブルに登録
    lua_newtable(L);
    lua_pushlightuserdata(L, object);
    lua_setfield(L, 1, "pointer");

    return 1;
}

LRESULT CALLBACK WndProc(HWND hWnd, UINT mes, WPARAM wParam, LPARAM lParam) {
    switch(mes) {
        case WM_DESTROY:
            PostQuitMessage(0);
            return 0;
    }
    return DefWindowProc(hWnd, mes, wParam, lParam);
}

int APIENTRY _tWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPTSTR lpCmdLine, int nCmdShow)
{
    // アプリケーションの初期化
    MSG msg; HWND hWnd;

    lua_State *L = luaL_newstate();
    luaL_openlibs(L);

    // Lua内の初期化関数を呼ぶ
    if (luaL_dofile(L, "InitWindow.lua")) {
        ::MessageBoxA(0, "初期化用Luaファイルがありません。", "初期化エラー", 0);
        lua_close(L);
        return -1;
    }
    lua_getglobal(L, "init");
    if (lua_type(L, -1) == LUA_TNIL) {
        ::MessageBoxA(0, "init関数がありません。", "初期化エラー", 0);
        lua_close(L);
        return -1;
    }
    if (lua_pcall(L, 0, 1, 0)) {
        char error[512];
        sprintf_s<512>(error, "init関数の呼び出しに失敗しました。\n%s", lua_tostring(L, -1));
        ::MessageBoxA(0, error, "初期化エラー", 0);
        lua_close(L);
        return -1;
    }

    // テーブルからパラメータ取得
    if (lua_type(L, -1) != LUA_TTABLE) {
        char error[512];
        sprintf_s<512>(error, "初期化テーブルがinit関数で返されていません。\n%s", lua_tostring(L, -1));
        ::MessageBoxA(0, error, "初期化エラー", 0);
        lua_close(L);
        return -1;
    }
    lua_getfield(L, 1, "width"); // テーブルに含まれる幅値をスタックに積む
    lua_getfield(L, 1, "height"); // テーブルに含まれる高さ値をスタックに積む
    int width = 800, height = 600;
    if (lua_type(L, 2) == LUA_TNUMBER)
        width = (int)lua_tonumber(L, 2);
    if (lua_type(L, 3) == LUA_TNUMBER)
        height = (int)lua_tonumber(L, 3);
    lua_pop(L, lua_gettop(L));

    // C言語側の関数を登録
    lua_register(L, "getWindow", &getWindow);
    lua_register(L, "createObject", &createObject);

    // ウィジットオブジェクトをLua側から登録
    lua_getglobal(L, "initObject");
    if (lua_pcall(L, 0, 0, 0)) {
        char error[512];
        sprintf_s<512>(error, "init関数の呼び出しに失敗しました。\n%s", lua_tostring(L, -1));
        ::MessageBoxA(0, error, "初期化エラー", 0);
        lua_close(L);
        return -1;
    }

    // Luaステートはもういらないので解放
    lua_close(L);

    WNDCLASSEX wcex ={sizeof(WNDCLASSEX), CS_HREDRAW | CS_VREDRAW, WndProc, 0, 0, hInstance, NULL, NULL, (HBRUSH)(COLOR_WINDOW+1), NULL, (TCHAR*)gName, NULL};
    if(!RegisterClassEx(&wcex))
        return 0;

    RECT r = {0, 0, width, height};
    ::AdjustWindowRect(&r, WS_OVERLAPPEDWINDOW, FALSE);
    r.right -= r.left;
    r.bottom -= r.top;
    if(!(hWnd = CreateWindow(gName, gName, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, 0, r.right, r.bottom, NULL, NULL, hInstance, NULL)))
        return 0;
   
    ShowWindow(hWnd, nCmdShow);

    // メッセージ ループ
    char str[512];
    sprintf_s<512>(str, "Window Width = %d, Window Height = %d", width, height);
    HDC hDC = GetDC(hWnd);
    do{
        if( PeekMessage(&msg, NULL, 0, 0, PM_REMOVE) ){
            DispatchMessage(&msg);
        }
        ::TextOutA(hDC, 0, 0, str, (int)strlen(str));

        for (size_t i = 0; i < objectArray.size(); i++) {
            objectArray[i]->draw(hDC);
        }
    }while(msg.message != WM_QUIT);

    return 0;
}


 ちなみに実行結果はこちら:

Lua側で指定した座標にラインが引けました(^-^)。Lua側でラインオブジェクトをどんどん取得してwindowに設定すれば、どんどん線が引けます。

 今回はWin32 APIでラインを引きましたが、C側はどうとでも書けるわけで、DirectXに置き換えももちろんできます。Lua側を変えることなくゲームエンジンをごそっと入れ替えるというのは「スクリプトによるゲームロジック」と「エンジン」を分離するという、昨今のゲームプログラムの大切な設計思想です。

 Luaによるクラスの操作っぽい事が少しずつ見えてきました。楽しいですね〜(^-^)