Spaces:
Running on Zero
Running on Zero
| // 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 = | |
| `<span class="gb-seed" style="color:#cdd2e0">✖ DIG UP</span>` + | |
| `<span class="gb-hint">Ⓐ remove plant Ⓑ next</span>`; | |
| return; | |
| } | |
| const col = { | |
| sunflower: P.petalSun, | |
| dandelion: P.dandel, | |
| daisy: "#ffffff", | |
| pumpkin: P.pumpkin, | |
| }[tool]; | |
| caption.innerHTML = | |
| `<span class="gb-seed" style="color:${col}">✿ ${SEED_LABEL[tool]}</span>` + | |
| `<span class="gb-hint">Ⓐ plant · water Ⓑ next</span>`; | |
| } | |
| 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); | |
| }, | |
| }; | |
| } | |