Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

---

Expand Down Expand Up @@ -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.

Expand Down
5 changes: 5 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ <h1>LITTLE ROCKET</h1>

<div id="near-miss" hidden>NEAR MISS</div>

<div id="milestone-toast" hidden aria-live="polite">
<span id="milestone-distance" class="milestone-distance"></span>
<span id="milestone-label" class="milestone-label"></span>
</div>

<div id="throttle-bar" hidden aria-hidden="true">
<div id="throttle-fill"></div>
</div>
Expand Down
24 changes: 24 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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}%`;
}
Expand Down
31 changes: 31 additions & 0 deletions src/milestones.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
46 changes: 46 additions & 0 deletions styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading