
很多学 Python 的人都有一种体会语法书看完了练习题也刷了不少但真要独立做一个“完整的东西”还是会卡在第一步——不知道从哪里下手。游戏开发恰好是能同时练到逻辑、面向对象、图形绘制和交互处理的落地方向。而“三角形游戏”又是游戏项目里最容易摸到完整开发流程的入门项目之一。本文会从零实现一个可运行、可玩、带简单难度曲线的三角形躲避游戏。玩家控制屏幕下方的三角形飞船移动躲避从屏幕上方生成的敌人存活时间越久分数越高撞到敌人游戏结束按 R 键重新开始。项目基于 Python 3 和 Pygame不依赖任何现成游戏引擎代码全部从零手写。适合读这篇文章的读者有两类一类是掌握了 Python 基础语法、想通过项目巩固面向对象编程和图形界面开发的初学者另一类是已经能写脚本但希望快速体验 Pygame 开发流程的开发者。读完以后你不仅拥有一个可以随时打开玩的游戏还能理解游戏循环、事件处理、坐标变换、碰撞检测等游戏开发通用概念。后面哪怕换语言、换引擎这些底层的思维方式依然能用上。1. 背景与核心概念1.1 The Triangle Game 是一个什么样的游戏The Triangle Game 是一个非常经典的小型动作游戏原型玩家控制一个三角形在屏幕底部区域自由移动屏幕顶部会不断生成圆形敌人并向下飞行。玩家的目标就是在敌人之间穿梭活得越久越好。这个游戏看起来简单但做起来需要处理的东西一点都不少如何绘制一个三角形并让它可以朝不同方向旋转。如何监听键盘输入让玩家移动起来。如何按固定节奏生成敌人。如何判断玩家和敌人是否撞上。如何让游戏在结束时进入可重启的状态。三角形是 2D 游戏里最容易实现的角色图形之一。相比角色图片素材用代码绘制一个三角形可以省去美术资源准备过程把注意力全部放在游戏机制和程序结构上。这也是它特别适合练手的原因。1.2 为什么“从零实现”对学习有价值现在做游戏有很多成熟的引擎比如 Unity、Godot、Unreal甚至 Pygame 本身也算是一种“半引擎”的封装库。如果直接用编辑器拖拽几分钟就能搭出一个原型但很多关键概念会被界面和工具遮挡住。从零实现则刚好相反没有场景编辑器没有组件系统没有精灵动画所有东西都要自己写。你被迫去理解游戏循环是怎么转起来的事件是怎么分发到逻辑层的角色移动为什么需要向量运算碰撞检测为什么不是“看起来差不多就行”。这些概念一旦通过自己写代码理解透将来再看任何游戏引擎的文档都会觉得顺眼很多。你会知道引擎帮你做的工作是什么而不是在黑盒里盲目操作。1.3 技术选型为什么用 Python PygamePygame 是 Python 生态里最常用的 2D 游戏开发库之一。它基于 SDL 库封装提供了窗口创建、图形绘制、事件处理、音频播放等底层能力同时又保留了 Python 语法简洁的特点。选 Python Pygame 的原因很简单环境搭建成本低一条pip install pygame就能完成。代码量可控核心逻辑两百行左右就能跑出一个完整游戏。Pygame 的 API 直观比如pygame.draw.polygon画多边形、pygame.draw.circle画圆形看到方法名就能猜到用途。学习资料多遇到问题容易在社区找到解决方案。当然Pyhame 并不适合做大型 3D 游戏它面向的就是 2D 小游戏、原型验证和初学者练习。拿它入门游戏开发恰好在“简单”和“真实”之间取得了平衡。2. 环境准备与版本说明2.1 运行环境与版本要求本文示例代码基于以下环境编写Python 3 环境建议使用 Python 3.8 或更高版本。Pygame 2.x 版本本文使用的是主流稳定版本代码基于 2.x 常见接口编写。操作系统不限Windows、macOS、Linux 均支持。如果你的环境刚好不是这些版本也不需要担心代码中使用的都是 Pygame 基础且稳定的接口例如pygame.display.set_mode、pygame.key.get_pressed、pygame.draw.polygon等这些接口在 Pygame 2.x 中没有显著变化。具体版本以你本机实际安装为准关键是理解思路。2.2 安装 Pygame 与验证安装 Pygame 使用 pip 即可pip install pygame如果你的机器上同时存在多个 Python 环境建议用模块方式安装避免把包装错环境python -m pip install pygame在 Linux 或 macOS 上如果系统使用python3作为 Python 3 命令可以执行python3 -m pip install pygame安装完成后用下面这行命令验证是否安装成功python -c import pygame; print(pygame.version.ver)如果能看到类似2.5.2这样的版本号输出说明 Pygame 已经正确安装。如果提示ModuleNotFoundError说明安装失败或者装到了另一个 Python 环境中需要回到安装步骤检查命令。如果安装速度很慢可能是默认源网络不稳定可以临时换成国内镜像源pip install pygame -i https://pypi.tuna.tsinghua.edu.cn/simple这里补充一句镜像源只是从国内服务器下载提速不会影响包本身的功能可以放心使用。2.3 项目目录结构入门阶段建议先用单文件方式把所有代码放在同一个main.py里方便阅读和调试。项目结构非常简单triangle_game/ └── main.py当代码规模变大以后再考虑拆分层次triangle_game/ ├── main.py ├── settings.py ├── player.py ├── enemy.py ├── game.py └── assets/ ├── images/ └── sounds/不过本文演示的小游戏单文件完全够用也更适合初学者完整地看懂一个项目的来龙去脉。3. 游戏设计与核心原理拆解3.1 游戏循环所有游戏的心脏任何一个实时游戏无论多复杂最底层都是一套循环每一帧重复做“读取输入、更新状态、绘制画面”这三件事。下面是本文游戏最核心的循环骨架running True while running: # 1. 处理事件 for event in pygame.event.get(): if event.type pygame.QUIT: running False # 2. 更新游戏状态 keys pygame.key.get_pressed() player.handle_input(keys) for enemy in enemies: enemy.update() # 3. 绘制画面 screen.fill((18, 18, 24)) player.draw(screen) for enemy in enemies: enemy.draw(screen) pygame.display.flip() # 4. 控制帧率 clock.tick(60)这段逻辑理解起来并不难但它是整个游戏的地基。pygame.event.get()会取出窗口事件比如点击关闭按钮、按下按键等。pygame.key.get_pressed()返回当前所有按键的状态适合做连续移动这种实时操作。screen.fill用背景色覆盖上一帧画面避免出现残影。所有draw方法把图形画到内存中的画布上最后用pygame.display.flip()一次性提交到屏幕。clock.tick(60)控制循环每秒最多执行 60 次也就是 60 FPSFrames Per Second。对于实时游戏来说如果缺少事件处理窗口就会变成“未响应”状态如果缺少flip()画面始终不会刷新。这些都是新手最容易忽略的细节。3.2 屏幕坐标系与绘制基础Pygame 的屏幕坐标系和数学里的坐标系不同原点在窗口左上角x 轴向右增大y 轴向下增大。也就是说越往下 y 值越大。这一点在写敌人移动逻辑时特别重要。本文中的敌人从顶部生成需要向下移动所以更新位置时要让y不断增加self.y self.dy如果按惯性写成y - 1敌人就会反向飞出屏幕整个游戏体验会完全不同。绘制方面Pygame 最基本的方法是pygame.draw.polygon(screen, color, points)绘制多边形。pygame.draw.circle(screen, color, center, radius)绘制圆形。pygame.draw.line(screen, color, start, end, width)绘制直线。利用这几组方法基本可以画出绝大多数 2D 原型游戏里的图形。3.3 玩家移动方向向量与归一化控制玩家移动时最直观的思路是按左键 x 减 1按右键 x 加 1按上键 y 减 1按下键 y 加 1。但这里有一个新手经常踩的坑如果用户同时按“上”和“右”那么dx 1、dy -1直接这样移动的话实际合速度会变成sqrt(1^2 1^2) 1.414也就是说斜着移动会比上下左右移动更快。为了让八个方向速度一致需要把方向向量归一化也就是把向量长度缩放到 1if dx ! 0 or dy ! 0: length math.hypot(dx, dy) dx / length dy / lengthmath.hypot(dx, dy)等价于math.sqrt(dx * dx dy * dy)用来计算向量长度。除以长度之后(dx, dy)就变成了单位向量。再乘以玩家速度就得到这一帧的实际位移self.x dx * self.speed self.y dy * self.speed这是一个非常基础但非常重要的数学知识点很多游戏角色的移动逻辑都建立在向量运算之上。3.4 绘制旋转三角形顶点坐标计算绘制三角形本身不难只要给pygame.draw.polygon传三个顶点坐标即可。但玩家在游戏中会朝不同方向移动三角形需要跟着旋转。我的做法是先在本地坐标系中定义三角形也就是以玩家位置为原点尖端朝上local_points [ (0, -self.radius * 1.4), # 尖端 (-self.radius, self.radius * 0.8), # 左下角 (self.radius, self.radius * 0.8), # 右下角 ]然后再根据玩家的朝向角度对每个点做旋转和平移。假设角度为angle旋转矩阵公式是world_x self.x local_x * cos(angle) - local_y * sin(angle) world_y self.y local_x * sin(angle) local_y * cos(angle)对应代码rad math.radians(self.angle) cos_a math.cos(rad) sin_a math.sin(rad) points [] for lx, ly in local_points: wx self.x lx * cos_a - ly * sin_a wy self.y lx * sin_a ly * cos_a points.append((wx, wy)) pygame.draw.polygon(screen, PLAYER_COLOR, points)玩家移动时可以通过atan2算出方向角self.angle math.degrees(math.atan2(dy, dx)) 90这里加 90 度是因为atan2算出来的是 x 轴正方向的角度而三角形本地坐标默认尖端朝上也就是 y 轴负方向两者相差 90 度。这段逻辑初看有点绕但它是 2D 图形学里最常用的旋转写法值得反复理解。3.5 敌人生成、移动与碰撞检测敌人的生成逻辑比较直接在窗口顶部随机一个 x 坐标从屏幕上方边界外面开始下落避免玩家看不见敌人“凭空出现”。为了让敌人不是单调直线下落可以给每个敌人一个随机的斜向速度但必须保证方向总体向下dx_probe random.uniform(-1.0, 1.0) dy_probe random.uniform(0.3, 1.0) length math.hypot(dx_probe, dy_probe) self.dx dx_probe / length * speed self.dy dy_probe / length * speeddy_probe限定在 0.3 到 1.0 之间让dy始终为正数保证敌人始终向下移动同时斜向角度有随机变化。碰撞检测采用圆与圆相交的判定方式这是最简单也最常用的碰撞检测方法。玩家三角形有一个外接圆半径敌人是一个圆两者圆心距离小于半径之和就认为发生碰撞def check_collision(player, enemy): dist math.hypot(player.x - enemy.x, player.y - enemy.y) return dist player.radius enemy.radius这种判定方式会有一定误差三角形四个角的位置比外接圆更“虚”会出现视觉上没碰到但被判定为碰撞的情况。不过对入门项目来说足够用后面在“最佳实践”一节会提到更精确的改进方案。4. 完整实战案例4.1 创建项目文件在任意目录下新建一个文件夹triangle_game并在里面创建main.py。下面的所有代码都写入main.py。4.2 完整代码下面是 The Triangle Game 的完整实现代码直接复制即可运行。 The Triangle Game, from Zero 一个用 Python Pygame 从零实现的三角形躲避小游戏 操作说明 - W / A / S / D 或方向键控制三角形移动 - R游戏结束后重新开始 - ESC退出游戏 import math import random import sys import pygame # --------------------------- # 区域 1常量配置 # --------------------------- SCREEN_WIDTH 800 SCREEN_HEIGHT 600 FPS 60 PLAYER_SPEED 5 PLAYER_RADIUS 15 PLAYER_COLOR (80, 200, 120) ENEMY_RADIUS_MIN 8 ENEMY_RADIUS_MAX 14 ENEMY_SPEED_MIN 2.0 ENEMY_SPEED_MAX 4.5 ENEMY_COLOR (220, 90, 90) ENEMY_INTERVAL 60 # 初始每隔 60 帧生成一个敌人 BG_COLOR (18, 18, 24) SCORE_COLOR (240, 240, 240) GAME_OVER_COLOR (255, 90, 90) # --------------------------- # 区域 2玩家类 # --------------------------- class Player: 玩家控制的三角形默认朝上会跟随移动方向旋转。 def __init__(self, x, y): self.x x self.y y self.radius PLAYER_RADIUS self.speed PLAYER_SPEED self.angle 0 def handle_input(self, keys): dx 0 dy 0 if keys[pygame.K_LEFT] or keys[pygame.K_a]: dx - 1 if keys[pygame.K_RIGHT] or keys[pygame.K_d]: dx 1 if keys[pygame.K_UP] or keys[pygame.K_w]: dy - 1 if keys[pygame.K_DOWN] or keys[pygame.K_s]: dy 1 if dx ! 0 or dy ! 0: length math.hypot(dx, dy) dx / length dy / length self.angle math.degrees(math.atan2(dy, dx)) 90 self.x dx * self.speed self.y dy * self.speed self.x max(self.radius, min(SCREEN_WIDTH - self.radius, self.x)) self.y max(self.radius, min(SCREEN_HEIGHT - self.radius, self.y)) def draw(self, screen): 按照当前角度绘制一个三角形。 rad math.radians(self.angle) cos_a math.cos(rad) sin_a math.sin(rad) local_points [ (0, -self.radius * 1.4), (-self.radius, self.radius * 0.8), (self.radius, self.radius * 0.8), ] points [] for lx, ly in local_points: wx self.x lx * cos_a - ly * sin_a wy self.y lx * sin_a ly * cos_a points.append((wx, wy)) pygame.draw.polygon(screen, PLAYER_COLOR, points) # --------------------------- # 区域 3敌人类 # --------------------------- class Enemy: 从顶部随机位置生成以随机斜向速度向下移动。 def __init__(self, speed_multiplier1.0): self.radius random.randint(ENEMY_RADIUS_MIN, ENEMY_RADIUS_MAX) self.x random.randint(self.radius, SCREEN_WIDTH - self.radius) self.y -self.radius base_speed random.uniform(ENEMY_SPEED_MIN, ENEMY_SPEED_MAX) * speed_multiplier dx_probe random.uniform(-1.0, 1.0) dy_probe random.uniform(0.3, 1.0) length math.hypot(dx_probe, dy_probe) self.dx dx_probe / length * base_speed self.dy dy_probe / length * base_speed def update(self): self.x self.dx self.y self.dy def draw(self, screen): pygame.draw.circle(screen, ENEMY_COLOR, (int(self.x), int(self.y)), self.radius) def off_screen(self): return self.y - self.radius SCREEN_HEIGHT # --------------------------- # 区域 4碰撞检测 # --------------------------- def check_collision(player, enemy): 使用圆与圆相交的判定玩家的三角形用外接圆近似。 dist math.hypot(player.x - enemy.x, player.y - enemy.y) return dist player.radius enemy.radius # --------------------------- # 区域 5游戏主类 # --------------------------- class Game: def __init__(self): pygame.init() self.screen pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT)) pygame.display.set_caption(The Triangle Game - from Zero) self.clock pygame.time.Clock() self.font pygame.font.SysFont(Arial, 24) self.reset() def reset(self): self.player Player(SCREEN_WIDTH // 2, SCREEN_HEIGHT - 80) self.enemies [] self.score 0 self.frame_count 0 self.running True self.game_over False def handle_events(self): for event in pygame.event.get(): if event.type pygame.QUIT: self.running False elif event.type pygame.KEYDOWN: if event.key pygame.K_ESCAPE: self.running False elif event.key pygame.K_r and self.game_over: self.reset() def spawn_enemies(self): self.frame_count 1 interval max(15, ENEMY_INTERVAL - self.score // 80) if self.frame_count % interval 0: speed_multiplier 1 self.score / 800 self.enemies.append(Enemy(speed_multiplier)) if self.score 200: self.enemies.append(Enemy(speed_multiplier)) def update(self): if self.game_over: return keys pygame.key.get_pressed() self.player.handle_input(keys) self.spawn_enemies() for enemy in self.enemies: enemy.update() self.enemies [e for e in self.enemies if not e.off_screen()] for enemy in self.enemies: if check_collision(self.player, enemy): self.game_over True break if not self.game_over: self.score 1 def draw(self): self.screen.fill(BG_COLOR) self.player.draw(self.screen) for enemy in self.enemies: enemy.draw(self.screen) score_text self.font.render(fScore: {self.score}, True, SCORE_COLOR) self.screen.blit(score_text, (12, 12)) if self.game_over: over_text self.font.render(Game Over - Press R to Restart, True, GAME_OVER_COLOR) rect over_text.get_rect(center(SCREEN_WIDTH // 2, SCREEN_HEIGHT // 2)) self.screen.blit(over_text, rect) pygame.display.flip() def run(self): while self.running: self.handle_events() self.update() self.draw() self.clock.tick(FPS) pygame.quit() sys.exit() # --------------------------- # 区域 6程序入口 # --------------------------- if __name__ __main__: Game().run()4.3 运行与验证在项目目录下执行python main.py如果使用 Linux 或 macOS 且python指向的是 Python 2请改用python3 main.py运行成功后会弹出一个 800×600 的黑色窗口窗口标题是The Triangle Game - from Zero。画面效果如下窗口底部中央有一个绿色三角形。窗口顶部不断生成红色圆形敌人向下斜向移动。左上角显示实时分数分数会随着存活时间增长。玩家移动时三角形会朝移动方向旋转。撞到敌人后画面中央显示Game Over - Press R to Restart。按下 R 键后游戏重新开始按 ESC 键退出游戏。4.4 游戏代码整体逻辑说明进入main.py后程序先加载 Pygame 模块然后定义常量、三个类和一个碰撞检测函数。重点是Game类的职责分配Game()构造方法完成 Pygame 初始化和窗口创建。reset()把玩家、敌人、分数、状态全部恢复为初始值重新开始游戏只需要调用它。handle_events()处理窗口事件包括退出、ESC、R 键重启。spawn_enemies()负责按固定节奏生成敌人并且根据分数动态调整生成间隔和敌人速度。update()更新玩家位置、敌人位置、碰撞状态和分数。draw()负责把所有元素绘制到屏幕上。run()是游戏主循环把事件、更新、绘制串起来。这种“事件、更新、绘制分离”的结构是游戏开发中非常经典的分层方式。后续无论加菜单页面、加道具系统还是加关卡过渡动画都需要在update和draw的基础上扩展。5. 常见问题与排查思路在实际运行过程中容易遇到下面这些问题。这里整理成一个表格方便快速定位。问题现象常见原因解决思路ModuleNotFoundError: No module named pygamePygame 未安装或装到了其他 Python 环境用python -m pip install pygame安装并确认与运行命令一致游戏窗口打开后立刻退出主循环没有进入或代码在循环前就执行完了确认有Game().run()且running初始为 True窗口一直没反应CPU 占用高事件处理缺失或循环里没有clock.tick确保主循环中有event.get()和clock.tick(FPS)方向键控制没反应在pygame.event里判断按键而不是用key.get_pressed()连续移动请用pygame.key.get_pressed()它返回的是当前帧的按键状态斜着走比直着走快方向向量没有归一化用math.hypot计算长度再把dx、dy除以长度三角形旋转方向不对角度计算与 Pygame 坐标系不一致先用固定角度0、90、180测试再接入atan2的动态角度敌人从底部冒出来速度方向计算错误dy小于 0生成敌人时确保dy始终为正数碰撞检测感觉不准离很远就被撞玩家外接圆半径过大缩小碰撞半径或者把外接圆改为多边形精确检测游戏在不同电脑上速度不一样每帧移动距离固定但每秒帧数不同使用clock.tick(FPS)固定帧率或者引入 delta timepip 安装慢或超时默认源网络不稳使用清华镜像等国内镜像源下面挑几个最常见的问题展开说明。问题一窗口打开后立刻消失这个情况多半是主循环没有进入。新手容易把Game().run()漏掉或者在pygame.init()之后直接写绘制代码画完一帧就结束了。解决方案是保证代码最后有if __name__ __main__: Game().run()另外一个隐藏原因是脚本本身运行报错窗口初始化失败。可以在命令行运行脚本如果窗口一闪而过立刻检查终端输出的异常信息通常都会提示具体是哪一行出错。问题二按键没有反应如果用下面的方式判断按键会有一个隐蔽的问题for event in pygame.event.get(): if event.type pygame.KEYDOWN: if event.key pygame.K_LEFT: player.x - 1这种写法只能触发一次移动。如果用户一直按住方向键只有当按键按下瞬间才会触发事件并不会持续移动。要做连续移动应该用pygame.key.get_pressed()keys pygame.key.get_pressed() if keys[pygame.K_LEFT]: player.x - 1两者并没有谁比谁更好而是适用场景不同event方式适合处理快捷键、菜单选择等一次性操作get_pressed()适合处理角色移动这种持续输入。问题三碰撞检测感觉不精准这是因为玩家碰撞半径使用了三角形的外接圆三角形顶点到中心的距离明显大于边长中心到中心的距离。比如三角形本身并不大但四个角“感应范围”比视觉上大一圈。一个改进的方法是把碰撞半径适当缩小比如player.radius * 0.8牺牲少量碰撞范围换取更接近视觉的效果。更精确的做法是使用“点到线段距离”检测分别计算敌人圆心到三角形三条边的距离。这个方案更准确但代码量会明显增加