draft lessons
This commit is contained in:
parent
665689c550
commit
4144cdf067
|
@ -0,0 +1,253 @@
|
||||||
|
# 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!
|
|
@ -0,0 +1,457 @@
|
||||||
|
# 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!
|
|
@ -0,0 +1,562 @@
|
||||||
|
# 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!
|
|
@ -0,0 +1,548 @@
|
||||||
|
# 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!
|
|
@ -0,0 +1,520 @@
|
||||||
|
# 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.
|
|
@ -0,0 +1,570 @@
|
||||||
|
# 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!
|
|
@ -0,0 +1,743 @@
|
||||||
|
# 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!")
|
Loading…
Reference in New Issue