diff --git a/package.json b/package.json index 48e261d..0c3087d 100644 --- a/package.json +++ b/package.json @@ -69,11 +69,13 @@ "remark-gfm": "^4.0.1", "sonner": "2.0.3", "tailwind-merge": "3.2.0", + "three": "^0.160.0", "tw-animate-css": "1.3.8", "vaul": "1.1.2" }, "devDependencies": { "@tailwindcss/vite": "4.1.12", + "@types/three": "^0.160.0", "@vitejs/plugin-react": "4.7.0", "tailwindcss": "4.1.12", "vite": "^6.4.2" diff --git a/src/app/components/RealityOverridePage.tsx b/src/app/components/RealityOverridePage.tsx index 9d825a3..75fdfec 100644 --- a/src/app/components/RealityOverridePage.tsx +++ b/src/app/components/RealityOverridePage.tsx @@ -1,185 +1,234 @@ import { useEffect, useRef, useState } from 'react'; import { ChevronLeft, Loader2, Wifi, WifiOff } from 'lucide-react'; +import * as THREE from 'three'; +import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js'; +import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js'; +import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js'; -// ── Rocket sprite (6w × 10h, nose pointing UP) ─────────────────────────────── -// Type: 0=hull, 1=trim, 2=porthole -const HULL: [number, number, 0 | 1 | 2][] = [ - [2,0,0],[3,0,0], - [1,1,0],[2,1,0],[3,1,0],[4,1,0], - [0,2,0],[1,2,0],[2,2,2],[3,2,2],[4,2,0],[5,2,0], - [0,3,0],[1,3,0],[2,3,0],[3,3,0],[4,3,0],[5,3,0], - [0,4,0],[1,4,0],[2,4,0],[3,4,0],[4,4,0],[5,4,0], - [1,5,0],[2,5,0],[3,5,0],[4,5,0], - [1,6,1],[2,6,0],[3,6,0],[4,6,1], -]; -// Alternating flame patterns -const FLAMES: [number, number][][] = [ - [[2,7],[3,7],[1,8],[2,8],[3,8],[4,8],[2,9],[3,9]], - [[2,7],[3,7],[2,8],[3,8],[1,9],[4,9]], -]; +interface Msg { text: string; id: number; } -const S = 2; // sprite cell size in CSS pixels +// ── Shaders (verbatim from public/vortex/script.js) ────────────────────────── -// ── Types ───────────────────────────────────────────────────────────────────── -interface Star { x:number; y:number; sz:number; ph:number; spd:number; col:[number,number,number]; } -interface Rocket { x:number; y:number; vx:number; vy:number; rot:number; color:string; ff:number; tick:number; } -interface Msg { text:string; id:number; } +const VERT_SHELL = ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } +`; -// ── Helpers ─────────────────────────────────────────────────────────────────── -const STAR_PALETTE: [number,number,number][] = [ - [255,255,255],[255,240,210],[210,220,255],[255,210,210],[200,255,230], -]; -const ROCKET_COLORS = ['#C0C0C0','#FF9090','#88AAFF','#AAFFAA','#FFDDAA','#FFAAFF']; +const FRAG_SHELL = ` + uniform float uTime; + uniform vec2 uGridSize; + uniform vec3 uColor; + uniform float uPulseSpeed; + uniform float uGap; + uniform float uOpacity; + varying vec2 vUv; + float random(vec2 st) { + return fract(sin(dot(st.xy, vec2(12.9898,78.233))) * 43758.5453123); + } + void main() { + vec2 gridUV = vUv * uGridSize; + vec2 cellID = floor(gridUV); + vec2 cellUV = fract(gridUV); + float padding = uGap; + float square = step(padding, cellUV.x) * step(padding, cellUV.y) + * step(cellUV.x, 1.0 - padding) * step(cellUV.y, 1.0 - padding); + float rnd = random(cellID); + float pulse = 0.5 + 0.5 * sin(uTime * uPulseSpeed + rnd * 6.28); + float alpha = square * (0.05 + 0.95 * pulse) * uOpacity; + gl_FragColor = vec4(uColor, alpha); + } +`; -function mkStar(W: number, H: number): Star { - return { - x: Math.random()*W|0, y: Math.random()*H|0, - sz: Math.random()<0.8 ? 1 : Math.random()<0.6 ? 2 : 3, - ph: Math.random()*Math.PI*2, - spd: Math.random()*0.04+0.005, - col: STAR_PALETTE[Math.random()*STAR_PALETTE.length|0], - }; -} +const VERT_TRAIL = ` + varying vec2 vUv; + void main() { + vUv = uv; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + } +`; -function mkRocket(W: number, H: number, initialX?: number): Rocket { - const dir = Math.random()<0.5 ? 1 : -1; - const speed = Math.random()*1.5+0.8; - return { - x: initialX ?? (dir>0 ? -20 : W+20), - y: Math.random()*H*0.75+H*0.1, - vx: dir*speed, - vy: (Math.random()-0.5)*0.5, - rot: dir>0 ? Math.PI/2 : -Math.PI/2, - color: ROCKET_COLORS[Math.random()*ROCKET_COLORS.length|0], - ff: 0, tick: 0, - }; -} - -function buildNebula(W: number, H: number): HTMLCanvasElement { - const c = document.createElement('canvas'); - c.width = W; c.height = H; - const ctx = c.getContext('2d')!; - const blobs = [ - { cx:W*.25, cy:H*.35, sx:W*.2, sy:H*.2, r:80, g:0, b:170 }, - { cx:W*.72, cy:H*.22, sx:W*.16, sy:H*.2, r:0, g:40, b:155 }, - { cx:W*.55, cy:H*.65, sx:W*.22, sy:H*.2, r:65, g:0, b:115 }, - { cx:W*.12, cy:H*.75, sx:W*.14, sy:H*.15, r:0, g:55, b:90 }, - { cx:W*.88, cy:H*.6, sx:W*.13, sy:H*.15, r:90, g:15, b:70 }, - ]; - for (const { cx,cy,sx,sy,r,g,b } of blobs) { - for (let i = 0; i < 600; i++) { - const u = Math.random()+1e-9, v = Math.random(); - const z1 = Math.sqrt(-2*Math.log(u))*Math.cos(2*Math.PI*v); - const z2 = Math.sqrt(-2*Math.log(u))*Math.sin(2*Math.PI*v); - const a = Math.min((0.04+Math.random()*0.12)/(1+(z1*z1+z2*z2)*0.12), 0.18); - if (a < 0.01) continue; - const sz = (Math.random()*4+2)|0; - ctx.fillStyle = `rgba(${r},${g},${b},${a})`; - ctx.fillRect((cx+z1*sx*.5)|0, (cy+z2*sy*.5)|0, sz, sz); +const FRAG_TRAIL = ` + uniform float uTime; + uniform vec3 uColor; + uniform float uSpeed; + uniform float uSpeedMult; + uniform float uLength; + uniform float uLengthMult; + uniform float uOffset; + uniform float uDirection; + varying vec2 vUv; + void main() { + float finalSpeed = uSpeed * uSpeedMult; + float progress = fract(vUv.x * 2.0 - (uTime * finalSpeed * uDirection) + uOffset); + float finalLength = clamp(uLength * uLengthMult, 0.01, 0.99); + float alpha = 0.0; + if (uDirection > 0.0) { + alpha = smoothstep(1.0 - finalLength, 1.0, progress); + } else { + alpha = 1.0 - smoothstep(0.0, finalLength, progress); } + alpha = pow(alpha, 3.0); + gl_FragColor = vec4(uColor, alpha); } - return c; -} - -// Pivot is sprite center (3*S, 5*S). rotate(±π/2) to aim left/right. -function drawRocket(ctx: CanvasRenderingContext2D, rk: Rocket) { - ctx.save(); - ctx.translate(rk.x, rk.y); - ctx.rotate(rk.rot); - ctx.translate(-3*S, -5*S); - for (const [dc, dr, t] of HULL) { - ctx.fillStyle = t===2 ? '#44DDFF' : t===1 ? '#555' : rk.color; - ctx.fillRect(dc*S, dr*S, S, S); - } - const flames = FLAMES[rk.ff%2]; - for (let i = 0; i < flames.length; i++) { - ctx.fillStyle = i%2===0 ? '#FF6600' : '#FFCC00'; - ctx.fillRect(flames[i][0]*S, flames[i][1]*S, S, S); - } - ctx.restore(); -} +`; // ── Component ───────────────────────────────────────────────────────────────── + export default function RealityOverridePage({ onBack }: { onBack: () => void }) { - const canvasRef = useRef(null); - const animRef = useRef(0); - const starsRef = useRef([]); - const rocketsRef = useRef([]); - const nebulaRef = useRef(null); - const wsRef = useRef(null); - const msgIdRef = useRef(0); + const containerRef = useRef(null); + const wsRef = useRef(null); + const msgIdRef = useRef(0); + const fadeTimer = useRef | undefined>(undefined); + const pausedRef = useRef(false); + const lastTapRef = useRef(0); - const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting'); - const [currentCmd, setCurrentCmd] = useState(null); - const [history, setHistory] = useState([]); - const fadeTimer = useRef | undefined>(undefined); + const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting'); + const [currentCmd, setCurrentCmd] = useState(null); + const [history, setHistory] = useState([]); + const [bgVisible, setBgVisible] = useState(true); - // ── Canvas animation loop ─────────────────────────────────────────────────── + const toggleBg = () => { + pausedRef.current = !pausedRef.current; + setBgVisible(!pausedRef.current); + }; + + const handleTouchEnd = () => { + const now = Date.now(); + if (now - lastTapRef.current < 300) { toggleBg(); lastTapRef.current = 0; } + else { lastTapRef.current = now; } + }; + + // ── Three.js vortex ───────────────────────────────────────────────────────── useEffect(() => { - const canvas = canvasRef.current; - if (!canvas) return; - let prevW = 0, prevH = 0; + const container = containerRef.current; + if (!container) return; - const loop = () => { - const W = canvas.clientWidth, H = canvas.clientHeight; - if (!W || !H) { animRef.current = requestAnimationFrame(loop); return; } + const W = container.clientWidth || window.innerWidth; + const H = container.clientHeight || window.innerHeight; - if (W !== prevW || H !== prevH) { - canvas.width = W; canvas.height = H; - prevW = W; prevH = H; - nebulaRef.current = buildNebula(W, H); - starsRef.current = Array.from({ length: 200 }, () => mkStar(W, H)); - // Spread initial rockets across the screen - rocketsRef.current = [0,1,2].map(i => mkRocket(W, H, (i+0.5)*W/3)); - } + // Scene + const scene = new THREE.Scene(); + scene.fog = new THREE.FogExp2(0x000000, 0.12); - const ctx = canvas.getContext('2d')!; - ctx.imageSmoothingEnabled = false; + // Camera + const camera = new THREE.PerspectiveCamera(90, W / H, 0.01, 100); - // Background - ctx.fillStyle = '#000008'; - ctx.fillRect(0, 0, W, H); + // Renderer + const renderer = new THREE.WebGLRenderer({ antialias: true }); + renderer.setSize(W, H); + renderer.setPixelRatio(window.devicePixelRatio); + renderer.toneMapping = THREE.NoToneMapping; + container.appendChild(renderer.domElement); - // Nebula - if (nebulaRef.current) ctx.drawImage(nebulaRef.current, 0, 0); + // Post-processing + const composer = new EffectComposer(renderer); + composer.addPass(new RenderPass(scene, camera)); + const bloomPass = new UnrealBloomPass(new THREE.Vector2(W, H), 1.5, 0.4, 0.85); + bloomPass.threshold = 0.1; + bloomPass.strength = 0.5; + bloomPass.radius = 0.6; + composer.addPass(bloomPass); - // Stars - for (const st of starsRef.current) { - st.ph += st.spd; - const br = Math.sin(st.ph)*0.4+0.6; - const [r,g,b] = st.col; - ctx.fillStyle = `rgba(${r},${g},${b},${br})`; - ctx.fillRect(st.x, st.y, st.sz, st.sz); - // Sparkle cross on bright large stars - if (st.sz >= 3 && br > 0.85) { - ctx.fillStyle = `rgba(${r},${g},${b},${br*0.35})`; - ctx.fillRect(st.x-2, st.y+1, st.sz+4, 1); - ctx.fillRect(st.x+1, st.y-2, 1, st.sz+4); - } - } + // Tokamak group + const tokamakGroup = new THREE.Group(); + scene.add(tokamakGroup); - // Rockets - const PAD = 60; - for (let i = rocketsRef.current.length-1; i >= 0; i--) { - const rk = rocketsRef.current[i]; - rk.x += rk.vx; rk.y += rk.vy; - rk.tick++; - if (rk.tick >= 8) { rk.tick = 0; rk.ff++; } - drawRocket(ctx, rk); - if (rk.x < -PAD || rk.x > W+PAD || rk.y < -PAD || rk.y > H+PAD) { - rocketsRef.current.splice(i, 1); - setTimeout(() => { - const cv = canvasRef.current; - if (cv) rocketsRef.current.push(mkRocket(cv.clientWidth, cv.clientHeight)); - }, Math.random()*4000+500); - } - } + // Grid shell + const shellMaterial = new THREE.ShaderMaterial({ + vertexShader: VERT_SHELL, + fragmentShader: FRAG_SHELL, + uniforms: { + uTime: { value: 0 }, + uGridSize: { value: new THREE.Vector2(600, 300) }, + uColor: { value: new THREE.Color('#3388ff') }, + uPulseSpeed: { value: 4.89 }, + uGap: { value: 0.23 }, + uOpacity: { value: 1.0 }, + }, + transparent: true, + side: THREE.DoubleSide, + depthWrite: false, + blending: THREE.AdditiveBlending, + }); + tokamakGroup.add(new THREE.Mesh(new THREE.TorusGeometry(10, 3.0, 60, 200), shellMaterial)); - animRef.current = requestAnimationFrame(loop); + // Trail lines + const trailsGroup = new THREE.Group(); + tokamakGroup.add(trailsGroup); + const trailMaterials: THREE.ShaderMaterial[] = []; + + for (let i = 0; i < 80; i++) { + const laneRadius = 7.5 + Math.random() * 5.0; + const mat = new THREE.ShaderMaterial({ + vertexShader: VERT_TRAIL, + fragmentShader: FRAG_TRAIL, + uniforms: { + uTime: { value: 0 }, + uColor: { value: new THREE.Color('#38cdff') }, + uSpeed: { value: 0.1 + Math.random() * 0.3 }, + uSpeedMult: { value: 1.0 }, + uLength: { value: 0.1 + Math.random() * 0.2 }, + uLengthMult: { value: 0.47 }, + uOffset: { value: Math.random() * 100.0 }, + uDirection: { value: 1.0 }, + }, + transparent: true, + blending: THREE.AdditiveBlending, + depthWrite: false, + }); + const mesh = new THREE.Mesh(new THREE.TorusGeometry(laneRadius, 0.005, 6, 120), mat); + mesh.rotation.x = (Math.random() - 0.5) * 0.1; + mesh.position.z = (Math.random() - 0.5) * 0.5; + trailsGroup.add(mesh); + trailMaterials.push(mat); + } + + // Animation loop — automated camera flight (no OrbitControls needed) + const clock = new THREE.Clock(); + let cameraAngle = 0; + let animId = 0; + + const animate = () => { + animId = requestAnimationFrame(animate); + const delta = clock.getDelta(); + const elapsed = clock.getElapsedTime(); + + cameraAngle += 0.3 * delta; + const r = 10; + camera.position.set(Math.cos(cameraAngle) * r, Math.sin(cameraAngle) * r, 0.5); + const la = cameraAngle + 0.1; + camera.lookAt(Math.cos(la) * r, Math.sin(la) * r, 0); + + if (pausedRef.current) return; + + shellMaterial.uniforms.uTime.value = elapsed; + trailMaterials.forEach(m => { m.uniforms.uTime.value = elapsed; }); + + composer.render(); }; - animRef.current = requestAnimationFrame(loop); - return () => cancelAnimationFrame(animRef.current); + animate(); + + // Resize observer keeps canvas filling its container + const ro = new ResizeObserver(() => { + const w = container.clientWidth; + const h = container.clientHeight; + camera.aspect = w / h; + camera.updateProjectionMatrix(); + renderer.setSize(w, h); + composer.setSize(w, h); + }); + ro.observe(container); + + return () => { + cancelAnimationFrame(animId); + ro.disconnect(); + renderer.dispose(); + if (container.contains(renderer.domElement)) container.removeChild(renderer.domElement); + }; }, []); // ── WebSocket ─────────────────────────────────────────────────────────────── @@ -192,21 +241,17 @@ export default function RealityOverridePage({ onBack }: { onBack: () => void }) ws = new WebSocket(`ws://${window.location.hostname}/ws`); wsRef.current = ws; setWsStatus('connecting'); - ws.onopen = () => { if (!cancelled) setWsStatus('connected'); }; + ws.onopen = () => { if (!cancelled) setWsStatus('connected'); }; ws.onmessage = (e) => { if (cancelled) return; - const id = msgIdRef.current++; + const id = msgIdRef.current++; const msg: Msg = { text: String(e.data), id }; setCurrentCmd(msg); setHistory(prev => [...prev.slice(-9), msg]); clearTimeout(fadeTimer.current); fadeTimer.current = setTimeout(() => setCurrentCmd(null), 5000); }; - ws.onclose = () => { - if (cancelled) return; - setWsStatus('disconnected'); - setTimeout(connect, 3000); - }; + ws.onclose = () => { if (!cancelled) { setWsStatus('disconnected'); setTimeout(connect, 3000); } }; ws.onerror = () => ws?.close(); }; @@ -215,27 +260,14 @@ export default function RealityOverridePage({ onBack }: { onBack: () => void }) }, []); return ( -
- +
- {/* Back */} - - - {/* WS status */} -
- {wsStatus === 'connecting' && <>Connecting…} - {wsStatus === 'connected' && <>Connected} - {wsStatus === 'disconnected' && <>Reconnecting…} -
+ {/* Three.js canvas mount */} +
{/* CSS keyframe for command flash */} + {/* Back */} + + + {/* WS status */} +
+ {wsStatus === 'connecting' && <>Connecting…} + {wsStatus === 'connected' && <>Connected} + {wsStatus === 'disconnected' && <>Reconnecting…} +
+ {/* Idle title — shown only before first message */} {history.length === 0 && (