// space.jsx — Trek-style viewscreen backdrop for PLUR demo video. // // Design goal: the entire stage IS the bridge viewscreen. Space is always // visible; terminals and callouts float as HUD panels over it. Warps are // native star behavior (existing stars elongate and punch forward) rather // than an overlaid effect. // // Components: // — deep-space backdrop: gradient, nebulae, stars, warp // — publishes a warp-active window so stretches // its stars. Renders no visuals of its own — just state. // — optional slow-rotating planet for accent // // All components read Stage time via useTimeline(). // ─── Deterministic RNG ───────────────────────────────────────────────────── function mulberry32(seed) { let a = seed >>> 0; return function() { a = (a + 0x6D2B79F5) >>> 0; let t = a; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // ─── Shared warp state ───────────────────────────────────────────────────── // WarpTransition components register their windows here; Viewscreen reads them. const __warpWindows = []; function registerWarp(w) { __warpWindows.push(w); return () => { const i = __warpWindows.indexOf(w); if (i >= 0) __warpWindows.splice(i, 1); }; } // Returns { power: 0..1, direction: 'forward' } for a given time. function warpAt(time) { let power = 0; for (const w of __warpWindows) { if (time < w.start || time > w.end) continue; const t = (time - w.start) / (w.end - w.start); // Exponential spool-up → peak → smooth drop. // Feels like actually jumping to warp: slow lean, then SNAP, then decelerate. let p; if (t < 0.45) { // Exponential acceleration — eased quint for that "leaning forward" feel const a = t / 0.45; p = a * a * a; // cubic → exponential feel } else if (t > 0.7) { // Smooth decel after peak const a = (t - 0.7) / 0.3; p = 1 - a * a; // quadratic ease-out } else { p = 1; } power = Math.max(power, p * (w.intensity || 1)); } return power; } // ─── Viewscreen — the ALWAYS-ON space backdrop ───────────────────────────── function Viewscreen({ density = 1.0, opacity = 1.0, seed = 42 }) { const { time } = useTimeline(); const stars = React.useMemo(() => { const rng = mulberry32(seed); const layers = [ // Far: tiny, many, slow { count: Math.floor(180 * density), size: [0.7, 1.3], speed: 0.4, opacity: [0.3, 0.55] }, // Mid: medium, some twinkle { count: Math.floor(90 * density), size: [1.3, 2.2], speed: 1.1, opacity: [0.55, 0.85] }, // Near: bright, fast (these streak most dramatically in warp) { count: Math.floor(40 * density), size: [2.2, 3.6], speed: 2.6, opacity: [0.8, 1.0] }, ]; const out = []; layers.forEach((L, li) => { for (let i = 0; i < L.count; i++) { // Distribute stars around a center-out polar field so warp feels radial const angle = rng() * Math.PI * 2; const radius = rng() * 1300; // distance from center at t=0 out.push({ layer: li, angle, baseRadius: radius, s: L.size[0] + rng() * (L.size[1] - L.size[0]), o: L.opacity[0] + rng() * (L.opacity[1] - L.opacity[0]), speed: L.speed, twinklePhase: rng() * Math.PI * 2, twinkleSpeed: 0.5 + rng() * 1.5, tint: rng() < 0.08 ? (rng() < 0.5 ? '#E5C07B' : '#C678DD') : rng() < 0.15 ? '#9ECBFF' : '#ffffff', }); } }); return out; }, [density, seed]); const nebulae = React.useMemo(() => { const rng = mulberry32(seed + 1); return [ { x: 320, y: 260, r: 540, color: '#4a1d5c', opacity: 0.28 }, // magenta cloud upper-left { x: 1500, y: 800, r: 620, color: '#1d3a5c', opacity: 0.32 }, // cyan cloud lower-right { x: 1700, y: 220, r: 380, color: '#5c3a1d', opacity: 0.22 }, // gold cloud upper-right { x: 180, y: 900, r: 440, color: '#2d1d4a', opacity: 0.20 }, // purple cloud lower-left ]; }, [seed]); const warp = warpAt(time); return (
{/* Nebula clouds — soft blurred blobs, drift VERY slowly */} {nebulae.map((n, i) => { const drift = Math.sin(time * 0.03 + i) * 30; return (
); })} {/* Stars — rendered as polar positions. Warp = radial outward + streak. */}
{stars.map((star, i) => { // Slow outward drift at rest (lets stars feel alive without warp) const driftRadius = star.baseRadius + time * star.speed * 2; // In warp: push radius WAY outward, exponentially stronger at higher warp // Makes distant stars fly past the camera. const warpPush = warp * warp * star.speed * 1400; const r = driftRadius + warpPush; // Wrap stars that drift off-screen (keep the field full) const effectiveR = r % 1400; const x = Math.cos(star.angle) * effectiveR; const y = Math.sin(star.angle) * effectiveR; // Distance-based brightness — stars are dim far from center, bright when // they "reach" the viewer. Normalized to 0..1 across the radius range. const distNorm = Math.min(1, effectiveR / 900); const proximityBoost = warp > 0.1 ? 0.3 + distNorm * 1.5 // in warp: dim at center, bright near edge : 1; // Twinkle (only at rest — during warp, stars are bright lines) const tw = warp > 0.1 ? 1 : (0.55 + 0.45 * Math.sin(time * star.twinkleSpeed + star.twinklePhase)); // Length — during warp, stars become lines, length scales with warp² // and star speed, so near/fast stars streak the most. const length = warp > 0.05 ? star.s + warp * warp * (80 + star.speed * 140) * (0.3 + distNorm) : star.s; const thickness = warp > 0.05 ? Math.max(star.s * (0.5 + distNorm * 0.6), 0.6) : star.s; const alpha = Math.min(1, star.o * tw * opacity * proximityBoost); return (
0.05 ? `linear-gradient(to right, transparent 0%, ${star.tint} 100%)` : star.tint, opacity: alpha, borderRadius: warp > 0.05 ? 1 : '50%', boxShadow: star.s > 2 && warp < 0.1 ? `0 0 ${star.s * 2}px ${star.tint}` : 'none', transform: `translate(${x - length/2}px, ${y - thickness/2}px) rotate(${star.angle}rad)`, transformOrigin: `${length/2}px ${thickness/2}px`, }}/> ); })}
{/* Warp overlay — darker vignette (no cyan flash, center stays dark) */} {warp > 0.05 && (
)}
); } // ─── WarpTransition — just registers a window ────────────────────────────── // Renders nothing visual. Its presence in the tree tells the Viewscreen to // stretch stars during [start..end]. function WarpTransition({ start, end, intensity = 1.0 }) { const window = React.useMemo(() => ({ start, end, intensity }), [start, end, intensity]); React.useEffect(() => registerWarp(window), [window]); return null; } // ─── Gas Giant (optional accent) ─────────────────────────────────────────── function GasGiant({ x = 1500, y = 200, radius = 220, intensity = 0.6 }) { const { time } = useTimeline(); const rotation = time * 2.5; return (
); } Object.assign(window, { Viewscreen, WarpTransition, GasGiant, });