Engine3
Nach all den Jahren voller Konzepte ist jetzt Nummer 3 dran

Engine3
Im Artikel über die Entwicklungswerkzeuge konntest du lesen, was ich alles verwenden werde, um das Spiel Signal From The Stars zu machen.
Da Love2d ein Framework mit nur ein paar einfachen Grundfunktionen ist, kam mir die Idee, zuerst eine Basis-Engine zu bauen und darauf das Spiel. Da ich in meinem Leben schon öfter solche Konzepte gebaut habe, wird das inzwischen die dritte Engine, daher der Name engine3.
Der Vorteil ist, dass ich engine3 eventuell als Open Source veröffentlichen, aber auch irgendwann ein ganz anderes Spiel damit machen kann. Das alles liegt in einem eigenen Git-Repository, das das Spiel später verwenden wird.
Anfangs werde ich also noch nicht konkret am Spiel arbeiten, sondern erst etwas Logik schreiben. Natürlich mache ich das mit dem Hintergedanken, was ich später tatsächlich im Spiel verwenden werde. Mehr noch: Alles, was ich nicht brauche, kommt nicht ins Framework (less is more).
Auf Dateiebene sieht die Struktur von engine3 so aus:

Grundstruktur von engine3
Bibliotheken, Module und Mods
Manche Dinge will man selbst bauen, andere nicht. Der Grund ist, dass es sehr teuer ist, das Rad neu zu erfinden. Man verliert zusätzlich Zeit und muss alles selbst testen und pflegen.
Deshalb gibt es in engine3 einen Ordner lib. Darin liegen ein paar Bibliotheken, die ich nicht selbst gebaut habe, die aber zum Beispiel unter der MIT-Lizenz veröffentlicht sind.
- Binser https://github.com/bakpakin/binser
- Das nutze ich, um Daten wie Spielstände zu speichern.
- Json https://github.com/rxi/json.lua
- Wird zum Einlesen von json-Daten verwendet (Backup)
- Msgpack https://framagit.org/fperrad/lua-MessagePack
- Json ist ja schön, aber msgpack verarbeitet die Dateien um ein Vielfaches schneller
- Tick https://github.com/rxi/tick
- Damit kann man eine Funktion zu einer festgelegten Zeit aufrufen, zum Beispiel nach 3 Sekunden
- Vector https://github.com/themousery/vector.lua
- Alle Objekte bewegen sich mit Vektoren
Es gibt auch einen Ordner lib-dev, in dem Module von Dritten liegen, die nur für die Entwicklung nötig sind.
- ProFi https://gist.github.com/perky/2838755
- Damit kann man sehen, ob man nicht zu viel Speicher verbraucht
Code schreiben
Es ist sehr wichtig, Code konsequent zu schreiben, was die Benennung von Klassen, Funktionen, Variablen und Dateinamen angeht. Aber auch bei Formatierung und Annotationen. Dadurch bleibt der Code lesbar, und man sieht später leichter, wo etwas schiefgeht. Außerdem können andere den Code dann besser verstehen. Dafür verwende ich https://luals.github.io/wiki/.
Den Namen dieses Programms, lua language server, finde ich persönlich etwas verwirrend, aber es macht genau, was ich will.
Nach der Installation kann man im Code —Zeilen schreiben wie unten.
---@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
Das wirkt unwichtig, sorgt aber dafür, dass Visual Studio Code Warnungen anzeigt, wenn ich zum Beispiel Folgendes mache:
local something = endsWith({something = "a"}, "b") -- invalid str
Einer der Nachteile von Lua gegenüber zum Beispiel GO ist, dass Lua diese Art von Kontrolle nicht hat und dadurch fehleranfällig ist.
Dokumentation
Niemand liest die Dokumentation… Aber ich weiß, dass sie auf lange Sicht nötig sein wird, erst recht, wenn die Engine Open Source würde. Zum Glück enthält der lua language server auch einen .md-Generator, der die komplette Dokumentation erstellt. Das mache ich mit dem folgenden Shell-Skript create_code_doc.sh
#!/bin/bash
echo "Generate code documentation"
cd ../
lua-language-server --logpath=temp --doc=src/ --doc_out_path=codedoc
Zusätzliche Kontrolle
Tests können enorm im Weg stehen und Zeit kosten, vor allem, wenn man etwas aus dem Nichts baut. Man testet und ändert dann noch ständig Dinge. Refactor ist wohl das am häufigsten verwendete Wort im git log.
Trotzdem nutze ich Unit-Tests und Integrationstests.

Grundstruktur der engine3-Tests
Das Shell-Skript check_code.sh sieht jetzt so aus, und ich kann es starten, wann ich will, zum Beispiel vor jedem git push oder später in einer Pipeline.
#!/bin/bash
cd ../
luacheck --no-cache --config scripts/.luacheckrc src
echo "UNIT TEST"
./unittest.sh
echo "INTEGRATION TEST"
./integrationtest.sh
Code-Kontrolle
Mit dem Programm https://github.com/mpeterv/luacheck kann man verschiedene Punkte prüfen (oder eben nicht).
Unit-Test
Dafür verwende ich https://github.com/bluebird75/luaunit, und jeder Unit-Test hat seine eigene Datei mit den zugehörigen Funktionen.
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())
Integrationstest
Das zum Laufen zu bringen, war etwas schwieriger, aber praktisch, denn es muss love2d starten und etwas mit Input und Output machen. Auch dafür verwende ich luaunit, aber als main.lua-Start von love2d, wie unten
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
Bauen, bauen, bauen
Wo ich wohne, gibt es einen großen Wohnungsmangel, und der Spruch, den man dann oft hört, lautet:
Bauen, bauen, bauen
Engine3 muss natürlich auch das Spiel bauen, aber zum Glück muss ich das nur tun, wenn das Spiel fertig ist oder wenn ich es auf einer anderen Plattform testen will.
Das Shell-Skript, das ich dafür im Moment gebaut habe, heißt build.sh und wird später noch angepasst.
#!/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
Zum Schluss
Wie du siehst, muss man zuerst dafür sorgen, dass die Basis stimmt. Bis dahin ist man eine Weile beschäftigt, aber später kommt die Belohnung. Früher hatte ich zum Beispiel keine Tests oder Syntax-Checks. Am Anfang geht das noch gut, aber irgendwann hat man so viel Code geschrieben, dass alles undurchsichtig wird.
Man muss davon ausgehen, dass jemand anderes den Code anschaut und verstehen können muss. Oder dass man ihn selbst in 3 oder 10 Jahren noch versteht.