本文使用 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 的 image
和 rect
属性,以及 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 )