Files

597 lines
27 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>NeonVerse — Warm & Playful (1file)</title>
<meta name="description" content="Warm glass UI over a Three.js galaxy background with playful actions. One single HTML file with embedded CSS & JS." />
<!-- Google Font -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap" rel="stylesheet">
<style>
/* =============================================
Warm, modern, glass + neon (sunset inspired)
============================================= */
:root {
/* Warm theme variables */
--bg: #0f0b09; /* deep warm charcoal */
--text: #fff7f2; /* soft ivory */
--muted: #ffd3b6; /* peachy muted */
--accent: #ff9d5c; /* tangerine */
--accent-2: #ff6b6b; /* coral */
--accent-3: #f7b267; /* apricot */
--glass: rgba(255, 236, 222, 0.08);
--glass-2: rgba(255, 236, 222, 0.14);
--blur: 18px;
--card-radius: 20px;
--shadow: 0 10px 40px rgba(0,0,0,.35);
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: 'Outfit', system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, 'Apple Color Emoji', 'Segoe UI Emoji';
color: var(--text);
background: radial-gradient(1200px 600px at 20% 20%, #2a1712 0%, #120b09 60%), var(--bg);
overflow-x: hidden;
}
/* Fullscreen WebGL background */
#bg-canvas { position: fixed; inset: 0; width: 100vw; height: 100vh; z-index: -2; display: block; }
/* Subtle scanline overlay */
.scanlines {
pointer-events: none; position: fixed; inset: 0; z-index: -1;
background-image: repeating-linear-gradient(
to bottom,
rgba(255,255,255,0.04),
rgba(255,255,255,0.04) 1px,
transparent 1px,
transparent 3px
);
mix-blend-mode: soft-light;
}
header {
max-width: 1200px; margin: 0 auto; padding: 56px 24px 20px;
display: flex; align-items: center; justify-content: space-between; gap: 16px;
}
.brand { display: flex; align-items: center; gap: 14px; text-decoration: none; color: var(--text); }
.brand .logo {
width: 44px; height: 44px; border-radius: 12px;
background: conic-gradient(from 210deg, var(--accent), var(--accent-2), #ffd166, var(--accent));
box-shadow: 0 0 30px rgba(255, 157, 92, .45);
position: relative; isolation: isolate;
}
.brand .logo::after {
content: ""; position: absolute; inset: 2px; border-radius: 10px;
background: radial-gradient(120px 120px at 30% 30%, rgba(255,255,255,.35), transparent 60%);
filter: blur(8px); z-index: -1;
}
.brand h1 { font-size: clamp(20px, 2.4vw, 28px); margin: 0; letter-spacing: 0.4px; }
.brand span { display: block; font-weight: 300; font-size: 12px; color: var(--muted); margin-top: -4px; }
.controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.btn {
--glow: var(--accent);
border: 1px solid rgba(255,255,255,.15);
color: var(--text);
background: linear-gradient(180deg, rgba(255,255,255,.06), rgba(255,255,255,.02));
padding: 12px 16px; border-radius: 12px; cursor: pointer;
backdrop-filter: blur(var(--blur));
box-shadow: var(--shadow), 0 0 0 0 rgba(255,157,92,.45) inset;
transition: box-shadow .3s ease, transform .15s ease;
font-weight: 600; letter-spacing: .3px;
}
.btn:hover { transform: translateY(-1px); box-shadow: var(--shadow), 0 0 0 2px rgba(255,157,92,.45) inset; }
.btn:active { transform: translateY(0); }
.btn-primary {
--glow: var(--accent-2);
border-color: rgba(255,107,107,.35);
box-shadow: var(--shadow), 0 0 0 0 rgba(255,107,107,.45) inset;
}
.btn-primary:hover { box-shadow: var(--shadow), 0 0 0 2px rgba(255,107,107,.45) inset; }
.toggle {
display: inline-flex; align-items: center; gap: 8px; cursor: pointer;
user-select: none; padding: 10px 12px; border-radius: 12px;
border: 1px solid rgba(255,255,255,.12);
background: rgba(255,255,255,.04);
backdrop-filter: blur(12px);
}
.toggle input { display: none; }
.toggle .dot {
width: 24px; height: 24px; border-radius: 999px; position: relative;
background: radial-gradient(circle at 30% 30%, #fff, #ffe9d6 35%, #ffc9a9 70%, #ff9d5c);
box-shadow: 0 0 16px rgba(255,157,92,.45);
transition: transform .3s ease;
}
.toggle input:checked + .dot { transform: translateX(4px) rotate(16deg) scale(1.02); }
.slider-wrap { display:flex; align-items:center; gap:10px; padding: 8px 10px; border-radius: 12px; border:1px solid rgba(255,255,255,.12); background: rgba(255,255,255,.04); backdrop-filter: blur(12px); }
.slider-wrap label { font-size: 12px; color: var(--muted); }
.slider-wrap input[type="range"] { accent-color: var(--accent); }
main { max-width: 1200px; margin: 0 auto; padding: 20px 24px 80px; }
.hero { display: grid; grid-template-columns: 1.1fr 1fr; gap: clamp(18px, 4vw, 40px); align-items: center; }
@media (max-width: 900px) { .hero { grid-template-columns: 1fr; } }
.hero h2 { font-size: clamp(32px, 6vw, 68px); line-height: 1.02; margin: 8px 0 10px; font-weight: 800; text-shadow: 0 10px 50px rgba(255,157,92,.25); }
.hero p { color: var(--muted); font-size: clamp(14px, 1.8vw, 18px); margin: 0 0 20px; }
.cta { display: flex; gap: 12px; flex-wrap: wrap; }
.card {
border: 1px solid rgba(255,255,255,.12);
background: linear-gradient(180deg, var(--glass), rgba(255,255,255,0.05));
backdrop-filter: blur(var(--blur));
border-radius: var(--card-radius);
box-shadow: var(--shadow);
padding: clamp(14px, 2.6vw, 24px);
transform-style: preserve-3d; perspective: 1200px;
transition: transform .2s ease, box-shadow .35s ease;
}
.card:hover { transform: translateY(-2px) rotateX(1deg) rotateY(-1deg); box-shadow: 0 20px 60px rgba(0,0,0,.45); }
.card h3 { margin: 0 0 8px; font-size: clamp(18px, 2.4vw, 22px); }
.card p { margin: 0; color: var(--muted); }
.grid { margin-top: 28px; display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
@media (max-width: 1000px) { .grid { grid-template-columns: repeat(2,1fr); } }
@media (max-width: 640px) { .grid { grid-template-columns: 1fr; } }
.icon {
width: 42px; height: 42px; border-radius: 12px;
display: grid; place-items: center; margin-bottom: 10px;
background: radial-gradient(120px 60px at 30% 20%, rgba(255,255,255,.35), transparent 60%),
linear-gradient(180deg, rgba(255,157,92,.25), rgba(255,107,107,.25));
border: 1px solid rgba(255,255,255,.18);
box-shadow: 0 6px 22px rgba(255,157,92,.30);
}
footer { opacity: .8; text-align: center; padding: 30px 0 40px; font-size: 13px; color: var(--muted); }
/* Decorative blurred blobs */
.blob { position: fixed; filter: blur(60px); opacity: .35; z-index: -1; pointer-events: none; width: 45vmax; height: 45vmax; border-radius: 999px; mix-blend-mode: screen; }
.blob.one { top: -10vmax; left: -10vmax; background: radial-gradient(circle at 30% 30%, rgba(255,157,92,.6), transparent 60%), radial-gradient(circle at 70% 70%, rgba(255,107,107,.6), transparent 60%); }
.blob.two { bottom: -15vmax; right: -10vmax; background: radial-gradient(circle at 30% 30%, rgba(247,178,103,.6), transparent 60%), radial-gradient(circle at 70% 70%, rgba(255,214,102,.6), transparent 60%); }
/* "Hyperdrive" visual feedback */
.hyper-outline { outline: 2px solid rgba(255,157,92,.0); outline-offset: 0; transition: outline-color .25s ease, outline-offset .25s ease; }
.hyper-outline.active { outline-color: rgba(255,157,92,.6); outline-offset: 8px; }
/* Hide the noscript hint unless JS is disabled */
noscript { position: fixed; inset: 0; background: #2b120e; color: #fff; display: grid; place-items: center; z-index: 999; padding: 32px; text-align: center; }
/* Utility */
.sep { width:1px; height:36px; background: rgba(255,255,255,.12); }
</style>
</head>
<body>
<canvas id="bg-canvas"></canvas>
<div class="scanlines"></div>
<div class="blob one"></div>
<div class="blob two"></div>
<header>
<a class="brand" href="#" aria-label="Inicio NeonVerse">
<div class="logo" aria-hidden="true"></div>
<div>
<h1>NeonVerse</h1>
<span>Un archivo · Todo el espectáculo</span>
</div>
</a>
<div class="controls" role="toolbar" aria-label="Controles de escena">
<label class="toggle" title="Modo Calma / Hiper">
<input id="modeToggle" type="checkbox" />
<div class="dot" aria-hidden="true"></div>
<span>Calma</span>
</label>
<div class="sep" aria-hidden="true"></div>
<button id="btnHyper" class="btn btn-primary" aria-pressed="false">Hiperimpulso ⚡️</button>
<button id="btnReset" class="btn">Reiniciar</button>
<button id="btnFocus" class="btn" title="Enfocar núcleo">Enfocar Núcleo</button>
<button id="btnComet" class="btn" title="Lanzar cometa">Cometa ✨</button>
<button id="btnAuto" class="btn" aria-pressed="false" title="Auto-órbita">Auto‑órbita</button>
<button id="btnScreenshot" class="btn" title="Capturar imagen">Capturar 📸</button>
<div class="slider-wrap" title="Ajusta la calidez global">
<label for="warmth">Calidez</label>
<input id="warmth" type="range" min="0" max="100" value="70" />
</div>
</div>
</header>
<main>
<section class="hero card hyper-outline" id="hero">
<div>
<h2>Futuro en una sola página</h2>
<p>Fondo 3D reactivo con <strong>Three.js</strong>, animaciones fluidas con <strong>GSAP</strong> y una UI de cristal con tonos cálidos. Todo en este único archivo HTML.</p>
<div class="cta">
<button class="btn btn-primary" id="btnPulse">Pulso Neón</button>
<button class="btn" id="btnParticles">Explosión de Estrellas</button>
<button class="btn" id="btnPalette">Aleatorizar Paleta</button>
</div>
</div>
<div class="card" style="min-height: 220px; display:flex; align-items:center; justify-content:center;">
<svg width="180" height="160" viewBox="0 0 180 160" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<defs>
<linearGradient id="grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ff9d5c"/>
<stop offset="100%" stop-color="#ff6b6b"/>
</linearGradient>
</defs>
<g opacity="0.95" stroke="url(#grad)" stroke-width="2">
<path d="M20 120 C 60 40, 120 40, 160 120"/>
<circle cx="90" cy="80" r="36"/>
<circle cx="90" cy="80" r="56" opacity=".45"/>
<circle cx="90" cy="80" r="70" opacity=".2"/>
</g>
</svg>
</div>
</section>
<section class="grid">
<article class="card">
<div class="icon">⚙️</div>
<h3>Interacción en tiempo real</h3>
<p>El campo de estrellas responde al ratón, a la calidez global y a la velocidad del modo <em>Hiper</em>.</p>
</article>
<article class="card">
<div class="icon">🧪</div>
<h3>Una sola dependencia</h3>
<p>Three.js para el 3D y GSAP para animar. Nada más. Ligero y espectacular.</p>
</article>
<article class="card">
<div class="icon">🎛️</div>
<h3>Controles extra</h3>
<p>Cometa, auto‑órbita, captura de pantalla, enfoque al núcleo y paletas aleatorias.</p>
</article>
</section>
<footer>
Hecho con ♥︎ · Three.js · GSAP · HTML/CSS · En un solo archivo · Paleta cálida
</footer>
</main>
<noscript>
<div>
<h2>Necesitas JavaScript</h2>
<p>Esta demo usa WebGL y animaciones. Activa JavaScript para verla en acción ✨</p>
</div>
</noscript>
<!-- External JS libraries (CDN). Still a single HTML file; no separate JS/CSS files used. -->
<script src="https://unpkg.com/three@0.160.0/build/three.min.js"></script>
<script src="https://unpkg.com/gsap@3.12.5/dist/gsap.min.js"></script>
<script>
// =============================================
// NeonVerse — Warm + Extra Actions (single file)
// (c) You — Free to tweak
// Comments in EN as requested
// =============================================
// ---- Three.js setup ----
const canvas = document.getElementById('bg-canvas');
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, preserveDrawingBuffer: true /* enable screenshot */ });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 3000);
camera.position.set(0, 0, 140);
// Starfield parameters
const STAR_COUNT = 3200; // tiny bump
const galaxy = new THREE.Group();
// Attributes
const positions = new Float32Array(STAR_COUNT * 3);
const colors = new Float32Array(STAR_COUNT * 3);
const baseHues = { warmA: 0.06 /* ~tangerine */ , warmB: 0.02 /* amber */ , warmC: 0.98 /* red wrap */ };
const color = new THREE.Color(); // helper
// Build spiral galaxy-like distribution
for (let i = 0; i < STAR_COUNT; i++) {
const radius = Math.pow(Math.random(), 0.6) * 380 + 20;
const arms = 3;
const branchAngle = ((i % arms) / arms) * Math.PI * 2;
const spin = radius * 0.035;
const randomness = (Math.random() - 0.5) * 28;
const angle = branchAngle + spin;
const x = Math.cos(angle) * radius + randomness;
const y = (Math.random() - 0.5) * 24 + Math.sin(angle * 3) * 3;
const z = Math.sin(angle) * radius + randomness;
const idx = i * 3;
positions[idx] = x; positions[idx + 1] = y; positions[idx + 2] = z;
// Warm gradient across radius (tangerine -> coral -> soft red)
const t = radius / 420;
const hue = baseHues.warmA + 0.15 * (1 - t); // small drift
color.setHSL(hue % 1, 0.85, 0.60);
colors[idx] = color.r; colors[idx + 1] = color.g; colors[idx + 2] = color.b;
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
size: 1.8,
vertexColors: true,
transparent: true,
opacity: 0.95,
depthWrite: false,
blending: THREE.AdditiveBlending
});
const points = new THREE.Points(geometry, material);
galaxy.add(points);
scene.add(galaxy);
// Subtle core glow (sprite)
const coreTexture = new THREE.CanvasTexture(generateRadialGlow(256));
const coreMaterial = new THREE.SpriteMaterial({ map: coreTexture, transparent: true, depthWrite: false, blending: THREE.AdditiveBlending, opacity: 0.9 });
const core = new THREE.Sprite(coreMaterial);
core.scale.set(120, 120, 1);
scene.add(core);
function generateRadialGlow(size) {
// Create a radial gradient canvas for the galaxy core (warm)
const c = document.createElement('canvas');
c.width = c.height = size;
const ctx = c.getContext('2d');
const g = ctx.createRadialGradient(size/2, size/2, 0, size/2, size/2, size/2);
g.addColorStop(0, 'rgba(255,245,235,0.95)');
g.addColorStop(0.25, 'rgba(255,157,92,0.85)');
g.addColorStop(0.55, 'rgba(255,107,107,0.55)');
g.addColorStop(1, 'rgba(0,0,0,0)');
ctx.fillStyle = g; ctx.fillRect(0,0,size,size);
return c;
}
// Interaction state
const state = {
rotSpeed: 0.0026,
hyper: false,
parallaxX: 0,
parallaxY: 0,
autoOrbit: false,
warmth: 0.7 // 0..1
};
// Parallax by mouse/touch
const lerp = (a, b, t) => a + (b - a) * t;
let targetX = 0, targetY = 0;
window.addEventListener('pointermove', (e) => {
const nx = (e.clientX / window.innerWidth) * 2 - 1;
const ny = (e.clientY / window.innerHeight) * 2 - 1;
targetX = nx * 0.6;
targetY = ny * 0.4;
}, { passive: true });
// Animation loop
const clock = new THREE.Clock();
function animate() {
const dt = clock.getDelta();
// Smooth parallax
state.parallaxX = lerp(state.parallaxX, targetX, 0.05);
state.parallaxY = lerp(state.parallaxY, targetY, 0.05);
camera.position.x = state.parallaxX * 20;
camera.position.y = -state.parallaxY * 12;
camera.lookAt(0,0,0);
// Galaxy rotation
galaxy.rotation.y += state.rotSpeed;
galaxy.rotation.x = lerp(galaxy.rotation.x, state.parallaxY * 0.25, 0.02);
// Auto orbit pushes slight z oscillation
if (state.autoOrbit) {
camera.position.z = 140 + Math.sin(performance.now()*0.0007)*6;
}
// Subtle material twinkle
const time = performance.now() * 0.001;
material.size = 1.6 + Math.sin(time * 1.7) * 0.2 + (state.hyper ? 0.6 : 0);
material.opacity = 0.9 + Math.sin(time * 0.9) * 0.08 + (state.hyper ? 0.05 : 0);
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
animate();
// Handle resize
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// ---- GSAP UI animations ----
const hero = document.getElementById('hero');
const btnHyper = document.getElementById('btnHyper');
const btnReset = document.getElementById('btnReset');
const btnPulse = document.getElementById('btnPulse');
const btnParticles = document.getElementById('btnParticles');
const btnPalette = document.getElementById('btnPalette');
const btnFocus = document.getElementById('btnFocus');
const btnComet = document.getElementById('btnComet');
const btnAuto = document.getElementById('btnAuto');
const btnScreenshot = document.getElementById('btnScreenshot');
const modeToggle = document.getElementById('modeToggle');
const warmthSlider = document.getElementById('warmth');
// Intro animation
gsap.from(['.brand', '.controls'], { y: -16, opacity: 0, duration: 0.9, ease: 'power2.out', stagger: 0.08 });
gsap.from('.hero .card', { y: 20, opacity: 0, duration: 1, ease: 'power3.out', stagger: 0.14, delay: 0.1 });
gsap.from('.grid .card', { y: 24, opacity: 0, duration: 0.9, ease: 'power3.out', stagger: 0.08, delay: 0.25 });
// Helper: pulse outline on hero card
function pulseOutline() {
hero.classList.add('active');
gsap.fromTo(hero, { outlineOffset: 0 }, { outlineOffset: 8, duration: 0.3, yoyo: true, repeat: 1, onComplete: () => hero.classList.remove('active') });
}
// ---- Actions ----
// Hyperdrive action — accelerate, cam kick, blob flash
btnHyper.addEventListener('click', () => {
state.hyper = true; btnHyper.setAttribute('aria-pressed', 'true');
pulseOutline();
gsap.to(state, { rotSpeed: 0.020, duration: 0.8, ease: 'power3.out' });
gsap.fromTo(camera.position, { z: 160 }, { z: 118, duration: 0.8, ease: 'power2.out', yoyo: true, repeat: 1 });
gsap.to('.blob', { opacity: 0.55, scale: 1.08, duration: 0.6, yoyo: true, repeat: 1, ease: 'sine.inOut' });
});
// Reset to calm
btnReset.addEventListener('click', () => {
state.hyper = false; btnHyper.setAttribute('aria-pressed', 'false');
gsap.to(state, { rotSpeed: 0.0026, duration: 1.2, ease: 'power3.inOut' });
gsap.to(camera.position, { x: 0, y: 0, z: 140, duration: 0.9, ease: 'power2.inOut' });
gsap.to('.blob', { opacity: 0.35, duration: 0.8 });
});
// Neon pulse — UI + core glow amp
btnPulse.addEventListener('click', () => {
pulseOutline();
gsap.fromTo(core.material, { opacity: 0.6 }, { opacity: 1, duration: 0.35, yoyo: true, repeat: 1, ease: 'sine.inOut' });
gsap.to('.brand .logo', { boxShadow: '0 0 60px rgba(255,157,92,.8)', duration: 0.35, yoyo: true, repeat: 1, ease: 'sine.inOut' });
});
// Particles burst — radial push on some vertices
btnParticles.addEventListener('click', () => {
const pos = geometry.attributes.position; const burst = [];
for (let i = 0; i < STAR_COUNT; i += Math.floor(Math.random()*30)+15) {
const idx = i * 3; const x = pos.array[idx], y = pos.array[idx+1], z = pos.array[idx+2];
const len = Math.max(60, Math.hypot(x, y, z)); const nx = x/len, ny = y/len, nz = z/len;
burst.push({ idx, dx: nx * 20, dy: ny * 20, dz: nz * 20 });
}
gsap.to(burst, {
duration: 0.5, ease: 'power3.out',
onUpdate: () => { burst.forEach(b => { pos.array[b.idx] += b.dx * 0.35; pos.array[b.idx+1] += b.dy * 0.35; pos.array[b.idx+2] += b.dz * 0.35; }); pos.needsUpdate = true; }
});
gsap.to({}, { duration: 0.7, delay: 0.35, ease: 'power2.in', onUpdate: () => { galaxy.rotation.y += 0.01; } });
pulseOutline();
});
// Focus core — ease camera closer to center and slowly orbit
btnFocus.addEventListener('click', () => {
pulseOutline();
gsap.to(camera.position, { x: 0, y: 0, z: 100, duration: 1.0, ease: 'power2.inOut' });
gsap.to(state, { rotSpeed: 0.006, duration: 0.8, ease: 'sine.inOut' });
});
// Toggle auto orbit
btnAuto.addEventListener('click', () => {
state.autoOrbit = !state.autoOrbit;
btnAuto.setAttribute('aria-pressed', String(state.autoOrbit));
pulseOutline();
});
// Launch a simple comet (sprite streak that crosses the field)
btnComet.addEventListener('click', () => {
// Create a small streak using a canvas texture
const tex = new THREE.CanvasTexture(makeComet(128));
const mat = new THREE.SpriteMaterial({ map: tex, transparent: true, blending: THREE.AdditiveBlending, depthWrite: false });
const comet = new THREE.Sprite(mat);
comet.scale.set(50, 20, 1); // streaky aspect
// Spawn from a random edge
const side = Math.random() < 0.5 ? -1 : 1;
comet.position.set(side * 300, (Math.random()-0.5)*120, -80 + Math.random()*160);
scene.add(comet);
// Animate across scene
gsap.to(comet.position, { x: -side * 300, y: comet.position.y + (Math.random()*80-40), z: comet.position.z + (Math.random()*60-30), duration: 1.4, ease: 'power2.out', onComplete: () => { scene.remove(comet); mat.dispose(); tex.dispose(); } });
gsap.to(comet.material, { opacity: 0, duration: 1.4, ease: 'power1.in' });
// Little shake
gsap.fromTo(camera.position, { x: camera.position.x + side*2 }, { x: camera.position.x, duration: 0.6, ease: 'power2.out' });
pulseOutline();
});
function makeComet(size) {
// Create a small canvas with a warm streak
const c = document.createElement('canvas'); c.width = c.height = size; const ctx = c.getContext('2d');
const g = ctx.createLinearGradient(0, size/2, size, size/2);
g.addColorStop(0, 'rgba(255,255,255,0.0)');
g.addColorStop(0.2, 'rgba(255,245,235,0.8)');
g.addColorStop(0.5, 'rgba(255,157,92,0.9)');
g.addColorStop(1, 'rgba(255,107,107,0.0)');
ctx.fillStyle = g; ctx.fillRect(0, size/2 - 6, size, 12);
// Glow
ctx.filter = 'blur(6px)'; ctx.fillRect(0, size/2 - 6, size, 12);
return c;
}
// Screenshot — download current canvas frame
btnScreenshot.addEventListener('click', () => {
// NOTE: preserveDrawingBuffer true on renderer
const data = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = data; a.download = `neonverse_${Date.now()}.png`;
document.body.appendChild(a); a.click(); a.remove();
});
// Palette randomizer — quickly re-hue vertex colors within warm range
btnPalette.addEventListener('click', () => {
const colAttr = geometry.getAttribute('color');
for (let i = 0; i < STAR_COUNT; i++) {
const idx = i*3; const t = Math.random()*0.6 + 0.2; // bias to center
const hue = (0.02 + Math.random()*0.12); // 0.02..0.14 warm hues
color.setHSL(hue, 0.85, 0.58 + Math.random()*0.08);
colAttr.array[idx] = color.r; colAttr.array[idx+1] = color.g; colAttr.array[idx+2] = color.b;
}
geometry.attributes.color.needsUpdate = true;
pulseOutline();
});
// Mode toggle: calm vs hyper bias
modeToggle.addEventListener('change', (e) => {
const on = e.target.checked; state.hyper = on; btnHyper.setAttribute('aria-pressed', String(on));
gsap.to(state, { rotSpeed: on ? 0.010 : 0.003, duration: 0.8, ease: 'power2.inOut' });
gsap.to('.blob', { opacity: on ? 0.5 : 0.35, duration: 0.6 });
});
// Warmth slider: remap palette towards warmer/cooler within warm range
warmthSlider.addEventListener('input', (e) => {
state.warmth = Number(e.target.value) / 100; // 0..1
const colAttr = geometry.getAttribute('color');
for (let i = 0; i < STAR_COUNT; i++) {
const idx = i*3; const t = i / STAR_COUNT;
const hue = 0.02 + state.warmth * 0.14; // 0.02..0.16
color.setHSL((hue + (1-t)*0.05)%1, 0.85, 0.58 + (1-t)*0.1);
colAttr.array[idx] = color.r; colAttr.array[idx+1] = color.g; colAttr.array[idx+2] = color.b;
}
geometry.attributes.color.needsUpdate = true;
// Also tint core slightly by adjusting material opacity pulse
gsap.to(core.material, { opacity: 0.8 + state.warmth*0.2, duration: 0.3, ease: 'sine.out' });
pulseOutline();
});
// Accessibility: key shortcuts
addEventListener('keydown', (e) => {
const k = e.key.toLowerCase();
if (k === 'h') btnHyper.click();
if (k === 'r') btnReset.click();
if (k === 'p') btnPulse.click();
if (k === 'o') btnAuto.click();
if (k === 'c') btnComet.click();
if (k === 's') btnScreenshot.click();
});
// Optional: reduce motion respect
const mq = matchMedia('(prefers-reduced-motion: reduce)');
if (mq.matches) { state.rotSpeed = 0.001; }
</script>
</body>
</html>