Engine3

Después de tantos años de prototipos, ahora toca el número 3

Motor número 3...

Engine3

En el artículo sobre las herramientas de desarrollo pudiste leer todo lo que voy a usar para hacer el juego Signal From The Stars.

Como Love2d es un framework con solo unas pocas funciones básicas sencillas, se me ocurrió hacer primero un motor base y encima el juego. Como a lo largo de mi vida ya he hecho varios conceptos así, este va a ser ya el tercer motor, de ahí el nombre engine3.

La ventaja es que quizá pueda publicar engine3 como open source, pero también hacer algún día un juego totalmente distinto. Todo esto está en un repositorio git aparte, que el juego usará más adelante.

Así que al principio todavía no voy a trabajar de verdad en el juego, sino que empiezo escribiendo algo de lógica. Claro que lo hago pensando en lo que de verdad voy a usar en el juego. Es más, todo lo que no vaya a necesitar no lo meto en el framework (less is more).

A nivel de archivos, la estructura de engine3 es así:

Estructura básica de engine3

Estructura básica de engine3

Librerías, módulos y mods

Algunas cosas las quieres hacer tú y otras no. El motivo es que reinventar la rueda sale muy caro. Pierdes tiempo extra y tienes que probarlo y mantenerlo tú.

Por eso en engine3 hay una carpeta lib. En ella hay algunas librerías que no he hecho yo, pero que están publicadas, por ejemplo, con licencia MIT.

También hay una carpeta lib-dev, con módulos de terceros que solo se necesitan durante el desarrollo.

Escribir código

Es muy importante escribir el código de forma coherente en los nombres de clases, funciones, variables y archivos. Pero también en el formato del código y las anotaciones. Así el código sigue siendo legible y después es más fácil ver dónde falla algo. Además, otras personas también entienden mejor el código. Para eso uso https://luals.github.io/wiki/.

Personalmente el nombre de ese programa, lua language server, me parece un poco confuso, pero hace exactamente lo que quiero. Tras instalarlo puedes escribir líneas con — en el código como aquí abajo.

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

Parece poco importante, pero hace que Visual Studio Code me avise si, por ejemplo, hago esto:

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

Una de las desventajas de Lua frente, por ejemplo, a GO es que Lua no tiene este tipo de control y por eso es más propenso a errores.

Documentación

Nadie lee la documentación… Pero sé que a largo plazo va a hacer falta, sobre todo si el motor pasara a ser open source. Por suerte el lua language server también tiene un generador de .md que crea toda la documentación. Lo hago con el siguiente script de shell, create_code_doc.sh

#!/bin/bash

echo "Generate code documentation"

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

Control extra

Los tests pueden estorbar muchísimo y costar tiempo, sobre todo cuando empiezas algo desde cero. Todavía estás probando y cambiando cosas todo el rato. Creo que refactor es la palabra más usada en el git log.

Aun así uso unit tests e integration tests.

Estructura básica de los tests de engine3

Estructura básica de los tests de engine3

El script de shell check_code.sh ahora es así, y puedo lanzarlo cuando quiera, por ejemplo antes de cada git push o más adelante en un pipeline.

#!/bin/bash

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

echo "UNIT TEST"
./unittest.sh

echo "INTEGRATION TEST"
./integrationtest.sh

Control de código

Con el programa https://github.com/mpeterv/luacheck puedes comprobar (o no) distintos puntos.

Unit test

Para esto uso https://github.com/bluebird75/luaunit y cada unit test tiene su propio archivo con sus funciones correspondientes.

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

Fue algo más complicado, pero útil, hacer que funcionara, porque tiene que arrancar love2d y hacer algo con la entrada y la salida. Aquí también uso luaunit, pero como un main.lua de arranque de love2d, como abajo

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

Construir, construir, construir

Donde vivo hay una gran escasez de viviendas, y el lema que oyes a menudo es:

Construir, construir, construir

Engine3 también tiene que construir el juego, claro, pero por suerte solo tengo que hacerlo cuando el juego esté terminado o cuando quiera probarlo en otra plataforma.

El script de shell que he hecho para esto por ahora es build.sh, y lo cambiaré más adelante.

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

Para terminar

Como ves, primero tienes que asegurarte de tener una buena base. Hasta llegar ahí tardas un tiempo, pero después llega la recompensa. En su día, por ejemplo, no tenía tests ni comprobación de sintaxis, y al principio todavía va bien, pero llegas a un punto en el que has escrito tanto código que todo se vuelve confuso.

Tienes que partir de que otra persona va a mirar el código y tiene que poder entenderlo. O de que tú mismo lo sigas entendiendo dentro de 3 o 10 años.