ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

用Python和Pygame开发吃豆人:地图建模、碰撞检测与幽灵AI实战解析

用Python和Pygame开发吃豆人:地图建模、碰撞检测与幽灵AI实战解析 简介Pacman经典游戏的Java实现项目由Andrei与Marius合作完成面向正在学习Java游戏开发、图形界面编程或基础人工智能算法的学生与开发者可作为课程设计、期末项目或入门实践的完整参考帮助解决从零搭建游戏框架与理解核心机制的问题。资源以Java面向对象思想组织源码围绕图形用户界面Swing/JavaFX、游戏循环与碰撞检测、鬼魂移动AI、迷宫结构定义、键盘事件监听以及多线程动画同步等关键环节展开能够帮助读者从零理清一款小型游戏的开发脉络。压缩包共12个文件其中包含5个Java源文件、3个class编译文件、1份README说明文档以及项目配置文件classpath、project等整体大小仅12KB结构轻量、目录清晰便于直接导入IDE查看和运行。目前已有152人学习浏览通过研究源码与控制逻辑可直观理解Pacman移动、吃豆、躲避鬼魂等状态的实现方式也能借鉴其中对资源加载与线程同步的处理思路是一份精简而实用的Java游戏学习素材。1. pacman-andreimarius 项目的本质一个吃豆人游戏背后要解决的三件事如果你拿到 pacman-andreimarius 这个项目看到的不是一张能直接玩的成品图而是一堆代码和一个迷宫地图。pacman 游戏本身不难难的是把网格地图、像素级移动、幽灵寻路和碰撞判定四件事拧在一起还要保证双人协作时不互相踩脚。这篇文章把它拆成三件事地图怎么建模、角色怎么动、幽灵怎么追。适合正在做游戏课程项目、或者想拿 pacman 练手协作开发的从业者。你不需要图形学基础但要能读 Python 和 Pygame 代码跟着章节跑通最小版本后再按自己的需求改。2. 地图建模与选型为什么 pacman 项目先把地图写成数据再用 Pygame 渲染2.1 选型为什么用 Pygame 兜底而不是从窗口和输入开始写常见做法是 Python 加 Pygame。原因很直接窗口创建、键盘事件、图片加载、音频播放都有现成 API不需要从零写一个渲染循环。Pygame 不是万能的它没有物理引擎也没有 Tilemap 编辑器集成但 pacman 这类游戏全是轴对齐的方块和格子Pygame 的 Surface 和事件系统刚好覆盖全部需求。如果换成 JavaScript 加 Canvas思路完全一样网格和状态机不绑定语言这篇按 Python 讲是因为它最容易照着写。选 Pygame 还有一个现实理由教程多报错信息好搜。双人协作时两个人各自查资料都能找到答案不会因为某个冷门报错卡住一整天。你只需要会用display.set_mode、event.get、key.get_pressed、draw.rect这四组 API就能把这个项目的骨架搭完。其他功能可以边写边补。2.2 把字符地图加载成碰撞网格最小可运行代码pacman 的地图不是图片是数据。原版 28 乘 31 的迷宫每个格子要么是墙、要么是豆子、要么是空地这种结构用字符文本表达最直观。我一般把地图存成map.txt每行一个字符串字符和格子的对应关系写死在代码里。############### #.........#...# #.###.###.#.#.# #o#...#...#.#o# #.#.###.###.#.# #.#....P....#.# #.###.#.#.###.# #..............# #.###.#.#.###.# #.#.....#...#.# #.#.###.###.#.# #o#.........#o# ###.#########.# #.............# ###############from enum import IntEnum TILE_SIZE 16 class Tile(IntEnum): EMPTY 0 WALL 1 DOT 2 PELLET 3 CHAR_TO_TILE { #: Tile.WALL, .: Tile.DOT, o: Tile.PELLET, : Tile.EMPTY, } def load_map(path): grid [] start None with open(path, r, encodingutf-8) as f: for y, line in enumerate(f): line line.rstrip(\n) if not line: continue row [] for x, ch in enumerate(line): if ch P: row.append(Tile.EMPTY) start (x, y) else: row.append(CHAR_TO_TILE[ch]) grid.append(row) if start: # 格子坐标换算成像素坐标取格心后续碰撞全部用像素坐标 start (start[0] * TILE_SIZE TILE_SIZE // 2, start[1] * TILE_SIZE TILE_SIZE // 2) return grid, start这段代码把字符流翻译成二维整数网格同时把玩家起点 P 单独记下来换算成像素坐标。P 所在的格子按空地处理这样加载完地图后碰撞检测只认格子类型不需要特判起点。TILE_SIZE 16是我常用的参数在 960 乘 720 的窗口里能完整放下这张训练图角色大小也合适。注意地图文件有个硬约束每一行的字符数必须完全一致空格也是合法字符。如果某行少一个字符后面查格子时就会越界。我自己的习惯是用编辑器的“显示空格”功能排版排完数一遍行宽写进代码前先跑一次len(line)检查。2.3 碰撞查询的边界四个角检测为什么够用地图加载完之后最关键的是碰撞查询。这里不能用 Pygame 的spritecollide直接拿角色去撞墙的 Rect因为墙是一堆矩形拼起来的角色经过接缝时会被卡住。正确做法是查网格给定角色的像素坐标和一个半径检查包围盒四个角落在哪个格子里。COLS len(grid[0]) ROWS len(grid) def can_move(grid, x, y, radius): corners ( (x - radius, y - radius), (x radius, y - radius), (x - radius, y radius), (x radius, y radius), ) for cx, cy in corners: tx int(cx // TILE_SIZE) ty int(cy // TILE_SIZE) if not (0 tx COLS and 0 ty ROWS): return False if grid[ty][tx] Tile.WALL: return False return True用四角而不是中心点是为了避免“中心点还没进墙、角已经进墙”的穿模。radius 我一般给 6TILE_SIZE 是 16差不多是格子的四成。这个值很敏感太大角色在拐角会被空气墙挡住太小角色会贴着墙边漏出去。四角检测对训练版完全够用但有一个边界要知道如果速度超过每帧半个格子一次移动可能直接越过一层薄墙。训练版里玩家速度 120 像素每秒一秒过 7.5 格不会触发这个问题但如果你后面要做加速道具就得把移动拆成小段逐段检测。3. 角色动起来玩家位移、幽灵 AI 与得分循环3.1 玩家移动方向意图、dt 位移与顶墙对齐地图就绪后先把主循环搭起来再往里填玩家逻辑。主循环里最关键的是dt也就是上一帧到这一帧的时间差单位是秒。所有位移都要乘以它速度单位才是“像素/秒”而不是“像素/帧”。import pygame def main(): pygame.init() screen pygame.display.set_mode((COLS * TILE_SIZE, ROWS * TILE_SIZE)) clock pygame.time.Clock() grid, start load_map(map.txt) player Player(start) ghosts [Ghost((7 * TILE_SIZE, 7 * TILE_SIZE), blinky)] running True while running: dt clock.tick(60) / 1000.0 for event in pygame.event.get(): if event.type pygame.QUIT: running False keys pygame.key.get_pressed() player.update(keys, dt, grid) for g in ghosts: g.update(player, dt, grid) # 绘制网格、豆子和角色 pygame.quit()clock.tick(60)返回的是上一帧到当前帧的毫秒数除以 1000 才是秒。新手最容易在这里踩坑忘了除玩家速度会变成每秒 12 万像素角色瞬间飞出去。主循环里我用固定 60 帧上限但帧率上限不保证实际帧率稳定所以物理更新必须依赖dt这是后面第 4 章的重点。玩家移动我习惯分成“意图方向”和“当前方向”两个变量。按方向键只改意图意图方向能走才真正转弯否则保持原方向前进。这样手感比“按下就转、撞墙就停”舒服很多也接近原版 Pacman 的提前拐弯。class Player: SPEED 120 # 像素/秒 def __init__(self, start): self.x, self.y start self.dir (1, 0) self.intent (1, 0) self.radius 6 def update(self, keys, dt, grid): if keys[pygame.K_LEFT]: self.intent (-1, 0) elif keys[pygame.K_RIGHT]: self.intent (1, 0) elif keys[pygame.K_UP]: self.intent (0, -1) elif keys[pygame.K_DOWN]: self.intent (0, 1) # 意图方向能走才转否则保持当前方向 ix self.x self.intent[0] * Player.SPEED * dt iy self.y self.intent[1] * Player.SPEED * dt if can_move(grid, ix, iy, self.radius): self.dir self.intent # 按当前方向移动 nx self.x self.dir[0] * Player.SPEED * dt ny self.y self.dir[1] * Player.SPEED * dt if can_move(grid, nx, ny, self.radius): self.x, self.y nx, ny else: # 顶墙时对齐到格心避免卡在缝里抖动 self.x round(self.x / TILE_SIZE) * TILE_SIZE self.y round(self.y / TILE_SIZE) * TILE_SIZE def center_tile(self): return int(self.x // TILE_SIZE), int(self.y // TILE_SIZE)这里有个细节值得说明顶墙对齐。如果省略这段角色顶着墙时坐标会停在非网格位置下一帧继续朝墙推就会产生高频抖动视觉上像在抽搐。对齐到格心后推墙就变成平滑的贴墙滑行。SPEED 120配合TILE_SIZE 16每秒过 7.5 格节奏和原版接近。这个值后面要挪到配置文件里因为只要改TILE_SIZE速度就得跟着调。3.2 幽灵 AI追逐、散开、受惊三态切换与方向选择幽灵 AI 是 pacman 项目里最容易被当成玄学的部分其实核心就一个状态机加一个方向选择函数。原版四只幽灵有各自的性格训练版先统一用一套规则SCATTER 回角落CHASE 追玩家FRIGHTENED 随机乱走。class Ghost: def __init__(self, start, name, speed100): self.x, self.y start self.name name self.speed speed self.dir (-1, 0) self.radius 6 self.mode SCATTER def update(self, player, dt, grid): # 几乎到达格心时才重新选向 gx round(self.x / TILE_SIZE) gy round(self.y / TILE_SIZE) if abs(self.x - gx * TILE_SIZE) 1 and abs(self.y - gy * TILE_SIZE) 1: target self._target(player) self.dir pick_direction(grid, gx, gy, self.dir, target) nx self.x self.dir[0] * self.speed * dt ny self.y self.dir[1] * self.speed * dt if can_move(grid, nx, ny, self.radius): self.x, self.y nx, ny def _target(self, player): if self.mode CHASE: return player.center_tile() if self.mode SCATTER: return (0, 0) return None # FRIGHTENED 不走目标点import random def pick_direction(grid, gx, gy, prev_dir, target): candidates [] for dx, dy in ((1, 0), (-1, 0), (0, 1), (0, -1)): if (dx, dy) (-prev_dir[0], -prev_dir[1]): continue # 禁止原地掉头 nx, ny gx dx, gy dy if not is_wall(grid, nx, ny): if target is None: candidates.append((nx, ny, 0)) else: dist abs(nx - target[0]) abs(ny - target[1]) candidates.append((nx, ny, dist)) if not candidates: return (-prev_dir[0], -prev_dir[1]) # 死路只能掉头 if target is None: return random.choice(candidates)[:2] return min(candidates, keylambda c: c[2])[:2]方向选择只在幽灵接近格心时触发两次选向之间保持直线这比每帧都选要稳定得多。选向时先排除回头路否则四选一里永远包含来路幽灵会在十字路口反复横跳。SCATTER 阶段的目标是左上角训练版够用不需要做原版复杂的四角分配。这里用的是曼哈顿距离直观、代码少训练版完全够用。如果以后要上真实迷宫可以换 BFS 算最短路径但那是后话。幽灵速度 100玩家 120FRIGHTENED 时幽灵降到 70这个数值关系决定了游戏难度幽灵太快玩家必死太慢又没追逐感。这些参数最终都要进配置层见第 5 章。3.3 豆子结算与通关判定用网格中心判碰撞不用 Rect 碰撞豆子画在格子中心玩家沿着网格走所以最稳的判定是查玩家中心所在的格子而不是用 Rect 做碰撞。Rect 碰撞要处理半格重叠反而容易漏检或重复计分。def eat_dot(grid, player, score, remaining): tx, ty player.center_tile() tile grid[ty][tx] if tile Tile.DOT: score 10 remaining - 1 grid[ty][tx] Tile.EMPTY elif tile Tile.PELLET: score 50 remaining - 1 grid[ty][tx] Tile.EMPTY # 触发幽灵 FRIGHTENED 的逻辑由状态机上层负责 return score, remaining豆子被吃掉后直接把格子改成 EMPTY地图数据就是游戏状态不需要再维护一张豆子 sprite 列表。普通豆 10 分能量豆 50 分remaining归零就通关。这个函数里我只处理了计分和格子更新FRIGHTENED 的触发放在上层因为那是全局状态切换不能耦合在吃豆逻辑里。4. pacman 项目常见踩坑记录5 个翻车点与排查顺序这一章是血泪经验。pacman 项目的逻辑不复杂翻车基本都翻在细节上而且这些坑有个共同特点现象看起来像随机 bug查下去全是逻辑问题。按下面的顺序排查能省下大量时间。4.1 幽灵在交叉口反复横跳现象幽灵走到十字路口后左右来回抖动或者在一个 T 字路口不停折返完全不追玩家。原因选向时没有排除回头路或者prev_dir的更新时机不对。常见错误是在pick_direction之后才更新prev_dir导致候选方向里永远包含刚才走过来的方向。另外如果幽灵不在格心选向、而是每帧选向也会在路口来回摆。解决选向开始时直接用当前方向作为prev_dir候选方向排除它的反方向代码里就是if (dx, dy) (-prev_dir[0], -prev_dir[1]): continue。只有在四个方向都不可走的死路才允许掉头。同时保证选向只在格心附近触发两次选向之间保持直线移动。4.2 角色顶墙抽搐或者卡进墙缝现象玩家推着墙走时一抖一抖偶尔半个身子插进墙里再走一步又弹出来。原因碰撞检测只用中心点中心没进墙但包围盒的角已经进墙了或者顶墙时没有把坐标对齐回网格坐标停在非格心位置下一帧继续推。解决用第 2 章的can_move把四个角都查一遍。顶墙时执行round(x / TILE_SIZE) * TILE_SIZE对齐到格心。另外把速度上限控制在半格以内防止高速移动时一次跨过薄墙。这几个改动一起做抽搐问题基本绝迹。4.3 换一台电脑速度差一倍现象同一个 pacman 项目在 60Hz 显示器上正常在 144Hz 上快得离谱笔记本降频后又明显变慢。原因位移代码写成了每帧固定像素比如x 2速度单位是“像素/帧”。帧率越高每秒移动距离越大。这跟 Pygame 无关纯属物理更新没用时间差。解决所有位移改成乘以dt速度单位统一成“像素/秒”。注意clock.tick(60)返回毫秒先除以 1000 再乘否则速度会大 1000 倍。主循环里写成dt clock.tick(60) / 1000.0玩家和幽灵的更新函数都接收这个dt。4.4 能量豆效果结束幽灵不会恢复正常追踪现象吃完能量豆幽灵变蓝8 秒后变回红色开始追但追了几秒又变蓝或者直接进入错误的 SCATTER 模式。原因FRIGHTENED 结束时直接写回 CHASE但“打断前的模式”被覆盖了或者全局模式计时器在 FRIGHTENED 期间继续走把 CHASE 的剩余时间冲掉。最后结果就是状态机上下文全乱了。解决状态机里单独存一个pre_frightened变量FRIGHTENED 开始时保存当前模式结束时恢复它。FRIGHTENED 用自己的独立计时器不要和 SCATTER/CHASE 的全局节奏共用同一个变量。验证方法很简单吃豆后打日志确认 8 秒后进入的是 CHASE 而不是别的模式。4.5 双人协作时地图和常量互相覆盖现象git merge 时map.txt和config.py冲突一个人改了角色速度另一个人改了豆子数量合并后有一方被静默覆盖。原因地图、速度、分数这些数据散落在同一个文件里两个人改同一片区域。这种冲突不是哪行代码写错了而是文件边界没划清楚。解决把项目拆成map.txt、config.py、entities.py、main.py约定地图和配置归一个人管逻辑另一个人管。merge 时再小的冲突也要打开上下文看一遍。还有一条协作习惯不要用final_final_map.txt这种命名方式给文件留后悔药版本管理交给 git。5. 收一个配置层把幽灵节奏调成可维护的参数并自动验证5.1 用配置字典收拢速度和模式时长做到这里项目已经能玩了但调难度还是很痛苦改一次幽灵速度要翻三四个文件。最舒服的收尾是把所有手感相关参数抽到一个配置字典里实体类只认配置不认散落的常量。CONFIG { tile_size: 16, player_speed: 120, ghost_speed: 100, frightened_speed: 70, frightened_time: 8.0, mode_sequence: [(SCATTER, 7.0), (CHASE, 20.0), (SCATTER, 7.0), (CHASE, 20.0)], scores: {dot: 10, pellet: 50}, } player Player(start, speedCONFIG[player_speed]) ghost Ghost(start, speedCONFIG[ghost_speed])配置项默认值影响player_speed120玩家手感需要略大于普通幽灵ghost_speed100普通追逐速度frightened_speed70受惊幽灵减速给玩家反杀窗口frightened_time8.0受惊持续时间mode_sequence见代码全局 SCATTER/CHASE 切换节奏mode_sequence用列表存多个阶段调节奏时不用改逻辑只改列表长度和数值。这样双人协作时一个人调配置另一个人调逻辑互不干扰。5.2 自动回放日志让 AI 节奏可验证配置抽出来后难点变成“怎么证明配置生效了”。我一般写一个自动回放脚本让玩家沿固定路径自动移动不接键盘同时打印每只幽灵的状态日志。def log_state(t, ghosts): for g in ghosts: print(ft{t:.1f} ghost{g.name} mode{g.mode} pos{g.x:.0f},{g.y:.0f})跑 60 秒把日志和时间轴对照检查 SCATTER/CHASE 的切换点是否和mode_sequence一致。FRIGHTENED 一定要单独验证吃了能量豆后确认 8 秒结束回到 CHASE而不是回到 SCATTER。这一步做完幽灵 AI 就不再是黑匣子任何一次改动都能被日志和断言兜住。我最开始在 pacman 项目里也是先写逻辑后补配置结果调一次难度改十几个常量翻了一次车之后才把配置全部抽出来。后来再调 AI 节奏就只改 JSON跑一遍日志就能确认结果。希望帮到你。本文还有配套的精品资源点击获取
返回列表