Skip to content

第31章 游戏开发

学习目标

完成本章学习后,你将能够:

  1. 理解游戏开发基础:游戏循环、帧率、事件处理
  2. 掌握Pygame核心概念:Surface、Rect、Sprite、事件系统
  3. 实现游戏图形渲染:绘制形状、加载图片、动画效果
  4. 处理用户输入:键盘、鼠标、游戏手柄控制
  5. 实现游戏物理:碰撞检测、速度、加速度、重力
  6. 管理游戏状态:菜单、游戏进行、暂停、结束
  7. 添加音效和音乐:播放背景音乐、音效管理
  8. 构建完整游戏项目:从设计到实现的完整流程

31.1 Pygame基础

31.1.1 游戏开发概述

游戏开发涉及多个核心概念:

┌─────────────────────────────────────────────────────────────────────┐
│                        游戏开发核心概念                              │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  ┌─────────────────────────────────────────────────────────────┐   │
│  │                      游戏循环 (Game Loop)                    │   │
│  │                                                             │   │
│  │   ┌──────────┐    ┌──────────┐    ┌──────────┐            │   │
│  │   │ 处理输入  │───►│ 更新状态  │───►│ 渲染画面  │──┐        │   │
│  │   │ (Input)  │    │ (Update) │    │ (Render) │  │        │   │
│  │   └──────────┘    └──────────┘    └──────────┘  │        │   │
│  │         ▲                                        │        │   │
│  │         └────────────────────────────────────────┘        │   │
│  │                     (60 FPS)                              │   │
│  └─────────────────────────────────────────────────────────────┘   │
│                                                                     │
│  核心组件:                                                          │
│  • Surface: 绘图表面,承载图形内容                                   │
│  • Rect: 矩形区域,用于位置和碰撞检测                                │
│  • Sprite: 精灵,游戏对象的抽象                                      │
│  • Event: 事件,用户输入和系统消息                                   │
│  • Clock: 时钟,控制帧率和时间                                       │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

31.1.2 Pygame初始化

python
import pygame
import sys
from typing import Optional, Tuple, List, Dict, Any
from dataclasses import dataclass
from enum import Enum, auto
import math
import random


class GameState(Enum):
    MENU = auto()
    PLAYING = auto()
    PAUSED = auto()
    GAME_OVER = auto()


@dataclass
class GameConfig:
    title: str = "Pygame Game"
    width: int = 800
    height: int = 600
    fps: int = 60
    background_color: Tuple[int, int, int] = (0, 0, 0)

    @property
    def center(self) -> Tuple[int, int]:
        return (self.width // 2, self.height // 2)

    @property
    def size(self) -> Tuple[int, int]:
        return (self.width, self.height)


class Game:
    def __init__(self, config: Optional[GameConfig] = None):
        self.config = config or GameConfig()
        self._initialize_pygame()
        self.state = GameState.MENU
        self.running = True
        self.clock = pygame.time.Clock()
        self.screen: Optional[pygame.Surface] = None
        self.dt: float = 0.0

    def _initialize_pygame(self) -> None:
        pygame.init()
        pygame.mixer.init()
        pygame.font.init()

        self.screen = pygame.display.set_mode(self.config.size)
        pygame.display.set_caption(self.config.title)

    def run(self) -> None:
        while self.running:
            self.dt = self.clock.tick(self.config.fps) / 1000.0

            self._handle_events()
            self._update()
            self._render()

        self._quit()

    def _handle_events(self) -> None:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                self.running = False
            elif event.type == pygame.KEYDOWN:
                self._handle_keydown(event)
            elif event.type == pygame.KEYUP:
                self._handle_keyup(event)
            elif event.type == pygame.MOUSEBUTTONDOWN:
                self._handle_mouse_click(event)

    def _handle_keydown(self, event: pygame.event.Event) -> None:
        if event.key == pygame.K_ESCAPE:
            if self.state == GameState.PLAYING:
                self.state = GameState.PAUSED
            elif self.state == GameState.PAUSED:
                self.state = GameState.PLAYING

    def _handle_keyup(self, event: pygame.event.Event) -> None:
        pass

    def _handle_mouse_click(self, event: pygame.event.Event) -> None:
        pass

    def _update(self) -> None:
        pass

    def _render(self) -> None:
        self.screen.fill(self.config.background_color)
        pygame.display.flip()

    def _quit(self) -> None:
        pygame.quit()
        sys.exit()


if __name__ == "__main__":
    game = Game()
    game.run()

31.1.3 颜色和绘图基础

python
class Colors:
    WHITE = (255, 255, 255)
    BLACK = (0, 0, 0)
    RED = (255, 0, 0)
    GREEN = (0, 255, 0)
    BLUE = (0, 0, 255)
    YELLOW = (255, 255, 0)
    CYAN = (0, 255, 255)
    MAGENTA = (255, 0, 255)
    ORANGE = (255, 165, 0)
    PURPLE = (128, 0, 128)
    GRAY = (128, 128, 128)
    LIGHT_GRAY = (200, 200, 200)
    DARK_GRAY = (64, 64, 64)

    @staticmethod
    def random_color() -> Tuple[int, int, int]:
        return (
            random.randint(0, 255),
            random.randint(0, 255),
            random.randint(0, 255)
        )

    @staticmethod
    def hex_to_rgb(hex_color: str) -> Tuple[int, int, int]:
        hex_color = hex_color.lstrip("#")
        return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))

    @staticmethod
    def rgb_to_hex(rgb: Tuple[int, int, int]) -> str:
        return "#{:02x}{:02x}{:02x}".format(*rgb)


class ShapeRenderer:
    def __init__(self, surface: pygame.Surface):
        self.surface = surface

    def draw_line(
        self,
        start: Tuple[int, int],
        end: Tuple[int, int],
        color: Tuple[int, int, int],
        width: int = 1
    ) -> None:
        pygame.draw.line(self.surface, color, start, end, width)

    def draw_rect(
        self,
        rect: pygame.Rect,
        color: Tuple[int, int, int],
        width: int = 0
    ) -> None:
        pygame.draw.rect(self.surface, color, rect, width)

    def draw_circle(
        self,
        center: Tuple[int, int],
        radius: int,
        color: Tuple[int, int, int],
        width: int = 0
    ) -> None:
        pygame.draw.circle(self.surface, color, center, radius, width)

    def draw_polygon(
        self,
        points: List[Tuple[int, int]],
        color: Tuple[int, int, int],
        width: int = 0
    ) -> None:
        pygame.draw.polygon(self.surface, color, points, width)

    def draw_ellipse(
        self,
        rect: pygame.Rect,
        color: Tuple[int, int, int],
        width: int = 0
    ) -> None:
        pygame.draw.ellipse(self.surface, color, rect, width)

    def draw_arc(
        self,
        rect: pygame.Rect,
        color: Tuple[int, int, int],
        start_angle: float,
        stop_angle: float,
        width: int = 1
    ) -> None:
        pygame.draw.arc(self.surface, color, rect, start_angle, stop_angle, width)


class TextRenderer:
    def __init__(self, surface: pygame.Surface):
        self.surface = surface
        self._fonts: Dict[str, pygame.font.Font] = {}

    def get_font(
        self,
        name: str = None,
        size: int = 24
    ) -> pygame.font.Font:
        key = f"{name}_{size}"
        if key not in self._fonts:
            self._fonts[key] = pygame.font.SysFont(name, size)
        return self._fonts[key]

    def render(
        self,
        text: str,
        position: Tuple[int, int],
        color: Tuple[int, int, int] = Colors.WHITE,
        font_size: int = 24,
        font_name: str = None,
        center: bool = False
    ) -> pygame.Rect:
        font = self.get_font(font_name, font_size)
        text_surface = font.render(text, True, color)

        if center:
            text_rect = text_surface.get_rect(center=position)
        else:
            text_rect = text_surface.get_rect(topleft=position)

        self.surface.blit(text_surface, text_rect)
        return text_rect

    def render_multiline(
        self,
        lines: List[str],
        position: Tuple[int, int],
        color: Tuple[int, int, int] = Colors.WHITE,
        font_size: int = 24,
        line_spacing: int = 5
    ) -> None:
        x, y = position
        for line in lines:
            self.render(line, (x, y), color, font_size)
            y += font_size + line_spacing

31.2 精灵系统

31.2.1 Sprite基类

python
from abc import ABC, abstractmethod


class Sprite(pygame.sprite.Sprite):
    def __init__(self, x: float = 0, y: float = 0):
        super().__init__()
        self.x = x
        self.y = y
        self.vx: float = 0.0
        self.vy: float = 0.0
        self.image: Optional[pygame.Surface] = None
        self.rect: Optional[pygame.Rect] = None

    def update(self, dt: float) -> None:
        self.x += self.vx * dt
        self.y += self.vy * dt

        if self.rect:
            self.rect.x = int(self.x)
            self.rect.y = int(self.y)

    def draw(self, surface: pygame.Surface) -> None:
        if self.image and self.rect:
            surface.blit(self.image, self.rect)

    def set_position(self, x: float, y: float) -> None:
        self.x = x
        self.y = y
        if self.rect:
            self.rect.x = int(x)
            self.rect.y = int(y)

    def set_velocity(self, vx: float, vy: float) -> None:
        self.vx = vx
        self.vy = vy

    def create_image(
        self,
        width: int,
        height: int,
        color: Tuple[int, int, int]
    ) -> None:
        self.image = pygame.Surface((width, height), pygame.SRCALPHA)
        self.image.fill(color)
        self.rect = self.image.get_rect()
        self.rect.x = int(self.x)
        self.rect.y = int(self.y)


class AnimatedSprite(Sprite):
    def __init__(
        self,
        x: float = 0,
        y: float = 0,
        frame_width: int = 32,
        frame_height: int = 32
    ):
        super().__init__(x, y)
        self.frame_width = frame_width
        self.frame_height = frame_height
        self.frames: List[pygame.Surface] = []
        self.current_frame: int = 0
        self.animation_speed: float = 10.0
        self.animation_timer: float = 0.0
        self.loop: bool = True
        self.playing: bool = True

    def load_spritesheet(
        self,
        filepath: str,
        columns: int,
        rows: int
    ) -> None:
        spritesheet = pygame.image.load(filepath).convert_alpha()

        for row in range(rows):
            for col in range(columns):
                frame = pygame.Surface(
                    (self.frame_width, self.frame_height),
                    pygame.SRCALPHA
                )
                frame.blit(
                    spritesheet,
                    (0, 0),
                    (
                        col * self.frame_width,
                        row * self.frame_height,
                        self.frame_width,
                        self.frame_height
                    )
                )
                self.frames.append(frame)

        if self.frames:
            self.image = self.frames[0]
            self.rect = self.image.get_rect()
            self.rect.x = int(self.x)
            self.rect.y = int(self.y)

    def add_frame(self, frame: pygame.Surface) -> None:
        self.frames.append(frame)
        if len(self.frames) == 1:
            self.image = frame
            self.rect = self.image.get_rect()

    def update(self, dt: float) -> None:
        super().update(dt)

        if self.playing and len(self.frames) > 1:
            self.animation_timer += dt * self.animation_speed

            if self.animation_timer >= 1.0:
                self.animation_timer = 0.0
                self.current_frame += 1

                if self.current_frame >= len(self.frames):
                    if self.loop:
                        self.current_frame = 0
                    else:
                        self.current_frame = len(self.frames) - 1
                        self.playing = False

                self.image = self.frames[self.current_frame]

    def play(self) -> None:
        self.playing = True

    def pause(self) -> None:
        self.playing = False

    def reset(self) -> None:
        self.current_frame = 0
        self.animation_timer = 0.0
        if self.frames:
            self.image = self.frames[0]


class SpriteSheet:
    def __init__(self, filepath: str):
        self.sheet = pygame.image.load(filepath).convert_alpha()

    def get_image(
        self,
        x: int,
        y: int,
        width: int,
        height: int,
        scale: float = 1.0
    ) -> pygame.Surface:
        image = pygame.Surface((width, height), pygame.SRCALPHA)
        image.blit(self.sheet, (0, 0), (x, y, width, height))

        if scale != 1.0:
            new_size = (int(width * scale), int(height * scale))
            image = pygame.transform.scale(image, new_size)

        return image

    def get_images(
        self,
        x: int,
        y: int,
        width: int,
        height: int,
        count: int,
        spacing: int = 0,
        scale: float = 1.0
    ) -> List[pygame.Surface]:
        images = []
        for i in range(count):
            image = self.get_image(
                x + i * (width + spacing),
                y,
                width,
                height,
                scale
            )
            images.append(image)
        return images

31.2.2 精灵组管理

python
class SpriteGroup(pygame.sprite.Group):
    def __init__(self):
        super().__init__()
        self._layers: Dict[int, List[Sprite]] = {}

    def add(self, *sprites, layer: int = 0) -> None:
        for sprite in sprites:
            if layer not in self._layers:
                self._layers[layer] = []
            self._layers[layer].append(sprite)
        super().add(*sprites)

    def remove(self, *sprites) -> None:
        for sprite in sprites:
            for layer in self._layers.values():
                if sprite in layer:
                    layer.remove(sprite)
        super().remove(*sprites)

    def update(self, *args, **kwargs) -> None:
        for sprite in self.sprites():
            sprite.update(*args, **kwargs)

    def draw(self, surface: pygame.Surface) -> None:
        for layer in sorted(self._layers.keys()):
            for sprite in self._layers[layer]:
                sprite.draw(surface)

    def get_sprites_at(self, pos: Tuple[int, int]) -> List[Sprite]:
        return [s for s in self.sprites() if s.rect.collidepoint(pos)]

    def clear(self) -> None:
        self._layers.clear()
        super().empty()


class Camera:
    def __init__(self, width: int, height: int):
        self.width = width
        self.height = height
        self.x: float = 0.0
        self.y: float = 0.0
        self.target: Optional[Sprite] = None
        self.smoothing: float = 0.1

    def set_target(self, target: Sprite) -> None:
        self.target = target

    def update(self, dt: float) -> None:
        if self.target:
            target_x = self.target.x - self.width // 2
            target_y = self.target.y - self.height // 2

            self.x += (target_x - self.x) * self.smoothing
            self.y += (target_y - self.y) * self.smoothing

    def apply(self, sprite: Sprite) -> pygame.Rect:
        return pygame.Rect(
            sprite.rect.x - self.x,
            sprite.rect.y - self.y,
            sprite.rect.width,
            sprite.rect.height
        )

    def apply_position(self, x: float, y: float) -> Tuple[int, int]:
        return (int(x - self.x), int(y - self.y))

    def get_visible_rect(self) -> pygame.Rect:
        return pygame.Rect(self.x, self.y, self.width, self.height)

    def is_visible(self, rect: pygame.Rect) -> bool:
        return self.get_visible_rect().colliderect(rect)

31.3 碰撞检测

31.3.1 矩形碰撞

python
from typing import Optional, List, Tuple


class CollisionHandler:
    @staticmethod
    def rect_rect(rect1: pygame.Rect, rect2: pygame.Rect) -> bool:
        return rect1.colliderect(rect2)

    @staticmethod
    def circle_circle(
        center1: Tuple[float, float],
        radius1: float,
        center2: Tuple[float, float],
        radius2: float
    ) -> bool:
        dx = center2[0] - center1[0]
        dy = center2[1] - center1[1]
        distance = math.sqrt(dx * dx + dy * dy)
        return distance < radius1 + radius2

    @staticmethod
    def point_rect(
        point: Tuple[float, float],
        rect: pygame.Rect
    ) -> bool:
        return rect.collidepoint(point)

    @staticmethod
    def point_circle(
        point: Tuple[float, float],
        center: Tuple[float, float],
        radius: float
    ) -> bool:
        dx = point[0] - center[0]
        dy = point[1] - center[1]
        distance = math.sqrt(dx * dx + dy * dy)
        return distance <= radius

    @staticmethod
    def circle_rect(
        center: Tuple[float, float],
        radius: float,
        rect: pygame.Rect
    ) -> bool:
        closest_x = max(rect.left, min(center[0], rect.right))
        closest_y = max(rect.top, min(center[1], rect.bottom))

        dx = center[0] - closest_x
        dy = center[1] - closest_y

        return (dx * dx + dy * dy) < (radius * radius)

    @staticmethod
    def line_rect(
        line_start: Tuple[float, float],
        line_end: Tuple[float, float],
        rect: pygame.Rect
    ) -> bool:
        left = rect.left
        right = rect.right
        top = rect.top
        bottom = rect.bottom

        x1, y1 = line_start
        x2, y2 = line_end

        def line_line(x1, y1, x2, y2, x3, y3, x4, y4):
            denom = (y4 - y3) * (x2 - x1) - (x4 - x3) * (y2 - y1)
            if denom == 0:
                return False
            ua = ((x4 - x3) * (y1 - y3) - (y4 - y3) * (x1 - x3)) / denom
            ub = ((x2 - x1) * (y1 - y3) - (y2 - y1) * (x1 - x3)) / denom
            return 0 <= ua <= 1 and 0 <= ub <= 1

        if line_line(x1, y1, x2, y2, left, top, right, top):
            return True
        if line_line(x1, y1, x2, y2, left, bottom, right, bottom):
            return True
        if line_line(x1, y1, x2, y2, left, top, left, bottom):
            return True
        if line_line(x1, y1, x2, y2, right, top, right, bottom):
            return True

        return False


class CollisionResponse:
    @staticmethod
    def get_rect_overlap(rect1: pygame.Rect, rect2: pygame.Rect) -> Optional[pygame.Rect]:
        if not rect1.colliderect(rect2):
            return None

        left = max(rect1.left, rect2.left)
        right = min(rect1.right, rect2.right)
        top = max(rect1.top, rect2.top)
        bottom = min(rect1.bottom, rect2.bottom)

        return pygame.Rect(left, top, right - left, bottom - top)

    @staticmethod
    def resolve_rect_collision(
        moving_rect: pygame.Rect,
        static_rect: pygame.Rect,
        velocity: Tuple[float, float]
    ) -> Tuple[pygame.Rect, Tuple[float, float]]:
        overlap = CollisionResponse.get_rect_overlap(moving_rect, static_rect)
        if overlap is None:
            return moving_rect, velocity

        if overlap.width < overlap.height:
            if velocity[0] > 0:
                new_rect = moving_rect.move(-overlap.width, 0)
            else:
                new_rect = moving_rect.move(overlap.width, 0)
            new_velocity = (0, velocity[1])
        else:
            if velocity[1] > 0:
                new_rect = moving_rect.move(0, -overlap.height)
            else:
                new_rect = moving_rect.move(0, overlap.height)
            new_velocity = (velocity[0], 0)

        return new_rect, new_velocity

    @staticmethod
    def bounce_velocity(
        velocity: Tuple[float, float],
        normal: Tuple[float, float],
        restitution: float = 1.0
    ) -> Tuple[float, float]:
        dot = velocity[0] * normal[0] + velocity[1] * normal[1]
        return (
            velocity[0] - (1 + restitution) * dot * normal[0],
            velocity[1] - (1 + restitution) * dot * normal[1]
        )

31.3.2 空间分区

python
class SpatialHash:
    def __init__(self, cell_size: int = 64):
        self.cell_size = cell_size
        self.cells: Dict[Tuple[int, int], List[Sprite]] = {}

    def _get_cell(self, x: float, y: float) -> Tuple[int, int]:
        return (int(x // self.cell_size), int(y // self.cell_size))

    def _get_cells_for_rect(self, rect: pygame.Rect) -> List[Tuple[int, int]]:
        cells = []
        start_cell = self._get_cell(rect.left, rect.top)
        end_cell = self._get_cell(rect.right, rect.bottom)

        for x in range(start_cell[0], end_cell[0] + 1):
            for y in range(start_cell[1], end_cell[1] + 1):
                cells.append((x, y))

        return cells

    def insert(self, sprite: Sprite) -> None:
        cells = self._get_cells_for_rect(sprite.rect)
        for cell in cells:
            if cell not in self.cells:
                self.cells[cell] = []
            self.cells[cell].append(sprite)

    def remove(self, sprite: Sprite) -> None:
        cells = self._get_cells_for_rect(sprite.rect)
        for cell in cells:
            if cell in self.cells and sprite in self.cells[cell]:
                self.cells[cell].remove(sprite)

    def get_nearby_sprites(self, rect: pygame.Rect) -> List[Sprite]:
        nearby = set()
        cells = self._get_cells_for_rect(rect)
        for cell in cells:
            if cell in self.cells:
                nearby.update(self.cells[cell])
        return list(nearby)

    def clear(self) -> None:
        self.cells.clear()

    def update(self, sprites: List[Sprite]) -> None:
        self.clear()
        for sprite in sprites:
            self.insert(sprite)

31.4 用户输入

31.4.1 键盘输入

python
from typing import Dict, Set, Callable


class KeyboardInput:
    def __init__(self):
        self._keys_pressed: Set[int] = set()
        self._keys_just_pressed: Set[int] = set()
        self._keys_just_released: Set[int] = set()
        self._key_callbacks: Dict[int, List[Callable]] = {}

    def update(self) -> None:
        self._keys_just_pressed.clear()
        self._keys_just_released.clear()

    def handle_event(self, event: pygame.event.Event) -> None:
        if event.type == pygame.KEYDOWN:
            if event.key not in self._keys_pressed:
                self._keys_just_pressed.add(event.key)
            self._keys_pressed.add(event.key)

            if event.key in self._key_callbacks:
                for callback in self._key_callbacks[event.key]:
                    callback()

        elif event.type == pygame.KEYUP:
            self._keys_pressed.discard(event.key)
            self._keys_just_released.add(event.key)

    def is_key_pressed(self, key: int) -> bool:
        return key in self._keys_pressed

    def is_key_just_pressed(self, key: int) -> bool:
        return key in self._keys_just_pressed

    def is_key_just_released(self, key: int) -> bool:
        return key in self._keys_just_released

    def register_callback(self, key: int, callback: Callable) -> None:
        if key not in self._key_callbacks:
            self._key_callbacks[key] = []
        self._key_callbacks[key].append(callback)

    def unregister_callback(self, key: int, callback: Callable) -> None:
        if key in self._key_callbacks and callback in self._key_callbacks[key]:
            self._key_callbacks[key].remove(callback)

    def get_axis(self, negative_key: int, positive_key: int) -> float:
        result = 0.0
        if self.is_key_pressed(negative_key):
            result -= 1.0
        if self.is_key_pressed(positive_key):
            result += 1.0
        return result

    def get_vector(
        self,
        left_key: int,
        right_key: int,
        up_key: int,
        down_key: int
    ) -> Tuple[float, float]:
        x = self.get_axis(left_key, right_key)
        y = self.get_axis(up_key, down_key)

        if x != 0 and y != 0:
            length = math.sqrt(x * x + y * y)
            x /= length
            y /= length

        return (x, y)


class InputManager:
    def __init__(self):
        self.keyboard = KeyboardInput()
        self._mouse_buttons_pressed: Set[int] = set()
        self._mouse_buttons_just_pressed: Set[int] = set()
        self._mouse_buttons_just_released: Set[int] = set()
        self._mouse_position: Tuple[int, int] = (0, 0)
        self._mouse_rel: Tuple[int, int] = (0, 0)

    def update(self) -> None:
        self.keyboard.update()
        self._mouse_buttons_just_pressed.clear()
        self._mouse_buttons_just_released.clear()
        self._mouse_rel = (0, 0)

    def handle_event(self, event: pygame.event.Event) -> None:
        if event.type in (pygame.KEYDOWN, pygame.KEYUP):
            self.keyboard.handle_event(event)

        elif event.type == pygame.MOUSEMOTION:
            self._mouse_position = event.pos
            self._mouse_rel = event.rel

        elif event.type == pygame.MOUSEBUTTONDOWN:
            if event.button not in self._mouse_buttons_pressed:
                self._mouse_buttons_just_pressed.add(event.button)
            self._mouse_buttons_pressed.add(event.button)

        elif event.type == pygame.MOUSEBUTTONUP:
            self._mouse_buttons_pressed.discard(event.button)
            self._mouse_buttons_just_released.add(event.button)

    @property
    def mouse_position(self) -> Tuple[int, int]:
        return self._mouse_position

    @property
    def mouse_relative(self) -> Tuple[int, int]:
        return self._mouse_rel

    def is_mouse_button_pressed(self, button: int) -> bool:
        return button in self._mouse_buttons_pressed

    def is_mouse_button_just_pressed(self, button: int) -> bool:
        return button in self._mouse_buttons_just_pressed

    def is_mouse_button_just_released(self, button: int) -> bool:
        return button in self._mouse_buttons_just_released

31.4.2 鼠标输入

python
class MouseCursor:
    def __init__(self, visible: bool = True):
        self._visible = visible
        self._custom_cursor: Optional[pygame.Surface] = None
        self._hotspot: Tuple[int, int] = (0, 0)
        pygame.mouse.set_visible(visible)

    def set_visible(self, visible: bool) -> None:
        self._visible = visible
        pygame.mouse.set_visible(visible)

    def set_custom_cursor(
        self,
        surface: pygame.Surface,
        hotspot: Tuple[int, int] = (0, 0)
    ) -> None:
        self._custom_cursor = surface
        self._hotspot = hotspot
        self.set_visible(False)

    def draw(self, surface: pygame.Surface, position: Tuple[int, int]) -> None:
        if self._custom_cursor and not self._visible:
            x = position[0] - self._hotspot[0]
            y = position[1] - self._hotspot[1]
            surface.blit(self._custom_cursor, (x, y))

    def reset(self) -> None:
        self._custom_cursor = None
        self.set_visible(True)


class VirtualJoystick:
    def __init__(
        self,
        center: Tuple[int, int],
        radius: int = 50,
        handle_radius: int = 20
    ):
        self.center = center
        self.radius = radius
        self.handle_radius = handle_radius
        self.handle_position = center
        self.active = False
        self.touch_id: Optional[int] = None

    def handle_event(self, event: pygame.event.Event) -> None:
        if event.type == pygame.MOUSEBUTTONDOWN:
            dx = event.pos[0] - self.center[0]
            dy = event.pos[1] - self.center[1]
            distance = math.sqrt(dx * dx + dy * dy)

            if distance <= self.radius + self.handle_radius:
                self.active = True
                self._update_handle(event.pos)

        elif event.type == pygame.MOUSEMOTION:
            if self.active:
                self._update_handle(event.pos)

        elif event.type == pygame.MOUSEBUTTONUP:
            if self.active:
                self.active = False
                self.handle_position = self.center

    def _update_handle(self, position: Tuple[int, int]) -> None:
        dx = position[0] - self.center[0]
        dy = position[1] - self.center[1]
        distance = math.sqrt(dx * dx + dy * dy)

        if distance > self.radius:
            dx = dx / distance * self.radius
            dy = dy / distance * self.radius

        self.handle_position = (
            int(self.center[0] + dx),
            int(self.center[1] + dy)
        )

    def get_direction(self) -> Tuple[float, float]:
        dx = self.handle_position[0] - self.center[0]
        dy = self.handle_position[1] - self.center[1]

        if dx == 0 and dy == 0:
            return (0.0, 0.0)

        distance = math.sqrt(dx * dx + dy * dy)
        return (dx / self.radius, dy / self.radius)

    def draw(self, surface: pygame.Surface) -> None:
        pygame.draw.circle(
            surface,
            (100, 100, 100),
            self.center,
            self.radius,
            2
        )

        pygame.draw.circle(
            surface,
            (150, 150, 150),
            self.handle_position,
            self.handle_radius
        )

31.5 游戏物理

31.5.1 运动和速度

python
@dataclass
class PhysicsBody:
    x: float = 0.0
    y: float = 0.0
    vx: float = 0.0
    vy: float = 0.0
    ax: float = 0.0
    ay: float = 0.0
    mass: float = 1.0
    friction: float = 0.0
    restitution: float = 0.8
    gravity: float = 0.0
    max_speed: float = 1000.0

    def apply_force(self, fx: float, fy: float) -> None:
        self.ax += fx / self.mass
        self.ay += fy / self.mass

    def apply_impulse(self, ix: float, iy: float) -> None:
        self.vx += ix / self.mass
        self.vy += iy / self.mass

    def update(self, dt: float) -> None:
        self.ay += self.gravity

        self.vx += self.ax * dt
        self.vy += self.ay * dt

        if self.friction > 0:
            self.vx *= (1 - self.friction * dt)
            self.vy *= (1 - self.friction * dt)

        speed = math.sqrt(self.vx ** 2 + self.vy ** 2)
        if speed > self.max_speed:
            self.vx = (self.vx / speed) * self.max_speed
            self.vy = (self.vy / speed) * self.max_speed

        self.x += self.vx * dt
        self.y += self.vy * dt

        self.ax = 0.0
        self.ay = 0.0

    @property
    def position(self) -> Tuple[float, float]:
        return (self.x, self.y)

    @property
    def velocity(self) -> Tuple[float, float]:
        return (self.vx, self.vy)

    @property
    def speed(self) -> float:
        return math.sqrt(self.vx ** 2 + self.vy ** 2)


class PhysicsWorld:
    def __init__(self, gravity: float = 980.0):
        self.gravity = gravity
        self.bodies: List[PhysicsBody] = []
        self.static_rects: List[pygame.Rect] = []

    def add_body(self, body: PhysicsBody) -> None:
        body.gravity = self.gravity
        self.bodies.append(body)

    def remove_body(self, body: PhysicsBody) -> None:
        if body in self.bodies:
            self.bodies.remove(body)

    def add_static_rect(self, rect: pygame.Rect) -> None:
        self.static_rects.append(rect)

    def update(self, dt: float) -> None:
        for body in self.bodies:
            body.update(dt)

    def check_collisions(self) -> List[Tuple[PhysicsBody, pygame.Rect]]:
        collisions = []
        for body in self.bodies:
            body_rect = pygame.Rect(
                body.x - 16,
                body.y - 16,
                32,
                32
            )

            for static_rect in self.static_rects:
                if body_rect.colliderect(static_rect):
                    collisions.append((body, static_rect))

        return collisions


class Particle:
    def __init__(
        self,
        x: float,
        y: float,
        vx: float = 0,
        vy: float = 0,
        lifetime: float = 1.0,
        color: Tuple[int, int, int] = Colors.WHITE,
        size: int = 3
    ):
        self.x = x
        self.y = y
        self.vx = vx
        self.vy = vy
        self.lifetime = lifetime
        self.max_lifetime = lifetime
        self.color = color
        self.size = size
        self.alive = True

    def update(self, dt: float) -> None:
        self.x += self.vx * dt
        self.y += self.vy * dt
        self.lifetime -= dt

        if self.lifetime <= 0:
            self.alive = False

    def draw(self, surface: pygame.Surface) -> None:
        alpha = int(255 * (self.lifetime / self.max_lifetime))
        size = int(self.size * (self.lifetime / self.max_lifetime))

        if size > 0:
            pygame.draw.circle(
                surface,
                self.color,
                (int(self.x), int(self.y)),
                size
            )


class ParticleSystem:
    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y
        self.particles: List[Particle] = []

    def emit(
        self,
        count: int,
        speed_range: Tuple[float, float] = (50, 150),
        angle_range: Tuple[float, float] = (0, 360),
        lifetime_range: Tuple[float, float] = (0.5, 1.5),
        color: Tuple[int, int, int] = Colors.WHITE,
        size_range: Tuple[int, int] = (2, 5)
    ) -> None:
        for _ in range(count):
            speed = random.uniform(*speed_range)
            angle = math.radians(random.uniform(*angle_range))

            vx = math.cos(angle) * speed
            vy = math.sin(angle) * speed

            lifetime = random.uniform(*lifetime_range)
            size = random.randint(*size_range)

            particle = Particle(
                self.x, self.y, vx, vy,
                lifetime, color, size
            )
            self.particles.append(particle)

    def update(self, dt: float) -> None:
        for particle in self.particles[:]:
            particle.update(dt)
            if not particle.alive:
                self.particles.remove(particle)

    def draw(self, surface: pygame.Surface) -> None:
        for particle in self.particles:
            particle.draw(surface)

31.6 音效系统

31.6.1 音频管理

python
from typing import Dict, Optional


class SoundManager:
    def __init__(self):
        self._sounds: Dict[str, pygame.mixer.Sound] = {}
        self._music_volume: float = 1.0
        self._sound_volume: float = 1.0
        self._muted: bool = False

    def load_sound(self, name: str, filepath: str) -> None:
        try:
            sound = pygame.mixer.Sound(filepath)
            self._sounds[name] = sound
        except pygame.error as e:
            print(f"Failed to load sound {filepath}: {e}")

    def play_sound(
        self,
        name: str,
        volume: Optional[float] = None
    ) -> Optional[pygame.mixer.Channel]:
        if self._muted or name not in self._sounds:
            return None

        sound = self._sounds[name]
        vol = volume if volume is not None else self._sound_volume
        sound.set_volume(vol)
        return sound.play()

    def stop_sound(self, name: str) -> None:
        if name in self._sounds:
            self._sounds[name].stop()

    def stop_all_sounds(self) -> None:
        pygame.mixer.stop()

    def load_music(self, filepath: str) -> None:
        try:
            pygame.mixer.music.load(filepath)
        except pygame.error as e:
            print(f"Failed to load music {filepath}: {e}")

    def play_music(self, loops: int = -1, start: float = 0.0) -> None:
        if not self._muted:
            pygame.mixer.music.play(loops, start)

    def stop_music(self) -> None:
        pygame.mixer.music.stop()

    def pause_music(self) -> None:
        pygame.mixer.music.pause()

    def resume_music(self) -> None:
        pygame.mixer.music.unpause()

    def set_music_volume(self, volume: float) -> None:
        self._music_volume = max(0.0, min(1.0, volume))
        pygame.mixer.music.set_volume(self._music_volume)

    def set_sound_volume(self, volume: float) -> None:
        self._sound_volume = max(0.0, min(1.0, volume))

    def mute(self) -> None:
        self._muted = True
        pygame.mixer.music.set_volume(0)

    def unmute(self) -> None:
        self._muted = False
        pygame.mixer.music.set_volume(self._music_volume)

    def toggle_mute(self) -> None:
        if self._muted:
            self.unmute()
        else:
            self.mute()

    @property
    def is_muted(self) -> bool:
        return self._muted

31.7 完整游戏示例

31.7.1 太空射击游戏

python
class Player(Sprite):
    def __init__(self, x: float, y: float):
        super().__init__(x, y)
        self.speed = 300
        self.health = 100
        self.max_health = 100
        self.shoot_cooldown = 0.15
        self.shoot_timer = 0
        self.create_image(40, 40, Colors.CYAN)

    def update(self, dt: float, input_manager: InputManager) -> None:
        super().update(dt)

        move_x, move_y = input_manager.keyboard.get_vector(
            pygame.K_a, pygame.K_d,
            pygame.K_w, pygame.K_s
        )

        self.vx = move_x * self.speed
        self.vy = move_y * self.speed

        if self.shoot_timer > 0:
            self.shoot_timer -= dt

    def can_shoot(self) -> bool:
        return self.shoot_timer <= 0

    def shoot(self) -> None:
        self.shoot_timer = self.shoot_cooldown


class Bullet(Sprite):
    def __init__(self, x: float, y: float, speed: float = 500):
        super().__init__(x, y)
        self.speed = speed
        self.vy = -speed
        self.create_image(6, 15, Colors.YELLOW)

    def update(self, dt: float) -> None:
        super().update(dt)

    def is_off_screen(self, height: int) -> bool:
        return self.y < -20 or self.y > height + 20


class Enemy(Sprite):
    def __init__(self, x: float, y: float, speed: float = 100):
        super().__init__(x, y)
        self.speed = speed
        self.vy = speed
        self.health = 30
        self.points = 10
        self.create_image(35, 35, Colors.RED)

    def update(self, dt: float) -> None:
        super().update(dt)

    def is_off_screen(self, height: int) -> bool:
        return self.y > height + 50


class SpaceShooter(Game):
    def __init__(self):
        config = GameConfig(
            title="Space Shooter",
            width=800,
            height=600,
            fps=60,
            background_color=(10, 10, 30)
        )
        super().__init__(config)

        self.input_manager = InputManager()
        self.sound_manager = SoundManager()

        self.player: Optional[Player] = None
        self.bullets: List[Bullet] = []
        self.enemies: List[Enemy] = []
        self.particles: List[ParticleSystem] = []

        self.score = 0
        self.high_score = 0
        self.spawn_timer = 0
        self.spawn_interval = 1.5

    def _handle_events(self) -> None:
        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                self.running = False
            else:
                self.input_manager.handle_event(event)

    def _update(self) -> None:
        self.input_manager.update()

        if self.state == GameState.PLAYING:
            self._update_game()
        elif self.state == GameState.MENU:
            self._update_menu()
        elif self.state == GameState.GAME_OVER:
            self._update_game_over()

    def _update_game(self) -> None:
        if self.player:
            self.player.update(self.dt, self.input_manager)

            self.player.x = max(20, min(self.config.width - 20, self.player.x))
            self.player.y = max(20, min(self.config.height - 20, self.player.y))

            if self.input_manager.keyboard.is_key_pressed(pygame.K_SPACE):
                if self.player.can_shoot():
                    self._shoot_bullet()

        for bullet in self.bullets[:]:
            bullet.update(self.dt)
            if bullet.is_off_screen(self.config.height):
                self.bullets.remove(bullet)

        self._spawn_enemies()
        self._check_collisions()

        for particle in self.particles[:]:
            particle.update(self.dt)

        if self.player and self.player.health <= 0:
            self.state = GameState.GAME_OVER
            if self.score > self.high_score:
                self.high_score = self.score

    def _shoot_bullet(self) -> None:
        if self.player:
            bullet = Bullet(self.player.x, self.player.y - 20)
            self.bullets.append(bullet)
            self.player.shoot()
            self.sound_manager.play_sound("shoot", 0.3)

    def _spawn_enemies(self) -> None:
        self.spawn_timer += self.dt

        if self.spawn_timer >= self.spawn_interval:
            self.spawn_timer = 0

            x = random.randint(50, self.config.width - 50)
            speed = random.randint(80, 200)
            enemy = Enemy(x, -40, speed)
            self.enemies.append(enemy)

            self.spawn_interval = max(0.3, self.spawn_interval - 0.01)

    def _check_collisions(self) -> None:
        for bullet in self.bullets[:]:
            for enemy in self.enemies[:]:
                if bullet.rect and enemy.rect:
                    if CollisionHandler.rect_rect(bullet.rect, enemy.rect):
                        enemy.health -= 20

                        if enemy.health <= 0:
                            self.score += enemy.points
                            particle = ParticleSystem(enemy.x, enemy.y)
                            particle.emit(15, color=Colors.ORANGE)
                            self.particles.append(particle)
                            self.enemies.remove(enemy)

                        if bullet in self.bullets:
                            self.bullets.remove(bullet)
                        break

        if self.player:
            for enemy in self.enemies:
                if self.player.rect and enemy.rect:
                    if CollisionHandler.rect_rect(self.player.rect, enemy.rect):
                        self.player.health -= 20
                        particle = ParticleSystem(self.player.x, self.player.y)
                        particle.emit(10, color=Colors.RED)
                        self.particles.append(particle)

                        if enemy in self.enemies:
                            self.enemies.remove(enemy)

    def _render(self) -> None:
        self.screen.fill(self.config.background_color)

        self._draw_stars()

        if self.state == GameState.PLAYING:
            self._render_game()
        elif self.state == GameState.MENU:
            self._render_menu()
        elif self.state == GameState.GAME_OVER:
            self._render_game_over()

        pygame.display.flip()

    def _draw_stars(self) -> None:
        for i in range(50):
            x = (i * 37 + pygame.time.get_ticks() // 100) % self.config.width
            y = (i * 53 + pygame.time.get_ticks() // 50) % self.config.height
            pygame.draw.circle(self.screen, Colors.WHITE, (x, y), 1)

    def _render_game(self) -> None:
        if self.player:
            self.player.draw(self.screen)

        for bullet in self.bullets:
            bullet.draw(self.screen)

        for enemy in self.enemies:
            enemy.draw(self.screen)

        for particle in self.particles:
            particle.draw(self.screen)

        text_renderer = TextRenderer(self.screen)
        text_renderer.render(
            f"Score: {self.score}",
            (10, 10),
            Colors.WHITE,
            font_size=24
        )

        if self.player:
            self._draw_health_bar(10, 40, 150, 15)

    def _draw_health_bar(self, x: int, y: int, width: int, height: int) -> None:
        if self.player:
            pygame.draw.rect(self.screen, Colors.DARK_GRAY, (x, y, width, height))

            health_width = int(width * (self.player.health / self.player.max_health))
            pygame.draw.rect(self.screen, Colors.GREEN, (x, y, health_width, height))

            pygame.draw.rect(self.screen, Colors.WHITE, (x, y, width, height), 2)

    def _update_menu(self) -> None:
        if self.input_manager.keyboard.is_key_just_pressed(pygame.K_SPACE):
            self._start_game()

    def _render_menu(self) -> None:
        text_renderer = TextRenderer(self.screen)

        text_renderer.render(
            "SPACE SHOOTER",
            self.config.center,
            Colors.CYAN,
            font_size=48,
            center=True
        )

        text_renderer.render(
            "Press SPACE to Start",
            (self.config.center[0], self.config.center[1] + 60),
            Colors.WHITE,
            font_size=24,
            center=True
        )

        if self.high_score > 0:
            text_renderer.render(
                f"High Score: {self.high_score}",
                (self.config.center[0], self.config.center[1] + 120),
                Colors.YELLOW,
                font_size=24,
                center=True
            )

    def _update_game_over(self) -> None:
        if self.input_manager.keyboard.is_key_just_pressed(pygame.K_SPACE):
            self._start_game()

    def _render_game_over(self) -> None:
        text_renderer = TextRenderer(self.screen)

        text_renderer.render(
            "GAME OVER",
            self.config.center,
            Colors.RED,
            font_size=48,
            center=True
        )

        text_renderer.render(
            f"Final Score: {self.score}",
            (self.config.center[0], self.config.center[1] + 60),
            Colors.WHITE,
            font_size=32,
            center=True
        )

        text_renderer.render(
            "Press SPACE to Play Again",
            (self.config.center[0], self.config.center[1] + 120),
            Colors.WHITE,
            font_size=24,
            center=True
        )

    def _start_game(self) -> None:
        self.state = GameState.PLAYING
        self.score = 0
        self.spawn_interval = 1.5
        self.spawn_timer = 0

        self.player = Player(self.config.center[0], self.config.height - 80)
        self.bullets.clear()
        self.enemies.clear()
        self.particles.clear()


if __name__ == "__main__":
    game = SpaceShooter()
    game.run()

31.8 知识图谱

31.8.1 游戏开发架构

游戏开发核心架构

┌─────────────────────────────────────────────────────────────┐
│                    游戏循环 (Game Loop)                     │
│  处理输入 → 更新状态 → 渲染画面                            │
└─────────────────────────────────────────────────────────────┘

核心组件:
┌─────────────────────────────────────────┐
│ 输入系统    键盘、鼠标、手柄            │
│ 游戏状态    场景、实体、物理            │
│ 渲染系统    图形、动画、特效            │
│ 音频系统    音效、背景音乐              │
│ 资源管理    图片、音频、字体            │
└─────────────────────────────────────────┘

游戏循环模式:
┌─────────────────────────────────────────┐
│ while running:                          │
│     handle_input()    # 处理输入        │
│     update(dt)        # 更新状态        │
│     render()          # 渲染画面        │
│     clock.tick(60)    # 控制帧率        │
└─────────────────────────────────────────┘

31.8.2 Pygame核心模块

Pygame模块层次

┌─────────────────────────────────────────┐
│ pygame.display    显示控制              │
│ pygame.draw       图形绘制              │
│ pygame.event      事件处理              │
│ pygame.image      图片加载              │
│ pygame.sprite     精灵系统              │
│ pygame.mixer      音频播放              │
│ pygame.font       字体渲染              │
│ pygame.time       时间控制              │
│ pygame.transform  图形变换              │
└─────────────────────────────────────────┘

31.9 技术选型指南

31.9.1 游戏引擎选型

游戏类型推荐方案原因
2D小游戏Pygame简单直接
2D中型游戏Arcade功能丰富
3D游戏Panda3D专业引擎
教育游戏Ursina易学易用

31.9.2 碰撞检测选型

场景推荐方案性能
简单形状矩形/圆形检测O(n²)
大量对象空间分区O(n log n)
复杂形状像素级检测较慢

31.10 常见问题与解决方案

31.10.1 帧率不稳定

python
# 问题:游戏帧率波动大
# 解决方案:使用固定时间步长

import pygame

clock = pygame.time.Clock()
FPS = 60
dt = 1 / FPS

while running:
    update(dt)  # 固定时间步长
    render()
    clock.tick(FPS)

31.10.2 精灵闪烁

python
# 问题:精灵移动时闪烁
# 解决方案:使用双缓冲

screen = pygame.display.set_mode((800, 600), pygame.DOUBLEBUF)

31.10.3 内存泄漏

python
# 问题:游戏运行久后内存增加
# 解决方案:及时释放资源

# 使用精灵组自动管理
all_sprites = pygame.sprite.Group()

# 清理时
all_sprites.empty()

31.11 本章小结

本章详细介绍了Python游戏开发的核心概念和实践:

  1. Pygame基础:初始化、游戏循环、帧率控制
  2. 图形渲染:绘制形状、文字、加载图片
  3. 精灵系统:Sprite类、动画精灵、精灵组管理
  4. 碰撞检测:矩形碰撞、圆形碰撞、空间分区优化
  5. 用户输入:键盘、鼠标、虚拟摇杆
  6. 游戏物理:运动、速度、粒子系统
  7. 音效系统:音效播放、背景音乐、音量控制
  8. 完整游戏:太空射击游戏示例

练习题

  1. 创建一个简单的弹球游戏,实现球体的反弹效果
  2. 开发一个贪吃蛇游戏,包含得分系统和难度递增
  3. 实现一个平台跳跃游戏,包含重力、碰撞检测和关卡设计
  4. 创建一个塔防游戏,包含敌人波次、防御塔升级系统
  5. 开发一个多人本地对战游戏,支持双人键盘控制

扩展阅读

Python技术丛书 - 江苏省宿城中等专业学校