Engine3

Depois de todos esses anos de conceitos, chegou a vez do número 3

Engine número 3...

Engine3

No artigo sobre as ferramentas de desenvolvimento você pôde ler tudo o que vou usar para fazer o jogo Signal From The Stars.

Como o Love2d é um framework com só algumas funções básicas simples, tive a ideia de primeiro fazer uma engine base e, em cima dela, o jogo. Como já fiz vários conceitos desse tipo na vida, esta vai ser a terceira engine, daí o nome engine3.

A vantagem é que eu poderia lançar a engine3 como open source, mas também um dia fazer um jogo totalmente diferente. Tudo isso fica em um repositório git separado, que o jogo vai usar depois.

Então, no começo, ainda não vou trabalhar de fato no jogo, mas começar escrevendo um pouco de lógica. Claro que faço isso pensando no que realmente vou usar no jogo. Mais do que isso: tudo o que não vou precisar não entra no framework (less is more).

No nível de arquivos, a estrutura da engine3 é assim:

Estrutura básica da engine3

Estrutura básica da engine3

Bibliotecas, módulos e mods

Algumas coisas você quer fazer por conta própria, outras não. O motivo é que reinventar a roda custa muito caro. Você gasta tempo extra e precisa testar e manter tudo sozinho.

Por isso há uma pasta lib na engine3. Nela ficam algumas bibliotecas que não fiz, mas que são publicadas, por exemplo, sob a licença MIT.

Também há uma pasta lib-dev, com módulos de terceiros necessários só durante o desenvolvimento.

Escrevendo código

É muito importante escrever o código de forma consistente nos nomes de classes, funções, variáveis e arquivos. Mas também na formatação e nas anotações. Assim o código continua legível e depois fica mais fácil ver onde algo dá errado. Além disso, outras pessoas também entendem melhor o código. Para isso uso https://luals.github.io/wiki/.

Pessoalmente acho o nome desse programa, lua language server, um pouco confuso, mas ele faz exatamente o que quero. Depois de instalar, você pode escrever linhas — no código, como abaixo.

---@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 pouco importante, mas faz o Visual Studio Code me avisar se eu fizer, por exemplo, isto:

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

Uma das desvantagens do Lua em relação, por exemplo, ao GO é que o Lua não tem esse tipo de verificação e por isso é mais propenso a erros.

Documentação

Ninguém lê a documentação… Mas sei que ela vai ser necessária a longo prazo, principalmente se a engine virar open source. Felizmente o lua language server também tem um gerador de .md que cria toda a documentação. Faço isso com o seguinte script shell, create_code_doc.sh

#!/bin/bash

echo "Generate code documentation"

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

Controle extra

Testes podem atrapalhar muito e custar tempo, principalmente quando você cria algo do zero. Você ainda está testando e mudando coisas o tempo todo. Acho que refactor é a palavra mais usada no git log.

Mesmo assim uso testes unitários e testes de integração.

Estrutura básica dos testes da engine3

Estrutura básica dos testes da engine3

O script shell check_code.sh agora é assim, e posso rodá-lo quando quiser, por exemplo antes de cada git push ou depois em um pipeline.

#!/bin/bash

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

echo "UNIT TEST"
./unittest.sh

echo "INTEGRATION TEST"
./integrationtest.sh

Verificação de código

Com o programa https://github.com/mpeterv/luacheck você pode verificar (ou justamente não verificar) vários pontos.

Teste unitário

Para isso uso https://github.com/bluebird75/luaunit, e cada teste unitário tem seu próprio arquivo com as funções correspondentes.

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

Teste de integração

Foi um pouco mais difícil, mas útil, fazer isso funcionar, porque precisa iniciar o love2d e fazer algo com a entrada e a saída. Aqui também uso luaunit, mas como um main.lua de inicialização do love2d, como abaixo

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

Onde moro há uma grande falta de moradias, e o slogan que você ouve com frequência é:

Construir, construir, construir

A engine3 também precisa fazer o build do jogo, claro, mas felizmente só preciso fazer isso quando o jogo estiver pronto ou quando quiser testá-lo em outra plataforma.

O script shell que fiz para isso no momento é o build.sh, e ele vai ser ajustado depois.

#!/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 você vê, primeiro é preciso garantir uma boa base. Até chegar lá leva um tempo, mas depois vem a recompensa. Antigamente, por exemplo, eu não tinha testes nem verificação de sintaxe. No começo ainda dá certo, mas você chega a um ponto em que escreveu tanto código que tudo fica confuso.

Você precisa partir do princípio de que outra pessoa vai olhar o código e precisa conseguir entendê-lo. Ou de que você mesmo ainda vai entendê-lo daqui a 3 ou 10 anos.