import type { EggView, Game, Scene, ScenePet, Stage } from "./types.ts"; // The LCD: a 96x64 pixel scene drawn every animation frame. import { C, SPRITES, drawSprite, drawText } from "./sprites.ts"; export const W = 96, H = 64, FLOOR = 57; export const BODY_COLORS: Record = { cream: ["#f6f0e2", "#ddd2bb"], pink: ["#f7d3da", "#e3aab6"], mint: ["#d3eedf", "#a8d3bb"], butter: ["#f8e9b0", "#e3cd7e"], lavender: ["#e3d8f4", "#c0afe0"], sky: ["#d3e6f6", "#a9c8e4"], }; const SIZE: Record = { egg: [18, 22], baby: [20, 17], kid: [22, 20], teen: [24, 22], adult: [27, 24], }; function px(ctx: CanvasRenderingContext2D, x: number, y: number, c: string, w = 1, h = 1) { ctx.fillStyle = c; ctx.fillRect(Math.round(x), Math.round(y), w, h); } // Superellipse blob with an ink outline; flatter on the bottom. function blob( ctx: CanvasRenderingContext2D, cx: number, bottom: number, w: number, h: number, fill: string, shade: string, ) { const top = bottom - h, x0 = Math.round(cx - w / 2); const inside = (i: number, j: number) => { if (i < 0 || j < 0 || i >= w || j >= h) return false; const nx = (i + 0.5 - w / 2) / (w / 2), ny = (j + 0.5 - h / 2) / (h / 2); const e = ny > 0 ? 4 : 2.3; return Math.abs(nx) ** e + Math.abs(ny) ** e <= 1; }; for (let j = 0; j < h; j++) for (let i = 0; i < w; i++) { if (!inside(i, j)) continue; const edge = !inside(i - 1, j) || !inside(i + 1, j) || !inside(i, j - 1) || !inside(i, j + 1); px(ctx, x0 + i, top + j, edge ? C.ink : j > h * 0.72 || i > w * 0.8 ? shade : fill); } // a little shine px(ctx, x0 + Math.round(w * 0.25), top + Math.round(h * 0.22), C.white, 2, 1); px(ctx, x0 + Math.round(w * 0.22), top + Math.round(h * 0.22) + 1, C.white); return { x0, top }; } function eyes( ctx: CanvasRenderingContext2D, ex1: number, ex2: number, ey: number, kind: string, big: boolean, t: number, ) { for (const ex of [ex1, ex2]) { switch (kind) { case "closed": px(ctx, ex - 1, ey + 1, C.ink, 3, 1); break; case "happy": px(ctx, ex - 1, ey + 1, C.ink); px(ctx, ex, ey, C.ink); px(ctx, ex + 1, ey + 1, C.ink); break; case "love": px(ctx, ex - 1, ey - 1, C.red); px(ctx, ex + 1, ey - 1, C.red); px(ctx, ex - 1, ey, C.red, 3, 1); px(ctx, ex, ey + 1, C.red); break; case "wow": px(ctx, ex - 1, ey - 1, C.ink, 3, 3); px(ctx, ex, ey, C.white); break; case "swirl": px(ctx, ex - 1, ey - 1, C.ink); px(ctx, ex + 1, ey - 1, C.ink); px(ctx, ex, ey, C.ink); px(ctx, ex - 1, ey + 1, C.ink); px(ctx, ex + 1, ey + 1, C.ink); break; case "grumpy": px(ctx, ex - 1, ey + 1, C.ink, 2, 1); px(ctx, ex === ex1 ? ex - 1 : ex + 1, ey - 2, C.ink); px(ctx, ex, ey - 1, C.ink); break; default: // open, with a highlight if (big) { px(ctx, ex - 1, ey - 1, C.ink, 2, 3); px(ctx, ex - 1, ey - 1, C.white); } else { px(ctx, ex, ey - 1, C.ink, 1, 2); } } } } function mouth(ctx: CanvasRenderingContext2D, mx: number, my: number, kind: string) { switch (kind) { case "open": px(ctx, mx - 1, my, C.ink, 3, 1); px(ctx, mx - 1, my + 1, C.ink); px(ctx, mx + 1, my + 1, C.ink); px(ctx, mx, my + 1, C.red); break; case "o": px(ctx, mx, my, C.ink); px(ctx, mx - 1, my + 1, C.ink); px(ctx, mx + 1, my + 1, C.ink); px(ctx, mx, my + 2, C.ink); break; case "sad": px(ctx, mx - 1, my + 1, C.ink); px(ctx, mx, my, C.ink); px(ctx, mx + 1, my + 1, C.ink); break; case "flat": px(ctx, mx - 1, my, C.ink, 3, 1); break; case "chomp": px(ctx, mx - 1, my, C.ink, 3, 2); break; default: // cat mouth "w" px(ctx, mx - 2, my, C.ink); px(ctx, mx, my, C.ink); px(ctx, mx + 2, my, C.ink); px(ctx, mx - 1, my + 1, C.ink); px(ctx, mx + 1, my + 1, C.ink); } } const FACE: Record = { idle: ["open", "cat"], happy: ["happy", "open"], love: ["love", "open"], sad: ["open", "sad"], sleepy: ["closed", "flat"], wave: ["happy", "cat"], dance: ["happy", "open"], spin: ["happy", "open"], blush: ["happy", "cat"], grumpy: ["grumpy", "flat"], wow: ["wow", "o"], giggle: ["happy", "open"], cry: ["closed", "sad"], eat: ["happy", "chomp"], stretch: ["closed", "cat"], asleep: ["closed", "cat"], sick: ["swirl", "flat"], }; export function drawPet(ctx: CanvasRenderingContext2D, p: ScenePet, now: number) { const stage = p.stage; const [fill, shade] = BODY_COLORS[p.color] || BODY_COLORS.cream; let [w, h] = SIZE[stage]; const anim = p.anim?.name || (p.asleep ? "asleep" : p.sick ? "sick" : "idle"); const at = p.anim ? (now - p.anim.t0) / 1000 : now / 1000; let x = p.x, lift = 0, flip = p.facing < 0; // motion if (anim === "idle" && !p.asleep) { const hop = Math.max(0, Math.sin(now / 260)); lift = p.walking ? Math.round(hop * 2) : 0; // slow breathing: a soft 1px squish for a moment every few seconds if (!p.walking && Math.sin(now / 700) > 0.8) { h -= 1; w += 1; } } else if (anim === "happy" || anim === "giggle") { lift = Math.round(Math.abs(Math.sin(at * 9)) * 4); if (lift === 0) { h -= 1; w += 2; } } else if (anim === "dance") { x += Math.round(Math.sin(at * 8) * 3); const s = Math.sin(at * 16) > 0; if (s) { h -= 1; w += 1; } flip = Math.sin(at * 8) < 0; } else if (anim === "spin") { const c = Math.cos(at * 10); w = Math.max(2, Math.round(w * Math.abs(c))); flip = c < 0; lift = 2; } else if (anim === "eat") { if (Math.sin(at * 12) > 0) { h -= 1; w += 1; } } else if (anim === "stretch") { const sq = Math.max(0, Math.sin(at * 5)); h -= Math.round(sq * 2); w += Math.round(sq * 2); } else if (anim === "asleep") { if (Math.sin(now / 700) > 0) { h -= 1; w += 1; } } else if (anim === "grumpy") { x += Math.round(Math.sin(at * 30)); } else if (anim === "pat") { const sq = Math.max(0, Math.sin(at * 10)); h -= Math.round(sq * 3); w += Math.round(sq * 2); } const bottom = FLOOR - lift; const cx = Math.round(x); // shadow px(ctx, cx - Math.round(w / 2) + 2, FLOOR, C.lcdShade, w - 4, 1); // little dust puffs behind its feet while it toddles if (p.walking && !p.asleep) { const ph = (now / 480) % 1, back = flip ? 1 : -1; const dx = cx + back * (Math.round(w / 2) + 1 + Math.round(ph * 4)); if (ph < 0.7) px(ctx, dx, FLOOR - 1 - Math.round(ph * 2), C.mid, ph < 0.35 ? 2 : 1, ph < 0.35 ? 2 : 1); } // stage decorations behind the body if ((stage === "teen" || stage === "adult") && !p.asleep) { const top = bottom - h; for (const s of [-1, 1]) { const ex = cx + s * Math.round(w * 0.28); px(ctx, ex - 1, top - 1, C.ink, 3, 1); px(ctx, ex, top - 2, C.ink); px(ctx, ex - 1, top, fill, 3, 1); } } const { x0, top } = blob(ctx, cx, bottom, w, h, fill, shade); // feet const step = p.walking && !p.asleep ? Math.floor(now / 160) % 2 : 0; px(ctx, cx - Math.round(w * 0.25) - 1, bottom, C.ink, 2, step ? 0 : 1); px(ctx, cx + Math.round(w * 0.25) - 1, bottom, C.ink, 2, step ? 1 : 0); // face if (w > 6) { const look = flip ? -1 : 1; const ey = top + Math.round(h * 0.42); const gap = Math.round(w * 0.2); const blink = anim === "idle" && Math.floor(now / 100) % 42 === 0; let [ek, mk] = FACE[anim] || FACE.idle; if (blink) ek = "closed"; if (p.sick && anim === "idle") [ek, mk] = FACE.sick; eyes(ctx, cx - gap + look, cx + gap + look, ey, ek, true, now); mouth(ctx, cx + look, ey + 3, mk); const blushy = anim === "blush" || anim === "love" || anim === "pat"; px(ctx, cx - gap - 2 + look, ey + 2, C.pink, blushy ? 3 : 2, 1); px(ctx, cx + gap + look, ey + 2, C.pink, blushy ? 3 : 2, 1); if (anim === "sad" || anim === "cry") { const ty = ey + 2 + ((at * 12) % 6); drawSprite(ctx, "tear", cx - gap - 1 + look, ty); if (anim === "cry") drawSprite(ctx, "tear", cx + gap + look, ey + 2 + ((at * 12 + 3) % 6)); } if (anim === "wave") { const up = Math.sin(at * 14) > 0; const sx = flip ? x0 - 2 : x0 + w; px(ctx, sx, top + Math.round(h * 0.45) - (up ? 2 : 0), C.ink, 2, 2); } } // stage decorations on top if (stage === "baby") { const sy = bottom - 3; for (let i = 0; i < w + 2; i++) { const zig = i % 4 < 2 ? 0 : 1; px(ctx, x0 - 1 + i, sy + zig, C.ink); px(ctx, x0 - 1 + i, sy + zig + 1, C.white, 1, 2 - zig); } px(ctx, x0 - 1, sy, C.ink, 1, 3); px(ctx, x0 + w, sy, C.ink, 1, 3); } else if (stage === "kid" && !p.asleep) { px(ctx, cx, top - 3, C.dgreen, 1, 3); px(ctx, cx - 2, top - 4, C.green, 2, 1); px(ctx, cx - 3, top - 3, C.green, 2, 1); px(ctx, cx + 1, top - 4, C.green, 2, 1); px(ctx, cx + 2, top - 5, C.green, 2, 1); } else if (stage === "adult" && !p.asleep) { px(ctx, cx, top - 2, C.dgreen, 1, 2); px(ctx, cx - 1, top - 5, C.pink, 3, 3); px(ctx, cx, top - 6, C.pink); px(ctx, cx, top - 2, C.pink); px(ctx, cx - 2, top - 4, C.pink); px(ctx, cx + 2, top - 4, C.pink); px(ctx, cx, top - 4, C.yellow); } // a floppy nightcap with a pompom while it sleeps if (p.asleep) { const tip = flip ? -1 : 1, base = Math.round(w * 0.62), rows = 6; let ex = cx; for (let j = 0; j < rows; j++) { const rw = Math.max(1, Math.round(base * (1 - j / rows))), rx = Math.round(cx - rw / 2) + tip * Math.round(j * 0.8); px(ctx, rx - 1, top + 1 - j, C.ink, rw + 2, 1); if (j < rows - 1) px(ctx, rx, top + 1 - j, j === 0 ? C.white : C.blue, rw, 1); ex = rx + (tip > 0 ? rw : -1); } // the tip flops over, with a pompom px(ctx, ex, top + 1 - rows, C.ink, 1, 2); const pom = tip > 0 ? ex : ex - 3; px(ctx, pom, top + 1 - rows, C.ink, 4, 4); px(ctx, pom + 1, top + 2 - rows, C.white, 2, 2); } if (p.sick) drawSprite(ctx, "pill", x0 + w - 1, top - 6); return { top, x0, w, h, cx }; } export function drawEgg(ctx: CanvasRenderingContext2D, egg: EggView, now: number) { if (egg.bad) return drawBadEgg(ctx, now); const [w, h] = SIZE.egg; const wob = egg.ready ? Math.round(Math.sin(now / 90) * 1.5) : Math.sin(now / 600) > 0.9 ? 1 : 0; const cx = W / 2 + wob, bottom = FLOOR; const { x0, top } = blob(ctx, cx, bottom, w, h, "#f6f0e2", "#ddd2bb"); // spots for (const [i, j, c] of [ [3, 5, C.pink], [9, 4, C.blue], [6, 10, C.yellow], [10, 11, C.pink], [3, 12, C.lav], ] as [number, number, string][]) { px(ctx, x0 + i, top + j, c, 2, 2); } // cracks grow with progress const n = Math.floor(egg.progress * 8); let x = x0 + 4, y = top + 7; for (let k = 0; k < n; k++) { px(ctx, x, y, C.ink); x += 1; y += k % 2 ? 1 : -1; } px(ctx, cx - Math.round(w / 2) + 2, FLOOR, C.lcdShade, w - 4, 1); if (egg.ready && Math.floor(now / 400) % 2) drawSprite(ctx, "bang", cx + 9, top - 2); } // A save that could not be read: a grey egg that glitches, with a "?" on it. function drawBadEgg(ctx: CanvasRenderingContext2D, now: number) { const [w, h] = SIZE.egg; const glitch = Math.floor(now / 150) % 7 === 0; const cx = W / 2 + (glitch ? 1 : 0); const { x0, top } = blob(ctx, cx, FLOOR, w, h, "#b9b3bf", "#8d8494"); // scrambled rows shift sideways now and then if (glitch) { const row = top + 3 + (Math.floor(now / 150) % (h - 6)); const img = ctx.getImageData(x0, row, w, 2); ctx.putImageData(img, x0 + 2, row); } const q = ["0110", "1001", "0010", "0100", "0000", "0100"]; q.forEach((r, j) => [...r].forEach((b, i) => b === "1" && px(ctx, cx - 2 + i, top + 6 + j, C.ink)), ); px(ctx, cx - Math.round(w / 2) + 2, FLOOR, C.lcdShade, w - 4, 1); } function drawWindow( ctx: CanvasRenderingContext2D, weather: string, hour: number, now: number, dark: boolean, ) { const x = 3, y = 3, w = 20, h = 15; const night = hour < 6 || hour >= 20; px(ctx, x, y, C.ink, w, h); px(ctx, x + 1, y + 1, night ? "#3d4a6b" : "#bfe0f2", w - 2, h - 2); ctx.save(); ctx.beginPath(); ctx.rect(x + 1, y + 1, w - 2, h - 2); ctx.clip(); const t = now / 1000; if (weather === "sunny" || weather === "clear") { if (night) { px(ctx, x + 13, y + 3, C.yellow, 3, 3); px(ctx, x + 14, y + 3, "#3d4a6b", 2, 2); } else { px(ctx, x + 12, y + 3, C.yellow, 4, 4); } } if (weather === "starry" || (night && weather === "clear")) { for (let k = 0; k < 6; k++) if (Math.sin(t * 2 + k * 7) > -0.3) px(ctx, x + 2 + ((k * 7) % 16), y + 2 + ((k * 5) % 10), C.yellow); } if (["cloudy", "rainy", "snowy", "windy", "foggy"].includes(weather)) { const dx = weather === "windy" ? Math.round((t * 6) % 22) - 4 : 0; px(ctx, x + 3 + dx, y + 4, C.white, 7, 2); px(ctx, x + 5 + dx, y + 3, C.white, 3, 1); px(ctx, x + 10 + (dx >> 1), y + 6, C.white, 6, 2); } if (weather === "rainy") for (let k = 0; k < 7; k++) { const ry = y + 7 + ((t * 14 + k * 3) % 7); px(ctx, x + 2 + k * 2.5, ry, C.blue, 1, 2); } if (weather === "snowy") for (let k = 0; k < 7; k++) { px( ctx, x + 2 + ((k * 5 + Math.sin(t + k) * 1.5) % 16), y + 2 + ((t * 4 + k * 4) % 11), C.white, ); } if (weather === "foggy") px(ctx, x + 1, y + 8, "#e7eef0", w - 2, 3); ctx.restore(); // frame bars px(ctx, x + w / 2, y, C.ink, 1, h); px(ctx, x, y + 7, C.ink, w, 1); if (dark) { ctx.globalAlpha = 0.45; px(ctx, x, y, C.ink, w, h); ctx.globalAlpha = 1; } } function drawShelf(ctx: CanvasRenderingContext2D, shelf: string[]) { const items = shelf.slice(-4); const x = W - 3 - 4 * 9, y = 3; px(ctx, x - 1, y + 9, C.brown, 4 * 9 + 2, 1); px(ctx, x - 1, y + 10, C.mid, 4 * 9 + 2, 1); items.forEach((name, i) => drawSprite(ctx, name, x + i * 9 + 9 * (4 - items.length), y)); } export function drawScene(ctx: CanvasRenderingContext2D, v: Scene, now: number) { const dark = v.lightsOff; px(ctx, 0, 0, dark ? C.lcdNight : C.lcd, W, H); // wall/floor px(ctx, 0, FLOOR + 1, dark ? "#1f2725" : C.lcdShade, W, H - FLOOR - 1); px(ctx, 0, FLOOR + 1, dark ? "#151b19" : C.mid, W, 1); drawWindow(ctx, v.weather, v.hour, now, dark); if (!dark) drawShelf(ctx, v.shelf); if (dark) ctx.globalAlpha = 0.55; for (let i = 0; i < v.poop; i++) drawSprite(ctx, "poop", W - 12 - i * 9, FLOOR - 5); let box = null; if (v.egg) drawEgg(ctx, v.egg, now); else if (v.pet) box = drawPet(ctx, v.pet, now); ctx.globalAlpha = 1; // held food / effects for (const fx of v.effects) { const t = (now - fx.t0) / 1000; if (fx.kind === "food" && box) { const bites = Math.min(3, Math.floor(t / 0.5)); const rows = SPRITES[fx.item]; const fxX = box.cx + (v.pet!.facing < 0 ? -box.w / 2 - 7 : box.w / 2 - 1), fxY = FLOOR - 9; ctx.save(); ctx.beginPath(); ctx.rect(fxX + bites * 2.5, fxY, 8, 8); ctx.clip(); drawSprite(ctx, fx.item, fxX, fxY); ctx.restore(); if (bites > 0) drawSprite(ctx, "crumb", fxX + 1 + ((t * 13) % 6), FLOOR - 2); void rows; } else if (fx.kind === "float") { const y = fx.y - t * 10, x = fx.x + Math.sin(t * 5 + fx.x) * 2; if (y > -8) drawSprite(ctx, fx.item, x, y); } else if (fx.kind === "bubble") { const y = fx.y - t * 8; px(ctx, fx.x + Math.sin(t * 6 + fx.x) * 2, y, C.white, 2, 2); px(ctx, fx.x + Math.sin(t * 6 + fx.x) * 2, y, C.blue, 1, 1); } } if (box && v.pet!.asleep) { const t = now / 1000; for (let k = 0; k < 2; k++) { const ph = (t * 0.6 + k * 0.5) % 1; if (ph < 0.85) drawSprite(ctx, "zz", box.x0 + box.w + 1 + ph * 5, box.top - 2 - ph * 10, { color: dark ? C.white : C.ink, }); } } // something the owner is offering, bobbing beside the pet while it decides if (box && v.offer && SPRITES[v.offer]) { const side = box.cx < W / 2 ? 1 : -1; const ox = side > 0 ? box.x0 + box.w + 3 : box.x0 - 11, oy = FLOOR - 10 - Math.round(Math.abs(Math.sin(now / 300)) * 2); drawSprite(ctx, v.offer, ox, oy); } // Dreams share the thought bubble, with the sprite chosen by the brain. if (box && (v.pet!.asleep ? v.dream : v.want || v.thinking)) { const bx = Math.min(W - 16, box.x0 + box.w + 2), by = Math.max(14, box.top - 14); px(ctx, bx, by, C.ink, 14, 12); px(ctx, bx + 1, by + 1, C.white, 12, 10); px(ctx, bx - 2, by + 12, C.ink, 2, 2); px(ctx, bx - 4, by + 15, C.ink, 1, 1); if (v.dream || v.want) drawSprite(ctx, (v.pet!.asleep ? v.dream : v.want)!, bx + 3, by + 2); else for (let k = 0; k < 3; k++) if (Math.floor(now / 250) % 4 > k) px(ctx, bx + 3 + k * 3, by + 6, C.ink, 2, 2); } if (v.game) drawGame(ctx, v.game, now); if (v.clock) drawText(ctx, v.clock, 36, 4, dark ? C.mid : C.ink); } function drawGame(ctx: CanvasRenderingContext2D, g: Game, now: number) { for (const s of g.stars) drawSprite(ctx, s.bad ? "ghost" : "star", s.x - 4, s.y - 4); drawText(ctx, `${g.score}`, W - 4 - String(g.score).length * 4, H - 5); const left = Math.max(0, Math.ceil(g.timeLeft)); drawText(ctx, `${left}`, W / 2 - 4, 22); }