Sephiria inventory optimizer v0.1.0
- 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
This commit is contained in:
257
sephiria_inv/gui.py
Normal file
257
sephiria_inv/gui.py
Normal file
@@ -0,0 +1,257 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user