Python 3.7's bundled Tcl/Tk on Windows is UCS-2 only and refuses characters above U+FFFF. The button labels contained game-controller, desktop, folder and refresh emojis (U+1F3AE, U+1F5A5, U+1F4C2, U+1F501), so App.__init__ raised TclError and gui.main caught it, exited 1, and the user saw 'nothing happened'. Replace with plain Korean text. Per CLAUDE.md these emojis should not have been added in the first place.
882 lines
36 KiB
Python
882 lines
36 KiB
Python
"""Tkinter GUI for the Sephiria inventory optimizer.
|
||
|
||
Two input modes via Notebook:
|
||
- "게임창 캡처": list visible Windows top-level windows, pick the Sephiria
|
||
one, capture its full client area (PrintWindow, works even when not
|
||
focused), click two corners of the bag area, then run the recognizer
|
||
(NCC + 4 rotations + artifacts + empty/unknown). Mis-recognized cells
|
||
can be fixed by click. Unknown ("?") cells likely correspond to merged
|
||
slab boxes — clicking one opens a popup that lets the user describe
|
||
which two slabs got merged.
|
||
- "수동 선택": existing tier-grouped +/- input.
|
||
|
||
Both modes feed a basket → solver → renderer pipeline shown on the right.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
import threading
|
||
import tkinter as tk
|
||
from tkinter import filedialog, messagebox, ttk
|
||
from typing import Dict, List, Optional, Tuple
|
||
|
||
from PIL import Image, ImageTk
|
||
|
||
from .artifacts import ARTIFACTS, ARTIFACTS_BY_VALUE
|
||
from .recognizer import CellResult, recognize_image, warm_templates
|
||
from .renderer import fetch_artifact_image, fetch_slab_image, render_solution
|
||
from .slabs import (
|
||
GRID_COLS,
|
||
SLABS,
|
||
SLABS_BY_VALUE,
|
||
TIER_LABEL,
|
||
TIER_ORDER,
|
||
generate_grid_config,
|
||
)
|
||
from .solver import solve
|
||
|
||
|
||
# ---------- helpers ----------
|
||
|
||
def _slab_thumb(value: str, size: int = 48) -> Optional[ImageTk.PhotoImage]:
|
||
slab = SLABS_BY_VALUE.get(value)
|
||
if not slab:
|
||
return None
|
||
img = fetch_slab_image(slab.image)
|
||
if img is None:
|
||
return None
|
||
return ImageTk.PhotoImage(img.resize((size, size)))
|
||
|
||
|
||
def _artifact_thumb(value: str, size: int = 48) -> Optional[ImageTk.PhotoImage]:
|
||
a = ARTIFACTS_BY_VALUE.get(value)
|
||
if not a:
|
||
return None
|
||
img = fetch_artifact_image(a.image)
|
||
if img is None:
|
||
return None
|
||
return ImageTk.PhotoImage(img.resize((size, size)))
|
||
|
||
|
||
# ---------- window-picker dialog ----------
|
||
|
||
class WindowPicker(tk.Toplevel):
|
||
"""List visible windows; the chosen WindowInfo ends up on .selected."""
|
||
|
||
def __init__(self, parent: tk.Tk) -> None:
|
||
super().__init__(parent)
|
||
self.title("게임 창 선택")
|
||
self.transient(parent)
|
||
self.selected = None
|
||
|
||
from .window_capture import list_windows, find_sephiria
|
||
windows = list_windows()
|
||
|
||
wrap = ttk.Frame(self, padding=8)
|
||
wrap.pack(fill="both", expand=True)
|
||
|
||
if not windows:
|
||
ttk.Label(wrap, text=(
|
||
"현재 OS 에서 창 목록을 가져올 수 없습니다.\n"
|
||
"(Windows 전용 기능 - 리눅스/맥에서는 전체 화면 캡처를 쓰세요)"
|
||
), justify="left").pack(pady=8)
|
||
ttk.Button(wrap, text="닫기", command=self.destroy).pack()
|
||
self.grab_set()
|
||
return
|
||
|
||
ttk.Label(wrap, text="아래 목록에서 세피리아 게임 창을 더블클릭하세요:") \
|
||
.pack(anchor="w")
|
||
|
||
tree_frame = ttk.Frame(wrap)
|
||
tree_frame.pack(fill="both", expand=True, pady=6)
|
||
cols = ("title", "size")
|
||
self.tree = ttk.Treeview(tree_frame, columns=cols, show="headings", height=14)
|
||
self.tree.heading("title", text="창 이름")
|
||
self.tree.heading("size", text="크기")
|
||
self.tree.column("title", width=440)
|
||
self.tree.column("size", width=120, anchor="center")
|
||
sb = ttk.Scrollbar(tree_frame, orient="vertical", command=self.tree.yview)
|
||
self.tree.configure(yscrollcommand=sb.set)
|
||
self.tree.pack(side="left", fill="both", expand=True)
|
||
sb.pack(side="right", fill="y")
|
||
|
||
self._windows = windows
|
||
# auto-promote Sephiria-matching entries
|
||
auto = find_sephiria()
|
||
sel_idx = None
|
||
for i, w in enumerate(windows):
|
||
self.tree.insert(
|
||
"", "end", iid=str(i),
|
||
values=(w.title, f"{w.width}×{w.height}"),
|
||
)
|
||
if auto and w.handle == auto.handle:
|
||
sel_idx = i
|
||
if sel_idx is not None:
|
||
self.tree.selection_set(str(sel_idx))
|
||
self.tree.see(str(sel_idx))
|
||
|
||
self.tree.bind("<Double-Button-1>", lambda _e: self._confirm())
|
||
|
||
btns = ttk.Frame(wrap)
|
||
btns.pack(fill="x", pady=(6, 0))
|
||
ttk.Button(btns, text="선택", command=self._confirm).pack(side="left")
|
||
ttk.Button(btns, text="취소", command=self.destroy).pack(side="right")
|
||
|
||
self.grab_set()
|
||
self.focus_set()
|
||
|
||
def _confirm(self) -> None:
|
||
sel = self.tree.selection()
|
||
if not sel:
|
||
return
|
||
self.selected = self._windows[int(sel[0])]
|
||
self.destroy()
|
||
|
||
|
||
# ---------- crop dialog (two-click bbox) ----------
|
||
|
||
class CropDialog(tk.Toplevel):
|
||
MAX_DISPLAY = 1100
|
||
|
||
def __init__(self, parent: tk.Tk, img: Image.Image) -> None:
|
||
super().__init__(parent)
|
||
self.title("가방 영역 선택 — 좌상단과 우하단을 차례로 클릭")
|
||
self.transient(parent)
|
||
self.img_full = img
|
||
self.bbox: Optional[Tuple[int, int, int, int]] = None
|
||
self._clicks: List[Tuple[int, int]] = []
|
||
|
||
w, h = img.size
|
||
self.scale = min(1.0, self.MAX_DISPLAY / max(w, h))
|
||
disp = img.resize((int(w * self.scale), int(h * self.scale)))
|
||
self.tk_img = ImageTk.PhotoImage(disp)
|
||
|
||
ttk.Label(
|
||
self, justify="left",
|
||
text=(
|
||
"가방(인벤토리) 격자의 좌상단을 먼저 클릭하고, 우하단을 다시 클릭하세요.\n"
|
||
"그리드 안쪽 첫 칸의 좌상단 모서리와 마지막 칸의 우하단 모서리로 맞추는 게 정확합니다."
|
||
),
|
||
).pack(padx=8, pady=(8, 4))
|
||
|
||
self.canvas = tk.Canvas(
|
||
self, width=disp.size[0], height=disp.size[1], highlightthickness=0
|
||
)
|
||
self.canvas.pack(padx=8, pady=4)
|
||
self.canvas.create_image(0, 0, anchor="nw", image=self.tk_img)
|
||
self.canvas.bind("<Button-1>", self._click)
|
||
|
||
bar = ttk.Frame(self)
|
||
bar.pack(fill="x", padx=8, pady=(4, 8))
|
||
ttk.Button(bar, text="처음부터", command=self._reset).pack(side="left")
|
||
ttk.Button(bar, text="취소", command=self._cancel).pack(side="right")
|
||
|
||
self.protocol("WM_DELETE_WINDOW", self._cancel)
|
||
self.grab_set()
|
||
self.focus_set()
|
||
|
||
def _click(self, e: tk.Event) -> None:
|
||
self._clicks.append((e.x, e.y))
|
||
self.canvas.create_oval(
|
||
e.x - 5, e.y - 5, e.x + 5, e.y + 5, outline="#ff5050", width=2
|
||
)
|
||
if len(self._clicks) == 2:
|
||
(x1, y1), (x2, y2) = self._clicks
|
||
lx, ty = min(x1, x2), min(y1, y2)
|
||
rx, by = max(x1, x2), max(y1, y2)
|
||
self.canvas.create_rectangle(lx, ty, rx, by, outline="#50ff80", width=2)
|
||
inv = 1.0 / self.scale
|
||
self.bbox = (int(lx * inv), int(ty * inv), int(rx * inv), int(by * inv))
|
||
self.after(120, self.destroy)
|
||
|
||
def _reset(self) -> None:
|
||
self._clicks.clear()
|
||
self.canvas.delete("all")
|
||
self.canvas.create_image(0, 0, anchor="nw", image=self.tk_img)
|
||
|
||
def _cancel(self) -> None:
|
||
self.bbox = None
|
||
self.destroy()
|
||
|
||
|
||
# ---------- cell editor (slab / artifact / merged / empty) ----------
|
||
|
||
class CellEditor(tk.Toplevel):
|
||
"""Modal editor. Result on .result: dict {kind, value, rotation, merged}."""
|
||
|
||
def __init__(self, parent: tk.Tk, current: Optional[CellResult]) -> None:
|
||
super().__init__(parent)
|
||
self.title("셀 수정")
|
||
self.transient(parent)
|
||
self.result: Optional[dict] = None
|
||
cur_kind = current.kind if current else "empty"
|
||
cur_val = current.value if current else None
|
||
|
||
wrap = ttk.Frame(self, padding=8)
|
||
wrap.pack(fill="both", expand=True)
|
||
|
||
ttk.Label(wrap, text="이 셀의 종류:").pack(anchor="w")
|
||
self.kind_var = tk.StringVar(value=cur_kind if cur_kind in ("slab", "artifact", "empty", "merged") else "empty")
|
||
kind_row = ttk.Frame(wrap)
|
||
kind_row.pack(fill="x", pady=(0, 6))
|
||
for label, key in [("석판", "slab"), ("아티팩트", "artifact"),
|
||
("합쳐진(?)", "merged"), ("빈칸", "empty")]:
|
||
ttk.Radiobutton(kind_row, text=label, value=key,
|
||
variable=self.kind_var, command=self._render).pack(side="left", padx=2)
|
||
|
||
self.body = ttk.Frame(wrap)
|
||
self.body.pack(fill="both", expand=True)
|
||
self._slab_buttons: List[ttk.Button] = []
|
||
self._artifact_buttons: List[ttk.Button] = []
|
||
self._chosen_value: Optional[str] = cur_val
|
||
self._chosen_kind: str = self.kind_var.get()
|
||
# merged sub-state
|
||
self._merged_a = tk.StringVar(value="")
|
||
self._merged_b = tk.StringVar(value="")
|
||
self._merged_lvl = tk.IntVar(value=1)
|
||
|
||
self._render()
|
||
|
||
bar = ttk.Frame(self)
|
||
bar.pack(fill="x", padx=8, pady=(0, 8))
|
||
ttk.Button(bar, text="확인", command=self._ok).pack(side="left")
|
||
ttk.Button(bar, text="취소", command=self.destroy).pack(side="right")
|
||
|
||
self.grab_set()
|
||
self.focus_set()
|
||
|
||
def _render(self) -> None:
|
||
for c in self.body.winfo_children():
|
||
c.destroy()
|
||
k = self.kind_var.get()
|
||
if k == "slab":
|
||
self._build_slab_picker()
|
||
elif k == "artifact":
|
||
self._build_artifact_picker()
|
||
elif k == "merged":
|
||
self._build_merged_picker()
|
||
else:
|
||
ttk.Label(self.body, text="이 칸은 빈칸으로 처리됩니다.").pack(pady=20)
|
||
|
||
def _build_slab_picker(self) -> None:
|
||
sub = ttk.Frame(self.body)
|
||
sub.pack(fill="both", expand=True)
|
||
cnv = tk.Canvas(sub, height=320, highlightthickness=0)
|
||
sb = ttk.Scrollbar(sub, orient="vertical", command=cnv.yview)
|
||
inner = ttk.Frame(cnv)
|
||
inner.bind("<Configure>", lambda e: cnv.configure(scrollregion=cnv.bbox("all")))
|
||
cnv.create_window((0, 0), window=inner, anchor="nw")
|
||
cnv.configure(yscrollcommand=sb.set)
|
||
cnv.pack(side="left", fill="both", expand=True)
|
||
sb.pack(side="right", fill="y")
|
||
by_tier: Dict[str, List] = {}
|
||
for s in SLABS:
|
||
by_tier.setdefault(s.tier, []).append(s)
|
||
for tier in sorted(by_tier, key=lambda t: TIER_ORDER.get(t, 99)):
|
||
ttk.Label(inner, text=TIER_LABEL.get(tier, tier),
|
||
font=("TkDefaultFont", 10, "bold")).pack(anchor="w", pady=(8, 2))
|
||
grid = ttk.Frame(inner)
|
||
grid.pack(fill="x")
|
||
for i, s in enumerate(by_tier[tier]):
|
||
b = ttk.Button(grid, text=s.ko_label, width=8,
|
||
command=lambda v=s.value: self._pick_slab(v))
|
||
b.grid(row=i // 6, column=i % 6, padx=2, pady=2, sticky="w")
|
||
|
||
def _build_artifact_picker(self) -> None:
|
||
sub = ttk.Frame(self.body)
|
||
sub.pack(fill="both", expand=True)
|
||
ttk.Label(sub, text=(
|
||
f"아티팩트 {len(ARTIFACTS)}종. 한글명으로 검색:"
|
||
)).pack(anchor="w")
|
||
sv = tk.StringVar()
|
||
ttk.Entry(sub, textvariable=sv).pack(fill="x", pady=2)
|
||
list_frame = ttk.Frame(sub)
|
||
list_frame.pack(fill="both", expand=True)
|
||
lb = tk.Listbox(list_frame, height=14)
|
||
lb.pack(side="left", fill="both", expand=True)
|
||
sb = ttk.Scrollbar(list_frame, orient="vertical", command=lb.yview)
|
||
sb.pack(side="right", fill="y")
|
||
lb.configure(yscrollcommand=sb.set)
|
||
|
||
items = sorted(ARTIFACTS, key=lambda a: (TIER_ORDER.get(a.tier, 99), a.ko_label))
|
||
|
||
def refresh(*_a):
|
||
lb.delete(0, "end")
|
||
q = sv.get().strip()
|
||
for a in items:
|
||
if not q or q in a.ko_label or q in a.value:
|
||
lb.insert("end", f"[{TIER_LABEL.get(a.tier, a.tier)}] {a.ko_label}")
|
||
|
||
sv.trace_add("write", refresh)
|
||
refresh()
|
||
|
||
def on_pick(_e=None):
|
||
sel = lb.curselection()
|
||
if not sel:
|
||
return
|
||
label = lb.get(sel[0])
|
||
# match by Korean label
|
||
ko = label.split("] ", 1)[1] if "] " in label else label
|
||
for a in ARTIFACTS:
|
||
if a.ko_label == ko:
|
||
self._chosen_value = a.value
|
||
self._chosen_kind = "artifact"
|
||
return
|
||
lb.bind("<<ListboxSelect>>", on_pick)
|
||
|
||
def _build_merged_picker(self) -> None:
|
||
sub = ttk.Frame(self.body, padding=4)
|
||
sub.pack(fill="both", expand=True)
|
||
ttk.Label(sub, text=(
|
||
"합쳐진 박스(?)에 어떤 두 석판이 들어갔는지 고르고, 누적 레벨을 지정하세요.\n"
|
||
"선택한 정보로 결과 이미지에 합쳐진 형태가 표시됩니다."
|
||
), justify="left").pack(anchor="w", pady=(0, 6))
|
||
|
||
slab_values = [s.value for s in SLABS]
|
||
slab_labels = {s.value: f"{s.ko_label} ({s.value})" for s in SLABS}
|
||
|
||
def labelled(v: str) -> str:
|
||
return slab_labels.get(v, v)
|
||
|
||
rowA = ttk.Frame(sub); rowA.pack(fill="x", pady=2)
|
||
ttk.Label(rowA, text="첫 번째 석판:", width=14).pack(side="left")
|
||
ttk.OptionMenu(rowA, self._merged_a, "",
|
||
*[""] + [labelled(v) for v in slab_values]).pack(side="left", fill="x", expand=True)
|
||
|
||
rowB = ttk.Frame(sub); rowB.pack(fill="x", pady=2)
|
||
ttk.Label(rowB, text="두 번째 석판:", width=14).pack(side="left")
|
||
ttk.OptionMenu(rowB, self._merged_b, "",
|
||
*[""] + [labelled(v) for v in slab_values]).pack(side="left", fill="x", expand=True)
|
||
|
||
rowL = ttk.Frame(sub); rowL.pack(fill="x", pady=2)
|
||
ttk.Label(rowL, text="누적 레벨:", width=14).pack(side="left")
|
||
ttk.Spinbox(rowL, from_=1, to=10, textvariable=self._merged_lvl,
|
||
width=6).pack(side="left")
|
||
|
||
def _pick_slab(self, value: str) -> None:
|
||
self._chosen_value = value
|
||
self._chosen_kind = "slab"
|
||
|
||
def _ok(self) -> None:
|
||
k = self.kind_var.get()
|
||
if k == "slab":
|
||
if not self._chosen_value:
|
||
messagebox.showinfo("안내", "석판을 선택하세요.")
|
||
return
|
||
self.result = {"kind": "slab", "value": self._chosen_value,
|
||
"rotation": 0, "merged": None}
|
||
elif k == "artifact":
|
||
if not self._chosen_value:
|
||
messagebox.showinfo("안내", "아티팩트를 선택하세요.")
|
||
return
|
||
self.result = {"kind": "artifact", "value": self._chosen_value,
|
||
"rotation": 0, "merged": None}
|
||
elif k == "merged":
|
||
def _val_from_label(lbl: str) -> Optional[str]:
|
||
if not lbl:
|
||
return None
|
||
if "(" in lbl and lbl.endswith(")"):
|
||
return lbl[lbl.rfind("(") + 1: -1]
|
||
return None
|
||
a = _val_from_label(self._merged_a.get())
|
||
b = _val_from_label(self._merged_b.get())
|
||
if not a or not b:
|
||
messagebox.showinfo("안내", "두 석판을 모두 선택하세요.")
|
||
return
|
||
self.result = {"kind": "merged", "value": None, "rotation": 0,
|
||
"merged": {"a": a, "b": b, "level": int(self._merged_lvl.get())}}
|
||
else:
|
||
self.result = {"kind": "empty", "value": None, "rotation": 0, "merged": None}
|
||
self.destroy()
|
||
|
||
|
||
# ---------- screenshot/game-window frame ----------
|
||
|
||
class ScreenshotFrame(ttk.Frame):
|
||
def __init__(self, master, on_confirmed) -> None:
|
||
super().__init__(master, padding=6)
|
||
self.on_confirmed = on_confirmed
|
||
self.slot_var = master.winfo_toplevel().slot_var # type: ignore[attr-defined]
|
||
self.image: Optional[Image.Image] = None
|
||
self.bbox: Optional[Tuple[int, int, int, int]] = None
|
||
self.cells: List[CellResult] = []
|
||
# per-slot override: dict slot_id -> {kind, value, rotation, merged}
|
||
self.overrides: Dict[str, dict] = {}
|
||
self._thumbs: List[ImageTk.PhotoImage] = []
|
||
self._templates_warmed = False
|
||
|
||
ctl = ttk.Frame(self)
|
||
ctl.pack(fill="x")
|
||
ttk.Button(ctl, text="게임 창 선택…", command=self._pick_window).pack(side="left")
|
||
ttk.Button(ctl, text="전체 화면 캡처", command=self._capture_screen).pack(side="left", padx=4)
|
||
ttk.Button(ctl, text="파일 열기…", command=self._open_file).pack(side="left", padx=4)
|
||
ttk.Button(ctl, text="영역 재지정", command=self._reselect_bbox).pack(side="left", padx=4)
|
||
ttk.Button(ctl, text="이 구성으로 계산", command=self._confirm).pack(side="right")
|
||
|
||
self.status = ttk.Label(
|
||
self,
|
||
text=(
|
||
"1) [게임 창 선택] 으로 Sephiria 창을 고르거나, [전체 화면 캡처]/[파일 열기] 로 이미지를 가져옵니다.\n"
|
||
"2) 가방 격자의 좌상단/우하단을 두 번 클릭해 영역을 지정합니다.\n"
|
||
"3) 자동 인식 후 잘못된 셀은 클릭해서 종류/석판/아티팩트/합쳐진(?) 으로 교정하세요."
|
||
),
|
||
wraplength=520, justify="left",
|
||
)
|
||
self.status.pack(fill="x", pady=(6, 4))
|
||
|
||
self.preview_canvas = tk.Canvas(self, height=440, highlightthickness=0)
|
||
self.preview_canvas.pack(fill="both", expand=True)
|
||
self.preview_inner = ttk.Frame(self.preview_canvas)
|
||
self.preview_canvas.create_window((0, 0), window=self.preview_inner, anchor="nw")
|
||
self.preview_inner.bind(
|
||
"<Configure>",
|
||
lambda e: self.preview_canvas.configure(
|
||
scrollregion=self.preview_canvas.bbox("all")
|
||
),
|
||
)
|
||
|
||
# ----- input modes -----
|
||
def _pick_window(self) -> None:
|
||
dlg = WindowPicker(self.winfo_toplevel())
|
||
self.winfo_toplevel().wait_window(dlg)
|
||
if not dlg.selected:
|
||
return
|
||
try:
|
||
from .window_capture import capture_window
|
||
self.image = capture_window(dlg.selected)
|
||
except Exception as e:
|
||
messagebox.showerror("창 캡처 실패", str(e))
|
||
return
|
||
self.status["text"] = f"창 캡처 완료: {dlg.selected.title} ({self.image.size[0]}×{self.image.size[1]})"
|
||
self._pick_bbox_and_recognize()
|
||
|
||
def _capture_screen(self) -> None:
|
||
try:
|
||
from .capture import capture_screen
|
||
top = self.winfo_toplevel()
|
||
top.withdraw(); top.update()
|
||
try:
|
||
self.image = capture_screen(monitor=1)
|
||
finally:
|
||
top.deiconify()
|
||
except Exception as e:
|
||
self.winfo_toplevel().deiconify()
|
||
messagebox.showerror("캡처 실패", str(e))
|
||
return
|
||
self._pick_bbox_and_recognize()
|
||
|
||
def _open_file(self) -> None:
|
||
path = filedialog.askopenfilename(
|
||
title="스크린샷 선택",
|
||
filetypes=[("Images", "*.png *.jpg *.jpeg *.webp"), ("All", "*.*")],
|
||
)
|
||
if not path:
|
||
return
|
||
try:
|
||
self.image = Image.open(path).convert("RGB")
|
||
except Exception as e:
|
||
messagebox.showerror("열기 실패", str(e))
|
||
return
|
||
self._pick_bbox_and_recognize()
|
||
|
||
def _reselect_bbox(self) -> None:
|
||
if self.image is None:
|
||
messagebox.showinfo("안내", "먼저 캡처 또는 파일 열기를 해주세요.")
|
||
return
|
||
self._pick_bbox_and_recognize()
|
||
|
||
# ----- pipeline -----
|
||
def _pick_bbox_and_recognize(self) -> None:
|
||
assert self.image is not None
|
||
dlg = CropDialog(self.winfo_toplevel(), self.image)
|
||
self.winfo_toplevel().wait_window(dlg)
|
||
if not dlg.bbox:
|
||
return
|
||
self.bbox = dlg.bbox
|
||
self.overrides.clear()
|
||
self.status["text"] = "템플릿 준비 + 셀 인식 중…"
|
||
self.update_idletasks()
|
||
threading.Thread(target=self._recognize_thread, daemon=True).start()
|
||
|
||
def _recognize_thread(self) -> None:
|
||
try:
|
||
if not self._templates_warmed:
|
||
# First call may download a lot — keep artifacts on (user wants them)
|
||
warm_templates(include_artifacts=True)
|
||
self._templates_warmed = True
|
||
slot_num = int(round(self.slot_var.get()))
|
||
cells = recognize_image(
|
||
self.image, self.bbox,
|
||
slot_num=slot_num, include_artifacts=True,
|
||
)
|
||
self.after(0, self._show_cells, cells)
|
||
except Exception as e:
|
||
self.after(0, lambda: messagebox.showerror("인식 실패", str(e)))
|
||
|
||
def _show_cells(self, cells: List[CellResult]) -> None:
|
||
self.cells = cells
|
||
for c in self.preview_inner.winfo_children():
|
||
c.destroy()
|
||
self._thumbs.clear()
|
||
|
||
slot_num = int(round(self.slot_var.get()))
|
||
grid = generate_grid_config(slot_num)
|
||
slot_to_cell = {c.slot_id: c for c in cells}
|
||
|
||
kind_counts = {"slab": 0, "artifact": 0, "empty": 0, "unknown": 0, "merged": 0}
|
||
for row_cfg in grid:
|
||
y = row_cfg["rows"]
|
||
for x in range(GRID_COLS):
|
||
if x >= row_cfg["cols"]:
|
||
tk.Frame(self.preview_inner, width=64, height=80,
|
||
bg=self.preview_inner.winfo_toplevel()["bg"]) \
|
||
.grid(row=y, column=x, padx=2, pady=2)
|
||
continue
|
||
slot_id = f"{y}-{x}"
|
||
cell = slot_to_cell.get(slot_id)
|
||
ov = self.overrides.get(slot_id)
|
||
effective = self._effective(cell, ov)
|
||
kind_counts[effective["kind"]] = kind_counts.get(effective["kind"], 0) + 1
|
||
self._make_cell(y, x, slot_id, effective)
|
||
|
||
msg = (
|
||
f"석판 {kind_counts.get('slab', 0)} · 아티팩트 {kind_counts.get('artifact', 0)} · "
|
||
f"빈칸 {kind_counts.get('empty', 0)} · 합쳐진(?) {kind_counts.get('merged', 0)} · "
|
||
f"미인식 {kind_counts.get('unknown', 0)}\n"
|
||
"셀을 클릭하면 종류/값을 교정할 수 있습니다. 끝나면 [이 구성으로 계산]."
|
||
)
|
||
self.status["text"] = msg
|
||
|
||
def _effective(self, cell: Optional[CellResult], ov: Optional[dict]) -> dict:
|
||
if ov is not None:
|
||
return ov
|
||
if cell is None:
|
||
return {"kind": "empty", "value": None, "rotation": 0, "merged": None, "score": 0.0}
|
||
return {
|
||
"kind": cell.kind, "value": cell.value,
|
||
"rotation": cell.rotation, "merged": None, "score": cell.score,
|
||
}
|
||
|
||
def _make_cell(self, y: int, x: int, slot_id: str, info: dict) -> None:
|
||
kind = info.get("kind", "empty")
|
||
value = info.get("value")
|
||
rot = info.get("rotation", 0) or 0
|
||
score = info.get("score", 0.0)
|
||
border = {"slab": "#7a4a8a", "artifact": "#6a82c8", "merged": "#c870c0",
|
||
"unknown": "#c8a050", "empty": "#3a2a3a"}.get(kind, "#3a2a3a")
|
||
frame = tk.Frame(self.preview_inner, bd=2, relief="solid",
|
||
width=72, height=82, bg="#2c1a2a",
|
||
highlightbackground=border, highlightthickness=2)
|
||
frame.grid(row=y, column=x, padx=2, pady=2)
|
||
frame.grid_propagate(False)
|
||
|
||
if kind == "slab" and value:
|
||
thumb = _slab_thumb(value, size=44)
|
||
if thumb is not None:
|
||
if rot:
|
||
# generate rotated thumb on the fly
|
||
base = fetch_slab_image(SLABS_BY_VALUE[value].image)
|
||
if base is not None:
|
||
base = base.resize((44, 44)).rotate(-90 * rot, expand=False)
|
||
thumb = ImageTk.PhotoImage(base)
|
||
self._thumbs.append(thumb)
|
||
lbl = tk.Label(frame, image=thumb, bg="#2c1a2a")
|
||
else:
|
||
lbl = tk.Label(frame, text=SLABS_BY_VALUE[value].ko_label,
|
||
fg="#fff", bg="#2c1a2a")
|
||
elif kind == "artifact" and value:
|
||
thumb = _artifact_thumb(value, size=44)
|
||
if thumb is not None:
|
||
self._thumbs.append(thumb)
|
||
lbl = tk.Label(frame, image=thumb, bg="#2c1a2a")
|
||
else:
|
||
a = ARTIFACTS_BY_VALUE.get(value)
|
||
lbl = tk.Label(frame, text=a.ko_label if a else value,
|
||
fg="#9bf", bg="#2c1a2a")
|
||
elif kind == "merged":
|
||
lbl = tk.Label(frame, text="?", font=("TkDefaultFont", 26, "bold"),
|
||
fg="#ffaaff", bg="#2c1a2a")
|
||
elif kind == "unknown":
|
||
lbl = tk.Label(frame, text="?", font=("TkDefaultFont", 26, "bold"),
|
||
fg="#ffd070", bg="#2c1a2a")
|
||
else:
|
||
lbl = tk.Label(frame, text="·", fg="#666", bg="#2c1a2a")
|
||
lbl.pack(expand=True)
|
||
|
||
caption = ""
|
||
if kind == "slab" and value:
|
||
caption = SLABS_BY_VALUE[value].ko_label
|
||
if rot:
|
||
caption += f" ↻{rot}"
|
||
elif kind == "artifact" and value:
|
||
a = ARTIFACTS_BY_VALUE.get(value)
|
||
caption = a.ko_label if a else value
|
||
elif kind == "merged":
|
||
m = info.get("merged") or {}
|
||
la = SLABS_BY_VALUE.get(m.get("a", "")) if m else None
|
||
lb = SLABS_BY_VALUE.get(m.get("b", "")) if m else None
|
||
caption = f"{la.ko_label if la else '?'}+{lb.ko_label if lb else '?'} L{m.get('level', 1)}" if m else "합쳐진 박스"
|
||
elif kind == "unknown":
|
||
caption = f"미인식 {score:.2f}"
|
||
else:
|
||
caption = "빈칸"
|
||
tk.Label(frame, text=caption, fg="#cfcfcf", bg="#2c1a2a",
|
||
font=("TkDefaultFont", 7)).pack(fill="x")
|
||
|
||
def on_click(_e=None, sid=slot_id):
|
||
self._edit(sid)
|
||
for w in (frame, lbl):
|
||
w.bind("<Button-1>", on_click)
|
||
|
||
def _edit(self, slot_id: str) -> None:
|
||
cell = next((c for c in self.cells if c.slot_id == slot_id), None)
|
||
dlg = CellEditor(self.winfo_toplevel(), cell)
|
||
self.winfo_toplevel().wait_window(dlg)
|
||
if dlg.result is None:
|
||
return
|
||
# store override, then re-render
|
||
self.overrides[slot_id] = {**dlg.result, "score": 1.0}
|
||
self._show_cells(self.cells)
|
||
|
||
def _confirm(self) -> None:
|
||
if not self.cells and not self.overrides:
|
||
messagebox.showinfo("안내", "먼저 캡처 + 영역 지정을 해주세요.")
|
||
return
|
||
basket: List[str] = []
|
||
for cell in self.cells:
|
||
ov = self.overrides.get(cell.slot_id)
|
||
eff = self._effective(cell, ov)
|
||
if eff["kind"] == "slab" and eff.get("value"):
|
||
basket.append(eff["value"])
|
||
elif eff["kind"] == "merged":
|
||
m = eff.get("merged") or {}
|
||
# treat a merged box as having both its component slabs in the basket
|
||
if m.get("a"):
|
||
basket.append(m["a"])
|
||
if m.get("b"):
|
||
basket.append(m["b"])
|
||
self.on_confirmed(basket)
|
||
|
||
|
||
# ---------- manual input frame ----------
|
||
|
||
class ManualFrame(ttk.Frame):
|
||
def __init__(self, master, on_changed) -> None:
|
||
super().__init__(master, padding=4)
|
||
self.counts: Dict[str, int] = {s.value: 0 for s in SLABS}
|
||
self.count_labels: Dict[str, tk.Label] = {}
|
||
self.on_changed = on_changed
|
||
self._build()
|
||
|
||
def _build(self) -> None:
|
||
canvas = tk.Canvas(self, highlightthickness=0)
|
||
scroll = ttk.Scrollbar(self, orient="vertical", command=canvas.yview)
|
||
inner = ttk.Frame(canvas)
|
||
inner.bind("<Configure>",
|
||
lambda e: canvas.configure(scrollregion=canvas.bbox("all")))
|
||
canvas.create_window((0, 0), window=inner, anchor="nw")
|
||
canvas.configure(yscrollcommand=scroll.set)
|
||
canvas.pack(side="left", fill="both", expand=True)
|
||
scroll.pack(side="right", fill="y")
|
||
|
||
by_tier: Dict[str, List] = {}
|
||
for s in SLABS:
|
||
by_tier.setdefault(s.tier, []).append(s)
|
||
|
||
row = 0
|
||
for tier in sorted(by_tier, key=lambda t: TIER_ORDER.get(t, 99)):
|
||
ttk.Label(inner, text=TIER_LABEL.get(tier, tier),
|
||
font=("TkDefaultFont", 10, "bold")) \
|
||
.grid(row=row, column=0, columnspan=4, sticky="w", pady=(8, 2))
|
||
row += 1
|
||
for slab in by_tier[tier]:
|
||
ttk.Label(inner, text=slab.ko_label, width=8).grid(row=row, column=0, sticky="w")
|
||
ttk.Button(inner, text="−", width=2,
|
||
command=lambda v=slab.value: self._change(v, -1)).grid(row=row, column=1)
|
||
lbl = ttk.Label(inner, text="0", width=3, anchor="center")
|
||
lbl.grid(row=row, column=2)
|
||
self.count_labels[slab.value] = lbl
|
||
ttk.Button(inner, text="+", width=2,
|
||
command=lambda v=slab.value: self._change(v, 1)).grid(row=row, column=3)
|
||
row += 1
|
||
|
||
def _change(self, v: str, d: int) -> None:
|
||
self.counts[v] = max(0, self.counts.get(v, 0) + d)
|
||
self.count_labels[v]["text"] = str(self.counts[v])
|
||
self.on_changed()
|
||
|
||
def clear(self) -> None:
|
||
for v in self.counts:
|
||
self.counts[v] = 0
|
||
self.count_labels[v]["text"] = "0"
|
||
self.on_changed()
|
||
|
||
def basket(self) -> List[str]:
|
||
out: List[str] = []
|
||
for v, n in self.counts.items():
|
||
out.extend([v] * n)
|
||
return out
|
||
|
||
|
||
# ---------- main app ----------
|
||
|
||
class App(tk.Tk):
|
||
def __init__(self) -> None:
|
||
super().__init__()
|
||
self.title("Sephiria Inventory Optimizer")
|
||
self.geometry("1320x820")
|
||
self.minsize(1100, 640)
|
||
|
||
self.slot_var = tk.IntVar(value=34)
|
||
self.basket: List[str] = []
|
||
self.summary_var = tk.StringVar(value="선택된 석판: 0개")
|
||
self.score_var = tk.StringVar(value="score: -")
|
||
self.solving = False
|
||
self.preview_image: Optional[ImageTk.PhotoImage] = None
|
||
self.last_solution = None
|
||
self._build()
|
||
|
||
def _build(self) -> None:
|
||
root = ttk.Frame(self, padding=8)
|
||
root.pack(fill="both", expand=True)
|
||
root.columnconfigure(0, weight=2)
|
||
root.columnconfigure(1, weight=3)
|
||
root.rowconfigure(0, weight=1)
|
||
|
||
nb = ttk.Notebook(root)
|
||
nb.grid(row=0, column=0, sticky="nsew", padx=(0, 6))
|
||
self.screenshot = ScreenshotFrame(nb, on_confirmed=self._on_screenshot_confirmed)
|
||
nb.add(self.screenshot, text="게임창/스크린샷")
|
||
self.manual = ManualFrame(nb, on_changed=self._on_manual_changed)
|
||
nb.add(self.manual, text="수동 선택")
|
||
self.nb = nb
|
||
|
||
right = ttk.Frame(root)
|
||
right.grid(row=0, column=1, sticky="nsew")
|
||
right.columnconfigure(0, weight=1)
|
||
right.rowconfigure(2, weight=1)
|
||
|
||
ctl = ttk.LabelFrame(right, text="옵션", padding=8)
|
||
ctl.grid(row=0, column=0, sticky="ew")
|
||
ttk.Label(ctl, text="슬롯 수").grid(row=0, column=0, sticky="w")
|
||
slot_scale = ttk.Scale(ctl, from_=18, to=60, orient="horizontal",
|
||
variable=self.slot_var, command=self._on_slot)
|
||
slot_scale.grid(row=0, column=1, sticky="ew", padx=6)
|
||
ctl.columnconfigure(1, weight=1)
|
||
self.slot_label = ttk.Label(ctl, text="34")
|
||
self.slot_label.grid(row=0, column=2, sticky="w")
|
||
|
||
ttk.Button(ctl, text="비우기", command=self._clear_basket).grid(
|
||
row=1, column=0, pady=(8, 0), sticky="w")
|
||
self.solve_btn = ttk.Button(ctl, text="최적 배치 계산", command=self._solve)
|
||
self.solve_btn.grid(row=1, column=1, pady=(8, 0), sticky="ew", padx=6)
|
||
ttk.Button(ctl, text="이미지 저장…", command=self._save).grid(
|
||
row=1, column=2, pady=(8, 0), sticky="e")
|
||
|
||
summary = ttk.Frame(right)
|
||
summary.grid(row=1, column=0, sticky="ew", pady=(6, 0))
|
||
ttk.Label(summary, textvariable=self.summary_var).pack(side="left")
|
||
ttk.Label(summary, textvariable=self.score_var).pack(side="right")
|
||
|
||
preview_frame = ttk.LabelFrame(right, text="결과 미리보기", padding=4)
|
||
preview_frame.grid(row=2, column=0, sticky="nsew", pady=(6, 0))
|
||
self.preview = ttk.Label(
|
||
preview_frame, anchor="center",
|
||
text="좌측에서 인벤토리를 가져오거나 석판을 추가한 뒤 "
|
||
"'최적 배치 계산'을 눌러주세요.",
|
||
)
|
||
self.preview.pack(fill="both", expand=True)
|
||
|
||
def _on_manual_changed(self) -> None:
|
||
self.basket = self.manual.basket()
|
||
self.summary_var.set(f"선택된 석판: {len(self.basket)}개 (수동)")
|
||
|
||
def _on_screenshot_confirmed(self, basket: List[str]) -> None:
|
||
self.basket = list(basket)
|
||
self.summary_var.set(f"선택된 석판: {len(self.basket)}개 (스크린샷)")
|
||
messagebox.showinfo("확정",
|
||
f"{len(self.basket)}개를 가져왔습니다. '최적 배치 계산'을 누르세요.")
|
||
|
||
def _on_slot(self, _evt) -> None:
|
||
v = int(round(self.slot_var.get()))
|
||
self.slot_var.set(v)
|
||
self.slot_label["text"] = str(v)
|
||
|
||
def _clear_basket(self) -> None:
|
||
self.manual.clear()
|
||
self.basket = []
|
||
self.summary_var.set("선택된 석판: 0개")
|
||
|
||
def _solve(self) -> None:
|
||
if self.solving:
|
||
return
|
||
if not self.basket:
|
||
messagebox.showinfo("안내",
|
||
"먼저 인벤토리를 가져오거나 수동 선택으로 석판을 추가해주세요.")
|
||
return
|
||
slot_num = int(round(self.slot_var.get()))
|
||
basket = list(self.basket)
|
||
if len(basket) > slot_num:
|
||
if not messagebox.askyesno("확인",
|
||
f"슬롯({slot_num})보다 석판({len(basket)})이 많습니다. 초과분 무시?"):
|
||
return
|
||
self.solving = True
|
||
self.solve_btn["state"] = "disabled"
|
||
self.score_var.set("score: 계산 중…")
|
||
threading.Thread(target=self._solve_worker, args=(basket, slot_num), daemon=True).start()
|
||
|
||
def _solve_worker(self, basket: List[str], slot_num: int) -> None:
|
||
try:
|
||
sol = solve(basket, slot_num=slot_num, time_limit=4.0)
|
||
img = render_solution(sol, download=True)
|
||
self.after(0, self._show_result, sol, img)
|
||
except Exception as e:
|
||
self.after(0, lambda: messagebox.showerror("오류", str(e)))
|
||
finally:
|
||
self.after(0, self._solve_done)
|
||
|
||
def _solve_done(self) -> None:
|
||
self.solving = False
|
||
self.solve_btn["state"] = "normal"
|
||
|
||
def _show_result(self, sol, img: Image.Image) -> None:
|
||
self.last_solution = sol
|
||
w = max(self.preview.winfo_width(), 600)
|
||
scale = min(1.0, w / img.width)
|
||
if scale < 1.0:
|
||
img = img.resize((int(img.width * scale), int(img.height * scale)))
|
||
self.preview_image = ImageTk.PhotoImage(img)
|
||
self.preview.configure(image=self.preview_image, text="")
|
||
self.score_var.set(f"score: {sol.score}")
|
||
|
||
def _save(self) -> None:
|
||
if self.last_solution is None:
|
||
messagebox.showinfo("안내", "먼저 계산을 실행하세요.")
|
||
return
|
||
path = filedialog.asksaveasfilename(
|
||
defaultextension=".png", filetypes=[("PNG", "*.png")],
|
||
initialfile="sephiria_layout.png",
|
||
)
|
||
if not path:
|
||
return
|
||
img = render_solution(self.last_solution, download=True)
|
||
img.save(path)
|
||
messagebox.showinfo("완료", f"저장됨: {path}")
|
||
|
||
|
||
def main() -> int:
|
||
try:
|
||
app = App()
|
||
except tk.TclError as e:
|
||
print(f"GUI를 띄울 수 없습니다 ({e}). CLI 모드를 시도하세요:", file=sys.stderr)
|
||
print(" python -m sephiria_inv --help", file=sys.stderr)
|
||
return 1
|
||
app.mainloop()
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|