389 lines
16 KiB
Python
389 lines
16 KiB
Python
import tkinter as tk
|
||
from tkinter import ttk, filedialog, messagebox
|
||
import json
|
||
import os
|
||
from pathlib import Path
|
||
from PIL import Image, ImageTk
|
||
|
||
# ── THEME ──────────────────────────────────────────────────────────────────
|
||
BG_DARK = "#2D2D30"
|
||
BG_PANEL = "#1E1E1E"
|
||
FG_MAIN = "#FFFFFF"
|
||
FG_GRAY = "#808080"
|
||
ACCENT = "#007ACC"
|
||
BTN_GREEN = "#3C8527"
|
||
BTN_RES = "#4A4A4F"
|
||
BTN_RES_ON= "#CC7A00"
|
||
|
||
# Base canvas sizes in 16x tile units (width_tiles, height_tiles)
|
||
# Multiply by tile_px at build time to get pixel size
|
||
CANVAS_TILES = {
|
||
"terrain": (16, 32),
|
||
"items": (16, 16),
|
||
"particles": (8, 8),
|
||
}
|
||
|
||
_preview_img = None
|
||
|
||
projects = {
|
||
"terrain": {"json_path": "", "layout": [], "final_map": {}},
|
||
"items": {"json_path": "", "layout": [], "final_map": {}},
|
||
"particles": {"json_path": "", "layout": [], "final_map": {}},
|
||
}
|
||
source_dir = {"v": ""}
|
||
current_scale = {"v": 1} # 1=16x 2=32x 4=64x
|
||
|
||
TAB_ORDER = ["terrain", "items", "particles"]
|
||
|
||
# ── ROOT ───────────────────────────────────────────────────────────────────
|
||
root = tk.Tk()
|
||
root.title("Texture Manager v5.5")
|
||
root.geometry("1000x800")
|
||
root.minsize(800, 600)
|
||
root.configure(bg=BG_DARK)
|
||
|
||
# ── TTK STYLES ─────────────────────────────────────────────────────────────
|
||
style = ttk.Style()
|
||
style.theme_use("default")
|
||
style.configure("Dark.Treeview",
|
||
background=BG_PANEL, foreground=FG_MAIN,
|
||
fieldbackground=BG_PANEL, rowheight=22,
|
||
font=("Segoe UI", 9))
|
||
style.configure("Dark.Treeview.Heading",
|
||
background="#3C3C3C", foreground=FG_MAIN,
|
||
font=("Segoe UI", 9, "bold"), relief="flat")
|
||
style.map("Dark.Treeview",
|
||
background=[("selected", ACCENT)],
|
||
foreground=[("selected", FG_MAIN)])
|
||
style.configure("Dark.TNotebook",
|
||
background=BG_DARK, borderwidth=0, tabmargins=0)
|
||
style.configure("Dark.TNotebook.Tab",
|
||
background="#3C3C3C", foreground=FG_GRAY,
|
||
padding=[18, 7], font=("Segoe UI", 9, "bold"))
|
||
style.map("Dark.TNotebook.Tab",
|
||
background=[("selected", ACCENT)],
|
||
foreground=[("selected", FG_MAIN)])
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# TOP BAR
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
top_bar = tk.Frame(root, bg=BG_PANEL, height=46)
|
||
top_bar.pack(side="top", fill="x")
|
||
top_bar.pack_propagate(False)
|
||
|
||
def pick_library():
|
||
d = filedialog.askdirectory(title="Select Texture Library Folder")
|
||
if d:
|
||
source_dir["v"] = d
|
||
btn_lib.config(text=f"Library: {Path(d).name}")
|
||
|
||
btn_lib = tk.Button(top_bar, text="1. SELECT LIBRARY",
|
||
bg="#3C3C3C", fg=FG_MAIN,
|
||
activebackground="#505050", activeforeground=FG_MAIN,
|
||
relief="flat", font=("Segoe UI", 9, "bold"),
|
||
command=pick_library)
|
||
btn_lib.pack(side="left", padx=(8, 6), pady=7, ipady=5, ipadx=10)
|
||
|
||
json_btns = {}
|
||
for ptype in TAB_ORDER:
|
||
def make_json_cmd(pt):
|
||
def cmd():
|
||
f = filedialog.askopenfilename(
|
||
title=f"Select {pt.upper()} JSON",
|
||
filetypes=[("JSON Files", "*.json")])
|
||
if f:
|
||
with open(f, "r", encoding="utf-8") as fh:
|
||
projects[pt]["layout"] = json.load(fh)
|
||
projects[pt]["json_path"] = f
|
||
projects[pt]["final_map"] = {}
|
||
json_btns[pt].config(
|
||
text=f"{pt.upper()} JSON: {Path(f).name}",
|
||
fg="#90EE90")
|
||
refresh_list(pt)
|
||
return cmd
|
||
b = tk.Button(top_bar,
|
||
text=f"{ptype.upper()} JSON: ---",
|
||
bg="#3C3C3C", fg=FG_GRAY,
|
||
activebackground="#505050", activeforeground=FG_MAIN,
|
||
relief="flat", font=("Segoe UI", 9),
|
||
command=make_json_cmd(ptype))
|
||
b.pack(side="left", padx=4, pady=7, ipady=5, ipadx=8)
|
||
json_btns[ptype] = b
|
||
|
||
tk.Frame(top_bar, bg="#555555", width=2).pack(
|
||
side="left", fill="y", pady=8, padx=6)
|
||
|
||
res_btns = {}
|
||
|
||
def set_resolution(scale):
|
||
current_scale["v"] = scale
|
||
labels = {1: "16x", 2: "32x", 4: "64x"}
|
||
for s, btn in res_btns.items():
|
||
if s == scale:
|
||
btn.config(bg=BTN_RES_ON, fg=FG_MAIN, relief="sunken")
|
||
else:
|
||
btn.config(bg=BTN_RES, fg=FG_GRAY, relief="flat")
|
||
root.title(f"Texture Manager v5.5 [{labels[scale]}]")
|
||
|
||
for scale, label in [(1, "16x"), (2, "32x"), (4, "64x")]:
|
||
def make_res_cmd(s):
|
||
return lambda: set_resolution(s)
|
||
b = tk.Button(top_bar, text=label,
|
||
bg=BTN_RES, fg=FG_GRAY,
|
||
activebackground="#666666", activeforeground=FG_MAIN,
|
||
relief="flat", font=("Segoe UI", 9, "bold"), width=4,
|
||
command=make_res_cmd(scale))
|
||
b.pack(side="left", padx=2, pady=7, ipady=5)
|
||
res_btns[scale] = b
|
||
|
||
set_resolution(1)
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# LOAD TILE
|
||
# Crops to a square first frame (handles animated strips of any height),
|
||
# then resizes to exactly tile_px × tile_px using nearest neighbour.
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
def load_tile(fpath, tile_px):
|
||
img = Image.open(fpath).convert("RGBA")
|
||
w, h = img.size
|
||
|
||
# Determine the native tile size: smallest of w and h
|
||
# (animated strips are always taller than wide)
|
||
native = min(w, h)
|
||
|
||
# Crop to first frame (top-left native×native square)
|
||
if w != native or h != native:
|
||
img = img.crop((0, 0, native, native))
|
||
|
||
# Scale to target tile size
|
||
if img.size != (tile_px, tile_px):
|
||
img = img.resize((tile_px, tile_px), Image.NEAREST)
|
||
|
||
return img
|
||
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# NOTEBOOK TABS
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
notebook = ttk.Notebook(root, style="Dark.TNotebook")
|
||
notebook.pack(side="top", fill="both", expand=True, padx=10, pady=(6, 0))
|
||
|
||
trees = {}
|
||
previews = {}
|
||
|
||
for ptype in TAB_ORDER:
|
||
tab = tk.Frame(notebook, bg=BG_DARK)
|
||
notebook.add(tab, text=f" {ptype.upper()} ")
|
||
|
||
sv = tk.StringVar()
|
||
se = tk.Entry(tab, textvariable=sv,
|
||
bg=BG_PANEL, fg=FG_MAIN, insertbackground=FG_MAIN,
|
||
relief="flat", font=("Segoe UI", 10))
|
||
se.pack(side="top", fill="x", padx=0, pady=(0, 4), ipady=4)
|
||
projects[ptype]["search_var"] = sv
|
||
sv.trace_add("write", lambda *_, pt=ptype: refresh_list(pt))
|
||
|
||
pane = tk.PanedWindow(tab, orient=tk.HORIZONTAL,
|
||
bg=BG_DARK, sashwidth=5, sashrelief="flat")
|
||
pane.pack(fill="both", expand=True)
|
||
|
||
lf = tk.Frame(pane, bg=BG_PANEL)
|
||
pane.add(lf, width=620, minsize=300)
|
||
|
||
tree = ttk.Treeview(lf, columns=("block", "source"),
|
||
show="headings", style="Dark.Treeview",
|
||
selectmode="browse")
|
||
tree.heading("block", text="BLOCK TYPE")
|
||
tree.heading("source", text="SOURCE FILE")
|
||
tree.column("block", width=250, anchor="w")
|
||
tree.column("source", width=350, anchor="w")
|
||
vsb = ttk.Scrollbar(lf, orient="vertical", command=tree.yview)
|
||
tree.configure(yscrollcommand=vsb.set)
|
||
vsb.pack(side="right", fill="y")
|
||
tree.pack(side="left", fill="both", expand=True)
|
||
trees[ptype] = tree
|
||
|
||
rf = tk.Frame(pane, bg=BG_DARK)
|
||
pane.add(rf, minsize=200)
|
||
|
||
pc = tk.Canvas(rf, bg="black", highlightthickness=0)
|
||
pc.place(x=10, y=10, relwidth=1.0, width=-20, height=310)
|
||
previews[ptype] = pc
|
||
|
||
def make_auto(pt):
|
||
def do_auto():
|
||
if not source_dir["v"]:
|
||
messagebox.showwarning("No Library",
|
||
"Please select a library folder first.")
|
||
return
|
||
png_files = {}
|
||
for r, _, files in os.walk(source_dir["v"]):
|
||
for f in files:
|
||
if f.lower().endswith(".png"):
|
||
base = Path(f).stem
|
||
if base not in png_files:
|
||
png_files[base] = os.path.join(r, f)
|
||
matched = 0
|
||
for i, obj in enumerate(projects[pt]["layout"]):
|
||
name = obj.get("Name") or obj.get("n", "")
|
||
if name in png_files:
|
||
projects[pt]["final_map"][i] = png_files[name]
|
||
matched += 1
|
||
refresh_list(pt)
|
||
messagebox.showinfo("Auto-Sync Complete",
|
||
f"Matched {matched} of {len(projects[pt]['layout'])} entries.")
|
||
return do_auto
|
||
|
||
tk.Button(rf, text="AUTO-SYNC FROM LIBRARY",
|
||
bg=ACCENT, fg=FG_MAIN,
|
||
activebackground="#005FA3", activeforeground=FG_MAIN,
|
||
relief="flat", font=("Segoe UI", 10, "bold"),
|
||
command=make_auto(ptype)).place(
|
||
x=10, y=328, relwidth=1.0, width=-20, height=52)
|
||
|
||
def make_sel(pt):
|
||
def on_sel(event):
|
||
global _preview_img
|
||
sel = trees[pt].selection()
|
||
if not sel:
|
||
return
|
||
idx = int(sel[0])
|
||
previews[pt].delete("all")
|
||
if idx not in projects[pt]["final_map"]:
|
||
return
|
||
try:
|
||
img = load_tile(projects[pt]["final_map"][idx], 16)
|
||
cw = previews[pt].winfo_width() or 310
|
||
ch = previews[pt].winfo_height() or 310
|
||
sc = max(1, min(cw // img.width, ch // img.height))
|
||
img = img.resize(
|
||
(img.width * sc, img.height * sc), Image.NEAREST)
|
||
_preview_img = ImageTk.PhotoImage(img)
|
||
ox = (cw - img.width) // 2
|
||
oy = (ch - img.height) // 2
|
||
previews[pt].create_image(ox, oy, anchor="nw",
|
||
image=_preview_img)
|
||
except Exception:
|
||
pass
|
||
return on_sel
|
||
|
||
tree.bind("<<TreeviewSelect>>", make_sel(ptype))
|
||
|
||
def make_dbl(pt):
|
||
def on_dbl(event):
|
||
sel = trees[pt].selection()
|
||
if not sel:
|
||
return
|
||
idx = int(sel[0])
|
||
path = filedialog.askopenfilename(
|
||
title="Select PNG",
|
||
filetypes=[("PNG Files", "*.png")])
|
||
if path:
|
||
projects[pt]["final_map"][idx] = path
|
||
refresh_list(pt)
|
||
trees[pt].selection_set(str(idx))
|
||
return on_dbl
|
||
|
||
tree.bind("<Double-1>", make_dbl(ptype))
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# REFRESH LIST
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
def refresh_list(ptype=None):
|
||
if ptype is None:
|
||
ptype = TAB_ORDER[notebook.index(notebook.select())]
|
||
tree = trees[ptype]
|
||
proj = projects[ptype]
|
||
filt = proj["search_var"].get().lower()
|
||
tree.delete(*tree.get_children())
|
||
for i, obj in enumerate(proj["layout"]):
|
||
display = (obj.get("DisplayName") or
|
||
obj.get("n") or
|
||
obj.get("Name", ""))
|
||
if filt and filt not in display.lower():
|
||
continue
|
||
fname = (os.path.basename(proj["final_map"][i])
|
||
if i in proj["final_map"] else "---")
|
||
iid = tree.insert("", "end", iid=str(i), values=(display, fname))
|
||
if fname == "---":
|
||
tree.item(iid, tags=("gray",))
|
||
tree.tag_configure("gray", foreground=FG_GRAY)
|
||
|
||
notebook.bind("<<NotebookTabChanged>>", lambda *_: refresh_list())
|
||
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
# BUILD
|
||
# ═══════════════════════════════════════════════════════════════════════════
|
||
def do_build():
|
||
ptype = TAB_ORDER[notebook.index(notebook.select())]
|
||
proj = projects[ptype]
|
||
scale = current_scale["v"]
|
||
tile_px = 16 * scale # e.g. 32 for 32x
|
||
|
||
if not proj["json_path"]:
|
||
messagebox.showwarning("No JSON",
|
||
f"Please load a {ptype.upper()} JSON first.")
|
||
return
|
||
|
||
cols, rows = CANVAS_TILES[ptype]
|
||
cw = cols * tile_px # canvas width in pixels
|
||
ch = rows * tile_px # canvas height in pixels
|
||
|
||
canvas_img = Image.new("RGBA", (cw, ch), (0, 0, 0, 0))
|
||
|
||
placed = 0
|
||
errors = 0
|
||
for k, fpath in proj["final_map"].items():
|
||
if not os.path.exists(fpath):
|
||
continue
|
||
try:
|
||
tile = load_tile(fpath, tile_px)
|
||
obj = proj["layout"][k]
|
||
|
||
# Convert JSON coord (always 16x pixel space) → grid slot → output pixel
|
||
json_x = int(obj.get("X", obj.get("x", 0)))
|
||
json_y = int(obj.get("Y", obj.get("y", 0)))
|
||
col = json_x // 16 # which grid column (0-based)
|
||
row = json_y // 16 # which grid row (0-based)
|
||
px = col * tile_px # output pixel X
|
||
py = row * tile_px # output pixel Y
|
||
|
||
# Use tile as its own alpha mask so transparent edges
|
||
# never overwrite adjacent tiles
|
||
canvas_img.paste(tile, (px, py), tile)
|
||
placed += 1
|
||
except Exception as e:
|
||
errors += 1
|
||
|
||
out_dir = os.path.dirname(proj["json_path"])
|
||
json_base = Path(proj["json_path"]).stem
|
||
out_path = os.path.join(out_dir, f"{json_base}.png")
|
||
canvas_img.save(out_path)
|
||
|
||
if ptype == "terrain":
|
||
canvas_img.resize((cw // 2, ch // 2), Image.NEAREST).save(
|
||
os.path.join(out_dir, "terrainMipMapLevel2.png"))
|
||
canvas_img.resize((cw // 4, ch // 4), Image.NEAREST).save(
|
||
os.path.join(out_dir, "terrainMipMapLevel3.png"))
|
||
|
||
res_label = {1: "16x", 2: "32x", 4: "64x"}[scale]
|
||
msg = (f"Resolution: {res_label}\n"
|
||
f"Canvas: {cw}×{ch}\n"
|
||
f"Tiles placed: {placed}")
|
||
if errors:
|
||
msg += f"\nErrors skipped: {errors}"
|
||
messagebox.showinfo("Build Complete", f"{msg}\n\nSaved to:\n{out_path}")
|
||
|
||
# ── BOTTOM BUILD BAR ───────────────────────────────────────────────────────
|
||
bottom = tk.Frame(root, bg=BG_DARK, height=70)
|
||
bottom.pack(side="bottom", fill="x", padx=10, pady=(0, 8))
|
||
bottom.pack_propagate(False)
|
||
|
||
tk.Button(bottom, text="BUILD ASSETS",
|
||
bg=BTN_GREEN, fg=FG_MAIN,
|
||
activebackground="#2E6B1A", activeforeground=FG_MAIN,
|
||
relief="flat", font=("Segoe UI", 12, "bold"),
|
||
command=do_build).pack(fill="both", expand=True)
|
||
|
||
root.mainloop()
|