评分

让我们来实现一个计分系统,实时跟踪游戏得分,并显示高分、关卡和剩余船只数量。

得分是一个游戏统计数据,因此我们将在 GameStats 中添加一个 score 属性:

game_stats.py
class GameStats:
    --snip--
    def reset_stats(self):
        """Initialize statistics that can change during the game."""
        self.ships_left = self.ai_settings.ship_limit
        self.score = 0

为了在每次新游戏开始时重置分数,我们在 reset_stats() 而不是 __init__() 中初始化分数。

显示分数

要在屏幕上显示分数,我们首先要创建一个新类 Scoreboard。目前,该类将只显示当前分数。最终,我们还将用它来报告高分、等级和剩余船只数。下面是类的第一部分;将其保存为 scoreboard.py

scoreboard.py
import pygame.font

class Scoreboard:
    """A class to report scoring information."""
    def __init__(self, ai_game): (1)
        """Initialize scorekeeping attributes."""
        self.screen = ai_game.screen
        self.screen_rect = self.screen.get_rect()
        self.settings = ai_game.settings
        self.stats = ai_game.stats

        # Font settings for scoring information.
        self.text_color = (30, 30, 30) (2)
        self.font = pygame.font.SysFont(None, 48) (3)

        # Prepare the initial score image.
        self.prep_score() (4)

因为 Scoreboard 会将文本写入屏幕,所以我们首先要导入 pygame.font 模块。接下来,我们给 __init__() 提供 ai_game 参数,这样它就可以访问设置(settings)、屏幕(screen)和统计(stats)对象,它需要这些对象来报告我们要跟踪的值❶。然后,我们设置文本颜色❷ 并实例化一个字体对象❸。

为了把要显示的文本转换成图像,我们调用了 prep_score() ❹,我们在这里定义了它:

scoreboard.py
def prep_score(self):
    """Turn the score into a rendered image."""
    score_str = str(self.stats.score) (1)
    self.score_image = self.font.render(score_str, True, self.text_color, self.settings.bg_color) (2)

    # Display the score at the top right of the screen.
    self.score_rect = self.score_image.get_rect() (3)
    self.score_rect.right = self.screen_rect.right - 20 (4)
    self.score_rect.top = 20 (5)

prep_score() 中,我们将数值 stats.score 转换成字符串 ❶,然后将该字符串传递给 render(),由 render() 创建图像 ❷。为了在屏幕上清晰显示分数,我们将屏幕的背景颜色和文本颜色传递给 render()

我们将把分数显示在屏幕的右上角,并随着分数的增加和数字宽度的增加而向左扩展。为确保分数始终与屏幕右侧对齐,我们创建了一个名为 score_rect ❸的矩形,并将其右侧边缘设置为距离屏幕右侧边缘 20 像素 ❹。然后,我们将顶部边缘设置为距离屏幕顶部❺向下 20 个像素。

然后,我们创建 show_score() 方法来显示渲染的分数图像:

scoreboard.py
def show_score(self):
    """Draw score to the screen."""
    self.screen.blit(self.score_image, self.score_rect)

此方法会在屏幕上 score_rect 指定的位置绘制分数图像。

制作记分牌

为了显示得分,我们将在 AlienInvasion 中创建一个 Scoreboard 实例。首先,让我们更新导入语句:

alien_invasion.py
--snip--
from game_stats import GameStats
from scoreboard import Scoreboard
--snip--

接下来,我们在 __init__() 中创建一个 Scoreboard 实例:

alien_invasion.py
def __init__(self):
    --snip--
    pygame.display.set_caption("Alien Invasion")

    # Create an instance to store game statistics,
    # and create a scoreboard.
    self.stats = GameStats(self)
    self.sb = Scoreboard(self)
    --snip--

然后我们在 _update_screen() 中在屏幕上绘制记分牌:

alien_invasion.py
def _update_screen(self):
    --snip--
    self.aliens.draw(self.screen)

    # Draw the score information.
    self.sb.show_score()

    # Draw the play button if the game is inactive.
    --snip--

在绘制 "播放" 按钮之前,我们会调用 show_score()

现在运行 "异形入侵" 时,屏幕右上方应该会出现一个 0。(此时,我们只想在进一步开发计分系统之前确保分数出现在正确的位置)。图 14-2 显示了游戏开始前的分数。

接下来,我们将为每个外星人分配分值!

image 2023 12 04 12 10 59 335
Figure 1. Figure 14-2: The score appears at the top-right corner of the screen.

外星人被击落时更新分数

为了在屏幕上写入实时分数,每当有外星人被击中,我们就更新 stats.score 的值,然后调用 prep_score() 更新分数图像。但首先,我们要确定玩家每次击落外星人后会得到多少分:

settings.py
def initialize_dynamic_settings(self):
    --snip--

    # Scoring settings
    self.alien_points = 50

随着游戏的进行,我们将增加每个外星人的点数值。为了确保每次新游戏开始时都能重置该点数值,我们在 initialize_dynamic_settings() 中设置了该值。

每击落一个外星人,我们就在 _check_bullet_alien_collisions() 中更新分数:

alien_invasion.py
def _check_bullet_alien_collisions(self):
    """Respond to bullet-alien collisions."""
    # Remove any bullets and aliens that have collided.
    collisions = pygame.sprite.groupcollide(self.bullets, self.aliens, True, True)

    if collisions:
        self.stats.score += self.settings.alien_points
        self.sb.prep_score()
    --snip--

当子弹击中外星人时,Pygame 会返回一个碰撞字典。我们检查字典是否存在,如果存在,外星人的值就会添加到分数中。然后我们调用 prep_score() 为更新后的分数创建一个新图像。

现在,当你玩 "异形入侵" 游戏时,你应该可以获得很多分数了!

重置分数

现在,我们只有在外星人被击中后才会准备新的分数,这在游戏的大部分时间里都有效。但当我们开始新游戏时,在第一个外星人被击中之前,我们仍然会看到旧游戏中的分数。

我们可以通过在开始新游戏时预置分数来解决这个问题:

alien_invasion.py
def _check_play_button(self, mouse_pos):
    --snip--
    if button_clicked and not self.game_active:
        --snip--
        # Reset the game statistics.
        self.stats.reset_stats()
        self.sb.prep_score()
        --snip--

开始新游戏时,我们会在重置游戏统计数据后调用 prep_score()。这样,记分牌上的分数就会变成 0。

确保获得所有命中

按照目前的写法,我们的代码可能会漏掉某些外星人的得分。例如,如果两颗子弹在同一循环中与外星人相撞,或者如果我们制作了一颗超宽子弹来击中多个外星人,那么玩家只能在击中其中一个外星人时获得分数。为了解决这个问题,让我们改进子弹与外星人碰撞的检测方式。

_check_bullet_alien_collisions() 中,任何与外星人相撞的子弹都会成为碰撞字典中的一个键。与每颗子弹相关的值是与之发生碰撞的外星人列表。我们会循环查看碰撞字典中的值,以确保每击中一个外星人都能获得分数:

def _check_bullet_alien_collisions(self):
    --snip--
    if collisions:
        for aliens in collisions.values():
            self.stats.score += self.settings.alien_points * len(aliens)
        self.sb.prep_score()
--snip--

如果已经定义了碰撞(collisions)字典,我们就会循环查看字典中的所有值。请记住,每个值都是被一颗子弹击中的外星人列表。我们将每个异形的值乘以每个列表中的异形数量,然后将该数量与当前分数相加。要测试这一点,请将子弹的宽度改为 300 像素,并验证您用超宽子弹击中的每个外星人都能获得分数;然后将子弹宽度恢复到正常值。

增加积分值

因为每次玩家到达一个新的关卡,游戏的难度都会增加,所以后面关卡中的外星人应该能获得更多的分数。为了实现这一功能,我们将添加代码,以便在游戏速度提高时增加分值:

settings.py
class Settings:
    """A class to store all settings for Alien Invasion."""

    def __init__(self):
        --snip--
        # How quickly the game speeds up
        self.speedup_scale = 1.1
        # How quickly the alien point values increase
        self.score_scale = 1.5 (1)

        self.initialize_dynamic_settings()

    def initialize_dynamic_settings(self):
        --snip--

    def increase_speed(self):
        """Increase speed settings and alien point values."""
        self.ship_speed *= self.speedup_scale
        self.bullet_speed *= self.speedup_scale
        self.alien_speed *= self.speedup_scale

        self.alien_points = int(self.alien_points * self.score_scale) (2)

我们定义了积分增加的速度,称之为 score_scale ❶。速度的小幅增加(1.1)会使游戏迅速变得更具挑战性。但是,要想看到更明显的得分差异,我们需要将外星分值改变得更大(1.5)。现在,当我们提高游戏速度时,我们也增加了每次击中的点值❷。我们使用 int() 函数来增加整数点值。

要查看每个外星人的数值,请在设置中的 increase_speed() 方法中添加 print() 调用:

settings.py
def increase_speed(self):
    --snip--
    self.alien_points = int(self.alien_points * self.score_scale)
    print(self.alien_points)

每达到一个新等级,终端中就会出现新的点值。

在确认点数值正在增加后,请务必删除 print() 调用,否则它可能会影响游戏性能并分散玩家的注意力。

四舍五入

大多数街机射击游戏都以 10 的倍数来报告分数,因此我们的分数也应如此。此外,让我们在分数格式中加入大数字的逗号分隔符。我们将在记分板中进行这一更改:

scoreboard.py
def prep_score(self):
    """Turn the score into a rendered image."""
    rounded_score = round(self.stats.score, -1)
    score_str = f"{rounded_score:,}"
    self.score_image = self.font.render(score_str, True, self.text_color, self.settings.bg_color)
    --snip--

round() 函数通常将浮点数舍入到第二个参数设置的小数位数。然而,当您传递一个负数作为第二个参数时,round() 会将数值舍入到最接近的 10、100、1,000,依此类推。这段代码告诉 Python 将 stats.score 的值四舍五入到最接近的 10,并将其赋值给 rounded_score

然后,我们在分数的 f-string 中使用格式指定符。格式指定符是一个特殊的字符序列,用于修改变量值的显示方式。在本例中,序列 : 告诉 Python 在所提供数值的适当位置插入逗号。这样,字符串就会变成 1,000,000 而不是 1000000。

现在,当您运行游戏时,即使您获得了很多分数,也会看到格式整齐、四舍五入的分数,如图 14-3 所示。

image 2023 12 04 12 25 11 094
Figure 2. Figure 14-3: A rounded score with comma separators

高分数

每个玩家都想打破游戏的最高分,因此让我们来跟踪和报告高分,让玩家有所追求。我们将在 GameStats 中存储高分:

game_stats.py
def __init__(self, ai_game):
    --snip--
    # High score should never be reset.
    self.high_score = 0

因为高分不应该被重置,所以我们在 __init__() 而不是 reset_stats() 中初始化 high_score

接下来,我们将修改 Scoreboard 以显示高分。让我们从 __init__() 方法开始:

scoreboard.py
def __init__(self, ai_game):
    --snip--
    # Prepare the initial score images.
    self.prep_score()
    self.prep_high_score() (1)

高分将与分数分开显示,因此我们需要一个新方法 prep_high_score() 来准备高分图像❶。

下面是 prep_high_score() 方法:

scoreboard.py
def prep_high_score(self):
    """Turn the high score into a rendered image."""
    high_score = round(self.stats.high_score, -1)
    high_score_str = f"{high_score:,}"
    self.high_score_image = self.font.render(high_score_str, True,self.text_color, self.settings.bg_color)

    # Center the high score at the top of the screen.
    self.high_score_rect = self.high_score_image.get_rect()
    self.high_score_rect.centerx = self.screen_rect.centerx
    self.high_score_rect.top = self.score_rect.top

我们将高分四舍五入到最接近的 10,并用逗号❶ 进行格式化。然后,我们根据高分❷ 生成一张图片,将高分矩形水平居中❸,并将其 top 属性设置为与分数图片的顶部相匹配❹。

现在,show_score() 方法会在屏幕右上方绘制当前分数,在屏幕中央上方绘制高分:

def show_score(self):
    """Draw score to the screen."""
    self.screen.blit(self.score_image, self.score_rect)
    self.screen.blit(self.high_score_image, self.high_score_rect)

为了检查高分,我们将在 Scoreboard 中编写一个新方法 check_high_score()

scoreboard.py
def check_high_score(self):
    """Check to see if there's a new high score."""
    if self.stats.score > self.stats.high_score:
        self.stats.high_score = self.stats.score
        self.prep_high_score()

方法 check_high_score() 将当前分数与高分进行比较。如果当前得分高于高分,我们就会更新 high_score 的值,并调用 prep_high_score() 更新高分的图像。

_check_bullet_alien_collisions() 中更新分数后,每次有外星人被撞击时,我们都需要调用 check_high_score()

alien_invasion.py
def _check_bullet_alien_collisions(self):
    --snip--
    if collisions:
        for aliens in collisions.values():
            self.stats.score += self.settings.alien_points * len(aliens)
        self.sb.prep_score()
        self.sb.check_high_score()
    --snip--

当碰撞字典存在时,我们会调用 check_high_score(),并在更新所有被击中的外星人的分数后调用 check_high_score()

第一次玩 "外星入侵" 时,您的分数将是最高分,因此会显示为当前分数和最高分。但当你开始第二局游戏时,你的高分应该显示在中间,而当前分数应该显示在右边,如图 14-4 所示。

image 2023 12 04 12 34 09 823
Figure 3. Figure 14-4: The high score is shown at the top center of the screen.

显示级别

要在游戏中显示玩家的等级,我们首先需要在 GameStats 中设置一个属性,代表当前等级。要在每个新游戏开始时重置等级,请在 reset_stats() 中对其进行初始化:

game_stats.py
def reset_stats(self):
    """Initialize statistics that can change during the game."""
    self.ships_left = self.settings.ship_limit
    self.score = 0
    self.level = 1

为了让 Scoreboard 显示当前级别,我们在 __init__() 中调用了一个新方法 prep_level()

scoreboard.py
def __init__(self, ai_game):
    --snip--
    self.prep_high_score()
    self.prep_level()

下面是 prep_level()

scoreboard.py
def prep_level(self):
"""Turn the level into a rendered image."""
level_str = str(self.stats.level)
self.level_image = self.font.render(level_str, True, self.text_color, self.settings.bg_color) (1)

# Position the level below the score.
self.level_rect = self.level_image.get_rect()
self.level_rect.right = self.score_rect.right (2)
self.level_rect.top = self.score_rect.bottom + 10 (3)

prep_level() 方法根据存储在 stats.level ❶ 中的值创建图像,并设置图像的右属性与分数的右属性❷ 匹配。然后将顶部属性设置为分数图像底部下方 10 个像素,以便在分数和级别❸ 之间留出空间。

我们还需要更新 show_score()

scoreboard.py
def show_score(self):
    """Draw scores and level to the screen."""
    self.screen.blit(self.score_image, self.score_rect)
    self.screen.blit(self.high_score_image, self.high_score_rect)
    self.screen.blit(self.level_image, self.level_rect)

这一行会将关卡图像绘制到屏幕上。

我们将在 _check_bullet_alien_collisions() 中递增 stats.level,并更新关卡图像:

alien_invasion.py
def _check_bullet_alien_collisions(self):
    --snip--
    if not self.aliens:
        # Destroy existing bullets and create new fleet.
        self.bullets.empty()
        self._create_fleet()
        self.settings.increase_speed()

        # Increase level.
        self.stats.level += 1
        self.sb.prep_level()

如果舰队被摧毁,我们会递增 stats.level 的值并调用 prep_level(),以确保正确显示新的关卡。

为确保在新游戏开始时正确更新关卡图像,我们还会在玩家点击 "播放" 按钮时调用 prep_level()

alien_invasion.py
def _check_play_button(self, mouse_pos):
    --snip--
    if button_clicked and not self.game_active:
        --snip--
        self.sb.prep_score()
        self.sb.prep_level()
        --snip--

我们在调用 prep_score() 之后立即调用 prep_level()

如图 14-5 所示,现在你将看到自己完成了多少关卡。

image 2023 12 04 12 41 48 015
Figure 4. Figure 14-5: The current level appears just below the current score.

在一些经典游戏中,分数都有标签,如得分、最高分和等级。我们省略了这些标签,因为只要玩过游戏,每个数字的含义就会一目了然。要包含这些标签,请在 Scoreboard 中调用 font.render() 之前将其添加到分数字符串中。

显示船舶数量

最后,让我们来显示玩家所剩的飞船数量,不过这次我们要使用图形。为此,我们将在屏幕左上角绘制船只,以表示还剩下多少艘船,就像许多经典街机游戏那样。

首先,我们需要让 Ship 继承自 Sprite,这样就可以创建一组船只:

import pygame
from pygame.sprite import Sprite

class Ship(Sprite): (1)
    """A class to manage the ship."""

    def __init__(self, ai_game):
        """Initialize the ship and set its starting position."""
        super().__init__() (2)
        --snip--

在这里,我们导入 Sprite,确保 Ship 继承自 Sprite ❶,并在 __init__() ❷的开头调用 super()

接下来,我们需要修改 Scoreboard 来创建一组可以显示的飞船。下面是 Scoreboard 的导入语句:

scoreboard.py
import pygame.font
from pygame.sprite import Group

from ship import Ship

因为我们要制作一组飞船,所以要导入 GroupShip 类。

下面是 __init__()

scoreboard.py
def __init__(self, ai_game):
    """Initialize scorekeeping attributes."""
    self.ai_game = ai_game
    self.screen = ai_game.screen
    --snip--
    self.prep_level()
    self.prep_ships()

我们将游戏实例分配给一个属性,因为我们需要它来创建一些飞船。我们在调用 prep_level() 之后调用 prep_ships()

下面是 prep_ships()

scoreboard.py
def prep_ships(self):
    """Show how many ships are left."""
    self.ships = Group() (1)
    for ship_number in range(self.stats.ships_left): (2)
        ship = Ship(self.ai_game)
        ship.rect.x = 10 + ship_number * ship.rect.width (3)
        ship.rect.y = 10 (4)
        self.ships.add(ship) (5)

prep_ships() 方法会创建一个空组 self.ships,用于保存飞船实例 ❶。为了填充这个组,我们会为玩家离开的每一艘飞船运行一次循环❷。在这个循环中,我们会创建一艘新的飞船,并设置每艘飞船的 x 坐标值,这样飞船就会相邻出现,并在飞船组 ❸ 的左侧留出 10 个像素的边距。我们将 Y 坐标值设置为从屏幕顶部向下 10 个像素,这样飞船就会出现在屏幕的左上角 ❹。然后,我们将每艘新船只添加到船只组 ❺。

现在我们需要将船只绘制到屏幕上:

scoreboard.py
def show_score(self):
    """Draw scores, level, and ships to the screen."""
    self.screen.blit(self.score_image, self.score_rect)
    self.screen.blit(self.high_score_image, self.high_score_rect)
    self.screen.blit(self.level_image, self.level_rect)
    self.ships.draw(self.screen)

为了在屏幕上显示战舰,我们在组上调用 draw(),然后 Pygame 会绘制每艘战舰。

为了向玩家显示他们一开始有多少艘飞船,我们在新游戏开始时调用 prep_ships()。我们在《AlienInvasion》中的 _check_play_button() 中这样做:

alien_invasion.py
def _check_play_button(self, mouse_pos):
    --snip--
    if button_clicked and not self.game_active:
        --snip--
        self.sb.prep_level()
        self.sb.prep_ships()
        --snip--

我们还会在船只被击中时调用 prep_ships(),以便在玩家失去船只时更新船只图像的显示:

alien_invasion.py
def _ship_hit(self):
    """Respond to ship being hit by alien."""
    if self.stats.ships_left > 0:
        # Decrement ships_left, and update scoreboard.
        self.stats.ships_left -= 1
        self.sb.prep_ships()
        --snip--

我们在减小 ships_left 的值后调用 prep_ships(),因此每次摧毁船只时都会显示正确的剩余船只数。

图 14-6 显示了完整的计分系统,剩余船只显示在屏幕左上方。

image 2023 12 04 12 51 38 898
Figure 5. Figure 14-6: The complete scoring system for Alien Invasion
亲身体验

14-5. 历史最高分:每次玩家关闭并重新启动《外星入侵》时,高分都会重置。修复方法是在调用 sys.exit() 之前将高分写入文件,并在 GameStats 中初始化高分值时读入高分。

14-6. 重构:查找执行多项任务的方法,并对其进行重构,以组织代码并提高效率。例如,将 _check_bullet_alien_collisions() 中的部分代码移到名为 start_new_level() 的函数中,该函数用于在外星人舰队被摧毁后启动新关卡。此外,将 Scoreboard__init__() 方法中的四个独立方法调用移至名为 prep_images() 的方法中,以缩短 __init__() 的时间。如果已经重构了 _check_play_button()prep_images() 方法还可以帮助简化 _check_play_button()start_game()

在尝试重构项目之前,请参阅附录 D,了解如果在重构过程中引入错误,如何将项目恢复到工作状态。

14-7. 扩展游戏:想办法扩展《外星人入侵》。例如,你可以让外星人向你的飞船发射子弹。你还可以为你的飞船添加护盾,让它躲在护盾后面,护盾可以被来自两侧的子弹摧毁。你也可以使用类似 pygame.mixer 模块的东西来添加音效,比如爆炸声和射击声。

14-8. Sideways Shooter 的最终版本:继续开发 Sideways Shooter,使用我们在本项目中所做的一切。添加 "播放" 按钮,使游戏在适当的时候加速,并开发一个计分系统。请务必在工作过程中重构代码,并寻找机会在本章展示的内容之外定制游戏。

总结

在本章中,您将学习如何实现 "开始新游戏" 的 "播放" 按钮。你还学会了如何检测鼠标事件并在活动游戏中隐藏光标。你还可以利用所学知识创建其他按钮,如显示游戏玩法说明的 "帮助" 按钮。您还学会了如何随着游戏的进行修改游戏速度、实施累进计分系统以及以文本和非文本方式显示信息。