From 83c5e7cb943b353cdd8763dad21121e4ed434d49 Mon Sep 17 00:00:00 2001 From: Jarl Lyng Date: Wed, 6 May 2026 13:23:29 +0200 Subject: [PATCH] Milestone toasts at distance thresholds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #11. When the rocket crosses one of the curated AU thresholds, a celebratory toast slides into view above the rocket for ~3 seconds: 100 AU → Past Pluto 1,000 AU → Through the heliosphere 10,000 AU → Into the Oort cloud 100,000 AU → One light-year out - src/milestones.js: thresholds + edge detection. fired Set guarantees one toast per threshold per session — reload to see them again. - index.html: new #milestone-toast element with two spans (distance + label). aria-live="polite" so a screen reader announces it without interrupting. - styles/main.css: prominent treatment — primary-color border + 32px glow, larger type than the regular HUD chrome, but still pointer-events: none and centered above the rocket so it never blocks gameplay. 480px media query trims sizes for phones. - main.js: flashMilestone() helper following the same pattern as flashNearMiss (force-reflow + class toggle for transition, hide-then- remove timers). Wired into the frame loop after distanceAU is updated. Each milestone fires a `milestone-reached` Umami event with {au} so we can see how far players actually get. - README: milestones.js added to the module table; milestone-reached added to the analytics table. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 ++ index.html | 5 +++++ src/main.js | 24 ++++++++++++++++++++++++ src/milestones.js | 31 +++++++++++++++++++++++++++++++ styles/main.css | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 108 insertions(+) create mode 100644 src/milestones.js diff --git a/README.md b/README.md index 3a57190..d281fb9 100644 --- a/README.md +++ b/README.md @@ -197,6 +197,7 @@ Two important conventions: | `motion.js` | `prefers-reduced-motion` matchMedia gate, live-updates | `prefersReducedMotion` | | `analytics.js` | Thin wrapper over Umami, with once-per-session helper | `trackEvent`, `trackOnce` | | `stats.js` | Collective-distance API client (Turso-backed via /api/distance) | `fetchTotalDistance`, `reportSessionDistance` | +| `milestones.js` | Distance threshold table + once-per-session edge detection | `MILESTONES`, `checkMilestone` | --- @@ -323,6 +324,7 @@ Tracked events: | `max-speed` | First time speed reaches MAX_SPEED, once per session | `{ distance }` | | `first-near-miss` | First NEAR MISS trigger, once per session | `{ distance }` | | `music-mute` / `music-unmute` | Each click on the ♪ button | – | +| `milestone-reached` | Each time the rocket crosses a new distance threshold | `{ au }` | Distances are integer AU. Nothing user-identifying is sent. Remove the Umami script tag (or fork without it) if you'd rather not be counted. diff --git a/index.html b/index.html index abf5001..a4779e2 100644 --- a/index.html +++ b/index.html @@ -68,6 +68,11 @@

LITTLE ROCKET

+ + diff --git a/src/main.js b/src/main.js index d970b01..1ab253b 100644 --- a/src/main.js +++ b/src/main.js @@ -31,6 +31,7 @@ import { initMusic, startMusic, toggleMusic, isMuted, hasMusic } from './music.j import { startStory } from './story.js'; import { trackOnce, trackEvent } from './analytics.js'; import { fetchTotalDistance, reportSessionDistance } from './stats.js'; +import { checkMilestone } from './milestones.js'; const ROT_SPEED = 0.02; // radians per 60fps-frame const ACCEL = 0.05; // speed delta per 60fps-frame @@ -56,6 +57,8 @@ fetchTotalDistance().then((stats) => { const HINT_VISIBLE_MS = 6000; const HINT_FADE_MS = 400; // matches --ij-duration-slow in CSS const NEAR_MISS_VISIBLE_MS = 700; +const MILESTONE_VISIBLE_MS = 3200; +const MILESTONE_FADE_MS = 600; // matches CSS transition const INTRO_DURATION = 1.6; // seconds of cinematic intro before player takes control const INTRO_FOV_START = 50; // narrow FOV → opens up to FOV_IDLE const INTRO_CAM_DISTANCE = 60; // how far back the camera starts behind the rocket @@ -168,6 +171,26 @@ function run() { }, NEAR_MISS_VISIBLE_MS); trackOnce('first-near-miss', { distance: Math.floor(distanceAU) }); } + + const milestoneEl = document.getElementById('milestone-toast'); + const milestoneDistanceEl = document.getElementById('milestone-distance'); + const milestoneLabelEl = document.getElementById('milestone-label'); + let milestoneHideTimer = null; + let milestoneRemoveTimer = null; + function flashMilestone(milestone) { + if (milestoneHideTimer) clearTimeout(milestoneHideTimer); + if (milestoneRemoveTimer) clearTimeout(milestoneRemoveTimer); + milestoneDistanceEl.textContent = `${milestone.au.toLocaleString()} AU`; + milestoneLabelEl.textContent = milestone.label; + milestoneEl.hidden = false; + void milestoneEl.offsetWidth; + milestoneEl.classList.add('visible'); + milestoneHideTimer = setTimeout(() => { + milestoneEl.classList.remove('visible'); + milestoneRemoveTimer = setTimeout(() => { milestoneEl.hidden = true; }, MILESTONE_FADE_MS); + }, MILESTONE_VISIBLE_MS); + trackEvent('milestone-reached', { au: milestone.au }); + } const clock = new THREE.Clock(); const forward = new THREE.Vector3(); const camOffset = new THREE.Vector3(); @@ -255,6 +278,7 @@ function run() { speedEl.textContent = speed.toFixed(1); distanceAU += speed * realDt; distanceEl.textContent = `${Math.floor(distanceAU)} AU`; + checkMilestone(distanceAU, flashMilestone); if (isTouch && introDone) { throttleFill.style.height = `${(speed / MAX_SPEED) * 100}%`; } diff --git a/src/milestones.js b/src/milestones.js new file mode 100644 index 0000000..41aba26 --- /dev/null +++ b/src/milestones.js @@ -0,0 +1,31 @@ +/** + * Distance milestones — fire celebratory HUD toasts at round-number AU + * thresholds. Each milestone fires at most once per session; reload to see + * them again. + * + * Tuned to feel like genuine landmarks rather than every other minute: + * the first one rewards a few minutes of flying, the rest space out + * exponentially so a long flight always has a next thing to chase. + */ + +export const MILESTONES = [ + { au: 100, label: 'Past Pluto' }, + { au: 1000, label: 'Through the heliosphere' }, + { au: 10000, label: 'Into the Oort cloud' }, + { au: 100000, label: 'One light-year out' }, +]; + +const fired = new Set(); + +/** + * Call once per frame. If the rocket has just crossed any unfired milestone + * threshold, invokes onTrigger(milestone) — once per threshold per session. + */ +export function checkMilestone(distanceAU, onTrigger) { + for (const m of MILESTONES) { + if (distanceAU >= m.au && !fired.has(m.au)) { + fired.add(m.au); + onTrigger(m); + } + } +} diff --git a/styles/main.css b/styles/main.css index 7224af5..ff6df25 100644 --- a/styles/main.css +++ b/styles/main.css @@ -274,6 +274,52 @@ h1 { #near-miss[hidden] { display: none; } #near-miss.visible { opacity: 1; } +/* Milestone toast — fires once per session when the rocket crosses a round + AU threshold. Bigger and more present than the near-miss flash, but still + non-blocking and pointer-event-free. */ +#milestone-toast { + position: fixed; + top: 28%; + left: 50%; + transform: translateX(-50%); + padding: var(--ij-spacing-lg, 16px) var(--ij-spacing-xxxl, 32px); + background: rgba(0, 0, 0, 0.78); + border: 2px solid var(--ij-color-primary, #D0FF00); + border-radius: var(--ij-radius-md, 12px); + box-shadow: 0 0 32px rgba(208, 255, 0, 0.28); + text-align: center; + pointer-events: none; + opacity: 0; + transition: opacity 600ms var(--ij-easing-emphasized, + cubic-bezier(0.2, 0, 0, 1)); +} +#milestone-toast[hidden] { display: none; } +#milestone-toast.visible { opacity: 1; } + +.milestone-distance { + display: block; + font-size: var(--ij-font-size-xl, 24px); + font-weight: var(--ij-font-weight-bold, 700); + color: var(--ij-color-primary, #D0FF00); + letter-spacing: 2px; + font-variant-numeric: tabular-nums; +} + +.milestone-label { + display: block; + margin-top: var(--ij-spacing-xs, 4px); + font-size: var(--ij-font-size-sm, 14px); + color: var(--ij-color-text-secondary, rgba(255, 255, 255, 0.78)); + letter-spacing: 1.5px; + text-transform: uppercase; +} + +@media (max-width: 480px) { + .milestone-distance { font-size: var(--ij-font-size-lg, 18px); } + .milestone-label { font-size: var(--ij-font-size-xs, 12px); } + #milestone-toast { padding: var(--ij-spacing-md, 12px) var(--ij-spacing-xl, 20px); } +} + /* Throttle bar: vertical fill on the right edge that mirrors current speed. Visible only on touch devices (no benefit when keyboard handles throttle). */ #throttle-bar {