
简介这份基于Python开发的大富翁游戏设计源码面向游戏编程初学者、Python学习者和课程设计人群完整演示了如何用模块化方式实现棋盘地图、玩家移动、地产交易、事件触发等核心玩法既适合作为入门游戏开发的练手项目也能为类似回合制桌游的设计提供参考。资源共26个文件压缩包约80KB其中20个Python源文件承担游戏逻辑与界面交互2个JSON配置文件用于存储地图布局和游戏参数2个Excel工作簿可记录玩家得分与对局数据另有gitignore和说明文档便于工程管理与快速启动。目前已有871人学习或下载。通过阅读源码可以直观理解大富翁项目的模块划分、事件驱动机制和测试思路项目自带测试用例与运行脚本能帮助读者快速体验完整流程在此基础上进行二次开发和玩法扩展。1. 用Python写大富翁难点从来不在界面“基于Python开发的大富翁游戏设计源码”这句话容易让人误解以为门槛在绘制棋盘、设计棋子贴图、处理鼠标点击。写过一个完整回合制桌游的人都清楚界面只是最后一步最花时间的模块是棋盘数据建模、回合状态机和“什么时候该触发买地、付款收钱、进监狱”的规则判断。本文的目标读者不是刚学完Python基础语法的新手而是想把这个练手项目做成“能拿得出手的源码”的开发者。整篇从数据建模开始用Pygame做渲染层把规则引擎独立成模块最后落到几个可落地的改进方向AI对手、存档和网络对战。代码以Python 3.10为例第三方依赖只有pygame你还在为Python安装发愁的话先用官方安装包把解释器装好再执行一句pip install pygame即可。2. 大富翁源码的第一行设计棋盘地图与游戏对象的数据建模2.1 用dataclass定义地块、玩家与卡片从数据字段到行为抽象大富翁棋盘上的格子可以抽象为几个类型起点、地产、机会卡、税收、监狱。直接用字典写也能跑但格子一多就失控board[3][price]与board[3][name]还能忍等到要表达“这块地已被玩家A购买”字典里又要塞一个owner键随后每个格子都要做存在性判断。更稳妥的起点是使用Pythondataclass定义格子类型from dataclasses import dataclass from enum import Enum class CellType(Enum): GO 起点 PROPERTY 地产 CHANCE 机会 TAX 税收 PRISON 监狱 dataclass class Cell: index: int name: str cell_type: CellType price: int 0 rent: int 0 owner: int | None None用Enum约束格子类型能有效防止拼写错误owner的类型标注是int | None指明“无主”和“玩家索引”两种状态比裸用None做默认值更明确。纯字段的dataclass已经够建模但当你发现买地、付过路费这类动作散落在主循环里时说明行为该上收了。常见做法是把行为挂到模型类上例如给Cell增加一个can_afford()方法把判断逻辑收敛到模型层。2.1.1 玩家对象为什么要独立持有金币和位置玩家是另一核心对象除姓名、金币、当前位置外还要记录是否被暂停回合、持有地产列表、是否破产。这些字段写进裸字典也能跑问题是后续写存档时只能靠json.dumps硬序列化一个键名改动旧存档就废了。把玩家也建成dataclass并提供to_dict()方法能为后面存档模块留好接口dataclass class Player: idx: int name: str cash: int 1500 position: int 0 turns_locked: int 0 def to_dict(self) - dict: return { idx: self.idx, name: self.name, cash: self.cash, position: self.position, turns_locked: self.turns_locked, }字段turns_locked对应“进监狱或跳过回合”的效果。这里用整数而不是布尔值是因为有些规则会连续暂停两回合布尔表达不了这个语义。后续AI决策、UI高亮、存档恢复都直接读这些字段不需要再到别处临时拼状态。2.2 地图序列化为什么把棋盘数据放到独立文件比写死在代码中更合理标题里带了“源码”二字意味着别人会读你的代码地图至少要做到“一眼能看懂”。把几十个格子逐个写进main.py的board [...]是最常见反面教材想调整地图布局得在整个文件里搜索格子的定义改完还要担心缩进出问题。把地图单独拎出来放map_data.py是更工程化的做法# map_data.py from models import Cell, CellType def build_board(size: int 10) - list[Cell]: cells [] for i in range(size): if i 0: cells.append(Cell(indexi, name起点, cell_typeCellType.GO)) elif i in (3, 7): cells.append(Cell(indexi, namef机会{i}, cell_typeCellType.CHANCE)) elif i 5: cells.append(Cell(indexi, name税收, cell_typeCellType.TAX, price200)) elif i % 3 0: cells.append(Cell(indexi, namef社区{i}, cell_typeCellType.PROPERTY, price100 i * 10, rent20 i * 5)) else: cells.append(Cell(indexi, namef地块{i}, cell_typeCellType.PROPERTY, price80 i * 10, rent15 i * 4)) return cells这种写法有两个好处第一格子编号和类型一目了然第二后续做地图编辑器或从JSON加载时只需改build_board的返回值来源其他模块不动。size参数控制格子数量方便调试时只用8格地图验证逻辑不必每次跑完整棋盘。至于为什么不用JSON文件存地图——在“源码”这一场景下Python文件本身就是最好的配置格式有注释、有类型提示、导入即用。等游戏逻辑稳定后再拆成JSON给策划改也不迟但那属于后期优化没必要现在引入额外加载代码。运行前确认models.py里的Cell和CellType可以被map_data.py导入。常见做法是让三者同处一个包内或者使用相对导入from .models import Cell, CellType。2.3 回合状态机把玩家的行动权限收敛到一个while循环里回合制游戏的所有规则本质上是一个状态机WAIT_ROLL等待掷骰子MOVING控制棋子移动ACTION处理落地后判定BUY_DECISION决定是否买地。一个问题在于用Python写回合制时很容易写成几百行顺序if全堆在while running:里。更稳的做法是显式维护一个状态变量每个状态对应一个处理函数from enum import Enum import pygame class TurnPhase(Enum): WAIT_ROLL 1 MOVING 2 SETTLE 3 BUY_DECISION 4 def run_turn(player, board, clock): phase TurnPhase.WAIT_ROLL while phase ! TurnPhase.SETTLE: for event in pygame.event.get(): if event.type pygame.QUIT: return False if phase TurnPhase.WAIT_ROLL and pygame.key.get_pressed()[pygame.K_SPACE]: phase TurnPhase.MOVING steps roll_dice() elif phase TurnPhase.MOVING: move_player(player, steps, board) phase TurnPhase.SETTLE elif phase TurnPhase.SETTLE: cell board[player.position] if cell.cell_type CellType.PROPERTY and cell.owner is None: phase TurnPhase.BUY_DECISION else: resolve_cell(player, cell, players) phase TurnPhase.SETTLE # 原地结算下一轮再切回 break clock.tick(30) return True这段代码把回合拆成几个阶段每个阶段只做一件事等待输入、移动、结算、购买决策。状态切换用phase变量显式完成而不是用continue和break互相跳转。clock.tick(30)将帧率限制在30fps避免事件轮询空转吃满CPU。这里steps是骰子点数roll_dice()实现见后文。这个状态机看着简单实则是后续所有规则扩展的骨架加“进监狱”只需新增JAIL状态加“骰子相同再摇一次”只需在SETTLE状态里加计数器而不是在主循环里再塞一个if。所谓源码质量的差异往往就体现在状态边界是否清晰。3. 用Pygame把棋盘画出来渲染层与事件循环的最小实现3.1 Pygame窗口与表面坐标系统、色深与缩放Pygame的坐标系统以窗口左上角为原点x轴向右y轴向下单位是像素。棋盘如果是10×10每格60像素窗口宽度至少600×600再在右侧加信息栏启动时就要算好尺寸import pygame import sys pygame.init() CELL_SIZE 60 BOARD_N 10 INFO_W 220 SCREEN_W BOARD_N * CELL_SIZE INFO_W SCREEN_H BOARD_N * CELL_SIZE screen pygame.display.set_mode((SCREEN_W, SCREEN_H)) pygame.display.set_caption(Python 大富翁) font pygame.font.SysFont(microsoftyahei, 18)SysFont的字体名依赖操作系统Windows下microsoftyahei是通用方案macOS和Linux上要换成pingfang或notosanscjk否则中文字符显示成方框。Pygame默认32位色大多数情况无需手动处理把颜色定义为常量即可比如C_BG (240, 240, 240)。每个格子对应的矩形区域可以用pygame.Rect预先算好绘制时直接遍历避免每帧重复计算坐标。set_mode还有常用标志位如pygame.RESIZABLE允许窗口拉伸但棋盘格子尺寸会跟着变化调试复杂度会上升。源码里建议先固定窗口大小窗口缩放留到后面进阶再做。3.2 把棋盘格子画成带边框的矩形rect对象的预计算与碰撞检测坐标算完后就是把几十个格子画出来。一个很常见的误区是在while循环里每帧计算每个格子的坐标。更常规的做法是预计算grid_rects列表只算一次绘制时直接查表# render.py grid_rects [] for i in range(BOARD_N): left (i % 5) * CELL_SIZE 10 top (i // 5) * CELL_SIZE 10 rect pygame.Rect(left, top, CELL_SIZE - 4, CELL_SIZE - 4) grid_rects.append(rect) def draw_board(surface, board, grid_rects, font): for cell in board: rect grid_rects[cell.index] color (255, 255, 255) if cell.cell_type CellType.GO: color (144, 238, 144) elif cell.cell_type CellType.CHANCE: color (255, 215, 0) pygame.draw.rect(surface, color, rect) pygame.draw.rect(surface, (0, 0, 0), rect, 2) text font.render(cell.name, True, (0, 0, 0)) surface.blit(text, (rect.x 4, rect.y 4))draw_board只做渲染不包含任何游戏逻辑判断颜色完全由cell.cell_type决定。pygame.draw.rect的最后一个参数2是线条宽度传0表示填充整个矩形。碰撞检测用于处理鼠标点击遍历grid_rects调用rect.collidepoint(mouse_pos)第一个命中的格子就是要操作的格子。不把所有格子填成同一颜色是为了让棋盘可读调试时一眼就能看出机会格和地产格的分布。条件判断的粒度可以加深但不要在这里算价格或改玩家余额渲染层不该改动模型层的数据。3.3 事件循环与输入骰子的两种实现方式上文回合状态机用到pygame.key.get_pressed()判断空格键。真正的骰子逻辑还需要两步滚动动画和结果返回。简单做法是按下空格后在0.3秒内快速随机出1到6的整数显示到界面上同时为了模拟骰子翻滚在MOVING阶段让棋子按步数一格一格移动而不是瞬间跳到位import random def roll_dice() - int: for _ in range(10): temp random.randint(1, 6) print(f\r骰子: {temp}, end) pygame.time.wait(30) steps random.randint(1, 6) print(f\r骰子: {steps}) return steps def move_player(player, steps, board): for _ in range(steps): player.position (player.position 1) % len(board) draw_board(screen, board, grid_rects, font) pygame.display.flip() pygame.time.wait(120)两种实现方式的取舍直接random.randint(1, 6)只能给用户一个数字没有“掷骰子”的互动感上面的版本用pygame.time.wait制造动画停顿体验接近实体游戏。代价是move_player里调用了渲染函数变相耦合逻辑与UI。更干净的做法是把移动拆成生成器每步yield当前位置主循环负责绘制。考虑到这是“源码”用带小动画的同步版本更直观至少能明白为什么这里要wait(120)而不是wait(0)。move_player里的% len(board)是处理“经过终点回到起点”的标准取模写法加钱逻辑可以通过对比pre_pos和player.position来判断是否经过起点。4. 买地、过路费、破产与胜利把游戏规则跑通的逻辑引擎4.1 地块归属与过路费的计算时机规则引擎的核心是resolve_cell(player, cell)玩家落到某格后根据格子的类型和归属决定发生什么。最容易踩的坑是“先渲染再结算”UI上棋子刚停稳就立刻弹窗玩家根本看不清落在哪。正确的顺序是“移动动画结束 → 更新地块状态 → 触发结算 → 再弹窗”。结算函数可以这样组织def resolve_cell(player, cell, players): if cell.cell_type CellType.GO: player.cash 200 return 经过起点获得 200 元 if cell.cell_type CellType.PROPERTY: if cell.owner is None: return buy_prompt elif cell.owner player.idx: return owned_by_self else: owner players[cell.owner] fee cell.rent player.cash - fee owner.cash fee return f支付过路费 {fee} 给 {owner.name} if cell.cell_type CellType.TAX: player.cash - cell.price return f缴税 {cell.price} if cell.cell_type CellType.CHANCE: return activate_chance_card(player, players) return 过路费计算的时机是“玩家落点确定之后、下一轮掷骰子之前”。一旦漏掉这个时序会出现玩家没钱时仍然能继续行动两回合的bug。另一个细节这里没有判断余额是否足够钱扣成负数不代表立即判负。把“扣钱”和“判负”分开是回合制游戏逻辑引擎的必要设计——负数只是过渡状态下一轮开始前统一检查谁的钱小于等于0再判定破产。activate_chance_card是机会卡函数它需要访问所有玩家因为有些卡牌要求全体玩家交钱。如果只传player这类卡片逻辑就只能写在外部所以这里把players作为参数传进来。4.2 卡片与随机性的“公平”机会卡设计思路与命运卡相同每张卡包含文本描述和效果函数。源码里最容易出现的写法是random.choice(cards)后被一个巨大if-elif处理卡片一多便不可维护。更规整的方案是数据驱动# chance_cards.py import random CHANCE_DEFS [ {name: 银行分红, effect: cash_delta, value: 200}, {name: 入狱, effect: goto_jail, value: 0}, {name: 回到起点, effect: goto_pos, value: 0}, ] def activate_chance_card(player, players): card random.choice(CHANCE_DEFS) if card[effect] cash_delta: player.cash card[value] elif card[effect] goto_jail: player.position JAIL_INDEX player.turns_locked 1 elif card[effect] goto_pos: player.position card[value] return f机会卡{card[name]}写卡片要注意两点一是random.choice不排除上次抽过的卡连续抽到同一张卡是真实概率想避免需要加洗牌逻辑二是“入狱”依赖JAIL_INDEX常量该常量必须在map_data.py中定义否则模块间传参容易把监狱位置写死成魔法数字。给卡片函数返回字符串是让上层UI把它显示为日志不阻塞游戏进程。即使卡牌效果让玩家破产字符串只是描述真正的破产判定仍由主循环完成。4.3 破产判定与胜者的收敛条件游戏的收敛条件是“仅剩一名玩家未破产”。在每回合开始前检查一次def check_bankrupt(players): active [p for p in players if p.cash 0] if len(active) 1: return True, active[0].name return False, None这里有一个规则讨论余额小于0是直接判负还是允许过渡状态原版大富翁不允许负资产但代码实现会引入“欠款状态”的复杂度。简单做法是让玩家先欠着下一轮统一结算这样破产判定逻辑只有一处坏处是UI上可能短暂显示负数余额。active [p for p in players if p.cash 0]的边界要用 0还是 0也值得考虑按传统规则余额为0时游戏还能继续很多源码写成 0导致玩家输在余额恰好为0的瞬间。建议用 0更接近实体桌游体验。4.4 日志系统与可观测性调试回合制源码为什么比写它更花时间回合制游戏有一个天然痛点bug难以复现。同样的骰子序列可能到第20回合才触发一次性错误。源码里最好内置一个简单的日志队列每执行一次关键操作就追加一条最后可导出文件。以下方案可直接落地# logger.py from datetime import datetime class GameLog: def __init__(self): self.entries [] def log(self, msg: str): ts datetime.now().strftime(%H:%M:%S) self.entries.append(f[{ts}] {msg}) print(f[{ts}] {msg}) def save(self, pathgame_log.txt): with open(path, w, encodingutf-8) as f: f.write(\n.join(self.entries))日志不是可选组件。比如遇到“玩家A第17回合路过起点没拿到钱”只有日志才能对比移动前位置和移动后位置确认是不是取模逻辑算错了。encodingutf-8是Windows环境下避免中文乱码的关键参数漏掉会导致调试信息不可读。5. 从“能玩”到“好玩”AI对手、存档与网络对战的三个改进方向5.1 给电脑玩家一个“贪心但不蠢”的决策AI很多源码里的AI就是random.random() 0.5来决定买不买地这是最粗糙的做法。稍微提升观赏性可以给每块地算一个性价比分AI只买评分足够高的地块。核心参数是“回本周期的期望值”class GreedyAI: def __init__(self, reserve_ratio0.4): self.reserve_ratio reserve_ratio def decide_buy(self, player, cell) - bool: safe_cash player.cash * (1 - self.reserve_ratio) if player.cash cell.price: return False score cell.rent / cell.price return score 0.2 and safe_cash cell.price这个AI的逻辑分三步先看钱够不够不够一律不买再算每块钱能换到多少过路费比值低于0.2不买意味着回本慢最后确认买完后仍有足够现金应对未来过路费。reserve_ratio0.4表示最多只花60%现金保留40%作为安全垫。调高这个参数AI变得更保守调低则更激进。这套AI行为模式已经接近真人前期土地便宜时果断买后期现金紧张就谨慎。如果想继续提高难度可以加“优先购买同色地块组队”的逻辑但那属于进阶玩法。5.2 存档与读取用JSON做断点续玩存档要保存的信息可归为三类玩家状态、地块归属、当前回合数。前两类用to_dict()序列化第三类需要在主循环里维护一个turn_count变量import json def save_game(players, board, turn_count, pathsave.json): data { turn_count: turn_count, players: [p.to_dict() for p in players], board: [{index: c.index, owner: c.owner} for c in board], } with open(path, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2)ensure_asciiFalse很关键否则中文名字会被转成\u转义序列。indent2让JSON可读性好查错。地块归属只存index和owner价格等信息定义在map_data.py里重复保存反而会造成两份数据不一致。读取时更稳的做法是加校验比如检测cash字段是否合法并用try...except json.JSONDecodeError捕获损坏文件。这一步属于标准反序列化防御不建议省略。5.3 网络对战的协议选择与雷区Pygame本身不具备网络能力多人联机需要自己做协议设计。很多人第一反应是UDP因为快、无连接适合高频小包。但回合制是“低频小包”场景每回合只有一次动作延迟影响微乎其微。UDP丢包后不会自动重传一旦丢了一条“买地”消息两个玩家的状态就直接分叉。TCP的重传机制省去自己写ack的精力尤其适合回合制。这里推荐TCP而不是UDP不是所有场景都用UDP回合制是典型的TCP舒适区。网络对战最麻烦的反而在传输层之上谁的客户端是权威状态源。单机改联机时常见错误是两个客户端各自跑一套完整逻辑一帧不同步就永久分叉。建议的架构是服务端跑权威逻辑客户端只发送期望动作服务端计算最终结果再广播。这样AI决策可以放服务端客户端只做渲染和输入状态永远只有一份。这套架构已经超出“基于Python的大富翁游戏源码”的最小范畴但它是把源码从教学项目推向分布式系统的关键一步。最后再提一个细节游戏结束时不要只弹出“XX获胜”然后pygame.quit()。在主循环外层包装一个play_again()方法询问是否重开一局只多几行代码但会让源码更像一个成品项目评审代码时也会躲开“写完就扔”的评价。本文还有配套的精品资源点击获取