Multi-Layer Tiles

Stack base tiles, decorations, and effects for rich visual environments.


Overview

Grid layers allow stacking multiple visual elements:

  • Base tiles: Floor, walls, terrain
  • Decoration layer: Furniture, debris, blood stains
  • Effect layer: Fog, fire, magic effects
  • UI layer: Selection cursors, path indicators

Layers come in two flavors: mcrfpy.TileLayer (sprite indices, needs a texture) and mcrfpy.ColorLayer (RGBA colors). Construct a layer object first, then attach it with grid.add_layer(layer)add_layer() never takes a string.


Quick Start

import mcrfpy

scene = mcrfpy.Scene("layers")
mcrfpy.current_scene = scene

grid = mcrfpy.Grid(
    grid_size=(16, 12),
    texture=mcrfpy.default_texture,
    pos=(0, 0),
    size=(1024, 768),
    zoom=4.0
)
scene.children.append(grid)

TILE_FLOOR = 48
TILE_RUG = 65

# Add layers with different z-indices
base_layer = mcrfpy.TileLayer(z_index=0, name="base", texture=mcrfpy.default_texture)     # Base terrain
decor_layer = mcrfpy.TileLayer(z_index=1, name="decor", texture=mcrfpy.default_texture)   # Decorations
effect_layer = mcrfpy.ColorLayer(z_index=2, name="effects")                               # Color effects
ui_layer = mcrfpy.ColorLayer(z_index=3, name="ui")                                        # UI highlights

for layer in (base_layer, decor_layer, effect_layer, ui_layer):
    grid.add_layer(layer)

# Set tiles on each layer
base_layer.set((5, 5), TILE_FLOOR)
decor_layer.set((5, 5), TILE_RUG)  # Rug on top of floor

Layer Types

Tile Layers

Display sprite tiles from the grid’s texture:

# Create tile layer
decor = mcrfpy.TileLayer(z_index=1, name="decor", texture=mcrfpy.default_texture)
grid.add_layer(decor)

# Set decoration tiles
decor.set((10, 10), TILE_TABLE)
decor.set((11, 10), TILE_CHAIR)
decor.set((10, 15), TILE_CHEST)

# Clear a tile (make transparent) - TileLayer has no clear(); set index -1 instead
decor.set((10, 10), -1)

Color Layers

Apply color overlays to cells:

# Create color layer
effects = mcrfpy.ColorLayer(z_index=2, name="effects")
grid.add_layer(effects)

# Set color at position - pos is always a single (x, y) tuple, not two args
effects.set((5, 5), mcrfpy.Color(255, 0, 0, 128))  # Red overlay

# Clear color at one cell
effects.set((5, 5), mcrfpy.Color(0, 0, 0, 0))  # Transparent

# Clear entire layer - ColorLayer has no clear(); fill() with a transparent color instead
effects.fill(mcrfpy.Color(0, 0, 0, 0))

Decoration System

Manage static decorations efficiently:

class DecorationManager:
    """Manage decorative tile overlays."""

    # Decoration categories with sprite indices
    DECORATIONS = {
        'furniture': {
            'table': 100,
            'chair': 101,
            'bed': 102,
            'chest': 103,
            'bookshelf': 104,
        },
        'debris': {
            'bones': 110,
            'rubble': 111,
            'cobweb': 112,
        },
        'nature': {
            'grass_tuft': 120,
            'mushroom': 121,
            'flower': 122,
            'moss': 123,
        },
        'stains': {
            'blood': 130,
            'water': 131,
            'acid': 132,
        }
    }

    def __init__(self, grid):
        self.grid = grid
        self.layer = grid.add_layer(
            mcrfpy.TileLayer(z_index=1, name="decor", texture=mcrfpy.default_texture)
        )
        self.placed = {}  # (x, y) -> decoration_name

    def place(self, x, y, category, name):
        """Place a decoration."""
        if category in self.DECORATIONS:
            if name in self.DECORATIONS[category]:
                sprite_idx = self.DECORATIONS[category][name]
                self.layer.set((x, y), sprite_idx)
                self.placed[(x, y)] = (category, name)

    def remove(self, x, y):
        """Remove a decoration."""
        self.layer.set((x, y), -1)
        if (x, y) in self.placed:
            del self.placed[(x, y)]

    def get(self, x, y):
        """Get decoration at position."""
        return self.placed.get((x, y))

    def scatter(self, category, count, walkable_only=True):
        """Randomly scatter decorations."""
        import random

        if category not in self.DECORATIONS:
            return

        names = list(self.DECORATIONS[category].keys())
        grid_w, grid_h = self.grid.grid_size
        grid_w, grid_h = int(grid_w), int(grid_h)  # grid_size components are floats

        placed = 0
        attempts = 0
        max_attempts = count * 10

        while placed < count and attempts < max_attempts:
            attempts += 1

            x = random.randint(0, grid_w - 1)
            y = random.randint(0, grid_h - 1)

            # Check if position is valid
            point = self.grid.at(x, y)
            if walkable_only and not point.walkable:
                continue
            if (x, y) in self.placed:
                continue

            name = random.choice(names)
            self.place(x, y, category, name)
            placed += 1


# Usage
decor = DecorationManager(grid)

# Place specific decorations
decor.place(10, 10, 'furniture', 'table')
decor.place(11, 10, 'furniture', 'chair')

# Scatter random debris
decor.scatter('debris', 20)
decor.scatter('nature', 15)

Effect Layer System

Dynamic visual effects:

class EffectLayer:
    """Manage visual effects with color overlays."""

    def __init__(self, grid, z_index=2):
        self.grid = grid
        self.layer = grid.add_layer(mcrfpy.ColorLayer(z_index=z_index, name="effects"))
        self.effects = {}  # (x, y) -> effect_data

    def add_effect(self, x, y, effect_type, duration=None, **kwargs):
        """Add a visual effect."""
        self.effects[(x, y)] = {
            'type': effect_type,
            'duration': duration,
            'time': 0,
            **kwargs
        }

    def remove_effect(self, x, y):
        """Remove an effect."""
        if (x, y) in self.effects:
            del self.effects[(x, y)]
            self.layer.set((x, y), mcrfpy.Color(0, 0, 0, 0))

    def update(self, dt):
        """Update all effects."""
        import math

        to_remove = []

        for (x, y), effect in self.effects.items():
            effect['time'] += dt

            # Check expiration
            if effect['duration'] and effect['time'] >= effect['duration']:
                to_remove.append((x, y))
                continue

            # Calculate color based on effect type
            color = self._calculate_color(effect)
            self.layer.set((x, y), color)

        for pos in to_remove:
            self.remove_effect(*pos)

    def _calculate_color(self, effect):
        """Get color for an effect at current time."""
        import math

        t = effect['time']
        effect_type = effect['type']

        if effect_type == 'fire':
            # Flickering orange/red
            flicker = 0.7 + 0.3 * math.sin(t * 10)
            return mcrfpy.Color(
                255,
                int(100 + 50 * math.sin(t * 8)),
                0,
                int(180 * flicker)
            )

        elif effect_type == 'poison':
            # Pulsing green
            pulse = 0.5 + 0.5 * math.sin(t * 3)
            return mcrfpy.Color(0, 200, 0, int(100 * pulse))

        elif effect_type == 'ice':
            # Static blue with shimmer
            shimmer = 0.8 + 0.2 * math.sin(t * 5)
            return mcrfpy.Color(100, 150, 255, int(120 * shimmer))

        elif effect_type == 'blood':
            # Fading red
            duration = effect.get('duration', 5)
            fade = 1 - (t / duration) if duration else 1
            return mcrfpy.Color(150, 0, 0, int(150 * fade))

        elif effect_type == 'highlight':
            # Pulsing highlight
            pulse = 0.5 + 0.5 * math.sin(t * 4)
            base = effect.get('color', mcrfpy.Color(255, 255, 0, 100))
            return mcrfpy.Color(base.r, base.g, base.b, int(base.a * pulse))

        return mcrfpy.Color(128, 128, 128, 50)


# Usage
effects = EffectLayer(grid)

# Add fire effect (permanent)
effects.add_effect(5, 5, 'fire')

# Add blood stain (fades over 10 seconds)
effects.add_effect(10, 10, 'blood', duration=10)

# Add poison cloud
for x in range(8, 12):
    for y in range(8, 12):
        effects.add_effect(x, y, 'poison', duration=5)

# Update in game loop
def game_update(timer, runtime):
    effects.update(0.016)  # 60 FPS

mcrfpy.Timer("effects", game_update, 16)

Complete Layer Stack

Combine all layers:

class LayeredGrid:
    """Complete multi-layer grid system."""

    def __init__(self, grid):
        self.grid = grid

        # Layer stack (bottom to top)
        self.fog = mcrfpy.ColorLayer(z_index=-1, name="fog")   # Fog of war
        grid.add_layer(self.fog)
        # z_index=0 is the base grid tiles
        self.decor_manager = DecorationManager(grid)           # Decorations (z_index=1)
        self.effect_manager = EffectLayer(grid, z_index=2)     # Effects
        self.highlight = mcrfpy.ColorLayer(z_index=3, name="highlight")  # UI highlights
        grid.add_layer(self.highlight)

    def update(self, dt):
        """Update all animated layers."""
        self.effect_manager.update(dt)

    def set_fog(self, entity):
        """Apply fog of war from entity perspective."""
        self.fog.apply_perspective(
            entity,
            visible=mcrfpy.Color(0, 0, 0, 0),
            discovered=mcrfpy.Color(20, 20, 40, 160),
            unknown=mcrfpy.Color(0, 0, 0, 255)
        )
        # apply_perspective() only binds the layer; repaint it once now, and
        # call update_perspective() again whenever the entity moves.
        self.fog.update_perspective()

    def highlight_cells(self, cells, color):
        """Highlight cells for targeting/movement."""
        self.highlight.fill(mcrfpy.Color(0, 0, 0, 0))
        for x, y in cells:
            self.highlight.set((x, y), color)

    def clear_highlights(self):
        """Clear all highlights."""
        self.highlight.fill(mcrfpy.Color(0, 0, 0, 0))


# Usage
layers = LayeredGrid(grid)

# Setup fog of war
layers.set_fog(player)

# Add decorations
layers.decor_manager.place(10, 10, 'furniture', 'chest')

# Add effect
layers.effect_manager.add_effect(15, 15, 'fire')

# Highlight movement range
movement_cells = get_movement_range(player.cell_pos, 5)
layers.highlight_cells(movement_cells, mcrfpy.Color(50, 100, 255, 80))

# Game loop
def update(timer, runtime):
    layers.update(0.016)

mcrfpy.Timer("layers", update, 16)

Performance Considerations

class OptimizedLayers:
    """Performance-optimized layer management."""

    def __init__(self, grid):
        self.grid = grid
        self.dirty_effects = set()  # Only update changed cells
        self.batch_updates = []

    def mark_dirty(self, x, y):
        """Mark a cell as needing update."""
        self.dirty_effects.add((x, y))

    def batch_set(self, layer, cells_and_values):
        """Queue batch updates."""
        self.batch_updates.append((layer, cells_and_values))

    def flush(self):
        """Apply all queued updates."""
        for layer, updates in self.batch_updates:
            for x, y, value in updates:
                layer.set((x, y), value)
        self.batch_updates = []

    def update_dirty_only(self, effect_layer, effect_calculator):
        """Only update cells marked dirty."""
        for x, y in self.dirty_effects:
            color = effect_calculator(x, y)
            effect_layer.set((x, y), color)
        self.dirty_effects.clear()

Tips

  1. Z-index order: Lower numbers render first (behind higher numbers)
  2. Alpha blending: Layers blend - use alpha for transparency
  3. Performance: Minimize layer count; combine when possible
  4. Clear strategically: neither layer type has a clear() method — fill() (whole layer) is faster than setting each cell transparent/-1 individually
  5. Fog on bottom: Use negative z-index for fog of war

Layer Z-Index Reference

Z-Index Layer Purpose
-1 Fog Fog of war overlay
0 Base Base tiles (floors, walls)
1 Decor Static decorations
2 Effects Animated effects
3 UI Selection, targeting