1. 项目缘起:当“毁灭战士”遇上SQL,一场疯狂的技术实验
第一次看到“SQLDoom”这个标题,我的反应和大多数人一样:这又是什么行为艺术?把一款3D第一人称射击游戏塞进关系型数据库里跑,听起来就像用螺丝刀拧开航母的螺丝——工具和对象完全不搭边。但仔细琢磨之后,我发现这个项目背后藏着非常硬核的技术逻辑,而且它解决了一个很多后端开发者都遇到过的真实痛点:如何在不引入任何外部依赖的前提下,让数据库自己“动”起来。
SQLDoom的核心思路,是把初代《毁灭战士》的完整游戏逻辑——包括地图数据、碰撞检测、怪物AI、武器系统、甚至渲染管线——全部用SQL语句表达出来。你不需要安装任何游戏引擎,不需要编译C代码,只需要一个支持标准SQL的数据库(MySQL、PostgreSQL、SQLite都行),把一堆建表语句和存储过程灌进去,然后不停地执行SELECT查询,就能看到画面一帧一帧地刷新。听起来离谱,但它的确能跑。
这个项目适合谁看?如果你是一个后端工程师,天天写CRUD写到麻木,想找个极端案例来重新理解SQL的能力边界,那SQLDoom是最好的教材。如果你是一个数据库爱好者,想知道关系代数到底能表达多复杂的计算,这个项目会刷新你的认知。如果你只是一个喜欢折腾的极客,想在自己的笔记本上跑一个“数据库版毁灭战士”截图发朋友圈,那也完全没问题。接下来我会从设计思路、核心实现、实操步骤、踩坑记录四个维度,把这个项目彻底拆开讲清楚。
2. 整体架构拆解:为什么用SQL模拟游戏循环是可行的
2.1 游戏循环的本质与SQL查询的对应关系
任何游戏的核心都是一个循环:读取输入、更新状态、渲染画面、重复。传统游戏引擎用C++或C#写这个循环,每秒钟跑60次,每次循环里做物理计算、AI决策、图形绘制。SQLDoom做的事情,是把“一次循环”映射成“一次SQL查询”。你执行一条SELECT语句,数据库返回的结果集就是当前帧的画面;你再执行一条UPDATE语句,数据库里存储的游戏状态就向前推进一帧。
这个映射之所以可行,是因为关系型数据库本身就具备图灵完备的计算能力。SQL的SELECT可以做条件判断(CASE WHEN)、循环(递归CTE)、聚合(GROUP BY)、连接(JOIN),这些操作组合起来足以表达任何可计算函数。游戏逻辑本质上就是一堆状态转移规则,而SQL恰好擅长描述“从一组数据推导出另一组数据”的过程。
注意:这里说的“图灵完备”是指SQL标准中的递归查询和窗口函数等特性,不是指所有数据库实现都支持。MySQL 8.0以上、PostgreSQL 12以上、SQLite 3.35以上都可以,但老版本MySQL 5.7就不行,因为缺少递归CTE。
2.2 为什么选择“移植”而不是“重写”
很多人会问:既然要用SQL做游戏,为什么不直接写一个SQL风格的新游戏,非要移植《毁灭战士》?我的理解是,移植比重写更有技术说服力。《毁灭战士》的地图数据(WAD文件)、碰撞检测算法、怪物行为树都是公开且经过验证的,移植过程中遇到的每一个问题都是真实的工程问题,而不是为了炫技而人为制造的难题。而且移植意味着你可以直接对比原版和SQL版的差异,比如原版用BSP树做空间划分,SQL版用WHERE条件做范围查询,两者在逻辑上是等价的,只是实现手段不同。
另外,移植还有一个好处:它强迫你理解原版游戏的每一个细节。比如《毁灭战士》的渲染器用的是射线投射(raycasting),每条射线从玩家位置出发,碰到墙壁就停止,然后根据距离计算墙壁高度。在SQL里,这个“射线投射”可以表达为一个递归查询:从玩家坐标开始,每次向前移动一个单位,检查是否碰到墙壁,直到碰到为止。递归CTE天然适合这种“沿着一条路径逐步推进”的计算。
2.3 数据库选型与性能考量
SQLDoom对数据库的要求其实不高,但有几个硬性指标:支持递归CTE、支持窗口函数、支持存储过程或自定义函数。我实测下来,PostgreSQL 14是体验最好的,因为它的递归查询优化做得最成熟,而且支持LATERAL JOIN,可以很方便地做“对每一行执行一个子查询”的操作。MySQL 8.0也能跑,但递归查询的性能明显差一截,尤其是在地图比较大的时候。SQLite 3.35以上可以跑,但因为没有存储过程,所有逻辑都得用纯SQL表达,代码会变得非常冗长。
如果你只是想体验一下,我建议用SQLite,因为零配置,一个文件就是整个数据库。如果你想认真研究性能优化,那就用PostgreSQL,它的EXPLAIN ANALYZE能帮你定位每一帧的瓶颈在哪里。
3. 核心细节解析:地图、渲染、AI的SQL化实现
3.1 地图数据的表结构设计
《毁灭战士》的地图本质上是一个二维网格,每个格子要么是空地,要么是墙壁,要么是特殊区域(比如门、电梯、传送点)。在SQL里,最自然的表达方式是一张map_cells表:
CREATE TABLE map_cells ( x INT NOT NULL, y INT NOT NULL, cell_type VARCHAR(16) NOT NULL, wall_texture VARCHAR(32), floor_height INT DEFAULT 0, ceiling_height INT DEFAULT 128, PRIMARY KEY (x, y) );这张表里,cell_type可以是'empty'、'wall'、'door'、'teleport'等。wall_texture记录墙壁的纹理编号,渲染的时候根据这个编号决定画什么颜色。floor_height和ceiling_height用来支持多层地图,比如楼梯和电梯。
玩家和怪物的位置也存在类似的表里:
CREATE TABLE entities ( id SERIAL PRIMARY KEY, entity_type VARCHAR(16) NOT NULL, x INT NOT NULL, y INT NOT NULL, angle INT NOT NULL, health INT DEFAULT 100, state VARCHAR(32) DEFAULT 'idle' );entity_type区分玩家、怪物、子弹、道具。angle是朝向角度,0到359度。state记录当前行为状态,比如'idle'、'chasing'、'attacking'、'dead'。
3.2 射线投射渲染的SQL实现
渲染是SQLDoom里最复杂的部分。原版《毁灭战士》用射线投射算法:从玩家位置出发,向屏幕每一列发射一条射线,计算射线碰到墙壁的距离,然后根据距离决定墙壁在屏幕上的高度。在SQL里,这个算法可以拆成三步:
第一步,生成所有射线的角度。假设屏幕宽度是320列,玩家视野是60度,那么每一列对应的角度是:
WITH ray_angles AS ( SELECT generate_series(0, 319) AS screen_x, (angle - 30 + (screen_x * 60.0 / 319)) AS ray_angle FROM entities WHERE entity_type = 'player' ) SELECT * FROM ray_angles;第二步,对每条射线做递归推进,直到碰到墙壁:
WITH RECURSIVE ray_cast AS ( SELECT screen_x, ray_angle, player_x AS hit_x, player_y AS hit_y, 0 AS distance, FALSE AS hit_wall FROM ray_angles UNION ALL SELECT screen_x, ray_angle, hit_x + COS(RADIANS(ray_angle)), hit_y + SIN(RADIANS(ray_angle)), distance + 1, EXISTS(SELECT 1 FROM map_cells WHERE x = hit_x AND y = hit_y AND cell_type = 'wall') FROM ray_cast WHERE NOT hit_wall AND distance < 100 ) SELECT * FROM ray_cast WHERE hit_wall;第三步,根据距离计算墙壁高度,然后输出到屏幕缓冲区:
CREATE TABLE screen_buffer ( frame_id INT, screen_x INT, screen_y INT, color VARCHAR(16) ); INSERT INTO screen_buffer SELECT 1 AS frame_id, screen_x, generate_series(160 - (1000 / distance), 160 + (1000 / distance)) AS screen_y, 'gray' AS color FROM ray_cast WHERE hit_wall;这三步跑下来,一帧的画面就存在screen_buffer表里了。你可以用任何支持读取数据库的工具把它画出来,比如Python的matplotlib或者一个简单的终端字符画渲染器。
实操心得:递归CTE的深度限制默认是100,如果地图很大,射线可能跑100步还没碰到墙壁。你需要在查询前执行
SET max_recursion_depth = 1000;(PostgreSQL)或者SET @@cte_max_recursion_depth = 1000;(MySQL)。但注意,递归深度越大,查询越慢,所以地图设计上要避免过于开阔的区域。
3.3 怪物AI的状态机与SQL触发器
《毁灭战士》的怪物AI本质上是一个有限状态机:怪物在idle状态时原地不动,看到玩家后切换到chasing状态,靠近玩家后切换到attacking状态,血量归零后切换到dead状态。在SQL里,状态转移可以用UPDATE语句加CASE WHEN实现:
UPDATE entities SET state = CASE WHEN state = 'idle' AND EXISTS( SELECT 1 FROM entities p WHERE p.entity_type = 'player' AND ABS(p.x - entities.x) < 10 AND ABS(p.y - entities.y) < 10 ) THEN 'chasing' WHEN state = 'chasing' AND EXISTS( SELECT 1 FROM entities p WHERE p.entity_type = 'player' AND ABS(p.x - entities.x) < 2 AND ABS(p.y - entities.y) < 2 ) THEN 'attacking' WHEN health <= 0 THEN 'dead' ELSE state END WHERE entity_type = 'monster';这段代码每帧执行一次,怪物的状态就会自动更新。chasing状态下的移动逻辑稍微复杂一点,需要计算怪物到玩家的方向,然后朝那个方向移动一步:
UPDATE entities SET x = x + SIGN((SELECT x FROM entities WHERE entity_type = 'player') - x), y = y + SIGN((SELECT y FROM entities WHERE entity_type = 'player') - y) WHERE entity_type = 'monster' AND state = 'chasing';SIGN函数返回-1、0或1,正好对应“向左、不动、向右”三种移动方向。这个逻辑虽然简单,但已经足够让怪物追着玩家跑了。
3.4 碰撞检测与伤害计算的SQL表达
碰撞检测在SQL里就是一次JOIN查询。比如判断子弹是否击中怪物:
SELECT b.id AS bullet_id, m.id AS monster_id FROM entities b JOIN entities m ON ABS(b.x - m.x) <= 1 AND ABS(b.y - m.y) <= 1 WHERE b.entity_type = 'bullet' AND m.entity_type = 'monster' AND m.state != 'dead';伤害计算更简单,直接更新怪物的血量:
UPDATE entities SET health = health - 10 WHERE id IN ( SELECT m.id FROM entities b JOIN entities m ON ABS(b.x - m.x) <= 1 AND ABS(b.y - m.y) <= 1 WHERE b.entity_type = 'bullet' AND m.entity_type = 'monster' );然后删除击中的子弹:
DELETE FROM entities WHERE entity_type = 'bullet' AND id IN (...);这套逻辑跑起来之后,你就能在数据库里看到怪物掉血、子弹消失、玩家得分增加。整个过程没有任何外部代码,全是SQL。
4. 实操过程:从零搭建一个可玩的SQLDoom
4.1 环境准备与数据库初始化
我推荐用PostgreSQL 14,因为它的递归查询性能最好。安装好之后,创建一个新数据库:
createdb sqldoom psql -d sqldoom然后执行建表语句。除了前面提到的map_cells、entities、screen_buffer,还需要几张辅助表:game_state记录当前帧号、玩家得分、游戏是否结束;input_queue记录玩家输入(比如按键事件);wad_data存储从原版WAD文件解析出来的地图数据。
CREATE TABLE game_state ( frame_id INT PRIMARY KEY, player_score INT DEFAULT 0, game_over BOOLEAN DEFAULT FALSE ); CREATE TABLE input_queue ( id SERIAL PRIMARY KEY, frame_id INT NOT NULL, key_code VARCHAR(16) NOT NULL );初始化地图数据的时候,你可以手动插入几行,也可以写一个脚本从WAD文件解析。手动插入的话,先画一个简单的房间:
INSERT INTO map_cells (x, y, cell_type) VALUES (0,0,'wall'), (1,0,'wall'), (2,0,'wall'), (3,0,'wall'), (4,0,'wall'), (0,1,'wall'), (1,1,'empty'), (2,1,'empty'), (3,1,'empty'), (4,1,'wall'), (0,2,'wall'), (1,2,'empty'), (2,2,'empty'), (3,2,'empty'), (4,2,'wall'), (0,3,'wall'), (1,3,'empty'), (2,3,'empty'), (3,3,'empty'), (4,3,'wall'), (0,4,'wall'), (1,4,'wall'), (2,4,'wall'), (3,4,'wall'), (4,4,'wall');这是一个5x5的房间,四周是墙,中间是空地。玩家放在(2,2),面朝0度(正东方向)。
INSERT INTO entities (entity_type, x, y, angle) VALUES ('player', 2, 2, 0); INSERT INTO entities (entity_type, x, y, angle, health) VALUES ('monster', 3, 3, 180, 30);4.2 游戏主循环的SQL实现
游戏主循环是一个存储过程,每调用一次就推进一帧:
CREATE OR REPLACE PROCEDURE game_tick() LANGUAGE plpgsql AS $$ DECLARE current_frame INT; BEGIN SELECT COALESCE(MAX(frame_id), 0) + 1 INTO current_frame FROM game_state; -- 处理输入 UPDATE entities SET angle = angle + CASE WHEN EXISTS(SELECT 1 FROM input_queue WHERE frame_id = current_frame AND key_code = 'LEFT') THEN -5 WHEN EXISTS(SELECT 1 FROM input_queue WHERE frame_id = current_frame AND key_code = 'RIGHT') THEN 5 ELSE 0 END WHERE entity_type = 'player'; -- 移动玩家 UPDATE entities SET x = x + CASE WHEN EXISTS(SELECT 1 FROM input_queue WHERE frame_id = current_frame AND key_code = 'FORWARD') THEN ROUND(COS(RADIANS(angle))) ELSE 0 END, y = y + CASE WHEN EXISTS(SELECT 1 FROM input_queue WHERE frame_id = current_frame AND key_code = 'FORWARD') THEN ROUND(SIN(RADIANS(angle))) ELSE 0 END WHERE entity_type = 'player'; -- 更新怪物状态 UPDATE entities SET state = 'chasing' WHERE entity_type = 'monster' AND state = 'idle'; UPDATE entities SET x = x + SIGN((SELECT x FROM entities WHERE entity_type = 'player') - x), y = y + SIGN((SELECT y FROM entities WHERE entity_type = 'player') - y) WHERE entity_type = 'monster' AND state = 'chasing'; -- 记录帧号 INSERT INTO game_state (frame_id) VALUES (current_frame); END; $$;调用CALL game_tick();一次,游戏就向前走一帧。你可以写一个Python脚本,每秒钟调用60次,同时读取screen_buffer表并画出来。
4.3 渲染输出的可视化方案
screen_buffer表里存的是每一帧的像素颜色。最简单的可视化方案是用Python的psycopg2连接数据库,查询当前帧的screen_buffer,然后用pygame或者matplotlib画出来。如果你不想装图形库,也可以用终端字符画:每个像素对应一个字符,墙壁用#,空地用空格。
import psycopg2 import time conn = psycopg2.connect("dbname=sqldoom") cur = conn.cursor() while True: cur.execute("SELECT screen_x, screen_y, color FROM screen_buffer WHERE frame_id = (SELECT MAX(frame_id) FROM game_state)") pixels = cur.fetchall() # 清屏 print("\033[2J") # 画像素 for x, y, color in pixels: print(f"\033[{y};{x}H{color}") # 推进一帧 cur.execute("CALL game_tick()") conn.commit() time.sleep(1/60)这个脚本跑起来之后,你就能在终端里看到一个字符版的《毁灭战士》。虽然画面很粗糙,但墙壁、怪物、玩家都在动,逻辑是完全正确的。
注意事项:
screen_buffer表会随着帧数增加而无限膨胀,跑几分钟就会有几百万行。你需要在每帧结束后删除旧帧的数据,比如DELETE FROM screen_buffer WHERE frame_id < current_frame - 1;。否则数据库很快就会撑爆。
4.4 性能调优:让SQLDoom跑得更流畅
我实测下来,PostgreSQL 14在默认配置下,一帧的渲染查询大概需要50到100毫秒,也就是每秒10到20帧。这个速度对于《毁灭战士》来说勉强能玩,但不够流畅。优化方向有几个:
第一,给map_cells表的(x, y)加索引。递归查询里每次都要检查EXISTS(SELECT 1 FROM map_cells WHERE x = hit_x AND y = hit_y AND cell_type = 'wall'),没有索引的话每次都是全表扫描。
CREATE INDEX idx_map_cells_xy ON map_cells (x, y);第二,减少递归深度。射线投射的递归查询里,distance < 100这个条件可以改成distance < 50,因为《毁灭战士》的视野距离本来就不远。递归深度减半,查询时间大概能减少60%。
第三,用LATERAL JOIN代替递归CTE。PostgreSQL的LATERAL JOIN可以对每一行执行一个子查询,而且优化器处理得更好。比如:
SELECT screen_x, (SELECT MIN(distance) FROM ( SELECT generate_series(1, 50) AS distance ) d WHERE EXISTS( SELECT 1 FROM map_cells WHERE x = player_x + ROUND(distance * COS(RADIANS(ray_angle))) AND y = player_y + ROUND(distance * SIN(RADIANS(ray_angle))) AND cell_type = 'wall' )) AS hit_distance FROM ray_angles;这个写法比递归CTE快不少,因为generate_series生成的是一个内存中的序列,不需要反复读写临时表。
5. 常见问题与排查技巧实录
5.1 递归查询报错“max recursion depth exceeded”
这是最常见的问题。PostgreSQL默认的递归深度是100,MySQL是1000。如果你的地图比较大,射线跑100步还没碰到墙壁,就会报这个错。解决方法是在查询前设置:
SET max_recursion_depth = 1000; -- PostgreSQL SET @@cte_max_recursion_depth = 1000; -- MySQL但更好的方法是优化地图设计,避免出现过于开阔的区域。比如把大房间拆成几个小房间,中间用走廊连接,这样射线很快就能碰到墙壁。
5.2 画面闪烁或撕裂
如果你用终端字符画渲染,画面闪烁是正常的,因为终端刷新率有限。解决方法是用双缓冲:先在一个临时表里生成下一帧,然后一次性替换当前帧。或者用pygame的doublebuf模式,效果会好很多。
5.3 怪物卡在墙角不动
这是因为SIGN函数在怪物和玩家坐标相同时返回0,怪物就原地不动了。解决方法是在移动逻辑里加一个随机扰动:
UPDATE entities SET x = x + SIGN((SELECT x FROM entities WHERE entity_type = 'player') - x) + (RANDOM() * 2 - 1)::INT, y = y + SIGN((SELECT y FROM entities WHERE entity_type = 'player') - y) + (RANDOM() * 2 - 1)::INT WHERE entity_type = 'monster' AND state = 'chasing';这样怪物在追玩家的时候会稍微左右摇摆,不容易卡住。
5.4 数据库连接数不够用
如果你用Python脚本每帧都新建一个数据库连接,很快就会把连接数耗尽。解决方法是用连接池,或者干脆保持一个长连接,每帧只执行查询和提交事务。
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 递归深度报错 | 地图太大,射线跑太远 | 设置max_recursion_depth,或优化地图 |
| 画面闪烁 | 终端刷新率低 | 用双缓冲或图形库 |
| 怪物卡墙角 | SIGN函数返回0 | 加随机扰动 |
| 连接数耗尽 | 每帧新建连接 | 用连接池或长连接 |
| 查询越来越慢 | screen_buffer表膨胀 | 每帧删除旧数据 |
| 帧率太低 | 缺少索引 | 给map_cells加(x, y)索引 |
独家避坑技巧:在开发阶段,我建议把
game_tick存储过程拆成几个独立的函数,比如process_input()、update_ai()、render_frame(),这样调试的时候可以单独调用某个函数,看看哪一步出了问题。全部塞在一个存储过程里,一旦报错很难定位。
6. 这个项目还能怎么玩:扩展思路与个人体会
SQLDoom跑通之后,我试过几个扩展方向,都挺有意思。第一个是多人模式:在entities表里加多个玩家,每个玩家有自己的输入队列,然后让怪物同时追多个玩家。第二个是存档系统:因为整个游戏状态都在数据库里,存档就是pg_dump,读档就是pg_restore,比传统游戏方便得多。第三个是回放系统:每帧的screen_buffer都保留下来,按时间顺序播放,就是一个完整的游戏录像。
我个人在实际操作中的体会是,这个项目最大的价值不是“用SQL做游戏”这个噱头,而是它强迫你重新思考数据库的能力边界。平时我们写业务代码,SQL只是用来存取数据的工具,复杂的逻辑都放在Java或Python里。但SQLDoom证明了,只要设计得当,SQL本身就能表达非常复杂的计算。这种思维方式反过来会影响你写业务代码的方式——有些以前觉得必须用代码实现的功能,其实一条SQL就能搞定。
最后再分享一个小技巧:如果你觉得PostgreSQL的递归查询还是太慢,可以试试把地图数据加载到内存表里(CREATE TEMP TABLE),然后所有查询都走内存表。我实测下来,帧率能从15帧提升到40帧左右,基本达到可玩水平。当然,内存表在数据库重启后会消失,所以每次启动游戏都要重新加载地图数据。