第31章 游戏开发
学习目标
完成本章学习后,你将能够:
- 理解游戏开发基础:游戏循环、帧率、事件处理
- 掌握Pygame核心概念:Surface、Rect、Sprite、事件系统
- 实现游戏图形渲染:绘制形状、加载图片、动画效果
- 处理用户输入:键盘、鼠标、游戏手柄控制
- 实现游戏物理:碰撞检测、速度、加速度、重力
- 管理游戏状态:菜单、游戏进行、暂停、结束
- 添加音效和音乐:播放背景音乐、音效管理
- 构建完整游戏项目:从设计到实现的完整流程
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_spacing31.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 images31.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_released31.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._muted31.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游戏开发的核心概念和实践:
- Pygame基础:初始化、游戏循环、帧率控制
- 图形渲染:绘制形状、文字、加载图片
- 精灵系统:Sprite类、动画精灵、精灵组管理
- 碰撞检测:矩形碰撞、圆形碰撞、空间分区优化
- 用户输入:键盘、鼠标、虚拟摇杆
- 游戏物理:运动、速度、粒子系统
- 音效系统:音效播放、背景音乐、音量控制
- 完整游戏:太空射击游戏示例
练习题
- 创建一个简单的弹球游戏,实现球体的反弹效果
- 开发一个贪吃蛇游戏,包含得分系统和难度递增
- 实现一个平台跳跃游戏,包含重力、碰撞检测和关卡设计
- 创建一个塔防游戏,包含敌人波次、防御塔升级系统
- 开发一个多人本地对战游戏,支持双人键盘控制