Add new PNG images for clean and preview outputs

- Added `image_clean.png` to the output directory for the clean image representation.
- Added `image_clean_preview.png` for the preview of the clean image.
- Introduced `image_svg_clean.png` for the SVG clean image representation.
This commit is contained in:
2026-03-27 23:40:27 +01:00
parent e7c5ebb119
commit 02202e4d3d
403 changed files with 40415 additions and 411 deletions
+7 -3
View File
@@ -5,14 +5,18 @@ import random
import os
import json
from runtime_paths import resolve_bundle_path
bindings = {}
if os.path.exists("conf/keybindings.json"):
with open("conf/keybindings.json", "r") as f:
json_bindings_path = resolve_bundle_path("conf/keybindings.json")
yaml_bindings_path = resolve_bundle_path("conf/keybindings.yaml")
if os.path.exists(json_bindings_path):
with open(json_bindings_path, "r") as f:
bindings = json.load(f)
else:
import yaml
# read yaml config file
with open("conf/keybindings.yaml", "r") as f:
with open(yaml_bindings_path, "r") as f:
bindings = yaml.safe_load(f)
class KeyBindings:
+245 -49
View File
@@ -1,48 +1,123 @@
import os
import random
from engine import maze
from runtime_paths import bundle_path
class Graphics():
def load_assets(self):
print("Loading graphics assets...")
self.tunnel = self.render_engine.load_image("Rat/BMP_TUNNEL.png", surface=True)
self.grasses = [self.render_engine.load_image(f"Rat/BMP_1_GRASS_{i+1}.png", surface=True) for i in range(4)]
self.rat_assets = {}
self.rat_assets_textures = {}
self.rat_image_sizes = {} # Pre-cache image sizes
self.bomb_assets = {}
for sex in ["MALE", "FEMALE", "BABY"]:
self.rat_assets[sex] = {}
for direction in ["UP", "DOWN", "LEFT", "RIGHT"]:
self.rat_assets[sex][direction] = self.render_engine.load_image(f"Rat/BMP_{sex}_{direction}.png", transparent_color=(128, 128, 128))
# Load textures and pre-cache sizes
for sex in ["MALE", "FEMALE", "BABY"]:
self.rat_assets_textures[sex] = {}
self.rat_image_sizes[sex] = {}
for direction in ["UP", "DOWN", "LEFT", "RIGHT"]:
texture = self.render_engine.load_image(f"Rat/BMP_{sex}_{direction}.png", transparent_color=(128, 128, 128), surface=False)
self.rat_assets_textures[sex][direction] = texture
# Cache size to avoid get_image_size() calls in draw loop
self.rat_image_sizes[sex][direction] = texture.size
for n in range(5):
self.bomb_assets[n] = self.render_engine.load_image(f"Rat/BMP_BOMB{n}.png", transparent_color=(128, 128, 128))
self.assets = {}
for file in os.listdir("assets/Rat"):
if file.endswith(".png"):
self.assets[file[:-4]] = self.render_engine.load_image(f"Rat/{file}", transparent_color=(128, 128, 128))
# Pre-generate blood stain textures pool (optimization)
print("Pre-generating blood stain pool...")
self.blood_stain_textures = []
for _ in range(10):
blood_surface = self.render_engine.generate_blood_surface()
blood_texture = self.render_engine.draw_blood_surface(blood_surface, (0, 0))
if blood_texture:
self.blood_stain_textures.append(blood_texture)
# Blood layer sprites (instead of regenerating background)
self.blood_layer_sprites = []
theme_index = self.get_theme_index()
print(f"[gfx] load_assets requested: level={self.current_level + 1} theme={theme_index}")
if not hasattr(self, "theme_assets_cache"):
self.theme_assets_cache = {}
if not hasattr(self, "blood_layer_sprites"):
self.blood_layer_sprites = []
if not hasattr(self, "cave_foreground_tiles"):
self.cave_foreground_tiles = []
if not getattr(self, "common_assets_loaded", False):
print("Loading graphics assets...")
self.rat_assets = {}
self.rat_assets_textures = {}
self.rat_image_sizes = {}
self.bomb_assets = {}
self.assets = {}
for sex in ["MALE", "FEMALE", "BABY"]:
self.rat_assets[sex] = {}
self.rat_assets_textures[sex] = {}
self.rat_image_sizes[sex] = {}
for direction in ["UP", "DOWN", "LEFT", "RIGHT"]:
self.rat_assets[sex][direction] = self.render_engine.load_image(
f"Rat/BMP_{sex}_{direction}.png",
transparent_color=((125, 125, 125), (128, 128, 128)),
)
texture = self.render_engine.load_image(
f"Rat/BMP_{sex}_{direction}.png",
transparent_color=((125, 125, 125), (128, 128, 128)),
surface=False,
)
self.rat_assets_textures[sex][direction] = texture
self.rat_image_sizes[sex][direction] = texture.size
for n in range(5):
self.bomb_assets[n] = self.render_engine.load_image(
f"Rat/BMP_BOMB{n}.png",
transparent_color=((125, 125, 125), (128, 128, 128)),
)
rat_asset_dir = bundle_path("assets", "Rat")
for file in os.listdir(rat_asset_dir):
if file.endswith(".png"):
self.assets[file[:-4]] = self.render_engine.load_image(
f"Rat/{file}",
transparent_color=((125, 125, 125), (128, 128, 128)),
)
print("Pre-generating blood stain pool...")
self.blood_stain_textures = []
for _ in range(10):
blood_surface = self.render_engine.generate_blood_surface()
blood_texture = self.render_engine.draw_blood_surface(blood_surface, (0, 0))
if blood_texture:
self.blood_stain_textures.append(blood_texture)
self.common_assets_loaded = True
print("[gfx] common assets loaded")
else:
print("[gfx] common assets cache hit")
if theme_index not in self.theme_assets_cache:
print(f"Loading theme assets {theme_index}...")
self.theme_assets_cache[theme_index] = {
"floor_tile": self.render_engine.create_color_surface((128, 128, 128)),
"tunnel": self.render_engine.load_image("Rat/BMP_TUNNEL.png", surface=True),
"grasses": [
self.render_engine.load_image(f"Rat/BMP_{theme_index}_GRASS_{i+1}.png", surface=True)
for i in range(4)
],
"flowers": [
self.render_engine.load_image(f"Rat/BMP_{theme_index}_FLOWER_{i+1}.png", surface=True)
for i in range(4)
],
"caves": {
direction: self.render_engine.load_image(
f"Rat/BMP_{theme_index}_CAVE_{direction}.png",
transparent_color=((125, 125, 125), (128, 128, 128)),
surface=False,
)
for direction in ["UP", "DOWN", "LEFT", "RIGHT"]
},
"edges": {
direction: self.render_engine.load_image(f"Rat/BMP_{theme_index}_{direction}.png", surface=True)
for direction in ["N", "S", "E", "W"]
},
"corners": {
direction: self.render_engine.load_image(f"Rat/BMP_{theme_index}_{direction}.png", surface=True)
for direction in ["NE", "NW", "SE", "SW"]
},
"inner_corners": {
direction: self.render_engine.load_image(f"Rat/BMP_{theme_index}_{direction}.png", surface=True)
for direction in ["EN", "ES", "WN", "WS"]
},
}
print(f"[gfx] theme cache miss -> loaded theme {theme_index}")
else:
print(f"[gfx] theme cache hit -> reusing theme {theme_index}")
self.loaded_theme_index = theme_index
theme_assets = self.theme_assets_cache[theme_index]
self.floor_tile = theme_assets["floor_tile"]
self.tunnel = theme_assets["tunnel"]
self.grasses = theme_assets["grasses"]
self.flowers = theme_assets["flowers"]
self.caves = theme_assets["caves"]
self.edges = theme_assets["edges"]
self.corners = theme_assets["corners"]
self.inner_corners = theme_assets["inner_corners"]
def get_theme_index(self):
return self.current_level % 32 // 8 + 1
@@ -50,12 +125,16 @@ class Graphics():
def draw_maze(self):
if self.background_texture is None:
print("Generating background texture")
print(f"[gfx] generating background texture for level={self.current_level + 1} theme={self.loaded_theme_index}")
self.regenerate_background()
self.render_engine.draw_background(self.background_texture)
# Draw blood layer as sprites (optimized - no background regeneration)
self.draw_blood_layer()
def draw_cave_foreground(self):
for surface, x, y in self.cave_foreground_tiles:
self.render_engine.draw_image(x, y, surface, anchor="nw", tag="cave")
def draw_blood_layer(self):
"""Draw all blood stains as sprites overlay (optimized)"""
@@ -65,19 +144,136 @@ class Graphics():
def regenerate_background(self):
"""Generate or regenerate the background texture (static - no blood stains)"""
texture_tiles = []
for y, row in enumerate(self.map.matrix):
self.cave_foreground_tiles = []
half_cell = self.cell_size // 2
def draw(surface, x, y):
texture_tiles.append((surface, x, y))
def draw_cave(surface, x, y):
self.cave_foreground_tiles.append((surface, x, y))
def occupied(x, y):
return self.map.in_bounds(x, y) and self.map.get_cell(x, y) != maze.MAP_EMPTY
def is_tunnel(x, y):
return self.map.in_bounds(x, y) and self.map.get_cell(x, y) == maze.MAP_TUNNEL
def random_wall():
return random.choice(self.grasses)
def random_flower():
return random.choice(self.flowers)
for y, row in enumerate(self.map.tiles):
for x, cell in enumerate(row):
variant = x*y % 4
tile = self.grasses[variant] if cell else self.tunnel
texture_tiles.append((tile, x*self.cell_size, y*self.cell_size))
px = x * self.cell_size
py = y * self.cell_size
if cell == maze.MAP_EMPTY:
continue
if cell == maze.MAP_WALL:
if x == 0 or y == 0 or x == self.map.width - 1 or y == self.map.height - 1:
draw(random_wall(), px, py)
if x > 0 and y > 0 and (not occupied(x - 1, y - 1) or not occupied(x, y - 1) or not occupied(x - 1, y)):
north = occupied(x, y - 1)
west = occupied(x - 1, y)
if north or west:
if north and west:
draw(self.inner_corners["WN"], px, py)
elif north and not west:
draw(self.edges["W"], px, py)
else:
draw(self.edges["N"], px, py)
else:
draw(self.corners["NW"], px, py)
if y < self.map.height - 1 and x < self.map.width - 1:
south = occupied(x, y + 1)
east = occupied(x + 1, y)
southeast = occupied(x + 1, y + 1)
if southeast and south and east:
if (
random.randrange(10) != 0
or x == 0
or y == 0
or x == self.map.width - 2
or y == self.map.height - 2
or is_tunnel(x + 1, y)
or is_tunnel(x, y + 1)
or is_tunnel(x + 1, y + 1)
):
draw(random_wall(), px + half_cell, py + half_cell)
else:
draw(random_flower(), px + half_cell, py + half_cell)
elif south or east:
if south and east:
draw(self.inner_corners["ES"], px + half_cell, py + half_cell)
elif south and not east:
draw(self.edges["E"], px + half_cell, py + half_cell)
else:
draw(self.edges["S"], px + half_cell, py + half_cell)
else:
draw(self.corners["SE"], px + half_cell, py + half_cell)
if y > 0 and x < self.map.width - 1 and (not occupied(x + 1, y - 1) or not occupied(x, y - 1) or not occupied(x + 1, y)):
north = occupied(x, y - 1)
east = occupied(x + 1, y)
if north or east:
if north and east:
draw(self.inner_corners["EN"], px + half_cell, py)
elif north and not east:
draw(self.edges["E"], px + half_cell, py)
else:
draw(self.edges["N"], px + half_cell, py)
else:
draw(self.corners["NE"], px + half_cell, py)
if y < self.map.height - 1 and x > 0 and (not occupied(x - 1, y + 1) or not occupied(x, y + 1) or not occupied(x - 1, y)):
south = occupied(x, y + 1)
west = occupied(x - 1, y)
if south or west:
if south and west:
draw(self.inner_corners["WS"], px, py + half_cell)
elif south and not west:
draw(self.edges["W"], px, py + half_cell)
else:
draw(self.edges["S"], px, py + half_cell)
else:
draw(self.corners["SW"], px, py + half_cell)
elif cell == maze.MAP_TUNNEL:
above = occupied(x, y - 1)
below = occupied(x, y + 1)
left = occupied(x - 1, y)
right = occupied(x + 1, y)
if above:
if below:
if left:
if right:
if random.randrange(10) != 0:
draw(random_wall(), px + half_cell, py + half_cell)
else:
draw(random_flower(), px + half_cell, py + half_cell)
else:
draw_cave(self.caves["RIGHT"], px, py)
else:
draw(self.grasses[0], px + half_cell, py + half_cell)
draw_cave(self.caves["LEFT"], px, py)
else:
draw_cave(self.caves["DOWN"], px, py)
else:
draw(self.grasses[0], px + half_cell, py + half_cell)
draw_cave(self.caves["UP"], px, py)
# Blood stains now handled separately as overlay layer
self.background_texture = self.render_engine.create_texture(texture_tiles)
self.background_texture = self.render_engine.create_texture(texture_tiles, fill_color=(128, 128, 128))
def add_blood_stain(self, position):
"""Add a blood stain as sprite overlay (optimized - no background regeneration)"""
import random
# Pick random blood texture from pre-generated pool
if not self.blood_stain_textures:
return
+118 -7
View File
@@ -1,13 +1,124 @@
import json
from pathlib import Path
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
def get_default_map_source():
if DEFAULT_DAT_PATH.exists():
return DEFAULT_DAT_PATH
return DEFAULT_JSON_PATH
class Map:
"""Classe che rappresenta la mappa del labirinto."""
def __init__(self, maze_file):
with open(maze_file, 'r') as file:
self.matrix = json.load(file)
self.height = len(self.matrix)
self.width = len(self.matrix[0])
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.matrix = [
[cell == MAP_WALL for cell in row]
for row in self.tiles
]
self.height = len(self.tiles)
self.width = len(self.tiles[0])
def _resolve_source_path(self, maze_file):
if maze_file is None:
return get_default_map_source()
candidate = Path(maze_file)
if candidate.is_absolute():
return candidate
if candidate.exists():
return candidate.resolve()
project_candidate = PROJECT_ROOT / candidate
if project_candidate.exists():
return project_candidate
return project_candidate
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)
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
]
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
def in_bounds(self, x, y):
return 0 <= x < self.width and 0 <= y < self.height
def get_cell(self, x, y):
return self.tiles[y][x]
def is_wall(self, x, y):
"""Restituisce True se la cella è un muro, False altrimenti."""
return self.matrix[y][x]
return self.matrix[y][x]
def is_traversable(self, x, y):
return self.get_cell(x, y) != MAP_WALL
def is_empty(self, x, y):
return self.get_cell(x, y) == MAP_EMPTY
def is_tunnel(self, x, y):
return self.get_cell(x, y) == MAP_TUNNEL
def get_tunnel_direction(self, x, y):
directions = [
("UP", 0, -1),
("DOWN", 0, 1),
("LEFT", -1, 0),
("RIGHT", 1, 0),
]
traversable_neighbors = []
for direction, dx, dy in directions:
nx = x + dx
ny = y + dy
if self.in_bounds(nx, ny) and self.is_traversable(nx, ny):
traversable_neighbors.append(direction)
if len(traversable_neighbors) == 1:
return traversable_neighbors[0]
if traversable_neighbors:
return traversable_neighbors[0]
return "UP"
+7 -2
View File
@@ -1,13 +1,18 @@
import datetime
from runtime_paths import persistent_data_path
SCORES_FILE = persistent_data_path("scores.txt", default_text="")
class Scoring:
# ==================== SCORING ====================
def save_score(self):
# Save to traditional scores.txt file
with open("scores.txt", "a") as f:
with SCORES_FILE.open("a", encoding="utf-8") as f:
player_name = getattr(self, 'profile_integration', None)
if player_name and hasattr(player_name, 'get_profile_name'):
name = player_name.get_profile_name()
@@ -19,7 +24,7 @@ class Scoring:
def read_score(self):
table = []
try:
with open("scores.txt") as f:
with SCORES_FILE.open(encoding="utf-8") as f:
rows = f.read().splitlines()
for row in rows:
parts = row.split(" - ")
+147 -32
View File
@@ -9,6 +9,8 @@ from sdl2.ext.compat import byteify
from sdl2 import SDL_AudioSpec
from PIL import Image
from runtime_paths import resolve_bundle_path
class GameWindow:
def __init__(self, width, height, cell_size, title="Default", key_callback=None):
@@ -93,9 +95,12 @@ class GameWindow:
# TEXTURE & IMAGE METHODS
# ======================
def create_texture(self, tiles: list):
def create_texture(self, tiles: list, fill_color=None):
"""Create a texture from a list of tiles"""
bg_surface = sdl2.SDL_CreateRGBSurface(0, self.width, self.height, 32, 0, 0, 0, 0)
if fill_color is not None:
mapped_color = sdl2.SDL_MapRGB(bg_surface.contents.format, *fill_color)
sdl2.SDL_FillRect(bg_surface, None, mapped_color)
for tile in tiles:
dstrect = sdl2.SDL_Rect(tile[1], tile[2], self.cell_size, self.cell_size)
sdl2.SDL_BlitSurface(tile[0], None, bg_surface, dstrect)
@@ -103,30 +108,42 @@ class GameWindow:
sdl2.SDL_FreeSurface(bg_surface)
return bg_texture
def create_color_surface(self, color, width=None, height=None):
"""Create a solid color surface matching the current cell size by default."""
width = width or self.cell_size
height = height or self.cell_size
image = Image.new("RGBA", (width, height), (*color, 255))
return sdl2.ext.pillow_to_surface(image)
def load_image(self, path, transparent_color=None, surface=False):
"""Load and process an image with optional transparency and scaling"""
image_path = os.path.join("assets", path)
image_path = resolve_bundle_path(os.path.join("assets", path))
image = Image.open(image_path)
# Handle transparency
if transparent_color:
image = image.convert("RGBA")
# Support single color tuple or sequence of color tuples
if isinstance(transparent_color[0], int):
color_set = {transparent_color}
else:
color_set = set(transparent_color)
datas = image.getdata()
new_data = []
for item in datas:
if item[:3] == transparent_color:
new_data.append((255, 255, 255, 0))
else:
new_data.append(item)
new_data = [
(255, 255, 255, 0) if item[:3] in color_set else item
for item in datas
]
image.putdata(new_data)
# Scale image
scale = self.cell_size // 20
image = image.resize((image.width * scale, image.height * scale), Image.NEAREST)
# Scale image: tiles are now 64px (was 20px), multiply by 5/8 to reach cell_size (40px)
image = image.resize((image.width * 5 // 8, image.height * 5 // 8), Image.NEAREST)
if surface:
return sdl2.ext.pillow_to_surface(image)
return self.factory.from_surface(sdl2.ext.pillow_to_surface(image))
temp_surface = sdl2.ext.pillow_to_surface(image)
texture = self.factory.from_surface(temp_surface)
sdl2.SDL_FreeSurface(temp_surface)
return texture
def get_image_size(self, image):
"""Get the size of an image sprite"""
@@ -139,8 +156,9 @@ class GameWindow:
def generate_fonts(self, font_file):
"""Generate font managers for different sizes"""
fonts = {}
font_path = str(resolve_bundle_path(font_file))
for i in range(10, 70, 1):
fonts.update({i: sdl2.ext.FontManager(font_path=font_file, size=i)})
fonts.update({i: sdl2.ext.FontManager(font_path=font_path, size=i)})
return fonts
# ======================
@@ -166,10 +184,23 @@ class GameWindow:
"""Draw background texture with current view offset"""
self.renderer.copy(bg_texture, dstrect=sdl2.SDL_Rect(self.w_offset, self.h_offset, self.width, self.height))
def draw_image(self, x, y, sprite, tag=None, anchor="nw"):
def draw_image(self, x, y, sprite, tag=None, anchor="nw", source_rect=None, dest_size=None):
"""Draw an image sprite at specified coordinates"""
if not self.is_in_visible_area(x, y):
return
if source_rect is not None:
src_x, src_y, src_w, src_h = (int(value) for value in source_rect)
dst_w, dst_h = dest_size or (src_w, src_h)
dstrect = sdl2.SDL_Rect(
int(x + self.w_offset),
int(y + self.h_offset),
int(dst_w),
int(dst_h),
)
srcrect = sdl2.SDL_Rect(src_x, src_y, src_w, src_h)
self.renderer.copy(sprite, srcrect=srcrect, dstrect=dstrect)
return
sprite.position = (x + self.w_offset, y + self.h_offset)
self.renderer.copy(sprite, dstrect=sprite.position)
@@ -230,17 +261,20 @@ class GameWindow:
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
if scores := kwargs.get("scores"):
scores_start_y = self.target_size[1] * 3 // 4 # Bottom quarter of screen
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 len(score) >= 4: # New format: date, score, name, device
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"
@@ -317,22 +351,16 @@ class GameWindow:
def scroll_view(self, pointer):
"""Adjust the view offset based on pointer coordinates"""
x, y = pointer
# Scale down and invert coordinates
x = -(x // 2) * self.cell_size
y = -(y // 2) * self.cell_size
# Clamp horizontal offset to valid range
if x <= self.max_w_offset + self.cell_size:
x = self.max_w_offset
# Clamp vertical offset to valid range
if y < self.max_h_offset:
y = self.max_h_offset
cell_x, cell_y = pointer
self.w_offset = x
self.h_offset = y
pointer_x = cell_x * self.cell_size
pointer_y = cell_y * self.cell_size
desired_w_offset = (self.target_size[0] - self.cell_size) // 2 - pointer_x
desired_h_offset = (self.target_size[1] - self.cell_size) // 2 - pointer_y
self.w_offset = max(self.max_w_offset, min(0, desired_w_offset))
self.h_offset = max(self.max_h_offset, min(0, desired_h_offset))
# Update cached bounds when viewport changes
self._update_viewport_bounds()
@@ -354,7 +382,7 @@ class GameWindow:
"""Play a sound file on the specified audio channel"""
if not self.audio:
return
sound_path = os.path.join("sound", sound_file)
sound_path = str(resolve_bundle_path(os.path.join("sound", sound_file)))
rw = sdl2.SDL_RWFromFile(byteify(sound_path, "utf-8"), b"rb")
if not rw:
raise RuntimeError("Failed to open sound file")
@@ -450,6 +478,93 @@ class GameWindow:
delay = max(0, self.delay - round(self.performance))
sdl2.SDL_Delay(delay)
# ======================
# INTRO SCREEN
# ======================
def show_intro(self, path, duration_ms=2000, fade_ms=500, crop_center_y=520):
"""Show a fullscreen intro image with fade-in/out from black. Skip on keypress.
SDL scales the image to target width (proportional height), then crops
vertically around crop_center_y. No PIL resize — scaling is done by SDL
via srcrect/dstrect.
"""
img = Image.open(resolve_bundle_path(path)).convert("RGBA")
tw, th = self.target_size
iw, ih = img.size
# Compute scaled height at target width (aspect-correct), keep in source space
scaled_h = int(ih * tw / iw)
# Crop window in scaled coords, mapped back to source coords for srcrect
crop_y_scaled = max(0, min(crop_center_y - th // 2, scaled_h - th))
src_y = int(crop_y_scaled * ih / scaled_h)
src_h = max(1, int(th * ih / scaled_h))
surface = sdl2.ext.pillow_to_surface(img)
texture = self.factory.from_surface(surface)
sdl2.SDL_FreeSurface(surface)
srcrect = sdl2.SDL_Rect(0, src_y, iw, src_h)
dstrect = sdl2.SDL_Rect(0, 0, tw, th)
start = sdl2.SDL_GetTicks()
skipped = False
while True:
elapsed = sdl2.SDL_GetTicks() - start
if elapsed >= duration_ms:
break
# Compute black overlay alpha for fade-in / fade-out
if elapsed < fade_ms:
overlay_alpha = int(255 * (1.0 - elapsed / fade_ms))
elif elapsed > duration_ms - fade_ms:
overlay_alpha = int(255 * (elapsed - (duration_ms - fade_ms)) / fade_ms)
else:
overlay_alpha = 0
self.renderer.clear()
self.renderer.copy(texture, srcrect=srcrect, dstrect=dstrect)
if overlay_alpha > 0:
sdl2.SDL_SetRenderDrawBlendMode(
self.renderer.sdlrenderer, sdl2.SDL_BLENDMODE_BLEND)
sdl2.SDL_SetRenderDrawColor(
self.renderer.sdlrenderer, 0, 0, 0, overlay_alpha)
sdl2.SDL_RenderFillRect(self.renderer.sdlrenderer, None)
self.renderer.present()
sdl2.SDL_Delay(16)
for event in sdl2.ext.get_events():
if event.type == sdl2.SDL_QUIT:
self.running = False
return
elif event.type in (sdl2.SDL_KEYDOWN, sdl2.SDL_JOYBUTTONDOWN):
skipped = True
if skipped:
break
# Fade to black before returning (fast if skipped, already there if natural end)
fade_out_ms = fade_ms // 2 if skipped else 0
if fade_out_ms > 0:
fade_start = sdl2.SDL_GetTicks()
while True:
elapsed = sdl2.SDL_GetTicks() - fade_start
if elapsed >= fade_out_ms:
break
alpha = int(255 * elapsed / fade_out_ms)
self.renderer.clear()
self.renderer.copy(texture, srcrect=srcrect, dstrect=dstrect)
sdl2.SDL_SetRenderDrawBlendMode(
self.renderer.sdlrenderer, sdl2.SDL_BLENDMODE_BLEND)
sdl2.SDL_SetRenderDrawColor(self.renderer.sdlrenderer, 0, 0, 0, alpha)
sdl2.SDL_RenderFillRect(self.renderer.sdlrenderer, None)
self.renderer.present()
sdl2.SDL_Delay(16)
# Final black frame
self.renderer.clear()
self.renderer.present()
# ======================
# SPECIAL EFFECTS
# ======================
+43 -10
View File
@@ -5,6 +5,20 @@ from units import gas, rat, bomb, mine
class UnitManager:
def _spawnable_rat_positions(self):
positions = []
for y in range(1, self.map.height - 1):
for x in range(1, self.map.width - 1):
if not self.map.is_empty(x, y):
continue
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
nx = x + dx
ny = y + dy
if self.map.in_bounds(nx, ny) and self.map.is_empty(nx, ny):
positions.append((x, y))
break
return positions
def has_weapon_at(self, position):
"""Check if there's a weapon (bomb, gas, mine) at the given position"""
for unit in self.units.values():
@@ -13,6 +27,16 @@ class UnitManager:
if isinstance(unit, (bomb.Timer, bomb.NuclearBomb, gas.Gas, mine.Mine)):
return True
return False
def can_place_weapon_at(self, position):
x, y = position
if not self.map.in_bounds(x, y):
return False
if not self.map.is_empty(x, y):
return False
if self.has_weapon_at(position):
return False
return True
def count_rats(self):
count = 0
@@ -22,7 +46,7 @@ class UnitManager:
return count
def spawn_gas(self, parent_id=None):
if self.map.is_wall(self.pointer[0], self.pointer[1]):
if not self.can_place_weapon_at(self.pointer):
return
if self.ammo["gas"]["count"] <= 0:
return
@@ -33,23 +57,33 @@ class UnitManager:
def spawn_rat(self, position=None):
if position is None:
position = self.choose_start()
if position is None:
print("[flow] spawn_rat aborted: no valid spawn position", flush=True)
return
print(f"[flow] spawn_rat using position={position}", flush=True)
# Don't spawn rats on top of weapons
if self.has_weapon_at(position):
# Try nearby positions
for dx, dy in [(0,1), (1,0), (0,-1), (-1,0), (1,1), (-1,-1), (1,-1), (-1,1)]:
alt_pos = (position[0] + dx, position[1] + dy)
if not self.map.is_wall(alt_pos[0], alt_pos[1]) and not self.has_weapon_at(alt_pos):
if not self.map.in_bounds(alt_pos[0], alt_pos[1]):
continue
if self.map.is_empty(alt_pos[0], alt_pos[1]) and not self.has_weapon_at(alt_pos):
position = alt_pos
break
else:
# All nearby positions blocked, abort spawn
print(f"[flow] spawn_rat aborted: weapon blocks spawn near {position}", flush=True)
return
rat_class = rat.Male if random.random() < 0.5 else rat.Female
self.spawn_unit(rat_class, position)
def spawn_bomb(self, position):
if not self.can_place_weapon_at(position):
return
if self.ammo["bomb"]["count"] <= 0:
return
self.render_engine.play_sound("PUTDOWN.WAV")
@@ -60,7 +94,7 @@ class UnitManager:
"""Spawn a nuclear bomb at the specified position"""
if self.ammo["nuclear"]["count"] <= 0:
return
if self.map.is_wall(position[0], position[1]):
if not self.can_place_weapon_at(position):
return
self.render_engine.play_sound("NUCLEAR.WAV")
self.ammo["nuclear"]["count"] -= 1
@@ -69,7 +103,7 @@ class UnitManager:
def spawn_mine(self, position):
if self.ammo["mine"]["count"] <= 0:
return
if self.map.is_wall(position[0], position[1]):
if not self.can_place_weapon_at(position):
return
self.render_engine.play_sound("PUTDOWN.WAV")
self.ammo["mine"]["count"] -= 1
@@ -83,12 +117,11 @@ class UnitManager:
self.units[id] = unit(self, position, id, **kwargs)
def choose_start(self):
if not hasattr(self, '_valid_positions'):
self._valid_positions = [
(x, y) for y in range(1, self.map.height-1)
for x in range(1, self.map.width-1)
if self.map.matrix[y][x]
]
if not hasattr(self, '_valid_positions') or self._valid_positions is None:
self._valid_positions = self._spawnable_rat_positions()
print(f"[flow] choose_start computed {len(self._valid_positions)} spawnable cells", flush=True)
if not self._valid_positions:
return None
return random.choice(self._valid_positions)
def get_unit_by_id(self, id):
+82 -132
View File
@@ -9,24 +9,22 @@ import uuid
import platform
import hashlib
from datetime import datetime
from engine.score_api_client import ScoreAPIClient
from runtime_paths import DEFAULT_PROFILE_DATA, persistent_data_path
class UserProfileIntegration:
"""Integration layer between the game and profile system"""
def __init__(self, profiles_file="user_profiles.json", api_url="http://172.27.23.245:8000"):
self.profiles_file = profiles_file
def __init__(self, profiles_file="user_profiles.json"):
self.profiles_file = persistent_data_path(
profiles_file,
default_text=DEFAULT_PROFILE_DATA,
)
self.current_profile = None
self.device_id = self.generate_device_id()
self.api_client = ScoreAPIClient(api_url)
self.api_enabled = self.api_client.is_server_available()
self.api_enabled = False
self.load_active_profile()
if self.api_enabled:
print(f"✓ Connected to score server at {api_url}")
else:
print(f"✗ Score server not available at {api_url} - running offline")
def generate_device_id(self):
"""Generate a unique device ID based on system information"""
@@ -48,17 +46,12 @@ class UserProfileIntegration:
def load_active_profile(self):
"""Load the currently active profile"""
try:
with open(self.profiles_file, 'r') as f:
with self.profiles_file.open('r', encoding='utf-8') as f:
data = json.load(f)
active_name = data.get('active_profile')
if active_name and active_name in data['profiles']:
self.current_profile = data['profiles'][active_name]
print(f"Loaded profile: {self.current_profile['name']}")
# Sync with API if available
if self.api_enabled:
self.sync_profile_with_api()
return True
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f"Could not load profile: {e}")
@@ -80,40 +73,9 @@ class UserProfileIntegration:
return self.current_profile['settings'].get(setting_name, default_value)
return default_value
def sync_profile_with_api(self):
"""Ensure current profile is registered with the API server"""
if not self.current_profile or not self.api_enabled:
return False
profile_name = self.current_profile['name']
# Check if user exists on server
if not self.api_client.user_exists(self.device_id, profile_name):
print(f"Registering {profile_name} with score server...")
result = self.api_client.signup_user(self.device_id, profile_name)
if result.get('success'):
print(f"{profile_name} registered successfully")
return True
else:
print(f"✗ Failed to register {profile_name}: {result.get('message')}")
return False
else:
print(f"{profile_name} already registered on server")
return True
def register_new_user(self, user_id):
"""Register a new user both locally and on the API server"""
if not self.api_enabled:
print("API server not available - user will only be registered locally")
return True
result = self.api_client.signup_user(self.device_id, user_id)
if result.get('success'):
print(f"{user_id} registered with server successfully")
return True
else:
print(f"✗ Failed to register {user_id} with server: {result.get('message')}")
return False
"""Registration is handled locally via the profile manager."""
return True
def update_game_stats(self, score, completed=True):
"""Update the current profile's game statistics"""
@@ -121,42 +83,23 @@ class UserProfileIntegration:
print("No profile loaded - stats not saved")
return False
# Submit score to API first if available
if self.api_enabled:
profile_name = self.current_profile['name']
result = self.api_client.submit_score(
self.device_id,
profile_name,
score,
completed
)
if result.get('success'):
print(f"✓ Score {score} submitted to server successfully")
# Print server stats if available
if 'user_stats' in result:
stats = result['user_stats']
print(f" Server stats - Games: {stats['total_games']}, Best: {stats['best_score']}")
else:
print(f"✗ Failed to submit score to server: {result.get('message')}")
try:
# Update local profile
with open(self.profiles_file, 'r') as f:
with self.profiles_file.open('r', encoding='utf-8') as f:
data = json.load(f)
profile_name = self.current_profile['name']
if profile_name in data['profiles']:
profile = data['profiles'][profile_name]
# Update statistics
if completed:
profile['games_played'] += 1
print(f"Game completed for {profile_name}! Total games: {profile['games_played']}")
profile['total_score'] += score
profile['games_played'] += 1
profile['total_score'] += max(0, score)
if score > profile['best_score']:
profile['best_score'] = score
print(f"New best score for {profile_name}: {score}!")
if completed and 'first_win' not in profile['achievements']:
profile['achievements'].append('first_win')
profile['last_played'] = datetime.now().isoformat()
@@ -164,7 +107,7 @@ class UserProfileIntegration:
self.current_profile = profile
# Save back to file
with open(self.profiles_file, 'w') as f:
with self.profiles_file.open('w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
print(f"Local profile stats updated: Score +{score}, Total: {profile['total_score']}")
@@ -181,7 +124,7 @@ class UserProfileIntegration:
return False
try:
with open(self.profiles_file, 'r') as f:
with self.profiles_file.open('r', encoding='utf-8') as f:
data = json.load(f)
profile_name = self.current_profile['name']
@@ -192,7 +135,7 @@ class UserProfileIntegration:
profile['achievements'].append(achievement_id)
self.current_profile = profile
with open(self.profiles_file, 'w') as f:
with self.profiles_file.open('w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
print(f"Achievement unlocked for {profile_name}: {achievement_id}")
@@ -220,44 +163,72 @@ class UserProfileIntegration:
return None
def get_device_leaderboard(self, limit=10):
"""Get leaderboard for the current device from API server"""
if not self.api_enabled:
print("API server not available - cannot get leaderboard")
return []
leaderboard = self.api_client.get_leaderboard(self.device_id, limit)
return leaderboard
"""Get leaderboard for local profiles on this device."""
return self._get_local_leaderboard(limit)
def get_global_leaderboard(self, limit=10):
"""Get global leaderboard across all devices from API server"""
if not self.api_enabled:
print("API server not available - cannot get global leaderboard")
return []
leaderboard = self.api_client.get_global_leaderboard(limit)
return leaderboard
"""Global leaderboard falls back to local profiles for this standalone build."""
return self._get_local_leaderboard(limit)
def get_all_device_users(self):
"""Get all users registered for this device from API server"""
if not self.api_enabled:
print("API server not available - cannot get user list")
return []
users = self.api_client.get_device_users(self.device_id)
return users
leaderboard = self._get_local_leaderboard(limit=None)
return [
{
'user_id': entry['user_id'],
'best_score': entry['best_score'],
'total_games': entry['total_games'],
'device_id': entry['device_id'],
}
for entry in leaderboard
]
def get_user_server_scores(self, user_id=None, limit=10):
"""Get recent scores from server for a user (defaults to current profile)"""
if not self.api_enabled:
return []
"""Get recent local scores for a user (defaults to current profile)."""
if user_id is None:
if not self.current_profile:
return []
user_id = self.current_profile['name']
scores = self.api_client.get_user_scores(self.device_id, user_id, limit)
return scores
table = []
try:
score_file_path = persistent_data_path('scores.txt', default_text='')
with score_file_path.open(encoding='utf-8') as score_file:
for row in score_file.read().splitlines():
parts = row.split(' - ')
if len(parts) >= 4 and parts[2] == user_id:
table.append({
'last_play': parts[0],
'score': int(parts[1]),
'user_id': parts[2],
'device_id': parts[3],
})
except FileNotFoundError:
return []
table.sort(key=lambda entry: entry['score'], reverse=True)
return table[:limit]
def _get_local_leaderboard(self, limit=10):
try:
with self.profiles_file.open('r', encoding='utf-8') as profile_file:
data = json.load(profile_file)
except (FileNotFoundError, json.JSONDecodeError):
return []
entries = []
for profile in data.get('profiles', {}).values():
entries.append({
'user_id': profile.get('name', 'Unknown'),
'best_score': profile.get('best_score', 0),
'total_games': profile.get('games_played', 0),
'device_id': self.device_id,
'last_play': profile.get('last_played', ''),
})
entries.sort(key=lambda entry: (entry['best_score'], entry['total_games']), reverse=True)
if limit is None:
return entries
return entries[:limit]
def reload_profile(self):
"""Reload the current profile from disk (useful for external profile changes)"""
@@ -293,7 +264,7 @@ def get_global_leaderboard(limit=10):
if __name__ == "__main__":
# Test the integration
print("Testing User Profile Integration with API...")
print("Testing User Profile Integration...")
integration = UserProfileIntegration()
print(f"Device ID: {integration.get_device_id()}")
@@ -311,29 +282,8 @@ if __name__ == "__main__":
sound_volume = integration.get_setting('sound_volume', 50)
print(f"Settings - Difficulty: {difficulty}, Sound: {sound_volume}%")
# Test API features if connected
if integration.api_enabled:
print("\nTesting API features...")
# Get leaderboard
leaderboard = integration.get_device_leaderboard(5)
if leaderboard:
print("Device Leaderboard:")
for entry in leaderboard:
print(f" {entry['rank']}. {entry['user_id']}: {entry['best_score']} pts ({entry['total_games']} games)")
else:
print("No leaderboard data available")
# Get all users
users = integration.get_all_device_users()
print(f"\nTotal users on device: {len(users)}")
for user in users:
print(f" {user['user_id']}: Best {user['best_score']}, {user['total_scores']} games")
# Test score submission
if integration.current_profile:
print(f"\nTesting score submission for {integration.current_profile['name']}...")
result = integration.update_game_stats(1234, True)
print(f"Score update result: {result}")
else:
print("API features not available - server offline")
leaderboard = integration.get_device_leaderboard(5)
if leaderboard:
print("Local Leaderboard:")
for index, entry in enumerate(leaderboard, start=1):
print(f" {index}. {entry['user_id']}: {entry['best_score']} pts ({entry['total_games']} games)")