diff --git a/src/ecs/components/mirrored_pair.py b/src/ecs/components/mirrored_pair.py new file mode 100644 index 0000000..58c80e5 --- /dev/null +++ b/src/ecs/components/mirrored_pair.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python3 +# Copyright (c) 2023, Monaco F. J. +# +# This file is part of Naja. +# +# Naja is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Component for tracking mirrored snake pairs.""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass +class MirroredPair: + """Component that links two snakes in a mirrored relationship. + + Attributes: + partner_id: Entity ID of the mirrored partner snake + """ + + partner_id: Optional[int] = None diff --git a/src/ecs/prefabs/snake.py b/src/ecs/prefabs/snake.py index a6ff986..016124d 100644 --- a/src/ecs/prefabs/snake.py +++ b/src/ecs/prefabs/snake.py @@ -29,6 +29,7 @@ from ecs.components.renderable import Renderable from ecs.components.input_buffer import InputBuffer from ecs.components.hunger import Hunger +from ecs.components.mirrored_pair import MirroredPair from core.types.color import Color from game import constants @@ -45,6 +46,7 @@ def create_snake( autoplay_mode: bool = False, initial_x: Optional[int] = None, initial_y: Optional[int] = None, + mirrored_snake_id: Optional[int] = None, ) -> int: """Create a snake entity with all required components. @@ -60,6 +62,7 @@ def create_snake( autoplay_mode: Whether Autoplay Mode is enabled (starts with zero velocity) initial_x: Initial X position in grid coordinates (default: center) initial_y: Initial Y position in grid coordinates (default: center) + mirrored_snake_id: Entity ID of the mirrored snake (for Mirrored mode) Returns: int: Entity ID of created snake @@ -145,6 +148,10 @@ def create_snake( input_buffer=InputBuffer(), ) + # Add mirrored pair component if this is a mirrored snake + if mirrored_snake_id is not None: + snake.mirrored_pair = MirroredPair(partner_id=mirrored_snake_id) + # register entity with world and return ID entity_id = world.registry.add(snake) return entity_id diff --git a/src/ecs/systems/collision.py b/src/ecs/systems/collision.py index 4dd71b0..a344605 100644 --- a/src/ecs/systems/collision.py +++ b/src/ecs/systems/collision.py @@ -39,7 +39,11 @@ from ecs.systems.scoring import ScoringSystem from game.settings import GameSettings from game.services.audio_service import AudioService -from game.game_modes_registry import GAME_MODE_TELEPORT, PLAYER_VS_PLAYER_MODE_NAME +from game.game_modes_registry import ( + GAME_MODE_TELEPORT, + PLAYER_VS_PLAYER_MODE_NAME, + MIRRORED_MODE_NAME, +) from game.services.game_over_service import GameOverService @@ -104,13 +108,17 @@ def update(self, world: World) -> None: Args: world: ECS world to query entities """ - # Check if we're in PvP mode + # Check if we're in PvP or Mirrored mode game_state = self._get_game_state(world) is_pvp_mode = game_state and game_state.game_mode == PLAYER_VS_PLAYER_MODE_NAME + is_mirrored_mode = game_state and game_state.game_mode == MIRRORED_MODE_NAME if is_pvp_mode: # In PvP mode, check collisions for each snake individually self._check_all_snakes_collisions(world) + elif is_mirrored_mode: + # In Mirrored mode, check collisions for both snakes + self._check_mirrored_mode_collisions(world) else: # Single player mode - check collisions for the single snake # Check wall collision first (highest priority) @@ -386,6 +394,221 @@ def _check_all_snakes_collisions(self, world: World) -> None: # check apple collisions for all snakes self._check_apple_collision_all_snakes(world) + def _check_mirrored_mode_collisions(self, world: World) -> None: + """Check collisions for both mirrored snakes. + + Args: + world: ECS world + """ + from ecs.entities.entity import EntityType + + snakes = world.registry.query_by_type(EntityType.SNAKE) + snake_list = list(snakes.items()) + + if len(snake_list) < 2: + return + + snake1_id, snake1 = snake_list[0] + snake2_id, snake2 = snake_list[1] + + # Check wall collisions for both snakes + if self._check_wall_collision_for_snake( + world, snake1 + ) or self._check_wall_collision_for_snake(world, snake2): + self._handle_death(world, "Wall collision") + return + + # Check self-bite for both snakes + if self._check_self_bite_for_snake( + world, snake1 + ) or self._check_self_bite_for_snake(world, snake2): + self._handle_death(world, "Self-bite collision") + return + + # Check if snakes collide with each other + if self._check_mirrored_snakes_collision(world, snake1, snake2): + self._handle_death(world, "Snake collision") + return + + # Check obstacle collisions for both snakes + if self._check_obstacle_collision_for_snake( + world, snake1 + ) or self._check_obstacle_collision_for_snake(world, snake2): + self._handle_death(world, "Obstacle collision") + return + + # Check apple collisions with synchronized growth + self._check_mirrored_apple_collision(world) + + def _check_mirrored_snakes_collision(self, world: World, snake1, snake2) -> bool: + """Check if two mirrored snakes collide with each other. + + Args: + world: ECS world + snake1: First snake entity + snake2: Second snake entity + + Returns: + bool: True if collision detected + """ + if not hasattr(snake1, "position") or not hasattr(snake2, "position"): + return False + + # Check head-to-head collision + if ( + snake1.position.x == snake2.position.x + and snake1.position.y == snake2.position.y + ): + return True + + # Check if snake1 head hits snake2 body + if hasattr(snake2, "body"): + for segment in snake2.body.segments: + if snake1.position.x == segment.x and snake1.position.y == segment.y: + return True + + # Check if snake2 head hits snake1 body + if hasattr(snake1, "body"): + for segment in snake1.body.segments: + if snake2.position.x == segment.x and snake2.position.y == segment.y: + return True + + return False + + def _check_mirrored_apple_collision(self, world: World) -> None: + """Check apple collision for mirrored snakes with synchronized growth. + + When either snake eats an apple, both snakes grow. + + Args: + world: ECS world + """ + from ecs.entities.entity import EntityType + + snakes = world.registry.query_by_type(EntityType.SNAKE) + snake_list = list(snakes.values()) + + if len(snake_list) < 2: + return + + snake1 = snake_list[0] + snake2 = snake_list[1] + + # Check apple collision for both snakes + apples = world.registry.query_by_type(EntityType.APPLE) + for entity_id, apple in apples.items(): + if not hasattr(apple, "position"): + continue + + # Check if either snake ate the apple + snake1_ate = ( + hasattr(snake1, "position") + and snake1.position.x == apple.position.x + and snake1.position.y == apple.position.y + ) + snake2_ate = ( + hasattr(snake2, "position") + and snake2.position.x == apple.position.x + and snake2.position.y == apple.position.y + ) + + if snake1_ate or snake2_ate: + print( + f"APPLE EATEN IN MIRRORED MODE: apple=({apple.position.x},{apple.position.y})" + ) + + # Play apple eating sound + if self._audio_service: + self._audio_service.play_sound("assets/sound/eat.flac") + + # Grow BOTH snakes + if hasattr(snake1, "body"): + snake1.body.size += 1 + if hasattr(snake2, "body"): + snake2.body.size += 1 + + # Increment score + if self._scoring_system: + points = 1 + if hasattr(apple, "edible"): + points = apple.edible.points + self._scoring_system.on_apple_eaten(world, points) + + game_state = self._get_game_state(world) + if game_state: + game_state.apples_eaten_count += 1 + + # Apply (constant) speed ensures variables stay consistent + max_speed = ( + float(self._settings.get("max_speed")) if self._settings else 20.0 + ) + speed_increase_rate = ( + self._settings.get("speed_increase_rate") + if self._settings + else "10%" + ) + multiplier = 1.05 if speed_increase_rate == "5%" else 1.10 + if hasattr(snake1, "velocity"): + snake1.velocity.speed = min( + snake1.velocity.speed * multiplier, max_speed + ) + if hasattr(snake2, "velocity"): + snake2.velocity.speed = min( + snake2.velocity.speed * multiplier, max_speed + ) + + # Remove eaten apple + world.registry.remove(entity_id) + + # [FIX 2] Robust Apple Respawn Logic + from ecs.prefabs.apple import create_apple + import random + + occupied_positions = set() + + # Add snake positions (Force INT to match random grid coordinates) + if hasattr(snake1, "position"): + occupied_positions.add( + (int(snake1.position.x), int(snake1.position.y)) + ) + if hasattr(snake1, "body"): + for segment in snake1.body.segments: + occupied_positions.add((int(segment.x), int(segment.y))) + + if hasattr(snake2, "position"): + occupied_positions.add( + (int(snake2.position.x), int(snake2.position.y)) + ) + if hasattr(snake2, "body"): + for segment in snake2.body.segments: + occupied_positions.add((int(segment.x), int(segment.y))) + + # Add obstacles (Force INT) + obstacles = world.registry.query_by_type(EntityType.OBSTACLE) + for _, obstacle in obstacles.items(): + if hasattr(obstacle, "position"): + occupied_positions.add( + (int(obstacle.position.x), int(obstacle.position.y)) + ) + + # Find valid position for new apple + attempts = 0 + max_attempts = 1000 + while attempts < max_attempts: + x = random.randint(0, world.board.width - 1) + y = random.randint(0, world.board.height - 1) + + if (x, y) not in occupied_positions: + create_apple( + world, x=x, y=y, grid_size=world.board.cell_size, color=None + ) + print(f"NEW MIRRORED APPLE: ({x}, {y})") + break + + attempts += 1 + + return # Only process one apple per frame # Only process one apple per frame + def _check_wall_collision_for_snake(self, world: World, snake) -> bool: """Check wall collision for a specific snake. diff --git a/src/ecs/systems/input.py b/src/ecs/systems/input.py index 93cae52..42d1cf3 100644 --- a/src/ecs/systems/input.py +++ b/src/ecs/systems/input.py @@ -29,7 +29,7 @@ from ecs.systems.base_system import BaseSystem from ecs.world import World -from game.game_modes_registry import PLAYER_VS_PLAYER_MODE_NAME +from game.game_modes_registry import PLAYER_VS_PLAYER_MODE_NAME, MIRRORED_MODE_NAME class InputSystem(BaseSystem): @@ -175,6 +175,11 @@ def _buffer_direction( if not snake: return + # Handle mirrored mode: apply input to both snakes with opposite directions + if self._game_mode == MIRRORED_MODE_NAME: + self._buffer_mirrored_direction(world, snake, dx, dy) + return + # Mark game as started on first input game_state = self._get_game_state(world) if game_state and not game_state.game_started: @@ -205,6 +210,60 @@ def _buffer_direction( # Enqueue the new direction buf.moves.append((dx, dy)) + def _buffer_mirrored_direction(self, world: World, snake, dx: int, dy: int) -> None: + """Buffer direction for both mirrored snakes with opposite directions. + + Args: + world: ECS world + snake: The primary snake entity + dx: Horizontal direction for primary snake + dy: Vertical direction for primary snake + """ + from ecs.entities.entity import EntityType + + # Mark game as started on first input + game_state = self._get_game_state(world) + if game_state and not game_state.game_started: + game_state.game_started = True + + # Get both snakes + snakes = world.registry.query_by_type(EntityType.SNAKE) + snake_list = list(snakes.values()) + + if len(snake_list) < 2: + return + + snake1 = snake_list[0] + snake2 = snake_list[1] + + # Buffer direction for snake 1 + if hasattr(snake1, "input_buffer"): + buf1 = snake1.input_buffer + last_dx1, last_dy1 = (snake1.velocity.dx, snake1.velocity.dy) + if buf1.moves: + last_dx1, last_dy1 = buf1.moves[-1] + + if not ((dx != 0 and last_dx1 == -dx) or (dy != 0 and last_dy1 == -dy)): + if len(buf1.moves) < buf1.max_len: + buf1.moves.append((dx, dy)) + + # Buffer OPPOSITE direction for snake 2 (mirrored) + mirrored_dx = -dx + mirrored_dy = -dy + + if hasattr(snake2, "input_buffer"): + buf2 = snake2.input_buffer + last_dx2, last_dy2 = (snake2.velocity.dx, snake2.velocity.dy) + if buf2.moves: + last_dx2, last_dy2 = buf2.moves[-1] + + if not ( + (mirrored_dx != 0 and last_dx2 == -mirrored_dx) + or (mirrored_dy != 0 and last_dy2 == -mirrored_dy) + ): + if len(buf2.moves) < buf2.max_len: + buf2.moves.append((mirrored_dx, mirrored_dy)) + def _handle_keydown(self, world: World, key: int) -> None: """Handle key down events. diff --git a/src/ecs/systems/settings_apply.py b/src/ecs/systems/settings_apply.py index 97cec5b..e3f1418 100644 --- a/src/ecs/systems/settings_apply.py +++ b/src/ecs/systems/settings_apply.py @@ -157,9 +157,12 @@ def _apply_grid_size_change(self, world: World, desired_cells: int) -> None: """ # For autoplay mode, enforce even grid size (maze algorithm requires it) - from game.game_modes_registry import AUTOPLAY_MODE_NAME + from game.game_modes_registry import AUTOPLAY_MODE_NAME, MIRRORED_MODE_NAME - if self._game_mode == AUTOPLAY_MODE_NAME and desired_cells % 2 != 0: + if ( + self._game_mode == AUTOPLAY_MODE_NAME + or self._game_mode == MIRRORED_MODE_NAME + ) and desired_cells % 2 != 0: desired_cells += 1 # Round up to nearest even number # calculate optimal grid/cell size diff --git a/src/game/game_modes_registry.py b/src/game/game_modes_registry.py index 2f4b1fc..9872da6 100644 --- a/src/game/game_modes_registry.py +++ b/src/game/game_modes_registry.py @@ -32,6 +32,7 @@ TRAIL_MODE_NAME = "Trail Mode" LIGHTS_OUT_MODE_NAME = "Lights Out" PLAYER_VS_PLAYER_MODE_NAME = "Player vs Player" +MIRRORED_MODE_NAME = "Mirrored" ACTUAL_GAME_MODES = [ { @@ -89,6 +90,11 @@ "description": "Two players compete locally on the same board!", "detailed_info": "Grab a friend and compete head-to-head! Player 1 uses WASD, Player 2 uses Arrow keys. Each player has 3 lives and respawns after 3 seconds. Colliding with your opponent costs a life. Last player standing wins!", }, + { + "name": MIRRORED_MODE_NAME, + "description": "Two snakes move in opposite directions, mirrored across the board center.", + "detailed_info": "Control two snakes simultaneously! They move in opposite directions, perfectly mirrored across the board center. When one snake eats food, both grow. If they collide with each other, you lose. Requires careful spatial planning!", + }, ] RANDOM_MODE_LABEL = "Random" @@ -104,6 +110,7 @@ "TRAIL_MODE_NAME", "LIGHTS_OUT_MODE_NAME", "PLAYER_VS_PLAYER_MODE_NAME", + "MIRRORED_MODE_NAME", "ACTUAL_GAME_MODES", "RANDOM_MODE_LABEL", "RANDOM_MODE_INDEX", diff --git a/src/game/services/game_initializer.py b/src/game/services/game_initializer.py index b3bba33..d91aeef 100644 --- a/src/game/services/game_initializer.py +++ b/src/game/services/game_initializer.py @@ -38,6 +38,7 @@ TRAIL_MODE_NAME, LIGHTS_OUT_MODE_NAME, PLAYER_VS_PLAYER_MODE_NAME, + MIRRORED_MODE_NAME, ) @@ -147,6 +148,9 @@ def create_initial_entities(self, world: World) -> None: if self._game_mode == PLAYER_VS_PLAYER_MODE_NAME: # create 2 snakes for PvP mode self._create_pvp_snakes(world, grid_size) + elif self._game_mode == MIRRORED_MODE_NAME: + # create 2 mirrored snakes + self._create_mirrored_snakes(world, grid_size) else: # create single snake for other modes self._create_snake(world, grid_size) @@ -381,6 +385,78 @@ def _create_pvp_snakes(self, world: World, grid_size: int) -> None: snake.player_id = PlayerID(player_number=2, score=0) break + def _create_mirrored_snakes(self, world: World, grid_size: int) -> None: + """Create two mirrored snakes for Mirrored mode.""" + from ecs.prefabs.snake import create_snake + from ecs.entities.entity import EntityType + from core.types.color_utils import hex_to_rgb + + # [FIX] Use proportional spacing (1/4 width) so it works on ANY board size + snake1_x = world.board.width // 4 + snake1_y = world.board.height // 2 + + # Snake 2 is mirrored: (Width - 1 - X) + snake2_x = world.board.width - 1 - snake1_x + snake2_y = world.board.height - 1 - snake1_y + + # Get snake colors + snake_colors = self._settings.get_snake_colors() + head_color = hex_to_rgb(snake_colors["head"]) + tail_color = hex_to_rgb(snake_colors["tail"]) + + p2_colors = self._settings.get_player2_colors() + p2_head_color = hex_to_rgb(p2_colors["head"]) + p2_tail_color = hex_to_rgb(p2_colors["tail"]) + + # Create snake 1 + snake1_id = create_snake( + world=world, + grid_size=grid_size, + initial_speed=4.0, # [FIX] Hardcode slower start speed for Mirrored Mode + head_color=head_color, + tail_color=tail_color, + enable_hunger=False, + cheese_mode=False, + shrinking_mode=False, + autoplay_mode=False, + initial_x=snake1_x, + initial_y=snake1_y, + mirrored_snake_id=None, + ) + + # Create snake 2 + snake2_id = create_snake( + world=world, + grid_size=grid_size, + initial_speed=4.0, # [FIX] Hardcode slower start speed for Mirrored Mode + head_color=p2_head_color, + tail_color=p2_tail_color, + enable_hunger=False, + cheese_mode=False, + shrinking_mode=False, + autoplay_mode=False, + initial_x=snake2_x, + initial_y=snake2_y, + mirrored_snake_id=snake1_id, + ) + + # [FIX] Invert Snake 2's velocity to face LEFT (-1, 0) + # Default is RIGHT (1, 0). If we don't fix this, the first "Left" input + # is rejected as a 180-degree turn, causing Snake 2 to follow Snake 1. + snake2 = world.registry.get(snake2_id) + if snake2 and hasattr(snake2, "velocity"): + snake2.velocity.dx = -1 + snake2.velocity.dy = 0 + + # Update snake1's mirrored_pair + snakes = world.registry.query_by_type(EntityType.SNAKE) + for snake_id, snake in snakes.items(): + if snake_id == snake1_id: + from ecs.components.mirrored_pair import MirroredPair + + snake.mirrored_pair = MirroredPair(partner_id=snake2_id) + break + def _create_pvp_apples(self, world: World, grid_size: int) -> None: """Create 2 apples for Player vs Player mode. @@ -652,8 +728,11 @@ def _sync_board_with_settings(self, world: World) -> None: desired_cells = self._settings.get("cells_per_side") actual_cells = world.board.width # board is always square + # Define modes that require even boards + requires_even = self._game_mode in [AUTOPLAY_MODE_NAME, MIRRORED_MODE_NAME] + # if board doesn't match settings, recreate it - if desired_cells != actual_cells: + if desired_cells != actual_cells or (requires_even and actual_cells % 2 != 0): # need config to calculate optimal sizes if not self._config: from game.config import GameConfig @@ -665,8 +744,10 @@ def _sync_board_with_settings(self, world: World) -> None: # ensure minimum size desired_cells = max(10, int(desired_cells)) - # For autoplay mode, enforce even grid size (maze algorithm requires it) - if self._game_mode == AUTOPLAY_MODE_NAME and desired_cells % 2 != 0: + # Trigger update if settings changed OR if mode needs even board but has odd + if desired_cells != actual_cells or ( + requires_even and actual_cells % 2 != 0 + ): desired_cells += 1 # Round up to nearest even number # calculate optimal grid/cell size @@ -676,11 +757,20 @@ def _sync_board_with_settings(self, world: World) -> None: new_width_pixels, new_height_pixels = config.calculate_window_size( new_cell_size ) - # calculate board dimensions in cells new_width_cells = new_width_pixels // new_cell_size new_height_cells = new_height_pixels // new_cell_size + if requires_even: + if new_width_cells % 2 != 0: + new_width_cells -= 1 + if new_height_cells % 2 != 0: + new_height_cells -= 1 + + # CRITICAL: Recalculate pixels so window matches the board exactly + new_width_pixels = new_width_cells * new_cell_size + new_height_pixels = new_height_cells * new_cell_size + # create a new board with the new dimensions from ecs.board import Board diff --git a/src/game/settings.py b/src/game/settings.py index 29e5560..cf32044 100644 --- a/src/game/settings.py +++ b/src/game/settings.py @@ -414,8 +414,12 @@ def format_setting_value( grid_str = f"{int(value)} × {int(value)}" # Add note for AutoPlay mode about even grid requirement from game.game_modes_registry import AUTOPLAY_MODE_NAME + from game.game_modes_registry import MIRRORED_MODE_NAME - if self._current_game_mode == AUTOPLAY_MODE_NAME: + if ( + self._current_game_mode == AUTOPLAY_MODE_NAME + or self._current_game_mode == MIRRORED_MODE_NAME + ): grid_str += " (even only)" return grid_str elif field["key"] == "obstacle_difficulty":