Engine3

Nach all den Jahren voller Konzepte ist jetzt Nummer 3 dran

Engine Nummer 3...

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

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.

Es gibt auch einen Ordner lib-dev, in dem Module von Dritten liegen, die nur für die Entwicklung nötig sind.

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

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.