Ir al contenido

3. Colisiones y física

No necesitas un motor de física para hacer un juego de plataformas. Unas pocas líneas te dan velocidad (lo rápido que se mueve algo), gravedad (un tirón constante hacia abajo) y colisiones con cajas. Este paso construye Jump Lab, un laboratorio donde una caja corre y salta entre plataformas mientras unas pelotas rebotan, con una vista de depuración que muestra las cajas de colisión, la velocidad y el arco de cada salto.

Una caja cian corre y salta entre plataformas oscuras; un rastro punteado cian dibuja el arco de cada salto, los bordes de las plataformas se iluminan en amarillo al contacto y tres pelotas de colores rebotan
Jump Lab con la vista de depuración: caja de colisión verde, velocidad rosa, el rastro del último segundo y los contactos en amarillo.
Objetivo Tiempo Nivel Necesitas
Una caja que corre, salta y aterriza en plataformas, y pelotas que rebotan 40 minutos Intermedio 2. Sprites y animación
  • Velocidad y gravedad: la posición cambia con velocidad × dt, la velocidad con gravedad × dt.
  • Colisiones AABB (cajas alineadas con los ejes) con game.Rect.overlaps, resueltas eje por eje.
  • Círculos contra cajas con game.circle_rect, que dice qué lado se golpeó.
  • Sensación de juego: aceleración, altura de salto variable, coyote time y buffer de salto.
  • Una vista de depuración que puedes encender y apagar.
Windows, macOS y Linux
uv run --with pyzed-ui python examples/games/jump_lab.py
Izquierda: una caja verde gira alrededor de una caja gris; cuando se solapan las dos se vuelven rojas, el solape se sombrea en amarillo y una flecha muestra la salida más corta. Derecha: una pelota cae sobre una barra y rebota; una flecha amarilla muestra la normal que devuelve circle_rect
Izquierda: dos cajas se solapan cuando se solapan en los dos ejes; la salida más corta va por el lado menor del solape. Derecha: game.circle_rect devuelve el lado que golpeó la pelota, para hacerla rebotar.
  1. Números que se sienten bien. Toda la física son unas pocas constantes, en píxeles y segundos. Cambiarlas es afinar la sensación:

    RUN = 250 # top running speed, px/s
    ACCEL = 2200 # px/s² on the ground (how fast you reach RUN)
    AIR_ACCEL = 1300 # less control in the air
    GRAVITY = 1900 # px/s², added to vy every second
    JUMP_SPEED = 660 # vy right after a jump (upwards, so negative)
    LOW_JUMP = 2.6 # gravity multiplier while rising without the jump key: short hops
    MAX_FALL = 900
    COYOTE = 0.10 # seconds after leaving a ledge when a jump still works
    BUFFER = 0.12 # seconds a jump press is remembered before landing

    Con estos valores, el salto más alto es JUMP_SPEED² / (2 × GRAVITY) ≈ 115 píxeles. Las pruebas lo comprueban.

  2. Velocidad y gravedad. vx y vy son velocidades en píxeles por segundo (y crece hacia abajo, así que arriba es negativo). En vez de poner vx a la velocidad objetivo de golpe, se acerca a ella como mucho ACCEL × dt por fotograma, lo que da un arranque y una parada cortos y suaves:

    move = game.axis(("left", "a"), ("right", "d"), held=self.held)
    jump_held = game.is_pressed(*JUMP_KEYS, held=self.held)
    target = move * RUN
    accel = (ACCEL if self.on_ground else AIR_ACCEL) * dt
    self.vx += game.clamp(target - self.vx, -accel, accel)
    g = GRAVITY * (LOW_JUMP if self.vy < 0 and not jump_held else 1)
    self.vy = min(self.vy + g * dt, MAX_FALL)

    La segunda línea de la gravedad es la altura de salto variable: mientras sube, soltar la tecla hace la gravedad más fuerte, así que un toque es un saltito y una pulsación larga un salto completo.

  3. Mueve un eje cada vez. Muévete en horizontal y sal de lo que ahora solapes; luego haz lo mismo en vertical. Resolver cada eje por separado es lo que permite deslizarse por una pared al caer y aterrizar justo encima del suelo:

    def move_x(self, dx):
    self.player.x += dx
    for i, p in self.solids_hit(self.player):
    self.player.x = p.left - self.player.w if dx > 0 else p.right
    self.vx = 0.0
    def move_y(self, dy):
    self.player.y += dy
    for i, p in self.solids_hit(self.player):
    if dy > 0: # falling: land on top
    self.player.y = p.top - self.player.h
    self.on_ground = True
    else: # rising: bump the head
    self.player.y = p.bottom
    self.vy = 0.0
    # in tick:
    self.move_x(self.vx * dt)
    self.on_ground = False
    self.move_y(self.vy * dt)

    on_ground solo es cierto cuando el movimiento vertical chocó con algo debajo, así que siempre está al día.

  4. Coyote time y buffer de salto. Dos temporizadores diminutos hacen los saltos indulgentes. El coyote time te deja saltar 0,1 s después de salir corriendo de un borde (como el coyote de los dibujos, que solo cae cuando mira abajo). El buffer de salto recuerda una pulsación hecha justo antes de aterrizar para que no se pierda:

    def press_jump(self): # called by on_key for Space, Up and W
    self.buffer = BUFFER
    # in tick:
    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 = -JUMP_SPEED
    self.buffer = self.coyote = 0.0

    Nadie nota que están, pero todo el mundo nota cuando faltan: el juego parece “comerse” saltos.

  5. Las pelotas rebotan en cajas. game.circle_rect(cx, cy, r, rect) devuelve None, o la dirección desde la caja hacia el círculo por el lado golpeado: (0, -1) desde arriba, (1, 0) desde la derecha. Gira la velocidad hacia ese lado, saca la pelota y quítale un poco de energía:

    normal = game.circle_rect(ball.x, ball.y, ball.r, p)
    if normal:
    nx, ny = normal
    if nx:
    ball.vx = abs(ball.vx) * nx
    ball.x = (p.right + ball.r) if nx > 0 else (p.left - ball.r)
    if ny:
    ball.vy = abs(ball.vy) * ny * 0.82
    ball.y = (p.bottom + ball.r) if ny > 0 else (p.top - ball.r)
  6. Una vista de depuración. Dibujar lo que “ve” el código ahorra horas. Con H el juego dibuja el contorno de las cajas, la velocidad como una línea, las últimas 60 posiciones como puntos que se desvanecen (el arco del salto) y los números:

    self.trail.append(self.player.center)
    del self.trail[:-60]
    ...
    if self.debug:
    shapes.append(p.shape(stroke="#34d399", width=1.5))
    shapes.append(draw.line(cx, cy, cx + self.vx * 0.15, cy + self.vy * 0.15, stroke="#f472b6", width=2.5))
  • Añade una plataforma móvil: un Rect cuya x sigue una onda seno; cuando el jugador está encima, suma su movimiento a la x del jugador.
  • Añade un doble salto: un salto extra en el aire, que se recupera al aterrizar.
  • Ajusta GRAVITY y JUMP_SPEED para un salto más flotante o más seco, y mira cómo cambia el rastro.
examples/games/jump_lab.py
"""Jump Lab: velocity, gravity, box collisions and a jump that feels good.
python examples/games/jump_lab.py
Arrows or A/D run, Space (or Up/W) jumps, hold it to jump higher, H hides the debug view.
A box runs and jumps between platforms while three balls bounce around. The debug view
draws the hitboxes, the velocity and the trail of the last second, so you can see the jump
arc. Shows:
- **Velocity and gravity**: ``vx``/``vy`` in pixels per second; gravity adds to ``vy`` every
frame, and the position moves by ``velocity x dt``.
- **AABB collisions** (axis-aligned bounding boxes, ``game.Rect.overlaps``), resolved one
axis at a time so the box slides along walls and lands on floors.
- **Circle against box** with ``game.circle_rect``, which says which side was hit, to bounce.
- **Game feel**: acceleration, coyote time, jump buffering and variable jump height.
Tutorial: https://albakore.github.io/pyzed/games/collisions-physics/
"""
import random
import pyzed as pz
from pyzed import draw, game, ui
W, H = 720, 400
RUN = 250 # top running speed, px/s
ACCEL = 2200 # px/s² on the ground (how fast you reach RUN)
AIR_ACCEL = 1300 # less control in the air
GRAVITY = 1900 # px/s², added to vy every second
JUMP_SPEED = 660 # vy right after a jump (upwards, so negative)
LOW_JUMP = 2.6 # gravity multiplier while rising without the jump key: short hops
MAX_FALL = 900
COYOTE = 0.10 # seconds after leaving a ledge when a jump still works
BUFFER = 0.12 # seconds a jump press is remembered before landing
JUMP_KEYS = ("space", "up", "w")
PLATFORMS = [
game.Rect(0, H - 36, W, 36), # the floor
game.Rect(120, 280, 140, 16),
game.Rect(330, 214, 130, 16),
game.Rect(520, 150, 150, 16),
game.Rect(560, H - 116, 40, 80), # a wall to bump into
game.Rect(0, 0, 12, H), game.Rect(W - 12, 0, 12, H), # side walls
]
class Ball:
def __init__(self, x, y, vx, vy, r, color):
self.x, self.y, self.vx, self.vy, self.r, self.color = x, y, vx, vy, r, color
class JumpLab(pz.App):
title = "Jump Lab"
size = (W + 32, H + 92)
theme = "One Dark"
def __init__(self, seed=None):
rnd = random.Random(seed)
self.held = None # the keys held down; None reads the keyboard
self.debug = True
self.player = game.Rect(60, H - 36 - 34, 26, 34)
self.vx = self.vy = 0.0
self.on_ground = False
self.coyote = 0.0 # time left to jump after walking off a ledge
self.buffer = 0.0 # time left of a remembered jump press
self.trail = [] # the player's center, one point per frame
self.hits = {} # platform index -> seconds to keep it highlighted
self.balls = [Ball(rnd.uniform(100, 600), rnd.uniform(40, 120), rnd.choice((-1, 1)) * rnd.uniform(120, 220),
0.0, r, color)
for r, color in ((10, "#f472b6"), (14, "#a78bfa"), (8, "#34d399"))]
# -- input ------------------------------------------------------------------------------
def on_key(self, combo):
if combo in JUMP_KEYS:
self.press_jump()
elif combo == "h":
self.debug = not self.debug
def press_jump(self):
self.buffer = BUFFER # remember the press for a moment (jump buffering)
# -- update -----------------------------------------------------------------------------
def subscriptions(self):
return [pz.frames(self.tick)]
def tick(self, dt):
dt = min(dt, 1 / 30) # big steps would let things pass through walls
move = game.axis(("left", "a"), ("right", "d"), held=self.held)
jump_held = game.is_pressed(*JUMP_KEYS, held=self.held)
# Horizontal: accelerate toward the target speed instead of jumping to it.
target = move * RUN
accel = (ACCEL if self.on_ground else AIR_ACCEL) * dt
self.vx += game.clamp(target - self.vx, -accel, accel)
# Vertical: gravity, stronger while rising without the key held (variable jump height).
g = GRAVITY * (LOW_JUMP if self.vy < 0 and not jump_held else 1)
self.vy = min(self.vy + g * dt, MAX_FALL)
# Coyote time and jump buffer: both are small timers counting down.
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 = -JUMP_SPEED
self.buffer = self.coyote = 0.0
# Move one axis at a time and push out of whatever we hit.
self.move_x(self.vx * dt)
self.on_ground = False
self.move_y(self.vy * dt)
self.trail.append(self.player.center)
del self.trail[:-60]
self.hits = {i: t - dt for i, t in self.hits.items() if t - dt > 0}
for ball in self.balls:
self.bounce(ball, dt)
def solids_hit(self, rect):
return [(i, p) for i, p in enumerate(PLATFORMS) if rect.overlaps(p)]
def move_x(self, dx):
self.player.x += dx
for i, p in self.solids_hit(self.player):
self.player.x = p.left - self.player.w if dx > 0 else p.right
self.vx = 0.0
self.hits[i] = 0.2
def move_y(self, dy):
self.player.y += dy
for i, p in self.solids_hit(self.player):
if dy > 0: # falling: land on top
self.player.y = p.top - self.player.h
self.on_ground = True
else: # rising: bump the head
self.player.y = p.bottom
self.vy = 0.0
self.hits[i] = 0.2
def bounce(self, ball, dt):
ball.vy += GRAVITY * 0.5 * dt
ball.x += ball.vx * dt
ball.y += ball.vy * dt
for i, p in enumerate(PLATFORMS):
normal = game.circle_rect(ball.x, ball.y, ball.r, p)
if normal:
nx, ny = normal
if nx:
ball.vx = abs(ball.vx) * nx # reflect the speed on that side
ball.x = (p.right + ball.r) if nx > 0 else (p.left - ball.r)
if ny:
ball.vy = abs(ball.vy) * ny * 0.82 # lose a little energy on each bounce
ball.y = (p.bottom + ball.r) if ny > 0 else (p.top - ball.r)
if ny < 0 and abs(ball.vy) < 120: # nearly still: kick it again
ball.vy = -620
self.hits[i] = 0.2
# -- draw -------------------------------------------------------------------------------
def board(self):
shapes = [draw.background("#0f141c")]
for i, p in enumerate(PLATFORMS):
lit = i in self.hits and self.debug
shapes.append(p.shape(fill="#2a3446", radius=3))
shapes.append(draw.rect(p.x, p.y, p.w, 3, fill="#4b5d7c" if not lit else "#fbbf24"))
if self.debug:
shapes.append(p.shape(stroke="#fbbf24" if lit else "#ffffff1f", width=1.5 if lit else 1))
for ball in self.balls:
shapes += game.glow(ball.x, ball.y, ball.r, ball.color, layers=2, strength=0.35)
if self.debug:
shapes.append(draw.line(ball.x, ball.y, ball.x + ball.vx * 0.08, ball.y + ball.vy * 0.08,
stroke=game.rgba(ball.color, 0.7), width=1.5))
if self.debug and len(self.trail) > 1: # the trail: fading dots, one per frame
n = len(self.trail)
for i, (x, y) in enumerate(self.trail[::2]):
a = (i * 2 + 1) / n
shapes.append(draw.circle(x, y, 2.2, fill=game.rgba("#22d3ee", a * 0.8)))
p = self.player
squash = 1 + game.clamp(abs(self.vy) / MAX_FALL, 0, 1) * 0.15 if not self.on_ground else 1
w, h = p.w / squash, p.h * squash # stretch while flying fast
shapes.append(draw.rect(p.center[0] - w / 2, p.bottom - h, w, h, radius=6, fill="#22d3ee"))
eye = 1 if self.vx >= 0 else -1
shapes.append(draw.rect(p.center[0] + eye * 4 - 3, p.bottom - h + 9, 6, 7, radius=2, fill="#0b0e14"))
if self.debug:
cx, cy = p.center
shapes.append(p.shape(stroke="#34d399", width=1.5))
shapes.append(draw.line(cx, cy, cx + self.vx * 0.15, cy + self.vy * 0.15, stroke="#f472b6", width=2.5,
cap="round"))
info = (f"vx {self.vx:6.0f} vy {self.vy:6.0f} ground {'yes' if self.on_ground else 'no '} "
f"coyote {self.coyote:.2f} buffer {self.buffer:.2f}")
shapes.append(draw.rect(16, 16, 520, 24, radius=6, fill="#00000088"))
shapes.append(draw.text(26, 21, info, size=12, font="mono", color="#cbd5e1"))
return pz.canvas(shapes, width=W, height=H)
def view(self):
t = self.resolved_theme()
hud = pz.row([ui.label("Jump Lab", weight="semibold"), pz.hspace(),
ui.badge("on the ground" if self.on_ground else "in the air",
icon="footprints" if self.on_ground else "wind",
tint="success" if self.on_ground else "accent"),
ui.badge("debug on" if self.debug else "debug off", icon="bug", tint="text_muted")],
spacing=8, align="center")
return pz.container(pz.column([hud, pz.container(self.board(), radius=10, clip=True),
ui.muted("←/→ run · Space jumps (hold for higher) · H toggles the debug view",
size="sm")], spacing=10),
padding=16, width="fill", height="fill", bg=t.background)
if __name__ == "__main__":
pz.run(JumpLab)