Implement stuck snake mechanics, persistent colors, and length display

Major gameplay changes:
- Snakes no longer die from collisions
- When blocked, snakes get "stuck" - head stays in place, tail shrinks by 1 per tick
- Snakes auto-unstick when obstacle clears (other snakes move/shrink away)
- Minimum snake length is 1 (head-only)
- Game runs continuously without rounds or game-over state

Color system:
- Each player gets a persistent color for their entire connection
- Colors assigned on join, rotate through available colors
- Color follows player even after disconnect/reconnect
- Works for both desktop and web clients

Display improvements:
- Show snake length instead of score
- Length accurately reflects current snake size
- Updates in real-time as snakes grow/shrink

Server fixes:
- Fixed HTTP server initialization issues
- Changed default host to 0.0.0.0 for network multiplayer
- Improved file serving with proper routing

Testing:
- Updated all collision tests for stuck mechanics
- Added tests for stuck/unstick behavior
- Added tests for color persistence
- All 12 tests passing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Vladyslav Doloman
2025-10-04 16:39:30 +03:00
parent ec8e9cd5fb
commit 84a58038f7
10 changed files with 271 additions and 133 deletions

View File

@@ -61,7 +61,7 @@ class TestGameLogic:
assert len(snake.body) == initial_length
def test_collision_with_wall(self) -> None:
"""Test collision detection with walls."""
"""Test collision detection with walls - snake gets stuck."""
logic = GameLogic()
# Snake at left wall
@@ -72,13 +72,22 @@ class TestGameLogic:
], direction=LEFT)
logic.state.snakes.append(snake)
logic.move_snakes()
logic.check_collisions()
initial_length = len(snake.body)
head_pos = snake.get_head()
assert snake.alive is False
logic.move_snakes()
# Snake should be stuck, not dead
assert snake.stuck is True
assert snake.alive is True
# Head position should not change
assert snake.get_head().x == head_pos.x
assert snake.get_head().y == head_pos.y
# Tail should shrink by 1
assert len(snake.body) == initial_length - 1
def test_collision_with_self(self) -> None:
"""Test collision detection with self."""
"""Test collision detection with self - snake gets stuck then auto-unsticks."""
logic = GameLogic()
# Create a snake that will hit itself
@@ -90,10 +99,19 @@ class TestGameLogic:
], direction=DOWN)
logic.state.snakes.append(snake)
# First move: should get stuck
logic.move_snakes()
logic.check_collisions()
assert snake.stuck is True
assert snake.alive is True
assert len(snake.body) == 3 # Shrunk by 1
assert snake.alive is False
# Continue moving: tail shrinks, eventually unsticks
logic.move_snakes()
assert len(snake.body) == 2 # Shrunk again
logic.move_snakes()
# After enough shrinking, blocking segment is gone, snake unsticks
assert len(snake.body) >= 1
def test_food_eating(self) -> None:
"""Test snake eating food and growing."""
@@ -121,28 +139,116 @@ class TestGameLogic:
assert snake.score == initial_score + 10
assert food not in logic.state.food
def test_is_game_over(self) -> None:
"""Test game over detection."""
def test_stuck_minimum_length(self) -> None:
"""Test that snake cannot shrink below length 1."""
logic = GameLogic()
# No game over with multiple alive snakes
snake1 = Snake(player_id="player1", alive=True)
snake2 = Snake(player_id="player2", alive=True)
logic.state.snakes = [snake1, snake2]
# Create length-1 snake stuck against wall
snake = Snake(player_id="player1", body=[Position(0, 5)], direction=LEFT)
logic.state.snakes.append(snake)
assert logic.is_game_over() is False
# Move multiple times - should stay stuck, not shrink below 1
for _ in range(5):
logic.move_snakes()
assert len(snake.body) == 1
assert snake.stuck is True
assert snake.alive is True
# Game over when only one snake alive
snake2.alive = False
assert logic.is_game_over() is True
def test_get_winner(self) -> None:
"""Test winner determination."""
def test_other_snake_blocks(self) -> None:
"""Test snake getting stuck on another snake."""
logic = GameLogic()
snake1 = Snake(player_id="player1", alive=True)
snake2 = Snake(player_id="player2", alive=False)
logic.state.snakes = [snake1, snake2]
# Snake A trying to move into Snake B
snake_a = Snake(player_id="player_a", body=[
Position(5, 5),
Position(4, 5),
], direction=RIGHT)
snake_b = Snake(player_id="player_b", body=[
Position(6, 5), # Blocking Snake A
Position(7, 5),
], direction=RIGHT)
logic.state.snakes.extend([snake_a, snake_b])
logic.move_snakes()
# Snake A should be stuck
assert snake_a.stuck is True
assert len(snake_a.body) == 1 # Shrunk by 1
def test_other_snake_moves_away(self) -> None:
"""Test snake auto-unsticks when other snake moves away."""
logic = GameLogic()
# Snake A stuck behind Snake B
snake_a = Snake(player_id="player_a", body=[
Position(5, 5),
Position(4, 5),
Position(3, 5),
], direction=RIGHT)
snake_b = Snake(player_id="player_b", body=[
Position(6, 5), # Blocking at (6,5)
Position(6, 6), # Tail at (6,6)
], direction=UP) # Will move up
logic.state.snakes.extend([snake_a, snake_b])
# Tick 1: Snake A gets stuck, Snake B moves to (6,4)-(6,5)
logic.move_snakes()
assert snake_a.stuck is True
assert len(snake_a.body) == 2 # Shrunk by 1
# Tick 2: Snake B moves to (6,3)-(6,4), still stuck (shrunk to length 1)
logic.move_snakes()
assert snake_a.stuck is True
assert len(snake_a.body) == 1 # Shrunk to minimum
# Tick 3: Position (6,5) clear, Snake A finally unsticks!
logic.move_snakes()
assert snake_a.stuck is False
assert snake_a.get_head().x == 6 # Moved forward to (6,5)
def test_direction_change_unsticks(self) -> None:
"""Test changing direction to clear path unsticks snake."""
logic = GameLogic()
# Snake stuck against wall facing left
snake = Snake(player_id="player1", body=[
Position(0, 5),
Position(1, 5),
], direction=LEFT)
logic.state.snakes.append(snake)
# Get stuck
logic.move_snakes()
assert snake.stuck is True
# Change direction to up (clear path)
logic.update_snake_direction("player1", UP)
# Next move should unstick
logic.move_snakes()
assert snake.stuck is False
assert snake.get_head().y == 4 # Moved up
def test_two_snakes_head_to_head(self) -> None:
"""Test two snakes stuck head-to-head both shrink."""
logic = GameLogic()
snake_a = Snake(player_id="player_a", body=[
Position(5, 5),
Position(4, 5),
], direction=RIGHT)
snake_b = Snake(player_id="player_b", body=[
Position(6, 5),
Position(7, 5),
], direction=LEFT)
logic.state.snakes.extend([snake_a, snake_b])
logic.move_snakes()
# Both snakes should be stuck
assert snake_a.stuck is True
assert snake_b.stuck is True
# Both should shrink
assert len(snake_a.body) == 1
assert len(snake_b.body) == 1
winner = logic.get_winner()
assert winner == "player1"