import { animationManifest } from "./animation-manifest.js"; export class SequencePlayer { constructor(canvas, options = {}) { if (!(canvas instanceof HTMLCanvasElement)) { throw new TypeError( "SequencePlayer requires a valid canvas element." ); } this.canvas = canvas; this.ctx = canvas.getContext("2d"); if (!this.ctx) { throw new Error( "The browser could not create a 2D canvas context." ); } this.basePath = options.basePath || ""; this.defaultFPS = options.fps || 12; this.frames = []; this.currentFrame = 0; this.playing = false; this.loop = true; this.animationName = null; this.animationFrameId = null; this.lastFrameTime = 0; this.frameDuration = 1000 / this.defaultFPS; this.onComplete = null; this.playRequestId = 0; this.ctx.imageSmoothingEnabled = false; this.resize(); window.addEventListener("resize", () => { this.resize(); }); } getAnimation(name) { const animation = animationManifest[name]; if (!animation) { throw new Error( `Animation "${name}" does not exist in the manifest.` ); } if ( !Array.isArray(animation.frames) || animation.frames.length === 0 ) { throw new Error( `Animation "${name}" does not contain any frames.` ); } return animation; } async play(name, options = {}) { const requestId = ++this.playRequestId; this.stop(false); const animation = this.getAnimation(name); this.animationName = name; this.loop = options.loop ?? animation.loop ?? true; this.onComplete = typeof options.onComplete === "function" ? options.onComplete : null; const fps = options.fps ?? animation.fps ?? this.defaultFPS; this.frameDuration = 1000 / fps; const folder = animation.path || name; const loadedFrames = await this.loadFrames( folder, animation.frames ); /* * Ignore old loading requests if the user selected * another animation before loading completed. */ if (requestId !== this.playRequestId) { return; } this.frames = loadedFrames; this.currentFrame = 0; this.lastFrameTime = 0; this.playing = true; this.drawFrame(this.frames[0]); this.animationFrameId = requestAnimationFrame( (timestamp) => this.tick(timestamp) ); } async loadFrames(folder, filenames) { const framePromises = filenames.map((filename) => { const url = this.createFrameURL( folder, filename ); return this.loadImage(url); }); return Promise.all(framePromises); } createFrameURL(folder, filename) { const cleanBasePath = this.basePath.replace(/\/+$/, ""); const cleanFolder = folder.replace(/^\/+|\/+$/g, ""); const cleanFilename = filename.replace(/^\/+/, ""); return ( `${cleanBasePath}/` + `${cleanFolder}/` + `${cleanFilename}` ); } loadImage(url) { return new Promise((resolve, reject) => { const image = new Image(); image.onload = () => { resolve(image); }; image.onerror = () => { reject( new Error( `Could not load frame: ${url}` ) ); }; image.src = url; }); } tick(timestamp) { if (!this.playing) { return; } if (this.lastFrameTime === 0) { this.lastFrameTime = timestamp; } const elapsed = timestamp - this.lastFrameTime; if (elapsed >= this.frameDuration) { this.lastFrameTime = timestamp - (elapsed % this.frameDuration); this.advanceFrame(); } if (this.playing) { this.animationFrameId = requestAnimationFrame( (nextTimestamp) => this.tick(nextTimestamp) ); } } advanceFrame() { const nextFrame = this.currentFrame + 1; if (nextFrame >= this.frames.length) { if (this.loop) { this.currentFrame = 0; } else { this.finish(); return; } } else { this.currentFrame = nextFrame; } this.drawFrame( this.frames[this.currentFrame] ); } drawFrame(image) { if (!image) { return; } const canvasWidth = this.canvas.width; const canvasHeight = this.canvas.height; this.ctx.clearRect( 0, 0, canvasWidth, canvasHeight ); const paddingRatio = 0.08; const availableWidth = canvasWidth * (1 - paddingRatio * 2); const availableHeight = canvasHeight * (1 - paddingRatio * 2); const scale = Math.min( availableWidth / image.width, availableHeight / image.height ); const drawWidth = image.width * scale; const drawHeight = image.height * scale; const drawX = (canvasWidth - drawWidth) / 2; const drawY = (canvasHeight - drawHeight) / 2; this.ctx.imageSmoothingEnabled = false; this.ctx.drawImage( image, drawX, drawY, drawWidth, drawHeight ); } resize() { const rect = this.canvas.getBoundingClientRect(); const pixelRatio = window.devicePixelRatio || 1; const width = Math.max( 1, Math.floor(rect.width * pixelRatio) ); const height = Math.max( 1, Math.floor(rect.height * pixelRatio) ); if ( this.canvas.width !== width || this.canvas.height !== height ) { this.canvas.width = width; this.canvas.height = height; } this.ctx.imageSmoothingEnabled = false; if (this.frames.length > 0) { this.drawFrame( this.frames[this.currentFrame] ); } } finish() { this.playing = false; if (this.animationFrameId !== null) { cancelAnimationFrame( this.animationFrameId ); this.animationFrameId = null; } const completionCallback = this.onComplete; this.onComplete = null; if (completionCallback) { completionCallback(); } } stop(invalidateRequest = true) { this.playing = false; if (invalidateRequest) { this.playRequestId++; } if (this.animationFrameId !== null) { cancelAnimationFrame( this.animationFrameId ); this.animationFrameId = null; } this.frames = []; this.currentFrame = 0; this.lastFrameTime = 0; this.onComplete = null; } }