Featured image of post Genshin Impact Lottery 开发笔记

Genshin Impact Lottery 开发笔记

一个基于Pygame框架,模仿原神祈愿系统完成的抽奖系统。

引子

这个项目其实是为了班级元旦晚会的抽奖环节而设计的。由于班上几位原批+点子王一拍脑袋,决定整场晚会从幻灯片到背景音乐到幕间游戏全用原神的风格,并且发现现有的抽奖软件都 UI 太过古早且不支持自定义权重,于是就决定自己完成一个程序。

另外,我加入了抽到相应学生会显示对应图片的功能,以提高节目效果(e gao)。

前端设计

项目的前端基于 Pygame 框架,以此来达成绘制出类似于原神祈愿界面的目的。

素材查找

项目所使用的图片素材来源于开源项目Mantan21/Genshin-Impact-Wish-Simulator。通过其我们可以很轻松地得到无背景的按钮、边框及祈愿图片、背景、图标等素材。

面向对象的编程

获得素材之后,紧接着要做的就是完成绘制图片所需要的方法。

经过整理,发现要想完成界面的渲染,我们需要以下对象类。

objectdescription
Text渲染文字
Image渲染图片
ImageFixed渲染固定尺寸的图片
ColorSurface渲染图形
ButtonText渲染文字按钮
ButtonImage渲染图片按钮
ButtonColorSurface渲染图形按钮

其中,每个类都需要一个draw方法来完成对象的绘制,而按钮类需要handle_event方法来会调事件。

这些代码均在ui.py中。

图形类的编写

下面以Text对象为例子,讲讲图形类是如何编写的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# ui.py
class Text:
    def __init__(self, text: str, font_name, color, font_size):
        """
        text: Text to render
        font_name: Filename of the font (in assests directory)
        color: Color of the text
        font_size: Scale of the text
        """
        self.text = text
        self.color = color
        self.font_type = get_path(f'assets/fonts/{font_name}')
        self.font_size = font_size
        font = pygame.font.Font(self.font_type, self.font_size)
        self.text_image = font.render(
            self.text, True, self.color).convert_alpha()
        self.text_width = self.text_image.get_width()
        self.text_height = self.text_image.get_height()

    def draw(self, canvas: pygame.Surface, center_x, center_y):
        upperleft_x = center_x - self.text_width / 2
        upperleft_y = center_y - self.text_height / 2
        canvas.blit(self.text_image, (upperleft_x, upperleft_y))

需要注意的是,为了方便主程序的编写,在draw方法中我进行了坐标的转译,使得在调用时不需要左上角坐标而只需要中心坐标就行。

另外,为了方便图形的渲染,可以使用如下方法进行放缩。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# ui.py > Image
    def __init__(self, img_name, ratio=GLOBAL_RATIO):
        self.img_name = img_name
        self.ratio = ratio
        self.image_original = pygame.image.load(
            get_path(f'assets/images/{self.img_name}')).convert_alpha()
        self.image_width = self.image_original.get_width()
        self.image_height = self.image_original.get_height()

        self.size_scaled = (self.image_width * self.ratio,
                            self.image_height * self.ratio)
        self.image_scaled = pygame.transform.smoothscale(
            self.image_original, self.size_scaled)
        self.img_width_scaled = self.image_scaled.get_width()
        self.img_height_scaled = self.image_scaled.get_height()

按钮类的编写

按钮类与上述大体相同,唯一的区别就是需要一个handle_event方法。

1
2
3
4
5
# ui.py > ButtonImage
    def handle_event(self, command, *args):
        self.hovered = self.rect.collidepoint(pygame.mouse.get_pos())
        if self.hovered:
            command(*args)

如是,我们即可在主循环中重复检测是否将鼠标移动到按钮上并点击,以此来达到承载事件的目的。

主界面的配置

程序的主界面主要有三部份构成:

  • 背景
  • 结果卡片(result_card)
    • 用户图片(image)
    • 文字背景(text_background)
    • 中奖人姓名(text)
  • 按钮
    • 开始抽奖按钮(roll_button)
    • 配置按钮(config_button)

在本章节中,我们只讲背景和结果卡片的渲染。完整主界面的渲染与按钮我们在后文中会提及。

背景

很简单,没什么可讲的。

1
2
3
# main.py > InterFace
    def load_background(self):
        Image('background.webp').draw(self.canvas, self.width/2, self.height/2)

结果卡片

这是三者中最复杂的部分。其中主要分为三部份,要依次渲染以面上面的内容被下面的内容覆盖,

在未开始抽奖的情况下,先渲染初始的结果卡片。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# main.py > InterFace
    def load_init_result_card(self):
        for pos in RESULTCARD_POSX:
            Image("resultcard-bg.webp").draw(self.canvas,
                                             self.width*pos, self.height*0.45)
            Image("placeholder-face.webp", GLOBAL_RATIO*0.8).draw(self.canvas,
                                                                  self.width*pos, self.height*0.45)
            ColorSurface(COLOR.BLACK, 171*GLOBAL_RATIO, 80*GLOBAL_RATIO,
                         170).draw(self.canvas, self.width*pos, self.height*0.6)
            Text("是谁呢", "HYWenHei-85W.ttf", COLOR.WHITE,
                 20).draw(self.canvas, self.width*pos, self.height*0.6)
            ColorSurface(COLOR.BLACK, 171*GLOBAL_RATIO, 40*GLOBAL_RATIO,
                         170).draw(self.canvas, self.width*pos, self.height*0.66)
            Text(f"好期待呢", "HYWenHei-85W.ttf", COLOR.WHITE,
                 10).draw(self.canvas, self.width*pos, self.height*0.66)

在开始抽奖的情况下,渲染响应抽中人的图片和名称。

 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
# main.py > MainInterFace
    def load_result_card(self, n):
        items = get_items(n)
        for i in range(0, n):
            pos = RESULTCARD_POSX[i]
            Image("resultcard-bg.webp").draw(self.canvas,
                                             self.width*pos, self.height*0.45)
            if items[i].image != '':
                ImageFixed(f"items/{items[i].image}", 171*GLOBAL_RATIO, 357*GLOBAL_RATIO).draw(self.canvas,
                                                                                               self.width*pos, self.height*0.45)
            else:
                Image("placeholder-face.webp", GLOBAL_RATIO*0.8).draw(self.canvas,
                                                                      self.width*pos, self.height*0.45)
            ColorSurface(COLOR.BLACK, 171*GLOBAL_RATIO, 80*GLOBAL_RATIO,
                         170).draw(self.canvas, self.width*pos, self.height*0.6)
            Text(items[i].item, "HYWenHei-85W.ttf", COLOR.WHITE,
                 20).draw(self.canvas, self.width*pos, self.height*0.6)
            ColorSurface(COLOR.BLACK, 171*GLOBAL_RATIO, 40*GLOBAL_RATIO,
                         170).draw(self.canvas, self.width*pos, self.height*0.66)
            Text(f{items[i].weight}", "HYWenHei-85W.ttf", COLOR.WHITE,
                 10).draw(self.canvas, self.width*pos, self.height*0.66)
        for i in range(n,10):
            pos = RESULTCARD_POSX[i]
            Image("resultcard-bg.webp").draw(self.canvas,
                                             self.width*pos, self.height*0.45)
            Image("placeholder-face.webp", GLOBAL_RATIO*0.8).draw(self.canvas,
                                                                  self.width*pos, self.height*0.45)
            ColorSurface(COLOR.BLACK, 171*GLOBAL_RATIO, 80*GLOBAL_RATIO,
                         170).draw(self.canvas, self.width*pos, self.height*0.6)
            Text("这里没人", "HYWenHei-85W.ttf", COLOR.WHITE,
                 20).draw(self.canvas, self.width*pos, self.height*0.6)
            ColorSurface(COLOR.BLACK, 171*GLOBAL_RATIO, 40*GLOBAL_RATIO,
                         170).draw(self.canvas, self.width*pos, self.height*0.66)
            Text(f"只抽了{n}人哦", "HYWenHei-85W.ttf", COLOR.WHITE,
                 10).draw(self.canvas, self.width*pos, self.height*0.66)

后端设计

本项目中使用的随机抽取算法为A-Res算法,其通过关键值变换将权重转化为随机优先级,并用最小堆动态维护优先级最高的几个元素,实现了高效的加权随机抽样。

对于$n$个元素中抽取$m$个样本,其时间复杂度为$O(n\log{m})$。

核心思想:关键值变换

对每个元素(item, weight),计算一个关键值$k_i = u_i^{1/w_i}$:

  • $u_i$:从 [0,1] 均匀分布的随机数。

  • $w_i$:当前元素的权重。

其具有如下数学意义:该变换确保元素被选中的概率与其权重$w_i$成正比。权重越大,$k_i$的期望值越高(因为指数$1/w_i$越小,$u_i^{(1/w_i)}$越接近 1)。

算法流程

  1. 初始化:创建一个空的最小堆heap(堆顶始终是关键值最小的元素)。
  2. 遍历所有元素:
    1. 未填满堆时(len(heap) < m):直接将当前元素的关键值 ki 和元素本身压入堆。
    2. 堆已满(len(heap) = m):若当前ki > 堆顶的关键值,则替换堆顶元素:
      1. (ki, 元素)压入堆。
      2. 弹出堆顶元素(保持堆大小为m)。
  3. 反回结果:遍历结束后,堆中所有元素即为被选中的样本。

伪代码实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
算法:A-Res 加权随机抽样
输入:
  samples: 样本列表,每个元素为 (item, weight)
  m: 需要抽取的样本数量
输出:
  包含 m 个样本的列表

步骤:
1. 初始化空的最小堆 heap  # 存储元组 (key, sample)
2. for 每个样本 s in samples:
   a. 提取权重 w = s.weight
   b. 生成随机数 u ~ Uniform(0, 1)
   c. 计算关键值 key = u^(1/w)
   
   d. if 当前堆大小 < m:
        heapq.push(heap, (key, s))  # 直接加入堆中
   
   e. else if key > heap[0].key:    # 大于堆顶元素
        heapq.push(heap, (key, s))  # 加入堆中
        if 堆大小 > m:
            heapq.pop(heap)          # 弹出最小元素
   
3. 从堆中提取样本:result = [element.sample for element in heap]
4. 返回 result

Python实现

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# lottery.py
def a_res(samples, m):
    """
    :samples: [(item, weight), ...]
    :k: number of selected items
    :returns: [(item, weight), ...]
    """
    heap = []  # [(new_weight, item), ...]
    for sample in samples:
        wi = sample[1]
        ui = random.uniform(0, 1)
        ki = ui ** (1/wi)

        if len(heap) < m:
            heapq.heappush(heap, (ki, sample))
        elif ki > heap[0][0]:
            heapq.heappush(heap, (ki, sample))

            if len(heap) > m:
                heapq.heappop(heap)
    return [item[1] for item in heap]

主程序

主循环

 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
# main.py > InterFace
    def main_interface(self):
        config_button = ButtonImage('button.webp', GLOBAL_RATIO*1.3)
        config_button.draw(self.canvas, self.width*0.3, self.height*0.9)
        Text("更改配置", "HYWenHei-85W.ttf", COLOR.BLACK,
             30).draw(self.canvas, self.width*0.3, self.height*0.9)
        roll_button = ButtonImage('button.webp', GLOBAL_RATIO*1.3)
        roll_button.draw(self.canvas, self.width*0.7, self.height*0.9)
        if rolling:
            Text("停止抽奖", "HYWenHei-85W.ttf", COLOR.BLACK,
                 30).draw(self.canvas, self.width*0.7, self.height*0.9)
        else:
            Text("开始抽奖", "HYWenHei-85W.ttf", COLOR.BLACK,
                 30).draw(self.canvas, self.width*0.7, self.height*0.9)
        while True:
            if rolling == True:
                self.load_result_card(n)
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    sys.exit()
                if event.type == pygame.MOUSEBUTTONDOWN:
                    roll_button.handle_event(self.on_roll_button_clicked)
                    config_button.handle_event(self.on_config_button_clicked)
                    continue
            pygame.time.delay(10)
            pygame.display.update()

两个按钮

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# main.py > InterFace
    def on_config_button_clicked(self):
        global n
        n = int(eg.integerbox("设置抽取人数","Settings",10,1,10))

    def on_roll_button_clicked(self):
        global rolling
        rolling = not rolling
        if rolling:
            self.play_gacha_audio()
            self.play_gacha_video()
            pygame.time.delay(500)
            self.load_background()
        else:
            pygame.mixer.music.fadeout(500)
        self.main_interface()

杂项:播放音视频

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# main.py > InterFace
    def play_gacha_audio(self):
        pygame.mixer.init()
        pygame.mixer.music.load(get_path("./assets/audios/zzz-gacha.mp3"))
        pygame.mixer.music.set_volume(0.5)
        pygame.mixer.music.play()

    def play_gacha_video(self):
        movie = VideoFileClip(get_path("./assets/videos/gacha.mp4"))
        frames = movie.iter_frames()
        clock = pygame.time.Clock()
        while True:
            for event in pygame.event.get():
                if event.type == pygame.QUIT:
                    sys.exit()
            frame = next(frames, None)
            if frame is None:
                return
            else:
                pygame_frame = pygame.surfarray.make_surface(
                    frame.swapaxes(0, 1))
                self.canvas.blit(pygame_frame, (0, 0))
                clock.tick(movie.fps)
                pygame.display.update()
使用 Hugo 构建
主题 StackJimmy 设计