プログラミング
オリジナルのDoomをSQLに移植しました
We ported the original Doom to SQL (cedardb.com)
要約
この記事では、1993年発売のオリジナルのDoomのゲームロジックとレンダラーをSQLに移植し、データベース内で実行したプロジェクトについて解説しています。ゲームループはオリジナルの35 FPSで動作し、レンダラーは最大60 Hzで完全な320x200のフレームバッファを生成します。Pythonはタイミング、キーボード入力、ビットマップ表示のみを担当し、マルチプレイヤーも機能します。
全文翻訳
TL;DR: 1993年発売のオリジナルのDoomのゲームロジックとレンダラーをSQLに移植し、データベース内で実行しました。ゲームループはオリジナルの35 FPSで動作し、レンダラーは私のラップトップ上で最大60 Hzで完全な320x200のフレームバッファを生成します。Pythonはタイミング、キーボード入力、そして受け取ったビットマップの表示のみを処理します。マルチプレイヤーも機能します。お使いのブラウザはvideoタグをサポートしていません。
SQLDoomの動作AMD Ryzen 7 7840U上でのSQLDoomの動作
今すぐプレイできますデスマッチ、4スロット、先着順。
SQLDoom on 🇪🇺 EU Servers
SQLDoom on 🇺🇸 US Servers
これは最初のエピソードのシェアウェアバージョンです。すべてのスロットが埋まっている場合、キューに入ります。キューがいっぱいの場合でも、待っている間にSQL経由でライブゲームの状態を調べることができます。
SQLDoom
昨年、DOOMQL [Github]を公開しました。これはDoomに似たASCIIアートを30 FPSでレンダリングし、多くの人に好評でした。しかし、一部の人々は、レイキャスティングアプローチを使用しているため、DoomよりもWolfenstein 3Dに近いと正しく指摘しました。一方、DoomはBSPツリーを使用しており、これにより正しい深度順序付けが容易になり、テクスチャ、任意の壁の角度、および異なる床の高さを実現できます。
私はこのままにしておくことができず、少し試行錯誤した後(ご想像の通り、また育児休暇中でした)、ついにSQLだけで実行される本物のDoomを発表できることになりました。
これらの一つは1993年のバイナリです。もう一つはSQLクエリです。どちらがどちらか分かりますか?
ルール
まず、達成したいことに関するいくつかの基本ルールを確立しましょう。
本物のDoomのように見える必要があります。DOOMQLの視覚的忠実度は、今となってはかなり恥ずかしいものです。
さらに重要なことに、本物のDoomのように感じる必要があります。オリジナルのゲームは純粋に楽しいものです。
レンダリングは純粋にSQLベースである必要があります。許容される唯一のSQL出力は、各ピクセルの正確なRGB値をエンコードしたテーブルまたはビットマップです。
ゲームループも純粋にSQLベースである必要があります。ただし、DB内でユーザー定義関数を使用することは問題ありません。
入力の解析、ゲームティックの駆動、出力ビットマップのレンダリングのみを担当する限り、他のプログラミング言語でクライアントを作成することは許可されます。
アーキテクチャ
Pythonは意図的に単純です(ルール5)。単一のスクリプトがpygameを使用して入力を駆動し、出力ビットマップを描画し、毎秒35回ゲームティックをトリガーします。ゲームロジック、ゲーム状態、レンダラーはデータベース内に存在します。
Python 入力 / タイミング / 表示 | ^ | | ゲームティック実行要求フレーム | | v | +-----------------+ +----------------+ | | | | | SQLゲームロジック | | SQLレンダラー | | | | | +-------+--------+ +--------+-------+ | ^ | | v | +-----------------------------+ | | | ゲーム状態テーブル | | | +-----------------------------+ 2つのパスは意図的に分離されています。ゲームロジックは固定の35 Hzループで実行されますが、レンダラーはゲーム状態テーブルの純粋な関数であり、クライアントはいつでも(つまり、可能な限り速く頻繁に)新しいフレームを要求できます。
ゲームデータのロード
都合の良いことに、Doomの.wadファイル形式は実際には非常にリレーショナルです。
2つのVERTEXESはLINEDEFで接続され、LINEDEFは2つのSIDEDEFを持ちます。SIDEDEFはSECTORを境界付け、SECTORにはTHINGSが含まれることがあります。お分かりいただけるでしょう。WAD全体をデータベースに変換するのは驚くほど簡単で、Pythonで約1000行かかりました。Doom 1のすべてをインポートするには、私のラップトップで約18秒かかります。
例えば、これはE1M1を鳥瞰図でレンダリングするクエリです。
WITH wall AS (
SELECT
round((v1.x + (v2.x - v1.x) * t / 32.0) / 48) AS col, -- 48 units per column
round((v1.y + (v2.y - v1.y) * t / 32.0) / 96) AS row, -- chars are 2:1
l.left_sd_id < 0 AS solid -- one-sided lines are pass-through
FROM linedefs l,
generate_series(0, 32) AS t -- walk each line in 32 steps
JOIN vertexes v1 ON (v1.map_id, v1.id) = (l.map_id, l.v1_id)
JOIN vertexes v2 ON (v2.map_id, v2.id) = (l.map_id, l.v2_id)
WHERE l.map_id = 1
)
SELECT string_agg(CASE WHEN (col, row) IN (SELECT col, row FROM wall WHERE solid) THEN '#' WHEN (col, row) IN (SELECT col, row FROM wall) THEN '.' ELSE ' ' END, '' ORDER BY col)
FROM generate_series(-16, 79) AS col,
generate_series(-51, -21) AS row
GROUP BY row
ORDER BY row DESC;
出力:
#####################
# ..................#
# . ...... .#
# . ...... ###### .#
###### .. ## .#
#####.. . .. ## ##
# ####### ...... ######
##########
# ##
# . ###.. ..##
################
##
# ###.........######## #####.
##
### ........... # ########..########.........###
#### .## ###### # .. ########## #### ## ##
#..... ####### ## # . ##### ... ## ###
#.....#..## ........ ##########..... ....##.#### ##
# . ###.### ...... ### . . ## ... ... .
...... .# ## ## # . ##.. . ...... ##
. . ## . . .......... ## ## ##
# . ###.###### ... ######
#.....#..## ... .. #.
... .. # ## ##
# . ############ # .. .......... #.......... ...#
# # ###......... # ##### ##### ##..... ... ##.###
# ################ ####### ####.........##.##........#######
.####### ### # ###########.#################
# #### # # ## #.# ####...... #
# . ### ## #.#################
# ########### ####. .####
#..# ##### ######..###### # . .. #
# ##...## # # ## ##
# ######..###### #### ##### #.. #
##### #.. #
ゲームループ
私にとって重要なのは、単にそれらしく見えるフレームをレンダリングするだけでなく、実際にDoomを移植することでした。もちろん、ビジュアルは大きな部分を占めますが、Doomはプレイしていて素晴らしい感覚も与えてくれます。以下のシーンはルール2の実行例です(私が楽しんでいる様子です)。
ロケットランチャーで兵士3人をギブさせる
ご覧の通り、多くのことが起こっています。この短いクリップだけでも、以下が見られます。
プレイヤーの入力のポーリングと処理(歩行、旋回、射撃)
敵の歩行と攻撃
アイテムの取得
ロケットランチャーが発射され、弾丸が移動する
ロケット爆発には爆風半径がある
敵のスプライトのレンダリング
アニメーション、ビューボビング、HUD
そして、それらを処理する時間はあまりありません。オリジナルのDoomは固定の35 Hzクロックで実行されていたため、1ティックの予算は1000 ms × 35 Hz = 28.6 msです。また、1ティックあたり正確に1フレームを描画していたため、35 FPSに制限されていました。
SQLDoomはゲームロジックを35 Hzで維持し(そのため、すべてのオリジナルの定数が引き続き機能します)、描画を分離します。クライアントは好きなときにフレームをクエリ(Get it?)でき、ティック間でカメラ位置を補間します。したがって、注意すべき2つの予算があります。
ティックを28.6 msごとに実行する(そうでなければ完全に間違った感覚になるでしょう)
少なくとも毎秒35フレームをレンダリングする(それ以下でもまあまあですが、スムーズには感じられません)
ティックシーケンス
ゲームティックは本質的に手続き的です。ティックごとに実行する必要がある一連のタスクがあります。CedarDBにはcedarscriptと呼ばれるスクリプト言語があり、PL/pgSQLに似ており、ティックごとに実行する関数を事前に計画できます。
以下はティック関数の小さなセクションです。
```sql
doom_cs_clock(map, p);
let mut plan = doom_cs_plan(map, p); -- returns a bitmask of functions to trigger
let use_queued = doom_tic_use(map, p, plan);
if (plan & 2) <> 0 OR use_queued {
active = doom_cs_activate_specials(map);
}
if (plan & 4) <> 0 OR active <> 0 {
doom_cs_doors(map, p);
}
doom_tic_move(map, p); -- full movement, or just turning
doom_cs_death(map, p); -- process deaths
plan = doom_cs_plan(map, p); -- the world moved; re-plan
plan = doom_tic_secrets(map, p, plan); -- secrets, walkover lines, pickups
plan = doom_tic_weapon(map, p, plan); -- weapon state, hitscan, damage
...
if sound_due {
doom_cs_sound(map, p);
}
-- yes, we also play sounds
doom_cs_monsters(map, p); -- always
doom_cs_sector_fx(map, p); -- always
doom_cs_thing_physics(map); -- always
```
上記のPythonドライバーは、1/35秒ごとにSELECT doom_run_game_tic(...)を呼び出します。
これらの呼び出された各関数は、SQLステートメントのバッチを実行します。以下は、モンスターAIの状態機械の一部です。
-- Abridged from sql/runtime/functions/26_cs_monsters.sql.
WITH RECURSIVE monsters AS (
[...]
), -- who is alive, what kind, where
los AS (
[...]
), -- visible, in_view_cone, dist: recursive, walks walls
decision AS (
[...]
), -- one row per actor: its state and what it can see
transitions AS (
SELECT d.*,
CASE WHEN NOT d.alive AND d.state