eloigil6 commited on
Commit
1af28be
Β·
1 Parent(s): 93c06d6

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
Files changed (4) hide show
  1. frontend/garden.js +788 -0
  2. frontend/index.html +134 -55
  3. frontend/style.css +98 -25
  4. 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 &nbsp; β’· 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 &nbsp; β’· 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 name="description" content="A magical vending machine that dispenses endless lofi cassette tapes. Tell it a vibe; it brews you a beat." />
 
 
 
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 property="og:title" content="LoFinity β€” chill beats, freshly vended" />
15
- <meta property="og:description" content="A magical vending machine that dispenses endless lofi cassette tapes. Tell it a vibe; it brews you a beat." />
16
- <meta property="og:url" content="https://build-small-hackathon-lofinity.hf.space/" />
17
- <meta property="og:image" content="https://build-small-hackathon-lofinity.hf.space/static/og.png" />
 
 
 
 
 
 
 
 
 
 
 
 
18
  <meta property="og:image:width" content="1200" />
19
  <meta property="og:image:height" content="630" />
20
- <meta property="og:image:alt" content="LoFinity title screen β€” chill beats, freshly vended" />
 
 
 
21
  <meta name="twitter:card" content="summary_large_image" />
22
- <meta name="twitter:title" content="LoFinity β€” chill beats, freshly vended" />
23
- <meta name="twitter:description" content="A magical vending machine that dispenses endless lofi cassette tapes." />
24
- <meta name="twitter:image" content="https://build-small-hackathon-lofinity.hf.space/static/og.png" />
 
 
 
 
 
 
 
 
 
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
- <p id="collection-heading"><span>β™ͺ cassette collection</span></p>
169
- <button id="collection-close" aria-label="close collection">Γ—</button>
170
- <p id="collection-status" class="hidden"></p>
171
- <div id="carousel-row">
172
- <button class="caro-btn" id="caro-prev" aria-label="scroll left">β€Ή</button>
173
- <div id="carousel"></div>
174
- <button class="caro-btn" id="caro-next" aria-label="scroll right">β€Ί</button>
 
 
 
 
 
175
  </div>
176
- </div>
177
 
178
- <div id="tape-deck" class="hidden">
179
- <button id="deck-play" aria-label="play/pause">β–Ά</button>
180
- <div id="deck-info">
181
- <span id="deck-title">pick a tape</span>
182
- <div id="deck-progress"><div id="deck-fill"></div></div>
183
- </div>
184
- <span id="deck-time">0:00</span>
185
- <div id="deck-cassette" aria-hidden="true">
186
- <span class="mini-reel"></span>
187
- <span class="mini-reel"></span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- <div id="gameboy-modal" class="hidden">
198
- <button id="gameboy-close" aria-label="close">Γ—</button>
199
- <div class="gb-shell">
200
- <div class="gb-screen-frame">
201
- <div class="gb-power"><i></i>BATTERY</div>
202
- <div id="gameboy-screen" class="gb-screen">
203
- <p class="gb-line">β˜… MINI-GAME β˜…</p>
204
- <p class="gb-line gb-blink">β–Ά COMING SOON</p>
205
  </div>
206
- </div>
207
- <div class="gb-logo">LoFinity<span>β™ͺ</span></div>
208
- <div class="gb-controls">
209
- <div class="gb-dpad" aria-hidden="true">
210
- <span class="gb-pad-v"></span><span class="gb-pad-h"></span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
211
  </div>
212
- <div class="gb-ab" aria-hidden="true">
213
- <span class="gb-btn">B</span><span class="gb-btn">A</span>
 
 
 
 
 
 
 
 
 
 
 
 
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
- <div id="now-playing" class="hidden">
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
- opacity: 0;
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
- color: #213a08;
1244
  box-shadow:
1245
- inset 0 0 0 2px #6b8b06,
1246
- inset 0 2px 10px rgba(0, 0, 0, 0.25);
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
- .gb-line {
1253
- margin: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1254
  font-family: "DotGothic16", monospace;
1255
- font-size: 0.95rem;
1256
- text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);
1257
  }
1258
 
1259
- .gb-blink {
1260
- animation: gb-blink 1s steps(1) infinite;
 
 
1261
  }
1262
 
1263
- @keyframes gb-blink {
1264
- 50% {
1265
- opacity: 0;
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 span {
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");