初めまして、高橋直樹と申します。ゲームエンジンNScripter,NScripter2の開発者です。twitterアカウントはhttps://twitter.com/NTak_indies です。
今回は、NScripter2とLuaを題材にしてゲームに汎用スクリプト言語を組み込む利点を紹介したいと思います。
NScripter2は商用ではそれなりに採用例があるのですが、同人ではあまり使われていないのが実情です。NScripter2に特化した議論というよりは、スクリプト言語を組み込めばこういうことが出来るという紹介として読めるようにしたつもりです。
さて、みなさんはLuaをご存じでしょうか。こちらにwikipediaの記事があります。 2007年頃から強い注目を受け始め、ゲームやアプリケーションに組み込むスクリプト言語としては今でも人気があります。
私はNScripterのプラグインとして、NScripter2においてはシステムの主幹としてLuaを採用し、仕事をこなしてきたわけですが、他にもLuaを使えるゲーム開発環境は多くあります。例えばCocos2d-xにはLuaバインディングがあります。他のエンジンでも、CやC++で書かれているものなら全てLuaを利用可能です。JavaにはLuaj(マインクラフト内でプログラミングが出来るMOD「ComputerCraft」にはこれが使われているようです)、C#にはMoonSharpなどのLuaを移植したライブラリがあります(UnityからはこのMoonSharpが使えます。C#で書かれているため、プラットフォームを選ばず使えます)。
07年頃とは状況が変わり、今ではLua一択ということはなくなってきています。mrubyという組み込み向きのRuby実装や、Google V8 EngineというオープンソースのJavaScript実装、それを利用したnode.jsやElectron、nw.js(RPGツクールMVはこれで動いています)なども台頭してきています。RubyもJavaScriptもWEB系プログラマによく使われる言語でありその点アドバンテージがあるように思われます。いずれを使う場合でも、今回紹介する内容は参考になるかと思います。
なぜスクリプト言語を使うのか。柔軟なゲーム開発が可能になるからです。もし貴方がゲームエンジン開発者であるのなら、「最初から」スクリプト言語を組み込むことをおすすめします。出来上がったエンジンに途中からスクリプト言語を組み込むのよりもかなり楽に作ることが出来るはずです。
プリプロセッサを柔軟に書ける
これは簡単でおすすめです。まずはここから始めるのもいいかもしれません。ADVエンジンが自前のシナリオスクリプト言語を持っている場合でも、シナリオスクリプトを読み込む前にスクリプト言語を通すようにすることで、ソースを加工し、簡単にプリプロセッサ処理、マクロ処理が書けますし、プロジェクトに合わせた細かい変更もすぐに出来ます(C++だとコンパイルしなおさなければなりませんので)。
Luaの正規表現には実はちょっとクセがある(機能縮小版で文法もUnixで標準的に使われているものとは違う)のですが、NScripter2ではstring:replaceという関数を付け加え、ここでboost::regex_replaceを使えるようにしています。
なお、こういう目的で使う場合、文字列処理はRubyのほうがリッチなので、mrubyのほうがいいかもしれません(mrbgemから正規表現ライブラリを導入した場合)。
--root.lua function main()を抜粋
function main ()
--BASICのソースファイルのロード
for i=0,99 do
local fname=string.format("%02d.txt",i)
if archive.seek(fname) then
local str=archive.read("\n")
if basic.utf8_flag==0 then
str=encoding.ansi_to_utf8(str)
end
------------
--以下追加分
------------
str=str:replace("〓","❤")
--〓(「げた」を変換すると出ます)を❤に置換しています。
--業界慣習的にSJISが標準なのですが、せっかくUnicodeにハートマークがあるので、
--こうやって記述できるようにしました。
str=str:replace("kreturn","if kaisoumode==1 then return")
--回想モードのときのみreturnし、そうでないときはそのまま処理を続ける
--制御文です。NScripter2では制御文はユーザーからは作れないため、
--文字列置換で実現しています。
--ここでは単純な置換だけしかしていませんが、Luaで色々書くことで、
--もっと複雑なスクリプトを作ることも出来ます。
------------
--追加ここまで
------------
basic.loadscript(fname,str)
end
end
--BASICの実行
basic.run() -- 無限ループ
end
;(00.txt)
@start
@scene1 ;ここにシーン回想モードからgosubで飛んでくるのを想定
表示文字列中の〓はちゃんとハートに書き換わります。
kreturn ;ここが置換されます。
@scene2
回想モードのときはreturnしますが、ゲーム本編ではそのまま実行されます。
quit
;なお、NScripter2ではエラーの行番号をずらさないために置換内容は一行に収める必要があります
Luaのテーブルは強力でかなり高速に動作する
Luaのテーブルは、JSONフォーマットと同様に連想配列や配列を扱うことが出来ます。
フリーソフトの2Dタイルマップエディタとして有名な「Tiled Map Editor」は出力フォーマットにLuaを選ぶことが出来るので、簡単にタイルマップを実現できます。(他にもJSON等で出力できますので、他のツールではそれらの方法を用いることが多いです)
--(テストではroot.luaにこれを書いて読み込みました)
basic.registercom("LOADTILEMAP","SS")
function basic_func.LOADTILEMAP(spname,filename)
local mapdata=archive.dofile(filename)
-- マップデータを読み込む
-- (実際はあとで外からアクセスしたいだろうから、変数を関数の外で宣言し
-- 指定座標のチップ番号を取得する命令も作った方がいいと思います)
local layer=mapdata.layers[1]
-- 最初のレイヤを取り出す
local texw=layer.width*32
local texh=layer.height*32
basic_func.SP(spname,'name="*'..texw..","..texh..',#00000000",x=0,y=0,z=0')
-- スプライトを作成する(BASICのスプライトシステムの命令を呼んでいる)
local tex=basic.texture[spname..":0"]
-- テクスチャを取得
local mapchip=bitmap.load("edge1.png")
for j=0,layer.height-1 do
for i=0,layer.width-1 do
local chip=layer.data[i+j*layer.width+1]
tex:copybitmaprect(mapchip,i*32,j*32,32,32,(chip-1)*32,0)
-- マップデータを読んで該当するチップをテクスチャに転送する
end
end
end
;(00.txt)
@start
loadtilemap "test","stage1.lua"
; スプライト"test"にマップファイルstage1.luaを読み込んでスプライトを作成
click ;すぐ終了しないようにとりあえずクリック待ち
quit
グルー言語としてのLua
C++で書かれたなんらかのライブラリがあるとします。そのままDLLにしてエンジンから読めるようにするよりも、Luaの外部ライブラリとしてラップして使うほうが何かと便利です。組み込み方法を統一出来ますし、ちょっとしたLuaコードを書いて、引数を加工してDLLに渡すようなヘルパー関数も簡単に作れます。Luaにはuserdataというものがあり、外部ライブラリとデータをやりとりするときに便利に使うことが出来ます。NScripter2の実装においても、内部のTextureクラスやBitmapクラスやSoundクラスをこのユーザーデータとして扱っています。さらに、メタテーブルを設定することによって、それらをLua上でもクラスのように扱うことが可能です(tex:draw()など)。この辺りの詳細な仕様を学ぶにはProgramming in Lua という本がよいです(ただ、やや難しい本です。C言語が使えないと読めないと思います。Luaの入門書としては別のものがいいかも)。
クラスを扱おうとすると少し難しいですが、とりあえずクラスを使わずとも、関数をどんどん付け加えていけるだけでも十分便利だと思います。
C++のクラスのLuaへのバインディングの自動化に、tolua++というソフトが有名なようです。私は使っていませんが、複雑なクラスを大量に組み込みたい場合は調べてみて下さい。
//main.cpp(sample.dllとして出力しています)
#pragma comment(lib,"lua5.1.lib")
#include "lua.hpp"
#include <stdio.h>
#include <string.h>
#include <new>
extern "C" {
__declspec (dllexport) int luaopen_sample(lua_State *L);
}
int foo (lua_State *L);
int foo (lua_State *L) {
const char *s=luaL_checkstring(L,1);//第一引数
int i=luaL_checkinteger(L,2);//第二引数
char ret[256];
sprintf(ret,"ret:%d",i);
lua_settop(L,0);
lua_pushinteger(L,strlen(s));//第一戻り値
lua_pushstring(L,ret);//第二戻り値
return 2;
}
static const struct luaL_Reg sample_reg[] = {
{"foo",foo},
{NULL,NULL}
};
class SampleClass {
private :
int m_a;
public :
SampleClass(int a) {
m_a=a;
}
~SampleClass() {
//何か解放処理
}
int bar (int b) {
m_a+=b;
return m_a;
}
};
int sampleclass_create(lua_State *L);
int sampleclass_create(lua_State *L) {
int a=luaL_checkinteger(L,1);
lua_settop(L,0);
void *userdata=lua_newuserdata(L,sizeof(SampleClass));
SampleClass *sc=new( userdata ) SampleClass(a);
//この書き方が分からない人は「placement new」を調べて下さい。
luaL_getmetatable(L,"sampleclass");
lua_setmetatable(L,-2);
//ユーザーデータとして生成したクラスのポインタを返す
return 1;
}
int sampleclass_bar (lua_State *L);
int sampleclass_bar (lua_State *L) {
SampleClass *sc=(SampleClass *)luaL_checkudata(L,1,"sampleclass");
//第一引数からSampleClassへのポインタを取得
int b=luaL_checkinteger(L,2);
lua_settop(L,0);
lua_pushinteger(L,sc->bar(b));
return 1;
}
int sampleclass_gc (lua_State *L);
int sampleclass_gc (lua_State *L) {
SampleClass *sc=(SampleClass *)luaL_checkudata(L,1,"sampleclass");
sc->~SampleClass();//明示的デストラクタ呼び出し
lua_settop(L,0);
return 0;
}
static const struct luaL_Reg sampleclass_reg[]={
{"create",sampleclass_create},
{NULL,NULL}
};
static const struct luaL_Reg sampleclass_member_reg[]={
{"bar",sampleclass_bar},
{"__gc",sampleclass_gc},
{NULL,NULL}
};
int luaopen_sample(lua_State *L) {
//sampleの登録
luaL_register(L,"sample",sample_reg);
//sampleclassの登録
luaL_register(L,"sampleclass",sampleclass_reg);
luaL_newmetatable(L,"sampleclass");
lua_pushvalue(L,-1);
lua_setfield(L,-2,"__index");
luaL_register(L,NULL,sampleclass_member_reg);
lua_settop(L,0);
return 0;
}
//C言語から呼び出せる関数はなんでもLuaに登録できます
//NScripter2から使っているLuaは5.1です。5.2以降では
//関数の登録はluaL_newlib等を使うようです。
--Luaからの呼び出し例
require "sample"
local r1,r2=sample.foo("test",10)
console.print(r1)
--出力 4("test"の文字数)
console.print(r2)
--出力 "ret:10"
local sc=sampleclass.create(4)
--初期値4でSampleClassインスタンス作成
console.print(sc:bar(1))
--出力 5(4+1)
console.print(sc:bar(2))
--出力 7(5+2)
console.print(sc:bar(9))
--出力 16(7+9)
sc=nil
--ガベコレのタイミングでデストラクタが呼ばれます
--(その場で削除したい場合はdelete関数等を実装しましょう)
同じ言語で書かれたコードの使い回しが可能になる
自前の言語と違い、Luaは汎用言語です。他でも使われていて、その資産を使える可能性があります。例えば、NScripter2のスプライトシステムは全部Luaで書かれています(system.luaに全部書いてあります)。Cocos2d-xの描画関数と橋渡しするような関数を用意するだけで、Cocos2dに持ち込むことが出来るでしょう(あちらにはあちらのスプライトシステムがあるので移植以外ではあまりそういうことをする利点はありませんが)。高水準機能を汎用スクリプト言語で組み、ネイティブで実装するのを低水準機能に限定することで、エンジンの移植性が高まります(ただし、あまり重い処理をスクリプト言語にやらせると遅くなる可能性もあります)。
他の選択肢について
ここで書いたことはmrubyでも実現可能です。mrubyはrubyの資産を引き継いでライブラリも豊富なので、今新しくエンジンを作るならmrubyもおすすめです。RPGツクールのXPからVX AceまででRubyが使われていましたので、その資産を使える可能性もあります。また、mrubyにはmrbgemsというRubyGemsに似たツールがあり、XMLやJSONのパース等色々なライブラリを簡単に組み込むことができます。