Pygame制作看图猜单词游戏:从数据层到状态机的完整实现
2026/9/16 3:41:33 网站建设 项目流程

简介:这是一份基于Pygame实现“看图猜单词”游戏的Python入门项目,面向正在学习Python游戏开发或希望理解Pygame核心用法的初学者,也适合作为教学演示案例。压缩包内共14个文件,包含main.py主脚本、png图片素材、wav音效、ttf字体以及words.txt词库文件,整体大小仅1.41MB,结构精简,运行门槛低。目前已有142人学习下载。项目完整演示了Pygame的初始化、主循环、键盘事件监听、图像与文字的blit渲染,以及猜词反馈、剩余次数提示和图片线索展示等游戏逻辑;词库、图片、字体与音效独立存放,便于替换和二次开发。通过拆解这份代码,读者可以快速掌握2D游戏的基本框架,并在此基础上扩展多关卡、计分或动画效果,是一份兼顾趣味性与学习价值的实战资源。

1. 看图猜单词游戏,难的不是 pygame,是数据组织

拿到“看图猜单词”这个题目,多数人第一反应是去写窗口、画图片、监听鼠标,结果写到一半发现最耗时的不是事件循环,而是“下一关出什么题”这件事。Pygame 本身只负责三件事:把图片画到屏幕上、把文字画到按钮上、告诉你鼠标点在哪里。游戏逻辑——哪张图对应哪个单词、干扰选项怎么挑、答对答错怎么记分——才是这个项目真正值得设计的部分。

这篇文章按我实际做这类项目时的顺序来:先定数据结构和出题策略,再写渲染和事件,然后组装状态机,最后处理安装、字体、打包这些一定会遇到的坑。适合刚学完 Python 基础、想拿 pygame 做第一个完整作品的人,也适合想看看“小游戏怎么组织代码结构”的开发者。看完你能得到一个可以直接跑的骨架,并且知道每个参数为什么这么设。

2. 看图猜单词的数据层设计:词库、配图与干扰项生成

游戏手感好不好,百分之六十取决于“题”是怎么生成的。一个单词配一张图,四个选项里只有一个正确——这句话听起来简单,但仔细一拆会发现几个必须解决的问题:单词库长什么样;每张图片怎么和单词绑定;干扰项怎么选才不至于让玩家一眼排除;同一张图会不会反复出现在不同关卡里。

我一般把数据分成两层。第一层是静态词库,也就是所有题目素材;第二层是每局游戏动态生成的“关卡列表”。静态词库用列表套字典就够了,每个元素包含英文单词、中文释义、图片文件名三个字段。图片按单词命名,省去额外关联表。

WORDS = [ {"word": "apple", "cn": "苹果", "img": "apple.png"}, {"word": "banana", "cn": "香蕉", "img": "banana.png"}, {"word": "cat", "cn": "猫", "img": "cat.png"}, {"word": "dog", "cn": "狗", "img": "dog.png"}, ]

这里有个值得注意的细节:图片文件名用单词本身命名,看起来有点土,但省掉了 ID 关联的麻烦。你不需要维护id -> wordid -> img两张表,代码里少一层映射,新手排错就少一处疑点。cn字段很多人会省略,但实际游戏里答案区往往需要显示中文作为辅助,留着它后面写界面会省很多事。

2.1 关卡生成:从词库中抽题并构造干扰项

有了静态词库,下一步是生成一局游戏的关卡。常见的做法是“随机选取 N 个不重复的单词作为正确答案,每道题的干扰项从其他单词里随机抽 3 个”。这里最容易被忽略的边界条件是:干扰项不能和正确答案重复,而且最好也不要和正确答案长得像,否则玩家会因为拼写接近而误选。考虑到是入门项目,我先把“不允许重复”做掉,“拼写相似度”放到第六节当进阶话题。

import random def build_quiz(words, round_count=10, distract_count=3): selected = random.sample(words, k=round_count) quiz = [] for item in selected: others = [w for w in words if w["word"] != item["word"]] distractors = random.sample(others, k=distract_count) options = [item["word"]] + [d["word"] for d in distractors] random.shuffle(options) quiz.append({ "word": item["word"], "cn": item["cn"], "img": item["img"], "options": options, "answer": item["word"], }) return quiz

这个函数的核心逻辑是random.sample的两次使用。第一次从全部词库里抽出round_count个不重复单词作为当轮题目,第二次从“除了正确答案以外的词”里抽出distract_count个干扰项。注意others的推导式写法,它保证了干扰项和正确答案一定不同——这是最容易踩烂的边界,如果直接用random.sample(words, ...),有概率把正确答案抽进干扰项里。最后random.shuffle(options)让正确答案不固定在同一个位置。

参数方面,round_count=10表示一局 10 题,适合 3 到 5 分钟的游戏;distract_count=3加上正确答案正好四个选项,是看图猜词最舒服的配置。两个参数都建议从数据层开放出来,后面做难度分级时直接传参即可,比如简单模式 6 题、每题 3 个选项,困难模式 15 题、每题 6 个选项。

2.2 图片资源管理:路径约定与缺失兜底

数据层还有一个容易在开发后期爆雷的点:图片路径。Pygame 的image.load只接收路径字符串,不会帮你查找文件。如果你的代码从项目根目录启动,而图片在assets/images/下,直接写相对路径“看上去没问题”,但换个目录启动就可能崩。

我的做法是定义常量并用pathlib处理路径拼接,同时加一个缺失图片的兜底——用一张纯色替代图撑住画面,避免游戏直接崩溃。

from pathlib import Path BASE_DIR = Path(__file__).resolve().parent IMG_DIR = BASE_DIR / "assets" / "images" def load_img(filename, size=(320, 240), fallback_color=(200, 200, 200)): path = IMG_DIR / filename try: img = pygame.image.load(str(path)) img = pygame.transform.scale(img, size) except FileNotFoundError: img = pygame.Surface(size) img.fill(fallback_color) return img.convert()

这里有三个参数值得说明。size=(320, 240)是题目图片的显示尺寸,需要按你窗口布局来定,比例不对会导致图片拉伸变形;fallback_color是找不到图片时替补底色,开发阶段建议用显眼的颜色(比如红色),这样扫一眼就知道哪张图缺失,上线前再改成灰色。“convert()” 这个方法容易被忽略,它把图片转成与屏幕相同的像素格式,绘制速度会明显提升,是 pygame 性能优化里性价比最高的一行代码。

3. pygame 渲染与交互:窗口、事件循环、图片与按钮绘制

数据层准备好之后,再来看 pygame 的活。很多教程喜欢一上来就写完整游戏代码,几百行糊在一个文件里,新手跟不下来,老手看着头疼。我习惯把它拆开看:初始化、绘制、事件、主循环四个组成部分,每个部分其实都有自己的套路。

先看初始化和主循环的基本结构,这是所有 pygame 程序的骨架。

import pygame pygame.init() screen = pygame.display.set_mode((900, 600)) pygame.display.set_caption("看图猜单词") clock = pygame.time.Clock() running = True while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.MOUSEBUTTONDOWN: handle_click(event.pos) draw(screen) pygame.display.flip() clock.tick(60) pygame.quit()

这段代码里的每个组件都是固定的:pygame.display.set_mode创建窗口,clock.tick(60)把帧率锁在 60 FPS,pygame.display.flip()把绘制结果推上屏幕。新手最容易犯的错误是把绘制代码写在事件循环外面或者在flip()之后继续画图。记住一个原则:每一帧先清屏、再画全部内容、最后 flip,不要试图只更新局部画面——对于这种简单游戏,全量重绘的代价微乎其微。

3.1 事件处理:鼠标点击区域判定

看图猜单词的主要交互是“点按按钮选择答案”,交互难度不在点击本身,而在“按钮区域的命中判定”。你需要画一个矩形按钮,然后判断鼠标坐标是否落在矩形内部。Pygame 没有内置的 UI 控件库,矩形按键需要自己写。

def draw_button(screen, rect, text, font, bg_color, text_color=(255, 255, 255)): pygame.draw.rect(screen, bg_color, rect, border_radius=8) text_surf = font.render(text, True, text_color) text_rect = text_surf.get_rect(center=rect.center) screen.blit(text_surf, text_rect) def handle_click(pos, option_rects, quiz_item): for idx, rect in enumerate(option_rects): if rect.collidepoint(pos): player_answer = quiz_item["options"][idx] return player_answer == quiz_item["answer"] return None

pygame.draw.rectborder_radius=8让按钮带圆角,视觉上比直角更接近现代 UI;font.render生成文字表面,然后通过get_rect(center=rect.center)把文字居中在按钮内。collidepoint是矩形对象的成员方法,直接判断一个点是否落在矩形里,比自己写min <= x <= max要简洁得多。

这个设计里我把“选项下标”和“选项内容”的对应关系封装在option_rectsquiz_item里。为什么要返回None而不是返回值?因为点击空白区域既不算对也不算错,应该直接忽略,None这个三态返回值(True/False/None)能让游戏状态机区分“点空了”和“答错了”,后面写状态逻辑时这个区分很重要。

3.2 图片自适应绘制与常用事件速查

题目图片的绘制也不是直接blit就完事。如果图片源尺寸和显示区域比例不一致,会出现拉伸变形。常见方案是“等比缩放 + 居中填充”,也就是先按宽度和高度分别计算缩放比例,取较小值保证图片完整显示,然后用背景色填充剩余空间。

def draw_image(screen, img, target_rect): img_rect = img.get_rect() scale = min(target_rect.width / img_rect.width, target_rect.height / img_rect.height) new_w = int(img_rect.width * scale) new_h = int(img_rect.height * scale) scaled = pygame.transform.smoothscale(img, (new_w, new_h)) pos = (target_rect.x + (target_rect.width - new_w) // 2, target_rect.y + (target_rect.height - new_h) // 2) screen.blit(scaled, pos)

min这一行是整个函数的关键。当图片较宽时,宽度比例会很小,取min可以保证整体缩放在图片较长的那条边上,另一条边留白;如果你取max,图片会填满区域但超出裁剪,视觉上会缺一块。smoothscalescale缩放质量更好,代价是稍微多占一点 CPU,对静态图片来说完全值得。

开发时遇到“点了按钮没反应”或者“画面不更新”这类问题,首先应该怀疑事件有没有进对分支。下面这张表是我做 pygame 项目时经常对照的,它列出了本游戏会用到的核心事件类型:

事件类型触发时机本游戏中的用途
pygame.QUIT点击窗口关闭按钮退出主循环
pygame.MOUSEBUTTONDOWN鼠标按下任意键检测答案按钮点击
pygame.MOUSEMOTION鼠标移动按钮悬停变色效果
pygame.KEYDOWN键盘按键按下回车键提交/跳过

注意MOUSEBUTTONDOWNMOUSEBUTTONUP的区别。如果你判断点击用的是前者,玩家在按钮上按住鼠标再拖出按钮范围松开,依然会被判为一次点击;如果用后者,则可以做成“按住不放取消、松开才确认”的防误触逻辑。入门项目里用MOUSEBUTTONDOWN就行,但你要知道这个差异存在于现实中。

4. 游戏状态机设计:把图片、选项、计分和流程串起来

现在数据层和渲染层都备齐了,但如果你直接把它们拼到一起,很快会发现一个问题:游戏有多个阶段——刚打开时的欢迎页、展示题目等待回答、答对后的反馈、答错后的反馈、一局结束的结算页——这些阶段不能同时显示,它们之间需要切换。这就是状态机的用武之地。

看图猜单词最少需要五个状态,我用常量或者enum来定义。状态切换的触发条件很简单:欢迎页点击“开始”进入题目状态;题目状态点击选项后,无论对错都切换成“反馈”状态并显示 1 秒结果;反馈计时结束进入下一题或者进入结算页。

MENU, PLAYING, FEEDBACK, GAME_OVER = range(4) state = MENU current_quiz = None quiz_index = 0 score = 0 feedback_timer = 0 feedback_correct = False

四个变量分别记录当前状态、当前题目数据、题目进度、总分和反馈倒计时。feedback_timer是用时间驱动的,它需要一个递增或递减的计数逻辑。很多人会想到用pygame.time.wait阻塞线程来“暂停 1 秒”,但那是错误方案——主循环被阻塞后窗口会无响应,在 Windows 上会显示“程序未响应”。

正确做法是基于帧率计时,把反馈状态的持续时间折算成帧数:

FEEDBACK_FRAMES = 60 # 60帧约1秒 while running: for event in pygame.event.get(): ... if state == PLAYING: # 处理点击,更新 score、feedback_correct pass elif state == FEEDBACK: feedback_timer -= 1 if feedback_timer <= 0: quiz_index += 1 if quiz_index >= len(quiz): state = GAME_OVER else: current_quiz = quiz[quiz_index] state = PLAYING draw() pygame.display.flip() clock.tick(60)

FEEDBACK_FRAMES = 60配合clock.tick(60)就是“显示 1 秒反馈画面”。这个写法的好处是主循环始终在运转,可以在反馈期间播放音效、画动画,不会被阻塞。如果机器帧率不稳定,比如掉到 40 FPS,实际反馈会拉长到 1.5 秒,但对这种休闲游戏来说完全可以接受。需要更高精度时可以用pygame.time.get_ticks()记录毫秒级时间戳,然后相减判断——那是另一种更严谨的做法,入门先理解帧数计时的原理。

4.1 绘制层的状态分发

有了状态变量之后,draw函数内部要有对应的分支逻辑。这是整个项目里最容易变成“意大利面条”的地方。我的经验是:不要用一堆 if 直接堆内容,而是按状态拆成独立的绘制函数,每个函数只负责一个画面的所有内容。

def draw(screen, state, ...): screen.fill((30, 30, 40)) if state == MENU: draw_menu(screen) elif state == PLAYING: draw_question(screen) draw_options(screen) draw_score(screen) elif state == FEEDBACK: draw_question(screen) draw_options(screen) draw_feedback(screen) elif state == GAME_OVER: draw_game_over(screen)

FEEDBACK状态里我选择保留题目区域和选项按钮的绘制,目的是让玩家在反馈时能看到自己刚才点了哪个按钮,然后在这个基础上叠加一层半透明蒙版或者提示文字。如果你只画反馈文字而把题目清掉,玩家会短暂失忆:“我刚才点的是哪个来着?”视觉连续性对这种需要学习的游戏很重要。

4.2 计分与进度展示

计分逻辑我建议用两个指标:答对的题数和连续的“答对次数”。“连续答对”也叫 streak,它能制造小小的紧张感,让玩家在简单单词上也不会乱点。计分规则不复杂,放在游戏主循环里用一个全局字典维护即可。下面是计分组件的实现片段,里面包含了分数计算和 UI 文本拼装:

stats = {"score": 0, "correct": 0, "total": 0, "streak": 0} def evaluate_answer(selected, answer): is_correct = selected == answer stats["total"] += 1 if is_correct: stats["score"] += 10 + stats["streak"] * 2 stats["correct"] += 1 stats["streak"] += 1 else: stats["streak"] = 0 return is_correct def draw_stats(screen, font, stats): lines = [ f"得分: {stats['score']}", f"正确: {stats['correct']}/{stats['total']}", f"连对: {stats['streak']}", ] for i, line in enumerate(lines): surf = font.render(line, True, (220, 220, 220)) screen.blit(surf, (20, 20 + i * 30))

计分公式10 + streak * 2的意思是基础分 10 分,连对加成分每层 2 分。这个公式是拍脑袋定的,但它传递了一个设计思考:如果连对加成分太高(比如 5 分),玩家会在某一题卡住前积累巨大优势,后段变得无所谓;如果太低,streak 机制形同虚设。2 分是比较温和的激励。f-string直接拼进绘制函数里,比单独维护文本类更直观。字体大小建议至少 24px,低于这个数值在高分辨率窗口下会难以辨认。

5. pygame 安装、中文显示与图片加载排错

进入实战环节,先处理一个所有初学者都会卡住的点:pygame 安装失败。热搜里反复出现error: failed to build 'pygame' when getting requirements to build wheel,这个错误的本质是:你的机器上没有合适的预编译 wheel 包,pip 尝试从源码编译 pygame,而编译需要系统层面的 C 开发库和 SDL 依赖。

5.1 先用预编译包,解决 failed to build pygame

现代 PC 上绝大多数乘 Python 3.10 以上的环境都能直接装到预编译包。正确的命令是:

pip install pygame

或者显式指定只使用预编译二进制:

pip install --only-binary :all: pygame

如果第二条命令报错“找不到匹配的发行版”,说明你用的 Python 版本太新或太老,官方还没提供对应架构的 wheel。这时候在 Linux 系统上,需要先安装系统依赖再重新编译:

sudo apt-get install libsdl2-dev libsdl2-image-dev libsdl2-mixer-dev libsdl2-ttf-dev pip install --no-cache-dir pygame

Linux 下安装libsdl2-dev这组包是编译 pygame 的常见前置条件,缺少它们时编译器会在处理 SDL 头文件阶段报错。安装完成后用下面的代码验证是否可用:

python -c "import pygame; print(pygame.version.ver)"

能输出版本号就说明基础环境没问题。如果这一步报ModuleNotFoundError,检查是不是在虚拟环境里执行了安装但又在全局环境里跑脚本。

5.2 中文无法显示的根因与修复

Pygame 的默认字体pygame.font.Font(None, size)用的内置字体不包含中文字形,所以font.render("苹果", ...)画出来是一堆方框或者什么都不显示。解决方案是手动指定一个本机已有的中文字体路径:

def load_font(size): font_paths = [ "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", "C:/Windows/Fonts/msyh.ttc", "C:/Windows/Fonts/simhei.ttf", ] for path in font_paths: if Path(path).exists(): return pygame.font.Font(str(path), size) return pygame.font.Font(None, size)

这段代码用Path.exists()依次探测三处常见字体位置。Windows 上msyh.ttc是微软雅黑,simhei.ttf是黑体;Linux 上 Noto Sans CJK 是各大发行版最常见的开源中文字体。在实际项目中,建议把可选字体列表抽成配置项,方便不同系统的用户自定义。字体文件路径写死虽然丑,但比让用户手动上传字体文件要皮实得多。

5.3 图片加载失败的三个排查方向

图片加载除了路径问题,还有格式和颜色格式两个方向。pygame.image.load支持 PNG、JPG 和 GIF,但不支持 WebP 等较新格式。开发阶段图片加载不出来,按下面顺序检查:

  1. 路径对不对:用os.path.exists打印检查,不要靠猜。推荐在启动时打印一次IMG_DIR的绝对路径。
  2. 文件是否损坏:部分损坏的 PNG 文件能打开但绘制出来全是乱码或透明块。
  3. 是否调用了convert():没有 convert 的图片在每次blit时会做像素格式转换,可能显示偏色或性能异常,并不会“加载失败”,但很容易被误判为问题。

这里额外提一句:把截图截图再改名保存会导致某些 PNG 文件带上错误色深信息。如果你用的是 macOS 的截图工具,注意它以 PNG 保存时可能包含 P3 色域信息,pygame 在老版本里对这类文件兼容性欠佳。稳妥的做法是在项目里写一个小脚本统一处理图片尺寸和格式。

5.4 主循环中的常见性能问题

入门项目往往到不了性能瓶颈,但有一个坑值得一提:如果图片动起来一卡一卡,先看是不是每帧都在pygame.image.load。加载一次图片的开销远高于绘制一次,正确做法是在初始化阶段把所有图片提前加载到字典里,后续只做查找和绘制。这个思路和游戏开发里的“资源预加载”同源,小游戏用字典缓存就足够:

image_cache = {} def get_image(name, size): key = (name, size) if key not in image_cache: image_cache[key] = load_img(name, size) return image_cache[key]

注意缓存 key 里包含了尺寸,因为同一张图在不同界面可能需要不同大小。如果直接以文件名为 key,第一次加载的是 320x240,第二次要 640x480 时就会拿到错的尺寸。

6. 进阶技巧:答案记录、难度分级与打包发布

游戏能跑通之后,想让它更像一个“产品”而不是教学 demo,有三个投入产出比很高的改进方向。第一个是记录答题数据,这不仅能让玩家看到自己的强弱项,还能用来做后续内容的个性化出题。下面这个小函数把做错的单词追加到本地 JSON 文件里,每次出题时优先从不熟悉的单词中抽取:

import json WRONG_FILE = BASE_DIR / "wrong_words.json" def record_wrong(word): try: with open(WRONG_FILE, "r", encoding="utf-8") as f: wrong_list = json.load(f) except (FileNotFoundError, json.JSONDecodeError): wrong_list = [] if word not in wrong_list: wrong_list.append(word) with open(WRONG_FILE, "w", encoding="utf-8") as f: json.dump(wrong_list, f, ensure_ascii=False, indent=2)

这里用json.load读取已有记录时捕获了两种异常:文件不存在和文件内容损坏。JSON 文件在开发阶段容易被手写坏或者写出不完整内容,捕获异常后重置列表比直接崩溃要友好。数据层拿到wrong_list后,可以把这些词标记为“高频出题词”,在build_quiz里提高选中权重。权重算法最简单的版本是:从错题里抽一半、从全量词库里抽一半,然后合并打乱。

第二个方向是难度分级。把词库拆成初级、中级、高级三份,每份单独配置选项数和题目数。这个改动只在数据层发生,build_quiz的参数已经留好了接口。再加上关卡选择界面的话,就需要在 UI 层增加一个状态,这是把游戏从“单关卡脚本”变成“完整产品”的分水岭。

最后说打包。分发 pygame 游戏最常用的工具是 PyInstaller:

pip install pyinstaller pyinstaller --onefile --windowed --add-data "assets:assets" main.py

--onefile把整个环境打包成单个 exe,--windowed去掉控制台窗口,--add-data把图片资源打进包里。打包后运行时报“找不到图片”,是因为 PyInstaller 会把资源解压到临时目录,此时__file__指向的是临时路径,原来的Path(__file__).parent / "assets"失效。用sys._MEIPASS判断是一个通用的处理方案,但它属于另一个标题该讲的坑了。

做完了这三个改进,这个项目就不再是写着练手的代码,而是一个可以丢给朋友玩的应用。拿它去拓展——换一个词库就是英语学习工具,换一套图片就是儿童识字游戏,改一下数据结构就能从“看图猜单词”变成“看图猜成语”。Pygame 适合做这种轻量、独立、打包即用的图形小工具,它的边界恰好也是它的价值所在。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询