1185 lines
47 KiB
Python
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() |