Files
mice/tools/level_editor.py
T

1185 lines
47 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
import copy
import sys
from collections import deque
from pathlib import Path
try:
import tkinter as tk
from tkinter import filedialog, messagebox, simpledialog, ttk
TKINTER_IMPORT_ERROR = None
except ModuleNotFoundError as exc:
if exc.name not in {"tkinter", "_tkinter"}:
raise
tk = None
filedialog = None
messagebox = None
simpledialog = None
ttk = None
TKINTER_IMPORT_ERROR = exc
PROJECT_ROOT = Path(__file__).resolve().parents[1]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from engine import maze
APP_TITLE = "Mice! Level Editor"
DEFAULT_CELL_SIZE = 22
MIN_CELL_SIZE = 12
MAX_CELL_SIZE = 48
MAX_HISTORY = 80
VIEWPORT_PADDING = 24
GRID_COLOR = "#2f2f2f"
HOVER_COLOR = "#ffd54a"
TILE_COLORS = {
maze.MAP_EMPTY: "#f0e7d0",
maze.MAP_WALL: "#4d8b57",
maze.MAP_TUNNEL: "#6f7785",
}
TILE_NAMES = {
maze.MAP_EMPTY: "EMPTY",
maze.MAP_WALL: "WALL",
maze.MAP_TUNNEL: "TUNNEL",
}
TILE_DESCRIPTIONS = {
maze.MAP_EMPTY: "cella aperta: spawn ratti e piazzamento armi",
maze.MAP_WALL: "muro solido non attraversabile",
maze.MAP_TUNNEL: "grotta attraversabile ma non valida per spawn/armi",
}
TOOL_NAMES = {
"brush": "Pennello",
"fill": "Riempimento",
"rectangle": "Rettangolo",
}
def ensure_tkinter_available():
if TKINTER_IMPORT_ERROR is None:
return
raise RuntimeError(
"tkinter is not available in this Python installation. "
"Install the system package that provides tkinter for Python 3 "
"(for example python3-tkinter on Fedora) and run the editor again."
) from TKINTER_IMPORT_ERROR
def parse_args():
parser = argparse.ArgumentParser(description="Tkinter editor for Mice! level.dat archives")
parser.add_argument("--file", dest="file_path", default=None, help="Path to an existing level.dat archive")
parser.add_argument("--level", type=int, default=0, help="Initial 0-based level index")
return parser.parse_args()
def deep_copy_level(level):
return [row[:] for row in level]
def build_default_archive():
return [maze.create_level() for _ in range(maze.DEFAULT_LEVELS_PER_DAT_FILE)]
def fit_level_to_dat_size(source_tiles):
fitted = maze.create_level()
source_height = len(source_tiles)
source_width = len(source_tiles[0])
copy_width = min(source_width, maze.LEVEL_WIDTH)
copy_height = min(source_height, maze.LEVEL_HEIGHT)
source_x = max((source_width - maze.LEVEL_WIDTH) // 2, 0)
source_y = max((source_height - maze.LEVEL_HEIGHT) // 2, 0)
target_x = max((maze.LEVEL_WIDTH - source_width) // 2, 0)
target_y = max((maze.LEVEL_HEIGHT - source_height) // 2, 0)
for y in range(copy_height):
for x in range(copy_width):
fitted[target_y + y][target_x + x] = source_tiles[source_y + y][source_x + x]
return fitted
def compute_level_stats(tiles):
height = len(tiles)
width = len(tiles[0]) if tiles else 0
empty_count = 0
wall_count = 0
tunnel_count = 0
traversable_count = 0
spawnable_count = 0
border_openings = 0
for y, row in enumerate(tiles):
for x, cell in enumerate(row):
if cell == maze.MAP_EMPTY:
empty_count += 1
traversable_count += 1
elif cell == maze.MAP_WALL:
wall_count += 1
elif cell == maze.MAP_TUNNEL:
tunnel_count += 1
traversable_count += 1
if x in (0, width - 1) or y in (0, height - 1):
if cell != maze.MAP_WALL:
border_openings += 1
for y in range(1, height - 1):
for x in range(1, width - 1):
if tiles[y][x] != maze.MAP_EMPTY:
continue
for dx, dy in ((0, -1), (1, 0), (0, 1), (-1, 0)):
if tiles[y + dy][x + dx] == maze.MAP_EMPTY:
spawnable_count += 1
break
component_count = 0
largest_component = 0
visited = set()
for y, row in enumerate(tiles):
for x, cell in enumerate(row):
if cell == maze.MAP_WALL or (x, y) in visited:
continue
component_count += 1
queue = deque([(x, y)])
visited.add((x, y))
component_size = 0
while queue:
current_x, current_y = queue.popleft()
component_size += 1
for dx, dy in ((0, -1), (1, 0), (0, 1), (-1, 0)):
next_x = current_x + dx
next_y = current_y + dy
if not (0 <= next_x < width and 0 <= next_y < height):
continue
if tiles[next_y][next_x] == maze.MAP_WALL:
continue
if (next_x, next_y) in visited:
continue
visited.add((next_x, next_y))
queue.append((next_x, next_y))
largest_component = max(largest_component, component_size)
warnings = []
if border_openings:
warnings.append(f"bordo aperto in {border_openings} celle")
if traversable_count == 0:
warnings.append("nessuna cella attraversabile")
if empty_count == 0:
warnings.append("nessuna cella EMPTY: niente spawn e niente armi")
elif spawnable_count == 0:
warnings.append("nessuna posizione di spawn valida per i ratti")
if component_count > 1:
warnings.append(f"area attraversabile divisa in {component_count} componenti")
return {
"width": width,
"height": height,
"empty_count": empty_count,
"wall_count": wall_count,
"tunnel_count": tunnel_count,
"traversable_count": traversable_count,
"spawnable_count": spawnable_count,
"border_openings": border_openings,
"component_count": component_count,
"largest_component": largest_component,
"warnings": warnings,
}
def compute_canvas_layout(
tile_width,
tile_height,
viewport_width,
viewport_height,
cell_size,
fit_to_viewport,
):
render_cell_size = float(cell_size)
if fit_to_viewport and viewport_width > 1 and viewport_height > 1 and tile_width > 0 and tile_height > 0:
available_width = max(viewport_width - VIEWPORT_PADDING * 2, tile_width)
available_height = max(viewport_height - VIEWPORT_PADDING * 2, tile_height)
render_cell_size = max(1.0, min(available_width / tile_width, available_height / tile_height))
map_width = tile_width * render_cell_size
map_height = tile_height * render_cell_size
origin_x = max((viewport_width - map_width) / 2.0, 0.0) if viewport_width > 1 else 0.0
origin_y = max((viewport_height - map_height) / 2.0, 0.0) if viewport_height > 1 else 0.0
return {
"cell_size": render_cell_size,
"origin_x": origin_x,
"origin_y": origin_y,
"map_width": map_width,
"map_height": map_height,
"scrollregion": (
0,
0,
max(viewport_width, origin_x + map_width),
max(viewport_height, origin_y + map_height),
),
}
class LevelEditor(tk.Tk if tk is not None else object):
def __init__(self, file_path=None, level_index=0):
ensure_tkinter_available()
super().__init__()
self.title(APP_TITLE)
self.geometry("1280x920")
self.minsize(1100, 760)
self.protocol("WM_DELETE_WINDOW", self.on_exit)
self.selected_tile = tk.IntVar(value=maze.MAP_EMPTY)
self.selected_tool = tk.StringVar(value="brush")
self.level_number_var = tk.IntVar(value=1)
self.level_range_var = tk.StringVar(value="Vai a livello")
self.show_grid = tk.BooleanVar(value=True)
self.fit_to_viewport = tk.BooleanVar(value=True)
self.file_var = tk.StringVar(value="Nuovo archivio DAT non salvato")
self.stats_var = tk.StringVar(value="")
self.status_var = tk.StringVar(value="")
self.cell_size = DEFAULT_CELL_SIZE
self.current_path = None
self.levels = build_default_archive()
self.current_level_index = maze.normalize_level_index(level_index, len(self.levels))
self.dirty = False
self.clipboard_level = None
self.undo_stack = []
self.redo_stack = []
self._action_snapshot = None
self._action_changed = False
self.dragging = False
self.drag_start = None
self.drag_current = None
self.last_painted_cell = None
self.hover_cell = None
self.toast_window = None
self._toast_after_id = None
self.render_cell_size = float(DEFAULT_CELL_SIZE)
self.canvas_origin_x = 0.0
self.canvas_origin_y = 0.0
self.map_render_width = maze.LEVEL_WIDTH * self.render_cell_size
self.map_render_height = maze.LEVEL_HEIGHT * self.render_cell_size
self._build_ui()
self._bind_shortcuts()
if file_path:
opened = self.open_archive(file_path, prompt_on_dirty=False)
if not opened:
self.reset_to_new_archive(prompt_on_dirty=False)
elif maze.DEFAULT_DAT_PATH.exists():
if not self.open_archive(maze.DEFAULT_DAT_PATH, prompt_on_dirty=False):
self.reset_to_new_archive(prompt_on_dirty=False)
else:
self.reset_to_new_archive(prompt_on_dirty=False)
self.go_to_level(level_index)
self.after_idle(self.refresh_canvas)
self.set_status("Pronto. Tasto sinistro per dipingere, destro per campionare il tile.")
def _build_ui(self):
self.option_add("*tearOff", False)
self.columnconfigure(0, weight=1)
self.rowconfigure(0, weight=1)
self._build_menu()
container = ttk.Frame(self, padding=10)
container.grid(row=0, column=0, sticky="nsew")
container.columnconfigure(0, weight=1)
container.columnconfigure(1, weight=0)
container.rowconfigure(0, weight=1)
canvas_panel = ttk.Frame(container)
canvas_panel.grid(row=0, column=0, sticky="nsew")
canvas_panel.columnconfigure(0, weight=1)
canvas_panel.rowconfigure(0, weight=1)
self.canvas = tk.Canvas(canvas_panel, background="#161616", highlightthickness=0)
self.canvas.grid(row=0, column=0, sticky="nsew")
y_scroll = ttk.Scrollbar(canvas_panel, orient="vertical", command=self.canvas.yview)
y_scroll.grid(row=0, column=1, sticky="ns")
x_scroll = ttk.Scrollbar(canvas_panel, orient="horizontal", command=self.canvas.xview)
x_scroll.grid(row=1, column=0, sticky="ew")
self.canvas.configure(xscrollcommand=x_scroll.set, yscrollcommand=y_scroll.set)
self.canvas.bind("<ButtonPress-1>", self.on_left_press)
self.canvas.bind("<B1-Motion>", self.on_left_drag)
self.canvas.bind("<ButtonRelease-1>", self.on_left_release)
self.canvas.bind("<Button-3>", self.on_right_click)
self.canvas.bind("<Motion>", self.on_mouse_move)
self.canvas.bind("<Leave>", self.on_canvas_leave)
self.canvas.bind("<Configure>", self.on_canvas_configure)
sidebar = ttk.Frame(container, padding=(10, 0, 0, 0))
sidebar.grid(row=0, column=1, sticky="ns")
file_frame = ttk.LabelFrame(sidebar, text="Archivio", padding=10)
file_frame.grid(row=0, column=0, sticky="ew")
ttk.Label(file_frame, textvariable=self.file_var, wraplength=280, justify="left").grid(
row=0, column=0, columnspan=2, sticky="w"
)
ttk.Button(file_frame, text="Nuovo DAT", command=self.reset_to_new_archive).grid(row=1, column=0, sticky="ew", pady=(8, 0))
ttk.Button(file_frame, text="Apri DAT", command=self.open_archive_dialog).grid(row=1, column=1, sticky="ew", padx=(8, 0), pady=(8, 0))
ttk.Button(file_frame, text="Salva", command=self.save_archive).grid(row=2, column=0, sticky="ew", pady=(8, 0))
ttk.Button(file_frame, text="Salva come", command=self.save_archive_as).grid(row=2, column=1, sticky="ew", padx=(8, 0), pady=(8, 0))
file_frame.columnconfigure(0, weight=1)
file_frame.columnconfigure(1, weight=1)
level_frame = ttk.LabelFrame(sidebar, text="Livelli", padding=10)
level_frame.grid(row=1, column=0, sticky="ew", pady=(10, 0))
ttk.Button(level_frame, text="Livello precedente", command=self.previous_level).grid(row=0, column=0, sticky="ew")
ttk.Button(level_frame, text="Livello successivo", command=self.next_level).grid(row=0, column=1, sticky="ew", padx=(8, 0))
ttk.Label(level_frame, textvariable=self.level_range_var).grid(row=1, column=0, columnspan=2, sticky="w", pady=(8, 0))
self.level_spinbox = ttk.Spinbox(
level_frame,
from_=1,
to=len(self.levels),
textvariable=self.level_number_var,
command=self.on_level_spinbox,
width=8,
)
self.level_spinbox.grid(row=2, column=0, sticky="w", pady=(4, 0))
self.level_spinbox.bind("<Return>", self.on_level_spinbox)
self.level_spinbox.bind("<FocusOut>", self.on_level_spinbox)
ttk.Button(level_frame, text="Duplica in...", command=self.duplicate_current_level).grid(row=2, column=1, sticky="ew", padx=(8, 0), pady=(4, 0))
ttk.Button(level_frame, text="Copia livello", command=self.copy_current_level).grid(row=3, column=0, sticky="ew", pady=(8, 0))
ttk.Button(level_frame, text="Incolla livello", command=self.paste_current_level).grid(row=3, column=1, sticky="ew", padx=(8, 0), pady=(8, 0))
level_frame.columnconfigure(0, weight=1)
level_frame.columnconfigure(1, weight=1)
tools_frame = ttk.LabelFrame(sidebar, text="Strumenti", padding=10)
tools_frame.grid(row=2, column=0, sticky="ew", pady=(10, 0))
for row_index, (tool_key, label) in enumerate(TOOL_NAMES.items()):
ttk.Radiobutton(
tools_frame,
text=label,
variable=self.selected_tool,
value=tool_key,
command=self.refresh_canvas,
).grid(row=row_index, column=0, sticky="w")
ttk.Checkbutton(tools_frame, text="Mostra griglia", variable=self.show_grid, command=self.refresh_canvas).grid(
row=len(TOOL_NAMES), column=0, sticky="w", pady=(8, 0)
)
ttk.Checkbutton(
tools_frame,
text="Adatta al viewport",
variable=self.fit_to_viewport,
command=self.on_fit_to_viewport_toggle,
).grid(row=len(TOOL_NAMES) + 1, column=0, sticky="w", pady=(4, 0))
ttk.Button(tools_frame, text="Zoom +", command=self.zoom_in).grid(row=len(TOOL_NAMES) + 2, column=0, sticky="ew", pady=(8, 0))
ttk.Button(tools_frame, text="Zoom -", command=self.zoom_out).grid(row=len(TOOL_NAMES) + 3, column=0, sticky="ew", pady=(6, 0))
ttk.Button(tools_frame, text="Adatta viewport", command=self.reset_zoom).grid(row=len(TOOL_NAMES) + 4, column=0, sticky="ew", pady=(6, 0))
tiles_frame = ttk.LabelFrame(sidebar, text="Tile", padding=10)
tiles_frame.grid(row=3, column=0, sticky="ew", pady=(10, 0))
for row_index, tile_value in enumerate((maze.MAP_EMPTY, maze.MAP_WALL, maze.MAP_TUNNEL)):
swatch = tk.Label(tiles_frame, width=2, background=TILE_COLORS[tile_value], relief="ridge")
swatch.grid(row=row_index, column=0, sticky="w")
ttk.Radiobutton(
tiles_frame,
text=f"{TILE_NAMES[tile_value]} - {TILE_DESCRIPTIONS[tile_value]}",
variable=self.selected_tile,
value=tile_value,
).grid(row=row_index, column=1, sticky="w", padx=(8, 0))
actions_frame = ttk.LabelFrame(sidebar, text="Azioni", padding=10)
actions_frame.grid(row=4, column=0, sticky="ew", pady=(10, 0))
ttk.Button(actions_frame, text="Preset arena", command=self.apply_arena_preset).grid(row=0, column=0, sticky="ew")
ttk.Button(actions_frame, text="Riempi con tile selezionato", command=self.fill_current_level).grid(row=1, column=0, sticky="ew", pady=(8, 0))
ttk.Button(actions_frame, text="Importa livello da JSON", command=self.import_level_from_json).grid(row=2, column=0, sticky="ew", pady=(8, 0))
ttk.Button(actions_frame, text="Esporta livello in JSON", command=self.export_current_level_json).grid(row=3, column=0, sticky="ew", pady=(8, 0))
ttk.Button(actions_frame, text="Valida livello corrente", command=self.validate_current_level).grid(row=4, column=0, sticky="ew", pady=(8, 0))
ttk.Button(actions_frame, text="Valida intero archivio", command=self.validate_archive).grid(row=5, column=0, sticky="ew", pady=(8, 0))
stats_frame = ttk.LabelFrame(sidebar, text="Statistiche", padding=10)
stats_frame.grid(row=5, column=0, sticky="nsew", pady=(10, 0))
ttk.Label(stats_frame, textvariable=self.stats_var, justify="left", wraplength=280).grid(row=0, column=0, sticky="nw")
sidebar.rowconfigure(5, weight=1)
status = ttk.Label(self, textvariable=self.status_var, anchor="w", relief="sunken", padding=(10, 6))
status.grid(row=1, column=0, sticky="ew")
def _build_menu(self):
menu_bar = tk.Menu(self)
file_menu = tk.Menu(menu_bar)
file_menu.add_command(label="Nuovo DAT", command=self.reset_to_new_archive, accelerator="Ctrl+N")
file_menu.add_command(label="Apri DAT...", command=self.open_archive_dialog, accelerator="Ctrl+O")
file_menu.add_separator()
file_menu.add_command(label="Salva", command=self.save_archive, accelerator="Ctrl+S")
file_menu.add_command(label="Salva come...", command=self.save_archive_as, accelerator="Ctrl+Shift+S")
file_menu.add_separator()
file_menu.add_command(label="Importa livello da JSON...", command=self.import_level_from_json)
file_menu.add_command(label="Esporta livello corrente in JSON...", command=self.export_current_level_json)
file_menu.add_separator()
file_menu.add_command(label="Esci", command=self.on_exit)
menu_bar.add_cascade(label="File", menu=file_menu)
edit_menu = tk.Menu(menu_bar)
edit_menu.add_command(label="Undo", command=self.undo, accelerator="Ctrl+Z")
edit_menu.add_command(label="Redo", command=self.redo, accelerator="Ctrl+Y")
edit_menu.add_separator()
edit_menu.add_command(label="Copia livello", command=self.copy_current_level, accelerator="Ctrl+C")
edit_menu.add_command(label="Incolla livello", command=self.paste_current_level, accelerator="Ctrl+V")
menu_bar.add_cascade(label="Modifica", menu=edit_menu)
level_menu = tk.Menu(menu_bar)
level_menu.add_command(label="Livello precedente", command=self.previous_level, accelerator="PageUp")
level_menu.add_command(label="Livello successivo", command=self.next_level, accelerator="PageDown")
level_menu.add_separator()
level_menu.add_command(label="Duplica in...", command=self.duplicate_current_level)
level_menu.add_command(label="Preset arena", command=self.apply_arena_preset)
level_menu.add_command(label="Riempi con tile selezionato", command=self.fill_current_level)
level_menu.add_separator()
level_menu.add_command(label="Valida livello corrente", command=self.validate_current_level)
level_menu.add_command(label="Valida archivio", command=self.validate_archive)
menu_bar.add_cascade(label="Livello", menu=level_menu)
view_menu = tk.Menu(menu_bar)
view_menu.add_command(label="Zoom +", command=self.zoom_in, accelerator="Ctrl++")
view_menu.add_command(label="Zoom -", command=self.zoom_out, accelerator="Ctrl+-")
view_menu.add_command(label="Adatta viewport", command=self.reset_zoom, accelerator="Ctrl+0")
view_menu.add_checkbutton(label="Adatta al viewport", variable=self.fit_to_viewport, command=self.on_fit_to_viewport_toggle)
view_menu.add_checkbutton(label="Mostra griglia", variable=self.show_grid, command=self.refresh_canvas)
menu_bar.add_cascade(label="Vista", menu=view_menu)
self.config(menu=menu_bar)
def _bind_shortcuts(self):
self.bind_all("<Control-n>", lambda event: self.reset_to_new_archive())
self.bind_all("<Control-o>", lambda event: self.open_archive_dialog())
self.bind_all("<Control-s>", lambda event: self.save_archive())
self.bind_all("<Control-S>", lambda event: self.save_archive_as())
self.bind_all("<Control-z>", lambda event: self.undo())
self.bind_all("<Control-y>", lambda event: self.redo())
self.bind_all("<Control-c>", lambda event: self.copy_current_level())
self.bind_all("<Control-v>", lambda event: self.paste_current_level())
self.bind_all("<Page_Up>", lambda event: self.previous_level())
self.bind_all("<Page_Down>", lambda event: self.next_level())
self.bind_all("<Control-plus>", lambda event: self.zoom_in())
self.bind_all("<Control-KP_Add>", lambda event: self.zoom_in())
self.bind_all("<Control-minus>", lambda event: self.zoom_out())
self.bind_all("<Control-KP_Subtract>", lambda event: self.zoom_out())
self.bind_all("<Control-0>", lambda event: self.reset_zoom())
self.bind_all("1", lambda event: self.selected_tile.set(maze.MAP_EMPTY))
self.bind_all("2", lambda event: self.selected_tile.set(maze.MAP_WALL))
self.bind_all("3", lambda event: self.selected_tile.set(maze.MAP_TUNNEL))
self.bind_all("b", lambda event: self.selected_tool.set("brush"))
self.bind_all("f", lambda event: self.selected_tool.set("fill"))
self.bind_all("r", lambda event: self.selected_tool.set("rectangle"))
def current_level(self):
return self.levels[self.current_level_index]
def snapshot_state(self):
return {
"levels": copy.deepcopy(self.levels),
"current_level_index": self.current_level_index,
}
def restore_state(self, snapshot):
self.levels = copy.deepcopy(snapshot["levels"])
self.current_level_index = snapshot["current_level_index"]
self.refresh_view()
def record_undo_snapshot(self, snapshot):
self.undo_stack.append(snapshot)
if len(self.undo_stack) > MAX_HISTORY:
self.undo_stack.pop(0)
self.redo_stack.clear()
def begin_action(self):
self._action_snapshot = self.snapshot_state()
self._action_changed = False
def commit_action(self, success_message=None):
if self._action_changed and self._action_snapshot is not None:
self.record_undo_snapshot(self._action_snapshot)
self.mark_dirty(True)
self.refresh_view()
if success_message:
self.set_status(success_message)
self._action_snapshot = None
self._action_changed = False
def apply_edit(self, mutator, success_message=None):
snapshot = self.snapshot_state()
if not mutator():
return False
self.record_undo_snapshot(snapshot)
self.mark_dirty(True)
self.refresh_view()
if success_message:
self.set_status(success_message)
return True
def mark_dirty(self, dirty):
self.dirty = dirty
self.update_window_title()
self.refresh_metadata()
def update_window_title(self):
archive_name = self.current_path.name if self.current_path else "nuovo-archivio.dat"
dirty_marker = " *" if self.dirty else ""
self.title(f"{APP_TITLE} - {archive_name}{dirty_marker}")
def refresh_view(self):
self.level_range_var.set(f"Vai a livello (1-{len(self.levels)})")
self.level_spinbox.configure(to=len(self.levels))
self.level_number_var.set(self.current_level_index + 1)
self.refresh_metadata()
self.refresh_canvas()
def refresh_metadata(self):
if self.current_path is None:
file_text = "Nuovo archivio DAT non salvato"
else:
file_text = str(self.current_path)
if self.dirty:
file_text = f"{file_text} *"
self.file_var.set(file_text)
stats = compute_level_stats(self.current_level())
lines = [
f"Livello: {self.current_level_index + 1}/{len(self.levels)}",
f"Dimensioni: {stats['width']}x{stats['height']}",
f"EMPTY: {stats['empty_count']}",
f"WALL: {stats['wall_count']}",
f"TUNNEL: {stats['tunnel_count']}",
f"Celle attraversabili: {stats['traversable_count']}",
f"Spawn ratti validi: {stats['spawnable_count']}",
f"Componenti attraversabili: {stats['component_count']}",
f"Componente piu grande: {stats['largest_component']}",
]
if stats["warnings"]:
lines.append("")
lines.append("Avvisi:")
for warning in stats["warnings"]:
lines.append(f"- {warning}")
else:
lines.append("")
lines.append("Validazione: nessun problema rilevato")
self.stats_var.set("\n".join(lines))
self.update_window_title()
def refresh_canvas(self):
self.canvas.delete("all")
tiles = self.current_level()
layout = compute_canvas_layout(
len(tiles[0]),
len(tiles),
self.canvas.winfo_width(),
self.canvas.winfo_height(),
self.cell_size,
self.fit_to_viewport.get(),
)
self.render_cell_size = layout["cell_size"]
self.canvas_origin_x = layout["origin_x"]
self.canvas_origin_y = layout["origin_y"]
self.map_render_width = layout["map_width"]
self.map_render_height = layout["map_height"]
for y, row in enumerate(tiles):
for x, cell in enumerate(row):
x1 = self.canvas_origin_x + x * self.render_cell_size
y1 = self.canvas_origin_y + y * self.render_cell_size
x2 = x1 + self.render_cell_size
y2 = y1 + self.render_cell_size
outline = GRID_COLOR if self.show_grid.get() else TILE_COLORS[cell]
self.canvas.create_rectangle(x1, y1, x2, y2, fill=TILE_COLORS[cell], outline=outline)
if self.hover_cell is not None:
hover_x, hover_y = self.hover_cell
x1 = self.canvas_origin_x + hover_x * self.render_cell_size
y1 = self.canvas_origin_y + hover_y * self.render_cell_size
x2 = x1 + self.render_cell_size
y2 = y1 + self.render_cell_size
self.canvas.create_rectangle(x1, y1, x2, y2, outline=HOVER_COLOR, width=2)
if self.dragging and self.selected_tool.get() == "rectangle" and self.drag_start and self.drag_current:
start_x, start_y = self.drag_start
end_x, end_y = self.drag_current
x1 = self.canvas_origin_x + min(start_x, end_x) * self.render_cell_size
y1 = self.canvas_origin_y + min(start_y, end_y) * self.render_cell_size
x2 = self.canvas_origin_x + (max(start_x, end_x) + 1) * self.render_cell_size
y2 = self.canvas_origin_y + (max(start_y, end_y) + 1) * self.render_cell_size
self.canvas.create_rectangle(x1, y1, x2, y2, outline="#ffffff", width=2, dash=(6, 4))
self.canvas.configure(scrollregion=layout["scrollregion"])
def set_status(self, message):
self.status_var.set(message)
def clear_feedback_toast(self):
if self._toast_after_id is not None:
try:
self.after_cancel(self._toast_after_id)
except ValueError:
pass
self._toast_after_id = None
if self.toast_window is not None:
try:
if self.toast_window.winfo_exists():
self.toast_window.destroy()
except tk.TclError:
pass
self.toast_window = None
def show_feedback(self, message, duration_ms=1800):
self.clear_feedback_toast()
self.set_status(message)
toast = tk.Toplevel(self)
toast.overrideredirect(True)
toast.transient(self)
try:
toast.attributes("-topmost", True)
except tk.TclError:
pass
frame = tk.Frame(toast, background="#1f6f43", borderwidth=1, relief="solid")
frame.pack()
label = tk.Label(
frame,
text=message,
background="#1f6f43",
foreground="#ffffff",
padx=14,
pady=8,
)
label.pack()
self.update_idletasks()
toast.update_idletasks()
x = self.winfo_rootx() + self.winfo_width() - toast.winfo_reqwidth() - 24
y = self.winfo_rooty() + self.winfo_height() - toast.winfo_reqheight() - 48
toast.geometry(f"+{max(x, 0)}+{max(y, 0)}")
self.toast_window = toast
self._toast_after_id = self.after(duration_ms, self.clear_feedback_toast)
def maybe_save_changes(self):
if not self.dirty:
return True
answer = messagebox.askyesnocancel(
"Modifiche non salvate",
"L'archivio e stato modificato. Vuoi salvarlo prima di continuare?",
parent=self,
)
if answer is None:
return False
if answer:
return self.save_archive()
return True
def reset_to_new_archive(self, prompt_on_dirty=True):
if prompt_on_dirty and not self.maybe_save_changes():
return False
self.levels = build_default_archive()
self.current_path = None
self.current_level_index = 0
self.clipboard_level = None
self.undo_stack.clear()
self.redo_stack.clear()
self.dragging = False
self.drag_start = None
self.drag_current = None
self.last_painted_cell = None
self.hover_cell = None
self.mark_dirty(False)
self.refresh_view()
self.set_status(
f"Nuovo archivio DAT creato con {maze.DEFAULT_LEVELS_PER_DAT_FILE} livelli di default."
)
return True
def open_archive_dialog(self):
target = filedialog.askopenfilename(
parent=self,
title="Apri archivio level.dat",
initialdir=str(PROJECT_ROOT),
filetypes=(("DAT archive", "*.dat"), ("All files", "*.*")),
)
if target:
self.open_archive(target)
def open_archive(self, path_like, prompt_on_dirty=True):
if prompt_on_dirty and not self.maybe_save_changes():
return False
path = Path(path_like).expanduser()
try:
levels = maze.load_dat_levels(path)
except Exception as exc:
messagebox.showerror("Errore apertura", f"Impossibile leggere {path}:\n{exc}", parent=self)
return False
self.levels = levels
self.current_path = path
self.current_level_index = 0
self.undo_stack.clear()
self.redo_stack.clear()
self.dragging = False
self.drag_start = None
self.drag_current = None
self.last_painted_cell = None
self.hover_cell = None
self.mark_dirty(False)
self.refresh_view()
self.set_status(f"Archivio caricato: {path}")
return True
def save_archive(self):
if self.current_path is None:
return self.save_archive_as()
try:
maze.save_dat_levels(self.current_path, self.levels)
except Exception as exc:
messagebox.showerror("Errore salvataggio", f"Impossibile salvare {self.current_path}:\n{exc}", parent=self)
return False
self.mark_dirty(False)
self.set_status(f"Archivio salvato: {self.current_path}")
self.show_feedback(f"Salvato: {self.current_path.name}")
return True
def save_archive_as(self):
target = filedialog.asksaveasfilename(
parent=self,
title="Salva archivio DAT",
initialdir=str(PROJECT_ROOT),
defaultextension=".dat",
filetypes=(("DAT archive", "*.dat"), ("All files", "*.*")),
)
if not target:
return False
self.current_path = Path(target).expanduser()
return self.save_archive()
def import_level_from_json(self):
source = filedialog.askopenfilename(
parent=self,
title="Importa livello da JSON",
initialdir=str(PROJECT_ROOT),
filetypes=(("JSON level", "*.json"), ("All files", "*.*")),
)
if not source:
return
try:
imported_level = maze.load_json_level(source)
except Exception as exc:
messagebox.showerror("Errore import", f"Impossibile leggere il livello JSON:\n{exc}", parent=self)
return
source_height = len(imported_level)
source_width = len(imported_level[0])
if source_width != maze.LEVEL_WIDTH or source_height != maze.LEVEL_HEIGHT:
answer = messagebox.askyesno(
"Ridimensiona livello",
(
f"Il livello importato misura {source_width}x{source_height}.\n"
f"Vuoi centrarlo in una griglia {maze.LEVEL_WIDTH}x{maze.LEVEL_HEIGHT} "
"con riempimento di default e crop dell'eccesso?"
),
parent=self,
)
if not answer:
return
imported_level = fit_level_to_dat_size(imported_level)
def mutator():
self.levels[self.current_level_index] = deep_copy_level(imported_level)
return True
self.apply_edit(mutator, success_message=f"Livello importato da {source}")
def export_current_level_json(self):
target = filedialog.asksaveasfilename(
parent=self,
title="Esporta livello corrente in JSON",
initialdir=str(PROJECT_ROOT),
defaultextension=".json",
filetypes=(("JSON level", "*.json"), ("All files", "*.*")),
)
if not target:
return False
try:
maze.save_json_level(target, self.current_level())
except Exception as exc:
messagebox.showerror("Errore export", f"Impossibile esportare il livello:\n{exc}", parent=self)
return False
self.set_status(f"Livello {self.current_level_index + 1} esportato in {target}")
return True
def duplicate_current_level(self):
target = simpledialog.askinteger(
"Duplica livello",
f"Copia il livello corrente in quale slot? (1-{len(self.levels)})",
parent=self,
minvalue=1,
maxvalue=len(self.levels),
initialvalue=self.current_level_index + 1,
)
if target is None:
return
target_index = target - 1
if target_index == self.current_level_index:
self.set_status("Il livello sorgente e quello di destinazione coincidono.")
return
current_copy = deep_copy_level(self.current_level())
def mutator():
self.levels[target_index] = current_copy
return True
self.apply_edit(mutator, success_message=f"Livello duplicato nello slot {target}")
def copy_current_level(self):
self.clipboard_level = deep_copy_level(self.current_level())
self.set_status(f"Livello {self.current_level_index + 1} copiato negli appunti interni.")
def paste_current_level(self):
if self.clipboard_level is None:
self.set_status("Nessun livello copiato negli appunti interni.")
return False
copied_level = deep_copy_level(self.clipboard_level)
def mutator():
self.levels[self.current_level_index] = copied_level
return True
return self.apply_edit(mutator, success_message=f"Livello incollato nello slot {self.current_level_index + 1}")
def apply_arena_preset(self):
preset = maze.create_level()
def mutator():
self.levels[self.current_level_index] = preset
return True
self.apply_edit(mutator, success_message=f"Preset arena applicato al livello {self.current_level_index + 1}")
def fill_current_level(self):
tile_value = self.selected_tile.get()
def mutator():
changed = False
level = self.current_level()
for y in range(len(level)):
for x in range(len(level[y])):
if level[y][x] != tile_value:
level[y][x] = tile_value
changed = True
return changed
self.apply_edit(mutator, success_message=f"Livello riempito con {TILE_NAMES[tile_value]}")
def go_to_level(self, index):
normalized_index = maze.normalize_level_index(index, len(self.levels))
self.current_level_index = normalized_index
self.level_number_var.set(normalized_index + 1)
self.refresh_view()
self.set_status(f"Livello corrente: {normalized_index + 1}")
def previous_level(self):
self.go_to_level(self.current_level_index - 1)
def next_level(self):
self.go_to_level(self.current_level_index + 1)
def on_level_spinbox(self, event=None):
try:
target_level = int(self.level_spinbox.get())
except (TypeError, ValueError):
self.level_number_var.set(self.current_level_index + 1)
return
target_level = max(1, min(len(self.levels), target_level))
self.go_to_level(target_level - 1)
def on_fit_to_viewport_toggle(self):
self.refresh_canvas()
if self.fit_to_viewport.get():
self.set_status("Vista adattata al viewport")
else:
self.set_status(f"Adattamento disattivato. Zoom manuale: {int(round(self.cell_size))} px per cella")
def on_canvas_configure(self, event):
if event.width <= 1 or event.height <= 1:
return
if self.fit_to_viewport.get():
self.refresh_canvas()
def zoom_in(self):
if self.fit_to_viewport.get():
self.cell_size = max(self.cell_size, int(round(self.render_cell_size)))
self.fit_to_viewport.set(False)
self.cell_size = min(MAX_CELL_SIZE, self.cell_size + 2)
self.refresh_canvas()
self.set_status(f"Zoom: {self.cell_size}px per cella")
def zoom_out(self):
if self.fit_to_viewport.get():
self.cell_size = max(self.cell_size, int(round(self.render_cell_size)))
self.fit_to_viewport.set(False)
self.cell_size = max(MIN_CELL_SIZE, self.cell_size - 2)
self.refresh_canvas()
self.set_status(f"Zoom: {self.cell_size}px per cella")
def reset_zoom(self):
self.cell_size = DEFAULT_CELL_SIZE
self.fit_to_viewport.set(True)
self.refresh_canvas()
self.set_status("Vista adattata al viewport")
def validate_current_level(self):
stats = compute_level_stats(self.current_level())
lines = [
f"Livello {self.current_level_index + 1}",
"",
f"EMPTY: {stats['empty_count']}",
f"WALL: {stats['wall_count']}",
f"TUNNEL: {stats['tunnel_count']}",
f"Spawn validi: {stats['spawnable_count']}",
f"Componenti attraversabili: {stats['component_count']}",
f"Componente piu grande: {stats['largest_component']}",
]
if stats["warnings"]:
lines.append("")
lines.append("Problemi rilevati:")
for warning in stats["warnings"]:
lines.append(f"- {warning}")
else:
lines.append("")
lines.append("Nessun problema rilevato.")
messagebox.showinfo("Validazione livello", "\n".join(lines), parent=self)
def validate_archive(self):
issues = []
for index, level in enumerate(self.levels):
stats = compute_level_stats(level)
if stats["warnings"]:
joined = "; ".join(stats["warnings"])
issues.append(f"Livello {index + 1}: {joined}")
if issues:
message = "Problemi rilevati nell'archivio:\n\n" + "\n".join(issues)
else:
message = "Tutti i 32 livelli superano i controlli base dell'editor."
messagebox.showinfo("Validazione archivio", message, parent=self)
def undo(self):
if not self.undo_stack:
self.set_status("Nessuna operazione da annullare.")
return False
snapshot = self.undo_stack.pop()
self.redo_stack.append(self.snapshot_state())
self.restore_state(snapshot)
self.mark_dirty(True)
self.set_status("Undo eseguito.")
return True
def redo(self):
if not self.redo_stack:
self.set_status("Nessuna operazione da ripristinare.")
return False
snapshot = self.redo_stack.pop()
self.undo_stack.append(self.snapshot_state())
self.restore_state(snapshot)
self.mark_dirty(True)
self.set_status("Redo eseguito.")
return True
def event_to_cell(self, event):
canvas_x = self.canvas.canvasx(event.x) - self.canvas_origin_x
canvas_y = self.canvas.canvasy(event.y) - self.canvas_origin_y
if canvas_x < 0 or canvas_y < 0:
return None
if canvas_x >= self.map_render_width or canvas_y >= self.map_render_height:
return None
x = int(canvas_x // self.render_cell_size)
y = int(canvas_y // self.render_cell_size)
if not (0 <= x < maze.LEVEL_WIDTH and 0 <= y < maze.LEVEL_HEIGHT):
return None
return x, y
def set_cell(self, x, y, tile_value):
level = self.current_level()
if level[y][x] == tile_value:
return False
level[y][x] = tile_value
self._action_changed = True
return True
def flood_fill(self, start_x, start_y, tile_value):
level = self.current_level()
original = level[start_y][start_x]
if original == tile_value:
return False
queue = deque([(start_x, start_y)])
visited = set([(start_x, start_y)])
changed = False
while queue:
x, y = queue.popleft()
if level[y][x] != original:
continue
level[y][x] = tile_value
changed = True
for dx, dy in ((0, -1), (1, 0), (0, 1), (-1, 0)):
next_x = x + dx
next_y = y + dy
if not (0 <= next_x < maze.LEVEL_WIDTH and 0 <= next_y < maze.LEVEL_HEIGHT):
continue
if (next_x, next_y) in visited:
continue
visited.add((next_x, next_y))
if level[next_y][next_x] == original:
queue.append((next_x, next_y))
if changed:
self._action_changed = True
return changed
def fill_rectangle(self, start_cell, end_cell, tile_value):
start_x, start_y = start_cell
end_x, end_y = end_cell
changed = False
for y in range(min(start_y, end_y), max(start_y, end_y) + 1):
for x in range(min(start_x, end_x), max(start_x, end_x) + 1):
if self.set_cell(x, y, tile_value):
changed = True
return changed
def paint_brush_cell(self, cell):
if cell is None or cell == self.last_painted_cell:
return
cell_x, cell_y = cell
tile_value = self.selected_tile.get()
if self.set_cell(cell_x, cell_y, tile_value):
self.refresh_canvas()
self.last_painted_cell = cell
def on_left_press(self, event):
cell = self.event_to_cell(event)
if cell is None:
return
tool = self.selected_tool.get()
self.hover_cell = cell
if tool == "brush":
self.begin_action()
self.dragging = True
self.last_painted_cell = None
self.paint_brush_cell(cell)
elif tool == "fill":
self.begin_action()
self.flood_fill(cell[0], cell[1], self.selected_tile.get())
self.commit_action(success_message=f"Riempimento applicato al livello {self.current_level_index + 1}")
elif tool == "rectangle":
self.begin_action()
self.dragging = True
self.drag_start = cell
self.drag_current = cell
self.refresh_canvas()
def on_left_drag(self, event):
if not self.dragging:
return
cell = self.event_to_cell(event)
if cell is None:
return
self.hover_cell = cell
tool = self.selected_tool.get()
if tool == "brush":
self.paint_brush_cell(cell)
elif tool == "rectangle":
self.drag_current = cell
self.refresh_canvas()
def on_left_release(self, event):
if not self.dragging:
return
cell = self.event_to_cell(event) or self.drag_current or self.drag_start
tool = self.selected_tool.get()
if tool == "rectangle" and self.drag_start and cell is not None:
self.drag_current = cell
self.fill_rectangle(self.drag_start, self.drag_current, self.selected_tile.get())
self.dragging = False
self.drag_start = None
self.drag_current = None
self.last_painted_cell = None
self.commit_action(success_message=f"Modifica applicata al livello {self.current_level_index + 1}")
def on_right_click(self, event):
cell = self.event_to_cell(event)
if cell is None:
return
cell_x, cell_y = cell
tile_value = self.current_level()[cell_y][cell_x]
self.selected_tile.set(tile_value)
self.hover_cell = cell
self.refresh_canvas()
self.set_status(f"Campionato tile {TILE_NAMES[tile_value]} in ({cell_x}, {cell_y})")
def on_mouse_move(self, event):
cell = self.event_to_cell(event)
if cell == self.hover_cell:
return
self.hover_cell = cell
self.refresh_canvas()
if cell is None:
self.set_status("Pronto. Tasto sinistro per dipingere, destro per campionare il tile.")
return
cell_x, cell_y = cell
tile_value = self.current_level()[cell_y][cell_x]
self.set_status(
f"({cell_x}, {cell_y}) - {TILE_NAMES[tile_value]} - strumento {TOOL_NAMES[self.selected_tool.get()]}"
)
def on_canvas_leave(self, event):
self.hover_cell = None
self.refresh_canvas()
self.set_status("Pronto. Tasto sinistro per dipingere, destro per campionare il tile.")
def on_exit(self):
if not self.maybe_save_changes():
return
self.clear_feedback_toast()
self.destroy()
def main():
args = parse_args()
try:
ensure_tkinter_available()
except RuntimeError as exc:
print(f"Error: {exc}", file=sys.stderr)
raise SystemExit(1) from exc
editor = LevelEditor(file_path=args.file_path, level_index=args.level)
editor.mainloop()
if __name__ == "__main__":
main()