开发工具

不要试图(完全)重新发明轮子!

Love2d 框架的官方标志

决定

在关于最初的文章里,你也许读到过,我决定要做一款游戏。

做这样一件事有非常多的方法,每种情况的做法也各不相同。我唯一百分之百确定的是,它必须是一款2D的老派点击式冒险游戏。

这样一来很多开发工具就直接被排除了,但还剩下几个问题:

  • 用哪种编程语言?
  • 游戏要在哪个平台上运行?
  • 用哪个引擎或框架?
  • 有没有支持论坛?

编程语言

我已经会用 PHP、C#、Python、Javascript、Node、Blitz 3D、Monkey X、Monkey 2。我曾经用最后两种语言做过一个原型。但那门语言的开发者停止了开发,所以我对最终结果失去了信心,当时也就停了下来。

所以它必须是一门简单又可靠的语言。我喜欢简单,也没有时间去好好掌握一门新语言。

引擎

像 Unreal Engine 这样的游戏引擎,是为了让工作变得简单得多。所以我的搜索从研究完整的列表 https://en.wikipedia.org/wiki/List_of_game_engines 开始。

很多引擎马上就被排除了,因为我不想在引擎上花钱,至少一次性付费还可以接受。但绝对不要某些引擎想出来的那些奇怪又含糊的许可证安排。我还希望它仍在积极开发,而且不是 C 或 C++。当然还得是2D。

没错,Unreal Engine 也能做2D,但这违背了我“less is more”的原则。这类引擎有太多我用不到的功能,而这些功能只会出问题。所以这类引擎也被排除了。

  • Adventure Game Studio
    • 只支持 Windows,而且没法实现我想的所有东西。
  • Construct
    • 许可证问题,太贵,还要按月付费
  • Flixel
    • 我不想用 ActionScript
  • GameMaker Studio
    • 太老了,有许可证问题,我也觉得它不够酷,哈哈
  • LÖVE
    • 不错!不过我还在研究
  • Monkey X
    • 我以前就是用它起步的,但它已经没有支持了
  • Pygame
    • 很有意思,但最后没有选它
  • HaxeFlixel
    • 不,就是不
  • Cocos2d
    • 很有意思,但在我看来不够精简
  • Godot
    • 很有意思,但在我看来不够精简
  • Unity
    • 许可证问题

结论是,看来我真的想自己做点什么。它必须采用 MIT 这样开放的许可证,或者类似的,而且要用途广泛。 研究发现,上面有些引擎使用了 SDL2,我得多了解一下它。

SDL2

当你去了解软件和硬件如何配合,通过显卡让屏幕上的图像动起来(以及声音)时,会发现 Windows 用的是 DirectX,Ubuntu 用 OpenGL,Mac 用 Metal/Vulkan。而 Windows 和 Mac 其实也可以直接用 OpenGL…

所以每个平台都有自己的库,而这些库又是用各自的语言写的。非常不方便,因为你不想为每个平台都重写一遍代码。你想要的是代码只写1次,到处都能运行。

这个问题的解决方案就是 https://www.libsdl.org 这个库。在合适的许可证下它是免费的,而且同一套代码可以用于所有平台。SDL2 是用 C 写的,还有不少绑定,让你可以在其他编程语言里使用 SDL2。

因为我不想用 C,我把绑定列表从头到尾看了一遍,做了些尝试:

  • C
    • 觉得不太方便
  • C#
    • 排除,因为我在家用的是 Mac
  • GO
    • 其实挺喜欢,也很简单,只是每次都得编译
  • Haskell
    • 恐怖,完全不是我要找的
  • Rust
    • 呃,太难了,突然得用函数式编程
  • Lua
    • 有意思又简单
  • Python
    • 不知为何就是不太合适,我也不想到处加 TAB。
  • Nim
    • 好像没什么人用

到这一步就只剩下 GO 和 Lua 了,而我之前找引擎时已经见过 LÖVE。

LÖVE

框架 https://love2d.org 满足了我所有的要求。

我不把它叫作引擎,而是框架,因为它只包含以简单方式调用 SDL2 的函数。 这正是我要找的,它满足了我所有的条件:

  1. 有一个论坛,大家仍然乐于助人又友善。
  2. 框架很精简(less is more)。
  3. Lua 语言简单,而且已经用了很多年。
  4. 支持游戏要运行的各个目标平台。
function love.draw()
  love.graphics.print("Hello: Signal From The Stars", 400, 300)
end

Lua

Lua 的官方标志

Lua 的官方标志

我只需要再学会 Lua,那该怎么做?当然是去父母家把整本 Lua 手册打印出来… 他们对此可不太高兴。

结果发现这门语言简单到我根本不需要手册。 我觉得可以把 Lua 看作一个和 C 打交道的 wrapper。另一个好处是,每次改完代码不用等编译器。

当然也有一些缺点,比如它没有 C 那么快,但这不是问题。

目标平台

游戏 Signal From The Stars 至少要能在 Mac、Windows 和 iOS(iPad)上运行。之后还会加入 Nintendo Switch、Ubuntu 和 Android。它大概也能在 Xbox 和 PlayStation 上运行,但不是开箱即用。

图像

游戏当然也会有图像,一开始我用的是 Aseprite,因为它有像素完美模式。这是我采用的一种特定风格。不过后来我还是改用了 Adobe Photoshop。以后我会就此写一篇文章。

之后所有图片都会通过 TexturePacker https://www.codeandweb.com/texturepacker 进行最优处理,然后再用脚本处理它的输出。

脚本

需要一些单独的脚本来把某些事情整理好。大多数是 Lua 写的,但以后会越来越多。

Shell

#!/bin/bash

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

cd src/lib/engine3/scripts/

echo "UNIT TEST"
./unittest.sh

echo "INTEGRATION TEST"
./integrationtest.sh

Node

选择 Node 只是因为有一个能读取 Adobe Photoshop 文件的模块 (https://github.com/meltingice/psd.js)。我现在还在用它,它会把 Photoshop 文件转换成游戏用的素材和 Atlas。

如果当时我在 Python 里找到同样的东西,我就不会用 Node 了。不知为何,带着数十亿个库的 Node 并不太符合我要找的东西,它让我有点想起 Windows 的 DLL 地狱。

Python

我后悔没有多写点 Python。下面是我目前唯一在用、但很重要的 Python 脚本。用它我可以找到透明 .png 文件的轮廓,并简化这些坐标。

# python3 outline.py
# pip3 install pillow
# pip3 install opencv-python
import sys
import cv2
import numpy as np
import json
import os
import argparse

def find_outer_contours(image_path, simplify=0):
    img = cv2.imread(image_path, cv2.IMREAD_UNCHANGED)
    mask = img[:, :, 3] > 0
    contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    min_contour_size = 5
    filtered_contours = [contour for contour in contours if cv2.contourArea(contour) > min_contour_size]

    if simplify > 0:
        simplified_contours = []
        for contour in filtered_contours:
            epsilon = simplify * cv2.arcLength(contour, True)
            simplified_contour = cv2.approxPolyDP(contour, epsilon, True)
            simplified_contours.append(simplified_contour)
        return simplified_contours
    else:
        return filtered_contours

def draw_and_save_contours(contours, output_path, original_size):
    img = np.zeros((original_size[1], original_size[0]), dtype=np.uint8)
    cv2.drawContours(img, contours, -1, (255), thickness=1)
    cv2.imwrite(output_path, img)

def contour_coordinates_to_json(contours):
    contour_coordinates_list = []
    for contour in contours:
        contour_coordinates = []
        for point in contour:
            x, y = point[0]
            contour_coordinates.append([int(x), int(y)])
        
        if len(contour_coordinates) % 2 != 0:
            contour_coordinates.append([contour_coordinates[-1][0], contour_coordinates[-1][1]])

        contour_coordinates_list.append(contour_coordinates)
    return json.dumps(contour_coordinates_list)

def apply_convex_hull(contours):
    combined_contour = np.concatenate(contours)
    hull = cv2.convexHull(combined_contour)
    return [hull]

def create_rectangles(contours):
    rectangles = []
    for contour in contours:
        x, y, w, h = cv2.boundingRect(contour)
        rectangles.append((x, y, x + w, y + h))

    return rectangles

def rectangles_to_json(rectangles):
    json_output = json.dumps(rectangles)
    return json_output

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description='Find outer contours of a transparent image.')
    parser.add_argument('image_path', type=str, help='Path to the image file.')
    parser.add_argument('--simplify', type=float, default=0.01, help='Simplify factor for contour approximation. Set to 0 to disable simplification.')
    parser.add_argument('--hull', action='store_true', help='Apply convex hull to the contours.')
    parser.add_argument('--rects', action='store_true', help='Return contour x1,y2,x2,y2 outline rects')
    parser.add_argument('--output_path', type=str, default='', help='Path to save the output image. If not provided, the image will not be saved.')
    args = parser.parse_args()

    image_path = args.image_path

    original_img = cv2.imread(image_path, cv2.IMREAD_UNCHANGED)
    original_size = (original_img.shape[1], original_img.shape[0])
    contours = find_outer_contours(image_path, simplify=args.simplify)

    if args.hull:
        contours = apply_convex_hull(contours)

    if args.output_path:
        draw_and_save_contours(contours, output_path, original_size)

    if args.rects:
        rectangles = create_rectangles(contours)
        json_output = rectangles_to_json(rectangles)
        print(json_output)
    else:
        json_output = contour_coordinates_to_json(contours)
        print(json_output)

Git

所有东西都用 git 管理,并保存在 BitBucket 上,因为它提供多个(免费的)私有仓库。

编辑器

我试过好几个编辑器,但就我个人而言 Visual Studio Code 最好用。当然要配合一些扩展。

  • Code Runner
  • Love2D Support
  • Markdown Preview Enhanced
  • reStructuredText Syntax highlighting
  • vscode-lua

这样我按一下键就能重启游戏,需要时还能调试。语法高亮对我来说也是一种解脱。自动补全我其实从来不用。

写故事

接下来是奇怪的部分。你可能会想到 Word、LibreOffice、Markdown… 但我是用 reStructuredText(.rst)文件来写的。

===========================
Scene 20: Nederland - Intro
===========================

.. image:: ../project/story/scene/20/resources/scene20.png
    :width: 100%
    
.. contents::
   :depth: 1

.. raw:: pdf

   PageBreak


Omschrijving
============

Een bos in Nederland in de len...

所有 .rst 文件都放在不同的文件夹里,我用下面的脚本把它们导出成 PDF,然后打印出来。

关于这段冒险,我当然会写一篇文章。

#!/bin/bash
set -e

source config

if [ -n "$1" ]; then
    TARGET_SCENE="$1"
    TARGET_PDF="scene_${TARGET_SCENE}.pdf"
    echo "Generating PDF for scene $TARGET_SCENE"
else
    echo "Generating all PDF files"
fi

mkdir -p ../temp/docs/
for dir in ../project/story/scene/*; do 
    SCENE_NUMBER=$(basename "$dir")
    PDF_NAME="scene_${SCENE_NUMBER}.pdf"
    
    if [ -z "$TARGET_PDF" ] || [ "$PDF_NAME" = "$TARGET_PDF" ]; then
        echo -e "Create: $PDF_NAME"

        # merge *.rst scene files
        first_iteration=true
        TEMP_FILE=$(mktemp tempfile.XXXXXX)
        for md_file in $(ls "${dir}"/*.rst | sort); do
            TEMP_CONTENT=$(mktemp /tmp/tempcontent.XXXXXX)

            if $first_iteration; then
                first_iteration=false          
            else
                item_number=$(basename "$md_file" .rst | cut -d'-' -f1)
                item_description=$(basename "$md_file" .rst | sed -E 's/^[0-9]+-//; s/-/ /g')
                title=""
                # inventory item ends with 9
                if [ ${#item_number} -eq 6 ] && [[ "$item_number" == *"9" ]]; then
                    title="$item_number $item_description (inventory)"
                else
                    title="$item_number $item_description"
                fi
                title_length=${#title}
                separator=$(printf '=%.0s' $(seq 1 $title_length))
                echo -e "$title\n$separator\n" >> "$TEMP_CONTENT"
            fi

            cat "$md_file" >> "$TEMP_CONTENT"
            echo -e "\n\n.. raw:: pdf\n\n    PageBreak\n" >> "$TEMP_CONTENT"

            cat "$TEMP_CONTENT" >> "$TEMP_FILE"
            rm "$TEMP_CONTENT"
        done

        rst2pdf --footer="###Page### | ###Title###" "$TEMP_FILE" --output="../temp/docs/${PDF_NAME}"  
        rm "$TEMP_FILE"

        if [ $# -eq 1 ]; then
            echo "Opening $PDF_NAME..."
            open "../temp/docs/${PDF_NAME}"
        fi
    fi
done

if [ $# -eq 0 ]; then
    echo -e 'Create: concept_document.pdf'
    rst2pdf --footer="###Page### | ###Title###" ../project/story/concept-document.rst --output="../temp/docs/concept_document.pdf"  

    echo -e 'Create: story_notes.pdf'
    rst2pdf --footer="###Page### | ###Title###" ../project/story/notes.rst --output="../temp/docs/story_notes.pdf"  
fi

示意图

整个故事当然包含谜题,我用免费的软件 (https://app.diagrams.net) 把它们可视化,方便自己查看。

Yaml

为了一开始能简单地把一切拼起来,我选择不花时间做游戏编辑器。整个游戏由 .yaml 文件搭建而成,这些文件会被转换成 json 并在游戏里使用。

总览

把我到目前为止写的所有内容放在一起,就得到下面这张图。 图里有 engine3 和 game,我会在另一篇文章里解释。

最终工具示意图

最终工具示意图