资讯动态

Python实现大富翁游戏:从状态机到pygame渲染的完整设计

发布时间:2026/9/15 16:19:11 来源:尧图企业网站定制
简介基于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。下面这个表格说明每个字段的职责读者对着源码找字段时可以快速对位字段类型默认值说明indexint无棋盘上的位置从 0 开始namestr无地块显示名land_typeLandType无地块类型决定落点后的行为priceint0购买价格非地产类型为 0rentint0基础租金升级后另算ownerint | NoneNone持有者玩家编号None 表示无主levelint0地产等级升级会增加租金2.2 棋盘初始化用列表表达顺序比硬编码变量更可读棋盘本质上是一串有顺序的地块。我一般把地图数据放在独立的board_data.py模块里主逻辑只通过len(BOARD)和索引访问地块。这样改地图不需要动引擎。START_CASH 10000 PASS_GO_BONUS 200 BOARD [ Land(0, 起点, LandType.START), Land(1, 胡同口, LandType.ESTATE, price200, rent50), Land(2, 机会卡, LandType.CHANCE), Land(3, 老城街, LandType.ESTATE, price400, rent90), 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格、骰子最大12target不会超过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(pidi, namename, cashstart_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_CASH10000初始现金PASS_GO_BONUS200经过起点奖励地价范围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, typeint, defaultNone) 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(fseed {seed}: 异常剩 {survivors} 名玩家)5000回合上限是为了防止死循环。如果200个seed都正常结束说明引擎基本稳定。还可以在next_turn末尾加资金守恒检查所有玩家现金加上地块原始价格的总和应该始终等于初始资金总和。机会卡里的加钱应该由银行资金池承担而不是凭空生成否则守恒检查会失败并暴露逻辑漏洞。5.3 破产结算的三个常见坑破产收尾是最容易被敷衍的地方。玩家现金不足以支付租金时要先尝试出售地块补款再决定是否破产。如果直接置bankrupt地块上的owner会残留下一局或读档时状态就脏了。另外不要用copy.copy复制包含列表字段的玩家对象持有地块列表会被多个玩家共享要复制就写一个to_dict/from_dict做明确的存档转换。最后把每一步操作写到logging.debug排查时比看print强很多。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价