diff --git a/sonar-project.properties b/sonar-project.properties index 9324baa..1bdd28d 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -78,7 +78,14 @@ sonar.test.inclusions=**/*.test.{js,jsx},**/*.spec.{js,jsx} # WebGL/canvas que NO son focusable por teclado; la accesibilidad por # teclado se gestiona via los hotkeys globales (ESC, Tab, flechas...) # que escucha el componente raiz UniverseView en window.keydown. -sonar.issue.ignore.multicriteria=e1,e2,e3,e4,e5,e6,e7,e8 +# javascript:S7748 (zero fraction in number) - en codigo de animacion 3D +# escribir 1.0 / 0.0 explicitamente hace SEMANTICAMENTE claro que es un +# float (escalas, coordenadas, alphas), aunque JS no distinga tipos. +# Mantenemos el estilo para legibilidad. +# javascript:S6774 (missing PropTypes) - este proyecto NO usa prop-types +# (es un proyecto puramente JSX sin sistema de tipos en componentes +# internos). Los contratos se documentan en JSDoc/comentarios. +sonar.issue.ignore.multicriteria=e1,e2,e3,e4,e5,e6,e7,e8,e9,e10 sonar.issue.ignore.multicriteria.e1.ruleKey=javascript:S2245 sonar.issue.ignore.multicriteria.e1.resourceKey=src/components/Universe/**/* @@ -104,4 +111,12 @@ sonar.issue.ignore.multicriteria.e7.resourceKey=src/components/Dashboard/Collabo sonar.issue.ignore.multicriteria.e8.ruleKey=javascript:S1082 sonar.issue.ignore.multicriteria.e8.resourceKey=src/components/LanguageSelector.jsx +# S7748: zero-fraction floats en codigo de animacion - mantenemos 1.0/0.0 explicito +sonar.issue.ignore.multicriteria.e9.ruleKey=javascript:S7748 +sonar.issue.ignore.multicriteria.e9.resourceKey=src/components/Universe/**/* + +# S6774: PropTypes - proyecto no usa prop-types; contratos documentados en JSDoc +sonar.issue.ignore.multicriteria.e10.ruleKey=javascript:S6774 +sonar.issue.ignore.multicriteria.e10.resourceKey=src/components/Universe/**/* + sonar.sourceEncoding=UTF-8 diff --git a/src/components/Universe/BigBangEntry.jsx b/src/components/Universe/BigBangEntry.jsx index 207e930..f1a0d09 100644 --- a/src/components/Universe/BigBangEntry.jsx +++ b/src/components/Universe/BigBangEntry.jsx @@ -7,11 +7,11 @@ * anticipation → ignition → primary shockwave → cosmic web → settle. * * Phases (total 4000ms): - * 1. SINGULARITY (0 – 400 ms) energy lines converge into a pulsing point - * 2. IGNITION (400 – 800 ms) white flash + chromatic aberration + anamorphic flare - * 3. SHOCKWAVE (800 – 1900 ms) plasma streaks + 5 concentric rings + 240 particles burst - * 4. COSMIC WEB (1900 – 3200 ms) filaments draw outward, stars turn on across the sky - * 5. SETTLE (3200 – 4000 ms) afterglow fades, residual sparks twinkle + * 1. SINGULARITY (0 – 400 ms) pulsing seed point + * 2. IGNITION (400 – 800 ms) white flash + anamorphic flare + * 3. SHOCKWAVE (800 – 1900 ms) concentric rings echo the node expansion + * 4. COSMIC WEB (1900 – 3200 ms) real 3D nodes provide matter motion + * 5. SETTLE (3200 – 4000 ms) afterglow fades * * Design principles: * - Transparent canvas — never obscures the 3D content underneath @@ -32,7 +32,6 @@ const COLORS = { gold: [255, 200, 100], blue: [80, 160, 255], } -const PARTICLE_PALETTE = [COLORS.cyan, COLORS.purple, COLORS.green, COLORS.gold, COLORS.white, COLORS.blue] /* ─── Math helpers ─── */ function clamp(v, mn, mx) { return Math.max(mn, Math.min(mx, v)) } @@ -40,7 +39,6 @@ function lerp(a, b, t) { return a + (b - a) * t } function easeOutCubic(t) { return 1 - Math.pow(1 - clamp(t, 0, 1), 3) } function easeOutQuart(t) { return 1 - Math.pow(1 - clamp(t, 0, 1), 4) } function easeInCubic(t) { return clamp(t, 0, 1) ** 3 } -function easeInOutCubic(t) { return t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2, 3)/2 } /* Map progress (0..1) → phase-local progress (0..1) for a [start,end] window */ function phaseT(p, start, end) { @@ -49,236 +47,6 @@ function phaseT(p, start, end) { return (p - start) / (end - start) } -/* ══════════════════════════════════════════════════════════════════ - * PHASE 1: CONVERGENT ENERGY STREAKS - * Líneas brillantes desde los bordes del viewport convergiendo al centro. - * Comunican "algo se está formando". 14-18 streaks, ángulos pseudoaleatorios. - * ══════════════════════════════════════════════════════════════════ */ -class ConvergentStreak { - constructor(cx, cy, w, h) { - this.cx = cx; this.cy = cy - this.angle = Math.random() * Math.PI * 2 - const startDist = Math.hypot(w, h) * (0.45 + Math.random() * 0.20) - this.x0 = cx + Math.cos(this.angle) * startDist - this.y0 = cy + Math.sin(this.angle) * startDist - this.length = 80 + Math.random() * 180 - this.color = PARTICLE_PALETTE[Math.floor(Math.random() * 4)] // exclude white/blue → too dim - this.bornAt = Math.random() * 0.4 // local to phase, staggered - this.lateral = (Math.random() - 0.5) * 0.15 // slight bend - } - draw(ctx, phaseProg) { - if (phaseProg < this.bornAt) return - const t = clamp((phaseProg - this.bornAt) / (1 - this.bornAt), 0, 1) - const e = easeInCubic(t) // accelerate into the singularity - const headX = lerp(this.x0, this.cx, e) - const headY = lerp(this.y0, this.cy, e) - const tailX = headX - Math.cos(this.angle) * this.length * (1 - e * 0.7) - const tailY = headY - Math.sin(this.angle) * this.length * (1 - e * 0.7) - const alpha = (1 - easeInCubic(t)) * 0.85 - if (alpha < 0.01) return - const grad = ctx.createLinearGradient(tailX, tailY, headX, headY) - grad.addColorStop(0, `rgba(${this.color[0]},${this.color[1]},${this.color[2]},0)`) - grad.addColorStop(1, `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${alpha})`) - ctx.strokeStyle = grad - ctx.lineWidth = 1.5 + e * 1.5 - ctx.shadowColor = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${alpha * 0.6})` - ctx.shadowBlur = 10 - ctx.beginPath() - ctx.moveTo(tailX, tailY); ctx.lineTo(headX, headY) - ctx.stroke() - ctx.shadowBlur = 0 - } -} - -/* ══════════════════════════════════════════════════════════════════ - * PHASE 3: BURST PARTICLES (con chromatic aberration RGB) - * Cada partícula tiene 3 sub-puntos R/G/B desplazados → efecto film cromático - * ══════════════════════════════════════════════════════════════════ */ -class BurstParticle { - constructor(cx, cy) { - this.x = cx; this.y = cy - const angle = Math.random() * Math.PI * 2 - const speed = 140 + Math.random() * 420 - this.vx = Math.cos(angle) * speed - this.vy = Math.sin(angle) * speed - this.color = PARTICLE_PALETTE[Math.floor(Math.random() * PARTICLE_PALETTE.length)] - this.size = 0.9 + Math.random() * 2.4 - this.alpha = 0.55 + Math.random() * 0.40 - this.trail = [] - this.trailMax = 5 + Math.floor(Math.random() * 6) - this.drag = 0.955 + Math.random() * 0.030 - this.bornAt = Math.random() * 0.18 - this.spin = (Math.random() - 0.5) * 0.4 - } - update(localT, dt) { - if (localT < this.bornAt) return - const speed = Math.hypot(this.vx, this.vy) - if (speed > 1) { // tangential spin → spiral effect - const nx = -this.vy / speed - const ny = this.vx / speed - this.vx += nx * this.spin * speed * dt - this.vy += ny * this.spin * speed * dt - } - this.vx *= this.drag - this.vy *= this.drag - this.x += this.vx * dt - this.y += this.vy * dt - this.trail.unshift({ x: this.x, y: this.y }) - if (this.trail.length > this.trailMax) this.trail.pop() - } - draw(ctx, localT, chromaOffset) { - if (localT < this.bornAt) return - const life = clamp((localT - this.bornAt) / (0.85 - this.bornAt), 0, 1) - const a = this.alpha * (1 - easeInCubic(life)) - if (a < 0.01) return - // Trail - for (let i = 1; i < this.trail.length; i++) { - const f = 1 - i / this.trail.length - ctx.beginPath() - ctx.arc(this.trail[i].x, this.trail[i].y, this.size * f * 0.65, 0, Math.PI * 2) - ctx.fillStyle = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a * f * 0.35})` - ctx.fill() - } - // Chromatic-aberration triplet (R/G/B offset around true position) - if (chromaOffset > 0.5) { - ctx.beginPath(); ctx.arc(this.x - chromaOffset, this.y, this.size, 0, Math.PI * 2) - ctx.fillStyle = `rgba(255,80,80,${a * 0.5})`; ctx.fill() - ctx.beginPath(); ctx.arc(this.x + chromaOffset, this.y, this.size, 0, Math.PI * 2) - ctx.fillStyle = `rgba(80,80,255,${a * 0.5})`; ctx.fill() - } - // Main particle (G + actual color) - ctx.beginPath() - ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2) - ctx.fillStyle = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a})` - ctx.fill() - } -} - -/* ══════════════════════════════════════════════════════════════════ - * PHASE 3: PLASMA STREAK - * Rayos cósmicos largos cruzando la pantalla por el centro (estilo - * "cosmic ray" o trazas de partículas relativistas). - * ══════════════════════════════════════════════════════════════════ */ -class PlasmaStreak { - constructor(cx, cy, w, h) { - this.cx = cx; this.cy = cy - this.angle = Math.random() * Math.PI * 2 - const reach = Math.hypot(w, h) * (0.45 + Math.random() * 0.25) - this.x1 = cx + Math.cos(this.angle) * reach - this.y1 = cy + Math.sin(this.angle) * reach - this.x2 = cx - Math.cos(this.angle) * reach * 0.20 // streak extends past center slightly - this.y2 = cy - Math.sin(this.angle) * reach * 0.20 - this.color = PARTICLE_PALETTE[Math.floor(Math.random() * PARTICLE_PALETTE.length)] - this.bornAt = Math.random() * 0.25 - this.width = 1.2 + Math.random() * 1.6 - } - draw(ctx, localT) { - if (localT < this.bornAt) return - const t = clamp((localT - this.bornAt) / (1 - this.bornAt), 0, 1) - // Streak extends outward fast, then fades - const headT = easeOutQuart(t) - const hx = lerp(this.cx, this.x1, headT) - const hy = lerp(this.cy, this.y1, headT) - const tx = lerp(this.cx, this.x2, headT) - const ty = lerp(this.cy, this.y2, headT) - const a = (1 - easeInCubic(t)) * 0.75 - if (a < 0.01) return - const grad = ctx.createLinearGradient(tx, ty, hx, hy) - grad.addColorStop(0, `rgba(${this.color[0]},${this.color[1]},${this.color[2]},0)`) - grad.addColorStop(0.5, `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a})`) - grad.addColorStop(0.85, `rgba(255,255,255,${a * 0.9})`) - grad.addColorStop(1, `rgba(${this.color[0]},${this.color[1]},${this.color[2]},0)`) - ctx.strokeStyle = grad - ctx.lineWidth = this.width + (1 - t) * 1.2 - ctx.shadowColor = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a * 0.6})` - ctx.shadowBlur = 14 - ctx.beginPath() - ctx.moveTo(tx, ty); ctx.lineTo(hx, hy) - ctx.stroke() - ctx.shadowBlur = 0 - } -} - -/* ══════════════════════════════════════════════════════════════════ - * PHASE 4: FILAMENT (cosmic web) - * Línea curva creciendo radialmente desde el centro, con segmentos. - * Representa la estructura a gran escala del universo recién formado. - * ══════════════════════════════════════════════════════════════════ */ -class Filament { - constructor(cx, cy, w, h) { - this.cx = cx; this.cy = cy - this.angle = Math.random() * Math.PI * 2 - this.maxLen = 110 + Math.random() * Math.max(w, h) * 0.45 - this.color = PARTICLE_PALETTE[Math.floor(Math.random() * PARTICLE_PALETTE.length)] - this.curvature = (Math.random() - 0.5) * 0.6 - this.bornAt = Math.random() * 0.35 - this.width = 0.7 + Math.random() * 1.4 - } - draw(ctx, localT) { - if (localT < this.bornAt) return - const t = clamp((localT - this.bornAt) / (1 - this.bornAt), 0, 1) - const len = this.maxLen * easeOutCubic(Math.min(t * 2, 1)) - if (len < 4) return - const a = (1 - easeInCubic(Math.max(0, t * 1.3 - 0.3))) * 0.55 - if (a < 0.01) return - // Curved filament via quadratic bezier - const ex = this.cx + Math.cos(this.angle) * len - const ey = this.cy + Math.sin(this.angle) * len - const perpX = -Math.sin(this.angle) - const perpY = Math.cos(this.angle) - const ctrlX = this.cx + Math.cos(this.angle) * len * 0.5 + perpX * len * this.curvature - const ctrlY = this.cy + Math.sin(this.angle) * len * 0.5 + perpY * len * this.curvature - ctx.strokeStyle = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a})` - ctx.lineWidth = this.width - ctx.shadowColor = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a * 0.5})` - ctx.shadowBlur = 8 - ctx.beginPath() - ctx.moveTo(this.cx, this.cy) - ctx.quadraticCurveTo(ctrlX, ctrlY, ex, ey) - ctx.stroke() - ctx.shadowBlur = 0 - } -} - -/* ══════════════════════════════════════════════════════════════════ - * PHASE 4-5: STAR (turning on across the sky) - * Puntos blancos que aparecen progresivamente en posiciones aleatorias - * tras la formación de la estructura cósmica. - * ══════════════════════════════════════════════════════════════════ */ -class Star { - constructor(w, h, cx, cy) { - // Position with mild bias away from center (avoid covering 3D content) - const angle = Math.random() * Math.PI * 2 - const dist = (0.18 + Math.random() * 0.45) * Math.min(w, h) - this.x = cx + Math.cos(angle) * dist - this.y = cy + Math.sin(angle) * dist - this.size = 0.4 + Math.random() * 1.2 - this.bornAt = Math.random() * 0.7 // staggered turn-on across the full phase - this.maxAlpha = 0.45 + Math.random() * 0.45 - // Twinkle - this.twinkleFreq = 1.5 + Math.random() * 2.5 - this.twinklePhase = Math.random() * Math.PI * 2 - } - draw(ctx, localT, absoluteTime) { - if (localT < this.bornAt) return - const turnOn = clamp((localT - this.bornAt) / 0.15, 0, 1) - const twinkle = 0.7 + 0.3 * Math.sin(absoluteTime * this.twinkleFreq + this.twinklePhase) - const a = this.maxAlpha * easeOutCubic(turnOn) * twinkle - if (a < 0.02) return - ctx.beginPath() - ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2) - ctx.fillStyle = `rgba(255,255,255,${a})` - ctx.fill() - // Soft halo for the brighter ones - if (this.size > 0.9) { - ctx.beginPath() - ctx.arc(this.x, this.y, this.size * 2.5, 0, Math.PI * 2) - ctx.fillStyle = `rgba(180,220,255,${a * 0.15})` - ctx.fill() - } - } -} - /* ══════════════════════════════════════════════════════════════════ * MAIN COMPONENT * ══════════════════════════════════════════════════════════════════ */ @@ -309,13 +77,6 @@ export default function BigBangEntry({ active, wrapperRef, replay = 0 }) { const cy = h / 2 const wrapperEl = wrapperRef?.current - /* ─── Spawn all actors ─── */ - const streaks = Array.from({ length: 18 }, () => new ConvergentStreak(cx, cy, w, h)) - const bursts = Array.from({ length: 240 }, () => new BurstParticle(cx, cy)) - const plasmas = Array.from({ length: 14 }, () => new PlasmaStreak(cx, cy, w, h)) - const filaments = Array.from({ length: 28 }, () => new Filament(cx, cy, w, h)) - const stars = Array.from({ length: 110 }, () => new Star(w, h, cx, cy)) - /* ─── Shockwave configuration (5 concentric rings, staggered) ─── */ const maxDim = Math.max(w, h) const shockwaves = [ @@ -327,7 +88,6 @@ export default function BigBangEntry({ active, wrapperRef, replay = 0 }) { ] const startTime = performance.now() - let lastTime = startTime /* ─── Cleanup of CSS filters on wrapper ─── */ const resetWrapper = () => { @@ -340,8 +100,6 @@ export default function BigBangEntry({ active, wrapperRef, replay = 0 }) { const frame = (now) => { const elapsed = now - startTime const progress = clamp(elapsed / DURATION, 0, 1) - const dt = Math.min(0.033, (now - lastTime) / 1000) - lastTime = now const absT = (now - startTime) / 1000 ctx.clearRect(0, 0, w, h) @@ -390,12 +148,10 @@ export default function BigBangEntry({ active, wrapperRef, replay = 0 }) { /* ════════════════════════════════════════════════════════ * PHASE 1: SINGULARITY (0 → 0.10 of total) - * Energy lines converge into a pulsing white-cyan point + * Pulsing white-cyan seed point * ════════════════════════════════════════════════════════ */ const p1 = phaseT(progress, 0.0, 0.10) if (p1 > 0 && p1 < 1) { - // Convergent streaks rushing toward center - for (const s of streaks) s.draw(ctx, p1) // Pulsing seed dot at the centre (anticipation) const pulse = 0.55 + 0.45 * Math.sin(absT * 18) const seedR = 2 + p1 * 6 @@ -410,7 +166,7 @@ export default function BigBangEntry({ active, wrapperRef, replay = 0 }) { /* ════════════════════════════════════════════════════════ * PHASE 2: IGNITION (0.08 → 0.22 of total) - * The flash: chromatic aberration + white overlay + anamorphic flare + * The flash: white overlay + anamorphic flare * ════════════════════════════════════════════════════════ */ const p2 = phaseT(progress, 0.08, 0.22) if (p2 > 0 && p2 < 1) { @@ -455,13 +211,10 @@ export default function BigBangEntry({ active, wrapperRef, replay = 0 }) { /* ════════════════════════════════════════════════════════ * PHASE 3: SHOCKWAVE (0.20 → 0.475 of total) - * Plasma streaks + 5 concentric rings + 240 particles bursting + * Five concentric rings echo the real node expansion underneath * ════════════════════════════════════════════════════════ */ const p3 = phaseT(progress, 0.20, 0.475) if (p3 > 0 && p3 < 1) { - // Plasma streaks (cosmic rays) - for (const ps of plasmas) ps.draw(ctx, p3) - // Concentric shockwave rings (each born at its `born` time) for (const sw of shockwaves) { if (p3 < sw.born) continue @@ -479,28 +232,11 @@ export default function BigBangEntry({ active, wrapperRef, replay = 0 }) { ctx.stroke() ctx.shadowBlur = 0 } - - // Burst particles (chromatic aberration scales with energy) - const chroma = lerp(4, 0, easeOutQuart(p3)) - for (const bp of bursts) { - bp.update(p3, dt) - bp.draw(ctx, p3, chroma) - } - } - - /* ════════════════════════════════════════════════════════ - * PHASE 4: COSMIC WEB (0.475 → 0.80 of total) - * Filaments expand outward, stars start turning on - * ════════════════════════════════════════════════════════ */ - const p4 = phaseT(progress, 0.475, 0.80) - if (p4 > 0 && p4 < 1) { - for (const f of filaments) f.draw(ctx, p4) - for (const st of stars) st.draw(ctx, p4, absT) } /* ════════════════════════════════════════════════════════ * PHASE 5: SETTLE (0.80 → 1.00 of total) - * Stars persist with twinkle, residual central afterglow fades + * Residual central afterglow fades * ════════════════════════════════════════════════════════ */ const p5 = phaseT(progress, 0.80, 1.0) if (p5 > 0) { @@ -514,13 +250,6 @@ export default function BigBangEntry({ active, wrapperRef, replay = 0 }) { ctx.fillStyle = ag ctx.fillRect(0, 0, w, h) } - // Continue twinkling stars but fading out gently - const starFade = 1 - easeInCubic(p5) - if (starFade > 0.05) { - ctx.globalAlpha = starFade - for (const st of stars) st.draw(ctx, 1, absT) - ctx.globalAlpha = 1 - } } if (progress < 1) { diff --git a/src/components/Universe/BlackHoleExit.jsx b/src/components/Universe/BlackHoleExit.jsx index c6ef0fb..e93d4a8 100644 --- a/src/components/Universe/BlackHoleExit.jsx +++ b/src/components/Universe/BlackHoleExit.jsx @@ -3,14 +3,14 @@ * ===================================================== * * 5-phase Marvel-tier exit animation that consumes the 3D universe scene - * into a singularity. Drives both a transparent Canvas2D overlay (particles, - * photon ring, accretion disk, gravitational lensing, hawking radiation) + * into a singularity. Drives both a transparent Canvas2D overlay (photon ring, + * accretion disk, gravitational lensing, remnant glow) * AND DOM effects on the universe wrapper (clip-path circle, scale, blur, * tremor) for an integrated feel. * * Phases (total 4500ms): * 1. PREMONITION (0 – 500 ms) desaturate, faint glitch, mild shake - * 2. GATHERING (500 – 1700 ms) debris falls inward on logarithmic spirals + * 2. GATHERING (500 – 1700 ms) wrapper begins bending inward * 3. EVENT HORIZON (1700 – 2900 ms) photon ring + accretion disk + clip-path begins * 4. SPAGHETTIFICATION (2900 – 3900 ms) content stretches, lensing intensifies * 5. TOTAL COLLAPSE (3900 – 4500 ms) clip-path rushes to zero, final flash, void @@ -26,20 +26,9 @@ */ import { useEffect, useRef, useCallback } from 'react' -const DURATION = 6500 +const DURATION = 5500 const DPR = Math.min(window.devicePixelRatio || 1, 2) -const COLORS = { - white: [255, 255, 255], - cyan: [0, 212, 228], - purple: [157, 111, 219], - green: [0, 255, 159], - gold: [255, 200, 100], - orange: [255, 140, 60], - blue: [80, 160, 255], -} -const DEBRIS_PALETTE = [COLORS.cyan, COLORS.purple, COLORS.green, COLORS.gold, COLORS.white, COLORS.blue] - /* ─── Math helpers ─── */ function clamp(v, mn, mx) { return Math.max(mn, Math.min(mx, v)) } function lerp(a, b, t) { return a + (b - a) * t } @@ -49,6 +38,37 @@ function easeOutCubic(t) { return 1 - Math.pow(1 - clamp(t, 0, 1), 3) } function easeOutQuart(t) { return 1 - Math.pow(1 - clamp(t, 0, 1), 4) } function easeInOutCubic(t) { return t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2, 3)/2 } +/* ─── Clip-path curve — function ANALYTIC (no keyframes) ──────── + * Calcula el factor de clip [0..1] vs progress [0..1] como una + * curva CONTINUA con derivada suave. Tiene 3 segmentos: + * - Premonición (0-0.08): clip 1.0 (sin cambios) + * - Reducción agresiva (0.08-0.55): clip baja de 1.0 a 0.15 con + * easeInQuart (lento al inicio, rápido al final) + * - Oscilación amortiguada (0.55-0.80): el agujero se contrae + * hacia 0 mientras OSCILA suavemente (3 ciclos sinusoidales + * con amplitud decreciente). Esto se siente como olas naturales + * en lugar de bounces saltarines. + * - Cerrado (>0.80): clip = 0 + * ──────────────────────────────────────────────────────────────── */ +function computeClipFactor(t) { + if (t < 0.08) return 1.0 + if (t < 0.55) { + const p1 = (t - 0.08) / 0.47 + return 1.0 - easeInQuart(p1) * 0.85 + } + if (t < 0.80) { + const oscT = (t - 0.55) / 0.25 + // Base decay: 0.15 → 0 + const baseR = 0.15 * (1 - easeInCubic(oscT)) + // Oscilación: 2.75 ciclos sinusoidales (suaves) con amplitud + // decreciente. Amplitud peak = 0.028 (2.8% del max screen radius) + // — visible como respiración del agujero, no como salto. + const oscillation = Math.cos(oscT * Math.PI * 5.5) * 0.028 * (1 - oscT) * (1 - oscT) + return Math.max(0, baseR + oscillation) + } + return 0 +} + function phaseT(p, start, end) { if (p < start) return 0 if (p > end) return 1 @@ -74,156 +94,6 @@ function sampleKeyframes(p, frames, ease = easeInOutCubic) { return frames[frames.length - 1].v } -/* ══════════════════════════════════════════════════════════════════ - * DEBRIS PARTICLE — fragments of the universe being absorbed - * Falls inward on a logarithmic spiral (Kerr-metric inspired). - * Trail intensifies as it accelerates → gives sense of relativistic fall. - * ══════════════════════════════════════════════════════════════════ */ -class DebrisParticle { - constructor(cx, cy, w, h) { - const angle = Math.random() * Math.PI * 2 - const dist = 0.18 + Math.random() * 0.85 - const maxR = Math.hypot(w, h) * 0.55 - this.x = cx + Math.cos(angle) * dist * maxR - this.y = cy + Math.sin(angle) * dist * maxR - this.vx = 0; this.vy = 0 - this.color = DEBRIS_PALETTE[Math.floor(Math.random() * DEBRIS_PALETTE.length)] - this.size = 0.6 + Math.random() * 2.0 - this.alpha = 0.30 + Math.random() * 0.60 - this.trail = [] - this.trailMax = 5 + Math.floor(Math.random() * 6) - this.absorbed = false - this.spinBias = (Math.random() - 0.5) * 0.65 // logarithmic spiral coefficient - } - update(cx, cy, progress, dt) { - if (this.absorbed) return - const dx = cx - this.x, dy = cy - this.y - const d = Math.hypot(dx, dy) - if (d < 4) { this.absorbed = true; return } - // Gravity grows non-linearly with time (event horizon forms → accelerates) - const gravity = 80 + 32000 * easeInCubic(progress) - const nx = dx / d, ny = dy / d - // Tangential spin → Kerr-spiral (frame dragging) - const tx = -ny, ty = nx - const spinMag = (260 + 1100 * progress) * this.spinBias / Math.max(d * 0.012, 1) - this.vx += (nx * gravity / Math.max(d, 1) + tx * spinMag) * dt - this.vy += (ny * gravity / Math.max(d, 1) + ty * spinMag) * dt - // Drag scaled with progress so it doesn't stall at end - const drag = 0.94 - progress * 0.06 - this.vx *= drag - this.vy *= drag - this.x += this.vx * dt - this.y += this.vy * dt - this.trail.unshift({ x: this.x, y: this.y, a: this.alpha }) - if (this.trail.length > this.trailMax) this.trail.pop() - } - draw(ctx, progress) { - if (this.absorbed) return - const a = this.alpha * (0.85 + 0.15 * progress) - if (a < 0.01) return - // Trail (gives motion blur + sense of velocity) - for (let i = 1; i < this.trail.length; i++) { - const f = 1 - i / this.trail.length - ctx.beginPath() - ctx.arc(this.trail[i].x, this.trail[i].y, this.size * f * 0.7, 0, Math.PI * 2) - ctx.fillStyle = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a * f * 0.4})` - ctx.fill() - } - // Main particle - ctx.beginPath() - ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2) - ctx.fillStyle = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a})` - ctx.fill() - } -} - -/* ══════════════════════════════════════════════════════════════════ - * REALITY FRACTURE — spacetime cracks radiating from the singularity - * Líneas quebradas que aparecen brevemente representando "el tejido - * del espacio-tiempo rompiéndose" alrededor del horizonte de eventos. - * ══════════════════════════════════════════════════════════════════ */ -class RealityFracture { - constructor(cx, cy, w, h) { - this.cx = cx; this.cy = cy - this.angle = Math.random() * Math.PI * 2 - const reach = Math.hypot(w, h) * (0.35 + Math.random() * 0.30) - // Pre-compute the kinked path: 4-6 segments with small lateral jitter - const segs = 4 + Math.floor(Math.random() * 3) - this.points = [] - for (let i = 0; i <= segs; i++) { - const t = i / segs - const r = reach * t - const perpJitter = (Math.random() - 0.5) * 28 * (1 - t) - const px = cx + Math.cos(this.angle) * r + Math.cos(this.angle + Math.PI / 2) * perpJitter - const py = cy + Math.sin(this.angle) * r + Math.sin(this.angle + Math.PI / 2) * perpJitter - this.points.push([px, py]) - } - this.color = Math.random() < 0.5 ? COLORS.cyan : COLORS.purple - this.bornAt = 0.35 + Math.random() * 0.45 // appear during EVENT HORIZON phase - this.flickerPhase = Math.random() * Math.PI * 2 - } - draw(ctx, p, absT) { - if (p < this.bornAt) return - const local = clamp((p - this.bornAt) / 0.18, 0, 1) - const flicker = 0.55 + 0.45 * Math.sin(absT * 22 + this.flickerPhase) - const a = easeOutQuart(local) * (1 - easeInCubic(Math.max(0, local * 1.4 - 0.4))) * flicker * 0.65 - if (a < 0.01) return - ctx.strokeStyle = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a})` - ctx.lineWidth = 1.1 + (1 - local) * 1.4 - ctx.shadowColor = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a * 0.6})` - ctx.shadowBlur = 8 - ctx.beginPath() - ctx.moveTo(this.points[0][0], this.points[0][1]) - for (let i = 1; i < this.points.length; i++) { - ctx.lineTo(this.points[i][0], this.points[i][1]) - } - ctx.stroke() - ctx.shadowBlur = 0 - } -} - -/* ══════════════════════════════════════════════════════════════════ - * HAWKING PARTICLE — emitted from the event horizon outward - * Pequeñas partículas que ESCAPAN del horizonte (radiación de Hawking). - * Spawned only after the event horizon forms. - * ══════════════════════════════════════════════════════════════════ */ -class HawkingParticle { - constructor(cx, cy, eventRadius) { - const angle = Math.random() * Math.PI * 2 - this.x = cx + Math.cos(angle) * eventRadius - this.y = cy + Math.sin(angle) * eventRadius - const speed = 60 + Math.random() * 180 - this.vx = Math.cos(angle) * speed - this.vy = Math.sin(angle) * speed - this.color = Math.random() < 0.6 ? COLORS.white : COLORS.cyan - this.size = 0.4 + Math.random() * 0.9 - this.alpha = 0.65 + Math.random() * 0.30 - this.age = 0 - // Vida más larga y variable para que el último frame de cada partícula - // ya esté en alpha~0 antes de morir (fade natural, no corte). - this.life = 0.85 + Math.random() * 0.70 // seconds (was 0.45 + 0.35) - } - update(dt) { - this.age += dt - this.x += this.vx * dt - this.y += this.vy * dt - this.vx *= 0.985 - this.vy *= 0.985 - } - draw(ctx) { - const f = clamp(1 - this.age / this.life, 0, 1) - // Curva quart en lugar de cubic — tail-off MUY suave al final - const fade = easeOutQuart(f) * easeOutQuart(f) - const a = this.alpha * fade - if (a < 0.001) return - ctx.beginPath() - ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2) - ctx.fillStyle = `rgba(${this.color[0]},${this.color[1]},${this.color[2]},${a})` - ctx.fill() - } - isDead() { return this.age >= this.life } -} - /* ══════════════════════════════════════════════════════════════════ * MAIN COMPONENT * ══════════════════════════════════════════════════════════════════ */ @@ -267,15 +137,8 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { de forma orgánica. Si tocásemos su opacity tendríamos un "salto" al restaurarla al final. */ - /* ─── Actors ─── */ - const debris = Array.from({ length: 260 }, () => new DebrisParticle(cx, cy, w, h)) - const fractures = Array.from({ length: 16 }, () => new RealityFracture(cx, cy, w, h)) - const hawking = [] - let hawkingSpawnedAt = 0 - const startTime = performance.now() completedRef.current = false - let lastT = startTime /* ─── KEYFRAME TABLES ────────────────────────────────────────── * Cada propiedad del wrapper se define como una curva continua @@ -287,82 +150,76 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { // Scale (transform): zoom inicial leve, retrocede, luego inward zoom scale: [ { t: 0.00, v: 1.00 }, - { t: 0.05, v: 0.97 }, // fly-back out (más rápido) + { t: 0.05, v: 0.97 }, // fly-back out { t: 0.10, v: 1.00 }, // vuelve a neutral - { t: 0.28, v: 1.04 }, // start subtle zoom-in (era 0.42) - { t: 0.50, v: 1.10 }, // era 0.62 - { t: 0.65, v: 1.22 }, // era 0.78 - { t: 0.74, v: 1.45 }, // peak final zoom (era 0.86) - { t: 1.00, v: 1.45 }, // hold + { t: 0.25, v: 1.04 }, // start subtle zoom-in + { t: 0.45, v: 1.12 }, + { t: 0.62, v: 1.28 }, + { t: 0.78, v: 1.50 }, // peak final zoom (collapse) + { t: 1.00, v: 1.50 }, // hold ], // Saturate: gradual desaturation saturate: [ { t: 0.00, v: 1.00 }, - { t: 0.07, v: 0.85 }, // era 0.11 - { t: 0.28, v: 0.65 }, // era 0.42 - { t: 0.50, v: 0.48 }, // era 0.62 - { t: 0.65, v: 0.30 }, // era 0.78 - { t: 0.74, v: 0.15 }, // era 0.86 - { t: 1.00, v: 0.15 }, + { t: 0.10, v: 0.85 }, + { t: 0.30, v: 0.65 }, + { t: 0.50, v: 0.45 }, + { t: 0.65, v: 0.25 }, + { t: 0.78, v: 0.12 }, + { t: 1.00, v: 0.12 }, ], - // Blur: smooth ramp from 0 to 12 + // Blur arranca a t=0.20 y crece hasta el cierre del clip (t=0.80) blur: [ { t: 0.00, v: 0 }, - { t: 0.10, v: 0 }, // sin blur durante premonición (era 0.20) - { t: 0.28, v: 1.3 }, // era 0.42 - { t: 0.50, v: 3.0 }, // era 0.62 - { t: 0.65, v: 5.5 }, // era 0.78 - { t: 0.74, v: 11 }, // era 0.86 - { t: 1.00, v: 11 }, + { t: 0.20, v: 0 }, + { t: 0.38, v: 2.0 }, + { t: 0.55, v: 6.0 }, + { t: 0.70, v: 9.0 }, + { t: 0.80, v: 14 }, + { t: 1.00, v: 14 }, ], - // Brightness: stays at 1 until phase 4, dims hard at the end brightness: [ { t: 0.00, v: 1 }, - { t: 0.50, v: 1 }, // era 0.62 - { t: 0.65, v: 0.78 }, // era 0.78 - { t: 0.74, v: 0.10 }, // era 0.86 + { t: 0.40, v: 1 }, + { t: 0.65, v: 0.78 }, + { t: 0.80, v: 0.10 }, { t: 1.00, v: 0.05 }, ], - // Hue rotation: blueshift progresivo (efecto agujero negro) hueRot: [ { t: 0.00, v: 0 }, - { t: 0.28, v: 0 }, // era 0.42 - { t: 0.50, v: -18 }, // era 0.62 - { t: 0.65, v: -32 }, // era 0.78 - { t: 0.74, v: -55 }, // era 0.86 + { t: 0.25, v: 0 }, + { t: 0.50, v: -18 }, + { t: 0.68, v: -36 }, + { t: 0.80, v: -55 }, { t: 1.00, v: -55 }, ], - // Clip radius (en px, antes de aplicar counter-zoom de scale). - // Valor BASELINE = maxScreenR (sin clip visible). El shrink ahora - // arranca a t=0.15 (era 0.40) — agujero negro visible mucho antes. - // A t=0.74 (era 0.86) el clip llega a 0 → dashboard 100% visible - // y el remnant brilla sobre él durante todo el final. + // Clip radius — ya NO se usa de aquí. computeClipFactor() genera + // una curva analítica con oscilación amortiguada (sin keyframes). + // Se mantiene como vestigio comentado por si hace falta debug. clipR: [ - { t: 0.00, v: maxScreenR * 1.0 }, - { t: 0.15, v: maxScreenR * 1.0 }, // era 0.40 — arranca el cierre antes - { t: 0.32, v: maxScreenR * 0.78 }, // era 0.62 — primera contracción visible - { t: 0.52, v: maxScreenR * 0.45 }, // era 0.78 - { t: 0.74, v: 0 }, // era 0.86 — total collapse antes - { t: 1.00, v: 0 }, // hold colapsado + { t: 0.00, v: maxScreenR * 1.00 }, + { t: 1.00, v: 0 }, ], } // Cache absolute screen-shake amplitude curve (used only in phase 1-2) const shakeAmpFor = (p) => { - if (p < 0.04) return p / 0.04 * 2 - if (p < 0.28) return 2 + ((p - 0.04) / 0.24) * 4 - if (p < 0.50) return 6 * (1 - (p - 0.28) / 0.22) + if (p < 0.05) return p / 0.05 * 2 + if (p < 0.36) return 2 + ((p - 0.05) / 0.31) * 4 + if (p < 0.55) return 6 * (1 - (p - 0.36) / 0.19) return 0 } const frame = (now) => { const elapsed = now - startTime const progress = clamp(elapsed / DURATION, 0, 1) - const dt = Math.min(0.033, (now - lastT) / 1000) - lastT = now const absT = (now - startTime) / 1000 ctx.clearRect(0, 0, w, h) + // ClipR calculado con función analítica continua (en lugar de + // keyframes) — da oscilaciones amortiguadas suaves sin saltos. + const currentClipR = computeClipFactor(progress) * maxScreenR + /* ════════════════════════════════════════════════════════ * WRAPPER (CSS) — una sola actualización por frame con * valores interpolados continuamente de los keyframes. @@ -374,7 +231,7 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { const blur = sampleKeyframes(progress, KF.blur) const br = sampleKeyframes(progress, KF.brightness) const hue = sampleKeyframes(progress, KF.hueRot) - const clipR = sampleKeyframes(progress, KF.clipR) + const clipR = currentClipR const shakeAmp = shakeAmpFor(progress) const sx = Math.sin(absT * 26) * shakeAmp @@ -386,9 +243,13 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { const clipInCSS = clipR / scale wrapperEl.style.transform = `translate(${sx}px, ${sy}px) scale(${scale})` wrapperEl.style.filter = `saturate(${sat}) blur(${blur}px) hue-rotate(${hue}deg) brightness(${br})` - // Solo aplicar clipPath cuando ya empezó a cerrarse (evita - // clip visible artificial al inicio) - if (progress > 0.39) { + // Solo aplicar clipPath cuando ya empezó a cerrarse de verdad. + // El threshold compara el clip en pixels: si está al 99% o más + // del radio máximo, no aplica nada (evita un clip "completo" que + // se vería como un círculo gigante). En cuanto baja del 99% se + // aplica de forma continua para que el cierre sea PROGRESIVO + // desde el primer keyframe, sin saltos discretos. + if (clipR < maxScreenR * 0.995) { wrapperEl.style.clipPath = `circle(${Math.max(0, clipInCSS)}px at 50% 50%)` } else { wrapperEl.style.clipPath = '' @@ -432,37 +293,18 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { // Sin draws aquí — los efectos de phase 1 son los keyframes // wrapper de scale/saturate + shake aplicados arriba. - /* ════════════════════════════════════════════════════════ - * PHASE 2: GATHERING (0.04 → 0.28) - * Debris falls inward - * ════════════════════════════════════════════════════════ */ - const p2 = phaseT(progress, 0.04, 0.28) - if (p2 > 0) { - for (const d of debris) { - d.update(cx, cy, p2, dt) - d.draw(ctx, p2) - } - } - /* ════════════════════════════════════════════════════════ * PHASE 3: EVENT HORIZON (0.28 → 0.52) - * Photon ring + accretion disk + reality fractures. + * Photon ring + accretion disk. * El wrapper (clip + filter) lo gestiona la tabla de keyframes * arriba — aquí solo dibujamos en el canvas overlay. * ════════════════════════════════════════════════════════ */ - const p3 = phaseT(progress, 0.28, 0.52) + const p3 = phaseT(progress, 0.30, 0.55) if (p3 > 0) { - // Continue updating debris (acceleration intensifies) - for (const d of debris) { - d.update(cx, cy, p2 || 1, dt) - d.draw(ctx, p2 || 1) - } - - // Reality fractures appear (use overall progress so timings sync) - for (const f of fractures) f.draw(ctx, progress, absT) - - // Event horizon radius (shrinks as we go through phase 3-4-5) - const eventR = lerp(150, 4, easeInQuart(progress)) + // Event horizon radius: sits AT the actual clip-path edge (+6px + // so the stroke renders just outside the dark interior) so the + // ring/disk visually wrap the black hole and respect the bounces. + const eventR = Math.max(4, currentClipR + 6) // ── Accretion disk con doppler shift sutil ── const diskInner = eventR * 1.05 @@ -494,7 +336,11 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { } // ── Photon ring at the event horizon ── - if (eventR > 2) { + // Only draw when it fits inside the viewport — when the clip is + // very wide at the start of phase 3, eventR is huge and the ring + // would render off-screen as a flat line crossing the borders. + const maxVisibleRing = Math.min(w, h) * 0.55 + if (eventR > 2 && eventR < maxVisibleRing) { const ringA = clamp(easeOutQuart(p3) * 0.95, 0, 0.95) ctx.beginPath() ctx.arc(cx, cy, eventR, 0, Math.PI * 2) @@ -516,12 +362,6 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { ctx.arc(cx, cy, innerR, 0, Math.PI * 2) ctx.fill() } - - // ── Hawking radiation (spawn periodically) ── - if (p3 > 0.25 && absT - hawkingSpawnedAt > 0.04) { - for (let i = 0; i < 3; i++) hawking.push(new HawkingParticle(cx, cy, eventR * 1.1)) - hawkingSpawnedAt = absT - } } /* ════════════════════════════════════════════════════════ @@ -530,15 +370,14 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { * El wrapper (zoom, blur, clip) lo gestiona la tabla de * keyframes arriba — aquí solo dibujamos en canvas. * ════════════════════════════════════════════════════════ */ - const p4 = phaseT(progress, 0.52, 0.75) + const p4 = phaseT(progress, 0.55, 0.80) if (p4 > 0) { - // Reality fractures remain - for (const f of fractures) f.draw(ctx, progress, absT) + // Event horizon — AT the clip edge (+6px outside) so the ring + // wraps the black hole and honors every bounce. + const eventR = Math.max(4, currentClipR + 6) + const maxVisibleRing = Math.min(w, h) * 0.55 - // Event horizon - const eventR = lerp(150, 4, easeInQuart(progress)) - - if (eventR > 2) { + if (eventR > 2 && eventR < maxVisibleRing) { ctx.save() ctx.translate(cx, cy) ctx.rotate(absT * 2.4) @@ -582,19 +421,6 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { } } - /* ════════════════════════════════════════════════════════ - * HAWKING PARTICLES — render global (post phase 4) - * Las partículas spawneadas durante phase 3 siguen vivas - * después de que phase 4 termine; las renderizamos siempre - * que existan para que hagan su fade natural en lugar de - * cortarse de golpe al cambiar de fase. - * ════════════════════════════════════════════════════════ */ - for (let i = hawking.length - 1; i >= 0; i--) { - hawking[i].update(dt) - if (hawking[i].isDead()) hawking.splice(i, 1) - } - for (const hp of hawking) hp.draw(ctx) - /* ════════════════════════════════════════════════════════ * PHASE 5: TOTAL COLLAPSE (0.66 → 0.74) * Solo el white flash final. clip-path y wrapper los maneja @@ -602,7 +428,7 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { * a 0 y el dashboard es 100% visible. Tras eso queda el * REMNANT brillando (siguiente bloque). * ════════════════════════════════════════════════════════ */ - const p5 = phaseT(progress, 0.66, 0.74) + const p5 = phaseT(progress, 0.70, 0.80) if (p5 > 0) { const flashLocal = clamp(p5 / 0.40, 0, 1) const flashA = flashLocal < 0.45 @@ -629,74 +455,116 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { * que se diluye imperceptiblemente. * El dashboard es 100% visible detrás durante toda esta fase. * ════════════════════════════════════════════════════════ */ - const pRemnant = phaseT(progress, 0.58, 1.0) + /* ════════════════════════════════════════════════════════ + * SINGULARITY REMNANT (0.60 → 1.00 — fase final cinemática) + * Físicamente: el agujero negro acaba de colapsar sobre su + * propia singularidad. El brillo es lo que queda DESPUÉS del + * colapso. Estructura: + * - Build-up (0.00-0.30 local): el brillo aparece mientras + * el clip se sigue cerrando. + * - PEAK largo y estable (0.30-0.82 local): el brillo permanece + * al 100% durante el cierre del agujero y bastante después, + * sobreviviéndolo claramente. + * - Blink (0.82-0.92 local): pulso de intensidad final (×1.4) + * como un "destello" antes de extinguirse. + * - Off (0.92-1.00 local): apagado rápido a 0. + * ════════════════════════════════════════════════════════ */ + const pRemnant = phaseT(progress, 0.80, 1.0) if (pRemnant > 0) { // Pulse suave (frecuencia 10 rad/s = ~1.6 Hz) const pulse = 0.72 + 0.28 * Math.sin(absT * 10) - // Curva de intensidad de 3 tramos (build-up, peak, fade muy largo) + // ── Curva de intensidad con BLINK ÉPICO ── + // pRemnant es 0-1 sobre 1100ms (de t global 0.80 a 1.00, DURATION=5500ms) + // 0.00-0.15 (165ms): build-up rápido + // 0.15-0.62 (520ms): PEAK ESTABLE — singularidad visible ~medio segundo + // 0.62-0.90 (310ms): BLINK rápido (intensity 2.8×) + // 0.90-1.00 (110ms): off let intensity - if (pRemnant < 0.30) { - intensity = easeOutCubic(pRemnant / 0.30) // build-up - } else if (pRemnant < 0.45) { - intensity = 1 // peak sostenido + if (pRemnant < 0.15) { + intensity = easeOutCubic(pRemnant / 0.15) + } else if (pRemnant < 0.62) { + intensity = 1 // PEAK estable 520ms + } else if (pRemnant < 0.90) { + // BLINK rápido + const bp = (pRemnant - 0.62) / 0.28 + if (bp < 0.35) { + intensity = 1 + 1.8 * easeOutCubic(bp / 0.35) // 1 → 2.8 (rápido) + } else { + const fp = (bp - 0.35) / 0.65 + intensity = 2.8 - 2.5 * (fp * fp) // 2.8 → 0.3 + } + } else { + intensity = 0.3 - 0.3 * easeOutCubic((pRemnant - 0.90) / 0.10) + } + const baseA = Math.max(0, intensity * pulse) + + // ── Curva de tamaño: durante PEAK tamaño normal, blink expande, off colapsa ── + let sizeShrink + if (pRemnant < 0.62) { + sizeShrink = 1.0 // tamaño normal + } else if (pRemnant < 0.90) { + const bp = (pRemnant - 0.62) / 0.28 + if (bp < 0.35) { + sizeShrink = 1.0 + 0.6 * easeOutCubic(bp / 0.35) // 1.0 → 1.6 + } else { + const fp = (bp - 0.35) / 0.65 + sizeShrink = 1.6 - 1.0 * (fp * fp) // 1.6 → 0.6 + } } else { - // Fade out muy largo (55% del tiempo del remnant) con curva suave - // easeInOutCubic invertido para que decrezca muy lento al final - intensity = 1 - easeInOutCubic((pRemnant - 0.45) / 0.55) + sizeShrink = Math.max(0.05, 0.6 - 0.55 * easeOutCubic((pRemnant - 0.90) / 0.10)) } - const baseA = intensity * pulse - /* IMPORTANTE: no aplicamos threshold (`baseA > X`) para evitar - el "pop" cuando el valor cae por debajo. Si baseA es muy bajo - el navegador renderiza alpha~0 (imperceptible) sin discontinuidad. */ if (baseA > 0.001) { // ── Halo exterior masivo (suave, gigante) ── - const haloR = 140 + 50 * pulse + const haloR = (140 + 50 * pulse) * sizeShrink const haloGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, haloR) - haloGrad.addColorStop(0, `rgba(255,245,210,${baseA * 0.50})`) - haloGrad.addColorStop(0.15, `rgba(255,210,120,${baseA * 0.40})`) - haloGrad.addColorStop(0.40, `rgba(255,150,80,${baseA * 0.22})`) - haloGrad.addColorStop(0.65, `rgba(0,212,228,${baseA * 0.14})`) - haloGrad.addColorStop(0.85, `rgba(157,111,219,${baseA * 0.06})`) + haloGrad.addColorStop(0, `rgba(255,245,210,${Math.min(1, baseA * 0.50)})`) + haloGrad.addColorStop(0.15, `rgba(255,210,120,${Math.min(1, baseA * 0.40)})`) + haloGrad.addColorStop(0.40, `rgba(255,150,80,${Math.min(1, baseA * 0.22)})`) + haloGrad.addColorStop(0.65, `rgba(0,212,228,${Math.min(1, baseA * 0.14)})`) + haloGrad.addColorStop(0.85, `rgba(157,111,219,${Math.min(1, baseA * 0.06)})`) haloGrad.addColorStop(1, 'rgba(0,0,0,0)') ctx.fillStyle = haloGrad ctx.fillRect(cx - haloR, cy - haloR, haloR * 2, haloR * 2) - // ── Halo interior más concentrado (dorado puro) ── - const innerHaloR = 32 + 14 * pulse + // ── Halo interior dorado puro ── + const innerHaloR = (32 + 14 * pulse) * sizeShrink const innerGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, innerHaloR) - innerGrad.addColorStop(0, `rgba(255,255,240,${baseA * 0.80})`) - innerGrad.addColorStop(0.35, `rgba(255,230,160,${baseA * 0.55})`) - innerGrad.addColorStop(0.75, `rgba(255,180,90,${baseA * 0.20})`) + innerGrad.addColorStop(0, `rgba(255,255,240,${Math.min(1, baseA * 0.80)})`) + innerGrad.addColorStop(0.35, `rgba(255,230,160,${Math.min(1, baseA * 0.55)})`) + innerGrad.addColorStop(0.75, `rgba(255,180,90,${Math.min(1, baseA * 0.20)})`) innerGrad.addColorStop(1, 'rgba(0,0,0,0)') ctx.fillStyle = innerGrad ctx.fillRect(cx - innerHaloR, cy - innerHaloR, innerHaloR * 2, innerHaloR * 2) - // ── Núcleo brillante (singularity dot) ── - const coreR = 2.5 + pulse * 2.5 + // ── Núcleo brillante (singularity dot) — durante blink x2.2 ── + const isBlink = pRemnant >= 0.62 && pRemnant < 0.90 + const coreBoost = isBlink ? 2.2 : 1.0 + const coreR = (2.5 + pulse * 2.5) * Math.max(0.4, sizeShrink) * coreBoost ctx.beginPath() ctx.arc(cx, cy, coreR, 0, Math.PI * 2) - ctx.fillStyle = `rgba(255,255,255,${baseA * 0.98})` - ctx.shadowColor = `rgba(255,230,160,${baseA})` - ctx.shadowBlur = 28 + ctx.fillStyle = `rgba(255,255,255,${Math.min(1, baseA * 0.98)})` + ctx.shadowColor = `rgba(255,230,160,${Math.min(1, baseA)})` + ctx.shadowBlur = 28 * sizeShrink * coreBoost ctx.fill() ctx.shadowBlur = 0 - // ── Star spikes (4 puntas, lens flare cinemático) ── - const spikeLen = 30 + 20 * pulse - const spikeA = baseA * 0.55 - if (spikeA > 0.005) { + // ── Star spikes — durante blink se ALARGAN dramáticamente ── + const spikeBoost = isBlink ? 2.5 : 1.0 + const spikeLen = (30 + 20 * pulse) * sizeShrink * spikeBoost + const spikeA = Math.min(1, baseA * 0.55) + if (spikeA > 0.005 && spikeLen > 1) { ctx.strokeStyle = `rgba(255,245,200,${spikeA})` - ctx.lineWidth = 1.2 + ctx.lineWidth = 1.2 * (isBlink ? 1.8 : 1) ctx.shadowColor = `rgba(255,220,140,${spikeA})` - ctx.shadowBlur = 16 + ctx.shadowBlur = 16 * (isBlink ? 1.5 : 1) ctx.beginPath() ctx.moveTo(cx - spikeLen, cy); ctx.lineTo(cx + spikeLen, cy) ctx.moveTo(cx, cy - spikeLen); ctx.lineTo(cx, cy + spikeLen) ctx.stroke() ctx.strokeStyle = `rgba(255,245,200,${spikeA * 0.55})` - ctx.lineWidth = 0.8 + ctx.lineWidth = 0.8 * (isBlink ? 1.6 : 1) ctx.beginPath() const diag = spikeLen * 0.55 ctx.moveTo(cx - diag, cy - diag); ctx.lineTo(cx + diag, cy + diag) @@ -704,28 +572,38 @@ export default function BlackHoleExit({ active, onComplete, wrapperRef }) { ctx.stroke() ctx.shadowBlur = 0 } + + // ── BLINK extra: shockwave radial ── + if (pRemnant >= 0.62 && pRemnant < 0.80) { + const sp = (pRemnant - 0.62) / 0.18 + const ringR = 50 + 200 * easeOutCubic(sp) + const ringA = (1 - sp) * 0.55 + if (ringA > 0.01) { + ctx.strokeStyle = `rgba(255,235,180,${ringA})` + ctx.lineWidth = 2 + (1 - sp) * 3 + ctx.shadowColor = `rgba(255,210,120,${ringA})` + ctx.shadowBlur = 22 + ctx.beginPath() + ctx.arc(cx, cy, ringR, 0, Math.PI * 2) + ctx.stroke() + ctx.shadowBlur = 0 + } + } } } /* ════════════════════════════════════════════════════════ - * CANVAS OVERLAY + UNIVERSE CONTAINER FADE-OUT (últimos 12%) - * El `.universe` div tiene background opaco y z-index 9999. - * Sin un fade explícito, al desmontarse (cuando onComplete - * dispara setIsExiting+closeCollaborationGraph) desaparece de - * golpe revelando el dashboard en 1 frame = corte visible. - * Aquí lo desvanecemos progresivamente en paralelo con el - * canvas overlay, así el dashboard emerge orgánicamente y el - * unmount es imperceptible (ya estaba transparente). + * CANVAS OVERLAY FADE-OUT (últimos 4%) + * El remnant ya se reduce naturalmente de tamaño hasta quedar + * un puntito brillante. Este fade final es solo el "apagado" + * limpio del puntito al unmount — 4% del DURATION = ~220ms. * ════════════════════════════════════════════════════════ */ - if (progress > 0.88) { - const fadeT = (progress - 0.88) / 0.12 // 0 → 1 en los últimos 12% + if (progress > 0.96) { + const fadeT = (progress - 0.96) / 0.04 const eased = easeInOutCubic(fadeT) if (canvasRef.current) { canvasRef.current.style.opacity = String(1 - eased) } - if (universeContainer) { - universeContainer.style.opacity = String(1 - eased) - } } if (progress < 1) { diff --git a/src/components/Universe/UniverseView.jsx b/src/components/Universe/UniverseView.jsx index d0d11f9..d8b188e 100644 --- a/src/components/Universe/UniverseView.jsx +++ b/src/components/Universe/UniverseView.jsx @@ -45,6 +45,39 @@ const REPO_MAX_ORBIT = 55 // órbita máxima qubit→procesador const USER_MIN_ORBIT = 4 // órbita mínima partícula→qubit const USER_MAX_ORBIT = 10 // órbita máxima partícula→qubit +const SINGULARITY = new THREE.Vector3(0, 0, 0) +const _tmpRadial = new THREE.Vector3() +const _tmpTangent = new THREE.Vector3() +const _tmpUp = new THREE.Vector3(0, 1, 0) +const _tmpRight = new THREE.Vector3(1, 0, 0) + +function setEntryTravelPosition(out, pos, progress, staggerDelay, duration) { + const travelP = Math.min(1, Math.max(0, (progress - staggerDelay) / duration)) + const eased = travelP < 1 ? easeOutCubic(travelP) : 1 + out.lerpVectors(SINGULARITY, pos, eased) + if (eased < 0.999) { + const len = _tmpRadial.set(pos.x, pos.y, pos.z).length() + if (len > 0.001) { + const swirlMag = Math.sin(eased * Math.PI) * len * 0.12 + const radial = _tmpRadial.normalize() + _tmpTangent.crossVectors(_tmpUp, radial) + if (_tmpTangent.lengthSq() < 0.0001) _tmpTangent.crossVectors(_tmpRight, radial) + out.addScaledVector(_tmpTangent.normalize(), swirlMag) + } + } + return travelP +} + +function applyExitCollapse(out, exitProgress) { + if (exitProgress <= 0.001) return 0 + // Quadratic curve (`t^2`) for visible collapse from ~25% of progress. + // Linear lerp toward singularity (no spiral — spiral was disorienting + // for the viewer; straight line preserves spatial mapping of nodes). + const eased = exitProgress < 1 ? exitProgress * exitProgress : 1 + out.lerp(SINGULARITY, eased) + return eased +} + // Known bot logins — fallback when backend isBot flag is missing from cached data const KNOWN_BOT_LOGINS = new Set([ 'dependabot', 'renovate', 'greenkeeper', 'snyk-bot', 'codecov', @@ -552,12 +585,14 @@ function QuantumVacuum({ progressRef, progressKey }) { useFrame(({ clock }) => { const t = clock.getElapsedTime() const p = easeOutCubic(progressRef.current[progressKey]) + const exitProgress = progressRef.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.0) : 1 fluctMat.uniforms.uTime.value = t fluctMat.uniforms.uProgress.value = p - fluctMat.uniforms.uOpacity.value = p > 0.01 ? 0.15 * p : 0 + fluctMat.uniforms.uOpacity.value = p > 0.01 ? 0.15 * p * exitFade : 0 // Lattice fade-in - if (latticeRef.current) latticeRef.current.material.opacity = 0.025 * p + if (latticeRef.current) latticeRef.current.material.opacity = 0.025 * p * exitFade }) return ( @@ -615,6 +650,7 @@ function QuantumProcessors({ orgNodes, positions, onHover, onClick, progressRef, const t = clock.getElapsedTime() const hasSel = highlightSet !== null const progress = progressRef.current[progressKey] + const exitProgress = progressRef.current.exitProgress || 0 // Smooth lens blend if (lensData !== lastOrgLens.current) { @@ -656,11 +692,14 @@ function QuantumProcessors({ orgNodes, positions, onHover, onClick, progressRef, : 1.0 const appliedLensScale = 1.0 + (lensScaleFactor - 1.0) * blend const vis = visRef.current[i] - const scale = (isHighlighted ? localP * 1.25 : localP) * appliedLensScale * (vis < 0.001 ? 0 : 1) + const travelP = setEntryTravelPosition(tmpObj.position, pos, progress, stagger * 0.6, 0.5) + const exitEased = applyExitCollapse(tmpObj.position, exitProgress) + const breathing = travelP >= 1 ? 1 + Math.sin(t * 1.5 + i * 0.7) * 0.015 : 1 + let scale = (isHighlighted ? localP * 1.25 : localP) * appliedLensScale * breathing * (vis < 0.001 ? 0 : 1) + if (exitEased > 0) scale *= 1 - exitEased * 0.85 const speed = 0.3 + i * 0.05 // Torus 1 - rotación X,Y - tmpObj.position.copy(pos) tmpObj.rotation.set(t * speed, t * speed * 0.7, 0) tmpObj.scale.setScalar(scale) tmpObj.updateMatrix() @@ -812,9 +851,11 @@ function ProbabilityClouds({ repoNodes, positions, progressRef, progressKey, dim useFrame(({ clock }) => { if (!ref.current) return const p = easeOutCubic(progressRef.current[progressKey]) + const exitProgress = progressRef.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.5) : 1 shaderMat.uniforms.uTime.value = clock.getElapsedTime() * 0.4 shaderMat.uniforms.uProgress.value = p - shaderMat.uniforms.uOpacity.value = (dimmed ? 0.008 : 0.5) * p + shaderMat.uniforms.uOpacity.value = (dimmed ? 0.008 : 0.5) * p * exitFade // === Temporal visibility per cloud particle === const geo = ref.current?.geometry @@ -894,6 +935,7 @@ function Qubits({ repoNodes, positions, onHover, onClick, progressRef, progressK const t = clock.getElapsedTime() const n = repoNodes.length const hasSel = highlightSet !== null + const exitProgress = progressRef.current.exitProgress || 0 // Smooth lens blend for qubits - delayed until canvas visible if (lensData !== lastQubitLens.current) { @@ -947,16 +989,22 @@ function Qubits({ repoNodes, positions, onHover, onClick, progressRef, progressK const appliedLensScale = 1.0 + (lensScaleFactor - 1.0) * blend const vis = visRef.current[i] const visFade = vis * vis // quadratic for smoother perceptual fade - dummy.position.copy(pos) + const travelP = setEntryTravelPosition(dummy.position, pos, progress, stagger * 0.5, 0.6) // Heisenberg uncertainty - micro-vibración cuántica - dummy.position.x += Math.sin(t * 1.7 + i * 3.14) * 0.04 - dummy.position.y += Math.cos(t * 2.3 + i * 2.71) * 0.04 - dummy.position.z += Math.sin(t * 1.9 + i * 1.62) * 0.04 - dummy.scale.setScalar(baseScale * localP * selScale * appliedLensScale * (vis < 0.001 ? 0 : 1)) + if (travelP >= 1) { + dummy.position.x += Math.sin(t * 1.7 + i * 3.14) * 0.04 + dummy.position.y += Math.cos(t * 2.3 + i * 2.71) * 0.04 + dummy.position.z += Math.sin(t * 1.9 + i * 1.62) * 0.04 + } + const exitEased = applyExitCollapse(dummy.position, exitProgress) + const breathing = travelP >= 1 ? 1 + Math.sin(t * 1.5 + i * 0.7) * 0.015 : 1 + let scale = baseScale * localP * selScale * appliedLensScale * breathing * (vis < 0.001 ? 0 : 1) + if (exitEased > 0) scale *= 1 - exitEased * 0.85 + dummy.scale.setScalar(scale) dummy.updateMatrix() ref.current.setMatrixAt(i, dummy.matrix) if (hitRef.current) { - dummy.scale.setScalar(vis > 0.01 && localP > 0.1 ? 1 : 0.001) + dummy.scale.setScalar(vis > 0.01 && localP > 0.1 ? Math.max(0.001, 1 - exitEased * 0.85) : 0.001) dummy.updateMatrix() hitRef.current.setMatrixAt(i, dummy.matrix) } @@ -1031,7 +1079,10 @@ function BlochAxes({ repoNodes, positions, progressRef, progressKey, dimmed, act useEffect(() => () => geometry?.dispose(), [geometry]) useFrame(() => { - if (matRef.current) matRef.current.uniforms.uOpacity.value = 0.15 * easeOutCubic(progressRef?.current?.[progressKey] || 0) * (dimmed ? 0.04 : 1) + const progress = easeOutCubic(progressRef?.current?.[progressKey] || 0) + const exitProgress = progressRef?.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.5) : 1 + if (matRef.current) matRef.current.uniforms.uOpacity.value = 0.15 * progress * (dimmed ? 0.04 : 1) * exitFade // === Temporal visibility for Bloch axes === const visAttr = geometry.attributes.aVisible if (visAttr) { @@ -1125,6 +1176,7 @@ const PARTICLE_VERTEX = /* glsl */` attribute float aVisible; uniform float uTime; uniform float uProgress; + uniform float uExitProgress; uniform float uBaseSize; uniform float uBridgeSize; uniform float uLensActive; @@ -1137,6 +1189,12 @@ const PARTICLE_VERTEX = /* glsl */` varying float vDensity; varying float vBridgeBlend; varying float vVisible; + varying float vExitFade; + + float easeOutCubicGpu(float t) { + t = clamp(t, 0.0, 1.0); + return 1.0 - pow(1.0 - t, 3.0); + } void main() { vBrightness = aBrightness; @@ -1150,6 +1208,12 @@ const PARTICLE_VERTEX = /* glsl */` // === STAGGERING PER-PARTICLE === float stagger = fract(aSeed * 3.7) * 0.55; float localP = smoothstep(stagger, stagger + 0.45, p); + float travelP = clamp((p - stagger) / 0.45, 0.0, 1.0); + float entryEased = easeOutCubicGpu(travelP); + float exitEased = uExitProgress > 0.001 + ? (uExitProgress < 1.0 ? uExitProgress * uExitProgress : 1.0) + : 0.0; + vExitFade = 1.0 - exitEased; // === CRITICAL: GPU clampea gl_PointSize mínimo a 1px === if (localP < 0.001 || aVisible < 0.001) { @@ -1170,7 +1234,16 @@ const PARTICLE_VERTEX = /* glsl */` float jx = sin(uTime * 3.14 + aSeed * 17.3) * 0.04; float jy = sin(uTime * 2.71 + aSeed * 31.7) * 0.04; float jz = sin(uTime * 1.62 + aSeed * 47.1) * 0.04; - vec3 pos = position + vec3(jx, jy, jz); + float radialLen = length(position); + vec3 radial = radialLen > 0.001 ? normalize(position) : vec3(0.0, 0.0, 1.0); + vec3 tangent = cross(vec3(0.0, 1.0, 0.0), radial); + if (length(tangent) < 0.001) tangent = cross(vec3(1.0, 0.0, 0.0), radial); + tangent = normalize(tangent); + float swirlMag = sin(entryEased * 3.14159265) * radialLen * 0.12; + vec3 pos = mix(vec3(0.0), position, entryEased) + tangent * swirlMag; + if (travelP >= 1.0) pos += vec3(jx, jy, jz); + // Linear collapse toward singularity (no spiral — disorienting for viewer) + pos = mix(pos, vec3(0.0), exitEased); vec4 mvPos = modelViewMatrix * vec4(pos, 1.0); @@ -1189,6 +1262,7 @@ const PARTICLE_VERTEX = /* glsl */` float normalSize = uBaseSize * normalPulse * lensScale; float bridgeSize = uBridgeSize * bridgePulse * lensScale; float size = mix(normalSize, bridgeSize, bridgeBlend); + size *= 1.0 - exitEased * 0.85; // Reducir tamaño en repos densos (bridges preservan tamaño mínimo) float densitySize = mix(aDensity, max(aDensity, 0.5), bridgeBlend); @@ -1200,7 +1274,7 @@ const PARTICLE_VERTEX = /* glsl */` vGlow = mix(normalGlow, 1.0 + personalFlash * 0.6, bridgeBlend); gl_PointSize = size * (350.0 / -mvPos.z); - if (aVisible < 0.001) { gl_PointSize = 0.0; gl_Position = vec4(9999.0, 9999.0, 9999.0, 1.0); return; } + if (aVisible < 0.001 || size < 0.001) { gl_PointSize = 0.0; gl_Position = vec4(9999.0, 9999.0, 9999.0, 1.0); return; } gl_Position = projectionMatrix * mvPos; } ` @@ -1217,9 +1291,10 @@ const PARTICLE_FRAGMENT = /* glsl */` varying float vDensity; varying float vBridgeBlend; varying float vVisible; + varying float vExitFade; void main() { - float visFade = vVisible * vVisible; + float visFade = vVisible * vVisible * vExitFade; vec4 texel = texture2D(uMap, gl_PointCoord); // Bridge reveal: verde → dorado progresivo según vBridgeBlend vec3 baseCol = mix(uColorNormal, uColorBridge, vBridgeBlend); @@ -1294,6 +1369,7 @@ function QuantumParticles({ userNodes, positions, onHover, onClick, progressRef, uniforms: { uTime: { value: 0 }, uProgress: { value: 0 }, + uExitProgress: { value: 0 }, uBaseSize: { value: 3.5 }, uBridgeSize: { value: 5.0 }, uMap: { value: glowMap }, @@ -1349,6 +1425,7 @@ function QuantumParticles({ userNodes, positions, onHover, onClick, progressRef, useFrame(({ clock }) => { mat.uniforms.uTime.value = clock.getElapsedTime() mat.uniforms.uProgress.value = progressRef.current[progressKey] + mat.uniforms.uExitProgress.value = progressRef.current.exitProgress || 0 // Bridge reveal driven by entanglement progress - bridges "se descubren" con las conexiones mat.uniforms.uBridgeReveal.value = easeOutCubic(progressRef.current.entanglement || 0) @@ -1678,9 +1755,11 @@ function QuantumBonds({ repoUsers, positions, progressRef, progressKey, dimmed, useFrame(({ clock }) => { if (!mat) return const progress = progressRef.current[progressKey] + const exitProgress = progressRef.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.5) : 1 mat.uniforms.uTime.value = clock.getElapsedTime() mat.uniforms.uProgress.value = progress - mat.uniforms.uOpacity.value = (dimmed ? 0.008 : 0.35) * easeOutCubic(progress) + mat.uniforms.uOpacity.value = (dimmed ? 0.008 : 0.35) * easeOutCubic(progress) * exitFade // === Temporal visibility lerp === if (geo) { @@ -1849,8 +1928,10 @@ function OrgEntanglementArcs({ arcs, progressRef, progressKey, dimmed, collabHig if (!mat) return mat.uniforms.uTime.value = clock.getElapsedTime() const p = easeOutCubic(progressRef.current[progressKey]) + const exitProgress = progressRef.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.5) : 1 mat.uniforms.uProgress.value = p - mat.uniforms.uOpacity.value = p < 0.01 ? 0 : (collabHighlight ? 1.0 : (dimmed ? 0.015 : 0.7)) + mat.uniforms.uOpacity.value = (p < 0.01 ? 0 : (collabHighlight ? 1.0 : (dimmed ? 0.015 : 0.7))) * exitFade // Smooth boost transition const targetBoost = collabHighlight ? 1.0 : 0.0 mat.uniforms.uBoost.value += (targetBoost - mat.uniforms.uBoost.value) * 0.08 @@ -2041,9 +2122,11 @@ function EntanglementChannels({ connections, progressRef, progressKey, dimmed, h useFrame(({ clock }) => { if (!ref.current || connections.length === 0) return const p = easeOutCubic(progressRef.current[progressKey]) + const exitProgress = progressRef.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.5) : 1 const baseOpacity = p < 0.01 ? 0 : (collabHighlight ? 0.03 : (dimmed ? (highlightSet ? 0.4 : 0.05) : 0.55)) - shaderMat.uniforms.uOpacity.value = baseOpacity + shaderMat.uniforms.uOpacity.value = baseOpacity * exitFade shaderMat.uniforms.uTime.value = clock.getElapsedTime() shaderMat.uniforms.uDrawProgress.value = p @@ -2154,7 +2237,9 @@ function EnergyRings({ orgNodes, positions, progressRef, progressKey, highlightS useFrame(({ clock }) => { if (!meshRef.current) return const t = clock.getElapsedTime() - const p = easeOutCubic(progressRef.current[progressKey]) + const progress = progressRef.current[progressKey] + const p = easeOutCubic(progress) + const exitProgress = progressRef.current.exitProgress || 0 const hasSel = highlightSet !== null // === Temporal visibility lerp === @@ -2176,11 +2261,15 @@ function EnergyRings({ orgNodes, positions, progressRef, progressKey, highlightS const vis = visRef.current[i] const visFade = vis * vis // quadratic for smoother perceptual fade const phase = (t * 0.5 + i * 0.8) % 3 - const scale = (1 + phase * 6) * p + const stagger = n > 1 ? i / (n - 1) : 0 + const travelP = setEntryTravelPosition(tmpObj.position, pos, progress, stagger * 0.6, 0.5) + const exitEased = applyExitCollapse(tmpObj.position, exitProgress) + const breathing = travelP >= 1 ? 1 + Math.sin(t * 1.5 + i * 0.7) * 0.015 : 1 + let scale = (1 + phase * 6) * p * breathing + if (exitEased > 0) scale *= 1 - exitEased * 0.85 const dim = (hasSel && !highlightSet.has(orgNodes[i]?.id) ? 0.02 : 1) * (dimmed && !hasSel ? 0.03 : 1) - const fade = Math.max(0, 1 - phase / 3) * p * dim * visFade + const fade = Math.max(0, 1 - phase / 3) * p * dim * visFade * (1 - exitEased) - tmpObj.position.copy(pos) tmpObj.rotation.set(Math.PI / 2, 0, 0) tmpObj.scale.setScalar(fade < 0.001 ? 0 : scale) tmpObj.updateMatrix() @@ -2254,8 +2343,10 @@ function InterferenceField({ progressRef, progressKey }) { useFrame(({ clock }) => { const p = easeOutCubic(progressRef.current[progressKey]) + const exitProgress = progressRef.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.0) : 1 shaderMat.uniforms.uTime.value = clock.getElapsedTime() - shaderMat.uniforms.uOpacity.value = 0.06 * p + shaderMat.uniforms.uOpacity.value = 0.06 * p * exitFade }) return ( @@ -2305,6 +2396,8 @@ function QuantumGenesis({ progressRef, progressKey }) { useFrame((_, delta) => { const p = progressRef.current[progressKey] const done = p >= 1 + const exitProgress = progressRef.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.0) : 1 // Central flash sphere — bright, expanding fast, fading if (flashRef.current) { @@ -2314,7 +2407,7 @@ function QuantumGenesis({ progressRef, progressKey }) { flashRef.current.visible = true const s = easeOutCubic(Math.min(p * 3, 1)) * 22 flashRef.current.scale.setScalar(Math.max(s, 0.001)) - flashRef.current.material.opacity = Math.max(0, 1 - p * 1.5) * 0.90 + flashRef.current.material.opacity = Math.max(0, 1 - p * 1.5) * 0.90 * exitFade } } @@ -2326,7 +2419,7 @@ function QuantumGenesis({ progressRef, progressKey }) { innerGlowRef.current.visible = true const s = easeOutCubic(Math.min(p * 2, 1)) * 8 innerGlowRef.current.scale.setScalar(Math.max(s, 0.001)) - innerGlowRef.current.material.opacity = Math.max(0, 0.6 * (1 - p * 1.2)) + innerGlowRef.current.material.opacity = Math.max(0, 0.6 * (1 - p * 1.2)) * exitFade } } @@ -2338,7 +2431,7 @@ function QuantumGenesis({ progressRef, progressKey }) { wave1Ref.current.visible = true const s = easeOutCubic(p) * 500 wave1Ref.current.scale.setScalar(Math.max(s, 0.001)) - wave1Ref.current.material.opacity = Math.max(0, 0.25 * (1 - p)) + wave1Ref.current.material.opacity = Math.max(0, 0.25 * (1 - p)) * exitFade } } @@ -2351,7 +2444,7 @@ function QuantumGenesis({ progressRef, progressKey }) { wave2Ref.current.visible = true const s = easeOutCubic(wp) * 350 wave2Ref.current.scale.setScalar(Math.max(s, 0.001)) - wave2Ref.current.material.opacity = Math.max(0, 0.18 * (1 - wp)) + wave2Ref.current.material.opacity = Math.max(0, 0.18 * (1 - wp)) * exitFade } } @@ -2364,7 +2457,7 @@ function QuantumGenesis({ progressRef, progressKey }) { wave3Ref.current.visible = true const s = easeOutCubic(wp) * 250 wave3Ref.current.scale.setScalar(Math.max(s, 0.001)) - wave3Ref.current.material.opacity = Math.max(0, 0.15 * (1 - wp)) + wave3Ref.current.material.opacity = Math.max(0, 0.15 * (1 - wp)) * exitFade } } @@ -2393,9 +2486,9 @@ function QuantumGenesis({ progressRef, progressKey }) { } } pos.needsUpdate = true - burstRef.current.material.opacity = p > fadeStart + burstRef.current.material.opacity = (p > fadeStart ? Math.max(0, 0.7 * (1 - (p - fadeStart) / (1 - fadeStart))) - : Math.min(0.7, p * 10) + : Math.min(0.7, p * 10)) * exitFade } } }) @@ -2448,7 +2541,7 @@ const _norm = new THREE.Vector3() const _up = new THREE.Vector3() const _perp = new THREE.Vector3() -function TunnelingPulses({ connections, startAnimation, dimmed, activeNodeIdsRef }) { +function TunnelingPulses({ connections, startAnimation, dimmed, activeNodeIdsRef, exitProgressRef }) { const ref = useRef() const dummy = useMemo(() => new THREE.Object3D(), []) const PULSE_COUNT = Math.min(connections.length, 25) @@ -2487,8 +2580,10 @@ function TunnelingPulses({ connections, startAnimation, dimmed, activeNodeIdsRef return } // Fade-in gradual (empieza DESPUÉS del delay) + const exitProgress = exitProgressRef?.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.0) : 1 fadeRef.current = Math.min(fadeRef.current + delta * 0.5, 0.9) - mat.opacity = fadeRef.current * (dimmed ? 0.04 : 1) + mat.opacity = fadeRef.current * (dimmed ? 0.04 : 1) * exitFade pulseData.forEach((pulse, i) => { pulse.t += pulse.speed * delta @@ -2539,7 +2634,7 @@ function TunnelingPulses({ connections, startAnimation, dimmed, activeNodeIdsRef // DECOHERENCE SHOCKWAVES - ondas de decoherencia desde procesadores // ============================================================================ -function DecoherenceWaves({ orgNodes, positions, startAnimation, dimmed, activeNodeIdsRef }) { +function DecoherenceWaves({ orgNodes, positions, startAnimation, dimmed, activeNodeIdsRef, exitProgressRef }) { const MAX_WAVES = 3 const wavesRef = useRef([]) const waveState = useRef( @@ -2557,6 +2652,8 @@ function DecoherenceWaves({ orgNodes, positions, startAnimation, dimmed, activeN useFrame(({ clock }, delta) => { if (orgNodes.length === 0 || !startAnimation) return const t = clock.getElapsedTime() + const exitProgress = exitProgressRef?.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.0) : 1 nextWave.current -= delta if (nextWave.current <= 0) { nextWave.current = 8 + Math.random() * 6 @@ -2588,7 +2685,7 @@ function DecoherenceWaves({ orgNodes, positions, startAnimation, dimmed, activeN const p = wave.age / duration mesh.position.copy(wave.pos) mesh.scale.setScalar(easeOutCubic(p) * 90) - waveMats[i].opacity = 0.3 * Math.pow(1 - p, 2) * (dimmed ? 0.04 : 1) + waveMats[i].opacity = 0.3 * Math.pow(1 - p, 2) * (dimmed ? 0.04 : 1) * exitFade mesh.rotation.x = Math.PI / 2 mesh.rotation.z = t * 0.08 }) @@ -2662,7 +2759,7 @@ const COSMIC_RAY_FRAGMENT = ` } ` -function CosmicRays({ startAnimation, dimmed, shellImpactsRef }) { +function CosmicRays({ startAnimation, dimmed, shellImpactsRef, exitProgressRef }) { const MAX_RAYS = 8 const TRAIL_POINTS = 48 const SHELL_R = 3500 // radio de la Dyson Shell — barrera de impacto @@ -2776,13 +2873,15 @@ function CosmicRays({ startAnimation, dimmed, shellImpactsRef }) { timerRef.current += dt if (timerRef.current < DELAY) { shaderMat.uniforms.uGlobalOpacity.value = 0; return } + const exitProgress = exitProgressRef?.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.0) : 1 const targetOpacity = dimmed ? 0.15 : 1 fadeRef.current += (targetOpacity - fadeRef.current) * dt * 2 - shaderMat.uniforms.uGlobalOpacity.value = fadeRef.current + shaderMat.uniforms.uGlobalOpacity.value = fadeRef.current * exitFade const t = clock.getElapsedTime() // Spawn new rays periodically - if (raysRef.current.length < MAX_RAYS && Math.random() < dt * 1.8) { + if (exitFade > 0.001 && raysRef.current.length < MAX_RAYS && Math.random() < dt * 1.8) { raysRef.current.push(spawnRay()) } @@ -2959,7 +3058,7 @@ const DYSON_NODE_FRAGMENT = ` } ` -function ElectronOrbits({ startAnimation, dimmed, shellImpactsRef }) { +function ElectronOrbits({ startAnimation, dimmed, shellImpactsRef, exitProgressRef }) { const SHELL_R = 3500 // radio de la esfera — frontera masiva que envuelve todo el universo const SUBDIVISIONS = 4 // subdivisiones geodésicas (4 → ~1280 triángulos, más detalle a gran escala) const PULSE_COUNT = 12 // más pulsos para cubrir la red más grande @@ -3157,16 +3256,27 @@ function ElectronOrbits({ startAnimation, dimmed, shellImpactsRef }) { const target = dimmed ? 0.04 : 0.45 fadeRef.current += (target - fadeRef.current) * dt * 0.4 - edgeMat.uniforms.uOpacity.value = fadeRef.current - nodeMat.uniforms.uOpacity.value = fadeRef.current * 0.7 + // Exit collapse: Dyson shell compresses toward the singularity in sync + // with the matter. The whole envelope shrinks via group scale, and the + // shader opacity fades a bit slower so it's still faintly visible as a + // contracting halo around the implosion. + const exitProgress = exitProgressRef?.current?.exitProgress || 0 + const exitEased = exitProgress > 0.001 + ? (exitProgress < 1 ? exitProgress * exitProgress : 1) + : 0 + const exitFade = exitEased > 0 ? Math.max(0, 1 - exitEased * 1.6) : 1 + const exitScale = 1 - exitEased // 1 → 0 (sphere collapses to center) + edgeMat.uniforms.uOpacity.value = fadeRef.current * exitFade + nodeMat.uniforms.uOpacity.value = fadeRef.current * 0.7 * exitFade edgeMat.uniforms.uDimmed.value = dimmed ? 1.0 : 0.0 nodeMat.uniforms.uDimmed.value = dimmed ? 1.0 : 0.0 const t = clock.getElapsedTime() - // ─── Rotación ultra-lenta ─── + // ─── Rotación ultra-lenta + compresión por exit collapse ─── groupRef.current.rotation.y = t * ROTATION_SPEED groupRef.current.rotation.x = Math.sin(t * 0.003) * 0.06 + groupRef.current.scale.setScalar(exitScale) // ─── Reset energías de edges ─── const eArr = edgeEnergyAttr.array @@ -3395,7 +3505,7 @@ const FOAM_FRAGMENT = ` } ` -function QuantumFoam({ startAnimation, dimmed }) { +function QuantumFoam({ startAnimation, dimmed, exitProgressRef }) { const ref = useRef() const fadeRef = useRef(0) const timerRef = useRef(0) @@ -3443,7 +3553,9 @@ function QuantumFoam({ startAnimation, dimmed }) { const target = dimmed ? 0.06 : 0.5 fadeRef.current += (target - fadeRef.current) * dt * 0.7 - shaderMat.uniforms.uOpacity.value = fadeRef.current + const exitProgress = exitProgressRef?.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.0) : 1 + shaderMat.uniforms.uOpacity.value = fadeRef.current * exitFade shaderMat.uniforms.uTime.value = clock.getElapsedTime() }) @@ -3524,7 +3636,7 @@ const GRID_FRAGMENT = ` } ` -function InterferenceGrid({ startAnimation, dimmed }) { +function InterferenceGrid({ startAnimation, dimmed, exitProgressRef }) { const ref = useRef() const fadeRef = useRef(0) const timerRef = useRef(0) @@ -3584,7 +3696,9 @@ function InterferenceGrid({ startAnimation, dimmed }) { const target = dimmed ? 0.08 : 0.65 fadeRef.current += (target - fadeRef.current) * dt * 0.5 - shaderMat.uniforms.uOpacity.value = fadeRef.current + const exitProgress = exitProgressRef?.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.0) : 1 + shaderMat.uniforms.uOpacity.value = fadeRef.current * exitFade const t = clock.getElapsedTime() shaderMat.uniforms.uTime.value = t @@ -3635,7 +3749,7 @@ const GRAV_WAVE_FRAGMENT = ` } ` -function GravitationalWaves({ orgNodes, positions, startAnimation, dimmed, activeNodeIdsRef }) { +function GravitationalWaves({ orgNodes, positions, startAnimation, dimmed, activeNodeIdsRef, exitProgressRef }) { const MAX_WAVES = 4 const RING_SEGMENTS = 96 const meshRef = useRef() @@ -3697,11 +3811,22 @@ function GravitationalWaves({ orgNodes, positions, startAnimation, dimmed, activ const target = dimmed ? 0.08 : 0.85 fadeRef.current += (target - fadeRef.current) * dt * 1.0 - shaderMat.uniforms.uGlobalOpacity.value = fadeRef.current + // Black hole exit: the orgs emitting the pulses are themselves being + // absorbed → kill existing waves fast and stop spawning new ones. + const exitProgress = exitProgressRef?.current?.exitProgress || 0 + const isCollapsing = exitProgress > 0.02 + const exitFade = isCollapsing ? Math.max(0, 1 - exitProgress * 3.0) : 1 + shaderMat.uniforms.uGlobalOpacity.value = fadeRef.current * exitFade + if (isCollapsing) { + // Force existing waves to die quickly + for (const wave of wavesRef.current) { + if (wave) wave.maxAge = Math.min(wave.maxAge, wave.age + 0.2) + } + } - // Spawn waves periodically + // Spawn waves periodically — but NOT during exit nextSpawnRef.current -= dt - if (nextSpawnRef.current <= 0 && wavesRef.current.length < MAX_WAVES) { + if (!isCollapsing && nextSpawnRef.current <= 0 && wavesRef.current.length < MAX_WAVES) { const org = topOrgs[Math.floor(Math.random() * topOrgs.length)] const pos = positions[org.id] if (pos) { @@ -3824,7 +3949,7 @@ const HAWKING_FRAGMENT = ` } ` -function HawkingRadiation({ orgNodes, positions, startAnimation, dimmed, activeNodeIdsRef }) { +function HawkingRadiation({ orgNodes, positions, startAnimation, dimmed, activeNodeIdsRef, exitProgressRef }) { const ref = useRef() const PER_ORG = 18 const animTimer = useRef(0) @@ -3880,7 +4005,11 @@ function HawkingRadiation({ orgNodes, positions, startAnimation, dimmed, activeN const target = dimmed ? 0.03 : 0.7 opacityRef.current = Math.min(opacityRef.current + dt * 0.4, target) if (dimmed && opacityRef.current > target) opacityRef.current = Math.max(opacityRef.current - dt * 0.8, target) - shaderMat.uniforms.uOpacity.value = opacityRef.current + // Black hole exit: Hawking radiation around orgs fades fast since the + // emitters (orgs) are themselves being absorbed by the singularity. + const exitProgress = exitProgressRef?.current?.exitProgress || 0 + const exitFade = exitProgress > 0.01 ? Math.max(0, 1 - exitProgress * 2.5) : 1 + shaderMat.uniforms.uOpacity.value = opacityRef.current * exitFade shaderMat.uniforms.uTime.value = clock.getElapsedTime() // === Temporal visibility per org radiation === @@ -3938,8 +4067,36 @@ function HawkingRadiation({ orgNodes, positions, startAnimation, dimmed, activeN const _CAM_OFFSET_USER = new THREE.Vector3(4, 2.5, 4) const _CAM_OFFSET_REPO = new THREE.Vector3(10, 6, 10) const _CAM_OFFSET_ORG = new THREE.Vector3(18, 10, 18) +const _tmpGoal = new THREE.Vector3() +const _tmpDir = new THREE.Vector3() + +// Cinematic camera dolly used by CameraRig during Big Bang entry and +// Black Hole exit. Computes a RADIAL push (along the look direction) so +// the camera pulls back when the universe is condensed at the singularity. +// - Entry: starts at full 220-unit pullback, eases in via cubic to 0 as +// the slowest of the three node phases (processors/qubits/particles) +// reaches 1. Once settled, dolly is 0 and the camera sits at its base goal. +// - Exit: ramps from 0 to 280 with easeInExpo as the universe collapses, +// pulling the camera back so the implosion is framed instead of consuming it. +function dollyMagnitude(progressRef) { + const bp = progressRef?.current + if (!bp) return 0 + const entryP = Math.min(1, Math.max( + 0, + Math.max(bp.processors || 0, bp.qubits || 0, bp.particles || 0) + )) + const entryEased = entryP < 1 ? 1 - Math.pow(1 - entryP, 3) : 1 + const entryDolly = (1 - entryEased) * 380 + const exitP = bp.exitProgress || 0 + // Quadratic curve: visible motion from ~30% of the way through (instead of + // exponential which only kicks in at the end). Lets the user appreciate + // the camera pulling back while matter condenses to the singularity. + const exitEased = exitP > 0.001 ? exitP * exitP : 0 + const exitDolly = exitEased * 280 + return entryDolly + exitDolly +} -function CameraRig({ focusTarget, resetTrigger, selectedEntity, tourCameraRef, flyingRef }) { +function CameraRig({ focusTarget, resetTrigger, selectedEntity, tourCameraRef, flyingRef, progressRef }) { const controlsRef = useRef() const { camera, gl } = useThree() const target = useRef(new THREE.Vector3(0, 0, 0)) @@ -3949,6 +4106,10 @@ function CameraRig({ focusTarget, resetTrigger, selectedEntity, tourCameraRef, f // ref local. Antes era `flyingRef || useRef(false)` — condicional. const localFlyingRef = useRef(false) const flying = flyingRef || localFlyingRef + // Track previous dolly magnitude to detect rising edge (start of Big Bang + // entry). On rising edge we snap the camera to the far position so the + // big bang frames it instead of starting zoomed in catching up. + const prevDollyRef = useRef(0) useEffect(() => { if (focusTarget) { @@ -4042,14 +4203,42 @@ function CameraRig({ focusTarget, resetTrigger, selectedEntity, tourCameraRef, f controlsRef.current.update() return } - if (!flying.current) return + if (!flying.current && dollyMagnitude(progressRef) < 0.5) { + prevDollyRef.current = 0 + return + } // Damping exponencial independiente del frame rate: // a 60fps → t≈0.072, a 30fps → t≈0.139 (compensa frames largos automáticamente) const t = 1 - Math.exp(-4.5 * delta) controlsRef.current.target.lerp(target.current, t) - camera.position.lerp(goal.current, t) + + // Compute effective goal = base goal + radial dolly (cinematic zoom-out + // during Big Bang start and Black Hole end). The dolly is a radial push + // along the view direction so it never alters where the camera looks. + const dollyZ = dollyMagnitude(progressRef) + _tmpGoal.copy(goal.current) + if (dollyZ > 0.5) { + _tmpDir.copy(_tmpGoal).sub(target.current) + const baseDist = _tmpDir.length() + if (baseDist > 0.01) { + _tmpDir.normalize() + _tmpGoal.copy(target.current).addScaledVector(_tmpDir, baseDist + dollyZ) + } + } + + // Rising edge: previous frame had little/no dolly, this frame has a + // large one → Big Bang just started. Teleport the camera to the far + // position to avoid 200ms of "catching up" while nodes already explode. + const prev = prevDollyRef.current + if (prev < 20 && dollyZ > 120 && !tc?.active && !focusTarget) { + camera.position.copy(_tmpGoal) + } else { + camera.position.lerp(_tmpGoal, t) + } + prevDollyRef.current = dollyZ + controlsRef.current.update() - if (camera.position.distanceTo(goal.current) < 0.5) flying.current = false + if (camera.position.distanceTo(_tmpGoal) < 0.5 && dollyZ < 0.5) flying.current = false }) const tourActive = tourCameraRef?.current?.active @@ -4546,6 +4735,25 @@ function BuildDirector({ progressRef, startAnimation, replayKey }) { return null } +function ExitDirector({ progressRef, isExiting }) { + // 2 seconds is enough to see the matter visibly condense to the + // singularity BEFORE BlackHoleExit's wrapper blur/clip kicks in + // around t=0.32 of the 6.5s total (~2s). + const EXIT_DURATION = 2.0 + + useFrame((_, delta) => { + const bp = progressRef.current + if (!bp) return + if (isExiting) { + bp.exitProgress = Math.min(1, (bp.exitProgress || 0) + delta / EXIT_DURATION) + } else { + bp.exitProgress = 0 + } + }) + + return null +} + // ============================================================================ // LOD CONTROLLER - ajusta detalle por distancia de cámara // ============================================================================ @@ -5539,10 +5747,10 @@ function generateTourWaypoints(universeData, temporalRange, t) { return waypoints } -const QuantumScene = memo(function QuantumScene({ universeData, onSelect, setHovered, focusTarget, resetTrigger, selectedEntity, lensData, lensRevealDelay, searchHighlightSet, onSceneReady, startAnimation, showZones, entityFilter, disciplineHighlightSet, tunnelPath, favoriteIdSet, multiOrgColors, multiDiscColors, activeNodeIdsRef, tourCameraRef, tourBigBangReplay, simpleMode, cameraFlyingRef }) { +const QuantumScene = memo(function QuantumScene({ universeData, onSelect, setHovered, focusTarget, resetTrigger, selectedEntity, lensData, lensRevealDelay, searchHighlightSet, onSceneReady, startAnimation, showZones, entityFilter, disciplineHighlightSet, tunnelPath, favoriteIdSet, multiOrgColors, multiDiscColors, activeNodeIdsRef, tourCameraRef, tourBigBangReplay, simpleMode, cameraFlyingRef, isExiting }) { // === PROGRESO VIA REF - CERO re-renders de React desde el render-loop === // BuildDirector escribe directo a este ref; los componentes lo leen en useFrame - const bpRef = useRef({ genesis: 0, vacuum: 0, processors: 0, qubits: 0, particles: 0, entanglement: 0 }) + const bpRef = useRef({ genesis: 0, vacuum: 0, processors: 0, qubits: 0, particles: 0, entanglement: 0, exitProgress: 0 }) const shellImpactsRef = useRef([]) // eventos de impacto rayo-cósmico → Dyson Shell const pointerDownPos = useRef({ x: 0, y: 0, dragged: false }) const isDraggingRef = useRef(false) @@ -5756,10 +5964,11 @@ const QuantumScene = memo(function QuantumScene({ universeData, onSelect, setHov {/* Cámara */} - + {/* Director de animación - escribe directo al ref, sin setState */} + {/* Génesis cuántica - Big Bang inicial */} @@ -5802,16 +6011,16 @@ const QuantumScene = memo(function QuantumScene({ universeData, onSelect, setHov {/* Stage 8: Efectos de ambiente - radiación + decoherencia + tunelización */} {/* Pasan startAnimation para no hacerse visibles durante la carga */} - {mountStage >= 8 && showEffects && } - {mountStage >= 8 && showEffects && } - {mountStage >= 8 && showEffects && } + {mountStage >= 8 && showEffects && } + {mountStage >= 8 && showEffects && } + {mountStage >= 8 && showEffects && } {/* Stage 9: Efectos ambientales espectaculares */} - {mountStage >= 8 && showAmbient && } - {mountStage >= 8 && showAmbient && devFeatures.electronOrbits !== false && } - {mountStage >= 8 && showAmbient && } - {mountStage >= 8 && showAmbient && } - {mountStage >= 8 && showAmbient && } + {mountStage >= 8 && showAmbient && } + {mountStage >= 8 && showAmbient && devFeatures.electronOrbits !== false && } + {mountStage >= 8 && showAmbient && } + {mountStage >= 8 && showAmbient && } + {mountStage >= 8 && showAmbient && } {/* Highlight de selección - anillos rotando */} @@ -7337,7 +7546,7 @@ export default function UniverseView() { dpr={[1, 1.5]} raycaster={{ params: { Points: { threshold: 2 } } }} onCreated={({ gl }) => gl.setClearColor('#020208')} - frameloop={(animationStarted || tourActive) ? 'always' : 'demand'} + frameloop={(animationStarted || tourActive || isExiting) ? 'always' : 'demand'} > {/* Label flotante - fuera de QuantumScene para no causar re-renders de la escena 3D */}