Lispを活用したゲーム開発、第2部: ダンジョンとインターフェース
(gitlab.com)- Common Lispのゲーム開発環境の上で、ECSアーキテクチャとメタ言語的プログラミングを実際のダンジョンクローラーの例へ拡張してみるチュートリアル
- Tiled XMLマップをcl-tiledで読み込んだ後、CLOSオブジェクトをそのまま使わずECSコンポーネントへ移し、レンダリング、衝突、メモリ管理を分離する
- タイルプレハブ、画像ポインタ、親子インデックス、finalizerを組み合わせ、重複ロードとdouble freeを避けつつ、Tiledのカスタムプロパティをデータのように活用する
- プレイヤーと敵はECSシステムで移動、アニメーション切り替え、衝突処理を行い、敵はcl-astarベースのA*経路探索で壁を避けて追跡する
- NuklearベースのUIとナラティブオブジェクト、一時停止、勝利条件まで加え、約500行規模の小さなダンジョンクローラーの例を完成させる
プロジェクトの開始と基本実行
- Part 1で扱ったEntity-Component-Systemアーキテクチャとメタ言語的プログラミング手法を用いて、UI付きの小さなダンジョンクローラーを作る
- 実行可能なデモバイナリとソースコードはecs-tutorial-2 GitHubリポジトリにある
- 開発環境はPart 1のCommon Lispゲーム開発環境を前提とし、SBCL REPLでQuicklispディストリビューションを更新する
(ql-util:without-prompting (ql:update-all-dists))
- cookiecutter-lisp-gameテンプレートで新しいプロジェクト
ecs-tutorial-2を作成し、例ではバックエンドとしてliballegroを選択する - プロジェクトディレクトリをQuicklispの
local-projectsに接続した後、src/main.lispのウィンドウサイズを1280×800に変更する (ql:quickload :ecs-tutorial-2)と(ecs-tutorial-2:main)を実行すると、指定した解像度の黒いウィンドウとFPSカウンタが表示される
TiledマップとECSストレージ
- ダンジョンマップの作成には、オープンソースのマップエディタTiledを使用する
- Tiledはクロスプラットフォーム・クロスエンジンのツールであり、マップデータをXMLで保存する
- Common Lispではcl-tiledがTiledファイルをLispオブジェクトとしてロードする
- 例のタイルセットにはDungeon Tileset II - Extendedを使用する
- 元の16×16タイルは小さいため、ImageMagickで200%拡大し、32×32タイルとして使う
level1.tmxとタイルセットファイルは、チュートリアルで提供されるResources.zipから入手できる
ecs-tutorial-2.asdにcl-tiled依存関係を追加し、src/map.lispを新しく作ってマップの読み込み・表示コードを分離するsrc/package.lispではcl-tiledをtiledというローカルニックネームとして登録する
CLOSオブジェクトをECSコンポーネントへ移す理由
cl-tiledはマップデータをCLOSオブジェクトとして返すため、REPLで探索しやすい- ゲームループでこれらのオブジェクトを直接使うと、ランタイムディスパッチのコストが大きくなる可能性がある
- 1280×800ウィンドウを32×32タイルで埋めるには、最低でも40×25 = 1000個のタイルが必要になる
- 別のデモでは、12コアのRyzen 5 3600でマップレンダリングを有効にすると、FPSが20,000から600まで落ちる
- 1フレームあたり約
1/600 - 1/20000 = 0.0016秒、つまり1.5ms以上が追加される
cl-tiledが読み込んだデータをcl-fast-ecsストレージへ移すことで、ディスパッチを減らしCPUキャッシュの活用を改善できるcl-fast-ecs依存関係を追加し、initでecs:make-storage、updateでecs:run-systemsを呼び出す
マップ、タイル、プレハブコンポーネント
mapは、ロードされたマップエンティティを表すタグコンポーネントであるmap-tileは個々のタイルを表し、壁や閉じたドアのような障害物かどうかを示すobstacleブールスロットを持つparentコンポーネントは、タイルとマップ関連オブジェクトがどのマップエンティティの子なのかを示すentityスロットに:index childrenを指定し、特定の親の子エンティティを高速に見つける- インデックスはオープンアドレス法のハッシュテーブルベースなので、平均**O(1)**の検索を提供するが、生成・削除時には更新コストがある
ecs:*entity-deleting-hook*にフックを追加し、親エンティティが削除されるとchildrenインデックスで見つけた子エンティティも一緒に削除するimageコンポーネントはALLEGRO_BITMAPへのCポインタだけを保存する- タイルセット画像は
al_create_sub_bitmapで32×32の断片を作成し、そのポインタを保存する
- タイルセット画像は
map-tile-prefabは、TiledタイルのグローバルIDgidを持つタイルプレハブであるgidには:index map-tile-prefab :unique tを指定し、IDで単一のプレハブエンティティを見つける- マップ上の実際のタイルはプレハブの
imageなどをコピーする一方、位置は別のpositionコンポーネントとして持つ
imagefinalizerは、エンティティがmap-tile-prefabである場合にのみal_destroy_bitmapを呼び出す- 同じ
ALLEGRO_BITMAPポインタを複数のマップタイルが共有するため、double freeを避けるためである
- 同じ
positionとsizeは、画面座標とサイズをsingle-floatで保存するliballegroがOpenGL互換性のために画面座標を単精度浮動小数点で扱うので、同じ方式に従う
画像レンダリングとマップ読み込み
render-imagesシステムは、positionとimageを持つエンティティをレンダリングするal_hold_bitmap_drawingでsprite batchingをオン・オフするal_draw_bitmapで指定座標に画像を描画する- プレハブは
positionを持たないため、このシステムでは処理されない
load-bitmapは、al_load_bitmapをal:ensure-loadedでラップした画像読み込み関数であるtile->specは、タイルプレハブ生成のためのECSオブジェクト仕様を作る- 親マップエンティティ
- タイル画像の断片
- TiledグローバルタイルID
- タイルサイズ
load-tile-prefabは、map-tile-prefabインデックスで既に読み込まれたプレハブかどうかを確認し、なければmake-objectで生成するload-tileは、実際のマップタイルエンティティを作る際にプレハブからコンポーネントをコピーし、positionを追加するload-mapは、tiled:load-mapで読み込んだCLOSオブジェクトからタイルセットとレイヤーを走査する- タイルセット画像を読み込み、各タイルをプレハブとして生成する
- タイルレイヤーの各セルをエンティティにし、プレハブデータをコピーする
- Tiledのレイヤー順序はエディタ順のまま維持され、
make-entityは増加するエンティティ番号を保証する- システムは古いエンティティから処理するため、上位レイヤーのタイルは後で描画され、下位レイヤーを覆う
- すべてのタイルを個別エンティティとして保存する方式が唯一の答えではなく、静的マップをバッファへ事前レンダリングする方法も可能である
タイルアニメーション
- Tiled はアニメーションタイルをサポートしているため、松明や魔法の噴水のような要素を表現できる
common.lispとanimation.lispを追加し、共通コンポーネントとアニメーション関連のコンポーネント・システムを分離するanimation-frameコンポーネントはアニメーションの1フレームを表すsequenceはアニメーション名で、keyword型として保存するsequence-framesインデックスで特定アニメーションのフレームを探すdurationは秒単位のフレーム持続時間である
animation-stateは実際のマップ上のアニメーションタイルの現在状態を保存する- 現在の
sequence - 現在の
frame - 現在フレームの
duration - 現在フレームが表示されてからの
elapsed時間
- 現在の
let-plus依存関係を追加し、フレーム切り替えコードを簡潔に記述するupdate-animationsシステムはdt分だけelapsedを増やし、持続時間を超えると次のフレームへ切り替える- フレーム時間は大きな
dtより短い場合があるため、floorで何フレーム飛ばすべきかを計算する truncateによりフレーム番号がリスト長を超えたら先頭に循環するようにするimageの bitmap ポインタを次フレームのプレハブの bitmap に変更する
- フレーム時間は大きな
- アニメーションの持続時間は Tiled ではミリ秒で保存されるため、
animation->specで秒単位に変換する instantiate-animationは実際のタイルエンティティにanimation-stateを作成し、同じアニメーションが完全に同期しないようelapsedを 0 とdurationの間の乱数で初期化する- アニメーションタイルは Tiled のプロパティ
"sequence"を持っている必要がある- このプロパティがないと
NIL名で読み込まれ、想定したアニメーション名で見つけられず、型エラーが発生する可能性がある
- このプロパティがないと
プレイヤーキャラクターと操作
character.lispを追加し、移動可能なキャラクター向けのcharacterコンポーネントを定義するspeedは1秒あたりのピクセル速度であるtarget-x、target-yは移動目標座標である- 初期目標値は
single-float-nanにして、新しいキャラクターが理由なく左上へ移動しないようにする
playerタグコンポーネントはbitスロットと:index player-entity :unique tを使う(player-entity 1)でプレイヤーエンティティを O(1) で見つけるための構造である- グローバル変数にプレイヤーエンティティを保存しない
- 初期実装ではタイルセットからオーク画像を切り出して
player.pngを作り、load-playerでプレイヤーをハードコードして生成する- 位置は
(64.0, 64.0) - サイズは 32×32
- 速度は 100.0
- 位置は
move-charactersシステムは目標地点までキャラクターを移動させるNaNの目標座標があれば現在位置で初期化する- 浮動小数点の直接比較の代わりに
approx-equalを使う atan、cos、sin、速度、dtで新しい座標を計算する
control-playerシステムはW、A、S、Dキー入力を読み取って目標座標を更新するal:with-current-keyboard-stateとal:key-downを使うclampで画面境界の外に出ないようにする:after (move-characters)にして移動システムの後で実行し、NaN初期化の問題を避ける
Tiled のプロパティによる衝突判定とオブジェクト読み込み
- 当初は壁が床タイルと同じ通常画像だったため、プレイヤーが壁をすり抜けていた
- Tiled のカスタム型として
map-tileクラスを作り、obstacleBoolean メンバーを追加する- 壁タイルのプロパティとして
map-tileを追加し、obstacleをチェックする
- 壁タイルのプロパティとして
properties->spec関数は Tiled のプロパティハッシュテーブルを ECS オブジェクト仕様に変換する- Tiled のカスタムクラスはコンポーネントとして扱われる
- クラスメンバーはコンポーネントスロットとして扱われる
- 例は
((:map-tile :obstacle t))という形式である
load-tile-prefabはproperties->specの結果をプレハブ仕様に含める- プロパティがなければ
spec-adjoinによりデフォルトのmap-tileコンポーネントが追加され、obstacleはデフォルト値nilを持つ
- プロパティがなければ
positionコンポーネントにtile-hashスロットとtilesインデックスを追加するtile-hashはx、yを整数化した後、1つの64ビット整数にパックする- 特定タイルの左上座標にあるすべてのエンティティを
tilesインデックスで探す
tile-startは任意の座標が属するグリッドタイルの左上座標を返すtile-obstacle-pは同じ座標のエンティティ群の中に、map-tileかつobstacleが真のタイルがあるかを確認するobstaclepは任意座標について、そのタイルが障害物かどうかを検査するcontrol-playerは移動方向に応じてキャラクター矩形の関連する角のタイルを調べ、障害物があれば目標座標を現在位置に戻す- この衝突方式は完全ではない
- キャラクターの中心座標を基準に設計すれば、数学とコードを単純化できる可能性があるが、この例では複雑さを避けるため現行方式を維持している
マップからのプレイヤーとアニメーションキャラクターの読み込み
- Tiled に
characterとplayerのカスタムクラスを追加するcharacterはspeedfloat メンバーだけを持つtarget-x、target-yはデフォルト値を使うため省略するplayerはデフォルト値 1 のplayerint メンバーを持つ
- Tiled のオブジェクトレイヤーにタイルオブジェクトとしてプレイヤーキャラクターを配置し、
characterとplayerのプロパティを付与する load-mapはtiled:object-layerも処理するよう拡張される- オブジェクトプロパティを
properties->specで ECS コンポーネントに変換する tiled:tile-objectはload-tileでタイルデータとアニメーションをコピーし、位置を設定する- Tiled のオブジェクト座標は左下基準なので、
yからオブジェクトの高さを引いて左上基準に合わせる
- オブジェクトプロパティを
- ハードコードされた
load-player呼び出しと関数は削除される - この構造は Tiled のマップデータを ECS オブジェクトとして直接読み込む方式であり、データ駆動プログラミングに近づいている
- キャラクターアニメーションは、オークの
orc-idleとorc-runシーケンスをタイルセットで定義して使用する change-animation-sequenceはエンティティの現在アニメーションを変更する- すでに同じシーケンスなら何もしない
- 新しいシーケンスの先頭フレームを
sequence-framesインデックスで探し、animation-stateとimage-bitmapを更新する
move-charactersはキャラクターが停止していれば:orc-idle、移動していれば:orc-runに切り替える
敵、ゲームオーバー、A* 経路探索
enemyコンポーネントは敵の行動に必要な2つのスロットを持つvision-range: プレイヤーを視認して反応し始める距離attack-range: 攻撃範囲
- ゲーム終了のために
*should-quit*グローバル変数を追加し、メインループはこの値が真なら終了する handle-enemiesシステムはプレイヤー座標を取得して敵と比較する- プレイヤーが視界範囲内にいれば、敵の目標座標をプレイヤー位置に設定する
- プレイヤーが攻撃範囲内にいれば、
*should-quit*を真に設定し、You diedネイティブメッセージボックスを表示する
- 敵アニメーションは
demon-idle、demon-runシーケンスを使うmove-charactersはhas-player-pの結果に応じて、プレイヤーにはオークのアニメーション、敵にはデーモンのアニメーションを選択する
- 直接追跡方式では敵も壁を通り抜けてしまうため、A* 経路探索を追加する
- cl-astar を依存関係に追加する
- このライブラリは、問題に合わせて最適化された経路探索関数をマクロで生成する
- 経路はコンポーネントスロット内の配列として保存せず、各経路点を個別のエンティティとして表現する
path-pointはx、y、travellerを持ち、travellerにはpath-pointsインデックスがあるpathは最終目的地destination-x、destination-yを保存するcharacterの目標座標は次の経路点、pathは最終目的地を表す
follow-pathシステムは最初の経路点を取得し、キャラクターをその地点へ移動させる- 地点に到達したら、その
path-pointエンティティを削除する - それ以上地点がなければ、
pathコンポーネントを削除する
- 地点に到達したら、その
find-pathはa*:define-path-finderで定義される- ワールドサイズはウィンドウサイズをタイルサイズで割って計算する
- row-major インデクサを使う
- 目標到達判定はタイル座標が同じかどうかで行う
- 隣接ノードは8方向で列挙する
- 障害物、または障害物をまたぐ斜め移動には
most-positive-single-floatコストを与え、事実上不可能にする - ヒューリスティックには octile distance を使う
- 既存の経路があれば経路点を削除し、新しい
pathを割り当てる - 結果の経路の各点は、
path-pointとparentを持つエンティティとして生成される
handle-enemiesは、敵がプレイヤーを視認した際に既存の経路目的地がプレイヤー位置と異なっていればfind-pathを呼び出す- 変更後、敵はプレイヤーを追いかけつつ障害物を避けて移動する
Nuklear ベースのゲーム UI
- ナラティブ要素のために GUI が必要だが、Qt や GTK のような従来型 GUI ライブラリは、
liballegroのグラフィックスコンテキスト上に描画されるゲーム UI には向いていない - UI ライブラリとして Nuklear を使う
liballegroと併用するための Common Lisp バインディング cl-liballegro-nuklear がある- バインディングは宣言的インターフェース用の DSL も提供する
cl-liballegro-nuklear/declarative依存関係を追加し、src/narrative.lispを新規ファイルとして追加する- パッケージには
uiローカルニックネームを登録し、cl-liballegro-nuklear/declarativeを短く参照する - UI フォントとして Google Fonts の Alegreya を使い、ファイル名を
alegreya-sc.ttfに変更する ui:defwindow narrativeはナラティブウィンドウ関数を定義する- ウィンドウ位置は画面中央領域として計算する
ui:label-wrapで自動改行テキストを表示するui:button-label "Ok"はクリック時に真を返す
- Nuklear は immediate mode UI ライブラリである
- ウィジェットオブジェクトをメモリに保持する retained mode ではなく、毎フレーム描画と処理を行う
- ボタンクリックはコールバックではなく、毎フレームの戻り値と条件で処理する
main.lispでは UI フォントを読み込み、nk:allegro-initで UI コンテキストを初期化する- イベントループでは
nk:input-begin、nk:allegro-handle-event、nk:input-endを呼び出す - レンダリング時には
nk:allegro-renderを呼び出す - 終了時には
nk:allegro-shutdown、nk:allegro-font-delを呼び出す
- イベントループでは
UI スキンとナラティブオブジェクト
- デフォルト UI は単調なため、Kenney の
fantasy-ui-borders画像アセットを使ってスタイルを付ける *window-background*、*button-normal-background*、*button-hover-background*、*button-active-background*グローバル変数に UI 画像を保存するload-uiはnk:allegro-create-imageで画像を読み込み、unload-uiはnk:allegro-del-imageで C 側の画像リソースを解放するinitでload-uiを呼び出し、メインループ終了時にunload-uiを呼び出すui:defwindowの:styles引数で背景、ボタン各状態の画像、テキスト色を指定するnarrativeコンポーネントは環境ストーリーテリング用オブジェクトを表すtext: 表示するテキストshown: すでに一度表示されたかどうかactive: 現在ウィンドウが有効化されているかどうかactiveにはactive-narrativesインデックスを持たせる
show-narrativeシステムは、プレイヤーがナラティブオブジェクトの近くにいるとウィンドウを表示する- インタラクト距離は
+interact-distance-factor+とプレイヤーのタイルサイズから計算する - ウィンドウがすでに有効化されているか、まだ一度も表示されていないか、
Eキーが押された場合に表示する Okボタン、Esc、Space、Enterのいずれかでウィンドウを閉じる
- インタラクト距離は
- Tiled には
narrativeカスタムタイプを作成し、textstring メンバーを追加する - 衝突判定と位置合わせのため、通行不可オブジェクトはタイルグリッドに座標を合わせる必要がある
- ナラティブウィンドウが表示されている間もダンジョンが動き続ける問題は、システム実行条件で防ぐ
move-charactersとcontrol-playerに:when (null (active-narratives t))を追加する- 有効なナラティブがある場合、移動と操作システムは実行されない
- 勝利条件は
winタグコンポーネントとして追加するnarrativeと一緒に付いたオブジェクトでウィンドウを閉じると、*should-quit*が真に設定されてゲームが終了する
まとめとスコープ
- 最終サンプルは
cl-fast-ecs、cl-tiled、cl-astar、cl-liballegro-nuklearを使い、環境ストーリーテリング、敵 AI、GUI を備えた Souls-like ダンジョンクローラーを構成する - 実装規模は約 500行のコード である
- 全コードは GitHub リポジトリ にあり、チュートリアルコード以外に
declaimを用いた任意の型宣言も含まれる - サウンドデザイン、カットシーン、メインメニュー、レベル遷移、"door problem" などは扱わない
- Autumn Lisp Game Jam 2024 は 2024年10月25日に itch.io で開催され、Lisp 方言で10日間ゲームを作り、互いに評価・フィードバックするイベントである
- このパートは Spring Lisp Game Jam 2023 出品作 Thoughtbound をベースにしている
- 次のパートでは規模を広げ、さらに高度な AI を追加してリアルタイムストラテジーゲームを作る挑戦を予告している
1件のコメント
Hacker News のコメント
すべての技術チュートリアルがこんなふうだったらいいのに。構成がよく、文法ミスもほとんどなく、新しいトピックを紹介するたびにちょうどよい分量で説明し、完全なコード例と、そのコードが実際に何をしているのかを示すビジュアル資料までそろっている。
内容を深く扱うのに十分な長さがありつつ、パート1を読んでおらず、数年前に Common Lisp を数か月触った程度でもついていけるくらい独立している。Clojure と Emacs Lisp はかなりやったことがある。
Bravo, awkravchuk/Andrew :^)
(https://mxjn.me/2024/10/17/1 からのクロスポスト)
オフライン利用や保存用に簡単に保存して注釈も付けられ、検索もしやすいという利点がある。
Common Lisp についての優れたプロジェクトや記事ほど、技術分野で心を動かされるものはめったにない。本当に大きな贈り物のような記事だ。
パート1が出たときに読んだので、今回のパートを読むのも本当に楽しみだ。著者に賛辞を送りたい。
package.shと全般的な3つのOS向けビルド管理だけでも、それ自体がマスタークラスだ。GitHub リポジトリをざっと見るだけでも多くを学べた。普段は SBCL や LispWorks で Common Lisp のコマンドラインアプリをビルドしているが、次は ECL でやってみてもよさそうだ。macOS と Linux の両方のビルドがそろっているのは素晴らしいし、新しいことを試す楽しさもありそうだ。
とても良い記事だ。Lisp、正確には ClojureScript でマルチプレイヤーの三人称・呪文ベースのシューターを開発している。Web ベースの 3D ゲームで、プロジェクトのために作ったツールや抽象化も含めて、その道のりをブログに書く予定だ。
興味があればデモはここにある: https://wizardmasters.io
記事自体は本当にしっかりしているが、パート1のセットアップ手順が Common Lisp 本体、Python、C と複数段階にわたっているのを見ると、なぜ CL が特に若いプログラマーにあまり人気がないのかが表れているように思う。
残念なことだし、誰かがインストール面でこの言語をもっと手に取りやすくする労力を払ってくれるといいのだが。
私の理解では、選択肢が多すぎることや、古いデフォルトが時代遅れに見えることのほうに焦点を当てている。
イベントループは、
loopがどれほど本格的な反復用ドメイン特化言語であるかを示す素晴らしい例だ。好きでも嫌いでも ;)loopの代わりに https://iterate.common-lisp.dev/ を使えばいいのでは? S式ではない見慣れない構文もなく、Lisp 構文に戻るためにdoも必要ない。見苦しい
else/endなしに通常のif/whenを使え、全体として便利な機能も追加されている。loopは CL の構成要素の中でも特に好きなものの一つになった。この記事は「Caves of Clojure」を思い出させる: https://stevelosh.com/blog/2012/07/caves-of-clojure-01/
ちょうど今週 Python でローグライクの開発を始めたところだが、Lisp でやってみるのも面白そうだ。
だまされた気分だ。簡単なゲームの作り方を学びに来たのに、コンピューティング全般についてものすごく多くのことを学んでしまった。
本当に良い。