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.

| Objetivo | Tiempo | Nivel | Necesitas |
|---|---|---|---|
| Un personaje animado con estados de caminar, reposo y salto | 35 minutos | Principiante | 1. Tu primer juego |
Qué aprenderás
Sección titulada «Qué aprenderás»- Dibujar pixel art desde texto con
game.sprite, y reflejarlo conflip=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
Tweeny usar curvas de easing para el salto, el aplastamiento al aterrizar, un “+1” y un título.
uv run --with pyzed-ui python examples/games/walker.pypython3 -m venv .venvsource .venv/bin/activatepython -m pip install pyzed-uipython examples/games/walker.pypy -m venv .venv.venv\Scripts\Activate.ps1python -m pip install pyzed-uipython examples\games\walker.pypython3 examples/games/walker.pypy examples\games\walker.py-
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.spritelo 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 -
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"]] -
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, loopself.time = 0.0def update(self, dt):self.time += dt@propertydef index(self):i = int(self.time * self.fps)return i % len(self.frames) if self.loop else min(i, len(self.frames) - 1)@propertydef frame(self):return self.frames[self.index] -
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 = stateself.animations[state].time = 0.0# in tick(dt):move = game.axis(("left", "a"), ("right", "d"), held=self.held)if move:self.facing = moveself.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) -
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) ** 3def ease_out_back(t): # goes a little past the end, then settlesc = 1.70158return 1 + (c + 1) * (t - 1) ** 3 + c * (t - 1) ** 2class 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, easeself.time = -delaydef update(self, dt):self.time += dt@propertydef progress(self):return game.clamp(self.time / self.duration, 0.0, 1.0)@propertydef done(self):return self.time >= self.duration@propertydef value(self):return game.lerp(self.start, self.end, self.ease(self.progress))
El mismo tween con cuatro curvas de easing. Los puntos que se desvanecen detrás muestran su velocidad. -
Un salto también es un tween. El progreso del salto va de 0 a 1 en
JUMP_TIMEsegundos; 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)@propertydef height(self):if not self.jump:return 0.0t = self.jump.valuereturn 4 * t * (1 - t) * JUMP_HEIGHTEn 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.
-
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.0sx, sy = 1 + 0.25 * s, 1 - 0.2 * sfeet_x, feet_y = self.x + SPRITE_W / 2, GROUND - self.heightshapes += [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. -
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_back0,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"))
Prueba esto
Sección titulada «Prueba esto»- 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_sinedespués de aterrizar.
Código completo
Sección titulada «Código completo»"""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 herowalks, blinks, jumps and squashes when it lands; coins bob and pop with a "+1" thateases 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 pzfrom pyzed import draw, game, ui
W, H = 640, 300GROUND = 236PIXEL = 4WALK_SPEED = 170JUMP_HEIGHT = 70JUMP_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 frameBODY = [ "..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 whileWALK = [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]) * PIXELSPRITE_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)