From 48359b5a48c2714963769cc4615323f4659df063 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Sat, 3 Jan 2026 11:01:10 -0500 Subject: [PATCH] draft tutorial revisions --- .../combat/combat_animated_movement_basic.py | 13 + .../combat_animated_movement_basic_2.py | 12 + docs/cookbook/combat/combat_enemy_ai_basic.py | 45 +++ docs/cookbook/combat/combat_enemy_ai_multi.py | 11 + .../combat/combat_enemy_ai_multi_2.py | 14 + docs/cookbook/combat/combat_melee_basic.py | 82 ++++ docs/cookbook/combat/combat_melee_complete.py | 15 + .../combat/combat_melee_complete_2.py | 14 + .../combat/combat_status_effects_basic.py | 56 +++ .../combat/combat_status_effects_basic_2.py | 16 + .../combat/combat_status_effects_basic_3.py | 12 + docs/cookbook/combat/combat_turn_system.py | 45 +++ .../effects/effects_color_pulse_basic.py | 118 ++++++ .../effects/effects_color_pulse_multi.py | 61 +++ .../effects/effects_damage_flash_basic.py | 41 ++ .../effects/effects_damage_flash_complete.py | 85 ++++ .../effects/effects_damage_flash_multi.py | 25 ++ .../cookbook/effects/effects_floating_text.py | 42 ++ .../effects/effects_path_animation.py | 65 ++++ .../effects/effects_scene_transitions.py | 166 ++++++++ .../effects/effects_screen_shake_basic.py | 38 ++ .../effects/effects_screen_shake_multi.py | 58 +++ .../grid/grid_cell_highlighting_animated.py | 74 ++++ .../grid/grid_cell_highlighting_basic.py | 74 ++++ .../grid/grid_cell_highlighting_multi.py | 23 ++ docs/cookbook/grid/grid_dijkstra_basic.py | 31 ++ docs/cookbook/grid/grid_dijkstra_multi.py | 44 +++ .../grid/grid_dungeon_generator_basic.py | 125 ++++++ .../grid/grid_dungeon_generator_complete.py | 148 +++++++ docs/cookbook/grid/grid_fog_of_war.py | 20 + docs/cookbook/grid/grid_multi_layer_basic.py | 114 ++++++ .../grid/grid_multi_layer_complete.py | 38 ++ docs/cookbook/ui/ui_health_bar_animated.py | 120 ++++++ docs/cookbook/ui/ui_health_bar_basic.py | 43 +++ docs/cookbook/ui/ui_health_bar_enhanced.py | 123 ++++++ docs/cookbook/ui/ui_health_bar_multi.py | 108 ++++++ docs/cookbook/ui/ui_menu_basic.py | 53 +++ docs/cookbook/ui/ui_menu_enhanced.py | 159 ++++++++ docs/cookbook/ui/ui_message_log_basic.py | 54 +++ docs/cookbook/ui/ui_message_log_enhanced.py | 27 ++ docs/cookbook/ui/ui_modal_dialog_basic.py | 69 ++++ docs/cookbook/ui/ui_modal_dialog_enhanced.py | 78 ++++ docs/cookbook/ui/ui_tooltip_basic.py | 65 ++++ docs/cookbook/ui/ui_tooltip_multi.py | 80 ++++ docs/templates/complete/ai.py | 289 ++++++++++++++ docs/templates/complete/combat.py | 187 +++++++++ docs/templates/complete/constants.py | 210 ++++++++++ docs/templates/complete/dungeon.py | 298 ++++++++++++++ docs/templates/complete/entities.py | 319 +++++++++++++++ docs/templates/complete/game.py | 313 +++++++++++++++ docs/templates/complete/turns.py | 232 +++++++++++ docs/templates/complete/ui.py | 330 ++++++++++++++++ docs/templates/minimal/game.py | 176 +++++++++ docs/templates/roguelike/constants.py | 138 +++++++ docs/templates/roguelike/dungeon.py | 340 ++++++++++++++++ docs/templates/roguelike/entities.py | 364 ++++++++++++++++++ docs/templates/roguelike/game.py | 290 ++++++++++++++ .../part_01_grid_movement.py | 6 +- .../part_02_tiles_collision.py | 4 +- .../part_03_dungeon_generation.py | 4 +- docs/tutorials/part_04_fov/part_04_fov.py | 4 +- .../part_05_enemies/part_05_enemies.py | 4 +- .../part_06_combat/part_06_combat.py | 4 +- docs/tutorials/part_07_ui/part_07_ui.py | 4 +- docs/tutorials/part_08_items/part_08_items.py | 4 +- .../part_09_ranged/part_09_ranged.py | 4 +- .../part_10_save_load/part_10_save_load.py | 4 +- .../part_11_levels/part_11_levels.py | 4 +- .../part_12_experience/part_12_experience.py | 4 +- .../part_13_equipment/part_13_equipment.py | 4 +- 70 files changed, 6216 insertions(+), 28 deletions(-) create mode 100644 docs/cookbook/combat/combat_animated_movement_basic.py create mode 100644 docs/cookbook/combat/combat_animated_movement_basic_2.py create mode 100644 docs/cookbook/combat/combat_enemy_ai_basic.py create mode 100644 docs/cookbook/combat/combat_enemy_ai_multi.py create mode 100644 docs/cookbook/combat/combat_enemy_ai_multi_2.py create mode 100644 docs/cookbook/combat/combat_melee_basic.py create mode 100644 docs/cookbook/combat/combat_melee_complete.py create mode 100644 docs/cookbook/combat/combat_melee_complete_2.py create mode 100644 docs/cookbook/combat/combat_status_effects_basic.py create mode 100644 docs/cookbook/combat/combat_status_effects_basic_2.py create mode 100644 docs/cookbook/combat/combat_status_effects_basic_3.py create mode 100644 docs/cookbook/combat/combat_turn_system.py create mode 100644 docs/cookbook/effects/effects_color_pulse_basic.py create mode 100644 docs/cookbook/effects/effects_color_pulse_multi.py create mode 100644 docs/cookbook/effects/effects_damage_flash_basic.py create mode 100644 docs/cookbook/effects/effects_damage_flash_complete.py create mode 100644 docs/cookbook/effects/effects_damage_flash_multi.py create mode 100644 docs/cookbook/effects/effects_floating_text.py create mode 100644 docs/cookbook/effects/effects_path_animation.py create mode 100644 docs/cookbook/effects/effects_scene_transitions.py create mode 100644 docs/cookbook/effects/effects_screen_shake_basic.py create mode 100644 docs/cookbook/effects/effects_screen_shake_multi.py create mode 100644 docs/cookbook/grid/grid_cell_highlighting_animated.py create mode 100644 docs/cookbook/grid/grid_cell_highlighting_basic.py create mode 100644 docs/cookbook/grid/grid_cell_highlighting_multi.py create mode 100644 docs/cookbook/grid/grid_dijkstra_basic.py create mode 100644 docs/cookbook/grid/grid_dijkstra_multi.py create mode 100644 docs/cookbook/grid/grid_dungeon_generator_basic.py create mode 100644 docs/cookbook/grid/grid_dungeon_generator_complete.py create mode 100644 docs/cookbook/grid/grid_fog_of_war.py create mode 100644 docs/cookbook/grid/grid_multi_layer_basic.py create mode 100644 docs/cookbook/grid/grid_multi_layer_complete.py create mode 100644 docs/cookbook/ui/ui_health_bar_animated.py create mode 100644 docs/cookbook/ui/ui_health_bar_basic.py create mode 100644 docs/cookbook/ui/ui_health_bar_enhanced.py create mode 100644 docs/cookbook/ui/ui_health_bar_multi.py create mode 100644 docs/cookbook/ui/ui_menu_basic.py create mode 100644 docs/cookbook/ui/ui_menu_enhanced.py create mode 100644 docs/cookbook/ui/ui_message_log_basic.py create mode 100644 docs/cookbook/ui/ui_message_log_enhanced.py create mode 100644 docs/cookbook/ui/ui_modal_dialog_basic.py create mode 100644 docs/cookbook/ui/ui_modal_dialog_enhanced.py create mode 100644 docs/cookbook/ui/ui_tooltip_basic.py create mode 100644 docs/cookbook/ui/ui_tooltip_multi.py create mode 100644 docs/templates/complete/ai.py create mode 100644 docs/templates/complete/combat.py create mode 100644 docs/templates/complete/constants.py create mode 100644 docs/templates/complete/dungeon.py create mode 100644 docs/templates/complete/entities.py create mode 100644 docs/templates/complete/game.py create mode 100644 docs/templates/complete/turns.py create mode 100644 docs/templates/complete/ui.py create mode 100644 docs/templates/minimal/game.py create mode 100644 docs/templates/roguelike/constants.py create mode 100644 docs/templates/roguelike/dungeon.py create mode 100644 docs/templates/roguelike/entities.py create mode 100644 docs/templates/roguelike/game.py diff --git a/docs/cookbook/combat/combat_animated_movement_basic.py b/docs/cookbook/combat/combat_animated_movement_basic.py new file mode 100644 index 0000000..a1e9b05 --- /dev/null +++ b/docs/cookbook/combat/combat_animated_movement_basic.py @@ -0,0 +1,13 @@ +"""McRogueFace - Animated Movement (basic) + +Documentation: https://mcrogueface.github.io/cookbook/combat_animated_movement +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/combat/combat_animated_movement_basic.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +if new_x != current_x: + anim = mcrfpy.Animation("x", float(new_x), duration, "easeInOut", callback=done) + else: + anim = mcrfpy.Animation("y", float(new_y), duration, "easeInOut", callback=done) \ No newline at end of file diff --git a/docs/cookbook/combat/combat_animated_movement_basic_2.py b/docs/cookbook/combat/combat_animated_movement_basic_2.py new file mode 100644 index 0000000..68fba47 --- /dev/null +++ b/docs/cookbook/combat/combat_animated_movement_basic_2.py @@ -0,0 +1,12 @@ +"""McRogueFace - Animated Movement (basic_2) + +Documentation: https://mcrogueface.github.io/cookbook/combat_animated_movement +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/combat/combat_animated_movement_basic_2.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +current_anim = mcrfpy.Animation("x", 100.0, 0.5, "linear") + current_anim.start(entity) + # Later: current_anim = None # Let it complete or create new one \ No newline at end of file diff --git a/docs/cookbook/combat/combat_enemy_ai_basic.py b/docs/cookbook/combat/combat_enemy_ai_basic.py new file mode 100644 index 0000000..a462cd3 --- /dev/null +++ b/docs/cookbook/combat/combat_enemy_ai_basic.py @@ -0,0 +1,45 @@ +"""McRogueFace - Basic Enemy AI (basic) + +Documentation: https://mcrogueface.github.io/cookbook/combat_enemy_ai +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/combat/combat_enemy_ai_basic.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import random + +def wander(enemy, grid): + """Move randomly to an adjacent walkable tile.""" + ex, ey = int(enemy.x), int(enemy.y) + + # Get valid adjacent tiles + directions = [(0, -1), (0, 1), (-1, 0), (1, 0)] + random.shuffle(directions) + + for dx, dy in directions: + new_x, new_y = ex + dx, ey + dy + + if is_walkable(grid, new_x, new_y) and not is_occupied(new_x, new_y): + enemy.x = new_x + enemy.y = new_y + return + + # No valid moves - stay in place + +def is_walkable(grid, x, y): + """Check if a tile can be walked on.""" + grid_w, grid_h = grid.grid_size + if x < 0 or x >= grid_w or y < 0 or y >= grid_h: + return False + return grid.at(x, y).walkable + +def is_occupied(x, y, entities=None): + """Check if a tile is occupied by another entity.""" + if entities is None: + return False + + for entity in entities: + if int(entity.x) == x and int(entity.y) == y: + return True + return False \ No newline at end of file diff --git a/docs/cookbook/combat/combat_enemy_ai_multi.py b/docs/cookbook/combat/combat_enemy_ai_multi.py new file mode 100644 index 0000000..9f78b97 --- /dev/null +++ b/docs/cookbook/combat/combat_enemy_ai_multi.py @@ -0,0 +1,11 @@ +"""McRogueFace - Basic Enemy AI (multi) + +Documentation: https://mcrogueface.github.io/cookbook/combat_enemy_ai +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/combat/combat_enemy_ai_multi.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +# Filter to cardinal directions only + path = [p for p in path if abs(p[0] - ex) + abs(p[1] - ey) == 1] \ No newline at end of file diff --git a/docs/cookbook/combat/combat_enemy_ai_multi_2.py b/docs/cookbook/combat/combat_enemy_ai_multi_2.py new file mode 100644 index 0000000..205eba6 --- /dev/null +++ b/docs/cookbook/combat/combat_enemy_ai_multi_2.py @@ -0,0 +1,14 @@ +"""McRogueFace - Basic Enemy AI (multi_2) + +Documentation: https://mcrogueface.github.io/cookbook/combat_enemy_ai +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/combat/combat_enemy_ai_multi_2.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +def alert_nearby(x, y, radius, enemies): + for enemy in enemies: + dist = abs(enemy.entity.x - x) + abs(enemy.entity.y - y) + if dist <= radius and hasattr(enemy.ai, 'alert'): + enemy.ai.alert = True \ No newline at end of file diff --git a/docs/cookbook/combat/combat_melee_basic.py b/docs/cookbook/combat/combat_melee_basic.py new file mode 100644 index 0000000..b488d71 --- /dev/null +++ b/docs/cookbook/combat/combat_melee_basic.py @@ -0,0 +1,82 @@ +"""McRogueFace - Melee Combat System (basic) + +Documentation: https://mcrogueface.github.io/cookbook/combat_melee +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/combat/combat_melee_basic.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +class CombatLog: + """Scrolling combat message log.""" + + def __init__(self, x, y, width, height, max_messages=10): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_messages = max_messages + self.messages = [] + self.captions = [] + + ui = mcrfpy.sceneUI(mcrfpy.currentScene()) + + # Background + self.frame = mcrfpy.Frame(x, y, width, height) + self.frame.fill_color = mcrfpy.Color(0, 0, 0, 180) + ui.append(self.frame) + + def add_message(self, text, color=None): + """Add a message to the log.""" + if color is None: + color = mcrfpy.Color(200, 200, 200) + + self.messages.append((text, color)) + + # Keep only recent messages + if len(self.messages) > self.max_messages: + self.messages.pop(0) + + self._refresh_display() + + def _refresh_display(self): + """Redraw all messages.""" + ui = mcrfpy.sceneUI(mcrfpy.currentScene()) + + # Remove old captions + for caption in self.captions: + try: + ui.remove(caption) + except: + pass + self.captions.clear() + + # Create new captions + line_height = 18 + for i, (text, color) in enumerate(self.messages): + caption = mcrfpy.Caption(text, self.x + 5, self.y + 5 + i * line_height) + caption.fill_color = color + ui.append(caption) + self.captions.append(caption) + + def log_attack(self, attacker_name, defender_name, damage, killed=False, critical=False): + """Log an attack event.""" + if critical: + text = f"{attacker_name} CRITS {defender_name} for {damage}!" + color = mcrfpy.Color(255, 255, 0) + else: + text = f"{attacker_name} hits {defender_name} for {damage}." + color = mcrfpy.Color(200, 200, 200) + + self.add_message(text, color) + + if killed: + self.add_message(f"{defender_name} is defeated!", mcrfpy.Color(255, 100, 100)) + + +# Global combat log +combat_log = None + +def init_combat_log(): + global combat_log + combat_log = CombatLog(10, 500, 400, 200) \ No newline at end of file diff --git a/docs/cookbook/combat/combat_melee_complete.py b/docs/cookbook/combat/combat_melee_complete.py new file mode 100644 index 0000000..b7f3e87 --- /dev/null +++ b/docs/cookbook/combat/combat_melee_complete.py @@ -0,0 +1,15 @@ +"""McRogueFace - Melee Combat System (complete) + +Documentation: https://mcrogueface.github.io/cookbook/combat_melee +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/combat/combat_melee_complete.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +def die_with_animation(entity): + # Play death animation + anim = mcrfpy.Animation("opacity", 0.0, 0.5, "linear") + anim.start(entity) + # Remove after animation + mcrfpy.setTimer("remove", lambda dt: remove_entity(entity), 500) \ No newline at end of file diff --git a/docs/cookbook/combat/combat_melee_complete_2.py b/docs/cookbook/combat/combat_melee_complete_2.py new file mode 100644 index 0000000..c936d9f --- /dev/null +++ b/docs/cookbook/combat/combat_melee_complete_2.py @@ -0,0 +1,14 @@ +"""McRogueFace - Melee Combat System (complete_2) + +Documentation: https://mcrogueface.github.io/cookbook/combat_melee +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/combat/combat_melee_complete_2.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +@dataclass + class AdvancedFighter(Fighter): + fire_resist: float = 0.0 + ice_resist: float = 0.0 + physical_resist: float = 0.0 \ No newline at end of file diff --git a/docs/cookbook/combat/combat_status_effects_basic.py b/docs/cookbook/combat/combat_status_effects_basic.py new file mode 100644 index 0000000..ec87939 --- /dev/null +++ b/docs/cookbook/combat/combat_status_effects_basic.py @@ -0,0 +1,56 @@ +"""McRogueFace - Status Effects (basic) + +Documentation: https://mcrogueface.github.io/cookbook/combat_status_effects +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/combat/combat_status_effects_basic.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +class StackableEffect(StatusEffect): + """Effect that stacks intensity.""" + + def __init__(self, name, duration, intensity=1, max_stacks=5, **kwargs): + super().__init__(name, duration, **kwargs) + self.intensity = intensity + self.max_stacks = max_stacks + self.stacks = 1 + + def add_stack(self): + """Add another stack.""" + if self.stacks < self.max_stacks: + self.stacks += 1 + return True + return False + + +class StackingEffectManager(EffectManager): + """Effect manager with stacking support.""" + + def add_effect(self, effect): + if isinstance(effect, StackableEffect): + # Check for existing stacks + for existing in self.effects: + if existing.name == effect.name: + if existing.add_stack(): + # Refresh duration + existing.duration = max(existing.duration, effect.duration) + return + else: + return # Max stacks + + # Default behavior + super().add_effect(effect) + + +# Stacking poison example +def create_stacking_poison(base_damage=1, duration=5): + def on_tick(target): + # Find the poison effect to get stack count + effect = target.effects.get_effect("poison") + if effect: + damage = base_damage * effect.stacks + target.hp -= damage + print(f"{target.name} takes {damage} poison damage! ({effect.stacks} stacks)") + + return StackableEffect("poison", duration, on_tick=on_tick, max_stacks=5) \ No newline at end of file diff --git a/docs/cookbook/combat/combat_status_effects_basic_2.py b/docs/cookbook/combat/combat_status_effects_basic_2.py new file mode 100644 index 0000000..10fe619 --- /dev/null +++ b/docs/cookbook/combat/combat_status_effects_basic_2.py @@ -0,0 +1,16 @@ +"""McRogueFace - Status Effects (basic_2) + +Documentation: https://mcrogueface.github.io/cookbook/combat_status_effects +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/combat/combat_status_effects_basic_2.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +def apply_effect(self, effect): + if effect.name in self.immunities: + print(f"{self.name} is immune to {effect.name}!") + return + if effect.name in self.resistances: + effect.duration //= 2 # Half duration + self.effects.add_effect(effect) \ No newline at end of file diff --git a/docs/cookbook/combat/combat_status_effects_basic_3.py b/docs/cookbook/combat/combat_status_effects_basic_3.py new file mode 100644 index 0000000..567ea44 --- /dev/null +++ b/docs/cookbook/combat/combat_status_effects_basic_3.py @@ -0,0 +1,12 @@ +"""McRogueFace - Status Effects (basic_3) + +Documentation: https://mcrogueface.github.io/cookbook/combat_status_effects +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/combat/combat_status_effects_basic_3.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +def serialize_effects(effect_manager): + return [{"name": e.name, "duration": e.duration} + for e in effect_manager.effects] \ No newline at end of file diff --git a/docs/cookbook/combat/combat_turn_system.py b/docs/cookbook/combat/combat_turn_system.py new file mode 100644 index 0000000..61194e5 --- /dev/null +++ b/docs/cookbook/combat/combat_turn_system.py @@ -0,0 +1,45 @@ +"""McRogueFace - Turn-Based Game Loop (combat_turn_system) + +Documentation: https://mcrogueface.github.io/cookbook/combat_turn_system +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/combat/combat_turn_system.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +def create_turn_order_ui(turn_manager, x=800, y=50): + """Create a visual turn order display.""" + ui = mcrfpy.sceneUI(mcrfpy.currentScene()) + + # Background frame + frame = mcrfpy.Frame(x, y, 200, 300) + frame.fill_color = mcrfpy.Color(30, 30, 30, 200) + frame.outline = 2 + frame.outline_color = mcrfpy.Color(100, 100, 100) + ui.append(frame) + + # Title + title = mcrfpy.Caption("Turn Order", x + 10, y + 10) + title.fill_color = mcrfpy.Color(255, 255, 255) + ui.append(title) + + return frame + +def update_turn_order_display(frame, turn_manager, x=800, y=50): + """Update the turn order display.""" + ui = mcrfpy.sceneUI(mcrfpy.currentScene()) + + # Clear old entries (keep frame and title) + # In practice, store references to caption objects and update them + + for i, actor_data in enumerate(turn_manager.actors): + actor = actor_data["actor"] + is_current = (i == turn_manager.current) + + # Actor name/type + name = getattr(actor, 'name', f"Actor {i}") + color = mcrfpy.Color(255, 255, 0) if is_current else mcrfpy.Color(200, 200, 200) + + caption = mcrfpy.Caption(name, x + 10, y + 40 + i * 25) + caption.fill_color = color + ui.append(caption) \ No newline at end of file diff --git a/docs/cookbook/effects/effects_color_pulse_basic.py b/docs/cookbook/effects/effects_color_pulse_basic.py new file mode 100644 index 0000000..441b852 --- /dev/null +++ b/docs/cookbook/effects/effects_color_pulse_basic.py @@ -0,0 +1,118 @@ +"""McRogueFace - Color Pulse Effect (basic) + +Documentation: https://mcrogueface.github.io/cookbook/effects_color_pulse +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/effects/effects_color_pulse_basic.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +class PulsingCell: + """A cell that continuously pulses until stopped.""" + + def __init__(self, grid, x, y, color, period=1.0, max_alpha=180): + """ + Args: + grid: Grid with color layer + x, y: Cell position + color: RGB tuple + period: Time for one complete pulse cycle + max_alpha: Maximum alpha value (0-255) + """ + self.grid = grid + self.x = x + self.y = y + self.color = color + self.period = period + self.max_alpha = max_alpha + self.is_pulsing = False + self.pulse_id = 0 + self.cell = None + + self._setup_layer() + + def _setup_layer(self): + """Ensure color layer exists and get cell reference.""" + color_layer = None + for layer in self.grid.layers: + if isinstance(layer, mcrfpy.ColorLayer): + color_layer = layer + break + + if not color_layer: + self.grid.add_layer("color") + color_layer = self.grid.layers[-1] + + self.cell = color_layer.at(self.x, self.y) + if self.cell: + self.cell.color = mcrfpy.Color(self.color[0], self.color[1], + self.color[2], 0) + + def start(self): + """Start continuous pulsing.""" + if self.is_pulsing or not self.cell: + return + + self.is_pulsing = True + self.pulse_id += 1 + self._pulse_up() + + def _pulse_up(self): + """Animate alpha increasing.""" + if not self.is_pulsing: + return + + current_id = self.pulse_id + half_period = self.period / 2 + + anim = mcrfpy.Animation("a", float(self.max_alpha), half_period, "easeInOut") + anim.start(self.cell.color) + + def next_phase(timer_name): + if self.is_pulsing and self.pulse_id == current_id: + self._pulse_down() + + mcrfpy.Timer(f"pulse_up_{id(self)}_{current_id}", + next_phase, int(half_period * 1000), once=True) + + def _pulse_down(self): + """Animate alpha decreasing.""" + if not self.is_pulsing: + return + + current_id = self.pulse_id + half_period = self.period / 2 + + anim = mcrfpy.Animation("a", 0.0, half_period, "easeInOut") + anim.start(self.cell.color) + + def next_phase(timer_name): + if self.is_pulsing and self.pulse_id == current_id: + self._pulse_up() + + mcrfpy.Timer(f"pulse_down_{id(self)}_{current_id}", + next_phase, int(half_period * 1000), once=True) + + def stop(self): + """Stop pulsing and fade out.""" + self.is_pulsing = False + if self.cell: + anim = mcrfpy.Animation("a", 0.0, 0.2, "easeOut") + anim.start(self.cell.color) + + def set_color(self, color): + """Change pulse color.""" + self.color = color + if self.cell: + current_alpha = self.cell.color.a + self.cell.color = mcrfpy.Color(color[0], color[1], color[2], current_alpha) + + +# Usage +objective_pulse = PulsingCell(grid, 10, 10, (0, 255, 100), period=1.5) +objective_pulse.start() + +# Later, when objective is reached: +objective_pulse.stop() \ No newline at end of file diff --git a/docs/cookbook/effects/effects_color_pulse_multi.py b/docs/cookbook/effects/effects_color_pulse_multi.py new file mode 100644 index 0000000..e39946c --- /dev/null +++ b/docs/cookbook/effects/effects_color_pulse_multi.py @@ -0,0 +1,61 @@ +"""McRogueFace - Color Pulse Effect (multi) + +Documentation: https://mcrogueface.github.io/cookbook/effects_color_pulse +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/effects/effects_color_pulse_multi.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +def ripple_effect(grid, center_x, center_y, color, max_radius=5, duration=1.0): + """ + Create an expanding ripple effect. + + Args: + grid: Grid with color layer + center_x, center_y: Ripple origin + color: RGB tuple + max_radius: Maximum ripple size + duration: Total animation time + """ + # Get color layer + color_layer = None + for layer in grid.layers: + if isinstance(layer, mcrfpy.ColorLayer): + color_layer = layer + break + + if not color_layer: + grid.add_layer("color") + color_layer = grid.layers[-1] + + step_duration = duration / max_radius + + for radius in range(max_radius + 1): + # Get cells at this radius (ring, not filled) + ring_cells = [] + for dy in range(-radius, radius + 1): + for dx in range(-radius, radius + 1): + dist_sq = dx * dx + dy * dy + # Include cells approximately on the ring edge + if radius * radius - radius <= dist_sq <= radius * radius + radius: + cell = color_layer.at(center_x + dx, center_y + dy) + if cell: + ring_cells.append(cell) + + # Schedule this ring to animate + def animate_ring(timer_name, cells=ring_cells, c=color): + for cell in cells: + cell.color = mcrfpy.Color(c[0], c[1], c[2], 200) + # Fade out + anim = mcrfpy.Animation("a", 0.0, step_duration * 2, "easeOut") + anim.start(cell.color) + + delay = int(radius * step_duration * 1000) + mcrfpy.Timer(f"ripple_{radius}", animate_ring, delay, once=True) + + +# Usage +ripple_effect(grid, 10, 10, (100, 200, 255), max_radius=6, duration=0.8) \ No newline at end of file diff --git a/docs/cookbook/effects/effects_damage_flash_basic.py b/docs/cookbook/effects/effects_damage_flash_basic.py new file mode 100644 index 0000000..ad6c42c --- /dev/null +++ b/docs/cookbook/effects/effects_damage_flash_basic.py @@ -0,0 +1,41 @@ +"""McRogueFace - Damage Flash Effect (basic) + +Documentation: https://mcrogueface.github.io/cookbook/effects_damage_flash +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/effects/effects_damage_flash_basic.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +# Add a color layer to your grid (do this once during setup) +grid.add_layer("color") +color_layer = grid.layers[-1] # Get the color layer + +def flash_cell(grid, x, y, color, duration=0.3): + """Flash a grid cell with a color overlay.""" + # Get the color layer (assumes it's the last layer added) + color_layer = None + for layer in grid.layers: + if isinstance(layer, mcrfpy.ColorLayer): + color_layer = layer + break + + if not color_layer: + return + + # Set cell to flash color + cell = color_layer.at(x, y) + cell.color = mcrfpy.Color(color[0], color[1], color[2], 200) + + # Animate alpha back to 0 + anim = mcrfpy.Animation("a", 0.0, duration, "easeOut") + anim.start(cell.color) + +def damage_at_position(grid, x, y, duration=0.3): + """Flash red at a grid position when damage occurs.""" + flash_cell(grid, x, y, (255, 0, 0), duration) + +# Usage when entity takes damage +damage_at_position(grid, int(enemy.x), int(enemy.y)) \ No newline at end of file diff --git a/docs/cookbook/effects/effects_damage_flash_complete.py b/docs/cookbook/effects/effects_damage_flash_complete.py new file mode 100644 index 0000000..f123a5e --- /dev/null +++ b/docs/cookbook/effects/effects_damage_flash_complete.py @@ -0,0 +1,85 @@ +"""McRogueFace - Damage Flash Effect (complete) + +Documentation: https://mcrogueface.github.io/cookbook/effects_damage_flash +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/effects/effects_damage_flash_complete.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +class DamageEffects: + """Manages visual damage feedback effects.""" + + # Color presets + DAMAGE_RED = (255, 50, 50) + HEAL_GREEN = (50, 255, 50) + POISON_PURPLE = (150, 50, 200) + FIRE_ORANGE = (255, 150, 50) + ICE_BLUE = (100, 200, 255) + + def __init__(self, grid): + self.grid = grid + self.color_layer = None + self._setup_color_layer() + + def _setup_color_layer(self): + """Ensure grid has a color layer for effects.""" + self.grid.add_layer("color") + self.color_layer = self.grid.layers[-1] + + def flash_entity(self, entity, color, duration=0.3): + """Flash an entity with a color tint.""" + # Flash at entity's grid position + x, y = int(entity.x), int(entity.y) + self.flash_cell(x, y, color, duration) + + def flash_cell(self, x, y, color, duration=0.3): + """Flash a specific grid cell.""" + if not self.color_layer: + return + + cell = self.color_layer.at(x, y) + if cell: + cell.color = mcrfpy.Color(color[0], color[1], color[2], 180) + + # Fade out + anim = mcrfpy.Animation("a", 0.0, duration, "easeOut") + anim.start(cell.color) + + def damage(self, entity, amount, duration=0.3): + """Standard damage flash.""" + self.flash_entity(entity, self.DAMAGE_RED, duration) + + def heal(self, entity, amount, duration=0.4): + """Healing effect - green flash.""" + self.flash_entity(entity, self.HEAL_GREEN, duration) + + def poison(self, entity, duration=0.5): + """Poison damage - purple flash.""" + self.flash_entity(entity, self.POISON_PURPLE, duration) + + def fire(self, entity, duration=0.3): + """Fire damage - orange flash.""" + self.flash_entity(entity, self.FIRE_ORANGE, duration) + + def ice(self, entity, duration=0.4): + """Ice damage - blue flash.""" + self.flash_entity(entity, self.ICE_BLUE, duration) + + def area_damage(self, center_x, center_y, radius, color, duration=0.4): + """Flash all cells in a radius.""" + for dy in range(-radius, radius + 1): + for dx in range(-radius, radius + 1): + if dx * dx + dy * dy <= radius * radius: + self.flash_cell(center_x + dx, center_y + dy, color, duration) + +# Setup +effects = DamageEffects(grid) + +# Usage examples +effects.damage(player, 10) # Red flash +effects.heal(player, 5) # Green flash +effects.poison(enemy) # Purple flash +effects.area_damage(5, 5, 3, effects.FIRE_ORANGE) # Area effect \ No newline at end of file diff --git a/docs/cookbook/effects/effects_damage_flash_multi.py b/docs/cookbook/effects/effects_damage_flash_multi.py new file mode 100644 index 0000000..cccddc3 --- /dev/null +++ b/docs/cookbook/effects/effects_damage_flash_multi.py @@ -0,0 +1,25 @@ +"""McRogueFace - Damage Flash Effect (multi) + +Documentation: https://mcrogueface.github.io/cookbook/effects_damage_flash +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/effects/effects_damage_flash_multi.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +def multi_flash(grid, x, y, color, flashes=3, flash_duration=0.1): + """Flash a cell multiple times for emphasis.""" + delay = 0 + + for i in range(flashes): + # Schedule each flash with increasing delay + def do_flash(timer_name, fx=x, fy=y, fc=color, fd=flash_duration): + flash_cell(grid, fx, fy, fc, fd) + + mcrfpy.Timer(f"flash_{x}_{y}_{i}", do_flash, int(delay * 1000), once=True) + delay += flash_duration * 1.5 # Gap between flashes + +# Usage for critical hit +multi_flash(grid, int(enemy.x), int(enemy.y), (255, 255, 0), flashes=3) \ No newline at end of file diff --git a/docs/cookbook/effects/effects_floating_text.py b/docs/cookbook/effects/effects_floating_text.py new file mode 100644 index 0000000..d6ba2a0 --- /dev/null +++ b/docs/cookbook/effects/effects_floating_text.py @@ -0,0 +1,42 @@ +"""McRogueFace - Floating Damage Numbers (effects_floating_text) + +Documentation: https://mcrogueface.github.io/cookbook/effects_floating_text +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/effects/effects_floating_text.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +class StackedFloatingText: + """Prevents overlapping text by stacking vertically.""" + + def __init__(self, scene_name, grid=None): + self.manager = FloatingTextManager(scene_name, grid) + self.position_stack = {} # Track recent spawns per position + + def spawn_stacked(self, x, y, text, color, **kwargs): + """Spawn with automatic vertical stacking.""" + key = (int(x), int(y)) + + # Calculate offset based on recent spawns at this position + offset = self.position_stack.get(key, 0) + actual_y = y - (offset * 20) # 20 pixels between stacked texts + + self.manager.spawn(x, actual_y, text, color, **kwargs) + + # Increment stack counter + self.position_stack[key] = offset + 1 + + # Reset stack after delay + def reset_stack(timer_name, k=key): + if k in self.position_stack: + self.position_stack[k] = max(0, self.position_stack[k] - 1) + + mcrfpy.Timer(f"stack_reset_{x}_{y}_{offset}", reset_stack, 300, once=True) + +# Usage +stacked = StackedFloatingText("game", grid) +# Rapid hits will stack vertically instead of overlapping +stacked.spawn_stacked(5, 5, "-10", (255, 0, 0), is_grid_pos=True) +stacked.spawn_stacked(5, 5, "-8", (255, 0, 0), is_grid_pos=True) +stacked.spawn_stacked(5, 5, "-12", (255, 0, 0), is_grid_pos=True) \ No newline at end of file diff --git a/docs/cookbook/effects/effects_path_animation.py b/docs/cookbook/effects/effects_path_animation.py new file mode 100644 index 0000000..977591d --- /dev/null +++ b/docs/cookbook/effects/effects_path_animation.py @@ -0,0 +1,65 @@ +"""McRogueFace - Path Animation (Multi-Step Movement) (effects_path_animation) + +Documentation: https://mcrogueface.github.io/cookbook/effects_path_animation +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/effects/effects_path_animation.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +class CameraFollowingPath: + """Path animator that also moves the camera.""" + + def __init__(self, entity, grid, path, step_duration=0.2): + self.entity = entity + self.grid = grid + self.path = path + self.step_duration = step_duration + self.index = 0 + self.on_complete = None + + def start(self): + self.index = 0 + self._next() + + def _next(self): + if self.index >= len(self.path): + if self.on_complete: + self.on_complete(self) + return + + x, y = self.path[self.index] + + def done(anim, target): + self.index += 1 + self._next() + + # Animate entity + if self.entity.x != x: + anim = mcrfpy.Animation("x", float(x), self.step_duration, + "easeInOut", callback=done) + anim.start(self.entity) + elif self.entity.y != y: + anim = mcrfpy.Animation("y", float(y), self.step_duration, + "easeInOut", callback=done) + anim.start(self.entity) + else: + done(None, None) + return + + # Animate camera to follow + cam_x = mcrfpy.Animation("center_x", (x + 0.5) * 16, + self.step_duration, "easeInOut") + cam_y = mcrfpy.Animation("center_y", (y + 0.5) * 16, + self.step_duration, "easeInOut") + cam_x.start(self.grid) + cam_y.start(self.grid) + + +# Usage +path = [(5, 5), (5, 10), (10, 10)] +mover = CameraFollowingPath(player, grid, path) +mover.on_complete = lambda m: print("Journey complete!") +mover.start() \ No newline at end of file diff --git a/docs/cookbook/effects/effects_scene_transitions.py b/docs/cookbook/effects/effects_scene_transitions.py new file mode 100644 index 0000000..0a28bea --- /dev/null +++ b/docs/cookbook/effects/effects_scene_transitions.py @@ -0,0 +1,166 @@ +"""McRogueFace - Scene Transition Effects (effects_scene_transitions) + +Documentation: https://mcrogueface.github.io/cookbook/effects_scene_transitions +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/effects/effects_scene_transitions.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +class TransitionManager: + """Manages scene transitions with multiple effect types.""" + + def __init__(self, screen_width=1024, screen_height=768): + self.width = screen_width + self.height = screen_height + self.is_transitioning = False + + def go_to(self, scene_name, effect="fade", duration=0.5, **kwargs): + """ + Transition to a scene with the specified effect. + + Args: + scene_name: Target scene + effect: "fade", "flash", "wipe", "instant" + duration: Transition duration + **kwargs: Effect-specific options (color, direction) + """ + if self.is_transitioning: + return + + self.is_transitioning = True + + if effect == "instant": + mcrfpy.setScene(scene_name) + self.is_transitioning = False + + elif effect == "fade": + color = kwargs.get("color", (0, 0, 0)) + self._fade(scene_name, duration, color) + + elif effect == "flash": + color = kwargs.get("color", (255, 255, 255)) + self._flash(scene_name, duration, color) + + elif effect == "wipe": + direction = kwargs.get("direction", "right") + color = kwargs.get("color", (0, 0, 0)) + self._wipe(scene_name, duration, direction, color) + + def _fade(self, scene, duration, color): + half = duration / 2 + ui = mcrfpy.sceneUI(mcrfpy.currentScene()) + + overlay = mcrfpy.Frame(0, 0, self.width, self.height) + overlay.fill_color = mcrfpy.Color(color[0], color[1], color[2], 0) + overlay.z_index = 9999 + ui.append(overlay) + + anim = mcrfpy.Animation("opacity", 1.0, half, "easeIn") + anim.start(overlay) + + def phase2(timer_name): + mcrfpy.setScene(scene) + new_ui = mcrfpy.sceneUI(scene) + + new_overlay = mcrfpy.Frame(0, 0, self.width, self.height) + new_overlay.fill_color = mcrfpy.Color(color[0], color[1], color[2], 255) + new_overlay.z_index = 9999 + new_ui.append(new_overlay) + + anim2 = mcrfpy.Animation("opacity", 0.0, half, "easeOut") + anim2.start(new_overlay) + + def cleanup(timer_name): + for i, elem in enumerate(new_ui): + if elem is new_overlay: + new_ui.remove(i) + break + self.is_transitioning = False + + mcrfpy.Timer("fade_done", cleanup, int(half * 1000) + 50, once=True) + + mcrfpy.Timer("fade_switch", phase2, int(half * 1000), once=True) + + def _flash(self, scene, duration, color): + quarter = duration / 4 + ui = mcrfpy.sceneUI(mcrfpy.currentScene()) + + overlay = mcrfpy.Frame(0, 0, self.width, self.height) + overlay.fill_color = mcrfpy.Color(color[0], color[1], color[2], 0) + overlay.z_index = 9999 + ui.append(overlay) + + anim = mcrfpy.Animation("opacity", 1.0, quarter, "easeOut") + anim.start(overlay) + + def phase2(timer_name): + mcrfpy.setScene(scene) + new_ui = mcrfpy.sceneUI(scene) + + new_overlay = mcrfpy.Frame(0, 0, self.width, self.height) + new_overlay.fill_color = mcrfpy.Color(color[0], color[1], color[2], 255) + new_overlay.z_index = 9999 + new_ui.append(new_overlay) + + anim2 = mcrfpy.Animation("opacity", 0.0, duration / 2, "easeIn") + anim2.start(new_overlay) + + def cleanup(timer_name): + for i, elem in enumerate(new_ui): + if elem is new_overlay: + new_ui.remove(i) + break + self.is_transitioning = False + + mcrfpy.Timer("flash_done", cleanup, int(duration * 500) + 50, once=True) + + mcrfpy.Timer("flash_switch", phase2, int(quarter * 2000), once=True) + + def _wipe(self, scene, duration, direction, color): + # Simplified wipe - right direction only for brevity + half = duration / 2 + ui = mcrfpy.sceneUI(mcrfpy.currentScene()) + + overlay = mcrfpy.Frame(0, 0, 0, self.height) + overlay.fill_color = mcrfpy.Color(color[0], color[1], color[2], 255) + overlay.z_index = 9999 + ui.append(overlay) + + anim = mcrfpy.Animation("w", float(self.width), half, "easeInOut") + anim.start(overlay) + + def phase2(timer_name): + mcrfpy.setScene(scene) + new_ui = mcrfpy.sceneUI(scene) + + new_overlay = mcrfpy.Frame(0, 0, self.width, self.height) + new_overlay.fill_color = mcrfpy.Color(color[0], color[1], color[2], 255) + new_overlay.z_index = 9999 + new_ui.append(new_overlay) + + anim2 = mcrfpy.Animation("x", float(self.width), half, "easeInOut") + anim2.start(new_overlay) + + def cleanup(timer_name): + for i, elem in enumerate(new_ui): + if elem is new_overlay: + new_ui.remove(i) + break + self.is_transitioning = False + + mcrfpy.Timer("wipe_done", cleanup, int(half * 1000) + 50, once=True) + + mcrfpy.Timer("wipe_switch", phase2, int(half * 1000), once=True) + + +# Usage +transitions = TransitionManager() + +# Various transition styles +transitions.go_to("game", effect="fade", duration=0.5) +transitions.go_to("menu", effect="flash", color=(255, 255, 255), duration=0.4) +transitions.go_to("next_level", effect="wipe", direction="right", duration=0.6) +transitions.go_to("options", effect="instant") \ No newline at end of file diff --git a/docs/cookbook/effects/effects_screen_shake_basic.py b/docs/cookbook/effects/effects_screen_shake_basic.py new file mode 100644 index 0000000..710d722 --- /dev/null +++ b/docs/cookbook/effects/effects_screen_shake_basic.py @@ -0,0 +1,38 @@ +"""McRogueFace - Screen Shake Effect (basic) + +Documentation: https://mcrogueface.github.io/cookbook/effects_screen_shake +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/effects/effects_screen_shake_basic.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +def screen_shake(frame, intensity=5, duration=0.2): + """ + Shake a frame/container by animating its position. + + Args: + frame: The UI Frame to shake (often a container for all game elements) + intensity: Maximum pixel offset + duration: Total shake duration in seconds + """ + original_x = frame.x + original_y = frame.y + + # Quick shake to offset position + shake_x = mcrfpy.Animation("x", float(original_x + intensity), duration / 4, "easeOut") + shake_x.start(frame) + + # Schedule return to center + def return_to_center(timer_name): + anim = mcrfpy.Animation("x", float(original_x), duration / 2, "easeInOut") + anim.start(frame) + + mcrfpy.Timer("shake_return", return_to_center, int(duration * 250), once=True) + +# Usage - wrap your game content in a Frame +game_container = mcrfpy.Frame(0, 0, 1024, 768) +# ... add game elements to game_container.children ... +screen_shake(game_container, intensity=8, duration=0.3) \ No newline at end of file diff --git a/docs/cookbook/effects/effects_screen_shake_multi.py b/docs/cookbook/effects/effects_screen_shake_multi.py new file mode 100644 index 0000000..dbb1285 --- /dev/null +++ b/docs/cookbook/effects/effects_screen_shake_multi.py @@ -0,0 +1,58 @@ +"""McRogueFace - Screen Shake Effect (multi) + +Documentation: https://mcrogueface.github.io/cookbook/effects_screen_shake +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/effects/effects_screen_shake_multi.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy +import math + +def directional_shake(shaker, direction_x, direction_y, intensity=10, duration=0.2): + """ + Shake in a specific direction (e.g., direction of impact). + + Args: + shaker: ScreenShakeManager instance + direction_x, direction_y: Direction vector (will be normalized) + intensity: Shake strength + duration: Shake duration + """ + # Normalize direction + length = math.sqrt(direction_x * direction_x + direction_y * direction_y) + if length == 0: + return + + dir_x = direction_x / length + dir_y = direction_y / length + + # Shake in the direction, then opposite, then back + shaker._animate_position( + shaker.original_x + dir_x * intensity, + shaker.original_y + dir_y * intensity, + duration / 3 + ) + + def reverse(timer_name): + shaker._animate_position( + shaker.original_x - dir_x * intensity * 0.5, + shaker.original_y - dir_y * intensity * 0.5, + duration / 3 + ) + + def reset(timer_name): + shaker._animate_position( + shaker.original_x, + shaker.original_y, + duration / 3 + ) + shaker.is_shaking = False + + mcrfpy.Timer("dir_shake_rev", reverse, int(duration * 333), once=True) + mcrfpy.Timer("dir_shake_reset", reset, int(duration * 666), once=True) + +# Usage: shake away from impact direction +hit_from_x, hit_from_y = -1, 0 # Hit from the left +directional_shake(shaker, hit_from_x, hit_from_y, intensity=12) \ No newline at end of file diff --git a/docs/cookbook/grid/grid_cell_highlighting_animated.py b/docs/cookbook/grid/grid_cell_highlighting_animated.py new file mode 100644 index 0000000..14ae702 --- /dev/null +++ b/docs/cookbook/grid/grid_cell_highlighting_animated.py @@ -0,0 +1,74 @@ +"""McRogueFace - Cell Highlighting (Targeting) (animated) + +Documentation: https://mcrogueface.github.io/cookbook/grid_cell_highlighting +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/grid/grid_cell_highlighting_animated.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +class TargetingSystem: + """Handle ability targeting with visual feedback.""" + + def __init__(self, grid, player): + self.grid = grid + self.player = player + self.highlights = HighlightManager(grid) + self.current_ability = None + self.valid_targets = set() + + def start_targeting(self, ability): + """Begin targeting for an ability.""" + self.current_ability = ability + px, py = self.player.pos + + # Get valid targets based on ability + if ability.target_type == 'self': + self.valid_targets = {(px, py)} + elif ability.target_type == 'adjacent': + self.valid_targets = get_adjacent(px, py) + elif ability.target_type == 'ranged': + self.valid_targets = get_radius_range(px, py, ability.range) + elif ability.target_type == 'line': + self.valid_targets = get_line_range(px, py, ability.range) + + # Filter to visible tiles only + self.valid_targets = { + (x, y) for x, y in self.valid_targets + if grid.is_in_fov(x, y) + } + + # Show valid targets + self.highlights.add('attack', self.valid_targets) + + def update_hover(self, x, y): + """Update when cursor moves.""" + if not self.current_ability: + return + + # Clear previous AoE preview + self.highlights.remove('danger') + + if (x, y) in self.valid_targets: + # Valid target - highlight it + self.highlights.add('select', [(x, y)]) + + # Show AoE if applicable + if self.current_ability.aoe_radius > 0: + aoe = get_radius_range(x, y, self.current_ability.aoe_radius, True) + self.highlights.add('danger', aoe) + else: + self.highlights.remove('select') + + def confirm_target(self, x, y): + """Confirm target selection.""" + if (x, y) in self.valid_targets: + self.cancel_targeting() + return (x, y) + return None + + def cancel_targeting(self): + """Cancel targeting mode.""" + self.current_ability = None + self.valid_targets = set() + self.highlights.clear() \ No newline at end of file diff --git a/docs/cookbook/grid/grid_cell_highlighting_basic.py b/docs/cookbook/grid/grid_cell_highlighting_basic.py new file mode 100644 index 0000000..830e17e --- /dev/null +++ b/docs/cookbook/grid/grid_cell_highlighting_basic.py @@ -0,0 +1,74 @@ +"""McRogueFace - Cell Highlighting (Targeting) (basic) + +Documentation: https://mcrogueface.github.io/cookbook/grid_cell_highlighting +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/grid/grid_cell_highlighting_basic.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +def get_line_range(start_x, start_y, max_range): + """Get cells in cardinal directions (ranged attack).""" + cells = set() + + for dx, dy in [(0, -1), (0, 1), (-1, 0), (1, 0)]: + for dist in range(1, max_range + 1): + x = start_x + dx * dist + y = start_y + dy * dist + + # Stop if wall blocks line of sight + if not grid.at(x, y).transparent: + break + + cells.add((x, y)) + + return cells + +def get_radius_range(center_x, center_y, radius, include_center=False): + """Get cells within a radius (spell area).""" + cells = set() + + for x in range(center_x - radius, center_x + radius + 1): + for y in range(center_y - radius, center_y + radius + 1): + # Euclidean distance + dist = ((x - center_x) ** 2 + (y - center_y) ** 2) ** 0.5 + if dist <= radius: + if include_center or (x, y) != (center_x, center_y): + cells.add((x, y)) + + return cells + +def get_cone_range(origin_x, origin_y, direction, length, spread): + """Get cells in a cone (breath attack).""" + import math + cells = set() + + # Direction angles (in radians) + angles = { + 'n': -math.pi / 2, + 's': math.pi / 2, + 'e': 0, + 'w': math.pi, + 'ne': -math.pi / 4, + 'nw': -3 * math.pi / 4, + 'se': math.pi / 4, + 'sw': 3 * math.pi / 4 + } + + base_angle = angles.get(direction, 0) + half_spread = math.radians(spread / 2) + + for x in range(origin_x - length, origin_x + length + 1): + for y in range(origin_y - length, origin_y + length + 1): + dx = x - origin_x + dy = y - origin_y + dist = (dx * dx + dy * dy) ** 0.5 + + if dist > 0 and dist <= length: + angle = math.atan2(dy, dx) + angle_diff = abs((angle - base_angle + math.pi) % (2 * math.pi) - math.pi) + + if angle_diff <= half_spread: + cells.add((x, y)) + + return cells \ No newline at end of file diff --git a/docs/cookbook/grid/grid_cell_highlighting_multi.py b/docs/cookbook/grid/grid_cell_highlighting_multi.py new file mode 100644 index 0000000..57112af --- /dev/null +++ b/docs/cookbook/grid/grid_cell_highlighting_multi.py @@ -0,0 +1,23 @@ +"""McRogueFace - Cell Highlighting (Targeting) (multi) + +Documentation: https://mcrogueface.github.io/cookbook/grid_cell_highlighting +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/grid/grid_cell_highlighting_multi.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +def show_path_preview(start, end): + """Highlight the path between two points.""" + path = find_path(start, end) # Your pathfinding function + + if path: + highlights.add('path', path) + + # Highlight destination specially + highlights.add('select', [end]) + +def hide_path_preview(): + """Clear path display.""" + highlights.remove('path') + highlights.remove('select') \ No newline at end of file diff --git a/docs/cookbook/grid/grid_dijkstra_basic.py b/docs/cookbook/grid/grid_dijkstra_basic.py new file mode 100644 index 0000000..dd318b1 --- /dev/null +++ b/docs/cookbook/grid/grid_dijkstra_basic.py @@ -0,0 +1,31 @@ +"""McRogueFace - Dijkstra Distance Maps (basic) + +Documentation: https://mcrogueface.github.io/cookbook/grid_dijkstra +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/grid/grid_dijkstra_basic.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +def ai_flee(entity, threat_x, threat_y): + """Move entity away from threat using Dijkstra map.""" + grid.compute_dijkstra(threat_x, threat_y) + + ex, ey = entity.pos + current_dist = grid.get_dijkstra_distance(ex, ey) + + # Find neighbor with highest distance + best_move = None + best_dist = current_dist + + for dx, dy in [(0, -1), (0, 1), (-1, 0), (1, 0)]: + nx, ny = ex + dx, ey + dy + + if grid.at(nx, ny).walkable: + dist = grid.get_dijkstra_distance(nx, ny) + if dist > best_dist: + best_dist = dist + best_move = (nx, ny) + + if best_move: + entity.pos = best_move \ No newline at end of file diff --git a/docs/cookbook/grid/grid_dijkstra_multi.py b/docs/cookbook/grid/grid_dijkstra_multi.py new file mode 100644 index 0000000..32d011e --- /dev/null +++ b/docs/cookbook/grid/grid_dijkstra_multi.py @@ -0,0 +1,44 @@ +"""McRogueFace - Dijkstra Distance Maps (multi) + +Documentation: https://mcrogueface.github.io/cookbook/grid_dijkstra +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/grid/grid_dijkstra_multi.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +# Cache Dijkstra maps when possible +class CachedDijkstra: + """Cache Dijkstra computations.""" + + def __init__(self, grid): + self.grid = grid + self.cache = {} + self.cache_valid = False + + def invalidate(self): + """Call when map changes.""" + self.cache = {} + self.cache_valid = False + + def get_distance(self, from_x, from_y, to_x, to_y): + """Get cached distance or compute.""" + key = (to_x, to_y) # Cache by destination + + if key not in self.cache: + self.grid.compute_dijkstra(to_x, to_y) + # Store all distances from this computation + self.cache[key] = self._snapshot_distances() + + return self.cache[key].get((from_x, from_y), float('inf')) + + def _snapshot_distances(self): + """Capture current distance values.""" + grid_w, grid_h = self.grid.grid_size + distances = {} + for x in range(grid_w): + for y in range(grid_h): + dist = self.grid.get_dijkstra_distance(x, y) + if dist != float('inf'): + distances[(x, y)] = dist + return distances \ No newline at end of file diff --git a/docs/cookbook/grid/grid_dungeon_generator_basic.py b/docs/cookbook/grid/grid_dungeon_generator_basic.py new file mode 100644 index 0000000..fd36d26 --- /dev/null +++ b/docs/cookbook/grid/grid_dungeon_generator_basic.py @@ -0,0 +1,125 @@ +"""McRogueFace - Room and Corridor Generator (basic) + +Documentation: https://mcrogueface.github.io/cookbook/grid_dungeon_generator +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/grid/grid_dungeon_generator_basic.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +class BSPNode: + """Node in a BSP tree for dungeon generation.""" + + MIN_SIZE = 6 + + def __init__(self, x, y, w, h): + self.x = x + self.y = y + self.w = w + self.h = h + self.left = None + self.right = None + self.room = None + + def split(self): + """Recursively split this node.""" + if self.left or self.right: + return False + + # Choose split direction + if self.w > self.h and self.w / self.h >= 1.25: + horizontal = False + elif self.h > self.w and self.h / self.w >= 1.25: + horizontal = True + else: + horizontal = random.random() < 0.5 + + max_size = (self.h if horizontal else self.w) - self.MIN_SIZE + if max_size <= self.MIN_SIZE: + return False + + split = random.randint(self.MIN_SIZE, max_size) + + if horizontal: + self.left = BSPNode(self.x, self.y, self.w, split) + self.right = BSPNode(self.x, self.y + split, self.w, self.h - split) + else: + self.left = BSPNode(self.x, self.y, split, self.h) + self.right = BSPNode(self.x + split, self.y, self.w - split, self.h) + + return True + + def create_rooms(self, grid): + """Create rooms in leaf nodes and connect siblings.""" + if self.left or self.right: + if self.left: + self.left.create_rooms(grid) + if self.right: + self.right.create_rooms(grid) + + # Connect children + if self.left and self.right: + left_room = self.left.get_room() + right_room = self.right.get_room() + if left_room and right_room: + connect_points(grid, left_room.center, right_room.center) + else: + # Leaf node - create room + w = random.randint(3, self.w - 2) + h = random.randint(3, self.h - 2) + x = self.x + random.randint(1, self.w - w - 1) + y = self.y + random.randint(1, self.h - h - 1) + self.room = Room(x, y, w, h) + carve_room(grid, self.room) + + def get_room(self): + """Get a room from this node or its children.""" + if self.room: + return self.room + + left_room = self.left.get_room() if self.left else None + right_room = self.right.get_room() if self.right else None + + if left_room and right_room: + return random.choice([left_room, right_room]) + return left_room or right_room + + +def generate_bsp_dungeon(grid, iterations=4): + """Generate a BSP-based dungeon.""" + grid_w, grid_h = grid.grid_size + + # Fill with walls + for x in range(grid_w): + for y in range(grid_h): + point = grid.at(x, y) + point.tilesprite = TILE_WALL + point.walkable = False + point.transparent = False + + # Build BSP tree + root = BSPNode(0, 0, grid_w, grid_h) + nodes = [root] + + for _ in range(iterations): + new_nodes = [] + for node in nodes: + if node.split(): + new_nodes.extend([node.left, node.right]) + nodes = new_nodes or nodes + + # Create rooms and corridors + root.create_rooms(grid) + + # Collect all rooms + rooms = [] + def collect_rooms(node): + if node.room: + rooms.append(node.room) + if node.left: + collect_rooms(node.left) + if node.right: + collect_rooms(node.right) + + collect_rooms(root) + return rooms \ No newline at end of file diff --git a/docs/cookbook/grid/grid_dungeon_generator_complete.py b/docs/cookbook/grid/grid_dungeon_generator_complete.py new file mode 100644 index 0000000..e795dfa --- /dev/null +++ b/docs/cookbook/grid/grid_dungeon_generator_complete.py @@ -0,0 +1,148 @@ +"""McRogueFace - Room and Corridor Generator (complete) + +Documentation: https://mcrogueface.github.io/cookbook/grid_dungeon_generator +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/grid/grid_dungeon_generator_complete.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy +import random + +# Tile indices (adjust for your tileset) +TILE_FLOOR = 0 +TILE_WALL = 1 +TILE_DOOR = 2 +TILE_STAIRS_DOWN = 3 +TILE_STAIRS_UP = 4 + +class DungeonGenerator: + """Procedural dungeon generator with rooms and corridors.""" + + def __init__(self, grid, seed=None): + self.grid = grid + self.grid_w, self.grid_h = grid.grid_size + self.rooms = [] + + if seed is not None: + random.seed(seed) + + def generate(self, room_count=8, min_room=4, max_room=10): + """Generate a complete dungeon level.""" + self.rooms = [] + + # Fill with walls + self._fill_walls() + + # Place rooms + attempts = 0 + max_attempts = room_count * 10 + + while len(self.rooms) < room_count and attempts < max_attempts: + attempts += 1 + + # Random room size + w = random.randint(min_room, max_room) + h = random.randint(min_room, max_room) + + # Random position (leaving border) + x = random.randint(1, self.grid_w - w - 2) + y = random.randint(1, self.grid_h - h - 2) + + room = Room(x, y, w, h) + + # Check overlap + if not any(room.intersects(r) for r in self.rooms): + self._carve_room(room) + + # Connect to previous room + if self.rooms: + self._dig_corridor(self.rooms[-1].center, room.center) + + self.rooms.append(room) + + # Place stairs + if len(self.rooms) >= 2: + self._place_stairs() + + return self.rooms + + def _fill_walls(self): + """Fill the entire grid with wall tiles.""" + for x in range(self.grid_w): + for y in range(self.grid_h): + point = self.grid.at(x, y) + point.tilesprite = TILE_WALL + point.walkable = False + point.transparent = False + + def _carve_room(self, room): + """Carve out a room, making it walkable.""" + for x in range(room.x, room.x + room.width): + for y in range(room.y, room.y + room.height): + self._set_floor(x, y) + + def _set_floor(self, x, y): + """Set a single tile as floor.""" + if 0 <= x < self.grid_w and 0 <= y < self.grid_h: + point = self.grid.at(x, y) + point.tilesprite = TILE_FLOOR + point.walkable = True + point.transparent = True + + def _dig_corridor(self, start, end): + """Dig an L-shaped corridor between two points.""" + x1, y1 = start + x2, y2 = end + + # Randomly choose horizontal-first or vertical-first + if random.random() < 0.5: + # Horizontal then vertical + self._dig_horizontal(x1, x2, y1) + self._dig_vertical(y1, y2, x2) + else: + # Vertical then horizontal + self._dig_vertical(y1, y2, x1) + self._dig_horizontal(x1, x2, y2) + + def _dig_horizontal(self, x1, x2, y): + """Dig a horizontal tunnel.""" + for x in range(min(x1, x2), max(x1, x2) + 1): + self._set_floor(x, y) + + def _dig_vertical(self, y1, y2, x): + """Dig a vertical tunnel.""" + for y in range(min(y1, y2), max(y1, y2) + 1): + self._set_floor(x, y) + + def _place_stairs(self): + """Place stairs in first and last rooms.""" + # Stairs up in first room + start_room = self.rooms[0] + sx, sy = start_room.center + point = self.grid.at(sx, sy) + point.tilesprite = TILE_STAIRS_UP + + # Stairs down in last room + end_room = self.rooms[-1] + ex, ey = end_room.center + point = self.grid.at(ex, ey) + point.tilesprite = TILE_STAIRS_DOWN + + return (sx, sy), (ex, ey) + + def get_spawn_point(self): + """Get a good spawn point for the player.""" + if self.rooms: + return self.rooms[0].center + return (self.grid_w // 2, self.grid_h // 2) + + def get_random_floor(self): + """Get a random walkable floor tile.""" + floors = [] + for x in range(self.grid_w): + for y in range(self.grid_h): + if self.grid.at(x, y).walkable: + floors.append((x, y)) + return random.choice(floors) if floors else None \ No newline at end of file diff --git a/docs/cookbook/grid/grid_fog_of_war.py b/docs/cookbook/grid/grid_fog_of_war.py new file mode 100644 index 0000000..5a2fc7f --- /dev/null +++ b/docs/cookbook/grid/grid_fog_of_war.py @@ -0,0 +1,20 @@ +"""McRogueFace - Basic Fog of War (grid_fog_of_war) + +Documentation: https://mcrogueface.github.io/cookbook/grid_fog_of_war +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/grid/grid_fog_of_war.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +# Shadowcasting (default) - fast and produces nice results +grid.compute_fov(x, y, 10, mcrfpy.FOV.SHADOW) + +# Recursive shadowcasting - slightly different corner behavior +grid.compute_fov(x, y, 10, mcrfpy.FOV.RECURSIVE_SHADOW) + +# Diamond - simple but produces diamond-shaped FOV +grid.compute_fov(x, y, 10, mcrfpy.FOV.DIAMOND) + +# Permissive - sees more tiles, good for tactical games +grid.compute_fov(x, y, 10, mcrfpy.FOV.PERMISSIVE) \ No newline at end of file diff --git a/docs/cookbook/grid/grid_multi_layer_basic.py b/docs/cookbook/grid/grid_multi_layer_basic.py new file mode 100644 index 0000000..a9dda3c --- /dev/null +++ b/docs/cookbook/grid/grid_multi_layer_basic.py @@ -0,0 +1,114 @@ +"""McRogueFace - Multi-Layer Tiles (basic) + +Documentation: https://mcrogueface.github.io/cookbook/grid_multi_layer +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/grid/grid_multi_layer_basic.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +class EffectLayer: + """Manage visual effects with color overlays.""" + + def __init__(self, grid, z_index=2): + self.grid = grid + self.layer = grid.add_layer("color", z_index=z_index) + self.effects = {} # (x, y) -> effect_data + + def add_effect(self, x, y, effect_type, duration=None, **kwargs): + """Add a visual effect.""" + self.effects[(x, y)] = { + 'type': effect_type, + 'duration': duration, + 'time': 0, + **kwargs + } + + def remove_effect(self, x, y): + """Remove an effect.""" + if (x, y) in self.effects: + del self.effects[(x, y)] + self.layer.set(x, y, mcrfpy.Color(0, 0, 0, 0)) + + def update(self, dt): + """Update all effects.""" + import math + + to_remove = [] + + for (x, y), effect in self.effects.items(): + effect['time'] += dt + + # Check expiration + if effect['duration'] and effect['time'] >= effect['duration']: + to_remove.append((x, y)) + continue + + # Calculate color based on effect type + color = self._calculate_color(effect) + self.layer.set(x, y, color) + + for pos in to_remove: + self.remove_effect(*pos) + + def _calculate_color(self, effect): + """Get color for an effect at current time.""" + import math + + t = effect['time'] + effect_type = effect['type'] + + if effect_type == 'fire': + # Flickering orange/red + flicker = 0.7 + 0.3 * math.sin(t * 10) + return mcrfpy.Color( + 255, + int(100 + 50 * math.sin(t * 8)), + 0, + int(180 * flicker) + ) + + elif effect_type == 'poison': + # Pulsing green + pulse = 0.5 + 0.5 * math.sin(t * 3) + return mcrfpy.Color(0, 200, 0, int(100 * pulse)) + + elif effect_type == 'ice': + # Static blue with shimmer + shimmer = 0.8 + 0.2 * math.sin(t * 5) + return mcrfpy.Color(100, 150, 255, int(120 * shimmer)) + + elif effect_type == 'blood': + # Fading red + duration = effect.get('duration', 5) + fade = 1 - (t / duration) if duration else 1 + return mcrfpy.Color(150, 0, 0, int(150 * fade)) + + elif effect_type == 'highlight': + # Pulsing highlight + pulse = 0.5 + 0.5 * math.sin(t * 4) + base = effect.get('color', mcrfpy.Color(255, 255, 0, 100)) + return mcrfpy.Color(base.r, base.g, base.b, int(base.a * pulse)) + + return mcrfpy.Color(128, 128, 128, 50) + + +# Usage +effects = EffectLayer(grid) + +# Add fire effect (permanent) +effects.add_effect(5, 5, 'fire') + +# Add blood stain (fades over 10 seconds) +effects.add_effect(10, 10, 'blood', duration=10) + +# Add poison cloud +for x in range(8, 12): + for y in range(8, 12): + effects.add_effect(x, y, 'poison', duration=5) + +# Update in game loop +def game_update(runtime): + effects.update(0.016) # 60 FPS + +mcrfpy.setTimer("effects", game_update, 16) \ No newline at end of file diff --git a/docs/cookbook/grid/grid_multi_layer_complete.py b/docs/cookbook/grid/grid_multi_layer_complete.py new file mode 100644 index 0000000..c002ab2 --- /dev/null +++ b/docs/cookbook/grid/grid_multi_layer_complete.py @@ -0,0 +1,38 @@ +"""McRogueFace - Multi-Layer Tiles (complete) + +Documentation: https://mcrogueface.github.io/cookbook/grid_multi_layer +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/grid/grid_multi_layer_complete.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +class OptimizedLayers: + """Performance-optimized layer management.""" + + def __init__(self, grid): + self.grid = grid + self.dirty_effects = set() # Only update changed cells + self.batch_updates = [] + + def mark_dirty(self, x, y): + """Mark a cell as needing update.""" + self.dirty_effects.add((x, y)) + + def batch_set(self, layer, cells_and_values): + """Queue batch updates.""" + self.batch_updates.append((layer, cells_and_values)) + + def flush(self): + """Apply all queued updates.""" + for layer, updates in self.batch_updates: + for x, y, value in updates: + layer.set(x, y, value) + self.batch_updates = [] + + def update_dirty_only(self, effect_layer, effect_calculator): + """Only update cells marked dirty.""" + for x, y in self.dirty_effects: + color = effect_calculator(x, y) + effect_layer.set(x, y, color) + self.dirty_effects.clear() \ No newline at end of file diff --git a/docs/cookbook/ui/ui_health_bar_animated.py b/docs/cookbook/ui/ui_health_bar_animated.py new file mode 100644 index 0000000..4856475 --- /dev/null +++ b/docs/cookbook/ui/ui_health_bar_animated.py @@ -0,0 +1,120 @@ +"""McRogueFace - Health Bar Widget (animated) + +Documentation: https://mcrogueface.github.io/cookbook/ui_health_bar +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/ui/ui_health_bar_animated.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +class AnimatedHealthBar: + """Health bar with smooth fill animation.""" + + def __init__(self, x, y, w, h, current, maximum): + self.x = x + self.y = y + self.w = w + self.h = h + self.current = current + self.display_current = current # What's visually shown + self.maximum = maximum + self.timer_name = f"hp_anim_{id(self)}" + + # Background + self.background = mcrfpy.Frame(x, y, w, h) + self.background.fill_color = mcrfpy.Color(40, 40, 40) + self.background.outline = 2 + self.background.outline_color = mcrfpy.Color(60, 60, 60) + + # Damage preview (shows recent damage in different color) + self.damage_fill = mcrfpy.Frame(x + 2, y + 2, w - 4, h - 4) + self.damage_fill.fill_color = mcrfpy.Color(180, 50, 50) + self.damage_fill.outline = 0 + + # Main fill + self.fill = mcrfpy.Frame(x + 2, y + 2, w - 4, h - 4) + self.fill.fill_color = mcrfpy.Color(50, 200, 50) + self.fill.outline = 0 + + self._update_display() + + def _update_display(self): + """Update the visual fill based on display_current.""" + ratio = max(0, min(1, self.display_current / self.maximum)) + self.fill.w = (self.w - 4) * ratio + + # Color based on ratio + if ratio > 0.6: + self.fill.fill_color = mcrfpy.Color(50, 200, 50) + elif ratio > 0.3: + self.fill.fill_color = mcrfpy.Color(230, 180, 30) + else: + self.fill.fill_color = mcrfpy.Color(200, 50, 50) + + def set_health(self, new_current, animate=True): + """ + Set health with optional animation. + + Args: + new_current: New health value + animate: Whether to animate the transition + """ + old_current = self.current + self.current = max(0, min(self.maximum, new_current)) + + if not animate: + self.display_current = self.current + self._update_display() + return + + # Show damage preview immediately + if self.current < old_current: + damage_ratio = self.current / self.maximum + self.damage_fill.w = (self.w - 4) * (old_current / self.maximum) + + # Animate the fill + self._start_animation() + + def _start_animation(self): + """Start animating toward target health.""" + mcrfpy.delTimer(self.timer_name) + + def animate_step(dt): + # Lerp toward target + diff = self.current - self.display_current + if abs(diff) < 0.5: + self.display_current = self.current + mcrfpy.delTimer(self.timer_name) + # Also update damage preview + self.damage_fill.w = self.fill.w + else: + # Move 10% of the way each frame + self.display_current += diff * 0.1 + + self._update_display() + + mcrfpy.setTimer(self.timer_name, animate_step, 16) + + def damage(self, amount): + """Apply damage with animation.""" + self.set_health(self.current - amount, animate=True) + + def heal(self, amount): + """Apply healing with animation.""" + self.set_health(self.current + amount, animate=True) + + def add_to_scene(self, ui): + """Add all frames to scene.""" + ui.append(self.background) + ui.append(self.damage_fill) + ui.append(self.fill) + + +# Usage +hp_bar = AnimatedHealthBar(50, 50, 300, 30, current=100, maximum=100) +hp_bar.add_to_scene(ui) + +# Damage will animate smoothly +hp_bar.damage(40) \ No newline at end of file diff --git a/docs/cookbook/ui/ui_health_bar_basic.py b/docs/cookbook/ui/ui_health_bar_basic.py new file mode 100644 index 0000000..4e711e6 --- /dev/null +++ b/docs/cookbook/ui/ui_health_bar_basic.py @@ -0,0 +1,43 @@ +"""McRogueFace - Health Bar Widget (basic) + +Documentation: https://mcrogueface.github.io/cookbook/ui_health_bar +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/ui/ui_health_bar_basic.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +mcrfpy.createScene("game") +mcrfpy.setScene("game") +ui = mcrfpy.sceneUI("game") + +# Player health bar at top +player_hp = EnhancedHealthBar(10, 10, 300, 30, 100, 100) +player_hp.add_to_scene(ui) + +# Enemy health bar +enemy_hp = EnhancedHealthBar(400, 10, 200, 20, 50, 50) +enemy_hp.add_to_scene(ui) + +# Simulate combat +def combat_tick(dt): + import random + if random.random() < 0.3: + player_hp.damage(random.randint(5, 15)) + if random.random() < 0.4: + enemy_hp.damage(random.randint(3, 8)) + +mcrfpy.setTimer("combat", combat_tick, 1000) + +# Keyboard controls for testing +def on_key(key, state): + if state != "start": + return + if key == "H": + player_hp.heal(20) + elif key == "D": + player_hp.damage(10) + +mcrfpy.keypressScene(on_key) \ No newline at end of file diff --git a/docs/cookbook/ui/ui_health_bar_enhanced.py b/docs/cookbook/ui/ui_health_bar_enhanced.py new file mode 100644 index 0000000..e0c128d --- /dev/null +++ b/docs/cookbook/ui/ui_health_bar_enhanced.py @@ -0,0 +1,123 @@ +"""McRogueFace - Health Bar Widget (enhanced) + +Documentation: https://mcrogueface.github.io/cookbook/ui_health_bar +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/ui/ui_health_bar_enhanced.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +class EnhancedHealthBar: + """Health bar with text display, color transitions, and animations.""" + + def __init__(self, x, y, w, h, current, maximum, show_text=True): + self.x = x + self.y = y + self.w = w + self.h = h + self.current = current + self.maximum = maximum + self.show_text = show_text + + # Color thresholds (ratio -> color) + self.colors = { + 0.6: mcrfpy.Color(50, 205, 50), # Green when > 60% + 0.3: mcrfpy.Color(255, 165, 0), # Orange when > 30% + 0.0: mcrfpy.Color(220, 20, 20), # Red when <= 30% + } + + # Background frame with dark fill + self.background = mcrfpy.Frame(x, y, w, h) + self.background.fill_color = mcrfpy.Color(30, 30, 30) + self.background.outline = 2 + self.background.outline_color = mcrfpy.Color(100, 100, 100) + + # Fill frame (nested inside background conceptually) + padding = 2 + self.fill = mcrfpy.Frame( + x + padding, + y + padding, + w - padding * 2, + h - padding * 2 + ) + self.fill.outline = 0 + + # Text label + self.label = None + if show_text: + self.label = mcrfpy.Caption( + "", + mcrfpy.default_font, + x + w / 2 - 20, + y + h / 2 - 8 + ) + self.label.fill_color = mcrfpy.Color(255, 255, 255) + self.label.outline = 1 + self.label.outline_color = mcrfpy.Color(0, 0, 0) + + self._update() + + def _get_color_for_ratio(self, ratio): + """Get the appropriate color based on health ratio.""" + for threshold, color in sorted(self.colors.items(), reverse=True): + if ratio > threshold: + return color + # Return the lowest threshold color if ratio is 0 or below + return self.colors[0.0] + + def _update(self): + """Update fill width, color, and text.""" + ratio = max(0, min(1, self.current / self.maximum)) + + # Update fill width (accounting for padding) + padding = 2 + self.fill.w = (self.w - padding * 2) * ratio + + # Update color based on ratio + self.fill.fill_color = self._get_color_for_ratio(ratio) + + # Update text + if self.label: + self.label.text = f"{int(self.current)}/{int(self.maximum)}" + # Center the text + text_width = len(self.label.text) * 8 # Approximate + self.label.x = self.x + (self.w - text_width) / 2 + + def set_health(self, current, maximum=None): + """Update health values.""" + self.current = max(0, current) + if maximum is not None: + self.maximum = maximum + self._update() + + def damage(self, amount): + """Apply damage (convenience method).""" + self.set_health(self.current - amount) + + def heal(self, amount): + """Apply healing (convenience method).""" + self.set_health(min(self.maximum, self.current + amount)) + + def add_to_scene(self, ui): + """Add all components to scene UI.""" + ui.append(self.background) + ui.append(self.fill) + if self.label: + ui.append(self.label) + + +# Usage +mcrfpy.createScene("demo") +mcrfpy.setScene("demo") +ui = mcrfpy.sceneUI("demo") + +# Create enhanced health bar +hp = EnhancedHealthBar(50, 50, 250, 25, current=100, maximum=100) +hp.add_to_scene(ui) + +# Simulate damage +hp.damage(30) # Now 70/100, shows green +hp.damage(25) # Now 45/100, shows orange +hp.damage(20) # Now 25/100, shows red \ No newline at end of file diff --git a/docs/cookbook/ui/ui_health_bar_multi.py b/docs/cookbook/ui/ui_health_bar_multi.py new file mode 100644 index 0000000..fc20554 --- /dev/null +++ b/docs/cookbook/ui/ui_health_bar_multi.py @@ -0,0 +1,108 @@ +"""McRogueFace - Health Bar Widget (multi) + +Documentation: https://mcrogueface.github.io/cookbook/ui_health_bar +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/ui/ui_health_bar_multi.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +class ResourceBar: + """Generic resource bar that can represent any stat.""" + + def __init__(self, x, y, w, h, current, maximum, + fill_color, bg_color=None, label=""): + self.x = x + self.y = y + self.w = w + self.h = h + self.current = current + self.maximum = maximum + self.label_text = label + + if bg_color is None: + bg_color = mcrfpy.Color(30, 30, 30) + + # Background + self.background = mcrfpy.Frame(x, y, w, h) + self.background.fill_color = bg_color + self.background.outline = 1 + self.background.outline_color = mcrfpy.Color(60, 60, 60) + + # Fill + self.fill = mcrfpy.Frame(x + 1, y + 1, w - 2, h - 2) + self.fill.fill_color = fill_color + self.fill.outline = 0 + + # Label (left side) + self.label = mcrfpy.Caption(label, mcrfpy.default_font, x - 30, y + 2) + self.label.fill_color = mcrfpy.Color(200, 200, 200) + + self._update() + + def _update(self): + ratio = max(0, min(1, self.current / self.maximum)) + self.fill.w = (self.w - 2) * ratio + + def set_value(self, current, maximum=None): + self.current = max(0, current) + if maximum: + self.maximum = maximum + self._update() + + def add_to_scene(self, ui): + if self.label_text: + ui.append(self.label) + ui.append(self.background) + ui.append(self.fill) + + +class PlayerStats: + """Collection of resource bars for a player.""" + + def __init__(self, x, y): + bar_width = 200 + bar_height = 18 + spacing = 25 + + self.hp = ResourceBar( + x, y, bar_width, bar_height, + current=100, maximum=100, + fill_color=mcrfpy.Color(220, 50, 50), + label="HP" + ) + + self.mp = ResourceBar( + x, y + spacing, bar_width, bar_height, + current=50, maximum=50, + fill_color=mcrfpy.Color(50, 100, 220), + label="MP" + ) + + self.stamina = ResourceBar( + x, y + spacing * 2, bar_width, bar_height, + current=80, maximum=80, + fill_color=mcrfpy.Color(50, 180, 50), + label="SP" + ) + + def add_to_scene(self, ui): + self.hp.add_to_scene(ui) + self.mp.add_to_scene(ui) + self.stamina.add_to_scene(ui) + + +# Usage +mcrfpy.createScene("stats_demo") +mcrfpy.setScene("stats_demo") +ui = mcrfpy.sceneUI("stats_demo") + +stats = PlayerStats(80, 20) +stats.add_to_scene(ui) + +# Update individual stats +stats.hp.set_value(75) +stats.mp.set_value(30) +stats.stamina.set_value(60) \ No newline at end of file diff --git a/docs/cookbook/ui/ui_menu_basic.py b/docs/cookbook/ui/ui_menu_basic.py new file mode 100644 index 0000000..184e4b4 --- /dev/null +++ b/docs/cookbook/ui/ui_menu_basic.py @@ -0,0 +1,53 @@ +"""McRogueFace - Selection Menu Widget (basic) + +Documentation: https://mcrogueface.github.io/cookbook/ui_menu +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/ui/ui_menu_basic.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +# Setup +mcrfpy.createScene("main_menu") +mcrfpy.setScene("main_menu") +ui = mcrfpy.sceneUI("main_menu") + +# Background +bg = mcrfpy.Frame(0, 0, 1024, 768) +bg.fill_color = mcrfpy.Color(20, 20, 35) +ui.append(bg) + +# Title +title = mcrfpy.Caption("DUNGEON QUEST", mcrfpy.default_font, 350, 100) +title.fill_color = mcrfpy.Color(255, 200, 50) +ui.append(title) + +# Menu +def start_game(): + print("Starting game...") + +def show_options(): + print("Options...") + +menu = Menu( + 362, 250, + ["New Game", "Continue", "Options", "Quit"], + lambda i, opt: { + 0: start_game, + 1: lambda: print("Continue..."), + 2: show_options, + 3: mcrfpy.exit + }.get(i, lambda: None)(), + title="Main Menu" +) +menu.add_to_scene(ui) + +# Input +def on_key(key, state): + if state != "start": + return + menu.handle_key(key) + +mcrfpy.keypressScene(on_key) \ No newline at end of file diff --git a/docs/cookbook/ui/ui_menu_enhanced.py b/docs/cookbook/ui/ui_menu_enhanced.py new file mode 100644 index 0000000..b0e25cc --- /dev/null +++ b/docs/cookbook/ui/ui_menu_enhanced.py @@ -0,0 +1,159 @@ +"""McRogueFace - Selection Menu Widget (enhanced) + +Documentation: https://mcrogueface.github.io/cookbook/ui_menu +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/ui/ui_menu_enhanced.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +class MenuBar: + """Horizontal menu bar with dropdown submenus.""" + + def __init__(self, y=0, items=None): + """ + Create a menu bar. + + Args: + y: Y position (usually 0 for top) + items: List of dicts with 'label' and 'options' keys + """ + self.y = y + self.items = items or [] + self.selected_item = 0 + self.dropdown_open = False + self.dropdown_selected = 0 + + self.item_width = 100 + self.height = 30 + + # Main bar frame + self.bar = mcrfpy.Frame(0, y, 1024, self.height) + self.bar.fill_color = mcrfpy.Color(50, 50, 70) + self.bar.outline = 0 + + # Item captions + self.item_captions = [] + for i, item in enumerate(items): + cap = mcrfpy.Caption( + item['label'], + mcrfpy.default_font, + 10 + i * self.item_width, + y + 7 + ) + cap.fill_color = mcrfpy.Color(200, 200, 200) + self.item_captions.append(cap) + + # Dropdown panel (hidden initially) + self.dropdown = None + self.dropdown_captions = [] + + def _update_highlight(self): + """Update visual selection on bar.""" + for i, cap in enumerate(self.item_captions): + if i == self.selected_item and self.dropdown_open: + cap.fill_color = mcrfpy.Color(255, 255, 100) + else: + cap.fill_color = mcrfpy.Color(200, 200, 200) + + def _show_dropdown(self, ui): + """Show dropdown for selected item.""" + # Remove existing dropdown + self._hide_dropdown(ui) + + item = self.items[self.selected_item] + options = item.get('options', []) + + if not options: + return + + x = 5 + self.selected_item * self.item_width + y = self.y + self.height + width = 150 + height = len(options) * 25 + 10 + + self.dropdown = mcrfpy.Frame(x, y, width, height) + self.dropdown.fill_color = mcrfpy.Color(40, 40, 60, 250) + self.dropdown.outline = 1 + self.dropdown.outline_color = mcrfpy.Color(80, 80, 100) + ui.append(self.dropdown) + + self.dropdown_captions = [] + for i, opt in enumerate(options): + cap = mcrfpy.Caption( + opt['label'], + mcrfpy.default_font, + x + 10, + y + 5 + i * 25 + ) + cap.fill_color = mcrfpy.Color(200, 200, 200) + self.dropdown_captions.append(cap) + ui.append(cap) + + self.dropdown_selected = 0 + self._update_dropdown_highlight() + + def _hide_dropdown(self, ui): + """Hide dropdown menu.""" + if self.dropdown: + try: + ui.remove(self.dropdown) + except: + pass + self.dropdown = None + + for cap in self.dropdown_captions: + try: + ui.remove(cap) + except: + pass + self.dropdown_captions = [] + + def _update_dropdown_highlight(self): + """Update dropdown selection highlight.""" + for i, cap in enumerate(self.dropdown_captions): + if i == self.dropdown_selected: + cap.fill_color = mcrfpy.Color(255, 255, 100) + else: + cap.fill_color = mcrfpy.Color(200, 200, 200) + + def add_to_scene(self, ui): + ui.append(self.bar) + for cap in self.item_captions: + ui.append(cap) + + def handle_key(self, key, ui): + """Handle keyboard navigation.""" + if not self.dropdown_open: + if key == "Left": + self.selected_item = (self.selected_item - 1) % len(self.items) + self._update_highlight() + elif key == "Right": + self.selected_item = (self.selected_item + 1) % len(self.items) + self._update_highlight() + elif key == "Return" or key == "Down": + self.dropdown_open = True + self._show_dropdown(ui) + self._update_highlight() + else: + if key == "Up": + options = self.items[self.selected_item].get('options', []) + self.dropdown_selected = (self.dropdown_selected - 1) % len(options) + self._update_dropdown_highlight() + elif key == "Down": + options = self.items[self.selected_item].get('options', []) + self.dropdown_selected = (self.dropdown_selected + 1) % len(options) + self._update_dropdown_highlight() + elif key == "Return": + opt = self.items[self.selected_item]['options'][self.dropdown_selected] + if opt.get('action'): + opt['action']() + self.dropdown_open = False + self._hide_dropdown(ui) + self._update_highlight() + elif key == "Escape": + self.dropdown_open = False + self._hide_dropdown(ui) + self._update_highlight() \ No newline at end of file diff --git a/docs/cookbook/ui/ui_message_log_basic.py b/docs/cookbook/ui/ui_message_log_basic.py new file mode 100644 index 0000000..0722573 --- /dev/null +++ b/docs/cookbook/ui/ui_message_log_basic.py @@ -0,0 +1,54 @@ +"""McRogueFace - Message Log Widget (basic) + +Documentation: https://mcrogueface.github.io/cookbook/ui_message_log +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/ui/ui_message_log_basic.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +# Initialize +mcrfpy.createScene("game") +mcrfpy.setScene("game") +ui = mcrfpy.sceneUI("game") + +# Create log at bottom of screen +log = EnhancedMessageLog(10, 500, 700, 250, line_height=20) +ui.append(log.frame) + +# Simulate game events +def simulate_combat(dt): + import random + events = [ + ("You swing your sword!", "combat"), + ("The orc dodges!", "combat"), + ("Critical hit!", "combat"), + ("You found a potion!", "loot"), + ] + event = random.choice(events) + log.add(event[0], event[1]) + +# Add messages every 2 seconds for demo +mcrfpy.setTimer("combat_sim", simulate_combat, 2000) + +# Keyboard controls +def on_key(key, state): + if state != "start": + return + if key == "PageUp": + log.scroll_up(3) + elif key == "PageDown": + log.scroll_down(3) + elif key == "C": + log.set_filter('combat') + elif key == "L": + log.set_filter('loot') + elif key == "A": + log.set_filter(None) # All + +mcrfpy.keypressScene(on_key) + +log.system("Press PageUp/PageDown to scroll") +log.system("Press C for combat, L for loot, A for all") \ No newline at end of file diff --git a/docs/cookbook/ui/ui_message_log_enhanced.py b/docs/cookbook/ui/ui_message_log_enhanced.py new file mode 100644 index 0000000..e26b76e --- /dev/null +++ b/docs/cookbook/ui/ui_message_log_enhanced.py @@ -0,0 +1,27 @@ +"""McRogueFace - Message Log Widget (enhanced) + +Documentation: https://mcrogueface.github.io/cookbook/ui_message_log +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/ui/ui_message_log_enhanced.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +def handle_keys(key, state): + if state != "start": + return + + if key == "PageUp": + log.scroll_up(5) + elif key == "PageDown": + log.scroll_down(5) + +mcrfpy.keypressScene(handle_keys) + +# Or with mouse scroll on the frame +def on_log_scroll(x, y, button, action): + # Note: You may need to implement scroll detection + # based on your input system + pass + +log.frame.click = on_log_scroll \ No newline at end of file diff --git a/docs/cookbook/ui/ui_modal_dialog_basic.py b/docs/cookbook/ui/ui_modal_dialog_basic.py new file mode 100644 index 0000000..9f56efa --- /dev/null +++ b/docs/cookbook/ui/ui_modal_dialog_basic.py @@ -0,0 +1,69 @@ +"""McRogueFace - Modal Dialog Widget (basic) + +Documentation: https://mcrogueface.github.io/cookbook/ui_modal_dialog +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/ui/ui_modal_dialog_basic.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +# Scene setup +mcrfpy.createScene("game") +mcrfpy.setScene("game") +ui = mcrfpy.sceneUI("game") + +# Game background +bg = mcrfpy.Frame(0, 0, 1024, 768) +bg.fill_color = mcrfpy.Color(25, 35, 45) +ui.append(bg) + +title = mcrfpy.Caption("My Game", mcrfpy.default_font, 450, 50) +title.fill_color = mcrfpy.Color(255, 255, 255) +ui.append(title) + +# Quit button +quit_btn = mcrfpy.Frame(430, 400, 160, 50) +quit_btn.fill_color = mcrfpy.Color(150, 50, 50) +quit_btn.outline = 2 +quit_btn.outline_color = mcrfpy.Color(200, 100, 100) +ui.append(quit_btn) + +quit_label = mcrfpy.Caption("Quit Game", mcrfpy.default_font, 460, 415) +quit_label.fill_color = mcrfpy.Color(255, 255, 255) +ui.append(quit_label) + +# Confirmation dialog +confirm_dialog = None + +def show_quit_confirm(): + global confirm_dialog + + def on_response(index, label): + if label == "Yes": + mcrfpy.exit() + + confirm_dialog = EnhancedDialog( + "Quit Game?", + "Are you sure you want to quit?\nUnsaved progress will be lost.", + ["Yes", "No"], + DialogStyle.WARNING, + on_response + ) + confirm_dialog.add_to_scene(ui) + confirm_dialog.show() + +quit_btn.click = lambda x, y, b, a: show_quit_confirm() if a == "start" else None + +def on_key(key, state): + if state != "start": + return + + if confirm_dialog and confirm_dialog.handle_key(key): + return + + if key == "Escape": + show_quit_confirm() + +mcrfpy.keypressScene(on_key) \ No newline at end of file diff --git a/docs/cookbook/ui/ui_modal_dialog_enhanced.py b/docs/cookbook/ui/ui_modal_dialog_enhanced.py new file mode 100644 index 0000000..90f0cf8 --- /dev/null +++ b/docs/cookbook/ui/ui_modal_dialog_enhanced.py @@ -0,0 +1,78 @@ +"""McRogueFace - Modal Dialog Widget (enhanced) + +Documentation: https://mcrogueface.github.io/cookbook/ui_modal_dialog +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/ui/ui_modal_dialog_enhanced.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +class DialogManager: + """Manages a queue of dialogs.""" + + def __init__(self, ui): + self.ui = ui + self.queue = [] + self.current = None + + def show(self, title, message, buttons=None, style=None, callback=None): + """ + Queue a dialog to show. + + If no dialog is active, shows immediately. + Otherwise, queues for later. + """ + dialog_data = { + 'title': title, + 'message': message, + 'buttons': buttons or ["OK"], + 'style': style or DialogStyle.INFO, + 'callback': callback + } + + if self.current is None: + self._show_dialog(dialog_data) + else: + self.queue.append(dialog_data) + + def _show_dialog(self, data): + """Actually display a dialog.""" + def on_close(index, label): + if data['callback']: + data['callback'](index, label) + self._on_dialog_closed() + + self.current = EnhancedDialog( + data['title'], + data['message'], + data['buttons'], + data['style'], + on_close + ) + self.current.add_to_scene(self.ui) + self.current.show() + + def _on_dialog_closed(self): + """Handle dialog close, show next if queued.""" + self.current = None + + if self.queue: + next_dialog = self.queue.pop(0) + self._show_dialog(next_dialog) + + def handle_key(self, key): + """Forward key events to current dialog.""" + if self.current: + return self.current.handle_key(key) + return False + + +# Usage +manager = DialogManager(ui) + +# Queue multiple dialogs +manager.show("First", "This is the first message") +manager.show("Second", "This appears after closing the first") +manager.show("Third", "And this is last", ["Done"]) \ No newline at end of file diff --git a/docs/cookbook/ui/ui_tooltip_basic.py b/docs/cookbook/ui/ui_tooltip_basic.py new file mode 100644 index 0000000..4530d90 --- /dev/null +++ b/docs/cookbook/ui/ui_tooltip_basic.py @@ -0,0 +1,65 @@ +"""McRogueFace - Tooltip on Hover (basic) + +Documentation: https://mcrogueface.github.io/cookbook/ui_tooltip +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/ui/ui_tooltip_basic.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +mcrfpy.createScene("game") +mcrfpy.setScene("game") +ui = mcrfpy.sceneUI("game") + +# Background +bg = mcrfpy.Frame(0, 0, 1024, 768) +bg.fill_color = mcrfpy.Color(25, 25, 35) +ui.append(bg) + +# Create inventory slots with tooltips +class InventorySlot: + def __init__(self, x, y, item_name, item_desc, tooltip_mgr): + self.frame = mcrfpy.Frame(x, y, 50, 50) + self.frame.fill_color = mcrfpy.Color(50, 50, 60) + self.frame.outline = 1 + self.frame.outline_color = mcrfpy.Color(80, 80, 90) + + self.label = mcrfpy.Caption(item_name[:3], mcrfpy.default_font, x + 10, y + 15) + self.label.fill_color = mcrfpy.Color(200, 200, 200) + + tooltip_mgr.register(self.frame, item_desc, title=item_name) + + def add_to_scene(self, ui): + ui.append(self.frame) + ui.append(self.label) + +# Setup tooltip manager +tips = TooltipManager() +tips.hover_delay = 300 + +# Create inventory +items = [ + ("Health Potion", "Restores 50 HP\nConsumable"), + ("Mana Crystal", "Restores 30 MP\nConsumable"), + ("Iron Key", "Opens iron doors\nQuest Item"), + ("Gold Ring", "Worth 100 gold\nSell to merchant"), +] + +slots = [] +for i, (name, desc) in enumerate(items): + slot = InventorySlot(100 + i * 60, 100, name, desc, tips) + slot.add_to_scene(ui) + slots.append(slot) + +# Add tooltip last +tips.add_to_scene(ui) + +# Update loop +def update(dt): + from mcrfpy import automation + x, y = automation.position() + tips.update(x, y) + +mcrfpy.setTimer("update", update, 50) \ No newline at end of file diff --git a/docs/cookbook/ui/ui_tooltip_multi.py b/docs/cookbook/ui/ui_tooltip_multi.py new file mode 100644 index 0000000..069fda3 --- /dev/null +++ b/docs/cookbook/ui/ui_tooltip_multi.py @@ -0,0 +1,80 @@ +"""McRogueFace - Tooltip on Hover (multi) + +Documentation: https://mcrogueface.github.io/cookbook/ui_tooltip +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/cookbook/ui/ui_tooltip_multi.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +def create_info_icon(x, y, tooltip_text, ui): + """ + Create an info icon that shows tooltip on hover. + + Args: + x, y: Position of the icon + tooltip_text: Text to show + ui: Scene UI to add elements to + """ + # Info icon (small circle with "i") + icon = mcrfpy.Frame(x, y, 20, 20) + icon.fill_color = mcrfpy.Color(70, 130, 180) + icon.outline = 1 + icon.outline_color = mcrfpy.Color(100, 160, 210) + + icon_label = mcrfpy.Caption("i", mcrfpy.default_font, x + 6, y + 2) + icon_label.fill_color = mcrfpy.Color(255, 255, 255) + + # Tooltip (positioned to the right of icon) + tip_frame = mcrfpy.Frame(x + 25, y - 5, 180, 50) + tip_frame.fill_color = mcrfpy.Color(40, 40, 55, 240) + tip_frame.outline = 1 + tip_frame.outline_color = mcrfpy.Color(80, 80, 100) + tip_frame.visible = False + + tip_text = mcrfpy.Caption(tooltip_text, mcrfpy.default_font, x + 33, y + 3) + tip_text.fill_color = mcrfpy.Color(220, 220, 220) + tip_text.visible = False + + # Hover behavior + def on_icon_hover(mx, my, button, action): + tip_frame.visible = True + tip_text.visible = True + + icon.click = on_icon_hover + + # Track when to hide + def check_hover(dt): + from mcrfpy import automation + mx, my = automation.position() + if not (icon.x <= mx <= icon.x + icon.w and + icon.y <= my <= icon.y + icon.h): + if tip_frame.visible: + tip_frame.visible = False + tip_text.visible = False + + timer_name = f"info_hover_{id(icon)}" + mcrfpy.setTimer(timer_name, check_hover, 100) + + # Add to scene + ui.append(icon) + ui.append(icon_label) + ui.append(tip_frame) + ui.append(tip_text) + + return icon + + +# Usage +mcrfpy.createScene("info_demo") +mcrfpy.setScene("info_demo") +ui = mcrfpy.sceneUI("info_demo") + +# Setting with info icon +setting_label = mcrfpy.Caption("Difficulty:", mcrfpy.default_font, 100, 100) +setting_label.fill_color = mcrfpy.Color(200, 200, 200) +ui.append(setting_label) + +create_info_icon(200, 98, "Affects enemy\nHP and damage", ui) \ No newline at end of file diff --git a/docs/templates/complete/ai.py b/docs/templates/complete/ai.py new file mode 100644 index 0000000..8d5902f --- /dev/null +++ b/docs/templates/complete/ai.py @@ -0,0 +1,289 @@ +""" +ai.py - Enemy AI System for McRogueFace Roguelike + +Simple AI behaviors for enemies: chase player when visible, wander otherwise. +Uses A* pathfinding via entity.path_to() for movement. +""" + +from typing import List, Tuple, Optional, TYPE_CHECKING +import random + +from entities import Enemy, Player, Actor +from combat import melee_attack, CombatResult + +if TYPE_CHECKING: + from dungeon import Dungeon + + +class AIBehavior: + """Base class for AI behaviors.""" + + def take_turn(self, enemy: Enemy, player: Player, dungeon: 'Dungeon', + enemies: List[Enemy]) -> Optional[CombatResult]: + """ + Execute one turn of AI behavior. + + Args: + enemy: The enemy taking a turn + player: The player to potentially chase/attack + dungeon: The dungeon map + enemies: List of all enemies (for collision avoidance) + + Returns: + CombatResult if combat occurred, None otherwise + """ + raise NotImplementedError + + +class BasicChaseAI(AIBehavior): + """ + Simple chase AI: If player is visible, move toward them. + If adjacent, attack. Otherwise, stand still or wander. + """ + + def __init__(self, sight_range: int = 8): + """ + Args: + sight_range: How far the enemy can see + """ + self.sight_range = sight_range + + def can_see_player(self, enemy: Enemy, player: Player, + dungeon: 'Dungeon') -> bool: + """Check if enemy can see the player.""" + # Simple distance check combined with line of sight + distance = enemy.distance_to(player) + + if distance > self.sight_range: + return False + + # Check line of sight using Bresenham's line + return self._has_line_of_sight(enemy.x, enemy.y, player.x, player.y, dungeon) + + def _has_line_of_sight(self, x1: int, y1: int, x2: int, y2: int, + dungeon: 'Dungeon') -> bool: + """ + Check if there's a clear line of sight between two points. + Uses Bresenham's line algorithm. + """ + dx = abs(x2 - x1) + dy = abs(y2 - y1) + x, y = x1, y1 + sx = 1 if x1 < x2 else -1 + sy = 1 if y1 < y2 else -1 + + if dx > dy: + err = dx / 2 + while x != x2: + if not dungeon.is_transparent(x, y): + return False + err -= dy + if err < 0: + y += sy + err += dx + x += sx + else: + err = dy / 2 + while y != y2: + if not dungeon.is_transparent(x, y): + return False + err -= dx + if err < 0: + x += sx + err += dy + y += sy + + return True + + def get_path_to_player(self, enemy: Enemy, player: Player) -> List[Tuple[int, int]]: + """ + Get a path from enemy to player using A* pathfinding. + + Uses the entity's built-in path_to method. + """ + try: + path = enemy.entity.path_to(player.x, player.y) + # Convert path to list of tuples + return [(int(p[0]), int(p[1])) for p in path] if path else [] + except (AttributeError, TypeError): + # Fallback: simple direction-based movement + return [] + + def is_position_blocked(self, x: int, y: int, dungeon: 'Dungeon', + enemies: List[Enemy], player: Player) -> bool: + """Check if a position is blocked by terrain or another actor.""" + # Check terrain + if not dungeon.is_walkable(x, y): + return True + + # Check player position + if player.x == x and player.y == y: + return True + + # Check other enemies + for other in enemies: + if other.is_alive and other.x == x and other.y == y: + return True + + return False + + def move_toward(self, enemy: Enemy, target_x: int, target_y: int, + dungeon: 'Dungeon', enemies: List[Enemy], + player: Player) -> bool: + """ + Move one step toward the target position. + + Returns True if movement occurred, False otherwise. + """ + # Try pathfinding first + path = self.get_path_to_player(enemy, player) + + if path and len(path) > 1: + # First element is current position, second is next step + next_x, next_y = path[1] + else: + # Fallback: move in the general direction + dx = 0 + dy = 0 + + if target_x < enemy.x: + dx = -1 + elif target_x > enemy.x: + dx = 1 + + if target_y < enemy.y: + dy = -1 + elif target_y > enemy.y: + dy = 1 + + next_x = enemy.x + dx + next_y = enemy.y + dy + + # Check if the position is blocked + if not self.is_position_blocked(next_x, next_y, dungeon, enemies, player): + enemy.move_to(next_x, next_y) + return True + + # Try moving in just one axis + if next_x != enemy.x: + if not self.is_position_blocked(next_x, enemy.y, dungeon, enemies, player): + enemy.move_to(next_x, enemy.y) + return True + + if next_y != enemy.y: + if not self.is_position_blocked(enemy.x, next_y, dungeon, enemies, player): + enemy.move_to(enemy.x, next_y) + return True + + return False + + def take_turn(self, enemy: Enemy, player: Player, dungeon: 'Dungeon', + enemies: List[Enemy]) -> Optional[CombatResult]: + """Execute the enemy's turn.""" + if not enemy.is_alive: + return None + + # Check if adjacent to player (can attack) + if enemy.distance_to(player) == 1: + return melee_attack(enemy, player) + + # Check if can see player + if self.can_see_player(enemy, player, dungeon): + # Move toward player + self.move_toward(enemy, player.x, player.y, dungeon, enemies, player) + + return None + + +class WanderingAI(BasicChaseAI): + """ + AI that wanders randomly when it can't see the player. + More active than BasicChaseAI. + """ + + def __init__(self, sight_range: int = 8, wander_chance: float = 0.3): + """ + Args: + sight_range: How far the enemy can see + wander_chance: Probability of wandering each turn (0.0 to 1.0) + """ + super().__init__(sight_range) + self.wander_chance = wander_chance + + def wander(self, enemy: Enemy, dungeon: 'Dungeon', + enemies: List[Enemy], player: Player) -> bool: + """ + Move in a random direction. + + Returns True if movement occurred. + """ + # Random direction + directions = [ + (-1, 0), (1, 0), (0, -1), (0, 1), # Cardinal + (-1, -1), (1, -1), (-1, 1), (1, 1) # Diagonal + ] + random.shuffle(directions) + + for dx, dy in directions: + new_x = enemy.x + dx + new_y = enemy.y + dy + + if not self.is_position_blocked(new_x, new_y, dungeon, enemies, player): + enemy.move_to(new_x, new_y) + return True + + return False + + def take_turn(self, enemy: Enemy, player: Player, dungeon: 'Dungeon', + enemies: List[Enemy]) -> Optional[CombatResult]: + """Execute the enemy's turn with wandering behavior.""" + if not enemy.is_alive: + return None + + # Check if adjacent to player (can attack) + if enemy.distance_to(player) == 1: + return melee_attack(enemy, player) + + # Check if can see player + if self.can_see_player(enemy, player, dungeon): + # Chase player + self.move_toward(enemy, player.x, player.y, dungeon, enemies, player) + else: + # Wander randomly + if random.random() < self.wander_chance: + self.wander(enemy, dungeon, enemies, player) + + return None + + +# Default AI instance +default_ai = WanderingAI(sight_range=8, wander_chance=0.3) + + +def process_enemy_turns(enemies: List[Enemy], player: Player, + dungeon: 'Dungeon', + ai: AIBehavior = None) -> List[CombatResult]: + """ + Process turns for all enemies. + + Args: + enemies: List of all enemies + player: The player + dungeon: The dungeon map + ai: AI behavior to use (defaults to WanderingAI) + + Returns: + List of combat results from this round of enemy actions + """ + if ai is None: + ai = default_ai + + results = [] + + for enemy in enemies: + if enemy.is_alive: + result = ai.take_turn(enemy, player, dungeon, enemies) + if result: + results.append(result) + + return results diff --git a/docs/templates/complete/combat.py b/docs/templates/complete/combat.py new file mode 100644 index 0000000..b0f55e7 --- /dev/null +++ b/docs/templates/complete/combat.py @@ -0,0 +1,187 @@ +""" +combat.py - Combat System for McRogueFace Roguelike + +Handles attack resolution, damage calculation, and combat outcomes. +""" + +from dataclasses import dataclass +from typing import Tuple, Optional +import random + +from entities import Actor, Player, Enemy +from constants import ( + MSG_PLAYER_ATTACK, MSG_PLAYER_KILL, MSG_PLAYER_MISS, + MSG_ENEMY_ATTACK, MSG_ENEMY_MISS +) + + +@dataclass +class CombatResult: + """ + Result of a combat action. + + Attributes: + attacker: The attacking actor + defender: The defending actor + damage: Damage dealt (after defense) + killed: Whether the defender was killed + message: Human-readable result message + message_color: Color tuple for the message + """ + attacker: Actor + defender: Actor + damage: int + killed: bool + message: str + message_color: Tuple[int, int, int, int] + + +def calculate_damage(attack: int, defense: int, variance: float = 0.2) -> int: + """ + Calculate damage with some randomness. + + Args: + attack: Attacker's attack power + defense: Defender's defense value + variance: Random variance as percentage (0.2 = +/-20%) + + Returns: + Final damage amount (minimum 0) + """ + # Base damage is attack vs defense + base_damage = attack - defense + + # Add some variance + if base_damage > 0: + variance_amount = int(base_damage * variance) + damage = base_damage + random.randint(-variance_amount, variance_amount) + else: + # Small chance to do 1 damage even with high defense + damage = 1 if random.random() < 0.1 else 0 + + return max(0, damage) + + +def attack(attacker: Actor, defender: Actor) -> CombatResult: + """ + Perform an attack from one actor to another. + + Args: + attacker: The actor making the attack + defender: The actor being attacked + + Returns: + CombatResult with outcome details + """ + # Calculate damage + damage = calculate_damage( + attacker.fighter.attack, + defender.fighter.defense + ) + + # Apply damage + actual_damage = defender.fighter.take_damage(damage + defender.fighter.defense) + # Note: take_damage applies defense internally, so we add it back + # Actually, we calculated damage already reduced by defense, so just apply it: + defender.fighter.hp = max(0, defender.fighter.hp - damage + actual_damage) + # Simplified: just use take_damage properly + # Reset and do it right: + + # Apply raw damage (defense already calculated) + defender.fighter.hp = max(0, defender.fighter.hp - damage) + killed = not defender.is_alive + + # Generate message based on attacker/defender types + if isinstance(attacker, Player): + if killed: + message = MSG_PLAYER_KILL % defender.name + color = (255, 255, 100, 255) # Yellow for kills + elif damage > 0: + message = MSG_PLAYER_ATTACK % (defender.name, damage) + color = (255, 255, 255, 255) # White for hits + else: + message = MSG_PLAYER_MISS % defender.name + color = (150, 150, 150, 255) # Gray for misses + else: + if damage > 0: + message = MSG_ENEMY_ATTACK % (attacker.name, damage) + color = (255, 100, 100, 255) # Red for enemy hits + else: + message = MSG_ENEMY_MISS % attacker.name + color = (150, 150, 150, 255) # Gray for misses + + return CombatResult( + attacker=attacker, + defender=defender, + damage=damage, + killed=killed, + message=message, + message_color=color + ) + + +def melee_attack(attacker: Actor, defender: Actor) -> CombatResult: + """ + Perform a melee attack (bump attack). + This is the standard roguelike bump-to-attack. + + Args: + attacker: The actor making the attack + defender: The actor being attacked + + Returns: + CombatResult with outcome details + """ + return attack(attacker, defender) + + +def try_attack(attacker: Actor, target_x: int, target_y: int, + enemies: list, player: Optional[Player] = None) -> Optional[CombatResult]: + """ + Attempt to attack whatever is at the target position. + + Args: + attacker: The actor making the attack + target_x: X coordinate to attack + target_y: Y coordinate to attack + enemies: List of Enemy actors + player: The player (if attacker is an enemy) + + Returns: + CombatResult if something was attacked, None otherwise + """ + # Check if player is attacking + if isinstance(attacker, Player): + # Look for enemy at position + for enemy in enemies: + if enemy.is_alive and enemy.x == target_x and enemy.y == target_y: + return melee_attack(attacker, enemy) + else: + # Enemy attacking - check if player is at position + if player and player.x == target_x and player.y == target_y: + return melee_attack(attacker, player) + + return None + + +def process_kill(attacker: Actor, defender: Actor) -> int: + """ + Process the aftermath of killing an enemy. + + Args: + attacker: The actor that made the kill + defender: The actor that was killed + + Returns: + XP gained (if attacker is player and defender is enemy) + """ + xp_gained = 0 + + if isinstance(attacker, Player) and isinstance(defender, Enemy): + xp_gained = defender.xp_reward + attacker.gain_xp(xp_gained) + + # Remove the dead actor from the grid + defender.remove() + + return xp_gained diff --git a/docs/templates/complete/constants.py b/docs/templates/complete/constants.py new file mode 100644 index 0000000..cca8c24 --- /dev/null +++ b/docs/templates/complete/constants.py @@ -0,0 +1,210 @@ +""" +constants.py - Game Constants for McRogueFace Complete Roguelike Template + +All configuration values in one place for easy tweaking. +""" + +# ============================================================================= +# WINDOW AND DISPLAY +# ============================================================================= +SCREEN_WIDTH = 1024 +SCREEN_HEIGHT = 768 + +# Grid display area (where the dungeon is rendered) +GRID_X = 0 +GRID_Y = 0 +GRID_WIDTH = 800 +GRID_HEIGHT = 600 + +# Tile dimensions (must match your texture) +TILE_WIDTH = 16 +TILE_HEIGHT = 16 + +# ============================================================================= +# DUNGEON GENERATION +# ============================================================================= +# Size of the dungeon in tiles +DUNGEON_WIDTH = 80 +DUNGEON_HEIGHT = 45 + +# Room size constraints +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 15 + +# Enemy spawning per room +MAX_ENEMIES_PER_ROOM = 3 +MIN_ENEMIES_PER_ROOM = 0 + +# ============================================================================= +# SPRITE INDICES (for kenney_tinydungeon.png - 16x16 tiles) +# Adjust these if using a different tileset +# ============================================================================= +# Terrain +SPRITE_FLOOR = 48 # Dungeon floor +SPRITE_WALL = 33 # Wall tile +SPRITE_STAIRS_DOWN = 50 # Stairs going down +SPRITE_DOOR = 49 # Door tile + +# Player sprites +SPRITE_PLAYER = 84 # Player character (knight) + +# Enemy sprites +SPRITE_GOBLIN = 111 # Goblin enemy +SPRITE_ORC = 112 # Orc enemy +SPRITE_TROLL = 116 # Troll enemy + +# Items (for future expansion) +SPRITE_POTION = 89 # Health potion +SPRITE_CHEST = 91 # Treasure chest + +# ============================================================================= +# COLORS (R, G, B, A) +# ============================================================================= +# Map colors +COLOR_DARK_WALL = (50, 50, 100, 255) +COLOR_DARK_FLOOR = (30, 30, 50, 255) +COLOR_LIGHT_WALL = (100, 100, 150, 255) +COLOR_LIGHT_FLOOR = (80, 80, 100, 255) + +# FOV overlay colors +COLOR_FOG = (0, 0, 0, 200) # Unexplored areas +COLOR_REMEMBERED = (0, 0, 0, 128) # Seen but not visible +COLOR_VISIBLE = (0, 0, 0, 0) # Currently visible (transparent) + +# UI Colors +COLOR_UI_BG = (20, 20, 30, 230) +COLOR_UI_BORDER = (80, 80, 120, 255) +COLOR_TEXT = (255, 255, 255, 255) +COLOR_TEXT_HIGHLIGHT = (255, 255, 100, 255) + +# Health bar colors +COLOR_HP_BAR_BG = (80, 0, 0, 255) +COLOR_HP_BAR_FILL = (0, 180, 0, 255) +COLOR_HP_BAR_WARNING = (180, 180, 0, 255) +COLOR_HP_BAR_CRITICAL = (180, 0, 0, 255) + +# Message log colors +COLOR_MSG_DEFAULT = (255, 255, 255, 255) +COLOR_MSG_DAMAGE = (255, 100, 100, 255) +COLOR_MSG_HEAL = (100, 255, 100, 255) +COLOR_MSG_INFO = (100, 100, 255, 255) +COLOR_MSG_IMPORTANT = (255, 255, 100, 255) + +# ============================================================================= +# PLAYER STATS +# ============================================================================= +PLAYER_START_HP = 30 +PLAYER_START_ATTACK = 5 +PLAYER_START_DEFENSE = 2 + +# ============================================================================= +# ENEMY STATS +# Each enemy type: (hp, attack, defense, xp_reward, name) +# ============================================================================= +ENEMY_STATS = { + 'goblin': { + 'hp': 10, + 'attack': 3, + 'defense': 0, + 'xp': 35, + 'sprite': SPRITE_GOBLIN, + 'name': 'Goblin' + }, + 'orc': { + 'hp': 16, + 'attack': 4, + 'defense': 1, + 'xp': 50, + 'sprite': SPRITE_ORC, + 'name': 'Orc' + }, + 'troll': { + 'hp': 24, + 'attack': 6, + 'defense': 2, + 'xp': 100, + 'sprite': SPRITE_TROLL, + 'name': 'Troll' + } +} + +# Enemy spawn weights per dungeon level +# Format: {level: [(enemy_type, weight), ...]} +# Higher weight = more likely to spawn +ENEMY_SPAWN_WEIGHTS = { + 1: [('goblin', 100)], + 2: [('goblin', 80), ('orc', 20)], + 3: [('goblin', 60), ('orc', 40)], + 4: [('goblin', 40), ('orc', 50), ('troll', 10)], + 5: [('goblin', 20), ('orc', 50), ('troll', 30)], +} + +# Default weights for levels beyond those defined +DEFAULT_SPAWN_WEIGHTS = [('goblin', 10), ('orc', 50), ('troll', 40)] + +# ============================================================================= +# FOV (Field of View) SETTINGS +# ============================================================================= +FOV_RADIUS = 8 # How far the player can see +FOV_LIGHT_WALLS = True # Whether walls at FOV edge are visible + +# ============================================================================= +# INPUT KEYS +# Key names as returned by McRogueFace keypressScene +# ============================================================================= +KEY_UP = ['Up', 'W', 'Numpad8'] +KEY_DOWN = ['Down', 'S', 'Numpad2'] +KEY_LEFT = ['Left', 'A', 'Numpad4'] +KEY_RIGHT = ['Right', 'D', 'Numpad6'] + +# Diagonal movement (numpad) +KEY_UP_LEFT = ['Numpad7'] +KEY_UP_RIGHT = ['Numpad9'] +KEY_DOWN_LEFT = ['Numpad1'] +KEY_DOWN_RIGHT = ['Numpad3'] + +# Actions +KEY_WAIT = ['Period', 'Numpad5'] # Skip turn +KEY_DESCEND = ['Greater', 'Space'] # Go down stairs (> key or space) + +# ============================================================================= +# GAME MESSAGES +# ============================================================================= +MSG_WELCOME = "Welcome to the dungeon! Find the stairs to descend deeper." +MSG_DESCEND = "You descend the stairs to level %d..." +MSG_PLAYER_ATTACK = "You attack the %s for %d damage!" +MSG_PLAYER_KILL = "You have slain the %s!" +MSG_PLAYER_MISS = "You attack the %s but do no damage." +MSG_ENEMY_ATTACK = "The %s attacks you for %d damage!" +MSG_ENEMY_MISS = "The %s attacks you but does no damage." +MSG_BLOCKED = "You can't move there!" +MSG_STAIRS = "You see stairs leading down here. Press > or Space to descend." +MSG_DEATH = "You have died! Press R to restart." +MSG_NO_STAIRS = "There are no stairs here." + +# ============================================================================= +# UI LAYOUT +# ============================================================================= +# Health bar +HP_BAR_X = 10 +HP_BAR_Y = 620 +HP_BAR_WIDTH = 200 +HP_BAR_HEIGHT = 24 + +# Message log +MSG_LOG_X = 10 +MSG_LOG_Y = 660 +MSG_LOG_WIDTH = 780 +MSG_LOG_HEIGHT = 100 +MSG_LOG_MAX_LINES = 5 + +# Dungeon level display +LEVEL_DISPLAY_X = 700 +LEVEL_DISPLAY_Y = 620 + +# ============================================================================= +# ASSET PATHS +# ============================================================================= +TEXTURE_PATH = "assets/kenney_tinydungeon.png" +FONT_PATH = "assets/JetbrainsMono.ttf" diff --git a/docs/templates/complete/dungeon.py b/docs/templates/complete/dungeon.py new file mode 100644 index 0000000..9a009a4 --- /dev/null +++ b/docs/templates/complete/dungeon.py @@ -0,0 +1,298 @@ +""" +dungeon.py - Procedural Dungeon Generation for McRogueFace + +Generates a roguelike dungeon with rooms connected by corridors. +Includes stairs placement for multi-level progression. +""" + +import random +from dataclasses import dataclass +from typing import List, Tuple, Optional + +from constants import ( + DUNGEON_WIDTH, DUNGEON_HEIGHT, + ROOM_MIN_SIZE, ROOM_MAX_SIZE, MAX_ROOMS, + SPRITE_FLOOR, SPRITE_WALL, SPRITE_STAIRS_DOWN, + MAX_ENEMIES_PER_ROOM, MIN_ENEMIES_PER_ROOM, + ENEMY_SPAWN_WEIGHTS, DEFAULT_SPAWN_WEIGHTS +) + + +@dataclass +class Rect: + """A rectangle representing a room in the dungeon.""" + x: int + y: int + width: int + height: int + + @property + def x2(self) -> int: + return self.x + self.width + + @property + def y2(self) -> int: + return self.y + self.height + + @property + def center(self) -> Tuple[int, int]: + """Return the center coordinates of this room.""" + center_x = (self.x + self.x2) // 2 + center_y = (self.y + self.y2) // 2 + return center_x, center_y + + def intersects(self, other: 'Rect') -> bool: + """Check if this room overlaps with another (with 1 tile buffer).""" + return (self.x <= other.x2 + 1 and self.x2 + 1 >= other.x and + self.y <= other.y2 + 1 and self.y2 + 1 >= other.y) + + def inner(self) -> Tuple[int, int, int, int]: + """Return the inner area of the room (excluding walls).""" + return self.x + 1, self.y + 1, self.width - 2, self.height - 2 + + +class Tile: + """Represents a single tile in the dungeon.""" + + def __init__(self, walkable: bool = False, transparent: bool = False, + sprite: int = SPRITE_WALL): + self.walkable = walkable + self.transparent = transparent + self.sprite = sprite + self.explored = False + self.visible = False + + +class Dungeon: + """ + The dungeon map with rooms, corridors, and tile data. + + Attributes: + width: Width of the dungeon in tiles + height: Height of the dungeon in tiles + level: Current dungeon depth + tiles: 2D array of Tile objects + rooms: List of rooms (Rect objects) + player_start: Starting position for the player + stairs_pos: Position of the stairs down + """ + + def __init__(self, width: int = DUNGEON_WIDTH, height: int = DUNGEON_HEIGHT, + level: int = 1): + self.width = width + self.height = height + self.level = level + self.tiles: List[List[Tile]] = [] + self.rooms: List[Rect] = [] + self.player_start: Tuple[int, int] = (0, 0) + self.stairs_pos: Tuple[int, int] = (0, 0) + + # Initialize all tiles as walls + self._init_tiles() + + def _init_tiles(self) -> None: + """Fill the dungeon with wall tiles.""" + self.tiles = [ + [Tile(walkable=False, transparent=False, sprite=SPRITE_WALL) + for _ in range(self.height)] + for _ in range(self.width) + ] + + def in_bounds(self, x: int, y: int) -> bool: + """Check if coordinates are within dungeon bounds.""" + return 0 <= x < self.width and 0 <= y < self.height + + def is_walkable(self, x: int, y: int) -> bool: + """Check if a tile can be walked on.""" + if not self.in_bounds(x, y): + return False + return self.tiles[x][y].walkable + + def is_transparent(self, x: int, y: int) -> bool: + """Check if a tile allows light to pass through.""" + if not self.in_bounds(x, y): + return False + return self.tiles[x][y].transparent + + def get_tile(self, x: int, y: int) -> Optional[Tile]: + """Get the tile at the given position.""" + if not self.in_bounds(x, y): + return None + return self.tiles[x][y] + + def set_tile(self, x: int, y: int, walkable: bool, transparent: bool, + sprite: int) -> None: + """Set properties of a tile.""" + if self.in_bounds(x, y): + tile = self.tiles[x][y] + tile.walkable = walkable + tile.transparent = transparent + tile.sprite = sprite + + def carve_room(self, room: Rect) -> None: + """Carve out a room in the dungeon (make tiles walkable).""" + inner_x, inner_y, inner_w, inner_h = room.inner() + + for x in range(inner_x, inner_x + inner_w): + for y in range(inner_y, inner_y + inner_h): + self.set_tile(x, y, walkable=True, transparent=True, + sprite=SPRITE_FLOOR) + + def carve_tunnel_h(self, x1: int, x2: int, y: int) -> None: + """Carve a horizontal tunnel.""" + for x in range(min(x1, x2), max(x1, x2) + 1): + self.set_tile(x, y, walkable=True, transparent=True, + sprite=SPRITE_FLOOR) + + def carve_tunnel_v(self, y1: int, y2: int, x: int) -> None: + """Carve a vertical tunnel.""" + for y in range(min(y1, y2), max(y1, y2) + 1): + self.set_tile(x, y, walkable=True, transparent=True, + sprite=SPRITE_FLOOR) + + def connect_rooms(self, room1: Rect, room2: Rect) -> None: + """Connect two rooms with an L-shaped corridor.""" + x1, y1 = room1.center + x2, y2 = room2.center + + # Randomly choose to go horizontal then vertical, or vice versa + if random.random() < 0.5: + self.carve_tunnel_h(x1, x2, y1) + self.carve_tunnel_v(y1, y2, x2) + else: + self.carve_tunnel_v(y1, y2, x1) + self.carve_tunnel_h(x1, x2, y2) + + def place_stairs(self) -> None: + """Place stairs in the last room.""" + if self.rooms: + # Stairs go in the center of the last room + self.stairs_pos = self.rooms[-1].center + x, y = self.stairs_pos + self.set_tile(x, y, walkable=True, transparent=True, + sprite=SPRITE_STAIRS_DOWN) + + def generate(self) -> None: + """Generate the dungeon using BSP-style room placement.""" + self._init_tiles() + self.rooms.clear() + + for _ in range(MAX_ROOMS): + # Random room dimensions + w = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + h = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + + # Random position (ensure room fits in dungeon) + x = random.randint(1, self.width - w - 1) + y = random.randint(1, self.height - h - 1) + + new_room = Rect(x, y, w, h) + + # Check for intersections with existing rooms + if any(new_room.intersects(other) for other in self.rooms): + continue + + # Room is valid - carve it out + self.carve_room(new_room) + + if self.rooms: + # Connect to previous room + self.connect_rooms(self.rooms[-1], new_room) + else: + # First room - player starts here + self.player_start = new_room.center + + self.rooms.append(new_room) + + # Place stairs in the last room + self.place_stairs() + + def get_spawn_positions(self) -> List[Tuple[int, int]]: + """ + Get valid spawn positions for enemies. + Returns positions from all rooms except the first (player start). + """ + positions = [] + + for room in self.rooms[1:]: # Skip first room (player start) + inner_x, inner_y, inner_w, inner_h = room.inner() + + for x in range(inner_x, inner_x + inner_w): + for y in range(inner_y, inner_y + inner_h): + # Don't spawn on stairs + if (x, y) != self.stairs_pos: + positions.append((x, y)) + + return positions + + def get_enemy_spawns(self) -> List[Tuple[str, int, int]]: + """ + Determine which enemies to spawn and where. + Returns list of (enemy_type, x, y) tuples. + """ + spawns = [] + + # Get spawn weights for this level + weights = ENEMY_SPAWN_WEIGHTS.get(self.level, DEFAULT_SPAWN_WEIGHTS) + + # Create weighted list for random selection + enemy_types = [] + for enemy_type, weight in weights: + enemy_types.extend([enemy_type] * weight) + + # Spawn enemies in each room (except the first) + for room in self.rooms[1:]: + num_enemies = random.randint(MIN_ENEMIES_PER_ROOM, MAX_ENEMIES_PER_ROOM) + + # Scale up enemies slightly with dungeon level + num_enemies = min(num_enemies + (self.level - 1) // 2, MAX_ENEMIES_PER_ROOM + 2) + + inner_x, inner_y, inner_w, inner_h = room.inner() + used_positions = set() + + for _ in range(num_enemies): + # Find an unused position + attempts = 0 + while attempts < 20: + x = random.randint(inner_x, inner_x + inner_w - 1) + y = random.randint(inner_y, inner_y + inner_h - 1) + + if (x, y) not in used_positions and (x, y) != self.stairs_pos: + enemy_type = random.choice(enemy_types) + spawns.append((enemy_type, x, y)) + used_positions.add((x, y)) + break + + attempts += 1 + + return spawns + + def apply_to_grid(self, grid) -> None: + """ + Apply the dungeon data to a McRogueFace Grid object. + + Args: + grid: A mcrfpy.Grid object to update + """ + for x in range(self.width): + for y in range(self.height): + tile = self.tiles[x][y] + point = grid.at(x, y) + point.tilesprite = tile.sprite + point.walkable = tile.walkable + point.transparent = tile.transparent + + +def generate_dungeon(level: int = 1) -> Dungeon: + """ + Convenience function to generate a new dungeon. + + Args: + level: The dungeon depth (affects enemy spawns) + + Returns: + A fully generated Dungeon object + """ + dungeon = Dungeon(level=level) + dungeon.generate() + return dungeon diff --git a/docs/templates/complete/entities.py b/docs/templates/complete/entities.py new file mode 100644 index 0000000..bb52323 --- /dev/null +++ b/docs/templates/complete/entities.py @@ -0,0 +1,319 @@ +""" +entities.py - Player and Enemy Entity Definitions + +Defines the game actors with stats, rendering, and basic behaviors. +Uses composition with McRogueFace Entity objects for rendering. +""" + +from dataclasses import dataclass, field +from typing import Optional, List, Tuple, TYPE_CHECKING +import mcrfpy + +from constants import ( + PLAYER_START_HP, PLAYER_START_ATTACK, PLAYER_START_DEFENSE, + SPRITE_PLAYER, ENEMY_STATS, FOV_RADIUS +) + +if TYPE_CHECKING: + from dungeon import Dungeon + + +@dataclass +class Fighter: + """ + Combat statistics component for entities that can fight. + + Attributes: + hp: Current hit points + max_hp: Maximum hit points + attack: Attack power + defense: Damage reduction + """ + hp: int + max_hp: int + attack: int + defense: int + + @property + def is_alive(self) -> bool: + """Check if this fighter is still alive.""" + return self.hp > 0 + + @property + def hp_percent(self) -> float: + """Return HP as a percentage (0.0 to 1.0).""" + if self.max_hp <= 0: + return 0.0 + return self.hp / self.max_hp + + def heal(self, amount: int) -> int: + """ + Heal by the given amount, up to max_hp. + + Returns: + The actual amount healed. + """ + old_hp = self.hp + self.hp = min(self.hp + amount, self.max_hp) + return self.hp - old_hp + + def take_damage(self, amount: int) -> int: + """ + Take damage, reduced by defense. + + Args: + amount: Raw damage before defense calculation + + Returns: + The actual damage taken after defense. + """ + # Defense reduces damage, minimum 0 + actual_damage = max(0, amount - self.defense) + self.hp = max(0, self.hp - actual_damage) + return actual_damage + + +class Actor: + """ + Base class for all game actors (player and enemies). + + Wraps a McRogueFace Entity and adds game logic. + """ + + def __init__(self, x: int, y: int, sprite: int, name: str, + texture: mcrfpy.Texture, grid: mcrfpy.Grid, + fighter: Fighter): + """ + Create a new actor. + + Args: + x: Starting X position + y: Starting Y position + sprite: Sprite index for rendering + name: Display name of this actor + texture: Texture for the entity sprite + grid: Grid to add the entity to + fighter: Combat statistics + """ + self.name = name + self.fighter = fighter + self.grid = grid + self._x = x + self._y = y + + # Create the McRogueFace entity + self.entity = mcrfpy.Entity((x, y), texture, sprite) + grid.entities.append(self.entity) + + @property + def x(self) -> int: + return self._x + + @x.setter + def x(self, value: int) -> None: + self._x = value + self.entity.pos = (value, self._y) + + @property + def y(self) -> int: + return self._y + + @y.setter + def y(self, value: int) -> None: + self._y = value + self.entity.pos = (self._x, value) + + @property + def pos(self) -> Tuple[int, int]: + return (self._x, self._y) + + @pos.setter + def pos(self, value: Tuple[int, int]) -> None: + self._x, self._y = value + self.entity.pos = value + + @property + def is_alive(self) -> bool: + return self.fighter.is_alive + + def move(self, dx: int, dy: int) -> None: + """Move by the given delta.""" + self.x += dx + self.y += dy + + def move_to(self, x: int, y: int) -> None: + """Move to an absolute position.""" + self.pos = (x, y) + + def distance_to(self, other: 'Actor') -> int: + """Calculate Manhattan distance to another actor.""" + return abs(self.x - other.x) + abs(self.y - other.y) + + def remove(self) -> None: + """Remove this actor's entity from the grid.""" + try: + idx = self.entity.index() + self.grid.entities.remove(idx) + except (ValueError, RuntimeError): + pass # Already removed + + +class Player(Actor): + """ + The player character with additional player-specific functionality. + """ + + def __init__(self, x: int, y: int, texture: mcrfpy.Texture, + grid: mcrfpy.Grid): + fighter = Fighter( + hp=PLAYER_START_HP, + max_hp=PLAYER_START_HP, + attack=PLAYER_START_ATTACK, + defense=PLAYER_START_DEFENSE + ) + super().__init__( + x=x, y=y, + sprite=SPRITE_PLAYER, + name="Player", + texture=texture, + grid=grid, + fighter=fighter + ) + self.xp = 0 + self.level = 1 + self.dungeon_level = 1 + + def gain_xp(self, amount: int) -> bool: + """ + Gain experience points. + + Args: + amount: XP to gain + + Returns: + True if the player leveled up + """ + self.xp += amount + xp_to_level = self.xp_for_next_level + + if self.xp >= xp_to_level: + self.level_up() + return True + return False + + @property + def xp_for_next_level(self) -> int: + """XP required for the next level.""" + return self.level * 100 + + def level_up(self) -> None: + """Level up the player, improving stats.""" + self.level += 1 + + # Improve stats + hp_increase = 5 + attack_increase = 1 + defense_increase = 1 if self.level % 3 == 0 else 0 + + self.fighter.max_hp += hp_increase + self.fighter.hp += hp_increase # Heal the increase amount + self.fighter.attack += attack_increase + self.fighter.defense += defense_increase + + def update_fov(self, dungeon: 'Dungeon') -> None: + """ + Update field of view based on player position. + + Uses entity.update_visibility() for TCOD FOV calculation. + """ + # Update the entity's visibility data + self.entity.update_visibility() + + # Apply FOV to dungeon tiles + for x in range(dungeon.width): + for y in range(dungeon.height): + state = self.entity.at(x, y) + tile = dungeon.get_tile(x, y) + + if tile: + tile.visible = state.visible + if state.visible: + tile.explored = True + + +class Enemy(Actor): + """ + An enemy actor with AI behavior. + """ + + def __init__(self, x: int, y: int, enemy_type: str, + texture: mcrfpy.Texture, grid: mcrfpy.Grid): + """ + Create a new enemy. + + Args: + x: Starting X position + y: Starting Y position + enemy_type: Key into ENEMY_STATS dictionary + texture: Texture for the entity sprite + grid: Grid to add the entity to + """ + stats = ENEMY_STATS.get(enemy_type, ENEMY_STATS['goblin']) + + fighter = Fighter( + hp=stats['hp'], + max_hp=stats['hp'], + attack=stats['attack'], + defense=stats['defense'] + ) + + super().__init__( + x=x, y=y, + sprite=stats['sprite'], + name=stats['name'], + texture=texture, + grid=grid, + fighter=fighter + ) + + self.enemy_type = enemy_type + self.xp_reward = stats['xp'] + + # AI state + self.target: Optional[Actor] = None + self.path: List[Tuple[int, int]] = [] + + +def create_player(x: int, y: int, texture: mcrfpy.Texture, + grid: mcrfpy.Grid) -> Player: + """ + Factory function to create the player. + + Args: + x: Starting X position + y: Starting Y position + texture: Texture for player sprite + grid: Grid to add player to + + Returns: + A new Player instance + """ + return Player(x, y, texture, grid) + + +def create_enemy(x: int, y: int, enemy_type: str, + texture: mcrfpy.Texture, grid: mcrfpy.Grid) -> Enemy: + """ + Factory function to create an enemy. + + Args: + x: Starting X position + y: Starting Y position + enemy_type: Type of enemy ('goblin', 'orc', 'troll') + texture: Texture for enemy sprite + grid: Grid to add enemy to + + Returns: + A new Enemy instance + """ + return Enemy(x, y, enemy_type, texture, grid) diff --git a/docs/templates/complete/game.py b/docs/templates/complete/game.py new file mode 100644 index 0000000..31dfa1a --- /dev/null +++ b/docs/templates/complete/game.py @@ -0,0 +1,313 @@ +""" +game.py - Main Entry Point for McRogueFace Complete Roguelike Template + +This is the main game file that ties everything together: +- Scene setup +- Input handling +- Game loop +- Level transitions + +To run: Copy this template to your McRogueFace scripts/ directory +and rename to game.py (or import from game.py). +""" + +import mcrfpy +from typing import List, Optional + +# Import game modules +from constants import ( + SCREEN_WIDTH, SCREEN_HEIGHT, + GRID_X, GRID_Y, GRID_WIDTH, GRID_HEIGHT, + DUNGEON_WIDTH, DUNGEON_HEIGHT, + TEXTURE_PATH, FONT_PATH, + KEY_UP, KEY_DOWN, KEY_LEFT, KEY_RIGHT, + KEY_UP_LEFT, KEY_UP_RIGHT, KEY_DOWN_LEFT, KEY_DOWN_RIGHT, + KEY_WAIT, KEY_DESCEND, + MSG_WELCOME, MSG_DESCEND, MSG_BLOCKED, MSG_STAIRS, MSG_DEATH, MSG_NO_STAIRS, + FOV_RADIUS, COLOR_FOG, COLOR_REMEMBERED, COLOR_VISIBLE +) +from dungeon import Dungeon, generate_dungeon +from entities import Player, Enemy, create_player, create_enemy +from turns import TurnManager, GameState +from ui import GameUI, DeathScreen + + +class Game: + """ + Main game class that manages the complete roguelike experience. + """ + + def __init__(self): + """Initialize the game.""" + # Load resources + self.texture = mcrfpy.Texture(TEXTURE_PATH, 16, 16) + self.font = mcrfpy.Font(FONT_PATH) + + # Create scene + mcrfpy.createScene("game") + self.ui_collection = mcrfpy.sceneUI("game") + + # Create grid + self.grid = mcrfpy.Grid( + DUNGEON_WIDTH, DUNGEON_HEIGHT, + self.texture, + GRID_X, GRID_Y, + GRID_WIDTH, GRID_HEIGHT + ) + self.ui_collection.append(self.grid) + + # Game state + self.dungeon: Optional[Dungeon] = None + self.player: Optional[Player] = None + self.enemies: List[Enemy] = [] + self.turn_manager: Optional[TurnManager] = None + self.current_level = 1 + + # UI + self.game_ui = GameUI(self.font) + self.game_ui.add_to_scene(self.ui_collection) + + self.death_screen: Optional[DeathScreen] = None + self.game_over = False + + # Set up input handling + mcrfpy.keypressScene(self.handle_keypress) + + # Start the game + self.new_game() + + # Switch to game scene + mcrfpy.setScene("game") + + def new_game(self) -> None: + """Start a new game from level 1.""" + self.current_level = 1 + self.game_over = False + + # Clear any death screen + if self.death_screen: + self.death_screen.remove_from_scene(self.ui_collection) + self.death_screen = None + + # Generate first level + self.generate_level() + + # Welcome message + self.game_ui.clear_messages() + self.game_ui.add_message(MSG_WELCOME, (255, 255, 100, 255)) + + def generate_level(self) -> None: + """Generate a new dungeon level.""" + # Clear existing entities from grid + while len(self.grid.entities) > 0: + self.grid.entities.remove(0) + + self.enemies.clear() + + # Generate dungeon + self.dungeon = generate_dungeon(self.current_level) + self.dungeon.apply_to_grid(self.grid) + + # Create player at start position + start_x, start_y = self.dungeon.player_start + self.player = create_player(start_x, start_y, self.texture, self.grid) + self.player.dungeon_level = self.current_level + + # Spawn enemies + enemy_spawns = self.dungeon.get_enemy_spawns() + for enemy_type, x, y in enemy_spawns: + enemy = create_enemy(x, y, enemy_type, self.texture, self.grid) + self.enemies.append(enemy) + + # Set up turn manager + self.turn_manager = TurnManager(self.player, self.enemies, self.dungeon) + self.turn_manager.on_message = self.game_ui.add_message + self.turn_manager.on_player_death = self.on_player_death + + # Update FOV + self.update_fov() + + # Center camera on player + self.center_camera() + + # Update UI + self.game_ui.update_level(self.current_level) + self.update_ui() + + def descend(self) -> None: + """Go down to the next dungeon level.""" + # Check if player is on stairs + if self.player.pos != self.dungeon.stairs_pos: + self.game_ui.add_message(MSG_NO_STAIRS, (150, 150, 150, 255)) + return + + self.current_level += 1 + self.game_ui.add_message(MSG_DESCEND % self.current_level, (100, 100, 255, 255)) + + # Keep player stats + old_hp = self.player.fighter.hp + old_max_hp = self.player.fighter.max_hp + old_attack = self.player.fighter.attack + old_defense = self.player.fighter.defense + old_xp = self.player.xp + old_level = self.player.level + + # Generate new level + self.generate_level() + + # Restore player stats + self.player.fighter.hp = old_hp + self.player.fighter.max_hp = old_max_hp + self.player.fighter.attack = old_attack + self.player.fighter.defense = old_defense + self.player.xp = old_xp + self.player.level = old_level + + self.update_ui() + + def update_fov(self) -> None: + """Update field of view and apply to grid tiles.""" + if not self.player or not self.dungeon: + return + + # Use entity's built-in FOV calculation + self.player.entity.update_visibility() + + # Apply visibility to tiles + for x in range(self.dungeon.width): + for y in range(self.dungeon.height): + point = self.grid.at(x, y) + tile = self.dungeon.get_tile(x, y) + + if tile: + state = self.player.entity.at(x, y) + + if state.visible: + # Currently visible + tile.explored = True + tile.visible = True + point.color_overlay = mcrfpy.Color(*COLOR_VISIBLE) + elif tile.explored: + # Explored but not visible + tile.visible = False + point.color_overlay = mcrfpy.Color(*COLOR_REMEMBERED) + else: + # Never seen + point.color_overlay = mcrfpy.Color(*COLOR_FOG) + + def center_camera(self) -> None: + """Center the camera on the player.""" + if self.player: + self.grid.center = (self.player.x, self.player.y) + + def update_ui(self) -> None: + """Update all UI elements.""" + if self.player: + self.game_ui.update_hp( + self.player.fighter.hp, + self.player.fighter.max_hp + ) + + def on_player_death(self) -> None: + """Handle player death.""" + self.game_over = True + self.game_ui.add_message(MSG_DEATH, (255, 0, 0, 255)) + + # Show death screen + self.death_screen = DeathScreen(self.font) + self.death_screen.add_to_scene(self.ui_collection) + + def handle_keypress(self, key: str, state: str) -> None: + """ + Handle keyboard input. + + Args: + key: Key name + state: "start" for key down, "end" for key up + """ + # Only handle key down events + if state != "start": + return + + # Handle restart when dead + if self.game_over: + if key == "R": + self.new_game() + return + + # Handle movement + dx, dy = 0, 0 + + if key in KEY_UP: + dy = -1 + elif key in KEY_DOWN: + dy = 1 + elif key in KEY_LEFT: + dx = -1 + elif key in KEY_RIGHT: + dx = 1 + elif key in KEY_UP_LEFT: + dx, dy = -1, -1 + elif key in KEY_UP_RIGHT: + dx, dy = 1, -1 + elif key in KEY_DOWN_LEFT: + dx, dy = -1, 1 + elif key in KEY_DOWN_RIGHT: + dx, dy = 1, 1 + elif key in KEY_WAIT: + # Skip turn + self.turn_manager.handle_wait() + self.after_turn() + return + elif key in KEY_DESCEND: + # Try to descend + self.descend() + return + elif key == "Escape": + # Quit game + mcrfpy.exit() + return + + # Process movement/attack + if dx != 0 or dy != 0: + if self.turn_manager.handle_player_action(dx, dy): + self.after_turn() + else: + # Movement was blocked + self.game_ui.add_message(MSG_BLOCKED, (150, 150, 150, 255)) + + def after_turn(self) -> None: + """Called after each player turn.""" + # Update FOV + self.update_fov() + + # Center camera + self.center_camera() + + # Update UI + self.update_ui() + + # Check if standing on stairs + if self.player.pos == self.dungeon.stairs_pos: + self.game_ui.add_message(MSG_STAIRS, (100, 255, 100, 255)) + + # Clean up dead enemies + self.enemies = [e for e in self.enemies if e.is_alive] + + +# ============================================================================= +# ENTRY POINT +# ============================================================================= + +# Global game instance +game: Optional[Game] = None + + +def start_game(): + """Start the game.""" + global game + game = Game() + + +# Auto-start when this script is loaded +start_game() diff --git a/docs/templates/complete/turns.py b/docs/templates/complete/turns.py new file mode 100644 index 0000000..147e9ec --- /dev/null +++ b/docs/templates/complete/turns.py @@ -0,0 +1,232 @@ +""" +turns.py - Turn Management System for McRogueFace Roguelike + +Handles the turn-based game flow: player turn, then enemy turns. +""" + +from enum import Enum, auto +from typing import List, Optional, Callable, TYPE_CHECKING + +from entities import Player, Enemy +from combat import try_attack, process_kill, CombatResult +from ai import process_enemy_turns + +if TYPE_CHECKING: + from dungeon import Dungeon + + +class GameState(Enum): + """Current state of the game.""" + PLAYER_TURN = auto() # Waiting for player input + ENEMY_TURN = auto() # Processing enemy actions + PLAYER_DEAD = auto() # Player has died + VICTORY = auto() # Player has won (optional) + LEVEL_TRANSITION = auto() # Moving to next level + + +class TurnManager: + """ + Manages the turn-based game loop. + + The game follows this flow: + 1. Player takes action (move or attack) + 2. If action was valid, enemies take turns + 3. Check for game over conditions + 4. Return to step 1 + """ + + def __init__(self, player: Player, enemies: List[Enemy], dungeon: 'Dungeon'): + """ + Initialize the turn manager. + + Args: + player: The player entity + enemies: List of all enemies + dungeon: The dungeon map + """ + self.player = player + self.enemies = enemies + self.dungeon = dungeon + self.state = GameState.PLAYER_TURN + self.turn_count = 0 + + # Callbacks for game events + self.on_message: Optional[Callable[[str, tuple], None]] = None + self.on_player_death: Optional[Callable[[], None]] = None + self.on_enemy_death: Optional[Callable[[Enemy], None]] = None + self.on_turn_end: Optional[Callable[[int], None]] = None + + def reset(self, player: Player, enemies: List[Enemy], dungeon: 'Dungeon') -> None: + """Reset the turn manager with new game state.""" + self.player = player + self.enemies = enemies + self.dungeon = dungeon + self.state = GameState.PLAYER_TURN + self.turn_count = 0 + + def add_message(self, message: str, color: tuple = (255, 255, 255, 255)) -> None: + """Add a message to the log via callback.""" + if self.on_message: + self.on_message(message, color) + + def handle_player_action(self, dx: int, dy: int) -> bool: + """ + Handle a player movement or attack action. + + Args: + dx: X direction (-1, 0, or 1) + dy: Y direction (-1, 0, or 1) + + Returns: + True if the action consumed a turn, False otherwise + """ + if self.state != GameState.PLAYER_TURN: + return False + + target_x = self.player.x + dx + target_y = self.player.y + dy + + # Check for attack + result = try_attack(self.player, target_x, target_y, self.enemies) + + if result: + # Player attacked something + self.add_message(result.message, result.message_color) + + if result.killed: + # Process kill + xp = process_kill(self.player, result.defender) + self.enemies.remove(result.defender) + + if xp > 0: + self.add_message(f"You gain {xp} XP!", (255, 255, 100, 255)) + + if self.on_enemy_death: + self.on_enemy_death(result.defender) + + # Action consumed a turn + self._end_player_turn() + return True + + # No attack - try to move + if self.dungeon.is_walkable(target_x, target_y): + # Check for enemy blocking + blocked = False + for enemy in self.enemies: + if enemy.is_alive and enemy.x == target_x and enemy.y == target_y: + blocked = True + break + + if not blocked: + self.player.move_to(target_x, target_y) + self._end_player_turn() + return True + + # Movement blocked + return False + + def handle_wait(self) -> bool: + """ + Handle the player choosing to wait (skip turn). + + Returns: + True (always consumes a turn) + """ + if self.state != GameState.PLAYER_TURN: + return False + + self.add_message("You wait...", (150, 150, 150, 255)) + self._end_player_turn() + return True + + def _end_player_turn(self) -> None: + """End the player's turn and process enemy turns.""" + self.state = GameState.ENEMY_TURN + self._process_enemy_turns() + + def _process_enemy_turns(self) -> None: + """Process all enemy turns.""" + # Get combat results from enemy actions + results = process_enemy_turns( + self.enemies, + self.player, + self.dungeon + ) + + # Report results + for result in results: + self.add_message(result.message, result.message_color) + + # Check if player died + if not self.player.is_alive: + self.state = GameState.PLAYER_DEAD + if self.on_player_death: + self.on_player_death() + else: + # End turn + self.turn_count += 1 + self.state = GameState.PLAYER_TURN + + if self.on_turn_end: + self.on_turn_end(self.turn_count) + + def is_player_turn(self) -> bool: + """Check if it's the player's turn.""" + return self.state == GameState.PLAYER_TURN + + def is_game_over(self) -> bool: + """Check if the game is over (player dead).""" + return self.state == GameState.PLAYER_DEAD + + def get_enemy_count(self) -> int: + """Get the number of living enemies.""" + return sum(1 for e in self.enemies if e.is_alive) + + +class ActionResult: + """Result of a player action.""" + + def __init__(self, success: bool, message: str = "", + color: tuple = (255, 255, 255, 255)): + self.success = success + self.message = message + self.color = color + + +def try_move_or_attack(player: Player, dx: int, dy: int, + dungeon: 'Dungeon', enemies: List[Enemy]) -> ActionResult: + """ + Attempt to move or attack in a direction. + + This is a simpler, standalone function for games that don't want + the full TurnManager. + + Args: + player: The player + dx: X direction + dy: Y direction + dungeon: The dungeon map + enemies: List of enemies + + Returns: + ActionResult indicating success and any message + """ + target_x = player.x + dx + target_y = player.y + dy + + # Check for attack + for enemy in enemies: + if enemy.is_alive and enemy.x == target_x and enemy.y == target_y: + result = try_attack(player, target_x, target_y, enemies) + if result: + if result.killed: + process_kill(player, enemy) + enemies.remove(enemy) + return ActionResult(True, result.message, result.message_color) + + # Check for movement + if dungeon.is_walkable(target_x, target_y): + player.move_to(target_x, target_y) + return ActionResult(True) + + return ActionResult(False, "You can't move there!", (150, 150, 150, 255)) diff --git a/docs/templates/complete/ui.py b/docs/templates/complete/ui.py new file mode 100644 index 0000000..b3e16d1 --- /dev/null +++ b/docs/templates/complete/ui.py @@ -0,0 +1,330 @@ +""" +ui.py - User Interface Components for McRogueFace Roguelike + +Contains the health bar and message log UI elements. +""" + +from typing import List, Tuple, Optional +from dataclasses import dataclass +import mcrfpy + +from constants import ( + HP_BAR_X, HP_BAR_Y, HP_BAR_WIDTH, HP_BAR_HEIGHT, + MSG_LOG_X, MSG_LOG_Y, MSG_LOG_WIDTH, MSG_LOG_HEIGHT, MSG_LOG_MAX_LINES, + LEVEL_DISPLAY_X, LEVEL_DISPLAY_Y, + COLOR_UI_BG, COLOR_UI_BORDER, COLOR_TEXT, + COLOR_HP_BAR_BG, COLOR_HP_BAR_FILL, COLOR_HP_BAR_WARNING, COLOR_HP_BAR_CRITICAL, + COLOR_MSG_DEFAULT +) + + +@dataclass +class Message: + """A message in the message log.""" + text: str + color: Tuple[int, int, int, int] + + +class HealthBar: + """ + Visual health bar displaying player HP. + + Uses nested Frames: an outer background frame and an inner fill frame + that resizes based on HP percentage. + """ + + def __init__(self, x: int = HP_BAR_X, y: int = HP_BAR_Y, + width: int = HP_BAR_WIDTH, height: int = HP_BAR_HEIGHT, + font: mcrfpy.Font = None): + """ + Create a health bar. + + Args: + x: X position + y: Y position + width: Total width of the bar + height: Height of the bar + font: Font for the HP text + """ + self.x = x + self.y = y + self.width = width + self.height = height + self.font = font or mcrfpy.default_font + + # Background frame + self.bg_frame = mcrfpy.Frame(x, y, width, height) + self.bg_frame.fill_color = mcrfpy.Color(*COLOR_HP_BAR_BG) + self.bg_frame.outline = 2 + self.bg_frame.outline_color = mcrfpy.Color(*COLOR_UI_BORDER) + + # Fill frame (inside background) + self.fill_frame = mcrfpy.Frame(x + 2, y + 2, width - 4, height - 4) + self.fill_frame.fill_color = mcrfpy.Color(*COLOR_HP_BAR_FILL) + self.fill_frame.outline = 0 + + # HP text + self.hp_text = mcrfpy.Caption("HP: 0 / 0", self.font, x + 8, y + 4) + self.hp_text.fill_color = mcrfpy.Color(*COLOR_TEXT) + + self._max_fill_width = width - 4 + + def add_to_scene(self, ui: mcrfpy.UICollection) -> None: + """Add all health bar components to a scene.""" + ui.append(self.bg_frame) + ui.append(self.fill_frame) + ui.append(self.hp_text) + + def update(self, current_hp: int, max_hp: int) -> None: + """ + Update the health bar display. + + Args: + current_hp: Current hit points + max_hp: Maximum hit points + """ + # Calculate fill percentage + if max_hp <= 0: + percent = 0.0 + else: + percent = max(0.0, min(1.0, current_hp / max_hp)) + + # Update fill bar width + self.fill_frame.w = int(self._max_fill_width * percent) + + # Update color based on HP percentage + if percent > 0.6: + color = COLOR_HP_BAR_FILL + elif percent > 0.3: + color = COLOR_HP_BAR_WARNING + else: + color = COLOR_HP_BAR_CRITICAL + + self.fill_frame.fill_color = mcrfpy.Color(*color) + + # Update text + self.hp_text.text = f"HP: {current_hp} / {max_hp}" + + +class MessageLog: + """ + Scrolling message log displaying game events. + + Uses a Frame container with Caption children for each line. + """ + + def __init__(self, x: int = MSG_LOG_X, y: int = MSG_LOG_Y, + width: int = MSG_LOG_WIDTH, height: int = MSG_LOG_HEIGHT, + max_lines: int = MSG_LOG_MAX_LINES, + font: mcrfpy.Font = None): + """ + Create a message log. + + Args: + x: X position + y: Y position + width: Width of the log + height: Height of the log + max_lines: Maximum number of visible lines + font: Font for the messages + """ + self.x = x + self.y = y + self.width = width + self.height = height + self.max_lines = max_lines + self.font = font or mcrfpy.default_font + + # Container frame + self.frame = mcrfpy.Frame(x, y, width, height) + self.frame.fill_color = mcrfpy.Color(*COLOR_UI_BG) + self.frame.outline = 1 + self.frame.outline_color = mcrfpy.Color(*COLOR_UI_BORDER) + + # Message storage + self.messages: List[Message] = [] + self.captions: List[mcrfpy.Caption] = [] + + # Line height (approximate based on font) + self.line_height = 18 + + # Create caption objects for each line + self._init_captions() + + def _init_captions(self) -> None: + """Initialize caption objects for message display.""" + for i in range(self.max_lines): + caption = mcrfpy.Caption( + "", + self.font, + self.x + 5, + self.y + 5 + i * self.line_height + ) + caption.fill_color = mcrfpy.Color(*COLOR_MSG_DEFAULT) + self.captions.append(caption) + + def add_to_scene(self, ui: mcrfpy.UICollection) -> None: + """Add the message log to a scene.""" + ui.append(self.frame) + for caption in self.captions: + ui.append(caption) + + def add_message(self, text: str, + color: Tuple[int, int, int, int] = COLOR_MSG_DEFAULT) -> None: + """ + Add a message to the log. + + Args: + text: Message text + color: Text color as (R, G, B, A) + """ + self.messages.append(Message(text, color)) + + # Trim old messages + if len(self.messages) > 100: + self.messages = self.messages[-100:] + + # Update display + self._update_display() + + def _update_display(self) -> None: + """Update the displayed messages.""" + # Get the most recent messages + recent = self.messages[-self.max_lines:] + + for i, caption in enumerate(self.captions): + if i < len(recent): + msg = recent[i] + caption.text = msg.text + caption.fill_color = mcrfpy.Color(*msg.color) + else: + caption.text = "" + + def clear(self) -> None: + """Clear all messages.""" + self.messages.clear() + self._update_display() + + +class LevelDisplay: + """Simple display showing current dungeon level.""" + + def __init__(self, x: int = LEVEL_DISPLAY_X, y: int = LEVEL_DISPLAY_Y, + font: mcrfpy.Font = None): + """ + Create a level display. + + Args: + x: X position + y: Y position + font: Font for the text + """ + self.font = font or mcrfpy.default_font + + self.caption = mcrfpy.Caption("Level: 1", self.font, x, y) + self.caption.fill_color = mcrfpy.Color(*COLOR_TEXT) + + def add_to_scene(self, ui: mcrfpy.UICollection) -> None: + """Add to a scene.""" + ui.append(self.caption) + + def update(self, level: int) -> None: + """Update the displayed level.""" + self.caption.text = f"Dungeon Level: {level}" + + +class GameUI: + """ + Container for all UI elements. + + Provides a single point of access for updating the entire UI. + """ + + def __init__(self, font: mcrfpy.Font = None): + """ + Create the game UI. + + Args: + font: Font for all UI elements + """ + self.font = font or mcrfpy.default_font + + # Create UI components + self.health_bar = HealthBar(font=self.font) + self.message_log = MessageLog(font=self.font) + self.level_display = LevelDisplay(font=self.font) + + def add_to_scene(self, ui: mcrfpy.UICollection) -> None: + """Add all UI elements to a scene.""" + self.health_bar.add_to_scene(ui) + self.message_log.add_to_scene(ui) + self.level_display.add_to_scene(ui) + + def update_hp(self, current_hp: int, max_hp: int) -> None: + """Update the health bar.""" + self.health_bar.update(current_hp, max_hp) + + def add_message(self, text: str, + color: Tuple[int, int, int, int] = COLOR_MSG_DEFAULT) -> None: + """Add a message to the log.""" + self.message_log.add_message(text, color) + + def update_level(self, level: int) -> None: + """Update the dungeon level display.""" + self.level_display.update(level) + + def clear_messages(self) -> None: + """Clear the message log.""" + self.message_log.clear() + + +class DeathScreen: + """Game over screen shown when player dies.""" + + def __init__(self, font: mcrfpy.Font = None): + """ + Create the death screen. + + Args: + font: Font for text + """ + self.font = font or mcrfpy.default_font + self.elements: List = [] + + # Semi-transparent overlay + self.overlay = mcrfpy.Frame(0, 0, 1024, 768) + self.overlay.fill_color = mcrfpy.Color(0, 0, 0, 180) + self.elements.append(self.overlay) + + # Death message + self.death_text = mcrfpy.Caption( + "YOU HAVE DIED", + self.font, + 362, 300 + ) + self.death_text.fill_color = mcrfpy.Color(255, 0, 0, 255) + self.death_text.outline = 2 + self.death_text.outline_color = mcrfpy.Color(0, 0, 0, 255) + self.elements.append(self.death_text) + + # Restart prompt + self.restart_text = mcrfpy.Caption( + "Press R to restart", + self.font, + 400, 400 + ) + self.restart_text.fill_color = mcrfpy.Color(200, 200, 200, 255) + self.elements.append(self.restart_text) + + def add_to_scene(self, ui: mcrfpy.UICollection) -> None: + """Add death screen elements to a scene.""" + for element in self.elements: + ui.append(element) + + def remove_from_scene(self, ui: mcrfpy.UICollection) -> None: + """Remove death screen elements from a scene.""" + for element in self.elements: + try: + ui.remove(element) + except (ValueError, RuntimeError): + pass diff --git a/docs/templates/minimal/game.py b/docs/templates/minimal/game.py new file mode 100644 index 0000000..bedafad --- /dev/null +++ b/docs/templates/minimal/game.py @@ -0,0 +1,176 @@ +""" +McRogueFace Minimal Template +============================ + +A starting point for simple roguelike prototypes. + +This template demonstrates: +- Scene object pattern (preferred OOP approach) +- Grid-based movement with boundary checking +- Keyboard input handling +- Entity positioning on a grid + +Usage: + Place this file in your McRogueFace scripts directory and run McRogueFace. + Use arrow keys to move the @ symbol. Press Escape to exit. +""" + +import mcrfpy + +# ============================================================================= +# CONSTANTS +# ============================================================================= + +# Grid dimensions (in tiles) +GRID_WIDTH: int = 20 +GRID_HEIGHT: int = 15 + +# Tile size in pixels (must match your sprite sheet) +TILE_SIZE: int = 16 + +# CP437 sprite indices (standard roguelike character mapping) +# In CP437, character codes map to sprite indices: '@' = 64, '.' = 46, etc. +SPRITE_PLAYER: int = 64 # '@' symbol +SPRITE_FLOOR: int = 46 # '.' symbol + +# Colors (RGBA tuples) +COLOR_BACKGROUND: tuple[int, int, int] = (20, 20, 30) + +# ============================================================================= +# GAME STATE +# ============================================================================= + +# Player position in grid coordinates +player_x: int = GRID_WIDTH // 2 +player_y: int = GRID_HEIGHT // 2 + +# Reference to player entity (set during setup) +player_entity: mcrfpy.Entity = None + +# ============================================================================= +# MOVEMENT LOGIC +# ============================================================================= + +def try_move(dx: int, dy: int) -> bool: + """ + Attempt to move the player by (dx, dy) tiles. + + Args: + dx: Horizontal movement (-1 = left, +1 = right, 0 = none) + dy: Vertical movement (-1 = up, +1 = down, 0 = none) + + Returns: + True if movement succeeded, False if blocked by boundary + """ + global player_x, player_y + + new_x = player_x + dx + new_y = player_y + dy + + # Boundary checking: ensure player stays within grid + if 0 <= new_x < GRID_WIDTH and 0 <= new_y < GRID_HEIGHT: + player_x = new_x + player_y = new_y + + # Update the entity's position on the grid + player_entity.x = player_x + player_entity.y = player_y + return True + + return False + +# ============================================================================= +# INPUT HANDLING +# ============================================================================= + +def handle_keypress(key: str, action: str) -> None: + """ + Handle keyboard input for the game scene. + + Args: + key: The key that was pressed (e.g., "Up", "Down", "Escape", "a", "W") + action: Either "start" (key pressed) or "end" (key released) + + Note: + We only process on "start" to avoid double-triggering on key release. + """ + if action != "start": + return + + # Movement keys (both arrow keys and WASD) + if key == "Up" or key == "W" or key == "w": + try_move(0, -1) + elif key == "Down" or key == "S" or key == "s": + try_move(0, 1) + elif key == "Left" or key == "A" or key == "a": + try_move(-1, 0) + elif key == "Right" or key == "D" or key == "d": + try_move(1, 0) + + # Exit on Escape + elif key == "Escape": + mcrfpy.exit() + +# ============================================================================= +# SCENE SETUP +# ============================================================================= + +def setup_game() -> mcrfpy.Scene: + """ + Create and configure the game scene. + + Returns: + The configured Scene object, ready to be activated. + """ + global player_entity + + # Create the scene using the OOP pattern (preferred over createScene) + scene = mcrfpy.Scene("game") + + # Load the sprite sheet texture + # Adjust the path and tile size to match your assets + texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", TILE_SIZE, TILE_SIZE) + + # Create the game grid + # Grid(pos, size, grid_size) where: + # pos = pixel position on screen + # size = pixel dimensions of the grid display + # grid_size = number of tiles (columns, rows) + grid = mcrfpy.Grid( + pos=(32, 32), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture + ) + grid.fill_color = mcrfpy.Color(*COLOR_BACKGROUND) + + # Fill the grid with floor tiles + for x in range(GRID_WIDTH): + for y in range(GRID_HEIGHT): + point = grid.at(x, y) + point.tilesprite = SPRITE_FLOOR + point.walkable = True + point.transparent = True + + # Create the player entity + player_entity = mcrfpy.Entity( + pos=(player_x, player_y), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player_entity) + + # Add the grid to the scene's UI + scene.children.append(grid) + + # Set up keyboard input handler for this scene + scene.on_key = handle_keypress + + return scene + +# ============================================================================= +# MAIN ENTRY POINT +# ============================================================================= + +# Create and activate the game scene +game_scene = setup_game() +game_scene.activate() diff --git a/docs/templates/roguelike/constants.py b/docs/templates/roguelike/constants.py new file mode 100644 index 0000000..81413e9 --- /dev/null +++ b/docs/templates/roguelike/constants.py @@ -0,0 +1,138 @@ +""" +constants.py - Roguelike Template Constants + +This module defines all the constants used throughout the roguelike template, +including sprite indices for CP437 tileset, colors for FOV system, and +game configuration values. + +CP437 is the classic IBM PC character set commonly used in traditional roguelikes. +The sprite indices correspond to ASCII character codes in a CP437 tileset. +""" + +import mcrfpy + +# ============================================================================= +# SPRITE INDICES (CP437 Character Codes) +# ============================================================================= +# These indices correspond to characters in a CP437-style tileset. +# The default McRogueFace tileset uses 16x16 sprites arranged in a grid. + +# Terrain sprites +SPRITE_FLOOR = 46 # '.' - Standard floor tile +SPRITE_WALL = 35 # '#' - Wall/obstacle tile +SPRITE_DOOR_CLOSED = 43 # '+' - Closed door +SPRITE_DOOR_OPEN = 47 # '/' - Open door +SPRITE_STAIRS_DOWN = 62 # '>' - Stairs going down +SPRITE_STAIRS_UP = 60 # '<' - Stairs going up + +# Player sprite +SPRITE_PLAYER = 64 # '@' - The classic roguelike player symbol + +# Enemy sprites +SPRITE_ORC = 111 # 'o' - Orc enemy +SPRITE_TROLL = 84 # 'T' - Troll enemy +SPRITE_GOBLIN = 103 # 'g' - Goblin enemy +SPRITE_RAT = 114 # 'r' - Giant rat +SPRITE_SNAKE = 115 # 's' - Snake +SPRITE_ZOMBIE = 90 # 'Z' - Zombie + +# Item sprites +SPRITE_POTION = 33 # '!' - Potion +SPRITE_SCROLL = 63 # '?' - Scroll +SPRITE_GOLD = 36 # '$' - Gold/treasure +SPRITE_WEAPON = 41 # ')' - Weapon +SPRITE_ARMOR = 91 # '[' - Armor +SPRITE_RING = 61 # '=' - Ring + +# ============================================================================= +# FOV/VISIBILITY COLORS +# ============================================================================= +# These colors are applied as overlays to grid tiles to create the fog of war +# effect. The alpha channel determines how much of the original tile shows through. + +# Fully visible - no overlay (alpha = 0 means completely transparent overlay) +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) + +# Previously explored but not currently visible - dim blue-gray overlay +# This creates the "memory" effect where you can see the map layout +# but not current enemy positions +COLOR_EXPLORED = mcrfpy.Color(50, 50, 80, 180) + +# Never seen - completely black (alpha = 255 means fully opaque) +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) + +# ============================================================================= +# TILE COLORS +# ============================================================================= +# Base colors for different tile types (applied to the tile's color property) + +COLOR_FLOOR = mcrfpy.Color(50, 50, 50) # Dark gray floor +COLOR_WALL = mcrfpy.Color(100, 100, 100) # Lighter gray walls +COLOR_FLOOR_LIT = mcrfpy.Color(100, 90, 70) # Warm lit floor +COLOR_WALL_LIT = mcrfpy.Color(130, 110, 80) # Warm lit walls + +# ============================================================================= +# ENTITY COLORS +# ============================================================================= +# Colors applied to entity sprites + +COLOR_PLAYER = mcrfpy.Color(255, 255, 255) # White player +COLOR_ORC = mcrfpy.Color(63, 127, 63) # Green orc +COLOR_TROLL = mcrfpy.Color(0, 127, 0) # Darker green troll +COLOR_GOBLIN = mcrfpy.Color(127, 127, 0) # Yellow-green goblin + +# ============================================================================= +# GAME CONFIGURATION +# ============================================================================= + +# Map dimensions (in tiles) +MAP_WIDTH = 80 +MAP_HEIGHT = 45 + +# Room generation parameters +ROOM_MIN_SIZE = 6 # Minimum room dimension +ROOM_MAX_SIZE = 12 # Maximum room dimension +MAX_ROOMS = 30 # Maximum number of rooms to generate + +# FOV settings +FOV_RADIUS = 8 # How far the player can see + +# Display settings +GRID_PIXEL_WIDTH = 1024 # Grid display width in pixels +GRID_PIXEL_HEIGHT = 768 # Grid display height in pixels + +# Sprite size (should match your tileset) +SPRITE_WIDTH = 16 +SPRITE_HEIGHT = 16 + +# ============================================================================= +# ENEMY DEFINITIONS +# ============================================================================= +# Dictionary of enemy types with their properties for easy spawning + +ENEMY_TYPES = { + "orc": { + "sprite": SPRITE_ORC, + "color": COLOR_ORC, + "name": "Orc", + "hp": 10, + "power": 3, + "defense": 0, + }, + "troll": { + "sprite": SPRITE_TROLL, + "color": COLOR_TROLL, + "name": "Troll", + "hp": 16, + "power": 4, + "defense": 1, + }, + "goblin": { + "sprite": SPRITE_GOBLIN, + "color": COLOR_GOBLIN, + "name": "Goblin", + "hp": 6, + "power": 2, + "defense": 0, + }, +} diff --git a/docs/templates/roguelike/dungeon.py b/docs/templates/roguelike/dungeon.py new file mode 100644 index 0000000..5b4d788 --- /dev/null +++ b/docs/templates/roguelike/dungeon.py @@ -0,0 +1,340 @@ +""" +dungeon.py - Procedural Dungeon Generation + +This module provides classic roguelike dungeon generation using the +"rooms and corridors" algorithm: + +1. Generate random non-overlapping rectangular rooms +2. Connect rooms with L-shaped corridors +3. Mark tiles as walkable/transparent based on terrain type + +The algorithm is simple but effective, producing dungeons similar to +the original Rogue game. +""" + +from __future__ import annotations +import random +from typing import Iterator, Tuple, List, TYPE_CHECKING + +if TYPE_CHECKING: + import mcrfpy + +from constants import ( + MAP_WIDTH, MAP_HEIGHT, + ROOM_MIN_SIZE, ROOM_MAX_SIZE, MAX_ROOMS, + SPRITE_FLOOR, SPRITE_WALL, + COLOR_FLOOR, COLOR_WALL, +) + + +class RectangularRoom: + """ + A rectangular room in the dungeon. + + This class represents a single room and provides utilities for + working with room geometry. Rooms are defined by their top-left + corner (x1, y1) and bottom-right corner (x2, y2). + + Attributes: + x1, y1: Top-left corner coordinates + x2, y2: Bottom-right corner coordinates + """ + + def __init__(self, x: int, y: int, width: int, height: int) -> None: + """ + Create a new rectangular room. + + Args: + x: X coordinate of the top-left corner + y: Y coordinate of the top-left corner + width: Width of the room in tiles + height: Height of the room in tiles + """ + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> Tuple[int, int]: + """ + Return the center coordinates of the room. + + This is useful for connecting rooms with corridors and + for placing the player in the starting room. + + Returns: + Tuple of (center_x, center_y) + """ + center_x = (self.x1 + self.x2) // 2 + center_y = (self.y1 + self.y2) // 2 + return center_x, center_y + + @property + def inner(self) -> Tuple[slice, slice]: + """ + Return the inner area of the room as a pair of slices. + + The inner area excludes the walls (1 tile border), giving + the floor area where entities can be placed. + + Returns: + Tuple of (x_slice, y_slice) for array indexing + """ + # Add 1 to exclude the walls on all sides + return slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: RectangularRoom) -> bool: + """ + Check if this room overlaps with another room. + + Used during generation to ensure rooms don't overlap. + + Args: + other: Another RectangularRoom to check against + + Returns: + True if the rooms overlap, False otherwise + """ + return ( + self.x1 <= other.x2 + and self.x2 >= other.x1 + and self.y1 <= other.y2 + and self.y2 >= other.y1 + ) + + def inner_tiles(self) -> Iterator[Tuple[int, int]]: + """ + Iterate over all floor tile coordinates in the room. + + Yields coordinates for the interior of the room (excluding walls). + + Yields: + Tuples of (x, y) coordinates + """ + for x in range(self.x1 + 1, self.x2): + for y in range(self.y1 + 1, self.y2): + yield x, y + + +def tunnel_between( + start: Tuple[int, int], + end: Tuple[int, int] +) -> Iterator[Tuple[int, int]]: + """ + Generate an L-shaped tunnel between two points. + + The tunnel goes horizontally first, then vertically (or vice versa, + chosen randomly). This creates the classic roguelike corridor style. + + Args: + start: Starting (x, y) coordinates + end: Ending (x, y) coordinates + + Yields: + Tuples of (x, y) coordinates for each tile in the tunnel + """ + x1, y1 = start + x2, y2 = end + + # Randomly choose whether to go horizontal-first or vertical-first + if random.random() < 0.5: + # Horizontal first, then vertical + corner_x, corner_y = x2, y1 + else: + # Vertical first, then horizontal + corner_x, corner_y = x1, y2 + + # Generate the horizontal segment + for x in range(min(x1, corner_x), max(x1, corner_x) + 1): + yield x, y1 + + # Generate the vertical segment + for y in range(min(y1, corner_y), max(y1, corner_y) + 1): + yield corner_x, y + + # Generate to the endpoint (if needed) + for x in range(min(corner_x, x2), max(corner_x, x2) + 1): + yield x, corner_y + + for y in range(min(corner_y, y2), max(corner_y, y2) + 1): + yield x2, y + + +def generate_dungeon( + max_rooms: int = MAX_ROOMS, + room_min_size: int = ROOM_MIN_SIZE, + room_max_size: int = ROOM_MAX_SIZE, + map_width: int = MAP_WIDTH, + map_height: int = MAP_HEIGHT, +) -> List[RectangularRoom]: + """ + Generate a dungeon using the rooms-and-corridors algorithm. + + This function creates a list of non-overlapping rooms. The actual + tile data should be applied to a Grid using populate_grid(). + + Algorithm: + 1. Try to place MAX_ROOMS rooms randomly + 2. Reject rooms that overlap existing rooms + 3. Connect each new room to the previous room with a corridor + + Args: + max_rooms: Maximum number of rooms to generate + room_min_size: Minimum room dimension + room_max_size: Maximum room dimension + map_width: Width of the dungeon in tiles + map_height: Height of the dungeon in tiles + + Returns: + List of RectangularRoom objects representing the dungeon layout + """ + rooms: List[RectangularRoom] = [] + + for _ in range(max_rooms): + # Random room dimensions + room_width = random.randint(room_min_size, room_max_size) + room_height = random.randint(room_min_size, room_max_size) + + # Random position (ensuring room fits within map bounds) + x = random.randint(0, map_width - room_width - 1) + y = random.randint(0, map_height - room_height - 1) + + new_room = RectangularRoom(x, y, room_width, room_height) + + # Check if this room overlaps with any existing room + if any(new_room.intersects(other) for other in rooms): + continue # Skip this room, try again + + # Room is valid, add it + rooms.append(new_room) + + return rooms + + +def populate_grid(grid: mcrfpy.Grid, rooms: List[RectangularRoom]) -> None: + """ + Apply dungeon layout to a McRogueFace Grid. + + This function: + 1. Fills the entire grid with walls + 2. Carves out floor tiles for each room + 3. Carves corridors connecting adjacent rooms + 4. Sets walkable/transparent flags appropriately + + Args: + grid: The McRogueFace Grid to populate + rooms: List of RectangularRoom objects from generate_dungeon() + """ + grid_width, grid_height = grid.grid_size + + # Step 1: Fill entire map with walls + for x in range(grid_width): + for y in range(grid_height): + point = grid.at(x, y) + point.tilesprite = SPRITE_WALL + point.walkable = False + point.transparent = False + point.color = COLOR_WALL + + # Step 2: Carve out rooms + for room in rooms: + for x, y in room.inner_tiles(): + # Bounds check (room might extend past grid) + if 0 <= x < grid_width and 0 <= y < grid_height: + point = grid.at(x, y) + point.tilesprite = SPRITE_FLOOR + point.walkable = True + point.transparent = True + point.color = COLOR_FLOOR + + # Step 3: Carve corridors between adjacent rooms + for i in range(1, len(rooms)): + # Connect each room to the previous room + start = rooms[i - 1].center + end = rooms[i].center + + for x, y in tunnel_between(start, end): + if 0 <= x < grid_width and 0 <= y < grid_height: + point = grid.at(x, y) + point.tilesprite = SPRITE_FLOOR + point.walkable = True + point.transparent = True + point.color = COLOR_FLOOR + + +def get_random_floor_position( + grid: mcrfpy.Grid, + rooms: List[RectangularRoom], + exclude_first_room: bool = False +) -> Tuple[int, int]: + """ + Get a random walkable floor position for entity placement. + + This is useful for placing enemies, items, or other entities + in valid floor locations. + + Args: + grid: The populated Grid to search + rooms: List of rooms (used for faster random selection) + exclude_first_room: If True, won't return positions from the + first room (where the player usually starts) + + Returns: + Tuple of (x, y) coordinates of a walkable floor tile + """ + available_rooms = rooms[1:] if exclude_first_room and len(rooms) > 1 else rooms + + if not available_rooms: + # Fallback: find any walkable tile + grid_width, grid_height = grid.grid_size + walkable_tiles = [] + for x in range(grid_width): + for y in range(grid_height): + if grid.at(x, y).walkable: + walkable_tiles.append((x, y)) + return random.choice(walkable_tiles) if walkable_tiles else (1, 1) + + # Pick a random room and a random position within it + room = random.choice(available_rooms) + floor_tiles = list(room.inner_tiles()) + return random.choice(floor_tiles) + + +def get_spawn_positions( + rooms: List[RectangularRoom], + count: int, + exclude_first_room: bool = True +) -> List[Tuple[int, int]]: + """ + Get multiple spawn positions for enemies. + + Distributes enemies across different rooms for better gameplay. + + Args: + rooms: List of rooms from dungeon generation + count: Number of positions to generate + exclude_first_room: If True, won't spawn in the player's starting room + + Returns: + List of (x, y) coordinate tuples + """ + available_rooms = rooms[1:] if exclude_first_room and len(rooms) > 1 else rooms + + if not available_rooms: + return [] + + positions = [] + for i in range(count): + # Cycle through rooms to distribute enemies + room = available_rooms[i % len(available_rooms)] + floor_tiles = list(room.inner_tiles()) + + # Try to avoid placing on the same tile + available_tiles = [t for t in floor_tiles if t not in positions] + if available_tiles: + positions.append(random.choice(available_tiles)) + elif floor_tiles: + positions.append(random.choice(floor_tiles)) + + return positions diff --git a/docs/templates/roguelike/entities.py b/docs/templates/roguelike/entities.py new file mode 100644 index 0000000..e6c1c23 --- /dev/null +++ b/docs/templates/roguelike/entities.py @@ -0,0 +1,364 @@ +""" +entities.py - Entity Management for Roguelike Template + +This module provides entity creation and management utilities for the +roguelike template. Entities in McRogueFace are game objects that exist +on a Grid, such as the player, enemies, items, and NPCs. + +The module includes: +- Entity factory functions for creating common entity types +- Helper functions for entity management +- Simple data containers for entity stats (for future expansion) + +Note: McRogueFace entities are simple position + sprite objects. For +complex game logic like AI, combat, and inventory, you'll want to wrap +them in Python classes that reference the underlying Entity. +""" + +from __future__ import annotations +from typing import Tuple, Optional, List, Dict, Any, TYPE_CHECKING +from dataclasses import dataclass + +if TYPE_CHECKING: + import mcrfpy + +from constants import ( + SPRITE_PLAYER, SPRITE_ORC, SPRITE_TROLL, SPRITE_GOBLIN, + COLOR_PLAYER, COLOR_ORC, COLOR_TROLL, COLOR_GOBLIN, + ENEMY_TYPES, +) + + +@dataclass +class EntityStats: + """ + Optional stats container for game entities. + + This dataclass can be used to track stats for entities that need them. + Attach it to your entity wrapper class for combat, leveling, etc. + + Attributes: + hp: Current hit points + max_hp: Maximum hit points + power: Attack power + defense: Damage reduction + name: Display name for the entity + """ + hp: int = 10 + max_hp: int = 10 + power: int = 3 + defense: int = 0 + name: str = "Unknown" + + @property + def is_alive(self) -> bool: + """Check if the entity is still alive.""" + return self.hp > 0 + + def take_damage(self, amount: int) -> int: + """ + Apply damage, accounting for defense. + + Args: + amount: Raw damage amount + + Returns: + Actual damage dealt after defense + """ + actual_damage = max(0, amount - self.defense) + self.hp = max(0, self.hp - actual_damage) + return actual_damage + + def heal(self, amount: int) -> int: + """ + Heal the entity. + + Args: + amount: Amount to heal + + Returns: + Actual amount healed (may be less if near max HP) + """ + old_hp = self.hp + self.hp = min(self.max_hp, self.hp + amount) + return self.hp - old_hp + + +def create_player( + grid: mcrfpy.Grid, + texture: mcrfpy.Texture, + x: int, + y: int +) -> mcrfpy.Entity: + """ + Create and place the player entity on the grid. + + The player uses the classic '@' symbol (sprite index 64 in CP437). + + Args: + grid: The Grid to place the player on + texture: The texture/tileset to use + x: Starting X position + y: Starting Y position + + Returns: + The created player Entity + """ + import mcrfpy + + player = mcrfpy.Entity( + pos=(x, y), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + return player + + +def create_enemy( + grid: mcrfpy.Grid, + texture: mcrfpy.Texture, + x: int, + y: int, + enemy_type: str = "orc" +) -> Tuple[mcrfpy.Entity, EntityStats]: + """ + Create an enemy entity with associated stats. + + Enemy types are defined in constants.py. Currently available: + - "orc": Standard enemy, balanced stats + - "troll": Tough enemy, high HP and power + - "goblin": Weak enemy, low stats + + Args: + grid: The Grid to place the enemy on + texture: The texture/tileset to use + x: X position + y: Y position + enemy_type: Key from ENEMY_TYPES dict + + Returns: + Tuple of (Entity, EntityStats) for the created enemy + """ + import mcrfpy + + # Get enemy definition, default to orc if not found + enemy_def = ENEMY_TYPES.get(enemy_type, ENEMY_TYPES["orc"]) + + entity = mcrfpy.Entity( + pos=(x, y), + texture=texture, + sprite_index=enemy_def["sprite"] + ) + grid.entities.append(entity) + + stats = EntityStats( + hp=enemy_def["hp"], + max_hp=enemy_def["hp"], + power=enemy_def["power"], + defense=enemy_def["defense"], + name=enemy_def["name"] + ) + + return entity, stats + + +def create_enemies_in_rooms( + grid: mcrfpy.Grid, + texture: mcrfpy.Texture, + rooms: list, + enemies_per_room: int = 2, + skip_first_room: bool = True +) -> List[Tuple[mcrfpy.Entity, EntityStats]]: + """ + Populate dungeon rooms with enemies. + + This helper function places random enemies throughout the dungeon, + typically skipping the first room (where the player starts). + + Args: + grid: The Grid to populate + texture: The texture/tileset to use + rooms: List of RectangularRoom objects from dungeon generation + enemies_per_room: Maximum enemies to spawn per room + skip_first_room: If True, don't spawn enemies in the first room + + Returns: + List of (Entity, EntityStats) tuples for all created enemies + """ + import random + + enemies = [] + enemy_type_keys = list(ENEMY_TYPES.keys()) + + # Iterate through rooms, optionally skipping the first + rooms_to_populate = rooms[1:] if skip_first_room else rooms + + for room in rooms_to_populate: + # Random number of enemies (0 to enemies_per_room) + num_enemies = random.randint(0, enemies_per_room) + + # Get available floor tiles in this room + floor_tiles = list(room.inner_tiles()) + + for _ in range(num_enemies): + if not floor_tiles: + break + + # Pick a random position and remove it from available + pos = random.choice(floor_tiles) + floor_tiles.remove(pos) + + # Pick a random enemy type (weighted toward weaker enemies) + if random.random() < 0.8: + enemy_type = "orc" # 80% orcs + else: + enemy_type = "troll" # 20% trolls + + x, y = pos + entity, stats = create_enemy(grid, texture, x, y, enemy_type) + enemies.append((entity, stats)) + + return enemies + + +def get_blocking_entity_at( + entities: List[mcrfpy.Entity], + x: int, + y: int +) -> Optional[mcrfpy.Entity]: + """ + Check if there's a blocking entity at the given position. + + Useful for collision detection - checks if an entity exists at + the target position before moving there. + + Args: + entities: List of entities to check + x: X coordinate to check + y: Y coordinate to check + + Returns: + The entity at that position, or None if empty + """ + for entity in entities: + if entity.pos[0] == x and entity.pos[1] == y: + return entity + return None + + +def move_entity( + entity: mcrfpy.Entity, + grid: mcrfpy.Grid, + dx: int, + dy: int, + entities: List[mcrfpy.Entity] = None +) -> bool: + """ + Attempt to move an entity by a delta. + + Checks for: + - Grid bounds + - Walkable terrain + - Other blocking entities (if entities list provided) + + Args: + entity: The entity to move + grid: The grid for terrain collision + dx: Delta X (-1, 0, or 1 typically) + dy: Delta Y (-1, 0, or 1 typically) + entities: Optional list of entities to check for collision + + Returns: + True if movement succeeded, False otherwise + """ + dest_x = entity.pos[0] + dx + dest_y = entity.pos[1] + dy + + # Check grid bounds + grid_width, grid_height = grid.grid_size + if not (0 <= dest_x < grid_width and 0 <= dest_y < grid_height): + return False + + # Check if tile is walkable + if not grid.at(dest_x, dest_y).walkable: + return False + + # Check for blocking entities + if entities and get_blocking_entity_at(entities, dest_x, dest_y): + return False + + # Move is valid + entity.pos = (dest_x, dest_y) + return True + + +def distance_between( + entity1: mcrfpy.Entity, + entity2: mcrfpy.Entity +) -> float: + """ + Calculate the Chebyshev distance between two entities. + + Chebyshev distance (also called chessboard distance) counts + diagonal moves as 1, which is standard for roguelikes. + + Args: + entity1: First entity + entity2: Second entity + + Returns: + Distance in tiles (diagonal = 1) + """ + dx = abs(entity1.pos[0] - entity2.pos[0]) + dy = abs(entity1.pos[1] - entity2.pos[1]) + return max(dx, dy) + + +def entities_in_radius( + center: mcrfpy.Entity, + entities: List[mcrfpy.Entity], + radius: float +) -> List[mcrfpy.Entity]: + """ + Find all entities within a given radius of a center entity. + + Uses Chebyshev distance for roguelike-style radius. + + Args: + center: The entity to search around + entities: List of entities to check + radius: Maximum distance in tiles + + Returns: + List of entities within the radius (excluding center) + """ + nearby = [] + for entity in entities: + if entity is not center: + if distance_between(center, entity) <= radius: + nearby.append(entity) + return nearby + + +def remove_entity( + entity: mcrfpy.Entity, + grid: mcrfpy.Grid +) -> bool: + """ + Remove an entity from a grid. + + Args: + entity: The entity to remove + grid: The grid containing the entity + + Returns: + True if removal succeeded, False otherwise + """ + try: + idx = entity.index() + grid.entities.remove(idx) + return True + except (ValueError, AttributeError): + return False diff --git a/docs/templates/roguelike/game.py b/docs/templates/roguelike/game.py new file mode 100644 index 0000000..2eca8be --- /dev/null +++ b/docs/templates/roguelike/game.py @@ -0,0 +1,290 @@ +""" +game.py - Roguelike Template Main Entry Point + +A minimal but complete roguelike starter using McRogueFace. + +This template demonstrates: +- Scene and grid setup +- Procedural dungeon generation +- Player entity with keyboard movement +- Enemy entities (static, no AI) +- Field of view using TCOD via Entity.update_visibility() +- FOV visualization with grid color overlays + +Run with: ./mcrogueface + +Controls: +- Arrow keys / WASD: Move player +- Escape: Quit game + +The template is designed to be extended. Good next steps: +- Add enemy AI (chase player, pathfinding) +- Implement combat system +- Add items and inventory +- Add multiple dungeon levels +""" + +import mcrfpy +from typing import List, Tuple + +# Import our template modules +from constants import ( + MAP_WIDTH, MAP_HEIGHT, + SPRITE_WIDTH, SPRITE_HEIGHT, + FOV_RADIUS, + COLOR_VISIBLE, COLOR_EXPLORED, COLOR_UNKNOWN, + SPRITE_PLAYER, +) +from dungeon import generate_dungeon, populate_grid, RectangularRoom +from entities import ( + create_player, + create_enemies_in_rooms, + move_entity, + EntityStats, +) + + +# ============================================================================= +# GAME STATE +# ============================================================================= +# Global game state - in a larger game, you'd use a proper state management +# system, but for a template this keeps things simple and visible. + +class GameState: + """Container for all game state.""" + + def __init__(self): + # Core game objects (set during initialization) + self.grid: mcrfpy.Grid = None + self.player: mcrfpy.Entity = None + self.rooms: List[RectangularRoom] = [] + self.enemies: List[Tuple[mcrfpy.Entity, EntityStats]] = [] + + # Texture reference + self.texture: mcrfpy.Texture = None + + +# Global game state instance +game = GameState() + + +# ============================================================================= +# FOV (FIELD OF VIEW) SYSTEM +# ============================================================================= + +def update_fov() -> None: + """ + Update the field of view based on player position. + + This function: + 1. Calls update_visibility() on the player entity to compute FOV using TCOD + 2. Applies color overlays to tiles based on visibility state + + The FOV creates the classic roguelike effect where: + - Visible tiles are fully bright (no overlay) + - Previously seen tiles are dimmed (remembered layout) + - Never-seen tiles are completely dark + + TCOD handles the actual FOV computation based on the grid's + walkable and transparent flags set during dungeon generation. + """ + if not game.player or not game.grid: + return + + # Tell McRogueFace/TCOD to recompute visibility from player position + game.player.update_visibility() + + grid_width, grid_height = game.grid.grid_size + + # Apply visibility colors to each tile + for x in range(grid_width): + for y in range(grid_height): + point = game.grid.at(x, y) + + # Get the player's visibility state for this tile + state = game.player.at(x, y) + + if state.visible: + # Currently visible - no overlay (full brightness) + point.color_overlay = COLOR_VISIBLE + elif state.discovered: + # Previously seen - dimmed overlay (memory) + point.color_overlay = COLOR_EXPLORED + else: + # Never seen - completely dark + point.color_overlay = COLOR_UNKNOWN + + +# ============================================================================= +# INPUT HANDLING +# ============================================================================= + +def handle_keys(key: str, state: str) -> None: + """ + Handle keyboard input for player movement and game controls. + + This is the main input handler registered with McRogueFace. + It processes key events and updates game state accordingly. + + Args: + key: The key that was pressed (e.g., "W", "Up", "Escape") + state: Either "start" (key pressed) or "end" (key released) + """ + # Only process key press events, not releases + if state != "start": + return + + # Movement deltas: (dx, dy) + movement = { + # Arrow keys + "Up": (0, -1), + "Down": (0, 1), + "Left": (-1, 0), + "Right": (1, 0), + # WASD keys + "W": (0, -1), + "S": (0, 1), + "A": (-1, 0), + "D": (1, 0), + # Numpad (for diagonal movement if desired) + "Numpad8": (0, -1), + "Numpad2": (0, 1), + "Numpad4": (-1, 0), + "Numpad6": (1, 0), + "Numpad7": (-1, -1), + "Numpad9": (1, -1), + "Numpad1": (-1, 1), + "Numpad3": (1, 1), + } + + if key in movement: + dx, dy = movement[key] + + # Get list of all entity objects for collision checking + all_entities = [e for e, _ in game.enemies] + + # Attempt to move the player + if move_entity(game.player, game.grid, dx, dy, all_entities): + # Movement succeeded - update FOV + update_fov() + + # Center camera on player + px, py = game.player.pos + game.grid.center = (px, py) + + elif key == "Escape": + # Quit the game + mcrfpy.exit() + + +# ============================================================================= +# GAME INITIALIZATION +# ============================================================================= + +def initialize_game() -> None: + """ + Set up the game world. + + This function: + 1. Creates the scene and loads resources + 2. Generates the dungeon layout + 3. Creates and places all entities + 4. Initializes the FOV system + 5. Sets up input handling + """ + # Create the game scene + mcrfpy.createScene("game") + ui = mcrfpy.sceneUI("game") + + # Load the tileset texture + # The default McRogueFace texture works great for roguelikes + game.texture = mcrfpy.Texture( + "assets/kenney_tinydungeon.png", + SPRITE_WIDTH, + SPRITE_HEIGHT + ) + + # Create the grid (tile-based game world) + # Using keyword arguments for clarity - this is the preferred style + game.grid = mcrfpy.Grid( + pos=(0, 0), # Screen position in pixels + size=(1024, 768), # Display size in pixels + grid_size=(MAP_WIDTH, MAP_HEIGHT), # Map size in tiles + texture=game.texture + ) + ui.append(game.grid) + + # Generate dungeon layout + game.rooms = generate_dungeon() + + # Apply dungeon to grid (sets tiles, walkable flags, etc.) + populate_grid(game.grid, game.rooms) + + # Place player in the center of the first room + if game.rooms: + start_x, start_y = game.rooms[0].center + else: + # Fallback if no rooms generated + start_x, start_y = MAP_WIDTH // 2, MAP_HEIGHT // 2 + + game.player = create_player( + grid=game.grid, + texture=game.texture, + x=start_x, + y=start_y + ) + + # Center camera on player + game.grid.center = (start_x, start_y) + + # Spawn enemies in other rooms + game.enemies = create_enemies_in_rooms( + grid=game.grid, + texture=game.texture, + rooms=game.rooms, + enemies_per_room=2, + skip_first_room=True + ) + + # Initial FOV calculation + update_fov() + + # Register input handler + mcrfpy.keypressScene(handle_keys) + + # Switch to game scene + mcrfpy.setScene("game") + + +# ============================================================================= +# MAIN ENTRY POINT +# ============================================================================= + +def main() -> None: + """ + Main entry point for the roguelike template. + + This function is called when the script starts. It initializes + the game and McRogueFace handles the game loop automatically. + """ + initialize_game() + + # Display welcome message + print("=" * 50) + print(" ROGUELIKE TEMPLATE") + print("=" * 50) + print("Controls:") + print(" Arrow keys / WASD - Move") + print(" Escape - Quit") + print() + print(f"Dungeon generated with {len(game.rooms)} rooms") + print(f"Enemies spawned: {len(game.enemies)}") + print("=" * 50) + + +# Run the game +if __name__ == "__main__": + main() +else: + # McRogueFace runs game.py directly, not as __main__ + main() diff --git a/docs/tutorials/part_01_grid_movement/part_01_grid_movement.py b/docs/tutorials/part_01_grid_movement/part_01_grid_movement.py index 53c236e..ef5ee40 100644 --- a/docs/tutorials/part_01_grid_movement/part_01_grid_movement.py +++ b/docs/tutorials/part_01_grid_movement/part_01_grid_movement.py @@ -29,13 +29,11 @@ texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) grid = mcrfpy.Grid( pos=(100, 80), # Position on screen (pixels) size=(640, 480), # Display size (pixels) + zoom = 2.0, grid_size=(GRID_WIDTH, GRID_HEIGHT), # Size in tiles texture=texture ) -# Set the zoom level for better visibility -grid.zoom = 2.0 - # Fill the grid with floor tiles for y in range(GRID_HEIGHT): for x in range(GRID_WIDTH): @@ -118,4 +116,4 @@ scene.on_key = handle_keys # Activate the scene scene.activate() -print("Part 1 loaded! Use WASD or Arrow keys to move.") \ No newline at end of file +print("Part 1 loaded! Use WASD or Arrow keys to move.") diff --git a/docs/tutorials/part_02_tiles_collision/part_02_tiles_collision.py b/docs/tutorials/part_02_tiles_collision/part_02_tiles_collision.py index 66feaa4..e8a09d3 100644 --- a/docs/tutorials/part_02_tiles_collision/part_02_tiles_collision.py +++ b/docs/tutorials/part_02_tiles_collision/part_02_tiles_collision.py @@ -102,9 +102,9 @@ grid = mcrfpy.Grid( pos=(80, 100), size=(720, 480), grid_size=(GRID_WIDTH, GRID_HEIGHT), - texture=texture + texture=texture, + zoom=1.5 ) -grid.zoom = 1.5 # Build the map create_map(grid) diff --git a/docs/tutorials/part_03_dungeon_generation/part_03_dungeon_generation.py b/docs/tutorials/part_03_dungeon_generation/part_03_dungeon_generation.py index 632ad2f..d3e3874 100644 --- a/docs/tutorials/part_03_dungeon_generation/part_03_dungeon_generation.py +++ b/docs/tutorials/part_03_dungeon_generation/part_03_dungeon_generation.py @@ -250,9 +250,9 @@ grid = mcrfpy.Grid( pos=(50, 80), size=(800, 560), grid_size=(GRID_WIDTH, GRID_HEIGHT), - texture=texture + texture=texture, + zoom=1.0 ) -grid.zoom = 1.0 # Generate the dungeon and get player start position player_start_x, player_start_y = generate_dungeon(grid) diff --git a/docs/tutorials/part_04_fov/part_04_fov.py b/docs/tutorials/part_04_fov/part_04_fov.py index 97d9187..c7c89d7 100644 --- a/docs/tutorials/part_04_fov/part_04_fov.py +++ b/docs/tutorials/part_04_fov/part_04_fov.py @@ -236,9 +236,9 @@ grid = mcrfpy.Grid( pos=(50, 80), size=(800, 560), grid_size=(GRID_WIDTH, GRID_HEIGHT), - texture=texture + texture=texture, + zoom=1.0 ) -grid.zoom = 1.0 # Generate the dungeon player_start_x, player_start_y = generate_dungeon(grid) diff --git a/docs/tutorials/part_05_enemies/part_05_enemies.py b/docs/tutorials/part_05_enemies/part_05_enemies.py index 9abfc42..51d0299 100644 --- a/docs/tutorials/part_05_enemies/part_05_enemies.py +++ b/docs/tutorials/part_05_enemies/part_05_enemies.py @@ -448,9 +448,9 @@ grid = mcrfpy.Grid( pos=(50, 80), size=(800, 560), grid_size=(GRID_WIDTH, GRID_HEIGHT), - texture=texture + texture=texture, + zoom=1.0 ) -grid.zoom = 1.0 # Generate the dungeon (without player first to get starting position) fill_with_walls(grid) diff --git a/docs/tutorials/part_06_combat/part_06_combat.py b/docs/tutorials/part_06_combat/part_06_combat.py index 59d6ab2..fd6a62f 100644 --- a/docs/tutorials/part_06_combat/part_06_combat.py +++ b/docs/tutorials/part_06_combat/part_06_combat.py @@ -685,9 +685,9 @@ grid = mcrfpy.Grid( pos=(50, 80), size=(800, 480), grid_size=(GRID_WIDTH, GRID_HEIGHT), - texture=texture + texture=texture, + zoom=1.0 ) -grid.zoom = 1.0 # Generate initial dungeon structure fill_with_walls(grid) diff --git a/docs/tutorials/part_07_ui/part_07_ui.py b/docs/tutorials/part_07_ui/part_07_ui.py index 459adee..0ec3da3 100644 --- a/docs/tutorials/part_07_ui/part_07_ui.py +++ b/docs/tutorials/part_07_ui/part_07_ui.py @@ -785,9 +785,9 @@ grid = mcrfpy.Grid( pos=(20, GAME_AREA_Y), size=(800, GAME_AREA_HEIGHT - 20), grid_size=(GRID_WIDTH, GRID_HEIGHT), - texture=texture + texture=texture, + zoom=1.0 ) -grid.zoom = 1.0 # Generate initial dungeon structure fill_with_walls(grid) diff --git a/docs/tutorials/part_08_items/part_08_items.py b/docs/tutorials/part_08_items/part_08_items.py index e8f271e..d3829d9 100644 --- a/docs/tutorials/part_08_items/part_08_items.py +++ b/docs/tutorials/part_08_items/part_08_items.py @@ -1006,9 +1006,9 @@ grid = mcrfpy.Grid( pos=(20, GAME_AREA_Y), size=(700, GAME_AREA_HEIGHT - 20), grid_size=(GRID_WIDTH, GRID_HEIGHT), - texture=texture + texture=texture, + zoom=1.0 ) -grid.zoom = 1.0 # Generate initial dungeon fill_with_walls(grid) diff --git a/docs/tutorials/part_09_ranged/part_09_ranged.py b/docs/tutorials/part_09_ranged/part_09_ranged.py index f855a75..5605629 100644 --- a/docs/tutorials/part_09_ranged/part_09_ranged.py +++ b/docs/tutorials/part_09_ranged/part_09_ranged.py @@ -1089,9 +1089,9 @@ grid = mcrfpy.Grid( pos=(20, GAME_AREA_Y), size=(700, GAME_AREA_HEIGHT - 20), grid_size=(GRID_WIDTH, GRID_HEIGHT), - texture=texture + texture=texture, + zoom=1.0 ) -grid.zoom = 1.0 # Generate initial dungeon fill_with_walls(grid) diff --git a/docs/tutorials/part_10_save_load/part_10_save_load.py b/docs/tutorials/part_10_save_load/part_10_save_load.py index a0c6380..b0a911d 100644 --- a/docs/tutorials/part_10_save_load/part_10_save_load.py +++ b/docs/tutorials/part_10_save_load/part_10_save_load.py @@ -1385,9 +1385,9 @@ grid = mcrfpy.Grid( pos=(20, GAME_AREA_Y), size=(700, GAME_AREA_HEIGHT - 20), grid_size=(GRID_WIDTH, GRID_HEIGHT), - texture=texture + texture=texture, + zoom=1.0 ) -grid.zoom = 1.0 # Add FOV layer fov_layer = grid.add_layer("color", z_index=-1) diff --git a/docs/tutorials/part_11_levels/part_11_levels.py b/docs/tutorials/part_11_levels/part_11_levels.py index ee31c04..da2573c 100644 --- a/docs/tutorials/part_11_levels/part_11_levels.py +++ b/docs/tutorials/part_11_levels/part_11_levels.py @@ -1583,9 +1583,9 @@ grid = mcrfpy.Grid( pos=(20, GAME_AREA_Y), size=(700, GAME_AREA_HEIGHT - 20), grid_size=(GRID_WIDTH, GRID_HEIGHT), - texture=texture + texture=texture, + zoom=1.0 ) -grid.zoom = 1.0 fov_layer = grid.add_layer("color", z_index=-1) for y in range(GRID_HEIGHT): diff --git a/docs/tutorials/part_12_experience/part_12_experience.py b/docs/tutorials/part_12_experience/part_12_experience.py index ec59009..6a3034e 100644 --- a/docs/tutorials/part_12_experience/part_12_experience.py +++ b/docs/tutorials/part_12_experience/part_12_experience.py @@ -1694,9 +1694,9 @@ grid = mcrfpy.Grid( pos=(20, GAME_AREA_Y), size=(700, GAME_AREA_HEIGHT - 20), grid_size=(GRID_WIDTH, GRID_HEIGHT), - texture=texture + texture=texture, + zoom=1.0 ) -grid.zoom = 1.0 fov_layer = grid.add_layer("color", z_index=-1) for y in range(GRID_HEIGHT): diff --git a/docs/tutorials/part_13_equipment/part_13_equipment.py b/docs/tutorials/part_13_equipment/part_13_equipment.py index 725f20b..bca3f3d 100644 --- a/docs/tutorials/part_13_equipment/part_13_equipment.py +++ b/docs/tutorials/part_13_equipment/part_13_equipment.py @@ -1662,9 +1662,9 @@ grid = mcrfpy.Grid( pos=(20, GAME_AREA_Y), size=(700, GAME_AREA_HEIGHT - 20), grid_size=(GRID_WIDTH, GRID_HEIGHT), - texture=texture + texture=texture, + zoom=1.0 ) -grid.zoom = 1.0 fov_layer = grid.add_layer("color", z_index=-1) for y in range(GRID_HEIGHT):