Save work: Update game engine, maps, and add vision/pathing utilities. (Tests failing: vision raycast)
This commit is contained in:
parent
071906df59
commit
be371e887a
@ -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
|
||||||
}
|
}
|
||||||
@ -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
|
|
||||||
|
|||||||
1117
data/maps/skeld.json
1117
data/maps/skeld.json
File diff suppressed because it is too large
Load Diff
50
docs/api.md
50
docs/api.md
@ -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
164
docs/design_rendering.md
Normal 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
442
scripts/config_manager.py
Executable 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()
|
||||||
@ -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."""
|
||||||
|
|
||||||
|
|||||||
@ -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
248
src/engine/path_utils.py
Normal 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()
|
||||||
|
}
|
||||||
@ -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]:
|
||||||
|
|||||||
@ -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"]))
|
||||||
|
|||||||
313
src/engine/vision_raycast.py
Normal file
313
src/engine/vision_raycast.py
Normal 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
|
||||||
|
)
|
||||||
|
|
||||||
198
src/map/graph.py
198
src/map/graph.py
@ -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
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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):
|
||||||
@ -35,17 +35,39 @@ class TestRoom(unittest.TestCase):
|
|||||||
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")
|
||||||
@ -127,29 +159,18 @@ class TestGameMap(unittest.TestCase):
|
|||||||
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):
|
||||||
"""Tests for map serialization."""
|
"""Tests for map serialization."""
|
||||||
|
|
||||||
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)
|
||||||
@ -214,6 +243,14 @@ class TestSkeldMap(unittest.TestCase):
|
|||||||
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__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
248
tests/test_path_utils.py
Normal file
248
tests/test_path_utils.py
Normal 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()
|
||||||
@ -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)
|
||||||
|
|||||||
328
tests/test_vision_raycast.py
Normal file
328
tests/test_vision_raycast.py
Normal 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()
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user