Add DAT finale flow, editor, and new soundtrack assets
@@ -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`
|
||||
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 519 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 543 B |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 550 B |
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 517 B |
|
After Width: | Height: | Size: 743 KiB |
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||