mooncake030 commited on
Commit
719b8f9
·
1 Parent(s): 782ebaf
Files changed (11) hide show
  1. README.md +3 -3
  2. SarasaFixedTC-Regular.ttf +3 -0
  3. app.py +864 -0
  4. bgm.mp3 +3 -0
  5. favicon.png +0 -0
  6. gen-roulette-sfx.py +80 -0
  7. reduce-vol.py +15 -0
  8. requirements.txt +1 -0
  9. roulette.wav +3 -0
  10. style.css +50 -0
  11. win.mp3 +3 -0
README.md CHANGED
@@ -1,8 +1,8 @@
1
  ---
2
  title: SecretSanta
3
- emoji: 🌍
4
- colorFrom: gray
5
- colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.0.2
8
  app_file: app.py
 
1
  ---
2
  title: SecretSanta
3
+ emoji: 🎄
4
+ colorFrom: green
5
+ colorTo: red
6
  sdk: gradio
7
  sdk_version: 6.0.2
8
  app_file: app.py
SarasaFixedTC-Regular.ttf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4b83a30eaf68d86fb7d2d275c812dc1f6148a84aef9e13748cb2d0e3823c8402
3
+ size 25098552
app.py ADDED
@@ -0,0 +1,864 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import random
2
+ import time
3
+
4
+ import gradio as gr
5
+ from wcwidth import wcswidth
6
+
7
+
8
+ def quick_draw(
9
+ n, # 要抽出的數量
10
+ do_shuffle, # 是否在抽獎前先打亂原始名單
11
+ no_self, # 是否禁止抽到自己
12
+ names: str, # 名單字串(以換行分隔)
13
+ frame_delay_scale, # 延遲時間縮放倍率,用於加速或減速動畫
14
+ emoji, # 用於顯示箭頭的表情符號
15
+ char_delay=0.01, # 每個字元出現的延遲(製造打字動畫效果)
16
+ item_delay=0.1, # 每項抽完後的延遲(項目間動畫)
17
+ ):
18
+ # 先將 names 字串拆成列表(以換行為分隔)並移除空白行
19
+ src_names = [name for name in names.strip().split("\n") if name]
20
+
21
+ # 若名單人數不足 n,補上「編號 i」
22
+ if len(src_names) < n:
23
+ for i in range(n - len(src_names)):
24
+ src_names.append(f"編號 {i}")
25
+
26
+ # 目標名單(會被打亂後作為抽中結果)
27
+ dst_names = src_names.copy()
28
+ random.shuffle(dst_names)
29
+
30
+ # 若 do_shuffle 為 True,也把原始名單打亂
31
+ random.shuffle(src_names) if do_shuffle else None
32
+
33
+ # 根據 frame_delay_scale 調整延遲(用來快轉/慢動作用)
34
+ char_delay *= frame_delay_scale
35
+ item_delay *= frame_delay_scale
36
+
37
+ # 若不允許抽到自己,需重新洗牌 dst_names,直到沒有任何同名配對
38
+ if no_self:
39
+ while True:
40
+ for src_name, dst_name in zip(src_names, dst_names):
41
+ if src_name == dst_name: # 有抽到自己的情況
42
+ break
43
+ # 若有抽到自己,重新洗牌後再檢查
44
+ if src_name == dst_name:
45
+ random.shuffle(dst_names)
46
+ continue
47
+ break
48
+
49
+ msg = list() # 用來累積每一筆抽獎輸出
50
+
51
+ # 逐一比對 src -> dst
52
+ for src_name, dst_name in zip(src_names, dst_names):
53
+
54
+ # 若抽到自己
55
+ if src_name == dst_name:
56
+ msg.append(f"{src_name} 送給自己!")
57
+ else:
58
+ msg.append(f"{src_name} {emoji}→ {dst_name}")
59
+
60
+ # 若設定了 char_delay,則逐字輸出動畫
61
+ if char_delay:
62
+ for i in range(len(msg[-1])):
63
+ # 產生目前的輸出畫面(前面完整行 + 最後一行逐字顯示)
64
+ yield "\n".join(msg[:-1] + [msg[-1][: i + 1]])
65
+ time.sleep(char_delay)
66
+
67
+ # 每個項目完成後的延遲動畫
68
+ if item_delay:
69
+ yield "\n".join(msg)
70
+ time.sleep(item_delay)
71
+
72
+ # 最終完整結果
73
+ yield "\n".join(msg)
74
+
75
+
76
+ def simple_start(n, names: str):
77
+ # 將輸入的 names 字串按行拆解,並移除空白行
78
+ name_list = [name for name in names.strip().split("\n") if name]
79
+
80
+ # 若名單數量不足 n,則以「編號 i」補足
81
+ if len(name_list) < n:
82
+ for i in range(n - len(name_list)):
83
+ name_list.append(f"編號 {i}")
84
+
85
+ # 將名單再組合成以換行分隔的字串
86
+ name_list = "\n".join(name_list)
87
+ return names, name_list, name_list, ""
88
+
89
+
90
+ def simple_draw(
91
+ src_name_text: str, # 左側來源名單(尚未抽中的人)
92
+ dst_name_text: str, # 右側目標名單(可被抽到的人)
93
+ results: str, # 已抽結果字串
94
+ frame_delay_scale, # 動畫速度縮放倍率
95
+ do_shuffle, # 是否隨機選取來源 index
96
+ no_self, # 是否禁止抽到自己
97
+ enable_sfx, # 是否啟用音效
98
+ emoji, # 顯示箭頭符號
99
+ turn_time=0.25, # 每輪滾動時間
100
+ n_turns=3, # 滾輪來回次數
101
+ n_blink=3, # 中獎後閃爍次數
102
+ blink_delay=0.1, # 閃爍間隔
103
+ ):
104
+ # 若來源名單已空 → 全部抽完
105
+ if not src_name_text.strip():
106
+ gr.Info(title="訊息", message="抽完了!")
107
+ yield src_name_text, dst_name_text, results, None
108
+ return
109
+
110
+ # 拆解來源與目標名單為列表
111
+ src_names = [name for name in src_name_text.strip().split("\n")]
112
+ dst_names = [name for name in dst_name_text.strip().split("\n")]
113
+
114
+ # 套用動畫時間倍率
115
+ turn_time *= frame_delay_scale
116
+ blink_delay *= frame_delay_scale
117
+
118
+ # 如果啟用音效,載入音檔
119
+ rlt_sfx = get_sfx("roulette.wav") if enable_sfx else None
120
+ win_sfx = get_sfx("win.mp3") if enable_sfx else None
121
+
122
+ # 建立格式化名稱+箭頭,並處理全形/半形字寬
123
+ def get_arrow(name, width):
124
+ pad_len = width - wcswidth(name) + 2 # 計算補齊空白寬度
125
+ return name + " " * pad_len + emoji # 在名字後補空白並加箭頭
126
+
127
+ # 選取來源 index(可選擇洗牌或固定從 0 開始)
128
+ src_idx = random.choice(range(len(src_names))) if do_shuffle else 0
129
+ # 隨機選取目標 index
130
+ dst_idx = random.choice(range(len(dst_names)))
131
+
132
+ # 若不允許抽到自己 → 調整 dst_idx
133
+ if no_self:
134
+ # 特例:只有兩人時,抽到自己會導致死循環,需要特殊邏輯
135
+ if len(src_names) == 2:
136
+ src_left_idx = (src_idx + 1) & 1 # 另一個 index
137
+ if src_names[src_left_idx] in dst_names:
138
+ dst_idx = dst_names.index(src_names[src_left_idx])
139
+ else:
140
+ # 一般情況下,若抽到自己就重新選擇
141
+ while src_names[src_idx] == dst_names[dst_idx]:
142
+ dst_idx = random.choice(range(len(dst_names)))
143
+
144
+ dst_name = dst_names[dst_idx] # 最終目標(被抽中的人)
145
+
146
+ # 計算字寬,使箭頭對齊
147
+ max_dst_name_width = max(wcswidth(s) for s in dst_names)
148
+ max_src_name_width = max(wcswidth(s) for s in dst_names)
149
+
150
+ # 在來源名單中標示出「目前正在抽的人」
151
+ curr_src_name = get_arrow(src_names[src_idx], max_src_name_width)
152
+ curr_src_names = src_names[:src_idx] + [curr_src_name] + src_names[src_idx + 1 :]
153
+ src_name_text = "\n".join(curr_src_names)
154
+
155
+ # 顯示來源列表的更新(右側尚未進入滾動)
156
+ yield src_name_text, dst_name_text, results, None
157
+
158
+ # 將實際要抽出的人從來源名單移除
159
+ src_name = src_names.pop(src_idx)
160
+
161
+ # 產生當前滾動階段的目的名單(某一行加上箭頭)
162
+ def get_curr_names(i):
163
+ if i is None:
164
+ return "\n".join(dst_names) # 無高亮,顯示完整列表
165
+
166
+ selected_item = get_arrow(dst_names[i], max_dst_name_width)
167
+ curr_names = dst_names[:i] + [selected_item] + dst_names[i + 1 :]
168
+ return "\n".join(curr_names)
169
+
170
+ # 設定滾輪動畫的延遲(越滾越慢)
171
+ delay = turn_time / len(dst_names)
172
+
173
+ # 主滾動動畫:前後來回 n_turns 次
174
+ for _ in range(n_turns):
175
+ # 從頭滾到尾
176
+ for i in range(0, len(dst_names)):
177
+ yield src_name_text, get_curr_names(i), results, f"{rlt_sfx}{i}"
178
+ time.sleep(delay)
179
+
180
+ # 從尾倒回到頭
181
+ for i in range(len(dst_names) - 2, 0, -1):
182
+ yield src_name_text, get_curr_names(i), results, f"{rlt_sfx}{i}"
183
+ time.sleep(delay)
184
+
185
+ delay *= 2 # 每次回合後增加等待時間(營造減速效果)
186
+
187
+ # 最終慢慢滾到中獎者的位置
188
+ for i in range(0, len(dst_names)):
189
+ yield src_name_text, get_curr_names(i), results, f"{rlt_sfx}{i}"
190
+ if dst_names[i] == dst_name:
191
+ break
192
+ time.sleep(delay)
193
+ time.sleep(delay)
194
+
195
+ # 昇格畫面 → 目標列表停止滾動,高亮中獎者
196
+ yield src_name_text, get_curr_names(None), results, win_sfx
197
+
198
+ # 中獎閃爍效果
199
+ for _ in range(n_blink):
200
+ yield src_name_text, get_curr_names(None), results, None
201
+ time.sleep(blink_delay)
202
+ yield src_name_text, get_curr_names(i), results, None
203
+ time.sleep(blink_delay)
204
+
205
+ # 將已被抽中的目標從名單移除
206
+ dst_names.pop(i)
207
+
208
+ # 記錄抽獎結果
209
+ if src_name == dst_name:
210
+ results = f"{results}{src_name} 抽到自己!\n"
211
+ else:
212
+ results = f"{results}{src_name} {emoji}→ {dst_name}\n"
213
+
214
+ # 更新名單(顯示在前端)
215
+ yield "\n".join(src_names), "\n".join(dst_names), results, None
216
+
217
+ # 若只剩 1 對 1,則自動進行最後一次配對
218
+ if len(dst_names) == 1:
219
+ src_name = src_names.pop(0)
220
+ dst_name = dst_names.pop(0)
221
+ results = f"{results}{src_name} {emoji}→ {dst_name}"
222
+ yield "\n".join(src_names), "\n".join(dst_names), results, None
223
+
224
+
225
+ def gr_textarea(label, interactive=False, lines=10, max_lines=10):
226
+ """
227
+ 建立一個 Gradio TextArea(多行文字輸入框)
228
+
229
+ 參數:
230
+ label — 顯示在元件上的標籤文字
231
+ interactive — 是否允許使用者編輯(False 時僅能顯示)
232
+ lines — 預設顯示的行數高度
233
+ max_lines — 最大可擴張到的行數高度
234
+
235
+ 功能:
236
+ - 用於顯示抽籤名單或抽籤結果
237
+ """
238
+ return gr.TextArea(
239
+ label=label,
240
+ lines=lines,
241
+ max_lines=max_lines,
242
+ interactive=interactive,
243
+ )
244
+
245
+
246
+ def gr_slider(label, value=10, minimum=2, maximum=300):
247
+ """
248
+ 建立一般範圍調整用 Slider(滑桿)
249
+
250
+ 參數:
251
+ label — 標籤文字
252
+ value — 預設值
253
+ minimum — 最小值
254
+ maximum — 最大值
255
+
256
+ 功能:
257
+ - 常用於「參與人數」、「輪盤大小」等需要數值輸入的場景
258
+ """
259
+ return gr.Slider(label=label, value=value, minimum=minimum, maximum=maximum)
260
+
261
+
262
+ def gr_slider_delay(label, value, minimum=0, maximum=1, step=0.01):
263
+ """
264
+ 建立可用於動畫速度控制的 Slider
265
+
266
+ 參數:
267
+ label — 標籤文字
268
+ value — 預設值
269
+ minimum — 最小值
270
+ maximum — 最大值
271
+ step — 調整步進(例如 0.01)
272
+
273
+ 功能:
274
+ - 用於 frame_delay_scale(動畫速率倍率)
275
+ - 使用小步進可使動畫速度更細緻
276
+ """
277
+ return gr.Slider(
278
+ label=label,
279
+ value=value,
280
+ minimum=minimum,
281
+ maximum=maximum,
282
+ step=step, # 微調精度
283
+ )
284
+
285
+
286
+ def draw_roulette(arr: list[str], index: int) -> str:
287
+ # 陣列長度
288
+ n = len(arr)
289
+ if n == 0:
290
+ return
291
+
292
+ # 將陣列平均分配到四邊(上、右、下、左)
293
+ side_length = n // 4 # 每側的基本數量
294
+ remainder = n % 4 # 平均分配後剩餘的數量
295
+
296
+ # 依照剩餘數量,從上→右→下依序多分配 1 個
297
+ length_top = side_length + (1 if remainder > 0 else 0)
298
+ length_right = side_length + (1 if remainder > 1 else 0)
299
+ length_bottom = side_length + (1 if remainder > 2 else 0)
300
+ length_left = side_length # 左側不額外補
301
+
302
+ # 用索引切分四邊
303
+ idx_top_end = length_top
304
+ idx_right_end = idx_top_end + length_right
305
+ idx_bottom_end = idx_right_end + length_bottom
306
+
307
+ list_top = arr[0:idx_top_end]
308
+ list_right = arr[idx_top_end:idx_right_end]
309
+ list_bottom = arr[idx_right_end:idx_bottom_end]
310
+ list_left = arr[idx_bottom_end:]
311
+
312
+ # 計算所有字串最大寬度(支援全形字)
313
+ max_text_width = max((wcswidth(s) for s in arr), default=0)
314
+
315
+ # 每格寬度 = 最大字寬 + padding
316
+ cell_width = max_text_width + 2
317
+ # 使 cell_width 為奇數,以便在中央置中
318
+ if cell_width % 2 == 0:
319
+ cell_width += 1
320
+
321
+ # 上方格子一排的總寬度
322
+ box_inner_width = length_top * cell_width
323
+
324
+ # 左右兩側格子的總高度(每項佔 2 行,中間留空)
325
+ max_side_items = max(length_right, length_left)
326
+ box_inner_height = (max_side_items * 2) + 1
327
+
328
+ # 建立空白網格,後續會在上面放置「@」
329
+ grid = [[" " for _ in range(box_inner_width)] for _ in range(box_inner_height)]
330
+
331
+ # 目標位置(@)的座標
332
+ target_pos = None
333
+
334
+ # 判斷 index 屬於哪一側,並計算其在圓形 ASCII 中的實際位置
335
+ if 0 <= index < idx_top_end:
336
+ # 屬於上側
337
+ local_idx = index
338
+ center_x = (local_idx * cell_width) + (cell_width // 2)
339
+ target_pos = (0, center_x) # row=0 → 第一行
340
+ elif idx_top_end <= index < idx_right_end:
341
+ # 屬於右側
342
+ local_idx = index - idx_top_end
343
+ target_row = 1 + (local_idx * 2) # 每項佔兩行
344
+ target_pos = (target_row, box_inner_width - 2)
345
+ elif idx_right_end <= index < idx_bottom_end:
346
+ # 屬於下側(需反向映射)
347
+ local_idx = index - idx_right_end
348
+ offset_from_right = local_idx
349
+ visual_slot_from_left = (length_top - 1) - offset_from_right
350
+ center_x = (visual_slot_from_left * cell_width) + (cell_width // 2)
351
+ target_pos = (box_inner_height - 1, center_x)
352
+ else:
353
+ # 屬於左側
354
+ local_idx = index - idx_bottom_end
355
+ target_row = (box_inner_height - 2) - (local_idx * 2)
356
+ target_pos = (target_row, 1) # 左側靠內一格
357
+
358
+ # 在 grid 上標記 @ 位置
359
+ if target_pos:
360
+ row, col = target_pos
361
+ if 0 <= row < box_inner_height and 0 <= col < box_inner_width:
362
+ grid[row][col] = "@"
363
+
364
+ # 上方標籤需要向右縮排(給左右側留空間)
365
+ left_margin_width = cell_width
366
+
367
+ # 上邊文字(置中)
368
+ top_labels = "".join([get_centered_cell(s, cell_width) for s in list_top])
369
+
370
+ # 開始組建輸出內容
371
+ outputs = list()
372
+
373
+ # 上方第一行:空白 + 上側文字
374
+ outputs.append(" " * left_margin_width + " " + top_labels)
375
+ # 上方第二行:空白 + 上框線
376
+ outputs.append(" " * left_margin_width + " " + "#" * box_inner_width + " ")
377
+
378
+ # 中間內容(含左側與右側)
379
+ for row in range(box_inner_height):
380
+ left_char = " " * cell_width # 預設左側為空
381
+ right_char = " " * cell_width # 預設右側為空
382
+
383
+ # 左右項目位於 (row - 1) % 2 == 0 的行上
384
+ if (row - 1) % 2 == 0:
385
+ # 計算左側 index(從下往上排)
386
+ calc_left_idx = (box_inner_height - 2 - row) // 2
387
+ is_in_left = 0 <= calc_left_idx < len(list_left)
388
+ if is_in_left and (box_inner_height - 2 - row) % 2 == 0:
389
+ left_char = get_right_align_cell(list_left[calc_left_idx], cell_width)
390
+
391
+ # 計算右側 index(從上往下排)
392
+ calc_right_idx = (row - 1) // 2
393
+ is_in_right = 0 <= calc_right_idx < len(list_right)
394
+ if is_in_right and (row - 1) >= 0:
395
+ right_char = get_left_align_cell(list_right[calc_right_idx], cell_width)
396
+
397
+ # grid[row] 中可能有 @ 或空白
398
+ row_content = "".join(grid[row])
399
+
400
+ # 左 / 框線 / 右
401
+ outputs.append(f"{left_char}#{row_content}#{right_char}")
402
+
403
+ # 下方框線
404
+ outputs.append(" " * left_margin_width + " " + "#" * box_inner_width + " ")
405
+
406
+ # 下方文字排在框線下方(注意 bottom list 是反向排列)
407
+ bottom_padding_cells = length_top - length_bottom
408
+ bottom_border = "".join([get_centered_cell(s, cell_width) for s in list_bottom[::-1]])
409
+ bottom_labels = (" " * cell_width * bottom_padding_cells) + bottom_border
410
+
411
+ outputs.append(" " * left_margin_width + " " + bottom_labels)
412
+
413
+ return "\n".join(outputs)
414
+
415
+
416
+ def get_centered_cell(text, width):
417
+ # 計算字串的實際寬度(支援全形字)
418
+ text_width = wcswidth(text)
419
+
420
+ # 如果字串本身超過格子寬度,直接回傳(不裁切)
421
+ if text_width >= width:
422
+ return text
423
+
424
+ # 需要補的空白總量
425
+ total_space = width - text_width
426
+
427
+ # 左右平均分配空白
428
+ left_space = total_space // 2
429
+ right_space = total_space - left_space
430
+
431
+ # 回傳:左空白 + 文字 + 右空白
432
+ return " " * left_space + text + " " * right_space
433
+
434
+
435
+ def get_right_align_cell(text, width):
436
+ # 計算字串寬度
437
+ text_width = wcswidth(text)
438
+
439
+ # 若字串比格子寬,不處理
440
+ if text_width >= width:
441
+ return text
442
+
443
+ # 左側需要補多少空白,使文字靠右
444
+ # 最右邊保留一格空白,讓版面不緊貼
445
+ left_space = width - text_width - 1
446
+
447
+ # 回傳:左空白 + 文字 + 最右一格空白
448
+ return " " * left_space + text + " "
449
+
450
+
451
+ def get_left_align_cell(text, width):
452
+ # 計算字串寬度
453
+ text_width = wcswidth(text)
454
+
455
+ # 若字串比格子寬,直接回傳
456
+ if text_width >= width:
457
+ return text
458
+
459
+ # 讓文字靠左,右側補空白(右側 -1 是為了留一格邊界空位)
460
+ right_space = width - text_width - 1
461
+
462
+ # 回傳:左邊固定一格空白 + 文字 + 右邊空白
463
+ return " " + text + " " * right_space
464
+
465
+
466
+ def make_pre_html(content):
467
+ # 將 ASCII 輸出包裝成 HTML,利用 <pre> 保留字元排版
468
+ # 外層 div 用於樣式設定(例如固定寬度、捲動條等)
469
+ return f'<div class="ascii-container"><pre class="ascii">{content}</pre></div>'
470
+
471
+
472
+ def get_sfx(path):
473
+ # 回傳 HTML <audio> 標籤,用於播放音效
474
+ # autoplay 會使音效自動播放
475
+ # /gradio_api/file=path 為 Gradio 的檔案 URL
476
+ return f'<audio src="/gradio_api/file={path}" autoplay></audio>'
477
+
478
+
479
+ def draw_roulette_html(arr, index):
480
+ # 呼叫 draw_roulette 生成 ASCII 輪盤
481
+ # 再用 make_pre_html 包成 HTML 並回傳
482
+ return make_pre_html(draw_roulette(arr, index))
483
+
484
+
485
+ def galaxy_start(n, names: str, shuffle):
486
+ # 將輸入 names 以換行切割成列表,並移除空白行
487
+ name_list = [name for name in names.strip().split("\n") if name]
488
+
489
+ # 若名單數量不足 n,則以「編號 i」補足
490
+ if len(name_list) < n:
491
+ for i in range(n - len(name_list)):
492
+ name_list.append(f"編號 {i}")
493
+
494
+ # src_names 為來源名單的副本(後續可依需求洗牌)
495
+ src_names = name_list[::]
496
+
497
+ # 若 shuffle = True,將來源名單打亂
498
+ if shuffle:
499
+ random.shuffle(src_names)
500
+
501
+ return draw_roulette_html(name_list, -1), src_names, name_list[::], ""
502
+
503
+
504
+ def galaxy_draw(
505
+ src_names: list, # 左側:尚未抽出的人(來源名單)
506
+ dst_names: list, # 右側:可被抽到的人(目標名單)
507
+ html, # 目前顯示的 ASCII 輪盤 HTML
508
+ results, # 已抽紀錄字串
509
+ frame_delay_scale, # 動畫速度倍率
510
+ no_self, # 是否禁止抽到自己
511
+ enable_sfx, # 是否啟用音效
512
+ emoji, # 箭頭符號
513
+ turn_time=0.1, # 每輪滾動時間參數
514
+ n_turns=5, # 完整繞圈次數(未指定 index)
515
+ acc_factor=2, # 每輪速度加倍倍率(越滾越快)
516
+ n_blink=3, # 結果出現後閃爍次數
517
+ blink_delay=0.1, # 閃爍延遲
518
+ ):
519
+ # 若來源名單已空 → 全部抽完
520
+ if not src_names:
521
+ gr.Info(title="訊息", message="抽完了!")
522
+ yield html, results, None
523
+ return
524
+
525
+ # 套用速度倍率
526
+ turn_time *= frame_delay_scale
527
+ blink_delay *= frame_delay_scale
528
+
529
+ # 載入音效
530
+ rlt_sfx = get_sfx("roulette.wav") if enable_sfx else None
531
+ win_sfx = get_sfx("win.mp3") if enable_sfx else None
532
+
533
+ # 隨機挑一個目標 index
534
+ idx = random.choice(range(len(dst_names)))
535
+
536
+ # 禁止抽到自己的處理
537
+ if no_self:
538
+ # 特例:只剩 2 人時避免死循環
539
+ if len(src_names) == 2:
540
+ if src_names[-1] in dst_names:
541
+ idx = dst_names.index(src_names[-1])
542
+ else:
543
+ # 一般情況 → 若抽到自己就重抽 index
544
+ while src_names[0] == dst_names[idx]:
545
+ idx = random.choice(range(len(dst_names)))
546
+
547
+ # 取出來源名單的第一人(固定第一項,而非隨機)
548
+ src_name = src_names.pop(0)
549
+
550
+ # 在結果欄先加上「來源 →」
551
+ results = f"{results}{src_name} {emoji}→ "
552
+
553
+ # 先輸出一次更新
554
+ yield html, results, None
555
+
556
+ # 初始滾動區間(愈滾愈快)
557
+ delay = turn_time / len(dst_names)
558
+
559
+ # 主轉盤滾動迴圈(未鎖定目標)
560
+ for _ in range(n_turns):
561
+ for i in range(len(dst_names)):
562
+ yield draw_roulette_html(dst_names, i), results, f"{rlt_sfx}{i}"
563
+ time.sleep(delay)
564
+ # 每一圈後加速
565
+ delay *= acc_factor
566
+
567
+ # 最終跑到指定 index 的滾動
568
+ for i in range(len(dst_names)):
569
+ yield draw_roulette_html(dst_names, i), results, f"{rlt_sfx}{i}"
570
+ time.sleep(delay)
571
+ if i == idx:
572
+ break
573
+
574
+ # 中獎特效:顯示無選取狀態的輪盤,播放勝利音效
575
+ yield draw_roulette_html(dst_names, -1), results, win_sfx
576
+
577
+ # 閃爍效果(選取框消失 / 出現交替)
578
+ for _ in range(n_blink):
579
+ yield draw_roulette_html(dst_names, -1), results, None
580
+ time.sleep(blink_delay)
581
+ yield draw_roulette_html(dst_names, i), results, None
582
+ time.sleep(blink_delay)
583
+
584
+ # 更新 HTML 顯示最後位置
585
+ html = draw_roulette_html(dst_names, i)
586
+
587
+ # 取出目標名單中的中獎者
588
+ dst_name = dst_names.pop(idx)
589
+
590
+ # 在結果欄加上目標名字
591
+ results = f"{results}{dst_name}\n"
592
+
593
+ # 若目標名單剩 1 人 → 自動配對最後一組
594
+ if len(dst_names) == 1:
595
+ src_name = src_names.pop(0)
596
+ dst_name = dst_names.pop(0)
597
+ results = f"{results}{src_name} {emoji}→ {dst_name}"
598
+
599
+ # 回傳最終畫面
600
+ yield html, results, None
601
+
602
+
603
+ def change_title(text):
604
+ # 回傳 Markdown 標題(第一層標題)
605
+ # 加上 "# " 使文字以大標題方式顯示
606
+ return f"# {text}"
607
+
608
+
609
+ def change_sub_title(text):
610
+ # 回傳 Markdown 子標題(第三層標題)
611
+ # 加上 "### " 使文字成為較小的子標題
612
+ return f"### {text}"
613
+
614
+
615
+ with gr.Blocks(title="交換禮物抽籤") as app:
616
+ # 顯示主標題(Markdown)
617
+ title_md = gr.Markdown("# 🎄 交換禮物抽籤")
618
+ # 顯示副標題(Markdown)
619
+ sub_title_md = gr.Markdown("### 一起來快樂的交換禮物吧!")
620
+
621
+ # 左側 sidebar 區域(設定面板)
622
+ with gr.Sidebar(width=350):
623
+
624
+ # 參加者名稱輸入區(多行文字)
625
+ names = gr_textarea("📝 人名", True)
626
+
627
+ # 基礎設定區(Accordion 預設展開)
628
+ with gr.Accordion("🛠️ 基本設定", open=True):
629
+ # 參與者人數(從名稱過濾後補足用)
630
+ n_people = gr_slider("🙋‍♂️ 參與者人數")
631
+
632
+ # 動畫延遲倍率控制(速度加減速)
633
+ frame_delay_scale = gr_slider_delay("⏱️ 影格延遲比率", 1.0, 0, 2, 0.1)
634
+
635
+ # 是否打亂來源名單
636
+ do_shuffle = gr.Checkbox(label="🔀 隨機順序")
637
+
638
+ # 是否禁止抽到自己
639
+ no_self = gr.Checkbox(label="🙅 不會抽到自己")
640
+
641
+ # 是否啟用音效
642
+ enable_sfx = gr.Checkbox(label="🔔 啟用音效", value=True)
643
+
644
+ # 進階設定區(Accordion 預設收合)
645
+ with gr.Accordion("⚙️ 詳細設定", open=False):
646
+ # UI 顯示的主標題
647
+ title = gr.Textbox("🎄 交換禮物抽籤", label="🎉 主標題")
648
+
649
+ # UI 顯示的副標題
650
+ sub_title = gr.Textbox("一起來快樂的交換禮物吧!", label="🎈 副標題")
651
+
652
+ # 禮物 emoji(例如:🎁)
653
+ gift_emoji = gr.Textbox("🎁", label="🔢 禮物符號")
654
+
655
+ # 背景音樂設定,可 loop
656
+ bgm = gr.Audio(
657
+ "bgm.mp3",
658
+ type="filepath",
659
+ autoplay=True,
660
+ loop=True,
661
+ label="🎶 背景音樂",
662
+ )
663
+
664
+ # 供 SFX 注入 HTML 用(隱藏)
665
+ sfx_html = gr.HTML(elem_classes="hidden")
666
+
667
+ # 第一個頁籤:快速抽籤模式
668
+ with gr.Tab("🚀 快速抽籤"):
669
+ quick_results = gr_textarea("🎯 抽籤結果")
670
+ quick_btn = gr.Button("▶️ 抽籤!")
671
+
672
+ # 第二個頁籤:簡易輪盤模式
673
+ with gr.Tab("✨ 簡易輪盤"):
674
+ with gr.Group():
675
+ with gr.Row():
676
+ # 左側:送禮物者名單
677
+ simple_src = gr_textarea("📤 送禮物的人")
678
+ # 中間:收禮物者名單
679
+ simple_dst = gr_textarea("📬 收禮物的人")
680
+ # 右側:抽籤結果
681
+ simple_results = gr_textarea("🎯 抽籤結果")
682
+
683
+ # 控制按鈕:開始 或 下一個
684
+ with gr.Row():
685
+ simple_start_btn = gr.Button("▶️ 開始")
686
+ simple_next_btn = gr.Button("⏭️ 下一個")
687
+
688
+ # 第三個頁籤:華麗輪盤模式(ASCII 螢幕動畫)
689
+ with gr.Tab("🎡 華麗輪盤"):
690
+ with gr.Group():
691
+ with gr.Row():
692
+
693
+ # 左側大區域:顯示 ASCII 輪盤(HTML)
694
+ with gr.Column(scale=2, elem_classes="ascii-container"):
695
+ galaxy_roulette = gr.HTML(elem_classes="ascii-container")
696
+
697
+ # 右側:抽籤結果
698
+ with gr.Column(scale=1):
699
+ galaxy_results = gr_textarea("🎯 抽籤結果")
700
+
701
+ # 使用 State 儲存來源名單與目標名單
702
+ galaxy_src = gr.State(None)
703
+ galaxy_dst = gr.State(None)
704
+
705
+ # 操作按鈕:開始 or 下一輪
706
+ with gr.Row():
707
+ galaxy_start_btn = gr.Button("▶️ 開始")
708
+ galaxy_next_btn = gr.Button("⏭️ 下一個")
709
+
710
+ # 當使用者修改主標題時,更新左上角的 Markdown 顯示
711
+ title.change(
712
+ change_title, # 觸發的函式
713
+ title, # 函式輸入(使用者輸入的文字)
714
+ title_md, # 輸出到 Markdown 元件
715
+ show_progress="hidden",
716
+ )
717
+
718
+ # 修改副標題時同理更新 UI
719
+ sub_title.change(change_sub_title, sub_title, sub_title_md, show_progress="hidden")
720
+
721
+ # -------------------------
722
+ # 🚀 快速抽籤模式按鈕
723
+ # -------------------------
724
+ quick_btn.click(
725
+ quick_draw, # 使用快速抽籤函式
726
+ [
727
+ n_people, # 抽幾人
728
+ do_shuffle, # 是否洗牌
729
+ no_self, # 是否禁止抽到自己
730
+ names, # 名字來源
731
+ frame_delay_scale,
732
+ gift_emoji,
733
+ ],
734
+ quick_results, # 輸出到快速抽籤結果框
735
+ show_progress="hidden",
736
+ concurrency_limit=100, # 限制此事件在多位使用者同時執行的最大數量
737
+ )
738
+
739
+ # -------------------------
740
+ # ✨ 簡易輪盤:開始按鈕
741
+ # -------------------------
742
+ simple_start_btn.click(
743
+ simple_start, # 初始化名單
744
+ [n_people, names], # 輸入參數
745
+ [names, simple_src, simple_dst, simple_results], # 多輸出
746
+ show_progress="hidden",
747
+ )
748
+
749
+ # -------------------------
750
+ # ✨ 簡易輪盤:下一個按鈕
751
+ # -------------------------
752
+ simple_next_btn.click(
753
+ simple_draw,
754
+ [
755
+ simple_src, # 來源名單
756
+ simple_dst, # 目標名單
757
+ simple_results, # 抽籤結果
758
+ frame_delay_scale,
759
+ do_shuffle,
760
+ no_self,
761
+ enable_sfx,
762
+ gift_emoji,
763
+ ],
764
+ [simple_src, simple_dst, simple_results, sfx_html], # 更新名單 + 音效輸出
765
+ show_progress="hidden",
766
+ scroll_to_output=True,
767
+ concurrency_limit=100,
768
+ ).then(
769
+ lambda: None, None, sfx_html # 動畫結束後清空 sfx_html
770
+ )
771
+
772
+ # -------------------------
773
+ # 🎡 華麗輪盤:開始按鈕
774
+ # -------------------------
775
+ galaxy_start_btn.click(
776
+ galaxy_start,
777
+ [n_people, names, do_shuffle],
778
+ [
779
+ galaxy_roulette, # ASCII 輪盤顯示
780
+ galaxy_src, # 儲存來源名單狀態
781
+ galaxy_dst, # 儲存目標名單狀態
782
+ galaxy_results, # 結果文字
783
+ ],
784
+ show_progress="hidden",
785
+ )
786
+
787
+ # -------------------------
788
+ # 🎡 華麗輪盤:下一個按鈕(動畫版輪盤)
789
+ # -------------------------
790
+ galaxy_next_btn.click(
791
+ galaxy_draw,
792
+ [
793
+ galaxy_src, # 狀態來源名單
794
+ galaxy_dst, # 狀態目標名單
795
+ galaxy_roulette, # ASCII 畫面
796
+ galaxy_results, # 抽籤結果
797
+ frame_delay_scale,
798
+ no_self,
799
+ enable_sfx,
800
+ gift_emoji,
801
+ ],
802
+ [galaxy_roulette, galaxy_results, sfx_html], # 新畫面 + 結果 + 音效
803
+ show_progress="hidden",
804
+ concurrency_limit=100,
805
+ )
806
+
807
+ # ============================================================
808
+ # 本地儲存功能(Local Storage)
809
+ # ============================================================
810
+
811
+ # 要儲存的 UI 元件(會同步保存至瀏覽器 Local Storage)
812
+ stored_components: list[gr.Textbox] = [
813
+ names,
814
+ n_people,
815
+ frame_delay_scale,
816
+ do_shuffle,
817
+ no_self,
818
+ enable_sfx,
819
+ title,
820
+ sub_title,
821
+ gift_emoji,
822
+ ]
823
+
824
+ # 觸發儲存事件的條件:當上述元件的 value 改變
825
+ triggers = [c.change for c in stored_components]
826
+
827
+ # 建立可以儲存整組資料的 BrowserState(本地瀏覽器儲存)
828
+ local_storage = gr.BrowserState(
829
+ [c.value for c in stored_components], # 預設值
830
+ storage_key="storage-key", # 儲存 key
831
+ secret="secret", # 加密用(防篡改)
832
+ )
833
+
834
+ # App 載入時 → 從 LocalStorage 還原 UI 設定
835
+ @app.load(inputs=local_storage, outputs=stored_components)
836
+ def load_from_local_storage(data):
837
+ return data
838
+
839
+ # 當使用者修改任一設定 → 自動更新 LocalStorage
840
+ @gr.on(triggers, inputs=stored_components, outputs=local_storage)
841
+ def save_to_local_storage(*data):
842
+ return data
843
+
844
+ # ============================================================
845
+ # 啟動應用程式
846
+ # ============================================================
847
+ app.launch(
848
+ css_paths="style.css", # 自訂 CSS
849
+ theme=gr.themes.Ocean(
850
+ text_size=gr.themes.sizes.text_lg,
851
+ radius_size=gr.themes.sizes.radius_lg,
852
+ spacing_size=gr.themes.sizes.spacing_lg,
853
+ primary_hue=gr.themes.colors.lime, # 主題顏色
854
+ ),
855
+ allowed_paths=[ # 允許本地檔案存取
856
+ "SarasaFixedTC-Regular.ttf",
857
+ "win.mp3",
858
+ "roulette.wav",
859
+ ],
860
+ share=False, # 是否生成可分享連結
861
+ footer_links=[None], # 隱藏 footer 連結
862
+ pwa=True, # 啟用 PWA(可安裝成 APP)
863
+ favicon_path="favicon.png",
864
+ )
bgm.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3b4c7dcb77a1e151e6a68f87d8865fa4537aad4f66ac0731c3c312c4b6a46615
3
+ size 2643844
favicon.png ADDED
gen-roulette-sfx.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import wave
2
+
3
+ import numpy as np
4
+
5
+ # 參數設定
6
+ sample_rate = 44100 # 取樣率 (Hz)
7
+ duration = 0.5 # 音效長度 (秒)
8
+ num_impacts = 1 # 撞擊次數,可自行調整
9
+
10
+ # 建立空的音訊 buffer
11
+ num_samples = int(sample_rate * duration)
12
+ audio = np.zeros(num_samples, dtype=np.float32)
13
+
14
+
15
+ # 產生單一撞擊聲的函式
16
+ def generate_impact(sample_rate, max_length_ms=5):
17
+ """
18
+ 回傳一個短促的撞擊聲波形 (numpy 1D array)。
19
+ 使用白噪音 * 指數衰減包絡,模擬鋼珠撞擊的高頻 'click'。
20
+ """
21
+ length_ms = np.random.uniform(2, max_length_ms) # 2~5ms 的撞擊長度
22
+ length_samples = int(sample_rate * length_ms / 1000.0)
23
+
24
+ t = np.arange(length_samples) / sample_rate
25
+
26
+ # 指數衰減包絡,衰減越快越「硬」
27
+ decay_rate = np.random.uniform(600, 1200) # 衰減速率隨機
28
+ envelope = np.exp(-decay_rate * t)
29
+
30
+ # 高頻噪音,模擬金屬撞擊的尖銳感
31
+ noise = np.random.randn(length_samples)
32
+
33
+ impact = noise * envelope
34
+
35
+ # 控制單一撞擊的音量
36
+ impact *= np.random.uniform(0.4, 0.9)
37
+
38
+ return impact
39
+
40
+
41
+ # 產生多個撞擊,時間點分佈在 0.5 秒內
42
+ # 為了模擬鋼珠逐漸減速,可以讓後面時間的撞擊較密集
43
+ times = np.linspace(0.0, duration, num_impacts + 2)[1:-1] # 避開完全 0 和完全結尾
44
+ # 稍微往後擠,形成「一開始快、後面密」的感覺
45
+ times = times**1.4 * (duration / (times[-1] ** 1.4))
46
+
47
+ for impact_time in times:
48
+ impact = generate_impact(sample_rate)
49
+ start_idx = int(impact_time * sample_rate)
50
+
51
+ end_idx = start_idx + len(impact)
52
+ if start_idx >= num_samples:
53
+ continue
54
+ if end_idx > num_samples:
55
+ impact = impact[: num_samples - start_idx]
56
+ end_idx = num_samples
57
+
58
+ audio[start_idx:end_idx] += impact
59
+
60
+ # 簡單做一下整體衰減包絡,避免尾端太突兀
61
+ t_all = np.linspace(0, duration, num_samples, endpoint=False)
62
+ global_env = np.exp(-t_all * 4.0) # 4 可調大一點讓衰減更快
63
+ audio *= global_env
64
+
65
+ # 避免削波:正規化到 -1.0 ~ 1.0 區間內,並留一點安全裕度
66
+ max_val = np.max(np.abs(audio))
67
+ if max_val > 0:
68
+ audio = audio / max_val * 0.9
69
+
70
+ # 轉成 16-bit PCM 並輸出為 WAV 檔
71
+ output_name = "roulette_ball.wav"
72
+ with wave.open(output_name, "w") as wf:
73
+ wf.setnchannels(1) # 單聲道
74
+ wf.setsampwidth(2) # 16-bit
75
+ wf.setframerate(sample_rate)
76
+
77
+ audio_int16 = (audio * 32767).astype(np.int16)
78
+ wf.writeframes(audio_int16.tobytes())
79
+
80
+ print(f"已輸出音效檔:{output_name}")
reduce-vol.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+
3
+ import fire
4
+ from pydub import AudioSegment
5
+
6
+
7
+ def reduce_volume(src_path, dst_path, volume_ratio):
8
+ audio = AudioSegment.from_file(src_path)
9
+ vol_change = 20 * math.log10(volume_ratio)
10
+ quieter = audio + vol_change
11
+ quieter.export(dst_path)
12
+
13
+
14
+ if __name__ == "__main__":
15
+ fire.Fire(reduce_volume)
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ wcwidth
roulette.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2a305d3354e59e0de031237d62d79e7f9840129733c61c0e4e1842b6986e75f1
3
+ size 4615
style.css ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @font-face {
2
+ font-family: "HelloFont";
3
+ src: url("/gradio_api/file=SarasaFixedTC-Regular.ttf") format("truetype");
4
+ font-weight: normal;
5
+ font-style: normal;
6
+ }
7
+
8
+ * {
9
+ font-family: "HelloFont";
10
+ }
11
+
12
+ .hidden {
13
+ display: none;
14
+ }
15
+
16
+ .ascii-container {
17
+ overflow-x: auto;
18
+ display: flex;
19
+ justify-content: center;
20
+ align-items: center;
21
+ max-height: 100vh;
22
+ margin: auto 0 !important;
23
+ }
24
+
25
+ .ascii {
26
+ font-family: 'HelloFont';
27
+ white-space: pre;
28
+ line-height: 1;
29
+ letter-spacing: 0;
30
+ display: inline-block;
31
+ padding: 20px;
32
+ margin: auto 0 !important;
33
+ }
34
+
35
+ input[type="number"]::-webkit-outer-spin-button,
36
+ input[type="number"]::-webkit-inner-spin-button {
37
+ -webkit-appearance: none;
38
+ margin: 0;
39
+ text-align: right;
40
+ }
41
+
42
+ input[type="number"] {
43
+ -moz-appearance: textfield;
44
+ appearance: textfield;
45
+ text-align: right;
46
+ }
47
+
48
+ .divider {
49
+ display: none;
50
+ }
win.mp3 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0d08dd8f463d581fb7e811520546b1fc2d3b34148890e511feddf4848722f083
3
+ size 41325