简介这是一款用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_mapCHAR_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(frow {y} length {len(line)}, expected {width}) for x, ch in enumerate(line): if ch not in char_map: raise ValueError(funknown char {ch!r} at ({x}, {y})) grid.set(x, y, char_map[ch]) return grid解析前先确认每一行等长不等长直接报错这是最有效的关卡校验。等长检查不通过时多半是手误多打了一个空格或少打了一个#报错信息里标出第几行改起来很快。char_map.get(ch, Tile.EMPTY)这种宽容写法我建议不要用它会把所有非法字符都变成空地等你发现地图缺了一块已经分不清哪一行写错了。4.2 关卡头部参数钻石门槛、计时与敌人速度关卡不只是地图还需要少量参数。最简单的做法是在关卡文本开头写几行以#开头的注释用keyvalue表达参数。这样地图和参数在同一个文件里不会出现“地图换了但参数没跟着换”的错位问题。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 波动影响而且数值越大越慢比较符合直觉。参数默认值作用调大后的效果diamonds5出口开启所需钻石数关卡变长玩家被迫探索更多区域time60整关限时单位秒压力变小可以慢慢挖enemy_speed2敌人每次移动间隔的逻辑帧数敌人变笨躲避更容易这三个参数是我做难度调节的入口。同一个地图只改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(maxlen4) 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 这类游戏对手感的要求比图像高先保证每一步输入都可预期再谈画面优化。验证方法也简单加一个暂停键每按一次只走一个逻辑步肉眼确认石头路径和敌人转向是否正确。希望帮到你。本文还有配套的精品资源点击获取