简介:基于Python开发的大富翁游戏设计源码,面向Python初学者、游戏开发爱好者及毕业设计学生,完整呈现桌面策略游戏从入口启动、地图构建、玩家交易到AI决策的整个闭环,适合用来学习模块化编程与项目组织。资源共26个文件,其中20个Python脚本构成游戏主体与测试用例,2个JSON文件用于配置管理,2个Excel工作簿承载地图与测试数据,另有.gitignore版本控制说明和txt文档,压缩包仅80KB,目录划分清晰。目前已有871人学习下载,作为Python游戏开发的入门级完整项目,难度适中,适合个人练手或教学案例。通过这份源码,读者可以拆解地图、地产、事件、玩家等独立模块,参考单元测试的写法,还能将其中GUI界面、数据读取和随机事件机制迁移到自己的课程设计或小游戏项目中,是兼顾学习与二次开发的实用素材。
1. 用Python写大富翁,先定回合状态机而不是先画棋盘
许多人拿到“基于Python开发的大富翁游戏设计源码”这个需求,第一反应是打开pygame画格子,等棋盘画完,才发现买地、收租、破产这些逻辑不知道该往哪里塞。反直觉的结论是:大富翁的复杂度根本不在图形界面,而在回合状态与资金流动。先用纯Python把地块、玩家、骰子、买卖流程写成不依赖界面的状态机,让一段终端循环能跑通,再考虑pygame还是命令行输入,都不需要改动引擎。这个思路适合做Python入门到进阶的项目练习、课程设计,也适合想把桌游规则抽象成可复用引擎的开发者。源码能否被其他人顺利读下去,关键不在代码行数,而在数据模型是否规整。
2. 大富翁游戏的数据模型:Python里用dataclass定义地块与玩家
2.1 地块用dataclass定义,别用字典散着写
常见做法是有人把地块写成{"name": "xxx", "price": 200}的字典,然后到处land["name"],代码一长就乱。作为一个要交付的源码项目,更稳的做法是用dataclass固定字段,让Python类型标注在开发期就拦住字段名拼写错误。
from dataclasses import dataclass from enum import Enum class LandType(Enum): START = "start" ESTATE = "estate" CHANCE = "chance" TAX = "tax" JAIL = "jail" PARKING = "parking" @dataclass class Land: index: int # 棋盘位置,从 0 开始 name: str # 地块显示名 land_type: LandType # 决定落点后的行为 price: int = 0 rent: int = 0 owner: int | None = None level: int = 0LandType用枚举而不是字符串,是为了后面的事件分发表能按类型做字典映射,也避免"estate"和"Estate"这类拼写错误。owner记录玩家编号而不是玩家对象,核心是避免对象引用让存档和拷贝变复杂。level是地块等级,用于后续升级加租,默认0。
下面这个表格说明每个字段的职责,读者对着源码找字段时可以快速对位:
| 字段 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| index | int | 无 | 棋盘上的位置,从 0 开始 |
| name | str | 无 | 地块显示名 |
| land_type | LandType | 无 | 地块类型,决定落点后的行为 |
| price | int | 0 | 购买价格,非地产类型为 0 |
| rent | int | 0 | 基础租金,升级后另算 |
| owner | int | None | None | 持有者玩家编号,None 表示无主 |
| level | int | 0 | 地产等级,升级会增加租金 |
2.2 棋盘初始化:用列表表达顺序,比硬编码变量更可读
棋盘本质上是一串有顺序的地块。我一般把地图数据放在独立的board_data.py模块里,主逻辑只通过len(BOARD)和索引访问地块。这样改地图不需要动引擎。
START_CASH = 10000 PASS_GO_BONUS = 200 BOARD = [ Land(0, "起点", LandType.START), Land(1, "胡同口", LandType.ESTATE, price=200, rent=50), Land(2, "机会卡", LandType.CHANCE), Land(3, "老城街", LandType.ESTATE, price=400, rent=90), Land(4, "交税", LandType.TAX), Land(5, "监狱", LandType.JAIL), Land(6, "中央公园", LandType.PARKING), ]这里只列了7个格子作示意,实际可以扩到24格或40格。列表本身的下标就是地块位置,玩家移动时用position % len(BOARD)计算,不需要额外的坐标字段。棋盘数据里不要再用dict包一层,否则取地块和遍历地块都要多写一层键名。
为什么不直接上JSON配置文件?如果项目不需要地图编辑器,Python模块本身就是最省事的配置格式:不需要解析,不容易写错引号,IDE还能补全字段提示。等将来需要让非程序员改地图,再把这份列表导出成JSON也不晚。
2.3 玩家对象:现金、位置和状态分开记
玩家字段比地块少,但有一个容易漏掉的:进监狱后的剩余回合。不记这个字段,后面写“监狱中掷出双数才能出狱”的逻辑就没有依据。
@dataclass class Player: pid: int # 玩家编号,从 0 开始 name: str cash: int = START_CASH position: int = 0 in_jail_turns: int = 0 bankrupt: bool = Falsecash用 int 而不是 float,因为游戏内资金没有小数,int 能避开浮点误差。position只记录当前索引,移动时通过取模转成合法坐标。in_jail_turns为0表示正常状态,大于0表示还要蹲几个回合。bankrupt是结果状态,一旦置 True 就跳过该玩家后续回合。
关于“玩家编号”再补一句:所有地块和交易记录都用pid而不是Player引用。如果Board和Game都持有Player,后续用copy.deepcopy做存档恢复时,引用图会复制出两份玩家对象,改其中一份另一份不同步。
提示:如果后续要支持“抵押地块换现金”,建议把抵押状态也放在
Land上,不要在Player里另开一份归属表,否则会出现两份状态不一致。
3. 大富翁回合引擎:骰子、移动、买地与事件处理
3.1 掷骰子与循环棋盘:经过起点判断别用“位置等于0”
回合引擎的第一步是掷骰子。基本动作是两个六面骰子相加,结果范围是2到12。移动逻辑不复杂,但“经过起点发钱”和“落在起点发钱”是两回事,很多实现只在position == 0时发钱,这漏掉了绕圈经过起点的情况。
import random def roll_dice() -> int: return random.randint(1, 6) + random.randint(1, 6) def move(player: Player, steps: int, board_size: int) -> None: target = player.position + steps passed_go = target // board_size > 0 # 跨过一圈才发钱 player.position = target % board_size if passed_go: player.cash += PASS_GO_BONUS print(f"{player.name} 经过起点,领到 {PASS_GO_BONUS}")关键是target // board_size。只要目标位置整除棋盘长度大于0,就说明这一步跨越了起点,哪怕最后停在起点也算经过。如果棋盘是24格、骰子最大12,target不会超过36,但写成通用形式后,以后加入加速道具或传送也能正确判断。board_size由调用者传入len(BOARD),引擎不直接依赖全局棋盘,方便换地图。
3.2 落到地块上的事件分发:字典映射替代一长串 if/elif
移动之后,要根据地块类型执行动作。常见写法是if land.type == "estate"一路elif下去,类型一多函数体就失控。更可维护的做法是建一个“类型到处理器”的映射表。
def handle_estate(player: Player, land: Land, game: "Game") -> None: if land.owner is None: if player.cash >= land.price: player.cash -= land.price land.owner = player.pid print(f"{player.name} 以 {land.price} 购买了 {land.name}") elif land.owner != player.pid: rent = land.rent * (1 + land.level) player.cash -= rent game.players[land.owner].cash += rent print(f"{player.name} 向玩家 {land.owner} 支付租金 {rent}") def handle_tax(player: Player, land: Land, game: "Game") -> None: player.cash -= land.price print(f"{player.name} 缴纳 {land.price}") HANDLERS = { LandType.ESTATE: handle_estate, LandType.TAX: handle_tax, LandType.CHANCE: draw_chance_card, LandType.JAIL: go_to_jail, LandType.START: lambda p, l, g: None, LandType.PARKING: lambda p, l, g: None, }HANDLERS的 value 统一是(Player, Land, Game) -> None的函数签名。新地块类型只需要写一个新函数并注册进字典,移动和回合逻辑完全不用改。game.players[land.owner]之所以能直接查,是因为owner里存的是整数 pid。rent = land.rent * (1 + land.level)是留给升级加租的公式,level 就是第2章字段表里的那个默认值0。
注意,handle_tax这里只做了扣款,没有处理破产。破产不能只在这种局部函数里判断,因为玩家可能在支付租金后现金为负,真正决定是否破产要放到更靠后的统一结算位置。
3.3 最小可跑的回合循环:用命令行试试引擎
有了数据结构和动作函数,就能搭一个不画界面的回合循环。这一步的价值在于:在接任何UI之前,先确认规则能完整跑起来。
class Game: def __init__(self, player_names: list[str], start_cash: int = START_CASH): self.board = BOARD self.players = [ Player(pid=i, name=name, cash=start_cash) for i, name in enumerate(player_names) ] self.current = 0 def next_turn(self) -> None: player = self.players[self.current] if player.bankrupt: self.current = (self.current + 1) % len(self.players) return steps = roll_dice() move(player, steps, len(self.board)) land = self.board[player.position] HANDLERS.get(land.land_type, lambda p, l, g: None)(player, land, self) self.current = (self.current + 1) % len(self.players) def alive_players(self) -> list[Player]: return [p for p in self.players if not p.bankrupt]Game只维护两个字段:棋盘和玩家列表。next_turn表示“一个玩家走完自己的一个回合”,函数里不看任何输入输出,所以终端循环和pygame循环都能直接调它。alive_players在判定胜负时用,存活玩家只剩1个时游戏结束。
接一个最简终端循环:
game = Game(["张", "李"]) while len(game.alive_players()) > 1: game.next_turn() print(f"胜利者:{game.alive_players()[0].name}")这个版本没有负资产结算,所以即使玩家现金变成负数游戏还会继续。生产级源码要把负资产处理和破产判定放在统一的回合收尾处,例如在next_turn末尾检查一次所有玩家的现金,低于0就触发破产结算。
3.4 玩家数量与资金参数的设置:先算再调
大富翁的参数不是随便填的。地图地价总和、初始资金、过路费之间要保持比例。常见做法是让初期玩家能买下2块便宜地产,又不至于囤积太多现金让后期失去压力。
| 参数 | 常见取值 | 说明 |
|---|---|---|
| 玩家数 | 2-4 | 超过4人时单局回合等待变长 |
| START_CASH | 10000 | 初始现金 |
| PASS_GO_BONUS | 200 | 经过起点奖励 |
| 地价范围 | 200-1000 | 按地块位置递增 |
| 租金/地价 | 20%-30% | 基础租金占购买价比例 |
如果初始资金给得太少,玩家连最便宜的地都买不起;给得太多,一局要跑很久。我一般先把地图里所有price列出来,让START_CASH约等于两块地价加三轮过起点奖励。这个参数关系写进源码注释里,比让使用者盲目调数字友好得多。
4. 用pygame把大富翁逻辑层渲染出来:事件循环与引擎解耦
4.1 为什么图形层用pygame而不是tkinter
上一章写出的Game.next_turn()不关心界面。图形化的诉求主要集中在棋盘绘制、玩家棋子移动、点击“掷骰子”按钮、显示资产。选型上常见做法是pygame,因为它的Surface.blit可以方便地画矩形、圆和图片,event.get()的事件循环也天然适合回合制游戏。tkinter做表单方便,但画棋盘需要维护Canvas对象,局部刷新要处理更多细节。如果不想装第三方库,终端版也能作为源码交付;但如果目标是“游戏感”,pygame更合适。
4.2 渲染层只读游戏状态:update/draw分离
pygame游戏循环一般是“处理事件、更新状态、绘制画面”。最容易出的问题是事件回调里直接写player.cash += 200,界面代码和规则纠缠不清。正确做法是让UI层只做两件事:把用户动作翻译成对Game的调用,以及重绘。
class GameApp: def __init__(self, game: Game): self.game = game def update(self, action: str) -> None: if action == "roll": self.game.next_turn() def draw(self, screen) -> None: screen.fill((0, 0, 0)) for land in self.game.board: x = (land.index % 8) * 80 y = (land.index // 8) * 80 pygame.draw.rect(screen, (80, 160, 80), (x, y, 76, 76), 2) # 画出玩家位置,圆点放在地块中心附近 for p in self.game.players: if not p.bankrupt: pygame.draw.circle(screen, (255, 220, 0), (p.position * 80 + 40, 300), 12)update接收字符串动作而不是具体按键,以后把“掷骰子”改成键盘空格还是手柄按键,都只需要在事件层转换。draw只读取game的公开字段,不修改任何状态。如果draw里出现赋值语句,就说明渲染和逻辑耦合了。
pygame.draw.rect的坐标由index % 8和index // 8计算,8是每行格子数。这个布局参数建议定义为COLS = 8,避免魔法数字到处都是。多个玩家站在同一格时会重叠,可以在绘制时按pid加一个3像素的小偏移,这里只是示意就不展开了。
4.3 给“掷骰子”按钮绑定事件:collidepoint判断点击
pygame没有内置按钮控件。常见做法是画一个矩形当按钮,用Rect.collidepoint判断点击是否落在矩形内。
ROLL_BTN = pygame.Rect(650, 80, 120, 50) def handle_event(event, app: GameApp): if event.type == pygame.MOUSEBUTTONDOWN and event.button == 1: if ROLL_BTN.collidepoint(event.pos): app.update("roll")collidepoint的event.pos是屏幕坐标。如果按钮不是放在最外层surface上,需要先折算成按钮父容器的相对坐标,否则点击判断会偏移。这是新手最容易卡住的地方:界面布局变了,碰撞坐标没跟着变,按钮点了没反应。
4.4 中文显示与字体路径:先测默认字体再换
pygame默认字体渲染中文会显示方框。常见做法是pygame.font.SysFont("microsoftyahei", 24),Windows上通常可用;Linux发行版字体路径各不相同,硬编码路径很容易在别的机器上报错。
try: font = pygame.font.SysFont("microsoftyahei", 24) except Exception: font = pygame.font.Font( "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", 24 )我一般把字体搜索路径写成列表,在多个常见路径里尝试加载,全部失败再退回默认字体并给出警告。这个细节在很多“开箱即用”的源码里被省略,结果是Windows能跑、Linux黑屏。另一个坑是字体对象必须在pygame.init()之后创建,否则拿不到SDL的字体系统。
4.5 绘制地块名称与资金:把UI文案和状态绑定
地块名称、价格属于静态信息,每次draw重画即可。每个格子按80像素宽计算,24号中文字体大约能放下3个汉字。用font.render渲染文字,再用font.size测量宽度做居中。
def draw_land_name(screen, land: Land, font, x: int, y: int) -> None: text = font.render(land.name, True, (255, 255, 255)) tw, _ = font.size(land.name) screen.blit(text, (x + (76 - tw) // 2, y + 56))(76 - tw) // 2是在76像素宽的格子里水平居中。玩家资金和位置属于动态信息,draw函数里直接读player.cash和player.position就行。渲染层一定要坚持“不缓存,全读取”,一旦把玩家的旧资金存到UI侧,就会出现画面和逻辑不一致的诡异问题。
5. 大富翁源码交付前的验证技巧:固定随机种子与自动对局
5.1 固定随机种子,让同一局可以复现
大富翁有很多随机环节:骰子、机会卡。排错时如果每次结果都不同,很难确认修复是否生效。最省事的办法是入口处接受一个--seed参数,测试时固定。
import argparse import random parser = argparse.ArgumentParser() parser.add_argument("--seed", type=int, default=None) args = parser.parse_args() if args.seed is not None: random.seed(args.seed)用同一个seed跑两遍,移动顺序、骰子结果完全相同。报告bug时带上seed,别人就能原样复现。注意random.seed只影响之后首次调用random模块的顺序,如果游戏运行中有人再次调用random.seed,复现就被打破,源码里不要随机重置。
5.2 自动对局:跑200局验证引擎不崩溃
图形界面会掩盖规则错误。源码交付前,写一个不依赖pygame的自动对局脚本,让AI玩家连续调用next_turn(),观察是否正常结束。
def auto_play(seed: int) -> int: random.seed(seed) game = Game(["A", "B", "C"]) for _ in range(5000): if len(game.alive_players()) <= 1: break game.next_turn() return len(game.alive_players()) for seed in range(200): survivors = auto_play(seed) if survivors not in (0, 1): print(f"seed {seed}: 异常,剩 {survivors} 名玩家")5000回合上限是为了防止死循环。如果200个seed都正常结束,说明引擎基本稳定。还可以在next_turn末尾加资金守恒检查:所有玩家现金加上地块原始价格的总和应该始终等于初始资金总和。机会卡里的加钱应该由银行资金池承担,而不是凭空生成,否则守恒检查会失败并暴露逻辑漏洞。
5.3 破产结算的三个常见坑
破产收尾是最容易被敷衍的地方。玩家现金不足以支付租金时,要先尝试出售地块补款,再决定是否破产。如果直接置bankrupt,地块上的owner会残留,下一局或读档时状态就脏了。另外,不要用copy.copy复制包含列表字段的玩家对象,持有地块列表会被多个玩家共享;要复制就写一个to_dict/from_dict做明确的存档转换。最后,把每一步操作写到logging.debug,排查时比看print强很多。
本文还有配套的精品资源,点击获取