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:
tkrmagid
2026-05-13 22:12:49 +09:00
parent 88f46eb146
commit 3cb8140cfa
13 changed files with 1940 additions and 0 deletions

257
sephiria_inv/gui.py Normal file
View 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())