はじめに
gumi Inc. 2018 Advent Calendar の 21 日目の記事です.
今回は, cocos2d-x + Lua での開発を通じて感じたことをご紹介したいと思います.
なお, 現在は Unity での業務に携わっており 2014 〜 2017 年頃の体験談です.
記憶の底から呼び起こして書いたため, 内容が不正確であったり精細に欠くのですが, ご容赦ください.
対象読者
Lua に興味はあるが, ゲーム開発に Lua を使った経験がない方
何故 Lua か
前職での開発環境は C/C++ だったのですが, プロジェクトの後半になると例によってビルド時間が長くなっていきました.
長いビルド時間・遅いリソース転送・過ぎ去る時間・消えない不具合・消えていく仲間...
このような問題に直面すると, もっと楽にスピーディーに開発できないものか(ラピッドな開発がしたい), というモチベーションが湧いてきます.
商業ゲームに組み込み可能で汎用性が高いスクリプトを探すと, スクリプト言語による効率的ゲーム開発 新訂版 (LuaとC/C++連携プログラミング) や, Game Programming Gems シリーズで紹介されていた, などの理由で Lua に辿り着くのではないでしょうか.
テーブルによってもたらされる柔軟さ, 構文の簡潔さ, 軽量な VM, メタプログラミング, 組み込み易さ etc. 当時の自分には魅力的に映りました.
Lua が導入できればより良い開発ができる筈...そう思っていた時期が僕にもありました.
何故 cocos2d-x か
時は経ち2013年末頃, モバイルゲームの活発化に伴い Unity と cocos2d-x が業界内で注目を集めていました.
当時 Unity は今までの(自分が知っている)ゲームプログラミングの世界とは違うけど凄そうなゲームエンジン,
cocos2d-x は C++ なこともあり親しみやすそうという印象を抱いていました.
前述のような不満ないし欲求を満たしてくれるのはデフォルトで Lua Binding がサポートされている cocos2d-x でした.
そのような中, 弊社で当時開発中だった案件が正にコレだったため, 開発メンバーに加わることになりました.
どのような開発スタイルだったか
C/C++ => Lua, Lua => C/C++
ゲームのコア機能は C/C++ で実装されていました.
また, iOS / Android のネイティブコードを呼び出す必要がある場合は, Objective-C, Java/JNI で実装したコードを C/C++ でラップし, tolua++ で Glue コードを生成する流れです.
C/C++ 側から Lua コードを呼び出すことは殆どありませんでしたが, Lua 側で定義したい関数が僅かにありました.
8 割の Lua コードと 2 割の Native コード
試しに手元に残っていたプロジェクトで適当にファイル数を抽出してみました.
Lua コードのファイル数 | Native コードのファイル数 |
---|---|
12062 | 2886 |
Lua コードは拡張子 .lua であるファイル, Netiveコードは .c, .cpp, .m, .mm, .java でファイルで抽出しただけなので, 精度は良くないのですが, 大局観は得られると思います.
Lua コードの中には自動生成されたコード(e.g. マスターデータから変換された lua)や運用に併せて量産されたコードを含みます.
また, Native コードはゲームエンジンやライブラリ側のコードが大部分です.
大体 8 割のコードが Lua コードであり, 体感ともおおよそ一致しているように思えます.
基本は macOS 版アプリで開発
開発の途中までは, iOS シミュレーターが使われていましたが, iOS シミュレーターって重いんですよね...
ゲーム以外のアプリ開発であれば良いのかもしれませんが, ゲームの場合は洒落になりません.
そこで基本的には cocos2d-x で macOS 版のアプリとしてビルドしたものを使っていました.
ホットリロードによるコンパイル不要なコーディング
Lua ではモジュールのような外部の Lua ファイルを require すると packages.loaded テーブルにキャッシュされる仕組みとなっています.
このテーブルからキャッシュを消してしてしまえば, ホットリロードのような機能が実現できます.
(ただし bootstrap に関わるような重要な処理は例外扱いした方が良いです.)
-- require を hook しておき, ホットリロード対象となる module_names を事前に記録しておく
for module_name, is_loaded in pairs(module_names) do
packages.loaded[module_name] = nil
end
これによってゲームを起動したまま, コードを修正 => リロード => 修正が反映されていることを確認,
といったサイクルとなりラピッドな開発に近づけました.
Testing
Busted を利用してテストコードを書いてました.
途中から導入されましたが, プロジェクトのかなり後半だったと記憶しています.
一番効果的だったと思うテストは「require を一通り実行する」テストでした.
うっかり構文エラーを含むコードを push してしまった, というケースでも CI でエラー報告してくれたので, 問題の早期発見に役立ったからです.
動的型付けの言語あるあるなのですが, lightweight に書ける分油断してしまいがちです.
また, cocos2d-x の処理を騙すための mock コードも用意されていました.
mock の実装は殆ど何もしないコードや入力系やスケジューラー系であれば数フレーム待ったりイベントを発火したりイベントリスナーを登録したり...
といったコードだったのですが, テストを実行するために必須でした.
cocos2d-x はオープンソースなので実装を見て, Lua からはこう騙せば良いかなどと実装していました.
mock が未実装だとテストで失敗するため, テストが失敗する度に少しづつ mock 実装を増やしていくというなんとも言えない感じだったのですが,
テストが実行できる環境があると安心感が違います.
Lua と JSON
マスターデータはエクセルで管理されており, プロジェクト初期は JSON に変換していました.
サーバー/クライアント共に出力された JSON を使う想定だったのですが, JSON のパースが重くなりがちなので,
クライアントサイドでは JSON をやめて直接 Lua に変換することになりました.
とはいえ, サーバーから受ける HTTP Response は JSON 形式なのでパースできる必要があります.
初期は DKJSON が利用されたのですが, パフォーマンス上の理由から後に Lua CJSON に置き換えられました.
ベンチマークはこちらが参考になります.
NPC の AI
対戦モノのゲーム性だったので, ゲーム AI があります. ゲーム AI といえば Lua が得意とするところでもあります.
アルゴリズムはステートマシンではなく, Behavior Tree が採用されていました.
特にライブラリ等を使われていた訳ではなく, ゴリっと先人が書いていましたw
AI はサーバーサイドで動作していたのですが, luerl という Erlang で書かれた Lua 実装(!)が利用されており,
サーバーに Lua で書かれた Behavior Tree を送りつけて HTTP 経由で NPC を動かす, という面白い技術が使われました.
ただし, デバッグは地獄でした. なにせサーバー越しで動作することによりログがまともに見れないのですから...
デバッグ用に websocket で通信できるようになっていたので, 先人から wscat を伝授してもらうことに.
wscat を使用することでログはターミナルに出力されるようになったのですが, それでも解析には苦労した記憶があります.
NPC ごとに Behavior Tree 上で現在どの Node にいるのか/どのようなステートを持っているかを可視化できるようなツールがあれば少しは楽だったかも...
非エンジニアを巻き込む
演出の調整や, イベント毎のアドベンチャーパートをエンジニアが実装していては非生産的です.
パターンがある程度決まっていれば, 各種命令をコマンドとしてラップしたり, キャラ構成など表示に必要なデータ構造一式を用意すれば,
「この演出はもう数フレームずらして表示してください」
「このキャラはこのタイミングでフェードインして, その後ジャンプさせてください」
といった不毛なコミュニケーションを減らすができます.
少しだけ Lua の書き方を教える必要はありますが, 非エンジニアにも Lua を書いてもらえると捗ります.
実際, デザイナーさん&プランナーさんにも Lua コードを一部書いてもらっていました.
コピペ運用
イベントページと呼ばれていたシーンがありました.
これは期間限定のイベントごとに用意された特設画面のことを指します.
画面仕様がイベントごとに微妙に変わっていったため, 初版からソースコードのコピーを繰り返し, 4, 5種程度のパターンに派生していきました.
ソースコードのコピーって...と最初は抵抗感があったのですが, イベントページのような一定期間で寿命を終えるコードの場合は下手に共通化するよりは良かったのではないかと思います.
最後のインデックスは e159 となっているため, 少なくとも 159 回開催された形跡があります...
Lua で開発して苦労したこと
書き方バラバラ過ぎた件
Lua でがっつりチーム開発をしたことがある方はいない状況だったので, 必然的に各々自由に書いており, コーディング規約等もありませんでした.
後に静的解析ができる luacheck が導入されましたが, 古いコードを開く度に修正する必要に迫られる
などが起きていました.
静的解析ツールはプロジェクトの初期から採用しないと後に辛い思いをする, ということを身を以て知りました.
callback 地獄
Lua の関数はファーストクラスです. つまり引数に渡したり, 変数に代入が可能です.
強力かつコールバックをあまりに気軽に渡せるので, 気づいた頃にはコールバック地獄に陥っています.
javascript のような他の言語でも同様ですが, 最大でも2,3回のネストまでといった心掛けをしないと処理が追い辛くなってしまいます(なりました).
テーブルに何でも突っ込める!という闇
動的型付け言語であることは柔軟さをもたらす一方で時に悪夢となります.
t1 = { foo = function() return "hello" end }
t2 = { foo = "hello" }
function do_foo(t)
t.foo()
end
t = t1
do_foo(t)
-- 遠いどこか
t = t2
-- さらに遠いどこか
do_foo(t) -- error
静的型付けの言語であればコンパイル時エラーとなるため安心できますが, Lua の場合は上記のようなエラーが発生し易いため注意が必要です.
型を意識しなくて良い, という思想は危険です.
Observer での失敗
beholder が使われていた時期がありました. が, これは失敗でした.
グローバルな observer だったため, 当たり前のように stopObserving 忘れが発生し, 分かりにくい不具合に繋がったのです.
印象的だったのは, 「そこに存在する筈のないキャラの声がどこからともなく聞こえてくる」というホラー現象でした.
また, Node.js の EventEmitter のような仕組みを導入して, emit / on をすることでイベント駆動っぽいスタイルとなっていきました.
Scheduler での失敗
更新関数のような一定時間ごとに処理を行いたい場合, scheduler があると便利です.
ですが, これもグローバルな scheduler だっったため, ハンドラの解除忘れが発生. シーンを跨いでもハンドラが実行されてしまい, これまた不具合に繋がるケースがありました.
シーンを跨いだら勝手に解除されるようにする方が良いため, 次第に cocos2d-x の CCAction / CallFunc 系のオブジェクトを組み合わせて CCObject 系のオブジェクトの破棄とハンドラの解除を同期させるスタイルへと変化していきました.
cleanup 時に nil 代入
破棄済みである筈の cocos2d-x のオブジェクトが Lua 側では参照が残っておりエラーとなることがよくありました.
下記のように cleanup イベントを拾ってあげて, きちんと nil 代入して参照を消すようにしないと安全ではないため気をつける必要があります.
local some_object = cc.Sprite:create("path/to/image.png")
local t = {}
t.object = some_object
some_object:registerScriptHandler(function(ev)
if (ev == "cleanup") t.object = nil end
end)
実際には registerScriptHandler を直接使用してしまうとハンドラが 1 つしか扱えないため, 複数のイベントハンドラが実行されるようにしていました.
CCObject系のオブジェクトをテーブルの(弱参照)キーとして, ハンドラを管理するテーブルをどこかに用意しておき,
各オブジェクトの registerScriptHandler に唯一渡せるハンドラ内で cleanup や exit を登録されたハンドラ分実行するするイメージです.
some_object:registerScriptHandler(function(ev)
if(ev == "exit")
for k, v in pairs(exit_handlers) do v() end
elseif (ev == "cleanup")
for k, v in pairs(cleanup_handlers) do v() end
end
end)
最終的に下記のようにして nil 代入していました.
--- 内部的には some_object をキーとして cleanup_handlers テーブルに関数が insert される
cc.oncleanup(some_object, function() t.object = nil end)
cocos2d-x v2 系の Lua Binding の罠
cocos2d-x v2 系での Lua Binding は tolua++ ベースでした.
ここで見事に罠にハマった訳です.
cocos2d-x のクラスは tolua_fix.h で宣言されている関数でライフサイクルが調整されていたのです.
cocos2d-x 側のクラスに関してはビルド時に tolua++ -L basic.lua -o LuaCocos2d.cpp Cocos2d.pkg
によって cocos2d-x でパッチ(basic.lua)が適用される一方, CCObject のような cocos2d-x のクラスを継承して定義した独自のカスタムクラスは, そのままではパッチが適用されません.
結果, 実行時にカスタムクラス側で定義されているメソッドが Lua 側からは見つからないことがある, といった事態が発生しました.
このことは cocos2d-x/tools/tolua++/README に書かれていたのですが, 見落とされていました.
cocos2d-x v3 系の Lua Binding
cocos2d-x v3 系からは Lua Binding に tolua ベースではなくなり Python で自動生成される方向に倒れたようです.
tolua++ が長らく停滞しており先が無さそうなことが理由として挙げられると思います.
また前述のような cocos2d-x 管轄のオブジェクトのライフサイクルを独自に管理下に置きたかったのではないかと推測しています.
残念ながら開発中に v2 系のから v3 系になることはなかったため, 最近の事情は把握していません.
Unity の場合は...?
最近は Unity での開発業務に携わっていますが, Unity でも一応 Lua を動かすことは可能なようです.
詳しく調査できていませんが, 例えば xLua が人気のようです.
README が中国語なので初見殺しなのですが, 英語版 README もあるようです.
まとめ
かなり走り書きとなってしまいましたが, いかがでしたでしょうか.
Lua での開発は確かにラピッドに開発できる一方で落とし穴もあり決して楽ではありませんでした. また, 簡単でもありませんでした.
それでもビルド時間のストレスから解放され, スピーディーかつ柔軟にゲーム画面を構築できることは最高だったと思います :)