Compare commits
2 Commits
97d6df1896
...
4dbbf44638
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dbbf44638 | ||
|
|
ce492b0dc2 |
@@ -80,8 +80,7 @@ async def main() -> None:
|
|||||||
web_dir = Path(args.web_dir)
|
web_dir = Path(args.web_dir)
|
||||||
if web_dir.exists():
|
if web_dir.exists():
|
||||||
# Use same host as game server for HTTP
|
# 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, args.host)
|
||||||
http_server = HTTPServer(web_dir, args.http_port, http_host)
|
|
||||||
await http_server.start()
|
await http_server.start()
|
||||||
else:
|
else:
|
||||||
print(f"Warning: Web directory '{web_dir}' not found. HTTP server disabled.")
|
print(f"Warning: Web directory '{web_dir}' not found. HTTP server disabled.")
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from ..shared.protocol import (
|
|||||||
create_move_message,
|
create_move_message,
|
||||||
create_start_game_message,
|
create_start_game_message,
|
||||||
)
|
)
|
||||||
from ..shared.models import GameState
|
from ..shared.models import GameState, Position
|
||||||
from ..shared.constants import (
|
from ..shared.constants import (
|
||||||
DEFAULT_HOST,
|
DEFAULT_HOST,
|
||||||
DEFAULT_PORT,
|
DEFAULT_PORT,
|
||||||
@@ -51,6 +51,10 @@ class GameClient:
|
|||||||
self.running = True
|
self.running = True
|
||||||
self.clock = pygame.time.Clock()
|
self.clock = pygame.time.Clock()
|
||||||
|
|
||||||
|
# Client-side prediction
|
||||||
|
self.player_input_buffers: dict[str, list] = {} # player_id -> input_buffer
|
||||||
|
self.predicted_heads: dict[str, Position] = {} # player_id -> predicted head position
|
||||||
|
|
||||||
async def connect(self) -> None:
|
async def connect(self) -> None:
|
||||||
"""Connect to the game server."""
|
"""Connect to the game server."""
|
||||||
try:
|
try:
|
||||||
@@ -62,6 +66,10 @@ class GameClient:
|
|||||||
# Send JOIN message
|
# Send JOIN message
|
||||||
await self.send_message(create_join_message(self.player_name))
|
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:
|
except Exception as e:
|
||||||
print(f"Failed to connect to server: {e}")
|
print(f"Failed to connect to server: {e}")
|
||||||
raise
|
raise
|
||||||
@@ -121,6 +129,8 @@ class GameClient:
|
|||||||
elif message.type == MessageType.STATE_UPDATE:
|
elif message.type == MessageType.STATE_UPDATE:
|
||||||
state_dict = message.data.get("game_state")
|
state_dict = message.data.get("game_state")
|
||||||
self.game_state = GameState.from_dict(state_dict)
|
self.game_state = GameState.from_dict(state_dict)
|
||||||
|
# Clear predictions on authoritative update
|
||||||
|
self.predicted_heads.clear()
|
||||||
|
|
||||||
elif message.type == MessageType.PLAYER_JOINED:
|
elif message.type == MessageType.PLAYER_JOINED:
|
||||||
player_id = message.data.get("player_id")
|
player_id = message.data.get("player_id")
|
||||||
@@ -142,6 +152,27 @@ class GameClient:
|
|||||||
error = message.data.get("error")
|
error = message.data.get("error")
|
||||||
print(f"Error from server: {error}")
|
print(f"Error from server: {error}")
|
||||||
|
|
||||||
|
elif message.type == MessageType.PLAYER_INPUT:
|
||||||
|
# Update input buffer and predict next position
|
||||||
|
player_id = message.data.get("player_id")
|
||||||
|
direction = tuple(message.data.get("direction", (1, 0)))
|
||||||
|
input_buffer = [tuple(d) for d in message.data.get("input_buffer", [])]
|
||||||
|
|
||||||
|
self.player_input_buffers[player_id] = input_buffer
|
||||||
|
|
||||||
|
# Predict next head position
|
||||||
|
if self.game_state:
|
||||||
|
snake = next((s for s in self.game_state.snakes if s.player_id == player_id), None)
|
||||||
|
if snake and snake.body:
|
||||||
|
# Use first buffered input if available, otherwise current direction
|
||||||
|
next_dir = input_buffer[0] if input_buffer else direction
|
||||||
|
head = snake.body[0]
|
||||||
|
predicted_head = Position(
|
||||||
|
head.x + next_dir[0],
|
||||||
|
head.y + next_dir[1]
|
||||||
|
)
|
||||||
|
self.predicted_heads[player_id] = predicted_head
|
||||||
|
|
||||||
def handle_input(self) -> None:
|
def handle_input(self) -> None:
|
||||||
"""Handle pygame input events."""
|
"""Handle pygame input events."""
|
||||||
for event in pygame.event.get():
|
for event in pygame.event.get():
|
||||||
@@ -174,8 +205,8 @@ class GameClient:
|
|||||||
# Handle input
|
# Handle input
|
||||||
self.handle_input()
|
self.handle_input()
|
||||||
|
|
||||||
# Render current state
|
# Render current state with predictions
|
||||||
self.renderer.render(self.game_state, self.player_id)
|
self.renderer.render(self.game_state, self.player_id, self.predicted_heads)
|
||||||
|
|
||||||
# Maintain frame rate
|
# Maintain frame rate
|
||||||
self.clock.tick(FPS)
|
self.clock.tick(FPS)
|
||||||
|
|||||||
@@ -30,13 +30,15 @@ class Renderer:
|
|||||||
self.font = pygame.font.Font(None, 36)
|
self.font = pygame.font.Font(None, 36)
|
||||||
self.small_font = pygame.font.Font(None, 24)
|
self.small_font = pygame.font.Font(None, 24)
|
||||||
|
|
||||||
def render(self, game_state: Optional[GameState], player_id: Optional[str] = None) -> None:
|
def render(self, game_state: Optional[GameState], player_id: Optional[str] = None, predicted_heads: dict = None) -> None:
|
||||||
"""Render the current game state.
|
"""Render the current game state.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
game_state: Current game state to render
|
game_state: Current game state to render
|
||||||
player_id: ID of the current player (for highlighting)
|
player_id: ID of the current player (for highlighting)
|
||||||
|
predicted_heads: Dict mapping player_id to predicted head Position
|
||||||
"""
|
"""
|
||||||
|
predicted_heads = predicted_heads or {}
|
||||||
# Clear screen
|
# Clear screen
|
||||||
self.screen.fill(COLOR_BACKGROUND)
|
self.screen.fill(COLOR_BACKGROUND)
|
||||||
|
|
||||||
@@ -65,6 +67,13 @@ class Renderer:
|
|||||||
head_color = tuple(min(c + 50, 255) for c in color)
|
head_color = tuple(min(c + 50, 255) for c in color)
|
||||||
self.draw_cell(snake.body[0], head_color)
|
self.draw_cell(snake.body[0], head_color)
|
||||||
|
|
||||||
|
# Draw predicted head position (if available)
|
||||||
|
if snake.player_id in predicted_heads:
|
||||||
|
predicted_pos = predicted_heads[snake.player_id]
|
||||||
|
# Draw with semi-transparent overlay (darker color)
|
||||||
|
predicted_color = tuple(int(c * 0.6) for c in head_color)
|
||||||
|
self.draw_cell(predicted_pos, predicted_color)
|
||||||
|
|
||||||
# Draw scores
|
# Draw scores
|
||||||
self.draw_scores(game_state, player_id)
|
self.draw_scores(game_state, player_id)
|
||||||
|
|
||||||
@@ -105,7 +114,9 @@ class Renderer:
|
|||||||
player_id: Current player's ID
|
player_id: Current player's ID
|
||||||
"""
|
"""
|
||||||
y_offset = 10
|
y_offset = 10
|
||||||
for snake in game_state.snakes:
|
# Sort snakes by length descending
|
||||||
|
sorted_snakes = sorted(game_state.snakes, key=lambda s: len(s.body), reverse=True)
|
||||||
|
for snake in sorted_snakes:
|
||||||
color = COLOR_SNAKES[snake.color_index % len(COLOR_SNAKES)]
|
color = COLOR_SNAKES[snake.color_index % len(COLOR_SNAKES)]
|
||||||
|
|
||||||
# Prepare length text
|
# Prepare length text
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ class GameLogic:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
def update_snake_direction(self, player_id: str, direction: Tuple[int, int]) -> None:
|
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:
|
Args:
|
||||||
player_id: Player whose snake to update
|
player_id: Player whose snake to update
|
||||||
@@ -112,9 +112,19 @@ class GameLogic:
|
|||||||
"""
|
"""
|
||||||
for snake in self.state.snakes:
|
for snake in self.state.snakes:
|
||||||
if snake.player_id == player_id and snake.alive:
|
if snake.player_id == player_id and snake.alive:
|
||||||
# Prevent 180-degree turns
|
# Don't add duplicate inputs (same as last in buffer)
|
||||||
if direction != OPPOSITE_DIRECTIONS.get(snake.direction):
|
if snake.input_buffer and snake.input_buffer[-1] == direction:
|
||||||
snake.direction = 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
|
break
|
||||||
|
|
||||||
def move_snakes(self) -> None:
|
def move_snakes(self) -> None:
|
||||||
@@ -123,6 +133,18 @@ class GameLogic:
|
|||||||
if not snake.alive: # Skip disconnected players
|
if not snake.alive: # Skip disconnected players
|
||||||
continue
|
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
|
# Calculate next position based on current direction
|
||||||
next_position = snake.get_head() + snake.direction
|
next_position = snake.get_head() + snake.direction
|
||||||
|
|
||||||
@@ -160,6 +182,14 @@ class GameLogic:
|
|||||||
"""Perform one game tick: move snakes and spawn food."""
|
"""Perform one game tick: move snakes and spawn food."""
|
||||||
self.move_snakes()
|
self.move_snakes()
|
||||||
|
|
||||||
# Spawn food if needed
|
# Spawn food based on player count
|
||||||
if len(self.state.food) < len([s for s in self.state.snakes if s.alive]):
|
alive_snakes = [s for s in self.state.snakes if s.alive]
|
||||||
self.state.food.append(self.spawn_food())
|
|
||||||
|
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())
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from ..shared.protocol import (
|
|||||||
create_player_left_message,
|
create_player_left_message,
|
||||||
create_game_started_message,
|
create_game_started_message,
|
||||||
create_error_message,
|
create_error_message,
|
||||||
|
create_player_input_message,
|
||||||
)
|
)
|
||||||
from ..shared.constants import DEFAULT_HOST, DEFAULT_PORT, TICK_RATE, COLOR_SNAKES
|
from ..shared.constants import DEFAULT_HOST, DEFAULT_PORT, TICK_RATE, COLOR_SNAKES
|
||||||
from .game_logic import GameLogic
|
from .game_logic import GameLogic
|
||||||
@@ -184,6 +185,16 @@ class GameServer:
|
|||||||
direction = tuple(message.data.get("direction", (1, 0)))
|
direction = tuple(message.data.get("direction", (1, 0)))
|
||||||
self.game_logic.update_snake_direction(player_id, direction)
|
self.game_logic.update_snake_direction(player_id, direction)
|
||||||
|
|
||||||
|
# Broadcast input to all clients for prediction
|
||||||
|
snake = next((s for s in self.game_logic.state.snakes if s.player_id == player_id), None)
|
||||||
|
if snake:
|
||||||
|
input_msg = create_player_input_message(
|
||||||
|
player_id,
|
||||||
|
snake.direction,
|
||||||
|
snake.input_buffer
|
||||||
|
)
|
||||||
|
await self.broadcast(input_msg)
|
||||||
|
|
||||||
async def handle_start_game(self) -> None:
|
async def handle_start_game(self) -> None:
|
||||||
"""Start the game."""
|
"""Start the game."""
|
||||||
if self.game_logic.state.game_running:
|
if self.game_logic.state.game_running:
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class Snake:
|
|||||||
stuck: bool = False # True when snake is blocked and shrinking
|
stuck: bool = False # True when snake is blocked and shrinking
|
||||||
color_index: int = 0 # Index in COLOR_SNAKES array for persistent color
|
color_index: int = 0 # Index in COLOR_SNAKES array for persistent color
|
||||||
player_name: str = "" # Human-readable player name
|
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:
|
def get_head(self) -> Position:
|
||||||
"""Get the head position of the snake."""
|
"""Get the head position of the snake."""
|
||||||
@@ -51,6 +52,7 @@ class Snake:
|
|||||||
"stuck": self.stuck,
|
"stuck": self.stuck,
|
||||||
"color_index": self.color_index,
|
"color_index": self.color_index,
|
||||||
"player_name": self.player_name,
|
"player_name": self.player_name,
|
||||||
|
"input_buffer": self.input_buffer,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -64,6 +66,7 @@ class Snake:
|
|||||||
snake.stuck = data.get("stuck", False) # Default to False for backward compatibility
|
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.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.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
|
return snake
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class MessageType(Enum):
|
|||||||
GAME_STARTED = "GAME_STARTED"
|
GAME_STARTED = "GAME_STARTED"
|
||||||
GAME_OVER = "GAME_OVER"
|
GAME_OVER = "GAME_OVER"
|
||||||
ERROR = "ERROR"
|
ERROR = "ERROR"
|
||||||
|
PLAYER_INPUT = "PLAYER_INPUT" # Broadcast player input for prediction
|
||||||
|
|
||||||
|
|
||||||
class Message:
|
class Message:
|
||||||
@@ -114,3 +115,18 @@ def create_game_over_message(winner_id: str = None) -> Message:
|
|||||||
def create_error_message(error: str) -> Message:
|
def create_error_message(error: str) -> Message:
|
||||||
"""Create an ERROR message."""
|
"""Create an ERROR message."""
|
||||||
return Message(MessageType.ERROR, {"error": error})
|
return Message(MessageType.ERROR, {"error": error})
|
||||||
|
|
||||||
|
|
||||||
|
def create_player_input_message(player_id: str, direction: tuple, input_buffer: list) -> Message:
|
||||||
|
"""Create a PLAYER_INPUT message for client-side prediction.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player_id: ID of the player who sent the input
|
||||||
|
direction: Current direction tuple
|
||||||
|
input_buffer: List of buffered direction tuples (max 3)
|
||||||
|
"""
|
||||||
|
return Message(MessageType.PLAYER_INPUT, {
|
||||||
|
"player_id": player_id,
|
||||||
|
"direction": direction,
|
||||||
|
"input_buffer": input_buffer
|
||||||
|
})
|
||||||
|
|||||||
@@ -29,18 +29,20 @@ class TestGameLogic:
|
|||||||
assert 0 <= food.position.y < GRID_HEIGHT
|
assert 0 <= food.position.y < GRID_HEIGHT
|
||||||
|
|
||||||
def test_update_snake_direction(self) -> None:
|
def test_update_snake_direction(self) -> None:
|
||||||
"""Test updating snake direction."""
|
"""Test updating snake direction via input buffer."""
|
||||||
logic = GameLogic()
|
logic = GameLogic()
|
||||||
snake = logic.create_snake("player1")
|
snake = logic.create_snake("player1")
|
||||||
logic.state.snakes.append(snake)
|
logic.state.snakes.append(snake)
|
||||||
|
|
||||||
# Valid direction change
|
# Direction changes go into buffer first
|
||||||
logic.update_snake_direction("player1", UP)
|
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)
|
# Moving consumes from buffer
|
||||||
logic.update_snake_direction("player1", DOWN)
|
logic.move_snakes()
|
||||||
assert snake.direction == UP # Should remain UP
|
assert snake.direction == UP # Now changed after movement
|
||||||
|
assert len(snake.input_buffer) == 0
|
||||||
|
|
||||||
def test_move_snakes(self) -> None:
|
def test_move_snakes(self) -> None:
|
||||||
"""Test snake movement."""
|
"""Test snake movement."""
|
||||||
@@ -252,3 +254,117 @@ class TestGameLogic:
|
|||||||
assert len(snake_a.body) == 1
|
assert len(snake_a.body) == 1
|
||||||
assert len(snake_b.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
|
||||||
|
|
||||||
|
|||||||
56
web/game.js
56
web/game.js
@@ -10,6 +10,10 @@ class GameClient {
|
|||||||
this.canvas = document.getElementById('game-canvas');
|
this.canvas = document.getElementById('game-canvas');
|
||||||
this.ctx = this.canvas.getContext('2d');
|
this.ctx = this.canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Client-side prediction
|
||||||
|
this.playerInputBuffers = {}; // player_id -> input_buffer array
|
||||||
|
this.predictedHeads = {}; // player_id -> [x, y] predicted position
|
||||||
|
|
||||||
// Game constants (matching Python)
|
// Game constants (matching Python)
|
||||||
this.GRID_WIDTH = 40;
|
this.GRID_WIDTH = 40;
|
||||||
this.GRID_HEIGHT = 30;
|
this.GRID_HEIGHT = 30;
|
||||||
@@ -134,6 +138,8 @@ class GameClient {
|
|||||||
case MessageType.STATE_UPDATE:
|
case MessageType.STATE_UPDATE:
|
||||||
this.gameState = message.data.game_state;
|
this.gameState = message.data.game_state;
|
||||||
this.updatePlayersList();
|
this.updatePlayersList();
|
||||||
|
// Clear predictions on authoritative update
|
||||||
|
this.predictedHeads = {};
|
||||||
if (this.gameState.game_running) {
|
if (this.gameState.game_running) {
|
||||||
this.hideOverlay();
|
this.hideOverlay();
|
||||||
}
|
}
|
||||||
@@ -164,6 +170,29 @@ class GameClient {
|
|||||||
console.error('Server error:', message.data.error);
|
console.error('Server error:', message.data.error);
|
||||||
this.showStatus('Error: ' + message.data.error, 'error');
|
this.showStatus('Error: ' + message.data.error, 'error');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case MessageType.PLAYER_INPUT:
|
||||||
|
// Update input buffer and predict next position
|
||||||
|
const playerId = message.data.player_id;
|
||||||
|
const direction = message.data.direction;
|
||||||
|
const inputBuffer = message.data.input_buffer || [];
|
||||||
|
|
||||||
|
this.playerInputBuffers[playerId] = inputBuffer;
|
||||||
|
|
||||||
|
// Predict next head position
|
||||||
|
if (this.gameState && this.gameState.snakes) {
|
||||||
|
const snake = this.gameState.snakes.find(s => s.player_id === playerId);
|
||||||
|
if (snake && snake.body && snake.body.length > 0) {
|
||||||
|
// Use first buffered input if available, otherwise current direction
|
||||||
|
const nextDir = inputBuffer.length > 0 ? inputBuffer[0] : direction;
|
||||||
|
const head = snake.body[0];
|
||||||
|
this.predictedHeads[playerId] = [
|
||||||
|
head[0] + nextDir[0],
|
||||||
|
head[1] + nextDir[1]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,6 +274,15 @@ class GameClient {
|
|||||||
this.drawCell(x, y, color);
|
this.drawCell(x, y, color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Draw predicted head position (if available)
|
||||||
|
if (this.predictedHeads[snake.player_id]) {
|
||||||
|
const [px, py] = this.predictedHeads[snake.player_id];
|
||||||
|
const brightColor = this.brightenColor(color, 50);
|
||||||
|
// Draw with reduced opacity (darker color)
|
||||||
|
const predictedColor = this.darkenColor(brightColor, 0.6);
|
||||||
|
this.drawCell(px, py, predictedColor);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -307,6 +345,19 @@ class GameClient {
|
|||||||
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
darkenColor(hex, factor) {
|
||||||
|
// Convert hex to RGB and multiply by factor
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
|
||||||
|
const newR = Math.floor(r * factor);
|
||||||
|
const newG = Math.floor(g * factor);
|
||||||
|
const newB = Math.floor(b * factor);
|
||||||
|
|
||||||
|
return `#${newR.toString(16).padStart(2, '0')}${newG.toString(16).padStart(2, '0')}${newB.toString(16).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
updatePlayersList() {
|
updatePlayersList() {
|
||||||
if (!this.gameState || !this.gameState.snakes) {
|
if (!this.gameState || !this.gameState.snakes) {
|
||||||
return;
|
return;
|
||||||
@@ -314,7 +365,10 @@ class GameClient {
|
|||||||
|
|
||||||
this.playersList.innerHTML = '';
|
this.playersList.innerHTML = '';
|
||||||
|
|
||||||
this.gameState.snakes.forEach((snake) => {
|
// Sort snakes by length descending
|
||||||
|
const sortedSnakes = [...this.gameState.snakes].sort((a, b) => b.body.length - a.body.length);
|
||||||
|
|
||||||
|
sortedSnakes.forEach((snake) => {
|
||||||
const playerItem = document.createElement('div');
|
const playerItem = document.createElement('div');
|
||||||
playerItem.className = `player-item ${snake.alive ? 'alive' : 'dead'}`;
|
playerItem.className = `player-item ${snake.alive ? 'alive' : 'dead'}`;
|
||||||
playerItem.style.borderLeftColor = this.COLOR_SNAKES[snake.color_index % this.COLOR_SNAKES.length];
|
playerItem.style.borderLeftColor = this.COLOR_SNAKES[snake.color_index % this.COLOR_SNAKES.length];
|
||||||
|
|||||||
@@ -59,13 +59,13 @@
|
|||||||
<script src="protocol.js"></script>
|
<script src="protocol.js"></script>
|
||||||
<script src="game.js"></script>
|
<script src="game.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Auto-detect WebSocket URL
|
// Auto-detect WebSocket URL based on page hostname
|
||||||
const wsUrl = document.getElementById('server-url');
|
const wsUrl = document.getElementById('server-url');
|
||||||
if (window.location.protocol === 'file:') {
|
if (window.location.protocol === 'file:') {
|
||||||
wsUrl.value = 'ws://localhost:8889';
|
wsUrl.value = 'ws://localhost:8889';
|
||||||
} else {
|
} else {
|
||||||
const host = window.location.hostname;
|
const host = window.location.hostname;
|
||||||
const port = window.location.port ? parseInt(window.location.port) + 889 : 8889;
|
const port = 8889; // Default WebSocket port
|
||||||
wsUrl.value = `ws://${host}:${port}`;
|
wsUrl.value = `ws://${host}:${port}`;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ const MessageType = {
|
|||||||
PLAYER_LEFT: 'PLAYER_LEFT',
|
PLAYER_LEFT: 'PLAYER_LEFT',
|
||||||
GAME_STARTED: 'GAME_STARTED',
|
GAME_STARTED: 'GAME_STARTED',
|
||||||
GAME_OVER: 'GAME_OVER',
|
GAME_OVER: 'GAME_OVER',
|
||||||
ERROR: 'ERROR'
|
ERROR: 'ERROR',
|
||||||
|
PLAYER_INPUT: 'PLAYER_INPUT' // Broadcast player input for prediction
|
||||||
};
|
};
|
||||||
|
|
||||||
class Message {
|
class Message {
|
||||||
|
|||||||
Reference in New Issue
Block a user