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:
commit
8153fd2503
File diff suppressed because it is too large
Load Diff
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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!
|
||||
|
|
@ -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!
|
||||
|
|
@ -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!
|
||||
|
|
@ -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")
|
||||
|
|
@ -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!")
|
||||
|
|
@ -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!")
|
||||
|
|
@ -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!")
|
||||
|
|
@ -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!")
|
||||
|
|
@ -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!")
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
308
src/UIGrid.cpp
308
src/UIGrid.cpp
|
|
@ -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, ¢er_x, ¢er_y, &zoom, &perspective,
|
||||
&fill_color, &click_handler, ¢er_x, ¢er_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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue