☰
Pacman复刻项目实战:字符地图、游戏循环与Ghost AI完整拆解
2026/10/17 16:06:26 网站建设 项目流程

简介:由Andrei和Marius完成的Pacman项目Java实现,是一份适合Java学习者与游戏开发入门者研究的完整示例代码;资源面向需要理解经典游戏逻辑、GUI构建与基础AI算法的人群,帮助读者将面向对象编程知识应用到实际项目中,且代码组织清晰、接口划分明确。压缩包共12个文件,包含5个Java源文件、3个class编译文件,以及README说明文档和项目配置文件,整体仅12KB,源码与编译输出目录分离,结构紧凑、便于快速查看核心代码。目前已有152人学习下载。通过阅读源码可重点掌握Swing/JavaFX界面搭建、游戏循环与碰撞检测、鬼魂移动规则、事件监听及多线程协作等关键知识点;配合README与项目配置可快速还原Eclipse工程结构,适合课后实践、课程设计或自学参考。该项目代码量不大,但模块划分清晰,从窗口搭建到角色控制均有覆盖,是理解Java游戏开发流程的优质样例。

1. pacman-andreimarius 这个项目:双人协作的 Pacman 为什么值得拆开做

这个叫 pacman-andreimarius 的项目,本质上是一个经典的 Pacman(吃豆人)复刻项目,两位合作者把地图、幽灵 AI、碰撞、得分和管理器全部揉进了一个可运行的桌面程序里。它看起来像个练手小游戏,但真正动手做一遍就会明白:幽灵的行为模式、路口转向决策、状态切换时机、死后重开的数据复位,每一个点单独拉出来都是一道不小的工程题。对刚入门的人,它能一次性练到游戏循环、网格碰撞、状态机三块硬骨头;对熟手,它又是一个验证 AI 调度逻辑的绝佳试验台。这篇文章按我平时做这类项目的顺序——先地图,再游戏循环,再幽灵 AI,最后是踩坑和验证——把它完整拆开讲清楚。

2. 先立地图和渲染:用字符网格把迷宫变成代码

2.1 为什么用字符网格而不是 Tiled 地图编辑器

做 Pacman 的第一件事是让迷宫先跑起来,这决定了后面所有碰撞检测和寻路代码的复杂度。常见做法有两种:一种是引入地图编辑器,把迷宫画成图片或 JSON 瓦片数据;另一种是直接用字符画网格,每行字符串代表一层迷宫,字符#代表墙、.代表豆子、o代表能量豆、P是玩家出生点、G是幽灵出生点。

我强烈建议 P 系列项目用字符网格起步。原因有三个:第一,地图可读性极强,打开一个文本文件就能确认迷宫长什么样,不需要额外开编辑器预览;第二,碰撞检测写起来极其直接,判断某个网格是不是墙,只需要一次二维数组查询;第三,AI 寻路时天然就在网格上做,不用把像素坐标再换算成逻辑坐标。原版 Pacman 的迷宫本质就是 28×31 的网格,用字符画反而是最贴近原始设计的选择。

这里要注意一个细节:手绘字符地图时很容易出现行宽不一致,比如某行少了一个字符,这会导致解析后索引错位。所以在加载地图时,我习惯按最长行补齐宽度,而不是直接假定所有行相等。这个处理能省掉后面一大半的「奇怪的穿墙越界」问题。

2.2 地图解析、碰撞检测与渲染的最小实现

下面这段代码是一个最小可运行的地图模块,包含加载、碰撞查询和渲染三个函数。地图用三引号字符串直接贴在代码里,便于随时改。

import pygame # 字符图例:# 墙;. 豆子;o 能量豆;P 玩家出生点;G 幽灵出生点 MAP_RAW = """ ############# #...........# #.####.###.## #o..#...#...# #.#.#.#####.# #.#.........# #.###.#####.# #.#...#...#.# #.####.#.#.## #......#....# #.####.#.#### #.#.........# #.########### #...........# ############# """ def load_map(raw): lines = [l for l in raw.strip("\n").splitlines()] width = max(len(l) for l in lines) # 按最长行补齐,防止手绘地图行宽不一 return [list(l.ljust(width)) for l in lines] def is_wall(grid, col, row): if col < 0 or row < 0 or row >= len(grid) or col >= len(grid[0]): return True # 越界一律当墙处理 return grid[row][col] == "#" def draw_grid(screen, grid, cell_size): for row, line in enumerate(grid): for col, ch in enumerate(line): rect = pygame.Rect(col * cell_size, row * cell_size, cell_size, cell_size) if ch == "#": pygame.draw.rect(screen, (33, 33, 200), rect) elif ch == ".": pygame.draw.circle(screen, (255, 200, 100), rect.center, 3) elif ch == "o": pygame.draw.circle(screen, (255, 200, 100), rect.center, 8)

这段代码的逻辑分三层说。load_map先按换行切出每一行,再用max找出最长行的宽度,ljust(width)把短行补空格,这样后面遍历网格就不会因某一行短一截而索引越界。is_wall是碰撞检测的核心,任何越界坐标都直接返回 True,好处是角色走到地图边缘时不会因为数组越界而崩掉,而是被当作撞墙处理。draw_grid只是把逻辑网格画成屏幕上的色块:墙是蓝色方块,豆子是黄色小圆点,能量豆是稍大的圆。cell_size 是每个网格的像素边长,窗口尺寸按cols * cell_size和rows * cell_size计算即可。

实际调整时,cell_size一般在 16 到 24 像素之间。太小的格子让迷宫显得拥挤,眼睛容易疲劳;太大的格子会让窗口超出屏幕。另外,如果你想把地图换成原版迷宫,只需把MAP_RAW替换成 28 列的新字符画,同时保证#围住整个边界,解析和渲染代码一行都不用改。

3. 游戏循环与 Pacman 控制:移动手感从 delta time 开始

3.1 帧率、delta time 与网格对齐

地图跑起来之后,下一步是让 Pacman 能动起来。这里最常见的翻车点就是 movement speed 没有和帧率解耦——直接写x += speed,结果 60 帧和 144 帧的屏幕刷新下角色速度差出一倍多。正确做法是一律使用 delta time:每帧根据实际经过的时间来计算位移,而不是假设固定帧率。

另一个关键点是网格对齐。Pacman 的移动看似平滑,但本质上是在网格上滑行,角色需要在每个交叉点精确对齐到格子里,否则转角时会出现半格偏移,看起来像卡进了墙里。我的做法是维护一个像素坐标(px, py),每帧先判断是否已经到达网格交叉点,到达了就先用方向缓冲更新当前方向,然后强制对齐到网格中心,再正常移动。这样角色永远不会停在两个格子之间。

下面是一个 Pacman 类的移动更新核心代码:

class Pacman: def __init__(self, col, row, speed=8.0): self.col, self.row = col, row self.px = col * cell_size self.py = row * cell_size self.dir = (1, 0) # 当前方向,用 (dx, dy) 表示 self.buffed_dir = None # 方向缓冲,交叉点处应用 self.speed = speed # 单位:格/秒 def at_grid_point(self): # 判断是否足够接近网格中心,容差 0.1 像素 return (abs(self.px - self.col * cell_size) < 0.1 and abs(self.py - self.row * cell_size) < 0.1) def align_to_grid(self): self.px = self.col * cell_size self.py = self.row * cell_size def update(self, dt, grid): if self.at_grid_point(): # 应用方向缓冲:缓冲方向可行才生效 if self.buffed_dir and not is_wall_for_player(self.col + self.buffed_dir[0], self.row + self.buffed_dir[1], grid): self.dir = self.buffed_dir self.col = round(self.px / cell_size) self.row = round(self.py / cell_size) self.align_to_grid() self.buffed_dir = None # 按方向移动,speed 换算成像素 self.px += self.dir[0] * self.speed * cell_size * dt self.py += self.dir[1] * self.speed * cell_size * dt # 隧道:左右越界循环穿越 if self.px < 0: self.px = total_width - cell_size elif self.px >= total_width: self.px = 0

参数上最需要理解的是speed=8.0这个值,单位是「格/秒」。原版 Pacman 在正常模式下每秒大约走 8 个格子,能量豆生效期间会降到 5 格左右。cell_size * dt把格子速度换算成像素位移,这样无论你把 cell_size 改成 16 还是 24,角色手感是完全一致的,这是数据驱动配置的雏形。

at_grid_point的容差 0.1 像素是调出来的经验值。容差太大,角色会在距离交叉点还有一段距离时就提前转向,看起来像瞬移;容差太小,浮点误差会导致始终对齐不上,转向永远是延迟的。0.1 像素在 60 帧、速度 8 格/秒的配置下,一帧位移将近 2 像素,所以 0.1 的容差足够在到达交叉点的这一帧触发对齐逻辑。

3.2 方向缓冲与 180 度掉头规则

手感是 Pacman 最容易做得「僵」的地方,罪魁祸首通常是方向输入处理得太粗暴。如果只在玩家按键的瞬间判断能不能走,能走就转向,不能走就忽略,那么玩家在死胡同里连按两个方向键时,第二个键往往已经被吞掉了,操作反馈极其难受。

方向缓冲就是为这个场景设计的:玩家按键时不直接改方向,而是先把意图存进buffed_dir,等角色到达交叉点那一刻再应用。缓冲区里只保留最近一次按键。注意,方向缓冲只对「和当前方向垂直或相反」的键有意义;如果缓冲方向和当前方向相同,那本来就是直走,处理逻辑上可以不管。但有一个隐藏规则必须实现:当角色在单行隧道里时,按相反方向应该立即掉头,不需要等交叉点。原版 Pacman 就有这个特性,玩家按左、角色立刻回头,这是通关的基本操作逻辑,不能省略。

我在 3.1 的基础上补一个处理输入的方法:

def handle_input(self, key): key_map = { pygame.K_LEFT: (-1, 0), pygame.K_RIGHT: (1, 0), pygame.K_UP: (0, -1), pygame.K_DOWN: (0, 1), } if key not in key_map: return new_dir = key_map[key] # 180 度掉头:立即生效,不用等交叉点 if (new_dir[0] == -self.dir[0] and new_dir[1] == -self.dir[1]): self.dir = new_dir else: self.buffed_dir = new_dir

这里的关键决策是:掉头优先级最高,因为玩家在隧道里按反向键时,再等一个交叉点会让操作显得迟钝。而转向(90 度)才走缓冲,因为当前格子可能还没到交叉点,强行转向就会变成斜向穿墙。这个区分是玩过几百把 Pacman 之后的体感经验,值得在代码注释里写清楚,不然过两周再看代码会困惑为什么同一个按键有两种处理路径。

4. Ghost AI 从随机到四鬼性格:状态机与路口决策的实现

4.1 四只鬼的不同目标点算法

Ghost AI 是整个 Pacman 项目的灵魂,也是工作量最大的部分。最常见的错误是一上来就给所有鬼写同一套追踪逻辑,结果四只鬼排成一列走,毫无压迫感。原版的设计是四只鬼各有性格差异:红鬼 Blinky 直接追玩家;粉鬼 Pinky 瞄准玩家前方 4 格(经典 bug 是玩家朝上时目标点会变成朝左 4 格,这个 bug 后来被当作特性保留);青鬼 Inky 取玩家前方 2 格,再以红鬼位置为对称点取镜像;橙鬼 Clyde 离玩家超过 8 格时追踪,小于等于 8 格时回左下角巡航,给人一种「该追的时候追,该躲的时候躲」的感觉。

下表是四鬼目标点的计算规则,实现时可以直接照这个逻辑映射。

鬼名颜色目标点计算性格比喻
Blinky红直接取玩家当前所在网格贴身死咬
Pinky粉玩家前方 4 格的网格坐标跑位拦截
Inky青玩家前方 2 格 × 2 - 红鬼坐标,取镜像对称点协同包抄
Clyde橙距离玩家 >8 格时追踪;<=8 格时回巡航点左上角忽近忽远

这里有个技术细节值得多说一句:Pinky 的「前方」是按玩家当前方向计算的,但玩家在转向前的最后一帧方向可能已经不准确了。所以我在实现时取的是「玩家当前网格坐标 + 玩家当前方向向量 × 4」,而不是用玩家的像素位置计算,否则目标点会被像素偏移干扰。Inky 的镜像公式2 * ahead - blinky_pos也是一个网格向量运算,blinky_pos 必须取红鬼当前所在网格坐标,不能取像素坐标。

4.2 交叉口决策与 BFS 回巢

确定目标点之后,怎么移动又分两个流派:一种是在每个交叉口(当前格子有 3 个及以上可通行方向)重新选择方向,选离目标点曼哈顿距离最近的那个;另一种是每帧用 BFS 全图搜索真正的最短路径。前者计算量极小,行为接近原版,实现也简单;后者更准确,但要在每帧对每只鬼跑一次全图寻路,性能上不够优雅。

我一般会选交叉口决策而不是全图 BFS,理由是它更贴近原版鬼的行为——原版鬼也不做全局寻路,它们只在十字路口做局部贪心选择,这反而形成了一种「可预测的压迫感」。关键是不能让鬼 180 度掉头,否则它会在相邻两格之间来回横跳。决策逻辑如下:

DIRS = [(-1, 0), (1, 0), (0, -1), (0, 1)] def choose_dir(ghost, grid, target): # 可走方向:排除墙、排除反向 candidates = [] for dx, dy in DIRS: if (dx, dy) == (-ghost.dir[0], -ghost.dir[1]): continue # 禁止 180 度掉头 nx, ny = ghost.col + dx, ghost.row + dy if is_wall(grid, nx, ny): continue candidates.append((dx, dy)) if not candidates: return (-ghost.dir[0], -ghost.dir[1]) # 死胡同,只能掉头 # 选曼哈顿距离最短的方向;相等时按上>左>下>右优先级 def dist(d): nx, ny = ghost.col + d[0], ghost.row + d[1] return abs(nx - target[0]) + abs(ny - target[1]) min_d = min(dist(d) for d in candidates) best = [d for d in candidates if dist(d) == min_d] for d in best: # 优先级排序决定唯一方向 if d == (0, -1): return d # 上 for d in best: if d == (-1, 0): return d # 左 for d in best: if d == (1, 0): return d # 下 return best[0] # 默认右

这段逻辑里最容易被忽略的是相等距离时的优先级。原版 Pacman 在距离相同时有一个固定的偏向顺序,这个优先级直接决定了鬼在交叉口的走位,看起来像「性格」。如果你完全随机选一个,鬼会显得神经质,玩家没法预判。优先级表按上、左、下、右来排能让鬼偏好在竖直方向运动,更贴合原版观感。

Eyes 状态(鬼被吃掉后回巢)不能用这套局部贪心,因为鬼屋可能在远处,局部决策会困在死胡同。这里我单独用 BFS 求到鬼屋门口的最短路径,每帧重算一次即可,因为 Eyes 状态下场上只有一只鬼(或者少数几只),性能开销完全可接受。BFS 返回的是下一步的移动方向,鬼顺着走回巢后立即恢复正常状态和颜色。

4.3 scatter/chase/frightened 模式切换时间表

只要还停留在「四鬼永远追玩家」这个层面,游戏就缺少节奏变化。原版 Pacman 有一个固定的时间表:开局先进入 scatter(散开模式,每只鬼回自己的巡航角落),过几秒切到 chase(追踪),再过几秒又切回 scatter,如此反复。初始阶段 scatter 时间长,后期 chase 时间长,整体节奏越来越紧迫。这个时间表在项目里用一张配置表驱动最合适:

SCHEDULE = [ (7, "scatter"), (20, "chase"), (7, "scatter"), (20, "chase"), (5, "scatter"), (20, "chase"), (5, "scatter"), (999999, "chase"), # 之后一直追杀,不再散开 ]

SCHEDULE里每一项是一个二元组,第一个元素是这个模式持续的秒数,第二个是模式名。实现时用一个索引变量指向当前项,用累计计时器判断是否切换。切到frightened模式是另一条路径:玩家吃到能量豆时触发,持续 6 到 8 秒。注意 frightened 模式是「叠加」在 scatter/chase 之上的——原版中吃能量豆会打断当前模式,等 frightened 结束后回到之前的模式,而不是永远停留在 frightened。这个细节很容易搞错,很多人做成 frightened 结束之后直接永久随机游荡,游戏就失去了节奏。

状态切换时要顺带处理一个硬需求:鬼必须立刻反向。原版中能量豆生效瞬间所有鬼强制掉头,给玩家逃跑窗口,这个反向逻辑要在状态机切换函数里单独写,不能等到下一个交叉口才生效。

def switch_mode(self, new_mode, reverse_all): self.mode = new_mode if new_mode == "frightened": self.color = (33, 33, 255) # 蓝色 self.speed = 5.0 self.reverse = True # 能量豆生效,立即反向 elif new_mode == "eyes": self.speed = 10.0 # 回巢速度更快 self.reverse = True else: self.color = self.default_color self.speed = 7.5

参数上要说明的是速度值。原版中鬼的正常速度是 7.5 格/秒,frightened 时降到 5 格/秒左右,Eyes 回巢时是 10 格/秒,而 Pacman 正常是 8 格/秒。这个微小差距保证了玩家在没吃能量豆时会被追上,吃了能量豆后又能拉开距离,整个追逐的紧张感就建立在数字的细微差异上。速度配置放在状态机里维护是可行的,但更好的做法是抽到一个全局配置表里,后面第 6 章会展开。

5. Pacman 项目避坑指南:5 个最容易翻车的点

5.1 鬼在交叉口来回横跳

现象:鬼走到两个相邻格子之间后,不停左右摆动,卡在一个路口出不去。原因:鬼在每个交叉口重新选方向时没有排除「当前方向的反向」,曼哈顿距离计算又让回头路恰好是距离最短的选项,于是掉头再掉头。解决:在choose_dir里显式跳过-ghost.dir,死胡同里才允许掉头。另一个容易被忽略的点是:鬼在「幽灵屋」里时,鬼屋门口的两格宽通道也会被当作交叉口处理,这时同样要遵守禁止反向规则,否则鬼会在鬼屋门口来回踱步出不了门。我一般在实现时把「是否在鬼屋内」作为一个独立标志,鬼屋内只允许沿着出口方向移动。

5.2 穿墙和卡墙是同一种病

现象:角色有时能斜着穿过墙角的交叉点,或者走到墙边后卡住抖动。原因:移动方向按像素累计,但碰撞检测按网格判断,如果角色像素坐标没能及时对齐到网格中心,下一步的位移会带着它插进墙角半格,视觉上就是穿墙。解决:严格使用网格对齐逻辑,在at_grid_point判定成功的那个时刻强制对齐到网格中心,再进行方向切换;同时移动前要检测「目标格」是否可通行,而不是只靠当前格子判断。还有一个细节:Pacman 的碰撞盒应该比网格略小——比如网格 20 像素、角色画成半径 8 像素的圆——这样在视觉上更宽容,操作没那么容易产生「被墙吸住」的感觉。

5.3 帧率不稳导致速度漂移

现象:同一局游戏里,画面卡顿一下之后角色突然加速。原因:主循环里的位移量没有乘以 delta time,或者直接把pygame.time.delay(10)当成帧率控制器。解决:统一用dt = clock.tick(60) / 1000.0,所有速度都以「格/秒」为单位,位移量乘以cell_size * dt。特别注意:不要用pygame.time.get_ticks()的差值来算 dt 但忘记取毫秒转秒,这个单位错误导致的速度漂移非常隐蔽,连自动测试都测不出来。我一般会写一个speed_check的辅助函数:让角色沿直线走 10 秒,断言位移量等于speed * 10 * cell_size,误差超过 0.1 帧就报警。

5.4 状态切换瞬间鬼反向进墙

现象:能量豆生效时鬼丝滑掉头了,但 6 秒后 frightened 结束时,有些鬼直接冲向墙里,然后卡住。原因:frightened 结束的瞬间,鬼可能正处于两格之间的位置,目标点从「随机游荡」切回「玩家位置」,但鬼的当前方向还是随机方向,这个方向恰好撞墙。解决:所有模式切换函数里统一调用一次「对齐 + 重置方向」逻辑。切换到新模式的第一个更新帧,先align_to_grid(),再强制走choose_dir,绝不能沿用旧方向继续移动。这个顺序很关键,先对齐再选方向,否则选出来的方向会被旧位置干扰。

5.5 关卡重置漏复位,下一关全是蓝色鬼

现象:玩家死亡后重开本关,所有鬼还是蓝色的,或者有的鬼还在eyes状态直接瞬移到玩家身边。原因:重启关卡时,只重置了 Pacman 的位置和豆子,忘了把鬼的mode、color、speed、reverse全部复位。鬼实例是复用的,状态字段保留着上一关最后的模式。解决:给鬼写一个reset()方法,一次性把所有状态字段恢复到开局默认值,并在关卡管理器里统一调用。

def reset(self): self.col, self.row = self.home_col, self.home_row self.px = self.col * cell_size self.py = self.row * cell_size self.dir = (0, -1) self.mode = "scatter" self.color = self.default_color self.speed = 7.5 self.reverse = False self.timer = 0 self.schedule_index = 0

这个reset()函数也是吃豆人项目里最容易漏写的部分,因为反复测试一局之后,你往往只会盯着 Pacman 看,忽略了鬼的状态残留。观察方法很简单:故意在每一关内吃一个能量豆,杀掉一只鬼,然后立刻死亡重开本关,看鬼屋里的鬼是不是正确的默认颜色。如果所有鬼都是蓝色,基本就是漏复位了。

6. 进阶验证技巧:数据驱动配置与输入回放验收

做到这一步,项目已经能玩了,但离「交给别人看」还有一段距离。我的最后一个习惯是把所有数值从代码里抽出来,放进一个 JSON 配置,包括速度表、模式切换时间表、计分规则和颜色。这样调起参数来不用改代码、重新编译,直接改配置跑一把就行。

{ "speed": { "pacman": 8.0, "ghost": 7.5, "frightened": 5.0, "eyes": 10.0 }, "schedule": [ [7, "scatter"], [20, "chase"], [7, "scatter"], [20, "chase"], [5, "scatter"], [20, "chase"], [5, "scatter"], [999999, "chase"] ], "scores": { "dot": 10, "power": 50, "ghost": [200, 400, 800, 1600] } }

有了数据驱动,另一个值得做的验证工具是输入回放。写一个全局按键记录器,把每帧玩家按的键存成时间戳 + 按键编号,然后在关闭输入的状态下重放这些记录。回放跑出来的每一帧画面、分数和碰撞结果应该和真实对局完全一致。这个手段能帮助你定位那些「偶发性」问题——比如某一次穿墙、某一次鬼卡住,只要录下了当时的输入序列,就能无限次回放那一段,不用靠运气复现。

我最初调 Ghost AI 时,都是靠眼睛看,总觉得哪里不对,重启十几次也复现不了。后来花半小时写了这个回放工具,把一段「吃能量豆 + 连吃四鬼」的输入录下来回放,立刻发现第三只鬼在被吃掉前有半帧穿模。原因不是碰撞逻辑,而是鬼在 eyes 状态的瞬间还在用 frightened 速度走了一帧。从那以后我养成了习惯:任何游戏逻辑改动之后,先跑一遍旧回放,对比分数和位置数据是否一致,不一致说明改动引入了行为漂移。这套验证方法帮我省下的时间远超写工具本身,希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询