きっかけ
普段はPythonを利用する機会が多いのですが、フロントエンドは他の言語のことが多く、PythonでもフロントエンドやUIを作れないかと思ったのがきっかけでした。
その頃、同期会の企画を進めていたこともあり、参加者同士の話題や話者をランダムに決めるルーレットがあれば、会がもっと盛り上がるのではないかと考え、ルーレットを作成することにしました。
今回作ったもの
画面左側にルーレット、右側には表示項目の設定ができるアプリケーションです。
工夫したこと
コード作成は生成AIで行っていましたが、いくつか工夫を考えました。
- コードが長くなりすぎないようにファイルを分割する
- カラーの種類を減らしてトーンを揃える
(生成AIの提案はルーレットを虹の7色にしていた) - 表示項目の設定は画面で直接編集し、少ない操作で実施できるようにした
(生成AIの提案は、編集ボタン→ポップアップに行番号を入力→項目を編集、とやや煩雑だった) - 一度当たったものは、全ての項目が当たるまで再度当たらないように設計
(何度も同じものばかり当たると興ざめしそう)
システム構成
今回は以下の構成で作成しました。
プログラム実行時は、コマンドプロンプトでmain.pyを起動します。
roulette/
├── main.py
├── app/
│ ├── backend.py
│ ├── logger.py
│ ├── ui.py
│ ├── utils.py
│ └── state.py
├── data/
│ └── choices.txt
└── logs/
└── app.log
作成したコード
バックエンドでルーレットの動作を定義します。
from app.state import app_state
from app.utils import draw_roulette
from app.logger import setup_logger
import time
import random
logger = setup_logger('logs/backend.log')
def ease_out_cubic(t: float) -> float:
"""減速イージング関数"""
return 1 - (1 - t) ** 3
def spin_roulette(canvas, result_label, spin_button):
"""ルーレットスピン処理"""
if not app_state.choices:
result_label.configure(text="選択肢がありません")
return
if app_state.spinning:
return
remaining = [i for i in range(len(app_state.choices)) if i not in app_state.results]
if not remaining:
# result_label.configure(text="すべて選択済み。リセットします。")
reset_roulette(result_label, spin_button, canvas) # 自動リセット
spin_roulette(canvas, result_label, spin_button) # リセット後に再実行
return
app_state.spinning = True
spin_button.configure(state='disabled')
result_label.configure(text="回転中...")
final_index = random.choice(remaining)
duration = 2.5
total_rotation = 360.0 * 5 + (360.0 / len(app_state.choices)) * (len(app_state.choices) - final_index - 0.5) + 270
start_time = time.perf_counter()
def animate():
now = time.perf_counter()
elapsed = now - start_time
if elapsed >= duration:
# draw_roulette(canvas, total_rotation % 360.0, app_state.choices)
draw_roulette(canvas, total_rotation % 360.0)
choice = app_state.choices[final_index]
result_label.configure(text=f"結果: {choice}")
app_state.results.add(final_index)
app_state.spinning = False
spin_button.configure(state='normal')
logger.info(f"結果: {choice}")
return
progress = elapsed / duration
eased = ease_out_cubic(progress)
current_rotation = total_rotation * eased
# draw_roulette(canvas, current_rotation % 360.0, app_state.choices)
draw_roulette(canvas, current_rotation % 360.)
canvas.after(16, animate)
animate()
def reset_roulette(result_label, spin_button, canvas):
"""リセット処理"""
app_state.results.clear()
result_label.configure(text="")
spin_button.configure(state='normal')
# draw_roulette(canvas, 0.0, app_state.choices)
draw_roulette(canvas, 0.0)
ルーレット画面の表示をuiとして定義します。
import time
import customtkinter as ctk
from app.utils import draw_roulette, load_choices, save_choices, initialize_app
from app.backend import reset_roulette, spin_roulette
from app.state import app_state
from CustomTkinterMessagebox import CTkMessagebox
def build_choice_editor(parent, canvas, result_label):
"""選択肢編集用のインターフェースを構築する"""
# フレーム背景をルーレットのダーク背景に合わせて目立たなくする
frame = ctk.CTkFrame(parent, border_width=0, fg_color="#2e2e2e")
frame.pack(fill="both", expand=True, padx=5, pady=5)
textbox = ctk.CTkTextbox(frame, height=8, fg_color="#2e2e2e", text_color="#ffffff")
textbox.pack(fill="both", expand=True, padx=5, pady=5)
def save_choices_to_file():
try:
save_choices(app_state.filename, app_state.choices)
except Exception as e:
CTkMessagebox.messagebox(title='Error', text=str(e), sound='on', button_text='OK')
def confirm_changes():
user_input = textbox.get("1.0", "end").strip().split("\n")
app_state.choices = [line for line in user_input if line]
save_choices_to_file()
time.sleep(0.5)
draw_roulette(canvas)
def cancel_changes():
app_state.choices = load_choices(app_state.filename)
textbox.delete("1.0", "end")
for item in app_state.choices:
textbox.insert("end", item + "\n")
draw_roulette(canvas)
ctk.CTkButton(frame, text="確定", command=confirm_changes).pack(side="left", padx=2, pady=5)
ctk.CTkButton(frame, text="キャンセル", command=cancel_changes).pack(side="left", padx=2, pady=5)
initialize_app(canvas, textbox)
def create_main_window():
"""メインウィンドウ作成"""
# カスタムTKの外観をダークに固定して、デフォルトの色差を抑える
ctk.set_appearance_mode("dark")
try:
ctk.set_default_color_theme("blue")
except Exception:
# 古いバージョンやテーマ名が無効な場合は無視
pass
window = ctk.CTk()
window.title("ルーレット")
window.geometry("800x600")
# ウィンドウの背景を透過に設定
window.attributes('-alpha', 1.0) # ウィンドウ全体の透明度を100%に設定
# 左側: ルーレット
# 左右のフレーム色をルーレット背景(ダーク)に合わせる
left_frame = ctk.CTkFrame(window, fg_color="#2e2e2e")
left_frame.pack(side="left", fill="both", expand=True, padx=10, pady=10)
# ルーレットエリアのキャンバスを作成
canvas = ctk.CTkCanvas(left_frame, width=400, height=400, bg=None, highlightthickness=0)
canvas.pack(pady=10)
result_label = ctk.CTkLabel(left_frame, text="", font=("Arial", 14), bg_color='transparent')
result_label.pack(pady=5)
# ボタンを中央揃えで配置
spin_button = ctk.CTkButton(left_frame, text="スタート",
command=lambda: spin_roulette(canvas, result_label, spin_button))
spin_button.pack(pady=10) # 上下の余白を設定して中央揃えを維持
reset_button = ctk.CTkButton(left_frame, text="リセット",
command=lambda: reset_roulette(result_label, spin_button, canvas))
reset_button.pack(pady=10) # 上下の余白を設定して中央揃えを維持
# 右側: 設定
right_frame = ctk.CTkFrame(window, fg_color="#2e2e2e")
right_frame.pack(side="right", fill="y", padx=10, pady=10)
# 選択肢編集
build_choice_editor(right_frame, canvas, result_label)
# 初期描画
draw_roulette(canvas, 0.0)
return window
ファイル操作やカラー指定の関数はユーティリティ関数としました。
import colorsys
import math
from app.state import app_state
from app.logger import setup_logger
# ロガーの設定
logger = setup_logger()
def hsv_to_hex(h, s, v):
"""HSVカラーを16進数形式に変換する"""
r, g, b = colorsys.hsv_to_rgb(h, s, v)
return f"#{int(r*255):02x}{int(g*255):02x}{int(b*255):02x}"
def build_palette(n):
"""指定された数のカラーパレットを生成する"""
colors = []
for i in range(n):
h = (i / n) % 1.0
v = 0.95 if i % 2 == 0 else 0.85
colors.append(hsv_to_hex(h, 0.65, v))
return colors
def draw_roulette(canvas, rotation_angle: float = 0.0):
"""ルーレットを描画し、選択肢を表示する"""
canvas.delete("all")
canvas.configure(bg="#2e2e2e") # ダークグレー
app_state.choices = load_choices(app_state.filename)
if not app_state.choices:
logger.warning("選択肢がありません。") # 警告をログに記録
canvas.create_text(200, 200, text="選択肢がありません", font=("Arial", 16), fill="red")
return
center_x, center_y, radius = 200, 200, 150
n = len(app_state.choices)
angle_step = 360.0 / n
background_colors = ["#013864", "#184F79", "#3C759B", "#2196F3"]
for i, choice in enumerate(app_state.choices):
start_angle = angle_step * i + rotation_angle
end_angle = start_angle + angle_step
x0 = center_x + radius * math.cos(math.radians(start_angle))
y0 = center_y + radius * math.sin(math.radians(start_angle))
x1 = center_x + radius * math.cos(math.radians(end_angle))
y1 = center_y + radius * math.sin(math.radians(end_angle))
fill = background_colors[i % len(background_colors)]
canvas.create_polygon(center_x, center_y, x0, y0, x1, y1,
fill=fill, outline="white", width=2)
center_angle = (start_angle + end_angle) / 2.0
text_radius = radius * 0.7
tx = center_x + text_radius * math.cos(math.radians(center_angle))
ty = center_y + text_radius * math.sin(math.radians(center_angle))
text_color = "#FFFFFF"
font_size = 12
ty -= 5
canvas.create_text(tx, ty, text=choice, fill=text_color,
font=("Arial", font_size), anchor="center")
pointer_x = center_x
pointer_y = center_y - radius - 10
canvas.create_polygon(
pointer_x, pointer_y,
pointer_x - 15, pointer_y - 25,
pointer_x + 15, pointer_y - 25,
fill="red", outline="black", width=2
)
def load_choices(filename):
"""指定されたファイルから選択肢を読み込む"""
try:
with open(filename, 'r', encoding='utf-8') as f:
items = [line.strip() for line in f if line.strip()]
if not (2 <= len(items) <= 20):
raise ValueError("選択肢は2〜20個である必要があります")
# logger.info(f"選択肢を正常に読み込みました: {items}") # 情報をログに記録
return items
except FileNotFoundError:
logger.error(f"エラー: ファイルが見つかりません: {filename}") # エラーログ
return []
except ValueError as e:
logger.error(f"エラー: {str(e)}") # エラーログ
return []
def save_choices(filename, items):
"""選択肢を指定されたファイルに保存する"""
if not (2 <= len(items) <= 20):
raise ValueError("選択肢は2〜20個である必要があります")
with open(filename, 'w', encoding='utf-8') as f:
f.write("\n".join(items))
logger.info(f"選択肢を正常に保存しました: {items}") # 情報をログに記録
def ease_out_cubic(t: float) -> float:
"""イージング関数を使用して滑らかな遷移を計算する"""
return 1 - (1 - t) ** 3
def initialize_app(canvas, textbox=None):
"""アプリケーションを初期化し、選択肢を読み込んでルーレットを描画する"""
app_state.choices = load_choices(app_state.filename)
if textbox:
textbox.delete("1.0", "end")
for item in app_state.choices:
textbox.insert("end", item + "\n")
draw_roulette(canvas)
ログ出力の設定は以下で行います。
import logging
import os
def setup_logger(log_file='logs/app.log'):
"""ロガーを設定する関数"""
# ロガーの取得
logger = logging.getLogger()
# 既にハンドラが設定されていれば何もしない(二重設定防止)
if len(logger.handlers) > 0:
return logger
# ログレベルの設定
logger.setLevel(logging.DEBUG)
# フォーマッタの作成
log_format = logging.Formatter('%(asctime)s - %(name)s - [%(levelname)s] - %(message)s')
# コンソールハンドラの設定
stream_handler = logging.StreamHandler()
stream_handler.setLevel(logging.INFO)
stream_handler.setFormatter(log_format)
logger.addHandler(stream_handler)
# ファイルハンドラの設定
os.makedirs(os.path.dirname(log_file), exist_ok=True) # ディレクトリが存在しない場合は作成
file_handler = logging.FileHandler(log_file, mode='a', encoding='utf-8') # 'a'は追記モード
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(log_format)
logger.addHandler(file_handler)
return logger
ルーレット項目設定の読込と反映を以下で行います。
import os
from app.logger import setup_logger
# ロガーの設定
logger = setup_logger()
class AppState:
def __init__(self):
self.filename = 'data/choices.txt'
self.choices = [] # 初期化は空のリスト
self.results = set()
self.spinning = False
self.load_choices() # 初期化時に選択肢を読み込む
def load_choices(self):
if not os.path.exists(self.filename):
logger.error(f"ファイルが見つかりません: {self.filename}") # エラーログ
return
try:
with open(self.filename, 'r', encoding='utf-8') as file:
self.choices = [line.strip() for line in file if line.strip()] # 空行を除外
except Exception as e:
logger.exception(f"エラーが発生しました: {e}") # エラーログ
# AppStateのインスタンスを作成
app_state = AppState()
メインは呼び出された時に、ウインドウを継続的に開くよう設定します。
from app.ui import create_main_window
from app.state import app_state
if __name__ == "__main__":
# メインウィンドウを作成・実行
root = create_main_window()
root.mainloop()
おわりに
バックエンド、フロントエンド共に試行錯誤しながら、楽しく作成することができました。
今後はGitHub Copilotを利用しながら、より複雑なアプリケーションにもチャレンジしていきたいと思います!
ご覧いただきありがとうございました。