Add input buffering, auto-start, and gameplay improvements
Input buffer system: - Added 3-slot direction input buffer to handle rapid key presses - Buffer ignores duplicate inputs (same key pressed multiple times) - Opposite direction replaces last buffered input (e.g., LEFT→RIGHT replaces LEFT) - Buffer overflow replaces last slot when full - Multi-segment snakes skip invalid 180° turns when consuming buffer - Head-only snakes (length=1) can turn 180° for flexibility Gameplay improvements: - Desktop client auto-starts game on connect (no SPACE needed) - Field populates with 3 apples when no players connected - HTTP server now binds to 0.0.0.0 for network access (matches game server) Testing: - Added 7 new tests for input buffer functionality - Added test for zero-player apple spawning - All 19 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -80,8 +80,7 @@ async def main() -> None:
|
||||
web_dir = Path(args.web_dir)
|
||||
if web_dir.exists():
|
||||
# Use same host as game server for HTTP
|
||||
http_host = args.host if args.host != "0.0.0.0" else "localhost"
|
||||
http_server = HTTPServer(web_dir, args.http_port, http_host)
|
||||
http_server = HTTPServer(web_dir, args.http_port, args.host)
|
||||
await http_server.start()
|
||||
else:
|
||||
print(f"Warning: Web directory '{web_dir}' not found. HTTP server disabled.")
|
||||
|
||||
@@ -62,6 +62,10 @@ class GameClient:
|
||||
# Send JOIN message
|
||||
await self.send_message(create_join_message(self.player_name))
|
||||
|
||||
# Automatically start the game
|
||||
await self.send_message(create_start_game_message())
|
||||
print("Starting game...")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Failed to connect to server: {e}")
|
||||
raise
|
||||
|
||||
@@ -104,7 +104,7 @@ class GameLogic:
|
||||
return False
|
||||
|
||||
def update_snake_direction(self, player_id: str, direction: Tuple[int, int]) -> None:
|
||||
"""Update a snake's direction if valid.
|
||||
"""Update a snake's direction by adding to input buffer.
|
||||
|
||||
Args:
|
||||
player_id: Player whose snake to update
|
||||
@@ -112,9 +112,19 @@ class GameLogic:
|
||||
"""
|
||||
for snake in self.state.snakes:
|
||||
if snake.player_id == player_id and snake.alive:
|
||||
# Prevent 180-degree turns
|
||||
if direction != OPPOSITE_DIRECTIONS.get(snake.direction):
|
||||
snake.direction = direction
|
||||
# Don't add duplicate inputs (same as last in buffer)
|
||||
if snake.input_buffer and snake.input_buffer[-1] == direction:
|
||||
break
|
||||
|
||||
# If opposite to last in buffer, replace it
|
||||
if snake.input_buffer and direction == OPPOSITE_DIRECTIONS.get(snake.input_buffer[-1]):
|
||||
snake.input_buffer[-1] = direction
|
||||
# If buffer not full, append
|
||||
elif len(snake.input_buffer) < 3:
|
||||
snake.input_buffer.append(direction)
|
||||
# Buffer full, replace last slot
|
||||
else:
|
||||
snake.input_buffer[-1] = direction
|
||||
break
|
||||
|
||||
def move_snakes(self) -> None:
|
||||
@@ -123,6 +133,18 @@ class GameLogic:
|
||||
if not snake.alive: # Skip disconnected players
|
||||
continue
|
||||
|
||||
# Consume direction from input buffer if available
|
||||
while snake.input_buffer:
|
||||
buffered_direction = snake.input_buffer.pop(0)
|
||||
|
||||
# Skip 180-degree turns for multi-segment snakes
|
||||
if len(snake.body) > 1 and buffered_direction == OPPOSITE_DIRECTIONS.get(snake.direction):
|
||||
continue # Skip this buffered input, try next
|
||||
|
||||
# Valid direction from buffer
|
||||
snake.direction = buffered_direction
|
||||
break
|
||||
|
||||
# Calculate next position based on current direction
|
||||
next_position = snake.get_head() + snake.direction
|
||||
|
||||
@@ -160,6 +182,14 @@ class GameLogic:
|
||||
"""Perform one game tick: move snakes and spawn food."""
|
||||
self.move_snakes()
|
||||
|
||||
# Spawn food if needed
|
||||
if len(self.state.food) < len([s for s in self.state.snakes if s.alive]):
|
||||
self.state.food.append(self.spawn_food())
|
||||
# Spawn food based on player count
|
||||
alive_snakes = [s for s in self.state.snakes if s.alive]
|
||||
|
||||
if len(alive_snakes) == 0:
|
||||
# No players - populate field with 3 apples
|
||||
while len(self.state.food) < 3:
|
||||
self.state.food.append(self.spawn_food())
|
||||
else:
|
||||
# Normal game - 1 food per alive snake
|
||||
if len(self.state.food) < len(alive_snakes):
|
||||
self.state.food.append(self.spawn_food())
|
||||
|
||||
@@ -35,6 +35,7 @@ class Snake:
|
||||
stuck: bool = False # True when snake is blocked and shrinking
|
||||
color_index: int = 0 # Index in COLOR_SNAKES array for persistent color
|
||||
player_name: str = "" # Human-readable player name
|
||||
input_buffer: List[Tuple[int, int]] = field(default_factory=list) # Buffer for pending direction changes (max 3)
|
||||
|
||||
def get_head(self) -> Position:
|
||||
"""Get the head position of the snake."""
|
||||
@@ -51,6 +52,7 @@ class Snake:
|
||||
"stuck": self.stuck,
|
||||
"color_index": self.color_index,
|
||||
"player_name": self.player_name,
|
||||
"input_buffer": self.input_buffer,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -64,6 +66,7 @@ class Snake:
|
||||
snake.stuck = data.get("stuck", False) # Default to False for backward compatibility
|
||||
snake.color_index = data.get("color_index", 0) # Default to 0 for backward compatibility
|
||||
snake.player_name = data.get("player_name", "") # Default to empty string for backward compatibility
|
||||
snake.input_buffer = [tuple(d) for d in data.get("input_buffer", [])] # Default to empty list for backward compatibility
|
||||
return snake
|
||||
|
||||
|
||||
|
||||
@@ -29,18 +29,20 @@ class TestGameLogic:
|
||||
assert 0 <= food.position.y < GRID_HEIGHT
|
||||
|
||||
def test_update_snake_direction(self) -> None:
|
||||
"""Test updating snake direction."""
|
||||
"""Test updating snake direction via input buffer."""
|
||||
logic = GameLogic()
|
||||
snake = logic.create_snake("player1")
|
||||
logic.state.snakes.append(snake)
|
||||
|
||||
# Valid direction change
|
||||
# Direction changes go into buffer first
|
||||
logic.update_snake_direction("player1", UP)
|
||||
assert snake.direction == UP
|
||||
assert snake.input_buffer == [UP]
|
||||
assert snake.direction == RIGHT # Original direction unchanged
|
||||
|
||||
# Invalid 180-degree turn (should be ignored)
|
||||
logic.update_snake_direction("player1", DOWN)
|
||||
assert snake.direction == UP # Should remain UP
|
||||
# Moving consumes from buffer
|
||||
logic.move_snakes()
|
||||
assert snake.direction == UP # Now changed after movement
|
||||
assert len(snake.input_buffer) == 0
|
||||
|
||||
def test_move_snakes(self) -> None:
|
||||
"""Test snake movement."""
|
||||
@@ -252,3 +254,117 @@ class TestGameLogic:
|
||||
assert len(snake_a.body) == 1
|
||||
assert len(snake_b.body) == 1
|
||||
|
||||
def test_input_buffer_fills_to_three(self) -> None:
|
||||
"""Test input buffer fills up to 3 directions."""
|
||||
logic = GameLogic()
|
||||
snake = logic.create_snake("player1")
|
||||
logic.state.snakes.append(snake)
|
||||
|
||||
# Add 3 different directions
|
||||
logic.update_snake_direction("player1", UP)
|
||||
logic.update_snake_direction("player1", LEFT)
|
||||
logic.update_snake_direction("player1", DOWN)
|
||||
|
||||
assert len(snake.input_buffer) == 3
|
||||
assert snake.input_buffer == [UP, LEFT, DOWN]
|
||||
|
||||
def test_input_buffer_ignores_duplicates(self) -> None:
|
||||
"""Test input buffer ignores duplicate inputs."""
|
||||
logic = GameLogic()
|
||||
snake = logic.create_snake("player1")
|
||||
logic.state.snakes.append(snake)
|
||||
|
||||
logic.update_snake_direction("player1", UP)
|
||||
logic.update_snake_direction("player1", UP) # Duplicate
|
||||
|
||||
assert len(snake.input_buffer) == 1
|
||||
assert snake.input_buffer == [UP]
|
||||
|
||||
def test_input_buffer_opposite_replacement(self) -> None:
|
||||
"""Test opposite direction replaces last in buffer."""
|
||||
logic = GameLogic()
|
||||
snake = logic.create_snake("player1")
|
||||
logic.state.snakes.append(snake)
|
||||
|
||||
logic.update_snake_direction("player1", UP)
|
||||
logic.update_snake_direction("player1", DOWN) # Opposite to UP
|
||||
|
||||
# DOWN should replace UP
|
||||
assert len(snake.input_buffer) == 1
|
||||
assert snake.input_buffer == [DOWN]
|
||||
|
||||
def test_input_buffer_overflow_replacement(self) -> None:
|
||||
"""Test 4th input replaces last slot when buffer is full."""
|
||||
logic = GameLogic()
|
||||
snake = logic.create_snake("player1")
|
||||
logic.state.snakes.append(snake)
|
||||
|
||||
# Fill buffer with 3 directions
|
||||
logic.update_snake_direction("player1", UP)
|
||||
logic.update_snake_direction("player1", LEFT)
|
||||
logic.update_snake_direction("player1", DOWN)
|
||||
|
||||
# 4th input should replace last slot
|
||||
logic.update_snake_direction("player1", RIGHT)
|
||||
|
||||
assert len(snake.input_buffer) == 3
|
||||
assert snake.input_buffer == [UP, LEFT, RIGHT] # DOWN replaced by RIGHT
|
||||
|
||||
def test_input_buffer_consumption(self) -> None:
|
||||
"""Test buffer is consumed during movement."""
|
||||
logic = GameLogic()
|
||||
snake = Snake(player_id="player1", body=[
|
||||
Position(5, 5),
|
||||
Position(4, 5),
|
||||
Position(3, 5),
|
||||
], direction=RIGHT)
|
||||
logic.state.snakes.append(snake)
|
||||
|
||||
# Add direction to buffer
|
||||
snake.input_buffer = [UP]
|
||||
|
||||
logic.move_snakes()
|
||||
|
||||
# Buffer should be consumed and direction applied
|
||||
assert len(snake.input_buffer) == 0
|
||||
assert snake.direction == UP
|
||||
assert snake.get_head().y == 4 # Moved up
|
||||
|
||||
def test_input_buffer_skips_180_turn(self) -> None:
|
||||
"""Test buffer skips 180-degree turns for multi-segment snakes."""
|
||||
logic = GameLogic()
|
||||
snake = Snake(player_id="player1", body=[
|
||||
Position(5, 5),
|
||||
Position(4, 5),
|
||||
Position(3, 5),
|
||||
], direction=RIGHT)
|
||||
logic.state.snakes.append(snake)
|
||||
|
||||
# Buffer has opposite direction then valid direction
|
||||
snake.input_buffer = [LEFT, UP] # LEFT is 180° from RIGHT
|
||||
|
||||
logic.move_snakes()
|
||||
|
||||
# LEFT should be skipped, UP should be applied
|
||||
assert len(snake.input_buffer) == 0
|
||||
assert snake.direction == UP
|
||||
assert snake.get_head().y == 4 # Moved up
|
||||
|
||||
def test_zero_players_spawns_three_apples(self) -> None:
|
||||
"""Test field populates with 3 apples when no players."""
|
||||
logic = GameLogic()
|
||||
logic.state.game_running = True
|
||||
|
||||
# Start with no snakes and no food
|
||||
logic.state.snakes = []
|
||||
logic.state.food = []
|
||||
|
||||
# Update should populate 3 apples
|
||||
logic.update()
|
||||
|
||||
assert len(logic.state.food) == 3
|
||||
|
||||
# Subsequent updates should maintain 3 apples
|
||||
logic.update()
|
||||
assert len(logic.state.food) == 3
|
||||
|
||||
|
||||
Reference in New Issue
Block a user