打开游戏后,背景是一张带有淡黄色的方格纸,所有的元素(主角、平台、道具等)看起来都像是用彩笔随手涂鸦上去的,线条有些许的不规整,但是却显得灵动、亲切。
游戏主角是一个亮绿色、有四条小短腿、长着一个漏斗状长鼻子的外星生物。当按下空格时它会向上蹦跳,整个过程中都可以通过左右方向键控制游戏主角左右移动,蹦跳时借助绿色平台不断攀升到新的高度,以此来获得更高的分数。
在游戏主角向上攀升的过程中,偶尔会运气好遇到一些加速神器,帮助玩家更快的获得分数,这些加速神器包括弹簧、竹蜻蜓、火箭喷气背包。
当游戏主角踩到弹簧时会发出清脆的「叮」的声音,并让游戏主角获得比自主跳跃要更高的跳跃高度。如果游戏主角踩到竹蜻蜓,竹蜻蜓会戴在游戏主角头顶,并像直升机一样拖着游戏主角飞行一段距离,期间会发出直升机螺旋桨转动的声音。若游戏主角有幸遇到了火箭喷气背包,它可以获得比竹蜻蜓更快的飞行速度,并且会带着游戏主角飞行更久的时间,自然也会获得更多的分数。
整个游戏没有终点,只有不断增加的分数。在游戏主角跳跃过程中始终都会有重力存在,因此若游戏主角下落过程中没有遇到平台便会一直下坠,直到坠落超过底部则 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 ~
标签:#
Python
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 ~
使用订阅号实现微信公众号历史文章爬虫
微信公众号已经成为生活的一部分了,虽然里面有很多作者只是为了蹭热点,撩读者的 G 点,自己从中获得一些收益;但是不乏好的订阅号,像刘大的码农翻身、Fenng的小道消息、曹大的caoz的梦呓等订阅号非常值得阅读。
平时有时候看到一些好的公众号,也会不自觉去查看该公众号的历史文章,然而每次都看不完,下一次再从微信里面打开历史文章,又需要从头翻起。而且对于写了很多年的大号,每次还翻不到底。有一些平台提供了相关的服务,但是得收几十块钱的费用,倒不是缺几十块钱,主要是觉得这种没必要花的钱不值得去浪费。
网上搜如何爬微信公众号历史文章,大致给了三种思路,第一是使用搜狗微信搜索文章,但是好像每次能搜到的不多;第二是使用抓包工具;第三种是使用个人订阅号进行抓取。
简单来说就是使用程序来模拟人的操作,抓取公众号历史文章。首先登录微信公众号个人平台,期间需要管理员扫码才能登录成功。
def __open_gzh(self):
self.driver.get(BASE_URL)
self.driver.maximize_window()
username_element = self.driver.find_element_by_name("account")
password_element = self.driver.find_element_by_name("password")
login_btn = self.driver.find_element_by_class_name("btn_login")
username_element.send_keys(USERNAME)
password_element.send_keys(PASSWORD)
login_btn.click()
WebDriverWait(driver=self.driver, timeout=200).until(
ec.url_contains("cgi-bin/home?t=home/index")
)
# 一定要设置这一步,不然公众平台菜单栏不会自动展开
self.driver.maximize_window()
进入微信公众平台首页后,点击素材管理,然后点击新建图文素材,就会进入到文章写作页面,此时前面打开的微信公众平台首页就不需要了,可以将其关闭。
def __open_write_page(self):
management = self.driver.find_element_by_class_name("weui-desktop-menu_management")
material_manage = management.find_element_by_css_selector("a[title='素材管理']")
material_manage.click()
new_material = self.driver.find_element_by_class_name("weui-desktop-btn_main")
new_material.click()
# 关闭公众平台首页
handles = self.driver.window_handles
self.driver.close()
self.driver.switch_to_window(handles[1])
在文章写作页面的工具栏上面有一个超链接按钮,点击超链接即会弹出超链接编辑框,选择查找文章,输入自己喜欢的公众号进行查找,一般第一个就是自己想要的结果,点击对应的公众号,该公众号所有的文章就会通过列表的形式展现出来。
def __open_official_list(self):
# 超链接
link_click = self.driver.find_element_by_class_name("edui-for-link")
link_click.click()
time.sleep(3)
# 查找文章
radio = self.driver.find_element_by_class_name("frm_vertical_lh").find_elements_by_tag_name("label")[1]
radio.click()
# 输入查找关键字
search_input = self.driver.find_element_by_class_name("js_acc_search_input")
search_input.send_keys(OFFICIAL_ACCOUNT)
search_btn = self.driver.find_element_by_class_name("js_acc_search_btn")
search_btn.click()
# 等待5秒,待公众号列表加载完毕
time.sleep(5)
result_list = self.driver.find_element_by_class_name("js_acc_list").find_elements_by_tag_name("div")
result_list[0].click()
文章列表已经展现出来了,直接抓取每条文章超链接的信息即可,每抓取完一页就进入下一页,继续抓取文章列表信息,直到所有文章信息都抓取完毕。
def __get_article_list(self):
# 等待文章列表加载
time.sleep(5)
total_page = self.driver.find_element_by_class_name("search_article_result")\
.find_element_by_class_name("js_article_pagebar").find_element_by_class_name("page_nav_area")\
.find_element_by_class_name("page_num")\
.find_elements_by_tag_name("label")[1].text
total_page = int(total_page)
articles = []
for i in range(0, total_page-1):
time.sleep(5)
next_page = self.driver.find_element_by_class_name("search_article_result")\
.find_element_by_class_name("js_article_pagebar").find_element_by_class_name("pagination")\
.find_element_by_class_name("page_nav_area").find_element_by_class_name("page_next")
article_list = self.driver.find_element_by_class_name("js_article_list")\
.find_element_by_class_name(" my_link_list").find_elements_by_tag_name("li")
for article in article_list:
article_info = {
"date": article.find_element_by_class_name("date").text,
"title": article.find_element_by_tag_name("a").text,
"link": article.find_element_by_tag_name("a").get_attribute("href")
}
articles.append(article_info)
next_page.click()
return articles
至此,微信公众号历史文章的爬虫已经实现,其实整个过程只不过是用程序来模拟的了人类的操作。需要注意的是,程序不能设置太快,因为微信做了相关限制,所以设太快会在一段时间内无法使用文章查找功能;另外一点是使用选择器选择页面元素的时候,会有一些坑,而且我发现不同账号登录,有很少部分的页面元素虽然直观上是一样的,但是它的 html 代码有细微的差别。
这个小程序会用到selenium库,和chromedriver,前者直接pip install即可,后者自行下载;另外你还需要一个订阅号才行,本文只实现了关键的文章信息抓取,并没有进行文章信息的持久化存储,完整代码在这里。
Read More ~
跨域请求是什么?如何解决?
参考内容:
JavaScript: Use a Web Proxy for Cross-Domain XMLHttpRequest Calls
别慌,不就是跨域么!
跨域资源共享 CORS 详解
AJAX请求和跨域请求详解(原生JS、Jquery)
JavaScript跨域总结与解决办法
刚毕业入职,大部分时间还在培训,中间有一段时间的空闲时间,就学习了下 Angular,在学校都是编写的单体应用,所有代码都放在同一个工程下面,到公司使用的是前后端分离了,虽然后端程序也是我自己写的,但是有一些数据是从公司现有接口去拿的,然后就遇到让我纠结了两小时的跨域请求问题,在这里做一个简单的总结输出。
什么是跨域请求跨域请求问题是浏览器的同源策略造成的,该策略不允许执行其它网站的脚本,是浏览器施加的安全限制。什么是同源?最初是指网页 A 设置的 Cookie 不能被网页 B 打开,包括三个相同:协议、域名、端口。这个同源是从 URL 判断的,不是从 IP 判断的,如果同一个服务器对应连个域名,这两个域名是不同源的。
http://www.nealyang.cn/index.html 调用 http://www.nealyang.cn/server.php 非跨域
http://www.nealyang.cn/index.html 调用 http://www.neal.cn/server.php 跨域,主域不同
http://abc.nealyang.cn/index.html 调用 http://def.neal.cn/server.php 跨域,子域名不同
http://www.nealyang.cn:8080/index.html 调用 http://www.nealyang.cn/server.php 跨域,端口不同
https://www.nealyang.cn/index.html 调用 http://www.nealyang.cn/server.php 跨域,协议不同
localhost 调用 127.0.0.1 跨域
同源政策的目的是为了保护用户信息的安全,防止恶意网站窃取数据,随着互联网的发展,同源政策更加严格了,下面三种行为都会受到限制。
(1) Cookie、LocalStorage 和 IndexDB 无法读取。
(2) DOM 无法获得。
(3) AJAX 请求不能发送。
所有的现代浏览器都对网络连接进行了安全限制,包括 XMLHttpRequest,如果你的 web 应用程序和其使用的数据在同一个服务器,你不会遇到跨域请求问题。但是当你的 web 应用程序和 web 服务数据不在同一个服务器时,就会被浏览器限制连接了。
常用解决方案 对于跨域请求有很多的解决方案,最常用的解决方案是在你的 web 服务器上面设置代理。在设置代理之前就通过,应用程序直接去请求另一个服务器下的数据;设置代理之后,应用程序从自己的 web 服务器中请求数据,再由代理去请求数据,这样 web 服务器拿到数据之后返回给应用程序即可。从浏览器角度看,就是从同一个服务器拿的数据,并没有进行跨域请求。
通俗易懂的说,你家的宠物狗不会吃别家的食物,因为它担心别人的食物会把自己给药死,所以你的狗狗只管找你要食物,你是它的主人,它绝对相信你,而你可以鉴别别人给的食物是不是安全的。类比,小狗就是浏览器,你就是代理。
Angular 中的解决办法上面所说的解决方案在开发过程中不方便操作,每新发一个接口都到服务器中去配置一下,不仅麻烦而且效率低下。首先说一下在 Angular 中一个人比较常用的解决方法,默认你在使用angular-cli构建你的项目,我们可以创建一个代理配置文件proxy.conf.json(假设你的后端服务的访问地址为10.121.163.10:8080),代理配置文件如下:
{
"/api": {
"target": "http://10.121.163.10:8080",
"secure": false
}
}
然后修改package.json文件中的启动命令为"start": "ng serve --proxy-config proxy.conf.json",启动项目时使用npm start即可解决跨域请求问题。
上述解决方案仅在开发时使用,你当然可以使用 tomcat、nginx 配置代理,但是这很麻烦,需要打包代码部署,为了保证效率,我们想写完了立刻测试,同时也不想麻烦做后端的同学,在项目发布时,应该把代理配置到服务器中去;修改启动命令也不是必须的,你也可以选择每次使用 ng serve --proxy-config proxy.conf.json命令启动项目;示例代理配置文件内容可以有更多的属性,可以通过网络查阅相关资料。
后端解决办法我的后端是是用 tornado 实现的,然后我又写了一个单独的页面用于在大屏幕上展示相关数据,没有用 Angular 了,要通过 AJAX请求数据,又怎么解决跨域请求问题呢?这时就需要设置请求头了,让后端允许跨域请求。
这时需要了解一下简单请求和非简单请求了,简单请求就是只发送一次请求的请求;非简单请求会发送数据之前先发一次请求做预检,通过预检后才能再发送一次请求用于数据传输。
更清晰区别,满足下列两大条件的属于简单请求,而非简单请求就是请求方法为PUT或DELETE,或者 Content-Type字段是application/json的请求。
1.请求方法为 GET、POST、HEAD之一
2.HTTP头信息不超出字段:Accept、Accept-Language、Content-Language、Last-Event-ID、Content-Type,并且 Content-Type 的值仅限于 application/x-www-form-urlencoded、multipart/form-data、text/plain。
对于简单请求,只需要设置一下响应头就可以了。
class TestHandler(tornado.web.RequestHandler):
def get(self):
self.set_header('Access-Control-Allow-Origin', "*")
# 可以把 * 写成具体的域名
self.write('cors get success')
对于复杂请求,需要设置预检方法,如下所示:
class CORSHandler(tornado.web.RequestHandler):
# 复杂请求方法put
def put(self):
self.set_header('Access-Control-Allow-Origin', "*")
self.write('put success')
# 预检方法设置
def options(self, *args, **kwargs):
#设置预检方法接收源
self.set_header('Access-Control-Allow-Origin', "*")
#设置预复杂方法自定义请求头h1和h2
self.set_header('Access-Control-Allow-Headers', "h1,h2")
#设置允许哪些复杂请求方法
self.set_header('Access-Control-Allow-Methods', "PUT,DELETE")
#设置预检缓存时间秒,缓存时间内发送请求无需再预检
self.set_header('Access-Control-Max-Age', 10)
Read More ~
Scrapy 爬虫框架入门——抓取豆瓣电影 Top250
最好的学习方式就是输入之后再输出,分享一个自己学习scrapy框架的小案例,方便快速的掌握使用scrapy的基本方法。
本想从零开始写一个用Scrapy爬取教程,但是官方已经有了样例,一想已经有了,还是不写了,尽量分享在网上不太容易找到的东西。自己近期在封闭培训,更文像蜗牛一样,抱歉。
Scrapy简介Scrapy是一个为了爬取网站数据,提取结构性数据而编写的应用框架。 可以应用在包括数据挖掘,信息处理或存储历史数据等一系列的程序中。
其最初是为了 页面抓取 (更确切来说, 网络抓取 )所设计的, 也可以应用在获取API所返回的数据(例如 Amazon Associates Web Services ) 或者通用的网络爬虫。
如果此前对scrapy没有了解,请先查看下面的官方教程链接。
架构概览:https://docs.pythontab.com/scrapy/scrapy0.24/topics/architecture.html
Scrapy入门教程:https://docs.pythontab.com/scrapy/scrapy0.24/intro/tutorial.html
爬虫教程首先,我们看一下豆瓣TOP250页面,发现可以从中提取电影名称、排名、评分、评论人数、导演、年份、地区、类型、电影描述。
Item对象是种简单的容器,保存了爬取到得数据。其提供了类似于词典的API以及用于声明可用字段的简单语法。所以可以声明Item为如下形式。
class DoubanItem(scrapy.Item):
# 排名
ranking = scrapy.Field()
# 电影名称
title = scrapy.Field()
# 评分
score = scrapy.Field()
# 评论人数
pople_num = scrapy.Field()
# 导演
director = scrapy.Field()
# 年份
year = scrapy.Field()
# 地区
area = scrapy.Field()
# 类型
clazz = scrapy.Field()
# 电影描述
decsription = scrapy.Field()
我们抓取到相应的网页后,需要从网页中提取自己需要的信息,可以使用xpath语法,我使用的是BeautifulSoup网页解析器,经过BeautifulSoup解析的网页,可以直接使用选择器筛选需要的信息。有一些说明写到代码注释里面去了,就不再赘述。
Chrome 也可以直接复制选择器或者XPath,如下图所示。
class douban_spider(Spider):
count = 1
# 爬虫启动命令
name = 'douban'
# 头部信息,伪装自己不是爬虫程序
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/53.0.2785.143 Safari/537.36',
}
# 爬虫启动链接
def start_requests(self):
url = 'https://movie.douban.com/top250'
yield Request(url, headers=self.headers)
# 处理爬取的数据
def parse(self, response):
print('第', self.count, '页')
self.count += 1
item = DoubanItem()
soup = BeautifulSoup(response.text, 'html.parser')
# 选出电影列表
movies = soup.select('#content div div.article ol li')
for movie in movies:
item['title'] = movie.select('.title')[0].text
item['ranking'] = movie.select('em')[0].text
item['score'] = movie.select('.rating_num')[0].text
item['pople_num'] = movie.select('.star span')[3].text
# 包含导演、年份、地区、类别
info = movie.select('.bd p')[0].text
director = info.strip().split('\n')[0].split(' ')
yac = info.strip().split('\n')[1].strip().split(' / ')
item['director'] = director[0].split(': ')[1]
item['year'] = yac[0]
item['area'] = yac[1]
item['clazz'] = yac[2]
# 电影描述有为空的,所以需要判断
if len(movie.select('.inq')) is not 0:
item['decsription'] = movie.select('.inq')[0].text
else:
item['decsription'] = 'None'
yield item
# 下一页:
# 1,可以在页面中找到下一页的地址
# 2,自己根据url规律构造地址,这里使用的是第二种方法
next_url = soup.select('.paginator .next a')[0]['href']
if next_url:
next_url = 'https://movie.douban.com/top250' + next_url
yield Request(next_url, headers=self.headers)
然后在项目文件夹内打开cmd命令,运行scrapy crawl douban -o movies.csv就会发现提取的信息就写入指定文件了,下面是爬取的结果,效果很理想。
Read More ~
知识图谱如何构建?——经济责任审计知识图谱构建案例实战
参考:
汉语言处理包 HanLP:https://github.com/hankcs/HanLP
中文文本分类:https://github.com/gaussic/text-classification-cnn-rnn
农业知识图谱:https://github.com/qq547276542/Agriculture_KnowledgeGraph
事实三元组抽取:https://github.com/twjiang/fact_triple_extraction
中文自然语言处理相关资料:https://github.com/mengxiaoxu/Awesome-Chinese-NLP
开放中文实体关系抽取:http://www.docin.com/p-1715877509.html
自 2012 年 Google 提出“知识图谱”的概念以来,知识图谱就一直是学术研究的重要方向,现在有很多高校、企业都致力于将这项技术应用到医疗、教育、商业等领域,并且已经取得了些许成果。Google 也宣布将以知识图谱为基础,构建下一代智能搜索引擎。
现在已经可以在谷歌、百度、搜狗等搜索引擎上面看到知识图谱的应用了。比如在 Google 搜索某个关键词时,会在其结果页面的右边显示该关键字的详细信息。在几个常用的搜索引擎中搜索知识时,返回的答案也变得更加精确,比如搜索“汪涵的妻子”,搜索引擎会直接给出答案“杨乐乐”,方便了用户快速精准的获取想要的信息。不过目前的搜索引擎只有少部分搜索问题能达到这种效果。
关于知识图谱是什么,我想就不用介绍了,这种通过搜索引擎就能轻松得到的结果写在这里有点浪费篇章,并且我对知识图谱的理解也不深,不敢夸夸其谈,只是把自己这一段时间以来的工作做一个总结。
本文只相当于以经济责任审计这一特定领域构建了一个知识图谱,仅仅是走了一遍流程,当作入门项目,构建过程中参考甚至抄袭了别人的很多方法与代码,末尾都会给出参考的项目等等。
上图是我构建经济责任审计知识图谱的流程,看起来很繁琐,但只要静下心看,个人觉得相对还算清晰,箭头都有指向。下面就一步一步进行说明。
数据获取数据获取主要分为两部分数据,一部分是新闻类数据,我把它用作文本分类模型的训练集;另一部分是实体数据,为了方便,我直接把互动百科抓取的词条文件作为实体,省了属性抽取这一环节。
因为本文构建的是一个经济责任审计领域的知识图谱,所以作为文本分类模型训练集的数据也应该是经济责任审计领域的。这里主要抓取了审计署、纪检委、新浪网的部分新闻。
像上面的图一样,新闻类网站一般都有搜索框,为了简单,所以我直接用搜索框搜索“经济责任审计”,然后从搜索结果中抓取新闻数据,即认为是经济责任审计相关的文本。抓取新闻类网站使用了 chrome 模拟用户进行访问。最终获得了 3500 多条新闻文本。
领域词汇判定领域词汇判定,本文构建的不是开放领域的知识图谱,所以需要采用一种方法来判定所抓取的内容是否属于经济责任审计领域。领域词汇本文的方法实际上是领域句子判定,直接使用了大神的项目。CNN-RNN中文文本分类,基于tensorflow。也看到有人通过改进逻辑回归算法,在进行领域词汇的判定。
我判定领域词汇的逻辑是这样的,一个词语即使是人类也不一定能确定它是否属于经济责任审计领域,但是每个词语都会有它的含义解释对不对,一个词语的解释就是一段话。我用网上的新闻训练出一个判断一段话属于哪个领域的模型,然后把词语的解释放到模型了里面去,如果模型给出的结果是属于经济责任审计领域,那则认为这个词语属于经济责任审计领域。
实体关系抽取知识图谱的基本单位为(实体1,关系,实体2)这样的三元组,实体是直接从互动百科获取的词条,关系由两部分组成,一部分来自 wikidata 所提供的关系,这一部分直接从 wikidata 爬取即可得到,另一部分使用的是基于依存句法分析的开放式中文实体关系抽取,已经在前面的文章发过了。
知识存储有了实体和实体关系,那么把这些数据进行筛选,然后入库,通过直观的页面展示,就可以了。这里使用的数据库是 neo4j,它作为图形数据库,用于知识图谱的存储非常方便。知识的展示使用了别人的项目,仅仅是把里面的数据换掉了而已,感谢大神的无私。
当然你也可以选择使用关系型数据库,因为我做的经济责任审计知识图谱不够深入,所以做到最后展示的时候,发现其实用我比较熟悉的 MySql 更好,相比 NOSql 我更熟悉关系型数据库,而且 MySql 有更大的社区在维护,它的 Bug 少、性能也更好。
最后放几张效果图
下面是以“职业”为关系查询条件所得出的结果。
总结一下只是对几个月工作的梳理,大多数核心代码都改自现有的代码,所有的数据都来自于网络,与知识图谱相关的公开技术较少,我也只是尝试着做了一下,虽然很菜,也可以对大致的技术路线、流程有一个简单的了解,主要工作都是自然语言处理的内容。后期可以利用现在的知识图谱构建智能问答系统,实现从 what 到 why 的转换。
以下内容更新于 2020 年 3 月。
在毕业前收到了电子工业出版社和另一家出版社的写书邀请,我和电子工业出版社签订了写书合同,从还未毕业开始断断续续写作了一年的时间,因为自己的懒惰,加上内容中涉及到大量爬虫,而且爬目标网站是政府网站(不允许爬),另外 19 年网上时不时曝出某某程序员因爬虫而入狱的故事,出版社和我难免不会恐惧,我也正好找到了不再继续写下去的理由。
花了点时间把以前的程序,书籍已经写成的内容整理了一下,放在了 economic_audit_knowledge_graph 中,所有资料都在里面,希望能帮助到自然语言入门的小伙伴,我自己已经不做这个领域了!
Read More ~
如何抽取实体关系?——基于依存句法分析的事实三元组抽取
参考:
HanLP 自然语言处理
基于依存分析的开放式中文实体关系抽取方法
命名实体三元组抽取参考自fact_triple_extraction
这一段时间一直在做知识图谱,卡在实体关系抽取这里几个月了,在 Github 上面看到有人使用卷积神经网络训练模型进行抽取,自己也尝试了一下,但是一直苦于没有像样数据去训练,而标注训练集又太费时间了,我不太愿意干体力活。另外自己也不会什么机器学习、深度学习之类的技术,而且毕业设计都是有时间要求的,所以采用了一个低档次的方法,基于依存句法分析的实体关系抽取,记录一下心得,方便日后忘记可以再找回来。
论文给出了 8 种中文关系的表达方式,并且最后给出了一个采用正则表达式语法指出表达,核心就是谓语动词表示关系,即关系表述中一定得有动词。
状语*动词+补语?宾语?
我不太赞同把宾语也当作关系表述的一部分,论文指出“p4生于山西”应该抽出(p4,山西,生于山西),我认为关系不应该表述为“生于山西”,所以我把关系表述改为下面的样子了。
状语*动词+补语?
这篇文章只是作为一个方法介绍,我自己先看了一遍,能够保证我下次看到这篇文章,可以立马回忆起自己的实现方法,希望你看了也能了解方法,看不懂的话,我表示抱歉,浪费您的时间了,我已经尽可能写到简单了。
先来看几个简单句子吧:
主谓宾关系:刘小绪 生于 四川
// 这个三元组很明显:(刘小绪,生于,四川)
动补结构:刘小绪 洗 干净 了 衣服
// 如果套用主谓宾关系就是:(刘小绪,洗,衣服)
// 但是这里描述的是一个状态,是刘小绪把衣服洗干净了
// “干净”是动词“洗”的补语,所以还应该提取出一个如下三元组
// (刘小绪,洗干净了,衣服)
状动结构:父亲 非常 喜欢 跑步
// 这句和上面很像,主谓宾关系是:父亲喜欢跑步
// “非常”用于修饰“喜欢”
// (父亲,非常喜欢,跑步)
介宾关系:刘小绪 就职 于 学校
// 如果直接把这个三元组抽取为(刘小绪,就职,学校),很别扭
// “于”和“学校”是介宾关系,它们的关系应该是:就职于
// (刘小绪,就职于,学校)
宾语前置:海洋 由 水 组成
// “海洋”是“组成”的前置宾语
// “由”是“组成”的状语
// “水”和“由”是介宾关系
// 所以上面的句子没有明确的主谓关系,需要我们判断
// 抽出的三元组应该为:(水,组成,海洋)
HanLP 提供了两种依存句法分析的器,默认采用的是基于神经网络的依存句法分析器。依存句法分析就是将句子分析成一棵依存句法树,描述各个词语之间的依存关系,即指出词语之间在句法上的搭配关系。
有了上面所说的依存句法树,其实我们只需要进行各种判断就可以了。先做出下面的一点说明,就拿第一个例子来说。
原文:刘小绪生于四川
# 这是分词结果
[刘小绪/nr, 生于/v, 四川/ns]
#这是句法分析结果
刘小绪 --(主谓关系)--> 生于
生于 --(核心关系)--> ##核心##
四川 --(动宾关系)--> 生于
为了方便理解,也为了方便程序的编写,我把他们组织成了下面的形式,为每一个词语都建一个依存句法字典。
刘小绪:{}
生于:{主谓关系=[刘小绪], 动宾关系=[四川]}
四川:{}
然后只需要写出类似于下面的程序段就可以抽出关系了。
// 主谓宾关系:刘小绪生于四川
// dic是这个词语的依存句法字典
if (dic.containsKey("主谓关系") && dic.containsKey("动宾关系")){
// 当前的词语,用上面的例子来说,relation=“生于”
String relation = curWord.LEMMA;
// 用循环遍历,是因为关系列表里面不一定只有一个词语
for (CoNLLWord entity1:
dic.get("主谓关系")) {
for (CoNLLWord entity2:
dic.get("动宾关系")) {
System.out.println(entity1.LEMMA + "," + relation + "," + entity2.LEMMA);
}
}
}
对于分词后的每个词语都进行上面程序段的操作。“刘小绪”和“四川”,关系字典都为空。而对于“生于”,关系列表里面既有主谓也有动宾,而自己本身就是动词,主谓宾就出来了。直接从主谓关系中拿出来词语作为 entity1,再拿上自己作为关系,最后拿出动宾关系中的词语作为 entity2。很明确的三元组(刘小绪,生于,四川)就出来了。
最后给出一个程序运行结果图吧。
我个人觉得效果还行,在简单句子上面表现的差强人意,在长句子上面表现的差劲。注意上文使用的第三方包随着时间的推移肯定会改一些接口,源码链接:entity_relation_extraction
Read More ~