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,
|
||||
"player_speed": 100.0,
|
||||
"crewmate_vision": 1.0,
|
||||
"impostor_vision": 1.5,
|
||||
"kill_cooldown": 25.0,
|
||||
"vision_range": 10.0,
|
||||
"impostor_vision_multiplier": 1.5,
|
||||
"light_sabotage_vision_multiplier": 0.25,
|
||||
"kill_distance": "medium",
|
||||
"emergencies_per_player": 1,
|
||||
"emergency_cooldown": 15.0,
|
||||
"discussion_time": 30.0,
|
||||
"voting_time": 120.0,
|
||||
"confirm_ejects": true,
|
||||
"player_speed": 2.0,
|
||||
"task_duration": 3.0,
|
||||
"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,
|
||||
"reactor_timer": 45.0
|
||||
"lights_vision_multiplier": 0.25,
|
||||
"max_discussion_rounds": 20,
|
||||
"convergence_threshold": 2
|
||||
}
|
||||
@ -1,40 +1,40 @@
|
||||
# Game Settings Configuration
|
||||
# Edit this file to customize game rules
|
||||
# The Glass Box League — Complete Among Us Settings
|
||||
# All values match the in-game lobby settings panel
|
||||
|
||||
game:
|
||||
map: "skeld"
|
||||
min_players: 4
|
||||
max_players: 10
|
||||
map_name: skeld
|
||||
num_impostors: 2
|
||||
|
||||
player:
|
||||
speed: 1.5 # meters per second
|
||||
vision_range: 10.0 # meters
|
||||
|
||||
player_speed: 100.0 # pixels/second (1.0x = 100px/s)
|
||||
crewmate_vision: 1.0 # multiplier (1.0x = 300px radius)
|
||||
impostor_vision: 1.5 # multiplier (not affected by lights)
|
||||
|
||||
impostor:
|
||||
kill_cooldown: 25.0 # seconds
|
||||
kill_range: 2.0 # meters
|
||||
|
||||
crewmate:
|
||||
tasks_short: 2
|
||||
tasks_long: 1
|
||||
tasks_common: 2
|
||||
kill_cooldown: 25.0 # seconds
|
||||
kill_distance: medium # short (50px), medium (100px), long (150px)
|
||||
|
||||
meeting:
|
||||
emergency_cooldown: 15.0 # seconds
|
||||
emergencies_per_player: 1
|
||||
discussion_time: 30.0 # seconds (for human mode)
|
||||
voting_time: 60.0 # seconds (for human mode)
|
||||
confirm_ejects: true
|
||||
emergency_cooldown: 15.0 # seconds
|
||||
discussion_time: 30.0 # seconds
|
||||
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:
|
||||
o2_timer: 30.0 # seconds until death
|
||||
reactor_timer: 30.0 # seconds until meltdown
|
||||
lights_vision_multiplier: 0.25
|
||||
comms_disables_tasks: true
|
||||
sabotage_cooldown: 30.0 # seconds between sabotages
|
||||
reactor_timer: 45.0 # seconds to fix reactor
|
||||
o2_timer: 45.0 # seconds to fix O2
|
||||
lights_vision_multiplier: 0.25 # crewmate vision during lights sabotage
|
||||
|
||||
# LLM-specific settings
|
||||
llm:
|
||||
max_discussion_rounds: 20
|
||||
min_convergence_rounds: 2
|
||||
convergence_threshold: 2 # desire_to_speak <= this = silence
|
||||
convergence_threshold: 2
|
||||
|
||||
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
|
||||
|
||||
### `config/game_settings.yaml`
|
||||
```yaml
|
||||
num_impostors: 2
|
||||
kill_cooldown: 25.0
|
||||
vision_range: 10.0
|
||||
impostor_vision_multiplier: 1.5
|
||||
light_sabotage_vision_multiplier: 0.25
|
||||
emergencies_per_player: 1
|
||||
confirm_ejects: true
|
||||
Use the CLI config manager for easy configuration:
|
||||
|
||||
```bash
|
||||
python scripts/config_manager.py settings list # List all settings
|
||||
python scripts/config_manager.py settings get kill_cooldown
|
||||
python scripts/config_manager.py settings set kill_cooldown 30
|
||||
python scripts/config_manager.py validate # Validate all configs
|
||||
```
|
||||
|
||||
### `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`
|
||||
```json
|
||||
{
|
||||
"canvas": {"width": 2000, "height": 1500},
|
||||
"rooms": [
|
||||
{"id": "cafeteria", "name": "Cafeteria", "tasks": [...], "vent": null},
|
||||
{"id": "cafeteria", "name": "Cafeteria", "center": [1000, 350], "bounds": [[850, 200], [1150, 500]]},
|
||||
...
|
||||
],
|
||||
"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}
|
||||
- Impostors: {game_settings.get('num_impostors', 2)}
|
||||
- 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."""
|
||||
|
||||
|
||||
@ -25,45 +25,70 @@ from src.map.graph import GameMap
|
||||
|
||||
@dataclass
|
||||
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"
|
||||
min_players: int = 4
|
||||
max_players: int = 15
|
||||
num_impostors: int = 2
|
||||
|
||||
# Player stats (can be overridden per-player)
|
||||
player_speed: float = 1.5 # meters per second
|
||||
vision_range: float = 10.0 # meters
|
||||
crewmate_vision: float = 1.0 # multiplier
|
||||
# === Player Settings ===
|
||||
player_speed: float = 100.0 # pixels per second (1.0x = 100px/s)
|
||||
crewmate_vision: float = 1.0 # multiplier (1.0x = 300px radius)
|
||||
impostor_vision: float = 1.5 # multiplier
|
||||
|
||||
# Impostor mechanics
|
||||
# === Kill Settings ===
|
||||
kill_cooldown: float = 25.0 # seconds
|
||||
kill_range: float = 2.0 # meters
|
||||
kill_distance: str = "medium" # "short" (50px), "medium" (100px), "long" (150px)
|
||||
|
||||
# Meeting settings
|
||||
emergency_cooldown: float = 15.0 # seconds
|
||||
# === Meeting Settings ===
|
||||
emergencies_per_player: int = 1
|
||||
discussion_time: float = 30.0 # seconds (informational for LLMs)
|
||||
voting_time: float = 60.0 # seconds (informational for LLMs)
|
||||
confirm_ejects: bool = True
|
||||
anonymous_votes: bool = False
|
||||
emergency_cooldown: float = 15.0 # seconds
|
||||
discussion_time: float = 30.0 # seconds
|
||||
voting_time: float = 120.0 # seconds
|
||||
confirm_ejects: bool = True # "Red was The Impostor" or "Red was ejected"
|
||||
anonymous_votes: bool = False # hide who voted for whom
|
||||
|
||||
# Sabotage settings
|
||||
o2_timer: float = 30.0
|
||||
reactor_timer: float = 30.0
|
||||
lights_vision_multiplier: float = 0.25
|
||||
# === Task Settings ===
|
||||
visual_tasks: bool = True # can see others doing visual tasks (medbay scan, etc)
|
||||
taskbar_updates: str = "always" # "always", "meetings", "never"
|
||||
common_tasks: int = 2
|
||||
long_tasks: int = 1
|
||||
short_tasks: int = 2
|
||||
|
||||
# Task settings
|
||||
tasks_short: int = 2
|
||||
tasks_long: int = 1
|
||||
tasks_common: int = 2
|
||||
# === Sabotage Settings ===
|
||||
sabotage_cooldown: float = 30.0 # seconds between sabotages
|
||||
reactor_timer: float = 45.0 # seconds to fix reactor
|
||||
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
|
||||
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
|
||||
def load(cls, path: str) -> "GameConfig":
|
||||
"""Load config from YAML or JSON file."""
|
||||
@ -88,28 +113,25 @@ class GameConfig:
|
||||
|
||||
# Flatten nested structure from YAML
|
||||
mappings = {
|
||||
"game": ["map_name", "min_players", "max_players", "num_impostors"],
|
||||
"player": ["player_speed", "vision_range", "crewmate_vision", "impostor_vision"],
|
||||
"impostor": ["kill_cooldown", "kill_range"],
|
||||
"game": ["map_name", "num_impostors"],
|
||||
"player": ["player_speed", "crewmate_vision", "impostor_vision"],
|
||||
"impostor": ["kill_cooldown", "kill_distance"],
|
||||
"meeting": ["emergency_cooldown", "emergencies_per_player", "discussion_time",
|
||||
"voting_time", "confirm_ejects", "anonymous_votes"],
|
||||
"sabotage": ["o2_timer", "reactor_timer", "lights_vision_multiplier"],
|
||||
"crewmate": ["tasks_short", "tasks_long", "tasks_common"],
|
||||
"sabotage": ["sabotage_cooldown", "o2_timer", "reactor_timer", "lights_vision_multiplier"],
|
||||
"tasks": ["visual_tasks", "taskbar_updates", "common_tasks", "long_tasks", "short_tasks"],
|
||||
"llm": ["max_discussion_rounds", "convergence_threshold"]
|
||||
}
|
||||
|
||||
for section, keys in mappings.items():
|
||||
section_data = data.get(section, {})
|
||||
for key in keys:
|
||||
# Handle key name variations
|
||||
yaml_key = key.replace(f"{section}_", "").replace("player_", "")
|
||||
if key == "map_name":
|
||||
yaml_key = "map"
|
||||
elif key == "player_speed":
|
||||
yaml_key = "speed"
|
||||
|
||||
if yaml_key in section_data:
|
||||
setattr(config, key, section_data[yaml_key])
|
||||
# Check if key exists in section data directly
|
||||
if key in section_data:
|
||||
setattr(config, key, section_data[key])
|
||||
# Also check top-level for flat JSON configs
|
||||
elif key in data:
|
||||
setattr(config, key, data[key])
|
||||
|
||||
return config
|
||||
|
||||
@ -170,20 +192,20 @@ class GameEngine:
|
||||
player_id: str,
|
||||
name: str,
|
||||
color: str,
|
||||
role: Role = Role.CREWMATE,
|
||||
speed: Optional[float] = None,
|
||||
vision: Optional[float] = None
|
||||
role: Role = Role.CREWMATE
|
||||
) -> 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
|
||||
|
||||
# Get spawn point from map
|
||||
spawn = self.map.spawn_points.get("cafeteria", Position(x=500, y=200))
|
||||
|
||||
player = Player(
|
||||
id=player_id,
|
||||
name=name,
|
||||
color=color,
|
||||
role=role,
|
||||
position=Position(room_id="cafeteria"),
|
||||
speed=speed or self.config.player_speed,
|
||||
position=Position(x=spawn.x, y=spawn.y, room_id="cafeteria"),
|
||||
kill_cooldown=self.config.kill_cooldown if is_impostor else 0.0
|
||||
)
|
||||
|
||||
@ -299,7 +321,7 @@ class GameEngine:
|
||||
player.path = 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", {
|
||||
"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."""
|
||||
return [
|
||||
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]:
|
||||
|
||||
@ -2,12 +2,14 @@
|
||||
The Glass Box League — Core Types
|
||||
|
||||
Fundamental data structures for the discrete event simulator.
|
||||
Now with pixel-based positions for ray-traced FOV.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import Optional
|
||||
import uuid
|
||||
import math
|
||||
|
||||
|
||||
class Role(Enum):
|
||||
@ -26,21 +28,48 @@ class GamePhase(Enum):
|
||||
@dataclass
|
||||
class Position:
|
||||
"""
|
||||
A position in the game world.
|
||||
A pixel-based position in the game world.
|
||||
|
||||
Can be:
|
||||
- In a room: room_id is set, edge_id is None
|
||||
- On an edge: edge_id is set, progress is 0.0-1.0
|
||||
Coordinates are in pixels on the map image.
|
||||
Room ID is derived from position via polygon containment.
|
||||
"""
|
||||
x: float = 0.0
|
||||
y: float = 0.0
|
||||
|
||||
# Derived from position (set by engine)
|
||||
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:
|
||||
return self.room_id is not None and self.edge_id is None
|
||||
def distance_to(self, other: "Position") -> float:
|
||||
"""Euclidean distance to another position."""
|
||||
return math.sqrt((self.x - other.x)**2 + (self.y - other.y)**2)
|
||||
|
||||
def is_on_edge(self) -> bool:
|
||||
return self.edge_id is not None
|
||||
def direction_to(self, other: "Position") -> tuple[float, float]:
|
||||
"""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
|
||||
@ -50,14 +79,14 @@ class Player:
|
||||
name: str
|
||||
color: str
|
||||
role: Role = Role.CREWMATE
|
||||
position: Position = field(default_factory=lambda: Position(room_id="cafeteria"))
|
||||
position: Position = field(default_factory=Position)
|
||||
|
||||
is_alive: bool = True
|
||||
speed: float = 1.0 # meters per second
|
||||
# Speed is a game-wide constant in GameConfig, not per-player
|
||||
|
||||
# Movement intent
|
||||
destination: Optional[str] = None # Target room_id
|
||||
path: list[str] = field(default_factory=list) # Sequence of edge_ids
|
||||
# Movement
|
||||
destination: Optional[Position] = None # Target position
|
||||
path: list[Position] = field(default_factory=list) # Waypoints
|
||||
|
||||
# Task state
|
||||
current_task: Optional[str] = None
|
||||
@ -69,7 +98,7 @@ class Player:
|
||||
kill_cooldown: float = 0.0
|
||||
|
||||
# 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
|
||||
@ -97,3 +126,17 @@ class Event:
|
||||
|
||||
def __lt__(self, other: "Event") -> bool:
|
||||
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
|
||||
|
||||
Continuous node graph with distances for position tracking.
|
||||
Continuous node graph with pixel coordinates for ray-traced vision.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
import json
|
||||
import math
|
||||
|
||||
from ..engine.types import Position, Wall
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -15,6 +18,7 @@ class Task:
|
||||
id: str
|
||||
name: str
|
||||
duration: float # seconds to complete
|
||||
position: Position = field(default_factory=Position)
|
||||
is_visual: bool = False # Can others see you doing it?
|
||||
|
||||
|
||||
@ -23,6 +27,7 @@ class Vent:
|
||||
"""A vent connection point."""
|
||||
id: str
|
||||
connects_to: list[str] # Other vent IDs
|
||||
position: Position = field(default_factory=Position)
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -30,13 +35,20 @@ class Room:
|
||||
"""A room (node) in the map."""
|
||||
id: 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)
|
||||
vent: Optional[Vent] = None
|
||||
emergency_button: Optional[Position] = None
|
||||
|
||||
# Position within the room (for spawn points, task locations)
|
||||
# Simplified: just a single point for now
|
||||
x: float = 0.0
|
||||
y: float = 0.0
|
||||
def contains_point(self, pos: Position) -> bool:
|
||||
"""Check if a position is within this room's bounds."""
|
||||
if self.bounds is None:
|
||||
# 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
|
||||
@ -45,11 +57,18 @@ class Edge:
|
||||
id: str
|
||||
room_a: 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)
|
||||
# Each waypoint is (x, y)
|
||||
waypoints: list[tuple[float, float]] = field(default_factory=list)
|
||||
@property
|
||||
def distance(self) -> float:
|
||||
"""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:
|
||||
"""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.
|
||||
|
||||
Supports pathfinding, distance calculation, and visibility queries.
|
||||
Now with pixel coordinates and wall geometry for ray-traced vision.
|
||||
"""
|
||||
|
||||
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.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)
|
||||
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_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]:
|
||||
"""Get a room by 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]:
|
||||
"""Get an edge by ID."""
|
||||
return self.edges.get(edge_id)
|
||||
@ -108,6 +146,17 @@ class GameMap:
|
||||
return self.edges[edge_id]
|
||||
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 ---
|
||||
|
||||
def find_path(self, from_room: str, to_room: str) -> Optional[list[str]]:
|
||||
@ -144,6 +193,46 @@ class GameMap:
|
||||
|
||||
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:
|
||||
"""Calculate total distance of a path."""
|
||||
return sum(self.edges[eid].distance for eid in edge_ids)
|
||||
@ -153,14 +242,20 @@ class GameMap:
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize map to dictionary."""
|
||||
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": [
|
||||
{
|
||||
"id": r.id,
|
||||
"name": r.name,
|
||||
"x": r.x,
|
||||
"y": r.y,
|
||||
"tasks": [{"id": t.id, "name": t.name, "duration": t.duration} for t in r.tasks],
|
||||
"vent": {"id": r.vent.id, "connects_to": r.vent.connects_to} if r.vent else None
|
||||
"center": [r.center.x, r.center.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, "position": [t.position.x, t.position.y]} for t in r.tasks],
|
||||
"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()
|
||||
],
|
||||
@ -169,11 +264,11 @@ class GameMap:
|
||||
"id": e.id,
|
||||
"room_a": e.room_a,
|
||||
"room_b": e.room_b,
|
||||
"distance": e.distance,
|
||||
"waypoints": e.waypoints
|
||||
"waypoints": [[p.x, p.y] for p in e.waypoints]
|
||||
}
|
||||
for e in self.edges.values()
|
||||
]
|
||||
],
|
||||
"walls": [w.to_dict() for w in self.walls]
|
||||
}
|
||||
|
||||
def save(self, path: str) -> None:
|
||||
@ -188,21 +283,76 @@ class GameMap:
|
||||
data = json.load(f)
|
||||
|
||||
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"]:
|
||||
tasks = [Task(id=t["id"], name=t["name"], duration=t["duration"]) for t in r.get("tasks", [])]
|
||||
vent = Vent(id=r["vent"]["id"], connects_to=r["vent"]["connects_to"]) if r.get("vent") else None
|
||||
room = Room(id=r["id"], name=r["name"], x=r.get("x", 0), y=r.get("y", 0), tasks=tasks, vent=vent)
|
||||
# Spawn points
|
||||
for room_id, pos in data.get("spawn_points", {}).items():
|
||||
game_map.spawn_points[room_id] = Position(x=pos[0], y=pos[1])
|
||||
|
||||
# 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)
|
||||
|
||||
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(
|
||||
id=e["id"],
|
||||
room_a=e["room_a"],
|
||||
room_b=e["room_b"],
|
||||
distance=e["distance"],
|
||||
waypoints=[tuple(w) for w in e.get("waypoints", [])]
|
||||
waypoints=waypoints
|
||||
)
|
||||
game_map.add_edge(edge)
|
||||
|
||||
# Walls
|
||||
for w in data.get("walls", []):
|
||||
game_map.add_wall(Wall.from_dict(w))
|
||||
|
||||
return game_map
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
"""
|
||||
Tests for the game engine.
|
||||
Updated for pixel-based Position system.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
@ -14,40 +15,61 @@ from src.map.graph import GameMap, Room, Edge, Task, Vent
|
||||
|
||||
|
||||
def create_simple_map():
|
||||
"""Create a simple test map."""
|
||||
"""Create a simple test map with pixel coordinates."""
|
||||
game_map = GameMap()
|
||||
game_map.spawn_points["cafeteria"] = Position(x=500, y=200)
|
||||
|
||||
# Cafeteria with task
|
||||
game_map.add_room(Room(
|
||||
id="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
|
||||
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(
|
||||
id="electrical",
|
||||
name="Electrical",
|
||||
center=Position(x=200, y=500),
|
||||
bounds=(Position(x=100, y=400), Position(x=300, y=600)),
|
||||
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
|
||||
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(
|
||||
id="security",
|
||||
name="Security",
|
||||
center=Position(x=400, y=500),
|
||||
bounds=(Position(x=300, y=400), Position(x=500, y=600)),
|
||||
vent=sec_vent
|
||||
))
|
||||
|
||||
# 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
|
||||
game_map.add_edge(Edge(id="cafe_elec", room_a="cafeteria", room_b="electrical", distance=5.0))
|
||||
game_map.add_edge(Edge(id="cafe_admin", room_a="cafeteria", room_b="admin", distance=3.0))
|
||||
game_map.add_edge(Edge(id="elec_sec", room_a="electrical", room_b="security", distance=4.0))
|
||||
# Connect rooms with waypoints
|
||||
game_map.add_edge(Edge(
|
||||
id="cafe_elec", room_a="cafeteria", room_b="electrical",
|
||||
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
|
||||
|
||||
@ -96,10 +118,6 @@ class TestGameEngineSetup(unittest.TestCase):
|
||||
self.assertIn("p1", self.engine.impostor_ids)
|
||||
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):
|
||||
self.engine.add_player("p1", "Red", "red", 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):
|
||||
# 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"})
|
||||
results = self.engine.resolve_actions()
|
||||
@ -222,7 +240,7 @@ class TestKill(unittest.TestCase):
|
||||
|
||||
def test_cannot_kill_different_room(self):
|
||||
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"})
|
||||
results = self.engine.resolve_actions()
|
||||
@ -262,7 +280,7 @@ class TestVenting(unittest.TestCase):
|
||||
|
||||
# Place impostor in electrical (has vent)
|
||||
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):
|
||||
self.engine.queue_action("imp", "VENT", {"destination": "security"})
|
||||
@ -272,7 +290,7 @@ class TestVenting(unittest.TestCase):
|
||||
|
||||
def test_crewmate_cannot_vent(self):
|
||||
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"})
|
||||
results = self.engine.resolve_actions()
|
||||
@ -282,7 +300,7 @@ class TestVenting(unittest.TestCase):
|
||||
def test_cannot_vent_unconnected(self):
|
||||
# Cafeteria has no vent
|
||||
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"})
|
||||
results = self.engine.resolve_actions()
|
||||
@ -336,7 +354,7 @@ class TestReporting(unittest.TestCase):
|
||||
id="body1",
|
||||
player_id="dead",
|
||||
player_name="Blue",
|
||||
position=Position(room_id="cafeteria"),
|
||||
position=Position(x=500, y=200, room_id="cafeteria"),
|
||||
time_of_death=0.0
|
||||
)
|
||||
self.engine.simulator.bodies.append(body)
|
||||
@ -349,7 +367,7 @@ class TestReporting(unittest.TestCase):
|
||||
|
||||
def test_cannot_report_body_in_different_room(self):
|
||||
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"})
|
||||
results = self.engine.resolve_actions()
|
||||
@ -383,7 +401,7 @@ class TestEmergency(unittest.TestCase):
|
||||
|
||||
def test_cannot_call_emergency_outside_cafeteria(self):
|
||||
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", {})
|
||||
results = self.engine.resolve_actions()
|
||||
|
||||
@ -6,11 +6,11 @@ import unittest
|
||||
import sys
|
||||
import os
|
||||
import tempfile
|
||||
import json
|
||||
|
||||
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.engine.types import Position
|
||||
|
||||
|
||||
class TestRoom(unittest.TestCase):
|
||||
@ -34,18 +34,40 @@ class TestRoom(unittest.TestCase):
|
||||
room = Room(id="test", name="Test Room", vent=vent)
|
||||
self.assertIsNotNone(room.vent)
|
||||
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):
|
||||
"""Tests for Edge dataclass."""
|
||||
|
||||
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.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):
|
||||
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("b"), "a")
|
||||
|
||||
@ -60,14 +82,24 @@ class TestGameMap(unittest.TestCase):
|
||||
# Create rooms: A -- B -- C
|
||||
# |
|
||||
# D
|
||||
self.game_map.add_room(Room(id="a", name="Room A"))
|
||||
self.game_map.add_room(Room(id="b", name="Room B"))
|
||||
self.game_map.add_room(Room(id="c", name="Room C"))
|
||||
self.game_map.add_room(Room(id="d", name="Room D"))
|
||||
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", center=Position(x=100, y=0)))
|
||||
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", center=Position(x=100, y=100)))
|
||||
|
||||
self.game_map.add_edge(Edge(id="ab", room_a="a", room_b="b", distance=3.0))
|
||||
self.game_map.add_edge(Edge(id="bc", room_a="b", room_b="c", distance=4.0))
|
||||
self.game_map.add_edge(Edge(id="bd", room_a="b", room_b="d", distance=2.0))
|
||||
# Create edges with waypoints
|
||||
self.game_map.add_edge(Edge(
|
||||
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):
|
||||
self.assertEqual(len(self.game_map.rooms), 4)
|
||||
@ -87,7 +119,7 @@ class TestGameMap(unittest.TestCase):
|
||||
def test_get_edge(self):
|
||||
edge = self.game_map.get_edge("ab")
|
||||
self.assertIsNotNone(edge)
|
||||
self.assertEqual(edge.distance, 3.0)
|
||||
self.assertAlmostEqual(edge.distance, 100.0)
|
||||
|
||||
def test_get_neighbors(self):
|
||||
neighbors = self.game_map.get_neighbors("b")
|
||||
@ -126,20 +158,6 @@ class TestGameMap(unittest.TestCase):
|
||||
self.game_map.add_room(Room(id="isolated", name="Isolated"))
|
||||
path = self.game_map.find_path("a", "isolated")
|
||||
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):
|
||||
@ -147,9 +165,12 @@ class TestMapSerialization(unittest.TestCase):
|
||||
|
||||
def test_to_dict(self):
|
||||
game_map = GameMap()
|
||||
game_map.add_room(Room(id="a", name="A"))
|
||||
game_map.add_room(Room(id="b", name="B"))
|
||||
game_map.add_edge(Edge(id="ab", room_a="a", room_b="b", distance=5.0))
|
||||
game_map.add_room(Room(id="a", name="A", center=Position(x=0, y=0)))
|
||||
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",
|
||||
waypoints=[Position(x=0, y=0), Position(x=100, y=0)]
|
||||
))
|
||||
|
||||
data = game_map.to_dict()
|
||||
self.assertEqual(len(data["rooms"]), 2)
|
||||
@ -157,11 +178,19 @@ class TestMapSerialization(unittest.TestCase):
|
||||
|
||||
def test_save_and_load(self):
|
||||
game_map = GameMap()
|
||||
task = Task(id="t1", name="Task", duration=3.0)
|
||||
vent = Vent(id="v1", connects_to=["v2"])
|
||||
game_map.add_room(Room(id="a", name="A", tasks=[task], vent=vent))
|
||||
game_map.add_room(Room(id="b", name="B"))
|
||||
game_map.add_edge(Edge(id="ab", room_a="a", room_b="b", distance=5.0))
|
||||
task = Task(id="t1", name="Task", duration=3.0, position=Position(x=50, y=50))
|
||||
vent = Vent(id="v1", connects_to=["v2"], position=Position(x=60, y=60))
|
||||
game_map.add_room(Room(
|
||||
id="a", name="A",
|
||||
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:
|
||||
game_map.save(f.name)
|
||||
@ -213,6 +242,14 @@ class TestSkeldMap(unittest.TestCase):
|
||||
medbay = self.skeld.get_room("medbay")
|
||||
self.assertIn("vent_security", 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__":
|
||||
|
||||
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(
|
||||
id="p1", name="Red", color="red",
|
||||
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.role, Role.CREWMATE)
|
||||
self.assertTrue(player.is_alive)
|
||||
|
||||
def test_position_in_room(self):
|
||||
pos = Position(room_id="cafeteria")
|
||||
self.assertTrue(pos.is_in_room())
|
||||
self.assertFalse(pos.is_on_edge())
|
||||
def test_position_distance(self):
|
||||
pos1 = Position(x=0, y=0)
|
||||
pos2 = Position(x=3, y=4)
|
||||
self.assertAlmostEqual(pos1.distance_to(pos2), 5.0)
|
||||
|
||||
def test_position_on_edge(self):
|
||||
pos = Position(edge_id="ab", progress=0.5)
|
||||
self.assertFalse(pos.is_in_room())
|
||||
self.assertTrue(pos.is_on_edge())
|
||||
def test_position_with_room(self):
|
||||
pos = Position(x=100, y=200, room_id="cafeteria")
|
||||
self.assertEqual(pos.room_id, "cafeteria")
|
||||
self.assertEqual(pos.x, 100)
|
||||
|
||||
|
||||
class TestSimulator(unittest.TestCase):
|
||||
@ -196,9 +196,9 @@ class TestSimulator(unittest.TestCase):
|
||||
self.assertEqual(len(living), 2)
|
||||
|
||||
def test_players_at(self):
|
||||
p1 = Player(id="p1", name="Red", color="red", position=Position(room_id="cafeteria"))
|
||||
p2 = Player(id="p2", name="Blue", color="blue", position=Position(room_id="cafeteria"))
|
||||
p3 = Player(id="p3", name="Green", color="green", position=Position(room_id="admin"))
|
||||
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(x=1000, y=400, room_id="cafeteria"))
|
||||
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(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