開発ツール

車輪を(まるごと)再発明しようとしないで!

Love2d フレームワークの公式ロゴ

決断

始まりの記事で、私がゲームを作ると決めたことを読んだかもしれません。

こういうことの進め方は本当にたくさんあって、状況ごとにやり方も変わります。100%わかっていたのは、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 ですが、あとからどんどん増えていきます。

シェル

#!/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 で管理・保存しています。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 がありますが、これについては別の投稿で説明します。

最終的なツールの図

最終的なツールの図