SecretSanta / app.py
mooncake030's picture
hello
00bfc3a
import random
import time
import gradio as gr
from wcwidth import wcswidth
def quick_draw(
n, # 要抽出的數量
do_shuffle, # 是否在抽獎前先打亂原始名單
no_self, # 是否禁止抽到自己
names: str, # 名單字串(以換行分隔)
frame_delay_scale, # 延遲時間縮放倍率,用於加速或減速動畫
emoji, # 用於顯示箭頭的表情符號
char_delay=0.01, # 每個字元出現的延遲(製造打字動畫效果)
item_delay=0.1, # 每項抽完後的延遲(項目間動畫)
):
# 先將 names 字串拆成列表(以換行為分隔)並移除空白行
src_names = [name for name in names.strip().split("\n") if name]
# 若名單人數不足 n,補上「編號 i」
if len(src_names) < n:
for i in range(n - len(src_names)):
src_names.append(f"編號 {i}")
# 目標名單(會被打亂後作為抽中結果)
dst_names = src_names.copy()
random.shuffle(dst_names)
# 若 do_shuffle 為 True,也把原始名單打亂
random.shuffle(src_names) if do_shuffle else None
# 根據 frame_delay_scale 調整延遲(用來快轉/慢動作用)
char_delay *= frame_delay_scale
item_delay *= frame_delay_scale
# 若不允許抽到自己,需重新洗牌 dst_names,直到沒有任何同名配對
if no_self:
while True:
for src_name, dst_name in zip(src_names, dst_names):
if src_name == dst_name: # 有抽到自己的情況
break
# 若有抽到自己,重新洗牌後再檢查
if src_name == dst_name:
random.shuffle(dst_names)
continue
break
msg = list() # 用來累積每一筆抽獎輸出
# 逐一比對 src -> dst
for src_name, dst_name in zip(src_names, dst_names):
# 若抽到自己
if src_name == dst_name:
msg.append(f"{src_name} 送給自己!")
else:
msg.append(f"{src_name} {emoji}{dst_name}")
# 若設定了 char_delay,則逐字輸出動畫
if char_delay:
for i in range(len(msg[-1])):
# 產生目前的輸出畫面(前面完整行 + 最後一行逐字顯示)
yield "\n".join(msg[:-1] + [msg[-1][: i + 1]])
time.sleep(char_delay)
# 每個項目完成後的延遲動畫
if item_delay:
yield "\n".join(msg)
time.sleep(item_delay)
# 最終完整結果
yield "\n".join(msg)
def simple_start(n, names: str):
# 將輸入的 names 字串按行拆解,並移除空白行
name_list = [name for name in names.strip().split("\n") if name]
# 若名單數量不足 n,則以「編號 i」補足
if len(name_list) < n:
for i in range(n - len(name_list)):
name_list.append(f"編號 {i}")
# 將名單再組合成以換行分隔的字串
name_list = "\n".join(name_list)
return names, name_list, name_list, ""
def simple_draw(
src_name_text: str, # 左側來源名單(尚未抽中的人)
dst_name_text: str, # 右側目標名單(可被抽到的人)
results: str, # 已抽結果字串
frame_delay_scale, # 動畫速度縮放倍率
do_shuffle, # 是否隨機選取來源 index
no_self, # 是否禁止抽到自己
enable_sfx, # 是否啟用音效
emoji, # 顯示箭頭符號
turn_time=0.25, # 每輪滾動時間
n_turns=3, # 滾輪來回次數
n_blink=3, # 中獎後閃爍次數
blink_delay=0.1, # 閃爍間隔
):
# 若來源名單已空 → 全部抽完
if not src_name_text.strip():
gr.Info(title="訊息", message="抽完了!")
yield src_name_text, dst_name_text, results, None
return
# 拆解來源與目標名單為列表
src_names = [name for name in src_name_text.strip().split("\n")]
dst_names = [name for name in dst_name_text.strip().split("\n")]
# 套用動畫時間倍率
turn_time *= frame_delay_scale
blink_delay *= frame_delay_scale
# 如果啟用音效,載入音檔
rlt_sfx = get_sfx("roulette.mp3") if enable_sfx else None
win_sfx = get_sfx("win.mp3") if enable_sfx else None
# 建立格式化名稱+箭頭,並處理全形/半形字寬
def get_arrow(name, width):
pad_len = width - wcswidth(name) + 2 # 計算補齊空白寬度
return name + " " * pad_len + emoji # 在名字後補空白並加箭頭
# 選取來源 index(可選擇洗牌或固定從 0 開始)
src_idx = random.choice(range(len(src_names))) if do_shuffle else 0
# 隨機選取目標 index
dst_idx = random.choice(range(len(dst_names)))
# 若不允許抽到自己 → 調整 dst_idx
if no_self:
# 特例:只有兩人時,抽到自己會導致死循環,需要特殊邏輯
if len(src_names) == 2:
src_left_idx = (src_idx + 1) & 1 # 另一個 index
if src_names[src_left_idx] in dst_names:
dst_idx = dst_names.index(src_names[src_left_idx])
else:
# 一般情況下,若抽到自己就重新選擇
while src_names[src_idx] == dst_names[dst_idx]:
dst_idx = random.choice(range(len(dst_names)))
dst_name = dst_names[dst_idx] # 最終目標(被抽中的人)
# 計算字寬,使箭頭對齊
max_dst_name_width = max(wcswidth(s) for s in dst_names)
max_src_name_width = max(wcswidth(s) for s in dst_names)
# 在來源名單中標示出「目前正在抽的人」
curr_src_name = get_arrow(src_names[src_idx], max_src_name_width)
curr_src_names = src_names[:src_idx] + [curr_src_name] + src_names[src_idx + 1 :]
src_name_text = "\n".join(curr_src_names)
# 顯示來源列表的更新(右側尚未進入滾動)
yield src_name_text, dst_name_text, results, None
# 將實際要抽出的人從來源名單移除
src_name = src_names.pop(src_idx)
# 產生當前滾動階段的目的名單(某一行加上箭頭)
def get_curr_names(i):
if i is None:
return "\n".join(dst_names) # 無高亮,顯示完整列表
selected_item = get_arrow(dst_names[i], max_dst_name_width)
curr_names = dst_names[:i] + [selected_item] + dst_names[i + 1 :]
return "\n".join(curr_names)
# 設定滾輪動畫的延遲(越滾越慢)
delay = turn_time / len(dst_names)
# 主滾動動畫:前後來回 n_turns 次
for _ in range(n_turns):
# 從頭滾到尾
for i in range(0, len(dst_names)):
yield src_name_text, get_curr_names(i), results, f"{rlt_sfx}{i}"
time.sleep(delay)
# 從尾倒回到頭
for i in range(len(dst_names) - 2, 0, -1):
yield src_name_text, get_curr_names(i), results, f"{rlt_sfx}{i}"
time.sleep(delay)
delay *= 2 # 每次回合後增加等待時間(營造減速效果)
# 最終慢慢滾到中獎者的位置
for i in range(0, len(dst_names)):
yield src_name_text, get_curr_names(i), results, f"{rlt_sfx}{i}"
if dst_names[i] == dst_name:
break
time.sleep(delay)
time.sleep(delay)
# 昇格畫面 → 目標列表停止滾動,高亮中獎者
yield src_name_text, get_curr_names(None), results, win_sfx
# 中獎閃爍效果
for _ in range(n_blink):
yield src_name_text, get_curr_names(None), results, None
time.sleep(blink_delay)
yield src_name_text, get_curr_names(i), results, None
time.sleep(blink_delay)
# 將已被抽中的目標從名單移除
dst_names.pop(i)
# 記錄抽獎結果
if src_name == dst_name:
results = f"{results}{src_name} 抽到自己!\n"
else:
results = f"{results}{src_name} {emoji}{dst_name}\n"
# 更新名單(顯示在前端)
yield "\n".join(src_names), "\n".join(dst_names), results, None
# 若只剩 1 對 1,則自動進行最後一次配對
if len(dst_names) == 1:
src_name = src_names.pop(0)
dst_name = dst_names.pop(0)
results = f"{results}{src_name} {emoji}{dst_name}"
yield "\n".join(src_names), "\n".join(dst_names), results, None
def gr_textarea(label, interactive=False, lines=10, max_lines=10):
"""
建立一個 Gradio TextArea(多行文字輸入框)
參數:
label — 顯示在元件上的標籤文字
interactive — 是否允許使用者編輯(False 時僅能顯示)
lines — 預設顯示的行數高度
max_lines — 最大可擴張到的行數高度
功能:
- 用於顯示抽籤名單或抽籤結果
"""
return gr.TextArea(
label=label,
lines=lines,
max_lines=max_lines,
interactive=interactive,
)
def gr_slider(label, value=10, minimum=2, maximum=300):
"""
建立一般範圍調整用 Slider(滑桿)
參數:
label — 標籤文字
value — 預設值
minimum — 最小值
maximum — 最大值
功能:
- 常用於「參與人數」、「輪盤大小」等需要數值輸入的場景
"""
return gr.Slider(label=label, value=value, minimum=minimum, maximum=maximum)
def gr_slider_delay(label, value, minimum=0, maximum=1, step=0.01):
"""
建立可用於動畫速度控制的 Slider
參數:
label — 標籤文字
value — 預設值
minimum — 最小值
maximum — 最大值
step — 調整步進(例如 0.01)
功能:
- 用於 frame_delay_scale(動畫速率倍率)
- 使用小步進可使動畫速度更細緻
"""
return gr.Slider(
label=label,
value=value,
minimum=minimum,
maximum=maximum,
step=step, # 微調精度
)
def draw_roulette(arr: list[str], index: int) -> str:
# 陣列長度
n = len(arr)
if n == 0:
return
# 將陣列平均分配到四邊(上、右、下、左)
side_length = n // 4 # 每側的基本數量
remainder = n % 4 # 平均分配後剩餘的數量
# 依照剩餘數量,從上→右→下依序多分配 1 個
length_top = side_length + (1 if remainder > 0 else 0)
length_right = side_length + (1 if remainder > 1 else 0)
length_bottom = side_length + (1 if remainder > 2 else 0)
length_left = side_length # 左側不額外補
# 用索引切分四邊
idx_top_end = length_top
idx_right_end = idx_top_end + length_right
idx_bottom_end = idx_right_end + length_bottom
list_top = arr[0:idx_top_end]
list_right = arr[idx_top_end:idx_right_end]
list_bottom = arr[idx_right_end:idx_bottom_end]
list_left = arr[idx_bottom_end:]
# 計算所有字串最大寬度(支援全形字)
max_text_width = max((wcswidth(s) for s in arr), default=0)
# 每格寬度 = 最大字寬 + padding
cell_width = max_text_width + 2
# 使 cell_width 為奇數,以便在中央置中
if cell_width % 2 == 0:
cell_width += 1
# 上方格子一排的總寬度
box_inner_width = length_top * cell_width
# 左右兩側格子的總高度(每項佔 2 行,中間留空)
max_side_items = max(length_right, length_left)
box_inner_height = (max_side_items * 2) + 1
# 建立空白網格,後續會在上面放置「@」
grid = [[" " for _ in range(box_inner_width)] for _ in range(box_inner_height)]
# 目標位置(@)的座標
target_pos = None
# 判斷 index 屬於哪一側,並計算其在圓形 ASCII 中的實際位置
if 0 <= index < idx_top_end:
# 屬於上側
local_idx = index
center_x = (local_idx * cell_width) + (cell_width // 2)
target_pos = (0, center_x) # row=0 → 第一行
elif idx_top_end <= index < idx_right_end:
# 屬於右側
local_idx = index - idx_top_end
target_row = 1 + (local_idx * 2) # 每項佔兩行
target_pos = (target_row, box_inner_width - 2)
elif idx_right_end <= index < idx_bottom_end:
# 屬於下側(需反向映射)
local_idx = index - idx_right_end
offset_from_right = local_idx
visual_slot_from_left = (length_top - 1) - offset_from_right
center_x = (visual_slot_from_left * cell_width) + (cell_width // 2)
target_pos = (box_inner_height - 1, center_x)
else:
# 屬於左側
local_idx = index - idx_bottom_end
target_row = (box_inner_height - 2) - (local_idx * 2)
target_pos = (target_row, 1) # 左側靠內一格
# 在 grid 上標記 @ 位置
if target_pos:
row, col = target_pos
if 0 <= row < box_inner_height and 0 <= col < box_inner_width:
grid[row][col] = "@"
# 上方標籤需要向右縮排(給左右側留空間)
left_margin_width = cell_width
# 上邊文字(置中)
top_labels = "".join([get_centered_cell(s, cell_width) for s in list_top])
# 開始組建輸出內容
outputs = list()
# 上方第一行:空白 + 上側文字
outputs.append(" " * left_margin_width + " " + top_labels)
# 上方第二行:空白 + 上框線
outputs.append(" " * left_margin_width + " " + "#" * box_inner_width + " ")
# 中間內容(含左側與右側)
for row in range(box_inner_height):
left_char = " " * cell_width # 預設左側為空
right_char = " " * cell_width # 預設右側為空
# 左右項目位於 (row - 1) % 2 == 0 的行上
if (row - 1) % 2 == 0:
# 計算左側 index(從下往上排)
calc_left_idx = (box_inner_height - 2 - row) // 2
is_in_left = 0 <= calc_left_idx < len(list_left)
if is_in_left and (box_inner_height - 2 - row) % 2 == 0:
left_char = get_right_align_cell(list_left[calc_left_idx], cell_width)
# 計算右側 index(從上往下排)
calc_right_idx = (row - 1) // 2
is_in_right = 0 <= calc_right_idx < len(list_right)
if is_in_right and (row - 1) >= 0:
right_char = get_left_align_cell(list_right[calc_right_idx], cell_width)
# grid[row] 中可能有 @ 或空白
row_content = "".join(grid[row])
# 左 / 框線 / 右
outputs.append(f"{left_char}#{row_content}#{right_char}")
# 下方框線
outputs.append(" " * left_margin_width + " " + "#" * box_inner_width + " ")
# 下方文字排在框線下方(注意 bottom list 是反向排列)
bottom_padding_cells = length_top - length_bottom
bottom_border = "".join([get_centered_cell(s, cell_width) for s in list_bottom[::-1]])
bottom_labels = (" " * cell_width * bottom_padding_cells) + bottom_border
outputs.append(" " * left_margin_width + " " + bottom_labels)
return "\n".join(outputs)
def get_centered_cell(text, width):
# 計算字串的實際寬度(支援全形字)
text_width = wcswidth(text)
# 如果字串本身超過格子寬度,直接回傳(不裁切)
if text_width >= width:
return text
# 需要補的空白總量
total_space = width - text_width
# 左右平均分配空白
left_space = total_space // 2
right_space = total_space - left_space
# 回傳:左空白 + 文字 + 右空白
return " " * left_space + text + " " * right_space
def get_right_align_cell(text, width):
# 計算字串寬度
text_width = wcswidth(text)
# 若字串比格子寬,不處理
if text_width >= width:
return text
# 左側需要補多少空白,使文字靠右
# 最右邊保留一格空白,讓版面不緊貼
left_space = width - text_width - 1
# 回傳:左空白 + 文字 + 最右一格空白
return " " * left_space + text + " "
def get_left_align_cell(text, width):
# 計算字串寬度
text_width = wcswidth(text)
# 若字串比格子寬,直接回傳
if text_width >= width:
return text
# 讓文字靠左,右側補空白(右側 -1 是為了留一格邊界空位)
right_space = width - text_width - 1
# 回傳:左邊固定一格空白 + 文字 + 右邊空白
return " " + text + " " * right_space
def make_pre_html(content):
# 將 ASCII 輸出包裝成 HTML,利用 <pre> 保留字元排版
# 外層 div 用於樣式設定(例如固定寬度、捲動條等)
return f'<div class="ascii-container"><pre class="ascii">{content}</pre></div>'
def get_sfx(path):
# 回傳 HTML <audio> 標籤,用於播放音效
# autoplay 會使音效自動播放
# /gradio_api/file=path 為 Gradio 的檔案 URL
return f'<audio src="/gradio_api/file={path}" autoplay></audio>'
def draw_roulette_html(arr, index):
# 呼叫 draw_roulette 生成 ASCII 輪盤
# 再用 make_pre_html 包成 HTML 並回傳
return make_pre_html(draw_roulette(arr, index))
def galaxy_start(n, names: str, shuffle):
# 將輸入 names 以換行切割成列表,並移除空白行
name_list = [name for name in names.strip().split("\n") if name]
# 若名單數量不足 n,則以「編號 i」補足
if len(name_list) < n:
for i in range(n - len(name_list)):
name_list.append(f"編號 {i}")
# src_names 為來源名單的副本(後續可依需求洗牌)
src_names = name_list[::]
# 若 shuffle = True,將來源名單打亂
if shuffle:
random.shuffle(src_names)
return draw_roulette_html(name_list, -1), src_names, name_list[::], ""
def galaxy_draw(
src_names: list, # 左側:尚未抽出的人(來源名單)
dst_names: list, # 右側:可被抽到的人(目標名單)
html, # 目前顯示的 ASCII 輪盤 HTML
results, # 已抽紀錄字串
frame_delay_scale, # 動畫速度倍率
no_self, # 是否禁止抽到自己
enable_sfx, # 是否啟用音效
emoji, # 箭頭符號
turn_time=0.1, # 每輪滾動時間參數
n_turns=5, # 完整繞圈次數(未指定 index)
acc_factor=2, # 每輪速度加倍倍率(越滾越快)
n_blink=3, # 結果出現後閃爍次數
blink_delay=0.1, # 閃爍延遲
):
# 若來源名單已空 → 全部抽完
if not src_names:
gr.Info(title="訊息", message="抽完了!")
yield html, results, None
return
# 套用速度倍率
turn_time *= frame_delay_scale
blink_delay *= frame_delay_scale
# 載入音效
rlt_sfx = get_sfx("roulette.mp3") if enable_sfx else None
win_sfx = get_sfx("win.mp3") if enable_sfx else None
# 隨機挑一個目標 index
idx = random.choice(range(len(dst_names)))
# 禁止抽到自己的處理
if no_self:
# 特例:只剩 2 人時避免死循環
if len(src_names) == 2:
if src_names[-1] in dst_names:
idx = dst_names.index(src_names[-1])
else:
# 一般情況 → 若抽到自己就重抽 index
while src_names[0] == dst_names[idx]:
idx = random.choice(range(len(dst_names)))
# 取出來源名單的第一人(固定第一項,而非隨機)
src_name = src_names.pop(0)
# 在結果欄先加上「來源 →」
results = f"{results}{src_name} {emoji}→ "
# 先輸出一次更新
yield html, results, None
# 初始滾動區間(愈滾愈快)
delay = turn_time / len(dst_names)
# 主轉盤滾動迴圈(未鎖定目標)
for _ in range(n_turns):
for i in range(len(dst_names)):
yield draw_roulette_html(dst_names, i), results, f"{rlt_sfx}{i}"
time.sleep(delay)
# 每一圈後加速
delay *= acc_factor
# 最終跑到指定 index 的滾動
for i in range(len(dst_names)):
yield draw_roulette_html(dst_names, i), results, f"{rlt_sfx}{i}"
time.sleep(delay)
if i == idx:
break
# 中獎特效:顯示無選取狀態的輪盤,播放勝利音效
yield draw_roulette_html(dst_names, -1), results, win_sfx
# 閃爍效果(選取框消失 / 出現交替)
for _ in range(n_blink):
yield draw_roulette_html(dst_names, -1), results, None
time.sleep(blink_delay)
yield draw_roulette_html(dst_names, i), results, None
time.sleep(blink_delay)
# 更新 HTML 顯示最後位置
html = draw_roulette_html(dst_names, i)
# 取出目標名單中的中獎者
dst_name = dst_names.pop(idx)
# 在結果欄加上目標名字
results = f"{results}{dst_name}\n"
# 若目標名單剩 1 人 → 自動配對最後一組
if len(dst_names) == 1:
src_name = src_names.pop(0)
dst_name = dst_names.pop(0)
results = f"{results}{src_name} {emoji}{dst_name}"
# 回傳最終畫面
yield html, results, None
def change_title(text):
# 回傳 Markdown 標題(第一層標題)
# 加上 "# " 使文字以大標題方式顯示
return f"# {text}"
def change_sub_title(text):
# 回傳 Markdown 子標題(第三層標題)
# 加上 "### " 使文字成為較小的子標題
return f"### {text}"
with gr.Blocks(title="交換禮物抽籤") as app:
# 顯示主標題(Markdown)
title_md = gr.Markdown("# 🎄 交換禮物抽籤")
# 顯示副標題(Markdown)
sub_title_md = gr.Markdown("### 一起來快樂的交換禮物吧!")
# 左側 sidebar 區域(設定面板)
with gr.Sidebar(width=350):
# 參加者名稱輸入區(多行文字)
names = gr_textarea("📝 人名", True)
# 基礎設定區(Accordion 預設展開)
with gr.Accordion("🛠️ 基本設定", open=True):
# 參與者人數(從名稱過濾後補足用)
n_people = gr_slider("🙋‍♂️ 參與者人數")
# 動畫延遲倍率控制(速度加減速)
frame_delay_scale = gr_slider_delay("⏱️ 影格延遲比率", 1.0, 0, 2, 0.1)
# 是否打亂來源名單
do_shuffle = gr.Checkbox(label="🔀 隨機順序")
# 是否禁止抽到自己
no_self = gr.Checkbox(label="🙅 不會抽到自己")
# 是否啟用音效
enable_sfx = gr.Checkbox(label="🔔 啟用音效", value=True)
# 進階設定區(Accordion 預設收合)
with gr.Accordion("⚙️ 詳細設定", open=False):
# UI 顯示的主標題
title = gr.Textbox("🎄 交換禮物抽籤", label="🎉 主標題")
# UI 顯示的副標題
sub_title = gr.Textbox("一起來快樂的交換禮物吧!", label="🎈 副標題")
# 禮物 emoji(例如:🎁)
gift_emoji = gr.Textbox("🎁", label="🔢 禮物符號")
# 背景音樂設定,可 loop
bgm = gr.Audio(
"bgm.mp3",
type="filepath",
autoplay=True,
loop=True,
label="🎶 背景音樂",
)
# 供 SFX 注入 HTML 用(隱藏)
sfx_html = gr.HTML(elem_classes="hidden")
# 第一個頁籤:快速抽籤模式
with gr.Tab("🚀 快速抽籤"):
quick_results = gr_textarea("🎯 抽籤結果")
quick_btn = gr.Button("▶️ 抽籤!")
# 第二個頁籤:簡易輪盤模式
with gr.Tab("✨ 簡易輪盤"):
with gr.Group():
with gr.Row():
# 左側:送禮物者名單
simple_src = gr_textarea("📤 送禮物的人")
# 中間:收禮物者名單
simple_dst = gr_textarea("📬 收禮物的人")
# 右側:抽籤結果
simple_results = gr_textarea("🎯 抽籤結果")
# 控制按鈕:開始 或 下一個
with gr.Row():
simple_start_btn = gr.Button("▶️ 開始")
simple_next_btn = gr.Button("⏭️ 下一個")
# 第三個頁籤:華麗輪盤模式(ASCII 螢幕動畫)
with gr.Tab("🎡 華麗輪盤"):
with gr.Group():
with gr.Row():
# 左側大區域:顯示 ASCII 輪盤(HTML)
with gr.Column(scale=2, elem_classes="ascii-container"):
galaxy_roulette = gr.HTML(elem_classes="ascii-container")
# 右側:抽籤結果
with gr.Column(scale=1):
galaxy_results = gr_textarea("🎯 抽籤結果")
# 使用 State 儲存來源名單與目標名單
galaxy_src = gr.State(None)
galaxy_dst = gr.State(None)
# 操作按鈕:開始 or 下一輪
with gr.Row():
galaxy_start_btn = gr.Button("▶️ 開始")
galaxy_next_btn = gr.Button("⏭️ 下一個")
# 當使用者修改主標題時,更新左上角的 Markdown 顯示
title.change(
change_title, # 觸發的函式
title, # 函式輸入(使用者輸入的文字)
title_md, # 輸出到 Markdown 元件
show_progress="hidden",
)
# 修改副標題時同理更新 UI
sub_title.change(change_sub_title, sub_title, sub_title_md, show_progress="hidden")
# -------------------------
# 🚀 快速抽籤模式按鈕
# -------------------------
quick_btn.click(
quick_draw, # 使用快速抽籤函式
[
n_people, # 抽幾人
do_shuffle, # 是否洗牌
no_self, # 是否禁止抽到自己
names, # 名字來源
frame_delay_scale,
gift_emoji,
],
quick_results, # 輸出到快速抽籤結果框
show_progress="hidden",
concurrency_limit=100, # 限制此事件在多位使用者同時執行的最大數量
)
# -------------------------
# ✨ 簡易輪盤:開始按鈕
# -------------------------
simple_start_btn.click(
simple_start, # 初始化名單
[n_people, names], # 輸入參數
[names, simple_src, simple_dst, simple_results], # 多輸出
show_progress="hidden",
)
# -------------------------
# ✨ 簡易輪盤:下一個按鈕
# -------------------------
simple_next_btn.click(
simple_draw,
[
simple_src, # 來源名單
simple_dst, # 目標名單
simple_results, # 抽籤結果
frame_delay_scale,
do_shuffle,
no_self,
enable_sfx,
gift_emoji,
],
[simple_src, simple_dst, simple_results, sfx_html], # 更新名單 + 音效輸出
show_progress="hidden",
scroll_to_output=True,
concurrency_limit=100,
).then(
lambda: None, None, sfx_html # 動畫結束後清空 sfx_html
)
# -------------------------
# 🎡 華麗輪盤:開始按鈕
# -------------------------
galaxy_start_btn.click(
galaxy_start,
[n_people, names, do_shuffle],
[
galaxy_roulette, # ASCII 輪盤顯示
galaxy_src, # 儲存來源名單狀態
galaxy_dst, # 儲存目標名單狀態
galaxy_results, # 結果文字
],
show_progress="hidden",
)
# -------------------------
# 🎡 華麗輪盤:下一個按鈕(動畫版輪盤)
# -------------------------
galaxy_next_btn.click(
galaxy_draw,
[
galaxy_src, # 狀態來源名單
galaxy_dst, # 狀態目標名單
galaxy_roulette, # ASCII 畫面
galaxy_results, # 抽籤結果
frame_delay_scale,
no_self,
enable_sfx,
gift_emoji,
],
[galaxy_roulette, galaxy_results, sfx_html], # 新畫面 + 結果 + 音效
show_progress="hidden",
concurrency_limit=100,
)
# ============================================================
# 本地儲存功能(Local Storage)
# ============================================================
# 要儲存的 UI 元件(會同步保存至瀏覽器 Local Storage)
stored_components: list[gr.Textbox] = [
names,
n_people,
frame_delay_scale,
do_shuffle,
no_self,
enable_sfx,
title,
sub_title,
gift_emoji,
]
# 觸發儲存事件的條件:當上述元件的 value 改變
triggers = [c.change for c in stored_components]
# 建立可以儲存整組資料的 BrowserState(本地瀏覽器儲存)
local_storage = gr.BrowserState(
[c.value for c in stored_components], # 預設值
storage_key="storage-key", # 儲存 key
secret="secret", # 加密用(防篡改)
)
# App 載入時 → 從 LocalStorage 還原 UI 設定
@app.load(inputs=local_storage, outputs=stored_components)
def load_from_local_storage(data):
return data
# 當使用者修改任一設定 → 自動更新 LocalStorage
@gr.on(triggers, inputs=stored_components, outputs=local_storage)
def save_to_local_storage(*data):
return data
# ============================================================
# 啟動應用程式
# ============================================================
app.launch(
css_paths="style.css", # 自訂 CSS
theme=gr.themes.Ocean(
text_size=gr.themes.sizes.text_lg,
radius_size=gr.themes.sizes.radius_lg,
spacing_size=gr.themes.sizes.spacing_lg,
primary_hue=gr.themes.colors.lime, # 主題顏色
),
allowed_paths=[ # 允許本地檔案存取
"SarasaFixedTC-Regular.ttf",
"win.mp3",
"roulette.mp3",
],
share=False, # 是否生成可分享連結
footer_links=[None], # 隱藏 footer 連結
pwa=True, # 啟用 PWA(可安裝成 APP)
favicon_path="favicon.png",
)