Scenes, Audio, e Tilemaps

Curso de Desenvolvimento de Jogos com Pygame

Felipe A. Moreno-Vera

🗺️ Tilemaps & Scrolling

O que é um Tilemap?

Um tilemap é uma grade 2D onde cada célula contém um número inteiro pequeno (tile ID) que é associado a uma imagem de tile. É assim que quase todo jogo 2D armazena seus níveis — desde o Super Mario Bros até os jogos indie modernos.

O que é um Tilemap?

Um tilemap é uma grade 2D onde cada célula contém um número inteiro pequeno (tile ID) que é associado a uma imagem de tile. É assim que quase todo jogo 2D armazena seus níveis — desde o Super Mario Bros até os jogos indie modernos.

Level data (2D list of ints):     Rendered result:
0 = air   1 = grass   2 = stone

 0  0  0  0  0  0  0  0            ░░░░░░░░
 0  0  0  1  1  0  0  0            ░░░🌿🌿░░░
 0  0  0  0  0  0  0  0            ░░░░░░░░
 1  1  1  2  2  1  1  1            🌿🌿🌿🪨🪨🌿🌿🌿
 2  2  2  2  2  2  2  2            🪨🪨🪨🪨🪨🪨🪨🪨

O que é um Tilemap?

Um tilemap é uma grade 2D onde cada célula contém um número inteiro pequeno (tile ID) que é associado a uma imagem de tile. É assim que quase todo jogo 2D armazena seus níveis — desde o Super Mario Bros até os jogos indie modernos.

Level data (2D list of ints):     Rendered result:
0 = air   1 = grass   2 = stone

 0  0  0  0  0  0  0  0            ░░░░░░░░
 0  0  0  1  1  0  0  0            ░░░🌿🌿░░░
 0  0  0  0  0  0  0  0            ░░░░░░░░
 1  1  1  2  2  1  1  1            🌿🌿🌿🪨🪨🌿🌿🌿
 2  2  2  2  2  2  2  2            🪨🪨🪨🪨🪨🪨🪨🪨

Benefícios dos tilemaps: - Os níveis são compactos — um mapa de 100×50 são apenas 5.000 inteiros - Fáceis de serializar (salvar/carregar níveis como arquivos de texto) - Renderização eficiente — desenha apenas os tiles visíveis (culling) - Assets reutilizáveis — 10 imagens de tile podem construir níveis infinitos

Classe TileMap

class TileMap:
    def __init__(self, level_data, tile_size):
        self.data      = level_data
        self.tile_size = tile_size
        self.rows      = len(data)
        self.cols      = len(data[0])

    def get_tile(self, col, row):
        if 0 <= row < self.rows and 0 <= col < self.cols:
            return self.data[row][col]
        return -1   # Out of bounds

    def set_tile(self, col, row, tile_id):
        if 0 <= row < self.rows and 0 <= col < self.cols:
            self.data[row][col] = tile_id

    def world_to_tile(self, world_x, world_y):
        return int(world_x // self.tile_size), int(world_y // self.tile_size)

    def draw(self, surface, camera_x, camera_y):
        ts = self.tile_size

        # CULLING: only draw tiles visible in the viewport
        col_start = max(0, int(camera_x // ts))
        col_end   = min(self.cols, int((camera_x + SCREEN_W) // ts) + 1)
        row_start = max(0, int(camera_y // ts))
        row_end   = min(self.rows, int((camera_y + SCREEN_H) // ts) + 1)

        for row in range(row_start, row_end):
            for col in range(col_start, col_end):
                tile_id  = self.data[row][col]
                if tile_id == 0: continue
                screen_x = col * ts - camera_x
                screen_y = row * ts - camera_y
                surface.blit(tile_images[tile_id], (screen_x, screen_y))
Culling savings — tiles ignorados vs desenhados por frame
============================================================
  Tamanho do mapa  Total    Visível    Ignorados  Economizado
  --------------------------------------------------------
  Pequeno (20×15)  300      300        0          0%
  Médio (50×30)    1500     408        1092       73%
  Grande (100×50)  5000     408        4592       92%
  Enorme (200×80)  16000    408        15592      97%

A Camera

A Camera representa o viewport para o mundo. Tudo é desenhado em world space e convertido para screen space para o rendering.

class Camera:
    def __init__(self, view_w, view_h):
        self.x    = 0.0   # Camera top-left in world space
        self.y    = 0.0
        self.zoom = 1.0

    def world_to_screen(self, wx, wy):
        """What screen pixel does this world coordinate map to?"""
        sx = (wx - self.x) * self.zoom
        sy = (wy - self.y) * self.zoom
        return int(sx), int(sy)

    def screen_to_world(self, sx, sy):
        """What world coordinate is under this screen pixel?"""
        wx = sx / self.zoom + self.x
        wy = sy / self.zoom + self.y
        return wx, wy

    def follow(self, target_x, target_y, dt, world_w, world_h):
        """Smooth follow with lerp."""
        desired_x = target_x - self.view_w / (2 * self.zoom)
        desired_y = target_y - self.view_h / (2 * self.zoom)
        self.x += (desired_x - self.x) * 6.0 * dt  # Lerp
        # Clamp to world bounds
        self.x = max(0, min(self.x, world_w - self.view_w / self.zoom))
        self.y = max(0, min(self.y, world_h - self.view_h / self.zoom))

Camera Dead Zone

Uma dead zone é uma região ao redor do centro da tela onde a Camera não se move. O jogador pode se mover livremente dentro da dead zone; a Camera só se move quando o jogador atinge sua borda.

Screen:
┌──────────────────────────────────┐
│                                  │
│      ┌──────────────┐            │
│      │  DEAD ZONE   │            │
│      │   Player ●   │ ← camera   │
│      │  moves here  │   doesn't  │
│      │  freely      │   move     │
│      └──────────────┘            │
│                                  │
└──────────────────────────────────┘

Camera Shake

def shake(self, strength=8.0, duration=0.4):
    self._shake_strength = strength
    self._shake_timer    = duration

def update_shake(self, dt):
    if self._shake_timer > 0:
        self._shake_timer -= dt
        pct = self._shake_timer / 0.4
        self._shake_offset = (
            random.uniform(-1, 1) * self._shake_strength * pct,
            random.uniform(-1, 1) * self._shake_strength * pct,
        )
    else:
        self._shake_offset = (0, 0)

# Apply offset in world_to_screen():
sx = (wx - self.x) * self.zoom + self._shake_offset[0]

Parallax Scrolling

Parallax faz o mundo parecer profundo ao fazer scroll das diferentes camadas de fundo em velocidades diferentes.

Layer           Speed (% of camera)   Perceived depth
─────           ───────────────────   ───────────────
Stars              5%                   Very far away
Distant mountains  15%                  Far
Near mountains     25%                  Medium
Clouds            30–50%                Near background
class ParallaxLayer:
    """
    factor = 0.0 → stationary (sky)
    factor = 1.0 → moves with the world (foreground)
    """
    def __init__(self, factor):
        self.factor = factor

    def draw(self, surface, camera_x):
        offset_x = int(camera_x * self.factor)
        for element in self.elements:
            screen_x = element.x - offset_x
            # Seamless horizontal wrap
            screen_x = screen_x % (WORLD_W + SCREEN_W) - SCREEN_W
            # Draw at screen_x...

Parallax Scrolling

Camera se deslocou 500px para a direita

  Camada                 Fator    Deslocamento Ilusão
  --------------------------------------------------------
  Estrelas (longe)       0.05     25           Estático
  Montanhas distantes    0.15     75           Muito longe
  Montanhas próximas     0.25     125          Longe
  Nuvens distantes       0.30     150          Longe
  Nuvens próximas        0.50     250          Médio
  Árvores de fundo       0.70     350          Igual ao mundo
  Objetos do mundo       1.00     500          Igual ao mundo

Tilemaps & Camera — Resumo

  • Tilemap = lista 2D de ints → compacto, serializável, eficiente
  • Culling: desenha apenas os tiles dentro do viewport — crítico para mapas grandes
  • A Camera converte coordenadas world ↔︎ screen para cada objeto
  • Smooth follow: lerp em direção à posição desejada da Camera a cada frame
  • Dead zone: o jogador se move livremente em uma região central antes que a Camera o siga
  • Parallax: as camadas de fundo fazem scroll de acordo com camera_x * factor; menor = mais longe