使用 pygame 构建碰撞模拟器:UI 交互

本文使用 pygame 创建了 UI 组件 Widget 类,并以之为基类创建了按钮 Button 类和开关 Switch 类,以实现游戏中最基础的鼠标点击反馈和开关功能。

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

总览

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

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

# python 运行
$ python Interaction/main.py

# pygbag 打包
$ pygbag Interaction

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

其中,debug.pygrid.pymain.pyparticle.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
 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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# 引入 pygame 模块
import pygame
# 引入 asyncio 模块,game.py 中仅需 2 行与之相关的代码
import asyncio
import random
import math

# 引入 debug 模块,方便在游戏界面上输出调试信息
from debug import Debug
from particle import Particle
from grid import GridGroup
from ui import UIGroup, Button, Switch

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, num, max_radius, groups, grid):

        """ 生成 num 个随机粒子

        generate(groups)

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

        """

        for i in range(num):
            radius = random.randint(2, max_radius)
            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)

            grid.add2grid(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, 10)

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

        max_radius = 5

        particles = pygame.sprite.Group()
        grid = GridGroup(max_radius * 2)
        self.generate(1, max_radius, [particles, grid], grid)

        uis = UIGroup()
        button_plus = Button("+100", (650, 4), self.generate, 'P', uis)
        switch_grid = Switch("Gridding", (1050, 6), 'G', uis)
        mouse_pos = pygame.mouse.get_pos()

        # 游戏运行控制变量(gamen_running)
        # True:游戏运行
        # False:游戏结束
        game_running = True
        switch_grid.is_on = False
        # 游戏主循环
        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
                elif event.type == pygame.KEYDOWN:
                    # 按 P 键添加随机粒子
                    if event.key == pygame.K_p:
                        self.generate(100, max_radius, [particles, grid], grid)
                    # 按 G 键切换碰撞检测算法
                    if event.key == pygame.K_g:
                        switch_grid.switch()
                elif event.type == pygame.MOUSEMOTION:
                    # 当鼠标移动时更新鼠标所在位置
                    mouse_pos = event.pos
                elif event.type == pygame.MOUSEBUTTONDOWN:
                    uis.on_click(event.pos, 100, max_radius, [particles, grid], grid)

            # 以背景色覆盖刷新游戏界面
            screen.fill(screen_color)
            
            list_particles = particles.sprites()
            total_num = len(list_particles)
            # 当粒子总数超过 801 时,启用网格算法,禁用算法切换功能
            if total_num > 801:
                switch_grid.is_on = True
                switch_grid.is_available = False
            else:
                switch_grid.is_available = True

            if switch_grid.is_on:
                # 调用 GridGroup 类的 update() 函数,更新粒子状态
                grid.update(dt)
                # 调用 GridGroup 类的 draw() 函数,绘制粒子
                grid.draw(screen)
            else:
                # 每对粒子都进行碰撞检测
                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)

            # 根据鼠标位置和键盘按键信息更新组件的外观渲染
            uis.update(mouse_pos, pygame.key.get_pressed())
            uis.draw(screen)

            # 调用 debug 函数在游戏界面上方中间显示粒子个数
            debug.debug(total_num, 'white', '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
 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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
import pygame

class UIGroup(pygame.sprite.Group):
    def __init(self, *sprites):
        super().__init__(*sprites)

    def on_click(self, pos, *args, **kwargs):
        # 依次调用所有 UI 组件的 on_click() 命令,
        # 判断鼠标点击坐标是否位于组件响应区域之内
        for sprite in self.sprites():
            sprite.on_click(pos, *args, **kwargs)

class Widget(pygame.sprite.Sprite):
    def __init__(self, info, keys, groups):
        super().__init__(groups)

        self.info = str(info)
        self.pykey = eval("pygame.K_" + keys.lower())

        # 定义组件的响应外观类别
        self.reactions = ['disable', 'normal', 'active']
        # 定义组件的响应状态
        self.is_available = True
        self.is_active = False

        self.font = pygame.font.Font(None, 30)
        
        self.padding = 2
        self.margin  = 2

    def func(self, *args, **kwargs):
        pass

    def text_surf(self, text, color):
        surf = self.font.render(text, True, color)
        return self.pad_surf(surf)

    def pad_surf(self, inner_surf):
        # 为部件显示内容添加内边距 padding
        size = (inner_surf.get_width()  + 2 * self.padding, 
                inner_surf.get_height() + 2 * self.padding)
        surf = pygame.Surface(size)
        surf.set_colorkey('black')
        surf.blit(inner_surf, ((self.padding, self.padding), inner_surf.get_size()))

        return surf

    def arrange_surfs(self, surfs, active_id = -1):
        """ 将所有部件水平排列,竖直居中绘制到组件平面上

        arrange_surfs(surfs, active_id)

        surfs: 所有部件平面的列表
        active_id: 鼠标响应部件序号,-1 为整个组件
        """

        # 确定组件高度,部件上下边缘间距最小为 margin
        height = max([surf.get_height() for surf in surfs]) + 2 * self.margin
        
        # 确定所有部件位置
        x = self.margin
        rects = []
        for surf in surfs:
            # 竖直居中对齐
            rects.append(surf.get_rect(midleft = (x, height / 2)))
            # 水平间距为 margin
            x = rects[-1].right + self.margin
        # 确定组件宽度
        width = x
        
        surf = pygame.Surface((width, height))
        surf.set_colorkey('black')
        # 同时绘制所有部件
        surf.blits(zip(surfs, rects))

        # 返回组件平面以及鼠标响应区域
        if active_id < 0:
            # 默认鼠标响应区域为整个组件
            return surf, surf.get_rect().copy()
        else:
            # 可指定鼠标响应区域为某个部分
            return surf, rects[active_id]

    def align_active(self, offset):
        # 将响应区域与组件在屏幕上的绘制位置对齐
        self.active_rect.x += self.rect.x
        self.active_rect.y += self.rect.y
        # 消除响应区域的内外边距
        self.active_rect.inflate_ip(-2 * offset, -2 * offset)

    def update(self, pos, pressed):
        # 当组件绑定的快捷键被按下或鼠标掠过组件的响应区域
        if pressed[self.pykey] or self.active_rect.collidepoint(pos):
            self.is_active = True
        else:
            self.is_active = False

    def on_click(self, pos, *args, **kwargs):
        if self.active_rect.collidepoint(pos):
            self.func(*args, **kwargs)

class Switch(Widget):
    def __init__(self, info, position, keys, groups):
        super().__init__(info, keys, groups)

        self.is_on = False

        self.radius = int(self.font.get_height() / 2)

        # 定义开关状态
        self.states = ['off', 'on']

        # 定义不同开关状态以及响应状态下组件的配色
        self.color_text   = {'off': 'gray', 'on': 'white'}
        self.color_switch = {'off': 'red',  'on': 'green'}

        self.color_border = {'off': {'disable': 'gray', 
                                     'normal':  'red', 
                                     'active':  'white'}, 
                             'on':  {'disable': 'gray', 
                                     'normal':  'green', 
                                     'active':  'white'}}
        self.color_slider = {'off': {'disable': 'red', 
                                     'normal':  'gray', 
                                     'active':  'white'}, 
                             'on':  {'disable': 'green', 
                                     'normal':  'gray', 
                                     'active':  'white'}}

        # 绘制不同状态下组件的外观
        self.images = {}
        for state in self.states:
            self.images[state] = {}
            for react in self.reactions:
                self.images[state][react] = self.init_image(state, react)

        self.rect = self.image.get_rect(topleft = position)
        self.align_active(self.padding)

    # 根据组件的响应状态,渲染不同的组件外观
    @property
    def image(self):
        if self.is_available:
            if self.is_active:
                react = 'active'
            else:
                react = 'normal'
        else:
            react = 'disable'

        if self.is_on:
            state = 'on'
        else:
            state = 'off'

        return self.images[state][react]

    def init_image(self, state, react):
        surfs = []
        surfs.append(self.text_surf(self.info, self.color_text[state]))
        surfs.append(self.init_switch(state, react))

        surf, self.active_rect = self.arrange_surfs(surfs, 1)

        return surf

    def init_switch(self, state, react):
        switch_size = (4 * self.radius, 2 * self.radius)
        switch_surf = pygame.Surface(switch_size)
        switch_surf.set_colorkey('black')
        pygame.draw.rect(switch_surf, self.color_switch[state],        ((0, 0), switch_size), 0, self.radius)
        pygame.draw.rect(switch_surf, self.color_border[state][react], ((0, 0), switch_size), 2, self.radius)

        slider_size = ((self.radius - 1) * 2, (self.radius - 1) * 2)
        slider_surf = pygame.Surface(slider_size)
        slider_surf.set_colorkey('black')
        pygame.draw.ellipse(slider_surf, self.color_slider[state][react], ((0, 0), slider_size))

        slider_rect = slider_surf.get_rect()
        # on: left slider
        if state == 'on':
            slider_rect.midright = (switch_size[0] - 2, switch_size[1] / 2)
        # off: right slider
        else:
            slider_rect.midleft = (2, switch_size[1] / 2)

        switch_surf.blit(slider_surf, slider_rect)

        return self.pad_surf(switch_surf)

    def switch(self):
        if self.is_available:
            self.is_on = not self.is_on

    def func(self, *args, **kwargs):
        self.switch()

class Button(Widget):
    def __init__(self, info, position, func, keys, groups):
        super().__init__(info, keys, groups)

        self.func = func

        self.border_width = 2

        # 定义组件的响应外观类别对应的颜色
        self.color = {'disable': 'gray',
                      'normal':  'gray', 
                      'active':  'white'}

        # 生成不同响应状态下的组件外观
        self.images = {}
        for react in self.reactions:
            self.images[react] = self.init_image(react)

        self.rect = self.image.get_rect(topleft = position)
        self.align_active(self.margin)

    # 根据组件的响应状态,渲染不同的组件外观
    @property
    def image(self):
        if self.is_available:
            if self.is_active:
                react = 'active'
            else:
                react = 'normal'
        else:
            react = 'disable'

        return self.images[react]

    def border_surf(self, inner_surf, color):
        radius = int(inner_surf.get_height() / 2) + self.border_width

        size = (inner_surf.get_width() + 2 * radius, inner_surf.get_height() + 2 * self.border_width)
        surf = pygame.Surface(size)
        surf.set_colorkey('black')

        pygame.draw.rect(surf, color, ((0, 0), size), self.border_width, radius)

        surf.blit(inner_surf, ((radius, self.border_width), inner_surf.get_size()))

        return surf

    def init_image(self, react):
        text_surf = self.text_surf(self.info, self.color[react])
        border_surf = self.border_surf(text_surf, self.color[react])

        surfs = [border_surf]
        surf, self.active_rect = self.arrange_surfs(surfs)

        return surf

组件结构

组件

组件(Widget)由一个或多个部件(Part)组成:

  • 水平方向:部件与边缘、部件与部件间距为 margin,
  • 竖直方向:居中对齐,部件与边缘最小间距为 margin。
W e i d d g g e e y t m a W r i g d i x g n e t P a r t m a r g i n P a r t m a r g i n P a r t m m a a r m r g a g i r i n g n i n W e i d d g g e e t

实现代码

 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
    def arrange_parts(self, parts, active_id = -1):
        """ 将所有部件水平排列,竖直居中绘制到组件平面上

        arrange_parts(parts, active_id)

        parts: 所有部件平面的列表
        active_id: 鼠标响应部件序号,-1 为整个组件
        """

        # 确定组件高度,部件上下边缘间距最小为 margin
        height = max([part.get_height() for part in parts]) + 2 * self.margin
        
        # 确定所有部件位置
        x = self.margin
        rects = []
        for part in parts:
            # 竖直居中对齐
            rects.append(part.get_rect(midleft = (x, height / 2)))
            # 水平间距为 margin
            x = rects[-1].right + self.margin
        # 确定组件宽度
        width = x
        
        surf = pygame.Surface((width, height))
        surf.set_colorkey('black')
        # 同时绘制所有部件
        surf.blits(zip(parts, rects))

        # 返回组件平面以及鼠标响应区域
        if active_id < 0:
            # 默认鼠标响应区域为整个组件
            return surf, surf.get_rect().copy()
        else:
            # 可指定鼠标响应区域为某个部分
            return surf, rects[active_id]

部件

每个部件(Part)中显示内容(Surf)与边缘内间距 padding;

  • 若有边框,边框绘制在边缘之外。
P O B a r o r r t d e e r d g e p a P d a d r i t n g I S n u n r e f p r a d d i n g P O B a r o r r t d e e r d g e

实现代码

1
2
3
4
5
6
7
8
9
    def pad_surf(self, inner_surf):
        # 为部件显示内容添加内边距 padding
        size = (inner_surf.get_width()  + 2 * self.padding, 
                inner_surf.get_height() + 2 * self.padding)
        surf = pygame.Surface(size)
        surf.set_colorkey('black')
        surf.blit(inner_surf, ((self.padding, self.padding), inner_surf.get_size()))

        return surf

操作响应

组件通常有两种通用状态:可用(enable)和不可用(disable)。

  • 若组件可用,当组件绑定的快捷键被按下或鼠标掠过组件的响应区域时,组件(Widget)颜色整体高亮或显示边框,此时组件处于激活(active)状态,以表明该组件可响应用户操作;

    按钮和开关均可被激活

    实现代码

    1
    2
    3
    4
    5
    6
    
        def update(self, pos, pressed):
            # 当组件绑定的快捷键被按下或鼠标掠过组件的响应区域
            if pressed[self.pykey] or self.active_rect.collidepoint(pos):
                self.is_active = True
            else:
                self.is_active = False
  • 当组件不可用时,不响应用户操作,组件整体颜色亮度较低。

    按钮可被激活,开关不可用

实现代码

 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
        # 定义组件的响应外观类别
        self.reactions = ['disable', 'normal', 'active']
        # 定义组件的响应外观类别对应的颜色
        self.color = {'disable': 'gray',
                      'normal':  'gray', 
                      'active':  'white'}

        # 绘制不同响应状态下的组件外观
        self.images = {}
        for react in self.reactions:
            self.images[react] = self.init_image(react)

        # 定义组件的响应状态
        self.is_available = True
        self.is_active = False

    # 根据组件的响应状态,渲染不同的组件外观
    @property
    def image(self):
        if self.is_available:
            if self.is_active:
                react = 'active'
            else:
                react = 'normal'
        else:
            react = 'disable'

        return self.images[react]

可用组件(Widget)

按钮(Button)

Button(info, position, func, keys, groups)

  • info字符串):按钮上显示的文本信息。
  • position(整数, 整数)):按钮左上角坐标位置。
  • func函数):点击按钮触发的函数。
  • keys字符):按钮绑定的快捷键,如 P
  • groups群组):按钮所属的群组(用于批量处理组件)。

按钮 外观 见上述动图展示。

开关(Switch)

Switch(info, position, keys, groups)

  • info字符串):按钮上显示的文本信息。
  • position(整数, 整数)):按钮左上角坐标位置。
  • keys字符):按钮绑定的快捷键,如 G
  • groups群组):按钮所属的群组(用于批量处理组件)。

开关 外观 见上述动图展示。

0%