- Slab catalog and effect handlers ported from WhiteDog1004/sephiria - Hill-climbing solver maximizes effect sum on slab-occupied cells - PIL renderer outputs PNG with effects overlay; downloads + caches slab images from img.sephiria.wiki on demand - Tkinter GUI for picking slabs by tier; CLI also available - Screenshot recognizer (template matching, beta) - build.bat / build.sh for portable single-file builds via PyInstaller
258 lines
9.4 KiB
Python
258 lines
9.4 KiB
Python
"""Tkinter GUI for picking slabs and rendering the optimal layout.
|
||
|
||
Layout (left-to-right):
|
||
1. Tier-grouped slab catalog with [+] [−] count controls
|
||
2. Current basket summary
|
||
3. Slot count slider + Solve button + result preview
|
||
|
||
The preview is rendered to a temp PNG via renderer and shown via PhotoImage.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import sys
|
||
import tempfile
|
||
import threading
|
||
import tkinter as tk
|
||
from tkinter import filedialog, messagebox, ttk
|
||
from typing import Dict, List
|
||
|
||
from PIL import Image, ImageTk
|
||
|
||
from .renderer import render_solution
|
||
from .slabs import SLABS, SLABS_BY_VALUE, TIER_LABEL, TIER_ORDER
|
||
from .solver import solve
|
||
|
||
|
||
class App(tk.Tk):
|
||
def __init__(self) -> None:
|
||
super().__init__()
|
||
self.title("Sephiria Inventory Optimizer")
|
||
self.geometry("1280x800")
|
||
self.minsize(1000, 600)
|
||
|
||
# counts: value -> int
|
||
self.counts: Dict[str, int] = {s.value: 0 for s in SLABS}
|
||
self.count_labels: Dict[str, tk.Label] = {}
|
||
self.summary_var = tk.StringVar(value="선택된 석판: 0개")
|
||
self.score_var = tk.StringVar(value="score: -")
|
||
self.slot_var = tk.IntVar(value=34)
|
||
self.solving = False
|
||
self.preview_image: ImageTk.PhotoImage | None = None
|
||
self.last_solution = None
|
||
|
||
self._build()
|
||
|
||
# -------------------------------------------------------------- layout
|
||
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)
|
||
|
||
# Left: catalog
|
||
left = ttk.LabelFrame(root, text="석판 목록 (보유 개수)", padding=6)
|
||
left.grid(row=0, column=0, sticky="nsew", padx=(0, 6))
|
||
self._build_catalog(left)
|
||
|
||
# Right: controls + preview
|
||
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_change,
|
||
)
|
||
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).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 _build_catalog(self, parent: tk.Widget) -> None:
|
||
canvas = tk.Canvas(parent, highlightthickness=0)
|
||
scroll = ttk.Scrollbar(parent, 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")
|
||
|
||
# mousewheel
|
||
def _wheel(e):
|
||
canvas.yview_scroll(int(-e.delta / 120), "units")
|
||
|
||
canvas.bind_all("<MouseWheel>", _wheel)
|
||
canvas.bind_all("<Button-4>", lambda e: canvas.yview_scroll(-1, "units"))
|
||
canvas.bind_all("<Button-5>", lambda e: canvas.yview_scroll(1, "units"))
|
||
|
||
# group by tier
|
||
by_tier: Dict[str, List] = {t: [] for t in TIER_ORDER}
|
||
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)):
|
||
slabs = by_tier[tier]
|
||
if not slabs:
|
||
continue
|
||
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 slabs:
|
||
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_count(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_count(v, 1),
|
||
).grid(row=row, column=3)
|
||
row += 1
|
||
|
||
# -------------------------------------------------------------- handlers
|
||
def _change_count(self, value: str, delta: int) -> None:
|
||
n = max(0, self.counts.get(value, 0) + delta)
|
||
self.counts[value] = n
|
||
self.count_labels[value]["text"] = str(n)
|
||
self._update_summary()
|
||
|
||
def _clear(self) -> None:
|
||
for v in self.counts:
|
||
self.counts[v] = 0
|
||
self.count_labels[v]["text"] = "0"
|
||
self._update_summary()
|
||
|
||
def _update_summary(self) -> None:
|
||
total = sum(self.counts.values())
|
||
self.summary_var.set(f"선택된 석판: {total}개")
|
||
|
||
def _on_slot_change(self, _evt) -> None:
|
||
v = int(round(self.slot_var.get()))
|
||
self.slot_var.set(v)
|
||
self.slot_label["text"] = str(v)
|
||
|
||
def _expand_basket(self) -> List[str]:
|
||
basket: List[str] = []
|
||
for v, n in self.counts.items():
|
||
basket.extend([v] * n)
|
||
return basket
|
||
|
||
def _solve(self) -> None:
|
||
if self.solving:
|
||
return
|
||
basket = self._expand_basket()
|
||
if not basket:
|
||
messagebox.showinfo("안내", "먼저 보유한 석판을 추가하세요.")
|
||
return
|
||
slot_num = int(round(self.slot_var.get()))
|
||
if len(basket) > slot_num:
|
||
if not messagebox.askyesno(
|
||
"확인",
|
||
f"슬롯({slot_num})보다 석판({len(basket)})이 많습니다.\n"
|
||
"초과분은 무시하고 계산할까요?",
|
||
):
|
||
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
|
||
# fit preview width
|
||
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())
|