feat(RealityOverridePage): integrate Three.js for enhanced visuals and add background toggle functionality
This commit is contained in:
parent
50f4167a8b
commit
e87aeb6726
|
|
@ -69,11 +69,13 @@
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "2.0.3",
|
"sonner": "2.0.3",
|
||||||
"tailwind-merge": "3.2.0",
|
"tailwind-merge": "3.2.0",
|
||||||
|
"three": "^0.160.0",
|
||||||
"tw-animate-css": "1.3.8",
|
"tw-animate-css": "1.3.8",
|
||||||
"vaul": "1.1.2"
|
"vaul": "1.1.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "4.1.12",
|
"@tailwindcss/vite": "4.1.12",
|
||||||
|
"@types/three": "^0.160.0",
|
||||||
"@vitejs/plugin-react": "4.7.0",
|
"@vitejs/plugin-react": "4.7.0",
|
||||||
"tailwindcss": "4.1.12",
|
"tailwindcss": "4.1.12",
|
||||||
"vite": "^6.4.2"
|
"vite": "^6.4.2"
|
||||||
|
|
|
||||||
|
|
@ -1,185 +1,234 @@
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { ChevronLeft, Loader2, Wifi, WifiOff } from 'lucide-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) ───────────────────────────────
|
interface Msg { text: string; id: number; }
|
||||||
// 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]],
|
|
||||||
];
|
|
||||||
|
|
||||||
const S = 2; // sprite cell size in CSS pixels
|
// ── Shaders (verbatim from public/vortex/script.js) ──────────────────────────
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
const VERT_SHELL = `
|
||||||
interface Star { x:number; y:number; sz:number; ph:number; spd:number; col:[number,number,number]; }
|
varying vec2 vUv;
|
||||||
interface Rocket { x:number; y:number; vx:number; vy:number; rot:number; color:string; ff:number; tick:number; }
|
void main() {
|
||||||
interface Msg { text:string; id:number; }
|
vUv = uv;
|
||||||
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
const FRAG_SHELL = `
|
||||||
const STAR_PALETTE: [number,number,number][] = [
|
uniform float uTime;
|
||||||
[255,255,255],[255,240,210],[210,220,255],[255,210,210],[200,255,230],
|
uniform vec2 uGridSize;
|
||||||
];
|
uniform vec3 uColor;
|
||||||
const ROCKET_COLORS = ['#C0C0C0','#FF9090','#88AAFF','#AAFFAA','#FFDDAA','#FFAAFF'];
|
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 {
|
const VERT_TRAIL = `
|
||||||
return {
|
varying vec2 vUv;
|
||||||
x: Math.random()*W|0, y: Math.random()*H|0,
|
void main() {
|
||||||
sz: Math.random()<0.8 ? 1 : Math.random()<0.6 ? 2 : 3,
|
vUv = uv;
|
||||||
ph: Math.random()*Math.PI*2,
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
||||||
spd: Math.random()*0.04+0.005,
|
}
|
||||||
col: STAR_PALETTE[Math.random()*STAR_PALETTE.length|0],
|
`;
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mkRocket(W: number, H: number, initialX?: number): Rocket {
|
const FRAG_TRAIL = `
|
||||||
const dir = Math.random()<0.5 ? 1 : -1;
|
uniform float uTime;
|
||||||
const speed = Math.random()*1.5+0.8;
|
uniform vec3 uColor;
|
||||||
return {
|
uniform float uSpeed;
|
||||||
x: initialX ?? (dir>0 ? -20 : W+20),
|
uniform float uSpeedMult;
|
||||||
y: Math.random()*H*0.75+H*0.1,
|
uniform float uLength;
|
||||||
vx: dir*speed,
|
uniform float uLengthMult;
|
||||||
vy: (Math.random()-0.5)*0.5,
|
uniform float uOffset;
|
||||||
rot: dir>0 ? Math.PI/2 : -Math.PI/2,
|
uniform float uDirection;
|
||||||
color: ROCKET_COLORS[Math.random()*ROCKET_COLORS.length|0],
|
varying vec2 vUv;
|
||||||
ff: 0, tick: 0,
|
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);
|
||||||
function buildNebula(W: number, H: number): HTMLCanvasElement {
|
float alpha = 0.0;
|
||||||
const c = document.createElement('canvas');
|
if (uDirection > 0.0) {
|
||||||
c.width = W; c.height = H;
|
alpha = smoothstep(1.0 - finalLength, 1.0, progress);
|
||||||
const ctx = c.getContext('2d')!;
|
} else {
|
||||||
const blobs = [
|
alpha = 1.0 - smoothstep(0.0, finalLength, progress);
|
||||||
{ 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);
|
|
||||||
}
|
}
|
||||||
|
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 ─────────────────────────────────────────────────────────────────
|
// ── Component ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export default function RealityOverridePage({ onBack }: { onBack: () => void }) {
|
export default function RealityOverridePage({ onBack }: { onBack: () => void }) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const animRef = useRef<number>(0);
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
const starsRef = useRef<Star[]>([]);
|
const msgIdRef = useRef(0);
|
||||||
const rocketsRef = useRef<Rocket[]>([]);
|
const fadeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||||
const nebulaRef = useRef<HTMLCanvasElement | null>(null);
|
const pausedRef = useRef(false);
|
||||||
const wsRef = useRef<WebSocket | null>(null);
|
const lastTapRef = useRef(0);
|
||||||
const msgIdRef = useRef(0);
|
|
||||||
|
|
||||||
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
|
const [wsStatus, setWsStatus] = useState<'connecting' | 'connected' | 'disconnected'>('connecting');
|
||||||
const [currentCmd, setCurrentCmd] = useState<Msg | null>(null);
|
const [currentCmd, setCurrentCmd] = useState<Msg | null>(null);
|
||||||
const [history, setHistory] = useState<Msg[]>([]);
|
const [history, setHistory] = useState<Msg[]>([]);
|
||||||
const fadeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
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(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const container = containerRef.current;
|
||||||
if (!canvas) return;
|
if (!container) return;
|
||||||
let prevW = 0, prevH = 0;
|
|
||||||
|
|
||||||
const loop = () => {
|
const W = container.clientWidth || window.innerWidth;
|
||||||
const W = canvas.clientWidth, H = canvas.clientHeight;
|
const H = container.clientHeight || window.innerHeight;
|
||||||
if (!W || !H) { animRef.current = requestAnimationFrame(loop); return; }
|
|
||||||
|
|
||||||
if (W !== prevW || H !== prevH) {
|
// Scene
|
||||||
canvas.width = W; canvas.height = H;
|
const scene = new THREE.Scene();
|
||||||
prevW = W; prevH = H;
|
scene.fog = new THREE.FogExp2(0x000000, 0.12);
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d')!;
|
// Camera
|
||||||
ctx.imageSmoothingEnabled = false;
|
const camera = new THREE.PerspectiveCamera(90, W / H, 0.01, 100);
|
||||||
|
|
||||||
// Background
|
// Renderer
|
||||||
ctx.fillStyle = '#000008';
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||||
ctx.fillRect(0, 0, W, H);
|
renderer.setSize(W, H);
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio);
|
||||||
|
renderer.toneMapping = THREE.NoToneMapping;
|
||||||
|
container.appendChild(renderer.domElement);
|
||||||
|
|
||||||
// Nebula
|
// Post-processing
|
||||||
if (nebulaRef.current) ctx.drawImage(nebulaRef.current, 0, 0);
|
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
|
// Tokamak group
|
||||||
for (const st of starsRef.current) {
|
const tokamakGroup = new THREE.Group();
|
||||||
st.ph += st.spd;
|
scene.add(tokamakGroup);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rockets
|
// Grid shell
|
||||||
const PAD = 60;
|
const shellMaterial = new THREE.ShaderMaterial({
|
||||||
for (let i = rocketsRef.current.length-1; i >= 0; i--) {
|
vertexShader: VERT_SHELL,
|
||||||
const rk = rocketsRef.current[i];
|
fragmentShader: FRAG_SHELL,
|
||||||
rk.x += rk.vx; rk.y += rk.vy;
|
uniforms: {
|
||||||
rk.tick++;
|
uTime: { value: 0 },
|
||||||
if (rk.tick >= 8) { rk.tick = 0; rk.ff++; }
|
uGridSize: { value: new THREE.Vector2(600, 300) },
|
||||||
drawRocket(ctx, rk);
|
uColor: { value: new THREE.Color('#3388ff') },
|
||||||
if (rk.x < -PAD || rk.x > W+PAD || rk.y < -PAD || rk.y > H+PAD) {
|
uPulseSpeed: { value: 4.89 },
|
||||||
rocketsRef.current.splice(i, 1);
|
uGap: { value: 0.23 },
|
||||||
setTimeout(() => {
|
uOpacity: { value: 1.0 },
|
||||||
const cv = canvasRef.current;
|
},
|
||||||
if (cv) rocketsRef.current.push(mkRocket(cv.clientWidth, cv.clientHeight));
|
transparent: true,
|
||||||
}, Math.random()*4000+500);
|
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);
|
animate();
|
||||||
return () => cancelAnimationFrame(animRef.current);
|
|
||||||
|
// 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 ───────────────────────────────────────────────────────────────
|
// ── WebSocket ───────────────────────────────────────────────────────────────
|
||||||
|
|
@ -192,21 +241,17 @@ export default function RealityOverridePage({ onBack }: { onBack: () => void })
|
||||||
ws = new WebSocket(`ws://${window.location.hostname}/ws`);
|
ws = new WebSocket(`ws://${window.location.hostname}/ws`);
|
||||||
wsRef.current = ws;
|
wsRef.current = ws;
|
||||||
setWsStatus('connecting');
|
setWsStatus('connecting');
|
||||||
ws.onopen = () => { if (!cancelled) setWsStatus('connected'); };
|
ws.onopen = () => { if (!cancelled) setWsStatus('connected'); };
|
||||||
ws.onmessage = (e) => {
|
ws.onmessage = (e) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
const id = msgIdRef.current++;
|
const id = msgIdRef.current++;
|
||||||
const msg: Msg = { text: String(e.data), id };
|
const msg: Msg = { text: String(e.data), id };
|
||||||
setCurrentCmd(msg);
|
setCurrentCmd(msg);
|
||||||
setHistory(prev => [...prev.slice(-9), msg]);
|
setHistory(prev => [...prev.slice(-9), msg]);
|
||||||
clearTimeout(fadeTimer.current);
|
clearTimeout(fadeTimer.current);
|
||||||
fadeTimer.current = setTimeout(() => setCurrentCmd(null), 5000);
|
fadeTimer.current = setTimeout(() => setCurrentCmd(null), 5000);
|
||||||
};
|
};
|
||||||
ws.onclose = () => {
|
ws.onclose = () => { if (!cancelled) { setWsStatus('disconnected'); setTimeout(connect, 3000); } };
|
||||||
if (cancelled) return;
|
|
||||||
setWsStatus('disconnected');
|
|
||||||
setTimeout(connect, 3000);
|
|
||||||
};
|
|
||||||
ws.onerror = () => ws?.close();
|
ws.onerror = () => ws?.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -215,27 +260,14 @@ export default function RealityOverridePage({ onBack }: { onBack: () => void })
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full bg-black overflow-hidden">
|
<div
|
||||||
<canvas
|
className="relative w-full h-full bg-black overflow-hidden"
|
||||||
ref={canvasRef}
|
onDoubleClick={toggleBg}
|
||||||
className="absolute inset-0 w-full h-full"
|
onTouchEnd={handleTouchEnd}
|
||||||
style={{ imageRendering: 'pixelated' }}
|
>
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Back */}
|
{/* Three.js canvas mount */}
|
||||||
<button
|
<div ref={containerRef} className={`absolute inset-0 transition-opacity duration-500 ${bgVisible ? 'opacity-100' : 'opacity-0'}`} />
|
||||||
onClick={onBack}
|
|
||||||
className="absolute top-3 left-3 z-10 flex items-center gap-1 text-white/50 hover:text-white text-xs px-2 py-1 rounded bg-black/40 border border-white/10"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-3.5 h-3.5" /> Back
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* WS status */}
|
|
||||||
<div className="absolute top-3 right-3 z-10 flex items-center gap-1.5 text-xs px-2 py-1 rounded bg-black/40 border border-white/10">
|
|
||||||
{wsStatus === 'connecting' && <><Loader2 className="w-3.5 h-3.5 text-yellow-400 animate-spin" /><span className="text-yellow-400">Connecting…</span></>}
|
|
||||||
{wsStatus === 'connected' && <><Wifi className="w-3.5 h-3.5 text-green-400" /><span className="text-green-400">Connected</span></>}
|
|
||||||
{wsStatus === 'disconnected' && <><WifiOff className="w-3.5 h-3.5 text-red-400" /><span className="text-red-400">Reconnecting…</span></>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* CSS keyframe for command flash */}
|
{/* CSS keyframe for command flash */}
|
||||||
<style>{`
|
<style>{`
|
||||||
|
|
@ -248,6 +280,21 @@ export default function RealityOverridePage({ onBack }: { onBack: () => void })
|
||||||
.cmd-flash { animation: cmd-flash 5s ease-out forwards; }
|
.cmd-flash { animation: cmd-flash 5s ease-out forwards; }
|
||||||
`}</style>
|
`}</style>
|
||||||
|
|
||||||
|
{/* Back */}
|
||||||
|
<button
|
||||||
|
onClick={onBack}
|
||||||
|
className="absolute top-3 left-3 z-10 flex items-center gap-1 text-white/50 hover:text-white text-xs px-2 py-1 rounded bg-black/40 border border-white/10"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-3.5 h-3.5" /> Back
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* WS status */}
|
||||||
|
<div className="absolute top-3 right-3 z-10 flex items-center gap-1.5 text-xs px-2 py-1 rounded bg-black/40 border border-white/10">
|
||||||
|
{wsStatus === 'connecting' && <><Loader2 className="w-3.5 h-3.5 text-yellow-400 animate-spin" /><span className="text-yellow-400">Connecting…</span></>}
|
||||||
|
{wsStatus === 'connected' && <><Wifi className="w-3.5 h-3.5 text-green-400" /><span className="text-green-400">Connected</span></>}
|
||||||
|
{wsStatus === 'disconnected' && <><WifiOff className="w-3.5 h-3.5 text-red-400" /><span className="text-red-400">Reconnecting…</span></>}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Idle title — shown only before first message */}
|
{/* Idle title — shown only before first message */}
|
||||||
{history.length === 0 && (
|
{history.length === 0 && (
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-center z-10 pointer-events-none select-none">
|
<div className="absolute inset-0 flex flex-col items-center justify-center z-10 pointer-events-none select-none">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user