Python 现代开发学习笔记

(十八)由当前状态绘制整幅游戏画面

1. 将模型与精灵逐项同步

现在 World 已经保存玩家、障碍和每块障碍的 uid,继续在 sprites.py 中加入场景同步:

# 续写同一文件:dodge_project/src/dodgegame/sprites.py
class SceneSprites:
    def __init__(self, images, world):
        self.images = images
        self.obstacles = pygame.sprite.Group()
        self.by_id = {}
        self.player = BodySprite(world.player, images.get("player", (48, 36)))
        self.players = pygame.sprite.Group(self.player)

    def sync(self, world):
        # 重开会建立新的 Player,显示层每帧连接当前模型,避免保留上一局对象。
        self.player.body = world.player
        self.player.sync()
        live_ids = {body.uid for body in world.obstacles}
        for uid in set(self.by_id) - live_ids:
            self.by_id.pop(uid).kill()
        for body in world.obstacles:
            image = self.images.get("obstacle", (body.width, body.height))
            if body.uid not in self.by_id:
                self.by_id[body.uid] = BodySprite(body, image)
                self.obstacles.add(self.by_id[body.uid])
            sprite = self.by_id[body.uid]
            sprite.body, sprite.image = body, image
            sprite.rect.size = image.get_size()
            sprite.sync()

每帧先给玩家精灵连接本局的 Player,再同步坐标。重开建立新 Player 后,显示层不会继续拿着上一局对象。障碍按 uid 找到精灵,新出现就加入,已经离屏就从映射和组中移除。

逻辑可以在没有窗口时检查,画面也可以用同一份状态重复绘制。绘制一次不会让障碍多落一步,画两次也不会多加一分。

2. 先准备字体、区域与按钮

# 项目文件:dodge_project/src/dodgegame/view.py
# 每帧从当前世界重画背景、精灵、界面和覆盖层,绘制不修改分数或坐标。
import pygame

from .config import BOTTOM, HEIGHT, LEFT, RIGHT, TOP, WIDTH
from .models import Mode
from .sprites import SceneSprites

INK = "#24324A"
MUTED = "#637189"
INDIGO = "#4657B5"
TEAL = "#087F82"
AMBER = "#A85D11"
PAPER = "#F5F7FB"
LINE = "#D9E0EC"


class Renderer:
    def __init__(self, images, world):
        self.sprites = SceneSprites(images, world)
        self.small = pygame.font.Font(None, 23)
        self.body = pygame.font.Font(None, 29)
        self.large = pygame.font.Font(None, 49)
        self.arena = pygame.Rect(LEFT, TOP, RIGHT - LEFT, BOTTOM - TOP)
        self.menu_button = pygame.Rect(258, 370, 284, 58)
        self.pause_button = pygame.Rect(642, 28, 126, 40)

    def text(self, screen, message, font, color, position, centered=False):
        image = font.render(message, True, color)
        rect = (
            image.get_rect(center=position)
            if centered
            else image.get_rect(topleft=position)
        )
        screen.blit(image, rect)

    def button(self, screen, rect, label, mouse):
        fill = TEAL if rect.collidepoint(mouse) else INDIGO
        pygame.draw.rect(screen, fill, rect, border_radius=12)
        self.text(screen, label, self.body, "white", rect.center, centered=True)

文字先 render 成图像,再按左上角或中心放置;按钮的绘制和点击判断使用同一 Rect。游戏区域上边为 108,下边为 568,顶部空间留给标题、分数和暂停按钮。

3. 清屏后按层次合成

# 续写同一文件:dodge_project/src/dodgegame/view.py
    def draw(self, screen, world, muted=False, debug=False, fps=0, mouse=(-1, -1)):
        screen.fill(PAPER)
        self.text(screen, "NOTEBOOK ARCADE  /  01", self.small, INDIGO, (32, 18))
        self.text(screen, "DODGE THE FALL", self.large, INK, (30, 42))
        self.text(
            screen,
            f"SCORE  {world.score:02d}     BEST  {world.best:02d}",
            self.body,
            TEAL,
            (365, 48),
        )
        self.button(
            screen,
            self.pause_button,
            "RESUME" if world.mode == Mode.PAUSED else "PAUSE",
            mouse,
        )
        pygame.draw.rect(screen, "white", self.arena, border_radius=16)
        # 限定游戏区域的绘制,刚生成但还在上边缘之外的障碍不会盖住标题。
        screen.set_clip(self.arena)
        offset = int(world.elapsed * 45) % 32
        for x in range(LEFT + 16, RIGHT, 32):
            for y in range(TOP + offset, BOTTOM, 32):
                pygame.draw.circle(screen, "#E6EBF3", (x, y), 1)
        for x in (216, 400, 584):
            pygame.draw.line(screen, "#F0F2F8", (x, TOP), (x, BOTTOM), 1)
        self.sprites.sync(world)
        self.sprites.obstacles.draw(screen)
        self.sprites.players.draw(screen)
        if debug:
            for sprite in [self.sprites.player, *self.sprites.obstacles.sprites()]:
                pygame.draw.rect(screen, INDIGO, sprite.rect, 1)
        if world.hit_uid in self.sprites.by_id:
            pygame.draw.rect(
                screen,
                "#AC4964",
                self.sprites.by_id[world.hit_uid].rect.inflate(8, 8),
                3,
                border_radius=9,
            )
        screen.set_clip(None)
        pygame.draw.rect(screen, LINE, self.arena, 2, border_radius=16)
        self.text(
            screen,
            "A / D  or  ARROWS     P: pause     M: sound     F1: hitboxes",
            self.small,
            MUTED,
            (32, 577),
        )
        self.text(screen, f"{fps:3.0f} FPS", self.small, MUTED, (700, 577))
        if world.mode != Mode.PLAYING:
            self.draw_overlay(screen, world, mouse)
        self.text(
            screen, "SOUND OFF" if muted else "SOUND ON", self.small, MUTED, (645, 82)
        )

先画背景和界面区域,再同步并画障碍、玩家,随后画碰撞框和菜单。set_clip(arena) 限制游戏对象的绘制范围,刚生成但还没落进区域的障碍不会盖住标题;画完后清除限制,再绘制界面。

draw.circle(screen, 颜色, 中心坐标, 半径) 画点阵中的圆点,draw.line(screen, 颜色, 起点, 终点, 线宽) 画竖向参考线。两者都只改变给定 Surface 的像素,位置与经过时间仍由 World 提供。

背景点阵的偏移来自 world.elapsed,暂停时经过时间不更新,背景也一起停止。F1 只切换碰撞框是否显示,显示框不会改变逻辑使用的形状。

绘制按背景点阵障碍玩家界面覆盖层排列,裁剪只影响游戏区域,绘制不改变模型

4. 暂停与结束用覆盖层表达

# 续写同一文件:dodge_project/src/dodgegame/view.py
    # 先铺半透明遮罩,再按当前状态绘制暂停或结束提示,让底下的游戏画面仍然可见。
    def draw_overlay(self, screen, world, mouse):
        veil = pygame.Surface((WIDTH, HEIGHT), pygame.SRCALPHA)
        veil.fill((245, 247, 251, 220))
        screen.blit(veil, (0, 0))
        panel = pygame.Rect(148, 166, 504, 304)
        pygame.draw.rect(screen, "white", panel.move(0, 6), border_radius=22)
        pygame.draw.rect(screen, "white", panel, border_radius=22)
        pygame.draw.rect(screen, LINE, panel, 2, border_radius=22)
        if world.mode == Mode.TITLE:
            title, subtitle, button = (
                "DODGE THE FALL",
                "Move sideways. Let every block pass.",
                "START  /  ENTER",
            )
            detail = "One passed obstacle = one point"
        elif world.mode == Mode.PAUSED:
            title, subtitle, button = (
                "TAKE A BREATH",
                "The world waits for your next move.",
                "RESUME  /  P",
            )
            detail = f"Score {world.score}  /  Best {world.best}"
        else:
            title, subtitle, button = (
                "ROUND OVER",
                "A new round starts with a clear board.",
                "TRY AGAIN  /  R",
            )
            detail = f"Score {world.score}  /  Best {world.best}"
        self.text(screen, title, self.large, INK, (400, 221), centered=True)
        self.text(screen, subtitle, self.body, MUTED, (400, 274), centered=True)
        self.text(screen, detail, self.body, TEAL, (400, 320), centered=True)
        self.button(screen, self.menu_button, button, mouse)
        self.text(screen, "ESC to exit", self.small, MUTED, (400, 450), centered=True)

先用半透明 Surface 淡化底下的画面,再画标题、说明和共同按钮。开始、暂停、结束分别选择自己的文字,按钮区域保持一致。状态保存在 World 中,绘制只是把当前状态表达出来。

笔记正文与全部题解 · Ctrl / ⌘ K