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:
+7
-3
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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)")
|
||||
|
||||
Reference in New Issue
Block a user