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,48 @@
from text_input_widget_improved import FocusManager, TextInput
# Create focus manager
focus_mgr = FocusManager()
# Create input field
name_input = TextInput(
x=50, y=100,
width=300,
label="Name:",
placeholder="Enter your name",
on_change=lambda text: print(f"Name changed to: {text}")
)
tags_input = TextInput(
x=50, y=160,
width=300,
label="Tags:",
placeholder="door,chest,floor,wall",
on_change=lambda text: print(f"Text: {text}")
)
# Register with focus manager
name_input._focus_manager = focus_mgr
focus_mgr.register(name_input)
# Create demo scene
import mcrfpy
mcrfpy.createScene("text_example")
mcrfpy.setScene("text_example")
ui = mcrfpy.sceneUI("text_example")
# Add to scene
#ui.append(name_input) # don't do this, only the internal Frame class can go into the UI; have to manage derived objects "carefully" (McRogueFace alpha anti-feature)
name_input.add_to_scene(ui)
tags_input.add_to_scene(ui)
# Handle keyboard events
def handle_keys(key, state):
if not focus_mgr.handle_key(key, state):
if key == "Tab" and state == "start":
focus_mgr.focus_next()
# McRogueFace alpha anti-feature: only the active scene can be given a keypress callback
mcrfpy.keypressScene(handle_keys)

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)

View file

@ -0,0 +1,265 @@
"""
Improved Text Input Widget System for McRogueFace
Uses proper parent-child frame structure and handles keyboard input correctly
"""
import mcrfpy
class FocusManager:
"""Manages focus across multiple widgets"""
def __init__(self):
self.widgets = []
self.focused_widget = None
self.focus_index = -1
# Global keyboard state
self.shift_pressed = False
self.caps_lock = False
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, state):
"""Send key to focused widget"""
# Track shift state
if key == "LShift" or key == "RShift":
self.shift_pressed = True
return True
elif key == "start": # Key release for shift
self.shift_pressed = False
return True
elif key == "CapsLock":
self.caps_lock = not self.caps_lock
return True
if self.focused_widget:
return self.focused_widget.handle_key(key, self.shift_pressed, self.caps_lock)
return False
class TextInput:
"""Text input field widget with proper parent-child structure"""
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
# Create the widget structure
self._create_ui()
def _create_ui(self):
"""Create UI components with proper parent-child structure"""
# Parent frame that contains everything
self.parent_frame = mcrfpy.Frame(self.x, self.y - (20 if self.label else 0),
self.width, self.height + (20 if self.label else 0))
self.parent_frame.fill_color = (0, 0, 0, 0) # Transparent parent
# Input frame (relative to parent)
self.frame = mcrfpy.Frame(0, 20 if self.label else 0, 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 (relative to parent)
if self.label:
self.label_text = mcrfpy.Caption(self.label, 0, 0)
self.label_text.fill_color = (255, 255, 255, 255)
self.parent_frame.children.append(self.label_text)
# Text content (relative to input frame)
self.text_display = mcrfpy.Caption("", 4, 4)
self.text_display.fill_color = (0, 0, 0, 255)
# Placeholder text (relative to input frame)
if self.placeholder:
self.placeholder_text = mcrfpy.Caption(self.placeholder, 4, 4)
self.placeholder_text.fill_color = (180, 180, 180, 255)
self.frame.children.append(self.placeholder_text)
# Cursor (relative to input frame)
# Experiment: replacing cursor frame with an inline text character
#self.cursor = mcrfpy.Frame(4, 4, 2, self.height - 8)
#self.cursor.fill_color = (0, 0, 0, 255)
#self.cursor.visible = False
# Add children to input frame
self.frame.children.append(self.text_display)
#self.frame.children.append(self.cursor)
# Add input frame to parent
self.parent_frame.children.append(self.frame)
# Click handler on the input frame
self.frame.click = self._on_click
def _on_click(self, x, y, button, state):
"""Handle mouse clicks"""
print(f"{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, shift_pressed, caps_lock):
"""Process keyboard input with shift state"""
if not self.focused:
return False
old_text = self.text
handled = True
# Special key mappings for shifted characters
shift_map = {
"1": "!", "2": "@", "3": "#", "4": "$", "5": "%",
"6": "^", "7": "&", "8": "*", "9": "(", "0": ")",
"-": "_", "=": "+", "[": "{", "]": "}", "\\": "|",
";": ":", "'": '"', ",": "<", ".": ">", "/": "?",
"`": "~"
}
# 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 == "Space":
self._insert_at_cursor(" ")
elif key in ("Tab", "Return"):
handled = False # Let parent handle
# Handle number keys with "Num" prefix
elif key.startswith("Num") and len(key) == 4:
num = key[3] # Get the digit after "Num"
if shift_pressed and num in shift_map:
self._insert_at_cursor(shift_map[num])
else:
self._insert_at_cursor(num)
# Handle single character keys
elif len(key) == 1:
char = key
# Apply shift transformations
if shift_pressed:
if char in shift_map:
char = shift_map[char]
elif char.isalpha():
char = char.upper()
else:
# Apply caps lock for letters
if char.isalpha():
if caps_lock:
char = char.upper()
else:
char = char.lower()
self._insert_at_cursor(char)
else:
# Unhandled key - print for debugging
print(f"[TextInput] Unhandled key: '{key}' (shift={shift_pressed}, caps={caps_lock})")
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 _insert_at_cursor(self, char):
"""Insert a character at the cursor position"""
self.text = self.text[:self.cursor_pos] + char + self.text[self.cursor_pos:]
self.cursor_pos += 1
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.cursor_pos] + "|" + self.text[self.cursor_pos:]
self._update_cursor()
def _update_cursor(self):
"""Update cursor position"""
if self.focused:
# Estimate position (10 pixels per character)
#self.cursor.x = 4 + (self.cursor_pos * 10)
self.text_display.text = self.text[:self.cursor_pos] + "|" + self.text[self.cursor_pos:]
pass
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 only the parent frame to scene"""
scene.append(self.parent_frame)