1. 自学一周就敢做游戏?先聊聊这个项目是怎么冒出来的一周前,我的Python水平还停留在“能看懂print、if和for”的阶段,写过的所有“程序”加起来不超过五十行,什么面向对象、事件循环、图形绘制,全是只听过没碰过的名词。一周后,我居然打开了自己写的程序,看到屏幕里一个彩色小球随着方向键移动,吃掉更小的球就变大,碰到更大的球就Game Over——《大球吃小球》这个项目,成了我自学Python七天交出的第一份完整作业。这个游戏的规则一句话就能讲清楚:你控制一个大球,吃掉比你小的球就会变大,撞到比你大的球就失败,目标是把场上所有能吃的食物球清空。听起来简单,但真正动手做才发现,这个小游戏把Python基础里最重要的知识点全占齐了:变量和循环、函数、类与对象、事件处理、图形绘制,甚至还有一点几何计算。做完这个项目,我脑子里关于“编程到底是怎么运作的”有了一个立体的框架,不再是东一榔头西一棒子的碎片知识。这篇文章主要写给两类人。第一类是刚学完Python基础、正发愁“我学的这些东西到底能干什么”的初学者;第二类是语法都会、但从来没独立完成过一个项目的朋友。我会把整个项目的设计思路、技术选型、核心逻辑、完整代码、以及我这七天里踩过的所有坑全部摊开来讲,包括普通教程里不会写的那些翻车细节。照着这篇文章走一遍,你收获的不只是一个能运行的游戏,而是一套“从想法到成品”的完整做事方法。1.1 为什么选“大球吃小球”而不是别的项目我当初也考虑过“猜数字”“计算器”“爬虫”这些经典练手项目,但逐个否决了。猜数字和计算器太偏逻辑训练,做完之后没有“我在做一个东西”的实感,成就感很低;爬虫对我来说又太难,涉及网络请求、页面解析、反爬处理,一周根本啃不下来。游戏类项目就完全不同:画面反馈非常强烈,每次改动都能立刻看到效果,这种即时刺激是最适合维持学习动力的。具体到“大球吃小球”,它的优势很突出。第一,规则极简,不需要游戏策划,不用画任何美术资源,所有角色就是几个圆,用pygame的pygame.draw.circle十几行代码就能画出来。第二,核心机制只有三个——移动、碰撞、长大,拆开来看每一个都能用几十行代码搞定,对新手来说心智负担非常小。第三,它天然包含一个“游戏循环”,这是几乎所有游戏的心脏骨架,把这套循环摸透了,后面换什么项目都只是往骨架里填不同的肉。我当时还差点被“贪吃蛇”和“Flappy Bird”吸引,但仔细一想,贪吃蛇涉及蛇身跟随移动的数据结构设计,对新手来说容易绕晕;Flappy Bird涉及重力模拟和障碍物生成,像素级碰撞也麻烦。相比之下,圆形与圆形的碰撞检测只有一条数学公式,复杂度最低,最适合当第一个完整项目。1.2 一周时间线:零基础到底够不够用先说实话:一周是“每天三小时以上”的一周,不是每天摸十分钟那种。我每天下班后固定学两三个小时,周末两天全天投入,累计大概二十多个小时。如果你能保证类似的强度,从零到跑通这个游戏是完全做得到的。如果你每天只能挤半小时,那就把战线拉长到两周,节奏不变,效果一样。我这一周的时间分配大概是这样的:天数学习内容当天小目标第1天环境搭建、变量、数据类型、输入输出装好Python并跑通十个小练习第2天if/else、while、for循环、列表完成一个命令行版猜数字小游戏第3天函数、类与对象入门定义一个“球”类,创建几个实例第4天pygame安装、窗口搭建、绘制基础图形在窗口里画出一个会动的圆第5天键盘事件、游戏循环、边界处理用方向键流畅控制小球移动第6天碰撞检测、食物生成、长大逻辑能吃到食物球且半径变大第7天AI球、输赢判定、细节打磨做出一个可以完整通关的成品前三天其实都是在补Python基础,真正写游戏是从第四天开始的。所以如果你已经学完了基础语法,只是没做过项目,那第四天往后才是你真正要爬的坡,前面完全可以跳过。有个很重要的教训:第一天学习环境搭建时我差点放弃。因为装完Python之后不知道下一步该干嘛,跟着某个视频教程配置编辑器又折腾了好几轮。后来我才想明白,初学者最容易卡住的地方根本不是语法,而是“不知道每一步操作是为了什么”。这也是我写这篇文章时特别强调“为什么”的原因——只告诉你怎么做,你换个场景还是不会。2. 技术选型:为什么用pygame而不是tkinter或者turtle2.1 三个图形库的真实体验对比我一开始根本没听过pygame,是搜索“Python怎么做游戏”才知道有这个东西。第一反应是:为什么不直接用Python自带的库?后来我在网上查了一圈,把三个最常见的选择都捋了一遍。turtle(海龟绘图)是Python自带的教学库,画线画圆很方便,但它是为“绘图演示”设计的,不是为“实时游戏”设计的。想让海龟动起来只能用大量的清除重绘,画面稍微复杂一点就卡顿,键盘事件处理也很简陋,做做数学图形演示还行,做游戏非常勉强。tkinter是Python自带的图形界面库,按钮、输入框、标签这些控件很好用,也有一个Canvas画布可以用来画图做动画。它的优势是不用额外安装,但劣势也很明显:画布刷新机制和游戏循环不是为高频实时渲染设计的,写出来的游戏代码会绕很多弯。如果是做带界面的小工具,tkinter是首选,但做游戏,它属于“能用但别扭”。pygame是专门为2D游戏设计的第三方库,内置了事件系统、音效播放、精灵管理、计时器等游戏开发需要的常见功能。它把窗口创建、图形绘制、键盘鼠标事件、碰撞检测这些都封装好了,让你能把精力集中在游戏逻辑本身。社区教程也非常丰富,遇到问题很容易搜到解决方案。库上手难度实时刷新事件处理最适合的场景turtle低较差很弱教学演示、数学图形tkinter中一般一般带界面的桌面小工具pygame中流畅完善2D游戏、动画演示安装pygame非常简单,在终端里执行一行命令即可:python -m pip install pygame装完之后可以用一行命令验证是否安装成功:python -c "import pygame; print(pygame.version.ver)"能打印出版本号就说明环境没问题了。我建议优先确认这一步,很多同学在写代码之前根本没验证库能不能导入,结果代码抄了好几遍,报错却是因为没装上。2.2 游戏循环:整个游戏的心脏很多人第一次写游戏时会犯一个很微妙的错误:把游戏当成“从上到下执行一遍就结束”的普通脚本。但游戏本质上是一个“死循环”——只要游戏没有退出,它就会一直重复做三件事:接收用户输入、更新游戏状态、重新绘制画面。这三件事合在一起,就构成了游戏循环。它和电影播放的原理很像:电影本质上是每秒连续播放24张静态画面,人眼觉得画面在动。游戏也类似,每循环一次就生成一帧画面,循环得够快,画面就流畅了。pygame里用pygame.display.flip()来切换画面,作用就相当于“把这一帧展示出来”。下面这段是所有基于pygame的游戏都要用到的骨架:clock = pygame.time.Clock() running = True while running: # 第一步:处理事件(用户输入、窗口关闭等) # 第二步:更新游戏状态(移动小球、检测碰撞) # 第三步:绘制画面(画背景、画所有球) pygame.display.flip() clock.tick(60)clock.tick(60)的作用是限制循环每秒最多执行60次,也就是60帧。不要小看这行代码——如果不加它,游戏循环会以电脑CPU能跑多快就跑多快,画面刷新快倒是其次,CPU占用会直接拉满,风扇狂转,电脑卡成幻灯片。加上tick限制之后,游戏节奏稳定,表现也一致,不会说在你自己的电脑上流畅、换台配置好的电脑就快到没法玩。2.3 写代码之前先想清楚三件事新手最常见的做法是想到哪写到哪,打开编辑器就开始敲。我第一天写这个游戏时也是这样,结果代码写到一半就乱了:变量到处定义、逻辑层层套嵌,改一个功能要动十个地方,最后实在忍不了,推倒重来。第二次我学乖了,先花半个小时做了个简单规划。规划过程其实就回答三个问题。第一,这个游戏有哪些“角色”?答案很清晰:一个玩家控制的球、几个会移动的AI球、一堆静止的食物球。第二,每个角色有哪些属性?位置、半径、颜色、移动速度。第三,角色之间会发生什么交互?玩家吃食物球会变大,玩家碰到更大的AI球会失败。有了这三个答案,代码结构自然就出来了。小球这种有属性、有行为的东西,最适合用类来封装;食物球和AI球列表单独管理;碰撞检测写成独立的函数;主循环只负责把各部分串起来。是的,虽然最终代码只有一百多行,但它已经是一个“结构清晰的小系统”了,而不是一坨写完就没人能改明白的脚本。这里也要多说一句:新手不要一上来就搞多文件架构。我自己就犯过这个毛病,项目还没跑通,先建了三个文件、写了一大堆接口,结果每天花大量时间在“文件之间怎么互相导入”上,游戏本身反而没进展。一周的项目,单文件完全够用,只要把代码按“常量区、类定义、工具函数、主循环”的顺序排好,可读性就很好了。3. 三个核心逻辑:移动、碰撞、长大3.1 移动控制:从“按一次动一下”到“按住不走”移动是第一个让我卡住的功能。我一开始用的是KEYDOWN事件,就是“按下键盘按键时触发一次”的方式,写完测试发现:按一下方向键,球只动一个身位,想让它持续移动就得疯狂点键盘,根本没法玩。这个问题背后的原理很简单:KEYDOWN是一个“瞬时事件”,按下的那一刻触发一次就结束了,它不会因为“你一直按着”而重复触发。想要“按住方向键就一直动”的效果,需要实时查询键盘状态,pygame提供了pygame.key.get_pressed(),它返回一个包含所有按键当前状态的元组,每帧查询一次就能实现持续移动。keys = pygame.key.get_pressed() speed = 5 if keys[pygame.K_LEFT]: player.x -= speed if keys[pygame.K_RIGHT]: player.x += speed if keys[pygame.K_UP]: player.y -= speed if keys[pygame.K_DOWN]: player.y += speed这里还有个细节:控制方向不能用if...elif...else,要用四个独立的if。因为玩家可能同时按“右”和“上”,想让球斜着走,就必须允许两个方向同时生效。用elif的话,如果按了右,上就被跳过了,球永远只能横平竖直地移动,手感很僵硬。边界处理也是移动的一部分。如果不处理,球就能跑到窗口外面消失掉。我使用的是“反弹”策略,碰到左右边界就反转水平方向速度,碰到上下边界就反转垂直方向速度,这样球永远不会越界:if player.x - player.radius < 0 or player.x + player.radius > WIDTH: player.speed_x *= -1 if player.y - player.radius < 0 or player.y + player.radius > HEIGHT: player.speed_y *= -1注意边界判断必须考虑球的半径,否则球会“半个身子探出屏幕外”才被弹回来,视觉上很违和。3.2 碰撞检测:一条数学公式搞定一切碰撞检测是整个游戏最核心的逻辑。两个圆什么时候算“碰到”?数学上的答案是:两个圆心的距离小于等于两个半径之和。这个结论可以打个比方——两个圆就像两个人伸手试图互相碰到,圆心是各自的位置,半径是手臂长度,两人手臂长度加起来够得着对方的身体,就会发生接触。代码实现最直观的方式是算距离再比较:import math def check_collision(a, b): dist = math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2) return dist <= a.radius + b.radius但我在实际写的时候做了一个优化:不比较距离,直接比较距离的平方。因为math.sqrt计算平方根是有开销的,而“比较大小”完全可以在平方层面完成,等于省掉了一次平方根计算:def check_collision(a, b): dx = a.x - b.x dy = a.y - b.y dist_sq = dx * dx + dy * dy return dist_sq <= (a.radius + b.radius) ** 2这个优化在小项目里可能感觉不出性能差异,但它养成了一个好习惯:能省的运算尽量省,等哪天你要处理几百上千个对象时,这种习惯会直接决定游戏卡不卡。还有一个新手容易踩的细节:碰撞的判断是“小于等于”,不只是“小于”。如果两个球半径之和恰好等于圆心距离,也就是刚好相切,也应该判定为“接触”。有些教程写的时候漏了等于号,导致球要“互相穿透一点”才能触发碰撞,视觉上就像是融为一体了。3.3 长大机制:为什么不是简单地“半径加一”吃食物球变大的逻辑是我最开始想得最简单的部分——碰撞到了就删掉食物球,玩家的半径加10,完事。测试之后发现手感很怪:一开始半径加10很明显,但球已经很大的时候,加10几乎看不出来,而且大球吃小球、小球吃更小的球,增长幅度完全没区分。后来查资料才知道,增长应该按照“面积”来计算,而不是直接加“半径”。圆的面积公式是π乘以半径的平方,玩家吃掉一个食物球,真正获得的应该是那个食物球的面积,把它加到玩家球的面积上,再反推新的半径:new_area = math.pi * player.radius ** 2 + math.pi * food.radius ** 2 player.radius = math.sqrt(new_area / math.pi)简化后其实就是:player.radius = math.sqrt(player.radius ** 2 + food.radius ** 2)这样做的效果是:大球吃掉一个同样大小的球,半径只会变成原来的√2倍,视觉增长适中;而吃掉一个小得多的球,几乎看不出变化。这符合直觉——对小体积角色来说,增加固定的量效果明显;对大体型角色来说,等量增加已经微不足道了。还有个细节:吃球之后,食物球要从列表里移除,同时要立刻生成一个新的食物球,保证场上的食物数量恒定,玩家始终有事可做。3.4 AI球与胜负判定:让游戏紧张起来如果场上只有静止的食物球,游戏玩起来就是“随便逛逛吃吃”,没有压力。为了让游戏有意思,我加了几个人工智能球,它们会在窗口里随机游走,碰到边界就反弹,看起来像是在漫无目的地巡逻。AI球的行为可以做得复杂,但第一天版本我只做了最简单的随机移动。每个AI球有一个随机的初始速度和方向,之后每帧按照这个速度移动即可。关键的设计是:AI球和玩家一样,碰到比自己小的球也会吃掉并变大。这意味着随着时间推移,AI球可能长得比玩家还大,一旦玩家不小心撞上它们,游戏就结束了。胜负判定的逻辑也很直观。失败:玩家与AI球发生碰撞,且AI球的半径大于玩家半径,游戏失败。胜利:场上所有食物球被吃光,玩家成为最大的球。为了增加可玩性,我还设计了一个神灵级别的细节——玩家打不过可以按R键重新开始,不然每次失败都要关窗口重开,烦躁感极强。4. 完整代码拆解:从初始化到跑起来的每一步4.1 初始化与常量设置整个游戏代码我写在一个文件里,命名为big_ball_game.py。开头是导入库和定义常量:import pygame import random import math # 窗口参数 WIDTH, HEIGHT = 800, 600 FPS = 60 # 颜色定义(RGB三元组) WHITE = (255, 255, 255) BLACK = (0, 0, 0) RED = (255, 60, 60) BLUE = (60, 120, 255) GREEN = (60, 255, 120) ORANGE = (255, 180, 60)把颜色抽成常量是为了后面调用方便。新手容易犯的错是直接在绘制代码里写一堆数字,比如pygame.draw.circle(screen, (255, 60, 60), pos, r),代码一多就看不出哪个颜色是哪个了。定义成常量后,阅读成本低很多。初始化pygame的代码也很固定,三行搞定:pygame.init() screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption("大球吃小球")4.2 Ball类的设计与实现我用一个类来封装所有球的行为,玩家球、AI球和食物球都复用这个类。类的好处是属性集中管理,创建新球只需一行代码,而不是每次都要手动处理一大堆坐标和颜色变量:class Ball: def __init__(self, x, y, radius, color, speed_x=0, speed_y=0, is_player=False): self.x = x self.y = y self.radius = radius self.color = color self.speed_x = speed_x self.speed_y = speed_y self.is_player = is_player def draw(self, screen): pygame.draw.circle( screen, self.color, (int(self.x), int(self.y)), self.radius ) def move(self): self.x += self.speed_x self.y += self.speed_y self.bounce() def bounce(self): if self.x - self.radius < 0 or self.x + self.radius > WIDTH: self.speed_x *= -1 if self.y - self.radius < 0 or self.y + self.radius > HEIGHT: self.speed_y *= -1三个方法各司其职:draw负责绘制,move负责移动,bounce负责边界反弹。玩家球不需要bounce,它由玩家控制并且应该被窗口边界挡住,所以我单独把边界处理抽出来,玩家球可以调用也可以不调用。这里有一个新手很容易纠结的问题:为什么draw里要把self.x转成int?因为pygame.draw.circle在部分版本里要求坐标必须是整数,而球的位置在做碰撞计算时会用浮点数,如果不转换,某些机器上会弹出类型错误,某些机器上会绘制抖动。稳妥做法就是绘制时统一转int,计算时保留浮点精度。4.3 生成食物球和AI球初始化阶段需要生成一个玩家球、若干个AI球和一批食物球。生成食物球时有一个关键细节:不能生成在玩家球内部,否则玩家一出生就自动吃掉一个球,逻辑上很怪。生成AI球时也要避免和玩家球重叠,否则游戏一开始就直接判负或者看着两个球重叠在一起。def create_food(count): foods = [] while len(foods) < count: x = random.randint(50, WIDTH - 50) y = random.randint(50, HEIGHT - 50) r = random.randint(8, 20) color = random.choice([GREEN, ORANGE, RED, BLUE]) food = Ball(x, y, r, color) # 检查不能和玩家以及已有食物球重叠 overlap = False for existing in foods + [player]: if check_collision(food, existing): overlap = True break if not overlap: foods.append(food) return foods循环生成的方式可能看起来有点笨,但绝对可靠。生成了一个球,逐项检查它跟已有球是否重叠,有重叠就重新随机一个位置,直到不重叠为止。这个“生成-检查-重试”的模式在很多游戏里都通用,算是第一个项目就能学到的实用套路。4.4 主循环:把各部分串起来主循环是整个游戏的指挥中心。它的逻辑顺序非常固定:处理事件、获取键盘状态、移动玩家球、移动AI球、检测碰撞、绘制画面。def main(): global player player = Ball(WIDTH // 2, HEIGHT // 2, 25, BLUE, is_player=True) ai_balls = [] for _ in range(6): x = random.randint(100, WIDTH - 100) y = random.randint(100, HEIGHT - 100) r = random.randint(20, 40) speed_x = random.choice([-2, -1, 1, 2]) speed_y = random.choice([-2, -1, 1, 2]) ai_balls.append(Ball(x, y, r, BLACK, speed_x, speed_y)) foods = create_food(15) clock = pygame.time.Clock() running = True game_over = False while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False if event.type == pygame.KEYDOWN and event.key == pygame.K_r: return main() # 按R重新开始 keys = pygame.key.get_pressed() if not game_over: if keys[pygame.K_LEFT]: player.x -= 5 if keys[pygame.K_RIGHT]: player.x += 5 if keys[pygame.K_UP]: player.y -= 5 if keys[pygame.K_DOWN]: player.y += 5 # 限制玩家球不出边界 player.x = max(player.radius, min(WIDTH - player.radius, player.x)) player.y = max(player.radius, min(HEIGHT - player.radius, player.y)) # 移动AI球 for ai in ai_balls: ai.move() # 玩家吃食物球 for food in foods[:]: if check_collision(player, food) and player.radius >= food.radius: player.radius = math.sqrt(player.radius ** 2 + food.radius ** 2) foods.remove(food) foods.extend(create_food(1)) # 判定失败:撞到比自己大的AI球 for ai in ai_balls: if check_collision(player, ai) and ai.radius >= player.radius: game_over = True # 判定胜利:吃完所有食物球 if len(foods) == 0: game_over = True # 绘制 screen.fill(WHITE) for food in foods: food.draw(screen) for ai in ai_balls: ai.draw(screen) player.draw(screen) if game_over: # 简单提示文字 font = pygame.font.Font(None, 48) if len(foods) == 0: text = font.render("YOU WIN! Press R to restart", True, BLACK) else: text = font.render("YOU LOSE! Press R to restart", True, RED) screen.blit(text, (WIDTH // 2 - text.get_width() // 2, HEIGHT // 2)) pygame.display.flip() clock.tick(FPS) pygame.quit()这里有个非常容易踩的坑:从foods列表中删除元素时,绝对不能在遍历列表的同时直接删除,否则会跳过元素甚至报错。我的做法是遍历列表的副本foods[:],从副本里判断,从原列表里删除,这样遍历过程不会被删除操作干扰。这个“先复制再遍历”的套路在做列表增删时极其常用。绘制顺序也有讲究:先画背景,再画食物球,再画AI球,最后画玩家球。这样玩家球永远在最上面一层,视觉上最清晰。如果顺序反了,食物球可能会盖在玩家球上面,看起来像是在玩家球底下被吃,非常别扭。5. 新手最容易翻车的五个坑5.1 窗口一打开就“无响应”这是我第四天遇到的第一个大问题。窗口弹出来了,但标题栏马上显示“未响应”,过几秒系统就提示我关闭程序。排查半天才发现,问题出在事件处理的写法上:我一开始写的是while True死循环,但循环里忘记调用pygame.event.get()。没有调用事件获取函数的话,pygame的内置事件队列会不断积压消息,操作系统判定窗口已经卡死,直接罢工。这一点对新手特别隐蔽,因为窗口确实弹出来了,但它其实已经“死了”。解决办法就是在主循环里始终保留这个处理:for event in pygame.event.get(): if event.type == pygame.QUIT: running = False5.2 小球走过之后留下一串残影这个问题出现得莫名其妙:球移动起来很流畅,但身后拖着一条长长的尾巴,像彗星一样。原因是:我每一帧都只画了球,却没有先清空上一帧的画面。上一帧的球还在屏幕上,新一帧的球又在新的位置画了一个,两个球同时存在,叠加起来就形成了残影。解决办法只有一行代码:在每一帧画所有东西之前,先用背景色覆盖整个窗口。screen.fill(WHITE)这行代码必须放在所有draw调用之前。它相当于“擦黑板”:先把黑板擦干净,再把新的一帧画上去。理论上也可以用一个透明矩形覆盖画面,但screen.fill(WHITE)最简单直接。这也解释了为什么游戏循环必须严格遵循“清屏、更新、绘制、刷新”的顺序。5.3 只是碰到根本不算“碰到”:碰撞不生效第六天测碰撞时,我发现球已经明显重叠了一部分,却依然没有触发吃球的逻辑。反复检查数学公式,甚至打印了两个球的位置和半径逐帧对照,最后发现问题是坐标精度:Ball.x和Ball.y在移动过程中会变成小数,但绘制时我转成了整数。碰撞检测用的是带小数的原始坐标,视觉上两个球已经“碰到”了(因为绘制的时候四舍五入到了整数像素),但原始坐标的圆心距离还差一点点,不够触发碰撞条件。解决方案有两个方向:要么碰撞检测也用绘制后的整数坐标,保持视觉和逻辑一致;要么在绘制时使用四舍五入而不是截断取整。我用的是int(self.x)直接截断,改成round(self.x)就解决了。这个问题的本质是“视觉”和“逻辑”不一致。游戏开发中这种不一致经常造成莫名其妙的bug,强烈建议新手在做碰撞检测时,先想清楚自己比较的是哪一组坐标。5.4 小球一多就卡成PPT我扩展测试时把食物球数量调到了50个,AI球调到20个,结果帧率肉眼可见地掉了下来。原因是碰撞检测是两两比较,50个食物球加上20个AI球再加上玩家自己,每帧要做超过70次碰撞检测,每次检测又要计算坐标差、平方、比较半径和,这些运算叠加起来确实有压力。但真正的问题不是计算量,而是数量没限制。最终我做了两个调整:第一,食物球数量控制在10~15个,每吃掉一个再补一个,场上总数永远不超过15;第二,AI球控制在4~6个,它们不需要太多,4个就足以营造压迫感了。数量降下来之后,帧率立刻稳定在60帧。另外有个小建议:如果你未来处理的对象数量动辄上百,可以考虑用“空间网格”之类的加速技巧,但在这个项目里完全没必要。学会“用数量上限控制性能”这个思路,比学会高级优化技巧更有用——绝大多数小游戏根本不需要复杂的优化,限制一下对象总量就解决了。5.5 写着写着代码成了一坨第五天时,我的代码已经出现了“全局变量满天飞”的征兆:player_x、player_y、player_r、player_speed_x、player_speed_y,还有AI球的一堆坐标变量,分散在程序各个角落。改一个功能要同时修改十几个地方,测试时经常忘记同步某个变量,bug越修越多。最后我停下来,把这些变量全部收进一个Ball类,代码量不仅减少了接近三分之一,逻辑也清晰多了。这个经历让我深刻理解了一件事:面向对象不是为了“显得高级”,而是为了让一堆互相关联的数据和操作有一个统一的归属。当你发现某个程序里“数据散得到处都是、改一处就要跟着改十处”的时候,就该考虑用类来重构了。6. 改一改就能玩出新花样的扩展方向6.1 让AI球“聪明”起来:难度曲线设计第一个能玩的版本,AI球只是随机游走,玩家只要绕着走就能轻松获胜。想让游戏有挑战性,最简单有效的改动是让AI球有“目标意识”:当AI球比玩家大时,它主动追着玩家跑;当AI球比玩家小时,它逃离玩家。这个行为的代码实现非常直接,每帧计算玩家和AI球的相对位置,然后把AI球的移动方向调整为指向或背离玩家。逻辑很简单:两点之间的方向向量就是目标点坐标减去当前点坐标,归一化之后乘以速度,就是新的速度向量。我还加了一个“AI球也会吃食物球”的机制,让AI球有机会慢慢长大,这样玩家不能一直苟着发育,必须主动出击抢食物。这个改动让游戏从“逛街吃球”变成了“有策略的对抗”,可玩性提升了一个档次。再进一步,可以给AI球加一个“感知范围”,只追一定距离内的玩家,避免AI全图透视一样追着玩家,压迫感反而过于紧张。6.2 视觉和听觉体验升级游戏能玩通顺之后,我开始琢磨“怎么让它更像一个游戏”。第一件事是加音效:用一个非常短的“啵”的声音在吃到食物球时播放,这个反馈很重要——玩家每吃一个球就能听到一声,多巴胺直接拉满。pygame里播放音效很简单,pygame.mixer.Sound("eat.wav").play()即可。第二件事是加分数显示。光看球的半径大小,玩家很难感受到自己进步了多少。我在左上角显示“当前半径”和“已吃球数”,用pygame.font.Font渲染文字,每帧更新,很直接。第三件事是加“特殊食物球”:随机刷出金色的球,吃了之后短时间内速度翻倍,或者半径直接大幅增长。这相当于给游戏增加了一个“爽点”,节奏更有起伏。金色球用黄色绘制,注意跟普通球颜色区分开。6.3 做完这个游戏,下一步学什么《大球吃小球》做完之后,我获得的不是“我会做游戏了”这种虚妄的自信,而是“我具备自己折腾一个项目的能力了”。这个项目教会的核心能力——拆解需求、循环、事件、状态管理、碰撞检测——基本上覆盖了90%的2D休闲游戏所需要的基础。接下来可以按三条路线走。第一条是换个游戏做一遍:贪吃蛇、飞机大战、Flappy Bird,难度都比大球吃小球高一些,但核心骨架完全一样,是在重复中加深理解。第二条是给这个游戏加特性:关卡系统、能力道具、排行榜、多人在线,每个特性都会逼你学一个新知识点。第三条是往“可维护性”发展:把这个单文件拆成多个模块,学会写测试、写注释、管理代码版本,这是从“能做出来”到“能长期维护”的关键一步。我个人最推荐第二条路,因为加特性带给你的正反馈最强。我做完这个游戏后,第一个扩充就是加音效,然后是AI追人,接着是特殊食物,每一次扩充都在刷新我对“编程能做的事”的认知。最后分享一个对我帮助很大的小习惯:跑通一个成品之后,把代码复制一份,然后在副本上放心大胆地乱改。改一句,跑一次;再改一句,再跑一次。改成什么样都不心疼,因为没有保留价值的是那堆试错过程,留下来的才是你自己真正理解的东西。这个游戏可能在未来某天被我彻底重构或者扔进回收站,但“我能独立完成一个项目”这个事实,已经成为我继续学下去的底气。
1. 自学一周就敢做游戏?先聊聊这个项目是怎么冒出来的一周前,我的Python水平还停留在“能看懂print、if和for”的阶段,写过的所有“程序”加起来不超过五十行,什么面向对象、事件循环、图形绘制,全是只听过没碰过的名词。一周后,我居然打开了自己写的程序,看到屏幕里一个彩色小球随着方向键移动,吃掉更小的球就变大,碰到更大的球就Game Over——《大球吃小球》这个项目,成了我自学Python七天交出的第一份完整作业。这个游戏的规则一句话就能讲清楚:你控制一个大球,吃掉比你小的球就会变大,撞到比你大的球就失败,目标是把场上所有能吃的食物球清空。听起来简单,但真正动手做才发现,这个小游戏把Python基础里最重要的知识点全占齐了:变量和循环、函数、类与对象、事件处理、图形绘制,甚至还有一点几何计算。做完这个项目,我脑子里关于“编程到底是怎么运作的”有了一个立体的框架,不再是东一榔头西一棒子的碎片知识。这篇文章主要写给两类人。第一类是刚学完Python基础、正发愁“我学的这些东西到底能干什么”的初学者;第二类是语法都会、但从来没独立完成过一个项目的朋友。我会把整个项目的设计思路、技术选型、核心逻辑、完整代码、以及我这七天里踩过的所有坑全部摊开来讲,包括普通教程里不会写的那些翻车细节。照着这篇文章走一遍,你收获的不只是一个能运行的游戏,而是一套“从想法到成品”的完整做事方法。1.1 为什么选“大球吃小球”而不是别的项目我当初也考虑过“猜数字”“计算器”“爬虫”这些经典练手项目,但逐个否决了。猜数字和计算器太偏逻辑训练,做完之后没有“我在做一个东西”的实感,成就感很低;爬虫对我来说又太难,涉及网络请求、页面解析、反爬处理,一周根本啃不下来。游戏类项目就完全不同:画面反馈非常强烈,每次改动都能立刻看到效果,这种即时刺激是最适合维持学习动力的。具体到“大球吃小球”,它的优势很突出。第一,规则极简,不需要游戏策划,不用画任何美术资源,所有角色就是几个圆,用pygame的pygame.draw.circle十几行代码就能画出来。第二,核心机制只有三个——移动、碰撞、长大,拆开来看每一个都能用几十行代码搞定,对新手来说心智负担非常小。第三,它天然包含一个“游戏循环”,这是几乎所有游戏的心脏骨架,把这套循环摸透了,后面换什么项目都只是往骨架里填不同的肉。我当时还差点被“贪吃蛇”和“Flappy Bird”吸引,但仔细一想,贪吃蛇涉及蛇身跟随移动的数据结构设计,对新手来说容易绕晕;Flappy Bird涉及重力模拟和障碍物生成,像素级碰撞也麻烦。相比之下,圆形与圆形的碰撞检测只有一条数学公式,复杂度最低,最适合当第一个完整项目。1.2 一周时间线:零基础到底够不够用先说实话:一周是“每天三小时以上”的一周,不是每天摸十分钟那种。我每天下班后固定学两三个小时,周末两天全天投入,累计大概二十多个小时。如果你能保证类似的强度,从零到跑通这个游戏是完全做得到的。如果你每天只能挤半小时,那就把战线拉长到两周,节奏不变,效果一样。我这一周的时间分配大概是这样的:天数学习内容当天小目标第1天环境搭建、变量、数据类型、输入输出装好Python并跑通十个小练习第2天if/else、while、for循环、列表完成一个命令行版猜数字小游戏第3天函数、类与对象入门定义一个“球”类,创建几个实例第4天pygame安装、窗口搭建、绘制基础图形在窗口里画出一个会动的圆第5天键盘事件、游戏循环、边界处理用方向键流畅控制小球移动第6天碰撞检测、食物生成、长大逻辑能吃到食物球且半径变大第7天AI球、输赢判定、细节打磨做出一个可以完整通关的成品前三天其实都是在补Python基础,真正写游戏是从第四天开始的。所以如果你已经学完了基础语法,只是没做过项目,那第四天往后才是你真正要爬的坡,前面完全可以跳过。有个很重要的教训:第一天学习环境搭建时我差点放弃。因为装完Python之后不知道下一步该干嘛,跟着某个视频教程配置编辑器又折腾了好几轮。后来我才想明白,初学者最容易卡住的地方根本不是语法,而是“不知道每一步操作是为了什么”。这也是我写这篇文章时特别强调“为什么”的原因——只告诉你怎么做,你换个场景还是不会。2. 技术选型:为什么用pygame而不是tkinter或者turtle2.1 三个图形库的真实体验对比我一开始根本没听过pygame,是搜索“Python怎么做游戏”才知道有这个东西。第一反应是:为什么不直接用Python自带的库?后来我在网上查了一圈,把三个最常见的选择都捋了一遍。turtle(海龟绘图)是Python自带的教学库,画线画圆很方便,但它是为“绘图演示”设计的,不是为“实时游戏”设计的。想让海龟动起来只能用大量的清除重绘,画面稍微复杂一点就卡顿,键盘事件处理也很简陋,做做数学图形演示还行,做游戏非常勉强。tkinter是Python自带的图形界面库,按钮、输入框、标签这些控件很好用,也有一个Canvas画布可以用来画图做动画。它的优势是不用额外安装,但劣势也很明显:画布刷新机制和游戏循环不是为高频实时渲染设计的,写出来的游戏代码会绕很多弯。如果是做带界面的小工具,tkinter是首选,但做游戏,它属于“能用但别扭”。pygame是专门为2D游戏设计的第三方库,内置了事件系统、音效播放、精灵管理、计时器等游戏开发需要的常见功能。它把窗口创建、图形绘制、键盘鼠标事件、碰撞检测这些都封装好了,让你能把精力集中在游戏逻辑本身。社区教程也非常丰富,遇到问题很容易搜到解决方案。库上手难度实时刷新事件处理最适合的场景turtle低较差很弱教学演示、数学图形tkinter中一般一般带界面的桌面小工具pygame中流畅完善2D游戏、动画演示安装pygame非常简单,在终端里执行一行命令即可:python -m pip install pygame装完之后可以用一行命令验证是否安装成功:python -c "import pygame; print(pygame.version.ver)"能打印出版本号就说明环境没问题了。我建议优先确认这一步,很多同学在写代码之前根本没验证库能不能导入,结果代码抄了好几遍,报错却是因为没装上。2.2 游戏循环:整个游戏的心脏很多人第一次写游戏时会犯一个很微妙的错误:把游戏当成“从上到下执行一遍就结束”的普通脚本。但游戏本质上是一个“死循环”——只要游戏没有退出,它就会一直重复做三件事:接收用户输入、更新游戏状态、重新绘制画面。这三件事合在一起,就构成了游戏循环。它和电影播放的原理很像:电影本质上是每秒连续播放24张静态画面,人眼觉得画面在动。游戏也类似,每循环一次就生成一帧画面,循环得够快,画面就流畅了。pygame里用pygame.display.flip()来切换画面,作用就相当于“把这一帧展示出来”。下面这段是所有基于pygame的游戏都要用到的骨架:clock = pygame.time.Clock() running = True while running: # 第一步:处理事件(用户输入、窗口关闭等) # 第二步:更新游戏状态(移动小球、检测碰撞) # 第三步:绘制画面(画背景、画所有球) pygame.display.flip() clock.tick(60)clock.tick(60)的作用是限制循环每秒最多执行60次,也就是60帧。不要小看这行代码——如果不加它,游戏循环会以电脑CPU能跑多快就跑多快,画面刷新快倒是其次,CPU占用会直接拉满,风扇狂转,电脑卡成幻灯片。加上tick限制之后,游戏节奏稳定,表现也一致,不会说在你自己的电脑上流畅、换台配置好的电脑就快到没法玩。2.3 写代码之前先想清楚三件事新手最常见的做法是想到哪写到哪,打开编辑器就开始敲。我第一天写这个游戏时也是这样,结果代码写到一半就乱了:变量到处定义、逻辑层层套嵌,改一个功能要动十个地方,最后实在忍不了,推倒重来。第二次我学乖了,先花半个小时做了个简单规划。规划过程其实就回答三个问题。第一,这个游戏有哪些“角色”?答案很清晰:一个玩家控制的球、几个会移动的AI球、一堆静止的食物球。第二,每个角色有哪些属性?位置、半径、颜色、移动速度。第三,角色之间会发生什么交互?玩家吃食物球会变大,玩家碰到更大的AI球会失败。有了这三个答案,代码结构自然就出来了。小球这种有属性、有行为的东西,最适合用类来封装;食物球和AI球列表单独管理;碰撞检测写成独立的函数;主循环只负责把各部分串起来。是的,虽然最终代码只有一百多行,但它已经是一个“结构清晰的小系统”了,而不是一坨写完就没人能改明白的脚本。这里也要多说一句:新手不要一上来就搞多文件架构。我自己就犯过这个毛病,项目还没跑通,先建了三个文件、写了一大堆接口,结果每天花大量时间在“文件之间怎么互相导入”上,游戏本身反而没进展。一周的项目,单文件完全够用,只要把代码按“常量区、类定义、工具函数、主循环”的顺序排好,可读性就很好了。3. 三个核心逻辑:移动、碰撞、长大3.1 移动控制:从“按一次动一下”到“按住不走”移动是第一个让我卡住的功能。我一开始用的是KEYDOWN事件,就是“按下键盘按键时触发一次”的方式,写完测试发现:按一下方向键,球只动一个身位,想让它持续移动就得疯狂点键盘,根本没法玩。这个问题背后的原理很简单:KEYDOWN是一个“瞬时事件”,按下的那一刻触发一次就结束了,它不会因为“你一直按着”而重复触发。想要“按住方向键就一直动”的效果,需要实时查询键盘状态,pygame提供了pygame.key.get_pressed(),它返回一个包含所有按键当前状态的元组,每帧查询一次就能实现持续移动。keys = pygame.key.get_pressed() speed = 5 if keys[pygame.K_LEFT]: player.x -= speed if keys[pygame.K_RIGHT]: player.x += speed if keys[pygame.K_UP]: player.y -= speed if keys[pygame.K_DOWN]: player.y += speed这里还有个细节:控制方向不能用if...elif...else,要用四个独立的if。因为玩家可能同时按“右”和“上”,想让球斜着走,就必须允许两个方向同时生效。用elif的话,如果按了右,上就被跳过了,球永远只能横平竖直地移动,手感很僵硬。边界处理也是移动的一部分。如果不处理,球就能跑到窗口外面消失掉。我使用的是“反弹”策略,碰到左右边界就反转水平方向速度,碰到上下边界就反转垂直方向速度,这样球永远不会越界:if player.x - player.radius < 0 or player.x + player.radius > WIDTH: player.speed_x *= -1 if player.y - player.radius < 0 or player.y + player.radius > HEIGHT: player.speed_y *= -1注意边界判断必须考虑球的半径,否则球会“半个身子探出屏幕外”才被弹回来,视觉上很违和。3.2 碰撞检测:一条数学公式搞定一切碰撞检测是整个游戏最核心的逻辑。两个圆什么时候算“碰到”?数学上的答案是:两个圆心的距离小于等于两个半径之和。这个结论可以打个比方——两个圆就像两个人伸手试图互相碰到,圆心是各自的位置,半径是手臂长度,两人手臂长度加起来够得着对方的身体,就会发生接触。代码实现最直观的方式是算距离再比较:import math def check_collision(a, b): dist = math.sqrt((a.x - b.x) ** 2 + (a.y - b.y) ** 2) return dist <= a.radius + b.radius但我在实际写的时候做了一个优化:不比较距离,直接比较距离的平方。因为math.sqrt计算平方根是有开销的,而“比较大小”完全可以在平方层面完成,等于省掉了一次平方根计算:def check_collision(a, b): dx = a.x - b.x dy = a.y - b.y dist_sq = dx * dx + dy * dy return dist_sq <= (a.radius + b.radius) ** 2这个优化在小项目里可能感觉不出性能差异,但它养成了一个好习惯:能省的运算尽量省,等哪天你要处理几百上千个对象时,这种习惯会直接决定游戏卡不卡。还有一个新手容易踩的细节:碰撞的判断是“小于等于”,不只是“小于”。如果两个球半径之和恰好等于圆心距离,也就是刚好相切,也应该判定为“接触”。有些教程写的时候漏了等于号,导致球要“互相穿透一点”才能触发碰撞,视觉上就像是融为一体了。3.3 长大机制:为什么不是简单地“半径加一”吃食物球变大的逻辑是我最开始想得最简单的部分——碰撞到了就删掉食物球,玩家的半径加10,完事。测试之后发现手感很怪:一开始半径加10很明显,但球已经很大的时候,加10几乎看不出来,而且大球吃小球、小球吃更小的球,增长幅度完全没区分。后来查资料才知道,增长应该按照“面积”来计算,而不是直接加“半径”。圆的面积公式是π乘以半径的平方,玩家吃掉一个食物球,真正获得的应该是那个食物球的面积,把它加到玩家球的面积上,再反推新的半径:new_area = math.pi * player.radius ** 2 + math.pi * food.radius ** 2 player.radius = math.sqrt(new_area / math.pi)简化后其实就是:player.radius = math.sqrt(player.radius ** 2 + food.radius ** 2)这样做的效果是:大球吃掉一个同样大小的球,半径只会变成原来的√2倍,视觉增长适中;而吃掉一个小得多的球,几乎看不出变化。这符合直觉——对小体积角色来说,增加固定的量效果明显;对大体型角色来说,等量增加已经微不足道了。还有个细节:吃球之后,食物球要从列表里移除,同时要立刻生成一个新的食物球,保证场上的食物数量恒定,玩家始终有事可做。3.4 AI球与胜负判定:让游戏紧张起来如果场上只有静止的食物球,游戏玩起来就是“随便逛逛吃吃”,没有压力。为了让游戏有意思,我加了几个人工智能球,它们会在窗口里随机游走,碰到边界就反弹,看起来像是在漫无目的地巡逻。AI球的行为可以做得复杂,但第一天版本我只做了最简单的随机移动。每个AI球有一个随机的初始速度和方向,之后每帧按照这个速度移动即可。关键的设计是:AI球和玩家一样,碰到比自己小的球也会吃掉并变大。这意味着随着时间推移,AI球可能长得比玩家还大,一旦玩家不小心撞上它们,游戏就结束了。胜负判定的逻辑也很直观。失败:玩家与AI球发生碰撞,且AI球的半径大于玩家半径,游戏失败。胜利:场上所有食物球被吃光,玩家成为最大的球。为了增加可玩性,我还设计了一个神灵级别的细节——玩家打不过可以按R键重新开始,不然每次失败都要关窗口重开,烦躁感极强。4. 完整代码拆解:从初始化到跑起来的每一步4.1 初始化与常量设置整个游戏代码我写在一个文件里,命名为big_ball_game.py。开头是导入库和定义常量:import pygame import random import math # 窗口参数 WIDTH, HEIGHT = 800, 600 FPS = 60 # 颜色定义(RGB三元组) WHITE = (255, 255, 255) BLACK = (0, 0, 0) RED = (255, 60, 60) BLUE = (60, 120, 255) GREEN = (60, 255, 120) ORANGE = (255, 180, 60)把颜色抽成常量是为了后面调用方便。新手容易犯的错是直接在绘制代码里写一堆数字,比如pygame.draw.circle(screen, (255, 60, 60), pos, r),代码一多就看不出哪个颜色是哪个了。定义成常量后,阅读成本低很多。初始化pygame的代码也很固定,三行搞定:pygame.init() screen = pygame.display.set_mode((WIDTH, HEIGHT)) pygame.display.set_caption("大球吃小球")4.2 Ball类的设计与实现我用一个类来封装所有球的行为,玩家球、AI球和食物球都复用这个类。类的好处是属性集中管理,创建新球只需一行代码,而不是每次都要手动处理一大堆坐标和颜色变量:class Ball: def __init__(self, x, y, radius, color, speed_x=0, speed_y=0, is_player=False): self.x = x self.y = y self.radius = radius self.color = color self.speed_x = speed_x self.speed_y = speed_y self.is_player = is_player def draw(self, screen): pygame.draw.circle( screen, self.color, (int(self.x), int(self.y)), self.radius ) def move(self): self.x += self.speed_x self.y += self.speed_y self.bounce() def bounce(self): if self.x - self.radius < 0 or self.x + self.radius > WIDTH: self.speed_x *= -1 if self.y - self.radius < 0 or self.y + self.radius > HEIGHT: self.speed_y *= -1三个方法各司其职:draw负责绘制,move负责移动,bounce负责边界反弹。玩家球不需要bounce,它由玩家控制并且应该被窗口边界挡住,所以我单独把边界处理抽出来,玩家球可以调用也可以不调用。这里有一个新手很容易纠结的问题:为什么draw里要把self.x转成int?因为pygame.draw.circle在部分版本里要求坐标必须是整数,而球的位置在做碰撞计算时会用浮点数,如果不转换,某些机器上会弹出类型错误,某些机器上会绘制抖动。稳妥做法就是绘制时统一转int,计算时保留浮点精度。4.3 生成食物球和AI球初始化阶段需要生成一个玩家球、若干个AI球和一批食物球。生成食物球时有一个关键细节:不能生成在玩家球内部,否则玩家一出生就自动吃掉一个球,逻辑上很怪。生成AI球时也要避免和玩家球重叠,否则游戏一开始就直接判负或者看着两个球重叠在一起。def create_food(count): foods = [] while len(foods) < count: x = random.randint(50, WIDTH - 50) y = random.randint(50, HEIGHT - 50) r = random.randint(8, 20) color = random.choice([GREEN, ORANGE, RED, BLUE]) food = Ball(x, y, r, color) # 检查不能和玩家以及已有食物球重叠 overlap = False for existing in foods + [player]: if check_collision(food, existing): overlap = True break if not overlap: foods.append(food) return foods循环生成的方式可能看起来有点笨,但绝对可靠。生成了一个球,逐项检查它跟已有球是否重叠,有重叠就重新随机一个位置,直到不重叠为止。这个“生成-检查-重试”的模式在很多游戏里都通用,算是第一个项目就能学到的实用套路。4.4 主循环:把各部分串起来主循环是整个游戏的指挥中心。它的逻辑顺序非常固定:处理事件、获取键盘状态、移动玩家球、移动AI球、检测碰撞、绘制画面。def main(): global player player = Ball(WIDTH // 2, HEIGHT // 2, 25, BLUE, is_player=True) ai_balls = [] for _ in range(6): x = random.randint(100, WIDTH - 100) y = random.randint(100, HEIGHT - 100) r = random.randint(20, 40) speed_x = random.choice([-2, -1, 1, 2]) speed_y = random.choice([-2, -1, 1, 2]) ai_balls.append(Ball(x, y, r, BLACK, speed_x, speed_y)) foods = create_food(15) clock = pygame.time.Clock() running = True game_over = False while running: for event in pygame.event.get(): if event.type == pygame.QUIT: running = False if event.type == pygame.KEYDOWN and event.key == pygame.K_r: return main() # 按R重新开始 keys = pygame.key.get_pressed() if not game_over: if keys[pygame.K_LEFT]: player.x -= 5 if keys[pygame.K_RIGHT]: player.x += 5 if keys[pygame.K_UP]: player.y -= 5 if keys[pygame.K_DOWN]: player.y += 5 # 限制玩家球不出边界 player.x = max(player.radius, min(WIDTH - player.radius, player.x)) player.y = max(player.radius, min(HEIGHT - player.radius, player.y)) # 移动AI球 for ai in ai_balls: ai.move() # 玩家吃食物球 for food in foods[:]: if check_collision(player, food) and player.radius >= food.radius: player.radius = math.sqrt(player.radius ** 2 + food.radius ** 2) foods.remove(food) foods.extend(create_food(1)) # 判定失败:撞到比自己大的AI球 for ai in ai_balls: if check_collision(player, ai) and ai.radius >= player.radius: game_over = True # 判定胜利:吃完所有食物球 if len(foods) == 0: game_over = True # 绘制 screen.fill(WHITE) for food in foods: food.draw(screen) for ai in ai_balls: ai.draw(screen) player.draw(screen) if game_over: # 简单提示文字 font = pygame.font.Font(None, 48) if len(foods) == 0: text = font.render("YOU WIN! Press R to restart", True, BLACK) else: text = font.render("YOU LOSE! Press R to restart", True, RED) screen.blit(text, (WIDTH // 2 - text.get_width() // 2, HEIGHT // 2)) pygame.display.flip() clock.tick(FPS) pygame.quit()这里有个非常容易踩的坑:从foods列表中删除元素时,绝对不能在遍历列表的同时直接删除,否则会跳过元素甚至报错。我的做法是遍历列表的副本foods[:],从副本里判断,从原列表里删除,这样遍历过程不会被删除操作干扰。这个“先复制再遍历”的套路在做列表增删时极其常用。绘制顺序也有讲究:先画背景,再画食物球,再画AI球,最后画玩家球。这样玩家球永远在最上面一层,视觉上最清晰。如果顺序反了,食物球可能会盖在玩家球上面,看起来像是在玩家球底下被吃,非常别扭。5. 新手最容易翻车的五个坑5.1 窗口一打开就“无响应”这是我第四天遇到的第一个大问题。窗口弹出来了,但标题栏马上显示“未响应”,过几秒系统就提示我关闭程序。排查半天才发现,问题出在事件处理的写法上:我一开始写的是while True死循环,但循环里忘记调用pygame.event.get()。没有调用事件获取函数的话,pygame的内置事件队列会不断积压消息,操作系统判定窗口已经卡死,直接罢工。这一点对新手特别隐蔽,因为窗口确实弹出来了,但它其实已经“死了”。解决办法就是在主循环里始终保留这个处理:for event in pygame.event.get(): if event.type == pygame.QUIT: running = False5.2 小球走过之后留下一串残影这个问题出现得莫名其妙:球移动起来很流畅,但身后拖着一条长长的尾巴,像彗星一样。原因是:我每一帧都只画了球,却没有先清空上一帧的画面。上一帧的球还在屏幕上,新一帧的球又在新的位置画了一个,两个球同时存在,叠加起来就形成了残影。解决办法只有一行代码:在每一帧画所有东西之前,先用背景色覆盖整个窗口。screen.fill(WHITE)这行代码必须放在所有draw调用之前。它相当于“擦黑板”:先把黑板擦干净,再把新的一帧画上去。理论上也可以用一个透明矩形覆盖画面,但screen.fill(WHITE)最简单直接。这也解释了为什么游戏循环必须严格遵循“清屏、更新、绘制、刷新”的顺序。5.3 只是碰到根本不算“碰到”:碰撞不生效第六天测碰撞时,我发现球已经明显重叠了一部分,却依然没有触发吃球的逻辑。反复检查数学公式,甚至打印了两个球的位置和半径逐帧对照,最后发现问题是坐标精度:Ball.x和Ball.y在移动过程中会变成小数,但绘制时我转成了整数。碰撞检测用的是带小数的原始坐标,视觉上两个球已经“碰到”了(因为绘制的时候四舍五入到了整数像素),但原始坐标的圆心距离还差一点点,不够触发碰撞条件。解决方案有两个方向:要么碰撞检测也用绘制后的整数坐标,保持视觉和逻辑一致;要么在绘制时使用四舍五入而不是截断取整。我用的是int(self.x)直接截断,改成round(self.x)就解决了。这个问题的本质是“视觉”和“逻辑”不一致。游戏开发中这种不一致经常造成莫名其妙的bug,强烈建议新手在做碰撞检测时,先想清楚自己比较的是哪一组坐标。5.4 小球一多就卡成PPT我扩展测试时把食物球数量调到了50个,AI球调到20个,结果帧率肉眼可见地掉了下来。原因是碰撞检测是两两比较,50个食物球加上20个AI球再加上玩家自己,每帧要做超过70次碰撞检测,每次检测又要计算坐标差、平方、比较半径和,这些运算叠加起来确实有压力。但真正的问题不是计算量,而是数量没限制。最终我做了两个调整:第一,食物球数量控制在10~15个,每吃掉一个再补一个,场上总数永远不超过15;第二,AI球控制在4~6个,它们不需要太多,4个就足以营造压迫感了。数量降下来之后,帧率立刻稳定在60帧。另外有个小建议:如果你未来处理的对象数量动辄上百,可以考虑用“空间网格”之类的加速技巧,但在这个项目里完全没必要。学会“用数量上限控制性能”这个思路,比学会高级优化技巧更有用——绝大多数小游戏根本不需要复杂的优化,限制一下对象总量就解决了。5.5 写着写着代码成了一坨第五天时,我的代码已经出现了“全局变量满天飞”的征兆:player_x、player_y、player_r、player_speed_x、player_speed_y,还有AI球的一堆坐标变量,分散在程序各个角落。改一个功能要同时修改十几个地方,测试时经常忘记同步某个变量,bug越修越多。最后我停下来,把这些变量全部收进一个Ball类,代码量不仅减少了接近三分之一,逻辑也清晰多了。这个经历让我深刻理解了一件事:面向对象不是为了“显得高级”,而是为了让一堆互相关联的数据和操作有一个统一的归属。当你发现某个程序里“数据散得到处都是、改一处就要跟着改十处”的时候,就该考虑用类来重构了。6. 改一改就能玩出新花样的扩展方向6.1 让AI球“聪明”起来:难度曲线设计第一个能玩的版本,AI球只是随机游走,玩家只要绕着走就能轻松获胜。想让游戏有挑战性,最简单有效的改动是让AI球有“目标意识”:当AI球比玩家大时,它主动追着玩家跑;当AI球比玩家小时,它逃离玩家。这个行为的代码实现非常直接,每帧计算玩家和AI球的相对位置,然后把AI球的移动方向调整为指向或背离玩家。逻辑很简单:两点之间的方向向量就是目标点坐标减去当前点坐标,归一化之后乘以速度,就是新的速度向量。我还加了一个“AI球也会吃食物球”的机制,让AI球有机会慢慢长大,这样玩家不能一直苟着发育,必须主动出击抢食物。这个改动让游戏从“逛街吃球”变成了“有策略的对抗”,可玩性提升了一个档次。再进一步,可以给AI球加一个“感知范围”,只追一定距离内的玩家,避免AI全图透视一样追着玩家,压迫感反而过于紧张。6.2 视觉和听觉体验升级游戏能玩通顺之后,我开始琢磨“怎么让它更像一个游戏”。第一件事是加音效:用一个非常短的“啵”的声音在吃到食物球时播放,这个反馈很重要——玩家每吃一个球就能听到一声,多巴胺直接拉满。pygame里播放音效很简单,pygame.mixer.Sound("eat.wav").play()即可。第二件事是加分数显示。光看球的半径大小,玩家很难感受到自己进步了多少。我在左上角显示“当前半径”和“已吃球数”,用pygame.font.Font渲染文字,每帧更新,很直接。第三件事是加“特殊食物球”:随机刷出金色的球,吃了之后短时间内速度翻倍,或者半径直接大幅增长。这相当于给游戏增加了一个“爽点”,节奏更有起伏。金色球用黄色绘制,注意跟普通球颜色区分开。6.3 做完这个游戏,下一步学什么《大球吃小球》做完之后,我获得的不是“我会做游戏了”这种虚妄的自信,而是“我具备自己折腾一个项目的能力了”。这个项目教会的核心能力——拆解需求、循环、事件、状态管理、碰撞检测——基本上覆盖了90%的2D休闲游戏所需要的基础。接下来可以按三条路线走。第一条是换个游戏做一遍:贪吃蛇、飞机大战、Flappy Bird,难度都比大球吃小球高一些,但核心骨架完全一样,是在重复中加深理解。第二条是给这个游戏加特性:关卡系统、能力道具、排行榜、多人在线,每个特性都会逼你学一个新知识点。第三条是往“可维护性”发展:把这个单文件拆成多个模块,学会写测试、写注释、管理代码版本,这是从“能做出来”到“能长期维护”的关键一步。我个人最推荐第二条路,因为加特性带给你的正反馈最强。我做完这个游戏后,第一个扩充就是加音效,然后是AI追人,接着是特殊食物,每一次扩充都在刷新我对“编程能做的事”的认知。最后分享一个对我帮助很大的小习惯:跑通一个成品之后,把代码复制一份,然后在副本上放心大胆地乱改。改一句,跑一次;再改一句,再跑一次。改成什么样都不心疼,因为没有保留价值的是那堆试错过程,留下来的才是你自己真正理解的东西。这个游戏可能在未来某天被我彻底重构或者扔进回收站,但“我能独立完成一个项目”这个事实,已经成为我继续学下去的底气。