draft tutorial revisions
This commit is contained in:
parent
838da4571d
commit
48359b5a48
70 changed files with 6216 additions and 28 deletions
13
docs/cookbook/combat/combat_animated_movement_basic.py
Normal file
13
docs/cookbook/combat/combat_animated_movement_basic.py
Normal file
|
|
@ -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)
|
||||
12
docs/cookbook/combat/combat_animated_movement_basic_2.py
Normal file
12
docs/cookbook/combat/combat_animated_movement_basic_2.py
Normal file
|
|
@ -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
|
||||
45
docs/cookbook/combat/combat_enemy_ai_basic.py
Normal file
45
docs/cookbook/combat/combat_enemy_ai_basic.py
Normal file
|
|
@ -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
|
||||
11
docs/cookbook/combat/combat_enemy_ai_multi.py
Normal file
11
docs/cookbook/combat/combat_enemy_ai_multi.py
Normal file
|
|
@ -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]
|
||||
14
docs/cookbook/combat/combat_enemy_ai_multi_2.py
Normal file
14
docs/cookbook/combat/combat_enemy_ai_multi_2.py
Normal file
|
|
@ -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
|
||||
82
docs/cookbook/combat/combat_melee_basic.py
Normal file
82
docs/cookbook/combat/combat_melee_basic.py
Normal file
|
|
@ -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)
|
||||
15
docs/cookbook/combat/combat_melee_complete.py
Normal file
15
docs/cookbook/combat/combat_melee_complete.py
Normal file
|
|
@ -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)
|
||||
14
docs/cookbook/combat/combat_melee_complete_2.py
Normal file
14
docs/cookbook/combat/combat_melee_complete_2.py
Normal file
|
|
@ -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
|
||||
56
docs/cookbook/combat/combat_status_effects_basic.py
Normal file
56
docs/cookbook/combat/combat_status_effects_basic.py
Normal file
|
|
@ -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)
|
||||
16
docs/cookbook/combat/combat_status_effects_basic_2.py
Normal file
16
docs/cookbook/combat/combat_status_effects_basic_2.py
Normal file
|
|
@ -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)
|
||||
12
docs/cookbook/combat/combat_status_effects_basic_3.py
Normal file
12
docs/cookbook/combat/combat_status_effects_basic_3.py
Normal file
|
|
@ -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]
|
||||
45
docs/cookbook/combat/combat_turn_system.py
Normal file
45
docs/cookbook/combat/combat_turn_system.py
Normal file
|
|
@ -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)
|
||||
118
docs/cookbook/effects/effects_color_pulse_basic.py
Normal file
118
docs/cookbook/effects/effects_color_pulse_basic.py
Normal file
|
|
@ -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()
|
||||
61
docs/cookbook/effects/effects_color_pulse_multi.py
Normal file
61
docs/cookbook/effects/effects_color_pulse_multi.py
Normal file
|
|
@ -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)
|
||||
41
docs/cookbook/effects/effects_damage_flash_basic.py
Normal file
41
docs/cookbook/effects/effects_damage_flash_basic.py
Normal file
|
|
@ -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))
|
||||
85
docs/cookbook/effects/effects_damage_flash_complete.py
Normal file
85
docs/cookbook/effects/effects_damage_flash_complete.py
Normal file
|
|
@ -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
|
||||
25
docs/cookbook/effects/effects_damage_flash_multi.py
Normal file
25
docs/cookbook/effects/effects_damage_flash_multi.py
Normal file
|
|
@ -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)
|
||||
42
docs/cookbook/effects/effects_floating_text.py
Normal file
42
docs/cookbook/effects/effects_floating_text.py
Normal file
|
|
@ -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)
|
||||
65
docs/cookbook/effects/effects_path_animation.py
Normal file
65
docs/cookbook/effects/effects_path_animation.py
Normal file
|
|
@ -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()
|
||||
166
docs/cookbook/effects/effects_scene_transitions.py
Normal file
166
docs/cookbook/effects/effects_scene_transitions.py
Normal file
|
|
@ -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")
|
||||
38
docs/cookbook/effects/effects_screen_shake_basic.py
Normal file
38
docs/cookbook/effects/effects_screen_shake_basic.py
Normal file
|
|
@ -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)
|
||||
58
docs/cookbook/effects/effects_screen_shake_multi.py
Normal file
58
docs/cookbook/effects/effects_screen_shake_multi.py
Normal file
|
|
@ -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)
|
||||
74
docs/cookbook/grid/grid_cell_highlighting_animated.py
Normal file
74
docs/cookbook/grid/grid_cell_highlighting_animated.py
Normal file
|
|
@ -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()
|
||||
74
docs/cookbook/grid/grid_cell_highlighting_basic.py
Normal file
74
docs/cookbook/grid/grid_cell_highlighting_basic.py
Normal file
|
|
@ -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
|
||||
23
docs/cookbook/grid/grid_cell_highlighting_multi.py
Normal file
23
docs/cookbook/grid/grid_cell_highlighting_multi.py
Normal file
|
|
@ -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')
|
||||
31
docs/cookbook/grid/grid_dijkstra_basic.py
Normal file
31
docs/cookbook/grid/grid_dijkstra_basic.py
Normal file
|
|
@ -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
|
||||
44
docs/cookbook/grid/grid_dijkstra_multi.py
Normal file
44
docs/cookbook/grid/grid_dijkstra_multi.py
Normal file
|
|
@ -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
|
||||
125
docs/cookbook/grid/grid_dungeon_generator_basic.py
Normal file
125
docs/cookbook/grid/grid_dungeon_generator_basic.py
Normal file
|
|
@ -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
|
||||
148
docs/cookbook/grid/grid_dungeon_generator_complete.py
Normal file
148
docs/cookbook/grid/grid_dungeon_generator_complete.py
Normal file
|
|
@ -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
|
||||
20
docs/cookbook/grid/grid_fog_of_war.py
Normal file
20
docs/cookbook/grid/grid_fog_of_war.py
Normal file
|
|
@ -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)
|
||||
114
docs/cookbook/grid/grid_multi_layer_basic.py
Normal file
114
docs/cookbook/grid/grid_multi_layer_basic.py
Normal file
|
|
@ -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)
|
||||
38
docs/cookbook/grid/grid_multi_layer_complete.py
Normal file
38
docs/cookbook/grid/grid_multi_layer_complete.py
Normal file
|
|
@ -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()
|
||||
120
docs/cookbook/ui/ui_health_bar_animated.py
Normal file
120
docs/cookbook/ui/ui_health_bar_animated.py
Normal file
|
|
@ -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)
|
||||
43
docs/cookbook/ui/ui_health_bar_basic.py
Normal file
43
docs/cookbook/ui/ui_health_bar_basic.py
Normal file
|
|
@ -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)
|
||||
123
docs/cookbook/ui/ui_health_bar_enhanced.py
Normal file
123
docs/cookbook/ui/ui_health_bar_enhanced.py
Normal file
|
|
@ -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
|
||||
108
docs/cookbook/ui/ui_health_bar_multi.py
Normal file
108
docs/cookbook/ui/ui_health_bar_multi.py
Normal file
|
|
@ -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)
|
||||
53
docs/cookbook/ui/ui_menu_basic.py
Normal file
53
docs/cookbook/ui/ui_menu_basic.py
Normal file
|
|
@ -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)
|
||||
159
docs/cookbook/ui/ui_menu_enhanced.py
Normal file
159
docs/cookbook/ui/ui_menu_enhanced.py
Normal file
|
|
@ -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()
|
||||
54
docs/cookbook/ui/ui_message_log_basic.py
Normal file
54
docs/cookbook/ui/ui_message_log_basic.py
Normal file
|
|
@ -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")
|
||||
27
docs/cookbook/ui/ui_message_log_enhanced.py
Normal file
27
docs/cookbook/ui/ui_message_log_enhanced.py
Normal file
|
|
@ -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
|
||||
69
docs/cookbook/ui/ui_modal_dialog_basic.py
Normal file
69
docs/cookbook/ui/ui_modal_dialog_basic.py
Normal file
|
|
@ -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)
|
||||
78
docs/cookbook/ui/ui_modal_dialog_enhanced.py
Normal file
78
docs/cookbook/ui/ui_modal_dialog_enhanced.py
Normal file
|
|
@ -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"])
|
||||
65
docs/cookbook/ui/ui_tooltip_basic.py
Normal file
65
docs/cookbook/ui/ui_tooltip_basic.py
Normal file
|
|
@ -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)
|
||||
80
docs/cookbook/ui/ui_tooltip_multi.py
Normal file
80
docs/cookbook/ui/ui_tooltip_multi.py
Normal file
|
|
@ -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)
|
||||
289
docs/templates/complete/ai.py
vendored
Normal file
289
docs/templates/complete/ai.py
vendored
Normal file
|
|
@ -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
|
||||
187
docs/templates/complete/combat.py
vendored
Normal file
187
docs/templates/complete/combat.py
vendored
Normal file
|
|
@ -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
|
||||
210
docs/templates/complete/constants.py
vendored
Normal file
210
docs/templates/complete/constants.py
vendored
Normal file
|
|
@ -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"
|
||||
298
docs/templates/complete/dungeon.py
vendored
Normal file
298
docs/templates/complete/dungeon.py
vendored
Normal file
|
|
@ -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
|
||||
319
docs/templates/complete/entities.py
vendored
Normal file
319
docs/templates/complete/entities.py
vendored
Normal file
|
|
@ -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)
|
||||
313
docs/templates/complete/game.py
vendored
Normal file
313
docs/templates/complete/game.py
vendored
Normal file
|
|
@ -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()
|
||||
232
docs/templates/complete/turns.py
vendored
Normal file
232
docs/templates/complete/turns.py
vendored
Normal file
|
|
@ -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))
|
||||
330
docs/templates/complete/ui.py
vendored
Normal file
330
docs/templates/complete/ui.py
vendored
Normal file
|
|
@ -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
|
||||
176
docs/templates/minimal/game.py
vendored
Normal file
176
docs/templates/minimal/game.py
vendored
Normal file
|
|
@ -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()
|
||||
138
docs/templates/roguelike/constants.py
vendored
Normal file
138
docs/templates/roguelike/constants.py
vendored
Normal file
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
340
docs/templates/roguelike/dungeon.py
vendored
Normal file
340
docs/templates/roguelike/dungeon.py
vendored
Normal file
|
|
@ -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
|
||||
364
docs/templates/roguelike/entities.py
vendored
Normal file
364
docs/templates/roguelike/entities.py
vendored
Normal file
|
|
@ -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
|
||||
290
docs/templates/roguelike/game.py
vendored
Normal file
290
docs/templates/roguelike/game.py
vendored
Normal file
|
|
@ -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()
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue