打开游戏后,背景是一张带有淡黄色的方格纸,所有的元素(主角平台道具等)看起来都像是用彩笔随手涂鸦上去的,线条有些许的不规整,但是却显得灵动、亲切。

游戏主角是一个亮绿色、有四条小短腿、长着一个漏斗状长鼻子的外星生物。当按下空格时它会向上蹦跳,整个过程中都可以通过左右方向键控制游戏主角左右移动,蹦跳时借助绿色平台不断攀升到新的高度,以此来获得更高的分数。

游戏主角向上攀升的过程中,偶尔会运气好遇到一些加速神器,帮助玩家更快的获得分数,这些加速神器包括弹簧竹蜻蜓火箭喷气背包

游戏主角踩到弹簧时会发出清脆的「叮」的声音,并让游戏主角获得比自主跳跃要更高的跳跃高度。如果游戏主角踩到竹蜻蜓竹蜻蜓会戴在游戏主角头顶,并像直升机一样拖着游戏主角飞行一段距离,期间会发出直升机螺旋桨转动的声音。若游戏主角有幸遇到了火箭喷气背包,它可以获得比竹蜻蜓更快的飞行速度,并且会带着游戏主角飞行更久的时间,自然也会获得更多的分数。

整个游戏没有终点,只有不断增加的分数。在游戏主角跳跃过程中始终都会有重力存在,因此若游戏主角下落过程中没有遇到平台便会一直下坠,直到坠落超过底部则 Game Over。

领域建模

前文是对 Doodle Jump 游戏的场景及玩法描述,虽然相比各大游戏平台的版本,我的这个描述简化了许多,但请不要在意这些细节。在编辑前文描述的过程中我已经将其中的名词做了特殊标记,下面我们把前文出现的名词去重后列举出来。

游戏主角、外星生物、平台、加速神器、玩家、弹簧、竹蜻蜓、火箭喷气背包

可以发现其中的 游戏主角、外星生物、玩家其实指的是同一事物,我们给它统一为玩家。平台姑且就叫平台吧。加速神器我们给它取名为道具,弹簧、竹蜻蜓、火箭喷气背包的名字保持不变,则我们就有了下面的统一语言。

  • 玩家:游戏的控制主体,拥有重力属性,可以进行跳跃;
  • 平台:玩家可以接力向上,或是可以停留保持不下坠的载体;
  • 道具:可以改变玩家物理状态,比如跳跃的初始速度、赋予飞行的能力等,弹簧、竹蜻蜓、火箭喷气背包均为某一具体的道具。

当然仅仅只有上面的名词表还不够,每个名词仅仅只是一个演员而已,我们还需要一个优秀的导演来指导才能演一出好戏,这个导演在领域驱动设计(DDD)里面叫做聚合根(Aggregate Root)

代码实现

有了前文的分析我们就可以开始写代码了,首先可以搭建出下面的框架结构。

class Player: # 玩家
    pass

class Platform: # 平台
    pass

class Item: # 道具
    pass

class Spring(Item): # 弹簧
    pass

class Propeller(Item): # 竹蜻蜓
    pass

class Rocket(Item): # 火箭喷气背包
    pass

class GameSession: # 聚合根(导演)
    pass


if __name__ == "__main__":
    pass

我们采用 Pygame 来实现 Doodle Jump,因此需要导入 Pygame 包,并快速验证一下 Pygame 是否可以正常使用,直接完善GameSession类的代码即可。

import pygame
import sys


SCREEN_WIDTH, SCREEN_HEIGHT = 400, 600  # 定义游戏窗口的宽度和高度
FPS = 60                                # 每秒刷新的帧数,控制游戏运行流畅度

class GameSession:
    def __init__(self):
        pygame.init()
        self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
        pygame.display.set_caption("Doodle Jump By Guanngxu")
        self.clock = pygame.time.Clock()

    def run(self):
        while True:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    pygame.quit()
                    sys.exit()
            
            self.clock.tick(FPS)  # 控制游戏循环以每秒FPS帧的速度运行
            pygame.display.flip()  # 更新屏幕显示


if __name__ == "__main__":
    GameSession().run()

运行代码后确定可以正常弹出 pygame 弹窗,下一步即可添加游戏窗口背景。由于背景是一张图片,因此我们在源文件同目录下新建images文件夹,用于存放程序所需要用到的图片文件。加载图片需要使用pygame.image模块,我们增加一个工具类Utils用于处理此类需求。

import os

class Utils:
    # --- 资源加载助手函数 ---
    @staticmethod
    def load_img(name, scale=None):
        path = os.path.join("./images/", name) # 拼接图片路径
        try:
            img = pygame.image.load(path).convert_alpha() # 加载图片并转换Alpha通道(透明度优化)
            if scale: img = pygame.transform.scale(img, scale) # 如果指定了尺寸,则进行缩放
            return img
        except:
            # 如果图片丢失,生成一个占位用的灰色方块,确保程序不崩溃
            surf = pygame.Surface(scale if scale else (30, 30))
            surf.fill((200, 200, 200))
            return surf

有了Utils工具类后,就像导演搭建舞台一样,即可对GameSession进行修改,增加bg属性和updatedraw方法,分别用以存储背景图片和更新游戏数据以及绘制游戏界面,直观效果即游戏界面可加载背景图片显示的更好看了。

class GameSession:
    def __init__(self):
        pygame.init()
        self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
        pygame.display.set_caption("Doodle Jump By Guanngxu")
        self.clock = pygame.time.Clock()
        self.bg = Utils.load_img("background.png", (SCREEN_WIDTH, SCREEN_HEIGHT)) # 加载背景图片

    def update(self): # 更新游戏数据
        pass
    
    def draw(self): # 绘制游戏画面
        self.screen.blit(self.bg, (0, 0)) # 绘制背景图片

    def run(self):
        while True:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    pygame.quit()
                    sys.exit()
            
            self.update()
            self.draw()

            self.clock.tick(FPS)  # 控制游戏循环以每秒FPS帧的速度运行
            pygame.display.flip()  # 更新屏幕显示

游戏背景加载成功之后,继续完善PlayerPlatform类,先将所属的图片资源加载进来,与GameSession类同理,我们也需要增加updatedraw方法用于更新位置、动作数据和绘制工作。

class Player:
    def __init__(self):
        self.image = Utils.load_img("player.png", (40, 40)) # 加载玩家图片并缩放到40x40像素
        self.rect = self.image.get_rect() # 获取玩家图片的矩形区域,用于碰撞检测和位置管理

    def update(self):
        pass

    def draw(self, screen):
        screen.blit(self.image, self.rect) # 将玩家图片绘制在屏幕底部中央位置

class Platform:
    def __init__(self):
        self.image = Utils.load_img("platform.png", (70, 20)) # 加载平台图片并缩放到70x20像素
        self.rect = self.image.get_rect() # 获取平台图片的矩形区域,用于碰撞检测和位置管理

    def update(self):
        pass

    def draw(self, screen):
        screen.blit(self.image, self.rect) # 将平台图片绘制在屏幕上

为了让玩家能够有地方停留住,我们将第一个平台固定在屏幕底部正中央,其它平台则随机生成铺满即可,更新后的GameSession类如下。

import random

class GameSession:
    def __init__(self):
        pygame.init()
        self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
        pygame.display.set_caption("Doodle Jump By Guanngxu")
        self.clock = pygame.time.Clock()
        self.bg = Utils.load_img("background.png", (SCREEN_WIDTH, SCREEN_HEIGHT)) # 加载背景图片
        self.player = Player() # 创建玩家实例
        self.platforms = []
        self._init_platforms() # 初始化平台列表

    def _init_platforms(self):
        platform = Platform()
        platform.rect.x = SCREEN_WIDTH // 2 - platform.rect.width // 2
        platform.rect.y = SCREEN_HEIGHT - 50
        self.platforms.append(platform)
        # 初始化一些平台,确保玩家有地方跳
        for i in range(5):
            platform = Platform()
            platform.rect.x = random.randint(0, SCREEN_WIDTH - platform.rect.width)
            platform.rect.y = random.randint(0, SCREEN_HEIGHT - platform.rect.height)
            self.platforms.append(platform)


    def update(self): # 更新游戏数据
        pass
    
    def draw(self): # 绘制游戏画面
        self.screen.blit(self.bg, (0, 0)) # 绘制背景图片
        for platform in self.platforms:
            platform.draw(self.screen) # 绘制平台
        self.player.draw(self.screen) # 绘制玩家

运行代码后的效果如下。

可以发现平台生成的位置也太随意了,我们理应控制平台生成的高度间距以确保玩家可以跳上去,因此把Platform类的初始化函数做修改,将生成坐标改外外部传参以方便指定位置,待后续调整时方便修改。同时玩家还需要调整位置站在第一个平台上面。


class Player:
    def __init__(self):
        self.image = Utils.load_img("player.png", (40, 40)) # 加载玩家图片并缩放到40x40像素
        # 第一个平台离 y 轴 50,平台高度 20,所以减去 70
        self.rect = self.image.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT - 70)) # 获取玩家图片的矩形区域,用于碰撞检测和位置管理

class Platform:
    def __init__(self, x, y):
        self.image = Utils.load_img("platform.png", (70, 20)) # 加载平台图片并缩放到70x20像素
        self.rect = self.image.get_rect(topleft=(x, y)) # 获取平台图片的矩形区域,用于碰撞检测和位置管理

class GameSession:
    def _init_platforms(self):
        # 在屏幕底部中央创建一个初始平台,确保玩家有地方跳
        # platform 的 width 为 70,所以 x 坐标需要减去 35 来居中
        platform = Platform(SCREEN_WIDTH // 2 - 35, SCREEN_HEIGHT - 50)
        self.platforms.append(platform)
        # 初始化一些平台,确保玩家有地方跳
        for i in range(8):
            platform = Platform(random.randint(0, SCREEN_WIDTH - 70), SCREEN_HEIGHT - (i * 80) - 150)
            self.platforms.append(platform)

接下来需要加入动作效果了,还记得前文描述说整个过程都有重力作用在玩家身上,下面我们加入重力让玩家不断下坠。同时也需要引入碰撞检测以确保玩家在平台上时可以跳起来。

GRAVITY = 0.7                           # 模拟物理重力,每帧给玩家增加的向下速度

class Player:
    def __init__(self):
        self.image = Utils.load_img("player.png", (40, 40)) # 加载玩家图片并缩放到40x40像素
        # 第一个平台离 y 轴 50,平台高度 20,所以减去 70
        self.rect = self.image.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT - 70)) # 获取玩家图片的矩形区域,用于碰撞检测和位置管理
        self.speed_y = 0 # 玩家在 y 轴上的速度,初始为0

    def update(self):
        self.speed_y += GRAVITY # 每帧增加重力加速度
        self.rect.y += self.speed_y # 根据速度更新玩家的 y 坐标

class GameSession:
    def update(self): # 更新游戏数据
        for platform in self.platforms:
            platform.update() # 更新平台状态
        self.player.update() # 更新玩家状态

        for platform in self.platforms:
            # 检测玩家是否与平台发生碰撞,并且玩家正在向下移动时检测,向上移动时不检测
            if self.player.rect.colliderect(platform.rect) and self.player.speed_y > 0:
                self.player.speed_y = -15 # 碰撞后给予玩家一个向上的速度,模拟跳跃效果

仔细观察后会发现玩家在跳跃的过程中,小脚有可能会嵌入到平台中间,为了解决这个不符合逻辑的问题,我们需要在检测到碰撞后调整玩家和平台的相对位置,让玩家的底部坐标与平台的顶部坐标相同。

回顾前文描述,我们的初始设计是玩家站在平台上时若按下空格键,此时就可以跳跃起来。因此我们需要监听键盘事件,同时玩家还可以左右移动的逻辑也一并加入。

考虑到跳跃动作需要检测玩家是否站在平台上,跳跃的触发逻辑放在GameSession类的update方法中应会更方便,玩家左右移动的逻辑不涉及其它对象,则可尤其自身的update处理即可。

class Player:
    def update(self): # 更新游戏数据
        for platform in self.platforms:
            platform.update() # 更新平台状态
        self.player.update() # 更新玩家状态

        for platform in self.platforms:
            # 检测玩家是否与平台发生碰撞,并且玩家正在向下移动时检测,向上移动时不检测
            if self.player.rect.colliderect(platform.rect) and self.player.speed_y > 0:
                self.player.rect.bottom = platform.rect.top # 碰撞后将玩家的底部位置调整到平台的顶部,避免玩家穿过平台
                self.player.speed_y = 0 # 碰撞后将玩家的垂直速度重置为0,模拟站在平台上的效果
                keys = pygame.key.get_pressed()
                if keys[pygame.K_SPACE]: # 只有按空格才跳跃
                    self.player.jump() # 调用玩家的 jump 方法,执行跳跃逻辑

继续运行发现玩家如果头部碰到平台,玩家就会被那一个平台给「吸」上去,这种现象我们是不允许发生的,因此需要对玩家与平台的碰撞检测逻辑做一些修整,确保只有玩家的小脚碰到平台才会站住。

class GameSession:
    def update(self): # 更新游戏数据
        for platform in self.platforms:
            platform.update() # 更新平台状态
        self.player.update() # 更新玩家状态

        for platform in self.platforms:
            # 检测玩家是否与平台发生碰撞,并且玩家正在向下移动时检测,向上移动时不检测
            if self.player.rect.colliderect(platform.rect) and self.player.speed_y > 0:
                # 15 是一个经验值,表示玩家底部与平台顶部的碰撞距离,如果小于这个值才算真正的站在平台上,避免侧面碰撞误判
                if self.player.rect.bottom - platform.rect.top < 15: # 碰撞时只检测玩家底部与平台顶部的碰撞,避免侧面碰撞误判
                    self.player.rect.bottom = platform.rect.top # 碰撞后将玩家的底部位置调整到平台的顶部,避免玩家穿过平台
                    self.player.speed_y = 0 # 碰撞后将玩家的垂直速度重置为0,模拟站在平台上的效果
                    keys = pygame.key.get_pressed()
                    if keys[pygame.K_SPACE]: # 只有按空格才跳跃
                        self.player.jump() # 调用玩家的 jump 方法,执行跳跃逻辑

再运行代码会发现玩家不会被平台「吸」上去了,但是如果玩家左右移动超过边界时就看不见了,也不知道玩家到底移动到哪里去了,此处我们加入一个「穿墙」的效果,即如果玩家从右边移出了边界就让玩家从左边出现,反之亦然。

为了让游戏更加生动,当玩家跳跃时播放一个动效声音。声音文件与图片文件类似,将其存放在代码同目录下的sounds文件夹下,自然在工具类中需要增加加载音频文件的方法。

class Utils:
    @staticmethod
    def load_sound(name):
        base_path = "./sounds/"
        for ext in ['.wav', '.mp3', '.ogg']: # 遍历常见的音频格式
            full_path = os.path.join(base_path, name + ext)
            if os.path.exists(full_path):
                try: return pygame.mixer.Sound(full_path)
                except: continue
        return None # 如果没有找到任何格式的音效文件,返回 None

class Player:
    def __init__(self):
        self.image = Utils.load_img("player.png", (40, 40)) # 加载玩家图片并缩放到40x40像素
        # 第一个平台离 y 轴 50,平台高度 20,所以减去 70
        self.rect = self.image.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT - 70)) # 获取玩家图片的矩形区域,用于碰撞检测和位置管理
        self.speed_y = 0 # 玩家在 y 轴上的速度,初始为0
        self.speed_x = 8 # 玩家在 x 轴上的速度,固定为8
        self.jump_sound = Utils.load_sound("jump") # 加载跳跃音效

    def jump(self):
        self.speed_y = -15 # 碰撞后给予玩家一个向上的速度,模拟跳跃效果
        if self.jump_sound:
            self.jump_sound.play() # 播放跳跃音效

    def update(self):
        self.speed_y += GRAVITY # 每帧增加重力加速度
        self.rect.y += self.speed_y # 根据速度更新玩家的 y 坐标
        
        # 处理键盘左右键输入
        keys = pygame.key.get_pressed()
        if keys[pygame.K_LEFT]:
            self.rect.x -= self.speed_x # 向左移动
        if keys[pygame.K_RIGHT]:
            self.rect.x += self.speed_x # 向右移动

        if self.rect.right < 0: # 如果玩家完全移出左边界
            self.rect.left = SCREEN_WIDTH # 从右边重新出现
        elif self.rect.left > SCREEN_WIDTH: # 如果玩家完全移出右边界
            self.rect.right = 0 # 从左边重新出现

    def draw(self, screen):
        screen.blit(self.image, self.rect) # 将玩家图片绘制在屏幕底部中央位置

有了声音之后的程序是不是交互感更加强烈了,有木有?但是玩家跳到顶部后就没办法再往上跳了,所以我们要增加屏幕滚动的逻辑,确保玩家可以一直向上跳跃。我们可以设定一个高度阈值,如果玩家跳跃超过了这个高度阈值,就让整个屏幕向下滚动,需要注意的是屏幕向下滚动后需要在顶部区域生成新的平台才能保证游戏可继续下去。

所谓的屏幕滚动其实就是把除了背景的所有元素全部往下移动,人眼看起来就是屏幕在整体向下滚动,因此我们可以看玩家超过高度阈值多少,就让所有元素向下移动多少距离。

别忘了此时可以引入游戏计分的逻辑了,此处我们根据滚动距离增加分数。对于分数的显示借助pygame.font模块渲染文字即可。

class GameSession:
    def __init__(self):
        pygame.init()
        self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
        pygame.display.set_caption("Doodle Jump By Guanngxu")
        self.clock = pygame.time.Clock()
        self.bg = Utils.load_img("background.png", (SCREEN_WIDTH, SCREEN_HEIGHT)) # 加载背景图片
        self.player = Player() # 创建玩家实例
        self.platforms = [] # 初始化平台列表
        self.score = 0 # 初始化分数
        self._init_platforms() # 初始化平台列表

    def update_scroll(self):
        scroll_threshold = SCREEN_HEIGHT // 2 # 定义一个滚动阈值,当玩家超过这个高度时,平台开始向下滚动
        scroll_amount = 0 # 初始化滚动量
        if self.player.rect.top < scroll_threshold:
            scroll_amount = scroll_threshold - self.player.rect.top # 计算需要滚动的距离
            self.player.rect.top = scroll_threshold # 将玩家位置固定在滚动阈值上
            for platform in self.platforms:
                platform.rect.y += scroll_amount # 平台向下滚动

        # 平台向下滚动后,移除那些已经完全移出屏幕底部的平台,并在顶部生成新的平台
        self.platforms = [p for p in self.platforms if p.rect.top < SCREEN_HEIGHT]
        self.score += scroll_amount // 10 # 根据滚动距离增加分数,10 是一个经验值,表示每滚动10像素得1分
        while len(self.platforms) < 8: # 保持屏幕上至少有8个平台
            new_platform = Platform(random.randint(0, SCREEN_WIDTH - 70), random.randint(-100, -40))
            self.platforms.append(new_platform)

    def update(self): # 更新游戏数据
        self.update_scroll() # 更新滚动逻辑
        # 省略部分代码 .....

    def draw(self): # 绘制游戏画面
        self.screen.blit(self.bg, (0, 0)) # 绘制背景图片
        for platform in self.platforms:
            platform.draw(self.screen) # 绘制平台
        self.player.draw(self.screen) # 绘制玩家
        scrore_text = pygame.font.SysFont("Arial", 24).render(f"Score: {self.score}", True, (0, 0, 0))
        self.screen.blit(scrore_text, (10, 10)) # 在屏幕左

确保玩家的相关逻辑都完善后,我们开始引入道具助力玩家获得更多的分数。道具是对弹簧、竹蜻蜓、火箭喷气背包等的统称,即我们可以实现一个道具父类Item,所有道具共性的部分由Item类来实现,具体道具个性化的部分则自行实现。

道具在未生效前应该和平台绑定,因为道具始终停留在平台之上,这样也可以在平台更新时顺便就更新了道具。考虑到生成道具还需要一定的概率,所以我们在工具类中再添加一个计算概率的方法。我们先加入弹簧类看看效果。

class Utils:
    @staticmethod
    def hit_probability(prob):
        return random.random() < prob # 返回一个布尔值,表示是否以给定概率命中

class Platform:
    def __init__(self, x, y):
        self.image = Utils.load_img("platform.png", (70, 20)) # 加载平台图片并缩放到70x20像素
        self.rect = self.image.get_rect(topleft=(x, y)) # 获取平台图片的矩形区域,用于碰撞检测和位置管理
        self.item = None # 平台上可能有一个道具,初始为 None

    def update(self):
        if self.item:
            self.item.update() # 如果平台上有道具,更新道具状态

    def draw(self, screen):
        screen.blit(self.image, self.rect) # 将平台图片绘制在屏幕上
        if self.item:
            self.item.draw(screen) # 如果平台上有道具,绘制道具

class Item:
    probability = 0.3 # 物品生成的概率,默认30%
    
    def __init__(self, platform):
        self.platform = platform # 物品所在的平台
        self.frames = [] # 存储动画帧的列表
        self.current_frame = 0 # 当前动画帧的索引
        self.rect = None # 物品的矩形区域,用于碰撞检测和位置管理
        self.has_used = False # 物品是否已经被玩家使用过,避免重复使用

    def update(self):
        self.rect.midbottom = self.platform.rect.midtop # 物品始终跟随平台移动,保持在平台顶部

    def draw(self, screen):
        if self.frames:
            screen.blit(self.frames[self.current_frame], self.rect) # 绘制当前动画帧

class Spring(Item):
    probability = 0.5 # 弹簧生成的概率,50%

    def __init__(self, platform):
        super().__init__(platform)
        self.frames = [Utils.load_img(f"spring_{i}.png", (30, 30)) for i in range(2)] # 加载弹簧的两帧动画
        self.rect = self.frames[0].get_rect() # 获取弹簧图片的矩形区域,用于碰撞检测和位置管理
        self.sound = Utils.load_sound("spring") # 加载弹簧音效

class GameSession:
    def update_scroll(self):
        # 省略部分代码 ......
        while len(self.platforms) < 8: # 保持屏幕上至少有8个平台
            new_platform = Platform(random.randint(0, SCREEN_WIDTH - 70), random.randint(-100, -40))
            if Utils.hit_probability(Spring.probability): # 根据弹簧的生成概率决定是否在新平台上生成弹簧
                spring = Spring(new_platform)
                new_platform.item = spring # 将弹簧作为平台的一个属性,方便后续碰撞检测和更新
            self.platforms.append(new_platform)

现在程序运行过程中会出现弹簧道具了,但是玩家碰到弹簧道具并没有相应的效果生效,下面就来完善弹簧触发后的效果逻辑。

若玩家触碰到弹簧,则首先弹簧会「弹开」,即涉及到弹簧的动画播放。其实对于弹簧弹开的动画,我们只需要快速切换不同的弹簧图片即可,这样看起来就是弹簧弹开一样,这个过程由animate方法实现。

弹簧弹开后,玩家会被弹簧作用一个更强的向上的速度,以此来模拟更高的跳跃效果,所有道具对玩家的作用我们都通过apply_effect方法实现。

class Platform:
    def update(self):
        if self.item:
            self.item.update() # 如果平台上有道具,更新道具状态
            self.item.animate() # 如果平台上有道具,执行道具动画

class Item:
    def apply_effect(self, player):
        pass

class Spring(Item):
    def apply_effect(self, player):
        if self.has_used: return # 如果已经被使用过,直接返回,避免重复使用
        self.has_used = True # 标记为已使用
        player.speed_y = -20 # 弹簧给予玩家一个更强的向上的速度,模拟更高的跳跃效果
        if self.sound:
            self.sound.play() # 播放弹簧音效
    
    def animate(self):
        if self.has_used:
            self.current_frame = (self.current_frame + 1) % len(self.frames) # 切换到下一帧动画
        else:
            self.current_frame = 0 # 如果没有被使用过,保持在第一帧动画

class GameSession:
    def update_scroll(self):
        scroll_threshold = SCREEN_HEIGHT // 2 # 定义一个滚动阈值,当玩家超过这个高度时,平台开始向下滚动
        scroll_amount = 0 # 初始化滚动量
        if self.player.rect.top < scroll_threshold:
            scroll_amount = scroll_threshold - self.player.rect.top # 计算需要滚动的距离
            self.player.rect.top = scroll_threshold # 将玩家位置固定在滚动阈值上
            for platform in self.platforms:
                platform.rect.y += scroll_amount # 平台向下滚动

        # 平台向下滚动后,移除那些已经完全移出屏幕底部的平台,并在顶部生成新的平台
        self.platforms = [p for p in self.platforms if p.rect.top < SCREEN_HEIGHT]
        self.score += scroll_amount // 10 # 根据滚动距离增加分数,10 是一个经验值,表示每滚动10像素得1分
        while len(self.platforms) < 8: # 保持屏幕上至少有8个平台
            new_platform = Platform(random.randint(0, SCREEN_WIDTH - 70), random.randint(-100, -40))
            if Utils.hit_probability(Spring.probability): # 根据弹簧的生成概率决定是否在新平台上生成弹簧
                spring = Spring(new_platform)
                new_platform.item = spring # 将弹簧作为平台的一个属性,方便后续碰撞检测和更新
            self.platforms.append(new_platform)

    # 检测玩家与道具的碰撞,并处理道具效果
    def item_colliderect(self, item):
        # 检测玩家是否与道具发生碰撞,并且道具没有被使用过
        if self.player.rect.colliderect(item.rect) and not item.has_used:
            item.apply_effect(self.player) # 调用 apply_effect 方法

    # 检测玩家与平台的碰撞,并处理跳跃逻辑
    def platform_colliderect(self, platform):
        # 检测玩家是否与平台发生碰撞,并且玩家正在向下移动时检测,向上移动时不检测
            if self.player.rect.colliderect(platform.rect) and self.player.speed_y > 0:
                # 15 是一个经验值,表示玩家底部与平台顶部的碰撞距离,如果小于这个值才算真正的站在平台上,避免侧面碰撞误判
                if self.player.rect.bottom - platform.rect.top < 15: # 碰撞时只检测玩家底部与平台顶部的碰撞,避免侧面碰撞误判
                    self.player.rect.bottom = platform.rect.top # 碰撞后将玩家的底部位置调整到平台的顶部,避免玩家穿过平台
                    self.player.speed_y = 0 # 碰撞后将玩家的垂直速度重置为0,模拟站在平台上的效果
                    keys = pygame.key.get_pressed()
                    if keys[pygame.K_SPACE]: # 只有按空格才跳跃
                        self.player.jump() # 调用玩家的 jump 方法,执行跳跃逻辑
    
    def update(self): # 更新游戏数据
        self.update_scroll() # 更新滚动逻辑

        for platform in self.platforms:
            platform.update() # 更新平台状态
        self.player.update() # 更新玩家状态

        # 检测玩家与平台的碰撞,并处理跳跃逻辑,同时检测玩家与道具的碰撞,并处理道具效果
        for platform in self.platforms:
            self.platform_colliderect(platform) # 检测玩家与平台的碰撞,并处理跳跃逻辑
            if platform.item: # 如果平台上有道具,检测玩家与道具的碰撞,并处理道具效果
                self.item_colliderect(platform.item)

运行之后发现此前实现的animate方法不适用于弹簧道具,玩家触碰到弹簧道具后应是弹簧弹开一次即可,现在的效果是弹簧会一直不停的切换状态,且切换的速度太快了,需要重新实现animate方法,并调整一下动画的播放速度。

除了弹簧弹开动画不适用外,还存在的问题是玩家在跳跃过程中,如果脑袋先触碰到弹簧也会直接触发效果,这和现实世界逻辑是不对应的,弹簧应该是跌落过程踩到才可生效,因此需要对玩家与道具的碰撞检测进行条件限制,限制在向下移动的过程中。

class Item:
    probability = 0.3 # 物品生成的概率,默认30%
    animate_speed = 0.2 # 物品动画的速度,经验值,表示每帧切换动画的概率
    
    def __init__(self, platform):
        self.platform = platform # 物品所在的平台
        self.frames = [] # 存储动画帧的列表
        self.current_frame_index = 0 # 当前动画帧的索引
        self.animate_timer = 0 # 动画计时器,用于控制动画切换速度
        self.rect = None # 物品的矩形区域,用于碰撞检测和位置管理
        self.has_used = False # 物品是否已经被玩家使用过,避免重复使用

    def draw(self, screen):
        if self.frames:
            screen.blit(self.frames[self.current_frame_index], self.rect) # 绘制当前动画帧

class Spring(Item):
    probability = 0.5 # 弹簧生成的概率,50%

    def __init__(self, platform):
        super().__init__(platform)
        self.frames = [Utils.load_img(f"spring_{i}.png", (30, 30)) for i in range(2)] # 加载弹簧的两帧动画
        self.rect = self.frames[0].get_rect() # 获取弹簧图片的矩形区域,用于碰撞检测和位置管理
        self.sound = Utils.load_sound("spring") # 加载弹簧音效
        self.animate_played = False # 标记动画是否已经播放过,避免重复播放
    
    def animate(self):
        if self.has_used and not self.animate_played:
            self.animate_timer += self.animate_speed # 增加动画计时器
            if self.animate_timer >= 1: # 如果计时器达到切换动画的条件
                self.animate_timer = 0 # 重置动画计时器
                self.current_frame_index = (self.current_frame_index + 1) % len(self.frames) # 切换到下一帧动画
                if self.current_frame_index == len(self.frames) - 1: # 如果动画已经播放到最后一帧,标记动画已经播放过
                    self.animate_played = True
        elif not self.has_used:
            self.current_frame_index = 0 # 如果没有被使用过,保持在第一帧动画

class GameSession:
    def update(self): # 更新游戏数据
        self.update_scroll() # 更新滚动逻辑

        for platform in self.platforms:
            platform.update() # 更新平台状态
        self.player.update() # 更新玩家状态

        if self.player.speed_y >= 0: # 只有当玩家正在向下移动时才检测碰撞,向上移动时不检测
            # 检测玩家与平台的碰撞,并处理跳跃逻辑,同时检测玩家与道具的碰撞,并处理道具效果
            for platform in self.platforms:
                self.platform_colliderect(platform) # 检测玩家与平台的碰撞,并处理跳跃逻辑
                if platform.item: # 如果平台上有道具,检测玩家与道具的碰撞,并处理道具效果
                    self.item_colliderect(platform.item)

确认弹簧道具没有问题后,我们继续补充竹蜻蜓道具的逻辑,并在生成新的平台时引入竹蜻蜓道具。

class Propeller(Item):
    probability = 0.8 # 螺旋桨生成的概率,10%
    animate_speed = 0.1 # 螺旋桨动画的速度,经验值,表示每帧切换动画的概率
    def __init__(self, platform):
        super().__init__(platform)
        self.frames = [Utils.load_img(f"propeller_{i}.png", (40, 20)) for i in range(2)] # 加载螺旋桨的两帧动画
        self.rect = self.frames[0].get_rect() # 获取螺旋桨图片的矩形区域,用于碰撞检测和位置管理
        self.sound = Utils.load_sound("propeller") # 加载螺旋桨音效
        self.fly_duration_timer = 150 # 竹蜻蜓效果持续的帧数(约2.5秒)
        self.player = None # 记录被螺旋桨影响的玩家实例,方便在 update 中处理竹蜻蜓效果

    def update(self):
        if not self.has_used:
            super().update() # 调用父类的 update 方法,保持物品跟随平台移动
        else:
            if self.has_used and self.fly_duration_timer > 0:
                self.player.speed_y = -12 - GRAVITY # 竹蜻蜓给予玩家一个持续的向上的速度,模拟竹蜻蜓效果,同时考虑重力影响
                self.rect.midbottom = self.player.rect.midtop # 竹蜻蜓效果期间,物品跟随玩家移动,保持在玩家头顶
                self.rect.centerx -= 5 # 细节微调位置
                self.rect.centery -= 5
                # TODO:因为对应的平台被回收了,对应没有调用 item 指定的 draw 等方案
                print(self.fly_duration_timer)
                self.fly_duration_timer -= 1 # 竹蜻蜓效果持续期间,减少计时器
            else:
                self.player = None # 竹蜻蜓效果结束,重置玩家引用

    def apply_effect(self, player):
        if self.has_used: return # 如果已经被使用过,直接返回,避免重复使用
        self.has_used = True # 标记为已使用
        player.speed_y = -12 # 螺旋桨给予玩家一个持续的向上的速度,模拟竹蜻蜓效果
        self.player = player # 记录被螺旋桨影响的玩家实例
        if self.sound:
            self.sound.play() # 播放螺旋桨音效
    
    def animate(self):
        if self.has_used:
            self.animate_timer += self.animate_speed # 增加动画计时器
            if self.animate_timer >= 1: # 如果计时器达到切换动画的条件
                self.animate_timer = 0 # 重置动画计时器
                self.current_frame_index = (self.current_frame_index + 1) % len(self.frames) # 切换到下一帧动画

class GameSession:
    def update_scroll(self):
        # 省略部分代码
        while len(self.platforms) < 8: # 保持屏幕上至少有8个平台
            new_platform = Platform(random.randint(0, SCREEN_WIDTH - 70), random.randint(-100, -40))
            if Utils.hit_probability(Spring.probability): # 根据弹簧的生成概率决定是否在新平台上生成弹簧
                spring = Spring(new_platform)
                new_platform.item = spring # 将弹簧作为平台的一个属性,方便后续碰撞检测和更新
            elif Utils.hit_probability(Propeller.probability): # 根据螺旋桨的生成概率决定是否在新平台上生成螺旋桨
                propeller = Propeller(new_platform)
                new_platform.item = propeller # 将螺旋桨作为平台的一个属性,方便后续碰撞检测和更新
            self.platforms.append(new_platform)

现在当玩家碰到竹蜻蜓道具后,即会触发飞行效果,但是生效的时间并不符合我们的预期,分析之后确认原因在于道具始终和平台绑定,当平台被移除后道具则跟着消失,因此对于竹蜻蜓这样的道具需要在玩家碰到它后,让其和玩家绑定才不会消失。

class Player:
    def __init__(self):
        # 省略部分代码
        self.active_item = None # 记录当前正在影响玩家的道具实例,方便在 update 中处理道具效果

    def update(self):
        if self.active_item:
            self.active_item.update() # 如果有正在影响玩家的道具,更新道具状态,处理道具效果
            self.active_item.animate() # 如果有正在影响玩家的道具,执行道具动画
            self.active_item.rect.midbottom = self.rect.midtop # 竹蜻蜓效果期间,物品跟随玩家移动,保持在玩家头顶
            self.active_item.rect.centerx -= 5 # 细节微调位置
            self.active_item.rect.centery -= 5
            self.speed_y = self.active_item.fly_velocity # 竹蜻蜓效果期间,玩家获得一个持续的向上的速度,模拟竹蜻蜓效果
            if self.active_item.fly_duration_timer <= 0: # 竹蜻蜓效果结束
                self.active_item = None # 重置当前正在影响玩家的道具实例,结束竹蜻蜓效果

        self.speed_y += GRAVITY # 每帧增加重力加速度
        self.rect.y += self.speed_y # 根据速度更新玩家的 y 坐标
# 省略部分代码

    def draw(self, screen):
        screen.blit(self.image, self.rect) # 将玩家图片绘制在屏幕底部中央位置
        if self.active_item:
            self.active_item.draw(screen) # 如果有正在影响玩家的道具,绘制道具

class Propeller(Item):
    probability = 0.8 # 螺旋桨生成的概率,10%
    animate_speed = 0.1 # 螺旋桨动画的速度,经验值,表示每帧切换动画的概率
    def __init__(self, platform):
        # 省略部分代码
        self.fly_velocity = -12 # 竹蜻蜓给予玩家的持续向上的速度,经验值,表示比普通跳跃更高的跳跃效果

    def update(self):
        if not self.has_used:
            super().update() # 调用父类的 update 方法,保持物品跟随平台移动
        else:
            if self.has_used and self.fly_duration_timer > 0:
                self.fly_duration_timer -= 1 # 竹蜻蜓效果持续期间,减少计时器

    def apply_effect(self, player):
        if self.has_used: return # 如果已经被使用过,直接返回,避免重复使用
        self.has_used = True # 标记为已使用
        player.active_item = self # 记录当前正在影响玩家的道具实例,方便在 update 中处理道具效果
        if self.sound:
            self.sound.play() # 播放螺旋桨音效

考虑到后续可能还有火箭喷气背包等道具也需要做同样的处理,需要一直跟随着玩家,并且也需要一直循环播放动画,因此我们可以抽出来follow_playeranimate方法。另外我们本次更新考虑将代码进行极小部分重构,以及修复可能存在的 bug。

class Player:
    def update(self):
        if self.active_item:
            self.active_item.update() # 如果有正在影响玩家的道具,更新道具状态,处理道具效果
            self.active_item.animate() # 如果有正在影响玩家的道具,执行道具动画
            self.active_item.follow_player(self) # 如果有正在影响玩家的道具,执行跟随玩家的逻辑,保持道具与玩家位置同步
            self.speed_y = self.active_item.fly_velocity - GRAVITY # 竹蜻蜓效果期间,玩家获得一个持续的向上的速度,模拟竹蜻蜓效果
            if self.active_item.fly_duration_timer <= 0: # 竹蜻蜓效果结束
                self.active_item = None # 重置当前正在影响玩家的道具实例,结束竹蜻蜓效果
    # 省略部分代码......

class Item:
    # 某些道具需要跟随玩家移动,比如竹蜻蜓,这个方法可以用来实现跟随玩家的逻辑
    def follow_player(self, player):
        pass

    def animate(self):
        pass
    
class Propeller(Item):
    probability = 0.1 # 螺旋桨生成的概率,10%
    animate_speed = 0.1 # 螺旋桨动画的速度,经验值,表示每帧切换动画的概率

    def follow_player(self, player):
        self.rect.midbottom = player.rect.midtop # 竹蜻蜓效果期间,物品跟随玩家移动,保持在玩家头顶
        self.rect.centerx -= 5 # 细节微调位置,让它更居中一些
        self.rect.centery -= 5

    def apply_effect(self, player):
        if self.has_used: return # 如果已经被使用过,直接返回,避免重复使用
        self.platform.item = None # 使用后将平台上的道具引用清除,避免重复使用
        self.has_used = True # 标记为已使用
        player.active_item = self # 记录当前正在影响玩家的道具实例,方便在 update 中处理道具效果
        if self.sound:
            self.sound.play() # 播放螺旋桨音效

class GameSession:
    def __init__(self):
        # 省略部分代码......
        self.font = pygame.font.SysFont("Arial", 24) # 初始化字体对象,用于绘制分数
        self._init_platforms() # 初始化平台列表

    def update_scroll(self):
        # 省略部分代码
        while len(self.platforms) < 8: # 保持屏幕上至少有8个平台
            new_platform = Platform(random.randint(0, SCREEN_WIDTH - 70), random.randint(-100, -40))
            self.generate_item(new_platform)
            self.platforms.append(new_platform)

    def generate_item(self, platform):
        if Utils.hit_probability(Spring.probability): # 根据弹簧的生成概率决定是否在平台上生成弹簧
            spring = Spring(platform)
            platform.item = spring # 将弹簧作为平台的一个属性,方便后续碰撞检测和更新
        elif Utils.hit_probability(Propeller.probability): # 根据螺旋桨的生成概率决定是否在平台上生成螺旋桨
            propeller = Propeller(platform)
            platform.item = propeller # 将螺旋桨作为平台的一个属性,方便后续碰撞检测和更新
    # 检测玩家与平台的碰撞,并处理跳跃逻辑
    def platform_colliderect(self, platform):
        # 省略部分代码......
                    if keys[pygame.K_SPACE]: # 只有按空格才跳跃
                        self.player.jump() # 调用玩家的 jump 方法,执行跳跃逻辑
                    return True # 碰撞后返回 True,表示玩家成功站在平台上

    def update(self): # 更新游戏数据
        # 省略部分代码......
            for platform in self.platforms:
                if platform.item: # 如果平台上有道具,检测玩家与道具的碰撞,并处理道具效果
                    self.item_colliderect(platform.item)
                if self.platform_colliderect(platform): # 检测玩家与平台的碰撞,并处理跳跃逻辑
                    break # 如果已经检测到玩家与一个平台发生碰撞并处理了跳跃逻辑,就不再继续检测其他平台,避免多重碰撞导致的跳跃问题
            

    def draw(self): # 绘制游戏画面
        # 省略部分代码
        scrore_text = self.font.render(f"Score: {self.score}", True, (0, 0, 0))
        self.screen.blit(scrore_text, (10, 10)) # 在屏幕左

下面我们引入菜单页面,用于提示玩家玩法,标注作者信息。

WHITE, BLACK, GRAY = (255, 255, 255), (0, 0, 0), (100, 100, 100) # 颜色常量(RGB)
TITLE_COLOR = (255, 120, 0)             # 主界面标题的颜色

class GameSession:
    def __init__(self):
        # 省略部分代码......
        self.score = 0 # 初始化分数
        self.score_font = pygame.font.SysFont("Arial", 24, bold=True) # 初始化字体对象,用于绘制分数
        self.title_font = pygame.font.SysFont("Comic Sans MS", 55, bold=True)
        self.author_font = pygame.font.SysFont("Arial", 22, italic=True)
        self.start_font = pygame.font.SysFont("Arial", 26, bold=True)
        self.state = "menu" # 游戏状态,初始为菜单界面
        self._init_platforms() # 初始化平台列表
    def update(self): # 更新游戏数据
        self.update_scroll() # 更新滚动逻辑

        for platform in self.platforms:
            platform.update() # 更新平台状态
        self.player.update() # 更新玩家状态

        if self.player.speed_y >= 0: # 只有当玩家正在向下移动时才检测碰撞,向上移动时不检测
            # 检测玩家与平台的碰撞,并处理跳跃逻辑,同时检测玩家与道具的碰撞,并处理道具效果
            for platform in self.platforms:
                if platform.item: # 如果平台上有道具,检测玩家与道具的碰撞,并处理道具效果
                    self.item_colliderect(platform.item)
                if self.platform_colliderect(platform): # 检测玩家与平台的碰撞,并处理跳跃逻辑
                    break # 如果已经检测到玩家与一个平台发生碰撞并处理了跳跃逻辑,就不再继续检测其他平台,避免多重碰撞导致的跳跃问题
        
        if self.player.rect.top > SCREEN_HEIGHT: # 如果玩家掉出屏幕底部,游戏结束,重置游戏状态
            self.__init__() # 重新初始化游戏状态,回到菜单界面

    def draw(self): # 绘制游戏画面
        self.screen.blit(self.bg, (0, 0)) # 绘制背景图片
        for platform in self.platforms:
            platform.draw(self.screen) # 绘制平台
        self.player.draw(self.screen) # 绘制玩家
        scrore_text = self.score_font.render(f"Score: {self.score}", True, BLACK)
        self.screen.blit(scrore_text, (10, 10)) # 在屏幕左

    def draw_menu(self):
        self.screen.blit(self.bg, (0, 0)) # 绘制背景图片
        # 渲染标题
        title_surf = self.title_font.render("Doodle Jump", True, TITLE_COLOR)
        self.screen.blit(title_surf, (SCREEN_WIDTH//2 - title_surf.get_width()//2, 120))
        # 渲染作者信息
        author_surf = self.author_font.render("Author: Guanngxu", True, BLACK)
        self.screen.blit(author_surf, (SCREEN_WIDTH//2 - author_surf.get_width()//2, 210))
        # 渲染提示文字
        msg = self.start_font.render("Press [ SPACE ] to Start", True, (50, 50, 50))
        self.screen.blit(msg, (SCREEN_WIDTH//2 - msg.get_width()//2, 380))
        pygame.display.flip()

    def run(self):
        while True:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    pygame.quit()
                    sys.exit()
            
            if self.state == "menu":
                self.draw_menu()
                keys = pygame.key.get_pressed()
                if keys[pygame.K_SPACE]: # 在菜单界面按空格开始游戏
                    self.state = "playing"
            elif self.state == "playing":
                self.update()
                self.draw()

            self.clock.tick(FPS)  # 控制游戏循环以每秒FPS帧的速度运行
            pygame.display.flip()  # 更新屏幕显示

运行后确认效果如预期,继续引入背景白云朵朵,只需要加入Cloud类后,在合适的地方实例化并调用其更新方法即可。

class Cloud():
    def __init__(self):
        size = random.randint(50, 100) # 云朵的随机大小
        self.frame = Utils.load_img("cloud.png", (size, size // 2)) # 加载云朵图片并缩放到随机大小
        self.rect = self.frame.get_rect(
            x=random.randint(0, SCREEN_WIDTH - size), # 云朵的随机水平位置
            y=random.randint(0, SCREEN_HEIGHT // 2) # 云朵的随机垂直位置,限制在屏幕上半部分
        )
        self.direction = random.choice([-1, 1]) # 云朵的移动方向,-1表示向左,1表示向右
        self.speed = random.uniform(0.5, 1.5) # 云朵的移动速度,随机生成一个经验值
        self.alpha = random.randint(100, 255) # 云朵的随机透明度,增加视觉层次感

    def update(self):
        self.rect.x += self.direction * self.speed # 云朵以固定速度向左右移动
        if self.rect.right < 0: # 如果云朵完全移出左边界
            self.rect.left = SCREEN_WIDTH # 从右边重新出现
        elif self.rect.left > SCREEN_WIDTH: # 如果云朵完全移出右边界
            self.rect.right = 0 # 从左边重新出现
        
        self.rect.y += random.uniform(-0.5, 0.5) # 云朵在垂直方向上有轻微的随机漂浮效果
        if self.rect.top > SCREEN_HEIGHT // 2: # 限制云朵在屏幕上半部分
            self.rect.y = random.randint(0, SCREEN_HEIGHT // 2) # 如果云朵漂浮到下半部分,随机重置到上半部分

    def draw(self, screen):
        # 设置透明度
        temp_surface = self.frame.copy()
        temp_surface.set_alpha(self.alpha)
        screen.blit(temp_surface, self.rect) # 绘制云朵图片

class GameSession:
    def __init__(self):
        # 省略部分代码......
        self.clouds = [Cloud() for _ in range(5)] # 初始化云朵列表,创建5朵云

    def update_scroll(self):
        # 省略部分代码......
        for cloud in self.clouds:
            cloud.rect.y += scroll_amount // 2 # 云朵以较慢的速度向下滚动

    def update(self): # 更新游戏数据
        self.update_scroll() # 更新滚动逻辑

        for cloud in self.clouds:
            cloud.update() # 更新云朵状态
        # 省略部分代码......

    def draw(self): # 绘制游戏画面
        self.screen.blit(self.bg, (0, 0)) # 绘制背景图片
        for cloud in self.clouds:
            cloud.draw(self.screen) # 绘制云朵
        # 省略部分代码......

考虑到当玩家掉到屏幕外时直接调用__init__方法可能存在一些风险,比如重复初始化 Pygame 导致系统资源分配异常或某些模块状态错乱;每次都会重新创建pygame.display.set_mode和多个pygame.font.SysFont对象,旧的对象如果没有被 Python 的垃圾回收机制及时清理,会导致内存占用不断上升。所以单独抽取出来reset_game方法,只初始化游戏内的对象数据。

class Cloud():
    def update(self):
        # 省略部分代码......
        if self.rect.top > SCREEN_HEIGHT:
            self.rect.y = random.randint(0, SCREEN_HEIGHT // 2) # 如果云朵漂浮到下半部分,随机重置到上半部分

class GameSession:
    def reset_game(self):
        # 重新初始化游戏内的对象数据
        self.player = Player() 
        self.platforms = [] 
        self.clouds = [Cloud() for _ in range(5)] 
        self.score = 0
        self.state = "menu" # 回到菜单界面
        self._init_platforms() 

    def update(self): # 更新游戏数据
        # 省略部分代码......
        if self.player.rect.top > SCREEN_HEIGHT: # 如果玩家掉出屏幕底部,游戏结束,重置游戏状态
            self.reset_game() # 重新初始化游戏状态,回到菜单界面

潜在问题都解决后,最后我们引入火箭喷气背包道具,它的大部分代码应是和竹蜻蜓道具一样。需要注意的是由于素材问题,需要将玩家进行翻转,以确保火箭喷气背包可以背在玩家右侧。

class Player:
    def __init__(self):
        self.image = Utils.load_img("player.png", (40, 40)) # 加载玩家图片并缩放到40x40像素
        self.image = pygame.transform.flip(self.image, flip_x=True, flip_y=False) # 将玩家镜像翻转,方便背上火箭道具
        # 省略部分代码......

class Item:
    def apply_effect(self, player):
        if self.has_used: return # 如果已经被使用过,直接返回,避免重复使用
        self.platform.item = None # 使用后将平台上的道具引用清除,避免重复使用
        self.has_used = True # 标记为已使用
        player.active_item = self # 记录当前正在影响玩家的道具实例,方便在 update 中处理道具效果
        if self.sound:
            self.sound.play() # 播放螺旋桨音效

    def animate(self):
        if self.has_used:
            self.animate_timer += self.animate_speed # 增加动画计时器
            if self.animate_timer >= 1: # 如果计时器达到切换动画的条件
                self.animate_timer = 0 # 重置动画计时器
                self.current_frame_index = (self.current_frame_index + 1) % len(self.frames) # 切换到下一帧动画

class Rocket(Item):
    probability = 0.05 # 火箭生成的概率,5%
    animate_speed = 0.5 # 火箭动画的速度,经验值,表示每帧切换动画的概率
    def __init__(self, platform):
        super().__init__(platform)
        self._init_frames()
        self.rect = self.frames[0].get_rect() # 获取火箭图片的矩形区域,用于碰撞检测和位置管理
        self.sound = Utils.load_sound("rocket") # 加载火箭音效
        self.fly_duration_timer = 200 # 火箭效果持续的帧数(约3.3秒)
        self.fly_velocity = -18 # 火箭给予玩家的持续向上的速度,经验值,表示比竹蜻蜓更高的跳跃效果

    def _init_frames(self):
        img_sheet = Utils.load_img("rocket.png", (160, 240)) # 加载火箭图片
        frame_width = 40
        frame_height = 80
        # 计算当前帧的位置:(x, y, width, height)
        for i in range(3):
            for j in range(4):
                frame = img_sheet.subsurface((j * frame_width, i * frame_height, frame_width, frame_height)) # 从图片中提取每一帧
                self.frames.append(frame) # 将每一帧添加到动画帧列表中
        self.frames.remove(self.frames[len(self.frames) - 1]) # 移除最后一帧,因为它是空白的
        self.frames.remove(self.frames[len(self.frames) - 1]) # 再次移除最后一帧,因为它是空白的

    def follow_player(self, player):
        self.rect.y = player.rect.y - 25
        # 因为玩家的宽度是20
        self.rect.x = player.rect.x + 28 # 细节微调位置

    def update(self):
        if not self.has_used:
            super().update() # 调用父类的 update 方法,保持物品跟随平台移动
            self.rect.y += 22 # 调整图片位置
        else:
            if self.has_used and self.fly_duration_timer > 0:
                self.fly_duration_timer -= 1 # 火箭效果持续期间,减少计时器

class GameSession:
    def generate_item(self, platform):
        if Utils.hit_probability(Spring.probability): # 根据弹簧的生成概率决定是否在平台上生成弹簧
            spring = Spring(platform)
            platform.item = spring # 将弹簧作为平台的一个属性,方便后续碰撞检测和更新
        elif Utils.hit_probability(Propeller.probability): # 根据螺旋桨的生成概率决定是否在平台上生成螺旋桨
            propeller = Propeller(platform)
            platform.item = propeller # 将螺旋桨作为平台的一个属性,方便后续碰撞检测和更新
        elif Utils.hit_probability(Rocket.probability): # 根据火箭的生成概率决定是否在新平台上生成火箭
            rocket = Rocket(platform)
            platform.item = rocket # 将火箭作为平台的一个属性,方便后续碰撞检测和更新

程序打包

程序完成后需要进行打包方可给到用户使用,我们打包工具使用 Pyinstaller。当 PyInstaller 把所有东西打包进一个.exe时,运行时它会把资源解压到一个临时的文件夹(通常叫_MEIPASS)。但我们的代码里写的是死路径./images/,程序会去.exe所在的文件夹找,而不是去临时文件夹找,会导致报错。

我们需要修改Utils类,添加一个路径转换函数,之后方可进行打包。

class Utils:
    # --- 资源加载助手函数 ---
    @staticmethod
    def resource_path(relative_path):
        try:
            # PyInstaller创建临时文件夹,将路径存储于_MEIPASS
            base_path = sys._MEIPASS
        except Exception:
            base_path = os.path.abspath(".")
        return os.path.join(base_path, relative_path)
    
    @staticmethod
    def load_img(name, scale=None):
        # path = os.path.join("./images/", name) # 拼接图片路径
        path = Utils.resource_path(os.path.join("images", name)) # 获取资源路径,兼容打包后的路径
        try:
            img = pygame.image.load(path).convert_alpha() # 加载图片并转换Alpha通道(透明度优化)
            if scale: img = pygame.transform.scale(img, scale) # 如果指定了尺寸,则进行缩放
            return img
        except:
            # 如果图片丢失,生成一个占位用的灰色方块,确保程序不崩溃
            surf = pygame.Surface(scale if scale else (30, 30))
            surf.fill((200, 200, 200))
            return surf
    
    @staticmethod
    def load_sound(name):
        base_path = "./sounds/"
        for ext in ['.wav', '.mp3', '.ogg']: # 遍历常见的音频格式
            # full_path = os.path.join(base_path, name + ext)
            full_path = Utils.resource_path(os.path.join(base_path, name + ext)) # 获取资源路径,兼容打包后的路径
            if os.path.exists(full_path):
                try: return pygame.mixer.Sound(full_path)
                except: continue
        return None # 如果没有找到任何格式的音效文件,返回 None

随后安装 Pyinstaller,打开终端(Command Prompt 或 PowerShell),运行以下命令即可。

pip install pyinstaller

随后在项目根目录下(即main.py所在的文件夹),输入以下命令。需要注意的是 Windows 下资源路径的分隔符是分号;,命令运行完成后可以看到会生成dist文件夹,其中的main.exe就是单文件游戏。

pyinstaller --onefile --noconsole --add-data "images;images" --add-data "sounds;sounds" main.py
  • --onefile(或-F): 将所有内容打包成一个单一的.exe文件;
  • --noconsole(或-w): 运行游戏时不显示黑色的控制台窗口;
  • --add-data "源文件夹;目标文件夹": 这是核心!它告诉 PyInstaller 把imagessounds文件夹里的内容也塞进.exe里;
  • main.py: 我们的的主程序文件名。

附完整代码

import pygame
import random
import sys
import os


SCREEN_WIDTH, SCREEN_HEIGHT = 400, 600  # 定义游戏窗口的宽度和高度
FPS = 60                                # 每秒刷新的帧数,控制游戏运行流畅度
GRAVITY = 0.7                           # 模拟物理重力,每帧给玩家增加的向下速度
WHITE, BLACK, GRAY = (255, 255, 255), (0, 0, 0), (100, 100, 100) # 颜色常量(RGB)
TITLE_COLOR = (255, 120, 0)             # 主界面标题的颜色

class Utils:
    # --- 资源加载助手函数 ---
    @staticmethod
    def resource_path(relative_path):
        try:
            # PyInstaller创建临时文件夹,将路径存储于_MEIPASS
            base_path = sys._MEIPASS
        except Exception:
            base_path = os.path.abspath(".")
        return os.path.join(base_path, relative_path)
    
    @staticmethod
    def load_img(name, scale=None):
        # path = os.path.join("./images/", name) # 拼接图片路径
        path = Utils.resource_path(os.path.join("images", name)) # 获取资源路径,兼容打包后的路径
        try:
            img = pygame.image.load(path).convert_alpha() # 加载图片并转换Alpha通道(透明度优化)
            if scale: img = pygame.transform.scale(img, scale) # 如果指定了尺寸,则进行缩放
            return img
        except:
            # 如果图片丢失,生成一个占位用的灰色方块,确保程序不崩溃
            surf = pygame.Surface(scale if scale else (30, 30))
            surf.fill((200, 200, 200))
            return surf
    
    @staticmethod
    def load_sound(name):
        base_path = "./sounds/"
        for ext in ['.wav', '.mp3', '.ogg']: # 遍历常见的音频格式
            # full_path = os.path.join(base_path, name + ext)
            full_path = Utils.resource_path(os.path.join(base_path, name + ext)) # 获取资源路径,兼容打包后的路径
            if os.path.exists(full_path):
                try: return pygame.mixer.Sound(full_path)
                except: continue
        return None # 如果没有找到任何格式的音效文件,返回 None

    @staticmethod
    def hit_probability(prob):
        return random.random() < prob # 返回一个布尔值,表示是否以给定概率命中

class Player:
    def __init__(self):
        self.image = Utils.load_img("player.png", (40, 40)) # 加载玩家图片并缩放到40x40像素
        self.image = pygame.transform.flip(self.image, flip_x=True, flip_y=False) # 将玩家镜像翻转,方便背上火箭道具
        # 第一个平台离 y 轴 50,平台高度 20,所以减去 70
        self.rect = self.image.get_rect(center=(SCREEN_WIDTH // 2, SCREEN_HEIGHT - 70)) # 获取玩家图片的矩形区域,用于碰撞检测和位置管理
        self.speed_y = 0 # 玩家在 y 轴上的速度,初始为0
        self.speed_x = 8 # 玩家在 x 轴上的速度,固定为8
        self.jump_sound = Utils.load_sound("jump") # 加载跳跃音效
        self.active_item = None # 记录当前正在影响玩家的道具实例,方便在 update 中处理道具效果

    def jump(self):
        self.speed_y = -15 # 碰撞后给予玩家一个向上的速度,模拟跳跃效果
        if self.jump_sound:
            self.jump_sound.play() # 播放跳跃音效

    def update(self):
        if self.active_item:
            self.active_item.update() # 如果有正在影响玩家的道具,更新道具状态,处理道具效果
            self.active_item.animate() # 如果有正在影响玩家的道具,执行道具动画
            self.active_item.follow_player(self) # 如果有正在影响玩家的道具,执行跟随玩家的逻辑,保持道具与玩家位置同步
            self.speed_y = self.active_item.fly_velocity - GRAVITY # 竹蜻蜓效果期间,玩家获得一个持续的向上的速度,模拟竹蜻蜓效果
            if self.active_item.fly_duration_timer <= 0: # 竹蜻蜓效果结束
                self.active_item = None # 重置当前正在影响玩家的道具实例,结束竹蜻蜓效果

        self.speed_y += GRAVITY # 每帧增加重力加速度
        self.rect.y += self.speed_y # 根据速度更新玩家的 y 坐标
        
        # 处理键盘左右键输入
        keys = pygame.key.get_pressed()
        if keys[pygame.K_LEFT]:
            self.rect.x -= self.speed_x # 向左移动
        if keys[pygame.K_RIGHT]:
            self.rect.x += self.speed_x # 向右移动

        if self.rect.right < 0: # 如果玩家完全移出左边界
            self.rect.left = SCREEN_WIDTH # 从右边重新出现
        elif self.rect.left > SCREEN_WIDTH: # 如果玩家完全移出右边界
            self.rect.right = 0 # 从左边重新出现

    def draw(self, screen):
        screen.blit(self.image, self.rect) # 将玩家图片绘制在屏幕底部中央位置
        if self.active_item:
            self.active_item.draw(screen) # 如果有正在影响玩家的道具,绘制道具

class Platform:
    def __init__(self, x, y):
        self.image = Utils.load_img("platform.png", (70, 20)) # 加载平台图片并缩放到70x20像素
        self.rect = self.image.get_rect(topleft=(x, y)) # 获取平台图片的矩形区域,用于碰撞检测和位置管理
        self.item = None # 平台上可能有一个道具,初始为 None

    def update(self):
        if self.item:
            self.item.update() # 如果平台上有道具,更新道具状态
            self.item.animate() # 如果平台上有道具,执行道具动画

    def draw(self, screen):
        screen.blit(self.image, self.rect) # 将平台图片绘制在屏幕上
        if self.item:
            self.item.draw(screen) # 如果平台上有道具,绘制道具

class Item:
    probability = 0.3 # 物品生成的概率,默认30%
    animate_speed = 0.2 # 物品动画的速度,经验值,表示每帧切换动画的概率
    
    def __init__(self, platform):
        self.platform = platform # 物品所在的平台
        self.frames = [] # 存储动画帧的列表
        self.current_frame_index = 0 # 当前动画帧的索引
        self.animate_timer = 0 # 动画计时器,用于控制动画切换速度
        self.rect = None # 物品的矩形区域,用于碰撞检测和位置管理
        self.has_used = False # 物品是否已经被玩家使用过,避免重复使用

    # 某些道具需要跟随玩家移动,比如竹蜻蜓,这个方法可以用来实现跟随玩家的逻辑
    def follow_player(self, player):
        pass

    def apply_effect(self, player):
        if self.has_used: return # 如果已经被使用过,直接返回,避免重复使用
        self.platform.item = None # 使用后将平台上的道具引用清除,避免重复使用
        self.has_used = True # 标记为已使用
        player.active_item = self # 记录当前正在影响玩家的道具实例,方便在 update 中处理道具效果
        if self.sound:
            self.sound.play() # 播放螺旋桨音效

    def animate(self):
        if self.has_used:
            self.animate_timer += self.animate_speed # 增加动画计时器
            if self.animate_timer >= 1: # 如果计时器达到切换动画的条件
                self.animate_timer = 0 # 重置动画计时器
                self.current_frame_index = (self.current_frame_index + 1) % len(self.frames) # 切换到下一帧动画
    
    def update(self):
        self.rect.midbottom = self.platform.rect.midtop # 物品始终跟随平台移动,保持在平台顶部

    def draw(self, screen):
        if self.frames:
            screen.blit(self.frames[self.current_frame_index], self.rect) # 绘制当前动画帧

class Spring(Item):
    probability = 0.2 # 弹簧生成的概率,20%

    def __init__(self, platform):
        super().__init__(platform)
        self.frames = [Utils.load_img(f"spring_{i}.png", (30, 30)) for i in range(2)] # 加载弹簧的两帧动画
        self.rect = self.frames[0].get_rect() # 获取弹簧图片的矩形区域,用于碰撞检测和位置管理
        self.sound = Utils.load_sound("spring") # 加载弹簧音效
        self.animate_played = False # 标记动画是否已经播放过,避免重复播放

    def apply_effect(self, player):
        if self.has_used: return # 如果已经被使用过,直接返回,避免重复使用
        self.has_used = True # 标记为已使用
        player.speed_y = -20 # 弹簧给予玩家一个更强的向上的速度,模拟更高的跳跃效果
        if self.sound:
            self.sound.play() # 播放弹簧音效
    
    def animate(self):
        if self.has_used and not self.animate_played:
            self.animate_timer += self.animate_speed # 增加动画计时器
            if self.animate_timer >= 1: # 如果计时器达到切换动画的条件
                self.animate_timer = 0 # 重置动画计时器
                self.current_frame_index = (self.current_frame_index + 1) % len(self.frames) # 切换到下一帧动画
                if self.current_frame_index == len(self.frames) - 1: # 如果动画已经播放到最后一帧,标记动画已经播放过
                    self.animate_played = True
        elif not self.has_used:
            self.current_frame_index = 0 # 如果没有被使用过,保持在第一帧动画

class Propeller(Item):
    probability = 0.1 # 螺旋桨生成的概率,10%
    animate_speed = 0.1 # 螺旋桨动画的速度,经验值,表示每帧切换动画的概率
    def __init__(self, platform):
        super().__init__(platform)
        self.frames = [Utils.load_img(f"propeller_{i}.png", (40, 20)) for i in range(2)] # 加载螺旋桨的两帧动画
        self.rect = self.frames[0].get_rect() # 获取螺旋桨图片的矩形区域,用于碰撞检测和位置管理
        self.sound = Utils.load_sound("propeller") # 加载螺旋桨音效
        self.fly_duration_timer = 150 # 竹蜻蜓效果持续的帧数(约2.5秒)
        self.fly_velocity = -12 # 竹蜻蜓给予玩家的持续向上的速度,经验值,表示比普通跳跃更高的跳跃效果

    def follow_player(self, player):
        self.rect.midbottom = player.rect.midtop # 竹蜻蜓效果期间,物品跟随玩家移动,保持在玩家头顶
        self.rect.centerx += 5 # 细节微调位置,让它更居中一些
        self.rect.centery -= 5

    def update(self):
        if not self.has_used:
            super().update() # 调用父类的 update 方法,保持物品跟随平台移动
        else:
            if self.has_used and self.fly_duration_timer > 0:
                self.fly_duration_timer -= 1 # 竹蜻蜓效果持续期间,减少计时器
    
class Rocket(Item):
    probability = 0.05 # 火箭生成的概率,5%
    animate_speed = 0.5 # 火箭动画的速度,经验值,表示每帧切换动画的概率
    def __init__(self, platform):
        super().__init__(platform)
        self._init_frames()
        self.rect = self.frames[0].get_rect() # 获取火箭图片的矩形区域,用于碰撞检测和位置管理
        self.sound = Utils.load_sound("rocket") # 加载火箭音效
        self.fly_duration_timer = 200 # 火箭效果持续的帧数(约3.3秒)
        self.fly_velocity = -18 # 火箭给予玩家的持续向上的速度,经验值,表示比竹蜻蜓更高的跳跃效果

    def _init_frames(self):
        img_sheet = Utils.load_img("rocket.png", (160, 240)) # 加载火箭图片
        frame_width = 40
        frame_height = 80
        # 计算当前帧的位置:(x, y, width, height)
        for i in range(3):
            for j in range(4):
                frame = img_sheet.subsurface((j * frame_width, i * frame_height, frame_width, frame_height)) # 从图片中提取每一帧
                self.frames.append(frame) # 将每一帧添加到动画帧列表中
        self.frames.remove(self.frames[len(self.frames) - 1]) # 移除最后一帧,因为它是空白的
        self.frames.remove(self.frames[len(self.frames) - 1]) # 再次移除最后一帧,因为它是空白的

    def follow_player(self, player):
        self.rect.y = player.rect.y - 25
        # 因为玩家的宽度是20
        self.rect.x = player.rect.x + 28 # 细节微调位置

    def update(self):
        if not self.has_used:
            super().update() # 调用父类的 update 方法,保持物品跟随平台移动
            self.rect.y += 22 # 调整图片位置
        else:
            if self.has_used and self.fly_duration_timer > 0:
                self.fly_duration_timer -= 1 # 火箭效果持续期间,减少计时器

class Cloud():
    def __init__(self):
        size = random.randint(50, 100) # 云朵的随机大小
        self.frame = Utils.load_img("cloud.png", (size, size // 2)) # 加载云朵图片并缩放到随机大小
        self.rect = self.frame.get_rect(
            x=random.randint(0, SCREEN_WIDTH - size), # 云朵的随机水平位置
            y=random.randint(0, SCREEN_HEIGHT // 2) # 云朵的随机垂直位置,限制在屏幕上半部分
        )
        self.direction = random.choice([-1, 1]) # 云朵的移动方向,-1表示向左,1表示向右
        self.speed = random.uniform(0.5, 1.5) # 云朵的移动速度,随机生成一个经验值
        self.alpha = random.randint(100, 255) # 云朵的随机透明度,增加视觉层次感

    def update(self):
        self.rect.x += self.direction * self.speed # 云朵以固定速度向左右移动
        if self.rect.right < 0: # 如果云朵完全移出左边界
            self.rect.left = SCREEN_WIDTH # 从右边重新出现
        elif self.rect.left > SCREEN_WIDTH: # 如果云朵完全移出右边界
            self.rect.right = 0 # 从左边重新出现
        
        if self.rect.top > SCREEN_HEIGHT:
            self.rect.y = random.randint(0, SCREEN_HEIGHT // 2) # 如果云朵漂浮到下半部分,随机重置到上半部分

    def draw(self, screen):
        # 设置透明度
        temp_surface = self.frame.copy()
        temp_surface.set_alpha(self.alpha)
        screen.blit(temp_surface, self.rect) # 绘制云朵图片

class GameSession:
    def __init__(self):
        pygame.init()
        self.screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
        pygame.display.set_caption("Doodle Jump By Guanngxu")
        self.clock = pygame.time.Clock()
        self.bg = Utils.load_img("background.png", (SCREEN_WIDTH, SCREEN_HEIGHT)) # 加载背景图片
        self.player = Player() # 创建玩家实例
        self.platforms = [] # 初始化平台列表
        self.clouds = [Cloud() for _ in range(5)] # 初始化云朵列表,创建5朵云
        self.score = 0 # 初始化分数
        self.score_font = pygame.font.SysFont("Arial", 24, bold=True) # 初始化字体对象,用于绘制分数
        self.title_font = pygame.font.SysFont("Comic Sans MS", 55, bold=True)
        self.author_font = pygame.font.SysFont("Arial", 22, italic=True)
        self.start_font = pygame.font.SysFont("Arial", 26, bold=True)
        self.state = "menu" # 游戏状态,初始为菜单界面
        self._init_platforms() # 初始化平台列表

    def _init_platforms(self):
        # 在屏幕底部中央创建一个初始平台,确保玩家有地方跳
        # platform 的 width 为 70,所以 x 坐标需要减去 35 来居中
        platform = Platform(SCREEN_WIDTH // 2 - 35, SCREEN_HEIGHT - 50)
        self.platforms.append(platform)
        # 初始化一些平台,确保玩家有地方跳
        # 初始化的平台没有道具
        for i in range(8):
            platform = Platform(random.randint(0, SCREEN_WIDTH - 70), SCREEN_HEIGHT - (i * 80) - 150)
            self.platforms.append(platform)

    def update_scroll(self):
        scroll_threshold = SCREEN_HEIGHT // 2 # 定义一个滚动阈值,当玩家超过这个高度时,平台开始向下滚动
        scroll_amount = 0 # 初始化滚动量
        if self.player.rect.top < scroll_threshold:
            scroll_amount = scroll_threshold - self.player.rect.top # 计算需要滚动的距离
            self.player.rect.top = scroll_threshold # 将玩家位置固定在滚动阈值上
            for platform in self.platforms:
                platform.rect.y += scroll_amount # 平台向下滚动

        # 平台向下滚动后,移除那些已经完全移出屏幕底部的平台,并在顶部生成新的平台
        self.platforms = [p for p in self.platforms if p.rect.top < SCREEN_HEIGHT]
        self.score += scroll_amount // 10 # 根据滚动距离增加分数,10 是一个经验值,表示每滚动10像素得1分
        while len(self.platforms) < 8: # 保持屏幕上至少有8个平台
            new_platform = Platform(random.randint(0, SCREEN_WIDTH - 70), random.randint(-100, -40))
            self.generate_item(new_platform)
            self.platforms.append(new_platform)

        for cloud in self.clouds:
            cloud.rect.y += scroll_amount // 2 # 云朵以较慢的速度向下滚动

    def generate_item(self, platform):
        if Utils.hit_probability(Spring.probability): # 根据弹簧的生成概率决定是否在平台上生成弹簧
            spring = Spring(platform)
            platform.item = spring # 将弹簧作为平台的一个属性,方便后续碰撞检测和更新
        elif Utils.hit_probability(Propeller.probability): # 根据螺旋桨的生成概率决定是否在平台上生成螺旋桨
            propeller = Propeller(platform)
            platform.item = propeller # 将螺旋桨作为平台的一个属性,方便后续碰撞检测和更新
        elif Utils.hit_probability(Rocket.probability): # 根据火箭的生成概率决定是否在新平台上生成火箭
            rocket = Rocket(platform)
            platform.item = rocket # 将火箭作为平台的一个属性,方便后续碰撞检测和更新

    # 检测玩家与道具的碰撞,并处理道具效果
    def item_colliderect(self, item):
        # 检测玩家是否与道具发生碰撞,并且道具没有被使用过
        if self.player.rect.colliderect(item.rect) and not item.has_used:
            item.apply_effect(self.player) # 调用 apply_effect 方法

    # 检测玩家与平台的碰撞,并处理跳跃逻辑
    def platform_colliderect(self, platform):
        # 检测玩家是否与平台发生碰撞,并且玩家正在向下移动时检测,向上移动时不检测
        if self.player.rect.colliderect(platform.rect) and self.player.speed_y > 0:
            # 15 是一个经验值,表示玩家底部与平台顶部的碰撞距离,如果小于这个值才算真正的站在平台上,避免侧面碰撞误判
            if self.player.rect.bottom - platform.rect.top < 15: # 碰撞时只检测玩家底部与平台顶部的碰撞,避免侧面碰撞误判
                self.player.rect.bottom = platform.rect.top # 碰撞后将玩家的底部位置调整到平台的顶部,避免玩家穿过平台
                self.player.speed_y = 0 # 碰撞后将玩家的垂直速度重置为0,模拟站在平台上的效果
                keys = pygame.key.get_pressed()
                if keys[pygame.K_SPACE]: # 只有按空格才跳跃
                    self.player.jump() # 调用玩家的 jump 方法,执行跳跃逻辑
                return True # 碰撞后返回 True,表示玩家成功站在平台上

    def reset_game(self):
        # 重新初始化游戏内的对象数据
        self.player = Player() 
        self.platforms = [] 
        self.clouds = [Cloud() for _ in range(5)] 
        self.score = 0
        self.state = "menu" # 回到菜单界面
        self._init_platforms() 

    def update(self): # 更新游戏数据
        self.update_scroll() # 更新滚动逻辑

        for cloud in self.clouds:
            cloud.update() # 更新云朵状态

        for platform in self.platforms:
            platform.update() # 更新平台状态
        self.player.update() # 更新玩家状态

        if self.player.speed_y >= 0: # 只有当玩家正在向下移动时才检测碰撞,向上移动时不检测
            # 检测玩家与平台的碰撞,并处理跳跃逻辑,同时检测玩家与道具的碰撞,并处理道具效果
            for platform in self.platforms:
                if platform.item: # 如果平台上有道具,检测玩家与道具的碰撞,并处理道具效果
                    self.item_colliderect(platform.item)
                if self.platform_colliderect(platform): # 检测玩家与平台的碰撞,并处理跳跃逻辑
                    break # 如果已经检测到玩家与一个平台发生碰撞并处理了跳跃逻辑,就不再继续检测其他平台,避免多重碰撞导致的跳跃问题
        
        if self.player.rect.top > SCREEN_HEIGHT: # 如果玩家掉出屏幕底部,游戏结束,重置游戏状态
            self.reset_game() # 重新初始化游戏状态,回到菜单界面

    def draw(self): # 绘制游戏画面
        self.screen.blit(self.bg, (0, 0)) # 绘制背景图片
        for cloud in self.clouds:
            cloud.draw(self.screen) # 绘制云朵
        for platform in self.platforms:
            platform.draw(self.screen) # 绘制平台
        self.player.draw(self.screen) # 绘制玩家
        scrore_text = self.score_font.render(f"Score: {self.score}", True, BLACK)
        self.screen.blit(scrore_text, (10, 10)) # 在屏幕左

    def draw_menu(self):
        self.screen.blit(self.bg, (0, 0)) # 绘制背景图片
        # 渲染标题
        title_surf = self.title_font.render("Doodle Jump", True, TITLE_COLOR)
        self.screen.blit(title_surf, (SCREEN_WIDTH//2 - title_surf.get_width()//2, 120))
        # 渲染作者信息
        author_surf = self.author_font.render("Author: Guanngxu", True, BLACK)
        self.screen.blit(author_surf, (SCREEN_WIDTH//2 - author_surf.get_width()//2, 210))
        # 渲染提示文字
        msg = self.start_font.render("Press [ SPACE ] to Start", True, (50, 50, 50))
        self.screen.blit(msg, (SCREEN_WIDTH//2 - msg.get_width()//2, 380))
        pygame.display.flip()

    def run(self):
        while True:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    pygame.quit()
                    sys.exit()
            
            if self.state == "menu":
                self.draw_menu()
                keys = pygame.key.get_pressed()
                if keys[pygame.K_SPACE]: # 在菜单界面按空格开始游戏
                    self.state = "playing"
            elif self.state == "playing":
                self.update()
                self.draw()

            self.clock.tick(FPS)  # 控制游戏循环以每秒FPS帧的速度运行
            pygame.display.flip()  # 更新屏幕显示


if __name__ == "__main__":
    GameSession().run()