# 4. Un juego de plataformas

> Crea un juego de plataformas con scroll lateral en Python con Pyzed: un mapa de tiles escrito como texto, colisiones solo con los tiles cercanos, una cámara suave con parallax, enemigos que patrullan y se pueden pisar, monedas, pinchos, vidas y una bandera de meta.

Source: https://albakore.github.io/pyzed/es/games/platformer/

Con la física de Jump Lab tienes un personaje que corre y salta. Ahora dale un mundo. Este paso construye
*Pixel Run*: un nivel de tres mil píxeles de ancho escrito en unas pocas líneas de texto, una cámara que sigue al
jugador, slimes que patrullan y se pueden pisar, monedas, pinchos y una bandera al final.

| Objetivo | Tiempo | Nivel | Necesitas |
| --- | --- | --- | --- |
| Un nivel completo con cámara, enemigos, monedas, vidas y pantalla de victoria | 60 minutos | Intermedio | [3. Colisiones y física](https://albakore.github.io/pyzed/es/games/collisions-physics/) |

## Qué aprenderás

- Escribir un nivel como **mapa de tiles** en texto y convertirlo en objetos.
- Colisionar solo con los **tiles cerca del jugador**, y dibujar solo los **tiles en pantalla**.
- Una **cámara** que se acerca suave al jugador y se detiene en los bordes del nivel, y **parallax**.
- **Enemigos** que patrullan, se dan la vuelta en paredes y bordes, y se pueden **pisar**.
- **Vidas, reaparición y pantalla de victoria**.

1. **El nivel es texto.** Cada carácter es un tile de 32 × 32: `#` suelo, `=` una plataforma, `o` una moneda, `E`
   un slime, `^` pinchos, `P` el inicio y `F` la bandera. Editar el nivel es editar estas cadenas:

```python
TILE = 32
LEVEL = [
    "................................................................................................",
    ...
    "...P.ooo......ooo.......Eoo.######............Eoo...##...^^.ooE...............E.ooo.......F.....",
    "##################...####################...######################...###########################",
    "##################...####################...######################...###########################",
]
COLS, ROWS = len(LEVEL[0]), len(LEVEL)
SOLID = "#="
```

   Al empezar una partida, `new_game` lo lee una vez: las monedas van a un conjunto de `(col, row)`, cada `E` se
   convierte en un `Enemy`, `P` fija el inicio y `F` la caja de la bandera.

2. **Solo los tiles que importan.** El nivel tiene más de mil tiles, pero el jugador solo puede tocar los pocos que
   hay bajo su caja. `tiles_near` convierte la caja del jugador en un rango de columnas y filas y devuelve los tiles
   sólidos que hay ahí; la colisión es la misma de Jump Lab, eje por eje:

```python
def solid(col, row):
    if col < 0 or col >= COLS:
        return True              # the sides of the level are walls
    if row < 0 or row >= ROWS:
        return False             # above and below are open (falling out is a lost life)
    return LEVEL[row][col] in SOLID

def tiles_near(rect):
    out = []
    for row in range(int(rect.top // TILE), int(rect.bottom // TILE) + 1):
        for col in range(int(rect.left // TILE), int(rect.right // TILE) + 1):
            if solid(col, row):
                out.append(game.Rect(col * TILE, row * TILE, TILE, TILE))
    return out
```

3. **Una cámara.** La cámara es un número: cuánto se ha desplazado el mundo. Apunta un poco por delante del jugador
   (al 40% de la pantalla), no sale del nivel y se acerca a su objetivo con `lerp`, así que se desliza en vez de dar
   tirones:

```python
target = game.clamp(self.player.center[0] - W * 0.4, 0, LEVEL_W - W)
self.camera = game.lerp(self.camera, target, min(1.0, 8 * dt))
```

   Dibujar el mundo a través de la cámara es una sola transformación: `draw.translate(-cam, 0)` antes del mundo,
   dentro de `save()`/`restore()` para no mover el cielo. Las colinas se mueven `cam × 0.2` y `cam × 0.45`, lo que
   las hace parecer lejanas (**parallax**).

4. **Dibuja solo lo que está en pantalla.** Con la cámara sabes la primera y la última columna visibles. Los tiles
   de un mismo color se juntan y se dibujan con un solo `draw.rects`, así que todo el nivel cuesta tres formas:

```python
first, last = cam // TILE, (cam + W) // TILE + 1
dirt, grass, stone = [], [], []
for row, line in enumerate(LEVEL):
    for col in range(max(0, first), min(COLS, last + 1)):
        ...
shapes += [draw.rects(dirt, fill="#5b3a29"), draw.rects(grass, fill="#65a30d"),
           draw.rects(stone, fill="#64748b")]
```

5. **Enemigos que patrullan.** Un slime camina hasta que el tile de delante es una pared o el de abajo-delante está
   vacío; entonces retrocede y se da la vuelta. Esa única regla lo mantiene en su plataforma:

```python
r.x += e.vx * dt
ahead = r.right if e.vx > 0 else r.left
col = int(ahead // TILE)
row = int((r.bottom - 1) // TILE)
if solid(col, row) or not solid(col, row + 1):
    r.x -= e.vx * dt
    e.vx = -e.vx
```

6. **Pisar o hacerse daño.** Tocar un slime está bien *si caes sobre él*: la base del jugador estaba por encima de
   la parte superior del slime en el fotograma anterior. Entonces el slime se aplasta y el jugador rebota.
   Cualquier otro contacto cuesta una vida:

```python
if self.vy > 0 and p.bottom - self.vy / 60 <= e.rect.top + 8:   # coming down on it: stomp
    e.squashed = 0.001
    self.vy = -JUMP_SPEED * 0.6
    self.sparks.burst(e.rect.center[0], e.rect.bottom, count=14, color="#a3e635", angle=-90, spread=160)
    self.shake.kick(3)
elif not self.invincible:
    return self.hurt()
```

   Después de un golpe el jugador reaparece en el inicio y parpadea un segundo (`invincible`), así que un error
   nunca cuesta dos vidas.

7. **Ganar y perder.** Caer fuera del nivel, los pinchos y los slimes llaman a `hurt()`; llegar a la bandera pone
   `won`. Las tarjetas son los mismos widgets de cristal de antes, y <kbd>Enter</kbd> llama a `new_game()`.

**Diseña niveles para el salto que tienes:**

Con `JUMP_SPEED = 680` y `GRAVITY = 1900` el héroe sube unos 120 píxeles (casi cuatro tiles) y recorre unos cinco
tiles en un salto completo a toda velocidad. Deja los huecos en tres tiles y las paredes en tres tiles de alto y
todo salto será justo. Cambia la física y el nivel cambia con ella.

**Mundos más grandes:**

Los mapas de texto son perfectos para unos pocos niveles. Para muchos, guarda cada nivel en su propio `.txt` y léelo
con `Path("levels/1.txt").read_text().splitlines()`, o exporta como CSV desde un editor de tiles como Tiled.

## Prueba esto

- Añade una bandera de control a mitad de nivel: al tocarla, `respawn()` usa su posición.
- Haz que las plataformas `=` sean de un solo sentido: solo colisionan al caer, si la base del jugador estaba por
  encima de la plataforma.
- Añade un segundo nivel: una lista de mapas y `level += 1` al llegar a la bandera.

## Código completo

`examples/games/platformer.py`:

```py
"""Pixel Run: a side-scrolling platformer with a tile map, a camera, enemies and coins.

    python examples/games/platformer.py

Arrows or A/D run, Space (or Up/W) jumps, hold it to jump higher. Stomp the slimes, grab
the coins, avoid the spikes and reach the flag. Enter plays again. Builds on the physics of
Jump Lab and adds what turns it into a level:

- **A tile map written as text**: ``#`` ground, ``=`` platforms, ``o`` coins, ``E`` enemies,
  ``^`` spikes, ``P`` the start and ``F`` the flag. Only the tiles near the player are
  checked for collisions, and only the ones on screen are drawn.
- **A camera** that follows the player smoothly and stops at the edges of the level, with
  hills that scroll slower than the ground (parallax).
- **Enemies** that patrol and turn at walls and ledges, and can be stomped from above.
- **Lives, respawn, a win screen** and a HUD made of ordinary widgets.

Tutorial: https://albakore.github.io/pyzed/games/platformer/
"""

import math

import pyzed as pz
from pyzed import draw, game, ui

TILE = 32
LEVEL = [
    "................................................................................................",
    "................................................................................................",
    "................................................................................................",
    "................................................................................................",
    "....................................ooo.........................................................",
    "...................................=====........................................................",
    ".........ooo.............................................................ooo....................",
    "........=====...........................................................=====...................",
    "...................oo.....................oo........##.............oo...........................",
    "...............................###..................##..........................................",
    "...P.ooo......ooo.......Eoo.######............Eoo...##...^^.ooE...............E.ooo.......F.....",
    "##################...####################...######################...###########################",
    "##################...####################...######################...###########################",
]
COLS, ROWS = len(LEVEL[0]), len(LEVEL)
LEVEL_W, LEVEL_H = COLS * TILE, ROWS * TILE
W, H = 704, LEVEL_H          # the window shows 22 of the 96 columns

RUN, ACCEL, AIR_ACCEL = 240, 2200, 1400
GRAVITY, JUMP_SPEED, LOW_JUMP, MAX_FALL = 1900, 680, 2.6, 900
COYOTE, BUFFER = 0.1, 0.12
JUMP_KEYS = ("space", "up", "w")
ENEMY_SPEED = 55
SOLID = "#="

SLIME = ["..####..", ".######.", "##w##w##", "##k##k##", "########", "########", ".#.##.#."]
SLIME_FLAT = ["........", "........", "........", "..####..", "##w##w##", "########", "########"]

def solid(col, row):
    """Whether the tile at ``(col, row)`` blocks; outside the map, the sides block and the bottom is open."""
    if col < 0 or col >= COLS:
        return True
    if row < 0 or row >= ROWS:
        return False
    return LEVEL[row][col] in SOLID

def tiles_near(rect):
    """The solid tiles ``rect`` could touch: only the few cells under it, never the whole map."""
    out = []
    for row in range(int(rect.top // TILE), int(rect.bottom // TILE) + 1):
        for col in range(int(rect.left // TILE), int(rect.right // TILE) + 1):
            if solid(col, row):
                out.append(game.Rect(col * TILE, row * TILE, TILE, TILE))
    return out

class Enemy:
    def __init__(self, x, y):
        self.rect = game.Rect(x + 2, y + TILE - 24, 28, 24)
        self.vx = -ENEMY_SPEED
        self.squashed = 0.0      # seconds since it was stomped (0 while alive)

class Platformer(pz.App):
    title = "Pixel Run"
    size = (W + 32, H + 92)
    theme = "One Dark"

    def __init__(self, seed=None):
        self.held = None             # the keys held down; None reads the keyboard
        self.sparks = game.Particles(gravity=600, seed=seed)
        self.shake = game.Shake(seed=seed)
        self.best_time = None
        self.new_game()

    # -- state ------------------------------------------------------------------------------
    def new_game(self):
        self.coins, self.enemies = set(), []
        for row, line in enumerate(LEVEL):
            for col, ch in enumerate(line):
                if ch == "o":
                    self.coins.add((col, row))
                elif ch == "E":
                    self.enemies.append(Enemy(col * TILE, row * TILE))
                elif ch == "P":
                    self.start = (col * TILE + 4, row * TILE + TILE - 30)
                elif ch == "F":
                    self.flag = game.Rect(col * TILE + 10, row * TILE - 4 * TILE, 12, 5 * TILE)
        self.total_coins = len(self.coins)
        self.lives, self.time = 3, 0.0
        self.won = self.over = False
        self.camera = 0.0
        self.respawn()

    def respawn(self):
        self.player = game.Rect(*self.start, 22, 30)
        self.vx = self.vy = 0.0
        self.on_ground = False
        self.coyote = self.buffer = 0.0
        self.facing = 1
        self.invincible = 1.0        # a second of blinking after (re)spawning

    @property
    def collected(self):
        return self.total_coins - len(self.coins)

    # -- input ------------------------------------------------------------------------------
    def on_key(self, combo):
        if combo in JUMP_KEYS:
            self.buffer = BUFFER
        elif combo == "enter" and (self.won or self.over):
            self.new_game()

    # -- update -----------------------------------------------------------------------------
    def subscriptions(self):
        return [pz.frames(self.tick)]

    def tick(self, dt):
        dt = min(dt, 1 / 30)
        self.sparks.update(dt)
        self.shake.update(dt)
        if self.won or self.over:
            return
        self.time += dt
        self.invincible = max(0.0, self.invincible - dt)
        self.move_player(dt)
        self.move_enemies(dt)
        self.pick_coins()
        self.check_dangers()
        # The camera looks a little ahead of the player and eases toward it.
        target = game.clamp(self.player.center[0] - W * 0.4, 0, LEVEL_W - W)
        self.camera = game.lerp(self.camera, target, min(1.0, 8 * dt))

    def move_player(self, dt):
        move = game.axis(("left", "a"), ("right", "d"), held=self.held)
        if move:
            self.facing = move
        accel = (ACCEL if self.on_ground else AIR_ACCEL) * dt
        self.vx += game.clamp(move * RUN - self.vx, -accel, accel)
        rising_without_key = self.vy < 0 and not game.is_pressed(*JUMP_KEYS, held=self.held)
        self.vy = min(self.vy + GRAVITY * (LOW_JUMP if rising_without_key else 1) * dt, MAX_FALL)
        self.coyote = COYOTE if self.on_ground else max(0.0, self.coyote - dt)
        self.buffer = max(0.0, self.buffer - dt)
        if self.buffer > 0 and self.coyote > 0:
            self.vy, self.buffer, self.coyote = -JUMP_SPEED, 0.0, 0.0

        p = self.player
        p.x += self.vx * dt
        for tile in tiles_near(p):
            if p.overlaps(tile):
                p.x = tile.left - p.w if self.vx > 0 else tile.right
                self.vx = 0.0
        p.y += self.vy * dt
        self.on_ground = False
        for tile in tiles_near(p):
            if p.overlaps(tile):
                if self.vy > 0:
                    p.y, self.on_ground = tile.top - p.h, True
                else:
                    p.y = tile.bottom
                self.vy = 0.0

    def move_enemies(self, dt):
        for e in self.enemies:
            if e.squashed:
                e.squashed += dt
                continue
            r = e.rect
            r.x += e.vx * dt
            ahead = r.right if e.vx > 0 else r.left
            col = int(ahead // TILE)
            row = int((r.bottom - 1) // TILE)
            if solid(col, row) or not solid(col, row + 1):   # a wall, or no floor ahead:
                r.x -= e.vx * dt                              # step back and turn around
                e.vx = -e.vx
        self.enemies = [e for e in self.enemies if e.squashed < 0.6]

    def pick_coins(self):
        p = self.player
        for col, row in list(self.coins):
            if p.overlaps(game.Rect(col * TILE + 8, row * TILE + 8, 16, 16)):
                self.coins.discard((col, row))
                self.sparks.burst(col * TILE + 16, row * TILE + 16, count=12, color=["#fde68a", "#fbbf24"],
                                  speed=(40, 160), life=(0.2, 0.5))

    def check_dangers(self):
        p = self.player
        for e in self.enemies:
            if e.squashed or not p.overlaps(e.rect):
                continue
            if self.vy > 0 and p.bottom - self.vy / 60 <= e.rect.top + 8:   # coming down on it: stomp
                e.squashed = 0.001
                self.vy = -JUMP_SPEED * 0.6
                self.sparks.burst(e.rect.center[0], e.rect.bottom, count=14, color="#a3e635", angle=-90, spread=160)
                self.shake.kick(3)
            elif not self.invincible:
                return self.hurt()
        for row in range(int(p.top // TILE), int(p.bottom // TILE) + 1):
            for col in range(int(p.left // TILE), int(p.right // TILE) + 1):
                if 0 <= row < ROWS and 0 <= col < COLS and LEVEL[row][col] == "^":
                    spike = game.Rect(col * TILE + 4, row * TILE + 14, TILE - 8, TILE - 14)
                    if p.overlaps(spike) and not self.invincible:
                        return self.hurt()
        if p.top > LEVEL_H + 40:
            return self.hurt()
        if p.overlaps(self.flag):
            self.won = True
            self.best_time = self.time if self.best_time is None else min(self.best_time, self.time)
            self.sparks.burst(self.flag.center[0], self.flag.top + 20, count=60,
                              color=["#f472b6", "#fbbf24", "#22d3ee", "#a3e635"], speed=(80, 320))

    def hurt(self):
        self.lives -= 1
        self.shake.kick(10)
        self.sparks.burst(*self.player.center, count=30, color=["#f87171", "#fecaca"], speed=(80, 260))
        if self.lives <= 0:
            self.over = True
        else:
            self.respawn()

    # -- draw -------------------------------------------------------------------------------
    def board(self):
        cam = round(self.camera)
        shapes = [draw.background("#1e1b3a")]
        for i, color in enumerate(("#2a2550", "#352d63", "#43367a")):       # sky bands
            shapes.append(draw.rect(0, 70 + i * 60, W, 60, fill=color))
        shapes.append(draw.circle(560, 90, 34, fill="#fde68a"))
        for layer, (speed, color, base) in enumerate(((0.2, "#2c2a55", 300), (0.45, "#24344d", 340))):
            off = -(cam * speed) % 240                                       # parallax hills
            for i in range(-1, W // 240 + 2):
                cx = i * 240 + off + layer * 120
                shapes.append(draw.circle(cx, base, 150 - layer * 30, fill=color))

        shapes += [draw.save(), *self.shake.shapes(), draw.translate(-cam, 0)]
        first, last = cam // TILE, (cam + W) // TILE + 1
        dirt, grass, stone = [], [], []
        for row, line in enumerate(LEVEL):
            for col in range(max(0, first), min(COLS, last + 1)):       # only the visible columns
                ch = line[col]
                x, y = col * TILE, row * TILE
                if ch == "#":
                    dirt.append((x, y, TILE, TILE))
                    if row == 0 or LEVEL[row - 1][col] not in SOLID:
                        grass.append((x, y, TILE, 8))
                elif ch == "=":
                    stone.append((x, y, TILE, TILE / 2))
                elif ch == "^":
                    for k in range(2):
                        sx = x + 2 + k * 14
                        shapes.append(draw.polygon([(sx, y + TILE), (sx + 7, y + 12), (sx + 14, y + TILE)],
                                                   fill="#cbd5e1"))
        shapes += [draw.rects(dirt, fill="#5b3a29"), draw.rects(grass, fill="#65a30d"),
                   draw.rects(stone, fill="#64748b")]

        for col, row in self.coins:
            cx, cy = col * TILE + 16, row * TILE + 16 + math.sin(self.time * 4 + col) * 3
            spin = 0.35 + 0.65 * abs(math.cos(self.time * 3 + col * 0.7))
            shapes += game.glow(cx, cy, 6, "#fbbf24", layers=2, strength=0.35, core=False)
            shapes.append(draw.rect(cx - 7 * spin, cy - 8, 14 * spin, 16, radius=7 * spin, fill="#fbbf24"))

        fx, fy = self.flag.x, self.flag.y
        shapes.append(draw.rect(fx + 4, fy, 4, self.flag.h, fill="#e2e8f0"))
        wave = math.sin(self.time * 5) * 4
        shapes.append(draw.polygon([(fx + 8, fy + 2), (fx + 52, fy + 14 + wave), (fx + 8, fy + 28)], fill="#f472b6"))

        for e in self.enemies:
            frame = SLIME_FLAT if e.squashed else SLIME
            bob = 0 if e.squashed else abs(math.sin(self.time * 8 + e.rect.x * 0.1)) * -2
            shapes += game.sprite(frame, {"#": "#84cc16", "w": "#f8fafc", "k": "#111827"},
                                  e.rect.x - 2, e.rect.y - 4 + bob, scale=4, flip=e.vx > 0)

        if not self.over and (not self.invincible or int(self.time * 14) % 2):
            p = self.player
            shapes.append(p.shape(fill="#22d3ee", radius=5))
            ex = p.center[0] + self.facing * 4
            shapes.append(draw.rect(ex - 2, p.y + 8, 4, 7, radius=2, fill="#0b0e14"))
        shapes += self.sparks.shapes(glow=True)
        shapes.append(draw.restore())
        return pz.canvas(shapes, width=W, height=H)

    def overlay(self):
        if self.won:
            title, lines = "Level clear!", [f"{self.collected}/{self.total_coins} coins in {self.time:.1f} s",
                                            "Enter plays again"]
        elif self.over:
            title, lines = "Game over", [f"{self.collected}/{self.total_coins} coins", "Enter plays again"]
        else:
            return None
        card = pz.container(pz.column([ui.heading(title, level=2), *[ui.muted(t) for t in lines]],
                                      spacing=8, align="center"), padding=(20, 32)).style(glass=True, radius=14)
        return pz.container(card, width="fill", height="fill", align_x="center", align_y="center")

    def view(self):
        t = self.resolved_theme()
        hearts = pz.row([pz.icon("heart", size=15, color="#f87171" if i < self.lives else t.border)
                         for i in range(3)], spacing=3)
        hud = pz.row([
            ui.label("Pixel Run", weight="semibold"), pz.hspace(),
            ui.badge(f"{self.collected}/{self.total_coins}", icon="coins", tint="warning"),
            ui.badge(f"{self.time:.1f} s", icon="timer", tint="accent"),
            ui.badge(f"Best {self.best_time:.1f} s", icon="trophy", tint="text_muted") if self.best_time else None,
            hearts,
        ], spacing=8, align="center")
        play = pz.stack([self.board(), self.overlay()], width=W, height=H)
        return pz.container(pz.column([hud, pz.container(play, radius=10, clip=True),
                                       ui.muted("←/→ run · Space jumps · stomp the slimes · reach the flag",
                                                size="sm")], spacing=10),
                            padding=16, width="fill", height="fill", bg=t.background)

if __name__ == "__main__":
    pz.run(Platformer)
```

## Siguiente

- [5. Asteroids con partículas](https://albakore.github.io/pyzed/es/games/asteroids/): Rotación y empuje, espacio que da la vuelta, colisiones de círculos y muchas partículas.
