十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

用Python和pygame开发回合制三国策略游戏:架构与实战

用Python和pygame开发回合制三国策略游戏:架构与实战 简介回合制策略游戏是一种以决策和状态管理为核心的游戏类型与快节奏动作游戏相比更强调数据结构和系统设计。Python凭借清晰的语法和丰富的库生态搭配pygame这一轻量级2D游戏开发库能够高效实现地图渲染、势力管理、战斗结算等复杂模块。通过事件队列和指令系统开发者可以构建稳定可控的回合循环并利用AI决策模型提升游戏可玩性。此类技术栈适合独立开发者验证玩法原型也适用于学习教育游戏开发。本文从地图格子化、武将数据结构到战斗公式与AI设计逐步剖析一个三国题材回合制策略游戏的关键实现并分享字体显示、性能优化和打包部署等工程经验为同类项目提供可落地的参考。 用Python和pygame做三国策略游戏听起来像小打小闹但真正把地图、武将、势力、资源、战斗、AI这些模块拼起来之后你就会发现它不比做一个“小飞机大战”简单反而更考验对数据结构和状态管理的理解。我的这个项目开发了大概三周最终做成一个可运行的回合制三国策略游戏有30x20的格子地图包含魏蜀吴三个势力每方有自己的武将池可以征兵、招贤、移动、攻城AI会在每个回合按规则做出决策。这篇文章把整个项目的架构、关键代码和踩过的坑都梳理出来给同样想用Python写策略游戏的朋友做参考。1. 项目整体设计与技术选型1.1 为什么选 pygame 来写策略游戏很多人入门pygame做的都是打飞机、贪吃蛇这类小动作游戏而策略游戏对架构的要求不太一样它更多是在处理状态、数据和AI判断。但反过来看策略游戏其实很适合用Python pygame来做原因是它的交互密度低不像动作游戏那样对帧率和输入延迟有极高要求。pygame的定位是2D游戏开发库它虽然不带编辑器、不带物理引擎但提供了窗口管理、事件处理、图片绘制、字体渲染、音频播放等核心能力足够撑起一个回合制策略游戏。另一个考虑是自己对Python生态比较熟数据清洗、逻辑调试、脚本迭代都很快写个单元测试也很方便。如果换Unity或Godot虽然也有脚本语言但引入的工程概念就多了场景、预制体、动画器、相机、节点树这些对一个想在短时间内验证玩法的个人项目来说学习成本偏重。网页技术也能做但要在浏览器里跑得维护一套前端工程打包和部署反而更绕。所以我最终选了pygame让项目保持在“一套Python代码直接跑”的简单状态。1.2 模块划分与设计取舍这个项目在动手之前我先画了一个模块提纲避免写到后面被各种状态纠缠到崩溃。整体划分为地图模块、势力模块、武将模块、城市模块、回合/指令模块、战斗模块、AI模块、UI模块、存档模块。地图管格子、地形和城市坐标势力和武将管归属和属性回合和指令模块是玩法的发动机负责把玩家或AI想做的事收集起来、统一结算战斗模块独立成一套计算公式UI模块只负责画和接收输入不掺业务逻辑存档模块JSON化整个局面。这里有一个很重要的取舍做回合制策略而不是即时战略。原因有三点。第一回合制天然适合调试每一步逻辑都可以复现战斗结果能输出成日志出错时方便定位。第二回合制对性能要求低每一回合的AI决策量和战斗结算只执行一次而不是每秒几十次这样我就可以用Python这种解释型语言放心写复杂逻辑。第三策略玩法的底层是决策树和事件驱动回合制用事件队列推着状态往前走比实时循环要清晰得多。我做了下面这个对比供参考维度回合制即时制开发难度较低逻辑集中较高需要并行处理和响应延迟控制调试体验好可逐步复现差时间因素干扰AI复杂度简单规则即可表现需要寻路、战术决策、实时调度性能压力低每帧只渲染高需要持续运算玩家体验偏策略烧脑偏操作紧张如果你是一个人用业余时间做我强烈建议先做回合制。就算最终想做即时制也可以先做一套回合制原型把核心玩法验证完再改成半实时或纯实时的执行层。2. 核心数据模型与地图渲染2.1 地图与城市的格子化数据设计地图是整个游戏的舞台我把它设计成30x20的格子数组。窗口大小是960x640每个格子是32像素正好排满。每个格子保存三样信息地形类型、所属势力、是否有城市。地形类型有平原、山地、森林、水域四种不同地形影响战斗加成和移动消耗比如山地防守有加成森林在防御时的视野会更差水域不能进入城市以外的格子。MAP_WIDTH 30 MAP_HEIGHT 20 TILE_SIZE 32 terrain_list [plain, mountain, forest, water] # 地形到颜色和加成系数的映射 terrain_props { plain: {color: (180, 220, 160), move_cost: 1, defense_bonus: 0.0}, mountain: {color: (150, 150, 140), move_cost: 2, defense_bonus: 0.3}, forest: {color: (90, 160, 90), move_cost: 2, defense_bonus: 0.2}, water: {color: (100, 180, 220), move_cost: 99, defense_bonus: 0.0}, } # 地图初始化随机生成地形并保证边界一圈没有水域 grid [] for y in range(MAP_HEIGHT): row [] for x in range(MAP_WIDTH): if x 0 or y 0 or x MAP_WIDTH - 1 or y MAP_HEIGHT - 1: terrain plain else: terrain terrain_list[random.randint(0, 3)] row.append({ terrain: terrain, owner: None, city: None, }) grid.append(row)地图这块的核心细节是“坐标系统”和“渲染系统”要分离。数据层始终用x和y来索引格子渲染层则把x32、y32作为像素坐标。很多新手会直接在绘制函数里写死像素位置后面一旦加入镜头移动或地图缩放就会改到怀疑人生。所以哪怕是最开始的版本我也建议定义好两个转换函数grid_to_pixel和pixel_to_grid一个数据进、一个数据出后面所有鼠标点击和格子定位都走这两个函数。另外我用一个背景surface把地形层一次性画好。地形不会每帧变化所以每次游戏启动时把整个地图的地形颜色画到这个surfurface上之后每帧只需要blit一次而不是遍历3000多个格子重新填充颜色。这一招在性能优化里尤其重要后面章节会再展开。2.2 武将、势力、资源的数据结构武将和势力的数据结构我一开始用的是字典写着写着发现属性太多访问起来容易拼错key于是改成class。武将类包含姓名、统率、武力、智力、忠诚度、带兵数、状态等字段。统率影响战斗时的部队攻防武力影响单挑和战法伤害智力影响计略成功率和识破能力忠诚度低了会跑或被挖走状态则标记当前武将是“空闲”、“出征中”还是“留守城中”。class General: def __init__(self, name, leadership, force, intelligence, loyalty80): self.name name self.leadership leadership self.force force self.intelligence intelligence self.loyalty loyalty self.troops 0 self.status idle # idle, marching, defending def to_dict(self): return { name: self.name, leadership: self.leadership, force: self.force, intelligence: self.intelligence, loyalty: self.loyalty, troops: self.troops, status: self.status, }势力类保存势力的名称、颜色、拥有的城市列表、武将列表、金钱、粮食和总兵力。颜色这个字段是用来在地图上区分势力范围的比如魏国用蓝色蜀国用绿色吴国用红色这样玩家一眼能看出当前版图大概的形态。金钱用于招贤和建造粮食用于征兵和维持军队每回合根据城市数量和兵力规模进行消耗粮食为负时士兵会不断逃亡。class Faction: def __init__(self, name, color, capital_city): self.name name self.color color self.cities [capital_city] self.generals [] self.gold 5000 self.food 5000 self.troops 0 def income(self): # 每座城市提供基础的金钱和粮食收入 return {gold: 800 * len(self.cities), food: 600 * len(self.cities)}选class还有一个好处是可以顺手把存档逻辑写在类里。上面代码里的to_dict方法就是干这个的存档时会遍历所有势力、所有武将转成字典再转成JSON。读档时再通过构造方法恢复。这个设计看起来简单但在后面调试的时候帮了大忙因为可以随时把整个游戏状态打印成JSON来查看不用靠猜。2.3 UI 框架在 pygame 里手写控件pygame自身没有现成的Button、ListBox这类控件需要自己写。我实现了几个最基础的控件按钮、文本标签、消息面板。按钮就是一个矩形区域加上文字鼠标点击时检测事件坐标是否落在矩形范围内。这些控件统一用world坐标和anchored position来布局不做复杂层级只求够用。class Button: def __init__(self, rect, text, callback): self.rect rect self.text text self.callback callback self.hovered False def handle_event(self, event): if event.type pygame.MOUSEMOTION: self.hovered self.rect.collidepoint(event.pos) elif event.type pygame.MOUSEBUTTONDOWN and event.button 1: if self.rect.collidepoint(event.pos): self.callback() def draw(self, surface, font): color (150, 180, 220) if self.hovered else (80, 100, 140) pygame.draw.rect(surface, color, self.rect, border_radius4) text_surf font.render(self.text, True, (255, 255, 255)) surface.blit(text_surf, (self.rect.x 8, self.rect.y 8))实际开发中UI这块最值得注意的问题是“响应区域”和“渲染位置”要保持一致。按钮的rect一旦定义好就不要在绘制时去临时改位置否则点击检测会出现偏差。我把所有按钮和面板的矩形集中放在一个layout字典里统一管理修改布局只需要改一处。右侧信息面板大约占960像素宽度中的240像素。左侧720像素是地图区域右侧240像素显示当前选中势力的资源、武将列表、当前回合数以及操作按钮。操作按钮包括“征兵”、“招贤”、“进攻”、“结束回合”每个按钮点击后进入对应的指令状态再在地图上选目标格子。这种“先点按钮、再点地图”的交互模式是回合策略游戏最常用的操作流实现起来也不复杂点击按钮时设置一个当前操作模式再在地图点击事件里根据模式做出不同响应。3. 回合流程与核心玩法实现3.1 回合制主循环与事件队列游戏主循环看起来和普通pygame程序没有太大区别核心差异在于“世界状态”的推进方式。我在游戏对象里维护一个事件队列玩家或AI产生的操作指令先放进队列等到回合结算时再统一执行。这样做的好处是玩家连续点击多个按钮时不会立即改变世界数据而是先积累一批待处理的指令从而避免在状态半更新时又被下一次点击打断。def run(self): clock pygame.time.Clock() while self.running: dt clock.tick(60) / 1000.0 for event in pygame.event.get(): if event.type pygame.QUIT: self.running False self.handle_ui_event(event) self.update(dt) self.draw() pygame.display.flip()回合的推进流程是当前势力行动玩家可以给它下达命令点“结束回合”后把该势力这一回合累积的指令交给指令系统执行随后进入下一个势力的回合。如果当前势力是AI控制就调用AI决策函数生成指令同样加入指令队列再统一结算。这个顺序保证了玩家和AI的权利是对等的都遵循同一套规则。指令队列用的是先收集后执行的方式而不是用户一操作就立刻执行。这是我踩坑之后改的最初版本里玩家点击“征兵”按钮会立刻减少金钱并增加兵力但如果后来发现粮食不够想撤回已经改动的数据就得回滚非常麻烦。改成队列模式后每一步操作只是记录意图直到回合结束时才落库如果这次操作不合法只需要在入队时给个提示不改任何数据回滚成本为零。3.2 指令系统行动、征兵、招贤与攻城指令用一个基类加多个子类实现。每类指令实现validate和execute两个方法validate负责校验条件比如金币是否足够、目标格子是否有效execute负责真正修改游戏状态。执行顺序按照入队顺序处理但攻城指令放在最后这样若某个势力先征兵再攻城执行时兵力已经增加打仗会更合理。class RecruitOrder: def __init__(self, faction, general, amount): self.faction faction self.general general self.amount amount def validate(self, game): cost self.amount * 10 return (self.faction.gold cost and self.faction.food self.amount * 5 and self.general.status idle) def execute(self, game): self.faction.gold - self.amount * 10 self.faction.food - self.amount * 5 self.general.troops self.amount self.faction.troops self.amount招贤指令每次从武将池中随机挑选一名未被雇佣的武将有一定概率被“婉拒”。这个概率我设定为40%拒绝的原因会显示在消息面板中比如“先生无意出山”。之所以不让招贤100%成功是为了增加游戏的不确定性也让“人才”显得更珍贵。每座城市每个回合可以招贤一次但是否成功另算。攻城是策略游戏的重头戏。当玩家或AI选择一个相邻的敌方城市作为进攻目标时会生成一个AttackOrder。攻城需要满足两个条件当前武将的兵力大于0目标城市和武将所在城市在地图上相邻。在execute阶段会调用战斗结算函数根据攻防双方的战力计算出胜负、战损和占领结果并把战斗日志输出到消息面板。3.3 战斗结算公式与随机因素战斗我采用了“战力比值 随机波动”的方式。战力不是一个固定数值而是由武将统率、兵力、地形和城防共同决定。攻击方战力 统率100 兵力1.2 武力15 地形系数兵力防守方战力 统率100 兵力1.0 武力10 城防等级300 地形加成*兵力。有了攻防战力之后先算一个比值再通过一个分段函数映射到胜率区间最后用随机数决定胜负。def battle_estimate(attacker, defender, terrain_defense_bonus): attack_power (attacker.leadership * 100 attacker.troops * 1.2 attacker.force * 15) defense_power (defender.leadership * 100 defender.troops * 1.0 defender.force * 10 defender.city_defense * 300 defender.troops * terrain_defense_bonus) ratio attack_power / max(defense_power, 1) win_chance min(max((ratio - 0.75) / 0.30, 0.05), 0.95) return win_chance, attack_power, defense_power为什么用战力比值而不是直接比较兵力大小因为武力、统率、城防这类属性需要有存在感。如果只看兵力玩家就只需要堆兵策略性会大大降低。而通过战力比值一次以少胜多是有可能的关键看武将属性和地形是否占优。引入随机波动后同一次战斗重复读档可能有不同结果这对单机策略游戏来说反而增加了可玩性玩家会去想办法提升胜率而不是背板。不过这里有一个必须注意的问题随机结果不能影响存档的稳定性。我在地图初始化和随机事件中都指定了随机种子存档时把种子也存下来读档后继续用相同的随机数流这样才能保证读档后的世界状态和战斗进程一致否则会出现“读一次档世界全变”的混乱情况。4. 敌我 AI 与事件系统4.1 简单 AI 决策模型我给AI设计了一套基于优先级的规则决策不搞复杂的搜索树和状态评估。每个回合AI会先检查自己的资源面板然后按以下优先级做决定如果粮食低于300进入“发展”模式执行开垦或征收指令如果金币低于300进入“经济”模式执行贸易或征税指令如果将领数量少于3执行招贤指令如果以上都满足则寻找邻近的敌对城市如果兵力足够就进攻否则先征兵再防御。def ai_turn(faction, game): if faction.food 300: faction.develop(food) elif faction.gold 300: faction.develop(gold) elif len(faction.generals) 3: faction.hire_general() else: target game.find_nearest_enemy_city(faction) if target and faction.total_troops() target.troops * 1.2: faction.attack(target) else: faction.develop(army)这个AI的优点是好懂、好改出问题时几乎不需要调试就能看出是哪个if条件判断错了。缺点是行为模式固定玩几局就能摸到规律。如果想让它变强可以在后续版本中加一个“威胁评估”逻辑比较自己和周边全部敌人的兵力总和如果局势不利就主动结盟或防守如果局势有利就集中兵力进攻。这一步很值得做因为策略游戏的乐趣很大程度在于AI的“不可完全预测性”。AI决策不需要每帧执行只在每次轮到该势力时执行一次。这样做的性能收益很大我在开发中测试过30x20地图上三个势力各跑几十回合AI的总耗时几乎可以忽略不计。如果你未来想做更复杂的AI比如带有路径搜索和动态规划能力也建议保持“每回合只决策一次”的节奏而不要放进主循环里每帧跑。4.2 随机事件与游戏节奏控制为了让游戏不会从第一个回合开始就纯粹比拼数值我加了一个随机事件系统。每回合开始时有大约15%的概率触发一个事件。事件类型写在配置字典里包含“丰收”、“闹灾”、“流寇”、“流民投靠”等。事件会影响当前回合的所有势力或者只影响触发势力效果在回合开始前结算。events { harvest: {label: 丰收之年, effect: {food: 1000}}, disaster: {label: 洪涝灾害, effect: {food: -800, gold: -300}}, brigand: {label: 流寇袭扰, effect: {troops: -200}}, refugees: {label: 流民来投, effect: {troops: 150}}, }随机事件会让游戏的节奏出现波峰和波谷有时粮食突然紧张被迫改变扩张计划有时又好运连连。不过要控制频率太频繁会让玩家觉得不可控太少则形同虚设。我实测下来每回合15%的概率偏适中玩家大概几回合会碰到一次能明显感受到世界在动。游戏节奏控制还依赖胜利条件和失败条件。我设定的胜利条件是某个势力占领所有城市失败条件是玩家势力失去所有城市且没有武将。当胜负判定触发时游戏弹出结算画面并显示当前的回合数和势力版图。胜负条件不应该只做“全统一”这一种可以考虑加入“时间上限”模式比如100回合内按占领城市数量排名这样玩家不会因为前期劣势就完全失去动力。5. 常见问题与优化实战5.1 中文字体显示一定是第一个坑在pygame里显示中文很多第一次做项目的朋友都会踩坑。直接用默认字体render中文出来的是一堆方框。pygame的默认字体不支持中文必须手动加载中文字体。用以下代码可以在不同操作系统上查找可用的中文字体import pygame def get_chinese_font(size20): preferred [simhei, simsun, microsoftyahei, pingfangsc, notosanscjk] available pygame.font.get_fonts() for name in preferred: if name in available: return pygame.font.SysFont(name, size) raise RuntimeError(No Chinese font found, please install one.)这段代码在Windows上一般能命中simhei或microsoftyaheiMac上会命中pingfangscLinux上如果安装过noto字体也能找到。字体加载一次后要保存成全局变量不要每帧都去调用SysFont否则性能和内存都会有问题。我最初没注意每帧渲染文字时都重新创建字体对象结果帧率从60掉到30排查半天才找到原因。5.2 性能优化遍历、绘制与场景规模策略游戏的地图再大也没有大到需要显卡级别优化的程度。30x20的格子规模用pygame完全无压力但前提是别把不该做的事放到每帧去执行。第一地形层要缓存。我前面的代码里提到背景surface一次性绘制这基本省掉了每帧3000次矩形填充的消耗。第二城市和军团这些动态元素单独绘制在前景层数量少、重绘成本低。第三不要每帧遍历所有格子去做视野计算或地形判断只有玩家点击、移动、回合结算时才需要访问格子信息。如果未来要扩展到更大的地图比如80x50可以考虑把渲染和数据访问都改成“只处理屏幕内可见区域”。pygame的Rect裁剪功能可以辅助但重点是在遍历格子时跳过屏幕外的部分而不是把几千个格子全查一遍。这里有一个很简单的写法先算当前摄像机的偏移量然后只遍历cam_x到cam_xscreen_width/tile_size范围内的格子。另一个常见的性能坑是消息日志和操作面板的文本刷新。每帧都重新渲染整段日志文字在字符串很长时会拖慢帧率。我的做法是只在“新消息追加”时重新渲染日志区域其余帧直接blit缓存下来的surface。5.3 打包发布与存档设计用pyinstaller打包Python游戏有几个容易踩的坑。第一个是资源路径问题。代码里如果直接写了assets/font.ttf这类相对路径打包成exe后经常找不到文件因为程序的当前工作目录不一定是exe所在目录。解决办法是用一个resource_path函数让开发时和打包后走不同的路径基准import sys, os def resource_path(relative_path): base_path getattr(sys, _MEIPASS, os.path.dirname(os.path.abspath(__file__))) return os.path.join(base_path, relative_path)打包命令可以参考pyinstaller --onefile --noconsole --add-data assets;assets game.py。注意--add-data参数在Windows和Mac上的路径分隔符不一样Mac用冒号分隔Windows用分号。打包后会得到一个单文件exe体积较大是因为打包了Python运行环境、pygame库和资源文件。存档设计我用了JSON格式。存档内容包含地图种子、当前回合、当前行动势力索引、每个势力在每座城市的兵力、武将状态和资源。JSON的好处是肉眼可读出错时可以直接打开存档文件检查坏处是数据量大的时候读写偏慢但对这个体量的策略游戏来说完全够用。存档的时机放在“回合结束并结算完”之后这样读取存档时不会出现“正在执行到一半的指令”。我还在存档里加了一个save_version字段方便以后格式变更时做兼容处理否则旧存档会直接把新版本代码跑崩。最后再分享一个开发上的心得这个项目做到最后我最大的感受是用Python写策略游戏瓶颈从来不是语言运行速度而是状态设计是否足够清晰。回合循环加上指令队列这套模式从一开始就帮我规避了大量时序问题后续加功能时几乎不需要重构主逻辑。如果你也想做类似的游戏我的建议很直接先砍到最小可玩原型三个势力、十个武将、一座城把“选势力—操作—结束回合—AI行动—结算”这条循环跑通再往里面填建筑、外交、计谋这些内容。我当初就是先写了一个只有两个城市的地图反复测试战斗和征兵确认稳定后才开始扩充地图和武将池最后整个项目的完成度反而比一开始就追求“大而全”的方案高很多。本文还有配套的精品资源点击获取
返回列表