简介:这款Python版警察抓小偷游戏源码包,适合Python初学者、游戏开发爱好者以及想研究多关卡逻辑设计的读者,通过完整可运行的游戏项目展示从界面搭建到游戏循环的实现思路。压缩包共50个文件,大小仅2.36MB,包含.py游戏源码、txt关卡与节点数据、png/jpg角色图像素材,以及bat启动脚本和exe辅助程序等,各类型文件分工明确,方便按模块查阅。已有1283人学习下载,项目内置13个难度级别,运行game.py即可从初级到高级逐级挑战,难度参数会直接影响警察追捕效率与小偷逃跑策略。源码覆盖类与对象、BFS路径搜索、随机数生成、用户输入处理等关键Python知识点,并附带虚拟环境配置,适合边运行边拆解游戏循环、状态更新和碰撞检测机制,对学习Python游戏编程有直接参考价值。
1. 警察抓小偷游戏:Python语法到可运行程序的最近路径
学完 Python 的循环、函数和类之后,最常见的问题往往是:这些语法到底能拼出什么?警察抓小偷游戏给了一个非常直接的答案:一张二维地图、两个角色、一套追与逃的判定,拼起来就是一个能在终端里运行、有明确输赢、能反复重开的完整程序。它不需要图形库,不需要网络请求,不需要数据库,只要装好 Python 环境、把源码保存成文件再执行,就能得到一个带多个难度级别的回合制小游戏。
对平时做运维、数据分析这类离游戏开发比较远的 IT 人来说,它也是一个理解状态机、坐标系和 AI 走位的最小成本样本。整个实现大约 150 行代码,核心不过五件事:坐标怎么表示、警察怎么追、小偷怎么动、回合怎么结算、难度怎么配置。下面按这个顺序逐一拆开,最后一章还给一套可自动运行的平衡性验证脚本,让“多难度级别”不只是听起来有梯度,而是能通过数据确认。
2. 移动建模与追捕判定:坐标系、警察走位与回合结算顺序
所有追逐类游戏的第一块地基是坐标系统。先把坐标定义清楚,后面警察走位、小偷偷跑、出口判定才不会被绕晕。这一章用可运行的代码定义地图、移动与判定,读完可以直接照着写进自己的源码。
2.1 先定坐标系:网格上的 [x, y] 表示法
用列表表示二维网格,每个坐标是[x, y],其中 x 是列、y 是行。小偷初始位置[0, 0]表示左上角,出口放在[size - 1, size - 1]表示右下角。这样选择的理由很直接:Python 列表天然支持下标访问,渲染地图时按 y 行 x 列做双层循环,逻辑顺序和视觉顺序一致,排错时不容易搞混方向。
| 角色/对象 | 坐标字段 | 初始位置 |
|---|---|---|
| 小偷 | thief | [0, 0] |
| 警察 | police | [0, size - 1] |
| 出口 | goal | [size - 1, size - 1] |
警察从右上角出发而不是地图中心,是为了避免开局直接堵在小偷的正前方。出口放在右下角,逼着玩家在“直线冲刺”和“绕开警察”之间做路线取舍。如果你的地图尺寸比较大,还可以把警察放在右下角、出口放在左下角,制造更长的追逐距离,这属于参数调整的范畴,后面第三章节会专门讲。
坐标一定要做越界裁剪。玩家输入无效方向时,如果不对坐标做 min/max 限制,小偷会走出地图边界,后续所有距离计算全部失真。这一步是这个游戏里最常见的隐性 bug:
nx = g.thief[0] + dx ny = g.thief[1] + dy g.thief[0] = max(0, min(g.size - 1, nx)) g.thief[1] = max(0, min(g.size - 1, ny))这里先把新坐标算进 nx 和 ny,再用max(0, min(size - 1, nx))把结果夹在合法区间内,最后写回坐标。注意顺序不能反过来:如果先裁剪再叠加方向增量,角色移动到边界时的一步会被吞掉,表现为“明明按了方向却不走”。用这个写法,无论玩家输入多少次越界指令,小偷始终留在地图内,警察的追捕计算永远拿到的都是有效坐标。
2.2 警察 AI:按切比雪夫距离走位并限制单回合步数
警察不需要寻路算法。地图最大 12×12,警察每回合最多移动 speed 格,采用切比雪夫距离作为逼近策略已经完全够用。切比雪夫距离允许斜线移动,很适合表达“警察在网格上直接朝目标逼近”的行为,而且计算时完全不需要开根号,避免浮点数误差。
def move_police(self): dx = self.thief[0] - self.police[0] dy = self.thief[1] - self.police[1] mx = max(-self.speed, min(self.speed, dx)) my = max(-self.speed, min(self.speed, dy)) self.police[0] += mx self.police[1] += mydx 是警察与小偷在列方向上的差,dy 是行方向上的差。max(-speed, min(speed, dx))的作用是把单轴逼近量限制在[-speed, speed]区间内,防止警察瞬间瞬移到小偷脸上。如果小偷在警察右上方恰好 2 格、speed 为 1,那么 mx 是 1、my 是 -1,警察这一回合只能追 1 列 1 行,速度感就出来了。
这里用 max 加 min 做截断,而不是用math.sqrt或atan计算方向之后再归一化,原因有两个:一是网格移动只关心水平和垂直方向,欧氏距离会产生“斜着走比横着走更快”的错误手感;二是浮点数运算后再取整容易出现坐标漂移,坏了不好查。如果你以后想给地图加障碍物,只需把move_police内部换成 A* 寻路,游戏主体逻辑完全不用动。
2.3 回合结算:先动小偷、再动警察、统一判胜负
回合顺序直接影响游戏手感。如果让警察先移动,玩家会产生“我还没操作就被抓”的挫败感,所以设计上必须玩家先行动、警察后追捕。
def step(self, cmd): if cmd in DIRS: dx, dy = DIRS[cmd] self.thief[0] = max(0, min(self.size - 1, self.thief[0] + dx)) self.thief[1] = max(0, min(self.size - 1, self.thief[1] + dy)) self.energy -= 1 self.move_police() self.steps += 1 if self.thief == self.police: self.caught = True elif self.thief == self.goal: self.escaped = True elif self.energy <= 0 or self.steps >= self.max_steps: self.caught = True判定条件的顺序就是胜负优先级:重合优先于到达出口。如果小偷和警察同时出现在出口格,游戏判为被捕而不是逃脱,这在逻辑上更严谨,防止玩家利用“踩点”实现无风险胜利。体力 energy 只在合法移动后减 1,警察的移动不消耗体力,这样压力全部落在玩家身上,符合“小偷在逃”的设定。
玩家输入不在 DIRS 里时,小偷不移动但警察照常逼近,回合照常累积。这个设计是刻意的:乱按方向等同于主动送人头,同时避免玩家用无效输入拖延回合。总回合数用>=判断,因为 steps 从 0 开始计数,max_steps=25表示玩家最多拥有 25 次有效操作机会,语义上更容易理解。
3. 多难度级别设计:参数矩阵、配置中心与动态平衡
难度不是单一数值,而是地图尺寸、警察速度、体力上限、最大回合四个参数组合出来的结果。单独把警察速度调快一点,或者只把地图变大,都会让难度曲线变得难以预测。这一章节先给参数矩阵,再落到配置代码,最后提供一个难度建议函数,让游戏能根据玩家的表现给出升降级提示。
3.1 难度参数矩阵:四个参数决定一局游戏的节奏
| 级别 | 地图尺寸 | 警察速度 | 小偷体力 | 最大回合 | 风格说明 |
|---|---|---|---|---|---|
| 简单 | 8×8 | 1 | 18 | 30 | 步数宽裕,可以边走边观察路线 |
| 普通 | 10×10 | 1 | 14 | 25 | 标准压制,失误两三次就危险 |
| 困难 | 12×12 | 2 | 10 | 20 | 警察速度翻倍,路径必须提前规划 |
四个参数互相制约。地图尺寸决定路径长度和观察负担,警察速度决定追击压力,体力限制无脑乱跑,最大回合给出一局的总时间预算。比如困难模式把警察速度从 1 提到 2 后,如果不同时缩小体力上限或降低最大回合数,玩家会发现警察永远追不上自己,因为 12×12 地图上小偷有足够空间绕圈。
反过来说,如果只把体力从 14 降到 8,地图仍然是 8×8,玩家很可能连出口位置都没摸清就被判定失败。难度设计的核心是“所有压力都在同一时间到达极限附近”,而不是某一项单独拉满。
3.2 用字典做配置中心:比类更直观,改参数不用翻代码
配置直接用模块级字典,不定义专门的 Difficulty 类。字典的键是难度名,值是由四个参数组成的子字典。create_game函数根据难度名取出参数构造游戏对象,新增难度只需要在 LEVELS 里加一行。
LEVELS = { "简单": {"size": 8, "speed": 1, "energy": 18, "max_steps": 30}, "普通": {"size": 10, "speed": 1, "energy": 14, "max_steps": 25}, "困难": {"size": 12, "speed": 2, "energy": 10, "max_steps": 20}, } def create_game(level_name): cfg = LEVELS[level_name] g = Game(cfg["size"], cfg["speed"], cfg["energy"], cfg["max_steps"]) return gcreate_game把“配置数据”和“对象构造”分成两层:配置集中在字典中管理,构造逻辑只认参数。大部分 Python 教程讲字典时都拿{"a": 1}举例子,这里字典键直接承担了配置名的作用,读写比用if/elif链清晰得多。外部玩家想调难度时,不需要看懂整个游戏逻辑,改一行"speed": 2就能感受到差别。
如果希望非程序员也能调整参数,可以把 LEVELS 导出成 JSON 文件:
python -c "import json; print(json.dumps(LEVELS))" > levels.json之后再写一段json.load的加载代码替代模块级字典。这样难度参数彻底从源码里剥离出来,游戏源码不做任何修改就能通过改 JSON 换难度,适合拿给朋友测试手感时快速迭代。
3.3 动态难度:按玩家剩余回合比例自动给出升降级提示
静态参数的手感未必适合所有玩家,可以让游戏在每局结束后根据使用步数占比给出难度建议。步数占比越低说明玩得越轻松,占比超过 0.8 说明玩家一直在死亡边缘挣扎。
def suggest_level(game): ratio = game.steps / game.max_steps if ratio < 0.4: return "上调一个难度" if ratio > 0.8: return "下调一个难度" return "难度匹配,保持当前级别"ratio 用steps / max_steps而不是绝对值,能反映节奏感:一局 30 回合上限在第 20 回合结束,占比 0.67,说明过程有惊无险;占比 0.35 说明警察基本没有形成有效压迫,玩家很轻松就摸到了出口。这个函数可以在play()的结束分支里调用,作为对局结束画面的一部分展示。
提示:动态难度只是建议,不要自动改写玩家选择的级别。自动升降级会让玩家失去对难度的掌控感,还容易让“挑战困难”变成“系统替我降低难度”,这就违背了多难度级别存在的意义。
4. 警察抓小偷完整源代码:主循环、终端渲染与运行方式
到这一章,所有零件可以组装成一个直接运行的单文件程序了。没有外部依赖,Python 3.8 及以上版本都能跑。在 VS Code 里配好 Python 环境后,把代码保存为catch.py,在终端执行python catch.py就能开始游戏。
4.1 运行方式与文件结构
单文件即可运行,目录结构只有一层:
catch_game/ └── catch.py运行命令:
python catch.py如果命令提示找不到 python,先确认 Python 是否安装到系统 PATH 中。Windows 用户在安装向导里勾选 Add Python to PATH,macOS 和 Linux 用户一般自带或通过包管理器安装。在 VS Code 里运行前,安装官方 Python 扩展,然后直接在终端执行上面的命令;也可以点击编辑器右上角的运行三角,但终端方式更能看清 ANSI 清屏效果。
4.2 完整源代码:catch.py
import os LEVELS = { "简单": {"size": 8, "speed": 1, "energy": 18, "max_steps": 30}, "普通": {"size": 10, "speed": 1, "energy": 14, "max_steps": 25}, "困难": {"size": 12, "speed": 2, "energy": 10, "max_steps": 20}, } DIRS = {"w": (0, -1), "s": (0, 1), "a": (-1, 0), "d": (1, 0)} class Game: def __init__(self, size, speed, energy, max_steps): self.size = size self.speed = speed self.energy = energy self.max_steps = max_steps self.steps = 0 self.thief = [0, 0] self.police = [0, size - 1] self.goal = [size - 1, size - 1] self.caught = False self.escaped = False def move_police(self): dx = self.thief[0] - self.police[0] dy = self.thief[1] - self.police[1] mx = max(-self.speed, min(self.speed, dx)) my = max(-self.speed, min(self.speed, dy)) self.police[0] += mx self.police[1] += my def step(self, cmd): if cmd in DIRS: dx, dy = DIRS[cmd] self.thief[0] = max(0, min(self.size - 1, self.thief[0] + dx)) self.thief[1] = max(0, min(self.size - 1, self.thief[1] + dy)) self.energy -= 1 self.move_police() self.steps += 1 if self.thief == self.police: self.caught = True elif self.thief == self.goal: self.escaped = True elif self.energy <= 0 or self.steps >= self.max_steps: self.caught = True def render(self): os.system("cls" if os.name == "nt" else "clear") for y in range(self.size): row = "" for x in range(self.size): p = [x, y] if p == self.police: row += "P " elif p == self.thief: row += "T " elif p == self.goal: row += "G " else: row += ". " print(row) print(f"回合 {self.steps}/{self.max_steps} 体力 {self.energy}") def status(self): if self.caught: return "被抓住,游戏结束" if self.escaped: return "逃脱成功!" return "进行中" def play(self): self.render() print("指令:w上 s下 a左 d右") while not self.caught and not self.escaped: cmd = input("> ").strip().lower() self.step(cmd) self.render() print(self.status()) if __name__ == "__main__": names = list(LEVELS.keys()) for i, name in enumerate(names, 1): print(f"{i}. {name}") ch = input("选择难度编号:") try: level = names[int(ch) - 1] except (ValueError, IndexError): level = "普通" Game(**LEVELS[level]).play()代码里Game(**LEVELS[level])把配置字典解包成构造参数,等价于Game(size=..., speed=..., energy=..., max_steps=...)。这样写少一行变量传参,也直接表达了“配置字典就是构造器参数来源”这一层关系。如果你不习惯 ** 解包,写成先取 cfg 再逐个传参的效果完全一样。
4.3 主循环与 input 解析的关键行为
play()里的 while 循环是整个游戏的主循环,每轮做四件事:等待玩家输入、调用step推进一回合、渲染新地图、打印当前状态。循环结束条件是被抓或逃脱,两个布尔开关都只会在step中被设置为 True,不会出现第三个位置改变状态,这是状态机最常见的实现约束。
输入处理用input("> ").strip().lower(),strip 去掉误敲的空格,lower 把大写 W 统一成小写 w,避免玩家开着大写锁按键后指令失效。if __name__ == "__main__"是 Python 源码的标准入口写法,作用是让文件既能被直接运行,也能被其他脚本 import 后复用 Game 类而不触发游戏循环,后面的自动对局脚本就要靠这一步。
4.4 运行报错排查表
| 错误现象 | 常见原因 | 处理方法 |
|---|---|---|
| python 不是内部或外部命令 | 未安装 Python 或未勾选 PATH | 重装时勾选 Add Python to PATH,安装后新开终端 |
| UnicodeDecodeError | 源码文件编码不是 UTF-8 | 在 VS Code 右下角把编码切换为 UTF-8 后保存 |
| IndentationError | 缩进与空格混用 | 统一用 4 空格,VS Code 里开启 renderWhitespace 查看 |
| 按键无反应但警察在走 | 输入了方向以外的按键 | 确认 cmd 在 DIRS 中,无效输入会照常消耗回合 |
前三个错误是在免费 Python 源码大全里被问得最多的问题,基本都是环境或编辑器问题而不是游戏逻辑问题。第四种情况其实是设计行为:按错键就相当于原地发呆一回合。
5. 难度验证与扩展:自动对局、日志回溯与加玩法
多难度级别做出来后,最需要验证的是“难度之间真的有梯度”而不是“只有数字不同”。手工玩很难客观评估,可以写一个模拟小偷的 AI 脚本自动对局,统计胜率。这个脚本独立于主程序运行,不污染游戏逻辑。
5.1 用自动对局脚本验证难度平衡
import random from catch import Game, DIRS, LEVELS def ai_move(g): dx = g.goal[0] - g.thief[0] dy = g.goal[1] - g.thief[1] moves = [] if dx > 0: moves.append("d") if dy > 0: moves.append("s") if dx < 0: moves.append("a") if dy < 0: moves.append("w") random.shuffle(moves) for cmd in moves: nx = g.thief[0] + DIRS[cmd][0] ny = g.thief[1] + DIRS[cmd][1] if max(abs(g.police[0] - nx), abs(g.police[1] - ny)) > 2: return cmd return moves[0] def win_rate(level, rounds=100): win = 0 for _ in range(rounds): g = Game(**LEVELS[level]) while not g.caught and not g.escaped: cmd = ai_move(g) g.step(cmd) win += 1 if g.escaped else 0 return win / rounds if __name__ == "__main__": for level in LEVELS: print(level, f"{win_rate(level):.0%}")ai_move的策略是尽量向右下方向走,同时避开警察切比雪夫距离 2 以内的格子。random.shuffle 让 AI 在选择方向时有一定随机性,避免同一难度每次都走同一条路线。win_rate跑 100 局后返回逃脱率,用这个数据可以验证难度梯度:简单模式通过率 70% 到 90%、普通 40% 到 60%、困难 10% 到 30%,是比较常见的参考区间。两个相邻级别通过率差距小于 10% 时,就需要回头调整参数矩阵。
5.2 逐回合日志回溯逻辑问题
给step方法追加一行日志,定位“警察为何原地不动”这类问题,比单步断点更快:
def step(self, cmd): ... self.move_police() self.steps += 1 after = (self.police[0], self.police[1]) print(f"[回合{self.steps}] 小偷{tuple(self.thief)} 警察{after} 体力{self.energy}")完整跑完一局后,警察每一回合的坐标变化都留在终端里。若连续多回合警察坐标不变,检查move_police中 mx、my 是否被 speed 截断为 0,常见原因是 speed 配成 0,或警察已经站在小偷同一格但判定逻辑走错了分支。
5.3 三个低成本扩展点
一是道具系统:在地图上随机生成体力药水,小偷踩到后 energy 加 3,警察踩到则重置追击距离,两种效果都能产生新的策略分支。二是障碍物:把地图里的部分坐标标记为不可通行,move_police在做坐标叠加前先检查目标格是否可走,不可走就只在当前回合原地等待。三是距离压迫:把体力消耗改成随警察距离变化,警察越近体力消耗越快,让紧张感有梯度而不是每回合固定扣 1。这三个扩展都不需要改变主循环结构,只改step或move_police的内部逻辑,一个文件仍然能装下所有功能。
本文还有配套的精品资源,点击获取