# Tutorial: Neon Invaders

> Un shooter espacial completo con sprites pixel art, oleadas, power-ups, brillo neón, partículas y un HUD de cristal con bloom.

Source: https://albakore.github.io/pyzed/es/games/neon-invaders/

La última demo junta todo en un shooter con aspecto de juego indie moderno: un fondo de
estrellas con paralaje, invasores pixel art que marchan y se lanzan en picado, disparo
automático, un power-up de triple disparo, luces neón por todas partes, explosiones, temblor
de pantalla y un HUD de paneles de cristal y texto brillante dibujado por la biblioteca de
widgets.

| Objetivo | Tiempo | Nivel | Necesitas |
| --- | --- | --- | --- |
| Un matamarcianos con sprites de pixel art, oleadas, power-ups, brillo de neón y un HUD de cristal | 1 hora | Avanzado | [Breakout](https://albakore.github.io/pyzed/es/games/breakout/) |

Juégalo primero:

1. **Pixel art en texto plano.** Los sprites son listas de cadenas; `game.sprite` asigna un
   color a cada carácter y dibuja una forma por color, así que una nave de 9×7 cuesta dos
   formas:

```python
SHIP = ["....#....",
        "...###...",
        "...#@#...",
        "..#####..",
        ".##.#.##.",
        "###.#.###",
        "#.......#"]

game.sprite(SHIP, {"#": NEON["cyan"], "@": "#ffffff"}, self.x - 13.5, self.y - 10.5, scale=3)
```

   ¿Prefieres imágenes? `draw.image("ship.png", x, y, w, h, rotation=angle)` las dibuja,
   decodificadas una vez y en caché.

2. **Volar y disparo automático.** `game.vector()` convierte las flechas y WASD en una
   dirección (con las diagonales normalizadas) y `pz.is_pressed("space")` dispara mientras
   la tecla está pulsada, con un tiempo de recarga:

```python
def fly(self, dt):
    dx, dy = game.vector()
    self.x = game.clamp(self.x + dx * PLAYER_SPEED * dt, 20, W - 20)
    self.y = game.clamp(self.y + dy * PLAYER_SPEED * dt, H * 0.55, H - 30)
    self.cooldown -= dt
    if game.is_pressed("space") and self.cooldown <= 0:
        self.fire()

def fire(self):
    self.cooldown = FIRE_DELAY
    for a in ((-12, 0, 12) if self.triple else (0,)):     # el power-up abre el disparo en abanico
        r = math.radians(a - 90)
        self.shots.append([self.x, self.y - 14, math.cos(r) * 640, math.sin(r) * 640])
```

3. **Una formación que marcha y ataca en picado.** La formación se desliza de lado a lado y
   baja un poco en cada borde; de vez en cuando un invasor la abandona y se lanza hacia la
   nave, corrigiendo el rumbo en cada fotograma:

```python
self.march += self.direction * speed * dt
if abs(self.march) > 60:
    self.direction = -self.direction
    self.drop = min(self.drop + 10, 140)

for inv in self.invaders:
    if inv.diving:
        inv.vx += (self.x - inv.x) * 1.4 * dt      # persigue a la nave
        inv.x += inv.vx * dt
        inv.y += inv.vy * dt
    elif self.random.random() < dt * (0.02 + 0.01 * self.wave):
        inv.diving, inv.vx, inv.vy = True, 0.0, 170 + 20 * self.wave
```

   Usar `random.random() < tasa * dt` mantiene la misma frecuencia media de eventos sea cual
   sea la tasa de fotogramas.

4. **Luces neón.** Un canvas no tiene desenfoque, pero la luz es fácil de imitar: `game.glow`
   dibuja varios círculos translúcidos cada vez más grandes detrás de un núcleo brillante. Los
   láseres son dos líneas, una ancha y transparente bajo otra fina y blanca:

```python
shapes += game.glow(cx, cy, 10, color, layers=2, strength=0.25, core=False)  # halo del invasor
shapes += game.glow(x, y, 3.5, NEON["pink"], layers=2, strength=0.6)         # bala enemiga
shapes.append(draw.line(x, y, x, y + 12, stroke=game.rgba(NEON["cyan"], 0.3), width=7, cap="round"))
shapes.append(draw.line(x, y, x, y + 12, stroke="#e0fbff", width=2.5, cap="round"))
```

   El fondo de estrellas son tres capas de puntos que caen a distinta velocidad (paralaje), y
   la cuadrícula en perspectiva de abajo es un puñado de `draw.line`.

5. **Explosiones y respuesta.** Cada baja suelta partículas brillantes del color del invasor
   y los golpes fuertes hacen temblar la pantalla. Tras recibir un impacto la nave parpadea y
   es invulnerable un momento:

```python
def explode(self, inv, big=False):
    cx, cy = inv.rect.center
    color = INVADER_COLORS[inv.kind]
    self.sparks.burst(cx, cy, count=40 if big else 26, color=[color, "#ffffff", color],
                      speed=(60, 300))
    self.shake.kick(10 if big else 3)
```

6. **Un HUD con efectos de interfaz reales.** Aquí brilla una librería de UI. La puntuación es
   texto con `bloom` (luz a partir de sus píxeles brillantes), la barra es un panel `glass` que
   desenfoca las estrellas de detrás, y el título y los avisos "WAVE 2" usan animaciones de
   entrada:

```python
def neon(widget, color, intensity=1.0):
    """Bloom solo en las partes brillantes, sin el borde iluminado de la caja."""
    return pz.container(widget, padding=(4, 12)).style(
        bloom=(color, intensity), border_width=0, shadow=("#00000000", (0, 0), 0))

score = neon(pz.text(f"{self.score:06d}", size=22, font="mono", color="#ffffff"), NEON["cyan"], 0.8)
bar = pz.row([score, pz.hspace(), ui.badge(f"Wave {self.wave}", icon="crosshair"), shield])
hud = pz.container(bar, padding=(8, 14)).style(glass=True, radius=12, border=(1, "#ffffff22"))

banner = neon(pz.text(f"WAVE {self.wave}", size=30, weight="bold"), NEON["lime"]).animate("zoom_in")
board = pz.stack([self.scene(), banner, hud], width=W, height=H)
```

   (Image: La pantalla de título: una tarjeta de cristal con un título neón que late)

**Tip:**

Todo lo que se ve se reconstruye en cada fotograma a partir del estado del juego, como
cualquier vista de Pyzed. Eso mantiene el código simple: no hay objetos sprite que crear,
mover o borrar, solo listas que `view()` convierte en formas.

## Código completo

`examples/games/neon_invaders.py`:

```py
"""Neon Invaders: a space shooter with a glowing HUD, particles, bloom and screen shake.

    python examples/games/neon_invaders.py

Arrows/WASD fly, hold Space to fire, P pauses, Enter starts. Waves of pixel-art invaders
march and dive; shoot the purple capsules for a triple shot. Shows a complete game
built from Pyzed parts: ``pz.frames`` for the loop, ``pz.pressed`` for flying and
auto-fire, ``game.sprite`` pixel art, ``game.glow`` neon lights, ``game.Particles``
explosions, ``game.Shake``, and a HUD of regular widgets with ``glass`` panels,
``bloom`` and animations stacked over the canvas.
Tutorial: https://albakore.github.io/pyzed/games/neon-invaders/
"""

import math
import random

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

W, H = 760, 560
PLAYER_SPEED = 330
FIRE_DELAY = 0.16
NEON = {"cyan": "#22d3ee", "pink": "#f472b6", "lime": "#a3e635", "amber": "#fbbf24", "violet": "#a78bfa"}

SHIP = [
    "....#....",
    "...###...",
    "...#@#...",
    "..#####..",
    ".##.#.##.",
    "###.#.###",
    "#.......#",
]
INVADERS = [
    ["..#.....#..", "...#...#...", "..#######..", ".##.###.##.", "###########", "#.#######.#", "#.#.....#.#",
     "...##.##..."],
    ["...####...", ".########.", "##########", "###..##..#", "##########", "..##..##..", ".##.##.##.",
     "##......##"],
    ["...##...", "..####..", ".######.", "##.##.##", "########", "..#..#..", ".#.##.#.", "#.#..#.#"],
]
INVADER_COLORS = [NEON["lime"], NEON["pink"], NEON["amber"]]
PIXEL = 3

def neon(widget, color, intensity=1.0):
    """Bloom on the bright parts only: no lit edge or box shadow, so text glows by itself."""
    return pz.container(widget, padding=(4, 12)).style(bloom=(color, intensity), border_width=0,
                                                        shadow=("#00000000", (0, 0), 0))

class Invader:
    def __init__(self, kind, col, row):
        self.kind, self.col, self.row = kind, col, row
        self.hp = 1 + (kind == 0)
        self.diving = False
        self.flash = 0.0  # seconds left of the white flash after a hit
        self.x = self.y = 0.0
        self.vx = self.vy = 0.0

    @property
    def w(self):
        return len(INVADERS[self.kind][0]) * PIXEL

    @property
    def rect(self):
        return game.Rect(self.x, self.y, self.w, 8 * PIXEL)

class NeonInvaders(pz.App):
    title = "Neon Invaders"
    size = (W + 32, H + 32)
    theme = "One Dark"

    def __init__(self, seed=None):
        self.random = random.Random(seed)
        self.sparks = game.Particles(drag=2.2, seed=seed)
        self.shake = game.Shake(seed=seed)
        self.stars = [(self.random.uniform(0, W), self.random.uniform(0, H), layer)
                      for layer in (1, 2, 3) for _ in range(40 // layer)]
        self.best = 0
        self.started = False  # the title screen until Enter
        self.time = 0.0
        self.new_game()

    # -- state ----------------------------------------------------------------
    def new_game(self):
        self.x, self.y = W / 2, H - 60
        self.shield, self.score, self.wave = 3, 0, 0
        self.shots, self.enemy_shots, self.powerups = [], [], []
        self.cooldown = 0.0
        self.triple = 0.0  # seconds of triple shot left
        self.hurt = 0.0  # invulnerability after a hit
        self.over = self.paused = False
        self.next_wave()

    def next_wave(self):
        self.wave += 1
        self.invaders = [Invader(min(row // 2, 2), col, row) for row in range(4) for col in range(9)]
        self.march, self.direction, self.drop = 0.0, 1, 0.0
        self.banner = 2.0  # "Wave N" shown for two seconds
        self.place_formation()

    def place_formation(self):
        left = 70 + self.march
        for inv in self.invaders:
            if not inv.diving:
                inv.x = left + inv.col * 64 + (33 - inv.w) / 2
                inv.y = 70 + inv.row * 44 + self.drop

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

    def tick(self, dt):
        self.time += dt
        self.sparks.update(dt)
        self.shake.update(dt)
        self.stars = [(x, (y + 20 * layer * dt) % H, layer) for x, y, layer in self.stars]
        if not self.started or self.paused or self.over:
            return
        self.banner = max(0.0, self.banner - dt)
        self.hurt = max(0.0, self.hurt - dt)
        self.triple = max(0.0, self.triple - dt)
        self.fly(dt)
        self.move_invaders(dt)
        self.move_shots(dt)
        if not self.invaders:
            self.next_wave()

    def fly(self, dt):
        dx, dy = game.vector()
        self.x = game.clamp(self.x + dx * PLAYER_SPEED * dt, 20, W - 20)
        self.y = game.clamp(self.y + dy * PLAYER_SPEED * dt, H * 0.55, H - 30)
        self.cooldown -= dt
        if game.is_pressed("space") and self.cooldown <= 0:
            self.fire()

    def fire(self):
        self.cooldown = FIRE_DELAY
        angles = (-12, 0, 12) if self.triple else (0,)
        for a in angles:
            r = math.radians(a - 90)
            self.shots.append([self.x, self.y - 14, math.cos(r) * 640, math.sin(r) * 640])

    def move_invaders(self, dt):
        speed = 40 + 10 * self.wave + (36 - len(self.invaders)) * 3
        self.march += self.direction * speed * dt
        if abs(self.march) > 60:
            self.direction = -self.direction
            self.march = game.clamp(self.march, -60, 60)
            self.drop = min(self.drop + 10, 140)
        self.place_formation()
        for inv in self.invaders:
            inv.flash = max(0.0, inv.flash - dt)
            if inv.diving:
                inv.vx += (self.x - inv.x) * 1.4 * dt  # home in on the ship
                inv.x += inv.vx * dt
                inv.y += inv.vy * dt
                if inv.y > H + 30:
                    inv.diving = False  # back to the formation from the top
            elif self.random.random() < dt * (0.02 + 0.01 * self.wave):
                inv.diving, inv.vx, inv.vy = True, 0.0, 170 + 20 * self.wave
            if self.random.random() < dt * (0.06 + 0.02 * self.wave):
                cx, cy = inv.rect.center
                self.enemy_shots.append([cx, cy + 12, 0, 230 + 15 * self.wave])
            if self.hurt <= 0 and inv.rect.overlaps(self.ship_rect):
                self.invaders.remove(inv)
                self.explode(inv, big=True)
                self.damage()
                break

    def move_shots(self, dt):
        for shot in self.shots[:]:
            shot[0] += shot[2] * dt
            shot[1] += shot[3] * dt
            if shot[1] < -10:
                self.shots.remove(shot)
                continue
            for inv in self.invaders:
                if inv.rect.contains(shot[0], shot[1]):
                    self.shots.remove(shot)
                    inv.hp -= 1
                    if inv.hp <= 0:
                        self.invaders.remove(inv)
                        self.score += (30 - 10 * inv.kind) * (2 if inv.diving else 1)
                        self.explode(inv)
                        if self.random.random() < 0.06:
                            self.powerups.append(list(inv.rect.center))
                    else:
                        inv.flash = 0.08
                        self.sparks.burst(shot[0], shot[1], count=6, color="#ffffff", speed=(40, 140))
                    break
        for shot in self.enemy_shots[:]:
            shot[1] += shot[3] * dt
            if shot[1] > H + 10:
                self.enemy_shots.remove(shot)
            elif self.hurt <= 0 and self.ship_rect.contains(shot[0], shot[1]):
                self.enemy_shots.remove(shot)
                self.damage()
        for p in self.powerups[:]:
            p[1] += 90 * dt
            if self.ship_rect.contains(*p) or self.ship_rect.moved(-8, -8).contains(*p):
                self.powerups.remove(p)
                self.triple = 8.0
                self.sparks.burst(p[0], p[1], count=30, color=NEON["violet"], speed=(60, 200))
            elif p[1] > H + 10:
                self.powerups.remove(p)

    @property
    def ship_rect(self):
        return game.Rect(self.x - 13, self.y - 10, 27, 21)

    def explode(self, inv, big=False):
        cx, cy = inv.rect.center
        color = INVADER_COLORS[inv.kind]
        self.sparks.burst(cx, cy, count=40 if big else 26, color=[color, "#ffffff", color], speed=(60, 300),
                          life=(0.3, 0.8), size=(1.5, 3.2))
        self.shake.kick(10 if big else 3)

    def damage(self):
        self.shield -= 1
        self.hurt = 1.5
        self.shake.kick(16)
        self.sparks.burst(self.x, self.y, count=50, color=[NEON["cyan"], "#ffffff"], speed=(80, 320))
        if self.shield <= 0:
            self.over = True
            self.best = max(self.best, self.score)

    # -- input ----------------------------------------------------------------
    def on_key(self, combo):
        if combo == "enter" and (not self.started or self.over):
            self.started = True
            self.new_game()
        elif combo == "p" and self.started and not self.over:
            self.paused = not self.paused

    # -- drawing --------------------------------------------------------------
    def scene(self):
        shapes = [draw.background("#05060d"), *self.shake.shapes()]
        for x, y, layer in self.stars:
            shapes.append(draw.circle(x, y, 0.5 * layer, fill=game.rgba("#c7d2fe", 0.25 * layer)))
        # A neon horizon grid at the bottom.
        for i in range(9):
            y = H - 120 + i * i * 1.6
            shapes.append(draw.line(0, y, W, y, stroke=game.rgba(NEON["violet"], 0.05 + i * 0.012)))
        for i in range(-8, 9):
            shapes.append(draw.line(W / 2 + i * 30, H - 120, W / 2 + i * 120, H,
                                    stroke=game.rgba(NEON["violet"], 0.08)))
        bob = math.sin(self.time * 6)
        for inv in self.invaders:
            color = "#ffffff" if inv.flash > 0 else INVADER_COLORS[inv.kind]
            cx, cy = inv.rect.center
            shapes += game.glow(cx, cy, 10, color, layers=2, strength=0.25, core=False)
            shapes += game.sprite(INVADERS[inv.kind], {"#": color}, inv.x, inv.y + bob * (0 if inv.diving else 1.5),
                                  scale=PIXEL)
        for x, y in self.powerups:
            shapes += game.glow(x, y, 7, NEON["violet"], layers=3, strength=0.7)
            shapes.append(draw.text(x, y, "3", color="#05060d", size=10, weight="bold", align_x="center",
                                    align_y="center"))
        for x, y, *_ in self.shots:
            shapes.append(draw.line(x, y, x, y + 12, stroke=game.rgba(NEON["cyan"], 0.3), width=7, cap="round"))
            shapes.append(draw.line(x, y, x, y + 12, stroke="#e0fbff", width=2.5, cap="round"))
        for x, y, *_ in self.enemy_shots:
            shapes += game.glow(x, y, 3.5, NEON["pink"], layers=2, strength=0.6)
        if self.started and not self.over and (self.hurt <= 0 or int(self.time * 12) % 2):
            flame = 6 + 3 * math.sin(self.time * 40)
            shapes += game.glow(self.x, self.y + 14, 4, NEON["amber"], layers=2, strength=0.8)
            shapes.append(draw.polygon([(self.x - 4, self.y + 11), (self.x + 4, self.y + 11),
                                        (self.x, self.y + 11 + flame)], fill=NEON["amber"]))
            shapes += game.glow(self.x, self.y, 16, NEON["cyan"], layers=3, strength=0.22, core=False)
            shapes += game.sprite(SHIP, {"#": NEON["cyan"], "@": "#ffffff"}, self.x - 13.5, self.y - 10.5, scale=PIXEL)
        shapes += self.sparks.shapes(glow=True)
        return pz.canvas(shapes, width=W, height=H)

    def hud(self, t):
        shield = pz.row([
            pz.icon("shield", size=14, color=NEON["cyan"]),
            pz.container(ui.progress(self.shield, max=3, tint=NEON["cyan"], height=6), width=90),
        ], spacing=6, align="center")
        score = neon(pz.text(f"{self.score:06d}", size=22, font="mono", color="#ffffff"), NEON["cyan"], 0.8)
        chips = [
            ui.badge(f"Wave {self.wave}", tint=NEON["pink"], icon="crosshair"),
            ui.badge(f"Triple {self.triple:.0f}s", tint=NEON["violet"], icon="sparkles") if self.triple else None,
            ui.badge(f"Best {self.best:,}", tint="text_muted", icon="trophy"),
        ]
        bar = pz.row([score, pz.hspace(), *chips, shield], spacing=10, align="center")
        return pz.container(bar, padding=(8, 14)).style(glass=True, radius=12, border=(1, "#ffffff22"))

    def center_card(self, title, lines, color):
        heading = neon(pz.text(title, size=40, weight="bold", color="#ffffff"), color).animate("pulse")
        body = [ui.muted(line) for line in lines]
        card = pz.container(pz.column([heading, *body], spacing=10, align="center"),
                            padding=(28, 40)).style(glass=True, radius=16, border=(1, "#ffffff26"))
        return pz.container(card, width="fill", height="fill", align_x="center", align_y="center")

    def overlay(self):
        if not self.started:
            return self.center_card("NEON INVADERS", ["Arrows / WASD fly · hold Space to fire",
                                                      "Press Enter to start"], NEON["pink"])
        if self.over:
            return self.center_card("GAME OVER", [f"Score {self.score:,} · wave {self.wave}",
                                                  "Press Enter to play again"], NEON["pink"])
        if self.paused:
            return self.center_card("PAUSED", ["Press P to continue"], NEON["cyan"])
        if self.banner:
            text = neon(pz.text(f"WAVE {self.wave}", size=30, weight="bold", color="#ffffff"),
                        NEON["lime"]).animate("zoom_in")
            return pz.container(text, width="fill", height="fill", align_x="center", align_y="center")
        return None

    def view(self):
        t = self.resolved_theme()
        hud = pz.container(self.hud(t), padding=12, width="fill") if self.started else None
        board = pz.stack([self.scene(), self.overlay(), hud], width=W, height=H)
        return pz.container(pz.container(board, radius=12, clip=True), padding=16, width="fill", height="fill",
                            bg="#0b0d14", align_x="center", align_y="center")

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