LoFinity / frontend /garden.js
eloigil6's picture
Throttle render loop to 30fps for improved performance in garden animation. Updated frame handling in createGarden function to reduce CPU/GPU load while maintaining smooth visuals.
dda4750
Raw
History Blame Contribute Delete
22.7 kB
// 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 &nbsp; Ⓑ 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 &nbsp; Ⓑ 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);
},
};
}