This commit is contained in:
eric
2026-03-27 18:20:36 -05:00
parent a8976b2eee
commit 326bad4336
34 changed files with 2787 additions and 996 deletions

View File

@@ -0,0 +1,405 @@
"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>
);
}