
简介由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, speed8.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参数上最需要理解的是speed8.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 速度走了一帧。从那以后我养成了习惯任何游戏逻辑改动之后先跑一遍旧回放对比分数和位置数据是否一致不一致说明改动引入了行为漂移。这套验证方法帮我省下的时间远超写工具本身希望帮到你。本文还有配套的精品资源点击获取