打开游戏后,背景是一张带有淡黄色的方格纸,所有的元素(主角、平台、道具等)看起来都像是用彩笔随手涂鸦上去的,线条有些许的不规整,但是却显得灵动、亲切。
游戏主角是一个亮绿色、有四条小短腿、长着一个漏斗状长鼻子的外星生物。当按下空格时它会向上蹦跳,整个过程中都可以通过左右方向键控制游戏主角左右移动,蹦跳时借助绿色平台不断攀升到新的高度,以此来获得更高的分数。
在游戏主角向上攀升的过程中,偶尔会运气好遇到一些加速神器,帮助玩家更快的获得分数,这些加速神器包括弹簧、竹蜻蜓、火箭喷气背包。
当游戏主角踩到弹簧时会发出清脆的「叮」的声音,并让游戏主角获得比自主跳跃要更高的跳跃高度。如果游戏主角踩到竹蜻蜓,竹蜻蜓会戴在游戏主角头顶,并像直升机一样拖着游戏主角飞行一段距离,期间会发出直升机螺旋桨转动的声音。若游戏主角有幸遇到了火箭喷气背包,它可以获得比竹蜻蜓更快的飞行速度,并且会带着游戏主角飞行更久的时间,自然也会获得更多的分数。
整个游戏没有终点,只有不断增加的分数。在游戏主角跳跃过程中始终都会有重力存在,因此若游戏主角下落过程中没有遇到平台便会一直下坠,直到坠落超过底部则 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属性和update、draw方法,分别用以存储背景图片和更新游戏数据以及绘制游戏界面,直观效果即游戏界面可加载背景图片显示的更好看了。
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() # 更新屏幕显示
游戏背景加载成功之后,继续完善Player和Platform类,先将所属的图片资源加载进来,与GameSession类同理,我们也需要增加update和draw方法用于更新位置、动作数据和绘制工作。
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_player和animate方法。另外我们本次更新考虑将代码进行极小部分重构,以及修复可能存在的 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 把images和sounds文件夹里的内容也塞进.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()
Read More ~
一文搞懂 TCP 协议核心机制
参考内容:
Transmission Control Protocol (TCP)
TCP - 12 simple ideas to explain the Transmission Control Protocol
TCP (Transmission Control Protocol) – What is it, and how does it work?
The Internet's Layered Network Architecture
《计算机网络-自顶向下方法(第7版)》
TCP (Transmission Control Protocol - 传输控制协议) 在过去 40 多年的时间里,一直是互联网通信的核心。在大学学习计算机网络时,大部分同学都只是简单机械的背诵 TCP 是面向连接的,是可靠的传输协议。本文借助 Wireshark 抓包工具分析 TCP 报文头部,来详细的介绍它的工作原理。
TCP 报文格式如下图所示,我们将重点关注 Sequence Number、Acknowledgment Number、Window 几个字段,以及 TCP Flags 中的 ACK、PSH、RST、SYN、FIN。后续的内容除了建立连接过程外,其它均与服务端客户端无关,即任何一方均可发起相关操作。
建立连接
在 TCP 建立连接时进行抓包,可以发现客户端首先向服务端发送了 SYN 包,服务端向客户端响应了 SYN, ACK 包,最后客户端向服务端发送了 SYN 包,这就是大名鼎鼎的三次握手。
TCP 建立连接的三次握手过程,有四个事件发生:
客户端→服务端:SYNchronize(同步)我的初始序列号 X;
服务端→客户端:我收到了你的 SYN,我 ACKnowledge(确认)我已经准备好接收 [X+1];
服务端→客户端:SYNchronize(同步)我的初始序列号 Y;
客户端→服务端:我收到了你的 SYN,我 ACKnowledge(确认)我已经准备好接收 [Y+1];
为什么是三次握手而不是两次握手?
我们用打电话的场景来类比两次握手和三次握手的区别,可以发现在场景一中,B 以为通话已经建立,但其实可能 A 没有听到 B 的回复,而在场景二中双方都确认对方能够听到自己的声音,所以三次握手要更加可靠。
场景一:两次握手
A:喂,听得到吗?(SYN)
B:听得到,你听得到吗?(ACK)
场景二:三次握手
A:喂,听得到吗?(SYN)
B:听得到,你听得到吗?(SYN + ACK)
A:我也听得到!(ACK)
发送数据
TCP 连接建立后就可以发送数据了,数据发送过程主要关注 Sequence Number、Acknowledgment Number、Window 几个字段。
Sequence Number 用于跟踪已经发送的数据,Acknowledgment Number 则用于跟踪已经接收的数据。比如客户端本次发送的数据包为 seq=1001 且数据长度为 200 字节,当服务端接收到这 200 个字节后,将回复 ack=1201 表示已经成功接收数据,下一次客户端发送数据的序列号将为 1201。
如果接收方为接收到的每段报文都发送确认信息,那就意味着在线路上传输的报文数量翻了一倍,为了减轻数据传输的负担,TCP 加入了延迟确认机制。延迟确认机制规定每收到两段报文,或者距离最后一段报文的时间不超过 500ms,至少要回应一次 ACK。
超时重传
TCP 是如何处理丢包情况的呢?实际上每次数据发送时,客户端都会保留一份已经发送的数据副本到缓存,并启动重传超时。如果客户端收到服务端发送的数据包确认,则将缓存中的数据删除即可,因为可以确认服务端已经正确接收了数据包。
如果客户端一直没有收到已发送数据包的确认,重传超时时间一定会在某个时刻到期,此时客户端即会意识到服务端可能没有接收到数据,所以客户端会重新发送丢失的数据包,确保数据可以被服务端接收到。
超时重传机制的巧妙之处在于不管在哪个方向丢包,它都能正常工作。比如客户端正常发送了数据包,但是服务端的 ACK 包丢失了,当达到重传超时时间后,客户端会再次发送对应数据包,而对服务端来说即可猜出是 ACK 数据包丢了,服务端不会重复存储该数据包,只需要再次回应对应的 ack 即可。
流量控制
如果发送方一味的发送数据,而接收方可能正忙于其它事务,可能会读取数据相对缓慢,如此就会很容易地使接收缓存溢出。TCP 为应用程序提供了流量控制服务以消除发送方使接收方缓存溢出的可能性。流量控制因此是一个速度匹配服务,即发送方的发送速率与接收方应用程序的读取速率相匹配。
从上图可以看出 TCP 通过 Window 字段来进行流量控制,通过该字段告知发送方在必须等待接收确认之前可以发送多少数据。即通过控制窗口大小来达到流量控制的目的。
数据包解析
将 Wireshark 抓取的 TCP 数据包原始字节数据复制出来如下所示,会发现里面不止有 TCP 报文的头部,还有以太网和 IPv4 的头部。
0000 74 5a 01 b5 29 e4 90 65 84 0b 69 8b 08 00 45 00
0010 00 32 1b bf 40 00 80 06 00 00 c0 a8 00 0a 08 9c
0020 53 29 6a b8 1a 0a 06 6e 20 43 47 9e 92 37 50 18
0030 00 ff 1c 9c 00 00 31 32 33 34 35 36 37 38 39 30
数据链路层:以太网头部(14 Bytes)
74 5a 01 b5 29 e4 90 65 84 0b 69 8b 08 00
目标 MAC:74:5a:01:b5:29:e4
源 MAC:90:65:84:0b:69:8b
类型:08 00 = IPv4
网络层:IPv4 头部 (20 Bytes)
08 00 45 00 00 32 1b bf 40 00 80 06 00 00 c0 a8 00 0a 08 9c 53 29
版本 + IHL:45 = IPv4,头长 20 字节
差分服务字段 (DSCP/ECN),通常为 0:00
总长度:00 32 = 50 字节(IP 头 20 + TCP 头 20 + 数据 10 字节)
标识符:1b bf,用于数据包分片重组
标志 + 分片偏移:40 00 = DF (表示不分片)
生存时间 TTL:80 = 128,表示数据包最多能经过 128 跳
协议:06 = TCP,即上层协议是 TCP
头部校验和:00 00,这里显示为 0,可能是抓包时未计算,或者故意置零
源 IP:c0 a8 00 0a = 192.168.0.10
目标 IP:08 9c 53 29 = 8.156.83.41
传输层:TCP 头部 (20 Bytes)
6a b8 1a 0a 06 6e 20 43 47 9e 92 37 50 18 00 ff 1c 9c 00 00
源端口:6a b8 = 27320
目标端口:1a 0a = 6666
序列号:06 6e 20 43 = 107,661,379, wireshark 显示的是相对序列号,所以不一样
确认号:47 9e 92 37 = 1,201,353,271,与序列号同理
数据偏移 (4位) + 保留 (3位) + 标志 (9位) (2字节): 50 18 -> 分解:
数据偏移: 5 -> TCP 头部长度 5 个“32位字” (5 * 4 = 20 字节)。
标志位: 0x018(二进制 0000 0001 1000):
ACK = 1 (确认有效)
PSH = 1 (推送数据,提示接收端应立即交给上层应用)
RST, SYN, FIN = 0
窗口大小:00 ff = 65535
校验和:1c 9c
紧急指针:00 00 = 0,未使用
应用层:TCP 载荷数据(10 Bytes)
31 32 33 34 35 36 37 38 39 30
都是 ASCII 码,数据内容为:"1234567890"
从数据包的封装情况即可看出来对网络协议分层的好处,比如工作在链路层的交换机只需要解析出 MAC 地址即可进行数据转发,而不需要再解析网络层中的 IP 地址等信息。同理对工作在网络层的路由器来说,解析出 IP 地址即可转发数据,不需要对数据进行深度解析。
断开连接
TCP 有两种关闭连接的方式,一种是通过 FIN 来优雅的关闭连接,另一种是通过 RST 来暴力的关闭连接。我们首先来看优雅的关闭方式。
如果一切都进展的很顺利,客户端和服务端建立连接并成功的交换了彼此的数据,它们就可以关闭连接并继续进行各自的工作。与建立连接的过程类似,关闭连接的过程也会发生四个事件,即大家所说的四次挥手。
客户端→服务端:我已经FINished(完成)了数据发送,我最后的序列号是 X;
服务端→客户端:我 ACKnowledge(确认)接收到了你的 FIN 且 ack=[X+1];
服务端→客户端:我已经FINished(完成)了数据发送,我最后的序列号是 Y;
客户端→服务端:我 ACKnowledge(确认)接收到了你的 FIN 且 ack=[Y+1];
和三次握手一样,中间两个事件可能在同一个数据包中发生,比如下面使用 Wireshark 工具抓到的包。
实际情况不会每次都将两个事件合并,这是因为四个事件并不是严格要求必须按照上述顺序进行的,很有可能客户端已经发送完数据并断开连接,但是服务端仍然有数据需要向客户端发送,所以可以只关闭客户端向服务端发送数据的通道,而不关闭服务端向客户端发送数据的通道。请记住 TCP 是全双工通信,一个 FIN-ACK 组合事件只会关闭一个方向的连接。
非优雅的暴力关闭连接方式采用 RST Flag 实现,任何一方都可以发送该连接关闭数据包,使用该方式关闭连接意味着 TCP 连接出现了问题,发送方向接收方发送了 RST 数据包后,即将该 TCP 连接相关信息清空,接收方接收到该数据包后也即将该 TCP 连接相关信息清空。
Read More ~
Python 使用 pygame 实现人机对战五子棋
五子棋,是一种两人对弈的纯策略型棋类游戏,通常双方分别使用黑白两色的棋子,轮流下在棋盘直线与横线的交叉点上,先在横线、直线或斜对角线上形成 5 子连线者获胜。因为棋子在落子后不能移动或拿掉,所以也可以用纸和笔来进行游戏。
下面我们使用 pygame 来实现一个简约的人机对战版五子棋。考虑到整个程序包括 UI、五子棋规则、AI 智能三个部分,我们就简单使用三个 class 来实现各自具体的功能,大致就是下面这个样子。
class GomokuGame:
"""游戏主类,负责游戏流程控制和界面显示"""
pass
class Judge:
"""裁判类,负责管理棋盘状态和判断胜负"""
pass
class AI:
"""人工智能类,负责AI的下棋逻辑"""
pass
我们首先来完善 GomokuGame 类的代码,棋局的显示与更新、黑白棋落子都需要它来搞定。先准备五子棋盘,五子棋的标准棋盘大小是 15x15,即有 15 条横线和 15 条竖线,共有 225 个交叉点可供落子,所以使用 pygame.draw.line 画 15 条竖线、15 条横线即可。
这里特别提醒不要忘了棋盘上面还有五个星位点需要突出显示。考虑到 UI 的显示美观问题,我们横线和竖线与窗口边框还需要预留一定的边距,具体实现如下:
import pygame
import sys
WINDOW_SIZE = 736 # 窗口大小(像素)
BOARD_SIZE = 16 # 棋盘大小(16x16,包含边框)
GAP = WINDOW_SIZE // BOARD_SIZE # 网格间距(每个格子的大小)
# 棋盘上的五个星位点(传统五子棋的标准位置)
# 坐标从0开始,对应棋盘上的交叉点
POINTS = [(2, 2), (2, 12), (7, 7), (12, 2), (12, 12)]
# 颜色定义(使用RGB格式)
COLORS = {
"background": (240, 217, 181), # 背景色(米黄色)
"line": (0, 0, 0), # 棋盘线颜色(黑色)
"black_stone": (0, 0, 0), # 黑棋颜色
"white_stone": (255, 255, 255), # 白棋颜色
}
class GomokuGame:
"""游戏主类,负责游戏流程控制和界面显示"""
def __init__(self):
"""初始化游戏"""
pygame.init() # 初始化Pygame所有模块
# 创建游戏窗口,大小为WINDOW_SIZE × WINDOW_SIZE
self.window = pygame.display.set_mode((WINDOW_SIZE, WINDOW_SIZE))
pygame.display.set_caption("五子棋人机对战") # 设置窗口标题
self.draw_board() # 绘制初始棋盘
def main_loop(self):
"""游戏主循环,不断处理事件和更新画面"""
while True: # 无限循环,直到游戏退出
# 获取所有发生的事件(鼠标点击、窗口关闭等)
for event in pygame.event.get():
# 如果事件是关闭窗口(点击右上角的X)
if event.type == pygame.QUIT:
pygame.quit() # 关闭Pygame
sys.exit() # 退出程序
# 更新整个游戏窗口的显示
pygame.display.update()
def draw_board(self):
"""绘制棋盘背景、网格线和星位点"""
# 填充背景颜色
self.window.fill(COLORS["background"])
# 绘制棋盘网格线
for i in range(BOARD_SIZE):
# 绘制水平线:从左到右
pygame.draw.line(self.window, COLORS["line"], # 表面, 颜色
(GAP, GAP * (i + 1)), # 起点坐标
(WINDOW_SIZE - GAP, GAP * (i + 1)), # 终点坐标
1) # 线宽(像素)
# 绘制垂直线:从上到下
pygame.draw.line(self.window, COLORS["line"], # 表面, 颜色
(GAP * (i + 1), GAP), # 起点坐标
(GAP * (i + 1), WINDOW_SIZE - GAP), # 终点坐标
1) # 线宽
# 绘制五个星位点(棋盘上的小黑点)
for point in POINTS:
# 计算星位点的像素坐标
# point[0]和point[1]是网格坐标,需要转换为像素坐标
# 注意:point坐标是0-based,但棋盘有边框,所以要+1
pixel_x = GAP * (point[0] + 1)
pixel_y = GAP * (point[1] + 1)
# 绘制小黑点:表面, 颜色, 圆心坐标, 半径
pygame.draw.circle(self.window, COLORS["line"],
(pixel_x, pixel_y), 5)
棋盘准备好后我们需要实现落棋子的逻辑,落单个棋子本身的逻辑很容易实现,使用 pygame.draw.circle 即可完成。需要注意的是窗口使用的坐标是像素,而我们落棋子的坐标是 15x15 的网格坐标,因此需要实现将鼠标点击的像素坐标转换为网格坐标后,才可进行落子。还需要特别注意的是前文给窗口留了内边距,因此网格的实际有效坐标是从 1 开始的,像素坐标转换为的网格坐标应在 [1, 15] 范围内。
STONE_SIZE = 15 # 棋子半径(像素)
class GomokuGame:
"""游戏主类,负责游戏流程控制和界面显示"""
def __init__(self):
"""初始化游戏"""
# 省略部分代码 ......
# 当前回合的棋子颜色,1=黑棋(玩家先手),2=白棋(AI)
self.cur_color = 1
def main_loop(self):
"""游戏主循环,不断处理事件和更新画面"""
while True: # 无限循环,直到游戏退出
# 获取所有发生的事件(鼠标点击、窗口关闭等)
for event in pygame.event.get():
# 省略部分代码 ......
# 如果事件是鼠标按钮按下(玩家点击落子)
if event.type == pygame.MOUSEBUTTONDOWN:
# 获取鼠标点击的像素坐标
x, y = event.pos
# 将像素坐标转换为棋盘网格坐标
grid_x, grid_y = self.compute_grid_position(x, y)
# 处理玩家落子,如果成功返回True
ret = self.make_move(grid_x, grid_y)
# 更新整个游戏窗口的显示
pygame.display.update()
def place_stone(self, grid_x, grid_y, color):
"""
在棋盘上绘制一个棋子
参数:
grid_x: 网格行坐标
grid_y: 网格列坐标
color: 棋子颜色,1=黑棋,2=白棋
"""
# 根据颜色选择棋子颜色
stone_color = COLORS["black_stone"] if color == 1 else COLORS["white_stone"]
# 绘制圆形棋子
pygame.draw.circle(self.window, stone_color, (grid_x * GAP, grid_y * GAP), STONE_SIZE)
def compute_grid_position(self, x, y):
"""
将鼠标点击的像素坐标转换为棋盘网格坐标
参数:
x: 像素X坐标
y: 像素Y坐标
返回:
(grid_x, grid_y): 网格坐标
"""
# 计算最近的网格坐标:像素坐标 ÷ 网格间距,四舍五入
grid_x = round(x / GAP)
grid_y = round(y / GAP)
# 确保坐标在有效范围内(1到BOARD_SIZE-1)
grid_x = max(1, min(BOARD_SIZE - 1, grid_x))
grid_y = max(1, min(BOARD_SIZE - 1, grid_y))
return grid_x, grid_y
def make_move(self, grid_x, grid_y):
"""
处理棋子落子,包括玩家和AI
参数:
grid_x: 网格行坐标
grid_y: 网格列坐标
返回:
True: 落子成功
False: 落子失败(位置无效或已有棋子)
"""
# 检查坐标是否在有效范围内(1到BOARD_SIZE-1)
if 0 < grid_x < BOARD_SIZE and 0 < grid_y < BOARD_SIZE:
# 检查这个位置是否为空
if self.judge.board[grid_x][grid_y] == 0:
# 在棋盘上绘制棋子
self.place_stone(grid_x, grid_y, self.cur_color)
# 切换当前回合:黑棋变白棋,白棋变黑棋
self.cur_color = 2 if self.cur_color == 1 else 1
return True # 落子成功
return False # 落子失败
接下来来完善 Judge 类的代码。Judge 类在此处主要充当一个裁判的角色,回想一下各种比赛中裁判主要干什么?裁判的作用就是记录比赛当前得分与判断输赢的,我们把五子棋当前的棋局称之为当前得分,即裁判需要记录每时每刻棋盘的样子。
直接使用一个二维数组来表示整个棋盘即可。0 表示未落子;1 表示已落黑子;2 表示已落白子。那么检查输赢的逻辑就可以抽象为判断二维数组中是否在四个方向(横向、纵向、斜向、反斜)中任意方向存在连续相同的 5 个元素;判断平局的逻辑即可抽象为检查二维数组中是否还存在 0。
class Judge:
"""裁判类,负责管理棋盘状态和判断胜负"""
def __init__(self):
"""初始化棋盘"""
# 创建棋盘二维数组,所有位置初始化为0(空)
# 棋盘大小:BOARD_SIZE × BOARD_SIZE(第1行和第一列没有使用)
# 0 = 空,1 = 黑棋(玩家),2 = 白棋(AI)
self.board = [[0 for _ in range(BOARD_SIZE)] for _ in range(BOARD_SIZE)]
def update_board(self, x, y, color):
"""
更新棋盘并检查游戏是否结束
参数:
x: 落子的行坐标
y: 落子的列坐标
color: 棋子颜色,1或2
返回:
True: 更新成功
False: 更新失败(传入位置有棋子)
"""
# 检查要落子的位置是否为空
if self.board[x][y] == 0:
# 在棋盘上放置棋子
self.board[x][y] = color
# 检查是否获胜(五子连珠)
if self.check_win(x, y):
winner = "玩家 (黑色)" if color == 1 else "AI (白色)"
print(f"{winner} 赢了")
# 检查是否平局(棋盘满了)
if self.is_full():
print("平局 !!!")
# 返回更新成功
return True
# 位置已有棋子,更新失败
return False
def check_win(self, x, y):
"""
检查是否五子连珠(获胜条件)
参数:
x: 最后落子的行坐标
y: 最后落子的列坐标
返回:
True: 有五子连珠,获胜
False: 没有五子连珠
"""
# 四个检查方向:(行增量, 列增量)
directions = [(1, 0), # 水平方向(右/左)
(0, 1), # 垂直方向(下/上)
(1, 1), # 右下/左上对角线
(1, -1)] # 左下/右上对角线
# 获取最后落子的颜色
cur_color = self.board[x][y]
# 检查每个方向
for dx, dy in directions:
stone_count = 1 # 从当前棋子开始计数,初始为1
# 向两个方向检查:正向和反向
for sign in (1, -1):
# 从当前位置向指定方向移动一步
cur_x, cur_y = x + dx * sign, y + dy * sign
# 沿着这个方向连续检查相同颜色的棋子
while (0 < cur_x < BOARD_SIZE and # 检查行坐标是否在边界内
0 < cur_y < BOARD_SIZE and # 检查列坐标是否在边界内
self.board[cur_x][cur_y] == cur_color): # 检查颜色是否相同
stone_count += 1 # 发现相同颜色棋子,计数加1
# 继续向同一方向移动,检查下一个位置
cur_x += dx * sign
cur_y += dy * sign
# 如果连续相同颜色的棋子数达到5个,获胜!
if stone_count >= 5:
return True
# 所有方向都检查完毕,没有找到五子连珠
return False
def is_full(self):
"""
检查棋盘是否已满(平局条件)
返回:
True: 棋盘已满,平局
False: 棋盘还有空位
"""
# 遍历棋盘的每一行
for row in self.board:
# 检查这一行是否还有空位(0表示空位)
if 0 in row:
return False # 发现空位,棋盘未满
# 所有位置都非空,棋盘已满
return True
对于 AI 类,需要考虑 AI 使用何种方式进行「思考」?网上有基于搜索的极小化极大算法(MiniMax算法)和 Alpha-beta 剪枝,也有使用 Q-learning 等强化学习算法优化决策的方法。此处我们尽量实现的简单一些,采用预存储所有的赢法来加快评估速度,暂不考虑引入深度搜索来预见多步之后的变化。
那么所有赢法如何进行存储呢?我们把所有能连成五子的情况都列举出来,考虑到棋盘上某一个交叉点可能同时属于好几种五子连珠(赢法)的问题,需要采用三维数组进行所有赢法的枚举。
四个方向的五子连珠的所有情况全部都存储下来,使用数组 win_patterns[x][y][k] 来表示,其中下标 x, y 用来表示网格坐标点,下标 k 用来表示属于第 k 种赢法。
class AI:
"""人工智能类,负责AI的下棋逻辑"""
def __init__(self):
"""初始化AI"""
# 三维数组:记录每个棋盘位置属于哪些获胜模式
# win_patterns[x][y][k] = True 表示位置(x,y)属于第k个获胜模式
self.win_patterns = [[[False for _ in range(TOTAL_WIN_PATTERNS)]
for _ in range(BOARD_SIZE)]
for _ in range(BOARD_SIZE)]
# 当前已记录的获胜模式数量
self.win_pattern_count = 0
# 初始化所有可能的获胜模式
self.init_win_patterns()
def add_win_pattern(self, start_i, start_j, di, dj):
"""
添加一个获胜模式
参数:
start_i: 起始行
start_j: 起始列
di: 行方向增量
dj: 列方向增量(与di配合定义方向)
"""
# 一个获胜模式包含连续的5个位置
for k in range(5):
# 标记这个获胜模式包含的所有位置
self.win_patterns[start_i + k * di][start_j + k * dj][self.win_pattern_count] = True
# 获胜模式计数器加1
self.win_pattern_count += 1
def init_win_patterns(self):
"""初始化所有可能的获胜模式(所有可能的五子连珠位置)"""
# 横向和纵向的所有赢法
for i in range(1, BOARD_SIZE):
for j in range(1, BOARD_SIZE - 4):
# 水平方向的获胜模式
self.add_win_pattern(i, j, 0, 1)
# 垂直方向的获胜模式
self.add_win_pattern(j, i, 1, 0)
# 对角线方向的所有赢法
for i in range(1, BOARD_SIZE - 4):
for j in range(1, BOARD_SIZE - 4):
# 右下对角线获胜模式
self.add_win_pattern(i, j, 1, 1)
# 左下对角线获胜模式
self.add_win_pattern(i, BOARD_SIZE - j, 1, -1)
所有的赢法列举并存储下来了怎么使用呢?到现在还没有明确使用方式。我们再回顾一下玩五子棋的场景,人类落下一颗棋子后,此时 AI 需要「计算」需要把棋子落到哪个位置可以获取到最大收益。如何计算最大收益?自然是需要一个评估函数计算出每个位置的收益值再进行比较,方可找到最大收益值的位置。
现在的问题就变成了如何设计评估函数?因为前文我们已经存储了所有赢法的信息,此处我们直接采用记录玩家在第 k 种赢法上已经落下了多少个棋子来进行评估,自然第 k 种赢法落下的棋子越多得分就越高。值得提醒的是,只要对手在第 k 种赢法上面随便落一子,那么本方在第 k 种赢法上就不再可能获胜。
当任何一方玩家落子时,我们即遍历所有赢法,并将该落子位置所对应的赢法在当次玩家获胜模式占有棋子数进行更新,通过对获胜模式不同棋子数赋予不同得分权重,即可抽象出数学模型(公式)实现计算最大收益位置的效果。
class AI:
"""人工智能类,负责AI的下棋逻辑"""
def __init__(self):
"""初始化AI"""
# 省略部分代码......
# 记录每个获胜模式中AI已占有的棋子数
self.ai_win_count = [0 for _ in range(TOTAL_WIN_PATTERNS)]
# 记录每个获胜模式中玩家已占有的棋子数
self.human_win_count = [0 for _ in range(TOTAL_WIN_PATTERNS)]
def update_win_counts(self, x, y, color):
"""
更新获胜模式计数(当棋子落下时调用)
参数:
x: 行坐标
y: 列坐标
color: 棋子颜色(1:玩家/黑棋,2:AI/白棋)
"""
# 遍历所有获胜模式
for k in range(TOTAL_WIN_PATTERNS):
# 检查这个位置是否属于第k个获胜模式
if self.win_patterns[x][y][k]:
if color == 2: # AI下棋(白棋)
# AI在这个获胜模式中增加一子
self.ai_win_count[k] += 1
# 玩家在这个模式中不可能获胜了(设置为异常值6,超过5)
self.human_win_count[k] = 6
else: # 玩家下棋(黑棋)
# 玩家在这个获胜模式中增加一子
self.human_win_count[k] += 1
# AI在这个模式中不可能获胜了
self.ai_win_count[k] = 6
至此就可以设计实现评估函数,以期达到自主对弈的效果了。我们为不同长度的的连珠设置不同的分值,并使相同连珠的情况下 AI 得分略高于人类玩家,使 AI 更具备进攻性。当人类落子后,即遍历棋盘所有空位,并计算空位对应的得分,将最高得分的位置返回进行落子即可。
class AI:
"""人工智能类,负责AI的下棋逻辑"""
def __init__(self):
# 省略部分代码......
# 玩家的得分权重:不同长度的连珠对应不同的分数
# 键:连珠长度,值:对应的分数
self.human_score_weights = {
1: 200, # 单独一子
2: 400, # 两子连珠
3: 2000, # 三子连珠
4: 10000, # 四子连珠(差一子获胜)
}
# AI的得分权重(略高于玩家,使AI更具攻击性)
self.ai_score_weights = {
1: 220, # 比玩家略高
2: 420,
3: 2100,
4: 20000, # 四子连珠得分远高于玩家
}
def evaluate_position(self, human_score, ai_score):
"""
评估位置的得分(综合进攻和防守)
参数:
human_score: 玩家在这个位置的得分
ai_score: AI在这个位置的得分
返回:
综合得分(AI进攻和防守玩家的加权和)
"""
OFFENSIVE_WEIGHT = 1.2 # 进攻权重:鼓励AI积极进攻
DEFENSIVE_WEIGHT = 1.0 # 防守权重:阻止玩家连成五子
# 综合得分 = AI进攻得分 * 进攻权重 + 防守玩家得分 * 防守权重
return ai_score * OFFENSIVE_WEIGHT + human_score * DEFENSIVE_WEIGHT
def ai_run(self, board):
"""
AI主逻辑:选择最佳落子位置
参数:
board: 当前棋盘状态
返回:
(best_i, best_j): 最佳落子位置的行列坐标
"""
# 初始化最佳位置和最佳得分
best_pos = (0, 0) # 最佳位置(默认左上角)
best_score = -1 # 最佳得分(初始为-1)
# 遍历棋盘上的所有位置
for i in range(1, BOARD_SIZE):
for j in range(1, BOARD_SIZE):
# 如果这个位置是空的
if board[i][j] == 0:
# 重置当前位置i,j得分
cur_human_score, cur_ai_score = 0, 0
# 遍历所有获胜模式,计算这个位置的得分
for k in range(TOTAL_WIN_PATTERNS):
# 如果这个位置属于第k个获胜模式
if self.win_patterns[i][j][k]:
# 累加玩家在这个模式下的得分
cur_human_score += self.human_score_weights.get(
self.human_win_count[k], 0)
# 累加AI在这个模式下的得分
cur_ai_score += self.ai_score_weights.get(
self.ai_win_count[k], 0)
# 计算综合得分
cur_score = self.evaluate_position(cur_human_score, cur_ai_score)
# 如果当前得分更好,更新最佳位置
if cur_score >= best_score:
best_score = cur_score
best_pos = (i, j)
# 返回最佳落子位置
return best_pos
考虑到赢棋时需要给用户以比较友好的提示,现在采用 print 的方式太过粗糙了,因此再实现一个弹窗类用于显示提示信息。由于 pygame 没有内置的弹窗工具,我们借助 python 自带的 tkinter 实现。
因为 tkinter 中的 messagebox 会阻塞主线程的运行,导致获胜时棋子不能及时显示出来,因此再引入线程来避免程序被阻塞的问题,当然引入了线程自然就会涉及到共享变量、互斥锁等问题,具体实现如下:
import time
import threading
from tkinter import Tk, messagebox
# 线程锁:用于多线程同步,防止多个线程同时访问共享资源
lock = threading.Lock()
# 弹窗状态:标记是否已经弹出获胜提示窗口
show_popup_window = False
# 获胜者:记录游戏获胜方(玩家或AI)
winner = None
class PopupWindow(Tk):
"""弹窗类,用于显示游戏结果提示"""
def __init__(self):
"""初始化弹窗窗口"""
# 调用父类Tk的初始化方法
super().__init__()
# 隐藏主窗口,我们只需要消息框
self.wm_withdraw()
def show_message(self, msg):
"""
显示消息框
参数:
msg: 要显示的消息内容
"""
# 使用tkinter的messagebox显示提示信息
# "提示!!!"是窗口标题,msg是具体内容
messagebox.showinfo("提示!!!", msg)
def show_winner_popup():
"""
显示获胜弹窗的线程函数
这个函数在一个单独的线程中运行,定期检查是否需要显示获胜提示。
使用多线程可以避免弹窗阻塞游戏主循环。
"""
# 声明使用全局变量
global show_popup_window, winner
# 无限循环,持续检查是否需要显示弹窗
while True:
# 使用线程锁,确保安全访问全局变量
with lock:
# 检查是否需要显示弹窗
if show_popup_window:
# 创建弹窗对象
popup = PopupWindow()
# 根据获胜者显示不同的消息
if winner: # winner不为None,表示有获胜者
popup.show_message(f"{winner} 获胜!")
else: # winner为None,表示平局
popup.show_message("平局!")
# 重置弹窗标志,避免重复显示
show_popup_window = False
# 退出循环(弹窗已经显示)
break
# 暂停0.5秒再检查,避免过度占用CPU
time.sleep(0.5)
最终实现人机对战五子棋完整代码如下,使用 Python 版本为:3.6.8。
"""
五子棋人机对战游戏
这是一个使用Python的Pygame库实现的五子棋游戏,包含简单的人工智能对手。
主要功能包括:图形化界面、玩家与AI对战、胜负判断、弹窗提示等。
"""
import pygame
import sys
import time
import threading
from tkinter import Tk, messagebox
# ==================== 游戏常量定义 ====================
# 这些常量定义了游戏的基本参数,修改它们可以调整游戏的外观和行为
# 窗口大小(像素)
WINDOW_SIZE = 736
# 棋盘大小(16x16,包含边框)
BOARD_SIZE = 16
# 棋子半径(像素)
STONE_SIZE = 15
# 总的获胜模式数量(所有可能的五子连珠方向)
# 公式解释:棋盘有4个方向(横、竖、两个斜向)
# 每个方向在棋盘上可以放置的位置数量
TOTAL_WIN_PATTERNS = 4 * (BOARD_SIZE - 4) * (BOARD_SIZE - 2)
# 网格间距(每个格子的大小)
GAP = WINDOW_SIZE // BOARD_SIZE
# 棋盘上的五个星位点(传统五子棋的标准位置)
# 坐标从0开始,对应棋盘上的交叉点
POINTS = [(2, 2), (2, 12), (7, 7), (12, 2), (12, 12)]
# 颜色定义(使用RGB格式)
COLORS = {
"background": (240, 217, 181), # 背景色(米黄色)
"line": (0, 0, 0), # 棋盘线颜色(黑色)
"black_stone": (0, 0, 0), # 黑棋颜色
"white_stone": (255, 255, 255), # 白棋颜色
}
# ==================== 全局变量 ====================
# 注意:实际项目中应尽量避免使用全局变量,这里为了简化而使用
# 线程锁:用于多线程同步,防止多个线程同时访问共享资源
lock = threading.Lock()
# 弹窗状态:标记是否已经弹出获胜提示窗口
show_popup_window = False
# 获胜者:记录游戏获胜方(玩家或AI)
winner = None
class GomokuGame:
"""游戏主类,负责游戏流程控制和界面显示"""
def __init__(self):
"""初始化游戏"""
pygame.init() # 初始化Pygame所有模块
# 创建游戏窗口,大小为WINDOW_SIZE × WINDOW_SIZE
self.window = pygame.display.set_mode((WINDOW_SIZE, WINDOW_SIZE))
pygame.display.set_caption("五子棋人机对战") # 设置窗口标题
self.ai = AI() # 创建AI对象
self.judge = Judge() # 创建裁判对象
# 当前回合的棋子颜色,1=黑棋(玩家先手),2=白棋(AI)
self.cur_color = 1
self.draw_board() # 绘制初始棋盘
def main_loop(self):
"""游戏主循环,不断处理事件和更新画面"""
while True: # 无限循环,直到游戏退出
# 获取所有发生的事件(鼠标点击、窗口关闭等)
for event in pygame.event.get():
# 如果事件是关闭窗口(点击右上角的X)
if event.type == pygame.QUIT:
pygame.quit() # 关闭Pygame
sys.exit() # 退出程序
# 如果事件是鼠标按钮按下(玩家点击落子)
elif event.type == pygame.MOUSEBUTTONDOWN:
# 获取鼠标点击的像素坐标
x, y = event.pos
# 将像素坐标转换为棋盘网格坐标
grid_x, grid_y = self.compute_grid_position(x, y)
# 处理玩家落子,如果成功返回True
ret = self.make_move(grid_x, grid_y)
# 如果玩家落子成功,AI进行落子
if ret:
# AI计算最佳落子位置
ai_x, ai_y = self.ai.ai_run(self.judge.board)
# 处理AI落子
self.make_move(ai_x, ai_y)
# 更新整个游戏窗口的显示
pygame.display.update()
def make_move(self, grid_x, grid_y):
"""
处理棋子落子,包括玩家和AI
参数:
grid_x: 网格行坐标
grid_y: 网格列坐标
返回:
True: 落子成功
False: 落子失败(位置无效或已有棋子)
"""
# 检查坐标是否在有效范围内(1 到 BOARD_SIZE-1)
if 0 < grid_x < BOARD_SIZE and 0 < grid_y < BOARD_SIZE:
# 检查这个位置是否为空
if self.judge.board[grid_x][grid_y] == 0:
# 在棋盘上绘制棋子
self.place_stone(grid_x, grid_y, self.cur_color)
# 更新棋盘状态,并检查是否获胜
self.judge.update_board(grid_x, grid_y, self.cur_color)
# 更新AI的获胜模式计数
self.ai.update_win_counts(grid_x, grid_y, self.cur_color)
# 切换当前回合:黑棋变白棋,白棋变黑棋
self.cur_color = 2 if self.cur_color == 1 else 1
return True # 落子成功
return False # 落子失败
def place_stone(self, grid_x, grid_y, color):
"""
在棋盘上绘制一个棋子
参数:
grid_x: 网格行坐标
grid_y: 网格列坐标
color: 棋子颜色,1=黑棋,2=白棋
"""
# 根据颜色选择棋子颜色
stone_color = COLORS["black_stone"] if color == 1 else COLORS["white_stone"]
# 绘制圆形棋子
pygame.draw.circle(self.window, stone_color, (grid_x * GAP, grid_y * GAP), STONE_SIZE)
def compute_grid_position(self, x, y):
"""
将鼠标点击的像素坐标转换为棋盘网格坐标
参数:
x: 像素X坐标
y: 像素Y坐标
返回:
(grid_x, grid_y): 网格坐标
"""
# 计算最近的网格坐标:像素坐标 ÷ 网格间距,四舍五入
grid_x = round(x / GAP)
grid_y = round(y / GAP)
# 确保坐标在有效范围内(1到BOARD_SIZE-1)
grid_x = max(1, min(BOARD_SIZE - 1, grid_x))
grid_y = max(1, min(BOARD_SIZE - 1, grid_y))
return grid_x, grid_y
def draw_board(self):
"""绘制棋盘背景、网格线和星位点"""
# 填充背景颜色
self.window.fill(COLORS["background"])
# 绘制棋盘网格线
for i in range(BOARD_SIZE):
# 绘制水平线:从左到右
pygame.draw.line(self.window, COLORS["line"], # 表面, 颜色
(GAP, GAP * (i + 1)), # 起点坐标
(WINDOW_SIZE - GAP, GAP * (i + 1)), # 终点坐标
1) # 线宽(像素)
# 绘制垂直线:从上到下
pygame.draw.line(self.window, COLORS["line"], # 表面, 颜色
(GAP * (i + 1), GAP), # 起点坐标
(GAP * (i + 1), WINDOW_SIZE - GAP), # 终点坐标
1) # 线宽
# 绘制五个星位点(棋盘上的小黑点)
for point in POINTS:
# 计算星位点的像素坐标
# point[0]和point[1]是网格坐标,需要转换为像素坐标
# 注意:point坐标是0-based,但棋盘有边框,所以要+1
pixel_x = GAP * (point[0] + 1)
pixel_y = GAP * (point[1] + 1)
# 绘制小黑点:表面, 颜色, 圆心坐标, 半径
pygame.draw.circle(self.window, COLORS["line"],
(pixel_x, pixel_y), 5)
class Judge:
"""裁判类,负责管理棋盘状态和判断胜负"""
def __init__(self):
"""初始化棋盘"""
# 创建棋盘二维数组,所有位置初始化为0(空)
# 棋盘大小:BOARD_SIZE × BOARD_SIZE(第1行和第一列没有使用)
# 0 = 空,1 = 黑棋(玩家),2 = 白棋(AI)
self.board = [[0 for _ in range(BOARD_SIZE)] for _ in range(BOARD_SIZE)]
def update_board(self, x, y, color):
"""
更新棋盘并检查游戏是否结束
参数:
x: 落子的行坐标
y: 落子的列坐标
color: 棋子颜色,1或2
返回:
True: 更新成功
False: 更新失败(传入位置有棋子)
"""
# 检查要落子的位置是否为空
if self.board[x][y] == 0:
# 在棋盘上放置棋子
self.board[x][y] = color
# 检查是否获胜(五子连珠)
if self.check_win(x, y):
# 使用全局变量记录获胜信息
global show_popup_window, winner
with lock: # 加锁,确保线程安全
show_popup_window = True # 设置弹窗标志
# 确定获胜者:1=玩家,2=AI
winner = "玩家 (黑色)" if color == 1 else "AI (白色)"
# 检查是否平局(棋盘满了)
if self.is_full():
show_popup_window = True
# 返回更新成功
return True
# 位置已有棋子,更新失败
return False
def check_win(self, x, y):
"""
检查是否五子连珠(获胜条件)
参数:
x: 最后落子的行坐标
y: 最后落子的列坐标
返回:
True: 有五子连珠,获胜
False: 没有五子连珠
"""
# 四个检查方向:(行增量, 列增量)
directions = [(1, 0), # 水平方向(右/左)
(0, 1), # 垂直方向(下/上)
(1, 1), # 右下/左上对角线
(1, -1)] # 左下/右上对角线
# 获取最后落子的颜色
cur_color = self.board[x][y]
# 检查每个方向
for dx, dy in directions:
stone_count = 1 # 从当前棋子开始计数,初始为1
# 向两个方向检查:正向和反向
for sign in (1, -1):
# 从当前位置向指定方向移动一步
cur_x, cur_y = x + dx * sign, y + dy * sign
# 沿着这个方向连续检查相同颜色的棋子
while (0 < cur_x < BOARD_SIZE and # 检查行坐标是否在边界内
0 < cur_y < BOARD_SIZE and # 检查列坐标是否在边界内
self.board[cur_x][cur_y] == cur_color): # 检查颜色是否相同
stone_count += 1 # 发现相同颜色棋子,计数加1
# 继续向同一方向移动,检查下一个位置
cur_x += dx * sign
cur_y += dy * sign
# 如果连续相同颜色的棋子数达到5个,获胜!
if stone_count >= 5:
return True
# 所有方向都检查完毕,没有找到五子连珠
return False
def is_full(self):
"""
检查棋盘是否已满(平局条件)
返回:
True: 棋盘已满,平局
False: 棋盘还有空位
"""
# 遍历棋盘的每一行
for row in self.board:
# 检查这一行是否还有空位(0表示空位)
if 0 in row:
return False # 发现空位,棋盘未满
# 所有位置都非空,棋盘已满
return True
class AI:
"""人工智能类,负责AI的下棋逻辑"""
def __init__(self):
"""初始化AI"""
# 三维数组:记录每个棋盘位置属于哪些获胜模式
# win_patterns[x][y][k] = True 表示位置(x,y)属于第k个获胜模式
self.win_patterns = [[[False for _ in range(TOTAL_WIN_PATTERNS)]
for _ in range(BOARD_SIZE)]
for _ in range(BOARD_SIZE)]
# 记录每个获胜模式中AI已占有的棋子数
self.ai_win_count = [0 for _ in range(TOTAL_WIN_PATTERNS)]
# 记录每个获胜模式中玩家已占有的棋子数
self.human_win_count = [0 for _ in range(TOTAL_WIN_PATTERNS)]
# 当前已记录的获胜模式数量
self.win_pattern_count = 0
# 玩家的得分权重:不同长度的连珠对应不同的分数
# 键:连珠长度,值:对应的分数
self.human_score_weights = {
1: 200, # 单独一子
2: 400, # 两子连珠
3: 2000, # 三子连珠
4: 10000, # 四子连珠(差一子获胜)
}
# AI的得分权重(略高于玩家,使AI更具攻击性)
self.ai_score_weights = {
1: 220, # 比玩家略高
2: 420,
3: 2100,
4: 20000, # 四子连珠得分远高于玩家
}
# 初始化所有可能的获胜模式
self.init_win_patterns()
def add_win_pattern(self, start_i, start_j, di, dj):
"""
添加一个获胜模式
参数:
start_i: 起始行
start_j: 起始列
di: 行方向增量
dj: 列方向增量(与di配合定义方向)
"""
# 一个获胜模式包含连续的5个位置
for k in range(5):
# 标记这个获胜模式包含的所有位置
self.win_patterns[start_i + k * di][start_j + k * dj][self.win_pattern_count] = True
# 获胜模式计数器加1
self.win_pattern_count += 1
def init_win_patterns(self):
"""初始化所有可能的获胜模式(所有可能的五子连珠位置)"""
# 横向和纵向的所有赢法
for i in range(1, BOARD_SIZE):
for j in range(1, BOARD_SIZE - 4):
# 水平方向的获胜模式
self.add_win_pattern(i, j, 0, 1)
# 垂直方向的获胜模式
self.add_win_pattern(j, i, 1, 0)
# 对角线方向的所有赢法
for i in range(1, BOARD_SIZE - 4):
for j in range(1, BOARD_SIZE - 4):
# 右下对角线获胜模式
self.add_win_pattern(i, j, 1, 1)
# 左下对角线获胜模式
self.add_win_pattern(i, BOARD_SIZE - j, 1, -1)
def update_win_counts(self, x, y, color):
"""
更新获胜模式计数(当棋子落下时调用)
参数:
x: 行坐标
y: 列坐标
color: 棋子颜色(1:玩家/黑棋,2:AI/白棋)
"""
# 遍历所有获胜模式
for k in range(TOTAL_WIN_PATTERNS):
# 检查这个位置是否属于第k个获胜模式
if self.win_patterns[x][y][k]:
if color == 2: # AI下棋(白棋)
# AI在这个获胜模式中增加一子
self.ai_win_count[k] += 1
# 玩家在这个模式中不可能获胜了(设置为异常值6,超过5)
self.human_win_count[k] = 6
else: # 玩家下棋(黑棋)
# 玩家在这个获胜模式中增加一子
self.human_win_count[k] += 1
# AI在这个模式中不可能获胜了
self.ai_win_count[k] = 6
def evaluate_position(self, human_score, ai_score):
"""
评估位置的得分(综合进攻和防守)
参数:
human_score: 玩家在这个位置的得分
ai_score: AI在这个位置的得分
返回:
综合得分(AI进攻和防守玩家的加权和)
"""
OFFENSIVE_WEIGHT = 1.2 # 进攻权重:鼓励AI积极进攻
DEFENSIVE_WEIGHT = 1.0 # 防守权重:阻止玩家连成五子
# 综合得分 = AI进攻得分 * 进攻权重 + 防守玩家得分 * 防守权重
return ai_score * OFFENSIVE_WEIGHT + human_score * DEFENSIVE_WEIGHT
def ai_run(self, board):
"""
AI主逻辑:选择最佳落子位置
参数:
board: 当前棋盘状态
返回:
(best_i, best_j): 最佳落子位置的行列坐标
"""
# 初始化最佳位置和最佳得分
best_pos = (0, 0) # 最佳位置(默认左上角)
best_score = -1 # 最佳得分(初始为-1)
# 遍历棋盘上的所有位置
for i in range(1, BOARD_SIZE):
for j in range(1, BOARD_SIZE):
# 如果这个位置是空的
if board[i][j] == 0:
# 重置当前得分
cur_human_score, cur_ai_score = 0, 0
# 遍历所有获胜模式,计算这个位置的得分
for k in range(TOTAL_WIN_PATTERNS):
# 如果这个位置属于第k个获胜模式
if self.win_patterns[i][j][k]:
# 累加玩家在这个模式下的得分
cur_human_score += self.human_score_weights.get(
self.human_win_count[k], 0)
# 累加AI在这个模式下的得分
cur_ai_score += self.ai_score_weights.get(
self.ai_win_count[k], 0)
# 计算综合得分
cur_score = self.evaluate_position(cur_human_score, cur_ai_score)
# 如果当前得分更好,更新最佳位置
if cur_score >= best_score:
best_score = cur_score
best_pos = (i, j)
# 返回最佳落子位置
return best_pos
class PopupWindow(Tk):
"""弹窗类,用于显示游戏结果提示"""
def __init__(self):
"""初始化弹窗窗口"""
# 调用父类Tk的初始化方法
super().__init__()
# 隐藏主窗口,我们只需要消息框
self.wm_withdraw()
def show_message(self, msg):
"""
显示消息框
参数:
msg: 要显示的消息内容
"""
# 使用tkinter的messagebox显示提示信息
# "提示!!!"是窗口标题,msg是具体内容
messagebox.showinfo("提示!!!", msg)
def show_winner_popup():
"""
显示获胜弹窗的线程函数
这个函数在一个单独的线程中运行,定期检查是否需要显示获胜提示。
使用多线程可以避免弹窗阻塞游戏主循环。
"""
# 声明使用全局变量
global show_popup_window, winner
# 无限循环,持续检查是否需要显示弹窗
while True:
# 使用线程锁,确保安全访问全局变量
with lock:
# 检查是否需要显示弹窗
if show_popup_window:
# 创建弹窗对象
popup = PopupWindow()
# 根据获胜者显示不同的消息
if winner: # winner不为None,表示有获胜者
popup.show_message(f"{winner} 获胜!")
else: # winner为None,表示平局
popup.show_message("平局!")
# 重置弹窗标志,避免重复显示
show_popup_window = False
# 退出循环(弹窗已经显示)
break
# 暂停0.5秒再检查,避免过度占用CPU
time.sleep(0.5)
def main():
"""程序主入口函数"""
# 创建游戏对象
game = GomokuGame()
# 创建并启动弹窗线程
# target: 线程要执行的函数
# daemon=True: 设置为守护线程,主程序退出时自动结束
popup_thread = threading.Thread(target=show_winner_popup, daemon=True)
popup_thread.start() # 启动线程
# 开始游戏主循环
game.main_loop()
# Python程序的入口点
# 当直接运行这个文件时,__name__等于"__main__"
# 当被导入为模块时,__name__等于模块名
if __name__ == "__main__":
main() # 调用主函数开始游戏
Read More ~
哈夫曼树实现文件压缩与解压缩
参考内容:
哈夫曼树和哈夫曼编码, 看完秒懂!
哈夫曼实现文件压缩解压缩(c语言)
#include<bits/stdc++.h>
using namespace std;
#define BUFFER_SIZE 1024
#define CHAR_COUNT_LEN 256
// 哈夫曼树的结点结构
struct huffman_node {
size_t times; // 本字符出现的次数
unsigned char val; // 字符的值
huffman_node *left; // 左孩子
huffman_node *right; // 右孩子
};
size_t char_count_info[CHAR_COUNT_LEN] = {}; // 统计出现字符频率
vector<huffman_node *> huffman_tree_nodes; // 动态数组用于存储哈夫曼树的结点
huffman_node *huffman_tree_root = NULL; // 哈夫曼树的根结点
map<unsigned char, string> huffman_codes_table; // 用于存储哈夫曼编码
// 打印哈夫曼编码表
void print_huffman_codes_table(map<unsigned char, string> huffman_codes_table) {
printf("the huffman codes is:\n");
for(map<unsigned char, string>::iterator it = huffman_codes_table.begin();
it != huffman_codes_table.end(); it++) {
printf("character '%c'(%d) : %s\n",
(it->first >= 32 && it->first <= 126) ? it->first : '?',
it->first, it->second.c_str());
}
printf("\n");
}
// 生成哈夫曼编码表(递归函数)
void generate_huffman_codes_table(huffman_node* root, string code) {
if(root == NULL) return;
// 如果是叶子节点,保存编码
if(root->left == NULL && root->right == NULL) {
huffman_codes_table[root->val] = code;
} else {
// 递归处理左右子树
generate_huffman_codes_table(root->left, code + "0");
generate_huffman_codes_table(root->right, code + "1");
}
}
// 排序辅助函数
bool cmp(huffman_node *a,huffman_node *b){
return a->times > b->times;
}
// 创建哈弗曼树
int generate_huffman_tree() {
printf("begin generate huffman tree......\n");
while(huffman_tree_nodes.size() > 1) {
// 按照字符出现频率进行降序排序
sort(huffman_tree_nodes.begin(), huffman_tree_nodes.end(), cmp);
// 取出字符出现频率最小的两个
huffman_node *min1 = huffman_tree_nodes.back();
huffman_tree_nodes.pop_back();
huffman_node *min2 = huffman_tree_nodes.back();
huffman_tree_nodes.pop_back();
// 生成新的父亲结点,频率最小的结点作为左孩子
huffman_node *cur = (huffman_node *) malloc(sizeof(huffman_node));
cur->times = min1->times + min2->times;
cur->left = min1;
cur->right = min2;
huffman_tree_nodes.push_back(cur);
huffman_tree_root = cur;
}
printf("success generate huffman tree\n\n");
return 0;
}
// 逐一生成哈夫曼树结点,并存储于全局变量 tree_nodes 中
int generate_huffman_tree_nodes()
{
printf("begin generate huffman tree nodes......\n");
int nodes_count = 0; // 统计生成了多少个结点
for(int i = 0; i < CHAR_COUNT_LEN; i++) {
// 出现次数为 0 的字符不需要生成结点
if(char_count_info[i] > 0) {
nodes_count++;
huffman_node *cur = (huffman_node *) malloc(sizeof(huffman_node));
cur->val = i;
cur->times = char_count_info[i];
cur->left = NULL;
cur->right = NULL;
huffman_tree_nodes.push_back(cur);
}
}
printf("success generate %d huffman tree nodes\n\n", nodes_count);
return 0;
}
// 用于统计每个字符在文件出现的次数
// 统计结果存储在全局变量 char_count_info 中
int count_char(unsigned char buffer[], size_t len)
{
for(size_t i = 0; i < len; i++) {
char_count_info[buffer[i]]++;
}
return 0;
}
// 读取指定文件内容,并统计字符出现的次数
int count_file_char(char *file_name)
{
FILE *file; // 打开文件句柄
file = fopen(file_name, "rb");
if (file == NULL) {
printf("can't open file [%s], please check file name!\n", file_name);
return -1;
}
unsigned char buffer[BUFFER_SIZE] = {}; // 读取文件时的缓冲区
size_t bytes_read = 0; // 每次读了多少个字节
size_t read_times_count = 1; // 总共读取了多少次了
// 循环读取直到文件结束,1 表示每次读取的数据块大小,单位为字节
while ((bytes_read = fread(buffer, 1, BUFFER_SIZE, file)) > 0) {
printf("the %zu th read, read %zu byte! process......\n",
read_times_count, bytes_read);
count_char(buffer, bytes_read);
read_times_count++;
}
fclose(file);
printf("success read file: [%s] and count char\n\n", file_name);
return 0;
}
int compress_file(char *input_file_name, char *output_file_name) {
printf("begin cmpress [%s] -> [%s]\n", input_file_name, output_file_name);
FILE *input = fopen(input_file_name, "rb");
FILE *output = fopen(output_file_name, "wb");
if(input == NULL || output == NULL) {
printf("can't open file [%s] or [%s], please check file name!\n",
input_file_name, output_file_name);
return -1;
}
unsigned char buffer[BUFFER_SIZE]; // 文件读取缓冲区
size_t bytes_read; // 已经读取了多少字节
unsigned char current_byte = 0; // 当前字节
int bit_count = 0; // 已经处理了多少个二进制位
while((bytes_read = fread(buffer, 1, BUFFER_SIZE, input)) > 0) {
for(size_t i = 0; i < bytes_read; i++) {
string code = huffman_codes_table[buffer[i]];
int code_length = code.length();
for(int j = 0; j < code_length; j++) {
char bit = code[j];
current_byte <<= 1; // 左移 1 位
if(bit == '1') {
current_byte |= 1;
}
bit_count++;
if(bit_count == 8) { // 凑齐了 8 位(1 个字节)
fputc(current_byte, output);
current_byte = 0; // 清零,继续下一个字节
bit_count = 0;
}
}
}
}
// 处理最后一个不完整的字节
if(bit_count > 0) {
current_byte <<= (8 - bit_count); // 左移补齐 0
fputc(current_byte, output);
// 记录最后一个字节的有效位数
fputc(bit_count, output);
} else {
fputc(8, output); // 表示最后一个字节是完整的
}
fclose(input);
fclose(output);
printf("success compress file: [%s] to [%s]\n\n", input_file_name, output_file_name);
return 0;
}
// 获取指定文件大小
size_t get_file_size(char *file_name) {
FILE *fp = fopen(file_name, "rb");
if (fp == NULL) {
printf("can't open file [%s], please check file name!\n", file_name);
return -1;
}
fseek(fp, 0, SEEK_END);
size_t file_size = ftell(fp);
fclose(fp);
return file_size;
}
int uncompress_file(char *input_file_name, char *output_file_name) {
printf("begin uncmpress [%s] -> [%s]\n", input_file_name, output_file_name);
size_t input_file_size = get_file_size(input_file_name);
FILE *input = fopen(input_file_name, "rb");
FILE *output = fopen(output_file_name, "wb");
if(input == NULL || output == NULL) {
printf("can't open file [%s] or [%s], please check file name!\n",
input_file_name, output_file_name);
return -1;
}
// 3. 解压数据
huffman_node *current_node = huffman_tree_root; // 用于记录当前在哪个结点
unsigned char cur_byte; // 当前读取的字节
int byte_bits = 8; // 默认最后一个字节完整
long bytes_decoded = 0; // 已经解码的字节
while(bytes_decoded < input_file_size) {
cur_byte = fgetc(input);
bytes_decoded++;
// 需要对最后一个字节处理,因为最后一个字节可能不满
if(bytes_decoded == input_file_size-1) {
int bit_count = fgetc(input); // 最后一个字节有效位数
byte_bits = 8 - bit_count;
}
for(int i = 7; i >= 8 - byte_bits; i--) {
int bit = (cur_byte >> i) & 1;
if(bit) { // 1 往右走
current_node = current_node->right;
} else { // 0 往左走
current_node = current_node->left;
}
// 如果到达叶子节点
if(!current_node->left && !current_node->right) {
// 将当前结点的 val 写入文件
fputc(current_node->val, output);
current_node = huffman_tree_root; // 回到根节点
}
}
}
printf("success uncompress file: [%s] to [%s]\n\n", input_file_name, output_file_name);
return 0;
}
// 释放哈夫曼树
void destroy_huffman_tree(huffman_node* root) {
if (root == NULL) return;
destroy_huffman_tree(root->left);
destroy_huffman_tree(root->right);
free(root);
}
// 哈夫曼函数
int huffman(char *file_name)
{
// 压缩后的文件名
char compress_file_name[strlen(file_name) + 8];
// 解压缩后的文件名
char uncompress_file_name[strlen(file_name) + 11];
strcpy(compress_file_name, file_name);
strcat(compress_file_name, ".huffman");
strcpy(uncompress_file_name, "uncompress_");
strcat(uncompress_file_name, file_name);
count_file_char(file_name);
generate_huffman_tree_nodes();
generate_huffman_tree();
// 根据哈夫曼树生成哈夫曼编码表
huffman_node *root = huffman_tree_root;
generate_huffman_codes_table(root, "");
print_huffman_codes_table(huffman_codes_table);
compress_file(file_name, compress_file_name);
uncompress_file(compress_file_name, uncompress_file_name);
destroy_huffman_tree(huffman_tree_root);
}
int main(int argc, char** argv)
{
if(argc < 2) {
printf("usage: %s <filename>\n", argv[0]);
return -1;
} else {
printf("the file is [%s]\n", argv[1]);
return huffman(argv[1]);
}
return 0;
}
Read More ~
动态电压调节
参考内容:
Dynamically Program Voltage Regulators
How to Dynamically Adjust Power Module Output Voltage
一种通过DAC调节DCDC输出电压的电路方案
诸如音频放大器供电等应用中需要动态电压。虽然有许多线性稳压器或开关电源 IC 可以允许通过固定的分压电阻设置其输出电压,但却很少有实现动态电压调整的,并且设置的输出电压也必须大于内部参考电压。
那么如何实现能够将输出变化到小于参考电压值的电源,或者如何实现一个输出电压可动态调整的稳压器。
固定输出电压反馈网络
下面这张图展示了线性稳压器的反馈网络,其包含一个误差放大器,放大器将外部电压(通过分压电阻网络从输出电压采样得到)与内部固定的电压基准源进行比较。误差放大器的输出驱动导通器件的导通程度,导通器件将电流运送到输出端。通过设置外部电阻 Rf1 和 Rf2,可以将线性稳压器的输出电压设置为大于或等于内部基准的任何电压。
接下来的这张图展示的是降压型开关稳压器的典型电路。与线性放大器类似,同样具有误差放大器和内部基准电压。不同的是导通器件通过 PWM 或者脉冲频率调制方式实现快速打开或关闭,功率波形经过滤波器以产生恒定的直流电压输出。
可以发现不论是 LDO,还是降压型开关电源,它们都可以用下面的模型简化。即通过分压电阻网络对输出进行采样,将采样结果反馈至内部误差放大器,与内部基准源进行比较,从而调节输出电压使之稳定在设定值。
动态调节输出电压
很容易能想到只需要将反馈网络的上分压电阻或下分压电阻替换为滑动电阻,通过改变不同的阻值即可达到动态调节电压的目的。
当然在实际应用中不太可能使用滑动电阻那么笨拙的东西,我们可以通过向反馈网络中注入少量电流,以此来改变反馈网络的有效增益VFBVOUT\frac{V_{FB}}{V_{OUT}}VOUTVFB。当在反馈节点注入正电流时,上分压电阻表现为变小,因此输出电压会降低;当电流被拉出时,下分压电阻表现为变小,因此输出电压表现为增加。
上文提到的电流源我们可以通过外部对一个电阻施加电压来实现,用下面图中简化模型来进行理论计算。芯片正常工作时,FB 引脚电压始终为芯片内部基准电压,利用这一特点我们可以推导出 Vout 与外部施加电压 Vs 之间的关系。
I2=I1+I3I_{2} = I_{1} + I_{3}
I2=I1+I3
VrefR2=Vout−VrefR1+Vs−VrefR3\frac{V_{ref}}{R_{2}} = \frac{V_{out} - V_{ref}}{R_{1}} + \frac{V_{s} - V_{ref}}{R_{3}}
R2Vref=R1Vout−Vref+R3Vs−Vref
Vout=R1∙(VrefR2+Vref−VsR3)+VrefV_{out} = R_{1} \bullet (\frac{V_{ref}}{R_{2}} + \frac{V_{ref} - V_{s}}{R_{3}} ) + V_{ref}
Vout=R1∙(R2Vref+R3Vref−Vs)+Vref
Vout=−VsR1R3+Vref∙(1+R1R3+R1R2)V_{out} = -V_{s}\frac{R_{1}}{R_{3}} + V_{ref}\bullet (1 + \frac{R_{1}}{R_{3}} + \frac{R_{1}}{R_{2}})
Vout=−VsR3R1+Vref∙(1+R3R1+R2R1)
通过理论计算可以得出VoutV_{out}Vout与VsV_{s}Vs是负相关关系。R3 的值越大,曲线就越缓,意味着可调的精度越高。当 Vs 为 0 时,即 R3 与 R2 并联后的等效阻值为下分压电阻。
能否实现负压输出?
上文中曲线看出可以通过调节 Vs 的值,实现输出负压的效果,那是否真的能输出负压呢?
以前在BUCK 电路基础知识中提到过电感电流是个三角波,电感电流上升阶段的斜率为Vi−VoL\frac{V_{i}-V_{o}}{L}LVi−Vo,电感电流下降阶段斜率为−VoL-\frac{V_{o}}{L}−LVo。若通过上述方式输出负压,则电感电流将一直处于上升阶段,系统是不稳定的,因此无法输出负压。
BUCK 电路不能输出负压,那如果换成 LDO 芯片能否输出负压呢?答案也是不能输出负压,原因在于从 GND 至 VOUT 都放了 ESD 二极管,如果 VOUT 为负压,那么二极管就会被导通钳位。
Buck-Boost 实现正转负
虽然 Buck 不能实现正转负的功能,但是我们可以通过改变 Buck 芯片外围电路,使其工作在 Buck-Boost 模式下,以此来达到正转负的目的。而对外围电路的修改也很简单,只需要将原来的的 Vout 改为 GND,那么原来的 GND 即输出负压。
与上文同理,我们可以通过外部施加一个电流源来实现对负压进行动态调节的目的。用下面图中简化模型来进行理论计算。芯片正常工作时,FB 引脚电压始终为芯片内部基准电压,利用这一特点我们可以推导出 Vout 与外部施加电压 Vs 之间的关系。
I2=I1+I3I_{2} = I_{1} + I_{3}
I2=I1+I3
GND−VrefR1+Vs−VrefR3=Vref−VoutR2\frac{ GND-V_{ref} }{ R_{1} } + \frac{ V_{s}-V_{ref} }{ R_{3} } = \frac{ V_{ref}-V_{out} }{ R_{2} }
R1GND−Vref+R3Vs−Vref=R2Vref−Vout
Vout=Vref+R2R1Vref−R2R3(Vs−Vref)V_{out} = V_{ref} + \frac{R_{2}}{R_{1}}V_{ref} - \frac{R_{2}}{R_{3}}(V_{s} - V_{ref})
Vout=Vref+R1R2Vref−R3R2(Vs−Vref)
Vout=Vref∙(1+R2R1+R2R3)−R2R3VsV_{out} = V_{ref} \bullet (1 + \frac{R_{2}}{R_{1}} + \frac{R_{2}}{R_{3}}) - \frac{R_{2}}{R_{3}} V_{s}
Vout=Vref∙(1+R1R2+R3R2)−R3R2Vs
考虑到此处的 Vout 是负压,Vout 与 Vs 为正相关关系,所以画出 Vout 与 Vs 的曲线关系如下图所示。
输出最大值最小值由什么决定?
由于 BUCK 上下管打开关断始终需要一定时间,因此最小值由最小占空比决定,即由 min-on time 决定。
同理最大值则由 min-off time 决定。
Read More ~
Boost 电路基础知识
参考内容:
boost升压,电感电压和输入电压叠加、实现升压
电力电子技术 05 DC-DC变换器
手撕Boost!Boost公式推导及实验验证
Boost 电路构建
我们先复习一下 BUCK 电路的基础知识。BUCK 电路是一个降压结构,在不考虑电路的损耗时,输入功率 Pin 与输出功率 Pout 是一致的。电压降低,那么电流就必然升高,因此我们可以说 BUCK 是一个降压、升流的电路。
在不考虑使用变压器的情况下,如何构造一个升压结构呢?其实换个角度想构造升压电路,其实就是构造一个降流电路出来。那么如何构造降流电路呢?
参考 BUCK 降压的原理,我们使用同样的方式对电流进行处理,即将 BUCK 电路进行改造来完成这个降流工作。下面是一个电流源、开关和负载构成的电路,当开关 S 闭合时,电流不流向负载;当开关 S 打开时,电流即流向负载。
此时负载所获得的电流是一个方波,大致是下面这个样子的。可以发现与 BUCK 电路很像,我们只需要控制开关 S 的闭合时间,就可以控制流向负载的总电流。根据波形图可以确定,流向负载的电流一定小于输入电流,当开关打开时电流才流向负载。设置占空比为 D,则 Io=(1−D)IiI_{o}=(1-D)I_{i}Io=(1−D)Ii,根据 VoIo=ViIiV_{o}I_{o}=V_{i}I_{i}VoIo=ViIi,那么即有 Vo=11−DViV_{o}=\frac{1}{1-D}V _{i}Vo=1−D1Vi
我们目前的电路还是过于理想化了,我们平时见到的都是电压源,基本没有见过电流源的场景。那么如何利用电压源做出来电流源呢?考虑到电感可以阻碍电路的变化,一个电压源串联一个电感我们可以将之近似认为是一个电流源。
回想一下前面的波形,负载所获得的电流波动太剧烈了,势必会影响电压的剧烈抖动。与 BUCK 电路的思维一致,我们还是可以用电容来进行稳压。
在负载端添加输出电容后,电压得以被稳定,但是考虑一下此时开关 S 闭合时是什么情况?直接将电容短路了,如何解决电容被短路的问题呢?发现当只有当 S 闭合时才会有电容短路的问题,S 打开时电流从电源端流向负载并给电容充电。我们可以通过添加一个二极管来防止 S 闭合时电容短路。
当然,也可以把二极管换成另一个开关,只要保证 S 打开时二极管位置闭合,S 闭合时二极管位置打开即可。
考虑到机械开关容易磨损、使用寿命短、有机械惯性(转换频率低)的问题,我们需要将机械开关换成转换频率高的半导体器件,此处我们选择 NMOS 管来替代开关。同时二极管起到的是一个开关的作用。而且考虑到电流从二极管流过期间,二极管两端的压降恒定为导通电压 0.7V,二极管所消耗的能量较大。因此我们也可以把二极管换为导通电阻更小的 NMOS 管。
为了提高 Boost 电路的稳定性,防止由于输入纹波带来异常,我们在 Boost 电路的输入端并联一个电容,用于滤除输入电压的纹波。
至此我们即构建了一个标准的同步 Boost 电路。回过头再来看看,我们利用储能元件从输入电源获得能量得到一个电压,并将这个电压与输入电压进行串联,以此来实现了升压的效果。电容和电感是两种常见的储能元件,使用电感或电容均可以达到升压目的。
比如 BUCK 电路中若上管为 NMOS,则需要添加 boot 电容实现升压才能打开 NMOS 管。原因在于 NMOS 导通条件为 VGS 大于 Vth,而 BUCK 电路中的 NMOS 上管 S 电压最低为 Vin,因此需要 boot 电容进行升压。
我们在 Boost 电路中使用电感来实现升压升压的功能。当电感与负载断开并接地时,电感激磁并将能量存储在电感中。当电感和输入电压断开后,由于电感电流不能突变:Vin=LdidtV_{in} =L\frac{di}{dt}Vin=Ldtdi,电感中变化的电流产生感应电压,感应电压与输入电压串联达到了升压目的。
稳态分析
我们所说的稳态分析,是指输入输出电压都是稳定不变的前提下进行的分析。我们将一些已知条件列出来:
输入电压:ViV_{i}Vi
输出电压:VoV_{o}Vo
负载电阻:RRR
开关频率:fff
伏秒平衡
计算的基本原理就是电容和电感的充放电,根据前文所说的 Vo=11−DViV_{o}=\frac{1}{1-D}V _{i}Vo=1−D1Vi,可以知道输出电压肯定大于输入电压。
在下管导通上管截止时,电感两端电压是 ViV_{i}Vi;在下管截止上管导通时,电感右侧的电压为 VoV_{o}Vo,左侧为电源输入 ViV_{i}Vi,所以电感两端电压为 Vo−ViV_{o}-V_{i}Vo−Vi。
整个电路处于稳定状态,负载电流恒定,那么在一个周期内,电感电流增加的量肯定等于电感电流减小的量,即充了多少电就要放多少电,不然负载的电流或电压将会发生变化。
前文已有didt=UL\frac{\mathrm{d} i}{\mathrm{d} t} =\frac{{U}}{L}dtdi=LU,而 LLL 恒定,那么电感电流的变化速度即与电压成正比关系,即电感电流上升(下降)的斜率与电压成正比关系。而电感电流上升和下降的高度相同,那么上升时间和下降时间就自然构成反比关系。
TonToff=Vo−ViVi\frac{T_{on} }{T_{off} } = \frac{V_{o}-V_{i}}{V_{i}}ToffTon=ViVo−Vi,将其进行简单变换即可得到闻名江湖的伏秒平衡法则。
TonVi=Toff(Vo−Vi)T_{on}V_{i} = T_{off}(V_{o}-V_{i})TonVi=Toff(Vo−Vi)
占空比
已知 T=Ton+Toff=1fT=T_{on}+T_{off}=\frac{1}{f}T=Ton+Toff=f1,结合伏秒平衡法则可以计算出:
开通时间:Ton=Vo−ViVo∙1fT_{on} = \frac{V_{o}-V_{i}}{V_{o}} \bullet \frac{1}{f}Ton=VoVo−Vi∙f1
关断时间:Toff=ViVo∙1fT_{off} = \frac{V_{i}}{V_{o}} \bullet \frac{1}{f}Toff=VoVi∙f1
占空比:D=TonT=Vo−ViVoD = \frac{T_{on}}{T}=\frac{V_{o}-V_{i}}{V_{o}}D=TTon=VoVo−Vi
通过计算可以发现,占空比与电感没有关系,与负载电流大小也没有关系,只和输入输出电压有联系。
纹波电流
上文计算电感电流斜率时已经能确定电流波形是个三角波,纹波电流等于在下管导通时电感电流的增大值,也等于在下管截止时电感电流减小的值,计算任意一个即可得到纹波电流。我们以下管导通时增大的电感电流计算。
下管导通时电感两端电压为 ViV_{i}Vi,导通时间为 Ton=Vo−ViVo∙1fT_{on} = \frac{V_{o}-V_{i}}{V_{o}} \bullet \frac{1}{f}Ton=VoVo−Vi∙f1,根据 U=LdidtU=L\frac{di}{dt}U=Ldtdi 有:
△IL=di\triangle I_{L} =di△IL=di
=Ton∙UL=T_{on}\bullet \frac{U}{L}=Ton∙LU
=Vo−ViVo∙ViL∙1f=\frac{V_{o}-V_{i}}{V_{o}} \bullet \frac{V_{i}}{L} \bullet \frac{1}{f}=VoVo−Vi∙LVi∙f1
=(Vo−Vi)ViVoLf=\frac{(V_{o}-V_{i})V_{i}}{V_{o}Lf}=VoLf(Vo−Vi)Vi
根据理论计算可以发现,电感电流的纹波和负载电流的大小没有关系。同时电感的感值会影响纹波电流的大小,因此电感的选择是系统一个很重要的参数。
功率电感选择
在系统稳态时,输出端电容是不耗电的,电压也不会有变化,所以输出电容上面流过的平均电流为 0。负载的所有电流都从上管而来,因此流过上管的平均电流也是 IoI_{o}Io。输入功率等于输出功率,输入电流等于流过电感的电流,即有:
ViIL=VoIoV_{i}I_{L}=V_{o}I_{o}ViIL=VoIo
IL=VoIoViI_{L}=\frac{V_{o}I_{o}}{V_{i}}IL=ViVoIo
计算电感峰值电流可以得到:
ILP=Io+△IL2I_{LP} =I_{o}+\frac{\triangle I_{L}}{2}ILP=Io+2△IL
=IL+Vo−Vi2Vo∙ViL∙1f=I_{L}+\frac{V_{o}-V_{i}}{2V_{o}} \bullet \frac{V_{i}}{L} \bullet \frac{1}{f}=IL+2VoVo−Vi∙LVi∙f1
=VoIoVi+Vo−Vi2Vo∙ViL∙1f=\frac{V_{o}I_{o}}{V_{i}}+\frac{V_{o}-V_{i}}{2V_{o}} \bullet \frac{V_{i}}{L} \bullet \frac{1}{f}=ViVoIo+2VoVo−Vi∙LVi∙f1
在选择功率电感时,电感的饱和电流就必须要大于这个ILPI_{LP}ILP,并且需要留有一定的裕量。实际应用时电感的纹波电流应是平均电流的 30%30\%30% 至 50%50\%50% 为宜,我们将这个参数称之为电流纹波率 r,关于电流纹波率为什么选择 0.3 至 0.5,可以查看为何 r 为 0.3~0.5。根据电流纹波率范围就可以计算出电感值的范围:
△IL=Vo−ViVo∙ViL∙1f\triangle I_{L} =\frac{V_{o}-V_{i}}{V_{o}} \bullet \frac{V_{i}}{L} \bullet \frac{1}{f}△IL=VoVo−Vi∙LVi∙f1
L=Vo−ViVo∙Vi△IL∙1fL =\frac{V_{o}-V_{i}}{V_{o}} \bullet \frac{V_{i}}{\triangle I_{L}} \bullet \frac{1}{f}L=VoVo−Vi∙△ILVi∙f1
=Vo−ViVo∙Vi(0.3至0.5)Ii∙1f=\frac{V_{o}-V_{i}}{V_{o}} \bullet \frac{V_{i}}{(0.3至0.5) I_{i}} \bullet \frac{1}{f}=VoVo−Vi∙(0.3至0.5)IiVi∙f1
=(Vo−Vi)Vi(0.3至0.5)IiVof=\frac{(V_{o}-V_{i})V_{i}}{(0.3至0.5) I_{i}V_{o}f}=(0.3至0.5)IiVof(Vo−Vi)Vi
输入纹波
输入电压纹波就是输入电容上面电压的变化,这个变化可以分为两部分。一部分为电容充放电所导致的电压变化 UqU_{q}Uq,另一部分为电流流过电容 ESRESRESR 导致的压降 UesrU_{esr}Uesr。即 △Vi=Uq+User\triangle V_{i} = U_{q} + U_{ser}△Vi=Uq+User。
我们来看输入节点,这个节点的电流有三个,一个是来自电源输入的,在一个周期内我们看作是恒定的。另外两个节点是电容和电感。根据基尔霍夫电流定律,节点电流和为 0,并且电源输入的电流恒定,那么电感电流的变化量必然等于电容电流的变化量,因为最终三者的和为 0。
节点电流和为 0,那么输入电容的电流变化就是功率电感的电流变化。我们画出三者的电流波形。电容电流大于 0 时,电容在充电,电容电流小于 0 时,电容在放电。并且电容充电和放电时间长度是一样的,都是周期的一半。
需要注意的是充电与放电的切换的时刻并不是开关导通与断开的时候,而是在中间时刻。电容充放电的总电荷量即等于电流乘以时间,其实就是图中阴影三角形的面积。
三角形底部是时间,充电/放电时间为T2\frac{T}{2}2T;三角形的高为电感纹波电流的一半 △IL2\frac{\triangle I_{L}}{2}2△IL;根据三角形面积计算公式即有:
Q=CiUqQ=C_{i}U_{q}Q=CiUq
=12∙T2∙△IL2=\frac{1}{2}\bullet \frac{T}{2}\bullet \frac{\triangle I_{L}}{2}=21∙2T∙2△IL
=12∙12f∙(Vo−Vi)ViVoLf2=\frac{1}{2}\bullet \frac{1}{2f}\bullet \frac{\frac{(V_{o}-V_{i})V_{i}}{V_{o}Lf}}{2}=21∙2f1∙2VoLf(Vo−Vi)Vi
=(Vo−Vi)Vi8f2VoL=\frac{(V_{o}-V_{i})V_{i}}{8f^{2} V_{o}L}=8f2VoL(Vo−Vi)Vi
Uq=(Vo−Vi)Vi8f2VoLCiU_{q}=\frac{(V_{o}-V_{i})V_{i}}{8f^{2} V_{o}LC_{i}}Uq=8f2VoLCi(Vo−Vi)Vi
前面波形图已经知道,电容的充电电流最大是 △IL2\frac{\triangle I_{L}}{2}2△IL,放电电流最大是 −△IL2-\frac{\triangle I_{L}}{2}−2△IL。那么 ESRESRESR 引起的总压降即为:
Uesr=△IL2∙ESR−(−△IL2∙ESR)U_{esr} = \frac{\triangle I_{L}}{2} \bullet ESR - (-\frac{\triangle I_{L}}{2} \bullet ESR)Uesr=2△IL∙ESR−(−2△IL∙ESR)
=△IL∙ESR= \triangle I_{L}\bullet ESR=△IL∙ESR
=(Vo−Vi)ViVoLf∙ESR= \frac{(V_{o}-V_{i})V_{i}}{V_{o}Lf} \bullet ESR=VoLf(Vo−Vi)Vi∙ESR
最终可得:
△Vi=Uq+Uesr\triangle V_{i} = U_{q} + U_{esr}△Vi=Uq+Uesr
=(Vo−Vi)Vi8f2VoLCi+(Vo−Vi)ViVoLf∙ESR=\frac{(V_{o}-V_{i})V_{i}}{8f^{2} V_{o}LC_{i}} + \frac{(V_{o}-V_{i})V_{i}}{V_{o}Lf} \bullet ESR=8f2VoLCi(Vo−Vi)Vi+VoLf(Vo−Vi)Vi∙ESR
输入电容选择
考虑到电容的实际使用情况,陶瓷电容的 ESRESRESR 小,容量小,所以 UqU_{q}Uq 对纹波起决定性作用,输入纹波可近似为 UqU_{q}Uq。若选择铝电解电容,则 ESRESRESR 大,容量大,UesrU_{esr}Uesr 对纹波起到决定性作用,输入纹波可以近似为 UesrU_{esr}Uesr,假设电路设计要求输入纹波不能大于 △Vi\triangle V_{i}△Vi,则有:
陶瓷电容:Ci≥(Vo−Vi)Vi8f2VoL△ViC_{i} \ge \frac{(V_{o}-V_{i})V_{i}}{8f^{2} V_{o}L \triangle V_{i}}Ci≥8f2VoL△Vi(Vo−Vi)Vi
铝电解电容:ESR≤△ViVoLf(Vo−Vi)ViESR \le \frac{\triangle V_{i}V_{o}Lf}{(V_{o}-V_{i})V_{i}}ESR≤(Vo−Vi)Vi△ViVoLf
输出纹波
输出纹波与输入纹波同理,亦是 △Vo=Uq+Uesr\triangle V_{o} = U_{q} + U_{esr}△Vo=Uq+Uesr。一个周期内电容的充电电荷必然与放电电荷量相等,我们只需要计算出其中任意一个即可。很显然计算放电电荷量更为容易,因为放电电流就是负载电流,负载电流为恒定的 Io=VoRLI_{o} = \frac{V_{o}}{R_{L}}Io=RLVo。
Q=CoUq=IoTonQ = C_{o}U_{q}=I_{o}T_{on}Q=CoUq=IoTon
Ton=Vo−ViVo∙1fT_{on} = \frac{V_{o}-V_{i}}{V_{o}} \bullet \frac{1}{f}Ton=VoVo−Vi∙f1
Uq=IoTonCoU_{q} = \frac{I_{o}T_{on}}{C_{o}}Uq=CoIoTon
=Io(Vo−Vi)CoVof= \frac{I_{o}(V_{o}-V_{i})}{C_{o}V_{o}f}=CoVofIo(Vo−Vi)
在下管导通的时候,负载的电流为 IoI_{o}Io,完全由输出滤波电容提供,即滤波电容的放电电流也为 IoI_{o}Io,而且还是在导通时间里面恒定不变的。
在下管从导通切换到截止时,电感的电流已经是充到最大的,因为先前下管导通时电感一直在充电,所以切换时电感电流最大,且等于电感平均电流加上纹波电流的一半,即 IL+△IL2I_{L}+\frac{\triangle I_{L}}{2}IL+2△IL。已经充好的电感电流会给负载供电,负载电流为 IoI_{o}Io。同时电感还要给输出电容进行充电,根据基尔霍夫电流定律,电容的充电电流就是电感充到最大的电流减去负载的电流,即 IL+△IL2−IoI_{L}+\frac{\triangle I_{L}}{2} - I_{o}IL+2△IL−Io。
在下管关断之后,电感电压反向,电感电流持续减小,负载电流恒定不变,所以输出滤波电容的电流持续减小。画出输出电容的电流波形就非常明显了。
在下管导通时,ESRESRESR 两端的电流为 −Io-I_{o}−Io。在下管截止时,ESRESRESR 两端的电流为 IL+△IL2−IoI_{L}+\frac{\triangle I_{L}}{2} - I_{o}IL+2△IL−Io。由此即可计算出 ESRESRESR 上的电压变化量。
Uesr=(IL+△IL2−Io)∙ESR−(−Io∙ESR)U_{esr} = (I_{L}+\frac{\triangle I_{L}}{2} - I_{o})\bullet ESR - (-I_{o} \bullet ESR)Uesr=(IL+2△IL−Io)∙ESR−(−Io∙ESR)
=(IL+△IL2)∙ESR= (I_{L}+\frac{\triangle I_{L}}{2})\bullet ESR=(IL+2△IL)∙ESR
=(VoIoVi+(Vo−Vi)Vi2VoLf)∙ESR= (\frac{V_{o}I_{o}}{V_{i}} + \frac{(V_{o}-V_{i})V_{i}}{2V_{o}Lf}) \bullet ESR=(ViVoIo+2VoLf(Vo−Vi)Vi)∙ESR
最终可得:
△Uo=Uq+Uesr\bigtriangleup U_{o} = U_{q} + U_{esr}△Uo=Uq+Uesr
=Io(Vo−Vi)CoVof+(VoIoVi+(Vo−Vi)Vi2VoLf)∙ESR=\frac{I_{o}(V_{o}-V_{i})}{C_{o}V_{o}f} + (\frac{V_{o}I_{o}}{V_{i}} + \frac{(V_{o}-V_{i})V_{i}}{2V_{o}Lf}) \bullet ESR=CoVofIo(Vo−Vi)+(ViVoIo+2VoLf(Vo−Vi)Vi)∙ESR
输出电容选择
与输入电容选择的方式一致,考虑是容值还是 ESRESRESR 占主导地位,假设要求输出纹波要小于 △Vo\triangle V_{o}△Vo,则有:
陶瓷电容:Co≥Io(Vo−Vi)△VoVofC_{o} \ge \frac{I_{o}(V_{o}-V_{i})}{\triangle V_{o}V_{o}f}Co≥△VoVofIo(Vo−Vi)
铝电解电容:ESR≤△VoVoIoVi+(Vo−Vi)Vi2VoLfESR \le \frac{\triangle V_{o}}{\frac{V_{o}I_{o}}{V_{i}} + \frac{(V_{o}-V_{i})V_{i}}{2V_{o}Lf}}ESR≤ViVoIo+2VoLf(Vo−Vi)Vi△Vo
Read More ~
电源 PCB 布局及其常见错误
参考内容:
电源 PCB 布局中的常见错误及避免方式
嘉立创EDA-PCB设计零基础入门课程(54集全)
硬件设计基础之PCB设计中的10条重要布线原则
PCB 基础
我们先来看看在没有 PCB 之前的电路是如何连接的,阻容感和芯片等均通过导线连接,由此不可避免的就带来了混乱的问题。为了解决束线、混乱等问题就出现了 PCB。
PCB(Printed Circuit Board)即印制电路板,由保罗.爱斯勒发明。PCB 是元器件的支撑体,更是实现诸多电子元器件电气连接的载体。随着历史车轮的滚滚前进,为了在 PCB 上放置更多的元器件,由一层板逐渐发展为多层板。
多层板可以看作是由多个两层板,加上一个芯板组成,不同层之间的切换通过过孔来实现,即钻孔并在孔的内壁覆上铜。由于制造 3 层板和制造 4 层板的工艺、成本等均基本一致,所以需要注意一般是没有单数层板的,只有偶数层的 PCB。
图中 1 属于盲孔,用于第一层和最后一层之间的切换;2 属于埋孔,用于内层之间的切换;3 属于通孔,用于第一层和除最后一层之间的切换。这些孔我们统一将其称之为过孔。
走线为什么要避免锐角、直角
PCB 板上的铜线需要做到阻抗连续,那么什么情况下会导致其阻抗不连续呢?当线宽改变的时候阻抗就会变得不连续,同时寄生电容、电感的值也会发生变化,那么什么情况会导致线宽改变呢?自然是涉及到拐弯的时候。
除了线宽改变导致阻抗不连续外还有哪些影响呢?比如线宽改变会导致寄生容值变大,即相当于在传输导线上加了容性负载,当信号通过时即会涉及到充放电的过程,信号速度即会被减缓。
另外还存在尖端放电的问题,导体上面很尖的点即表示面积非常小,相应即电荷密度很高,将产生巨大的电场强度,击穿空气释放电子,就会产生比较强的电磁干扰,因此布线拐角应该使用钝角或是圆角。
典型案例
对于整体性能来说,电源的布局与挑选合适的半导体(芯片、阻容感)是一样重要的。一个糟糕的布局会在电路中产生额外的寄生阻容感,这会让电路在不同的地方产生更多的噪音。除此之外,如果没有注意布局还可能让功率元件无法有效散热。
输入电容太远导致寄生电感过大
如图所示是一个同步降压转换器上下两面的布局。这是一个将 12V 输入降低到 1.2V 输出,且能够承载 8A 电流的降压转换器。可以从图中左上角看到开关节点(SW)对 GND 的电压波形。可以发现振铃峰值已经到了 17V,这种高频的振荡几乎可以肯定会对 EMI 造成不利的影响。而且该开关电源元件建议的最高操作电压即为 17V,绝对额定值最高为 19V,这个振荡峰值已经很接近让电源芯片因超过电压应力而损坏的点。同时该振荡电压也导致了输出纹波超过 100mV。
在图中右边部分用绿色框起来的电容即为输入电容,它不止离集成开关电源芯片的 VIN 和 PGND 有一段距离,甚至还通过过孔连接到另外一面,造成额外的寄生电感。而改进方法也很简单,在原理图不变的前提下,直接在芯片的 VIN 和 PGND 引脚旁边放上输入电容器,如下图中绿色框中所示。需要注意的是较小的高频旁路电容要越靠近电源芯片才好。
从测试波形可以发现,当输入电容位置摆放正确后,振荡电压几乎就被消除了,这肯定会对 EMI 特性产生正面影响。开关电压峰值也远低于芯片所建议的操作范围之内。输出电压纹波同样有了极大的改善,峰峰值已经降到 10mV 以下。
方波的波形是由基本的开关频率加上各个奇次谐波所组成,越快的电压瞬间爬升或下降即表示会包含更高的频率,这就是可能导致振荡和 EMI 的原因。上图给出了不同容值(尺寸)的陶瓷电容的典型阻抗曲线,电容引起的阻抗会随着频率的增加减小,到某个频率点以上则由寄生电感所带来的阻抗影响占主导。图中红色虚线表示将不同尺寸的电容器并联时等效的阻抗,随着开关频率的不断增加,其 EMI 的要求也会变得更加严格,高频电容器和其摆放的位置也变得更加重要。
回过头来看为什么将输入电容仅靠输入引脚就可以减少解决上述问题呢?如上图所示表示了一个同步降压拓扑中,寄生电感影响最大的地方,以及可以容忍且影响相对小的地方。可以发现输入电容的环路对寄生电感最为敏感,上述案例中最开始的布局由于输入电容离芯片引脚过长,PCB 走线太长导致寄生电感过大,进一步导致高频振荡严重。
电感磁场扩散至外围
如下图是一个从 12V 输入转为 2.5V 输出,承载 2.5A 的一个拓扑结构。从右上角可以看到测量的输出波形中虽然纹波没有很大,但是可以看到里面包含可疑的方波分量。有意思的是当我们把电感解焊旋转 180 度后再装上,方波分量会随之反转,那么这个设计发生了什么呢?
此处的线索是电感器,仔细观察会发现这个是一个半屏蔽式的电感器。实际上半屏蔽式的电感器和非屏蔽式的电感器没有太大区别,这表示磁场没有得到适当的控制,而且还有可能扩散到电感器外面。如图所示可以发现输出电容 1 在靠近电感器的位置,这会导致电感器的磁场耦合到输出电容的等效串联电感中,这就像一个变压器一样。因此显示了除转换器本身的输出纹波外,还加上了被磁场耦合到的夹噪。
当把电感器旋转 180 度时,磁场也会跟着翻转,这就是波形完全反转的原因。要摆脱这种方波的方法是确保来自电感的磁场不会耦合到输出电容器的等效串联电感中,那么我们把输出电容移到电感磁场范围之外,比如图中 2 或另一侧 3 的地方。上图左侧就是改善后的布局,即将输出电容移到了另一侧,从波形可以看出方波分量即被消除了。
另一种减少电感器和输出电容之间耦合的方法是使用全屏蔽的电感器,这与上文移动输出电容位置的方法一样有效。上图右下角显示了非屏蔽、半屏蔽、全屏蔽电感器的磁幅射影,可以发现黑色线和红色线仅仅只是小了 5dB 左右,而蓝色线则少了 30dB 的以上。
另一个小地方要注意的是绕组(电感)的起点用一个点或一条线来标记,这个端点应该要连接到电路里面的开关节点,即接到电压会有跳动方波的那一点。因为绕组的起点埋在电感器的中心,并且连接到直流输出的外绕组,会有一定的自我屏蔽的效应。在电感器下方有一个接地层也能屏蔽电场带来的干扰。
回过头来看上文虽然将输出纹波中的方波分量消除了,但是开关瞬间产生的纹波尖峰并没有消失,这些尖峰的主要来源是通过电感器本身并联的寄生电容耦合到电压开关瞬间变化所造成的。电容本身很少出现在电感器的规格书中,但是我们还是可以通过自谐振频率推断出来,为了确保最小寄生电容和最小输出尖峰,我们应该选择具有高 SRF 的电感器。
高阻抗反馈电阻走线太长引入噪声
如下是一个将 12V 输入转换为 1.05V 且输出为 5A 的拓扑,从右上角波形可以看出输出波形中大约有 70mV 的噪音纹波,另外开关波形上面也能看出有一些抖动,这会让输出瞬态响应的稳定性受到影响。
解决上述问题的线索是反馈电阻,可以发现绿色框出来的地方摆在板子的右上方,而红色框起来的地方为电源芯片摆在板子的正中央。反馈引脚是一个高阻抗的节点,因此对噪音会很敏感。在初版布局中反馈电阻摆放在输出连接器的附近,这会使敏感的反馈走线跨越了快整个电路板,而且还穿过了电感的下方,干扰源很容易就耦合到这种长的反馈走线上,还有可能会导致转换器运行不稳定。
右上角的第二版是改善后的布局,可以看出反馈电阻比上一版更靠近反馈引脚,同时输出电压反馈点放在远离电感和开关节点的另一侧,这样输出的波形会更加干净。右下角可以发现纹波也从大于 70mV 降低到 20mV,开关波形的抖动现象也大大减小。
尽量让敏感的信号拉线远离电感器或开关节点,如果负载端与反馈调节点有一段的距离,那就要考虑上图所示的差分接线,以此来提高噪音的抑制能力。另外在选择反馈电阻时,虽然阻值越高是可以减少一些损耗,但是也更容易受到噪音的影响,并且可能因为一点点的反馈偏置电流而改变输出电压,10kohm~100kohm 范围的总的反馈电阻通常是一个不错的折中方案。
大电流引起压降
下面是一个大电流同步降压转换器的负载调节案例,这是一个将 12V 转换为 0.85V 并承受 20A 负载的设计。其 PCB 布局如图左下角所示,控制器位于电路板右侧而输出是位于左上角。左上图显示了输出电压调节与负载的关系,可以看到随着电流的增加输出电压下降到低于规定值的两个百分点。
造成这个问题的原因是因为控制器直接检测输出电压,第一版设计中反馈与输出之间的连接点就在电感旁边,而且远离输出连接器。大的铜线会有一些寄生电阻,根据欧姆定律我们知道当电流流过电阻时会有一定的电压降,虽然铜的电阻相对较低,但是在高电流下可能还是会有影响。
通过将输出的反馈点移动到输出连接器旁边,控制器能够补偿寄生电阻带来的损失,并且更好的调节输出电压。左边的蓝线即改进后的输出电压调节与负载的关系。那么又如何计算铜的寄生电阻呢?
估计铜铂寄生电阻的快速方法是使用计数平方,通过将长宽设置为彼此相等,平面总电阻的方程简化为铜的电阻率除以厚度。表中给出了几个标准铜铂厚度的每平方电阻,在查看铜铂时串联的电阻相加,平行的相当于并联等效电阻更小。
再回过头来看这个案例,使用的 PCB 是 2 盎司的铜,从输出到回收点的距离有 4 个正方形这么长,每平方电阻为 0.25mohm,4 个即为 1mohm,再乘以 20A 的负载电流就可以解释最大负载下输出大约下降了 20mV 的现象。
Read More ~
电平转换器基础知识
参考内容:
迷失在电平转换中
为什么芯片都是 CMOS 的呢?CMOS 和 TTL 有什么区别?
高阻态,三态门
TI 162245 用户手册
为什么需要电平转换
在电路设计中,常常会遇到一个系统中存在不同的电平信号,比如 0.9V、1.8V、3.3V、5V 等,要使系统不同模块协同工作,就需要对电平信号进行转换。
其实需要进行电平转换的本质原因是因为半导体加工工艺的不同。比如 TTL 电平 2.0V 以上表示高,0.8V 以下表示低。但是 CMOS 电平由于其物理原因,常常是将高定义为 3.5V 以上,低定义为 1.5V 以下。所以伴随着每一个新的半导体发展阶段,高和低都被改了一下,直到形成了下图这个样子。
一个 5V TTL 输出跟一个 5V TTL 输入可以没有障碍的交流,但是当一个 3V CMOS 和一个 5V CMOS 进行交流时就存在问题了。当 3V CMOS 输出高时,意味着它应用一个 2.5V 的信号在其输出引脚上,如果此时将这个输出与一个 5V CMOS 输入端进行连接,5V CMOS 只能识别超过 3.5V 的高信号和低于 1.5V 的低信号,那么 2.5V 的输入对于 5V CMOS 来说是高还是低呢?
常见电平转换电路
使用电阻分压
最直接也是最简单的办法就是使用电阻来分压来转换电平,后续芯片可以等效为一个负载电阻,通过串联一个电阻与芯片内部电阻构成分压关系。这种电路的优势是电阻器件采购方便、价格低廉,电路也很简单。但是缺点也很明显,因为两芯片引脚之间存在电压差且中间只有串联电阻,所以会有电流的流动造成两芯片相互影响。
二极管电平转换
二极管电平转换电路如下图所示。该电路由二极管和电阻组成,电路使用元件比较少,其中二极管最好用肖特基二极管,因为肖特基二极管具有开关频率高和正向压降低的优点。
当 5V TXD1 发送高电平时,二极管正极电压比负极电压低,二极管截止,RXD2 被上拉电阻拉为高电平;
当 5V TXD1 发送低电平时,二极管导通,RXD2 被钳位至二极管管压降(0.7V),所以收到低电平;
当 3.3V TXD2 发送低电平时,二极管导通,RXD1 被钳位至二极管管压降(0.7V),所以收到低电平;
当 3.3V TXD2 发送高电平时,二极管也是导通的,RXD1 电压为 3.3V 加上一个管压降,即 4.0V,所以收到高电平。
当 3.3V 端发送高电平给 5V 端时,5V 端收到的并不是 5V,同时该电路只适合单向通讯的场景,发送端和接收端不可以互换。
三极管电平转换
三极管电平转换电路其实就是模电中的共射极放大电路,也就是 TTL 的前身,原来的 RTL(Resistor Transistor Logic),以下图左边 5V 转 3.3V 为例。
当 TXD1 发送高电平时,第一个三极管导通,导致其集电极电位为低电平,所以第二个三极管基极为低电平,第二个三极管处于截止状态,RXD2 被上拉电阻拉高至 3.3V;
当 TXD1 发送低电平时,第一个三极管截止,第二个三极管导通,RXD2 相当于接地,输出低电平信号。
3V 转 5V 类似则不再赘述。
如果接收端可以接受反相信号,则可以去掉电路中一个三极管,电路会简单一些,如下图所示。
MOS 管电平转换
使用三极管搭建的逻辑电路的优点是速度快,但是其缺点也很明显,就是静态电流损耗很大,所以无法进行大规模的集成。MOS 管的导通电阻很小,其静态功耗可以忽略不计。MOS 管搭建的逻辑电路相比三极管的速度要慢,原因在于三极管是电流控制型器件,而 MOS 管是电压控制型器件,打开 MOS 管会涉及到给 MOS 管寄生电容充电,这个充电过程即是导致速度慢的原因。以 MOS 管搭建的如下电平转换电路可双向传输。
5V 电路发送高电平时,MOS 管 D 极电压被拉至 5V,但是 MOS 管的状态和 D 极电压无关,由 VGS 决定导通程度,所以 MOS 管截止,S 极被上拉电阻拉高为 3.3V;
5V 电路发送低电平时,MOS 管 D 极电压为 0V,S 极电压为 3.3V,由于 MOS 管存在体二极管,所以 3.3V 电路被钳位在 0.7V,为低电平;
3.3V 发送高电平时,MOS 管 G 极和 S 极电压都为 3.3V,MOS 管截止,D 极被上拉电阻拉高至 5V;
3.3V 发送低电平时,MOS 管 G 极为 3.3V,S 极为 0V,MOS 管导通,D 极为低电平。
电平转换芯片
在介绍集成好的电平转换芯片之前,我们需要先了解高阻态、三态门等基本的概念,最后再将三态门等进行组合形成电平转换芯片。
高阻态
高阻态是数字电路中常见的术语,表示电路中的某个节点具有相对电路中其它点更高的阻抗,它是电路的一种特殊的输出状态,但是这个状态既不是高电平也不是低电平。如果用万用表进行测试,可能测到高电平也可能测到低电平,它的状态取决于其后面的电路。
在电路分析时可以将高阻态作开路理解,理论上高阻态是不悬空的,它对地和对电源的电阻都非常大,但实际应用时与引脚悬空几乎一致,因此高阻态的极限可以认为就是悬空。
那么高阻态又是如何造成的呢?通常是由于三态门或三态缓冲器的存在而导致的,当三态门的使能端为低电平时,门电路输出上拉管和下拉管都截止,输出端就处于浮空状态,即没有电流的流动。此时相当于门电路放弃了对输出端电路的控制,其实际电平就由外部电平来决定。
三态门
那么什么又是三态门呢?三态门允许输出端除了高电平和低电平之外呈现高阻态。可以理解为除了输出输出端口外,三态门另外加了一个 EN 引脚,若没有对三态门进行使能,则其输出为高阻态,否则输出由输入决定。
三态门常常用于总线连接结构。在总线上有多个设备连接到同一条数据线上,但是在特定时间内只有一个设备是有效的,不活动的设备处于高阻态,因此不会对总线上其它设备产生影响。一般来说需要双向数据传输,我们再增加一个三态门即可实现此功能。
三态门输出结构
三态门输出端由上 PMOS 管、下 NMOS 管和一个输出供电电压构成,当上管导通时输出低电平,当下管导通时输出为高电平。
但是仔细观察会发现输出和输入是反相的,因此为了保证输入与输出同相,我们还需要在输入端加一级反相器。神奇的事情发生了,这不是施密特触发器吗?而且实际应用时为加强电路的抗干扰能力,使用的就是施密特触发器。
我们在上文的基础上再加入 EN 使能逻辑,即可实现完整的三态门输出结构,在排故障时可通过该等效电路对现象进行分析。
集成芯片
在实际应用中肯定不能一位一位的传输,我们假设一次传输一个字节(8 bit),为了简化电路我们把同一个方向的 EN 信号接到一起,如此即只需要两个信号即可控制数据的传输方向了。
通过控制 EN1 和 EN2 的使能与否,即可控制数据的传输方向。需要注意的是应该避免 EN1 和 EN2 同时都使能的状态,因为在此种状态下左右两侧的数据传输会打架,谁也不知道接收到的信号到底是自己发出去的还是别人发过来的。
EN1
EN2
左侧
右侧
输出状态
H
H
使能
使能
无效状态
H
L
使能
高阻态
数据由左至右
L
H
高阻态
使能
数据由右至左
L
L
高阻态
高阻态
隔离,不传输数据
为了避免上述同时使能的状态,我们对电路做一点改进,以保证芯片不会出现稀奇古怪的现象。
OE
DIR
左侧
右侧
输出状态
H
H
使能
高阻态
数据由左至右
H
L
高阻态
使能
数据由右至左
L
H
高阻态
高阻态
隔离,不传输数据
L
L
高阻态
高阻态
隔离,不传输数据
Read More ~
LDO 基础知识
参考内容:
ADI 公司 LDO 电容选型指南
线性和低压降 (LDO) 稳压器
BUCK 电路通过控制占空比来达到降压的目的,添加 LC 二阶低通滤波器将高频部分滤除,即可达到稳定输出直流的目的。但是滤波不能完全滤除高频分量,BUCK 从原理上就决定了其纹波不容易做到很小,其固有的开关频率会导致电源噪声很大,用来给噪声敏感的元器件供电就不合适。
相比 BUCK 来说,LDO(Low Dropout Regulaor:低压差线性稳压器)输出的电压会更加平稳,可以弥补 BUCK 输出纹波大的缺点。
总体框图
线性稳压器主要由四部分组成,基准源用于提供精准的电压基准、导通器件用于控制从 VIN 到 VOUT 的电流大小、误差放大器将强制反馈节点与基准电压匹配、反馈电阻用于调整以改变输出电压。
从框图中也可以看到线性稳压器只能用于降压,因此输入电压必须高于输出电压。当然其名字中本身带了低压差的,低压差就意味着少的发热,意味着电源转化效率的提升。线性则是指器件的工作状态,器件的内部模块工作在放大区,放大状态呈线性关系。
工作原理
线性稳压器的工作可以模拟为两个电阻器和一个用于 VIN 的电源,其中电源用于给负载供电,通过调整可变电阻(导通器件)的阻值来控制负载电阻所获得的电压,整个系统中唯一不变的恒定的参数就是输出电压 VOUT。
其稳压过程如下图所示,当负载电压升高/降低时,采样电路所采到的电压就跟着升高/降低,传递给误差放大器后通过调节导通器件的导通程度来调节输出电压。
导通器件
导通器件常见的有 PMOS、NMOS、BJT 等。BJT 应用于大电流的场景。PMOS 不需要额外的电源轨即可控制其导通程度,但是相比 NMOS 其 RDSon 更大,即 PMOS 架构的 LDO 在芯片本身所消耗的能量会更大。
使用 NMOS 作为导通器件时,需要添加辅助电源轨或者使用电荷泵才能将 NMOS 打开。当然电荷泵也有其缺点,虽然电荷泵可以提升 VIN,但是也带来了额外的噪声影响。若采用辅助电源轨时则需要注意,VBIAS 会影响 NMOS 的导通程度,进而影响输出电压的大小。
PSRR
PSRR(Power Supply Rejection Ratio)量化了 LDO 抑制任何电源变化传递到其输出信号的能力,也就是 PSRR 决定了输入耦合到输出的噪声有多少。除了 LDO 本身的设计影响 PSRR 外,也可以通过调整 VIN 与 VOUT 之间的差值、输出电容来提高在特定应用(频率)下的 PSRR。
输入输出电容
为了确保 LDO 稳定工作,会在 LDO 输入输出端增加旁路电容,并且旁路电容的 ESR 需要很小,即在符合最小电容和最大 ESR 的要求下,使用任何质量良好的电容都可采用。在选择电容时还需要注意由于直流电压偏置、温度变化、制造商容差等需要对电容进行一定的降额。
输出电容除了可以进行滤波外,还会影响负载电流的变化的瞬态响应,采用较大的输出电容可以改善 LDO 对大负载电流变化的瞬态响应。输入电容则可以降低电路对 PCB 布局的敏感性,尤其是在长输入走线或者高源阻抗的情况下。
多层陶瓷电容、固态钽点解电容、铝电解电容通常用作输入和输出旁路电容。多层陶瓷电容具备 ESR 和 ESL 低、工作温度范围宽的优点,但是陶瓷电容中的介质材料具备压电性,振动或机械冲击可能会转化为电容上的交流噪声电压,在极端情况下可能会产生 mV 级的噪声。
压电性是在某些固体材料(晶体、陶瓷、骨头、DNA、蛋白质等)受到机械应力作用后,在材料中聚集电荷的现象。「压电」即由压力产生的电。
钽电容的优点是单位体积电容最高(CV 乘积),并且不太容易受到温度、偏执电压、震动效应的影响,在无法容忍压电效应的低噪声应用中,钽电容基本是唯一可行的选择。与陶瓷电容相比,钽电容的泄漏电流要比等值的陶瓷电容大很多倍,不适合超低电流应用。
铝电解电容往往体积较大、ESR 和 ESL 较高,漏电流相对较高,与钽电容一样不受压电效应影响,适合要求低噪声的应用场合,但是铝电解电容在航天应用中禁止使用。
Read More ~
半导体功率器件
高中时候我们在化学课程中学过元素周期表,「氢氦锂铍硼、碳氮氧氟氖......」倒背如流,在元素周期表的中间三、四、五族元素定义为半导体元素,所谓半导体是根据其导电能力来定义的,我们可以通过一定的半导体工艺来改变其导电能力。
以硅(Si)为例,硅是处在第四族的元素,它的外部有 4 个电子,所以硅的稳定结构是形成下图所示的及其稳定的共价键结构。
硅的两侧对应的是三族和五族的元素,三族的元素意味着外层有 3 个电子,五族的元素意味着外层有 5 个电子。如果把三族元素插入到四族的硅当中,由于硅想形成稳定的四个共价键结构,所以会存在一个空置的位置,我们称之为空穴,这个空穴是具备一定的正电荷的能力的,如此就形成了 P 型半导体。
同理,若使用五族元素与硅进行掺杂,就会多出一个可移动的电子,即存在自由电荷,形成了 N 型半导体。
PN 结(普通二极管)
当我们把 P 型半导体和 N 型半导体进行组合后,即可得到最基本的二极管(PN 结)。在 P 型半导体中存在高浓度的空穴(正电荷),在 N 型半导体中存在高浓度的电子,浓度高的载流子会自然而然向浓度低的区域进行扩散。
由于载流子扩散,最终会形成一个势垒,这是一个空间电荷区,也称之为耗尽层。可以发现 PN 结存在 P 区、耗尽层、N 区三个区域,这几个区域都是呈现电中性的,不管是空穴还是电子想要到达另一个区域,都必须要穿过耗尽层,即耗尽层会阻碍空穴和电子的运动,因此整个 PN 结在没有外界干扰的情况下,是不具备导电能力的。
当我们从外界施加 N 到 P 的电场时,即 PN 结反偏。此时外界电场与耗尽层电场是同向的,所以在外部电场的作用下,耗尽层的宽度会被加强,于是 PN 结的导电能力就变得更弱,因此就呈现了一个无导电能力的特性。
当然导电只是一种相对情况,即便空间电荷区变宽了,也不能百分百保证说就完全没有导电能力,因为还是有一定的空间电荷浓度,在这样的情况下会有微弱的电流流经 PN 结,意味着系统存在一个反向电流,这就是二极管一个比较重要的漏电流参数。
当外部施加的电场是从 P 到 N 时,即 PN 结正偏。外界电场的效果是使耗尽层变窄,加强了 P 区内空穴往 N 区内移动的能力,扩散电流远大于漂移电流,形成了一个正向导通电流。
最终二极管将呈现如下的导通特性,当正向电压大于势垒电压时,二极管开始导通。当施加反向电压时,二极管将截止,当反向电压大到一定程度后,二极管就会被反向击穿,即二极管损坏的过程。
功率二极管
既然谈到了「功率」二字,那么更加关注的就是二极管承载电流、电压的能力了。如何把二极管承载电流、电压的能力加强呢?根据上文关于二极管的介绍可以知道,将耗尽层加宽可以承载更大的电压。
图中中间 n- 为轻度参杂区域,下面 n+ 为重度参杂区域,这个参杂就导致了耗尽层的加宽,当然也导致导通损耗更大,不过也正因为如此,功率二极管才更加能耐压。
我们以非同步 BUCK 电路为载体,来说明一下功率二极管的变化过程。
图中(1)部分指二极管导通,有一个小小的二极管导通压降,因此曲线没有贴着 x 轴;
图中(2)的位置由于二极管承受的是反向电压,此时它关断了,所以电压为负;
图中(3)二极管需要经历一个从没有电压到有外加电压的变化,当电压加到二极管上时,二极管中的载流子流动的趋势逐渐增大,宏观表现出来是电阻慢慢变小的过程,但是电流保持不变,所有会有一个小尖峰,这一小段时间也会导致整体功率的损耗,开关频率越高,这个导通过程导致的损耗越多;
图中(4)处伴随系统从通到断的状态变化,大规模载流子需要进行重新分配,这个重新分配表现出来就是电流,而且这个电流与主电流相反,所以会看到一个反向的电流,而且这个反向电流会施加在主电路里面。这一段反向电流又分为两部分,下降阶段是之前外加电压时,PN 结中从 P 区域移动到 N 区域的载流子移除(恢复)过程,即从正偏到反偏的过程,正偏时空间电荷区非常非常窄,此时要进入反偏状态,空间电荷区需要加强,载流子需要重新分配,外部激励会移除不必要的空间电荷。电流上升的过程,即二极管又变成一个耐压器件了,也就是空间电荷区加宽,更多的载流子会不均匀的分布在两端。整个过程不可避免的需要移动电荷,而电荷的聚集效应可以认为就是一个电容的效应,当我们需要施加电压时,电压的增加就会需要额外的电荷,电荷不断聚集提供相反电荷,使其电压不断增加,以致增加到刚好截止输出电压为止。
MOS 管
以 NMOS 为例,它以 P 型半导体衬底,以 N 型半导体作为导电沟道,金属部分作为栅极(Gate),氧化部分(SiO2)作为绝缘层,两端分别为源极(Source)和漏极(Drain),从物理结构可以看出 MOS 管的源极和漏极是可以互换的,不像三极管有严格的顺序。
在栅极和源极施加电压,随着电压的不断增大,导电沟道将逐渐形成,当导电沟道刚好形成时的电压,称之为开启电压。外加电压继续增大,导电沟道将变得越来越宽,即导电能力越来越强。
PMOS 相比 NMOS 更加容易驱动,只需要 VGS 小于一定值即可导通。但是 PMOS 的导通电阻比 NMOS 要大,并且成本也比 NMOS 要高,所以比 NMOS 的实际应用场景要少许多。
功率 MOS 管
对比前文普通 MOS 管,可以看到源极、栅极、漏极是分开的,顶上那个灰色的板子是金属板。而功率 MOS 管在这个基础上做了一点创新,下图中的阴影部分就是金属板,可以发现总共只有两个金属板,上面的金属板把 N 区和 P 区都给连起来了,所以即使在栅极没有加电压的时候,也会存在一个天然的二极管通道,但是普通 MOS 管是没有体二极管通道存在的。同时由于是功率 MOS 管,所以也会想办法将耗尽层加宽,以增加其耐压能力。
体二极管和耐压能力的加强是功率 MOS 和普通 MOS 的区别。
功率 MOS 管的正向导通能力就是涉及「场效应」了,所谓的场效应即意味着外部可以通过电场来控制其内部载流子的浓度,在栅极施加正电压时就会产生一个电子的导电沟道,由于整体是 N 型半导体衬底,所以整体也就形成了一个电子的导电沟道,并且该沟道支持电子的双向移动。
如下图所示是功率 MOS 管的等效电路模型。其主要损耗由三部分组成,分别为导通损耗、开关损耗(开通损耗和关断损耗)、驱动损耗。其中导通损耗与开关损耗容易理解,驱动损耗作何理解呢?MOS 并不像二极管是一个被动型器件,MOS 管开或关的行为都需要能量作为代价,就好比要打开机械开关需要用手去按压,这个过程所消耗的能量就是驱动损耗。
晶体管
二极管只有一个 P 型半导体和一个 N 型半导体结合,如果再加一个 N 型半导体(或 P 型半导体)即构成了晶体管(三极管),晶体管有集电极、发射极、基极三个极。
需要注意的是三极管的集电区和发射区掺杂浓度是不一样的,其中基区多子少且做的很薄,而发射区的多子浓度很高,集电区多子浓度相对较低但面积大。不管三极管是正接还是反接,三极管都处于截止状态,这是因为三极管可以看作两个二极管反向相连,不论如何接都会有一个二极管处于截止状态。
为了能让三极管导通,我们在基极和发射极再施加一个电压,此时二极管开始导通,发射区的自由电子就可以源源不断的流向基区,但是基区的掺杂浓度很低且很薄,基区短时间内吸收不了太多的电子,只有一少部分电子能与空穴复合形成基极电流,而大部分被吸引到了集电区,形成集电极电流,也就是三极管的输出电流。
流过基极的电流越大,流到基区的自由电子也就越多,相应的被吸引到集电区的电子也就更多,这就是三极管小电流控制大电流的原理。基区做的很薄是为了让发射区的电子更容易进入集电区,浓度很低视为了形成更小的基极电流,这样才会有更多的自由电子流向集电区。
IGBT
三极管工作时涉及载流子的注入和抽离所以会很慢,由于其性能的关系正在逐步退出历史舞台,因此需要对其进行改进,改进后的器件就是 IGBT,如下图所示。
可以发现 IGBT 是一个受 MOS 管控制的 BJT,即同时继承了 MOS 管快速和 BJT 大电流的优点。当然,它也有缺点,并且缺点主要来自于 BJT 关断较慢的问题,因为当 MOS 管门级信号撤出时,并不能立马把电流都抽走,所以电流会经历一段下降时间。
Read More ~