Ir al contenido

1. Tu primer juego: el bucle y las teclas

Un juego es un programa que sigue funcionando aunque no pulses nada. Cada pocos milisegundos mira las teclas, mueve un poco las cosas y lo vuelve a dibujar todo. En este primer paso de la ruta construyes Coin Chase: un orbe brillante que diriges con las flechas, una moneda que atrapar, un reloj de 30 segundos y una puntuación. Es pequeño, pero tiene todas las partes de un juego de verdad.

Un orbe cian se desliza sobre una cuadrícula oscura hacia una moneda dorada que gira; al tocarla aparece un anillo y la moneda reaparece en otro sitio
Coin Chase: el orbe se mueve con teclas mantenidas, y la puntuación y el reloj son widgets normales.
Objetivo Tiempo Nivel Necesitas
Un juego completo con bucle, teclas mantenidas, temporizador y puntuación 25 minutos Principiante Python y Pyzed instalados (Instalación); ninguna experiencia con juegos
  • Las tres partes de todo juego: estado, actualización y dibujo.
  • Ejecutar código una vez por fotograma con pz.frames(self.tick).
  • Mover cosas con velocidad × dt, para que el juego vaya igual de rápido en cualquier pantalla.
  • Leer las teclas mantenidas (game.vector) y las pulsadas una vez (on_key).
  • Pausar el juego (y la CPU) devolviendo cero suscripciones.

Juégalo primero:

Windows, macOS y Linux
uv run --with pyzed-ui python examples/games/coin_chase.py
flowchart LR
    S["Estado<br/>x, y, score, time_left"] --> U["Actualizar: tick(dt)<br/>leer teclas · mover · colisiones"]
    U --> D["Dibujar: view()<br/>canvas + widgets del HUD"]
    D -->|siguiente fotograma| U

Pyzed llama a tick(dt) antes de cada fotograma que muestra la pantalla (60, 120 o 144 veces por segundo) y luego a view() para dibujar. Nunca escribes el bucle: solo dices qué pasa en un fotograma.

  1. El estado. Todo lo que el juego necesita recordar vive en atributos de la app. reset() les da valor, así que el mismo método empieza la primera ronda y cada ronda nueva:

    W, H = 560, 380
    SPEED = 260 # pixels per second
    PLAYER_R = 14
    COIN_R = 9
    ROUND = 30.0 # seconds per round
    class CoinChase(pz.App):
    def __init__(self, seed=None):
    self.random = random.Random(seed)
    self.held = None # the keys held down; None reads the keyboard
    self.best = 0
    self.reset()
    def reset(self):
    self.x, self.y = W / 2, H / 2
    self.score = 0
    self.time_left = ROUND
    self.paused = self.over = False
    self.pops = [] # [x, y, age]: rings that grow and fade where a coin was taken
    self.clock = 0.0
    self.coin = self.new_coin()

    held es para pruebas y demos: le asignan {"right"} para mover sin teclado. Cuando es None, el juego lee las teclas reales.

  2. Enciende el bucle. subscriptions() devuelve lo que debe estar en marcha ahora. pz.frames(self.tick) significa “llama a tick en cada fotograma”. Pyzed vuelve a preguntar tras cada cambio, así que no devolver nada pausa el juego, y la CPU descansa:

    def subscriptions(self):
    if self.paused or self.over:
    return []
    return [pz.frames(self.tick)]
  3. Mueve con velocidad × dt. dt son los segundos desde el fotograma anterior (unos 0.0167 a 60 Hz). Si movieras 4 píxeles fijos por fotograma, el orbe iría el doble de rápido en una pantalla de 120 Hz. Multiplicar por dt da píxeles por segundo:

    def tick(self, dt):
    self.clock += dt
    self.time_left = max(0.0, self.time_left - dt)
    dx, dy = game.vector(held=self.held) # arrows / WASD, diagonals normalized
    self.x = game.clamp(self.x + dx * SPEED * dt, PLAYER_R, W - PLAYER_R)
    self.y = game.clamp(self.y + dy * SPEED * dt, PLAYER_R, H - PLAYER_R)

    game.vector() convierte las flechas y WASD en una dirección: (1, 0) a la derecha, (0.71, -0.71) arriba a la derecha (así las diagonales no son más rápidas). game.clamp mantiene el orbe dentro del tablero.

  4. ¿Tocó la moneda? Dos círculos se tocan cuando la distancia entre sus centros es menor que la suma de sus radios. math.hypot mide esa distancia:

    cx, cy = self.coin
    if math.hypot(cx - self.x, cy - self.y) < PLAYER_R + COIN_R:
    self.score += 1
    self.pops.append([cx, cy, 0.0])
    self.coin = self.new_coin()
    for pop in self.pops:
    pop[2] += dt
    self.pops = [p for p in self.pops if p[2] < 0.4]
    if self.time_left <= 0:
    self.over = True
    self.best = max(self.best, self.score)

    Cada “pop” es un anillo que vive 0,4 segundos. Efectos así son la forma más barata de que un juego se sienta bien; la página de consejos tiene más.

  5. Teclas pulsadas una vez. Mantener una tecla mueve el orbe; pulsarla hace algo una vez. on_key recibe cada pulsación como texto:

    def on_key(self, combo):
    if combo == "space" and not self.over:
    self.paused = not self.paused
    elif combo == "enter" and self.over:
    self.reset()
  6. Dibuja el fotograma. view() convierte el estado en una imagen. El tablero es un pz.canvas con formas de pyzed.draw; la moneda estrecha su ancho con cos para parecer que gira, y game.glow añade una luz suave:

    def board(self):
    shapes = [draw.background("#0b0e14")]
    ...
    for x, y, age in self.pops:
    k = age / 0.4 # 0 -> 1 over the ring's life
    shapes.append(draw.circle(x, y, COIN_R + k * 26, stroke=game.rgba("#fde68a", 1 - k), width=3))
    shapes += game.glow(self.x, self.y, PLAYER_R, "#22d3ee", layers=4, strength=0.5)
    return pz.canvas(shapes, width=W, height=H)
  7. El HUD son widgets. La puntuación, el reloj y la tarjeta de pausa son componentes normales de Pyzed apilados sobre el canvas con pz.stack, así que reciben el tema, los iconos y el efecto glass gratis:

    hud = pz.row([
    ui.label("Coin Chase", weight="semibold"),
    pz.hspace(),
    ui.badge(f"{self.score} coins", icon="circle-dollar-sign", tint="warning"),
    ui.badge(f"{math.ceil(self.time_left)} s", icon="timer",
    tint="error" if self.time_left < 5 else "accent"),
    ], spacing=8, align="center")
    play = pz.stack([self.board(), self.overlay()], width=W, height=H)
  • Haz que el orbe vaya más rápido un segundo después de cada moneda (un temporizador boost que baja en tick).
  • Añade una segunda moneda, más rara, que vale 5 puntos y desaparece a los 3 segundos.
  • Guarda la mejor puntuación en un archivo; el paso 8 enseña cómo.
examples/games/coin_chase.py
"""Coin Chase: your first game. A frame loop, keys held down and a 30-second timer.
python examples/games/coin_chase.py
Arrows or WASD move the orb, collect as many coins as you can in 30 seconds. Space pauses,
Enter plays again. Shows the three parts of every real-time game:
1. **State**: plain attributes on the app (position, score, time left).
2. **Update**: ``tick(dt)``, called by ``pz.frames`` once per displayed frame, moves things by
*speed x dt* and checks what happened.
3. **Draw**: ``view()`` turns the state into a canvas, with the HUD as ordinary widgets.
Tutorial: https://albakore.github.io/pyzed/games/first-game/
"""
import math
import random
import pyzed as pz
from pyzed import draw, game, ui
W, H = 560, 380
SPEED = 260 # pixels per second
PLAYER_R = 14
COIN_R = 9
ROUND = 30.0 # seconds per round
class CoinChase(pz.App):
title = "Coin Chase"
size = (W + 32, H + 92)
theme = "One Dark"
def __init__(self, seed=None):
self.random = random.Random(seed)
#: The keys held down; ``None`` reads the keyboard. Tests and demos set it to steer.
self.held = None
self.best = 0
self.reset()
# -- 1. state ---------------------------------------------------------------------------
def reset(self):
self.x, self.y = W / 2, H / 2
self.score = 0
self.time_left = ROUND
self.paused = self.over = False
self.pops = [] # [x, y, age]: rings that grow and fade where a coin was taken
self.clock = 0.0 # seconds since the round started (for the coin's spin)
self.coin = self.new_coin()
def new_coin(self):
"""A coin somewhere not too close to the player."""
while True:
x = self.random.uniform(30, W - 30)
y = self.random.uniform(30, H - 30)
if math.hypot(x - self.x, y - self.y) > 120:
return (x, y)
# -- 2. update --------------------------------------------------------------------------
def subscriptions(self):
# No frames while paused or over: the game stops and so does the CPU.
if self.paused or self.over:
return []
return [pz.frames(self.tick)]
def tick(self, dt):
self.clock += dt
self.time_left = max(0.0, self.time_left - dt)
# Move by speed x dt: the same speed at 60, 120 or 144 frames per second.
dx, dy = game.vector(held=self.held) # arrows / WASD, diagonals normalized
self.x = game.clamp(self.x + dx * SPEED * dt, PLAYER_R, W - PLAYER_R)
self.y = game.clamp(self.y + dy * SPEED * dt, PLAYER_R, H - PLAYER_R)
# Two circles touch when the distance between centers is less than the radii added.
cx, cy = self.coin
if math.hypot(cx - self.x, cy - self.y) < PLAYER_R + COIN_R:
self.score += 1
self.pops.append([cx, cy, 0.0])
self.coin = self.new_coin()
for pop in self.pops:
pop[2] += dt
self.pops = [p for p in self.pops if p[2] < 0.4]
if self.time_left <= 0:
self.over = True
self.best = max(self.best, self.score)
def on_key(self, combo):
if combo == "space" and not self.over:
self.paused = not self.paused
elif combo == "enter" and self.over:
self.reset()
# -- 3. draw ----------------------------------------------------------------------------
def board(self):
shapes = [draw.background("#0b0e14")]
for gx in range(0, W, 40): # a faint grid, to feel the motion
shapes.append(draw.line(gx, 0, gx, H, stroke="#ffffff08"))
for gy in range(0, H, 40):
shapes.append(draw.line(0, gy, W, gy, stroke="#ffffff08"))
cx, cy = self.coin
spin = abs(math.cos(self.clock * 4)) # squash the coin's width: it seems to spin
shapes += game.glow(cx, cy, COIN_R * 0.6, "#fbbf24", layers=3, strength=0.5, core=False)
shapes.append(draw.path([("M", cx, cy - COIN_R)] + [
("L", cx + math.sin(a) * COIN_R * max(spin, 0.15), cy - math.cos(a) * COIN_R)
for a in (i * math.pi / 8 for i in range(1, 17))], fill="#fbbf24", stroke="#fde68a", width=1.5))
for x, y, age in self.pops:
k = age / 0.4 # 0 -> 1 over the ring's life
shapes.append(draw.circle(x, y, COIN_R + k * 26, stroke=game.rgba("#fde68a", 1 - k), width=3))
shapes += game.glow(self.x, self.y, PLAYER_R, "#22d3ee", layers=4, strength=0.5)
return pz.canvas(shapes, width=W, height=H)
def overlay(self):
if self.over:
title, lines = "Time!", [f"{self.score} coins · best {self.best}", "Enter plays again"]
elif self.paused:
title, lines = "Paused", ["Space continues"]
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()
hud = pz.row([
ui.label("Coin Chase", weight="semibold"),
pz.hspace(),
ui.badge(f"{self.score} coins", icon="circle-dollar-sign", tint="warning"),
ui.badge(f"{math.ceil(self.time_left)} s", icon="timer",
tint="error" if self.time_left < 5 else "accent"),
ui.badge(f"Best {self.best}", icon="trophy", tint="text_muted"),
], 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("Arrows / WASD move · Space pauses · Enter restarts", size="sm")],
spacing=10),
padding=16, width="fill", height="fill", bg=t.background)
if __name__ == "__main__":
pz.run(CoinChase)