You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
354 lines
14 KiB
354 lines
14 KiB
#!/usr/bin/python3 |
|
|
|
import random |
|
import os |
|
import json |
|
|
|
from engine import maze, controls, graphics, pygame_layer as engine, unit_manager, scoring |
|
from units import points |
|
from engine.user_profile_integration import UserProfileIntegration |
|
|
|
|
|
class MiceMaze( |
|
controls.KeyBindings, |
|
unit_manager.UnitManager, |
|
graphics.Graphics, |
|
scoring.Scoring |
|
): |
|
|
|
# ==================== INITIALIZATION ==================== |
|
|
|
def __init__(self, maze_file): |
|
# Initialize user profile integration |
|
print("[DEBUG] Initializing user profile integration...") |
|
self.profile_integration = UserProfileIntegration() |
|
print(f"[DEBUG] Profile integration initialized. Has current_profile: {self.profile_integration.current_profile is not None}") |
|
|
|
if self.profile_integration.current_profile: |
|
print(f"[DEBUG] Current profile: {self.profile_integration.get_profile_name()}") |
|
else: |
|
print("[DEBUG] No profile loaded, will use default settings") |
|
|
|
#self.profile_integration = None |
|
|
|
self.map = maze.Map(maze_file) |
|
|
|
# Load profile'-specific settings |
|
if self.profile_integration is None: |
|
self.audio = True |
|
sound_volume = 50 |
|
else: |
|
self.audio = self.profile_integration.get_setting('sound_enabled', True) |
|
sound_volume = self.profile_integration.get_setting('sound_volume', 50) |
|
|
|
self.cell_size = 40 |
|
self.full_screen = False |
|
|
|
# Initialize render engine with profile-aware title |
|
if self.profile_integration is None: |
|
player_name = "Guest" |
|
else: |
|
player_name = self.profile_integration.get_profile_name() |
|
window_title = f"Mice! - {player_name}" |
|
|
|
# If running under Pyodide in the browser, ensure the JS canvas is bound |
|
# to Pyodide's pygame integration _before_ creating the display. This |
|
# avoids cases where pygame.display.set_mode is called before the HTML |
|
# canvas is attached, which would result in a blank canvas in the page. |
|
try: |
|
# 'js' is available under Pyodide as a proxy to the global window |
|
import js |
|
try: |
|
canvas_el = js.document.getElementById('canvas') |
|
if hasattr(js.pyodide, 'canvas') and hasattr(js.pyodide.canvas, 'setCanvas2D'): |
|
js.pyodide.canvas.setCanvas2D(canvas_el) |
|
except Exception: |
|
# Non-fatal: continue with engine initialization |
|
pass |
|
except Exception: |
|
# Not running under Pyodide (native run), ignore |
|
pass |
|
|
|
self.render_engine = engine.GameWindow(self.map.width, self.map.height, |
|
self.cell_size, window_title, |
|
key_callback=self.trigger) |
|
|
|
# Apply profile settings |
|
if hasattr(self.render_engine, 'set_volume'): |
|
self.render_engine.set_volume(sound_volume) |
|
|
|
self.load_assets() |
|
# Show window (for pygame, this is implicit; for SDL2 it's explicit) |
|
if hasattr(self.render_engine.window, 'show'): |
|
self.render_engine.window.show() |
|
self.pointer = (random.randint(1, self.map.width-2), random.randint(1, self.map.height-2)) |
|
self.scroll_cursor() |
|
self.points = 0 |
|
self.units = {} |
|
self.unit_positions = {} |
|
self.unit_positions_before = {} |
|
self.scrolling_direction = None |
|
self.game_status = "start_menu" |
|
self.game_end = (False, None) |
|
self.scrolling = False |
|
self.sounds = {} |
|
self.start_game() |
|
# If running under Pyodide, try to force a single-frame render of the |
|
# start menu so the dialog is visible even before the JS-driven |
|
# requestAnimationFrame loop begins. This helps when the browser |
|
# scheduling would otherwise miss the initial dialog draw. |
|
try: |
|
import js |
|
try: |
|
self.show_start_dialog() |
|
except Exception: |
|
# Non-fatal if the helper can't run now |
|
pass |
|
except Exception: |
|
# Not running in Pyodide - ignore |
|
pass |
|
self.background_texture = None |
|
self.configs = self.get_config() |
|
self.combined_scores = None |
|
|
|
|
|
def get_config(self): |
|
configs = {} |
|
for file in os.listdir("conf"): |
|
if file.endswith(".json"): |
|
with open(os.path.join("conf", file)) as f: |
|
configs[file[:-5]] = json.load(f) |
|
return configs |
|
|
|
def start_game(self): |
|
self.combined_scores = False |
|
self.ammo = { |
|
"bomb": { |
|
"count": 2, |
|
"max": 8 |
|
}, |
|
"nuclear": { |
|
"count": 11, |
|
"max": 1 |
|
}, |
|
"mine": { |
|
"count": 2, |
|
"max": 4 |
|
}, |
|
"gas": { |
|
"count": 2, |
|
"max": 4 |
|
} |
|
} |
|
self.blood_stains = {} |
|
self.background_texture = None |
|
for _ in range(5): |
|
self.spawn_rat() |
|
|
|
def reset_game(self): |
|
self.pause = False |
|
self.game_status = "game" |
|
self.game_end = (False, None) |
|
self.units.clear() |
|
self.points = 0 |
|
self.start_game() |
|
|
|
|
|
# ==================== GAME LOGIC ==================== |
|
|
|
def refill_ammo(self): |
|
for ammo_type, data in self.ammo.items(): |
|
if ammo_type == "bomb": |
|
if random.random() < 0.02: |
|
data["count"] = min(data["count"] + 1, data["max"]) |
|
elif ammo_type == "mine": |
|
if random.random() < 0.05: |
|
data["count"] = min(data["count"] + 1, data["max"]) |
|
elif ammo_type == "gas": |
|
if random.random() < 0.01: |
|
data["count"] = min(data["count"] + 1, data["max"]) |
|
|
|
def update_maze(self): |
|
if self.game_over(): |
|
return |
|
if self.game_status == "paused": |
|
self.render_engine.dialog("Pause") |
|
return |
|
if self.game_status == "start_menu": |
|
# Create personalized greeting |
|
if self.profile_integration: |
|
player_name = self.profile_integration.get_profile_name() |
|
else: |
|
player_name = "Guest" |
|
if self.profile_integration: |
|
device_id = self.profile_integration.get_device_id() |
|
else: |
|
device_id = "Unknown Device" |
|
|
|
greeting_title = f"Welcome to Mice, {player_name}!" |
|
|
|
# Build subtitle with proper formatting |
|
subtitle = "A game by Matteo, because he was bored." |
|
device_line = f"Device: {device_id}" |
|
|
|
# Show profile stats if available |
|
if self.profile_integration and self.profile_integration.current_profile: |
|
profile = self.profile_integration.current_profile |
|
stats_line = f"Best Score: {profile['best_score']} | Games: {profile['games_played']}" |
|
full_subtitle = f"{subtitle}\n{device_line}\n{stats_line}" |
|
else: |
|
full_subtitle = f"{device_line}\nNo profile loaded - playing as guest" |
|
|
|
self.render_engine.dialog(greeting_title, |
|
subtitle=full_subtitle, |
|
image=self.assets["BMP_WEWIN"]) |
|
return |
|
self.render_engine.delete_tag("unit") |
|
self.render_engine.delete_tag("effect") |
|
self.render_engine.draw_pointer(self.pointer[0] * self.cell_size, self.pointer[1] * self.cell_size) |
|
self.unit_positions.clear() |
|
self.unit_positions_before.clear() |
|
for unit in self.units.values(): |
|
self.unit_positions.setdefault(unit.position, []).append(unit) |
|
self.unit_positions_before.setdefault(unit.position_before, []).append(unit) |
|
for unit in self.units.copy().values(): |
|
unit.move() |
|
unit.collisions() |
|
unit.draw() |
|
self.render_engine.update_status(f"Mice: {self.count_rats()} - Points: {self.points}") |
|
self.refill_ammo() |
|
self.render_engine.update_ammo(self.ammo, self.assets) |
|
self.scroll() |
|
self.render_engine.new_cycle(50, self.update_maze) |
|
|
|
def tick(self): |
|
"""Run a single frame tick: update logic + background update (non-blocking). |
|
Intended to be called repeatedly from the browser via requestAnimationFrame. |
|
""" |
|
try: |
|
# Use render_engine.step() to run one frame iteration (draw + flip) |
|
if hasattr(self.render_engine, 'step'): |
|
self.render_engine.step(update=self.update_maze, bg_update=self.draw_maze) |
|
else: |
|
# Fallback: call update_maze directly |
|
self.update_maze() |
|
except Exception: |
|
pass |
|
|
|
def show_start_dialog(self): |
|
"""Force a single-frame render of the start menu/dialog. |
|
|
|
This calls the underlying render engine's `step` method once with |
|
a small update callback that draws the dialog. Useful under Pyodide |
|
where the JS-driven animation loop may start slightly later and the |
|
initial dialog could be missed. |
|
""" |
|
try: |
|
# Reconstruct the greeting and subtitle similar to update_maze |
|
if self.profile_integration: |
|
player_name = self.profile_integration.get_profile_name() |
|
device_id = self.profile_integration.get_device_id() |
|
else: |
|
player_name = 'Guest' |
|
device_id = 'Unknown Device' |
|
|
|
greeting_title = f"Welcome to Mice, {player_name}!" |
|
|
|
if self.profile_integration and self.profile_integration.current_profile: |
|
profile = self.profile_integration.current_profile |
|
stats_line = f"Best Score: {profile.get('best_score', 0)} | Games: {profile.get('games_played', 0)}" |
|
full_subtitle = f"A game by Matteo, because he was bored.\nDevice: {device_id}\n{stats_line}" |
|
else: |
|
full_subtitle = f"A game by Matteo, because he was bored.\nDevice: {device_id}\nNo profile loaded - playing as guest" |
|
|
|
def do_dialog(): |
|
try: |
|
self.render_engine.dialog(greeting_title, |
|
subtitle=full_subtitle, |
|
image=self.assets.get('BMP_WEWIN')) |
|
except Exception: |
|
pass |
|
|
|
if hasattr(self.render_engine, 'step'): |
|
# Draw background and then the dialog, flip once |
|
self.render_engine.step(update=do_dialog, bg_update=self.draw_maze) |
|
else: |
|
do_dialog() |
|
except Exception as e: |
|
print('[DEBUG] show_start_dialog failed:', e) |
|
|
|
def run(self): |
|
self.render_engine.mainloop(update=self.update_maze, bg_update=self.draw_maze) |
|
|
|
# ==================== GAME OVER LOGIC ==================== |
|
|
|
def game_over(self): |
|
if self.game_end[0]: |
|
if not self.combined_scores: |
|
self.combined_scores = self.profile_integration.get_global_leaderboard(4) |
|
global_scores = [] |
|
|
|
for entry in self.combined_scores: |
|
# Convert to format expected by dialog: [date, score, name, device] |
|
global_scores.append([ |
|
entry.get('last_play', ''), |
|
entry.get('best_score', 0), |
|
entry.get('user_id', 'Unknown'), |
|
|
|
]) |
|
|
|
if not self.game_end[1]: |
|
self.render_engine.dialog( |
|
"Game Over: Mice are too many!", |
|
image=self.assets["BMP_WEWIN"], |
|
scores=global_scores |
|
) |
|
else: |
|
self.render_engine.dialog( |
|
f"You Win! Points: {self.points}", |
|
image=self.assets["BMP_WEWIN"], |
|
scores=global_scores |
|
) |
|
|
|
|
|
return True |
|
count_rats = self.count_rats() |
|
if count_rats > 200: |
|
print("[DEBUG GAME_OVER] Loss condition: rats > 200") |
|
print(f"[DEBUG GAME_OVER] Rat count: {count_rats}, Points: {self.points}") |
|
self.render_engine.stop_sound() |
|
self.render_engine.play_sound("WEWIN.WAV") |
|
self.game_end = (True, False) |
|
self.game_status = "paused" |
|
|
|
# Track incomplete game in profile |
|
print(f"[DEBUG GAME_OVER] Calling update_game_stats(completed=False)") |
|
self.profile_integration.update_game_stats(self.points, completed=False) |
|
|
|
return True |
|
if not count_rats and not any(isinstance(unit, points.Point) for unit in self.units.values()): |
|
print("[DEBUG GAME_OVER] Win condition: all rats and points cleared") |
|
print(f"[DEBUG GAME_OVER] Points earned: {self.points}") |
|
self.render_engine.stop_sound() |
|
self.render_engine.play_sound("VICTORY.WAV") |
|
self.render_engine.play_sound("WELLDONE.WAV", tag="effects") |
|
self.game_end = (True, True) |
|
self.game_status = "paused" |
|
|
|
# Save score to both traditional file and user profile |
|
print(f"[DEBUG GAME_OVER] Calling save_score()") |
|
self.save_score() |
|
print(f"[DEBUG GAME_OVER] Calling update_game_stats(completed=True)") |
|
self.profile_integration.update_game_stats(self.points, completed=True) |
|
|
|
return True |
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__": |
|
print("Game starting...") |
|
solver = MiceMaze('maze.json') |
|
solver.run() |
|
|
|
|