Engine3

Efter alle de år med koncepter er det nu tid til nummer 3

Engine nummer 3...

Engine3

I artiklen om udviklingsværktøjerne kunne du læse, hvad jeg skal bruge til at lave spillet Signal From The Stars.

Da Love2d er et framework med kun nogle få simple basisfunktioner, fik jeg idéen først at lave en basis-engine og så spillet ovenpå. Da jeg i mit liv allerede har lavet den slags koncepter flere gange, bliver det efterhånden den tredje engine, deraf navnet engine3.

Fordelen er, at jeg eventuelt kan udgive engine3 som open source, men også at jeg en dag kan lave et helt andet spil. Det hele ligger i et separat git-repo, som spillet senere skal bruge.

I starten arbejder jeg altså ikke konkret på spillet, men begynder med at skrive noget logik. Selvfølgelig gør jeg det med tanke på, hvad jeg faktisk skal bruge i spillet. Ja, alt det, jeg ikke får brug for, tager jeg ikke med i frameworket (less is more).

På filniveau ser strukturen i engine3 sådan ud:

Grundstruktur i engine3

Grundstruktur i engine3

Biblioteker, moduler og mods

Nogle ting vil man lave selv, andre ikke. Grunden er, at det er meget dyrt at opfinde den dybe tallerken igen. Man bruger ekstra tid og skal selv teste og vedligeholde det.

Derfor er der en mappe lib i engine3. Her ligger nogle biblioteker, som jeg ikke selv har lavet, men som fx er udgivet under MIT-licensen.

Der er også en mappe lib-dev med moduler fra tredjeparter, som kun skal bruges under udviklingen.

At skrive kode

Det er meget vigtigt at skrive koden konsekvent i navngivningen af klasser, funktioner, variabler og filnavne. Men også i brugen af kodeformatering og annotationer. Så forbliver koden læsbar, og det er senere nemmere at se, hvor noget går galt. Desuden kan andre så bedre forstå koden. Til det bruger jeg https://luals.github.io/wiki/.

Programmets navn, lua language server, synes jeg personligt er lidt forvirrende, men det gør præcis det, jeg vil. Efter installationen kan man skrive —linjer i koden som herunder.

---@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

Det virker ligegyldigt, men gør, at Visual Studio Code giver advarsler, hvis jeg fx gør sådan her:

local something = endsWith({something = "a"}, "b") -- invalid str

En af ulemperne ved Lua i forhold til fx GO er, at Lua ikke har den slags kontrol og derfor er fejlbehæftet.

Dokumentation

Ingen læser dokumentationen… Men jeg ved, at den bliver nødvendig på længere sigt, og især hvis engine bliver open source. Heldigvis har lua language server også en .md-generator, der laver hele dokumentationen. Det gør jeg med følgende shell-script, create_code_doc.sh

#!/bin/bash

echo "Generate code documentation"

cd ../
lua-language-server --logpath=temp --doc=src/ --doc_out_path=codedoc

Ekstra kontrol

Tests kan stå enormt meget i vejen og koste tid, især når man bygger noget fra bunden. Man tester og ændrer stadig konstant ting. Refactor er nok det mest brugte ord i git log.

Alligevel bruger jeg unit tests og integrationstests.

Grundstruktur for engine3-tests

Grundstruktur for engine3-tests

Shell-scriptet check_code.sh ser nu sådan ud, og jeg kan starte det, når jeg vil, fx før hvert git push eller senere i en pipeline.

#!/bin/bash

cd ../
luacheck --no-cache --config scripts/.luacheckrc src

echo "UNIT TEST"
./unittest.sh

echo "INTEGRATION TEST"
./integrationtest.sh

Kodekontrol

Med programmet https://github.com/mpeterv/luacheck kan man kontrollere forskellige punkter (eller netop lade være).

Unit test

Til det bruger jeg https://github.com/bluebird75/luaunit, og hver unit test har sin egen fil med de tilhørende funktioner.

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

Det var lidt sværere, men praktisk at få det til at virke, for det skal starte love2d og gøre noget med input og output. Her bruger jeg også luaunit, men som en main.lua love2d-start som herunder

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

Byg, byg, byg

Hvor jeg bor, er der stor mangel på boliger, og det råb, man ofte hører, er:

Byg, byg, byg

Engine3 skal selvfølgelig også bygge spillet, men heldigvis skal jeg kun gøre det, når spillet er færdigt, eller når jeg vil teste det på en anden platform.

Shell-scriptet, jeg har lavet til det lige nu, hedder build.sh og bliver tilpasset senere.

#!/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

Til sidst

Som du kan se, skal man først sørge for, at fundamentet er i orden. Det tager lidt tid, men senere får man belønningen. Engang havde jeg fx ingen tests eller syntakstjek. I starten går det fint, men man når et punkt, hvor man har skrevet så meget kode, at det hele bliver uoverskueligt.

Man skal gå ud fra, at en anden skal kigge på koden og kunne forstå den. Eller at man selv stadig forstår den om 3 eller 10 år.