- 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
223 lines
6.6 KiB
Python
223 lines
6.6 KiB
Python
"""Render a Solution to a PNG image.
|
|
|
|
The renderer draws a grid mirroring the in-game inventory: each cell is a
|
|
fixed-size square; cells with slabs show the slab image (downloaded from the
|
|
CDN on first run and cached locally). Effect numbers from `compute_effects`
|
|
are overlaid on each cell.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from io import BytesIO
|
|
from typing import Dict, Optional
|
|
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
from .slabs import (
|
|
GRID_COLS,
|
|
Placement,
|
|
SLABS_BY_VALUE,
|
|
compute_effects,
|
|
)
|
|
from .solver import Solution
|
|
|
|
|
|
CACHE_DIR = os.path.join(
|
|
os.environ.get("LOCALAPPDATA")
|
|
or os.environ.get("XDG_CACHE_HOME")
|
|
or os.path.expanduser("~/.cache"),
|
|
"sephiria_inv",
|
|
"slabs",
|
|
)
|
|
|
|
CDN_BASE = "https://img.sephiria.wiki"
|
|
|
|
CELL = 96 # pixels per slot
|
|
PAD = 12
|
|
HEADER = 56
|
|
FOOTER = 40
|
|
|
|
BG_COLOR = (29, 16, 27)
|
|
CELL_BG = (50, 30, 50)
|
|
CELL_BORDER = (88, 60, 90)
|
|
POS_COLOR = (132, 220, 132)
|
|
NEG_COLOR = (240, 110, 110)
|
|
ZERO_COLOR = (160, 160, 160)
|
|
IGNORE_COLOR = (160, 160, 160)
|
|
TEXT_COLOR = (236, 226, 232)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Asset cache
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _ensure_cache_dir() -> None:
|
|
os.makedirs(CACHE_DIR, exist_ok=True)
|
|
|
|
|
|
def _local_path(slab_image: str) -> str:
|
|
# slab.image is like "slabs/foo.png"; flatten to filename
|
|
return os.path.join(CACHE_DIR, os.path.basename(slab_image))
|
|
|
|
|
|
def fetch_slab_image(slab_image: str, timeout: float = 10.0) -> Optional[Image.Image]:
|
|
"""Return a PIL Image for the slab, downloading + caching if needed.
|
|
|
|
Returns None if download fails — caller draws a placeholder.
|
|
"""
|
|
_ensure_cache_dir()
|
|
path = _local_path(slab_image)
|
|
if os.path.exists(path):
|
|
try:
|
|
return Image.open(path).convert("RGBA")
|
|
except Exception:
|
|
pass
|
|
try:
|
|
import requests # lazy import; allow renderer use without network if cached
|
|
|
|
url = f"{CDN_BASE}/{slab_image.lstrip('/')}"
|
|
r = requests.get(url, timeout=timeout)
|
|
if r.status_code != 200:
|
|
return None
|
|
with open(path, "wb") as f:
|
|
f.write(r.content)
|
|
return Image.open(BytesIO(r.content)).convert("RGBA")
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Font
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _load_font(size: int) -> ImageFont.ImageFont:
|
|
candidates = [
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
|
|
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
|
"C:\\Windows\\Fonts\\malgun.ttf",
|
|
"C:\\Windows\\Fonts\\arial.ttf",
|
|
"/System/Library/Fonts/Helvetica.ttc",
|
|
]
|
|
for c in candidates:
|
|
if os.path.exists(c):
|
|
try:
|
|
return ImageFont.truetype(c, size)
|
|
except Exception:
|
|
continue
|
|
return ImageFont.load_default()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Public API
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def render_solution(
|
|
sol: Solution,
|
|
*,
|
|
download: bool = True,
|
|
title: Optional[str] = None,
|
|
) -> Image.Image:
|
|
"""Render the solution to a PIL Image."""
|
|
grid = sol.grid
|
|
rows = len(grid)
|
|
width = PAD * 2 + GRID_COLS * CELL
|
|
height = PAD * 2 + HEADER + rows * CELL + FOOTER
|
|
|
|
img = Image.new("RGB", (width, height), BG_COLOR)
|
|
draw = ImageDraw.Draw(img)
|
|
|
|
title_font = _load_font(22)
|
|
cell_font = _load_font(20)
|
|
eff_font = _load_font(28)
|
|
foot_font = _load_font(16)
|
|
|
|
# Title
|
|
txt = title or "Sephiria Inventory Optimizer"
|
|
draw.text((PAD, PAD), txt, fill=TEXT_COLOR, font=title_font)
|
|
draw.text(
|
|
(PAD, PAD + 26),
|
|
f"slots {sol.slot_num} · placed {len(sol.placements)} · score {sol.score}",
|
|
fill=(180, 180, 180),
|
|
font=foot_font,
|
|
)
|
|
|
|
# Effects map
|
|
effects, flags = compute_effects(sol.placements, grid)
|
|
by_slot: Dict[str, Placement] = {p.slot_id: p for p in sol.placements}
|
|
|
|
grid_top = PAD + HEADER
|
|
for row_cfg in grid:
|
|
y = row_cfg["rows"]
|
|
for x in range(row_cfg["cols"]):
|
|
cx0 = PAD + x * CELL
|
|
cy0 = grid_top + y * CELL
|
|
cx1 = cx0 + CELL - 4
|
|
cy1 = cy0 + CELL - 4
|
|
# cell background
|
|
draw.rounded_rectangle(
|
|
(cx0, cy0, cx1, cy1), radius=8, fill=CELL_BG, outline=CELL_BORDER
|
|
)
|
|
|
|
slot_id = f"{y}-{x}"
|
|
p = by_slot.get(slot_id)
|
|
if p is not None:
|
|
slab = SLABS_BY_VALUE.get(p.value)
|
|
placed = False
|
|
if slab is not None and download:
|
|
img_s = fetch_slab_image(slab.image)
|
|
if img_s is not None:
|
|
img_s = img_s.resize((CELL - 16, CELL - 16))
|
|
if p.rotation:
|
|
# PIL rotates counter-clockwise; sephiria rotation
|
|
# is 90°-CW per step.
|
|
img_s = img_s.rotate(-90 * p.rotation, expand=False)
|
|
img.paste(img_s, (cx0 + 6, cy0 + 6), img_s)
|
|
placed = True
|
|
if not placed and slab is not None:
|
|
# text fallback
|
|
draw.text(
|
|
(cx0 + 6, cy0 + 6),
|
|
slab.ko_label,
|
|
fill=TEXT_COLOR,
|
|
font=cell_font,
|
|
)
|
|
|
|
# effect overlay (top-right)
|
|
v = effects.get(slot_id, 0)
|
|
if flags.get(slot_id) == "ignore":
|
|
label = "—"
|
|
color = IGNORE_COLOR
|
|
else:
|
|
label = f"{v:+d}" if v else "0"
|
|
color = (
|
|
POS_COLOR if v > 0 else NEG_COLOR if v < 0 else ZERO_COLOR
|
|
)
|
|
tw = draw.textlength(label, font=eff_font)
|
|
draw.text(
|
|
(cx1 - tw - 6, cy0 + 4),
|
|
label,
|
|
fill=color,
|
|
font=eff_font,
|
|
)
|
|
|
|
# Footer
|
|
draw.text(
|
|
(PAD, height - FOOTER + 8),
|
|
"Generated by sephiria_inv_program · data from img.sephiria.wiki",
|
|
fill=(120, 120, 120),
|
|
font=foot_font,
|
|
)
|
|
|
|
return img
|
|
|
|
|
|
def save_solution(sol: Solution, out_path: str, **kwargs) -> str:
|
|
img = render_solution(sol, **kwargs)
|
|
img.save(out_path)
|
|
return out_path
|