Refactor HTML structure and enhance CSS for Game Boy modal. Improved the organization of meta tags in index.html for better readability and SEO. Updated style.css to optimize the Game Boy modal's appearance and interaction, including new styles for the d-pad and button interactions. Enhanced ui.js to integrate a garden mini-game within the Game Boy modal, ensuring a smoother user experience.
Browse files- frontend/garden.js +788 -0
- frontend/index.html +134 -55
- frontend/style.css +98 -25
- frontend/ui.js +6 -0
frontend/garden.js
ADDED
|
@@ -0,0 +1,788 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// LoFinity β the Game Boy mini-game: a tiny, relaxing garden.
|
| 2 |
+
// Plant a seed, water it, watch it grow. No score, no losing, nothing to chase.
|
| 3 |
+
// Rendered as Game-Boy-Color-style pixel art on a 160x144 canvas.
|
| 4 |
+
|
| 5 |
+
const W = 160; // Game Boy (Color) screen resolution
|
| 6 |
+
const H = 144;
|
| 7 |
+
|
| 8 |
+
const SEEDS = ["sunflower", "dandelion", "daisy", "pumpkin"];
|
| 9 |
+
// the bottom tray = the four seeds plus a "dig" tool for removing a plant
|
| 10 |
+
const TRAY = [...SEEDS, "dig"];
|
| 11 |
+
const SEED_LABEL = {
|
| 12 |
+
sunflower: "SUNFLOWER",
|
| 13 |
+
dandelion: "DANDELION",
|
| 14 |
+
daisy: "DAISY",
|
| 15 |
+
pumpkin: "PUMPKIN",
|
| 16 |
+
dig: "DIG UP",
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
// Garden plots: two rows of four, drawn back-to-front for a little depth.
|
| 20 |
+
const COLS = 4;
|
| 21 |
+
const ROWS = 2;
|
| 22 |
+
const COL_X = [29, 63, 97, 131]; // soil centre x per column
|
| 23 |
+
const ROW_Y = [84, 110]; // soil-top y per row (plants grow upward from here)
|
| 24 |
+
// each row is sheared sideways (~22Β°) so the front row sits offset from the
|
| 25 |
+
// back row instead of directly in front of it β the back plots stay visible.
|
| 26 |
+
const ROW_SHEAR = 12;
|
| 27 |
+
|
| 28 |
+
// Growth tuning β gentle. A watered plant blooms in ~10s; left dry it crawls.
|
| 29 |
+
const WET_TIME = 6; // seconds the soil stays damp after a watering
|
| 30 |
+
const WET_RATE = 0.5; // growth-stages per second while damp
|
| 31 |
+
const DRY_RATE = 0.1; // growth-stages per second while dry
|
| 32 |
+
const WATER_BUMP = 0.18; // instant nudge each time you water
|
| 33 |
+
|
| 34 |
+
// A cohesive, sunny GBC-ish palette.
|
| 35 |
+
const P = {
|
| 36 |
+
sky0: "#7ec8ff",
|
| 37 |
+
sky1: "#a6dcff",
|
| 38 |
+
sky2: "#cdeeff",
|
| 39 |
+
haze: "#e7f7ff",
|
| 40 |
+
sun: "#ffe45e",
|
| 41 |
+
sunHi: "#fff6c2",
|
| 42 |
+
cloud: "#ffffff",
|
| 43 |
+
cloudShade: "#d8eefc",
|
| 44 |
+
grass: "#7bcf5a",
|
| 45 |
+
grassHi: "#9be06f",
|
| 46 |
+
grassLo: "#57b045",
|
| 47 |
+
soilTop: "#b9824e",
|
| 48 |
+
soil: "#9a6336",
|
| 49 |
+
soilDark: "#74462450",
|
| 50 |
+
soilWetTop: "#7c5230",
|
| 51 |
+
soilWet: "#5f3c20",
|
| 52 |
+
shadow: "#3a6b3055",
|
| 53 |
+
stem: "#4faa46",
|
| 54 |
+
stemDark: "#2f7d3a",
|
| 55 |
+
leaf: "#6cc24a",
|
| 56 |
+
leafHi: "#8ed86a",
|
| 57 |
+
petalSun: "#ffc01e",
|
| 58 |
+
petalSunHi: "#ffe066",
|
| 59 |
+
sunCenter: "#7a431b",
|
| 60 |
+
sunCenter2: "#9c5a26",
|
| 61 |
+
dandel: "#ffd23f",
|
| 62 |
+
dandelHi: "#fff0a6",
|
| 63 |
+
white: "#ffffff",
|
| 64 |
+
whiteShade: "#dfe8f0",
|
| 65 |
+
daisyCenter: "#ffcb1f",
|
| 66 |
+
pumpkin: "#ff8a3d",
|
| 67 |
+
pumpkinHi: "#ffb072",
|
| 68 |
+
pumpkinDark: "#e0651f",
|
| 69 |
+
pumpkinRib: "#c8531a",
|
| 70 |
+
bud: "#9bd06a",
|
| 71 |
+
drop: "#7cc6ff",
|
| 72 |
+
star: "#fff6c2",
|
| 73 |
+
cursor: "#ffffff",
|
| 74 |
+
cursorDark: "#1c2540",
|
| 75 |
+
tray: "#2a2350",
|
| 76 |
+
trayLo: "#1a1530",
|
| 77 |
+
traySlot: "#3a3070",
|
| 78 |
+
traySel: "#ffe45e",
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
export function createGarden(root) {
|
| 82 |
+
const canvas = root.querySelector("#gb-canvas");
|
| 83 |
+
const caption = root.querySelector("#gb-caption");
|
| 84 |
+
const ctx = canvas.getContext("2d");
|
| 85 |
+
ctx.imageSmoothingEnabled = false;
|
| 86 |
+
|
| 87 |
+
// --- state (persists across opens β your garden stays put) -----------------
|
| 88 |
+
// progress (0..3 β seed, sprout, bud, bloom) is the single source of truth;
|
| 89 |
+
// the drawn stage is always floor(progress), so the art never lags the state.
|
| 90 |
+
const plots = Array.from({ length: COLS * ROWS }, (_, i) => ({
|
| 91 |
+
type: null,
|
| 92 |
+
progress: 0,
|
| 93 |
+
bloomed: false, // tracks the one-shot bloom sparkle
|
| 94 |
+
wet: 0, // seconds of dampness left
|
| 95 |
+
phase: i * 1.7, // desync the sway
|
| 96 |
+
}));
|
| 97 |
+
const stageOf = (p) => Math.min(3, Math.floor(p.progress));
|
| 98 |
+
let cursor = 0; // selected plot index
|
| 99 |
+
let seedIdx = 0; // selected seed type
|
| 100 |
+
const particles = [];
|
| 101 |
+
const cloud = { x: 116 };
|
| 102 |
+
const fly = { t: 0 }; // butterfly clock
|
| 103 |
+
let t = 0; // game clock (seconds)
|
| 104 |
+
|
| 105 |
+
let running = false;
|
| 106 |
+
let raf = 0;
|
| 107 |
+
let last = 0;
|
| 108 |
+
|
| 109 |
+
// --- helpers ---------------------------------------------------------------
|
| 110 |
+
const r = (x, y, w, h, c) => {
|
| 111 |
+
ctx.fillStyle = c;
|
| 112 |
+
ctx.fillRect(x | 0, y | 0, w | 0, h | 0);
|
| 113 |
+
};
|
| 114 |
+
const px = (x, y, c) => {
|
| 115 |
+
ctx.fillStyle = c;
|
| 116 |
+
ctx.fillRect(x | 0, y | 0, 1, 1);
|
| 117 |
+
};
|
| 118 |
+
const disc = (cx, cy, rad, c) => {
|
| 119 |
+
ctx.fillStyle = c;
|
| 120 |
+
for (let y = -rad; y <= rad; y++) {
|
| 121 |
+
const w = Math.floor(Math.sqrt(rad * rad - y * y));
|
| 122 |
+
ctx.fillRect((cx - w) | 0, (cy + y) | 0, 2 * w + 1, 1);
|
| 123 |
+
}
|
| 124 |
+
};
|
| 125 |
+
const oval = (cx, cy, rx, ry, c) => {
|
| 126 |
+
ctx.fillStyle = c;
|
| 127 |
+
for (let y = -ry; y <= ry; y++) {
|
| 128 |
+
const w = Math.floor(rx * Math.sqrt(1 - (y * y) / (ry * ry)));
|
| 129 |
+
ctx.fillRect((cx - w) | 0, (cy + y) | 0, 2 * w + 1, 1);
|
| 130 |
+
}
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
function plotPos(i) {
|
| 134 |
+
const col = i % COLS;
|
| 135 |
+
const row = Math.floor(i / COLS);
|
| 136 |
+
// shear rows around the centre so the layout leans diagonally
|
| 137 |
+
return {
|
| 138 |
+
cx: COL_X[col] + (row - (ROWS - 1) / 2) * ROW_SHEAR,
|
| 139 |
+
baseY: ROW_Y[row],
|
| 140 |
+
};
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
// --- input -----------------------------------------------------------------
|
| 144 |
+
function move(dx, dy) {
|
| 145 |
+
const col = cursor % COLS;
|
| 146 |
+
const row = Math.floor(cursor / COLS);
|
| 147 |
+
const nc = Math.min(COLS - 1, Math.max(0, col + dx));
|
| 148 |
+
const nr = Math.min(ROWS - 1, Math.max(0, row + dy));
|
| 149 |
+
cursor = nr * COLS + nc;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
function spawnDrops(cx, baseY) {
|
| 153 |
+
for (let i = 0; i < 6; i++) {
|
| 154 |
+
particles.push({
|
| 155 |
+
type: "drop",
|
| 156 |
+
x: cx - 8 + Math.floor((i / 6) * 16),
|
| 157 |
+
y: baseY - 26 - (i % 3) * 4,
|
| 158 |
+
vy: 36 + (i % 3) * 8,
|
| 159 |
+
life: 0.7,
|
| 160 |
+
max: 0.7,
|
| 161 |
+
});
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
function spawnStars(cx, cy) {
|
| 165 |
+
for (let i = 0; i < 6; i++) {
|
| 166 |
+
const a = (i / 6) * Math.PI * 2;
|
| 167 |
+
particles.push({
|
| 168 |
+
type: "star",
|
| 169 |
+
x: cx + Math.cos(a) * 8,
|
| 170 |
+
y: cy + Math.sin(a) * 8,
|
| 171 |
+
vx: Math.cos(a) * 6,
|
| 172 |
+
vy: Math.sin(a) * 6 - 4,
|
| 173 |
+
life: 0.9,
|
| 174 |
+
max: 0.9,
|
| 175 |
+
});
|
| 176 |
+
}
|
| 177 |
+
}
|
| 178 |
+
function spawnPoof(cx, baseY) {
|
| 179 |
+
for (let i = 0; i < 8; i++) {
|
| 180 |
+
particles.push({
|
| 181 |
+
type: "poof",
|
| 182 |
+
x: cx - 6 + (i % 4) * 4,
|
| 183 |
+
y: baseY - 4 - Math.floor(i / 4) * 4,
|
| 184 |
+
vx: ((i % 4) - 1.5) * 8,
|
| 185 |
+
vy: -10 - (i % 3) * 6,
|
| 186 |
+
life: 0.6,
|
| 187 |
+
max: 0.6,
|
| 188 |
+
});
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
// A button: with the dig tool selected, remove the plant; with a seed
|
| 193 |
+
// selected, plant on an empty plot or water what's already growing.
|
| 194 |
+
function act() {
|
| 195 |
+
if (TRAY[seedIdx] === "dig") {
|
| 196 |
+
clearPlot();
|
| 197 |
+
return;
|
| 198 |
+
}
|
| 199 |
+
const p = plots[cursor];
|
| 200 |
+
const { cx, baseY } = plotPos(cursor);
|
| 201 |
+
if (!p.type) {
|
| 202 |
+
// just sow the seed β it grows slowly until you water it
|
| 203 |
+
p.type = SEEDS[seedIdx];
|
| 204 |
+
p.progress = 0;
|
| 205 |
+
p.bloomed = false;
|
| 206 |
+
p.wet = 0;
|
| 207 |
+
} else {
|
| 208 |
+
p.wet = WET_TIME;
|
| 209 |
+
if (p.progress < 3) p.progress = Math.min(3, p.progress + WATER_BUMP);
|
| 210 |
+
spawnDrops(cx, baseY);
|
| 211 |
+
if (p.progress >= 3) spawnStars(cx, baseY - bloomHeight(p.type));
|
| 212 |
+
flash(caption, P.drop);
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
function cycleSeed(dir = 1) {
|
| 217 |
+
seedIdx = (seedIdx + dir + TRAY.length) % TRAY.length;
|
| 218 |
+
updateCaption();
|
| 219 |
+
}
|
| 220 |
+
|
| 221 |
+
// dig up the current plot so you can replant / rearrange (Bβdig tool, or START)
|
| 222 |
+
function clearPlot() {
|
| 223 |
+
const p = plots[cursor];
|
| 224 |
+
if (!p.type) return;
|
| 225 |
+
const { cx, baseY } = plotPos(cursor);
|
| 226 |
+
p.type = null;
|
| 227 |
+
p.progress = 0;
|
| 228 |
+
p.bloomed = false;
|
| 229 |
+
p.wet = 0;
|
| 230 |
+
spawnPoof(cx, baseY);
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
function input(action) {
|
| 234 |
+
if (!running) return;
|
| 235 |
+
switch (action) {
|
| 236 |
+
case "up":
|
| 237 |
+
move(0, -1);
|
| 238 |
+
break;
|
| 239 |
+
case "down":
|
| 240 |
+
move(0, 1);
|
| 241 |
+
break;
|
| 242 |
+
case "left":
|
| 243 |
+
move(-1, 0);
|
| 244 |
+
break;
|
| 245 |
+
case "right":
|
| 246 |
+
move(1, 0);
|
| 247 |
+
break;
|
| 248 |
+
case "a":
|
| 249 |
+
act();
|
| 250 |
+
break;
|
| 251 |
+
case "b":
|
| 252 |
+
cycleSeed(1);
|
| 253 |
+
break;
|
| 254 |
+
case "select":
|
| 255 |
+
cycleSeed(1);
|
| 256 |
+
break;
|
| 257 |
+
case "start":
|
| 258 |
+
clearPlot();
|
| 259 |
+
break;
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
// --- update ----------------------------------------------------------------
|
| 264 |
+
function update(dt) {
|
| 265 |
+
t += dt;
|
| 266 |
+
fly.t += dt;
|
| 267 |
+
cloud.x -= dt * 4;
|
| 268 |
+
if (cloud.x < -34) cloud.x = W + 6;
|
| 269 |
+
|
| 270 |
+
for (const p of plots) {
|
| 271 |
+
if (!p.type) continue;
|
| 272 |
+
if (p.wet > 0) {
|
| 273 |
+
p.wet = Math.max(0, p.wet - dt);
|
| 274 |
+
p.progress = Math.min(3, p.progress + WET_RATE * dt);
|
| 275 |
+
} else {
|
| 276 |
+
p.progress = Math.min(3, p.progress + DRY_RATE * dt);
|
| 277 |
+
}
|
| 278 |
+
if (p.progress >= 3 && !p.bloomed) {
|
| 279 |
+
p.bloomed = true;
|
| 280 |
+
const { cx, baseY } = plotPos(plots.indexOf(p));
|
| 281 |
+
spawnStars(cx, baseY - bloomHeight(p.type));
|
| 282 |
+
}
|
| 283 |
+
}
|
| 284 |
+
|
| 285 |
+
for (let i = particles.length - 1; i >= 0; i--) {
|
| 286 |
+
const q = particles[i];
|
| 287 |
+
q.life -= dt;
|
| 288 |
+
if (q.life <= 0) {
|
| 289 |
+
particles.splice(i, 1);
|
| 290 |
+
continue;
|
| 291 |
+
}
|
| 292 |
+
q.x += (q.vx || 0) * dt;
|
| 293 |
+
q.y += (q.vy || 0) * dt;
|
| 294 |
+
if (q.type === "drop") q.vy += 120 * dt;
|
| 295 |
+
if (q.type === "poof") q.vy += 40 * dt;
|
| 296 |
+
}
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
// --- drawing ---------------------------------------------------------------
|
| 300 |
+
function drawSky() {
|
| 301 |
+
r(0, 0, W, 14, P.sky0);
|
| 302 |
+
r(0, 14, W, 12, P.sky1);
|
| 303 |
+
r(0, 26, W, 10, P.sky2);
|
| 304 |
+
r(0, 36, W, 6, P.haze);
|
| 305 |
+
|
| 306 |
+
// sun, top-left, with a few soft rays
|
| 307 |
+
const sx = 22,
|
| 308 |
+
sy = 22;
|
| 309 |
+
for (let a = 0; a < 8; a++) {
|
| 310 |
+
const ang = (a / 8) * Math.PI * 2 + Math.sin(t * 0.4) * 0.05;
|
| 311 |
+
px(sx + Math.cos(ang) * 13, sy + Math.sin(ang) * 13, P.sunHi);
|
| 312 |
+
px(sx + Math.cos(ang) * 14, sy + Math.sin(ang) * 14, P.sunHi);
|
| 313 |
+
}
|
| 314 |
+
disc(sx, sy, 9, P.sun);
|
| 315 |
+
disc(sx - 2, sy - 2, 4, P.sunHi);
|
| 316 |
+
|
| 317 |
+
// a drifting cloud
|
| 318 |
+
const cx = cloud.x | 0,
|
| 319 |
+
cy = 18;
|
| 320 |
+
oval(cx, cy + 2, 16, 5, P.cloudShade);
|
| 321 |
+
oval(cx, cy, 16, 5, P.cloud);
|
| 322 |
+
disc(cx - 7, cy - 2, 5, P.cloud);
|
| 323 |
+
disc(cx + 6, cy - 1, 6, P.cloud);
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
function drawGround() {
|
| 327 |
+
r(0, 42, W, H - 42, P.grass);
|
| 328 |
+
// a brighter strip near the top of the field + a darker foreground
|
| 329 |
+
r(0, 42, W, 4, P.grassHi);
|
| 330 |
+
r(0, H - 16, W, 16, P.grassLo);
|
| 331 |
+
// scattered grass tufts for texture
|
| 332 |
+
for (let i = 0; i < 16; i++) {
|
| 333 |
+
const gx = (i * 53 + 11) % W;
|
| 334 |
+
const gy = 52 + ((i * 37) % (H - 70));
|
| 335 |
+
px(gx, gy, P.grassHi);
|
| 336 |
+
px(gx + 1, gy - 1, P.grassHi);
|
| 337 |
+
}
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
function soilMound(cx, baseY, wet) {
|
| 341 |
+
const c = wet > 0 ? P.soilWet : P.soil;
|
| 342 |
+
const top = wet > 0 ? P.soilWetTop : P.soilTop;
|
| 343 |
+
oval(cx, baseY + 3, 15, 5, P.soilDark);
|
| 344 |
+
oval(cx, baseY + 1, 14, 4, c);
|
| 345 |
+
oval(cx, baseY - 1, 13, 3, top);
|
| 346 |
+
// a couple of pebbles / specks
|
| 347 |
+
px(cx - 6, baseY, P.soilDark);
|
| 348 |
+
px(cx + 5, baseY + 1, P.soilDark);
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
function bloomHeight(type) {
|
| 352 |
+
return { sunflower: 30, dandelion: 20, daisy: 16, pumpkin: 6 }[type] || 16;
|
| 353 |
+
}
|
| 354 |
+
|
| 355 |
+
// green stem that leans slightly with `sway` near the top
|
| 356 |
+
function stem(cx, baseY, top, sway) {
|
| 357 |
+
const span = baseY - top;
|
| 358 |
+
for (let y = baseY; y >= top; y--) {
|
| 359 |
+
const f = span > 0 ? (baseY - y) / span : 0;
|
| 360 |
+
r(cx - 1 + sway * f, y, 2, 1, P.stem);
|
| 361 |
+
}
|
| 362 |
+
return cx + sway; // x of the tip
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
function leaf(x, y, dir) {
|
| 366 |
+
const d = dir; // -1 left, +1 right
|
| 367 |
+
r(x, y, 3 * d, 1, P.leaf);
|
| 368 |
+
r(x + d, y - 1, 3 * d, 1, P.leafHi);
|
| 369 |
+
r(x + d, y + 1, 2 * d, 1, P.leaf);
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
function drawSunflower(hx, hy) {
|
| 373 |
+
disc(hx, hy, 7, P.petalSun);
|
| 374 |
+
// petal points
|
| 375 |
+
const pts = [
|
| 376 |
+
[0, -8],
|
| 377 |
+
[0, 8],
|
| 378 |
+
[-8, 0],
|
| 379 |
+
[8, 0],
|
| 380 |
+
[-6, -6],
|
| 381 |
+
[6, -6],
|
| 382 |
+
[-6, 6],
|
| 383 |
+
[6, 6],
|
| 384 |
+
];
|
| 385 |
+
for (const [dx, dy] of pts)
|
| 386 |
+
disc(hx + dx * 0.7, hy + dy * 0.7, 2, P.petalSunHi);
|
| 387 |
+
disc(hx, hy, 4, P.sunCenter);
|
| 388 |
+
disc(hx - 1, hy - 1, 2, P.sunCenter2);
|
| 389 |
+
px(hx + 1, hy + 1, P.sunCenter2);
|
| 390 |
+
px(hx - 2, hy + 1, P.sunCenter2);
|
| 391 |
+
}
|
| 392 |
+
function drawDandelion(hx, hy) {
|
| 393 |
+
disc(hx, hy, 5, P.dandel);
|
| 394 |
+
const rag = [
|
| 395 |
+
[-6, 0],
|
| 396 |
+
[6, 0],
|
| 397 |
+
[0, -6],
|
| 398 |
+
[0, 6],
|
| 399 |
+
[-4, -4],
|
| 400 |
+
[4, -4],
|
| 401 |
+
[-4, 4],
|
| 402 |
+
[4, 4],
|
| 403 |
+
];
|
| 404 |
+
for (const [dx, dy] of rag) px(hx + dx, hy + dy, P.dandel);
|
| 405 |
+
disc(hx - 1, hy - 1, 2, P.dandelHi);
|
| 406 |
+
}
|
| 407 |
+
function drawDaisy(hx, hy) {
|
| 408 |
+
const pet = [
|
| 409 |
+
[0, -4],
|
| 410 |
+
[0, 4],
|
| 411 |
+
[-4, 0],
|
| 412 |
+
[4, 0],
|
| 413 |
+
[-3, -3],
|
| 414 |
+
[3, -3],
|
| 415 |
+
[-3, 3],
|
| 416 |
+
[3, 3],
|
| 417 |
+
];
|
| 418 |
+
for (const [dx, dy] of pet) disc(hx + dx, hy + dy, 2, P.white);
|
| 419 |
+
for (const [dx, dy] of pet) px(hx + dx, hy + dy + 1, P.whiteShade);
|
| 420 |
+
disc(hx, hy, 2, P.daisyCenter);
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
function drawUpright(p, cx, baseY, sway, stage) {
|
| 424 |
+
const cfg = bloomHeight(p.type);
|
| 425 |
+
if (stage === 0) {
|
| 426 |
+
// a sprouting seed: a tiny green nub poking out of the soil
|
| 427 |
+
r(cx - 1, baseY - 3, 2, 3, P.stem);
|
| 428 |
+
px(cx - 2, baseY - 3, P.leafHi);
|
| 429 |
+
px(cx + 1, baseY - 4, P.leafHi);
|
| 430 |
+
return;
|
| 431 |
+
}
|
| 432 |
+
if (stage === 1) {
|
| 433 |
+
const top = baseY - 7;
|
| 434 |
+
stem(cx, baseY, top, sway * 0.4);
|
| 435 |
+
leaf(cx, baseY - 4, -1);
|
| 436 |
+
leaf(cx, baseY - 6, 1);
|
| 437 |
+
return;
|
| 438 |
+
}
|
| 439 |
+
if (stage === 2) {
|
| 440 |
+
const top = baseY - Math.round(cfg * 0.6);
|
| 441 |
+
const tip = stem(cx, baseY, top, sway * 0.7);
|
| 442 |
+
leaf(cx, baseY - 6, -1);
|
| 443 |
+
leaf(cx, baseY - Math.round(cfg * 0.4), 1);
|
| 444 |
+
// a closed bud, tinted toward the flower it will become
|
| 445 |
+
const budCol =
|
| 446 |
+
p.type === "daisy"
|
| 447 |
+
? P.bud
|
| 448 |
+
: p.type === "sunflower"
|
| 449 |
+
? P.stemDark
|
| 450 |
+
: P.bud;
|
| 451 |
+
disc(tip, top - 1, 2, budCol);
|
| 452 |
+
px(tip, top - 2, P.leafHi);
|
| 453 |
+
return;
|
| 454 |
+
}
|
| 455 |
+
// stage 3 β bloom
|
| 456 |
+
const top = baseY - cfg;
|
| 457 |
+
const tip = stem(cx, baseY, top + 2, sway);
|
| 458 |
+
leaf(cx, baseY - 7, -1);
|
| 459 |
+
leaf(cx, baseY - Math.round(cfg * 0.5), 1);
|
| 460 |
+
if (p.type === "sunflower") drawSunflower(tip, top);
|
| 461 |
+
else if (p.type === "dandelion") drawDandelion(tip, top);
|
| 462 |
+
else drawDaisy(tip, top);
|
| 463 |
+
}
|
| 464 |
+
|
| 465 |
+
function drawPumpkin(p, cx, baseY, sway, stage) {
|
| 466 |
+
if (stage === 0) {
|
| 467 |
+
r(cx - 1, baseY - 3, 2, 3, P.stem);
|
| 468 |
+
px(cx + 1, baseY - 4, P.leafHi);
|
| 469 |
+
return;
|
| 470 |
+
}
|
| 471 |
+
if (stage === 1) {
|
| 472 |
+
// a vine creeping along the soil with a couple of leaves
|
| 473 |
+
r(cx - 6, baseY, 12, 1, P.stemDark);
|
| 474 |
+
leaf(cx - 5, baseY - 1, -1);
|
| 475 |
+
leaf(cx + 4, baseY - 1, 1);
|
| 476 |
+
return;
|
| 477 |
+
}
|
| 478 |
+
if (stage === 2) {
|
| 479 |
+
// small unripe green pumpkin + a yellow blossom
|
| 480 |
+
r(cx - 7, baseY, 14, 1, P.stemDark);
|
| 481 |
+
oval(cx - 3, baseY - 2, 4, 3, P.stem);
|
| 482 |
+
oval(cx - 3, baseY - 3, 3, 2, P.leafHi);
|
| 483 |
+
disc(cx + 6, baseY - 4 + sway, 2, P.dandel);
|
| 484 |
+
px(cx + 6, baseY - 4 + sway, P.daisyCenter);
|
| 485 |
+
leaf(cx + 3, baseY - 1, 1);
|
| 486 |
+
return;
|
| 487 |
+
}
|
| 488 |
+
// stage 3 β a plump ribbed pumpkin resting on the soil
|
| 489 |
+
const py = baseY - 5;
|
| 490 |
+
oval(cx, py, 10, 7, P.pumpkin);
|
| 491 |
+
oval(cx - 5, py, 3, 6, P.pumpkinRib);
|
| 492 |
+
oval(cx + 5, py, 3, 6, P.pumpkinRib);
|
| 493 |
+
oval(cx, py, 2, 7, P.pumpkinDark);
|
| 494 |
+
oval(cx - 3, py - 1, 2, 5, P.pumpkinHi);
|
| 495 |
+
r(cx - 1, py - 8, 3, 3, P.stemDark);
|
| 496 |
+
// a leaf and a little curling vine off to the side
|
| 497 |
+
leaf(cx + 8, py - 5, 1);
|
| 498 |
+
px(cx + 10, py - 6, P.stem);
|
| 499 |
+
px(cx + 11, py - 5, P.stem);
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
function drawPlant(p, cx, baseY) {
|
| 503 |
+
if (!p.type) return;
|
| 504 |
+
const stage = stageOf(p);
|
| 505 |
+
const sway = stage >= 2 ? Math.sin(t * 1.6 + p.phase) * 1.4 : 0;
|
| 506 |
+
// soft shadow on the soil
|
| 507 |
+
oval(
|
| 508 |
+
cx,
|
| 509 |
+
baseY + 2,
|
| 510 |
+
p.type === "pumpkin" && stage === 3 ? 11 : 6,
|
| 511 |
+
2,
|
| 512 |
+
P.shadow,
|
| 513 |
+
);
|
| 514 |
+
if (p.type === "pumpkin") drawPumpkin(p, cx, baseY, sway, stage);
|
| 515 |
+
else drawUpright(p, cx, baseY, sway, stage);
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
function drawCursor() {
|
| 519 |
+
const { cx, baseY } = plotPos(cursor);
|
| 520 |
+
if (Math.floor(t * 2.2) % 2 === 0) return; // blink
|
| 521 |
+
const x0 = cx - 15,
|
| 522 |
+
x1 = cx + 14,
|
| 523 |
+
y0 = baseY - 8,
|
| 524 |
+
y1 = baseY + 7,
|
| 525 |
+
len = 5;
|
| 526 |
+
const corners = [
|
| 527 |
+
[x0, y0, 1, 1],
|
| 528 |
+
[x1, y0, -1, 1],
|
| 529 |
+
[x0, y1, 1, -1],
|
| 530 |
+
[x1, y1, -1, -1],
|
| 531 |
+
];
|
| 532 |
+
for (const [bx, by, sx, sy] of corners) {
|
| 533 |
+
// a dark backing so the bracket reads on any colour, then the bright line
|
| 534 |
+
r(bx, by + sy, len * sx, 1, P.cursorDark);
|
| 535 |
+
r(bx + sx, by, 1, len * sy, P.cursorDark);
|
| 536 |
+
r(bx, by, len * sx, 1, P.cursor);
|
| 537 |
+
r(bx, by, 1, len * sy, P.cursor);
|
| 538 |
+
}
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
function drawParticles() {
|
| 542 |
+
for (const q of particles) {
|
| 543 |
+
if (q.type === "drop") {
|
| 544 |
+
r(q.x, q.y, 1, 2, P.drop);
|
| 545 |
+
} else if (q.type === "star") {
|
| 546 |
+
const c = q.life > q.max * 0.5 ? P.star : P.white;
|
| 547 |
+
px(q.x, q.y - 1, c);
|
| 548 |
+
px(q.x, q.y + 1, c);
|
| 549 |
+
px(q.x - 1, q.y, c);
|
| 550 |
+
px(q.x + 1, q.y, c);
|
| 551 |
+
px(q.x, q.y, c);
|
| 552 |
+
} else if (q.type === "poof") {
|
| 553 |
+
disc(q.x, q.y, q.life > q.max * 0.5 ? 2 : 1, P.soilTop);
|
| 554 |
+
}
|
| 555 |
+
}
|
| 556 |
+
}
|
| 557 |
+
|
| 558 |
+
function drawButterfly() {
|
| 559 |
+
const bx = (W / 2 + Math.sin(fly.t * 0.6) * 56) | 0;
|
| 560 |
+
const by =
|
| 561 |
+
(62 + Math.sin(fly.t * 1.3) * 14 + Math.cos(fly.t * 0.5) * 6) | 0;
|
| 562 |
+
const up = Math.floor(fly.t * 9) % 2 === 0;
|
| 563 |
+
px(bx, by, P.stemDark); // body
|
| 564 |
+
px(bx, by + 1, P.stemDark);
|
| 565 |
+
const wy = up ? -1 : 1;
|
| 566 |
+
r(bx - 3, by + (up ? -1 : 0), 2, 2, "#ff9ecb");
|
| 567 |
+
r(bx + 2, by + (up ? -1 : 0), 2, 2, "#ff9ecb");
|
| 568 |
+
px(bx - 3, by + wy, "#ffd2e8");
|
| 569 |
+
px(bx + 3, by + wy, "#ffd2e8");
|
| 570 |
+
}
|
| 571 |
+
|
| 572 |
+
function miniIcon(type, cx, cy) {
|
| 573 |
+
if (type === "sunflower") {
|
| 574 |
+
disc(cx, cy, 3, P.petalSun);
|
| 575 |
+
disc(cx, cy, 1, P.sunCenter);
|
| 576 |
+
} else if (type === "dandelion") {
|
| 577 |
+
disc(cx, cy, 3, P.dandel);
|
| 578 |
+
px(cx, cy, P.dandelHi);
|
| 579 |
+
} else if (type === "daisy") {
|
| 580 |
+
const pet = [
|
| 581 |
+
[0, -2],
|
| 582 |
+
[0, 2],
|
| 583 |
+
[-2, 0],
|
| 584 |
+
[2, 0],
|
| 585 |
+
];
|
| 586 |
+
for (const [dx, dy] of pet) px(cx + dx, cy + dy, P.white);
|
| 587 |
+
disc(cx, cy, 1, P.daisyCenter);
|
| 588 |
+
} else if (type === "pumpkin") {
|
| 589 |
+
oval(cx, cy + 1, 3, 2, P.pumpkin);
|
| 590 |
+
px(cx, cy - 1, P.stemDark);
|
| 591 |
+
} else {
|
| 592 |
+
// dig β a little shovel: wooden handle + silver spade
|
| 593 |
+
r(cx, cy - 4, 1, 4, "#9a6336");
|
| 594 |
+
r(cx - 2, cy, 5, 1, "#c3c8d2");
|
| 595 |
+
r(cx - 2, cy + 1, 5, 1, "#aeb4c0");
|
| 596 |
+
r(cx - 1, cy + 2, 3, 1, "#aeb4c0");
|
| 597 |
+
px(cx, cy + 3, "#9aa0ad");
|
| 598 |
+
}
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
function drawTray() {
|
| 602 |
+
const ty = H - 14;
|
| 603 |
+
r(0, ty, W, 14, P.trayLo);
|
| 604 |
+
r(0, ty, W, 1, P.traySlot);
|
| 605 |
+
const cw = W / TRAY.length;
|
| 606 |
+
for (let i = 0; i < TRAY.length; i++) {
|
| 607 |
+
const cx = (i * cw + cw / 2) | 0;
|
| 608 |
+
const sel = i === seedIdx;
|
| 609 |
+
if (sel) {
|
| 610 |
+
r(i * cw + 3, ty + 1, cw - 6, 12, P.tray);
|
| 611 |
+
// bright selection frame
|
| 612 |
+
r(i * cw + 3, ty + 1, cw - 6, 1, P.traySel);
|
| 613 |
+
r(i * cw + 3, ty + 12, cw - 6, 1, P.traySel);
|
| 614 |
+
r(i * cw + 3, ty + 1, 1, 12, P.traySel);
|
| 615 |
+
r(i * cw + cw - 4, ty + 1, 1, 12, P.traySel);
|
| 616 |
+
}
|
| 617 |
+
miniIcon(TRAY[i], cx, ty + 7 - (sel ? 1 : 0));
|
| 618 |
+
}
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
function render() {
|
| 622 |
+
drawSky();
|
| 623 |
+
drawGround();
|
| 624 |
+
for (let i = 0; i < plots.length; i++) {
|
| 625 |
+
const { cx, baseY } = plotPos(i);
|
| 626 |
+
soilMound(cx, baseY, plots[i].wet);
|
| 627 |
+
}
|
| 628 |
+
// plants back-to-front so front rows overlap correctly
|
| 629 |
+
for (let i = 0; i < plots.length; i++) {
|
| 630 |
+
const { cx, baseY } = plotPos(i);
|
| 631 |
+
drawPlant(plots[i], cx, baseY);
|
| 632 |
+
}
|
| 633 |
+
drawCursor();
|
| 634 |
+
drawButterfly();
|
| 635 |
+
drawParticles();
|
| 636 |
+
drawTray();
|
| 637 |
+
}
|
| 638 |
+
|
| 639 |
+
// --- caption (DotGothic16 DOM line under the screen) -----------------------
|
| 640 |
+
function updateCaption() {
|
| 641 |
+
if (!caption) return;
|
| 642 |
+
const tool = TRAY[seedIdx];
|
| 643 |
+
if (tool === "dig") {
|
| 644 |
+
caption.innerHTML =
|
| 645 |
+
`<span class="gb-seed" style="color:#cdd2e0">β DIG UP</span>` +
|
| 646 |
+
`<span class="gb-hint">βΆ remove plant β· next</span>`;
|
| 647 |
+
return;
|
| 648 |
+
}
|
| 649 |
+
const col = {
|
| 650 |
+
sunflower: P.petalSun,
|
| 651 |
+
dandelion: P.dandel,
|
| 652 |
+
daisy: "#ffffff",
|
| 653 |
+
pumpkin: P.pumpkin,
|
| 654 |
+
}[tool];
|
| 655 |
+
caption.innerHTML =
|
| 656 |
+
`<span class="gb-seed" style="color:${col}">βΏ ${SEED_LABEL[tool]}</span>` +
|
| 657 |
+
`<span class="gb-hint">βΆ plant Β· water β· next</span>`;
|
| 658 |
+
}
|
| 659 |
+
function flash(el, _c) {
|
| 660 |
+
if (!el) return;
|
| 661 |
+
el.classList.remove("gb-flash");
|
| 662 |
+
void el.offsetWidth;
|
| 663 |
+
el.classList.add("gb-flash");
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
// --- loop ------------------------------------------------------------------
|
| 667 |
+
function frame(now) {
|
| 668 |
+
if (!running) return;
|
| 669 |
+
const dt = Math.min(0.05, (now - last) / 1000 || 0);
|
| 670 |
+
last = now;
|
| 671 |
+
update(dt);
|
| 672 |
+
render();
|
| 673 |
+
raf = requestAnimationFrame(frame);
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
// --- input wiring ----------------------------------------------------------
|
| 677 |
+
function bindButton(el, action) {
|
| 678 |
+
if (!el) return;
|
| 679 |
+
el.addEventListener("click", (e) => {
|
| 680 |
+
e.preventDefault();
|
| 681 |
+
input(action);
|
| 682 |
+
});
|
| 683 |
+
}
|
| 684 |
+
root
|
| 685 |
+
.querySelectorAll("[data-dir]")
|
| 686 |
+
.forEach((el) => bindButton(el, el.dataset.dir));
|
| 687 |
+
root
|
| 688 |
+
.querySelectorAll("[data-action]")
|
| 689 |
+
.forEach((el) => bindButton(el, el.dataset.action));
|
| 690 |
+
|
| 691 |
+
const TRAY_TOP = H - 14; // matches drawTray()
|
| 692 |
+
|
| 693 |
+
function pointAt(e) {
|
| 694 |
+
const rect = canvas.getBoundingClientRect();
|
| 695 |
+
return {
|
| 696 |
+
mx: ((e.clientX - rect.left) / rect.width) * W,
|
| 697 |
+
my: ((e.clientY - rect.top) / rect.height) * H,
|
| 698 |
+
};
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
// click the seed tray to pick a flower, or a plot to plant / water it
|
| 702 |
+
canvas.addEventListener("click", (e) => {
|
| 703 |
+
if (!running) return;
|
| 704 |
+
const { mx, my } = pointAt(e);
|
| 705 |
+
if (my >= TRAY_TOP) {
|
| 706 |
+
const i = Math.floor(mx / (W / TRAY.length));
|
| 707 |
+
seedIdx = Math.min(TRAY.length - 1, Math.max(0, i));
|
| 708 |
+
updateCaption();
|
| 709 |
+
return;
|
| 710 |
+
}
|
| 711 |
+
let best = -1,
|
| 712 |
+
bestD = 1e9;
|
| 713 |
+
for (let i = 0; i < plots.length; i++) {
|
| 714 |
+
const { cx, baseY } = plotPos(i);
|
| 715 |
+
const d = (mx - cx) ** 2 + (my - (baseY - 4)) ** 2;
|
| 716 |
+
if (d < bestD) {
|
| 717 |
+
bestD = d;
|
| 718 |
+
best = i;
|
| 719 |
+
}
|
| 720 |
+
}
|
| 721 |
+
if (best >= 0 && bestD < 26 * 26) {
|
| 722 |
+
cursor = best;
|
| 723 |
+
act();
|
| 724 |
+
}
|
| 725 |
+
});
|
| 726 |
+
|
| 727 |
+
// a pointer cursor over the clickable spots (tray + plots) hints they're interactive
|
| 728 |
+
canvas.addEventListener("mousemove", (e) => {
|
| 729 |
+
if (!running) return;
|
| 730 |
+
const { mx, my } = pointAt(e);
|
| 731 |
+
let hot = my >= TRAY_TOP;
|
| 732 |
+
if (!hot) {
|
| 733 |
+
for (let i = 0; i < plots.length; i++) {
|
| 734 |
+
const { cx, baseY } = plotPos(i);
|
| 735 |
+
if ((mx - cx) ** 2 + (my - (baseY - 4)) ** 2 < 26 * 26) {
|
| 736 |
+
hot = true;
|
| 737 |
+
break;
|
| 738 |
+
}
|
| 739 |
+
}
|
| 740 |
+
}
|
| 741 |
+
canvas.style.cursor = hot ? "pointer" : "default";
|
| 742 |
+
});
|
| 743 |
+
|
| 744 |
+
// arrows move; the A/B keys mirror the on-screen A/B buttons (z/x/space/enter alias A).
|
| 745 |
+
const KEYS = {
|
| 746 |
+
ArrowUp: "up",
|
| 747 |
+
ArrowDown: "down",
|
| 748 |
+
ArrowLeft: "left",
|
| 749 |
+
ArrowRight: "right",
|
| 750 |
+
a: "a",
|
| 751 |
+
A: "a",
|
| 752 |
+
z: "a",
|
| 753 |
+
Z: "a",
|
| 754 |
+
Enter: "a",
|
| 755 |
+
" ": "a",
|
| 756 |
+
b: "b",
|
| 757 |
+
B: "b",
|
| 758 |
+
x: "b",
|
| 759 |
+
X: "b",
|
| 760 |
+
};
|
| 761 |
+
function onKey(e) {
|
| 762 |
+
if (!running) return;
|
| 763 |
+
const action = KEYS[e.key];
|
| 764 |
+
if (!action) return;
|
| 765 |
+
e.preventDefault();
|
| 766 |
+
e.stopPropagation();
|
| 767 |
+
input(action);
|
| 768 |
+
}
|
| 769 |
+
|
| 770 |
+
return {
|
| 771 |
+
start() {
|
| 772 |
+
if (running) return;
|
| 773 |
+
running = true;
|
| 774 |
+
last = 0;
|
| 775 |
+
updateCaption();
|
| 776 |
+
window.addEventListener("keydown", onKey, true);
|
| 777 |
+
raf = requestAnimationFrame((now) => {
|
| 778 |
+
last = now;
|
| 779 |
+
frame(now);
|
| 780 |
+
});
|
| 781 |
+
},
|
| 782 |
+
stop() {
|
| 783 |
+
running = false;
|
| 784 |
+
cancelAnimationFrame(raf);
|
| 785 |
+
window.removeEventListener("keydown", onKey, true);
|
| 786 |
+
},
|
| 787 |
+
};
|
| 788 |
+
}
|
frontend/index.html
CHANGED
|
@@ -4,24 +4,51 @@
|
|
| 4 |
<meta charset="utf-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
<title>LoFinity β chill beats, freshly vended</title>
|
| 7 |
-
<meta
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
<!-- Open Graph / Discord / social preview (single-line per tag so simple
|
| 10 |
scrapers parse them). Absolute URLs point at the deployed Space;
|
| 11 |
the image is built by scripts/make_og.py. -->
|
| 12 |
<meta property="og:type" content="website" />
|
| 13 |
<meta property="og:site_name" content="LoFinity" />
|
| 14 |
-
<meta
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
<meta property="og:image:width" content="1200" />
|
| 19 |
<meta property="og:image:height" content="630" />
|
| 20 |
-
<meta
|
|
|
|
|
|
|
|
|
|
| 21 |
<meta name="twitter:card" content="summary_large_image" />
|
| 22 |
-
<meta
|
| 23 |
-
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 26 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 27 |
<link
|
|
@@ -165,62 +192,114 @@
|
|
| 165 |
</div>
|
| 166 |
|
| 167 |
<div id="collection-panel" class="hidden">
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
</div>
|
| 176 |
-
</div>
|
| 177 |
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
</div>
|
| 189 |
-
<a id="deck-download" class="disabled" aria-label="download tape" title="download tape">
|
| 190 |
-
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
|
| 191 |
-
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
| 192 |
-
<path d="M12 3v12" /><path d="m6 11 6 6 6-6" /><path d="M5 21h14" />
|
| 193 |
-
</svg>
|
| 194 |
-
</a>
|
| 195 |
-
</div>
|
| 196 |
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
<
|
| 205 |
</div>
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
</div>
|
| 212 |
-
<div class="gb-
|
| 213 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
</div>
|
| 215 |
-
</div>
|
| 216 |
-
<div class="gb-startsel" aria-hidden="true"><span></span><span></span></div>
|
| 217 |
-
<div class="gb-speaker" aria-hidden="true">
|
| 218 |
-
<i></i><i></i><i></i><i></i><i></i><i></i>
|
| 219 |
</div>
|
| 220 |
</div>
|
| 221 |
-
</div>
|
| 222 |
|
| 223 |
-
|
| 224 |
<button id="np-toggle" aria-label="play/pause">ββ</button>
|
| 225 |
<span id="np-title"></span>
|
| 226 |
</div>
|
|
|
|
| 4 |
<meta charset="utf-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
| 6 |
<title>LoFinity β chill beats, freshly vended</title>
|
| 7 |
+
<meta
|
| 8 |
+
name="description"
|
| 9 |
+
content="A magical vending machine that dispenses endless lofi cassette tapes. Tell it a vibe; it brews you a beat."
|
| 10 |
+
/>
|
| 11 |
|
| 12 |
<!-- Open Graph / Discord / social preview (single-line per tag so simple
|
| 13 |
scrapers parse them). Absolute URLs point at the deployed Space;
|
| 14 |
the image is built by scripts/make_og.py. -->
|
| 15 |
<meta property="og:type" content="website" />
|
| 16 |
<meta property="og:site_name" content="LoFinity" />
|
| 17 |
+
<meta
|
| 18 |
+
property="og:title"
|
| 19 |
+
content="LoFinity β chill beats, freshly vended"
|
| 20 |
+
/>
|
| 21 |
+
<meta
|
| 22 |
+
property="og:description"
|
| 23 |
+
content="A magical vending machine that dispenses endless lofi cassette tapes. Tell it a vibe; it brews you a beat."
|
| 24 |
+
/>
|
| 25 |
+
<meta
|
| 26 |
+
property="og:url"
|
| 27 |
+
content="https://build-small-hackathon-lofinity.hf.space/"
|
| 28 |
+
/>
|
| 29 |
+
<meta
|
| 30 |
+
property="og:image"
|
| 31 |
+
content="https://build-small-hackathon-lofinity.hf.space/static/og.png"
|
| 32 |
+
/>
|
| 33 |
<meta property="og:image:width" content="1200" />
|
| 34 |
<meta property="og:image:height" content="630" />
|
| 35 |
+
<meta
|
| 36 |
+
property="og:image:alt"
|
| 37 |
+
content="LoFinity title screen β chill beats, freshly vended"
|
| 38 |
+
/>
|
| 39 |
<meta name="twitter:card" content="summary_large_image" />
|
| 40 |
+
<meta
|
| 41 |
+
name="twitter:title"
|
| 42 |
+
content="LoFinity β chill beats, freshly vended"
|
| 43 |
+
/>
|
| 44 |
+
<meta
|
| 45 |
+
name="twitter:description"
|
| 46 |
+
content="A magical vending machine that dispenses endless lofi cassette tapes."
|
| 47 |
+
/>
|
| 48 |
+
<meta
|
| 49 |
+
name="twitter:image"
|
| 50 |
+
content="https://build-small-hackathon-lofinity.hf.space/static/og.png"
|
| 51 |
+
/>
|
| 52 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 53 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 54 |
<link
|
|
|
|
| 192 |
</div>
|
| 193 |
|
| 194 |
<div id="collection-panel" class="hidden">
|
| 195 |
+
<p id="collection-heading"><span>βͺ cassette collection</span></p>
|
| 196 |
+
<button id="collection-close" aria-label="close collection">Γ</button>
|
| 197 |
+
<p id="collection-status" class="hidden"></p>
|
| 198 |
+
<div id="carousel-row">
|
| 199 |
+
<button class="caro-btn" id="caro-prev" aria-label="scroll left">
|
| 200 |
+
βΉ
|
| 201 |
+
</button>
|
| 202 |
+
<div id="carousel"></div>
|
| 203 |
+
<button class="caro-btn" id="caro-next" aria-label="scroll right">
|
| 204 |
+
βΊ
|
| 205 |
+
</button>
|
| 206 |
+
</div>
|
| 207 |
</div>
|
|
|
|
| 208 |
|
| 209 |
+
<div id="tape-deck" class="hidden">
|
| 210 |
+
<button id="deck-play" aria-label="play/pause">βΆ</button>
|
| 211 |
+
<div id="deck-info">
|
| 212 |
+
<span id="deck-title">pick a tape</span>
|
| 213 |
+
<div id="deck-progress"><div id="deck-fill"></div></div>
|
| 214 |
+
</div>
|
| 215 |
+
<span id="deck-time">0:00</span>
|
| 216 |
+
<div id="deck-cassette" aria-hidden="true">
|
| 217 |
+
<span class="mini-reel"></span>
|
| 218 |
+
<span class="mini-reel"></span>
|
| 219 |
+
</div>
|
| 220 |
+
<a
|
| 221 |
+
id="deck-download"
|
| 222 |
+
class="disabled"
|
| 223 |
+
aria-label="download tape"
|
| 224 |
+
title="download tape"
|
| 225 |
+
>
|
| 226 |
+
<svg
|
| 227 |
+
viewBox="0 0 24 24"
|
| 228 |
+
width="20"
|
| 229 |
+
height="20"
|
| 230 |
+
fill="none"
|
| 231 |
+
stroke="currentColor"
|
| 232 |
+
stroke-width="2.4"
|
| 233 |
+
stroke-linecap="round"
|
| 234 |
+
stroke-linejoin="round"
|
| 235 |
+
aria-hidden="true"
|
| 236 |
+
>
|
| 237 |
+
<path d="M12 3v12" />
|
| 238 |
+
<path d="m6 11 6 6 6-6" />
|
| 239 |
+
<path d="M5 21h14" />
|
| 240 |
+
</svg>
|
| 241 |
+
</a>
|
| 242 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
|
| 244 |
+
<div id="gameboy-modal" class="hidden">
|
| 245 |
+
<button id="gameboy-close" aria-label="close">Γ</button>
|
| 246 |
+
<div class="gb-shell">
|
| 247 |
+
<div class="gb-screen-frame">
|
| 248 |
+
<div class="gb-power"><i></i>POWER</div>
|
| 249 |
+
<div id="gameboy-screen" class="gb-screen">
|
| 250 |
+
<canvas id="gb-canvas" width="160" height="144"></canvas>
|
| 251 |
+
</div>
|
| 252 |
</div>
|
| 253 |
+
<div id="gb-caption" class="gb-caption"></div>
|
| 254 |
+
<div class="gb-logo">NO DOPAMINE<span>βͺ</span></div>
|
| 255 |
+
<div class="gb-controls">
|
| 256 |
+
<div class="gb-dpad">
|
| 257 |
+
<span class="gb-pad-v"></span><span class="gb-pad-h"></span>
|
| 258 |
+
<button
|
| 259 |
+
class="gb-pad-hit gb-up"
|
| 260 |
+
data-dir="up"
|
| 261 |
+
aria-label="up"
|
| 262 |
+
></button>
|
| 263 |
+
<button
|
| 264 |
+
class="gb-pad-hit gb-down"
|
| 265 |
+
data-dir="down"
|
| 266 |
+
aria-label="down"
|
| 267 |
+
></button>
|
| 268 |
+
<button
|
| 269 |
+
class="gb-pad-hit gb-left"
|
| 270 |
+
data-dir="left"
|
| 271 |
+
aria-label="left"
|
| 272 |
+
></button>
|
| 273 |
+
<button
|
| 274 |
+
class="gb-pad-hit gb-right"
|
| 275 |
+
data-dir="right"
|
| 276 |
+
aria-label="right"
|
| 277 |
+
></button>
|
| 278 |
+
</div>
|
| 279 |
+
<div class="gb-ab">
|
| 280 |
+
<button class="gb-btn" data-action="b">B</button>
|
| 281 |
+
<button class="gb-btn" data-action="a">A</button>
|
| 282 |
+
</div>
|
| 283 |
</div>
|
| 284 |
+
<div class="gb-startsel">
|
| 285 |
+
<button
|
| 286 |
+
class="gb-ss"
|
| 287 |
+
data-action="select"
|
| 288 |
+
aria-label="select (change seed)"
|
| 289 |
+
></button>
|
| 290 |
+
<button
|
| 291 |
+
class="gb-ss"
|
| 292 |
+
data-action="start"
|
| 293 |
+
aria-label="start (dig up)"
|
| 294 |
+
></button>
|
| 295 |
+
</div>
|
| 296 |
+
<div class="gb-speaker" aria-hidden="true">
|
| 297 |
+
<i></i><i></i><i></i><i></i><i></i><i></i>
|
| 298 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
</div>
|
| 300 |
</div>
|
|
|
|
| 301 |
|
| 302 |
+
<div id="now-playing" class="hidden">
|
| 303 |
<button id="np-toggle" aria-label="play/pause">ββ</button>
|
| 304 |
<span id="np-title"></span>
|
| 305 |
</div>
|
frontend/style.css
CHANGED
|
@@ -1137,12 +1137,12 @@ body {
|
|
| 1137 |
left: 50%;
|
| 1138 |
transform: translate(-50%, -50%);
|
| 1139 |
z-index: 19;
|
| 1140 |
-
transition: opacity 0.3s ease;
|
| 1141 |
}
|
| 1142 |
|
|
|
|
|
|
|
| 1143 |
#gameboy-modal.hidden {
|
| 1144 |
-
|
| 1145 |
-
pointer-events: none;
|
| 1146 |
}
|
| 1147 |
|
| 1148 |
#gameboy-modal.opening .gb-shell {
|
|
@@ -1233,37 +1233,66 @@ body {
|
|
| 1233 |
}
|
| 1234 |
|
| 1235 |
.gb-screen {
|
|
|
|
| 1236 |
aspect-ratio: 10 / 9;
|
| 1237 |
-
display: flex;
|
| 1238 |
-
flex-direction: column;
|
| 1239 |
-
align-items: center;
|
| 1240 |
-
justify-content: center;
|
| 1241 |
-
gap: 10px;
|
| 1242 |
border-radius: 4px;
|
| 1243 |
-
|
| 1244 |
box-shadow:
|
| 1245 |
-
inset 0 0 0 2px #
|
| 1246 |
-
inset 0 2px 10px rgba(0, 0, 0, 0.
|
| 1247 |
-
background-image:
|
| 1248 |
-
linear-gradient(#9bbc0f, #8ba30c),
|
| 1249 |
-
repeating-linear-gradient(0deg, rgba(0, 0, 0, 0.05) 0 2px, transparent 2px 4px);
|
| 1250 |
}
|
| 1251 |
|
| 1252 |
-
|
| 1253 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1254 |
font-family: "DotGothic16", monospace;
|
| 1255 |
-
|
| 1256 |
-
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);
|
| 1257 |
}
|
| 1258 |
|
| 1259 |
-
.gb-
|
| 1260 |
-
|
|
|
|
|
|
|
| 1261 |
}
|
| 1262 |
|
| 1263 |
-
|
| 1264 |
-
|
| 1265 |
-
|
| 1266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1267 |
}
|
| 1268 |
|
| 1269 |
.gb-logo {
|
|
@@ -1315,6 +1344,28 @@ body {
|
|
| 1315 |
height: 18px;
|
| 1316 |
}
|
| 1317 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1318 |
.gb-ab {
|
| 1319 |
display: flex;
|
| 1320 |
gap: 14px;
|
|
@@ -1325,6 +1376,10 @@ body {
|
|
| 1325 |
width: 40px;
|
| 1326 |
height: 40px;
|
| 1327 |
border-radius: 50%;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1328 |
background: radial-gradient(circle at 35% 30%, #a8406b, #7a1f45);
|
| 1329 |
color: #f2d6e2;
|
| 1330 |
font-weight: 800;
|
|
@@ -1335,6 +1390,15 @@ body {
|
|
| 1335 |
box-shadow:
|
| 1336 |
0 3px 5px rgba(0, 0, 0, 0.3),
|
| 1337 |
inset 0 2px 3px rgba(255, 255, 255, 0.25);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1338 |
}
|
| 1339 |
|
| 1340 |
.gb-startsel {
|
|
@@ -1344,13 +1408,22 @@ body {
|
|
| 1344 |
margin-top: 18px;
|
| 1345 |
}
|
| 1346 |
|
| 1347 |
-
.gb-startsel
|
| 1348 |
width: 34px;
|
| 1349 |
height: 11px;
|
|
|
|
|
|
|
|
|
|
| 1350 |
border-radius: 999px;
|
| 1351 |
background: #6b7280;
|
| 1352 |
transform: rotate(-22deg);
|
| 1353 |
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1354 |
}
|
| 1355 |
|
| 1356 |
.gb-speaker {
|
|
|
|
| 1137 |
left: 50%;
|
| 1138 |
transform: translate(-50%, -50%);
|
| 1139 |
z-index: 19;
|
|
|
|
| 1140 |
}
|
| 1141 |
|
| 1142 |
+
/* display:none (like #machine-modal) β an opacity fade here would let the inner
|
| 1143 |
+
.gb-shell snap back to full opacity as .closing is removed, causing a flicker. */
|
| 1144 |
#gameboy-modal.hidden {
|
| 1145 |
+
display: none;
|
|
|
|
| 1146 |
}
|
| 1147 |
|
| 1148 |
#gameboy-modal.opening .gb-shell {
|
|
|
|
| 1233 |
}
|
| 1234 |
|
| 1235 |
.gb-screen {
|
| 1236 |
+
position: relative;
|
| 1237 |
aspect-ratio: 10 / 9;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1238 |
border-radius: 4px;
|
| 1239 |
+
overflow: hidden;
|
| 1240 |
box-shadow:
|
| 1241 |
+
inset 0 0 0 2px #1d2540,
|
| 1242 |
+
inset 0 2px 10px rgba(0, 0, 0, 0.35);
|
|
|
|
|
|
|
|
|
|
| 1243 |
}
|
| 1244 |
|
| 1245 |
+
#gb-canvas {
|
| 1246 |
+
display: block;
|
| 1247 |
+
width: 100%;
|
| 1248 |
+
height: 100%;
|
| 1249 |
+
image-rendering: pixelated;
|
| 1250 |
+
image-rendering: crisp-edges;
|
| 1251 |
+
}
|
| 1252 |
+
|
| 1253 |
+
/* faint scanlines for retro feel β subtle so the colour reads */
|
| 1254 |
+
.gb-screen::after {
|
| 1255 |
+
content: "";
|
| 1256 |
+
position: absolute;
|
| 1257 |
+
inset: 0;
|
| 1258 |
+
pointer-events: none;
|
| 1259 |
+
background: repeating-linear-gradient(
|
| 1260 |
+
0deg,
|
| 1261 |
+
rgba(0, 0, 0, 0.07) 0 1px,
|
| 1262 |
+
transparent 1px 3px
|
| 1263 |
+
);
|
| 1264 |
+
}
|
| 1265 |
+
|
| 1266 |
+
.gb-caption {
|
| 1267 |
+
margin: 9px 2px 0;
|
| 1268 |
+
min-height: 14px;
|
| 1269 |
+
display: flex;
|
| 1270 |
+
align-items: baseline;
|
| 1271 |
+
justify-content: space-between;
|
| 1272 |
+
gap: 8px;
|
| 1273 |
font-family: "DotGothic16", monospace;
|
| 1274 |
+
transition: filter 0.15s ease;
|
|
|
|
| 1275 |
}
|
| 1276 |
|
| 1277 |
+
.gb-caption .gb-seed {
|
| 1278 |
+
font-size: 0.72rem;
|
| 1279 |
+
letter-spacing: 0.06em;
|
| 1280 |
+
text-shadow: 0 1px 1px rgba(0, 0, 0, 0.35);
|
| 1281 |
}
|
| 1282 |
|
| 1283 |
+
.gb-caption .gb-hint {
|
| 1284 |
+
font-size: 0.5rem;
|
| 1285 |
+
letter-spacing: 0.03em;
|
| 1286 |
+
color: #6a6f86;
|
| 1287 |
+
}
|
| 1288 |
+
|
| 1289 |
+
.gb-caption.gb-flash {
|
| 1290 |
+
animation: gb-flash 0.3s ease;
|
| 1291 |
+
}
|
| 1292 |
+
|
| 1293 |
+
@keyframes gb-flash {
|
| 1294 |
+
0% { filter: brightness(1.9); }
|
| 1295 |
+
100% { filter: brightness(1); }
|
| 1296 |
}
|
| 1297 |
|
| 1298 |
.gb-logo {
|
|
|
|
| 1344 |
height: 18px;
|
| 1345 |
}
|
| 1346 |
|
| 1347 |
+
/* transparent click targets over each arm of the d-pad */
|
| 1348 |
+
.gb-pad-hit {
|
| 1349 |
+
position: absolute;
|
| 1350 |
+
z-index: 2;
|
| 1351 |
+
margin: 0;
|
| 1352 |
+
padding: 0;
|
| 1353 |
+
border: 0;
|
| 1354 |
+
background: transparent;
|
| 1355 |
+
cursor: pointer;
|
| 1356 |
+
-webkit-tap-highlight-color: transparent;
|
| 1357 |
+
}
|
| 1358 |
+
|
| 1359 |
+
.gb-pad-hit:active {
|
| 1360 |
+
background: rgba(255, 255, 255, 0.14);
|
| 1361 |
+
border-radius: 3px;
|
| 1362 |
+
}
|
| 1363 |
+
|
| 1364 |
+
.gb-up { left: 19px; top: 0; width: 18px; height: 19px; }
|
| 1365 |
+
.gb-down { left: 19px; top: 37px; width: 18px; height: 19px; }
|
| 1366 |
+
.gb-left { left: 0; top: 19px; width: 19px; height: 18px; }
|
| 1367 |
+
.gb-right { left: 37px; top: 19px; width: 19px; height: 18px; }
|
| 1368 |
+
|
| 1369 |
.gb-ab {
|
| 1370 |
display: flex;
|
| 1371 |
gap: 14px;
|
|
|
|
| 1376 |
width: 40px;
|
| 1377 |
height: 40px;
|
| 1378 |
border-radius: 50%;
|
| 1379 |
+
border: 0;
|
| 1380 |
+
padding: 0;
|
| 1381 |
+
cursor: pointer;
|
| 1382 |
+
font-family: inherit;
|
| 1383 |
background: radial-gradient(circle at 35% 30%, #a8406b, #7a1f45);
|
| 1384 |
color: #f2d6e2;
|
| 1385 |
font-weight: 800;
|
|
|
|
| 1390 |
box-shadow:
|
| 1391 |
0 3px 5px rgba(0, 0, 0, 0.3),
|
| 1392 |
inset 0 2px 3px rgba(255, 255, 255, 0.25);
|
| 1393 |
+
-webkit-tap-highlight-color: transparent;
|
| 1394 |
+
transition: transform 0.06s ease, box-shadow 0.06s ease;
|
| 1395 |
+
}
|
| 1396 |
+
|
| 1397 |
+
.gb-btn:active {
|
| 1398 |
+
transform: translateY(2px);
|
| 1399 |
+
box-shadow:
|
| 1400 |
+
0 1px 2px rgba(0, 0, 0, 0.3),
|
| 1401 |
+
inset 0 2px 3px rgba(255, 255, 255, 0.25);
|
| 1402 |
}
|
| 1403 |
|
| 1404 |
.gb-startsel {
|
|
|
|
| 1408 |
margin-top: 18px;
|
| 1409 |
}
|
| 1410 |
|
| 1411 |
+
.gb-startsel .gb-ss {
|
| 1412 |
width: 34px;
|
| 1413 |
height: 11px;
|
| 1414 |
+
border: 0;
|
| 1415 |
+
padding: 0;
|
| 1416 |
+
cursor: pointer;
|
| 1417 |
border-radius: 999px;
|
| 1418 |
background: #6b7280;
|
| 1419 |
transform: rotate(-22deg);
|
| 1420 |
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.3);
|
| 1421 |
+
-webkit-tap-highlight-color: transparent;
|
| 1422 |
+
transition: background 0.1s ease;
|
| 1423 |
+
}
|
| 1424 |
+
|
| 1425 |
+
.gb-startsel .gb-ss:active {
|
| 1426 |
+
background: #565d6b;
|
| 1427 |
}
|
| 1428 |
|
| 1429 |
.gb-speaker {
|
frontend/ui.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
| 1 |
// LoFinity β vending machine modal: prompt β coin drop β brewing β cassette.
|
| 2 |
// main.js owns the camera; this module owns the DOM and the audio element.
|
| 3 |
|
|
|
|
|
|
|
| 4 |
const $ = (id) => document.getElementById(id);
|
| 5 |
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
| 6 |
|
|
@@ -59,6 +61,7 @@ export function initUI({
|
|
| 59 |
let busy = false;
|
| 60 |
let collectionOpen = false;
|
| 61 |
let gameboyOpen = false;
|
|
|
|
| 62 |
let currentSong = null; // { title, url } of the tape in the deck
|
| 63 |
|
| 64 |
function setStage(stage) {
|
|
@@ -491,10 +494,13 @@ export function initUI({
|
|
| 491 |
gameboyModal.classList.remove("hidden", "closing");
|
| 492 |
void gameboyModal.offsetWidth; // restart the entrance animation
|
| 493 |
gameboyModal.classList.add("opening");
|
|
|
|
|
|
|
| 494 |
syncPill();
|
| 495 |
},
|
| 496 |
closeGameboy() {
|
| 497 |
gameboyOpen = false;
|
|
|
|
| 498 |
if (gameboyModal.classList.contains("hidden")) return syncPill();
|
| 499 |
gameboyModal.classList.remove("opening");
|
| 500 |
gameboyModal.classList.add("closing");
|
|
|
|
| 1 |
// LoFinity β vending machine modal: prompt β coin drop β brewing β cassette.
|
| 2 |
// main.js owns the camera; this module owns the DOM and the audio element.
|
| 3 |
|
| 4 |
+
import { createGarden } from "/static/garden.js";
|
| 5 |
+
|
| 6 |
const $ = (id) => document.getElementById(id);
|
| 7 |
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
| 8 |
|
|
|
|
| 61 |
let busy = false;
|
| 62 |
let collectionOpen = false;
|
| 63 |
let gameboyOpen = false;
|
| 64 |
+
let garden = null; // the Game Boy garden mini-game (lazily started)
|
| 65 |
let currentSong = null; // { title, url } of the tape in the deck
|
| 66 |
|
| 67 |
function setStage(stage) {
|
|
|
|
| 494 |
gameboyModal.classList.remove("hidden", "closing");
|
| 495 |
void gameboyModal.offsetWidth; // restart the entrance animation
|
| 496 |
gameboyModal.classList.add("opening");
|
| 497 |
+
if (!garden) garden = createGarden(gameboyModal);
|
| 498 |
+
garden.start();
|
| 499 |
syncPill();
|
| 500 |
},
|
| 501 |
closeGameboy() {
|
| 502 |
gameboyOpen = false;
|
| 503 |
+
garden?.stop();
|
| 504 |
if (gameboyModal.classList.contains("hidden")) return syncPill();
|
| 505 |
gameboyModal.classList.remove("opening");
|
| 506 |
gameboyModal.classList.add("closing");
|