aethex-forge/client/components/Scene.tsx
sirpiglr a2805ea740 Add a fallback interface for when the 3D scene cannot be rendered
Updates Scene.tsx to include a fallback UI with realm selection when WebGL is not available, and fixes TypeScript errors in TitleBar.tsx.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 9203795e-937a-4306-b81d-b4d5c78c240e
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 893e1048-aa5f-4dea-8907-56a7ccad680b
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/7c94b7a0-29c7-4f2e-94ef-44b2153872b7/9203795e-937a-4306-b81d-b4d5c78c240e/c8LGG4t
Replit-Helium-Checkpoint-Created: true
2025-12-05 22:22:25 +00:00

452 lines
11 KiB
TypeScript

import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { Grid, OrbitControls, Text } from "@react-three/drei";
import * as THREE from "three";
import { MathUtils, Vector3 } from "three";
import React, { useMemo, useRef, useState, useEffect } from "react";
import { motion } from "framer-motion";
import { useNavigate, Link } from "react-router-dom";
type Gateway = {
label: string;
color: string;
route: string;
angle: number; // radians
};
const gateways: Gateway[] = [
{
label: "NEXUS",
color: "#a855f7",
route: "/dashboard/nexus",
angle: MathUtils.degToRad(-50),
},
{
label: "GAMEFORGE",
color: "#22c55e",
route: "/gameforge",
angle: MathUtils.degToRad(-20),
},
{
label: "FOUNDATION",
color: "#ef4444",
route: "/foundation",
angle: MathUtils.degToRad(0),
},
{
label: "LABS",
color: "#eab308",
route: "/dashboard/labs",
angle: MathUtils.degToRad(20),
},
{
label: "CORP",
color: "#3b82f6",
route: "/corp",
angle: MathUtils.degToRad(50),
},
];
function CoreCube() {
const ref = useRef<THREE.Mesh>(null);
useFrame((_, delta) => {
if (!ref.current) return;
ref.current.rotation.x += delta * 0.45;
ref.current.rotation.y += delta * 0.65;
});
return (
<mesh ref={ref}>
<boxGeometry args={[1.4, 1.4, 1.4]} />
<meshStandardMaterial
emissive="#38bdf8"
emissiveIntensity={1.5}
color="#0ea5e9"
metalness={0.6}
roughness={0.2}
/>
</mesh>
);
}
function GatewayMesh({
gateway,
onHover,
onClick,
isActive,
}: {
gateway: Gateway;
onHover: (label: string | null) => void;
onClick: (gw: Gateway) => void;
isActive: boolean;
}) {
const ref = useRef<THREE.Mesh>(null);
const glow = useRef<THREE.Mesh>(null);
const position = useMemo(() => {
const radius = 6;
return new Vector3(
Math.cos(gateway.angle) * radius,
1.5,
Math.sin(gateway.angle) * radius,
);
}, [gateway.angle]);
useFrame((_, delta) => {
if (ref.current) {
const targetScale = isActive ? 1.22 : 1;
ref.current.scale.lerp(
new Vector3(targetScale, targetScale, targetScale),
6 * delta,
);
}
if (glow.current) {
glow.current.rotation.y += delta * 0.8;
}
});
return (
<group position={position}>
<mesh
ref={ref}
onPointerOver={() => onHover(gateway.label)}
onPointerOut={() => onHover(null)}
onClick={() => onClick(gateway)}
>
<torusKnotGeometry args={[0.5, 0.15, 120, 16]} />
<meshStandardMaterial
color={gateway.color}
emissive={gateway.color}
emissiveIntensity={isActive ? 2.4 : 1.2}
roughness={0.25}
metalness={0.7}
/>
</mesh>
<mesh ref={glow} rotation={[Math.PI / 2, 0, 0]}>
<ringGeometry args={[0.9, 1.2, 64]} />
<meshBasicMaterial
color={gateway.color}
opacity={0.35}
transparent
side={2}
/>
</mesh>
<Text
position={[0, 1.5, 0]}
fontSize={0.6}
color={gateway.color}
anchorX="center"
anchorY="middle"
outlineColor="#0f172a"
outlineWidth={0.01}
>
{gateway.label}
</Text>
</group>
);
}
function CameraRig({ target }: { target: Gateway | null }) {
const { camera } = useThree();
const desired = useRef(new Vector3(0, 3, 12));
useFrame((_, delta) => {
if (target) {
const radius = 3.2;
desired.current.set(
Math.cos(target.angle) * radius,
2.5,
Math.sin(target.angle) * radius,
);
camera.lookAt(
Math.cos(target.angle) * 6,
1.5,
Math.sin(target.angle) * 6,
);
} else {
desired.current.set(0, 3, 12);
camera.lookAt(0, 0, 0);
}
camera.position.lerp(desired.current, 3 * delta);
});
return null;
}
function SceneContent() {
const [hovered, setHovered] = useState<string | null>(null);
const [selected, setSelected] = useState<Gateway | null>(null);
const navigate = useNavigate();
const handleClick = (gw: Gateway) => {
setSelected(gw);
setTimeout(() => navigate(gw.route), 550);
};
return (
<>
<color attach="background" args={["#030712"]} />
<fog attach="fog" args={["#030712", 15, 60]} />
<hemisphereLight
intensity={0.35}
color="#4f46e5"
groundColor="#0f172a"
/>
<spotLight
position={[5, 12, 5]}
intensity={1.5}
angle={0.4}
penumbra={0.7}
/>
<pointLight position={[-6, 6, -6]} intensity={1.2} color="#22d3ee" />
<Grid
args={[100, 100]}
sectionSize={2}
sectionThickness={0.2}
sectionColor="#0ea5e9"
cellSize={0.5}
cellThickness={0.1}
cellColor="#1e293b"
fadeDistance={30}
fadeStrength={3}
position={[0, -1, 0]}
infiniteGrid
/>
<CoreCube />
{gateways.map((gw) => (
<GatewayMesh
key={gw.label}
gateway={gw}
onHover={setHovered}
onClick={handleClick}
isActive={hovered === gw.label || selected?.label === gw.label}
/>
))}
<OrbitControls
enablePan={false}
minDistance={8}
maxDistance={18}
maxPolarAngle={Math.PI / 2.2}
minPolarAngle={Math.PI / 3}
enableDamping
dampingFactor={0.08}
/>
<CameraRig target={selected} />
</>
);
}
function FallbackUI() {
return (
<div
style={{
width: "100vw",
height: "100vh",
background: "linear-gradient(135deg, #030712 0%, #0f172a 50%, #1e1b4b 100%)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
fontFamily: "Inter, sans-serif",
color: "#e5e7eb",
padding: 20,
}}
>
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
style={{ textAlign: "center", marginBottom: 40 }}
>
<h1 style={{ fontSize: 48, fontWeight: 700, marginBottom: 8, letterSpacing: "0.05em" }}>
AeThex OS
</h1>
<p style={{ fontSize: 16, opacity: 0.7, letterSpacing: "0.1em" }}>
Select Your Realm
</p>
</motion.div>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
gap: 16,
maxWidth: 900,
width: "100%",
}}
>
{gateways.map((gw, i) => (
<motion.div
key={gw.label}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: i * 0.1 }}
>
<Link
to={gw.route}
style={{
display: "block",
padding: "24px 20px",
borderRadius: 16,
border: `2px solid ${gw.color}40`,
background: `${gw.color}10`,
textDecoration: "none",
color: "#e5e7eb",
textAlign: "center",
transition: "all 0.3s ease",
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = gw.color;
e.currentTarget.style.transform = "translateY(-4px)";
e.currentTarget.style.boxShadow = `0 8px 30px ${gw.color}30`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = `${gw.color}40`;
e.currentTarget.style.transform = "translateY(0)";
e.currentTarget.style.boxShadow = "none";
}}
>
<div
style={{
fontSize: 18,
fontWeight: 600,
letterSpacing: "0.1em",
color: gw.color,
}}
>
{gw.label}
</div>
</Link>
</motion.div>
))}
</div>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.6, delay: 0.5 }}
style={{ marginTop: 40 }}
>
<Link
to="/login"
style={{
padding: "12px 24px",
borderRadius: 10,
border: "1px solid #38bdf8",
background: "rgba(14, 165, 233, 0.12)",
color: "#e0f2fe",
fontWeight: 600,
textDecoration: "none",
backdropFilter: "blur(6px)",
}}
>
Connect Passport
</Link>
</motion.div>
</div>
);
}
function checkWebGLSupport(): boolean {
try {
const canvas = document.createElement("canvas");
const gl = canvas.getContext("webgl") || canvas.getContext("experimental-webgl");
return !!gl;
} catch {
return false;
}
}
export default function Scene() {
const [webglSupported, setWebglSupported] = useState<boolean | null>(null);
const [webglError, setWebglError] = useState(false);
useEffect(() => {
setWebglSupported(checkWebGLSupport());
}, []);
if (webglSupported === null) {
return (
<div style={{ width: "100vw", height: "100vh", background: "#030712" }} />
);
}
if (!webglSupported || webglError) {
return <FallbackUI />;
}
return (
<div
style={{
width: "100vw",
height: "100vh",
position: "relative",
background: "#030712",
}}
>
{/* HUD Overlay */}
<motion.div
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut" }}
style={{
position: "absolute",
top: 16,
left: 20,
color: "#e5e7eb",
fontFamily: "Inter, sans-serif",
letterSpacing: "0.08em",
fontSize: 14,
zIndex: 10,
userSelect: "none",
}}
>
AeThex OS v5.0
</motion.div>
<motion.button
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, ease: "easeOut", delay: 0.05 }}
style={{
position: "absolute",
top: 12,
right: 20,
padding: "10px 16px",
borderRadius: 10,
border: "1px solid #38bdf8",
background: "rgba(14, 165, 233, 0.12)",
color: "#e0f2fe",
fontWeight: 600,
fontFamily: "Inter, sans-serif",
cursor: "pointer",
zIndex: 10,
backdropFilter: "blur(6px)",
}}
onClick={() => alert("Connect Passport")}
>
Connect Passport
</motion.button>
<Canvas
shadows
camera={{ position: [0, 3, 12], fov: 50, near: 0.1, far: 100 }}
gl={{ antialias: true }}
onCreated={({ gl }) => {
gl.domElement.addEventListener("webglcontextlost", () => {
setWebglError(true);
});
}}
>
<SceneContent />
</Canvas>
</div>
);
}