feat(engine): implement perspective FOV, pathfinding, and GUI text widgets

Major Engine Enhancements:
- Complete FOV (Field of View) system with perspective rendering
  - UIGrid.perspective property for entity-based visibility
  - Three-layer overlay colors (unexplored, explored, visible)
  - Per-entity visibility state tracking
  - Perfect knowledge updates only for explored areas

- Advanced Pathfinding Integration
  - A* pathfinding implementation in UIGrid
  - Entity.path_to() method for direct pathfinding
  - Dijkstra maps for multi-target pathfinding
  - Path caching for performance optimization

- GUI Text Input Widgets
  - TextInputWidget class with cursor, selection, scrolling
  - Improved widget with proper text rendering and input handling
  - Example showcase of multiple text input fields
  - Foundation for in-game console and chat systems

- Performance & Architecture Improvements
  - PyTexture copy operations optimized
  - GameEngine update cycle refined
  - UIEntity property handling enhanced
  - UITestScene modernized

Test Suite:
- Interactive visibility demos showing FOV in action
- Pathfinding comparison (A* vs Dijkstra)
- Debug utilities for visibility and empty path handling
- Sizzle reel demo combining pathfinding and vision
- Multiple text input test scenarios

This commit brings McRogueFace closer to a complete roguelike engine
with essential features like line-of-sight, intelligent pathfinding,
and interactive text input capabilities.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
John McCardle 2025-07-09 22:18:29 -04:00
commit d13153ddb4
25 changed files with 3317 additions and 225 deletions

View file

@ -0,0 +1,201 @@
"""
Text Input Widget System for McRogueFace
A reusable module for text input fields with focus management
"""
import mcrfpy
class FocusManager:
"""Manages focus across multiple widgets"""
def __init__(self):
self.widgets = []
self.focused_widget = None
self.focus_index = -1
def register(self, widget):
"""Register a widget"""
self.widgets.append(widget)
if self.focused_widget is None:
self.focus(widget)
def focus(self, widget):
"""Set focus to widget"""
if self.focused_widget:
self.focused_widget.on_blur()
self.focused_widget = widget
self.focus_index = self.widgets.index(widget) if widget in self.widgets else -1
if widget:
widget.on_focus()
def focus_next(self):
"""Focus next widget"""
if not self.widgets:
return
self.focus_index = (self.focus_index + 1) % len(self.widgets)
self.focus(self.widgets[self.focus_index])
def focus_prev(self):
"""Focus previous widget"""
if not self.widgets:
return
self.focus_index = (self.focus_index - 1) % len(self.widgets)
self.focus(self.widgets[self.focus_index])
def handle_key(self, key):
"""Send key to focused widget"""
if self.focused_widget:
return self.focused_widget.handle_key(key)
return False
class TextInput:
"""Text input field widget"""
def __init__(self, x, y, width, height=24, label="", placeholder="", on_change=None):
self.x = x
self.y = y
self.width = width
self.height = height
self.label = label
self.placeholder = placeholder
self.on_change = on_change
# Text state
self.text = ""
self.cursor_pos = 0
self.focused = False
# Visual elements
self._create_ui()
def _create_ui(self):
"""Create UI components"""
# Background frame
self.frame = mcrfpy.Frame(self.x, self.y, self.width, self.height)
self.frame.fill_color = (255, 255, 255, 255)
self.frame.outline_color = (128, 128, 128, 255)
self.frame.outline = 2
# Label (above input)
if self.label:
self.label_text = mcrfpy.Caption(self.label, self.x, self.y - 20)
self.label_text.fill_color = (255, 255, 255, 255)
# Text content
self.text_display = mcrfpy.Caption("", self.x + 4, self.y + 4)
self.text_display.fill_color = (0, 0, 0, 255)
# Placeholder text
if self.placeholder:
self.placeholder_text = mcrfpy.Caption(self.placeholder, self.x + 4, self.y + 4)
self.placeholder_text.fill_color = (180, 180, 180, 255)
# Cursor
self.cursor = mcrfpy.Frame(self.x + 4, self.y + 4, 2, self.height - 8)
self.cursor.fill_color = (0, 0, 0, 255)
self.cursor.visible = False
# Click handler
self.frame.click = self._on_click
def _on_click(self, x, y, button, state):
"""Handle mouse clicks"""
print(self, x, y, button, state)
if button == "left" and hasattr(self, '_focus_manager'):
self._focus_manager.focus(self)
def on_focus(self):
"""Called when focused"""
self.focused = True
self.frame.outline_color = (0, 120, 255, 255)
self.frame.outline = 3
self.cursor.visible = True
self._update_display()
def on_blur(self):
"""Called when focus lost"""
self.focused = False
self.frame.outline_color = (128, 128, 128, 255)
self.frame.outline = 2
self.cursor.visible = False
self._update_display()
def handle_key(self, key):
"""Process keyboard input"""
if not self.focused:
return False
old_text = self.text
handled = True
# Navigation and editing keys
if key == "BackSpace":
if self.cursor_pos > 0:
self.text = self.text[:self.cursor_pos-1] + self.text[self.cursor_pos:]
self.cursor_pos -= 1
elif key == "Delete":
if self.cursor_pos < len(self.text):
self.text = self.text[:self.cursor_pos] + self.text[self.cursor_pos+1:]
elif key == "Left":
self.cursor_pos = max(0, self.cursor_pos - 1)
elif key == "Right":
self.cursor_pos = min(len(self.text), self.cursor_pos + 1)
elif key == "Home":
self.cursor_pos = 0
elif key == "End":
self.cursor_pos = len(self.text)
elif key in ("Tab", "Return"):
handled = False # Let parent handle
elif len(key) == 1 and key.isprintable():
self.text = self.text[:self.cursor_pos] + key + self.text[self.cursor_pos:]
self.cursor_pos += 1
else:
handled = False
# Update if changed
if old_text != self.text:
self._update_display()
if self.on_change:
self.on_change(self.text)
elif handled:
self._update_cursor()
return handled
def _update_display(self):
"""Update visual state"""
# Show/hide placeholder
if hasattr(self, 'placeholder_text'):
self.placeholder_text.visible = (self.text == "" and not self.focused)
# Update text
self.text_display.text = self.text
self._update_cursor()
def _update_cursor(self):
"""Update cursor position"""
if self.focused:
# Estimate position (10 pixels per character)
self.cursor.x = self.x + 4 + (self.cursor_pos * 10)
def set_text(self, text):
"""Set text programmatically"""
self.text = text
self.cursor_pos = len(text)
self._update_display()
def get_text(self):
"""Get current text"""
return self.text
def add_to_scene(self, scene):
"""Add all components to scene"""
scene.append(self.frame)
if hasattr(self, 'label_text'):
scene.append(self.label_text)
if hasattr(self, 'placeholder_text'):
scene.append(self.placeholder_text)
scene.append(self.text_display)
scene.append(self.cursor)