// LoFinity — the Game Boy mini-game: a tiny, relaxing garden. // Plant a seed, water it, watch it grow. No score, no losing, nothing to chase. // Rendered as Game-Boy-Color-style pixel art on a 160x144 canvas. const W = 160; // Game Boy (Color) screen resolution const H = 144; const SEEDS = ["sunflower", "dandelion", "daisy", "pumpkin"]; // the bottom tray = the four seeds plus a "dig" tool for removing a plant const TRAY = [...SEEDS, "dig"]; const SEED_LABEL = { sunflower: "SUNFLOWER", dandelion: "DANDELION", daisy: "DAISY", pumpkin: "PUMPKIN", dig: "DIG UP", }; // Garden plots: two rows of four, drawn back-to-front for a little depth. const COLS = 4; const ROWS = 2; const COL_X = [29, 63, 97, 131]; // soil centre x per column const ROW_Y = [84, 110]; // soil-top y per row (plants grow upward from here) // each row is sheared sideways (~22°) so the front row sits offset from the // back row instead of directly in front of it — the back plots stay visible. const ROW_SHEAR = 12; // Growth tuning — gentle. A watered plant blooms in ~10s; left dry it crawls. const WET_TIME = 6; // seconds the soil stays damp after a watering const WET_RATE = 0.5; // growth-stages per second while damp const DRY_RATE = 0.1; // growth-stages per second while dry const WATER_BUMP = 0.18; // instant nudge each time you water // cap the render loop at 30fps — the garden animates gently, so there's no need // to redraw at the display's full 60/120Hz (keeps it light on the CPU/GPU). const FRAME_MS = 1000 / 30; // A cohesive, sunny GBC-ish palette. const P = { sky0: "#7ec8ff", sky1: "#a6dcff", sky2: "#cdeeff", haze: "#e7f7ff", sun: "#ffe45e", sunHi: "#fff6c2", cloud: "#ffffff", cloudShade: "#d8eefc", grass: "#7bcf5a", grassHi: "#9be06f", grassLo: "#57b045", soilTop: "#b9824e", soil: "#9a6336", soilDark: "#74462450", soilWetTop: "#7c5230", soilWet: "#5f3c20", shadow: "#3a6b3055", stem: "#4faa46", stemDark: "#2f7d3a", leaf: "#6cc24a", leafHi: "#8ed86a", petalSun: "#ffc01e", petalSunHi: "#ffe066", sunCenter: "#7a431b", sunCenter2: "#9c5a26", dandel: "#ffd23f", dandelHi: "#fff0a6", white: "#ffffff", whiteShade: "#dfe8f0", daisyCenter: "#ffcb1f", pumpkin: "#ff8a3d", pumpkinHi: "#ffb072", pumpkinDark: "#e0651f", pumpkinRib: "#c8531a", bud: "#9bd06a", drop: "#7cc6ff", star: "#fff6c2", cursor: "#ffffff", cursorDark: "#1c2540", tray: "#2a2350", trayLo: "#1a1530", traySlot: "#3a3070", traySel: "#ffe45e", }; export function createGarden(root) { const canvas = root.querySelector("#gb-canvas"); const caption = root.querySelector("#gb-caption"); const ctx = canvas.getContext("2d"); ctx.imageSmoothingEnabled = false; // --- state (persists across opens — your garden stays put) ----------------- // progress (0..3 → seed, sprout, bud, bloom) is the single source of truth; // the drawn stage is always floor(progress), so the art never lags the state. const plots = Array.from({ length: COLS * ROWS }, (_, i) => ({ type: null, progress: 0, bloomed: false, // tracks the one-shot bloom sparkle wet: 0, // seconds of dampness left phase: i * 1.7, // desync the sway })); const stageOf = (p) => Math.min(3, Math.floor(p.progress)); let cursor = 0; // selected plot index let seedIdx = 0; // selected seed type const particles = []; const cloud = { x: 116 }; const fly = { t: 0 }; // butterfly clock let t = 0; // game clock (seconds) let running = false; let raf = 0; let last = 0; // --- helpers --------------------------------------------------------------- const r = (x, y, w, h, c) => { ctx.fillStyle = c; ctx.fillRect(x | 0, y | 0, w | 0, h | 0); }; const px = (x, y, c) => { ctx.fillStyle = c; ctx.fillRect(x | 0, y | 0, 1, 1); }; const disc = (cx, cy, rad, c) => { ctx.fillStyle = c; for (let y = -rad; y <= rad; y++) { const w = Math.floor(Math.sqrt(rad * rad - y * y)); ctx.fillRect((cx - w) | 0, (cy + y) | 0, 2 * w + 1, 1); } }; const oval = (cx, cy, rx, ry, c) => { ctx.fillStyle = c; for (let y = -ry; y <= ry; y++) { const w = Math.floor(rx * Math.sqrt(1 - (y * y) / (ry * ry))); ctx.fillRect((cx - w) | 0, (cy + y) | 0, 2 * w + 1, 1); } }; function plotPos(i) { const col = i % COLS; const row = Math.floor(i / COLS); // shear rows around the centre so the layout leans diagonally return { cx: COL_X[col] + (row - (ROWS - 1) / 2) * ROW_SHEAR, baseY: ROW_Y[row], }; } // --- input ----------------------------------------------------------------- function move(dx, dy) { const col = cursor % COLS; const row = Math.floor(cursor / COLS); const nc = Math.min(COLS - 1, Math.max(0, col + dx)); const nr = Math.min(ROWS - 1, Math.max(0, row + dy)); cursor = nr * COLS + nc; } function spawnDrops(cx, baseY) { for (let i = 0; i < 6; i++) { particles.push({ type: "drop", x: cx - 8 + Math.floor((i / 6) * 16), y: baseY - 26 - (i % 3) * 4, vy: 36 + (i % 3) * 8, life: 0.7, max: 0.7, }); } } function spawnStars(cx, cy) { for (let i = 0; i < 6; i++) { const a = (i / 6) * Math.PI * 2; particles.push({ type: "star", x: cx + Math.cos(a) * 8, y: cy + Math.sin(a) * 8, vx: Math.cos(a) * 6, vy: Math.sin(a) * 6 - 4, life: 0.9, max: 0.9, }); } } function spawnPoof(cx, baseY) { for (let i = 0; i < 8; i++) { particles.push({ type: "poof", x: cx - 6 + (i % 4) * 4, y: baseY - 4 - Math.floor(i / 4) * 4, vx: ((i % 4) - 1.5) * 8, vy: -10 - (i % 3) * 6, life: 0.6, max: 0.6, }); } } // A button: with the dig tool selected, remove the plant; with a seed // selected, plant on an empty plot or water what's already growing. function act() { if (TRAY[seedIdx] === "dig") { clearPlot(); return; } const p = plots[cursor]; const { cx, baseY } = plotPos(cursor); if (!p.type) { // just sow the seed — it grows slowly until you water it p.type = SEEDS[seedIdx]; p.progress = 0; p.bloomed = false; p.wet = 0; } else { p.wet = WET_TIME; if (p.progress < 3) p.progress = Math.min(3, p.progress + WATER_BUMP); spawnDrops(cx, baseY); if (p.progress >= 3) spawnStars(cx, baseY - bloomHeight(p.type)); flash(caption, P.drop); } } function cycleSeed(dir = 1) { seedIdx = (seedIdx + dir + TRAY.length) % TRAY.length; updateCaption(); } // dig up the current plot so you can replant / rearrange (B→dig tool, or START) function clearPlot() { const p = plots[cursor]; if (!p.type) return; const { cx, baseY } = plotPos(cursor); p.type = null; p.progress = 0; p.bloomed = false; p.wet = 0; spawnPoof(cx, baseY); } function input(action) { if (!running) return; switch (action) { case "up": move(0, -1); break; case "down": move(0, 1); break; case "left": move(-1, 0); break; case "right": move(1, 0); break; case "a": act(); break; case "b": cycleSeed(1); break; case "select": cycleSeed(1); break; case "start": clearPlot(); break; } } // --- update ---------------------------------------------------------------- function update(dt) { t += dt; fly.t += dt; cloud.x -= dt * 4; if (cloud.x < -34) cloud.x = W + 6; for (const p of plots) { if (!p.type) continue; if (p.wet > 0) { p.wet = Math.max(0, p.wet - dt); p.progress = Math.min(3, p.progress + WET_RATE * dt); } else { p.progress = Math.min(3, p.progress + DRY_RATE * dt); } if (p.progress >= 3 && !p.bloomed) { p.bloomed = true; const { cx, baseY } = plotPos(plots.indexOf(p)); spawnStars(cx, baseY - bloomHeight(p.type)); } } for (let i = particles.length - 1; i >= 0; i--) { const q = particles[i]; q.life -= dt; if (q.life <= 0) { particles.splice(i, 1); continue; } q.x += (q.vx || 0) * dt; q.y += (q.vy || 0) * dt; if (q.type === "drop") q.vy += 120 * dt; if (q.type === "poof") q.vy += 40 * dt; } } // --- drawing --------------------------------------------------------------- function drawSky() { r(0, 0, W, 14, P.sky0); r(0, 14, W, 12, P.sky1); r(0, 26, W, 10, P.sky2); r(0, 36, W, 6, P.haze); // sun, top-left, with a few soft rays const sx = 22, sy = 22; for (let a = 0; a < 8; a++) { const ang = (a / 8) * Math.PI * 2 + Math.sin(t * 0.4) * 0.05; px(sx + Math.cos(ang) * 13, sy + Math.sin(ang) * 13, P.sunHi); px(sx + Math.cos(ang) * 14, sy + Math.sin(ang) * 14, P.sunHi); } disc(sx, sy, 9, P.sun); disc(sx - 2, sy - 2, 4, P.sunHi); // a drifting cloud const cx = cloud.x | 0, cy = 18; oval(cx, cy + 2, 16, 5, P.cloudShade); oval(cx, cy, 16, 5, P.cloud); disc(cx - 7, cy - 2, 5, P.cloud); disc(cx + 6, cy - 1, 6, P.cloud); } function drawGround() { r(0, 42, W, H - 42, P.grass); // a brighter strip near the top of the field + a darker foreground r(0, 42, W, 4, P.grassHi); r(0, H - 16, W, 16, P.grassLo); // scattered grass tufts for texture for (let i = 0; i < 16; i++) { const gx = (i * 53 + 11) % W; const gy = 52 + ((i * 37) % (H - 70)); px(gx, gy, P.grassHi); px(gx + 1, gy - 1, P.grassHi); } } function soilMound(cx, baseY, wet) { const c = wet > 0 ? P.soilWet : P.soil; const top = wet > 0 ? P.soilWetTop : P.soilTop; oval(cx, baseY + 3, 15, 5, P.soilDark); oval(cx, baseY + 1, 14, 4, c); oval(cx, baseY - 1, 13, 3, top); // a couple of pebbles / specks px(cx - 6, baseY, P.soilDark); px(cx + 5, baseY + 1, P.soilDark); } function bloomHeight(type) { return { sunflower: 30, dandelion: 20, daisy: 16, pumpkin: 6 }[type] || 16; } // green stem that leans slightly with `sway` near the top function stem(cx, baseY, top, sway) { const span = baseY - top; for (let y = baseY; y >= top; y--) { const f = span > 0 ? (baseY - y) / span : 0; r(cx - 1 + sway * f, y, 2, 1, P.stem); } return cx + sway; // x of the tip } function leaf(x, y, dir) { const d = dir; // -1 left, +1 right r(x, y, 3 * d, 1, P.leaf); r(x + d, y - 1, 3 * d, 1, P.leafHi); r(x + d, y + 1, 2 * d, 1, P.leaf); } function drawSunflower(hx, hy) { disc(hx, hy, 7, P.petalSun); // petal points const pts = [ [0, -8], [0, 8], [-8, 0], [8, 0], [-6, -6], [6, -6], [-6, 6], [6, 6], ]; for (const [dx, dy] of pts) disc(hx + dx * 0.7, hy + dy * 0.7, 2, P.petalSunHi); disc(hx, hy, 4, P.sunCenter); disc(hx - 1, hy - 1, 2, P.sunCenter2); px(hx + 1, hy + 1, P.sunCenter2); px(hx - 2, hy + 1, P.sunCenter2); } function drawDandelion(hx, hy) { disc(hx, hy, 5, P.dandel); const rag = [ [-6, 0], [6, 0], [0, -6], [0, 6], [-4, -4], [4, -4], [-4, 4], [4, 4], ]; for (const [dx, dy] of rag) px(hx + dx, hy + dy, P.dandel); disc(hx - 1, hy - 1, 2, P.dandelHi); } function drawDaisy(hx, hy) { const pet = [ [0, -4], [0, 4], [-4, 0], [4, 0], [-3, -3], [3, -3], [-3, 3], [3, 3], ]; for (const [dx, dy] of pet) disc(hx + dx, hy + dy, 2, P.white); for (const [dx, dy] of pet) px(hx + dx, hy + dy + 1, P.whiteShade); disc(hx, hy, 2, P.daisyCenter); } function drawUpright(p, cx, baseY, sway, stage) { const cfg = bloomHeight(p.type); if (stage === 0) { // a sprouting seed: a tiny green nub poking out of the soil r(cx - 1, baseY - 3, 2, 3, P.stem); px(cx - 2, baseY - 3, P.leafHi); px(cx + 1, baseY - 4, P.leafHi); return; } if (stage === 1) { const top = baseY - 7; stem(cx, baseY, top, sway * 0.4); leaf(cx, baseY - 4, -1); leaf(cx, baseY - 6, 1); return; } if (stage === 2) { const top = baseY - Math.round(cfg * 0.6); const tip = stem(cx, baseY, top, sway * 0.7); leaf(cx, baseY - 6, -1); leaf(cx, baseY - Math.round(cfg * 0.4), 1); // a closed bud, tinted toward the flower it will become const budCol = p.type === "daisy" ? P.bud : p.type === "sunflower" ? P.stemDark : P.bud; disc(tip, top - 1, 2, budCol); px(tip, top - 2, P.leafHi); return; } // stage 3 — bloom const top = baseY - cfg; const tip = stem(cx, baseY, top + 2, sway); leaf(cx, baseY - 7, -1); leaf(cx, baseY - Math.round(cfg * 0.5), 1); if (p.type === "sunflower") drawSunflower(tip, top); else if (p.type === "dandelion") drawDandelion(tip, top); else drawDaisy(tip, top); } function drawPumpkin(p, cx, baseY, sway, stage) { if (stage === 0) { r(cx - 1, baseY - 3, 2, 3, P.stem); px(cx + 1, baseY - 4, P.leafHi); return; } if (stage === 1) { // a vine creeping along the soil with a couple of leaves r(cx - 6, baseY, 12, 1, P.stemDark); leaf(cx - 5, baseY - 1, -1); leaf(cx + 4, baseY - 1, 1); return; } if (stage === 2) { // small unripe green pumpkin + a yellow blossom r(cx - 7, baseY, 14, 1, P.stemDark); oval(cx - 3, baseY - 2, 4, 3, P.stem); oval(cx - 3, baseY - 3, 3, 2, P.leafHi); disc(cx + 6, baseY - 4 + sway, 2, P.dandel); px(cx + 6, baseY - 4 + sway, P.daisyCenter); leaf(cx + 3, baseY - 1, 1); return; } // stage 3 — a plump ribbed pumpkin resting on the soil const py = baseY - 5; oval(cx, py, 10, 7, P.pumpkin); oval(cx - 5, py, 3, 6, P.pumpkinRib); oval(cx + 5, py, 3, 6, P.pumpkinRib); oval(cx, py, 2, 7, P.pumpkinDark); oval(cx - 3, py - 1, 2, 5, P.pumpkinHi); r(cx - 1, py - 8, 3, 3, P.stemDark); // a leaf and a little curling vine off to the side leaf(cx + 8, py - 5, 1); px(cx + 10, py - 6, P.stem); px(cx + 11, py - 5, P.stem); } function drawPlant(p, cx, baseY) { if (!p.type) return; const stage = stageOf(p); const sway = stage >= 2 ? Math.sin(t * 1.6 + p.phase) * 1.4 : 0; // soft shadow on the soil oval( cx, baseY + 2, p.type === "pumpkin" && stage === 3 ? 11 : 6, 2, P.shadow, ); if (p.type === "pumpkin") drawPumpkin(p, cx, baseY, sway, stage); else drawUpright(p, cx, baseY, sway, stage); } function drawCursor() { const { cx, baseY } = plotPos(cursor); if (Math.floor(t * 2.2) % 2 === 0) return; // blink const x0 = cx - 15, x1 = cx + 14, y0 = baseY - 8, y1 = baseY + 7, len = 5; const corners = [ [x0, y0, 1, 1], [x1, y0, -1, 1], [x0, y1, 1, -1], [x1, y1, -1, -1], ]; for (const [bx, by, sx, sy] of corners) { // a dark backing so the bracket reads on any colour, then the bright line r(bx, by + sy, len * sx, 1, P.cursorDark); r(bx + sx, by, 1, len * sy, P.cursorDark); r(bx, by, len * sx, 1, P.cursor); r(bx, by, 1, len * sy, P.cursor); } } function drawParticles() { for (const q of particles) { if (q.type === "drop") { r(q.x, q.y, 1, 2, P.drop); } else if (q.type === "star") { const c = q.life > q.max * 0.5 ? P.star : P.white; px(q.x, q.y - 1, c); px(q.x, q.y + 1, c); px(q.x - 1, q.y, c); px(q.x + 1, q.y, c); px(q.x, q.y, c); } else if (q.type === "poof") { disc(q.x, q.y, q.life > q.max * 0.5 ? 2 : 1, P.soilTop); } } } function drawButterfly() { const bx = (W / 2 + Math.sin(fly.t * 0.6) * 56) | 0; const by = (62 + Math.sin(fly.t * 1.3) * 14 + Math.cos(fly.t * 0.5) * 6) | 0; const up = Math.floor(fly.t * 9) % 2 === 0; px(bx, by, P.stemDark); // body px(bx, by + 1, P.stemDark); const wy = up ? -1 : 1; r(bx - 3, by + (up ? -1 : 0), 2, 2, "#ff9ecb"); r(bx + 2, by + (up ? -1 : 0), 2, 2, "#ff9ecb"); px(bx - 3, by + wy, "#ffd2e8"); px(bx + 3, by + wy, "#ffd2e8"); } function miniIcon(type, cx, cy) { if (type === "sunflower") { disc(cx, cy, 3, P.petalSun); disc(cx, cy, 1, P.sunCenter); } else if (type === "dandelion") { disc(cx, cy, 3, P.dandel); px(cx, cy, P.dandelHi); } else if (type === "daisy") { const pet = [ [0, -2], [0, 2], [-2, 0], [2, 0], ]; for (const [dx, dy] of pet) px(cx + dx, cy + dy, P.white); disc(cx, cy, 1, P.daisyCenter); } else if (type === "pumpkin") { oval(cx, cy + 1, 3, 2, P.pumpkin); px(cx, cy - 1, P.stemDark); } else { // dig — a little shovel: wooden handle + silver spade r(cx, cy - 4, 1, 4, "#9a6336"); r(cx - 2, cy, 5, 1, "#c3c8d2"); r(cx - 2, cy + 1, 5, 1, "#aeb4c0"); r(cx - 1, cy + 2, 3, 1, "#aeb4c0"); px(cx, cy + 3, "#9aa0ad"); } } function drawTray() { const ty = H - 14; r(0, ty, W, 14, P.trayLo); r(0, ty, W, 1, P.traySlot); const cw = W / TRAY.length; for (let i = 0; i < TRAY.length; i++) { const cx = (i * cw + cw / 2) | 0; const sel = i === seedIdx; if (sel) { r(i * cw + 3, ty + 1, cw - 6, 12, P.tray); // bright selection frame r(i * cw + 3, ty + 1, cw - 6, 1, P.traySel); r(i * cw + 3, ty + 12, cw - 6, 1, P.traySel); r(i * cw + 3, ty + 1, 1, 12, P.traySel); r(i * cw + cw - 4, ty + 1, 1, 12, P.traySel); } miniIcon(TRAY[i], cx, ty + 7 - (sel ? 1 : 0)); } } function render() { drawSky(); drawGround(); for (let i = 0; i < plots.length; i++) { const { cx, baseY } = plotPos(i); soilMound(cx, baseY, plots[i].wet); } // plants back-to-front so front rows overlap correctly for (let i = 0; i < plots.length; i++) { const { cx, baseY } = plotPos(i); drawPlant(plots[i], cx, baseY); } drawCursor(); drawButterfly(); drawParticles(); drawTray(); } // --- caption (DotGothic16 DOM line under the screen) ----------------------- function updateCaption() { if (!caption) return; const tool = TRAY[seedIdx]; if (tool === "dig") { caption.innerHTML = `✖ DIG UP` + `Ⓐ remove plant   Ⓑ next`; return; } const col = { sunflower: P.petalSun, dandelion: P.dandel, daisy: "#ffffff", pumpkin: P.pumpkin, }[tool]; caption.innerHTML = `✿ ${SEED_LABEL[tool]}` + `Ⓐ plant · water   Ⓑ next`; } function flash(el, _c) { if (!el) return; el.classList.remove("gb-flash"); void el.offsetWidth; el.classList.add("gb-flash"); } // --- loop ------------------------------------------------------------------ function frame(now) { if (!running) return; raf = requestAnimationFrame(frame); if (now - last < FRAME_MS - 1) return; // throttle to ~30fps const dt = Math.min(0.05, last ? (now - last) / 1000 : 0); last = now; update(dt); render(); } // --- input wiring ---------------------------------------------------------- function bindButton(el, action) { if (!el) return; el.addEventListener("click", (e) => { e.preventDefault(); input(action); }); } root .querySelectorAll("[data-dir]") .forEach((el) => bindButton(el, el.dataset.dir)); root .querySelectorAll("[data-action]") .forEach((el) => bindButton(el, el.dataset.action)); const TRAY_TOP = H - 14; // matches drawTray() function pointAt(e) { const rect = canvas.getBoundingClientRect(); return { mx: ((e.clientX - rect.left) / rect.width) * W, my: ((e.clientY - rect.top) / rect.height) * H, }; } // click the seed tray to pick a flower, or a plot to plant / water it canvas.addEventListener("click", (e) => { if (!running) return; const { mx, my } = pointAt(e); if (my >= TRAY_TOP) { const i = Math.floor(mx / (W / TRAY.length)); seedIdx = Math.min(TRAY.length - 1, Math.max(0, i)); updateCaption(); return; } let best = -1, bestD = 1e9; for (let i = 0; i < plots.length; i++) { const { cx, baseY } = plotPos(i); const d = (mx - cx) ** 2 + (my - (baseY - 4)) ** 2; if (d < bestD) { bestD = d; best = i; } } if (best >= 0 && bestD < 26 * 26) { cursor = best; act(); } }); // a pointer cursor over the clickable spots (tray + plots) hints they're interactive canvas.addEventListener("mousemove", (e) => { if (!running) return; const { mx, my } = pointAt(e); let hot = my >= TRAY_TOP; if (!hot) { for (let i = 0; i < plots.length; i++) { const { cx, baseY } = plotPos(i); if ((mx - cx) ** 2 + (my - (baseY - 4)) ** 2 < 26 * 26) { hot = true; break; } } } canvas.style.cursor = hot ? "pointer" : "default"; }); // arrows move; the A/B keys mirror the on-screen A/B buttons (z/x/space/enter alias A). const KEYS = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", a: "a", A: "a", z: "a", Z: "a", Enter: "a", " ": "a", b: "b", B: "b", x: "b", X: "b", }; function onKey(e) { if (!running) return; const action = KEYS[e.key]; if (!action) return; e.preventDefault(); e.stopPropagation(); input(action); } return { start() { if (running) return; running = true; last = 0; // 0 → the first frame renders immediately, then the gate kicks in updateCaption(); window.addEventListener("keydown", onKey, true); raf = requestAnimationFrame(frame); }, stop() { running = false; cancelAnimationFrame(raf); window.removeEventListener("keydown", onKey, true); }, }; }