简介该项目是一套用Python完整实现的经典大富翁Monopoly游戏设计源码面向Python学习者、游戏开发入门者以及需要参考完整项目结构进行课设或毕设的开发者。资源共26个文件解压后约80KB包含20个Python源文件、2个JSON配置、2个Excel工作簿、1个Git忽略文件及1个Markdown说明文档。Python源文件按模块化思路组织了玩家、地产、地图、事件与界面等核心逻辑JSON负责游戏规则与地图配置Excel则用于玩家得分与财务等数据的管理统计。项目同时配有单元测试用例、版本控制配置与完整说明文档目录结构清晰便于直接阅读、运行和二次扩展。通过阅读源码可系统理解回合制棋类游戏的建模思路、状态流转与数据持久化方式也能学习到如何用配置文件和外部数据表解耦游戏参数。目前已有871人学习下载适合希望从零掌握Python桌面游戏开发全流程的读者参考。1. 用 Python 写大富翁真正的难点不在骰子而在状态机如果你是想交一份课程设计、练手完整项目或者教学生理解 Python 语法怎么串成“能玩的东西”用 Python 开发一局大富翁游戏是个性价比极高的选择。这个标题里的“设计源码”四个字往往让人误以为重点是画界面实际上我见过的大多数半成品都死在同一个地方地图画得很漂亮结果轮到买地、入狱、抽事件卡时逻辑全散在 if 里改一处崩三处。反直觉的结论是——大富翁的核心不是骰子而是回合状态机谁掷骰、谁付钱、谁破产、谁进监狱每一步都是状态迁移。把它用纯 Python 写清楚界面只是顺手包一层。这篇文章面向两类人刚学完 Python 基础、想找一个能跑起来的完整项目的新手以及需要交源码和文档、不想返工的课程设计选手。2. 先搭引擎层目录结构、Board 棋盘类与 Player 状态机的落地代码2.1 为什么要先写纯逻辑层可测试、可 headless 跑界面只是壳我习惯把一个 Python 游戏项目先拆成game/纯逻辑和main.py入口界面两层而不是上来就写pygame.draw。原因很简单大富翁的规则多买地、过路费、入狱、事件卡、破产判定这些都需要反复调整。如果规则和画图代码耦合在一起每次调一个数字都要在一堆坐标和颜色代码里面找位置改完还得肉眼验证对不对。把逻辑层做成不依赖任何图形库的纯 Python 模块你就能在命令行里直接跑一整局用print看每一步的决策这就是 headless 模式。我一般会先保证python -m monopoly --headless能跑通再往上套界面。目录结构可以这样拆这个结构对课程设计和自用项目都够用monopoly/ ├── main.py # 入口负责 pygame 窗口和事件循环 ├── config/ │ └── map.json # 地图配置格子数据全部走 JSON ├── game/ │ ├── __init__.py │ ├── board.py # 棋盘类格子列表、移动、查询 │ ├── player.py # 玩家类资金、位置、入狱状态、道具库存 │ ├── turn.py # 回合状态机掷骰、结算、破产判定 │ └── events.py # 事件卡与道具定义 └── tests/ └── test_turn.py # 用 unittest 锁住核心规则关键点是把“数据”和“行为”分开格子数据放在 JSON 里玩家和回合逻辑放进类里。以后你想加新格子类型或把地图从 24 格改成 30 格不用碰代码逻辑只改 JSON。2.2 Board 棋盘类与 JSON 配置表用结构化数据定义地图而不是散落 if地图是环形结构在代码里最自然的表示就是“列表 取模”。Board 类只需要做三件事加载格子配置、按步数移动、按索引查格子。这样设计之后地图上的每个格子是什么类型、多少钱、过路费多少都可以用 JSON 描述代码不会出现几十行if idx 3这种写法。game/board.py的常见实现# game/board.py from dataclasses import dataclass, field from typing import List, Dict dataclass class Cell: idx: int # 格子编号从 0 开始 name: str # 格子名如“老街” cell_type: str # start / property / chance / tax / prison / rest price: int 0 # 地产买价非地产格子默认为 0 rent: int 0 # 基础过路费非地产格子默认为 0 owner: int -1 # 拥有者玩家 ID-1 表示无主 class Board: def __init__(self, cells: List[Dict]): self.cells [] for i, c in enumerate(cells): self.cells.append(Cell( idxi, namec[name], cell_typec[type], pricec.get(price, 0), rentc.get(rent, 0), )) self.size len(self.cells) def move(self, pos: int, step: int) - int: # 取模实现环形地图走完一圈回到 0 return (pos step) % self.size这里的核心逻辑是move的取模运算。大富翁的棋盘本质是循环链表(pos step) % size一句话就能处理“绕一圈回到起点”的情况比画成二维数组再判断边界要省事得多。Cell用dataclass定义是因为它只是一个数据容器不需要写一堆__init__样板代码。price和rent用c.get(price, 0)读取这样 JSON 里不是地产的格子可以不写这两个字段加载时自动补 0。对应的config/map.json片段字段名和上面的代码一一对应[ {name: 起点, type: start}, {name: 老街, type: property, price: 1200, rent: 200}, {name: 机会, type: chance}, {name: 北门, type: property, price: 2400, rent: 450}, {name: 税务, type: tax, price: 0, rent: 0, tax: 500} ]2.3 Player 与回合状态机支付、移动与破产判定的最小实现玩家类的设计要提前想清楚一件事资金运算用什么类型。我的答案是全程用int单位是“元”一分钱都不要用浮点数。原因后面在避坑章会展开这里先记住结论。Player 保存资金、位置、入狱状态和道具库存支付方法在钱不够时直接抛异常而不是把余额扣成负数这样破产判定只需要捕获异常。game/player.py# game/player.py from dataclasses import dataclass, field from typing import Dict dataclass class Player: pid: int name: str money: int 20000 # 初始资金 pos: int 0 # 当前格子索引 in_prison: bool False # 是否在监狱 prison_days: int 0 # 已待的天数最多 3 天 inventory: Dict[str, int] field(default_factorydict) def pay(self, amount: int) - None: # 钱不够直接抛异常由回合状态机统一处理破产 if amount self.money: raise ValueError(fplayer {self.pid} cannot afford {amount}) self.money - amount def earn(self, amount: int) - None: self.money amountpay方法是最容易写错的地方。如果把“钱不够”处理成self.money - amount然后返回 False后续所有逻辑都要背着“余额可能是负数”这个包袱排查起来很痛苦。抛异常的好处是调用方只需在回合结算处用try/except包一层余额归零或负数的状态不可能出现。回合状态机game/turn.py是整局游戏的心脏先看骨架# game/turn.py from typing import List, Optional from .board import Board from .player import Player class Turn: def __init__(self, players: List[Player], board: Board): self.players players self.board board self.current 0 def step(self) - bool: player self.players[self.current] ok self._take_turn(player) if not ok: # 玩家破产主循环会把它从列表移除 return False self.current (self.current 1) % len(self.players) return True def _take_turn(self, player: Player) - bool: # 真正的回合逻辑在第四章补全掷骰、移动、结算 raise NotImplementedError先把这个空壳跑通再往_take_turn里填骰子逻辑。这样做的价值在于你可以在没有界面、没有事件卡的情况下先验证“轮转”是否正常后面加功能只是往一个确定的位置里填代码而不是重构整个流程。3. 地图与地产数值生成随机地图并把房价、过路费调到能 20 分钟内打完3.1 格子类型与生成权重起点、地产、机会卡的合理配比大富翁好玩与否很大程度上取决于地图上不同类型格子的配比。地产太少整局游戏没有买卖感机会卡太多随机性盖过策略税和监狱全是负面体验也不行。我常用的配比是一圈 24 格“起点”1 个、“地产”12 个、“机会”3 个、“税务”2 个、“监狱”1 个、“休息站”5 个。休息站是纯路过格子不产生任何效果用来拉长地产之间的距离避免玩家刚买到地就被下一格的高阶地产收割。格子类型数量作用start起点1经过或停留时发工资property地产12可购买、可升级、收取过路费chance机会3抽事件卡随机奖励或惩罚tax税务2固定扣款prison监狱1停留即入狱或触发入狱条件rest休息站5空过一站调节节奏这个比例不是拍脑袋定的。地产占比一半保证玩家在大多数轮次里有“买地还是攒钱”的决策机会卡控制在一圈 3 张太多会让胜负完全看脸。你要做自己的地图时按住这个原则调就行地产 50% 左右纯惩罚格子不超过 20%。3.2 随机种子与地图可复现调试地图不要靠运气的三个参数地图随机生成时最容易踩的坑是“这次跑的地图和上次不一样Bug 无法复现”。所有的随机性都来自random模块而它默认从系统时间取种子导致每次运行结果都不一样。调试时这不是玄学是纯概率问题——你没法知道刚才那次是哪个地图触发的异常。解决方案是给生成器加一个seed参数测试时固定写死。我一般会在三个地方控制随机性地图洗牌、骰子、事件卡抽取。它们各自持有独立的random.Random实例而不是共用全局random否则地图和骰子的随机序列互相干扰日志里没法单点复现。下面这行代码就是这类项目里“后悔药”的雏形import random from typing import Optional, List, Dict # 每个随机源单独持有 Random 实例便于固定种子 map_rng random.Random(42) dice_rng random.Random() def set_global_seed(seed: Optional[int] None) - None: if seed is not None: map_rng.seed(seed) dice_rng.seed(seed 1) # 错开种子避免两个源序列完全相同全局只留一个set_global_seed入口命令行参数--seed 42透传进来测试全部固定种子。线上默认不传 seed每次开局都是新地图调试时传 seed复现率接近 100%。这一步看起来简单没做之前你在“黑匣子”里排错做了之后你手里才有可控的测试样本。3.3 模板地图生成代码24 格环形棋盘怎么铺生成地图时直接用random.shuffle打乱 24 个格子会导致起点不在 0 号位、监狱和机会卡挤在一起。常见的做法是“定向插入”先固定起点和监狱的位置其余格子按权重洗牌后填进剩余位置。# tools/gen_map.py import random import json from pathlib import Path from typing import List, Dict, Optional def build_template() - List[Dict]: # 地产用占位名后续可以在 JSON 里手工改名 return ( [{name: 起点, type: start}] [{name: f地块{i}, type: property, price: 1000 i * 400, rent: 180 i * 60} for i in range(12)] [{name: 机会, type: chance} for _ in range(3)] [{name: 税务, type: tax, tax: 500} for _ in range(2)] [{name: 监狱, type: prison}] [{name: 休息站, type: rest} for _ in range(5)] ) def shuffle_map(template: List[Dict], seed: Optional[int] None, start_idx: int 0, prison_idx: int 6) - List[Dict]: rng random.Random(seed) layout template[:] # 起点和监狱原地不动其余格子只在它们之间打乱 movable [i for i in range(len(layout)) if i not in (start_idx, prison_idx)] shuffled [layout[i] for i in movable] rng.shuffle(shuffled) for idx, card in zip(movable, shuffled): layout[idx] card return layout def save_map(layout: List[Dict], path: str config/map.json) - None: Path(path).parent.mkdir(parentsTrue, exist_okTrue) Path(path).write_text( json.dumps(layout, ensure_asciiFalse, indent2), encodingutf-8 )参数说明start_idx0让起点固定在 0 号位置玩家从 0 出发prison_idx6把监狱放在一圈的四分之一处这样玩家绕四圈左右就会碰到一次。shuffle_map只洗movable列表里的索引起点和监狱不参与洗牌。价格用1000 i * 400递增保证越靠后的地块越贵玩家走到地图后半段时手里的钱也攒起来了一些节奏是匹配的。跑python tools/gen_map.py生成 JSON 后直接交给第二章的Board加载。这时你再调整地块价格只需要改 JSON不需要动任何 Python 类。4. 事件卡、道具与 pygame 界面从洗牌抽卡到渲染一局可玩的游戏4.1 机会卡洗牌抽取random.shuffle 后 pop保证一局不重复机会卡和道具是让大富翁脱离“纯掷骰子走路”的关键。事件卡的实现最容易翻车的是“重复抽取”。如果每次都用random.choice同一张“前进三格”可能连续出现五次玩家体验很差。正确做法是开局时把卡牌列表shuffle一次之后每次pop取最后一张这样一副牌抽完之前不会重复。# game/events.py import random from typing import List, Dict, Optional class Deck: def __init__(self, cards: List[Dict], seed: Optional[int] None): self.cards cards[:] self.rng random.Random(seed) self.rng.shuffle(self.cards) def draw(self) - Dict: if not self.cards: raise RuntimeError(deck is empty) return self.cards.pop() def remaining(self) - int: return len(self.cards)说说cards cards[:]这行的用意它拷贝了一份列表避免外部修改原卡牌配置。pop从尾部取卡效率是 O(1)比pop(0)好。种子参数的用法和第 3 章一样——测试传固定值线上留空。 一副牌抽完后再重新shuffle一次进入下一轮这是标准做法如果你想让“好卡”和“坏卡”在局内均匀分布可以在洗牌前手动把卡牌分成两组每组内部洗牌后再交替合并比纯随机更稳。事件卡配置示例放进 JSON 或 Python 列表都行CHANCE_CARDS [ {text: 前进三步, effect: move, value: 3}, {text: 获得分红 800 元, effect: money, value: 800}, {text: 支付维修费 500 元, effect: money, value: -500}, {text: 退到起点, effect: move_to, value: 0}, ]4.2 双骰、入狱与道具回合状态机里最容易翻车的一段逻辑大富翁的标准骰子规则是掷出两个骰子点数和为移动步数掷出双骰可以再掷一次连续三次双骰直接入狱。在监狱里玩家每回合只能掷骰掷出双骰或交 500 元才能出狱最多关 3 回合。这段逻辑用注释写在_take_turn里很容易越写越乱我给你一版能直接跑的状态机# game/turn.py 核心片段 import random from typing import List, Optional class Turn: def __init__(self, players, board): self.players players self.board board self.current 0 self.dice_rng random.Random() self.double_streak 0 self.deck None # 事件卡牌初始化时传入 def _roll(self): d1 self.dice_rng.randint(1, 6) d2 self.dice_rng.randint(1, 6) return d1, d2 def _take_turn(self, player) - bool: # 玩家在监狱里的回合单独处理 if player.in_prison: return self._prison_turn(player) # 使用道具也消耗一回合这里先留入口 if self._use_item_if_any(player): return True d1, d2 self._roll() if d1 d2: self.double_streak 1 if self.double_streak 3: self._send_to_prison(player) return True else: self.double_streak 0 player.pos self.board.move(player.pos, d1 d2) cell self.board.cells[player.pos] if cell.cell_type property: return self._handle_property(player, cell) if cell.cell_type chance: card self.deck.draw() return self._apply_card(player, card) if cell.cell_type tax: try: player.pay(cell.tax) except ValueError: return False return True def _prison_turn(self, player) - bool: player.prison_days 1 d1, d2 self._roll() if d1 d2 or player.money 500: # 双骰或交 500 出狱 player.pay(500) if player.money 500 else None player.in_prison False player.prison_days 0 if player.prison_days 3: player.in_prison False player.prison_days 0 return True这里有几个参数和边界要说明。_prison_turn的写法是“至少待满一回合”进监狱当回合不掷骰。交 500 元出狱的条件写在player.money 500里钱不够就只能等双骰或待满 3 天。double_streak是回合状态机的成员变量不是局部变量因为连续双骰的计数跨回合。inventory里的道具在_use_item_if_any里消耗用完数量减一为 0 就忽略。道具系统我通常只做两个遥控骰子和路障。遥控骰子让你指定 1-6 的步数不走双骰判定路障可以让下一个走到某格的玩家停一次。道具的存在意义是给玩家一个“反随机”的策略手段但它不能太强否则每个人都囤着遥控骰子整个游戏的随机性就被打破了。4.3 pygame 界面层render 只读状态、事件循环只发指令、fps 限制界面层的职责是“把状态画出来”不是“在画图的代码里改游戏规则”。我见过最痛的问题就是有人在pygame.draw.rect后面直接改玩家资金结果同样的逻辑在 headless 模式下根本不存在界面一关游戏就变了个样。正确做法是界面层持有一个Turn实例每帧调用render(state)读取状态并绘制用户输入只转换成指令step、buy、use_item所有状态修改都发生在引擎层。# main.py import pygame from game.board import Board from game.player import Player from game.turn import Turn CELL_W, CELL_H 90, 70 # 单个格子宽高 PAD 20 # 边距 def cell_pos(idx: int, cols: int 6): # 格子坐标由行列推导格子再多也不会叠在一起 row idx // cols col idx % cols return PAD col * (CELL_W 8), PAD row * (CELL_H 8) class MonopolyUI: def __init__(self, board_cells, players): pygame.init() self.screen pygame.display.set_mode((960, 720)) pygame.display.set_caption(Python 大富翁) self.clock pygame.time.Clock() self.board Board(board_cells) self.players [Player(pidi, namef玩家{i1}) for i in range(players)] self.turn Turn(self.players, self.board) self.running True def render(self): self.screen.fill((245, 245, 245)) for cell in self.board.cells: x, y cell_pos(cell.idx) color (200, 200, 200) if cell.owner -1 else (255, 180, 80) pygame.draw.rect(self.screen, color, (x, y, CELL_W, CELL_H), 2) font pygame.font.SysFont(microsoftyahei, 16) text font.render(cell.name, True, (30, 30, 30)) self.screen.blit(text, (x 5, y 5)) pygame.display.flip() def run(self): while self.running: for event in pygame.event.get(): if event.type pygame.QUIT: self.running False elif event.type pygame.KEYDOWN: if event.key pygame.K_SPACE: self.turn.step() # 空格前进一步 elif event.key pygame.K_b: cell self.board.cells[self.players[self.turn.current].pos] self._buy_current(cell) # B买地 self.render() self.clock.tick(30) # 每秒 30 帧 pygame.quit()三个关键参数clock.tick(30)限制帧率在 30 fps没有这一行循环会以 CPU 能跑的最高速度空转风扇直接拉满cols6决定棋盘按一行 6 个格子排布24 格正好 4 行字号用 16 像素格子名在 90 像素宽的格子里刚好显示太长就截断。如果格子数变多cell_pos会按行列自动排开不会出现“横坐标太密集”导致的文字重叠——这个经验是从数据可视化图表里借来的固定间距 自动推导坐标比手工写绝对坐标省心太多。5. 常见问题避坑环境变量、中文编码、浮点金额与随机种子黑匣子5.1 python 环境变量没配好cmd 里 python 不是内部命令现象双击main.py窗口一闪就退在命令行输入python --version提示“不是内部或外部命令”。原因Windows 安装 Python 时没有勾选“Add Python to PATH”解释器路径没有写进环境变量。系统找不到python.exe脚本自然没法执行。解决重新运行安装包勾选“Add Python to PATH”或者手动把 Python 安装目录和Scripts子目录加进系统环境变量。加完后开新终端验证where python python --versionwhere python会列出所有能被系统识别的 Python 解释器路径看到两条以上时还要留意是不是装了多个版本。我遇到过 PATH 里同时有 Python 3.8 和 3.11结果pip install pygame装到了 3.8python main.py用的却是 3.11报ModuleNotFoundError。统一用py -3.11 main.py这种精确指定版本的方式能避开这类环境问题。5.2 JSON 中文读入乱码open 不指定 utf-8 在 Windows 上必踩现象加载map.json后格子名变成“馃挭”或者直接抛UnicodeDecodeError。原因Windows 上open()默认使用 GBK 编码读取文件而 JSON 文件保存的是 UTF-8 中文字节流对不上就报错。解决所有读写文件的地方强制指定encodingutf-8写入时再加上ensure_asciiFalse让中文在文件里就是中文而不是\uXXXX转义序列import json from pathlib import Path path Path(config/map.json) # 读必须指定 utf-8 data json.loads(path.read_text(encodingutf-8)) # 写ensure_asciiFalse 保留中文 path.write_text(json.dumps(data, ensure_asciiFalse, indent2), encodingutf-8)这个坑写代码时不显眼但换一台电脑跑就立刻翻车。宁可每个文件操作都写全参数也不要赌默认编码。5.3 资金出现 0.30000000000000004金额结算必须用 int现象玩家资金经过几次租金、税费结算后变成1289.3000000000000004判断余额是否足够时永远对不上。原因二进制浮点数无法精确表示 0.1、0.2、0.3 这些十进制小数。租金、税率、折扣一旦用float计算误差就累积进来了。解决全部金额用整数“元”折扣按整数百分比计算。比如“打八折”就是money * 80 // 100# 错误写法 rent 0.1 * property_price # 正确写法 rent property_price * 10 // 100 # 10% 过路费单位元用//整数除法保证结果还是整数。凡是涉及钱的字段类型一律标注为intJSON 配置里的price、rent、tax也全部是整数小数点不允许出现。5.4 复现不了 Bug随机种子就是调试界的黑匣子现象测试反馈“玩家 A 连续三次入狱”你手动跑同样的流程却一次都不能复现怀疑是内存问题或硬件问题。原因random模块默认从系统熵取种子每次运行的随机序列都不一样。地图布局、骰子点数、事件卡顺序全都不同Bug 本身是概率性的。解决程序启动时接受--seed参数测试固定传同一个值import argparse parser argparse.ArgumentParser() parser.add_argument(--seed, typeint, defaultNone) args parser.parse_args() # 固定种子后每次运行的地图和骰子序列完全一致 set_global_seed(args.seed)调试完再跑一次不带--seed的验证随机性恢复。这一步不做你永远在“黑匣子”里猜条件做了之后任何一次异常都能用当时的 seed 原样回放。5.5 pygame 窗口无响应、CPU 占满事件循环和帧率一个都不能省现象窗口能打开但拖不动、点关闭没反应任务管理器里 Python 进程占用 100% CPU。原因主循环里没有调用pygame.event.get()事件队列没被消费操作系统认为程序卡死同时循环没有clock.tick()帧率不受限制CPU 全力渲染。解决事件循环写完整帧率限制加上while running: for event in pygame.event.get(): if event.type pygame.QUIT: running False # 每帧都必须渲染并刷新 render() pygame.display.flip() clock.tick(60)注意pygame.event.get()是事件队列的出口你不主动拿队列会越积越多窗口管理消息就永远处理不了。这句话适用于所有 pygame 项目。6. 验证与进阶从“能玩”到“能交付”的 4 个检查点到这个阶段你的项目已经能在命令行和 pygame 界面下跑起来了。交付之前我习惯按下面四个检查点快速过一遍每个点都能在 5 分钟内完成但它们决定了项目是“能跑”还是“能交”。检查点做法通过标准规则正确性unittest覆盖破产、入狱、双骰断言不抛异常且状态符合预期整局可跑完python -m monopoly --headless --rounds 200 --seed 42200 回合内不卡死、不崩溃界面不卡顿观察 pygame 窗口 1 分钟CPU 占用稳定无残影随机可复现同一 seed 跑两局对比日志关键事件序列完全一致用unittest锁住核心规则是最值得先做的一条。比如入狱判定写一个只掷三次双骰的测试用例断言玩家状态变成in_prisonTrue# tests/test_turn.py import unittest from game.player import Player from game.turn import Turn class PrisonTest(unittest.TestCase): def test_three_doubles_to_prison(self): p Player(pid0, nameA) turn Turn([p], []) # 手动控制骰子只掷双骰 turn.dice_rng unittest.mock.Mock( randintunittest.mock.Mock(return_value3) ) turn._take_turn(p) self.assertTrue(p.in_prison)这类测试的价值在于以后你改关卡数值、调道具强度跑一遍测试就知道有没有把原有规则改坏。我最早做这个项目时先画好界面再写规则每次调租金都要在绘制代码里翻坐标后来改成引擎与视图分离才体会到什么是真正的省事。最后悔的是没有从一开始写测试导致后面每次改动都要手动跑两三局确认没引入新 Bug。这个方向值不值得做如果你是练 Python 基础或交课程设计值得。把规则引擎和界面拆开剩下的时间全都可以花在加玩法上而不是和坐标与浮点误差搏斗。希望帮到你。本文还有配套的精品资源点击获取 SEO 优化官网定制响应式建站教育培训建站