Engine3

Dopo tutti quegli anni di prototipi, ora è il momento del numero 3

Motore numero 3...

Engine3

Nell’articolo sugli strumenti di sviluppo hai potuto leggere tutto ciò che userò per creare il gioco Signal From The Stars.

Visto che Love2d è un framework con solo alcune funzioni di base semplici, mi è venuta l’idea di creare prima un motore di base e poi il gioco sopra di esso. Dato che nella mia vita ho già creato più volte cose del genere, questo sarà ormai il terzo motore, da qui il nome engine3.

Il vantaggio è che potrei pubblicare engine3 come open source, ma anche un giorno creare un gioco completamente diverso. Tutto questo è in un repository git separato, che il gioco userà più avanti.

All’inizio quindi non lavorerò ancora davvero al gioco, ma comincerò a scrivere un po’ di logica. Ovviamente lo faccio pensando a ciò che userò davvero nel gioco. Anzi, tutto ciò che non mi servirà non lo metterò nel framework (less is more).

A livello di file, la struttura di engine3 è così:

Struttura di base di engine3

Struttura di base di engine3

Librerie, moduli e mod

Alcune cose vuoi farle da solo, altre no. Il motivo è che reinventare la ruota costa moltissimo. Perdi tempo in più e devi testarla e mantenerla da solo.

Per questo in engine3 c’è una cartella lib. Contiene alcune librerie che non ho creato io, ma che sono pubblicate per esempio con licenza MIT.

C’è anche una cartella lib-dev, con moduli di terzi che servono solo durante lo sviluppo.

Scrivere codice

È molto importante scrivere il codice in modo coerente nei nomi di classi, funzioni, variabili e file. Ma anche nell’uso della formattazione e delle annotazioni. Così il codice resta leggibile e dopo è più facile capire dove qualcosa va storto. Inoltre anche altri possono capire meglio il codice. Per questo uso https://luals.github.io/wiki/.

Personalmente trovo il nome di quel programma un po’ confuso, lua language server, ma fa esattamente ciò che voglio. Dopo l’installazione puoi scrivere nel codice righe con — come qui sotto.

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

Sembra poco importante, ma fa sì che Visual Studio Code mi dia degli avvisi se per esempio faccio così:

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

Uno degli svantaggi di Lua rispetto per esempio a GO è che Lua non ha questo tipo di controllo ed è quindi soggetto a errori.

Documentazione

Nessuno legge la documentazione… Però so che a lungo termine servirà, soprattutto se il motore diventasse open source. Per fortuna nel lua language server c’è anche un generatore .md che crea tutta la documentazione. Lo faccio con il seguente script shell, create_code_doc.sh

#!/bin/bash

echo "Generate code documentation"

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

Controlli extra

I test possono essere un enorme intralcio e costare tempo, soprattutto quando crei qualcosa da zero. Stai ancora continuamente provando e cambiando cose. Credo che la parola refactor sia la più usata nel git log.

Nonostante questo uso comunque unit test e integration test.

Struttura di base dei test di engine3

Struttura di base dei test di engine3

Lo script shell check_code.sh ora è così, e posso avviarlo quando voglio, per esempio prima di ogni git push o più avanti in una pipeline.

#!/bin/bash

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

echo "UNIT TEST"
./unittest.sh

echo "INTEGRATION TEST"
./integrationtest.sh

Controllo del codice

Con il programma https://github.com/mpeterv/luacheck puoi controllare (o proprio non controllare) diversi aspetti.

Unit test

Per questo uso https://github.com/bluebird75/luaunit e ogni unit test ha il suo file con le funzioni corrispondenti.

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())

Integration test

È stato un po’ più difficile, ma utile, farlo funzionare, perché deve avviare love2d e fare qualcosa con input e output. Anche qui uso luaunit, ma come main.lua di avvio di love2d, come qui sotto

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

Costruire, costruire, costruire

Dove vivo c’è una grande carenza di case, e lo slogan che senti spesso è:

Costruire, costruire, costruire

Ovviamente engine3 deve anche fare la build del gioco, ma per fortuna devo farlo solo quando il gioco è finito o quando voglio provarlo su un’altra piattaforma.

Lo script shell che ho creato per questo al momento è build.sh, e verrà modificato più avanti.

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

Per finire

Come vedi, prima devi assicurarti che le basi siano solide. Prima di arrivarci ci vuole un po’, ma dopo arriva la ricompensa. Un tempo per esempio non avevo test né controlli di sintassi, e all’inizio va ancora bene, ma arrivi a un punto in cui hai scritto così tanto codice che tutto diventa poco chiaro.

Devi partire dal presupposto che qualcun altro guarderà il codice e dovrà capirlo. O che tu stesso lo capisca ancora tra 3 o 10 anni.