使用 pygame 构建碰撞模拟器:粒子反弹

本文使用 pygame 中的 Sprite 类构建粒子类,并使粒子可以在游戏界面边缘反弹。

碰撞模拟器 - 粒子反弹 https://github.com/Newverse-Wiki/Code-for-Blog/tree/main/Pygame-On-Web/Collision-Simulator/Particle

总览

项目代码可从上述 GitHub - Particle 链接下载。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# 项目结构
$ tree Particle
Particle
├── build
│   └── web
│       ├── favicon.png
│       ├── index.html
│       └── particle.apk
├── debug.py
├── game.py
├── main.py
└── particle.py

# python 运行
$ python Particle/main.py

# pygbag 打包
$ pygbag Particle

build/web 目录下为 pygbag 打包好的网页项目,将该目录下的所有内容上传至网站服务器即可完成部署。

其中,main.pydebug.py 内容与 使用 pygame 搭建网页游戏框架 中的内容一致。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# 引入 pygame 模块
import pygame
# 引入 asyncio 模块,game.py 中仅需 2 行与之相关的代码
import asyncio
import random
import math

# 引入 debug 函数,方便在游戏界面上输出调试信息
from debug import debug
from particle import Particle

class Game:

    """Game 类承载游戏主循环

    Game(dims, FPS)

    定义游戏界面的尺寸 dims,游戏帧数 FPS,控制游戏流程

    """

    def __init__(self, dims, FPS = 60):
        self.dims = dims
        self.FPS  = FPS

        # 初始化pygame,预定义各种常量
        pygame.init()

    def generate(self, position):
        radius = 20
        speed = 200
        # 速度方向随机
        angle = random.random() * 2.0 * math.pi
        velocity = (speed * math.cos(angle), speed * math.sin(angle))

        return Particle(position, radius, velocity)

    # 游戏主循环所在函数需要由 async 定义
    async def start(self):
        # 初始化游戏界面(screen):尺寸、背景色等
        screen = pygame.display.set_mode(self.dims)
        screen_width, screen_height = self.dims
        screen_color = 'Black'

        # 初始化游戏时钟(clock),由于控制游戏帧率
        clock = pygame.time.Clock()

        particle = self.generate((screen_width / 2, screen_height / 2))

        # 游戏运行控制变量(gamen_running)
        # True:游戏运行
        # False:游戏结束
        game_running = True
        # 游戏主循环
        while game_running:
            # 按照给定的 FPS 刷新游戏
            # clock.tick() 函数返回上一次调用该函数后经历的时间,单位为毫秒 ms
            # dt 记录上一帧接受之后经历的时间,单位为秒 m
            # 使用 dt 控制物体运动可以使游戏物理过程与帧率无关
            dt = clock.tick(self.FPS) / 1000.0
            # 使用 asyncio 同步
            # 此外游戏主体代码中不需要再考虑 asyncio
            await asyncio.sleep(0)

            # 游戏事件处理
            # 包括键盘、鼠标输入等
            for event in pygame.event.get():
                # 点击关闭窗口按钮或关闭网页
                if event.type == pygame.QUIT:
                    game_running = False

            # 以背景色覆盖刷新游戏界面
            screen.fill(screen_color)

            particle.update(dt)
            screen.blit(particle.image, particle.rect)

            # 调用 debug 函数在游戏界面左上角显示游戏帧率
            debug(f"{clock.get_fps():.1f}", color = 'green')

            # 将游戏界面内容输出至屏幕
            pygame.display.update()

        # 当 game_running 为 False 时,
        # 跳出游戏主循环,退出游戏
        pygame.quit()
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import pygame

class Particle(pygame.sprite.Sprite):
    def __init__(self, position, radius, velocity):
        super().__init__()

        self.position = pygame.Vector2(position)
        self.velocity = pygame.Vector2(velocity)

        self.radius = radius
        self.color = 'blue'

        self.image = pygame.Surface((2 * radius, 2 * radius))
        self.image.fill('black')
        self.image.set_colorkey('black')
        pygame.draw.circle(self.image, self.color, (radius, radius), radius)
        self.rect = self.image.get_rect(center = self.position)

    def bounce(self):
        screen_width, screen_height = pygame.display.get_window_size()

        bound_left   = self.radius
        bound_right  = screen_width - self.radius
        bound_top    = self.radius
        bound_bottom = screen_height - self.radius

        if self.position.x < bound_left:
            self.position.x = 2.0 * bound_left - self.position.x
            if self.velocity.x < 0:
                self.velocity.x *= -1.0
        elif self.position.x > bound_right:
            self.position.x = 2.0 * bound_right - self.position.x
            if self.velocity.x > 0:
                self.velocity.x *= -1.0

        if self.position.y < bound_top:
            self.position.y = 2.0 * bound_top - self.position.y
            if self.velocity.y < 0:
                self.velocity.y *= -1.0
        elif self.position.y > bound_bottom:
            self.position.y = 2.0 * bound_bottom - self.position.y
            if self.velocity.y > 0:
                self.velocity.y *= -1.0

    def update(self, dt):
        self.position += self.velocity * dt
        self.bounce()
        self.rect.center = self.position

Sprite 类 与 Group 类

Sprite 类作为 pygame 提供的轻量级基础类,旨在作为游戏中各种对象的基类。 Sprite 类的实例对应游戏中的单个物体。 pygame 还提供了 Group 类用来存放和操作多个 Sprite 实例,以简化游戏代码中处理同一类对象的流程。

Sprite 类有两个基本属性 imagerect,用来存放图片和其在游戏界面中的位置。 将多个定义好 imagerect 属性的 Sprite 类实例存放到同一个 Group 类实例中后, 可以方便的调用 Group 类的 draw() 函数,将所有 Sprite 实例绘制到游戏界面内。

Sprite 类有一个基本函数 update(),用于更新其实例的自身状态。 调用 Group 类的 update() 函数同样可以方便地执行其内所有 Sprite 实例的 update() 函数。

同时,Group 类还提供了一整套添加、查找、删除其内 Sprite 实例的函数, 使用 Sprite 和 Group 类可以极大的方便我们处理游戏内的同一类对象。

本文主要介绍 Sprite 类的继承和使用,本系列下一篇会介绍 Group 类的使用。

Particle 类

Particle 类继承自 Sprite 类,其实例为碰撞模拟器中的粒子,与碰撞有关的物理属性为位置 position、半径 radius、速度 velocity,与游戏画面有关的属性为图片 image 和矩形 rect

Particle 类的初始化:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
class Particle(pygame.sprite.Sprite):
    def __init__(self, position, radius, velocity):
        super().__init__()

        self.position = pygame.Vector2(position)
        self.velocity = pygame.Vector2(velocity)

        self.radius = radius
        self.color = 'blue'

        self.image = pygame.Surface((2 * radius, 2 * radius))
        self.image.fill('black')
        self.image.set_colorkey('black')
        pygame.draw.circle(self.image, self.color, (radius, radius), radius)
        self.rect = self.image.get_rect(center = self.position)
  • pygame.Vector2(x, y) 为 pygame 中的二维向量,可以方便的进行向量点乘、叉乘、投影等运算,在涉及到物体运动处理时使用向量运算更方便。
  • pygame.Surface((width, height)) 可生成 pygame 中的平面,用于绘制图形。
  • pygame.draw.circle(surf, color, (x, y), radius) 用于在 surf 平面上的 (x, y) 位置绘制一个半径为 radius 、颜色为 color 的圆,作为粒子。
  • Surface.get_rect() 用于从平面生成矩形,参数可以给定矩形的位置,从而固定平面的位置。

定义好 imagerect 之后,只需在游戏主循环中使用 screen.blit(particle.image, particle.rect) 就可以在游戏界面给定位置绘制 particle。

生成粒子

在 Game 类中定义粒子生成函数:

1
2
3
4
5
6
7
8
def generate(self, position):
    radius = 20
    speed = 200
    # 速度方向随机
    angle = random.random() * 2.0 * math.pi
    velocity = (speed * math.cos(angle), speed * math.sin(angle))

    return Particle(position, radius, velocity)

然后在游戏主循环前调用生成函数:

1
particle = self.generate((screen_width / 2, screen_height / 2))

在游戏界面中央生成速度方向随机的粒子。

粒子运动

只需在 update(dt) 函数中根据粒子运动速度,更新粒子的位置和图片矩形即可。

1
2
3
def update(self, dt):
    self.position += self.velocity * dt
    self.rect.center = self.position

在游戏主循环中运行:

1
particle.update(dt)

边界反弹

上述定义中,粒子的位置坐标对应粒子的圆心。 考虑粒子与游戏界面左侧边界 x = 0 碰撞时,粒子圆心实际可到达的左侧边界为 bound_left = self.radius

p o s b . o x u n d _ l e f t

当粒子 X 轴坐标 self.position.x < bound_left 时,表明粒子已经与左侧边界发生碰撞,需要将粒子的位置置于其碰撞之后的位置,并改变速度 X 轴方向的分量。

粒子在 X 轴方向上向左多运动的距离为 bound_left - self.position.x,这一距离再加上 bound_left 即为粒子碰撞后的 X 坐标,
bound_left + (bound_left - self.position.x) = 2 * bound_left - self.position.x
粒子在 Y 轴方向上的坐标不变。

速度方面,粒子碰撞后在 X 轴上向反方向运动,其速度 X 轴的分量 self.velocity.x *= -1。 为了避免粒子在边界线上反复抖动,需先判断此时粒子时向左运动 self.velocity.x < 0 后,再改变其运动方向。

因此,粒子在左侧边界反弹的代码为:

1
2
3
4
5
6
bound_left = self.radius

if self.position.x < bound_left:
    self.position.x = 2.0 * bound_left - self.position.x
    if self.velocity.x < 0:
        self.velocity.x *= -1.0

粒子在游戏界面其他边缘反弹的代码逻辑与上述代码一致,具体可参考 particle.py

更新粒子位置

由于粒子在界面边缘反弹后,其坐标会发生变化,因此粒子图片矩形的更新需在 bounce() 函数调用之后。

因此 update(dt) 函数应更新为:

1
2
3
4
def update(self, dt):
    self.position += self.velocity * dt
    self.bounce()
    self.rect.center = self.position
0%