#!/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("", self.on_left_press) self.canvas.bind("", self.on_left_drag) self.canvas.bind("", self.on_left_release) self.canvas.bind("", self.on_right_click) self.canvas.bind("", self.on_mouse_move) self.canvas.bind("", self.on_canvas_leave) self.canvas.bind("", 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("", self.on_level_spinbox) self.level_spinbox.bind("", 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("", lambda event: self.reset_to_new_archive()) self.bind_all("", lambda event: self.open_archive_dialog()) self.bind_all("", lambda event: self.save_archive()) self.bind_all("", lambda event: self.save_archive_as()) self.bind_all("", lambda event: self.undo()) self.bind_all("", lambda event: self.redo()) self.bind_all("", lambda event: self.copy_current_level()) self.bind_all("", lambda event: self.paste_current_level()) self.bind_all("", lambda event: self.previous_level()) self.bind_all("", lambda event: self.next_level()) self.bind_all("", lambda event: self.zoom_in()) self.bind_all("", lambda event: self.zoom_in()) self.bind_all("", lambda event: self.zoom_out()) self.bind_all("", lambda event: self.zoom_out()) self.bind_all("", 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()