1import { SpriteCanvas } from "@/components/ui/pxl/sprite-canvas";2
3export default function SpriteCanvasPreview() {4 return (5 <div className="w-full min-h-23 flex items-center justify-center">6 <SpriteCanvas7 src="https://raw.githubusercontent.com/pxl-ui/registry/main/app/public/sprites/tiny_chick.png"8 animation="Idle"9 size="lg"10 atlas={{11 frames: {12 "0": { frame: { x: 0, y: 0, w: 16, h: 16 }, },13 "1": { frame: { x: 16, y: 0, w: 16, h: 16 }, },14 "2": { frame: { x: 32, y: 0, w: 16, h: 16 }, },15 "3": { frame: { x: 48, y: 0, w: 16, h: 16 }, },16 },17 meta: { frameTags: [ { name: "Idle", from: 0, to: 3, }, ], },18 }}19 />20 </div>21 );22}Installation
Section titled “Installation”pnpm dlx shadcn@latest add pxl-ui/registry/sprite-canvas
Copy and paste the following code into your project.
1import { type CSSProperties, forwardRef, useImperativeHandle } from "react";2
3import { type PlayFn, useSprite } from "@/hooks/pxl/use-sprite";4import {5 type AnimationEffect,6 type Atlas,7 getNativeFrameSize,8 SPRITE_SIZE_MAP,9 type SpriteSize,10} from "@/lib/pxl/sprite";11
12export type SpriteCanvasHandle = {13 play: PlayFn;14 loadSprite: (src: string, atlas?: Atlas) => void;15};16
17type Props = {18 /** Spritesheet image url. */19 src: string | string[];20 /** Aseprite atlas spritesheet definition (frames + frameTags). */21 atlas: Atlas;22 /** Size of the sprite. By default, the native size from the atlas. */23 size?: SpriteSize;24 /** frameTag name from the atlas to play in loop. */25 animation?: string;26 animationPayload?: unknown;27 /** static frame to display if there's no animation. */28 frame?: string;29 /** animation to go back after playing other animations. */30 idleTag?: string;31 /** ad-hoc animations registered for this canvas. */32 effects?: AnimationEffect<unknown, unknown>[];33 className?: string;34 style?: CSSProperties;35};36
37export const SpriteCanvas = forwardRef<SpriteCanvasHandle, Props>(38 function SpriteCanvas(39 {40 src,41 atlas,42 size,43 animation,44 animationPayload,45 frame,46 idleTag,47 effects,48 className,49 style,50 },51 ref,52 ) {53 const nativeSize = getNativeFrameSize(atlas, frame);54 const pixelSize = size55 ? SPRITE_SIZE_MAP[size]56 : Math.max(nativeSize.w, nativeSize.h);57
58 const { canvasRef, play, loadSprite } = useSprite({59 src,60 atlas,61 frame,62 animation,63 animationPayload,64 idleTag,65 effects,66 size: pixelSize,67 });68
69 useImperativeHandle(ref, () => ({ play, loadSprite }), [play, loadSprite]);70
71 return (72 <canvas73 ref={canvasRef}74 className={className}75 style={{76 width: pixelSize,77 height: pixelSize,78 imageRendering: "pixelated",79 ...style,80 }}81 />82 );83 },84);85
86SpriteCanvas.displayName = "SpriteCanvas";Update the import paths to match your project setup.
import { SpriteCanvas } from "@/components/ui/pxl/sprite-canvas"<SpriteCanvas src="https://raw.githubusercontent.com/pxl-ui/registry/main/app/public/sprites/tiny_chick.png" animation="Idle" size="lg" atlas={{ frames: { "0": { frame: { x: 0, y: 0, w: 16, h: 16 }, }, "1": { frame: { x: 16, y: 0, w: 16, h: 16 }, }, "2": { frame: { x: 32, y: 0, w: 16, h: 16 }, }, "3": { frame: { x: 48, y: 0, w: 16, h: 16 }, }, }, meta: { frameTags: [ { name: "Idle", from: 0, to: 3, }, ], }, }}/>Use the size prop to change the size of the button.
ExtraSmall
Small
Medium
Large
ExtraLarge
1import { Fragment } from "react";2
3import { SpriteCanvas } from "@/components/ui/pxl/sprite-canvas";4import type { SpriteSize } from "@/lib/pxl/sprite";5
6export default function Sizes() {7 const sizes: Record<SpriteSize, string> = {8 xs: "ExtraSmall",9 sm: "Small",10 md: "Medium",11 lg: "Large",12 xl: "ExtraLarge",13 };14
15 return (16 <div className="w-full min-h-92 flex flex-col gap-2.5 items-start justify-center">17 <div>18 {Object.entries(sizes).map(([size, label]) => (19 <Fragment key={size}>20 <h2>{label}</h2>21 <SpriteCanvas22 src="https://raw.githubusercontent.com/pxl-ui/registry/main/app/public/sprites/tiny_chick.png"23 animation="Idle"24 size={size as SpriteSize}25 atlas={{26 frames: {27 "0": { frame: { x: 0, y: 0, w: 16, h: 16 }, },28 "1": { frame: { x: 16, y: 0, w: 16, h: 16 }, },29 "2": { frame: { x: 32, y: 0, w: 16, h: 16 }, },30 "3": { frame: { x: 48, y: 0, w: 16, h: 16 }, },31 },32 meta: { frameTags: [ { name: "Idle", from: 0, to: 3, }, ], },33 }}34 />35 </Fragment>36 ))}37 </div>38 </div>39 );40}Multiple Layers
Section titled “Multiple Layers”You can pass multiple urls to compose a multi-layered sprite
1import { SpriteCanvas } from "@/components/ui/pxl/sprite-canvas";2
3export default function MultiLayer() {4 return (5 <div className="w-full min-h-23 flex items-center justify-center">6 <SpriteCanvas7 src={[8 "https://raw.githubusercontent.com/central-factory/Universal-LPC-spritesheet/refs/heads/new-api/assets2/characters/humanoids/bodies/human/male/ivory/Universal.png",9 "https://raw.githubusercontent.com/central-factory/Universal-LPC-spritesheet/refs/heads/new-api/assets2/characters/humanoids/bodyParts/hair/male/long/blonde2/Universal.png",10 "https://raw.githubusercontent.com/central-factory/Universal-LPC-spritesheet/refs/heads/new-api/assets2/characters/humanoids/bodyParts/eyes/male/blue/Universal.png",11 "https://raw.githubusercontent.com/central-factory/Universal-LPC-spritesheet/refs/heads/new-api/assets2/characters/humanoids/bodyParts/facialHair/male/beard/blonde2/Universal.png",12 "https://raw.githubusercontent.com/central-factory/Universal-LPC-spritesheet/refs/heads/new-api/assets2/characters/humanoids/clothes/torso/male/shirt/formal/white/Universal.png",13 "https://raw.githubusercontent.com/central-factory/Universal-LPC-spritesheet/refs/heads/new-api/assets2/characters/humanoids/clothes/legs/male/pants/pants/blue/Universal.png",14 "https://raw.githubusercontent.com/central-factory/Universal-LPC-spritesheet/refs/heads/new-api/assets2/characters/humanoids/clothes/belt/male/leather/leather/Universal.png",15 "https://raw.githubusercontent.com/central-factory/Universal-LPC-spritesheet/refs/heads/new-api/assets2/characters/humanoids/clothes/feet/male/shoes/brown/Universal.png"16 ]}17 size="lg"18 animation="Idle_Down"19 atlas={{20 frames: {21 "Idle_Down_0": { frame: { x: 0, y: 640, w: 64, h: 64 } },22 "Idle_Down_1": { frame: { x: 64, y: 640, w: 64, h: 64 } },23 "Idle_Down_2": { frame: { x: 128, y: 640, w: 64, h: 64 } },24 "Idle_Down_3": { frame: { x: 192, y: 640, w: 64, h: 64 } },25 "Idle_Down_4": { frame: { x: 256, y: 640, w: 64, h: 64 } },26 "Idle_Down_5": { frame: { x: 320, y: 640, w: 64, h: 64 } },27 "Idle_Down_6": { frame: { x: 384, y: 640, w: 64, h: 64 } },28 "Idle_Down_7": { frame: { x: 448, y: 640, w: 64, h: 64 } },29 "Idle_Down_8": { frame: { x: 512, y: 640, w: 64, h: 64 } },30 },31 meta: { frameTags: [{ name: "Idle_Down", from: 0, to: 8 }] },32 }}33 />34 </div>35 );36}Extensions
Section titled “Extensions”Choice Animation
Section titled “Choice Animation”A custom choice animation for sprite canvas
1import { useRef } from "react";2
3import { Button } from "@/components/ui/pxl/button";4import {5 SpriteCanvas,6 type SpriteCanvasHandle,7} from "@/components/ui/pxl/sprite-canvas";8import { choiceEffect } from "@/lib/pxl/sprite-animations/choice";9
10export default function ChoiceExample() {11 const ref = useRef<SpriteCanvasHandle>(null);12 function choose() {13 if (!ref.current) {14 return;15 }16
17 ref.current.play("Choice", undefined, () => console.log("end"), {18 src: "/sprites/clucking_chicken.png",19 });20 }21
22 return (23 <div className="flex flex-col gap-4 items-center">24 <SpriteCanvas25 animation="Idle"26 ref={ref}27 src="https://raw.githubusercontent.com/pxl-ui/registry/main/app/public/sprites/tiny_chick.png"28 size="lg"29 effects={[choiceEffect]}30 atlas={{31 frames: {32 "0": { frame: { x: 0, y: 0, w: 16, h: 16 }, },33 "1": { frame: { x: 16, y: 0, w: 16, h: 16 }, },34 "2": { frame: { x: 32, y: 0, w: 16, h: 16 }, },35 "3": { frame: { x: 48, y: 0, w: 16, h: 16 }, },36 },37 meta: { frameTags: [ { name: "Idle", from: 0, to: 3, }, ], },38 }}39 />40 <Button onClick={choose}>Play</Button>41 </div>42 );43}pnpm dlx shadcn@latest add pxl-ui/registry/sprite-animation-choice
Copy and paste the following code into your project.
1import type { AnimationEffect } from "@/lib/pxl/sprite";2
3export type ChoicePayload = {4 /** Tag a reproducir durante la celebración. Por defecto "Action". */5 celebrateTag?: string;6 /** Tag al que volver al terminar. Por defecto "Idle". */7 idleTag?: string;8}9
10type ConfettiParticle = {11 x: number;12 y: number;13 vx: number;14 vy: number;15 maxLife: number;16 size: number;17 color: string;18}19
20type ChoiceState = {21 particles: ConfettiParticle[];22 dx: number;23 dy: number;24 bracketOpacity: number;25 idleTag: string;26}27
28const CONFETTI_COLORS = ["#000000", "#555555", "#aaaaaa", "#ffffff"];29
30/**31 * Animación de confirmación de selección: flash táctil inicial, wiggle +32 * brackets parpadeando, salto de celebración con confetti, aterrizaje con33 * squash y fade de brackets. Portado del useCanvas de StarterSelector.34 *35 * Las coordenadas de los brackets de selección se escalan proporcionalmente36 * al tamaño real del frame (originalmente fijas para un frame de 16x16).37 */38export const choiceEffect: AnimationEffect<ChoicePayload | undefined, ChoiceState> = {39 name: "Choice",40 defaultDuration: 1800,41
42 createState: () => ({43 particles: [],44 dx: 0,45 dy: 0,46 bracketOpacity: 0,47 idleTag: "Idle",48 }),49
50 start(runtime, payload) {51 runtime.state.idleTag = payload?.idleTag ?? "Idle";52 runtime.sprite?.playTag(payload?.celebrateTag ?? "Action", Infinity);53 },54
55 update(runtime, dt, elapsedMs, durationMs) {56 const { state, sprite, tick } = runtime;57
58 sprite?.update(tick);59
60 let dx = 0;61 let dy = 0;62 let whiteRatio = 0;63 let bracketOpacity = 0;64
65 // Flash táctil al inicio.66 if (elapsedMs < 80) {67 whiteRatio = 1;68 }69
70 // Etapa 1 (0-500ms): shake + brackets parpadeando.71 if (elapsedMs < 500) {72 dx = Math.floor(elapsedMs / 60) % 2 === 0 ? 1 : -1;73 bracketOpacity = Math.floor(elapsedMs / 120) % 2 === 0 ? 0.8 : 0.2;74 }75 // Etapa 2 (500-1100ms): salto parabólico de celebración + confetti.76 else if (elapsedMs < 1100) {77 const jumpDuration = 600;78 const t = (elapsedMs - 500) / jumpDuration;79 dy = -Math.round(8 * 4 * t * (1 - t));80 bracketOpacity = 1;81
82 if (Math.random() < 0.35) {83 const angle = -Math.PI / 4 - (Math.random() * Math.PI) / 2;84 const speed = 0.015 + Math.random() * 0.02;85 const size = Math.random() < 0.4 ? 2 : 1;86 const color = CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)];87 state.particles.push({88 x: runtime.frameW / 2 + (Math.random() - 0.5) * 4,89 y: runtime.frameH / 2 + (Math.random() - 0.5) * 4 + dy,90 vx: Math.cos(angle) * speed,91 vy: Math.sin(angle) * speed,92 maxLife: 400 + Math.random() * 400,93 size,94 color,95 });96 }97 }98 // Etapa 3 (1100-1800ms): aterrizaje con squash + fade de brackets.99 else {100 if (elapsedMs < 1250) {101 dy = 1;102 dx = Math.floor(elapsedMs / 40) % 2 === 0 ? 1 : -1;103 }104 const fadeProgress = (elapsedMs - 1100) / 700;105 const flash = Math.floor(elapsedMs / 65) % 2 === 0;106 bracketOpacity = flash ? Math.max(0, 1 - fadeProgress) : 0;107 }108
109 if (sprite) {110 sprite.whiteRatio = whiteRatio;111 sprite.flipOverride = null;112 }113
114 state.dx = dx;115 state.dy = dy;116 state.bracketOpacity = bracketOpacity;117
118 state.particles = state.particles119 .map((p) => ({120 ...p,121 x: p.x + p.vx * dt,122 y: p.y + p.vy * dt,123 vy: p.vy + 0.00005 * dt,124 maxLife: p.maxLife - dt,125 }))126 .filter((p) => p.maxLife > 0);127
128 return elapsedMs < durationMs;129 },130
131 draw(runtime, { ctx }) {132 const { state, sprite, frameW } = runtime;133
134 if (sprite) {135 sprite.draw(ctx, { dx: state.dx, dy: state.dy });136 }137
138 if (state.bracketOpacity > 0) {139 const s = frameW / 16; // escala respecto al frame de referencia (16x16)140 ctx.fillStyle = `rgba(0, 0, 0, ${state.bracketOpacity})`;141 const rect = (x: number, y: number, w: number, h: number) =>142 ctx.fillRect(x * s, y * s, Math.max(1, w * s), Math.max(1, h * s));143
144 rect(1, 1, 3, 1);145 rect(1, 2, 1, 2);146 rect(12, 1, 3, 1);147 rect(14, 2, 1, 2);148 rect(1, 14, 3, 1);149 rect(1, 12, 1, 2);150 rect(12, 14, 3, 1);151 rect(14, 12, 1, 2);152 }153
154 state.particles.forEach((p) => {155 ctx.save();156 ctx.fillStyle = p.color;157 ctx.globalAlpha = p.maxLife > 120 ? 1 : p.maxLife / 120;158 ctx.fillRect(Math.floor(p.x), Math.floor(p.y), p.size, p.size);159 ctx.restore();160 });161 },162
163 finish(runtime) {164 runtime.sprite?.playTag(runtime.state.idleTag, Infinity);165 },166};Update the import paths to match your project setup.
Transition Animation
Section titled “Transition Animation”A custom transition animation for sprite canvas
1import { useCallback, useRef, useState } from "react";2
3import { Button } from "@/components/ui/pxl/button";4import {5 SpriteCanvas,6 type SpriteCanvasHandle,7} from "@/components/ui/pxl/sprite-canvas";8import { transitionEffect } from "@/lib/pxl/sprite-animations/transition";9
10const sprites = [11 "https://raw.githubusercontent.com/pxl-ui/registry/main/app/public/sprites/tiny_chick.png",12 "https://raw.githubusercontent.com/pxl-ui/registry/main/app/public/sprites/clucking_chicken.png",13];14
15export default function TransitionExample() {16
17 const [activeIndex, setActiveIndex] = useState(0);18 const ref = useRef<SpriteCanvasHandle>(null);19
20 const play = useCallback((direction: 1 | -1) => {21 if (!ref.current) {22 return;23 }24
25 const nextIndex = activeIndex === 0 ? 1 : 0;26
27 ref.current.play("Transition", undefined, () => console.log("end"), {28 src: sprites[nextIndex],29 direction,30 });31
32 setActiveIndex(nextIndex);33
34 }, [activeIndex])35
36 return (37 <div className="flex flex-col items-center gap-4">38 <SpriteCanvas39 ref={ref}40 src={sprites[0]}41 size="lg"42 effects={[transitionEffect]}43 atlas={{44 frames: {45 "0": { frame: { x: 0, y: 0, w: 16, h: 16 }, },46 "1": { frame: { x: 16, y: 0, w: 16, h: 16 }, },47 "2": { frame: { x: 32, y: 0, w: 16, h: 16 }, },48 "3": { frame: { x: 48, y: 0, w: 16, h: 16 }, },49 },50 meta: { frameTags: [ { name: "Idle", from: 0, to: 3, }, ], },51 }}52 animation="Idle"53 />54 <div className="flex flex-row gap-6">55 <Button size="sm" onClick={() => play(1)}>PREV</Button>56 <Button size="sm" onClick={() => play(-1)}>NEXT</Button>57 </div>58 </div>59 );60}pnpm dlx shadcn@latest add pxl-ui/registry/sprite-animation-transition
Copy and paste the following code into your project.
1import type { AnimationEffect, Sprite } from "@/lib/pxl/sprite";2
3type TransitionPayload = {4 /** New sprite's src */5 src: string | string[];6 /** -1: el nuevo entra desde la derecha (slide izquierda). 1: entra desde la izquierda. */7 direction: 1 | -1;8};9
10type TransitionState = {11 oldSprite: Sprite | null;12 newSprite: Sprite | null;13 direction: 1 | -1;14 progress: number;15};16
17/**18 * Transición de slide entre dos formas (p.ej. al navegar entre starters).19 * Portado de transitionTo() en el useCanvas de StarterSelector; el desplazamiento20 * usa el ancho real del frame en vez del valor fijo de 16px original.21 */22export const transitionEffect: AnimationEffect<23 TransitionPayload,24 TransitionState25> = {26 name: "Transition",27 defaultDuration: 250,28
29 createState: () => ({30 oldSprite: null,31 newSprite: null,32 direction: -1,33 progress: 0,34 }),35
36 start(runtime, payload) {37 runtime.state.oldSprite = runtime.sprite;38 runtime.state.newSprite = runtime.createSprite(payload.src);39 runtime.state.direction = payload.direction;40 },41
42 update(runtime, _dt, elapsedMs, durationMs) {43 const { state, tick } = runtime;44 state.oldSprite?.update(tick);45 state.newSprite?.update(tick);46 state.progress = Math.min(1, elapsedMs / durationMs);47 return elapsedMs < durationMs;48 },49
50 draw(runtime, { ctx }) {51 const { state, frameW } = runtime;52 const { oldSprite, newSprite, direction, progress } = state;53
54 let oldDx = 0;55 let newDx = 0;56
57 if (direction === -1) {58 oldDx = Math.round(-frameW * progress);59 newDx = Math.round(frameW - frameW * progress);60 } else {61 oldDx = Math.round(frameW * progress);62 newDx = Math.round(-frameW + frameW * progress);63 }64
65 oldSprite?.draw(ctx, { dx: oldDx });66 newSprite?.draw(ctx, { dx: newDx });67 },68
69 finish(runtime) {70 return runtime.state.newSprite ?? undefined;71 },72};Update the import paths to match your project setup.
Evolution Animation
Section titled “Evolution Animation”A custom evolution animation for sprite canvas
1import { useRef } from "react";2
3import { Button } from "@/components/ui/pxl/button";4import {5 SpriteCanvas,6 type SpriteCanvasHandle,7} from "@/components/ui/pxl/sprite-canvas";8import { evolutionEffect } from "@/lib/pxl/sprite-animations/evolution";9
10export default function EvolutionExample() {11 const ref = useRef<SpriteCanvasHandle>(null);12 function evolve() {13 if (!ref.current) {14 return;15 }16
17 ref.current.play("Evolution", undefined, () => console.log("end"), {18 src: "https://raw.githubusercontent.com/pxl-ui/registry/main/app/public/sprites/clucking_chicken.png",19 });20 }21
22 return (23 <div className="flex flex-col gap-4 items-center">24 <SpriteCanvas25 animation="Idle"26 ref={ref}27 src="https://raw.githubusercontent.com/pxl-ui/registry/main/app/public/sprites/tiny_chick.png"28 size="lg"29 effects={[evolutionEffect]}30 atlas={{31 frames: {32 "0": { frame: { x: 0, y: 0, w: 16, h: 16 }, },33 "1": { frame: { x: 16, y: 0, w: 16, h: 16 }, },34 "2": { frame: { x: 32, y: 0, w: 16, h: 16 }, },35 "3": { frame: { x: 48, y: 0, w: 16, h: 16 }, },36 },37 meta: { frameTags: [ { name: "Idle", from: 0, to: 3, }, ], },38 }}39 />40 <Button onClick={evolve}>Play</Button>41 </div>42 );43}pnpm dlx shadcn@latest add pxl-ui/registry/sprite-animation-evolution
Copy and paste the following code into your project.
1import type { AnimationEffect, Sprite } from "@/lib/pxl/sprite";2
3type EvolutionPayload = {4 /** New sprite's src */5 src: string|string[];6};7
8type Particle = {9 x: number;10 y: number;11 vx: number;12 vy: number;13 maxLife: number;14 size: number;15};16
17type EvolutionState = {18 oldSprite: Sprite | null;19 newSprite: Sprite | null;20 spriteToDraw: Sprite | null;21 particles: Particle[];22 acc: number;23};24
25/**26 * Animación de evolución tipo Digimon: 3 fases (shake+flash creciente,27 * alternancia rápida entre forma vieja/nueva, asentamiento en la forma nueva),28 * con flashes blancos y partículas ascendentes. Portado del useCanvas original.29 */30export const evolutionEffect: AnimationEffect<31 EvolutionPayload,32 EvolutionState33> = {34 name: "Evolution",35 defaultDuration: 4000,36
37 createState: () => ({38 oldSprite: null,39 newSprite: null,40 spriteToDraw: null,41 particles: [],42 acc: 0,43 }),44
45 start(runtime, payload) {46 runtime.state.oldSprite = runtime.sprite;47 runtime.state.newSprite = runtime.createSprite(payload.src);48 },49
50 update(runtime, dt, elapsedMs, durationMs) {51 const { state, tick, frameW, frameH } = runtime;52 const { oldSprite, newSprite } = state;53
54 // Ticka la animación de frame de ambos sprites a paso fijo.55 state.acc += dt;56 while (state.acc >= tick) {57 oldSprite?.update(tick);58 newSprite?.update(tick);59 state.acc -= tick;60 }61
62 const progress = Math.min(1, elapsedMs / durationMs);63 let phase: 1 | 2 | 3;64 let phaseProgress: number;65
66 if (progress < 0.3) {67 phase = 1;68 phaseProgress = progress / 0.3;69 } else if (progress < 0.7) {70 phase = 2;71 phaseProgress = (progress - 0.3) / 0.4;72 } else {73 phase = 3;74 phaseProgress = (progress - 0.7) / 0.3;75 }76
77 // Frecuencia de flip (ms por flip).78 let flipPeriod = 150;79 if (phase === 1) {80 flipPeriod = 150 - (150 - 30) * phaseProgress;81 } else if (phase === 2) {82 flipPeriod = 30;83 } else {84 flipPeriod = 30 + (200 - 30) * phaseProgress;85 }86
87 const shouldFlip =88 phase === 3 && phaseProgress > 0.8589 ? false90 : Math.floor(elapsedMs / flipPeriod) % 2 === 0;91
92 if (oldSprite) oldSprite.flipOverride = shouldFlip;93 if (newSprite) newSprite.flipOverride = shouldFlip;94
95 // Ratio de overlay blanco.96 let whiteRatio = 0;97 if (phase === 1) whiteRatio = phaseProgress;98 else if (phase === 2) whiteRatio = 1;99 else whiteRatio = 1 - phaseProgress;100
101 if (oldSprite) oldSprite.whiteRatio = whiteRatio;102 if (newSprite) newSprite.whiteRatio = whiteRatio;103
104 // Qué forma se dibuja en cada fase.105 if (phase === 1) {106 state.spriteToDraw = oldSprite;107 } else if (phase === 2) {108 const alternatePeriod = 60;109 const alternateOn = Math.floor(elapsedMs / alternatePeriod) % 2 === 0;110 state.spriteToDraw = alternateOn ? oldSprite : newSprite;111 } else {112 state.spriteToDraw = newSprite;113 }114
115 // Probabilidad de spawn de partículas.116 let spawnProbability = 0.1;117 if (phase === 1) spawnProbability = 0.05 + 0.15 * phaseProgress;118 else if (phase === 2) spawnProbability = 0.35;119 else spawnProbability = 0.35 * (1 - phaseProgress);120
121 if (Math.random() < spawnProbability) {122 const angle = Math.random() * Math.PI * 2;123 const speed = 0.008 + Math.random() * 0.015;124 const size = Math.random() < 0.3 ? 2 : 1;125 state.particles.push({126 x: frameW / 2 + (Math.random() - 0.5) * frameW * 0.5,127 y: frameH / 2 + (Math.random() - 0.5) * frameH * 0.5,128 vx: Math.cos(angle) * speed,129 vy: Math.sin(angle) * speed - 0.008,130 maxLife: 400 + Math.random() * 400,131 size,132 });133 }134
135 state.particles = state.particles136 .map((p) => ({137 ...p,138 x: p.x + p.vx * dt,139 y: p.y + p.vy * dt,140 vy: p.vy - 0.000015 * dt,141 maxLife: p.maxLife - dt,142 }))143 .filter((p) => p.maxLife > 0);144
145 return elapsedMs < durationMs;146 },147
148 draw(runtime, { ctx }) {149 const { state } = runtime;150
151 if (state.spriteToDraw) {152 state.spriteToDraw.draw(ctx);153 }154
155 ctx.fillStyle = "#ffffff";156 state.particles.forEach((p) => {157 ctx.save();158 ctx.globalAlpha = p.maxLife > 150 ? 1 : p.maxLife / 150;159 ctx.fillRect(Math.floor(p.x), Math.floor(p.y), p.size, p.size);160 ctx.restore();161 });162 },163
164 finish(runtime) {165 const { state } = runtime;166
167 if (state.oldSprite) {168 state.oldSprite.flipOverride = null;169 state.oldSprite.whiteRatio = 0;170 }171 if (state.newSprite) {172 state.newSprite.flipOverride = null;173 state.newSprite.whiteRatio = 0;174 }175
176 return state.newSprite ?? undefined;177 },178};