Engine3

Sau bao năm làm các bản thử, giờ đến lượt số 3

Engine số 3...

Engine3

Trong bài viết về các công cụ phát triển bạn đã đọc được tôi sẽ dùng những gì để làm trò chơi Signal From The Stars.

Vì Love2d là một framework chỉ có vài hàm cơ bản đơn giản, tôi nảy ra ý tưởng làm một engine nền tảng trước rồi xây trò chơi lên trên. Vì trong đời tôi đã làm những thứ như thế này vài lần, đây sẽ là engine thứ ba, nên có tên engine3.

Ưu điểm là tôi có thể phát hành engine3 dưới dạng mã nguồn mở, và cũng có thể một ngày nào đó làm một trò chơi hoàn toàn khác. Tất cả nằm trong một kho git riêng mà trò chơi sẽ dùng sau này.

Vì vậy thời gian đầu tôi chưa thật sự làm trò chơi mà bắt đầu viết một ít logic. Dĩ nhiên tôi làm vậy với suy nghĩ về những gì sẽ thật sự dùng trong trò chơi. Hơn nữa, những gì không cần thì tôi sẽ không đưa vào framework (less is more).

Ở cấp độ tệp, cấu trúc của engine3 trông như sau:

Cấu trúc cơ bản của engine3

Cấu trúc cơ bản của engine3

Thư viện, mô-đun và mod

Có những thứ bạn muốn tự làm, có những thứ thì không. Lý do là phát minh lại bánh xe rất tốn kém. Bạn mất thêm thời gian và phải tự kiểm thử, tự bảo trì.

Vì vậy trong engine3 có thư mục lib. Trong đó có một số thư viện không phải tôi viết, mà được phát hành, ví dụ theo giấy phép MIT.

Ngoài ra còn có thư mục lib-dev, chứa các mô-đun bên thứ ba chỉ cần trong lúc phát triển.

Viết mã

Rất quan trọng là viết mã nhất quán trong cách đặt tên lớp, hàm, biến và tệp. Cả trong việc định dạng mã và chú thích. Nhờ vậy mã luôn dễ đọc và sau này dễ thấy chỗ nào có lỗi. Ngoài ra người khác cũng hiểu mã tốt hơn. Để làm điều đó tôi dùng https://luals.github.io/wiki/.

Cá nhân tôi thấy tên chương trình, lua language server, hơi khó hiểu, nhưng nó làm đúng những gì tôi muốn. Sau khi cài đặt, bạn có thể viết các dòng — trong mã như bên dưới.

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

Trông có vẻ không quan trọng, nhưng nhờ vậy Visual Studio Code sẽ cảnh báo nếu tôi làm, ví dụ, thế này:

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

Một nhược điểm của Lua so với, ví dụ, GO là Lua không có kiểu kiểm tra như vậy nên dễ sinh lỗi.

Tài liệu

Chẳng ai đọc tài liệu cả… Nhưng tôi biết về lâu dài sẽ cần đến nó, nhất là nếu engine trở thành mã nguồn mở. May là lua language server còn có bộ tạo .md để tạo toàn bộ tài liệu. Tôi làm việc đó bằng script shell sau, create_code_doc.sh

#!/bin/bash

echo "Generate code documentation"

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

Kiểm tra thêm

Kiểm thử có thể rất vướng víu và tốn thời gian, nhất là khi bạn làm một thứ từ con số không. Bạn vẫn liên tục thử và thay đổi mọi thứ. Tôi nghĩ refactor là từ xuất hiện nhiều nhất trong git log.

Dù vậy tôi vẫn dùng unit test và integration test.

Cấu trúc cơ bản của phần kiểm thử engine3

Cấu trúc cơ bản của phần kiểm thử engine3

Script shell check_code.sh hiện trông như sau, và tôi có thể chạy nó bất cứ lúc nào, ví dụ trước mỗi lần git push hoặc sau này trong một pipeline.

#!/bin/bash

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

echo "UNIT TEST"
./unittest.sh

echo "INTEGRATION TEST"
./integrationtest.sh

Kiểm tra mã

Với chương trình https://github.com/mpeterv/luacheck bạn có thể kiểm tra (hoặc cố ý không kiểm tra) nhiều điểm khác nhau.

Unit test

Tôi dùng https://github.com/bluebird75/luaunit, và mỗi unit test có tệp riêng chứa các hàm tương ứng.

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

Làm cho phần này chạy được khó hơn một chút nhưng rất hữu ích, vì nó phải khởi động love2d và làm gì đó với đầu vào và đầu ra. Ở đây tôi cũng dùng luaunit, nhưng dưới dạng main.lua khởi động love2d như bên dưới

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

Xây, xây, xây

Nơi tôi sống đang thiếu nhà ở trầm trọng, và khẩu hiệu bạn hay nghe là:

Xây, xây, xây

Engine3 dĩ nhiên cũng phải build trò chơi, nhưng may là tôi chỉ cần làm việc đó khi trò chơi đã xong, hoặc khi muốn thử trò chơi trên một nền tảng khác.

Script shell tôi làm cho việc này hiện là build.sh, và sau này sẽ được chỉnh sửa.

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

Lời kết

Như bạn thấy, trước tiên phải đảm bảo nền tảng vững chắc. Trước khi làm được điều đó bạn sẽ mất một thời gian, nhưng sau này sẽ nhận được thành quả. Có thời tôi không có kiểm thử hay kiểm tra cú pháp, lúc đầu vẫn ổn, nhưng rồi sẽ đến lúc bạn viết nhiều mã đến mức mọi thứ trở nên rối rắm.

Bạn phải mặc định rằng người khác sẽ đọc mã và phải hiểu được nó. Hoặc chính bạn vẫn hiểu nó sau 3 hay 10 năm nữa.