// Stateless canvas painter for the mascot. Single render path: slice a // 16x16 frame from a PNG sheet and draw it bottom-anchored, horizontally // centered, optionally flipped (for left-facing) and optionally scaled // (chick is smaller). Egg-stage sheets are also 16x16 PNGs (from the // Onocentaur egg pack), so no special-case vector path is needed. // // The renderer is generic over the SPRITES registry — adding a new // sheet to sprites.ts requires no change here. import type { AnimDef, MascotStage } from './types' import { getImage } from './sprites' /** * Logical canvas size (CSS px) the mascot is drawn onto. The sprite's * feet land on the bottom row. The canvas is taller than wide (20x28) * so there's room above the sprite for the reaction bubble — the sprite * bottom-anchors at y=CANVAS_H, and the bubble renders in the top * ~8 logical px without being clipped. */ export const CANVAS_W = 20 export const CANVAS_H = 28 /** Source frame size for the bundled sheets (px). */ const FRAME = 16 export interface DrawOpts { /** Render scale; usually STAGE_SCALE[stage]. */ scale: number /** Horizontal facing — when -1, draw the sheet mirrored. */ facing: 1 | -1 /** Wiggle phase (radians) for the egg wobble; ignored for chicken stages. 0 disables. */ wiggle: number } /** Draw one animation frame into the given 2D context (which is already sized CANVAS_W x CANVAS_H in CSS px). */ export function drawFrame( ctx: CanvasRenderingContext2D, _stage: MascotStage, anim: AnimDef, frameIdx: number, opts: DrawOpts ): void { ctx.clearRect(0, 0, CANVAS_W, CANVAS_H) const img = anim.src ? getImage(anim.src) : null if (!img) return // not yet loaded — skip; the loop picks it up next tick const idx = Math.max(0, Math.min(frameIdx, anim.frames - 1)) const sx = idx * FRAME const scale = opts.scale const drawW = FRAME * scale const drawH = FRAME * scale // Bottom-anchor the 16x16 frame in the 20x20 canvas, then scale. const dx = (CANVAS_W - drawW) / 2 + Math.sin(opts.wiggle) * 1.2 const dy = CANVAS_H - drawH ctx.save() if (opts.facing === -1) { ctx.translate(CANVAS_W, 0) ctx.scale(-1, 1) } ctx.imageSmoothingEnabled = false ctx.drawImage(img, sx, 0, FRAME, FRAME, dx, dy, drawW, drawH) ctx.restore() } /** Draw a reaction bubble above the sprite (call after drawFrame, same context). */ export function drawBubble( ctx: CanvasRenderingContext2D, bubbleSrc: string, facing: 1 | -1 ): void { const img = getImage(bubbleSrc) if (!img) return // Bubbles are 16x16 single-frame PNGs drawn in the top portion of the // 20x28 canvas (above the sprite's head). imageSmoothingEnabled is // TRUE here — bubbles are crisp UI, not pixel-art, so nearest-neighbor // downscale looks blocky. const drawW = 14 const drawH = 14 const dx = (CANVAS_W - drawW) / 2 const dy = 0 ctx.save() if (facing === -1) { ctx.translate(CANVAS_W, 0) ctx.scale(-1, 1) } ctx.imageSmoothingEnabled = true ctx.drawImage(img, 0, 0, img.width, img.height, dx, dy, drawW, drawH) ctx.restore() }