news 2026/10/8 3:41:32

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用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", encoding="utf-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 我一般给 6,TILE_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, speed=100): 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, key=lambda c: c[2])[:2]

方向选择只在幽灵接近格心时触发,两次选向之间保持直线,这比每帧都选要稳定得多。选向时先排除回头路,否则四选一里永远包含来路,幽灵会在十字路口反复横跳。SCATTER 阶段的目标是左上角,训练版够用,不需要做原版复杂的四角分配。

这里用的是曼哈顿距离,直观、代码少,训练版完全够用。如果以后要上真实迷宫,可以换 BFS 算最短路径,但那是后话。幽灵速度 100,玩家 120,FRIGHTENED 时幽灵降到 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, speed=CONFIG["player_speed"]) ghost = Ghost(start, speed=CONFIG["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(f"t={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,跑一遍日志就能确认结果。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/8 3:41:04

AI Coding Agent Workflows:从踩坑到拆坑的完整实践指南

如果你最近也在关注 AI coding&#xff0c;那你大概率绕不开“agent”这个词。我花了大半年时间折腾 AI coding agent workflows&#xff0c;也就是怎么让 AI 编程智能体能真正独立地把活干完——读代码、改文件、跑测试、看报错、再改&#xff0c;而不是每句话都要人盯着。今天…

作者头像 李华
网站建设 2026/10/8 3:41:00

敏捷团队任务认领制:从派活到自主协作的完整落地指南

1. 为什么"任务派发"是敏捷团队效率的第一杀手先讲一个我亲眼见过的场景。某个团队号称敏捷转型两年&#xff0c;每日站会开得比会议室预定还准时&#xff0c;看板上的贴纸五颜六色&#xff0c;燃尽图天天更新。但每次迭代规划会上&#xff0c;技术经理抱着一张Excel…

作者头像 李华
网站建设 2026/10/8 3:40:41

联想SR650装Win2012 R2认不到盘?530-8i驱动加载与注入全攻略

简介&#xff1a;联想SR650服务器配合530-8i RAID卡安装Windows Server 2012 R2时&#xff0c;常因系统安装介质缺少磁盘控制器驱动而无法识别硬盘&#xff0c;这份驱动包正是解决该场景的专用工具&#xff0c;适合需要现场装机的运维工程师和服务器管理员。压缩包共10个文件&a…

作者头像 李华
网站建设 2026/10/8 3:40:37

2024-2026多模态大模型研究全景:Fusion、Agent与World Model实战复盘

1. 多模态研究的版图为什么需要重新梳理过去两年&#xff0c;多模态大模型&#xff08;MLLM&#xff09;的论文数量几乎是以季度为单位翻倍。2024年初大家还在讨论“视觉指令微调怎么做”&#xff0c;到了2024年中&#xff0c;LLaVA、Qwen-VL、InternVL 这类工作已经把图文对齐…

作者头像 李华
网站建设 2026/10/8 3:40:07

MoE架构与AI辅助研发:Naive-N0.5-Flash工程实践解析

1. 从"用AI造AI"这个说法说起&#xff1a;Naive-N0.5-Flash到底在做什么第一次看到"用 AI 构建前沿 AI"这个描述&#xff0c;我的反应是&#xff1a;又是一个把"自动化"包装成"自我进化"的营销话术。但把 NaiveAI 这次开源的 Naive-N0…

作者头像 李华
网站建设 2026/10/8 3:40:00

C++跨语言调用全攻略:从C ABI到Python/JNI/PInvoke实战

做C开发这么多年&#xff0c;被问到最多的一个问题就是&#xff1a;“我把核心算法用C写完了&#xff0c;Python那边要调用&#xff0c;怎么办&#xff1f;” “跨语言调用C接口”这个话题&#xff0c;说难不难&#xff0c;说简单也真不简单。它本质上是让C这种带着沉重历史包袱…

作者头像 李华