Compare commits

..

4 commits

Author SHA1 Message Date
51e96c0c6b fix: Refine geometry demos for 1024x768 and fix animations
- Fix timer restart when switching between animated demo scenes
- Update all demos from 800x600 to 1024x768 resolution
- Add screen_angle_between() for correct arc angles in screen coords
- Fix arc directions by accounting for screen Y inversion
- Reposition labels to avoid text overlaps
- Shift solar system center down to prevent moon orbit overflow
- Reposition ship/target in pathfinding demo to avoid sun clipping
- Scale menu screen to fill 1024x768 with wider buttons
- Regenerate all demo screenshots

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 04:54:13 -05:00
576481957a cleanup: remove partial tutorial 2025-11-26 04:53:31 -05:00
198686cba9 feat: Add geometry module demo system for orbital mechanics
Creates comprehensive visual demonstrations of the geometry module:

Static demos:
- Bresenham algorithms: circle/line rasterization on grid cells
- Angle calculations: line elements showing angles between points,
  waypoint viability with angle thresholds, orbit exit headings
- Pathfinding: planets with surfaces and orbit rings, optimal
  path using orbital slingshots vs direct path comparison

Animated demos:
- Solar system: planets orbiting star with discrete time steps,
  nested moon orbit, position updates every second
- Pathfinding through moving system: ship navigates to target
  using orbital intercepts, anticipating planetary motion

Includes 5 screenshot outputs demonstrating each feature.

Run: ./mcrogueface --headless --exec tests/geometry_demo/geometry_main.py
Interactive: ./mcrogueface tests/geometry_demo/geometry_main.py

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 00:46:38 -05:00
bc95cb1f0b feat: Add geometry module for orbital mechanics and spatial calculations
Implements issue #130 with:
- Basic utilities: distance, angle_between, normalize_angle, lerp, clamp
- Grid algorithms: bresenham_circle, bresenham_line, filled_circle
- OrbitalBody class with recursive positioning (star -> planet -> moon)
- OrbitingShip class for relative ship positioning on orbit rings
- Pathfinding helpers: nearest_orbit_entry, optimal_exit_heading,
  is_viable_waypoint, line_of_sight_blocked
- Comprehensive test suite (25+ tests)

Designed for Pinships turn-based space roguelike with:
- Discrete time steps (planets move in whole grid squares)
- Deterministic position projection
- Free orbital movement while in orbit
- Support for nested orbits (moons of moons)

closes #130

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 00:26:14 -05:00
54 changed files with 3062 additions and 11118 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,253 +0,0 @@
# Part 0 - Setting Up McRogueFace
Welcome to the McRogueFace Roguelike Tutorial! This tutorial will teach you how to create a complete roguelike game using the McRogueFace game engine. Unlike traditional Python libraries, McRogueFace is a complete, portable game engine that includes everything you need to make and distribute games.
## What is McRogueFace?
McRogueFace is a high-performance game engine with Python scripting support. Think of it like Unity or Godot, but specifically designed for roguelikes and 2D games. It includes:
- A complete Python 3.12 runtime (no installation needed!)
- High-performance C++ rendering and entity management
- Built-in UI components and scene management
- Integrated audio system
- Professional sprite-based graphics
- Easy distribution - your players don't need Python installed!
## Prerequisites
Before starting this tutorial, you should:
- Have basic Python knowledge (variables, functions, classes)
- Be comfortable editing text files
- Have a text editor (VS Code, Sublime Text, Notepad++, etc.)
That's it! Unlike other roguelike tutorials, you don't need Python installed - McRogueFace includes everything.
## Getting McRogueFace
### Step 1: Download the Engine
1. Visit the McRogueFace releases page
2. Download the version for your operating system:
- `McRogueFace-Windows.zip` for Windows
- `McRogueFace-MacOS.zip` for macOS
- `McRogueFace-Linux.zip` for Linux
### Step 2: Extract the Archive
Extract the downloaded archive to a folder where you want to develop your game. You should see this structure:
```
McRogueFace/
├── mcrogueface (or mcrogueface.exe on Windows)
├── scripts/
│ └── game.py
├── assets/
│ ├── sprites/
│ ├── fonts/
│ └── audio/
└── lib/
```
### Step 3: Run the Engine
Run the McRogueFace executable:
- **Windows**: Double-click `mcrogueface.exe`
- **Mac/Linux**: Open a terminal in the folder and run `./mcrogueface`
You should see a window open with the default McRogueFace demo. This shows the engine is working correctly!
## Your First McRogueFace Script
Let's modify the engine to display "Hello Roguelike!" instead of the default demo.
### Step 1: Open game.py
Open `scripts/game.py` in your text editor. You'll see the default demo code. Replace it entirely with:
```python
import mcrfpy
# Create a new scene called "hello"
mcrfpy.createScene("hello")
# Switch to our new scene
mcrfpy.setScene("hello")
# Get the UI container for our scene
ui = mcrfpy.sceneUI("hello")
# Create a text caption
caption = mcrfpy.Caption("Hello Roguelike!", 400, 300)
caption.font_size = 32
caption.fill_color = mcrfpy.Color(255, 255, 255) # White text
# Add the caption to our scene
ui.append(caption)
# Create a smaller instruction caption
instruction = mcrfpy.Caption("Press ESC to exit", 400, 350)
instruction.font_size = 16
instruction.fill_color = mcrfpy.Color(200, 200, 200) # Light gray
ui.append(instruction)
# Set up a simple key handler
def handle_keys(key, state):
if state == "start" and key == "Escape":
mcrfpy.setScene(None) # This exits the game
mcrfpy.keypressScene(handle_keys)
print("Hello Roguelike is running!")
```
### Step 2: Save and Run
1. Save the file
2. If McRogueFace is still running, it will automatically reload!
3. If not, run the engine again
You should now see "Hello Roguelike!" displayed in the window.
### Step 3: Understanding the Code
Let's break down what we just wrote:
1. **Import mcrfpy**: This is McRogueFace's Python API
2. **Create a scene**: Scenes are like game states (menu, gameplay, inventory, etc.)
3. **UI elements**: We create Caption objects for text display
4. **Colors**: McRogueFace uses RGB colors (0-255 for each component)
5. **Input handling**: We set up a callback for keyboard input
6. **Scene switching**: Setting the scene to None exits the game
## Key Differences from Pure Python Development
### The Game Loop
Unlike typical Python scripts, McRogueFace runs your code inside its game loop:
1. The engine starts and loads `scripts/game.py`
2. Your script sets up scenes, UI elements, and callbacks
3. The engine runs at 60 FPS, handling rendering and input
4. Your callbacks are triggered by game events
### Hot Reloading
McRogueFace can reload your scripts while running! Just save your changes and the engine will reload automatically. This makes development incredibly fast.
### Asset Pipeline
McRogueFace includes a complete asset system:
- **Sprites**: Place images in `assets/sprites/`
- **Fonts**: TrueType fonts in `assets/fonts/`
- **Audio**: Sound effects and music in `assets/audio/`
We'll explore these in later lessons.
## Testing Your Setup
Let's create a more interactive test to ensure everything is working properly:
```python
import mcrfpy
# Create our test scene
mcrfpy.createScene("test")
mcrfpy.setScene("test")
ui = mcrfpy.sceneUI("test")
# Create a background frame
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(20, 20, 30) # Dark blue-gray
ui.append(background)
# Title text
title = mcrfpy.Caption("McRogueFace Setup Test", 512, 100)
title.font_size = 36
title.fill_color = mcrfpy.Color(255, 255, 100) # Yellow
ui.append(title)
# Status text that will update
status_text = mcrfpy.Caption("Press any key to test input...", 512, 300)
status_text.font_size = 20
status_text.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(status_text)
# Instructions
instructions = [
"Arrow Keys: Test movement input",
"Space: Test action input",
"Mouse Click: Test mouse input",
"ESC: Exit"
]
y_offset = 400
for instruction in instructions:
inst_caption = mcrfpy.Caption(instruction, 512, y_offset)
inst_caption.font_size = 16
inst_caption.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(inst_caption)
y_offset += 30
# Input handler
def handle_input(key, state):
if state != "start":
return
if key == "Escape":
mcrfpy.setScene(None)
else:
status_text.text = f"You pressed: {key}"
status_text.fill_color = mcrfpy.Color(100, 255, 100) # Green
# Set up input handling
mcrfpy.keypressScene(handle_input)
print("Setup test is running! Try pressing different keys.")
```
## Troubleshooting
### Engine Won't Start
- **Windows**: Make sure you extracted all files, not just the .exe
- **Mac**: You may need to right-click and select "Open" the first time
- **Linux**: Make sure the file is executable: `chmod +x mcrogueface`
### Scripts Not Loading
- Ensure your script is named exactly `game.py` in the `scripts/` folder
- Check the console output for Python errors
- Make sure you're using Python 3 syntax
### Performance Issues
- McRogueFace should run smoothly at 60 FPS
- If not, check if your graphics drivers are updated
- The engine shows FPS in the window title
## What's Next?
Congratulations! You now have McRogueFace set up and running. You've learned:
- How to download and run the McRogueFace engine
- The basic structure of a McRogueFace project
- How to create scenes and UI elements
- How to handle keyboard input
- The development workflow with hot reloading
In Part 1, we'll create our player character and implement movement. We'll explore McRogueFace's entity system and learn how to create a game world.
## Why McRogueFace?
Before we continue, let's highlight why McRogueFace is excellent for roguelike development:
1. **No Installation Hassles**: Your players just download and run - no Python needed!
2. **Professional Performance**: C++ engine core means smooth gameplay even with hundreds of entities
3. **Built-in Features**: UI, audio, scenes, and animations are already there
4. **Easy Distribution**: Just zip your game folder and share it
5. **Rapid Development**: Hot reloading and Python scripting for quick iteration
Ready to make a roguelike? Let's continue to Part 1!

View file

@ -1,33 +0,0 @@
import mcrfpy
# Create a new scene called "hello"
mcrfpy.createScene("hello")
# Switch to our new scene
mcrfpy.setScene("hello")
# Get the UI container for our scene
ui = mcrfpy.sceneUI("hello")
# Create a text caption
caption = mcrfpy.Caption("Hello Roguelike!", 400, 300)
caption.font_size = 32
caption.fill_color = mcrfpy.Color(255, 255, 255) # White text
# Add the caption to our scene
ui.append(caption)
# Create a smaller instruction caption
instruction = mcrfpy.Caption("Press ESC to exit", 400, 350)
instruction.font_size = 16
instruction.fill_color = mcrfpy.Color(200, 200, 200) # Light gray
ui.append(instruction)
# Set up a simple key handler
def handle_keys(key, state):
if state == "start" and key == "Escape":
mcrfpy.setScene(None) # This exits the game
mcrfpy.keypressScene(handle_keys)
print("Hello Roguelike is running!")

View file

@ -1,55 +0,0 @@
import mcrfpy
# Create our test scene
mcrfpy.createScene("test")
mcrfpy.setScene("test")
ui = mcrfpy.sceneUI("test")
# Create a background frame
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(20, 20, 30) # Dark blue-gray
ui.append(background)
# Title text
title = mcrfpy.Caption("McRogueFace Setup Test", 512, 100)
title.font_size = 36
title.fill_color = mcrfpy.Color(255, 255, 100) # Yellow
ui.append(title)
# Status text that will update
status_text = mcrfpy.Caption("Press any key to test input...", 512, 300)
status_text.font_size = 20
status_text.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(status_text)
# Instructions
instructions = [
"Arrow Keys: Test movement input",
"Space: Test action input",
"Mouse Click: Test mouse input",
"ESC: Exit"
]
y_offset = 400
for instruction in instructions:
inst_caption = mcrfpy.Caption(instruction, 512, y_offset)
inst_caption.font_size = 16
inst_caption.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(inst_caption)
y_offset += 30
# Input handler
def handle_input(key, state):
if state != "start":
return
if key == "Escape":
mcrfpy.setScene(None)
else:
status_text.text = f"You pressed: {key}"
status_text.fill_color = mcrfpy.Color(100, 255, 100) # Green
# Set up input handling
mcrfpy.keypressScene(handle_input)
print("Setup test is running! Try pressing different keys.")

View file

@ -1,457 +0,0 @@
# Part 1 - Drawing the '@' Symbol and Moving It Around
In Part 0, we set up McRogueFace and created a simple "Hello Roguelike" scene. Now it's time to create the foundation of our game: a player character that can move around the screen.
In traditional roguelikes, the player is represented by the '@' symbol. We'll honor that tradition while taking advantage of McRogueFace's powerful sprite-based rendering system.
## Understanding McRogueFace's Architecture
Before we dive into code, let's understand two key concepts in McRogueFace:
### Grid - The Game World
A `Grid` represents your game world. It's a 2D array of tiles where each tile can be:
- **Walkable or not** (for collision detection)
- **Transparent or not** (for field of view, which we'll cover later)
- **Have a visual appearance** (sprite index and color)
Think of the Grid as the dungeon floor, walls, and other static elements.
### Entity - Things That Move
An `Entity` represents anything that can move around on the Grid:
- The player character
- Monsters
- Items (if you want them to be thrown or moved)
- Projectiles
Entities exist "on top of" the Grid and automatically handle smooth movement animation between tiles.
## Creating Our Game World
Let's start by creating a simple room for our player to move around in. Create a new `game.py`:
```python
import mcrfpy
# Define some constants for our tile types
FLOOR_TILE = 0
WALL_TILE = 1
PLAYER_SPRITE = 2
# Window configuration
mcrfpy.createScene("game")
mcrfpy.setScene("game")
# Configure window properties
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 1"
# Get the UI container for our scene
ui = mcrfpy.sceneUI("game")
# Create a dark background
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
ui.append(background)
```
Now we need to set up our tileset. For this tutorial, we'll use ASCII-style sprites. McRogueFace comes with a built-in ASCII tileset:
```python
# Load the ASCII tileset
# This tileset has characters mapped to sprite indices
# For example: @ = 64, # = 35, . = 46
tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
# Create the game grid
# 50x30 tiles is a good size for a roguelike
GRID_WIDTH = 50
GRID_HEIGHT = 30
grid = mcrfpy.Grid(grid_x=GRID_WIDTH, grid_y=GRID_HEIGHT, texture=tileset)
grid.position = (100, 100) # Position on screen
grid.size = (800, 480) # Size in pixels
# Add the grid to our UI
ui.append(grid)
```
## Initializing the Game World
Now let's fill our grid with a simple room:
```python
def create_room():
"""Create a room with walls around the edges"""
# Fill everything with floor tiles first
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
cell = grid.at(x, y)
cell.walkable = True
cell.transparent = True
cell.sprite_index = 46 # '.' character
cell.color = mcrfpy.Color(50, 50, 50) # Dark gray floor
# Create walls around the edges
for x in range(GRID_WIDTH):
# Top wall
cell = grid.at(x, 0)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100) # Gray walls
# Bottom wall
cell = grid.at(x, GRID_HEIGHT - 1)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100)
for y in range(GRID_HEIGHT):
# Left wall
cell = grid.at(0, y)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100)
# Right wall
cell = grid.at(GRID_WIDTH - 1, y)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100)
# Create the room
create_room()
```
## Creating the Player
Now let's add our player character:
```python
# Create the player entity
player = mcrfpy.Entity(x=GRID_WIDTH // 2, y=GRID_HEIGHT // 2, grid=grid)
player.sprite_index = 64 # '@' character
player.color = mcrfpy.Color(255, 255, 255) # White
# The entity is automatically added to the grid when we pass grid= parameter
# This is equivalent to: grid.entities.append(player)
```
## Handling Input
McRogueFace uses a callback system for input. For a turn-based roguelike, we only care about key presses, not releases:
```python
def handle_input(key, state):
"""Handle keyboard input for player movement"""
# Only process key presses, not releases
if state != "start":
return
# Movement deltas
dx, dy = 0, 0
# Arrow keys
if key == "Up":
dy = -1
elif key == "Down":
dy = 1
elif key == "Left":
dx = -1
elif key == "Right":
dx = 1
# Numpad movement (for true roguelike feel!)
elif key == "Num7": # Northwest
dx, dy = -1, -1
elif key == "Num8": # North
dy = -1
elif key == "Num9": # Northeast
dx, dy = 1, -1
elif key == "Num4": # West
dx = -1
elif key == "Num6": # East
dx = 1
elif key == "Num1": # Southwest
dx, dy = -1, 1
elif key == "Num2": # South
dy = 1
elif key == "Num3": # Southeast
dx, dy = 1, 1
# Escape to quit
elif key == "Escape":
mcrfpy.setScene(None)
return
# If there's movement, try to move the player
if dx != 0 or dy != 0:
move_player(dx, dy)
# Register the input handler
mcrfpy.keypressScene(handle_input)
```
## Implementing Movement with Collision Detection
Now let's implement the movement function with proper collision detection:
```python
def move_player(dx, dy):
"""Move the player if the destination is walkable"""
# Calculate new position
new_x = player.x + dx
new_y = player.y + dy
# Check bounds
if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT:
return
# Check if the destination is walkable
destination = grid.at(new_x, new_y)
if destination.walkable:
# Move the player
player.x = new_x
player.y = new_y
# The entity will automatically animate to the new position!
```
## Adding Visual Polish
Let's add some UI elements to make our game look more polished:
```python
# Add a title
title = mcrfpy.Caption("McRogueFace Roguelike", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100) # Yellow
ui.append(title)
# Add instructions
instructions = mcrfpy.Caption("Arrow Keys or Numpad to move, ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200) # Light gray
ui.append(instructions)
# Add a status line at the bottom
status = mcrfpy.Caption("@ You", 100, 600)
status.font_size = 18
status.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(status)
```
## Complete Code
Here's the complete `game.py` for Part 1:
```python
import mcrfpy
# Window configuration
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 1"
# Get the UI container for our scene
ui = mcrfpy.sceneUI("game")
# Create a dark background
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
ui.append(background)
# Load the ASCII tileset
tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
# Create the game grid
GRID_WIDTH = 50
GRID_HEIGHT = 30
grid = mcrfpy.Grid(grid_x=GRID_WIDTH, grid_y=GRID_HEIGHT, texture=tileset)
grid.position = (100, 100)
grid.size = (800, 480)
ui.append(grid)
def create_room():
"""Create a room with walls around the edges"""
# Fill everything with floor tiles first
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
cell = grid.at(x, y)
cell.walkable = True
cell.transparent = True
cell.sprite_index = 46 # '.' character
cell.color = mcrfpy.Color(50, 50, 50) # Dark gray floor
# Create walls around the edges
for x in range(GRID_WIDTH):
# Top wall
cell = grid.at(x, 0)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100) # Gray walls
# Bottom wall
cell = grid.at(x, GRID_HEIGHT - 1)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100)
for y in range(GRID_HEIGHT):
# Left wall
cell = grid.at(0, y)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100)
# Right wall
cell = grid.at(GRID_WIDTH - 1, y)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100)
# Create the room
create_room()
# Create the player entity
player = mcrfpy.Entity(x=GRID_WIDTH // 2, y=GRID_HEIGHT // 2, grid=grid)
player.sprite_index = 64 # '@' character
player.color = mcrfpy.Color(255, 255, 255) # White
def move_player(dx, dy):
"""Move the player if the destination is walkable"""
# Calculate new position
new_x = player.x + dx
new_y = player.y + dy
# Check bounds
if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT:
return
# Check if the destination is walkable
destination = grid.at(new_x, new_y)
if destination.walkable:
# Move the player
player.x = new_x
player.y = new_y
def handle_input(key, state):
"""Handle keyboard input for player movement"""
# Only process key presses, not releases
if state != "start":
return
# Movement deltas
dx, dy = 0, 0
# Arrow keys
if key == "Up":
dy = -1
elif key == "Down":
dy = 1
elif key == "Left":
dx = -1
elif key == "Right":
dx = 1
# Numpad movement (for true roguelike feel!)
elif key == "Num7": # Northwest
dx, dy = -1, -1
elif key == "Num8": # North
dy = -1
elif key == "Num9": # Northeast
dx, dy = 1, -1
elif key == "Num4": # West
dx = -1
elif key == "Num6": # East
dx = 1
elif key == "Num1": # Southwest
dx, dy = -1, 1
elif key == "Num2": # South
dy = 1
elif key == "Num3": # Southeast
dx, dy = 1, 1
# Escape to quit
elif key == "Escape":
mcrfpy.setScene(None)
return
# If there's movement, try to move the player
if dx != 0 or dy != 0:
move_player(dx, dy)
# Register the input handler
mcrfpy.keypressScene(handle_input)
# Add UI elements
title = mcrfpy.Caption("McRogueFace Roguelike", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
ui.append(title)
instructions = mcrfpy.Caption("Arrow Keys or Numpad to move, ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(instructions)
status = mcrfpy.Caption("@ You", 100, 600)
status.font_size = 18
status.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(status)
print("Part 1: The @ symbol moves!")
```
## Understanding What We've Built
Let's review the key concepts we've implemented:
1. **Grid-Entity Architecture**: The Grid represents our static world (floors and walls), while the Entity (player) moves on top of it.
2. **Collision Detection**: By checking the `walkable` property of grid cells, we prevent the player from walking through walls.
3. **Turn-Based Input**: By only responding to key presses (not releases), we've created true turn-based movement.
4. **Visual Feedback**: The Entity system automatically animates movement between tiles, giving smooth visual feedback.
## Exercises
Try these modifications to deepen your understanding:
1. **Add More Rooms**: Create multiple rooms connected by corridors
2. **Different Tile Types**: Add doors (walkable but different appearance)
3. **Sprint Movement**: Hold Shift to move multiple tiles at once
4. **Mouse Support**: Click a tile to pathfind to it (we'll cover pathfinding properly later)
## ASCII Sprite Reference
Here are some useful ASCII character indices for the default tileset:
- @ (player): 64
- # (wall): 35
- . (floor): 46
- + (door): 43
- ~ (water): 126
- % (item): 37
- ! (potion): 33
## What's Next?
In Part 2, we'll expand our world with:
- A proper Entity system for managing multiple objects
- NPCs that can also move around
- A more interesting map layout
- The beginning of our game architecture
The foundation is set - you have a player character that can move around a world with collision detection. This is the core of any roguelike game!

View file

@ -1,162 +0,0 @@
import mcrfpy
# Window configuration
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 1"
# Get the UI container for our scene
ui = mcrfpy.sceneUI("game")
# Create a dark background
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
ui.append(background)
# Load the ASCII tileset
tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
# Create the game grid
GRID_WIDTH = 50
GRID_HEIGHT = 30
grid = mcrfpy.Grid(grid_x=GRID_WIDTH, grid_y=GRID_HEIGHT, texture=tileset)
grid.position = (100, 100)
grid.size = (800, 480)
ui.append(grid)
def create_room():
"""Create a room with walls around the edges"""
# Fill everything with floor tiles first
for y in range(GRID_HEIGHT):
for x in range(GRID_WIDTH):
cell = grid.at(x, y)
cell.walkable = True
cell.transparent = True
cell.sprite_index = 46 # '.' character
cell.color = mcrfpy.Color(50, 50, 50) # Dark gray floor
# Create walls around the edges
for x in range(GRID_WIDTH):
# Top wall
cell = grid.at(x, 0)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100) # Gray walls
# Bottom wall
cell = grid.at(x, GRID_HEIGHT - 1)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100)
for y in range(GRID_HEIGHT):
# Left wall
cell = grid.at(0, y)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100)
# Right wall
cell = grid.at(GRID_WIDTH - 1, y)
cell.walkable = False
cell.transparent = False
cell.sprite_index = 35 # '#' character
cell.color = mcrfpy.Color(100, 100, 100)
# Create the room
create_room()
# Create the player entity
player = mcrfpy.Entity(x=GRID_WIDTH // 2, y=GRID_HEIGHT // 2, grid=grid)
player.sprite_index = 64 # '@' character
player.color = mcrfpy.Color(255, 255, 255) # White
def move_player(dx, dy):
"""Move the player if the destination is walkable"""
# Calculate new position
new_x = player.x + dx
new_y = player.y + dy
# Check bounds
if new_x < 0 or new_x >= GRID_WIDTH or new_y < 0 or new_y >= GRID_HEIGHT:
return
# Check if the destination is walkable
destination = grid.at(new_x, new_y)
if destination.walkable:
# Move the player
player.x = new_x
player.y = new_y
def handle_input(key, state):
"""Handle keyboard input for player movement"""
# Only process key presses, not releases
if state != "start":
return
# Movement deltas
dx, dy = 0, 0
# Arrow keys
if key == "Up":
dy = -1
elif key == "Down":
dy = 1
elif key == "Left":
dx = -1
elif key == "Right":
dx = 1
# Numpad movement (for true roguelike feel!)
elif key == "Num7": # Northwest
dx, dy = -1, -1
elif key == "Num8": # North
dy = -1
elif key == "Num9": # Northeast
dx, dy = 1, -1
elif key == "Num4": # West
dx = -1
elif key == "Num6": # East
dx = 1
elif key == "Num1": # Southwest
dx, dy = -1, 1
elif key == "Num2": # South
dy = 1
elif key == "Num3": # Southeast
dx, dy = 1, 1
# Escape to quit
elif key == "Escape":
mcrfpy.setScene(None)
return
# If there's movement, try to move the player
if dx != 0 or dy != 0:
move_player(dx, dy)
# Register the input handler
mcrfpy.keypressScene(handle_input)
# Add UI elements
title = mcrfpy.Caption("McRogueFace Roguelike", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
ui.append(title)
instructions = mcrfpy.Caption("Arrow Keys or Numpad to move, ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
ui.append(instructions)
status = mcrfpy.Caption("@ You", 100, 600)
status.font_size = 18
status.fill_color = mcrfpy.Color(255, 255, 255)
ui.append(status)
print("Part 1: The @ symbol moves!")

View file

@ -1,562 +0,0 @@
# Part 2 - The Generic Entity, the Render Functions, and the Map
In Part 1, we created a player character that could move around a simple room. Now it's time to build a proper architecture for our roguelike. We'll create a flexible entity system, a proper map structure, and organize our code for future expansion.
## Understanding Game Architecture
Before diving into code, let's understand the architecture we're building:
1. **Entities**: Anything that can exist in the game world (player, monsters, items)
2. **Game Map**: The dungeon structure with tiles that can be walls or floors
3. **Game Engine**: Coordinates everything - entities, map, input, and rendering
In McRogueFace, we'll adapt these concepts to work with the engine's scene-based architecture.
## Creating a Flexible Entity System
While McRogueFace provides a built-in `Entity` class, we'll create a wrapper to add game-specific functionality:
```python
class GameObject:
"""Base class for all game objects (player, monsters, items)"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks # Does this entity block movement?
self._entity = None # The McRogueFace entity
self.grid = None # Reference to the grid
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = self.color
def move(self, dx, dy):
"""Move by the given amount if possible"""
if not self.grid:
return
new_x = self.x + dx
new_y = self.y + dy
# Update our position
self.x = new_x
self.y = new_y
# Update the visual entity
if self._entity:
self._entity.x = new_x
self._entity.y = new_y
def destroy(self):
"""Remove this entity from the game"""
if self._entity and self.grid:
# Find and remove from grid's entity list
for i, entity in enumerate(self.grid.entities):
if entity == self._entity:
del self.grid.entities[i]
break
self._entity = None
```
## Building the Game Map
Let's create a proper map class that manages our dungeon:
```python
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = [] # List of GameObjects
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
# Initialize all tiles as walls
self.fill_with_walls()
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, color=(100, 100, 100))
def set_tile(self, x, y, walkable, transparent, sprite_index, color):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*color)
def create_room(self, x1, y1, x2, y2):
"""Carve out a room in the map"""
# Make sure coordinates are in the right order
x1, x2 = min(x1, x2), max(x1, x2)
y1, y2 = min(y1, y2), max(y1, y2)
# Carve out floor tiles
for y in range(y1, y2 + 1):
for x in range(x1, x2 + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def create_tunnel_h(self, x1, x2, y):
"""Create a horizontal tunnel"""
for x in range(min(x1, x2), max(x1, x2) + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def create_tunnel_v(self, y1, y2, x):
"""Create a vertical tunnel"""
for y in range(min(y1, y2), max(y1, y2) + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
# Check map boundaries
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
# Check if tile is walkable
if not self.grid.at(x, y).walkable:
return True
# Check if any blocking entity is at this position
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
def get_blocking_entity_at(self, x, y):
"""Return any blocking entity at the given position"""
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return entity
return None
```
## Creating the Game Engine
Now let's build our game engine to tie everything together:
```python
class Engine:
"""Main game engine that manages game state"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
# Create the game scene
mcrfpy.createScene("game")
mcrfpy.setScene("game")
# Configure window
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 2"
# Get UI container
self.ui = mcrfpy.sceneUI("game")
# Add background
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
# Load tileset
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
# Create the game world
self.setup_game()
# Setup input handling
self.setup_input()
# Add UI elements
self.setup_ui()
def setup_game(self):
"""Initialize the game world"""
# Create the map
self.game_map = GameMap(50, 30)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
# Create some rooms
self.game_map.create_room(10, 10, 20, 20)
self.game_map.create_room(30, 15, 40, 25)
self.game_map.create_room(15, 22, 25, 28)
# Connect rooms with tunnels
self.game_map.create_tunnel_h(20, 30, 15)
self.game_map.create_tunnel_v(20, 22, 20)
# Create player
self.player = GameObject(15, 15, 64, (255, 255, 255), "Player", blocks=True)
self.game_map.add_entity(self.player)
# Create an NPC
npc = GameObject(35, 20, 64, (255, 255, 0), "NPC", blocks=True)
self.game_map.add_entity(npc)
self.entities.append(npc)
# Create some items (non-blocking)
potion = GameObject(12, 12, 33, (255, 0, 255), "Potion", blocks=False)
self.game_map.add_entity(potion)
self.entities.append(potion)
def handle_movement(self, dx, dy):
"""Handle player movement"""
new_x = self.player.x + dx
new_y = self.player.y + dy
# Check if movement is blocked
if not self.game_map.is_blocked(new_x, new_y):
self.player.move(dx, dy)
else:
# Check if we bumped into an entity
target = self.game_map.get_blocking_entity_at(new_x, new_y)
if target:
print(f"You bump into the {target.name}!")
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
# Movement keys
movement = {
"Up": (0, -1),
"Down": (0, 1),
"Left": (-1, 0),
"Right": (1, 0),
"Num7": (-1, -1),
"Num8": (0, -1),
"Num9": (1, -1),
"Num4": (-1, 0),
"Num6": (1, 0),
"Num1": (-1, 1),
"Num2": (0, 1),
"Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
self.handle_movement(dx, dy)
elif key == "Escape":
mcrfpy.setScene(None)
mcrfpy.keypressScene(handle_keys)
def setup_ui(self):
"""Setup UI elements"""
# Title
title = mcrfpy.Caption("McRogueFace Roguelike - Part 2", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
# Instructions
instructions = mcrfpy.Caption("Explore the dungeon! ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
```
## Putting It All Together
Here's the complete `game.py` file:
```python
import mcrfpy
class GameObject:
"""Base class for all game objects (player, monsters, items)"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount if possible"""
if not self.grid:
return
new_x = self.x + dx
new_y = self.y + dy
self.x = new_x
self.y = new_y
if self._entity:
self._entity.x = new_x
self._entity.y = new_y
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
self.fill_with_walls()
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, color=(100, 100, 100))
def set_tile(self, x, y, walkable, transparent, sprite_index, color):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*color)
def create_room(self, x1, y1, x2, y2):
"""Carve out a room in the map"""
x1, x2 = min(x1, x2), max(x1, x2)
y1, y2 = min(y1, y2), max(y1, y2)
for y in range(y1, y2 + 1):
for x in range(x1, x2 + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def create_tunnel_h(self, x1, x2, y):
"""Create a horizontal tunnel"""
for x in range(min(x1, x2), max(x1, x2) + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def create_tunnel_v(self, y1, y2, x):
"""Create a vertical tunnel"""
for y in range(min(y1, y2), max(y1, y2) + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
def get_blocking_entity_at(self, x, y):
"""Return any blocking entity at the given position"""
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return entity
return None
class Engine:
"""Main game engine that manages game state"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 2"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(50, 30)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
self.game_map.create_room(10, 10, 20, 20)
self.game_map.create_room(30, 15, 40, 25)
self.game_map.create_room(15, 22, 25, 28)
self.game_map.create_tunnel_h(20, 30, 15)
self.game_map.create_tunnel_v(20, 22, 20)
self.player = GameObject(15, 15, 64, (255, 255, 255), "Player", blocks=True)
self.game_map.add_entity(self.player)
npc = GameObject(35, 20, 64, (255, 255, 0), "NPC", blocks=True)
self.game_map.add_entity(npc)
self.entities.append(npc)
potion = GameObject(12, 12, 33, (255, 0, 255), "Potion", blocks=False)
self.game_map.add_entity(potion)
self.entities.append(potion)
def handle_movement(self, dx, dy):
"""Handle player movement"""
new_x = self.player.x + dx
new_y = self.player.y + dy
if not self.game_map.is_blocked(new_x, new_y):
self.player.move(dx, dy)
else:
target = self.game_map.get_blocking_entity_at(new_x, new_y)
if target:
print(f"You bump into the {target.name}!")
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
self.handle_movement(dx, dy)
elif key == "Escape":
mcrfpy.setScene(None)
mcrfpy.keypressScene(handle_keys)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("McRogueFace Roguelike - Part 2", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Explore the dungeon! ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# Create and run the game
engine = Engine()
print("Part 2: Entities and Maps!")
```
## Understanding the Architecture
### GameObject Class
Our `GameObject` class wraps McRogueFace's `Entity` and adds:
- Game logic properties (name, blocking)
- Position tracking independent of the visual entity
- Easy attachment/detachment from grids
### GameMap Class
The `GameMap` manages:
- The McRogueFace `Grid` for visual representation
- A list of all entities in the map
- Collision detection including entity blocking
- Map generation utilities (rooms, tunnels)
### Engine Class
The `Engine` coordinates everything:
- Scene and UI setup
- Game state management
- Input handling
- Entity-map interactions
## Key Improvements from Part 1
1. **Proper Entity Management**: Multiple entities can exist and interact
2. **Blocking Entities**: Some entities block movement, others don't
3. **Map Generation**: Tools for creating rooms and tunnels
4. **Collision System**: Checks both tiles and entities
5. **Organized Code**: Clear separation of concerns
## Exercises
1. **Add More Entity Types**: Create different sprites for monsters, items, and NPCs
2. **Entity Interactions**: Make items disappear when walked over
3. **Random Map Generation**: Place rooms and tunnels randomly
4. **Entity Properties**: Add health, damage, or other attributes to GameObjects
## What's Next?
In Part 3, we'll implement proper dungeon generation with:
- Procedurally generated rooms
- Smart tunnel routing
- Entity spawning
- The beginning of a real roguelike dungeon!
We now have a solid foundation with proper entity management and map structure. This architecture will serve us well as we add more complex features to our roguelike!

View file

@ -1,217 +0,0 @@
import mcrfpy
class GameObject:
"""Base class for all game objects (player, monsters, items)"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount if possible"""
if not self.grid:
return
new_x = self.x + dx
new_y = self.y + dy
self.x = new_x
self.y = new_y
if self._entity:
self._entity.x = new_x
self._entity.y = new_y
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
self.fill_with_walls()
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, color=(100, 100, 100))
def set_tile(self, x, y, walkable, transparent, sprite_index, color):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*color)
def create_room(self, x1, y1, x2, y2):
"""Carve out a room in the map"""
x1, x2 = min(x1, x2), max(x1, x2)
y1, y2 = min(y1, y2), max(y1, y2)
for y in range(y1, y2 + 1):
for x in range(x1, x2 + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def create_tunnel_h(self, x1, x2, y):
"""Create a horizontal tunnel"""
for x in range(min(x1, x2), max(x1, x2) + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def create_tunnel_v(self, y1, y2, x):
"""Create a vertical tunnel"""
for y in range(min(y1, y2), max(y1, y2) + 1):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
def get_blocking_entity_at(self, x, y):
"""Return any blocking entity at the given position"""
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return entity
return None
class Engine:
"""Main game engine that manages game state"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 2"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(50, 30)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
self.game_map.create_room(10, 10, 20, 20)
self.game_map.create_room(30, 15, 40, 25)
self.game_map.create_room(15, 22, 25, 28)
self.game_map.create_tunnel_h(20, 30, 15)
self.game_map.create_tunnel_v(20, 22, 20)
self.player = GameObject(15, 15, 64, (255, 255, 255), "Player", blocks=True)
self.game_map.add_entity(self.player)
npc = GameObject(35, 20, 64, (255, 255, 0), "NPC", blocks=True)
self.game_map.add_entity(npc)
self.entities.append(npc)
potion = GameObject(12, 12, 33, (255, 0, 255), "Potion", blocks=False)
self.game_map.add_entity(potion)
self.entities.append(potion)
def handle_movement(self, dx, dy):
"""Handle player movement"""
new_x = self.player.x + dx
new_y = self.player.y + dy
if not self.game_map.is_blocked(new_x, new_y):
self.player.move(dx, dy)
else:
target = self.game_map.get_blocking_entity_at(new_x, new_y)
if target:
print(f"You bump into the {target.name}!")
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
self.handle_movement(dx, dy)
elif key == "Escape":
mcrfpy.setScene(None)
mcrfpy.keypressScene(handle_keys)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("McRogueFace Roguelike - Part 2", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Explore the dungeon! ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# Create and run the game
engine = Engine()
print("Part 2: Entities and Maps!")

View file

@ -1,548 +0,0 @@
# Part 3 - Generating a Dungeon
In Parts 1 and 2, we created a player that could move around and interact with a hand-crafted dungeon. Now it's time to generate dungeons procedurally - a core feature of any roguelike game!
## The Plan
We'll create a dungeon generator that:
1. Places rectangular rooms randomly
2. Ensures rooms don't overlap
3. Connects rooms with tunnels
4. Places the player in the first room
This is a classic approach used by many roguelikes, and it creates interesting, playable dungeons.
## Creating a Room Class
First, let's create a class to represent rectangular rooms:
```python
class RectangularRoom:
"""A rectangular room with its position and size"""
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
@property
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
@property
def inner(self):
"""Return the inner area of the room as a tuple of slices
This property returns the area inside the walls.
We'll add 1 to min coordinates and subtract 1 from max coordinates.
"""
return self.x1 + 1, self.y1 + 1, self.x2 - 1, self.y2 - 1
def intersects(self, other):
"""Return True if this room overlaps with another RectangularRoom"""
return (
self.x1 <= other.x2
and self.x2 >= other.x1
and self.y1 <= other.y2
and self.y2 >= other.y1
)
```
## Implementing Tunnel Generation
Since McRogueFace doesn't include line-drawing algorithms, let's implement simple L-shaped tunnels:
```python
def tunnel_between(start, end):
"""Return an L-shaped tunnel between two points"""
x1, y1 = start
x2, y2 = end
# Randomly decide whether to go horizontal first or vertical first
if random.random() < 0.5:
# Horizontal, then vertical
corner_x = x2
corner_y = y1
else:
# Vertical, then horizontal
corner_x = x1
corner_y = y2
# Generate the coordinates
# First line: from start to corner
for x in range(min(x1, corner_x), max(x1, corner_x) + 1):
yield x, y1
for y in range(min(y1, corner_y), max(y1, corner_y) + 1):
yield corner_x, y
# Second line: from corner to end
for x in range(min(corner_x, x2), max(corner_x, x2) + 1):
yield x, corner_y
for y in range(min(corner_y, y2), max(corner_y, y2) + 1):
yield x2, y
```
## The Dungeon Generator
Now let's update our GameMap class to generate dungeons:
```python
import random
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
self.rooms = [] # Keep track of rooms for game logic
def generate_dungeon(
self,
max_rooms,
room_min_size,
room_max_size,
player
):
"""Generate a new dungeon map"""
# Start with everything as walls
self.fill_with_walls()
for r in range(max_rooms):
# Random width and height
room_width = random.randint(room_min_size, room_max_size)
room_height = random.randint(room_min_size, room_max_size)
# Random position without going out of bounds
x = random.randint(0, self.width - room_width - 1)
y = random.randint(0, self.height - room_height - 1)
# Create the room
new_room = RectangularRoom(x, y, room_width, room_height)
# Check if it intersects with any existing room
if any(new_room.intersects(other_room) for other_room in self.rooms):
continue # This room intersects, so go to the next attempt
# If we get here, it's a valid room
# Carve out this room
self.carve_room(new_room)
# Place the player in the center of the first room
if len(self.rooms) == 0:
player.x, player.y = new_room.center
if player._entity:
player._entity.x, player._entity.y = new_room.center
else:
# All rooms after the first:
# Tunnel between this room and the previous one
self.carve_tunnel(self.rooms[-1].center, new_room.center)
# Finally, append the new room to the list
self.rooms.append(new_room)
def carve_room(self, room):
"""Carve out a room"""
inner_x1, inner_y1, inner_x2, inner_y2 = room.inner
for y in range(inner_y1, inner_y2):
for x in range(inner_x1, inner_x2):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def carve_tunnel(self, start, end):
"""Carve a tunnel between two points"""
for x, y in tunnel_between(start, end):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(30, 30, 40)) # Slightly different color for tunnels
```
## Complete Code
Here's the complete `game.py` with procedural dungeon generation:
```python
import mcrfpy
import random
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount"""
if not self.grid:
return
self.x += dx
self.y += dy
if self._entity:
self._entity.x = self.x
self._entity.y = self.y
class RectangularRoom:
"""A rectangular room with its position and size"""
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
@property
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
@property
def inner(self):
"""Return the inner area of the room"""
return self.x1 + 1, self.y1 + 1, self.x2 - 1, self.y2 - 1
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
)
def tunnel_between(start, end):
"""Return an L-shaped tunnel between two points"""
x1, y1 = start
x2, y2 = end
if random.random() < 0.5:
corner_x = x2
corner_y = y1
else:
corner_x = x1
corner_y = y2
# Generate the coordinates
for x in range(min(x1, corner_x), max(x1, corner_x) + 1):
yield x, y1
for y in range(min(y1, corner_y), max(y1, corner_y) + 1):
yield corner_x, y
for x in range(min(corner_x, x2), max(corner_x, x2) + 1):
yield x, corner_y
for y in range(min(corner_y, y2), max(corner_y, y2) + 1):
yield x2, y
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
self.rooms = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, color=(100, 100, 100))
def set_tile(self, x, y, walkable, transparent, sprite_index, color):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*color)
def generate_dungeon(self, max_rooms, room_min_size, room_max_size, player):
"""Generate a new dungeon map"""
self.fill_with_walls()
for r in range(max_rooms):
room_width = random.randint(room_min_size, room_max_size)
room_height = random.randint(room_min_size, room_max_size)
x = random.randint(0, self.width - room_width - 1)
y = random.randint(0, self.height - room_height - 1)
new_room = RectangularRoom(x, y, room_width, room_height)
if any(new_room.intersects(other_room) for other_room in self.rooms):
continue
self.carve_room(new_room)
if len(self.rooms) == 0:
player.x, player.y = new_room.center
if player._entity:
player._entity.x, player._entity.y = new_room.center
else:
self.carve_tunnel(self.rooms[-1].center, new_room.center)
self.rooms.append(new_room)
def carve_room(self, room):
"""Carve out a room"""
inner_x1, inner_y1, inner_x2, inner_y2 = room.inner
for y in range(inner_y1, inner_y2):
for x in range(inner_x1, inner_x2):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def carve_tunnel(self, start, end):
"""Carve a tunnel between two points"""
for x, y in tunnel_between(start, end):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(30, 30, 40))
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
class Engine:
"""Main game engine"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 3"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(80, 45)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
# Create player (before dungeon generation)
self.player = GameObject(0, 0, 64, (255, 255, 255), "Player", blocks=True)
# Generate the dungeon
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player
)
# Add player to map
self.game_map.add_entity(self.player)
# Add some monsters in random rooms
for i in range(5):
if i < len(self.game_map.rooms) - 1: # Don't spawn in first room
room = self.game_map.rooms[i + 1]
x, y = room.center
# Create an orc
orc = GameObject(x, y, 111, (63, 127, 63), "Orc", blocks=True)
self.game_map.add_entity(orc)
self.entities.append(orc)
def handle_movement(self, dx, dy):
"""Handle player movement"""
new_x = self.player.x + dx
new_y = self.player.y + dy
if not self.game_map.is_blocked(new_x, new_y):
self.player.move(dx, dy)
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
self.handle_movement(dx, dy)
elif key == "Escape":
mcrfpy.setScene(None)
elif key == "Space":
# Regenerate the dungeon
self.regenerate_dungeon()
mcrfpy.keypressScene(handle_keys)
def regenerate_dungeon(self):
"""Generate a new dungeon"""
# Clear existing entities
self.game_map.entities.clear()
self.game_map.rooms.clear()
self.entities.clear()
# Clear the entity list in the grid
if self.game_map.grid:
self.game_map.grid.entities.clear()
# Regenerate
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player
)
# Re-add player
self.game_map.add_entity(self.player)
# Add new monsters
for i in range(5):
if i < len(self.game_map.rooms) - 1:
room = self.game_map.rooms[i + 1]
x, y = room.center
orc = GameObject(x, y, 111, (63, 127, 63), "Orc", blocks=True)
self.game_map.add_entity(orc)
self.entities.append(orc)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("Procedural Dungeon Generation", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Arrow keys to move, SPACE to regenerate, ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# Create and run the game
engine = Engine()
print("Part 3: Procedural Dungeon Generation!")
print("Press SPACE to generate a new dungeon")
```
## Understanding the Algorithm
Our dungeon generation algorithm is simple but effective:
1. **Start with solid walls** - The entire map begins filled with wall tiles
2. **Try to place rooms** - Generate random rooms and check for overlaps
3. **Connect with tunnels** - Each new room connects to the previous one
4. **Place entities** - The player starts in the first room, monsters in others
### Room Placement
The algorithm attempts to place `max_rooms` rooms, but may place fewer if many attempts result in overlapping rooms. This is called "rejection sampling" - we generate random rooms and reject ones that don't fit.
### Tunnel Design
Our L-shaped tunnels are simple but effective. They either go:
- Horizontal first, then vertical
- Vertical first, then horizontal
This creates variety while ensuring all rooms are connected.
## Experimenting with Parameters
Try adjusting these parameters to create different dungeon styles:
```python
# Sparse dungeon with large rooms
self.game_map.generate_dungeon(
max_rooms=10,
room_min_size=10,
room_max_size=15,
player=self.player
)
# Dense dungeon with small rooms
self.game_map.generate_dungeon(
max_rooms=50,
room_min_size=4,
room_max_size=6,
player=self.player
)
```
## Visual Enhancements
Notice how we gave tunnels a slightly different color:
- Rooms: `color=(50, 50, 50)` - Medium gray
- Tunnels: `color=(30, 30, 40)` - Darker with blue tint
This subtle difference helps players understand the dungeon layout.
## Exercises
1. **Different Room Shapes**: Create circular or cross-shaped rooms
2. **Better Tunnel Routing**: Implement A* pathfinding for more natural tunnels
3. **Room Types**: Create special rooms (treasure rooms, trap rooms)
4. **Dungeon Themes**: Use different tile sets and colors for different dungeon levels
## What's Next?
In Part 4, we'll implement Field of View (FOV) so the player can only see parts of the dungeon they've explored. This will add mystery and atmosphere to our procedurally generated dungeons!
Our dungeon generator is now creating unique, playable levels every time. The foundation of a true roguelike is taking shape!

View file

@ -1,312 +0,0 @@
import mcrfpy
import random
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount"""
if not self.grid:
return
self.x += dx
self.y += dy
if self._entity:
self._entity.x = self.x
self._entity.y = self.y
class RectangularRoom:
"""A rectangular room with its position and size"""
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
@property
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
@property
def inner(self):
"""Return the inner area of the room"""
return self.x1 + 1, self.y1 + 1, self.x2 - 1, self.y2 - 1
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
)
def tunnel_between(start, end):
"""Return an L-shaped tunnel between two points"""
x1, y1 = start
x2, y2 = end
if random.random() < 0.5:
corner_x = x2
corner_y = y1
else:
corner_x = x1
corner_y = y2
# Generate the coordinates
for x in range(min(x1, corner_x), max(x1, corner_x) + 1):
yield x, y1
for y in range(min(y1, corner_y), max(y1, corner_y) + 1):
yield corner_x, y
for x in range(min(corner_x, x2), max(corner_x, x2) + 1):
yield x, corner_y
for y in range(min(corner_y, y2), max(corner_y, y2) + 1):
yield x2, y
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
self.rooms = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, color=(100, 100, 100))
def set_tile(self, x, y, walkable, transparent, sprite_index, color):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*color)
def generate_dungeon(self, max_rooms, room_min_size, room_max_size, player):
"""Generate a new dungeon map"""
self.fill_with_walls()
for r in range(max_rooms):
room_width = random.randint(room_min_size, room_max_size)
room_height = random.randint(room_min_size, room_max_size)
x = random.randint(0, self.width - room_width - 1)
y = random.randint(0, self.height - room_height - 1)
new_room = RectangularRoom(x, y, room_width, room_height)
if any(new_room.intersects(other_room) for other_room in self.rooms):
continue
self.carve_room(new_room)
if len(self.rooms) == 0:
player.x, player.y = new_room.center
if player._entity:
player._entity.x, player._entity.y = new_room.center
else:
self.carve_tunnel(self.rooms[-1].center, new_room.center)
self.rooms.append(new_room)
def carve_room(self, room):
"""Carve out a room"""
inner_x1, inner_y1, inner_x2, inner_y2 = room.inner
for y in range(inner_y1, inner_y2):
for x in range(inner_x1, inner_x2):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(50, 50, 50))
def carve_tunnel(self, start, end):
"""Carve a tunnel between two points"""
for x, y in tunnel_between(start, end):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, color=(30, 30, 40))
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
class Engine:
"""Main game engine"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 3"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(80, 45)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
# Create player (before dungeon generation)
self.player = GameObject(0, 0, 64, (255, 255, 255), "Player", blocks=True)
# Generate the dungeon
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player
)
# Add player to map
self.game_map.add_entity(self.player)
# Add some monsters in random rooms
for i in range(5):
if i < len(self.game_map.rooms) - 1: # Don't spawn in first room
room = self.game_map.rooms[i + 1]
x, y = room.center
# Create an orc
orc = GameObject(x, y, 111, (63, 127, 63), "Orc", blocks=True)
self.game_map.add_entity(orc)
self.entities.append(orc)
def handle_movement(self, dx, dy):
"""Handle player movement"""
new_x = self.player.x + dx
new_y = self.player.y + dy
if not self.game_map.is_blocked(new_x, new_y):
self.player.move(dx, dy)
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
self.handle_movement(dx, dy)
elif key == "Escape":
mcrfpy.setScene(None)
elif key == "Space":
# Regenerate the dungeon
self.regenerate_dungeon()
mcrfpy.keypressScene(handle_keys)
def regenerate_dungeon(self):
"""Generate a new dungeon"""
# Clear existing entities
self.game_map.entities.clear()
self.game_map.rooms.clear()
self.entities.clear()
# Clear the entity list in the grid
if self.game_map.grid:
self.game_map.grid.entities.clear()
# Regenerate
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player
)
# Re-add player
self.game_map.add_entity(self.player)
# Add new monsters
for i in range(5):
if i < len(self.game_map.rooms) - 1:
room = self.game_map.rooms[i + 1]
x, y = room.center
orc = GameObject(x, y, 111, (63, 127, 63), "Orc", blocks=True)
self.game_map.add_entity(orc)
self.entities.append(orc)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("Procedural Dungeon Generation", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Arrow keys to move, SPACE to regenerate, ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# Create and run the game
engine = Engine()
print("Part 3: Procedural Dungeon Generation!")
print("Press SPACE to generate a new dungeon")

View file

@ -1,520 +0,0 @@
# Part 4 - Field of View
One of the defining features of roguelikes is exploration and discovery. In Part 3, we could see the entire dungeon at once. Now we'll implement Field of View (FOV) so players can only see what their character can actually see, adding mystery and tactical depth to our game.
## Understanding Field of View
Field of View creates three distinct visibility states for each tile:
1. **Visible**: Currently in the player's line of sight
2. **Explored**: Previously seen but not currently visible
3. **Unexplored**: Never seen (completely hidden)
This creates the classic "fog of war" effect where you remember the layout of areas you've explored, but can't see current enemy positions unless they're in your view.
## McRogueFace's FOV System
Good news! McRogueFace includes built-in FOV support through its C++ engine. We just need to enable and configure it. The engine uses an efficient shadowcasting algorithm that provides smooth, realistic line-of-sight calculations.
Let's update our code to use FOV:
```python
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount"""
if not self.grid:
return
self.x += dx
self.y += dy
if self._entity:
self._entity.x = self.x
self._entity.y = self.y
# Update FOV when player moves
if self.name == "Player":
self.update_fov()
def update_fov(self):
"""Update field of view from this entity's position"""
if self._entity and self.grid:
self._entity.update_fov(radius=8)
```
## Configuring Visibility Rendering
McRogueFace automatically handles the rendering of visible/explored/unexplored tiles. We need to set up our grid to use perspective-based rendering:
```python
class GameMap:
"""Manages the game world"""
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
# Enable perspective rendering (0 = first entity = player)
self.grid.perspective = 0
return self.grid
```
## Visual Appearance Configuration
Let's define how our tiles look in different visibility states:
```python
# Color configurations for visibility states
COLORS_VISIBLE = {
'wall': (100, 100, 100), # Light gray
'floor': (50, 50, 50), # Dark gray
'tunnel': (30, 30, 40), # Dark blue-gray
}
COLORS_EXPLORED = {
'wall': (50, 50, 70), # Darker, bluish
'floor': (20, 20, 30), # Very dark
'tunnel': (15, 15, 25), # Almost black
}
# Update the tile-setting methods to store the tile type
def set_tile(self, x, y, walkable, transparent, sprite_index, tile_type):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
# Store both visible and explored colors
cell.color = mcrfpy.Color(*COLORS_VISIBLE[tile_type])
# The engine will automatically darken explored tiles
```
## Complete Implementation
Here's the complete updated `game.py` with FOV:
```python
import mcrfpy
import random
# Color configurations for visibility
COLORS_VISIBLE = {
'wall': (100, 100, 100),
'floor': (50, 50, 50),
'tunnel': (30, 30, 40),
}
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount"""
if not self.grid:
return
self.x += dx
self.y += dy
if self._entity:
self._entity.x = self.x
self._entity.y = self.y
# Update FOV when player moves
if self.name == "Player":
self.update_fov()
def update_fov(self):
"""Update field of view from this entity's position"""
if self._entity and self.grid:
self._entity.update_fov(radius=8)
class RectangularRoom:
"""A rectangular room with its position and size"""
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
@property
def center(self):
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return center_x, center_y
@property
def inner(self):
return self.x1 + 1, self.y1 + 1, self.x2 - 1, self.y2 - 1
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 tunnel_between(start, end):
"""Return an L-shaped tunnel between two points"""
x1, y1 = start
x2, y2 = end
if random.random() < 0.5:
corner_x = x2
corner_y = y1
else:
corner_x = x1
corner_y = y2
for x in range(min(x1, corner_x), max(x1, corner_x) + 1):
yield x, y1
for y in range(min(y1, corner_y), max(y1, corner_y) + 1):
yield corner_x, y
for x in range(min(corner_x, x2), max(corner_x, x2) + 1):
yield x, corner_y
for y in range(min(corner_y, y2), max(corner_y, y2) + 1):
yield x2, y
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
self.rooms = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
# Enable perspective rendering (0 = first entity = player)
self.grid.perspective = 0
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, tile_type='wall')
def set_tile(self, x, y, walkable, transparent, sprite_index, tile_type):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*COLORS_VISIBLE[tile_type])
def generate_dungeon(self, max_rooms, room_min_size, room_max_size, player):
"""Generate a new dungeon map"""
self.fill_with_walls()
for r in range(max_rooms):
room_width = random.randint(room_min_size, room_max_size)
room_height = random.randint(room_min_size, room_max_size)
x = random.randint(0, self.width - room_width - 1)
y = random.randint(0, self.height - room_height - 1)
new_room = RectangularRoom(x, y, room_width, room_height)
if any(new_room.intersects(other_room) for other_room in self.rooms):
continue
self.carve_room(new_room)
if len(self.rooms) == 0:
player.x, player.y = new_room.center
if player._entity:
player._entity.x, player._entity.y = new_room.center
else:
self.carve_tunnel(self.rooms[-1].center, new_room.center)
self.rooms.append(new_room)
def carve_room(self, room):
"""Carve out a room"""
inner_x1, inner_y1, inner_x2, inner_y2 = room.inner
for y in range(inner_y1, inner_y2):
for x in range(inner_x1, inner_x2):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='floor')
def carve_tunnel(self, start, end):
"""Carve a tunnel between two points"""
for x, y in tunnel_between(start, end):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='tunnel')
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
class Engine:
"""Main game engine"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
self.fov_radius = 8
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 4"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(80, 45)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
# Create player
self.player = GameObject(0, 0, 64, (255, 255, 255), "Player", blocks=True)
# Generate the dungeon
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player
)
# Add player to map
self.game_map.add_entity(self.player)
# Add monsters in random rooms
for i in range(10):
if i < len(self.game_map.rooms) - 1:
room = self.game_map.rooms[i + 1]
x, y = room.center
# Randomly offset from center
x += random.randint(-2, 2)
y += random.randint(-2, 2)
# Make sure position is walkable
if self.game_map.grid.at(x, y).walkable:
if i % 2 == 0:
# Create an orc
orc = GameObject(x, y, 111, (63, 127, 63), "Orc", blocks=True)
self.game_map.add_entity(orc)
self.entities.append(orc)
else:
# Create a troll
troll = GameObject(x, y, 84, (0, 127, 0), "Troll", blocks=True)
self.game_map.add_entity(troll)
self.entities.append(troll)
# Initial FOV calculation
self.player.update_fov()
def handle_movement(self, dx, dy):
"""Handle player movement"""
new_x = self.player.x + dx
new_y = self.player.y + dy
if not self.game_map.is_blocked(new_x, new_y):
self.player.move(dx, dy)
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
self.handle_movement(dx, dy)
elif key == "Escape":
mcrfpy.setScene(None)
elif key == "v":
# Toggle FOV on/off
if self.game_map.grid.perspective == 0:
self.game_map.grid.perspective = -1 # Omniscient
print("FOV disabled - omniscient view")
else:
self.game_map.grid.perspective = 0 # Player perspective
print("FOV enabled - player perspective")
elif key == "Plus" or key == "Equals":
# Increase FOV radius
self.fov_radius = min(self.fov_radius + 1, 20)
self.player._entity.update_fov(radius=self.fov_radius)
print(f"FOV radius: {self.fov_radius}")
elif key == "Minus":
# Decrease FOV radius
self.fov_radius = max(self.fov_radius - 1, 3)
self.player._entity.update_fov(radius=self.fov_radius)
print(f"FOV radius: {self.fov_radius}")
mcrfpy.keypressScene(handle_keys)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("Field of View", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Arrow keys to move | V to toggle FOV | +/- to adjust radius | ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# FOV indicator
self.fov_text = mcrfpy.Caption(f"FOV Radius: {self.fov_radius}", 900, 100)
self.fov_text.font_size = 14
self.fov_text.fill_color = mcrfpy.Color(150, 200, 255)
self.ui.append(self.fov_text)
# Create and run the game
engine = Engine()
print("Part 4: Field of View!")
print("Press V to toggle FOV on/off")
print("Press +/- to adjust FOV radius")
```
## How FOV Works
McRogueFace's built-in FOV system uses a shadowcasting algorithm that:
1. **Casts rays** from the player's position to tiles within the radius
2. **Checks transparency** along each ray path
3. **Marks tiles as visible** if the ray reaches them unobstructed
4. **Remembers explored tiles** automatically
The engine handles all the complex calculations in C++ for optimal performance.
## Visibility States in Detail
### Visible Tiles
- Currently in the player's line of sight
- Rendered at full brightness
- Show current entity positions
### Explored Tiles
- Previously seen but not currently visible
- Rendered darker/muted
- Show remembered terrain but not entities
### Unexplored Tiles
- Never been in the player's FOV
- Rendered as black/invisible
- Complete mystery to the player
## FOV Parameters
You can customize FOV behavior:
```python
# Basic FOV update
entity.update_fov(radius=8)
# The grid's perspective property controls rendering:
grid.perspective = 0 # Use first entity's FOV (player)
grid.perspective = 1 # Use second entity's FOV
grid.perspective = -1 # Omniscient (no FOV, see everything)
```
## Performance Considerations
McRogueFace's C++ FOV implementation is highly optimized:
- Uses efficient shadowcasting algorithm
- Only recalculates when needed
- Handles large maps smoothly
- Automatically culls entities outside FOV
## Visual Polish
The engine automatically handles visual transitions:
- Smooth color changes between visibility states
- Entities fade in/out of view
- Explored areas remain visible but dimmed
## Exercises
1. **Variable Vision**: Give different entities different FOV radii
2. **Light Sources**: Create torches that expand local FOV
3. **Blind Spots**: Add pillars that create interesting shadows
4. **X-Ray Vision**: Temporary power-up to see through walls
## What's Next?
In Part 5, we'll place enemies throughout the dungeon and implement basic interactions. With FOV in place, enemies will appear and disappear as you explore, creating tension and surprise!
Field of View transforms our dungeon from a tactical puzzle into a mysterious world to explore. The fog of war adds atmosphere and gameplay depth that's essential to the roguelike experience.

View file

@ -1,334 +0,0 @@
import mcrfpy
import random
# Color configurations for visibility
COLORS_VISIBLE = {
'wall': (100, 100, 100),
'floor': (50, 50, 50),
'tunnel': (30, 30, 40),
}
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount"""
if not self.grid:
return
self.x += dx
self.y += dy
if self._entity:
self._entity.x = self.x
self._entity.y = self.y
# Update FOV when player moves
if self.name == "Player":
self.update_fov()
def update_fov(self):
"""Update field of view from this entity's position"""
if self._entity and self.grid:
self._entity.update_fov(radius=8)
class RectangularRoom:
"""A rectangular room with its position and size"""
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
@property
def center(self):
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return center_x, center_y
@property
def inner(self):
return self.x1 + 1, self.y1 + 1, self.x2 - 1, self.y2 - 1
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 tunnel_between(start, end):
"""Return an L-shaped tunnel between two points"""
x1, y1 = start
x2, y2 = end
if random.random() < 0.5:
corner_x = x2
corner_y = y1
else:
corner_x = x1
corner_y = y2
for x in range(min(x1, corner_x), max(x1, corner_x) + 1):
yield x, y1
for y in range(min(y1, corner_y), max(y1, corner_y) + 1):
yield corner_x, y
for x in range(min(corner_x, x2), max(corner_x, x2) + 1):
yield x, corner_y
for y in range(min(corner_y, y2), max(corner_y, y2) + 1):
yield x2, y
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
self.rooms = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
# Enable perspective rendering (0 = first entity = player)
self.grid.perspective = 0
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, tile_type='wall')
def set_tile(self, x, y, walkable, transparent, sprite_index, tile_type):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*COLORS_VISIBLE[tile_type])
def generate_dungeon(self, max_rooms, room_min_size, room_max_size, player):
"""Generate a new dungeon map"""
self.fill_with_walls()
for r in range(max_rooms):
room_width = random.randint(room_min_size, room_max_size)
room_height = random.randint(room_min_size, room_max_size)
x = random.randint(0, self.width - room_width - 1)
y = random.randint(0, self.height - room_height - 1)
new_room = RectangularRoom(x, y, room_width, room_height)
if any(new_room.intersects(other_room) for other_room in self.rooms):
continue
self.carve_room(new_room)
if len(self.rooms) == 0:
player.x, player.y = new_room.center
if player._entity:
player._entity.x, player._entity.y = new_room.center
else:
self.carve_tunnel(self.rooms[-1].center, new_room.center)
self.rooms.append(new_room)
def carve_room(self, room):
"""Carve out a room"""
inner_x1, inner_y1, inner_x2, inner_y2 = room.inner
for y in range(inner_y1, inner_y2):
for x in range(inner_x1, inner_x2):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='floor')
def carve_tunnel(self, start, end):
"""Carve a tunnel between two points"""
for x, y in tunnel_between(start, end):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='tunnel')
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
class Engine:
"""Main game engine"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
self.fov_radius = 8
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 4"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(80, 45)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
# Create player
self.player = GameObject(0, 0, 64, (255, 255, 255), "Player", blocks=True)
# Generate the dungeon
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player
)
# Add player to map
self.game_map.add_entity(self.player)
# Add monsters in random rooms
for i in range(10):
if i < len(self.game_map.rooms) - 1:
room = self.game_map.rooms[i + 1]
x, y = room.center
# Randomly offset from center
x += random.randint(-2, 2)
y += random.randint(-2, 2)
# Make sure position is walkable
if self.game_map.grid.at(x, y).walkable:
if i % 2 == 0:
# Create an orc
orc = GameObject(x, y, 111, (63, 127, 63), "Orc", blocks=True)
self.game_map.add_entity(orc)
self.entities.append(orc)
else:
# Create a troll
troll = GameObject(x, y, 84, (0, 127, 0), "Troll", blocks=True)
self.game_map.add_entity(troll)
self.entities.append(troll)
# Initial FOV calculation
self.player.update_fov()
def handle_movement(self, dx, dy):
"""Handle player movement"""
new_x = self.player.x + dx
new_y = self.player.y + dy
if not self.game_map.is_blocked(new_x, new_y):
self.player.move(dx, dy)
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
self.handle_movement(dx, dy)
elif key == "Escape":
mcrfpy.setScene(None)
elif key == "v":
# Toggle FOV on/off
if self.game_map.grid.perspective == 0:
self.game_map.grid.perspective = -1 # Omniscient
print("FOV disabled - omniscient view")
else:
self.game_map.grid.perspective = 0 # Player perspective
print("FOV enabled - player perspective")
elif key == "Plus" or key == "Equals":
# Increase FOV radius
self.fov_radius = min(self.fov_radius + 1, 20)
self.player._entity.update_fov(radius=self.fov_radius)
print(f"FOV radius: {self.fov_radius}")
elif key == "Minus":
# Decrease FOV radius
self.fov_radius = max(self.fov_radius - 1, 3)
self.player._entity.update_fov(radius=self.fov_radius)
print(f"FOV radius: {self.fov_radius}")
mcrfpy.keypressScene(handle_keys)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("Field of View", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Arrow keys to move | V to toggle FOV | +/- to adjust radius | ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# FOV indicator
self.fov_text = mcrfpy.Caption(f"FOV Radius: {self.fov_radius}", 900, 100)
self.fov_text.font_size = 14
self.fov_text.fill_color = mcrfpy.Color(150, 200, 255)
self.ui.append(self.fov_text)
# Create and run the game
engine = Engine()
print("Part 4: Field of View!")
print("Press V to toggle FOV on/off")
print("Press +/- to adjust FOV radius")

View file

@ -1,570 +0,0 @@
# Part 5 - Placing Enemies and Kicking Them (Harmlessly)
Now that we have Field of View working, it's time to populate our dungeon with enemies! In this part, we'll:
- Place enemies randomly in rooms
- Implement entity-to-entity collision detection
- Create basic interactions (bumping into enemies)
- Set the stage for combat in Part 6
## Enemy Spawning System
First, let's create a system to spawn enemies in our dungeon rooms. We'll avoid placing them in the first room (where the player starts) to give players a safe starting area.
```python
def spawn_enemies_in_room(room, game_map, max_enemies=2):
"""Spawn between 0 and max_enemies in a room"""
import random
number_of_enemies = random.randint(0, max_enemies)
for i in range(number_of_enemies):
# Try to find a valid position
attempts = 10
while attempts > 0:
# Random position within room bounds
x = random.randint(room.x1 + 1, room.x2 - 1)
y = random.randint(room.y1 + 1, room.y2 - 1)
# Check if position is valid
if not game_map.is_blocked(x, y):
# 80% chance for orc, 20% for troll
if random.random() < 0.8:
enemy = GameObject(x, y, 111, (63, 127, 63), "Orc", blocks=True)
else:
enemy = GameObject(x, y, 84, (0, 127, 0), "Troll", blocks=True)
game_map.add_entity(enemy)
break
attempts -= 1
```
## Enhanced Collision Detection
We need to improve our collision detection to check for entities, not just walls:
```python
class GameMap:
"""Manages the game world"""
def get_blocking_entity_at(self, x, y):
"""Return any blocking entity at the given position"""
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return entity
return None
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
# Check boundaries
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
# Check walls
if not self.grid.at(x, y).walkable:
return True
# Check entities
if self.get_blocking_entity_at(x, y):
return True
return False
```
## Action System Introduction
Let's create a simple action system to handle different types of interactions:
```python
class Action:
"""Base class for all actions"""
pass
class MovementAction(Action):
"""Action for moving an entity"""
def __init__(self, dx, dy):
self.dx = dx
self.dy = dy
class BumpAction(Action):
"""Action for bumping into something"""
def __init__(self, dx, dy, target=None):
self.dx = dx
self.dy = dy
self.target = target
class WaitAction(Action):
"""Action for waiting/skipping turn"""
pass
```
## Handling Player Actions
Now let's update our movement handling to support bumping into enemies:
```python
def handle_player_turn(self, action):
"""Process the player's action"""
if isinstance(action, MovementAction):
dest_x = self.player.x + action.dx
dest_y = self.player.y + action.dy
# Check what's at the destination
target = self.game_map.get_blocking_entity_at(dest_x, dest_y)
if target:
# We bumped into something!
print(f"You kick the {target.name} in the shins, much to its annoyance!")
elif not self.game_map.is_blocked(dest_x, dest_y):
# Move the player
self.player.move(action.dx, action.dy)
# Update message
self.status_text.text = "Exploring the dungeon..."
else:
# Bumped into a wall
self.status_text.text = "Ouch! You bump into a wall."
elif isinstance(action, WaitAction):
self.status_text.text = "You wait..."
```
## Complete Updated Code
Here's the complete `game.py` with enemy placement and interactions:
```python
import mcrfpy
import random
# Color configurations
COLORS_VISIBLE = {
'wall': (100, 100, 100),
'floor': (50, 50, 50),
'tunnel': (30, 30, 40),
}
# Actions
class Action:
"""Base class for all actions"""
pass
class MovementAction(Action):
"""Action for moving an entity"""
def __init__(self, dx, dy):
self.dx = dx
self.dy = dy
class WaitAction(Action):
"""Action for waiting/skipping turn"""
pass
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount"""
if not self.grid:
return
self.x += dx
self.y += dy
if self._entity:
self._entity.x = self.x
self._entity.y = self.y
# Update FOV when player moves
if self.name == "Player":
self.update_fov()
def update_fov(self):
"""Update field of view from this entity's position"""
if self._entity and self.grid:
self._entity.update_fov(radius=8)
class RectangularRoom:
"""A rectangular room with its position and size"""
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
@property
def center(self):
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return center_x, center_y
@property
def inner(self):
return self.x1 + 1, self.y1 + 1, self.x2 - 1, self.y2 - 1
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 tunnel_between(start, end):
"""Return an L-shaped tunnel between two points"""
x1, y1 = start
x2, y2 = end
if random.random() < 0.5:
corner_x = x2
corner_y = y1
else:
corner_x = x1
corner_y = y2
for x in range(min(x1, corner_x), max(x1, corner_x) + 1):
yield x, y1
for y in range(min(y1, corner_y), max(y1, corner_y) + 1):
yield corner_x, y
for x in range(min(corner_x, x2), max(corner_x, x2) + 1):
yield x, corner_y
for y in range(min(corner_y, y2), max(corner_y, y2) + 1):
yield x2, y
def spawn_enemies_in_room(room, game_map, max_enemies=2):
"""Spawn between 0 and max_enemies in a room"""
number_of_enemies = random.randint(0, max_enemies)
enemies_spawned = []
for i in range(number_of_enemies):
# Try to find a valid position
attempts = 10
while attempts > 0:
# Random position within room bounds
x = random.randint(room.x1 + 1, room.x2 - 1)
y = random.randint(room.y1 + 1, room.y2 - 1)
# Check if position is valid
if not game_map.is_blocked(x, y):
# 80% chance for orc, 20% for troll
if random.random() < 0.8:
enemy = GameObject(x, y, 111, (63, 127, 63), "Orc", blocks=True)
else:
enemy = GameObject(x, y, 84, (0, 127, 0), "Troll", blocks=True)
game_map.add_entity(enemy)
enemies_spawned.append(enemy)
break
attempts -= 1
return enemies_spawned
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
self.rooms = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
# Enable perspective rendering
self.grid.perspective = 0
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, tile_type='wall')
def set_tile(self, x, y, walkable, transparent, sprite_index, tile_type):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*COLORS_VISIBLE[tile_type])
def generate_dungeon(self, max_rooms, room_min_size, room_max_size, player, max_enemies_per_room):
"""Generate a new dungeon map"""
self.fill_with_walls()
for r in range(max_rooms):
room_width = random.randint(room_min_size, room_max_size)
room_height = random.randint(room_min_size, room_max_size)
x = random.randint(0, self.width - room_width - 1)
y = random.randint(0, self.height - room_height - 1)
new_room = RectangularRoom(x, y, room_width, room_height)
if any(new_room.intersects(other_room) for other_room in self.rooms):
continue
self.carve_room(new_room)
if len(self.rooms) == 0:
# First room - place player
player.x, player.y = new_room.center
if player._entity:
player._entity.x, player._entity.y = new_room.center
else:
# All other rooms - add tunnel and enemies
self.carve_tunnel(self.rooms[-1].center, new_room.center)
spawn_enemies_in_room(new_room, self, max_enemies_per_room)
self.rooms.append(new_room)
def carve_room(self, room):
"""Carve out a room"""
inner_x1, inner_y1, inner_x2, inner_y2 = room.inner
for y in range(inner_y1, inner_y2):
for x in range(inner_x1, inner_x2):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='floor')
def carve_tunnel(self, start, end):
"""Carve a tunnel between two points"""
for x, y in tunnel_between(start, end):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='tunnel')
def get_blocking_entity_at(self, x, y):
"""Return any blocking entity at the given position"""
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return entity
return None
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
if self.get_blocking_entity_at(x, y):
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
class Engine:
"""Main game engine"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 5"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(80, 45)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
# Create player
self.player = GameObject(0, 0, 64, (255, 255, 255), "Player", blocks=True)
# Generate the dungeon
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player,
max_enemies_per_room=2
)
# Add player to map
self.game_map.add_entity(self.player)
# Store reference to all entities
self.entities = [e for e in self.game_map.entities if e != self.player]
# Initial FOV calculation
self.player.update_fov()
def handle_player_turn(self, action):
"""Process the player's action"""
if isinstance(action, MovementAction):
dest_x = self.player.x + action.dx
dest_y = self.player.y + action.dy
# Check what's at the destination
target = self.game_map.get_blocking_entity_at(dest_x, dest_y)
if target:
# We bumped into something!
print(f"You kick the {target.name} in the shins, much to its annoyance!")
self.status_text.text = f"You kick the {target.name}!"
elif not self.game_map.is_blocked(dest_x, dest_y):
# Move the player
self.player.move(action.dx, action.dy)
self.status_text.text = ""
else:
# Bumped into a wall
self.status_text.text = "Blocked!"
elif isinstance(action, WaitAction):
self.status_text.text = "You wait..."
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
action = None
# Movement keys
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num5": (0, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
if dx == 0 and dy == 0:
action = WaitAction()
else:
action = MovementAction(dx, dy)
elif key == "Period":
action = WaitAction()
elif key == "Escape":
mcrfpy.setScene(None)
return
# Process the action
if action:
self.handle_player_turn(action)
mcrfpy.keypressScene(handle_keys)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("Placing Enemies", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Arrow keys to move | . to wait | Bump into enemies! | ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# Status text
self.status_text = mcrfpy.Caption("", 512, 600)
self.status_text.font_size = 18
self.status_text.fill_color = mcrfpy.Color(255, 200, 200)
self.ui.append(self.status_text)
# Entity count
entity_count = len(self.entities)
count_text = mcrfpy.Caption(f"Enemies: {entity_count}", 900, 100)
count_text.font_size = 14
count_text.fill_color = mcrfpy.Color(150, 150, 255)
self.ui.append(count_text)
# Create and run the game
engine = Engine()
print("Part 5: Placing Enemies!")
print("Try bumping into enemies - combat coming in Part 6!")
```
## Understanding Entity Interactions
### Collision Detection
Our system now checks three things when the player tries to move:
1. **Map boundaries** - Can't move outside the map
2. **Wall tiles** - Can't walk through walls
3. **Blocking entities** - Can't walk through enemies
### The Action System
We've introduced a simple action system that will grow in Part 6:
- `Action` - Base class for all actions
- `MovementAction` - Represents attempted movement
- `WaitAction` - Skip a turn (important for turn-based games)
### Entity Spawning
Enemies are placed randomly in rooms with these rules:
- Never in the first room (player's starting room)
- Random number between 0 and max per room
- 80% orcs, 20% trolls
- Must be placed on walkable, unoccupied tiles
## Visual Feedback
With FOV enabled, enemies will appear and disappear as you explore:
- Enemies in sight are fully visible
- Enemies in explored but dark areas are hidden
- Creates tension and surprise encounters
## Exercises
1. **More Enemy Types**: Add different sprites and names (goblins, skeletons)
2. **Enemy Density**: Adjust spawn rates based on dungeon depth
3. **Special Rooms**: Create rooms with guaranteed enemies or treasures
4. **Better Feedback**: Add sound effects or visual effects for bumping
## What's Next?
In Part 6, we'll transform those harmless kicks into a real combat system! We'll add:
- Health points for all entities
- Damage calculations
- Death and corpses
- Combat messages
- The beginning of a real roguelike!
Right now our enemies are just obstacles. Soon they'll fight back!

View file

@ -1,388 +0,0 @@
import mcrfpy
import random
# Color configurations
COLORS_VISIBLE = {
'wall': (100, 100, 100),
'floor': (50, 50, 50),
'tunnel': (30, 30, 40),
}
# Actions
class Action:
"""Base class for all actions"""
pass
class MovementAction(Action):
"""Action for moving an entity"""
def __init__(self, dx, dy):
self.dx = dx
self.dy = dy
class WaitAction(Action):
"""Action for waiting/skipping turn"""
pass
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name, blocks=False):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount"""
if not self.grid:
return
self.x += dx
self.y += dy
if self._entity:
self._entity.x = self.x
self._entity.y = self.y
# Update FOV when player moves
if self.name == "Player":
self.update_fov()
def update_fov(self):
"""Update field of view from this entity's position"""
if self._entity and self.grid:
self._entity.update_fov(radius=8)
class RectangularRoom:
"""A rectangular room with its position and size"""
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
@property
def center(self):
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return center_x, center_y
@property
def inner(self):
return self.x1 + 1, self.y1 + 1, self.x2 - 1, self.y2 - 1
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 tunnel_between(start, end):
"""Return an L-shaped tunnel between two points"""
x1, y1 = start
x2, y2 = end
if random.random() < 0.5:
corner_x = x2
corner_y = y1
else:
corner_x = x1
corner_y = y2
for x in range(min(x1, corner_x), max(x1, corner_x) + 1):
yield x, y1
for y in range(min(y1, corner_y), max(y1, corner_y) + 1):
yield corner_x, y
for x in range(min(corner_x, x2), max(corner_x, x2) + 1):
yield x, corner_y
for y in range(min(corner_y, y2), max(corner_y, y2) + 1):
yield x2, y
def spawn_enemies_in_room(room, game_map, max_enemies=2):
"""Spawn between 0 and max_enemies in a room"""
number_of_enemies = random.randint(0, max_enemies)
enemies_spawned = []
for i in range(number_of_enemies):
# Try to find a valid position
attempts = 10
while attempts > 0:
# Random position within room bounds
x = random.randint(room.x1 + 1, room.x2 - 1)
y = random.randint(room.y1 + 1, room.y2 - 1)
# Check if position is valid
if not game_map.is_blocked(x, y):
# 80% chance for orc, 20% for troll
if random.random() < 0.8:
enemy = GameObject(x, y, 111, (63, 127, 63), "Orc", blocks=True)
else:
enemy = GameObject(x, y, 84, (0, 127, 0), "Troll", blocks=True)
game_map.add_entity(enemy)
enemies_spawned.append(enemy)
break
attempts -= 1
return enemies_spawned
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
self.rooms = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
# Enable perspective rendering
self.grid.perspective = 0
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, tile_type='wall')
def set_tile(self, x, y, walkable, transparent, sprite_index, tile_type):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*COLORS_VISIBLE[tile_type])
def generate_dungeon(self, max_rooms, room_min_size, room_max_size, player, max_enemies_per_room):
"""Generate a new dungeon map"""
self.fill_with_walls()
for r in range(max_rooms):
room_width = random.randint(room_min_size, room_max_size)
room_height = random.randint(room_min_size, room_max_size)
x = random.randint(0, self.width - room_width - 1)
y = random.randint(0, self.height - room_height - 1)
new_room = RectangularRoom(x, y, room_width, room_height)
if any(new_room.intersects(other_room) for other_room in self.rooms):
continue
self.carve_room(new_room)
if len(self.rooms) == 0:
# First room - place player
player.x, player.y = new_room.center
if player._entity:
player._entity.x, player._entity.y = new_room.center
else:
# All other rooms - add tunnel and enemies
self.carve_tunnel(self.rooms[-1].center, new_room.center)
spawn_enemies_in_room(new_room, self, max_enemies_per_room)
self.rooms.append(new_room)
def carve_room(self, room):
"""Carve out a room"""
inner_x1, inner_y1, inner_x2, inner_y2 = room.inner
for y in range(inner_y1, inner_y2):
for x in range(inner_x1, inner_x2):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='floor')
def carve_tunnel(self, start, end):
"""Carve a tunnel between two points"""
for x, y in tunnel_between(start, end):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='tunnel')
def get_blocking_entity_at(self, x, y):
"""Return any blocking entity at the given position"""
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return entity
return None
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
if self.get_blocking_entity_at(x, y):
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
class Engine:
"""Main game engine"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 5"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(80, 45)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
# Create player
self.player = GameObject(0, 0, 64, (255, 255, 255), "Player", blocks=True)
# Generate the dungeon
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player,
max_enemies_per_room=2
)
# Add player to map
self.game_map.add_entity(self.player)
# Store reference to all entities
self.entities = [e for e in self.game_map.entities if e != self.player]
# Initial FOV calculation
self.player.update_fov()
def handle_player_turn(self, action):
"""Process the player's action"""
if isinstance(action, MovementAction):
dest_x = self.player.x + action.dx
dest_y = self.player.y + action.dy
# Check what's at the destination
target = self.game_map.get_blocking_entity_at(dest_x, dest_y)
if target:
# We bumped into something!
print(f"You kick the {target.name} in the shins, much to its annoyance!")
self.status_text.text = f"You kick the {target.name}!"
elif not self.game_map.is_blocked(dest_x, dest_y):
# Move the player
self.player.move(action.dx, action.dy)
self.status_text.text = ""
else:
# Bumped into a wall
self.status_text.text = "Blocked!"
elif isinstance(action, WaitAction):
self.status_text.text = "You wait..."
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
action = None
# Movement keys
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num5": (0, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
if dx == 0 and dy == 0:
action = WaitAction()
else:
action = MovementAction(dx, dy)
elif key == "Period":
action = WaitAction()
elif key == "Escape":
mcrfpy.setScene(None)
return
# Process the action
if action:
self.handle_player_turn(action)
mcrfpy.keypressScene(handle_keys)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("Placing Enemies", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Arrow keys to move | . to wait | Bump into enemies! | ESC to quit", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# Status text
self.status_text = mcrfpy.Caption("", 512, 600)
self.status_text.font_size = 18
self.status_text.fill_color = mcrfpy.Color(255, 200, 200)
self.ui.append(self.status_text)
# Entity count
entity_count = len(self.entities)
count_text = mcrfpy.Caption(f"Enemies: {entity_count}", 900, 100)
count_text.font_size = 14
count_text.fill_color = mcrfpy.Color(150, 150, 255)
self.ui.append(count_text)
# Create and run the game
engine = Engine()
print("Part 5: Placing Enemies!")
print("Try bumping into enemies - combat coming in Part 6!")

View file

@ -1,743 +0,0 @@
# Part 6 - Doing (and Taking) Some Damage
It's time to turn our harmless kicks into real combat! In this part, we'll implement:
- Health points for all entities
- A damage calculation system
- Death and corpse mechanics
- Combat feedback messages
- The foundation of tactical roguelike combat
## Adding Combat Stats
First, let's enhance our GameObject class with combat capabilities:
```python
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name,
blocks=False, hp=0, defense=0, power=0):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
# Combat stats
self.max_hp = hp
self.hp = hp
self.defense = defense
self.power = power
@property
def is_alive(self):
"""Returns True if this entity can act"""
return self.hp > 0
def take_damage(self, amount):
"""Apply damage to this entity"""
damage = amount - self.defense
if damage > 0:
self.hp -= damage
# Check for death
if self.hp <= 0 and self.hp + damage > 0:
self.die()
return damage
def die(self):
"""Handle entity death"""
if self.name == "Player":
# Player death is special - we'll handle it differently
self.sprite_index = 64 # Stay as @ but change color
self.color = (127, 0, 0) # Dark red
if self._entity:
self._entity.color = mcrfpy.Color(127, 0, 0)
print("You have died!")
else:
# Enemy death
self.sprite_index = 37 # % character for corpse
self.color = (127, 0, 0) # Dark red
self.blocks = False # Corpses don't block
self.name = f"remains of {self.name}"
if self._entity:
self._entity.sprite_index = 37
self._entity.color = mcrfpy.Color(127, 0, 0)
```
## The Combat System
Now let's implement actual combat when entities bump into each other:
```python
class MeleeAction(Action):
"""Action for melee attacks"""
def __init__(self, attacker, target):
self.attacker = attacker
self.target = target
def perform(self):
"""Execute the attack"""
if not self.target.is_alive:
return # Can't attack the dead
damage = self.attacker.power - self.target.defense
if damage > 0:
attack_desc = f"{self.attacker.name} attacks {self.target.name} for {damage} damage!"
self.target.take_damage(damage)
else:
attack_desc = f"{self.attacker.name} attacks {self.target.name} but does no damage."
return attack_desc
```
## Entity Factories
Let's create factory functions for consistent entity creation:
```python
def create_player(x, y):
"""Create the player entity"""
return GameObject(
x=x, y=y,
sprite_index=64, # @
color=(255, 255, 255),
name="Player",
blocks=True,
hp=30,
defense=2,
power=5
)
def create_orc(x, y):
"""Create an orc enemy"""
return GameObject(
x=x, y=y,
sprite_index=111, # o
color=(63, 127, 63),
name="Orc",
blocks=True,
hp=10,
defense=0,
power=3
)
def create_troll(x, y):
"""Create a troll enemy"""
return GameObject(
x=x, y=y,
sprite_index=84, # T
color=(0, 127, 0),
name="Troll",
blocks=True,
hp=16,
defense=1,
power=4
)
```
## The Message Log
Combat needs feedback! Let's create a simple message log:
```python
class MessageLog:
"""Manages game messages"""
def __init__(self, max_messages=5):
self.messages = []
self.max_messages = max_messages
def add_message(self, text, color=(255, 255, 255)):
"""Add a message to the log"""
self.messages.append((text, color))
# Keep only recent messages
if len(self.messages) > self.max_messages:
self.messages.pop(0)
def render(self, ui, x, y, line_height=20):
"""Render messages to the UI"""
for i, (text, color) in enumerate(self.messages):
caption = mcrfpy.Caption(text, x, y + i * line_height)
caption.font_size = 14
caption.fill_color = mcrfpy.Color(*color)
ui.append(caption)
```
## Complete Implementation
Here's the complete `game.py` with combat:
```python
import mcrfpy
import random
# Color configurations
COLORS_VISIBLE = {
'wall': (100, 100, 100),
'floor': (50, 50, 50),
'tunnel': (30, 30, 40),
}
# Message colors
COLOR_PLAYER_ATK = (230, 230, 230)
COLOR_ENEMY_ATK = (255, 200, 200)
COLOR_PLAYER_DIE = (255, 100, 100)
COLOR_ENEMY_DIE = (255, 165, 0)
# Actions
class Action:
"""Base class for all actions"""
pass
class MovementAction(Action):
"""Action for moving an entity"""
def __init__(self, dx, dy):
self.dx = dx
self.dy = dy
class MeleeAction(Action):
"""Action for melee attacks"""
def __init__(self, attacker, target):
self.attacker = attacker
self.target = target
def perform(self):
"""Execute the attack"""
if not self.target.is_alive:
return None
damage = self.attacker.power - self.target.defense
if damage > 0:
attack_desc = f"{self.attacker.name} attacks {self.target.name} for {damage} damage!"
self.target.take_damage(damage)
# Choose color based on attacker
if self.attacker.name == "Player":
color = COLOR_PLAYER_ATK
else:
color = COLOR_ENEMY_ATK
return attack_desc, color
else:
attack_desc = f"{self.attacker.name} attacks {self.target.name} but does no damage."
return attack_desc, (150, 150, 150)
class WaitAction(Action):
"""Action for waiting/skipping turn"""
pass
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name,
blocks=False, hp=0, defense=0, power=0):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
# Combat stats
self.max_hp = hp
self.hp = hp
self.defense = defense
self.power = power
@property
def is_alive(self):
"""Returns True if this entity can act"""
return self.hp > 0
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount"""
if not self.grid:
return
self.x += dx
self.y += dy
if self._entity:
self._entity.x = self.x
self._entity.y = self.y
# Update FOV when player moves
if self.name == "Player":
self.update_fov()
def update_fov(self):
"""Update field of view from this entity's position"""
if self._entity and self.grid:
self._entity.update_fov(radius=8)
def take_damage(self, amount):
"""Apply damage to this entity"""
self.hp -= amount
# Check for death
if self.hp <= 0:
self.die()
def die(self):
"""Handle entity death"""
if self.name == "Player":
# Player death
self.sprite_index = 64 # Stay as @
self.color = (127, 0, 0) # Dark red
if self._entity:
self._entity.color = mcrfpy.Color(127, 0, 0)
else:
# Enemy death
self.sprite_index = 37 # % character for corpse
self.color = (127, 0, 0) # Dark red
self.blocks = False # Corpses don't block
self.name = f"remains of {self.name}"
if self._entity:
self._entity.sprite_index = 37
self._entity.color = mcrfpy.Color(127, 0, 0)
# Entity factories
def create_player(x, y):
"""Create the player entity"""
return GameObject(
x=x, y=y,
sprite_index=64, # @
color=(255, 255, 255),
name="Player",
blocks=True,
hp=30,
defense=2,
power=5
)
def create_orc(x, y):
"""Create an orc enemy"""
return GameObject(
x=x, y=y,
sprite_index=111, # o
color=(63, 127, 63),
name="Orc",
blocks=True,
hp=10,
defense=0,
power=3
)
def create_troll(x, y):
"""Create a troll enemy"""
return GameObject(
x=x, y=y,
sprite_index=84, # T
color=(0, 127, 0),
name="Troll",
blocks=True,
hp=16,
defense=1,
power=4
)
class RectangularRoom:
"""A rectangular room with its position and size"""
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
@property
def center(self):
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return center_x, center_y
@property
def inner(self):
return self.x1 + 1, self.y1 + 1, self.x2 - 1, self.y2 - 1
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 tunnel_between(start, end):
"""Return an L-shaped tunnel between two points"""
x1, y1 = start
x2, y2 = end
if random.random() < 0.5:
corner_x = x2
corner_y = y1
else:
corner_x = x1
corner_y = y2
for x in range(min(x1, corner_x), max(x1, corner_x) + 1):
yield x, y1
for y in range(min(y1, corner_y), max(y1, corner_y) + 1):
yield corner_x, y
for x in range(min(corner_x, x2), max(corner_x, x2) + 1):
yield x, corner_y
for y in range(min(corner_y, y2), max(corner_y, y2) + 1):
yield x2, y
def spawn_enemies_in_room(room, game_map, max_enemies=2):
"""Spawn between 0 and max_enemies in a room"""
number_of_enemies = random.randint(0, max_enemies)
enemies_spawned = []
for i in range(number_of_enemies):
attempts = 10
while attempts > 0:
x = random.randint(room.x1 + 1, room.x2 - 1)
y = random.randint(room.y1 + 1, room.y2 - 1)
if not game_map.is_blocked(x, y):
# 80% chance for orc, 20% for troll
if random.random() < 0.8:
enemy = create_orc(x, y)
else:
enemy = create_troll(x, y)
game_map.add_entity(enemy)
enemies_spawned.append(enemy)
break
attempts -= 1
return enemies_spawned
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
self.rooms = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
# Enable perspective rendering
self.grid.perspective = 0
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, tile_type='wall')
def set_tile(self, x, y, walkable, transparent, sprite_index, tile_type):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*COLORS_VISIBLE[tile_type])
def generate_dungeon(self, max_rooms, room_min_size, room_max_size, player, max_enemies_per_room):
"""Generate a new dungeon map"""
self.fill_with_walls()
for r in range(max_rooms):
room_width = random.randint(room_min_size, room_max_size)
room_height = random.randint(room_min_size, room_max_size)
x = random.randint(0, self.width - room_width - 1)
y = random.randint(0, self.height - room_height - 1)
new_room = RectangularRoom(x, y, room_width, room_height)
if any(new_room.intersects(other_room) for other_room in self.rooms):
continue
self.carve_room(new_room)
if len(self.rooms) == 0:
# First room - place player
player.x, player.y = new_room.center
if player._entity:
player._entity.x, player._entity.y = new_room.center
else:
# All other rooms - add tunnel and enemies
self.carve_tunnel(self.rooms[-1].center, new_room.center)
spawn_enemies_in_room(new_room, self, max_enemies_per_room)
self.rooms.append(new_room)
def carve_room(self, room):
"""Carve out a room"""
inner_x1, inner_y1, inner_x2, inner_y2 = room.inner
for y in range(inner_y1, inner_y2):
for x in range(inner_x1, inner_x2):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='floor')
def carve_tunnel(self, start, end):
"""Carve a tunnel between two points"""
for x, y in tunnel_between(start, end):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='tunnel')
def get_blocking_entity_at(self, x, y):
"""Return any blocking entity at the given position"""
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return entity
return None
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
if self.get_blocking_entity_at(x, y):
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
class Engine:
"""Main game engine"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
self.messages = [] # Simple message log
self.max_messages = 5
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 6"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame(0, 0, 1024, 768)
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def add_message(self, text, color=(255, 255, 255)):
"""Add a message to the log"""
self.messages.append((text, color))
if len(self.messages) > self.max_messages:
self.messages.pop(0)
self.update_message_display()
def update_message_display(self):
"""Update the message display"""
# Clear old messages
for caption in self.message_captions:
# Remove from UI (McRogueFace doesn't have remove, so we hide it)
caption.text = ""
# Display current messages
for i, (text, color) in enumerate(self.messages):
if i < len(self.message_captions):
self.message_captions[i].text = text
self.message_captions[i].fill_color = mcrfpy.Color(*color)
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(80, 45)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
# Create player
self.player = create_player(0, 0)
# Generate the dungeon
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player,
max_enemies_per_room=2
)
# Add player to map
self.game_map.add_entity(self.player)
# Store reference to all entities
self.entities = [e for e in self.game_map.entities if e != self.player]
# Initial FOV calculation
self.player.update_fov()
# Welcome message
self.add_message("Welcome to the dungeon!", (100, 100, 255))
def handle_player_turn(self, action):
"""Process the player's action"""
if not self.player.is_alive:
return
if isinstance(action, MovementAction):
dest_x = self.player.x + action.dx
dest_y = self.player.y + action.dy
# Check what's at the destination
target = self.game_map.get_blocking_entity_at(dest_x, dest_y)
if target:
# Attack!
attack = MeleeAction(self.player, target)
result = attack.perform()
if result:
text, color = result
self.add_message(text, color)
# Check if target died
if not target.is_alive:
death_msg = f"The {target.name.replace('remains of ', '')} is dead!"
self.add_message(death_msg, COLOR_ENEMY_DIE)
elif not self.game_map.is_blocked(dest_x, dest_y):
# Move the player
self.player.move(action.dx, action.dy)
elif isinstance(action, WaitAction):
pass # Do nothing
# Enemy turns
self.handle_enemy_turns()
def handle_enemy_turns(self):
"""Let all enemies take their turn"""
for entity in self.entities:
if entity.is_alive:
# Simple AI: if player is adjacent, attack. Otherwise, do nothing.
dx = entity.x - self.player.x
dy = entity.y - self.player.y
distance = abs(dx) + abs(dy)
if distance == 1: # Adjacent to player
attack = MeleeAction(entity, self.player)
result = attack.perform()
if result:
text, color = result
self.add_message(text, color)
# Check if player died
if not self.player.is_alive:
self.add_message("You have died!", COLOR_PLAYER_DIE)
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
action = None
# Movement keys
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num5": (0, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
if dx == 0 and dy == 0:
action = WaitAction()
else:
action = MovementAction(dx, dy)
elif key == "Period":
action = WaitAction()
elif key == "Escape":
mcrfpy.setScene(None)
return
# Process the action
if action:
self.handle_player_turn(action)
mcrfpy.keypressScene(handle_keys)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("Combat System", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Attack enemies by bumping into them!", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# Player stats
self.hp_text = mcrfpy.Caption(f"HP: {self.player.hp}/{self.player.max_hp}", 50, 100)
self.hp_text.font_size = 18
self.hp_text.fill_color = mcrfpy.Color(255, 100, 100)
self.ui.append(self.hp_text)
# Message log
self.message_captions = []
for i in range(self.max_messages):
caption = mcrfpy.Caption("", 50, 620 + i * 20)
caption.font_size = 14
caption.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(caption)
self.message_captions.append(caption)
# Timer to update HP display
def update_stats(dt):
self.hp_text.text = f"HP: {self.player.hp}/{self.player.max_hp}"
if self.player.hp <= 0:
self.hp_text.fill_color = mcrfpy.Color(127, 0, 0)
elif self.player.hp < self.player.max_hp // 3:
self.hp_text.fill_color = mcrfpy.Color(255, 100, 100)
else:
self.hp_text.fill_color = mcrfpy.Color(0, 255, 0)
mcrfpy.setTimer("update_stats", update_stats, 100)
# Create and run the game
engine = Engine()
print("Part 6: Combat System!")
print("Attack enemies to defeat them, but watch your HP!")

View file

@ -1,568 +0,0 @@
import mcrfpy
import random
# Color configurations
COLORS_VISIBLE = {
'wall': (100, 100, 100),
'floor': (50, 50, 50),
'tunnel': (30, 30, 40),
}
# Message colors
COLOR_PLAYER_ATK = (230, 230, 230)
COLOR_ENEMY_ATK = (255, 200, 200)
COLOR_PLAYER_DIE = (255, 100, 100)
COLOR_ENEMY_DIE = (255, 165, 0)
# Actions
class Action:
"""Base class for all actions"""
pass
class MovementAction(Action):
"""Action for moving an entity"""
def __init__(self, dx, dy):
self.dx = dx
self.dy = dy
class MeleeAction(Action):
"""Action for melee attacks"""
def __init__(self, attacker, target):
self.attacker = attacker
self.target = target
def perform(self):
"""Execute the attack"""
if not self.target.is_alive:
return None
damage = self.attacker.power - self.target.defense
if damage > 0:
attack_desc = f"{self.attacker.name} attacks {self.target.name} for {damage} damage!"
self.target.take_damage(damage)
# Choose color based on attacker
if self.attacker.name == "Player":
color = COLOR_PLAYER_ATK
else:
color = COLOR_ENEMY_ATK
return attack_desc, color
else:
attack_desc = f"{self.attacker.name} attacks {self.target.name} but does no damage."
return attack_desc, (150, 150, 150)
class WaitAction(Action):
"""Action for waiting/skipping turn"""
pass
class GameObject:
"""Base class for all game objects"""
def __init__(self, x, y, sprite_index, color, name,
blocks=False, hp=0, defense=0, power=0):
self.x = x
self.y = y
self.sprite_index = sprite_index
self.color = color
self.name = name
self.blocks = blocks
self._entity = None
self.grid = None
# Combat stats
self.max_hp = hp
self.hp = hp
self.defense = defense
self.power = power
@property
def is_alive(self):
"""Returns True if this entity can act"""
return self.hp > 0
def attach_to_grid(self, grid):
"""Attach this game object to a McRogueFace grid"""
self.grid = grid
self._entity = mcrfpy.Entity(x=self.x, y=self.y, grid=grid)
self._entity.sprite_index = self.sprite_index
self._entity.color = mcrfpy.Color(*self.color)
def move(self, dx, dy):
"""Move by the given amount"""
if not self.grid:
return
self.x += dx
self.y += dy
if self._entity:
self._entity.x = self.x
self._entity.y = self.y
# Update FOV when player moves
if self.name == "Player":
self.update_fov()
def update_fov(self):
"""Update field of view from this entity's position"""
if self._entity and self.grid:
self._entity.update_fov(radius=8)
def take_damage(self, amount):
"""Apply damage to this entity"""
self.hp -= amount
# Check for death
if self.hp <= 0:
self.die()
def die(self):
"""Handle entity death"""
if self.name == "Player":
# Player death
self.sprite_index = 64 # Stay as @
self.color = (127, 0, 0) # Dark red
if self._entity:
self._entity.color = mcrfpy.Color(127, 0, 0)
else:
# Enemy death
self.sprite_index = 37 # % character for corpse
self.color = (127, 0, 0) # Dark red
self.blocks = False # Corpses don't block
self.name = f"remains of {self.name}"
if self._entity:
self._entity.sprite_index = 37
self._entity.color = mcrfpy.Color(127, 0, 0)
# Entity factories
def create_player(x, y):
"""Create the player entity"""
return GameObject(
x=x, y=y,
sprite_index=64, # @
color=(255, 255, 255),
name="Player",
blocks=True,
hp=30,
defense=2,
power=5
)
def create_orc(x, y):
"""Create an orc enemy"""
return GameObject(
x=x, y=y,
sprite_index=111, # o
color=(63, 127, 63),
name="Orc",
blocks=True,
hp=10,
defense=0,
power=3
)
def create_troll(x, y):
"""Create a troll enemy"""
return GameObject(
x=x, y=y,
sprite_index=84, # T
color=(0, 127, 0),
name="Troll",
blocks=True,
hp=16,
defense=1,
power=4
)
class RectangularRoom:
"""A rectangular room with its position and size"""
def __init__(self, x, y, width, height):
self.x1 = x
self.y1 = y
self.x2 = x + width
self.y2 = y + height
@property
def center(self):
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return center_x, center_y
@property
def inner(self):
return self.x1 + 1, self.y1 + 1, self.x2 - 1, self.y2 - 1
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 tunnel_between(start, end):
"""Return an L-shaped tunnel between two points"""
x1, y1 = start
x2, y2 = end
if random.random() < 0.5:
corner_x = x2
corner_y = y1
else:
corner_x = x1
corner_y = y2
for x in range(min(x1, corner_x), max(x1, corner_x) + 1):
yield x, y1
for y in range(min(y1, corner_y), max(y1, corner_y) + 1):
yield corner_x, y
for x in range(min(corner_x, x2), max(corner_x, x2) + 1):
yield x, corner_y
for y in range(min(corner_y, y2), max(corner_y, y2) + 1):
yield x2, y
def spawn_enemies_in_room(room, game_map, max_enemies=2):
"""Spawn between 0 and max_enemies in a room"""
number_of_enemies = random.randint(0, max_enemies)
enemies_spawned = []
for i in range(number_of_enemies):
attempts = 10
while attempts > 0:
x = random.randint(room.x1 + 1, room.x2 - 1)
y = random.randint(room.y1 + 1, room.y2 - 1)
if not game_map.is_blocked(x, y):
# 80% chance for orc, 20% for troll
if random.random() < 0.8:
enemy = create_orc(x, y)
else:
enemy = create_troll(x, y)
game_map.add_entity(enemy)
enemies_spawned.append(enemy)
break
attempts -= 1
return enemies_spawned
class GameMap:
"""Manages the game world"""
def __init__(self, width, height):
self.width = width
self.height = height
self.grid = None
self.entities = []
self.rooms = []
def create_grid(self, tileset):
"""Create the McRogueFace grid"""
self.grid = mcrfpy.Grid(grid_x=self.width, grid_y=self.height, texture=tileset)
self.grid.position = (100, 100)
self.grid.size = (800, 480)
# Enable perspective rendering
self.grid.perspective = 0
return self.grid
def fill_with_walls(self):
"""Fill the entire map with wall tiles"""
for y in range(self.height):
for x in range(self.width):
self.set_tile(x, y, walkable=False, transparent=False,
sprite_index=35, tile_type='wall')
def set_tile(self, x, y, walkable, transparent, sprite_index, tile_type):
"""Set properties for a specific tile"""
if 0 <= x < self.width and 0 <= y < self.height:
cell = self.grid.at(x, y)
cell.walkable = walkable
cell.transparent = transparent
cell.sprite_index = sprite_index
cell.color = mcrfpy.Color(*COLORS_VISIBLE[tile_type])
def generate_dungeon(self, max_rooms, room_min_size, room_max_size, player, max_enemies_per_room):
"""Generate a new dungeon map"""
self.fill_with_walls()
for r in range(max_rooms):
room_width = random.randint(room_min_size, room_max_size)
room_height = random.randint(room_min_size, room_max_size)
x = random.randint(0, self.width - room_width - 1)
y = random.randint(0, self.height - room_height - 1)
new_room = RectangularRoom(x, y, room_width, room_height)
if any(new_room.intersects(other_room) for other_room in self.rooms):
continue
self.carve_room(new_room)
if len(self.rooms) == 0:
# First room - place player
player.x, player.y = new_room.center
if player._entity:
player._entity.x, player._entity.y = new_room.center
else:
# All other rooms - add tunnel and enemies
self.carve_tunnel(self.rooms[-1].center, new_room.center)
spawn_enemies_in_room(new_room, self, max_enemies_per_room)
self.rooms.append(new_room)
def carve_room(self, room):
"""Carve out a room"""
inner_x1, inner_y1, inner_x2, inner_y2 = room.inner
for y in range(inner_y1, inner_y2):
for x in range(inner_x1, inner_x2):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='floor')
def carve_tunnel(self, start, end):
"""Carve a tunnel between two points"""
for x, y in tunnel_between(start, end):
self.set_tile(x, y, walkable=True, transparent=True,
sprite_index=46, tile_type='tunnel')
def get_blocking_entity_at(self, x, y):
"""Return any blocking entity at the given position"""
for entity in self.entities:
if entity.blocks and entity.x == x and entity.y == y:
return entity
return None
def is_blocked(self, x, y):
"""Check if a tile blocks movement"""
if x < 0 or x >= self.width or y < 0 or y >= self.height:
return True
if not self.grid.at(x, y).walkable:
return True
if self.get_blocking_entity_at(x, y):
return True
return False
def add_entity(self, entity):
"""Add a GameObject to the map"""
self.entities.append(entity)
entity.attach_to_grid(self.grid)
class Engine:
"""Main game engine"""
def __init__(self):
self.game_map = None
self.player = None
self.entities = []
self.messages = [] # Simple message log
self.max_messages = 5
mcrfpy.createScene("game")
mcrfpy.setScene("game")
window = mcrfpy.Window.get()
window.title = "McRogueFace Roguelike - Part 6"
self.ui = mcrfpy.sceneUI("game")
background = mcrfpy.Frame((0, 0), (1024, 768))
background.fill_color = mcrfpy.Color(0, 0, 0)
self.ui.append(background)
self.tileset = mcrfpy.Texture("assets/sprites/ascii_tileset.png", 16, 16)
self.setup_game()
self.setup_input()
self.setup_ui()
def add_message(self, text, color=(255, 255, 255)):
"""Add a message to the log"""
self.messages.append((text, color))
if len(self.messages) > self.max_messages:
self.messages.pop(0)
self.update_message_display()
def update_message_display(self):
"""Update the message display"""
# Clear old messages
for caption in self.message_captions:
# Remove from UI (McRogueFace doesn't have remove, so we hide it)
caption.text = ""
# Display current messages
for i, (text, color) in enumerate(self.messages):
if i < len(self.message_captions):
self.message_captions[i].text = text
self.message_captions[i].fill_color = mcrfpy.Color(*color)
def setup_game(self):
"""Initialize the game world"""
self.game_map = GameMap(80, 45)
grid = self.game_map.create_grid(self.tileset)
self.ui.append(grid)
# Create player
self.player = create_player(0, 0)
# Generate the dungeon
self.game_map.generate_dungeon(
max_rooms=30,
room_min_size=6,
room_max_size=10,
player=self.player,
max_enemies_per_room=2
)
# Add player to map
self.game_map.add_entity(self.player)
# Store reference to all entities
self.entities = [e for e in self.game_map.entities if e != self.player]
# Initial FOV calculation
self.player.update_fov()
# Welcome message
self.add_message("Welcome to the dungeon!", (100, 100, 255))
def handle_player_turn(self, action):
"""Process the player's action"""
if not self.player.is_alive:
return
if isinstance(action, MovementAction):
dest_x = self.player.x + action.dx
dest_y = self.player.y + action.dy
# Check what's at the destination
target = self.game_map.get_blocking_entity_at(dest_x, dest_y)
if target:
# Attack!
attack = MeleeAction(self.player, target)
result = attack.perform()
if result:
text, color = result
self.add_message(text, color)
# Check if target died
if not target.is_alive:
death_msg = f"The {target.name.replace('remains of ', '')} is dead!"
self.add_message(death_msg, COLOR_ENEMY_DIE)
elif not self.game_map.is_blocked(dest_x, dest_y):
# Move the player
self.player.move(action.dx, action.dy)
elif isinstance(action, WaitAction):
pass # Do nothing
# Enemy turns
self.handle_enemy_turns()
def handle_enemy_turns(self):
"""Let all enemies take their turn"""
for entity in self.entities:
if entity.is_alive:
# Simple AI: if player is adjacent, attack. Otherwise, do nothing.
dx = entity.x - self.player.x
dy = entity.y - self.player.y
distance = abs(dx) + abs(dy)
if distance == 1: # Adjacent to player
attack = MeleeAction(entity, self.player)
result = attack.perform()
if result:
text, color = result
self.add_message(text, color)
# Check if player died
if not self.player.is_alive:
self.add_message("You have died!", COLOR_PLAYER_DIE)
def setup_input(self):
"""Setup keyboard input handling"""
def handle_keys(key, state):
if state != "start":
return
action = None
# Movement keys
movement = {
"Up": (0, -1), "Down": (0, 1),
"Left": (-1, 0), "Right": (1, 0),
"Num7": (-1, -1), "Num8": (0, -1), "Num9": (1, -1),
"Num4": (-1, 0), "Num5": (0, 0), "Num6": (1, 0),
"Num1": (-1, 1), "Num2": (0, 1), "Num3": (1, 1),
}
if key in movement:
dx, dy = movement[key]
if dx == 0 and dy == 0:
action = WaitAction()
else:
action = MovementAction(dx, dy)
elif key == "Period":
action = WaitAction()
elif key == "Escape":
mcrfpy.setScene(None)
return
# Process the action
if action:
self.handle_player_turn(action)
mcrfpy.keypressScene(handle_keys)
def setup_ui(self):
"""Setup UI elements"""
title = mcrfpy.Caption("Combat System", 512, 30)
title.font_size = 24
title.fill_color = mcrfpy.Color(255, 255, 100)
self.ui.append(title)
instructions = mcrfpy.Caption("Attack enemies by bumping into them!", 512, 60)
instructions.font_size = 16
instructions.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(instructions)
# Player stats
self.hp_text = mcrfpy.Caption(f"HP: {self.player.hp}/{self.player.max_hp}", 50, 100)
self.hp_text.font_size = 18
self.hp_text.fill_color = mcrfpy.Color(255, 100, 100)
self.ui.append(self.hp_text)
# Message log
self.message_captions = []
for i in range(self.max_messages):
caption = mcrfpy.Caption("", 50, 620 + i * 20)
caption.font_size = 14
caption.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(caption)
self.message_captions.append(caption)
# Timer to update HP display
def update_stats(dt):
self.hp_text.text = f"HP: {self.player.hp}/{self.player.max_hp}"
if self.player.hp <= 0:
self.hp_text.fill_color = mcrfpy.Color(127, 0, 0)
elif self.player.hp < self.player.max_hp // 3:
self.hp_text.fill_color = mcrfpy.Color(255, 100, 100)
else:
self.hp_text.fill_color = mcrfpy.Color(0, 255, 0)
mcrfpy.setTimer("update_stats", update_stats, 100)
# Create and run the game
engine = Engine()
print("Part 6: Combat System!")
print("Attack enemies to defeat them, but watch your HP!")

View file

@ -1,80 +0,0 @@
"""
McRogueFace Tutorial - Part 0: Introduction to Scene, Texture, and Grid
This tutorial introduces the basic building blocks:
- Scene: A container for UI elements and game state
- Texture: Loading image assets for use in the game
- Grid: A tilemap component for rendering tile-based worlds
"""
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)
# Create a grid of tiles
# Each tile is 16x16 pixels, so with 3x zoom: 16*3 = 48 pixels per tile
grid_width, grid_height = 25, 20 # width, height in number of tiles
# calculating the size in pixels to fit the entire grid on-screen
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# calculating the position to center the grid on the screen - assuming default 1024x768 resolution
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size, # height and width on screen
)
grid.zoom = zoom
grid.center = (grid_width/2.0)*16, (grid_height/2.0)*16 # center on the middle of the central tile
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Fill the grid with a simple pattern
for y in range(grid_height):
for x in range(grid_width):
# Create walls around the edges
if x == 0 or x == grid_width-1 or y == 0 or y == grid_height-1:
tile_index = random.choice(WALL_TILES)
else:
# Fill interior with floor tiles
tile_index = random.choice(FLOOR_TILES)
# Set the tile at this position
point = grid.at(x, y)
if point:
point.tilesprite = tile_index
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Add a title caption
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 0",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
# Add instructions
instructions = mcrfpy.Caption((280, 750),
text="Scene + Texture + Grid = Tilemap!",
)
instructions.font_size=18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
print("Tutorial Part 0 loaded!")
print(f"Created a {grid.grid_size[0]}x{grid.grid_size[1]} grid")
print(f"Grid positioned at ({grid.x}, {grid.y})")

View file

@ -1,116 +0,0 @@
"""
McRogueFace Tutorial - Part 1: Entities and Keyboard Input
This tutorial builds on Part 0 by adding:
- Entity: A game object that can be placed in a grid
- Keyboard handling: Responding to key presses to move the entity
"""
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 (32x32 sprite sheet)
hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16)
# Create a grid of tiles
# Each tile is 16x16 pixels, so with 3x zoom: 16*3 = 48 pixels per tile
grid_width, grid_height = 25, 20 # width, height in number of tiles
# calculating the size in pixels to fit the entire grid on-screen
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# calculating the position to center the grid on the screen - assuming default 1024x768 resolution
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size, # height and width on screen
)
grid.zoom = zoom
grid.center = (grid_width/2.0)*16, (grid_height/2.0)*16 # center on the middle of the central tile
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Fill the grid with a simple pattern
for y in range(grid_height):
for x in range(grid_width):
# Create walls around the edges
if x == 0 or x == grid_width-1 or y == 0 or y == grid_height-1:
tile_index = random.choice(WALL_TILES)
else:
# Fill interior with floor tiles
tile_index = random.choice(FLOOR_TILES)
# Set the tile at this position
point = grid.at(x, y)
if point:
point.tilesprite = tile_index
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Create a player entity at position (4, 4)
player = mcrfpy.Entity(
(4, 4), # Entity positions are tile coordinates
texture=hero_texture,
sprite_index=0 # Use the first sprite in the texture
)
# Add the player entity to the grid
grid.entities.append(player)
# Define keyboard handler
def handle_keys(key, state):
"""Handle keyboard input to move the player"""
if state == "start": # Only respond to key press, not release
# Get current player position in grid coordinates
px, py = player.x, player.y
# Calculate new position based on key press
if key == "W" or key == "Up":
py -= 1
elif key == "S" or key == "Down":
py += 1
elif key == "A" or key == "Left":
px -= 1
elif key == "D" or key == "Right":
px += 1
# Update player position (no collision checking yet)
player.x = px
player.y = py
# Register the keyboard handler
mcrfpy.keypressScene(handle_keys)
# Add a title caption
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 1",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
# Add instructions
instructions = mcrfpy.Caption((200, 750),
text="Use WASD or Arrow Keys to move the hero!",
)
instructions.font_size=18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
print("Tutorial Part 1 loaded!")
print(f"Player entity created at grid position (4, 4)")
print("Use WASD or Arrow keys to move!")

View file

@ -1,117 +0,0 @@
"""
McRogueFace Tutorial - Part 1: Entities and Keyboard Input
This tutorial builds on Part 0 by adding:
- Entity: A game object that can be placed in a grid
- Keyboard handling: Responding to key presses to move the entity
"""
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 (32x32 sprite sheet)
hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16)
# Create a grid of tiles
# Each tile is 16x16 pixels, so with 3x zoom: 16*3 = 48 pixels per tile
grid_width, grid_height = 25, 20 # width, height in number of tiles
# calculating the size in pixels to fit the entire grid on-screen
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# calculating the position to center the grid on the screen - assuming default 1024x768 resolution
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size, # height and width on screen
)
grid.zoom = 3.0 # we're not using the zoom variable! It's going to be really big!
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Fill the grid with a simple pattern
for y in range(grid_height):
for x in range(grid_width):
# Create walls around the edges
if x == 0 or x == grid_width-1 or y == 0 or y == grid_height-1:
tile_index = random.choice(WALL_TILES)
else:
# Fill interior with floor tiles
tile_index = random.choice(FLOOR_TILES)
# Set the tile at this position
point = grid.at(x, y)
if point:
point.tilesprite = tile_index
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Create a player entity at position (4, 4)
player = mcrfpy.Entity(
(4, 4), # Entity positions are tile coordinates
texture=hero_texture,
sprite_index=0 # Use the first sprite in the texture
)
# Add the player entity to the grid
grid.entities.append(player)
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16 # grid center is in texture/pixel coordinates
# Define keyboard handler
def handle_keys(key, state):
"""Handle keyboard input to move the player"""
if state == "start": # Only respond to key press, not release
# Get current player position in grid coordinates
px, py = player.x, player.y
# Calculate new position based on key press
if key == "W" or key == "Up":
py -= 1
elif key == "S" or key == "Down":
py += 1
elif key == "A" or key == "Left":
px -= 1
elif key == "D" or key == "Right":
px += 1
# Update player position (no collision checking yet)
player.x = px
player.y = py
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16 # grid center is in texture/pixel coordinates
# Register the keyboard handler
mcrfpy.keypressScene(handle_keys)
# Add a title caption
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 1",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
# Add instructions
instructions = mcrfpy.Caption((200, 750),
text="Use WASD or Arrow Keys to move the hero!",
)
instructions.font_size=18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
print("Tutorial Part 1 loaded!")
print(f"Player entity created at grid position (4, 4)")
print("Use WASD or Arrow keys to move!")

View file

@ -1,149 +0,0 @@
"""
McRogueFace Tutorial - Part 2: Animated Movement
This tutorial builds on Part 1 by adding:
- Animation system for smooth movement
- Movement that takes 0.5 seconds per tile
- Input blocking during movement animation
"""
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 (32x32 sprite sheet)
hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16)
# Create a grid of tiles
# Each tile is 16x16 pixels, so with 3x zoom: 16*3 = 48 pixels per tile
grid_width, grid_height = 25, 20 # width, height in number of tiles
# calculating the size in pixels to fit the entire grid on-screen
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# calculating the position to center the grid on the screen - assuming default 1024x768 resolution
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size, # height and width on screen
)
grid.zoom = 3.0 # we're not using the zoom variable! It's going to be really big!
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Fill the grid with a simple pattern
for y in range(grid_height):
for x in range(grid_width):
# Create walls around the edges
if x == 0 or x == grid_width-1 or y == 0 or y == grid_height-1:
tile_index = random.choice(WALL_TILES)
else:
# Fill interior with floor tiles
tile_index = random.choice(FLOOR_TILES)
# Set the tile at this position
point = grid.at(x, y)
if point:
point.tilesprite = tile_index
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Create a player entity at position (4, 4)
player = mcrfpy.Entity(
(4, 4), # Entity positions are tile coordinates
texture=hero_texture,
sprite_index=0 # Use the first sprite in the texture
)
# Add the player entity to the grid
grid.entities.append(player)
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16 # grid center is in texture/pixel coordinates
# Movement state tracking
is_moving = False
move_animations = [] # Track active animations
# Animation completion callback
def movement_complete(runtime):
"""Called when movement animation completes"""
global is_moving
is_moving = False
# Ensure grid is centered on final position
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16
motion_speed = 0.30 # seconds per tile
# Define keyboard handler
def handle_keys(key, state):
"""Handle keyboard input to move the player"""
global is_moving, move_animations
if state == "start" and not is_moving: # Only respond to key press when not moving
# Get current player position in grid coordinates
px, py = player.x, player.y
new_x, new_y = px, py
# Calculate new position based on key press
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 position changed, start movement animation
if new_x != px or new_y != py:
is_moving = True
# Create animations for player position
anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad")
anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad")
anim_x.start(player)
anim_y.start(player)
# Animate grid center to follow player
center_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear")
center_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear")
center_x.start(grid)
center_y.start(grid)
# Set a timer to mark movement as complete
mcrfpy.setTimer("move_complete", movement_complete, 500)
# Register the keyboard handler
mcrfpy.keypressScene(handle_keys)
# Add a title caption
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 2",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
# Add instructions
instructions = mcrfpy.Caption((150, 750),
text="Smooth movement! Each step takes 0.5 seconds.",
)
instructions.font_size=18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
print("Tutorial Part 2 loaded!")
print(f"Player entity created at grid position (4, 4)")
print("Movement is now animated over 0.5 seconds per tile!")
print("Use WASD or Arrow keys to move!")

View file

@ -1,241 +0,0 @@
"""
McRogueFace Tutorial - Part 2: Enhanced with Single Move Queue
This tutorial builds on Part 2 by adding:
- Single queued move system for responsive input
- Debug display showing position and queue status
- Smooth continuous movement when keys are held
- Animation callbacks to prevent race conditions
"""
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 (32x32 sprite sheet)
hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16)
# Create a grid of tiles
# Each tile is 16x16 pixels, so with 3x zoom: 16*3 = 48 pixels per tile
grid_width, grid_height = 25, 20 # width, height in number of tiles
# calculating the size in pixels to fit the entire grid on-screen
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# calculating the position to center the grid on the screen - assuming default 1024x768 resolution
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size, # height and width on screen
)
grid.zoom = 3.0 # we're not using the zoom variable! It's going to be really big!
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Fill the grid with a simple pattern
for y in range(grid_height):
for x in range(grid_width):
# Create walls around the edges
if x == 0 or x == grid_width-1 or y == 0 or y == grid_height-1:
tile_index = random.choice(WALL_TILES)
else:
# Fill interior with floor tiles
tile_index = random.choice(FLOOR_TILES)
# Set the tile at this position
point = grid.at(x, y)
if point:
point.tilesprite = tile_index
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Create a player entity at position (4, 4)
player = mcrfpy.Entity(
(4, 4), # Entity positions are tile coordinates
texture=hero_texture,
sprite_index=0 # Use the first sprite in the texture
)
# Add the player entity to the grid
grid.entities.append(player)
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16 # grid center is in texture/pixel coordinates
# Movement state tracking
is_moving = False
move_queue = [] # List to store queued moves (max 1 item)
#last_position = (4, 4) # Track last position
current_destination = None # Track where we're currently moving to
current_move = None # Track current move direction
# Store animation references
player_anim_x = None
player_anim_y = None
grid_anim_x = None
grid_anim_y = None
# Debug display caption
debug_caption = mcrfpy.Caption((10, 40),
text="Last: (4, 4) | Queue: 0 | Dest: None",
)
debug_caption.font_size = 16
debug_caption.fill_color = mcrfpy.Color(255, 255, 0, 255)
mcrfpy.sceneUI("tutorial").append(debug_caption)
# Additional debug caption for movement state
move_debug_caption = mcrfpy.Caption((10, 60),
text="Moving: False | Current: None | Queued: None",
)
move_debug_caption.font_size = 16
move_debug_caption.fill_color = mcrfpy.Color(255, 200, 0, 255)
mcrfpy.sceneUI("tutorial").append(move_debug_caption)
def key_to_direction(key):
"""Convert key to direction string"""
if key == "W" or key == "Up":
return "Up"
elif key == "S" or key == "Down":
return "Down"
elif key == "A" or key == "Left":
return "Left"
elif key == "D" or key == "Right":
return "Right"
return None
def update_debug_display():
"""Update the debug caption with current state"""
queue_count = len(move_queue)
dest_text = f"({current_destination[0]}, {current_destination[1]})" if current_destination else "None"
debug_caption.text = f"Last: ({player.x}, {player.y}) | Queue: {queue_count} | Dest: {dest_text}"
# Update movement state debug
current_dir = key_to_direction(current_move) if current_move else "None"
queued_dir = key_to_direction(move_queue[0]) if move_queue else "None"
move_debug_caption.text = f"Moving: {is_moving} | Current: {current_dir} | Queued: {queued_dir}"
# Animation completion callback
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
print(f"In callback for animation: {anim=} {target=}")
# Clear movement state
is_moving = False
current_move = None
current_destination = None
# Clear animation references
player_anim_x = None
player_anim_y = None
# Update last position to where we actually are now
#last_position = (int(player.x), int(player.y))
# Ensure grid is centered on final position
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16
# Check if there's a queued move
if move_queue:
# Pop the next move from the queue
next_move = move_queue.pop(0)
print(f"Processing queued move: {next_move}")
# Process it like a fresh input
process_move(next_move)
update_debug_display()
motion_speed = 0.30 # seconds per tile
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 already moving, just update the queue
if is_moving:
print(f"process_move processing {key=} as a queued move (is_moving = True)")
# Clear queue and add new move (only keep 1 queued move)
move_queue.clear()
move_queue.append(key)
update_debug_display()
return
print(f"process_move processing {key=} as a new, immediate animation (is_moving = False)")
# Calculate new position from current position
px, py = int(player.x), int(player.y)
new_x, new_y = px, py
# Calculate new position based on key press (only one tile movement)
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
# Start the move if position changed
if new_x != px or new_y != py:
is_moving = True
current_move = key
current_destination = (new_x, new_y)
# only animate a single axis, same callback from either
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)
# Animate grid center to follow 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)
update_debug_display()
# Define keyboard handler
def handle_keys(key, state):
"""Handle keyboard input to move the player"""
if state == "start":
# Only process movement keys
if key in ["W", "Up", "S", "Down", "A", "Left", "D", "Right"]:
print(f"handle_keys producing actual input: {key=}")
process_move(key)
# Register the keyboard handler
mcrfpy.keypressScene(handle_keys)
# Add a title caption
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 2 Enhanced",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
# Add instructions
instructions = mcrfpy.Caption((150, 750),
text="One-move queue system with animation callbacks!",
)
instructions.font_size=18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
print("Tutorial Part 2 Enhanced loaded!")
print(f"Player entity created at grid position (4, 4)")
print("Movement now uses animation callbacks to prevent race conditions!")
print("Use WASD or Arrow keys to move!")

View file

@ -1,149 +0,0 @@
"""
McRogueFace Tutorial - Part 2: Animated Movement
This tutorial builds on Part 1 by adding:
- Animation system for smooth movement
- Movement that takes 0.5 seconds per tile
- Input blocking during movement animation
"""
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 (32x32 sprite sheet)
hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16)
# Create a grid of tiles
# Each tile is 16x16 pixels, so with 3x zoom: 16*3 = 48 pixels per tile
grid_width, grid_height = 25, 20 # width, height in number of tiles
# calculating the size in pixels to fit the entire grid on-screen
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# calculating the position to center the grid on the screen - assuming default 1024x768 resolution
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size, # height and width on screen
)
grid.zoom = 3.0 # we're not using the zoom variable! It's going to be really big!
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Fill the grid with a simple pattern
for y in range(grid_height):
for x in range(grid_width):
# Create walls around the edges
if x == 0 or x == grid_width-1 or y == 0 or y == grid_height-1:
tile_index = random.choice(WALL_TILES)
else:
# Fill interior with floor tiles
tile_index = random.choice(FLOOR_TILES)
# Set the tile at this position
point = grid.at(x, y)
if point:
point.tilesprite = tile_index
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Create a player entity at position (4, 4)
player = mcrfpy.Entity(
(4, 4), # Entity positions are tile coordinates
texture=hero_texture,
sprite_index=0 # Use the first sprite in the texture
)
# Add the player entity to the grid
grid.entities.append(player)
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16 # grid center is in texture/pixel coordinates
# Movement state tracking
is_moving = False
move_animations = [] # Track active animations
# Animation completion callback
def movement_complete(runtime):
"""Called when movement animation completes"""
global is_moving
is_moving = False
# Ensure grid is centered on final position
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16
motion_speed = 0.30 # seconds per tile
# Define keyboard handler
def handle_keys(key, state):
"""Handle keyboard input to move the player"""
global is_moving, move_animations
if state == "start" and not is_moving: # Only respond to key press when not moving
# Get current player position in grid coordinates
px, py = player.x, player.y
new_x, new_y = px, py
# Calculate new position based on key press
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 position changed, start movement animation
if new_x != px or new_y != py:
is_moving = True
# Create animations for player position
anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad")
anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad")
anim_x.start(player)
anim_y.start(player)
# Animate grid center to follow player
center_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear")
center_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear")
center_x.start(grid)
center_y.start(grid)
# Set a timer to mark movement as complete
mcrfpy.setTimer("move_complete", movement_complete, 500)
# Register the keyboard handler
mcrfpy.keypressScene(handle_keys)
# Add a title caption
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 2",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
# Add instructions
instructions = mcrfpy.Caption((150, 750),
"Smooth movement! Each step takes 0.5 seconds.",
)
instructions.font_size=18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
print("Tutorial Part 2 loaded!")
print(f"Player entity created at grid position (4, 4)")
print("Movement is now animated over 0.5 seconds per tile!")
print("Use WASD or Arrow keys to move!")

View file

@ -1,313 +0,0 @@
"""
McRogueFace Tutorial - Part 3: Procedural Dungeon Generation
This tutorial builds on Part 2 by adding:
- Binary Space Partition (BSP) dungeon generation
- Rooms connected by hallways using libtcod.line()
- Walkable/non-walkable terrain
- Player spawning in a valid location
- Wall tiles that block movement
Key code references:
- src/scripts/cos_level.py (lines 7-15, 184-217, 218-224) - BSP algorithm
- mcrfpy.libtcod.line() for smooth hallway generation
"""
import mcrfpy
import random
# Create and activate a new scene
mcrfpy.createScene("tutorial")
mcrfpy.setScene("tutorial")
# Load the texture (4x3 tiles, 64x48 pixels total, 16x16 per tile)
texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16)
# Load the hero sprite texture
hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16)
# Create a grid of tiles
grid_width, grid_height = 40, 30 # Larger grid for dungeon
# Calculate the size in pixels to fit the entire grid on-screen
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# Calculate the position to center the grid on the screen
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
# Create the grid with a TCODMap for pathfinding/FOV
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size,
)
grid.zoom = zoom
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Room class for BSP
class Room:
def __init__(self, x, y, w, h):
self.x1 = x
self.y1 = y
self.x2 = x + w
self.y2 = y + h
self.w = w
self.h = h
def center(self):
"""Return the center coordinates of the room"""
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return (center_x, center_y)
def intersects(self, other):
"""Return True if this room overlaps with another"""
return (self.x1 <= other.x2 and self.x2 >= other.x1 and
self.y1 <= other.y2 and self.y2 >= other.y1)
# Dungeon generation functions
def carve_room(room):
"""Carve out a room in the grid - referenced from cos_level.py lines 117-120"""
# Using individual updates for now (batch updates would be more efficient)
for x in range(room.x1, room.x2):
for y in range(room.y1, room.y2):
if 0 <= x < grid_width and 0 <= y < grid_height:
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(FLOOR_TILES)
point.walkable = True
point.transparent = True
def carve_hallway(x1, y1, x2, y2):
"""Carve a hallway between two points using libtcod.line()
Referenced from cos_level.py lines 184-217, improved with libtcod.line()
"""
# Get all points along the line
# Simple solution: works if your characters have diagonal movement
#points = mcrfpy.libtcod.line(x1, y1, x2, y2)
# We don't, so we're going to carve a path with an elbow in it
points = []
if random.choice([True, False]):
# x1,y1 -> x2,y1 -> x2,y2
points.extend(mcrfpy.libtcod.line(x1, y1, x2, y1))
points.extend(mcrfpy.libtcod.line(x2, y1, x2, y2))
else:
# x1,y1 -> x1,y2 -> x2,y2
points.extend(mcrfpy.libtcod.line(x1, y1, x1, y2))
points.extend(mcrfpy.libtcod.line(x1, y2, x2, y2))
# Carve out each point
for x, y in points:
if 0 <= x < grid_width and 0 <= y < grid_height:
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(FLOOR_TILES)
point.walkable = True
point.transparent = True
def generate_dungeon(max_rooms=10, room_min_size=4, room_max_size=10):
"""Generate a dungeon using simplified BSP approach
Referenced from cos_level.py lines 218-224
"""
rooms = []
# First, fill everything with walls
for y in range(grid_height):
for x in range(grid_width):
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(WALL_TILES)
point.walkable = False
point.transparent = False
# Generate rooms
for _ in range(max_rooms):
# Random room size
w = random.randint(room_min_size, room_max_size)
h = random.randint(room_min_size, room_max_size)
# Random position (with margin from edges)
x = random.randint(1, grid_width - w - 1)
y = random.randint(1, grid_height - h - 1)
new_room = Room(x, y, w, h)
# Check if it overlaps with existing rooms
failed = False
for other_room in rooms:
if new_room.intersects(other_room):
failed = True
break
if not failed:
# Carve out the room
carve_room(new_room)
# If not the first room, connect to previous room
if rooms:
# Get centers
prev_x, prev_y = rooms[-1].center()
new_x, new_y = new_room.center()
# Carve hallway using libtcod.line()
carve_hallway(prev_x, prev_y, new_x, new_y)
rooms.append(new_room)
return rooms
# Generate the dungeon
rooms = generate_dungeon(max_rooms=8, room_min_size=4, room_max_size=8)
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Spawn player in the first room
if rooms:
spawn_x, spawn_y = rooms[0].center()
else:
# Fallback spawn position
spawn_x, spawn_y = 4, 4
# Create a player entity at the spawn position
player = mcrfpy.Entity(
(spawn_x, spawn_y),
texture=hero_texture,
sprite_index=0
)
# Add the player entity to the grid
grid.entities.append(player)
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16
# Movement state tracking (from Part 2)
is_moving = False
move_queue = []
current_destination = None
current_move = None
# Store animation references
player_anim_x = None
player_anim_y = None
grid_anim_x = None
grid_anim_y = None
def movement_complete(anim, target):
"""Called when movement animation completes"""
global is_moving, move_queue, current_destination, current_move
global player_anim_x, player_anim_y
is_moving = False
current_move = None
current_destination = None
player_anim_x = None
player_anim_y = None
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16
if move_queue:
next_move = move_queue.pop(0)
process_move(next_move)
motion_speed = 0.20 # Slightly faster for dungeon exploration
def can_move_to(x, y):
"""Check if a position is valid for movement"""
# Boundary check
if x < 0 or x >= grid_width or y < 0 or y >= grid_height:
return False
# Walkability check
point = grid.at(x, y)
if point and point.walkable:
return True
return False
def process_move(key):
"""Process a move based on the key"""
global is_moving, current_move, current_destination, move_queue
global player_anim_x, player_anim_y, grid_anim_x, grid_anim_y
if is_moving:
move_queue.clear()
move_queue.append(key)
return
px, py = int(player.x), int(player.y)
new_x, new_y = px, py
if key == "W" or key == "Up":
new_y -= 1
elif key == "S" or key == "Down":
new_y += 1
elif key == "A" or key == "Left":
new_x -= 1
elif key == "D" or key == "Right":
new_x += 1
# Check if we can move to the new position
if new_x != px or new_y != py:
if can_move_to(new_x, new_y):
is_moving = True
current_move = key
current_destination = (new_x, new_y)
if new_x != px:
player_anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_x.start(player)
elif new_y != py:
player_anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_y.start(player)
grid_anim_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear")
grid_anim_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear")
grid_anim_x.start(grid)
grid_anim_y.start(grid)
else:
# Play a "bump" sound or visual feedback here
print(f"Can't move to ({new_x}, {new_y}) - blocked!")
def handle_keys(key, state):
"""Handle keyboard input to move the player"""
if state == "start":
if key in ["W", "Up", "S", "Down", "A", "Left", "D", "Right"]:
process_move(key)
# Register the keyboard handler
mcrfpy.keypressScene(handle_keys)
# Add UI elements
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 3: Dungeon Generation",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
instructions = mcrfpy.Caption((150, 750),
text=f"Procedural dungeon with {len(rooms)} rooms connected by hallways!",
)
instructions.font_size = 18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
# Debug info
debug_caption = mcrfpy.Caption((10, 40),
text=f"Grid: {grid_width}x{grid_height} | Player spawned at ({spawn_x}, {spawn_y})",
)
debug_caption.font_size = 16
debug_caption.fill_color = mcrfpy.Color(255, 255, 0, 255)
mcrfpy.sceneUI("tutorial").append(debug_caption)
print("Tutorial Part 3 loaded!")
print(f"Generated dungeon with {len(rooms)} rooms")
print(f"Player spawned at ({spawn_x}, {spawn_y})")
print("Walls now block movement!")
print("Use WASD or Arrow keys to explore the dungeon!")

View file

@ -1,366 +0,0 @@
"""
McRogueFace Tutorial - Part 4: Field of View
This tutorial builds on Part 3 by adding:
- Field of view calculation using grid.compute_fov()
- Entity perspective rendering with grid.perspective
- Three visibility states: unexplored (black), explored (dark), visible (lit)
- Memory of previously seen areas
- Enemy entity to demonstrate perspective switching
Key code references:
- tests/unit/test_tcod_fov_entities.py (lines 89-118) - FOV with multiple entities
- ROADMAP.md (lines 216-229) - FOV system implementation details
"""
import mcrfpy
import random
# Create and activate a new scene
mcrfpy.createScene("tutorial")
mcrfpy.setScene("tutorial")
# Load the texture (4x3 tiles, 64x48 pixels total, 16x16 per tile)
texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16)
# Load the hero sprite texture
hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16)
# Create a grid of tiles
grid_width, grid_height = 40, 30
# Calculate the size in pixels
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# Calculate the position to center the grid on the screen
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
# Create the grid with a TCODMap for pathfinding/FOV
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size,
)
grid.zoom = zoom
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Room class for BSP
class Room:
def __init__(self, x, y, w, h):
self.x1 = x
self.y1 = y
self.x2 = x + w
self.y2 = y + h
self.w = w
self.h = h
def center(self):
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return (center_x, center_y)
def intersects(self, other):
return (self.x1 <= other.x2 and self.x2 >= other.x1 and
self.y1 <= other.y2 and self.y2 >= other.y1)
# Dungeon generation functions (from Part 3)
def carve_room(room):
for x in range(room.x1, room.x2):
for y in range(room.y1, room.y2):
if 0 <= x < grid_width and 0 <= y < grid_height:
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(FLOOR_TILES)
point.walkable = True
point.transparent = True
def carve_hallway(x1, y1, x2, y2):
#points = mcrfpy.libtcod.line(x1, y1, x2, y2)
points = []
if random.choice([True, False]):
# x1,y1 -> x2,y1 -> x2,y2
points.extend(mcrfpy.libtcod.line(x1, y1, x2, y1))
points.extend(mcrfpy.libtcod.line(x2, y1, x2, y2))
else:
# x1,y1 -> x1,y2 -> x2,y2
points.extend(mcrfpy.libtcod.line(x1, y1, x1, y2))
points.extend(mcrfpy.libtcod.line(x1, y2, x2, y2))
for x, y in points:
if 0 <= x < grid_width and 0 <= y < grid_height:
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(FLOOR_TILES)
point.walkable = True
point.transparent = True
def generate_dungeon(max_rooms=10, room_min_size=4, room_max_size=10):
rooms = []
# Fill with walls
for y in range(grid_height):
for x in range(grid_width):
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(WALL_TILES)
point.walkable = False
point.transparent = False
# Generate rooms
for _ in range(max_rooms):
w = random.randint(room_min_size, room_max_size)
h = random.randint(room_min_size, room_max_size)
x = random.randint(1, grid_width - w - 1)
y = random.randint(1, grid_height - h - 1)
new_room = Room(x, y, w, h)
failed = False
for other_room in rooms:
if new_room.intersects(other_room):
failed = True
break
if not failed:
carve_room(new_room)
if rooms:
prev_x, prev_y = rooms[-1].center()
new_x, new_y = new_room.center()
carve_hallway(prev_x, prev_y, new_x, new_y)
rooms.append(new_room)
return rooms
# Generate the dungeon
rooms = generate_dungeon(max_rooms=8, room_min_size=4, room_max_size=8)
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Spawn player in the first room
if rooms:
spawn_x, spawn_y = rooms[0].center()
else:
spawn_x, spawn_y = 4, 4
# Create a player entity
player = mcrfpy.Entity(
(spawn_x, spawn_y),
texture=hero_texture,
sprite_index=0
)
# Add the player entity to the grid
grid.entities.append(player)
# Create an enemy entity in another room (to demonstrate perspective switching)
enemy = None
if len(rooms) > 1:
enemy_x, enemy_y = rooms[1].center()
enemy = mcrfpy.Entity(
(enemy_x, enemy_y),
texture=hero_texture,
sprite_index=0 # Enemy sprite
)
grid.entities.append(enemy)
# Set the grid perspective to the player by default
# Note: The new perspective system uses entity references directly
grid.perspective = player
# Initial FOV computation
def update_fov():
"""Update field of view from current perspective
Referenced from test_tcod_fov_entities.py lines 89-118
"""
if grid.perspective == player:
grid.compute_fov(int(player.x), int(player.y), radius=8, algorithm=0)
player.update_visibility()
elif enemy and grid.perspective == enemy:
grid.compute_fov(int(enemy.x), int(enemy.y), radius=6, algorithm=0)
enemy.update_visibility()
# Perform initial FOV calculation
update_fov()
# Center grid on current perspective
def center_on_perspective():
if grid.perspective == player:
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16
elif enemy and grid.perspective == enemy:
grid.center = (enemy.x + 0.5) * 16, (enemy.y + 0.5) * 16
center_on_perspective()
# Movement state tracking (from Part 3)
is_moving = False
move_queue = []
current_destination = None
current_move = None
# Store animation references
player_anim_x = None
player_anim_y = None
grid_anim_x = None
grid_anim_y = None
def movement_complete(anim, target):
"""Called when movement animation completes"""
global is_moving, move_queue, current_destination, current_move
global player_anim_x, player_anim_y
is_moving = False
current_move = None
current_destination = None
player_anim_x = None
player_anim_y = None
# Update FOV after movement
update_fov()
center_on_perspective()
if move_queue:
next_move = move_queue.pop(0)
process_move(next_move)
motion_speed = 0.20
def can_move_to(x, y):
"""Check if a position is valid for movement"""
if x < 0 or x >= grid_width or y < 0 or y >= grid_height:
return False
point = grid.at(x, y)
if point and point.walkable:
return True
return False
def process_move(key):
"""Process a move based on the key"""
global is_moving, current_move, current_destination, move_queue
global player_anim_x, player_anim_y, grid_anim_x, grid_anim_y
# Only allow player movement when in player perspective
if grid.perspective != player:
return
if is_moving:
move_queue.clear()
move_queue.append(key)
return
px, py = int(player.x), int(player.y)
new_x, new_y = px, py
if key == "W" or key == "Up":
new_y -= 1
elif key == "S" or key == "Down":
new_y += 1
elif key == "A" or key == "Left":
new_x -= 1
elif key == "D" or key == "Right":
new_x += 1
if new_x != px or new_y != py:
if can_move_to(new_x, new_y):
is_moving = True
current_move = key
current_destination = (new_x, new_y)
if new_x != px:
player_anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_x.start(player)
elif new_y != py:
player_anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_y.start(player)
grid_anim_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear")
grid_anim_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear")
grid_anim_x.start(grid)
grid_anim_y.start(grid)
def handle_keys(key, state):
"""Handle keyboard input"""
if state == "start":
# Movement keys
if key in ["W", "Up", "S", "Down", "A", "Left", "D", "Right"]:
process_move(key)
# Perspective switching
elif key == "Tab":
# Switch perspective between player and enemy
if enemy:
if grid.perspective == player:
grid.perspective = enemy
print("Switched to enemy perspective")
else:
grid.perspective = player
print("Switched to player perspective")
# Update FOV and camera for new perspective
update_fov()
center_on_perspective()
# Register the keyboard handler
mcrfpy.keypressScene(handle_keys)
# Add UI elements
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 4: Field of View",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
instructions = mcrfpy.Caption((150, 720),
text="Use WASD/Arrows to move. Press Tab to switch perspective!",
)
instructions.font_size = 18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
# FOV info
fov_caption = mcrfpy.Caption((150, 745),
text="FOV: Player (radius 8) | Enemy visible in other room",
)
fov_caption.font_size = 16
fov_caption.fill_color = mcrfpy.Color(100, 200, 255, 255)
mcrfpy.sceneUI("tutorial").append(fov_caption)
# Debug info
debug_caption = mcrfpy.Caption((10, 40),
text=f"Grid: {grid_width}x{grid_height} | Rooms: {len(rooms)} | Perspective: Player",
)
debug_caption.font_size = 16
debug_caption.fill_color = mcrfpy.Color(255, 255, 0, 255)
mcrfpy.sceneUI("tutorial").append(debug_caption)
# Update function for perspective display
def update_perspective_display():
current_perspective = "Player" if grid.perspective == player else "Enemy"
debug_caption.text = f"Grid: {grid_width}x{grid_height} | Rooms: {len(rooms)} | Perspective: {current_perspective}"
if grid.perspective == player:
fov_caption.text = "FOV: Player (radius 8) | Tab to switch perspective"
else:
fov_caption.text = "FOV: Enemy (radius 6) | Tab to switch perspective"
# Timer to update display
def update_display(runtime):
update_perspective_display()
mcrfpy.setTimer("display_update", update_display, 100)
print("Tutorial Part 4 loaded!")
print("Field of View system active!")
print("- Unexplored areas are black")
print("- Previously seen areas are dark")
print("- Currently visible areas are lit")
print("Press Tab to switch between player and enemy perspective!")
print("Use WASD or Arrow keys to move!")

View file

@ -1,363 +0,0 @@
"""
McRogueFace Tutorial - Part 5: Interacting with other entities
This tutorial builds on Part 4 by adding:
- Subclassing mcrfpy.Entity
- Non-blocking movement animations with destination tracking
- Bump interactions (combat, pushing)
"""
import mcrfpy
import random
# Create and activate a new scene
mcrfpy.createScene("tutorial")
mcrfpy.setScene("tutorial")
# Load the texture (4x3 tiles, 64x48 pixels total, 16x16 per tile)
texture = mcrfpy.Texture("assets/tutorial2.png", 16, 16)
# Load the hero sprite texture
hero_texture = mcrfpy.Texture("assets/custom_player.png", 16, 16)
# Create a grid of tiles
grid_width, grid_height = 40, 30
# Calculate the size in pixels
zoom = 2.0
grid_size = grid_width * zoom * 16, grid_height * zoom * 16
# Calculate the position to center the grid on the screen
grid_position = (1024 - grid_size[0]) / 2, (768 - grid_size[1]) / 2
# Create the grid with a TCODMap for pathfinding/FOV
grid = mcrfpy.Grid(
pos=grid_position,
grid_size=(grid_width, grid_height),
texture=texture,
size=grid_size,
)
grid.zoom = zoom
# Define tile types
FLOOR_TILES = [0, 1, 2, 4, 5, 6, 8, 9, 10]
WALL_TILES = [3, 7, 11]
# Room class for BSP
class Room:
def __init__(self, x, y, w, h):
self.x1 = x
self.y1 = y
self.x2 = x + w
self.y2 = y + h
self.w = w
self.h = h
def center(self):
center_x = (self.x1 + self.x2) // 2
center_y = (self.y1 + self.y2) // 2
return (center_x, center_y)
def intersects(self, other):
return (self.x1 <= other.x2 and self.x2 >= other.x1 and
self.y1 <= other.y2 and self.y2 >= other.y1)
# Dungeon generation functions (from Part 3)
def carve_room(room):
for x in range(room.x1, room.x2):
for y in range(room.y1, room.y2):
if 0 <= x < grid_width and 0 <= y < grid_height:
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(FLOOR_TILES)
point.walkable = True
point.transparent = True
def carve_hallway(x1, y1, x2, y2):
#points = mcrfpy.libtcod.line(x1, y1, x2, y2)
points = []
if random.choice([True, False]):
# x1,y1 -> x2,y1 -> x2,y2
points.extend(mcrfpy.libtcod.line(x1, y1, x2, y1))
points.extend(mcrfpy.libtcod.line(x2, y1, x2, y2))
else:
# x1,y1 -> x1,y2 -> x2,y2
points.extend(mcrfpy.libtcod.line(x1, y1, x1, y2))
points.extend(mcrfpy.libtcod.line(x1, y2, x2, y2))
for x, y in points:
if 0 <= x < grid_width and 0 <= y < grid_height:
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(FLOOR_TILES)
point.walkable = True
point.transparent = True
def generate_dungeon(max_rooms=10, room_min_size=4, room_max_size=10):
rooms = []
# Fill with walls
for y in range(grid_height):
for x in range(grid_width):
point = grid.at(x, y)
if point:
point.tilesprite = random.choice(WALL_TILES)
point.walkable = False
point.transparent = False
# Generate rooms
for _ in range(max_rooms):
w = random.randint(room_min_size, room_max_size)
h = random.randint(room_min_size, room_max_size)
x = random.randint(1, grid_width - w - 1)
y = random.randint(1, grid_height - h - 1)
new_room = Room(x, y, w, h)
failed = False
for other_room in rooms:
if new_room.intersects(other_room):
failed = True
break
if not failed:
carve_room(new_room)
if rooms:
prev_x, prev_y = rooms[-1].center()
new_x, new_y = new_room.center()
carve_hallway(prev_x, prev_y, new_x, new_y)
rooms.append(new_room)
return rooms
# Generate the dungeon
rooms = generate_dungeon(max_rooms=8, room_min_size=4, room_max_size=8)
# Add the grid to the scene
mcrfpy.sceneUI("tutorial").append(grid)
# Spawn player in the first room
if rooms:
spawn_x, spawn_y = rooms[0].center()
else:
spawn_x, spawn_y = 4, 4
class GameEntity(mcrfpy.Entity):
"""An entity whose default behavior is to prevent others from moving into its tile."""
def __init__(self, x, y, walkable=False, **kwargs):
super().__init__(x=x, y=y, **kwargs)
self.walkable = walkable
self.dest_x = x
self.dest_y = y
self.is_moving = False
def get_position(self):
"""Get logical position (destination if moving, otherwise current)"""
if self.is_moving:
return (self.dest_x, self.dest_y)
return (int(self.x), int(self.y))
def on_bump(self, other):
return self.walkable # allow other's motion to proceed if entity is walkable
def __repr__(self):
return f"<{self.__class__.__name__} x={self.x}, y={self.y}, sprite_index={self.sprite_index}>"
class BumpableEntity(GameEntity):
def __init__(self, x, y, **kwargs):
super().__init__(x, y, **kwargs)
def on_bump(self, other):
print(f"Watch it, {other}! You bumped into {self}!")
return False
# Create a player entity
player = GameEntity(
spawn_x, spawn_y,
texture=hero_texture,
sprite_index=0
)
# Add the player entity to the grid
grid.entities.append(player)
for r in rooms:
enemy_x, enemy_y = r.center()
enemy = BumpableEntity(
enemy_x, enemy_y,
grid=grid,
texture=hero_texture,
sprite_index=0 # Enemy sprite
)
# Set the grid perspective to the player by default
# Note: The new perspective system uses entity references directly
grid.perspective = player
# Initial FOV computation
def update_fov():
"""Update field of view from current perspective
Referenced from test_tcod_fov_entities.py lines 89-118
"""
if grid.perspective == player:
grid.compute_fov(int(player.x), int(player.y), radius=8, algorithm=0)
player.update_visibility()
elif enemy and grid.perspective == enemy:
grid.compute_fov(int(enemy.x), int(enemy.y), radius=6, algorithm=0)
enemy.update_visibility()
# Perform initial FOV calculation
update_fov()
# Center grid on current perspective
def center_on_perspective():
if grid.perspective == player:
grid.center = (player.x + 0.5) * 16, (player.y + 0.5) * 16
elif enemy and grid.perspective == enemy:
grid.center = (enemy.x + 0.5) * 16, (enemy.y + 0.5) * 16
center_on_perspective()
# Movement state tracking (from Part 3)
#is_moving = False # make it an entity property
move_queue = []
current_destination = None
current_move = None
# Store animation references
player_anim_x = None
player_anim_y = None
grid_anim_x = None
grid_anim_y = None
def movement_complete(anim, target):
"""Called when movement animation completes"""
global move_queue, current_destination, current_move
global player_anim_x, player_anim_y
player.is_moving = False
current_move = None
current_destination = None
player_anim_x = None
player_anim_y = None
# Update FOV after movement
update_fov()
center_on_perspective()
if move_queue:
next_move = move_queue.pop(0)
process_move(next_move)
motion_speed = 0.20
def can_move_to(x, y):
"""Check if a position is valid for movement"""
if x < 0 or x >= grid_width or y < 0 or y >= grid_height:
return False
point = grid.at(x, y)
if point and point.walkable:
for e in grid.entities:
if not e.walkable and (x, y) == e.get_position(): # blocking the way
e.on_bump(player)
return False
return True # all checks passed, no collision
return False
def process_move(key):
"""Process a move based on the key"""
global current_move, current_destination, move_queue
global player_anim_x, player_anim_y, grid_anim_x, grid_anim_y
# Only allow player movement when in player perspective
if grid.perspective != player:
return
if player.is_moving:
move_queue.clear()
move_queue.append(key)
return
px, py = int(player.x), int(player.y)
new_x, new_y = px, py
if key == "W" or key == "Up":
new_y -= 1
elif key == "S" or key == "Down":
new_y += 1
elif key == "A" or key == "Left":
new_x -= 1
elif key == "D" or key == "Right":
new_x += 1
if new_x != px or new_y != py:
if can_move_to(new_x, new_y):
player.is_moving = True
current_move = key
current_destination = (new_x, new_y)
if new_x != px:
player_anim_x = mcrfpy.Animation("x", float(new_x), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_x.start(player)
elif new_y != py:
player_anim_y = mcrfpy.Animation("y", float(new_y), motion_speed, "easeInOutQuad", callback=movement_complete)
player_anim_y.start(player)
grid_anim_x = mcrfpy.Animation("center_x", (new_x + 0.5) * 16, motion_speed, "linear")
grid_anim_y = mcrfpy.Animation("center_y", (new_y + 0.5) * 16, motion_speed, "linear")
grid_anim_x.start(grid)
grid_anim_y.start(grid)
def handle_keys(key, state):
"""Handle keyboard input"""
if state == "start":
# Movement keys
if key in ["W", "Up", "S", "Down", "A", "Left", "D", "Right"]:
process_move(key)
# Register the keyboard handler
mcrfpy.keypressScene(handle_keys)
# Add UI elements
title = mcrfpy.Caption((320, 10),
text="McRogueFace Tutorial - Part 5: Entity Collision",
)
title.fill_color = mcrfpy.Color(255, 255, 255, 255)
mcrfpy.sceneUI("tutorial").append(title)
instructions = mcrfpy.Caption((150, 720),
text="Use WASD/Arrows to move. Try to bump into the other entity!",
)
instructions.font_size = 18
instructions.fill_color = mcrfpy.Color(200, 200, 200, 255)
mcrfpy.sceneUI("tutorial").append(instructions)
# Debug info
debug_caption = mcrfpy.Caption((10, 40),
text=f"Grid: {grid_width}x{grid_height} | Rooms: {len(rooms)} | Perspective: Player",
)
debug_caption.font_size = 16
debug_caption.fill_color = mcrfpy.Color(255, 255, 0, 255)
mcrfpy.sceneUI("tutorial").append(debug_caption)
# Update function for perspective display
def update_perspective_display():
current_perspective = "Player" if grid.perspective == player else "Enemy"
debug_caption.text = f"Grid: {grid_width}x{grid_height} | Rooms: {len(rooms)} | Perspective: {current_perspective}"
# Timer to update display
def update_display(runtime):
update_perspective_display()
mcrfpy.setTimer("display_update", update_display, 100)
print("Tutorial Part 4 loaded!")
print("Field of View system active!")
print("- Unexplored areas are black")
print("- Previously seen areas are dark")
print("- Currently visible areas are lit")
print("Press Tab to switch between player and enemy perspective!")
print("Use WASD or Arrow keys to move!")

View file

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

View file

@ -1,582 +0,0 @@
"""
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!")

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

580
src/scripts/geometry.py Normal file
View file

@ -0,0 +1,580 @@
"""
Geometry module for turn-based games with orbital mechanics.
Designed for Pinships but reusable for any game needing:
- Circular orbit calculations
- Grid-aligned geometric primitives
- Recursive celestial body positioning
- Pathfinding helpers for orbital navigation
Philosophy: "C++ every frame, Python every game step"
This module handles game logic, not rendering.
"""
from __future__ import annotations
import math
from typing import Optional, List, Tuple, Set
from dataclasses import dataclass, field
# =============================================================================
# Basic Utility Functions
# =============================================================================
def distance(p1: Tuple[float, float], p2: Tuple[float, float]) -> float:
"""Euclidean distance between two points."""
dx = p2[0] - p1[0]
dy = p2[1] - p1[1]
return math.sqrt(dx * dx + dy * dy)
def distance_squared(p1: Tuple[float, float], p2: Tuple[float, float]) -> float:
"""Squared distance (avoids sqrt, useful for comparisons)."""
dx = p2[0] - p1[0]
dy = p2[1] - p1[1]
return dx * dx + dy * dy
def angle_between(p1: Tuple[float, float], p2: Tuple[float, float]) -> float:
"""
Angle from p1 to p2 in degrees (0-360).
0 degrees = east (+x), 90 = north (+y in screen coords, or south in math coords).
"""
dx = p2[0] - p1[0]
dy = p2[1] - p1[1]
angle = math.degrees(math.atan2(dy, dx))
return normalize_angle(angle)
def normalize_angle(angle: float) -> float:
"""Normalize angle to 0-360 range."""
angle = angle % 360
if angle < 0:
angle += 360
return angle
def angle_difference(a1: float, a2: float) -> float:
"""
Shortest angular distance between two angles (signed, -180 to 180).
Positive = counterclockwise from a1 to a2.
"""
diff = normalize_angle(a2) - normalize_angle(a1)
if diff > 180:
diff -= 360
elif diff < -180:
diff += 360
return diff
def lerp(a: float, b: float, t: float) -> float:
"""Linear interpolation from a to b by factor t (0-1)."""
return a + (b - a) * t
def clamp(value: float, min_val: float, max_val: float) -> float:
"""Clamp value to range [min_val, max_val]."""
return max(min_val, min(max_val, value))
def point_on_circle(
center: Tuple[float, float],
radius: float,
angle_degrees: float
) -> Tuple[float, float]:
"""Get point on circle at given angle (degrees)."""
angle_rad = math.radians(angle_degrees)
x = center[0] + radius * math.cos(angle_rad)
y = center[1] + radius * math.sin(angle_rad)
return (x, y)
def rotate_point(
point: Tuple[float, float],
center: Tuple[float, float],
angle_degrees: float
) -> Tuple[float, float]:
"""Rotate point around center by angle (degrees)."""
angle_rad = math.radians(angle_degrees)
cos_a = math.cos(angle_rad)
sin_a = math.sin(angle_rad)
# Translate to origin
px = point[0] - center[0]
py = point[1] - center[1]
# Rotate
rx = px * cos_a - py * sin_a
ry = px * sin_a + py * cos_a
# Translate back
return (rx + center[0], ry + center[1])
# =============================================================================
# Grid-Aligned Geometry (Bresenham algorithms)
# =============================================================================
def bresenham_circle(
center: Tuple[int, int],
radius: int
) -> List[Tuple[int, int]]:
"""
Generate all grid cells on a circle's perimeter using Bresenham's algorithm.
Returns cells in no particular order (use sort_circle_cells for ordering).
"""
if radius <= 0:
return [center]
cx, cy = center
cells: Set[Tuple[int, int]] = set()
x = 0
y = radius
d = 3 - 2 * radius
def add_circle_points(cx: int, cy: int, x: int, y: int):
"""Add all 8 symmetric points."""
cells.add((cx + x, cy + y))
cells.add((cx - x, cy + y))
cells.add((cx + x, cy - y))
cells.add((cx - x, cy - y))
cells.add((cx + y, cy + x))
cells.add((cx - y, cy + x))
cells.add((cx + y, cy - x))
cells.add((cx - y, cy - x))
add_circle_points(cx, cy, x, y)
while y >= x:
x += 1
if d > 0:
y -= 1
d = d + 4 * (x - y) + 10
else:
d = d + 4 * x + 6
add_circle_points(cx, cy, x, y)
return list(cells)
def sort_circle_cells(
cells: List[Tuple[int, int]],
center: Tuple[int, int]
) -> List[Tuple[int, int]]:
"""Sort circle cells by angle from center (for ordered traversal)."""
return sorted(cells, key=lambda p: angle_between(center, p))
def bresenham_line(
p1: Tuple[int, int],
p2: Tuple[int, int]
) -> List[Tuple[int, int]]:
"""Generate all grid cells on a line using Bresenham's algorithm."""
cells = []
x1, y1 = p1
x2, y2 = p2
dx = abs(x2 - x1)
dy = abs(y2 - y1)
sx = 1 if x1 < x2 else -1
sy = 1 if y1 < y2 else -1
err = dx - dy
while True:
cells.append((x1, y1))
if x1 == x2 and y1 == y2:
break
e2 = 2 * err
if e2 > -dy:
err -= dy
x1 += sx
if e2 < dx:
err += dx
y1 += sy
return cells
def filled_circle(
center: Tuple[int, int],
radius: int
) -> List[Tuple[int, int]]:
"""Generate all grid cells within a filled circle."""
if radius <= 0:
return [center]
cx, cy = center
cells = []
r_sq = radius * radius
for y in range(cy - radius, cy + radius + 1):
for x in range(cx - radius, cx + radius + 1):
if (x - cx) ** 2 + (y - cy) ** 2 <= r_sq:
cells.append((x, y))
return cells
# =============================================================================
# Orbital Body System
# =============================================================================
@dataclass
class OrbitalBody:
"""
A celestial body that may orbit another body.
Supports recursive orbits: star -> planet -> moon -> moon-of-moon
Position is calculated by walking up the parent chain.
"""
name: str
surface_radius: int # Physical size of the body
orbit_ring_radius: int # Distance from center where ships can orbit
# Orbital parameters (ignored if parent is None)
parent: Optional[OrbitalBody] = None
orbital_radius: float = 0.0 # Distance from parent's center
angular_velocity: float = 0.0 # Degrees per turn
initial_angle: float = 0.0 # Angle at t=0
# Base position (only used if parent is None, i.e., the star)
base_position: Tuple[int, int] = (0, 0)
def center_at_time(self, t: int) -> Tuple[float, float]:
"""
Get continuous (float) position at time t.
Recursively calculates position through parent chain.
"""
if self.parent is None:
# Stationary body (star)
return (float(self.base_position[0]), float(self.base_position[1]))
# Get parent's position at this time
parent_pos = self.parent.center_at_time(t)
# Calculate our angle at time t
angle = self.initial_angle + self.angular_velocity * t
# Calculate offset from parent
offset = point_on_circle((0, 0), self.orbital_radius, angle)
return (parent_pos[0] + offset[0], parent_pos[1] + offset[1])
def grid_position_at_time(self, t: int) -> Tuple[int, int]:
"""
Get snapped grid position at time t.
This is where the body appears on the discrete game grid.
"""
cx, cy = self.center_at_time(t)
return (round(cx), round(cy))
def surface_cells(self, t: int) -> List[Tuple[int, int]]:
"""Get all grid cells occupied by this body's surface at time t."""
return filled_circle(self.grid_position_at_time(t), self.surface_radius)
def orbit_ring_cells(self, t: int) -> List[Tuple[int, int]]:
"""
Get all grid cells forming the orbit ring at time t.
Ships can occupy these cells while orbiting this body.
"""
return bresenham_circle(self.grid_position_at_time(t), self.orbit_ring_radius)
def orbit_ring_cells_sorted(self, t: int) -> List[Tuple[int, int]]:
"""Get orbit ring cells sorted by angle (for ordered traversal)."""
center = self.grid_position_at_time(t)
cells = bresenham_circle(center, self.orbit_ring_radius)
return sort_circle_cells(cells, center)
def position_in_orbit(self, t: int, angle: float) -> Tuple[int, int]:
"""
Get the grid position for a ship orbiting this body at given angle.
The ship moves with the body - this returns absolute grid coords.
"""
center = self.grid_position_at_time(t)
pos = point_on_circle(center, self.orbit_ring_radius, angle)
return (round(pos[0]), round(pos[1]))
def is_inside_surface(self, point: Tuple[int, int], t: int) -> bool:
"""Check if a grid point is inside this body's surface."""
center = self.grid_position_at_time(t)
return distance_squared(center, point) <= self.surface_radius ** 2
def is_on_orbit_ring(self, point: Tuple[int, int], t: int) -> bool:
"""Check if a grid point is on this body's orbit ring."""
return point in self.orbit_ring_cells(t)
def nearest_orbit_angle(self, point: Tuple[float, float], t: int) -> float:
"""
Get the angle on the orbit ring closest to the given point.
Useful for determining where a ship would enter orbit.
"""
center = self.grid_position_at_time(t)
return angle_between(center, point)
def turns_until_position_changes(self, current_t: int) -> int:
"""
Calculate how many turns until this body's grid position changes.
Returns 0 if it changes next turn, -1 if it never moves (star).
"""
if self.parent is None:
return -1 # Stars don't move
current_pos = self.grid_position_at_time(current_t)
# Check future turns (reasonable limit to avoid infinite loop)
for dt in range(1, 1000):
future_pos = self.grid_position_at_time(current_t + dt)
if future_pos != current_pos:
return dt
return -1 # Essentially stationary (very slow orbit)
@dataclass
class OrbitingShip:
"""
A ship that is currently in orbit around a body.
When orbiting, position is relative to the body, not absolute grid coords.
The ship moves with the body automatically.
"""
body: OrbitalBody
orbital_angle: float # Position on orbit ring (degrees)
def grid_position_at_time(self, t: int) -> Tuple[int, int]:
"""Get absolute grid position at time t."""
return self.body.position_in_orbit(t, self.orbital_angle)
def move_along_orbit(self, angle_delta: float) -> None:
"""Move ship along the orbit ring (free movement while orbiting)."""
self.orbital_angle = normalize_angle(self.orbital_angle + angle_delta)
def set_orbit_angle(self, angle: float) -> None:
"""Set ship to specific angle on orbit ring."""
self.orbital_angle = normalize_angle(angle)
# =============================================================================
# Pathfinding Helpers
# =============================================================================
def nearest_orbit_entry(
ship_pos: Tuple[float, float],
body: OrbitalBody,
t: int
) -> Tuple[Tuple[int, int], float]:
"""
Find the nearest point on a body's orbit ring to enter.
Returns:
(grid_position, angle): Entry point and the orbital angle
"""
angle = body.nearest_orbit_angle(ship_pos, t)
entry_pos = body.position_in_orbit(t, angle)
return (entry_pos, angle)
def optimal_exit_heading(
body: OrbitalBody,
target: Tuple[float, float],
t: int
) -> Tuple[float, Tuple[int, int]]:
"""
Find the best angle to exit an orbit when heading toward a target.
Returns:
(exit_angle, exit_position): Best exit angle and grid position
"""
center = body.grid_position_at_time(t)
exit_angle = angle_between(center, target)
exit_pos = body.position_in_orbit(t, exit_angle)
return (exit_angle, exit_pos)
def is_viable_waypoint(
ship_pos: Tuple[float, float],
body: OrbitalBody,
target: Tuple[float, float],
t: int,
angle_threshold: float = 90.0
) -> bool:
"""
Check if an orbital body is a useful waypoint toward a target.
A body is viable if it's roughly "on the way" - the angle from
ship to body to target isn't too sharp (would be backtracking).
Args:
ship_pos: Ship's current position
body: Potential waypoint body
target: Final destination
t: Current time
angle_threshold: Maximum deflection angle (degrees)
Returns:
True if using this body's orbit could help reach target
"""
body_pos = body.grid_position_at_time(t)
# Angle from ship to body
angle_to_body = angle_between(ship_pos, body_pos)
# Angle from ship to target
angle_to_target = angle_between(ship_pos, target)
# How much would we deviate from direct path?
deviation = abs(angle_difference(angle_to_target, angle_to_body))
return deviation <= angle_threshold
def project_body_positions(
body: OrbitalBody,
start_t: int,
num_turns: int
) -> List[Tuple[int, Tuple[int, int]]]:
"""
Project a body's grid positions over future turns.
Returns:
List of (turn, grid_position) tuples
"""
positions = []
for dt in range(num_turns):
t = start_t + dt
pos = body.grid_position_at_time(t)
positions.append((t, pos))
return positions
def find_intercept_turn(
ship_pos: Tuple[float, float],
ship_speed: float,
body: OrbitalBody,
start_t: int,
max_turns: int = 100
) -> Optional[Tuple[int, Tuple[int, int]]]:
"""
Find when a ship could intercept a moving body's orbit.
Simple approach: check each future turn to see if ship could
reach the body's orbit ring by then.
Args:
ship_pos: Ship's starting position
ship_speed: Ship's movement per turn (grid units)
body: Target body to intercept
start_t: Current turn
max_turns: Maximum turns to search
Returns:
(turn, intercept_position) or None if no intercept found
"""
for dt in range(1, max_turns + 1):
t = start_t + dt
body_center = body.grid_position_at_time(t)
# Distance ship could travel
max_travel = ship_speed * dt
# Distance to body's orbit ring
dist_to_center = distance(ship_pos, body_center)
dist_to_orbit = abs(dist_to_center - body.orbit_ring_radius)
if dist_to_orbit <= max_travel:
# Ship could reach orbit this turn
entry_pos, _ = nearest_orbit_entry(ship_pos, body, t)
return (t, entry_pos)
return None
def line_of_sight_blocked(
p1: Tuple[int, int],
p2: Tuple[int, int],
bodies: List[OrbitalBody],
t: int
) -> Optional[OrbitalBody]:
"""
Check if line of sight between two points is blocked by any body's surface.
Returns:
The blocking body, or None if LOS is clear
"""
line_cells = set(bresenham_line(p1, p2))
for body in bodies:
surface = set(body.surface_cells(t))
if line_cells & surface: # Intersection
return body
return None
# =============================================================================
# Convenience Functions
# =============================================================================
def create_solar_system(
grid_width: int,
grid_height: int,
star_radius: int = 10,
star_orbit_radius: int = 15
) -> OrbitalBody:
"""
Create a star at the center of the grid.
Returns the star body (other bodies should use it as parent).
"""
return OrbitalBody(
name="Star",
surface_radius=star_radius,
orbit_ring_radius=star_orbit_radius,
parent=None,
base_position=(grid_width // 2, grid_height // 2)
)
def create_planet(
name: str,
star: OrbitalBody,
orbital_radius: float,
surface_radius: int,
orbit_ring_radius: int,
angular_velocity: float,
initial_angle: float = 0.0
) -> OrbitalBody:
"""Create a planet orbiting a star."""
return OrbitalBody(
name=name,
surface_radius=surface_radius,
orbit_ring_radius=orbit_ring_radius,
parent=star,
orbital_radius=orbital_radius,
angular_velocity=angular_velocity,
initial_angle=initial_angle
)
def create_moon(
name: str,
planet: OrbitalBody,
orbital_radius: float,
surface_radius: int,
orbit_ring_radius: int,
angular_velocity: float,
initial_angle: float = 0.0
) -> OrbitalBody:
"""Create a moon orbiting a planet (or another moon)."""
return OrbitalBody(
name=name,
surface_radius=surface_radius,
orbit_ring_radius=orbit_ring_radius,
parent=planet,
orbital_radius=orbital_radius,
angular_velocity=angular_velocity,
initial_angle=initial_angle
)

View file

@ -0,0 +1 @@
"""Geometry module demo system for Pinships orbital mechanics."""

View file

@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""
Geometry Module Demo System
Demonstrates the geometry module for Pinships orbital mechanics:
- Bresenham algorithms for grid-aligned circles and lines
- Angle calculations for pathfinding
- Static pathfinding through planetary orbits
- Animated solar system with discrete time steps
- Ship navigation anticipating planetary motion
Usage:
Headless (screenshots): ./mcrogueface --headless --exec tests/geometry_demo/geometry_main.py
Interactive: ./mcrogueface tests/geometry_demo/geometry_main.py
"""
import mcrfpy
from mcrfpy import automation
import sys
import os
# Add paths for imports
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', 'src', 'scripts'))
# Import screen modules
from geometry_demo.screens.bresenham_demo import BresenhamDemo
from geometry_demo.screens.angle_lines_demo import AngleLinesDemo
from geometry_demo.screens.pathfinding_static_demo import PathfindingStaticDemo
from geometry_demo.screens.solar_system_demo import SolarSystemDemo
from geometry_demo.screens.pathfinding_animated_demo import PathfindingAnimatedDemo
# All demo screens in order
DEMO_SCREENS = [
BresenhamDemo,
AngleLinesDemo,
PathfindingStaticDemo,
SolarSystemDemo,
PathfindingAnimatedDemo,
]
class GeometryDemoRunner:
"""Manages the geometry demo system."""
def __init__(self):
self.screens = []
self.current_index = 0
self.headless = self._detect_headless()
self.screenshot_dir = os.path.join(os.path.dirname(__file__), "screenshots")
def _detect_headless(self):
"""Detect if running in headless mode."""
try:
win = mcrfpy.Window.get()
return str(win).find("headless") >= 0
except:
return True
def setup_all_screens(self):
"""Initialize all demo screens."""
for i, ScreenClass in enumerate(DEMO_SCREENS):
scene_name = f"geo_{i:02d}_{ScreenClass.name.lower().replace(' ', '_')}"
screen = ScreenClass(scene_name)
screen.setup()
self.screens.append(screen)
def create_menu(self):
"""Create the main menu screen."""
mcrfpy.createScene("geo_menu")
ui = mcrfpy.sceneUI("geo_menu")
# Screen dimensions
SCREEN_WIDTH = 1024
SCREEN_HEIGHT = 768
# Background
bg = mcrfpy.Frame(pos=(0, 0), size=(SCREEN_WIDTH, SCREEN_HEIGHT))
bg.fill_color = mcrfpy.Color(15, 15, 25)
ui.append(bg)
# Title
title = mcrfpy.Caption(text="Geometry Module Demo", pos=(SCREEN_WIDTH // 2, 40))
title.fill_color = mcrfpy.Color(255, 255, 255)
title.outline = 2
title.outline_color = mcrfpy.Color(0, 0, 0)
ui.append(title)
subtitle = mcrfpy.Caption(text="Pinships Orbital Mechanics", pos=(SCREEN_WIDTH // 2, 80))
subtitle.fill_color = mcrfpy.Color(180, 180, 180)
ui.append(subtitle)
# Menu items - wider buttons centered on 1024 width
btn_width = 500
btn_x = (SCREEN_WIDTH - btn_width) // 2
for i, screen in enumerate(self.screens):
y = 140 + i * 70
# Button frame
btn = mcrfpy.Frame(pos=(btn_x, y), size=(btn_width, 60))
btn.fill_color = mcrfpy.Color(30, 40, 60)
btn.outline = 2
btn.outline_color = mcrfpy.Color(80, 100, 150)
ui.append(btn)
# Button text
label = mcrfpy.Caption(text=f"{i+1}. {screen.name}", pos=(20, 12))
label.fill_color = mcrfpy.Color(200, 200, 255)
btn.children.append(label)
# Description
desc = mcrfpy.Caption(text=screen.description, pos=(20, 35))
desc.fill_color = mcrfpy.Color(120, 120, 150)
btn.children.append(desc)
# Instructions
instr1 = mcrfpy.Caption(text="Press 1-5 to view demos", pos=(SCREEN_WIDTH // 2 - 100, 540))
instr1.fill_color = mcrfpy.Color(150, 150, 150)
ui.append(instr1)
instr2 = mcrfpy.Caption(text="ESC = return to menu | Q = quit", pos=(SCREEN_WIDTH // 2 - 130, 580))
instr2.fill_color = mcrfpy.Color(100, 100, 100)
ui.append(instr2)
# Credits
credits = mcrfpy.Caption(text="Geometry module: src/scripts/geometry.py", pos=(SCREEN_WIDTH // 2 - 150, 700))
credits.fill_color = mcrfpy.Color(80, 80, 100)
ui.append(credits)
def run_headless(self):
"""Run in headless mode - generate all screenshots."""
print(f"Generating {len(self.screens)} geometry demo screenshots...")
os.makedirs(self.screenshot_dir, exist_ok=True)
self.current_index = 0
self.render_wait = 0
def screenshot_cycle(runtime):
if self.render_wait == 0:
if self.current_index >= len(self.screens):
print("Done!")
sys.exit(0)
return
screen = self.screens[self.current_index]
mcrfpy.setScene(screen.scene_name)
self.render_wait = 1
elif self.render_wait < 3:
# Wait for animated demos to show initial state
self.render_wait += 1
else:
screen = self.screens[self.current_index]
filename = os.path.join(self.screenshot_dir, screen.get_screenshot_name())
automation.screenshot(filename)
print(f" [{self.current_index+1}/{len(self.screens)}] {filename}")
# Clean up timers for animated demos
screen.cleanup()
self.current_index += 1
self.render_wait = 0
if self.current_index >= len(self.screens):
print("Done!")
sys.exit(0)
mcrfpy.setTimer("screenshot", screenshot_cycle, 100)
def run_interactive(self):
"""Run in interactive mode with menu."""
self.create_menu()
def handle_key(key, state):
if state != "start":
return
# Number keys 1-9 for direct screen access
if key in [f"Num{n}" for n in "123456789"]:
idx = int(key[-1]) - 1
if idx < len(self.screens):
# Clean up ALL screen's timers first
for screen in self.screens:
screen.cleanup()
# Switch to selected scene
mcrfpy.setScene(self.screens[idx].scene_name)
# Restart timers for the selected screen
self.screens[idx].restart_timers()
# ESC returns to menu
elif key == "Escape":
for screen in self.screens:
screen.cleanup()
mcrfpy.setScene("geo_menu")
# Q quits
elif key == "Q":
sys.exit(0)
# Register keyboard handler on all scenes
mcrfpy.setScene("geo_menu")
mcrfpy.keypressScene(handle_key)
for screen in self.screens:
mcrfpy.setScene(screen.scene_name)
mcrfpy.keypressScene(handle_key)
mcrfpy.setScene("geo_menu")
def main():
"""Main entry point."""
runner = GeometryDemoRunner()
runner.setup_all_screens()
if runner.headless:
runner.run_headless()
else:
runner.run_interactive()
# Run when executed
main()

View file

@ -0,0 +1,6 @@
"""Geometry demo screens."""
from .bresenham_demo import BresenhamDemo
from .angle_lines_demo import AngleLinesDemo
from .pathfinding_static_demo import PathfindingStaticDemo
from .solar_system_demo import SolarSystemDemo
from .pathfinding_animated_demo import PathfindingAnimatedDemo

View file

@ -0,0 +1,347 @@
"""Angle calculation demonstration with Line elements."""
import mcrfpy
import math
from .base import (GeometryDemoScreen, screen_angle_between, angle_difference,
normalize_angle, distance, SCREEN_WIDTH, SCREEN_HEIGHT)
class AngleLinesDemo(GeometryDemoScreen):
"""Demonstrate angle calculations between points using Line elements."""
name = "Angle Calculations"
description = "Visualizing angles between grid positions"
def setup(self):
self.add_title("Angle Calculations & Line Elements")
self.add_description("Computing headings, deviations, and opposite angles for pathfinding")
margin = 30
frame_gap = 20
top_area = 80
bottom_margin = 30
# Calculate frame dimensions for 2x2 layout
frame_width = (SCREEN_WIDTH - 2 * margin - frame_gap) // 2
available_height = SCREEN_HEIGHT - top_area - bottom_margin - frame_gap
frame_height = available_height // 2
# Demo 1: Basic angle between two points (top-left)
self._demo_basic_angle(margin, top_area, frame_width, frame_height)
# Demo 2: Angle deviation (top-right)
self._demo_angle_deviation(margin + frame_width + frame_gap, top_area,
frame_width, frame_height)
# Demo 3: Multiple waypoints (bottom-left)
self._demo_waypoint_viability(margin, top_area + frame_height + frame_gap,
frame_width, frame_height)
# Demo 4: Orbit exit heading (bottom-right)
self._demo_orbit_exit(margin + frame_width + frame_gap, top_area + frame_height + frame_gap,
frame_width, frame_height)
def _demo_basic_angle(self, fx, fy, fw, fh):
"""Show angle from point A to point B."""
bg = mcrfpy.Frame(pos=(fx, fy), size=(fw, fh))
bg.fill_color = mcrfpy.Color(15, 15, 25)
bg.outline = 1
bg.outline_color = mcrfpy.Color(60, 60, 100)
self.ui.append(bg)
self.add_label("Basic Angle Calculation", fx + 10, fy + 5, (255, 200, 100))
# Point A (origin) - lower left area of frame
ax, ay = fx + 80, fy + fh - 80
# Point B (target) - upper right area of frame
bx, by = fx + fw - 100, fy + 100
# Calculate angle using screen coordinates
angle = screen_angle_between((ax, ay), (bx, by))
dist = distance((ax, ay), (bx, by))
# Draw the line A to B (green)
line_ab = mcrfpy.Line(
start=(ax, ay), end=(bx, by),
color=mcrfpy.Color(100, 255, 100),
thickness=3
)
self.ui.append(line_ab)
# Draw reference line (east from A) in gray
ref_length = 120
line_ref = mcrfpy.Line(
start=(ax, ay), end=(ax + ref_length, ay),
color=mcrfpy.Color(100, 100, 100),
thickness=1
)
self.ui.append(line_ref)
# Draw arc showing the angle (from reference to target line)
# Arc goes from 0 degrees (east) to the calculated angle
arc = mcrfpy.Arc(
center=(ax, ay), radius=50,
start_angle=0, end_angle=angle,
color=mcrfpy.Color(255, 255, 100),
thickness=2
)
self.ui.append(arc)
# Points
point_a = mcrfpy.Circle(center=(ax, ay), radius=8,
fill_color=mcrfpy.Color(255, 100, 100))
point_b = mcrfpy.Circle(center=(bx, by), radius=8,
fill_color=mcrfpy.Color(100, 255, 100))
self.ui.append(point_a)
self.ui.append(point_b)
# Labels
self.add_label("A", ax - 20, ay + 5, (255, 100, 100))
self.add_label("B", bx + 12, by - 5, (100, 255, 100))
self.add_label(f"Angle: {angle:.1f} deg", fx + 10, fy + fh - 45, (255, 255, 100))
self.add_label(f"Distance: {dist:.1f}", fx + 10, fy + fh - 25, (150, 150, 150))
def _demo_angle_deviation(self, fx, fy, fw, fh):
"""Show angle deviation when considering a waypoint."""
bg = mcrfpy.Frame(pos=(fx, fy), size=(fw, fh))
bg.fill_color = mcrfpy.Color(15, 15, 25)
bg.outline = 1
bg.outline_color = mcrfpy.Color(60, 60, 100)
self.ui.append(bg)
self.add_label("Waypoint Deviation", fx + 10, fy + 5, (255, 200, 100))
self.add_label("Is planet C useful from A to B?", fx + 10, fy + 25, (150, 150, 150))
# Ship at A, target at B, potential waypoint C
ax, ay = fx + 60, fy + fh - 100
bx, by = fx + fw - 60, fy + fh - 60
cx, cy = fx + fw // 2, fy + 100
# Calculate angles using screen coordinates
angle_to_target = screen_angle_between((ax, ay), (bx, by))
angle_to_waypoint = screen_angle_between((ax, ay), (cx, cy))
deviation = abs(angle_difference(angle_to_target, angle_to_waypoint))
# Draw line A to B (direct path - green)
line_ab = mcrfpy.Line(
start=(ax, ay), end=(bx, by),
color=mcrfpy.Color(100, 255, 100),
thickness=2
)
self.ui.append(line_ab)
# Draw line A to C (waypoint path)
viable = deviation <= 45
waypoint_color = (255, 255, 100) if viable else (255, 100, 100)
line_ac = mcrfpy.Line(
start=(ax, ay), end=(cx, cy),
color=mcrfpy.Color(*waypoint_color),
thickness=2
)
self.ui.append(line_ac)
# Draw arc showing the deviation angle between the two directions
# Arc should go from angle_to_target to angle_to_waypoint
start_ang = min(angle_to_target, angle_to_waypoint)
end_ang = max(angle_to_target, angle_to_waypoint)
# If the arc would be > 180, we need to go the other way
if end_ang - start_ang > 180:
start_ang, end_ang = end_ang, start_ang + 360
arc = mcrfpy.Arc(
center=(ax, ay), radius=50,
start_angle=start_ang, end_angle=end_ang,
color=mcrfpy.Color(*waypoint_color),
thickness=2
)
self.ui.append(arc)
# Points
point_a = mcrfpy.Circle(center=(ax, ay), radius=8,
fill_color=mcrfpy.Color(255, 100, 100))
point_b = mcrfpy.Circle(center=(bx, by), radius=8,
fill_color=mcrfpy.Color(100, 255, 100))
point_c = mcrfpy.Circle(center=(cx, cy), radius=12,
fill_color=mcrfpy.Color(100, 100, 200),
outline_color=mcrfpy.Color(150, 150, 255),
outline=2)
self.ui.append(point_a)
self.ui.append(point_b)
self.ui.append(point_c)
# Labels - positioned to avoid overlap
self.add_label("A (ship)", ax - 15, ay + 15, (255, 100, 100))
self.add_label("B (target)", bx - 30, by + 15, (100, 255, 100))
self.add_label("C (planet)", cx + 15, cy - 10, (150, 150, 255))
# Status at bottom
self.add_label(f"Deviation: {deviation:.1f} deg", fx + 10, fy + fh - 45, waypoint_color)
status = "VIABLE (<45 deg)" if viable else "NOT VIABLE (>45 deg)"
self.add_label(status, fx + 200, fy + fh - 45, waypoint_color)
def _demo_waypoint_viability(self, fx, fy, fw, fh):
"""Show multiple potential waypoints with viability indicators."""
bg = mcrfpy.Frame(pos=(fx, fy), size=(fw, fh))
bg.fill_color = mcrfpy.Color(15, 15, 25)
bg.outline = 1
bg.outline_color = mcrfpy.Color(60, 60, 100)
self.ui.append(bg)
self.add_label("Multiple Waypoint Analysis", fx + 10, fy + 5, (255, 200, 100))
# Ship and target positions
ax, ay = fx + 60, fy + fh - 80
bx, by = fx + fw - 80, fy + fh // 2
angle_to_target = screen_angle_between((ax, ay), (bx, by))
# Draw direct path
line_ab = mcrfpy.Line(
start=(ax, ay), end=(bx, by),
color=mcrfpy.Color(100, 255, 100, 128),
thickness=2
)
self.ui.append(line_ab)
# Potential waypoints at various positions
waypoints = [
(fx + 180, fy + 80, "W1"), # Upper area
(fx + 280, fy + fh - 60, "W2"), # Right of path
(fx + 80, fy + fh - 150, "W3"), # Left/behind
(fx + fw - 150, fy + fh // 2 - 30, "W4"), # Near target
]
threshold = 45
for wx, wy, label in waypoints:
angle_to_wp = screen_angle_between((ax, ay), (wx, wy))
deviation = abs(angle_difference(angle_to_target, angle_to_wp))
viable = deviation <= threshold
# Line to waypoint
color_tuple = (100, 255, 100) if viable else (255, 100, 100)
line = mcrfpy.Line(
start=(ax, ay), end=(wx, wy),
color=mcrfpy.Color(*color_tuple),
thickness=1
)
self.ui.append(line)
# Waypoint circle
wp_circle = mcrfpy.Circle(
center=(wx, wy), radius=15,
fill_color=mcrfpy.Color(80, 80, 120),
outline_color=mcrfpy.Color(*color_tuple),
outline=2
)
self.ui.append(wp_circle)
self.add_label(f"{label}:{deviation:.0f}", wx + 18, wy - 8, color_tuple)
# Ship and target markers
ship = mcrfpy.Circle(center=(ax, ay), radius=8,
fill_color=mcrfpy.Color(255, 200, 100))
target = mcrfpy.Circle(center=(bx, by), radius=8,
fill_color=mcrfpy.Color(100, 255, 100))
self.ui.append(ship)
self.ui.append(target)
self.add_label("Ship", ax - 10, ay + 12, (255, 200, 100))
self.add_label("Target", bx - 20, by + 12, (100, 255, 100))
self.add_label(f"Threshold: {threshold} deg", fx + 10, fy + fh - 25, (150, 150, 150))
def _demo_orbit_exit(self, fx, fy, fw, fh):
"""Show optimal orbit exit heading toward target."""
bg = mcrfpy.Frame(pos=(fx, fy), size=(fw, fh))
bg.fill_color = mcrfpy.Color(15, 15, 25)
bg.outline = 1
bg.outline_color = mcrfpy.Color(60, 60, 100)
self.ui.append(bg)
self.add_label("Orbit Exit Heading", fx + 10, fy + 5, (255, 200, 100))
self.add_label("Ship repositions FREE in orbit", fx + 10, fy + 25, (150, 150, 150))
# Planet center and orbit
px, py = fx + fw // 3, fy + fh // 2
orbit_radius = 70
surface_radius = 30
# Target position
tx, ty = fx + fw - 80, fy + 100
# Calculate optimal exit angle (toward target in screen coords)
exit_angle = screen_angle_between((px, py), (tx, ty))
exit_x = px + orbit_radius * math.cos(math.radians(exit_angle))
exit_y = py - orbit_radius * math.sin(math.radians(exit_angle)) # Negate for screen Y
# Ship's current position on orbit (arbitrary starting position)
ship_angle = exit_angle + 120 # 120 degrees away from exit
ship_x = px + orbit_radius * math.cos(math.radians(ship_angle))
ship_y = py - orbit_radius * math.sin(math.radians(ship_angle))
# Draw planet surface
planet = mcrfpy.Circle(
center=(px, py), radius=surface_radius,
fill_color=mcrfpy.Color(80, 120, 180),
outline_color=mcrfpy.Color(100, 150, 220),
outline=2
)
self.ui.append(planet)
# Draw orbit ring
orbit = mcrfpy.Circle(
center=(px, py), radius=orbit_radius,
fill_color=mcrfpy.Color(0, 0, 0, 0),
outline_color=mcrfpy.Color(50, 150, 50),
outline=2
)
self.ui.append(orbit)
# Draw arc showing orbital movement from ship to exit (FREE movement)
# Arc goes from ship_angle to exit_angle
start_ang = min(ship_angle, exit_angle)
end_ang = max(ship_angle, exit_angle)
orbit_arc = mcrfpy.Arc(
center=(px, py), radius=orbit_radius,
start_angle=start_ang, end_angle=end_ang,
color=mcrfpy.Color(255, 255, 100),
thickness=4
)
self.ui.append(orbit_arc)
# Draw ship
ship = mcrfpy.Circle(
center=(ship_x, ship_y), radius=8,
fill_color=mcrfpy.Color(255, 200, 100)
)
self.ui.append(ship)
# Draw exit point
exit_point = mcrfpy.Circle(
center=(exit_x, exit_y), radius=6,
fill_color=mcrfpy.Color(100, 255, 100)
)
self.ui.append(exit_point)
# Draw line from exit to target
exit_line = mcrfpy.Line(
start=(exit_x, exit_y), end=(tx, ty),
color=mcrfpy.Color(100, 255, 100),
thickness=2
)
self.ui.append(exit_line)
# Target
target = mcrfpy.Circle(
center=(tx, ty), radius=10,
fill_color=mcrfpy.Color(255, 100, 100)
)
self.ui.append(target)
# Labels - positioned to avoid overlap
self.add_label("Planet", px - 20, py + surface_radius + 5, (100, 150, 220))
self.add_label("Ship", ship_x - 30, ship_y - 15, (255, 200, 100))
self.add_label("Exit", exit_x + 10, exit_y - 15, (100, 255, 100))
self.add_label("Target", tx - 20, ty + 15, (255, 100, 100))
# Info at bottom
self.add_label(f"Exit angle: {exit_angle:.1f} deg", fx + 10, fy + fh - 45, (150, 150, 150))
self.add_label("Yellow = FREE orbital move", fx + 200, fy + fh - 45, (255, 255, 100))

View file

@ -0,0 +1,116 @@
"""Base class for geometry demo screens."""
import mcrfpy
import sys
import os
import math
# Add scripts path for geometry module
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', 'src', 'scripts'))
from geometry import *
# Screen resolution
SCREEN_WIDTH = 1024
SCREEN_HEIGHT = 768
def screen_angle_between(p1, p2):
"""
Calculate angle from p1 to p2 in screen coordinates.
In screen coords, Y increases downward, so we negate dy.
Returns angle in degrees where 0=right, 90=up, 180=left, 270=down.
"""
dx = p2[0] - p1[0]
dy = p1[1] - p2[1] # Negate because screen Y is inverted
angle = math.degrees(math.atan2(dy, dx))
if angle < 0:
angle += 360
return angle
class GeometryDemoScreen:
"""Base class for geometry demo screens."""
name = "Base Screen"
description = "Override this description"
def __init__(self, scene_name):
self.scene_name = scene_name
mcrfpy.createScene(scene_name)
self.ui = mcrfpy.sceneUI(scene_name)
self.timers = [] # Track timer names for cleanup
self._timer_configs = [] # Store timer configs for restart
def setup(self):
"""Override to set up the screen content."""
pass
def cleanup(self):
"""Clean up timers when leaving screen."""
for timer_name in self.timers:
try:
mcrfpy.delTimer(timer_name)
except:
pass
def restart_timers(self):
"""Re-register timers after cleanup."""
for name, callback, interval in self._timer_configs:
try:
mcrfpy.setTimer(name, callback, interval)
except Exception as e:
print(f"Timer restart failed: {e}")
def get_screenshot_name(self):
"""Return the screenshot filename for this screen."""
return f"{self.scene_name}.png"
def add_title(self, text, y=10):
"""Add a title caption centered at top."""
title = mcrfpy.Caption(text=text, pos=(SCREEN_WIDTH // 2, y))
title.fill_color = mcrfpy.Color(255, 255, 255)
title.outline = 2
title.outline_color = mcrfpy.Color(0, 0, 0)
self.ui.append(title)
return title
def add_description(self, text, y=50):
"""Add a description caption."""
desc = mcrfpy.Caption(text=text, pos=(50, y))
desc.fill_color = mcrfpy.Color(200, 200, 200)
self.ui.append(desc)
return desc
def add_label(self, text, x, y, color=(200, 200, 200)):
"""Add a label caption."""
label = mcrfpy.Caption(text=text, pos=(x, y))
label.fill_color = mcrfpy.Color(*color)
self.ui.append(label)
return label
def create_grid(self, grid_size, pos, size, cell_size=16):
"""Create a grid for visualization."""
grid = mcrfpy.Grid(
grid_size=grid_size,
pos=pos,
size=size
)
grid.fill_color = mcrfpy.Color(20, 20, 30)
self.ui.append(grid)
return grid
def color_grid_cell(self, grid, x, y, color):
"""Color a specific grid cell."""
try:
point = grid.at(x, y)
point.color = mcrfpy.Color(*color) if isinstance(color, tuple) else color
except:
pass # Out of bounds
def add_timer(self, name, callback, interval):
"""Add a timer and track it for cleanup/restart."""
if callback is None:
print(f"Warning: Timer '{name}' callback is None, skipping")
return
mcrfpy.setTimer(name, callback, interval)
self.timers.append(name)
self._timer_configs.append((name, callback, interval))

View file

@ -0,0 +1,207 @@
"""Bresenham circle algorithm demonstration on a grid."""
import mcrfpy
import math
from .base import GeometryDemoScreen, bresenham_circle, bresenham_line, filled_circle, SCREEN_WIDTH, SCREEN_HEIGHT
class BresenhamDemo(GeometryDemoScreen):
"""Demonstrate Bresenham circle and line algorithms on a grid."""
name = "Bresenham Algorithms"
description = "Grid-aligned circle and line rasterization"
def setup(self):
self.add_title("Bresenham Circle & Line Algorithms")
self.add_description("Grid-aligned geometric primitives for orbit rings and LOS calculations")
cell_size = 16
margin = 30
frame_gap = 20
# Calculate frame dimensions for 2x2 layout
# Available width: 1024 - 2*margin = 964, split into 2 with gap
frame_width = (SCREEN_WIDTH - 2 * margin - frame_gap) // 2 # ~472 each
# Available height for frames: 768 - 80 (top) - 30 (bottom margin)
top_area = 80
bottom_margin = 30
available_height = SCREEN_HEIGHT - top_area - bottom_margin - frame_gap
frame_height = available_height // 2 # ~314 each
# Top-left: Bresenham Circle
self._draw_circle_demo(margin, top_area, frame_width, frame_height, cell_size)
# Top-right: Bresenham Lines
self._draw_lines_demo(margin + frame_width + frame_gap, top_area, frame_width, frame_height, cell_size)
# Bottom-left: Filled Circle
self._draw_filled_demo(margin, top_area + frame_height + frame_gap, frame_width, frame_height, cell_size)
# Bottom-right: Planet + Orbit Ring
self._draw_combined_demo(margin + frame_width + frame_gap, top_area + frame_height + frame_gap,
frame_width, frame_height, cell_size)
def _draw_circle_demo(self, x, y, w, h, cell_size):
"""Draw Bresenham circle demonstration."""
bg = mcrfpy.Frame(pos=(x, y), size=(w, h))
bg.fill_color = mcrfpy.Color(15, 15, 25)
bg.outline = 1
bg.outline_color = mcrfpy.Color(60, 60, 100)
self.ui.append(bg)
self.add_label("Bresenham Circle (radius=8)", x + 10, y + 5, (255, 200, 100))
self.add_label("Center: (12, 9)", x + 10, y + 25, (150, 150, 150))
# Grid origin for this demo
grid_x = x + 20
grid_y = y + 50
center = (12, 9)
radius = 8
circle_cells = bresenham_circle(center, radius)
# Draw each cell
for cx, cy in circle_cells:
px = grid_x + cx * cell_size
py = grid_y + cy * cell_size
cell_rect = mcrfpy.Frame(pos=(px, py), size=(cell_size - 1, cell_size - 1))
cell_rect.fill_color = mcrfpy.Color(100, 200, 255)
cell_rect.outline = 0
self.ui.append(cell_rect)
# Draw center point
cx_px = grid_x + center[0] * cell_size
cy_px = grid_y + center[1] * cell_size
center_rect = mcrfpy.Frame(pos=(cx_px, cy_px), size=(cell_size - 1, cell_size - 1))
center_rect.fill_color = mcrfpy.Color(255, 100, 100)
self.ui.append(center_rect)
# Draw actual circle outline for comparison (centered on cells)
actual_circle = mcrfpy.Circle(
center=(grid_x + center[0] * cell_size + cell_size // 2,
grid_y + center[1] * cell_size + cell_size // 2),
radius=radius * cell_size,
fill_color=mcrfpy.Color(0, 0, 0, 0),
outline_color=mcrfpy.Color(255, 255, 100, 128),
outline=2
)
self.ui.append(actual_circle)
def _draw_lines_demo(self, x, y, w, h, cell_size):
"""Draw Bresenham lines demonstration."""
bg = mcrfpy.Frame(pos=(x, y), size=(w, h))
bg.fill_color = mcrfpy.Color(15, 15, 25)
bg.outline = 1
bg.outline_color = mcrfpy.Color(60, 60, 100)
self.ui.append(bg)
self.add_label("Bresenham Lines", x + 10, y + 5, (255, 200, 100))
grid_x = x + 20
grid_y = y + 40
# Draw multiple lines at different angles
lines_data = [
((2, 2), (17, 5), (255, 100, 100)), # Shallow
((2, 7), (17, 14), (100, 255, 100)), # Diagonal-ish
((2, 12), (10, 17), (100, 100, 255)), # Steep
]
for start, end, color in lines_data:
line_cells = bresenham_line(start, end)
for cx, cy in line_cells:
px = grid_x + cx * cell_size
py = grid_y + cy * cell_size
cell_rect = mcrfpy.Frame(pos=(px, py), size=(cell_size - 1, cell_size - 1))
cell_rect.fill_color = mcrfpy.Color(*color)
self.ui.append(cell_rect)
# Draw the actual line for comparison (through cell centers)
line = mcrfpy.Line(
start=(grid_x + start[0] * cell_size + cell_size // 2,
grid_y + start[1] * cell_size + cell_size // 2),
end=(grid_x + end[0] * cell_size + cell_size // 2,
grid_y + end[1] * cell_size + cell_size // 2),
color=mcrfpy.Color(255, 255, 255, 128),
thickness=1
)
self.ui.append(line)
def _draw_filled_demo(self, x, y, w, h, cell_size):
"""Draw filled circle demonstration."""
bg = mcrfpy.Frame(pos=(x, y), size=(w, h))
bg.fill_color = mcrfpy.Color(15, 15, 25)
bg.outline = 1
bg.outline_color = mcrfpy.Color(60, 60, 100)
self.ui.append(bg)
self.add_label("Filled Circle (radius=5)", x + 10, y + 5, (255, 200, 100))
self.add_label("Planet surface representation", x + 10, y + 25, (150, 150, 150))
grid_x = x + 50
grid_y = y + 60
fill_center = (8, 8)
fill_radius = 5
filled_cells = filled_circle(fill_center, fill_radius)
for cx, cy in filled_cells:
px = grid_x + cx * cell_size
py = grid_y + cy * cell_size
# Gradient based on distance from center
dist = math.sqrt((cx - fill_center[0])**2 + (cy - fill_center[1])**2)
intensity = int(255 * (1 - dist / (fill_radius + 1)))
cell_rect = mcrfpy.Frame(pos=(px, py), size=(cell_size - 1, cell_size - 1))
cell_rect.fill_color = mcrfpy.Color(intensity, intensity // 2, 50)
self.ui.append(cell_rect)
def _draw_combined_demo(self, x, y, w, h, cell_size):
"""Draw planet + orbit ring demonstration."""
bg = mcrfpy.Frame(pos=(x, y), size=(w, h))
bg.fill_color = mcrfpy.Color(15, 15, 25)
bg.outline = 1
bg.outline_color = mcrfpy.Color(60, 60, 100)
self.ui.append(bg)
self.add_label("Planet + Orbit Ring", x + 10, y + 5, (255, 200, 100))
self.add_label("Surface (r=3) + Orbit (r=8)", x + 10, y + 25, (150, 150, 150))
grid_x = x + 60
grid_y = y + 50
planet_center = (12, 10)
surface_radius = 3
orbit_radius = 8
# Draw orbit ring (behind planet)
orbit_cells = bresenham_circle(planet_center, orbit_radius)
for cx, cy in orbit_cells:
px = grid_x + cx * cell_size
py = grid_y + cy * cell_size
cell_rect = mcrfpy.Frame(pos=(px, py), size=(cell_size - 1, cell_size - 1))
cell_rect.fill_color = mcrfpy.Color(50, 150, 50, 180)
self.ui.append(cell_rect)
# Draw planet surface (on top)
surface_cells = filled_circle(planet_center, surface_radius)
for cx, cy in surface_cells:
px = grid_x + cx * cell_size
py = grid_y + cy * cell_size
dist = math.sqrt((cx - planet_center[0])**2 + (cy - planet_center[1])**2)
intensity = int(200 * (1 - dist / (surface_radius + 1)))
cell_rect = mcrfpy.Frame(pos=(px, py), size=(cell_size - 1, cell_size - 1))
cell_rect.fill_color = mcrfpy.Color(50 + intensity, 100 + intensity // 2, 200)
self.ui.append(cell_rect)
# Legend in bottom-left of frame
leg_x = x + 10
leg_y = y + h - 50
leg1 = mcrfpy.Frame(pos=(leg_x, leg_y), size=(12, 12))
leg1.fill_color = mcrfpy.Color(100, 150, 200)
self.ui.append(leg1)
self.add_label("Planet", leg_x + 18, leg_y - 2, (150, 150, 150))
leg2 = mcrfpy.Frame(pos=(leg_x, leg_y + 20), size=(12, 12))
leg2.fill_color = mcrfpy.Color(50, 150, 50)
self.ui.append(leg2)
self.add_label("Orbit ring", leg_x + 18, leg_y + 18, (150, 150, 150))

View file

@ -0,0 +1,373 @@
"""Animated pathfinding through a moving solar system."""
import mcrfpy
import math
from .base import (GeometryDemoScreen, OrbitalBody, create_solar_system,
create_planet, point_on_circle, distance, angle_between,
normalize_angle, is_viable_waypoint, nearest_orbit_entry,
optimal_exit_heading, screen_angle_between,
SCREEN_WIDTH, SCREEN_HEIGHT)
class PathfindingAnimatedDemo(GeometryDemoScreen):
"""Demonstrate ship navigation through moving orbital bodies."""
name = "Animated Pathfinding"
description = "Ship navigates through moving planets"
def setup(self):
self.add_title("Pathfinding Through Moving Planets")
self.add_description("Ship anticipates planetary motion to use orbital slingshots")
margin = 30
top_area = 80
bottom_panel = 60
# Screen layout - full width for 1024x768
frame_width = SCREEN_WIDTH - 2 * margin
frame_height = SCREEN_HEIGHT - top_area - bottom_panel - margin
# Center of display area
self.center_x = margin + frame_width // 2
self.center_y = top_area + frame_height // 2
self.scale = 2.5 # Larger scale for better visibility
# Background
bg = mcrfpy.Frame(pos=(margin, top_area), size=(frame_width, frame_height))
bg.fill_color = mcrfpy.Color(5, 5, 15)
bg.outline = 1
bg.outline_color = mcrfpy.Color(40, 40, 80)
self.ui.append(bg)
# Store frame boundaries
self.frame_bottom = top_area + frame_height
# Create solar system
self.star = create_solar_system(
grid_width=200, grid_height=200,
star_radius=10, star_orbit_radius=18
)
# Create a planet that the ship will use as waypoint
self.planet = create_planet(
name="Waypoint",
star=self.star,
orbital_radius=60, # Smaller orbit to not clip edges
surface_radius=8,
orbit_ring_radius=15,
angular_velocity=5, # Moves 5 degrees per turn
initial_angle=180 # Starts on left side
)
# Ship state
self.ship_speed = 8 # Grid units per turn
# Position ship further from sun to avoid line clipping through it
self.ship_pos = [-80, 60] # Start position (grid coords, relative to star) - lower left
self.ship_target = [80, -60] # Target position - upper right
self.ship_state = "approach" # approach, orbiting, exiting, traveling
self.ship_orbit_angle = 0
self.current_time = 0
# Plan the path
self.path_plan = []
self.current_path_index = 0
# Store UI elements
self.planet_circle = None
self.planet_orbit_ring = None
self.ship_circle = None
self.path_lines = []
self.status_label = None
# Draw static elements
self._draw_static()
# Draw initial state
self._draw_dynamic()
# Info panel
self._draw_info_panel()
# Start animation
self.add_timer("pathfind_tick", self._tick, 1000)
def _to_screen(self, grid_pos):
"""Convert grid position (relative to star) to screen coordinates."""
gx, gy = grid_pos
return (self.center_x + gx * self.scale, self.center_y - gy * self.scale)
def _draw_static(self):
"""Draw static elements."""
star_screen = self._to_screen((0, 0))
# Star
star = mcrfpy.Circle(
center=star_screen,
radius=self.star.surface_radius * self.scale,
fill_color=mcrfpy.Color(255, 220, 100),
outline_color=mcrfpy.Color(255, 180, 50),
outline=2
)
self.ui.append(star)
# Planet orbital path
orbit_path = mcrfpy.Circle(
center=star_screen,
radius=self.planet.orbital_radius * self.scale,
fill_color=mcrfpy.Color(0, 0, 0, 0),
outline_color=mcrfpy.Color(40, 40, 60),
outline=1
)
self.ui.append(orbit_path)
# Target marker
target_screen = self._to_screen(self.ship_target)
target = mcrfpy.Circle(
center=target_screen,
radius=12,
fill_color=mcrfpy.Color(255, 100, 100),
outline_color=mcrfpy.Color(255, 200, 200),
outline=2
)
self.ui.append(target)
self.add_label("TARGET", target_screen[0] - 25, target_screen[1] + 15, (255, 100, 100))
# Start marker
start_screen = self._to_screen(self.ship_pos)
self.add_label("START", start_screen[0] - 20, start_screen[1] + 15, (100, 255, 100))
def _draw_dynamic(self):
"""Draw/update dynamic elements (planet, ship, path)."""
# Get planet position at current time
planet_grid = self._get_planet_pos(self.current_time)
planet_screen = self._to_screen(planet_grid)
# Planet
if self.planet_circle:
self.planet_circle.center = planet_screen
else:
self.planet_circle = mcrfpy.Circle(
center=planet_screen,
radius=self.planet.surface_radius * self.scale,
fill_color=mcrfpy.Color(100, 150, 255),
outline_color=mcrfpy.Color(150, 200, 255),
outline=2
)
self.ui.append(self.planet_circle)
# Planet orbit ring
if self.planet_orbit_ring:
self.planet_orbit_ring.center = planet_screen
else:
self.planet_orbit_ring = mcrfpy.Circle(
center=planet_screen,
radius=self.planet.orbit_ring_radius * self.scale,
fill_color=mcrfpy.Color(0, 0, 0, 0),
outline_color=mcrfpy.Color(50, 150, 50),
outline=2
)
self.ui.append(self.planet_orbit_ring)
# Ship
ship_screen = self._to_screen(self.ship_pos)
if self.ship_circle:
self.ship_circle.center = ship_screen
else:
self.ship_circle = mcrfpy.Circle(
center=ship_screen,
radius=8,
fill_color=mcrfpy.Color(255, 200, 100),
outline_color=mcrfpy.Color(255, 255, 200),
outline=2
)
self.ui.append(self.ship_circle)
# Draw predicted path
self._draw_predicted_path()
def _get_planet_pos(self, t):
"""Get planet position in grid coords relative to star."""
angle = self.planet.initial_angle + self.planet.angular_velocity * t
x = self.planet.orbital_radius * math.cos(math.radians(angle))
y = self.planet.orbital_radius * math.sin(math.radians(angle))
return (x, y)
def _draw_predicted_path(self):
"""Draw the predicted ship path."""
# Clear old path lines
# (In a real implementation, we'd remove old lines from UI)
# For now, we'll just draw new ones each time
ship_pos = tuple(self.ship_pos)
target = tuple(self.ship_target)
# Simple path prediction:
# 1. Calculate when ship can intercept planet's orbit
# 2. Show line to intercept point
# 3. Show arc on orbit
# 4. Show line to target
if self.ship_state == "approach":
# Find intercept time
intercept_time, intercept_pos = self._find_intercept()
if intercept_time:
# Line from ship to intercept
ship_screen = self._to_screen(ship_pos)
intercept_screen = self._to_screen(intercept_pos)
# Draw approach line
approach_line = mcrfpy.Line(
start=ship_screen, end=intercept_screen,
color=mcrfpy.Color(100, 200, 255, 150),
thickness=2
)
self.ui.append(approach_line)
def _find_intercept(self):
"""Find when ship can intercept planet's orbit."""
# Simplified: check next 20 turns
for dt in range(1, 20):
future_t = self.current_time + dt
planet_pos = self._get_planet_pos(future_t)
# Distance ship could travel
max_dist = self.ship_speed * dt
# Distance from ship to planet's orbit ring
dist_to_planet = distance(self.ship_pos, planet_pos)
dist_to_orbit = abs(dist_to_planet - self.planet.orbit_ring_radius)
if dist_to_orbit <= max_dist:
# Calculate entry point
angle_to_planet = angle_between(self.ship_pos, planet_pos)
entry_x = planet_pos[0] + self.planet.orbit_ring_radius * math.cos(math.radians(angle_to_planet + 180))
entry_y = planet_pos[1] + self.planet.orbit_ring_radius * math.sin(math.radians(angle_to_planet + 180))
return (future_t, (entry_x, entry_y))
return (None, None)
def _draw_info_panel(self):
"""Draw information panel."""
panel_y = self.frame_bottom + 10
panel = mcrfpy.Frame(pos=(30, panel_y), size=(SCREEN_WIDTH - 60, 45))
panel.fill_color = mcrfpy.Color(20, 20, 35)
panel.outline = 1
panel.outline_color = mcrfpy.Color(60, 60, 100)
self.ui.append(panel)
# Time display
self.time_label = mcrfpy.Caption(text="Turn: 0", pos=(40, panel_y + 12))
self.time_label.fill_color = mcrfpy.Color(255, 255, 255)
self.ui.append(self.time_label)
# Status display
self.status_label = mcrfpy.Caption(text="Status: Approaching planet", pos=(180, panel_y + 12))
self.status_label.fill_color = mcrfpy.Color(100, 200, 255)
self.ui.append(self.status_label)
# Distance display
self.dist_label = mcrfpy.Caption(text="Distance to target: ---", pos=(550, panel_y + 12))
self.dist_label.fill_color = mcrfpy.Color(150, 150, 150)
self.ui.append(self.dist_label)
def _tick(self, runtime):
"""Advance one turn."""
self.current_time += 1
self.time_label.text = f"Turn: {self.current_time}"
# Update ship based on state
if self.ship_state == "approach":
self._update_approach()
elif self.ship_state == "orbiting":
self._update_orbiting()
elif self.ship_state == "exiting":
self._update_exiting()
elif self.ship_state == "traveling":
self._update_traveling()
elif self.ship_state == "arrived":
pass # Done!
# Update distance display
dist = distance(self.ship_pos, self.ship_target)
self.dist_label.text = f"Distance to target: {dist:.1f}"
# Update visuals
self._draw_dynamic()
def _update_approach(self):
"""Move ship toward planet's predicted position."""
# Find where planet will be when we can intercept
intercept_time, intercept_pos = self._find_intercept()
if intercept_pos:
# Move toward intercept point
dx = intercept_pos[0] - self.ship_pos[0]
dy = intercept_pos[1] - self.ship_pos[1]
dist = math.sqrt(dx*dx + dy*dy)
if dist <= self.ship_speed:
# Arrived at orbit - enter orbit
self.ship_pos = list(intercept_pos)
planet_pos = self._get_planet_pos(self.current_time)
self.ship_orbit_angle = angle_between(planet_pos, self.ship_pos)
self.ship_state = "orbiting"
self.status_label.text = "Status: In orbit (repositioning FREE)"
self.status_label.fill_color = mcrfpy.Color(255, 255, 100)
else:
# Move toward intercept
self.ship_pos[0] += (dx / dist) * self.ship_speed
self.ship_pos[1] += (dy / dist) * self.ship_speed
self.status_label.text = f"Status: Approaching intercept (T+{intercept_time - self.current_time})"
else:
# Can't find intercept, go direct
self.ship_state = "traveling"
def _update_orbiting(self):
"""Reposition on orbit toward optimal exit."""
planet_pos = self._get_planet_pos(self.current_time)
# Calculate optimal exit angle (toward target)
exit_angle = angle_between(planet_pos, self.ship_target)
# Move along orbit toward exit angle (this is FREE movement)
angle_diff = exit_angle - self.ship_orbit_angle
if angle_diff > 180:
angle_diff -= 360
elif angle_diff < -180:
angle_diff += 360
# Move up to 45 degrees per turn along orbit (arbitrary limit for demo)
move_angle = max(-45, min(45, angle_diff))
self.ship_orbit_angle = normalize_angle(self.ship_orbit_angle + move_angle)
# Update ship position to new orbital position
self.ship_pos[0] = planet_pos[0] + self.planet.orbit_ring_radius * math.cos(math.radians(self.ship_orbit_angle))
self.ship_pos[1] = planet_pos[1] + self.planet.orbit_ring_radius * math.sin(math.radians(self.ship_orbit_angle))
# Check if we're at optimal exit
if abs(angle_diff) < 10:
self.ship_state = "exiting"
self.status_label.text = "Status: Exiting orbit toward target"
self.status_label.fill_color = mcrfpy.Color(100, 255, 100)
def _update_exiting(self):
"""Exit orbit and head toward target."""
# Just transition to traveling
self.ship_state = "traveling"
def _update_traveling(self):
"""Travel directly toward target."""
dx = self.ship_target[0] - self.ship_pos[0]
dy = self.ship_target[1] - self.ship_pos[1]
dist = math.sqrt(dx*dx + dy*dy)
if dist <= self.ship_speed:
# Arrived!
self.ship_pos = list(self.ship_target)
self.ship_state = "arrived"
self.status_label.text = "Status: ARRIVED!"
self.status_label.fill_color = mcrfpy.Color(100, 255, 100)
else:
# Move toward target
self.ship_pos[0] += (dx / dist) * self.ship_speed
self.ship_pos[1] += (dy / dist) * self.ship_speed
self.status_label.text = f"Status: Traveling to target ({dist:.0f} units)"

View file

@ -0,0 +1,314 @@
"""Static pathfinding demonstration with planets and orbit rings."""
import mcrfpy
import math
from .base import (GeometryDemoScreen, bresenham_circle, filled_circle,
screen_angle_between, distance, SCREEN_WIDTH, SCREEN_HEIGHT)
class PathfindingStaticDemo(GeometryDemoScreen):
"""Demonstrate optimal path through a static solar system."""
name = "Static Pathfinding"
description = "Optimal path using orbital slingshots"
def setup(self):
self.add_title("Pathfinding Through Orbital Bodies")
self.add_description("Using free orbital movement to optimize travel paths")
margin = 30
top_area = 80
legend_height = 70
# Main display area - use most of screen
frame_width = SCREEN_WIDTH - 2 * margin
frame_height = SCREEN_HEIGHT - top_area - margin - legend_height
self.cell_size = 8
self.grid_x = margin + 20
self.grid_y = top_area + 20
# Background
bg = mcrfpy.Frame(pos=(margin, top_area), size=(frame_width, frame_height))
bg.fill_color = mcrfpy.Color(5, 5, 15)
bg.outline = 1
bg.outline_color = mcrfpy.Color(40, 40, 80)
self.ui.append(bg)
# Define planets (center_x, center_y, surface_radius, orbit_radius, name)
self.planets = [
(25, 50, 6, 12, "Alpha"),
(60, 25, 4, 9, "Beta"),
(85, 55, 5, 11, "Gamma"),
]
# Ship start and end
self.ship_start = (8, 65)
self.ship_end = (105, 15)
# Draw grid reference
self._draw_grid_reference()
# Draw planets
for px, py, sr, orbit_r, name in self.planets:
self._draw_planet(px, py, sr, orbit_r, name)
# Draw optimal path
self._draw_optimal_path()
# Draw ship and target
self._draw_ship_and_target()
# Legend at bottom
self._draw_legend(margin, top_area + frame_height + 10)
def _to_screen(self, gx, gy):
"""Convert grid coords to screen coords (center of cell)."""
return (self.grid_x + gx * self.cell_size + self.cell_size // 2,
self.grid_y + gy * self.cell_size + self.cell_size // 2)
def _to_screen_corner(self, gx, gy):
"""Convert grid coords to screen coords (corner of cell)."""
return (self.grid_x + gx * self.cell_size,
self.grid_y + gy * self.cell_size)
def _draw_grid_reference(self):
"""Draw faint grid lines for reference."""
max_x = 115
max_y = 75
for i in range(0, max_x + 1, 10):
x = self.grid_x + i * self.cell_size
line = mcrfpy.Line(
start=(x, self.grid_y),
end=(x, self.grid_y + max_y * self.cell_size),
color=mcrfpy.Color(30, 30, 50),
thickness=1
)
self.ui.append(line)
for i in range(0, max_y + 1, 10):
y = self.grid_y + i * self.cell_size
line = mcrfpy.Line(
start=(self.grid_x, y),
end=(self.grid_x + max_x * self.cell_size, y),
color=mcrfpy.Color(30, 30, 50),
thickness=1
)
self.ui.append(line)
def _draw_planet(self, cx, cy, surface_r, orbit_r, name):
"""Draw a planet with surface and orbit ring."""
sx, sy = self._to_screen(cx, cy)
# Orbit ring (smooth circle)
orbit = mcrfpy.Circle(
center=(sx, sy),
radius=orbit_r * self.cell_size,
fill_color=mcrfpy.Color(0, 0, 0, 0),
outline_color=mcrfpy.Color(50, 150, 50, 150),
outline=2
)
self.ui.append(orbit)
# Bresenham orbit cells
orbit_cells = bresenham_circle((cx, cy), orbit_r)
for gx, gy in orbit_cells:
px, py = self._to_screen_corner(gx, gy)
cell = mcrfpy.Frame(
pos=(px, py),
size=(self.cell_size - 1, self.cell_size - 1)
)
cell.fill_color = mcrfpy.Color(40, 100, 40, 100)
self.ui.append(cell)
# Planet surface
surface_cells = filled_circle((cx, cy), surface_r)
for gx, gy in surface_cells:
px, py = self._to_screen_corner(gx, gy)
dist = math.sqrt((gx - cx)**2 + (gy - cy)**2)
intensity = int(180 * (1 - dist / (surface_r + 1)))
cell = mcrfpy.Frame(
pos=(px, py),
size=(self.cell_size - 1, self.cell_size - 1)
)
cell.fill_color = mcrfpy.Color(60 + intensity, 80 + intensity//2, 150)
self.ui.append(cell)
# Planet label (below planet to avoid overlap)
self.add_label(name, sx - 15, sy + (surface_r + 2) * self.cell_size, (150, 150, 200))
def _draw_optimal_path(self):
"""Draw the optimal path using orbital waypoints."""
# The path:
# 1. Ship at (8, 65) -> Alpha orbit entry
# 2. Arc around Alpha -> exit toward Gamma
# 3. Straight to Gamma orbit entry
# 4. Arc around Gamma -> exit toward target
# 5. Straight to target (105, 15)
# Alpha: center (25, 50), orbit_r=12
alpha_center = (25, 50)
alpha_orbit = 12
# Gamma: center (85, 55), orbit_r=11
gamma_center = (85, 55)
gamma_orbit = 11
ship_screen = self._to_screen(*self.ship_start)
target_screen = self._to_screen(*self.ship_end)
# --- Segment 1: Ship to Alpha orbit entry ---
# Entry angle: direction from Alpha to ship
entry_angle_alpha = screen_angle_between(
self._to_screen(*alpha_center),
ship_screen
)
entry_alpha = (
alpha_center[0] + alpha_orbit * math.cos(math.radians(entry_angle_alpha)),
alpha_center[1] - alpha_orbit * math.sin(math.radians(entry_angle_alpha)) # Screen Y inverted
)
entry_alpha_screen = self._to_screen(*entry_alpha)
self._draw_path_line(ship_screen, entry_alpha_screen, (100, 200, 255))
# --- Segment 2: Arc around Alpha toward Gamma ---
exit_angle_alpha = screen_angle_between(
self._to_screen(*alpha_center),
self._to_screen(*gamma_center)
)
exit_alpha = (
alpha_center[0] + alpha_orbit * math.cos(math.radians(exit_angle_alpha)),
alpha_center[1] - alpha_orbit * math.sin(math.radians(exit_angle_alpha))
)
exit_alpha_screen = self._to_screen(*exit_alpha)
self._draw_orbit_arc(self._to_screen(*alpha_center), alpha_orbit * self.cell_size,
entry_angle_alpha, exit_angle_alpha)
# --- Segment 3: Alpha exit to Gamma entry ---
entry_angle_gamma = screen_angle_between(
self._to_screen(*gamma_center),
exit_alpha_screen
)
entry_gamma = (
gamma_center[0] + gamma_orbit * math.cos(math.radians(entry_angle_gamma)),
gamma_center[1] - gamma_orbit * math.sin(math.radians(entry_angle_gamma))
)
entry_gamma_screen = self._to_screen(*entry_gamma)
self._draw_path_line(exit_alpha_screen, entry_gamma_screen, (100, 200, 255))
# --- Segment 4: Arc around Gamma toward target ---
exit_angle_gamma = screen_angle_between(
self._to_screen(*gamma_center),
target_screen
)
exit_gamma = (
gamma_center[0] + gamma_orbit * math.cos(math.radians(exit_angle_gamma)),
gamma_center[1] - gamma_orbit * math.sin(math.radians(exit_angle_gamma))
)
exit_gamma_screen = self._to_screen(*exit_gamma)
self._draw_orbit_arc(self._to_screen(*gamma_center), gamma_orbit * self.cell_size,
entry_angle_gamma, exit_angle_gamma)
# --- Segment 5: Gamma exit to target ---
self._draw_path_line(exit_gamma_screen, target_screen, (100, 200, 255))
# Draw direct path for comparison
direct_line = mcrfpy.Line(
start=ship_screen, end=target_screen,
color=mcrfpy.Color(255, 100, 100, 80),
thickness=1
)
self.ui.append(direct_line)
def _draw_path_line(self, p1, p2, color):
"""Draw a path segment line."""
line = mcrfpy.Line(
start=p1, end=p2,
color=mcrfpy.Color(*color),
thickness=3
)
self.ui.append(line)
def _draw_orbit_arc(self, center, radius, start_angle, end_angle):
"""Draw an arc showing orbital movement (free movement)."""
# Ensure we draw the shorter arc
diff = end_angle - start_angle
if diff > 180:
start_angle, end_angle = end_angle, start_angle
elif diff < -180:
start_angle, end_angle = end_angle, start_angle
arc = mcrfpy.Arc(
center=center,
radius=radius,
start_angle=min(start_angle, end_angle),
end_angle=max(start_angle, end_angle),
color=mcrfpy.Color(255, 255, 100),
thickness=4
)
self.ui.append(arc)
def _draw_ship_and_target(self):
"""Draw ship and target markers."""
ship_screen = self._to_screen(*self.ship_start)
target_screen = self._to_screen(*self.ship_end)
# Ship
ship = mcrfpy.Circle(
center=ship_screen,
radius=10,
fill_color=mcrfpy.Color(255, 200, 100),
outline_color=mcrfpy.Color(255, 255, 200),
outline=2
)
self.ui.append(ship)
self.add_label("SHIP", ship_screen[0] - 15, ship_screen[1] + 15, (255, 200, 100))
# Target
target = mcrfpy.Circle(
center=target_screen,
radius=10,
fill_color=mcrfpy.Color(255, 100, 100),
outline_color=mcrfpy.Color(255, 200, 200),
outline=2
)
self.ui.append(target)
self.add_label("TARGET", target_screen[0] - 25, target_screen[1] + 15, (255, 100, 100))
def _draw_legend(self, x, y):
"""Draw legend."""
# Blue line = movement cost
line1 = mcrfpy.Line(
start=(x, y + 15), end=(x + 40, y + 15),
color=mcrfpy.Color(100, 200, 255),
thickness=3
)
self.ui.append(line1)
self.add_label("Impulse movement (costs energy)", x + 50, y + 8, (150, 150, 150))
# Yellow arc = free movement
arc1 = mcrfpy.Arc(
center=(x + 20, y + 50), radius=18,
start_angle=0, end_angle=180,
color=mcrfpy.Color(255, 255, 100),
thickness=3
)
self.ui.append(arc1)
self.add_label("Orbital movement (FREE)", x + 50, y + 40, (255, 255, 100))
# Red line = direct
line2 = mcrfpy.Line(
start=(x + 400, y + 15), end=(x + 440, y + 15),
color=mcrfpy.Color(255, 100, 100, 80),
thickness=1
)
self.ui.append(line2)
self.add_label("Direct path (comparison)", x + 450, y + 8, (150, 150, 150))
# Green cells = orbit ring
cell1 = mcrfpy.Frame(pos=(x + 400, y + 40), size=(15, 15))
cell1.fill_color = mcrfpy.Color(40, 100, 40)
self.ui.append(cell1)
self.add_label("Orbit ring (ship positions)", x + 420, y + 40, (150, 150, 150))

View file

@ -0,0 +1,294 @@
"""Animated solar system demonstration."""
import mcrfpy
import math
from .base import (GeometryDemoScreen, OrbitalBody, create_solar_system,
create_planet, create_moon, point_on_circle,
SCREEN_WIDTH, SCREEN_HEIGHT)
class SolarSystemDemo(GeometryDemoScreen):
"""Demonstrate animated orbital mechanics with timer-based updates."""
name = "Solar System Animation"
description = "Planets orbiting with discrete time steps"
def setup(self):
self.add_title("Animated Solar System")
self.add_description("Planets snap to grid positions as time advances (1 tick = 1 turn)")
margin = 30
top_area = 80
bottom_panel = 60
# Screen layout - centered, with room for Earth's moon orbit
frame_width = SCREEN_WIDTH - 2 * margin
frame_height = SCREEN_HEIGHT - top_area - bottom_panel - margin
# Center of display area, shifted down a bit to give room for moon orbit at top
self.center_x = margin + frame_width // 2
self.center_y = top_area + frame_height // 2 + 30 # Shifted down
self.scale = 2.0 # Pixels per grid unit (larger for 1024x768)
# Background
bg = mcrfpy.Frame(pos=(margin, top_area), size=(frame_width, frame_height))
bg.fill_color = mcrfpy.Color(5, 5, 15)
bg.outline = 1
bg.outline_color = mcrfpy.Color(40, 40, 80)
self.ui.append(bg)
# Store frame boundaries for info panel
self.frame_bottom = top_area + frame_height
# Create the solar system using geometry module
self.star = create_solar_system(
grid_width=200, grid_height=200,
star_radius=15, star_orbit_radius=25
)
# Create planets with different orbital speeds
self.planet1 = create_planet(
name="Mercury",
star=self.star,
orbital_radius=60,
surface_radius=5,
orbit_ring_radius=12,
angular_velocity=12, # Fast orbit
initial_angle=0
)
self.planet2 = create_planet(
name="Venus",
star=self.star,
orbital_radius=100,
surface_radius=8,
orbit_ring_radius=16,
angular_velocity=7, # Medium orbit
initial_angle=120
)
self.planet3 = create_planet(
name="Earth",
star=self.star,
orbital_radius=150,
surface_radius=10,
orbit_ring_radius=20,
angular_velocity=4, # Slow orbit
initial_angle=240
)
# Moon orbiting Earth
self.moon = create_moon(
name="Luna",
planet=self.planet3,
orbital_radius=30,
surface_radius=3,
orbit_ring_radius=8,
angular_velocity=15, # Faster than Earth
initial_angle=45
)
self.planets = [self.planet1, self.planet2, self.planet3]
self.moons = [self.moon]
# Current time step
self.current_time = 0
# Store UI elements for updating
self.planet_circles = {}
self.orbit_rings = {}
self.moon_circles = {}
# Draw static elements (star, orbit paths)
self._draw_static_elements()
# Draw initial planet positions
self._draw_planets()
# Info panel below the main frame
panel_y = self.frame_bottom + 10
panel = mcrfpy.Frame(pos=(30, panel_y), size=(SCREEN_WIDTH - 60, 45))
panel.fill_color = mcrfpy.Color(20, 20, 35)
panel.outline = 1
panel.outline_color = mcrfpy.Color(60, 60, 100)
self.ui.append(panel)
# Time display
self.time_label = mcrfpy.Caption(text="Turn: 0", pos=(40, panel_y + 12))
self.time_label.fill_color = mcrfpy.Color(255, 255, 255)
self.ui.append(self.time_label)
# Instructions
self.add_label("Time advances automatically every second", 200, panel_y + 12, (150, 150, 150))
# Start the animation timer
self.add_timer("solar_tick", self._tick, 1000) # 1 second per turn
def _to_screen(self, grid_pos):
"""Convert grid position to screen coordinates."""
gx, gy = grid_pos
# Center on screen, with star at center
star_pos = self.star.base_position
dx = (gx - star_pos[0]) * self.scale
dy = (gy - star_pos[1]) * self.scale
return (self.center_x + dx, self.center_y + dy)
def _draw_static_elements(self):
"""Draw elements that don't move (star, orbital paths)."""
star_screen = self._to_screen(self.star.base_position)
# Star
star_circle = mcrfpy.Circle(
center=star_screen,
radius=self.star.surface_radius * self.scale,
fill_color=mcrfpy.Color(255, 220, 100),
outline_color=mcrfpy.Color(255, 180, 50),
outline=3
)
self.ui.append(star_circle)
# Star glow effect
for i in range(3):
glow = mcrfpy.Circle(
center=star_screen,
radius=(self.star.surface_radius + 5 + i * 8) * self.scale,
fill_color=mcrfpy.Color(0, 0, 0, 0),
outline_color=mcrfpy.Color(255, 200, 50, 50 - i * 15),
outline=2
)
self.ui.append(glow)
# Orbital paths (static ellipses showing where planets travel)
for planet in self.planets:
path = mcrfpy.Circle(
center=star_screen,
radius=planet.orbital_radius * self.scale,
fill_color=mcrfpy.Color(0, 0, 0, 0),
outline_color=mcrfpy.Color(40, 40, 60),
outline=1
)
self.ui.append(path)
# Star label
self.add_label("Star", star_screen[0] - 15, star_screen[1] + self.star.surface_radius * self.scale + 5,
(255, 220, 100))
def _draw_planets(self):
"""Draw planets at their current positions."""
for planet in self.planets:
self._draw_planet(planet)
for moon in self.moons:
self._draw_moon(moon)
def _draw_planet(self, planet):
"""Draw a single planet."""
# Get grid position at current time
grid_pos = planet.grid_position_at_time(self.current_time)
screen_pos = self._to_screen(grid_pos)
# Color based on planet
colors = {
"Mercury": (180, 180, 180),
"Venus": (255, 200, 150),
"Earth": (100, 150, 255),
}
color = colors.get(planet.name, (150, 150, 150))
# Planet surface
planet_circle = mcrfpy.Circle(
center=screen_pos,
radius=planet.surface_radius * self.scale,
fill_color=mcrfpy.Color(*color),
outline_color=mcrfpy.Color(255, 255, 255, 100),
outline=1
)
self.ui.append(planet_circle)
self.planet_circles[planet.name] = planet_circle
# Orbit ring around planet
orbit_ring = mcrfpy.Circle(
center=screen_pos,
radius=planet.orbit_ring_radius * self.scale,
fill_color=mcrfpy.Color(0, 0, 0, 0),
outline_color=mcrfpy.Color(50, 150, 50, 100),
outline=1
)
self.ui.append(orbit_ring)
self.orbit_rings[planet.name] = orbit_ring
# Planet label
label = mcrfpy.Caption(
text=planet.name,
pos=(screen_pos[0] - 20, screen_pos[1] - planet.surface_radius * self.scale - 15)
)
label.fill_color = mcrfpy.Color(*color)
self.ui.append(label)
# Store label for updating
if not hasattr(self, 'planet_labels'):
self.planet_labels = {}
self.planet_labels[planet.name] = label
def _draw_moon(self, moon):
"""Draw a moon."""
grid_pos = moon.grid_position_at_time(self.current_time)
screen_pos = self._to_screen(grid_pos)
moon_circle = mcrfpy.Circle(
center=screen_pos,
radius=moon.surface_radius * self.scale,
fill_color=mcrfpy.Color(200, 200, 200),
outline_color=mcrfpy.Color(255, 255, 255, 100),
outline=1
)
self.ui.append(moon_circle)
self.moon_circles[moon.name] = moon_circle
# Moon's orbit path around Earth
parent_pos = self._to_screen(moon.parent.grid_position_at_time(self.current_time))
moon_path = mcrfpy.Circle(
center=parent_pos,
radius=moon.orbital_radius * self.scale,
fill_color=mcrfpy.Color(0, 0, 0, 0),
outline_color=mcrfpy.Color(60, 60, 80),
outline=1
)
self.ui.append(moon_path)
self.orbit_rings[moon.name + "_path"] = moon_path
def _tick(self, runtime):
"""Advance time by one turn and update planet positions."""
self.current_time += 1
# Update time display
self.time_label.text = f"Turn: {self.current_time}"
# Update planet positions
for planet in self.planets:
grid_pos = planet.grid_position_at_time(self.current_time)
screen_pos = self._to_screen(grid_pos)
# Update circle position
if planet.name in self.planet_circles:
self.planet_circles[planet.name].center = screen_pos
self.orbit_rings[planet.name].center = screen_pos
# Update label position
if hasattr(self, 'planet_labels') and planet.name in self.planet_labels:
self.planet_labels[planet.name].pos = (
screen_pos[0] - 20,
screen_pos[1] - planet.surface_radius * self.scale - 15
)
# Update moon positions
for moon in self.moons:
grid_pos = moon.grid_position_at_time(self.current_time)
screen_pos = self._to_screen(grid_pos)
if moon.name in self.moon_circles:
self.moon_circles[moon.name].center = screen_pos
# Update moon's orbital path center
parent_pos = self._to_screen(moon.parent.grid_position_at_time(self.current_time))
path_key = moon.name + "_path"
if path_key in self.orbit_rings:
self.orbit_rings[path_key].center = parent_pos

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

604
tests/unit/test_geometry.py Normal file
View file

@ -0,0 +1,604 @@
"""
Unit tests for the geometry module (Pinships orbital mechanics).
Tests cover:
- Basic utility functions (distance, angle, etc.)
- Bresenham circle/line algorithms
- OrbitalBody recursive positioning
- Pathfinding helpers
"""
import sys
import math
# Import the geometry module
sys.path.insert(0, '/home/john/Development/McRogueFace/src/scripts')
from geometry import (
# Utilities
distance, distance_squared, angle_between, normalize_angle,
angle_difference, lerp, clamp, point_on_circle, rotate_point,
# Grid algorithms
bresenham_circle, bresenham_line, filled_circle, sort_circle_cells,
# Orbital system
OrbitalBody, OrbitingShip,
# Pathfinding
nearest_orbit_entry, optimal_exit_heading, is_viable_waypoint,
project_body_positions, line_of_sight_blocked,
# Convenience
create_solar_system, create_planet, create_moon
)
EPSILON = 0.0001 # Float comparison tolerance
def approx_equal(a, b, eps=EPSILON):
"""Check if two floats are approximately equal."""
return abs(a - b) < eps
def test_distance():
"""Test distance calculations."""
assert approx_equal(distance((0, 0), (3, 4)), 5.0)
assert approx_equal(distance((0, 0), (0, 0)), 0.0)
assert approx_equal(distance((1, 1), (4, 5)), 5.0)
assert approx_equal(distance((-3, -4), (0, 0)), 5.0)
print(" distance: PASS")
def test_distance_squared():
"""Test squared distance (no sqrt)."""
assert distance_squared((0, 0), (3, 4)) == 25
assert distance_squared((0, 0), (0, 0)) == 0
print(" distance_squared: PASS")
def test_angle_between():
"""Test angle calculations."""
# East = 0 degrees
assert approx_equal(angle_between((0, 0), (1, 0)), 0.0)
# North = 90 degrees (in screen coordinates, +y is down, but atan2 treats +y as up)
assert approx_equal(angle_between((0, 0), (0, 1)), 90.0)
# West = 180 degrees
assert approx_equal(angle_between((0, 0), (-1, 0)), 180.0)
# South = 270 degrees
assert approx_equal(angle_between((0, 0), (0, -1)), 270.0)
# Diagonal
assert approx_equal(angle_between((0, 0), (1, 1)), 45.0)
print(" angle_between: PASS")
def test_normalize_angle():
"""Test angle normalization to 0-360."""
assert approx_equal(normalize_angle(0), 0.0)
assert approx_equal(normalize_angle(360), 0.0)
assert approx_equal(normalize_angle(720), 0.0)
assert approx_equal(normalize_angle(-90), 270.0)
assert approx_equal(normalize_angle(-360), 0.0)
assert approx_equal(normalize_angle(450), 90.0)
print(" normalize_angle: PASS")
def test_angle_difference():
"""Test shortest angular distance."""
assert approx_equal(angle_difference(0, 90), 90.0)
assert approx_equal(angle_difference(90, 0), -90.0)
assert approx_equal(angle_difference(350, 10), 20.0) # Wrap around
assert approx_equal(angle_difference(10, 350), -20.0)
assert approx_equal(angle_difference(0, 180), 180.0)
print(" angle_difference: PASS")
def test_lerp():
"""Test linear interpolation."""
assert approx_equal(lerp(0, 10, 0.0), 0.0)
assert approx_equal(lerp(0, 10, 1.0), 10.0)
assert approx_equal(lerp(0, 10, 0.5), 5.0)
assert approx_equal(lerp(-5, 5, 0.5), 0.0)
print(" lerp: PASS")
def test_clamp():
"""Test value clamping."""
assert clamp(5, 0, 10) == 5
assert clamp(-5, 0, 10) == 0
assert clamp(15, 0, 10) == 10
assert clamp(0, 0, 10) == 0
assert clamp(10, 0, 10) == 10
print(" clamp: PASS")
def test_point_on_circle():
"""Test point calculation on circle."""
center = (100, 100)
radius = 50
# East (0 degrees)
p = point_on_circle(center, radius, 0)
assert approx_equal(p[0], 150.0)
assert approx_equal(p[1], 100.0)
# North (90 degrees)
p = point_on_circle(center, radius, 90)
assert approx_equal(p[0], 100.0)
assert approx_equal(p[1], 150.0)
# West (180 degrees)
p = point_on_circle(center, radius, 180)
assert approx_equal(p[0], 50.0)
assert approx_equal(p[1], 100.0)
print(" point_on_circle: PASS")
def test_rotate_point():
"""Test point rotation around center."""
center = (0, 0)
point = (1, 0)
# Rotate 90 degrees
p = rotate_point(point, center, 90)
assert approx_equal(p[0], 0.0)
assert approx_equal(p[1], 1.0)
# Rotate 180 degrees
p = rotate_point(point, center, 180)
assert approx_equal(p[0], -1.0)
assert approx_equal(p[1], 0.0)
print(" rotate_point: PASS")
def test_bresenham_circle():
"""Test Bresenham circle generation."""
# Radius 0 = just the center
cells = bresenham_circle((5, 5), 0)
assert cells == [(5, 5)]
# Radius 3 should give a circle-ish shape
cells = bresenham_circle((10, 10), 3)
assert len(cells) > 0
# All cells should be roughly radius distance from center
for x, y in cells:
dist = math.sqrt((x - 10) ** 2 + (y - 10) ** 2)
assert 2.5 <= dist <= 3.5, f"Cell ({x},{y}) has distance {dist}"
# Should be symmetric
cells_set = set(cells)
for x, y in cells:
# Check all 4 quadrant reflections exist
dx, dy = x - 10, y - 10
assert (10 + dx, 10 + dy) in cells_set
assert (10 - dx, 10 + dy) in cells_set
assert (10 + dx, 10 - dy) in cells_set
assert (10 - dx, 10 - dy) in cells_set
print(" bresenham_circle: PASS")
def test_bresenham_line():
"""Test Bresenham line generation."""
# Horizontal line
cells = bresenham_line((0, 0), (5, 0))
assert cells == [(0, 0), (1, 0), (2, 0), (3, 0), (4, 0), (5, 0)]
# Vertical line
cells = bresenham_line((0, 0), (0, 3))
assert cells == [(0, 0), (0, 1), (0, 2), (0, 3)]
# Diagonal line
cells = bresenham_line((0, 0), (3, 3))
assert (0, 0) in cells
assert (3, 3) in cells
assert len(cells) == 4 # Should hit 4 cells for 45-degree line
# Start and end should be included
cells = bresenham_line((10, 20), (15, 22))
assert (10, 20) in cells
assert (15, 22) in cells
print(" bresenham_line: PASS")
def test_filled_circle():
"""Test filled circle generation."""
cells = filled_circle((5, 5), 2)
# Center should be included
assert (5, 5) in cells
# Edges should be included
assert (5, 3) in cells # top
assert (5, 7) in cells # bottom
assert (3, 5) in cells # left
assert (7, 5) in cells # right
# Corners (at distance sqrt(8) ≈ 2.83) should NOT be included for radius 2
assert (3, 3) not in cells
print(" filled_circle: PASS")
def test_orbital_body_stationary():
"""Test stationary body (star) positioning."""
star = OrbitalBody(
name="Star",
surface_radius=10,
orbit_ring_radius=15,
parent=None,
base_position=(500, 500)
)
# Position should never change
assert star.grid_position_at_time(0) == (500, 500)
assert star.grid_position_at_time(100) == (500, 500)
assert star.grid_position_at_time(9999) == (500, 500)
# Continuous position should match
assert star.center_at_time(0) == (500.0, 500.0)
print(" orbital_body_stationary: PASS")
def test_orbital_body_simple_orbit():
"""Test planet orbiting a star."""
star = OrbitalBody(
name="Star",
surface_radius=10,
orbit_ring_radius=15,
parent=None,
base_position=(500, 500)
)
planet = OrbitalBody(
name="Planet",
surface_radius=5,
orbit_ring_radius=10,
parent=star,
orbital_radius=100, # 100 units from star
angular_velocity=90, # 90 degrees per turn (quarter orbit)
initial_angle=0 # Start to the east
)
# t=0: Planet should be east of star
pos0 = planet.center_at_time(0)
assert approx_equal(pos0[0], 600.0) # 500 + 100
assert approx_equal(pos0[1], 500.0)
# t=1: Planet should be north of star (rotated 90 degrees)
pos1 = planet.center_at_time(1)
assert approx_equal(pos1[0], 500.0)
assert approx_equal(pos1[1], 600.0) # 500 + 100
# t=2: Planet should be west of star
pos2 = planet.center_at_time(2)
assert approx_equal(pos2[0], 400.0) # 500 - 100
assert approx_equal(pos2[1], 500.0)
# t=4: Back to start (full orbit)
pos4 = planet.center_at_time(4)
assert approx_equal(pos4[0], 600.0)
assert approx_equal(pos4[1], 500.0)
print(" orbital_body_simple_orbit: PASS")
def test_orbital_body_nested_orbit():
"""Test moon orbiting a planet orbiting a star."""
star = OrbitalBody(
name="Star",
surface_radius=10,
orbit_ring_radius=15,
parent=None,
base_position=(500, 500)
)
planet = OrbitalBody(
name="Planet",
surface_radius=5,
orbit_ring_radius=10,
parent=star,
orbital_radius=100,
angular_velocity=90, # Quarter orbit per turn
initial_angle=0
)
moon = OrbitalBody(
name="Moon",
surface_radius=2,
orbit_ring_radius=5,
parent=planet,
orbital_radius=20, # 20 units from planet
angular_velocity=180, # Half orbit per turn (faster than planet)
initial_angle=0
)
# t=0: Moon should be east of planet, which is east of star
moon_pos0 = moon.center_at_time(0)
# Planet at (600, 500), moon 20 units east = (620, 500)
assert approx_equal(moon_pos0[0], 620.0)
assert approx_equal(moon_pos0[1], 500.0)
# t=1: Planet moved north (500, 600), moon rotated 180 degrees (west of planet)
moon_pos1 = moon.center_at_time(1)
# Planet at (500, 600), moon 20 units west = (480, 600)
assert approx_equal(moon_pos1[0], 480.0)
assert approx_equal(moon_pos1[1], 600.0)
print(" orbital_body_nested_orbit: PASS")
def test_orbiting_ship():
"""Test ship orbiting a body."""
star = OrbitalBody(
name="Star",
surface_radius=10,
orbit_ring_radius=50,
parent=None,
base_position=(500, 500)
)
ship = OrbitingShip(body=star, orbital_angle=0)
# Ship at angle 0 should be east of star
pos = ship.grid_position_at_time(0)
assert pos == (550, 500) # 500 + 50
# Move ship along orbit
ship.move_along_orbit(90)
pos = ship.grid_position_at_time(0)
assert pos == (500, 550) # North of star
# Set specific angle
ship.set_orbit_angle(180)
pos = ship.grid_position_at_time(0)
assert pos == (450, 500) # West of star
print(" orbiting_ship: PASS")
def test_orbit_ring_cells():
"""Test orbit ring cell generation."""
body = OrbitalBody(
name="Planet",
surface_radius=5,
orbit_ring_radius=10,
parent=None,
base_position=(100, 100)
)
cells = body.orbit_ring_cells(0)
# Should have cells on the ring
assert len(cells) > 0
# All cells should be approximately orbit_ring_radius from center
for x, y in cells:
dist = math.sqrt((x - 100) ** 2 + (y - 100) ** 2)
assert 9.0 <= dist <= 11.0, f"Cell ({x},{y}) has distance {dist}"
print(" orbit_ring_cells: PASS")
def test_surface_cells():
"""Test surface cell generation."""
body = OrbitalBody(
name="Planet",
surface_radius=3,
orbit_ring_radius=10,
parent=None,
base_position=(50, 50)
)
cells = body.surface_cells(0)
# Center should be included
assert (50, 50) in cells
# All cells should be within surface_radius
for x, y in cells:
dist = math.sqrt((x - 50) ** 2 + (y - 50) ** 2)
assert dist <= 3.5, f"Cell ({x},{y}) has distance {dist}"
print(" surface_cells: PASS")
def test_nearest_orbit_entry():
"""Test finding nearest orbit entry point."""
body = OrbitalBody(
name="Planet",
surface_radius=5,
orbit_ring_radius=20,
parent=None,
base_position=(100, 100)
)
# Ship approaching from east
ship_pos = (150, 100)
entry_pos, angle = nearest_orbit_entry(ship_pos, body, 0)
# Entry should be on the east side of orbit ring
assert approx_equal(angle, 0.0)
assert entry_pos == (120, 100) # 100 + 20
# Ship approaching from north-east
ship_pos = (150, 150)
entry_pos, angle = nearest_orbit_entry(ship_pos, body, 0)
assert approx_equal(angle, 45.0)
print(" nearest_orbit_entry: PASS")
def test_optimal_exit_heading():
"""Test finding optimal orbit exit toward target."""
body = OrbitalBody(
name="Planet",
surface_radius=5,
orbit_ring_radius=20,
parent=None,
base_position=(100, 100)
)
# Target to the west
target = (0, 100)
exit_angle, exit_pos = optimal_exit_heading(body, target, 0)
assert approx_equal(exit_angle, 180.0)
assert exit_pos == (80, 100) # 100 - 20
print(" optimal_exit_heading: PASS")
def test_is_viable_waypoint():
"""Test waypoint viability check."""
body = OrbitalBody(
name="Planet",
surface_radius=5,
orbit_ring_radius=10,
parent=None,
base_position=(100, 100)
)
ship_pos = (50, 100) # West of body
target_east = (200, 100) # Far east
target_west = (0, 100) # Far west
# Body is between ship and eastern target - viable
assert is_viable_waypoint(ship_pos, body, target_east, 0, angle_threshold=90)
# Body is NOT between ship and western target - not viable
assert not is_viable_waypoint(ship_pos, body, target_west, 0, angle_threshold=45)
print(" is_viable_waypoint: PASS")
def test_line_of_sight_blocked():
"""Test line of sight blocking by bodies."""
blocker = OrbitalBody(
name="Planet",
surface_radius=10,
orbit_ring_radius=20,
parent=None,
base_position=(100, 100)
)
# LOS through the planet should be blocked
p1 = (50, 100)
p2 = (150, 100)
result = line_of_sight_blocked(p1, p2, [blocker], 0)
assert result == blocker
# LOS around the planet should be clear
p1 = (50, 50)
p2 = (150, 50)
result = line_of_sight_blocked(p1, p2, [blocker], 0)
assert result is None
print(" line_of_sight_blocked: PASS")
def test_convenience_functions():
"""Test solar system creation helpers."""
star = create_solar_system(1000, 1000, star_radius=15, star_orbit_radius=25)
assert star.name == "Star"
assert star.base_position == (500, 500)
assert star.surface_radius == 15
assert star.orbit_ring_radius == 25
assert star.parent is None
planet = create_planet(
name="Terra",
star=star,
orbital_radius=200,
surface_radius=10,
orbit_ring_radius=20,
angular_velocity=10,
initial_angle=45
)
assert planet.name == "Terra"
assert planet.parent == star
assert planet.orbital_radius == 200
moon = create_moon(
name="Luna",
planet=planet,
orbital_radius=30,
surface_radius=3,
orbit_ring_radius=8,
angular_velocity=30
)
assert moon.name == "Luna"
assert moon.parent == planet
print(" convenience_functions: PASS")
def test_discrete_movement():
"""Test that grid positions change at discrete thresholds."""
star = OrbitalBody(
name="Star",
surface_radius=10,
orbit_ring_radius=15,
parent=None,
base_position=(500, 500)
)
# Planet with moderate angular velocity
planet = OrbitalBody(
name="Planet",
surface_radius=5,
orbit_ring_radius=10,
parent=star,
orbital_radius=100,
angular_velocity=1.0, # 1 degree per turn
initial_angle=0
)
# Positions should be deterministic
pos0 = planet.grid_position_at_time(0)
pos10 = planet.grid_position_at_time(10)
pos10_again = planet.grid_position_at_time(10)
# Same time = same position (deterministic)
assert pos10 == pos10_again
# Position should change over time
assert pos0 != pos10
# Full orbit (360 degrees / 1 deg per turn = 360 turns) should return to start
pos360 = planet.grid_position_at_time(360)
assert pos0 == pos360
# Check the turns_until_position_changes function
turns = planet.turns_until_position_changes(0)
assert turns >= 1 # Should eventually change
# Verify it actually changes at that turn
pos_before = planet.grid_position_at_time(0)
pos_after = planet.grid_position_at_time(turns)
assert pos_before != pos_after
print(" discrete_movement: PASS")
def run_all_tests():
"""Run all geometry tests."""
print("Running geometry module tests...\n")
print("Utility functions:")
test_distance()
test_distance_squared()
test_angle_between()
test_normalize_angle()
test_angle_difference()
test_lerp()
test_clamp()
test_point_on_circle()
test_rotate_point()
print("\nGrid algorithms:")
test_bresenham_circle()
test_bresenham_line()
test_filled_circle()
print("\nOrbital body system:")
test_orbital_body_stationary()
test_orbital_body_simple_orbit()
test_orbital_body_nested_orbit()
test_orbiting_ship()
test_orbit_ring_cells()
test_surface_cells()
test_discrete_movement()
print("\nPathfinding helpers:")
test_nearest_orbit_entry()
test_optimal_exit_heading()
test_is_viable_waypoint()
test_line_of_sight_blocked()
print("\nConvenience functions:")
test_convenience_functions()
print("\n" + "=" * 50)
print("All geometry tests PASSED!")
print("=" * 50)
if __name__ == "__main__":
run_all_tests()