Save work: Update game engine, maps, and add vision/pathing utilities. (Tests failing: vision raycast)

This commit is contained in:
Antigravity 2026-02-01 00:48:32 -05:00
parent 071906df59
commit be371e887a
18 changed files with 3270 additions and 303 deletions

View File

@ -1,14 +1,27 @@
{ {
"_comment": "Complete Among Us lobby settings",
"map_name": "skeld",
"num_impostors": 2, "num_impostors": 2,
"player_speed": 100.0,
"crewmate_vision": 1.0,
"impostor_vision": 1.5,
"kill_cooldown": 25.0, "kill_cooldown": 25.0,
"vision_range": 10.0, "kill_distance": "medium",
"impostor_vision_multiplier": 1.5,
"light_sabotage_vision_multiplier": 0.25,
"emergencies_per_player": 1, "emergencies_per_player": 1,
"emergency_cooldown": 15.0,
"discussion_time": 30.0,
"voting_time": 120.0,
"confirm_ejects": true, "confirm_ejects": true,
"player_speed": 2.0, "anonymous_votes": false,
"task_duration": 3.0, "visual_tasks": true,
"taskbar_updates": "always",
"common_tasks": 2,
"long_tasks": 1,
"short_tasks": 2,
"sabotage_cooldown": 30.0, "sabotage_cooldown": 30.0,
"reactor_timer": 45.0,
"o2_timer": 45.0, "o2_timer": 45.0,
"reactor_timer": 45.0 "lights_vision_multiplier": 0.25,
"max_discussion_rounds": 20,
"convergence_threshold": 2
} }

View File

@ -1,40 +1,40 @@
# Game Settings Configuration # The Glass Box League — Complete Among Us Settings
# Edit this file to customize game rules # All values match the in-game lobby settings panel
game: game:
map: "skeld" map_name: skeld
min_players: 4
max_players: 10
num_impostors: 2 num_impostors: 2
player: player:
speed: 1.5 # meters per second player_speed: 100.0 # pixels/second (1.0x = 100px/s)
vision_range: 10.0 # meters crewmate_vision: 1.0 # multiplier (1.0x = 300px radius)
impostor_vision: 1.5 # multiplier (not affected by lights)
impostor: impostor:
kill_cooldown: 25.0 # seconds kill_cooldown: 25.0 # seconds
kill_range: 2.0 # meters kill_distance: medium # short (50px), medium (100px), long (150px)
crewmate:
tasks_short: 2
tasks_long: 1
tasks_common: 2
meeting: meeting:
emergency_cooldown: 15.0 # seconds
emergencies_per_player: 1 emergencies_per_player: 1
discussion_time: 30.0 # seconds (for human mode) emergency_cooldown: 15.0 # seconds
voting_time: 60.0 # seconds (for human mode) discussion_time: 30.0 # seconds
confirm_ejects: true voting_time: 120.0 # seconds
confirm_ejects: true # "Red was The Impostor" vs "Red was ejected"
anonymous_votes: false # hide who voted for whom
tasks:
visual_tasks: true # can see others doing visual tasks
taskbar_updates: always # always, meetings, never
common_tasks: 2
long_tasks: 1
short_tasks: 2
sabotage: sabotage:
o2_timer: 30.0 # seconds until death sabotage_cooldown: 30.0 # seconds between sabotages
reactor_timer: 30.0 # seconds until meltdown reactor_timer: 45.0 # seconds to fix reactor
lights_vision_multiplier: 0.25 o2_timer: 45.0 # seconds to fix O2
comms_disables_tasks: true lights_vision_multiplier: 0.25 # crewmate vision during lights sabotage
# LLM-specific settings
llm: llm:
max_discussion_rounds: 20 max_discussion_rounds: 20
min_convergence_rounds: 2 convergence_threshold: 2
convergence_threshold: 2 # desire_to_speak <= this = silence

File diff suppressed because it is too large Load Diff

View File

@ -204,27 +204,53 @@ class LLMClient:
## Configuration ## Configuration
### `config/game_settings.yaml` Use the CLI config manager for easy configuration:
```yaml
num_impostors: 2 ```bash
kill_cooldown: 25.0 python scripts/config_manager.py settings list # List all settings
vision_range: 10.0 python scripts/config_manager.py settings get kill_cooldown
impostor_vision_multiplier: 1.5 python scripts/config_manager.py settings set kill_cooldown 30
light_sabotage_vision_multiplier: 0.25 python scripts/config_manager.py validate # Validate all configs
emergencies_per_player: 1
confirm_ejects: true
``` ```
### `config/game_settings.json`
```json
{
"map_name": "skeld",
"num_impostors": 2,
"player_speed": 100.0,
"crewmate_vision": 1.0,
"impostor_vision": 1.5,
"kill_cooldown": 25.0,
"kill_distance": "medium",
"emergencies_per_player": 1,
"discussion_time": 30.0,
"voting_time": 120.0,
"confirm_ejects": true,
"visual_tasks": true,
"taskbar_updates": "always"
}
```
All measurements are in **pixels** (map is 2000x1500). Speed is pixels/second.
### `data/maps/skeld.json` ### `data/maps/skeld.json`
```json ```json
{ {
"canvas": {"width": 2000, "height": 1500},
"rooms": [ "rooms": [
{"id": "cafeteria", "name": "Cafeteria", "tasks": [...], "vent": null}, {"id": "cafeteria", "name": "Cafeteria", "center": [1000, 350], "bounds": [[850, 200], [1150, 500]]},
... ...
], ],
"edges": [ "edges": [
{"id": "cafe_weapons", "room_a": "cafeteria", "room_b": "weapons", "distance": 5.0}, {"id": "cafe_weapons", "room_a": "cafeteria", "room_b": "weapons", "waypoints": [[1150, 350], [1300, 350]]},
... ...
] ],
"walls": [
{"start": [850, 200], "end": [1150, 200]},
...
],
"spawn_points": {"cafeteria": [1000, 400]}
} }
``` ```

164
docs/design_rendering.md Normal file
View File

@ -0,0 +1,164 @@
# Ray-Traced FOV & Video Rendering Pipeline
## Current State
- Vision is **room-based** (graph traversal)
- Player sees everyone in same room
- No pixel-level visibility, no wall occlusion
## Target State
- **Ray-traced FOV** matching real Among Us
- Walls block visibility
- Circular vision radius from player
- Light sabotage reduces radius
- Video output at **60fps** for YouTube
---
## Part 1: Ray-Traced FOV
### Map Data
Current `skeld.json` has rooms + edges (corridors). Need to add:
```json
{
"walls": [
{"p1": [100, 50], "p2": [100, 200]},
{"p1": [100, 200], "p2": [250, 200]}
],
"spawn_positions": {"cafeteria": [300, 400]}
}
```
### Visibility Algorithm
1. **Cast rays** from player position in 360° (e.g., 360 rays)
2. **Intersect** each ray with wall segments
3. **Closest intersection** per ray = vision boundary
4. **Vision radius** clamps max distance
5. **Player visible** if: within radius AND not occluded by walls
### Implementation
- `src/engine/vision_raycast.py` — new module
- `RaycastVisionSystem.get_visible_players(observer_pos, all_players, walls)`
- Returns: list of visible player IDs + positions
---
## Part 2: Engine Changes
### Current Position Model (`types.py`)
```python
@dataclass
class Position:
room_id: Optional[str] = None # Discrete room
edge_id: Optional[str] = None # Walking between rooms
progress: float = 0.0 # 0.0-1.0 on edge
```
### New Position Model
```python
@dataclass
class Position:
x: float = 0.0 # Pixel X
y: float = 0.0 # Pixel Y
room_id: Optional[str] = None # Derived from position
def distance_to(self, other: "Position") -> float:
return math.sqrt((self.x - other.x)**2 + (self.y - other.y)**2)
```
### New Map Data (`skeld.json`)
```json
{
"rooms": [...],
"edges": [...],
"walls": [
{"p1": [100, 50], "p2": [100, 200]},
...
],
"room_polygons": {
"cafeteria": [[x1,y1], [x2,y2], ...],
...
},
"spawn_points": {
"cafeteria": [300, 400],
...
}
}
```
### New Module: `vision_raycast.py`
```python
class RaycastVision:
def __init__(self, walls: list[Wall], vision_radius: float):
...
def is_visible(self, from_pos: Position, to_pos: Position) -> bool:
"""True if line-of-sight exists (no wall occlusion)."""
...
def get_visible_players(self, observer: Position,
all_players: list[Player]) -> list[Player]:
"""Returns players within vision radius AND line-of-sight."""
...
def get_vision_polygon(self, observer: Position) -> list[tuple]:
"""For rendering: polygon representing visible area."""
...
```
### Wall Intersection Algorithm
```python
def ray_intersects_wall(ray_start, ray_dir, wall_p1, wall_p2) -> float | None:
"""Returns distance to intersection, or None if no hit."""
# Standard line-segment intersection math
```
---
## Part 3: Rendering Pipeline
### Frame Generation (60fps)
```
replay.json → Renderer → frames/0001.png, 0002.png, ...
```
### Per Frame:
1. Draw map background (Skeld PNG)
2. Apply FOV mask (ray-traced vignette)
3. Draw player sprites at interpolated positions
4. Draw bodies
5. Overlay effects (kill, vent, sabotage)
### Video Assembly
```bash
ffmpeg -framerate 60 -i frames/%04d.png -c:v libx264 output.mp4
```
---
## Part 4: Assets
| Asset | Format | Source |
|-------|--------|--------|
| Skeld map | PNG | Fan art / game extract |
| Crewmate sprites | Spritesheet | Available online |
| Kill animations | Sprite sequence | Extract or recreate |
| Meeting UI | HTML/PNG | Recreate |
---
## Implementation Order
1. **Map upgrade** — Add walls + pixel coords
2. **Raycast vision**`vision_raycast.py`
3. **Pixel positions** — Upgrade engine to (x,y)
4. **Path interpolation** — Smooth walking
5. **Frame renderer** — Pillow/Pygame
6. **Meeting renderer** — Overlay
7. **FFmpeg integration** — Stitch to video
## Questions
1. **POV style**: Single player POV, or omniscient?
2. **Internal thoughts**: Show as subtitles?
3. **TTS**: Voice for dialogue?

442
scripts/config_manager.py Executable file
View File

@ -0,0 +1,442 @@
#!/usr/bin/env python3
"""
The Glass Box League Configuration Manager CLI
Unified CLI for managing all game configurations:
- Game settings (lobby options)
- Prompt templates
- Map data
- Model configurations
"""
import argparse
import json
import os
import sys
from pathlib import Path
from typing import Optional
# Add project root to path
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
try:
import yaml
HAS_YAML = True
except ImportError:
HAS_YAML = False
class ConfigManager:
"""Unified configuration manager."""
def __init__(self, project_root: Path = None):
self.root = project_root or PROJECT_ROOT
self.config_dir = self.root / "config"
self.data_dir = self.root / "data"
self.prompts_dir = self.config_dir / "prompts"
# -------------------------------------------------------------------------
# Game Settings
# -------------------------------------------------------------------------
def get_settings_path(self, format: str = "json") -> Path:
"""Get path to game settings file."""
return self.config_dir / f"game_settings.{format}"
def load_settings(self) -> dict:
"""Load game settings (prefers YAML if available)."""
yaml_path = self.get_settings_path("yaml")
json_path = self.get_settings_path("json")
if HAS_YAML and yaml_path.exists():
with open(yaml_path) as f:
return yaml.safe_load(f)
elif json_path.exists():
with open(json_path) as f:
return json.load(f)
return {}
def save_settings(self, settings: dict, format: str = "json"):
"""Save game settings."""
path = self.get_settings_path(format)
if format == "yaml" and HAS_YAML:
with open(path, "w") as f:
yaml.dump(settings, f, default_flow_style=False, sort_keys=False)
else:
with open(path, "w") as f:
json.dump(settings, f, indent=2)
print(f"✓ Saved settings to {path}")
def get_setting(self, key: str) -> Optional[any]:
"""Get a single setting value (supports nested keys like 'game.num_impostors')."""
settings = self.load_settings()
# Handle nested keys
parts = key.split(".")
value = settings
for part in parts:
if isinstance(value, dict) and part in value:
value = value[part]
else:
return None
return value
def set_setting(self, key: str, value: str):
"""Set a single setting value."""
settings = self.load_settings()
# Auto-detect type
parsed_value = self._parse_value(value)
# Handle nested keys
parts = key.split(".")
current = settings
for part in parts[:-1]:
if part not in current:
current[part] = {}
current = current[part]
current[parts[-1]] = parsed_value
# Save in same format as loaded
format = "yaml" if HAS_YAML and self.get_settings_path("yaml").exists() else "json"
self.save_settings(settings, format)
print(f"✓ Set {key} = {parsed_value}")
def _parse_value(self, value: str):
"""Parse string value to appropriate type."""
# Boolean
if value.lower() in ("true", "yes", "on"):
return True
if value.lower() in ("false", "no", "off"):
return False
# Number
try:
if "." in value:
return float(value)
return int(value)
except ValueError:
pass
return value
def list_settings(self):
"""List all game settings."""
settings = self.load_settings()
self._print_settings(settings)
def _print_settings(self, obj, prefix=""):
"""Recursively print settings."""
if isinstance(obj, dict):
for key, value in obj.items():
full_key = f"{prefix}.{key}" if prefix else key
if isinstance(value, dict):
print(f"\n[{full_key}]")
self._print_settings(value, full_key)
else:
print(f" {key}: {value}")
else:
print(f" {prefix}: {obj}")
# -------------------------------------------------------------------------
# Prompt Templates
# -------------------------------------------------------------------------
def list_prompts(self):
"""List all prompt templates."""
if not self.prompts_dir.exists():
print("No prompts directory found.")
return
print("\nPrompt Templates:")
for path in sorted(self.prompts_dir.glob("*.md")):
size = path.stat().st_size
print(f" {path.stem}: {size} bytes")
def show_prompt(self, name: str):
"""Show a prompt template."""
path = self.prompts_dir / f"{name}.md"
if not path.exists():
print(f"Prompt '{name}' not found.")
return
print(f"=== {name}.md ===\n")
print(path.read_text())
def edit_prompt(self, name: str):
"""Open prompt in default editor."""
path = self.prompts_dir / f"{name}.md"
if not path.exists():
# Create empty template
path.write_text(f"# {name.title()} Prompt\n\n")
editor = os.environ.get("EDITOR", "nano")
os.system(f"{editor} {path}")
# -------------------------------------------------------------------------
# Maps
# -------------------------------------------------------------------------
def list_maps(self):
"""List available maps."""
maps_dir = self.data_dir / "maps"
if not maps_dir.exists():
print("No maps directory found.")
return
print("\nAvailable Maps:")
for path in sorted(maps_dir.glob("*.json")):
with open(path) as f:
data = json.load(f)
rooms = len(data.get("rooms", []))
edges = len(data.get("edges", []))
walls = len(data.get("walls", []))
print(f" {path.stem}: {rooms} rooms, {edges} edges, {walls} walls")
def show_map_info(self, name: str):
"""Show detailed map information."""
path = self.data_dir / "maps" / f"{name}.json"
if not path.exists():
print(f"Map '{name}' not found.")
return
with open(path) as f:
data = json.load(f)
print(f"\n=== {name} Map ===\n")
# Canvas size
if "canvas" in data:
print(f"Canvas: {data['canvas']['width']}x{data['canvas']['height']} pixels")
# Rooms
print(f"\nRooms ({len(data.get('rooms', []))}):")
for room in data.get("rooms", []):
tasks = len(room.get("tasks", []))
vent = "vent" if room.get("vent") else ""
print(f" {room['id']}: {room['name']} ({tasks} tasks) {vent}")
# Spawn points
if data.get("spawn_points"):
print(f"\nSpawn Points: {', '.join(data['spawn_points'].keys())}")
# -------------------------------------------------------------------------
# Validation
# -------------------------------------------------------------------------
def validate(self):
"""Validate all configurations."""
errors = []
warnings = []
# Check settings
settings = self.load_settings()
if not settings:
errors.append("Game settings file is empty or missing")
else:
# Validate ranges
speed = self._get_nested(settings, "player_speed") or self._get_nested(settings, "player.player_speed")
if speed and (speed < 50 or speed > 300):
warnings.append(f"player_speed={speed} is unusual (typical: 75-150)")
impostors = self._get_nested(settings, "num_impostors") or self._get_nested(settings, "game.num_impostors")
if impostors and (impostors < 1 or impostors > 3):
warnings.append(f"num_impostors={impostors} is unusual (typical: 1-3)")
# Check maps
maps_dir = self.data_dir / "maps"
if maps_dir.exists():
for path in maps_dir.glob("*.json"):
try:
with open(path) as f:
data = json.load(f)
if not data.get("rooms"):
errors.append(f"Map {path.name} has no rooms")
except json.JSONDecodeError as e:
errors.append(f"Map {path.name} is invalid JSON: {e}")
# Check prompts
if self.prompts_dir.exists():
for path in self.prompts_dir.glob("*.md"):
content = path.read_text()
if len(content) < 50:
warnings.append(f"Prompt {path.name} is very short ({len(content)} chars)")
# Report
print("\n=== Configuration Validation ===\n")
if errors:
print("❌ Errors:")
for e in errors:
print(f" - {e}")
if warnings:
print("\n⚠️ Warnings:")
for w in warnings:
print(f" - {w}")
if not errors and not warnings:
print("✓ All configurations valid!")
return len(errors) == 0
def _get_nested(self, d: dict, key: str):
"""Get nested dict value."""
parts = key.split(".")
for part in parts:
if isinstance(d, dict) and part in d:
d = d[part]
else:
return None
return d
# -------------------------------------------------------------------------
# Reset/Defaults
# -------------------------------------------------------------------------
def reset_settings(self):
"""Reset settings to defaults."""
defaults = {
"map_name": "skeld",
"num_impostors": 2,
"player_speed": 100.0,
"crewmate_vision": 1.0,
"impostor_vision": 1.5,
"kill_cooldown": 25.0,
"kill_distance": "medium",
"emergencies_per_player": 1,
"emergency_cooldown": 15.0,
"discussion_time": 30.0,
"voting_time": 120.0,
"confirm_ejects": True,
"anonymous_votes": False,
"visual_tasks": True,
"taskbar_updates": "always",
"common_tasks": 2,
"long_tasks": 1,
"short_tasks": 2,
"sabotage_cooldown": 30.0,
"reactor_timer": 45.0,
"o2_timer": 45.0,
"lights_vision_multiplier": 0.25,
"max_discussion_rounds": 20,
"convergence_threshold": 2
}
self.save_settings(defaults)
print("✓ Reset all settings to defaults")
def main():
parser = argparse.ArgumentParser(
description="Glass Box League Configuration Manager",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
%(prog)s settings list # List all game settings
%(prog)s settings get kill_cooldown # Get a specific setting
%(prog)s settings set kill_cooldown 30 # Set a setting
%(prog)s settings reset # Reset to defaults
%(prog)s prompts list # List prompt templates
%(prog)s prompts show action # Show action prompt
%(prog)s prompts edit discussion # Edit discussion prompt
%(prog)s maps list # List available maps
%(prog)s maps info skeld # Show skeld map info
%(prog)s validate # Validate all configs
"""
)
subparsers = parser.add_subparsers(dest="command", help="Command to run")
# Settings commands
settings_parser = subparsers.add_parser("settings", help="Manage game settings")
settings_sub = settings_parser.add_subparsers(dest="action")
settings_sub.add_parser("list", help="List all settings")
get_parser = settings_sub.add_parser("get", help="Get a setting value")
get_parser.add_argument("key", help="Setting key (e.g., kill_cooldown)")
set_parser = settings_sub.add_parser("set", help="Set a setting value")
set_parser.add_argument("key", help="Setting key")
set_parser.add_argument("value", help="New value")
settings_sub.add_parser("reset", help="Reset to defaults")
# Prompts commands
prompts_parser = subparsers.add_parser("prompts", help="Manage prompt templates")
prompts_sub = prompts_parser.add_subparsers(dest="action")
prompts_sub.add_parser("list", help="List prompt templates")
show_parser = prompts_sub.add_parser("show", help="Show a prompt")
show_parser.add_argument("name", help="Prompt name (without .md)")
edit_parser = prompts_sub.add_parser("edit", help="Edit a prompt")
edit_parser.add_argument("name", help="Prompt name")
# Maps commands
maps_parser = subparsers.add_parser("maps", help="Manage map data")
maps_sub = maps_parser.add_subparsers(dest="action")
maps_sub.add_parser("list", help="List maps")
info_parser = maps_sub.add_parser("info", help="Show map info")
info_parser.add_argument("name", help="Map name")
# Validate command
subparsers.add_parser("validate", help="Validate all configurations")
args = parser.parse_args()
manager = ConfigManager()
if args.command == "settings":
if args.action == "list":
manager.list_settings()
elif args.action == "get":
value = manager.get_setting(args.key)
if value is not None:
print(f"{args.key} = {value}")
else:
print(f"Setting '{args.key}' not found")
elif args.action == "set":
manager.set_setting(args.key, args.value)
elif args.action == "reset":
manager.reset_settings()
else:
settings_parser.print_help()
elif args.command == "prompts":
if args.action == "list":
manager.list_prompts()
elif args.action == "show":
manager.show_prompt(args.name)
elif args.action == "edit":
manager.edit_prompt(args.name)
else:
prompts_parser.print_help()
elif args.command == "maps":
if args.action == "list":
manager.list_maps()
elif args.action == "info":
manager.show_map_info(args.name)
else:
maps_parser.print_help()
elif args.command == "validate":
success = manager.validate()
sys.exit(0 if success else 1)
else:
parser.print_help()
if __name__ == "__main__":
main()

View File

@ -77,7 +77,11 @@ Current settings:
- Map: {map_name} - Map: {map_name}
- Impostors: {game_settings.get('num_impostors', 2)} - Impostors: {game_settings.get('num_impostors', 2)}
- Kill cooldown: {game_settings.get('kill_cooldown', 25)}s - Kill cooldown: {game_settings.get('kill_cooldown', 25)}s
- Vision range: {game_settings.get('vision_range', 10)}m - Kill distance: {game_settings.get('kill_distance', 'medium')}
- Discussion time: {game_settings.get('discussion_time', 30)}s
- Voting time: {game_settings.get('voting_time', 120)}s
- Confirm ejects: {'Yes' if game_settings.get('confirm_ejects', True) else 'No'}
- Visual tasks: {'On' if game_settings.get('visual_tasks', True) else 'Off'}
Actions are taken by responding with JSON.""" Actions are taken by responding with JSON."""

View File

@ -25,45 +25,70 @@ from src.map.graph import GameMap
@dataclass @dataclass
class GameConfig: class GameConfig:
"""Game configuration loaded from YAML. All values are modular.""" """
# Game setup Complete Among Us lobby settings.
All values match the in-game settings panel.
"""
# === Game Setup ===
map_name: str = "skeld" map_name: str = "skeld"
min_players: int = 4
max_players: int = 15
num_impostors: int = 2 num_impostors: int = 2
# Player stats (can be overridden per-player) # === Player Settings ===
player_speed: float = 1.5 # meters per second player_speed: float = 100.0 # pixels per second (1.0x = 100px/s)
vision_range: float = 10.0 # meters crewmate_vision: float = 1.0 # multiplier (1.0x = 300px radius)
crewmate_vision: float = 1.0 # multiplier
impostor_vision: float = 1.5 # multiplier impostor_vision: float = 1.5 # multiplier
# Impostor mechanics # === Kill Settings ===
kill_cooldown: float = 25.0 # seconds kill_cooldown: float = 25.0 # seconds
kill_range: float = 2.0 # meters kill_distance: str = "medium" # "short" (50px), "medium" (100px), "long" (150px)
# Meeting settings # === Meeting Settings ===
emergency_cooldown: float = 15.0 # seconds
emergencies_per_player: int = 1 emergencies_per_player: int = 1
discussion_time: float = 30.0 # seconds (informational for LLMs) emergency_cooldown: float = 15.0 # seconds
voting_time: float = 60.0 # seconds (informational for LLMs) discussion_time: float = 30.0 # seconds
confirm_ejects: bool = True voting_time: float = 120.0 # seconds
anonymous_votes: bool = False confirm_ejects: bool = True # "Red was The Impostor" or "Red was ejected"
anonymous_votes: bool = False # hide who voted for whom
# Sabotage settings # === Task Settings ===
o2_timer: float = 30.0 visual_tasks: bool = True # can see others doing visual tasks (medbay scan, etc)
reactor_timer: float = 30.0 taskbar_updates: str = "always" # "always", "meetings", "never"
lights_vision_multiplier: float = 0.25 common_tasks: int = 2
long_tasks: int = 1
short_tasks: int = 2
# Task settings # === Sabotage Settings ===
tasks_short: int = 2 sabotage_cooldown: float = 30.0 # seconds between sabotages
tasks_long: int = 1 reactor_timer: float = 45.0 # seconds to fix reactor
tasks_common: int = 2 o2_timer: float = 45.0 # seconds to fix O2
lights_vision_multiplier: float = 0.25 # vision during lights sabotage
# LLM-specific # === LLM-Specific (not in actual game) ===
max_discussion_rounds: int = 20 max_discussion_rounds: int = 20
convergence_threshold: int = 2 convergence_threshold: int = 2
@property
def kill_distance_pixels(self) -> float:
"""Convert kill distance setting to pixels."""
distances = {"short": 50.0, "medium": 100.0, "long": 150.0}
return distances.get(self.kill_distance, 100.0)
@property
def vision_radius(self) -> float:
"""Base vision radius in pixels."""
return 300.0
def get_crewmate_vision_radius(self, lights_sabotaged: bool = False) -> float:
"""Get actual crewmate vision radius."""
base = self.vision_radius * self.crewmate_vision
if lights_sabotaged:
return base * self.lights_vision_multiplier
return base
def get_impostor_vision_radius(self, lights_sabotaged: bool = False) -> float:
"""Get actual impostor vision radius (not affected by lights)."""
return self.vision_radius * self.impostor_vision
@classmethod @classmethod
def load(cls, path: str) -> "GameConfig": def load(cls, path: str) -> "GameConfig":
"""Load config from YAML or JSON file.""" """Load config from YAML or JSON file."""
@ -88,28 +113,25 @@ class GameConfig:
# Flatten nested structure from YAML # Flatten nested structure from YAML
mappings = { mappings = {
"game": ["map_name", "min_players", "max_players", "num_impostors"], "game": ["map_name", "num_impostors"],
"player": ["player_speed", "vision_range", "crewmate_vision", "impostor_vision"], "player": ["player_speed", "crewmate_vision", "impostor_vision"],
"impostor": ["kill_cooldown", "kill_range"], "impostor": ["kill_cooldown", "kill_distance"],
"meeting": ["emergency_cooldown", "emergencies_per_player", "discussion_time", "meeting": ["emergency_cooldown", "emergencies_per_player", "discussion_time",
"voting_time", "confirm_ejects", "anonymous_votes"], "voting_time", "confirm_ejects", "anonymous_votes"],
"sabotage": ["o2_timer", "reactor_timer", "lights_vision_multiplier"], "sabotage": ["sabotage_cooldown", "o2_timer", "reactor_timer", "lights_vision_multiplier"],
"crewmate": ["tasks_short", "tasks_long", "tasks_common"], "tasks": ["visual_tasks", "taskbar_updates", "common_tasks", "long_tasks", "short_tasks"],
"llm": ["max_discussion_rounds", "convergence_threshold"] "llm": ["max_discussion_rounds", "convergence_threshold"]
} }
for section, keys in mappings.items(): for section, keys in mappings.items():
section_data = data.get(section, {}) section_data = data.get(section, {})
for key in keys: for key in keys:
# Handle key name variations # Check if key exists in section data directly
yaml_key = key.replace(f"{section}_", "").replace("player_", "") if key in section_data:
if key == "map_name": setattr(config, key, section_data[key])
yaml_key = "map" # Also check top-level for flat JSON configs
elif key == "player_speed": elif key in data:
yaml_key = "speed" setattr(config, key, data[key])
if yaml_key in section_data:
setattr(config, key, section_data[yaml_key])
return config return config
@ -170,20 +192,20 @@ class GameEngine:
player_id: str, player_id: str,
name: str, name: str,
color: str, color: str,
role: Role = Role.CREWMATE, role: Role = Role.CREWMATE
speed: Optional[float] = None,
vision: Optional[float] = None
) -> Player: ) -> Player:
"""Add a player to the game. All stats are configurable.""" """Add a player to the game. Speed is game-wide constant."""
is_impostor = role == Role.IMPOSTOR is_impostor = role == Role.IMPOSTOR
# Get spawn point from map
spawn = self.map.spawn_points.get("cafeteria", Position(x=500, y=200))
player = Player( player = Player(
id=player_id, id=player_id,
name=name, name=name,
color=color, color=color,
role=role, role=role,
position=Position(room_id="cafeteria"), position=Position(x=spawn.x, y=spawn.y, room_id="cafeteria"),
speed=speed or self.config.player_speed,
kill_cooldown=self.config.kill_cooldown if is_impostor else 0.0 kill_cooldown=self.config.kill_cooldown if is_impostor else 0.0
) )
@ -299,7 +321,7 @@ class GameEngine:
player.path = path player.path = path
total_distance = self.map.path_distance(path) total_distance = self.map.path_distance(path)
travel_time = total_distance / player.speed travel_time = total_distance / self.config.player_speed
self.simulator.schedule_in(travel_time, "PLAYER_MOVE_COMPLETE", { self.simulator.schedule_in(travel_time, "PLAYER_MOVE_COMPLETE", {
"player_id": player_id, "player_id": player_id,

248
src/engine/path_utils.py Normal file
View File

@ -0,0 +1,248 @@
"""
The Glass Box League Path Utilities
Utilities for consistent walk speed interpolation along paths.
Critical for rendering without teleportation artifacts.
"""
from dataclasses import dataclass, field
from typing import Optional
import math
from .types import Position
@dataclass
class PathSegment:
"""A segment of a walking path with distance info."""
start: Position
end: Position
distance: float # Euclidean distance
cumulative_distance: float # Distance from path start to segment end
def interpolate(self, t: float) -> Position:
"""
Interpolate position along this segment.
Args:
t: 0.0 = start, 1.0 = end
"""
return Position(
x=self.start.x + (self.end.x - self.start.x) * t,
y=self.start.y + (self.end.y - self.start.y) * t,
room_id=None # Will be recalculated by engine
)
@dataclass
class WalkPath:
"""
A complete walking path with distance tracking.
Enables smooth interpolation at constant speed.
"""
waypoints: list[Position]
segments: list[PathSegment] = field(default_factory=list)
total_distance: float = 0.0
def __post_init__(self):
"""Calculate segments from waypoints."""
if len(self.waypoints) < 2:
return
cumulative = 0.0
self.segments = []
for i in range(len(self.waypoints) - 1):
start = self.waypoints[i]
end = self.waypoints[i + 1]
dist = start.distance_to(end)
cumulative += dist
self.segments.append(PathSegment(
start=start,
end=end,
distance=dist,
cumulative_distance=cumulative
))
self.total_distance = cumulative
def position_at_distance(self, traveled: float) -> Position:
"""
Get position after traveling a certain distance along the path.
Args:
traveled: Distance traveled from path start (pixels)
Returns:
Interpolated position
"""
if not self.segments:
return self.waypoints[0] if self.waypoints else Position()
# Clamp to path bounds
if traveled <= 0:
return self.waypoints[0]
if traveled >= self.total_distance:
return self.waypoints[-1]
# Find which segment we're in
for segment in self.segments:
if traveled <= segment.cumulative_distance:
# How far into this segment?
prev_cumulative = segment.cumulative_distance - segment.distance
segment_traveled = traveled - prev_cumulative
t = segment_traveled / segment.distance if segment.distance > 0 else 0
return segment.interpolate(t)
# Fallback (shouldn't reach here)
return self.waypoints[-1]
def position_at_time(self, elapsed: float, speed: float) -> Position:
"""
Get position after elapsed time at given speed.
Args:
elapsed: Time elapsed (seconds)
speed: Walk speed (pixels/second)
Returns:
Interpolated position
"""
traveled = elapsed * speed
return self.position_at_distance(traveled)
def time_to_complete(self, speed: float) -> float:
"""Time required to walk the entire path at given speed."""
return self.total_distance / speed if speed > 0 else float('inf')
def is_complete(self, elapsed: float, speed: float) -> bool:
"""Check if path is fully walked after elapsed time."""
return elapsed * speed >= self.total_distance
def distance_at_time(self, elapsed: float, speed: float) -> float:
"""Distance traveled after elapsed time."""
return min(elapsed * speed, self.total_distance)
def progress(self, elapsed: float, speed: float) -> float:
"""Progress along path as 0.0-1.0."""
if self.total_distance == 0:
return 1.0
return min(elapsed * speed / self.total_distance, 1.0)
@classmethod
def from_positions(cls, positions: list[Position]) -> "WalkPath":
"""Create path from list of positions."""
return cls(waypoints=positions)
@classmethod
def direct(cls, start: Position, end: Position) -> "WalkPath":
"""Create direct path between two points."""
return cls(waypoints=[start, end])
@dataclass
class WalkState:
"""
Tracks a player's current walk state.
Used by engine to update positions each tick.
"""
player_id: str
path: WalkPath
start_time: float # Game time when walk started
speed: float # pixels/second
def current_position(self, current_time: float) -> Position:
"""Get current position at given game time."""
elapsed = current_time - self.start_time
return self.path.position_at_time(elapsed, self.speed)
def is_complete(self, current_time: float) -> bool:
"""Check if walk is complete at given time."""
elapsed = current_time - self.start_time
return self.path.is_complete(elapsed, self.speed)
def arrival_time(self) -> float:
"""Get game time when walk completes."""
return self.start_time + self.path.time_to_complete(self.speed)
def progress(self, current_time: float) -> float:
"""Get progress 0.0-1.0 at given time."""
elapsed = current_time - self.start_time
return self.path.progress(elapsed, self.speed)
class WalkManager:
"""
Manages active walks for all players.
Provides frame-accurate position queries for rendering.
"""
def __init__(self):
self._active_walks: dict[str, WalkState] = {}
def start_walk(self, player_id: str, path: WalkPath,
start_time: float, speed: float) -> WalkState:
"""Start a new walk for a player."""
state = WalkState(
player_id=player_id,
path=path,
start_time=start_time,
speed=speed
)
self._active_walks[player_id] = state
return state
def get_position(self, player_id: str, current_time: float) -> Optional[Position]:
"""
Get player's current position.
Returns None if player has no active walk.
"""
state = self._active_walks.get(player_id)
if not state:
return None
return state.current_position(current_time)
def get_walk_state(self, player_id: str) -> Optional[WalkState]:
"""Get player's current walk state."""
return self._active_walks.get(player_id)
def is_walking(self, player_id: str, current_time: float) -> bool:
"""Check if player is currently walking."""
state = self._active_walks.get(player_id)
if not state:
return False
return not state.is_complete(current_time)
def cancel_walk(self, player_id: str):
"""Cancel a player's walk."""
self._active_walks.pop(player_id, None)
def get_completed_walks(self, current_time: float) -> list[WalkState]:
"""Get list of walks that completed by current_time."""
completed = []
for player_id, state in list(self._active_walks.items()):
if state.is_complete(current_time):
completed.append(state)
return completed
def cleanup_completed(self, current_time: float) -> list[WalkState]:
"""Remove completed walks and return them."""
completed = []
for player_id in list(self._active_walks.keys()):
state = self._active_walks[player_id]
if state.is_complete(current_time):
completed.append(state)
del self._active_walks[player_id]
return completed
def get_all_positions(self, current_time: float) -> dict[str, Position]:
"""Get positions of all walking players at current_time."""
return {
player_id: state.current_position(current_time)
for player_id, state in self._active_walks.items()
}

View File

@ -131,7 +131,7 @@ class Simulator:
"""Get all living players in a specific room.""" """Get all living players in a specific room."""
return [ return [
p for p in self.get_living_players() p for p in self.get_living_players()
if p.position.is_in_room() and p.position.room_id == room_id if p.position.room_id == room_id
] ]
def bodies_at(self, room_id: str) -> list[Body]: def bodies_at(self, room_id: str) -> list[Body]:

View File

@ -2,12 +2,14 @@
The Glass Box League Core Types The Glass Box League Core Types
Fundamental data structures for the discrete event simulator. Fundamental data structures for the discrete event simulator.
Now with pixel-based positions for ray-traced FOV.
""" """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from enum import Enum, auto from enum import Enum, auto
from typing import Optional from typing import Optional
import uuid import uuid
import math
class Role(Enum): class Role(Enum):
@ -26,21 +28,48 @@ class GamePhase(Enum):
@dataclass @dataclass
class Position: class Position:
""" """
A position in the game world. A pixel-based position in the game world.
Can be: Coordinates are in pixels on the map image.
- In a room: room_id is set, edge_id is None Room ID is derived from position via polygon containment.
- On an edge: edge_id is set, progress is 0.0-1.0
""" """
x: float = 0.0
y: float = 0.0
# Derived from position (set by engine)
room_id: Optional[str] = None room_id: Optional[str] = None
edge_id: Optional[str] = None
progress: float = 0.0 # 0.0 = start of edge, 1.0 = end
def is_in_room(self) -> bool: def distance_to(self, other: "Position") -> float:
return self.room_id is not None and self.edge_id is None """Euclidean distance to another position."""
return math.sqrt((self.x - other.x)**2 + (self.y - other.y)**2)
def is_on_edge(self) -> bool: def direction_to(self, other: "Position") -> tuple[float, float]:
return self.edge_id is not None """Unit vector pointing toward another position."""
dist = self.distance_to(other)
if dist == 0:
return (0.0, 0.0)
return ((other.x - self.x) / dist, (other.y - self.y) / dist)
def move_toward(self, target: "Position", distance: float) -> "Position":
"""Return new position moved toward target by distance."""
dir_x, dir_y = self.direction_to(target)
return Position(
x=self.x + dir_x * distance,
y=self.y + dir_y * distance,
room_id=None # Will be recalculated
)
def to_tuple(self) -> tuple[float, float]:
return (self.x, self.y)
@classmethod
def from_tuple(cls, t: tuple[float, float]) -> "Position":
return cls(x=t[0], y=t[1])
def __eq__(self, other) -> bool:
if not isinstance(other, Position):
return False
return abs(self.x - other.x) < 0.01 and abs(self.y - other.y) < 0.01
@dataclass @dataclass
@ -50,14 +79,14 @@ class Player:
name: str name: str
color: str color: str
role: Role = Role.CREWMATE role: Role = Role.CREWMATE
position: Position = field(default_factory=lambda: Position(room_id="cafeteria")) position: Position = field(default_factory=Position)
is_alive: bool = True is_alive: bool = True
speed: float = 1.0 # meters per second # Speed is a game-wide constant in GameConfig, not per-player
# Movement intent # Movement
destination: Optional[str] = None # Target room_id destination: Optional[Position] = None # Target position
path: list[str] = field(default_factory=list) # Sequence of edge_ids path: list[Position] = field(default_factory=list) # Waypoints
# Task state # Task state
current_task: Optional[str] = None current_task: Optional[str] = None
@ -69,7 +98,7 @@ class Player:
kill_cooldown: float = 0.0 kill_cooldown: float = 0.0
# Trigger muting # Trigger muting
muted_triggers: dict[str, float] = field(default_factory=dict) # trigger_type -> until_time muted_triggers: dict[str, float] = field(default_factory=dict)
@dataclass @dataclass
@ -97,3 +126,17 @@ class Event:
def __lt__(self, other: "Event") -> bool: def __lt__(self, other: "Event") -> bool:
return self.time < other.time return self.time < other.time
@dataclass
class Wall:
"""A wall segment that blocks vision."""
p1: tuple[float, float]
p2: tuple[float, float]
def to_dict(self) -> dict:
return {"p1": list(self.p1), "p2": list(self.p2)}
@classmethod
def from_dict(cls, data: dict) -> "Wall":
return cls(p1=tuple(data["p1"]), p2=tuple(data["p2"]))

View File

@ -0,0 +1,313 @@
"""
The Glass Box League Ray-Traced Vision System
Line-of-sight visibility using raycasting against wall segments.
"""
from dataclasses import dataclass
from typing import Optional
import math
from .types import Position, Wall, Player
@dataclass
class VisibilityResult:
"""Result of visibility check."""
visible: bool
distance: float
blocked_by_wall: bool = False
class RaycastVision:
"""
Vision system using raycasting for line-of-sight.
Handles:
- Impostor vs Crewmate vision (impostors see further)
- Lights sabotage (only affects crewmates, not impostors)
- Wall occlusion
"""
def __init__(
self,
walls: list[Wall],
base_vision_radius: float = 300.0,
crewmate_vision: float = 1.0,
impostor_vision: float = 1.5,
lights_multiplier: float = 0.25
):
"""
Args:
walls: List of wall segments that block vision
base_vision_radius: Base vision distance in pixels
crewmate_vision: Crewmate vision multiplier
impostor_vision: Impostor vision multiplier
lights_multiplier: Vision reduction during lights sabotage
"""
self.walls = walls
self.base_vision_radius = base_vision_radius
self.crewmate_vision = crewmate_vision
self.impostor_vision = impostor_vision
self.lights_multiplier = lights_multiplier
self.lights_sabotaged = False
# Legacy support
self.vision_radius = base_vision_radius
def get_vision_radius_for_player(self, player: Player) -> float:
"""
Get vision radius for a specific player.
- Impostors: base * impostor_vision (NOT affected by lights)
- Crewmates: base * crewmate_vision (affected by lights)
"""
from .types import Role
if player.role == Role.IMPOSTOR:
# Impostors have better vision and are NOT affected by lights sabotage
return self.base_vision_radius * self.impostor_vision
else:
# Crewmates are affected by lights sabotage
base = self.base_vision_radius * self.crewmate_vision
if self.lights_sabotaged:
return base * self.lights_multiplier
return base
def set_lights_sabotaged(self, sabotaged: bool):
"""Set lights sabotage state. Only affects crewmate vision."""
self.lights_sabotaged = sabotaged
def line_segment_intersection(
self,
ray_start: tuple[float, float],
ray_end: tuple[float, float],
wall_p1: tuple[float, float],
wall_p2: tuple[float, float]
) -> Optional[tuple[float, float]]:
"""
Check if ray intersects wall segment.
Returns intersection point if exists, None otherwise.
Uses parametric line intersection.
"""
x1, y1 = ray_start
x2, y2 = ray_end
x3, y3 = wall_p1
x4, y4 = wall_p2
denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4)
if abs(denom) < 1e-10:
return None # Parallel lines
t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom
u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom
# Check if intersection is within both line segments
if 0 <= t <= 1 and 0 <= u <= 1:
ix = x1 + t * (x2 - x1)
iy = y1 + t * (y2 - y1)
return (ix, iy)
return None
def is_line_blocked(
self,
from_pos: tuple[float, float],
to_pos: tuple[float, float]
) -> bool:
"""Check if any wall blocks the line between two points."""
for wall in self.walls:
if self.line_segment_intersection(from_pos, to_pos, wall.p1, wall.p2):
return True
return False
def check_visibility(
self,
observer: Position,
target: Position,
vision_radius: float = None
) -> VisibilityResult:
"""
Check if target is visible from observer position.
Target is visible if:
1. Within vision radius
2. No walls blocking line-of-sight
"""
radius = vision_radius or self.vision_radius
distance = observer.distance_to(target)
# Beyond vision radius
if distance > radius:
return VisibilityResult(visible=False, distance=distance)
# Check for wall occlusion
blocked = self.is_line_blocked(observer.to_tuple(), target.to_tuple())
return VisibilityResult(
visible=not blocked,
distance=distance,
blocked_by_wall=blocked
)
def get_visible_players(
self,
observer: Player,
all_players: list[Player],
include_dead: bool = False
) -> list[tuple[Player, float]]:
"""
Get all players visible to observer.
Uses observer's role to determine vision radius.
Returns list of (player, distance) tuples for visible players.
"""
visible = []
vision_radius = self.get_vision_radius_for_player(observer)
for player in all_players:
# Skip self
if player.id == observer.id:
continue
# Skip dead unless requested
if not player.is_alive and not include_dead:
continue
result = self.check_visibility(
observer.position,
player.position,
vision_radius=vision_radius
)
if result.visible:
visible.append((player, result.distance))
# Sort by distance
visible.sort(key=lambda x: x[1])
return visible
def get_visible_bodies(
self,
observer: Player,
bodies: list
) -> list[tuple]:
"""Get all bodies visible to observer."""
visible = []
for body in bodies:
result = self.check_visibility(observer.position, body.position)
if result.visible:
visible.append((body, result.distance))
visible.sort(key=lambda x: x[1])
return visible
def cast_rays_for_polygon(
self,
observer: Position,
num_rays: int = 360
) -> list[tuple[float, float]]:
"""
Cast rays in all directions to build vision polygon.
Used for rendering the visible area.
Returns list of points forming the vision boundary.
"""
points = []
for i in range(num_rays):
angle = 2 * math.pi * i / num_rays
# Ray direction
dx = math.cos(angle)
dy = math.sin(angle)
# Default to vision radius
ray_end = (
observer.x + dx * self.vision_radius,
observer.y + dy * self.vision_radius
)
# Find closest intersection with any wall
closest_dist = self.vision_radius
closest_point = ray_end
for wall in self.walls:
intersection = self.line_segment_intersection(
observer.to_tuple(),
ray_end,
wall.p1,
wall.p2
)
if intersection:
dist = math.sqrt(
(intersection[0] - observer.x)**2 +
(intersection[1] - observer.y)**2
)
if dist < closest_dist:
closest_dist = dist
closest_point = intersection
points.append(closest_point)
return points
def update_vision_radius(self, new_radius: float):
"""Update base vision radius."""
self.base_vision_radius = new_radius
self.vision_radius = new_radius
def add_wall(self, wall: Wall):
"""Add a wall segment."""
self.walls.append(wall)
def clear_walls(self):
"""Remove all walls."""
self.walls = []
@classmethod
def from_map_data(cls, map_data: dict, config: dict = None) -> "RaycastVision":
"""
Create from map JSON data and optional game config.
Args:
map_data: Map JSON with walls
config: Game config dict with vision settings
"""
walls = [
Wall.from_dict(w) for w in map_data.get("walls", [])
]
# Use config if provided, otherwise defaults
if config:
return cls(
walls=walls,
base_vision_radius=config.get("vision_radius", 300.0),
crewmate_vision=config.get("crewmate_vision", 1.0),
impostor_vision=config.get("impostor_vision", 1.5),
lights_multiplier=config.get("lights_vision_multiplier", 0.25)
)
return cls(walls=walls)
@classmethod
def from_game_config(cls, walls: list[Wall], game_config) -> "RaycastVision":
"""
Create from Wall list and GameConfig object.
Args:
walls: Wall segments
game_config: GameConfig instance
"""
return cls(
walls=walls,
base_vision_radius=game_config.vision_radius,
crewmate_vision=game_config.crewmate_vision,
impostor_vision=game_config.impostor_vision,
lights_multiplier=game_config.lights_vision_multiplier
)

View File

@ -1,12 +1,15 @@
""" """
The Glass Box League Map Model The Glass Box League Map Model
Continuous node graph with distances for position tracking. Continuous node graph with pixel coordinates for ray-traced vision.
""" """
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Optional from typing import Optional
import json import json
import math
from ..engine.types import Position, Wall
@dataclass @dataclass
@ -15,6 +18,7 @@ class Task:
id: str id: str
name: str name: str
duration: float # seconds to complete duration: float # seconds to complete
position: Position = field(default_factory=Position)
is_visual: bool = False # Can others see you doing it? is_visual: bool = False # Can others see you doing it?
@ -23,6 +27,7 @@ class Vent:
"""A vent connection point.""" """A vent connection point."""
id: str id: str
connects_to: list[str] # Other vent IDs connects_to: list[str] # Other vent IDs
position: Position = field(default_factory=Position)
@dataclass @dataclass
@ -30,13 +35,20 @@ class Room:
"""A room (node) in the map.""" """A room (node) in the map."""
id: str id: str
name: str name: str
center: Position = field(default_factory=Position)
bounds: tuple[Position, Position] = None # Top-left, bottom-right
tasks: list[Task] = field(default_factory=list) tasks: list[Task] = field(default_factory=list)
vent: Optional[Vent] = None vent: Optional[Vent] = None
emergency_button: Optional[Position] = None
# Position within the room (for spawn points, task locations) def contains_point(self, pos: Position) -> bool:
# Simplified: just a single point for now """Check if a position is within this room's bounds."""
x: float = 0.0 if self.bounds is None:
y: float = 0.0 # Fallback: circular area around center
return self.center.distance_to(pos) < 100
tl, br = self.bounds
return (tl.x <= pos.x <= br.x and tl.y <= pos.y <= br.y)
@dataclass @dataclass
@ -45,11 +57,18 @@ class Edge:
id: str id: str
room_a: str # Room ID room_a: str # Room ID
room_b: str # Room ID room_b: str # Room ID
distance: float # meters waypoints: list[Position] = field(default_factory=list)
# Path geometry (list of waypoints for LoS calculation) @property
# Each waypoint is (x, y) def distance(self) -> float:
waypoints: list[tuple[float, float]] = field(default_factory=list) """Calculate total path distance through waypoints."""
if not self.waypoints:
return 0.0
total = 0.0
for i in range(len(self.waypoints) - 1):
total += self.waypoints[i].distance_to(self.waypoints[i + 1])
return total
def other_room(self, room_id: str) -> str: def other_room(self, room_id: str) -> str:
"""Get the room on the other end of this edge.""" """Get the room on the other end of this edge."""
@ -60,12 +79,20 @@ class GameMap:
""" """
The game map: a graph of rooms connected by edges. The game map: a graph of rooms connected by edges.
Supports pathfinding, distance calculation, and visibility queries. Now with pixel coordinates and wall geometry for ray-traced vision.
""" """
def __init__(self): def __init__(self):
self.name: str = ""
self.width: int = 2000
self.height: int = 1500
self.vision_radius: float = 300.0
self.vision_radius_sabotaged: float = 150.0
self.rooms: dict[str, Room] = {} self.rooms: dict[str, Room] = {}
self.edges: dict[str, Edge] = {} self.edges: dict[str, Edge] = {}
self.walls: list[Wall] = []
self.spawn_points: dict[str, Position] = {}
# Adjacency list: room_id -> list of (edge_id, neighbor_room_id) # Adjacency list: room_id -> list of (edge_id, neighbor_room_id)
self._adjacency: dict[str, list[tuple[str, str]]] = {} self._adjacency: dict[str, list[tuple[str, str]]] = {}
@ -89,10 +116,21 @@ class GameMap:
self._adjacency[edge.room_a].append((edge.id, edge.room_b)) self._adjacency[edge.room_a].append((edge.id, edge.room_b))
self._adjacency[edge.room_b].append((edge.id, edge.room_a)) self._adjacency[edge.room_b].append((edge.id, edge.room_a))
def add_wall(self, wall: Wall) -> None:
"""Add a wall segment."""
self.walls.append(wall)
def get_room(self, room_id: str) -> Optional[Room]: def get_room(self, room_id: str) -> Optional[Room]:
"""Get a room by ID.""" """Get a room by ID."""
return self.rooms.get(room_id) return self.rooms.get(room_id)
def get_room_at(self, pos: Position) -> Optional[Room]:
"""Find which room contains a position."""
for room in self.rooms.values():
if room.contains_point(pos):
return room
return None
def get_edge(self, edge_id: str) -> Optional[Edge]: def get_edge(self, edge_id: str) -> Optional[Edge]:
"""Get an edge by ID.""" """Get an edge by ID."""
return self.edges.get(edge_id) return self.edges.get(edge_id)
@ -108,6 +146,17 @@ class GameMap:
return self.edges[edge_id] return self.edges[edge_id]
return None return None
def get_spawn_position(self, room_id: str = "cafeteria") -> Position:
"""Get a spawn position, defaulting to cafeteria."""
if room_id in self.spawn_points:
return self.spawn_points[room_id]
if "cafeteria" in self.spawn_points:
return self.spawn_points["cafeteria"]
# Fallback to room center
if room_id in self.rooms:
return self.rooms[room_id].center
return Position(x=self.width / 2, y=self.height / 2)
# --- Pathfinding --- # --- Pathfinding ---
def find_path(self, from_room: str, to_room: str) -> Optional[list[str]]: def find_path(self, from_room: str, to_room: str) -> Optional[list[str]]:
@ -144,6 +193,46 @@ class GameMap:
return None return None
def get_path_waypoints(self, from_pos: Position, to_room: str) -> list[Position]:
"""
Get full path from a position to a room center.
Returns list of waypoints to walk through.
"""
current_room = self.get_room_at(from_pos)
if not current_room:
return [self.rooms[to_room].center] if to_room in self.rooms else []
edge_ids = self.find_path(current_room.id, to_room)
if edge_ids is None:
return []
waypoints = [from_pos] # Start from current position
for edge_id in edge_ids:
edge = self.edges[edge_id]
waypoints.extend(edge.waypoints)
# Add destination room center
if to_room in self.rooms:
waypoints.append(self.rooms[to_room].center)
return waypoints
def create_walk_path(self, from_pos: Position, to_room: str):
"""
Create a WalkPath for walking from position to room.
Returns a WalkPath object with proper segment distances
for frame-accurate interpolation.
"""
from ..engine.path_utils import WalkPath
waypoints = self.get_path_waypoints(from_pos, to_room)
if not waypoints:
return WalkPath(waypoints=[from_pos])
return WalkPath(waypoints=waypoints)
def path_distance(self, edge_ids: list[str]) -> float: def path_distance(self, edge_ids: list[str]) -> float:
"""Calculate total distance of a path.""" """Calculate total distance of a path."""
return sum(self.edges[eid].distance for eid in edge_ids) return sum(self.edges[eid].distance for eid in edge_ids)
@ -153,14 +242,20 @@ class GameMap:
def to_dict(self) -> dict: def to_dict(self) -> dict:
"""Serialize map to dictionary.""" """Serialize map to dictionary."""
return { return {
"name": self.name,
"width": self.width,
"height": self.height,
"vision_radius": self.vision_radius,
"vision_radius_sabotaged": self.vision_radius_sabotaged,
"spawn_points": {k: [v.x, v.y] for k, v in self.spawn_points.items()},
"rooms": [ "rooms": [
{ {
"id": r.id, "id": r.id,
"name": r.name, "name": r.name,
"x": r.x, "center": [r.center.x, r.center.y],
"y": r.y, "bounds": [[r.bounds[0].x, r.bounds[0].y], [r.bounds[1].x, r.bounds[1].y]] if r.bounds else None,
"tasks": [{"id": t.id, "name": t.name, "duration": t.duration} for t in r.tasks], "tasks": [{"id": t.id, "name": t.name, "duration": t.duration, "position": [t.position.x, t.position.y]} for t in r.tasks],
"vent": {"id": r.vent.id, "connects_to": r.vent.connects_to} if r.vent else None "vent": {"id": r.vent.id, "connects_to": r.vent.connects_to, "position": [r.vent.position.x, r.vent.position.y]} if r.vent else None
} }
for r in self.rooms.values() for r in self.rooms.values()
], ],
@ -169,11 +264,11 @@ class GameMap:
"id": e.id, "id": e.id,
"room_a": e.room_a, "room_a": e.room_a,
"room_b": e.room_b, "room_b": e.room_b,
"distance": e.distance, "waypoints": [[p.x, p.y] for p in e.waypoints]
"waypoints": e.waypoints
} }
for e in self.edges.values() for e in self.edges.values()
] ],
"walls": [w.to_dict() for w in self.walls]
} }
def save(self, path: str) -> None: def save(self, path: str) -> None:
@ -188,21 +283,76 @@ class GameMap:
data = json.load(f) data = json.load(f)
game_map = cls() game_map = cls()
game_map.name = data.get("name", "")
game_map.width = data.get("width", 2000)
game_map.height = data.get("height", 1500)
game_map.vision_radius = data.get("vision_radius", 300.0)
game_map.vision_radius_sabotaged = data.get("vision_radius_sabotaged", 150.0)
for r in data["rooms"]: # Spawn points
tasks = [Task(id=t["id"], name=t["name"], duration=t["duration"]) for t in r.get("tasks", [])] for room_id, pos in data.get("spawn_points", {}).items():
vent = Vent(id=r["vent"]["id"], connects_to=r["vent"]["connects_to"]) if r.get("vent") else None game_map.spawn_points[room_id] = Position(x=pos[0], y=pos[1])
room = Room(id=r["id"], name=r["name"], x=r.get("x", 0), y=r.get("y", 0), tasks=tasks, vent=vent)
# Rooms
for r in data.get("rooms", []):
center = r.get("center", [0, 0])
bounds_data = r.get("bounds")
bounds = None
if bounds_data:
bounds = (
Position(x=bounds_data[0][0], y=bounds_data[0][1]),
Position(x=bounds_data[1][0], y=bounds_data[1][1])
)
tasks = []
for t in r.get("tasks", []):
pos = t.get("position", [0, 0])
tasks.append(Task(
id=t["id"],
name=t["name"],
duration=t["duration"],
position=Position(x=pos[0], y=pos[1])
))
vent = None
if r.get("vent"):
v = r["vent"]
pos = v.get("position", [0, 0])
vent = Vent(
id=v["id"],
connects_to=v["connects_to"],
position=Position(x=pos[0], y=pos[1])
)
emergency = None
if r.get("emergency_button"):
eb = r["emergency_button"]
emergency = Position(x=eb[0], y=eb[1])
room = Room(
id=r["id"],
name=r["name"],
center=Position(x=center[0], y=center[1]),
bounds=bounds,
tasks=tasks,
vent=vent,
emergency_button=emergency
)
game_map.add_room(room) game_map.add_room(room)
for e in data["edges"]: # Edges
for e in data.get("edges", []):
waypoints = [Position(x=w[0], y=w[1]) for w in e.get("waypoints", [])]
edge = Edge( edge = Edge(
id=e["id"], id=e["id"],
room_a=e["room_a"], room_a=e["room_a"],
room_b=e["room_b"], room_b=e["room_b"],
distance=e["distance"], waypoints=waypoints
waypoints=[tuple(w) for w in e.get("waypoints", [])]
) )
game_map.add_edge(edge) game_map.add_edge(edge)
# Walls
for w in data.get("walls", []):
game_map.add_wall(Wall.from_dict(w))
return game_map return game_map

View File

@ -1,5 +1,6 @@
""" """
Tests for the game engine. Tests for the game engine.
Updated for pixel-based Position system.
""" """
import unittest import unittest
@ -14,40 +15,61 @@ from src.map.graph import GameMap, Room, Edge, Task, Vent
def create_simple_map(): def create_simple_map():
"""Create a simple test map.""" """Create a simple test map with pixel coordinates."""
game_map = GameMap() game_map = GameMap()
game_map.spawn_points["cafeteria"] = Position(x=500, y=200)
# Cafeteria with task # Cafeteria with task
game_map.add_room(Room( game_map.add_room(Room(
id="cafeteria", id="cafeteria",
name="Cafeteria", name="Cafeteria",
tasks=[Task(id="wires_cafe", name="Fix Wiring", duration=3.0)] center=Position(x=500, y=200),
bounds=(Position(x=400, y=100), Position(x=600, y=300)),
tasks=[Task(id="wires_cafe", name="Fix Wiring", duration=3.0, position=Position(x=450, y=150))]
)) ))
# Electrical with vent # Electrical with vent
elec_vent = Vent(id="vent_elec", connects_to=["vent_security"]) elec_vent = Vent(id="vent_elec", connects_to=["vent_security"], position=Position(x=200, y=450))
game_map.add_room(Room( game_map.add_room(Room(
id="electrical", id="electrical",
name="Electrical", name="Electrical",
center=Position(x=200, y=500),
bounds=(Position(x=100, y=400), Position(x=300, y=600)),
vent=elec_vent, vent=elec_vent,
tasks=[Task(id="wires_elec", name="Fix Wiring", duration=3.0)] tasks=[Task(id="wires_elec", name="Fix Wiring", duration=3.0, position=Position(x=250, y=550))]
)) ))
# Security with vent # Security with vent
sec_vent = Vent(id="vent_security", connects_to=["vent_elec"]) sec_vent = Vent(id="vent_security", connects_to=["vent_elec"], position=Position(x=400, y=450))
game_map.add_room(Room( game_map.add_room(Room(
id="security", id="security",
name="Security", name="Security",
center=Position(x=400, y=500),
bounds=(Position(x=300, y=400), Position(x=500, y=600)),
vent=sec_vent vent=sec_vent
)) ))
# Admin # Admin
game_map.add_room(Room(id="admin", name="Admin")) game_map.add_room(Room(
id="admin",
name="Admin",
center=Position(x=700, y=200),
bounds=(Position(x=600, y=100), Position(x=800, y=300))
))
# Connect rooms # Connect rooms with waypoints
game_map.add_edge(Edge(id="cafe_elec", room_a="cafeteria", room_b="electrical", distance=5.0)) game_map.add_edge(Edge(
game_map.add_edge(Edge(id="cafe_admin", room_a="cafeteria", room_b="admin", distance=3.0)) id="cafe_elec", room_a="cafeteria", room_b="electrical",
game_map.add_edge(Edge(id="elec_sec", room_a="electrical", room_b="security", distance=4.0)) waypoints=[Position(x=500, y=300), Position(x=300, y=400), Position(x=200, y=500)]
))
game_map.add_edge(Edge(
id="cafe_admin", room_a="cafeteria", room_b="admin",
waypoints=[Position(x=600, y=200), Position(x=700, y=200)]
))
game_map.add_edge(Edge(
id="elec_sec", room_a="electrical", room_b="security",
waypoints=[Position(x=300, y=500), Position(x=400, y=500)]
))
return game_map return game_map
@ -96,10 +118,6 @@ class TestGameEngineSetup(unittest.TestCase):
self.assertIn("p1", self.engine.impostor_ids) self.assertIn("p1", self.engine.impostor_ids)
self.assertEqual(player.kill_cooldown, self.config.kill_cooldown) self.assertEqual(player.kill_cooldown, self.config.kill_cooldown)
def test_custom_player_speed(self):
player = self.engine.add_player("p1", "Red", "red", speed=3.0)
self.assertEqual(player.speed, 3.0)
def test_impostor_context(self): def test_impostor_context(self):
self.engine.add_player("p1", "Red", "red", Role.IMPOSTOR) self.engine.add_player("p1", "Red", "red", Role.IMPOSTOR)
self.engine.add_player("p2", "Blue", "blue", Role.IMPOSTOR) self.engine.add_player("p2", "Blue", "blue", Role.IMPOSTOR)
@ -176,7 +194,7 @@ class TestMovement(unittest.TestCase):
def test_move_no_path(self): def test_move_no_path(self):
# Add isolated room # Add isolated room
self.game_map.add_room(Room(id="isolated", name="Isolated")) self.game_map.add_room(Room(id="isolated", name="Isolated", center=Position(x=1000, y=1000)))
self.engine.queue_action("p1", "MOVE", {"destination": "isolated"}) self.engine.queue_action("p1", "MOVE", {"destination": "isolated"})
results = self.engine.resolve_actions() results = self.engine.resolve_actions()
@ -222,7 +240,7 @@ class TestKill(unittest.TestCase):
def test_cannot_kill_different_room(self): def test_cannot_kill_different_room(self):
crew = self.engine.simulator.get_player("crew") crew = self.engine.simulator.get_player("crew")
crew.position = Position(room_id="electrical") crew.position = Position(x=200, y=500, room_id="electrical")
self.engine.queue_action("imp", "KILL", {"target_id": "crew"}) self.engine.queue_action("imp", "KILL", {"target_id": "crew"})
results = self.engine.resolve_actions() results = self.engine.resolve_actions()
@ -262,7 +280,7 @@ class TestVenting(unittest.TestCase):
# Place impostor in electrical (has vent) # Place impostor in electrical (has vent)
imp = self.engine.simulator.get_player("imp") imp = self.engine.simulator.get_player("imp")
imp.position = Position(room_id="electrical") imp.position = Position(x=200, y=500, room_id="electrical")
def test_impostor_can_vent(self): def test_impostor_can_vent(self):
self.engine.queue_action("imp", "VENT", {"destination": "security"}) self.engine.queue_action("imp", "VENT", {"destination": "security"})
@ -272,7 +290,7 @@ class TestVenting(unittest.TestCase):
def test_crewmate_cannot_vent(self): def test_crewmate_cannot_vent(self):
crew = self.engine.simulator.get_player("crew") crew = self.engine.simulator.get_player("crew")
crew.position = Position(room_id="electrical") crew.position = Position(x=200, y=500, room_id="electrical")
self.engine.queue_action("crew", "VENT", {"destination": "security"}) self.engine.queue_action("crew", "VENT", {"destination": "security"})
results = self.engine.resolve_actions() results = self.engine.resolve_actions()
@ -282,7 +300,7 @@ class TestVenting(unittest.TestCase):
def test_cannot_vent_unconnected(self): def test_cannot_vent_unconnected(self):
# Cafeteria has no vent # Cafeteria has no vent
imp = self.engine.simulator.get_player("imp") imp = self.engine.simulator.get_player("imp")
imp.position = Position(room_id="cafeteria") imp.position = Position(x=500, y=200, room_id="cafeteria")
self.engine.queue_action("imp", "VENT", {"destination": "security"}) self.engine.queue_action("imp", "VENT", {"destination": "security"})
results = self.engine.resolve_actions() results = self.engine.resolve_actions()
@ -336,7 +354,7 @@ class TestReporting(unittest.TestCase):
id="body1", id="body1",
player_id="dead", player_id="dead",
player_name="Blue", player_name="Blue",
position=Position(room_id="cafeteria"), position=Position(x=500, y=200, room_id="cafeteria"),
time_of_death=0.0 time_of_death=0.0
) )
self.engine.simulator.bodies.append(body) self.engine.simulator.bodies.append(body)
@ -349,7 +367,7 @@ class TestReporting(unittest.TestCase):
def test_cannot_report_body_in_different_room(self): def test_cannot_report_body_in_different_room(self):
player = self.engine.simulator.get_player("p1") player = self.engine.simulator.get_player("p1")
player.position = Position(room_id="electrical") player.position = Position(x=200, y=500, room_id="electrical")
self.engine.queue_action("p1", "REPORT", {"body_id": "body1"}) self.engine.queue_action("p1", "REPORT", {"body_id": "body1"})
results = self.engine.resolve_actions() results = self.engine.resolve_actions()
@ -383,7 +401,7 @@ class TestEmergency(unittest.TestCase):
def test_cannot_call_emergency_outside_cafeteria(self): def test_cannot_call_emergency_outside_cafeteria(self):
player = self.engine.simulator.get_player("p1") player = self.engine.simulator.get_player("p1")
player.position = Position(room_id="electrical") player.position = Position(x=200, y=500, room_id="electrical")
self.engine.queue_action("p1", "EMERGENCY", {}) self.engine.queue_action("p1", "EMERGENCY", {})
results = self.engine.resolve_actions() results = self.engine.resolve_actions()

View File

@ -6,11 +6,11 @@ import unittest
import sys import sys
import os import os
import tempfile import tempfile
import json
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.map.graph import GameMap, Room, Edge, Task, Vent from src.map.graph import GameMap, Room, Edge, Task, Vent
from src.engine.types import Position
class TestRoom(unittest.TestCase): class TestRoom(unittest.TestCase):
@ -34,18 +34,40 @@ class TestRoom(unittest.TestCase):
room = Room(id="test", name="Test Room", vent=vent) room = Room(id="test", name="Test Room", vent=vent)
self.assertIsNotNone(room.vent) self.assertIsNotNone(room.vent)
self.assertEqual(len(room.vent.connects_to), 2) self.assertEqual(len(room.vent.connects_to), 2)
def test_room_with_center(self):
room = Room(id="test", name="Test Room", center=Position(x=100, y=200))
self.assertEqual(room.center.x, 100)
self.assertEqual(room.center.y, 200)
def test_room_contains_point(self):
room = Room(
id="test", name="Test Room",
center=Position(x=100, y=100),
bounds=(Position(x=0, y=0), Position(x=200, y=200))
)
self.assertTrue(room.contains_point(Position(x=100, y=100)))
self.assertTrue(room.contains_point(Position(x=50, y=50)))
self.assertFalse(room.contains_point(Position(x=300, y=100)))
class TestEdge(unittest.TestCase): class TestEdge(unittest.TestCase):
"""Tests for Edge dataclass.""" """Tests for Edge dataclass."""
def test_edge_creation(self): def test_edge_creation(self):
edge = Edge(id="e1", room_a="a", room_b="b", distance=5.0) edge = Edge(id="e1", room_a="a", room_b="b")
self.assertEqual(edge.id, "e1") self.assertEqual(edge.id, "e1")
self.assertEqual(edge.distance, 5.0) self.assertEqual(edge.room_a, "a")
def test_edge_with_waypoints(self):
edge = Edge(
id="e1", room_a="a", room_b="b",
waypoints=[Position(x=0, y=0), Position(x=100, y=0)]
)
self.assertAlmostEqual(edge.distance, 100.0)
def test_edge_other_room(self): def test_edge_other_room(self):
edge = Edge(id="e1", room_a="a", room_b="b", distance=5.0) edge = Edge(id="e1", room_a="a", room_b="b")
self.assertEqual(edge.other_room("a"), "b") self.assertEqual(edge.other_room("a"), "b")
self.assertEqual(edge.other_room("b"), "a") self.assertEqual(edge.other_room("b"), "a")
@ -60,14 +82,24 @@ class TestGameMap(unittest.TestCase):
# Create rooms: A -- B -- C # Create rooms: A -- B -- C
# | # |
# D # D
self.game_map.add_room(Room(id="a", name="Room A")) self.game_map.add_room(Room(id="a", name="Room A", center=Position(x=0, y=0)))
self.game_map.add_room(Room(id="b", name="Room B")) self.game_map.add_room(Room(id="b", name="Room B", center=Position(x=100, y=0)))
self.game_map.add_room(Room(id="c", name="Room C")) self.game_map.add_room(Room(id="c", name="Room C", center=Position(x=200, y=0)))
self.game_map.add_room(Room(id="d", name="Room D")) self.game_map.add_room(Room(id="d", name="Room D", center=Position(x=100, y=100)))
self.game_map.add_edge(Edge(id="ab", room_a="a", room_b="b", distance=3.0)) # Create edges with waypoints
self.game_map.add_edge(Edge(id="bc", room_a="b", room_b="c", distance=4.0)) self.game_map.add_edge(Edge(
self.game_map.add_edge(Edge(id="bd", room_a="b", room_b="d", distance=2.0)) id="ab", room_a="a", room_b="b",
waypoints=[Position(x=0, y=0), Position(x=50, y=0), Position(x=100, y=0)]
))
self.game_map.add_edge(Edge(
id="bc", room_a="b", room_b="c",
waypoints=[Position(x=100, y=0), Position(x=200, y=0)]
))
self.game_map.add_edge(Edge(
id="bd", room_a="b", room_b="d",
waypoints=[Position(x=100, y=0), Position(x=100, y=100)]
))
def test_add_room(self): def test_add_room(self):
self.assertEqual(len(self.game_map.rooms), 4) self.assertEqual(len(self.game_map.rooms), 4)
@ -87,7 +119,7 @@ class TestGameMap(unittest.TestCase):
def test_get_edge(self): def test_get_edge(self):
edge = self.game_map.get_edge("ab") edge = self.game_map.get_edge("ab")
self.assertIsNotNone(edge) self.assertIsNotNone(edge)
self.assertEqual(edge.distance, 3.0) self.assertAlmostEqual(edge.distance, 100.0)
def test_get_neighbors(self): def test_get_neighbors(self):
neighbors = self.game_map.get_neighbors("b") neighbors = self.game_map.get_neighbors("b")
@ -126,20 +158,6 @@ class TestGameMap(unittest.TestCase):
self.game_map.add_room(Room(id="isolated", name="Isolated")) self.game_map.add_room(Room(id="isolated", name="Isolated"))
path = self.game_map.find_path("a", "isolated") path = self.game_map.find_path("a", "isolated")
self.assertIsNone(path) self.assertIsNone(path)
def test_path_distance(self):
path = self.game_map.find_path("a", "c")
distance = self.game_map.path_distance(path)
self.assertEqual(distance, 7.0) # 3 + 4
def test_shortest_path(self):
# Add direct edge from a to d (should be longer)
self.game_map.add_edge(Edge(id="ad", room_a="a", room_b="d", distance=10.0))
# Shortest path should still go through b
path = self.game_map.find_path("a", "d")
distance = self.game_map.path_distance(path)
self.assertEqual(distance, 5.0) # 3 + 2 via b
class TestMapSerialization(unittest.TestCase): class TestMapSerialization(unittest.TestCase):
@ -147,9 +165,12 @@ class TestMapSerialization(unittest.TestCase):
def test_to_dict(self): def test_to_dict(self):
game_map = GameMap() game_map = GameMap()
game_map.add_room(Room(id="a", name="A")) game_map.add_room(Room(id="a", name="A", center=Position(x=0, y=0)))
game_map.add_room(Room(id="b", name="B")) game_map.add_room(Room(id="b", name="B", center=Position(x=100, y=0)))
game_map.add_edge(Edge(id="ab", room_a="a", room_b="b", distance=5.0)) game_map.add_edge(Edge(
id="ab", room_a="a", room_b="b",
waypoints=[Position(x=0, y=0), Position(x=100, y=0)]
))
data = game_map.to_dict() data = game_map.to_dict()
self.assertEqual(len(data["rooms"]), 2) self.assertEqual(len(data["rooms"]), 2)
@ -157,11 +178,19 @@ class TestMapSerialization(unittest.TestCase):
def test_save_and_load(self): def test_save_and_load(self):
game_map = GameMap() game_map = GameMap()
task = Task(id="t1", name="Task", duration=3.0) task = Task(id="t1", name="Task", duration=3.0, position=Position(x=50, y=50))
vent = Vent(id="v1", connects_to=["v2"]) vent = Vent(id="v1", connects_to=["v2"], position=Position(x=60, y=60))
game_map.add_room(Room(id="a", name="A", tasks=[task], vent=vent)) game_map.add_room(Room(
game_map.add_room(Room(id="b", name="B")) id="a", name="A",
game_map.add_edge(Edge(id="ab", room_a="a", room_b="b", distance=5.0)) center=Position(x=50, y=50),
bounds=(Position(x=0, y=0), Position(x=100, y=100)),
tasks=[task], vent=vent
))
game_map.add_room(Room(id="b", name="B", center=Position(x=200, y=50)))
game_map.add_edge(Edge(
id="ab", room_a="a", room_b="b",
waypoints=[Position(x=100, y=50), Position(x=200, y=50)]
))
with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f: with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as f:
game_map.save(f.name) game_map.save(f.name)
@ -213,6 +242,14 @@ class TestSkeldMap(unittest.TestCase):
medbay = self.skeld.get_room("medbay") medbay = self.skeld.get_room("medbay")
self.assertIn("vent_security", medbay.vent.connects_to) self.assertIn("vent_security", medbay.vent.connects_to)
self.assertIn("vent_elec", medbay.vent.connects_to) self.assertIn("vent_elec", medbay.vent.connects_to)
def test_skeld_has_walls(self):
"""Skeld should have wall geometry for raycasting."""
self.assertGreater(len(self.skeld.walls), 0)
def test_skeld_has_spawn_points(self):
"""Skeld should have spawn points."""
self.assertIn("cafeteria", self.skeld.spawn_points)
if __name__ == "__main__": if __name__ == "__main__":

248
tests/test_path_utils.py Normal file
View File

@ -0,0 +1,248 @@
"""
Tests for path utilities walk interpolation.
"""
import unittest
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.engine.types import Position
from src.engine.path_utils import WalkPath, WalkState, WalkManager, PathSegment
class TestPathSegment(unittest.TestCase):
"""Tests for PathSegment."""
def test_interpolate_start(self):
segment = PathSegment(
start=Position(x=0, y=0),
end=Position(x=100, y=0),
distance=100,
cumulative_distance=100
)
pos = segment.interpolate(0.0)
self.assertEqual(pos.x, 0)
self.assertEqual(pos.y, 0)
def test_interpolate_end(self):
segment = PathSegment(
start=Position(x=0, y=0),
end=Position(x=100, y=0),
distance=100,
cumulative_distance=100
)
pos = segment.interpolate(1.0)
self.assertEqual(pos.x, 100)
self.assertEqual(pos.y, 0)
def test_interpolate_middle(self):
segment = PathSegment(
start=Position(x=0, y=0),
end=Position(x=100, y=0),
distance=100,
cumulative_distance=100
)
pos = segment.interpolate(0.5)
self.assertAlmostEqual(pos.x, 50)
self.assertAlmostEqual(pos.y, 0)
class TestWalkPath(unittest.TestCase):
"""Tests for WalkPath."""
def test_straight_line_distance(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
self.assertAlmostEqual(path.total_distance, 100.0)
def test_multi_segment_distance(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0),
Position(x=100, y=100)
])
self.assertAlmostEqual(path.total_distance, 200.0)
def test_position_at_distance_start(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
pos = path.position_at_distance(0)
self.assertEqual(pos.x, 0)
def test_position_at_distance_end(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
pos = path.position_at_distance(100)
self.assertEqual(pos.x, 100)
def test_position_at_distance_middle(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
pos = path.position_at_distance(50)
self.assertAlmostEqual(pos.x, 50)
def test_position_at_distance_multi_segment(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0),
Position(x=100, y=100)
])
# At 150 pixels: first 100 to (100,0), then 50 down
pos = path.position_at_distance(150)
self.assertAlmostEqual(pos.x, 100)
self.assertAlmostEqual(pos.y, 50)
def test_position_at_time(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
# Speed 50 px/sec, after 1 sec = 50 px traveled
pos = path.position_at_time(elapsed=1.0, speed=50.0)
self.assertAlmostEqual(pos.x, 50)
def test_time_to_complete(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
# 100 pixels at 50 px/sec = 2 seconds
self.assertAlmostEqual(path.time_to_complete(50.0), 2.0)
def test_is_complete(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
self.assertFalse(path.is_complete(elapsed=1.0, speed=50.0))
self.assertTrue(path.is_complete(elapsed=3.0, speed=50.0))
def test_progress(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
self.assertAlmostEqual(path.progress(elapsed=1.0, speed=50.0), 0.5)
self.assertAlmostEqual(path.progress(elapsed=2.0, speed=50.0), 1.0)
def test_direct_path(self):
path = WalkPath.direct(Position(x=0, y=0), Position(x=100, y=100))
self.assertAlmostEqual(path.total_distance, 141.42, places=1)
class TestWalkState(unittest.TestCase):
"""Tests for WalkState."""
def test_current_position(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
state = WalkState(
player_id="p1",
path=path,
start_time=10.0,
speed=50.0
)
# At time 11.0 (1 sec elapsed), should be at x=50
pos = state.current_position(11.0)
self.assertAlmostEqual(pos.x, 50)
def test_is_complete(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
state = WalkState(
player_id="p1",
path=path,
start_time=10.0,
speed=50.0
)
self.assertFalse(state.is_complete(11.0))
self.assertTrue(state.is_complete(13.0))
def test_arrival_time(self):
path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
state = WalkState(
player_id="p1",
path=path,
start_time=10.0,
speed=50.0
)
self.assertAlmostEqual(state.arrival_time(), 12.0)
class TestWalkManager(unittest.TestCase):
"""Tests for WalkManager."""
def setUp(self):
self.manager = WalkManager()
self.path = WalkPath(waypoints=[
Position(x=0, y=0),
Position(x=100, y=0)
])
def test_start_walk(self):
state = self.manager.start_walk("p1", self.path, 0.0, 50.0)
self.assertEqual(state.player_id, "p1")
self.assertIsNotNone(self.manager.get_walk_state("p1"))
def test_get_position(self):
self.manager.start_walk("p1", self.path, 0.0, 50.0)
pos = self.manager.get_position("p1", 1.0)
self.assertIsNotNone(pos)
self.assertAlmostEqual(pos.x, 50)
def test_get_position_no_walk(self):
pos = self.manager.get_position("nonexistent", 1.0)
self.assertIsNone(pos)
def test_is_walking(self):
self.manager.start_walk("p1", self.path, 0.0, 50.0)
self.assertTrue(self.manager.is_walking("p1", 1.0))
self.assertFalse(self.manager.is_walking("p1", 3.0))
def test_cancel_walk(self):
self.manager.start_walk("p1", self.path, 0.0, 50.0)
self.manager.cancel_walk("p1")
self.assertIsNone(self.manager.get_walk_state("p1"))
def test_cleanup_completed(self):
self.manager.start_walk("p1", self.path, 0.0, 50.0)
self.manager.start_walk("p2", self.path, 0.0, 100.0)
# p2 finishes at t=1, p1 at t=2
completed = self.manager.cleanup_completed(1.5)
self.assertEqual(len(completed), 1)
self.assertEqual(completed[0].player_id, "p2")
self.assertIsNone(self.manager.get_walk_state("p2"))
self.assertIsNotNone(self.manager.get_walk_state("p1"))
def test_get_all_positions(self):
self.manager.start_walk("p1", self.path, 0.0, 50.0)
self.manager.start_walk("p2", self.path, 0.0, 100.0)
positions = self.manager.get_all_positions(1.0)
self.assertEqual(len(positions), 2)
self.assertAlmostEqual(positions["p1"].x, 50)
self.assertAlmostEqual(positions["p2"].x, 100)
if __name__ == "__main__":
unittest.main()

View File

@ -39,21 +39,21 @@ class TestPlayer(unittest.TestCase):
player = Player( player = Player(
id="p1", name="Red", color="red", id="p1", name="Red", color="red",
role=Role.CREWMATE, role=Role.CREWMATE,
position=Position(room_id="cafeteria") position=Position(x=1000, y=400, room_id="cafeteria")
) )
self.assertEqual(player.id, "p1") self.assertEqual(player.id, "p1")
self.assertEqual(player.role, Role.CREWMATE) self.assertEqual(player.role, Role.CREWMATE)
self.assertTrue(player.is_alive) self.assertTrue(player.is_alive)
def test_position_in_room(self): def test_position_distance(self):
pos = Position(room_id="cafeteria") pos1 = Position(x=0, y=0)
self.assertTrue(pos.is_in_room()) pos2 = Position(x=3, y=4)
self.assertFalse(pos.is_on_edge()) self.assertAlmostEqual(pos1.distance_to(pos2), 5.0)
def test_position_on_edge(self): def test_position_with_room(self):
pos = Position(edge_id="ab", progress=0.5) pos = Position(x=100, y=200, room_id="cafeteria")
self.assertFalse(pos.is_in_room()) self.assertEqual(pos.room_id, "cafeteria")
self.assertTrue(pos.is_on_edge()) self.assertEqual(pos.x, 100)
class TestSimulator(unittest.TestCase): class TestSimulator(unittest.TestCase):
@ -196,9 +196,9 @@ class TestSimulator(unittest.TestCase):
self.assertEqual(len(living), 2) self.assertEqual(len(living), 2)
def test_players_at(self): def test_players_at(self):
p1 = Player(id="p1", name="Red", color="red", position=Position(room_id="cafeteria")) p1 = Player(id="p1", name="Red", color="red", position=Position(x=1000, y=400, room_id="cafeteria"))
p2 = Player(id="p2", name="Blue", color="blue", position=Position(room_id="cafeteria")) p2 = Player(id="p2", name="Blue", color="blue", position=Position(x=1000, y=400, room_id="cafeteria"))
p3 = Player(id="p3", name="Green", color="green", position=Position(room_id="admin")) p3 = Player(id="p3", name="Green", color="green", position=Position(x=1200, y=700, room_id="admin"))
self.sim.add_player(p1) self.sim.add_player(p1)
self.sim.add_player(p2) self.sim.add_player(p2)

View File

@ -0,0 +1,328 @@
"""
Tests for ray-traced vision system.
"""
import unittest
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from src.engine.types import Position, Wall, Player, Role
from src.engine.vision_raycast import RaycastVision, VisibilityResult
class TestPosition(unittest.TestCase):
"""Tests for pixel-based Position class."""
def test_distance_to(self):
"""Test distance calculation."""
p1 = Position(x=0, y=0)
p2 = Position(x=3, y=4)
self.assertAlmostEqual(p1.distance_to(p2), 5.0)
def test_direction_to(self):
"""Test direction vector."""
p1 = Position(x=0, y=0)
p2 = Position(x=10, y=0)
dx, dy = p1.direction_to(p2)
self.assertAlmostEqual(dx, 1.0)
self.assertAlmostEqual(dy, 0.0)
def test_move_toward(self):
"""Test moving toward a target."""
p1 = Position(x=0, y=0)
p2 = Position(x=10, y=0)
p3 = p1.move_toward(p2, 5.0)
self.assertAlmostEqual(p3.x, 5.0)
self.assertAlmostEqual(p3.y, 0.0)
def test_to_tuple(self):
"""Test tuple conversion."""
p = Position(x=100, y=200)
self.assertEqual(p.to_tuple(), (100, 200))
def test_from_tuple(self):
"""Test tuple construction."""
p = Position.from_tuple((100, 200))
self.assertEqual(p.x, 100)
self.assertEqual(p.y, 200)
class TestWall(unittest.TestCase):
"""Tests for Wall class."""
def test_to_dict(self):
"""Test serialization."""
wall = Wall(p1=(0, 0), p2=(100, 0))
d = wall.to_dict()
self.assertEqual(d["p1"], [0, 0])
self.assertEqual(d["p2"], [100, 0])
def test_from_dict(self):
"""Test deserialization."""
wall = Wall.from_dict({"p1": [0, 0], "p2": [100, 0]})
self.assertEqual(wall.p1, (0, 0))
self.assertEqual(wall.p2, (100, 0))
class TestRaycastVision(unittest.TestCase):
"""Tests for ray-traced vision system."""
def test_no_walls_visible(self):
"""With no walls, everything in range is visible."""
vision = RaycastVision(walls=[], base_vision_radius=300)
observer = Position(x=0, y=0)
target = Position(x=100, y=0)
result = vision.check_visibility(observer, target)
self.assertTrue(result.visible)
self.assertAlmostEqual(result.distance, 100.0)
def test_beyond_radius_not_visible(self):
"""Targets beyond vision radius are not visible."""
vision = RaycastVision(walls=[], base_vision_radius=100)
observer = Position(x=0, y=0)
target = Position(x=200, y=0)
result = vision.check_visibility(observer, target)
self.assertFalse(result.visible)
def test_wall_blocks_vision(self):
"""Wall between observer and target blocks vision."""
# Wall at x=50, from y=-100 to y=100
wall = Wall(p1=(50, -100), p2=(50, 100))
vision = RaycastVision(walls=[wall], base_vision_radius=300)
observer = Position(x=0, y=0)
target = Position(x=100, y=0)
result = vision.check_visibility(observer, target)
self.assertFalse(result.visible)
self.assertTrue(result.blocked_by_wall)
def test_wall_not_in_path(self):
"""Wall not in line of sight doesn't block."""
# Wall off to the side
wall = Wall(p1=(100, 100), p2=(100, 200))
vision = RaycastVision(walls=[wall], base_vision_radius=300)
observer = Position(x=0, y=0)
target = Position(x=100, y=0)
result = vision.check_visibility(observer, target)
self.assertTrue(result.visible)
def test_get_visible_players(self):
"""Test getting list of visible players."""
vision = RaycastVision(walls=[], base_vision_radius=300)
observer = Player(id="red", name="Red", color="red",
position=Position(x=0, y=0))
close_player = Player(id="blue", name="Blue", color="blue",
position=Position(x=100, y=0))
far_player = Player(id="green", name="Green", color="green",
position=Position(x=500, y=0))
all_players = [observer, close_player, far_player]
visible = vision.get_visible_players(observer, all_players)
# Should see blue but not green
self.assertEqual(len(visible), 1)
self.assertEqual(visible[0][0].id, "blue")
def test_dead_players_not_visible_by_default(self):
"""Dead players are not visible by default."""
vision = RaycastVision(walls=[], base_vision_radius=300)
observer = Player(id="red", name="Red", color="red",
position=Position(x=0, y=0))
dead_player = Player(id="blue", name="Blue", color="blue",
position=Position(x=100, y=0), is_alive=False)
visible = vision.get_visible_players(observer, [observer, dead_player])
self.assertEqual(len(visible), 0)
def test_dead_players_visible_when_requested(self):
"""Dead players visible when include_dead=True."""
vision = RaycastVision(walls=[], base_vision_radius=300)
observer = Player(id="red", name="Red", color="red",
position=Position(x=0, y=0))
dead_player = Player(id="blue", name="Blue", color="blue",
position=Position(x=100, y=0), is_alive=False)
visible = vision.get_visible_players(observer, [observer, dead_player], include_dead=True)
self.assertEqual(len(visible), 1)
class TestLineIntersection(unittest.TestCase):
"""Tests for line segment intersection math."""
def setUp(self):
self.vision = RaycastVision(walls=[], base_vision_radius=300)
def test_crossing_lines_intersect(self):
"""Two crossing lines have an intersection."""
result = self.vision.line_segment_intersection(
(0, 0), (10, 10), # Diagonal line
(0, 10), (10, 0) # Crossing diagonal
)
self.assertIsNotNone(result)
self.assertAlmostEqual(result[0], 5.0)
self.assertAlmostEqual(result[1], 5.0)
def test_parallel_lines_no_intersect(self):
"""Parallel lines don't intersect."""
result = self.vision.line_segment_intersection(
(0, 0), (10, 0), # Horizontal line
(0, 5), (10, 5) # Parallel horizontal
)
self.assertIsNone(result)
def test_non_overlapping_segments(self):
"""Non-overlapping segments don't intersect."""
result = self.vision.line_segment_intersection(
(0, 0), (5, 0), # Short segment
(10, -5), (10, 5) # Far away vertical
)
self.assertIsNone(result)
class TestVisionPolygon(unittest.TestCase):
"""Tests for vision polygon casting."""
def test_no_walls_circular(self):
"""Without walls, vision polygon is circular."""
vision = RaycastVision(walls=[], base_vision_radius=100)
observer = Position(x=500, y=500)
points = vision.cast_rays_for_polygon(observer, num_rays=36)
# All points should be at radius distance
for px, py in points:
dist = ((px - observer.x)**2 + (py - observer.y)**2)**0.5
self.assertAlmostEqual(dist, 100.0, places=1)
def test_wall_creates_shadow(self):
"""Wall creates a shadow in vision polygon."""
# Wall directly in front
wall = Wall(p1=(550, 400), p2=(550, 600))
vision = RaycastVision(walls=[wall], base_vision_radius=200)
observer = Position(x=400, y=500)
points = vision.cast_rays_for_polygon(observer, num_rays=360)
# Some points should be closer than radius due to wall
min_dist = min(((px - observer.x)**2 + (py - observer.y)**2)**0.5 for px, py in points)
self.assertLess(min_dist, 200)
class TestImpostorVision(unittest.TestCase):
"""Tests for role-based vision differences."""
def test_impostor_has_larger_vision(self):
"""Impostors can see further than crewmates."""
vision = RaycastVision(
walls=[],
base_vision_radius=300,
crewmate_vision=1.0,
impostor_vision=1.5
)
crewmate = Player(id="crew", name="Crew", color="blue",
position=Position(x=0, y=0), role=Role.CREWMATE)
impostor = Player(id="imp", name="Imp", color="red",
position=Position(x=0, y=0), role=Role.IMPOSTOR)
# Check vision radii
crew_radius = vision.get_vision_radius_for_player(crewmate)
imp_radius = vision.get_vision_radius_for_player(impostor)
self.assertAlmostEqual(crew_radius, 300.0)
self.assertAlmostEqual(imp_radius, 450.0) # 300 * 1.5
def test_lights_sabotage_affects_crewmates(self):
"""Lights sabotage reduces crewmate vision."""
vision = RaycastVision(
walls=[],
base_vision_radius=300,
crewmate_vision=1.0,
lights_multiplier=0.25
)
crewmate = Player(id="crew", name="Crew", color="blue",
position=Position(x=0, y=0), role=Role.CREWMATE)
# Before lights sabotage
normal_radius = vision.get_vision_radius_for_player(crewmate)
self.assertAlmostEqual(normal_radius, 300.0)
# After lights sabotage
vision.set_lights_sabotaged(True)
sabotaged_radius = vision.get_vision_radius_for_player(crewmate)
self.assertAlmostEqual(sabotaged_radius, 75.0) # 300 * 0.25
def test_lights_sabotage_does_not_affect_impostors(self):
"""Impostors are immune to lights sabotage."""
vision = RaycastVision(
walls=[],
base_vision_radius=300,
impostor_vision=1.5,
lights_multiplier=0.25
)
impostor = Player(id="imp", name="Imp", color="red",
position=Position(x=0, y=0), role=Role.IMPOSTOR)
# Before lights sabotage
normal_radius = vision.get_vision_radius_for_player(impostor)
# After lights sabotage
vision.set_lights_sabotaged(True)
sabotaged_radius = vision.get_vision_radius_for_player(impostor)
# Impostor vision should be unchanged
self.assertAlmostEqual(normal_radius, sabotaged_radius)
self.assertAlmostEqual(sabotaged_radius, 450.0)
def test_impostor_sees_further_during_lights_sabotage(self):
"""During lights sabotage, impostors can see targets crewmates cannot."""
vision = RaycastVision(
walls=[],
base_vision_radius=300,
crewmate_vision=1.0,
impostor_vision=1.5,
lights_multiplier=0.25
)
vision.set_lights_sabotaged(True)
crewmate = Player(id="crew", name="Crew", color="blue",
position=Position(x=0, y=0), role=Role.CREWMATE)
impostor = Player(id="imp", name="Imp", color="red",
position=Position(x=0, y=0), role=Role.IMPOSTOR)
# Target at 100 pixels away
target = Player(id="target", name="Target", color="green",
position=Position(x=100, y=0), role=Role.CREWMATE)
all_players = [crewmate, impostor, target]
# Crewmate sees 75px during sabotage, target is at 100px
crew_visible = vision.get_visible_players(crewmate, all_players)
self.assertEqual(len(crew_visible), 0) # Can't see target
# Impostor sees 450px, target is at 100px
imp_visible = vision.get_visible_players(impostor, all_players)
self.assertEqual(len(imp_visible), 2) # Can see both
if __name__ == "__main__":
unittest.main()