Merge branch 'rogueliketutorial25' - TCOD Tutorial Implementation

This merge brings in the complete TCOD-style tutorial implementation
for McRogueFace, demonstrating the engine as a viable alternative to
python-tcod for roguelike game development.

Key additions:
- Tutorial parts 0-6 with full documentation
- EntityCollection.remove() API improvement (object-based vs index-based)
- Development tooling scripts (test runner, issue tracking)
- Complete API reference documentation

Tutorial follows "forward-only" philosophy where each step builds
on previous work without requiring refactoring, making it more
accessible for beginners.

This work represents 2+ months of development (July-August 2025)
focused on validating McRogueFace's educational value and TCOD
compatibility.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
John McCardle 2025-10-23 13:19:50 -04:00
commit 8153fd2503
21 changed files with 6694 additions and 100 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,393 @@
# McRogueFace Tutorial Parts 6-8: Implementation Plan
**Date**: Monday, July 28, 2025
**Target Delivery**: Tuesday, July 29, 2025
## Executive Summary
This document outlines the implementation plan for Parts 6-8 of the McRogueFace roguelike tutorial, adapting the libtcod Python tutorial to McRogueFace's architecture. The key discovery is that Python classes can successfully inherit from `mcrfpy.Entity` and store custom attributes, enabling a clean, Pythonic implementation.
## Key Architectural Insights
### Entity Inheritance Works!
```python
class GameEntity(mcrfpy.Entity):
def __init__(self, x, y, **kwargs):
super().__init__(x=x, y=y, **kwargs)
# Custom attributes work perfectly!
self.hp = 10
self.inventory = []
self.any_attribute = "works"
```
This completely changes our approach from wrapper patterns to direct inheritance.
---
## Part 6: Doing (and Taking) Some Damage
### Overview
Implement a combat system with HP tracking, damage calculation, and death mechanics using entity inheritance.
### Core Components
#### 1. CombatEntity Base Class
```python
class CombatEntity(mcrfpy.Entity):
"""Base class for entities that can fight and take damage"""
def __init__(self, x, y, hp=10, defense=0, power=1, **kwargs):
super().__init__(x=x, y=y, **kwargs)
# Combat stats as direct attributes
self.hp = hp
self.max_hp = hp
self.defense = defense
self.power = power
self.is_alive = True
self.blocks_movement = True
def calculate_damage(self, attacker):
"""Simple damage formula: power - defense"""
return max(0, attacker.power - self.defense)
def take_damage(self, damage, attacker=None):
"""Apply damage and handle death"""
self.hp = max(0, self.hp - damage)
if self.hp == 0 and self.is_alive:
self.is_alive = False
self.on_death(attacker)
def on_death(self, killer=None):
"""Handle death - override in subclasses"""
self.sprite_index = self.sprite_index + 180 # Corpse offset
self.blocks_movement = False
```
#### 2. Entity Types
```python
class PlayerEntity(CombatEntity):
"""Player: HP=30, Defense=2, Power=5"""
def __init__(self, x, y, **kwargs):
kwargs['sprite_index'] = 64 # Hero sprite
super().__init__(x=x, y=y, hp=30, defense=2, power=5, **kwargs)
self.entity_type = "player"
class OrcEntity(CombatEntity):
"""Orc: HP=10, Defense=0, Power=3"""
def __init__(self, x, y, **kwargs):
kwargs['sprite_index'] = 65 # Orc sprite
super().__init__(x=x, y=y, hp=10, defense=0, power=3, **kwargs)
self.entity_type = "orc"
class TrollEntity(CombatEntity):
"""Troll: HP=16, Defense=1, Power=4"""
def __init__(self, x, y, **kwargs):
kwargs['sprite_index'] = 66 # Troll sprite
super().__init__(x=x, y=y, hp=16, defense=1, power=4, **kwargs)
self.entity_type = "troll"
```
#### 3. Combat Integration
- Extend `on_bump()` from Part 5 to include combat
- Add attack animations (quick bump toward target)
- Console messages initially, UI messages in Part 7
- Death changes sprite and removes blocking
### Key Differences from Original Tutorial
- No Fighter component - stats are direct attributes
- No AI component - behavior in entity methods
- Integrated animations for visual feedback
- Simpler architecture overall
---
## Part 7: Creating the Interface
### Overview
Add visual UI elements including health bars, message logs, and colored feedback for combat events.
### Core Components
#### 1. Health Bar
```python
class HealthBar:
"""Health bar that reads entity HP directly"""
def __init__(self, entity, pos=(10, 740), size=(200, 20)):
self.entity = entity # Direct reference!
# Background (dark red)
self.bg = mcrfpy.Frame(pos=pos, size=size)
self.bg.fill_color = mcrfpy.Color(64, 16, 16)
# Foreground (green)
self.fg = mcrfpy.Frame(pos=pos, size=size)
self.fg.fill_color = mcrfpy.Color(0, 96, 0)
# Text overlay
self.text = mcrfpy.Caption(
pos=(pos[0] + 5, pos[1] + 2),
text=f"HP: {entity.hp}/{entity.max_hp}"
)
def update(self):
"""Update based on entity's current HP"""
ratio = self.entity.hp / self.entity.max_hp
self.fg.w = int(self.bg.w * ratio)
self.text.text = f"HP: {self.entity.hp}/{self.entity.max_hp}"
# Color changes at low health
if ratio < 0.25:
self.fg.fill_color = mcrfpy.Color(196, 16, 16) # Red
elif ratio < 0.5:
self.fg.fill_color = mcrfpy.Color(196, 196, 16) # Yellow
```
#### 2. Message Log
```python
class MessageLog:
"""Scrolling message log for combat feedback"""
def __init__(self, pos=(10, 600), size=(400, 120), max_messages=6):
self.frame = mcrfpy.Frame(pos=pos, size=size)
self.messages = [] # List of (text, color) tuples
self.captions = [] # Pre-allocated Caption pool
def add_message(self, text, color=None):
"""Add message with optional color"""
# Handle duplicate detection (x2, x3, etc.)
# Update caption display
```
#### 3. Color System
```python
class Colors:
# Combat colors
PLAYER_ATTACK = mcrfpy.Color(224, 224, 224)
ENEMY_ATTACK = mcrfpy.Color(255, 192, 192)
PLAYER_DEATH = mcrfpy.Color(255, 48, 48)
ENEMY_DEATH = mcrfpy.Color(255, 160, 48)
HEALTH_RECOVERED = mcrfpy.Color(0, 255, 0)
```
### UI Layout
- Health bar at bottom of screen
- Message log above health bar
- Direct binding to entity attributes
- Real-time updates during gameplay
---
## Part 8: Items and Inventory
### Overview
Implement items as entities, inventory management, and a hotbar-style UI for item usage.
### Core Components
#### 1. Item Entities
```python
class ItemEntity(mcrfpy.Entity):
"""Base class for pickupable items"""
def __init__(self, x, y, name, sprite, **kwargs):
kwargs['sprite_index'] = sprite
super().__init__(x=x, y=y, **kwargs)
self.item_name = name
self.blocks_movement = False
self.item_type = "generic"
class HealingPotion(ItemEntity):
"""Consumable healing item"""
def __init__(self, x, y, healing_amount=4):
super().__init__(x, y, "Healing Potion", sprite=33)
self.healing_amount = healing_amount
self.item_type = "consumable"
def use(self, user):
"""Use the potion - returns (success, message)"""
if hasattr(user, 'hp'):
healed = min(self.healing_amount, user.max_hp - user.hp)
if healed > 0:
user.hp += healed
return True, f"You heal {healed} HP!"
```
#### 2. Inventory System
```python
class InventoryMixin:
"""Mixin for entities with inventory"""
def __init__(self, *args, capacity=10, **kwargs):
super().__init__(*args, **kwargs)
self.inventory = []
self.inventory_capacity = capacity
def pickup_item(self, item):
"""Pick up an item entity"""
if len(self.inventory) >= self.inventory_capacity:
return False, "Inventory full!"
self.inventory.append(item)
item.die() # Remove from grid
return True, f"Picked up {item.item_name}."
```
#### 3. Inventory UI
```python
class InventoryDisplay:
"""Hotbar-style inventory display"""
def __init__(self, entity, pos=(200, 700), slots=10):
# Create slot frames and sprites
# Number keys 1-9, 0 for slots
# Highlight selected slot
# Update based on entity.inventory
```
### Key Features
- Items exist as entities on the grid
- Direct inventory attribute on player
- Hotkey-based usage (1-9, 0)
- Visual hotbar display
- Item effects (healing, future: damage boost, etc.)
---
## Implementation Timeline
### Tuesday Morning (Priority 1: Core Systems)
1. **8:00-9:30**: Implement CombatEntity and entity types
2. **9:30-10:30**: Add combat to bump interactions
3. **10:30-11:30**: Basic health display (text or simple bar)
4. **11:30-12:00**: ItemEntity and pickup system
### Tuesday Afternoon (Priority 2: Integration)
1. **1:00-2:00**: Message log implementation
2. **2:00-3:00**: Full health bar with colors
3. **3:00-4:00**: Inventory UI (hotbar)
4. **4:00-5:00**: Testing and bug fixes
### Tuesday Evening (Priority 3: Polish)
1. **5:00-6:00**: Combat animations and effects
2. **6:00-7:00**: Sound integration (use CoS splat sounds)
3. **7:00-8:00**: Additional item types
4. **8:00-9:00**: Documentation and cleanup
---
## Testing Strategy
### Automated Tests
```python
# tests/test_part6_combat.py
- Test damage calculation
- Test death mechanics
- Test combat messages
# tests/test_part7_ui.py
- Test health bar updates
- Test message log scrolling
- Test color system
# tests/test_part8_inventory.py
- Test item pickup/drop
- Test inventory capacity
- Test item usage
```
### Visual Tests
- Screenshot combat states
- Verify UI element positioning
- Check animation smoothness
---
## File Structure
```
roguelike_tutorial/
├── part_6.py # Combat implementation
├── part_7.py # UI enhancements
├── part_8.py # Inventory system
├── combat.py # Shared combat utilities
├── ui_components.py # Reusable UI classes
├── colors.py # Color definitions
└── items.py # Item definitions
```
---
## Risk Mitigation
### Potential Issues
1. **Performance**: Many UI updates per frame
- Solution: Update only on state changes
2. **Entity Collection Bugs**: Known segfault issues
- Solution: Use index-based access when needed
3. **Animation Timing**: Complex with turn-based combat
- Solution: Queue animations, process sequentially
### Fallback Options
1. Start with console messages, add UI later
2. Simple health numbers before bars
3. Basic inventory list before hotbar
---
## Success Criteria
### Part 6
- [x] Entities can have HP and take damage
- [x] Death changes sprite and walkability
- [x] Combat messages appear
- [x] Player can kill enemies
### Part 7
- [x] Health bar shows current/max HP
- [x] Messages appear in scrolling log
- [x] Colors differentiate message types
- [x] UI updates in real-time
### Part 8
- [x] Items can be picked up
- [x] Inventory has capacity limit
- [x] Items can be used/consumed
- [x] Hotbar shows inventory items
---
## Notes for Implementation
1. **Keep It Simple**: Start with minimum viable features
2. **Build Incrementally**: Test each component before integrating
3. **Use Part 5**: Leverage existing entity interaction system
4. **Document Well**: Clear comments for tutorial purposes
5. **Visual Feedback**: McRogueFace excels at animations - use them!
---
## Comparison with Original Tutorial
### What We Keep
- Same combat formula (power - defense)
- Same entity stats (Player, Orc, Troll)
- Same item types (healing potions to start)
- Same UI elements (health bar, message log)
### What's Different
- Direct inheritance instead of components
- Integrated animations and visual effects
- Hotbar inventory instead of menu
- Built-in sound support
- Cleaner architecture overall
### What's Better
- More Pythonic with real inheritance
- Better visual feedback
- Smoother animations
- Simpler to understand
- Leverages McRogueFace's strengths
---
## Conclusion
This implementation plan leverages McRogueFace's support for Python entity inheritance to create a clean, intuitive tutorial series. By using direct attributes instead of components, we simplify the architecture while maintaining all the functionality of the original tutorial. The addition of animations, sound effects, and rich UI elements showcases McRogueFace's capabilities while keeping the code beginner-friendly.
The Tuesday delivery timeline is aggressive but achievable by focusing on core functionality first, then integration, then polish. The modular design allows for easy testing and incremental development.

View File

@ -0,0 +1,100 @@
# Simple TCOD Tutorial Part 1 - Drawing the player sprite and moving it around
This is Part 1 of the Simple TCOD Tutorial adapted for McRogueFace. It implements the sophisticated, refactored TCOD tutorial approach with professional architecture from day one.
## Running the Code
From your tutorial build directory (separate from the engine development build):
```bash
cd simple_tcod_tutorial/build
./mcrogueface scripts/main.py
```
Note: The `scripts` folder should be a symlink to your `simple_tcod_tutorial` directory.
## Architecture Overview
### Package Structure
```
simple_tcod_tutorial/
├── main.py # Entry point - ties everything together
├── game/ # Game package with proper separation
│ ├── __init__.py
│ ├── entity.py # Entity class - all game objects
│ ├── engine.py # Engine class - game coordinator
│ ├── actions.py # Action classes - command pattern
│ └── input_handlers.py # Input handling - extensible system
```
### Key Concepts Demonstrated
1. **Entity-Centric Design**
- Everything in the game is an Entity
- Entities have position, appearance, and behavior
- Designed to scale to items, NPCs, and effects
2. **Action-Based Command Pattern**
- All player actions are Action objects
- Separates input from game logic
- Enables undo, replay, and AI using same system
3. **Professional Input Handling**
- BaseEventHandler for different input contexts
- Complete movement key support (arrows, numpad, vi, WASD)
- Ready for menus, targeting, and other modes
4. **Engine as Coordinator**
- Manages game state without becoming a god object
- Delegates to appropriate systems
- Clean boundaries between systems
5. **Type Safety**
- Full type annotations throughout
- Forward references with TYPE_CHECKING
- Modern Python best practices
## Differences from Vanilla McRogueFace Tutorial
### Removed
- Animation system (instant movement instead)
- Complex UI elements (focus on core mechanics)
- Real-time features (pure turn-based)
- Visual effects (camera following, smooth scrolling)
- Entity color property (sprites handle appearance)
### Added
- Complete movement key support
- Professional architecture patterns
- Proper package structure
- Type annotations
- Action-based design
- Extensible handler system
- Proper exit handling (Escape/Q actually quits)
### Adapted
- Grid rendering with proper centering
- Simplified entity system (position + sprite ID)
- Using simple_tutorial.png sprite sheet (12 sprites)
- Floor tiles using ground sprites (indices 1 and 2)
- Direct sprite indices instead of character mapping
## Learning Objectives
Students completing Part 1 will understand:
- How to structure a game project professionally
- The value of entity-centric design
- Command pattern for game actions
- Input handling that scales to complex UIs
- Type-driven development in Python
- Architecture that grows without refactoring
## What's Next
Part 2 will add:
- The GameMap class for world representation
- Tile-based movement and collision
- Multiple entities in the world
- Basic terrain (walls and floors)
- Rendering order for entities
The architecture we've built in Part 1 makes these additions natural and painless, demonstrating the value of starting with good patterns.

View File

@ -0,0 +1,82 @@
# Simple TCOD Tutorial Part 2 - The generic Entity, the map, and walls
This is Part 2 of the Simple TCOD Tutorial adapted for McRogueFace. Building on Part 1's foundation, we now introduce proper world representation and collision detection.
## Running the Code
From your tutorial build directory:
```bash
cd simple_tcod_tutorial/build
./mcrogueface scripts/main.py
```
## New Architecture Components
### GameMap Class (`game/game_map.py`)
The GameMap inherits from `mcrfpy.Grid` and adds:
- **Tile Management**: Uses Grid's built-in point system with walkable property
- **Entity Container**: Manages entity lifecycle with `add_entity()` and `remove_entity()`
- **Spatial Queries**: `get_entities_at()`, `get_blocking_entity_at()`, `is_walkable()`
- **Direct Integration**: Leverages Grid's walkable and tilesprite properties
### Tiles System (`game/tiles.py`)
- **Simple Tile Types**: Using NamedTuple for clean tile definitions
- **Tile Types**: Floor (walkable) and Wall (blocks movement)
- **Grid Integration**: Maps directly to Grid point properties
- **Future-Ready**: Includes transparency for FOV system in Part 4
### Entity Placement System
- **Bidirectional References**: Entities know their map, maps track their entities
- **`place()` Method**: Handles all bookkeeping when entities move between maps
- **Lifecycle Management**: Automatic cleanup when entities leave maps
## Key Changes from Part 1
### Engine Updates
- Replaced direct grid management with GameMap
- Engine creates and configures the GameMap
- Player is placed using the new `place()` method
### Movement System
- MovementAction now checks `is_walkable()` before moving
- Collision detection for both walls and blocking entities
- Clean separation between validation and execution
### Visual Changes
- Walls rendered as trees (sprite index 3)
- Border of walls around the map edge
- Floor tiles still use alternating pattern
## Architectural Benefits
### McRogueFace Integration
- **No NumPy Dependency**: Uses Grid's native tile management
- **Direct Walkability**: Grid points have built-in walkable property
- **Unified System**: Visual and logical tile data in one place
### Separation of Concerns
- **GameMap**: Knows about tiles and spatial relationships
- **Engine**: Coordinates high-level game state
- **Entity**: Manages its own lifecycle through `place()`
- **Actions**: Validate their own preconditions
### Extensibility
- Easy to add new tile types
- Simple to implement different map generation
- Ready for FOV, pathfinding, and complex queries
- Entity system scales to items and NPCs
### Type Safety
- TYPE_CHECKING imports prevent circular dependencies
- Proper type hints throughout
- Forward references maintain clean architecture
## What's Next
Part 3 will add:
- Procedural dungeon generation
- Room and corridor creation
- Multiple entities in the world
- Foundation for enemy placement
The architecture established in Part 2 makes these additions straightforward, demonstrating the value of proper design from the beginning.

View File

@ -0,0 +1,87 @@
# Simple TCOD Tutorial Part 3 - Generating a dungeon
This is Part 3 of the Simple TCOD Tutorial adapted for McRogueFace. We now add procedural dungeon generation to create interesting, playable levels.
## Running the Code
From your tutorial build directory:
```bash
cd simple_tcod_tutorial/build
./mcrogueface scripts/main.py
```
## New Features
### Procedural Generation Module (`game/procgen.py`)
This dedicated module demonstrates separation of concerns - dungeon generation logic is kept separate from the game map implementation.
#### RectangularRoom Class
- **Clean Abstraction**: Represents a room with position and dimensions
- **Utility Properties**:
- `center` - Returns room center for connections
- `inner` - Returns slice objects for efficient carving
- **Intersection Detection**: `intersects()` method prevents overlapping rooms
#### Tunnel Generation
- **L-Shaped Corridors**: Simple but effective connection method
- **Iterator Pattern**: `tunnel_between()` yields coordinates efficiently
- **Random Variation**: 50/50 chance of horizontal-first vs vertical-first
#### Dungeon Generation Algorithm
```python
def generate_dungeon(max_rooms, room_min_size, room_max_size,
map_width, map_height, engine) -> GameMap:
```
- **Simple Algorithm**: Try to place random rooms, reject overlaps
- **Automatic Connection**: Each room connects to the previous one
- **Player Placement**: First room contains the player
- **Entity-Centric**: Uses `player.place()` for proper lifecycle
## Architecture Benefits
### Modular Design
- Generation logic separate from GameMap
- Easy to swap algorithms later
- Room class reusable for other features
### Forward Thinking
- Engine parameter anticipates entity spawning
- Room list available for future features
- Iterator-based tunnel generation is memory efficient
### Clean Integration
- Works seamlessly with existing entity placement
- Respects GameMap's tile management
- No special cases or hacks needed
## Visual Changes
- Map size increased to 80x45 for better dungeons
- Zoom reduced to 1.0 to see more of the map
- Random room layouts each time
- Connected rooms and corridors
## Algorithm Details
The generation follows these steps:
1. Start with a map filled with walls
2. Try to place up to `max_rooms` rooms
3. For each room attempt:
- Generate random size and position
- Check for intersections with existing rooms
- If valid, carve out the room
- Connect to previous room (if any)
4. Place player in center of first room
This simple algorithm creates playable dungeons while being easy to understand and modify.
## What's Next
Part 4 will add:
- Field of View (FOV) system
- Explored vs unexplored areas
- Light and dark tile rendering
- Torch radius around player
The modular dungeon generation makes it easy to add these visual features without touching the generation code.

View File

@ -0,0 +1,131 @@
# Part 4: Field of View and Exploration
## Overview
Part 4 introduces the Field of View (FOV) system, transforming our fully-visible dungeon into an atmospheric exploration experience. We leverage McRogueFace's built-in FOV capabilities and perspective system for efficient rendering.
## What's New in Part 4
### Field of View System
- **FOV Calculation**: Using `Grid.compute_fov()` with configurable radius
- **Perspective System**: Grid tracks which entity is the viewer
- **Visibility States**: Unexplored (black), explored (dark), visible (lit)
- **Automatic Updates**: FOV recalculates on player movement
### Implementation Details
#### FOV with McRogueFace's Grid
Unlike TCOD which uses numpy arrays for visibility tracking, McRogueFace's Grid has built-in FOV support:
```python
# In GameMap.update_fov()
self.compute_fov(viewer_x, viewer_y, radius, light_walls=True, algorithm=mcrfpy.FOV_BASIC)
```
The Grid automatically:
- Tracks which tiles have been explored
- Applies appropriate color overlays (shroud, dark, light)
- Updates entity visibility based on FOV
#### Perspective System
McRogueFace uses a perspective-based rendering approach:
```python
# Set the viewer
self.game_map.perspective = self.player
# Grid automatically renders from this entity's viewpoint
```
This is more efficient than manually updating tile colors every turn.
#### Color Overlays
We define overlay colors but let the Grid handle application:
```python
# In tiles.py
SHROUD = mcrfpy.Color(0, 0, 0, 255) # Unexplored
DARK = mcrfpy.Color(100, 100, 150, 128) # Explored but not visible
LIGHT = mcrfpy.Color(255, 255, 255, 0) # Currently visible
```
### Key Differences from TCOD
| TCOD Approach | McRogueFace Approach |
|---------------|----------------------|
| `visible` and `explored` numpy arrays | Grid's built-in FOV state |
| Manual tile color switching | Automatic overlay system |
| `tcod.map.compute_fov()` | `Grid.compute_fov()` |
| Render conditionals for each tile | Perspective-based rendering |
### Movement and FOV Updates
The action system now updates FOV after player movement:
```python
# In MovementAction.perform()
if self.entity == engine.player:
engine.update_fov()
```
## Architecture Notes
### Why Grid Perspective?
The perspective system provides several benefits:
1. **Efficiency**: No per-tile color updates needed
2. **Flexibility**: Easy to switch viewpoints (for debugging or features)
3. **Automatic**: Grid handles all rendering details
4. **Clean**: Separates game logic from rendering concerns
### Entity Visibility
Entities automatically update their visibility state:
```python
# After FOV calculation
self.player.update_visibility()
```
This ensures entities are only rendered when visible to the current perspective.
## Files Modified
- `game/tiles.py`: Added FOV color overlay constants
- `game/game_map.py`: Added `update_fov()` method
- `game/engine.py`: Added FOV initialization and update method
- `game/actions.py`: Update FOV after player movement
- `main.py`: Updated part description
## What's Next
Part 5 will add enemies to our dungeon, introducing:
- Enemy entities with AI
- Combat system
- Turn-based gameplay
- Health and damage
The FOV system will make enemies appear and disappear as you explore, adding tension and strategy to the gameplay.
## Learning Points
1. **Leverage Framework Features**: Use McRogueFace's built-in systems rather than reimplementing
2. **Perspective-Based Design**: Think in terms of viewpoints, not global state
3. **Automatic Systems**: Let the framework handle rendering details
4. **Clean Integration**: FOV updates fit naturally into the action system
## Running Part 4
```bash
cd simple_tcod_tutorial/build
./mcrogueface scripts/main.py
```
You'll now see:
- Black unexplored areas
- Dark blue tint on previously seen areas
- Full brightness only in your field of view
- Smooth exploration as you move through the dungeon

View File

@ -0,0 +1,169 @@
# Part 5: Placing Enemies and Fighting Them
## Overview
Part 5 brings our dungeon to life with enemies! We add rats and spiders that populate the rooms, implement a combat system with melee attacks, and handle entity death by turning creatures into gravestones.
## What's New in Part 5
### Actor System
- **Actor Class**: Extends Entity with combat stats (HP, defense, power)
- **Combat Properties**: Health tracking, damage calculation, alive status
- **Death Handling**: Entities become gravestones when killed
### Enemy Types
Using our sprite sheet, we have two enemy types:
- **Rat** (sprite 5): 10 HP, 0 defense, 3 power - Common enemy
- **Spider** (sprite 4): 16 HP, 1 defense, 4 power - Tougher enemy
### Combat System
#### Bump-to-Attack
When the player tries to move into an enemy:
```python
# In MovementAction.perform()
target = engine.game_map.get_blocking_entity_at(dest_x, dest_y)
if target:
if self.entity == engine.player:
from game.entity import Actor
if isinstance(target, Actor) and target != engine.player:
return MeleeAction(self.entity, self.dx, self.dy).perform(engine)
```
#### Damage Calculation
Simple formula with defense reduction:
```python
damage = attacker.power - target.defense
```
#### Death System
Dead entities become gravestones:
```python
def die(self) -> None:
"""Handle death by becoming a gravestone."""
self.sprite_index = 6 # Tombstone sprite
self.blocks_movement = False
self.name = f"Grave of {self.name}"
```
### Entity Factories
Factory functions create pre-configured entities:
```python
def rat(x: int, y: int, texture: mcrfpy.Texture) -> Actor:
return Actor(
x=x, y=y,
sprite_id=5, # Rat sprite
texture=texture,
name="Rat",
hp=10, defense=0, power=3,
)
```
### Dungeon Population
Enemies are placed randomly in rooms:
```python
def place_entities(room, dungeon, max_monsters, texture):
number_of_monsters = random.randint(0, max_monsters)
for _ in range(number_of_monsters):
x = random.randint(room.x1 + 1, room.x2 - 1)
y = random.randint(room.y1 + 1, room.y2 - 1)
if not any(entity.x == x and entity.y == y for entity in dungeon.entities):
# 80% rats, 20% spiders
if random.random() < 0.8:
monster = entity_factories.rat(x, y, texture)
else:
monster = entity_factories.spider(x, y, texture)
monster.place(x, y, dungeon)
```
## Key Implementation Details
### FOV and Enemy Visibility
Enemies are automatically shown/hidden by the FOV system:
```python
def update_fov(self) -> None:
# Update visibility for all entities
for entity in self.game_map.entities:
entity.update_visibility()
```
### Action System Extension
The action system now handles combat:
- **MovementAction**: Detects collision, triggers attack
- **MeleeAction**: New action for melee combat
- Actions remain decoupled from entity logic
### Gravestone System
Instead of removing dead entities:
- Sprite changes to tombstone (index 6)
- Name changes to "Grave of [Name]"
- No longer blocks movement
- Remains visible as dungeon decoration
## Architecture Notes
### Why Actor Extends Entity?
- Maintains entity hierarchy
- Combat stats only for creatures
- Future items/decorations won't have HP
- Clean separation of concerns
### Why Factory Functions?
- Centralized entity configuration
- Easy to add new enemy types
- Consistent stat management
- Type-safe entity creation
### Combat in Actions
Combat logic lives in actions, not entities:
- Entities store stats
- Actions perform combat
- Clean separation of data and behavior
- Extensible for future combat types
## Files Modified
- `game/entity.py`: Added Actor class with combat stats and death handling
- `game/entity_factories.py`: New module with entity creation functions
- `game/actions.py`: Added MeleeAction for combat
- `game/procgen.py`: Added enemy placement in rooms
- `game/engine.py`: Updated to use Actor type and handle all entity visibility
- `main.py`: Updated to use entity factories and Part 5 description
## What's Next
Part 6 will enhance the combat experience with:
- Health display UI
- Game over conditions
- Combat messages window
- More strategic combat mechanics
## Learning Points
1. **Entity Specialization**: Use inheritance to add features to specific entity types
2. **Factory Pattern**: Centralize object creation for consistency
3. **State Transformation**: Dead entities become decorations, not deletions
4. **Action Extensions**: Combat fits naturally into the action system
5. **Automatic Systems**: FOV handles entity visibility without special code
## Running Part 5
```bash
cd simple_tcod_tutorial/build
./mcrogueface scripts/main.py
```
You'll now encounter rats and spiders as you explore! Walk into them to attack. Dead enemies become gravestones that mark your battles.
## Sprite Adaptations
Following our sprite sheet (`sprite_sheet.md`), we made these thematic changes:
- Orcs → Rats (same stats, different sprite)
- Trolls → Spiders (same stats, different sprite)
- Corpses → Gravestones (all use same tombstone sprite)
The gameplay remains identical to the TCOD tutorial, just with different visual theming.

View File

@ -0,0 +1,187 @@
# Part 6: Doing (and Taking) Damage
## Overview
Part 6 transforms our basic combat into a complete gameplay loop with visual feedback, enemy AI, and win/lose conditions. We add a health bar, message log, enemy AI that pursues the player, and proper game over handling.
## What's New in Part 6
### User Interface Components
#### Health Bar
A visual representation of the player's current health:
```python
class HealthBar:
def create_ui(self) -> List[mcrfpy.UIDrawable]:
# Dark red background
self.background = mcrfpy.Frame(pos=(x, y), size=(width, height))
self.background.fill_color = mcrfpy.Color(100, 0, 0, 255)
# Bright colored bar (green/yellow/red based on HP)
self.bar = mcrfpy.Frame(pos=(x, y), size=(width, height))
# Text overlay showing HP numbers
self.text = mcrfpy.Caption(pos=(x+5, y+2),
text=f"HP: {hp}/{max_hp}")
```
The bar changes color based on health percentage:
- Green (>60% health)
- Yellow (30-60% health)
- Red (<30% health)
#### Message Log
A scrolling combat log that replaces console print statements:
```python
class MessageLog:
def __init__(self, max_messages: int = 5):
self.messages: deque[str] = deque(maxlen=max_messages)
def add_message(self, message: str) -> None:
self.messages.append(message)
self.update_display()
```
Messages include:
- Combat actions ("Rat attacks Player for 3 hit points.")
- Death notifications ("Spider is dead!")
- Game state changes ("You have died! Press Escape to quit.")
### Enemy AI System
#### Basic AI Component
Enemies now actively pursue and attack the player:
```python
class BasicAI:
def take_turn(self, engine: Engine) -> None:
distance = max(abs(dx), abs(dy)) # Chebyshev distance
if distance <= 1:
# Adjacent: Attack!
MeleeAction(self.entity, attack_dx, attack_dy).perform(engine)
elif distance <= 6:
# Can see player: Move closer
MovementAction(self.entity, move_dx, move_dy).perform(engine)
```
#### Turn-Based System
After each player action, all enemies take their turn:
```python
def handle_enemy_turns(self) -> None:
for entity in self.game_map.entities:
if isinstance(entity, Actor) and entity.ai and entity.is_alive:
entity.ai.take_turn(self)
```
### Game Over Condition
When the player dies:
1. Game state flag is set (`engine.game_over = True`)
2. Player becomes a gravestone (sprite changes)
3. Input is restricted (only Escape works)
4. Death message appears in the message log
```python
def handle_player_death(self) -> None:
self.game_over = True
self.message_log.add_message("You have died! Press Escape to quit.")
```
## Architecture Improvements
### UI Module (`game/ui.py`)
Separates UI concerns from game logic:
- `MessageLog`: Manages combat messages
- `HealthBar`: Displays player health
- Clean interface for updating displays
### AI Module (`game/ai.py`)
Encapsulates enemy behavior:
- `BasicAI`: Simple pursue-and-attack behavior
- Extensible for different AI types
- Uses existing action system
### Turn Management
Player actions trigger enemy turns:
- Movement → Enemy turns
- Attack → Enemy turns
- Wait → Enemy turns
- Maintains turn-based feel
## Key Implementation Details
### UI Updates
Health bar updates occur:
- After player takes damage
- Automatically via `engine.update_ui()`
- Color changes based on HP percentage
### Message Flow
Combat messages follow this pattern:
1. Action generates message text
2. `engine.message_log.add_message(text)`
3. Message appears in UI Caption
4. Old messages scroll up
### AI Decision Making
Basic AI uses simple rules:
1. Check if player is adjacent → Attack
2. Check if player is visible (within 6 tiles) → Move toward
3. Otherwise → Do nothing
### Game State Management
The `game_over` flag prevents:
- Player movement
- Player attacks
- Player waiting
- But allows Escape to quit
## Files Modified
- `game/ui.py`: New module for UI components
- `game/ai.py`: New module for enemy AI
- `game/engine.py`: Added UI setup, enemy turns, game over handling
- `game/entity.py`: Added AI component to Actor
- `game/entity_factories.py`: Attached AI to enemies
- `game/actions.py`: Integrated message log, added enemy turn triggers
- `main.py`: Updated part description
## What's Next
Part 7 will expand the user interface further with:
- More detailed entity inspection
- Possibly inventory display
- Additional UI panels
- Mouse interaction
## Learning Points
1. **UI Separation**: Keep UI logic separate from game logic
2. **Component Systems**: AI as a component allows different behaviors
3. **Turn-Based Flow**: Player action → Enemy reactions creates tactical gameplay
4. **Visual Feedback**: Health bars and message logs improve player understanding
5. **State Management**: Game over flag controls available actions
## Running Part 6
```bash
cd simple_tcod_tutorial/build
./mcrogueface scripts/main.py
```
You'll now see:
- Health bar at the top showing your current HP
- Message log at the bottom showing combat events
- Enemies that chase you when you're nearby
- Enemies that attack when adjacent
- Death state when HP reaches 0
## Combat Strategy
With enemy AI active, combat becomes more tactical:
- Enemies pursue when they see you
- Fighting in corridors limits how many can attack
- Running away is sometimes the best option
- Health management becomes critical
The game now has a complete combat loop with clear win/lose conditions!

View File

@ -0,0 +1,204 @@
# Part 7: Creating the User Interface
## Overview
Part 7 significantly enhances the user interface, transforming our roguelike from a basic game into a more polished experience. We add mouse interaction, help displays, information panels, and better visual feedback systems.
## What's New in Part 7
### Mouse Interaction
#### Click-to-Inspect System
Since McRogueFace doesn't have mouse motion events, we use click events to show entity information:
```python
def grid_click_handler(pixel_x, pixel_y, button, state):
# Convert pixel coordinates to grid coordinates
grid_x = int(pixel_x / (self.tile_size * self.zoom))
grid_y = int(pixel_y / (self.tile_size * self.zoom))
# Update hover display for this position
self.update_mouse_hover(grid_x, grid_y)
```
Click displays show:
- Entity names
- Current HP for living creatures
- Multiple entities if stacked (e.g., "Grave of Rat")
#### Mouse Handler Registration
The click handler is registered as a local function to avoid issues with bound methods:
```python
# Use a local function instead of a bound method
self.game_map.click = grid_click_handler
```
### Help System
#### Toggle Help Display
Press `?`, `H`, or `F1` to show/hide help:
```python
class HelpDisplay:
def toggle(self) -> None:
self.visible = not self.visible
self.panel.frame.visible = self.visible
```
The help panel includes:
- Movement controls for all input methods
- Combat instructions
- Mouse usage tips
- Gameplay strategies
### Information Panels
#### Player Stats Panel
Always-visible panel showing:
- Player name
- Current/Max HP
- Power and Defense stats
- Current grid position
```python
class InfoPanel:
def create_ui(self, title: str) -> List[mcrfpy.Drawable]:
# Semi-transparent background frame
self.frame = mcrfpy.Frame(pos=(x, y), size=(width, height))
self.frame.fill_color = mcrfpy.Color(20, 20, 40, 200)
# Title and content captions as children
self.frame.children.append(self.title_caption)
self.frame.children.append(self.content_caption)
```
#### Reusable Panel System
The `InfoPanel` class provides:
- Titled panels with borders
- Semi-transparent backgrounds
- Easy content updates
- Consistent visual style
### Enhanced UI Components
#### MouseHoverDisplay Class
Manages tooltip-style hover information:
- Follows mouse position
- Shows/hides automatically
- Offset to avoid cursor overlap
- Multiple entity support
#### UI Module Organization
Clean separation of UI components:
- `MessageLog`: Combat messages
- `HealthBar`: HP visualization
- `MouseHoverDisplay`: Entity inspection
- `InfoPanel`: Generic information display
- `HelpDisplay`: Keyboard controls
## Architecture Improvements
### UI Composition
Using McRogueFace's parent-child system:
```python
# Add caption as child of frame
self.frame.children.append(self.text_caption)
```
Benefits:
- Automatic relative positioning
- Group visibility control
- Clean hierarchy
### Event Handler Extensions
Input handler now manages:
- Keyboard input (existing)
- Mouse motion (new)
- Mouse clicks (prepared for future)
- UI toggles (help display)
### Dynamic Content Updates
All UI elements support real-time updates:
```python
def update_stats_panel(self) -> None:
stats_text = f"""Name: {self.player.name}
HP: {self.player.hp}/{self.player.max_hp}
Power: {self.player.power}
Defense: {self.player.defense}"""
self.stats_panel.update_content(stats_text)
```
## Key Implementation Details
### Mouse Coordinate Conversion
Pixel to grid conversion:
```python
grid_x = int(x / (self.engine.tile_size * self.engine.zoom))
grid_y = int(y / (self.engine.tile_size * self.engine.zoom))
```
### Visibility Management
UI elements can be toggled:
- Help panel starts hidden
- Mouse hover hides when not over entities
- Panels can be shown/hidden dynamically
### Color and Transparency
UI uses semi-transparent overlays:
- Panel backgrounds: `Color(20, 20, 40, 200)`
- Hover tooltips: `Color(255, 255, 200, 255)`
- Borders and outlines for readability
## Files Modified
- `game/ui.py`: Added MouseHoverDisplay, InfoPanel, HelpDisplay classes
- `game/engine.py`: Integrated new UI components, mouse hover handling
- `game/input_handlers.py`: Added mouse motion handling, help toggle
- `main.py`: Registered mouse handlers, updated part description
## What's Next
Part 8 will add items and inventory:
- Collectible items (potions, equipment)
- Inventory management UI
- Item usage mechanics
- Equipment system
## Learning Points
1. **UI Composition**: Use parent-child relationships for complex UI
2. **Event Delegation**: Separate input handling from UI updates
3. **Information Layers**: Multiple UI systems can coexist (hover, panels, help)
4. **Visual Polish**: Small touches like transparency and borders improve UX
5. **Reusable Components**: Generic panels can be specialized for different uses
## Running Part 7
```bash
cd simple_tcod_tutorial/build
./mcrogueface scripts/main.py
```
New features to try:
- Click on entities to see their details
- Press ? or H to toggle help display
- Watch the stats panel update as you take damage
- See entity HP in hover tooltips
- Notice the visual polish in UI panels
## UI Design Principles
### Consistency
- All panels use similar visual style
- Consistent color scheme
- Uniform text sizing
### Non-Intrusive
- Semi-transparent panels don't block view
- Hover info appears near cursor
- Help can be toggled off
### Information Hierarchy
- Critical info (health) always visible
- Contextual info (hover) on demand
- Help info toggleable
The UI now provides a professional feel while maintaining the roguelike aesthetic!

View File

@ -0,0 +1,297 @@
# Part 8: Items and Inventory
## Overview
Part 8 transforms our roguelike into a proper loot-driven game by adding items that can be collected, managed, and used. We implement a flexible inventory system with capacity limits, create consumable items like healing potions, and build UI for inventory management.
## What's New in Part 8
### Parent-Child Entity Architecture
#### Flexible Entity Ownership
Entities now have parent containers, allowing them to exist in different contexts:
```python
class Entity(mcrfpy.Entity):
def __init__(self, parent: Optional[Union[GameMap, Inventory]] = None):
self.parent = parent
@property
def gamemap(self) -> Optional[GameMap]:
"""Get the GameMap through the parent chain"""
if isinstance(self.parent, Inventory):
return self.parent.gamemap
return self.parent
```
Benefits:
- Items can exist in the world or in inventories
- Clean ownership transfer when picking up/dropping
- Automatic visibility management
### Inventory System
#### Container-Based Design
The inventory acts like a specialized entity container:
```python
class Inventory:
def __init__(self, capacity: int):
self.capacity = capacity
self.items: List[Item] = []
self.parent: Optional[Actor] = None
def add_item(self, item: Item) -> None:
if len(self.items) >= self.capacity:
raise Impossible("Your inventory is full.")
# Transfer ownership
self.items.append(item)
item.parent = self
item.visible = False # Hide from map
```
Features:
- Capacity limits (26 items for letter selection)
- Clean item transfer between world and inventory
- Automatic visual management
### Item System
#### Item Entity Class
Items are entities with consumable components:
```python
class Item(Entity):
def __init__(self, consumable: Optional = None):
super().__init__(blocks_movement=False)
self.consumable = consumable
if consumable:
consumable.parent = self
```
#### Consumable Components
Modular system for item effects:
```python
class HealingConsumable(Consumable):
def activate(self, action: ItemAction) -> None:
if consumer.hp >= consumer.max_hp:
raise Impossible("You are already at full health.")
amount_recovered = min(self.amount, consumer.max_hp - consumer.hp)
consumer.hp += amount_recovered
self.consume() # Remove item after use
```
### Exception-Driven Feedback
#### Clean Error Handling
Using exceptions for user feedback:
```python
class Impossible(Exception):
"""Action cannot be performed"""
pass
class PickupAction(Action):
def perform(self, engine: Engine) -> None:
if not items_here:
raise Impossible("There is nothing here to pick up.")
try:
inventory.add_item(item)
engine.message_log.add_message(f"You picked up the {item.name}!")
except Impossible as e:
engine.message_log.add_message(str(e))
```
Benefits:
- Consistent error messaging
- Clean control flow
- Centralized feedback handling
### Inventory UI
#### Modal Inventory Screen
Interactive inventory management:
```python
class InventoryEventHandler(BaseEventHandler):
def create_ui(self) -> None:
# Semi-transparent background
self.background = mcrfpy.Frame(pos=(100, 100), size=(400, 400))
self.background.fill_color = mcrfpy.Color(0, 0, 0, 200)
# List items with letter keys
for i, item in enumerate(inventory.items):
item_caption = mcrfpy.Caption(
pos=(20, 80 + i * 20),
text=f"{chr(ord('a') + i)}) {item.name}"
)
```
Features:
- Letter-based selection (a-z)
- Separate handlers for use/drop
- ESC to cancel
- Visual feedback
### Enhanced Actions
#### Item Actions
New actions for item management:
```python
class PickupAction(Action):
"""Pick up items at current location"""
class ItemAction(Action):
"""Base for item usage actions"""
class DropAction(ItemAction):
"""Drop item from inventory"""
```
Each action:
- Self-validates
- Provides feedback
- Triggers enemy turns
## Architecture Improvements
### Component Relationships
Parent-based component system:
```python
# Components know their parent
consumable.parent = item
item.parent = inventory
inventory.parent = actor
actor.parent = gamemap
gamemap.engine = engine
```
Benefits:
- Access to game context from any component
- Clean ownership transfer
- Simplified entity lifecycle
### Input Handler States
Modal UI through handler switching:
```python
# Main game
engine.current_handler = MainGameEventHandler(engine)
# Open inventory
engine.current_handler = InventoryActivateHandler(engine)
# Back to game
engine.current_handler = MainGameEventHandler(engine)
```
### Entity Lifecycle Management
Proper creation and cleanup:
```python
# Item spawning
item = entity_factories.health_potion(x, y, texture)
item.place(x, y, dungeon)
# Pickup
inventory.add_item(item) # Removes from map
# Drop
inventory.drop(item) # Returns to map
# Death
actor.die() # Drops all items
```
## Key Implementation Details
### Visibility Management
Items hide/show based on container:
```python
def add_item(self, item):
item.visible = False # Hide when in inventory
def drop(self, item):
item.visible = True # Show when on map
```
### Inventory Capacity
Limited to alphabet keys:
```python
if len(inventory.items) >= 26:
raise Impossible("Your inventory is full.")
```
### Item Generation
Procedural item placement:
```python
def place_entities(room, dungeon, max_monsters, max_items, texture):
# Place 0-2 items per room
number_of_items = random.randint(0, max_items)
for _ in range(number_of_items):
if space_available:
item = entity_factories.health_potion(x, y, texture)
item.place(x, y, dungeon)
```
## Files Modified
- `game/entity.py`: Added parent system, Item class, inventory to Actor
- `game/inventory.py`: New inventory container system
- `game/consumable.py`: New consumable component system
- `game/exceptions.py`: New Impossible exception
- `game/actions.py`: Added PickupAction, ItemAction, DropAction
- `game/input_handlers.py`: Added InventoryEventHandler classes
- `game/engine.py`: Added current_handler, inventory UI methods
- `game/procgen.py`: Added item generation
- `game/entity_factories.py`: Added health_potion factory
- `game/ui.py`: Updated help text with inventory controls
- `main.py`: Updated to Part 8, handler management
## What's Next
Part 9 will add ranged attacks and targeting:
- Targeting UI for selecting enemies
- Ranged damage items (lightning staff)
- Area-of-effect items (fireball staff)
- Confusion effects
## Learning Points
1. **Container Architecture**: Entity ownership through parent relationships
2. **Component Systems**: Modular, reusable components with parent references
3. **Exception Handling**: Clean error propagation and user feedback
4. **Modal UI**: State-based input handling for different screens
5. **Item Systems**: Flexible consumable architecture for varied effects
6. **Lifecycle Management**: Proper entity creation, transfer, and cleanup
## Running Part 8
```bash
cd simple_tcod_tutorial/build
./mcrogueface scripts/main.py
```
New features to try:
- Press G to pick up healing potions
- Press I to open inventory and use items
- Press O to drop items from inventory
- Heal yourself when injured in combat
- Manage limited inventory space (26 slots)
- Items drop from dead enemies
## Design Principles
### Flexibility Through Composition
- Items gain behavior through consumable components
- Easy to add new item types
- Reusable effect system
### Clean Ownership Transfer
- Entities always have clear parent
- Automatic visibility management
- No orphaned entities
### User-Friendly Feedback
- Clear error messages
- Consistent UI patterns
- Intuitive controls
The inventory system provides the foundation for equipment, spells, and complex item interactions in future parts!

View File

@ -0,0 +1,625 @@
"""
McRogueFace Tutorial - Part 5: Entity Interactions
This tutorial builds on Part 4 by adding:
- Entity class hierarchy (PlayerEntity, EnemyEntity, BoulderEntity, ButtonEntity)
- Non-blocking movement animations with destination tracking
- Bump interactions (combat, pushing)
- Step-on interactions (buttons, doors)
- Concurrent enemy AI with smooth animations
Key concepts:
- Entities inherit from mcrfpy.Entity for proper C++/Python integration
- Logic operates on destination positions during animations
- Player input is processed immediately, not blocked by animations
"""
import mcrfpy
import random
# ============================================================================
# Entity Classes - Inherit from mcrfpy.Entity
# ============================================================================
class GameEntity(mcrfpy.Entity):
"""Base class for all game entities with interaction logic"""
def __init__(self, x, y, **kwargs):
# Extract grid before passing to parent
grid = kwargs.pop('grid', None)
super().__init__(x=x, y=y, **kwargs)
# Current position is tracked by parent Entity.x/y
# Add destination tracking for animation system
self.dest_x = x
self.dest_y = y
self.is_moving = False
# Game properties
self.blocks_movement = True
self.hp = 10
self.max_hp = 10
self.entity_type = "generic"
# Add to grid if provided
if grid:
grid.entities.append(self)
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
# Create animations for smooth movement
if callback:
# Only x animation needs callback since they run in parallel
anim_x = mcrfpy.Animation("x", float(new_x), duration, "easeInOutQuad", callback=callback)
else:
anim_x = mcrfpy.Animation("x", float(new_x), duration, "easeInOutQuad")
anim_y = mcrfpy.Animation("y", float(new_y), duration, "easeInOutQuad")
anim_x.start(self)
anim_y.start(self)
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):
"""Called when another entity tries to move into our space"""
return False # Block movement by default
def on_step(self, other):
"""Called when another entity steps on us (non-blocking)"""
pass
def take_damage(self, damage):
"""Apply damage and handle death"""
self.hp -= damage
if self.hp <= 0:
self.hp = 0
self.die()
def die(self):
"""Remove entity from grid"""
# The C++ die() method handles removal from grid
super().die()
class PlayerEntity(GameEntity):
"""The player character"""
def __init__(self, x, y, **kwargs):
kwargs['sprite_index'] = 64 # Hero sprite
super().__init__(x=x, y=y, **kwargs)
self.damage = 3
self.entity_type = "player"
self.blocks_movement = True
def on_bump(self, other):
"""Player bumps into something"""
if other.entity_type == "enemy":
# Deal damage
other.take_damage(self.damage)
return False # Can't move into enemy space
elif other.entity_type == "boulder":
# Try to push
dx = self.dest_x - int(self.x)
dy = self.dest_y - int(self.y)
return other.try_push(dx, dy)
return False
class EnemyEntity(GameEntity):
"""Basic enemy with AI"""
def __init__(self, x, y, **kwargs):
kwargs['sprite_index'] = 65 # Enemy sprite
super().__init__(x=x, y=y, **kwargs)
self.damage = 1
self.entity_type = "enemy"
self.ai_state = "wander"
self.hp = 5
self.max_hp = 5
def on_bump(self, other):
"""Enemy bumps into something"""
if other.entity_type == "player":
other.take_damage(self.damage)
return False
return False
def can_see_player(self, player_pos, grid):
"""Check if enemy can see the player position"""
# Simple check: within 6 tiles and has line of sight
mx, my = self.get_position()
px, py = player_pos
dist = abs(px - mx) + abs(py - my)
if dist > 6:
return False
# Use libtcod for line of sight
line = list(mcrfpy.libtcod.line(mx, my, px, py))
if len(line) > 7: # Too far
return False
for x, y in line[1:-1]: # Skip start and end points
cell = grid.at(x, y)
if cell and not cell.transparent:
return False
return True
def ai_turn(self, grid, player):
"""Decide next move"""
px, py = player.get_position()
mx, my = self.get_position()
# Simple AI: move toward player if visible
if self.can_see_player((px, py), grid):
# 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 movement
dx, dy = random.choice([(0,1), (0,-1), (1,0), (-1,0)])
return (mx + dx, my + dy)
class BoulderEntity(GameEntity):
"""Pushable boulder"""
def __init__(self, x, y, **kwargs):
kwargs['sprite_index'] = 7 # Boulder sprite
super().__init__(x=x, y=y, **kwargs)
self.entity_type = "boulder"
self.pushable = True
def try_push(self, dx, dy):
"""Attempt to push boulder in direction"""
new_x = int(self.x) + dx
new_y = int(self.y) + dy
# Check if destination is free
if can_move_to(new_x, new_y):
self.start_move(new_x, new_y)
return True
return False
class ButtonEntity(GameEntity):
"""Pressure plate that triggers when stepped on"""
def __init__(self, x, y, target=None, **kwargs):
kwargs['sprite_index'] = 8 # Button sprite
super().__init__(x=x, y=y, **kwargs)
self.blocks_movement = False # Can be walked over
self.entity_type = "button"
self.pressed = False
self.pressed_by = set() # Track who's pressing
self.target = target # Door or other triggerable
def on_step(self, other):
"""Activate when stepped on"""
if other not in self.pressed_by:
self.pressed_by.add(other)
if not self.pressed:
self.pressed = True
self.sprite_index = 9 # Pressed sprite
if self.target:
self.target.activate()
def on_leave(self, other):
"""Deactivate when entity leaves"""
if other in self.pressed_by:
self.pressed_by.remove(other)
if len(self.pressed_by) == 0 and self.pressed:
self.pressed = False
self.sprite_index = 8 # Unpressed sprite
if self.target:
self.target.deactivate()
class DoorEntity(GameEntity):
"""Door that can be opened by buttons"""
def __init__(self, x, y, **kwargs):
kwargs['sprite_index'] = 3 # Closed door sprite
super().__init__(x=x, y=y, **kwargs)
self.entity_type = "door"
self.is_open = False
def activate(self):
"""Open the door"""
self.is_open = True
self.blocks_movement = False
self.sprite_index = 11 # Open door sprite
def deactivate(self):
"""Close the door"""
self.is_open = False
self.blocks_movement = True
self.sprite_index = 3 # Closed door sprite
# ============================================================================
# Global Game State
# ============================================================================
# Create and activate a new scene
mcrfpy.createScene("tutorial")
mcrfpy.setScene("tutorial")
# Load the texture
texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16)
# Create a grid of tiles
grid_width, grid_height = 40, 30
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
# Create the grid
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]
# Game state
player = None
enemies = []
all_entities = []
is_player_turn = True
move_duration = 0.2
# ============================================================================
# Dungeon Generation (from Part 3)
# ============================================================================
class Room:
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
def center(self):
return ((self.x1 + self.x2) // 2, (self.y1 + self.y2) // 2)
def intersects(self, other):
return (self.x1 <= other.x2 and self.x2 >= other.x1 and
self.y1 <= other.y2 and self.y2 >= other.y1)
def create_room(room):
"""Carve out a room in the grid"""
for x in range(room.x1 + 1, room.x2):
for y in range(room.y1 + 1, room.y2):
cell = grid.at(x, y)
if cell:
cell.walkable = True
cell.transparent = True
cell.tilesprite = random.choice(FLOOR_TILES)
def create_l_shaped_hallway(x1, y1, x2, y2):
"""Create L-shaped hallway between two points"""
corner_x = x2
corner_y = y1
if random.random() < 0.5:
corner_x = x1
corner_y = y2
for x, y in mcrfpy.libtcod.line(x1, y1, corner_x, corner_y):
cell = grid.at(x, y)
if cell:
cell.walkable = True
cell.transparent = True
cell.tilesprite = random.choice(FLOOR_TILES)
for x, y in mcrfpy.libtcod.line(corner_x, corner_y, x2, y2):
cell = grid.at(x, y)
if cell:
cell.walkable = True
cell.transparent = True
cell.tilesprite = random.choice(FLOOR_TILES)
def generate_dungeon():
"""Generate a simple dungeon with rooms and hallways"""
# Initialize all cells as walls
for x in range(grid_width):
for y in range(grid_height):
cell = grid.at(x, y)
if cell:
cell.walkable = False
cell.transparent = False
cell.tilesprite = random.choice(WALL_TILES)
rooms = []
num_rooms = 0
for _ in range(30):
w = random.randint(4, 8)
h = random.randint(4, 8)
x = random.randint(0, grid_width - w - 1)
y = random.randint(0, grid_height - h - 1)
new_room = Room(x, y, w, h)
# Check if room intersects with existing rooms
if any(new_room.intersects(other_room) for other_room in rooms):
continue
create_room(new_room)
if num_rooms > 0:
# Connect to previous room
new_x, new_y = new_room.center()
prev_x, prev_y = rooms[num_rooms - 1].center()
create_l_shaped_hallway(prev_x, prev_y, new_x, new_y)
rooms.append(new_room)
num_rooms += 1
return rooms
# ============================================================================
# Entity Management
# ============================================================================
def get_entities_at(x, y):
"""Get all entities at a specific position (including moving ones)"""
entities = []
for entity in all_entities:
ex, ey = entity.get_position()
if ex == x and ey == y:
entities.append(entity)
return entities
def get_blocking_entity_at(x, y):
"""Get the first blocking entity at position"""
for entity in get_entities_at(x, y):
if entity.blocks_movement:
return entity
return None
def can_move_to(x, y):
"""Check if a position is valid for movement"""
if x < 0 or x >= grid_width or y < 0 or y >= grid_height:
return False
cell = grid.at(x, y)
if not cell or not cell.walkable:
return False
# Check for blocking entities
if get_blocking_entity_at(x, y):
return False
return True
def can_entity_move_to(entity, x, y):
"""Check if specific entity can move to position"""
if x < 0 or x >= grid_width or y < 0 or y >= grid_height:
return False
cell = grid.at(x, y)
if not cell or not cell.walkable:
return False
# Check for other blocking entities (not self)
blocker = get_blocking_entity_at(x, y)
if blocker and blocker != entity:
return False
return True
# ============================================================================
# Turn Management
# ============================================================================
def process_player_move(key):
"""Handle player input with immediate response"""
global is_player_turn
if not is_player_turn or player.is_moving:
return # Not player's turn or still animating
px, py = player.get_position()
new_x, new_y = px, py
# Calculate movement direction
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
else:
return # Not a movement key
if new_x == px and new_y == py:
return # No movement
# Check what's at destination
cell = grid.at(new_x, new_y)
if not cell or not cell.walkable:
return # Can't move into walls
blocking_entity = get_blocking_entity_at(new_x, new_y)
if blocking_entity:
# Try bump interaction
if not player.on_bump(blocking_entity):
# Movement blocked, but turn still happens
is_player_turn = False
mcrfpy.setTimer("enemy_turn", process_enemy_turns, 50)
return
# Movement is valid - start player animation
is_player_turn = False
player.start_move(new_x, new_y, duration=move_duration, callback=player_move_complete)
# Update grid center to follow player
grid_anim_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, move_duration, "linear")
grid_anim_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, move_duration, "linear")
grid_anim_x.start(grid)
grid_anim_y.start(grid)
# Start enemy turns after a short delay (so player sees their move start first)
mcrfpy.setTimer("enemy_turn", process_enemy_turns, 50)
def process_enemy_turns(timer_name):
"""Process all enemy AI decisions and start their animations"""
enemies_to_move = []
for enemy in enemies:
if enemy.hp <= 0: # Skip dead enemies
continue
if enemy.is_moving:
continue # Skip if still animating
# AI decides next move based on player's destination
target_x, target_y = enemy.ai_turn(grid, player)
# Check if move is valid
cell = grid.at(target_x, target_y)
if not cell or not cell.walkable:
continue
# Check what's at the destination
blocking_entity = get_blocking_entity_at(target_x, target_y)
if blocking_entity and blocking_entity != enemy:
# Try bump interaction
enemy.on_bump(blocking_entity)
# Enemy doesn't move but still took its turn
else:
# Valid move - add to list
enemies_to_move.append((enemy, target_x, target_y))
# Start all enemy animations simultaneously
for enemy, tx, ty in enemies_to_move:
enemy.start_move(tx, ty, duration=move_duration)
def player_move_complete(anim, entity):
"""Called when player animation finishes"""
global is_player_turn
player.is_moving = False
# Check for step-on interactions at new position
for entity in get_entities_at(int(player.x), int(player.y)):
if entity != player and not entity.blocks_movement:
entity.on_step(player)
# Update FOV from new position
update_fov()
# Player's turn is ready again
is_player_turn = True
def update_fov():
"""Update field of view from player position"""
px, py = int(player.x), int(player.y)
grid.compute_fov(px, py, radius=8)
player.update_visibility()
# ============================================================================
# Input Handling
# ============================================================================
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_player_move(key)
# Register the key handler
mcrfpy.keypressScene(handle_keys)
# ============================================================================
# Initialize Game
# ============================================================================
# Generate dungeon
rooms = generate_dungeon()
# Place player in first room
if rooms:
start_x, start_y = rooms[0].center()
player = PlayerEntity(start_x, start_y, grid=grid)
all_entities.append(player)
# Place enemies in other rooms
for i in range(1, min(6, len(rooms))):
room = rooms[i]
ex, ey = room.center()
enemy = EnemyEntity(ex, ey, grid=grid)
enemies.append(enemy)
all_entities.append(enemy)
# Place some boulders
for i in range(3):
room = random.choice(rooms[1:])
bx = random.randint(room.x1 + 1, room.x2 - 1)
by = random.randint(room.y1 + 1, room.y2 - 1)
if can_move_to(bx, by):
boulder = BoulderEntity(bx, by, grid=grid)
all_entities.append(boulder)
# Place a button and door in one of the rooms
if len(rooms) > 2:
button_room = rooms[-2]
door_room = rooms[-1]
# Place door at entrance to last room
dx, dy = door_room.center()
door = DoorEntity(dx, door_room.y1, grid=grid)
all_entities.append(door)
# Place button in second to last room
bx, by = button_room.center()
button = ButtonEntity(bx, by, target=door, grid=grid)
all_entities.append(button)
# Set grid perspective to player
grid.perspective = player
grid.center_x = (start_x + 0.5) * 16
grid.center_y = (start_y + 0.5) * 16
# Initial FOV calculation
update_fov()
# Add grid to scene
mcrfpy.sceneUI("tutorial").append(grid)
# Show instructions
title = mcrfpy.Caption((320, 10),
text="Part 5: Entity Interactions - WASD to move, bump enemies, push boulders!",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
print("Part 5: Entity Interactions - Tutorial loaded!")
print("- Bump into enemies to attack them")
print("- Push boulders by walking into them")
print("- Step on buttons to open doors")
print("- Enemies will pursue you when they can see you")

View File

@ -0,0 +1,313 @@
"""
McRogueFace Tutorial - Part 3: Procedural Dungeon Generation
This tutorial builds on Part 2 by adding:
- Binary Space Partition (BSP) dungeon generation
- Rooms connected by hallways using libtcod.line()
- Walkable/non-walkable terrain
- Player spawning in a valid location
- Wall tiles that block movement
Key code references:
- src/scripts/cos_level.py (lines 7-15, 184-217, 218-224) - BSP algorithm
- mcrfpy.libtcod.line() for smooth hallway generation
"""
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 # Larger grid for dungeon
# Calculate the size in pixels to fit the entire grid on-screen
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):
"""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)
def intersects(self, other):
"""Return True if this room overlaps with another"""
return (self.x1 <= other.x2 and self.x2 >= other.x1 and
self.y1 <= other.y2 and self.y2 >= other.y1)
# Dungeon generation functions
def carve_room(room):
"""Carve out a room in the grid - referenced from cos_level.py lines 117-120"""
# Using individual updates for now (batch updates would be more efficient)
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):
"""Carve a hallway between two points using libtcod.line()
Referenced from cos_level.py lines 184-217, improved with libtcod.line()
"""
# Get all points along the line
# Simple solution: works if your characters have diagonal movement
#points = mcrfpy.libtcod.line(x1, y1, x2, y2)
# We don't, so we're going to carve a path with an elbow in it
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))
# Carve out each point
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):
"""Generate a dungeon using simplified BSP approach
Referenced from cos_level.py lines 218-224
"""
rooms = []
# First, fill everything 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):
# Random room size
w = random.randint(room_min_size, room_max_size)
h = random.randint(room_min_size, room_max_size)
# Random position (with margin from edges)
x = random.randint(1, grid_width - w - 1)
y = random.randint(1, grid_height - h - 1)
new_room = Room(x, y, w, h)
# Check if it overlaps with existing rooms
failed = False
for other_room in rooms:
if new_room.intersects(other_room):
failed = True
break
if not failed:
# Carve out the room
carve_room(new_room)
# If not the first room, connect to previous room
if rooms:
# Get centers
prev_x, prev_y = rooms[-1].center()
new_x, new_y = new_room.center()
# Carve hallway using libtcod.line()
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:
# Fallback spawn position
spawn_x, spawn_y = 4, 4
# Create a player entity at the spawn position
player = mcrfpy.Entity(
(spawn_x, spawn_y),
texture=hero_texture,
sprite_index=0
)
# Add the player entity to the grid
grid.entities.append(player)
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16
# Movement state tracking (from Part 2)
is_moving = False
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 is_moving, move_queue, current_destination, current_move
global player_anim_x, player_anim_y
is_moving = False
current_move = None
current_destination = None
player_anim_x = None
player_anim_y = None
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16
if move_queue:
next_move = move_queue.pop(0)
process_move(next_move)
motion_speed = 0.20 # Slightly faster for dungeon exploration
def can_move_to(x, y):
"""Check if a position is valid for movement"""
# Boundary check
if x < 0 or x >= grid_width or y < 0 or y >= grid_height:
return False
# Walkability check
point = grid.at(x, y)
if point and point.walkable:
return True
return False
def process_move(key):
"""Process a move based on the key"""
global is_moving, current_move, current_destination, move_queue
global player_anim_x, player_anim_y, grid_anim_x, grid_anim_y
if 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
# Check if we can move to the new position
if new_x != px or new_y != py:
if can_move_to(new_x, new_y):
is_moving = True
current_move = key
current_destination = (new_x, new_y)
if new_x != px:
player_anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_x.start(player)
elif new_y != py:
player_anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_y.start(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:
# Play a "bump" sound or visual feedback here
print(f"Can't move to ({new_x}, {new_y}) - blocked!")
def handle_keys(key, state):
"""Handle keyboard input to move the player"""
if state == "start":
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 3: Dungeon Generation",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
instructions = mcrfpy.Caption((150, 750),
text=f"Procedural dungeon with {len(rooms)} rooms connected by hallways!",
)
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} | Player spawned at ({spawn_x}, {spawn_y})",
)
debug_caption.font_size = 16
debug_caption.fill_color = mcrfpy.Color(255, 255, 0, 255)
mcrfpy.sceneUI("tutorial").append(debug_caption)
print("Tutorial Part 3 loaded!")
print(f"Generated dungeon with {len(rooms)} rooms")
print(f"Player spawned at ({spawn_x}, {spawn_y})")
print("Walls now block movement!")
print("Use WASD or Arrow keys to explore the dungeon!")

View File

@ -0,0 +1,366 @@
"""
McRogueFace Tutorial - Part 4: Field of View
This tutorial builds on Part 3 by adding:
- Field of view calculation using grid.compute_fov()
- Entity perspective rendering with grid.perspective
- Three visibility states: unexplored (black), explored (dark), visible (lit)
- Memory of previously seen areas
- Enemy entity to demonstrate perspective switching
Key code references:
- tests/unit/test_tcod_fov_entities.py (lines 89-118) - FOV with multiple entities
- ROADMAP.md (lines 216-229) - FOV system implementation details
"""
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
# Create a player entity
player = mcrfpy.Entity(
(spawn_x, spawn_y),
texture=hero_texture,
sprite_index=0
)
# Add the player entity to the grid
grid.entities.append(player)
# Create an enemy entity in another room (to demonstrate perspective switching)
enemy = None
if len(rooms) > 1:
enemy_x, enemy_y = rooms[1].center()
enemy = mcrfpy.Entity(
(enemy_x, enemy_y),
texture=hero_texture,
sprite_index=0 # Enemy sprite
)
grid.entities.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
Referenced from test_tcod_fov_entities.py lines 89-118
"""
if grid.perspective == player:
grid.compute_fov(int(player.x), int(player.y), radius=8, algorithm=0)
player.update_visibility()
elif enemy and grid.perspective == enemy:
grid.compute_fov(int(enemy.x), int(enemy.y), radius=6, algorithm=0)
enemy.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
elif enemy and grid.perspective == enemy:
grid.center = (enemy.x + 0.5) * 16, (enemy.y + 0.5) * 16
center_on_perspective()
# Movement state tracking (from Part 3)
is_moving = False
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 is_moving, move_queue, current_destination, current_move
global player_anim_x, player_anim_y
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()
if move_queue:
next_move = move_queue.pop(0)
process_move(next_move)
motion_speed = 0.20
def can_move_to(x, y):
"""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 point and point.walkable:
return True
return False
def process_move(key):
"""Process a move based on the key"""
global is_moving, current_move, current_destination, move_queue
global player_anim_x, player_anim_y, grid_anim_x, grid_anim_y
# Only allow player movement when in player perspective
if grid.perspective != player:
return
if 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:
if can_move_to(new_x, new_y):
is_moving = True
current_move = key
current_destination = (new_x, new_y)
if new_x != px:
player_anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_x.start(player)
elif new_y != py:
player_anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_y.start(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)
# Perspective switching
elif key == "Tab":
# Switch perspective between player and enemy
if enemy:
if grid.perspective == player:
grid.perspective = enemy
print("Switched to enemy perspective")
else:
grid.perspective = player
print("Switched to player perspective")
# Update FOV and camera for new perspective
update_fov()
center_on_perspective()
# Register the keyboard handler
mcrfpy.keypressScene(handle_keys)
# Add UI elements
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 4: Field of View",
)
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. Press Tab to switch perspective!",
)
instructions.font_size = 18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
# FOV info
fov_caption = mcrfpy.Caption((150, 745),
text="FOV: Player (radius 8) | Enemy visible in other room",
)
fov_caption.font_size = 16
fov_caption.fill_color = mcrfpy.Color(100, 200, 255, 255)
mcrfpy.sceneUI("tutorial").append(fov_caption)
# Debug info
debug_caption = mcrfpy.Caption((10, 40),
text=f"Grid: {grid_width}x{grid_height} | Rooms: {len(rooms)} | Perspective: Player",
)
debug_caption.font_size = 16
debug_caption.fill_color = mcrfpy.Color(255, 255, 0, 255)
mcrfpy.sceneUI("tutorial").append(debug_caption)
# Update function for perspective display
def update_perspective_display():
current_perspective = "Player" if grid.perspective == player else "Enemy"
debug_caption.text = f"Grid: {grid_width}x{grid_height} | Rooms: {len(rooms)} | Perspective: {current_perspective}"
if grid.perspective == player:
fov_caption.text = "FOV: Player (radius 8) | Tab to switch perspective"
else:
fov_caption.text = "FOV: Enemy (radius 6) | Tab to switch perspective"
# Timer to update display
def update_display(runtime):
update_perspective_display()
mcrfpy.setTimer("display_update", update_display, 100)
print("Tutorial Part 4 loaded!")
print("Field of View system active!")
print("- Unexplored areas are black")
print("- Previously seen areas are dark")
print("- Currently visible areas are lit")
print("Press Tab to switch between player and enemy perspective!")
print("Use WASD or Arrow keys to move!")

View File

@ -0,0 +1,363 @@
"""
McRogueFace Tutorial - Part 5: Interacting with other entities
This tutorial builds on Part 4 by adding:
- Subclassing mcrfpy.Entity
- Non-blocking movement animations with destination tracking
- Bump interactions (combat, pushing)
"""
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 BumpableEntity(GameEntity):
def __init__(self, x, y, **kwargs):
super().__init__(x, y, **kwargs)
def on_bump(self, other):
print(f"Watch it, {other}! You bumped into {self}!")
return False
# Create a player entity
player = GameEntity(
spawn_x, spawn_y,
texture=hero_texture,
sprite_index=0
)
# Add the player entity to the grid
grid.entities.append(player)
for r in rooms:
enemy_x, enemy_y = r.center()
enemy = BumpableEntity(
enemy_x, enemy_y,
grid=grid,
texture=hero_texture,
sprite_index=0 # Enemy sprite
)
# 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
Referenced from test_tcod_fov_entities.py lines 89-118
"""
if grid.perspective == player:
grid.compute_fov(int(player.x), int(player.y), radius=8, algorithm=0)
player.update_visibility()
elif enemy and grid.perspective == enemy:
grid.compute_fov(int(enemy.x), int(enemy.y), radius=6, algorithm=0)
enemy.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
elif enemy and grid.perspective == enemy:
grid.center = (enemy.x + 0.5) * 16, (enemy.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
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()
if move_queue:
next_move = move_queue.pop(0)
process_move(next_move)
motion_speed = 0.20
def can_move_to(x, y):
"""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 point and point.walkable:
for e in grid.entities:
if not e.walkable and (x, y) == e.get_position(): # blocking the way
e.on_bump(player)
return False
return True # all checks passed, no collision
return False
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
# 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:
if can_move_to(new_x, new_y):
player.is_moving = True
current_move = key
current_destination = (new_x, new_y)
if new_x != px:
player_anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_x.start(player)
elif new_y != py:
player_anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_y.start(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 5: Entity Collision",
)
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. Try to bump into the other entity!",
)
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)} | Perspective: Player",
)
debug_caption.font_size = 16
debug_caption.fill_color = mcrfpy.Color(255, 255, 0, 255)
mcrfpy.sceneUI("tutorial").append(debug_caption)
# Update function for perspective display
def update_perspective_display():
current_perspective = "Player" if grid.perspective == player else "Enemy"
debug_caption.text = f"Grid: {grid_width}x{grid_height} | Rooms: {len(rooms)} | Perspective: {current_perspective}"
# Timer to update display
def update_display(runtime):
update_perspective_display()
mcrfpy.setTimer("display_update", update_display, 100)
print("Tutorial Part 4 loaded!")
print("Field of View system active!")
print("- Unexplored areas are black")
print("- Previously seen areas are dark")
print("- Currently visible areas are lit")
print("Press Tab to switch between player and enemy perspective!")
print("Use WASD or Arrow keys to move!")

View File

@ -0,0 +1,645 @@
"""
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)
def ai_turn_dijkstra(self):
"""Decide next move using precomputed Dijkstra map"""
mx, my = self.get_position()
# Get current distance to player
current_dist = grid.get_dijkstra_distance(mx, my)
if current_dist is None or current_dist > 20:
# Too far or unreachable - random wander
dx, dy = random.choice([(0,1), (0,-1), (1,0), (-1,0)])
return (mx + dx, my + dy)
# Check all adjacent cells for best move
best_moves = []
for dx, dy in [(0,1), (0,-1), (1,0), (-1,0)]:
nx, ny = mx + dx, my + dy
# Skip if out of bounds
if nx < 0 or nx >= grid_width or ny < 0 or ny >= grid_height:
continue
# Skip if not walkable
cell = grid.at(nx, ny)
if not cell or not cell.walkable:
continue
# Get distance from this cell
dist = grid.get_dijkstra_distance(nx, ny)
if dist is not None:
best_moves.append((dist, nx, ny))
if best_moves:
# Sort by distance
best_moves.sort()
# If multiple moves have the same best distance, pick randomly
best_dist = best_moves[0][0]
equal_moves = [(nx, ny) for dist, nx, ny in best_moves if dist == best_dist]
if len(equal_moves) > 1:
# Random choice among equally good moves
nx, ny = random.choice(equal_moves)
else:
_, nx, ny = best_moves[0]
return (nx, ny)
else:
# No valid moves
return (mx, my)
# 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
# Compute Dijkstra map once for all enemies (if using Dijkstra)
if USE_DIJKSTRA:
px, py = player.get_position()
grid.compute_dijkstra(px, py, diagonal_cost=1.41)
enemies_to_move = []
claimed_positions = set() # Track where enemies plan to move
# Collect all enemy moves
for i, enemy in enumerate(enemies):
if enemy.is_dead():
continue
# AI decides next move
if USE_DIJKSTRA:
target_x, target_y = enemy.ai_turn_dijkstra()
else:
target_x, target_y = enemy.ai_turn(player.get_position())
# Check if move is valid and not claimed by another enemy
if can_move_to(target_x, target_y, enemy) and (target_x, target_y) not in claimed_positions:
enemies_to_move.append((enemy, target_x, target_y))
claimed_positions.add((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)}"
# Configuration toggle
USE_DIJKSTRA = True # Set to False to use old line-of-sight AI
# 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(f"Using {'Dijkstra' if USE_DIJKSTRA else 'Line-of-sight'} AI pathfinding")
print("- Enemies move after the player")
print("- Enemies pursue when they can see you" if not USE_DIJKSTRA else "- Enemies use optimal pathfinding")
print("- Enemies wander when they can't" if not USE_DIJKSTRA else "- All enemies share one pathfinding map")
print("Use WASD or Arrow keys to move!")

View File

@ -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!")

View File

@ -138,47 +138,67 @@ PyObject* PyAnimation::start(PyAnimationObject* self, PyObject* args) {
return NULL;
}
// Check type by comparing type names
const char* type_name = Py_TYPE(target_obj)->tp_name;
// Get type objects from the module to ensure they're initialized
PyObject* frame_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Frame");
PyObject* caption_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Caption");
PyObject* sprite_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Sprite");
PyObject* grid_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Grid");
PyObject* entity_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity");
if (strcmp(type_name, "mcrfpy.Frame") == 0) {
bool handled = false;
// Use PyObject_IsInstance to support inheritance
if (frame_type && PyObject_IsInstance(target_obj, frame_type)) {
PyUIFrameObject* frame = (PyUIFrameObject*)target_obj;
if (frame->data) {
self->data->start(frame->data);
AnimationManager::getInstance().addAnimation(self->data);
handled = true;
}
}
else if (strcmp(type_name, "mcrfpy.Caption") == 0) {
else if (caption_type && PyObject_IsInstance(target_obj, caption_type)) {
PyUICaptionObject* caption = (PyUICaptionObject*)target_obj;
if (caption->data) {
self->data->start(caption->data);
AnimationManager::getInstance().addAnimation(self->data);
handled = true;
}
}
else if (strcmp(type_name, "mcrfpy.Sprite") == 0) {
else if (sprite_type && PyObject_IsInstance(target_obj, sprite_type)) {
PyUISpriteObject* sprite = (PyUISpriteObject*)target_obj;
if (sprite->data) {
self->data->start(sprite->data);
AnimationManager::getInstance().addAnimation(self->data);
handled = true;
}
}
else if (strcmp(type_name, "mcrfpy.Grid") == 0) {
else if (grid_type && PyObject_IsInstance(target_obj, grid_type)) {
PyUIGridObject* grid = (PyUIGridObject*)target_obj;
if (grid->data) {
self->data->start(grid->data);
AnimationManager::getInstance().addAnimation(self->data);
handled = true;
}
}
else if (strcmp(type_name, "mcrfpy.Entity") == 0) {
else if (entity_type && PyObject_IsInstance(target_obj, entity_type)) {
// Special handling for Entity since it doesn't inherit from UIDrawable
PyUIEntityObject* entity = (PyUIEntityObject*)target_obj;
if (entity->data) {
self->data->startEntity(entity->data);
AnimationManager::getInstance().addAnimation(self->data);
handled = true;
}
}
else {
PyErr_SetString(PyExc_TypeError, "Target must be a Frame, Caption, Sprite, Grid, or Entity");
// Clean up references
Py_XDECREF(frame_type);
Py_XDECREF(caption_type);
Py_XDECREF(sprite_type);
Py_XDECREF(grid_type);
Py_XDECREF(entity_type);
if (!handled) {
PyErr_SetString(PyExc_TypeError, "Target must be a Frame, Caption, Sprite, Grid, or Entity (or a subclass of these)");
return NULL;
}

View File

@ -2,13 +2,14 @@
#include "GameEngine.h"
#include "McRFPy_API.h"
#include "PythonObjectCache.h"
#include "UIEntity.h"
#include <algorithm>
// UIDrawable methods now in UIBase.h
UIGrid::UIGrid()
: grid_x(0), grid_y(0), zoom(1.0f), center_x(0.0f), center_y(0.0f), ptex(nullptr),
fill_color(8, 8, 8, 255), tcod_map(nullptr), tcod_dijkstra(nullptr), tcod_path(nullptr),
perspective(-1) // Default to omniscient view
perspective_enabled(false) // Default to omniscient view
{
// Initialize entities list
entities = std::make_shared<std::list<std::shared_ptr<UIEntity>>>();
@ -36,7 +37,7 @@ UIGrid::UIGrid(int gx, int gy, std::shared_ptr<PyTexture> _ptex, sf::Vector2f _x
zoom(1.0f),
ptex(_ptex), points(gx * gy),
fill_color(8, 8, 8, 255), tcod_map(nullptr), tcod_dijkstra(nullptr), tcod_path(nullptr),
perspective(-1) // Default to omniscient view
perspective_enabled(false) // Default to omniscient view
{
// Use texture dimensions if available, otherwise use defaults
int cell_width = _ptex ? _ptex->sprite_width : DEFAULT_CELL_WIDTH;
@ -189,54 +190,78 @@ void UIGrid::render(sf::Vector2f offset, sf::RenderTarget& target)
// top layer - opacity for discovered / visible status based on perspective
// Only render visibility overlay if perspective is set (not omniscient)
if (perspective >= 0 && perspective < static_cast<int>(entities->size())) {
// Get the entity whose perspective we're using
auto it = entities->begin();
std::advance(it, perspective);
auto& entity = *it;
// Only render visibility overlay if perspective is enabled
if (perspective_enabled) {
auto entity = perspective_entity.lock();
// Create rectangle for overlays
sf::RectangleShape overlay;
overlay.setSize(sf::Vector2f(cell_width * zoom, cell_height * zoom));
for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0);
x < x_limit;
x+=1)
{
for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0);
y < y_limit;
y+=1)
if (entity) {
// Valid entity - use its gridstate for visibility
for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0);
x < x_limit;
x+=1)
{
// Skip out-of-bounds cells
if (x < 0 || x >= grid_x || y < 0 || y >= grid_y) continue;
auto pixel_pos = sf::Vector2f(
(x*cell_width - left_spritepixels) * zoom,
(y*cell_height - top_spritepixels) * zoom );
for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0);
y < y_limit;
y+=1)
{
// Skip out-of-bounds cells
if (x < 0 || x >= grid_x || y < 0 || y >= grid_y) continue;
auto pixel_pos = sf::Vector2f(
(x*cell_width - left_spritepixels) * zoom,
(y*cell_height - top_spritepixels) * zoom );
// Get visibility state from entity's perspective
int idx = y * grid_x + x;
if (idx >= 0 && idx < static_cast<int>(entity->gridstate.size())) {
const auto& state = entity->gridstate[idx];
// Get visibility state from entity's perspective
int idx = y * grid_x + x;
if (idx >= 0 && idx < static_cast<int>(entity->gridstate.size())) {
const auto& state = entity->gridstate[idx];
overlay.setPosition(pixel_pos);
// Three overlay colors as specified:
if (!state.discovered) {
// Never seen - black
overlay.setFillColor(sf::Color(0, 0, 0, 255));
renderTexture.draw(overlay);
} else if (!state.visible) {
// Discovered but not currently visible - dark gray
overlay.setFillColor(sf::Color(32, 32, 40, 192));
renderTexture.draw(overlay);
}
// If visible and discovered, no overlay (fully visible)
}
}
}
} else {
// Invalid/destroyed entity with perspective_enabled = true
// Show all cells as undiscovered (black)
for (int x = (left_edge - 1 >= 0 ? left_edge - 1 : 0);
x < x_limit;
x+=1)
{
for (int y = (top_edge - 1 >= 0 ? top_edge - 1 : 0);
y < y_limit;
y+=1)
{
// Skip out-of-bounds cells
if (x < 0 || x >= grid_x || y < 0 || y >= grid_y) continue;
auto pixel_pos = sf::Vector2f(
(x*cell_width - left_spritepixels) * zoom,
(y*cell_height - top_spritepixels) * zoom );
overlay.setPosition(pixel_pos);
// Three overlay colors as specified:
if (!state.discovered) {
// Never seen - black
overlay.setFillColor(sf::Color(0, 0, 0, 255));
renderTexture.draw(overlay);
} else if (!state.visible) {
// Discovered but not currently visible - dark gray
overlay.setFillColor(sf::Color(32, 32, 40, 192));
renderTexture.draw(overlay);
}
// If visible and discovered, no overlay (fully visible)
overlay.setFillColor(sf::Color(0, 0, 0, 255));
renderTexture.draw(overlay);
}
}
}
}
// else: omniscient view (no overlays)
// grid lines for testing & validation
/*
@ -316,6 +341,7 @@ void UIGrid::computeFOV(int x, int y, int radius, bool light_walls, TCOD_fov_alg
{
if (!tcod_map || x < 0 || x >= grid_x || y < 0 || y >= grid_y) return;
std::lock_guard<std::mutex> lock(fov_mutex);
tcod_map->computeFov(x, y, radius, light_walls, algo);
}
@ -323,6 +349,7 @@ bool UIGrid::isInFOV(int x, int y) const
{
if (!tcod_map || x < 0 || x >= grid_x || y < 0 || y >= grid_y) return false;
std::lock_guard<std::mutex> lock(fov_mutex);
return tcod_map->isInFov(x, y);
}
@ -527,7 +554,7 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
PyObject* click_handler = nullptr;
float center_x = 0.0f, center_y = 0.0f;
float zoom = 1.0f;
int perspective = -1; // perspective is a difficult __init__ arg; needs an entity in collection to work
// perspective is now handled via properties, not init args
int visible = 1;
float opacity = 1.0f;
int z_index = 0;
@ -539,15 +566,15 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
static const char* kwlist[] = {
"pos", "size", "grid_size", "texture", // Positional args (as per spec)
// Keyword-only args
"fill_color", "click", "center_x", "center_y", "zoom", "perspective",
"fill_color", "click", "center_x", "center_y", "zoom",
"visible", "opacity", "z_index", "name", "x", "y", "w", "h", "grid_x", "grid_y",
nullptr
};
// Parse arguments with | for optional positional args
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOOOfffiifizffffii", const_cast<char**>(kwlist),
if (!PyArg_ParseTupleAndKeywords(args, kwds, "|OOOOOOfffifizffffii", const_cast<char**>(kwlist),
&pos_obj, &size_obj, &grid_size_obj, &textureObj, // Positional
&fill_color, &click_handler, &center_x, &center_y, &zoom, &perspective,
&fill_color, &click_handler, &center_x, &center_y, &zoom,
&visible, &opacity, &z_index, &name, &x, &y, &w, &h, &grid_x, &grid_y)) {
return -1;
}
@ -653,7 +680,8 @@ int UIGrid::init(PyUIGridObject* self, PyObject* args, PyObject* kwds) {
self->data->center_x = center_x;
self->data->center_y = center_y;
self->data->zoom = zoom;
self->data->perspective = perspective;
// perspective is now handled by perspective_entity and perspective_enabled
// self->data->perspective = perspective;
self->data->visible = visible;
self->data->opacity = opacity;
self->data->z_index = z_index;
@ -941,33 +969,77 @@ int UIGrid::set_fill_color(PyUIGridObject* self, PyObject* value, void* closure)
PyObject* UIGrid::get_perspective(PyUIGridObject* self, void* closure)
{
return PyLong_FromLong(self->data->perspective);
auto locked = self->data->perspective_entity.lock();
if (locked) {
// Check cache first to preserve derived class
if (locked->serial_number != 0) {
PyObject* cached = PythonObjectCache::getInstance().lookup(locked->serial_number);
if (cached) {
return cached; // Already INCREF'd by lookup
}
}
// Legacy: If the entity has a stored Python object reference
if (locked->self != nullptr) {
Py_INCREF(locked->self);
return locked->self;
}
// Otherwise, create a new base Entity object
auto type = (PyTypeObject*)PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity");
auto o = (PyUIEntityObject*)type->tp_alloc(type, 0);
if (o) {
o->data = locked;
o->weakreflist = NULL;
Py_DECREF(type);
return (PyObject*)o;
}
Py_XDECREF(type);
}
Py_RETURN_NONE;
}
int UIGrid::set_perspective(PyUIGridObject* self, PyObject* value, void* closure)
{
long perspective = PyLong_AsLong(value);
if (PyErr_Occurred()) {
if (value == Py_None) {
// Clear perspective but keep perspective_enabled unchanged
self->data->perspective_entity.reset();
return 0;
}
// Extract UIEntity from PyObject
// Get the Entity type from the module
auto entity_type = PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity");
if (!entity_type) {
PyErr_SetString(PyExc_RuntimeError, "Could not get Entity type from mcrfpy module");
return -1;
}
// Validate perspective (-1 for omniscient, or valid entity index)
if (perspective < -1) {
PyErr_SetString(PyExc_ValueError, "perspective must be -1 (omniscient) or a valid entity index");
if (!PyObject_IsInstance(value, entity_type)) {
Py_DECREF(entity_type);
PyErr_SetString(PyExc_TypeError, "perspective must be a UIEntity or None");
return -1;
}
Py_DECREF(entity_type);
// Check if entity index is valid (if not omniscient)
if (perspective >= 0 && self->data->entities) {
int entity_count = self->data->entities->size();
if (perspective >= entity_count) {
PyErr_Format(PyExc_IndexError, "perspective index %ld out of range (grid has %d entities)",
perspective, entity_count);
return -1;
}
PyUIEntityObject* entity_obj = (PyUIEntityObject*)value;
self->data->perspective_entity = entity_obj->data;
self->data->perspective_enabled = true; // Enable perspective when entity assigned
return 0;
}
PyObject* UIGrid::get_perspective_enabled(PyUIGridObject* self, void* closure)
{
return PyBool_FromLong(self->data->perspective_enabled);
}
int UIGrid::set_perspective_enabled(PyUIGridObject* self, PyObject* value, void* closure)
{
int enabled = PyObject_IsTrue(value);
if (enabled == -1) {
return -1; // Error occurred
}
self->data->perspective = perspective;
self->data->perspective_enabled = enabled;
return 0;
}
@ -984,8 +1056,43 @@ PyObject* UIGrid::py_compute_fov(PyUIGridObject* self, PyObject* args, PyObject*
return NULL;
}
// Compute FOV
self->data->computeFOV(x, y, radius, light_walls, (TCOD_fov_algorithm_t)algorithm);
Py_RETURN_NONE;
// Build list of visible cells as tuples (x, y, visible, discovered)
PyObject* result_list = PyList_New(0);
if (!result_list) return NULL;
// Iterate through grid and collect visible cells
for (int gy = 0; gy < self->data->grid_y; gy++) {
for (int gx = 0; gx < self->data->grid_x; gx++) {
if (self->data->isInFOV(gx, gy)) {
// Create tuple (x, y, visible, discovered)
PyObject* cell_tuple = PyTuple_New(4);
if (!cell_tuple) {
Py_DECREF(result_list);
return NULL;
}
PyTuple_SET_ITEM(cell_tuple, 0, PyLong_FromLong(gx));
PyTuple_SET_ITEM(cell_tuple, 1, PyLong_FromLong(gy));
PyTuple_SET_ITEM(cell_tuple, 2, Py_True); // visible
PyTuple_SET_ITEM(cell_tuple, 3, Py_True); // discovered
Py_INCREF(Py_True); // Need to increment ref count for True
Py_INCREF(Py_True);
// Append to list
if (PyList_Append(result_list, cell_tuple) < 0) {
Py_DECREF(cell_tuple);
Py_DECREF(result_list);
return NULL;
}
Py_DECREF(cell_tuple); // List now owns the reference
}
}
}
return result_list;
}
PyObject* UIGrid::py_is_in_fov(PyUIGridObject* self, PyObject* args)
@ -1103,16 +1210,20 @@ PyObject* UIGrid::py_compute_astar_path(PyUIGridObject* self, PyObject* args, Py
PyMethodDef UIGrid::methods[] = {
{"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS},
{"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS,
"compute_fov(x: int, y: int, radius: int = 0, light_walls: bool = True, algorithm: int = FOV_BASIC) -> None\n\n"
"Compute field of view from a position.\n\n"
"compute_fov(x: int, y: int, radius: int = 0, light_walls: bool = True, algorithm: int = FOV_BASIC) -> List[Tuple[int, int, bool, bool]]\n\n"
"Compute field of view from a position and return visible cells.\n\n"
"Args:\n"
" x: X coordinate of the viewer\n"
" y: Y coordinate of the viewer\n"
" radius: Maximum view distance (0 = unlimited)\n"
" light_walls: Whether walls are lit when visible\n"
" algorithm: FOV algorithm to use (FOV_BASIC, FOV_DIAMOND, FOV_SHADOW, FOV_PERMISSIVE_0-8)\n\n"
"Updates the internal FOV state. Use is_in_fov() to check visibility after calling this.\n"
"When perspective is set, this also updates visibility overlays automatically."},
"Returns:\n"
" List of tuples (x, y, visible, discovered) for all visible cells:\n"
" - x, y: Grid coordinates\n"
" - visible: True (all returned cells are visible)\n"
" - discovered: True (FOV implies discovery)\n\n"
"Also updates the internal FOV state for use with is_in_fov()."},
{"is_in_fov", (PyCFunction)UIGrid::py_is_in_fov, METH_VARARGS,
"is_in_fov(x: int, y: int) -> bool\n\n"
"Check if a cell is in the field of view.\n\n"
@ -1185,16 +1296,20 @@ PyMethodDef UIGrid_all_methods[] = {
UIDRAWABLE_METHODS,
{"at", (PyCFunction)UIGrid::py_at, METH_VARARGS | METH_KEYWORDS},
{"compute_fov", (PyCFunction)UIGrid::py_compute_fov, METH_VARARGS | METH_KEYWORDS,
"compute_fov(x: int, y: int, radius: int = 0, light_walls: bool = True, algorithm: int = FOV_BASIC) -> None\n\n"
"Compute field of view from a position.\n\n"
"compute_fov(x: int, y: int, radius: int = 0, light_walls: bool = True, algorithm: int = FOV_BASIC) -> List[Tuple[int, int, bool, bool]]\n\n"
"Compute field of view from a position and return visible cells.\n\n"
"Args:\n"
" x: X coordinate of the viewer\n"
" y: Y coordinate of the viewer\n"
" radius: Maximum view distance (0 = unlimited)\n"
" light_walls: Whether walls are lit when visible\n"
" algorithm: FOV algorithm to use (FOV_BASIC, FOV_DIAMOND, FOV_SHADOW, FOV_PERMISSIVE_0-8)\n\n"
"Updates the internal FOV state. Use is_in_fov() to check visibility after calling this.\n"
"When perspective is set, this also updates visibility overlays automatically."},
"Returns:\n"
" List of tuples (x, y, visible, discovered) for all visible cells:\n"
" - x, y: Grid coordinates\n"
" - visible: True (all returned cells are visible)\n"
" - discovered: True (FOV implies discovery)\n\n"
"Also updates the internal FOV state for use with is_in_fov()."},
{"is_in_fov", (PyCFunction)UIGrid::py_is_in_fov, METH_VARARGS,
"is_in_fov(x: int, y: int) -> bool\n\n"
"Check if a cell is in the field of view.\n\n"
@ -1285,9 +1400,11 @@ PyGetSetDef UIGrid::getsetters[] = {
{"texture", (getter)UIGrid::get_texture, NULL, "Texture of the grid", NULL}, //TODO 7DRL-day2-item5
{"fill_color", (getter)UIGrid::get_fill_color, (setter)UIGrid::set_fill_color, "Background fill color of the grid", NULL},
{"perspective", (getter)UIGrid::get_perspective, (setter)UIGrid::set_perspective,
"Entity perspective index for FOV rendering (-1 for omniscient view, 0+ for entity index). "
"When set to an entity index, only cells visible to that entity are rendered normally; "
"explored but not visible cells are darkened, and unexplored cells are black.", NULL},
"Entity whose perspective to use for FOV rendering (None for omniscient view). "
"Setting an entity automatically enables perspective mode.", NULL},
{"perspective_enabled", (getter)UIGrid::get_perspective_enabled, (setter)UIGrid::set_perspective_enabled,
"Whether to use perspective-based FOV rendering. When True with no valid entity, "
"all cells appear undiscovered.", NULL},
{"z_index", (getter)UIDrawable::get_int, (setter)UIDrawable::set_int, "Z-order for rendering (lower values rendered first)", (void*)PyObjectsEnum::UIGRID},
{"name", (getter)UIDrawable::get_name, (setter)UIDrawable::set_name, "Name for finding elements", (void*)PyObjectsEnum::UIGRID},
UIDRAWABLE_GETSETTERS,
@ -1655,33 +1772,46 @@ PyObject* UIEntityCollection::append(PyUIEntityCollectionObject* self, PyObject*
PyObject* UIEntityCollection::remove(PyUIEntityCollectionObject* self, PyObject* o)
{
if (!PyLong_Check(o))
// Type checking - must be an Entity
if (!PyObject_IsInstance(o, PyObject_GetAttrString(McRFPy_API::mcrf_module, "Entity")))
{
PyErr_SetString(PyExc_TypeError, "EntityCollection.remove requires an integer index to remove");
PyErr_SetString(PyExc_TypeError, "EntityCollection.remove requires an Entity object");
return NULL;
}
long index = PyLong_AsLong(o);
// Handle negative indexing
while (index < 0) index += self->data->size();
if (index >= self->data->size())
{
PyErr_SetString(PyExc_ValueError, "Index out of range");
// Get the C++ object from the Python object
PyUIEntityObject* entity = (PyUIEntityObject*)o;
if (!entity->data) {
PyErr_SetString(PyExc_RuntimeError, "Invalid Entity object");
return NULL;
}
// Get iterator to the entity to remove
auto it = self->data->begin();
std::advance(it, index);
// Clear grid reference before removing
(*it)->grid = nullptr;
// Get the underlying list
auto list = self->data.get();
if (!list) {
PyErr_SetString(PyExc_RuntimeError, "The collection store returned a null pointer");
return NULL;
}
// release the shared pointer at correct part of the list
self->data->erase(it);
Py_INCREF(Py_None);
return Py_None;
// Search for the entity by comparing C++ pointers
auto it = list->begin();
while (it != list->end()) {
if (it->get() == entity->data.get()) {
// Found it - clear grid reference before removing
(*it)->grid = nullptr;
// Remove from the list
self->data->erase(it);
Py_INCREF(Py_None);
return Py_None;
}
++it;
}
// Entity not found - raise ValueError
PyErr_SetString(PyExc_ValueError, "Entity not in EntityCollection");
return NULL;
}
PyObject* UIEntityCollection::extend(PyUIEntityCollectionObject* self, PyObject* o)

View File

@ -6,6 +6,7 @@
#include "Resources.h"
#include <list>
#include <libtcod.h>
#include <mutex>
#include "PyCallable.h"
#include "PyTexture.h"
@ -29,6 +30,7 @@ private:
TCODMap* tcod_map; // TCOD map for FOV and pathfinding
TCODDijkstra* tcod_dijkstra; // Dijkstra pathfinding
TCODPath* tcod_path; // A* pathfinding
mutable std::mutex fov_mutex; // Mutex for thread-safe FOV operations
public:
UIGrid();
@ -77,8 +79,9 @@ public:
// Background rendering
sf::Color fill_color;
// Perspective system - which entity's view to render (-1 = omniscient/default)
int perspective;
// Perspective system - entity whose view to render
std::weak_ptr<UIEntity> perspective_entity; // Weak reference to perspective entity
bool perspective_enabled; // Whether to use perspective rendering
// Property system for animations
bool setProperty(const std::string& name, float value) override;
@ -103,6 +106,8 @@ public:
static int set_fill_color(PyUIGridObject* self, PyObject* value, void* closure);
static PyObject* get_perspective(PyUIGridObject* self, void* closure);
static int set_perspective(PyUIGridObject* self, PyObject* value, void* closure);
static PyObject* get_perspective_enabled(PyUIGridObject* self, void* closure);
static int set_perspective_enabled(PyUIGridObject* self, PyObject* value, void* closure);
static PyObject* py_at(PyUIGridObject* self, PyObject* args, PyObject* kwds);
static PyObject* py_compute_fov(PyUIGridObject* self, PyObject* args, PyObject* kwds);
static PyObject* py_is_in_fov(PyUIGridObject* self, PyObject* args);

42
tests/run_all_tests.sh Executable file
View File

@ -0,0 +1,42 @@
#!/bin/bash
# Run all tests and check for failures
TESTS=(
"test_click_init.py"
"test_drawable_base.py"
"test_frame_children.py"
"test_sprite_texture_swap.py"
"test_timer_object.py"
"test_timer_object_fixed.py"
)
echo "Running all tests..."
echo "===================="
failed=0
passed=0
for test in "${TESTS[@]}"; do
echo -n "Running $test... "
if timeout 5 ./mcrogueface --headless --exec ../tests/$test > /tmp/test_output.txt 2>&1; then
if grep -q "FAIL\|✗" /tmp/test_output.txt; then
echo "FAILED"
echo "Output:"
cat /tmp/test_output.txt | grep -E "✗|FAIL|Error|error" | head -10
((failed++))
else
echo "PASSED"
((passed++))
fi
else
echo "TIMEOUT/CRASH"
((failed++))
fi
done
echo "===================="
echo "Total: $((passed + failed)) tests"
echo "Passed: $passed"
echo "Failed: $failed"
exit $failed

102
tools/gitea_issues.py Normal file
View File

@ -0,0 +1,102 @@
import json
from time import time
#with open("/home/john/issues.json", "r") as f:
# data = json.loads(f.read())
#with open("/home/john/issues2.json", "r") as f:
# data.extend(json.loads(f.read()))
print("Fetching issues...", end='')
start = time()
from gitea import Gitea, Repository, Issue
g = Gitea("https://gamedev.ffwf.net/gitea", token_text="febad52bd50f87fb17691c5e972597d6fff73452")
repo = Repository.request(g, "john", "McRogueFace")
issues = repo.get_issues()
dur = time() - start
print(f"({dur:.1f}s)")
print("Gitea Version: " + g.get_version())
print("API-Token belongs to user: " + g.get_user().username)
data = [
{
"labels": i.labels,
"body": i.body,
"number": i.number,
}
for i in issues
]
input()
def front_number(txt):
if not txt[0].isdigit(): return None
number = ""
for c in txt:
if not c.isdigit():
break
number += c
return int(number)
def split_any(txt, splitters):
tokens = []
txt = [txt]
for s in splitters:
for t in txt:
tokens.extend(t.split(s))
txt = tokens
tokens = []
return txt
def find_refs(txt):
tokens = [tok for tok in split_any(txt, ' ,;\t\r\n') if tok.startswith('#')]
return [front_number(tok[1:]) for tok in tokens]
from collections import defaultdict
issue_relations = defaultdict(list)
nodes = set()
for issue in data:
#refs = issue['body'].split('#')[1::2]
#refs = [front_number(r) for r in refs if front_number(r) is not None]
refs = find_refs(issue['body'])
print(issue['number'], ':', refs)
issue_relations[issue['number']].extend(refs)
nodes.add(issue['number'])
for r in refs:
nodes.add(r)
issue_relations[r].append(issue['number'])
# Find issue labels
issue_labels = {}
for d in data:
labels = [l['name'] for l in d['labels']]
#print(d['number'], labels)
issue_labels[d['number']] = labels
import networkx as nx
import matplotlib.pyplot as plt
relations = nx.Graph()
for k in issue_relations:
relations.add_node(k)
for r in issue_relations[k]:
relations.add_edge(k, r)
relations.add_edge(r, k)
#nx.draw_networkx(relations)
pos = nx.spring_layout(relations)
nx.draw_networkx_nodes(relations, pos,
nodelist = [n for n in issue_labels if 'Alpha Release Requirement' in issue_labels[n]],
node_color="tab:red")
nx.draw_networkx_nodes(relations, pos,
nodelist = [n for n in issue_labels if 'Alpha Release Requirement' not in issue_labels[n]],
node_color="tab:blue")
nx.draw_networkx_edges(relations, pos,
edgelist = relations.edges()
)
nx.draw_networkx_labels(relations, pos, {i: str(i) for i in relations.nodes()})
plt.show()