Add cookbook examples with updated callback signatures for #229, #230

Cookbook structure:
- lib/: Reusable component library (Button, StatBar, AnimationChain, etc.)
- primitives/: Demo apps for individual components
- features/: Demo apps for complex features (animation chaining, shaders)
- apps/: Complete mini-applications (calculator, dialogue system)
- automation/: Screenshot capture utilities

API signature updates applied:
- on_enter/on_exit/on_move callbacks now only receive (pos) per #230
- on_cell_enter/on_cell_exit callbacks only receive (cell_pos) per #230
- Animation chain library uses Timer-based sequencing (unaffected by #229)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
John McCardle 2026-01-28 18:58:25 -05:00
commit 55f6ea9502
41 changed files with 8493 additions and 0 deletions

View file

@ -0,0 +1,15 @@
# McRogueFace Cookbook - Widget Standard Library and Demos
"""
A comprehensive collection of reusable UI widgets and interactive demos
showcasing McRogueFace capabilities.
Usage:
# Interactive menu
cd build && ./mcrogueface ../tests/cookbook/cookbook_main.py
# Run specific demo
cd build && ./mcrogueface ../tests/cookbook/primitives/demo_button.py
# Generate screenshots (headless)
cd build && ./mcrogueface --headless --exec ../tests/cookbook/run_screenshots.py
"""

View file

@ -0,0 +1,15 @@
# McRogueFace Cookbook - Mini-Application Demos
"""
Complete mini-applications demonstrating real-world patterns.
Applications:
calculator.py - Boss-key calculator with scene switching
dialogue_system.py - NPC dialogue with choices and mood
day_night_shadows.py - HeightMap shadow projection demo
remote_control.py - FOV + entity perspective switching
health_bar_hover.py - Hover-based health display
toast_demo.py - Notification system demo
banter_chat.py - Timed chat messages
loot_detector.py - Directional arrow compass
grid_puzzle.py - Interactive Sokoban-style puzzle
"""

View file

@ -0,0 +1,320 @@
#!/usr/bin/env python3
"""Calculator App - Boss-key calculator with scene switching
Interactive controls:
0-9: Number input
+, -, *, /: Operations
Enter/=: Calculate result
C: Clear
ESC: Toggle between game and calculator scenes
Backspace: Delete last digit
This demonstrates:
- Scene switching (boss key pattern)
- Button grid layout
- State management
- Click handlers
"""
import mcrfpy
import sys
import os
# Add parent to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from lib.button import Button
class Calculator:
"""A simple calculator with GUI."""
def __init__(self):
self.scene = mcrfpy.Scene("calculator")
self.ui = self.scene.children
self.expression = ""
self.result = ""
self.setup()
def setup(self):
"""Build the calculator UI."""
# Background
bg = mcrfpy.Frame(
pos=(0, 0),
size=(1024, 768),
fill_color=mcrfpy.Color(30, 30, 35)
)
self.ui.append(bg)
# Calculator frame
calc_frame = mcrfpy.Frame(
pos=(312, 100),
size=(400, 550),
fill_color=mcrfpy.Color(40, 40, 50),
outline_color=mcrfpy.Color(80, 80, 100),
outline=2
)
self.ui.append(calc_frame)
# Title
title = mcrfpy.Caption(
text="Calculator",
pos=(512, 130),
font_size=24,
fill_color=mcrfpy.Color(200, 200, 200)
)
self.ui.append(title)
# Display frame
display_bg = mcrfpy.Frame(
pos=(332, 170),
size=(360, 80),
fill_color=mcrfpy.Color(20, 25, 30),
outline_color=mcrfpy.Color(60, 60, 80),
outline=1
)
self.ui.append(display_bg)
# Expression display
self.expr_display = mcrfpy.Caption(
text="",
pos=(682, 180), # Right-aligned
font_size=18,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(self.expr_display)
# Result display
self.result_display = mcrfpy.Caption(
text="0",
pos=(682, 210), # Right-aligned
font_size=32,
fill_color=mcrfpy.Color(255, 255, 255)
)
self.ui.append(self.result_display)
# Button layout
button_layout = [
["C", "(", ")", "/"],
["7", "8", "9", "*"],
["4", "5", "6", "-"],
["1", "2", "3", "+"],
["0", ".", "DEL", "="],
]
start_x = 342
start_y = 270
btn_width = 80
btn_height = 50
spacing = 8
for row_idx, row in enumerate(button_layout):
for col_idx, label in enumerate(row):
x = start_x + col_idx * (btn_width + spacing)
y = start_y + row_idx * (btn_height + spacing)
# Different colors for different button types
if label in "0123456789.":
fill = mcrfpy.Color(60, 60, 70)
hover = mcrfpy.Color(80, 80, 95)
elif label == "=":
fill = mcrfpy.Color(80, 120, 80)
hover = mcrfpy.Color(100, 150, 100)
elif label == "C":
fill = mcrfpy.Color(120, 60, 60)
hover = mcrfpy.Color(150, 80, 80)
else:
fill = mcrfpy.Color(70, 70, 90)
hover = mcrfpy.Color(90, 90, 115)
btn = Button(
label,
pos=(x, y),
size=(btn_width, btn_height),
fill_color=fill,
hover_color=hover,
callback=lambda l=label: self.on_button(l),
font_size=20
)
self.ui.append(btn.frame)
# Instructions
instr = mcrfpy.Caption(
text="Press ESC to switch to game | Click buttons or use keyboard",
pos=(512, 580),
font_size=14,
fill_color=mcrfpy.Color(100, 100, 100)
)
self.ui.append(instr)
def on_button(self, label):
"""Handle button press."""
if label == "C":
self.expression = ""
self.result = "0"
elif label == "DEL":
self.expression = self.expression[:-1]
elif label == "=":
self.calculate()
else:
self.expression += label
self._update_display()
def calculate(self):
"""Evaluate the expression."""
if not self.expression:
return
try:
# Safe evaluation (only math operations)
allowed = set("0123456789+-*/.()")
if all(c in allowed for c in self.expression):
self.result = str(eval(self.expression))
if self.result.endswith('.0'):
self.result = self.result[:-2]
else:
self.result = "Error"
except Exception:
self.result = "Error"
def _update_display(self):
"""Update the display captions."""
self.expr_display.text = self.expression or ""
self.result_display.text = self.result or "0"
def on_key(self, key, state):
"""Handle keyboard input."""
if state != "start":
return
if key == "Escape":
# Switch to game scene
mcrfpy.current_scene = game_scene
return
# Map keys to buttons
key_map = {
"Num0": "0", "Num1": "1", "Num2": "2", "Num3": "3",
"Num4": "4", "Num5": "5", "Num6": "6", "Num7": "7",
"Num8": "8", "Num9": "9",
"Period": ".", "Add": "+", "Subtract": "-",
"Multiply": "*", "Divide": "/",
"Enter": "=", "Return": "=",
"C": "C", "Backspace": "DEL",
"LParen": "(", "RParen": ")",
}
if key in key_map:
self.on_button(key_map[key])
def activate(self):
"""Activate the calculator scene."""
self.scene.on_key = self.on_key
mcrfpy.current_scene = self.scene
class GamePlaceholder:
"""Placeholder game scene to switch to/from."""
def __init__(self):
self.scene = mcrfpy.Scene("game_placeholder")
self.ui = self.scene.children
self.setup()
def setup(self):
"""Build the game placeholder UI."""
# Background
bg = mcrfpy.Frame(
pos=(0, 0),
size=(1024, 768),
fill_color=mcrfpy.Color(20, 40, 20)
)
self.ui.append(bg)
# Title
title = mcrfpy.Caption(
text="GAME SCENE",
pos=(512, 350),
font_size=48,
fill_color=mcrfpy.Color(100, 200, 100)
)
title.outline = 3
title.outline_color = mcrfpy.Color(0, 0, 0)
self.ui.append(title)
# Subtitle
subtitle = mcrfpy.Caption(
text="Press ESC for Calculator (Boss Key!)",
pos=(512, 420),
font_size=20,
fill_color=mcrfpy.Color(150, 200, 150)
)
self.ui.append(subtitle)
# Fake game elements
for i in range(5):
fake_element = mcrfpy.Frame(
pos=(100 + i * 180, 550),
size=(150, 100),
fill_color=mcrfpy.Color(40 + i * 10, 60 + i * 5, 40),
outline_color=mcrfpy.Color(80, 120, 80),
outline=1
)
self.ui.append(fake_element)
label = mcrfpy.Caption(
text=f"Game Element {i+1}",
pos=(175 + i * 180, 590),
font_size=12,
fill_color=mcrfpy.Color(200, 200, 200)
)
self.ui.append(label)
def on_key(self, key, state):
"""Handle keyboard input."""
if state != "start":
return
if key == "Escape":
# Switch to calculator
calculator.activate()
def activate(self):
"""Activate the game scene."""
self.scene.on_key = self.on_key
mcrfpy.current_scene = self.scene
# Global instances
calculator = None
game_scene = None
def main():
"""Run the calculator app."""
global calculator, game_scene
calculator = Calculator()
game_scene = GamePlaceholder()
# Start with the game scene
game_scene.activate()
# Headless mode: capture screenshot and exit
try:
if mcrfpy.headless_mode():
from mcrfpy import automation
# Show calculator for screenshot
calculator.activate()
calculator.expression = "7+8"
calculator._update_display()
mcrfpy.Timer("screenshot", lambda rt: (
automation.screenshot("screenshots/apps/calculator.png"),
sys.exit(0)
), 100)
except AttributeError:
pass
if __name__ == "__main__":
main()

View file

@ -0,0 +1,497 @@
#!/usr/bin/env python3
"""Dialogue System - NPC dialogue with choices and mood
Interactive controls:
1-4: Select dialogue choice
Enter: Confirm selection
Space: Advance dialogue (skip typewriter)
R: Restart conversation
ESC: Exit demo
This demonstrates:
- Portrait + text + choices pattern
- State machine for NPC mood
- Choice consequences
- Dynamic UI updates
"""
import mcrfpy
import sys
import os
# Add parent to path for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from lib.text_box import DialogueBox
from lib.choice_list import ChoiceList
class NPC:
"""NPC with mood and dialogue state."""
MOODS = {
"neutral": mcrfpy.Color(150, 150, 150),
"happy": mcrfpy.Color(100, 200, 100),
"angry": mcrfpy.Color(200, 80, 80),
"sad": mcrfpy.Color(80, 80, 200),
"suspicious": mcrfpy.Color(200, 180, 80),
}
def __init__(self, name, initial_mood="neutral"):
self.name = name
self.mood = initial_mood
self.trust = 50 # 0-100 scale
self.dialogue_state = "greeting"
@property
def mood_color(self):
return self.MOODS.get(self.mood, self.MOODS["neutral"])
class DialogueSystem:
"""Complete dialogue system with NPC interaction."""
def __init__(self):
self.scene = mcrfpy.Scene("dialogue_system")
self.ui = self.scene.children
# Create NPC
self.npc = NPC("Elder Sage")
# Dialogue tree
self.dialogue_tree = self._create_dialogue_tree()
self.setup()
def _create_dialogue_tree(self):
"""Create the dialogue tree structure."""
return {
"greeting": {
"text": "Greetings, traveler. I am the Elder Sage of this village. What brings you to these remote lands?",
"choices": [
("I seek wisdom and knowledge.", "wise_response"),
("I'm looking for treasure!", "treasure_response"),
("None of your business.", "rude_response"),
("I'm lost. Can you help me?", "help_response"),
]
},
"wise_response": {
"text": "Ah, a seeker of truth! That is admirable. Knowledge is the greatest treasure one can possess. Tell me, what specific wisdom do you seek?",
"mood": "happy",
"trust_change": 10,
"choices": [
("I want to learn about the ancient prophecy.", "prophecy"),
("Teach me about magic.", "magic"),
("I wish to know the history of this land.", "history"),
]
},
"treasure_response": {
"text": "Treasure? Bah! Material wealth corrupts the soul. But... perhaps you could prove yourself worthy of learning where such things might be found.",
"mood": "suspicious",
"trust_change": -5,
"choices": [
("I apologize. I spoke hastily.", "apologize"),
("I don't need your approval!", "defiant"),
("What must I do to prove myself?", "prove_worthy"),
]
},
"rude_response": {
"text": "How dare you speak to me with such disrespect! Leave my presence at once!",
"mood": "angry",
"trust_change": -30,
"choices": [
("I'm sorry, I didn't mean that.", "apologize_angry"),
("Make me!", "defiant"),
("*Leave quietly*", "leave"),
]
},
"help_response": {
"text": "Lost? Poor soul. These mountains can be treacherous. I will help you find your way, but first, tell me where you wish to go.",
"mood": "neutral",
"trust_change": 5,
"choices": [
("To the nearest town.", "directions"),
("Anywhere but here.", "sad_path"),
("Actually, maybe I'll stay a while.", "stay"),
]
},
"prophecy": {
"text": "The ancient prophecy speaks of a chosen one who will restore balance when darkness falls. Many believe that time is now approaching...",
"mood": "neutral",
"choices": [
("Am I the chosen one?", "chosen"),
("How can I help prevent this darkness?", "help_prevent"),
("That sounds like nonsense.", "skeptic"),
]
},
"magic": {
"text": "Magic is not learned from books alone. It flows from within, from understanding the natural world. Your journey has only begun.",
"mood": "happy",
"choices": [
("Will you teach me?", "teach"),
("I understand. Thank you.", "thanks"),
]
},
"history": {
"text": "This land was once a great kingdom, until the Shadow Wars tore it asunder. Now only ruins remain of its former glory.",
"mood": "sad",
"choices": [
("What caused the Shadow Wars?", "shadow_wars"),
("Can the kingdom be restored?", "restore"),
("Thank you for sharing.", "thanks"),
]
},
"apologize": {
"text": "Hmm... perhaps I misjudged you. True wisdom includes recognizing one's mistakes. Let us start again.",
"mood": "neutral",
"trust_change": 5,
"choices": [
("Thank you for understanding.", "wise_response"),
]
},
"apologize_angry": {
"text": "*sighs* Very well. I accept your apology. But mind your tongue in the future.",
"mood": "neutral",
"trust_change": -10,
"choices": [
("I will. Now, I seek wisdom.", "wise_response"),
("Thank you, Elder.", "thanks"),
]
},
"defiant": {
"text": "GUARDS! Remove this insolent fool from my sight!",
"mood": "angry",
"trust_change": -50,
"choices": [
("*Run away!*", "escape"),
("*Fight the guards*", "fight"),
]
},
"prove_worthy": {
"text": "There is a sacred trial in the mountains. Complete it, and I may reconsider my opinion of you.",
"mood": "neutral",
"trust_change": 5,
"choices": [
("I accept the challenge!", "accept_trial"),
("What kind of trial?", "trial_info"),
("Perhaps another time.", "decline"),
]
},
"thanks": {
"text": "You are welcome, traveler. May your journey be blessed with wisdom and good fortune. Return if you need guidance.",
"mood": "happy",
"choices": [
("Farewell, Elder.", "end_good"),
("I have more questions.", "greeting"),
]
},
"end_good": {
"text": "Farewell. May the ancient spirits watch over you.",
"mood": "happy",
"choices": [
("*Restart conversation*", "greeting"),
]
},
"leave": {
"text": "Perhaps it is for the best. Safe travels... if you can find your way.",
"mood": "neutral",
"choices": [
("*Restart conversation*", "greeting"),
]
},
"escape": {
"text": "*You flee into the wilderness, the guards' shouts fading behind you...*",
"mood": "neutral",
"choices": [
("*Restart conversation*", "greeting"),
]
},
"fight": {
"text": "*The guards overwhelm you. You wake up in a cell...*",
"mood": "angry",
"choices": [
("*Restart conversation*", "greeting"),
]
},
# Default responses for missing states
"chosen": {"text": "That remains to be seen. Only time will tell.", "mood": "neutral", "choices": [("I understand.", "thanks")]},
"help_prevent": {"text": "Prepare yourself. Train hard. The darkness comes for us all.", "mood": "neutral", "choices": [("I will.", "thanks")]},
"skeptic": {"text": "Believe what you will. But when darkness comes, remember my words.", "mood": "sad", "choices": [("Perhaps you're right.", "apologize")]},
"teach": {"text": "In time, perhaps. First, prove your dedication.", "mood": "neutral", "choices": [("How?", "prove_worthy")]},
"shadow_wars": {"text": "Ancient evils that should have remained buried. Let us speak no more of it.", "mood": "sad", "choices": [("I understand.", "thanks")]},
"restore": {"text": "Perhaps... if the chosen one rises. Perhaps.", "mood": "neutral", "choices": [("I hope so.", "thanks")]},
"directions": {"text": "Head east through the mountain pass. The town of Millbrook lies two days' journey.", "mood": "neutral", "choices": [("Thank you!", "thanks")]},
"sad_path": {"text": "I sense great pain in you. Perhaps you should stay and heal.", "mood": "sad", "choices": [("You're right.", "stay")]},
"stay": {"text": "You are welcome here. Rest, and we shall talk more.", "mood": "happy", "choices": [("Thank you, Elder.", "thanks")]},
"accept_trial": {"text": "Brave soul! Seek the Cave of Trials to the north. Return victorious!", "mood": "happy", "choices": [("I will return!", "thanks")]},
"trial_info": {"text": "It tests courage, wisdom, and heart. Few have succeeded.", "mood": "neutral", "choices": [("I'll try anyway!", "accept_trial"), ("Maybe not...", "decline")]},
"decline": {"text": "Perhaps another time then. The offer stands.", "mood": "neutral", "choices": [("Thank you.", "thanks")]},
}
def setup(self):
"""Build the dialogue UI."""
# Background
bg = mcrfpy.Frame(
pos=(0, 0),
size=(1024, 768),
fill_color=mcrfpy.Color(25, 25, 30)
)
self.ui.append(bg)
# Scene background (simple village scene)
scene_bg = mcrfpy.Frame(
pos=(0, 0),
size=(1024, 400),
fill_color=mcrfpy.Color(40, 60, 40)
)
self.ui.append(scene_bg)
# Ground
ground = mcrfpy.Frame(
pos=(0, 350),
size=(1024, 50),
fill_color=mcrfpy.Color(60, 45, 30)
)
self.ui.append(ground)
# Simple building shapes
for i in range(3):
building = mcrfpy.Frame(
pos=(100 + i * 300, 200 - i * 20),
size=(200, 150 + i * 20),
fill_color=mcrfpy.Color(70 + i * 10, 60 + i * 5, 50),
outline_color=mcrfpy.Color(40, 35, 30),
outline=2
)
self.ui.append(building)
# Portrait frame
self.portrait_frame = mcrfpy.Frame(
pos=(50, 420),
size=(150, 180),
fill_color=mcrfpy.Color(50, 50, 60),
outline_color=mcrfpy.Color(100, 100, 120),
outline=3
)
self.ui.append(self.portrait_frame)
# NPC "face" (simple representation)
face_bg = mcrfpy.Frame(
pos=(15, 15),
size=(120, 120),
fill_color=mcrfpy.Color(200, 180, 160),
outline=0
)
self.portrait_frame.children.append(face_bg)
# Mood indicator (eyes/expression)
self.mood_indicator = mcrfpy.Frame(
pos=(35, 50),
size=(80, 30),
fill_color=self.npc.mood_color,
outline=0
)
self.portrait_frame.children.append(self.mood_indicator)
# Name label
name_label = mcrfpy.Caption(
text=self.npc.name,
pos=(75, 150),
font_size=14,
fill_color=mcrfpy.Color(255, 255, 255)
)
self.portrait_frame.children.append(name_label)
# Dialogue box
self.dialogue_box = DialogueBox(
pos=(220, 420),
size=(550, 180),
speaker=self.npc.name,
text="",
chars_per_second=40
)
self.ui.append(self.dialogue_box.frame)
# Choice list
self.choice_list = ChoiceList(
pos=(220, 610),
size=(550, 120),
choices=["Loading..."],
on_select=self.on_choice,
item_height=28
)
self.ui.append(self.choice_list.frame)
# Trust meter
trust_label = mcrfpy.Caption(
text="Trust:",
pos=(820, 430),
font_size=14,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(trust_label)
self.trust_bar_bg = mcrfpy.Frame(
pos=(820, 450),
size=(150, 20),
fill_color=mcrfpy.Color(40, 40, 50),
outline_color=mcrfpy.Color(80, 80, 100),
outline=1
)
self.ui.append(self.trust_bar_bg)
self.trust_bar = mcrfpy.Frame(
pos=(0, 0),
size=(75, 20), # 50% initial
fill_color=mcrfpy.Color(100, 150, 100),
outline=0
)
self.trust_bar_bg.children.append(self.trust_bar)
self.trust_value = mcrfpy.Caption(
text="50",
pos=(895, 473),
font_size=12,
fill_color=mcrfpy.Color(200, 200, 200)
)
self.ui.append(self.trust_value)
# Mood display
mood_label = mcrfpy.Caption(
text="Mood:",
pos=(820, 500),
font_size=14,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(mood_label)
self.mood_display = mcrfpy.Caption(
text="Neutral",
pos=(870, 500),
font_size=14,
fill_color=self.npc.mood_color
)
self.ui.append(self.mood_display)
# Instructions
instr = mcrfpy.Caption(
text="1-4: Select choice | Enter: Confirm | Space: Skip | R: Restart | ESC: Exit",
pos=(50, 740),
font_size=14,
fill_color=mcrfpy.Color(100, 100, 100)
)
self.ui.append(instr)
# Start dialogue
self._load_dialogue_state("greeting")
def _load_dialogue_state(self, state_name):
"""Load a dialogue state."""
self.npc.dialogue_state = state_name
if state_name not in self.dialogue_tree:
state_name = "greeting"
self.npc.dialogue_state = state_name
state = self.dialogue_tree[state_name]
# Apply mood change
if "mood" in state:
self.npc.mood = state["mood"]
self._update_mood_display()
# Apply trust change
if "trust_change" in state:
self.npc.trust = max(0, min(100, self.npc.trust + state["trust_change"]))
self._update_trust_display()
# Update dialogue
self.dialogue_box.set_dialogue(self.npc.name, state["text"], animate=True)
# Update choices
choices = [choice[0] for choice in state.get("choices", [])]
self._choice_targets = [choice[1] for choice in state.get("choices", [])]
if choices:
self.choice_list.choices = choices
else:
self.choice_list.choices = ["*Continue*"]
self._choice_targets = ["greeting"]
def _update_mood_display(self):
"""Update the mood indicators."""
self.mood_indicator.fill_color = self.npc.mood_color
self.mood_display.text = self.npc.mood.capitalize()
self.mood_display.fill_color = self.npc.mood_color
def _update_trust_display(self):
"""Update the trust bar."""
bar_width = int((self.npc.trust / 100) * 150)
self.trust_bar.w = bar_width
self.trust_value.text = str(self.npc.trust)
# Color based on trust level
if self.npc.trust >= 70:
self.trust_bar.fill_color = mcrfpy.Color(100, 200, 100)
elif self.npc.trust >= 30:
self.trust_bar.fill_color = mcrfpy.Color(200, 200, 100)
else:
self.trust_bar.fill_color = mcrfpy.Color(200, 100, 100)
def on_choice(self, index, value):
"""Handle choice selection."""
if index < len(self._choice_targets):
next_state = self._choice_targets[index]
self._load_dialogue_state(next_state)
def on_key(self, key, state):
"""Handle keyboard input."""
if state != "start":
return
if key == "Escape":
sys.exit(0)
elif key in ("Num1", "Num2", "Num3", "Num4"):
idx = int(key[-1]) - 1
if idx < len(self.choice_list.choices):
self.choice_list.set_selected(idx)
self.choice_list.confirm()
elif key == "Up":
self.choice_list.navigate(-1)
elif key == "Down":
self.choice_list.navigate(1)
elif key == "Enter":
self.choice_list.confirm()
elif key == "Space":
self.dialogue_box.skip_animation()
elif key == "R":
# Restart
self.npc.mood = "neutral"
self.npc.trust = 50
self._update_mood_display()
self._update_trust_display()
self._load_dialogue_state("greeting")
def activate(self):
"""Activate the dialogue scene."""
self.scene.on_key = self.on_key
mcrfpy.current_scene = self.scene
def main():
"""Run the dialogue system demo."""
dialogue = DialogueSystem()
dialogue.activate()
# Headless mode: capture screenshot and exit
try:
if mcrfpy.headless_mode():
from mcrfpy import automation
mcrfpy.Timer("screenshot", lambda rt: (
automation.screenshot("screenshots/apps/dialogue_system.png"),
sys.exit(0)
), 200)
except AttributeError:
pass
if __name__ == "__main__":
main()

View file

@ -0,0 +1,10 @@
# McRogueFace Cookbook - Automation Scripts
"""
Automation tools for capturing screenshots and testing interactions.
Scripts:
capture_primitives.py - Capture all primitive widget demos
capture_features.py - Capture feature demo screenshots
capture_apps.py - Capture mini-application screenshots
test_interactions.py - Automated interaction tests
"""

View file

@ -0,0 +1,117 @@
#!/usr/bin/env python3
"""Capture screenshots for all primitive widget demos.
Run with:
cd build && ./mcrogueface --headless --exec ../tests/cookbook/automation/capture_primitives.py
"""
import mcrfpy
from mcrfpy import automation
import sys
import os
# Add cookbook to path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# List of demos to capture with their setup functions
DEMOS = [
("button", "demo_button"),
("stat_bar", "demo_stat_bar"),
("choice_list", "demo_choice_list"),
("text_box", "demo_text_box"),
("toast", "demo_toast"),
]
# Screenshot output directory (relative to build/)
OUTPUT_DIR = "../tests/cookbook/screenshots/primitives"
class ScreenshotCapture:
"""Captures screenshots for all primitive demos."""
def __init__(self):
self.current_demo = 0
self.demos = []
def load_demos(self):
"""Load all demo modules."""
for name, module_name in DEMOS:
try:
module = __import__(f"primitives.{module_name}", fromlist=[module_name])
self.demos.append((name, module))
print(f"Loaded: {name}")
except Exception as e:
print(f"Failed to load {name}: {e}")
def capture_next(self, runtime=None):
"""Capture the next demo screenshot."""
if self.current_demo >= len(self.demos):
print(f"\nDone! Captured {self.current_demo} screenshots.")
sys.exit(0)
return
name, module = self.demos[self.current_demo]
print(f"Capturing: {name}...")
try:
# Create demo instance
demo_class = None
for attr_name in dir(module):
attr = getattr(module, attr_name)
if isinstance(attr, type) and attr_name.endswith('Demo'):
demo_class = attr
break
if demo_class:
demo = demo_class()
demo.activate()
# Schedule screenshot after render
def take_screenshot(rt):
filename = f"{OUTPUT_DIR}/{name}.png"
try:
automation.screenshot(filename)
print(f" Saved: {filename}")
except Exception as e:
print(f" Error saving: {e}")
self.current_demo += 1
self.capture_next()
mcrfpy.Timer(f"capture_{name}", take_screenshot, 100)
else:
print(f" No Demo class found in {name}")
self.current_demo += 1
self.capture_next()
except Exception as e:
print(f" Error: {e}")
import traceback
traceback.print_exc()
self.current_demo += 1
self.capture_next()
def start(self):
"""Start the capture process."""
print("=" * 50)
print("Cookbook Primitive Screenshot Capture")
print("=" * 50)
# Ensure output directory exists
os.makedirs(OUTPUT_DIR, exist_ok=True)
self.load_demos()
print(f"\nCapturing {len(self.demos)} demos...")
print("-" * 50)
# Start capture process
self.capture_next()
def main():
capture = ScreenshotCapture()
capture.start()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,11 @@
# McRogueFace Cookbook - Compound Widget Demos
"""
Complex UI patterns combining multiple widgets.
Demos:
shop_demo.py - Multi-grid item transfer with equipment slots
inventory_demo.py - Grid-based inventory with drag & drop
These demos build on the primitives and lib/ widgets to show
how to compose sophisticated game interfaces.
"""

View file

@ -0,0 +1,445 @@
#!/usr/bin/env python3
"""Shop Demo - Multi-grid item transfer with equipment slots
Interactive controls:
Left click: Pick up / Place item
Right click: Cancel pickup
ESC: Return to menu
This demonstrates:
- ItemManager coordinating multiple grids and slots
- Different zoom levels for shop vs inventory
- Equipment slots with type restrictions
- Item tooltips
- Transaction tracking (gold display)
"""
import mcrfpy
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from lib.item_manager import ItemManager, ItemSlot, Item, ITEM_DATABASE, get_item
class ShopDemo:
"""Demo showing a shop interface with inventory and equipment."""
def __init__(self):
self.scene = mcrfpy.Scene("shop_demo")
self.ui = self.scene.children
self.manager = None
self.gold = 100
# UI elements for updates
self.gold_display = None
self.tooltip = None
self.item_stats = None
self.setup()
def setup(self):
"""Build the shop UI."""
# Background
bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=(25, 22, 30))
self.ui.append(bg)
# Title
title = mcrfpy.Caption(
text="The Adventurer's Shop",
pos=(512, 25),
font_size=32,
fill_color=(255, 220, 100)
)
title.outline = 2
title.outline_color = (80, 60, 0)
self.ui.append(title)
# Gold display
self.gold_display = mcrfpy.Caption(
text=f"Gold: {self.gold}",
pos=(900, 25),
font_size=20,
fill_color=(255, 215, 0)
)
self.ui.append(self.gold_display)
# Initialize item manager
self.manager = ItemManager(self.scene)
self.manager.on_pickup = self._on_pickup
self.manager.on_drop = self._on_drop
# Create shop grid (left side, zoomed in)
self._create_shop_grid()
# Create player inventory (right side)
self._create_inventory_grid()
# Create equipment slots (center)
self._create_equipment_slots()
# Tooltip area
self._create_tooltip_area()
# Instructions
instr = mcrfpy.Caption(
text="Left click: Pick up/Place | Right click: Cancel | Items show stats on hover",
pos=(512, 740),
font_size=14,
fill_color=(150, 150, 150)
)
self.ui.append(instr)
def _create_shop_grid(self):
"""Create the shop's item display grid."""
# Shop panel
shop_panel = mcrfpy.Frame(
pos=(20, 70),
size=(300, 400),
fill_color=(40, 35, 50),
outline=2,
outline_color=(80, 70, 100)
)
self.ui.append(shop_panel)
shop_label = mcrfpy.Caption(
text="Shop Inventory",
pos=(170, 90),
font_size=18,
fill_color=(200, 180, 255)
)
self.ui.append(shop_label)
# Shop grid (larger cells for better visibility)
grid_size = (4, 4)
cell_size = 64 # Zoomed in
grid_pixel_size = (grid_size[0] * cell_size, grid_size[1] * cell_size)
shop_grid = mcrfpy.Grid(
pos=(40, 120),
size=grid_pixel_size,
grid_size=grid_size,
texture=mcrfpy.default_texture,
zoom=4.0
)
# Fill tile layer with floor tiles
tile_layer = shop_grid.layers[0]
tile_layer.fill(46)
# Add color layer with slight tint
color_layer = shop_grid.add_layer('color', z_index=-1)
for y in range(grid_size[1]):
for x in range(grid_size[0]):
color_layer.set((x, y), (180, 170, 200, 80))
self.ui.append(shop_grid)
self.manager.register_grid("shop", shop_grid, {})
# Stock the shop
shop_items = [
('longsword', (0, 0)),
('shield', (1, 0)),
('axe', (2, 0)),
('wand', (3, 0)),
('health_potion', (0, 1)),
('mana_potion', (1, 1)),
('str_potion', (2, 1)),
('staff', (3, 1)),
('buckler', (0, 2)),
('hammer', (1, 2)),
('spear', (2, 2)),
('double_axe', (3, 2)),
]
for item_name, pos in shop_items:
item = get_item(item_name)
if item:
self.manager.add_item_to_grid("shop", pos, item)
def _create_inventory_grid(self):
"""Create the player's inventory grid."""
# Inventory panel
inv_panel = mcrfpy.Frame(
pos=(704, 70),
size=(300, 400),
fill_color=(35, 40, 50),
outline=2,
outline_color=(70, 80, 100)
)
self.ui.append(inv_panel)
inv_label = mcrfpy.Caption(
text="Your Inventory",
pos=(854, 90),
font_size=18,
fill_color=(180, 200, 255)
)
self.ui.append(inv_label)
# Inventory grid (smaller cells, more slots)
grid_size = (5, 6)
cell_size = 48
grid_pixel_size = (grid_size[0] * cell_size, grid_size[1] * cell_size)
inv_grid = mcrfpy.Grid(
pos=(729, 120),
size=grid_pixel_size,
grid_size=grid_size,
texture=mcrfpy.default_texture,
zoom=3.0
)
# Fill tile layer with floor tiles
tile_layer = inv_grid.layers[0]
tile_layer.fill(46)
# Add color layer with slight tint
color_layer = inv_grid.add_layer('color', z_index=-1)
for y in range(grid_size[1]):
for x in range(grid_size[0]):
color_layer.set((x, y), (170, 180, 200, 80))
self.ui.append(inv_grid)
self.manager.register_grid("inventory", inv_grid, {})
# Give player starting items
starting_items = [
('shortsword', (0, 0)),
('lesser_health', (1, 0)),
('lesser_health', (2, 0)),
]
for item_name, pos in starting_items:
item = get_item(item_name)
if item:
self.manager.add_item_to_grid("inventory", pos, item)
def _create_equipment_slots(self):
"""Create equipment slots in the center."""
# Equipment panel
equip_panel = mcrfpy.Frame(
pos=(362, 70),
size=(300, 400),
fill_color=(45, 40, 35),
outline=2,
outline_color=(100, 90, 70)
)
self.ui.append(equip_panel)
equip_label = mcrfpy.Caption(
text="Equipment",
pos=(512, 90),
font_size=18,
fill_color=(255, 220, 180)
)
self.ui.append(equip_label)
# Character silhouette placeholder
char_frame = mcrfpy.Frame(
pos=(437, 150),
size=(150, 200),
fill_color=(60, 55, 50),
outline=1,
outline_color=(100, 95, 85)
)
self.ui.append(char_frame)
char_label = mcrfpy.Caption(
text="[Character]",
pos=(512, 240),
font_size=14,
fill_color=(120, 115, 105)
)
self.ui.append(char_label)
# Equipment slots around the character
slot_size = (64, 64)
# Weapon slot (left of character)
weapon_slot = ItemSlot(
pos=(382, 200),
size=slot_size,
slot_type='weapon',
empty_color=(80, 60, 60),
valid_color=(60, 100, 60),
invalid_color=(100, 60, 60),
filled_color=(100, 80, 80)
)
self.ui.append(weapon_slot)
self.manager.register_slot("weapon", weapon_slot)
weapon_label = mcrfpy.Caption(
text="Weapon",
pos=(414, 270),
font_size=12,
fill_color=(200, 180, 180)
)
self.ui.append(weapon_label)
# Shield slot (right of character)
shield_slot = ItemSlot(
pos=(578, 200),
size=slot_size,
slot_type='shield',
empty_color=(60, 60, 80),
valid_color=(60, 100, 60),
invalid_color=(100, 60, 60),
filled_color=(80, 80, 100)
)
self.ui.append(shield_slot)
self.manager.register_slot("shield", shield_slot)
shield_label = mcrfpy.Caption(
text="Shield",
pos=(610, 270),
font_size=12,
fill_color=(180, 180, 200)
)
self.ui.append(shield_label)
# Consumable slot (below character)
consumable_slot = ItemSlot(
pos=(480, 360),
size=slot_size,
slot_type='consumable',
empty_color=(60, 80, 60),
valid_color=(60, 100, 60),
invalid_color=(100, 60, 60),
filled_color=(80, 100, 80)
)
self.ui.append(consumable_slot)
self.manager.register_slot("consumable", consumable_slot)
consumable_label = mcrfpy.Caption(
text="Quick Item",
pos=(512, 430),
font_size=12,
fill_color=(180, 200, 180)
)
self.ui.append(consumable_label)
def _create_tooltip_area(self):
"""Create area for item tooltips."""
# Tooltip panel
tooltip_panel = mcrfpy.Frame(
pos=(20, 490),
size=(984, 100),
fill_color=(30, 30, 40),
outline=1,
outline_color=(60, 60, 80)
)
self.ui.append(tooltip_panel)
# Item name
self.tooltip = mcrfpy.Caption(
text="Hover over an item to see details",
pos=(512, 510),
font_size=18,
fill_color=(180, 180, 180)
)
self.ui.append(self.tooltip)
# Item stats
self.item_stats = mcrfpy.Caption(
text="",
pos=(512, 545),
font_size=14,
fill_color=(150, 150, 180)
)
self.ui.append(self.item_stats)
# Status message
self.status = mcrfpy.Caption(
text="",
pos=(512, 610),
font_size=16,
fill_color=(100, 255, 100)
)
self.ui.append(self.status)
def _format_item_stats(self, item):
"""Format item stats for display."""
stats = []
if item.atk > 0:
stats.append(f"+{item.atk} ATK")
if item.def_ > 0:
stats.append(f"+{item.def_} DEF")
if item.stats.get('int_', 0) > 0:
stats.append(f"+{item.stats['int_']} INT")
if item.stats.get('range_', 0) > 0:
stats.append(f"+{item.stats['range_']} Range")
if item.stats.get('two_handed'):
stats.append("(Two-handed)")
stats_str = " | ".join(stats) if stats else "No combat stats"
return f"{stats_str} | Price: {item.price} gold"
def _on_pickup(self, item, source, pos):
"""Called when item is picked up."""
self.tooltip.text = f"Holding: {item.name}"
self.tooltip.fill_color = (100, 200, 255)
self.item_stats.text = self._format_item_stats(item)
def _on_drop(self, item, target, pos):
"""Called when item is dropped."""
self.tooltip.text = f"Placed: {item.name}"
self.tooltip.fill_color = (100, 255, 100)
self.item_stats.text = ""
# Simple transaction feedback (in full version, would handle gold)
if target == "shop":
self.status.text = f"Returned {item.name} to shop"
elif target == "inventory":
self.status.text = f"Added {item.name} to inventory"
elif target in ("weapon", "shield", "consumable"):
self.status.text = f"Equipped {item.name}"
def on_key(self, key, state):
"""Handle keyboard input."""
if state != "start":
return
if key == "Escape":
if self.manager.held_item:
self.manager.cancel_pickup()
self.tooltip.text = "Cancelled"
self.tooltip.fill_color = (200, 150, 100)
self.item_stats.text = ""
return
try:
from cookbook_main import main
main()
except:
sys.exit(0)
def activate(self):
"""Activate the demo scene."""
self.scene.on_key = self.on_key
mcrfpy.current_scene = self.scene
def main():
"""Run the demo."""
demo = ShopDemo()
demo.activate()
# Headless screenshot
try:
if mcrfpy.headless_mode():
from mcrfpy import automation
# Pick up an item for the screenshot
demo.manager._pickup_from_grid("shop", (0, 0))
demo.manager.cursor_frame.x = 450
demo.manager.cursor_frame.y = 200
mcrfpy.Timer("screenshot", lambda rt: (
automation.screenshot("screenshots/compound/shop_demo.png"),
sys.exit(0)
), 100)
except AttributeError:
pass
if __name__ == "__main__":
main()

View file

@ -0,0 +1,250 @@
#!/usr/bin/env python3
"""McRogueFace Cookbook - Interactive Demo Launcher
A comprehensive collection of reusable UI widgets and interactive demos
showcasing McRogueFace capabilities.
Controls:
Up/Down: Navigate menu
Enter: Run selected demo
ESC: Exit (or go back from demo)
"""
import mcrfpy
import sys
import os
# Ensure lib is importable
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
class CookbookLauncher:
"""Main launcher for the cookbook demos."""
DEMOS = {
"Primitives": [
("Button Widget", "primitives.demo_button"),
("Stat Bar Widget", "primitives.demo_stat_bar"),
("Choice List Widget", "primitives.demo_choice_list"),
("Text Box Widget", "primitives.demo_text_box"),
("Toast Notifications", "primitives.demo_toast"),
("Drag & Drop (Frame)", "primitives.demo_drag_drop_frame"),
("Drag & Drop (Grid)", "primitives.demo_drag_drop_grid"),
("Click to Pick Up", "primitives.demo_click_pickup"),
],
"Features": [
("Animation Chain/Group", "features.demo_animation_chain"),
("Shader Effects", "features.demo_shaders"),
("Rotation & Origin", "features.demo_rotation"),
("Alignment (TODO)", None),
],
"Mini-Apps": [
("Calculator", "apps.calculator"),
("Dialogue System", "apps.dialogue_system"),
("Day/Night Shadows (TODO)", None),
],
"Compound": [
("Shop Demo", "compound.shop_demo"),
("Inventory UI (TODO)", None),
("Character Sheet (TODO)", None),
],
}
def __init__(self):
self.scene = mcrfpy.Scene("cookbook_main")
self.ui = self.scene.children
self.selected_category = 0
self.selected_item = 0
self.categories = list(self.DEMOS.keys())
self.setup()
def setup(self):
"""Build the launcher UI."""
# Background
bg = mcrfpy.Frame(
pos=(0, 0),
size=(1024, 768),
fill_color=mcrfpy.Color(15, 15, 20)
)
self.ui.append(bg)
# Title
title = mcrfpy.Caption(
text="McRogueFace Cookbook",
pos=(512, 40),
font_size=36,
fill_color=mcrfpy.Color(255, 255, 255)
)
title.outline = 3
title.outline_color = mcrfpy.Color(0, 0, 0)
self.ui.append(title)
# Subtitle
subtitle = mcrfpy.Caption(
text="Widget Library & Interactive Demos",
pos=(512, 85),
font_size=18,
fill_color=mcrfpy.Color(150, 150, 180)
)
self.ui.append(subtitle)
# Create category panels
self.category_frames = []
self.item_labels = {}
panel_width = 220
panel_spacing = 30
start_x = (1024 - (len(self.categories) * panel_width + (len(self.categories) - 1) * panel_spacing)) // 2
for i, category in enumerate(self.categories):
x = start_x + i * (panel_width + panel_spacing)
self._create_category_panel(category, x, 130, panel_width)
# Instructions
instr = mcrfpy.Caption(
text="Arrow Keys: Navigate | Enter: Run Demo | ESC: Exit",
pos=(512, 720),
font_size=14,
fill_color=mcrfpy.Color(100, 100, 100)
)
self.ui.append(instr)
# Update display
self._update_selection()
def _create_category_panel(self, category, x, y, width):
"""Create a category panel with demo items."""
items = self.DEMOS[category]
# Calculate panel height
header_height = 40
item_height = 35
padding = 10
panel_height = header_height + len(items) * item_height + padding * 2
# Panel background
panel = mcrfpy.Frame(
pos=(x, y),
size=(width, panel_height),
fill_color=mcrfpy.Color(30, 30, 40),
outline_color=mcrfpy.Color(60, 60, 80),
outline=2
)
self.ui.append(panel)
self.category_frames.append(panel)
# Category title
cat_title = mcrfpy.Caption(
text=category,
pos=(width // 2, 12),
font_size=16,
fill_color=mcrfpy.Color(200, 200, 220)
)
panel.children.append(cat_title)
# Separator line
sep = mcrfpy.Frame(
pos=(10, 35),
size=(width - 20, 2),
fill_color=mcrfpy.Color(60, 60, 80)
)
panel.children.append(sep)
# Item list
self.item_labels[category] = []
for j, (item_name, module) in enumerate(items):
item_y = header_height + j * item_height + 5
item_label = mcrfpy.Caption(
text=item_name,
pos=(15, item_y),
font_size=13,
fill_color=mcrfpy.Color(150, 150, 150) if module else mcrfpy.Color(80, 80, 80)
)
panel.children.append(item_label)
self.item_labels[category].append((item_label, module is not None))
def _update_selection(self):
"""Update the visual selection state."""
# Update all items
for cat_idx, category in enumerate(self.categories):
# Update panel outline
panel = self.category_frames[cat_idx]
if cat_idx == self.selected_category:
panel.outline_color = mcrfpy.Color(100, 150, 255)
panel.outline = 3
else:
panel.outline_color = mcrfpy.Color(60, 60, 80)
panel.outline = 2
# Update item colors
for item_idx, (label, available) in enumerate(self.item_labels[category]):
if cat_idx == self.selected_category and item_idx == self.selected_item:
if available:
label.fill_color = mcrfpy.Color(100, 200, 255)
else:
label.fill_color = mcrfpy.Color(100, 100, 120)
else:
if available:
label.fill_color = mcrfpy.Color(180, 180, 180)
else:
label.fill_color = mcrfpy.Color(80, 80, 80)
def _run_selected_demo(self):
"""Run the currently selected demo."""
category = self.categories[self.selected_category]
items = self.DEMOS[category]
if self.selected_item < len(items):
name, module = items[self.selected_item]
if module:
try:
# Import and run the demo module
exec(f"from {module} import main; main()")
except Exception as e:
print(f"Error running demo: {e}")
import traceback
traceback.print_exc()
def on_key(self, key, state):
"""Handle keyboard input."""
if state != "start":
return
category = self.categories[self.selected_category]
items = self.DEMOS[category]
if key == "Escape":
sys.exit(0)
elif key == "Left":
self.selected_category = (self.selected_category - 1) % len(self.categories)
# Clamp item selection to new category
new_category = self.categories[self.selected_category]
self.selected_item = min(self.selected_item, len(self.DEMOS[new_category]) - 1)
self._update_selection()
elif key == "Right":
self.selected_category = (self.selected_category + 1) % len(self.categories)
new_category = self.categories[self.selected_category]
self.selected_item = min(self.selected_item, len(self.DEMOS[new_category]) - 1)
self._update_selection()
elif key == "Up":
self.selected_item = (self.selected_item - 1) % len(items)
self._update_selection()
elif key == "Down":
self.selected_item = (self.selected_item + 1) % len(items)
self._update_selection()
elif key == "Enter":
self._run_selected_demo()
def activate(self):
"""Activate the launcher scene."""
self.scene.on_key = self.on_key
mcrfpy.current_scene = self.scene
def main():
"""Launch the cookbook."""
launcher = CookbookLauncher()
launcher.activate()
if __name__ == "__main__":
main()

View file

@ -0,0 +1,9 @@
# McRogueFace Cookbook - Feature Demos
"""
Showcase demos for new and advanced features.
- demo_shaders.py - GLSL shader effects
- demo_rotation.py - Transform rotation and origin
- demo_alignment.py - 9-point alignment system
- demo_animation_chain.py - Animation Chain/Group patterns
"""

View file

@ -0,0 +1,424 @@
#!/usr/bin/env python3
"""Animation Chain/Group Demo - Complex animation orchestration
Interactive controls:
1: Run sequential chain demo
2: Run parallel group demo
3: Run callback demo
4: Run looping demo
5: Run combined demo
R: Reset all animations
ESC: Exit demo
"""
import mcrfpy
import sys
# Add parent to path for imports
sys.path.insert(0, str(__file__).rsplit('/', 2)[0])
from lib.anim_utils import (
AnimationChain, AnimationGroup, delay, callback,
fade_in, fade_out, slide_in_from_left, shake
)
class AnimationDemo:
def __init__(self):
self.scene = mcrfpy.Scene("animation_demo")
self.ui = self.scene.children
self.demo_frames = []
self.active_animations = []
self.setup()
def setup(self):
"""Build the demo scene."""
# Background
bg = mcrfpy.Frame(
pos=(0, 0),
size=(1024, 768),
fill_color=mcrfpy.Color(20, 20, 25)
)
self.ui.append(bg)
# Title
title = mcrfpy.Caption(
text="Animation Chain/Group Demo",
pos=(512, 30),
font_size=28,
fill_color=mcrfpy.Color(255, 255, 255)
)
title.outline = 2
title.outline_color = mcrfpy.Color(0, 0, 0)
self.ui.append(title)
# Create demo areas
self._create_chain_demo()
self._create_group_demo()
self._create_callback_demo()
self._create_loop_demo()
self._create_combined_demo()
# Status display
self.status = mcrfpy.Caption(
text="Press 1-5 to run demos, R to reset",
pos=(50, 700),
font_size=16,
fill_color=mcrfpy.Color(100, 200, 100)
)
self.ui.append(self.status)
# Instructions
instr = mcrfpy.Caption(
text="1: Chain | 2: Group | 3: Callback | 4: Loop | 5: Combined | R: Reset | ESC: Exit",
pos=(50, 730),
font_size=14,
fill_color=mcrfpy.Color(120, 120, 120)
)
self.ui.append(instr)
def _create_chain_demo(self):
"""Create the sequential chain demo area."""
# Label
label = mcrfpy.Caption(
text="1. Sequential Chain",
pos=(50, 80),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(label)
# Description
desc = mcrfpy.Caption(
text="Move right -> wait -> move down -> wait -> move left",
pos=(50, 100),
font_size=12,
fill_color=mcrfpy.Color(100, 100, 100)
)
self.ui.append(desc)
# Animated frame
self.chain_frame = mcrfpy.Frame(
pos=(50, 130),
size=(60, 60),
fill_color=mcrfpy.Color(100, 150, 200),
outline_color=mcrfpy.Color(150, 200, 255),
outline=2
)
self.demo_frames.append(('chain', self.chain_frame, (50, 130)))
self.ui.append(self.chain_frame)
def _create_group_demo(self):
"""Create the parallel group demo area."""
# Label
label = mcrfpy.Caption(
text="2. Parallel Group",
pos=(350, 80),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(label)
# Description
desc = mcrfpy.Caption(
text="Move + resize + change color simultaneously",
pos=(350, 100),
font_size=12,
fill_color=mcrfpy.Color(100, 100, 100)
)
self.ui.append(desc)
# Animated frame
self.group_frame = mcrfpy.Frame(
pos=(350, 130),
size=(60, 60),
fill_color=mcrfpy.Color(200, 100, 100),
outline_color=mcrfpy.Color(255, 150, 150),
outline=2
)
self.demo_frames.append(('group', self.group_frame, (350, 130)))
self.ui.append(self.group_frame)
def _create_callback_demo(self):
"""Create the callback demo area."""
# Label
label = mcrfpy.Caption(
text="3. Callbacks",
pos=(650, 80),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(label)
# Description
desc = mcrfpy.Caption(
text="Each step triggers a callback",
pos=(650, 100),
font_size=12,
fill_color=mcrfpy.Color(100, 100, 100)
)
self.ui.append(desc)
# Animated frame
self.callback_frame = mcrfpy.Frame(
pos=(650, 130),
size=(60, 60),
fill_color=mcrfpy.Color(100, 200, 100),
outline_color=mcrfpy.Color(150, 255, 150),
outline=2
)
self.demo_frames.append(('callback', self.callback_frame, (650, 130)))
self.ui.append(self.callback_frame)
# Callback counter display
self.callback_counter = mcrfpy.Caption(
text="Callbacks: 0",
pos=(720, 160),
font_size=12,
fill_color=mcrfpy.Color(100, 200, 100)
)
self.ui.append(self.callback_counter)
def _create_loop_demo(self):
"""Create the looping demo area."""
# Label
label = mcrfpy.Caption(
text="4. Looping",
pos=(50, 280),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(label)
# Description
desc = mcrfpy.Caption(
text="Continuous back-and-forth animation",
pos=(50, 300),
font_size=12,
fill_color=mcrfpy.Color(100, 100, 100)
)
self.ui.append(desc)
# Animated frame
self.loop_frame = mcrfpy.Frame(
pos=(50, 330),
size=(40, 40),
fill_color=mcrfpy.Color(200, 200, 100),
outline_color=mcrfpy.Color(255, 255, 150),
outline=2
)
self.demo_frames.append(('loop', self.loop_frame, (50, 330)))
self.ui.append(self.loop_frame)
self.loop_chain = None
def _create_combined_demo(self):
"""Create the combined demo area."""
# Label
label = mcrfpy.Caption(
text="5. Combined (Chain of Groups)",
pos=(350, 280),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(label)
# Description
desc = mcrfpy.Caption(
text="Multiple frames animating in complex patterns",
pos=(350, 300),
font_size=12,
fill_color=mcrfpy.Color(100, 100, 100)
)
self.ui.append(desc)
# Multiple animated frames
self.combined_frames = []
colors = [
mcrfpy.Color(200, 100, 150),
mcrfpy.Color(150, 100, 200),
mcrfpy.Color(100, 150, 200),
]
for i, color in enumerate(colors):
frame = mcrfpy.Frame(
pos=(350 + i * 70, 330),
size=(50, 50),
fill_color=color,
outline=1
)
self.combined_frames.append(frame)
self.demo_frames.append(('combined', frame, (350 + i * 70, 330)))
self.ui.append(frame)
def run_chain_demo(self):
"""Run the sequential chain demo."""
self.status.text = "Running: Sequential Chain"
chain = AnimationChain(
(self.chain_frame, "x", 200, 0.5),
delay(0.3),
(self.chain_frame, "y", 200, 0.5),
delay(0.3),
(self.chain_frame, "x", 50, 0.5),
callback=lambda: setattr(self.status, 'text', 'Chain complete!')
)
chain.start()
self.active_animations.append(chain)
def run_group_demo(self):
"""Run the parallel group demo."""
self.status.text = "Running: Parallel Group"
group = AnimationGroup(
(self.group_frame, "x", 500, 1.0),
(self.group_frame, "w", 100, 1.0),
(self.group_frame, "h", 100, 1.0),
callback=lambda: setattr(self.status, 'text', 'Group complete!')
)
group.start()
self.active_animations.append(group)
def run_callback_demo(self):
"""Run the callback demo."""
self.status.text = "Running: Callbacks"
self.callback_count = 0
def increment_counter():
self.callback_count += 1
self.callback_counter.text = f"Callbacks: {self.callback_count}"
chain = AnimationChain(
callback(increment_counter),
(self.callback_frame, "x", 750, 0.3),
callback(increment_counter),
delay(0.2),
callback(increment_counter),
(self.callback_frame, "y", 200, 0.3),
callback(increment_counter),
(self.callback_frame, "x", 650, 0.3),
callback(increment_counter),
callback=lambda: setattr(self.status, 'text', f'Callback demo complete! ({self.callback_count} callbacks)')
)
chain.start()
self.active_animations.append(chain)
def run_loop_demo(self):
"""Run the looping demo."""
self.status.text = "Running: Looping (press R to stop)"
# Stop any existing loop
if self.loop_chain:
self.loop_chain.stop()
self.loop_chain = AnimationChain(
(self.loop_frame, "x", 250, 1.0),
(self.loop_frame, "x", 50, 1.0),
loop=True
)
self.loop_chain.start()
self.active_animations.append(self.loop_chain)
def run_combined_demo(self):
"""Run the combined demo with chain of groups."""
self.status.text = "Running: Combined"
# First, all frames slide down together
# Then, each one bounces back up sequentially
frame1, frame2, frame3 = self.combined_frames
chain = AnimationChain(
# Phase 1: All move down together (as a group effect via separate chains)
(frame1, "y", 450, 0.5),
delay(0.1),
(frame2, "y", 450, 0.5),
delay(0.1),
(frame3, "y", 450, 0.5),
delay(0.5),
# Phase 2: Spread out horizontally
(frame1, "x", 320, 0.3),
(frame3, "x", 520, 0.3),
delay(0.5),
# Phase 3: All return home
(frame1, "x", 350, 0.3),
(frame1, "y", 330, 0.3),
delay(0.1),
(frame2, "y", 330, 0.3),
delay(0.1),
(frame3, "x", 490, 0.3),
(frame3, "y", 330, 0.3),
callback=lambda: setattr(self.status, 'text', 'Combined demo complete!')
)
chain.start()
self.active_animations.append(chain)
def reset_all(self):
"""Reset all animations to initial state."""
# Stop all active animations
for anim in self.active_animations:
if hasattr(anim, 'stop'):
anim.stop()
self.active_animations.clear()
if self.loop_chain:
self.loop_chain.stop()
self.loop_chain = None
# Reset positions
for name, frame, (orig_x, orig_y) in self.demo_frames:
frame.x = orig_x
frame.y = orig_y
if name == 'group':
frame.w = 60
frame.h = 60
# Reset callback counter
self.callback_count = 0
self.callback_counter.text = "Callbacks: 0"
self.status.text = "All animations reset"
def on_key(self, key, state):
"""Handle keyboard input."""
if state != "start":
return
if key == "Escape":
sys.exit(0)
elif key == "Num1":
self.run_chain_demo()
elif key == "Num2":
self.run_group_demo()
elif key == "Num3":
self.run_callback_demo()
elif key == "Num4":
self.run_loop_demo()
elif key == "Num5":
self.run_combined_demo()
elif key == "R":
self.reset_all()
def activate(self):
"""Activate the demo scene."""
self.scene.on_key = self.on_key
mcrfpy.current_scene = self.scene
def main():
"""Run the animation demo."""
demo = AnimationDemo()
demo.activate()
# Headless mode: capture screenshot and exit
try:
if mcrfpy.headless_mode():
from mcrfpy import automation
# Run a quick demo then screenshot
demo.run_chain_demo()
mcrfpy.Timer("screenshot", lambda rt: (
automation.screenshot("screenshots/features/animation_chain_demo.png"),
sys.exit(0)
), 500)
except AttributeError:
pass
if __name__ == "__main__":
main()

View file

@ -0,0 +1,454 @@
#!/usr/bin/env python3
"""Rotation Demo - Transform rotation and origin points
Interactive controls:
Left/Right: Rotate selected element
Up/Down: Adjust rotation speed
1-4: Select element to rotate
O: Cycle origin point
R: Reset all rotations
A: Toggle auto-rotation
ESC: Exit demo
Rotation properties:
- rotation: Angle in degrees
- origin: (x, y) rotation center point
- rotate_with_camera: For Grid entities
"""
import mcrfpy
import sys
class RotationDemo:
def __init__(self):
self.scene = mcrfpy.Scene("rotation_demo")
self.ui = self.scene.children
self.elements = []
self.selected = 0
self.auto_rotate = False
self.rotation_speed = 45 # degrees per second
self.origin_index = 0
self.setup()
def setup(self):
"""Build the demo scene."""
# Background
bg = mcrfpy.Frame(
pos=(0, 0),
size=(1024, 768),
fill_color=mcrfpy.Color(15, 15, 20)
)
self.ui.append(bg)
# Title
title = mcrfpy.Caption(
text="Rotation Demo",
pos=(512, 30),
font_size=28,
fill_color=mcrfpy.Color(255, 255, 255)
)
title.outline = 2
title.outline_color = mcrfpy.Color(0, 0, 0)
self.ui.append(title)
# Create rotatable elements
self._create_frame_demo(100, 120)
self._create_sprite_demo(400, 120)
self._create_caption_demo(700, 120)
self._create_origin_demo(250, 450)
# Control panel
self._create_control_panel()
# Instructions
instr = mcrfpy.Caption(
text="Left/Right: Rotate | 1-4: Select | O: Cycle origin | A: Auto-rotate | R: Reset | ESC: Exit",
pos=(50, 730),
font_size=14,
fill_color=mcrfpy.Color(120, 120, 120)
)
self.ui.append(instr)
# Start update timer for auto-rotation
mcrfpy.Timer("rotation_update", self._update, 16) # ~60fps
def _create_frame_demo(self, x, y):
"""Create rotating frame demo."""
# Label
label = mcrfpy.Caption(
text="1. Frame Rotation",
pos=(x + 100, y - 20),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(label)
# Rotatable frame
frame = mcrfpy.Frame(
pos=(x, y),
size=(200, 200),
fill_color=mcrfpy.Color(80, 100, 140),
outline_color=mcrfpy.Color(120, 150, 200),
outline=3
)
# Add child content
inner = mcrfpy.Frame(
pos=(50, 50),
size=(100, 100),
fill_color=mcrfpy.Color(100, 120, 160),
outline=1
)
frame.children.append(inner)
content = mcrfpy.Caption(
text="Rotates!",
pos=(100, 85),
font_size=14,
fill_color=mcrfpy.Color(255, 255, 255)
)
frame.children.append(content)
self.ui.append(frame)
self.elements.append(('Frame', frame))
# Rotation indicator
angle_label = mcrfpy.Caption(
text="0.0°",
pos=(x + 100, y + 220),
font_size=14,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(angle_label)
self.frame_angle_label = angle_label
def _create_sprite_demo(self, x, y):
"""Create rotating sprite demo (using Frame as placeholder)."""
# Label
label = mcrfpy.Caption(
text="2. Sprite Rotation",
pos=(x + 100, y - 20),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(label)
# Use a frame to simulate sprite (actual sprite would need texture)
sprite_frame = mcrfpy.Frame(
pos=(x, y),
size=(200, 200),
fill_color=mcrfpy.Color(100, 140, 80),
outline_color=mcrfpy.Color(150, 200, 120),
outline=3
)
# Arrow pattern to show rotation direction
arrow_v = mcrfpy.Caption(
text="^",
pos=(100, 40),
font_size=48,
fill_color=mcrfpy.Color(255, 255, 255)
)
sprite_frame.children.append(arrow_v)
sprite_label = mcrfpy.Caption(
text="UP",
pos=(100, 100),
font_size=24,
fill_color=mcrfpy.Color(255, 255, 255)
)
sprite_frame.children.append(sprite_label)
self.ui.append(sprite_frame)
self.elements.append(('Sprite', sprite_frame))
angle_label = mcrfpy.Caption(
text="0.0°",
pos=(x + 100, y + 220),
font_size=14,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(angle_label)
self.sprite_angle_label = angle_label
def _create_caption_demo(self, x, y):
"""Create rotating caption demo."""
# Label
label = mcrfpy.Caption(
text="3. Caption Rotation",
pos=(x + 100, y - 20),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(label)
# Background for visibility
caption_bg = mcrfpy.Frame(
pos=(x, y),
size=(200, 200),
fill_color=mcrfpy.Color(40, 40, 50),
outline_color=mcrfpy.Color(80, 80, 100),
outline=1
)
self.ui.append(caption_bg)
# Rotatable caption
caption = mcrfpy.Caption(
text="Rotating Text!",
pos=(x + 100, y + 100),
font_size=24,
fill_color=mcrfpy.Color(255, 200, 100)
)
caption.outline = 2
caption.outline_color = mcrfpy.Color(0, 0, 0)
self.ui.append(caption)
self.elements.append(('Caption', caption))
angle_label = mcrfpy.Caption(
text="0.0°",
pos=(x + 100, y + 220),
font_size=14,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(angle_label)
self.caption_angle_label = angle_label
def _create_origin_demo(self, x, y):
"""Create demo showing different origin points."""
# Label
label = mcrfpy.Caption(
text="4. Origin Point Demo (press O to cycle)",
pos=(x + 200, y - 20),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(label)
# Large frame to show origin effects
origin_frame = mcrfpy.Frame(
pos=(x, y),
size=(400, 200),
fill_color=mcrfpy.Color(140, 80, 100),
outline_color=mcrfpy.Color(200, 120, 150),
outline=3
)
# Origin marker
origin_marker = mcrfpy.Frame(
pos=(0, 0),
size=(10, 10),
fill_color=mcrfpy.Color(255, 255, 0),
outline=0
)
origin_frame.children.append(origin_marker)
self.origin_marker = origin_marker
# Origin name
origin_label = mcrfpy.Caption(
text="Origin: Top-Left",
pos=(200, 85),
font_size=16,
fill_color=mcrfpy.Color(255, 255, 255)
)
origin_frame.children.append(origin_label)
self.origin_name_label = origin_label
self.ui.append(origin_frame)
self.elements.append(('Origin Demo', origin_frame))
self.origin_frame = origin_frame
# Origin positions to cycle through
self.origins = [
("Top-Left", (0, 0)),
("Top-Center", (200, 0)),
("Top-Right", (400, 0)),
("Center-Left", (0, 100)),
("Center", (200, 100)),
("Center-Right", (400, 100)),
("Bottom-Left", (0, 200)),
("Bottom-Center", (200, 200)),
("Bottom-Right", (400, 200)),
]
angle_label = mcrfpy.Caption(
text="0.0°",
pos=(x + 200, y + 220),
font_size=14,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(angle_label)
self.origin_angle_label = angle_label
def _create_control_panel(self):
"""Create control panel showing current state."""
panel = mcrfpy.Frame(
pos=(750, 450),
size=(220, 180),
fill_color=mcrfpy.Color(30, 30, 40),
outline_color=mcrfpy.Color(60, 60, 80),
outline=1
)
self.ui.append(panel)
panel_title = mcrfpy.Caption(
text="Status",
pos=(110, 10),
font_size=16,
fill_color=mcrfpy.Color(200, 200, 200)
)
panel.children.append(panel_title)
self.selected_label = mcrfpy.Caption(
text="Selected: Frame",
pos=(15, 40),
font_size=14,
fill_color=mcrfpy.Color(150, 150, 150)
)
panel.children.append(self.selected_label)
self.speed_label = mcrfpy.Caption(
text="Speed: 45°/sec",
pos=(15, 65),
font_size=14,
fill_color=mcrfpy.Color(150, 150, 150)
)
panel.children.append(self.speed_label)
self.auto_label = mcrfpy.Caption(
text="Auto-rotate: Off",
pos=(15, 90),
font_size=14,
fill_color=mcrfpy.Color(200, 100, 100)
)
panel.children.append(self.auto_label)
def _update(self, runtime):
"""Update loop for auto-rotation."""
if self.auto_rotate:
dt = 0.016 # Approximately 16ms per frame
for name, element in self.elements:
try:
element.rotation = (element.rotation + self.rotation_speed * dt) % 360
except AttributeError:
pass
# Update angle labels
self._update_angle_labels()
def _update_angle_labels(self):
"""Update the angle display labels."""
labels = [self.frame_angle_label, self.sprite_angle_label,
self.caption_angle_label, self.origin_angle_label]
for i, (name, element) in enumerate(self.elements):
if i < len(labels):
try:
labels[i].text = f"{element.rotation:.1f}°"
except AttributeError:
labels[i].text = "N/A"
def _cycle_origin(self):
"""Cycle to the next origin point."""
self.origin_index = (self.origin_index + 1) % len(self.origins)
name, pos = self.origins[self.origin_index]
# Update origin
try:
self.origin_frame.origin = pos
self.origin_name_label.text = f"Origin: {name}"
# Move marker to show origin position
self.origin_marker.x = pos[0] - 5
self.origin_marker.y = pos[1] - 5
except AttributeError:
pass
def on_key(self, key, state):
"""Handle keyboard input."""
if state != "start":
return
if key == "Escape":
sys.exit(0)
elif key == "Left":
# Rotate left (counter-clockwise)
name, element = self.elements[self.selected]
try:
element.rotation = (element.rotation - 15) % 360
except AttributeError:
pass
elif key == "Right":
# Rotate right (clockwise)
name, element = self.elements[self.selected]
try:
element.rotation = (element.rotation + 15) % 360
except AttributeError:
pass
elif key == "Up":
self.rotation_speed = min(180, self.rotation_speed + 15)
self.speed_label.text = f"Speed: {self.rotation_speed}°/sec"
elif key == "Down":
self.rotation_speed = max(15, self.rotation_speed - 15)
self.speed_label.text = f"Speed: {self.rotation_speed}°/sec"
elif key in ("Num1", "Num2", "Num3", "Num4"):
self.selected = int(key[-1]) - 1
if self.selected < len(self.elements):
self.selected_label.text = f"Selected: {self.elements[self.selected][0]}"
elif key == "O":
self._cycle_origin()
elif key == "A":
self.auto_rotate = not self.auto_rotate
if self.auto_rotate:
self.auto_label.text = "Auto-rotate: On"
self.auto_label.fill_color = mcrfpy.Color(100, 200, 100)
else:
self.auto_label.text = "Auto-rotate: Off"
self.auto_label.fill_color = mcrfpy.Color(200, 100, 100)
elif key == "R":
# Reset all rotations
for name, element in self.elements:
try:
element.rotation = 0
except AttributeError:
pass
self.origin_index = 0
name, pos = self.origins[0]
try:
self.origin_frame.origin = pos
self.origin_name_label.text = f"Origin: {name}"
self.origin_marker.x = pos[0] - 5
self.origin_marker.y = pos[1] - 5
except AttributeError:
pass
def activate(self):
"""Activate the demo scene."""
self.scene.on_key = self.on_key
mcrfpy.current_scene = self.scene
def main():
"""Run the rotation demo."""
demo = RotationDemo()
demo.activate()
# Headless mode: capture screenshot and exit
try:
if mcrfpy.headless_mode():
from mcrfpy import automation
# Rotate elements for visual interest
for name, element in demo.elements:
try:
element.rotation = 15
except AttributeError:
pass
mcrfpy.Timer("screenshot", lambda rt: (
automation.screenshot("screenshots/features/rotation_demo.png"),
sys.exit(0)
), 200)
except AttributeError:
pass
if __name__ == "__main__":
main()

View file

@ -0,0 +1,340 @@
#!/usr/bin/env python3
"""Shader Effects Demo - GLSL shader visual effects
Interactive controls:
1-4: Focus on specific shader
Space: Toggle shaders on/off
R: Reset all shaders
ESC: Exit demo
Shader uniforms available:
- float time: Seconds since engine start
- float delta_time: Seconds since last frame
- vec2 resolution: Texture size in pixels
- vec2 mouse: Mouse position in window coordinates
"""
import mcrfpy
import sys
class ShaderDemo:
def __init__(self):
self.scene = mcrfpy.Scene("shader_demo")
self.ui = self.scene.children
self.shaders_enabled = True
self.shader_frames = []
self.shaders = []
self.setup()
def setup(self):
"""Build the demo scene."""
# Background
bg = mcrfpy.Frame(
pos=(0, 0),
size=(1024, 768),
fill_color=mcrfpy.Color(15, 15, 20)
)
self.ui.append(bg)
# Title
title = mcrfpy.Caption(
text="Shader Effects Demo",
pos=(512, 30),
font_size=28,
fill_color=mcrfpy.Color(255, 255, 255)
)
title.outline = 2
title.outline_color = mcrfpy.Color(0, 0, 0)
self.ui.append(title)
# Create four shader demo quadrants
self._create_pulse_shader(50, 100)
self._create_vignette_shader(530, 100)
self._create_wave_shader(50, 420)
self._create_color_shift_shader(530, 420)
# Instructions
instr = mcrfpy.Caption(
text="1-4: Focus shader | Space: Toggle on/off | R: Reset | ESC: Exit",
pos=(50, 730),
font_size=14,
fill_color=mcrfpy.Color(120, 120, 120)
)
self.ui.append(instr)
# Status
self.status = mcrfpy.Caption(
text="Shaders: Enabled",
pos=(50, 700),
font_size=16,
fill_color=mcrfpy.Color(100, 200, 100)
)
self.ui.append(self.status)
def _create_pulse_shader(self, x, y):
"""Create pulsing glow shader demo."""
# Label
label = mcrfpy.Caption(
text="1. Pulse Glow",
pos=(x + 200, y - 20),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(label)
# Frame to apply shader to
frame = mcrfpy.Frame(
pos=(x, y),
size=(420, 280),
fill_color=mcrfpy.Color(80, 60, 120),
outline_color=mcrfpy.Color(150, 100, 200),
outline=2
)
# Add some content
content = mcrfpy.Caption(
text="Brightness pulses\nusing time uniform",
pos=(210, 100),
font_size=18,
fill_color=mcrfpy.Color(255, 255, 255)
)
frame.children.append(content)
# Create pulse shader
pulse_shader = mcrfpy.Shader('''
uniform sampler2D texture;
uniform float time;
void main() {
vec2 uv = gl_TexCoord[0].xy;
vec4 color = texture2D(texture, uv);
// Pulse brightness
float pulse = 0.7 + 0.3 * sin(time * 2.0);
color.rgb *= pulse;
gl_FragColor = color * gl_Color;
}
''', dynamic=True)
frame.shader = pulse_shader
self.ui.append(frame)
self.shader_frames.append(frame)
self.shaders.append(pulse_shader)
def _create_vignette_shader(self, x, y):
"""Create vignette (darkened edges) shader demo."""
# Label
label = mcrfpy.Caption(
text="2. Vignette",
pos=(x + 200, y - 20),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(label)
# Frame
frame = mcrfpy.Frame(
pos=(x, y),
size=(420, 280),
fill_color=mcrfpy.Color(100, 80, 60),
outline_color=mcrfpy.Color(200, 150, 100),
outline=2
)
content = mcrfpy.Caption(
text="Darkened edges\nclassic vignette effect",
pos=(210, 100),
font_size=18,
fill_color=mcrfpy.Color(255, 255, 255)
)
frame.children.append(content)
# Create vignette shader
vignette_shader = mcrfpy.Shader('''
uniform sampler2D texture;
uniform vec2 resolution;
void main() {
vec2 uv = gl_TexCoord[0].xy;
vec4 color = texture2D(texture, uv);
// Calculate distance from center
vec2 center = vec2(0.5, 0.5);
float dist = distance(uv, center);
// Vignette effect - darken edges
float vignette = 1.0 - smoothstep(0.3, 0.8, dist);
color.rgb *= vignette;
gl_FragColor = color * gl_Color;
}
''', dynamic=False)
frame.shader = vignette_shader
self.ui.append(frame)
self.shader_frames.append(frame)
self.shaders.append(vignette_shader)
def _create_wave_shader(self, x, y):
"""Create wave distortion shader demo."""
# Label
label = mcrfpy.Caption(
text="3. Wave Distortion",
pos=(x + 200, y - 20),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(label)
# Frame
frame = mcrfpy.Frame(
pos=(x, y),
size=(420, 280),
fill_color=mcrfpy.Color(60, 100, 120),
outline_color=mcrfpy.Color(100, 180, 220),
outline=2
)
content = mcrfpy.Caption(
text="Sine wave\nUV distortion",
pos=(210, 100),
font_size=18,
fill_color=mcrfpy.Color(255, 255, 255)
)
frame.children.append(content)
# Create wave shader
wave_shader = mcrfpy.Shader('''
uniform sampler2D texture;
uniform float time;
void main() {
vec2 uv = gl_TexCoord[0].xy;
// Apply wave distortion
float wave = sin(uv.y * 20.0 + time * 3.0) * 0.01;
uv.x += wave;
vec4 color = texture2D(texture, uv);
gl_FragColor = color * gl_Color;
}
''', dynamic=True)
frame.shader = wave_shader
self.ui.append(frame)
self.shader_frames.append(frame)
self.shaders.append(wave_shader)
def _create_color_shift_shader(self, x, y):
"""Create chromatic aberration / color shift shader demo."""
# Label
label = mcrfpy.Caption(
text="4. Color Shift",
pos=(x + 200, y - 20),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(label)
# Frame
frame = mcrfpy.Frame(
pos=(x, y),
size=(420, 280),
fill_color=mcrfpy.Color(80, 80, 80),
outline_color=mcrfpy.Color(150, 150, 150),
outline=2
)
content = mcrfpy.Caption(
text="Chromatic aberration\nRGB channel offset",
pos=(210, 100),
font_size=18,
fill_color=mcrfpy.Color(255, 255, 255)
)
frame.children.append(content)
# Create color shift shader
colorshift_shader = mcrfpy.Shader('''
uniform sampler2D texture;
uniform float time;
void main() {
vec2 uv = gl_TexCoord[0].xy;
// Offset for each channel
float offset = 0.005 + 0.003 * sin(time);
// Sample each channel with slight offset
float r = texture2D(texture, uv + vec2(offset, 0.0)).r;
float g = texture2D(texture, uv).g;
float b = texture2D(texture, uv - vec2(offset, 0.0)).b;
float a = texture2D(texture, uv).a;
gl_FragColor = vec4(r, g, b, a) * gl_Color;
}
''', dynamic=True)
frame.shader = colorshift_shader
self.ui.append(frame)
self.shader_frames.append(frame)
self.shaders.append(colorshift_shader)
def toggle_shaders(self):
"""Toggle all shaders on/off."""
self.shaders_enabled = not self.shaders_enabled
for frame, shader in zip(self.shader_frames, self.shaders):
if self.shaders_enabled:
frame.shader = shader
self.status.text = "Shaders: Enabled"
self.status.fill_color = mcrfpy.Color(100, 200, 100)
else:
frame.shader = None
self.status.text = "Shaders: Disabled"
self.status.fill_color = mcrfpy.Color(200, 100, 100)
def on_key(self, key, state):
"""Handle keyboard input."""
if state != "start":
return
if key == "Escape":
sys.exit(0)
elif key == "Space":
self.toggle_shaders()
elif key == "R":
# Re-enable all shaders
self.shaders_enabled = False
self.toggle_shaders()
elif key in ("Num1", "Num2", "Num3", "Num4"):
# Focus on specific shader (could zoom in)
idx = int(key[-1]) - 1
if idx < len(self.shader_frames):
self.status.text = f"Focused: Shader {idx + 1}"
def activate(self):
"""Activate the demo scene."""
self.scene.on_key = self.on_key
mcrfpy.current_scene = self.scene
def main():
"""Run the shader demo."""
demo = ShaderDemo()
demo.activate()
# Headless mode: capture screenshot and exit
try:
if mcrfpy.headless_mode():
from mcrfpy import automation
mcrfpy.Timer("screenshot", lambda rt: (
automation.screenshot("screenshots/features/shader_demo.png"),
sys.exit(0)
), 200)
except AttributeError:
pass
if __name__ == "__main__":
main()

View file

@ -0,0 +1,81 @@
# McRogueFace Cookbook - Standard Widget Library
"""
Reusable UI widget patterns for game development.
Widgets:
Button - Clickable button with hover/press states
StatBar - Horizontal bar showing current/max value (HP, mana, XP)
ChoiceList - Vertical list of selectable text options
ScrollableList - Scrolling list with arbitrary item rendering
TextBox - Word-wrapped text with typewriter effect
DialogueBox - TextBox with speaker name display
Modal - Overlay popup that blocks background input
ConfirmModal - Pre-configured yes/no modal
AlertModal - Pre-configured OK modal
ToastManager - Auto-dismissing notification popups
GridContainer - NxM clickable cells for inventory/slot systems
Utilities:
AnimationChain - Sequential animation execution
AnimationGroup - Parallel animation execution
delay - Create a delay step for animation chains
callback - Create a callback step for animation chains
fade_in, fade_out - Opacity animations
slide_in_from_* - Slide-in animations
shake - Shake effect animation
"""
from .button import Button, create_button_row, create_button_column
from .stat_bar import StatBar, create_stat_bar_group
from .choice_list import ChoiceList, create_menu
from .text_box import TextBox, DialogueBox
from .scrollable_list import ScrollableList
from .modal import Modal, ConfirmModal, AlertModal
from .toast import Toast, ToastManager
from .grid_container import GridContainer
from .anim_utils import (
AnimationChain, AnimationGroup, AnimationSequence,
PropertyAnimation, DelayStep, CallbackStep,
delay, callback,
fade_in, fade_out,
slide_in_from_left, slide_in_from_right,
slide_in_from_top, slide_in_from_bottom,
pulse, shake
)
__all__ = [
# Widgets
'Button',
'create_button_row',
'create_button_column',
'StatBar',
'create_stat_bar_group',
'ChoiceList',
'create_menu',
'TextBox',
'DialogueBox',
'ScrollableList',
'Modal',
'ConfirmModal',
'AlertModal',
'Toast',
'ToastManager',
'GridContainer',
# Animation utilities
'AnimationChain',
'AnimationGroup',
'AnimationSequence',
'PropertyAnimation',
'DelayStep',
'CallbackStep',
'delay',
'callback',
'fade_in',
'fade_out',
'slide_in_from_left',
'slide_in_from_right',
'slide_in_from_top',
'slide_in_from_bottom',
'pulse',
'shake',
]

View file

@ -0,0 +1,387 @@
# McRogueFace Cookbook - Animation Utilities
"""
Utilities for complex animation orchestration.
Example:
from lib.anim_utils import AnimationChain, AnimationGroup, delay
# Sequential animations
chain = AnimationChain(
(frame, "x", 200, 0.5),
delay(0.2),
(frame, "y", 300, 0.5),
callback=on_complete
)
chain.start()
# Parallel animations
group = AnimationGroup(
(frame, "x", 200, 0.5),
(frame, "opacity", 0.5, 0.5),
callback=on_complete
)
group.start()
"""
import mcrfpy
class AnimationStep:
"""Base class for animation steps."""
def start(self, callback):
"""Start the step, call callback when done."""
raise NotImplementedError
class PropertyAnimation(AnimationStep):
"""Single property animation step.
Args:
target: The UI element to animate
property: Property name (e.g., "x", "y", "opacity")
value: Target value
duration: Duration in seconds
easing: Easing function (default: EASE_OUT)
"""
def __init__(self, target, property_name, value, duration, easing=None):
self.target = target
self.property_name = property_name
self.value = value
self.duration = duration
self.easing = easing or mcrfpy.Easing.EASE_OUT
self._timer_name = None
def start(self, callback=None):
"""Start the animation."""
# Start the animation on the target
self.target.animate(self.property_name, self.value, self.duration, self.easing)
# Schedule callback after duration
if callback:
self._timer_name = f"anim_step_{id(self)}"
mcrfpy.Timer(self._timer_name, lambda rt: callback(), int(self.duration * 1000))
class DelayStep(AnimationStep):
"""Delay step - just waits for a duration."""
def __init__(self, duration):
self.duration = duration
self._timer_name = None
def start(self, callback=None):
"""Wait for duration then call callback."""
if callback:
self._timer_name = f"delay_step_{id(self)}"
mcrfpy.Timer(self._timer_name, lambda rt: callback(), int(self.duration * 1000))
# If no callback, this is a no-op (shouldn't happen in a chain)
class CallbackStep(AnimationStep):
"""Step that calls a function."""
def __init__(self, func):
self.func = func
def start(self, callback=None):
"""Call the function immediately, then callback."""
self.func()
if callback:
callback()
def delay(duration):
"""Create a delay step.
Args:
duration: Delay in seconds
Returns:
DelayStep object
"""
return DelayStep(duration)
def callback(func):
"""Create a callback step.
Args:
func: Function to call (no arguments)
Returns:
CallbackStep object
"""
return CallbackStep(func)
def _parse_animation_spec(spec):
"""Parse an animation specification.
Args:
spec: Either an AnimationStep, or a tuple of (target, property, value, duration[, easing])
Returns:
AnimationStep object
"""
if isinstance(spec, AnimationStep):
return spec
if isinstance(spec, tuple):
if len(spec) == 4:
target, prop, value, duration = spec
return PropertyAnimation(target, prop, value, duration)
elif len(spec) == 5:
target, prop, value, duration, easing = spec
return PropertyAnimation(target, prop, value, duration, easing)
raise ValueError(f"Invalid animation spec: {spec}")
class AnimationChain:
"""Sequential animation execution.
Runs animations one after another. Each step must complete before
the next one starts.
Args:
*steps: Animation steps - either AnimationStep objects or tuples of
(target, property, value, duration[, easing])
callback: Optional function to call when chain completes
loop: If True, restart chain when it completes
Example:
chain = AnimationChain(
(frame, "x", 200, 0.5),
delay(0.2),
(frame, "y", 300, 0.5),
callback=lambda: print("Done!")
)
chain.start()
"""
def __init__(self, *steps, callback=None, loop=False):
self.steps = [_parse_animation_spec(s) for s in steps]
self.callback = callback
self.loop = loop
self._current_index = 0
self._running = False
def start(self):
"""Start the animation chain."""
self._current_index = 0
self._running = True
self._run_next()
def stop(self):
"""Stop the animation chain."""
self._running = False
def _run_next(self):
"""Run the next step in the chain."""
if not self._running:
return
if self._current_index >= len(self.steps):
# Chain complete
if self.callback:
self.callback()
if self.loop:
self._current_index = 0
self._run_next()
else:
self._running = False
return
# Run current step
step = self.steps[self._current_index]
self._current_index += 1
step.start(callback=self._run_next)
class AnimationGroup:
"""Parallel animation execution.
Runs all animations simultaneously. The group completes when
all animations have finished.
Args:
*steps: Animation steps - either AnimationStep objects or tuples of
(target, property, value, duration[, easing])
callback: Optional function to call when all animations complete
Example:
group = AnimationGroup(
(frame, "x", 200, 0.5),
(frame, "opacity", 0.5, 0.3),
callback=lambda: print("All done!")
)
group.start()
"""
def __init__(self, *steps, callback=None):
self.steps = [_parse_animation_spec(s) for s in steps]
self.callback = callback
self._pending = 0
self._running = False
def start(self):
"""Start all animations in parallel."""
if not self.steps:
if self.callback:
self.callback()
return
self._running = True
self._pending = len(self.steps)
for step in self.steps:
step.start(callback=self._on_step_complete)
def stop(self):
"""Stop tracking the group (note: individual animations continue)."""
self._running = False
def _on_step_complete(self):
"""Called when each step completes."""
if not self._running:
return
self._pending -= 1
if self._pending <= 0:
self._running = False
if self.callback:
self.callback()
class AnimationSequence:
"""More complex animation sequencing with named steps and branching.
Args:
steps: Dict mapping step names to animation specs or step lists
start_step: Name of the first step to run
callback: Optional function to call when sequence ends
"""
def __init__(self, steps=None, start_step="start", callback=None):
self.steps = steps or {}
self.start_step = start_step
self.callback = callback
self._current_step = None
self._running = False
def add_step(self, name, *animations, next_step=None):
"""Add a named step.
Args:
name: Step name
*animations: Animation specs for this step
next_step: Name of next step (or None to end)
"""
self.steps[name] = {
"animations": [_parse_animation_spec(a) for a in animations],
"next": next_step
}
def start(self, step_name=None):
"""Start the sequence from a given step."""
self._running = True
self._run_step(step_name or self.start_step)
def stop(self):
"""Stop the sequence."""
self._running = False
def _run_step(self, name):
"""Run a named step."""
if not self._running or name not in self.steps:
if name is None and self.callback:
self.callback()
return
self._current_step = name
step_data = self.steps[name]
animations = step_data.get("animations", [])
next_step = step_data.get("next")
if not animations:
# No animations, go directly to next
self._run_step(next_step)
return
# Run animations in parallel, then proceed to next step
group = AnimationGroup(
*animations,
callback=lambda: self._run_step(next_step)
)
group.start()
# Convenience functions for common patterns
def fade_in(target, duration=0.3, easing=None):
"""Create a fade-in animation (opacity 0 to 1)."""
target.opacity = 0
return PropertyAnimation(target, "opacity", 1.0, duration, easing)
def fade_out(target, duration=0.3, easing=None):
"""Create a fade-out animation (current opacity to 0)."""
return PropertyAnimation(target, "opacity", 0.0, duration, easing)
def slide_in_from_left(target, distance=100, duration=0.3, easing=None):
"""Create a slide-in from left animation."""
original_x = target.x
target.x = original_x - distance
return PropertyAnimation(target, "x", original_x, duration, easing or mcrfpy.Easing.EASE_OUT)
def slide_in_from_right(target, distance=100, duration=0.3, easing=None):
"""Create a slide-in from right animation."""
original_x = target.x
target.x = original_x + distance
return PropertyAnimation(target, "x", original_x, duration, easing or mcrfpy.Easing.EASE_OUT)
def slide_in_from_top(target, distance=100, duration=0.3, easing=None):
"""Create a slide-in from top animation."""
original_y = target.y
target.y = original_y - distance
return PropertyAnimation(target, "y", original_y, duration, easing or mcrfpy.Easing.EASE_OUT)
def slide_in_from_bottom(target, distance=100, duration=0.3, easing=None):
"""Create a slide-in from bottom animation."""
original_y = target.y
target.y = original_y + distance
return PropertyAnimation(target, "y", original_y, duration, easing or mcrfpy.Easing.EASE_OUT)
def pulse(target, scale_factor=1.1, duration=0.2):
"""Create a pulse animation (scale up then back)."""
# Note: This requires scale animation support
# If not available, we approximate with position
original_x = target.x
original_y = target.y
offset = (scale_factor - 1) * 10 # Approximate offset
return AnimationChain(
(target, "x", original_x - offset, duration / 2),
(target, "x", original_x, duration / 2),
)
def shake(target, intensity=5, duration=0.3):
"""Create a shake animation."""
original_x = target.x
step_duration = duration / 6
return AnimationChain(
(target, "x", original_x - intensity, step_duration),
(target, "x", original_x + intensity, step_duration),
(target, "x", original_x - intensity * 0.5, step_duration),
(target, "x", original_x + intensity * 0.5, step_duration),
(target, "x", original_x - intensity * 0.25, step_duration),
(target, "x", original_x, step_duration),
)

View file

@ -0,0 +1,241 @@
# McRogueFace Cookbook - Button Widget
"""
Clickable button with hover/press states and visual feedback.
Example:
from lib.button import Button
def on_click():
print("Button clicked!")
btn = Button("Start Game", pos=(100, 100), callback=on_click)
scene.children.append(btn.frame)
"""
import mcrfpy
class Button:
"""Clickable button with hover/press states.
Args:
text: Button label text
pos: (x, y) position tuple
size: (width, height) tuple, default (120, 40)
callback: Function to call on click (no arguments)
fill_color: Background color (default: dark gray)
hover_color: Color when mouse hovers (default: lighter gray)
press_color: Color when pressed (default: even lighter)
text_color: Label color (default: white)
outline_color: Border color (default: white)
outline: Border thickness (default: 2)
font_size: Text size (default: 16)
enabled: Whether button is clickable (default: True)
Attributes:
frame: The underlying mcrfpy.Frame (add this to scene)
label: The mcrfpy.Caption for the text
is_hovered: True if mouse is over button
is_pressed: True if button is being pressed
enabled: Whether button responds to clicks
"""
# Default colors
DEFAULT_FILL = mcrfpy.Color(60, 60, 70)
DEFAULT_HOVER = mcrfpy.Color(80, 80, 95)
DEFAULT_PRESS = mcrfpy.Color(100, 100, 120)
DEFAULT_DISABLED = mcrfpy.Color(40, 40, 45)
DEFAULT_TEXT = mcrfpy.Color(255, 255, 255)
DEFAULT_TEXT_DISABLED = mcrfpy.Color(120, 120, 120)
DEFAULT_OUTLINE = mcrfpy.Color(200, 200, 210)
def __init__(self, text, pos, size=(120, 40), callback=None,
fill_color=None, hover_color=None, press_color=None,
text_color=None, outline_color=None, outline=2,
font_size=16, enabled=True):
self.text = text
self.pos = pos
self.size = size
self.callback = callback
self.font_size = font_size
self._enabled = enabled
# Store colors
self.fill_color = fill_color or self.DEFAULT_FILL
self.hover_color = hover_color or self.DEFAULT_HOVER
self.press_color = press_color or self.DEFAULT_PRESS
self.text_color = text_color or self.DEFAULT_TEXT
self.outline_color = outline_color or self.DEFAULT_OUTLINE
# State tracking
self.is_hovered = False
self.is_pressed = False
# Create the frame
self.frame = mcrfpy.Frame(
pos=pos,
size=size,
fill_color=self.fill_color,
outline_color=self.outline_color,
outline=outline
)
# Create the label (centered in frame)
self.label = mcrfpy.Caption(
text=text,
pos=(size[0] / 2, size[1] / 2 - font_size / 2),
fill_color=self.text_color,
font_size=font_size
)
self.frame.children.append(self.label)
# Set up event handlers
self.frame.on_click = self._on_click
self.frame.on_enter = self._on_enter
self.frame.on_exit = self._on_exit
# Apply initial state
if not enabled:
self._apply_disabled_style()
def _on_click(self, pos, button, action):
"""Handle click events."""
if not self._enabled:
return
if button == "left":
if action == "start":
self.is_pressed = True
self.frame.fill_color = self.press_color
# Animate a subtle press effect
self._animate_press()
elif action == "end":
self.is_pressed = False
# Restore hover or normal state
if self.is_hovered:
self.frame.fill_color = self.hover_color
else:
self.frame.fill_color = self.fill_color
# Trigger callback on release if still over button
if self.is_hovered and self.callback:
self.callback()
def _on_enter(self, pos):
"""Handle mouse enter.
Note: #230 - on_enter now only receives position, not button/action
"""
if not self._enabled:
return
self.is_hovered = True
if not self.is_pressed:
self.frame.fill_color = self.hover_color
def _on_exit(self, pos):
"""Handle mouse exit.
Note: #230 - on_exit now only receives position, not button/action
"""
if not self._enabled:
return
self.is_hovered = False
self.is_pressed = False
self.frame.fill_color = self.fill_color
def _animate_press(self):
"""Animate a subtle scale bounce on press."""
# Small scale down then back up
# Note: If scale animation is not available, this is a no-op
try:
# Animate origin for a press effect
original_x = self.frame.x
original_y = self.frame.y
# Quick bounce using position
self.frame.animate("x", original_x + 2, 0.05, mcrfpy.Easing.EASE_OUT)
self.frame.animate("y", original_y + 2, 0.05, mcrfpy.Easing.EASE_OUT)
except Exception:
pass # Animation not critical
def _apply_disabled_style(self):
"""Apply disabled visual style."""
self.frame.fill_color = self.DEFAULT_DISABLED
self.label.fill_color = self.DEFAULT_TEXT_DISABLED
def _apply_enabled_style(self):
"""Restore enabled visual style."""
self.frame.fill_color = self.fill_color
self.label.fill_color = self.text_color
@property
def enabled(self):
"""Whether the button is clickable."""
return self._enabled
@enabled.setter
def enabled(self, value):
"""Enable or disable the button."""
self._enabled = value
if value:
self._apply_enabled_style()
else:
self._apply_disabled_style()
self.is_hovered = False
self.is_pressed = False
def set_text(self, text):
"""Change the button label."""
self.text = text
self.label.text = text
def set_callback(self, callback):
"""Change the click callback."""
self.callback = callback
def create_button_row(labels, start_pos, spacing=10, size=(120, 40), callbacks=None):
"""Create a horizontal row of buttons.
Args:
labels: List of button labels
start_pos: (x, y) position of first button
spacing: Pixels between buttons
size: (width, height) for all buttons
callbacks: List of callbacks (or None for no callbacks)
Returns:
List of Button objects
"""
buttons = []
x, y = start_pos
callbacks = callbacks or [None] * len(labels)
for label, callback in zip(labels, callbacks):
btn = Button(label, pos=(x, y), size=size, callback=callback)
buttons.append(btn)
x += size[0] + spacing
return buttons
def create_button_column(labels, start_pos, spacing=10, size=(120, 40), callbacks=None):
"""Create a vertical column of buttons.
Args:
labels: List of button labels
start_pos: (x, y) position of first button
spacing: Pixels between buttons
size: (width, height) for all buttons
callbacks: List of callbacks (or None for no callbacks)
Returns:
List of Button objects
"""
buttons = []
x, y = start_pos
callbacks = callbacks or [None] * len(labels)
for label, callback in zip(labels, callbacks):
btn = Button(label, pos=(x, y), size=size, callback=callback)
buttons.append(btn)
y += size[1] + spacing
return buttons

View file

@ -0,0 +1,317 @@
# McRogueFace Cookbook - Choice List Widget
"""
Vertical list of selectable text options with keyboard/mouse navigation.
Example:
from lib.choice_list import ChoiceList
def on_select(index, value):
print(f"Selected {value} at index {index}")
choices = ChoiceList(
pos=(100, 100),
size=(200, 150),
choices=["New Game", "Load Game", "Options", "Quit"],
on_select=on_select
)
scene.children.append(choices.frame)
# Navigate with keyboard
choices.navigate(1) # Move down
choices.navigate(-1) # Move up
choices.confirm() # Select current
"""
import mcrfpy
class ChoiceList:
"""Vertical list of selectable text options.
Args:
pos: (x, y) position tuple
size: (width, height) tuple
choices: List of choice strings
on_select: Callback(index, value) when selection is confirmed
item_height: Height of each item (default: 30)
selected_color: Background color of selected item
hover_color: Background color when hovered
normal_color: Background color of unselected items
text_color: Color of choice text
selected_text_color: Text color when selected
font_size: Size of choice text (default: 16)
outline: Border thickness (default: 1)
Attributes:
frame: The outer frame (add this to scene)
selected_index: Currently selected index
choices: List of choice strings
"""
DEFAULT_NORMAL = mcrfpy.Color(40, 40, 45)
DEFAULT_HOVER = mcrfpy.Color(60, 60, 70)
DEFAULT_SELECTED = mcrfpy.Color(80, 100, 140)
DEFAULT_TEXT = mcrfpy.Color(200, 200, 200)
DEFAULT_SELECTED_TEXT = mcrfpy.Color(255, 255, 255)
DEFAULT_OUTLINE = mcrfpy.Color(100, 100, 110)
def __init__(self, pos, size, choices, on_select=None,
item_height=30, selected_color=None, hover_color=None,
normal_color=None, text_color=None, selected_text_color=None,
font_size=16, outline=1):
self.pos = pos
self.size = size
self._choices = list(choices)
self.on_select = on_select
self.item_height = item_height
self.font_size = font_size
self._selected_index = 0
# Colors
self.normal_color = normal_color or self.DEFAULT_NORMAL
self.hover_color = hover_color or self.DEFAULT_HOVER
self.selected_color = selected_color or self.DEFAULT_SELECTED
self.text_color = text_color or self.DEFAULT_TEXT
self.selected_text_color = selected_text_color or self.DEFAULT_SELECTED_TEXT
# Hover tracking
self._hovered_index = -1
# Create outer frame
self.frame = mcrfpy.Frame(
pos=pos,
size=size,
fill_color=self.normal_color,
outline_color=self.DEFAULT_OUTLINE,
outline=outline
)
# Item frames and labels
self._item_frames = []
self._item_labels = []
self._rebuild_items()
def _rebuild_items(self):
"""Rebuild all item frames and labels."""
# Clear existing - pop all items from the collection
while len(self.frame.children) > 0:
self.frame.children.pop()
self._item_frames = []
self._item_labels = []
# Create item frames
for i, choice in enumerate(self._choices):
# Create item frame
item_frame = mcrfpy.Frame(
pos=(0, i * self.item_height),
size=(self.size[0], self.item_height),
fill_color=self.selected_color if i == self._selected_index else self.normal_color,
outline=0
)
# Create label
label = mcrfpy.Caption(
text=choice,
pos=(10, (self.item_height - self.font_size) / 2),
fill_color=self.selected_text_color if i == self._selected_index else self.text_color,
font_size=self.font_size
)
item_frame.children.append(label)
# Set up click handler
idx = i # Capture index in closure
def make_click_handler(index):
def handler(pos, button, action):
if button == "left" and action == "end":
self.set_selected(index)
if self.on_select:
self.on_select(index, self._choices[index])
return handler
def make_enter_handler(index):
def handler(pos, button, action):
self._on_item_enter(index)
return handler
def make_exit_handler(index):
def handler(pos, button, action):
self._on_item_exit(index)
return handler
item_frame.on_click = make_click_handler(idx)
item_frame.on_enter = make_enter_handler(idx)
item_frame.on_exit = make_exit_handler(idx)
self._item_frames.append(item_frame)
self._item_labels.append(label)
self.frame.children.append(item_frame)
def _on_item_enter(self, index):
"""Handle mouse entering an item."""
self._hovered_index = index
if index != self._selected_index:
self._item_frames[index].fill_color = self.hover_color
def _on_item_exit(self, index):
"""Handle mouse leaving an item."""
self._hovered_index = -1
if index != self._selected_index:
self._item_frames[index].fill_color = self.normal_color
def _update_display(self):
"""Update visual state of all items."""
for i, (frame, label) in enumerate(zip(self._item_frames, self._item_labels)):
if i == self._selected_index:
frame.fill_color = self.selected_color
label.fill_color = self.selected_text_color
elif i == self._hovered_index:
frame.fill_color = self.hover_color
label.fill_color = self.text_color
else:
frame.fill_color = self.normal_color
label.fill_color = self.text_color
@property
def selected_index(self):
"""Currently selected index."""
return self._selected_index
@property
def selected_value(self):
"""Currently selected value."""
if 0 <= self._selected_index < len(self._choices):
return self._choices[self._selected_index]
return None
@property
def choices(self):
"""List of choices."""
return list(self._choices)
@choices.setter
def choices(self, value):
"""Set new choices list."""
self._choices = list(value)
self._selected_index = min(self._selected_index, len(self._choices) - 1)
if self._selected_index < 0:
self._selected_index = 0
self._rebuild_items()
def set_selected(self, index):
"""Set the selected index.
Args:
index: Index to select (clamped to valid range)
"""
if not self._choices:
return
old_index = self._selected_index
self._selected_index = max(0, min(index, len(self._choices) - 1))
if old_index != self._selected_index:
self._update_display()
def navigate(self, direction):
"""Navigate up or down in the list.
Args:
direction: +1 for down, -1 for up
"""
if not self._choices:
return
new_index = self._selected_index + direction
# Wrap around
if new_index < 0:
new_index = len(self._choices) - 1
elif new_index >= len(self._choices):
new_index = 0
self.set_selected(new_index)
def confirm(self):
"""Confirm the current selection (triggers callback)."""
if self.on_select and 0 <= self._selected_index < len(self._choices):
self.on_select(self._selected_index, self._choices[self._selected_index])
def add_choice(self, choice, index=None):
"""Add a choice at the given index (or end if None)."""
if index is None:
self._choices.append(choice)
else:
self._choices.insert(index, choice)
self._rebuild_items()
def remove_choice(self, index):
"""Remove the choice at the given index."""
if 0 <= index < len(self._choices):
del self._choices[index]
if self._selected_index >= len(self._choices):
self._selected_index = max(0, len(self._choices) - 1)
self._rebuild_items()
def set_choice(self, index, value):
"""Change the text of a choice."""
if 0 <= index < len(self._choices):
self._choices[index] = value
self._item_labels[index].text = value
def create_menu(pos, choices, on_select=None, title=None, width=200):
"""Create a simple menu with optional title.
Args:
pos: (x, y) position
choices: List of choice strings
on_select: Callback(index, value)
title: Optional menu title
width: Menu width
Returns:
Tuple of (container_frame, choice_list) or just choice_list if no title
"""
if title:
# Create container with title
item_height = 30
title_height = 40
total_height = title_height + len(choices) * item_height
container = mcrfpy.Frame(
pos=pos,
size=(width, total_height),
fill_color=mcrfpy.Color(30, 30, 35),
outline_color=mcrfpy.Color(100, 100, 110),
outline=2
)
# Title caption
title_cap = mcrfpy.Caption(
text=title,
pos=(width / 2, 10),
fill_color=mcrfpy.Color(255, 255, 255),
font_size=18
)
container.children.append(title_cap)
# Choice list below title
choice_list = ChoiceList(
pos=(0, title_height),
size=(width, len(choices) * item_height),
choices=choices,
on_select=on_select,
outline=0
)
# Add choice list frame as child
container.children.append(choice_list.frame)
return container, choice_list
else:
return ChoiceList(
pos=pos,
size=(width, len(choices) * 30),
choices=choices,
on_select=on_select
)

View file

@ -0,0 +1,344 @@
# McRogueFace Cookbook - Grid Container Widget
"""
NxM clickable cells for inventory/slot systems.
Example:
from lib.grid_container import GridContainer
def on_cell_click(x, y, item):
print(f"Clicked cell ({x}, {y}): {item}")
inventory = GridContainer(
pos=(100, 100),
cell_size=(48, 48),
grid_dims=(5, 4), # 5 columns, 4 rows
on_cell_click=on_cell_click
)
scene.children.append(inventory.frame)
# Set cell contents
inventory.set_cell(0, 0, sprite_index=10, count=5)
"""
import mcrfpy
class GridContainer:
"""NxM clickable cells for inventory/slot systems.
Args:
pos: (x, y) position tuple
cell_size: (width, height) of each cell
grid_dims: (columns, rows) grid dimensions
on_cell_click: Callback(x, y, item_data) when cell is clicked
on_cell_hover: Callback(x, y, item_data) when cell is hovered
texture: Optional texture for sprites
empty_color: Background color for empty cells
filled_color: Background color for cells with items
selected_color: Background color for selected cell
hover_color: Background color when hovered
cell_outline: Cell border thickness
cell_spacing: Space between cells
Attributes:
frame: The outer frame (add this to scene)
selected: (x, y) of selected cell or None
"""
DEFAULT_EMPTY = mcrfpy.Color(40, 40, 45)
DEFAULT_FILLED = mcrfpy.Color(50, 50, 60)
DEFAULT_SELECTED = mcrfpy.Color(80, 100, 140)
DEFAULT_HOVER = mcrfpy.Color(60, 60, 75)
DEFAULT_OUTLINE = mcrfpy.Color(70, 70, 80)
def __init__(self, pos, cell_size, grid_dims, on_cell_click=None,
on_cell_hover=None, texture=None,
empty_color=None, filled_color=None,
selected_color=None, hover_color=None,
cell_outline=1, cell_spacing=2):
self.pos = pos
self.cell_size = cell_size
self.grid_dims = grid_dims # (cols, rows)
self.on_cell_click = on_cell_click
self.on_cell_hover = on_cell_hover
self.texture = texture
self.cell_outline = cell_outline
self.cell_spacing = cell_spacing
# Colors
self.empty_color = empty_color or self.DEFAULT_EMPTY
self.filled_color = filled_color or self.DEFAULT_FILLED
self.selected_color = selected_color or self.DEFAULT_SELECTED
self.hover_color = hover_color or self.DEFAULT_HOVER
self.outline_color = self.DEFAULT_OUTLINE
# State
self._selected = None
self._hovered = None
self._cells = {} # (x, y) -> cell data
self._cell_frames = {} # (x, y) -> frame
# Calculate total size
cols, rows = grid_dims
total_width = cols * cell_size[0] + (cols - 1) * cell_spacing
total_height = rows * cell_size[1] + (rows - 1) * cell_spacing
# Create outer frame
self.frame = mcrfpy.Frame(
pos=pos,
size=(total_width, total_height),
fill_color=mcrfpy.Color(0, 0, 0, 0), # Transparent
outline=0
)
# Create cell frames
self._create_cells()
def _create_cells(self):
"""Create all cell frames."""
cols, rows = self.grid_dims
cw, ch = self.cell_size
for row in range(rows):
for col in range(cols):
x = col * (cw + self.cell_spacing)
y = row * (ch + self.cell_spacing)
cell_frame = mcrfpy.Frame(
pos=(x, y),
size=self.cell_size,
fill_color=self.empty_color,
outline_color=self.outline_color,
outline=self.cell_outline
)
# Set up event handlers
def make_click(cx, cy):
def handler(pos, button, action):
if button == "left" and action == "end":
self._on_cell_clicked(cx, cy)
return handler
def make_enter(cx, cy):
def handler(pos, button, action):
self._on_cell_enter(cx, cy)
return handler
def make_exit(cx, cy):
def handler(pos, button, action):
self._on_cell_exit(cx, cy)
return handler
cell_frame.on_click = make_click(col, row)
cell_frame.on_enter = make_enter(col, row)
cell_frame.on_exit = make_exit(col, row)
self._cell_frames[(col, row)] = cell_frame
self.frame.children.append(cell_frame)
def _on_cell_clicked(self, x, y):
"""Handle cell click."""
old_selected = self._selected
self._selected = (x, y)
# Update display
if old_selected:
self._update_cell_display(*old_selected)
self._update_cell_display(x, y)
# Fire callback
if self.on_cell_click:
item = self._cells.get((x, y))
self.on_cell_click(x, y, item)
def _on_cell_enter(self, x, y):
"""Handle cell hover enter."""
self._hovered = (x, y)
self._update_cell_display(x, y)
if self.on_cell_hover:
item = self._cells.get((x, y))
self.on_cell_hover(x, y, item)
def _on_cell_exit(self, x, y):
"""Handle cell hover exit."""
if self._hovered == (x, y):
self._hovered = None
self._update_cell_display(x, y)
def _update_cell_display(self, x, y):
"""Update visual state of a cell."""
if (x, y) not in self._cell_frames:
return
frame = self._cell_frames[(x, y)]
has_item = (x, y) in self._cells
if (x, y) == self._selected:
frame.fill_color = self.selected_color
elif (x, y) == self._hovered:
frame.fill_color = self.hover_color
elif has_item:
frame.fill_color = self.filled_color
else:
frame.fill_color = self.empty_color
@property
def selected(self):
"""Currently selected cell (x, y) or None."""
return self._selected
@selected.setter
def selected(self, value):
"""Set selected cell."""
old = self._selected
self._selected = value
if old:
self._update_cell_display(*old)
if value:
self._update_cell_display(*value)
def get_selected_item(self):
"""Get the item in the selected cell."""
if self._selected:
return self._cells.get(self._selected)
return None
def set_cell(self, x, y, sprite_index=None, count=None, data=None):
"""Set cell contents.
Args:
x, y: Cell coordinates
sprite_index: Index in texture for sprite display
count: Stack count to display
data: Arbitrary data to associate with cell
"""
if (x, y) not in self._cell_frames:
return
cell_frame = self._cell_frames[(x, y)]
# Store cell data
self._cells[(x, y)] = {
'sprite_index': sprite_index,
'count': count,
'data': data
}
# Clear existing children except the frame itself
while len(cell_frame.children) > 0:
cell_frame.children.pop()
# Add sprite if we have texture and sprite_index
if self.texture and sprite_index is not None:
sprite = mcrfpy.Sprite(
pos=(2, 2),
texture=self.texture,
sprite_index=sprite_index
)
# Scale sprite to fit cell (with padding)
# Note: sprite scaling depends on texture cell size
cell_frame.children.append(sprite)
# Add count label if count > 1
if count is not None and count > 1:
count_label = mcrfpy.Caption(
text=str(count),
pos=(self.cell_size[0] - 8, self.cell_size[1] - 12),
fill_color=mcrfpy.Color(255, 255, 255),
font_size=10
)
count_label.outline = 1
count_label.outline_color = mcrfpy.Color(0, 0, 0)
cell_frame.children.append(count_label)
self._update_cell_display(x, y)
def get_cell(self, x, y):
"""Get cell data.
Args:
x, y: Cell coordinates
Returns:
Cell data dict or None if empty
"""
return self._cells.get((x, y))
def clear_cell(self, x, y):
"""Clear a cell's contents.
Args:
x, y: Cell coordinates
"""
if (x, y) in self._cells:
del self._cells[(x, y)]
if (x, y) in self._cell_frames:
cell_frame = self._cell_frames[(x, y)]
while len(cell_frame.children) > 0:
cell_frame.children.pop()
self._update_cell_display(x, y)
def clear_all(self):
"""Clear all cells."""
for key in list(self._cells.keys()):
self.clear_cell(*key)
self._selected = None
def find_empty_cell(self):
"""Find the first empty cell.
Returns:
(x, y) of empty cell or None if all full
"""
cols, rows = self.grid_dims
for row in range(rows):
for col in range(cols):
if (col, row) not in self._cells:
return (col, row)
return None
def is_full(self):
"""Check if all cells are filled."""
return len(self._cells) >= self.grid_dims[0] * self.grid_dims[1]
def swap_cells(self, x1, y1, x2, y2):
"""Swap contents of two cells.
Args:
x1, y1: First cell
x2, y2: Second cell
"""
cell1 = self._cells.get((x1, y1))
cell2 = self._cells.get((x2, y2))
if cell1:
self.set_cell(x2, y2, **cell1)
else:
self.clear_cell(x2, y2)
if cell2:
self.set_cell(x1, y1, **cell2)
else:
self.clear_cell(x1, y1)
def move_cell(self, from_x, from_y, to_x, to_y):
"""Move cell contents to another cell.
Args:
from_x, from_y: Source cell
to_x, to_y: Destination cell
Returns:
True if successful, False if destination not empty
"""
if (to_x, to_y) in self._cells:
return False
cell = self._cells.get((from_x, from_y))
if cell:
self.set_cell(to_x, to_y, **cell)
self.clear_cell(from_x, from_y)
return True
return False

View file

@ -0,0 +1,563 @@
#!/usr/bin/env python3
"""Item Manager - Coordinates item pickup/drop across multiple widgets
This module provides:
- ItemManager: Central coordinator for item transfers
- ItemSlot: Frame-based equipment slot widget
- Item: Data class for item properties
The manager allows entities to move between:
- Grid widgets (inventory, shop displays)
- ItemSlot widgets (equipment slots)
- The cursor (when held)
Usage:
manager = ItemManager()
manager.register_grid("inventory", inventory_grid)
manager.register_slot("weapon", weapon_slot)
# Widgets call manager methods on click:
manager.pickup_from_grid("inventory", cell_pos)
manager.drop_to_slot("weapon")
"""
import mcrfpy
class ItemEntity(mcrfpy.Entity):
"""Entity subclass that can hold item data."""
def __init__(self):
super().__init__()
self.item = None # Will be set after adding to grid
class Item:
"""Data class representing an item's properties."""
def __init__(self, sprite_index, name, **stats):
"""
Args:
sprite_index: Texture sprite index
name: Display name
**stats: Arbitrary stats (atk, def, int, price, etc.)
"""
self.sprite_index = sprite_index
self.name = name
self.stats = stats
def __repr__(self):
return f"Item({self.name}, sprite={self.sprite_index})"
@property
def price(self):
return self.stats.get('price', 0)
@property
def atk(self):
return self.stats.get('atk', 0)
@property
def def_(self):
return self.stats.get('def', 0)
@property
def slot_type(self):
"""Equipment slot this item fits in (weapon, shield, etc.)"""
return self.stats.get('slot_type', None)
class ItemSlot(mcrfpy.Frame):
"""A frame that can hold a single item (for equipment slots)."""
def __init__(self, pos, size=(64, 64), slot_type=None,
empty_color=(60, 60, 70), valid_color=(60, 100, 60),
invalid_color=(100, 60, 60), filled_color=(80, 80, 90)):
"""
Args:
pos: Position tuple (x, y)
size: Size tuple (w, h)
slot_type: Type of items this slot accepts (e.g., 'weapon', 'shield')
empty_color: Color when empty and not hovered
valid_color: Color when valid item is hovering
invalid_color: Color when invalid item is hovering
filled_color: Color when containing an item
"""
super().__init__(pos, size, fill_color=empty_color, outline=2, outline_color=(100, 100, 120))
self.slot_type = slot_type
self.empty_color = empty_color
self.valid_color = valid_color
self.invalid_color = invalid_color
self.filled_color = filled_color
# Item stored in this slot
self.item = None
self.item_sprite = None
# Manager reference (set by manager.register_slot)
self.manager = None
self.slot_name = None
# Setup sprite for displaying item
sprite_scale = min(size[0], size[1]) / 16.0 * 0.8 # 80% of slot size
self.item_sprite = mcrfpy.Sprite(
pos=(size[0] * 0.1, size[1] * 0.1),
texture=mcrfpy.default_texture,
sprite_index=0
)
self.item_sprite.scale = sprite_scale
self.item_sprite.visible = False
self.children.append(self.item_sprite)
# Setup events
self.on_click = self._on_click
self.on_enter = self._on_enter
self.on_exit = self._on_exit
def set_item(self, item):
"""Set the item in this slot."""
self.item = item
if item:
self.item_sprite.sprite_index = item.sprite_index
self.item_sprite.visible = True
self.fill_color = self.filled_color
else:
self.item_sprite.visible = False
self.fill_color = self.empty_color
def clear(self):
"""Remove item from slot."""
self.set_item(None)
def can_accept(self, item):
"""Check if this slot can accept the given item."""
if self.slot_type is None:
return True # Accepts anything
if item is None:
return True
return item.slot_type == self.slot_type
def _on_click(self, pos, button, action):
if action != "start" or button != "left":
return
if self.manager:
self.manager.handle_slot_click(self.slot_name)
def _on_enter(self, pos, *args):
if self.manager and self.manager.held_item:
if self.can_accept(self.manager.held_item):
self.fill_color = self.valid_color
else:
self.fill_color = self.invalid_color
def _on_exit(self, pos, *args):
if self.item:
self.fill_color = self.filled_color
else:
self.fill_color = self.empty_color
class ItemManager:
"""Coordinates item pickup and placement across multiple widgets."""
def __init__(self, scene):
"""
Args:
scene: The mcrfpy.Scene to add cursor elements to
"""
self.scene = scene
self.grids = {} # name -> (grid, {(x,y): Item})
self.slots = {} # name -> ItemSlot
# Currently held item state
self.held_item = None
self.held_source = None # ('grid', name, pos) or ('slot', name)
self.held_entity = None # For grid sources, the original entity
# Cursor sprite setup
self.cursor_frame = mcrfpy.Frame(
pos=(0, 0),
size=(64, 64),
fill_color=(0, 0, 0, 0),
outline=0
)
self.cursor_sprite = mcrfpy.Sprite(
pos=(0, 0),
texture=mcrfpy.default_texture,
sprite_index=0
)
self.cursor_sprite.scale = 4.0
self.cursor_sprite.visible = False
self.cursor_frame.children.append(self.cursor_sprite)
scene.children.append(self.cursor_frame)
# Callbacks for UI updates
self.on_pickup = None # Called when item picked up
self.on_drop = None # Called when item dropped
self.on_cancel = None # Called when pickup cancelled
def register_grid(self, name, grid, items=None):
"""Register a grid for item management.
Args:
name: Unique name for this grid
grid: mcrfpy.Grid instance
items: Optional dict of {(x, y): Item} for initial items
"""
item_map = items or {}
# Add a color layer for highlighting if not present
color_layer = grid.add_layer('color', z_index=-1)
self.grids[name] = (grid, item_map, color_layer)
# Setup grid event handlers
grid.on_click = lambda pos, btn, act: self._on_grid_click(name, pos, btn, act)
grid.on_cell_enter = lambda cell_pos: self._on_grid_cell_enter(name, cell_pos)
grid.on_move = self._on_move
def register_slot(self, name, slot):
"""Register an ItemSlot for item management.
Args:
name: Unique name for this slot
slot: ItemSlot instance
"""
self.slots[name] = slot
slot.manager = self
slot.slot_name = name
def add_item_to_grid(self, grid_name, pos, item):
"""Add an item to a grid at the specified position.
Args:
grid_name: Name of registered grid
pos: (x, y) cell position
item: Item instance
Returns:
True if successful, False if cell occupied
"""
if grid_name not in self.grids:
return False
grid, item_map, color_layer = self.grids[grid_name]
if pos in item_map:
return False # Cell occupied
# Create entity
entity = ItemEntity()
grid.entities.append(entity)
entity.grid_pos = pos # Use grid_pos for tile coordinates
entity.sprite_index = item.sprite_index
entity.item = item # Store item reference on our subclass
item_map[pos] = item
return True
def get_item_at(self, grid_name, pos):
"""Get item at grid position, or None."""
if grid_name not in self.grids:
return None
grid, item_map, color_layer = self.grids[grid_name]
return item_map.get(pos)
def _get_entity_at(self, grid_name, pos):
"""Get entity at grid position."""
grid, _, _ = self.grids[grid_name]
for entity in grid.entities:
gp = entity.grid_pos
if (int(gp[0]), int(gp[1])) == pos:
return entity
return None
def _on_grid_click(self, grid_name, pos, button, action):
"""Handle click on a registered grid."""
if action != "start":
return
if button == "right":
self.cancel_pickup()
return
if button != "left":
return
grid, item_map, color_layer = self.grids[grid_name]
# Convert screen pos to cell
cell_size = 16 * grid.zoom
x = int((pos[0] - grid.x) / cell_size)
y = int((pos[1] - grid.y) / cell_size)
grid_w, grid_h = grid.grid_size
if not (0 <= x < grid_w and 0 <= y < grid_h):
return
cell_pos = (x, y)
if self.held_item is None:
# Try to pick up
if cell_pos in item_map:
self._pickup_from_grid(grid_name, cell_pos)
else:
# Try to drop
if cell_pos not in item_map:
self._drop_to_grid(grid_name, cell_pos)
def _pickup_from_grid(self, grid_name, pos):
"""Pick up item from grid cell."""
grid, item_map, color_layer = self.grids[grid_name]
item = item_map[pos]
entity = self._get_entity_at(grid_name, pos)
if entity:
entity.visible = False
self.held_item = item
self.held_source = ('grid', grid_name, pos)
self.held_entity = entity
# Setup cursor
self.cursor_sprite.sprite_index = item.sprite_index
self.cursor_sprite.visible = True
# Highlight source cell
color_layer.set(pos, (255, 255, 100, 200))
if self.on_pickup:
self.on_pickup(item, grid_name, pos)
def _drop_to_grid(self, grid_name, pos):
"""Drop held item to grid cell."""
grid, item_map, color_layer = self.grids[grid_name]
# Check if same grid and moving item
if self.held_source[0] == 'grid':
source_grid_name = self.held_source[1]
source_pos = self.held_source[2]
# Remove from source
source_grid, source_map, source_color_layer = self.grids[source_grid_name]
if source_pos in source_map:
del source_map[source_pos]
# Clear source highlight
source_color_layer.set(source_pos, (0, 0, 0, 0))
# Move or recreate entity
if grid_name == source_grid_name and self.held_entity:
# Same grid - just move entity
self.held_entity.grid_pos = pos
self.held_entity.visible = True
else:
# Different grid - remove old, create new
if self.held_entity:
# Remove from source grid
for i, e in enumerate(source_grid.entities):
if e is self.held_entity:
source_grid.entities.pop(i)
break
# Create in target grid
entity = ItemEntity()
grid.entities.append(entity)
entity.grid_pos = pos # Use grid_pos for tile coordinates
entity.sprite_index = self.held_item.sprite_index
entity.item = self.held_item
self.held_entity = None
elif self.held_source[0] == 'slot':
# Moving from slot to grid
slot_name = self.held_source[1]
slot = self.slots[slot_name]
slot.clear()
# Create entity in grid
entity = ItemEntity()
grid.entities.append(entity)
entity.grid_pos = pos # Use grid_pos for tile coordinates
entity.sprite_index = self.held_item.sprite_index
entity.item = self.held_item
# Add to target grid's item map
item_map[pos] = self.held_item
if self.on_drop:
self.on_drop(self.held_item, grid_name, pos)
# Clear held state
self.cursor_sprite.visible = False
self.held_item = None
self.held_source = None
self.held_entity = None
def handle_slot_click(self, slot_name):
"""Handle click on a registered slot."""
slot = self.slots[slot_name]
if self.held_item is None:
# Try to pick up from slot
if slot.item:
self._pickup_from_slot(slot_name)
else:
# Try to drop to slot
if slot.can_accept(self.held_item):
self._drop_to_slot(slot_name)
def _pickup_from_slot(self, slot_name):
"""Pick up item from slot."""
slot = self.slots[slot_name]
item = slot.item
self.held_item = item
self.held_source = ('slot', slot_name)
self.held_entity = None
slot.clear()
# Setup cursor
self.cursor_sprite.sprite_index = item.sprite_index
self.cursor_sprite.visible = True
if self.on_pickup:
self.on_pickup(item, slot_name, None)
def _drop_to_slot(self, slot_name):
"""Drop held item to slot."""
slot = self.slots[slot_name]
# If slot has item, swap
old_item = slot.item
slot.set_item(self.held_item)
# Clean up source
if self.held_source[0] == 'grid':
source_grid_name = self.held_source[1]
source_pos = self.held_source[2]
source_grid, source_map, source_color_layer = self.grids[source_grid_name]
# Remove from source grid
if source_pos in source_map:
del source_map[source_pos]
# Clear highlight
source_color_layer.set(source_pos, (0, 0, 0, 0))
# Remove entity
if self.held_entity:
for i, e in enumerate(source_grid.entities):
if e is self.held_entity:
source_grid.entities.pop(i)
break
# If swapping, put old item in source position
if old_item:
self.add_item_to_grid(source_grid_name, source_pos, old_item)
elif self.held_source[0] == 'slot':
# Slot to slot swap
source_slot_name = self.held_source[1]
source_slot = self.slots[source_slot_name]
if old_item:
source_slot.set_item(old_item)
if self.on_drop:
self.on_drop(self.held_item, slot_name, None)
# Clear held state
self.cursor_sprite.visible = False
self.held_item = None
self.held_source = None
self.held_entity = None
def cancel_pickup(self):
"""Cancel current pickup and return item to source."""
if not self.held_item:
return
if self.held_source[0] == 'grid':
grid_name = self.held_source[1]
pos = self.held_source[2]
grid, item_map, color_layer = self.grids[grid_name]
# Restore entity visibility
if self.held_entity:
self.held_entity.visible = True
# Restore item map
item_map[pos] = self.held_item
# Clear highlight
color_layer.set(pos, (0, 0, 0, 0))
elif self.held_source[0] == 'slot':
slot_name = self.held_source[1]
slot = self.slots[slot_name]
slot.set_item(self.held_item)
if self.on_cancel:
self.on_cancel(self.held_item)
# Clear held state
self.cursor_sprite.visible = False
self.held_item = None
self.held_source = None
self.held_entity = None
def _on_grid_cell_enter(self, grid_name, cell_pos):
"""Handle cell hover on registered grid."""
if not self.held_item:
return
grid, item_map, color_layer = self.grids[grid_name]
x, y = int(cell_pos[0]), int(cell_pos[1])
# Don't highlight source cell
if (self.held_source[0] == 'grid' and
self.held_source[1] == grid_name and
self.held_source[2] == (x, y)):
return
if (x, y) in item_map:
color_layer.set((x, y), (255, 100, 100, 200)) # Red - occupied
else:
color_layer.set((x, y), (100, 255, 100, 200)) # Green - available
def _on_move(self, pos, *args):
"""Update cursor position."""
if self.cursor_sprite.visible:
self.cursor_frame.x = pos[0] - 32
self.cursor_frame.y = pos[1] - 32
# Predefined items using the texture sprites
ITEM_DATABASE = {
'buckler': Item(101, "Buckler", def_=1, slot_type='shield', price=15),
'shield': Item(102, "Shield", def_=2, slot_type='shield', price=30),
'shortsword': Item(103, "Shortsword", atk=1, slot_type='weapon', price=20),
'longsword': Item(104, "Longsword", atk=2, slot_type='weapon', price=40),
'cleaver': Item(105, "Cleaver", atk=3, slot_type='weapon', two_handed=True, price=60),
'buster': Item(106, "Buster Sword", atk=4, slot_type='weapon', two_handed=True, price=100),
'training_sword': Item(107, "Training Sword", atk=2, slot_type='weapon', two_handed=True, price=25),
'hammer': Item(117, "Hammer", atk=2, slot_type='weapon', price=35),
'double_axe': Item(118, "Double Axe", atk=5, slot_type='weapon', two_handed=True, price=120),
'axe': Item(119, "Axe", atk=3, slot_type='weapon', price=50),
'wand': Item(129, "Wand", atk=1, int_=4, slot_type='weapon', price=45),
'staff': Item(130, "Staff", atk=1, int_=7, slot_type='weapon', two_handed=True, price=80),
'spear': Item(131, "Spear", atk=4, range_=1, slot_type='weapon', two_handed=True, price=55),
'cloudy_potion': Item(113, "Cloudy Potion", slot_type='consumable', price=5),
'str_potion': Item(114, "Strength Potion", slot_type='consumable', price=25),
'health_potion': Item(115, "Health Potion", slot_type='consumable', price=15),
'mana_potion': Item(116, "Mana Potion", slot_type='consumable', price=15),
'lesser_cloudy': Item(125, "Lesser Cloudy Potion", slot_type='consumable', price=3),
'lesser_str': Item(126, "Lesser Strength Potion", slot_type='consumable', price=12),
'lesser_health': Item(127, "Lesser Health Potion", slot_type='consumable', price=8),
'lesser_mana': Item(128, "Lesser Mana Potion", slot_type='consumable', price=8),
}
def get_item(name):
"""Get an item from the database by name."""
return ITEM_DATABASE.get(name)

332
tests/cookbook/lib/modal.py Normal file
View file

@ -0,0 +1,332 @@
# McRogueFace Cookbook - Modal Widget
"""
Overlay popup that blocks background input.
Example:
from lib.modal import Modal
def on_confirm():
print("Confirmed!")
modal.hide()
modal = Modal(
title="Confirm Action",
message="Are you sure you want to proceed?",
buttons=[
("Cancel", modal.hide),
("Confirm", on_confirm)
]
)
modal.show(scene)
"""
import mcrfpy
class Modal:
"""Overlay popup that blocks background input.
Args:
title: Modal title text
message: Modal body message (optional)
content_frame: Custom content frame (overrides message)
buttons: List of (label, callback) tuples
width: Modal width (default: 400)
height: Modal height (default: auto-calculated)
overlay_color: Semi-transparent overlay color
bg_color: Modal background color
title_color: Title text color
message_color: Message text color
button_spacing: Space between buttons
Attributes:
overlay: The overlay frame (add to scene to show)
modal_frame: The modal content frame
visible: Whether modal is currently visible
"""
DEFAULT_OVERLAY = mcrfpy.Color(0, 0, 0, 180)
DEFAULT_BG = mcrfpy.Color(40, 40, 50)
DEFAULT_TITLE_COLOR = mcrfpy.Color(255, 255, 255)
DEFAULT_MESSAGE_COLOR = mcrfpy.Color(200, 200, 200)
DEFAULT_BUTTON_BG = mcrfpy.Color(60, 60, 75)
DEFAULT_BUTTON_HOVER = mcrfpy.Color(80, 80, 100)
def __init__(self, title, message=None, content_frame=None,
buttons=None, width=400, height=None,
overlay_color=None, bg_color=None,
title_color=None, message_color=None,
button_spacing=10):
self.title = title
self.message = message
self.width = width
self.button_spacing = button_spacing
self._on_close = None
# Colors
self.overlay_color = overlay_color or self.DEFAULT_OVERLAY
self.bg_color = bg_color or self.DEFAULT_BG
self.title_color = title_color or self.DEFAULT_TITLE_COLOR
self.message_color = message_color or self.DEFAULT_MESSAGE_COLOR
# State
self.visible = False
self._scene = None
self._buttons = buttons or []
# Calculate height if not specified
if height is None:
height = 60 # Title
if message:
# Rough estimate of message height
lines = len(message) // 40 + message.count('\n') + 1
height += lines * 20 + 20
if content_frame:
height += 150 # Default content height
if buttons:
height += 50 # Button row
height = max(150, height)
self.height = height
# Create overlay (fullscreen semi-transparent)
self.overlay = mcrfpy.Frame(
pos=(0, 0),
size=(1024, 768), # Will be adjusted on show
fill_color=self.overlay_color,
outline=0
)
# Block clicks on overlay from reaching elements behind
self.overlay.on_click = self._on_overlay_click
# Create modal frame (centered)
modal_x = (1024 - width) // 2
modal_y = (768 - height) // 2
self.modal_frame = mcrfpy.Frame(
pos=(modal_x, modal_y),
size=(width, height),
fill_color=self.bg_color,
outline_color=mcrfpy.Color(100, 100, 120),
outline=2
)
self.overlay.children.append(self.modal_frame)
# Add title
self._title_caption = mcrfpy.Caption(
text=title,
pos=(width // 2, 15),
fill_color=self.title_color,
font_size=18
)
self._title_caption.outline = 1
self._title_caption.outline_color = mcrfpy.Color(0, 0, 0)
self.modal_frame.children.append(self._title_caption)
# Add separator
sep = mcrfpy.Frame(
pos=(20, 45),
size=(width - 40, 2),
fill_color=mcrfpy.Color(80, 80, 100),
outline=0
)
self.modal_frame.children.append(sep)
# Content area starts at y=55
content_y = 55
# Add message if provided
if message and not content_frame:
self._message_caption = mcrfpy.Caption(
text=message,
pos=(20, content_y),
fill_color=self.message_color,
font_size=14
)
self.modal_frame.children.append(self._message_caption)
elif content_frame:
content_frame.x = 20
content_frame.y = content_y
self.modal_frame.children.append(content_frame)
# Add buttons at bottom
if buttons:
self._create_buttons(buttons)
def _create_buttons(self, buttons):
"""Create button row at bottom of modal."""
button_width = 100
button_height = 35
total_width = len(buttons) * button_width + (len(buttons) - 1) * self.button_spacing
start_x = (self.width - total_width) // 2
button_y = self.height - button_height - 15
self._button_frames = []
for i, (label, callback) in enumerate(buttons):
x = start_x + i * (button_width + self.button_spacing)
btn_frame = mcrfpy.Frame(
pos=(x, button_y),
size=(button_width, button_height),
fill_color=self.DEFAULT_BUTTON_BG,
outline_color=mcrfpy.Color(120, 120, 140),
outline=1
)
btn_label = mcrfpy.Caption(
text=label,
pos=(button_width // 2, (button_height - 14) // 2),
fill_color=mcrfpy.Color(220, 220, 220),
font_size=14
)
btn_frame.children.append(btn_label)
# Hover effect
def make_enter(frame):
def handler(pos, button, action):
frame.fill_color = self.DEFAULT_BUTTON_HOVER
return handler
def make_exit(frame):
def handler(pos, button, action):
frame.fill_color = self.DEFAULT_BUTTON_BG
return handler
def make_click(cb):
def handler(pos, button, action):
if button == "left" and action == "end" and cb:
cb()
return handler
btn_frame.on_enter = make_enter(btn_frame)
btn_frame.on_exit = make_exit(btn_frame)
btn_frame.on_click = make_click(callback)
self._button_frames.append(btn_frame)
self.modal_frame.children.append(btn_frame)
def _on_overlay_click(self, pos, button, action):
"""Handle clicks on overlay (outside modal)."""
# Check if click is outside modal
if button == "left" and action == "end":
mx, my = self.modal_frame.x, self.modal_frame.y
mw, mh = self.modal_frame.w, self.modal_frame.h
px, py = pos.x, pos.y
if not (mx <= px <= mx + mw and my <= py <= my + mh):
# Click outside modal - close if allowed
if self._on_close:
self._on_close()
@property
def on_close(self):
"""Callback when modal is closed by clicking outside."""
return self._on_close
@on_close.setter
def on_close(self, callback):
"""Set close callback."""
self._on_close = callback
def show(self, scene=None):
"""Show the modal.
Args:
scene: Scene to add modal to (uses stored scene if not provided)
"""
if scene:
self._scene = scene
if self._scene and not self.visible:
# Adjust overlay size to match scene
# Note: Assumes 1024x768 for now
self._scene.children.append(self.overlay)
self.visible = True
def hide(self):
"""Hide the modal."""
if self._scene and self.visible:
# Remove overlay from scene
try:
# Find and remove overlay
for i in range(len(self._scene.children)):
if self._scene.children[i] is self.overlay:
self._scene.children.pop()
break
except Exception:
pass
self.visible = False
def set_message(self, message):
"""Update the modal message."""
self.message = message
if hasattr(self, '_message_caption'):
self._message_caption.text = message
def set_title(self, title):
"""Update the modal title."""
self.title = title
self._title_caption.text = title
class ConfirmModal(Modal):
"""Pre-configured confirmation modal with Yes/No buttons.
Args:
title: Modal title
message: Confirmation message
on_confirm: Callback when confirmed
on_cancel: Callback when cancelled (optional)
confirm_text: Text for confirm button (default: "Confirm")
cancel_text: Text for cancel button (default: "Cancel")
"""
def __init__(self, title, message, on_confirm, on_cancel=None,
confirm_text="Confirm", cancel_text="Cancel", **kwargs):
self._confirm_callback = on_confirm
self._cancel_callback = on_cancel
buttons = [
(cancel_text, self._on_cancel),
(confirm_text, self._on_confirm)
]
super().__init__(title, message=message, buttons=buttons, **kwargs)
def _on_confirm(self):
"""Handle confirm button."""
self.hide()
if self._confirm_callback:
self._confirm_callback()
def _on_cancel(self):
"""Handle cancel button."""
self.hide()
if self._cancel_callback:
self._cancel_callback()
class AlertModal(Modal):
"""Pre-configured alert modal with single OK button.
Args:
title: Modal title
message: Alert message
on_dismiss: Callback when dismissed (optional)
button_text: Text for button (default: "OK")
"""
def __init__(self, title, message, on_dismiss=None,
button_text="OK", **kwargs):
self._dismiss_callback = on_dismiss
buttons = [(button_text, self._on_dismiss)]
super().__init__(title, message=message, buttons=buttons,
width=kwargs.pop('width', 350), **kwargs)
def _on_dismiss(self):
"""Handle dismiss button."""
self.hide()
if self._dismiss_callback:
self._dismiss_callback()

View file

@ -0,0 +1,351 @@
# McRogueFace Cookbook - Scrollable List Widget
"""
Scrolling list with arbitrary item rendering.
Example:
from lib.scrollable_list import ScrollableList
def render_item(item, frame, index, selected):
# Add item content to frame
label = mcrfpy.Caption(text=item['name'], pos=(10, 5))
frame.children.append(label)
items = [{'name': f'Item {i}'} for i in range(50)]
scroll_list = ScrollableList(
pos=(100, 100),
size=(300, 400),
items=items,
render_item=render_item,
item_height=40
)
scene.children.append(scroll_list.frame)
"""
import mcrfpy
class ScrollableList:
"""Scrolling list with arbitrary item rendering.
Args:
pos: (x, y) position tuple
size: (width, height) tuple
items: List of items to display
item_height: Height of each item row (default: 30)
render_item: Callback(item, frame, index, selected) to render item content
on_select: Callback(index, item) when item is selected
bg_color: Background color (default: dark)
item_bg_color: Normal item background
selected_bg_color: Selected item background
hover_bg_color: Hovered item background
outline_color: Border color
outline: Border thickness
Attributes:
frame: The outer frame (add this to scene)
items: List of items
selected_index: Currently selected index
scroll_offset: Current scroll position
"""
DEFAULT_BG = mcrfpy.Color(30, 30, 35)
DEFAULT_ITEM_BG = mcrfpy.Color(35, 35, 40)
DEFAULT_SELECTED_BG = mcrfpy.Color(60, 80, 120)
DEFAULT_HOVER_BG = mcrfpy.Color(50, 50, 60)
DEFAULT_OUTLINE = mcrfpy.Color(80, 80, 90)
def __init__(self, pos, size, items=None, item_height=30,
render_item=None, on_select=None,
bg_color=None, item_bg_color=None,
selected_bg_color=None, hover_bg_color=None,
outline_color=None, outline=1):
self.pos = pos
self.size = size
self._items = list(items) if items else []
self.item_height = item_height
self.render_item = render_item or self._default_render
self.on_select = on_select
# Colors
self.bg_color = bg_color or self.DEFAULT_BG
self.item_bg_color = item_bg_color or self.DEFAULT_ITEM_BG
self.selected_bg_color = selected_bg_color or self.DEFAULT_SELECTED_BG
self.hover_bg_color = hover_bg_color or self.DEFAULT_HOVER_BG
self.outline_color = outline_color or self.DEFAULT_OUTLINE
# State
self._selected_index = -1
self._scroll_offset = 0
self._hovered_index = -1
self._visible_count = int(size[1] / item_height)
# Create outer frame with clipping
self.frame = mcrfpy.Frame(
pos=pos,
size=size,
fill_color=self.bg_color,
outline_color=self.outline_color,
outline=outline
)
self.frame.clip_children = True
# Content container (scrolls within frame)
self._content = mcrfpy.Frame(
pos=(0, 0),
size=(size[0], max(size[1], len(self._items) * item_height)),
fill_color=mcrfpy.Color(0, 0, 0, 0), # Transparent
outline=0
)
self.frame.children.append(self._content)
# Set up scroll handler
self.frame.on_move = self._on_mouse_move
# Build initial items
self._rebuild_items()
def _default_render(self, item, frame, index, selected):
"""Default item renderer - just shows string representation."""
text = str(item) if not isinstance(item, dict) else item.get('name', str(item))
label = mcrfpy.Caption(
text=text,
pos=(10, (self.item_height - 14) / 2),
fill_color=mcrfpy.Color(200, 200, 200),
font_size=14
)
frame.children.append(label)
def _rebuild_items(self):
"""Rebuild all item frames."""
# Clear content
while len(self._content.children) > 0:
self._content.children.pop()
# Update content size
total_height = len(self._items) * self.item_height
self._content.h = max(self.size[1], total_height)
# Create item frames
self._item_frames = []
for i, item in enumerate(self._items):
item_frame = mcrfpy.Frame(
pos=(0, i * self.item_height),
size=(self.size[0], self.item_height),
fill_color=self.item_bg_color,
outline=0
)
# Set up click handler
def make_click_handler(index):
def handler(pos, button, action):
if button == "left" and action == "end":
self.select(index)
return handler
def make_enter_handler(index):
def handler(pos, button, action):
self._on_item_enter(index)
return handler
def make_exit_handler(index):
def handler(pos, button, action):
self._on_item_exit(index)
return handler
item_frame.on_click = make_click_handler(i)
item_frame.on_enter = make_enter_handler(i)
item_frame.on_exit = make_exit_handler(i)
# Render item content
is_selected = i == self._selected_index
self.render_item(item, item_frame, i, is_selected)
self._item_frames.append(item_frame)
self._content.children.append(item_frame)
def _on_item_enter(self, index):
"""Handle mouse entering an item."""
self._hovered_index = index
if index != self._selected_index:
self._item_frames[index].fill_color = self.hover_bg_color
def _on_item_exit(self, index):
"""Handle mouse leaving an item."""
if self._hovered_index == index:
self._hovered_index = -1
if index != self._selected_index:
self._item_frames[index].fill_color = self.item_bg_color
def _on_mouse_move(self, pos, button, action):
"""Handle mouse movement for scroll wheel detection."""
# Note: This is a placeholder - actual scroll wheel handling
# depends on the engine's input system
pass
def _update_item_display(self):
"""Update visual state of items."""
for i, frame in enumerate(self._item_frames):
if i == self._selected_index:
frame.fill_color = self.selected_bg_color
elif i == self._hovered_index:
frame.fill_color = self.hover_bg_color
else:
frame.fill_color = self.item_bg_color
@property
def items(self):
"""List of items."""
return list(self._items)
@items.setter
def items(self, value):
"""Set new items list."""
self._items = list(value)
self._selected_index = -1
self._scroll_offset = 0
self._rebuild_items()
@property
def selected_index(self):
"""Currently selected index (-1 if none)."""
return self._selected_index
@property
def selected_item(self):
"""Currently selected item (None if none)."""
if 0 <= self._selected_index < len(self._items):
return self._items[self._selected_index]
return None
@property
def scroll_offset(self):
"""Current scroll position in pixels."""
return self._scroll_offset
def select(self, index):
"""Select an item by index.
Args:
index: Item index to select
"""
if not self._items:
return
old_index = self._selected_index
self._selected_index = max(-1, min(index, len(self._items) - 1))
if old_index != self._selected_index:
self._update_item_display()
if self.on_select and self._selected_index >= 0:
self.on_select(self._selected_index, self._items[self._selected_index])
def scroll(self, delta):
"""Scroll the list by a number of items.
Args:
delta: Number of items to scroll (positive = down)
"""
max_scroll = max(0, len(self._items) * self.item_height - self.size[1])
new_offset = self._scroll_offset + delta * self.item_height
new_offset = max(0, min(new_offset, max_scroll))
if new_offset != self._scroll_offset:
self._scroll_offset = new_offset
self._content.y = -self._scroll_offset
def scroll_to(self, index):
"""Scroll to make an item visible.
Args:
index: Item index to scroll to
"""
if not 0 <= index < len(self._items):
return
item_top = index * self.item_height
item_bottom = item_top + self.item_height
if item_top < self._scroll_offset:
self._scroll_offset = item_top
elif item_bottom > self._scroll_offset + self.size[1]:
self._scroll_offset = item_bottom - self.size[1]
self._content.y = -self._scroll_offset
def navigate(self, direction):
"""Navigate selection up or down.
Args:
direction: +1 for down, -1 for up
"""
if not self._items:
return
if self._selected_index < 0:
new_index = 0 if direction > 0 else len(self._items) - 1
else:
new_index = self._selected_index + direction
# Wrap around
if new_index < 0:
new_index = len(self._items) - 1
elif new_index >= len(self._items):
new_index = 0
self.select(new_index)
self.scroll_to(new_index)
def add_item(self, item, index=None):
"""Add an item to the list.
Args:
item: Item to add
index: Position to insert (None = end)
"""
if index is None:
self._items.append(item)
else:
self._items.insert(index, item)
self._rebuild_items()
def remove_item(self, index):
"""Remove an item by index.
Args:
index: Index of item to remove
"""
if 0 <= index < len(self._items):
del self._items[index]
if self._selected_index >= len(self._items):
self._selected_index = len(self._items) - 1
self._rebuild_items()
def clear(self):
"""Remove all items."""
self._items.clear()
self._selected_index = -1
self._scroll_offset = 0
self._rebuild_items()
def filter(self, predicate):
"""Filter displayed items.
Args:
predicate: Function(item) -> bool to filter items
Note: This creates a new filtered view, not modifying original items.
"""
# Store original items if not already stored
if not hasattr(self, '_original_items'):
self._original_items = list(self._items)
self._items = [item for item in self._original_items if predicate(item)]
self._selected_index = -1
self._rebuild_items()
def reset_filter(self):
"""Reset to show all items (undo filter)."""
if hasattr(self, '_original_items'):
self._items = list(self._original_items)
del self._original_items
self._selected_index = -1
self._rebuild_items()

View file

@ -0,0 +1,272 @@
# McRogueFace Cookbook - Stat Bar Widget
"""
Horizontal bar showing current/max value with animation support.
Example:
from lib.stat_bar import StatBar
hp_bar = StatBar(
pos=(100, 50),
size=(200, 20),
current=75,
maximum=100,
fill_color=mcrfpy.Color(200, 50, 50), # Red for health
label="HP"
)
scene.children.append(hp_bar.frame)
# Take damage
hp_bar.set_value(50, animate=True)
# Flash on hit
hp_bar.flash(mcrfpy.Color(255, 255, 255))
"""
import mcrfpy
class StatBar:
"""Horizontal bar showing current/max value.
Args:
pos: (x, y) position tuple
size: (width, height) tuple, default (200, 20)
current: Current value (default: 100)
maximum: Maximum value (default: 100)
fill_color: Bar fill color (default: green)
bg_color: Background color (default: dark gray)
outline_color: Border color (default: white)
outline: Border thickness (default: 1)
label: Optional label prefix (e.g., "HP")
show_text: Whether to show value text (default: True)
text_color: Color of value text (default: white)
font_size: Size of value text (default: 12)
Attributes:
frame: The outer frame (add this to scene)
bar: The inner fill bar
current: Current value
maximum: Maximum value
"""
# Preset colors for common stat types
HEALTH_COLOR = mcrfpy.Color(200, 50, 50) # Red
MANA_COLOR = mcrfpy.Color(50, 100, 200) # Blue
STAMINA_COLOR = mcrfpy.Color(50, 180, 80) # Green
XP_COLOR = mcrfpy.Color(200, 180, 50) # Gold
SHIELD_COLOR = mcrfpy.Color(100, 150, 200) # Light blue
DEFAULT_BG = mcrfpy.Color(30, 30, 35)
DEFAULT_OUTLINE = mcrfpy.Color(150, 150, 160)
DEFAULT_TEXT = mcrfpy.Color(255, 255, 255)
def __init__(self, pos, size=(200, 20), current=100, maximum=100,
fill_color=None, bg_color=None, outline_color=None,
outline=1, label=None, show_text=True,
text_color=None, font_size=12):
self.pos = pos
self.size = size
self._current = current
self._maximum = maximum
self.label = label
self.show_text = show_text
self.font_size = font_size
# Colors
self.fill_color = fill_color or self.STAMINA_COLOR
self.bg_color = bg_color or self.DEFAULT_BG
self.text_color = text_color or self.DEFAULT_TEXT
self.outline_color = outline_color or self.DEFAULT_OUTLINE
# Create outer frame (background)
self.frame = mcrfpy.Frame(
pos=pos,
size=size,
fill_color=self.bg_color,
outline_color=self.outline_color,
outline=outline
)
# Create inner bar (the fill)
bar_width = self._calculate_bar_width()
self.bar = mcrfpy.Frame(
pos=(0, 0),
size=(bar_width, size[1]),
fill_color=self.fill_color,
outline=0
)
self.frame.children.append(self.bar)
# Create text label if needed
if show_text:
text = self._format_text()
# Center text in the bar
self.text = mcrfpy.Caption(
text=text,
pos=(size[0] / 2, (size[1] - font_size) / 2),
fill_color=self.text_color,
font_size=font_size
)
# Add outline for readability over bar
self.text.outline = 1
self.text.outline_color = mcrfpy.Color(0, 0, 0)
self.frame.children.append(self.text)
else:
self.text = None
def _calculate_bar_width(self):
"""Calculate the fill bar width based on current/max ratio."""
if self._maximum <= 0:
return 0
ratio = max(0, min(1, self._current / self._maximum))
return ratio * self.size[0]
def _format_text(self):
"""Format the display text."""
if self.label:
return f"{self.label}: {int(self._current)}/{int(self._maximum)}"
return f"{int(self._current)}/{int(self._maximum)}"
def _update_display(self, animate=True):
"""Update the bar width and text."""
target_width = self._calculate_bar_width()
if animate:
# Animate bar width change
self.bar.animate("w", target_width, 0.3, mcrfpy.Easing.EASE_OUT)
else:
self.bar.w = target_width
# Update text
if self.text:
self.text.text = self._format_text()
@property
def current(self):
"""Current value."""
return self._current
@current.setter
def current(self, value):
"""Set current value (no animation)."""
self._current = max(0, min(value, self._maximum))
self._update_display(animate=False)
@property
def maximum(self):
"""Maximum value."""
return self._maximum
@maximum.setter
def maximum(self, value):
"""Set maximum value."""
self._maximum = max(1, value)
self._current = min(self._current, self._maximum)
self._update_display(animate=False)
def set_value(self, current, maximum=None, animate=True):
"""Set the bar value with optional animation.
Args:
current: New current value
maximum: New maximum value (optional)
animate: Whether to animate the change
"""
if maximum is not None:
self._maximum = max(1, maximum)
self._current = max(0, min(current, self._maximum))
self._update_display(animate=animate)
def flash(self, color=None, duration=0.2):
"""Flash the bar a color (e.g., white on damage).
Args:
color: Flash color (default: white)
duration: Flash duration in seconds
"""
color = color or mcrfpy.Color(255, 255, 255)
original_color = self.fill_color
# Flash to color
self.bar.fill_color = color
# Create a timer to restore color
# Using a closure to capture state
bar_ref = self.bar
restore_color = original_color
def restore(runtime):
bar_ref.fill_color = restore_color
# Schedule restoration
timer_name = f"flash_{id(self)}"
mcrfpy.Timer(timer_name, restore, int(duration * 1000))
def set_colors(self, fill_color=None, bg_color=None, text_color=None):
"""Update bar colors.
Args:
fill_color: New fill color
bg_color: New background color
text_color: New text color
"""
if fill_color:
self.fill_color = fill_color
self.bar.fill_color = fill_color
if bg_color:
self.bg_color = bg_color
self.frame.fill_color = bg_color
if text_color and self.text:
self.text_color = text_color
self.text.fill_color = text_color
@property
def ratio(self):
"""Get current fill ratio (0.0 to 1.0)."""
if self._maximum <= 0:
return 0
return self._current / self._maximum
def is_empty(self):
"""Check if bar is empty (current <= 0)."""
return self._current <= 0
def is_full(self):
"""Check if bar is full (current >= maximum)."""
return self._current >= self._maximum
def create_stat_bar_group(stats, start_pos, spacing=30, size=(200, 20)):
"""Create a vertical group of stat bars.
Args:
stats: List of dicts with keys: name, current, max, color
start_pos: (x, y) position of first bar
spacing: Vertical pixels between bars
size: (width, height) for all bars
Returns:
Dict mapping stat names to StatBar objects
Example:
bars = create_stat_bar_group([
{"name": "HP", "current": 80, "max": 100, "color": StatBar.HEALTH_COLOR},
{"name": "MP", "current": 50, "max": 80, "color": StatBar.MANA_COLOR},
{"name": "XP", "current": 250, "max": 1000, "color": StatBar.XP_COLOR},
], start_pos=(50, 50))
"""
bar_dict = {}
x, y = start_pos
for stat in stats:
bar = StatBar(
pos=(x, y),
size=size,
current=stat.get("current", 100),
maximum=stat.get("max", 100),
fill_color=stat.get("color"),
label=stat.get("name")
)
bar_dict[stat.get("name", f"stat_{len(bar_dict)}")] = bar
y += size[1] + spacing
return bar_dict

View file

@ -0,0 +1,286 @@
# McRogueFace Cookbook - Text Box Widget
"""
Word-wrapped text display with typewriter effect.
Example:
from lib.text_box import TextBox
text_box = TextBox(
pos=(100, 100),
size=(400, 200),
text="This is a long text that will be word-wrapped...",
chars_per_second=30
)
scene.children.append(text_box.frame)
# Skip animation
text_box.skip_animation()
"""
import mcrfpy
class TextBox:
"""Word-wrapped text with optional typewriter effect.
Args:
pos: (x, y) position tuple
size: (width, height) tuple
text: Initial text to display
chars_per_second: Typewriter speed (0 = instant)
font_size: Text size (default: 14)
text_color: Color of text (default: white)
bg_color: Background color (default: dark)
outline_color: Border color (default: gray)
outline: Border thickness (default: 1)
padding: Internal padding (default: 10)
Attributes:
frame: The outer frame (add this to scene)
text: Current text content
is_complete: Whether typewriter animation finished
"""
DEFAULT_TEXT_COLOR = mcrfpy.Color(220, 220, 220)
DEFAULT_BG_COLOR = mcrfpy.Color(25, 25, 30)
DEFAULT_OUTLINE = mcrfpy.Color(80, 80, 90)
def __init__(self, pos, size, text="", chars_per_second=30,
font_size=14, text_color=None, bg_color=None,
outline_color=None, outline=1, padding=10):
self.pos = pos
self.size = size
self._full_text = text
self.chars_per_second = chars_per_second
self.font_size = font_size
self.padding = padding
# Colors
self.text_color = text_color or self.DEFAULT_TEXT_COLOR
self.bg_color = bg_color or self.DEFAULT_BG_COLOR
self.outline_color = outline_color or self.DEFAULT_OUTLINE
# State
self._displayed_chars = 0
self._is_complete = chars_per_second == 0
self._on_complete = None
self._timer_name = None
# Create outer frame
self.frame = mcrfpy.Frame(
pos=pos,
size=size,
fill_color=self.bg_color,
outline_color=self.outline_color,
outline=outline
)
# Calculate text area
self._text_width = size[0] - padding * 2
self._text_height = size[1] - padding * 2
# Create text caption
self._caption = mcrfpy.Caption(
text="",
pos=(padding, padding),
fill_color=self.text_color,
font_size=font_size
)
self.frame.children.append(self._caption)
# Start typewriter if there's text
if text and chars_per_second > 0:
self._start_typewriter()
elif text:
self._caption.text = self._word_wrap(text)
self._displayed_chars = len(text)
self._is_complete = True
def _word_wrap(self, text):
"""Word-wrap text to fit within the text box width.
Simple implementation that breaks on spaces.
"""
# Estimate chars per line based on font size
# This is approximate - real implementation would measure text
avg_char_width = self.font_size * 0.6
chars_per_line = int(self._text_width / avg_char_width)
if chars_per_line <= 0:
return text
words = text.split(' ')
lines = []
current_line = []
current_length = 0
for word in words:
# Check if word fits on current line
word_len = len(word)
if current_length + word_len + (1 if current_line else 0) <= chars_per_line:
current_line.append(word)
current_length += word_len + (1 if len(current_line) > 1 else 0)
else:
# Start new line
if current_line:
lines.append(' '.join(current_line))
current_line = [word]
current_length = word_len
# Add last line
if current_line:
lines.append(' '.join(current_line))
return '\n'.join(lines)
def _start_typewriter(self):
"""Start the typewriter animation."""
if self.chars_per_second <= 0:
return
self._displayed_chars = 0
self._is_complete = False
# Calculate interval in milliseconds
interval_ms = max(1, int(1000 / self.chars_per_second))
self._timer_name = f"textbox_{id(self)}"
mcrfpy.Timer(self._timer_name, self._typewriter_tick, interval_ms)
def _typewriter_tick(self, runtime):
"""Add one character to the display."""
if self._displayed_chars >= len(self._full_text):
# Animation complete
self._is_complete = True
# Stop timer by deleting it - there's no direct stop method
# The timer will continue but we'll just not update
if self._on_complete:
self._on_complete()
return
self._displayed_chars += 1
visible_text = self._full_text[:self._displayed_chars]
self._caption.text = self._word_wrap(visible_text)
@property
def text(self):
"""Get the full text content."""
return self._full_text
@property
def is_complete(self):
"""Whether the typewriter animation has finished."""
return self._is_complete
@property
def on_complete(self):
"""Callback when animation completes."""
return self._on_complete
@on_complete.setter
def on_complete(self, callback):
"""Set callback for animation completion."""
self._on_complete = callback
def set_text(self, text, animate=True):
"""Change the text content.
Args:
text: New text to display
animate: Whether to use typewriter effect
"""
self._full_text = text
if animate and self.chars_per_second > 0:
self._start_typewriter()
else:
self._caption.text = self._word_wrap(text)
self._displayed_chars = len(text)
self._is_complete = True
def skip_animation(self):
"""Skip to the end of the typewriter animation."""
self._displayed_chars = len(self._full_text)
self._caption.text = self._word_wrap(self._full_text)
self._is_complete = True
if self._on_complete:
self._on_complete()
def clear(self):
"""Clear the text box."""
self._full_text = ""
self._displayed_chars = 0
self._caption.text = ""
self._is_complete = True
def append_text(self, text, animate=True):
"""Append text to the current content.
Args:
text: Text to append
animate: Whether to animate the new text
"""
new_text = self._full_text + text
self.set_text(new_text, animate=animate)
class DialogueBox(TextBox):
"""Specialized text box for dialogue with speaker name.
Args:
pos: (x, y) position tuple
size: (width, height) tuple
speaker: Name of the speaker
text: Dialogue text
speaker_color: Color for speaker name (default: yellow)
**kwargs: Additional TextBox arguments
"""
DEFAULT_SPEAKER_COLOR = mcrfpy.Color(255, 220, 100)
def __init__(self, pos, size, speaker="", text="",
speaker_color=None, **kwargs):
# Initialize parent with empty text
super().__init__(pos, size, text="", **kwargs)
self._speaker = speaker
self.speaker_color = speaker_color or self.DEFAULT_SPEAKER_COLOR
# Create speaker name caption
self._speaker_caption = mcrfpy.Caption(
text=speaker,
pos=(self.padding, self.padding - 5),
fill_color=self.speaker_color,
font_size=self.font_size + 2
)
self._speaker_caption.outline = 1
self._speaker_caption.outline_color = mcrfpy.Color(0, 0, 0)
self.frame.children.append(self._speaker_caption)
# Adjust main text position
self._caption.y = self.padding + self.font_size + 10
# Now set the actual text
if text:
self.set_text(text, animate=kwargs.get('chars_per_second', 30) > 0)
@property
def speaker(self):
"""Get the speaker name."""
return self._speaker
@speaker.setter
def speaker(self, value):
"""Set the speaker name."""
self._speaker = value
self._speaker_caption.text = value
def set_dialogue(self, speaker, text, animate=True):
"""Set both speaker and text.
Args:
speaker: Speaker name
text: Dialogue text
animate: Whether to animate the text
"""
self.speaker = speaker
self.set_text(text, animate=animate)

221
tests/cookbook/lib/toast.py Normal file
View file

@ -0,0 +1,221 @@
# McRogueFace Cookbook - Toast Notification Widget
"""
Auto-dismissing notification popups.
Example:
from lib.toast import ToastManager
# Create manager (once per scene)
toasts = ToastManager(scene)
# Show notifications
toasts.show("Game saved!")
toasts.show("Level up!", duration=5000, color=mcrfpy.Color(100, 200, 100))
"""
import mcrfpy
class Toast:
"""Single toast notification.
Internal class - use ToastManager to create toasts.
"""
DEFAULT_BG = mcrfpy.Color(50, 50, 60, 240)
DEFAULT_TEXT = mcrfpy.Color(255, 255, 255)
def __init__(self, message, y_position, width=300,
bg_color=None, text_color=None, duration=3000):
self.message = message
self.duration = duration
self.y_position = y_position
self.width = width
self._dismissed = False
# Colors
self.bg_color = bg_color or self.DEFAULT_BG
self.text_color = text_color or self.DEFAULT_TEXT
# Create toast frame (starts off-screen to the right)
self.frame = mcrfpy.Frame(
pos=(1024 + 10, y_position), # Start off-screen
size=(width, 40),
fill_color=self.bg_color,
outline_color=mcrfpy.Color(100, 100, 120),
outline=1
)
# Create message caption
self.caption = mcrfpy.Caption(
text=message,
pos=(15, 10),
fill_color=self.text_color,
font_size=14
)
self.frame.children.append(self.caption)
def slide_in(self, target_x):
"""Animate sliding in from the right."""
self.frame.animate("x", target_x, 0.3, mcrfpy.Easing.EASE_OUT)
def slide_out(self, callback=None):
"""Animate sliding out to the right."""
self._dismissed = True
self.frame.animate("x", 1024 + 10, 0.3, mcrfpy.Easing.EASE_IN)
if callback:
mcrfpy.Timer(f"toast_dismiss_{id(self)}", lambda rt: callback(), 350)
def move_up(self, new_y):
"""Animate moving to a new Y position."""
self.y_position = new_y
self.frame.animate("y", new_y, 0.2, mcrfpy.Easing.EASE_OUT)
@property
def is_dismissed(self):
"""Whether this toast has been dismissed."""
return self._dismissed
class ToastManager:
"""Manages auto-dismissing notification popups.
Args:
scene: Scene to add toasts to
position: Anchor position ("top-right", "bottom-right", "top-left", "bottom-left")
max_toasts: Maximum visible toasts (default: 5)
toast_width: Width of toast notifications
toast_spacing: Vertical spacing between toasts
margin: Margin from screen edge
Attributes:
scene: The scene toasts are added to
toasts: List of active toast objects
"""
def __init__(self, scene, position="top-right", max_toasts=5,
toast_width=300, toast_spacing=10, margin=20):
self.scene = scene
self.position = position
self.max_toasts = max_toasts
self.toast_width = toast_width
self.toast_spacing = toast_spacing
self.margin = margin
self.toasts = []
# Calculate anchor position
self._calculate_anchor()
def _calculate_anchor(self):
"""Calculate the anchor point based on position setting."""
# Assuming 1024x768 screen
if "right" in self.position:
self._anchor_x = 1024 - self.toast_width - self.margin
else:
self._anchor_x = self.margin
if "top" in self.position:
self._anchor_y = self.margin
self._direction = 1 # Stack downward
else:
self._anchor_y = 768 - 40 - self.margin # 40 = toast height
self._direction = -1 # Stack upward
def _get_toast_y(self, index):
"""Get Y position for toast at given index."""
return self._anchor_y + index * (40 + self.toast_spacing) * self._direction
def show(self, message, duration=3000, color=None):
"""Show a toast notification.
Args:
message: Text to display
duration: Time in ms before auto-dismiss (0 = never)
color: Optional background color
Returns:
Toast object
"""
# Create new toast
y_pos = self._get_toast_y(len(self.toasts))
toast = Toast(
message=message,
y_position=y_pos,
width=self.toast_width,
bg_color=color,
duration=duration
)
# Add to scene
self.scene.children.append(toast.frame)
self.toasts.append(toast)
# Animate in
toast.slide_in(self._anchor_x)
# Schedule auto-dismiss
if duration > 0:
timer_name = f"toast_auto_{id(toast)}"
mcrfpy.Timer(timer_name, lambda rt: self.dismiss(toast), duration)
# Remove oldest if over limit
while len(self.toasts) > self.max_toasts:
self.dismiss(self.toasts[0])
return toast
def dismiss(self, toast):
"""Dismiss a specific toast.
Args:
toast: Toast object to dismiss
"""
if toast not in self.toasts or toast.is_dismissed:
return
index = self.toasts.index(toast)
def on_dismissed():
# Remove from scene and list
if toast in self.toasts:
self.toasts.remove(toast)
# Try to remove from scene
try:
for i in range(len(self.scene.children)):
if self.scene.children[i] is toast.frame:
self.scene.children.pop()
break
except Exception:
pass
# Move remaining toasts up
self._reposition_toasts()
toast.slide_out(callback=on_dismissed)
def _reposition_toasts(self):
"""Reposition all remaining toasts after one is removed."""
for i, toast in enumerate(self.toasts):
if not toast.is_dismissed:
new_y = self._get_toast_y(i)
toast.move_up(new_y)
def dismiss_all(self):
"""Dismiss all active toasts."""
for toast in list(self.toasts):
self.dismiss(toast)
def show_success(self, message, duration=3000):
"""Show a success toast (green)."""
return self.show(message, duration, mcrfpy.Color(40, 120, 60, 240))
def show_error(self, message, duration=5000):
"""Show an error toast (red)."""
return self.show(message, duration, mcrfpy.Color(150, 50, 50, 240))
def show_warning(self, message, duration=4000):
"""Show a warning toast (yellow)."""
return self.show(message, duration, mcrfpy.Color(180, 150, 40, 240))
def show_info(self, message, duration=3000):
"""Show an info toast (blue)."""
return self.show(message, duration, mcrfpy.Color(50, 100, 150, 240))

View file

@ -0,0 +1,6 @@
# McRogueFace Cookbook - Primitive Widget Demos
"""
Demo scripts for individual widget components.
Each demo can be run interactively or in headless mode for screenshots.
"""

View file

@ -0,0 +1,296 @@
#!/usr/bin/env python3
"""Button Widget Demo - Clickable buttons with hover/press states
Interactive controls:
Click: Interact with buttons
1-4: Trigger button actions via keyboard
D: Toggle button 4 enabled/disabled
ESC: Exit demo
"""
import mcrfpy
import sys
# Add parent to path for imports
sys.path.insert(0, str(__file__).rsplit('/', 2)[0])
from lib.button import Button, create_button_row, create_button_column
class ButtonDemo:
def __init__(self):
self.scene = mcrfpy.Scene("button_demo")
self.ui = self.scene.children
self.click_count = 0
self.buttons = []
self.setup()
def setup(self):
"""Build the demo scene."""
# Background
bg = mcrfpy.Frame(
pos=(0, 0),
size=(1024, 768),
fill_color=mcrfpy.Color(20, 20, 25)
)
self.ui.append(bg)
# Title
title = mcrfpy.Caption(
text="Button Widget Demo",
pos=(512, 30),
font_size=28,
fill_color=mcrfpy.Color(255, 255, 255)
)
title.outline = 2
title.outline_color = mcrfpy.Color(0, 0, 0)
self.ui.append(title)
# Click counter display
self.counter_caption = mcrfpy.Caption(
text="Clicks: 0",
pos=(512, 70),
font_size=18,
fill_color=mcrfpy.Color(200, 200, 100)
)
self.ui.append(self.counter_caption)
# Section 1: Basic buttons with different styles
section1_label = mcrfpy.Caption(
text="Basic Buttons (click or press 1-4)",
pos=(100, 130),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section1_label)
# Default button
btn1 = Button(
"Default",
pos=(100, 160),
callback=lambda: self.on_button_click("Default")
)
self.buttons.append(btn1)
self.ui.append(btn1.frame)
# Custom color button
btn2 = Button(
"Custom",
pos=(240, 160),
fill_color=mcrfpy.Color(80, 50, 100),
hover_color=mcrfpy.Color(100, 70, 130),
press_color=mcrfpy.Color(120, 90, 150),
callback=lambda: self.on_button_click("Custom")
)
self.buttons.append(btn2)
self.ui.append(btn2.frame)
# Success-style button
btn3 = Button(
"Success",
pos=(380, 160),
fill_color=mcrfpy.Color(40, 120, 60),
hover_color=mcrfpy.Color(50, 150, 75),
press_color=mcrfpy.Color(60, 180, 90),
outline_color=mcrfpy.Color(100, 200, 120),
callback=lambda: self.on_button_click("Success")
)
self.buttons.append(btn3)
self.ui.append(btn3.frame)
# Danger-style button (toggleable)
self.btn4 = Button(
"Danger",
pos=(520, 160),
fill_color=mcrfpy.Color(150, 50, 50),
hover_color=mcrfpy.Color(180, 70, 70),
press_color=mcrfpy.Color(200, 90, 90),
outline_color=mcrfpy.Color(200, 100, 100),
callback=lambda: self.on_button_click("Danger")
)
self.buttons.append(self.btn4)
self.ui.append(self.btn4.frame)
# Section 2: Different sizes
section2_label = mcrfpy.Caption(
text="Button Sizes",
pos=(100, 240),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section2_label)
# Small button
small = Button(
"Small",
pos=(100, 270),
size=(80, 30),
font_size=12,
callback=lambda: self.on_button_click("Small")
)
self.ui.append(small.frame)
# Medium button (default size)
medium = Button(
"Medium",
pos=(200, 270),
callback=lambda: self.on_button_click("Medium")
)
self.ui.append(medium.frame)
# Large button
large = Button(
"Large Button",
pos=(340, 270),
size=(180, 50),
font_size=20,
callback=lambda: self.on_button_click("Large")
)
self.ui.append(large.frame)
# Section 3: Button row
section3_label = mcrfpy.Caption(
text="Button Row (auto-layout)",
pos=(100, 360),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section3_label)
row_buttons = create_button_row(
labels=["File", "Edit", "View", "Help"],
start_pos=(100, 390),
spacing=5,
size=(80, 35),
callbacks=[
lambda: self.on_button_click("File"),
lambda: self.on_button_click("Edit"),
lambda: self.on_button_click("View"),
lambda: self.on_button_click("Help"),
]
)
for btn in row_buttons:
self.ui.append(btn.frame)
# Section 4: Button column
section4_label = mcrfpy.Caption(
text="Button Column",
pos=(600, 240),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section4_label)
col_buttons = create_button_column(
labels=["New Game", "Load Game", "Options", "Quit"],
start_pos=(600, 270),
spacing=5,
size=(150, 35),
callbacks=[
lambda: self.on_button_click("New Game"),
lambda: self.on_button_click("Load Game"),
lambda: self.on_button_click("Options"),
lambda: self.on_button_click("Quit"),
]
)
for btn in col_buttons:
self.ui.append(btn.frame)
# Section 5: Disabled state
section5_label = mcrfpy.Caption(
text="Disabled State (press D to toggle)",
pos=(100, 470),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section5_label)
self.disabled_btn = Button(
"Disabled",
pos=(100, 500),
enabled=False,
callback=lambda: self.on_button_click("This shouldn't fire!")
)
self.ui.append(self.disabled_btn.frame)
self.toggle_info = mcrfpy.Caption(
text="Currently: Disabled",
pos=(240, 510),
font_size=14,
fill_color=mcrfpy.Color(180, 100, 100)
)
self.ui.append(self.toggle_info)
# Instructions
instr = mcrfpy.Caption(
text="Click buttons or press 1-4 | D: Toggle disabled | ESC: Exit",
pos=(50, 730),
font_size=14,
fill_color=mcrfpy.Color(120, 120, 120)
)
self.ui.append(instr)
# Last action display
self.action_caption = mcrfpy.Caption(
text="Last action: None",
pos=(50, 600),
font_size=16,
fill_color=mcrfpy.Color(100, 200, 100)
)
self.ui.append(self.action_caption)
def on_button_click(self, button_name):
"""Handle button click."""
self.click_count += 1
self.counter_caption.text = f"Clicks: {self.click_count}"
self.action_caption.text = f"Last action: Clicked '{button_name}'"
def on_key(self, key, state):
"""Handle keyboard input."""
if state != "start":
return
if key == "Escape":
sys.exit(0)
elif key == "Num1" and len(self.buttons) > 0:
self.buttons[0].callback()
elif key == "Num2" and len(self.buttons) > 1:
self.buttons[1].callback()
elif key == "Num3" and len(self.buttons) > 2:
self.buttons[2].callback()
elif key == "Num4" and len(self.buttons) > 3:
self.buttons[3].callback()
elif key == "D":
# Toggle disabled button
self.disabled_btn.enabled = not self.disabled_btn.enabled
if self.disabled_btn.enabled:
self.toggle_info.text = "Currently: Enabled"
self.toggle_info.fill_color = mcrfpy.Color(100, 180, 100)
else:
self.toggle_info.text = "Currently: Disabled"
self.toggle_info.fill_color = mcrfpy.Color(180, 100, 100)
def activate(self):
"""Activate the demo scene."""
self.scene.on_key = self.on_key
mcrfpy.current_scene = self.scene
def main():
"""Run the button demo."""
demo = ButtonDemo()
demo.activate()
# Headless mode: capture screenshot and exit
try:
if mcrfpy.headless_mode():
from mcrfpy import automation
mcrfpy.Timer("screenshot", lambda rt: (
automation.screenshot("screenshots/primitives/button_demo.png"),
sys.exit(0)
), 100)
except AttributeError:
# headless_mode() may not exist in all versions
pass
if __name__ == "__main__":
main()

View file

@ -0,0 +1,287 @@
#!/usr/bin/env python3
"""Choice List Widget Demo - Vertical selectable list with keyboard/mouse navigation
Interactive controls:
Up/Down: Navigate choices
Enter: Confirm selection
Click: Select item
A: Add a new choice
R: Remove selected choice
ESC: Exit demo
"""
import mcrfpy
import sys
# Add parent to path for imports
sys.path.insert(0, str(__file__).rsplit('/', 2)[0])
from lib.choice_list import ChoiceList, create_menu
class ChoiceListDemo:
def __init__(self):
self.scene = mcrfpy.Scene("choice_list_demo")
self.ui = self.scene.children
self.lists = []
self.active_list_idx = 0
self.add_counter = 0
self.setup()
def setup(self):
"""Build the demo scene."""
# Background
bg = mcrfpy.Frame(
pos=(0, 0),
size=(1024, 768),
fill_color=mcrfpy.Color(20, 20, 25)
)
self.ui.append(bg)
# Title
title = mcrfpy.Caption(
text="Choice List Widget Demo",
pos=(512, 30),
font_size=28,
fill_color=mcrfpy.Color(255, 255, 255)
)
title.outline = 2
title.outline_color = mcrfpy.Color(0, 0, 0)
self.ui.append(title)
# Section 1: Basic choice list
section1_label = mcrfpy.Caption(
text="Main Menu (keyboard or click)",
pos=(50, 90),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section1_label)
self.main_list = ChoiceList(
pos=(50, 120),
size=(200, 150),
choices=["New Game", "Continue", "Options", "Credits", "Quit"],
on_select=self.on_main_select
)
self.lists.append(self.main_list)
self.ui.append(self.main_list.frame)
# Selection indicator
self.main_selection = mcrfpy.Caption(
text="Selected: New Game",
pos=(50, 280),
font_size=14,
fill_color=mcrfpy.Color(100, 200, 100)
)
self.ui.append(self.main_selection)
# Section 2: Custom styled list
section2_label = mcrfpy.Caption(
text="Difficulty Selection",
pos=(300, 90),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section2_label)
self.diff_list = ChoiceList(
pos=(300, 120),
size=(180, 120),
choices=["Easy", "Normal", "Hard", "Nightmare"],
on_select=self.on_diff_select,
selected_color=mcrfpy.Color(120, 60, 60),
hover_color=mcrfpy.Color(80, 40, 40),
normal_color=mcrfpy.Color(50, 30, 30)
)
self.lists.append(self.diff_list)
self.ui.append(self.diff_list.frame)
self.diff_selection = mcrfpy.Caption(
text="Difficulty: Easy",
pos=(300, 250),
font_size=14,
fill_color=mcrfpy.Color(200, 100, 100)
)
self.ui.append(self.diff_selection)
# Section 3: Dynamic list (add/remove items)
section3_label = mcrfpy.Caption(
text="Dynamic List (A: Add, R: Remove)",
pos=(530, 90),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section3_label)
self.dynamic_list = ChoiceList(
pos=(530, 120),
size=(200, 180),
choices=["Item 1", "Item 2", "Item 3"],
on_select=self.on_dynamic_select
)
self.lists.append(self.dynamic_list)
self.ui.append(self.dynamic_list.frame)
self.dynamic_info = mcrfpy.Caption(
text="Items: 3",
pos=(530, 310),
font_size=14,
fill_color=mcrfpy.Color(100, 150, 200)
)
self.ui.append(self.dynamic_info)
# Section 4: Menu with title (using helper)
section4_label = mcrfpy.Caption(
text="Menu with Title (helper function)",
pos=(780, 90),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section4_label)
menu_container, self.titled_list = create_menu(
pos=(780, 120),
choices=["Attack", "Defend", "Magic", "Item", "Flee"],
on_select=self.on_combat_select,
title="Combat",
width=180
)
self.lists.append(self.titled_list)
self.ui.append(menu_container)
self.combat_selection = mcrfpy.Caption(
text="Action: Attack",
pos=(780, 340),
font_size=14,
fill_color=mcrfpy.Color(200, 200, 100)
)
self.ui.append(self.combat_selection)
# Section 5: Long list (scrolling needed in future)
section5_label = mcrfpy.Caption(
text="Long List",
pos=(50, 350),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section5_label)
long_choices = [f"Option {i+1}" for i in range(10)]
self.long_list = ChoiceList(
pos=(50, 380),
size=(200, 300),
choices=long_choices,
on_select=self.on_long_select,
item_height=28
)
self.lists.append(self.long_list)
self.ui.append(self.long_list.frame)
self.long_selection = mcrfpy.Caption(
text="Long list: Option 1",
pos=(50, 690),
font_size=14,
fill_color=mcrfpy.Color(150, 150, 200)
)
self.ui.append(self.long_selection)
# Active list indicator
self.active_indicator = mcrfpy.Caption(
text="Active list: Main Menu (Tab to switch)",
pos=(300, 400),
font_size=14,
fill_color=mcrfpy.Color(200, 200, 200)
)
self.ui.append(self.active_indicator)
# Instructions
instr = mcrfpy.Caption(
text="Up/Down: Navigate | Enter: Confirm | Tab: Switch list | A: Add | R: Remove | ESC: Exit",
pos=(50, 730),
font_size=14,
fill_color=mcrfpy.Color(120, 120, 120)
)
self.ui.append(instr)
def on_main_select(self, index, value):
"""Handle main menu selection."""
self.main_selection.text = f"Selected: {value}"
def on_diff_select(self, index, value):
"""Handle difficulty selection."""
self.diff_selection.text = f"Difficulty: {value}"
def on_dynamic_select(self, index, value):
"""Handle dynamic list selection."""
self.dynamic_info.text = f"Selected: {value} (Items: {len(self.dynamic_list.choices)})"
def on_combat_select(self, index, value):
"""Handle combat menu selection."""
self.combat_selection.text = f"Action: {value}"
def on_long_select(self, index, value):
"""Handle long list selection."""
self.long_selection.text = f"Long list: {value}"
def _update_active_indicator(self):
"""Update the active list indicator."""
names = ["Main Menu", "Difficulty", "Dynamic", "Combat", "Long List"]
if self.active_list_idx < len(names):
self.active_indicator.text = f"Active list: {names[self.active_list_idx]} (Tab to switch)"
def on_key(self, key, state):
"""Handle keyboard input."""
if state != "start":
return
if key == "Escape":
sys.exit(0)
# Get active list
active = self.lists[self.active_list_idx] if self.lists else None
if key == "Up" and active:
active.navigate(-1)
elif key == "Down" and active:
active.navigate(1)
elif key == "Enter" and active:
active.confirm()
elif key == "Tab":
# Switch active list
self.active_list_idx = (self.active_list_idx + 1) % len(self.lists)
self._update_active_indicator()
elif key == "A":
# Add item to dynamic list
self.add_counter += 1
self.dynamic_list.add_choice(f"New Item {self.add_counter}")
self.dynamic_info.text = f"Items: {len(self.dynamic_list.choices)}"
elif key == "R":
# Remove selected from dynamic list
if len(self.dynamic_list.choices) > 1:
self.dynamic_list.remove_choice(self.dynamic_list.selected_index)
self.dynamic_info.text = f"Items: {len(self.dynamic_list.choices)}"
def activate(self):
"""Activate the demo scene."""
self.scene.on_key = self.on_key
mcrfpy.current_scene = self.scene
def main():
"""Run the choice list demo."""
demo = ChoiceListDemo()
demo.activate()
# Headless mode: capture screenshot and exit
try:
if mcrfpy.headless_mode():
from mcrfpy import automation
mcrfpy.Timer("screenshot", lambda rt: (
automation.screenshot("screenshots/primitives/choice_list_demo.png"),
sys.exit(0)
), 100)
except AttributeError:
pass
if __name__ == "__main__":
main()

View file

@ -0,0 +1,380 @@
#!/usr/bin/env python3
"""Click to Pick Up Demo - Toggle-based inventory interaction
Interactive controls:
Left click on item: Pick up item (cursor changes)
Left click on empty cell: Place item
Right click: Cancel pickup
ESC: Return to menu
This demonstrates:
- Click-to-toggle pickup mode (not hold-to-drag)
- Cursor sprite following mouse
- ColorLayer for cell highlighting
- Inventory organization pattern
"""
import mcrfpy
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Item data for sprites
ITEMS = [
(103, "Shortsword"),
(104, "Longsword"),
(117, "Hammer"),
(119, "Axe"),
(101, "Buckler"),
(102, "Shield"),
(115, "Health Pot"),
(116, "Mana Pot"),
(129, "Wand"),
(130, "Staff"),
(114, "Str Potion"),
(127, "Lesser HP"),
]
class ClickPickupDemo:
"""Demo showing click-to-pickup inventory interaction."""
def __init__(self):
self.scene = mcrfpy.Scene("demo_click_pickup")
self.ui = self.scene.children
self.grid = None
self.tile_layer = None
self.color_layer = None
# Pickup state
self.held_entity = None
self.pickup_cell = None
self.cursor_sprite = None
self.last_hover_cell = None
# Track occupied cells
self.occupied_cells = {} # (x, y) -> entity
self.setup()
def setup(self):
"""Build the demo UI."""
# Background
bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=(25, 20, 30))
self.ui.append(bg)
# Title
title = mcrfpy.Caption(
text="Click to Pick Up",
pos=(512, 30),
font_size=28,
fill_color=(255, 255, 255)
)
title.outline = 2
title.outline_color = (0, 0, 0)
self.ui.append(title)
# Status caption
self.status = mcrfpy.Caption(
text="Click an item to pick it up",
pos=(512, 70),
font_size=16,
fill_color=(180, 180, 180)
)
self.ui.append(self.status)
# Create inventory grid - zoom in constructor for proper centering
grid_size = (8, 6)
cell_size = 64
grid_pixel_size = (grid_size[0] * cell_size, grid_size[1] * cell_size)
grid_pos = ((1024 - grid_pixel_size[0]) // 2, 140)
self.grid = mcrfpy.Grid(
pos=grid_pos,
size=grid_pixel_size,
grid_size=grid_size,
texture=mcrfpy.default_texture,
zoom=4.0 # 16px * 4 = 64px per cell
)
# Get tile layer and fill with slot tiles
self.tile_layer = self.grid.layers[0]
self.tile_layer.fill(46) # Floor/slot tile
# Add color layer for highlighting
self.color_layer = self.grid.add_layer('color', z_index=-1)
# Initialize with slight tint
for y in range(grid_size[1]):
for x in range(grid_size[0]):
self.color_layer.set((x, y), (200, 200, 200, 50))
# Add event handlers
self.grid.on_click = self._on_grid_click
self.grid.on_cell_enter = self._on_cell_enter
self.grid.on_move = self._on_grid_move
self.ui.append(self.grid)
# Populate with items
self._populate_grid()
# Create cursor sprite (initially invisible)
# This is a Frame with a Sprite child, positioned outside the grid
self.cursor_frame = mcrfpy.Frame(
pos=(0, 0),
size=(64, 64),
fill_color=(0, 0, 0, 0), # Transparent
outline=0
)
self.cursor_sprite = mcrfpy.Sprite(
pos=(0, 0),
texture=mcrfpy.default_texture,
sprite_index=0
)
self.cursor_sprite.scale = 4.0
self.cursor_sprite.visible = False
self.cursor_frame.children.append(self.cursor_sprite)
self.ui.append(self.cursor_frame)
# Item name display
self.item_name = mcrfpy.Caption(
text="",
pos=(512, 560),
font_size=18,
fill_color=(255, 220, 100)
)
self.ui.append(self.item_name)
# Instructions
instr = mcrfpy.Caption(
text="Left click: Pick up / Place | Right click: Cancel | ESC to exit",
pos=(512, 700),
font_size=14,
fill_color=(150, 150, 150)
)
self.ui.append(instr)
def _populate_grid(self):
"""Add items to the grid."""
# Place items in a pattern
positions = [
(0, 0), (2, 0), (4, 1), (6, 0),
(1, 2), (3, 2), (5, 2), (7, 3),
(0, 4), (2, 4), (4, 5), (6, 5),
]
for i, (x, y) in enumerate(positions):
if i >= len(ITEMS):
break
sprite_idx, name = ITEMS[i]
entity = mcrfpy.Entity()
self.grid.entities.append(entity)
entity.grid_pos = (x, y) # Use grid_pos for tile coordinates
entity.sprite_index = sprite_idx
entity.name = name # Store name for display
self.occupied_cells[(x, y)] = entity
def _get_grid_cell(self, screen_pos):
"""Convert screen position to grid cell coordinates."""
cell_size = 16 * self.grid.zoom
x = int((screen_pos[0] - self.grid.x) / cell_size)
y = int((screen_pos[1] - self.grid.y) / cell_size)
grid_w, grid_h = self.grid.grid_size
if 0 <= x < grid_w and 0 <= y < grid_h:
return (x, y)
return None
def _on_grid_click(self, pos, button, action):
"""Handle grid click."""
if action != "start":
return
cell = self._get_grid_cell(pos)
if cell is None:
return
x, y = cell
if button == "right":
# Cancel pickup
if self.held_entity:
self._cancel_pickup()
return
if button != "left":
return
if self.held_entity is None:
# Try to pick up
if cell in self.occupied_cells:
self._pickup_item(cell)
else:
# Try to place
if cell not in self.occupied_cells:
self._place_item(cell)
elif cell == self.pickup_cell:
# Clicked on original cell - cancel
self._cancel_pickup()
def _pickup_item(self, cell):
"""Pick up item from cell."""
entity = self.occupied_cells[cell]
self.held_entity = entity
self.pickup_cell = cell
# Hide the entity
entity.visible = False
# Mark source cell yellow
self.color_layer.set(cell, (255, 255, 100, 200))
# Setup cursor sprite
self.cursor_sprite.sprite_index = entity.sprite_index
self.cursor_sprite.visible = True
# Update status
name = getattr(entity, 'name', 'Item')
self.status.text = f"Holding: {name}"
self.status.fill_color = (100, 200, 255)
self.item_name.text = name
def _place_item(self, cell):
"""Place held item in cell."""
x, y = cell
# Move entity to new position
self.held_entity.grid_pos = (x, y)
self.held_entity.visible = True
# Update tracking
del self.occupied_cells[self.pickup_cell]
self.occupied_cells[cell] = self.held_entity
# Clear source cell highlight
self.color_layer.set(self.pickup_cell, (200, 200, 200, 50))
# Clear hover highlight
if self.last_hover_cell and self.last_hover_cell != self.pickup_cell:
self.color_layer.set(self.last_hover_cell, (200, 200, 200, 50))
# Hide cursor
self.cursor_sprite.visible = False
# Update status
self.status.text = f"Placed at ({x}, {y})"
self.status.fill_color = (100, 255, 100)
self.item_name.text = ""
self.held_entity = None
self.pickup_cell = None
self.last_hover_cell = None
def _cancel_pickup(self):
"""Cancel current pickup operation."""
if self.held_entity:
# Restore entity visibility
self.held_entity.visible = True
# Clear source cell highlight
self.color_layer.set(self.pickup_cell, (200, 200, 200, 50))
# Clear hover highlight
if self.last_hover_cell and self.last_hover_cell != self.pickup_cell:
self.color_layer.set(self.last_hover_cell, (200, 200, 200, 50))
# Hide cursor
self.cursor_sprite.visible = False
self.status.text = "Cancelled"
self.status.fill_color = (200, 150, 100)
self.item_name.text = ""
self.held_entity = None
self.pickup_cell = None
self.last_hover_cell = None
def _on_cell_enter(self, cell_pos):
"""Handle cell hover."""
x, y = int(cell_pos[0]), int(cell_pos[1])
cell = (x, y)
# Show item name on hover (when not holding)
if self.held_entity is None:
if cell in self.occupied_cells:
entity = self.occupied_cells[cell]
name = getattr(entity, 'name', 'Item')
self.item_name.text = name
else:
self.item_name.text = ""
return
# Clear previous hover highlight (if different from source)
if self.last_hover_cell and self.last_hover_cell != self.pickup_cell:
self.color_layer.set(self.last_hover_cell, (200, 200, 200, 50))
# Highlight current cell (if different from source)
if cell != self.pickup_cell:
if cell in self.occupied_cells:
self.color_layer.set(cell, (255, 100, 100, 200)) # Red - can't place
else:
self.color_layer.set(cell, (100, 255, 100, 200)) # Green - can place
self.last_hover_cell = cell
def _on_grid_move(self, pos):
"""Update cursor sprite position.
Note: #230 - on_move now only receives position, not button/action
"""
if self.cursor_sprite.visible:
# Position cursor centered on mouse
self.cursor_frame.x = pos[0] - 32
self.cursor_frame.y = pos[1] - 32
def on_key(self, key, state):
"""Handle keyboard input."""
if state != "start":
return
if key == "Escape":
if self.held_entity:
self._cancel_pickup()
return
try:
from cookbook_main import main
main()
except:
sys.exit(0)
def activate(self):
"""Activate the demo scene."""
self.scene.on_key = self.on_key
mcrfpy.current_scene = self.scene
def main():
"""Run the demo."""
demo = ClickPickupDemo()
demo.activate()
# Headless screenshot
try:
if mcrfpy.headless_mode():
from mcrfpy import automation
# Simulate picking up an item for screenshot
demo._pickup_item((0, 0))
demo.cursor_frame.x = 300
demo.cursor_frame.y = 350
mcrfpy.Timer("screenshot", lambda rt: (
automation.screenshot("screenshots/primitives/click_pickup.png"),
sys.exit(0)
), 100)
except AttributeError:
pass
if __name__ == "__main__":
main()

View file

@ -0,0 +1,270 @@
#!/usr/bin/env python3
"""Drag and Drop (Grid) Demo - Drag entities between grid cells
Interactive controls:
Left click + drag: Move entity to new cell
ESC: Return to menu
This demonstrates:
- Grid entity dragging with on_click and on_cell_enter
- ColorLayer for cell highlighting
- Collision detection (can't drop on occupied cells)
- Visual feedback during drag
"""
import mcrfpy
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# Item data for sprites
ITEMS = [
(103, "Shortsword"), # +1 atk
(104, "Longsword"), # +2 atk
(117, "Hammer"), # +2 atk
(119, "Axe"), # +3 atk
(101, "Buckler"), # +1 def
(102, "Shield"), # +2 def
(115, "Health Pot"),
(116, "Mana Pot"),
(129, "Wand"), # +1 atk, +4 int
(114, "Str Potion"),
]
class GridDragDropDemo:
"""Demo showing entity drag and drop on a grid."""
def __init__(self):
self.scene = mcrfpy.Scene("demo_drag_drop_grid")
self.ui = self.scene.children
self.grid = None
self.tile_layer = None
self.color_layer = None
self.dragging_entity = None
self.drag_start_cell = None
self.occupied_cells = set() # Track which cells have entities
self.setup()
def setup(self):
"""Build the demo UI."""
# Background
bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=(20, 25, 30))
self.ui.append(bg)
# Title
title = mcrfpy.Caption(
text="Grid Drag & Drop",
pos=(512, 30),
font_size=28,
fill_color=(255, 255, 255)
)
title.outline = 2
title.outline_color = (0, 0, 0)
self.ui.append(title)
# Status caption
self.status = mcrfpy.Caption(
text="Click and drag items to rearrange",
pos=(512, 70),
font_size=16,
fill_color=(180, 180, 180)
)
self.ui.append(self.status)
# Create grid - zoom in constructor for proper centering
grid_size = (10, 8)
cell_size = 48
grid_pixel_size = (grid_size[0] * cell_size, grid_size[1] * cell_size)
grid_pos = ((1024 - grid_pixel_size[0]) // 2, 150)
self.grid = mcrfpy.Grid(
pos=grid_pos,
size=grid_pixel_size,
grid_size=grid_size,
texture=mcrfpy.default_texture,
zoom=3.0 # Each cell is 16px * 3 = 48px
)
# Get tile layer and fill with floor tiles
self.tile_layer = self.grid.layers[0]
self.tile_layer.fill(46) # Floor tile
# Add color layer for highlighting (above tiles, below entities)
self.color_layer = self.grid.add_layer('color', z_index=-1)
# Add event handlers
self.grid.on_click = self._on_grid_click
self.grid.on_cell_enter = self._on_cell_enter
self.ui.append(self.grid)
# Add some entities to the grid
self._populate_grid()
# Instructions
instr = mcrfpy.Caption(
text="Click to pick up, drag to move, release to drop | Red = occupied | ESC to exit",
pos=(512, 700),
font_size=14,
fill_color=(150, 150, 150)
)
self.ui.append(instr)
def _populate_grid(self):
"""Add entities to the grid in a scattered pattern."""
# Place items at various positions
positions = [
(1, 1), (3, 1), (5, 2), (7, 1),
(2, 4), (4, 3), (6, 5), (8, 4),
(1, 6), (5, 6)
]
for i, (x, y) in enumerate(positions):
if i >= len(ITEMS):
break
sprite_idx, name = ITEMS[i]
entity = mcrfpy.Entity()
self.grid.entities.append(entity)
entity.grid_pos = (x, y) # Use grid_pos for tile coordinates
entity.sprite_index = sprite_idx
self.occupied_cells.add((x, y))
def _get_entity_at(self, x, y):
"""Get entity at grid position, or None."""
for entity in self.grid.entities:
gp = entity.grid_pos
ex, ey = int(gp[0]), int(gp[1])
if ex == x and ey == y:
return entity
return None
def _on_grid_click(self, pos, button, action):
"""Handle grid click for drag start/end."""
if button != "left":
return
# Convert screen pos to grid cell
grid_x = int((pos[0] - self.grid.x) / (16 * self.grid.zoom))
grid_y = int((pos[1] - self.grid.y) / (16 * self.grid.zoom))
# Bounds check
grid_w, grid_h = self.grid.grid_size
if not (0 <= grid_x < grid_w and 0 <= grid_y < grid_h):
return
if action == "start":
# Start drag if there's an entity here
entity = self._get_entity_at(grid_x, grid_y)
if entity:
self.dragging_entity = entity
self.drag_start_cell = (grid_x, grid_y)
self.status.text = f"Dragging from ({grid_x}, {grid_y})"
self.status.fill_color = (100, 200, 255)
# Highlight start cell yellow
self.color_layer.set((grid_x, grid_y), (255, 255, 100, 200))
elif action == "end":
if self.dragging_entity:
# Drop the entity
target_cell = (grid_x, grid_y)
if target_cell == self.drag_start_cell:
# Dropped in same cell - no change
self.status.text = "Cancelled - same cell"
elif target_cell in self.occupied_cells:
# Can't drop on occupied cell
self.status.text = f"Can't drop on occupied cell ({grid_x}, {grid_y})"
self.status.fill_color = (255, 100, 100)
else:
# Valid drop - move entity
self.occupied_cells.discard(self.drag_start_cell)
self.occupied_cells.add(target_cell)
self.dragging_entity.grid_pos = target_cell
self.status.text = f"Moved to ({grid_x}, {grid_y})"
self.status.fill_color = (100, 255, 100)
# Clear all highlights
self._clear_highlights()
self.dragging_entity = None
self.drag_start_cell = None
def _on_cell_enter(self, cell_pos):
"""Handle cell hover during drag."""
if not self.dragging_entity:
return
x, y = int(cell_pos[0]), int(cell_pos[1])
# Clear previous highlights (except start cell)
self._clear_highlights()
# Re-highlight start cell
if self.drag_start_cell:
self.color_layer.set(self.drag_start_cell, (255, 255, 100, 200))
# Highlight current cell
if (x, y) != self.drag_start_cell:
if (x, y) in self.occupied_cells:
self.color_layer.set((x, y), (255, 100, 100, 200)) # Red - can't drop
else:
self.color_layer.set((x, y), (100, 255, 100, 200)) # Green - can drop
# Move entity preview
self.dragging_entity.grid_pos = (x, y)
def _clear_highlights(self):
"""Clear all cell color highlights."""
grid_w, grid_h = self.grid.grid_size
for y in range(grid_h):
for x in range(grid_w):
self.color_layer.set((x, y), (0, 0, 0, 0))
def on_key(self, key, state):
"""Handle keyboard input."""
if state != "start":
return
if key == "Escape":
# Cancel any drag in progress
if self.dragging_entity and self.drag_start_cell:
self.dragging_entity.grid_pos = self.drag_start_cell
self._clear_highlights()
self.dragging_entity = None
self.drag_start_cell = None
self.status.text = "Drag cancelled"
return
# Return to cookbook menu or exit
try:
from cookbook_main import main
main()
except:
sys.exit(0)
def activate(self):
"""Activate the demo scene."""
self.scene.on_key = self.on_key
mcrfpy.current_scene = self.scene
def main():
"""Run the demo."""
demo = GridDragDropDemo()
demo.activate()
# Headless screenshot
try:
if mcrfpy.headless_mode():
from mcrfpy import automation
mcrfpy.Timer("screenshot", lambda rt: (
automation.screenshot("screenshots/primitives/drag_drop_grid.png"),
sys.exit(0)
), 100)
except AttributeError:
pass
if __name__ == "__main__":
main()

View file

@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""Stat Bar Widget Demo - Progress bars for health, mana, XP, etc.
Interactive controls:
1-4: Decrease stat bars
Shift+1-4: Increase stat bars
F: Flash the health bar
R: Reset all bars
ESC: Exit demo
"""
import mcrfpy
import sys
# Add parent to path for imports
sys.path.insert(0, str(__file__).rsplit('/', 2)[0])
from lib.stat_bar import StatBar, create_stat_bar_group
class StatBarDemo:
def __init__(self):
self.scene = mcrfpy.Scene("stat_bar_demo")
self.ui = self.scene.children
self.bars = {}
self.setup()
def setup(self):
"""Build the demo scene."""
# Background
bg = mcrfpy.Frame(
pos=(0, 0),
size=(1024, 768),
fill_color=mcrfpy.Color(20, 20, 25)
)
self.ui.append(bg)
# Title
title = mcrfpy.Caption(
text="Stat Bar Widget Demo",
pos=(512, 30),
font_size=28,
fill_color=mcrfpy.Color(255, 255, 255)
)
title.outline = 2
title.outline_color = mcrfpy.Color(0, 0, 0)
self.ui.append(title)
# Section 1: Basic stat bars with labels
section1_label = mcrfpy.Caption(
text="Character Stats (press 1-4 to decrease, Shift+1-4 to increase)",
pos=(50, 90),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section1_label)
# Health bar
self.bars['hp'] = StatBar(
pos=(50, 120),
size=(250, 25),
current=75,
maximum=100,
fill_color=StatBar.HEALTH_COLOR,
label="HP"
)
self.ui.append(self.bars['hp'].frame)
# Mana bar
self.bars['mp'] = StatBar(
pos=(50, 155),
size=(250, 25),
current=50,
maximum=80,
fill_color=StatBar.MANA_COLOR,
label="MP"
)
self.ui.append(self.bars['mp'].frame)
# Stamina bar
self.bars['stamina'] = StatBar(
pos=(50, 190),
size=(250, 25),
current=90,
maximum=100,
fill_color=StatBar.STAMINA_COLOR,
label="Stamina"
)
self.ui.append(self.bars['stamina'].frame)
# XP bar
self.bars['xp'] = StatBar(
pos=(50, 225),
size=(250, 25),
current=250,
maximum=1000,
fill_color=StatBar.XP_COLOR,
label="XP"
)
self.ui.append(self.bars['xp'].frame)
# Section 2: Different sizes
section2_label = mcrfpy.Caption(
text="Different Sizes",
pos=(50, 290),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section2_label)
# Thin bar
thin_bar = StatBar(
pos=(50, 320),
size=(200, 10),
current=60,
maximum=100,
fill_color=mcrfpy.Color(100, 150, 200),
show_text=False
)
self.ui.append(thin_bar.frame)
thin_label = mcrfpy.Caption(
text="Thin (no text)",
pos=(260, 315),
font_size=12,
fill_color=mcrfpy.Color(120, 120, 120)
)
self.ui.append(thin_label)
# Wide bar
wide_bar = StatBar(
pos=(50, 345),
size=(400, 35),
current=450,
maximum=500,
fill_color=StatBar.SHIELD_COLOR,
label="Shield",
font_size=16
)
self.ui.append(wide_bar.frame)
# Section 3: Stat bar group
section3_label = mcrfpy.Caption(
text="Stat Bar Group (auto-layout)",
pos=(500, 90),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section3_label)
group = create_stat_bar_group([
{"name": "Strength", "current": 15, "max": 20, "color": mcrfpy.Color(200, 80, 80)},
{"name": "Dexterity", "current": 18, "max": 20, "color": mcrfpy.Color(80, 200, 80)},
{"name": "Intelligence", "current": 12, "max": 20, "color": mcrfpy.Color(80, 80, 200)},
{"name": "Wisdom", "current": 14, "max": 20, "color": mcrfpy.Color(200, 200, 80)},
{"name": "Charisma", "current": 10, "max": 20, "color": mcrfpy.Color(200, 80, 200)},
], start_pos=(500, 120), spacing=10, size=(220, 22))
for bar in group.values():
self.ui.append(bar.frame)
# Section 4: Edge cases
section4_label = mcrfpy.Caption(
text="Edge Cases",
pos=(50, 420),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section4_label)
# Empty bar
empty_bar = StatBar(
pos=(50, 450),
size=(200, 20),
current=0,
maximum=100,
fill_color=StatBar.HEALTH_COLOR,
label="Empty"
)
self.ui.append(empty_bar.frame)
# Full bar
full_bar = StatBar(
pos=(50, 480),
size=(200, 20),
current=100,
maximum=100,
fill_color=StatBar.STAMINA_COLOR,
label="Full"
)
self.ui.append(full_bar.frame)
# Overfill attempt (should clamp)
overfill_bar = StatBar(
pos=(50, 510),
size=(200, 20),
current=150, # Will be clamped to 100
maximum=100,
fill_color=StatBar.XP_COLOR,
label="Overfill"
)
self.ui.append(overfill_bar.frame)
# Section 5: Animation demo
section5_label = mcrfpy.Caption(
text="Animation Demo (watch the bars change)",
pos=(500, 290),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section5_label)
self.anim_bar = StatBar(
pos=(500, 320),
size=(250, 30),
current=50,
maximum=100,
fill_color=mcrfpy.Color(150, 100, 200),
label="Animated"
)
self.ui.append(self.anim_bar.frame)
# Start animation loop
self._anim_direction = 1
mcrfpy.Timer("anim_bar", self._animate_bar, 2000)
# Section 6: Flash effect
section6_label = mcrfpy.Caption(
text="Flash Effect (press F)",
pos=(500, 400),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section6_label)
self.flash_bar = StatBar(
pos=(500, 430),
size=(250, 30),
current=80,
maximum=100,
fill_color=StatBar.HEALTH_COLOR,
label="Flash Me"
)
self.ui.append(self.flash_bar.frame)
# Instructions
instr = mcrfpy.Caption(
text="1-4: Decrease bars | Shift+1-4: Increase bars | F: Flash | R: Reset | ESC: Exit",
pos=(50, 730),
font_size=14,
fill_color=mcrfpy.Color(120, 120, 120)
)
self.ui.append(instr)
# Status display
self.status = mcrfpy.Caption(
text="Status: Ready",
pos=(50, 600),
font_size=16,
fill_color=mcrfpy.Color(100, 200, 100)
)
self.ui.append(self.status)
def _animate_bar(self, runtime):
"""Animate the demo bar back and forth."""
current = self.anim_bar.current
if self._anim_direction > 0:
new_val = min(100, current + 30)
if new_val >= 100:
self._anim_direction = -1
else:
new_val = max(10, current - 30)
if new_val <= 10:
self._anim_direction = 1
self.anim_bar.set_value(new_val, animate=True)
def on_key(self, key, state):
"""Handle keyboard input."""
if state != "start":
return
if key == "Escape":
sys.exit(0)
# Number keys to modify bars
bar_keys = ['hp', 'mp', 'stamina', 'xp']
key_map = {"Num1": 0, "Num2": 1, "Num3": 2, "Num4": 3}
if key in key_map:
idx = key_map[key]
if idx < len(bar_keys):
bar = self.bars[bar_keys[idx]]
# Decrease by 10
bar.set_value(bar.current - 10, animate=True)
self.status.text = f"Status: Decreased {bar_keys[idx].upper()}"
elif key == "F":
self.flash_bar.flash()
self.status.text = "Status: Flash effect triggered!"
elif key == "R":
# Reset all bars
self.bars['hp'].set_value(75, 100, animate=True)
self.bars['mp'].set_value(50, 80, animate=True)
self.bars['stamina'].set_value(90, 100, animate=True)
self.bars['xp'].set_value(250, 1000, animate=True)
self.status.text = "Status: All bars reset"
def activate(self):
"""Activate the demo scene."""
self.scene.on_key = self.on_key
mcrfpy.current_scene = self.scene
def main():
"""Run the stat bar demo."""
demo = StatBarDemo()
demo.activate()
# Headless mode: capture screenshot and exit
try:
if mcrfpy.headless_mode():
from mcrfpy import automation
mcrfpy.Timer("screenshot", lambda rt: (
automation.screenshot("screenshots/primitives/stat_bar_demo.png"),
sys.exit(0)
), 100)
except AttributeError:
pass
if __name__ == "__main__":
main()

View file

@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""Text Box Widget Demo - Word-wrapped text with typewriter effect
Interactive controls:
1: Show typewriter text
2: Show instant text
3: Skip animation
4: Clear text
D: Toggle dialogue mode
ESC: Exit demo
"""
import mcrfpy
import sys
# Add parent to path for imports
sys.path.insert(0, str(__file__).rsplit('/', 2)[0])
from lib.text_box import TextBox, DialogueBox
class TextBoxDemo:
def __init__(self):
self.scene = mcrfpy.Scene("text_box_demo")
self.ui = self.scene.children
self.setup()
def setup(self):
"""Build the demo scene."""
# Background
bg = mcrfpy.Frame(
pos=(0, 0),
size=(1024, 768),
fill_color=mcrfpy.Color(20, 20, 25)
)
self.ui.append(bg)
# Title
title = mcrfpy.Caption(
text="Text Box Widget Demo",
pos=(512, 30),
font_size=28,
fill_color=mcrfpy.Color(255, 255, 255)
)
title.outline = 2
title.outline_color = mcrfpy.Color(0, 0, 0)
self.ui.append(title)
# Section 1: Basic text box with typewriter
section1_label = mcrfpy.Caption(
text="Typewriter Effect (press 1 to play)",
pos=(50, 80),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section1_label)
self.typewriter_box = TextBox(
pos=(50, 110),
size=(400, 120),
text="",
chars_per_second=40
)
self.ui.append(self.typewriter_box.frame)
self.sample_text = (
"Welcome to McRogueFace! This is a demonstration of the "
"typewriter effect. Each character appears one at a time, "
"creating a classic RPG dialogue feel. You can adjust the "
"speed by changing the chars_per_second parameter."
)
# Completion indicator
self.completion_label = mcrfpy.Caption(
text="Status: Ready",
pos=(50, 240),
font_size=12,
fill_color=mcrfpy.Color(100, 100, 100)
)
self.ui.append(self.completion_label)
# Section 2: Instant text
section2_label = mcrfpy.Caption(
text="Instant Text (press 2 to change)",
pos=(500, 80),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section2_label)
self.instant_box = TextBox(
pos=(500, 110),
size=(450, 120),
text="This text appeared instantly. Press 2 to change it to different content.",
chars_per_second=0 # Instant display
)
self.ui.append(self.instant_box.frame)
# Section 3: Dialogue box with speaker
section3_label = mcrfpy.Caption(
text="Dialogue Box (press D to cycle speakers)",
pos=(50, 290),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section3_label)
self.dialogue_box = DialogueBox(
pos=(50, 320),
size=(600, 150),
speaker="Elder Sage",
text="Greetings, adventurer. I have been expecting you. The ancient prophecy speaks of one who would come to restore balance to our world.",
chars_per_second=35
)
self.ui.append(self.dialogue_box.frame)
self.dialogue_index = 0
self.dialogues = [
("Elder Sage", "Greetings, adventurer. I have been expecting you. The ancient prophecy speaks of one who would come to restore balance to our world."),
("Hero", "I'm not sure I'm the right person for this task. What exactly must I do?"),
("Elder Sage", "You must journey to the Forgotten Temple and retrieve the Crystal of Dawn. Only its light can dispel the darkness that threatens our land."),
("Mysterious Voice", "Beware... the path is fraught with danger. Many have tried and failed before you..."),
("Hero", "I accept this quest. Point me to the temple, and I shall not rest until the crystal is recovered!"),
]
# Section 4: Different styles
section4_label = mcrfpy.Caption(
text="Custom Styles",
pos=(50, 500),
font_size=16,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(section4_label)
# Dark theme
dark_box = TextBox(
pos=(50, 530),
size=(280, 100),
text="Dark theme with light text. Good for mysterious or ominous messages.",
chars_per_second=0,
bg_color=mcrfpy.Color(10, 10, 15),
text_color=mcrfpy.Color(180, 180, 200),
outline_color=mcrfpy.Color(60, 60, 80)
)
self.ui.append(dark_box.frame)
# Warning theme
warning_box = TextBox(
pos=(350, 530),
size=(280, 100),
text="Warning theme! Use for important alerts or danger notifications.",
chars_per_second=0,
bg_color=mcrfpy.Color(80, 40, 20),
text_color=mcrfpy.Color(255, 200, 100),
outline_color=mcrfpy.Color(200, 100, 50)
)
self.ui.append(warning_box.frame)
# System theme
system_box = TextBox(
pos=(650, 530),
size=(280, 100),
text="[SYSTEM] Connection established. Loading game data...",
chars_per_second=0,
bg_color=mcrfpy.Color(20, 40, 30),
text_color=mcrfpy.Color(100, 255, 150),
outline_color=mcrfpy.Color(50, 150, 80)
)
self.ui.append(system_box.frame)
# Instructions
instr = mcrfpy.Caption(
text="1: Play typewriter | 2: Change instant text | 3: Skip | 4: Clear | D: Next dialogue | ESC: Exit",
pos=(50, 730),
font_size=14,
fill_color=mcrfpy.Color(120, 120, 120)
)
self.ui.append(instr)
def on_typewriter_complete(self):
"""Called when typewriter animation finishes."""
self.completion_label.text = "Status: Animation complete!"
self.completion_label.fill_color = mcrfpy.Color(100, 200, 100)
def on_key(self, key, state):
"""Handle keyboard input."""
if state != "start":
return
if key == "Escape":
sys.exit(0)
elif key == "Num1":
# Start typewriter animation
self.typewriter_box.on_complete = self.on_typewriter_complete
self.typewriter_box.set_text(self.sample_text, animate=True)
self.completion_label.text = "Status: Playing..."
self.completion_label.fill_color = mcrfpy.Color(200, 200, 100)
elif key == "Num2":
# Change instant text
texts = [
"This text appeared instantly. Press 2 to change it to different content.",
"Here's some different content! Text boxes can hold any message you want.",
"The quick brown fox jumps over the lazy dog. Perfect for testing fonts!",
"Lorem ipsum dolor sit amet, consectetur adipiscing elit. Classic placeholder text.",
]
import random
self.instant_box.set_text(random.choice(texts), animate=False)
elif key == "Num3":
# Skip animation
self.typewriter_box.skip_animation()
self.completion_label.text = "Status: Skipped"
self.completion_label.fill_color = mcrfpy.Color(150, 150, 150)
elif key == "Num4":
# Clear text
self.typewriter_box.clear()
self.completion_label.text = "Status: Cleared"
self.completion_label.fill_color = mcrfpy.Color(150, 150, 150)
elif key == "D":
# Cycle dialogue
self.dialogue_index = (self.dialogue_index + 1) % len(self.dialogues)
speaker, text = self.dialogues[self.dialogue_index]
self.dialogue_box.set_dialogue(speaker, text, animate=True)
def activate(self):
"""Activate the demo scene."""
self.scene.on_key = self.on_key
mcrfpy.current_scene = self.scene
def main():
"""Run the text box demo."""
demo = TextBoxDemo()
demo.activate()
# Headless mode: capture screenshot and exit
try:
if mcrfpy.headless_mode():
from mcrfpy import automation
# Trigger typewriter then screenshot
demo.typewriter_box.set_text(demo.sample_text[:50], animate=False)
mcrfpy.Timer("screenshot", lambda rt: (
automation.screenshot("screenshots/primitives/text_box_demo.png"),
sys.exit(0)
), 100)
except AttributeError:
pass
if __name__ == "__main__":
main()

View file

@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""Toast Notification Demo - Auto-dismissing notification popups
Interactive controls:
1: Show default toast
2: Show success toast (green)
3: Show error toast (red)
4: Show warning toast (yellow)
5: Show info toast (blue)
S: Spam multiple toasts
C: Clear all toasts
ESC: Exit demo
"""
import mcrfpy
import sys
# Add parent to path for imports
sys.path.insert(0, str(__file__).rsplit('/', 2)[0])
from lib.toast import ToastManager
class ToastDemo:
def __init__(self):
self.scene = mcrfpy.Scene("toast_demo")
self.ui = self.scene.children
self.toast_count = 0
self.setup()
def setup(self):
"""Build the demo scene."""
# Background
bg = mcrfpy.Frame(
pos=(0, 0),
size=(1024, 768),
fill_color=mcrfpy.Color(20, 20, 25)
)
self.ui.append(bg)
# Title
title = mcrfpy.Caption(
text="Toast Notification Demo",
pos=(512, 30),
font_size=28,
fill_color=mcrfpy.Color(255, 255, 255)
)
title.outline = 2
title.outline_color = mcrfpy.Color(0, 0, 0)
self.ui.append(title)
# Create toast manager
self.toasts = ToastManager(self.scene, position="top-right", max_toasts=5)
# Instructions panel
panel = mcrfpy.Frame(
pos=(50, 100),
size=(400, 400),
fill_color=mcrfpy.Color(30, 30, 40),
outline_color=mcrfpy.Color(60, 60, 80),
outline=1
)
self.ui.append(panel)
panel_title = mcrfpy.Caption(
text="Toast Types",
pos=(200, 15),
font_size=18,
fill_color=mcrfpy.Color(200, 200, 200)
)
panel.children.append(panel_title)
# Type descriptions
types = [
("1 - Default", "Standard notification", mcrfpy.Color(200, 200, 200)),
("2 - Success", "Confirmation messages", mcrfpy.Color(100, 200, 100)),
("3 - Error", "Error notifications", mcrfpy.Color(200, 100, 100)),
("4 - Warning", "Warning alerts", mcrfpy.Color(200, 180, 80)),
("5 - Info", "Informational messages", mcrfpy.Color(100, 150, 200)),
]
for i, (key, desc, color) in enumerate(types):
y = 50 + i * 50
key_label = mcrfpy.Caption(
text=key,
pos=(20, y),
font_size=16,
fill_color=color
)
panel.children.append(key_label)
desc_label = mcrfpy.Caption(
text=desc,
pos=(20, y + 20),
font_size=12,
fill_color=mcrfpy.Color(150, 150, 150)
)
panel.children.append(desc_label)
# Additional controls
controls = [
("S - Spam", "Show multiple toasts quickly"),
("C - Clear", "Dismiss all active toasts"),
]
for i, (key, desc) in enumerate(controls):
y = 300 + i * 40
key_label = mcrfpy.Caption(
text=key,
pos=(20, y),
font_size=14,
fill_color=mcrfpy.Color(180, 180, 180)
)
panel.children.append(key_label)
desc_label = mcrfpy.Caption(
text=desc,
pos=(20, y + 18),
font_size=12,
fill_color=mcrfpy.Color(120, 120, 120)
)
panel.children.append(desc_label)
# Stats display
self.stats_label = mcrfpy.Caption(
text="Toasts shown: 0 | Active: 0",
pos=(50, 520),
font_size=14,
fill_color=mcrfpy.Color(150, 150, 150)
)
self.ui.append(self.stats_label)
# Preview area
preview_label = mcrfpy.Caption(
text="Toasts appear in the top-right corner ->",
pos=(500, 200),
font_size=16,
fill_color=mcrfpy.Color(100, 100, 100)
)
self.ui.append(preview_label)
arrow = mcrfpy.Caption(
text=">>>",
pos=(750, 200),
font_size=24,
fill_color=mcrfpy.Color(100, 100, 100)
)
self.ui.append(arrow)
# Instructions
instr = mcrfpy.Caption(
text="Press 1-5 to show different toast types | S: Spam | C: Clear all | ESC: Exit",
pos=(50, 730),
font_size=14,
fill_color=mcrfpy.Color(120, 120, 120)
)
self.ui.append(instr)
def update_stats(self):
"""Update the stats display."""
active = len([t for t in self.toasts.toasts if not t.is_dismissed])
self.stats_label.text = f"Toasts shown: {self.toast_count} | Active: {active}"
def on_key(self, key, state):
"""Handle keyboard input."""
if state != "start":
return
if key == "Escape":
sys.exit(0)
elif key == "Num1":
self.toast_count += 1
self.toasts.show(f"Default notification #{self.toast_count}")
self.update_stats()
elif key == "Num2":
self.toast_count += 1
self.toasts.show_success("Operation completed successfully!")
self.update_stats()
elif key == "Num3":
self.toast_count += 1
self.toasts.show_error("An error occurred!")
self.update_stats()
elif key == "Num4":
self.toast_count += 1
self.toasts.show_warning("Warning: Low health!")
self.update_stats()
elif key == "Num5":
self.toast_count += 1
self.toasts.show_info("New quest available")
self.update_stats()
elif key == "S":
# Spam multiple toasts
messages = [
"Game saved!",
"Achievement unlocked!",
"New item acquired!",
"Level up!",
"Quest complete!",
]
for msg in messages:
self.toast_count += 1
self.toasts.show(msg)
self.update_stats()
elif key == "C":
self.toasts.dismiss_all()
self.update_stats()
def activate(self):
"""Activate the demo scene."""
self.scene.on_key = self.on_key
mcrfpy.current_scene = self.scene
def main():
"""Run the toast demo."""
demo = ToastDemo()
demo.activate()
# Headless mode: show some toasts and screenshot
try:
if mcrfpy.headless_mode():
from mcrfpy import automation
# Show a few sample toasts
demo.toasts.show("Game saved!")
demo.toasts.show_success("Achievement unlocked!")
demo.toasts.show_error("Connection lost!")
mcrfpy.Timer("screenshot", lambda rt: (
automation.screenshot("screenshots/primitives/toast_demo.png"),
sys.exit(0)
), 500)
except AttributeError:
pass
if __name__ == "__main__":
main()

View file

@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""Capture all cookbook screenshots.
This script captures screenshots for all demos in the cookbook.
Run with:
cd build && ./mcrogueface --headless --exec ../tests/cookbook/run_screenshots.py
Output goes to:
tests/cookbook/screenshots/
primitives/
features/
apps/
compound/
"""
import mcrfpy
from mcrfpy import automation
import sys
import os
# Add cookbook to path
COOKBOOK_DIR = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, COOKBOOK_DIR)
# Output directories
SCREENSHOT_BASE = os.path.join(COOKBOOK_DIR, "screenshots")
def ensure_dirs():
"""Create screenshot directories."""
dirs = ["primitives", "features", "apps", "compound"]
for d in dirs:
os.makedirs(os.path.join(SCREENSHOT_BASE, d), exist_ok=True)
def capture_demo(module_path, output_name, category):
"""Capture a single demo screenshot.
Args:
module_path: Dotted module path (e.g., "primitives.demo_button")
output_name: Output filename without extension
category: Subdirectory (e.g., "primitives")
Returns:
True if successful, False otherwise
"""
try:
# Import the module
parts = module_path.rsplit('.', 1)
if len(parts) == 2:
parent, name = parts
module = __import__(module_path, fromlist=[name])
else:
module = __import__(module_path)
# Find the Demo class
demo_class = None
for attr_name in dir(module):
attr = getattr(module, attr_name)
if isinstance(attr, type) and 'Demo' in attr_name:
demo_class = attr
break
if not demo_class:
print(f" No Demo class in {module_path}")
return False
# Create and activate demo
demo = demo_class()
demo.activate()
# Take screenshot
output_path = os.path.join(SCREENSHOT_BASE, category, f"{output_name}.png")
automation.screenshot(output_path)
print(f" OK: {output_path}")
return True
except Exception as e:
print(f" FAIL: {module_path} - {e}")
return False
def main():
"""Main screenshot capture routine."""
print("=" * 60)
print("McRogueFace Cookbook - Screenshot Capture")
print("=" * 60)
ensure_dirs()
# Define all demos to capture
demos = [
# Primitives
("primitives.demo_button", "button", "primitives"),
("primitives.demo_stat_bar", "stat_bar", "primitives"),
("primitives.demo_choice_list", "choice_list", "primitives"),
("primitives.demo_text_box", "text_box", "primitives"),
("primitives.demo_toast", "toast", "primitives"),
("primitives.demo_drag_drop_frame", "drag_drop_frame", "primitives"),
("primitives.demo_drag_drop_grid", "drag_drop_grid", "primitives"),
("primitives.demo_click_pickup", "click_pickup", "primitives"),
# Features
("features.demo_animation_chain", "animation_chain", "features"),
("features.demo_shaders", "shaders", "features"),
("features.demo_rotation", "rotation", "features"),
# Apps
("apps.calculator", "calculator", "apps"),
("apps.dialogue_system", "dialogue_system", "apps"),
# Compound
("compound.shop_demo", "shop_demo", "compound"),
]
success = 0
failed = 0
for module_path, output_name, category in demos:
print(f"\nCapturing: {output_name}")
if capture_demo(module_path, output_name, category):
success += 1
else:
failed += 1
print("\n" + "=" * 60)
print(f"Complete! Success: {success}, Failed: {failed}")
print(f"Screenshots saved to: {SCREENSHOT_BASE}")
print("=" * 60)
sys.exit(0 if failed == 0 else 1)
if __name__ == "__main__":
# Use a timer to ensure the scene system is ready
mcrfpy.Timer("run_capture", lambda rt: main(), 50)

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB