简介:这是一款用JavaScript实现的简易版《宝石迷阵》游戏源码包,面向初学前端游戏开发的读者,适合用来理解网页游戏从地图搭建、角色控制到碰撞反馈的完整流程。压缩包共20个文件,囊括HTML入口、CSS样式、3个JS逻辑文件以及多张GIF/PNG图片素材,整体仅35KB,结构紧凑,便于逐行阅读。项目覆盖了键盘事件监听、requestAnimationFrame/setInterval帧率控制、二维数组地图、石头滚动与洞口判定、宝石收集和生命值管理等典型机制;动画与交互部分也给出可运行的示例。资源目前已有76人学习,对于想快速上手JavaScript游戏编程、想拆解一个迷你游戏项目的人来说,是麻雀虽小五脏俱全的参考样本。通过阅读代码能直观看到游戏循环如何组织、渲染与逻辑如何分离,以及如何用少量文件实现完整可玩性。
1. Simple Boulder Dash Game 到底是什么
一个只有几十 KB 的复刻版游戏,能让人从下午两点打到天黑。Simple Boulder Dash Game 把钻石、泥土、巨石和一个叫 Rockford 的角色塞进一张几十格宽的洞穴地图,规则一句话能说完:挖土、吃钻石、躲石头,钻石够了就找出口。但每挖一把土,你都在和重力博弈——头上的巨石随时会滚下来,敌人又总在你背后追着跑。这篇笔记要讲的是,怎样用 Python 和 Pygame 把这个经典玩法从头实现一遍:地图怎么存、石头怎么落、敌人怎么走、参数怎么调,以及为什么你的石头会卡在天上。适合想练项目结构、又不想只做扫雷的开发者。
2. 把洞穴变成网格:地图数据与渲染顺序
2.1 为什么用二维数组而不是对象列表
洞穴地图的本质是网格上的坐标访问。玩家下一步能不能走,石头下方是不是空,敌人撞到的是墙还是土,都要频繁做“取某个格子”的操作。二维数组用grid[y][x]一次取到格子类型,复杂度是 O(1);如果把所有东西存成对象列表,每次判断都要遍历一遍实体位置,代码绕,性能也吃亏。
这里只需要记录每个格子的类型,不需要给每个方块单独维护状态。用IntEnum定义类型,再用一张属性表描述行为,比写死一堆if tile == 3更容易维护。敌人可以单独用对象列表存,因为它的位置和方向是持续变化的;但地图本身用二维数组就够了。
from enum import IntEnum class Tile(IntEnum): EMPTY = 0 # 洞穴空地 WALL = 1 # 不可挖的岩壁 DIRT = 2 # 可以挖掉的泥土 ROCK = 3 # 会滚落的巨石 DIAMOND = 4 # 需要收集的钻石 PLAYER = 5 # 玩家出生点 ENEMY = 6 # 敌人出生点 EXIT = 7 # 钻石足够后可以离开的出口 TILE_PROPS = { Tile.EMPTY: {"walkable": True, "diggable": False, "collectable": False}, Tile.WALL: {"walkable": False, "diggable": False, "collectable": False}, Tile.DIRT: {"walkable": True, "diggable": True, "collectable": False}, Tile.ROCK: {"walkable": False, "diggable": False, "collectable": False}, Tile.DIAMOND: {"walkable": True, "diggable": False, "collectable": True}, Tile.PLAYER: {"walkable": True, "diggable": False, "collectable": False}, Tile.ENEMY: {"walkable": False, "diggable": False, "collectable": False}, Tile.EXIT: {"walkable": True, "diggable": False, "collectable": False}, }把类型定义成IntEnum而不是普通字符串,是因为后续要做状态比较和颜色映射,数字枚举写起来短,打印调试时又能显示名字。属性表里walkable表示玩家能否走进去,diggable表示走进后是否把格子清空,collectable表示是否增加钻石计数。注意泥土是可走且可挖的,玩家走进泥土后格子会被替换成空地;钻石也可走,走过去自动收集。敌人和墙都不可走,但这张表只负责移动预判,真正的死亡判定要交给碰撞逻辑。
2.2 地图类:加载、越界与坐标转换
地图类不需要花哨,核心就三件事:初始化、越界判断、读写格子。很多人一开始图方便直接用裸数组,结果边界处处要判断x < 0,一不留神就 IndexError。把越界统一收进Grid里,后续移动逻辑就干净很多。
class Grid: def __init__(self, rows, cols): self.rows = rows self.cols = cols self.cells = [[Tile.EMPTY] * cols for _ in range(rows)] def in_bounds(self, x, y): return 0 <= x < self.cols and 0 <= y < self.rows def get(self, x, y): if not self.in_bounds(x, y): return None return self.cells[y][x] def set(self, x, y, tile): if self.in_bounds(x, y): self.cells[y][x] = tileget在越界时返回None,这样移动逻辑里可以统一判断“目标不是None才能走”,不用每个方向各写一遍边界条件。set越界时直接忽略,避免推石头或敌人移动时因为坐标出界把整个程序炸掉。网格初始填EMPTY,之后关卡文件会覆盖成真实地形。
这里有一个容易被忽略的点:rows和cols的先后顺序。地图读入时通常是先按行读字符串,所以初始化参数写成Grid(rows, cols),但访问时一定要统一grid.cells[y][x],也就是先行后列。如果混用,轻则读到错位格子,重则数组越界。我一般会在set里加一个调试断言,发布前再关掉,避免上线后误操作静默失败。
2.3 渲染顺序:先格子,再实体
渲染逻辑的核心不是画图,而是顺序。Pygame 里后画的会盖住先画的,所以要先铺满地图格子,再画玩家、敌人这些动态实体。如果反过来,角色会被泥土和石头盖住,看起来像遁地。
def render(screen, grid, player, enemies, colors): screen.fill((0, 0, 0)) for y in range(grid.rows): for x in range(grid.cols): tile = grid.get(x, y) if tile is None: continue color = colors.get(tile, (40, 20, 60)) rect = (x * TILE_SIZE, y * TILE_SIZE, TILE_SIZE, TILE_SIZE) pygame.draw.rect(screen, color, rect) for enemy in enemies: cx = enemy.x * TILE_SIZE + TILE_SIZE // 2 cy = enemy.y * TILE_SIZE + TILE_SIZE // 2 pygame.draw.circle(screen, (255, 80, 80), (cx, cy), TILE_SIZE // 3) px = player.x * TILE_SIZE + TILE_SIZE // 2 py = player.y * TILE_SIZE + TILE_SIZE // 2 pygame.draw.circle(screen, (255, 200, 80), (px, py), TILE_SIZE // 3) pygame.display.flip()这里的TILE_SIZE我一般取 24 到 32。取值太小,角色和钻石难区分;太大,20x16 的地图就要占满整个屏幕,敌人视野太开阔。24 适合在 800x600 窗口里看 30 列上下的地图,32 适合做触屏和放大效果。colors是一张Tile -> RGB的字典,先用纯色方块调试,等逻辑稳定后再替换成图片素材。
这段代码每帧全量重绘所有格子。对 20x16 或 40x22 的地图,几百个矩形毫无压力;如果地图大到几千格,再考虑用Surface缓存静态图层,只重绘玩家和敌人所在的脏矩形。第一版不要急着优化,渲染慢不会骗你,逻辑错才会。
3. 让石头学会重力:核心更新循环与碰撞判定
3.1 更新顺序的底层逻辑:为什么从底往上扫
Boulder Dash 最核心的机制是重力,也是最容易翻车的地方。巨石会落在洞穴里,只要下方是空,就会往下掉,落到玩家头顶直接压死。问题在于“什么时候掉、一次掉几格”。如果每个逻辑帧都允许石头下落一格,那更新顺序就决定了石头会不会一帧掉到底。
常见做法是写一个双层循环扫描网格。如果从上往下扫,比如先扫第 0 行,发现石头掉到第 1 行,接着循环到第 1 行时又检查到同一块石头,于是它又掉到第 2 行。一个帧里石头连续移动多次,视觉上像瞬移,而且玩家来不及躲。反过来从底往上扫,第 1 行的石头先处理完,第 0 行的石头掉到第 1 行后,本轮不会再被检查到,天然保证每帧最多移动一格。
def apply_gravity(grid): for y in range(grid.rows - 2, -1, -1): for x in range(grid.cols): if grid.get(x, y) != Tile.ROCK: continue below = grid.get(x, y + 1) if below == Tile.EMPTY: grid.set(x, y, Tile.EMPTY) grid.set(x, y + 1, Tile.ROCK) elif below == Tile.PLAYER: return "player_dead" return Nonerange(grid.rows - 2, -1, -1)表示从倒数第二行开始往上扫,最后一行不需要检查,因为它的下方一定越界。注意这里只把EMPTY当作可下落空间,泥土、钻石、出口、敌人、玩家都不会被石头顶开。简化版里石头压到玩家直接返回死亡标记,不做滚动动画;如果你想要“石头压扁角色”的延迟效果,可以引入一个falling_rock实体,后续再加。
参数上,如果觉得石头下落太快,可以给重力加一个节拍器:每 2 帧或 3 帧才调用一次apply_gravity。我用全局计数器gravity_tick % GRAVITY_INTERVAL == 0控制,GRAVITY_INTERVAL = 1时石头最快,= 2时节奏更接近原版。调参数时记住一个基准:角色移动和自己的逻辑帧同步,石头速度永远不要超过每帧一格,否则玩家看到的不是“滚落”,而是“闪现”。
3.2 玩家移动、推动与实体占位
玩家的移动逻辑,本质上是对目标格子的分类讨论。目标可能是空地、泥土、钻石、墙、石头、敌人或出口,每种的处理方式都不一样。我习惯把移动函数收敛成单一入口,返回一个结果状态,由上层决定是否扣命或进入下一关。
def move_player(grid, player, dx, dy, level): nx, ny = player.x + dx, player.y + dy target = grid.get(nx, ny) if target is None: return "blocked" if target == Tile.WALL: return "blocked" if target == Tile.ROCK: return try_push_rock(grid, player, nx, ny, dx, dy) if target == Tile.DIRT: grid.set(nx, ny, Tile.EMPTY) elif target == Tile.DIAMOND: player.score += 1 grid.set(nx, ny, Tile.EMPTY) elif target == Tile.EXIT: if player.score >= level.diamonds_required: return "level_clear" return "blocked" elif target == Tile.ENEMY: return "player_dead" grid.set(player.x, player.y, Tile.EMPTY) grid.set(nx, ny, Tile.PLAYER) player.x, player.y = nx, ny return "moved"走进泥土时,格子被改成空地,等效于“挖掉一格土”。走进钻石时,钻石格变成空地,分数加一。出口在没有集齐钻石时相当于一堵墙,集齐后才能触发通关。这里把出口的判定放在移动前,避免玩家走到出口格子上又被挡回来,状态错乱。
推石头是 Boulder Dash 的特色,也是最容易出 bug 的地方。try_push_rock需要检查石头前方还有没有空间,如果前方是墙或另一块石头,就不能推。我个人允许石头被推进泥土或空地,但不允许推进钻石和出口,因为那会让收集品被石头盖掉,产生死局。
def try_push_rock(grid, player, rock_x, rock_y, dx, dy): ahead_x, ahead_y = rock_x + dx, rock_y + dy ahead = grid.get(ahead_x, ahead_y) if ahead not in (Tile.EMPTY, Tile.DIRT): return "blocked" grid.set(ahead_x, ahead_y, Tile.ROCK) grid.set(rock_x, rock_y, Tile.PLAYER) grid.set(player.x, player.y, Tile.EMPTY) player.x, player.y = rock_x, rock_y return "moved"注意这里把玩家直接放进了原石头的位置,而不是先清空原玩家格。顺序很关键:先移动石头,再把玩家放进石头原来的格子,最后清空玩家旧位置。如果先清空玩家,再移动石头,在某些地图边界情况下会覆盖掉不该覆盖的格子。推完石头后,下一帧重力扫描会自然处理石头继续下落的情况,玩家不用额外调apply_gravity,这样石头被推下悬崖再滚一圈的效果是自动出现的。
3.3 敌人移动:带冷却的随机游走
简单版不需要寻路,原版很多敌人也就是沿着固定方向走,碰到墙再换方向。随机游走实现简单,但有一个副作用:如果每帧碰墙都重新随机方向,敌人会在墙角抖动得像触电。解决方法是给敌人一个冷却计数器,撞墙后至少等几帧再转向。
class Enemy: def __init__(self, x, y): self.x = x self.y = y self.direction = (1, 0) self.cool_down = 0 def move_enemy(grid, enemy, rng): if enemy.cool_down > 0: enemy.cool_down -= 1 return None dx, dy = enemy.direction tx, ty = enemy.x + dx, enemy.y + dy target = grid.get(tx, ty) if target == Tile.PLAYER: return "player_dead" if target is None or target in (Tile.WALL, Tile.ROCK, Tile.DIRT, Tile.DIAMOND, Tile.EXIT): dirs = [(1, 0), (-1, 0), (0, 1), (0, -1)] rng.shuffle(dirs) for d in dirs: ndx, ndy = d nx, ny = enemy.x + ndx, enemy.y + ndy if grid.get(nx, ny) in (Tile.EMPTY, Tile.PLAYER): enemy.direction = d break enemy.cool_down = 3 return None grid.set(enemy.x, enemy.y, Tile.EMPTY) grid.set(tx, ty, Tile.ENEMY) enemy.x, enemy.y = tx, ty return Nonerng.shuffle(dirs)是让随机方向不总是优先朝左,否则所有敌人都往一个方向挤。cool_down = 3表示撞墙后三帧内不改变方向,敌人会表现出“停下思考”的感觉,而不是高频抖动。这个值同时影响难度:冷却越大,敌人反应越慢,玩家越容易绕背。把冷却和全局速度参数解耦,后续调难度时就不用改代码。
简化版里敌人不能穿过泥土和钻石,只能走空地和玩家所在的格子。这会导致敌人被泥土墙挡住,玩家可以挖一条通道引它过来。如果你想做更接近原版的敌人,让它可以钻土,那就要在移动时把泥土也当作可通行格,并让cool_down适当增大,否则敌人会把整个地图挖穿。
4. 关卡文件与难度参数:一套可编辑的洞穴格式
4.1 用字符画设计关卡
把地图写死在代码数组里是新手最常用的方式,但改一关就要动代码,很不方便。我一般会把关卡写成字符串文本,用单个字符代表一种格子,然后在启动时解析成Grid。这样做的好处是关卡可以放在独立文件里,能用文本编辑器直接画图,也能写单元测试验证解析逻辑。
LEVEL_SAMPLE = """ ################ #..............# #.D....P....O..# #..............# #....E.........# #...........X..# #..............# ################ """ CHAR_TO_TILE = { "#": Tile.WALL, " ": Tile.EMPTY, ".": Tile.DIRT, "O": Tile.ROCK, "D": Tile.DIAMOND, "P": Tile.PLAYER, "E": Tile.ENEMY, "X": Tile.EXIT, }字符和 Tile 的对应关系里,最容易混淆的是空格和.。空格代表已经挖开的空地,.代表还没挖的泥土。从图面上看,.更像“有东西的格子”,空格更像“空洞”,符合游戏直觉。P和E是出生点,解析完成后它们会被转换成实际实体,不会一直留在地图数组里参与重力处理。
把地图字符串丢给解析器时,要保留行结构,不能strip()掉每行的末尾空格,否则行列数对不上。我常用的加载函数长这样:
def load_level(text, char_map=CHAR_TO_TILE): lines = text.strip().splitlines() height = len(lines) width = len(lines[0]) grid = Grid(height, width) for y, line in enumerate(lines): if len(line) != width: raise ValueError(f"row {y} length {len(line)}, expected {width}") for x, ch in enumerate(line): if ch not in char_map: raise ValueError(f"unknown char {ch!r} at ({x}, {y})") grid.set(x, y, char_map[ch]) return grid解析前先确认每一行等长,不等长直接报错,这是最有效的关卡校验。等长检查不通过时,多半是手误多打了一个空格或少打了一个#,报错信息里标出第几行,改起来很快。char_map.get(ch, Tile.EMPTY)这种宽容写法我建议不要用,它会把所有非法字符都变成空地,等你发现地图缺了一块,已经分不清哪一行写错了。
4.2 关卡头部参数:钻石门槛、计时与敌人速度
关卡不只是地图,还需要少量参数。最简单的做法是在关卡文本开头写几行以#开头的注释,用key=value表达参数。这样地图和参数在同一个文件里,不会出现“地图换了但参数没跟着换”的错位问题。
def parse_meta(text): meta = { "diamonds": 5, "time": 60, "enemy_speed": 2, } for line in text.splitlines(): if not line.startswith("#"): continue for item in line[1:].split(): if "=" in item: key, value = item.split("=", 1) if key in meta: meta[key] = int(value.strip()) return meta def load_level_with_meta(text): meta = parse_meta(text) body = "\n".join(line for line in text.splitlines() if not line.startswith("#")) return load_level(body), metaenemy_speed是我额外加的全局节拍参数,含义是“敌人每 N 个逻辑帧移动一次”。实现时在主循环里加一个计数器,frame_count % enemy_speed == 0才调用move_enemy。这样敌人速度不会受 FPS 波动影响,而且数值越大越慢,比较符合直觉。
| 参数 | 默认值 | 作用 | 调大后的效果 |
|---|---|---|---|
| diamonds | 5 | 出口开启所需钻石数 | 关卡变长,玩家被迫探索更多区域 |
| time | 60 | 整关限时,单位秒 | 压力变小,可以慢慢挖 |
| enemy_speed | 2 | 敌人每次移动间隔的逻辑帧数 | 敌人变笨,躲避更容易 |
这三个参数是我做难度调节的入口。同一个地图,只改diamonds和enemy_speed就能产生三种难度曲线。调time要谨慎,时间太短容易逼着玩家莽撞挖土,反而被石头砸死。
4.3 设计关卡时容易踩的四个边界
第一,地图最外圈一定要封死。如果没有墙,石头会从边缘滚出地图,Grid.get返回None后逻辑容易漏判,敌人也会走出视野。我习惯用#把地图包一圈,解析后单独检查四周边框是不是全是WALL。
第二,玩家出生点只能有一个。解析时如果发现多个P,要直接报错,否则最后加载的玩家位置不可控。同样地,E敌人可以多个,也可以没有,但X出口必须存在。第三,要校验钻石总数量不小于diamonds参数,否则玩家永远集不齐钻石,关卡变成死局。第四,出口所在格必须留一条能走的路,不要放在封闭房间里。简单版可以不做全图可达性检查,但至少要肉眼确认出口不是被墙围死。
5. 避坑记录:重力扫描、推动、敌人抖动与出口判断的 5 个根因
5.1 石头一帧掉好几格,像瞬移
现象:石头从高处落下时,屏幕上看不到中间过程,直接出现在底部,玩家还没来得及躲就被压死。
原因:重力扫描方向是正的,从上往下遍历。一块石头在第 0 行掉到第 1 行后,循环继续往下走,又遇到同一块石头,于是它再次下落。一帧里重复下落,速度变成每帧多格,和玩家移动速度完全脱节。
解决:把扫描方向改成从底往上,range(rows - 2, -1, -1)。这样每块石头在一个逻辑帧里最多被移动一次。改完之后,如果还是觉得下落太快,就给重力加上节拍器,让apply_gravity每 2 帧调用一次,而不是继续去修扫描循环。
5.2 推动石头时角色和石头重叠
现象:玩家站在石头旁边,按方向键推石头,结果角色瞬间被“吸入”石头所在格,下一秒被判定死亡。
原因:try_push_rock里没有检查石头前方的格子,直接把石头往前移一格,再把玩家放进原石头位置。如果前方是墙或另一块石头,石头没动,玩家却被移动了,两个实体就叠在同一格。
解决:推动前先检查ahead = grid.get(rock_x + dx, rock_y + dy),只有ahead是空地或泥土时才能推。前方是墙、钻石、敌人、出口或另一块石头时,直接返回blocked。这条检查必须放在移动格子之前,不要用“先移动再检查”的写法。
5.3 敌人卡在墙角疯狂抖动
现象:敌人在一个墙角不断变换方向,每帧都在转向,看起来像抽搐,移动距离反而很短。
原因:每次检测到前方不可走,立刻重新随机方向。由于墙角左右下三个方向都不可走,随机结果大概率还是撞墙,于是下一帧又换方向,形成高频抖动。
解决:给敌人加cool_down计数器,撞墙后至少等几个帧再尝试下一次转向。我一般设 3,也就是三帧内不换方向,敌人会短暂停顿一下,节奏更接近原版。抖动如果还出现,检查随机方向是否包含了“原路返回”,确保它不是来回横跳。
5.4 按住方向键角色只走一格就停
现象:玩家按住右键不放,角色走一格就停下,必须松开再按才能继续走。
原因:移动逻辑只写在pygame.KEYDOWN事件里。按住按键时,系统事件不会持续触发,事件驱动的方式自然得不到连续移动。
解决:把移动改成状态驱动。在逻辑更新里读取pygame.key.get_pressed(),判断当前是否按住方向键,再调用移动函数。事件仍然可以用来记录“按下方向键”的瞬间,但持续移动要交给按键状态判断。这个坑和输入缓冲是两个问题,不要混在一起修。
5.5 钻石够了,走到出口却没有任何反应
现象:玩家分数已经达到diamonds值,走到出口格子上仍然被挡住,关卡无法结束。
原因:出口判定写成了player.score == level.diamonds_required,但玩家可能因为一次移动多收到钻石,分数直接跳过目标值。或者解析参数时diamonds被写成了字符串值,和整数分数比较时永远不相等。
解决:把关卡通关条件写成>=,同时在加载参数后用print把diamonds_required和玩家初始分数打出来。你看到的是数字对不上,就能定位是解析问题还是符号问题。如果分数是慢慢增加的,不会有跳过问题,但>=是更稳的写法。
6. 提升手感的最后一步:固定步长与输入缓冲
把逻辑更新和渲染混在同一个while循环里,是手感忽快忽慢的根源。FPS 高的时候角色像开了加速,FPS 低的时候又像踩了泥。我习惯把游戏循环拆成两个频率:逻辑步长固定为每秒 30 次,渲染尽量跑到 60 帧,两个循环用累加器衔接。
import time from collections import deque input_queue = deque(maxlen=4) def on_keydown(key): if key == pygame.K_LEFT: input_queue.append((-1, 0)) elif key == pygame.K_RIGHT: input_queue.append((1, 0)) elif key == pygame.K_UP: input_queue.append((0, -1)) elif key == pygame.K_DOWN: input_queue.append((0, 1)) def drain_inputs(player, grid, level): while input_queue: dx, dy = input_queue.popleft() result = move_player(grid, player, dx, dy, level) if result != "blocked": break输入队列长度设为 4,玩家快速连按方向时最多缓存四步,不会因为连按而一次走完一整条路,也不会丢掉关键转向。每次逻辑更新只取一个有效输入,移动成功就跳过队列里剩余的方向,避免玩家无意识堆叠输入导致角色乱跑。
我最初做简单版时,把移动直接绑在KEYDOWN上,玩家连按方向键总吞操作,后来改成deque缓存才正常。Boulder Dash 这类游戏对手感的要求比图像高,先保证每一步输入都可预期,再谈画面优化。验证方法也简单:加一个暂停键,每按一次只走一个逻辑步,肉眼确认石头路径和敌人转向是否正确。希望帮到你。
本文还有配套的精品资源,点击获取