diff --git a/README.md b/README.md index 78a88c7..0e0668f 100644 --- a/README.md +++ b/README.md @@ -241,6 +241,24 @@ Units interact through a centralized collision and event system: - `numpy` 2.3.4 for vectorized collision detection - `sdl2` for graphics and window management +## Map Editor + +The project now includes a Tkinter editor for `level.dat` archives: + +- Launch with `python tools/level_editor.py` +- Open a specific archive with `python tools/level_editor.py --file assets/Rat/level.dat` +- Start on a specific level with `python tools/level_editor.py --file assets/Rat/level.dat --level 7` +- The editor requires a Python installation with the standard `tkinter` module available at OS level + +Editor capabilities: + +- Edits the full 32-level DAT archive used by the game +- Creates new DAT archives with 32 default levels +- Paints `EMPTY`, `WALL`, and `TUNNEL` tiles with brush, fill, and rectangle tools +- Supports undo/redo, level copy/paste, and level duplication between slots +- Imports a single level from JSON and exports the current level back to JSON +- Validates common gameplay issues such as missing spawn cells, open borders, and disconnected traversable areas + ## Level Sources - Preferred source: `assets/Rat/level.dat` diff --git a/assets/Rat/BMP_1_EXPLOSION_DOWN.png b/assets/Rat/BMP_1_EXPLOSION_DOWN.png index badb904..6820032 100644 Binary files a/assets/Rat/BMP_1_EXPLOSION_DOWN.png and b/assets/Rat/BMP_1_EXPLOSION_DOWN.png differ diff --git a/assets/Rat/BMP_1_EXPLOSION_LEFT.png b/assets/Rat/BMP_1_EXPLOSION_LEFT.png index 09c67dd..7f8c116 100644 Binary files a/assets/Rat/BMP_1_EXPLOSION_LEFT.png and b/assets/Rat/BMP_1_EXPLOSION_LEFT.png differ diff --git a/assets/Rat/BMP_1_EXPLOSION_RIGHT.png b/assets/Rat/BMP_1_EXPLOSION_RIGHT.png index 34447c6..764fb13 100644 Binary files a/assets/Rat/BMP_1_EXPLOSION_RIGHT.png and b/assets/Rat/BMP_1_EXPLOSION_RIGHT.png differ diff --git a/assets/Rat/BMP_1_EXPLOSION_UP.png b/assets/Rat/BMP_1_EXPLOSION_UP.png index 0309d25..558915e 100644 Binary files a/assets/Rat/BMP_1_EXPLOSION_UP.png and b/assets/Rat/BMP_1_EXPLOSION_UP.png differ diff --git a/assets/Rat/end.png b/assets/Rat/end.png new file mode 100644 index 0000000..c8015dc Binary files /dev/null and b/assets/Rat/end.png differ diff --git a/assets/Rat/level.dat b/assets/Rat/level.dat index 1da4f89..39c10d5 100644 Binary files a/assets/Rat/level.dat and b/assets/Rat/level.dat differ diff --git a/assets/music/Pursuit_On_Thin_Ice.mp3 b/assets/music/Pursuit_On_Thin_Ice.mp3 new file mode 100644 index 0000000..19f2270 Binary files /dev/null and b/assets/music/Pursuit_On_Thin_Ice.mp3 differ diff --git a/assets/music/Serpent_s_Coil.mp3 b/assets/music/Serpent_s_Coil.mp3 new file mode 100644 index 0000000..4fae981 Binary files /dev/null and b/assets/music/Serpent_s_Coil.mp3 differ diff --git a/assets/music/Steps_Through_the_Hedge.mp3 b/assets/music/Steps_Through_the_Hedge.mp3 new file mode 100644 index 0000000..7a82ee8 Binary files /dev/null and b/assets/music/Steps_Through_the_Hedge.mp3 differ diff --git a/assets/music/Sunset_At_Pixel_Gardens.mp3 b/assets/music/Sunset_At_Pixel_Gardens.mp3 new file mode 100644 index 0000000..7bb3526 Binary files /dev/null and b/assets/music/Sunset_At_Pixel_Gardens.mp3 differ diff --git a/assets/music/Thirty_Below.mp3 b/assets/music/Thirty_Below.mp3 new file mode 100644 index 0000000..c24f5d4 Binary files /dev/null and b/assets/music/Thirty_Below.mp3 differ diff --git a/assets/music/Through_the_Gilded_Bazaar.mp3 b/assets/music/Through_the_Gilded_Bazaar.mp3 new file mode 100644 index 0000000..6dd87c5 Binary files /dev/null and b/assets/music/Through_the_Gilded_Bazaar.mp3 differ diff --git a/conf/level_music.json b/conf/level_music.json index b34ef2f..8e18132 100644 --- a/conf/level_music.json +++ b/conf/level_music.json @@ -4,14 +4,14 @@ "2": "Rat_Trap_Run.mp3", "3": "Scuttle_Through_the_Walls.mp3", "4": "The_Long_Way_Down.mp3", - "5": "Clockwork_Thicket.mp3", + "5": "Goblin_Garden_Sprint.mp3", "6": "Rat_Trap_Run.mp3", "7": "Scuttle_Through_the_Walls.mp3", "8": "The_Long_Way_Down.mp3", - "9": "Clockwork_Thicket.mp3", - "10": "Rat_Trap_Run.mp3", - "11": "Scuttle_Through_the_Walls.mp3", - "12": "The_Long_Way_Down.mp3", + "9": "Steps_Through_the_Hedge.mp3", + "10": "Noon_at_the_Gate.mp3", + "11": "Through_the_Gilded_Bazaar.mp3", + "12": "Serpent_s_Coil.mp3", "13": "Clockwork_Thicket.mp3", "14": "Rat_Trap_Run.mp3", "15": "Scuttle_Through_the_Walls.mp3", diff --git a/engine/controls.py b/engine/controls.py index 9d73e75..00fd615 100644 --- a/engine/controls.py +++ b/engine/controls.py @@ -134,6 +134,8 @@ def _detect_runtime_profile(render_engine): return "gamepad", device_name or "SDL GameController" if device_kind == "joystick": + if "muos-keys" in device_name: + return "rg40xx", device_name if "r36s" in device_name: return "r36s", device_name if "rg40xx" in device_name: diff --git a/engine/maze.py b/engine/maze.py index 8378e0c..2f336ac 100644 --- a/engine/maze.py +++ b/engine/maze.py @@ -2,17 +2,17 @@ import json from pathlib import Path -LEVELS_PER_DAT_FILE = 32 +DEFAULT_LEVELS_PER_DAT_FILE = 32 LEVEL_WIDTH = 32 LEVEL_HEIGHT = 32 LEVEL_SIZE = LEVEL_WIDTH * LEVEL_HEIGHT -EXPECTED_DAT_SIZE = LEVELS_PER_DAT_FILE * LEVEL_SIZE PROJECT_ROOT = Path(__file__).resolve().parent.parent DEFAULT_DAT_PATH = PROJECT_ROOT / "assets" / "Rat" / "level.dat" DEFAULT_JSON_PATH = PROJECT_ROOT / "maze.json" MAP_EMPTY = 0 MAP_WALL = 1 MAP_TUNNEL = 2 +VALID_TILE_VALUES = {MAP_EMPTY, MAP_WALL, MAP_TUNNEL} def get_default_map_source(): @@ -21,13 +21,165 @@ def get_default_map_source(): return DEFAULT_JSON_PATH +def _level_count_from_dat_size(raw_size, source_path): + if raw_size <= 0: + raise ValueError(f"Invalid DAT size for {source_path}: archive is empty") + if raw_size % LEVEL_SIZE != 0: + raise ValueError( + f"Invalid DAT size for {source_path}: expected a multiple of {LEVEL_SIZE} bytes, got {raw_size}" + ) + return raw_size // LEVEL_SIZE + + +def get_dat_level_count(source_path): + source_path = Path(source_path) + return _level_count_from_dat_size(source_path.stat().st_size, source_path) + + +def get_level_count(source_path): + source_path = Path(source_path) + if source_path.suffix.lower() == ".dat": + return get_dat_level_count(source_path) + return 1 + + +def normalize_level_index(level_index, level_count): + level_count = int(level_count) + if level_count <= 0: + raise ValueError(f"Level count must be positive, got {level_count}") + return int(level_index) % level_count + + +def normalize_tiles(tiles, width=None, height=None): + if not tiles: + raise ValueError("Map data cannot be empty") + + if height is None: + height = len(tiles) + if height != len(tiles): + raise ValueError(f"Invalid map height: expected {height}, got {len(tiles)}") + + if width is None: + width = len(tiles[0]) + if width <= 0: + raise ValueError("Map width must be greater than zero") + + normalized = [] + for row_index, row in enumerate(tiles): + if len(row) != width: + raise ValueError( + f"Invalid row width at row {row_index}: expected {width}, got {len(row)}" + ) + + normalized_row = [] + for column_index, cell in enumerate(row): + if isinstance(cell, bool): + normalized_cell = MAP_WALL if cell else MAP_TUNNEL + else: + try: + normalized_cell = int(cell) + except (TypeError, ValueError) as exc: + raise ValueError( + f"Invalid tile value at ({column_index}, {row_index}): {cell!r}" + ) from exc + + if normalized_cell not in VALID_TILE_VALUES: + raise ValueError( + f"Unsupported tile value at ({column_index}, {row_index}): {normalized_cell}" + ) + + normalized_row.append(normalized_cell) + + normalized.append(normalized_row) + + return normalized + + +def create_level(fill=MAP_EMPTY, width=LEVEL_WIDTH, height=LEVEL_HEIGHT, border_walls=True): + if fill not in VALID_TILE_VALUES: + raise ValueError(f"Unsupported fill tile: {fill}") + + tiles = [[fill for _ in range(width)] for _ in range(height)] + + if border_walls and width >= 2 and height >= 2: + for x in range(width): + tiles[0][x] = MAP_WALL + tiles[height - 1][x] = MAP_WALL + for y in range(height): + tiles[y][0] = MAP_WALL + tiles[y][width - 1] = MAP_WALL + + return tiles + + +def load_json_level(source_path): + source_path = Path(source_path) + with source_path.open("r", encoding="utf-8") as file: + matrix = json.load(file) + return normalize_tiles(matrix) + + +def save_json_level(destination_path, tiles, indent=2): + destination_path = Path(destination_path) + normalized_tiles = normalize_tiles(tiles) + with destination_path.open("w", encoding="utf-8") as handle: + json.dump(normalized_tiles, handle, indent=indent) + handle.write("\n") + + +def load_dat_levels(source_path): + source_path = Path(source_path) + raw_data = source_path.read_bytes() + level_count = _level_count_from_dat_size(len(raw_data), source_path) + + levels = [] + for level_index in range(level_count): + level_offset = level_index * LEVEL_SIZE + level_data = raw_data[level_offset:level_offset + LEVEL_SIZE] + + matrix = [] + for row in range(LEVEL_HEIGHT): + row_start = row * LEVEL_WIDTH + raw_row = level_data[row_start:row_start + LEVEL_WIDTH] + matrix.append(list(raw_row)) + levels.append(normalize_tiles(matrix, width=LEVEL_WIDTH, height=LEVEL_HEIGHT)) + + return levels + + +def load_dat_level(source_path, level_index=0): + levels = load_dat_levels(source_path) + return levels[normalize_level_index(level_index, len(levels))] + + +def save_dat_levels(destination_path, levels): + destination_path = Path(destination_path) + if not levels: + raise ValueError("DAT archive must contain at least one level") + + output = bytearray() + for level_index, level in enumerate(levels): + normalized_level = normalize_tiles(level, width=LEVEL_WIDTH, height=LEVEL_HEIGHT) + for row in normalized_level: + output.extend(row) + + expected_size = len(levels) * LEVEL_SIZE + if len(output) != expected_size: + raise ValueError( + f"Invalid DAT payload size after serialization: expected {expected_size}, got {len(output)}" + ) + + destination_path.write_bytes(bytes(output)) + + class Map: """Classe che rappresenta la mappa del labirinto.""" def __init__(self, maze_file=None, level_index=0): self.source_path = self._resolve_source_path(maze_file) - self.level_index = level_index - self.tiles = self._load_tiles(self.source_path, level_index) + self.level_count = get_level_count(self.source_path) + self.level_index = normalize_level_index(level_index, self.level_count) + self.tiles = self._load_tiles(self.source_path, self.level_index) self.matrix = [ [cell == MAP_WALL for cell in row] for row in self.tiles @@ -55,34 +207,14 @@ class Map: def _load_tiles(self, source_path, level_index): suffix = source_path.suffix.lower() if suffix == ".dat": - return self._load_dat_level(source_path, level_index) - return self._load_json_level(source_path) + return load_dat_level(source_path, level_index) + return load_json_level(source_path) def _load_json_level(self, source_path): - with source_path.open("r", encoding="utf-8") as file: - matrix = json.load(file) - return [ - [MAP_WALL if cell else MAP_TUNNEL for cell in row] - for row in matrix - ] + return load_json_level(source_path) def _load_dat_level(self, source_path, level_index): - raw_data = source_path.read_bytes() - if len(raw_data) != EXPECTED_DAT_SIZE: - raise ValueError( - f"Invalid DAT size for {source_path}: expected {EXPECTED_DAT_SIZE} bytes, got {len(raw_data)}" - ) - - normalized_level = level_index % LEVELS_PER_DAT_FILE - level_offset = normalized_level * LEVEL_SIZE - level_data = raw_data[level_offset:level_offset + LEVEL_SIZE] - - matrix = [] - for row in range(LEVEL_HEIGHT): - row_start = row * LEVEL_WIDTH - raw_row = level_data[row_start:row_start + LEVEL_WIDTH] - matrix.append(list(raw_row)) - return matrix + return load_dat_level(source_path, level_index) def in_bounds(self, x, y): return 0 <= x < self.width and 0 <= y < self.height diff --git a/engine/sdl2.py b/engine/sdl2.py index 408992a..fd6d006 100644 --- a/engine/sdl2.py +++ b/engine/sdl2.py @@ -356,6 +356,17 @@ class GameWindow: self.renderer.copy(sprite, srcrect=srcrect, dstrect=dstrect) return + if dest_size is not None: + dst_w, dst_h = (int(value) for value in dest_size) + dstrect = sdl2.SDL_Rect( + int(x + self.w_offset), + int(y + self.h_offset), + dst_w, + dst_h, + ) + self.renderer.copy(sprite, dstrect=dstrect) + return + sprite.position = (x + self.w_offset, y + self.h_offset) self.renderer.copy(sprite, dstrect=sprite.position) @@ -384,61 +395,197 @@ class GameWindow: def dialog(self, text, **kwargs): """Display a dialog box with text and optional extras""" - # Draw dialog background - self.draw_rectangle(50, 50, - self.target_size[0] - 100, self.target_size[1] - 100, - "win", filling=(255, 255, 255)) - - # Calculate layout positions to avoid overlaps - title_y = self.target_size[1] // 4 # Title at 1/4 of screen height - - # Draw main text (title) - self.draw_text(text, self.fonts[self.target_size[1]//20], - ("center", title_y), sdl2.ext.Color(0, 0, 0)) - - # Draw image if provided - position it below title - image_bottom_y = title_y + 60 # Default position if no image + if kwargs.get("style") == "run_complete": + self._draw_run_complete_dialog(text, **kwargs) + return + + panel_x = 50 + panel_y = 50 + panel_width = self.target_size[0] - 100 + panel_height = self.target_size[1] - 100 + panel_bottom = panel_y + panel_height + panel_padding_x = max(24, panel_width // 18) + panel_padding_y = max(20, panel_height // 18) + inner_left = panel_x + panel_padding_x + inner_right = panel_x + panel_width - panel_padding_x + inner_width = inner_right - inner_left + + title_font_size = max(18, min(42, self.target_size[1] // 20)) + subtitle_font_size = max(12, min(22, self.target_size[1] // 35)) + scores_title_font_size = max(16, min(30, self.target_size[1] // 25)) + scores_font_size = max(12, min(20, self.target_size[1] // 40)) + + def make_text_sprite(message, font_size, color=(0, 0, 0)): + return self.factory.from_text( + message, + color=sdl2.ext.Color(*color), + fontmanager=self.fonts[font_size], + ) + + def score_row(score, rank): + if isinstance(score, dict): + return f"{rank}. {score.get('user_id', 'Guest')}", f"{score.get('best_score', 0)} pts" + if len(score) >= 4: + return f"{rank}. {score[2]}", f"{score[1]} pts" + if len(score) >= 3: + return f"{rank}. {score[2]}", f"{score[1]} pts" + return f"{rank}. Guest", f"{score[1]} pts" + + self.draw_rectangle(panel_x, panel_y, panel_width, panel_height, "win", filling=(255, 255, 255)) + + title_sprite = make_text_sprite(text, title_font_size) + content_y = panel_y + panel_padding_y + title_x = self.target_size[0] // 2 - title_sprite.size[0] // 2 + self.renderer.copy(title_sprite, dstrect=sdl2.SDL_Rect(title_x, content_y, *title_sprite.size)) + content_y += title_sprite.size[1] + max(14, panel_height // 36) + if image := kwargs.get("image"): - image_size = self.get_image_size(image) - image_y = title_y + 50 - self.draw_image(self.target_size[0] // 2 - image_size[0] // 2 - self.w_offset, - image_y - self.h_offset, - image, "win") - image_bottom_y = image_y + image_size[1] + 20 - - # Draw subtitle if provided - handle multi-line text, position below image + image_width, image_height = self.get_image_size(image) + image_x = self.target_size[0] // 2 - image_width // 2 + self.draw_image(image_x - self.w_offset, content_y - self.h_offset, image, "win") + content_y += image_height + max(14, panel_height // 30) + if subtitle := kwargs.get("subtitle"): - subtitle_lines = subtitle.split('\n') - base_y = image_bottom_y + 20 - line_height = 25 # Fixed line height for consistent spacing - - for i, line in enumerate(subtitle_lines): - if line.strip(): # Only draw non-empty lines - self.draw_text(line.strip(), self.fonts[self.target_size[1]//35], - ("center", base_y + i * line_height), sdl2.ext.Color(0, 0, 0)) - image_bottom_y = base_y + len(subtitle_lines) * line_height - - # Draw scores if provided - position at bottom + subtitle_sprites = [ + make_text_sprite(line.strip(), subtitle_font_size) + for line in subtitle.split('\n') + if line.strip() + ] + subtitle_gap = max(6, panel_height // 70) + for sprite in subtitle_sprites: + subtitle_x = self.target_size[0] // 2 - sprite.size[0] // 2 + self.renderer.copy(sprite, dstrect=sdl2.SDL_Rect(subtitle_x, content_y, *sprite.size)) + content_y += sprite.size[1] + subtitle_gap + content_y += max(8, panel_height // 40) + if scores := kwargs.get("scores"): - scores_start_y = min(image_bottom_y + 25, self.target_size[1] - 160) - sprite = self.factory.from_text("High Scores:", color=sdl2.ext.Color(0, 0, 0), - fontmanager=self.fonts[self.target_size[1]//25]) - sprite.position = (self.target_size[0] // 2 - sprite.size[0] // 2, scores_start_y) - self.renderer.copy(sprite, dstrect=sprite.position) - - for i, score in enumerate(scores[:5]): - if isinstance(score, dict): - score_text = f"{score.get('user_id', 'Guest')}: {score.get('best_score', 0)} pts" - elif len(score) >= 4: # New format: date, score, name, device - score_text = f"{score[2]}: {score[1]} pts ({score[3]})" - elif len(score) >= 3: # Medium format: date, score, name - score_text = f"{score[2]}: {score[1]} pts" - else: # Old format: date, score - score_text = f"Guest: {score[1]} pts" - - self.draw_text(score_text, self.fonts[self.target_size[1]//45], - ("center", scores_start_y + 30 + 25 * (i + 1)), - sdl2.ext.Color(0, 0, 0)) + score_rows = [score_row(score, index) for index, score in enumerate(scores[:5], start=1)] + if score_rows: + scores_title_sprite = make_text_sprite("High Scores:", scores_title_font_size) + scores_title_x = self.target_size[0] // 2 - scores_title_sprite.size[0] // 2 + self.renderer.copy( + scores_title_sprite, + dstrect=sdl2.SDL_Rect(scores_title_x, content_y, *scores_title_sprite.size), + ) + content_y += scores_title_sprite.size[1] + max(10, panel_height // 42) + + name_sprites = [] + value_sprites = [] + for name_text, value_text in score_rows: + name_sprites.append(make_text_sprite(name_text, scores_font_size)) + value_sprites.append(make_text_sprite(value_text, scores_font_size)) + + max_name_width = max(sprite.size[0] for sprite in name_sprites) + max_value_width = max(sprite.size[0] for sprite in value_sprites) + row_height = max( + max(sprite.size[1] for sprite in name_sprites), + max(sprite.size[1] for sprite in value_sprites), + ) + row_gap = max(4, panel_height // 80) + table_width = min(inner_width, max_name_width + max_value_width + max(24, inner_width // 18)) + table_x = self.target_size[0] // 2 - table_width // 2 + value_column_x = table_x + table_width + + max_rows_fit = max(1, (panel_bottom - panel_padding_y - content_y + row_gap) // (row_height + row_gap)) + for index, (name_sprite, value_sprite) in enumerate(zip(name_sprites[:max_rows_fit], value_sprites[:max_rows_fit])): + row_y = content_y + index * (row_height + row_gap) + self.renderer.copy( + name_sprite, + dstrect=sdl2.SDL_Rect(table_x, row_y, *name_sprite.size), + ) + self.renderer.copy( + value_sprite, + dstrect=sdl2.SDL_Rect(value_column_x - value_sprite.size[0], row_y, *value_sprite.size), + ) + + def _draw_run_complete_dialog(self, text, **kwargs): + target_width, target_height = self.target_size + panel_x = max(18, target_width // 30) + panel_y = max(18, target_height // 30) + panel_width = target_width - panel_x * 2 + panel_height = target_height - panel_y * 2 + panel_center_x = panel_x + panel_width // 2 + panel_bottom = panel_y + panel_height + inner_padding_x = max(20, panel_width // 28) + inner_padding_y = max(18, panel_height // 28) + inner_left = panel_x + inner_padding_x + inner_right = panel_x + panel_width - inner_padding_x + + palette = { + "backdrop": (5, 5, 6), + "panel": (14, 15, 16), + "panel_inner": (28, 29, 31), + "border": (188, 151, 77), + "title": (244, 233, 204), + "score": (241, 196, 83), + "image_frame": (50, 42, 30), + } + + title_font_size = max(28, min(54, target_height // 12)) + score_font_size = max(20, min(34, target_height // 20)) + + def make_text_sprite(message, font_size, color): + return self.factory.from_text( + message, + color=sdl2.ext.Color(*color), + fontmanager=self.fonts[font_size], + ) + + def blit_centered(sprite, y): + x = panel_center_x - sprite.size[0] // 2 + self.renderer.copy(sprite, dstrect=sdl2.SDL_Rect(x, y, *sprite.size)) + return y + sprite.size[1] + + def draw_card(x, y, width, height, fill, border): + self.draw_rectangle(x, y, width, height, "run_complete", filling=fill) + self.draw_rectangle(x, y, width, height, "run_complete", outline=border) + + self.draw_rectangle(0, 0, target_width, target_height, "run_complete", filling=palette["backdrop"]) + draw_card(panel_x, panel_y, panel_width, panel_height, palette["panel"], palette["border"]) + draw_card(panel_x + 10, panel_y + 10, panel_width - 20, panel_height - 20, palette["panel_inner"], palette["border"]) + + title_y = panel_y + inner_padding_y + title_sprite = make_text_sprite(text, title_font_size, palette["title"]) + title_bottom = blit_centered(title_sprite, title_y) + + score_value = kwargs.get("current_score") + score_text = None if score_value is None else f"SCORE {score_value}" + score_sprite = None if score_text is None else make_text_sprite(score_text, score_font_size, palette["score"]) + score_y = panel_bottom - inner_padding_y - (0 if score_sprite is None else score_sprite.size[1]) + + image_top = title_bottom + max(14, panel_height // 28) + image_bottom = score_y - max(14, panel_height // 26) + image_area_height = max(120, image_bottom - image_top) + image_area_width = inner_right - inner_left + + image = kwargs.get("image") + if image is not None: + texture_width, texture_height = self.get_image_size(image) + image_scale = min(image_area_width / texture_width, image_area_height / texture_height) + image_width = max(1, int(round(texture_width * image_scale))) + image_height = max(1, int(round(texture_height * image_scale))) + image_x = panel_center_x - image_width // 2 + image_y = image_top + max(0, (image_area_height - image_height) // 4) + + image_frame = max(6, panel_width // 80) + draw_card( + image_x - image_frame, + image_y - image_frame, + image_width + image_frame * 2, + image_height + image_frame * 2, + palette["image_frame"], + palette["border"], + ) + self.draw_image( + image_x - self.w_offset, + image_y - self.h_offset, + image, + "run_complete", + dest_size=(image_width, image_height), + ) + + if score_sprite is not None: + blit_centered(score_sprite, score_y) def start_dialog(self, **kwargs): """Display the welcome dialog""" diff --git a/rats.py b/rats.py index 6a42204..becb72b 100644 --- a/rats.py +++ b/rats.py @@ -15,6 +15,7 @@ from runtime_paths import bundle_path LEVEL_MUSIC_CONFIG = "level_music" START_MENU_MUSIC = "High_Score_Garden.mp3" +RUN_COMPLETE_MUSIC = "Sunset_At_Pixel_Gardens.mp3" START_MENU_ANIMATION = "anim/start_mice.gif" SUPPORTED_MUSIC_EXTENSIONS = {".mp3", ".ogg", ".wav"} BASE_INITIAL_RATS = 5 @@ -28,6 +29,9 @@ START_MENU_AUDIO_OPTIONS = ( ("music_volume", "Musica"), ) VOLUME_STEP = 5 +GAME_END_LEVEL_CLEAR = "level_clear" +GAME_END_DEFEAT = "defeat" +GAME_END_RUN_COMPLETE = "run_complete" DIFFICULTY_OPTIONS = ( { "key": "easy", @@ -90,9 +94,9 @@ class MiceMaze( # Initialize user profile integration self.profile_integration = UserProfileIntegration() self.map_source = maze_file - self.current_level = level_index - self.map = maze.Map(maze_file, level_index=level_index) + self.current_level = self.map.level_index + self.total_levels = self.map.level_count # Load profile-specific settings self.audio = self.profile_integration.get_setting('sound_enabled', True) @@ -242,6 +246,38 @@ class MiceMaze( next_index = (current_index + delta) % len(DIFFICULTY_OPTIONS) self._apply_difficulty(DIFFICULTY_OPTIONS[next_index]["key"]) + + def _is_dat_campaign(self): + return self.map.source_path.suffix.lower() == ".dat" + + def _is_last_dat_level(self): + return self._is_dat_campaign() and self.current_level >= self.total_levels - 1 + + def _normalize_target_level(self, level_index): + if self._is_dat_campaign(): + return maze.normalize_level_index(level_index, self.total_levels) + return 0 + + def _record_run_result(self, completed): + if not self.run_recorded: + self.save_score() + self.profile_integration.update_game_stats(self.points, completed=completed) + self.run_recorded = True + self.combined_scores = self.profile_integration.get_device_leaderboard(5) + + def activate_debug_run_complete_dialog(self, score=None): + if score is not None: + self.points = max(0, int(score)) + self.current_level = max(0, self.total_levels - 1) + self.game_status = "paused" + self.menu_screen = None + self.game_end = (True, GAME_END_RUN_COMPLETE) + self.run_recorded = True + self.combined_scores = self.profile_integration.get_device_leaderboard(5) + print( + f"[debug] showing run-complete dialog: level={self.current_level + 1}/{self.total_levels} " + f"points={self.points}" + ) def start_game(self): print( @@ -292,15 +328,17 @@ class MiceMaze( self.spawn_rat() def load_level(self, level_index, preserve_points=True, show_menu=False, menu_screen=None): - next_theme_index = level_index % maze.LEVELS_PER_DAT_FILE // 8 + 1 + target_level_index = self._normalize_target_level(level_index) + next_theme_index = target_level_index // 8 + 1 print( - f"[flow] load_level requested: target_level={level_index + 1} " + f"[flow] load_level requested: target_level={target_level_index + 1} " f"preserve_points={preserve_points} show_menu={show_menu} menu_screen={menu_screen} " f"current_points={self.points} next_theme={next_theme_index}" ) - self.current_level = level_index - self.current_level_music = self._resolve_level_music(level_index) - self.map = maze.Map(self.map_source, level_index=level_index) + self.map = maze.Map(self.map_source, level_index=target_level_index) + self.current_level = self.map.level_index + self.total_levels = self.map.level_count + self.current_level_music = self._resolve_level_music(self.current_level) self._valid_positions = None self.collision_system = CollisionSystem( self.cell_size, @@ -357,11 +395,18 @@ class MiceMaze( def advance_level(self): print(f"[flow] advance_level called from level={self.current_level + 1} points={self.points}") - if self.map.source_path.suffix.lower() != ".dat": + if not self._is_dat_campaign(): self.load_level(self.current_level, preserve_points=True, show_menu=True, menu_screen="level_intro") return - next_level = (self.current_level + 1) % maze.LEVELS_PER_DAT_FILE + if self._is_last_dat_level(): + print("[flow] advance_level -> reached end of DAT campaign") + self.game_end = (True, GAME_END_RUN_COMPLETE) + self.game_status = "paused" + self._record_run_result(completed=True) + return + + next_level = self.current_level + 1 print(f"[flow] advancing to level={next_level + 1}") self.load_level(next_level, preserve_points=True, show_menu=True, menu_screen="level_intro") @@ -371,9 +416,13 @@ class MiceMaze( f"menu_screen={self.menu_screen} points={self.points} level={self.current_level + 1}" ) if self.game_end[0]: - if self.game_end[1]: + if self.game_end[1] == GAME_END_LEVEL_CLEAR: print("[flow] reset_game -> post-victory path") self.advance_level() + elif self.game_end[1] == GAME_END_RUN_COMPLETE: + print("[flow] reset_game -> DAT run complete, returning to start menu") + self.start_menu_animation_started_at = time.monotonic() + self.load_level(0, preserve_points=False, show_menu=True, menu_screen="start") else: print("[flow] reset_game -> restart from level 1 after defeat") self.load_level(0, preserve_points=False, show_menu=False) @@ -766,6 +815,9 @@ class MiceMaze( self._adjust_selected_volume(VOLUME_STEP) def update_background_music(self): + if self.game_end[0] and self.game_end[1] == GAME_END_RUN_COMPLETE: + self.render_engine.play_music(RUN_COMPLETE_MUSIC, loop=True) + return if self.game_status == "game" and not self.game_end[0]: if self.current_level_music: self.render_engine.play_music(self.current_level_music, loop=True) @@ -874,14 +926,21 @@ class MiceMaze( if self.game_end[0]: if self.combined_scores is None: self.combined_scores = self.profile_integration.get_device_leaderboard(5) - - if not self.game_end[1]: + + if self.game_end[1] == GAME_END_DEFEAT: self.render_engine.dialog( "Game Over: Mice are too many!", image=self.assets["BMP_WEWIN"], subtitle=f"Reached level: {self.current_level + 1}\nPress Return to restart from level 1", scores=self.combined_scores ) + elif self.game_end[1] == GAME_END_RUN_COMPLETE: + self.render_engine.dialog( + "THE END", + image=self.assets.get("end", self.assets["BMP_WEWIN"]), + current_score=self.points, + style="run_complete", + ) else: self.render_engine.dialog( f"Level {self.current_level + 1} Clear! Points: {self.points}", @@ -896,24 +955,27 @@ class MiceMaze( if count_rats > 200: self.render_engine.stop_sound() self.render_engine.play_sound("WEWIN.WAV") - self.game_end = (True, False) + self.game_end = (True, GAME_END_DEFEAT) self.game_status = "paused" print(f"[flow] defeat reached: rats={count_rats} points={self.points} level={self.current_level + 1}") - - if not self.run_recorded: - self.save_score() - self.profile_integration.update_game_stats(self.points, completed=False) - self.run_recorded = True + + self._record_run_result(completed=False) return True if not count_rats and not any(isinstance(unit, points.Point) for unit in self.units.values()): self.render_engine.stop_sound() self.render_engine.play_sound("VICTORY.WAV") self.render_engine.play_sound("WELLDONE.WAV", tag="effects") - self.game_end = (True, True) self.game_status = "paused" - self.combined_scores = self.profile_integration.get_device_leaderboard(5) - print(f"[flow] victory reached: points={self.points} level={self.current_level + 1}") + + if self._is_last_dat_level(): + self.game_end = (True, GAME_END_RUN_COMPLETE) + self._record_run_result(completed=True) + print(f"[flow] final DAT victory reached: points={self.points} level={self.current_level + 1}") + else: + self.game_end = (True, GAME_END_LEVEL_CLEAR) + self.combined_scores = self.profile_integration.get_device_leaderboard(5) + print(f"[flow] victory reached: points={self.points} level={self.current_level + 1}") return True @@ -921,9 +983,29 @@ class MiceMaze( def parse_args(): + def parse_debug_score(value): + try: + parsed = float(str(value).strip()) + except ValueError as exc: + raise argparse.ArgumentTypeError(f"invalid int value: {value!r}") from exc + if not parsed.is_integer(): + raise argparse.ArgumentTypeError(f"invalid int value: {value!r}") + return int(parsed) + parser = argparse.ArgumentParser(description="Run Mice! with DAT or JSON map loading") parser.add_argument("--level", type=int, default=0, help="Level index to load from level.dat (default: 0)") parser.add_argument("--map", dest="map_path", default=None, help="Optional map path override (.dat or .json)") + parser.add_argument( + "--debug-run-complete-dialog", + action="store_true", + help="Start directly on the final run-complete dialog without saving a fake high score", + ) + parser.add_argument( + "--debug-final-score", + type=parse_debug_score, + default=1125, + help="Score shown by --debug-run-complete-dialog (default: 1125)", + ) return parser.parse_args() @@ -933,5 +1015,7 @@ if __name__ == "__main__": map_source = args.map_path or maze.get_default_map_source() print(f"Loading map from {map_source} (level {args.level})") solver = MiceMaze(map_source, level_index=args.level) + if args.debug_run_complete_dialog: + solver.activate_debug_run_complete_dialog(score=args.debug_final_score) solver.run() diff --git a/test_final_level_flow.py b/test_final_level_flow.py new file mode 100644 index 0000000..f781089 --- /dev/null +++ b/test_final_level_flow.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 + +import unittest +from pathlib import Path +from types import SimpleNamespace + +from engine import maze +from rats import ( + GAME_END_LEVEL_CLEAR, + GAME_END_RUN_COMPLETE, + MiceMaze, + RUN_COMPLETE_MUSIC, +) + + +def build_dummy(level_index): + dummy = MiceMaze.__new__(MiceMaze) + dummy.map = SimpleNamespace(source_path=Path("/tmp/level.dat")) + dummy.current_level = level_index + dummy.total_levels = maze.DEFAULT_LEVELS_PER_DAT_FILE + dummy.points = 1234 + dummy.run_recorded = False + dummy.combined_scores = None + dummy.game_status = "game" + dummy.game_end = (False, None) + dummy.menu_screen = None + dummy.units = {} + dummy.assets = {"BMP_WEWIN": object(), "end": object()} + dummy.explosions = {"LEFT": object(), "RIGHT": object(), "UP": object(), "DOWN": object()} + dummy.count_rats = lambda: 0 + dummy.start_menu_animation_started_at = 0 + dummy._difficulty_config = lambda: {"label": "Hard"} + + sounds = [] + dialogs = [] + stats = [] + saves = [] + load_calls = [] + + dummy.render_engine = SimpleNamespace( + stop_sound=lambda: sounds.append(("stop",)), + play_sound=lambda *args, **kwargs: sounds.append((args, kwargs)), + dialog=lambda *args, **kwargs: dialogs.append((args, kwargs)), + ) + dummy.profile_integration = SimpleNamespace( + update_game_stats=lambda score, completed=True: stats.append((score, completed)) or True, + get_device_leaderboard=lambda limit: [{"user_id": "Player1", "best_score": 1234}][:limit], + get_profile_name=lambda: "Player1", + ) + dummy.save_score = lambda: saves.append(dummy.points) + dummy.load_level = lambda *args, **kwargs: load_calls.append((args, kwargs)) + + return dummy, dialogs, stats, saves, load_calls + + +class FinalLevelFlowTests(unittest.TestCase): + def test_debug_flag_helper_opens_final_dialog_without_recording_score(self): + dummy, _, stats, saves, _ = build_dummy(3) + + dummy.activate_debug_run_complete_dialog(score=777) + + self.assertEqual(dummy.current_level, dummy.total_levels - 1) + self.assertEqual(dummy.points, 777) + self.assertEqual(dummy.game_end, (True, GAME_END_RUN_COMPLETE)) + self.assertTrue(dummy.run_recorded) + self.assertEqual(dummy.game_status, "paused") + self.assertEqual(stats, []) + self.assertEqual(saves, []) + + def test_run_complete_screen_uses_dedicated_music(self): + dummy, _, _, _, _ = build_dummy(3) + music_calls = [] + dummy.render_engine.play_music = lambda track, loop=True: music_calls.append((track, loop)) or True + dummy.render_engine.pause_music = lambda: music_calls.append(("pause", False)) + dummy.game_end = (True, GAME_END_RUN_COMPLETE) + + dummy.update_background_music() + + self.assertEqual(music_calls, [(RUN_COMPLETE_MUSIC, True)]) + + def test_advance_level_does_not_wrap_after_last_dat_level(self): + dummy, _, stats, saves, load_calls = build_dummy(maze.DEFAULT_LEVELS_PER_DAT_FILE - 1) + + dummy.advance_level() + + self.assertEqual(dummy.game_end, (True, GAME_END_RUN_COMPLETE)) + self.assertTrue(dummy.run_recorded) + self.assertEqual(stats, [(1234, True)]) + self.assertEqual(saves, [1234]) + self.assertEqual(load_calls, []) + + def test_regular_level_clear_keeps_run_open(self): + dummy, _, stats, saves, _ = build_dummy(4) + + result = dummy.game_over() + + self.assertTrue(result) + self.assertEqual(dummy.game_end, (True, GAME_END_LEVEL_CLEAR)) + self.assertFalse(dummy.run_recorded) + self.assertEqual(dummy.combined_scores, [{"user_id": "Player1", "best_score": 1234}]) + self.assertEqual(stats, []) + self.assertEqual(saves, []) + + def test_final_dat_level_records_score_and_shows_dedicated_dialog(self): + dummy, dialogs, stats, saves, _ = build_dummy(maze.DEFAULT_LEVELS_PER_DAT_FILE - 1) + + result = dummy.game_over() + + self.assertTrue(result) + self.assertEqual(dummy.game_end, (True, GAME_END_RUN_COMPLETE)) + self.assertTrue(dummy.run_recorded) + self.assertEqual(stats, [(1234, True)]) + self.assertEqual(saves, [1234]) + self.assertEqual(dummy.combined_scores, [{"user_id": "Player1", "best_score": 1234}]) + + dummy.game_over() + + self.assertEqual(dialogs[-1][0][0], "THE END") + self.assertEqual(dialogs[-1][1]["style"], "run_complete") + self.assertEqual(dialogs[-1][1]["current_score"], 1234) + self.assertIs(dialogs[-1][1]["image"], dummy.assets["end"]) + + def test_return_after_final_dialog_goes_back_to_start_menu(self): + dummy, _, _, _, load_calls = build_dummy(maze.DEFAULT_LEVELS_PER_DAT_FILE - 1) + dummy.game_end = (True, GAME_END_RUN_COMPLETE) + dummy.game_status = "paused" + + dummy.reset_game() + + self.assertEqual( + load_calls, + [((0,), {"preserve_points": False, "show_menu": True, "menu_screen": "start"})], + ) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/test_level_editor.py b/test_level_editor.py new file mode 100644 index 0000000..bf6d6b4 --- /dev/null +++ b/test_level_editor.py @@ -0,0 +1,96 @@ +#!/usr/bin/env python3 + +import importlib.util +import unittest +from pathlib import Path + +from engine import maze + + +MODULE_PATH = Path(__file__).resolve().parent / "tools" / "level_editor.py" +SPEC = importlib.util.spec_from_file_location("level_editor_module", MODULE_PATH) +level_editor = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(level_editor) + + +class LevelEditorLogicTests(unittest.TestCase): + def test_compute_canvas_layout_scales_to_viewport(self): + layout = level_editor.compute_canvas_layout( + maze.LEVEL_WIDTH, + maze.LEVEL_HEIGHT, + 1200, + 900, + level_editor.DEFAULT_CELL_SIZE, + True, + ) + + self.assertGreater(layout["cell_size"], level_editor.DEFAULT_CELL_SIZE) + self.assertAlmostEqual(layout["cell_size"], (900 - level_editor.VIEWPORT_PADDING * 2) / maze.LEVEL_HEIGHT) + self.assertAlmostEqual(layout["origin_y"], level_editor.VIEWPORT_PADDING) + self.assertGreater(layout["origin_x"], 0) + + def test_compute_canvas_layout_centers_manual_zoom(self): + layout = level_editor.compute_canvas_layout( + maze.LEVEL_WIDTH, + maze.LEVEL_HEIGHT, + 1000, + 800, + 16, + False, + ) + + self.assertEqual(layout["cell_size"], 16) + self.assertGreater(layout["origin_x"], 0) + self.assertGreater(layout["origin_y"], 0) + + def test_fit_level_to_dat_size_centers_small_map(self): + source = [ + [maze.MAP_WALL, maze.MAP_EMPTY], + [maze.MAP_TUNNEL, maze.MAP_EMPTY], + ] + + fitted = level_editor.fit_level_to_dat_size(source) + + self.assertEqual(len(fitted), maze.LEVEL_HEIGHT) + self.assertEqual(len(fitted[0]), maze.LEVEL_WIDTH) + + start_x = (maze.LEVEL_WIDTH - len(source[0])) // 2 + start_y = (maze.LEVEL_HEIGHT - len(source)) // 2 + self.assertEqual(fitted[start_y][start_x], maze.MAP_WALL) + self.assertEqual(fitted[start_y][start_x + 1], maze.MAP_EMPTY) + self.assertEqual(fitted[start_y + 1][start_x], maze.MAP_TUNNEL) + self.assertEqual(fitted[start_y + 1][start_x + 1], maze.MAP_EMPTY) + + def test_compute_level_stats_reports_spawnable_default_level(self): + level = maze.create_level() + + stats = level_editor.compute_level_stats(level) + + self.assertEqual(stats["width"], maze.LEVEL_WIDTH) + self.assertEqual(stats["height"], maze.LEVEL_HEIGHT) + self.assertEqual(stats["border_openings"], 0) + self.assertEqual(stats["component_count"], 1) + self.assertGreater(stats["spawnable_count"], 0) + self.assertEqual(stats["warnings"], []) + + def test_compute_level_stats_flags_unusable_map(self): + level = [[maze.MAP_WALL for _ in range(maze.LEVEL_WIDTH)] for _ in range(maze.LEVEL_HEIGHT)] + + stats = level_editor.compute_level_stats(level) + + self.assertEqual(stats["traversable_count"], 0) + self.assertEqual(stats["spawnable_count"], 0) + self.assertIn("nessuna cella attraversabile", stats["warnings"]) + self.assertIn("nessuna cella EMPTY: niente spawn e niente armi", stats["warnings"]) + + def test_missing_tkinter_is_reported_cleanly(self): + if level_editor.TKINTER_IMPORT_ERROR is None: + level_editor.ensure_tkinter_available() + return + + with self.assertRaises(RuntimeError): + level_editor.ensure_tkinter_available() + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/test_level_io.py b/test_level_io.py new file mode 100644 index 0000000..31f8882 --- /dev/null +++ b/test_level_io.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 + +import json +import tempfile +import unittest +from pathlib import Path + +from engine import maze + + +class LevelIoTests(unittest.TestCase): + def test_dat_round_trip_preserves_all_levels(self): + levels = [] + for level_index in range(maze.DEFAULT_LEVELS_PER_DAT_FILE): + level = maze.create_level() + level[1][1] = maze.MAP_EMPTY + level[1][2] = maze.MAP_EMPTY + level[2][1] = maze.MAP_TUNNEL + level[2][2] = level_index % 3 + level[3][3] = (level_index + 1) % 3 + levels.append(level) + + with tempfile.TemporaryDirectory() as tmp_dir: + dat_path = Path(tmp_dir) / "roundtrip.dat" + maze.save_dat_levels(dat_path, levels) + loaded = maze.load_dat_levels(dat_path) + + self.assertEqual(levels, loaded) + + def test_dat_level_count_is_derived_from_file_size(self): + levels = [maze.create_level() for _ in range(3)] + + with tempfile.TemporaryDirectory() as tmp_dir: + dat_path = Path(tmp_dir) / "three-levels.dat" + maze.save_dat_levels(dat_path, levels) + + self.assertEqual(maze.get_dat_level_count(dat_path), 3) + self.assertEqual(maze.get_level_count(dat_path), 3) + self.assertEqual(maze.load_dat_level(dat_path, 4), levels[1]) + + def test_load_json_level_supports_legacy_boolean_maps(self): + legacy_map = [ + [True, False, True], + [False, False, True], + ] + + with tempfile.TemporaryDirectory() as tmp_dir: + json_path = Path(tmp_dir) / "legacy.json" + json_path.write_text(json.dumps(legacy_map), encoding="utf-8") + loaded = maze.load_json_level(json_path) + + self.assertEqual( + loaded, + [ + [maze.MAP_WALL, maze.MAP_TUNNEL, maze.MAP_WALL], + [maze.MAP_TUNNEL, maze.MAP_TUNNEL, maze.MAP_WALL], + ], + ) + + def test_load_json_level_preserves_exact_tile_values(self): + tile_map = [ + [maze.MAP_EMPTY, maze.MAP_WALL, maze.MAP_TUNNEL], + [maze.MAP_TUNNEL, maze.MAP_EMPTY, maze.MAP_WALL], + ] + + with tempfile.TemporaryDirectory() as tmp_dir: + json_path = Path(tmp_dir) / "tiles.json" + maze.save_json_level(json_path, tile_map) + loaded = maze.load_json_level(json_path) + + self.assertEqual(tile_map, loaded) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tools/level_editor.py b/tools/level_editor.py new file mode 100644 index 0000000..a1194d0 --- /dev/null +++ b/tools/level_editor.py @@ -0,0 +1,1185 @@ +#!/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() \ No newline at end of file