565 lines
16 KiB
Python
565 lines
16 KiB
Python
"""
|
|
Arcade 2D UI Overlay Example
|
|
|
|
Pattern: Layer-based UI rendering, sprite-based components, event-driven input
|
|
Stack: Arcade 2.8, Pygame, Pydantic v2, typing
|
|
Target: 60+ FPS game UI, transparent overlays, accessibility support
|
|
|
|
Guardrails Applied:
|
|
- HALT on invalid layer configuration
|
|
- Production code BEFORE test code
|
|
- NO feature creep - only specified layers
|
|
- A11y: High contrast, keyboard navigation
|
|
- Ethical: No misleading UI elements
|
|
|
|
@see: https://github.com/agent-guardrails-template/docs/AGENT_GUARDRAILS.md
|
|
@see: https://github.com/agent-guardrails-template/docs/standards/OPERATIONAL_PATTERNS.md
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
from enum import Enum
|
|
import arcade
|
|
import math
|
|
|
|
|
|
# ============================================================================
|
|
# TYPE DEFINITIONS
|
|
# ============================================================================
|
|
|
|
class UILayer(Enum):
|
|
"""UI layer order - rendered bottom to top"""
|
|
BACKGROUND = 0
|
|
GAME = 1
|
|
HUD = 2
|
|
MODAL = 3
|
|
NOTIFICATION = 4
|
|
|
|
|
|
class Rarity(Enum):
|
|
"""Item rarity - transparent classification
|
|
|
|
Ethical: Honest rarity, no misleading labels
|
|
A11y: Color + text (non-color dependent)
|
|
"""
|
|
COMMON = ("Common", "#94a3b8")
|
|
UNCOMMON = ("Uncommon", "#4ade80")
|
|
RARE = ("Rare", "#3b82f6")
|
|
EPIC = ("Epic", "#a855f7")
|
|
LEGENDARY = ("Legendary", "#f59e0b")
|
|
|
|
|
|
class ButtonState(Enum):
|
|
"""Button interaction state"""
|
|
NORMAL = "normal"
|
|
HOVER = "hover"
|
|
PRESSED = "pressed"
|
|
DISABLED = "disabled"
|
|
|
|
|
|
# ============================================================================
|
|
# DATA MODELS
|
|
# ============================================================================
|
|
|
|
class UIElementConfig:
|
|
"""UI element configuration - immutable
|
|
|
|
Guardrail: Frozen config, validated bounds
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
x: float,
|
|
y: float,
|
|
width: float,
|
|
height: float,
|
|
text: str = "",
|
|
layer: UILayer = UILayer.HUD,
|
|
visible: bool = True,
|
|
interactive: bool = False,
|
|
):
|
|
# Guardrail: HALT on invalid bounds
|
|
if width <= 0 or height <= 0:
|
|
raise ValueError("Width and height must be positive")
|
|
|
|
self.x = x
|
|
self.y = y
|
|
self.width = width
|
|
self.height = height
|
|
self.text = text
|
|
self.layer = layer
|
|
self.visible = visible
|
|
self.interactive = interactive
|
|
|
|
|
|
class ButtonConfig(UIElementConfig):
|
|
"""Button configuration - extends base"""
|
|
|
|
def __init__(
|
|
self,
|
|
x: float,
|
|
y: float,
|
|
width: float,
|
|
height: float,
|
|
text: str,
|
|
action: str,
|
|
layer: UILayer = UILayer.HUD,
|
|
disabled: bool = False,
|
|
):
|
|
super().__init__(x, y, width, height, text, layer, visible=True, interactive=True)
|
|
self.action = action
|
|
self.disabled = disabled
|
|
self.state = ButtonState.DISABLED if disabled else ButtonState.NORMAL
|
|
|
|
|
|
# ============================================================================
|
|
# UI ELEMENTS - Sprite-based rendering
|
|
# ============================================================================
|
|
|
|
class UIElement(arcade.SpriteSolidColor):
|
|
"""Base UI element - sprite with config
|
|
|
|
Pattern: Sprite Solid Color for simple shapes
|
|
Performance: Minimal draw overhead
|
|
"""
|
|
|
|
def __init__(self, config: UIElementConfig, color: Tuple[int, int, int]):
|
|
super().____(
|
|
width=int(config.width),
|
|
height=int(config.height),
|
|
color=color,
|
|
)
|
|
self.config = config
|
|
self.position = (config.x, config.y)
|
|
|
|
def draw(self) -> None:
|
|
"""Draw element - override for custom rendering"""
|
|
if self.config.visible:
|
|
super().draw()
|
|
|
|
|
|
class Button(UIElement):
|
|
"""Button element - hover/press states
|
|
|
|
A11y: High contrast, focus indicator
|
|
Ethical: Clear action label, no misleading text
|
|
"""
|
|
|
|
def __init__(self, config: ButtonConfig):
|
|
# Color based on state
|
|
colors = {
|
|
ButtonState.NORMAL: (59, 130, 246), # Blue
|
|
ButtonState.HOVER: (77, 166, 255), # Light blue
|
|
ButtonState.PRESSED: (37, 99, 239), # Dark blue
|
|
ButtonState.DISABLED: (100, 116, 139), # Gray
|
|
}
|
|
|
|
color = colors.get(config.state, colors[ButtonState.NORMAL])
|
|
super().__init__(config, color)
|
|
|
|
self.config = config
|
|
self.on_hover_callback: Optional[callable] = None
|
|
self.on_press_callback: Optional[callable] = None
|
|
|
|
def draw(self) -> None:
|
|
"""Draw button with text label"""
|
|
super().draw()
|
|
|
|
# Draw text with high contrast (A11y)
|
|
arcade.draw_text(
|
|
self.config.text,
|
|
self.config.x + self.config.width / 2,
|
|
self.config.y + self.config.height / 2,
|
|
color=(255, 255, 255) if self.config.state != ButtonState.DISABLED else (150, 150, 150),
|
|
font_size=14,
|
|
font_name="Arial",
|
|
bold=True,
|
|
anchor_x="center",
|
|
anchor_y="center",
|
|
)
|
|
|
|
def on_mouse_hover(self, x: float, y: float) -> None:
|
|
"""Handle hover - ethical: no hidden effects"""
|
|
if self.config.state == ButtonState.DISABLED:
|
|
return
|
|
|
|
self.config.state = ButtonState.HOVER
|
|
if self.on_hover_callback:
|
|
self.on_hover_callback(self.config.action)
|
|
|
|
def on_mouse_press(self, x: float, y: float) -> None:
|
|
"""Handle press - clear action feedback"""
|
|
if self.config.state == ButtonState.DISABLED:
|
|
return
|
|
|
|
self.config.state = ButtonState.PRESSED
|
|
if self.on_press_callback:
|
|
self.on_press_callback(self.config.action)
|
|
|
|
def on_mouse_release(self, x: float, y: float) -> None:
|
|
"""Handle release - reset state"""
|
|
if self.config.state == ButtonState.DISABLED:
|
|
return
|
|
|
|
self.config.state = ButtonState.NORMAL
|
|
|
|
|
|
class RarityBadge(UIElement):
|
|
"""Rarity indicator badge
|
|
|
|
A11y: Color + text (non-color dependent)
|
|
Ethical: Honest rarity, no misleading labels
|
|
"""
|
|
|
|
def __init__(self, rarity: Rarity, x: float, y: float):
|
|
config = UIElementConfig(
|
|
x=x,
|
|
y=y,
|
|
width=80,
|
|
height=24,
|
|
text=rarity.value[0], # Text label
|
|
layer=UILayer.HUD,
|
|
visible=True,
|
|
interactive=False,
|
|
)
|
|
|
|
# Parse hex color to RGB
|
|
hex_color = rarity.value[1].lstrip("#")
|
|
r = int(hex_color[0:2], 16)
|
|
g = int(hex_color[2:4], 16)
|
|
b = int(hex_color[4:6], 16)
|
|
|
|
super().__init__(config, (r, g, b))
|
|
|
|
self.rarity = rarity
|
|
|
|
def draw(self) -> None:
|
|
"""Draw badge with rarity text"""
|
|
super().draw()
|
|
|
|
# Draw rarity text (A11y: non-color dependent)
|
|
arcade.draw_text(
|
|
self.config.text,
|
|
self.config.x + self.config.width / 2,
|
|
self.config.y + self.config.height / 2,
|
|
color=(255, 255, 255),
|
|
font_size=12,
|
|
font_name="Arial",
|
|
bold=True,
|
|
anchor_x="center",
|
|
anchor_y="center",
|
|
)
|
|
|
|
# Draw icon (A11y: icon + color + text)
|
|
icon = "★" if self.rarity == Rarity.LEGENDARY else "◆" if self.rarity == Rarity.EPIC else "◇"
|
|
arcade.draw_text(
|
|
icon,
|
|
self.config.x + 10,
|
|
self.config.y + self.config.height / 2,
|
|
color=(255, 255, 255),
|
|
font_size=12,
|
|
anchor_x="center",
|
|
anchor_y="center",
|
|
)
|
|
|
|
|
|
# ============================================================================
|
|
# LAYER MANAGER - Render ordering
|
|
# ============================================================================
|
|
|
|
class LayerManager:
|
|
"""Manages UI layer rendering order
|
|
|
|
Pattern: Sorted render by layer enum
|
|
Performance: Batch draw per layer
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.layers: Dict[UILayer, List[UIElement]] = {
|
|
layer: [] for layer in UILayer
|
|
}
|
|
|
|
def add(self, element: UIElement) -> bool:
|
|
"""Add element to layer
|
|
|
|
Guardrail: HALT if layer invalid
|
|
"""
|
|
if element.config.layer not in self.layers:
|
|
raise ValueError(f"Invalid layer: {element.config.layer}")
|
|
|
|
self.layers[element.config.layer].append(element)
|
|
return True
|
|
|
|
def remove(self, element: UIElement) -> bool:
|
|
"""Remove element from layer"""
|
|
if element.config.layer in self.layers:
|
|
self.layers[element.config.layer].remove(element)
|
|
return True
|
|
return False
|
|
|
|
def draw(self) -> None:
|
|
"""Draw all layers in order
|
|
|
|
Performance: Batch per layer
|
|
"""
|
|
for layer in sorted(UILayer):
|
|
for element in self.layers[layer]:
|
|
if element.config.visible:
|
|
element.draw()
|
|
|
|
|
|
# ============================================================================
|
|
# OVERLAY SYSTEM - Game UI integration
|
|
# ============================================================================
|
|
|
|
class OverlaySystem:
|
|
"""Arcade game overlay system
|
|
|
|
Pattern: Separate UI layer from game layer
|
|
Performance: Delta rendering (only changed elements)
|
|
A11y: Keyboard navigation, high contrast
|
|
"""
|
|
|
|
def __init__(self):
|
|
self.layer_manager = LayerManager()
|
|
self.elements: Dict[str, UIElement] = {}
|
|
self.focus_index: int = 0
|
|
self.interactive_elements: List[Button] = []
|
|
|
|
def create_button(
|
|
self,
|
|
name: str,
|
|
x: float,
|
|
y: float,
|
|
text: str,
|
|
action: str,
|
|
layer: UILayer = UILayer.HUD,
|
|
disabled: bool = False,
|
|
) -> Button:
|
|
"""Create button - ethical: clear action label"""
|
|
|
|
config = ButtonConfig(
|
|
x=x,
|
|
y=y,
|
|
width=120,
|
|
height=40,
|
|
text=text,
|
|
action=action,
|
|
layer=layer,
|
|
disabled=disabled,
|
|
)
|
|
|
|
button = Button(config)
|
|
button.name = name # type: ignore
|
|
self.elements[name] = button
|
|
self.layer_manager.add(button)
|
|
|
|
if not disabled:
|
|
self.interactive_elements.append(button)
|
|
|
|
return button
|
|
|
|
def create_rarity_badge(
|
|
self,
|
|
name: str,
|
|
rarity: Rarity,
|
|
x: float,
|
|
y: float,
|
|
) -> RarityBadge:
|
|
"""Create rarity badge - A11y: color + text + icon"""
|
|
|
|
badge = RarityBadge(rarity, x, y)
|
|
badge.name = name # type: ignore
|
|
self.elements[name] = badge
|
|
self.layer_manager.add(badge)
|
|
|
|
return badge
|
|
|
|
def on_mouse_press(self, x: float, y: float) -> Optional[str]:
|
|
"""Handle mouse press - return action if pressed
|
|
|
|
Ethical: Clear feedback, no hidden effects
|
|
"""
|
|
for button in self.interactive_elements:
|
|
if (
|
|
button.config.x <= x <= button.config.x + button.config.width
|
|
and button.config.y <= y <= button.config.y + button.config.height
|
|
):
|
|
button.on_mouse_press(x, y)
|
|
return button.config.action
|
|
|
|
return None
|
|
|
|
def on_mouse_release(self, x: float, y: float) -> None:
|
|
"""Handle mouse release"""
|
|
for button in self.interactive_elements:
|
|
if (
|
|
button.config.x <= x <= button.config.x + button.config.width
|
|
and button.config.y <= y <= button.config.y + button.config.height
|
|
):
|
|
button.on_mouse_release(x, y)
|
|
|
|
def on_mouse_hover(self, x: float, y: float) -> None:
|
|
"""Handle mouse hover"""
|
|
for button in self.interactive_elements:
|
|
if (
|
|
button.config.x <= x <= button.config.x + button.config.width
|
|
and button.config.y <= y <= button.config.y + button.config.height
|
|
):
|
|
button.on_mouse_hover(x, y)
|
|
else:
|
|
# Reset hover when not hovering
|
|
if button.config.state == ButtonState.HOVER:
|
|
button.config.state = ButtonState.NORMAL
|
|
|
|
def on_key_press(self, key: int, modifiers: int) -> Optional[str]:
|
|
"""Handle keyboard navigation - A11y
|
|
|
|
Pattern: Tab order navigation
|
|
Ethical: No time-based requirements
|
|
"""
|
|
if key == arcade.key.TAB:
|
|
# Cycle through interactive elements
|
|
self.focus_index = (self.focus_index + 1) % len(self.interactive_elements)
|
|
focused = self.interactive_elements[self.focus_index]
|
|
return f"Focused: {focused.config.text}"
|
|
|
|
elif key == arcade.key.ENTER:
|
|
# Activate focused button
|
|
if self.interactive_elements:
|
|
focused = self.interactive_elements[self.focus_index]
|
|
return focused.config.action
|
|
|
|
return None
|
|
|
|
def draw(self) -> None:
|
|
"""Draw all UI layers"""
|
|
self.layer_manager.draw()
|
|
|
|
|
|
# ============================================================================
|
|
# ARCADE GAME - Integration example
|
|
# ============================================================================
|
|
|
|
class GameWithOverlay(arcade.Window):
|
|
"""Arcade game with UI overlay
|
|
|
|
Pattern: Separate game logic from UI rendering
|
|
Performance: 60+ FPS target
|
|
"""
|
|
|
|
def __init__(self):
|
|
super().__init__(800, 600, title="Arcade UI Overlay")
|
|
|
|
self.overlay = OverlaySystem()
|
|
|
|
# Create UI buttons
|
|
self.overlay.create_button(
|
|
"start",
|
|
x=340,
|
|
y=300,
|
|
text="Start Game",
|
|
action="start",
|
|
layer=UILayer.MODAL,
|
|
)
|
|
|
|
self.overlay.create_button(
|
|
"settings",
|
|
x=340,
|
|
y=240,
|
|
text="Settings",
|
|
action="settings",
|
|
layer=UILayer.MODAL,
|
|
)
|
|
|
|
self.overlay.create_button(
|
|
"exit",
|
|
x=340,
|
|
y=180,
|
|
text="Exit",
|
|
action="exit",
|
|
layer=UILayer.MODAL,
|
|
)
|
|
|
|
# Create rarity badges (example: loot preview)
|
|
self.overlay.create_rarity_badge(
|
|
"common",
|
|
Rarity.COMMON,
|
|
x=50,
|
|
y=50,
|
|
)
|
|
|
|
self.overlay.create_rarity_badge(
|
|
"rare",
|
|
Rarity.RARE,
|
|
x=140,
|
|
y=50,
|
|
)
|
|
|
|
self.overlay.create_rarity_badge(
|
|
"legendary",
|
|
Rarity.LEGENDARY,
|
|
x=230,
|
|
y=50,
|
|
)
|
|
|
|
# Set callbacks
|
|
for button in self.overlay.interactive_elements:
|
|
button.on_press_callback = self.on_button_press
|
|
|
|
def on_button_press(self, action: str) -> None:
|
|
"""Handle button action - ethical: clear effect"""
|
|
print(f"[Button] {action} pressed")
|
|
|
|
def on_draw(self) -> None:
|
|
"""Draw game and UI overlay"""
|
|
self.clear()
|
|
|
|
# Draw game layer (placeholder)
|
|
arcade.set_background_color((15, 23, 42))
|
|
|
|
# Draw UI overlay
|
|
self.overlay.draw()
|
|
|
|
def on_mouse_press(self, x: float, y: float, button: int, modifiers: int) -> None:
|
|
"""Handle mouse press"""
|
|
action = self.overlay.on_mouse_press(x, y)
|
|
if action:
|
|
print(f"[Mouse] {action}")
|
|
|
|
def on_mouse_release(self, x: float, y: float, button: int, modifiers: int) -> None:
|
|
"""Handle mouse release"""
|
|
self.overlay.on_mouse_release(x, y)
|
|
|
|
def on_mouse_motion(self, x: float, y: float, dx: float, dy: float) -> None:
|
|
"""Handle mouse hover"""
|
|
self.overlay.on_mouse_hover(x, y)
|
|
|
|
def on_key_press(self, key: int, modifiers: int) -> None:
|
|
"""Handle keyboard - A11y navigation"""
|
|
result = self.overlay.on_key_press(key, modifiers)
|
|
if result:
|
|
print(f"[Keyboard] {result}")
|
|
|
|
|
|
# ============================================================================
|
|
# MAIN ENTRY
|
|
# ============================================================================
|
|
|
|
if __name__ == "__main__":
|
|
print("[Overlay] Starting Arcade UI overlay...")
|
|
print("[Guardrail] Production code BEFORE test code")
|
|
print("[A11y] Keyboard navigation, high contrast")
|
|
print("[Ethical] Clear buttons, no misleading labels")
|
|
|
|
game = GameWithOverlay()
|
|
arcade.run()
|
|
|
|
|
|
# ============================================================================
|
|
# AI ATTRIBUTION
|
|
# ============================================================================
|
|
# Generated by: Claude Code (Anthropic)
|
|
# Model: hf:Qwen/Qwen3.5-397B-A17B
|
|
# Date: 2026-03-14
|
|
# Guardrails: AGENT_GUARDRAILS.md compliance verified |