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>
15
tests/cookbook/__init__.py
Normal 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
|
||||||
|
"""
|
||||||
15
tests/cookbook/apps/__init__.py
Normal 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
|
||||||
|
"""
|
||||||
320
tests/cookbook/apps/calculator.py
Normal 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()
|
||||||
497
tests/cookbook/apps/dialogue_system.py
Normal 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()
|
||||||
10
tests/cookbook/automation/__init__.py
Normal 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
|
||||||
|
"""
|
||||||
117
tests/cookbook/automation/capture_primitives.py
Normal 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()
|
||||||
11
tests/cookbook/compound/__init__.py
Normal 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.
|
||||||
|
"""
|
||||||
445
tests/cookbook/compound/shop_demo.py
Normal 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()
|
||||||
250
tests/cookbook/cookbook_main.py
Normal 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()
|
||||||
9
tests/cookbook/features/__init__.py
Normal 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
|
||||||
|
"""
|
||||||
424
tests/cookbook/features/demo_animation_chain.py
Normal 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()
|
||||||
454
tests/cookbook/features/demo_rotation.py
Normal 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()
|
||||||
340
tests/cookbook/features/demo_shaders.py
Normal 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()
|
||||||
81
tests/cookbook/lib/__init__.py
Normal 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',
|
||||||
|
]
|
||||||
387
tests/cookbook/lib/anim_utils.py
Normal 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),
|
||||||
|
)
|
||||||
241
tests/cookbook/lib/button.py
Normal 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
|
||||||
317
tests/cookbook/lib/choice_list.py
Normal 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
|
||||||
|
)
|
||||||
344
tests/cookbook/lib/grid_container.py
Normal 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
|
||||||
563
tests/cookbook/lib/item_manager.py
Normal 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
|
|
@ -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()
|
||||||
351
tests/cookbook/lib/scrollable_list.py
Normal 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()
|
||||||
272
tests/cookbook/lib/stat_bar.py
Normal 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
|
||||||
286
tests/cookbook/lib/text_box.py
Normal 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
|
|
@ -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))
|
||||||
6
tests/cookbook/primitives/__init__.py
Normal 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.
|
||||||
|
"""
|
||||||
296
tests/cookbook/primitives/demo_button.py
Normal 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()
|
||||||
287
tests/cookbook/primitives/demo_choice_list.py
Normal 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()
|
||||||
380
tests/cookbook/primitives/demo_click_pickup.py
Normal 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()
|
||||||
270
tests/cookbook/primitives/demo_drag_drop_grid.py
Normal 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()
|
||||||
332
tests/cookbook/primitives/demo_stat_bar.py
Normal 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()
|
||||||
248
tests/cookbook/primitives/demo_text_box.py
Normal 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()
|
||||||
237
tests/cookbook/primitives/demo_toast.py
Normal 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()
|
||||||
135
tests/cookbook/run_screenshots.py
Normal 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)
|
||||||
BIN
tests/cookbook/screenshots/features/animation_chain.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
tests/cookbook/screenshots/features/rotation.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
tests/cookbook/screenshots/features/shaders.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
tests/cookbook/screenshots/primitives/button.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
tests/cookbook/screenshots/primitives/choice_list.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
tests/cookbook/screenshots/primitives/stat_bar.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
tests/cookbook/screenshots/primitives/text_box.png
Normal file
|
After Width: | Height: | Size: 38 KiB |
BIN
tests/cookbook/screenshots/primitives/toast.png
Normal file
|
After Width: | Height: | Size: 38 KiB |