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,利用
保留字元排版
# 外層 div 用於樣式設定(例如固定寬度、捲動條等)
return f'{content}'
def get_sfx(path):
# 回傳 HTML