From 9f481a2e4a24e11aa43473d0d63e246365065f98 Mon Sep 17 00:00:00 2001 From: John McCardle Date: Mon, 29 Dec 2025 19:47:48 -0500 Subject: [PATCH 1/5] fix: Update test files to use current API patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates test suite to current API: - Frame(x, y, w, h) → Frame(pos=(x, y), size=(w, h)) - Caption("text", x, y) → Caption(pos=(x, y), text="text") - caption.size → caption.font_size - Entity(x, y, ...) → Entity((x, y), ...) - Grid(w, h, ...) → Grid(grid_size=(w, h), ...) - cell.color → ColorLayer system Tests now serve as valid API usage examples. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- tests/benchmarks/benchmark_moving_entities.py | 15 ++-- tests/benchmarks/benchmark_suite.py | 14 +-- tests/benchmarks/layer_performance_test.py | 31 +++---- tests/integration/astar_vs_dijkstra.py | 86 ++++++++++--------- tests/integration/debug_visibility.py | 3 +- tests/integration/dijkstra_all_paths.py | 71 +++++++-------- tests/integration/dijkstra_cycle_paths.py | 65 +++++++------- tests/integration/dijkstra_debug.py | 49 ++++++----- tests/integration/dijkstra_interactive.py | 66 +++++++------- .../dijkstra_interactive_enhanced.py | 76 ++++++++-------- tests/integration/dijkstra_test.py | 4 +- tests/integration/interactive_visibility.py | 31 ++++--- .../simple_interactive_visibility.py | 7 +- tests/integration/simple_visibility_test.py | 4 +- .../regression/issue_123_chunk_system_test.py | 65 +++++++------- tests/regression/issue_76_test.py | 14 +-- tests/unit/WORKING_automation_test_example.py | 6 +- tests/unit/check_entity_attrs.py | 2 +- tests/unit/debug_render_test.py | 2 +- tests/unit/generate_grid_screenshot.py | 44 +++------- tests/unit/generate_sprite_screenshot.py | 28 +++--- .../unit/screenshot_transparency_fix_test.py | 22 ++--- tests/unit/simple_screenshot_test.py | 4 +- tests/unit/simple_timer_screenshot_test.py | 2 +- tests/unit/test_animation_chaining.py | 17 ++-- tests/unit/test_animation_debug.py | 13 +-- tests/unit/test_animation_immediate.py | 2 +- tests/unit/test_animation_raii.py | 16 ++-- tests/unit/test_animation_removal.py | 6 +- tests/unit/test_dijkstra_pathfinding.py | 27 +++--- tests/unit/test_entity_animation.py | 20 +++-- tests/unit/test_entity_fix.py | 15 ++-- tests/unit/test_entity_path_to.py | 3 +- tests/unit/test_entity_path_to_edge_cases.py | 5 +- tests/unit/test_grid_background.py | 24 +++--- tests/unit/test_headless_detection.py | 4 +- tests/unit/test_headless_modes.py | 10 +-- tests/unit/test_metrics.py | 10 +-- tests/unit/test_path_colors.py | 32 ++++--- tests/unit/test_pathfinding_integration.py | 6 +- tests/unit/test_properties_quick.py | 2 +- tests/unit/test_scene_transitions.py | 50 +++++------ tests/unit/test_scene_transitions_headless.py | 8 +- tests/unit/test_simple_drawable.py | 2 +- tests/unit/test_text_input.py | 14 +-- tests/unit/test_uicaption_visual.py | 12 +-- tests/unit/test_visibility.py | 19 ++-- tests/unit/test_visual_path.py | 58 ++++++------- tests/unit/ui_Frame_test_detailed.py | 30 +++---- tests/unit/ui_Grid_none_texture_test.py | 38 ++++---- tests/unit/ui_UICollection_issue69_test.py | 8 +- tests/unit/validate_screenshot_test.py | 32 +++---- tests/unit/working_timer_test.py | 6 +- 53 files changed, 614 insertions(+), 586 deletions(-) diff --git a/tests/benchmarks/benchmark_moving_entities.py b/tests/benchmarks/benchmark_moving_entities.py index 6c0fb76..23d79d6 100644 --- a/tests/benchmarks/benchmark_moving_entities.py +++ b/tests/benchmarks/benchmark_moving_entities.py @@ -34,12 +34,15 @@ grid = mcrfpy.Grid( size=(1024, 768) ) +# Add color layer for floor pattern +color_layer = grid.add_layer("color", z_index=-1) + # Simple floor pattern for x in range(100): for y in range(100): - cell = grid.at((x, y)) + cell = grid.at(x, y) cell.tilesprite = 0 - cell.color = (40, 40, 40, 255) + color_layer.set(x, y, mcrfpy.Color(40, 40, 40, 255)) # Create 50 entities with random positions and velocities entities = [] @@ -47,15 +50,15 @@ ENTITY_COUNT = 50 for i in range(ENTITY_COUNT): entity = mcrfpy.Entity( - grid_pos=(random.randint(0, 99), random.randint(0, 99)), - sprite_index=random.randint(10, 20) # Use varied sprites + (random.randint(0, 99), random.randint(0, 99)), + sprite_index=random.randint(10, 20), # Use varied sprites + grid=grid ) - # Give each entity a random velocity + # Give each entity a random velocity (stored as Python attributes) entity.velocity_x = random.uniform(-0.5, 0.5) entity.velocity_y = random.uniform(-0.5, 0.5) - grid.entities.append(entity) entities.append(entity) ui.append(grid) diff --git a/tests/benchmarks/benchmark_suite.py b/tests/benchmarks/benchmark_suite.py index 18806d4..72c24c7 100644 --- a/tests/benchmarks/benchmark_suite.py +++ b/tests/benchmarks/benchmark_suite.py @@ -282,23 +282,23 @@ def setup_grid_stress(): grid.center = (400, 400) # Center view ui.append(grid) - # Fill with alternating colors + # Add color layer and fill with alternating colors + color_layer = grid.add_layer("color", z_index=-1) for y in range(50): for x in range(50): - cell = grid.at(x, y) if (x + y) % 2 == 0: - cell.color = mcrfpy.Color(60, 60, 80) + color_layer.set(x, y, mcrfpy.Color(60, 60, 80)) else: - cell.color = mcrfpy.Color(40, 40, 60) + color_layer.set(x, y, mcrfpy.Color(40, 40, 60)) # Add 50 entities try: texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) for i in range(50): - # Entity takes positional args: (position, texture, sprite_index, grid) - pos = mcrfpy.Vector(random.randint(5, 45), random.randint(5, 45)) - entity = mcrfpy.Entity(pos, texture, random.randint(0, 100), grid) + # Entity takes tuple position and keyword args + pos = (random.randint(5, 45), random.randint(5, 45)) + entity = mcrfpy.Entity(pos, texture=texture, sprite_index=random.randint(0, 100), grid=grid) grid.entities.append(entity) except Exception as e: print(f" Note: Could not create entities: {e}") diff --git a/tests/benchmarks/layer_performance_test.py b/tests/benchmarks/layer_performance_test.py index a81b441..6649a6b 100644 --- a/tests/benchmarks/layer_performance_test.py +++ b/tests/benchmarks/layer_performance_test.py @@ -6,10 +6,14 @@ Uses C++ benchmark logger (start_benchmark/end_benchmark) for accurate timing. Results written to JSON files for analysis. Compares rendering performance between: -1. Traditional grid.at(x,y).color API (no caching) -2. New layer system with dirty flag caching +1. ColorLayer with per-cell modifications (no caching benefit) +2. ColorLayer with dirty flag caching (static after fill) 3. Various layer configurations +NOTE: The old grid.at(x,y).color API no longer exists. All color operations +now go through the ColorLayer system. This benchmark compares different +layer usage patterns to measure caching effectiveness. + Usage: ./mcrogueface --exec tests/benchmarks/layer_performance_test.py # Results in benchmark_*.json files @@ -94,7 +98,7 @@ def run_next_test(): # ============================================================================ def setup_base_layer_static(): - """Traditional grid.at(x,y).color API - no modifications during render.""" + """ColorLayer with per-cell set() calls - static after initial fill.""" mcrfpy.createScene("test_base_static") ui = mcrfpy.sceneUI("test_base_static") @@ -102,17 +106,17 @@ def setup_base_layer_static(): pos=(10, 10), size=(600, 600)) ui.append(grid) - # Fill base layer using traditional API + # Fill using ColorLayer with per-cell set() calls (baseline) + layer = grid.add_layer("color", z_index=-1) for y in range(GRID_SIZE): for x in range(GRID_SIZE): - cell = grid.at(x, y) - cell.color = mcrfpy.Color((x * 2) % 256, (y * 2) % 256, 128, 255) + layer.set(x, y, mcrfpy.Color((x * 2) % 256, (y * 2) % 256, 128, 255)) mcrfpy.setScene("test_base_static") def setup_base_layer_modified(): - """Traditional API with single cell modified each frame.""" + """ColorLayer with single cell modified each frame - tests dirty flag.""" mcrfpy.createScene("test_base_mod") ui = mcrfpy.sceneUI("test_base_mod") @@ -120,19 +124,16 @@ def setup_base_layer_modified(): pos=(10, 10), size=(600, 600)) ui.append(grid) - # Fill base layer - for y in range(GRID_SIZE): - for x in range(GRID_SIZE): - cell = grid.at(x, y) - cell.color = mcrfpy.Color(100, 100, 100, 255) + # Fill using ColorLayer + layer = grid.add_layer("color", z_index=-1) + layer.fill(mcrfpy.Color(100, 100, 100, 255)) - # Timer to modify one cell per frame + # Timer to modify one cell per frame (triggers dirty flag each frame) mod_counter = [0] def modify_cell(runtime): x = mod_counter[0] % GRID_SIZE y = (mod_counter[0] // GRID_SIZE) % GRID_SIZE - cell = grid.at(x, y) - cell.color = mcrfpy.Color(255, 0, 0, 255) + layer.set(x, y, mcrfpy.Color(255, 0, 0, 255)) mod_counter[0] += 1 mcrfpy.setScene("test_base_mod") diff --git a/tests/integration/astar_vs_dijkstra.py b/tests/integration/astar_vs_dijkstra.py index 5b93c99..be75ea2 100644 --- a/tests/integration/astar_vs_dijkstra.py +++ b/tests/integration/astar_vs_dijkstra.py @@ -19,26 +19,30 @@ END_COLOR = mcrfpy.Color(255, 255, 100) # Yellow for end # Global state grid = None +color_layer = None mode = "ASTAR" start_pos = (5, 10) end_pos = (27, 10) # Changed from 25 to 27 to avoid the wall def create_map(): """Create a map with obstacles to show pathfinding differences""" - global grid - + global grid, color_layer + mcrfpy.createScene("pathfinding_comparison") - + # Create grid grid = mcrfpy.Grid(grid_x=30, grid_y=20) grid.fill_color = mcrfpy.Color(0, 0, 0) - + + # Add color layer for cell coloring + color_layer = grid.add_layer("color", z_index=-1) + # Initialize all as floor for y in range(20): for x in range(30): grid.at(x, y).walkable = True - grid.at(x, y).color = FLOOR_COLOR - + color_layer.set(x, y, FLOOR_COLOR) + # Create obstacles that make A* and Dijkstra differ obstacles = [ # Vertical wall with gaps @@ -50,15 +54,15 @@ def create_map(): [(x, 10) for x in range(20, 25)], [(25, y) for y in range(5, 15)], ] - + for obstacle_group in obstacles: for x, y in obstacle_group: grid.at(x, y).walkable = False - grid.at(x, y).color = WALL_COLOR - + color_layer.set(x, y, WALL_COLOR) + # Mark start and end - grid.at(start_pos[0], start_pos[1]).color = START_COLOR - grid.at(end_pos[0], end_pos[1]).color = END_COLOR + color_layer.set(start_pos[0], start_pos[1], START_COLOR) + color_layer.set(end_pos[0], end_pos[1], END_COLOR) def clear_paths(): """Clear path highlighting""" @@ -66,34 +70,34 @@ def clear_paths(): for x in range(30): cell = grid.at(x, y) if cell.walkable: - cell.color = FLOOR_COLOR - + color_layer.set(x, y, FLOOR_COLOR) + # Restore start and end colors - grid.at(start_pos[0], start_pos[1]).color = START_COLOR - grid.at(end_pos[0], end_pos[1]).color = END_COLOR + color_layer.set(start_pos[0], start_pos[1], START_COLOR) + color_layer.set(end_pos[0], end_pos[1], END_COLOR) def show_astar(): """Show A* path""" clear_paths() - + # Compute A* path path = grid.compute_astar_path(start_pos[0], start_pos[1], end_pos[0], end_pos[1]) - + # Color the path for i, (x, y) in enumerate(path): if (x, y) != start_pos and (x, y) != end_pos: - grid.at(x, y).color = ASTAR_COLOR - + color_layer.set(x, y, ASTAR_COLOR) + status_text.text = f"A* Path: {len(path)} steps (optimized for single target)" status_text.fill_color = ASTAR_COLOR def show_dijkstra(): """Show Dijkstra exploration""" clear_paths() - + # Compute Dijkstra from start grid.compute_dijkstra(start_pos[0], start_pos[1]) - + # Color cells by distance (showing exploration) max_dist = 40.0 for y in range(20): @@ -103,50 +107,50 @@ def show_dijkstra(): if dist is not None and dist < max_dist: # Color based on distance intensity = int(255 * (1 - dist / max_dist)) - grid.at(x, y).color = mcrfpy.Color(0, intensity // 2, intensity) - + color_layer.set(x, y, mcrfpy.Color(0, intensity // 2, intensity)) + # Get the actual path path = grid.get_dijkstra_path(end_pos[0], end_pos[1]) - + # Highlight the actual path more brightly for x, y in path: if (x, y) != start_pos and (x, y) != end_pos: - grid.at(x, y).color = DIJKSTRA_COLOR - + color_layer.set(x, y, DIJKSTRA_COLOR) + # Restore start and end - grid.at(start_pos[0], start_pos[1]).color = START_COLOR - grid.at(end_pos[0], end_pos[1]).color = END_COLOR - + color_layer.set(start_pos[0], start_pos[1], START_COLOR) + color_layer.set(end_pos[0], end_pos[1], END_COLOR) + status_text.text = f"Dijkstra: {len(path)} steps (explores all directions)" status_text.fill_color = DIJKSTRA_COLOR def show_both(): """Show both paths overlaid""" clear_paths() - + # Get both paths astar_path = grid.compute_astar_path(start_pos[0], start_pos[1], end_pos[0], end_pos[1]) grid.compute_dijkstra(start_pos[0], start_pos[1]) dijkstra_path = grid.get_dijkstra_path(end_pos[0], end_pos[1]) - + print(astar_path, dijkstra_path) # Color Dijkstra path first (blue) for x, y in dijkstra_path: if (x, y) != start_pos and (x, y) != end_pos: - grid.at(x, y).color = DIJKSTRA_COLOR - + color_layer.set(x, y, DIJKSTRA_COLOR) + # Then A* path (green) - will overwrite shared cells for x, y in astar_path: if (x, y) != start_pos and (x, y) != end_pos: - grid.at(x, y).color = ASTAR_COLOR - + color_layer.set(x, y, ASTAR_COLOR) + # Mark differences different_cells = [] for cell in dijkstra_path: if cell not in astar_path: different_cells.append(cell) - + status_text.text = f"Both paths: A*={len(astar_path)} steps, Dijkstra={len(dijkstra_path)} steps" if different_cells: info_text.text = f"Paths differ at {len(different_cells)} cells" @@ -202,26 +206,26 @@ grid.size = (600, 400) # 30*20, 20*20 grid.position = (100, 100) # Add title -title = mcrfpy.Caption("A* vs Dijkstra Pathfinding", 250, 20) +title = mcrfpy.Caption(pos=(250, 20), text="A* vs Dijkstra Pathfinding") title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) # Add status -status_text = mcrfpy.Caption("Press A for A*, D for Dijkstra, B for Both", 100, 60) +status_text = mcrfpy.Caption(pos=(100, 60), text="Press A for A*, D for Dijkstra, B for Both") status_text.fill_color = mcrfpy.Color(200, 200, 200) ui.append(status_text) # Add info -info_text = mcrfpy.Caption("", 100, 520) +info_text = mcrfpy.Caption(pos=(100, 520), text="") info_text.fill_color = mcrfpy.Color(200, 200, 200) ui.append(info_text) # Add legend -legend1 = mcrfpy.Caption("Red=Start, Yellow=End, Green=A*, Blue=Dijkstra", 100, 540) +legend1 = mcrfpy.Caption(pos=(100, 540), text="Red=Start, Yellow=End, Green=A*, Blue=Dijkstra") legend1.fill_color = mcrfpy.Color(150, 150, 150) ui.append(legend1) -legend2 = mcrfpy.Caption("Dark=Walls, Light=Floor", 100, 560) +legend2 = mcrfpy.Caption(pos=(100, 560), text="Dark=Walls, Light=Floor") legend2.fill_color = mcrfpy.Color(150, 150, 150) ui.append(legend2) diff --git a/tests/integration/debug_visibility.py b/tests/integration/debug_visibility.py index da0bd60..89a4ab7 100644 --- a/tests/integration/debug_visibility.py +++ b/tests/integration/debug_visibility.py @@ -20,9 +20,8 @@ for y in range(5): # Create entity print("Creating entity...") -entity = mcrfpy.Entity(2, 2) +entity = mcrfpy.Entity((2, 2), grid=grid) entity.sprite_index = 64 -grid.entities.append(entity) print(f"Entity at ({entity.x}, {entity.y})") # Check gridstate diff --git a/tests/integration/dijkstra_all_paths.py b/tests/integration/dijkstra_all_paths.py index e205f08..79ce919 100644 --- a/tests/integration/dijkstra_all_paths.py +++ b/tests/integration/dijkstra_all_paths.py @@ -20,6 +20,7 @@ NO_PATH_COLOR = mcrfpy.Color(255, 0, 0) # Pure red for unreachable # Global state grid = None +color_layer = None entities = [] current_combo_index = 0 all_combinations = [] # All possible pairs @@ -27,14 +28,17 @@ current_path = [] def create_map(): """Create the map with entities""" - global grid, entities, all_combinations - + global grid, color_layer, entities, all_combinations + mcrfpy.createScene("dijkstra_all") - + # Create grid grid = mcrfpy.Grid(grid_x=14, grid_y=10) grid.fill_color = mcrfpy.Color(0, 0, 0) - + + # Add color layer for cell coloring + color_layer = grid.add_layer("color", z_index=-1) + # Map layout - Entity 1 is intentionally trapped! map_layout = [ "..............", # Row 0 @@ -48,29 +52,28 @@ def create_map(): "..W.WWW.......", # Row 8 "..............", # Row 9 ] - + # Create the map entity_positions = [] for y, row in enumerate(map_layout): for x, char in enumerate(row): cell = grid.at(x, y) - + if char == 'W': cell.walkable = False - cell.color = WALL_COLOR + color_layer.set(x, y, WALL_COLOR) else: cell.walkable = True - cell.color = FLOOR_COLOR - + color_layer.set(x, y, FLOOR_COLOR) + if char == 'E': entity_positions.append((x, y)) - + # Create entities entities = [] for i, (x, y) in enumerate(entity_positions): - entity = mcrfpy.Entity(x, y) + entity = mcrfpy.Entity((x, y), grid=grid) entity.sprite_index = 49 + i # '1', '2', '3' - grid.entities.append(entity) entities.append(entity) print("Map Analysis:") @@ -90,47 +93,47 @@ def create_map(): def clear_path_colors(): """Reset all floor tiles to original color""" global current_path - + for y in range(grid.grid_y): for x in range(grid.grid_x): cell = grid.at(x, y) if cell.walkable: - cell.color = FLOOR_COLOR - + color_layer.set(x, y, FLOOR_COLOR) + current_path = [] def show_combination(index): """Show a specific path combination (valid or invalid)""" global current_combo_index, current_path - + current_combo_index = index % len(all_combinations) from_idx, to_idx = all_combinations[current_combo_index] - + # Clear previous path clear_path_colors() - + # Get entities e_from = entities[from_idx] e_to = entities[to_idx] - + # Calculate path path = e_from.path_to(int(e_to.x), int(e_to.y)) current_path = path if path else [] - + # Always color start and end positions - grid.at(int(e_from.x), int(e_from.y)).color = START_COLOR - grid.at(int(e_to.x), int(e_to.y)).color = NO_PATH_COLOR if not path else END_COLOR - + color_layer.set(int(e_from.x), int(e_from.y), START_COLOR) + color_layer.set(int(e_to.x), int(e_to.y), NO_PATH_COLOR if not path else END_COLOR) + # Color the path if it exists if path: # Color intermediate steps for i, (x, y) in enumerate(path): if i > 0 and i < len(path) - 1: - grid.at(x, y).color = PATH_COLOR - + color_layer.set(x, y, PATH_COLOR) + status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} → Entity {to_idx+1} = {len(path)} steps" status_text.fill_color = mcrfpy.Color(100, 255, 100) # Green for valid - + # Show path steps path_display = [] for i, (x, y) in enumerate(path[:5]): @@ -142,7 +145,7 @@ def show_combination(index): status_text.text = f"Path {current_combo_index + 1}/{len(all_combinations)}: Entity {from_idx+1} → Entity {to_idx+1} = NO PATH!" status_text.fill_color = mcrfpy.Color(255, 100, 100) # Red for invalid path_text.text = "Path: [] (No valid path exists)" - + # Update info info_text.text = f"From: Entity {from_idx+1} at ({int(e_from.x)}, {int(e_from.y)}) | To: Entity {to_idx+1} at ({int(e_to.x)}, {int(e_to.y)})" @@ -183,37 +186,37 @@ grid.size = (560, 400) grid.position = (120, 100) # Add title -title = mcrfpy.Caption("Dijkstra - All Paths (Valid & Invalid)", 200, 20) +title = mcrfpy.Caption(pos=(200, 20), text="Dijkstra - All Paths (Valid & Invalid)") title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) # Add status (will change color based on validity) -status_text = mcrfpy.Caption("Ready", 120, 60) +status_text = mcrfpy.Caption(pos=(120, 60), text="Ready") status_text.fill_color = mcrfpy.Color(255, 255, 100) ui.append(status_text) # Add info -info_text = mcrfpy.Caption("", 120, 80) +info_text = mcrfpy.Caption(pos=(120, 80), text="") info_text.fill_color = mcrfpy.Color(200, 200, 200) ui.append(info_text) # Add path display -path_text = mcrfpy.Caption("Path: None", 120, 520) +path_text = mcrfpy.Caption(pos=(120, 520), text="Path: None") path_text.fill_color = mcrfpy.Color(200, 200, 200) ui.append(path_text) # Add controls -controls = mcrfpy.Caption("SPACE/N=Next, P=Previous, 1-6=Jump to path, Q=Quit", 120, 540) +controls = mcrfpy.Caption(pos=(120, 540), text="SPACE/N=Next, P=Previous, 1-6=Jump to path, Q=Quit") controls.fill_color = mcrfpy.Color(150, 150, 150) ui.append(controls) # Add legend -legend = mcrfpy.Caption("Red Start→Blue End (valid) | Red Start→Red End (invalid)", 120, 560) +legend = mcrfpy.Caption(pos=(120, 560), text="Red Start→Blue End (valid) | Red Start→Red End (invalid)") legend.fill_color = mcrfpy.Color(150, 150, 150) ui.append(legend) # Expected results info -expected = mcrfpy.Caption("Entity 1 is trapped: paths 1→2, 1→3, 2→1, 3→1 will fail", 120, 580) +expected = mcrfpy.Caption(pos=(120, 580), text="Entity 1 is trapped: paths 1→2, 1→3, 2→1, 3→1 will fail") expected.fill_color = mcrfpy.Color(255, 150, 150) ui.append(expected) diff --git a/tests/integration/dijkstra_cycle_paths.py b/tests/integration/dijkstra_cycle_paths.py index 201219c..2f71862 100644 --- a/tests/integration/dijkstra_cycle_paths.py +++ b/tests/integration/dijkstra_cycle_paths.py @@ -18,6 +18,7 @@ END_COLOR = mcrfpy.Color(100, 100, 255) # Light blue # Global state grid = None +color_layer = None entities = [] current_path_index = 0 path_combinations = [] @@ -25,14 +26,17 @@ current_path = [] def create_map(): """Create the map with entities""" - global grid, entities - + global grid, color_layer, entities + mcrfpy.createScene("dijkstra_cycle") - + # Create grid grid = mcrfpy.Grid(grid_x=14, grid_y=10) grid.fill_color = mcrfpy.Color(0, 0, 0) - + + # Add color layer for cell coloring + color_layer = grid.add_layer("color", z_index=-1) + # Map layout map_layout = [ "..............", # Row 0 @@ -46,29 +50,28 @@ def create_map(): "..W.WWW.......", # Row 8 "..............", # Row 9 ] - + # Create the map entity_positions = [] for y, row in enumerate(map_layout): for x, char in enumerate(row): cell = grid.at(x, y) - + if char == 'W': cell.walkable = False - cell.color = WALL_COLOR + color_layer.set(x, y, WALL_COLOR) else: cell.walkable = True - cell.color = FLOOR_COLOR - + color_layer.set(x, y, FLOOR_COLOR) + if char == 'E': entity_positions.append((x, y)) - + # Create entities entities = [] for i, (x, y) in enumerate(entity_positions): - entity = mcrfpy.Entity(x, y) + entity = mcrfpy.Entity((x, y), grid=grid) entity.sprite_index = 49 + i # '1', '2', '3' - grid.entities.append(entity) entities.append(entity) print("Entities created:") @@ -113,48 +116,48 @@ def create_map(): def clear_path_colors(): """Reset all floor tiles to original color""" global current_path - + for y in range(grid.grid_y): for x in range(grid.grid_x): cell = grid.at(x, y) if cell.walkable: - cell.color = FLOOR_COLOR - + color_layer.set(x, y, FLOOR_COLOR) + current_path = [] def show_path(index): """Show a specific path combination""" global current_path_index, current_path - + if not path_combinations: status_text.text = "No valid paths available (Entity 1 is trapped!)" return - + current_path_index = index % len(path_combinations) from_idx, to_idx, path = path_combinations[current_path_index] - + # Clear previous path clear_path_colors() - + # Get entities e_from = entities[from_idx] e_to = entities[to_idx] - + # Color the path current_path = path if path: # Color start and end - grid.at(int(e_from.x), int(e_from.y)).color = START_COLOR - grid.at(int(e_to.x), int(e_to.y)).color = END_COLOR - + color_layer.set(int(e_from.x), int(e_from.y), START_COLOR) + color_layer.set(int(e_to.x), int(e_to.y), END_COLOR) + # Color intermediate steps for i, (x, y) in enumerate(path): if i > 0 and i < len(path) - 1: - grid.at(x, y).color = PATH_COLOR - + color_layer.set(x, y, PATH_COLOR) + # Update status status_text.text = f"Path {current_path_index + 1}/{len(path_combinations)}: Entity {from_idx+1} → Entity {to_idx+1} ({len(path)} steps)" - + # Update path display path_display = [] for i, (x, y) in enumerate(path[:5]): # Show first 5 steps @@ -194,27 +197,27 @@ grid.size = (560, 400) grid.position = (120, 100) # Add title -title = mcrfpy.Caption("Dijkstra Pathfinding - Cycle Paths", 200, 20) +title = mcrfpy.Caption(pos=(200, 20), text="Dijkstra Pathfinding - Cycle Paths") title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) # Add status -status_text = mcrfpy.Caption("Press SPACE to cycle paths", 120, 60) +status_text = mcrfpy.Caption(pos=(120, 60), text="Press SPACE to cycle paths") status_text.fill_color = mcrfpy.Color(255, 255, 100) ui.append(status_text) # Add path display -path_text = mcrfpy.Caption("Path: None", 120, 520) +path_text = mcrfpy.Caption(pos=(120, 520), text="Path: None") path_text.fill_color = mcrfpy.Color(200, 200, 200) ui.append(path_text) # Add controls -controls = mcrfpy.Caption("SPACE/N=Next, P=Previous, R=Refresh, Q=Quit", 120, 540) +controls = mcrfpy.Caption(pos=(120, 540), text="SPACE/N=Next, P=Previous, R=Refresh, Q=Quit") controls.fill_color = mcrfpy.Color(150, 150, 150) ui.append(controls) # Add legend -legend = mcrfpy.Caption("Red=Start, Blue=End, Green=Path, Dark=Wall", 120, 560) +legend = mcrfpy.Caption(pos=(120, 560), text="Red=Start, Blue=End, Green=Path, Dark=Wall") legend.fill_color = mcrfpy.Color(150, 150, 150) ui.append(legend) diff --git a/tests/integration/dijkstra_debug.py b/tests/integration/dijkstra_debug.py index fd182b8..6538fae 100644 --- a/tests/integration/dijkstra_debug.py +++ b/tests/integration/dijkstra_debug.py @@ -18,49 +18,52 @@ ENTITY_COLORS = [ # Global state grid = None +color_layer = None entities = [] first_point = None second_point = None def create_simple_map(): """Create a simple test map""" - global grid, entities - + global grid, color_layer, entities + mcrfpy.createScene("dijkstra_debug") - + # Small grid for easy debugging grid = mcrfpy.Grid(grid_x=10, grid_y=10) grid.fill_color = mcrfpy.Color(0, 0, 0) - + + # Add color layer for cell coloring + color_layer = grid.add_layer("color", z_index=-1) + print("Initializing 10x10 grid...") - + # Initialize all as floor for y in range(10): for x in range(10): grid.at(x, y).walkable = True grid.at(x, y).transparent = True - grid.at(x, y).color = FLOOR_COLOR - + color_layer.set(x, y, FLOOR_COLOR) + # Add a simple wall print("Adding walls at:") walls = [(5, 2), (5, 3), (5, 4), (5, 5), (5, 6)] for x, y in walls: print(f" Wall at ({x}, {y})") grid.at(x, y).walkable = False - grid.at(x, y).color = WALL_COLOR - + color_layer.set(x, y, WALL_COLOR) + # Create 3 entities entity_positions = [(2, 5), (8, 5), (5, 8)] entities = [] - + print("\nCreating entities at:") for i, (x, y) in enumerate(entity_positions): print(f" Entity {i+1} at ({x}, {y})") - entity = mcrfpy.Entity(x, y) + entity = mcrfpy.Entity((x, y), grid=grid) entity.sprite_index = 49 + i # '1', '2', '3' - grid.entities.append(entity) entities.append(entity) - + return grid def test_path_highlighting(): @@ -88,12 +91,14 @@ def test_path_highlighting(): print(f" Step {i}: ({x}, {y})") # Get current color for debugging cell = grid.at(x, y) - old_color = (cell.color.r, cell.color.g, cell.color.b) - + old_c = color_layer.at(x, y) + old_color = (old_c.r, old_c.g, old_c.b) + # Set new color - cell.color = PATH_COLOR - new_color = (cell.color.r, cell.color.g, cell.color.b) - + color_layer.set(x, y, PATH_COLOR) + new_c = color_layer.at(x, y) + new_color = (new_c.r, new_c.g, new_c.b) + print(f" Color changed from {old_color} to {new_color}") print(f" Walkable: {cell.walkable}") @@ -111,8 +116,8 @@ def test_path_highlighting(): # Verify colors were set print("\nVerifying cell colors after highlighting:") for x, y in path[:3]: # Check first 3 cells - cell = grid.at(x, y) - color = (cell.color.r, cell.color.g, cell.color.b) + c = color_layer.at(x, y) + color = (c.r, c.g, c.b) expected = (PATH_COLOR.r, PATH_COLOR.g, PATH_COLOR.b) match = color == expected print(f" Cell ({x}, {y}): color={color}, expected={expected}, match={match}") @@ -143,12 +148,12 @@ grid.position = (50, 50) grid.size = (400, 400) # 10*40 # Add title -title = mcrfpy.Caption("Dijkstra Debug - Press SPACE to retest, Q to quit", 50, 10) +title = mcrfpy.Caption(pos=(50, 10), text="Dijkstra Debug - Press SPACE to retest, Q to quit") title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) # Add debug info -info = mcrfpy.Caption("Check console for debug output", 50, 470) +info = mcrfpy.Caption(pos=(50, 470), text="Check console for debug output") info.fill_color = mcrfpy.Color(200, 200, 200) ui.append(info) diff --git a/tests/integration/dijkstra_interactive.py b/tests/integration/dijkstra_interactive.py index fdf2176..c9deeae 100644 --- a/tests/integration/dijkstra_interactive.py +++ b/tests/integration/dijkstra_interactive.py @@ -29,20 +29,24 @@ ENTITY_COLORS = [ # Global state grid = None +color_layer = None entities = [] first_point = None second_point = None def create_map(): """Create the interactive map with the layout specified by the user""" - global grid, entities - + global grid, color_layer, entities + mcrfpy.createScene("dijkstra_interactive") - + # Create grid - 14x10 as specified grid = mcrfpy.Grid(grid_x=14, grid_y=10) grid.fill_color = mcrfpy.Color(0, 0, 0) - + + # Add color layer for cell coloring + color_layer = grid.add_layer("color", z_index=-1) + # Define the map layout from user's specification # . = floor, W = wall, E = entity position map_layout = [ @@ -57,36 +61,35 @@ def create_map(): "..W.WWW.......", # Row 8 "..............", # Row 9 ] - + # Create the map entity_positions = [] for y, row in enumerate(map_layout): for x, char in enumerate(row): cell = grid.at(x, y) - + if char == 'W': # Wall cell.walkable = False cell.transparent = False - cell.color = WALL_COLOR + color_layer.set(x, y, WALL_COLOR) else: # Floor cell.walkable = True cell.transparent = True - cell.color = FLOOR_COLOR - + color_layer.set(x, y, FLOOR_COLOR) + if char == 'E': # Entity position entity_positions.append((x, y)) - + # Create entities at marked positions entities = [] for i, (x, y) in enumerate(entity_positions): - entity = mcrfpy.Entity(x, y) + entity = mcrfpy.Entity((x, y), grid=grid) entity.sprite_index = 49 + i # '1', '2', '3' - grid.entities.append(entity) entities.append(entity) - + return grid def clear_path_highlight(): @@ -96,37 +99,37 @@ def clear_path_highlight(): for x in range(grid.grid_x): cell = grid.at(x, y) if cell.walkable: - cell.color = FLOOR_COLOR + color_layer.set(x, y, FLOOR_COLOR) def highlight_path(): """Highlight the path between selected entities""" if first_point is None or second_point is None: return - + # Clear previous highlighting clear_path_highlight() - + # Get entities entity1 = entities[first_point] entity2 = entities[second_point] - + # Compute Dijkstra from first entity grid.compute_dijkstra(int(entity1.x), int(entity1.y)) - + # Get path to second entity path = grid.get_dijkstra_path(int(entity2.x), int(entity2.y)) - + if path: # Highlight the path for x, y in path: cell = grid.at(x, y) if cell.walkable: - cell.color = PATH_COLOR - + color_layer.set(x, y, PATH_COLOR) + # Also highlight start and end with entity colors - grid.at(int(entity1.x), int(entity1.y)).color = ENTITY_COLORS[first_point] - grid.at(int(entity2.x), int(entity2.y)).color = ENTITY_COLORS[second_point] - + color_layer.set(int(entity1.x), int(entity1.y), ENTITY_COLORS[first_point]) + color_layer.set(int(entity2.x), int(entity2.y), ENTITY_COLORS[second_point]) + # Update info distance = grid.get_dijkstra_distance(int(entity2.x), int(entity2.y)) info_text.text = f"Path: Entity {first_point+1} to Entity {second_point+1} - {len(path)} steps, {distance:.1f} units" @@ -199,34 +202,33 @@ grid.size = (560, 400) # 14*40, 10*40 grid.position = (120, 60) # Add title -title = mcrfpy.Caption("Dijkstra Pathfinding Interactive", 250, 10) +title = mcrfpy.Caption(pos=(250, 10), text="Dijkstra Pathfinding Interactive") title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) # Add status text -status_text = mcrfpy.Caption("Press 1/2/3 for first entity, A/B/C for second", 120, 480) +status_text = mcrfpy.Caption(pos=(120, 480), text="Press 1/2/3 for first entity, A/B/C for second") status_text.fill_color = mcrfpy.Color(255, 255, 255) ui.append(status_text) # Add info text -info_text = mcrfpy.Caption("Space to clear, Q to quit", 120, 500) +info_text = mcrfpy.Caption(pos=(120, 500), text="Space to clear, Q to quit") info_text.fill_color = mcrfpy.Color(200, 200, 200) ui.append(info_text) # Add legend -legend1 = mcrfpy.Caption("Entities: 1=Red 2=Green 3=Blue", 120, 540) +legend1 = mcrfpy.Caption(pos=(120, 540), text="Entities: 1=Red 2=Green 3=Blue") legend1.fill_color = mcrfpy.Color(150, 150, 150) ui.append(legend1) -legend2 = mcrfpy.Caption("Colors: Dark=Wall Light=Floor Cyan=Path", 120, 560) +legend2 = mcrfpy.Caption(pos=(120, 560), text="Colors: Dark=Wall Light=Floor Cyan=Path") legend2.fill_color = mcrfpy.Color(150, 150, 150) ui.append(legend2) # Mark entity positions with colored indicators for i, entity in enumerate(entities): - marker = mcrfpy.Caption(str(i+1), - 120 + int(entity.x) * 40 + 15, - 60 + int(entity.y) * 40 + 10) + marker = mcrfpy.Caption(pos=(120 + int(entity.x) * 40 + 15, 60 + int(entity.y) * 40 + 10), + text=str(i+1)) marker.fill_color = ENTITY_COLORS[i] marker.outline = 1 marker.outline_color = mcrfpy.Color(0, 0, 0) diff --git a/tests/integration/dijkstra_interactive_enhanced.py b/tests/integration/dijkstra_interactive_enhanced.py index 34da805..35c8655 100644 --- a/tests/integration/dijkstra_interactive_enhanced.py +++ b/tests/integration/dijkstra_interactive_enhanced.py @@ -32,6 +32,7 @@ ENTITY_COLORS = [ # Global state grid = None +color_layer = None entities = [] first_point = None second_point = None @@ -43,14 +44,17 @@ original_positions = [] # Store original entity positions def create_map(): """Create the interactive map with the layout specified by the user""" - global grid, entities, original_positions - + global grid, color_layer, entities, original_positions + mcrfpy.createScene("dijkstra_enhanced") - + # Create grid - 14x10 as specified grid = mcrfpy.Grid(grid_x=14, grid_y=10) grid.fill_color = mcrfpy.Color(0, 0, 0) - + + # Add color layer for cell coloring + color_layer = grid.add_layer("color", z_index=-1) + # Define the map layout from user's specification # . = floor, W = wall, E = entity position map_layout = [ @@ -65,87 +69,86 @@ def create_map(): "..W.WWW.......", # Row 8 "..............", # Row 9 ] - + # Create the map entity_positions = [] for y, row in enumerate(map_layout): for x, char in enumerate(row): cell = grid.at(x, y) - + if char == 'W': # Wall cell.walkable = False cell.transparent = False - cell.color = WALL_COLOR + color_layer.set(x, y, WALL_COLOR) else: # Floor cell.walkable = True cell.transparent = True - cell.color = FLOOR_COLOR - + color_layer.set(x, y, FLOOR_COLOR) + if char == 'E': # Entity position entity_positions.append((x, y)) - + # Create entities at marked positions entities = [] original_positions = [] for i, (x, y) in enumerate(entity_positions): - entity = mcrfpy.Entity(x, y) + entity = mcrfpy.Entity((x, y), grid=grid) entity.sprite_index = 49 + i # '1', '2', '3' - grid.entities.append(entity) entities.append(entity) original_positions.append((x, y)) - + return grid def clear_path_highlight(): """Clear any existing path highlighting""" global current_path - + # Reset all floor tiles to original color for y in range(grid.grid_y): for x in range(grid.grid_x): cell = grid.at(x, y) if cell.walkable: - cell.color = FLOOR_COLOR - + color_layer.set(x, y, FLOOR_COLOR) + current_path = [] def highlight_path(): """Highlight the path between selected entities using entity.path_to()""" global current_path - + if first_point is None or second_point is None: return - + # Clear previous highlighting clear_path_highlight() - + # Get entities entity1 = entities[first_point] entity2 = entities[second_point] - + # Use the new path_to method! path = entity1.path_to(int(entity2.x), int(entity2.y)) - + if path: current_path = path - + # Highlight the path for i, (x, y) in enumerate(path): cell = grid.at(x, y) if cell.walkable: # Use gradient for path visualization if i < len(path) - 1: - cell.color = PATH_COLOR + color_layer.set(x, y, PATH_COLOR) else: - cell.color = VISITED_COLOR - + color_layer.set(x, y, VISITED_COLOR) + # Highlight start and end with entity colors - grid.at(int(entity1.x), int(entity1.y)).color = ENTITY_COLORS[first_point] - grid.at(int(entity2.x), int(entity2.y)).color = ENTITY_COLORS[second_point] - + color_layer.set(int(entity1.x), int(entity1.y), ENTITY_COLORS[first_point]) + color_layer.set(int(entity2.x), int(entity2.y), ENTITY_COLORS[second_point]) + # Update info info_text.text = f"Path: Entity {first_point+1} to Entity {second_point+1} - {len(path)} steps" else: @@ -291,39 +294,38 @@ grid.size = (560, 400) # 14*40, 10*40 grid.position = (120, 60) # Add title -title = mcrfpy.Caption("Enhanced Dijkstra Pathfinding", 250, 10) +title = mcrfpy.Caption(pos=(250, 10), text="Enhanced Dijkstra Pathfinding") title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) # Add status text -status_text = mcrfpy.Caption("Press 1/2/3 for first entity, A/B/C for second", 120, 480) +status_text = mcrfpy.Caption(pos=(120, 480), text="Press 1/2/3 for first entity, A/B/C for second") status_text.fill_color = mcrfpy.Color(255, 255, 255) ui.append(status_text) # Add info text -info_text = mcrfpy.Caption("Space to clear, Q to quit", 120, 500) +info_text = mcrfpy.Caption(pos=(120, 500), text="Space to clear, Q to quit") info_text.fill_color = mcrfpy.Color(200, 200, 200) ui.append(info_text) # Add control text -control_text = mcrfpy.Caption("Press M to move, P to pause, R to reset", 120, 520) +control_text = mcrfpy.Caption(pos=(120, 520), text="Press M to move, P to pause, R to reset") control_text.fill_color = mcrfpy.Color(150, 200, 150) ui.append(control_text) # Add legend -legend1 = mcrfpy.Caption("Entities: 1=Red 2=Green 3=Blue", 120, 560) +legend1 = mcrfpy.Caption(pos=(120, 560), text="Entities: 1=Red 2=Green 3=Blue") legend1.fill_color = mcrfpy.Color(150, 150, 150) ui.append(legend1) -legend2 = mcrfpy.Caption("Colors: Dark=Wall Light=Floor Cyan=Path", 120, 580) +legend2 = mcrfpy.Caption(pos=(120, 580), text="Colors: Dark=Wall Light=Floor Cyan=Path") legend2.fill_color = mcrfpy.Color(150, 150, 150) ui.append(legend2) # Mark entity positions with colored indicators for i, entity in enumerate(entities): - marker = mcrfpy.Caption(str(i+1), - 120 + int(entity.x) * 40 + 15, - 60 + int(entity.y) * 40 + 10) + marker = mcrfpy.Caption(pos=(120 + int(entity.x) * 40 + 15, 60 + int(entity.y) * 40 + 10), + text=str(i+1)) marker.fill_color = ENTITY_COLORS[i] marker.outline = 1 marker.outline_color = mcrfpy.Color(0, 0, 0) diff --git a/tests/integration/dijkstra_test.py b/tests/integration/dijkstra_test.py index 9f99eeb..928a56e 100644 --- a/tests/integration/dijkstra_test.py +++ b/tests/integration/dijkstra_test.py @@ -128,12 +128,12 @@ grid.position = (50, 50) grid.size = (500, 300) # Add title -title = mcrfpy.Caption("Dijkstra Pathfinding Test", 200, 10) +title = mcrfpy.Caption(pos=(200, 10), text="Dijkstra Pathfinding Test") title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) # Add legend -legend = mcrfpy.Caption("Red=Entity1 Green=Entity2 Blue=Entity3 Cyan=Path 1→3", 50, 360) +legend = mcrfpy.Caption(pos=(50, 360), text="Red=Entity1 Green=Entity2 Blue=Entity3 Cyan=Path 1→3") legend.fill_color = mcrfpy.Color(180, 180, 180) ui.append(legend) diff --git a/tests/integration/interactive_visibility.py b/tests/integration/interactive_visibility.py index 3d7aef8..dcb386d 100644 --- a/tests/integration/interactive_visibility.py +++ b/tests/integration/interactive_visibility.py @@ -19,33 +19,36 @@ mcrfpy.createScene("visibility_demo") grid = mcrfpy.Grid(grid_x=30, grid_y=20) grid.fill_color = mcrfpy.Color(20, 20, 30) # Dark background +# Add color layer for cell coloring +color_layer = grid.add_layer("color", z_index=-1) + # Initialize grid - all walkable and transparent for y in range(20): for x in range(30): cell = grid.at(x, y) cell.walkable = True cell.transparent = True - cell.color = mcrfpy.Color(100, 100, 120) # Floor color + color_layer.set(x, y, mcrfpy.Color(100, 100, 120)) # Floor color # Create walls walls = [ # Central cross [(15, y) for y in range(8, 12)], [(x, 10) for x in range(13, 18)], - + # Rooms # Top-left room [(x, 5) for x in range(2, 8)] + [(8, y) for y in range(2, 6)], [(2, y) for y in range(2, 6)] + [(x, 2) for x in range(2, 8)], - - # Top-right room + + # Top-right room [(x, 5) for x in range(22, 28)] + [(22, y) for y in range(2, 6)], [(28, y) for y in range(2, 6)] + [(x, 2) for x in range(22, 28)], - + # Bottom-left room [(x, 15) for x in range(2, 8)] + [(8, y) for y in range(15, 18)], [(2, y) for y in range(15, 18)] + [(x, 18) for x in range(2, 8)], - + # Bottom-right room [(x, 15) for x in range(22, 28)] + [(22, y) for y in range(15, 18)], [(28, y) for y in range(15, 18)] + [(x, 18) for x in range(22, 28)], @@ -57,12 +60,12 @@ for wall_group in walls: cell = grid.at(x, y) cell.walkable = False cell.transparent = False - cell.color = mcrfpy.Color(40, 20, 20) # Wall color + color_layer.set(x, y, mcrfpy.Color(40, 20, 20)) # Wall color # Create entities -player = mcrfpy.Entity(5, 10, grid=grid) +player = mcrfpy.Entity((5, 10), grid=grid) player.sprite_index = 64 # @ -enemy = mcrfpy.Entity(25, 10, grid=grid) +enemy = mcrfpy.Entity((25, 10), grid=grid) enemy.sprite_index = 69 # E # Update initial visibility @@ -80,24 +83,24 @@ grid.position = (50, 100) grid.size = (900, 600) # 30*30, 20*30 # Title -title = mcrfpy.Caption("Interactive Visibility Demo", 350, 20) +title = mcrfpy.Caption(pos=(350, 20), text="Interactive Visibility Demo") title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) # Info displays -perspective_label = mcrfpy.Caption("Perspective: Omniscient", 50, 50) +perspective_label = mcrfpy.Caption(pos=(50, 50), text="Perspective: Omniscient") perspective_label.fill_color = mcrfpy.Color(200, 200, 200) ui.append(perspective_label) -controls = mcrfpy.Caption("WASD: Move player | Arrows: Move enemy | Tab: Cycle perspective | Space: Update visibility | R: Reset", 50, 730) +controls = mcrfpy.Caption(pos=(50, 730), text="WASD: Move player | Arrows: Move enemy | Tab: Cycle perspective | Space: Update visibility | R: Reset") controls.fill_color = mcrfpy.Color(150, 150, 150) ui.append(controls) -player_info = mcrfpy.Caption("Player: (5, 10)", 700, 50) +player_info = mcrfpy.Caption(pos=(700, 50), text="Player: (5, 10)") player_info.fill_color = mcrfpy.Color(100, 255, 100) ui.append(player_info) -enemy_info = mcrfpy.Caption("Enemy: (25, 10)", 700, 70) +enemy_info = mcrfpy.Caption(pos=(700, 70), text="Enemy: (25, 10)") enemy_info.fill_color = mcrfpy.Color(255, 100, 100) ui.append(enemy_info) diff --git a/tests/integration/simple_interactive_visibility.py b/tests/integration/simple_interactive_visibility.py index fd95d5a..6243ecb 100644 --- a/tests/integration/simple_interactive_visibility.py +++ b/tests/integration/simple_interactive_visibility.py @@ -11,6 +11,9 @@ mcrfpy.createScene("vis_test") print("Creating grid...") grid = mcrfpy.Grid(grid_x=10, grid_y=10) +# Add color layer for cell coloring +color_layer = grid.add_layer("color", z_index=-1) + # Initialize grid print("Initializing grid...") for y in range(10): @@ -18,11 +21,11 @@ for y in range(10): cell = grid.at(x, y) cell.walkable = True cell.transparent = True - cell.color = mcrfpy.Color(100, 100, 120) + color_layer.set(x, y, mcrfpy.Color(100, 100, 120)) # Create entity print("Creating entity...") -entity = mcrfpy.Entity(5, 5, grid=grid) +entity = mcrfpy.Entity((5, 5), grid=grid) entity.sprite_index = 64 print("Updating visibility...") diff --git a/tests/integration/simple_visibility_test.py b/tests/integration/simple_visibility_test.py index 5c20758..1e00b73 100644 --- a/tests/integration/simple_visibility_test.py +++ b/tests/integration/simple_visibility_test.py @@ -13,8 +13,8 @@ print("Scene created") grid = mcrfpy.Grid(grid_x=5, grid_y=5) print("Grid created") -# Create entity without appending -entity = mcrfpy.Entity(2, 2, grid=grid) +# Create entity with grid association +entity = mcrfpy.Entity((2, 2), grid=grid) print(f"Entity created at ({entity.x}, {entity.y})") # Check if gridstate is initialized diff --git a/tests/regression/issue_123_chunk_system_test.py b/tests/regression/issue_123_chunk_system_test.py index 0437494..f8be052 100644 --- a/tests/regression/issue_123_chunk_system_test.py +++ b/tests/regression/issue_123_chunk_system_test.py @@ -8,6 +8,10 @@ while small grids use the original flat storage. Verifies that: 2. Large grids work correctly with chunks 3. Cell access (read/write) works for both modes 4. Rendering displays correctly for both modes + +NOTE: This test uses ColorLayer for color operations since cell.color +is no longer supported. The chunk system affects internal storage, which +ColorLayer also uses. """ import mcrfpy @@ -19,22 +23,21 @@ def test_small_grid(): # Small grid should use flat storage grid = mcrfpy.Grid(grid_size=(50, 50), pos=(10, 10), size=(400, 400)) + color_layer = grid.add_layer("color", z_index=-1) # Set some cells for y in range(50): for x in range(50): cell = grid.at(x, y) - cell.color = mcrfpy.Color((x * 5) % 256, (y * 5) % 256, 128, 255) + color_layer.set(x, y, mcrfpy.Color((x * 5) % 256, (y * 5) % 256, 128, 255)) cell.tilesprite = -1 # Verify cells - cell = grid.at(25, 25) expected_r = (25 * 5) % 256 expected_g = (25 * 5) % 256 - color = cell.color - r, g = color[0], color[1] - if r != expected_r or g != expected_g: - print(f"FAIL: Small grid cell color mismatch. Expected ({expected_r}, {expected_g}), got ({r}, {g})") + color = color_layer.at(25, 25) + if color.r != expected_r or color.g != expected_g: + print(f"FAIL: Small grid cell color mismatch. Expected ({expected_r}, {expected_g}), got ({color.r}, {color.g})") return False print(" Small grid: PASS") @@ -46,6 +49,7 @@ def test_large_grid(): # Large grid should use chunk storage (100 > 64) grid = mcrfpy.Grid(grid_size=(100, 100), pos=(10, 10), size=(400, 400)) + color_layer = grid.add_layer("color", z_index=-1) # Set cells across multiple chunks # Chunks are 64x64, so a 100x100 grid has 2x2 = 4 chunks @@ -61,15 +65,14 @@ def test_large_grid(): for x, y in test_points: cell = grid.at(x, y) - cell.color = mcrfpy.Color(x, y, 100, 255) + color_layer.set(x, y, mcrfpy.Color(x, y, 100, 255)) cell.tilesprite = -1 # Verify cells for x, y in test_points: - cell = grid.at(x, y) - color = cell.color - if color[0] != x or color[1] != y: - print(f"FAIL: Large grid cell ({x},{y}) color mismatch. Expected ({x}, {y}), got ({color[0]}, {color[1]})") + color = color_layer.at(x, y) + if color.r != x or color.g != y: + print(f"FAIL: Large grid cell ({x},{y}) color mismatch. Expected ({x}, {y}), got ({color.r}, {color.g})") return False print(" Large grid cell access: PASS") @@ -81,6 +84,7 @@ def test_very_large_grid(): # 500x500 = 250,000 cells, should use ~64 chunks (8x8) grid = mcrfpy.Grid(grid_size=(500, 500), pos=(10, 10), size=(400, 400)) + color_layer = grid.add_layer("color", z_index=-1) # Set some cells at various positions test_points = [ @@ -94,14 +98,12 @@ def test_very_large_grid(): ] for x, y in test_points: - cell = grid.at(x, y) - cell.color = mcrfpy.Color(x % 256, y % 256, 200, 255) + color_layer.set(x, y, mcrfpy.Color(x % 256, y % 256, 200, 255)) # Verify for x, y in test_points: - cell = grid.at(x, y) - color = cell.color - if color[0] != (x % 256) or color[1] != (y % 256): + color = color_layer.at(x, y) + if color.r != (x % 256) or color.g != (y % 256): print(f"FAIL: Very large grid cell ({x},{y}) color mismatch") return False @@ -114,20 +116,20 @@ def test_boundary_case(): # 64x64 should use flat storage (not exceeding threshold) grid_64 = mcrfpy.Grid(grid_size=(64, 64), pos=(10, 10), size=(400, 400)) - cell = grid_64.at(63, 63) - cell.color = mcrfpy.Color(255, 0, 0, 255) - color = grid_64.at(63, 63).color - if color[0] != 255: - print(f"FAIL: 64x64 grid boundary cell not set correctly, got r={color[0]}") + color_layer_64 = grid_64.add_layer("color", z_index=-1) + color_layer_64.set(63, 63, mcrfpy.Color(255, 0, 0, 255)) + color = color_layer_64.at(63, 63) + if color.r != 255: + print(f"FAIL: 64x64 grid boundary cell not set correctly, got r={color.r}") return False # 65x65 should use chunk storage (exceeding threshold) grid_65 = mcrfpy.Grid(grid_size=(65, 65), pos=(10, 10), size=(400, 400)) - cell = grid_65.at(64, 64) - cell.color = mcrfpy.Color(0, 255, 0, 255) - color = grid_65.at(64, 64).color - if color[1] != 255: - print(f"FAIL: 65x65 grid cell not set correctly, got g={color[1]}") + color_layer_65 = grid_65.add_layer("color", z_index=-1) + color_layer_65.set(64, 64, mcrfpy.Color(0, 255, 0, 255)) + color = color_layer_65.at(64, 64) + if color.g != 255: + print(f"FAIL: 65x65 grid cell not set correctly, got g={color.g}") return False print(" Boundary cases: PASS") @@ -139,19 +141,18 @@ def test_edge_cases(): # Create 100x100 grid grid = mcrfpy.Grid(grid_size=(100, 100), pos=(10, 10), size=(400, 400)) + color_layer = grid.add_layer("color", z_index=-1) # Test all corners corners = [(0, 0), (99, 0), (0, 99), (99, 99)] for i, (x, y) in enumerate(corners): - cell = grid.at(x, y) - cell.color = mcrfpy.Color(i * 60, i * 60, i * 60, 255) + color_layer.set(x, y, mcrfpy.Color(i * 60, i * 60, i * 60, 255)) for i, (x, y) in enumerate(corners): - cell = grid.at(x, y) expected = i * 60 - color = cell.color - if color[0] != expected: - print(f"FAIL: Corner ({x},{y}) color mismatch, expected {expected}, got {color[0]}") + color = color_layer.at(x, y) + if color.r != expected: + print(f"FAIL: Corner ({x},{y}) color mismatch, expected {expected}, got {color.r}") return False print(" Edge cases: PASS") diff --git a/tests/regression/issue_76_test.py b/tests/regression/issue_76_test.py index 96dd723..ecd985d 100644 --- a/tests/regression/issue_76_test.py +++ b/tests/regression/issue_76_test.py @@ -10,10 +10,10 @@ import sys # Create a derived Entity class class CustomEntity(mcrfpy.Entity): - def __init__(self, x, y): - super().__init__(x, y) + def __init__(self, pos): + super().__init__(pos) self.custom_attribute = "I am custom!" - + def custom_method(self): return "Custom method called" @@ -21,11 +21,11 @@ def run_test(runtime): """Test that derived entity classes maintain their type in collections""" try: # Create a grid - grid = mcrfpy.Grid(10, 10) - + grid = mcrfpy.Grid(grid_size=(10, 10)) + # Create instances of base and derived entities - base_entity = mcrfpy.Entity(1, 1) - custom_entity = CustomEntity(2, 2) + base_entity = mcrfpy.Entity((1, 1)) + custom_entity = CustomEntity((2, 2)) # Add them to the grid's entity collection grid.entities.append(base_entity) diff --git a/tests/unit/WORKING_automation_test_example.py b/tests/unit/WORKING_automation_test_example.py index bb22673..91daaef 100644 --- a/tests/unit/WORKING_automation_test_example.py +++ b/tests/unit/WORKING_automation_test_example.py @@ -51,17 +51,17 @@ mcrfpy.setScene("timer_test_scene") ui = mcrfpy.sceneUI("timer_test_scene") # Add a bright red frame that should be visible -frame = mcrfpy.Frame(100, 100, 400, 300, +frame = mcrfpy.Frame(pos=(100, 100), size=(400, 300), fill_color=mcrfpy.Color(255, 0, 0), # Bright red outline_color=mcrfpy.Color(255, 255, 255), # White outline outline=5.0) ui.append(frame) # Add text -caption = mcrfpy.Caption(mcrfpy.Vector(150, 150), +caption = mcrfpy.Caption(pos=(150, 150), text="TIMER TEST - SHOULD BE VISIBLE", fill_color=mcrfpy.Color(255, 255, 255)) -caption.size = 24 +caption.font_size = 24 frame.children.append(caption) # Add click handler to demonstrate interaction diff --git a/tests/unit/check_entity_attrs.py b/tests/unit/check_entity_attrs.py index d0a44b8..564ea62 100644 --- a/tests/unit/check_entity_attrs.py +++ b/tests/unit/check_entity_attrs.py @@ -1,4 +1,4 @@ import mcrfpy -e = mcrfpy.Entity(0, 0) +e = mcrfpy.Entity((0, 0)) print("Entity attributes:", dir(e)) print("\nEntity repr:", repr(e)) \ No newline at end of file diff --git a/tests/unit/debug_render_test.py b/tests/unit/debug_render_test.py index d7c7f6c..1442f09 100644 --- a/tests/unit/debug_render_test.py +++ b/tests/unit/debug_render_test.py @@ -22,7 +22,7 @@ print(f"UI collection type: {type(ui)}") print(f"Initial UI elements: {len(ui)}") # Add a simple frame -frame = mcrfpy.Frame(0, 0, 100, 100, +frame = mcrfpy.Frame(pos=(0, 0), size=(100, 100), fill_color=mcrfpy.Color(255, 255, 255)) ui.append(frame) print(f"After adding frame: {len(ui)} elements") diff --git a/tests/unit/generate_grid_screenshot.py b/tests/unit/generate_grid_screenshot.py index 706b704..8c4500d 100644 --- a/tests/unit/generate_grid_screenshot.py +++ b/tests/unit/generate_grid_screenshot.py @@ -22,14 +22,13 @@ mcrfpy.createScene("grid") texture = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16) # Title -title = mcrfpy.Caption(400, 30, "Grid Example - Dungeon View") +title = mcrfpy.Caption(pos=(400, 30), text="Grid Example - Dungeon View") title.font = mcrfpy.default_font title.font_size = 24 -title.font_color = (255, 255, 255) +title.fill_color = mcrfpy.Color(255, 255, 255) # Create main grid (20x15 tiles, each 32x32 pixels) -grid = mcrfpy.Grid(100, 100, 20, 15, texture, 32, 32) -grid.texture = texture +grid = mcrfpy.Grid(pos=(100, 100), grid_size=(20, 15), texture=texture, size=(640, 480)) # Define tile types from Crypt of Sokoban FLOOR = 58 # Stone floor @@ -63,36 +62,21 @@ grid.set_tile(12, 8, BOULDER) # Create some entities on the grid # Player entity -player = mcrfpy.Entity(5, 7) -player.texture = texture -player.sprite_index = 84 # Player sprite +player = mcrfpy.Entity((5, 7), texture=texture, sprite_index=84, grid=grid) # Player sprite # Enemy entities -rat1 = mcrfpy.Entity(12, 5) -rat1.texture = texture -rat1.sprite_index = 123 # Rat +rat1 = mcrfpy.Entity((12, 5), texture=texture, sprite_index=123, grid=grid) # Rat -rat2 = mcrfpy.Entity(14, 9) -rat2.texture = texture -rat2.sprite_index = 123 # Rat +rat2 = mcrfpy.Entity((14, 9), texture=texture, sprite_index=123, grid=grid) # Rat -cyclops = mcrfpy.Entity(10, 10) -cyclops.texture = texture -cyclops.sprite_index = 109 # Cyclops - -# Add entities to grid -grid.entities.append(player) -grid.entities.append(rat1) -grid.entities.append(rat2) -grid.entities.append(cyclops) +cyclops = mcrfpy.Entity((10, 10), texture=texture, sprite_index=109, grid=grid) # Cyclops # Create a smaller grid showing tile palette -palette_label = mcrfpy.Caption(100, 600, "Tile Types:") +palette_label = mcrfpy.Caption(pos=(100, 600), text="Tile Types:") palette_label.font = mcrfpy.default_font -palette_label.font_color = (255, 255, 255) +palette_label.fill_color = mcrfpy.Color(255, 255, 255) -palette = mcrfpy.Grid(250, 580, 7, 1, texture, 32, 32) -palette.texture = texture +palette = mcrfpy.Grid(pos=(250, 580), grid_size=(7, 1), texture=texture, size=(224, 32)) palette.set_tile(0, 0, FLOOR) palette.set_tile(1, 0, WALL) palette.set_tile(2, 0, DOOR) @@ -104,17 +88,17 @@ palette.set_tile(6, 0, BOULDER) # Labels for palette labels = ["Floor", "Wall", "Door", "Chest", "Button", "Exit", "Boulder"] for i, label in enumerate(labels): - l = mcrfpy.Caption(250 + i * 32, 615, label) + l = mcrfpy.Caption(pos=(250 + i * 32, 615), text=label) l.font = mcrfpy.default_font l.font_size = 10 - l.font_color = (255, 255, 255) + l.fill_color = mcrfpy.Color(255, 255, 255) mcrfpy.sceneUI("grid").append(l) # Add info caption -info = mcrfpy.Caption(100, 680, "Grid supports tiles and entities. Entities can move independently of the tile grid.") +info = mcrfpy.Caption(pos=(100, 680), text="Grid supports tiles and entities. Entities can move independently of the tile grid.") info.font = mcrfpy.default_font info.font_size = 14 -info.font_color = (200, 200, 200) +info.fill_color = mcrfpy.Color(200, 200, 200) # Add all elements to scene ui = mcrfpy.sceneUI("grid") diff --git a/tests/unit/generate_sprite_screenshot.py b/tests/unit/generate_sprite_screenshot.py index 3a314bb..ff6114c 100644 --- a/tests/unit/generate_sprite_screenshot.py +++ b/tests/unit/generate_sprite_screenshot.py @@ -22,20 +22,20 @@ mcrfpy.createScene("sprites") texture = mcrfpy.Texture("assets/kenney_TD_MR_IP.png", 16, 16) # Title -title = mcrfpy.Caption(400, 30, "Sprite Examples") +title = mcrfpy.Caption(pos=(400, 30), text="Sprite Examples") title.font = mcrfpy.default_font title.font_size = 24 -title.font_color = (255, 255, 255) +title.fill_color = mcrfpy.Color(255, 255, 255) # Create a frame background -frame = mcrfpy.Frame(50, 80, 700, 500) -frame.bgcolor = (64, 64, 128) +frame = mcrfpy.Frame(pos=(50, 80), size=(700, 500)) +frame.fill_color = mcrfpy.Color(64, 64, 128) frame.outline = 2 # Player sprite -player_label = mcrfpy.Caption(100, 120, "Player") +player_label = mcrfpy.Caption(pos=(100, 120), text="Player") player_label.font = mcrfpy.default_font -player_label.font_color = (255, 255, 255) +player_label.fill_color = mcrfpy.Color(255, 255, 255) player = mcrfpy.Sprite(120, 150) player.texture = texture @@ -43,9 +43,9 @@ player.sprite_index = 84 # Player sprite player.scale = (3.0, 3.0) # Enemy sprites -enemy_label = mcrfpy.Caption(250, 120, "Enemies") +enemy_label = mcrfpy.Caption(pos=(250, 120), text="Enemies") enemy_label.font = mcrfpy.default_font -enemy_label.font_color = (255, 255, 255) +enemy_label.fill_color = mcrfpy.Color(255, 255, 255) rat = mcrfpy.Sprite(250, 150) rat.texture = texture @@ -63,9 +63,9 @@ cyclops.sprite_index = 109 # Cyclops cyclops.scale = (3.0, 3.0) # Items row -items_label = mcrfpy.Caption(100, 250, "Items") +items_label = mcrfpy.Caption(pos=(100, 250), text="Items") items_label.font = mcrfpy.default_font -items_label.font_color = (255, 255, 255) +items_label.fill_color = mcrfpy.Color(255, 255, 255) # Boulder boulder = mcrfpy.Sprite(100, 280) @@ -92,9 +92,9 @@ button.sprite_index = 250 # Button button.scale = (3.0, 3.0) # UI elements row -ui_label = mcrfpy.Caption(100, 380, "UI Elements") +ui_label = mcrfpy.Caption(pos=(100, 380), text="UI Elements") ui_label.font = mcrfpy.default_font -ui_label.font_color = (255, 255, 255) +ui_label.fill_color = mcrfpy.Color(255, 255, 255) # Hearts heart_full = mcrfpy.Sprite(100, 410) @@ -119,9 +119,9 @@ armor.sprite_index = 211 # Armor armor.scale = (3.0, 3.0) # Scale demonstration -scale_label = mcrfpy.Caption(500, 120, "Scale Demo") +scale_label = mcrfpy.Caption(pos=(500, 120), text="Scale Demo") scale_label.font = mcrfpy.default_font -scale_label.font_color = (255, 255, 255) +scale_label.fill_color = mcrfpy.Color(255, 255, 255) # Same sprite at different scales for i, scale in enumerate([1.0, 2.0, 3.0, 4.0]): diff --git a/tests/unit/screenshot_transparency_fix_test.py b/tests/unit/screenshot_transparency_fix_test.py index 7da8878..5d5e333 100644 --- a/tests/unit/screenshot_transparency_fix_test.py +++ b/tests/unit/screenshot_transparency_fix_test.py @@ -17,42 +17,42 @@ def test_transparency_workaround(): # WORKAROUND: Create a full-window opaque frame as the first element # This acts as an opaque background since the scene clears with transparent print("Creating full-window opaque background...") - background = mcrfpy.Frame(0, 0, 1024, 768, + background = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(50, 50, 50), # Dark gray outline_color=None, outline=0.0) ui.append(background) print("✓ Added opaque background frame") - + # Now add normal content on top print("\nAdding test content...") - + # Red frame - frame1 = mcrfpy.Frame(100, 100, 200, 150, + frame1 = mcrfpy.Frame(pos=(100, 100), size=(200, 150), fill_color=mcrfpy.Color(255, 0, 0), outline_color=mcrfpy.Color(255, 255, 255), outline=3.0) ui.append(frame1) - + # Green frame - frame2 = mcrfpy.Frame(350, 100, 200, 150, + frame2 = mcrfpy.Frame(pos=(350, 100), size=(200, 150), fill_color=mcrfpy.Color(0, 255, 0), outline_color=mcrfpy.Color(0, 0, 0), outline=3.0) ui.append(frame2) - + # Blue frame - frame3 = mcrfpy.Frame(100, 300, 200, 150, + frame3 = mcrfpy.Frame(pos=(100, 300), size=(200, 150), fill_color=mcrfpy.Color(0, 0, 255), outline_color=mcrfpy.Color(255, 255, 0), outline=3.0) ui.append(frame3) - + # Add text - caption = mcrfpy.Caption(mcrfpy.Vector(250, 50), + caption = mcrfpy.Caption(pos=(250, 50), text="OPAQUE BACKGROUND TEST", fill_color=mcrfpy.Color(255, 255, 255)) - caption.size = 32 + caption.font_size = 32 ui.append(caption) # Take screenshot diff --git a/tests/unit/simple_screenshot_test.py b/tests/unit/simple_screenshot_test.py index 42815a4..3117a81 100644 --- a/tests/unit/simple_screenshot_test.py +++ b/tests/unit/simple_screenshot_test.py @@ -31,9 +31,9 @@ def take_screenshot(runtime): mcrfpy.createScene("test") # Add a visible element -caption = mcrfpy.Caption(100, 100, "Screenshot Test") +caption = mcrfpy.Caption(pos=(100, 100), text="Screenshot Test") caption.font = mcrfpy.default_font -caption.font_color = (255, 255, 255) +caption.fill_color = mcrfpy.Color(255, 255, 255) caption.font_size = 24 mcrfpy.sceneUI("test").append(caption) diff --git a/tests/unit/simple_timer_screenshot_test.py b/tests/unit/simple_timer_screenshot_test.py index 5a5c9ac..d4aa001 100644 --- a/tests/unit/simple_timer_screenshot_test.py +++ b/tests/unit/simple_timer_screenshot_test.py @@ -30,7 +30,7 @@ mcrfpy.setScene("test") ui = mcrfpy.sceneUI("test") # Add visible content - a white frame on default background -frame = mcrfpy.Frame(100, 100, 200, 200, +frame = mcrfpy.Frame(pos=(100, 100), size=(200, 200), fill_color=mcrfpy.Color(255, 255, 255)) ui.append(frame) diff --git a/tests/unit/test_animation_chaining.py b/tests/unit/test_animation_chaining.py index b8402fd..7b3700a 100644 --- a/tests/unit/test_animation_chaining.py +++ b/tests/unit/test_animation_chaining.py @@ -73,6 +73,9 @@ mcrfpy.createScene("chain_test") grid = mcrfpy.Grid(grid_x=20, grid_y=15) grid.fill_color = mcrfpy.Color(20, 20, 30) +# Add a color layer for cell coloring +color_layer = grid.add_layer("color", z_index=-1) + # Simple map for y in range(15): for x in range(20): @@ -80,17 +83,17 @@ for y in range(15): if x == 0 or x == 19 or y == 0 or y == 14: cell.walkable = False cell.transparent = False - cell.color = mcrfpy.Color(60, 40, 40) + color_layer.set(x, y, mcrfpy.Color(60, 40, 40)) else: cell.walkable = True cell.transparent = True - cell.color = mcrfpy.Color(100, 100, 120) + color_layer.set(x, y, mcrfpy.Color(100, 100, 120)) # Create entities -player = mcrfpy.Entity(2, 2, grid=grid) +player = mcrfpy.Entity((2, 2), grid=grid) player.sprite_index = 64 # @ -enemy = mcrfpy.Entity(17, 12, grid=grid) +enemy = mcrfpy.Entity((17, 12), grid=grid) enemy.sprite_index = 69 # E # UI setup @@ -99,15 +102,15 @@ ui.append(grid) grid.position = (100, 100) grid.size = (600, 450) -title = mcrfpy.Caption("Animation Chaining Test", 300, 20) +title = mcrfpy.Caption(pos=(300, 20), text="Animation Chaining Test") title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) -status = mcrfpy.Caption("Press 1: Animate Player | 2: Animate Enemy | 3: Both | Q: Quit", 100, 50) +status = mcrfpy.Caption(pos=(100, 50), text="Press 1: Animate Player | 2: Animate Enemy | 3: Both | Q: Quit") status.fill_color = mcrfpy.Color(200, 200, 200) ui.append(status) -info = mcrfpy.Caption("Status: Ready", 100, 70) +info = mcrfpy.Caption(pos=(100, 70), text="Status: Ready") info.fill_color = mcrfpy.Color(100, 255, 100) ui.append(info) diff --git a/tests/unit/test_animation_debug.py b/tests/unit/test_animation_debug.py index 0b7ab7c..16c21a7 100644 --- a/tests/unit/test_animation_debug.py +++ b/tests/unit/test_animation_debug.py @@ -63,14 +63,15 @@ mcrfpy.createScene("anim_debug") # Simple grid grid = mcrfpy.Grid(grid_x=15, grid_y=10) +color_layer = grid.add_layer("color", z_index=-1) for y in range(10): for x in range(15): cell = grid.at(x, y) cell.walkable = True - cell.color = mcrfpy.Color(100, 100, 120) + color_layer.set(x, y, mcrfpy.Color(100, 100, 120)) # Test entity -entity = mcrfpy.Entity(5, 5, grid=grid) +entity = mcrfpy.Entity((5, 5), grid=grid) entity.sprite_index = 64 # UI @@ -79,19 +80,19 @@ ui.append(grid) grid.position = (100, 150) grid.size = (450, 300) -title = mcrfpy.Caption("Animation Debug Tool", 250, 20) +title = mcrfpy.Caption(pos=(250, 20), text="Animation Debug Tool") title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) -status = mcrfpy.Caption("Press keys to test animations", 100, 50) +status = mcrfpy.Caption(pos=(100, 50), text="Press keys to test animations") status.fill_color = mcrfpy.Color(200, 200, 200) ui.append(status) -pos_display = mcrfpy.Caption("", 100, 70) +pos_display = mcrfpy.Caption(pos=(100, 70), text="") pos_display.fill_color = mcrfpy.Color(255, 255, 100) ui.append(pos_display) -active_display = mcrfpy.Caption("Active animations: 0", 100, 90) +active_display = mcrfpy.Caption(pos=(100, 90), text="Active animations: 0") active_display.fill_color = mcrfpy.Color(100, 255, 255) ui.append(active_display) diff --git a/tests/unit/test_animation_immediate.py b/tests/unit/test_animation_immediate.py index d24f713..e78c63c 100644 --- a/tests/unit/test_animation_immediate.py +++ b/tests/unit/test_animation_immediate.py @@ -13,7 +13,7 @@ print("2. Getting UI...") ui = mcrfpy.sceneUI("test") print("3. Creating frame...") -frame = mcrfpy.Frame(100, 100, 200, 200) +frame = mcrfpy.Frame(pos=(100, 100), size=(200, 200)) ui.append(frame) print("4. Creating Animation object...") diff --git a/tests/unit/test_animation_raii.py b/tests/unit/test_animation_raii.py index 86ce225..53de59b 100644 --- a/tests/unit/test_animation_raii.py +++ b/tests/unit/test_animation_raii.py @@ -30,7 +30,7 @@ def test_1_basic_animation(): """Test that basic animations still work""" try: ui = mcrfpy.sceneUI("test") - frame = mcrfpy.Frame(100, 100, 100, 100) + frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) ui.append(frame) anim = mcrfpy.Animation("x", 200.0, 1000, "linear") @@ -49,7 +49,7 @@ def test_2_remove_animated_object(): """Test removing object with active animation""" try: ui = mcrfpy.sceneUI("test") - frame = mcrfpy.Frame(100, 100, 100, 100) + frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) ui.append(frame) # Start animation @@ -73,7 +73,7 @@ def test_3_complete_animation(): """Test completing animation immediately""" try: ui = mcrfpy.sceneUI("test") - frame = mcrfpy.Frame(100, 100, 100, 100) + frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) ui.append(frame) # Start animation @@ -98,7 +98,7 @@ def test_4_multiple_animations_timer(): nonlocal success try: ui = mcrfpy.sceneUI("test") - frame = mcrfpy.Frame(200, 200, 100, 100) + frame = mcrfpy.Frame(pos=(200, 200), size=(100, 100)) ui.append(frame) # Create multiple animations rapidly (this used to crash) @@ -129,7 +129,7 @@ def test_5_scene_cleanup(): # Add animated objects to first scene ui = mcrfpy.sceneUI("test") for i in range(5): - frame = mcrfpy.Frame(50 * i, 100, 40, 40) + frame = mcrfpy.Frame(pos=(50 * i, 100), size=(40, 40)) ui.append(frame) anim = mcrfpy.Animation("y", 300.0, 2000, "easeOutBounce") anim.start(frame) @@ -148,9 +148,9 @@ def test_6_animation_after_clear(): """Test animations after clearing UI""" try: ui = mcrfpy.sceneUI("test") - + # Create and animate - frame = mcrfpy.Frame(100, 100, 100, 100) + frame = mcrfpy.Frame(pos=(100, 100), size=(100, 100)) ui.append(frame) anim = mcrfpy.Animation("w", 200.0, 1500, "easeInOutCubic") anim.start(frame) @@ -207,7 +207,7 @@ mcrfpy.setScene("test") # Add a background ui = mcrfpy.sceneUI("test") -bg = mcrfpy.Frame(0, 0, 1024, 768) +bg = mcrfpy.Frame(pos=(0, 0), size=(1024, 768)) bg.fill_color = mcrfpy.Color(20, 20, 30) ui.append(bg) diff --git a/tests/unit/test_animation_removal.py b/tests/unit/test_animation_removal.py index a626d91..3aac09d 100644 --- a/tests/unit/test_animation_removal.py +++ b/tests/unit/test_animation_removal.py @@ -42,14 +42,14 @@ mcrfpy.setScene("test") ui = mcrfpy.sceneUI("test") # Add title and subtitle (to preserve during clearing) -title = mcrfpy.Caption("Test Title", 400, 20) -subtitle = mcrfpy.Caption("Test Subtitle", 400, 50) +title = mcrfpy.Caption(pos=(400, 20), text="Test Title") +subtitle = mcrfpy.Caption(pos=(400, 50), text="Test Subtitle") ui.extend([title, subtitle]) # Create initial animated objects print("Creating initial animated objects...") for i in range(10): - f = mcrfpy.Frame(50 + i*30, 100, 25, 25) + f = mcrfpy.Frame(pos=(50 + i*30, 100), size=(25, 25)) f.fill_color = mcrfpy.Color(255, 100, 100) ui.append(f) diff --git a/tests/unit/test_dijkstra_pathfinding.py b/tests/unit/test_dijkstra_pathfinding.py index 65ee1e6..a28b103 100644 --- a/tests/unit/test_dijkstra_pathfinding.py +++ b/tests/unit/test_dijkstra_pathfinding.py @@ -17,10 +17,15 @@ import sys def create_test_grid(): """Create a test grid with obstacles""" mcrfpy.createScene("dijkstra_test") - + # Create grid grid = mcrfpy.Grid(grid_x=20, grid_y=20) - + + # Add color layer for cell coloring + color_layer = grid.add_layer("color", z_index=-1) + # Store color_layer on grid for access elsewhere + grid._color_layer = color_layer + # Initialize all cells as walkable for y in range(grid.grid_y): for x in range(grid.grid_x): @@ -28,8 +33,8 @@ def create_test_grid(): cell.walkable = True cell.transparent = True cell.tilesprite = 46 # . period - cell.color = mcrfpy.Color(50, 50, 50) - + color_layer.set(x, y, mcrfpy.Color(50, 50, 50)) + # Create some walls to make pathfinding interesting # Vertical wall for y in range(5, 15): @@ -37,8 +42,8 @@ def create_test_grid(): cell.walkable = False cell.transparent = False cell.tilesprite = 219 # Block - cell.color = mcrfpy.Color(100, 100, 100) - + color_layer.set(10, y, mcrfpy.Color(100, 100, 100)) + # Horizontal wall for x in range(5, 15): if x != 10: # Leave a gap @@ -46,8 +51,8 @@ def create_test_grid(): cell.walkable = False cell.transparent = False cell.tilesprite = 219 - cell.color = mcrfpy.Color(100, 100, 100) - + color_layer.set(x, 10, mcrfpy.Color(100, 100, 100)) + return grid def test_basic_dijkstra(): @@ -133,7 +138,7 @@ def test_multi_target_scenario(): # Mark threat position cell = grid.at(tx, ty) cell.tilesprite = 84 # T for threat - cell.color = mcrfpy.Color(255, 0, 0) + grid._color_layer.set(tx, ty, mcrfpy.Color(255, 0, 0)) # Compute Dijkstra from this threat grid.compute_dijkstra(tx, ty) @@ -176,7 +181,7 @@ def test_multi_target_scenario(): # Mark safe position cell = grid.at(best_pos[0], best_pos[1]) cell.tilesprite = 83 # S for safe - cell.color = mcrfpy.Color(0, 255, 0) + grid._color_layer.set(best_pos[0], best_pos[1], mcrfpy.Color(0, 255, 0)) def run_test(runtime): """Timer callback to run tests after scene loads""" @@ -211,7 +216,7 @@ ui = mcrfpy.sceneUI("dijkstra_test") ui.append(grid) # Add title -title = mcrfpy.Caption("Dijkstra Pathfinding Test", 10, 10) +title = mcrfpy.Caption(pos=(10, 10), text="Dijkstra Pathfinding Test") title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) diff --git a/tests/unit/test_entity_animation.py b/tests/unit/test_entity_animation.py index 342f340..2cf539e 100644 --- a/tests/unit/test_entity_animation.py +++ b/tests/unit/test_entity_animation.py @@ -17,13 +17,16 @@ mcrfpy.createScene("test_anim") grid = mcrfpy.Grid(grid_x=15, grid_y=15) grid.fill_color = mcrfpy.Color(20, 20, 30) +# Add a color layer for cell coloring +color_layer = grid.add_layer("color", z_index=-1) + # Initialize all cells as walkable floors for y in range(15): for x in range(15): cell = grid.at(x, y) cell.walkable = True cell.transparent = True - cell.color = mcrfpy.Color(100, 100, 120) + color_layer.set(x, y, mcrfpy.Color(100, 100, 120)) # Mark the path we'll follow with different color path_cells = [(5,5), (6,5), (7,5), (8,5), (9,5), (10,5), @@ -32,11 +35,10 @@ path_cells = [(5,5), (6,5), (7,5), (8,5), (9,5), (10,5), (5,9), (5,8), (5,7), (5,6)] for x, y in path_cells: - cell = grid.at(x, y) - cell.color = mcrfpy.Color(120, 120, 150) + color_layer.set(x, y, mcrfpy.Color(120, 120, 150)) # Create entity at start position -entity = mcrfpy.Entity(5, 5, grid=grid) +entity = mcrfpy.Entity((5, 5), grid=grid) entity.sprite_index = 64 # @ # UI setup @@ -46,27 +48,27 @@ grid.position = (100, 100) grid.size = (450, 450) # 15 * 30 pixels per cell # Title -title = mcrfpy.Caption("Entity Animation Test - Square Path", 200, 20) +title = mcrfpy.Caption(pos=(200, 20), text="Entity Animation Test - Square Path") title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) # Status display -status = mcrfpy.Caption("Press SPACE to start animation | Q to quit", 100, 50) +status = mcrfpy.Caption(pos=(100, 50), text="Press SPACE to start animation | Q to quit") status.fill_color = mcrfpy.Color(200, 200, 200) ui.append(status) # Position display -pos_display = mcrfpy.Caption(f"Entity Position: ({entity.x:.2f}, {entity.y:.2f})", 100, 70) +pos_display = mcrfpy.Caption(pos=(100, 70), text=f"Entity Position: ({entity.x:.2f}, {entity.y:.2f})") pos_display.fill_color = mcrfpy.Color(255, 255, 100) ui.append(pos_display) # Animation info -anim_info = mcrfpy.Caption("Animation: Not started", 400, 70) +anim_info = mcrfpy.Caption(pos=(400, 70), text="Animation: Not started") anim_info.fill_color = mcrfpy.Color(100, 255, 255) ui.append(anim_info) # Debug info -debug_info = mcrfpy.Caption("Debug: Waiting...", 100, 570) +debug_info = mcrfpy.Caption(pos=(100, 570), text="Debug: Waiting...") debug_info.fill_color = mcrfpy.Color(150, 150, 150) ui.append(debug_info) diff --git a/tests/unit/test_entity_fix.py b/tests/unit/test_entity_fix.py index 90a660d..eef131b 100644 --- a/tests/unit/test_entity_fix.py +++ b/tests/unit/test_entity_fix.py @@ -33,16 +33,19 @@ mcrfpy.createScene("fix_demo") grid = mcrfpy.Grid(grid_x=15, grid_y=10) grid.fill_color = mcrfpy.Color(20, 20, 30) +# Add color layer for cell coloring +color_layer = grid.add_layer("color", z_index=-1) + # Make floor for y in range(10): for x in range(15): cell = grid.at(x, y) cell.walkable = True cell.transparent = True - cell.color = mcrfpy.Color(100, 100, 120) + color_layer.set(x, y, mcrfpy.Color(100, 100, 120)) # Create entity -entity = mcrfpy.Entity(2, 2, grid=grid) +entity = mcrfpy.Entity((2, 2), grid=grid) entity.sprite_index = 64 # @ # UI @@ -52,19 +55,19 @@ grid.position = (100, 150) grid.size = (450, 300) # Info displays -title = mcrfpy.Caption("Entity Animation Issue Demo", 250, 20) +title = mcrfpy.Caption(pos=(250, 20), text="Entity Animation Issue Demo") title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) -pos_info = mcrfpy.Caption("", 100, 50) +pos_info = mcrfpy.Caption(pos=(100, 50), text="") pos_info.fill_color = mcrfpy.Color(255, 255, 100) ui.append(pos_info) -sprite_info = mcrfpy.Caption("", 100, 70) +sprite_info = mcrfpy.Caption(pos=(100, 70), text="") sprite_info.fill_color = mcrfpy.Color(255, 100, 100) ui.append(sprite_info) -status = mcrfpy.Caption("Press SPACE to animate entity", 100, 100) +status = mcrfpy.Caption(pos=(100, 100), text="Press SPACE to animate entity") status.fill_color = mcrfpy.Color(200, 200, 200) ui.append(status) diff --git a/tests/unit/test_entity_path_to.py b/tests/unit/test_entity_path_to.py index eab54d4..caeb4c1 100644 --- a/tests/unit/test_entity_path_to.py +++ b/tests/unit/test_entity_path_to.py @@ -22,8 +22,7 @@ for x, y in walls: grid.at(x, y).walkable = False # Create entity -entity = mcrfpy.Entity(2, 2) -grid.entities.append(entity) +entity = mcrfpy.Entity((2, 2), grid=grid) print(f"Entity at: ({entity.x}, {entity.y})") diff --git a/tests/unit/test_entity_path_to_edge_cases.py b/tests/unit/test_entity_path_to_edge_cases.py index f255aca..ef67d8f 100644 --- a/tests/unit/test_entity_path_to_edge_cases.py +++ b/tests/unit/test_entity_path_to_edge_cases.py @@ -9,7 +9,7 @@ print("=" * 50) # Test 1: Entity without grid print("Test 1: Entity not in grid") try: - entity = mcrfpy.Entity(5, 5) + entity = mcrfpy.Entity((5, 5)) path = entity.path_to(8, 8) print(" ✗ Should have failed for entity not in grid") except ValueError as e: @@ -31,8 +31,7 @@ for y in range(5): for x in range(5): grid.at(x, 2).walkable = False -entity = mcrfpy.Entity(1, 1) -grid.entities.append(entity) +entity = mcrfpy.Entity((1, 1), grid=grid) try: path = entity.path_to(1, 4) diff --git a/tests/unit/test_grid_background.py b/tests/unit/test_grid_background.py index c79cf8e..b74daf4 100644 --- a/tests/unit/test_grid_background.py +++ b/tests/unit/test_grid_background.py @@ -13,32 +13,28 @@ def test_grid_background(): ui = mcrfpy.sceneUI("test") # Create a grid with default background - grid = mcrfpy.Grid(20, 15, grid_size=(20, 15)) - grid.x = 50 - grid.y = 50 - grid.w = 400 - grid.h = 300 + grid = mcrfpy.Grid(pos=(50, 50), size=(400, 300), grid_size=(20, 15)) ui.append(grid) - - # Add some tiles to see the background better + + # Add color layer for some tiles to see the background better + color_layer = grid.add_layer("color", z_index=-1) for x in range(5, 15): for y in range(5, 10): - point = grid.at(x, y) - point.color = mcrfpy.Color(100, 150, 100) + color_layer.set(x, y, mcrfpy.Color(100, 150, 100)) # Add UI to show current background color - info_frame = mcrfpy.Frame(500, 50, 200, 150, + info_frame = mcrfpy.Frame(pos=(500, 50), size=(200, 150), fill_color=mcrfpy.Color(40, 40, 40), outline_color=mcrfpy.Color(200, 200, 200), outline=2) ui.append(info_frame) - - color_caption = mcrfpy.Caption(510, 60, "Background Color:") + + color_caption = mcrfpy.Caption(pos=(510, 60), text="Background Color:") color_caption.font_size = 14 color_caption.fill_color = mcrfpy.Color(255, 255, 255) info_frame.children.append(color_caption) - - color_display = mcrfpy.Caption(510, 80, "") + + color_display = mcrfpy.Caption(pos=(510, 80), text="") color_display.font_size = 12 color_display.fill_color = mcrfpy.Color(200, 200, 200) info_frame.children.append(color_display) diff --git a/tests/unit/test_headless_detection.py b/tests/unit/test_headless_detection.py index bfc284e..babe65d 100644 --- a/tests/unit/test_headless_detection.py +++ b/tests/unit/test_headless_detection.py @@ -11,8 +11,8 @@ ui = mcrfpy.sceneUI("detect_test") mcrfpy.setScene("detect_test") # Create a frame -frame = mcrfpy.Frame(100, 100, 200, 200) -frame.fill_color = (255, 100, 100, 255) +frame = mcrfpy.Frame(pos=(100, 100), size=(200, 200)) +frame.fill_color = mcrfpy.Color(255, 100, 100, 255) ui.append(frame) def test_mode(runtime): diff --git a/tests/unit/test_headless_modes.py b/tests/unit/test_headless_modes.py index 124e9f9..3e36658 100644 --- a/tests/unit/test_headless_modes.py +++ b/tests/unit/test_headless_modes.py @@ -10,13 +10,13 @@ ui = mcrfpy.sceneUI("headless_test") mcrfpy.setScene("headless_test") # Create a visible indicator -frame = mcrfpy.Frame(200, 200, 400, 200) -frame.fill_color = (100, 200, 100, 255) +frame = mcrfpy.Frame(pos=(200, 200), size=(400, 200)) +frame.fill_color = mcrfpy.Color(100, 200, 100, 255) ui.append(frame) -caption = mcrfpy.Caption((400, 300), "If you see this, windowed mode is working!", mcrfpy.default_font) -caption.size = 24 -caption.fill_color = (255, 255, 255) +caption = mcrfpy.Caption(pos=(400, 300), text="If you see this, windowed mode is working!") +caption.font_size = 24 +caption.fill_color = mcrfpy.Color(255, 255, 255) ui.append(caption) print("Script started. Window should appear unless --headless was specified.") diff --git a/tests/unit/test_metrics.py b/tests/unit/test_metrics.py index e760b2b..885e2c5 100644 --- a/tests/unit/test_metrics.py +++ b/tests/unit/test_metrics.py @@ -115,18 +115,18 @@ mcrfpy.setScene("metrics_test") ui = mcrfpy.sceneUI("metrics_test") # Create various UI elements -frame1 = mcrfpy.Frame(10, 10, 200, 150) -frame1.fill_color = (100, 100, 100, 128) +frame1 = mcrfpy.Frame(pos=(10, 10), size=(200, 150)) +frame1.fill_color = mcrfpy.Color(100, 100, 100, 128) ui.append(frame1) -caption1 = mcrfpy.Caption("Test Caption", 50, 50) +caption1 = mcrfpy.Caption(pos=(50, 50), text="Test Caption") ui.append(caption1) -sprite1 = mcrfpy.Sprite(100, 100) +sprite1 = mcrfpy.Sprite(pos=(100, 100)) ui.append(sprite1) # Invisible element (should not count as visible) -frame2 = mcrfpy.Frame(300, 10, 100, 100) +frame2 = mcrfpy.Frame(pos=(300, 10), size=(100, 100)) frame2.visible = False ui.append(frame2) diff --git a/tests/unit/test_path_colors.py b/tests/unit/test_path_colors.py index 779ff9e..1bcd9cd 100644 --- a/tests/unit/test_path_colors.py +++ b/tests/unit/test_path_colors.py @@ -11,17 +11,20 @@ print("=" * 50) mcrfpy.createScene("test") grid = mcrfpy.Grid(grid_x=5, grid_y=5) +# Add color layer for cell coloring +color_layer = grid.add_layer("color", z_index=-1) + # Initialize for y in range(5): for x in range(5): grid.at(x, y).walkable = True - grid.at(x, y).color = mcrfpy.Color(200, 200, 200) # Light gray + color_layer.set(x, y, mcrfpy.Color(200, 200, 200)) # Light gray # Add entities -e1 = mcrfpy.Entity(0, 0) -e2 = mcrfpy.Entity(4, 4) -grid.entities.append(e1) -grid.entities.append(e2) +e1 = mcrfpy.Entity((0, 0), grid=grid) +e2 = mcrfpy.Entity((4, 4), grid=grid) +e1.sprite_index = 64 +e2.sprite_index = 69 print(f"Entity 1 at ({e1.x}, {e1.y})") print(f"Entity 2 at ({e2.x}, {e2.y})") @@ -35,24 +38,25 @@ PATH_COLOR = mcrfpy.Color(100, 255, 100) # Green print(f"\nSetting path cells to green ({PATH_COLOR.r}, {PATH_COLOR.g}, {PATH_COLOR.b})...") for x, y in path: - cell = grid.at(x, y) # Check before - before = cell.color[:3] # Get RGB from tuple - + before_c = color_layer.at(x, y) + before = (before_c.r, before_c.g, before_c.b) + # Set color - cell.color = PATH_COLOR - + color_layer.set(x, y, PATH_COLOR) + # Check after - after = cell.color[:3] # Get RGB from tuple - + after_c = color_layer.at(x, y) + after = (after_c.r, after_c.g, after_c.b) + print(f" Cell ({x},{y}): {before} -> {after}") # Verify all path cells print("\nVerifying all cells in grid:") for y in range(5): for x in range(5): - cell = grid.at(x, y) - color = cell.color[:3] # Get RGB from tuple + c = color_layer.at(x, y) + color = (c.r, c.g, c.b) is_path = (x, y) in path print(f" ({x},{y}): color={color}, in_path={is_path}") diff --git a/tests/unit/test_pathfinding_integration.py b/tests/unit/test_pathfinding_integration.py index 8f779f6..a27f6a5 100644 --- a/tests/unit/test_pathfinding_integration.py +++ b/tests/unit/test_pathfinding_integration.py @@ -21,10 +21,8 @@ for i in range(5): grid.at(5, i + 2).walkable = False # Create entities -e1 = mcrfpy.Entity(2, 5) -e2 = mcrfpy.Entity(8, 5) -grid.entities.append(e1) -grid.entities.append(e2) +e1 = mcrfpy.Entity((2, 5), grid=grid) +e2 = mcrfpy.Entity((8, 5), grid=grid) # Test pathfinding between entities print(f"Entity 1 at ({e1.x}, {e1.y})") diff --git a/tests/unit/test_properties_quick.py b/tests/unit/test_properties_quick.py index 31822c2..e16774a 100644 --- a/tests/unit/test_properties_quick.py +++ b/tests/unit/test_properties_quick.py @@ -10,7 +10,7 @@ def test_properties(runtime): # Test Frame try: - frame = mcrfpy.Frame(10, 10, 100, 100) + frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100)) print(f"Frame visible: {frame.visible}") frame.visible = False print(f"Frame visible after setting to False: {frame.visible}") diff --git a/tests/unit/test_scene_transitions.py b/tests/unit/test_scene_transitions.py index 603db6a..ea541b6 100644 --- a/tests/unit/test_scene_transitions.py +++ b/tests/unit/test_scene_transitions.py @@ -11,51 +11,51 @@ def create_test_scenes(): # Scene 1: Red background mcrfpy.createScene("red_scene") ui1 = mcrfpy.sceneUI("red_scene") - bg1 = mcrfpy.Frame(0, 0, 1024, 768, fill_color=mcrfpy.Color(255, 0, 0, 255)) - label1 = mcrfpy.Caption(512, 384, "RED SCENE", font=mcrfpy.Font.font_ui) - label1.color = mcrfpy.Color(255, 255, 255, 255) + bg1 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(255, 0, 0, 255)) + label1 = mcrfpy.Caption(pos=(512, 384), text="RED SCENE", font=mcrfpy.Font.font_ui) + label1.fill_color = mcrfpy.Color(255, 255, 255, 255) ui1.append(bg1) ui1.append(label1) - + # Scene 2: Blue background mcrfpy.createScene("blue_scene") ui2 = mcrfpy.sceneUI("blue_scene") - bg2 = mcrfpy.Frame(0, 0, 1024, 768, fill_color=mcrfpy.Color(0, 0, 255, 255)) - label2 = mcrfpy.Caption(512, 384, "BLUE SCENE", font=mcrfpy.Font.font_ui) - label2.color = mcrfpy.Color(255, 255, 255, 255) + bg2 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(0, 0, 255, 255)) + label2 = mcrfpy.Caption(pos=(512, 384), text="BLUE SCENE", font=mcrfpy.Font.font_ui) + label2.fill_color = mcrfpy.Color(255, 255, 255, 255) ui2.append(bg2) ui2.append(label2) - + # Scene 3: Green background mcrfpy.createScene("green_scene") ui3 = mcrfpy.sceneUI("green_scene") - bg3 = mcrfpy.Frame(0, 0, 1024, 768, fill_color=mcrfpy.Color(0, 255, 0, 255)) - label3 = mcrfpy.Caption(512, 384, "GREEN SCENE", font=mcrfpy.Font.font_ui) - label3.color = mcrfpy.Color(0, 0, 0, 255) # Black text on green + bg3 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(0, 255, 0, 255)) + label3 = mcrfpy.Caption(pos=(512, 384), text="GREEN SCENE", font=mcrfpy.Font.font_ui) + label3.fill_color = mcrfpy.Color(0, 0, 0, 255) # Black text on green ui3.append(bg3) ui3.append(label3) - + # Scene 4: Menu scene with buttons mcrfpy.createScene("menu_scene") ui4 = mcrfpy.sceneUI("menu_scene") - bg4 = mcrfpy.Frame(0, 0, 1024, 768, fill_color=mcrfpy.Color(50, 50, 50, 255)) - - title = mcrfpy.Caption(512, 100, "SCENE TRANSITION DEMO", font=mcrfpy.Font.font_ui) - title.color = mcrfpy.Color(255, 255, 255, 255) + bg4 = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(50, 50, 50, 255)) + + title = mcrfpy.Caption(pos=(512, 100), text="SCENE TRANSITION DEMO", font=mcrfpy.Font.font_ui) + title.fill_color = mcrfpy.Color(255, 255, 255, 255) ui4.append(bg4) ui4.append(title) - + # Add instruction text - instructions = mcrfpy.Caption(512, 200, "Press keys 1-6 for different transitions", font=mcrfpy.Font.font_ui) - instructions.color = mcrfpy.Color(200, 200, 200, 255) + instructions = mcrfpy.Caption(pos=(512, 200), text="Press keys 1-6 for different transitions", font=mcrfpy.Font.font_ui) + instructions.fill_color = mcrfpy.Color(200, 200, 200, 255) ui4.append(instructions) - - controls = mcrfpy.Caption(512, 250, "1: Fade | 2: Slide Left | 3: Slide Right | 4: Slide Up | 5: Slide Down | 6: Instant", font=mcrfpy.Font.font_ui) - controls.color = mcrfpy.Color(150, 150, 150, 255) + + controls = mcrfpy.Caption(pos=(512, 250), text="1: Fade | 2: Slide Left | 3: Slide Right | 4: Slide Up | 5: Slide Down | 6: Instant", font=mcrfpy.Font.font_ui) + controls.fill_color = mcrfpy.Color(150, 150, 150, 255) ui4.append(controls) - - scene_info = mcrfpy.Caption(512, 300, "R: Red Scene | B: Blue Scene | G: Green Scene | M: Menu", font=mcrfpy.Font.font_ui) - scene_info.color = mcrfpy.Color(150, 150, 150, 255) + + scene_info = mcrfpy.Caption(pos=(512, 300), text="R: Red Scene | B: Blue Scene | G: Green Scene | M: Menu", font=mcrfpy.Font.font_ui) + scene_info.fill_color = mcrfpy.Color(150, 150, 150, 255) ui4.append(scene_info) print("Created test scenes: red_scene, blue_scene, green_scene, menu_scene") diff --git a/tests/unit/test_scene_transitions_headless.py b/tests/unit/test_scene_transitions_headless.py index 3dd791a..1e9b571 100644 --- a/tests/unit/test_scene_transitions_headless.py +++ b/tests/unit/test_scene_transitions_headless.py @@ -13,13 +13,13 @@ def test_scene_transitions(): # Scene 1 mcrfpy.createScene("scene1") ui1 = mcrfpy.sceneUI("scene1") - frame1 = mcrfpy.Frame(0, 0, 100, 100, fill_color=mcrfpy.Color(255, 0, 0)) + frame1 = mcrfpy.Frame(pos=(0, 0), size=(100, 100), fill_color=mcrfpy.Color(255, 0, 0)) ui1.append(frame1) - - # Scene 2 + + # Scene 2 mcrfpy.createScene("scene2") ui2 = mcrfpy.sceneUI("scene2") - frame2 = mcrfpy.Frame(0, 0, 100, 100, fill_color=mcrfpy.Color(0, 0, 255)) + frame2 = mcrfpy.Frame(pos=(0, 0), size=(100, 100), fill_color=mcrfpy.Color(0, 0, 255)) ui2.append(frame2) # Test each transition type diff --git a/tests/unit/test_simple_drawable.py b/tests/unit/test_simple_drawable.py index a42fdcb..8a03baf 100644 --- a/tests/unit/test_simple_drawable.py +++ b/tests/unit/test_simple_drawable.py @@ -8,7 +8,7 @@ def simple_test(runtime): try: # Test basic functionality - frame = mcrfpy.Frame(10, 10, 100, 100) + frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100)) print(f"Frame created: visible={frame.visible}, opacity={frame.opacity}") bounds = frame.get_bounds() diff --git a/tests/unit/test_text_input.py b/tests/unit/test_text_input.py index 69464df..bc39a7f 100644 --- a/tests/unit/test_text_input.py +++ b/tests/unit/test_text_input.py @@ -18,13 +18,13 @@ def create_demo(): scene = mcrfpy.sceneUI("text_demo") # Background - bg = mcrfpy.Frame(0, 0, 800, 600) - bg.fill_color = (40, 40, 40, 255) + bg = mcrfpy.Frame(pos=(0, 0), size=(800, 600)) + bg.fill_color = mcrfpy.Color(40, 40, 40, 255) scene.append(bg) - + # Title - title = mcrfpy.Caption("Text Input Widget Demo", 20, 20) - title.fill_color = (255, 255, 255, 255) + title = mcrfpy.Caption(pos=(20, 20), text="Text Input Widget Demo") + title.fill_color = mcrfpy.Color(255, 255, 255, 255) scene.append(title) # Focus manager @@ -62,8 +62,8 @@ def create_demo(): inputs.append(comment_input) # Status display - status = mcrfpy.Caption("Ready for input...", 50, 360) - status.fill_color = (150, 255, 150, 255) + status = mcrfpy.Caption(pos=(50, 360), text="Ready for input...") + status.fill_color = mcrfpy.Color(150, 255, 150, 255) scene.append(status) # Update handler diff --git a/tests/unit/test_uicaption_visual.py b/tests/unit/test_uicaption_visual.py index 7d578f2..a4e3fc5 100644 --- a/tests/unit/test_uicaption_visual.py +++ b/tests/unit/test_uicaption_visual.py @@ -69,11 +69,11 @@ def main(): mcrfpy.setScene("test") # Create multiple captions for testing - caption1 = mcrfpy.Caption(50, 50, "Caption 1: Normal", fill_color=(255, 255, 255)) - caption2 = mcrfpy.Caption(50, 100, "Caption 2: Will be invisible", fill_color=(255, 200, 200)) - caption3 = mcrfpy.Caption(50, 150, "Caption 3: 50% opacity", fill_color=(200, 255, 200)) - caption4 = mcrfpy.Caption(50, 200, "Caption 4: 25% opacity", fill_color=(200, 200, 255)) - caption5 = mcrfpy.Caption(50, 250, "Caption 5: 0% opacity", fill_color=(255, 255, 200)) + caption1 = mcrfpy.Caption(pos=(50, 50), text="Caption 1: Normal", fill_color=mcrfpy.Color(255, 255, 255)) + caption2 = mcrfpy.Caption(pos=(50, 100), text="Caption 2: Will be invisible", fill_color=mcrfpy.Color(255, 200, 200)) + caption3 = mcrfpy.Caption(pos=(50, 150), text="Caption 3: 50% opacity", fill_color=mcrfpy.Color(200, 255, 200)) + caption4 = mcrfpy.Caption(pos=(50, 200), text="Caption 4: 25% opacity", fill_color=mcrfpy.Color(200, 200, 255)) + caption5 = mcrfpy.Caption(pos=(50, 250), text="Caption 5: 0% opacity", fill_color=mcrfpy.Color(255, 255, 200)) # Add captions to scene ui = mcrfpy.sceneUI("test") @@ -84,7 +84,7 @@ def main(): ui.append(caption5) # Also add a frame as background to see transparency better - frame = mcrfpy.Frame(40, 40, 400, 250, fill_color=(50, 50, 50)) + frame = mcrfpy.Frame(pos=(40, 40), size=(400, 250), fill_color=mcrfpy.Color(50, 50, 50)) frame.z_index = -1 # Put it behind the captions ui.append(frame) diff --git a/tests/unit/test_visibility.py b/tests/unit/test_visibility.py index 23ea9fc..b866078 100644 --- a/tests/unit/test_visibility.py +++ b/tests/unit/test_visibility.py @@ -18,6 +18,9 @@ mcrfpy.createScene("visibility_test") grid = mcrfpy.Grid(grid_x=20, grid_y=15) grid.fill_color = mcrfpy.Color(20, 20, 30) # Dark background +# Add a color layer for cell coloring +color_layer = grid.add_layer("color", z_index=-1) + # Initialize grid - all walkable and transparent print("\nInitializing 20x15 grid...") for y in range(15): @@ -25,7 +28,7 @@ for y in range(15): cell = grid.at(x, y) cell.walkable = True cell.transparent = True - cell.color = mcrfpy.Color(100, 100, 120) # Floor color + color_layer.set(x, y, mcrfpy.Color(100, 100, 120)) # Floor color # Create some walls to block vision print("Adding walls...") @@ -47,14 +50,14 @@ for wall_group in walls: cell = grid.at(x, y) cell.walkable = False cell.transparent = False - cell.color = mcrfpy.Color(40, 20, 20) # Wall color + color_layer.set(x, y, mcrfpy.Color(40, 20, 20)) # Wall color # Create entities print("\nCreating entities...") entities = [ - mcrfpy.Entity(2, 7), # Left side - mcrfpy.Entity(18, 7), # Right side - mcrfpy.Entity(10, 1), # Top center (above wall) + mcrfpy.Entity((2, 7)), # Left side + mcrfpy.Entity((18, 7)), # Right side + mcrfpy.Entity((10, 1)), # Top center (above wall) ] for i, entity in enumerate(entities): @@ -138,17 +141,17 @@ grid.position = (50, 50) grid.size = (600, 450) # 20*30, 15*30 # Add title -title = mcrfpy.Caption("Knowledge Stubs 1 - Visibility Test", 200, 10) +title = mcrfpy.Caption(pos=(200, 10), text="Knowledge Stubs 1 - Visibility Test") title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) # Add info -info = mcrfpy.Caption("Perspective: -1 (omniscient)", 50, 520) +info = mcrfpy.Caption(pos=(50, 520), text="Perspective: -1 (omniscient)") info.fill_color = mcrfpy.Color(200, 200, 200) ui.append(info) # Add legend -legend = mcrfpy.Caption("Black=Never seen, Dark gray=Discovered, Normal=Visible", 50, 540) +legend = mcrfpy.Caption(pos=(50, 540), text="Black=Never seen, Dark gray=Discovered, Normal=Visible") legend.fill_color = mcrfpy.Color(150, 150, 150) ui.append(legend) diff --git a/tests/unit/test_visual_path.py b/tests/unit/test_visual_path.py index 31b385f..11a8c71 100644 --- a/tests/unit/test_visual_path.py +++ b/tests/unit/test_visual_path.py @@ -4,28 +4,10 @@ import mcrfpy import sys -# Colors as tuples (r, g, b, a) -WALL_COLOR = (60, 30, 30, 255) -FLOOR_COLOR = (200, 200, 220, 255) -PATH_COLOR = (100, 255, 100, 255) - -def check_render(dt): - """Timer callback to verify rendering""" - print(f"\nTimer fired after {dt}ms") - - # Take screenshot - from mcrfpy import automation - automation.screenshot("visual_path_test.png") - print("Screenshot saved as visual_path_test.png") - - # Sample some path cells to verify colors - print("\nSampling path cell colors from grid:") - for x, y in [(1, 1), (2, 2), (3, 3)]: - cell = grid.at(x, y) - color = cell.color - print(f" Cell ({x},{y}): color={color[:3]}") - - sys.exit(0) +# Colors +WALL_COLOR = mcrfpy.Color(60, 30, 30) +FLOOR_COLOR = mcrfpy.Color(200, 200, 220) +PATH_COLOR = mcrfpy.Color(100, 255, 100) # Create scene mcrfpy.createScene("visual_test") @@ -34,20 +16,38 @@ mcrfpy.createScene("visual_test") grid = mcrfpy.Grid(grid_x=5, grid_y=5) grid.fill_color = mcrfpy.Color(0, 0, 0) +# Add color layer for cell coloring +color_layer = grid.add_layer("color", z_index=-1) + +def check_render(dt): + """Timer callback to verify rendering""" + print(f"\nTimer fired after {dt}ms") + + # Take screenshot + from mcrfpy import automation + automation.screenshot("visual_path_test.png") + print("Screenshot saved as visual_path_test.png") + + # Sample some path cells to verify colors + print("\nSampling path cell colors from grid:") + for x, y in [(1, 1), (2, 2), (3, 3)]: + color = color_layer.at(x, y) + print(f" Cell ({x},{y}): color=({color.r}, {color.g}, {color.b})") + + sys.exit(0) + # Initialize all cells as floor print("Initializing grid...") for y in range(5): for x in range(5): grid.at(x, y).walkable = True - grid.at(x, y).color = FLOOR_COLOR + color_layer.set(x, y, FLOOR_COLOR) # Create entities -e1 = mcrfpy.Entity(0, 0) -e2 = mcrfpy.Entity(4, 4) +e1 = mcrfpy.Entity((0, 0), grid=grid) +e2 = mcrfpy.Entity((4, 4), grid=grid) e1.sprite_index = 64 # @ e2.sprite_index = 69 # E -grid.entities.append(e1) -grid.entities.append(e2) print(f"Entity 1 at ({e1.x}, {e1.y})") print(f"Entity 2 at ({e2.x}, {e2.y})") @@ -60,7 +60,7 @@ print(f"\nPath from E1 to E2: {path}") if path: print("\nColoring path cells green...") for x, y in path: - grid.at(x, y).color = PATH_COLOR + color_layer.set(x, y, PATH_COLOR) print(f" Set ({x},{y}) to green") # Set up UI @@ -70,7 +70,7 @@ grid.position = (50, 50) grid.size = (250, 250) # Add title -title = mcrfpy.Caption("Path Visualization Test", 50, 10) +title = mcrfpy.Caption(pos=(50, 10), text="Path Visualization Test") title.fill_color = mcrfpy.Color(255, 255, 255) ui.append(title) diff --git a/tests/unit/ui_Frame_test_detailed.py b/tests/unit/ui_Frame_test_detailed.py index 3058d70..938a5a4 100644 --- a/tests/unit/ui_Frame_test_detailed.py +++ b/tests/unit/ui_Frame_test_detailed.py @@ -16,11 +16,11 @@ def test_issue_38_children(): print("\nTest 1: Passing children argument to Frame constructor") try: # Create some child elements - child1 = mcrfpy.Caption(mcrfpy.Vector(10, 10), text="Child 1") - child2 = mcrfpy.Sprite(mcrfpy.Vector(10, 30)) - + child1 = mcrfpy.Caption(pos=(10, 10), text="Child 1") + child2 = mcrfpy.Sprite(pos=(10, 30)) + # Try to create frame with children argument - frame = mcrfpy.Frame(10, 10, 200, 150, children=[child1, child2]) + frame = mcrfpy.Frame(pos=(10, 10), size=(200, 150), children=[child1, child2]) print("✗ UNEXPECTED: Frame accepted children argument (should fail per issue #38)") except TypeError as e: print(f"✓ EXPECTED: Frame constructor rejected children argument: {e}") @@ -30,12 +30,12 @@ def test_issue_38_children(): # Test 2: Verify children can be added after creation print("\nTest 2: Adding children after Frame creation") try: - frame = mcrfpy.Frame(10, 10, 200, 150) + frame = mcrfpy.Frame(pos=(10, 10), size=(200, 150)) ui.append(frame) - + # Add children via the children collection - child1 = mcrfpy.Caption(mcrfpy.Vector(10, 10), text="Added Child 1") - child2 = mcrfpy.Caption(mcrfpy.Vector(10, 30), text="Added Child 2") + child1 = mcrfpy.Caption(pos=(10, 10), text="Added Child 1") + child2 = mcrfpy.Caption(pos=(10, 30), text="Added Child 2") frame.children.append(child1) frame.children.append(child2) @@ -65,33 +65,33 @@ def test_issue_42_click_callback(): return True try: - frame1 = mcrfpy.Frame(10, 10, 200, 150) + frame1 = mcrfpy.Frame(pos=(10, 10), size=(200, 150)) ui.append(frame1) frame1.on_click = correct_callback print("✓ Click callback with correct signature assigned successfully") except Exception as e: print(f"✗ Failed to assign correct callback: {type(e).__name__}: {e}") - + # Test 2: Callback with wrong signature (no args) print("\nTest 2: Click callback with no arguments") def wrong_callback_no_args(): print(" Wrong callback called") - + try: - frame2 = mcrfpy.Frame(220, 10, 200, 150) + frame2 = mcrfpy.Frame(pos=(220, 10), size=(200, 150)) ui.append(frame2) frame2.on_click = wrong_callback_no_args print("✓ Click callback with no args assigned (will fail at runtime per issue #42)") except Exception as e: print(f"✗ Failed to assign callback: {type(e).__name__}: {e}") - + # Test 3: Callback with wrong signature (too few args) print("\nTest 3: Click callback with too few arguments") def wrong_callback_few_args(x, y): print(f" Wrong callback called: x={x}, y={y}") - + try: - frame3 = mcrfpy.Frame(10, 170, 200, 150) + frame3 = mcrfpy.Frame(pos=(10, 170), size=(200, 150)) ui.append(frame3) frame3.on_click = wrong_callback_few_args print("✓ Click callback with 2 args assigned (will fail at runtime per issue #42)") diff --git a/tests/unit/ui_Grid_none_texture_test.py b/tests/unit/ui_Grid_none_texture_test.py index 38150ef..a283cee 100644 --- a/tests/unit/ui_Grid_none_texture_test.py +++ b/tests/unit/ui_Grid_none_texture_test.py @@ -7,24 +7,24 @@ import sys def test_grid_none_texture(runtime): """Test Grid functionality without texture""" print("\n=== Testing Grid with None texture ===") - + # Test 1: Create Grid with None texture try: - grid = mcrfpy.Grid(10, 10, None, mcrfpy.Vector(50, 50), mcrfpy.Vector(400, 400)) + grid = mcrfpy.Grid(grid_size=(10, 10), pos=(50, 50), size=(400, 400)) print("✓ Grid created successfully with None texture") except Exception as e: print(f"✗ Failed to create Grid with None texture: {e}") sys.exit(1) - + # Add to UI ui = mcrfpy.sceneUI("grid_none_test") ui.append(grid) - + # Test 2: Verify grid properties try: grid_size = grid.grid_size print(f"✓ Grid size: {grid_size}") - + # Check texture property texture = grid.texture if texture is None: @@ -33,39 +33,41 @@ def test_grid_none_texture(runtime): print(f"✗ Grid texture should be None, got: {texture}") except Exception as e: print(f"✗ Property access failed: {e}") - - # Test 3: Access grid points and set colors + + # Test 3: Access grid points using ColorLayer (new API) + # Note: GridPoint no longer has .color - must use ColorLayer system try: + # Add a color layer to the grid + color_layer = grid.add_layer("color", z_index=-1) # Create a checkerboard pattern with colors for x in range(10): for y in range(10): - point = grid.at(x, y) if (x + y) % 2 == 0: - point.color = mcrfpy.Color(255, 0, 0, 255) # Red + color_layer.set(x, y, mcrfpy.Color(255, 0, 0, 255)) # Red else: - point.color = mcrfpy.Color(0, 0, 255, 255) # Blue - print("✓ Successfully set grid point colors") + color_layer.set(x, y, mcrfpy.Color(0, 0, 255, 255)) # Blue + print("✓ Successfully set grid colors via ColorLayer") except Exception as e: print(f"✗ Failed to set grid colors: {e}") - + # Test 4: Add entities to the grid try: # Create an entity with its own texture entity_texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) - entity = mcrfpy.Entity(mcrfpy.Vector(5, 5), entity_texture, 1, grid) + entity = mcrfpy.Entity((5, 5), texture=entity_texture, sprite_index=1, grid=grid) grid.entities.append(entity) print(f"✓ Added entity to grid, total entities: {len(grid.entities)}") except Exception as e: print(f"✗ Failed to add entity: {e}") - + # Test 5: Test grid interaction properties try: # Test zoom grid.zoom = 2.0 print(f"✓ Set zoom to: {grid.zoom}") - - # Test center - grid.center = mcrfpy.Vector(5, 5) + + # Test center (uses pixel coordinates) + grid.center = (200, 200) print(f"✓ Set center to: {grid.center}") except Exception as e: print(f"✗ Grid properties failed: {e}") @@ -86,7 +88,7 @@ mcrfpy.setScene("grid_none_test") # Add a background frame so we can see the grid ui = mcrfpy.sceneUI("grid_none_test") -background = mcrfpy.Frame(0, 0, 800, 600, +background = mcrfpy.Frame(pos=(0, 0), size=(800, 600), fill_color=mcrfpy.Color(200, 200, 200), outline_color=mcrfpy.Color(0, 0, 0), outline=2.0) diff --git a/tests/unit/ui_UICollection_issue69_test.py b/tests/unit/ui_UICollection_issue69_test.py index 3299bcd..44af8d2 100644 --- a/tests/unit/ui_UICollection_issue69_test.py +++ b/tests/unit/ui_UICollection_issue69_test.py @@ -11,8 +11,8 @@ def test_UICollection(): ui = mcrfpy.sceneUI("collection_test") # Add various UI elements - frame = mcrfpy.Frame(10, 10, 100, 100) - caption = mcrfpy.Caption(mcrfpy.Vector(120, 10), text="Test") + frame = mcrfpy.Frame(pos=(10, 10), size=(100, 100)) + caption = mcrfpy.Caption(pos=(120, 10), text="Test") # Skip sprite for now since it requires a texture ui.append(frame) @@ -74,9 +74,9 @@ def test_UICollection(): # Test type preservation (Issue #76) try: # Add a frame with children to test nested collections - parent_frame = mcrfpy.Frame(250, 10, 200, 200, + parent_frame = mcrfpy.Frame(pos=(250, 10), size=(200, 200), fill_color=mcrfpy.Color(200, 200, 200)) - child_caption = mcrfpy.Caption(mcrfpy.Vector(10, 10), text="Child") + child_caption = mcrfpy.Caption(pos=(10, 10), text="Child") parent_frame.children.append(child_caption) ui.append(parent_frame) diff --git a/tests/unit/validate_screenshot_test.py b/tests/unit/validate_screenshot_test.py index e949eda..7e1a068 100644 --- a/tests/unit/validate_screenshot_test.py +++ b/tests/unit/validate_screenshot_test.py @@ -18,50 +18,50 @@ def test_screenshot_validation(): print("Creating UI elements...") # Bright red frame with white outline - frame1 = mcrfpy.Frame(50, 50, 300, 200, + frame1 = mcrfpy.Frame(pos=(50, 50), size=(300, 200), fill_color=mcrfpy.Color(255, 0, 0), # Bright red outline_color=mcrfpy.Color(255, 255, 255), # White outline=5.0) ui.append(frame1) print("Added red frame at (50, 50)") - + # Bright green frame - frame2 = mcrfpy.Frame(400, 50, 300, 200, + frame2 = mcrfpy.Frame(pos=(400, 50), size=(300, 200), fill_color=mcrfpy.Color(0, 255, 0), # Bright green outline_color=mcrfpy.Color(0, 0, 0), # Black outline=3.0) ui.append(frame2) print("Added green frame at (400, 50)") - + # Blue frame - frame3 = mcrfpy.Frame(50, 300, 300, 200, + frame3 = mcrfpy.Frame(pos=(50, 300), size=(300, 200), fill_color=mcrfpy.Color(0, 0, 255), # Bright blue outline_color=mcrfpy.Color(255, 255, 0), # Yellow outline=4.0) ui.append(frame3) print("Added blue frame at (50, 300)") - + # Add text captions - caption1 = mcrfpy.Caption(mcrfpy.Vector(60, 60), + caption1 = mcrfpy.Caption(pos=(60, 60), text="RED FRAME TEST", fill_color=mcrfpy.Color(255, 255, 255)) - caption1.size = 24 + caption1.font_size = 24 frame1.children.append(caption1) - - caption2 = mcrfpy.Caption(mcrfpy.Vector(410, 60), + + caption2 = mcrfpy.Caption(pos=(410, 60), text="GREEN FRAME TEST", fill_color=mcrfpy.Color(0, 0, 0)) - caption2.size = 24 + caption2.font_size = 24 ui.append(caption2) - - caption3 = mcrfpy.Caption(mcrfpy.Vector(60, 310), + + caption3 = mcrfpy.Caption(pos=(60, 310), text="BLUE FRAME TEST", fill_color=mcrfpy.Color(255, 255, 0)) - caption3.size = 24 + caption3.font_size = 24 ui.append(caption3) - + # White background frame to ensure non-transparent background - background = mcrfpy.Frame(0, 0, 1024, 768, + background = mcrfpy.Frame(pos=(0, 0), size=(1024, 768), fill_color=mcrfpy.Color(200, 200, 200)) # Light gray # Insert at beginning so it's behind everything ui.remove(len(ui) - 1) # Remove to re-add at start diff --git a/tests/unit/working_timer_test.py b/tests/unit/working_timer_test.py index 4435014..a9d96c5 100644 --- a/tests/unit/working_timer_test.py +++ b/tests/unit/working_timer_test.py @@ -11,16 +11,16 @@ mcrfpy.setScene("timer_works") ui = mcrfpy.sceneUI("timer_works") # Add visible content -frame = mcrfpy.Frame(100, 100, 300, 200, +frame = mcrfpy.Frame(pos=(100, 100), size=(300, 200), fill_color=mcrfpy.Color(255, 0, 0), outline_color=mcrfpy.Color(255, 255, 255), outline=3.0) ui.append(frame) -caption = mcrfpy.Caption(mcrfpy.Vector(150, 150), +caption = mcrfpy.Caption(pos=(150, 150), text="TIMER TEST SUCCESS", fill_color=mcrfpy.Color(255, 255, 255)) -caption.size = 24 +caption.font_size = 24 ui.append(caption) # Timer callback with correct signature From b863698f6e63870c54a72cbce278b447089686cc Mon Sep 17 00:00:00 2001 From: John McCardle Date: Mon, 29 Dec 2025 19:48:00 -0500 Subject: [PATCH 2/5] test: Add comprehensive Scene object API test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrates the object-oriented Scene API as alternative to module-level functions. Key features tested: - Scene object creation and properties (name, active, children) - scene.activate() vs mcrfpy.setScene() - scene.on_key property - can be set on ANY scene, not just current - Scene visual properties (pos, visible, opacity) - Subclassing for lifecycle callbacks (on_enter, on_exit, update) The on_key advantage resolves confusion with keypressScene() which only works on the currently active scene. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- tests/unit/test_scene_object_api.py | 235 ++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 tests/unit/test_scene_object_api.py diff --git a/tests/unit/test_scene_object_api.py b/tests/unit/test_scene_object_api.py new file mode 100644 index 0000000..f52c1d4 --- /dev/null +++ b/tests/unit/test_scene_object_api.py @@ -0,0 +1,235 @@ +#!/usr/bin/env python3 +"""Test the object-oriented Scene API (alternative to module-level functions). + +The Scene object provides an OOP approach to scene management with several advantages: +1. `scene.on_key` can be set on ANY scene, not just the current one +2. `scene.children` provides direct access to UI elements +3. Subclassing enables lifecycle callbacks (on_enter, on_exit, update, etc.) + +This is the recommended approach for new code, replacing: +- mcrfpy.createScene(name) -> scene = mcrfpy.Scene(name) +- mcrfpy.setScene(name) -> scene.activate() +- mcrfpy.sceneUI(name) -> scene.children +- mcrfpy.keypressScene(callback) -> scene.on_key = callback +""" +import mcrfpy +import sys + +def test_scene_object_basics(): + """Test basic Scene object creation and properties.""" + print("=== Test: Scene Object Basics ===") + + # Create scene using object-oriented approach + scene = mcrfpy.Scene("oop_test") + + # Check name property + assert scene.name == "oop_test", f"Expected 'oop_test', got '{scene.name}'" + print(f" name: {scene.name}") + + # Check active property (should be False, not yet activated) + print(f" active: {scene.active}") + + # Check children property returns UICollection + children = scene.children + print(f" children type: {type(children).__name__}") + print(f" children count: {len(children)}") + + # Add UI elements via children + frame = mcrfpy.Frame(pos=(50, 50), size=(200, 100), fill_color=mcrfpy.Color(100, 100, 200)) + scene.children.append(frame) + print(f" children count after append: {len(scene.children)}") + + print(" PASS: Basic properties work correctly") + return scene + +def test_scene_activation(): + """Test scene activation.""" + print("\n=== Test: Scene Activation ===") + + scene1 = mcrfpy.Scene("scene_a") + scene2 = mcrfpy.Scene("scene_b") + + # Neither active yet + print(f" Before activation - scene1.active: {scene1.active}, scene2.active: {scene2.active}") + + # Activate scene1 + scene1.activate() + print(f" After scene1.activate() - scene1.active: {scene1.active}, scene2.active: {scene2.active}") + assert scene1.active == True, "scene1 should be active" + assert scene2.active == False, "scene2 should not be active" + + # Activate scene2 + scene2.activate() + print(f" After scene2.activate() - scene1.active: {scene1.active}, scene2.active: {scene2.active}") + assert scene1.active == False, "scene1 should not be active now" + assert scene2.active == True, "scene2 should be active" + + print(" PASS: Scene activation works correctly") + +def test_scene_on_key(): + """Test setting on_key callback on scene objects. + + This is the KEY ADVANTAGE over module-level keypressScene(): + You can set on_key on ANY scene, not just the current one! + """ + print("\n=== Test: Scene on_key Property ===") + + scene1 = mcrfpy.Scene("keys_scene1") + scene2 = mcrfpy.Scene("keys_scene2") + + # Track which callback was called + callback_log = [] + + def scene1_keyhandler(key, action): + callback_log.append(("scene1", key, action)) + + def scene2_keyhandler(key, action): + callback_log.append(("scene2", key, action)) + + # Set callbacks on BOTH scenes BEFORE activating either + # This is impossible with keypressScene() which only works on current scene! + scene1.on_key = scene1_keyhandler + scene2.on_key = scene2_keyhandler + + print(f" scene1.on_key set: {scene1.on_key is not None}") + print(f" scene2.on_key set: {scene2.on_key is not None}") + + # Verify callbacks are retrievable + assert callable(scene1.on_key), "scene1.on_key should be callable" + assert callable(scene2.on_key), "scene2.on_key should be callable" + + # Test clearing callback + scene1.on_key = None + assert scene1.on_key is None, "scene1.on_key should be None after clearing" + print(" scene1.on_key cleared successfully") + + # Re-set it + scene1.on_key = scene1_keyhandler + + print(" PASS: on_key property works correctly") + +def test_scene_visual_properties(): + """Test scene-level visual properties (pos, visible, opacity).""" + print("\n=== Test: Scene Visual Properties ===") + + scene = mcrfpy.Scene("visual_props_test") + + # Test pos property + print(f" Initial pos: {scene.pos}") + scene.pos = (100, 50) + print(f" After setting pos=(100, 50): {scene.pos}") + + # Test visible property + print(f" Initial visible: {scene.visible}") + scene.visible = False + print(f" After setting visible=False: {scene.visible}") + assert scene.visible == False, "visible should be False" + scene.visible = True + + # Test opacity property + print(f" Initial opacity: {scene.opacity}") + scene.opacity = 0.5 + print(f" After setting opacity=0.5: {scene.opacity}") + assert 0.49 < scene.opacity < 0.51, f"opacity should be ~0.5, got {scene.opacity}" + + print(" PASS: Visual properties work correctly") + +def test_scene_subclass(): + """Test subclassing Scene for lifecycle callbacks.""" + print("\n=== Test: Scene Subclass with Lifecycle ===") + + class GameScene(mcrfpy.Scene): + def __init__(self, name): + super().__init__(name) + self.enter_count = 0 + self.exit_count = 0 + self.update_count = 0 + + def on_enter(self): + self.enter_count += 1 + print(f" GameScene.on_enter() called (count: {self.enter_count})") + + def on_exit(self): + self.exit_count += 1 + print(f" GameScene.on_exit() called (count: {self.exit_count})") + + def on_keypress(self, key, action): + print(f" GameScene.on_keypress({key}, {action})") + + def update(self, dt): + self.update_count += 1 + # Note: update is called every frame, so we don't print + + game_scene = GameScene("game_scene_test") + other_scene = mcrfpy.Scene("other_scene_test") + + # Add some UI to game scene + game_scene.children.append( + mcrfpy.Caption(pos=(100, 100), text="Game Scene", fill_color=mcrfpy.Color(255, 255, 255)) + ) + + print(f" Created GameScene with {len(game_scene.children)} children") + print(f" enter_count before activation: {game_scene.enter_count}") + + # Activate - should trigger on_enter + game_scene.activate() + print(f" enter_count after activation: {game_scene.enter_count}") + + # Switch away - should trigger on_exit + other_scene.activate() + print(f" exit_count after switching: {game_scene.exit_count}") + + print(" PASS: Subclassing works correctly") + +def test_comparison_with_module_functions(): + """Demonstrate the difference between old and new approaches.""" + print("\n=== Comparison: Module Functions vs Scene Objects ===") + + print("\n OLD APPROACH (module-level functions):") + print(" mcrfpy.createScene('my_scene')") + print(" mcrfpy.setScene('my_scene')") + print(" ui = mcrfpy.sceneUI('my_scene')") + print(" ui.append(mcrfpy.Frame(...))") + print(" mcrfpy.keypressScene(handler) # ONLY works on current scene!") + + print("\n NEW APPROACH (Scene objects):") + print(" scene = mcrfpy.Scene('my_scene')") + print(" scene.activate()") + print(" scene.children.append(mcrfpy.Frame(...))") + print(" scene.on_key = handler # Works on ANY scene!") + + print("\n KEY BENEFITS:") + print(" 1. scene.on_key can be set on non-active scenes") + print(" 2. Subclassing enables on_enter/on_exit/update callbacks") + print(" 3. Object reference makes code more readable") + print(" 4. scene.children is clearer than sceneUI(name)") + + print("\n PASS: Documentation complete") + +def main(): + """Run all Scene object API tests.""" + print("=" * 60) + print("Scene Object API Test Suite") + print("=" * 60) + + try: + test_scene_object_basics() + test_scene_activation() + test_scene_on_key() + test_scene_visual_properties() + test_scene_subclass() + test_comparison_with_module_functions() + + print("\n" + "=" * 60) + print("ALL TESTS PASSED!") + print("=" * 60) + sys.exit(0) + + except Exception as e: + print(f"\nTEST FAILED: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + +if __name__ == "__main__": + main() From e64c5c147fe75c7e37c6d1bad8e22a2bdaa555da Mon Sep 17 00:00:00 2001 From: John McCardle Date: Mon, 29 Dec 2025 19:48:21 -0500 Subject: [PATCH 3/5] docs: Fix property extraction and add Scene documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Doc generator fixes (tools/generate_dynamic_docs.py): - Add types.GetSetDescriptorType detection for C++ extension properties - All 22 classes with properties now have documented Properties sections - Read-only detection via "read-only" docstring convention Scene class documentation (src/PySceneObject.h): - Expanded tp_doc with constructor, properties, lifecycle callbacks - Documents key advantage: on_key works on ANY scene - Includes usage examples for basic and subclass patterns CLAUDE.md additions: - New section "Adding Documentation for New Python Types" - Step-by-step guide for tp_doc, PyMethodDef, PyGetSetDef - Documentation extraction details and troubleshooting 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 119 +++++++++++ docs/API_REFERENCE_DYNAMIC.md | 326 +++++++++++++++++++++++++++++- docs/api_reference_dynamic.html | 348 +++++++++++++++++++++++++++++++- src/PySceneObject.h | 36 +++- tools/generate_dynamic_docs.py | 11 + 5 files changed, 835 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5f82320..027e513 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -625,4 +625,123 @@ After modifying C++ inline documentation with MCRF_* macros: 5. **No drift**: Impossible for docs and code to disagree - they're the same file! The macro system ensures complete, consistent documentation across all Python bindings. + +### Adding Documentation for New Python Types + +When adding a new Python class/type to the engine, follow these steps to ensure it's properly documented: + +#### 1. Class Docstring (tp_doc) + +In the `PyTypeObject` definition (usually in the header file), set `tp_doc` with a comprehensive docstring: + +```cpp +// In PyMyClass.h +.tp_doc = PyDoc_STR( + "MyClass(arg1: type, arg2: type)\n\n" + "Brief description of what this class does.\n\n" + "Args:\n" + " arg1: Description of first argument.\n" + " arg2: Description of second argument.\n\n" + "Properties:\n" + " prop1 (type, read-only): Description of property.\n" + " prop2 (type): Description of writable property.\n\n" + "Example:\n" + " obj = mcrfpy.MyClass('example', 42)\n" + " print(obj.prop1)\n" +), +``` + +#### 2. Method Documentation (PyMethodDef) + +For each method in the `methods[]` array, use the MCRF_* macros: + +```cpp +// In PyMyClass.cpp +PyMethodDef PyMyClass::methods[] = { + {"do_something", (PyCFunction)do_something, METH_VARARGS, + MCRF_METHOD(MyClass, do_something, + MCRF_SIG("(value: int)", "bool"), + MCRF_DESC("Does something with the value."), + MCRF_ARGS_START + MCRF_ARG("value", "The value to process") + MCRF_RETURNS("True if successful, False otherwise") + )}, + {NULL} // Sentinel +}; +``` + +#### 3. Property Documentation (PyGetSetDef) + +For each property in the `getsetters[]` array, include a docstring: + +```cpp +// In PyMyClass.cpp +PyGetSetDef PyMyClass::getsetters[] = { + {"property_name", (getter)get_property, (setter)set_property, + "Property description. Include (type, read-only) if not writable.", + NULL}, + {NULL} // Sentinel +}; +``` + +**Important for read-only properties:** Include "read-only" in the docstring so the doc generator detects it: +```cpp +{"name", (getter)get_name, NULL, // NULL setter = read-only + "Object name (str, read-only). Unique identifier.", + NULL}, +``` + +#### 4. Register Type in Module + +Ensure the type is properly registered in `McRFPy_API.cpp` and its methods/getsetters are assigned: + +```cpp +// Set methods and getsetters before PyType_Ready +mcrfpydef::PyMyClassType.tp_methods = PyMyClass::methods; +mcrfpydef::PyMyClassType.tp_getset = PyMyClass::getsetters; + +// Then call PyType_Ready and add to module +``` + +#### 5. Regenerate Documentation + +After adding the new type, regenerate all docs: + +```bash +make -j4 # Rebuild with new documentation +cd build +./mcrogueface --headless --exec ../tools/generate_dynamic_docs.py +cp docs/API_REFERENCE_DYNAMIC.md ../docs/ +cp docs/api_reference_dynamic.html ../docs/ +``` + +#### 6. Update Type Stubs (Optional) + +For IDE support, update `stubs/mcrfpy.pyi` with the new class: + +```python +class MyClass: + """Brief description.""" + def __init__(self, arg1: str, arg2: int) -> None: ... + @property + def prop1(self) -> str: ... + def do_something(self, value: int) -> bool: ... +``` + +### Documentation Extraction Details + +The doc generator (`tools/generate_dynamic_docs.py`) uses Python introspection: + +- **Classes**: Detected via `inspect.isclass()`, docstring from `cls.__doc__` +- **Methods**: Detected via `callable()` check on class attributes +- **Properties**: Detected via `types.GetSetDescriptorType` (C++ extension) or `property` (Python) +- **Read-only detection**: Checks if "read-only" appears in property docstring + +If documentation isn't appearing, verify: +1. The type is exported to the `mcrfpy` module +2. Methods/getsetters arrays are properly assigned before `PyType_Ready()` +3. Docstrings don't contain null bytes or invalid UTF-8 + +--- + - Close issues automatically in gitea by adding to the commit message "closes #X", where X is the issue number. This associates the issue closure with the specific commit, so granular commits are preferred. You should only use the MCP tool to close issues directly when discovering that the issue is already complete; when committing changes, always such "closes" (or the opposite, "reopens") references to related issues. If on a feature branch, the issue will be referenced by the commit, and when merged to master, the issue will be actually closed (or reopened). \ No newline at end of file diff --git a/docs/API_REFERENCE_DYNAMIC.md b/docs/API_REFERENCE_DYNAMIC.md index 4cf6ae8..281a658 100644 --- a/docs/API_REFERENCE_DYNAMIC.md +++ b/docs/API_REFERENCE_DYNAMIC.md @@ -1,6 +1,6 @@ # McRogueFace API Reference -*Generated on 2025-12-28 14:29:42* +*Generated on 2025-12-29 14:24:58* *This documentation was dynamically generated from the compiled module.* @@ -289,6 +289,13 @@ Note: Animation object for animating UI properties +**Properties:** +- `duration` *(read-only)*: Animation duration in seconds (float, read-only). Total time for the animation to complete. +- `elapsed` *(read-only)*: Elapsed time in seconds (float, read-only). Time since the animation started. +- `is_complete` *(read-only)*: Whether animation is complete (bool, read-only). True when elapsed >= duration or complete() was called. +- `is_delta` *(read-only)*: Whether animation uses delta mode (bool, read-only). In delta mode, the target value is added to the starting value. +- `property` *(read-only)*: Target property name (str, read-only). The property being animated (e.g., 'pos', 'opacity', 'sprite_index'). + **Methods:** #### `complete() -> None` @@ -376,6 +383,28 @@ Attributes: name (str): Element name +**Properties:** +- `bounds`: Bounding rectangle (x, y, width, height) in local coordinates. +- `center`: Center position of the arc +- `color`: Arc color +- `end_angle`: Ending angle in degrees +- `global_bounds`: Bounding rectangle (x, y, width, height) in screen coordinates. +- `global_position` *(read-only)*: Global screen position (read-only). Calculates absolute position by walking up the parent chain. +- `hovered` *(read-only)*: Whether mouse is currently over this element (read-only). Updated automatically by the engine during mouse movement. +- `name`: Name for finding this element. +- `on_click`: Callable executed when arc is clicked. +- `on_enter`: Callback for mouse enter events. Called with (x, y, button, action) when mouse enters this element's bounds. +- `on_exit`: Callback for mouse exit events. Called with (x, y, button, action) when mouse leaves this element's bounds. +- `on_move`: Callback for mouse movement within bounds. Called with (x, y, button, action) for each mouse movement while inside. Performance note: Called frequently during movement - keep handlers fast. +- `opacity`: Opacity level (0.0 = transparent, 1.0 = opaque). Automatically clamped to valid range [0.0, 1.0]. +- `parent`: Parent drawable. Get: Returns the parent Frame/Grid if nested, or None if at scene level. Set: Assign a Frame/Grid to reparent, or None to remove from parent. +- `pos`: Position as a Vector (same as center). +- `radius`: Arc radius in pixels +- `start_angle`: Starting angle in degrees +- `thickness`: Line thickness +- `visible`: Whether the object is visible (bool). Invisible objects are not rendered or clickable. +- `z_index`: Z-order for rendering (lower values rendered first). + **Methods:** #### `get_bounds() -> tuple` @@ -447,6 +476,29 @@ Attributes: name (str): Element name w, h (float): Read-only computed size based on text and font +**Properties:** +- `bounds`: Bounding rectangle (x, y, width, height) in local coordinates. +- `fill_color`: Fill color of the text +- `font_size`: Font size (integer) in points +- `global_bounds`: Bounding rectangle (x, y, width, height) in screen coordinates. +- `global_position` *(read-only)*: Global screen position (read-only). Calculates absolute position by walking up the parent chain. +- `hovered` *(read-only)*: Whether mouse is currently over this element (read-only). Updated automatically by the engine during mouse movement. +- `name`: Name for finding elements +- `on_click`: Callable executed when object is clicked. Function receives (x, y) coordinates of click. +- `on_enter`: Callback for mouse enter events. Called with (x, y, button, action) when mouse enters this element's bounds. +- `on_exit`: Callback for mouse exit events. Called with (x, y, button, action) when mouse leaves this element's bounds. +- `on_move`: Callback for mouse movement within bounds. Called with (x, y, button, action) for each mouse movement while inside. Performance note: Called frequently during movement - keep handlers fast. +- `opacity`: Opacity level (0.0 = transparent, 1.0 = opaque). Automatically clamped to valid range [0.0, 1.0]. +- `outline`: Thickness of the border +- `outline_color`: Outline color of the text +- `parent`: Parent drawable. Get: Returns the parent Frame/Grid if nested, or None if at scene level. Set: Assign a Frame/Grid to reparent, or None to remove from parent. +- `pos`: (x, y) vector +- `text`: The text displayed +- `visible`: Whether the object is visible (bool). Invisible objects are not rendered or clickable. +- `x`: X coordinate of top-left corner +- `y`: Y coordinate of top-left corner +- `z_index`: Z-order for rendering (lower values rendered first). Automatically triggers scene resort when changed. + **Methods:** #### `get_bounds() -> tuple` @@ -511,6 +563,27 @@ Attributes: name (str): Element name +**Properties:** +- `bounds`: Bounding rectangle (x, y, width, height) in local coordinates. +- `center`: Center position of the circle +- `fill_color`: Fill color of the circle +- `global_bounds`: Bounding rectangle (x, y, width, height) in screen coordinates. +- `global_position` *(read-only)*: Global screen position (read-only). Calculates absolute position by walking up the parent chain. +- `hovered` *(read-only)*: Whether mouse is currently over this element (read-only). Updated automatically by the engine during mouse movement. +- `name`: Name for finding this element. +- `on_click`: Callable executed when circle is clicked. +- `on_enter`: Callback for mouse enter events. Called with (x, y, button, action) when mouse enters this element's bounds. +- `on_exit`: Callback for mouse exit events. Called with (x, y, button, action) when mouse leaves this element's bounds. +- `on_move`: Callback for mouse movement within bounds. Called with (x, y, button, action) for each mouse movement while inside. Performance note: Called frequently during movement - keep handlers fast. +- `opacity`: Opacity level (0.0 = transparent, 1.0 = opaque). Automatically clamped to valid range [0.0, 1.0]. +- `outline`: Outline thickness (0 for no outline) +- `outline_color`: Outline color of the circle +- `parent`: Parent drawable. Get: Returns the parent Frame/Grid if nested, or None if at scene level. Set: Assign a Frame/Grid to reparent, or None to remove from parent. +- `pos`: Position as a Vector (same as center). +- `radius`: Circle radius in pixels +- `visible`: Whether the object is visible (bool). Invisible objects are not rendered or clickable. +- `z_index`: Z-order for rendering (lower values rendered first). + **Methods:** #### `get_bounds() -> tuple` @@ -545,6 +618,12 @@ Note: SFML Color Object +**Properties:** +- `a`: Alpha component (0-255, where 0=transparent, 255=opaque). Automatically clamped to valid range. +- `b`: Blue component (0-255). Automatically clamped to valid range. +- `g`: Green component (0-255). Automatically clamped to valid range. +- `r`: Red component (0-255). Automatically clamped to valid range. + **Methods:** #### `from_hex(hex_string: str) -> Color` @@ -600,6 +679,11 @@ Methods: set(x, y, color): Set color at cell position fill(color): Fill entire layer with color +**Properties:** +- `grid_size`: Layer dimensions as (width, height) tuple. +- `visible`: Whether the layer is rendered. +- `z_index`: Layer z-order. Negative values render below entities. + **Methods:** #### `apply_perspective(entity, visible=None, discovered=None, unknown=None)` @@ -644,6 +728,12 @@ Call this after the entity moves to update the visibility layer. Base class for all drawable UI elements +**Properties:** +- `on_click`: Callable executed when object is clicked. Function receives (x, y) coordinates of click. +- `opacity`: Opacity level (0.0 = transparent, 1.0 = opaque). Automatically clamped to valid range [0.0, 1.0]. +- `visible`: Whether the object is visible (bool). Invisible objects are not rendered or clickable. +- `z_index`: Z-order for rendering (lower values rendered first). Automatically triggers scene resort when changed. + **Methods:** #### `get_bounds() -> tuple` @@ -703,6 +793,19 @@ Attributes: opacity (float): Opacity value name (str): Element name +**Properties:** +- `draw_pos`: Entity position (graphically) +- `grid`: Grid this entity belongs to. Get: Returns the Grid or None. Set: Assign a Grid to move entity, or None to remove from grid. +- `gridstate`: Grid point states for the entity +- `name`: Name for finding elements +- `opacity`: Opacity (0.0 = transparent, 1.0 = opaque) +- `pos`: Entity position (integer grid coordinates) +- `sprite_index`: Sprite index on the texture on the display +- `sprite_number`: Sprite index (DEPRECATED: use sprite_index instead) +- `visible`: Visibility flag +- `x`: Entity x position +- `y`: Entity y position + **Methods:** #### `at(...)` @@ -810,6 +913,12 @@ Remove first occurrence of entity. Raises ValueError if not found. *Inherits from: IntEnum* +**Properties:** +- `denominator`: the denominator of a rational number in lowest terms +- `imag`: the imaginary part of a complex number +- `numerator`: the numerator of a rational number in lowest terms +- `real`: the real part of a complex number + **Methods:** #### `as_integer_ratio(...)` @@ -887,6 +996,10 @@ Return an array of bytes representing an integer. SFML Font Object +**Properties:** +- `family` *(read-only)*: Font family name (str, read-only). Retrieved from font metadata. +- `source` *(read-only)*: Source filename path (str, read-only). The path used to load this font. + **Methods:** ### Frame @@ -933,6 +1046,32 @@ Attributes: clip_children (bool): Whether to clip children to frame bounds cache_subtree (bool): Cache subtree rendering to texture +**Properties:** +- `bounds`: Bounding rectangle (x, y, width, height) in local coordinates. +- `cache_subtree`: #144: Cache subtree rendering to texture for performance +- `children`: UICollection of objects on top of this one +- `clip_children`: Whether to clip children to frame bounds +- `fill_color`: Fill color of the rectangle +- `global_bounds`: Bounding rectangle (x, y, width, height) in screen coordinates. +- `global_position` *(read-only)*: Global screen position (read-only). Calculates absolute position by walking up the parent chain. +- `h`: height of the rectangle +- `hovered` *(read-only)*: Whether mouse is currently over this element (read-only). Updated automatically by the engine during mouse movement. +- `name`: Name for finding elements +- `on_click`: Callable executed when object is clicked. Function receives (x, y) coordinates of click. +- `on_enter`: Callback for mouse enter events. Called with (x, y, button, action) when mouse enters this element's bounds. +- `on_exit`: Callback for mouse exit events. Called with (x, y, button, action) when mouse leaves this element's bounds. +- `on_move`: Callback for mouse movement within bounds. Called with (x, y, button, action) for each mouse movement while inside. Performance note: Called frequently during movement - keep handlers fast. +- `opacity`: Opacity level (0.0 = transparent, 1.0 = opaque). Automatically clamped to valid range [0.0, 1.0]. +- `outline`: Thickness of the border +- `outline_color`: Outline color of the rectangle +- `parent`: Parent drawable. Get: Returns the parent Frame/Grid if nested, or None if at scene level. Set: Assign a Frame/Grid to reparent, or None to remove from parent. +- `pos`: Position as a Vector +- `visible`: Whether the object is visible (bool). Invisible objects are not rendered or clickable. +- `w`: width of the rectangle +- `x`: X coordinate of top-left corner +- `y`: Y coordinate of top-left corner +- `z_index`: Z-order for rendering (lower values rendered first). Automatically triggers scene resort when changed. + **Methods:** #### `get_bounds() -> tuple` @@ -1015,6 +1154,48 @@ Attributes: z_index (int): Rendering order name (str): Element name +**Properties:** +- `bounds`: Bounding rectangle (x, y, width, height) in local coordinates. +- `center`: Grid coordinate at the center of the Grid's view (pan) +- `center_x`: center of the view X-coordinate +- `center_y`: center of the view Y-coordinate +- `children`: UICollection of UIDrawable children (speech bubbles, effects, overlays) +- `entities`: EntityCollection of entities on this grid +- `fill_color`: Background fill color of the grid +- `fov`: FOV algorithm for this grid (mcrfpy.FOV enum). Used by entity.updateVisibility() and layer methods when fov=None. +- `fov_radius`: Default FOV radius for this grid. Used when radius not specified. +- `global_bounds`: Bounding rectangle (x, y, width, height) in screen coordinates. +- `global_position` *(read-only)*: Global screen position (read-only). Calculates absolute position by walking up the parent chain. +- `grid_size`: Grid dimensions (grid_x, grid_y) +- `grid_x`: Grid x dimension +- `grid_y`: Grid y dimension +- `h`: visible widget height +- `hovered` *(read-only)*: Whether mouse is currently over this element (read-only). Updated automatically by the engine during mouse movement. +- `hovered_cell`: Currently hovered cell as (x, y) tuple, or None if not hovering. +- `layers`: List of grid layers (ColorLayer, TileLayer) sorted by z_index +- `name`: Name for finding elements +- `on_cell_click`: Callback when a grid cell is clicked. Called with (cell_x, cell_y). +- `on_cell_enter`: Callback when mouse enters a grid cell. Called with (cell_x, cell_y). +- `on_cell_exit`: Callback when mouse exits a grid cell. Called with (cell_x, cell_y). +- `on_click`: Callable executed when object is clicked. Function receives (x, y) coordinates of click. +- `on_enter`: Callback for mouse enter events. Called with (x, y, button, action) when mouse enters this element's bounds. +- `on_exit`: Callback for mouse exit events. Called with (x, y, button, action) when mouse leaves this element's bounds. +- `on_move`: Callback for mouse movement within bounds. Called with (x, y, button, action) for each mouse movement while inside. Performance note: Called frequently during movement - keep handlers fast. +- `opacity`: Opacity level (0.0 = transparent, 1.0 = opaque). Automatically clamped to valid range [0.0, 1.0]. +- `parent`: Parent drawable. Get: Returns the parent Frame/Grid if nested, or None if at scene level. Set: Assign a Frame/Grid to reparent, or None to remove from parent. +- `perspective`: Entity whose perspective to use for FOV rendering (None for omniscient view). Setting an entity automatically enables perspective mode. +- `perspective_enabled`: Whether to use perspective-based FOV rendering. When True with no valid entity, all cells appear undiscovered. +- `pos`: Position of the grid as Vector +- `position`: Position of the grid (x, y) +- `size`: Size of the grid (width, height) +- `texture`: Texture of the grid +- `visible`: Whether the object is visible (bool). Invisible objects are not rendered or clickable. +- `w`: visible widget width +- `x`: top-left corner X-coordinate +- `y`: top-left corner Y-coordinate +- `z_index`: Z-order for rendering (lower values rendered first). Automatically triggers scene resort when changed. +- `zoom`: zoom factor for displaying the Grid + **Methods:** #### `add_layer(type: str, z_index: int = -1, texture: Texture = None) -> ColorLayer | TileLayer` @@ -1165,12 +1346,22 @@ Note: UIGridPoint object +**Properties:** +- `entities` *(read-only)*: List of entities at this grid cell (read-only) +- `transparent`: Is the GridPoint transparent +- `walkable`: Is the GridPoint walkable + **Methods:** ### GridPointState UIGridPointState object +**Properties:** +- `discovered`: Has the GridPointState been discovered +- `point`: GridPoint at this position (None if not discovered) +- `visible`: Is the GridPointState visible + **Methods:** ### Line @@ -1205,6 +1396,26 @@ Attributes: name (str): Element name +**Properties:** +- `bounds`: Bounding rectangle (x, y, width, height) in local coordinates. +- `color`: Line color as a Color object. +- `end`: Ending point of the line as a Vector. +- `global_bounds`: Bounding rectangle (x, y, width, height) in screen coordinates. +- `global_position` *(read-only)*: Global screen position (read-only). Calculates absolute position by walking up the parent chain. +- `hovered` *(read-only)*: Whether mouse is currently over this element (read-only). Updated automatically by the engine during mouse movement. +- `name`: Name for finding this element. +- `on_click`: Callable executed when line is clicked. +- `on_enter`: Callback for mouse enter events. Called with (x, y, button, action) when mouse enters this element's bounds. +- `on_exit`: Callback for mouse exit events. Called with (x, y, button, action) when mouse leaves this element's bounds. +- `on_move`: Callback for mouse movement within bounds. Called with (x, y, button, action) for each mouse movement while inside. Performance note: Called frequently during movement - keep handlers fast. +- `opacity`: Opacity level (0.0 = transparent, 1.0 = opaque). Automatically clamped to valid range [0.0, 1.0]. +- `parent`: Parent drawable. Get: Returns the parent Frame/Grid if nested, or None if at scene level. Set: Assign a Frame/Grid to reparent, or None to remove from parent. +- `pos`: Position as a Vector (midpoint of line). +- `start`: Starting point of the line as a Vector. +- `thickness`: Line thickness in pixels. +- `visible`: Whether the object is visible (bool). Invisible objects are not rendered or clickable. +- `z_index`: Z-order for rendering (lower values rendered first). + **Methods:** #### `get_bounds() -> tuple` @@ -1237,7 +1448,56 @@ Note: ### Scene -Base class for object-oriented scenes +Scene(name: str) + +Object-oriented scene management with lifecycle callbacks. + +This is the recommended approach for scene management, replacing module-level +functions like createScene(), setScene(), and sceneUI(). Key advantage: you can +set on_key handlers on ANY scene, not just the currently active one. + +Args: + name: Unique identifier for this scene. Used for scene transitions. + +Properties: + name (str, read-only): Scene's unique identifier. + active (bool, read-only): Whether this scene is currently displayed. + children (UICollection, read-only): UI elements in this scene. Modify to add/remove elements. + on_key (callable): Keyboard handler. Set on ANY scene, regardless of which is active! + pos (Vector): Position offset for all UI elements. + visible (bool): Whether the scene renders. + opacity (float): Scene transparency (0.0-1.0). + +Lifecycle Callbacks (override in subclass): + on_enter(): Called when scene becomes active via activate(). + on_exit(): Called when scene is deactivated (another scene activates). + on_keypress(key: str, action: str): Called for keyboard events. Alternative to on_key property. + update(dt: float): Called every frame with delta time in seconds. + on_resize(width: int, height: int): Called when window is resized. + +Example: + # Basic usage (replacing module functions): + scene = mcrfpy.Scene('main_menu') + scene.children.append(mcrfpy.Caption(text='Welcome', pos=(100, 100))) + scene.on_key = lambda key, action: print(f'Key: {key}') + scene.activate() # Switch to this scene + + # Subclassing for lifecycle: + class GameScene(mcrfpy.Scene): + def on_enter(self): + print('Game started!') + def update(self, dt): + self.player.move(dt) + + +**Properties:** +- `active` *(read-only)*: Whether this scene is currently active (bool, read-only). Only one scene can be active at a time. +- `children` *(read-only)*: UI element collection for this scene (UICollection, read-only). Use to add, remove, or iterate over UI elements. Changes are reflected immediately. +- `name` *(read-only)*: Scene name (str, read-only). Unique identifier for this scene. +- `on_key`: Keyboard event handler (callable or None). Function receives (key: str, action: str) for keyboard events. Set to None to remove the handler. +- `opacity`: Scene opacity (0.0-1.0). Applied to all UI elements during rendering. +- `pos`: Scene position offset (Vector). Applied to all UI elements during rendering. +- `visible`: Scene visibility (bool). If False, scene is not rendered. **Methods:** @@ -1299,6 +1559,30 @@ Attributes: name (str): Element name w, h (float): Read-only computed size based on texture and scale +**Properties:** +- `bounds`: Bounding rectangle (x, y, width, height) in local coordinates. +- `global_bounds`: Bounding rectangle (x, y, width, height) in screen coordinates. +- `global_position` *(read-only)*: Global screen position (read-only). Calculates absolute position by walking up the parent chain. +- `hovered` *(read-only)*: Whether mouse is currently over this element (read-only). Updated automatically by the engine during mouse movement. +- `name`: Name for finding elements +- `on_click`: Callable executed when object is clicked. Function receives (x, y) coordinates of click. +- `on_enter`: Callback for mouse enter events. Called with (x, y, button, action) when mouse enters this element's bounds. +- `on_exit`: Callback for mouse exit events. Called with (x, y, button, action) when mouse leaves this element's bounds. +- `on_move`: Callback for mouse movement within bounds. Called with (x, y, button, action) for each mouse movement while inside. Performance note: Called frequently during movement - keep handlers fast. +- `opacity`: Opacity level (0.0 = transparent, 1.0 = opaque). Automatically clamped to valid range [0.0, 1.0]. +- `parent`: Parent drawable. Get: Returns the parent Frame/Grid if nested, or None if at scene level. Set: Assign a Frame/Grid to reparent, or None to remove from parent. +- `pos`: Position as a Vector +- `scale`: Uniform size factor +- `scale_x`: Horizontal scale factor +- `scale_y`: Vertical scale factor +- `sprite_index`: Which sprite on the texture is shown +- `sprite_number`: Sprite index (DEPRECATED: use sprite_index instead) +- `texture`: Texture object +- `visible`: Whether the object is visible (bool). Invisible objects are not rendered or clickable. +- `x`: X coordinate of top-left corner +- `y`: Y coordinate of top-left corner +- `z_index`: Z-order for rendering (lower values rendered first). Automatically triggers scene resort when changed. + **Methods:** #### `get_bounds() -> tuple` @@ -1333,6 +1617,14 @@ Note: SFML Texture Object +**Properties:** +- `sheet_height` *(read-only)*: Number of sprite rows in the texture sheet (int, read-only). Calculated as texture_height / sprite_height. +- `sheet_width` *(read-only)*: Number of sprite columns in the texture sheet (int, read-only). Calculated as texture_width / sprite_width. +- `source` *(read-only)*: Source filename path (str, read-only). The path used to load this texture. +- `sprite_count` *(read-only)*: Total number of sprites in the texture sheet (int, read-only). Equals sheet_width * sheet_height. +- `sprite_height` *(read-only)*: Height of each sprite in pixels (int, read-only). Specified during texture initialization. +- `sprite_width` *(read-only)*: Width of each sprite in pixels (int, read-only). Specified during texture initialization. + **Methods:** ### TileLayer @@ -1357,6 +1649,12 @@ Methods: set(x, y, index): Set tile index at cell position fill(index): Fill entire layer with tile index +**Properties:** +- `grid_size`: Layer dimensions as (width, height) tuple. +- `texture`: Texture atlas for tile sprites. +- `visible`: Whether the layer is rendered. +- `z_index`: Layer z-order. Negative values render below entities. + **Methods:** #### `at(x, y) -> int` @@ -1412,6 +1710,15 @@ Example: timer.resume() # Resume timer timer.once = True # Make it one-shot +**Properties:** +- `active` *(read-only)*: Whether the timer is active and not paused (bool, read-only). False if cancelled or paused. +- `callback`: The callback function to be called when timer fires (callable). Can be changed while timer is running. +- `interval`: Timer interval in milliseconds (int). Must be positive. Can be changed while timer is running. +- `name` *(read-only)*: Timer name (str, read-only). Unique identifier for this timer. +- `once`: Whether the timer stops after firing once (bool). If False, timer repeats indefinitely. +- `paused` *(read-only)*: Whether the timer is paused (bool, read-only). Paused timers preserve their remaining time. +- `remaining` *(read-only)*: Time remaining until next trigger in milliseconds (int, read-only). Preserved when timer is paused. + **Methods:** #### `cancel() -> None` @@ -1508,6 +1815,11 @@ Iterator for a collection of UI objects SFML Vector Object +**Properties:** +- `int` *(read-only)*: Integer tuple (floor of x and y) for use as dict keys. Read-only. +- `x`: X coordinate of the vector (float) +- `y`: Y coordinate of the vector (float) + **Methods:** #### `angle() -> float` @@ -1574,6 +1886,16 @@ Note: Window singleton for accessing and modifying the game window properties +**Properties:** +- `framerate_limit`: Frame rate limit in FPS (int, 0 for unlimited). Caps maximum frame rate. +- `fullscreen`: Window fullscreen state (bool). Setting this recreates the window. +- `game_resolution`: Fixed game resolution as (width, height) tuple. Enables resolution-independent rendering with scaling. +- `resolution`: Window resolution as (width, height) tuple. Setting this recreates the window. +- `scaling_mode`: Viewport scaling mode (str): 'center' (no scaling), 'stretch' (fill window), or 'fit' (maintain aspect ratio). +- `title`: Window title string (str). Displayed in the window title bar. +- `visible`: Window visibility state (bool). Hidden windows still process events. +- `vsync`: Vertical sync enabled state (bool). Prevents screen tearing but may limit framerate. + **Methods:** #### `center() -> None` diff --git a/docs/api_reference_dynamic.html b/docs/api_reference_dynamic.html index d41d591..bb50ca5 100644 --- a/docs/api_reference_dynamic.html +++ b/docs/api_reference_dynamic.html @@ -108,7 +108,7 @@

McRogueFace API Reference

-

Generated on 2025-12-28 14:29:42

+

Generated on 2025-12-29 14:23:40

This documentation was dynamically generated from the compiled module.

@@ -410,6 +410,14 @@ Note:

Animation

Animation object for animating UI properties

+

Properties:

+
    +
  • duration (read-only): Animation duration in seconds (float, read-only). Total time for the animation to complete.
  • +
  • elapsed (read-only): Elapsed time in seconds (float, read-only). Time since the animation started.
  • +
  • is_complete (read-only): Whether animation is complete (bool, read-only). True when elapsed >= duration or complete() was called.
  • +
  • is_delta (read-only): Whether animation uses delta mode (bool, read-only). In delta mode, the target value is added to the starting value.
  • +
  • property (read-only): Target property name (str, read-only). The property being animated (e.g., 'pos', 'opacity', 'sprite_index').
  • +

Methods:

@@ -495,6 +503,29 @@ Attributes: z_index (int): Rendering order name (str): Element name

+

Properties:

+
    +
  • bounds: Bounding rectangle (x, y, width, height) in local coordinates.
  • +
  • center: Center position of the arc
  • +
  • color: Arc color
  • +
  • end_angle: Ending angle in degrees
  • +
  • global_bounds: Bounding rectangle (x, y, width, height) in screen coordinates.
  • +
  • global_position (read-only): Global screen position (read-only). Calculates absolute position by walking up the parent chain.
  • +
  • hovered (read-only): Whether mouse is currently over this element (read-only). Updated automatically by the engine during mouse movement.
  • +
  • name: Name for finding this element.
  • +
  • on_click: Callable executed when arc is clicked.
  • +
  • on_enter: Callback for mouse enter events. Called with (x, y, button, action) when mouse enters this element's bounds.
  • +
  • on_exit: Callback for mouse exit events. Called with (x, y, button, action) when mouse leaves this element's bounds.
  • +
  • on_move: Callback for mouse movement within bounds. Called with (x, y, button, action) for each mouse movement while inside. Performance note: Called frequently during movement - keep handlers fast.
  • +
  • opacity: Opacity level (0.0 = transparent, 1.0 = opaque). Automatically clamped to valid range [0.0, 1.0].
  • +
  • parent: Parent drawable. Get: Returns the parent Frame/Grid if nested, or None if at scene level. Set: Assign a Frame/Grid to reparent, or None to remove from parent.
  • +
  • pos: Position as a Vector (same as center).
  • +
  • radius: Arc radius in pixels
  • +
  • start_angle: Starting angle in degrees
  • +
  • thickness: Line thickness
  • +
  • visible: Whether the object is visible (bool). Invisible objects are not rendered or clickable.
  • +
  • z_index: Z-order for rendering (lower values rendered first).
  • +

Methods:

@@ -567,6 +598,30 @@ Attributes: z_index (int): Rendering order name (str): Element name w, h (float): Read-only computed size based on text and font

+

Properties:

+
    +
  • bounds: Bounding rectangle (x, y, width, height) in local coordinates.
  • +
  • fill_color: Fill color of the text
  • +
  • font_size: Font size (integer) in points
  • +
  • global_bounds: Bounding rectangle (x, y, width, height) in screen coordinates.
  • +
  • global_position (read-only): Global screen position (read-only). Calculates absolute position by walking up the parent chain.
  • +
  • hovered (read-only): Whether mouse is currently over this element (read-only). Updated automatically by the engine during mouse movement.
  • +
  • name: Name for finding elements
  • +
  • on_click: Callable executed when object is clicked. Function receives (x, y) coordinates of click.
  • +
  • on_enter: Callback for mouse enter events. Called with (x, y, button, action) when mouse enters this element's bounds.
  • +
  • on_exit: Callback for mouse exit events. Called with (x, y, button, action) when mouse leaves this element's bounds.
  • +
  • on_move: Callback for mouse movement within bounds. Called with (x, y, button, action) for each mouse movement while inside. Performance note: Called frequently during movement - keep handlers fast.
  • +
  • opacity: Opacity level (0.0 = transparent, 1.0 = opaque). Automatically clamped to valid range [0.0, 1.0].
  • +
  • outline: Thickness of the border
  • +
  • outline_color: Outline color of the text
  • +
  • parent: Parent drawable. Get: Returns the parent Frame/Grid if nested, or None if at scene level. Set: Assign a Frame/Grid to reparent, or None to remove from parent.
  • +
  • pos: (x, y) vector
  • +
  • text: The text displayed
  • +
  • visible: Whether the object is visible (bool). Invisible objects are not rendered or clickable.
  • +
  • x: X coordinate of top-left corner
  • +
  • y: Y coordinate of top-left corner
  • +
  • z_index: Z-order for rendering (lower values rendered first). Automatically triggers scene resort when changed.
  • +

Methods:

@@ -632,6 +687,28 @@ Attributes: z_index (int): Rendering order name (str): Element name

+

Properties:

+
    +
  • bounds: Bounding rectangle (x, y, width, height) in local coordinates.
  • +
  • center: Center position of the circle
  • +
  • fill_color: Fill color of the circle
  • +
  • global_bounds: Bounding rectangle (x, y, width, height) in screen coordinates.
  • +
  • global_position (read-only): Global screen position (read-only). Calculates absolute position by walking up the parent chain.
  • +
  • hovered (read-only): Whether mouse is currently over this element (read-only). Updated automatically by the engine during mouse movement.
  • +
  • name: Name for finding this element.
  • +
  • on_click: Callable executed when circle is clicked.
  • +
  • on_enter: Callback for mouse enter events. Called with (x, y, button, action) when mouse enters this element's bounds.
  • +
  • on_exit: Callback for mouse exit events. Called with (x, y, button, action) when mouse leaves this element's bounds.
  • +
  • on_move: Callback for mouse movement within bounds. Called with (x, y, button, action) for each mouse movement while inside. Performance note: Called frequently during movement - keep handlers fast.
  • +
  • opacity: Opacity level (0.0 = transparent, 1.0 = opaque). Automatically clamped to valid range [0.0, 1.0].
  • +
  • outline: Outline thickness (0 for no outline)
  • +
  • outline_color: Outline color of the circle
  • +
  • parent: Parent drawable. Get: Returns the parent Frame/Grid if nested, or None if at scene level. Set: Assign a Frame/Grid to reparent, or None to remove from parent.
  • +
  • pos: Position as a Vector (same as center).
  • +
  • radius: Circle radius in pixels
  • +
  • visible: Whether the object is visible (bool). Invisible objects are not rendered or clickable.
  • +
  • z_index: Z-order for rendering (lower values rendered first).
  • +

Methods:

@@ -668,6 +745,13 @@ Note:

Color

SFML Color Object

+

Properties:

+
    +
  • a: Alpha component (0-255, where 0=transparent, 255=opaque). Automatically clamped to valid range.
  • +
  • b: Blue component (0-255). Automatically clamped to valid range.
  • +
  • g: Green component (0-255). Automatically clamped to valid range.
  • +
  • r: Red component (0-255). Automatically clamped to valid range.
  • +

Methods:

@@ -722,6 +806,12 @@ Methods: at(x, y): Get color at cell position set(x, y, color): Set color at cell position fill(color): Fill entire layer with color

+

Properties:

+
    +
  • grid_size: Layer dimensions as (width, height) tuple.
  • +
  • visible: Whether the layer is rendered.
  • +
  • z_index: Layer z-order. Negative values render below entities.
  • +

Methods:

@@ -774,6 +864,13 @@ Call this after the entity moves to update the visibility layer.

Drawable

Base class for all drawable UI elements

+

Properties:

+
    +
  • on_click: Callable executed when object is clicked. Function receives (x, y) coordinates of click.
  • +
  • opacity: Opacity level (0.0 = transparent, 1.0 = opaque). Automatically clamped to valid range [0.0, 1.0].
  • +
  • visible: Whether the object is visible (bool). Invisible objects are not rendered or clickable.
  • +
  • z_index: Z-order for rendering (lower values rendered first). Automatically triggers scene resort when changed.
  • +

Methods:

@@ -835,6 +932,20 @@ Attributes: visible (bool): Visibility state opacity (float): Opacity value name (str): Element name

+

Properties:

+
    +
  • draw_pos: Entity position (graphically)
  • +
  • grid: Grid this entity belongs to. Get: Returns the Grid or None. Set: Assign a Grid to move entity, or None to remove from grid.
  • +
  • gridstate: Grid point states for the entity
  • +
  • name: Name for finding elements
  • +
  • opacity: Opacity (0.0 = transparent, 1.0 = opaque)
  • +
  • pos: Entity position (integer grid coordinates)
  • +
  • sprite_index: Sprite index on the texture on the display
  • +
  • sprite_number: Sprite index (DEPRECATED: use sprite_index instead)
  • +
  • visible: Visibility flag
  • +
  • x: Entity x position
  • +
  • y: Entity y position
  • +

Methods:

@@ -956,6 +1067,13 @@ when the entity moves if it has a grid with perspective set.

FOV

Inherits from: IntEnum

+

Properties:

+
    +
  • denominator: the denominator of a rational number in lowest terms
  • +
  • imag: the imaginary part of a complex number
  • +
  • numerator: the numerator of a rational number in lowest terms
  • +
  • real: the real part of a complex number
  • +

Methods:

@@ -1040,6 +1158,11 @@ Also known as the population count.

Font

SFML Font Object

+

Properties:

+
    +
  • family (read-only): Font family name (str, read-only). Retrieved from font metadata.
  • +
  • source (read-only): Source filename path (str, read-only). The path used to load this font.
  • +

Methods:

@@ -1085,6 +1208,33 @@ Attributes: name (str): Element name clip_children (bool): Whether to clip children to frame bounds cache_subtree (bool): Cache subtree rendering to texture

+

Properties:

+
    +
  • bounds: Bounding rectangle (x, y, width, height) in local coordinates.
  • +
  • cache_subtree: #144: Cache subtree rendering to texture for performance
  • +
  • children: UICollection of objects on top of this one
  • +
  • clip_children: Whether to clip children to frame bounds
  • +
  • fill_color: Fill color of the rectangle
  • +
  • global_bounds: Bounding rectangle (x, y, width, height) in screen coordinates.
  • +
  • global_position (read-only): Global screen position (read-only). Calculates absolute position by walking up the parent chain.
  • +
  • h: height of the rectangle
  • +
  • hovered (read-only): Whether mouse is currently over this element (read-only). Updated automatically by the engine during mouse movement.
  • +
  • name: Name for finding elements
  • +
  • on_click: Callable executed when object is clicked. Function receives (x, y) coordinates of click.
  • +
  • on_enter: Callback for mouse enter events. Called with (x, y, button, action) when mouse enters this element's bounds.
  • +
  • on_exit: Callback for mouse exit events. Called with (x, y, button, action) when mouse leaves this element's bounds.
  • +
  • on_move: Callback for mouse movement within bounds. Called with (x, y, button, action) for each mouse movement while inside. Performance note: Called frequently during movement - keep handlers fast.
  • +
  • opacity: Opacity level (0.0 = transparent, 1.0 = opaque). Automatically clamped to valid range [0.0, 1.0].
  • +
  • outline: Thickness of the border
  • +
  • outline_color: Outline color of the rectangle
  • +
  • parent: Parent drawable. Get: Returns the parent Frame/Grid if nested, or None if at scene level. Set: Assign a Frame/Grid to reparent, or None to remove from parent.
  • +
  • pos: Position as a Vector
  • +
  • visible: Whether the object is visible (bool). Invisible objects are not rendered or clickable.
  • +
  • w: width of the rectangle
  • +
  • x: X coordinate of top-left corner
  • +
  • y: Y coordinate of top-left corner
  • +
  • z_index: Z-order for rendering (lower values rendered first). Automatically triggers scene resort when changed.
  • +

Methods:

@@ -1168,6 +1318,49 @@ Attributes: opacity (float): Opacity value z_index (int): Rendering order name (str): Element name

+

Properties:

+
    +
  • bounds: Bounding rectangle (x, y, width, height) in local coordinates.
  • +
  • center: Grid coordinate at the center of the Grid's view (pan)
  • +
  • center_x: center of the view X-coordinate
  • +
  • center_y: center of the view Y-coordinate
  • +
  • children: UICollection of UIDrawable children (speech bubbles, effects, overlays)
  • +
  • entities: EntityCollection of entities on this grid
  • +
  • fill_color: Background fill color of the grid
  • +
  • fov: FOV algorithm for this grid (mcrfpy.FOV enum). Used by entity.updateVisibility() and layer methods when fov=None.
  • +
  • fov_radius: Default FOV radius for this grid. Used when radius not specified.
  • +
  • global_bounds: Bounding rectangle (x, y, width, height) in screen coordinates.
  • +
  • global_position (read-only): Global screen position (read-only). Calculates absolute position by walking up the parent chain.
  • +
  • grid_size: Grid dimensions (grid_x, grid_y)
  • +
  • grid_x: Grid x dimension
  • +
  • grid_y: Grid y dimension
  • +
  • h: visible widget height
  • +
  • hovered (read-only): Whether mouse is currently over this element (read-only). Updated automatically by the engine during mouse movement.
  • +
  • hovered_cell: Currently hovered cell as (x, y) tuple, or None if not hovering.
  • +
  • layers: List of grid layers (ColorLayer, TileLayer) sorted by z_index
  • +
  • name: Name for finding elements
  • +
  • on_cell_click: Callback when a grid cell is clicked. Called with (cell_x, cell_y).
  • +
  • on_cell_enter: Callback when mouse enters a grid cell. Called with (cell_x, cell_y).
  • +
  • on_cell_exit: Callback when mouse exits a grid cell. Called with (cell_x, cell_y).
  • +
  • on_click: Callable executed when object is clicked. Function receives (x, y) coordinates of click.
  • +
  • on_enter: Callback for mouse enter events. Called with (x, y, button, action) when mouse enters this element's bounds.
  • +
  • on_exit: Callback for mouse exit events. Called with (x, y, button, action) when mouse leaves this element's bounds.
  • +
  • on_move: Callback for mouse movement within bounds. Called with (x, y, button, action) for each mouse movement while inside. Performance note: Called frequently during movement - keep handlers fast.
  • +
  • opacity: Opacity level (0.0 = transparent, 1.0 = opaque). Automatically clamped to valid range [0.0, 1.0].
  • +
  • parent: Parent drawable. Get: Returns the parent Frame/Grid if nested, or None if at scene level. Set: Assign a Frame/Grid to reparent, or None to remove from parent.
  • +
  • perspective: Entity whose perspective to use for FOV rendering (None for omniscient view). Setting an entity automatically enables perspective mode.
  • +
  • perspective_enabled: Whether to use perspective-based FOV rendering. When True with no valid entity, all cells appear undiscovered.
  • +
  • pos: Position of the grid as Vector
  • +
  • position: Position of the grid (x, y)
  • +
  • size: Size of the grid (width, height)
  • +
  • texture: Texture of the grid
  • +
  • visible: Whether the object is visible (bool). Invisible objects are not rendered or clickable.
  • +
  • w: visible widget width
  • +
  • x: top-left corner X-coordinate
  • +
  • y: top-left corner Y-coordinate
  • +
  • z_index: Z-order for rendering (lower values rendered first). Automatically triggers scene resort when changed.
  • +
  • zoom: zoom factor for displaying the Grid
  • +

Methods:

@@ -1325,12 +1518,24 @@ Note:

GridPoint

UIGridPoint object

+

Properties:

+
    +
  • entities (read-only): List of entities at this grid cell (read-only)
  • +
  • transparent: Is the GridPoint transparent
  • +
  • walkable: Is the GridPoint walkable
  • +

Methods:

GridPointState

UIGridPointState object

+

Properties:

+
    +
  • discovered: Has the GridPointState been discovered
  • +
  • point: GridPoint at this position (None if not discovered)
  • +
  • visible: Is the GridPointState visible
  • +

Methods:

@@ -1364,6 +1569,27 @@ Attributes: z_index (int): Rendering order name (str): Element name

+

Properties:

+
    +
  • bounds: Bounding rectangle (x, y, width, height) in local coordinates.
  • +
  • color: Line color as a Color object.
  • +
  • end: Ending point of the line as a Vector.
  • +
  • global_bounds: Bounding rectangle (x, y, width, height) in screen coordinates.
  • +
  • global_position (read-only): Global screen position (read-only). Calculates absolute position by walking up the parent chain.
  • +
  • hovered (read-only): Whether mouse is currently over this element (read-only). Updated automatically by the engine during mouse movement.
  • +
  • name: Name for finding this element.
  • +
  • on_click: Callable executed when line is clicked.
  • +
  • on_enter: Callback for mouse enter events. Called with (x, y, button, action) when mouse enters this element's bounds.
  • +
  • on_exit: Callback for mouse exit events. Called with (x, y, button, action) when mouse leaves this element's bounds.
  • +
  • on_move: Callback for mouse movement within bounds. Called with (x, y, button, action) for each mouse movement while inside. Performance note: Called frequently during movement - keep handlers fast.
  • +
  • opacity: Opacity level (0.0 = transparent, 1.0 = opaque). Automatically clamped to valid range [0.0, 1.0].
  • +
  • parent: Parent drawable. Get: Returns the parent Frame/Grid if nested, or None if at scene level. Set: Assign a Frame/Grid to reparent, or None to remove from parent.
  • +
  • pos: Position as a Vector (midpoint of line).
  • +
  • start: Starting point of the line as a Vector.
  • +
  • thickness: Line thickness in pixels.
  • +
  • visible: Whether the object is visible (bool). Invisible objects are not rendered or clickable.
  • +
  • z_index: Z-order for rendering (lower values rendered first).
  • +

Methods:

@@ -1399,7 +1625,57 @@ Note:

Scene

-

Base class for object-oriented scenes

+

Scene(name: str) + +Object-oriented scene management with lifecycle callbacks. + +This is the recommended approach for scene management, replacing module-level +functions like createScene(), setScene(), and sceneUI(). Key advantage: you can +set on_key handlers on ANY scene, not just the currently active one. + +Args: + name: Unique identifier for this scene. Used for scene transitions. + +Properties: + name (str, read-only): Scene's unique identifier. + active (bool, read-only): Whether this scene is currently displayed. + children (UICollection, read-only): UI elements in this scene. Modify to add/remove elements. + on_key (callable): Keyboard handler. Set on ANY scene, regardless of which is active! + pos (Vector): Position offset for all UI elements. + visible (bool): Whether the scene renders. + opacity (float): Scene transparency (0.0-1.0). + +Lifecycle Callbacks (override in subclass): + on_enter(): Called when scene becomes active via activate(). + on_exit(): Called when scene is deactivated (another scene activates). + on_keypress(key: str, action: str): Called for keyboard events. Alternative to on_key property. + update(dt: float): Called every frame with delta time in seconds. + on_resize(width: int, height: int): Called when window is resized. + +Example: + # Basic usage (replacing module functions): + scene = mcrfpy.Scene('main_menu') + scene.children.append(mcrfpy.Caption(text='Welcome', pos=(100, 100))) + scene.on_key = lambda key, action: print(f'Key: {key}') + scene.activate() # Switch to this scene + + # Subclassing for lifecycle: + class GameScene(mcrfpy.Scene): + def on_enter(self): + print('Game started!') + def update(self, dt): + self.player.move(dt) +

+

Properties:

+
    +
  • active (read-only): Whether this scene is currently active (bool, read-only). Only one scene can be active at a time.
  • +
  • children (read-only): UI element collection for this scene (UICollection, read-only). Use to add, remove, or iterate over UI elements. Changes are reflected immediately.
  • +
  • name (read-only): Scene name (str, read-only). Unique identifier for this scene.
  • +
  • on_key: Keyboard event handler (callable or None). Function receives (key: str, action: str) for keyboard events. Set to None to remove the handler.
  • +
  • opacity: Scene opacity (0.0-1.0). Applied to all UI elements during rendering.
  • +
  • pos: Scene position offset (Vector). Applied to all UI elements during rendering.
  • +
  • visible: Scene visibility (bool). If False, scene is not rendered.
  • +

Methods:

@@ -1459,6 +1735,31 @@ Attributes: z_index (int): Rendering order name (str): Element name w, h (float): Read-only computed size based on texture and scale

+

Properties:

+
    +
  • bounds: Bounding rectangle (x, y, width, height) in local coordinates.
  • +
  • global_bounds: Bounding rectangle (x, y, width, height) in screen coordinates.
  • +
  • global_position (read-only): Global screen position (read-only). Calculates absolute position by walking up the parent chain.
  • +
  • hovered (read-only): Whether mouse is currently over this element (read-only). Updated automatically by the engine during mouse movement.
  • +
  • name: Name for finding elements
  • +
  • on_click: Callable executed when object is clicked. Function receives (x, y) coordinates of click.
  • +
  • on_enter: Callback for mouse enter events. Called with (x, y, button, action) when mouse enters this element's bounds.
  • +
  • on_exit: Callback for mouse exit events. Called with (x, y, button, action) when mouse leaves this element's bounds.
  • +
  • on_move: Callback for mouse movement within bounds. Called with (x, y, button, action) for each mouse movement while inside. Performance note: Called frequently during movement - keep handlers fast.
  • +
  • opacity: Opacity level (0.0 = transparent, 1.0 = opaque). Automatically clamped to valid range [0.0, 1.0].
  • +
  • parent: Parent drawable. Get: Returns the parent Frame/Grid if nested, or None if at scene level. Set: Assign a Frame/Grid to reparent, or None to remove from parent.
  • +
  • pos: Position as a Vector
  • +
  • scale: Uniform size factor
  • +
  • scale_x: Horizontal scale factor
  • +
  • scale_y: Vertical scale factor
  • +
  • sprite_index: Which sprite on the texture is shown
  • +
  • sprite_number: Sprite index (DEPRECATED: use sprite_index instead)
  • +
  • texture: Texture object
  • +
  • visible: Whether the object is visible (bool). Invisible objects are not rendered or clickable.
  • +
  • x: X coordinate of top-left corner
  • +
  • y: Y coordinate of top-left corner
  • +
  • z_index: Z-order for rendering (lower values rendered first). Automatically triggers scene resort when changed.
  • +

Methods:

@@ -1495,6 +1796,15 @@ Note:

Texture

SFML Texture Object

+

Properties:

+
    +
  • sheet_height (read-only): Number of sprite rows in the texture sheet (int, read-only). Calculated as texture_height / sprite_height.
  • +
  • sheet_width (read-only): Number of sprite columns in the texture sheet (int, read-only). Calculated as texture_width / sprite_width.
  • +
  • source (read-only): Source filename path (str, read-only). The path used to load this texture.
  • +
  • sprite_count (read-only): Total number of sprites in the texture sheet (int, read-only). Equals sheet_width * sheet_height.
  • +
  • sprite_height (read-only): Height of each sprite in pixels (int, read-only). Specified during texture initialization.
  • +
  • sprite_width (read-only): Width of each sprite in pixels (int, read-only). Specified during texture initialization.
  • +

Methods:

@@ -1519,6 +1829,13 @@ Methods: at(x, y): Get tile index at cell position set(x, y, index): Set tile index at cell position fill(index): Fill entire layer with tile index

+

Properties:

+
    +
  • grid_size: Layer dimensions as (width, height) tuple.
  • +
  • texture: Texture atlas for tile sprites.
  • +
  • visible: Whether the layer is rendered.
  • +
  • z_index: Layer z-order. Negative values render below entities.
  • +

Methods:

@@ -1578,6 +1895,16 @@ Example: timer.pause() # Pause timer timer.resume() # Resume timer timer.once = True # Make it one-shot

+

Properties:

+
    +
  • active (read-only): Whether the timer is active and not paused (bool, read-only). False if cancelled or paused.
  • +
  • callback: The callback function to be called when timer fires (callable). Can be changed while timer is running.
  • +
  • interval: Timer interval in milliseconds (int). Must be positive. Can be changed while timer is running.
  • +
  • name (read-only): Timer name (str, read-only). Unique identifier for this timer.
  • +
  • once: Whether the timer stops after firing once (bool). If False, timer repeats indefinitely.
  • +
  • paused (read-only): Whether the timer is paused (bool, read-only). Paused timers preserve their remaining time.
  • +
  • remaining (read-only): Time remaining until next trigger in milliseconds (int, read-only). Preserved when timer is paused.
  • +

Methods:

@@ -1681,6 +2008,12 @@ Use name-based .find() for stable element access.

Vector

SFML Vector Object

+

Properties:

+
    +
  • int (read-only): Integer tuple (floor of x and y) for use as dict keys. Read-only.
  • +
  • x: X coordinate of the vector (float)
  • +
  • y: Y coordinate of the vector (float)
  • +

Methods:

@@ -1747,6 +2080,17 @@ Note:

Window

Window singleton for accessing and modifying the game window properties

+

Properties:

+
    +
  • framerate_limit: Frame rate limit in FPS (int, 0 for unlimited). Caps maximum frame rate.
  • +
  • fullscreen: Window fullscreen state (bool). Setting this recreates the window.
  • +
  • game_resolution: Fixed game resolution as (width, height) tuple. Enables resolution-independent rendering with scaling.
  • +
  • resolution: Window resolution as (width, height) tuple. Setting this recreates the window.
  • +
  • scaling_mode: Viewport scaling mode (str): 'center' (no scaling), 'stretch' (fill window), or 'fit' (maintain aspect ratio).
  • +
  • title: Window title string (str). Displayed in the window title bar.
  • +
  • visible: Window visibility state (bool). Hidden windows still process events.
  • +
  • vsync: Vertical sync enabled state (bool). Prevents screen tearing but may limit framerate.
  • +

Methods:

diff --git a/src/PySceneObject.h b/src/PySceneObject.h index bc12ea3..22ef8ab 100644 --- a/src/PySceneObject.h +++ b/src/PySceneObject.h @@ -53,7 +53,41 @@ namespace mcrfpydef { .tp_dealloc = (destructor)PySceneClass::__dealloc, .tp_repr = (reprfunc)PySceneClass::__repr__, .tp_flags = Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, // Allow subclassing - .tp_doc = PyDoc_STR("Base class for object-oriented scenes"), + .tp_doc = PyDoc_STR( + "Scene(name: str)\n\n" + "Object-oriented scene management with lifecycle callbacks.\n\n" + "This is the recommended approach for scene management, replacing module-level\n" + "functions like createScene(), setScene(), and sceneUI(). Key advantage: you can\n" + "set on_key handlers on ANY scene, not just the currently active one.\n\n" + "Args:\n" + " name: Unique identifier for this scene. Used for scene transitions.\n\n" + "Properties:\n" + " name (str, read-only): Scene's unique identifier.\n" + " active (bool, read-only): Whether this scene is currently displayed.\n" + " children (UICollection, read-only): UI elements in this scene. Modify to add/remove elements.\n" + " on_key (callable): Keyboard handler. Set on ANY scene, regardless of which is active!\n" + " pos (Vector): Position offset for all UI elements.\n" + " visible (bool): Whether the scene renders.\n" + " opacity (float): Scene transparency (0.0-1.0).\n\n" + "Lifecycle Callbacks (override in subclass):\n" + " on_enter(): Called when scene becomes active via activate().\n" + " on_exit(): Called when scene is deactivated (another scene activates).\n" + " on_keypress(key: str, action: str): Called for keyboard events. Alternative to on_key property.\n" + " update(dt: float): Called every frame with delta time in seconds.\n" + " on_resize(width: int, height: int): Called when window is resized.\n\n" + "Example:\n" + " # Basic usage (replacing module functions):\n" + " scene = mcrfpy.Scene('main_menu')\n" + " scene.children.append(mcrfpy.Caption(text='Welcome', pos=(100, 100)))\n" + " scene.on_key = lambda key, action: print(f'Key: {key}')\n" + " scene.activate() # Switch to this scene\n\n" + " # Subclassing for lifecycle:\n" + " class GameScene(mcrfpy.Scene):\n" + " def on_enter(self):\n" + " print('Game started!')\n" + " def update(self, dt):\n" + " self.player.move(dt)\n" + ), .tp_methods = nullptr, // Set in McRFPy_API.cpp .tp_getset = nullptr, // Set in McRFPy_API.cpp .tp_init = (initproc)PySceneClass::__init__, diff --git a/tools/generate_dynamic_docs.py b/tools/generate_dynamic_docs.py index c6e4d4d..e70cdc4 100644 --- a/tools/generate_dynamic_docs.py +++ b/tools/generate_dynamic_docs.py @@ -10,6 +10,7 @@ import inspect import datetime import html import re +import types from pathlib import Path def transform_doc_links(docstring, format='html', base_url=''): @@ -214,11 +215,21 @@ def get_all_classes(): "parsed": parse_docstring(method_doc) } elif isinstance(attr, property): + # Pure Python property prop_doc = (attr.fget.__doc__ if attr.fget else "") or "" class_info["properties"][attr_name] = { "doc": prop_doc, "readonly": attr.fset is None } + elif isinstance(attr, (types.GetSetDescriptorType, types.MemberDescriptorType)): + # C++ extension property (PyGetSetDef or PyMemberDef) + prop_doc = attr.__doc__ or "" + # Check if docstring indicates read-only (convention: "read-only" in description) + readonly = "read-only" in prop_doc.lower() + class_info["properties"][attr_name] = { + "doc": prop_doc, + "readonly": readonly + } except: pass From 05f28ef7cdc7eac09bce4e5bece9ca34ad8446de Mon Sep 17 00:00:00 2001 From: John McCardle Date: Wed, 31 Dec 2025 16:21:09 -0500 Subject: [PATCH 4/5] Add 14-part tutorial Python files (extracted, tested) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tutorial scripts extracted from documentation, with fixes: - Asset filename: kenney_roguelike.png → kenney_tinydungeon.png - Entity keyword: pos= → grid_pos= (tile coordinates) - Frame.size property → Frame.resize() method - Removed sprite_color (deferred to shader support) All 14 parts pass smoke testing (import + 2-frame run). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- docs/tutorials/part_00_setup/part_00_setup.py | 30 + .../part_01_grid_movement.py | 121 ++ .../part_02_tiles_collision.py | 206 ++ .../part_03_dungeon_generation.py | 356 ++++ docs/tutorials/part_04_fov/part_04_fov.py | 363 ++++ .../part_05_enemies/part_05_enemies.py | 685 ++++++ .../part_06_combat/part_06_combat.py | 940 +++++++++ docs/tutorials/part_07_ui/part_07_ui.py | 1035 +++++++++ docs/tutorials/part_08_items/part_08_items.py | 1275 ++++++++++++ .../part_09_ranged/part_09_ranged.py | 1396 +++++++++++++ .../part_10_save_load/part_10_save_load.py | 1565 ++++++++++++++ .../part_11_levels/part_11_levels.py | 1735 ++++++++++++++++ .../part_12_experience/part_12_experience.py | 1850 +++++++++++++++++ .../part_13_equipment/part_13_equipment.py | 1798 ++++++++++++++++ 14 files changed, 13355 insertions(+) create mode 100644 docs/tutorials/part_00_setup/part_00_setup.py create mode 100644 docs/tutorials/part_01_grid_movement/part_01_grid_movement.py create mode 100644 docs/tutorials/part_02_tiles_collision/part_02_tiles_collision.py create mode 100644 docs/tutorials/part_03_dungeon_generation/part_03_dungeon_generation.py create mode 100644 docs/tutorials/part_04_fov/part_04_fov.py create mode 100644 docs/tutorials/part_05_enemies/part_05_enemies.py create mode 100644 docs/tutorials/part_06_combat/part_06_combat.py create mode 100644 docs/tutorials/part_07_ui/part_07_ui.py create mode 100644 docs/tutorials/part_08_items/part_08_items.py create mode 100644 docs/tutorials/part_09_ranged/part_09_ranged.py create mode 100644 docs/tutorials/part_10_save_load/part_10_save_load.py create mode 100644 docs/tutorials/part_11_levels/part_11_levels.py create mode 100644 docs/tutorials/part_12_experience/part_12_experience.py create mode 100644 docs/tutorials/part_13_equipment/part_13_equipment.py diff --git a/docs/tutorials/part_00_setup/part_00_setup.py b/docs/tutorials/part_00_setup/part_00_setup.py new file mode 100644 index 0000000..f90eed9 --- /dev/null +++ b/docs/tutorials/part_00_setup/part_00_setup.py @@ -0,0 +1,30 @@ +"""McRogueFace - Part 0: Setting Up McRogueFace + +Documentation: https://mcrogueface.github.io/tutorial/part_00_setup +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_00_setup/part_00_setup.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +# Create a Scene object - this is the preferred approach +scene = mcrfpy.Scene("hello") + +# Create a caption to display text +title = mcrfpy.Caption( + pos=(512, 300), + text="Hello, Roguelike!" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 32 + +# Add the caption to the scene's UI collection +scene.children.append(title) + +# Activate the scene to display it +scene.activate() + +# Note: There is no run() function! +# The engine is already running - your script is imported by it. \ No newline at end of file diff --git a/docs/tutorials/part_01_grid_movement/part_01_grid_movement.py b/docs/tutorials/part_01_grid_movement/part_01_grid_movement.py new file mode 100644 index 0000000..53c236e --- /dev/null +++ b/docs/tutorials/part_01_grid_movement/part_01_grid_movement.py @@ -0,0 +1,121 @@ +"""McRogueFace - Part 1: The '@' and the Dungeon Grid + +Documentation: https://mcrogueface.github.io/tutorial/part_01_grid_movement +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_01_grid_movement/part_01_grid_movement.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +# Sprite indices for CP437 tileset +SPRITE_AT = 64 # '@' - player character +SPRITE_FLOOR = 46 # '.' - floor tile + +# Grid dimensions (in tiles) +GRID_WIDTH = 20 +GRID_HEIGHT = 15 + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load the texture (sprite sheet) +# Parameters: path, sprite_width, sprite_height +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid +# The grid displays tiles and contains entities +grid = mcrfpy.Grid( + pos=(100, 80), # Position on screen (pixels) + size=(640, 480), # Display size (pixels) + 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): + cell = grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + +# Create the player entity at the center of the grid +player = mcrfpy.Entity( + grid_pos=(GRID_WIDTH // 2, GRID_HEIGHT // 2), # Grid coordinates, not pixels! + texture=texture, + sprite_index=SPRITE_AT +) + +# Add the player to the grid +# Option 1: Use the grid parameter in constructor +# player = mcrfpy.Entity(grid_pos=(10, 7), texture=texture, sprite_index=SPRITE_AT, grid=grid) + +# Option 2: Append to grid.entities (what we will use) +grid.entities.append(player) + +# Add the grid to the scene +scene.children.append(grid) + +# Add a title caption +title = mcrfpy.Caption( + pos=(100, 20), + text="Part 1: Grid Movement - Use Arrow Keys or WASD" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 18 +scene.children.append(title) + +# Add a position display +pos_display = mcrfpy.Caption( + pos=(100, 50), + text=f"Player Position: ({player.x}, {player.y})" +) +pos_display.fill_color = mcrfpy.Color(200, 200, 100) +pos_display.font_size = 16 +scene.children.append(pos_display) + +def handle_keys(key: str, action: str) -> None: + """Handle keyboard input to move the player. + + Args: + key: The key that was pressed (e.g., "W", "Up", "Space") + action: Either "start" (key pressed) or "end" (key released) + """ + # Only respond to key press, not release + if action != "start": + return + + # Get current player position + px, py = int(player.x), int(player.y) + + # Calculate new position based on key + if key == "W" or key == "Up": + py -= 1 # Up decreases Y + elif key == "S" or key == "Down": + py += 1 # Down increases Y + elif key == "A" or key == "Left": + px -= 1 # Left decreases X + elif key == "D" or key == "Right": + px += 1 # Right increases X + elif key == "Escape": + mcrfpy.exit() + return + + # Update player position + player.x = px + player.y = py + + # Update the position display + pos_display.text = f"Player Position: ({player.x}, {player.y})" + +# Set the key handler on the scene +# This is the preferred approach - works on ANY scene, not just the active one +scene.on_key = handle_keys + +# Activate the scene +scene.activate() + +print("Part 1 loaded! Use WASD or Arrow keys to move.") \ No newline at end of file diff --git a/docs/tutorials/part_02_tiles_collision/part_02_tiles_collision.py b/docs/tutorials/part_02_tiles_collision/part_02_tiles_collision.py new file mode 100644 index 0000000..66feaa4 --- /dev/null +++ b/docs/tutorials/part_02_tiles_collision/part_02_tiles_collision.py @@ -0,0 +1,206 @@ +"""McRogueFace - Part 2: Walls, Floors, and Collision + +Documentation: https://mcrogueface.github.io/tutorial/part_02_tiles_collision +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_02_tiles_collision/part_02_tiles_collision.py + +This code is extracted from the McRogueFace documentation and can be +run directly with: ./mcrogueface path/to/this/file.py +""" + +import mcrfpy + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player + +# Grid dimensions +GRID_WIDTH = 30 +GRID_HEIGHT = 20 + +# ============================================================================= +# Map Creation +# ============================================================================= + +def create_map(grid: mcrfpy.Grid) -> None: + """Fill the grid with walls and floors. + + Creates a simple room with walls around the edges and floor in the middle. + """ + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + + # Place walls around the edges + if x == 0 or x == GRID_WIDTH - 1 or y == 0 or y == GRID_HEIGHT - 1: + cell.tilesprite = SPRITE_WALL + cell.walkable = False + else: + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + + # Add some interior walls to make it interesting + # Vertical wall + for y in range(5, 15): + cell = grid.at(10, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + + # Horizontal wall + for x in range(15, 25): + cell = grid.at(x, 10) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + + # Leave gaps for doorways + grid.at(10, 10).tilesprite = SPRITE_FLOOR + grid.at(10, 10).walkable = True + grid.at(20, 10).tilesprite = SPRITE_FLOOR + grid.at(20, 10).walkable = True + +# ============================================================================= +# Collision Detection +# ============================================================================= + +def can_move_to(grid: mcrfpy.Grid, x: int, y: int) -> bool: + """Check if a position is valid for movement. + + Args: + grid: The game grid + x: Target X coordinate (in tiles) + y: Target Y coordinate (in tiles) + + Returns: + True if the position is walkable, False otherwise + """ + # Check grid bounds first + if x < 0 or x >= GRID_WIDTH: + return False + if y < 0 or y >= GRID_HEIGHT: + return False + + # Check if the tile is walkable + cell = grid.at(x, y) + return cell.walkable + +# ============================================================================= +# Game Setup +# ============================================================================= + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid +grid = mcrfpy.Grid( + pos=(80, 100), + size=(720, 480), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.5 + +# Build the map +create_map(grid) + +# Create the player in the center of the left room +player = mcrfpy.Entity( + grid_pos=(5, 10), + texture=texture, + sprite_index=SPRITE_PLAYER +) +grid.entities.append(player) + +# Add grid to scene +scene.children.append(grid) + +# ============================================================================= +# UI Elements +# ============================================================================= + +title = mcrfpy.Caption( + pos=(80, 20), + text="Part 2: Walls and Collision" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +instructions = mcrfpy.Caption( + pos=(80, 55), + text="WASD or Arrow Keys to move | Walls block movement" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 16 +scene.children.append(instructions) + +pos_display = mcrfpy.Caption( + pos=(80, 600), + text=f"Position: ({int(player.x)}, {int(player.y)})" +) +pos_display.fill_color = mcrfpy.Color(200, 200, 100) +pos_display.font_size = 16 +scene.children.append(pos_display) + +status_display = mcrfpy.Caption( + pos=(400, 600), + text="Status: Ready" +) +status_display.fill_color = mcrfpy.Color(100, 200, 100) +status_display.font_size = 16 +scene.children.append(status_display) + +# ============================================================================= +# Input Handling +# ============================================================================= + +def handle_keys(key: str, action: str) -> None: + """Handle keyboard input with collision detection.""" + if action != "start": + return + + # Get current position + px, py = int(player.x), int(player.y) + + # Calculate intended new position + new_x, new_y = px, py + + if key == "W" or key == "Up": + new_y -= 1 + elif key == "S" or key == "Down": + new_y += 1 + elif key == "A" or key == "Left": + new_x -= 1 + elif key == "D" or key == "Right": + new_x += 1 + elif key == "Escape": + mcrfpy.exit() + return + else: + return # Ignore other keys + + # Check collision before moving + if can_move_to(grid, new_x, new_y): + player.x = new_x + player.y = new_y + pos_display.text = f"Position: ({new_x}, {new_y})" + status_display.text = "Status: Moved" + status_display.fill_color = mcrfpy.Color(100, 200, 100) + else: + status_display.text = "Status: Blocked!" + status_display.fill_color = mcrfpy.Color(200, 100, 100) + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 2 loaded! Try walking into walls.") \ No newline at end of file diff --git a/docs/tutorials/part_03_dungeon_generation/part_03_dungeon_generation.py b/docs/tutorials/part_03_dungeon_generation/part_03_dungeon_generation.py new file mode 100644 index 0000000..632ad2f --- /dev/null +++ b/docs/tutorials/part_03_dungeon_generation/part_03_dungeon_generation.py @@ -0,0 +1,356 @@ +"""McRogueFace - Part 3: Procedural Dungeon Generation + +Documentation: https://mcrogueface.github.io/tutorial/part_03_dungeon_generation +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_03_dungeon_generation/part_03_dungeon_generation.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 + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 35 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# ============================================================================= +# Room Class +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + """Create a new room. + + Args: + x: Left edge X coordinate + y: Top edge Y coordinate + width: Room width in tiles + height: Room height 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.""" + 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 (excluding walls). + + The inner area is one tile smaller on each side to leave room + for walls between adjacent rooms. + """ + 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. + + 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 + ) + +# ============================================================================= +# Dungeon Generation +# ============================================================================= + +def fill_with_walls(grid: mcrfpy.Grid) -> None: + """Fill the entire grid with wall tiles.""" + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(grid: mcrfpy.Grid, room: RectangularRoom) -> None: + """Carve out a room by setting its inner tiles to floor. + + Args: + grid: The game grid + room: The room to carve + """ + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + """Carve a horizontal tunnel. + + Args: + grid: The game grid + x1: Starting X coordinate + x2: Ending X coordinate + y: Y coordinate of the tunnel + """ + start_x = min(x1, x2) + end_x = max(x1, x2) + for x in range(start_x, end_x + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + """Carve a vertical tunnel. + + Args: + grid: The game grid + y1: Starting Y coordinate + y2: Ending Y coordinate + x: X coordinate of the tunnel + """ + start_y = min(y1, y2) + end_y = max(y1, y2) + for y in range(start_y, end_y + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + """Carve an L-shaped tunnel between two points. + + Randomly chooses to go horizontal-then-vertical or vertical-then-horizontal. + + Args: + grid: The game grid + start: Starting (x, y) coordinates + end: Ending (x, y) coordinates + """ + x1, y1 = start + x2, y2 = end + + # Randomly choose whether to go horizontal or vertical first + if random.random() < 0.5: + # Horizontal first, then vertical + carve_tunnel_horizontal(grid, x1, x2, y1) + carve_tunnel_vertical(grid, y1, y2, x2) + else: + # Vertical first, then horizontal + carve_tunnel_vertical(grid, y1, y2, x1) + carve_tunnel_horizontal(grid, x1, x2, y2) + +def generate_dungeon(grid: mcrfpy.Grid) -> tuple[int, int]: + """Generate a dungeon with rooms and tunnels. + + Args: + grid: The game grid to generate the dungeon in + + Returns: + The (x, y) coordinates where the player should start + """ + # Start with all walls + fill_with_walls(grid) + + 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 (leaving 1-tile border) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + # Check for overlap with existing rooms + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue # Skip this room, try another + + # No overlap - carve out the room + carve_room(grid, new_room) + + # Connect to previous room with a tunnel + if rooms: + # Tunnel from this room's center to the previous room's center + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + + # Return the center of the first room as the player start position + if rooms: + return rooms[0].center + else: + # Fallback if no rooms were generated + return GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# ============================================================================= +# Collision Detection +# ============================================================================= + +def can_move_to(grid: mcrfpy.Grid, x: int, y: int) -> bool: + """Check if a position is valid for movement.""" + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + return grid.at(x, y).walkable + +# ============================================================================= +# Game Setup +# ============================================================================= + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid +grid = mcrfpy.Grid( + pos=(50, 80), + size=(800, 560), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +# Generate the dungeon and get player start position +player_start_x, player_start_y = generate_dungeon(grid) + +# Create the player at the starting position +player = mcrfpy.Entity( + grid_pos=(player_start_x, player_start_y), + texture=texture, + sprite_index=SPRITE_PLAYER +) +grid.entities.append(player) + +# Add grid to scene +scene.children.append(grid) + +# ============================================================================= +# UI Elements +# ============================================================================= + +title = mcrfpy.Caption( + pos=(50, 15), + text="Part 3: Procedural Dungeon Generation" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +instructions = mcrfpy.Caption( + pos=(50, 50), + text="WASD/Arrows: Move | R: Regenerate dungeon | Escape: Quit" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 16 +scene.children.append(instructions) + +pos_display = mcrfpy.Caption( + pos=(50, 660), + text=f"Position: ({int(player.x)}, {int(player.y)})" +) +pos_display.fill_color = mcrfpy.Color(200, 200, 100) +pos_display.font_size = 16 +scene.children.append(pos_display) + +room_display = mcrfpy.Caption( + pos=(400, 660), + text="Press R to regenerate the dungeon" +) +room_display.fill_color = mcrfpy.Color(100, 200, 100) +room_display.font_size = 16 +scene.children.append(room_display) + +# ============================================================================= +# Input Handling +# ============================================================================= + +def regenerate_dungeon() -> None: + """Generate a new dungeon and reposition the player.""" + new_x, new_y = generate_dungeon(grid) + player.x = new_x + player.y = new_y + pos_display.text = f"Position: ({new_x}, {new_y})" + room_display.text = "New dungeon generated!" + +def handle_keys(key: str, action: str) -> None: + """Handle keyboard input.""" + if action != "start": + return + + px, py = int(player.x), int(player.y) + new_x, new_y = px, py + + if key == "W" or key == "Up": + new_y -= 1 + elif key == "S" or key == "Down": + new_y += 1 + elif key == "A" or key == "Left": + new_x -= 1 + elif key == "D" or key == "Right": + new_x += 1 + elif key == "R": + regenerate_dungeon() + return + elif key == "Escape": + mcrfpy.exit() + return + else: + return + + if can_move_to(grid, new_x, new_y): + player.x = new_x + player.y = new_y + pos_display.text = f"Position: ({new_x}, {new_y})" + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 3 loaded! Explore the dungeon or press R to regenerate.") \ No newline at end of file diff --git a/docs/tutorials/part_04_fov/part_04_fov.py b/docs/tutorials/part_04_fov/part_04_fov.py new file mode 100644 index 0000000..97d9187 --- /dev/null +++ b/docs/tutorials/part_04_fov/part_04_fov.py @@ -0,0 +1,363 @@ +"""McRogueFace - Part 4: Field of View + +Documentation: https://mcrogueface.github.io/tutorial/part_04_fov +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_04_fov/part_04_fov.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 + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 35 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# FOV settings +FOV_RADIUS = 8 + +# Visibility colors (applied as overlays) +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) # Fully transparent - show tile +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) # Dark blue tint - dimmed +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) # Solid black - hidden + +# ============================================================================= +# Room Class (from Part 3) +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> tuple[int, int]: + 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 slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return ( + self.x1 <= other.x2 and + self.x2 >= other.x1 and + self.y1 <= other.y2 and + self.y2 >= other.y1 + ) + +# ============================================================================= +# Exploration Tracking +# ============================================================================= + +# Track which tiles have been discovered (seen at least once) +explored: list[list[bool]] = [] + +def init_explored() -> None: + """Initialize the explored array to all False.""" + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + """Mark a tile as explored.""" + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + """Check if a tile has been explored.""" + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Dungeon Generation (from Part 3, with transparent property) +# ============================================================================= + +def fill_with_walls(grid: mcrfpy.Grid) -> None: + """Fill the entire grid with wall tiles.""" + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False # Walls block line of sight + +def carve_room(grid: mcrfpy.Grid, room: RectangularRoom) -> None: + """Carve out a room by setting its inner tiles to floor.""" + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True # Floors allow line of sight + +def carve_tunnel_horizontal(grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + """Carve a horizontal tunnel.""" + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + """Carve a vertical tunnel.""" + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + """Carve an L-shaped tunnel between two points.""" + x1, y1 = start + x2, y2 = end + + if random.random() < 0.5: + carve_tunnel_horizontal(grid, x1, x2, y1) + carve_tunnel_vertical(grid, y1, y2, x2) + else: + carve_tunnel_vertical(grid, y1, y2, x1) + carve_tunnel_horizontal(grid, x1, x2, y2) + +def generate_dungeon(grid: mcrfpy.Grid) -> tuple[int, int]: + """Generate a dungeon with rooms and tunnels.""" + fill_with_walls(grid) + init_explored() # Reset exploration when generating new dungeon + + rooms: list[RectangularRoom] = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + rooms.append(new_room) + + if rooms: + return rooms[0].center + return GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# ============================================================================= +# Field of View +# ============================================================================= + +def update_fov(grid: mcrfpy.Grid, fov_layer, player_x: int, player_y: int) -> None: + """Update the field of view visualization. + + Args: + grid: The game grid + fov_layer: The ColorLayer for FOV visualization + player_x: Player's X position + player_y: Player's Y position + """ + # Compute FOV from player position + grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + + # Update each tile's visibility + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if grid.is_in_fov(x, y): + # Currently visible - mark as explored and show clearly + mark_explored(x, y) + fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + # Previously seen but not currently visible - show dimmed + fov_layer.set(x, y, COLOR_DISCOVERED) + else: + # Never seen - hide completely + fov_layer.set(x, y, COLOR_UNKNOWN) + +# ============================================================================= +# Collision Detection +# ============================================================================= + +def can_move_to(grid: mcrfpy.Grid, x: int, y: int) -> bool: + """Check if a position is valid for movement.""" + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + return grid.at(x, y).walkable + +# ============================================================================= +# Game Setup +# ============================================================================= + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid +grid = mcrfpy.Grid( + pos=(50, 80), + size=(800, 560), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +# Generate the dungeon +player_start_x, player_start_y = generate_dungeon(grid) + +# Add a color layer for FOV visualization (below entities) +fov_layer = grid.add_layer("color", z_index=-1) + +# Initialize the FOV layer to all black (unknown) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +# Create the player +player = mcrfpy.Entity( + grid_pos=(player_start_x, player_start_y), + texture=texture, + sprite_index=SPRITE_PLAYER +) +grid.entities.append(player) + +# Calculate initial FOV +update_fov(grid, fov_layer, player_start_x, player_start_y) + +# Add grid to scene +scene.children.append(grid) + +# ============================================================================= +# UI Elements +# ============================================================================= + +title = mcrfpy.Caption( + pos=(50, 15), + text="Part 4: Field of View" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +instructions = mcrfpy.Caption( + pos=(50, 50), + text="WASD/Arrows: Move | R: Regenerate | Escape: Quit" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 16 +scene.children.append(instructions) + +pos_display = mcrfpy.Caption( + pos=(50, 660), + text=f"Position: ({int(player.x)}, {int(player.y)})" +) +pos_display.fill_color = mcrfpy.Color(200, 200, 100) +pos_display.font_size = 16 +scene.children.append(pos_display) + +fov_display = mcrfpy.Caption( + pos=(400, 660), + text=f"FOV Radius: {FOV_RADIUS}" +) +fov_display.fill_color = mcrfpy.Color(100, 200, 100) +fov_display.font_size = 16 +scene.children.append(fov_display) + +# ============================================================================= +# Input Handling +# ============================================================================= + +def regenerate_dungeon() -> None: + """Generate a new dungeon and reposition the player.""" + new_x, new_y = generate_dungeon(grid) + player.x = new_x + player.y = new_y + + # Reset FOV layer to unknown + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + # Calculate new FOV + update_fov(grid, fov_layer, new_x, new_y) + pos_display.text = f"Position: ({new_x}, {new_y})" + +def handle_keys(key: str, action: str) -> None: + """Handle keyboard input.""" + if action != "start": + return + + px, py = int(player.x), int(player.y) + new_x, new_y = px, py + + if key == "W" or key == "Up": + new_y -= 1 + elif key == "S" or key == "Down": + new_y += 1 + elif key == "A" or key == "Left": + new_x -= 1 + elif key == "D" or key == "Right": + new_x += 1 + elif key == "R": + regenerate_dungeon() + return + elif key == "Escape": + mcrfpy.exit() + return + else: + return + + if can_move_to(grid, new_x, new_y): + player.x = new_x + player.y = new_y + pos_display.text = f"Position: ({new_x}, {new_y})" + + # Update FOV after movement + update_fov(grid, fov_layer, new_x, new_y) + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 4 loaded! Explore the dungeon - watch the fog of war!") \ No newline at end of file diff --git a/docs/tutorials/part_05_enemies/part_05_enemies.py b/docs/tutorials/part_05_enemies/part_05_enemies.py new file mode 100644 index 0000000..9abfc42 --- /dev/null +++ b/docs/tutorials/part_05_enemies/part_05_enemies.py @@ -0,0 +1,685 @@ +"""McRogueFace - Part 5: Placing Enemies + +Documentation: https://mcrogueface.github.io/tutorial/part_05_enemies +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_05_enemies/part_05_enemies.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 + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player + +# Enemy sprites (lowercase letters in CP437) +SPRITE_GOBLIN = 103 # 'g' +SPRITE_ORC = 111 # 'o' +SPRITE_TROLL = 116 # 't' + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 35 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# Enemy spawn parameters +MAX_ENEMIES_PER_ROOM = 3 + +# FOV settings +FOV_RADIUS = 8 + +# Visibility colors +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) + +# ============================================================================= +# Enemy Data +# ============================================================================= + +# Enemy templates - stats for each enemy type +ENEMY_TEMPLATES = { + "goblin": { + "sprite": SPRITE_GOBLIN, + "hp": 6, + "max_hp": 6, + "attack": 3, + "defense": 0, + "color": mcrfpy.Color(100, 200, 100) # Greenish + }, + "orc": { + "sprite": SPRITE_ORC, + "hp": 10, + "max_hp": 10, + "attack": 4, + "defense": 1, + "color": mcrfpy.Color(100, 150, 100) # Darker green + }, + "troll": { + "sprite": SPRITE_TROLL, + "hp": 16, + "max_hp": 16, + "attack": 6, + "defense": 2, + "color": mcrfpy.Color(50, 150, 50) # Dark green + } +} + +# Global storage for entity data +# Maps entity objects to their data dictionaries +entity_data: dict = {} + +# Global references +player = None +grid = None +fov_layer = None + +# ============================================================================= +# Room Class (from Part 3) +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> tuple[int, int]: + 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 slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return ( + self.x1 <= other.x2 and + self.x2 >= other.x1 and + self.y1 <= other.y2 and + self.y2 >= other.y1 + ) + +# ============================================================================= +# Exploration Tracking (from Part 4) +# ============================================================================= + +explored: list[list[bool]] = [] + +def init_explored() -> None: + """Initialize the explored array to all False.""" + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + """Mark a tile as explored.""" + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + """Check if a tile has been explored.""" + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Dungeon Generation (from Part 4) +# ============================================================================= + +def fill_with_walls(target_grid: mcrfpy.Grid) -> None: + """Fill the entire grid with wall tiles.""" + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(target_grid: mcrfpy.Grid, room: RectangularRoom) -> None: + """Carve out a room by setting its inner tiles to floor.""" + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(target_grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + """Carve a horizontal tunnel.""" + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(target_grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + """Carve a vertical tunnel.""" + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + target_grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + """Carve an L-shaped tunnel between two points.""" + x1, y1 = start + x2, y2 = end + + if random.random() < 0.5: + carve_tunnel_horizontal(target_grid, x1, x2, y1) + carve_tunnel_vertical(target_grid, y1, y2, x2) + else: + carve_tunnel_vertical(target_grid, y1, y2, x1) + carve_tunnel_horizontal(target_grid, x1, x2, y2) + +# ============================================================================= +# Enemy Management +# ============================================================================= + +def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, texture: mcrfpy.Texture) -> mcrfpy.Entity: + """Spawn an enemy at the given position. + + Args: + target_grid: The game grid + x: X position in tiles + y: Y position in tiles + enemy_type: Type of enemy ("goblin", "orc", or "troll") + texture: The texture to use for the sprite + + Returns: + The created enemy Entity + """ + template = ENEMY_TEMPLATES[enemy_type] + + enemy = mcrfpy.Entity( + grid_pos=(x, y), + texture=texture, + sprite_index=template["sprite"] + ) + + + # Start hidden until player sees them + enemy.visible = False + + # Add to grid + target_grid.entities.append(enemy) + + # Store enemy data + entity_data[enemy] = { + "type": enemy_type, + "name": enemy_type.capitalize(), + "hp": template["hp"], + "max_hp": template["max_hp"], + "attack": template["attack"], + "defense": template["defense"], + "is_player": False + } + + return enemy + +def spawn_enemies_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, texture: mcrfpy.Texture) -> None: + """Spawn random enemies in a room. + + Args: + target_grid: The game grid + room: The room to spawn enemies in + texture: The texture to use for sprites + """ + # Random number of enemies (0 to MAX_ENEMIES_PER_ROOM) + num_enemies = random.randint(0, MAX_ENEMIES_PER_ROOM) + + for _ in range(num_enemies): + # Random position within the room's inner area + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + # Check if position is already occupied + if get_blocking_entity_at(target_grid, x, y) is not None: + continue # Skip this spawn attempt + + # Choose enemy type based on weighted random + roll = random.random() + if roll < 0.6: + enemy_type = "goblin" # 60% chance + elif roll < 0.9: + enemy_type = "orc" # 30% chance + else: + enemy_type = "troll" # 10% chance + + spawn_enemy(target_grid, x, y, enemy_type, texture) + +def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int) -> mcrfpy.Entity | None: + """Get any entity that blocks movement at the given position. + + Args: + target_grid: The game grid + x: X position to check + y: Y position to check + + Returns: + The blocking entity, or None if no entity blocks this position + """ + for entity in target_grid.entities: + if int(entity.x) == x and int(entity.y) == y: + return entity + return None + +def clear_enemies(target_grid: mcrfpy.Grid) -> None: + """Remove all enemies from the grid.""" + global entity_data + + # Get list of enemies to remove (not the player) + enemies_to_remove = [] + for entity in target_grid.entities: + if entity in entity_data and not entity_data[entity].get("is_player", False): + enemies_to_remove.append(entity) + + # Remove from grid and entity_data + for enemy in enemies_to_remove: + # Find and remove from grid.entities + for i, e in enumerate(target_grid.entities): + if e == enemy: + target_grid.entities.remove(i) + break + # Remove from entity_data + if enemy in entity_data: + del entity_data[enemy] + +# ============================================================================= +# Entity Visibility +# ============================================================================= + +def update_entity_visibility(target_grid: mcrfpy.Grid) -> None: + """Update visibility of all entities based on FOV. + + Entities outside the player's field of view are hidden. + """ + global player + + for entity in target_grid.entities: + # Player is always visible + if entity == player: + entity.visible = True + continue + + # Other entities are only visible if in FOV + ex, ey = int(entity.x), int(entity.y) + entity.visible = target_grid.is_in_fov(ex, ey) + +# ============================================================================= +# Field of View (from Part 4) +# ============================================================================= + +def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None: + """Update the field of view visualization.""" + # Compute FOV from player position + target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + + # Update each tile's visibility + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if target_grid.is_in_fov(x, y): + mark_explored(x, y) + target_fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + target_fov_layer.set(x, y, COLOR_DISCOVERED) + else: + target_fov_layer.set(x, y, COLOR_UNKNOWN) + + # Update entity visibility + update_entity_visibility(target_grid) + +# ============================================================================= +# Collision Detection +# ============================================================================= + +def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int) -> bool: + """Check if a position is valid for movement. + + A position is valid if: + 1. It is within grid bounds + 2. The tile is walkable + 3. No entity is blocking it + """ + # Check bounds + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + + # Check tile walkability + if not target_grid.at(x, y).walkable: + return False + + # Check for blocking entities + if get_blocking_entity_at(target_grid, x, y) is not None: + return False + + return True + +# ============================================================================= +# Dungeon Generation with Enemies +# ============================================================================= + +def generate_dungeon(target_grid: mcrfpy.Grid, texture: mcrfpy.Texture) -> tuple[int, int]: + """Generate a dungeon with rooms, tunnels, and enemies. + + Args: + target_grid: The game grid + texture: The texture for entity sprites + + Returns: + The (x, y) coordinates where the player should start + """ + # Clear any existing enemies + clear_enemies(target_grid) + + # Fill with walls + fill_with_walls(target_grid) + init_explored() + + rooms: list[RectangularRoom] = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(target_grid, new_room) + + if rooms: + carve_l_tunnel(target_grid, new_room.center, rooms[-1].center) + # Spawn enemies in all rooms except the first (player starting room) + spawn_enemies_in_room(target_grid, new_room, texture) + + rooms.append(new_room) + + if rooms: + return rooms[0].center + return GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# ============================================================================= +# Game Setup +# ============================================================================= + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid +grid = mcrfpy.Grid( + pos=(50, 80), + size=(800, 560), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +# Generate the dungeon (without player first to get starting position) +fill_with_walls(grid) +init_explored() + +rooms: list[RectangularRoom] = [] + +for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + +# Get player starting position +if rooms: + player_start_x, player_start_y = rooms[0].center +else: + player_start_x, player_start_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# Add FOV layer +fov_layer = grid.add_layer("color", z_index=-1) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +# Create the player +player = mcrfpy.Entity( + grid_pos=(player_start_x, player_start_y), + texture=texture, + sprite_index=SPRITE_PLAYER +) +grid.entities.append(player) + +# Store player data +entity_data[player] = { + "type": "player", + "name": "Player", + "hp": 30, + "max_hp": 30, + "attack": 5, + "defense": 2, + "is_player": True +} + +# Now spawn enemies in rooms (except the first one) +for i, room in enumerate(rooms): + if i == 0: + continue # Skip player's starting room + spawn_enemies_in_room(grid, room, texture) + +# Calculate initial FOV +update_fov(grid, fov_layer, player_start_x, player_start_y) + +# Add grid to scene +scene.children.append(grid) + +# ============================================================================= +# UI Elements +# ============================================================================= + +title = mcrfpy.Caption( + pos=(50, 15), + text="Part 5: Placing Enemies" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +instructions = mcrfpy.Caption( + pos=(50, 50), + text="WASD/Arrows: Move | R: Regenerate | Escape: Quit" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 16 +scene.children.append(instructions) + +pos_display = mcrfpy.Caption( + pos=(50, 660), + text=f"Position: ({int(player.x)}, {int(player.y)})" +) +pos_display.fill_color = mcrfpy.Color(200, 200, 100) +pos_display.font_size = 16 +scene.children.append(pos_display) + +status_display = mcrfpy.Caption( + pos=(400, 660), + text="Explore the dungeon..." +) +status_display.fill_color = mcrfpy.Color(100, 200, 100) +status_display.font_size = 16 +scene.children.append(status_display) + +# ============================================================================= +# Input Handling +# ============================================================================= + +def regenerate_dungeon() -> None: + """Generate a new dungeon and reposition the player.""" + global player, grid, fov_layer, rooms + + # Clear enemies + clear_enemies(grid) + + # Regenerate dungeon structure + fill_with_walls(grid) + init_explored() + + rooms = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + + # Reposition player + if rooms: + new_x, new_y = rooms[0].center + else: + new_x, new_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + + player.x = new_x + player.y = new_y + + # Spawn new enemies + for i, room in enumerate(rooms): + if i == 0: + continue + spawn_enemies_in_room(grid, room, texture) + + # Reset FOV layer + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + # Update FOV + update_fov(grid, fov_layer, new_x, new_y) + pos_display.text = f"Position: ({new_x}, {new_y})" + status_display.text = "New dungeon generated!" + +def handle_keys(key: str, action: str) -> None: + """Handle keyboard input.""" + global player, grid, fov_layer + + if action != "start": + return + + px, py = int(player.x), int(player.y) + new_x, new_y = px, py + + if key == "W" or key == "Up": + new_y -= 1 + elif key == "S" or key == "Down": + new_y += 1 + elif key == "A" or key == "Left": + new_x -= 1 + elif key == "D" or key == "Right": + new_x += 1 + elif key == "R": + regenerate_dungeon() + return + elif key == "Escape": + mcrfpy.exit() + return + else: + return + + # Check for blocking entity (potential combat target) + blocker = get_blocking_entity_at(grid, new_x, new_y) + if blocker is not None and blocker != player: + # For now, just report that we bumped into an enemy + if blocker in entity_data: + enemy_name = entity_data[blocker]["name"] + status_display.text = f"A {enemy_name} blocks your path!" + status_display.fill_color = mcrfpy.Color(200, 150, 100) + return + + # Check if we can move + if can_move_to(grid, new_x, new_y): + player.x = new_x + player.y = new_y + pos_display.text = f"Position: ({new_x}, {new_y})" + status_display.text = "Exploring..." + status_display.fill_color = mcrfpy.Color(100, 200, 100) + + # Update FOV after movement + update_fov(grid, fov_layer, new_x, new_y) + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 5 loaded! Enemies lurk in the dungeon...") \ No newline at end of file diff --git a/docs/tutorials/part_06_combat/part_06_combat.py b/docs/tutorials/part_06_combat/part_06_combat.py new file mode 100644 index 0000000..59d6ab2 --- /dev/null +++ b/docs/tutorials/part_06_combat/part_06_combat.py @@ -0,0 +1,940 @@ +"""McRogueFace - Part 6: Combat System + +Documentation: https://mcrogueface.github.io/tutorial/part_06_combat +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_06_combat/part_06_combat.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 +from dataclasses import dataclass +from typing import Optional + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player +SPRITE_CORPSE = 37 # '%' - remains + +# Enemy sprites +SPRITE_GOBLIN = 103 # 'g' +SPRITE_ORC = 111 # 'o' +SPRITE_TROLL = 116 # 't' + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 35 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# Enemy spawn parameters +MAX_ENEMIES_PER_ROOM = 3 + +# FOV settings +FOV_RADIUS = 8 + +# Visibility colors +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) + +# Message log settings +MAX_MESSAGES = 5 + +# ============================================================================= +# Fighter Component +# ============================================================================= + +@dataclass +class Fighter: + """Combat stats for an entity.""" + hp: int + max_hp: int + attack: int + defense: int + name: str + is_player: bool = False + + @property + def is_alive(self) -> bool: + """Check if this fighter is still alive.""" + return self.hp > 0 + + def take_damage(self, amount: int) -> int: + """Apply damage and return actual damage taken.""" + actual_damage = min(self.hp, amount) + self.hp -= actual_damage + return actual_damage + + def heal(self, amount: int) -> int: + """Heal and return actual amount healed.""" + actual_heal = min(self.max_hp - self.hp, amount) + self.hp += actual_heal + return actual_heal + +# ============================================================================= +# Enemy Templates +# ============================================================================= + +ENEMY_TEMPLATES = { + "goblin": { + "sprite": SPRITE_GOBLIN, + "hp": 6, + "attack": 3, + "defense": 0, + "color": mcrfpy.Color(100, 200, 100) + }, + "orc": { + "sprite": SPRITE_ORC, + "hp": 10, + "attack": 4, + "defense": 1, + "color": mcrfpy.Color(100, 150, 100) + }, + "troll": { + "sprite": SPRITE_TROLL, + "hp": 16, + "attack": 6, + "defense": 2, + "color": mcrfpy.Color(50, 150, 50) + } +} + +# ============================================================================= +# Global State +# ============================================================================= + +# Entity data storage +entity_data: dict[mcrfpy.Entity, Fighter] = {} + +# Global references +player: Optional[mcrfpy.Entity] = None +grid: Optional[mcrfpy.Grid] = None +fov_layer = None +texture: Optional[mcrfpy.Texture] = None + +# Game state +game_over: bool = False + +# Message log +messages: list[tuple[str, mcrfpy.Color]] = [] + +# ============================================================================= +# Room Class +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> tuple[int, int]: + 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 slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return ( + self.x1 <= other.x2 and + self.x2 >= other.x1 and + self.y1 <= other.y2 and + self.y2 >= other.y1 + ) + +# ============================================================================= +# Exploration Tracking +# ============================================================================= + +explored: list[list[bool]] = [] + +def init_explored() -> None: + """Initialize the explored array to all False.""" + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + """Mark a tile as explored.""" + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + """Check if a tile has been explored.""" + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Message Log +# ============================================================================= + +def add_message(text: str, color: mcrfpy.Color = None) -> None: + """Add a message to the log. + + Args: + text: The message text + color: Optional color (defaults to white) + """ + if color is None: + color = mcrfpy.Color(255, 255, 255) + + messages.append((text, color)) + + # Keep only the most recent messages + while len(messages) > MAX_MESSAGES: + messages.pop(0) + + # Update the message display + update_message_display() + +def update_message_display() -> None: + """Update the message log UI.""" + if message_log_caption is None: + return + + # Combine messages into a single string + lines = [] + for text, color in messages: + lines.append(text) + + message_log_caption.text = "\n".join(lines) + +def clear_messages() -> None: + """Clear all messages.""" + global messages + messages = [] + update_message_display() + +# ============================================================================= +# Dungeon Generation +# ============================================================================= + +def fill_with_walls(target_grid: mcrfpy.Grid) -> None: + """Fill the entire grid with wall tiles.""" + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(target_grid: mcrfpy.Grid, room: RectangularRoom) -> None: + """Carve out a room by setting its inner tiles to floor.""" + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(target_grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + """Carve a horizontal tunnel.""" + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(target_grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + """Carve a vertical tunnel.""" + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + target_grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + """Carve an L-shaped tunnel between two points.""" + x1, y1 = start + x2, y2 = end + + if random.random() < 0.5: + carve_tunnel_horizontal(target_grid, x1, x2, y1) + carve_tunnel_vertical(target_grid, y1, y2, x2) + else: + carve_tunnel_vertical(target_grid, y1, y2, x1) + carve_tunnel_horizontal(target_grid, x1, x2, y2) + +# ============================================================================= +# Entity Management +# ============================================================================= + +def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + """Spawn an enemy at the given position.""" + template = ENEMY_TEMPLATES[enemy_type] + + enemy = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + enemy.visible = False + + target_grid.entities.append(enemy) + + # Create Fighter component for this enemy + entity_data[enemy] = Fighter( + hp=template["hp"], + max_hp=template["hp"], + attack=template["attack"], + defense=template["defense"], + name=enemy_type.capitalize(), + is_player=False + ) + + return enemy + +def spawn_enemies_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture) -> None: + """Spawn random enemies in a room.""" + num_enemies = random.randint(0, MAX_ENEMIES_PER_ROOM) + + for _ in range(num_enemies): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + if get_entity_at(target_grid, x, y) is not None: + continue + + roll = random.random() + if roll < 0.6: + enemy_type = "goblin" + elif roll < 0.9: + enemy_type = "orc" + else: + enemy_type = "troll" + + spawn_enemy(target_grid, x, y, enemy_type, tex) + +def get_entity_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]: + """Get any entity at the given position.""" + for entity in target_grid.entities: + if int(entity.x) == x and int(entity.y) == y: + # Check if this entity is alive (or is a non-Fighter entity) + if entity in entity_data: + if entity_data[entity].is_alive: + return entity + else: + return entity + return None + +def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mcrfpy.Entity = None) -> Optional[mcrfpy.Entity]: + """Get any living entity that blocks movement at the given position.""" + for entity in target_grid.entities: + if entity == exclude: + continue + if int(entity.x) == x and int(entity.y) == y: + if entity in entity_data and entity_data[entity].is_alive: + return entity + return None + +def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + """Remove an entity from the grid and data storage.""" + # Find and remove from grid + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + + # Remove from entity data + if entity in entity_data: + del entity_data[entity] + +def clear_enemies(target_grid: mcrfpy.Grid) -> None: + """Remove all enemies from the grid.""" + enemies_to_remove = [] + + for entity in target_grid.entities: + if entity in entity_data and not entity_data[entity].is_player: + enemies_to_remove.append(entity) + + for enemy in enemies_to_remove: + remove_entity(target_grid, enemy) + +# ============================================================================= +# Combat System +# ============================================================================= + +def calculate_damage(attacker: Fighter, defender: Fighter) -> int: + """Calculate damage dealt from attacker to defender. + + Args: + attacker: The attacking Fighter + defender: The defending Fighter + + Returns: + The amount of damage to deal (minimum 0) + """ + damage = max(0, attacker.attack - defender.defense) + return damage + +def perform_attack(attacker_entity: mcrfpy.Entity, defender_entity: mcrfpy.Entity) -> None: + """Execute an attack from one entity to another. + + Args: + attacker_entity: The entity performing the attack + defender_entity: The entity being attacked + """ + global game_over + + attacker = entity_data.get(attacker_entity) + defender = entity_data.get(defender_entity) + + if attacker is None or defender is None: + return + + # Calculate and apply damage + damage = calculate_damage(attacker, defender) + defender.take_damage(damage) + + # Generate combat message + if damage > 0: + if attacker.is_player: + add_message( + f"You hit the {defender.name} for {damage} damage!", + mcrfpy.Color(200, 200, 200) + ) + else: + add_message( + f"The {attacker.name} hits you for {damage} damage!", + mcrfpy.Color(255, 150, 150) + ) + else: + if attacker.is_player: + add_message( + f"You hit the {defender.name} but deal no damage.", + mcrfpy.Color(150, 150, 150) + ) + else: + add_message( + f"The {attacker.name} hits you but deals no damage.", + mcrfpy.Color(150, 150, 200) + ) + + # Check for death + if not defender.is_alive: + handle_death(defender_entity, defender) + +def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None: + """Handle the death of an entity. + + Args: + entity: The entity that died + fighter: The Fighter component of the dead entity + """ + global game_over, grid + + if fighter.is_player: + # Player death + add_message("You have died!", mcrfpy.Color(255, 50, 50)) + add_message("Press R to restart or Escape to quit.", mcrfpy.Color(200, 200, 200)) + game_over = True + + # Change player sprite to corpse + entity.sprite_index = SPRITE_CORPSE + else: + # Enemy death + add_message(f"The {fighter.name} dies!", mcrfpy.Color(100, 255, 100)) + + # Replace with corpse + entity.sprite_index = SPRITE_CORPSE + + # Mark as dead (hp is already 0) + # Remove blocking but keep visual corpse + # Actually remove the entity and its data + remove_entity(grid, entity) + + # Update HP display + update_hp_display() + +# ============================================================================= +# Field of View +# ============================================================================= + +def update_entity_visibility(target_grid: mcrfpy.Grid) -> None: + """Update visibility of all entities based on FOV.""" + global player + + for entity in target_grid.entities: + if entity == player: + entity.visible = True + continue + + ex, ey = int(entity.x), int(entity.y) + entity.visible = target_grid.is_in_fov(ex, ey) + +def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None: + """Update the field of view visualization.""" + target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if target_grid.is_in_fov(x, y): + mark_explored(x, y) + target_fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + target_fov_layer.set(x, y, COLOR_DISCOVERED) + else: + target_fov_layer.set(x, y, COLOR_UNKNOWN) + + update_entity_visibility(target_grid) + +# ============================================================================= +# Movement and Actions +# ============================================================================= + +def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int, mover: mcrfpy.Entity = None) -> bool: + """Check if a position is valid for movement.""" + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + + if not target_grid.at(x, y).walkable: + return False + + blocker = get_blocking_entity_at(target_grid, x, y, exclude=mover) + if blocker is not None: + return False + + return True + +def try_move_or_attack(dx: int, dy: int) -> None: + """Attempt to move the player or attack if blocked by enemy. + + Args: + dx: Change in X position (-1, 0, or 1) + dy: Change in Y position (-1, 0, or 1) + """ + global player, grid, fov_layer, game_over + + if game_over: + return + + px, py = int(player.x), int(player.y) + target_x = px + dx + target_y = py + dy + + # Check bounds + if target_x < 0 or target_x >= GRID_WIDTH or target_y < 0 or target_y >= GRID_HEIGHT: + return + + # Check for blocking entity + blocker = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + + if blocker is not None: + # Attack the blocking entity + perform_attack(player, blocker) + # After player attacks, enemies take their turn + enemy_turn() + elif grid.at(target_x, target_y).walkable: + # Move to the empty tile + player.x = target_x + player.y = target_y + pos_display.text = f"Position: ({target_x}, {target_y})" + + # Update FOV after movement + update_fov(grid, fov_layer, target_x, target_y) + + # Enemies take their turn after player moves + enemy_turn() + + # Update HP display + update_hp_display() + +# ============================================================================= +# Enemy AI +# ============================================================================= + +def enemy_turn() -> None: + """Execute enemy actions.""" + global player, grid, game_over + + if game_over: + return + + player_x, player_y = int(player.x), int(player.y) + + # Collect enemies that can act + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data and entity_data[entity].is_alive: + enemies.append(entity) + + for enemy in enemies: + fighter = entity_data.get(enemy) + if fighter is None or not fighter.is_alive: + continue + + ex, ey = int(enemy.x), int(enemy.y) + + # Only act if in player's FOV (aware of player) + if not grid.is_in_fov(ex, ey): + continue + + # Check if adjacent to player + dx = player_x - ex + dy = player_y - ey + + if abs(dx) <= 1 and abs(dy) <= 1 and (dx != 0 or dy != 0): + # Adjacent - attack! + perform_attack(enemy, player) + else: + # Not adjacent - try to move toward player + move_toward_player(enemy, ex, ey, player_x, player_y) + +def move_toward_player(enemy: mcrfpy.Entity, ex: int, ey: int, px: int, py: int) -> None: + """Move an enemy one step toward the player. + + Uses simple greedy movement - not true pathfinding. + """ + global grid + + # Calculate direction to player + dx = 0 + dy = 0 + + if px < ex: + dx = -1 + elif px > ex: + dx = 1 + + if py < ey: + dy = -1 + elif py > ey: + dy = 1 + + # Try to move in the desired direction + # First try the combined direction + new_x = ex + dx + new_y = ey + dy + + if can_move_to(grid, new_x, new_y, enemy): + enemy.x = new_x + enemy.y = new_y + elif dx != 0 and can_move_to(grid, ex + dx, ey, enemy): + # Try horizontal only + enemy.x = ex + dx + elif dy != 0 and can_move_to(grid, ex, ey + dy, enemy): + # Try vertical only + enemy.y = ey + dy + # If all fail, enemy stays in place + +# ============================================================================= +# UI Updates +# ============================================================================= + +def update_hp_display() -> None: + """Update the HP display in the UI.""" + global player + + if hp_display is None or player is None: + return + + if player in entity_data: + fighter = entity_data[player] + hp_display.text = f"HP: {fighter.hp}/{fighter.max_hp}" + + # Color based on health percentage + hp_percent = fighter.hp / fighter.max_hp + if hp_percent > 0.6: + hp_display.fill_color = mcrfpy.Color(100, 255, 100) + elif hp_percent > 0.3: + hp_display.fill_color = mcrfpy.Color(255, 255, 100) + else: + hp_display.fill_color = mcrfpy.Color(255, 100, 100) + +# ============================================================================= +# Game Setup +# ============================================================================= + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid +grid = mcrfpy.Grid( + pos=(50, 80), + size=(800, 480), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +# Generate initial dungeon structure +fill_with_walls(grid) +init_explored() + +rooms: list[RectangularRoom] = [] + +for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + +# Get player starting position +if rooms: + player_start_x, player_start_y = rooms[0].center +else: + player_start_x, player_start_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# Add FOV layer +fov_layer = grid.add_layer("color", z_index=-1) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +# Create the player +player = mcrfpy.Entity( + grid_pos=(player_start_x, player_start_y), + texture=texture, + sprite_index=SPRITE_PLAYER +) +grid.entities.append(player) + +# Create player Fighter component +entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True +) + +# Spawn enemies in all rooms except the first +for i, room in enumerate(rooms): + if i == 0: + continue + spawn_enemies_in_room(grid, room, texture) + +# Calculate initial FOV +update_fov(grid, fov_layer, player_start_x, player_start_y) + +# Add grid to scene +scene.children.append(grid) + +# ============================================================================= +# UI Elements +# ============================================================================= + +title = mcrfpy.Caption( + pos=(50, 15), + text="Part 6: Combat System" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +instructions = mcrfpy.Caption( + pos=(50, 50), + text="WASD/Arrows: Move/Attack | R: Restart | Escape: Quit" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 16 +scene.children.append(instructions) + +# Position display +pos_display = mcrfpy.Caption( + pos=(50, 580), + text=f"Position: ({int(player.x)}, {int(player.y)})" +) +pos_display.fill_color = mcrfpy.Color(200, 200, 100) +pos_display.font_size = 16 +scene.children.append(pos_display) + +# HP display +hp_display = mcrfpy.Caption( + pos=(300, 580), + text="HP: 30/30" +) +hp_display.fill_color = mcrfpy.Color(100, 255, 100) +hp_display.font_size = 16 +scene.children.append(hp_display) + +# Message log (positioned below the grid) +message_log_caption = mcrfpy.Caption( + pos=(50, 610), + text="" +) +message_log_caption.fill_color = mcrfpy.Color(200, 200, 200) +message_log_caption.font_size = 14 +scene.children.append(message_log_caption) + +# Initial message +add_message("Welcome to the dungeon! Find and defeat the enemies.", mcrfpy.Color(100, 100, 255)) + +# ============================================================================= +# Input Handling +# ============================================================================= + +def restart_game() -> None: + """Restart the game with a new dungeon.""" + global player, grid, fov_layer, game_over, entity_data, rooms + + game_over = False + + # Clear all entities and data + entity_data.clear() + + # Remove all entities from grid + while len(grid.entities) > 0: + grid.entities.remove(0) + + # Regenerate dungeon + fill_with_walls(grid) + init_explored() + clear_messages() + + rooms = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + + # Get new player starting position + if rooms: + new_x, new_y = rooms[0].center + else: + new_x, new_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + + # Recreate player + player = mcrfpy.Entity( + grid_pos=(new_x, new_y), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True + ) + + # Spawn enemies + for i, room in enumerate(rooms): + if i == 0: + continue + spawn_enemies_in_room(grid, room, texture) + + # Reset FOV layer + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + # Update displays + update_fov(grid, fov_layer, new_x, new_y) + pos_display.text = f"Position: ({new_x}, {new_y})" + update_hp_display() + + add_message("A new adventure begins!", mcrfpy.Color(100, 100, 255)) + +def handle_keys(key: str, action: str) -> None: + """Handle keyboard input.""" + global game_over + + if action != "start": + return + + # Handle restart + if key == "R": + restart_game() + return + + if key == "Escape": + mcrfpy.exit() + return + + # Ignore other input if game is over + if game_over: + return + + # Movement and attack + if key == "W" or key == "Up": + try_move_or_attack(0, -1) + elif key == "S" or key == "Down": + try_move_or_attack(0, 1) + elif key == "A" or key == "Left": + try_move_or_attack(-1, 0) + elif key == "D" or key == "Right": + try_move_or_attack(1, 0) + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 6 loaded! Combat is now active. Good luck!") \ No newline at end of file diff --git a/docs/tutorials/part_07_ui/part_07_ui.py b/docs/tutorials/part_07_ui/part_07_ui.py new file mode 100644 index 0000000..459adee --- /dev/null +++ b/docs/tutorials/part_07_ui/part_07_ui.py @@ -0,0 +1,1035 @@ +"""McRogueFace - Part 7: User Interface + +Documentation: https://mcrogueface.github.io/tutorial/part_07_ui +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_07_ui/part_07_ui.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 +from dataclasses import dataclass +from typing import Optional + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player +SPRITE_CORPSE = 37 # '%' - remains + +# Enemy sprites +SPRITE_GOBLIN = 103 # 'g' +SPRITE_ORC = 111 # 'o' +SPRITE_TROLL = 116 # 't' + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 30 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# Enemy spawn parameters +MAX_ENEMIES_PER_ROOM = 3 + +# FOV settings +FOV_RADIUS = 8 + +# Visibility colors +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) + +# Message colors +COLOR_PLAYER_ATTACK = mcrfpy.Color(200, 200, 200) +COLOR_ENEMY_ATTACK = mcrfpy.Color(255, 150, 150) +COLOR_PLAYER_DEATH = mcrfpy.Color(255, 50, 50) +COLOR_ENEMY_DEATH = mcrfpy.Color(100, 255, 100) +COLOR_HEAL = mcrfpy.Color(100, 255, 100) +COLOR_INFO = mcrfpy.Color(100, 100, 255) +COLOR_WARNING = mcrfpy.Color(255, 200, 50) + +# UI Layout constants +UI_TOP_HEIGHT = 60 +UI_BOTTOM_HEIGHT = 150 +GAME_AREA_Y = UI_TOP_HEIGHT +GAME_AREA_HEIGHT = 768 - UI_TOP_HEIGHT - UI_BOTTOM_HEIGHT + +# ============================================================================= +# Fighter Component +# ============================================================================= + +@dataclass +class Fighter: + """Combat stats for an entity.""" + hp: int + max_hp: int + attack: int + defense: int + name: str + is_player: bool = False + + @property + def is_alive(self) -> bool: + return self.hp > 0 + + def take_damage(self, amount: int) -> int: + actual_damage = min(self.hp, amount) + self.hp -= actual_damage + return actual_damage + + def heal(self, amount: int) -> int: + actual_heal = min(self.max_hp - self.hp, amount) + self.hp += actual_heal + return actual_heal + +# ============================================================================= +# Enemy Templates +# ============================================================================= + +ENEMY_TEMPLATES = { + "goblin": { + "sprite": SPRITE_GOBLIN, + "hp": 6, + "attack": 3, + "defense": 0, + "color": mcrfpy.Color(100, 200, 100) + }, + "orc": { + "sprite": SPRITE_ORC, + "hp": 10, + "attack": 4, + "defense": 1, + "color": mcrfpy.Color(100, 150, 100) + }, + "troll": { + "sprite": SPRITE_TROLL, + "hp": 16, + "attack": 6, + "defense": 2, + "color": mcrfpy.Color(50, 150, 50) + } +} + +# ============================================================================= +# Message Log System +# ============================================================================= + +class MessageLog: + """A message log that displays recent game messages with colors.""" + + def __init__(self, x: int, y: int, width: int, height: int, max_messages: int = 6): + """Create a new message log. + + Args: + x: X position of the log + y: Y position of the log + width: Width of the log area + height: Height of the log area + max_messages: Maximum number of messages to display + """ + self.x = x + self.y = y + self.width = width + self.height = height + self.max_messages = max_messages + self.messages: list[tuple[str, mcrfpy.Color]] = [] + self.captions: list[mcrfpy.Caption] = [] + + # Create the background frame + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + # Create caption for each message line + line_height = 20 + for i in range(max_messages): + caption = mcrfpy.Caption( + pos=(x + 10, y + 5 + i * line_height), + text="" + ) + caption.font_size = 14 + caption.fill_color = mcrfpy.Color(200, 200, 200) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + """Add the message log UI elements to a scene.""" + scene.children.append(self.frame) + for caption in self.captions: + scene.children.append(caption) + + def add(self, text: str, color: mcrfpy.Color = None) -> None: + """Add a message to the log. + + Args: + text: The message text + color: Optional color (defaults to white) + """ + if color is None: + color = mcrfpy.Color(200, 200, 200) + + self.messages.append((text, color)) + + # Keep only the most recent messages + while len(self.messages) > self.max_messages: + self.messages.pop(0) + + self._refresh() + + def _refresh(self) -> None: + """Update the caption displays with current messages.""" + for i, caption in enumerate(self.captions): + if i < len(self.messages): + text, color = self.messages[i] + caption.text = text + caption.fill_color = color + else: + caption.text = "" + + def clear(self) -> None: + """Clear all messages.""" + self.messages.clear() + self._refresh() + +# ============================================================================= +# Health Bar System +# ============================================================================= + +class HealthBar: + """A visual health bar using nested frames.""" + + def __init__(self, x: int, y: int, width: int, height: int): + """Create a new health bar. + + Args: + x: X position + y: Y position + width: Total width of the health bar + height: Height of the health bar + """ + self.x = x + self.y = y + self.width = width + self.height = height + self.max_hp = 30 + self.current_hp = 30 + + # Background frame (dark red - shows when damaged) + self.bg_frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.bg_frame.fill_color = mcrfpy.Color(80, 0, 0) + self.bg_frame.outline = 2 + self.bg_frame.outline_color = mcrfpy.Color(150, 150, 150) + + # Foreground frame (the actual health - shrinks as HP decreases) + self.fg_frame = mcrfpy.Frame( + pos=(x + 2, y + 2), + size=(width - 4, height - 4) + ) + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + self.fg_frame.outline = 0 + + # HP text label + self.label = mcrfpy.Caption( + pos=(x + 5, y + 2), + text=f"HP: {self.current_hp}/{self.max_hp}" + ) + self.label.font_size = 16 + self.label.fill_color = mcrfpy.Color(255, 255, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + """Add the health bar UI elements to a scene.""" + scene.children.append(self.bg_frame) + scene.children.append(self.fg_frame) + scene.children.append(self.label) + + def update(self, current_hp: int, max_hp: int) -> None: + """Update the health bar display. + + Args: + current_hp: Current HP value + max_hp: Maximum HP value + """ + self.current_hp = current_hp + self.max_hp = max_hp + + # Calculate fill percentage + percent = max(0, current_hp / max_hp) if max_hp > 0 else 0 + + # Update the foreground width + inner_width = self.width - 4 + self.fg_frame.resize(int(inner_width * percent), self.height - 4) + + # Update the label + self.label.text = f"HP: {current_hp}/{max_hp}" + + # Update color based on health percentage + if percent > 0.6: + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) # Green + elif percent > 0.3: + self.fg_frame.fill_color = mcrfpy.Color(180, 180, 0) # Yellow + else: + self.fg_frame.fill_color = mcrfpy.Color(180, 0, 0) # Red + +# ============================================================================= +# Stats Panel +# ============================================================================= + +class StatsPanel: + """A panel displaying player stats and dungeon info.""" + + def __init__(self, x: int, y: int, width: int, height: int): + """Create a new stats panel. + + Args: + x: X position + y: Y position + width: Panel width + height: Panel height + """ + self.x = x + self.y = y + self.width = width + self.height = height + + # Background frame + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + # Dungeon level caption + self.level_caption = mcrfpy.Caption( + pos=(x + 10, y + 10), + text="Dungeon Level: 1" + ) + self.level_caption.font_size = 16 + self.level_caption.fill_color = mcrfpy.Color(200, 200, 255) + + # Attack stat caption + self.attack_caption = mcrfpy.Caption( + pos=(x + 10, y + 35), + text="Attack: 5" + ) + self.attack_caption.font_size = 14 + self.attack_caption.fill_color = mcrfpy.Color(255, 200, 150) + + # Defense stat caption + self.defense_caption = mcrfpy.Caption( + pos=(x + 120, y + 35), + text="Defense: 2" + ) + self.defense_caption.font_size = 14 + self.defense_caption.fill_color = mcrfpy.Color(150, 200, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + """Add the stats panel UI elements to a scene.""" + scene.children.append(self.frame) + scene.children.append(self.level_caption) + scene.children.append(self.attack_caption) + scene.children.append(self.defense_caption) + + def update(self, dungeon_level: int, attack: int, defense: int) -> None: + """Update the stats panel display. + + Args: + dungeon_level: Current dungeon level + attack: Player attack stat + defense: Player defense stat + """ + self.level_caption.text = f"Dungeon Level: {dungeon_level}" + self.attack_caption.text = f"Attack: {attack}" + self.defense_caption.text = f"Defense: {defense}" + +# ============================================================================= +# Global State +# ============================================================================= + +entity_data: dict[mcrfpy.Entity, Fighter] = {} +player: Optional[mcrfpy.Entity] = None +grid: Optional[mcrfpy.Grid] = None +fov_layer = None +texture: Optional[mcrfpy.Texture] = None +game_over: bool = False +dungeon_level: int = 1 + +# UI components +message_log: Optional[MessageLog] = None +health_bar: Optional[HealthBar] = None +stats_panel: Optional[StatsPanel] = None + +# ============================================================================= +# Room Class +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> tuple[int, int]: + 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 slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return ( + self.x1 <= other.x2 and + self.x2 >= other.x1 and + self.y1 <= other.y2 and + self.y2 >= other.y1 + ) + +# ============================================================================= +# Exploration Tracking +# ============================================================================= + +explored: list[list[bool]] = [] + +def init_explored() -> None: + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Dungeon Generation +# ============================================================================= + +def fill_with_walls(target_grid: mcrfpy.Grid) -> None: + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(target_grid: mcrfpy.Grid, room: RectangularRoom) -> None: + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(target_grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(target_grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + target_grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + x1, y1 = start + x2, y2 = end + + if random.random() < 0.5: + carve_tunnel_horizontal(target_grid, x1, x2, y1) + carve_tunnel_vertical(target_grid, y1, y2, x2) + else: + carve_tunnel_vertical(target_grid, y1, y2, x1) + carve_tunnel_horizontal(target_grid, x1, x2, y2) + +# ============================================================================= +# Entity Management +# ============================================================================= + +def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ENEMY_TEMPLATES[enemy_type] + + enemy = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + enemy.visible = False + + target_grid.entities.append(enemy) + + entity_data[enemy] = Fighter( + hp=template["hp"], + max_hp=template["hp"], + attack=template["attack"], + defense=template["defense"], + name=enemy_type.capitalize(), + is_player=False + ) + + return enemy + +def spawn_enemies_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture) -> None: + num_enemies = random.randint(0, MAX_ENEMIES_PER_ROOM) + + for _ in range(num_enemies): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + if get_entity_at(target_grid, x, y) is not None: + continue + + roll = random.random() + if roll < 0.6: + enemy_type = "goblin" + elif roll < 0.9: + enemy_type = "orc" + else: + enemy_type = "troll" + + spawn_enemy(target_grid, x, y, enemy_type, tex) + +def get_entity_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if int(entity.x) == x and int(entity.y) == y: + if entity in entity_data: + if entity_data[entity].is_alive: + return entity + else: + return entity + return None + +def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mcrfpy.Entity = None) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity == exclude: + continue + if int(entity.x) == x and int(entity.y) == y: + if entity in entity_data and entity_data[entity].is_alive: + return entity + return None + +def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in entity_data: + del entity_data[entity] + +def clear_enemies(target_grid: mcrfpy.Grid) -> None: + enemies_to_remove = [] + for entity in target_grid.entities: + if entity in entity_data and not entity_data[entity].is_player: + enemies_to_remove.append(entity) + for enemy in enemies_to_remove: + remove_entity(target_grid, enemy) + +# ============================================================================= +# Combat System +# ============================================================================= + +def calculate_damage(attacker: Fighter, defender: Fighter) -> int: + return max(0, attacker.attack - defender.defense) + +def perform_attack(attacker_entity: mcrfpy.Entity, defender_entity: mcrfpy.Entity) -> None: + global game_over + + attacker = entity_data.get(attacker_entity) + defender = entity_data.get(defender_entity) + + if attacker is None or defender is None: + return + + damage = calculate_damage(attacker, defender) + defender.take_damage(damage) + + if damage > 0: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} for {damage} damage!", + COLOR_PLAYER_ATTACK + ) + else: + message_log.add( + f"The {attacker.name} hits you for {damage} damage!", + COLOR_ENEMY_ATTACK + ) + else: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} but deal no damage.", + mcrfpy.Color(150, 150, 150) + ) + else: + message_log.add( + f"The {attacker.name} hits but deals no damage.", + mcrfpy.Color(150, 150, 200) + ) + + if not defender.is_alive: + handle_death(defender_entity, defender) + + update_ui() + +def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None: + global game_over, grid + + if fighter.is_player: + message_log.add("You have died!", COLOR_PLAYER_DEATH) + message_log.add("Press R to restart or Escape to quit.", COLOR_INFO) + game_over = True + entity.sprite_index = SPRITE_CORPSE + else: + message_log.add(f"The {fighter.name} dies!", COLOR_ENEMY_DEATH) + remove_entity(grid, entity) + +# ============================================================================= +# Field of View +# ============================================================================= + +def update_entity_visibility(target_grid: mcrfpy.Grid) -> None: + global player + + for entity in target_grid.entities: + if entity == player: + entity.visible = True + continue + + ex, ey = int(entity.x), int(entity.y) + entity.visible = target_grid.is_in_fov(ex, ey) + +def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None: + target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if target_grid.is_in_fov(x, y): + mark_explored(x, y) + target_fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + target_fov_layer.set(x, y, COLOR_DISCOVERED) + else: + target_fov_layer.set(x, y, COLOR_UNKNOWN) + + update_entity_visibility(target_grid) + +# ============================================================================= +# Movement and Actions +# ============================================================================= + +def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int, mover: mcrfpy.Entity = None) -> bool: + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + + if not target_grid.at(x, y).walkable: + return False + + blocker = get_blocking_entity_at(target_grid, x, y, exclude=mover) + if blocker is not None: + return False + + return True + +def try_move_or_attack(dx: int, dy: int) -> None: + global player, grid, fov_layer, game_over + + if game_over: + return + + px, py = int(player.x), int(player.y) + target_x = px + dx + target_y = py + dy + + if target_x < 0 or target_x >= GRID_WIDTH or target_y < 0 or target_y >= GRID_HEIGHT: + return + + blocker = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + + if blocker is not None: + perform_attack(player, blocker) + enemy_turn() + elif grid.at(target_x, target_y).walkable: + player.x = target_x + player.y = target_y + update_fov(grid, fov_layer, target_x, target_y) + enemy_turn() + + update_ui() + +# ============================================================================= +# Enemy AI +# ============================================================================= + +def enemy_turn() -> None: + global player, grid, game_over + + if game_over: + return + + player_x, player_y = int(player.x), int(player.y) + + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data and entity_data[entity].is_alive: + enemies.append(entity) + + for enemy in enemies: + fighter = entity_data.get(enemy) + if fighter is None or not fighter.is_alive: + continue + + ex, ey = int(enemy.x), int(enemy.y) + + if not grid.is_in_fov(ex, ey): + continue + + dx = player_x - ex + dy = player_y - ey + + if abs(dx) <= 1 and abs(dy) <= 1 and (dx != 0 or dy != 0): + perform_attack(enemy, player) + else: + move_toward_player(enemy, ex, ey, player_x, player_y) + +def move_toward_player(enemy: mcrfpy.Entity, ex: int, ey: int, px: int, py: int) -> None: + global grid + + dx = 0 + dy = 0 + + if px < ex: + dx = -1 + elif px > ex: + dx = 1 + + if py < ey: + dy = -1 + elif py > ey: + dy = 1 + + new_x = ex + dx + new_y = ey + dy + + if can_move_to(grid, new_x, new_y, enemy): + enemy.x = new_x + enemy.y = new_y + elif dx != 0 and can_move_to(grid, ex + dx, ey, enemy): + enemy.x = ex + dx + elif dy != 0 and can_move_to(grid, ex, ey + dy, enemy): + enemy.y = ey + dy + +# ============================================================================= +# UI Updates +# ============================================================================= + +def update_ui() -> None: + """Update all UI components.""" + global player, health_bar, stats_panel, dungeon_level + + if player in entity_data: + fighter = entity_data[player] + health_bar.update(fighter.hp, fighter.max_hp) + stats_panel.update(dungeon_level, fighter.attack, fighter.defense) + +# ============================================================================= +# Game Setup +# ============================================================================= + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid (positioned to leave room for UI) +grid = mcrfpy.Grid( + pos=(20, GAME_AREA_Y), + size=(800, GAME_AREA_HEIGHT - 20), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +# Generate initial dungeon structure +fill_with_walls(grid) +init_explored() + +rooms: list[RectangularRoom] = [] + +for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + +# Get player starting position +if rooms: + player_start_x, player_start_y = rooms[0].center +else: + player_start_x, player_start_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# Add FOV layer +fov_layer = grid.add_layer("color", z_index=-1) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +# Create the player +player = mcrfpy.Entity( + grid_pos=(player_start_x, player_start_y), + texture=texture, + sprite_index=SPRITE_PLAYER +) +grid.entities.append(player) + +entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True +) + +# Spawn enemies +for i, room in enumerate(rooms): + if i == 0: + continue + spawn_enemies_in_room(grid, room, texture) + +# Calculate initial FOV +update_fov(grid, fov_layer, player_start_x, player_start_y) + +# Add grid to scene +scene.children.append(grid) + +# ============================================================================= +# Create UI Elements +# ============================================================================= + +# Title bar +title = mcrfpy.Caption( + pos=(20, 10), + text="Part 7: User Interface" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +# Instructions +instructions = mcrfpy.Caption( + pos=(300, 15), + text="WASD/Arrows: Move/Attack | R: Restart | Escape: Quit" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 14 +scene.children.append(instructions) + +# Health Bar (top right area) +health_bar = HealthBar( + x=700, + y=10, + width=300, + height=30 +) +health_bar.add_to_scene(scene) + +# Stats Panel (below health bar) +stats_panel = StatsPanel( + x=830, + y=GAME_AREA_Y, + width=180, + height=80 +) +stats_panel.add_to_scene(scene) + +# Message Log (bottom of screen) +message_log = MessageLog( + x=20, + y=768 - UI_BOTTOM_HEIGHT + 10, + width=800, + height=UI_BOTTOM_HEIGHT - 20, + max_messages=6 +) +message_log.add_to_scene(scene) + +# Initial messages +message_log.add("Welcome to the dungeon!", COLOR_INFO) +message_log.add("Find and defeat all enemies to progress.", COLOR_INFO) + +# Initialize UI +update_ui() + +# ============================================================================= +# Input Handling +# ============================================================================= + +def restart_game() -> None: + """Restart the game with a new dungeon.""" + global player, grid, fov_layer, game_over, entity_data, rooms, dungeon_level + + game_over = False + dungeon_level = 1 + + entity_data.clear() + + while len(grid.entities) > 0: + grid.entities.remove(0) + + fill_with_walls(grid) + init_explored() + message_log.clear() + + rooms = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + + if rooms: + new_x, new_y = rooms[0].center + else: + new_x, new_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + + player = mcrfpy.Entity( + grid_pos=(new_x, new_y), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True + ) + + for i, room in enumerate(rooms): + if i == 0: + continue + spawn_enemies_in_room(grid, room, texture) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, new_x, new_y) + + message_log.add("A new adventure begins!", COLOR_INFO) + + update_ui() + +def handle_keys(key: str, action: str) -> None: + global game_over + + if action != "start": + return + + if key == "R": + restart_game() + return + + if key == "Escape": + mcrfpy.exit() + return + + if game_over: + return + + if key == "W" or key == "Up": + try_move_or_attack(0, -1) + elif key == "S" or key == "Down": + try_move_or_attack(0, 1) + elif key == "A" or key == "Left": + try_move_or_attack(-1, 0) + elif key == "D" or key == "Right": + try_move_or_attack(1, 0) + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 7 loaded! Notice the improved UI with health bar and message log.") \ No newline at end of file diff --git a/docs/tutorials/part_08_items/part_08_items.py b/docs/tutorials/part_08_items/part_08_items.py new file mode 100644 index 0000000..e8f271e --- /dev/null +++ b/docs/tutorials/part_08_items/part_08_items.py @@ -0,0 +1,1275 @@ +"""McRogueFace - Part 8: Items and Inventory + +Documentation: https://mcrogueface.github.io/tutorial/part_08_items +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_08_items/part_08_items.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 +from dataclasses import dataclass, field +from typing import Optional + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player +SPRITE_CORPSE = 37 # '%' - remains +SPRITE_POTION = 173 # Potion sprite (or use '!' = 33) + +# Enemy sprites +SPRITE_GOBLIN = 103 # 'g' +SPRITE_ORC = 111 # 'o' +SPRITE_TROLL = 116 # 't' + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 30 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# Enemy spawn parameters +MAX_ENEMIES_PER_ROOM = 3 + +# Item spawn parameters +MAX_ITEMS_PER_ROOM = 2 + +# FOV settings +FOV_RADIUS = 8 + +# Visibility colors +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) + +# Message colors +COLOR_PLAYER_ATTACK = mcrfpy.Color(200, 200, 200) +COLOR_ENEMY_ATTACK = mcrfpy.Color(255, 150, 150) +COLOR_PLAYER_DEATH = mcrfpy.Color(255, 50, 50) +COLOR_ENEMY_DEATH = mcrfpy.Color(100, 255, 100) +COLOR_HEAL = mcrfpy.Color(100, 255, 100) +COLOR_PICKUP = mcrfpy.Color(100, 200, 255) +COLOR_INFO = mcrfpy.Color(100, 100, 255) +COLOR_WARNING = mcrfpy.Color(255, 200, 50) +COLOR_INVALID = mcrfpy.Color(255, 100, 100) + +# UI Layout constants +UI_TOP_HEIGHT = 60 +UI_BOTTOM_HEIGHT = 150 +GAME_AREA_Y = UI_TOP_HEIGHT +GAME_AREA_HEIGHT = 768 - UI_TOP_HEIGHT - UI_BOTTOM_HEIGHT + +# ============================================================================= +# Fighter Component +# ============================================================================= + +@dataclass +class Fighter: + """Combat stats for an entity.""" + hp: int + max_hp: int + attack: int + defense: int + name: str + is_player: bool = False + + @property + def is_alive(self) -> bool: + return self.hp > 0 + + def take_damage(self, amount: int) -> int: + actual_damage = min(self.hp, amount) + self.hp -= actual_damage + return actual_damage + + def heal(self, amount: int) -> int: + actual_heal = min(self.max_hp - self.hp, amount) + self.hp += actual_heal + return actual_heal + +# ============================================================================= +# Item Component +# ============================================================================= + +@dataclass +class Item: + """Data for an item that can be picked up and used.""" + name: str + item_type: str + heal_amount: int = 0 + + def describe(self) -> str: + """Return a description of what this item does.""" + if self.item_type == "health_potion": + return f"Restores {self.heal_amount} HP" + return "Unknown item" + +# ============================================================================= +# Inventory System +# ============================================================================= + +@dataclass +class Inventory: + """Container for items the player is carrying.""" + capacity: int = 10 + items: list = field(default_factory=list) + + def add(self, item: Item) -> bool: + """Add an item to the inventory. + + Args: + item: The item to add + + Returns: + True if item was added, False if inventory is full + """ + if len(self.items) >= self.capacity: + return False + self.items.append(item) + return True + + def remove(self, index: int) -> Optional[Item]: + """Remove and return an item by index. + + Args: + index: The index of the item to remove + + Returns: + The removed item, or None if index is invalid + """ + if 0 <= index < len(self.items): + return self.items.pop(index) + return None + + def get(self, index: int) -> Optional[Item]: + """Get an item by index without removing it.""" + if 0 <= index < len(self.items): + return self.items[index] + return None + + def is_full(self) -> bool: + """Check if the inventory is full.""" + return len(self.items) >= self.capacity + + def count(self) -> int: + """Return the number of items in the inventory.""" + return len(self.items) + +# ============================================================================= +# Item Templates +# ============================================================================= + +ITEM_TEMPLATES = { + "health_potion": { + "name": "Health Potion", + "sprite": SPRITE_POTION, + + "item_type": "health_potion", + "heal_amount": 10 + } +} + +# ============================================================================= +# Enemy Templates +# ============================================================================= + +ENEMY_TEMPLATES = { + "goblin": { + "sprite": SPRITE_GOBLIN, + "hp": 6, + "attack": 3, + "defense": 0, + "color": mcrfpy.Color(100, 200, 100) + }, + "orc": { + "sprite": SPRITE_ORC, + "hp": 10, + "attack": 4, + "defense": 1, + "color": mcrfpy.Color(100, 150, 100) + }, + "troll": { + "sprite": SPRITE_TROLL, + "hp": 16, + "attack": 6, + "defense": 2, + "color": mcrfpy.Color(50, 150, 50) + } +} + +# ============================================================================= +# Message Log System +# ============================================================================= + +class MessageLog: + """A message log that displays recent game messages with colors.""" + + def __init__(self, x: int, y: int, width: int, height: int, max_messages: int = 6): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_messages = max_messages + self.messages: list[tuple[str, mcrfpy.Color]] = [] + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + line_height = 20 + for i in range(max_messages): + caption = mcrfpy.Caption( + pos=(x + 10, y + 5 + i * line_height), + text="" + ) + caption.font_size = 14 + caption.fill_color = mcrfpy.Color(200, 200, 200) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + for caption in self.captions: + scene.children.append(caption) + + def add(self, text: str, color: mcrfpy.Color = None) -> None: + if color is None: + color = mcrfpy.Color(200, 200, 200) + + self.messages.append((text, color)) + + while len(self.messages) > self.max_messages: + self.messages.pop(0) + + self._refresh() + + def _refresh(self) -> None: + for i, caption in enumerate(self.captions): + if i < len(self.messages): + text, color = self.messages[i] + caption.text = text + caption.fill_color = color + else: + caption.text = "" + + def clear(self) -> None: + self.messages.clear() + self._refresh() + +# ============================================================================= +# Health Bar System +# ============================================================================= + +class HealthBar: + """A visual health bar using nested frames.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_hp = 30 + self.current_hp = 30 + + self.bg_frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.bg_frame.fill_color = mcrfpy.Color(80, 0, 0) + self.bg_frame.outline = 2 + self.bg_frame.outline_color = mcrfpy.Color(150, 150, 150) + + self.fg_frame = mcrfpy.Frame( + pos=(x + 2, y + 2), + size=(width - 4, height - 4) + ) + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + self.fg_frame.outline = 0 + + self.label = mcrfpy.Caption( + pos=(x + 5, y + 2), + text=f"HP: {self.current_hp}/{self.max_hp}" + ) + self.label.font_size = 16 + self.label.fill_color = mcrfpy.Color(255, 255, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.bg_frame) + scene.children.append(self.fg_frame) + scene.children.append(self.label) + + def update(self, current_hp: int, max_hp: int) -> None: + self.current_hp = current_hp + self.max_hp = max_hp + + percent = max(0, current_hp / max_hp) if max_hp > 0 else 0 + + inner_width = self.width - 4 + self.fg_frame.resize(int(inner_width * percent), self.height - 4) + + self.label.text = f"HP: {current_hp}/{max_hp}" + + if percent > 0.6: + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + elif percent > 0.3: + self.fg_frame.fill_color = mcrfpy.Color(180, 180, 0) + else: + self.fg_frame.fill_color = mcrfpy.Color(180, 0, 0) + +# ============================================================================= +# Inventory Panel +# ============================================================================= + +class InventoryPanel: + """A panel displaying the player's inventory.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + self.title = mcrfpy.Caption( + pos=(x + 10, y + 5), + text="Inventory (G:pickup, U:use)" + ) + self.title.font_size = 14 + self.title.fill_color = mcrfpy.Color(200, 200, 255) + + # Create slots for inventory items + for i in range(5): # Show up to 5 items + caption = mcrfpy.Caption( + pos=(x + 10, y + 25 + i * 18), + text="" + ) + caption.font_size = 13 + caption.fill_color = mcrfpy.Color(180, 180, 180) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + scene.children.append(self.title) + for caption in self.captions: + scene.children.append(caption) + + def update(self, inventory: Inventory) -> None: + """Update the inventory display.""" + for i, caption in enumerate(self.captions): + if i < len(inventory.items): + item = inventory.items[i] + caption.text = f"{i+1}. {item.name}" + caption.fill_color = mcrfpy.Color(180, 180, 180) + else: + caption.text = f"{i+1}. ---" + caption.fill_color = mcrfpy.Color(80, 80, 80) + +# ============================================================================= +# Global State +# ============================================================================= + +entity_data: dict[mcrfpy.Entity, Fighter] = {} +item_data: dict[mcrfpy.Entity, Item] = {} + +player: Optional[mcrfpy.Entity] = None +player_inventory: Optional[Inventory] = None +grid: Optional[mcrfpy.Grid] = None +fov_layer = None +texture: Optional[mcrfpy.Texture] = None +game_over: bool = False +dungeon_level: int = 1 + +# UI components +message_log: Optional[MessageLog] = None +health_bar: Optional[HealthBar] = None +inventory_panel: Optional[InventoryPanel] = None + +# ============================================================================= +# Room Class +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> tuple[int, int]: + 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 slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return ( + self.x1 <= other.x2 and + self.x2 >= other.x1 and + self.y1 <= other.y2 and + self.y2 >= other.y1 + ) + +# ============================================================================= +# Exploration Tracking +# ============================================================================= + +explored: list[list[bool]] = [] + +def init_explored() -> None: + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Dungeon Generation +# ============================================================================= + +def fill_with_walls(target_grid: mcrfpy.Grid) -> None: + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(target_grid: mcrfpy.Grid, room: RectangularRoom) -> None: + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(target_grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(target_grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + target_grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + x1, y1 = start + x2, y2 = end + + if random.random() < 0.5: + carve_tunnel_horizontal(target_grid, x1, x2, y1) + carve_tunnel_vertical(target_grid, y1, y2, x2) + else: + carve_tunnel_vertical(target_grid, y1, y2, x1) + carve_tunnel_horizontal(target_grid, x1, x2, y2) + +# ============================================================================= +# Entity Management +# ============================================================================= + +def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ENEMY_TEMPLATES[enemy_type] + + enemy = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + enemy.visible = False + + target_grid.entities.append(enemy) + + entity_data[enemy] = Fighter( + hp=template["hp"], + max_hp=template["hp"], + attack=template["attack"], + defense=template["defense"], + name=enemy_type.capitalize(), + is_player=False + ) + + return enemy + +def spawn_enemies_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture) -> None: + num_enemies = random.randint(0, MAX_ENEMIES_PER_ROOM) + + for _ in range(num_enemies): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + if is_position_occupied(target_grid, x, y): + continue + + roll = random.random() + if roll < 0.6: + enemy_type = "goblin" + elif roll < 0.9: + enemy_type = "orc" + else: + enemy_type = "troll" + + spawn_enemy(target_grid, x, y, enemy_type, tex) + +# ============================================================================= +# Item Management +# ============================================================================= + +def spawn_item(target_grid: mcrfpy.Grid, x: int, y: int, item_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + """Spawn an item at the given position. + + Args: + target_grid: The game grid + x: X position in tiles + y: Y position in tiles + item_type: Type of item to spawn + tex: The texture to use + + Returns: + The created item entity + """ + template = ITEM_TEMPLATES[item_type] + + item_entity = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + item_entity.visible = False + + target_grid.entities.append(item_entity) + + # Create Item data + item_data[item_entity] = Item( + name=template["name"], + item_type=template["item_type"], + heal_amount=template.get("heal_amount", 0) + ) + + return item_entity + +def spawn_items_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture) -> None: + """Spawn random items in a room. + + Args: + target_grid: The game grid + room: The room to spawn items in + tex: The texture to use + """ + num_items = random.randint(0, MAX_ITEMS_PER_ROOM) + + for _ in range(num_items): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + # Check if position is blocked by entity or item + if is_position_occupied(target_grid, x, y): + continue + + # For now, only spawn health potions + spawn_item(target_grid, x, y, "health_potion", tex) + +def get_item_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]: + """Get an item entity at the given position. + + Args: + target_grid: The game grid + x: X position to check + y: Y position to check + + Returns: + The item entity, or None if no item at position + """ + for entity in target_grid.entities: + if entity in item_data: + if int(entity.x) == x and int(entity.y) == y: + return entity + return None + +def pickup_item() -> bool: + """Try to pick up an item at the player's position. + + Returns: + True if an item was picked up, False otherwise + """ + global player, player_inventory, grid + + px, py = int(player.x), int(player.y) + item_entity = get_item_at(grid, px, py) + + if item_entity is None: + message_log.add("There is nothing to pick up here.", COLOR_INVALID) + return False + + if player_inventory.is_full(): + message_log.add("Your inventory is full!", COLOR_WARNING) + return False + + # Get the item data + item = item_data.get(item_entity) + if item is None: + return False + + # Add to inventory + player_inventory.add(item) + + # Remove from ground + remove_item_entity(grid, item_entity) + + message_log.add(f"You pick up the {item.name}.", COLOR_PICKUP) + + update_ui() + return True + +def use_item(index: int) -> bool: + """Use an item from the inventory. + + Args: + index: The inventory index of the item to use + + Returns: + True if an item was used, False otherwise + """ + global player, player_inventory + + item = player_inventory.get(index) + if item is None: + message_log.add("Invalid item selection.", COLOR_INVALID) + return False + + # Handle different item types + if item.item_type == "health_potion": + fighter = entity_data.get(player) + if fighter is None: + return False + + if fighter.hp >= fighter.max_hp: + message_log.add("You are already at full health!", COLOR_WARNING) + return False + + # Apply healing + actual_heal = fighter.heal(item.heal_amount) + + # Remove the item from inventory + player_inventory.remove(index) + + message_log.add(f"You drink the {item.name} and recover {actual_heal} HP!", COLOR_HEAL) + + update_ui() + return True + + message_log.add(f"You cannot use the {item.name}.", COLOR_INVALID) + return False + +def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + """Remove an item entity from the grid and item_data.""" + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + + if entity in item_data: + del item_data[entity] + +# ============================================================================= +# Position Checking +# ============================================================================= + +def is_position_occupied(target_grid: mcrfpy.Grid, x: int, y: int) -> bool: + """Check if a position has any entity (enemy, item, or player). + + Args: + target_grid: The game grid + x: X position to check + y: Y position to check + + Returns: + True if position is occupied, False otherwise + """ + for entity in target_grid.entities: + if int(entity.x) == x and int(entity.y) == y: + return True + return False + +def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mcrfpy.Entity = None) -> Optional[mcrfpy.Entity]: + """Get any living entity that blocks movement at the given position.""" + for entity in target_grid.entities: + if entity == exclude: + continue + if int(entity.x) == x and int(entity.y) == y: + # Only fighters block movement + if entity in entity_data and entity_data[entity].is_alive: + return entity + return None + +def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in entity_data: + del entity_data[entity] + +def clear_all_entities(target_grid: mcrfpy.Grid) -> None: + """Remove all entities (enemies and items) except the player.""" + global entity_data, item_data + + entities_to_remove = [] + for entity in target_grid.entities: + if entity in entity_data and not entity_data[entity].is_player: + entities_to_remove.append(entity) + elif entity in item_data: + entities_to_remove.append(entity) + + for entity in entities_to_remove: + if entity in entity_data: + del entity_data[entity] + if entity in item_data: + del item_data[entity] + + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + +# ============================================================================= +# Combat System +# ============================================================================= + +def calculate_damage(attacker: Fighter, defender: Fighter) -> int: + return max(0, attacker.attack - defender.defense) + +def perform_attack(attacker_entity: mcrfpy.Entity, defender_entity: mcrfpy.Entity) -> None: + global game_over + + attacker = entity_data.get(attacker_entity) + defender = entity_data.get(defender_entity) + + if attacker is None or defender is None: + return + + damage = calculate_damage(attacker, defender) + defender.take_damage(damage) + + if damage > 0: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} for {damage} damage!", + COLOR_PLAYER_ATTACK + ) + else: + message_log.add( + f"The {attacker.name} hits you for {damage} damage!", + COLOR_ENEMY_ATTACK + ) + else: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} but deal no damage.", + mcrfpy.Color(150, 150, 150) + ) + else: + message_log.add( + f"The {attacker.name} hits but deals no damage.", + mcrfpy.Color(150, 150, 200) + ) + + if not defender.is_alive: + handle_death(defender_entity, defender) + + update_ui() + +def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None: + global game_over, grid + + if fighter.is_player: + message_log.add("You have died!", COLOR_PLAYER_DEATH) + message_log.add("Press R to restart or Escape to quit.", COLOR_INFO) + game_over = True + entity.sprite_index = SPRITE_CORPSE + else: + message_log.add(f"The {fighter.name} dies!", COLOR_ENEMY_DEATH) + remove_entity(grid, entity) + +# ============================================================================= +# Field of View +# ============================================================================= + +def update_entity_visibility(target_grid: mcrfpy.Grid) -> None: + global player + + for entity in target_grid.entities: + if entity == player: + entity.visible = True + continue + + ex, ey = int(entity.x), int(entity.y) + entity.visible = target_grid.is_in_fov(ex, ey) + +def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None: + target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if target_grid.is_in_fov(x, y): + mark_explored(x, y) + target_fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + target_fov_layer.set(x, y, COLOR_DISCOVERED) + else: + target_fov_layer.set(x, y, COLOR_UNKNOWN) + + update_entity_visibility(target_grid) + +# ============================================================================= +# Movement and Actions +# ============================================================================= + +def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int, mover: mcrfpy.Entity = None) -> bool: + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + + if not target_grid.at(x, y).walkable: + return False + + blocker = get_blocking_entity_at(target_grid, x, y, exclude=mover) + if blocker is not None: + return False + + return True + +def try_move_or_attack(dx: int, dy: int) -> None: + global player, grid, fov_layer, game_over + + if game_over: + return + + px, py = int(player.x), int(player.y) + target_x = px + dx + target_y = py + dy + + if target_x < 0 or target_x >= GRID_WIDTH or target_y < 0 or target_y >= GRID_HEIGHT: + return + + blocker = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + + if blocker is not None: + perform_attack(player, blocker) + enemy_turn() + elif grid.at(target_x, target_y).walkable: + player.x = target_x + player.y = target_y + update_fov(grid, fov_layer, target_x, target_y) + enemy_turn() + + update_ui() + +# ============================================================================= +# Enemy AI +# ============================================================================= + +def enemy_turn() -> None: + global player, grid, game_over + + if game_over: + return + + player_x, player_y = int(player.x), int(player.y) + + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data and entity_data[entity].is_alive: + enemies.append(entity) + + for enemy in enemies: + fighter = entity_data.get(enemy) + if fighter is None or not fighter.is_alive: + continue + + ex, ey = int(enemy.x), int(enemy.y) + + if not grid.is_in_fov(ex, ey): + continue + + dx = player_x - ex + dy = player_y - ey + + if abs(dx) <= 1 and abs(dy) <= 1 and (dx != 0 or dy != 0): + perform_attack(enemy, player) + else: + move_toward_player(enemy, ex, ey, player_x, player_y) + +def move_toward_player(enemy: mcrfpy.Entity, ex: int, ey: int, px: int, py: int) -> None: + global grid + + dx = 0 + dy = 0 + + if px < ex: + dx = -1 + elif px > ex: + dx = 1 + + if py < ey: + dy = -1 + elif py > ey: + dy = 1 + + new_x = ex + dx + new_y = ey + dy + + if can_move_to(grid, new_x, new_y, enemy): + enemy.x = new_x + enemy.y = new_y + elif dx != 0 and can_move_to(grid, ex + dx, ey, enemy): + enemy.x = ex + dx + elif dy != 0 and can_move_to(grid, ex, ey + dy, enemy): + enemy.y = ey + dy + +# ============================================================================= +# UI Updates +# ============================================================================= + +def update_ui() -> None: + """Update all UI components.""" + global player, health_bar, inventory_panel, player_inventory + + if player in entity_data: + fighter = entity_data[player] + health_bar.update(fighter.hp, fighter.max_hp) + + if player_inventory: + inventory_panel.update(player_inventory) + +# ============================================================================= +# Game Setup +# ============================================================================= + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid +grid = mcrfpy.Grid( + pos=(20, GAME_AREA_Y), + size=(700, GAME_AREA_HEIGHT - 20), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +# Generate initial dungeon +fill_with_walls(grid) +init_explored() + +rooms: list[RectangularRoom] = [] + +for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + +# Get player starting position +if rooms: + player_start_x, player_start_y = rooms[0].center +else: + player_start_x, player_start_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# Add FOV layer +fov_layer = grid.add_layer("color", z_index=-1) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +# Create the player +player = mcrfpy.Entity( + grid_pos=(player_start_x, player_start_y), + texture=texture, + sprite_index=SPRITE_PLAYER +) +grid.entities.append(player) + +entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True +) + +# Create player inventory +player_inventory = Inventory(capacity=10) + +# Spawn enemies and items +for i, room in enumerate(rooms): + if i == 0: + continue # Skip player's starting room + spawn_enemies_in_room(grid, room, texture) + spawn_items_in_room(grid, room, texture) + +# Calculate initial FOV +update_fov(grid, fov_layer, player_start_x, player_start_y) + +# Add grid to scene +scene.children.append(grid) + +# ============================================================================= +# Create UI Elements +# ============================================================================= + +# Title bar +title = mcrfpy.Caption( + pos=(20, 10), + text="Part 8: Items and Inventory" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +# Instructions +instructions = mcrfpy.Caption( + pos=(300, 15), + text="WASD: Move | G: Pickup | 1-5: Use item | R: Restart" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 14 +scene.children.append(instructions) + +# Health Bar +health_bar = HealthBar( + x=730, + y=10, + width=280, + height=30 +) +health_bar.add_to_scene(scene) + +# Inventory Panel +inventory_panel = InventoryPanel( + x=730, + y=GAME_AREA_Y, + width=280, + height=150 +) +inventory_panel.add_to_scene(scene) + +# Message Log +message_log = MessageLog( + x=20, + y=768 - UI_BOTTOM_HEIGHT + 10, + width=990, + height=UI_BOTTOM_HEIGHT - 20, + max_messages=6 +) +message_log.add_to_scene(scene) + +# Initial messages +message_log.add("Welcome to the dungeon!", COLOR_INFO) +message_log.add("Find potions to heal. Press G to pick up items.", COLOR_INFO) + +# Initialize UI +update_ui() + +# ============================================================================= +# Input Handling +# ============================================================================= + +def restart_game() -> None: + """Restart the game with a new dungeon.""" + global player, grid, fov_layer, game_over, entity_data, item_data, rooms + global player_inventory + + game_over = False + + entity_data.clear() + item_data.clear() + + while len(grid.entities) > 0: + grid.entities.remove(0) + + fill_with_walls(grid) + init_explored() + message_log.clear() + + rooms = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + + if rooms: + new_x, new_y = rooms[0].center + else: + new_x, new_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + + player = mcrfpy.Entity( + grid_pos=(new_x, new_y), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True + ) + + # Reset inventory + player_inventory = Inventory(capacity=10) + + for i, room in enumerate(rooms): + if i == 0: + continue + spawn_enemies_in_room(grid, room, texture) + spawn_items_in_room(grid, room, texture) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, new_x, new_y) + + message_log.add("A new adventure begins!", COLOR_INFO) + + update_ui() + +def handle_keys(key: str, action: str) -> None: + global game_over + + if action != "start": + return + + if key == "R": + restart_game() + return + + if key == "Escape": + mcrfpy.exit() + return + + if game_over: + return + + # Movement + if key == "W" or key == "Up": + try_move_or_attack(0, -1) + elif key == "S" or key == "Down": + try_move_or_attack(0, 1) + elif key == "A" or key == "Left": + try_move_or_attack(-1, 0) + elif key == "D" or key == "Right": + try_move_or_attack(1, 0) + # Pickup + elif key == "G" or key == ",": + pickup_item() + # Use items by number key + elif key in ["1", "2", "3", "4", "5"]: + index = int(key) - 1 + if use_item(index): + enemy_turn() # Using an item takes a turn + update_ui() + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 8 loaded! Pick up health potions with G, use with 1-5.") \ No newline at end of file diff --git a/docs/tutorials/part_09_ranged/part_09_ranged.py b/docs/tutorials/part_09_ranged/part_09_ranged.py new file mode 100644 index 0000000..f855a75 --- /dev/null +++ b/docs/tutorials/part_09_ranged/part_09_ranged.py @@ -0,0 +1,1396 @@ +"""McRogueFace - Part 9: Ranged Combat and Targeting + +Documentation: https://mcrogueface.github.io/tutorial/part_09_ranged +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_09_ranged/part_09_ranged.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 +from dataclasses import dataclass, field +from typing import Optional +from enum import Enum + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player +SPRITE_CORPSE = 37 # '%' - remains +SPRITE_POTION = 173 # Potion sprite +SPRITE_CURSOR = 88 # 'X' - targeting cursor + +# Enemy sprites +SPRITE_GOBLIN = 103 # 'g' +SPRITE_ORC = 111 # 'o' +SPRITE_TROLL = 116 # 't' + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 30 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# Enemy spawn parameters +MAX_ENEMIES_PER_ROOM = 3 + +# Item spawn parameters +MAX_ITEMS_PER_ROOM = 2 + +# FOV and targeting settings +FOV_RADIUS = 8 +RANGED_ATTACK_RANGE = 6 +RANGED_ATTACK_DAMAGE = 4 + +# Visibility colors +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) + +# Message colors +COLOR_PLAYER_ATTACK = mcrfpy.Color(200, 200, 200) +COLOR_ENEMY_ATTACK = mcrfpy.Color(255, 150, 150) +COLOR_PLAYER_DEATH = mcrfpy.Color(255, 50, 50) +COLOR_ENEMY_DEATH = mcrfpy.Color(100, 255, 100) +COLOR_HEAL = mcrfpy.Color(100, 255, 100) +COLOR_PICKUP = mcrfpy.Color(100, 200, 255) +COLOR_INFO = mcrfpy.Color(100, 100, 255) +COLOR_WARNING = mcrfpy.Color(255, 200, 50) +COLOR_INVALID = mcrfpy.Color(255, 100, 100) +COLOR_RANGED = mcrfpy.Color(255, 255, 100) + +# UI Layout constants +UI_TOP_HEIGHT = 60 +UI_BOTTOM_HEIGHT = 150 +GAME_AREA_Y = UI_TOP_HEIGHT +GAME_AREA_HEIGHT = 768 - UI_TOP_HEIGHT - UI_BOTTOM_HEIGHT + +# ============================================================================= +# Game Modes +# ============================================================================= + +class GameMode(Enum): + """The current game input mode.""" + NORMAL = "normal" + TARGETING = "targeting" + +# ============================================================================= +# Fighter Component +# ============================================================================= + +@dataclass +class Fighter: + """Combat stats for an entity.""" + hp: int + max_hp: int + attack: int + defense: int + name: str + is_player: bool = False + + @property + def is_alive(self) -> bool: + return self.hp > 0 + + def take_damage(self, amount: int) -> int: + actual_damage = min(self.hp, amount) + self.hp -= actual_damage + return actual_damage + + def heal(self, amount: int) -> int: + actual_heal = min(self.max_hp - self.hp, amount) + self.hp += actual_heal + return actual_heal + +# ============================================================================= +# Item Component +# ============================================================================= + +@dataclass +class Item: + """Data for an item that can be picked up and used.""" + name: str + item_type: str + heal_amount: int = 0 + + def describe(self) -> str: + if self.item_type == "health_potion": + return f"Restores {self.heal_amount} HP" + return "Unknown item" + +# ============================================================================= +# Inventory System +# ============================================================================= + +@dataclass +class Inventory: + """Container for items the player is carrying.""" + capacity: int = 10 + items: list = field(default_factory=list) + + def add(self, item: Item) -> bool: + if len(self.items) >= self.capacity: + return False + self.items.append(item) + return True + + def remove(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items.pop(index) + return None + + def get(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items[index] + return None + + def is_full(self) -> bool: + return len(self.items) >= self.capacity + + def count(self) -> int: + return len(self.items) + +# ============================================================================= +# Templates +# ============================================================================= + +ITEM_TEMPLATES = { + "health_potion": { + "name": "Health Potion", + "sprite": SPRITE_POTION, + + "item_type": "health_potion", + "heal_amount": 10 + } +} + +ENEMY_TEMPLATES = { + "goblin": { + "sprite": SPRITE_GOBLIN, + "hp": 6, + "attack": 3, + "defense": 0, + "color": mcrfpy.Color(100, 200, 100) + }, + "orc": { + "sprite": SPRITE_ORC, + "hp": 10, + "attack": 4, + "defense": 1, + "color": mcrfpy.Color(100, 150, 100) + }, + "troll": { + "sprite": SPRITE_TROLL, + "hp": 16, + "attack": 6, + "defense": 2, + "color": mcrfpy.Color(50, 150, 50) + } +} + +# ============================================================================= +# Message Log System +# ============================================================================= + +class MessageLog: + """A message log that displays recent game messages with colors.""" + + def __init__(self, x: int, y: int, width: int, height: int, max_messages: int = 6): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_messages = max_messages + self.messages: list[tuple[str, mcrfpy.Color]] = [] + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + line_height = 20 + for i in range(max_messages): + caption = mcrfpy.Caption( + pos=(x + 10, y + 5 + i * line_height), + text="" + ) + caption.font_size = 14 + caption.fill_color = mcrfpy.Color(200, 200, 200) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + for caption in self.captions: + scene.children.append(caption) + + def add(self, text: str, color: mcrfpy.Color = None) -> None: + if color is None: + color = mcrfpy.Color(200, 200, 200) + + self.messages.append((text, color)) + + while len(self.messages) > self.max_messages: + self.messages.pop(0) + + self._refresh() + + def _refresh(self) -> None: + for i, caption in enumerate(self.captions): + if i < len(self.messages): + text, color = self.messages[i] + caption.text = text + caption.fill_color = color + else: + caption.text = "" + + def clear(self) -> None: + self.messages.clear() + self._refresh() + +# ============================================================================= +# Health Bar System +# ============================================================================= + +class HealthBar: + """A visual health bar using nested frames.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_hp = 30 + self.current_hp = 30 + + self.bg_frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.bg_frame.fill_color = mcrfpy.Color(80, 0, 0) + self.bg_frame.outline = 2 + self.bg_frame.outline_color = mcrfpy.Color(150, 150, 150) + + self.fg_frame = mcrfpy.Frame( + pos=(x + 2, y + 2), + size=(width - 4, height - 4) + ) + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + self.fg_frame.outline = 0 + + self.label = mcrfpy.Caption( + pos=(x + 5, y + 2), + text=f"HP: {self.current_hp}/{self.max_hp}" + ) + self.label.font_size = 16 + self.label.fill_color = mcrfpy.Color(255, 255, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.bg_frame) + scene.children.append(self.fg_frame) + scene.children.append(self.label) + + def update(self, current_hp: int, max_hp: int) -> None: + self.current_hp = current_hp + self.max_hp = max_hp + + percent = max(0, current_hp / max_hp) if max_hp > 0 else 0 + + inner_width = self.width - 4 + self.fg_frame.resize(int(inner_width * percent), self.height - 4) + + self.label.text = f"HP: {current_hp}/{max_hp}" + + if percent > 0.6: + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + elif percent > 0.3: + self.fg_frame.fill_color = mcrfpy.Color(180, 180, 0) + else: + self.fg_frame.fill_color = mcrfpy.Color(180, 0, 0) + +# ============================================================================= +# Inventory Panel +# ============================================================================= + +class InventoryPanel: + """A panel displaying the player's inventory.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + self.title = mcrfpy.Caption( + pos=(x + 10, y + 5), + text="Inventory (G:pickup, 1-5:use)" + ) + self.title.font_size = 14 + self.title.fill_color = mcrfpy.Color(200, 200, 255) + + for i in range(5): + caption = mcrfpy.Caption( + pos=(x + 10, y + 25 + i * 18), + text="" + ) + caption.font_size = 13 + caption.fill_color = mcrfpy.Color(180, 180, 180) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + scene.children.append(self.title) + for caption in self.captions: + scene.children.append(caption) + + def update(self, inventory: Inventory) -> None: + for i, caption in enumerate(self.captions): + if i < len(inventory.items): + item = inventory.items[i] + caption.text = f"{i+1}. {item.name}" + caption.fill_color = mcrfpy.Color(180, 180, 180) + else: + caption.text = f"{i+1}. ---" + caption.fill_color = mcrfpy.Color(80, 80, 80) + +# ============================================================================= +# Mode Display +# ============================================================================= + +class ModeDisplay: + """Displays the current game mode.""" + + def __init__(self, x: int, y: int): + self.caption = mcrfpy.Caption( + pos=(x, y), + text="[NORMAL MODE]" + ) + self.caption.font_size = 16 + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.caption) + + def update(self, mode: GameMode) -> None: + if mode == GameMode.NORMAL: + self.caption.text = "[NORMAL MODE] - F: Ranged attack" + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + elif mode == GameMode.TARGETING: + self.caption.text = "[TARGETING] - Arrows: Move, Enter: Fire, Esc: Cancel" + self.caption.fill_color = mcrfpy.Color(255, 255, 100) + +# ============================================================================= +# Global State +# ============================================================================= + +entity_data: dict[mcrfpy.Entity, Fighter] = {} +item_data: dict[mcrfpy.Entity, Item] = {} + +player: Optional[mcrfpy.Entity] = None +player_inventory: Optional[Inventory] = None +grid: Optional[mcrfpy.Grid] = None +fov_layer = None +texture: Optional[mcrfpy.Texture] = None +game_over: bool = False +dungeon_level: int = 1 + +# Game mode state +game_mode: GameMode = GameMode.NORMAL +target_cursor: Optional[mcrfpy.Entity] = None +target_x: int = 0 +target_y: int = 0 + +# UI components +message_log: Optional[MessageLog] = None +health_bar: Optional[HealthBar] = None +inventory_panel: Optional[InventoryPanel] = None +mode_display: Optional[ModeDisplay] = None + +# ============================================================================= +# Room Class +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> tuple[int, int]: + 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 slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return ( + self.x1 <= other.x2 and + self.x2 >= other.x1 and + self.y1 <= other.y2 and + self.y2 >= other.y1 + ) + +# ============================================================================= +# Exploration Tracking +# ============================================================================= + +explored: list[list[bool]] = [] + +def init_explored() -> None: + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Dungeon Generation +# ============================================================================= + +def fill_with_walls(target_grid: mcrfpy.Grid) -> None: + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(target_grid: mcrfpy.Grid, room: RectangularRoom) -> None: + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(target_grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(target_grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + target_grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + x1, y1 = start + x2, y2 = end + + if random.random() < 0.5: + carve_tunnel_horizontal(target_grid, x1, x2, y1) + carve_tunnel_vertical(target_grid, y1, y2, x2) + else: + carve_tunnel_vertical(target_grid, y1, y2, x1) + carve_tunnel_horizontal(target_grid, x1, x2, y2) + +# ============================================================================= +# Entity Management +# ============================================================================= + +def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ENEMY_TEMPLATES[enemy_type] + + enemy = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + enemy.visible = False + + target_grid.entities.append(enemy) + + entity_data[enemy] = Fighter( + hp=template["hp"], + max_hp=template["hp"], + attack=template["attack"], + defense=template["defense"], + name=enemy_type.capitalize(), + is_player=False + ) + + return enemy + +def spawn_enemies_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture) -> None: + num_enemies = random.randint(0, MAX_ENEMIES_PER_ROOM) + + for _ in range(num_enemies): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + if is_position_occupied(target_grid, x, y): + continue + + roll = random.random() + if roll < 0.6: + enemy_type = "goblin" + elif roll < 0.9: + enemy_type = "orc" + else: + enemy_type = "troll" + + spawn_enemy(target_grid, x, y, enemy_type, tex) + +def spawn_item(target_grid: mcrfpy.Grid, x: int, y: int, item_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ITEM_TEMPLATES[item_type] + + item_entity = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + item_entity.visible = False + + target_grid.entities.append(item_entity) + + item_data[item_entity] = Item( + name=template["name"], + item_type=template["item_type"], + heal_amount=template.get("heal_amount", 0) + ) + + return item_entity + +def spawn_items_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture) -> None: + num_items = random.randint(0, MAX_ITEMS_PER_ROOM) + + for _ in range(num_items): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + if is_position_occupied(target_grid, x, y): + continue + + spawn_item(target_grid, x, y, "health_potion", tex) + +def is_position_occupied(target_grid: mcrfpy.Grid, x: int, y: int) -> bool: + for entity in target_grid.entities: + if int(entity.x) == x and int(entity.y) == y: + return True + return False + +def get_item_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity in item_data: + if int(entity.x) == x and int(entity.y) == y: + return entity + return None + +def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mcrfpy.Entity = None) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity == exclude: + continue + if int(entity.x) == x and int(entity.y) == y: + if entity in entity_data and entity_data[entity].is_alive: + return entity + return None + +def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in entity_data: + del entity_data[entity] + +def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in item_data: + del item_data[entity] + +def clear_all_entities(target_grid: mcrfpy.Grid) -> None: + global entity_data, item_data + + entities_to_remove = [] + for entity in target_grid.entities: + if entity in entity_data and not entity_data[entity].is_player: + entities_to_remove.append(entity) + elif entity in item_data: + entities_to_remove.append(entity) + + for entity in entities_to_remove: + if entity in entity_data: + del entity_data[entity] + if entity in item_data: + del item_data[entity] + + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + +# ============================================================================= +# Targeting System +# ============================================================================= + +def enter_targeting_mode() -> None: + """Enter targeting mode for ranged attack.""" + global game_mode, target_cursor, target_x, target_y, player, grid, texture + + # Start at player position + target_x = int(player.x) + target_y = int(player.y) + + # Create the targeting cursor + target_cursor = mcrfpy.Entity( + grid_pos=(target_x, target_y), + texture=texture, + sprite_index=SPRITE_CURSOR + ) + grid.entities.append(target_cursor) + + game_mode = GameMode.TARGETING + + message_log.add("Targeting mode: Use arrows to aim, Enter to fire, Esc to cancel.", COLOR_INFO) + mode_display.update(game_mode) + +def exit_targeting_mode() -> None: + """Exit targeting mode without firing.""" + global game_mode, target_cursor, grid + + if target_cursor is not None: + # Remove cursor from grid + for i, e in enumerate(grid.entities): + if e == target_cursor: + grid.entities.remove(i) + break + target_cursor = None + + game_mode = GameMode.NORMAL + mode_display.update(game_mode) + +def move_cursor(dx: int, dy: int) -> None: + """Move the targeting cursor.""" + global target_x, target_y, target_cursor, grid, player + + new_x = target_x + dx + new_y = target_y + dy + + # Check bounds + if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT: + return + + # Check if position is in FOV (can only target visible tiles) + if not grid.is_in_fov(new_x, new_y): + message_log.add("You cannot see that location.", COLOR_INVALID) + return + + # Check range + player_x, player_y = int(player.x), int(player.y) + distance = abs(new_x - player_x) + abs(new_y - player_y) + if distance > RANGED_ATTACK_RANGE: + message_log.add(f"Target is out of range (max {RANGED_ATTACK_RANGE}).", COLOR_WARNING) + return + + # Move cursor + target_x = new_x + target_y = new_y + target_cursor.x = target_x + target_cursor.y = target_y + + # Show info about target location + enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + if enemy and enemy in entity_data: + fighter = entity_data[enemy] + message_log.add(f"Target: {fighter.name} (HP: {fighter.hp}/{fighter.max_hp})", COLOR_RANGED) + +def confirm_target() -> None: + """Confirm the target and execute ranged attack.""" + global game_mode, target_x, target_y, player, grid + + # Check if targeting self + if target_x == int(player.x) and target_y == int(player.y): + message_log.add("You cannot target yourself!", COLOR_INVALID) + return + + # Check for enemy at target + target_enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + + if target_enemy is None or target_enemy not in entity_data: + message_log.add("No valid target at that location.", COLOR_INVALID) + return + + # Perform ranged attack + perform_ranged_attack(target_enemy) + + # Exit targeting mode + exit_targeting_mode() + + # Enemies take their turn + enemy_turn() + + update_ui() + +def perform_ranged_attack(target_entity: mcrfpy.Entity) -> None: + """Execute a ranged attack on the target. + + Args: + target_entity: The entity to attack + """ + global player, game_over + + defender = entity_data.get(target_entity) + attacker = entity_data.get(player) + + if defender is None or attacker is None: + return + + # Ranged attacks deal fixed damage (ignores defense partially) + damage = max(1, RANGED_ATTACK_DAMAGE - defender.defense // 2) + + defender.take_damage(damage) + + message_log.add( + f"Your ranged attack hits the {defender.name} for {damage} damage!", + COLOR_RANGED + ) + + # Check for death + if not defender.is_alive: + handle_death(target_entity, defender) + +# ============================================================================= +# Combat System +# ============================================================================= + +def calculate_damage(attacker: Fighter, defender: Fighter) -> int: + return max(0, attacker.attack - defender.defense) + +def perform_attack(attacker_entity: mcrfpy.Entity, defender_entity: mcrfpy.Entity) -> None: + global game_over + + attacker = entity_data.get(attacker_entity) + defender = entity_data.get(defender_entity) + + if attacker is None or defender is None: + return + + damage = calculate_damage(attacker, defender) + defender.take_damage(damage) + + if damage > 0: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} for {damage} damage!", + COLOR_PLAYER_ATTACK + ) + else: + message_log.add( + f"The {attacker.name} hits you for {damage} damage!", + COLOR_ENEMY_ATTACK + ) + else: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} but deal no damage.", + mcrfpy.Color(150, 150, 150) + ) + else: + message_log.add( + f"The {attacker.name} hits but deals no damage.", + mcrfpy.Color(150, 150, 200) + ) + + if not defender.is_alive: + handle_death(defender_entity, defender) + + update_ui() + +def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None: + global game_over, grid + + if fighter.is_player: + message_log.add("You have died!", COLOR_PLAYER_DEATH) + message_log.add("Press R to restart or Escape to quit.", COLOR_INFO) + game_over = True + entity.sprite_index = SPRITE_CORPSE + else: + message_log.add(f"The {fighter.name} dies!", COLOR_ENEMY_DEATH) + remove_entity(grid, entity) + +# ============================================================================= +# Item Actions +# ============================================================================= + +def pickup_item() -> bool: + global player, player_inventory, grid + + px, py = int(player.x), int(player.y) + item_entity = get_item_at(grid, px, py) + + if item_entity is None: + message_log.add("There is nothing to pick up here.", COLOR_INVALID) + return False + + if player_inventory.is_full(): + message_log.add("Your inventory is full!", COLOR_WARNING) + return False + + item = item_data.get(item_entity) + if item is None: + return False + + player_inventory.add(item) + remove_item_entity(grid, item_entity) + + message_log.add(f"You pick up the {item.name}.", COLOR_PICKUP) + + update_ui() + return True + +def use_item(index: int) -> bool: + global player, player_inventory + + item = player_inventory.get(index) + if item is None: + message_log.add("Invalid item selection.", COLOR_INVALID) + return False + + if item.item_type == "health_potion": + fighter = entity_data.get(player) + if fighter is None: + return False + + if fighter.hp >= fighter.max_hp: + message_log.add("You are already at full health!", COLOR_WARNING) + return False + + actual_heal = fighter.heal(item.heal_amount) + player_inventory.remove(index) + + message_log.add(f"You drink the {item.name} and recover {actual_heal} HP!", COLOR_HEAL) + + update_ui() + return True + + message_log.add(f"You cannot use the {item.name}.", COLOR_INVALID) + return False + +# ============================================================================= +# Field of View +# ============================================================================= + +def update_entity_visibility(target_grid: mcrfpy.Grid) -> None: + global player, target_cursor + + for entity in target_grid.entities: + if entity == player: + entity.visible = True + continue + + # Targeting cursor is always visible during targeting mode + if entity == target_cursor: + entity.visible = True + continue + + ex, ey = int(entity.x), int(entity.y) + entity.visible = target_grid.is_in_fov(ex, ey) + +def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None: + target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if target_grid.is_in_fov(x, y): + mark_explored(x, y) + target_fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + target_fov_layer.set(x, y, COLOR_DISCOVERED) + else: + target_fov_layer.set(x, y, COLOR_UNKNOWN) + + update_entity_visibility(target_grid) + +# ============================================================================= +# Movement and Actions +# ============================================================================= + +def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int, mover: mcrfpy.Entity = None) -> bool: + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + + if not target_grid.at(x, y).walkable: + return False + + blocker = get_blocking_entity_at(target_grid, x, y, exclude=mover) + if blocker is not None: + return False + + return True + +def try_move_or_attack(dx: int, dy: int) -> None: + global player, grid, fov_layer, game_over + + if game_over: + return + + px, py = int(player.x), int(player.y) + target_x = px + dx + target_y = py + dy + + if target_x < 0 or target_x >= GRID_WIDTH or target_y < 0 or target_y >= GRID_HEIGHT: + return + + blocker = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + + if blocker is not None: + perform_attack(player, blocker) + enemy_turn() + elif grid.at(target_x, target_y).walkable: + player.x = target_x + player.y = target_y + update_fov(grid, fov_layer, target_x, target_y) + enemy_turn() + + update_ui() + +# ============================================================================= +# Enemy AI +# ============================================================================= + +def enemy_turn() -> None: + global player, grid, game_over + + if game_over: + return + + player_x, player_y = int(player.x), int(player.y) + + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data and entity_data[entity].is_alive: + enemies.append(entity) + + for enemy in enemies: + fighter = entity_data.get(enemy) + if fighter is None or not fighter.is_alive: + continue + + ex, ey = int(enemy.x), int(enemy.y) + + if not grid.is_in_fov(ex, ey): + continue + + dx = player_x - ex + dy = player_y - ey + + if abs(dx) <= 1 and abs(dy) <= 1 and (dx != 0 or dy != 0): + perform_attack(enemy, player) + else: + move_toward_player(enemy, ex, ey, player_x, player_y) + +def move_toward_player(enemy: mcrfpy.Entity, ex: int, ey: int, px: int, py: int) -> None: + global grid + + dx = 0 + dy = 0 + + if px < ex: + dx = -1 + elif px > ex: + dx = 1 + + if py < ey: + dy = -1 + elif py > ey: + dy = 1 + + new_x = ex + dx + new_y = ey + dy + + if can_move_to(grid, new_x, new_y, enemy): + enemy.x = new_x + enemy.y = new_y + elif dx != 0 and can_move_to(grid, ex + dx, ey, enemy): + enemy.x = ex + dx + elif dy != 0 and can_move_to(grid, ex, ey + dy, enemy): + enemy.y = ey + dy + +# ============================================================================= +# UI Updates +# ============================================================================= + +def update_ui() -> None: + global player, health_bar, inventory_panel, player_inventory + + if player in entity_data: + fighter = entity_data[player] + health_bar.update(fighter.hp, fighter.max_hp) + + if player_inventory: + inventory_panel.update(player_inventory) + +# ============================================================================= +# Game Setup +# ============================================================================= + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid +grid = mcrfpy.Grid( + pos=(20, GAME_AREA_Y), + size=(700, GAME_AREA_HEIGHT - 20), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +# Generate initial dungeon +fill_with_walls(grid) +init_explored() + +rooms: list[RectangularRoom] = [] + +for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + +# Get player starting position +if rooms: + player_start_x, player_start_y = rooms[0].center +else: + player_start_x, player_start_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# Add FOV layer +fov_layer = grid.add_layer("color", z_index=-1) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +# Create the player +player = mcrfpy.Entity( + grid_pos=(player_start_x, player_start_y), + texture=texture, + sprite_index=SPRITE_PLAYER +) +grid.entities.append(player) + +entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True +) + +# Create player inventory +player_inventory = Inventory(capacity=10) + +# Spawn enemies and items +for i, room in enumerate(rooms): + if i == 0: + continue + spawn_enemies_in_room(grid, room, texture) + spawn_items_in_room(grid, room, texture) + +# Calculate initial FOV +update_fov(grid, fov_layer, player_start_x, player_start_y) + +# Add grid to scene +scene.children.append(grid) + +# ============================================================================= +# Create UI Elements +# ============================================================================= + +# Title bar +title = mcrfpy.Caption( + pos=(20, 10), + text="Part 9: Ranged Combat" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +# Instructions +instructions = mcrfpy.Caption( + pos=(280, 15), + text="WASD: Move | F: Ranged attack | G: Pickup | 1-5: Use | R: Restart" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 14 +scene.children.append(instructions) + +# Health Bar +health_bar = HealthBar( + x=730, + y=10, + width=280, + height=30 +) +health_bar.add_to_scene(scene) + +# Mode Display +mode_display = ModeDisplay(x=20, y=40) +mode_display.add_to_scene(scene) + +# Inventory Panel +inventory_panel = InventoryPanel( + x=730, + y=GAME_AREA_Y, + width=280, + height=150 +) +inventory_panel.add_to_scene(scene) + +# Message Log +message_log = MessageLog( + x=20, + y=768 - UI_BOTTOM_HEIGHT + 10, + width=990, + height=UI_BOTTOM_HEIGHT - 20, + max_messages=6 +) +message_log.add_to_scene(scene) + +# Initial messages +message_log.add("Welcome to the dungeon!", COLOR_INFO) +message_log.add("Press F to enter targeting mode for ranged attacks.", COLOR_INFO) + +# Initialize UI +update_ui() + +# ============================================================================= +# Input Handling +# ============================================================================= + +def restart_game() -> None: + global player, grid, fov_layer, game_over, entity_data, item_data, rooms + global player_inventory, game_mode, target_cursor + + # Exit targeting mode if active + if game_mode == GameMode.TARGETING: + exit_targeting_mode() + + game_over = False + game_mode = GameMode.NORMAL + + entity_data.clear() + item_data.clear() + + while len(grid.entities) > 0: + grid.entities.remove(0) + + fill_with_walls(grid) + init_explored() + message_log.clear() + + rooms = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + + if rooms: + new_x, new_y = rooms[0].center + else: + new_x, new_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + + player = mcrfpy.Entity( + grid_pos=(new_x, new_y), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True + ) + + player_inventory = Inventory(capacity=10) + + for i, room in enumerate(rooms): + if i == 0: + continue + spawn_enemies_in_room(grid, room, texture) + spawn_items_in_room(grid, room, texture) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, new_x, new_y) + + message_log.add("A new adventure begins!", COLOR_INFO) + + mode_display.update(game_mode) + update_ui() + +def handle_keys(key: str, action: str) -> None: + global game_over, game_mode + + if action != "start": + return + + # Always allow restart and quit + if key == "R": + restart_game() + return + + if key == "Escape": + if game_mode == GameMode.TARGETING: + exit_targeting_mode() + message_log.add("Targeting cancelled.", COLOR_INFO) + return + else: + mcrfpy.exit() + return + + if game_over: + return + + # Handle input based on game mode + if game_mode == GameMode.TARGETING: + handle_targeting_input(key) + else: + handle_normal_input(key) + +def handle_normal_input(key: str) -> None: + """Handle input in normal game mode.""" + # Movement + if key == "W" or key == "Up": + try_move_or_attack(0, -1) + elif key == "S" or key == "Down": + try_move_or_attack(0, 1) + elif key == "A" or key == "Left": + try_move_or_attack(-1, 0) + elif key == "D" or key == "Right": + try_move_or_attack(1, 0) + # Ranged attack (enter targeting mode) + elif key == "F": + enter_targeting_mode() + # Pickup + elif key == "G" or key == ",": + pickup_item() + # Use items + elif key in ["1", "2", "3", "4", "5"]: + index = int(key) - 1 + if use_item(index): + enemy_turn() + update_ui() + +def handle_targeting_input(key: str) -> None: + """Handle input in targeting mode.""" + if key == "Up" or key == "W": + move_cursor(0, -1) + elif key == "Down" or key == "S": + move_cursor(0, 1) + elif key == "Left" or key == "A": + move_cursor(-1, 0) + elif key == "Right" or key == "D": + move_cursor(1, 0) + elif key == "Return" or key == "Space": + confirm_target() + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 9 loaded! Press F to enter targeting mode for ranged attacks.") \ No newline at end of file diff --git a/docs/tutorials/part_10_save_load/part_10_save_load.py b/docs/tutorials/part_10_save_load/part_10_save_load.py new file mode 100644 index 0000000..a0c6380 --- /dev/null +++ b/docs/tutorials/part_10_save_load/part_10_save_load.py @@ -0,0 +1,1565 @@ +"""McRogueFace - Part 10: Saving and Loading + +Documentation: https://mcrogueface.github.io/tutorial/part_10_save_load +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_10_save_load/part_10_save_load.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 +import json +import os +from dataclasses import dataclass, field +from typing import Optional +from enum import Enum + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player +SPRITE_CORPSE = 37 # '%' - remains +SPRITE_POTION = 173 # Potion sprite +SPRITE_CURSOR = 88 # 'X' - targeting cursor + +# Enemy sprites +SPRITE_GOBLIN = 103 # 'g' +SPRITE_ORC = 111 # 'o' +SPRITE_TROLL = 116 # 't' + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 30 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# Enemy spawn parameters +MAX_ENEMIES_PER_ROOM = 3 + +# Item spawn parameters +MAX_ITEMS_PER_ROOM = 2 + +# FOV and targeting settings +FOV_RADIUS = 8 +RANGED_ATTACK_RANGE = 6 +RANGED_ATTACK_DAMAGE = 4 + +# Save file location +SAVE_FILE = "savegame.json" + +# Visibility colors +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) + +# Message colors +COLOR_PLAYER_ATTACK = mcrfpy.Color(200, 200, 200) +COLOR_ENEMY_ATTACK = mcrfpy.Color(255, 150, 150) +COLOR_PLAYER_DEATH = mcrfpy.Color(255, 50, 50) +COLOR_ENEMY_DEATH = mcrfpy.Color(100, 255, 100) +COLOR_HEAL = mcrfpy.Color(100, 255, 100) +COLOR_PICKUP = mcrfpy.Color(100, 200, 255) +COLOR_INFO = mcrfpy.Color(100, 100, 255) +COLOR_WARNING = mcrfpy.Color(255, 200, 50) +COLOR_INVALID = mcrfpy.Color(255, 100, 100) +COLOR_RANGED = mcrfpy.Color(255, 255, 100) +COLOR_SAVE = mcrfpy.Color(100, 255, 200) + +# UI Layout constants +UI_TOP_HEIGHT = 60 +UI_BOTTOM_HEIGHT = 150 +GAME_AREA_Y = UI_TOP_HEIGHT +GAME_AREA_HEIGHT = 768 - UI_TOP_HEIGHT - UI_BOTTOM_HEIGHT + +# ============================================================================= +# Game Modes +# ============================================================================= + +class GameMode(Enum): + NORMAL = "normal" + TARGETING = "targeting" + +# ============================================================================= +# Fighter Component +# ============================================================================= + +@dataclass +class Fighter: + """Combat stats for an entity.""" + hp: int + max_hp: int + attack: int + defense: int + name: str + is_player: bool = False + + @property + def is_alive(self) -> bool: + return self.hp > 0 + + def take_damage(self, amount: int) -> int: + actual_damage = min(self.hp, amount) + self.hp -= actual_damage + return actual_damage + + def heal(self, amount: int) -> int: + actual_heal = min(self.max_hp - self.hp, amount) + self.hp += actual_heal + return actual_heal + + def to_dict(self) -> dict: + """Serialize fighter data to dictionary.""" + return { + "hp": self.hp, + "max_hp": self.max_hp, + "attack": self.attack, + "defense": self.defense, + "name": self.name, + "is_player": self.is_player + } + + @classmethod + def from_dict(cls, data: dict) -> "Fighter": + """Deserialize fighter data from dictionary.""" + return cls( + hp=data["hp"], + max_hp=data["max_hp"], + attack=data["attack"], + defense=data["defense"], + name=data["name"], + is_player=data.get("is_player", False) + ) + +# ============================================================================= +# Item Component +# ============================================================================= + +@dataclass +class Item: + """Data for an item that can be picked up and used.""" + name: str + item_type: str + heal_amount: int = 0 + + def to_dict(self) -> dict: + """Serialize item data to dictionary.""" + return { + "name": self.name, + "item_type": self.item_type, + "heal_amount": self.heal_amount + } + + @classmethod + def from_dict(cls, data: dict) -> "Item": + """Deserialize item data from dictionary.""" + return cls( + name=data["name"], + item_type=data["item_type"], + heal_amount=data.get("heal_amount", 0) + ) + +# ============================================================================= +# Inventory System +# ============================================================================= + +@dataclass +class Inventory: + """Container for items the player is carrying.""" + capacity: int = 10 + items: list = field(default_factory=list) + + def add(self, item: Item) -> bool: + if len(self.items) >= self.capacity: + return False + self.items.append(item) + return True + + def remove(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items.pop(index) + return None + + def get(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items[index] + return None + + def is_full(self) -> bool: + return len(self.items) >= self.capacity + + def count(self) -> int: + return len(self.items) + + def to_dict(self) -> dict: + """Serialize inventory to dictionary.""" + return { + "capacity": self.capacity, + "items": [item.to_dict() for item in self.items] + } + + @classmethod + def from_dict(cls, data: dict) -> "Inventory": + """Deserialize inventory from dictionary.""" + inv = cls(capacity=data.get("capacity", 10)) + inv.items = [Item.from_dict(item_data) for item_data in data.get("items", [])] + return inv + +# ============================================================================= +# Templates +# ============================================================================= + +ITEM_TEMPLATES = { + "health_potion": { + "name": "Health Potion", + "sprite": SPRITE_POTION, + + "item_type": "health_potion", + "heal_amount": 10 + } +} + +ENEMY_TEMPLATES = { + "goblin": { + "sprite": SPRITE_GOBLIN, + "hp": 6, + "attack": 3, + "defense": 0, + "color": mcrfpy.Color(100, 200, 100) + }, + "orc": { + "sprite": SPRITE_ORC, + "hp": 10, + "attack": 4, + "defense": 1, + "color": mcrfpy.Color(100, 150, 100) + }, + "troll": { + "sprite": SPRITE_TROLL, + "hp": 16, + "attack": 6, + "defense": 2, + "color": mcrfpy.Color(50, 150, 50) + } +} + +# ============================================================================= +# Message Log System +# ============================================================================= + +class MessageLog: + """A message log that displays recent game messages with colors.""" + + def __init__(self, x: int, y: int, width: int, height: int, max_messages: int = 6): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_messages = max_messages + self.messages: list[tuple[str, mcrfpy.Color]] = [] + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + line_height = 20 + for i in range(max_messages): + caption = mcrfpy.Caption( + pos=(x + 10, y + 5 + i * line_height), + text="" + ) + caption.font_size = 14 + caption.fill_color = mcrfpy.Color(200, 200, 200) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + for caption in self.captions: + scene.children.append(caption) + + def add(self, text: str, color: mcrfpy.Color = None) -> None: + if color is None: + color = mcrfpy.Color(200, 200, 200) + + self.messages.append((text, color)) + + while len(self.messages) > self.max_messages: + self.messages.pop(0) + + self._refresh() + + def _refresh(self) -> None: + for i, caption in enumerate(self.captions): + if i < len(self.messages): + text, color = self.messages[i] + caption.text = text + caption.fill_color = color + else: + caption.text = "" + + def clear(self) -> None: + self.messages.clear() + self._refresh() + +# ============================================================================= +# Health Bar System +# ============================================================================= + +class HealthBar: + """A visual health bar using nested frames.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_hp = 30 + self.current_hp = 30 + + self.bg_frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.bg_frame.fill_color = mcrfpy.Color(80, 0, 0) + self.bg_frame.outline = 2 + self.bg_frame.outline_color = mcrfpy.Color(150, 150, 150) + + self.fg_frame = mcrfpy.Frame( + pos=(x + 2, y + 2), + size=(width - 4, height - 4) + ) + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + self.fg_frame.outline = 0 + + self.label = mcrfpy.Caption( + pos=(x + 5, y + 2), + text=f"HP: {self.current_hp}/{self.max_hp}" + ) + self.label.font_size = 16 + self.label.fill_color = mcrfpy.Color(255, 255, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.bg_frame) + scene.children.append(self.fg_frame) + scene.children.append(self.label) + + def update(self, current_hp: int, max_hp: int) -> None: + self.current_hp = current_hp + self.max_hp = max_hp + + percent = max(0, current_hp / max_hp) if max_hp > 0 else 0 + + inner_width = self.width - 4 + self.fg_frame.resize(int(inner_width * percent), self.height - 4) + + self.label.text = f"HP: {current_hp}/{max_hp}" + + if percent > 0.6: + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + elif percent > 0.3: + self.fg_frame.fill_color = mcrfpy.Color(180, 180, 0) + else: + self.fg_frame.fill_color = mcrfpy.Color(180, 0, 0) + +# ============================================================================= +# Inventory Panel +# ============================================================================= + +class InventoryPanel: + """A panel displaying the player's inventory.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + self.title = mcrfpy.Caption( + pos=(x + 10, y + 5), + text="Inventory (G:pickup, 1-5:use)" + ) + self.title.font_size = 14 + self.title.fill_color = mcrfpy.Color(200, 200, 255) + + for i in range(5): + caption = mcrfpy.Caption( + pos=(x + 10, y + 25 + i * 18), + text="" + ) + caption.font_size = 13 + caption.fill_color = mcrfpy.Color(180, 180, 180) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + scene.children.append(self.title) + for caption in self.captions: + scene.children.append(caption) + + def update(self, inventory: Inventory) -> None: + for i, caption in enumerate(self.captions): + if i < len(inventory.items): + item = inventory.items[i] + caption.text = f"{i+1}. {item.name}" + caption.fill_color = mcrfpy.Color(180, 180, 180) + else: + caption.text = f"{i+1}. ---" + caption.fill_color = mcrfpy.Color(80, 80, 80) + +# ============================================================================= +# Mode Display +# ============================================================================= + +class ModeDisplay: + """Displays the current game mode.""" + + def __init__(self, x: int, y: int): + self.caption = mcrfpy.Caption( + pos=(x, y), + text="[NORMAL MODE]" + ) + self.caption.font_size = 16 + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.caption) + + def update(self, mode: GameMode) -> None: + if mode == GameMode.NORMAL: + self.caption.text = "[NORMAL] F:Ranged | S:Save" + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + elif mode == GameMode.TARGETING: + self.caption.text = "[TARGETING] Arrows:Move, Enter:Fire, Esc:Cancel" + self.caption.fill_color = mcrfpy.Color(255, 255, 100) + +# ============================================================================= +# Global State +# ============================================================================= + +entity_data: dict[mcrfpy.Entity, Fighter] = {} +item_data: dict[mcrfpy.Entity, Item] = {} + +player: Optional[mcrfpy.Entity] = None +player_inventory: Optional[Inventory] = None +grid: Optional[mcrfpy.Grid] = None +fov_layer = None +texture: Optional[mcrfpy.Texture] = None +game_over: bool = False +dungeon_level: int = 1 + +# Game mode state +game_mode: GameMode = GameMode.NORMAL +target_cursor: Optional[mcrfpy.Entity] = None +target_x: int = 0 +target_y: int = 0 + +# UI components +message_log: Optional[MessageLog] = None +health_bar: Optional[HealthBar] = None +inventory_panel: Optional[InventoryPanel] = None +mode_display: Optional[ModeDisplay] = None + +# ============================================================================= +# Room Class +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> tuple[int, int]: + 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 slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return ( + self.x1 <= other.x2 and + self.x2 >= other.x1 and + self.y1 <= other.y2 and + self.y2 >= other.y1 + ) + +# ============================================================================= +# Exploration Tracking +# ============================================================================= + +explored: list[list[bool]] = [] + +def init_explored() -> None: + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Save/Load System +# ============================================================================= + +def save_game() -> bool: + """Save the current game state to a JSON file. + + Returns: + True if save succeeded, False otherwise + """ + global player, player_inventory, grid, explored, dungeon_level + + try: + # Collect tile data + tiles = [] + for y in range(GRID_HEIGHT): + row = [] + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + row.append({ + "tilesprite": cell.tilesprite, + "walkable": cell.walkable, + "transparent": cell.transparent + }) + tiles.append(row) + + # Collect enemy data + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data: + fighter = entity_data[entity] + enemies.append({ + "x": int(entity.x), + "y": int(entity.y), + "type": fighter.name.lower(), + "fighter": fighter.to_dict() + }) + + # Collect ground item data + items_on_ground = [] + for entity in grid.entities: + if entity in item_data: + item = item_data[entity] + items_on_ground.append({ + "x": int(entity.x), + "y": int(entity.y), + "item": item.to_dict() + }) + + # Build save data structure + save_data = { + "version": 1, # For future compatibility + "dungeon_level": dungeon_level, + "player": { + "x": int(player.x), + "y": int(player.y), + "fighter": entity_data[player].to_dict(), + "inventory": player_inventory.to_dict() + }, + "tiles": tiles, + "explored": [[explored[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)], + "enemies": enemies, + "items": items_on_ground + } + + # Write to file + with open(SAVE_FILE, "w") as f: + json.dump(save_data, f, indent=2) + + message_log.add("Game saved successfully!", COLOR_SAVE) + return True + + except Exception as e: + message_log.add(f"Failed to save: {str(e)}", COLOR_INVALID) + print(f"Save error: {e}") + return False + +def load_game() -> bool: + """Load a saved game from JSON file. + + Returns: + True if load succeeded, False otherwise + """ + global player, player_inventory, grid, explored, dungeon_level + global entity_data, item_data, fov_layer, game_over + + if not os.path.exists(SAVE_FILE): + return False + + try: + with open(SAVE_FILE, "r") as f: + save_data = json.load(f) + + # Clear current game state + entity_data.clear() + item_data.clear() + + while len(grid.entities) > 0: + grid.entities.remove(0) + + # Restore dungeon level + dungeon_level = save_data.get("dungeon_level", 1) + + # Restore tiles + tiles = save_data["tiles"] + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + tile_data = tiles[y][x] + cell.tilesprite = tile_data["tilesprite"] + cell.walkable = tile_data["walkable"] + cell.transparent = tile_data["transparent"] + + # Restore explored state + global explored + explored_data = save_data["explored"] + explored = [[explored_data[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)] + + # Restore player + player_data = save_data["player"] + player = mcrfpy.Entity( + grid_pos=(player_data["x"], player_data["y"]), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter.from_dict(player_data["fighter"]) + player_inventory = Inventory.from_dict(player_data["inventory"]) + + # Restore enemies + for enemy_data in save_data.get("enemies", []): + enemy_type = enemy_data["type"] + template = ENEMY_TEMPLATES.get(enemy_type, ENEMY_TEMPLATES["goblin"]) + + enemy = mcrfpy.Entity( + grid_pos=(enemy_data["x"], enemy_data["y"]), + texture=texture, + sprite_index=template["sprite"] + ) + enemy.visible = False + + grid.entities.append(enemy) + entity_data[enemy] = Fighter.from_dict(enemy_data["fighter"]) + + # Restore ground items + for item_entry in save_data.get("items", []): + template = ITEM_TEMPLATES.get( + item_entry["item"]["item_type"], + ITEM_TEMPLATES["health_potion"] + ) + + item_entity = mcrfpy.Entity( + grid_pos=(item_entry["x"], item_entry["y"]), + texture=texture, + sprite_index=template["sprite"] + ) + item_entity.visible = False + + grid.entities.append(item_entity) + item_data[item_entity] = Item.from_dict(item_entry["item"]) + + # Reset FOV layer + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + # Compute initial FOV + update_fov(grid, fov_layer, int(player.x), int(player.y)) + + game_over = False + + message_log.add("Game loaded successfully!", COLOR_SAVE) + update_ui() + return True + + except Exception as e: + message_log.add(f"Failed to load: {str(e)}", COLOR_INVALID) + print(f"Load error: {e}") + return False + +def delete_save() -> bool: + """Delete the save file. + + Returns: + True if deletion succeeded or file did not exist + """ + try: + if os.path.exists(SAVE_FILE): + os.remove(SAVE_FILE) + return True + except Exception as e: + print(f"Delete save error: {e}") + return False + +def has_save_file() -> bool: + """Check if a save file exists.""" + return os.path.exists(SAVE_FILE) + +# ============================================================================= +# Dungeon Generation +# ============================================================================= + +def fill_with_walls(target_grid: mcrfpy.Grid) -> None: + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(target_grid: mcrfpy.Grid, room: RectangularRoom) -> None: + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(target_grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(target_grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + target_grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + x1, y1 = start + x2, y2 = end + + if random.random() < 0.5: + carve_tunnel_horizontal(target_grid, x1, x2, y1) + carve_tunnel_vertical(target_grid, y1, y2, x2) + else: + carve_tunnel_vertical(target_grid, y1, y2, x1) + carve_tunnel_horizontal(target_grid, x1, x2, y2) + +# ============================================================================= +# Entity Management +# ============================================================================= + +def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ENEMY_TEMPLATES[enemy_type] + + enemy = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + enemy.visible = False + + target_grid.entities.append(enemy) + + entity_data[enemy] = Fighter( + hp=template["hp"], + max_hp=template["hp"], + attack=template["attack"], + defense=template["defense"], + name=enemy_type.capitalize(), + is_player=False + ) + + return enemy + +def spawn_enemies_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture) -> None: + num_enemies = random.randint(0, MAX_ENEMIES_PER_ROOM) + + for _ in range(num_enemies): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + if is_position_occupied(target_grid, x, y): + continue + + roll = random.random() + if roll < 0.6: + enemy_type = "goblin" + elif roll < 0.9: + enemy_type = "orc" + else: + enemy_type = "troll" + + spawn_enemy(target_grid, x, y, enemy_type, tex) + +def spawn_item(target_grid: mcrfpy.Grid, x: int, y: int, item_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ITEM_TEMPLATES[item_type] + + item_entity = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + item_entity.visible = False + + target_grid.entities.append(item_entity) + + item_data[item_entity] = Item( + name=template["name"], + item_type=template["item_type"], + heal_amount=template.get("heal_amount", 0) + ) + + return item_entity + +def spawn_items_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture) -> None: + num_items = random.randint(0, MAX_ITEMS_PER_ROOM) + + for _ in range(num_items): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + if is_position_occupied(target_grid, x, y): + continue + + spawn_item(target_grid, x, y, "health_potion", tex) + +def is_position_occupied(target_grid: mcrfpy.Grid, x: int, y: int) -> bool: + for entity in target_grid.entities: + if int(entity.x) == x and int(entity.y) == y: + return True + return False + +def get_item_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity in item_data: + if int(entity.x) == x and int(entity.y) == y: + return entity + return None + +def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mcrfpy.Entity = None) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity == exclude: + continue + if int(entity.x) == x and int(entity.y) == y: + if entity in entity_data and entity_data[entity].is_alive: + return entity + return None + +def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in entity_data: + del entity_data[entity] + +def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in item_data: + del item_data[entity] + +# ============================================================================= +# Targeting System +# ============================================================================= + +def enter_targeting_mode() -> None: + global game_mode, target_cursor, target_x, target_y, player, grid, texture + + target_x = int(player.x) + target_y = int(player.y) + + target_cursor = mcrfpy.Entity( + grid_pos=(target_x, target_y), + texture=texture, + sprite_index=SPRITE_CURSOR + ) + grid.entities.append(target_cursor) + + game_mode = GameMode.TARGETING + + message_log.add("Targeting mode: Arrows to aim, Enter to fire, Esc to cancel.", COLOR_INFO) + mode_display.update(game_mode) + +def exit_targeting_mode() -> None: + global game_mode, target_cursor, grid + + if target_cursor is not None: + for i, e in enumerate(grid.entities): + if e == target_cursor: + grid.entities.remove(i) + break + target_cursor = None + + game_mode = GameMode.NORMAL + mode_display.update(game_mode) + +def move_cursor(dx: int, dy: int) -> None: + global target_x, target_y, target_cursor, grid, player + + new_x = target_x + dx + new_y = target_y + dy + + if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT: + return + + if not grid.is_in_fov(new_x, new_y): + message_log.add("You cannot see that location.", COLOR_INVALID) + return + + player_x, player_y = int(player.x), int(player.y) + distance = abs(new_x - player_x) + abs(new_y - player_y) + if distance > RANGED_ATTACK_RANGE: + message_log.add(f"Target is out of range (max {RANGED_ATTACK_RANGE}).", COLOR_WARNING) + return + + target_x = new_x + target_y = new_y + target_cursor.x = target_x + target_cursor.y = target_y + + enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + if enemy and enemy in entity_data: + fighter = entity_data[enemy] + message_log.add(f"Target: {fighter.name} (HP: {fighter.hp}/{fighter.max_hp})", COLOR_RANGED) + +def confirm_target() -> None: + global game_mode, target_x, target_y, player, grid + + if target_x == int(player.x) and target_y == int(player.y): + message_log.add("You cannot target yourself!", COLOR_INVALID) + return + + target_enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + + if target_enemy is None or target_enemy not in entity_data: + message_log.add("No valid target at that location.", COLOR_INVALID) + return + + perform_ranged_attack(target_enemy) + exit_targeting_mode() + enemy_turn() + update_ui() + +def perform_ranged_attack(target_entity: mcrfpy.Entity) -> None: + global player, game_over + + defender = entity_data.get(target_entity) + attacker = entity_data.get(player) + + if defender is None or attacker is None: + return + + damage = max(1, RANGED_ATTACK_DAMAGE - defender.defense // 2) + + defender.take_damage(damage) + + message_log.add( + f"Your ranged attack hits the {defender.name} for {damage} damage!", + COLOR_RANGED + ) + + if not defender.is_alive: + handle_death(target_entity, defender) + +# ============================================================================= +# Combat System +# ============================================================================= + +def calculate_damage(attacker: Fighter, defender: Fighter) -> int: + return max(0, attacker.attack - defender.defense) + +def perform_attack(attacker_entity: mcrfpy.Entity, defender_entity: mcrfpy.Entity) -> None: + global game_over + + attacker = entity_data.get(attacker_entity) + defender = entity_data.get(defender_entity) + + if attacker is None or defender is None: + return + + damage = calculate_damage(attacker, defender) + defender.take_damage(damage) + + if damage > 0: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} for {damage} damage!", + COLOR_PLAYER_ATTACK + ) + else: + message_log.add( + f"The {attacker.name} hits you for {damage} damage!", + COLOR_ENEMY_ATTACK + ) + else: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} but deal no damage.", + mcrfpy.Color(150, 150, 150) + ) + else: + message_log.add( + f"The {attacker.name} hits but deals no damage.", + mcrfpy.Color(150, 150, 200) + ) + + if not defender.is_alive: + handle_death(defender_entity, defender) + + update_ui() + +def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None: + global game_over, grid + + if fighter.is_player: + message_log.add("You have died!", COLOR_PLAYER_DEATH) + message_log.add("Press R to restart or Escape to quit.", COLOR_INFO) + game_over = True + entity.sprite_index = SPRITE_CORPSE + # Delete save on death (permadeath!) + delete_save() + else: + message_log.add(f"The {fighter.name} dies!", COLOR_ENEMY_DEATH) + remove_entity(grid, entity) + +# ============================================================================= +# Item Actions +# ============================================================================= + +def pickup_item() -> bool: + global player, player_inventory, grid + + px, py = int(player.x), int(player.y) + item_entity = get_item_at(grid, px, py) + + if item_entity is None: + message_log.add("There is nothing to pick up here.", COLOR_INVALID) + return False + + if player_inventory.is_full(): + message_log.add("Your inventory is full!", COLOR_WARNING) + return False + + item = item_data.get(item_entity) + if item is None: + return False + + player_inventory.add(item) + remove_item_entity(grid, item_entity) + + message_log.add(f"You pick up the {item.name}.", COLOR_PICKUP) + + update_ui() + return True + +def use_item(index: int) -> bool: + global player, player_inventory + + item = player_inventory.get(index) + if item is None: + message_log.add("Invalid item selection.", COLOR_INVALID) + return False + + if item.item_type == "health_potion": + fighter = entity_data.get(player) + if fighter is None: + return False + + if fighter.hp >= fighter.max_hp: + message_log.add("You are already at full health!", COLOR_WARNING) + return False + + actual_heal = fighter.heal(item.heal_amount) + player_inventory.remove(index) + + message_log.add(f"You drink the {item.name} and recover {actual_heal} HP!", COLOR_HEAL) + + update_ui() + return True + + message_log.add(f"You cannot use the {item.name}.", COLOR_INVALID) + return False + +# ============================================================================= +# Field of View +# ============================================================================= + +def update_entity_visibility(target_grid: mcrfpy.Grid) -> None: + global player, target_cursor + + for entity in target_grid.entities: + if entity == player: + entity.visible = True + continue + + if entity == target_cursor: + entity.visible = True + continue + + ex, ey = int(entity.x), int(entity.y) + entity.visible = target_grid.is_in_fov(ex, ey) + +def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None: + target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if target_grid.is_in_fov(x, y): + mark_explored(x, y) + target_fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + target_fov_layer.set(x, y, COLOR_DISCOVERED) + else: + target_fov_layer.set(x, y, COLOR_UNKNOWN) + + update_entity_visibility(target_grid) + +# ============================================================================= +# Movement and Actions +# ============================================================================= + +def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int, mover: mcrfpy.Entity = None) -> bool: + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + + if not target_grid.at(x, y).walkable: + return False + + blocker = get_blocking_entity_at(target_grid, x, y, exclude=mover) + if blocker is not None: + return False + + return True + +def try_move_or_attack(dx: int, dy: int) -> None: + global player, grid, fov_layer, game_over + + if game_over: + return + + px, py = int(player.x), int(player.y) + new_target_x = px + dx + new_target_y = py + dy + + if new_target_x < 0 or new_target_x >= GRID_WIDTH or new_target_y < 0 or new_target_y >= GRID_HEIGHT: + return + + blocker = get_blocking_entity_at(grid, new_target_x, new_target_y, exclude=player) + + if blocker is not None: + perform_attack(player, blocker) + enemy_turn() + elif grid.at(new_target_x, new_target_y).walkable: + player.x = new_target_x + player.y = new_target_y + update_fov(grid, fov_layer, new_target_x, new_target_y) + enemy_turn() + + update_ui() + +# ============================================================================= +# Enemy AI +# ============================================================================= + +def enemy_turn() -> None: + global player, grid, game_over + + if game_over: + return + + player_x, player_y = int(player.x), int(player.y) + + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data and entity_data[entity].is_alive: + enemies.append(entity) + + for enemy in enemies: + fighter = entity_data.get(enemy) + if fighter is None or not fighter.is_alive: + continue + + ex, ey = int(enemy.x), int(enemy.y) + + if not grid.is_in_fov(ex, ey): + continue + + dx = player_x - ex + dy = player_y - ey + + if abs(dx) <= 1 and abs(dy) <= 1 and (dx != 0 or dy != 0): + perform_attack(enemy, player) + else: + move_toward_player(enemy, ex, ey, player_x, player_y) + +def move_toward_player(enemy: mcrfpy.Entity, ex: int, ey: int, px: int, py: int) -> None: + global grid + + dx = 0 + dy = 0 + + if px < ex: + dx = -1 + elif px > ex: + dx = 1 + + if py < ey: + dy = -1 + elif py > ey: + dy = 1 + + new_x = ex + dx + new_y = ey + dy + + if can_move_to(grid, new_x, new_y, enemy): + enemy.x = new_x + enemy.y = new_y + elif dx != 0 and can_move_to(grid, ex + dx, ey, enemy): + enemy.x = ex + dx + elif dy != 0 and can_move_to(grid, ex, ey + dy, enemy): + enemy.y = ey + dy + +# ============================================================================= +# UI Updates +# ============================================================================= + +def update_ui() -> None: + global player, health_bar, inventory_panel, player_inventory + + if player in entity_data: + fighter = entity_data[player] + health_bar.update(fighter.hp, fighter.max_hp) + + if player_inventory: + inventory_panel.update(player_inventory) + +# ============================================================================= +# New Game Generation +# ============================================================================= + +def generate_new_game() -> None: + """Generate a fresh dungeon with new player.""" + global player, player_inventory, grid, fov_layer, game_over + global entity_data, item_data, dungeon_level, game_mode + + # Reset state + game_over = False + game_mode = GameMode.NORMAL + dungeon_level = 1 + + entity_data.clear() + item_data.clear() + + while len(grid.entities) > 0: + grid.entities.remove(0) + + fill_with_walls(grid) + init_explored() + message_log.clear() + + rooms: list[RectangularRoom] = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(grid, new_room) + + if rooms: + carve_l_tunnel(grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + + if rooms: + new_x, new_y = rooms[0].center + else: + new_x, new_y = GRID_WIDTH // 2, GRID_HEIGHT // 2 + + player = mcrfpy.Entity( + grid_pos=(new_x, new_y), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True + ) + + player_inventory = Inventory(capacity=10) + + for i, room in enumerate(rooms): + if i == 0: + continue + spawn_enemies_in_room(grid, room, texture) + spawn_items_in_room(grid, room, texture) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, new_x, new_y) + + mode_display.update(game_mode) + update_ui() + +# ============================================================================= +# Game Setup +# ============================================================================= + +# Create the scene +scene = mcrfpy.Scene("game") + +# Load texture +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +# Create the grid +grid = mcrfpy.Grid( + pos=(20, GAME_AREA_Y), + size=(700, GAME_AREA_HEIGHT - 20), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +# Add FOV layer +fov_layer = grid.add_layer("color", z_index=-1) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +# Add grid to scene +scene.children.append(grid) + +# ============================================================================= +# Create UI Elements +# ============================================================================= + +# Title bar +title = mcrfpy.Caption( + pos=(20, 10), + text="Part 10: Save/Load" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +# Instructions +instructions = mcrfpy.Caption( + pos=(250, 15), + text="WASD:Move | F:Ranged | G:Pickup | Ctrl+S:Save | R:Restart" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 14 +scene.children.append(instructions) + +# Health Bar +health_bar = HealthBar( + x=730, + y=10, + width=280, + height=30 +) +health_bar.add_to_scene(scene) + +# Mode Display +mode_display = ModeDisplay(x=20, y=40) +mode_display.add_to_scene(scene) + +# Inventory Panel +inventory_panel = InventoryPanel( + x=730, + y=GAME_AREA_Y, + width=280, + height=150 +) +inventory_panel.add_to_scene(scene) + +# Message Log +message_log = MessageLog( + x=20, + y=768 - UI_BOTTOM_HEIGHT + 10, + width=990, + height=UI_BOTTOM_HEIGHT - 20, + max_messages=6 +) +message_log.add_to_scene(scene) + +# ============================================================================= +# Initialize Game (Load or New) +# ============================================================================= + +# Initialize explored array +init_explored() + +# Try to load existing save, otherwise generate new game +if has_save_file(): + message_log.add("Found saved game. Loading...", COLOR_INFO) + if not load_game(): + message_log.add("Failed to load. Starting new game.", COLOR_WARNING) + generate_new_game() + message_log.add("Welcome to the dungeon!", COLOR_INFO) +else: + generate_new_game() + message_log.add("Welcome to the dungeon!", COLOR_INFO) + message_log.add("Press Ctrl+S to save your progress.", COLOR_INFO) + +# ============================================================================= +# Input Handling +# ============================================================================= + +def handle_keys(key: str, action: str) -> None: + global game_over, game_mode + + if action != "start": + return + + # Always allow restart + if key == "R": + delete_save() + generate_new_game() + message_log.add("A new adventure begins!", COLOR_INFO) + return + + if key == "Escape": + if game_mode == GameMode.TARGETING: + exit_targeting_mode() + message_log.add("Targeting cancelled.", COLOR_INFO) + return + else: + # Save on quit + if not game_over: + save_game() + mcrfpy.exit() + return + + # Save game (Ctrl+S or just S when not moving) + if key == "S" and game_mode == GameMode.NORMAL: + # Check if this is meant to be a save (could add modifier check) + # For simplicity, we will use a dedicated save key + pass + + # Dedicated save with period key + if key == "Period" and game_mode == GameMode.NORMAL and not game_over: + save_game() + return + + if game_over: + return + + # Handle input based on game mode + if game_mode == GameMode.TARGETING: + handle_targeting_input(key) + else: + handle_normal_input(key) + +def handle_normal_input(key: str) -> None: + # Movement + if key == "W" or key == "Up": + try_move_or_attack(0, -1) + elif key == "S" or key == "Down": + try_move_or_attack(0, 1) + elif key == "A" or key == "Left": + try_move_or_attack(-1, 0) + elif key == "D" or key == "Right": + try_move_or_attack(1, 0) + # Ranged attack + elif key == "F": + enter_targeting_mode() + # Pickup + elif key == "G" or key == ",": + pickup_item() + # Use items + elif key in ["1", "2", "3", "4", "5"]: + index = int(key) - 1 + if use_item(index): + enemy_turn() + update_ui() + +def handle_targeting_input(key: str) -> None: + if key == "Up" or key == "W": + move_cursor(0, -1) + elif key == "Down" or key == "S": + move_cursor(0, 1) + elif key == "Left" or key == "A": + move_cursor(-1, 0) + elif key == "Right" or key == "D": + move_cursor(1, 0) + elif key == "Return" or key == "Space": + confirm_target() + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 10 loaded! Press Period (.) to save, Escape saves and quits.") \ No newline at end of file diff --git a/docs/tutorials/part_11_levels/part_11_levels.py b/docs/tutorials/part_11_levels/part_11_levels.py new file mode 100644 index 0000000..ee31c04 --- /dev/null +++ b/docs/tutorials/part_11_levels/part_11_levels.py @@ -0,0 +1,1735 @@ +"""McRogueFace - Part 11: Multiple Dungeon Levels + +Documentation: https://mcrogueface.github.io/tutorial/part_11_levels +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_11_levels/part_11_levels.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 +import json +import os +from dataclasses import dataclass, field +from typing import Optional +from enum import Enum + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player +SPRITE_CORPSE = 37 # '%' - remains +SPRITE_POTION = 173 # Potion sprite +SPRITE_CURSOR = 88 # 'X' - targeting cursor +SPRITE_STAIRS_DOWN = 62 # '>' - stairs down + +# Enemy sprites +SPRITE_GOBLIN = 103 # 'g' +SPRITE_ORC = 111 # 'o' +SPRITE_TROLL = 116 # 't' + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 30 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# FOV and targeting settings +FOV_RADIUS = 8 +RANGED_ATTACK_RANGE = 6 +RANGED_ATTACK_DAMAGE = 4 + +# Save file location +SAVE_FILE = "savegame.json" + +# Visibility colors +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) + +# Message colors +COLOR_PLAYER_ATTACK = mcrfpy.Color(200, 200, 200) +COLOR_ENEMY_ATTACK = mcrfpy.Color(255, 150, 150) +COLOR_PLAYER_DEATH = mcrfpy.Color(255, 50, 50) +COLOR_ENEMY_DEATH = mcrfpy.Color(100, 255, 100) +COLOR_HEAL = mcrfpy.Color(100, 255, 100) +COLOR_PICKUP = mcrfpy.Color(100, 200, 255) +COLOR_INFO = mcrfpy.Color(100, 100, 255) +COLOR_WARNING = mcrfpy.Color(255, 200, 50) +COLOR_INVALID = mcrfpy.Color(255, 100, 100) +COLOR_RANGED = mcrfpy.Color(255, 255, 100) +COLOR_SAVE = mcrfpy.Color(100, 255, 200) +COLOR_DESCEND = mcrfpy.Color(200, 200, 255) + +# UI Layout constants +UI_TOP_HEIGHT = 60 +UI_BOTTOM_HEIGHT = 150 +GAME_AREA_Y = UI_TOP_HEIGHT +GAME_AREA_HEIGHT = 768 - UI_TOP_HEIGHT - UI_BOTTOM_HEIGHT + +# ============================================================================= +# Game Modes +# ============================================================================= + +class GameMode(Enum): + NORMAL = "normal" + TARGETING = "targeting" + +# ============================================================================= +# Fighter Component +# ============================================================================= + +@dataclass +class Fighter: + """Combat stats for an entity.""" + hp: int + max_hp: int + attack: int + defense: int + name: str + is_player: bool = False + + @property + def is_alive(self) -> bool: + return self.hp > 0 + + def take_damage(self, amount: int) -> int: + actual_damage = min(self.hp, amount) + self.hp -= actual_damage + return actual_damage + + def heal(self, amount: int) -> int: + actual_heal = min(self.max_hp - self.hp, amount) + self.hp += actual_heal + return actual_heal + + def to_dict(self) -> dict: + return { + "hp": self.hp, + "max_hp": self.max_hp, + "attack": self.attack, + "defense": self.defense, + "name": self.name, + "is_player": self.is_player + } + + @classmethod + def from_dict(cls, data: dict) -> "Fighter": + return cls( + hp=data["hp"], + max_hp=data["max_hp"], + attack=data["attack"], + defense=data["defense"], + name=data["name"], + is_player=data.get("is_player", False) + ) + +# ============================================================================= +# Item Component +# ============================================================================= + +@dataclass +class Item: + """Data for an item that can be picked up and used.""" + name: str + item_type: str + heal_amount: int = 0 + + def to_dict(self) -> dict: + return { + "name": self.name, + "item_type": self.item_type, + "heal_amount": self.heal_amount + } + + @classmethod + def from_dict(cls, data: dict) -> "Item": + return cls( + name=data["name"], + item_type=data["item_type"], + heal_amount=data.get("heal_amount", 0) + ) + +# ============================================================================= +# Inventory System +# ============================================================================= + +@dataclass +class Inventory: + """Container for items the player is carrying.""" + capacity: int = 10 + items: list = field(default_factory=list) + + def add(self, item: Item) -> bool: + if len(self.items) >= self.capacity: + return False + self.items.append(item) + return True + + def remove(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items.pop(index) + return None + + def get(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items[index] + return None + + def is_full(self) -> bool: + return len(self.items) >= self.capacity + + def count(self) -> int: + return len(self.items) + + def to_dict(self) -> dict: + return { + "capacity": self.capacity, + "items": [item.to_dict() for item in self.items] + } + + @classmethod + def from_dict(cls, data: dict) -> "Inventory": + inv = cls(capacity=data.get("capacity", 10)) + inv.items = [Item.from_dict(item_data) for item_data in data.get("items", [])] + return inv + +# ============================================================================= +# Templates +# ============================================================================= + +ITEM_TEMPLATES = { + "health_potion": { + "name": "Health Potion", + "sprite": SPRITE_POTION, + + "item_type": "health_potion", + "heal_amount": 10 + }, + "greater_health_potion": { + "name": "Greater Health Potion", + "sprite": SPRITE_POTION, + + "item_type": "health_potion", + "heal_amount": 20 + } +} + +ENEMY_TEMPLATES = { + "goblin": { + "sprite": SPRITE_GOBLIN, + "hp": 6, + "attack": 3, + "defense": 0, + "color": mcrfpy.Color(100, 200, 100) + }, + "orc": { + "sprite": SPRITE_ORC, + "hp": 10, + "attack": 4, + "defense": 1, + "color": mcrfpy.Color(100, 150, 100) + }, + "troll": { + "sprite": SPRITE_TROLL, + "hp": 16, + "attack": 6, + "defense": 2, + "color": mcrfpy.Color(50, 150, 50) + } +} + +# ============================================================================= +# Difficulty Scaling +# ============================================================================= + +def get_max_enemies_per_room(level: int) -> int: + """Get maximum enemies per room based on dungeon level.""" + return min(2 + level, 6) + +def get_max_items_per_room(level: int) -> int: + """Get maximum items per room based on dungeon level.""" + return min(1 + level // 2, 3) + +def get_enemy_weights(level: int) -> list[tuple[str, float]]: + """Get enemy spawn weights based on dungeon level. + + Returns list of (enemy_type, cumulative_weight) tuples. + """ + if level <= 2: + # Levels 1-2: Mostly goblins + return [("goblin", 0.8), ("orc", 0.95), ("troll", 1.0)] + elif level <= 4: + # Levels 3-4: More orcs + return [("goblin", 0.5), ("orc", 0.85), ("troll", 1.0)] + else: + # Level 5+: Dangerous mix + return [("goblin", 0.3), ("orc", 0.6), ("troll", 1.0)] + +def get_item_weights(level: int) -> list[tuple[str, float]]: + """Get item spawn weights based on dungeon level.""" + if level <= 2: + return [("health_potion", 1.0)] + else: + return [("health_potion", 0.7), ("greater_health_potion", 1.0)] + +# ============================================================================= +# Message Log System +# ============================================================================= + +class MessageLog: + """A message log that displays recent game messages with colors.""" + + def __init__(self, x: int, y: int, width: int, height: int, max_messages: int = 6): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_messages = max_messages + self.messages: list[tuple[str, mcrfpy.Color]] = [] + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + line_height = 20 + for i in range(max_messages): + caption = mcrfpy.Caption( + pos=(x + 10, y + 5 + i * line_height), + text="" + ) + caption.font_size = 14 + caption.fill_color = mcrfpy.Color(200, 200, 200) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + for caption in self.captions: + scene.children.append(caption) + + def add(self, text: str, color: mcrfpy.Color = None) -> None: + if color is None: + color = mcrfpy.Color(200, 200, 200) + + self.messages.append((text, color)) + + while len(self.messages) > self.max_messages: + self.messages.pop(0) + + self._refresh() + + def _refresh(self) -> None: + for i, caption in enumerate(self.captions): + if i < len(self.messages): + text, color = self.messages[i] + caption.text = text + caption.fill_color = color + else: + caption.text = "" + + def clear(self) -> None: + self.messages.clear() + self._refresh() + +# ============================================================================= +# Health Bar System +# ============================================================================= + +class HealthBar: + """A visual health bar using nested frames.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_hp = 30 + self.current_hp = 30 + + self.bg_frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.bg_frame.fill_color = mcrfpy.Color(80, 0, 0) + self.bg_frame.outline = 2 + self.bg_frame.outline_color = mcrfpy.Color(150, 150, 150) + + self.fg_frame = mcrfpy.Frame( + pos=(x + 2, y + 2), + size=(width - 4, height - 4) + ) + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + self.fg_frame.outline = 0 + + self.label = mcrfpy.Caption( + pos=(x + 5, y + 2), + text=f"HP: {self.current_hp}/{self.max_hp}" + ) + self.label.font_size = 16 + self.label.fill_color = mcrfpy.Color(255, 255, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.bg_frame) + scene.children.append(self.fg_frame) + scene.children.append(self.label) + + def update(self, current_hp: int, max_hp: int) -> None: + self.current_hp = current_hp + self.max_hp = max_hp + + percent = max(0, current_hp / max_hp) if max_hp > 0 else 0 + + inner_width = self.width - 4 + self.fg_frame.resize(int(inner_width * percent), self.height - 4) + + self.label.text = f"HP: {current_hp}/{max_hp}" + + if percent > 0.6: + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + elif percent > 0.3: + self.fg_frame.fill_color = mcrfpy.Color(180, 180, 0) + else: + self.fg_frame.fill_color = mcrfpy.Color(180, 0, 0) + +# ============================================================================= +# Inventory Panel +# ============================================================================= + +class InventoryPanel: + """A panel displaying the player's inventory.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + self.title = mcrfpy.Caption( + pos=(x + 10, y + 5), + text="Inventory (G:pickup, 1-5:use)" + ) + self.title.font_size = 14 + self.title.fill_color = mcrfpy.Color(200, 200, 255) + + for i in range(5): + caption = mcrfpy.Caption( + pos=(x + 10, y + 25 + i * 18), + text="" + ) + caption.font_size = 13 + caption.fill_color = mcrfpy.Color(180, 180, 180) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + scene.children.append(self.title) + for caption in self.captions: + scene.children.append(caption) + + def update(self, inventory: Inventory) -> None: + for i, caption in enumerate(self.captions): + if i < len(inventory.items): + item = inventory.items[i] + caption.text = f"{i+1}. {item.name}" + caption.fill_color = mcrfpy.Color(180, 180, 180) + else: + caption.text = f"{i+1}. ---" + caption.fill_color = mcrfpy.Color(80, 80, 80) + +# ============================================================================= +# Level Display +# ============================================================================= + +class LevelDisplay: + """Displays current dungeon level.""" + + def __init__(self, x: int, y: int): + self.caption = mcrfpy.Caption( + pos=(x, y), + text="Level: 1" + ) + self.caption.font_size = 18 + self.caption.fill_color = mcrfpy.Color(200, 200, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.caption) + + def update(self, level: int) -> None: + self.caption.text = f"Dungeon Level: {level}" + +# ============================================================================= +# Mode Display +# ============================================================================= + +class ModeDisplay: + """Displays the current game mode.""" + + def __init__(self, x: int, y: int): + self.caption = mcrfpy.Caption( + pos=(x, y), + text="[NORMAL MODE]" + ) + self.caption.font_size = 16 + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.caption) + + def update(self, mode: GameMode) -> None: + if mode == GameMode.NORMAL: + self.caption.text = "[NORMAL] F:Ranged | >:Descend" + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + elif mode == GameMode.TARGETING: + self.caption.text = "[TARGETING] Arrows:Move, Enter:Fire, Esc:Cancel" + self.caption.fill_color = mcrfpy.Color(255, 255, 100) + +# ============================================================================= +# Global State +# ============================================================================= + +entity_data: dict[mcrfpy.Entity, Fighter] = {} +item_data: dict[mcrfpy.Entity, Item] = {} + +player: Optional[mcrfpy.Entity] = None +player_inventory: Optional[Inventory] = None +grid: Optional[mcrfpy.Grid] = None +fov_layer = None +texture: Optional[mcrfpy.Texture] = None +game_over: bool = False +dungeon_level: int = 1 + +# Stairs position +stairs_position: tuple[int, int] = (0, 0) + +# Game mode state +game_mode: GameMode = GameMode.NORMAL +target_cursor: Optional[mcrfpy.Entity] = None +target_x: int = 0 +target_y: int = 0 + +# UI components +message_log: Optional[MessageLog] = None +health_bar: Optional[HealthBar] = None +inventory_panel: Optional[InventoryPanel] = None +mode_display: Optional[ModeDisplay] = None +level_display: Optional[LevelDisplay] = None + +# ============================================================================= +# Room Class +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> tuple[int, int]: + 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 slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return ( + self.x1 <= other.x2 and + self.x2 >= other.x1 and + self.y1 <= other.y2 and + self.y2 >= other.y1 + ) + +# ============================================================================= +# Exploration Tracking +# ============================================================================= + +explored: list[list[bool]] = [] + +def init_explored() -> None: + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Dungeon Generation +# ============================================================================= + +def fill_with_walls(target_grid: mcrfpy.Grid) -> None: + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(target_grid: mcrfpy.Grid, room: RectangularRoom) -> None: + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(target_grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(target_grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + target_grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + x1, y1 = start + x2, y2 = end + + if random.random() < 0.5: + carve_tunnel_horizontal(target_grid, x1, x2, y1) + carve_tunnel_vertical(target_grid, y1, y2, x2) + else: + carve_tunnel_vertical(target_grid, y1, y2, x1) + carve_tunnel_horizontal(target_grid, x1, x2, y2) + +def place_stairs(target_grid: mcrfpy.Grid, x: int, y: int) -> None: + """Place stairs down at the given position.""" + global stairs_position + + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_STAIRS_DOWN + cell.walkable = True + cell.transparent = True + + stairs_position = (x, y) + +def generate_dungeon(target_grid: mcrfpy.Grid, level: int) -> tuple[int, int]: + """Generate a new dungeon level. + + Args: + target_grid: The grid to generate into + level: Current dungeon level (affects difficulty) + + Returns: + Player starting position (x, y) + """ + global stairs_position + + fill_with_walls(target_grid) + + rooms: list[RectangularRoom] = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(target_grid, new_room) + + if rooms: + carve_l_tunnel(target_grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + + # Place stairs in the last room + if rooms: + stairs_x, stairs_y = rooms[-1].center + place_stairs(target_grid, stairs_x, stairs_y) + + # Return starting position (first room center) + if rooms: + return rooms[0].center + else: + return GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# ============================================================================= +# Entity Management +# ============================================================================= + +def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ENEMY_TEMPLATES[enemy_type] + + enemy = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + enemy.visible = False + + target_grid.entities.append(enemy) + + entity_data[enemy] = Fighter( + hp=template["hp"], + max_hp=template["hp"], + attack=template["attack"], + defense=template["defense"], + name=enemy_type.capitalize(), + is_player=False + ) + + return enemy + +def spawn_enemies_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture, level: int) -> None: + """Spawn enemies in a room with level-scaled difficulty.""" + max_enemies = get_max_enemies_per_room(level) + num_enemies = random.randint(0, max_enemies) + enemy_weights = get_enemy_weights(level) + + for _ in range(num_enemies): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + if is_position_occupied(target_grid, x, y): + continue + + # Select enemy type based on weights + roll = random.random() + enemy_type = "goblin" + for etype, threshold in enemy_weights: + if roll < threshold: + enemy_type = etype + break + + spawn_enemy(target_grid, x, y, enemy_type, tex) + +def spawn_item(target_grid: mcrfpy.Grid, x: int, y: int, item_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ITEM_TEMPLATES[item_type] + + item_entity = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + item_entity.visible = False + + target_grid.entities.append(item_entity) + + item_data[item_entity] = Item( + name=template["name"], + item_type=template["item_type"], + heal_amount=template.get("heal_amount", 0) + ) + + return item_entity + +def spawn_items_in_room(target_grid: mcrfpy.Grid, room: RectangularRoom, tex: mcrfpy.Texture, level: int) -> None: + """Spawn items in a room with level-scaled variety.""" + max_items = get_max_items_per_room(level) + num_items = random.randint(0, max_items) + item_weights = get_item_weights(level) + + for _ in range(num_items): + inner_x, inner_y = room.inner + x = random.randint(inner_x.start, inner_x.stop - 1) + y = random.randint(inner_y.start, inner_y.stop - 1) + + if is_position_occupied(target_grid, x, y): + continue + + # Select item type based on weights + roll = random.random() + item_type = "health_potion" + for itype, threshold in item_weights: + if roll < threshold: + item_type = itype + break + + spawn_item(target_grid, x, y, item_type, tex) + +def is_position_occupied(target_grid: mcrfpy.Grid, x: int, y: int) -> bool: + for entity in target_grid.entities: + if int(entity.x) == x and int(entity.y) == y: + return True + return False + +def get_item_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity in item_data: + if int(entity.x) == x and int(entity.y) == y: + return entity + return None + +def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mcrfpy.Entity = None) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity == exclude: + continue + if int(entity.x) == x and int(entity.y) == y: + if entity in entity_data and entity_data[entity].is_alive: + return entity + return None + +def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in entity_data: + del entity_data[entity] + +def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in item_data: + del item_data[entity] + +def clear_entities_except_player(target_grid: mcrfpy.Grid) -> None: + """Remove all entities except the player.""" + global entity_data, item_data + + entities_to_remove = [] + for entity in target_grid.entities: + if entity in entity_data and entity_data[entity].is_player: + continue + entities_to_remove.append(entity) + + for entity in entities_to_remove: + if entity in entity_data: + del entity_data[entity] + if entity in item_data: + del item_data[entity] + + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + +# ============================================================================= +# Level Transition +# ============================================================================= + +def descend_stairs() -> bool: + """Attempt to descend stairs at player's position. + + Returns: + True if descended, False if no stairs here + """ + global player, dungeon_level, grid, fov_layer, stairs_position + + px, py = int(player.x), int(player.y) + + # Check if player is on stairs + if (px, py) != stairs_position: + message_log.add("There are no stairs here.", COLOR_INVALID) + return False + + # Descend to next level + dungeon_level += 1 + + # Clear current level's entities (except player) + clear_entities_except_player(grid) + + # Generate new dungeon + init_explored() + player_start = generate_dungeon(grid, dungeon_level) + + # Move player to starting position + player.x = player_start[0] + player.y = player_start[1] + + # Spawn enemies and items based on level + # We need to get the rooms - regenerate them + # For simplicity, spawn in all floor tiles + spawn_entities_for_level(grid, texture, dungeon_level) + + # Reset FOV + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, player_start[0], player_start[1]) + + message_log.add(f"You descend to level {dungeon_level}...", COLOR_DESCEND) + level_display.update(dungeon_level) + update_ui() + + return True + +def spawn_entities_for_level(target_grid: mcrfpy.Grid, tex: mcrfpy.Texture, level: int) -> None: + """Spawn enemies and items throughout the dungeon.""" + # Find all floor tiles and group them into rough areas + floor_tiles = [] + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + if cell.walkable and cell.tilesprite == SPRITE_FLOOR: + floor_tiles.append((x, y)) + + # Spawn enemies + max_enemies = get_max_enemies_per_room(level) * 3 # Approximate total + enemy_weights = get_enemy_weights(level) + + for _ in range(max_enemies): + if not floor_tiles: + break + + x, y = random.choice(floor_tiles) + + # Don't spawn on player or stairs + if (x, y) == (int(player.x), int(player.y)): + continue + if (x, y) == stairs_position: + continue + if is_position_occupied(target_grid, x, y): + continue + + roll = random.random() + enemy_type = "goblin" + for etype, threshold in enemy_weights: + if roll < threshold: + enemy_type = etype + break + + spawn_enemy(target_grid, x, y, enemy_type, tex) + + # Spawn items + max_items = get_max_items_per_room(level) * 2 + item_weights = get_item_weights(level) + + for _ in range(max_items): + if not floor_tiles: + break + + x, y = random.choice(floor_tiles) + + if (x, y) == (int(player.x), int(player.y)): + continue + if (x, y) == stairs_position: + continue + if is_position_occupied(target_grid, x, y): + continue + + roll = random.random() + item_type = "health_potion" + for itype, threshold in item_weights: + if roll < threshold: + item_type = itype + break + + spawn_item(target_grid, x, y, item_type, tex) + +# ============================================================================= +# Save/Load System +# ============================================================================= + +def save_game() -> bool: + """Save the current game state to a JSON file.""" + global player, player_inventory, grid, explored, dungeon_level, stairs_position + + try: + tiles = [] + for y in range(GRID_HEIGHT): + row = [] + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + row.append({ + "tilesprite": cell.tilesprite, + "walkable": cell.walkable, + "transparent": cell.transparent + }) + tiles.append(row) + + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data: + fighter = entity_data[entity] + enemies.append({ + "x": int(entity.x), + "y": int(entity.y), + "type": fighter.name.lower(), + "fighter": fighter.to_dict() + }) + + items_on_ground = [] + for entity in grid.entities: + if entity in item_data: + item = item_data[entity] + items_on_ground.append({ + "x": int(entity.x), + "y": int(entity.y), + "item": item.to_dict() + }) + + save_data = { + "version": 2, + "dungeon_level": dungeon_level, + "stairs_position": list(stairs_position), + "player": { + "x": int(player.x), + "y": int(player.y), + "fighter": entity_data[player].to_dict(), + "inventory": player_inventory.to_dict() + }, + "tiles": tiles, + "explored": [[explored[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)], + "enemies": enemies, + "items": items_on_ground + } + + with open(SAVE_FILE, "w") as f: + json.dump(save_data, f, indent=2) + + message_log.add("Game saved successfully!", COLOR_SAVE) + return True + + except Exception as e: + message_log.add(f"Failed to save: {str(e)}", COLOR_INVALID) + return False + +def load_game() -> bool: + """Load a saved game from JSON file.""" + global player, player_inventory, grid, explored, dungeon_level + global entity_data, item_data, fov_layer, game_over, stairs_position + + if not os.path.exists(SAVE_FILE): + return False + + try: + with open(SAVE_FILE, "r") as f: + save_data = json.load(f) + + entity_data.clear() + item_data.clear() + + while len(grid.entities) > 0: + grid.entities.remove(0) + + dungeon_level = save_data.get("dungeon_level", 1) + stairs_position = tuple(save_data.get("stairs_position", [0, 0])) + + tiles = save_data["tiles"] + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + tile_data = tiles[y][x] + cell.tilesprite = tile_data["tilesprite"] + cell.walkable = tile_data["walkable"] + cell.transparent = tile_data["transparent"] + + global explored + explored_data = save_data["explored"] + explored = [[explored_data[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)] + + player_data = save_data["player"] + player = mcrfpy.Entity( + grid_pos=(player_data["x"], player_data["y"]), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter.from_dict(player_data["fighter"]) + player_inventory = Inventory.from_dict(player_data["inventory"]) + + for enemy_data in save_data.get("enemies", []): + enemy_type = enemy_data["type"] + template = ENEMY_TEMPLATES.get(enemy_type, ENEMY_TEMPLATES["goblin"]) + + enemy = mcrfpy.Entity( + grid_pos=(enemy_data["x"], enemy_data["y"]), + texture=texture, + sprite_index=template["sprite"] + ) + enemy.visible = False + + grid.entities.append(enemy) + entity_data[enemy] = Fighter.from_dict(enemy_data["fighter"]) + + for item_entry in save_data.get("items", []): + item_type = item_entry["item"]["item_type"] + template = ITEM_TEMPLATES.get(item_type, ITEM_TEMPLATES["health_potion"]) + + item_entity = mcrfpy.Entity( + grid_pos=(item_entry["x"], item_entry["y"]), + texture=texture, + sprite_index=template["sprite"] + ) + item_entity.visible = False + + grid.entities.append(item_entity) + item_data[item_entity] = Item.from_dict(item_entry["item"]) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, int(player.x), int(player.y)) + + game_over = False + + message_log.add("Game loaded successfully!", COLOR_SAVE) + level_display.update(dungeon_level) + update_ui() + return True + + except Exception as e: + message_log.add(f"Failed to load: {str(e)}", COLOR_INVALID) + return False + +def delete_save() -> bool: + try: + if os.path.exists(SAVE_FILE): + os.remove(SAVE_FILE) + return True + except Exception: + return False + +def has_save_file() -> bool: + return os.path.exists(SAVE_FILE) + +# ============================================================================= +# Targeting System +# ============================================================================= + +def enter_targeting_mode() -> None: + global game_mode, target_cursor, target_x, target_y, player, grid, texture + + target_x = int(player.x) + target_y = int(player.y) + + target_cursor = mcrfpy.Entity( + grid_pos=(target_x, target_y), + texture=texture, + sprite_index=SPRITE_CURSOR + ) + grid.entities.append(target_cursor) + + game_mode = GameMode.TARGETING + + message_log.add("Targeting mode: Arrows to aim, Enter to fire, Esc to cancel.", COLOR_INFO) + mode_display.update(game_mode) + +def exit_targeting_mode() -> None: + global game_mode, target_cursor, grid + + if target_cursor is not None: + for i, e in enumerate(grid.entities): + if e == target_cursor: + grid.entities.remove(i) + break + target_cursor = None + + game_mode = GameMode.NORMAL + mode_display.update(game_mode) + +def move_cursor(dx: int, dy: int) -> None: + global target_x, target_y, target_cursor, grid, player + + new_x = target_x + dx + new_y = target_y + dy + + if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT: + return + + if not grid.is_in_fov(new_x, new_y): + message_log.add("You cannot see that location.", COLOR_INVALID) + return + + player_x, player_y = int(player.x), int(player.y) + distance = abs(new_x - player_x) + abs(new_y - player_y) + if distance > RANGED_ATTACK_RANGE: + message_log.add(f"Target is out of range (max {RANGED_ATTACK_RANGE}).", COLOR_WARNING) + return + + target_x = new_x + target_y = new_y + target_cursor.x = target_x + target_cursor.y = target_y + + enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + if enemy and enemy in entity_data: + fighter = entity_data[enemy] + message_log.add(f"Target: {fighter.name} (HP: {fighter.hp}/{fighter.max_hp})", COLOR_RANGED) + +def confirm_target() -> None: + global game_mode, target_x, target_y, player, grid + + if target_x == int(player.x) and target_y == int(player.y): + message_log.add("You cannot target yourself!", COLOR_INVALID) + return + + target_enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + + if target_enemy is None or target_enemy not in entity_data: + message_log.add("No valid target at that location.", COLOR_INVALID) + return + + perform_ranged_attack(target_enemy) + exit_targeting_mode() + enemy_turn() + update_ui() + +def perform_ranged_attack(target_entity: mcrfpy.Entity) -> None: + global player, game_over + + defender = entity_data.get(target_entity) + attacker = entity_data.get(player) + + if defender is None or attacker is None: + return + + damage = max(1, RANGED_ATTACK_DAMAGE - defender.defense // 2) + + defender.take_damage(damage) + + message_log.add( + f"Your ranged attack hits the {defender.name} for {damage} damage!", + COLOR_RANGED + ) + + if not defender.is_alive: + handle_death(target_entity, defender) + +# ============================================================================= +# Combat System +# ============================================================================= + +def calculate_damage(attacker: Fighter, defender: Fighter) -> int: + return max(0, attacker.attack - defender.defense) + +def perform_attack(attacker_entity: mcrfpy.Entity, defender_entity: mcrfpy.Entity) -> None: + global game_over + + attacker = entity_data.get(attacker_entity) + defender = entity_data.get(defender_entity) + + if attacker is None or defender is None: + return + + damage = calculate_damage(attacker, defender) + defender.take_damage(damage) + + if damage > 0: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} for {damage} damage!", + COLOR_PLAYER_ATTACK + ) + else: + message_log.add( + f"The {attacker.name} hits you for {damage} damage!", + COLOR_ENEMY_ATTACK + ) + else: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} but deal no damage.", + mcrfpy.Color(150, 150, 150) + ) + else: + message_log.add( + f"The {attacker.name} hits but deals no damage.", + mcrfpy.Color(150, 150, 200) + ) + + if not defender.is_alive: + handle_death(defender_entity, defender) + + update_ui() + +def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None: + global game_over, grid + + if fighter.is_player: + message_log.add("You have died!", COLOR_PLAYER_DEATH) + message_log.add("Press R to restart or Escape to quit.", COLOR_INFO) + game_over = True + entity.sprite_index = SPRITE_CORPSE + delete_save() + else: + message_log.add(f"The {fighter.name} dies!", COLOR_ENEMY_DEATH) + remove_entity(grid, entity) + +# ============================================================================= +# Item Actions +# ============================================================================= + +def pickup_item() -> bool: + global player, player_inventory, grid + + px, py = int(player.x), int(player.y) + item_entity = get_item_at(grid, px, py) + + if item_entity is None: + message_log.add("There is nothing to pick up here.", COLOR_INVALID) + return False + + if player_inventory.is_full(): + message_log.add("Your inventory is full!", COLOR_WARNING) + return False + + item = item_data.get(item_entity) + if item is None: + return False + + player_inventory.add(item) + remove_item_entity(grid, item_entity) + + message_log.add(f"You pick up the {item.name}.", COLOR_PICKUP) + + update_ui() + return True + +def use_item(index: int) -> bool: + global player, player_inventory + + item = player_inventory.get(index) + if item is None: + message_log.add("Invalid item selection.", COLOR_INVALID) + return False + + if item.item_type == "health_potion": + fighter = entity_data.get(player) + if fighter is None: + return False + + if fighter.hp >= fighter.max_hp: + message_log.add("You are already at full health!", COLOR_WARNING) + return False + + actual_heal = fighter.heal(item.heal_amount) + player_inventory.remove(index) + + message_log.add(f"You drink the {item.name} and recover {actual_heal} HP!", COLOR_HEAL) + + update_ui() + return True + + message_log.add(f"You cannot use the {item.name}.", COLOR_INVALID) + return False + +# ============================================================================= +# Field of View +# ============================================================================= + +def update_entity_visibility(target_grid: mcrfpy.Grid) -> None: + global player, target_cursor + + for entity in target_grid.entities: + if entity == player: + entity.visible = True + continue + + if entity == target_cursor: + entity.visible = True + continue + + ex, ey = int(entity.x), int(entity.y) + entity.visible = target_grid.is_in_fov(ex, ey) + +def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None: + target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if target_grid.is_in_fov(x, y): + mark_explored(x, y) + target_fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + target_fov_layer.set(x, y, COLOR_DISCOVERED) + else: + target_fov_layer.set(x, y, COLOR_UNKNOWN) + + update_entity_visibility(target_grid) + +# ============================================================================= +# Movement and Actions +# ============================================================================= + +def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int, mover: mcrfpy.Entity = None) -> bool: + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + + if not target_grid.at(x, y).walkable: + return False + + blocker = get_blocking_entity_at(target_grid, x, y, exclude=mover) + if blocker is not None: + return False + + return True + +def try_move_or_attack(dx: int, dy: int) -> None: + global player, grid, fov_layer, game_over + + if game_over: + return + + px, py = int(player.x), int(player.y) + new_target_x = px + dx + new_target_y = py + dy + + if new_target_x < 0 or new_target_x >= GRID_WIDTH or new_target_y < 0 or new_target_y >= GRID_HEIGHT: + return + + blocker = get_blocking_entity_at(grid, new_target_x, new_target_y, exclude=player) + + if blocker is not None: + perform_attack(player, blocker) + enemy_turn() + elif grid.at(new_target_x, new_target_y).walkable: + player.x = new_target_x + player.y = new_target_y + update_fov(grid, fov_layer, new_target_x, new_target_y) + enemy_turn() + + update_ui() + +# ============================================================================= +# Enemy AI +# ============================================================================= + +def enemy_turn() -> None: + global player, grid, game_over + + if game_over: + return + + player_x, player_y = int(player.x), int(player.y) + + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data and entity_data[entity].is_alive: + enemies.append(entity) + + for enemy in enemies: + fighter = entity_data.get(enemy) + if fighter is None or not fighter.is_alive: + continue + + ex, ey = int(enemy.x), int(enemy.y) + + if not grid.is_in_fov(ex, ey): + continue + + dx = player_x - ex + dy = player_y - ey + + if abs(dx) <= 1 and abs(dy) <= 1 and (dx != 0 or dy != 0): + perform_attack(enemy, player) + else: + move_toward_player(enemy, ex, ey, player_x, player_y) + +def move_toward_player(enemy: mcrfpy.Entity, ex: int, ey: int, px: int, py: int) -> None: + global grid + + dx = 0 + dy = 0 + + if px < ex: + dx = -1 + elif px > ex: + dx = 1 + + if py < ey: + dy = -1 + elif py > ey: + dy = 1 + + new_x = ex + dx + new_y = ey + dy + + if can_move_to(grid, new_x, new_y, enemy): + enemy.x = new_x + enemy.y = new_y + elif dx != 0 and can_move_to(grid, ex + dx, ey, enemy): + enemy.x = ex + dx + elif dy != 0 and can_move_to(grid, ex, ey + dy, enemy): + enemy.y = ey + dy + +# ============================================================================= +# UI Updates +# ============================================================================= + +def update_ui() -> None: + global player, health_bar, inventory_panel, player_inventory + + if player in entity_data: + fighter = entity_data[player] + health_bar.update(fighter.hp, fighter.max_hp) + + if player_inventory: + inventory_panel.update(player_inventory) + +# ============================================================================= +# New Game Generation +# ============================================================================= + +def generate_new_game() -> None: + """Generate a fresh dungeon with new player.""" + global player, player_inventory, grid, fov_layer, game_over + global entity_data, item_data, dungeon_level, game_mode + + game_over = False + game_mode = GameMode.NORMAL + dungeon_level = 1 + + entity_data.clear() + item_data.clear() + + while len(grid.entities) > 0: + grid.entities.remove(0) + + init_explored() + message_log.clear() + + player_start = generate_dungeon(grid, dungeon_level) + + player = mcrfpy.Entity( + grid_pos=(player_start[0], player_start[1]), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True + ) + + player_inventory = Inventory(capacity=10) + + spawn_entities_for_level(grid, texture, dungeon_level) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, player_start[0], player_start[1]) + + mode_display.update(game_mode) + level_display.update(dungeon_level) + update_ui() + +# ============================================================================= +# Game Setup +# ============================================================================= + +scene = mcrfpy.Scene("game") +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +grid = mcrfpy.Grid( + pos=(20, GAME_AREA_Y), + size=(700, GAME_AREA_HEIGHT - 20), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +fov_layer = grid.add_layer("color", z_index=-1) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +scene.children.append(grid) + +# ============================================================================= +# Create UI Elements +# ============================================================================= + +title = mcrfpy.Caption( + pos=(20, 10), + text="Part 11: Multiple Levels" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +instructions = mcrfpy.Caption( + pos=(260, 15), + text="WASD:Move | >:Descend | F:Ranged | G:Pickup | R:Restart" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 14 +scene.children.append(instructions) + +health_bar = HealthBar(x=730, y=10, width=280, height=30) +health_bar.add_to_scene(scene) + +level_display = LevelDisplay(x=730, y=45) +level_display.add_to_scene(scene) + +mode_display = ModeDisplay(x=20, y=40) +mode_display.add_to_scene(scene) + +inventory_panel = InventoryPanel(x=730, y=GAME_AREA_Y, width=280, height=150) +inventory_panel.add_to_scene(scene) + +message_log = MessageLog( + x=20, + y=768 - UI_BOTTOM_HEIGHT + 10, + width=990, + height=UI_BOTTOM_HEIGHT - 20, + max_messages=6 +) +message_log.add_to_scene(scene) + +# ============================================================================= +# Initialize Game +# ============================================================================= + +init_explored() + +if has_save_file(): + message_log.add("Found saved game. Loading...", COLOR_INFO) + if not load_game(): + message_log.add("Failed to load. Starting new game.", COLOR_WARNING) + generate_new_game() + message_log.add("Welcome to the dungeon!", COLOR_INFO) +else: + generate_new_game() + message_log.add("Welcome to the dungeon!", COLOR_INFO) + message_log.add("Find the stairs (>) to descend deeper.", COLOR_INFO) + +# ============================================================================= +# Input Handling +# ============================================================================= + +def handle_keys(key: str, action: str) -> None: + global game_over, game_mode + + if action != "start": + return + + if key == "R": + delete_save() + generate_new_game() + message_log.add("A new adventure begins!", COLOR_INFO) + return + + if key == "Escape": + if game_mode == GameMode.TARGETING: + exit_targeting_mode() + message_log.add("Targeting cancelled.", COLOR_INFO) + return + else: + if not game_over: + save_game() + mcrfpy.exit() + return + + if key == "Period" and game_mode == GameMode.NORMAL and not game_over: + save_game() + return + + if game_over: + return + + if game_mode == GameMode.TARGETING: + handle_targeting_input(key) + else: + handle_normal_input(key) + +def handle_normal_input(key: str) -> None: + if key == "W" or key == "Up": + try_move_or_attack(0, -1) + elif key == "S" or key == "Down": + try_move_or_attack(0, 1) + elif key == "A" or key == "Left": + try_move_or_attack(-1, 0) + elif key == "D" or key == "Right": + try_move_or_attack(1, 0) + elif key == "F": + enter_targeting_mode() + elif key == "G" or key == ",": + pickup_item() + elif key == "Period" and mcrfpy.keypressed("LShift"): + # Shift+. (>) to descend stairs + descend_stairs() + elif key in ["1", "2", "3", "4", "5"]: + index = int(key) - 1 + if use_item(index): + enemy_turn() + update_ui() + +def handle_targeting_input(key: str) -> None: + if key == "Up" or key == "W": + move_cursor(0, -1) + elif key == "Down" or key == "S": + move_cursor(0, 1) + elif key == "Left" or key == "A": + move_cursor(-1, 0) + elif key == "Right" or key == "D": + move_cursor(1, 0) + elif key == "Return" or key == "Space": + confirm_target() + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 11 loaded! Find stairs (>) to descend to deeper levels.") \ No newline at end of file diff --git a/docs/tutorials/part_12_experience/part_12_experience.py b/docs/tutorials/part_12_experience/part_12_experience.py new file mode 100644 index 0000000..ec59009 --- /dev/null +++ b/docs/tutorials/part_12_experience/part_12_experience.py @@ -0,0 +1,1850 @@ +"""McRogueFace - Part 12: Experience and Leveling + +Documentation: https://mcrogueface.github.io/tutorial/part_12_experience +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_12_experience/part_12_experience.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 +import json +import os +from dataclasses import dataclass, field +from typing import Optional +from enum import Enum + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player +SPRITE_CORPSE = 37 # '%' - remains +SPRITE_POTION = 173 # Potion sprite +SPRITE_CURSOR = 88 # 'X' - targeting cursor +SPRITE_STAIRS_DOWN = 62 # '>' - stairs down + +# Enemy sprites +SPRITE_GOBLIN = 103 # 'g' +SPRITE_ORC = 111 # 'o' +SPRITE_TROLL = 116 # 't' + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 30 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# FOV and targeting settings +FOV_RADIUS = 8 +RANGED_ATTACK_RANGE = 6 +RANGED_ATTACK_DAMAGE = 4 + +# Save file location +SAVE_FILE = "savegame.json" + +# XP values for enemies +ENEMY_XP_VALUES = { + "goblin": 35, + "orc": 50, + "troll": 100 +} + +# Visibility colors +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) + +# Message colors +COLOR_PLAYER_ATTACK = mcrfpy.Color(200, 200, 200) +COLOR_ENEMY_ATTACK = mcrfpy.Color(255, 150, 150) +COLOR_PLAYER_DEATH = mcrfpy.Color(255, 50, 50) +COLOR_ENEMY_DEATH = mcrfpy.Color(100, 255, 100) +COLOR_HEAL = mcrfpy.Color(100, 255, 100) +COLOR_PICKUP = mcrfpy.Color(100, 200, 255) +COLOR_INFO = mcrfpy.Color(100, 100, 255) +COLOR_WARNING = mcrfpy.Color(255, 200, 50) +COLOR_INVALID = mcrfpy.Color(255, 100, 100) +COLOR_RANGED = mcrfpy.Color(255, 255, 100) +COLOR_SAVE = mcrfpy.Color(100, 255, 200) +COLOR_DESCEND = mcrfpy.Color(200, 200, 255) +COLOR_LEVEL_UP = mcrfpy.Color(255, 255, 100) +COLOR_XP = mcrfpy.Color(200, 200, 100) + +# UI Layout constants +UI_TOP_HEIGHT = 60 +UI_BOTTOM_HEIGHT = 150 +GAME_AREA_Y = UI_TOP_HEIGHT +GAME_AREA_HEIGHT = 768 - UI_TOP_HEIGHT - UI_BOTTOM_HEIGHT + +# ============================================================================= +# Game Modes +# ============================================================================= + +class GameMode(Enum): + NORMAL = "normal" + TARGETING = "targeting" + +# ============================================================================= +# Fighter Component with XP +# ============================================================================= + +@dataclass +class Fighter: + """Combat stats for an entity with experience system.""" + hp: int + max_hp: int + attack: int + defense: int + name: str + is_player: bool = False + xp: int = 0 + level: int = 1 + + @property + def is_alive(self) -> bool: + return self.hp > 0 + + @property + def xp_to_next_level(self) -> int: + """Calculate XP needed to reach the next level.""" + return self.level * 100 + + @property + def xp_progress(self) -> float: + """Get XP progress as a percentage (0.0 to 1.0).""" + return self.xp / self.xp_to_next_level if self.xp_to_next_level > 0 else 0 + + def take_damage(self, amount: int) -> int: + actual_damage = min(self.hp, amount) + self.hp -= actual_damage + return actual_damage + + def heal(self, amount: int) -> int: + actual_heal = min(self.max_hp - self.hp, amount) + self.hp += actual_heal + return actual_heal + + def gain_xp(self, amount: int) -> bool: + """Add XP and check for level up. + + Args: + amount: XP to add + + Returns: + True if leveled up, False otherwise + """ + self.xp += amount + + if self.xp >= self.xp_to_next_level: + self.level_up() + return True + + return False + + def level_up(self) -> None: + """Level up the character, increasing stats.""" + # Subtract XP cost (excess carries over) + self.xp -= self.xp_to_next_level + + self.level += 1 + + # Stat increases + hp_increase = 5 + attack_increase = 1 + defense_increase = 1 if self.level % 3 == 0 else 0 # Every 3rd level + + self.max_hp += hp_increase + self.hp = self.max_hp # Full heal on level up! + self.attack += attack_increase + self.defense += defense_increase + + def to_dict(self) -> dict: + return { + "hp": self.hp, + "max_hp": self.max_hp, + "attack": self.attack, + "defense": self.defense, + "name": self.name, + "is_player": self.is_player, + "xp": self.xp, + "level": self.level + } + + @classmethod + def from_dict(cls, data: dict) -> "Fighter": + return cls( + hp=data["hp"], + max_hp=data["max_hp"], + attack=data["attack"], + defense=data["defense"], + name=data["name"], + is_player=data.get("is_player", False), + xp=data.get("xp", 0), + level=data.get("level", 1) + ) + +# ============================================================================= +# Item Component +# ============================================================================= + +@dataclass +class Item: + """Data for an item that can be picked up and used.""" + name: str + item_type: str + heal_amount: int = 0 + + def to_dict(self) -> dict: + return { + "name": self.name, + "item_type": self.item_type, + "heal_amount": self.heal_amount + } + + @classmethod + def from_dict(cls, data: dict) -> "Item": + return cls( + name=data["name"], + item_type=data["item_type"], + heal_amount=data.get("heal_amount", 0) + ) + +# ============================================================================= +# Inventory System +# ============================================================================= + +@dataclass +class Inventory: + """Container for items the player is carrying.""" + capacity: int = 10 + items: list = field(default_factory=list) + + def add(self, item: Item) -> bool: + if len(self.items) >= self.capacity: + return False + self.items.append(item) + return True + + def remove(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items.pop(index) + return None + + def get(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items[index] + return None + + def is_full(self) -> bool: + return len(self.items) >= self.capacity + + def count(self) -> int: + return len(self.items) + + def to_dict(self) -> dict: + return { + "capacity": self.capacity, + "items": [item.to_dict() for item in self.items] + } + + @classmethod + def from_dict(cls, data: dict) -> "Inventory": + inv = cls(capacity=data.get("capacity", 10)) + inv.items = [Item.from_dict(item_data) for item_data in data.get("items", [])] + return inv + +# ============================================================================= +# Templates +# ============================================================================= + +ITEM_TEMPLATES = { + "health_potion": { + "name": "Health Potion", + "sprite": SPRITE_POTION, + + "item_type": "health_potion", + "heal_amount": 10 + }, + "greater_health_potion": { + "name": "Greater Health Potion", + "sprite": SPRITE_POTION, + + "item_type": "health_potion", + "heal_amount": 20 + } +} + +ENEMY_TEMPLATES = { + "goblin": { + "sprite": SPRITE_GOBLIN, + "hp": 6, + "attack": 3, + "defense": 0, + "color": mcrfpy.Color(100, 200, 100) + }, + "orc": { + "sprite": SPRITE_ORC, + "hp": 10, + "attack": 4, + "defense": 1, + "color": mcrfpy.Color(100, 150, 100) + }, + "troll": { + "sprite": SPRITE_TROLL, + "hp": 16, + "attack": 6, + "defense": 2, + "color": mcrfpy.Color(50, 150, 50) + } +} + +# ============================================================================= +# Difficulty Scaling +# ============================================================================= + +def get_max_enemies_per_room(level: int) -> int: + return min(2 + level, 6) + +def get_max_items_per_room(level: int) -> int: + return min(1 + level // 2, 3) + +def get_enemy_weights(level: int) -> list[tuple[str, float]]: + if level <= 2: + return [("goblin", 0.8), ("orc", 0.95), ("troll", 1.0)] + elif level <= 4: + return [("goblin", 0.5), ("orc", 0.85), ("troll", 1.0)] + else: + return [("goblin", 0.3), ("orc", 0.6), ("troll", 1.0)] + +def get_item_weights(level: int) -> list[tuple[str, float]]: + if level <= 2: + return [("health_potion", 1.0)] + else: + return [("health_potion", 0.7), ("greater_health_potion", 1.0)] + +# ============================================================================= +# Message Log System +# ============================================================================= + +class MessageLog: + """A message log that displays recent game messages with colors.""" + + def __init__(self, x: int, y: int, width: int, height: int, max_messages: int = 6): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_messages = max_messages + self.messages: list[tuple[str, mcrfpy.Color]] = [] + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + line_height = 20 + for i in range(max_messages): + caption = mcrfpy.Caption( + pos=(x + 10, y + 5 + i * line_height), + text="" + ) + caption.font_size = 14 + caption.fill_color = mcrfpy.Color(200, 200, 200) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + for caption in self.captions: + scene.children.append(caption) + + def add(self, text: str, color: mcrfpy.Color = None) -> None: + if color is None: + color = mcrfpy.Color(200, 200, 200) + + self.messages.append((text, color)) + + while len(self.messages) > self.max_messages: + self.messages.pop(0) + + self._refresh() + + def _refresh(self) -> None: + for i, caption in enumerate(self.captions): + if i < len(self.messages): + text, color = self.messages[i] + caption.text = text + caption.fill_color = color + else: + caption.text = "" + + def clear(self) -> None: + self.messages.clear() + self._refresh() + +# ============================================================================= +# Health Bar System +# ============================================================================= + +class HealthBar: + """A visual health bar using nested frames.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_hp = 30 + self.current_hp = 30 + + self.bg_frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.bg_frame.fill_color = mcrfpy.Color(80, 0, 0) + self.bg_frame.outline = 2 + self.bg_frame.outline_color = mcrfpy.Color(150, 150, 150) + + self.fg_frame = mcrfpy.Frame( + pos=(x + 2, y + 2), + size=(width - 4, height - 4) + ) + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + self.fg_frame.outline = 0 + + self.label = mcrfpy.Caption( + pos=(x + 5, y + 2), + text=f"HP: {self.current_hp}/{self.max_hp}" + ) + self.label.font_size = 16 + self.label.fill_color = mcrfpy.Color(255, 255, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.bg_frame) + scene.children.append(self.fg_frame) + scene.children.append(self.label) + + def update(self, current_hp: int, max_hp: int) -> None: + self.current_hp = current_hp + self.max_hp = max_hp + + percent = max(0, current_hp / max_hp) if max_hp > 0 else 0 + + inner_width = self.width - 4 + self.fg_frame.resize(int(inner_width * percent), self.height - 4) + + self.label.text = f"HP: {current_hp}/{max_hp}" + + if percent > 0.6: + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + elif percent > 0.3: + self.fg_frame.fill_color = mcrfpy.Color(180, 180, 0) + else: + self.fg_frame.fill_color = mcrfpy.Color(180, 0, 0) + +# ============================================================================= +# XP Bar System +# ============================================================================= + +class XPBar: + """A visual XP bar showing progress to next level.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + + self.bg_frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.bg_frame.fill_color = mcrfpy.Color(40, 40, 80) + self.bg_frame.outline = 2 + self.bg_frame.outline_color = mcrfpy.Color(100, 100, 150) + + self.fg_frame = mcrfpy.Frame( + pos=(x + 2, y + 2), + size=(0, height - 4) + ) + self.fg_frame.fill_color = mcrfpy.Color(200, 200, 50) + self.fg_frame.outline = 0 + + self.label = mcrfpy.Caption( + pos=(x + 5, y + 1), + text="Level 1 | XP: 0/100" + ) + self.label.font_size = 14 + self.label.fill_color = mcrfpy.Color(255, 255, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.bg_frame) + scene.children.append(self.fg_frame) + scene.children.append(self.label) + + def update(self, level: int, xp: int, xp_to_next: int) -> None: + """Update the XP bar display. + + Args: + level: Current player level + xp: Current XP + xp_to_next: XP needed to reach next level + """ + percent = xp / xp_to_next if xp_to_next > 0 else 0 + percent = min(1.0, max(0.0, percent)) + + inner_width = self.width - 4 + self.fg_frame.resize(int(inner_width * percent), self.height - 4) + + self.label.text = f"Level {level} | XP: {xp}/{xp_to_next}" + +# ============================================================================= +# Inventory Panel +# ============================================================================= + +class InventoryPanel: + """A panel displaying the player's inventory.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + self.title = mcrfpy.Caption( + pos=(x + 10, y + 5), + text="Inventory (G:pickup, 1-5:use)" + ) + self.title.font_size = 14 + self.title.fill_color = mcrfpy.Color(200, 200, 255) + + for i in range(5): + caption = mcrfpy.Caption( + pos=(x + 10, y + 25 + i * 18), + text="" + ) + caption.font_size = 13 + caption.fill_color = mcrfpy.Color(180, 180, 180) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + scene.children.append(self.title) + for caption in self.captions: + scene.children.append(caption) + + def update(self, inventory: Inventory) -> None: + for i, caption in enumerate(self.captions): + if i < len(inventory.items): + item = inventory.items[i] + caption.text = f"{i+1}. {item.name}" + caption.fill_color = mcrfpy.Color(180, 180, 180) + else: + caption.text = f"{i+1}. ---" + caption.fill_color = mcrfpy.Color(80, 80, 80) + +# ============================================================================= +# Stats Panel +# ============================================================================= + +class StatsPanel: + """A panel displaying player stats.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x = x + self.y = y + self.width = width + self.height = height + + self.frame = mcrfpy.Frame( + pos=(x, y), + size=(width, height) + ) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + self.title = mcrfpy.Caption( + pos=(x + 10, y + 5), + text="Stats" + ) + self.title.font_size = 14 + self.title.fill_color = mcrfpy.Color(200, 200, 255) + + self.attack_label = mcrfpy.Caption( + pos=(x + 10, y + 25), + text="Attack: 5" + ) + self.attack_label.font_size = 13 + self.attack_label.fill_color = mcrfpy.Color(255, 150, 150) + + self.defense_label = mcrfpy.Caption( + pos=(x + 10, y + 43), + text="Defense: 2" + ) + self.defense_label.font_size = 13 + self.defense_label.fill_color = mcrfpy.Color(150, 150, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + scene.children.append(self.title) + scene.children.append(self.attack_label) + scene.children.append(self.defense_label) + + def update(self, attack: int, defense: int) -> None: + self.attack_label.text = f"Attack: {attack}" + self.defense_label.text = f"Defense: {defense}" + +# ============================================================================= +# Level Display +# ============================================================================= + +class LevelDisplay: + """Displays current dungeon level.""" + + def __init__(self, x: int, y: int): + self.caption = mcrfpy.Caption( + pos=(x, y), + text="Dungeon Level: 1" + ) + self.caption.font_size = 16 + self.caption.fill_color = mcrfpy.Color(200, 200, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.caption) + + def update(self, level: int) -> None: + self.caption.text = f"Dungeon: {level}" + +# ============================================================================= +# Mode Display +# ============================================================================= + +class ModeDisplay: + """Displays the current game mode.""" + + def __init__(self, x: int, y: int): + self.caption = mcrfpy.Caption( + pos=(x, y), + text="[NORMAL MODE]" + ) + self.caption.font_size = 16 + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.caption) + + def update(self, mode: GameMode) -> None: + if mode == GameMode.NORMAL: + self.caption.text = "[NORMAL] F:Ranged | >:Descend" + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + elif mode == GameMode.TARGETING: + self.caption.text = "[TARGETING] Arrows:Move, Enter:Fire, Esc:Cancel" + self.caption.fill_color = mcrfpy.Color(255, 255, 100) + +# ============================================================================= +# Global State +# ============================================================================= + +entity_data: dict[mcrfpy.Entity, Fighter] = {} +item_data: dict[mcrfpy.Entity, Item] = {} + +player: Optional[mcrfpy.Entity] = None +player_inventory: Optional[Inventory] = None +grid: Optional[mcrfpy.Grid] = None +fov_layer = None +texture: Optional[mcrfpy.Texture] = None +game_over: bool = False +dungeon_level: int = 1 +stairs_position: tuple[int, int] = (0, 0) + +game_mode: GameMode = GameMode.NORMAL +target_cursor: Optional[mcrfpy.Entity] = None +target_x: int = 0 +target_y: int = 0 + +# UI components +message_log: Optional[MessageLog] = None +health_bar: Optional[HealthBar] = None +xp_bar: Optional[XPBar] = None +inventory_panel: Optional[InventoryPanel] = None +stats_panel: Optional[StatsPanel] = None +mode_display: Optional[ModeDisplay] = None +level_display: Optional[LevelDisplay] = None + +# ============================================================================= +# Room Class +# ============================================================================= + +class RectangularRoom: + """A rectangular room with its position and size.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x1 = x + self.y1 = y + self.x2 = x + width + self.y2 = y + height + + @property + def center(self) -> tuple[int, int]: + 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 slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return ( + self.x1 <= other.x2 and + self.x2 >= other.x1 and + self.y1 <= other.y2 and + self.y2 >= other.y1 + ) + +# ============================================================================= +# Exploration Tracking +# ============================================================================= + +explored: list[list[bool]] = [] + +def init_explored() -> None: + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Dungeon Generation +# ============================================================================= + +def fill_with_walls(target_grid: mcrfpy.Grid) -> None: + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(target_grid: mcrfpy.Grid, room: RectangularRoom) -> None: + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(target_grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(target_grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel( + target_grid: mcrfpy.Grid, + start: tuple[int, int], + end: tuple[int, int] +) -> None: + x1, y1 = start + x2, y2 = end + + if random.random() < 0.5: + carve_tunnel_horizontal(target_grid, x1, x2, y1) + carve_tunnel_vertical(target_grid, y1, y2, x2) + else: + carve_tunnel_vertical(target_grid, y1, y2, x1) + carve_tunnel_horizontal(target_grid, x1, x2, y2) + +def place_stairs(target_grid: mcrfpy.Grid, x: int, y: int) -> None: + global stairs_position + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_STAIRS_DOWN + cell.walkable = True + cell.transparent = True + stairs_position = (x, y) + +def generate_dungeon(target_grid: mcrfpy.Grid, level: int) -> tuple[int, int]: + global stairs_position + fill_with_walls(target_grid) + + rooms: list[RectangularRoom] = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + overlaps = False + for other_room in rooms: + if new_room.intersects(other_room): + overlaps = True + break + + if overlaps: + continue + + carve_room(target_grid, new_room) + + if rooms: + carve_l_tunnel(target_grid, new_room.center, rooms[-1].center) + + rooms.append(new_room) + + if rooms: + stairs_x, stairs_y = rooms[-1].center + place_stairs(target_grid, stairs_x, stairs_y) + + if rooms: + return rooms[0].center + else: + return GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# ============================================================================= +# Entity Management +# ============================================================================= + +def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ENEMY_TEMPLATES[enemy_type] + + enemy = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + enemy.visible = False + + target_grid.entities.append(enemy) + + entity_data[enemy] = Fighter( + hp=template["hp"], + max_hp=template["hp"], + attack=template["attack"], + defense=template["defense"], + name=enemy_type.capitalize(), + is_player=False + ) + + return enemy + +def spawn_item(target_grid: mcrfpy.Grid, x: int, y: int, item_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ITEM_TEMPLATES[item_type] + + item_entity = mcrfpy.Entity( + grid_pos=(x, y), + texture=tex, + sprite_index=template["sprite"] + ) + + item_entity.visible = False + + target_grid.entities.append(item_entity) + + item_data[item_entity] = Item( + name=template["name"], + item_type=template["item_type"], + heal_amount=template.get("heal_amount", 0) + ) + + return item_entity + +def spawn_entities_for_level(target_grid: mcrfpy.Grid, tex: mcrfpy.Texture, level: int) -> None: + floor_tiles = [] + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + if cell.walkable and cell.tilesprite == SPRITE_FLOOR: + floor_tiles.append((x, y)) + + max_enemies = get_max_enemies_per_room(level) * 3 + enemy_weights = get_enemy_weights(level) + + for _ in range(max_enemies): + if not floor_tiles: + break + + x, y = random.choice(floor_tiles) + + if (x, y) == (int(player.x), int(player.y)): + continue + if (x, y) == stairs_position: + continue + if is_position_occupied(target_grid, x, y): + continue + + roll = random.random() + enemy_type = "goblin" + for etype, threshold in enemy_weights: + if roll < threshold: + enemy_type = etype + break + + spawn_enemy(target_grid, x, y, enemy_type, tex) + + max_items = get_max_items_per_room(level) * 2 + item_weights = get_item_weights(level) + + for _ in range(max_items): + if not floor_tiles: + break + + x, y = random.choice(floor_tiles) + + if (x, y) == (int(player.x), int(player.y)): + continue + if (x, y) == stairs_position: + continue + if is_position_occupied(target_grid, x, y): + continue + + roll = random.random() + item_type = "health_potion" + for itype, threshold in item_weights: + if roll < threshold: + item_type = itype + break + + spawn_item(target_grid, x, y, item_type, tex) + +def is_position_occupied(target_grid: mcrfpy.Grid, x: int, y: int) -> bool: + for entity in target_grid.entities: + if int(entity.x) == x and int(entity.y) == y: + return True + return False + +def get_item_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity in item_data: + if int(entity.x) == x and int(entity.y) == y: + return entity + return None + +def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mcrfpy.Entity = None) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity == exclude: + continue + if int(entity.x) == x and int(entity.y) == y: + if entity in entity_data and entity_data[entity].is_alive: + return entity + return None + +def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in entity_data: + del entity_data[entity] + +def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in item_data: + del item_data[entity] + +def clear_entities_except_player(target_grid: mcrfpy.Grid) -> None: + global entity_data, item_data + + entities_to_remove = [] + for entity in target_grid.entities: + if entity in entity_data and entity_data[entity].is_player: + continue + entities_to_remove.append(entity) + + for entity in entities_to_remove: + if entity in entity_data: + del entity_data[entity] + if entity in item_data: + del item_data[entity] + + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + +# ============================================================================= +# XP and Level Up +# ============================================================================= + +def award_xp(enemy_name: str) -> None: + """Award XP for killing an enemy. + + Args: + enemy_name: Name of the defeated enemy + """ + global player + + fighter = entity_data.get(player) + if fighter is None: + return + + enemy_type = enemy_name.lower() + xp_amount = ENEMY_XP_VALUES.get(enemy_type, 35) + + leveled_up = fighter.gain_xp(xp_amount) + + if leveled_up: + message_log.add( + f"You gained {xp_amount} XP and reached level {fighter.level}!", + COLOR_LEVEL_UP + ) + message_log.add( + f"HP +5, Attack +1{', Defense +1' if fighter.level % 3 == 0 else ''}!", + COLOR_LEVEL_UP + ) + else: + message_log.add(f"You gain {xp_amount} XP.", COLOR_XP) + + update_ui() + +# ============================================================================= +# Level Transition +# ============================================================================= + +def descend_stairs() -> bool: + global player, dungeon_level, grid, fov_layer, stairs_position + + px, py = int(player.x), int(player.y) + + if (px, py) != stairs_position: + message_log.add("There are no stairs here.", COLOR_INVALID) + return False + + dungeon_level += 1 + + clear_entities_except_player(grid) + + init_explored() + player_start = generate_dungeon(grid, dungeon_level) + + player.x = player_start[0] + player.y = player_start[1] + + spawn_entities_for_level(grid, texture, dungeon_level) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, player_start[0], player_start[1]) + + message_log.add(f"You descend to level {dungeon_level}...", COLOR_DESCEND) + level_display.update(dungeon_level) + update_ui() + + return True + +# ============================================================================= +# Save/Load System +# ============================================================================= + +def save_game() -> bool: + global player, player_inventory, grid, explored, dungeon_level, stairs_position + + try: + tiles = [] + for y in range(GRID_HEIGHT): + row = [] + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + row.append({ + "tilesprite": cell.tilesprite, + "walkable": cell.walkable, + "transparent": cell.transparent + }) + tiles.append(row) + + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data: + fighter = entity_data[entity] + enemies.append({ + "x": int(entity.x), + "y": int(entity.y), + "type": fighter.name.lower(), + "fighter": fighter.to_dict() + }) + + items_on_ground = [] + for entity in grid.entities: + if entity in item_data: + item = item_data[entity] + items_on_ground.append({ + "x": int(entity.x), + "y": int(entity.y), + "item": item.to_dict() + }) + + save_data = { + "version": 3, + "dungeon_level": dungeon_level, + "stairs_position": list(stairs_position), + "player": { + "x": int(player.x), + "y": int(player.y), + "fighter": entity_data[player].to_dict(), + "inventory": player_inventory.to_dict() + }, + "tiles": tiles, + "explored": [[explored[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)], + "enemies": enemies, + "items": items_on_ground + } + + with open(SAVE_FILE, "w") as f: + json.dump(save_data, f, indent=2) + + message_log.add("Game saved successfully!", COLOR_SAVE) + return True + + except Exception as e: + message_log.add(f"Failed to save: {str(e)}", COLOR_INVALID) + return False + +def load_game() -> bool: + global player, player_inventory, grid, explored, dungeon_level + global entity_data, item_data, fov_layer, game_over, stairs_position + + if not os.path.exists(SAVE_FILE): + return False + + try: + with open(SAVE_FILE, "r") as f: + save_data = json.load(f) + + entity_data.clear() + item_data.clear() + + while len(grid.entities) > 0: + grid.entities.remove(0) + + dungeon_level = save_data.get("dungeon_level", 1) + stairs_position = tuple(save_data.get("stairs_position", [0, 0])) + + tiles = save_data["tiles"] + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + tile_data = tiles[y][x] + cell.tilesprite = tile_data["tilesprite"] + cell.walkable = tile_data["walkable"] + cell.transparent = tile_data["transparent"] + + global explored + explored_data = save_data["explored"] + explored = [[explored_data[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)] + + player_data = save_data["player"] + player = mcrfpy.Entity( + grid_pos=(player_data["x"], player_data["y"]), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter.from_dict(player_data["fighter"]) + player_inventory = Inventory.from_dict(player_data["inventory"]) + + for enemy_data in save_data.get("enemies", []): + enemy_type = enemy_data["type"] + template = ENEMY_TEMPLATES.get(enemy_type, ENEMY_TEMPLATES["goblin"]) + + enemy = mcrfpy.Entity( + grid_pos=(enemy_data["x"], enemy_data["y"]), + texture=texture, + sprite_index=template["sprite"] + ) + enemy.visible = False + + grid.entities.append(enemy) + entity_data[enemy] = Fighter.from_dict(enemy_data["fighter"]) + + for item_entry in save_data.get("items", []): + item_type = item_entry["item"]["item_type"] + template = ITEM_TEMPLATES.get(item_type, ITEM_TEMPLATES["health_potion"]) + + item_entity = mcrfpy.Entity( + grid_pos=(item_entry["x"], item_entry["y"]), + texture=texture, + sprite_index=template["sprite"] + ) + item_entity.visible = False + + grid.entities.append(item_entity) + item_data[item_entity] = Item.from_dict(item_entry["item"]) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, int(player.x), int(player.y)) + + game_over = False + + message_log.add("Game loaded successfully!", COLOR_SAVE) + level_display.update(dungeon_level) + update_ui() + return True + + except Exception as e: + message_log.add(f"Failed to load: {str(e)}", COLOR_INVALID) + return False + +def delete_save() -> bool: + try: + if os.path.exists(SAVE_FILE): + os.remove(SAVE_FILE) + return True + except Exception: + return False + +def has_save_file() -> bool: + return os.path.exists(SAVE_FILE) + +# ============================================================================= +# Targeting System +# ============================================================================= + +def enter_targeting_mode() -> None: + global game_mode, target_cursor, target_x, target_y, player, grid, texture + + target_x = int(player.x) + target_y = int(player.y) + + target_cursor = mcrfpy.Entity( + grid_pos=(target_x, target_y), + texture=texture, + sprite_index=SPRITE_CURSOR + ) + grid.entities.append(target_cursor) + + game_mode = GameMode.TARGETING + + message_log.add("Targeting mode: Arrows to aim, Enter to fire, Esc to cancel.", COLOR_INFO) + mode_display.update(game_mode) + +def exit_targeting_mode() -> None: + global game_mode, target_cursor, grid + + if target_cursor is not None: + for i, e in enumerate(grid.entities): + if e == target_cursor: + grid.entities.remove(i) + break + target_cursor = None + + game_mode = GameMode.NORMAL + mode_display.update(game_mode) + +def move_cursor(dx: int, dy: int) -> None: + global target_x, target_y, target_cursor, grid, player + + new_x = target_x + dx + new_y = target_y + dy + + if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT: + return + + if not grid.is_in_fov(new_x, new_y): + message_log.add("You cannot see that location.", COLOR_INVALID) + return + + player_x, player_y = int(player.x), int(player.y) + distance = abs(new_x - player_x) + abs(new_y - player_y) + if distance > RANGED_ATTACK_RANGE: + message_log.add(f"Target is out of range (max {RANGED_ATTACK_RANGE}).", COLOR_WARNING) + return + + target_x = new_x + target_y = new_y + target_cursor.x = target_x + target_cursor.y = target_y + + enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + if enemy and enemy in entity_data: + fighter = entity_data[enemy] + message_log.add(f"Target: {fighter.name} (HP: {fighter.hp}/{fighter.max_hp})", COLOR_RANGED) + +def confirm_target() -> None: + global game_mode, target_x, target_y, player, grid + + if target_x == int(player.x) and target_y == int(player.y): + message_log.add("You cannot target yourself!", COLOR_INVALID) + return + + target_enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + + if target_enemy is None or target_enemy not in entity_data: + message_log.add("No valid target at that location.", COLOR_INVALID) + return + + perform_ranged_attack(target_enemy) + exit_targeting_mode() + enemy_turn() + update_ui() + +def perform_ranged_attack(target_entity: mcrfpy.Entity) -> None: + global player, game_over + + defender = entity_data.get(target_entity) + attacker = entity_data.get(player) + + if defender is None or attacker is None: + return + + damage = max(1, RANGED_ATTACK_DAMAGE - defender.defense // 2) + + defender.take_damage(damage) + + message_log.add( + f"Your ranged attack hits the {defender.name} for {damage} damage!", + COLOR_RANGED + ) + + if not defender.is_alive: + handle_death(target_entity, defender) + +# ============================================================================= +# Combat System +# ============================================================================= + +def calculate_damage(attacker: Fighter, defender: Fighter) -> int: + return max(0, attacker.attack - defender.defense) + +def perform_attack(attacker_entity: mcrfpy.Entity, defender_entity: mcrfpy.Entity) -> None: + global game_over + + attacker = entity_data.get(attacker_entity) + defender = entity_data.get(defender_entity) + + if attacker is None or defender is None: + return + + damage = calculate_damage(attacker, defender) + defender.take_damage(damage) + + if damage > 0: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} for {damage} damage!", + COLOR_PLAYER_ATTACK + ) + else: + message_log.add( + f"The {attacker.name} hits you for {damage} damage!", + COLOR_ENEMY_ATTACK + ) + else: + if attacker.is_player: + message_log.add( + f"You hit the {defender.name} but deal no damage.", + mcrfpy.Color(150, 150, 150) + ) + else: + message_log.add( + f"The {attacker.name} hits but deals no damage.", + mcrfpy.Color(150, 150, 200) + ) + + if not defender.is_alive: + handle_death(defender_entity, defender) + + update_ui() + +def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None: + global game_over, grid + + if fighter.is_player: + message_log.add("You have died!", COLOR_PLAYER_DEATH) + message_log.add("Press R to restart or Escape to quit.", COLOR_INFO) + game_over = True + entity.sprite_index = SPRITE_CORPSE + delete_save() + else: + message_log.add(f"The {fighter.name} dies!", COLOR_ENEMY_DEATH) + # Award XP before removing the entity + award_xp(fighter.name) + remove_entity(grid, entity) + +# ============================================================================= +# Item Actions +# ============================================================================= + +def pickup_item() -> bool: + global player, player_inventory, grid + + px, py = int(player.x), int(player.y) + item_entity = get_item_at(grid, px, py) + + if item_entity is None: + message_log.add("There is nothing to pick up here.", COLOR_INVALID) + return False + + if player_inventory.is_full(): + message_log.add("Your inventory is full!", COLOR_WARNING) + return False + + item = item_data.get(item_entity) + if item is None: + return False + + player_inventory.add(item) + remove_item_entity(grid, item_entity) + + message_log.add(f"You pick up the {item.name}.", COLOR_PICKUP) + + update_ui() + return True + +def use_item(index: int) -> bool: + global player, player_inventory + + item = player_inventory.get(index) + if item is None: + message_log.add("Invalid item selection.", COLOR_INVALID) + return False + + if item.item_type == "health_potion": + fighter = entity_data.get(player) + if fighter is None: + return False + + if fighter.hp >= fighter.max_hp: + message_log.add("You are already at full health!", COLOR_WARNING) + return False + + actual_heal = fighter.heal(item.heal_amount) + player_inventory.remove(index) + + message_log.add(f"You drink the {item.name} and recover {actual_heal} HP!", COLOR_HEAL) + + update_ui() + return True + + message_log.add(f"You cannot use the {item.name}.", COLOR_INVALID) + return False + +# ============================================================================= +# Field of View +# ============================================================================= + +def update_entity_visibility(target_grid: mcrfpy.Grid) -> None: + global player, target_cursor + + for entity in target_grid.entities: + if entity == player: + entity.visible = True + continue + + if entity == target_cursor: + entity.visible = True + continue + + ex, ey = int(entity.x), int(entity.y) + entity.visible = target_grid.is_in_fov(ex, ey) + +def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None: + target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if target_grid.is_in_fov(x, y): + mark_explored(x, y) + target_fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + target_fov_layer.set(x, y, COLOR_DISCOVERED) + else: + target_fov_layer.set(x, y, COLOR_UNKNOWN) + + update_entity_visibility(target_grid) + +# ============================================================================= +# Movement and Actions +# ============================================================================= + +def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int, mover: mcrfpy.Entity = None) -> bool: + if x < 0 or x >= GRID_WIDTH or y < 0 or y >= GRID_HEIGHT: + return False + + if not target_grid.at(x, y).walkable: + return False + + blocker = get_blocking_entity_at(target_grid, x, y, exclude=mover) + if blocker is not None: + return False + + return True + +def try_move_or_attack(dx: int, dy: int) -> None: + global player, grid, fov_layer, game_over + + if game_over: + return + + px, py = int(player.x), int(player.y) + new_target_x = px + dx + new_target_y = py + dy + + if new_target_x < 0 or new_target_x >= GRID_WIDTH or new_target_y < 0 or new_target_y >= GRID_HEIGHT: + return + + blocker = get_blocking_entity_at(grid, new_target_x, new_target_y, exclude=player) + + if blocker is not None: + perform_attack(player, blocker) + enemy_turn() + elif grid.at(new_target_x, new_target_y).walkable: + player.x = new_target_x + player.y = new_target_y + update_fov(grid, fov_layer, new_target_x, new_target_y) + enemy_turn() + + update_ui() + +# ============================================================================= +# Enemy AI +# ============================================================================= + +def enemy_turn() -> None: + global player, grid, game_over + + if game_over: + return + + player_x, player_y = int(player.x), int(player.y) + + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data and entity_data[entity].is_alive: + enemies.append(entity) + + for enemy in enemies: + fighter = entity_data.get(enemy) + if fighter is None or not fighter.is_alive: + continue + + ex, ey = int(enemy.x), int(enemy.y) + + if not grid.is_in_fov(ex, ey): + continue + + dx = player_x - ex + dy = player_y - ey + + if abs(dx) <= 1 and abs(dy) <= 1 and (dx != 0 or dy != 0): + perform_attack(enemy, player) + else: + move_toward_player(enemy, ex, ey, player_x, player_y) + +def move_toward_player(enemy: mcrfpy.Entity, ex: int, ey: int, px: int, py: int) -> None: + global grid + + dx = 0 + dy = 0 + + if px < ex: + dx = -1 + elif px > ex: + dx = 1 + + if py < ey: + dy = -1 + elif py > ey: + dy = 1 + + new_x = ex + dx + new_y = ey + dy + + if can_move_to(grid, new_x, new_y, enemy): + enemy.x = new_x + enemy.y = new_y + elif dx != 0 and can_move_to(grid, ex + dx, ey, enemy): + enemy.x = ex + dx + elif dy != 0 and can_move_to(grid, ex, ey + dy, enemy): + enemy.y = ey + dy + +# ============================================================================= +# UI Updates +# ============================================================================= + +def update_ui() -> None: + global player, health_bar, xp_bar, inventory_panel, stats_panel, player_inventory + + if player in entity_data: + fighter = entity_data[player] + health_bar.update(fighter.hp, fighter.max_hp) + xp_bar.update(fighter.level, fighter.xp, fighter.xp_to_next_level) + stats_panel.update(fighter.attack, fighter.defense) + + if player_inventory: + inventory_panel.update(player_inventory) + +# ============================================================================= +# New Game Generation +# ============================================================================= + +def generate_new_game() -> None: + global player, player_inventory, grid, fov_layer, game_over + global entity_data, item_data, dungeon_level, game_mode + + game_over = False + game_mode = GameMode.NORMAL + dungeon_level = 1 + + entity_data.clear() + item_data.clear() + + while len(grid.entities) > 0: + grid.entities.remove(0) + + init_explored() + message_log.clear() + + player_start = generate_dungeon(grid, dungeon_level) + + player = mcrfpy.Entity( + grid_pos=(player_start[0], player_start[1]), + texture=texture, + sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + + entity_data[player] = Fighter( + hp=30, + max_hp=30, + attack=5, + defense=2, + name="Player", + is_player=True, + xp=0, + level=1 + ) + + player_inventory = Inventory(capacity=10) + + spawn_entities_for_level(grid, texture, dungeon_level) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + + update_fov(grid, fov_layer, player_start[0], player_start[1]) + + mode_display.update(game_mode) + level_display.update(dungeon_level) + update_ui() + +# ============================================================================= +# Game Setup +# ============================================================================= + +scene = mcrfpy.Scene("game") +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +grid = mcrfpy.Grid( + pos=(20, GAME_AREA_Y), + size=(700, GAME_AREA_HEIGHT - 20), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +fov_layer = grid.add_layer("color", z_index=-1) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +scene.children.append(grid) + +# ============================================================================= +# Create UI Elements +# ============================================================================= + +title = mcrfpy.Caption( + pos=(20, 10), + text="Part 12: Experience System" +) +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +instructions = mcrfpy.Caption( + pos=(300, 15), + text="WASD:Move | >:Descend | F:Ranged | G:Pickup" +) +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 14 +scene.children.append(instructions) + +health_bar = HealthBar(x=730, y=10, width=280, height=25) +health_bar.add_to_scene(scene) + +xp_bar = XPBar(x=730, y=40, width=280, height=20) +xp_bar.add_to_scene(scene) + +level_display = LevelDisplay(x=860, y=65) +level_display.add_to_scene(scene) + +mode_display = ModeDisplay(x=20, y=40) +mode_display.add_to_scene(scene) + +stats_panel = StatsPanel(x=730, y=GAME_AREA_Y, width=140, height=70) +stats_panel.add_to_scene(scene) + +inventory_panel = InventoryPanel(x=730, y=GAME_AREA_Y + 75, width=280, height=120) +inventory_panel.add_to_scene(scene) + +message_log = MessageLog( + x=20, + y=768 - UI_BOTTOM_HEIGHT + 10, + width=990, + height=UI_BOTTOM_HEIGHT - 20, + max_messages=6 +) +message_log.add_to_scene(scene) + +# ============================================================================= +# Initialize Game +# ============================================================================= + +init_explored() + +if has_save_file(): + message_log.add("Found saved game. Loading...", COLOR_INFO) + if not load_game(): + message_log.add("Failed to load. Starting new game.", COLOR_WARNING) + generate_new_game() + message_log.add("Welcome to the dungeon!", COLOR_INFO) +else: + generate_new_game() + message_log.add("Welcome to the dungeon!", COLOR_INFO) + message_log.add("Kill enemies to gain XP and level up!", COLOR_INFO) + +# ============================================================================= +# Input Handling +# ============================================================================= + +def handle_keys(key: str, action: str) -> None: + global game_over, game_mode + + if action != "start": + return + + if key == "R": + delete_save() + generate_new_game() + message_log.add("A new adventure begins!", COLOR_INFO) + return + + if key == "Escape": + if game_mode == GameMode.TARGETING: + exit_targeting_mode() + message_log.add("Targeting cancelled.", COLOR_INFO) + return + else: + if not game_over: + save_game() + mcrfpy.exit() + return + + if key == "Period" and game_mode == GameMode.NORMAL and not game_over: + # Check for shift to descend + descend_stairs() + return + + if game_over: + return + + if game_mode == GameMode.TARGETING: + handle_targeting_input(key) + else: + handle_normal_input(key) + +def handle_normal_input(key: str) -> None: + if key == "W" or key == "Up": + try_move_or_attack(0, -1) + elif key == "S" or key == "Down": + try_move_or_attack(0, 1) + elif key == "A" or key == "Left": + try_move_or_attack(-1, 0) + elif key == "D" or key == "Right": + try_move_or_attack(1, 0) + elif key == "F": + enter_targeting_mode() + elif key == "G" or key == ",": + pickup_item() + elif key in ["1", "2", "3", "4", "5"]: + index = int(key) - 1 + if use_item(index): + enemy_turn() + update_ui() + +def handle_targeting_input(key: str) -> None: + if key == "Up" or key == "W": + move_cursor(0, -1) + elif key == "Down" or key == "S": + move_cursor(0, 1) + elif key == "Left" or key == "A": + move_cursor(-1, 0) + elif key == "Right" or key == "D": + move_cursor(1, 0) + elif key == "Return" or key == "Space": + confirm_target() + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 12 loaded! Kill enemies to gain XP and level up!") \ No newline at end of file diff --git a/docs/tutorials/part_13_equipment/part_13_equipment.py b/docs/tutorials/part_13_equipment/part_13_equipment.py new file mode 100644 index 0000000..725f20b --- /dev/null +++ b/docs/tutorials/part_13_equipment/part_13_equipment.py @@ -0,0 +1,1798 @@ +"""McRogueFace - Part 13: Equipment System + +Documentation: https://mcrogueface.github.io/tutorial/part_13_equipment +Repository: https://github.com/jmccardle/McRogueFace/blob/master/docs/tutorials/part_13_equipment/part_13_equipment.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 +import json +import os +from dataclasses import dataclass, field +from typing import Optional +from enum import Enum + +# ============================================================================= +# Constants +# ============================================================================= + +# Sprite indices for CP437 tileset +SPRITE_WALL = 35 # '#' - wall +SPRITE_FLOOR = 46 # '.' - floor +SPRITE_PLAYER = 64 # '@' - player +SPRITE_CORPSE = 37 # '%' - remains +SPRITE_POTION = 173 # Potion sprite +SPRITE_CURSOR = 88 # 'X' - targeting cursor +SPRITE_STAIRS_DOWN = 62 # '>' - stairs down +SPRITE_SWORD = 47 # '/' - weapon +SPRITE_ARMOR = 91 # '[' - armor + +# Enemy sprites +SPRITE_GOBLIN = 103 # 'g' +SPRITE_ORC = 111 # 'o' +SPRITE_TROLL = 116 # 't' + +# Grid dimensions +GRID_WIDTH = 50 +GRID_HEIGHT = 30 + +# Room generation parameters +ROOM_MIN_SIZE = 6 +ROOM_MAX_SIZE = 12 +MAX_ROOMS = 8 + +# FOV and targeting settings +FOV_RADIUS = 8 +RANGED_ATTACK_RANGE = 6 +RANGED_ATTACK_DAMAGE = 4 + +# Save file location +SAVE_FILE = "savegame.json" + +# XP values for enemies +ENEMY_XP_VALUES = { + "goblin": 35, + "orc": 50, + "troll": 100 +} + +# Visibility colors +COLOR_VISIBLE = mcrfpy.Color(0, 0, 0, 0) +COLOR_DISCOVERED = mcrfpy.Color(0, 0, 40, 180) +COLOR_UNKNOWN = mcrfpy.Color(0, 0, 0, 255) + +# Message colors +COLOR_PLAYER_ATTACK = mcrfpy.Color(200, 200, 200) +COLOR_ENEMY_ATTACK = mcrfpy.Color(255, 150, 150) +COLOR_PLAYER_DEATH = mcrfpy.Color(255, 50, 50) +COLOR_ENEMY_DEATH = mcrfpy.Color(100, 255, 100) +COLOR_HEAL = mcrfpy.Color(100, 255, 100) +COLOR_PICKUP = mcrfpy.Color(100, 200, 255) +COLOR_INFO = mcrfpy.Color(100, 100, 255) +COLOR_WARNING = mcrfpy.Color(255, 200, 50) +COLOR_INVALID = mcrfpy.Color(255, 100, 100) +COLOR_RANGED = mcrfpy.Color(255, 255, 100) +COLOR_SAVE = mcrfpy.Color(100, 255, 200) +COLOR_DESCEND = mcrfpy.Color(200, 200, 255) +COLOR_LEVEL_UP = mcrfpy.Color(255, 255, 100) +COLOR_XP = mcrfpy.Color(200, 200, 100) +COLOR_EQUIP = mcrfpy.Color(150, 200, 255) + +# UI Layout constants +UI_TOP_HEIGHT = 60 +UI_BOTTOM_HEIGHT = 150 +GAME_AREA_Y = UI_TOP_HEIGHT +GAME_AREA_HEIGHT = 768 - UI_TOP_HEIGHT - UI_BOTTOM_HEIGHT + +# ============================================================================= +# Game Modes +# ============================================================================= + +class GameMode(Enum): + NORMAL = "normal" + TARGETING = "targeting" + +# ============================================================================= +# Equipment Component +# ============================================================================= + +@dataclass +class Equipment: + """An equippable item that provides stat bonuses.""" + name: str + slot: str # "weapon" or "armor" + attack_bonus: int = 0 + defense_bonus: int = 0 + + def to_dict(self) -> dict: + return { + "name": self.name, + "slot": self.slot, + "attack_bonus": self.attack_bonus, + "defense_bonus": self.defense_bonus + } + + @classmethod + def from_dict(cls, data: dict) -> "Equipment": + return cls( + name=data["name"], + slot=data["slot"], + attack_bonus=data.get("attack_bonus", 0), + defense_bonus=data.get("defense_bonus", 0) + ) + +# ============================================================================= +# Fighter Component with Equipment +# ============================================================================= + +@dataclass +class Fighter: + """Combat stats for an entity with experience and equipment.""" + hp: int + max_hp: int + base_attack: int + base_defense: int + name: str + is_player: bool = False + xp: int = 0 + level: int = 1 + weapon: Optional[Equipment] = None + armor: Optional[Equipment] = None + + @property + def attack(self) -> int: + """Total attack including equipment bonus.""" + bonus = self.weapon.attack_bonus if self.weapon else 0 + return self.base_attack + bonus + + @property + def defense(self) -> int: + """Total defense including equipment bonus.""" + bonus = self.armor.defense_bonus if self.armor else 0 + return self.base_defense + bonus + + @property + def is_alive(self) -> bool: + return self.hp > 0 + + @property + def xp_to_next_level(self) -> int: + return self.level * 100 + + @property + def xp_progress(self) -> float: + return self.xp / self.xp_to_next_level if self.xp_to_next_level > 0 else 0 + + def take_damage(self, amount: int) -> int: + actual_damage = min(self.hp, amount) + self.hp -= actual_damage + return actual_damage + + def heal(self, amount: int) -> int: + actual_heal = min(self.max_hp - self.hp, amount) + self.hp += actual_heal + return actual_heal + + def gain_xp(self, amount: int) -> bool: + self.xp += amount + if self.xp >= self.xp_to_next_level: + self.level_up() + return True + return False + + def level_up(self) -> None: + self.xp -= self.xp_to_next_level + self.level += 1 + hp_increase = 5 + attack_increase = 1 + defense_increase = 1 if self.level % 3 == 0 else 0 + self.max_hp += hp_increase + self.hp = self.max_hp + self.base_attack += attack_increase + self.base_defense += defense_increase + + def equip(self, equipment: Equipment) -> Optional[Equipment]: + """Equip an item, returning any previously equipped item. + + Args: + equipment: The equipment to equip + + Returns: + Previously equipped item in that slot, or None + """ + old_equipment = None + + if equipment.slot == "weapon": + old_equipment = self.weapon + self.weapon = equipment + elif equipment.slot == "armor": + old_equipment = self.armor + self.armor = equipment + + return old_equipment + + def unequip(self, slot: str) -> Optional[Equipment]: + """Unequip an item from a slot. + + Args: + slot: "weapon" or "armor" + + Returns: + The unequipped item, or None if slot was empty + """ + if slot == "weapon": + item = self.weapon + self.weapon = None + return item + elif slot == "armor": + item = self.armor + self.armor = None + return item + return None + + def to_dict(self) -> dict: + return { + "hp": self.hp, + "max_hp": self.max_hp, + "base_attack": self.base_attack, + "base_defense": self.base_defense, + "name": self.name, + "is_player": self.is_player, + "xp": self.xp, + "level": self.level, + "weapon": self.weapon.to_dict() if self.weapon else None, + "armor": self.armor.to_dict() if self.armor else None + } + + @classmethod + def from_dict(cls, data: dict) -> "Fighter": + fighter = cls( + hp=data["hp"], + max_hp=data["max_hp"], + base_attack=data.get("base_attack", data.get("attack", 5)), + base_defense=data.get("base_defense", data.get("defense", 2)), + name=data["name"], + is_player=data.get("is_player", False), + xp=data.get("xp", 0), + level=data.get("level", 1) + ) + if data.get("weapon"): + fighter.weapon = Equipment.from_dict(data["weapon"]) + if data.get("armor"): + fighter.armor = Equipment.from_dict(data["armor"]) + return fighter + +# ============================================================================= +# Item Component +# ============================================================================= + +@dataclass +class Item: + """Data for an item that can be picked up and used.""" + name: str + item_type: str + heal_amount: int = 0 + equipment: Optional[Equipment] = None + + def to_dict(self) -> dict: + result = { + "name": self.name, + "item_type": self.item_type, + "heal_amount": self.heal_amount + } + if self.equipment: + result["equipment"] = self.equipment.to_dict() + return result + + @classmethod + def from_dict(cls, data: dict) -> "Item": + item = cls( + name=data["name"], + item_type=data["item_type"], + heal_amount=data.get("heal_amount", 0) + ) + if data.get("equipment"): + item.equipment = Equipment.from_dict(data["equipment"]) + return item + +# ============================================================================= +# Inventory System +# ============================================================================= + +@dataclass +class Inventory: + """Container for items the player is carrying.""" + capacity: int = 10 + items: list = field(default_factory=list) + + def add(self, item: Item) -> bool: + if len(self.items) >= self.capacity: + return False + self.items.append(item) + return True + + def remove(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items.pop(index) + return None + + def get(self, index: int) -> Optional[Item]: + if 0 <= index < len(self.items): + return self.items[index] + return None + + def is_full(self) -> bool: + return len(self.items) >= self.capacity + + def count(self) -> int: + return len(self.items) + + def to_dict(self) -> dict: + return { + "capacity": self.capacity, + "items": [item.to_dict() for item in self.items] + } + + @classmethod + def from_dict(cls, data: dict) -> "Inventory": + inv = cls(capacity=data.get("capacity", 10)) + inv.items = [Item.from_dict(item_data) for item_data in data.get("items", [])] + return inv + +# ============================================================================= +# Templates +# ============================================================================= + +ITEM_TEMPLATES = { + "health_potion": { + "name": "Health Potion", + "sprite": SPRITE_POTION, + + "item_type": "health_potion", + "heal_amount": 10 + }, + "greater_health_potion": { + "name": "Greater Health Potion", + "sprite": SPRITE_POTION, + + "item_type": "health_potion", + "heal_amount": 20 + }, + # Weapons + "dagger": { + "name": "Dagger", + "sprite": SPRITE_SWORD, + + "item_type": "equipment", + "slot": "weapon", + "attack_bonus": 2, + "defense_bonus": 0 + }, + "sword": { + "name": "Sword", + "sprite": SPRITE_SWORD, + + "item_type": "equipment", + "slot": "weapon", + "attack_bonus": 4, + "defense_bonus": 0 + }, + "great_axe": { + "name": "Great Axe", + "sprite": SPRITE_SWORD, + + "item_type": "equipment", + "slot": "weapon", + "attack_bonus": 6, + "defense_bonus": -1 + }, + # Armor + "leather_armor": { + "name": "Leather Armor", + "sprite": SPRITE_ARMOR, + + "item_type": "equipment", + "slot": "armor", + "attack_bonus": 0, + "defense_bonus": 2 + }, + "chain_mail": { + "name": "Chain Mail", + "sprite": SPRITE_ARMOR, + + "item_type": "equipment", + "slot": "armor", + "attack_bonus": 0, + "defense_bonus": 4 + }, + "plate_armor": { + "name": "Plate Armor", + "sprite": SPRITE_ARMOR, + + "item_type": "equipment", + "slot": "armor", + "attack_bonus": -1, + "defense_bonus": 6 + } +} + +ENEMY_TEMPLATES = { + "goblin": { + "sprite": SPRITE_GOBLIN, + "hp": 6, + "attack": 3, + "defense": 0, + "color": mcrfpy.Color(100, 200, 100) + }, + "orc": { + "sprite": SPRITE_ORC, + "hp": 10, + "attack": 4, + "defense": 1, + "color": mcrfpy.Color(100, 150, 100) + }, + "troll": { + "sprite": SPRITE_TROLL, + "hp": 16, + "attack": 6, + "defense": 2, + "color": mcrfpy.Color(50, 150, 50) + } +} + +# ============================================================================= +# Difficulty Scaling with Equipment +# ============================================================================= + +def get_max_enemies_per_room(level: int) -> int: + return min(2 + level, 6) + +def get_max_items_per_room(level: int) -> int: + return min(1 + level // 2, 4) + +def get_enemy_weights(level: int) -> list[tuple[str, float]]: + if level <= 2: + return [("goblin", 0.8), ("orc", 0.95), ("troll", 1.0)] + elif level <= 4: + return [("goblin", 0.5), ("orc", 0.85), ("troll", 1.0)] + else: + return [("goblin", 0.3), ("orc", 0.6), ("troll", 1.0)] + +def get_item_weights(level: int) -> list[tuple[str, float]]: + """Get item spawn weights based on dungeon level.""" + if level <= 1: + return [ + ("health_potion", 0.7), + ("dagger", 0.85), + ("leather_armor", 1.0) + ] + elif level <= 3: + return [ + ("health_potion", 0.4), + ("greater_health_potion", 0.55), + ("dagger", 0.65), + ("sword", 0.75), + ("leather_armor", 0.85), + ("chain_mail", 1.0) + ] + else: + return [ + ("health_potion", 0.2), + ("greater_health_potion", 0.4), + ("sword", 0.55), + ("great_axe", 0.7), + ("chain_mail", 0.85), + ("plate_armor", 1.0) + ] + +# ============================================================================= +# Message Log System +# ============================================================================= + +class MessageLog: + def __init__(self, x: int, y: int, width: int, height: int, max_messages: int = 6): + self.x = x + self.y = y + self.width = width + self.height = height + self.max_messages = max_messages + self.messages: list[tuple[str, mcrfpy.Color]] = [] + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame(pos=(x, y), size=(width, height)) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + line_height = 20 + for i in range(max_messages): + caption = mcrfpy.Caption(pos=(x + 10, y + 5 + i * line_height), text="") + caption.font_size = 14 + caption.fill_color = mcrfpy.Color(200, 200, 200) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + for caption in self.captions: + scene.children.append(caption) + + def add(self, text: str, color: mcrfpy.Color = None) -> None: + if color is None: + color = mcrfpy.Color(200, 200, 200) + self.messages.append((text, color)) + while len(self.messages) > self.max_messages: + self.messages.pop(0) + self._refresh() + + def _refresh(self) -> None: + for i, caption in enumerate(self.captions): + if i < len(self.messages): + text, color = self.messages[i] + caption.text = text + caption.fill_color = color + else: + caption.text = "" + + def clear(self) -> None: + self.messages.clear() + self._refresh() + +# ============================================================================= +# Health Bar System +# ============================================================================= + +class HealthBar: + def __init__(self, x: int, y: int, width: int, height: int): + self.x, self.y = x, y + self.width, self.height = width, height + + self.bg_frame = mcrfpy.Frame(pos=(x, y), size=(width, height)) + self.bg_frame.fill_color = mcrfpy.Color(80, 0, 0) + self.bg_frame.outline = 2 + self.bg_frame.outline_color = mcrfpy.Color(150, 150, 150) + + self.fg_frame = mcrfpy.Frame(pos=(x + 2, y + 2), size=(width - 4, height - 4)) + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + self.fg_frame.outline = 0 + + self.label = mcrfpy.Caption(pos=(x + 5, y + 2), text="HP: 30/30") + self.label.font_size = 16 + self.label.fill_color = mcrfpy.Color(255, 255, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.bg_frame) + scene.children.append(self.fg_frame) + scene.children.append(self.label) + + def update(self, current_hp: int, max_hp: int) -> None: + percent = max(0, current_hp / max_hp) if max_hp > 0 else 0 + self.fg_frame.resize(int((self.width - 4) * percent), self.height - 4) + self.label.text = f"HP: {current_hp}/{max_hp}" + if percent > 0.6: + self.fg_frame.fill_color = mcrfpy.Color(0, 180, 0) + elif percent > 0.3: + self.fg_frame.fill_color = mcrfpy.Color(180, 180, 0) + else: + self.fg_frame.fill_color = mcrfpy.Color(180, 0, 0) + +# ============================================================================= +# XP Bar System +# ============================================================================= + +class XPBar: + def __init__(self, x: int, y: int, width: int, height: int): + self.x, self.y = x, y + self.width, self.height = width, height + + self.bg_frame = mcrfpy.Frame(pos=(x, y), size=(width, height)) + self.bg_frame.fill_color = mcrfpy.Color(40, 40, 80) + self.bg_frame.outline = 2 + self.bg_frame.outline_color = mcrfpy.Color(100, 100, 150) + + self.fg_frame = mcrfpy.Frame(pos=(x + 2, y + 2), size=(0, height - 4)) + self.fg_frame.fill_color = mcrfpy.Color(200, 200, 50) + self.fg_frame.outline = 0 + + self.label = mcrfpy.Caption(pos=(x + 5, y + 1), text="Level 1 | XP: 0/100") + self.label.font_size = 14 + self.label.fill_color = mcrfpy.Color(255, 255, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.bg_frame) + scene.children.append(self.fg_frame) + scene.children.append(self.label) + + def update(self, level: int, xp: int, xp_to_next: int) -> None: + percent = min(1.0, max(0.0, xp / xp_to_next if xp_to_next > 0 else 0)) + self.fg_frame.resize(int((self.width - 4) * percent), self.height - 4) + self.label.text = f"Level {level} | XP: {xp}/{xp_to_next}" + +# ============================================================================= +# Equipment Panel +# ============================================================================= + +class EquipmentPanel: + """Panel showing equipped items.""" + + def __init__(self, x: int, y: int, width: int, height: int): + self.x, self.y = x, y + self.width, self.height = width, height + + self.frame = mcrfpy.Frame(pos=(x, y), size=(width, height)) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + self.title = mcrfpy.Caption(pos=(x + 10, y + 5), text="Equipment") + self.title.font_size = 14 + self.title.fill_color = mcrfpy.Color(200, 200, 255) + + self.weapon_label = mcrfpy.Caption(pos=(x + 10, y + 25), text="Weapon: None") + self.weapon_label.font_size = 13 + self.weapon_label.fill_color = mcrfpy.Color(255, 150, 150) + + self.armor_label = mcrfpy.Caption(pos=(x + 10, y + 43), text="Armor: None") + self.armor_label.font_size = 13 + self.armor_label.fill_color = mcrfpy.Color(150, 150, 255) + + self.stats_label = mcrfpy.Caption(pos=(x + 10, y + 61), text="ATK: 5 | DEF: 2") + self.stats_label.font_size = 12 + self.stats_label.fill_color = mcrfpy.Color(200, 200, 200) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + scene.children.append(self.title) + scene.children.append(self.weapon_label) + scene.children.append(self.armor_label) + scene.children.append(self.stats_label) + + def update(self, fighter: "Fighter") -> None: + if fighter.weapon: + weapon_text = f"Weapon: {fighter.weapon.name} (+{fighter.weapon.attack_bonus} ATK)" + else: + weapon_text = "Weapon: None" + self.weapon_label.text = weapon_text + + if fighter.armor: + armor_text = f"Armor: {fighter.armor.name} (+{fighter.armor.defense_bonus} DEF)" + else: + armor_text = "Armor: None" + self.armor_label.text = armor_text + + self.stats_label.text = f"Total ATK: {fighter.attack} | DEF: {fighter.defense}" + +# ============================================================================= +# Inventory Panel +# ============================================================================= + +class InventoryPanel: + def __init__(self, x: int, y: int, width: int, height: int): + self.x, self.y = x, y + self.width, self.height = width, height + self.captions: list[mcrfpy.Caption] = [] + + self.frame = mcrfpy.Frame(pos=(x, y), size=(width, height)) + self.frame.fill_color = mcrfpy.Color(20, 20, 30, 200) + self.frame.outline = 2 + self.frame.outline_color = mcrfpy.Color(80, 80, 100) + + self.title = mcrfpy.Caption(pos=(x + 10, y + 5), text="Inventory (G:get E:equip)") + self.title.font_size = 14 + self.title.fill_color = mcrfpy.Color(200, 200, 255) + + for i in range(5): + caption = mcrfpy.Caption(pos=(x + 10, y + 25 + i * 18), text="") + caption.font_size = 13 + caption.fill_color = mcrfpy.Color(180, 180, 180) + self.captions.append(caption) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.frame) + scene.children.append(self.title) + for caption in self.captions: + scene.children.append(caption) + + def update(self, inventory: Inventory) -> None: + for i, caption in enumerate(self.captions): + if i < len(inventory.items): + item = inventory.items[i] + # Show item type indicator + if item.item_type == "equipment" and item.equipment: + if item.equipment.slot == "weapon": + caption.text = f"{i+1}. {item.name} [W]" + caption.fill_color = mcrfpy.Color(255, 150, 150) + else: + caption.text = f"{i+1}. {item.name} [A]" + caption.fill_color = mcrfpy.Color(150, 150, 255) + else: + caption.text = f"{i+1}. {item.name}" + caption.fill_color = mcrfpy.Color(180, 180, 180) + else: + caption.text = f"{i+1}. ---" + caption.fill_color = mcrfpy.Color(80, 80, 80) + +# ============================================================================= +# Level Display +# ============================================================================= + +class LevelDisplay: + def __init__(self, x: int, y: int): + self.caption = mcrfpy.Caption(pos=(x, y), text="Dungeon: 1") + self.caption.font_size = 16 + self.caption.fill_color = mcrfpy.Color(200, 200, 255) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.caption) + + def update(self, level: int) -> None: + self.caption.text = f"Dungeon: {level}" + +# ============================================================================= +# Mode Display +# ============================================================================= + +class ModeDisplay: + def __init__(self, x: int, y: int): + self.caption = mcrfpy.Caption(pos=(x, y), text="[NORMAL MODE]") + self.caption.font_size = 16 + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + + def add_to_scene(self, scene: mcrfpy.Scene) -> None: + scene.children.append(self.caption) + + def update(self, mode: GameMode) -> None: + if mode == GameMode.NORMAL: + self.caption.text = "[NORMAL] F:Ranged | >:Descend | E:Equip" + self.caption.fill_color = mcrfpy.Color(100, 255, 100) + elif mode == GameMode.TARGETING: + self.caption.text = "[TARGETING] Arrows:Move, Enter:Fire, Esc:Cancel" + self.caption.fill_color = mcrfpy.Color(255, 255, 100) + +# ============================================================================= +# Global State +# ============================================================================= + +entity_data: dict[mcrfpy.Entity, Fighter] = {} +item_data: dict[mcrfpy.Entity, Item] = {} + +player: Optional[mcrfpy.Entity] = None +player_inventory: Optional[Inventory] = None +grid: Optional[mcrfpy.Grid] = None +fov_layer = None +texture: Optional[mcrfpy.Texture] = None +game_over: bool = False +dungeon_level: int = 1 +stairs_position: tuple[int, int] = (0, 0) + +game_mode: GameMode = GameMode.NORMAL +target_cursor: Optional[mcrfpy.Entity] = None +target_x: int = 0 +target_y: int = 0 + +# UI components +message_log: Optional[MessageLog] = None +health_bar: Optional[HealthBar] = None +xp_bar: Optional[XPBar] = None +inventory_panel: Optional[InventoryPanel] = None +equipment_panel: Optional[EquipmentPanel] = None +mode_display: Optional[ModeDisplay] = None +level_display: Optional[LevelDisplay] = None + +# ============================================================================= +# Room Class +# ============================================================================= + +class RectangularRoom: + def __init__(self, x: int, y: int, width: int, height: int): + self.x1, self.y1 = x, y + self.x2, self.y2 = x + width, y + height + + @property + def center(self) -> tuple[int, int]: + return (self.x1 + self.x2) // 2, (self.y1 + self.y2) // 2 + + @property + def inner(self) -> tuple[slice, slice]: + return slice(self.x1 + 1, self.x2), slice(self.y1 + 1, self.y2) + + def intersects(self, other: "RectangularRoom") -> bool: + return self.x1 <= other.x2 and self.x2 >= other.x1 and self.y1 <= other.y2 and self.y2 >= other.y1 + +# ============================================================================= +# Exploration Tracking +# ============================================================================= + +explored: list[list[bool]] = [] + +def init_explored() -> None: + global explored + explored = [[False for _ in range(GRID_WIDTH)] for _ in range(GRID_HEIGHT)] + +def mark_explored(x: int, y: int) -> None: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + explored[y][x] = True + +def is_explored(x: int, y: int) -> bool: + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + return explored[y][x] + return False + +# ============================================================================= +# Dungeon Generation (abbreviated for space) +# ============================================================================= + +def fill_with_walls(target_grid: mcrfpy.Grid) -> None: + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_WALL + cell.walkable = False + cell.transparent = False + +def carve_room(target_grid: mcrfpy.Grid, room: RectangularRoom) -> None: + inner_x, inner_y = room.inner + for y in range(inner_y.start, inner_y.stop): + for x in range(inner_x.start, inner_x.stop): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_horizontal(target_grid: mcrfpy.Grid, x1: int, x2: int, y: int) -> None: + for x in range(min(x1, x2), max(x1, x2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_tunnel_vertical(target_grid: mcrfpy.Grid, y1: int, y2: int, x: int) -> None: + for y in range(min(y1, y2), max(y1, y2) + 1): + if 0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT: + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_FLOOR + cell.walkable = True + cell.transparent = True + +def carve_l_tunnel(target_grid: mcrfpy.Grid, start: tuple[int, int], end: tuple[int, int]) -> None: + x1, y1 = start + x2, y2 = end + if random.random() < 0.5: + carve_tunnel_horizontal(target_grid, x1, x2, y1) + carve_tunnel_vertical(target_grid, y1, y2, x2) + else: + carve_tunnel_vertical(target_grid, y1, y2, x1) + carve_tunnel_horizontal(target_grid, x1, x2, y2) + +def place_stairs(target_grid: mcrfpy.Grid, x: int, y: int) -> None: + global stairs_position + cell = target_grid.at(x, y) + cell.tilesprite = SPRITE_STAIRS_DOWN + cell.walkable = True + cell.transparent = True + stairs_position = (x, y) + +def generate_dungeon(target_grid: mcrfpy.Grid, level: int) -> tuple[int, int]: + fill_with_walls(target_grid) + rooms: list[RectangularRoom] = [] + + for _ in range(MAX_ROOMS): + room_width = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + room_height = random.randint(ROOM_MIN_SIZE, ROOM_MAX_SIZE) + x = random.randint(1, GRID_WIDTH - room_width - 2) + y = random.randint(1, GRID_HEIGHT - room_height - 2) + + new_room = RectangularRoom(x, y, room_width, room_height) + + if any(new_room.intersects(other) for other in rooms): + continue + + carve_room(target_grid, new_room) + if rooms: + carve_l_tunnel(target_grid, new_room.center, rooms[-1].center) + rooms.append(new_room) + + if rooms: + place_stairs(target_grid, *rooms[-1].center) + return rooms[0].center + return GRID_WIDTH // 2, GRID_HEIGHT // 2 + +# ============================================================================= +# Entity Management +# ============================================================================= + +def spawn_enemy(target_grid: mcrfpy.Grid, x: int, y: int, enemy_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ENEMY_TEMPLATES[enemy_type] + enemy = mcrfpy.Entity(grid_pos=(x, y), texture=tex, sprite_index=template["sprite"]) + enemy.visible = False + target_grid.entities.append(enemy) + entity_data[enemy] = Fighter( + hp=template["hp"], max_hp=template["hp"], + base_attack=template["attack"], base_defense=template["defense"], + name=enemy_type.capitalize(), is_player=False + ) + return enemy + +def spawn_item(target_grid: mcrfpy.Grid, x: int, y: int, item_type: str, tex: mcrfpy.Texture) -> mcrfpy.Entity: + template = ITEM_TEMPLATES[item_type] + item_entity = mcrfpy.Entity(grid_pos=(x, y), texture=tex, sprite_index=template["sprite"]) + item_entity.visible = False + target_grid.entities.append(item_entity) + + # Create the item + item = Item( + name=template["name"], + item_type=template["item_type"], + heal_amount=template.get("heal_amount", 0) + ) + + # If it is equipment, create the equipment data + if template["item_type"] == "equipment": + item.equipment = Equipment( + name=template["name"], + slot=template["slot"], + attack_bonus=template.get("attack_bonus", 0), + defense_bonus=template.get("defense_bonus", 0) + ) + + item_data[item_entity] = item + return item_entity + +def spawn_entities_for_level(target_grid: mcrfpy.Grid, tex: mcrfpy.Texture, level: int) -> None: + floor_tiles = [] + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = target_grid.at(x, y) + if cell.walkable and cell.tilesprite == SPRITE_FLOOR: + floor_tiles.append((x, y)) + + # Spawn enemies + max_enemies = get_max_enemies_per_room(level) * 3 + enemy_weights = get_enemy_weights(level) + + for _ in range(max_enemies): + if not floor_tiles: + break + x, y = random.choice(floor_tiles) + if (x, y) == (int(player.x), int(player.y)) or (x, y) == stairs_position: + continue + if is_position_occupied(target_grid, x, y): + continue + + roll = random.random() + enemy_type = "goblin" + for etype, threshold in enemy_weights: + if roll < threshold: + enemy_type = etype + break + spawn_enemy(target_grid, x, y, enemy_type, tex) + + # Spawn items + max_items = get_max_items_per_room(level) * 2 + item_weights = get_item_weights(level) + + for _ in range(max_items): + if not floor_tiles: + break + x, y = random.choice(floor_tiles) + if (x, y) == (int(player.x), int(player.y)) or (x, y) == stairs_position: + continue + if is_position_occupied(target_grid, x, y): + continue + + roll = random.random() + item_type = "health_potion" + for itype, threshold in item_weights: + if roll < threshold: + item_type = itype + break + spawn_item(target_grid, x, y, item_type, tex) + +def is_position_occupied(target_grid: mcrfpy.Grid, x: int, y: int) -> bool: + for entity in target_grid.entities: + if int(entity.x) == x and int(entity.y) == y: + return True + return False + +def get_item_at(target_grid: mcrfpy.Grid, x: int, y: int) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity in item_data and int(entity.x) == x and int(entity.y) == y: + return entity + return None + +def get_blocking_entity_at(target_grid: mcrfpy.Grid, x: int, y: int, exclude: mcrfpy.Entity = None) -> Optional[mcrfpy.Entity]: + for entity in target_grid.entities: + if entity == exclude: + continue + if int(entity.x) == x and int(entity.y) == y: + if entity in entity_data and entity_data[entity].is_alive: + return entity + return None + +def remove_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in entity_data: + del entity_data[entity] + +def remove_item_entity(target_grid: mcrfpy.Grid, entity: mcrfpy.Entity) -> None: + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + if entity in item_data: + del item_data[entity] + +def clear_entities_except_player(target_grid: mcrfpy.Grid) -> None: + entities_to_remove = [] + for entity in target_grid.entities: + if entity in entity_data and entity_data[entity].is_player: + continue + entities_to_remove.append(entity) + + for entity in entities_to_remove: + if entity in entity_data: + del entity_data[entity] + if entity in item_data: + del item_data[entity] + for i, e in enumerate(target_grid.entities): + if e == entity: + target_grid.entities.remove(i) + break + +# ============================================================================= +# Equipment Actions +# ============================================================================= + +def equip_item(index: int) -> bool: + """Equip an item from inventory. + + Args: + index: Inventory index of item to equip + + Returns: + True if item was equipped, False otherwise + """ + global player, player_inventory + + item = player_inventory.get(index) + if item is None: + message_log.add("Invalid item selection.", COLOR_INVALID) + return False + + if item.item_type != "equipment" or item.equipment is None: + message_log.add(f"The {item.name} cannot be equipped.", COLOR_INVALID) + return False + + fighter = entity_data.get(player) + if fighter is None: + return False + + # Remove from inventory + player_inventory.remove(index) + + # Equip and get old equipment + old_equipment = fighter.equip(item.equipment) + + message_log.add(f"You equip the {item.name}.", COLOR_EQUIP) + + # Add old equipment back to inventory + if old_equipment: + old_item = Item( + name=old_equipment.name, + item_type="equipment", + equipment=old_equipment + ) + if player_inventory.add(old_item): + message_log.add(f"You unequip the {old_equipment.name}.", COLOR_INFO) + else: + # Inventory full - drop on ground + drop_equipment(old_equipment) + message_log.add(f"Inventory full! {old_equipment.name} dropped.", COLOR_WARNING) + + update_ui() + return True + +def drop_equipment(equipment: Equipment) -> None: + """Drop equipment on the ground at player position.""" + global player, grid, texture + + px, py = int(player.x), int(player.y) + + # Find template for this equipment + template = None + for key, tmpl in ITEM_TEMPLATES.items(): + if tmpl["name"] == equipment.name: + template = tmpl + break + + if template is None: + # Use default appearance + template = { + "sprite": SPRITE_SWORD if equipment.slot == "weapon" else SPRITE_ARMOR, + "color": mcrfpy.Color(200, 200, 200) + } + + item_entity = mcrfpy.Entity( + grid_pos=(px, py), + texture=texture, + sprite_index=template["sprite"] + ) + item_entity.visible = True + + grid.entities.append(item_entity) + + item_data[item_entity] = Item( + name=equipment.name, + item_type="equipment", + equipment=equipment + ) + +# ============================================================================= +# XP and Level Up +# ============================================================================= + +def award_xp(enemy_name: str) -> None: + global player + fighter = entity_data.get(player) + if fighter is None: + return + + xp_amount = ENEMY_XP_VALUES.get(enemy_name.lower(), 35) + leveled_up = fighter.gain_xp(xp_amount) + + if leveled_up: + message_log.add( + f"You gained {xp_amount} XP and reached level {fighter.level}!", + COLOR_LEVEL_UP + ) + message_log.add( + f"HP +5, Attack +1{', Defense +1' if fighter.level % 3 == 0 else ''}!", + COLOR_LEVEL_UP + ) + else: + message_log.add(f"You gain {xp_amount} XP.", COLOR_XP) + update_ui() + +# ============================================================================= +# Level Transition +# ============================================================================= + +def descend_stairs() -> bool: + global player, dungeon_level, grid, fov_layer, stairs_position + + px, py = int(player.x), int(player.y) + if (px, py) != stairs_position: + message_log.add("There are no stairs here.", COLOR_INVALID) + return False + + dungeon_level += 1 + clear_entities_except_player(grid) + init_explored() + player_start = generate_dungeon(grid, dungeon_level) + player.x, player.y = player_start + spawn_entities_for_level(grid, texture, dungeon_level) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + update_fov(grid, fov_layer, player_start[0], player_start[1]) + + message_log.add(f"You descend to level {dungeon_level}...", COLOR_DESCEND) + level_display.update(dungeon_level) + update_ui() + return True + +# ============================================================================= +# Save/Load System +# ============================================================================= + +def save_game() -> bool: + global player, player_inventory, grid, explored, dungeon_level, stairs_position + + try: + tiles = [] + for y in range(GRID_HEIGHT): + row = [] + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + row.append({ + "tilesprite": cell.tilesprite, + "walkable": cell.walkable, + "transparent": cell.transparent + }) + tiles.append(row) + + enemies = [] + for entity in grid.entities: + if entity == player: + continue + if entity in entity_data: + enemies.append({ + "x": int(entity.x), "y": int(entity.y), + "type": entity_data[entity].name.lower(), + "fighter": entity_data[entity].to_dict() + }) + + items_on_ground = [] + for entity in grid.entities: + if entity in item_data: + items_on_ground.append({ + "x": int(entity.x), "y": int(entity.y), + "item": item_data[entity].to_dict() + }) + + save_data = { + "version": 4, + "dungeon_level": dungeon_level, + "stairs_position": list(stairs_position), + "player": { + "x": int(player.x), "y": int(player.y), + "fighter": entity_data[player].to_dict(), + "inventory": player_inventory.to_dict() + }, + "tiles": tiles, + "explored": [[explored[y][x] for x in range(GRID_WIDTH)] for y in range(GRID_HEIGHT)], + "enemies": enemies, + "items": items_on_ground + } + + with open(SAVE_FILE, "w") as f: + json.dump(save_data, f, indent=2) + + message_log.add("Game saved!", COLOR_SAVE) + return True + except Exception as e: + message_log.add(f"Save failed: {e}", COLOR_INVALID) + return False + +def load_game() -> bool: + global player, player_inventory, grid, explored, dungeon_level + global entity_data, item_data, fov_layer, game_over, stairs_position + + if not os.path.exists(SAVE_FILE): + return False + + try: + with open(SAVE_FILE, "r") as f: + save_data = json.load(f) + + entity_data.clear() + item_data.clear() + while len(grid.entities) > 0: + grid.entities.remove(0) + + dungeon_level = save_data.get("dungeon_level", 1) + stairs_position = tuple(save_data.get("stairs_position", [0, 0])) + + tiles = save_data["tiles"] + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + cell = grid.at(x, y) + tile_data = tiles[y][x] + cell.tilesprite = tile_data["tilesprite"] + cell.walkable = tile_data["walkable"] + cell.transparent = tile_data["transparent"] + + global explored + explored = save_data["explored"] + + player_data = save_data["player"] + player = mcrfpy.Entity( + grid_pos=(player_data["x"], player_data["y"]), + texture=texture, sprite_index=SPRITE_PLAYER + ) + grid.entities.append(player) + entity_data[player] = Fighter.from_dict(player_data["fighter"]) + player_inventory = Inventory.from_dict(player_data["inventory"]) + + for enemy_data in save_data.get("enemies", []): + template = ENEMY_TEMPLATES.get(enemy_data["type"], ENEMY_TEMPLATES["goblin"]) + enemy = mcrfpy.Entity( + grid_pos=(enemy_data["x"], enemy_data["y"]), + texture=texture, sprite_index=template["sprite"] + ) + enemy.visible = False + grid.entities.append(enemy) + entity_data[enemy] = Fighter.from_dict(enemy_data["fighter"]) + + for item_entry in save_data.get("items", []): + item = Item.from_dict(item_entry["item"]) + # Find template for sprite + template = None + for key, tmpl in ITEM_TEMPLATES.items(): + if tmpl["name"] == item.name: + template = tmpl + break + if template is None: + template = ITEM_TEMPLATES["health_potion"] + + item_entity = mcrfpy.Entity( + grid_pos=(item_entry["x"], item_entry["y"]), + texture=texture, sprite_index=template["sprite"] + ) + item_entity.visible = False + grid.entities.append(item_entity) + item_data[item_entity] = item + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + update_fov(grid, fov_layer, int(player.x), int(player.y)) + + game_over = False + message_log.add("Game loaded!", COLOR_SAVE) + level_display.update(dungeon_level) + update_ui() + return True + except Exception as e: + message_log.add(f"Load failed: {e}", COLOR_INVALID) + return False + +def delete_save() -> bool: + try: + if os.path.exists(SAVE_FILE): + os.remove(SAVE_FILE) + return True + except: + return False + +def has_save_file() -> bool: + return os.path.exists(SAVE_FILE) + +# ============================================================================= +# Targeting System +# ============================================================================= + +def enter_targeting_mode() -> None: + global game_mode, target_cursor, target_x, target_y + target_x, target_y = int(player.x), int(player.y) + target_cursor = mcrfpy.Entity(grid_pos=(target_x, target_y), texture=texture, sprite_index=SPRITE_CURSOR) + grid.entities.append(target_cursor) + game_mode = GameMode.TARGETING + message_log.add("Targeting: Arrows to aim, Enter to fire, Esc to cancel.", COLOR_INFO) + mode_display.update(game_mode) + +def exit_targeting_mode() -> None: + global game_mode, target_cursor + if target_cursor: + for i, e in enumerate(grid.entities): + if e == target_cursor: + grid.entities.remove(i) + break + target_cursor = None + game_mode = GameMode.NORMAL + mode_display.update(game_mode) + +def move_cursor(dx: int, dy: int) -> None: + global target_x, target_y + new_x, new_y = target_x + dx, target_y + dy + if not (0 <= new_x < GRID_WIDTH and 0 <= new_y < GRID_HEIGHT): + return + if not grid.is_in_fov(new_x, new_y): + message_log.add("Cannot see that location.", COLOR_INVALID) + return + distance = abs(new_x - int(player.x)) + abs(new_y - int(player.y)) + if distance > RANGED_ATTACK_RANGE: + message_log.add(f"Out of range (max {RANGED_ATTACK_RANGE}).", COLOR_WARNING) + return + target_x, target_y = new_x, new_y + target_cursor.x, target_cursor.y = target_x, target_y + enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + if enemy and enemy in entity_data: + f = entity_data[enemy] + message_log.add(f"Target: {f.name} (HP: {f.hp}/{f.max_hp})", COLOR_RANGED) + +def confirm_target() -> None: + if target_x == int(player.x) and target_y == int(player.y): + message_log.add("Cannot target yourself!", COLOR_INVALID) + return + target_enemy = get_blocking_entity_at(grid, target_x, target_y, exclude=player) + if not target_enemy or target_enemy not in entity_data: + message_log.add("No valid target.", COLOR_INVALID) + return + perform_ranged_attack(target_enemy) + exit_targeting_mode() + enemy_turn() + update_ui() + +def perform_ranged_attack(target_entity: mcrfpy.Entity) -> None: + defender = entity_data.get(target_entity) + attacker = entity_data.get(player) + if not defender or not attacker: + return + damage = max(1, RANGED_ATTACK_DAMAGE - defender.defense // 2) + defender.take_damage(damage) + message_log.add(f"Ranged attack hits {defender.name} for {damage}!", COLOR_RANGED) + if not defender.is_alive: + handle_death(target_entity, defender) + +# ============================================================================= +# Combat System +# ============================================================================= + +def calculate_damage(attacker: Fighter, defender: Fighter) -> int: + return max(0, attacker.attack - defender.defense) + +def perform_attack(attacker_entity: mcrfpy.Entity, defender_entity: mcrfpy.Entity) -> None: + attacker = entity_data.get(attacker_entity) + defender = entity_data.get(defender_entity) + if not attacker or not defender: + return + + damage = calculate_damage(attacker, defender) + defender.take_damage(damage) + + if damage > 0: + if attacker.is_player: + message_log.add(f"You hit {defender.name} for {damage}!", COLOR_PLAYER_ATTACK) + else: + message_log.add(f"{attacker.name} hits you for {damage}!", COLOR_ENEMY_ATTACK) + else: + if attacker.is_player: + message_log.add(f"You hit {defender.name} but deal no damage.", mcrfpy.Color(150, 150, 150)) + else: + message_log.add(f"{attacker.name} hits but deals no damage.", mcrfpy.Color(150, 150, 200)) + + if not defender.is_alive: + handle_death(defender_entity, defender) + update_ui() + +def handle_death(entity: mcrfpy.Entity, fighter: Fighter) -> None: + global game_over + if fighter.is_player: + message_log.add("You have died!", COLOR_PLAYER_DEATH) + message_log.add("Press R to restart.", COLOR_INFO) + game_over = True + entity.sprite_index = SPRITE_CORPSE + delete_save() + else: + message_log.add(f"{fighter.name} dies!", COLOR_ENEMY_DEATH) + award_xp(fighter.name) + remove_entity(grid, entity) + +# ============================================================================= +# Item Actions +# ============================================================================= + +def pickup_item() -> bool: + px, py = int(player.x), int(player.y) + item_entity = get_item_at(grid, px, py) + if not item_entity: + message_log.add("Nothing to pick up.", COLOR_INVALID) + return False + if player_inventory.is_full(): + message_log.add("Inventory full!", COLOR_WARNING) + return False + item = item_data.get(item_entity) + if not item: + return False + player_inventory.add(item) + remove_item_entity(grid, item_entity) + message_log.add(f"Picked up {item.name}.", COLOR_PICKUP) + update_ui() + return True + +def use_item(index: int) -> bool: + item = player_inventory.get(index) + if not item: + message_log.add("Invalid selection.", COLOR_INVALID) + return False + + # Handle equipment + if item.item_type == "equipment": + return equip_item(index) + + # Handle consumables + if item.item_type == "health_potion": + fighter = entity_data.get(player) + if not fighter: + return False + if fighter.hp >= fighter.max_hp: + message_log.add("Already at full health!", COLOR_WARNING) + return False + actual_heal = fighter.heal(item.heal_amount) + player_inventory.remove(index) + message_log.add(f"Healed {actual_heal} HP!", COLOR_HEAL) + update_ui() + return True + + message_log.add(f"Cannot use {item.name}.", COLOR_INVALID) + return False + +# ============================================================================= +# Field of View +# ============================================================================= + +def update_entity_visibility(target_grid: mcrfpy.Grid) -> None: + for entity in target_grid.entities: + if entity == player or entity == target_cursor: + entity.visible = True + else: + entity.visible = target_grid.is_in_fov(int(entity.x), int(entity.y)) + +def update_fov(target_grid: mcrfpy.Grid, target_fov_layer, player_x: int, player_y: int) -> None: + target_grid.compute_fov(player_x, player_y, FOV_RADIUS, mcrfpy.FOV.SHADOW) + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + if target_grid.is_in_fov(x, y): + mark_explored(x, y) + target_fov_layer.set(x, y, COLOR_VISIBLE) + elif is_explored(x, y): + target_fov_layer.set(x, y, COLOR_DISCOVERED) + else: + target_fov_layer.set(x, y, COLOR_UNKNOWN) + update_entity_visibility(target_grid) + +# ============================================================================= +# Movement +# ============================================================================= + +def can_move_to(target_grid: mcrfpy.Grid, x: int, y: int, mover: mcrfpy.Entity = None) -> bool: + if not (0 <= x < GRID_WIDTH and 0 <= y < GRID_HEIGHT): + return False + if not target_grid.at(x, y).walkable: + return False + return get_blocking_entity_at(target_grid, x, y, exclude=mover) is None + +def try_move_or_attack(dx: int, dy: int) -> None: + global game_over + if game_over: + return + px, py = int(player.x), int(player.y) + new_x, new_y = px + dx, py + dy + if not (0 <= new_x < GRID_WIDTH and 0 <= new_y < GRID_HEIGHT): + return + + blocker = get_blocking_entity_at(grid, new_x, new_y, exclude=player) + if blocker: + perform_attack(player, blocker) + enemy_turn() + elif grid.at(new_x, new_y).walkable: + player.x, player.y = new_x, new_y + update_fov(grid, fov_layer, new_x, new_y) + enemy_turn() + update_ui() + +# ============================================================================= +# Enemy AI +# ============================================================================= + +def enemy_turn() -> None: + global game_over + if game_over: + return + px, py = int(player.x), int(player.y) + + for entity in list(grid.entities): + if entity == player or entity not in entity_data: + continue + fighter = entity_data[entity] + if not fighter.is_alive: + continue + ex, ey = int(entity.x), int(entity.y) + if not grid.is_in_fov(ex, ey): + continue + + dx, dy = px - ex, py - ey + if abs(dx) <= 1 and abs(dy) <= 1 and (dx or dy): + perform_attack(entity, player) + else: + move_toward_player(entity, ex, ey, px, py) + +def move_toward_player(enemy: mcrfpy.Entity, ex: int, ey: int, px: int, py: int) -> None: + dx = 1 if px > ex else (-1 if px < ex else 0) + dy = 1 if py > ey else (-1 if py < ey else 0) + + if can_move_to(grid, ex + dx, ey + dy, enemy): + enemy.x, enemy.y = ex + dx, ey + dy + elif dx and can_move_to(grid, ex + dx, ey, enemy): + enemy.x = ex + dx + elif dy and can_move_to(grid, ex, ey + dy, enemy): + enemy.y = ey + dy + +# ============================================================================= +# UI Updates +# ============================================================================= + +def update_ui() -> None: + if player in entity_data: + fighter = entity_data[player] + health_bar.update(fighter.hp, fighter.max_hp) + xp_bar.update(fighter.level, fighter.xp, fighter.xp_to_next_level) + equipment_panel.update(fighter) + if player_inventory: + inventory_panel.update(player_inventory) + +# ============================================================================= +# New Game +# ============================================================================= + +def generate_new_game() -> None: + global player, player_inventory, game_over, dungeon_level, game_mode + + game_over = False + game_mode = GameMode.NORMAL + dungeon_level = 1 + + entity_data.clear() + item_data.clear() + while len(grid.entities) > 0: + grid.entities.remove(0) + + init_explored() + message_log.clear() + + player_start = generate_dungeon(grid, dungeon_level) + + player = mcrfpy.Entity(grid_pos=player_start, texture=texture, sprite_index=SPRITE_PLAYER) + grid.entities.append(player) + + entity_data[player] = Fighter( + hp=30, max_hp=30, base_attack=5, base_defense=2, + name="Player", is_player=True, xp=0, level=1 + ) + + player_inventory = Inventory(capacity=10) + spawn_entities_for_level(grid, texture, dungeon_level) + + for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + update_fov(grid, fov_layer, player_start[0], player_start[1]) + + mode_display.update(game_mode) + level_display.update(dungeon_level) + update_ui() + +# ============================================================================= +# Game Setup +# ============================================================================= + +scene = mcrfpy.Scene("game") +texture = mcrfpy.Texture("assets/kenney_tinydungeon.png", 16, 16) + +grid = mcrfpy.Grid( + pos=(20, GAME_AREA_Y), + size=(700, GAME_AREA_HEIGHT - 20), + grid_size=(GRID_WIDTH, GRID_HEIGHT), + texture=texture +) +grid.zoom = 1.0 + +fov_layer = grid.add_layer("color", z_index=-1) +for y in range(GRID_HEIGHT): + for x in range(GRID_WIDTH): + fov_layer.set(x, y, COLOR_UNKNOWN) + +scene.children.append(grid) + +# UI Elements +title = mcrfpy.Caption(pos=(20, 10), text="Part 13: Equipment System") +title.fill_color = mcrfpy.Color(255, 255, 255) +title.font_size = 24 +scene.children.append(title) + +instructions = mcrfpy.Caption(pos=(300, 15), text="WASD:Move | E:Equip | G:Get | >:Descend") +instructions.fill_color = mcrfpy.Color(180, 180, 180) +instructions.font_size = 14 +scene.children.append(instructions) + +health_bar = HealthBar(x=730, y=10, width=280, height=25) +health_bar.add_to_scene(scene) + +xp_bar = XPBar(x=730, y=40, width=280, height=20) +xp_bar.add_to_scene(scene) + +level_display = LevelDisplay(x=860, y=65) +level_display.add_to_scene(scene) + +mode_display = ModeDisplay(x=20, y=40) +mode_display.add_to_scene(scene) + +equipment_panel = EquipmentPanel(x=730, y=GAME_AREA_Y, width=280, height=85) +equipment_panel.add_to_scene(scene) + +inventory_panel = InventoryPanel(x=730, y=GAME_AREA_Y + 90, width=280, height=120) +inventory_panel.add_to_scene(scene) + +message_log = MessageLog(x=20, y=768 - UI_BOTTOM_HEIGHT + 10, width=990, height=UI_BOTTOM_HEIGHT - 20, max_messages=6) +message_log.add_to_scene(scene) + +# Initialize +init_explored() + +if has_save_file(): + message_log.add("Loading saved game...", COLOR_INFO) + if not load_game(): + generate_new_game() + message_log.add("Welcome to the dungeon!", COLOR_INFO) +else: + generate_new_game() + message_log.add("Welcome to the dungeon!", COLOR_INFO) + message_log.add("Find equipment to grow stronger!", COLOR_INFO) + +# ============================================================================= +# Input Handling +# ============================================================================= + +def handle_keys(key: str, action: str) -> None: + global game_over, game_mode + + if action != "start": + return + + if key == "R": + delete_save() + generate_new_game() + message_log.add("New adventure begins!", COLOR_INFO) + return + + if key == "Escape": + if game_mode == GameMode.TARGETING: + exit_targeting_mode() + message_log.add("Targeting cancelled.", COLOR_INFO) + else: + if not game_over: + save_game() + mcrfpy.exit() + return + + if game_over: + return + + if game_mode == GameMode.TARGETING: + handle_targeting_input(key) + else: + handle_normal_input(key) + +def handle_normal_input(key: str) -> None: + if key in ("W", "Up"): + try_move_or_attack(0, -1) + elif key in ("S", "Down"): + try_move_or_attack(0, 1) + elif key in ("A", "Left"): + try_move_or_attack(-1, 0) + elif key in ("D", "Right"): + try_move_or_attack(1, 0) + elif key == "F": + enter_targeting_mode() + elif key in ("G", ","): + pickup_item() + elif key == "Period": + descend_stairs() + elif key == "E": + message_log.add("Press 1-5 to equip an item from inventory.", COLOR_INFO) + elif key in "12345": + index = int(key) - 1 + if use_item(index): + enemy_turn() + update_ui() + +def handle_targeting_input(key: str) -> None: + if key in ("Up", "W"): + move_cursor(0, -1) + elif key in ("Down", "S"): + move_cursor(0, 1) + elif key in ("Left", "A"): + move_cursor(-1, 0) + elif key in ("Right", "D"): + move_cursor(1, 0) + elif key in ("Return", "Space"): + confirm_target() + +scene.on_key = handle_keys + +# ============================================================================= +# Start the Game +# ============================================================================= + +scene.activate() +print("Part 13: Equipment System - Tutorial Complete!") +print("Find weapons and armor to become stronger!") \ No newline at end of file From f62362032e3e6097ad5ce56b2862ba68a28f2d6a Mon Sep 17 00:00:00 2001 From: John McCardle Date: Wed, 31 Dec 2025 16:30:51 -0500 Subject: [PATCH 5/5] feat: Grid camera defaults to tile (0,0) at top-left + center_camera() method (#169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Default Grid center now positions tile (0,0) at widget's top-left corner - Added center_camera() method to center grid's middle tile at view center - Added center_camera((tile_x, tile_y)) to position tile at top-left of widget - Uses NaN as sentinel to detect if user provided center values in kwargs - Animation-compatible: center_camera() just sets center property, no special state Behavior: - center_camera() → grid's center tile at view center - center_camera((0, 0)) → tile (0,0) at top-left corner - center_camera((5, 10)) → tile (5,10) at top-left corner Before: Grid(size=(320,240)) showed 3/4 of content off-screen (center=0,0) After: Grid(size=(320,240)) shows tile (0,0) at top-left (center=160,120) Closes #169 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/UIGrid.cpp | 103 +++++++++++++++++++++++++++++++++++++++++++++++-- src/UIGrid.h | 6 +++ 2 files changed, 106 insertions(+), 3 deletions(-) diff --git a/src/UIGrid.cpp b/src/UIGrid.cpp index e56ed5d..7b59b12 100644 --- a/src/UIGrid.cpp +++ b/src/UIGrid.cpp @@ -6,8 +6,9 @@ #include "Profiler.h" #include "PyFOV.h" #include -#include // #142 - for std::floor +#include // #142 - for std::floor, std::isnan #include // #150 - for strcmp +#include // #169 - for std::numeric_limits // UIDrawable methods now in UIBase.h UIGrid::UIGrid() @@ -735,7 +736,9 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) { PyObject* fill_color = nullptr; PyObject* click_handler = nullptr; PyObject* layers_obj = nullptr; // #150 - layers dict - float center_x = 0.0f, center_y = 0.0f; + // #169 - Use NaN as sentinel to detect if user provided center values + float center_x = std::numeric_limits::quiet_NaN(); + float center_y = std::numeric_limits::quiet_NaN(); float zoom = 1.0f; // perspective is now handled via properties, not init args int visible = 1; @@ -862,9 +865,19 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) { sf::Vector2f(x, y), sf::Vector2f(w, h)); // Set additional properties + self->data->zoom = zoom; // Set zoom first, needed for default center calculation + + // #169 - Calculate default center if not provided by user + // Default: tile (0,0) at top-left of widget + if (std::isnan(center_x)) { + // Center = half widget size (in pixels), so tile 0,0 appears at top-left + center_x = w / (2.0f * zoom); + } + if (std::isnan(center_y)) { + center_y = h / (2.0f * zoom); + } self->data->center_x = center_x; self->data->center_y = center_y; - self->data->zoom = zoom; // perspective is now handled by perspective_entity and perspective_enabled // self->data->perspective = perspective; self->data->visible = visible; @@ -1730,6 +1743,72 @@ PyObject* UIGrid::py_entities_in_radius(PyUIGridObject* self, PyObject* args, Py return result; } +// #169 - center_camera implementations +void UIGrid::center_camera() { + // Center on grid's middle tile + int cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH; + int cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT; + center_x = (grid_x / 2.0f) * cell_width; + center_y = (grid_y / 2.0f) * cell_height; + markDirty(); // #144 - View change affects content +} + +void UIGrid::center_camera(float tile_x, float tile_y) { + // Position specified tile at top-left of widget + int cell_width = ptex ? ptex->sprite_width : DEFAULT_CELL_WIDTH; + int cell_height = ptex ? ptex->sprite_height : DEFAULT_CELL_HEIGHT; + // To put tile (tx, ty) at top-left: center = tile_pos + half_viewport + float half_viewport_x = box.getSize().x / zoom / 2.0f; + float half_viewport_y = box.getSize().y / zoom / 2.0f; + center_x = tile_x * cell_width + half_viewport_x; + center_y = tile_y * cell_height + half_viewport_y; + markDirty(); // #144 - View change affects content +} + +PyObject* UIGrid::py_center_camera(PyUIGridObject* self, PyObject* args) { + PyObject* pos_arg = nullptr; + + // Parse optional positional argument (tuple of tile coordinates) + if (!PyArg_ParseTuple(args, "|O", &pos_arg)) { + return nullptr; + } + + if (pos_arg == nullptr || pos_arg == Py_None) { + // No args: center on grid's middle tile + self->data->center_camera(); + } else if (PyTuple_Check(pos_arg) && PyTuple_Size(pos_arg) == 2) { + // Tuple provided: center on (tile_x, tile_y) + PyObject* x_obj = PyTuple_GetItem(pos_arg, 0); + PyObject* y_obj = PyTuple_GetItem(pos_arg, 1); + + float tile_x, tile_y; + if (PyFloat_Check(x_obj)) { + tile_x = PyFloat_AsDouble(x_obj); + } else if (PyLong_Check(x_obj)) { + tile_x = (float)PyLong_AsLong(x_obj); + } else { + PyErr_SetString(PyExc_TypeError, "tile coordinates must be numeric"); + return nullptr; + } + + if (PyFloat_Check(y_obj)) { + tile_y = PyFloat_AsDouble(y_obj); + } else if (PyLong_Check(y_obj)) { + tile_y = (float)PyLong_AsLong(y_obj); + } else { + PyErr_SetString(PyExc_TypeError, "tile coordinates must be numeric"); + return nullptr; + } + + self->data->center_camera(tile_x, tile_y); + } else { + PyErr_SetString(PyExc_TypeError, "center_camera() takes an optional tuple (tile_x, tile_y)"); + return nullptr; + } + + Py_RETURN_NONE; +} + PyMethodDef UIGrid::methods[] = { {"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS}, {"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS, @@ -1818,6 +1897,15 @@ PyMethodDef UIGrid::methods[] = { " radius: Search radius\n\n" "Returns:\n" " List of Entity objects within the radius."}, + {"center_camera", (PyCFunction)UIGrid::py_center_camera, METH_VARARGS, + "center_camera(pos: tuple = None) -> None\n\n" + "Center the camera on a tile coordinate.\n\n" + "Args:\n" + " pos: Optional (tile_x, tile_y) tuple. If None, centers on grid's middle tile.\n\n" + "Example:\n" + " grid.center_camera() # Center on middle of grid\n" + " grid.center_camera((5, 10)) # Center on tile (5, 10)\n" + " grid.center_camera((0, 0)) # Center on tile (0, 0)"}, {NULL, NULL, 0, NULL} }; @@ -1929,6 +2017,15 @@ PyMethodDef UIGrid_all_methods[] = { " radius: Search radius\n\n" "Returns:\n" " List of Entity objects within the radius."}, + {"center_camera", (PyCFunction)UIGrid::py_center_camera, METH_VARARGS, + "center_camera(pos: tuple = None) -> None\n\n" + "Center the camera on a tile coordinate.\n\n" + "Args:\n" + " pos: Optional (tile_x, tile_y) tuple. If None, centers on grid's middle tile.\n\n" + "Example:\n" + " grid.center_camera() # Center on middle of grid\n" + " grid.center_camera((5, 10)) # Center on tile (5, 10)\n" + " grid.center_camera((0, 0)) # Center on tile (0, 0)"}, {NULL} // Sentinel }; diff --git a/src/UIGrid.h b/src/UIGrid.h index 2af03b7..3751466 100644 --- a/src/UIGrid.h +++ b/src/UIGrid.h @@ -170,6 +170,12 @@ public: static PyObject* py_get_dijkstra_path(PyUIGridObject* self, PyObject* args); static PyObject* py_compute_astar_path(PyUIGridObject* self, PyObject* args, PyObject* kwds); static PyObject* py_entities_in_radius(PyUIGridObject* self, PyObject* args, PyObject* kwds); // #115 + static PyObject* py_center_camera(PyUIGridObject* self, PyObject* args); // #169 + + // #169 - Camera positioning + void center_camera(); // Center on grid's middle tile + void center_camera(float tile_x, float tile_y); // Center on specific tile + static PyMethodDef methods[]; static PyGetSetDef getsetters[]; static PyObject* get_entities(PyUIGridObject* self, void* closure);