Something went wrong. Try again.
a tiny pet with a GPT-2 brain, living in your browser
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542import 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<string, [string, string]> = { cream: ["#f6f0e2", "#ddd2bb"], pink: ["#f7d3da", "#e3aab6"], mint: ["#d3eedf", "#a8d3bb"], butter: ["#f8e9b0", "#e3cd7e"], lavender: ["#e3d8f4", "#c0afe0"], sky: ["#d3e6f6", "#a9c8e4"],};const SIZE: Record<Stage | "egg", [number, number]> = { 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<string, [string, string]> = { 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);}