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

本文使用 pygame 中的 Group 类容纳多个随机粒子,并使粒子之间可以相互碰撞。

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

总览

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

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

# python 运行
$ python Particle/main.py

# pygbag 打包
$ pygbag Particle

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

其中,main.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
# 引入 pygame 模块
import pygame

class Debug:

    """ 调试类:方便在游戏界面输出调试信息

    Debug(screen)

    将调试信息输出至游戏界面 screen

    """

    def __init__(self, screen):
        # 获取游戏界面及其尺寸
        self.screen = screen
        self.width, self.height = screen.get_size()
        
        self.padding = 10

        self.map = {
                'topleft':     (self.padding,              self.padding),
                'midtop':      (self.width / 2,            self.padding),
                'topright':    (self.width - self.padding, self.padding),
                'midleft':     (self.padding,              self.height / 2),
                'center':      (self.width / 2,            self.height / 2),
                'midright':    (self.width - self.padding, self.height / 2),
                'bottomleft':  (self.padding,              self.height - self.padding),
                'midbottom':   (self.width / 2,            self.height - self.padding),
                'bottomright': (self.width - self.padding, self.height - self.padding)}
        
        # 调用 pygame 内置默认字体
        self.font = pygame.font.Font(None, 30)

    def debug(self, info, color = 'white', anchor = 'topleft'):

        """ debug 函数

        debug(info, color = 'white', anchor = 'topleft')

        将调试信息 info,以字符串格式输出至游戏界面
        可以自定义字体颜色 color 和输出位置 anchor

        可选的 anchor 位置有:
        'topleft',    'midtop',    'topright',
        'midleft',    'center',    'midright',
        'bottomleft', 'midbottom', 'bottomright'

        """

        # 渲染调试信息
        debug_surf = self.font.render(str(info), True, color)
        # 给定调试信息输出位置
        anchor_pos = {anchor: self.map[anchor]}
        debug_rect = debug_surf.get_rect(**anchor_pos)
        # 将调试信息背景设置为黑色,以覆盖游戏界面中的其他元素
        pygame.draw.rect(self.screen, 'black', debug_rect)
        # 将调试信息输出至游戏界面
        self.screen.blit(debug_surf, debug_rect)
  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
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# 引入 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, groups):

        """ 生成一个随机粒子

        generate(groups)

        生成粒子的 位置、速度、半径、密度均随机。

        """

        radius = random.randint(15, 20)
        x = random.randint(radius, self.dims[0] - radius)
        y = random.randint(radius, self.dims[1] - radius)

        speed = random.randint(100, 200)
        # 速度方向随机
        angle = random.random() * 2.0 * math.pi
        velocity = (speed * math.cos(angle), speed * math.sin(angle))

        density = random.randint(1, 20)

        Particle((x, y), velocity, radius, density, groups)

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

        debug = Debug(screen)

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

        particles = pygame.sprite.Group()
        self.generate(particles)

        # 游戏运行控制变量(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
                # 按 P 键添加一个随机粒子
                if event.type == pygame.KEYDOWN:
                    if event.key == pygame.K_p:
                        self.generate(particles)

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

            list_particles = particles.sprites()
            # 每对粒子都进行碰撞检测
            for i, p1 in enumerate(list_particles):
                # 每对粒子仅进行一次碰撞检测
                for p2 in list_particles[i+1:]:
                    p1.collide(p2)

            # 调用 Group 类的 update() 函数,更新粒子状态
            particles.update(dt)
            # 调用 Group 类的 draw() 函数,绘制粒子
            particles.draw(screen)

            # 调用 debug 函数在游戏界面上方中间显示粒子个数
            debug.debug(len(list_particles), 'blue', 'midtop')
            # 调用 debug 函数在游戏界面左上角显示游戏帧率
            debug.debug(f"{clock.get_fps():.1f}", '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
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
import pygame

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

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

        self.radius  = radius
        self.density = density
        self.mass    = density * radius ** 2

        # 密度越大,蓝色越深
        rg_value = 200 - density * 10
        self.color = pygame.Color(rg_value, rg_value, 255)

        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 collide(self, p2):
        p1 = self

        separation  = p1.position - p2.position
        overlap = p1.radius + p2.radius - separation.length()

        if overlap > 0:
            m1 = p1.mass
            m2 = p2.mass
            r1 = p1.position
            r2 = p2.position
            v1 = p1.velocity
            v2 = p2.velocity

            v1n = v1 - 2 * m2 / (m1 + m2) * (v1 - v2).dot(r1 - r2) / (r1 - r2).length_squared() * (r1 - r2)
            v2n = v2 - 2 * m1 / (m1 + m2) * (v2 - v1).dot(r2 - r1) / (r2 - r1).length_squared() * (r2 - r1)

            p1.velocity = v1n
            p2.velocity = v2n

            separation.scale_to_length(overlap)
            p1.position += 0.5 * separation
            p2.position -= 0.5 * separation

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

Group 类

Group 类作为 pygame 提供的轻量级容器类,旨在作为 Sprite 类实例的容器,为游戏中对象的批量化处理提供便利。

本系列上一篇简要介绍了 Group 类与 Sprite 类的关系,本文主要介绍 Group 类的使用,本系列下一篇会介绍 Group 类的继承与功能自定义。

作为 Sprite 的容器,Group 的一个基本功能就是将 Sprite 加入 Group,有三种实现方式:

  • 在 Group 类实例化时放入 Sprite 实例:
    1
    2
    3
    4
    5
    
    # 生成一个 Sprite 实例
    sprite = pygame.sprite.Sprite()
    
    # 生成一个 Group 实例,同时将 sprite 放入其中
    group = pygame.sprite.Group(sprite)
  • 在 Sprite 类实例化时加入 Group 实例:
    1
    2
    3
    4
    5
    
    # 生成一个 Group 实例
    group = pygame.sprite.Group()
    
    # 生成一个 Sprite 实例,同时将其放入 group 中
    sprite = pygame.sprite.Sprite(group)
  • 将 Sprite 实例加入 Group 实例:
     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    
    # 生成一个 Sprite 实例
    sprite = pygame.sprite.Sprite()
    
    # 生成一个 Group 实例
    group = pygame.sprite.Group()
    
    # 将 sprite 放入 group 中
    sprite.add(group)
    # 也可以将向 group 中加入 sprite
    group.add(sprite)

本系列上一篇中我们已经提到过,当定义好 sprite 的 imagerect 属性,以及 update() 函数后,只需在游戏主循环中调用 group 的 update()draw() 函数:

103
104
105
106
            # 调用 Group 类的 update() 函数,更新粒子状态
            particles.update(dt)
            # 调用 Group 类的 draw() 函数,绘制粒子
            particles.draw(screen)

即可批量完成 group 中所有 sprite 的状态更新和绘制。

Particle 类

定义

为实现粒子之间的碰撞,我们需要为 Particle 类添加密度 density 和质量 mass

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

        self.radius  = radius
        self.density = density
        self.mass    = density * radius ** 2

        # 密度越大,蓝色越深
        rg_value = 200 - density * 10
        self.color = pygame.Color(rg_value, rg_value, 255)

同时我们根据粒子的密度决定粒子的颜色,粒子密度越高,蓝色越深。

  • pygame.Color(r, g, b) 定义 pygame 中的 RGB 颜色。
  • super().__init__(groups) 调用 Sprite 父类的初始化函数,将实例化的 particle 加入 groups

生成

修改 game.py 中生成粒子的 generation() 函数:

29
30
31
32
33
34
35
36
37
38
39
40
41
42
    def generate(self, groups):

        radius = random.randint(15, 20)
        x = random.randint(radius, self.dims[0] - radius)
        y = random.randint(radius, self.dims[1] - radius)

        speed = random.randint(100, 200)
        # 速度方向随机
        angle = random.random() * 2.0 * math.pi
        velocity = (speed * math.cos(angle), speed * math.sin(angle))

        density = random.randint(1, 20)

        Particle((x, y), velocity, radius, density, groups)

粒子的半径 radius、位置 position、速度 velocity、密度 density 均随机。

在生成粒子的同时将其加入到 groups 中,这样我们就不需要在游戏主循环中直接操作单个粒子,只需要操作容纳所有粒子的 groups 即可。

添加

P 键在游戏中添加一个随机粒子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# 在游戏主循环外生成一个 Group 类的实例 particles,用来存放所有粒子
particles = pygame.sprite.Group()

while game_running:
    for event in pygame.event.get():
        # 按 P 键添加一个随机粒子
        if event.type == pygame.KEYDOWN:
            if event.key == pygame.K_p:
                # 生成一个随机粒子,并将其加入 particles
                self.generate(particles)

Pygame 通过 event 队列处理其所有事件消息,如退出游戏、按下/抬起键盘按键、按下/抬起鼠标按键、移动鼠标等。

碰撞处理

注意
与精确处理粒子在界面边缘的反弹相比,这里处理粒子碰撞的方式比较粗略,会引入一定的误差。

碰撞判断

由于所有粒子均为圆形,当两个粒子的位置(圆心)距离小于粒子半径之和时,粒子发生碰撞:

50
51
52
53
54
55
56
    def collide(self, p2):
        p1 = self

        separation  = p1.position - p2.position
        overlap = p1.radius + p2.radius - separation.length()

        if overlap > 0:

速度更新

参考 Wikipedia - Elastic collision # Two-dimensional collision with two moving objects 中给出的速度公式:

$$ \mathbf{v}'_1=\mathbf{v}_1-\frac{2m_2}{m_1+m_2}\frac{\langle\mathbf{v}_1-\mathbf{v}_2,\mathbf{r}_1-\mathbf{r}_2\rangle}{||\mathbf{r}_1-\mathbf{r}_2||^2}(\mathbf{r}_1-\mathbf{r}_2),\\ \mathbf{v}'_2=\mathbf{v}_2-\frac{2m_1}{m_1+m_2}\frac{\langle\mathbf{v}_2-\mathbf{v}_1,\mathbf{r}_2-\mathbf{r}_1\rangle}{||\mathbf{r}_2-\mathbf{r}_1||^2}(\mathbf{r}_2-\mathbf{r}_1) $$
57
58
59
60
61
62
63
64
65
66
67
68
            m1 = p1.mass
            m2 = p2.mass
            r1 = p1.position
            r2 = p2.position
            v1 = p1.velocity
            v2 = p2.velocity

            v1n = v1 - 2 * m2 / (m1 + m2) * (v1 - v2).dot(r1 - r2) / (r1 - r2).length_squared() * (r1 - r2)
            v2n = v2 - 2 * m1 / (m1 + m2) * (v2 - v1).dot(r2 - r1) / (r2 - r1).length_squared() * (r2 - r1)

            p1.velocity = v1n
            p2.velocity = v2n

更新碰撞之后粒子的速度。

位置更新

当检测到粒子碰撞时,两个粒子已经发生重叠,与处理粒子在界面边缘反弹的方式一样,需更新粒子位置使其不再重叠:

70
71
72
            separation.scale_to_length(overlap)
            p1.position += 0.5 * separation
            p2.position -= 0.5 * separation
注意
这里更新粒子碰撞后位置的方式十分粗略,只是简单的将两个粒子沿其圆心连线推开。

碰撞检测

在游戏主循环中,依次对每对粒子进行碰撞检测并处理:

 96
 97
 98
 99
100
101
            list_particles = particles.sprites()
            # 每对粒子都进行碰撞检测
            for i, p1 in enumerate(list_particles):
                # 每对粒子仅进行一次碰撞检测
                for p2 in list_particles[i+1:]:
                    p1.collide(p2)
  • pygame.sprite.Group.sprites() 返回 Group 中存储的 Sprites 列表。

上述碰撞检测算法的时间复杂度为 $O(n^2)$,当游戏中粒子数超过 500 时计算耗时明显变长,游戏帧数会显著降低。

本系列下一篇会结合 Group 类的继承,使用网格算法将碰撞检测的时间复杂度降为 $O(n)$,足以处理上万个粒子的碰撞。

Debug 类

debug() 函数改进为 Debug 类,使其不再依赖屏幕坐标输出调试信息,而是在屏幕上中下,左中右共 9 个固定位置输出调试信息。

Debug 类在初始化时获取屏幕尺寸信息并确定调试信息输出位置:

14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
    def __init__(self, screen):
        # 获取游戏界面及其尺寸
        self.screen = screen
        self.width, self.height = screen.get_size()
        
        self.padding = 10

        self.map = {
                'topleft':     (self.padding,              self.padding),
                'midtop':      (self.width / 2,            self.padding),
                'topright':    (self.width - self.padding, self.padding),
                'midleft':     (self.padding,              self.height / 2),
                'center':      (self.width / 2,            self.height / 2),
                'midright':    (self.width - self.padding, self.height / 2),
                'bottomleft':  (self.padding,              self.height - self.padding),
                'midbottom':   (self.width / 2,            self.height - self.padding),
                'bottomright': (self.width - self.padding, self.height - self.padding)}
        
        # 调用 pygame 内置默认字体
        self.font = pygame.font.Font(None, 30)

Debug 类的主要功能由其 debug() 函数实现:

debug(info, color = 'white', anchor = 'topleft')

将调试信息 info,以字符串格式输出至游戏界面,可以自定义字体颜色 color 和输出位置 anchor

可选的 anchor 位置有: topleft, midtop, topright, midleft, center, midright, bottomleft, midbottom, bottomright

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    def debug(self, info, color = 'white', anchor = 'topleft'):
        # 渲染调试信息
        debug_surf = self.font.render(str(info), True, color)
        # 给定调试信息输出位置
        anchor_pos = {anchor: self.map[anchor]}
        debug_rect = debug_surf.get_rect(**anchor_pos)
        # 将调试信息背景设置为黑色,以覆盖游戏界面中的其他元素
        pygame.draw.rect(self.screen, 'black', debug_rect)
        # 将调试信息输出至游戏界面
        self.screen.blit(debug_surf, debug_rect)
0%