Add grid perspective support to API for FOV-aware entity filtering

When a Grid has a perspective entity set (typically the player), the API
now respects field-of-view by default. Only entities visible to the
perspective entity are returned in /scene responses.

Changes:
- serialize_grid() filters entities using grid.is_in_fov()
- Added ?omniscient=true query param to bypass FOV filtering
- Response includes perspective info with hidden_entities count
- Updated README with perspective documentation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-01-29 23:24:53 -05:00
commit 96c66decba
3 changed files with 109 additions and 17 deletions

View file

@ -30,14 +30,44 @@ Returns the current scene graph with all UI elements.
```bash ```bash
curl http://localhost:8765/scene curl http://localhost:8765/scene
# Omniscient mode (see all entities, ignoring FOV)
curl "http://localhost:8765/scene?omniscient=true"
``` ```
Response includes: **Query Parameters:**
- `omniscient`: If `true`, show all entities regardless of FOV. Default is `false` (respect perspective).
**Response includes:**
- Scene name - Scene name
- Viewport dimensions - Viewport dimensions
- All UI elements with type, bounds, visibility, interactivity - All UI elements with type, bounds, visibility, interactivity
- Nested children - Nested children
- Type-specific properties (text for Caption, grid_size for Grid, etc.) - Type-specific properties (text for Caption, grid_size for Grid, etc.)
- `perspective_mode`: Either "respect_fov" or "omniscient"
**Grid Perspective Support:**
When a Grid has a `perspective` entity set (typically the player), the API respects
field-of-view by default. Only entities visible to the perspective entity are returned.
```json
{
"type": "Grid",
"entities": [...], // Only entities in FOV
"perspective": {
"enabled": true,
"entity_name": "player",
"entity_pos": {"x": 5, "y": 5},
"fov_radius": 10,
"hidden_entities": 3,
"note": "Entities filtered by FOV - only showing what perspective entity can see"
}
}
```
Use `?omniscient=true` to bypass FOV filtering and see all entities (useful for
debugging or when partial information isn't needed).
### GET /affordances ### GET /affordances
Returns only interactive elements with semantic labels. Returns only interactive elements with semantic labels.

View file

@ -86,8 +86,17 @@ def serialize_entity(entity) -> Dict[str, Any]:
return data return data
def serialize_grid(grid) -> Dict[str, Any]: def serialize_grid(grid, respect_perspective: bool = True) -> Dict[str, Any]:
"""Serialize a Grid element with its entities.""" """Serialize a Grid element with its entities.
Args:
grid: The Grid element to serialize
respect_perspective: If True and grid has a perspective entity,
only include entities visible to that entity's FOV
Returns:
Dictionary representation of the grid
"""
bounds = get_bounds(grid) bounds = get_bounds(grid)
# Get grid dimensions # Get grid dimensions
@ -106,10 +115,27 @@ def serialize_grid(grid) -> Dict[str, Any]:
center = getattr(grid, 'center', None) center = getattr(grid, 'center', None)
zoom = float(getattr(grid, 'zoom', 1.0)) zoom = float(getattr(grid, 'zoom', 1.0))
# Serialize entities # Check for perspective (player POV)
perspective_entity = getattr(grid, 'perspective', None)
perspective_enabled = bool(getattr(grid, 'perspective_enabled', False))
has_perspective = perspective_entity is not None and perspective_enabled
# Serialize entities, optionally filtering by FOV
entities = [] entities = []
hidden_count = 0
try: try:
for entity in grid.entities: for entity in grid.entities:
# Check if entity is visible from perspective
if has_perspective and respect_perspective:
try:
entity_pos = (int(entity.grid_x), int(entity.grid_y))
in_fov = grid.is_in_fov(entity_pos)
if not in_fov:
hidden_count += 1
continue # Skip entities not in FOV
except Exception:
pass # If FOV check fails, include the entity
entities.append(serialize_entity(entity)) entities.append(serialize_entity(entity))
except Exception: except Exception:
pass pass
@ -129,23 +155,42 @@ def serialize_grid(grid) -> Dict[str, Any]:
"has_cell_enter": getattr(grid, 'on_cell_enter', None) is not None, "has_cell_enter": getattr(grid, 'on_cell_enter', None) is not None,
} }
# Add perspective info
if has_perspective:
data["perspective"] = {
"enabled": True,
"entity_name": getattr(perspective_entity, 'name', None) or "",
"entity_pos": {
"x": float(getattr(perspective_entity, 'grid_x', 0)),
"y": float(getattr(perspective_entity, 'grid_y', 0))
},
"fov_radius": int(getattr(grid, 'fov_radius', 0)),
"hidden_entities": hidden_count,
"note": "Entities filtered by FOV - only showing what perspective entity can see"
}
else:
data["perspective"] = {
"enabled": False,
"note": "No perspective set - showing all entities (omniscient view)"
}
# Add cell size estimate if texture available # Add cell size estimate if texture available
texture = getattr(grid, 'texture', None) texture = getattr(grid, 'texture', None)
if texture: if texture:
# Texture dimensions divided by sprite count would give cell size
# but this is an approximation
data["has_texture"] = True data["has_texture"] = True
return data return data
def serialize_element(element, depth: int = 0, max_depth: int = 10) -> Dict[str, Any]: def serialize_element(element, depth: int = 0, max_depth: int = 10,
respect_perspective: bool = True) -> Dict[str, Any]:
"""Serialize a UI element to a dictionary. """Serialize a UI element to a dictionary.
Args: Args:
element: The UI element to serialize element: The UI element to serialize
depth: Current recursion depth depth: Current recursion depth
max_depth: Maximum recursion depth for children max_depth: Maximum recursion depth for children
respect_perspective: If True, filter grid entities by perspective FOV
Returns: Returns:
Dictionary representation of the element Dictionary representation of the element
@ -155,7 +200,7 @@ def serialize_element(element, depth: int = 0, max_depth: int = 10) -> Dict[str,
# Special handling for Grid # Special handling for Grid
if element_type == "Grid": if element_type == "Grid":
return serialize_grid(element) return serialize_grid(element, respect_perspective=respect_perspective)
data = { data = {
"type": element_type, "type": element_type,
@ -195,7 +240,8 @@ def serialize_element(element, depth: int = 0, max_depth: int = 10) -> Dict[str,
children = [] children = []
try: try:
for child in element.children: for child in element.children:
children.append(serialize_element(child, depth + 1, max_depth)) children.append(serialize_element(child, depth + 1, max_depth,
respect_perspective))
except Exception: except Exception:
pass pass
data["children"] = children data["children"] = children
@ -204,9 +250,13 @@ def serialize_element(element, depth: int = 0, max_depth: int = 10) -> Dict[str,
return data return data
def serialize_scene() -> Dict[str, Any]: def serialize_scene(respect_perspective: bool = True) -> Dict[str, Any]:
"""Serialize the entire current scene graph. """Serialize the entire current scene graph.
Args:
respect_perspective: If True, filter grid entities by perspective FOV.
If False, show all entities (omniscient view).
Returns: Returns:
Dictionary with scene name, viewport info, and all elements Dictionary with scene name, viewport info, and all elements
""" """
@ -224,7 +274,8 @@ def serialize_scene() -> Dict[str, Any]:
try: try:
if scene: if scene:
for element in scene.children: for element in scene.children:
elements.append(serialize_element(element)) elements.append(serialize_element(element,
respect_perspective=respect_perspective))
except Exception as e: except Exception as e:
pass pass
@ -233,7 +284,8 @@ def serialize_scene() -> Dict[str, Any]:
"timestamp": time.time(), "timestamp": time.time(),
"viewport": {"width": width, "height": height}, "viewport": {"width": width, "height": height},
"element_count": len(elements), "element_count": len(elements),
"elements": elements "elements": elements,
"perspective_mode": "respect_fov" if respect_perspective else "omniscient"
} }

View file

@ -92,7 +92,7 @@ class GameAPIHandler(BaseHTTPRequestHandler):
query = parse_qs(parsed.query) query = parse_qs(parsed.query)
if path == '/scene': if path == '/scene':
self.handle_scene() self.handle_scene(query)
elif path == '/affordances': elif path == '/affordances':
self.handle_affordances() self.handle_affordances()
elif path == '/screenshot': elif path == '/screenshot':
@ -145,16 +145,26 @@ class GameAPIHandler(BaseHTTPRequestHandler):
"timestamp": time.time() "timestamp": time.time()
}) })
def handle_scene(self) -> None: def handle_scene(self, query: Dict) -> None:
"""Return the current scene graph with semantic annotations.""" """Return the current scene graph with semantic annotations.
Query parameters:
omniscient: If 'true', show all entities regardless of FOV.
Default is to respect perspective (show only what
the perspective entity can see).
"""
# Check if client wants omniscient (all-seeing) view
omniscient = query.get('omniscient', ['false'])[0].lower() == 'true'
respect_perspective = not omniscient
try: try:
# Try to use lock for thread safety, but fall back if not available # Try to use lock for thread safety, but fall back if not available
try: try:
with mcrfpy.lock(): with mcrfpy.lock():
scene_data = serialize_scene() scene_data = serialize_scene(respect_perspective=respect_perspective)
except (RuntimeError, AttributeError): except (RuntimeError, AttributeError):
# Lock not available (e.g., main thread or headless mode issue) # Lock not available (e.g., main thread or headless mode issue)
scene_data = serialize_scene() scene_data = serialize_scene(respect_perspective=respect_perspective)
self.send_json(scene_data) self.send_json(scene_data)
except Exception as e: except Exception as e:
self.send_error_json(f"Scene introspection failed: {str(e)}", 500) self.send_error_json(f"Scene introspection failed: {str(e)}", 500)