406 lines
12 KiB
TypeScript
406 lines
12 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useRef } from "react";
|
||
import * as THREE from "three";
|
||
|
||
export type TopicPrepVariant = "indie" | "aiagent";
|
||
|
||
type Props = {
|
||
variant: TopicPrepVariant;
|
||
};
|
||
|
||
function disposeObject3D(root: THREE.Object3D) {
|
||
const materials = new Set<THREE.Material>();
|
||
const geometries = new Set<THREE.BufferGeometry>();
|
||
root.traverse((object) => {
|
||
if (object instanceof THREE.Mesh || object instanceof THREE.LineSegments) {
|
||
const g = object.geometry;
|
||
if (g && !geometries.has(g)) {
|
||
geometries.add(g);
|
||
g.dispose();
|
||
}
|
||
const mat = object.material;
|
||
if (Array.isArray(mat)) mat.forEach((m) => materials.add(m));
|
||
else if (mat) materials.add(mat);
|
||
}
|
||
});
|
||
materials.forEach((m) => m.dispose());
|
||
}
|
||
|
||
/** 独立开发:居家书桌 + 显示器 + homelab 机架 */
|
||
function buildIndieDeskScene(root: THREE.Group) {
|
||
const wood = new THREE.MeshStandardMaterial({
|
||
color: 0x6b5344,
|
||
roughness: 0.88,
|
||
metalness: 0.05,
|
||
});
|
||
const deskDark = new THREE.MeshStandardMaterial({
|
||
color: 0x18181b,
|
||
roughness: 0.55,
|
||
metalness: 0.25,
|
||
});
|
||
const screenGlow = new THREE.MeshStandardMaterial({
|
||
color: 0x052e16,
|
||
emissive: 0x4ade80,
|
||
emissiveIntensity: 0.55,
|
||
roughness: 0.35,
|
||
});
|
||
const keyCap = new THREE.MeshStandardMaterial({
|
||
color: 0x3f3f46,
|
||
roughness: 0.45,
|
||
metalness: 0.15,
|
||
});
|
||
const rackLed = new THREE.MeshStandardMaterial({
|
||
color: 0x0c0a09,
|
||
emissive: 0xf59e0b,
|
||
emissiveIntensity: 0.5,
|
||
roughness: 0.4,
|
||
});
|
||
|
||
const floor = new THREE.Mesh(new THREE.PlaneGeometry(7, 5.5), wood);
|
||
floor.rotation.x = -Math.PI / 2;
|
||
floor.position.y = -1.15;
|
||
root.add(floor);
|
||
|
||
const rug = new THREE.Mesh(new THREE.PlaneGeometry(2.8, 2), new THREE.MeshStandardMaterial({
|
||
color: 0x3d3832,
|
||
roughness: 0.95,
|
||
}));
|
||
rug.rotation.x = -Math.PI / 2;
|
||
rug.position.set(0, -1.14, 0.1);
|
||
root.add(rug);
|
||
|
||
const deskTop = new THREE.Mesh(new THREE.BoxGeometry(2.4, 0.07, 1), deskDark);
|
||
deskTop.position.set(0, -0.38, 0.15);
|
||
root.add(deskTop);
|
||
|
||
const legGeo = new THREE.BoxGeometry(0.08, 0.45, 0.08);
|
||
const legPositions: [number, number, number][] = [
|
||
[-1, -0.65, -0.25],
|
||
[1, -0.65, -0.25],
|
||
[-1, -0.65, 0.55],
|
||
[1, -0.65, 0.55],
|
||
];
|
||
for (const [x, y, z] of legPositions) {
|
||
const leg = new THREE.Mesh(legGeo, deskDark);
|
||
leg.position.set(x, y, z);
|
||
root.add(leg);
|
||
}
|
||
|
||
const monitorBezel = new THREE.Mesh(new THREE.BoxGeometry(1, 0.62, 0.045), deskDark);
|
||
monitorBezel.position.set(0, 0.12, -0.28);
|
||
root.add(monitorBezel);
|
||
|
||
const screen = new THREE.Mesh(new THREE.PlaneGeometry(0.88, 0.52), screenGlow);
|
||
screen.position.set(0, 0.12, -0.28 + 0.023);
|
||
root.add(screen);
|
||
|
||
const stand = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.12, 0.15), deskDark);
|
||
stand.position.set(0, -0.28, -0.22);
|
||
root.add(stand);
|
||
|
||
const keyboard = new THREE.Mesh(new THREE.BoxGeometry(0.75, 0.035, 0.3), keyCap);
|
||
keyboard.position.set(0, -0.32, 0.38);
|
||
keyboard.rotation.x = -0.06;
|
||
root.add(keyboard);
|
||
|
||
const mug = new THREE.Mesh(
|
||
new THREE.CylinderGeometry(0.09, 0.08, 0.12, 16),
|
||
new THREE.MeshStandardMaterial({ color: 0x3f3f46, roughness: 0.4 }),
|
||
);
|
||
mug.position.set(-0.85, -0.31, 0.42);
|
||
root.add(mug);
|
||
|
||
const rack = new THREE.Mesh(new THREE.BoxGeometry(0.38, 0.95, 0.42), deskDark);
|
||
rack.position.set(1.05, 0.1, -0.05);
|
||
root.add(rack);
|
||
|
||
const rackFace = new THREE.Mesh(new THREE.PlaneGeometry(0.3, 0.82), rackLed);
|
||
rackFace.position.set(1.05 + 0.191, 0.1, -0.05);
|
||
root.add(rackFace);
|
||
|
||
for (let row = 0; row < 4; row++) {
|
||
const led = new THREE.Mesh(
|
||
new THREE.PlaneGeometry(0.22, 0.04),
|
||
new THREE.MeshStandardMaterial({
|
||
color: 0x000000,
|
||
emissive: row % 2 === 0 ? 0x22c55e : 0xf59e0b,
|
||
emissiveIntensity: 0.65,
|
||
}),
|
||
);
|
||
led.position.set(1.05 + 0.192, 0.35 - row * 0.18, -0.05);
|
||
root.add(led);
|
||
}
|
||
|
||
const miniServer = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.15, 0.18), deskDark);
|
||
miniServer.position.set(1.05, -0.28, 0.45);
|
||
root.add(miniServer);
|
||
}
|
||
|
||
/** AI Agent:赛博空间网格 + 机器人 + 霓虹光环 */
|
||
function buildAiCyberScene(root: THREE.Group) {
|
||
const grid = new THREE.GridHelper(9, 28, 0x6366f1, 0x312e81);
|
||
grid.position.y = -1.05;
|
||
const gMat = grid.material;
|
||
if (Array.isArray(gMat)) {
|
||
gMat.forEach((m) => {
|
||
if ("opacity" in m) {
|
||
(m as THREE.Material & { opacity: number }).opacity = 0.45;
|
||
m.transparent = true;
|
||
}
|
||
});
|
||
} else if (gMat && "opacity" in gMat) {
|
||
(gMat as THREE.Material & { opacity: number }).opacity = 0.45;
|
||
gMat.transparent = true;
|
||
}
|
||
root.add(grid);
|
||
|
||
const bodyMat = new THREE.MeshStandardMaterial({
|
||
color: 0x52525b,
|
||
metalness: 0.55,
|
||
roughness: 0.28,
|
||
flatShading: true,
|
||
});
|
||
const glowCyan = new THREE.MeshStandardMaterial({
|
||
color: 0x06b6d4,
|
||
emissive: 0x22d3ee,
|
||
emissiveIntensity: 0.85,
|
||
});
|
||
const glowMagenta = new THREE.MeshStandardMaterial({
|
||
color: 0xd946ef,
|
||
emissive: 0xc026d3,
|
||
emissiveIntensity: 0.55,
|
||
});
|
||
|
||
const robot = new THREE.Group();
|
||
robot.position.y = -0.15;
|
||
|
||
const torso = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.58, 0.32), bodyMat);
|
||
torso.position.y = 0.1;
|
||
robot.add(torso);
|
||
|
||
const chestPanel = new THREE.Mesh(new THREE.PlaneGeometry(0.35, 0.25), glowCyan);
|
||
chestPanel.position.set(0, 0.12, 0.161);
|
||
robot.add(chestPanel);
|
||
|
||
const head = new THREE.Mesh(new THREE.BoxGeometry(0.44, 0.36, 0.38), bodyMat);
|
||
head.position.y = 0.55;
|
||
robot.add(head);
|
||
|
||
const visor = new THREE.Mesh(new THREE.BoxGeometry(0.38, 0.12, 0.02), glowCyan);
|
||
visor.position.set(0, 0.52, 0.19);
|
||
robot.add(visor);
|
||
|
||
const eyeL = new THREE.Mesh(new THREE.SphereGeometry(0.055, 16, 16), glowCyan);
|
||
eyeL.position.set(-0.1, 0.56, 0.18);
|
||
const eyeR = new THREE.Mesh(new THREE.SphereGeometry(0.055, 16, 16), glowCyan);
|
||
eyeR.position.set(0.1, 0.56, 0.18);
|
||
robot.add(eyeL, eyeR);
|
||
|
||
const antenna = new THREE.Mesh(new THREE.CylinderGeometry(0.025, 0.03, 0.28, 8), glowMagenta);
|
||
antenna.position.set(0, 0.82, 0);
|
||
robot.add(antenna);
|
||
|
||
const armL = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.45, 0.12), bodyMat);
|
||
armL.position.set(-0.38, 0.05, 0);
|
||
const armR = armL.clone();
|
||
armR.position.x = 0.38;
|
||
robot.add(armL, armR);
|
||
|
||
root.add(robot);
|
||
|
||
for (let i = 0; i < 3; i++) {
|
||
const ringMat = new THREE.MeshBasicMaterial({
|
||
color: i === 1 ? 0xa855f7 : 0x22d3ee,
|
||
transparent: true,
|
||
opacity: 0.35 - i * 0.08,
|
||
});
|
||
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.95 + i * 0.22, 0.012, 8, 64), ringMat);
|
||
ring.rotation.x = Math.PI / 2;
|
||
ring.position.y = -0.85 + i * 0.15;
|
||
root.add(ring);
|
||
}
|
||
|
||
const holo = new THREE.Mesh(
|
||
new THREE.OctahedronGeometry(0.18, 0),
|
||
new THREE.MeshBasicMaterial({ color: 0x67e8f9, wireframe: true, transparent: true, opacity: 0.5 }),
|
||
);
|
||
holo.position.set(-0.55, 0.35, 0.35);
|
||
root.add(holo);
|
||
|
||
return { robot, holo };
|
||
}
|
||
|
||
export function TopicPrepThreeScene({ variant }: Props) {
|
||
const containerRef = useRef<HTMLDivElement>(null);
|
||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||
|
||
useEffect(() => {
|
||
const canvas = canvasRef.current;
|
||
const container = containerRef.current;
|
||
if (!canvas || !container) return;
|
||
|
||
const renderer = new THREE.WebGLRenderer({
|
||
canvas,
|
||
antialias: true,
|
||
alpha: true,
|
||
powerPreference: "high-performance",
|
||
});
|
||
|
||
const scene = new THREE.Scene();
|
||
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
|
||
camera.position.set(0, 0.35, 5.2);
|
||
|
||
const group = new THREE.Group();
|
||
scene.add(group);
|
||
|
||
let bobRefs: { robot?: THREE.Group; holo?: THREE.Mesh } | null = null;
|
||
|
||
if (variant === "indie") {
|
||
scene.background = null;
|
||
const ambient = new THREE.AmbientLight(0xfff5e6, 0.45);
|
||
scene.add(ambient);
|
||
const warm = new THREE.PointLight(0xffb86b, 1.1, 12);
|
||
warm.position.set(0.6, 1.2, 1.2);
|
||
scene.add(warm);
|
||
const cool = new THREE.PointLight(0x4ade80, 0.35, 8);
|
||
cool.position.set(-0.3, 0.5, 0.5);
|
||
scene.add(cool);
|
||
const dir = new THREE.DirectionalLight(0xffffff, 0.35);
|
||
dir.position.set(-2, 4, 3);
|
||
scene.add(dir);
|
||
|
||
buildIndieDeskScene(group);
|
||
group.rotation.y = 0.25;
|
||
} else {
|
||
scene.fog = new THREE.FogExp2(0x0a0a14, 0.055);
|
||
const ambient = new THREE.AmbientLight(0x4c1d95, 0.35);
|
||
scene.add(ambient);
|
||
const key = new THREE.PointLight(0x22d3ee, 1.2, 16);
|
||
key.position.set(1.5, 2, 2);
|
||
scene.add(key);
|
||
const rim = new THREE.PointLight(0xd946ef, 0.85, 14);
|
||
rim.position.set(-2, 0.5, -1);
|
||
scene.add(rim);
|
||
const dir = new THREE.DirectionalLight(0xa78bfa, 0.4);
|
||
dir.position.set(0, 3, 4);
|
||
scene.add(dir);
|
||
|
||
bobRefs = buildAiCyberScene(group);
|
||
}
|
||
|
||
let rx = 0.1;
|
||
let ry = variant === "indie" ? 0.2 : 0;
|
||
let vx = 0;
|
||
let vy = 0;
|
||
const sens = 0.0052;
|
||
const damping = 0.93;
|
||
const autoRy = variant === "indie" ? 0.0018 : 0.0024;
|
||
|
||
let dragging = false;
|
||
let lastX = 0;
|
||
let lastY = 0;
|
||
|
||
const onPointerDown = (e: PointerEvent) => {
|
||
dragging = true;
|
||
lastX = e.clientX;
|
||
lastY = e.clientY;
|
||
canvas.setPointerCapture(e.pointerId);
|
||
};
|
||
const onPointerMove = (e: PointerEvent) => {
|
||
if (!dragging) return;
|
||
const dx = e.clientX - lastX;
|
||
const dy = e.clientY - lastY;
|
||
lastX = e.clientX;
|
||
lastY = e.clientY;
|
||
vy += dx * sens;
|
||
vx += dy * sens;
|
||
};
|
||
const onPointerEnd = (e: PointerEvent) => {
|
||
dragging = false;
|
||
try {
|
||
canvas.releasePointerCapture(e.pointerId);
|
||
} catch {
|
||
/* already released */
|
||
}
|
||
};
|
||
|
||
canvas.addEventListener("pointerdown", onPointerDown);
|
||
canvas.addEventListener("pointermove", onPointerMove);
|
||
canvas.addEventListener("pointerup", onPointerEnd);
|
||
canvas.addEventListener("pointercancel", onPointerEnd);
|
||
canvas.style.touchAction = "none";
|
||
|
||
const resize = () => {
|
||
const w = Math.max(280, container.clientWidth);
|
||
const h = Math.min(440, Math.max(236, Math.round(w * 0.5)));
|
||
camera.aspect = w / h;
|
||
camera.updateProjectionMatrix();
|
||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||
renderer.setSize(w, h, false);
|
||
};
|
||
|
||
const ro = new ResizeObserver(resize);
|
||
ro.observe(container);
|
||
resize();
|
||
|
||
let raf = 0;
|
||
const t0 = performance.now();
|
||
const tick = () => {
|
||
raf = requestAnimationFrame(tick);
|
||
const t = (performance.now() - t0) * 0.001;
|
||
if (!dragging) vy += autoRy;
|
||
rx += vx;
|
||
ry += vy;
|
||
vx *= damping;
|
||
vy *= damping;
|
||
group.rotation.x = rx;
|
||
group.rotation.y = ry;
|
||
|
||
if (bobRefs?.robot) {
|
||
bobRefs.robot.position.y = -0.15 + Math.sin(t * 1.8) * 0.035;
|
||
}
|
||
if (bobRefs?.holo) {
|
||
bobRefs.holo.rotation.y = t * 0.9;
|
||
bobRefs.holo.rotation.x = t * 0.45;
|
||
}
|
||
|
||
renderer.render(scene, camera);
|
||
};
|
||
tick();
|
||
|
||
return () => {
|
||
cancelAnimationFrame(raf);
|
||
ro.disconnect();
|
||
canvas.removeEventListener("pointerdown", onPointerDown);
|
||
canvas.removeEventListener("pointermove", onPointerMove);
|
||
canvas.removeEventListener("pointerup", onPointerEnd);
|
||
canvas.removeEventListener("pointercancel", onPointerEnd);
|
||
scene.fog = null;
|
||
disposeObject3D(group);
|
||
scene.remove(group);
|
||
renderer.dispose();
|
||
};
|
||
}, [variant]);
|
||
|
||
const label =
|
||
variant === "indie" ? "独立开发:居家工作台与 homelab 预览" : "AI Agent:赛博空间与机器人预览";
|
||
|
||
const hint =
|
||
variant === "indie"
|
||
? "拖动旋转 · 居家书桌、显示器与 homelab 机架(课程筹备视觉预告)"
|
||
: "拖动旋转 · 赛博网格空间与机器人(课程筹备视觉预告)";
|
||
|
||
return (
|
||
<div ref={containerRef} className="mx-auto mt-2 w-full max-w-2xl">
|
||
<p className="mb-2 text-center text-xs text-[var(--muted-foreground)]">{hint}</p>
|
||
<canvas
|
||
ref={canvasRef}
|
||
className="mx-auto block w-full cursor-grab touch-none rounded-2xl border border-[var(--border)] bg-[var(--muted)]/25 active:cursor-grabbing dark:bg-[var(--muted)]/15"
|
||
aria-label={label}
|
||
role="img"
|
||
/>
|
||
</div>
|
||
);
|
||
}
|