Ir al contenido

2. Sprites y animación

En un juego las cosas casi nunca solo se deslizan: los personajes caminan, parpadean y aterrizan; las monedas flotan y estallan. Dos técnicas hacen casi todo. La animación por fotogramas muestra una serie de dibujos uno tras otro, como un folioscopio. El tweening lleva un número (una posición, un tamaño, una opacidad) de un valor a otro con el tiempo, con la forma de una curva de easing. Este paso construye Walker, un héroe pixel art que usa las dos.

Un pequeño héroe pixel art camina a derecha e izquierda sobre el césped y salta para coger monedas brillantes que estallan con un +1 que sube, mientras una tira de fotogramas en la esquina resalta el que está en pantalla
Walker: la tira de arriba a la izquierda es la hoja de sprites de la animación; el fotograma resaltado es el que se dibuja.
Objetivo Tiempo Nivel Necesitas
Un personaje animado con estados de caminar, reposo y salto 35 minutos Principiante 1. Tu primer juego
  • Dibujar pixel art desde texto con game.sprite, y reflejarlo con flip=True.
  • Guardar una hoja de sprites (una lista de fotogramas) y reproducirla a unos pocos fotogramas por segundo.
  • Cambiar de animación con estados (idle, walk, jump).
  • Escribir una pequeña clase Tween y usar curvas de easing para el salto, el aplastamiento al aterrizar, un “+1” y un título.
Windows, macOS y Linux
uv run --with pyzed-ui python examples/games/walker.py
  1. Un sprite es texto. Cada cadena es una fila de píxeles; cada carácter elige un color de una paleta, y cualquier carácter que no esté en ella (como .) es transparente. game.sprite lo convierte en unas pocas formas, una por color, sea cual sea el tamaño del sprite:

    PALETTE = {"h": "#7c4a2d", "s": "#f5c99b", "S": "#e9b385", "e": "#1b1f27", "b": "#3b82f6",
    "B": "#60a5fa", "p": "#334155", "k": "#111827"}
    HEAD = [
    "...hhhh...",
    "..hhhhhh..",
    "..hhssss..",
    "..hsssess.",
    "..hsssssS.",
    "...ssss...",
    ]
    BODY = ["..bbBBbb..", ".sbbBBbbs.", ".sbbbbbbs.", "..bbbbbb.."]
    game.sprite(HEAD + BODY + LEGS["stand"], PALETTE, x, y, scale=4) # every pixel becomes 4x4
  2. Una hoja de sprites es una lista de fotogramas. El ciclo de caminar reutiliza la cabeza y el cuerpo y cambia las piernas; la animación de reposo es el mismo fotograma seis veces y un parpadeo, así que el héroe parpadea cada par de segundos:

    LEGS = {
    "stand": ["..pp..pp..", "..pp..pp..", "..kk..kk.."],
    "apart": ["..pp..pp..", ".pp....pp.", ".kk.....kk"],
    "pass": ["...pp.pp..", "...pp.pp..", "...kkkkk.."],
    "air": ["..pp..pp..", "..ppp.ppp.", "....k...k."],
    }
    IDLE = [HEAD + BODY + LEGS["stand"]] * 6 + [BLINK + BODY + LEGS["stand"]]
    WALK = [HEAD + BODY + LEGS["apart"], HEAD + BODY + LEGS["pass"],
    HEAD + BODY + LEGS["apart"], HEAD + BODY + LEGS["pass"]]
    JUMP = [HEAD + BODY + LEGS["air"]]
  3. Reproduce los fotogramas. Una animación solo necesita contar el tiempo; el fotograma a mostrar es tiempo × fotogramas por segundo, que vuelve al principio con % cuando se repite:

    class FrameAnimation:
    def __init__(self, frames, fps, loop=True):
    self.frames, self.fps, self.loop = frames, fps, loop
    self.time = 0.0
    def update(self, dt):
    self.time += dt
    @property
    def index(self):
    i = int(self.time * self.fps)
    return i % len(self.frames) if self.loop else min(i, len(self.frames) - 1)
    @property
    def frame(self):
    return self.frames[self.index]
  4. Elige la animación según el estado. En cada fotograma el héroe está en reposo, caminando o saltando. Cuando el estado cambia, la nueva animación empieza por su primer fotograma, y la dirección refleja el sprite:

    self.animations = {"idle": FrameAnimation(IDLE, 3), "walk": FrameAnimation(WALK, 10),
    "jump": FrameAnimation(JUMP, 1)}
    def set_state(self, state):
    if state != self.state:
    self.state = state
    self.animations[state].time = 0.0
    # in tick(dt):
    move = game.axis(("left", "a"), ("right", "d"), held=self.held)
    if move:
    self.facing = move
    self.set_state("jump" if self.jump else "walk" if move else "idle")
    self.animation.update(dt)
    # in board():
    game.sprite(self.animation.frame, PALETTE, x, y, scale=PIXEL, flip=self.facing < 0)
  5. Tweens: de A a B con el tiempo. Un tween recuerda cuándo empezó y calcula su valor a partir del progreso (de 0 a 1) pasado por una función de easing. El easing es lo que hace natural el movimiento: las cosas casi nunca arrancan y paran a toda velocidad.

    def ease_out_cubic(t):
    return 1 - (1 - t) ** 3
    def ease_out_back(t): # goes a little past the end, then settles
    c = 1.70158
    return 1 + (c + 1) * (t - 1) ** 3 + c * (t - 1) ** 2
    class Tween:
    def __init__(self, start, end, duration, ease=ease_out_cubic, delay=0.0):
    self.start, self.end, self.duration, self.ease = start, end, duration, ease
    self.time = -delay
    def update(self, dt):
    self.time += dt
    @property
    def progress(self):
    return game.clamp(self.time / self.duration, 0.0, 1.0)
    @property
    def done(self):
    return self.time >= self.duration
    @property
    def value(self):
    return game.lerp(self.start, self.end, self.ease(self.progress))
    Cuatro puntos brillantes cruzan de izquierda a derecha en el mismo segundo: linear avanza uniforme, ease_out_cubic sale rápido y frena, ease_in_out_sine acelera y frena, ease_out_back se pasa y vuelve
    El mismo tween con cuatro curvas de easing. Los puntos que se desvanecen detrás muestran su velocidad.
  6. Un salto también es un tween. El progreso del salto va de 0 a 1 en JUMP_TIME segundos; la altura es una parábola, 4t(1 − t), que vale 0 en los extremos y 1 en el medio:

    def start_jump(self):
    if not self.jump:
    self.jump = Tween(0.0, 1.0, JUMP_TIME, linear)
    @property
    def height(self):
    if not self.jump:
    return 0.0
    t = self.jump.value
    return 4 * t * (1 - t) * JUMP_HEIGHT

    En el paso 3 lo cambiarás por gravedad real, que puede aterrizar en plataformas; un tween es perfecto cuando el movimiento siempre es el mismo.

  7. Squash and stretch. Cuando el salto termina, un segundo tween va de 1 a 0 en un cuarto de segundo. Mientras dura, el sprite se dibuja más ancho y más bajo alrededor de sus pies, con una transformación scale:

    s = self.squash.value if self.squash else 0.0
    sx, sy = 1 + 0.25 * s, 1 - 0.2 * s
    feet_x, feet_y = self.x + SPRITE_W / 2, GROUND - self.height
    shapes += [draw.save(), draw.translate(feet_x, feet_y), draw.scale(sx, sy),
    *game.sprite(self.animation.frame, PALETTE, -SPRITE_W / 2, -SPRITE_H, scale=PIXEL,
    flip=self.facing < 0),
    draw.restore()]

    Llevar primero el origen a los pies (translate) hace que el sprite se aplaste contra el suelo y no hacia su esquina superior izquierda.

  8. Pequeños tweens por todas partes. El “+1” sobre una moneda sube 40 píxeles y se desvanece en 0,7 s; el título entra con ease_out_back 0,2 s después de empezar; las monedas flotan con una onda seno:

    self.popups.append([cx, cy, Tween(0.0, 1.0, 0.7, ease_out_cubic)])
    ...
    for x, y, tween in self.popups:
    shapes.append(draw.text(x, y - 40 * tween.value, "+1", size=18, weight="bold",
    color=game.rgba("#fde68a", 1 - tween.progress), align_x="center"))
  • Añade una animación de correr (6 fotogramas a 14 fps) que se use al mantener Shift, con más WALK_SPEED.
  • Haz girar las monedas estrechando su ancho con abs(math.cos(time)), como hace Coin Chase.
  • Dale al título un balanceo con ease_in_out_sine después de aterrizar.
examples/games/walker.py
"""Walker: sprites, frame animation and tweening.
python examples/games/walker.py
Arrows or A/D walk, Space jumps, F shows or hides the sprite sheet. A pixel-art hero
walks, blinks, jumps and squashes when it lands; coins bob and pop with a "+1" that
eases up and fades. Shows:
- **Sprites from text** with ``game.sprite`` (one string per row, one character per pixel),
flipped with ``flip=True`` when the hero faces left.
- **Frame animation**: a list of frames (a sprite sheet) played at a few frames per second
by ``FrameAnimation``, which only counts time.
- **Tweening**: a value going from A to B over a duration with an easing curve (``Tween``),
for the jump, the landing squash, the "+1" and the title that slides in.
Tutorial: https://albakore.github.io/pyzed/games/sprites-animation/
"""
import math
import pyzed as pz
from pyzed import draw, game, ui
W, H = 640, 300
GROUND = 236
PIXEL = 4
WALK_SPEED = 170
JUMP_HEIGHT = 70
JUMP_TIME = 0.55
PALETTE = {"h": "#7c4a2d", "s": "#f5c99b", "S": "#e9b385", "e": "#1b1f27", "b": "#3b82f6", "B": "#60a5fa",
"p": "#334155", "k": "#111827"} # "." (or any character not here) is transparent
# -- the sprite sheet: every frame is a list of rows, every character a pixel ---------------
HEAD = [
"...hhhh...",
"..hhhhhh..",
"..hhssss..",
"..hsssess.",
"..hsssssS.",
"...ssss...",
]
BLINK = HEAD[:3] + ["..hsssshs."] + HEAD[4:] # the eye closed for a frame
BODY = [
"..bbBBbb..",
".sbbBBbbs.",
".sbbbbbbs.",
"..bbbbbb..",
]
LEGS = {
"stand": ["..pp..pp..", "..pp..pp..", "..kk..kk.."],
"apart": ["..pp..pp..", ".pp....pp.", ".kk.....kk"],
"pass": ["...pp.pp..", "...pp.pp..", "...kkkkk.."],
"air": ["..pp..pp..", "..ppp.ppp.", "....k...k."],
}
IDLE = [HEAD + BODY + LEGS["stand"]] * 6 + [BLINK + BODY + LEGS["stand"]] # blink once in a while
WALK = [HEAD + BODY + LEGS["apart"], HEAD + BODY + LEGS["pass"],
HEAD + BODY + LEGS["apart"], HEAD + BODY + LEGS["pass"]]
JUMP = [HEAD + BODY + LEGS["air"]]
SPRITE_W = len(HEAD[0]) * PIXEL
SPRITE_H = len(IDLE[0]) * PIXEL
# -- easing: how a value moves from 0 to 1 ----------------------------------------------------
def linear(t):
return t
def ease_out_cubic(t):
return 1 - (1 - t) ** 3
def ease_out_back(t):
c = 1.70158
return 1 + (c + 1) * (t - 1) ** 3 + c * (t - 1) ** 2
def ease_in_out_sine(t):
return -(math.cos(math.pi * t) - 1) / 2
class Tween:
"""A number going from ``start`` to ``end`` in ``duration`` seconds, shaped by ``ease``."""
def __init__(self, start, end, duration, ease=ease_out_cubic, delay=0.0):
self.start, self.end, self.duration, self.ease = start, end, duration, ease
self.time = -delay
def update(self, dt):
self.time += dt
@property
def progress(self):
return game.clamp(self.time / self.duration, 0.0, 1.0)
@property
def done(self):
return self.time >= self.duration
@property
def value(self):
return game.lerp(self.start, self.end, self.ease(self.progress))
class FrameAnimation:
"""Plays a list of frames at ``fps`` frames per second, looping or not."""
def __init__(self, frames, fps, loop=True):
self.frames, self.fps, self.loop = frames, fps, loop
self.time = 0.0
def update(self, dt):
self.time += dt
@property
def index(self):
i = int(self.time * self.fps)
return i % len(self.frames) if self.loop else min(i, len(self.frames) - 1)
@property
def frame(self):
return self.frames[self.index]
class Walker(pz.App):
title = "Walker"
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.show_sheet = True
self.x = 120.0
self.facing = 1 # 1 right, -1 left
self.clock = 0.0
self.animations = {"idle": FrameAnimation(IDLE, 3), "walk": FrameAnimation(WALK, 10),
"jump": FrameAnimation(JUMP, 1)}
self.state = "idle"
self.jump = None # a Tween from 0 to 1 while in the air
self.squash = None # a Tween from 1 back to 0 after landing
self.coins = [{"x": 260 + i * 70, "high": i % 2 == 1, "taken": None} for i in range(5)]
self.popups = [] # [x, y, Tween] "+1" labels
self.title_in = Tween(-80, 18, 0.8, ease_out_back, delay=0.2)
self.score = 0
@property
def animation(self):
return self.animations[self.state]
def set_state(self, state):
if state != self.state:
self.state = state
self.animations[state].time = 0.0 # start the new animation from its first frame
# -- update -------------------------------------------------------------------------------
def subscriptions(self):
return [pz.frames(self.tick)]
def tick(self, dt):
self.clock += dt
self.title_in.update(dt)
move = game.axis(("left", "a"), ("right", "d"), held=self.held)
if move:
self.facing = move
self.x = game.clamp(self.x + move * WALK_SPEED * dt, 10, W - SPRITE_W - 10)
if self.held is not None and "space" in self.held:
self.start_jump()
if self.jump:
self.jump.update(dt)
if self.jump.done:
self.jump = None
self.squash = Tween(1.0, 0.0, 0.25, ease_out_cubic) # land: squash, then recover
if self.squash:
self.squash.update(dt)
if self.squash.done:
self.squash = None
self.set_state("jump" if self.jump else "walk" if move else "idle")
self.animation.update(dt)
self.collect()
for popup in self.popups:
popup[2].update(dt)
self.popups = [p for p in self.popups if not p[2].done]
def start_jump(self):
if not self.jump:
self.jump = Tween(0.0, 1.0, JUMP_TIME, linear)
@property
def height(self):
"""How high the hero is: a parabola, 4t(1-t), peaks at JUMP_HEIGHT halfway through."""
if not self.jump:
return 0.0
t = self.jump.value
return 4 * t * (1 - t) * JUMP_HEIGHT
def collect(self):
hero = game.Rect(self.x + 8, GROUND - SPRITE_H - self.height, SPRITE_W - 16, SPRITE_H)
for coin in self.coins:
if coin["taken"] is not None:
if self.clock - coin["taken"] > 3: # coins come back after three seconds
coin["taken"] = None
continue
cx, cy = coin["x"], self.coin_y(coin)
if hero.overlaps(game.Rect(cx - 9, cy - 9, 18, 18)):
coin["taken"] = self.clock
self.score += 1
self.popups.append([cx, cy, Tween(0.0, 1.0, 0.7, ease_out_cubic)])
def coin_y(self, coin):
base = GROUND - (110 if coin["high"] else 40)
return base + math.sin(self.clock * 3 + coin["x"] * 0.05) * 5 # bob with a sine wave
def on_key(self, combo):
if combo in ("space", "up", "w"):
self.start_jump()
elif combo == "f":
self.show_sheet = not self.show_sheet
# -- draw ---------------------------------------------------------------------------------
def board(self):
shapes = [draw.background("#101521")]
for i, (color, y) in enumerate((("#1a2236", 170), ("#202a40", 196))): # far hills
shapes += [draw.circle(80 + j * 190 + i * 90, y + 70, 110, fill=color) for j in range(4)]
shapes.append(draw.rect(0, GROUND, W, H - GROUND, fill="#1f2a1d"))
shapes.append(draw.rect(0, GROUND, W, 6, fill="#4d7c3a"))
for coin in self.coins:
if coin["taken"] is None:
cx, cy = coin["x"], self.coin_y(coin)
shapes += game.glow(cx, cy, 8, "#fbbf24", layers=3, strength=0.45)
shapes.append(draw.circle(cx, cy, 4, fill="#fde68a"))
# shadow: smaller and fainter the higher the hero is
k = 1 - self.height / JUMP_HEIGHT * 0.5
shapes.append(draw.rect(self.x + SPRITE_W / 2 - 18 * k, GROUND - 3, 36 * k, 6, radius=3,
fill=game.rgba("#000000", 0.35 * k)))
# squash and stretch: wider and shorter right after landing, around the feet
s = self.squash.value if self.squash else 0.0
sx, sy = 1 + 0.25 * s, 1 - 0.2 * s
feet_x, feet_y = self.x + SPRITE_W / 2, GROUND - self.height
shapes += [draw.save(), draw.translate(feet_x, feet_y), draw.scale(sx, sy),
*game.sprite(self.animation.frame, PALETTE, -SPRITE_W / 2, -SPRITE_H, scale=PIXEL,
flip=self.facing < 0),
draw.restore()]
for x, y, tween in self.popups:
shapes.append(draw.text(x, y - 40 * tween.value, "+1", size=18, weight="bold",
color=game.rgba("#fde68a", 1 - tween.progress), align_x="center"))
shapes.append(draw.text(W / 2, self.title_in.value, "WALKER", size=26, weight="bold", color="#e2e8f0",
align_x="center"))
if self.show_sheet:
shapes += self.sheet()
return pz.canvas(shapes, width=W, height=H)
def sheet(self):
"""The current animation's frames, small, with the one on screen highlighted."""
frames = self.animation.frames
unique = []
for f in frames:
if not unique or unique[-1] is not f:
unique.append(f)
scale, gap = 2, 6
fw, fh = len(HEAD[0]) * scale, len(IDLE[0]) * scale
x0, y0 = 12, 12
shapes = [draw.rect(x0 - 6, y0 - 6, max(len(frames) * (fw + gap) + 6, 160), fh + 30, radius=8,
fill="#00000066")]
for i, frame in enumerate(frames):
x = x0 + i * (fw + gap)
current = i == self.animation.index
shapes.append(draw.rect(x - 2, y0 - 2, fw + 4, fh + 4, radius=3,
stroke="#fbbf24" if current else "#ffffff22", width=1.5 if current else 1))
shapes += game.sprite(frame, PALETTE, x, y0, scale=scale)
shapes.append(draw.text(x0, y0 + fh + 8, f"{self.state} · frame {self.animation.index + 1}/{len(frames)}",
size=11, color="#cbd5e1", font="mono"))
return shapes
def view(self):
t = self.resolved_theme()
hud = pz.row([ui.label("Walker", weight="semibold"), pz.hspace(),
ui.badge(self.state, icon="film", tint="accent"),
ui.badge(f"{self.score} coins", icon="coins", tint="warning")], spacing=8, align="center")
return pz.container(pz.column([hud, pz.container(self.board(), radius=10, clip=True),
ui.muted("←/→ or A/D walk · Space jumps · F shows the sprite sheet",
size="sm")], spacing=10),
padding=16, width="fill", height="fill", bg=t.background)
if __name__ == "__main__":
pz.run(Walker)