三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

文科生用Python+Pygame开发历史策略游戏:从零到打包的实战指南

文科生用Python+Pygame开发历史策略游戏:从零到打包的实战指南

1. 项目缘起:一个历史系学生的“不务正业”

去年秋天,当身边的同学都在为文献综述和田野调查发愁时,我,一个历史系的大三学生,却一头扎进了Python和Pygame的世界,开始鼓捣一个名为《秦影长歌:始皇帝御极之途》的游戏项目。起因很简单,我们学院组织大学生科技创新大赛,主题是“科技赋能传统文化创新”。我当时就想,与其写一篇干巴巴的论文,不如做个能“玩”的历史科普。秦始皇统一六国的故事家喻户晓,但其中的权谋、抉择与代价,静态的文字和图片总感觉隔了一层。如果能做成一个策略角色扮演游戏,让玩家亲自扮演年轻的嬴政,在错综复杂的朝堂与烽火连天的战场上做出抉择,体验那段波澜壮阔的历史,会不会更有意思?

这个念头一起,就再也按不下去了。但问题随之而来:我没有任何编程基础,对游戏开发的理解还停留在“玩”的层面。市面上主流的游戏引擎如Unity、Unreal,学习曲线陡峭,且对美术资源要求高。经过一番调研,我把目光投向了Python和它的一个经典库——Pygame。选择它们的原因很直接:Python语法清晰易懂,被誉为“最适合入门的语言”;Pygame则是基于SDL库的2D游戏开发模块,社区资源丰富,从贪吃蛇到平台跳跃,有大量现成的案例可供学习。对于我一个文科生来说,这无疑是最友好的起点。于是,我的“从零开始的做游戏生活”正式拉开序幕,这不仅仅是一个比赛项目,更是一次跨学科的冒险,一次将历史叙事转化为互动体验的尝试。

2. 核心设计:当历史叙事遇见游戏机制

决定技术栈后,最核心的问题来了:如何把秦始皇的故事“游戏化”?这不仅仅是把历史事件罗列出来,而是需要设计一套核心玩法循环,让玩家在互动中理解历史逻辑。我们的游戏定位是“轻量级历史策略角色扮演游戏”,核心体验是“抉择”与“成长”。

2.1 叙事结构与核心循环设计

我们摒弃了传统的线性剧情推进。整个游戏被设计成一个以“季度”为时间单位的沙盘。玩家扮演的嬴政,每个季度会面临数个来自“朝堂”、“军事”、“民生”、“外交”等维度的随机事件。例如,“朝堂”可能触发“吕不韦专权,是隐忍还是制衡?”;“军事”可能遇到“王翦请战伐楚,是否批准?”每个选择不仅会影响单项属性(如威望、国力、军队战力),更会触发蝴蝶效应,导向不同的历史分支。核心游戏循环就是“处理事件 -> 做出抉择 -> 查看反馈(属性变化与叙事更新)-> 进入下一季度”。我们为关键历史节点,如“蕲年宫之变”、“统一六国”等,设置了必须达成的属性阈值和前置事件链,只有满足条件才能解锁并推进主线。

2.2 数据驱动的内容架构

为了实现高可扩展的事件系统,我们采用了数据驱动的设计。所有游戏内容——角色、事件、选项、结果——都被抽象成结构化的JSON或CSV文件。例如,一个事件对象的定义大致如下:

{ “event_id”: “court_001”, “title”: “相国擅权”, “description”: “吕不韦近来频频绕过你,直接向各地郡守下达指令,朝中议论纷纷。”, “options”: [ { “text”: “隐忍不发,静观其变”, “effects”: { “loyalty_lvbuwei”: -5, “prestige”: -3, “stability”: +2 }, “next_event”: “court_001_a” }, { “text”: “当廷斥责,敲打相国”, “effects”: { “loyalty_lvbuwei”: -15, “prestige”: +8, “stability”: -5 }, “next_event”: “court_001_b” } ], “trigger_conditions”: { “year”: “-238”, “season”: “spring”, “prestige_min”: 30 } }

这种设计的好处是巨大的。首先,策划(也就是我自己)可以脱离代码,专注于内容创作和数值平衡,用Excel或文本编辑器就能修改游戏内容。其次,它极大地提升了开发效率,添加新事件只需按格式编写数据文件,无需改动核心代码。最后,它为游戏的“模组化”打下了基础,未来其他历史爱好者完全可以基于这套框架,创作“楚汉争霸”或“三国鼎立”的模组。

2.3 美术与音效的“极简主义”

作为个人和小团队项目,我们没有专业的美术和音效资源。我们的解决方案是“极简主义”和“资源复用”。UI采用清晰的色块和图标,主色调选用玄黑、赭红、鎏金,契合秦朝崇尚水德、色尚黑的 historical aesthetic。角色立绘使用经过统一滤镜处理的古画素材或风格化剪影。音效方面,我们从无版权音乐库寻找编钟、古琴等采样,战斗音效则用简单的合成器音效替代。Pygame的pygame.mixer模块对音频播放支持很好,实现背景音乐循环和音效触发非常简单。关键在于,所有资源风格必须统一,粗糙但一致的视觉风格,远比精致但杂乱无章更能营造沉浸感。

注意:在项目初期,切忌追求画面华丽。我们的核心优势是玩法与叙事,应将至少70%的精力投入在核心逻辑和内容填充上。一个玩法扎实但画面简单的Demo,远比一个画面精美但内容空洞的演示更有说服力。

3. 技术攻坚:Pygame实战中的核心模块拆解

有了设计蓝图,接下来就是一行行代码的实现。Pygame虽然入门简单,但要构建一个结构清晰、易于维护的中小型项目,也需要良好的架构设计。我们采用了基于状态机的游戏循环管理。

3.1 游戏状态管理与主循环

游戏通常包含多个状态:开始菜单、大地图、事件对话、战斗场景等。如果将所有逻辑堆在主循环里,代码会迅速变成一团乱麻。我们实现了一个简单的状态栈管理器。

class GameState: def __init__(self, game): self.game = game # 持有主游戏对象的引用,方便共享资源 def handle_events(self, events): pass def update(self, dt): pass def render(self, screen): pass class Game: def __init__(self): pygame.init() self.screen = pygame.display.set_mode((1024, 768)) self.clock = pygame.time.Clock() self.running = True self.state_stack = [] # 状态栈 self.push_state(StartMenuState(self)) # 初始状态 def push_state(self, state): self.state_stack.append(state) def pop_state(self): if self.state_stack: return self.state_stack.pop() def current_state(self): if self.state_stack: return self.state_stack[-1] return None def run(self): while self.running: dt = self.clock.tick(60) / 1000.0 # 计算增量时间,使动画帧率无关 events = pygame.event.get() for event in events: if event.type == pygame.QUIT: self.running = False # 将事件传递给当前状态处理 current_state = self.current_state() if current_state: current_state.handle_events(events) current_state.update(dt) current_state.render(self.screen) pygame.display.flip()

这样,每个游戏状态(如EventDialogState,BattleState)都继承自GameState,只需关注自身的逻辑。通过push_statepop_state,可以轻松实现场景切换,例如从大地图进入事件对话。

3.2 事件对话系统的实现

这是游戏叙事的核心。我们实现了一个DialogBox类,它负责在屏幕上渲染一个对话框,逐字显示文本,并列出选项。

class DialogBox: def __init__(self, font, width, height): self.font = font self.rect = pygame.Rect(50, 500, width, height) # 对话框位置大小 self.text = “” self.display_text = “” # 实际显示的文字(用于逐字效果) self.options = [] self.char_index = 0 self.type_speed = 30 # 打字速度,毫秒每字符 self.last_type_time = 0 self.finished = False def set_content(self, text, options): self.text = text self.display_text = “” self.options = options self.char_index = 0 self.finished = False def update(self, current_time): # 逐字显示逻辑 if not self.finished and current_time - self.last_type_time > self.type_speed: if self.char_index < len(self.text): self.display_text += self.text[self.char_index] self.char_index += 1 self.last_type_time = current_time else: self.finished = True def render(self, screen): # 绘制对话框背景 pygame.draw.rect(screen, (50, 50, 50), self.rect, border_radius=10) pygame.draw.rect(screen, (200, 180, 100), self.rect, width=3, border_radius=10) # 渲染文本 text_surface = self.font.render(self.display_text, True, (240, 240, 240)) screen.blit(text_surface, (self.rect.x + 20, self.rect.y + 20)) # 如果文本显示完毕,渲染选项 if self.finished: for i, option in enumerate(self.options): opt_text = self.font.render(f“{i+1}. {option[‘text’]}”, True, (180, 220, 255)) screen.blit(opt_text, (self.rect.x + 40, self.rect.y + 100 + i * 40))

EventDialogState中,我们加载对应事件的JSON数据,初始化DialogBox,并根据玩家的键盘输入(按1、2、3选择)来触发选项效果,并切换下一个事件或返回地图。

3.3 基于属性与概率的简易战斗系统

战斗系统我们设计得相对简单,采用自动战斗+技能释放的模式,重点表现策略性而非操作。每个武将单位有攻击、防御、兵力等属性。战斗流程是回合制:

  1. 回合开始:计算双方士气、阵型加成。
  2. 行动阶段:根据单位的“行动速度”属性决定出手顺序。每个单位行动时,从技能池中按概率选择释放技能(例如,“强弩齐射”有70%概率触发,造成范围伤害;“战车冲锋”需要士气高于80才能触发)。
  3. 伤害计算最终伤害 = (攻击力 - 目标防御力) * 技能倍率 * (1 + 随机浮动) * 兵力系数。兵力系数意味着单位兵力越少,输出越低。
  4. 状态结算:处理中毒、燃烧等持续状态效果。

这个系统的所有逻辑都封装在BattleManager类中,它接收双方阵容数据,运行战斗模拟,并生成每一步的战斗日志,用于在战斗画面中播放。Pygame负责的是根据战斗日志,实时绘制单位血条变化、技能特效动画(简单的粒子效果或图片位移)和文字飘字。

3.4 数据持久化与游戏存档

玩家需要能保存进度。Pygame本身不提供存档功能,我们使用Python内置的pickle模块或json模块来序列化游戏状态。

import json def save_game(game_data, filename=“savegame.json”): “”“保存游戏数据”“” # game_data 是一个字典,包含玩家属性、当前时间、触发的事件ID列表等 with open(filename, ‘w’, encoding=‘utf-8’) as f: json.dump(game_data, f, ensure_ascii=False, indent=2) def load_game(filename=“savegame.json”): “”“加载游戏数据”“” try: with open(filename, ‘r’, encoding=‘utf-8’) as f: data = json.load(f) return data except FileNotFoundError: return None # 存档不存在

实操心得:使用jsonpickle更安全(避免恶意代码执行),且生成的文件是明文,方便调试和修改。存档数据不要直接保存复杂的Pygame对象(如Surface,Rect),只保存最核心的、能重建游戏状态的原始数据(如角色位置用x, y坐标,而非Rect对象)。

4. 开发流水线:从零搭建高效协作(单人)环境

虽然是个人项目,但建立规范的开发环境和工作流能极大提升效率,减少“昨天还能运行,今天就报错”的魔幻问题。

4.1 环境配置与依赖管理

第一步是安装Python。强烈建议使用Python 3.8以上的版本,并通过官方安装包或包管理器(如Windows的winget,macOS的Homebrew)安装。安装时务必勾选“Add Python to PATH”。

接下来是安装Pygame。新手最容易踩坑的地方就在这里。不要直接用pip install pygame!因为Pygame的某些二进制依赖在Windows上可能需要特定版本的Visual C++ Redistributable。最稳妥的方法是访问Pygame的官方GitHub仓库的Release页面,或者使用预编译的wheel文件。对于大多数用户,使用清华、阿里云等国内镜像源安装指定版本的成功率更高:

pip install pygame==2.5.2 -i https://pypi.tuna.tsinghua.edu.cn/simple

安装完成后,在Python交互环境中输入import pygame并回车,如果没有报错,且打印出版本号(如pygame 2.5.2 (SDL 2.28.4, Python 3.11.4)),即表示安装成功。

4.2 项目结构与版本控制

一个清晰的项目结构是后续开发的基石。我们的项目目录大致如下:

qin_shadow_song/ ├── assets/ # 资源文件 │ ├── fonts/ # 字体 │ ├── images/ # 图片(按场景或类型分子目录) │ ├── sounds/ # 音效 │ └── data/ # JSON/CSV游戏数据 ├── src/ # 源代码 │ ├── core/ # 核心模块 │ │ ├── __init__.py │ │ ├── game.py # 主游戏类 │ │ ├── state_manager.py # 状态管理 │ │ └── constants.py # 常量定义(屏幕尺寸、颜色等) │ ├── states/ # 游戏状态 │ │ ├── __init__.py │ │ ├── menu_state.py │ │ ├── map_state.py │ │ └── battle_state.py │ ├── entities/ # 游戏实体(角色、单位) │ ├── ui/ # UI组件(按钮、对话框) │ └── utils/ # 工具函数(资源加载、工具函数) ├── main.py # 程序入口 ├── requirements.txt # 项目依赖列表 └── README.md # 项目说明

务必使用Git进行版本控制。在项目根目录初始化仓库(git init),并创建一个.gitignore文件,忽略__pycache__/,.pyc文件以及可能产生的临时文件。每完成一个相对完整的功能模块(如“实现事件对话系统”),就进行一次提交(git commit -m “feat: 完成基础对话系统与选项逻辑”)。这不仅是良好的习惯,更是你项目开发过程的“时光机”,能随时回退到任何一个稳定版本。

4.3 调试与性能优化

Pygame开发中,调试是家常便饭。除了使用IDE(如VSCode、PyCharm)的断点调试功能外,一些“土法”调试非常有效:

  1. 控制台打印:在关键逻辑处打印变量状态(print(f“当前选项: {selected_option}”))。
  2. 帧率显示:在游戏窗口标题或角落显示实时帧率(FPS),这是发现性能问题的第一指标。
    fps = self.clock.get_fps() pygame.display.set_caption(f“《秦影长歌》 - FPS: {fps:.1f}”)
  3. 性能分析:如果感觉游戏卡顿,可以使用Python的cProfile模块定位耗时函数。
    python -m cProfile -o profile_stats main.py

Pygame性能优化的几个关键点:

  • 图像优化:频繁使用的静态图像(如背景、UI元素),在加载后使用convert()convert_alpha()方法进行转换,能显著提升blit(绘制)速度。
    self.background = pygame.image.load(“assets/images/bg.jpg”).convert()
  • 脏矩形更新:如果每帧重绘整个屏幕开销大,可以只更新屏幕上发生变化的部分区域(脏矩形)。但对于初学者或UI复杂的游戏,全屏更新更简单可靠,在60FPS下通常不是瓶颈。
  • 避免在循环中加载资源:所有图片、音效、字体都应在游戏初始化时加载到内存中,游戏循环内只进行引用和绘制。

5. 避坑实录:那些只有踩过才知道的“坑”

回顾整个开发过程,有些错误非常典型,几乎每个Pygame新手都会遇到。

5.1 坐标系统的“陷阱”

Pygame的坐标系原点(0, 0)在屏幕左上角,Y轴向下为正。这和数学中常见的坐标系不同,在处理物体移动、碰撞检测时极易混淆。例如,想让一个精灵向上移动,需要减小rect.y的值。

# 错误:以为向上是加 sprite.rect.y += 5 # 这实际上是向下移动! # 正确:向上移动是减 sprite.rect.y -= 5

另一个常见错误是Rect对象的centertopleft等属性。直接修改rect.xrect.y是修改左上角坐标。如果你希望精灵围绕其中心点旋转或定位,务必使用rect.center来设置位置。

# 将精灵放置在屏幕中心 sprite.rect.center = (screen_width // 2, screen_height // 2)

5.2 事件处理的“阻塞”与“穿透”

Pygame的事件循环pygame.event.get()会清空当前的事件队列。如果你在多个游戏状态中不小心都调用了它,会导致后面状态接收不到事件。我们的状态机模式很好地解决了这个问题,所有事件只由顶层的Game.run()循环获取,并传递给当前活跃状态处理。

对于鼠标点击检测,一个常见的需求是判断点击是否在某个按钮内。代码很简单:

if event.type == pygame.MOUSEBUTTONDOWN: mouse_pos = pygame.mouse.get_pos() if button_rect.collidepoint(mouse_pos): # 处理按钮点击

但这里有个细节:MOUSEBUTTONDOWN事件在鼠标按下时触发一次,而MOUSEBUTTONUP在松开时触发。对于按钮,通常我们希望在按下时改变按钮外观(如变暗),在松开且鼠标仍在按钮区域内时才执行动作。这需要一点额外的状态管理。

5.3 资源加载失败与路径问题

FileNotFoundError: No such file or directory: ‘assets/images/hero.png’” 这是最令人头疼的错误之一。根本原因是工作目录(Current Working Directory)不对。你的main.pysrc目录下,但代码中写的却是相对路径‘assets/…’,此时Python会在src文件夹里找assets,当然找不到。

解决方案一(推荐):使用绝对路径,并通过os.path模块构建与源代码位置无关的路径。

import os import sys def resource_path(relative_path): “”“获取资源的绝对路径。在开发环境和打包后都能工作。”“” try: # PyInstaller创建的临时文件夹 base_path = sys._MEIPASS except Exception: base_path = os.path.abspath(“.”) # 当前工作目录 # 根据你的项目结构调整,例如项目根目录在上一级 project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) return os.path.join(project_root, relative_path) # 使用方式 image_path = resource_path(“assets/images/hero.png”) hero_image = pygame.image.load(image_path)

解决方案二:统一从项目根目录运行脚本。在VSCode中,可以配置launch.json;在PyCharm中,可以设置“Working directory”。

5.4 中文显示乱码

Pygame默认的字体不支持中文。加载中文字体文件(.ttf.otf)是必须的。将字体文件(如“方正楷体.ttf”)放入assets/fonts/目录。

# 错误:使用默认字体,中文显示为方框 font = pygame.font.Font(None, 36) # 正确:指定中文字体文件路径 font_path = resource_path(“assets/fonts/方正楷体.ttf”) try: font = pygame.font.Font(font_path, 36) except FileNotFoundError: print(f“警告:字体文件未找到: {font_path}, 使用默认字体”) font = pygame.font.Font(None, 36) # 备选方案

踩坑心得:字体文件通常有版权,在项目中使用前务必确认其授权许可。开源字体(如思源系列、站酷系列)是安全的选择。另外,加载字体会消耗内存,避免在游戏循环中重复创建Font对象,应该全局初始化并复用。

6. 打包与分发:让作品走出实验室

项目开发完成,最终要提交给比赛评委,或者分享给朋友试玩。你不可能要求每个人都安装Python和Pygame。这时就需要将项目打包成一个独立的可执行文件(.exe, .app等)。我们选择使用PyInstaller

6.1 使用PyInstaller进行打包

首先安装PyInstaller:pip install pyinstaller

最简单的打包命令是:pyinstaller -F -w main.py。其中,-F表示打包成单个文件,-w表示运行时不显示控制台窗口(对于GUI游戏适用)。但这样打包几乎肯定会失败,因为PyInstaller无法自动找到你的图片、字体、数据文件。

我们需要创建一个.spec文件来指导打包过程。首先生成一个模板:pyinstaller --name “秦影长歌” main.py。这会在当前目录生成一个main.spec文件(名字取决于你的入口文件)。然后编辑这个.spec文件:

# -*- mode: python ; coding: utf-8 -*- a = Analysis( [‘main.py’], # 主入口文件 pathex=[], # 可添加模块搜索路径 binaries=[], datas=[], # **重点:这里添加资源文件** hiddenimports=[], # 如果PyInstaller找不到某些隐式导入的模块,加在这里 hookspath=[], hooksconfig={}, runtime_hooks=[], excludes=[], noarchive=False, ) # 将assets文件夹及其所有内容添加到打包中 import os project_root = os.path.dirname(os.path.abspath(__file__)) assets_dir = os.path.join(project_root, ‘assets’) def collect_data_files(dir_path): file_list = [] for root, dirs, files in os.walk(dir_path): for file in files: abs_path = os.path.join(root, file) # 计算相对于assets目录的相对路径 rel_path = os.path.relpath(abs_path, project_root) # PyInstaller需要 (源路径, 打包后的目标目录) file_list.append((abs_path, os.path.dirname(rel_path))) return file_list a.datas += collect_data_files(assets_dir) pyz = PYZ(a.pure) exe = EXE( pyz, a.scripts, a.binaries, a.datas, # 包含我们添加的资源文件 [], name=“秦影长歌”, debug=False, bootloader_ignore_signals=False, strip=False, upx=True, # 使用UPX压缩,减小体积 runtime_tmpdir=None, console=False, # 不显示控制台,等同于 -w disable_windowed_traceback=False, argv_emulation=False, target_arch=None, codesign_identity=None, entitlements_file=None, icon=‘assets/icon.ico’ # 可设置程序图标 ) coll = COLLECT(...) # 如果是单文件模式(-F),则没有COLLECT部分

编辑完成后,使用这个spec文件进行打包:pyinstaller main.spec。打包过程可能会比较慢,完成后会在dist文件夹下生成可执行文件。务必在非开发环境的干净目录下测试这个可执行文件,确保所有资源都能正确加载。

6.2 打包后的常见问题与解决

  1. 文件体积巨大:一个简单的Pygame游戏打包后可能达到几十甚至上百MB。这是因为PyInstaller打包了整个Python解释器和所有依赖库。使用-F单文件模式体积会更大。可以使用UPX(一个可执行文件压缩工具)来减小体积,在安装UPX后,PyInstaller会自动调用(如上文spec中upx=True)。另一个办法是手动排除不必要的库(在spec文件的excludes列表中添加),但风险较高。
  2. 运行时闪退/无错误提示:因为用了-wconsole=False,程序崩溃时没有控制台输出错误信息。调试阶段可以先去掉-w参数,让控制台显示,查看具体的报错信息。通常问题还是出在资源路径上。确保在代码中使用resource_path这类函数来兼容打包后的环境。
  3. 杀毒软件误报:这是PyInstaller打包程序的“老大难”问题。某些杀毒软件会将由PyInstaller生成的、未经数字签名的可执行文件视为可疑文件。解决办法包括:a) 对程序进行数字签名(成本高);b) 在发布时明确告知用户这是安全的自制程序,并指导他们如何添加信任;c) 提供源代码,让用户自行运行。

7. 参赛心得与项目复盘

将《秦影长歌》的Demo提交给科创赛后,我们获得了一些反馈,也让我对这类项目有了更深的思考。

技术不是壁垒,创意与完成度才是:评委们并不期待大学生做出3A级的画面。他们更看重项目的创意、完整性以及技术实现的合理性。我们的项目用相对简单的技术,清晰地传达了一个历史主题,并且拥有可运行的完整闭环(从开始菜单到游戏结束),这比一个拥有华丽特效但bug频出的半成品得分要高得多。

文档与演示至关重要:除了可执行文件,我们提交了一份详细的设计文档,包括游戏设计理念、核心玩法说明、技术架构图以及未来规划。在答辩演示时,我们重点展示了游戏最具特色的“历史事件抉择系统”和“数据驱动架构”,并现场修改了一个JSON文件,实时在游戏中生成了一个新事件,直观地体现了项目的可扩展性和技术亮点。这比单纯播放游戏录像效果要好得多。

Pygame的定位与局限:通过这个项目,我深刻认识到Pygame是一个优秀的学习工具2D原型开发工具。它让你能聚焦于游戏逻辑本身,而不必在引擎编辑器和复杂的工作流上花费过多时间。但对于需要复杂物理模拟、高性能3D渲染、跨平台精细适配的商业项目,Pygame就显得力不从心了。它的生态也远不如Unity、Godot等成熟引擎。然而,对于快速验证想法、参加Game Jam或完成一个课程设计,Pygame依然是Python开发者最顺手的选择之一。

最后,这段“从零开始的做游戏生活”带给我的,远不止一个比赛奖项。它是一次完整的项目历练:从需求分析、技术选型、架构设计、编码实现、调试测试到最终打包发布。它让我一个历史专业的学生,掌握了将抽象想法转化为具体产品的核心能力。如果你也对用代码创造世界感兴趣,不妨也从一个小想法开始,用Python和Pygame动手试试。最重要的不是技术有多高深,而是开始行动,并把第一个可运行的版本做出来。

← 返回列表