diff --git a/roguelike_tutorial/part_6.py b/roguelike_tutorial/part_6.py new file mode 100644 index 0000000..b385309 --- /dev/null +++ b/roguelike_tutorial/part_6.py @@ -0,0 +1,582 @@ +""" +McRogueFace Tutorial - Part 6: Turn-based enemy movement + +This tutorial builds on Part 5 by adding: +- Turn cycles where enemies move after the player +- Enemy AI that pursues or wanders +- Shared collision detection for all entities +""" +import mcrfpy +import random + +# Create and activate a new scene +mcrfpy.createScene("tutorial") +mcrfpy.setScene("tutorial") + +# Load the texture (4x3 tiles, 64x48 pixels total, 16x16 per tile) +texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16) + +# Load the hero sprite texture +hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16) + +# Create a grid of tiles +grid_width, grid_height = 40, 30 + +# Calculate the size in pixels +zoom = 2.0 +grid_size = grid_width * zoom * 16, grid_height * zoom * 16 + +# Calculate the position to center the grid on the screen +grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2 + +# Create the grid with a TCODMap for pathfinding/FOV +grid = mcrfpy.Grid( + pos=grid_position, + grid_size=(grid_width, grid_height), + texture=texture, + size=grid_size, +) + +grid.zoom = zoom + +# Define tile types +FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10] +WALL_TILES = [3, 7, 11] + +# Room class for BSP +class Room: + def __init__(self, x, y, w, h): + self.x1 = x + self.y1 = y + self.x2 = x + w + self.y2 = y + h + self.w = w + self.h = h + + def center(self): + center_x = (self.x1 + self.x2) // 2 + center_y = (self.y1 + self.y2) // 2 + return (center_x, center_y) + + def intersects(self, other): + return (self.x1 <= other.x2 and self.x2 >= other.x1 and + self.y1 <= other.y2 and self.y2 >= other.y1) + +# Dungeon generation functions (from Part 3) +def carve_room(room): + for x in range(room.x1, room.x2): + for y in range(room.y1, room.y2): + if 0 <= x < grid_width and 0 <= y < grid_height: + point = grid.at(x, y) + if point: + point.tilesprite = random.choice(FLOOR_TILES) + point.walkable = True + point.transparent = True + +def carve_hallway(x1, y1, x2, y2): + #points = mcrfpy.libtcod.line(x1, y1, x2, y2) + points = [] + if random.choice([True, False]): + # x1,y1 -> x2,y1 -> x2,y2 + points.extend(mcrfpy.libtcod.line(x1, y1, x2, y1)) + points.extend(mcrfpy.libtcod.line(x2, y1, x2, y2)) + else: + # x1,y1 -> x1,y2 -> x2,y2 + points.extend(mcrfpy.libtcod.line(x1, y1, x1, y2)) + points.extend(mcrfpy.libtcod.line(x1, y2, x2, y2)) + + for x, y in points: + if 0 <= x < grid_width and 0 <= y < grid_height: + point = grid.at(x, y) + if point: + point.tilesprite = random.choice(FLOOR_TILES) + point.walkable = True + point.transparent = True + +def generate_dungeon(max_rooms=10, room_min_size=4, room_max_size=10): + rooms = [] + + # Fill with walls + for y in range(grid_height): + for x in range(grid_width): + point = grid.at(x, y) + if point: + point.tilesprite = random.choice(WALL_TILES) + point.walkable = False + point.transparent = False + + # Generate rooms + for _ in range(max_rooms): + w = random.randint(room_min_size, room_max_size) + h = random.randint(room_min_size, room_max_size) + x = random.randint(1, grid_width - w - 1) + y = random.randint(1, grid_height - h - 1) + + new_room = Room(x, y, w, h) + + failed = False + for other_room in rooms: + if new_room.intersects(other_room): + failed = True + break + + if not failed: + carve_room(new_room) + + if rooms: + prev_x, prev_y = rooms[-1].center() + new_x, new_y = new_room.center() + carve_hallway(prev_x, prev_y, new_x, new_y) + + rooms.append(new_room) + + return rooms + +# Generate the dungeon +rooms = generate_dungeon(max_rooms=8, room_min_size=4, room_max_size=8) + +# Add the grid to the scene +mcrfpy.sceneUI("tutorial").append(grid) + +# Spawn player in the first room +if rooms: + spawn_x, spawn_y = rooms[0].center() +else: + spawn_x, spawn_y = 4, 4 + +class GameEntity(mcrfpy.Entity): + """An entity whose default behavior is to prevent others from moving into its tile.""" + + def __init__(self, x, y, walkable=False, **kwargs): + super().__init__(x=x, y=y, **kwargs) + self.walkable = walkable + self.dest_x = x + self.dest_y = y + self.is_moving = False + + def get_position(self): + """Get logical position (destination if moving, otherwise current)""" + if self.is_moving: + return (self.dest_x, self.dest_y) + return (int(self.x), int(self.y)) + + def on_bump(self, other): + return self.walkable # allow other's motion to proceed if entity is walkable + + def __repr__(self): + return f"<{self.__class__.__name__} x={self.x}, y={self.y}, sprite_index={self.sprite_index}>" + +class CombatEntity(GameEntity): + def __init__(self, x, y, hp=10, damage=(1,3), **kwargs): + super().__init__(x=x, y=y, **kwargs) + self.hp = hp + self.damage = damage + + def is_dead(self): + return self.hp <= 0 + + def start_move(self, new_x, new_y, duration=0.2, callback=None): + """Start animating movement to new position""" + self.dest_x = new_x + self.dest_y = new_y + self.is_moving = True + + # Define completion callback that resets is_moving + def movement_done(anim, entity): + self.is_moving = False + if callback: + callback(anim, entity) + + # Create animations for smooth movement + anim_x = mcrfpy.Animation("x", float(new_x), duration, "easeInOutQuad", callback=movement_done) + anim_y = mcrfpy.Animation("y", float(new_y), duration, "easeInOutQuad") + + anim_x.start(self) + anim_y.start(self) + + def can_see(self, target_x, target_y): + """Check if this entity can see the target position""" + mx, my = self.get_position() + + # Simple distance check first + dist = abs(target_x - mx) + abs(target_y - my) + if dist > 6: + return False + + # Line of sight check + line = list(mcrfpy.libtcod.line(mx, my, target_x, target_y)) + for x, y in line[1:-1]: # Skip start and end + cell = grid.at(x, y) + if cell and not cell.transparent: + return False + return True + + def ai_turn(self, player_pos): + """Decide next move""" + mx, my = self.get_position() + px, py = player_pos + + # Simple AI: move toward player if visible + if self.can_see(px, py): + # Calculate direction toward player + dx = 0 + dy = 0 + if px > mx: + dx = 1 + elif px < mx: + dx = -1 + if py > my: + dy = 1 + elif py < my: + dy = -1 + + # Prefer cardinal movement + if dx != 0 and dy != 0: + # Pick horizontal or vertical based on greater distance + if abs(px - mx) > abs(py - my): + dy = 0 + else: + dx = 0 + + return (mx + dx, my + dy) + else: + # Random wander + dx, dy = random.choice([(0,1), (0,-1), (1,0), (-1,0)]) + return (mx + dx, my + dy) + +# Create a player entity +player = CombatEntity( + spawn_x, spawn_y, + texture=hero_texture, + sprite_index=0 +) + +# Add the player entity to the grid +grid.entities.append(player) + +# Track all enemies +enemies = [] + +# Spawn enemies in other rooms +for i, room in enumerate(rooms[1:], 1): # Skip first room (player spawn) + if i <= 3: # Limit to 3 enemies for now + enemy_x, enemy_y = room.center() + enemy = CombatEntity( + enemy_x, enemy_y, + texture=hero_texture, + sprite_index=0 # Enemy sprite (borrow player's) + ) + grid.entities.append(enemy) + enemies.append(enemy) + +# Set the grid perspective to the player by default +# Note: The new perspective system uses entity references directly +grid.perspective = player + +# Initial FOV computation +def update_fov(): + """Update field of view from current perspective""" + if grid.perspective == player: + grid.compute_fov(int(player.x), int(player.y), radius=8, algorithm=0) + player.update_visibility() + +# Perform initial FOV calculation +update_fov() + +# Center grid on current perspective +def center_on_perspective(): + if grid.perspective == player: + grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16 + +center_on_perspective() + +# Movement state tracking (from Part 3) +#is_moving = False # make it an entity property +move_queue = [] +current_destination = None +current_move = None + +# Store animation references +player_anim_x = None +player_anim_y = None +grid_anim_x = None +grid_anim_y = None + +def movement_complete(anim, target): + """Called when movement animation completes""" + global move_queue, current_destination, current_move + global player_anim_x, player_anim_y, is_player_turn + + player.is_moving = False + current_move = None + current_destination = None + player_anim_x = None + player_anim_y = None + + # Update FOV after movement + update_fov() + center_on_perspective() + + # Player turn complete, start enemy turns and queued player move simultaneously + is_player_turn = False + process_enemy_turns_and_player_queue() + +motion_speed = 0.20 +is_player_turn = True # Track whose turn it is + +def get_blocking_entity_at(x, y): + """Get blocking entity at position""" + for e in grid.entities: + if not e.walkable and (x, y) == e.get_position(): + return e + return None + +def can_move_to(x, y, mover=None): + """Check if a position is valid for movement""" + if x < 0 or x >= grid_width or y < 0 or y >= grid_height: + return False + + point = grid.at(x, y) + if not point or not point.walkable: + return False + + # Check for blocking entities + blocker = get_blocking_entity_at(x, y) + if blocker and blocker != mover: + return False + + return True + +def process_enemy_turns_and_player_queue(): + """Process all enemy AI decisions and player's queued move simultaneously""" + global is_player_turn, move_queue + + enemies_to_move = [] + + # Collect all enemy moves + for i, enemy in enumerate(enemies): + if enemy.is_dead(): + continue + + # AI decides next move based on player's position + target_x, target_y = enemy.ai_turn(player.get_position()) + + # Check if move is valid + if can_move_to(target_x, target_y, enemy): + enemies_to_move.append((enemy, target_x, target_y)) + + # Start all enemy animations simultaneously + any_enemy_moved = False + if enemies_to_move: + for enemy, tx, ty in enemies_to_move: + enemy.start_move(tx, ty, duration=motion_speed) + any_enemy_moved = True + + # Process player's queued move at the same time + if move_queue: + next_move = move_queue.pop(0) + process_player_queued_move(next_move) + else: + # No queued move, set up callback to return control when animations finish + if any_enemy_moved: + # Wait for animations to complete + mcrfpy.setTimer("turn_complete", check_turn_complete, int(motion_speed * 1000) + 50) + else: + # No animations, return control immediately + is_player_turn = True + +def process_player_queued_move(key): + """Process player's queued move during enemy turn""" + global current_move, current_destination + global player_anim_x, player_anim_y, grid_anim_x, grid_anim_y + + 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 + + if new_x != px or new_y != py: + # Check destination at animation end time (considering enemy destinations) + future_blocker = get_future_blocking_entity_at(new_x, new_y) + + if future_blocker: + # Will bump at destination + # Schedule bump for when animations complete + mcrfpy.setTimer("delayed_bump", lambda t: handle_delayed_bump(future_blocker), int(motion_speed * 1000)) + elif can_move_to(new_x, new_y, player): + # Valid move, start animation + player.is_moving = True + current_move = key + current_destination = (new_x, new_y) + player.dest_x = new_x + player.dest_y = new_y + + # Player animation with callback + player_anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad", callback=player_queued_move_complete) + player_anim_x.start(player) + player_anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad") + player_anim_y.start(player) + + # Move camera with player + grid_anim_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear") + grid_anim_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear") + grid_anim_x.start(grid) + grid_anim_y.start(grid) + else: + # Blocked by wall, wait for turn to complete + mcrfpy.setTimer("turn_complete", check_turn_complete, int(motion_speed * 1000) + 50) + +def get_future_blocking_entity_at(x, y): + """Get entity that will be blocking at position after current animations""" + for e in grid.entities: + if not e.walkable and (x, y) == (e.dest_x, e.dest_y): + return e + return None + +def handle_delayed_bump(entity): + """Handle bump after animations complete""" + global is_player_turn + entity.on_bump(player) + is_player_turn = True + +def player_queued_move_complete(anim, target): + """Called when player's queued movement completes""" + global is_player_turn + player.is_moving = False + update_fov() + center_on_perspective() + is_player_turn = True + +def check_turn_complete(timer_name): + """Check if all animations are complete""" + global is_player_turn + + # Check if any entity is still moving + if player.is_moving: + mcrfpy.setTimer("turn_complete", check_turn_complete, 50) + return + + for enemy in enemies: + if enemy.is_moving: + mcrfpy.setTimer("turn_complete", check_turn_complete, 50) + return + + # All done + is_player_turn = True + +def process_move(key): + """Process a move based on the key""" + global current_move, current_destination, move_queue + global player_anim_x, player_anim_y, grid_anim_x, grid_anim_y, is_player_turn + + # Only allow player movement on player's turn + if not is_player_turn: + return + + # Only allow player movement when in player perspective + if grid.perspective != player: + return + + if player.is_moving: + move_queue.clear() + move_queue.append(key) + 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 + + if new_x != px or new_y != py: + # Check what's at destination + blocker = get_blocking_entity_at(new_x, new_y) + + if blocker: + # Bump interaction (combat will go here later) + blocker.on_bump(player) + # Still counts as a turn + is_player_turn = False + process_enemy_turns_and_player_queue() + elif can_move_to(new_x, new_y, player): + player.is_moving = True + current_move = key + current_destination = (new_x, new_y) + player.dest_x = new_x + player.dest_y = new_y + + # Start player move animation + player_anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad", callback=movement_complete) + player_anim_x.start(player) + player_anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad") + player_anim_y.start(player) + + # Move camera with player + grid_anim_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear") + grid_anim_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear") + grid_anim_x.start(grid) + grid_anim_y.start(grid) + +def handle_keys(key, state): + """Handle keyboard input""" + if state == "start": + # Movement keys + if key in ["W", "Up", "S", "Down", "A", "Left", "D", "Right"]: + process_move(key) + +# Register the keyboard handler +mcrfpy.keypressScene(handle_keys) + +# Add UI elements +title = mcrfpy.Caption((320, 10), + text="McRogueFace Tutorial - Part 6: Turn-based Movement", +) +title.fill_color = mcrfpy.Color(255, 255, 255, 255) +mcrfpy.sceneUI("tutorial").append(title) + +instructions = mcrfpy.Caption((150, 720), + text="Use WASD/Arrows to move. Enemies move after you!", +) +instructions.font_size = 18 +instructions.fill_color = mcrfpy.Color(200, 200, 200, 255) +mcrfpy.sceneUI("tutorial").append(instructions) + +# Debug info +debug_caption = mcrfpy.Caption((10, 40), + text=f"Grid: {grid_width}x{grid_height} | Rooms: {len(rooms)} | Enemies: {len(enemies)}", +) +debug_caption.font_size = 16 +debug_caption.fill_color = mcrfpy.Color(255, 255, 0, 255) +mcrfpy.sceneUI("tutorial").append(debug_caption) + +# Update function for turn display +def update_turn_display(): + turn_text = "Player" if is_player_turn else "Enemy" + alive_enemies = sum(1 for e in enemies if not e.is_dead()) + debug_caption.text = f"Grid: {grid_width}x{grid_height} | Turn: {turn_text} | Enemies: {alive_enemies}/{len(enemies)}" + +# Timer to update display +def update_display(runtime): + update_turn_display() + +mcrfpy.setTimer("display_update", update_display, 100) + +print("Tutorial Part 6 loaded!") +print("Turn-based movement system active!") +print("- Enemies move after the player") +print("- Enemies pursue when they can see you") +print("- Enemies wander when they can't") +print("Use WASD or Arrow keys to move!")