Files
UmaCardApplication/gui/deck_builder.py

556 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Deck Builder Frame
Build decks with 6 cards and view combined effects with breakdown
"""
import tkinter as tk
from tkinter import ttk, messagebox
import sys
import os
from PIL import Image, ImageTk
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from db.db_queries import (
get_all_cards, get_all_decks, create_deck, delete_deck,
add_card_to_deck, remove_card_from_deck, get_deck_cards,
get_effects_at_level
)
from utils import resolve_image_path
from gui.theme import (
BG_DARK, BG_DARKEST, BG_MEDIUM, BG_LIGHT, BG_HIGHLIGHT,
ACCENT_PRIMARY, ACCENT_SECONDARY, ACCENT_SUCCESS, ACCENT_ERROR,
TEXT_PRIMARY, TEXT_SECONDARY, TEXT_MUTED,
FONT_HEADER, FONT_SUBHEADER, FONT_BODY, FONT_BODY_BOLD, FONT_SMALL, FONT_TINY,
TYPE_COLORS, get_type_color, get_type_icon,
create_styled_button, create_styled_text, create_card_frame
)
class CardSlot(tk.Frame):
"""Visual component for a single card slot"""
def __init__(self, parent, index, remove_callback, level_callback):
super().__init__(parent, bg=BG_MEDIUM, highlightthickness=2, highlightbackground=BG_LIGHT)
self.index = index
self.remove_callback = remove_callback
self.level_callback = level_callback
self.image_ref = None # Keep reference to prevent GC
self.setup_ui()
def setup_ui(self):
# Configure grid weight
self.columnconfigure(1, weight=1)
# Slot number indicator
slot_label = tk.Label(self, text=f"#{self.index + 1}", font=FONT_TINY,
bg=BG_LIGHT, fg=TEXT_MUTED, padx=4, pady=2)
slot_label.place(x=2, y=2)
# Image Area (Left)
self.image_label = tk.Label(self, bg=BG_MEDIUM, text="📭", fg=TEXT_MUTED,
font=('Segoe UI', 32))
self.image_label.grid(row=0, column=0, rowspan=3, padx=12, pady=12)
# Details Area (Right)
self.name_label = tk.Label(self, text="Empty Slot", bg=BG_MEDIUM, fg=TEXT_PRIMARY,
font=FONT_BODY_BOLD, anchor='w', wraplength=180) # Increased wrap
self.name_label.grid(row=0, column=1, sticky='w', padx=8, pady=(15, 0))
self.meta_label = tk.Label(self, text="", bg=BG_MEDIUM, fg=TEXT_MUTED,
font=FONT_SMALL, anchor='w')
self.meta_label.grid(row=1, column=1, sticky='w', padx=8)
# Controls (Bottom Right)
ctrl_frame = tk.Frame(self, bg=BG_MEDIUM)
ctrl_frame.grid(row=2, column=1, sticky='ew', padx=8, pady=8)
# Level Selector
tk.Label(ctrl_frame, text="Lv:", bg=BG_MEDIUM, fg=TEXT_MUTED,
font=FONT_SMALL).pack(side=tk.LEFT)
self.level_var = tk.StringVar(value="50")
self.level_combo = ttk.Combobox(ctrl_frame, textvariable=self.level_var,
values=[], width=4, state='readonly')
self.level_combo.pack(side=tk.LEFT, padx=4)
self.level_combo.bind('<<ComboboxSelected>>', self._on_level_change)
# Remove Button
self.remove_btn = tk.Button(ctrl_frame, text="", bg=BG_LIGHT, fg=ACCENT_ERROR,
bd=0, font=FONT_BODY_BOLD, width=2,
activebackground=ACCENT_ERROR, activeforeground=TEXT_PRIMARY,
cursor='hand2',
command=lambda: self.remove_callback(self.index))
self.remove_btn.pack(side=tk.RIGHT)
# Hide controls initially
self.toggle_controls(False)
def toggle_controls(self, visible):
state = 'normal' if visible else 'disabled'
self.level_combo.config(state='readonly' if visible else 'disabled')
if not visible:
self.remove_btn.pack_forget()
else:
self.remove_btn.pack(side=tk.RIGHT)
def set_card(self, card_data):
"""Set card data: (id, name, rarity, type, image_path, level)"""
if not card_data:
self.reset()
return
card_id, name, rarity, card_type, image_path, level = card_data
# Calculate valid levels based on rarity
if rarity == 'SSR':
valid_levels = [50, 45, 40, 35, 30]
max_lvl = 50
elif rarity == 'SR':
valid_levels = [45, 40, 35, 30, 25]
max_lvl = 45
else: # R
valid_levels = [40, 35, 30, 25, 20]
max_lvl = 40
self.level_combo['values'] = [str(l) for l in valid_levels]
# Snap level to valid value if not present (e.g. old data)
if level not in valid_levels:
level = max_lvl
# Update styling based on type
color = get_type_color(card_type)
type_icon = get_type_icon(card_type)
self.name_label.config(text=name, fg=TEXT_PRIMARY)
self.meta_label.config(text=f"{type_icon} {rarity}{card_type}", fg=color)
self.level_var.set(str(level))
# Update border color based on rarity
rarity_borders = {'SSR': '#ffd700', 'SR': '#c0c0c0', 'R': '#cd853f'}
self.config(highlightbackground=rarity_borders.get(rarity, BG_LIGHT))
# Load Image
self._load_image(image_path)
self.toggle_controls(True)
def reset(self):
self.name_label.config(text="Empty Slot", fg=TEXT_MUTED)
self.meta_label.config(text="Click a card to add")
self.image_label.config(image='', text="📭", font=('Segoe UI', 32))
self.config(highlightbackground=BG_LIGHT)
self.image_ref = None
self.toggle_controls(False)
def _load_image(self, path):
resolved_path = resolve_image_path(path)
if resolved_path and os.path.exists(resolved_path):
try:
pil_img = Image.open(resolved_path)
# Significantly larger images as requested (120x120)
pil_img.thumbnail((120, 120), Image.Resampling.LANCZOS)
self.image_ref = ImageTk.PhotoImage(pil_img)
self.image_label.config(image=self.image_ref, text='')
except Exception as e:
print(f"Failed to load image: {e}")
self.image_label.config(image='', text="⚠️")
else:
self.image_label.config(image='', text="🖼️")
def _on_level_change(self, event):
self.level_callback(self.index, int(self.level_var.get()))
class DeckBuilderFrame(ttk.Frame):
"""Deck builder with combined effects breakdown"""
def __init__(self, parent):
super().__init__(parent)
self.current_deck_id = None
self.deck_slots = [None] * 6 # 6 card slots
self.setup_ui()
self.refresh_decks()
def setup_ui(self):
# Main container with split view
main_split = ttk.PanedWindow(self, orient=tk.HORIZONTAL)
main_split.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# === Left Panel: Card Browser ===
left_panel = ttk.Frame(main_split)
main_split.add(left_panel, weight=1)
# Header
header = tk.Frame(left_panel, bg=BG_DARK)
header.pack(fill=tk.X, pady=(0, 10))
tk.Label(header, text="📋 Available Cards", font=FONT_SUBHEADER,
bg=BG_DARK, fg=TEXT_PRIMARY).pack(side=tk.LEFT)
# Filters
filter_frame = tk.Frame(left_panel, bg=BG_DARK)
filter_frame.pack(fill=tk.X, pady=(0, 8))
# Filters - Initialize vars FIRST
self.type_var = tk.StringVar(value="All")
self.owned_only_var = tk.BooleanVar(value=False)
self.search_var = tk.StringVar()
# Search Entry
self.search_entry = ttk.Entry(filter_frame, textvariable=self.search_var, width=18)
self.search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=(0, 8))
# Placeholder behavior (before trace)
self.search_entry.insert(0, "Search...")
self.search_entry.config(foreground=TEXT_MUTED)
self.search_entry.bind('<FocusIn>', self._on_search_focus_in)
self.search_entry.bind('<FocusOut>', self._on_search_focus_out)
# Add trace AFTER placeholder is set
self.search_var.trace('w', lambda *args: self.filter_cards())
types = ["All", "Speed", "Stamina", "Power", "Guts", "Wisdom", "Friend", "Group"]
type_combo = ttk.Combobox(filter_frame, textvariable=self.type_var,
values=types, width=9, state='readonly')
type_combo.pack(side=tk.LEFT)
type_combo.bind('<<ComboboxSelected>>', lambda e: self.filter_cards())
ttk.Checkbutton(filter_frame, text="Owned", variable=self.owned_only_var,
command=self.filter_cards).pack(side=tk.LEFT, padx=8)
# Card List
list_frame = tk.Frame(left_panel, bg=BG_DARK)
list_frame.pack(fill=tk.BOTH, expand=True)
self.card_tree = ttk.Treeview(list_frame, columns=('name', 'rarity', 'type'),
show='tree headings', style="DeckList.Treeview")
self.card_tree.heading('#0', text='')
self.card_tree.column('#0', width=45, anchor='center')
self.card_tree.heading('name', text='Name')
self.card_tree.heading('rarity', text='Rarity')
self.card_tree.heading('type', text='Type')
self.card_tree.column('name', width=130)
self.card_tree.column('rarity', width=45, anchor='center')
self.card_tree.column('type', width=65, anchor='center')
scrollbar = ttk.Scrollbar(list_frame, orient=tk.VERTICAL, command=self.card_tree.yview)
self.card_tree.configure(yscrollcommand=scrollbar.set)
self.card_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
# Double-click to add
self.card_tree.bind('<Double-1>', lambda e: self.add_selected_to_deck())
# Add Button
add_btn = create_styled_button(left_panel, text=" Add to Deck",
command=self.add_selected_to_deck,
style_type='accent')
add_btn.pack(fill=tk.X, pady=10)
# === Right Panel: Deck & Stats ===
right_panel = ttk.Frame(main_split)
main_split.add(right_panel, weight=2)
# Deck Controls
deck_ctrl = tk.Frame(right_panel, bg=BG_DARK)
deck_ctrl.pack(fill=tk.X, pady=(0, 15))
tk.Label(deck_ctrl, text="🎴 Current Deck:", font=FONT_BODY,
bg=BG_DARK, fg=TEXT_SECONDARY).pack(side=tk.LEFT)
self.deck_combo = ttk.Combobox(deck_ctrl, width=25, state='readonly')
self.deck_combo.pack(side=tk.LEFT, padx=10)
self.deck_combo.bind('<<ComboboxSelected>>', self.on_deck_selected)
ttk.Button(deck_ctrl, text="+ New", command=self.create_new_deck,
style='Small.TButton').pack(side=tk.LEFT, padx=5)
ttk.Button(deck_ctrl, text="🗑️ Delete", command=self.delete_current_deck,
style='Small.TButton').pack(side=tk.LEFT)
# Card count indicator
self.deck_count_label = tk.Label(deck_ctrl, text="0/6 cards",
font=FONT_SMALL, bg=BG_DARK, fg=ACCENT_PRIMARY)
self.deck_count_label.pack(side=tk.LEFT, padx=15)
# Deck Grid (3x2)
self.slots_frame = tk.Frame(right_panel, bg=BG_DARK)
self.slots_frame.pack(fill=tk.X)
self.card_slots = []
for i in range(6):
slot = CardSlot(self.slots_frame, i, self.remove_from_slot, self.on_slot_level_changed)
r, c = divmod(i, 3)
slot.grid(row=r, column=c, padx=6, pady=6, sticky='nsew')
self.slots_frame.columnconfigure(c, weight=1)
self.card_slots.append(slot)
# Stats / Effects Area
effects_header = tk.Frame(right_panel, bg=BG_DARK)
effects_header.pack(fill=tk.X, pady=(20, 10))
tk.Label(effects_header, text="📊 Combined Effects Breakdown",
font=FONT_SUBHEADER, bg=BG_DARK, fg=TEXT_PRIMARY).pack(side=tk.LEFT)
effects_frame = create_card_frame(right_panel)
effects_frame.pack(fill=tk.BOTH, expand=True)
self.effects_tree = ttk.Treeview(effects_frame,
columns=('effect', 'total', 'c1', 'c2', 'c3', 'c4', 'c5', 'c6'),
show='headings', height=8)
self.effects_tree.heading('effect', text='Effect')
self.effects_tree.heading('total', text='TOTAL')
self.effects_tree.column('effect', width=140)
self.effects_tree.column('total', width=60, anchor='center')
for i in range(1, 7):
self.effects_tree.heading(f'c{i}', text=f'#{i}')
self.effects_tree.column(f'c{i}', width=45, anchor='center')
vsb = ttk.Scrollbar(effects_frame, orient=tk.VERTICAL, command=self.effects_tree.yview)
self.effects_tree.configure(yscrollcommand=vsb.set)
self.effects_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=2, pady=2)
vsb.pack(side=tk.RIGHT, fill=tk.Y, pady=2)
# Unique Effects Area
unique_header = tk.Frame(right_panel, bg=BG_DARK)
unique_header.pack(fill=tk.X, pady=(15, 8))
tk.Label(unique_header, text="✨ Unique Effects", font=FONT_BODY_BOLD,
bg=BG_DARK, fg=ACCENT_SECONDARY).pack(side=tk.LEFT)
unique_frame = create_card_frame(right_panel)
unique_frame.pack(fill=tk.X)
self.unique_text = create_styled_text(unique_frame, height=5)
self.unique_text.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
self.unique_text.config(state=tk.DISABLED)
self.icon_cache = {}
self.filter_cards()
# Helper methods for placeholder
def _on_search_focus_in(self, event):
"""Clear placeholder on focus"""
if self.search_entry.get() == "Search...":
self.search_entry.delete(0, tk.END)
self.search_entry.config(foreground=TEXT_PRIMARY)
def _on_search_focus_out(self, event):
"""Show placeholder if empty"""
if not self.search_entry.get():
self.search_entry.insert(0, "Search...")
self.search_entry.config(foreground=TEXT_MUTED)
# --- Logic Methods ---
def filter_cards(self):
for item in self.card_tree.get_children():
self.card_tree.delete(item)
type_filter = self.type_var.get() if self.type_var.get() != "All" else None
# Ignore placeholder
search_text = self.search_var.get()
search = search_text if search_text and search_text != "Search..." else None
owned_only = self.owned_only_var.get()
cards = get_all_cards(type_filter=type_filter, search_term=search, owned_only=owned_only)
for card in cards:
card_id, name, rarity, card_type, max_level, image_path, is_owned, owned_level = card
# Load Icon
img = self.icon_cache.get(card_id)
resolved_path = resolve_image_path(image_path)
if not img and resolved_path and os.path.exists(resolved_path):
try:
pil_img = Image.open(resolved_path)
# Larger thumbnails in the list too (48x48)
pil_img.thumbnail((48, 48), Image.Resampling.LANCZOS)
img = ImageTk.PhotoImage(pil_img)
self.icon_cache[card_id] = img
except:
pass
type_icon = get_type_icon(card_type)
if img:
self.card_tree.insert('', tk.END, text='', image=img,
values=(name, rarity, f"{type_icon}"), iid=str(card_id))
else:
self.card_tree.insert('', tk.END, text='?',
values=(name, rarity, f"{type_icon}"), iid=str(card_id))
def refresh_decks(self):
decks = get_all_decks()
self.deck_combo['values'] = [f"{d[0]}: {d[1]}" for d in decks]
if decks and not self.current_deck_id:
self.deck_combo.current(0)
self.on_deck_selected(None)
def on_deck_selected(self, event):
selection = self.deck_combo.get()
if selection:
self.current_deck_id = int(selection.split(':')[0])
self.load_deck()
def load_deck(self):
if not self.current_deck_id:
return
# Reset visual slots
for s in self.card_slots:
s.reset()
self.deck_slots = [None] * 6
# Load from DB
deck_cards = get_deck_cards(self.current_deck_id)
for card in deck_cards:
slot_pos, level, card_id, name, rarity, card_type, image_path = card
if 0 <= slot_pos < 6:
self.deck_slots[slot_pos] = card_id
self.card_slots[slot_pos].set_card((card_id, name, rarity, card_type, image_path, level))
self.update_deck_count()
self.update_effects_breakdown()
def create_new_deck(self):
name = tk.simpledialog.askstring("New Deck", "Enter deck name:")
if name:
deck_id = create_deck(name)
self.current_deck_id = deck_id
self.refresh_decks()
self.deck_combo.set(f"{deck_id}: {name}")
self.load_deck()
def delete_current_deck(self):
if self.current_deck_id:
if messagebox.askyesno("Delete Deck", "Are you sure you want to delete this deck?"):
delete_deck(self.current_deck_id)
self.current_deck_id = None
self.deck_combo.set('')
self.refresh_decks()
self.load_deck()
def add_selected_to_deck(self):
if not self.current_deck_id:
messagebox.showwarning("No Deck", "Select or create a deck first.")
return
selection = self.card_tree.selection()
if not selection:
return
card_id = int(selection[0])
# Check for duplicates
if card_id in self.deck_slots:
messagebox.showinfo("Duplicate Card", "This card is already in the deck.")
return
# Find empty slot
for i in range(6):
if self.deck_slots[i] is None:
# Get the last selected level for this card from main window
level = 50
parent = self.winfo_toplevel()
if hasattr(parent, 'last_selected_levels'):
level = parent.last_selected_levels.get(card_id, 50)
add_card_to_deck(self.current_deck_id, card_id, i, level)
self.load_deck()
return
messagebox.showinfo("Deck Full", "Remove a card first to add a new one.")
def remove_from_slot(self, index):
if self.current_deck_id and self.deck_slots[index]:
remove_card_from_deck(self.current_deck_id, index)
self.deck_slots[index] = None
self.card_slots[index].reset()
self.update_deck_count()
self.update_effects_breakdown()
def update_deck_count(self):
"""Update the X/6 cards display"""
count = sum(1 for slot in self.deck_slots if slot is not None)
self.deck_count_label.config(text=f"{count}/6 cards")
def on_slot_level_changed(self, index, new_level):
if self.current_deck_id and self.deck_slots[index]:
card_id = self.deck_slots[index]
add_card_to_deck(self.current_deck_id, card_id, index, new_level)
self.update_effects_breakdown()
def update_effects_breakdown(self):
for item in self.effects_tree.get_children():
self.effects_tree.delete(item)
# Clear Unique Text
self.unique_text.config(state=tk.NORMAL)
self.unique_text.delete('1.0', tk.END)
if not self.current_deck_id:
self.unique_text.insert(tk.END, "No deck selected")
self.unique_text.config(state=tk.DISABLED)
return
# Prepare data for calculation
card_info = []
for i in range(6):
if self.deck_slots[i]:
level = int(self.card_slots[i].level_var.get())
card_info.append((self.deck_slots[i], level))
else:
card_info.append(None)
# Gather effects
all_effects = {}
unique_effects_list = []
for i, info in enumerate(card_info):
if info:
card_id, level = info
card_name = self.card_slots[i].name_label.cget("text")
effects = get_effects_at_level(card_id, level)
for name, value in effects:
if name == "Unique Effect":
unique_effects_list.append(f"{card_name}: {value}")
continue
if name not in all_effects:
all_effects[name] = [''] * 6
all_effects[name][i] = value
# Configure tags
self.unique_text.tag_configure('card_name', foreground=ACCENT_PRIMARY)
# Fill Unique Effects
if unique_effects_list:
self.unique_text.insert(tk.END, "\n".join(unique_effects_list))
else:
self.unique_text.insert(tk.END, "No unique effects in this deck", 'card_name')
self.unique_text.config(state=tk.DISABLED)
# Sum totals
for effect_name, values in sorted(all_effects.items()):
total = 0
is_percent = False
for v in values:
if v:
if '%' in str(v): is_percent = True
try:
total += float(str(v).replace('%','').replace('+',''))
except: pass
total_str = f"{total:.0f}%" if is_percent else (f"+{total:.0f}" if total > 0 else str(int(total)))
row_vals = [effect_name, total_str] + values
self.effects_tree.insert('', tk.END, values=row_vals)
import tkinter.simpledialog