Files
gitlab-instance-0a899031_no…/app/topic/components/TopicPrepThreeScene.tsx
2026-03-27 18:20:36 -05:00

406 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}