Add DAT finale flow, editor, and new soundtrack assets

This commit is contained in:
2026-05-09 12:03:17 +02:00
parent f0d056e7f0
commit 310dc0dca9
22 changed files with 1982 additions and 106 deletions
+2
View File
@@ -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:
+160 -28
View File
@@ -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
+199 -52
View File
@@ -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"""