easter-egg-quest 1.0.15 → 1.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -462,6 +462,12 @@ class InputTracker {
462
462
  this._phases = [];
463
463
  this._currentPhaseType = "still";
464
464
  this._currentPhaseStart = Date.now();
465
+ this._snapshot.isMoving = false;
466
+ this._snapshot.velocity = 0;
467
+ if (this._stillTimer) {
468
+ clearTimeout(this._stillTimer);
469
+ this._stillTimer = null;
470
+ }
465
471
  }
466
472
  resetCounts() {
467
473
  this._snapshot.totalClicks = 0;
@@ -514,7 +520,25 @@ function isElementVisible(el) {
514
520
  if (rect.width === 0 || rect.height === 0) return false;
515
521
  const style = getComputedStyle(el);
516
522
  if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") return false;
517
- return rect.top < window.innerHeight && rect.bottom > 0 && rect.left < window.innerWidth && rect.right > 0;
523
+ const inViewport = rect.top < window.innerHeight && rect.bottom > 0 && rect.left < window.innerWidth && rect.right > 0;
524
+ if (!inViewport) return false;
525
+ let parent = el.parentElement;
526
+ while (parent && parent !== document.body) {
527
+ const parentStyle = getComputedStyle(parent);
528
+ const overflowClips = [
529
+ parentStyle.overflow,
530
+ parentStyle.overflowX,
531
+ parentStyle.overflowY
532
+ ].some((value) => ["auto", "scroll", "hidden", "clip"].includes(value));
533
+ if (overflowClips) {
534
+ const parentRect = parent.getBoundingClientRect();
535
+ if (rect.bottom <= parentRect.top || rect.top >= parentRect.bottom || rect.right <= parentRect.left || rect.left >= parentRect.right) {
536
+ return false;
537
+ }
538
+ }
539
+ parent = parent.parentElement;
540
+ }
541
+ return true;
518
542
  }
519
543
  function isDangerousElement(el) {
520
544
  const text = (el.textContent ?? "").toLowerCase().trim();
@@ -522,26 +546,6 @@ function isDangerousElement(el) {
522
546
  const combined = `${text} ${ariaLabel}`;
523
547
  return DANGEROUS_KEYWORDS.some((kw) => combined.includes(kw));
524
548
  }
525
- function findEligibleEntryElements(containerSelector, excludeSelectors = []) {
526
- const container = containerSelector ? document.querySelector(containerSelector) ?? document.body : document.body;
527
- const candidates = container.querySelectorAll(
528
- 'a, button, [role="button"], nav a, nav button, .cta, [data-easter-entry]'
529
- );
530
- const excludeSet = /* @__PURE__ */ new Set();
531
- for (const sel of excludeSelectors) {
532
- document.querySelectorAll(sel).forEach((el) => excludeSet.add(el));
533
- }
534
- const eligible = [];
535
- candidates.forEach((el) => {
536
- if (excludeSet.has(el)) return;
537
- if (!isElementVisible(el)) return;
538
- if (isDangerousElement(el)) return;
539
- const rect = el.getBoundingClientRect();
540
- if (rect.width < 30 || rect.height < 20) return;
541
- eligible.push(el);
542
- });
543
- return eligible;
544
- }
545
549
  function pickRandom(arr) {
546
550
  if (arr.length === 0) return void 0;
547
551
  return arr[Math.floor(Math.random() * arr.length)];
@@ -567,6 +571,15 @@ class HiddenEntry {
567
571
  this._hintVisibleStart = 0;
568
572
  this._hintCheckInterval = null;
569
573
  this._hintVisibilityHandler = null;
574
+ this._domObserver = null;
575
+ this._intersectionObserver = null;
576
+ this._mutationDebounce = null;
577
+ this._navigationHandler = null;
578
+ this._bootstrapObserver = null;
579
+ this._retryTimer = null;
580
+ this._fallbackTimer = null;
581
+ this._retryCount = 0;
582
+ this._historyPatched = false;
570
583
  this.config = config;
571
584
  this.script = script;
572
585
  this.onFound = onFound;
@@ -576,16 +589,10 @@ class HiddenEntry {
576
589
  this._createFallbackEntry();
577
590
  return;
578
591
  }
579
- const eligible = findEligibleEntryElements(
580
- this.config.hiddenEntry.selector,
581
- this.config.hiddenEntry.excludeSelectors
582
- );
583
- if (eligible.length === 0) {
584
- this._createFallbackEntry();
585
- return;
586
- }
587
- this._injectIntoExisting(eligible);
588
592
  this._startHintEscalation();
593
+ this._startBootstrapWatch();
594
+ this._scheduleFallback();
595
+ this._attemptInjection(0);
589
596
  }
590
597
  cleanup() {
591
598
  var _a2, _b2, _c, _d;
@@ -600,6 +607,8 @@ class HiddenEntry {
600
607
  this.targetElement.style.removeProperty("animation");
601
608
  this.targetElement.classList.remove("eeq-entry-target");
602
609
  }
610
+ this._stopWatchdog();
611
+ this._stopBootstrapWatch();
603
612
  (_a2 = this._injectedElement) == null ? void 0 : _a2.remove();
604
613
  this._injectedElement = null;
605
614
  (_b2 = this.hintContainer) == null ? void 0 : _b2.remove();
@@ -612,17 +621,11 @@ class HiddenEntry {
612
621
  this._attachToElement(this.targetElement);
613
622
  }
614
623
  // ─── Inject trigger text inside an existing element ─────────────────
615
- _injectIntoExisting(_eligible) {
616
- const candidates = Array.from(
617
- document.querySelectorAll(
618
- "p, li, figcaption, blockquote, h4, h3, h2, h1, header a, nav a, nav button"
619
- )
620
- ).filter((el) => {
621
- var _a2;
622
- const text = ((_a2 = el.textContent) == null ? void 0 : _a2.trim()) ?? "";
623
- const rect = el.getBoundingClientRect();
624
- return text.length >= 2 && rect.width > 0 && rect.height > 0 && !el.closest("[data-eeq]");
625
- });
624
+ _injectIntoExisting() {
625
+ var _a2;
626
+ const root = this._getSearchRoot();
627
+ const candidates = this._findTextCandidates(root);
628
+ if (candidates.length === 0) return false;
626
629
  const sorted = candidates.sort((a, b) => {
627
630
  const aNav = a.closest("nav") !== null || a.closest("header") !== null ? 1 : 0;
628
631
  const bNav = b.closest("nav") !== null || b.closest("header") !== null ? 1 : 0;
@@ -633,8 +636,8 @@ class HiddenEntry {
633
636
  });
634
637
  const prints = sorted.map(
635
638
  (el) => {
636
- var _a2;
637
- return `${el.tagName}:${(((_a2 = el.textContent) == null ? void 0 : _a2.trim()) ?? "").slice(0, 30)}`;
639
+ var _a3;
640
+ return `${el.tagName}:${(((_a3 = el.textContent) == null ? void 0 : _a3.trim()) ?? "").slice(0, 30)}`;
638
641
  }
639
642
  );
640
643
  const HISTORY_KEY = "eeq_trigger_history";
@@ -666,8 +669,8 @@ class HiddenEntry {
666
669
  ];
667
670
  const rotatedPrints = rotated.map(
668
671
  (el) => {
669
- var _a2;
670
- return `${el.tagName}:${(((_a2 = el.textContent) == null ? void 0 : _a2.trim()) ?? "").slice(0, 30)}`;
672
+ var _a3;
673
+ return `${el.tagName}:${(((_a3 = el.textContent) == null ? void 0 : _a3.trim()) ?? "").slice(0, 30)}`;
671
674
  }
672
675
  );
673
676
  const unused = rotated.filter((_, i) => !usedSet.has(rotatedPrints[i]));
@@ -681,6 +684,7 @@ class HiddenEntry {
681
684
  const isInline = host.tagName === "A" || host.tagName === "BUTTON" || host.tagName === "LI" || host.closest("nav") !== null;
682
685
  const span = document.createElement("span");
683
686
  span.textContent = isInline ? " · start hunt" : " start hunt";
687
+ span.setAttribute("data-eeq", "trigger");
684
688
  span.style.cssText = `
685
689
  font: inherit;
686
690
  color: inherit;
@@ -708,9 +712,264 @@ class HiddenEntry {
708
712
  this._injectedElement = span;
709
713
  this.targetElement = span;
710
714
  this._attachToElement(span);
711
- return;
715
+ this._retryCount = 0;
716
+ this._clearRetryTimer();
717
+ this._clearFallbackTimer();
718
+ (_a2 = this.fallbackButton) == null ? void 0 : _a2.remove();
719
+ this.fallbackButton = null;
720
+ this._stopBootstrapWatch();
721
+ this._startWatchdog();
722
+ return true;
723
+ }
724
+ return false;
725
+ }
726
+ _attemptInjection(delayMs) {
727
+ this._clearRetryTimer();
728
+ this._retryTimer = setTimeout(() => {
729
+ if (this._destroyed || this._injectedElement) return;
730
+ const ok = this._injectIntoExisting();
731
+ if (!ok) this._scheduleNextRetry();
732
+ }, delayMs);
733
+ }
734
+ _scheduleNextRetry() {
735
+ if (this._destroyed || this._injectedElement) return;
736
+ const delays = [300, 900, 1800, 3200, 5e3, 8e3];
737
+ const nextDelay = delays[Math.min(this._retryCount, delays.length - 1)];
738
+ this._retryCount += 1;
739
+ this._attemptInjection(nextDelay);
740
+ }
741
+ _scheduleFallback() {
742
+ if (this.fallbackButton || this._fallbackTimer) return;
743
+ this._fallbackTimer = setTimeout(() => {
744
+ if (this._destroyed || this._injectedElement || this.fallbackButton) return;
745
+ this._createFallbackEntry();
746
+ }, 1e4);
747
+ }
748
+ _clearRetryTimer() {
749
+ if (this._retryTimer) {
750
+ clearTimeout(this._retryTimer);
751
+ this._retryTimer = null;
752
+ }
753
+ }
754
+ _clearFallbackTimer() {
755
+ if (this._fallbackTimer) {
756
+ clearTimeout(this._fallbackTimer);
757
+ this._fallbackTimer = null;
758
+ }
759
+ }
760
+ _getSearchRoot() {
761
+ if (!this.config.hiddenEntry.selector) return document.body;
762
+ return document.querySelector(this.config.hiddenEntry.selector) ?? document.body;
763
+ }
764
+ _findTextCandidates(root) {
765
+ var _a2;
766
+ const selector = [
767
+ "p",
768
+ "li",
769
+ "figcaption",
770
+ "blockquote",
771
+ "caption",
772
+ "label",
773
+ "td",
774
+ "th",
775
+ "h1",
776
+ "h2",
777
+ "h3",
778
+ "h4",
779
+ "h5",
780
+ "h6",
781
+ "a",
782
+ "button",
783
+ '[role="button"]',
784
+ '[role="link"]',
785
+ "div",
786
+ "span",
787
+ "article",
788
+ "section",
789
+ "summary",
790
+ "[data-easter-entry]",
791
+ "[mat-button]",
792
+ "[mat-list-item]",
793
+ ".mat-mdc-button",
794
+ ".mat-mdc-list-item",
795
+ ".ag-cell",
796
+ ".ag-header-cell"
797
+ ].join(", ");
798
+ const excludeSet = /* @__PURE__ */ new Set();
799
+ for (const sel of this.config.hiddenEntry.excludeSelectors) {
800
+ (_a2 = root.querySelectorAll) == null ? void 0 : _a2.call(root, sel).forEach((el) => excludeSet.add(el));
801
+ }
802
+ return Array.from(root.querySelectorAll(selector)).filter((el) => {
803
+ var _a3, _b2;
804
+ if (!(el instanceof HTMLElement)) return false;
805
+ if (excludeSet.has(el)) return false;
806
+ if (el.closest("[data-eeq]")) return false;
807
+ if (el.closest("script, style, noscript, svg, canvas, form")) return false;
808
+ if (isDangerousElement(el)) return false;
809
+ if (!isElementVisible(el)) return false;
810
+ const text = ((_a3 = el.innerText) == null ? void 0 : _a3.trim()) || ((_b2 = el.textContent) == null ? void 0 : _b2.trim()) || "";
811
+ if (text.length < 6 || text.length > 220) return false;
812
+ const childElementCount = el.children.length;
813
+ if (childElementCount > 12) return false;
814
+ const rect = el.getBoundingClientRect();
815
+ if (rect.width < 18 || rect.height < 12) return false;
816
+ return true;
817
+ });
818
+ }
819
+ _startBootstrapWatch() {
820
+ this._stopBootstrapWatch();
821
+ const root = this._getSearchRoot();
822
+ const observerTarget = root instanceof HTMLElement ? root : document.body;
823
+ this._bootstrapObserver = new MutationObserver(() => {
824
+ if (this._destroyed || this._injectedElement) return;
825
+ if (this._mutationDebounce) clearTimeout(this._mutationDebounce);
826
+ this._mutationDebounce = setTimeout(() => {
827
+ if (this._destroyed || this._injectedElement) return;
828
+ this._attemptInjection(150);
829
+ }, 250);
830
+ });
831
+ this._bootstrapObserver.observe(observerTarget, { childList: true, subtree: true });
832
+ this._navigationHandler = () => {
833
+ if (this._destroyed || this._injectedElement) return;
834
+ this._attemptInjection(700);
835
+ };
836
+ window.addEventListener("popstate", this._navigationHandler);
837
+ window.addEventListener("hashchange", this._navigationHandler);
838
+ if (!this._historyPatched && typeof window.history !== "undefined") {
839
+ this._historyPatched = true;
840
+ const patchHistoryMethod = (method) => {
841
+ const original = window.history[method].bind(window.history);
842
+ window.history[method] = (...args) => {
843
+ const result = original(...args);
844
+ window.dispatchEvent(new Event("eeq:navigation"));
845
+ return result;
846
+ };
847
+ };
848
+ patchHistoryMethod("pushState");
849
+ patchHistoryMethod("replaceState");
712
850
  }
713
- this._createFallbackEntry();
851
+ window.addEventListener("eeq:navigation", this._navigationHandler);
852
+ }
853
+ _stopBootstrapWatch() {
854
+ if (this._bootstrapObserver) {
855
+ this._bootstrapObserver.disconnect();
856
+ this._bootstrapObserver = null;
857
+ }
858
+ this._clearRetryTimer();
859
+ this._clearFallbackTimer();
860
+ if (this._mutationDebounce) {
861
+ clearTimeout(this._mutationDebounce);
862
+ this._mutationDebounce = null;
863
+ }
864
+ if (this._navigationHandler) {
865
+ window.removeEventListener("popstate", this._navigationHandler);
866
+ window.removeEventListener("hashchange", this._navigationHandler);
867
+ window.removeEventListener("eeq:navigation", this._navigationHandler);
868
+ this._navigationHandler = null;
869
+ }
870
+ }
871
+ /**
872
+ * Watch for the injected trigger element being removed from the DOM
873
+ * (SPA re-render, route change, virtual scroll) or scrolled out of view.
874
+ *
875
+ * Uses IntersectionObserver (zero layout cost) for visibility,
876
+ * a MutationObserver on a stable ancestor (survives Angular/React re-renders),
877
+ * and navigation event listeners for SPA route changes.
878
+ */
879
+ _startWatchdog() {
880
+ this._stopWatchdog();
881
+ if (!this._injectedElement) return;
882
+ this._intersectionObserver = new IntersectionObserver(
883
+ (entries) => {
884
+ if (this._destroyed || !this._injectedElement) return;
885
+ const entry = entries[0];
886
+ if (entry && !entry.isIntersecting) {
887
+ this._reinject();
888
+ }
889
+ },
890
+ { threshold: 0 }
891
+ );
892
+ this._intersectionObserver.observe(this._injectedElement);
893
+ const observeTarget = this._findStableAncestor(this._injectedElement);
894
+ this._domObserver = new MutationObserver(() => {
895
+ if (this._destroyed || !this._injectedElement) return;
896
+ if (this._mutationDebounce) clearTimeout(this._mutationDebounce);
897
+ this._mutationDebounce = setTimeout(() => {
898
+ if (this._destroyed || !this._injectedElement) return;
899
+ if (!document.body.contains(this._injectedElement)) {
900
+ this._reinject();
901
+ }
902
+ }, 300);
903
+ });
904
+ this._domObserver.observe(observeTarget, { childList: true, subtree: true });
905
+ this._navigationHandler = () => {
906
+ if (this._destroyed || !this._injectedElement) return;
907
+ setTimeout(() => {
908
+ if (this._destroyed || !this._injectedElement) return;
909
+ if (!document.body.contains(this._injectedElement)) {
910
+ this._reinject();
911
+ }
912
+ }, 500);
913
+ };
914
+ window.addEventListener("popstate", this._navigationHandler);
915
+ window.addEventListener("hashchange", this._navigationHandler);
916
+ window.addEventListener("eeq:navigation", this._navigationHandler);
917
+ }
918
+ /**
919
+ * Walk up the DOM to find a stable ancestor that won't be replaced
920
+ * by SPA framework routing (Angular <router-outlet>, React root, etc.).
921
+ */
922
+ _findStableAncestor(el) {
923
+ let node = el.parentElement;
924
+ while (node && node !== document.body) {
925
+ const tag = node.tagName.toLowerCase();
926
+ if (tag === "main" || tag === "body" || tag.includes("app-") || // Angular app-root, app-component, etc.
927
+ tag.includes("-root") || // custom element roots
928
+ node.querySelector("router-outlet") !== null || node.id === "app" || node.id === "root" || node.id === "__next") {
929
+ return node;
930
+ }
931
+ node = node.parentElement;
932
+ }
933
+ return document.body;
934
+ }
935
+ _stopWatchdog() {
936
+ if (this._domObserver) {
937
+ this._domObserver.disconnect();
938
+ this._domObserver = null;
939
+ }
940
+ if (this._intersectionObserver) {
941
+ this._intersectionObserver.disconnect();
942
+ this._intersectionObserver = null;
943
+ }
944
+ if (this._mutationDebounce) {
945
+ clearTimeout(this._mutationDebounce);
946
+ this._mutationDebounce = null;
947
+ }
948
+ if (this._navigationHandler) {
949
+ window.removeEventListener("popstate", this._navigationHandler);
950
+ window.removeEventListener("hashchange", this._navigationHandler);
951
+ window.removeEventListener("eeq:navigation", this._navigationHandler);
952
+ this._navigationHandler = null;
953
+ }
954
+ }
955
+ /** Remove old trigger (if still in DOM) and re-inject into a new element. */
956
+ _reinject() {
957
+ this._stopWatchdog();
958
+ if (this.clickHandler && this.targetElement) {
959
+ this.targetElement.removeEventListener("click", this.clickHandler);
960
+ }
961
+ if (this._injectedElement && document.body.contains(this._injectedElement)) {
962
+ this._injectedElement.remove();
963
+ }
964
+ this._injectedElement = null;
965
+ this.targetElement = null;
966
+ this._startBootstrapWatch();
967
+ this._scheduleFallback();
968
+ setTimeout(() => {
969
+ if (this._destroyed) return;
970
+ const ok = this._injectIntoExisting();
971
+ if (!ok) this._scheduleNextRetry();
972
+ }, 1200);
714
973
  }
715
974
  /**
716
975
  * Try to insert the trigger span between words inside a text node.
@@ -718,12 +977,22 @@ class HiddenEntry {
718
977
  * Returns true if inserted without layout shift.
719
978
  */
720
979
  _tryInsertBetweenWords(host, span) {
721
- var _a2;
722
980
  const textNodes = [];
723
- for (const node of host.childNodes) {
724
- if (node.nodeType === Node.TEXT_NODE && (((_a2 = node.textContent) == null ? void 0 : _a2.trim().length) ?? 0) > 5) {
725
- textNodes.push(node);
981
+ const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT, {
982
+ acceptNode: (node) => {
983
+ var _a2;
984
+ const text2 = ((_a2 = node.textContent) == null ? void 0 : _a2.trim()) ?? "";
985
+ if (text2.length <= 5) return NodeFilter.FILTER_REJECT;
986
+ const parent = node.parentElement;
987
+ if (!parent) return NodeFilter.FILTER_REJECT;
988
+ if (parent.closest("[data-eeq], script, style, noscript")) return NodeFilter.FILTER_REJECT;
989
+ return NodeFilter.FILTER_ACCEPT;
726
990
  }
991
+ });
992
+ let current = walker.nextNode();
993
+ while (current) {
994
+ textNodes.push(current);
995
+ current = walker.nextNode();
727
996
  }
728
997
  if (!textNodes.length) return false;
729
998
  const slotSeed = Math.floor(Date.now() / (1e3 * 60 * 2));
@@ -740,13 +1009,13 @@ class HiddenEntry {
740
1009
  const splitAt = spacePositions[Math.min(idx, spacePositions.length - 1)];
741
1010
  const before = text.slice(0, splitAt);
742
1011
  const after = text.slice(splitAt);
743
- const heightBefore = host.getBoundingClientRect().height;
1012
+ const rectBefore = host.getBoundingClientRect();
744
1013
  const afterNode = document.createTextNode(after);
745
1014
  textNode.textContent = before;
746
1015
  host.insertBefore(afterNode, textNode.nextSibling);
747
1016
  host.insertBefore(span, afterNode);
748
- const heightAfter = host.getBoundingClientRect().height;
749
- if (Math.abs(heightAfter - heightBefore) > 2) {
1017
+ const rectAfter = host.getBoundingClientRect();
1018
+ if (Math.abs(rectAfter.height - rectBefore.height) > 8 || Math.abs(rectAfter.width - rectBefore.width) > 24) {
750
1019
  host.removeChild(span);
751
1020
  host.removeChild(afterNode);
752
1021
  textNode.textContent = text;
@@ -991,6 +1260,7 @@ class HiddenEntry {
991
1260
  document.head.appendChild(this.shimmerStyle);
992
1261
  }
993
1262
  _createFallbackEntry() {
1263
+ if (this.fallbackButton) return;
994
1264
  this.fallbackButton = document.createElement("button");
995
1265
  this.fallbackButton.textContent = "·";
996
1266
  this.fallbackButton.setAttribute("aria-label", "Hidden game entry");
@@ -1059,7 +1329,7 @@ class NarrativeRenderer {
1059
1329
  width: "100%",
1060
1330
  height: "100%",
1061
1331
  pointerEvents: "none",
1062
- zIndex: "999990"
1332
+ zIndex: "999993"
1063
1333
  });
1064
1334
  document.body.appendChild(this.host);
1065
1335
  this.shadow = this.host.attachShadow({ mode: "closed" });
@@ -1889,6 +2159,13 @@ class ThreeRenderer {
1889
2159
  }
1890
2160
  }
1891
2161
  }
2162
+ /** Mark all ambient particles for rapid fade-out (rhythm lost). */
2163
+ fadeOutAmbientParticles() {
2164
+ for (const p of this._ambientParticles) {
2165
+ p.fadeIn = false;
2166
+ if (p.life > 0.3) p.life = 0.3;
2167
+ }
2168
+ }
1892
2169
  /** Start the finale composition. */
1893
2170
  startFinale() {
1894
2171
  this._finaleActive = true;
@@ -2209,17 +2486,17 @@ class ThreeRenderer {
2209
2486
  this.eggBodies.push(body3);
2210
2487
  }
2211
2488
  // ── Mini egg helpers for particles ─────────────────────────────────────
2212
- /** Create a tiny egg-shaped geometry (same squash as main eggs, lower poly). */
2489
+ /** Create a tiny egg-shaped geometry rounder, less elongated. */
2213
2490
  _createMiniEggGeo(T, radius) {
2214
- const geo = new T.SphereGeometry(radius, 8, 8);
2491
+ const geo = new T.SphereGeometry(radius, 12, 12);
2215
2492
  const pos = geo.attributes.position;
2216
2493
  for (let i = 0; i < pos.count; i++) {
2217
2494
  let y = pos.getY(i);
2218
2495
  const x = pos.getX(i);
2219
2496
  const z = pos.getZ(i);
2220
- const topSquash = y > 0 ? 0.92 : 1;
2221
- y = y * 1.15 * topSquash;
2222
- const narrowing = 1 - Math.abs(y) * 0.06;
2497
+ const topSquash = y > 0 ? 0.95 : 1;
2498
+ y = y * 1.08 * topSquash;
2499
+ const narrowing = 1 - Math.abs(y) * 0.03;
2223
2500
  pos.setX(i, x * narrowing);
2224
2501
  pos.setY(i, y);
2225
2502
  pos.setZ(i, z * narrowing);
@@ -2235,8 +2512,8 @@ class ThreeRenderer {
2235
2512
  c.height = 64;
2236
2513
  const ctx = c.getContext("2d");
2237
2514
  const hue = Math.random() * 360;
2238
- const sat = 45 + Math.random() * 30;
2239
- const light = 65 + Math.random() * 15;
2515
+ const sat = 60 + Math.random() * 25;
2516
+ const light = 68 + Math.random() * 14;
2240
2517
  ctx.fillStyle = `hsl(${hue}, ${sat}%, ${light}%)`;
2241
2518
  ctx.fillRect(0, 0, 64, 64);
2242
2519
  const accentHue = (hue + 80 + Math.random() * 100) % 360;
@@ -2307,7 +2584,7 @@ class ThreeRenderer {
2307
2584
  return new T.MeshBasicMaterial({
2308
2585
  map: tex,
2309
2586
  transparent: true,
2310
- opacity
2587
+ opacity: opacity || 0.85
2311
2588
  });
2312
2589
  }
2313
2590
  _createEggGeometry(T) {
@@ -3037,12 +3314,12 @@ class ThreeRenderer {
3037
3314
  p.mesh.rotation.z += dt * 0.5;
3038
3315
  if (p.fadeIn && p.life > 0.7) {
3039
3316
  p.mesh.material.opacity = Math.min(
3040
- 0.55,
3041
- p.mesh.material.opacity + dt * 0.6
3317
+ 0.9,
3318
+ p.mesh.material.opacity + dt * 1
3042
3319
  );
3043
- if (p.mesh.material.opacity >= 0.5) p.fadeIn = false;
3320
+ if (p.mesh.material.opacity >= 0.85) p.fadeIn = false;
3044
3321
  } else {
3045
- p.mesh.material.opacity = Math.max(0, p.life * 0.55);
3322
+ p.mesh.material.opacity = Math.max(0, p.life * 0.85);
3046
3323
  }
3047
3324
  if (p.life <= 0) {
3048
3325
  this.scene.remove(p.mesh);
@@ -4511,7 +4788,7 @@ class RhythmStage {
4511
4788
  if (isGood) {
4512
4789
  this._goodCycles++;
4513
4790
  } else {
4514
- this._goodCycles = 0;
4791
+ this._goodCycles = Math.max(0, this._goodCycles - 1);
4515
4792
  this._showReaction();
4516
4793
  }
4517
4794
  this._lastPhaseCount += 2;
@@ -4829,11 +5106,13 @@ class GameController {
4829
5106
  (_a3 = this.eggRenderer) == null ? void 0 : _a3.addTrail(data.x, data.y, data.velocity);
4830
5107
  });
4831
5108
  this.bus.on("rhythm:breathe", (data) => {
4832
- var _a3, _b3, _c;
5109
+ var _a3, _b3;
4833
5110
  (_a3 = this.eggRenderer) == null ? void 0 : _a3.setBreathIntensity(data.accuracy);
4834
5111
  (_b3 = this.pageBreather) == null ? void 0 : _b3.update(data.accuracy, data.isMoving);
4835
- if (((_c = this.pageBreather) == null ? void 0 : _c.isInSync()) && data.accuracy > 0 && this.threeRenderer) {
5112
+ if (data.accuracy > 0 && this.threeRenderer) {
4836
5113
  this.threeRenderer.showProgressParticles(0.6 + data.accuracy * 0.4);
5114
+ } else if (this.threeRenderer) {
5115
+ this.threeRenderer.fadeOutAmbientParticles();
4837
5116
  }
4838
5117
  });
4839
5118
  this.overlay = new OverlayManager(this.config);
@@ -5151,6 +5430,7 @@ class GameController {
5151
5430
  }
5152
5431
  const successLines = eggIndex === 0 ? this.script.stage1Success : eggIndex === 1 ? this.script.stage2Success : this.script.stage3Success;
5153
5432
  await ((_c = this.narrative) == null ? void 0 : _c.showSequence(successLines, 4500));
5433
+ await this._wait(3e3);
5154
5434
  (_d = this.narrative) == null ? void 0 : _d.clear();
5155
5435
  await this._wait(800);
5156
5436
  if (this.threeRenderer) {