Engine3
長年の試作を経て、今度は3番目の出番

Engine3
開発ツールの記事で、Signal From The Stars を作るために何を使うのかを読んでもらえたと思います。
Love2d はシンプルな基本機能しかないフレームワークなので、まず基本となるエンジンを作り、その上にゲームを載せようと考えました。これまでの人生でこうした試みは何度もしてきたので、これで3つ目のエンジンになります。それで名前が engine3 なのです。
こうしておく利点は、engine3 をオープンソースとして公開できるかもしれないこと、そしていつかまったく別のゲームも作れることです。これはすべて別の git リポジトリに入っていて、あとでゲームがそれを使います。
なので最初のうちは、まだ具体的にゲームを作るのではなく、ロジックを書くところから始めます。もちろん、実際にゲームで使うものを念頭に置いています。それどころか、必要ないものはフレームワークに入れません(less is more)。
ファイルレベルでは、engine3 の構造はこうなっています。

engine3 の基本構造
ライブラリ、モジュール、Mod
自分で作りたいものもあれば、そうでないものもあります。車輪の再発明は、とても高くつくからです。余計な時間がかかり、テストも保守も自分でしなければなりません。
そのため engine3 には lib フォルダがあります。ここには、私が作ったのではなく、たとえば MIT ライセンスで公開されているライブラリがいくつか入っています。
- Binser https://github.com/bakpakin/binser
- セーブデータなどのデータ保存に使っています。
- Json https://github.com/rxi/json.lua
- json データの読み込みに使用(予備)
- Msgpack https://framagit.org/fperrad/lua-MessagePack
- json もいいですが、msgpack はファイルを何倍も速く処理します
- Tick https://github.com/rxi/tick
- 決めた時間、たとえば3秒後に関数を呼び出せます
- Vector https://github.com/themousery/vector.lua
- すべてのオブジェクトはベクトルで動きます
さらに lib-dev フォルダもあり、開発中だけ必要なサードパーティのモジュールが入っています。
- ProFi https://gist.github.com/perky/2838755
- メモリを使いすぎていないか確認できます
コードを書く
クラス、関数、変数、ファイル名の命名を一貫させてコードを書くことはとても大切です。コードのフォーマットやアノテーションの使い方も同じです。そうすればコードは読みやすいままで、あとで何がおかしいのかも見つけやすくなります。ほかの人もコードを理解しやすくなります。そのために https://luals.github.io/wiki/ を使っています。
lua language server というこのプログラムの名前は個人的には少しわかりにくいと思いますが、私が求めることをまさにやってくれます。
インストールすると、下のようにコードに — の行を書けるようになります。
---@param str string The string to check
---@param ending string The chararacter to check
local function endsWith(str, ending)
return ending == "" or str:sub(-(#ending)) == ending
end
大したことではないように見えますが、たとえば次のように書くと Visual Studio Code が警告を出してくれます。
local something = endsWith({something = "a"}, "b") -- invalid str
たとえば GO と比べたときの Lua の欠点のひとつは、こうしたチェックがなく、そのためミスが起きやすいことです。
ドキュメント
誰もドキュメントは読みません… でも長い目で見れば必要になること、特にエンジンをオープンソースにするなら必要になることはわかっています。幸い lua language server には、ドキュメント全体を作ってくれる .md ジェネレーターもあります。次のシェルスクリプト create_code_doc.sh でそれを実行しています。
#!/bin/bash
echo "Generate code documentation"
cd ../
lua-language-server --logpath=temp --doc=src/ --doc_out_path=codedoc
追加のチェック
テストはとても邪魔になることもあり、時間もかかります。特にゼロから何かを作っているときはそうです。 まだずっと試したり変えたりしている段階だからです。git log でいちばんよく使われている単語は、たぶん refactor でしょう。
それでも、ユニットテストと統合テストは使っています。

engine3 のテストの基本構造
シェルスクリプト check_code.sh は今こうなっていて、好きなときに、たとえば git push の前や、のちにはパイプラインの中で実行できます。
#!/bin/bash
cd ../
luacheck --no-cache --config scripts/.luacheckrc src
echo "UNIT TEST"
./unittest.sh
echo "INTEGRATION TEST"
./integrationtest.sh
コードチェック
https://github.com/mpeterv/luacheck というプログラムで、いろいろな項目をチェックできます(あえてチェックしないことも)。
ユニットテスト
これには https://github.com/bluebird75/luaunit を使っていて、ユニットテストごとにファイルを分け、そこに関連する関数を書いています。
test-order.lua
local lu = require("luaunit")
TestClass = {}
local numKeyTable = {
[1] = {name = "a", order = 1},
[2] = {name = "b", order = 2},
[3] = {name = "d", order = 3}
}
function TestClass:test1Sort()
table.sort(
numKeyTable,
function(a, b)
return a.order < b.order
end
)
lu.assertEquals(
numKeyTable,
{
[1] = {name = "a", order = 1},
[2] = {name = "b", order = 2},
[3] = {name = "d", order = 3}
}
)
end
os.exit(lu.LuaUnit.run())
統合テスト
love2d を起動して入出力で何かをする必要があるので、動かすのは少し大変でしたが、役に立ちます。ここでも luaunit を使っていますが、下のように love2d を起動する main.lua として動かしています。
local lu = require("luaunit")
local runner = lu.LuaUnit.new()
function love.load()
runner:runSuiteByInstances(
{
{"rect-helper", require "rect-helper"},
{"image-assets", require "image-assets"},
{"audio-assets", require "audio-assets"},
{"entity-manager", require "entity-manager"},
{"callback", require "callback"},
{"frameset", require "frameset"}
}
)
love.event.push("quit", 0)
end
建てろ、建てろ、建てろ
私の住んでいるところではひどい住宅不足で、よく耳にするスローガンがこれです。
建てろ、建てろ、建てろ
engine3 はもちろんゲームのビルドもしなければなりませんが、幸いそれが必要なのは、ゲームが完成したときか、別のプラットフォームで試したいときだけです。
そのために今作っているシェルスクリプトは build.sh で、あとで手を加える予定です。
#!/bin/bash
source config
osx_CFBundleName=${APP_NAME}
osx_CFBundleIdentifier="com.SuperCompany."${APP_NAME}
ios_CFBundleVersion="11.3"
src_dir="../src/"
osx_build_with=${ENGINE3_SRC_DIR}"/scripts/osx-love-build-source/love.app"
win64_build_with=${ENGINE3_SRC_DIR}"/scripts/win-love-build-source/love-win64/"
win32_build_with=${ENGINE3_SRC_DIR}"/scripts/win-love-build-source/love-win32/"
build_dir="../build/"
build_love_file=${build_dir}${APP_NAME}".love"
build_temp_dir=${build_dir}"temp/"
build_osx_dir=${build_dir}"osx/"
build_win64_dir=${build_dir}"win64/"
build_win32_dir=${build_dir}"win32/"
build_linux_dir=${build_dir}"linux/"
current_script_dir=$(pwd)
echo "[1/7] Remove old build ${build_dir}"
rm -Rf ${build_dir}
mkdir -p ${build_dir}
mkdir -p ${build_temp_dir}
echo "[2/7] Copy only the files that we actual need"
cd ${src_dir}
# We don't want/need all files in the actuale build, for the end-user.
rsync --relative -rpKL \
--exclude=lib/engine3/archive \
--exclude=lib/engine3/codedoc \
--exclude=lib/engine3/docs \
--exclude=lib/engine3/scripts \
--exclude=lib/engine3/temp \
--exclude=lib/engine3/README.MD \
--exclude=lib/engine3/src/framework/helper/dump.lua \
--exclude=lib/engine3/src/lib-dev \
--exclude=lib/engine3/src/test \
--exclude=assets/*/*/*.json \
--exclude=assets/*/*.json \
--exclude=unittest \
--exclude=*.DS_Store* \
--exclude=*.gitignore* \
* \
${build_temp_dir}
# some modules are exclude, check for example if the dump( function is in use
check1=$(grep -rnw ${build_temp_dir} -e 'dump(')
if [ ! -z "$check1" ] ; then
echo "dump() function found, remove it in the source"
echo ${check1}
exit 3
fi
echo "[3/7] Switch to the temp directory"
cd ${build_temp_dir}
echo "[4/7] Create love file: ${build_dir}${APP_NAME}.love"
zip -9 -r --quiet ${APP_NAME}".love" .
cd ${current_script_dir}
mv ${build_temp_dir}${APP_NAME}".love" ${build_love_file}
echo "[5/7] Create osx executable: ${build_osx_dir}"
mkdir -p ${build_osx_dir}
cp -R ${osx_build_with} ${build_osx_dir}
mv ${build_osx_dir}"love.app" ${build_osx_dir}${APP_NAME}".app"
cp ${build_love_file} ${build_osx_dir}${APP_NAME}".app/Contents/Resources"
plutil -replace CFBundleName -string ${osx_CFBundleName} ${build_osx_dir}${APP_NAME}".app/Contents/Info.plist"
plutil -replace CFBundleIdentifier -string ${osx_CFBundleIdentifier} ${build_osx_dir}${APP_NAME}".app/Contents/Info.plist"
plutil -remove UTExportedTypeDeclarations ${build_osx_dir}${APP_NAME}".app/Contents/Info.plist"
echo "[6/7] Create win 64 executable: ${build_win64_dir}"
mkdir -p ${build_win64_dir}
cp -R ${win64_build_with} ${build_win64_dir}
cat ${build_win64_dir}"love.exe" ${build_love_file} > ${build_win64_dir}${APP_NAME}".exe"
echo "[7/7] Create win 32 executable: ${build_win32_dir}"
mkdir -p ${build_win32_dir}
cp -R ${win32_build_with} ${build_win32_dir}
cat ${build_win32_dir}"love.exe" ${build_love_file} > ${build_win32_dir}${APP_NAME}".exe"
# don't need this file anymore
#rm ${build_love_file}
# comment this line below, to see/debug what files are used for the actual build.
rm -rf ${build_temp_dir}
exit 0
最後に
見てのとおり、まずは土台をしっかり固める必要があります。そこまでにはしばらくかかりますが、あとでちゃんと報われます。昔はたとえばテストも構文チェックもありませんでした。最初はそれでもうまくいきますが、コードを書きすぎて全体が見通せなくなる時点が必ず来ます。
ほかの誰かがコードを見て理解できるようにしておく、あるいは3年後や10年後の自分がまだ理解できるようにしておく、という前提で考えるべきです。