ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Python大富翁游戏开发全指南:类设计、回合循环与pygame可视化

Python大富翁游戏开发全指南:类设计、回合循环与pygame可视化 简介这是一份基于Python开发的大富翁游戏完整源码面向Python学习者、游戏开发爱好者以及课程设计/毕业设计需要者。项目按模块化拆分将玩家、地产、AI对手、地图、事件等核心玩法分别封装覆盖从游戏初始化到交互的完整流程并配套JSON配置、Excel数据、测试用例与readme文档体现从代码到工程管理的完整实践。压缩包共26个文件以20个py文件为主辅以json、xlsx、gitignore和txt整体仅80KB轻量易读适合快速上手和二次开发。通过阅读源码可学习Python面向对象编程、游戏状态管理、配置文件解析及自动化测试思路test目录中的测试用例还能帮助验证对各模块的理解。已有871人学习下载可按模块逐项对照阅读快速掌握一个可运行游戏项目的完整实现。1. 大富翁居然是最适合练手的 Python 项目先搞清楚它到底在做什么如果你搜到「基于 Python 开发的大富翁游戏设计源码」大概率不是想要一份能跑的代码而是想知道这东西的架构长什么样我是该照着抄还是自己写一套说实话大富翁是少数适合拿来做完整项目的练手题材——它既有明确规则可以落地成逻辑又有棋盘、道具、地产这些能可视化呈现的对象还天然带一个回合制状态机。相比爬虫和网站它没有外部依赖波动所有复杂度都集中在代码组织上。本文不假装见过什么现成源码包也不会给你一个神秘下载链接我会按照一线开发者做这种项目最常见的思路从类设计、回合循环、可视化和踩坑四个方向把一套能跑、能扩展的大富翁游戏源码结构讲透。适合刚学完 Python 基础、想用一个小项目把面向对象、事件驱动和状态管理串起来的人。2. 把棋盘和玩家变成对象MonopolyGame 类设计与三种数据模型2.1 为什么要用类而不是一堆函数大富翁最核心的资产是状态每个玩家的现金、位置、地产列表、是否在监狱棋盘上每个格子的类型和归属。如果你用全局变量加函数去写做到第三轮回合你会发现参数多到没法维护。常见做法是围绕玩家和格子各建一个类棋盘本身再套一个容器类游戏引擎单独放一个类来管回合流程。我一般会把项目拆成这样的文件结构monopoly/ ├── main.py # 程序入口命令行模式或启动 pygame ├── models.py # 数据模型格子、玩家、卡牌 ├── engine.py # 回合引擎掷骰、移动、交易、破产判定 ├── events.py # 事件卡牌定义机会卡 / 命运卡 ├── ui.py # pygame 可视化层只负责画不负责算 └── tests/ └── test_engine.py # 对裁决逻辑做单元测试这样拆的好处是引擎层不 import 任何 UI 代码你可以在命令行下把整套游戏逻辑跑通再决定要不要接图形界面。很多人一上来就写 pygame结果规则和渲染耦合在一起改个租金都要在事件循环里翻半天。2.2 格子类一块地该有的字段大富翁里的格子类型其实不超过十种起点、普通地产可购买、车站、公共事业、机会卡、命运卡、缴税、监狱/探访、停泊处。最常见的建模方式有两种一种是每种类型写一个子类另一种是用一个类加类型枚举字段。我建议新手用第二种因为子类爆炸会让代码量翻倍而且大部分行为差异只是触发效果不同用策略函数处理更干净。from enum import IntEnum from typing import Optional class LandType(IntEnum): START 0 # 起点 PROPERTY 1 # 可购买地产 STATION 2 # 车站 UTILITY 3 # 公共事业 CHANCE 4 # 机会卡 FATE 5 # 命运卡 TAX 6 # 缴税 PRISON 7 # 监狱 / 探访 PARKING 8 # 临时停泊 class Land: 棋盘上的一个格子。所有类型共用这个类行为差异由 land_type 分发。 def __init__( self, index: int, name: str, land_type: LandType, price: int 0, # 购买价格非地产格为 0 rent_base: int 0, # 基础租金未升级时的费用 ) - None: self.index index self.name name self.land_type land_type self.price price self.rent_base rent_base self.owner: Optional[int] None # 持有者玩家编号None 表示无主 self.houses: int 0 # 房屋数量0~45 表示旅馆这里有个容易忽略的细节owner默认值不要用-1也不要直接给玩家对象引用用None配合Optional[int]。原因有两层一个是None在逻辑判断里天然表示无主另一个是如果你存的是玩家对象的引用序列化存档时会遇到循环引用麻烦。我见过不少人在这里存对象后面写存档功能时被迫写一堆深拷贝代码。2.3 玩家类现金、位置和资产要分开记玩家的属性和格子不同它更强调状态约束现金不能为负破产即出局、位置必须合法0 到棋盘总格数减一、地产列表必须与棋盘格一一对应。大富翁的经典规则里玩家现金其实可以在回合中被扣成负数——比如欠租但还没变卖资产所以你的类设计不应该在 setter 里直接抛异常而是把校验放在引擎层。from typing import List, Dict class Player: def __init__(self, pid: int, name: str, start_cash: int 1500) - None: self.pid pid self.name name self.cash start_cash # 当前现金可以为负但回合结束时会被裁决 self.position: int 0 # 当前所在格子索引 self.lands: List[int] [] # 持有的地产格子索引列表 self.in_prison: bool False # 是否在监狱中 self.prison_rounds: int 0 # 已在监狱内停留的回合数 self.bankrupt: bool False # 破产标记 self.alive: bool True # 是否仍在游戏中 def pay(self, amount: int) - None: 支出。这里只改数字真正的资金校验交给引擎。 self.cash - amount def receive(self, amount: int) - None: 收入。 self.cash amount参数说明start_cash默认给 1500 是经典大富翁的初始资金设定但你自己写源码时完全可以把这做成配置项同理棋盘格数量也可以做成配置。这也是设计源码和运行脚本的差别——真正可复用的源码会把规则参数化而不是把所有数字写死在逻辑里。2.4 棋盘类用字典比用列表更适合查格子棋盘本身用列表按顺序存格子没问题因为按下标访问是 O(1)。但当你需要按名字找格子或统计某玩家持有某类地产数量时列表就很别扭。我一般会在棋盘类内部同时维护列表和字典两个视图from typing import Dict, List, Tuple class Board: 棋盘维护格子列表和索引映射。 def __init__(self, lands: List[Land]) - None: self.lands lands self.index_map: Dict[str, int] {land.name: land.index for land in lands} def get_land(self, index: int) - Land: # 取模防止越界索引-1 自动落到最后一格对循环棋盘很有用 return self.lands[index % len(self.lands)] def get_by_name(self, name: str) - Land: return self.lands[self.index_map[name]]这里我踩过一个坑get_land里取模操作让越界索引也能返回格子这看起来很方便但代价是把错误隐藏了。如果你的回合循环里有 bug 导致 position 超出棋盘范围取模会让它静默地绕一圈回起点而不是抛异常。调试时会非常头痛。后来我改成只在移动逻辑里主动取模get_land里直接抛IndexError这样问题能尽早暴露。3. 回合循环与核心裁决逻辑掷骰、地产买卖与破产判定怎么落地3.1 引擎类为什么把规则从主循环里抽出来主循环如果写成 while True 加一堆 if 判断前 50 行很爽写到后面就会出现某个分支忘写 continue 导致玩家连续行动两次这种问题。我这里说的引擎类本质是一个管理器它负责推进回合、执行动作、返回事件结果。UI 层只负责把结果画出来命令行模式也只是换一个输出函数而已。最常见的回合流程是掷骰 → 移动 → 触发落地格效果 → 询问是否购买/是否盖房 → 检查破产 → 下一个玩家。这个流程里有两个状态节点容易被遗忘首先是玩家经过起点时发工资其次是玩家掷出双骰时可再掷一次但连续三次双骰要进监狱这两条规则在经典大富翁里都是核心漏掉会让游戏平衡感变得很奇怪。import random from typing import List, Optional class MonopolyEngine: 回合引擎负责所有规则裁决。不依赖任何 UI。 def __init__(self, board: Board, players: List[Player]) - None: self.board board self.players [p for p in players if p.alive] self.current_index: int 0 self.double_count: int 0 # 连续双骰计数连续三次进监狱 self.game_over: bool False self.winner: Optional[Player] None property def current_player(self) - Player: return self.players[self.current_index] def roll_dice(self) - int: 掷骰返回骰子点数和同时记录是否双骰。 d1 random.randint(1, 6) d2 random.randint(1, 6) self.last_dice (d1, d2) return d1 d2 def is_double(self) - bool: return self.last_dice[0] self.last_dice[1]逻辑说明roll_dice把两个骰子的点数单独放在self.last_dice里这样外部可以判断是否双骰。返回值只给移动用的总点数。MonopolyEngine.__init__里直接过滤掉aliveFalse的玩家保证开局列表是干净的。这里有个细节self.double_count是连续双骰计数回合结束时如果玩家没有掷出双骰则清零。参数说明骰子面数写死 6 是常规做法。如果你后面想做两个骰子一个 1~4 一个 1~6之类的变体规则只需要改roll_dice一个方法不用动移动逻辑。3.2 移动与落地先看格子再看钱移动逻辑是引擎里最容易出 bug 的地方因为经过起点发钱和落在起点发钱是两回事经典规则里只有经过或停在起点会发工资。很多简化版源码直接用player.position dice完事导致经过起点时不触发发钱游戏经济瞬间失衡。def move_player(self, player: Player, steps: int) - Land: 移动玩家并触发起点经停发钱。 返回落点格子供调用方决定后续事件分发。 old_position player.position new_position old_position steps passes_start (old_position % len(self.board.lands)) steps len(self.board.lands) player.position new_position % len(self.board.lands) if passes_start: player.receive(self.start_bonus) # 默认 200经典规则是 200 return self.board.get_land(player.position) # 如果没有经过起点仍要检查恰好落在起点 if player.position 0 and old_position ! 0: player.receive(self.start_bonus) return self.board.get_land(player.position)这段代码里有两个容易踩的细节passes_start的判断在old_position本身就接近棋盘末尾时会产生边界误差所以我用取模后的当前值再做一次player.position 0检查作为补充。双管齐下经过起点和恰巧停在起点都能正确处理。start_bonus我一般在MonopolyEngine.__init__里再定义self.start_bonus 200不写死在代码里。3.3 地产购买与租金裁决钱不够时不是简单弹窗地产交易是游戏最核心的策略机关。落地格如果是无主地产且价格不为零玩家可以选择购买如果是有主地产则触发租金支付。常见错误是直接把扣钱和判定破产混在一起写结果玩家钱不够时就报错退出。正确做法是把支付做成一个独立方法返回布尔值表示是否支付成功让调用方决定下一步。def try_buy_land(self, player: Player, land: Land) - bool: 尝试购买无主地产。返回是否购买成功。 if land.owner is not None: return False if player.cash land.price: return False player.pay(land.price) land.owner player.pid player.lands.append(land.index) return True def pay_rent(self, renter: Player, owner: Player, land: Land) - int: 计算并支付租金。返回实际支付金额可能为 0破产保护。 rent self.calc_rent(land) if renter.cash rent: renter.pay(rent) owner.receive(rent) return rent else: # 现金不够先用现金抵扣剩余部分进入破产清算流程 remaining rent - renter.cash owner.receive(renter.cash) renter.pay(renter.cash) self.handle_bankruptcy(renter, owner, remaining) return rent def calc_rent(self, land: Land) - int: 计算租金有同类整组地产时租金翻倍房屋加成按梯队递增。 if land.houses 0: base land.rent_base # 检查是否拥有整组同色地产这里简化为按名称前缀判断 same_group self.count_same_group(land) if same_group 2: return base * 2 return base # 1~4 栋房子租金递增旅馆封顶 multiplier [1.0, 5.0, 15.0, 25.0, 40.0] return land.rent_base * int(multiplier[min(land.houses, 5)])逻辑说明pay_rent的返回值语义是实际支付金额这对 UI 层展示友好同时如果 rent 为 0比如地租设为 0 的自定义格子整个流程仍然走通不会出现除以零或负数。calc_rent里的整组判断我先用count_same_group简化处理真要做完整规则应按格子的区域编号分组判断而非按名称前缀我在后面避坑章节会展开讲。参数说明multiplier数组是租金倍率经典规则是地价加几倍具体数字因版本而异。我在代码里用的是相对夸张的倍率为了游戏节奏更快——测试时你不想等 40 回合才有一位玩家破产。这个数组应该做成配置项方便按游戏节奏调整。3.4 破产判定清算顺序决定游戏体验破产清算是大富翁源码里最容易写出死循环的部分。常见问题玩家欠钱后需要先卖房或抵押地产来还款但卖房给谁在单人本地游戏里没有明确答案。最可靠的落地策略是先卖房退款再抵押地产变卖资产仍不够的部分由债权方减免玩家出局。def handle_bankruptcy(self, debtor: Player, creditor: Player, unpaid: int) - None: 破产清算变卖资产冲抵债务不够的部分由债权方承担损失。 # 阶段1变卖房屋每次卖一栋按原价六成退款 for land_index in list(debtor.lands): land self.board.get_land(land_index) if land.houses 0 and unpaid 0: refund int(land.rent_base * 0.6) debtor.receive(refund) land.houses - 1 unpaid - refund # 阶段2抵押地产。抵押后该地不能再收租归债权方处置 while unpaid 0 and debtor.lands: land self.board.get_land(debtor.lands.pop()) unpaid - land.price creditor.lands.append(land.index) land.owner creditor.pid # 阶段3剩余债务由债权方记坏账债务人不死磕 if unpaid 0: creditor.pay(unpaid) # 标记破产出局 debtor.bankrupt True debtor.alive False debtor.cash 0 self.players [p for p in self.players if p.alive]这里有个值得一提的设计选择阶段 3 让债权方承担损失而不是让债务方强制变卖所有资产。经典桌游规则是资产清零时直接出局但电脑游戏里清算完所有地产还不够的概率很高如果死磕会让游戏卡在永无止境的循环里。我见过有源码在这里直接while unpaid 0死循环就是因为没有这个兜底。回调阶段 2 里把地产直接转给债权方也省掉了处理无主地产的额外分支。3.5 主循环引擎与 UI 的边界引擎准备好之后主循环可以非常薄。一个标准的主循环分为三步决定当前玩家 → 引擎执行裁决 → UI 刷屏。下面命令行的主循环可以直接跑通def run_cli_game(engine: MonopolyEngine, board: Board) - None: 命令行主循环适合作为最小可运行原型的入口。 while not engine.game_over: player engine.current_player print(f\n轮到 {player.name}现金 {player.cash}当前位置 {player.position}) input(按回车掷骰子...) steps engine.roll_dice() print(f掷出 {engine.last_dice[0]} {engine.last_dice[1]} {steps}) land engine.move_player(player, steps) print(f落到 {land.name}) # 根据格子的类型做分支裁决 if land.land_type LandType.PROPERTY and land.owner is None: if player.cash land.price and input(f是否购买 {land.name}({land.price}元) y/n).lower() y: engine.try_buy_land(player, land) elif land.owner is not None and land.owner ! player.pid: owner engine.players[engine.get_player_index_by_pid(land.owner)] paid engine.pay_rent(player, owner, land) print(f支付租金 {paid} 给 {owner.name}) elif land.land_type LandType.CHANCE: card engine.draw_chance_card() print(f机会卡{card.description}) # 开始结算环节检查玩家是否出局 engine.check_round_end() print(f游戏结束胜者{engine.winner.name})说明check_round_end是回合收尾方法负责清理破产玩家、判断游戏是否只剩一人并在只剩一人时设置game_over和winner在run_cli_game中UI 层只管读引擎的公开属性和调用引擎方法不直接修改玩家字段。这样后面接 pygame 时你只需要换掉input和print对应的事件分支引擎代码一行都不用动。4. 用 pygame 把逻辑层变成可视棋盘事件卡牌系统与 UI 渲染状态机4.1 为什么需要事件卡牌系统而不只是格子触发大富翁的灵魂在不确定性。如果不依赖外部随机事件让运气只来自骰子游戏策略会变得单调。机会卡和命运卡是最便宜的不确定性来源抽到移动到最近车站或缴纳 50 元罚金玩家体验差异很大。事件卡牌的正确建模方式是把卡牌定义成数据而不是硬编码在引擎的 if 分支里。from dataclasses import dataclass from typing import Callable, Optional, List import random dataclass class Card: 一张事件卡描述 效果类型 参数。效果类型决定引擎怎么处理。 name: str description: str effect_type: str # move_to, collect, pay, go_to_prison value: int target: Optional[str] None class CardDeck: 事件卡堆内置洗牌逻辑抽完重置。 def __init__(self, cards: List[Card]) - None: self.cards cards self.discard: List[Card] [] self.shuffle() def shuffle(self) - None: random.shuffle(self.cards) self.discard.clear() def draw(self) - Card: if not self.cards: self.cards self.discard self.shuffle() card self.cards.pop() self.discard.append(card) return card为什么用effect_type加value而不是直接放一个函数引用进去因为卡牌如果要序列化到存档文件函数引用没法保存而字符串类型的effect_type可以。如果你不想写存档功能用函数引用的确更灵活——但做源码设计时可序列化始终是个值得考虑的维度。CardDeck.draw里牌堆为空时把弃牌堆洗回牌堆的逻辑是标准做法不这样做会翻车你连续抽二十张后牌堆空了draw 直接报错。4.2 pygame 渲染层的状态机设计UI 层最大的坑是用一坨 while 循环包着所有画图逻辑然后发现响应键盘、处理动画、弹对话框的顺序全乱。我常用的 UI 结构是有限状态机START→ROLLING掷骰动画 →MOVING棋子移动 →LANDED展示落地效果 →WAIT_INPUT等待玩家决策 →ROUND_END。每一帧只处理当前状态下的逻辑状态切换由事件驱动。import pygame from enum import Enum, auto class UIState(Enum): START auto() ROLLING auto() MOVING auto() LANDED auto() WAIT_INPUT auto() ROUND_END auto() class MonopolyUI: pygame 可视化层。只负责接收事件和绘制不修改游戏数据。 def __init__(self, engine: MonopolyEngine) - None: pygame.init() self.engine engine self.screen pygame.display.set_mode((1200, 800)) self.clock pygame.time.Clock() self.state UIState.START self.anim_step 0 self.font_small pygame.font.SysFont(simhei, 18) def handle_event(self, event: pygame.event.Event) - None: if self.state UIState.START: if event.type pygame.KEYDOWN and event.key pygame.K_RETURN: self.state UIState.ROLLING elif self.state UIState.WAIT_INPUT: if event.type pygame.KEYDOWN and event.key pygame.K_y: self.do_action(confirmTrue) elif event.type pygame.KEYDOWN and event.key pygame.K_n: self.do_action(confirmFalse) elif self.state UIState.ROUND_END: if event.type pygame.KEYDOWN and event.key pygame.K_SPACE: self.next_round() def render(self) - None: self.screen.fill((200, 200, 190)) self.draw_board() self.draw_players() self.draw_status_panel() pygame.display.flip()状态机的关键价值在于你永远不用在render里猜测现在是该画对话框还是该画移动动画因为state字段已经告诉你答案。draw_board和draw_players各自独立画棋盘时只需要读engine.board画玩家时只需要读player.position不需要任何计算逻辑。参数说明1200, 800是窗口尺寸。棋盘格子大小和玩家棋子半径我建议在MonopolyUI.__init__里用常量定义不要散落在各个 draw 方法里否则改布局时要翻三四个函数。4.3 在屏幕绘制棋盘等距网格的另一种算法棋盘绘制常见做法是画一个方形路径图右边和下边各一排格子左上角是自由停泊区。这里我建议直接用等宽网格计算格子坐标而不是手搭一个实际棋盘的坐标表——手搭坐标表一旦要调整格子数量你会被迫重画所有图标。def draw_board(self) - None: 绘制棋盘格按顺时针方向布局路径。 side 11 # 每边格子数11x11 棋盘共 40 格 cell_w 60 cell_h 60 board_px cell_w * (side - 1) # 棋盘像素边长 lands self.engine.board.lands positions: list[tuple[int, int]] [] # 生成顺时针的格子坐标序列 for i in range(side): positions.append((i * cell_w, 0)) for j in range(1, side): positions.append(((side - 1) * cell_w, j * cell_h)) for i in range(side - 2, -1, -1): positions.append((i * cell_w, (side - 1) * cell_h)) for j in range(side - 2, 0, -1): positions.append((0, j * cell_h)) for idx, (x, y) in enumerate(positions): color self.land_color(lands[idx].land_type) pygame.draw.rect(self.screen, color, (x, y, cell_w - 2, cell_h - 2)) def land_color(self, land_type: LandType) - tuple[int, int, int]: mapping { LandType.PROPERTY: (180, 220, 160), LandType.START: (240, 240, 120), LandType.PRISON: (200, 160, 160), LandType.CHANCE: (200, 200, 240), LandType.FATE: (220, 180, 220), } return mapping.get(land_type, (200, 200, 200))这里的 40 格 11×11 棋盘坐标生成算法很容易出错我实际调试时也踩过坑顺时针的第三个循环右上→左上和第四个循环左上→起点上方的边界条件容易多算一格或少算一格。所以我建议把positions打印出来对一下数量再往下写如果数量不是 40问题一定出在 range 的起始或边界上。4.4 在 pygame 里接入引擎骰子动画被打断怎么办UI 和引擎衔接时的用户中断问题是 pygame 新手最常见的困惑。玩家在移动动画播到一半时按下空格要不要立刻跳转到落地逻辑我的方案是不响应任何按键只让动画自然播完。移动动画的帧计数器self.anim_step每次render时递增当anim_step * 每帧移动像素 目标格距离时把状态切到LANDED并调用引擎的落地裁决。这样既不会出现玩家看到棋子还在半路但已经弹出购买对话框的诡异场景也不需要在事件处理里做复杂的排队管理。5. 避坑手册大富翁项目最常见 5 个翻车点和排查路径5.1 现象玩家现金变成负数但游戏还在继续初版代码跑起来经常打着打着某个玩家现金变成 -300游戏却没有任何反应。原因是pay_rent只做了现金够不够的判断忽略了现金不够但玩家还有地产可以卖的情况。于是在pay_rent里我加了handle_bankruptcy的调用链现金不够 → 触发清算 → 清算失败才出局。另外还有一个容易漏掉的细节玩家在自己回合开始时没有检查现金是否为负就允许购买导致负资产继续扩大。我通常在check_round_end里加一行断言如果有人现金小于零先走清算流程再进入下一个玩家。5.2 现象连续掷出双骰三次没人进监狱规则是连续三次双骰进监狱但写代码时常常会只判断当前回合的双骰状态丢掉了连续这个前缀。我在MonopolyEngine.roll_dice里维护一个self.double_count每次掷骰后判断如果是双骰且double_count 2立刻把玩家送进监狱并把计数清零如果不是双骰计数归零。常见错误是把计数放在玩家对象上而不是引擎上导致玩家 A 掷出双骰后玩家 B 再掷一次普通点数也把 A 的计数清了。5.3 现象囚禁战永远无法结束玩家进监狱后规则是掷出双骰或缴纳罚款才能出狱。有一版我写成了只有掷出双骰才能出狱结果玩家连续五回合都是单骰没人输也没人赢。正确做法是给监狱规则加一个保底在监狱里停留满三回合后第四回合不管掷出什么都强制出狱并按下普通移动处理。否则纯靠骰子概率游戏可能拖到天荒地老。这个保底跟拖欠债务减免的思路一样都是给游戏一个终局条件避免出现不可终止的循环状态。5.4 现象pygame 窗口卡死但命令行模式正常运行引擎和 UI 分离之后会出现一个奇怪问题命令行模式跑得好好的一接 pygame 就卡死多半是事件循环里忘记调用pygame.event.pump()。如果你用pygame.event.get()作为主循环的事件入口这个问题不会出现但如果你用的是pygame.event.wait()去等待按键而某个回合分支里没有任何输入事件产生程序就会停在那里空转。我一般统一用pygame.event.get()加非阻塞事件轮询并且在主循环末尾强制调用self.clock.tick(30)保证帧率稳定顺便重置事件队列的状态。5.5 现象同一局游戏每次跑结果都不一样无法复现 bug大富翁大量依赖random导致同一个 bug 很难复现。调试第五回合突然有人资产清零这种问题最有用的操作是固定随机种子。我在引擎的__init__里加一个可选参数seed默认None表示用系统随机测试时传固定种子就能复现完整棋局。卡牌洗牌和骰子都属于随机源我建议把这两者的随机数生成都收敛到引擎持有的random.Random实例上而不是直接调用模块级random函数。这样固定种子后整局游戏的所有随机行为都可以精准复现对修 bug 意义重大。6. 让 AI 托管跟自己对局验证游戏闭环的三个技巧写到这里引擎和 UI 都有了但你还缺最后一块拼图怎么证明这套代码是游戏而不是脚本最直接的验证方式是让 AI 托管所有玩家跑一整局游戏看它能不能正常结束。我一般先写一个简单的托管策略函数有钱就买地买完地有钱就盖房现金低于 500 就不再购买。这个策略很粗糙但足以驱动游戏跑完。def ai_decision(player: Player, land: Land, engine: MonopolyEngine) - str: 最简单的 AI 托管策略有地就买买完先保底留 200 现金。 if player.cash land.price 200: return skip if land.owner is None: return buy if land.owner player.pid and player.cash 1000: return build return end_turn有了这个函数后我在run_cli_game的等待输入分支里做判断如果玩家是 AI 托管直接调ai_decision跳过input()。然后就可以开一个后台跑一千局统计每局的回合数和胜者分布。如果跑出来有死循环说明某个回合流程里存在状态无法推进的问题。第二种验证技巧是固定随机种子单局复现先用seed42跑一局记录每一步的输出再改一处逻辑后重跑对比哪里变了。这样就能判断你的改动是不是引入了意外分支。第三种是断言资产守恒每回合结束后检查全场现金总额加上地产估值是否等于初始资金乘玩家数。这个断言对纯现金游戏很准有了地产估值后按购买价格计算误差不大。如果有明显的钱凭空多出来一定有地方漏加了扣款。我个人的习惯是先跑通一场 4 人 AI 对局再把 AI 策略换成只买不建和只建不买各跑一场看哪种策略赢得多。这个验证方法比任何单元测试都能更快暴露规则漏洞。希望这篇拆解能帮你把自己的大富翁源码搭起来并且少踩几个我当年踩过的坑——祝编译顺利。本文还有配套的精品资源点击获取
返回列表