All checks were successful
Publish Docker Image / Publish Docker Image (push) Successful in 5s
449 lines
15 KiB
TypeScript
449 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { SpotLight } from "three";
|
|
import {
|
|
useGLTF,
|
|
PerspectiveCamera,
|
|
Environment,
|
|
Text,
|
|
Html,
|
|
type PerspectiveCameraProps,
|
|
useProgress,
|
|
} from "@react-three/drei";
|
|
import {
|
|
animated as threeAnimated,
|
|
useSpring as useThreeSpring,
|
|
} from "@react-spring/three";
|
|
import {
|
|
animated as webAnimated,
|
|
useSpring as useWebSpring,
|
|
} from "@react-spring/web";
|
|
import {
|
|
useEffect,
|
|
useMemo,
|
|
useState,
|
|
useRef,
|
|
Suspense,
|
|
useCallback,
|
|
type JSX,
|
|
type Dispatch,
|
|
type SetStateAction,
|
|
} from "react";
|
|
import Stack from "@/util/stack";
|
|
import { Canvas, useFrame } from "@react-three/fiber";
|
|
import { mdiArrowLeftBold } from "@mdi/js";
|
|
import { Icon } from "@mdi/react";
|
|
import { PDF } from "../util/PDF";
|
|
import { CellphoneUI } from "../ui/CellphoneUI";
|
|
import { PCUI } from "../ui/PCUI";
|
|
import { useConfig } from "../hooks/useConfig";
|
|
import { GLTFResult } from "@/types/scene";
|
|
import { hashtoView, views, View } from "./consts";
|
|
import { StaticMeshes } from "./StaticMeshes";
|
|
import { Buttons } from "./Buttons";
|
|
import { Credits } from "../ui/Credits";
|
|
import { isWebGL2Available } from "@react-three/drei";
|
|
import { useWindowSize } from "../hooks/useWindowSize";
|
|
import { Info } from "../util/Info";
|
|
|
|
const AnimatedCam = threeAnimated(PerspectiveCamera);
|
|
const AnimatedButton = webAnimated.button as React.ComponentType<
|
|
React.ButtonHTMLAttributes<HTMLButtonElement> & { children?: React.ReactNode }
|
|
>;
|
|
|
|
function FpsUpdater({
|
|
onUpdate,
|
|
}: {
|
|
onUpdate: Dispatch<SetStateAction<number>>;
|
|
}) {
|
|
const fpsRef = useRef([0]);
|
|
useFrame((_, delta) => fpsRef.current.push(1 / delta));
|
|
|
|
useEffect(() => {
|
|
const id = setInterval(() => {
|
|
const avg =
|
|
fpsRef.current.reduce((a, b) => a + b, 0) / fpsRef.current.length;
|
|
fpsRef.current = [];
|
|
onUpdate(avg);
|
|
}, 1000);
|
|
|
|
return () => clearInterval(id);
|
|
}, [onUpdate]);
|
|
|
|
return null;
|
|
}
|
|
|
|
export function Scene(props: JSX.IntrinsicElements["group"]) {
|
|
const { nodes, materials } = useGLTF("/scene.glb") as unknown as GLTFResult;
|
|
const { progress } = useProgress();
|
|
const { width, height } = useWindowSize();
|
|
const config = useConfig();
|
|
|
|
// States
|
|
const [fps, setFps] = useState(0);
|
|
const [pendingView, setPendingView] = useState<View | null>(null);
|
|
const [backHovered, setBackHovered] = useState(false);
|
|
const [backClicked, setBackClicked] = useState(false);
|
|
const [menuTraversal, setMenuTraversal] = useState<Stack<View>>(() => {
|
|
const s = new Stack<View>();
|
|
s.push(View.MainView);
|
|
const hash = window.location.hash.slice(1);
|
|
if (hash in hashtoView && hashtoView[hash] !== View.MainView) {
|
|
s.push(hashtoView[hash]);
|
|
}
|
|
return s;
|
|
});
|
|
|
|
// Variables
|
|
const currentView = menuTraversal.peek() ?? View.MainView;
|
|
const view = views[currentView];
|
|
|
|
// Refs
|
|
const portalRef = useRef<HTMLElement | null>(null);
|
|
|
|
// Callbacks
|
|
const goPreviousView = useCallback(() => {
|
|
setMenuTraversal((prev) => {
|
|
if (prev.size() <= 1) return prev;
|
|
const next = prev.clone();
|
|
next.pop();
|
|
setPendingView(next.peek()!);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// Memos
|
|
const spotlight = useMemo(() => {
|
|
const light = new SpotLight("#ffffff");
|
|
light.intensity = 175;
|
|
light.penumbra = 0.25;
|
|
light.castShadow = true;
|
|
light.shadow.mapSize.set(2048, 2048);
|
|
light.shadow.bias = -0.000025;
|
|
return light;
|
|
}, []);
|
|
|
|
// Effects
|
|
useEffect(() => {
|
|
portalRef.current = document.body!;
|
|
}, []);
|
|
|
|
// Springs
|
|
const cameraSpring = useThreeSpring<PerspectiveCameraProps>({
|
|
...view,
|
|
config: {
|
|
precision: 0.0001,
|
|
friction: 80,
|
|
mass: 10,
|
|
clamp: true,
|
|
},
|
|
});
|
|
|
|
const { backScale, backOpacity } = useWebSpring({
|
|
backScale: backHovered ? 1.25 : 1,
|
|
backOpacity: menuTraversal.size() > 1 ? 1 : 0,
|
|
onRest: () => backClicked && setBackClicked(false),
|
|
});
|
|
|
|
// Scene
|
|
return (
|
|
<main className="h-screen w-screen overscroll-none touch-none bg-[#1b1b1b]">
|
|
{isWebGL2Available() ? (
|
|
width >= 1.5 * height ? (
|
|
<>
|
|
<div className="pointer-events-none fixed z-[999999999] size-full">
|
|
<AnimatedButton
|
|
style={{
|
|
transform: backScale.to(
|
|
(s) => `scale(${s})`
|
|
) as unknown as string,
|
|
opacity: backOpacity as unknown as number,
|
|
pointerEvents: menuTraversal.size() > 1 ? "auto" : "none",
|
|
cursor: backHovered ? "pointer" : "auto",
|
|
}}
|
|
className="m-4"
|
|
onMouseEnter={() => setBackHovered(true)}
|
|
onMouseLeave={() => setBackHovered(false)}
|
|
onClick={() => {
|
|
setBackClicked(true);
|
|
goPreviousView();
|
|
}}
|
|
>
|
|
<Icon
|
|
path={mdiArrowLeftBold}
|
|
size={2}
|
|
color="white"
|
|
className="stroke-[0.75] stroke-black"
|
|
/>
|
|
</AnimatedButton>
|
|
<div className="min-w-12 text-white bottom-0 right-0 absolute m-3 p-1 text-xs text-right text-stroke-2 text-stroke-black paint-sfm">
|
|
<Info />
|
|
{isNaN(fps) ? "-" : fps.toFixed(0)} fps
|
|
</div>
|
|
</div>
|
|
<Canvas
|
|
shadows
|
|
gl={{ localClippingEnabled: true, antialias: true, alpha: true }}
|
|
>
|
|
<Suspense
|
|
fallback={
|
|
<Html fullscreen>
|
|
<div className="pt-10 w-screen h-screen flex flex-col space-y-6 justify-center items-center text-white text-4xl pointer-events-none">
|
|
<p>Loading...</p>
|
|
<div className="w-48 bg-neutral-700 h-4 rounded-lg">
|
|
<div
|
|
className="h-full rounded-lg bg-neutral-100"
|
|
style={{
|
|
width: `${progress.toFixed(0)}%`,
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Html>
|
|
}
|
|
>
|
|
<group {...props} dispose={null}>
|
|
<FpsUpdater onUpdate={setFps} />
|
|
<Environment preset="apartment" />
|
|
<AnimatedCam makeDefault {...cameraSpring} />
|
|
<Buttons
|
|
menuTraversal={menuTraversal}
|
|
setMenuTraversal={setMenuTraversal}
|
|
pendingView={pendingView}
|
|
setPendingView={setPendingView}
|
|
goPreviousView={goPreviousView}
|
|
currentView={currentView}
|
|
/>
|
|
<group>
|
|
<primitive
|
|
object={spotlight}
|
|
position={[-1.915, 20, 1.0925]}
|
|
/>
|
|
<primitive
|
|
object={spotlight.target}
|
|
position={[-1.915, 0, 1.0925]}
|
|
/>
|
|
</group>
|
|
<group
|
|
position={[2.50415, 0.12973, 3.47808]}
|
|
rotation={[0, -Math.PI / 2, 0]}
|
|
scale={0.01}
|
|
>
|
|
<mesh
|
|
castShadow
|
|
receiveShadow
|
|
geometry={nodes.Monitor.geometry}
|
|
material={materials.PlasticMaterial}
|
|
position={[-175, 415.25, -160]}
|
|
>
|
|
<Html
|
|
transform
|
|
receiveShadow
|
|
castShadow
|
|
pointerEvents={
|
|
currentView === View.PCView ? "auto" : "none"
|
|
}
|
|
occlude="blending"
|
|
className="w-[1452px] h-[810px] bg-[#0e1838]"
|
|
scale={10}
|
|
position={[0, 170, 0.25]}
|
|
raycast={
|
|
currentView === View.DesktopView
|
|
? () => null
|
|
: undefined
|
|
}
|
|
distanceFactor={10}
|
|
>
|
|
<div className="size-full">
|
|
<PCUI />
|
|
</div>
|
|
</Html>
|
|
</mesh>
|
|
</group>
|
|
<group
|
|
position={[4.105, 4.2825, -2.085]}
|
|
rotation={[0, -Math.PI / 2, 0]}
|
|
>
|
|
<mesh
|
|
castShadow
|
|
receiveShadow
|
|
geometry={nodes.SecondaryMonitorMesh.geometry}
|
|
material={materials.SecondaryMonitorMaterial}
|
|
scale={0.01}
|
|
>
|
|
<Credits currentView={currentView} />
|
|
</mesh>
|
|
</group>
|
|
<group
|
|
position={[3.81024, 3.85444, 6.13972]}
|
|
rotation={[-Math.PI, -0.44331, -Math.PI]}
|
|
scale={0.035}
|
|
>
|
|
<mesh
|
|
castShadow
|
|
receiveShadow
|
|
geometry={nodes.Paper3.geometry}
|
|
material={materials.PaperMaterial}
|
|
>
|
|
<Html
|
|
transform
|
|
receiveShadow
|
|
castShadow
|
|
pointerEvents={
|
|
currentView === View.ResumeView ? "auto" : "none"
|
|
}
|
|
occlude="blending"
|
|
className="w-[800px] h-[1074px] bg-white"
|
|
scale={15}
|
|
position={[28.7765, 12.431875, 3.725]}
|
|
rotation={[-Math.PI / 2, 0, (1 * Math.PI) / 9]}
|
|
raycast={
|
|
currentView === View.ResumeView
|
|
? () => null
|
|
: undefined
|
|
}
|
|
distanceFactor={1}
|
|
>
|
|
<PDF url="/config/resume.pdf" />
|
|
</Html>
|
|
</mesh>
|
|
</group>
|
|
<group
|
|
position={[2.58559, 4.69855, 3.29056]}
|
|
rotation={[-Math.PI, -0.43633, -Math.PI]}
|
|
scale={0.02}
|
|
>
|
|
<mesh
|
|
castShadow
|
|
receiveShadow
|
|
geometry={nodes.CellphoneMesh.geometry}
|
|
material={materials.CellphoneMaterial}
|
|
position={[-102.64982, -21.45192, 247.01788]}
|
|
>
|
|
<Html
|
|
transform
|
|
receiveShadow
|
|
castShadow
|
|
pointerEvents={
|
|
currentView === View.CellphoneView ? "auto" : "none"
|
|
}
|
|
occlude="blending"
|
|
className="w-[1008px] h-[1614px] bg-blue-100"
|
|
position={[0, 2.8, 0]}
|
|
rotation={[-Math.PI / 2, 0, Math.PI / 2]}
|
|
raycast={
|
|
currentView === View.CellphoneView
|
|
? () => null
|
|
: undefined
|
|
}
|
|
style={{
|
|
backgroundImage: "url('/assets/cellphone-bg.webp')",
|
|
backgroundColor: "black",
|
|
}}
|
|
distanceFactor={10}
|
|
>
|
|
<CellphoneUI />
|
|
</Html>
|
|
</mesh>
|
|
</group>
|
|
<Text
|
|
position={[-2.415, 12.5, -6]}
|
|
font="/assets/inter-bold.ttf"
|
|
color="black"
|
|
fontSize={3}
|
|
>
|
|
{config.name[0]}
|
|
</Text>
|
|
<Text
|
|
position={[5.185, 12.5, 1.592]}
|
|
font="/assets/inter-bold.ttf"
|
|
rotation={[0, -Math.PI / 2, 0]}
|
|
color="black"
|
|
fontSize={3}
|
|
>
|
|
{config.name[1]}
|
|
</Text>
|
|
{config.status && (
|
|
<Text
|
|
position={[5.185, 10, 1.592]}
|
|
font="/assets/inter.ttf"
|
|
rotation={[0, -Math.PI / 2, 0]}
|
|
color="black"
|
|
fontSize={0.75}
|
|
>
|
|
{config.status}
|
|
</Text>
|
|
)}
|
|
{config.location && (
|
|
<Text
|
|
position={[5.185, 8.5, 1.592]}
|
|
font="/assets/inter.ttf"
|
|
rotation={[0, -Math.PI / 2, 0]}
|
|
color="black"
|
|
fontSize={0.75}
|
|
>
|
|
📍 {config.location}
|
|
</Text>
|
|
)}
|
|
<StaticMeshes nodes={nodes} materials={materials} />
|
|
</group>
|
|
</Suspense>
|
|
</Canvas>
|
|
</>
|
|
) : (
|
|
<div
|
|
className={`${
|
|
config.fallbackUrl ? "pt-12" : ""
|
|
} flex flex-col space-y-6 w-screen h-screen text-center justify-center items-center text-white text-4xl`}
|
|
>
|
|
<p>Screen too small, please rotate</p>
|
|
{config.fallbackUrl && (
|
|
<p className="text-base">
|
|
or visit the{" "}
|
|
<a
|
|
href={config.fallbackUrl}
|
|
target="_blank"
|
|
className="text-blue-500 hover:underline"
|
|
>
|
|
fallback
|
|
</a>
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
) : (
|
|
<div
|
|
className={`${
|
|
config.fallbackUrl ? "pt-12" : ""
|
|
} flex flex-col space-y-6 w-screen h-screen text-center justify-center items-center text-white text-4xl`}
|
|
>
|
|
<p>
|
|
Please enable{" "}
|
|
<a
|
|
href="https://get.webgl.org"
|
|
target="_blank"
|
|
className="text-blue-500 hover:underline"
|
|
>
|
|
WebGL
|
|
</a>
|
|
</p>
|
|
{config.fallbackUrl && (
|
|
<p className="text-base">
|
|
or visit the{" "}
|
|
<a
|
|
href={config.fallbackUrl}
|
|
target="_blank"
|
|
className="text-blue-500 hover:underline"
|
|
>
|
|
fallback
|
|
</a>
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</main>
|
|
);
|
|
}
|
|
|
|
useGLTF.preload("/scene.glb");
|