1(function () { 2 /* global React, CHARACTER_FRAMES, PixelGrid, document */ 3 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 4 // Easter eggs: 5 // 1. Leaf particles drift from cursor 6 // 2. Tiny pixel character walks along bottom of viewport 7 // 2½. Idle garden â pixel plants grow from the bottom after 10s of stillness 8 // 3. Terminal overlay (type "cg" to open) 9 // 4. Mini-game (catch falling leaves) â launched from terminal 10 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 11 12 const { 13 useState, 14 useEffect, 15 useLayoutEffect, 16 useRef, 17 useCallback 18 } = React; 19 20 // âââââââââââ 1. Leaf particles âââââââââââ 21 function LeafParticles({ 22 enabled = true 23 }) { 24 const layerRef = useRef(null); 25 const particlesRef = useRef([]); 26 const rafRef = useRef(null); 27 useEffect(() => { 28 if (!enabled) return; 29 const layer = layerRef.current; 30 if (!layer) return; 31 // Same spawn rate and physics as before â only the per-leaf cost changed: 32 // ⢠The leaf SVG is built ONCE and cloned per spawn (cloneNode), instead 33 // of re-parsing an innerHTML string for every leaf. That string parse + 34 // style recalc was the intermittent hitch. 35 // ⢠The leaf element is created in the rAF loop, not in the mousemove 36 // handler, so pointer events never do DOM work and never stall. The 37 // mousemove handler only records the latest position; spawning stays 38 // gated to the original 180ms cadence and only fires while moving. 39 const tmpl = document.createElement("div"); 40 tmpl.innerHTML = '<svg viewBox="0 0 16 16" width="100%" height="100%">' + '<path d="M8 1 C 13 4, 14 10, 8 15 C 2 10, 3 4, 8 1 Z" opacity="0.85"/>' + '<path d="M8 1 L 8 15" stroke-width="0.8"/>' + "</svg>"; 41 const leafSvg = tmpl.firstChild; 42 let pos = { 43 x: -100, 44 y: -100 45 }; 46 let moved = false; // only emit on frames where the cursor actually moved 47 function onMove(e) { 48 pos.x = e.clientX; 49 pos.y = e.clientY; 50 moved = true; 51 } 52 function spawn(x, y) { 53 const el = document.createElement("div"); 54 el.className = "leaf"; 55 const hue = 80 + Math.random() * 30; 56 const sat = 30 + Math.random() * 25; 57 const lite = 35 + Math.random() * 15; 58 const size = 8 + Math.random() * 10; 59 const left = x - size / 2; 60 const top = y - size / 2; 61 el.style.cssText = `width:${size}px;height:${size}px;left:${left}px;top:${top}px`; 62 const svg = leafSvg.cloneNode(true); 63 svg.children[0].setAttribute("fill", `hsl(${hue},${sat}%,${lite}%)`); 64 svg.children[1].setAttribute("stroke", `hsl(${hue},${sat}%,${lite - 12}%)`); 65 el.appendChild(svg); 66 layer.appendChild(el); 67 particlesRef.current.push({ 68 el, 69 x, 70 y, 71 // Transform baseline = un-shifted spawn coords. The CSS left/top is 72 // offset by -size/2 to center the leaf on the cursor; using that as 73 // the translate baseline made every leaf jump by (size/2, size/2) 74 // on its first frame. 75 ox: x, 76 oy: y, 77 vx: (Math.random() - 0.5) * 1.6, 78 vy: 0.3 + Math.random() * 1.2, 79 rot: Math.random() * 360, 80 vr: (Math.random() - 0.5) * 8, 81 life: 0, 82 maxLife: 1500 + Math.random() * 1200, 83 sway: Math.random() * Math.PI * 2, 84 swayFreq: 0.002 + Math.random() * 0.003 85 }); 86 } 87 let last = performance.now(); 88 let lastEmit = 0; 89 function tick(now) { 90 // Halved spawn rate (was 90ms) â 50% fewer leaves on the cursor trail. 91 if (moved && now - lastEmit > 180) { 92 lastEmit = now; 93 moved = false;
94 spawn(pos.x, pos.y); 95 } 96 // Clamp dt: after a long frame gap (tab blur, GC pause, scroll jank) 97 // the next dt can be hundreds of ms, which makes particles teleport. 98 // 50ms is a 20fps floor â the motion slows briefly instead of jumping. 99 let dt = now - last; 100 last = now; 101 if (dt > 50) dt = 50; 102 // Normalize per-frame velocities to a 60fps baseline so motion is 103 // smooth at any framerate (the dt scaling makes 30fps look the same as 104 // 120fps, just rendered at half the cadence). 105 const tScale = dt / 16.67; 106 const ps = particlesRef.current; 107 for (let i = ps.length - 1; i >= 0; i--) { 108 const p = ps[i]; 109 p.life += dt; 110 p.sway += p.swayFreq * dt; 111 p.x += (p.vx + Math.sin(p.sway) * 0.4) * tScale; 112 p.y += p.vy * tScale; 113 p.vy += 0.0003 * dt; // gentle gravity (already time-scaled) 114 p.rot += p.vr * 0.02 * tScale; 115 const alpha = Math.max(0, 1 - p.life / p.maxLife); 116 p.el.style.transform = `translate3d(${p.x - p.ox}px, ${p.y - p.oy}px, 0) rotate(${p.rot}deg)`; 117 p.el.style.opacity = alpha; 118 if (p.life > p.maxLife) { 119 p.el.remove(); 120 ps.splice(i, 1); 121 } 122 } 123 rafRef.current = requestAnimationFrame(tick); 124 } 125 rafRef.current = requestAnimationFrame(tick); 126 window.addEventListener("mousemove", onMove); 127 return () => { 128 window.removeEventListener("mousemove", onMove); 129 cancelAnimationFrame(rafRef.current); 130 particlesRef.current.forEach(p => p.el.remove()); 131 particlesRef.current = []; 132 }; 133 }, [enabled]); 134 return /*#__PURE__*/React.createElement("div", { 135 ref: layerRef, 136 className: "leaf-layer" 137 }); 138 } 139 140 // âââââââââââ 2. Pixel character âââââââââââ 141 function PixelCharacter({ 142 enabled = true 143 }) { 144 const [x, setX] = useState(40); 145 const [flip, setFlip] = useState(false); 146 const [frame, setFrame] = useState(0); 147 const [scrolling, setScrolling] = useState(false); 148 const dirRef = useRef(1); 149 150 // ###### WALK + SPRITE-FRAME LOOP ###### 151 useEffect(() => { 152 if (!enabled) return; 153 let raf; 154 let last = performance.now(); 155 let frameAcc = 0; 156 let stepAcc = 0; 157 function loop(now) { 158 const dt = now - last; 159 last = now; 160 stepAcc += dt; 161 frameAcc += dt; 162 if (stepAcc > 16) { 163 stepAcc = 0; 164 setX(prev => { 165 let next = prev + dirRef.current * 0.6; 166 const maxX = window.innerWidth - 60; 167 const minX = 220; // past sidebar 168 if (next > maxX) { 169 dirRef.current = -1; 170 setFlip(true); 171 next = maxX; 172 } 173 if (next < minX) { 174 dirRef.current = 1; 175 setFlip(false); 176 next = minX; 177 } 178 return next; 179 }); 180 } 181 if (frameAcc > 220) { 182 frameAcc = 0; 183 setFrame(f => (f + 1) % 2); 184 } 185 raf = requestAnimationFrame(loop); 186 } 187 raf = requestAnimationFrame(loop); 188 return () => cancelAnimationFrame(raf); 189 }, [enabled]); 190 191 // ###### HIDE WHILE SCROLLING ###### 192 // Hide instantly on scroll, fade back in 800ms after the last scroll event. 193 useEffect(() => { 194 if (!enabled) return; 195 let idleTimer; 196 function onScroll() { 197 setScrolling(true); 198 clearTimeout(idleTimer); 199 idleTimer = setTimeout(() => setScrolling(false), 800); 200 } 201 window.addEventListener("scroll", onScroll, { 202 passive: true 203 }); 204 return () => { 205 window.removeEventListener("scroll", onScroll); 206 clearTimeout(idleTimer); 207 }; 208 }, [enabled]); 209 if (!enabled) return null; 210 return /*#__PURE__*/React.createElement("div", { 211 className: "pixel-character" + (scrolling ? " hidden" : ""), 212 style: { 213 transform: `translateX(${x}px) scaleX(${flip ? -1 : 1})` 214 } 215 }, /*#__PURE__*/React.createElement(PixelGrid, { 216 rows: CHARACTER_FRAMES[frame], 217 scale: 3 218 })); 219 } 220 221 // âââââââââââ 2½. Idle garden âââââââââââ 222 // After 10s of stillness, leafy vector plants grow slowly up from the bottom 223 // edge in irregular packs and sway in a gentle breeze. The moment the visitor 224 // moves, types, scrolls or taps, the whole garden retreats back into the soil. 225 // 226 // Each plant is procedurally composed of smooth SVG leaves/blades (see the 227 // vector plant kit below) and handed to <PlantSVG>. Growth is pure CSS: rather 228 // than wiping a clip-path, each plant scales up organically from its base 229 // (thin shoot â unfurling leaves â settle) on its own slow, randomized timer, 230 // so they sprout in bursts. Retreat shrinks back into the seed state. Sway is a 231 // separate, always-on CSS rotation about each plant's base. 232 233 function rint(a, b) { 234 return a + Math.floor(Math.random() * (b - a + 1)); 235 } 236 function rfloat(a, b) { 237 return a + Math.random() * (b - a); 238 } 239 function pick(arr) { 240 return arr[Math.floor(Math.random() * arr.length)]; 241 } 242 243 // ââ Vector plant kit ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 244 // The idle garden is drawn in the SAME art language as the cursor-trail leaves 245 // â smooth hsl-green SVG leaves with a soft midrib â rather than as pixel 246 // blocks, so a still page grows the very foliage the cursor scatters, just 247 // large and at rest. Each plant is composed from three primitives (broad 248 // leaves, grass blades, a flower head) around a base at local (0,0) with 249 // up = -y. We track the drawn extent so the SVG can size + centre on its base. 250 251 function clamp(v, lo, hi) { 252 return v < lo ? lo : v > hi ? hi : v; 253 } 254 255 // A pointed-lens leaf, mirrored about its own axis, pointing along +x with the 256 // base at the origin â the grown-up cousin of the cursor leaf's bezier. 257 function leafD(L, Wd) { 258 const m = (L * 0.5).toFixed(1); 259 return `M0 0Q${m} ${(-Wd).toFixed(1)} ${L.toFixed(1)} 0Q${m} ${Wd.toFixed(1)} 0 0Z`; 260 } 261 262 // A single grass blade: base centred at the origin, curving up to a leaning, 263 // tapering tip. `lean` is the tip's horizontal travel, `wb` its base half-width. 264 function bladeD(h, wb, lean) { 265 const cLx = (lean * 0.3 - wb).toFixed(1); 266 const cRx = (lean * 0.6 + wb).toFixed(1); 267 return `M${(-wb).toFixed(1)} 0Q${cLx} ${(-h * 0.6).toFixed(1)} ${lean.toFixed(1)} ${(-h).toFixed(1)}
267` + `Q${cRx} ${(-h * 0.5).toFixed(1)} ${wb.toFixed(1)} 0Z`; 268 } 269 270 // Foliage colour. Hues stay in the cursor-leaf green band (~80â112°, warm 271 // yellow-green â capped before it drifts into cool emerald/blue) and are 272 // fixed (not theme variables) so the garden reads identically in light + dark. 273 // `t` runs 0 (shaded, near the soil) â 1 (sunlit tip): higher = lighter and a 274 // touch more saturated, so a clump catches light through its upper leaves. 275 function greenFill(baseHue, t) { 276 const h = baseHue + rint(-7, 7); 277 const s = clamp(34 + Math.round(t * 22) + rint(-4, 4), 26, 64); 278 const l = clamp(25 + Math.round(t * 24) + rint(-3, 3), 20, 54); 279 return `hsl(${h} ${s}% ${l}%)`; 280 } 281 282 // Grow one plant into a render spec: { w, h, cx, baseY, parts }. `parts` are 283 // SVG path/circle descriptors in a base-at-origin frame; PlantSVG drops them 284 // into a <g> translated to (cx, baseY) so the base sits centred on the soil 285 // line. Varieties are biased toward leafy foliage and grass tufts. 286 function makePlant() { 287 const parts = []; 288 let minY = 0; 289 let maxAbsX = 5; 290 const note = (x, y, reach = 0) => { 291 if (y < minY) minY = y; 292 const ax = Math.abs(x) + reach; 293 if (ax > maxAbsX) maxAbsX = ax; 294 }; 295 const finalize = () => { 296 const padX = 7; 297 const padTop = 7; 298 const padBottom = 2; 299 const cx = maxAbsX + padX; 300 const baseY = Math.ceil(-minY) + padTop; 301 return { 302 w: Math.ceil(cx * 2), 303 h: baseY + padBottom, 304 cx, 305 baseY, 306 parts 307 }; 308 }; 309 310 // ââ primitives ââ 311 const addLeaf = (bx, by, deg, L, Wd, baseHue, t) => { 312 const tr = `translate(${bx.toFixed(1)} ${by.toFixed(1)}) rotate(${deg.toFixed(1)})`; 313 parts.push({ 314 d: leafD(L, Wd), 315 fill: greenFill(baseHue, t), 316 opacity: rfloat(0.82, 0.96), 317 transform: tr 318 }); 319 // Midrib â a hair darker, the same accent the cursor leaf carries. 320 parts.push({ 321 d: `M0 0L${(L * 0.92).toFixed(1)} 0`, 322 stroke: greenFill(baseHue, Math.max(0, t - 0.4)), 323 sw: Math.max(0.6, Wd * 0.16).toFixed(1), 324 cap: "round", 325 opacity: 0.5, 326 transform: tr 327 }); 328 const r = deg * Math.PI / 180; 329 note(bx, by); 330 note(bx + Math.cos(r) * L, by + Math.sin(r) * L, Wd); 331 }; 332 const addBlade = (bx, h, wb, lean, baseHue, t) => { 333 parts.push({ 334 d: bladeD(h, wb, lean), 335 fill: greenFill(baseHue, t), 336 opacity: rfloat(0.85, 0.97), 337 transform: `translate(${bx.toFixed(1)} 0)` 338 }); 339 note(bx, 0); 340 note(bx + lean, -h, wb); 341 }; 342 const addStem = (bend, h) => { 343 const topX = bend; 344 const topY = -h; 345 parts.push({ 346 d: `M0 0Q${(bend * 0.25).toFixed(1)} ${(-h * 0.5).toFixed(1)} ${topX.toFixed(1)} ${topY.toFixed(1)}`, 347 stroke: "hsl(96 30% 24%)", 348 sw: rfloat(1.6, 2.8).toFixed(1), 349 cap: "round", 350 opacity: 0.92 351 }); 352 note(0, 0); 353 note(topX, topY); 354 return { 355 topX, 356 topY 357 }; 358 }; 359 const addFlower = (cx, cy, baseHue) => { 360 const petal = pick(["hsl(330 56% 78%)", "hsl(282 40% 80%)", "hsl(45 78% 74%)", "hsl(38 34% 92%)"]); 361 const n = rint(5, 8); 362 const pr = rfloat(5, 8); 363 for (let i = 0; i < n; i++) { 364 const deg = 360 / n * i + rfloat(-8, 8); 365 parts.push({ 366 d: leafD(pr, pr * 0.52), 367 fill: petal, 368 opacity: 0.95, 369 transform: `translate(${cx.toFixed(1)} ${cy.toFixed(1)}) rotate(${deg.toFixed(1)})` 370 }); 371 } 372 parts.push({ 373 tag: "circle", 374 cx, 375 cy, 376 r: rfloat(1.8, 3), 377 fill: "hsl(46 80% 60%)" 378 }); 379 note(cx, cy, pr * 1.3); 380 }; 381 const baseHue = rint(80, 112); 382 const variety = pick(["leafy", "leafy", "leafy", "grass", "grass", "grass", "fern", "fern", "flower", "bud"]); 383 if (variety === "grass") { 384 // A loose tuft of curving blades at varied heights and leans. 385 const H = rint(54, 150); 386 const blades = rint(8, 15); 387 const spread = H * 0.24; 388 for (let b = 0; b < blades; b++) { 389 addBlade(rfloat(-spread, spread), rfloat(H * 0.5, H), rfloat(1.6, 3.4), rfloat(-H * 0.3, H * 0.3), baseHue, rfloat(0.25, 1)); 390 } 391 return finalize(); 392 } 393 const hasHead = variety === "flower" || variety === "bud"; 394 const H = rint(hasHead ? 90 : 78, hasHead ? 175 : 215); 395 const { 396 topX, 397 topY 398 } = addStem(rfloat(-H * 0.16, H * 0.16), H); 399 if (variety === "fern") { 400 // Paired leaflets marching up a curved rachis, longest near the base. 401 const pairs = rint(8, 13); 402 for (let i = 0; i < pairs; i++) { 403 const t = i / (pairs - 1 || 1); 404 const sx = topX * t; 405 const sy = -H * t; 406 const len = (1 - t) * rfloat(11, 20) + 5; 407 addLeaf(sx, sy, -34 - t * 28, len, len * 0.26, baseHue, 0.4 + t * 0.5); 408 addLeaf(sx, sy, -146 + t * 28, len, len * 0.26, baseHue, 0.4 + t * 0.5); 409 } 410 addLeaf(topX, topY, -90, rfloat(8, 14), rfloat(2.5, 4), baseHue, 0.95); 411 return finalize(); 412 } 413 414 // Leafy stem: broad leaves alternating up the stem, larger toward the base, 415 // frequently paired for a full bush, crowned by an upright leaf. 416 const nodes = rint(4, 7); 417 for (let i = 0; i < nodes; i++) { 418 const t = (i + 0.5) / nodes; 419 const sx = topX * t; 420 const sy = -H * t; 421 const side = i % 2 === 0 ? 1 : -1; 422 const L = (1 - t * 0.55) * rfloat(20, 36); 423 const Wd = L * rfloat(0.4, 0.55); 424 const deg = side > 0 ? -(20 + t * 30) : -(160 - t * 30); 425 addLeaf(sx, sy, deg, L, Wd, baseHue, 0.3 + t * 0.6); 426 if (Math.random() < 0.55) { 427 addLeaf(sx, sy, side >
427 0 ? -(150 - t * 30) : -(30 + t * 30), L * 0.8, Wd * 0.8, baseHue, 0.3 + t * 0.6); 428 } 429 } 430 addLeaf(topX, topY, -90, rfloat(16, 26), rfloat(7, 11), baseHue, 0.95); 431 if (hasHead) addFlower(topX, topY + (variety === "bud" ? 2 : -2), baseHue); 432 return finalize(); 433 } 434 435 // Render a plant spec into crisp vector SVG. Parts carry an optional `tag` 436 // ('circle'); everything else is a <path>. Stroked-only parts (stems, midribs) 437 // have no `fill`, so we default fill to 'none' to avoid SVG's black default. 438 function PlantSVG({ 439 spec 440 }) { 441 const children = spec.parts.map((p, i) => p.tag === "circle" ? /*#__PURE__*/React.createElement("circle", { 442 key: i, 443 cx: p.cx, 444 cy: p.cy, 445 r: p.r, 446 fill: p.fill 447 }) : /*#__PURE__*/React.createElement("path", { 448 key: i, 449 d: p.d, 450 fill: p.fill || "none", 451 stroke: p.stroke, 452 strokeWidth: p.sw, 453 strokeLinecap: p.cap, 454 opacity: p.opacity, 455 transform: p.transform 456 })); 457 return /*#__PURE__*/React.createElement("svg", { 458 width: spec.w, 459 height: spec.h, 460 viewBox: `0 0 ${spec.w} ${spec.h}`, 461 style: { 462 display: "block", 463 overflow: "visible" 464 } 465 }, /*#__PURE__*/React.createElement("g", { 466 transform: `translate(${spec.cx} ${spec.baseY})` 467 }, children)); 468 } 469 470 // Build one plant placed at viewport x. Growth is deliberately slow and each 471 // plant gets its own duration + delay so the garden emerges irregularly rather 472 // than as a tidy left-to-right sweep. Retreat is a touch quicker but still soft. 473 function makePlantObj(x) { 474 return { 475 x, 476 spec: makePlant(), 477 swayDur: rfloat(4, 8), 478 swayDelay: -rfloat(0, 8), 479 swayDeg: rfloat(1.2, 3), 480 growDur: rfloat(6, 13), 481 growDelay: rfloat(0, 8), 482 retreatDur: rfloat(0.9, 1.8), 483 retreatDelay: rfloat(0, 0.7), 484 // Direction + amount each plant leans as it wilts, so a clump folds down 485 // every-which-way rather than all tipping the same side. 486 wiltLean: rfloat(2.5, 6) * (Math.random() < 0.5 ? -1 : 1) 487 }; 488 } 489 490 // Lay plants out in irregular packs separated by bare gaps, rather than evenly 491 // spaced. A pack is a few plants crowded together (overlapping into a clump); 492 // between packs there's a wide stretch of empty soil. 493 function makePlantSet(width) { 494 const plants = []; 495 let x = rfloat(-12, 36); 496 while (x < width + 12) { 497 const packSize = rint(1, 5); 498 for (let p = 0; p < packSize && x < width + 12; p++) { 499 plants.push(makePlantObj(x)); 500 x += rfloat(16, 44); // tight, overlapping spacing inside a pack 501 } 502 x += rfloat(60, 260); // wide empty gap before the next pack 503 } 504 return plants; 505 } 506 function IdleGarden({ 507 enabled = true, 508 paused = false 509 }) { 510 const [active, setActive] = useState(false); 511 const [plants, setPlants] = useState(() => enabled ? makePlantSet(window.innerWidth) : []); 512 // True once the garden has grown at least once. Gates the wilt-retreat 513 // animation so it never plays on the initial (never-grown) render â plants 514 // should only wilt back down after they've actually risen. 515 const hasGrownRef = useRef(false); 516 517 // ###### IDLE WATCH ###### 518 // Re-arm a 20s timer on any interaction; firing it grows the garden. Any 519 // interaction while grown retreats it. Backgrounded tabs retreat immediately. 520 useEffect(() => { 521 if (!enabled) return; 522 if (paused) { 523 setActive(false); 524 return; 525 } 526 let timer; 527 const arm = () => { 528 clearTimeout(timer); 529 timer = setTimeout(() => { 530 hasGrownRef.current = true;
531 setActive(true); 532 }, 20000); 533 }; 534 const onActivity = () => { 535 setActive(false); 536 arm(); 537 }; 538 const onVisibility = () => { 539 if (document.hidden) { 540 clearTimeout(timer); 541 setActive(false); 542 } else { 543 arm(); 544 } 545 }; 546 const evs = ["mousemove", "mousedown", "keydown", "wheel", "touchstart", "scroll"]; 547 evs.forEach(e => window.addEventListener(e, onActivity, { 548 passive: true 549 })); 550 document.addEventListener("visibilitychange", onVisibility); 551 arm(); 552 return () => { 553 clearTimeout(timer); 554 evs.forEach(e => window.removeEventListener(e, onActivity)); 555 document.removeEventListener("visibilitychange", onVisibility); 556 }; 557 }, [enabled, paused]); 558 559 // Re-flow the garden to a new viewport width (debounced). 560 useEffect(() => { 561 if (!enabled) return; 562 let t; 563 const onResize = () => { 564 clearTimeout(t); 565 t = setTimeout(() => setPlants(makePlantSet(window.innerWidth)), 250); 566 }; 567 window.addEventListener("resize", onResize); 568 return () => { 569 clearTimeout(t); 570 window.removeEventListener("resize", onResize); 571 }; 572 }, [enabled]); 573 if (!enabled) return null; 574 return /*#__PURE__*/React.createElement("div", { 575 className: "idle-garden", 576 "aria-hidden": "true" 577 }, plants.map((p, i) => /*#__PURE__*/React.createElement("div", { 578 key: i, 579 className: "idle-plant" + (active ? " grown" : hasGrownRef.current ? " retreating" : ""), 580 style: { 581 left: p.x + "px" 582 } 583 }, /*#__PURE__*/React.createElement("div", { 584 className: "idle-plant-sway", 585 style: { 586 "--sway-dur": p.swayDur + "s", 587 "--sway-delay": p.swayDelay + "s", 588 "--sway-deg": p.swayDeg + "deg" 589 } 590 }, /*#__PURE__*/React.createElement("div", { 591 className: "idle-plant-reveal", 592 style: { 593 "--rev-dur": (active ? p.growDur : p.retreatDur) + "s", 594 "--rev-delay": (active ? p.growDelay : p.retreatDelay) + "s", 595 "--wilt-lean": p.wiltLean + "deg" 596 } 597 }, /*#__PURE__*/React.createElement(PlantSVG, { 598 spec: p.spec 599 })))))); 600 } 601 602 // âââââââââââ 3. Terminal + 4. Mini-game âââââââââââ 603 604 // Lazy loader for the Forest Wanderer adventure module. The game's data + parser 605 // is ~1000 LOC;
605 we don't want to parse it on initial page load just to support a 606 // rarely-typed command. The script is injected on demand the first time `forest` 607 // is invoked; subsequent invocations resolve instantly (browser cache). 608 let forestLoaderPromise = null; 609 function loadForestAdventure() { 610 if (window.ForestAdventure) return Promise.resolve(window.ForestAdventure); 611 if (forestLoaderPromise) return forestLoaderPromise; 612 forestLoaderPromise = new Promise((resolve, reject) => { 613 const s = document.createElement("script"); 614 s.src = "forest-adventure.js"; 615 s.async = true; 616 s.onload = () => resolve(window.ForestAdventure); 617 s.onerror = () => { 618 forestLoaderPromise = null; 619 reject(new Error("load failed")); 620 }; 621 document.head.appendChild(s); 622 }); 623 return forestLoaderPromise; 624 } 625 const TERMINAL_BANNER = [" ___ ___ _ _ ", "| _|/ __|| | |", "| |_ \\__ \\| | |", "|___||___/|_|_|", "", "âââ cg.term v1.0 ââââââââââââââââââââââââââââââââââââââââ", "â type `help` for commands. `exit` or Esc to close.", "âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ", ""]; 626 627 // 8-row figlet-style "Big" font for the `ascii` command. Each glyph is an 628 // array of 8 strings, all the same width within a glyph. Letters are 629 // concatenated row-by-row to form the rendered art. 630 const BIG_FONT = { 631 " ": [" ", " ", " ", " ", " ", " ", " ", " "], 632 ".": [" ", " ", " ", " ", " _ ", "(_)", " ", " "], 633 ",": [" ", " ", " ", " ", " ", " _ ", "( )", "|/ "], 634 "!": [" _ ", "| |", "| |", "| |", "|_|", "(_)", " ", " "], 635 "?": [" ___ ", "|__ \\ ", " ) |", " / / ", " |_| ", " (_) ", " ", " "], 636 "-": [" ", " ", " ", " ____ ", "|____|", " ", " ", " "], 637 _: [" ", " ", " ", " ", " ", " ", " ", " ______ "], 638 ":": [" ", " ", " _ ", "(_)", " _ ", "(_)", " ", " "], 639 "'": [" _ ", "( )", "|/ ", " ", " ", " ", " ", " "], 640 A: [" _ ", " / \\ ", " / _ \\ ", " / ___ \\ ", " /_/ \\_\\", " ", " ", " "], 641 B: [" ____ ", "| _ \\ ", "| |_) |", "| _ < ", "| |_) |", "|____/ ", " ", " "], 642 C: [" _____ ", " / ____|", "| | ", "| | ", "| |____ ", " \\_____|", " ", " "], 643 D: [" _____ ", "| __ \\ ", "| | | |", "| | | |", "| |__| |", "|_____/ ", " ", " "], 644 E: [" ______ ", "| ____|", "| |__ ", "| __| ", "| |____ ", "|______|", " ", " "], 645 F: [" ______ ", "| ____|", "| |__ ", "| __| ", "| | ", "|_| ", " ", " "], 646 G: [" _____ ", " / ____|", "| | __ ", "| | |_ |", "| |__| |", " \\_____|", " ", " "], 647 H: [" _ _ ", "| | | |", "| |__| |", "| __ |", "| | | |", "|_| |_|", " ", " "], 648 I: [" _____ ", "|_ _|", " | | ", " | | ", " _| |_ ", "|_____|", " ", " "], 649 J: [" _ ", " | |", " | |", " _ | |", " | |__| |", " \\____/ ", " ", " "], 650 K: [" _ __ ", "| |/ / ", "| ' / ", "| < ", "| . \\ ", "|_|\\_\\ ", " ", " "], 651 L: [" _ ", "| | ", "| | ", "| | ", "| |____ ", "|______|", " ", " "], 652 M: [" __ __ ", "| \\/ |", "| \\ / |", "| |\\/| |", "| | | |", "|_| |_|", " ", " "], 653 N: [" _ _ ", "| \\ | |", "| \\| |", "| . ` |", "| |\\ |", "|_| \\_|", " ", " "], 654 O: [" ____ ", " / __ \\ ", "| | | |", "| | | |", "| |__| |", " \\____/ ", " ", " "], 655 P: [" _____ ", "| __ \\ ", "| |__) |", "| ___/ ", "| | ", "|_| ", " ", " "], 656 Q: [" ____ ", " / __ \\ ", "| | | | ", "| | | | ", "| |__| | ", " \\___\\_\\ ", " ", " "], 657 R: [" _____ ", "| __ \\ ", "| |__) |", "| _ / ", "| | \\ \\ ", "|_| \\_\\", " ", " "], 658 S: [" _____ ", " / ____|", "| (___ ", " \\___ \\ ", " ____) |", "|_____/ ", " ", " "], 659 T: [" _______ ", "|__ __|", " | | ", " | | ", " | | ", " |_| ", " ", " "], 660 U: [" _ _ ", "| | | |", "| | | |", "| | | |", "| |__| |", " \\____/ ", " ", " "], 661 V: ["__ __", "\\ \\ / /", " \\ \\ / / ", " \\ \\/ / ", " \\ / ", " \\/ ", " ", " "], 662 W: ["__ __", "\\ \\ / /", " \\ \\ /\\ / / ", " \\ \\/ \\/ / ", " \\ /\\ / ", " \\/ \\/ ", " ", " "], 663 X: ["__ __", "\\ \\ / /", " \\ V / ", " > < ", " / . \\ ", "/_/ \\_\\", " ", " "], 664 Y: ["__ __", "\\ \\ / /", " \\ \\_/ / ", " \\ / ", " | | ", " |_| ", " ", " "], 665 Z: [" ______ ", "|___ / ", " / / ", " / / ", " / /__ ", "/_____| ", " ", " "], 666 a: [" ", " ", " __ _ ", " / _` |", " | (_| |", " \\__,_|", " ", " "], 667 b: [" _ ", "| | ", "| |__ ", "| '_ \\ ", "| |_) | ", "|_.__/ ", " ", " "], 668 c: [" ", " ", " ___ ", " / __| ", "| (__ ", " \\___| ", " ", " "], 669 d: [" _ ", " | |", " __| |", " / _` |", " | (_| |", " \\__,_|", " ", " "], 670 e: [" ", " ", " ___ ", " / _ \\ ", "| __/ ", " \\___| ", " ", " "], 671 f: [" __ ", " / _|", " | |_ ", " | _|", " | | ", " |_| ", " ", " "], 672 g: [" ", " ", " __ _ ", " / _` |", " | (_| |", " \\__, |", " __/ |", " |___/ "], 673 h: [" _ ", "| | ", "| |__ ", "| '_ \\ ", "| | | | ", "|_| |_| ", " ", " "], 674 i: [" _ ", "(_)", " _ ", "| |", "| |", "|_|", " ", " "], 675 j: [" _ ", " (_)", " _ ", " | |", " | |", " | |", " _ | |", "|__/ "],
676 k: [" _ ", "| | ", "| | __", "| |/ /", "| < ", "|_|\\_\\", " ", " "], 677 l: [" _ ", "| |", "| |", "| |", "| |", "|_|", " ", " "], 678 m: [" ", " ", " _ __ ___ ", "| '_ ` _ \\ ", "| | | | | | ", "|_| |_| |_| ", " ", " "], 679 n: [" ", " ", " _ __ ", "| '_ \\ ", "| | | | ", "|_| |_| ", " ", " "], 680 o: [" ", " ", " ___ ", " / _ \\ ", "| (_) | ", " \\___/ ", " ", " "], 681 p: [" ", " ", " _ __ ", "| '_ \\ ", "| |_) | ", "| .__/ ", "| | ", "|_| "], 682 q: [" ", " ", " __ _ ", " / _` |", " | (_| |", " \\__, |", " | |", " |_|"], 683 r: [" ", " ", " _ __ ", "| '__| ", "| | ", "|_| ", " ", " "], 684 s: [" ", " ", " ___ ", " / __| ", " \\__ \\ ", " |___/ ", " ", " "], 685 t: [" _ ", "| | ", "| |_ ", "| __| ", "| |_ ", " \\__| ", " ", " "], 686 u: [" ", " ", " _ _ ", "| | | | ", "| |_| | ", " \\__,_| ", " ", " "], 687 v: [" ", " ", "__ __ ", "\\ \\ / / ", " \\ V / ", " \\_/ ", " ", " "], 688 w: [" ", " ", "__ __ ", "\\ \\ /\\ / / ", " \\ V V / ", " \\_/\\_/ ", " ", " "], 689 x: [" ", " ", "__ __ ", "\\ \\ / / ", " > < ", "/_/\\_\\ ", " ", " "], 690 y: [" ", " ", " _ _ ", "| | | | ", "| |_| | ", " \\__, | ", " __/ | ", " |___/ "], 691 z: [" ", " ", " ____ ", "|_ / ", " / / ", "/___| ", " ", " "], 692 0: [" ___ ", " / _ \\ ", "| | | |", "| | | |", "| |_| |", " \\___/ ", " ", " "], 693 1: [" __ ", "/_ |", " | |", " | |", " | |", " |_|", " ", " "], 694 2: [" ___ ", "|__ \\ ", " ) |", " / / ", " / /_ ", "|____|", " ", " "], 695 3: [" ____ ", "|___ \\ ", " __) |", " |__ < ", " ___) |", "|____/ ", " ", " "], 696 4: [" _ _ ", "| || | ", "| || |_ ", "|__ _|", " | | ", " |_| ", " ", " "], 697 5: [" _____ ", "| ____|", "| |__ ", "|___ \\ ", " ___) |", "|____/ ", " ", " "], 698 6: [" __ ", " / / ", " / /_ ", "| _ \\ ", "| (_) |", " \\___/ ", " ", " "], 699 7: [" ______ ", "|____ |", " / / ", " / / ", " / / ", " /_/ ", " ", " "], 700 8: [" ___ ", " / _ \\ ", "| (_) |", " > _ < ", "| (_) |", " \\___/ ", " ", " "], 701 9: [" ___ ", " / _ \\ ", "| (_) |", " \\__, |", " / / ", " /_/ ", " ", " "] 702 }; 703 function renderBigText(text) { 704 const glyphs = []; 705 for (const ch of text) { 706 glyphs.push(BIG_FONT[ch] || BIG_FONT[ch.toUpperCase()] || BIG_FONT["?"] || BIG_FONT[" "]); 707 } 708 if (!glyphs.length) return []; 709 const lines = []; 710 for (let r = 0; r < 8; r++) { 711 let line = ""; 712 for (const g of glyphs) line += g[r] || ""; 713 lines.push(line.replace(/\s+$/, "")); 714 } 715 // Trim leading/trailing all-empty rows so words without descenders or 716 // ascenders render compactly. 717 while (lines.length && !lines[0].trim()) lines.shift(); 718 while (lines.length && !lines[lines.length - 1].trim()) lines.pop(); 719 return lines; 720 } 721 function Terminal({ 722 open, 723 onClose, 724 autoLaunchGame = false 725 }) { 726 const [history, setHistory] = useState([]); 727 const [gameOpen, setGameOpen] = useState(false); 728 const [minimized, setMinimized] = useState(false); 729 const [maximized, setMaximized] = useState(false); 730 const [asciiMode, setAsciiMode] = useState(null); // null | 'text' 731 const [gameMode, setGameMode] = useState(null); // null | 'forest' | 'forest-menu' | 'forest-seed' 732 const wasKonamiRef = useRef(false); 733 const inputRef = useRef(null); 734 const bodyRef = useRef(null); 735 // Ghost-text autocomplete overlay: the element we paint the dim suffix into, 736 // and the current suffix (accepted with â or Tab). 737 const ghostRef = useRef(null); 738 const ghostSuffixRef = useRef(""); 739 // Live forest-adventure state â ref'd (not state) so the per-turn updates 740 // don't trigger React re-renders of the history list. 741 const gameStateRef = useRef(null); 742 // Typewriter controller: a queue of lines to type out, plus the current 743 // skip function and intervalId so we can fast-forward or cancel cleanly. 744 const typewriterCtrl = useRef({ 745 queue: [], 746 busy: false, 747 skipFn: null, 748 started: null 749 }); 750 // Track which history indices have already kicked off their reveal so the 751 // callback ref doesn't restart the animation on every re-render. 752 const revealStartedRef = useRef(new Set()); 753 // Command-history recall. Past commands oldest->newest; a cursor into that list 754 // (-1 = the live line, i.e. not navigating); and the draft we were typing before 755 // pressing Up, restored when we navigate back down past the newest entry. 756 const cmdHistoryRef = useRef([]); 757 const histCursorRef = useRef(-1); 758 const histDraftRef = useRef(""); 759 760 // Uncontrolled input: the <input> owns its value via the DOM. We only read 761 // it on submit (via inputRef.current.value) and clear it the same way. 762 // This avoids re-rendering the entire history list on every keystroke, 763 // which is what causes the typing to feel sluggish. 764 function clearInput() { 765 if (inputRef.current) inputRef.current.value = ""; 766 clearGhost(); 767 } 768 769 // Set the input value and drop the caret at the end (used by arrow recall). 770 function setInputValue(v) { 771 const el = inputRef.current; 772 if (!el) return; 773 el.value = v; 774 el.setSelectionRange(v.length, v.length); 775 updateGhost(); 776 } 777 778 // ââ Ghost-text autocomplete ââââââââââââââââââââââââââââââââââ 779 // Paint a dim suffix after the caret showing the predicted completion. 780 // The typed text is mirrored transparently so the suffix lines up. 781 function clearGhost() { 782 ghostSuffixRef.current = ""; 783 if (ghostRef.current) ghostRef.current.textContent = ""; 784 } 785 function updateGhost() { 786 const el = inputRef.current; 787 const g = ghostRef.current; 788 if (!el || !g) return; 789 const val = el.value; 790 let suffix = ""; 791 // Only when there's input and the caret sits at the very end. 792 if (val && el.selectionStart === val.length && el.selectionEnd === val.length) { 793 const F = window.ForestAdventure; 794 const res = gameMode === "forest" && F && F.complete ? F.complete(val, gameStateRef.current) : completeCommand(val); 795 if (res && res.completion && res.completion.length > val.length && res.completion.indexOf(val) === 0) { 796 suffix = res.completion.slice(val.length); 797 } 798 } 799 ghostSuffixRef.current = suffix; 800 g.textContent = ""; 801 if (suffix) { 802 const typed = document.createElement("span"); 803 typed.className = "term-ghost-typed"; 804 typed.textContent = val; 805 const suf = document.createElement("span"); 806 suf.className = "term-ghost-suffix"; 807 suf.textContent = suffix; 808 g.appendChild(typed); 809 g.appendChild(suf); 810 } 811 } 812 813 // Render a history line's text, expanding `§x{â¦}` emphasis tokens emitted 814 // by the forest game into coloured spans. Plain text passes straight through. 815 function renderLineText(text) { 816 if (!text || text.indexOf("§") === -1) return text; 817 const parts = []; 818 const re = /§([ids])\{([^}]*)\}/g; 819 let last = 0; 820 let m; 821 let key = 0; 822 while (m = re.exec(text)) { 823 if (m.index > last) parts.push(text.slice(last, m.index)); 824 const cls = m[1] === "i" ? "tok-item" : m[1] === "d" ? "tok-dir" : "tok-secret"; 825 parts.push(/*#__PURE__*/React.createElement("span", { 826 key: key++, 827 className: cls 828 }, m[2])); 829 last = re.lastIndex; 830 } 831 if (last < text.length) parts.push(text.slice(last)); 832 return parts; 833 } 834 835 // Record a submitted line into the recall history and reset the cursor. 836 // Shared by the normal command line and the forest game so Up/Down recalls 837 // work the same in both. 838 function pushHistory(raw) { 839 const entry = (raw || "").trim(); 840 const hist = cmdHistoryRef.current; 841 if (entry && hist[hist.length - 1] !== entry) { 842 hist.push(entry); 843 if (hist.length > 100) hist.shift(); 844 } 845 histCursorRef.current = -1; 846 histDraftRef.current = ""; 847 } 848 849 // Tab-completion for the normal command line (the forest game supplies its 850 // own completer via window.ForestAdventure.complete). 851 const TERM_COMMANDS = ["about", "ascii", "clear", "contact", "date", "exit", "forest", "help", "projects", "snake", "stack", "whoami"]; 852 function completeCommand(val) { 853 const endsSpace = /\s$/.test(val); 854 const words = val.trim() ? val.trim().split(/\s+/) : []; 855 if (words.length !== 1 || endsSpace) return { 856 completion: null, 857 candidates: [] 858 }; 859 const prefix = words[0].toLowerCase(); 860 const matches = TERM_COMMANDS.filter(c => c.indexOf(prefix) === 0); 861 if (!matches.length) return { 862 completion: null, 863 candidates: [] 864 }; 865 if (matches.length === 1) return { 866 completion: matches[0], 867 candidates: matches 868 }; 869 let common = matches[0]; 870 for (const m of matches) { 871 let i = 0; 872 while (i < common.length && i < m.length && common[i] === m[i]) i++; 873 common = common.slice(0, i); 874 } 875 return { 876 completion: common.length > prefix.length ? common : null, 877 candidates: matches 878 }; 879 } 880 881 // Cancel any in-flight typewriter (e.g. on `clear` or terminal close). 882 function cancelAllTypewriters() { 883 if (typewriterCtrl.current.skipFn) typewriterCtrl.current.skipFn(); 884 typewriterCtrl.current.queue.length = 0; 885 typewriterCtrl.current.busy = false;
886 typewriterCtrl.current.skipFn = null; 887 } 888 889 // Append a batch of game-emitted lines to history all at once. The 890 // forest adventure prints instantly â no typewriter reveal â so the 891 // ASCII tables and room descriptions land legibly in one go. 892 function enqueueGameLines(lines) { 893 if (!lines || !lines.length) return; 894 setHistory(h => { 895 const next = h.slice(); 896 for (const ln of lines) { 897 next.push({ 898 kind: ln.kind || "ok", 899 text: ln.text || "" 900 }); 901 } 902 return next; 903 }); 904 } 905 906 // The forest title-screen items, in display order. `resume` only appears 907 // when a save exists â the rest stay contiguous so the printed numbers are 908 // always 1, 2, 3, ⦠with no gaps. 909 function forestMenuItems(F) { 910 const hasSave = F.hasSave && F.hasSave(); 911 const items = [{ 912 id: "new", 913 label: "new game", 914 aliases: ["new", "new game"] 915 }]; 916 if (hasSave) items.push({ 917 id: "resume", 918 label: "resume your walk", 919 aliases: ["resume"] 920 }); 921 items.push({ 922 id: "endings", 923 label: "endings & achievements", 924 aliases: ["endings", "achievements"] 925 }); 926 items.push({ 927 id: "help", 928 label: "how to play", 929 aliases: ["help", "how to play"] 930 }); 931 items.push({ 932 id: "seed", 933 label: "new game with a seed", 934 aliases: ["seed"] 935 }); 936 return items; 937 } 938 // The forest title screen, numbered from the active items above. 939 function forestMenuLines(F) { 940 const out = [{ 941 text: "", 942 kind: "game" 943 }, { 944 text: "F O R E S T W A N D E R E R", 945 kind: "room" 946 }, { 947 text: "a small adventure in the woods.", 948 kind: "dim" 949 }, { 950 text: "", 951 kind: "game" 952 }]; 953 forestMenuItems(F).forEach((it, i) => { 954 out.push({ 955 text: " " + (i + 1) + ") " + it.label, 956 kind: "choice" 957 }); 958 }); 959 out.push({ 960 text: "", 961 kind: "game" 962 }); 963 out.push({ 964 text: "(type a number.)", 965 kind: "dim" 966 }); 967 return out; 968 } 969 // Resolve a typed answer (a menu number or a word alias) to a menu item. 970 function resolveForestMenuChoice(F, ans) { 971 const items = forestMenuItems(F); 972 if (/^\d+$/.test(ans)) { 973 const n = parseInt(ans, 10); 974 return n >= 1 && n <= items.length ? items[n - 1] : null; 975 } 976 return items.filter(it => it.aliases.indexOf(ans) !== -1)[0] || null; 977 } 978 // Begin a fresh forest run (optionally seeded) and enter play mode. 979 function startForestGame(F, opts) { 980 if (F.clearSave) F.clearSave(); 981 const result = F.start(opts || {}); 982 gameStateRef.current = result.state; 983 setGameMode("forest"); 984 enqueueGameLines(result.lines); 985 } 986 // Resume the saved run and re-orient the player with a fresh `look`. 987 function resumeForestGame(F) { 988 const loaded = F.load(); 989 if (!loaded) { 990 startForestGame(F); 991 return; 992 } 993 gameStateRef.current = loaded; 994 setGameMode("forest"); 995 const ok = F.parse("look", gameStateRef.current); 996 gameStateRef.current = ok.state; 997 enqueueGameLines([{ 998 text: "(resumed.)", 999 kind: "dim" 1000 }].concat(ok.lines)); 1001 } 1002 1003 // Reveal `text` character-by-character on the given DOM element. Calls 1004 // onDone() when finished (either organically or via skipFn). 1005 function revealOnElement(el, text, lineIdx, onDone) { 1006 typewriterCtrl.current.busy = true; 1007 let i = 0; 1008 const id = setInterval(() => { 1009 i++; 1010 el.textContent = text.slice(0, i); 1011 if (i >= text.length) finalize(); 1012 }, 25); 1013 function finalize() { 1014 clearInterval(id); 1015 el.textContent = text; 1016 typewriterCtrl.current.skipFn = null; 1017 typewriterCtrl.current.busy = false; 1018 // Mark the history line as no longer typing so the ref doesn't re-fire. 1019 setHistory(h => h.map((l, i2) => i2 === lineIdx ? { 1020 ...l, 1021 text, 1022 isTyping: false, 1023 fullText: undefined 1024 } : l)); 1025 // Process next queued reveal. 1026 const next = typewriterCtrl.current.queue.shift(); 1027 if (next) revealOnElement(next.el, next.text, next.lineIdx, next.onDone);else if (onDone) onDone(); 1028 } 1029 typewriterCtrl.current.skipFn = finalize; 1030 } 1031 1032 // Start (or queue) a reveal for a freshly-mounted typing line. 1033 function startReveal(el, text, lineIdx) { 1034 if (typewriterCtrl.current.busy) { 1035 typewriterCtrl.current.queue.push({ 1036 el, 1037 text, 1038 lineIdx 1039 }); 1040 return; 1041 } 1042 revealOnElement(el, text, lineIdx); 1043 } 1044 1045 // Reset on open. If autoLaunchGame is true (Konami code path), jump straight into the game. 1046 useEffect(() => { 1047 if (open) { 1048 cancelAllTypewriters(); 1049 revealStartedRef.current = new Set(); 1050 setHistory(TERMINAL_BANNER.map(l => ({ 1051 kind: "ok", 1052 text: l 1053 }))); 1054 clearInput(); 1055 histCursorRef.current = -1; 1056 histDraftRef.current = "";
1057 setMinimized(false); 1058 setMaximized(false); 1059 setAsciiMode(null); 1060 setGameMode(null); 1061 gameStateRef.current = null; 1062 if (autoLaunchGame) { 1063 setGameOpen(true); 1064 wasKonamiRef.current = true; 1065 } else { 1066 setGameOpen(false); 1067 wasKonamiRef.current = false; 1068 } 1069 setTimeout(() => inputRef.current?.focus(), 30); 1070 } 1071 }, [open, autoLaunchGame]); 1072 1073 // Auto-scroll â runs synchronously after DOM mutation so new lines and the 1074 // scroll-to-bottom happen in the same paint (no visible jump). 1075 useLayoutEffect(() => { 1076 if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight; 1077 }, [history]); 1078 1079 // Esc closes 1080 useEffect(() => { 1081 if (!open) return; 1082 const onKey = e => { 1083 if (e.key === "Escape") onClose(); 1084 }; 1085 window.addEventListener("keydown", onKey); 1086 return () => window.removeEventListener("keydown", onKey); 1087 }, [open, onClose]); 1088 function print(text, kind = "ok") { 1089 setHistory(h => h.concat([{ 1090 kind, 1091 text 1092 }])); 1093 } 1094 function run(raw) { 1095 const cmd = raw.trim().toLowerCase(); 1096 print("$ " + raw, "cmd"); 1097 if (!cmd) return; 1098 if (cmd === "help") { 1099 print("Available commands:"); 1100 print(" about · quick bio"); 1101 print(" ascii · ASCII art generator"); 1102 print(" clear · clear the screen"); 1103 print(" contact · how to reach me"); 1104 print(" date · current date"); 1105 print(" exit · close terminal"); 1106 print(" forest · explore the woods"); 1107 print(" projects · list of projects"); 1108 print(" snake · play snake"); 1109 print(" stack · tech stack"); 1110 print(" whoami · guess"); 1111 } else if (cmd === "forest") { 1112 // Lazy-load, then show the title menu. 1113 const onReady = F => { 1114 enqueueGameLines(forestMenuLines(F)); 1115 setGameMode("forest-menu"); 1116 }; 1117 if (window.ForestAdventure) { 1118 onReady(window.ForestAdventure); 1119 } else { 1120 print("loading the woodsâ¦", "dim"); 1121 loadForestAdventure().then(onReady).catch(() => { 1122 print("failed to load the woods. try again?", "err"); 1123 }); 1124 } 1125 } else if (cmd === "ascii" || cmd.startsWith("ascii ")) { 1126 const inline = raw.trim().slice(5).trim(); 1127 if (inline) { 1128 print(""); 1129 renderBigText(inline).forEach(line => print(line, "art")); 1130 print(""); 1131 } else { 1132 print("what should I render? (or `cancel`)"); 1133 setAsciiMode("text"); 1134 } 1135 } else if (cmd === "about") { 1136 print("Cristian Gabriel. Software developer, Bucharest, RO."); 1137 print("Works mostly with Java + Spring Boot. Likes plants."); 1138 } else if (cmd === "projects") { 1139 print(" 1. Impostor · social party game (live)"); 1140 print(" 2. Loopretto · transcription tool for musicians"); 1141 print(" 3. QuickPaste · Chrome extension for clip-collecting"); 1142 print(" 4. Trendalizer · keyword interest grapher"); 1143 print(" 5. Furniture Boutique · landing page"); 1144 } else if (cmd === "stack") { 1145 print("Java · Spring Boot · Thymeleaf · PostgreSQL · MySQL"); 1146 print("Linux · Git · GitHub · HTML · CSS · JS · Bootstrap"); 1147 } else if (cmd === "contact") { 1148 print("email : [email protected]", "ok"); 1149 print("github : github.com/cristiangabriel88", "ok"); 1150 print("linkedin: in/cristian-gabriel-constantinescu-781a6b237", "ok"); 1151 } else if (cmd === "whoami") { 1152 print("[email protected]"); 1153 } else if (cmd === "date") { 1154 print(new Date().toString()); 1155 } else if (cmd === "snake") { 1156 print("Launching snake... arrow keys / WASD to steer. Esc to exit.", "dim"); 1157 setTimeout(() => setGameOpen(true), 300); 1158 } else if (cmd === "clear") { 1159 cancelAllTypewriters(); 1160 revealStartedRef.current = new Set(); 1161 setHistory([]); 1162 } else if (cmd === "exit" || cmd === "close" || cmd === "q") { 1163 onClose(); 1164 } else if (cmd === "sudo") { 1165 print("Nice try.", "err"); 1166 } else if (cmd === "ls") { 1167 print("home/ about/ projects/ .secret/"); 1168 } else if (cmd === "cat .secret") { 1169 print("You found it. There's a tiny snake hungry for fruit. Try `game`.", "ok"); 1170 } else { 1171 print(`cg: command not found: ${cmd}`, "err"); 1172 print("try `help`", "dim"); 1173 } 1174 } 1175 function onInputKeyDown(e) { 1176 // Tab completes the current word â forest verbs/nouns in the game, or 1177 // the top-level commands on the normal line. Never let Tab move focus. 1178 if (e.key === "Tab") { 1179 e.preventDefault(); 1180 if (asciiMode || gameOpen || gameMode && gameMode !== "forest") return; 1181 const el = inputRef.current; 1182 if (!el) return; 1183 const F = window.ForestAdventure; 1184 const res = gameMode === "forest" && F && F.complete ? F.complete(el.value, gameStateRef.current) : completeCommand(el.value); 1185 if (res && res.completion) { 1186 setInputValue(res.completion); 1187 } else if (res && res.candidates && res.candidates.length > 1) { 1188 enqueueGameLines([{ 1189 text: res.candidates.join(" "), 1190 kind: "dim" 1191 }]); 1192 } 1193 return; 1194 } 1195 // â at the end of the line accepts the dim ghost-text suggestion. 1196 if (e.key === "ArrowRight") { 1197 const el = inputRef.current; 1198 if (el && ghostSuffixRef.current && el.selectionStart === el.value.length) { 1199 e.preventDefault(); 1200 setInputValue(el.value + ghostSuffixRef.current); 1201 return; 1202 } 1203 } 1204 // Arrow recall on the normal command line and inside the forest game â 1205 // but not during a menu/prompt, the ASCII prompt, the snake overlay, 1206 // or while a typewriter reveal runs. 1207 if (asciiMode || gameOpen || typewriterCtrl.current.busy) return; 1208 if (gameMode && gameMode !== "forest") return; 1209 const hist = cmdHistoryRef.current; 1210 if (e.key === "ArrowUp") { 1211 if (!hist.length) return; 1212 e.preventDefault(); 1213 if (histCursorRef.current === -1) { 1214 histDraftRef.current = inputRef.current ? inputRef.current.value : ""; 1215 histCursorRef.current = hist.length - 1; 1216 } else if (histCursorRef.current > 0) { 1217 histCursorRef.current -= 1; 1218 } 1219 setInputValue(hist[histCursorRef.current]); 1220 } else if (e.key === "ArrowDown") { 1221 if (histCursorRef.current === -1) return; 1222 e.preventDefault(); 1223 if (histCursorRef.current < hist.length - 1) { 1224 histCursorRef.current += 1; 1225 setInputValue(hist[histCursorRef.current]); 1226 } else { 1227 histCursorRef.current = -1; // past the newest -> back to the draft 1228 setInputValue(histDraftRef.current); 1229 } 1230 } 1231 } 1232 function onSubmit(e) { 1233 e.preventDefault(); 1234 1235 // While the typewriter is mid-reveal, Enter skips the current paragraph 1236 // instead of submitting. The input value is left alone so the user can 1237 // keep typing through the reveal. 1238 if (typewriterCtrl.current.busy && typewriterCtrl.current.skipFn) { 1239 typewriterCtrl.current.skipFn(); 1240 return; 1241 } 1242 const raw = inputRef.current ? inputRef.current.value : ""; 1243 clearInput(); 1244 1245 // Forest adventure â title menu (numbered options). 1246 if (gameMode === "forest-menu") { 1247 print(">> " + raw, "cmd"); 1248 const F = window.ForestAdventure; 1249 const ans = raw.trim().toLowerCase(); 1250 const choice = resolveForestMenuChoice(F, ans); 1251 if (!choice) { 1252 // Wrong answer â re-show the menu so the options never scroll out of 1253 // reach, no matter how many times the player misses. 1254 enqueueGameLines([{ 1255 text: "that's not one of the options.", 1256 kind: "dim" 1257 }].concat(forestMenuLines(F))); 1258 } else if (choice.id === "new") { 1259 startForestGame(F); 1260 } else if (choice.id === "resume") { 1261 resumeForestGame(F); 1262 } else if (choice.id === "endings") { 1263 // Show the list, then re-print the menu so the choices are back in 1264 // view after the gallery has scrolled the screen. 1265 enqueueGameLines(F.galleryLines().concat(forestMenuLines(F))); 1266 } else if (choice.id === "help") { 1267 enqueueGameLines(F.helpLines().concat(forestMenuLines(F))); 1268 } else if (choice.id === "seed") { 1269 print("enter a number for the seed, leave blank for a random one, or type `cancel` to go back:", "dim"); 1270 setGameMode("forest-seed"); 1271 } 1272 return; 1273 } 1274 1275 // Forest adventure â seed entry for "new game with a seed". 1276 if (gameMode === "forest-seed") { 1277 print(">> " + raw, "cmd"); 1278 const F = window.ForestAdventure; 1279 const trimmed = raw.trim(); 1280 const lower = trimmed.toLowerCase(); 1281 if (lower === "cancel" || lower === "back") { 1282 // Bail out without starting a game (and without wiping any save). 1283 enqueueGameLines(forestMenuLines(F)); 1284 setGameMode("forest-menu"); 1285 } else if (trimmed === "") { 1286 startForestGame(F); // blank â a random seed 1287 } else if (/^\d+$/.test(trimmed)) { 1288 startForestGame(F, { 1289 seed: parseInt(trimmed, 10) 1290 }); 1291 } else { 1292 // Don't silently start (and clear the save) on a typo â re-ask. 1293 print("that's not a number. type a seed number, leave blank for random, or `cancel`.", "dim"); 1294 } 1295 return; 1296 } 1297 1298 // Forest adventure â main game loop. 1299 if (gameMode === "forest") { 1300 const F = window.ForestAdventure; 1301 print(">> " + raw, "cmd"); 1302 pushHistory(raw); 1303 const result = F.parse(raw, gameStateRef.current); 1304 gameStateRef.current = result.state; 1305 enqueueGameLines(result.lines); 1306 1307 // Auto-save after each substantive turn (skip a pure quit, and skip a 1308 // game-over so a finished run never overwrites a still-playable save). 1309 if (!result.done && !result.pendingGameOver) F.save(gameStateRef.current); 1310 1311 // Handle game-end flows. 1312 if (result.ended) { 1313 // Story ending reached â clear save so the next `forest` starts fresh. 1314 F.clearSave(); 1315 setGameMode(null); 1316 gameStateRef.current = null; 1317 } else if (result.done) { 1318 // Player quit cleanly. 1319 setGameMode(null); 1320 gameStateRef.current = null; 1321 } 1322 return; 1323 } 1324 1325 // Interactive ASCII flow â collect text, then render. 1326 if (asciiMode === "text") { 1327 print("> " + raw, "cmd"); 1328 const value = raw.trim(); 1329 if (!value || value.toLowerCase() === "cancel") { 1330 print("cancelled.", "dim"); 1331 setAsciiMode(null); 1332 return; 1333 } 1334 print(""); 1335 renderBigText(value).forEach(line => print(line, "art")); 1336 print(""); 1337 setAsciiMode(null); 1338 return; 1339 } 1340 pushHistory(raw); 1341 run(raw); 1342 } 1343 if (!open) return null; 1344
1345 // ###### MINIMIZED PILL ###### 1346 // Yellow button collapses the terminal to a small clickable pill at the bottom-right. 1347 if (minimized) { 1348 return /*#__PURE__*/React.createElement("button", { 1349 className: "terminal-minimized-pill", 1350 onClick: () => setMinimized(false), 1351 title: "Restore terminal" 1352 }, /*#__PURE__*/React.createElement("span", { 1353 className: "mp-dots" 1354 }, /*#__PURE__*/React.createElement("span", { 1355 className: "mp-dot" 1356 }), /*#__PURE__*/React.createElement("span", { 1357 className: "mp-dot" 1358 }), /*#__PURE__*/React.createElement("span", { 1359 className: "mp-dot" 1360 })), /*#__PURE__*/React.createElement("span", null, "cg.term \u2191")); 1361 } 1362 return /*#__PURE__*/React.createElement("div", { 1363 className: "terminal-backdrop" + (maximized ? " maximized" : ""), 1364 onMouseDown: e => { 1365 if (e.target === e.currentTarget) onClose(); 1366 } 1367 }, /*#__PURE__*/React.createElement("div", { 1368 className: "terminal" + (maximized ? " maximized" : ""), 1369 onMouseDown: e => e.stopPropagation() 1370 }, /*#__PURE__*/React.createElement("div", { 1371 className: "terminal-bar" 1372 }, /*#__PURE__*/React.createElement("div", { 1373 className: "dots" 1374 }, /*#__PURE__*/React.createElement("button", { 1375 className: "dot r", 1376 title: "Close", 1377 "aria-label": "Close", 1378 onClick: onClose 1379 }), /*#__PURE__*/React.createElement("button", { 1380 className: "dot y", 1381 title: "Minimize", 1382 "aria-label": "Minimize", 1383 onClick: () => setMinimized(true) 1384 }), /*#__PURE__*/React.createElement("button", { 1385 className: "dot g", 1386 title: "Zoom", 1387 "aria-label": "Zoom", 1388 onClick: () => setMaximized(m => !m) 1389 })), /*#__PURE__*/React.createElement("div", { 1390 className: "title" 1391 }, "cg@portfolio ~ %"), /*#__PURE__*/React.createElement("div", { 1392 style: { 1393 width: 56 1394 } 1395 })), gameOpen ? /*#__PURE__*/React.createElement(MiniGame, { 1396 onExit: () => { 1397 // If the game was launched via Konami, exiting closes the whole terminal. 1398 // If launched via the `game` command, exiting drops back to the prompt. 1399 if (wasKonamiRef.current) { 1400 onClose(); 1401 } else { 1402 setGameOpen(false); 1403 setTimeout(() => inputRef.current?.focus(), 30); 1404 } 1405 } 1406 }) : /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("div", { 1407 ref: bodyRef, 1408 className: "terminal-body" 1409 }, history.map((line, i) => /*#__PURE__*/React.createElement("p", { 1410 key: i, 1411 className: line.kind + (line.isTyping ? " typing" : ""), 1412 ref: line.isTyping ? el => { 1413 if (el && !revealStartedRef.current.has(i)) { 1414 revealStartedRef.current.add(i); 1415 startReveal(el, line.fullText, i); 1416 } 1417 } : null 1418 }, renderLineText(line.text)))), /*#__PURE__*/React.createElement("form", { 1419 className: "terminal-prompt", 1420 onSubmit: onSubmit 1421 }, /*#__PURE__*/React.createElement("span", { 1422 className: "ps" 1423 }, gameMode ? window.ForestAdventure ? window.ForestAdventure.PROMPT.trim() : ">>" : asciiMode ? ">" : "~ $"), /*#__PURE__*/React.createElement("span", { 1424 className: "term-inputwrap" 1425 }, /*#__PURE__*/React.createElement("span", { 1426 className: "term-ghost", 1427 ref: ghostRef, 1428 "aria-hidden": "true" 1429 }), /*#__PURE__*/React.createElement("input", { 1430 ref: inputRef, 1431 onKeyDown: onInputKeyDown, 1432 onInput: updateGhost, 1433 defaultValue: "", 1434 autoFocus: true, 1435 spellCheck: false, 1436 autoComplete: "off", 1437 autoCorrect: "off", 1438 autoCapitalize: "off" 1439 })))))); 1440 } 1441 1442 // ###### SNAKE ###### 1443 // Canvas-rendered snake on a 32Ã20 board. Walls wrap (toroidal), so the snake 1444 // re-enters from the opposite edge. Game state lives in refs; only HUD/score 1445 // goes through React, so the tick loop never triggers a 600-cell re-render. 1446 // 1447 // Rendering: requestAnimationFrame drives the paint continuously and lerps each 1448 // segment between its previous cell and current cell over the tick interval, so 1449 // motion looks smooth at 60fps regardless of the (slower) logical tick rate. 1450 // Wrap-aware lerp: when a segment straddles the board edge, we split-draw it on 1451 // both sides so the wrap reads as a continuous slide rather than a teleport. 1452 function MiniGame({ 1453 onExit 1454 }) { 1455 const COLS = 32, 1456 ROWS = 20; 1457 const PX = 16; // logical pixel size per cell â large enough to fit textured sprites (eyes, shading, apple stem) 1458 const INIT_SNAKE = [{ 1459 x: 16, 1460 y: 10 1461 }, {
1462 x: 15, 1463 y: 10 1464 }, { 1465 x: 14, 1466 y: 10 1467 }]; 1468 const TICK_BASE = 110; // ms per cell at score 0 1469 const TICK_MIN = 60; // ms per cell at high score 1470 1471 const canvasRef = useRef(null); 1472 const snakeRef = useRef(INIT_SNAKE); 1473 const prevSnakeRef = useRef(INIT_SNAKE); // snake positions *before* the current tick â used as the lerp "from" 1474 const foodRef = useRef({ 1475 x: 22, 1476 y: 10 1477 }); 1478 const dirRef = useRef({ 1479 dx: 1, 1480 dy: 0 1481 }); 1482 const lastAppliedDirRef = useRef({ 1483 dx: 1, 1484 dy: 0 1485 }); 1486 const scoreRef = useRef(0); 1487 const runningRef = useRef(false); 1488 const tickIntervalRef = useRef(TICK_BASE); 1489 1490 // ###### INPUT RESPONSIVENESS ###### 1491 // dirQueue: pending direction changes (size ⤠2) so a quick rightâdown isn't 1492 // dropped when both keys land between ticks â each tick consumes one entry. 1493 // lastTickAt + tickFnRef + MIN_INPUT_GAP: lets a valid turn fire a tick 1494 // immediately instead of waiting up to tick-time, capped so input spam can't 1495 // make the snake move faster than ~22 cells/sec. 1496 const dirQueueRef = useRef([]); 1497 const lastTickAtRef = useRef(0); 1498 const tickFnRef = useRef(null); 1499 const MIN_INPUT_GAP = 45; 1500 const [score, setScore] = useState(0); 1501 const [length, setLength] = useState(INIT_SNAKE.length); 1502 const [running, setRunning] = useState(false); 1503 const [over, setOver] = useState(false); 1504 function spawnFood(currentSnake) { 1505 for (let i = 0; i < 200; i++) { 1506 const f = { 1507 x: Math.floor(Math.random() * COLS), 1508 y: Math.floor(Math.random() * ROWS) 1509 }; 1510 if (!currentSnake.some(s => s.x === f.x && s.y === f.y)) return f; 1511 } 1512 return { 1513 x: 0, 1514 y: 0 1515 }; 1516 } 1517 1518 // ###### SPRITES ###### 1519 // Each cell is PXÃPX. Sprites use 1â2 pixel features (rim shade, highlight, eyes) 1520 // for a textured pixel-art read once the canvas is upscaled. Drawn in absolute 1521 // pixel coords so we can position at fractional cell offsets (for the lerp). 1522 function drawBackground(ctx) { 1523 ctx.fillStyle = "#0F1A07"; 1524 ctx.fillRect(0, 0, COLS * PX, ROWS * PX); 1525 // Faint cross-hatch dots â gives the board a board feel without competing for attention. 1526 ctx.fillStyle = "rgba(122, 163, 85, 0.08)"; 1527 for (let y = 0; y < ROWS; y++) { 1528 for (let x = 0; x < COLS; x++) { 1529 ctx.fillRect(x * PX + PX - 2, y * PX + PX - 2, 1, 1); 1530 } 1531 } 1532 } 1533 function drawBody(ctx, px, py) { 1534 // Rim (dark), main (mid green), highlight (light dot), shadow (bottom-right). 1535 ctx.fillStyle = "#2D5016"; 1536 ctx.fillRect(px + 1, py + 1, PX - 2, PX - 2); 1537 ctx.fillStyle = "#5A8A3C"; 1538 ctx.fillRect(px + 2, py + 2, PX - 4, PX - 4); 1539 ctx.fillStyle = "#7AA355"; 1540 ctx.fillRect(px + 3, py + 3, 2, 2); 1541 ctx.fillStyle = "#2D5016"; 1542 ctx.fillRect(px + PX - 5, py + PX - 5, 2, 2); 1543 } 1544 function drawHead(ctx, px, py, dir) { 1545 // Rim + brighter face so the head reads as distinct from the body. 1546 ctx.fillStyle = "#2D5016"; 1547 ctx.fillRect(px + 1, py + 1, PX - 2, PX - 2); 1548 ctx.fillStyle = "#A8CC7A"; 1549 ctx.fillRect(px + 2, py + 2, PX - 4, PX - 4); 1550 ctx.fillStyle = "#DFEFD2"; 1551 ctx.fillRect(px + 3, py + 3, 3, 2); 1552 // Eyes â placed on the leading edge of the head based on direction. 1553 ctx.fillStyle = "#0F1A07"; 1554 let ax, ay, bx, by; 1555 if (dir.dx === 1) { 1556 ax = px + PX - 5; 1557 ay = py + 4; 1558 bx = px + PX - 5; 1559 by = py + PX - 6; 1560 } else if (dir.dx === -1) { 1561 ax = px + 3; 1562 ay = py + 4; 1563 bx = px + 3; 1564 by = py + PX - 6; 1565 } else if (dir.dy === 1) { 1566 ax = px + 4; 1567 ay = py + PX - 5; 1568 bx = px + PX - 6; 1569 by = py + PX - 5; 1570 } else { 1571 ax = px + 4; 1572 ay = py + 3; 1573 bx = px + PX - 6; 1574 by = py + 3; 1575 } 1576 ctx.fillRect(ax, ay, 2, 2); 1577 ctx.fillRect(bx, by, 2, 2); 1578 } 1579 function drawFood(ctx, px, py) { 1580 // Apple silhouette: dark rim, red body, highlight dot, brown stem, leaf. 1581 ctx.fillStyle = "#5A1A0A"; 1582 ctx.fillRect(px + 3, py + 5, PX - 6, PX - 7); 1583 ctx.fillStyle = "#C24A2A"; 1584 ctx.fillRect(px + 4, py + 6, PX - 8, PX - 9); 1585 ctx.fillStyle = "#E8B19A"; 1586 ctx.fillRect(px + 5, py + 7, 2, 2); 1587 ctx.fillStyle = "#3A2410"; 1588 ctx.fillRect(px + (PX >> 1) - 1, py + 3, 2, 3); 1589 ctx.fillStyle = "#7AA355"; 1590 ctx.fillRect(px + (PX >> 1) + 1, py + 2, 3, 2); 1591 } 1592 1593 // ###### RAF DRAW ###### 1594 // Lerp each segment from prev cell to current cell using (time-since-tick / tickInterval). 1595 // Wrap handling: if the segment travelled more than 1 cell in a tick (e.g. 31â0), we 1596 // know it wrapped. Adjust the delta so the lerp goes the short way (off the board), then 1597 // split-draw on the opposite edge so the wrap looks continuous. 1598 function draw() { 1599 const canvas = canvasRef.current; 1600 if (!canvas) return; 1601 const ctx = canvas.getContext("2d"); 1602 ctx.imageSmoothingEnabled = false;
1603 drawBackground(ctx); 1604 drawFood(ctx, foodRef.current.x * PX, foodRef.current.y * PX); 1605 const snake = snakeRef.current; 1606 const prev = prevSnakeRef.current; 1607 const interval = tickIntervalRef.current; 1608 let t = (performance.now() - lastTickAtRef.current) / interval; 1609 if (t < 0) t = 0;else if (t > 1) t = 1; 1610 1611 // Back-to-front so the head paints on top of the neck. 1612 for (let i = snake.length - 1; i >= 0; i--) { 1613 const to = snake[i]; 1614 // On growth, the new tail-tip has no prev â anchor it at its current cell (no motion). 1615 const from = i < prev.length ? prev[i] : to; 1616 let dx = to.x - from.x; 1617 let dy = to.y - from.y; 1618 // Wrap-aware delta: any |d| > 1 means the segment crossed the board edge. 1619 if (dx > 1) dx -= COLS;else if (dx < -1) dx += COLS; 1620 if (dy > 1) dy -= ROWS;else if (dy < -1) dy += ROWS; 1621 const cx = (from.x + dx * t) * PX; 1622 const cy = (from.y + dy * t) * PX; 1623 const paint = (px, py) => { 1624 if (i === 0) drawHead(ctx, px, py, lastAppliedDirRef.current);else drawBody(ctx, px, py); 1625 }; 1626 paint(cx, cy); 1627 // Split-draw across wrapped edges so the segment is visible on both sides during the slide. 1628 if (cx < 0) paint(cx + COLS * PX, cy);else if (cx > (COLS - 1) * PX) paint(cx - COLS * PX, cy); 1629 if (cy < 0) paint(cx, cy + ROWS * PX);else if (cy > (ROWS - 1) * PX) paint(cx, cy - ROWS * PX); 1630 } 1631 } 1632 function begin() { 1633 snakeRef.current = INIT_SNAKE; 1634 prevSnakeRef.current = INIT_SNAKE; 1635 foodRef.current = spawnFood(INIT_SNAKE); 1636 dirRef.current = { 1637 dx: 1, 1638 dy: 0 1639 }; 1640 lastAppliedDirRef.current = { 1641 dx: 1, 1642 dy: 0 1643 }; 1644 dirQueueRef.current = []; 1645 lastTickAtRef.current = performance.now(); 1646 tickIntervalRef.current = TICK_BASE; 1647 scoreRef.current = 0; 1648 runningRef.current = true; 1649 setScore(0); 1650 setLength(INIT_SNAKE.length); 1651 setOver(false); 1652 setRunning(true); 1653 } 1654 1655 // ###### TICK LOOP ###### 1656 // Logical step only: snapshot prev positions, compute next snake, update interval. 1657 // Drawing happens in the RAF loop below, so a tick never touches the canvas directly. 1658 useEffect(() => { 1659 if (!running) return; 1660 let timer; 1661 let cancelled = false; 1662 function tick() { 1663 if (cancelled || !runningRef.current) return; 1664 // Cancel any scheduled tick â we might be called early from the key handler. 1665 clearTimeout(timer); 1666 1667 // Consume one queued direction. Validated at enqueue, so always safe. 1668 if (dirQueueRef.current.length > 0) { 1669 dirRef.current = dirQueueRef.current.shift(); 1670 } 1671 const d = dirRef.current; 1672 const snake = snakeRef.current; 1673 const head = snake[0]; 1674 // Wall wrap â modular arithmetic, no death at the edge. 1675 const newHead = { 1676 x: (head.x + d.dx + COLS) % COLS, 1677 y: (head.y + d.dy + ROWS) % ROWS 1678 }; 1679 const food = foodRef.current; 1680 const ate = newHead.x === food.x && newHead.y === food.y; 1681 // Self collision: skip tail tip if not eating (tail vacates the cell this tick). 1682 const checkAgainst = ate ? snake : snake.slice(0, -1); 1683 for (let i = 0; i < checkAgainst.length; i++) { 1684 if (checkAgainst[i].x === newHead.x && checkAgainst[i].y === newHead.y) { 1685 runningRef.current = false; 1686 setRunning(false); 1687 setOver(true); 1688 return; 1689 } 1690 } 1691 lastAppliedDirRef.current = d; 1692 1693 // Snapshot pre-tick positions so the RAF lerp has a "from" anchor. 1694 prevSnakeRef.current = snake; 1695 if (ate) { 1696 snakeRef.current = [newHead, ...snake]; 1697 scoreRef.current += 1; 1698 foodRef.current = spawnFood(snakeRef.current); 1699 setScore(scoreRef.current); 1700 setLength(snakeRef.current.length); 1701 } else { 1702 snakeRef.current = [newHead, ...snake.slice(0, -1)]; 1703 } 1704 lastTickAtRef.current = performance.now(); 1705 tickIntervalRef.current = Math.max(TICK_MIN, TICK_BASE - scoreRef.current * 3); 1706 timer = setTimeout(tick, tickIntervalRef.current); 1707 } 1708 tickFnRef.current = tick; 1709 timer = setTimeout(tick, TICK_BASE); 1710 return () => { 1711 cancelled = true; 1712 clearTimeout(timer); 1713 tickFnRef.current = null; 1714 }; 1715 }, [running]); 1716 1717 // ###### RAF RENDER LOOP ###### 1718 // Runs from mount until unmount. Drawing every frame is cheap (a few dozen fillRects) 1719 // and keeps motion buttery at the display refresh rate while ticks fire at 9â17Hz. 1720 useEffect(() => { 1721 let raf; 1722 function frame() { 1723 draw(); 1724 raf = requestAnimationFrame(frame); 1725 } 1726 raf = requestAnimationFrame(frame); 1727 return () => cancelAnimationFrame(raf); 1728 }, []); 1729 1730 // ###### KEYBOARD ###### 1731 useEffect(() => { 1732 function onKey(e) { 1733 if (e.key === "Escape") { 1734 onExit(); 1735 return; 1736 } 1737 if (!runningRef.current) { 1738 if (e.key === " " || e.key === "Enter") { 1739 begin(); 1740 e.preventDefault(); 1741 } 1742 return; 1743 } 1744 let next = null;
1745 const k = e.key.toLowerCase(); 1746 if (e.key === "ArrowUp" || k === "w") next = { 1747 dx: 0, 1748 dy: -1 1749 };else if (e.key === "ArrowDown" || k === "s") next = { 1750 dx: 0, 1751 dy: 1 1752 };else if (e.key === "ArrowLeft" || k === "a") next = { 1753 dx: -1, 1754 dy: 0 1755 };else if (e.key === "ArrowRight" || k === "d") next = { 1756 dx: 1, 1757 dy: 0 1758 }; 1759 if (!next) return; 1760 e.preventDefault(); 1761 1762 // Validate against the last direction in the pipeline (queue tail, or 1763 // the snake's current heading if the queue is empty). This way a queued 1764 // turn can't be cancelled by an illegal 180° that came right after it. 1765 const q = dirQueueRef.current; 1766 const ref = q.length ? q[q.length - 1] : lastAppliedDirRef.current; 1767 if (next.dx === -ref.dx && next.dy === -ref.dy) return; // 180° reversal 1768 if (next.dx === ref.dx && next.dy === ref.dy) return; // duplicate 1769 1770 q.push(next); 1771 if (q.length > 2) q.shift(); 1772 1773 // Fire a tick immediately if enough time has passed â this is what makes 1774 // turns feel instant. The MIN_INPUT_GAP cap stops input spam from 1775 // accelerating the snake beyond ~22 cells/sec. 1776 const now = performance.now(); 1777 if (now - lastTickAtRef.current >= MIN_INPUT_GAP && tickFnRef.current) { 1778 tickFnRef.current(); 1779 } 1780 } 1781 window.addEventListener("keydown", onKey); 1782 return () => window.removeEventListener("keydown", onKey); 1783 }, [onExit]); 1784 return /*#__PURE__*/React.createElement("div", { 1785 className: "mini-game snake" 1786 }, /*#__PURE__*/React.createElement("div", { 1787 className: "mg-hud" 1788 }, /*#__PURE__*/React.createElement("span", null, "SCORE: ", score), /*#__PURE__*/React.createElement("span", null, "LEN: ", length), /*#__PURE__*/React.createElement("span", { 1789 style: { 1790 cursor: "pointer" 1791 }, 1792 onClick: onExit 1793 }, "[esc] exit")), /*#__PURE__*/React.createElement("div", { 1794 className: "snake-stage" 1795 }, /*#__PURE__*/React.createElement("canvas", { 1796 ref: canvasRef, 1797 className: "snake-canvas", 1798 width: COLS * PX, 1799 height: ROWS * PX 1800 })), !running && /*#__PURE__*/React.createElement("div", { 1801 className: "mg-overlay" 1802 }, over ? /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("h4", null, "GAME OVER"), /*#__PURE__*/React.createElement("p", null, `Score: ${score}. Length: ${length}.`), /*#__PURE__*/React.createElement("button", { 1803 className: "mg-btn", 1804 onClick: begin 1805 }, "play again"), /*#__PURE__*/React.createElement("button", { 1806 className: "mg-btn", 1807 onClick: onExit, 1808 style: { 1809 marginTop: 6 1810 } 1811 }, "back to terminal")) : /*#__PURE__*/React.createElement(React.Fragment, null, /*#__PURE__*/React.createElement("h4", null, "SNAKE"), /*#__PURE__*/React.createElement("p", null, "Arrows or WASD to steer. Walls wrap. Eat the fruit. Don't bite yourself."), /*#__PURE__*/React.createElement("button", { 1812 className: "mg-btn", 1813 onClick: begin 1814 }, "start")))); 1815 } 1816 1817 // âââââââââââ Hook: keystroke listener for "cg" âââââââââââ 1818 function useSecretShortcut(sequence, onMatch) { 1819 useEffect(() => { 1820 let buf = ""; 1821 let resetTimer; 1822 function onKey(e) { 1823 // Ignore when typing inside inputs/textareas 1824 const t = e.target; 1825 if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; 1826 if (!/^[a-zA-Z0-9]$/.test(e.key)) return; 1827 buf = (buf + e.key.toLowerCase()).slice(-sequence.length); 1828 clearTimeout(resetTimer); 1829 resetTimer = setTimeout(() => { 1830 buf = ""; 1831 }, 1500); 1832 if (buf === sequence) { 1833 buf = ""; 1834 // Stop the matching keydown from also producing an input/keypress event, 1835 // which would otherwise land in the terminal input that's about to mount. 1836 e.preventDefault(); 1837 onMatch(); 1838 } 1839 } 1840 window.addEventListener("keydown", onKey); 1841 return () => { 1842 window.removeEventListener("keydown", onKey); 1843 clearTimeout(resetTimer); 1844 }; 1845 }, [sequence, onMatch]); 1846 } 1847 1848 // ###### HOOK: KONAMI CODE LISTENER ###### 1849 // Sequence: â â â â â â â â B A. Matches Event.key values exactly. 1850 const KONAMI_SEQUENCE = ["ArrowUp", "ArrowUp", "ArrowDown", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowLeft", "ArrowRight", "b", "a"]; 1851 function useKonamiCode(onMatch) { 1852 useEffect(() => { 1853 let idx = 0; 1854 function onKey(e) { 1855 // Ignore while typing into form fields. 1856 const t = e.target; 1857 if (t && (t.tagName === "INPUT" || t.tagName === "TEXTAREA" || t.isContentEditable)) return; 1858 const expected = KONAMI_SEQUENCE[idx];
1859 const key = e.key.length === 1 ? e.key.toLowerCase() : e.key; 1860 if (key === expected) { 1861 idx++; 1862 if (idx === KONAMI_SEQUENCE.length) { 1863 idx = 0; 1864 // Same focus-race fix as useSecretShortcut: stop the final 'a' from 1865 // being typed into the terminal input that's about to mount. 1866 e.preventDefault(); 1867 onMatch(); 1868 } 1869 } else { 1870 // Allow restart-from-start if the wrong key happened to be the first key of the sequence. 1871 idx = key === KONAMI_SEQUENCE[0] ? 1 : 0; 1872 } 1873 } 1874 window.addEventListener("keydown", onKey); 1875 return () => window.removeEventListener("keydown", onKey); 1876 }, [onMatch]); 1877 } 1878 Object.assign(window, { 1879 LeafParticles, 1880 PixelCharacter, 1881 IdleGarden, 1882 Terminal, 1883 useSecretShortcut, 1884 useKonamiCode 1885 }); 1886})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.