's'
This commit is contained in:
@@ -23,7 +23,7 @@ export function TopicComingSoon({ topicId }: TopicComingSoonProps) {
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-[var(--accent)] px-8 py-4 text-lg font-semibold text-[var(--accent-foreground)] transition hover:opacity-90"
|
||||
>
|
||||
前往数字游民指南了解更多 →
|
||||
了解更多 →
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -38,28 +38,32 @@ export function TopicFooter({ topicId }: TopicFooterProps) {
|
||||
模块列表
|
||||
</Link>
|
||||
{paid ? (
|
||||
<a
|
||||
href={CHAT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
<span className="hidden">
|
||||
<a
|
||||
href={CHAT_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</button>
|
||||
<span className="hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-[var(--accent)]/15 px-4 py-2 text-sm font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/25"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-center text-sm text-[var(--muted-foreground)]">
|
||||
|
||||
@@ -48,28 +48,32 @@ export function TopicHeader({ topicId }: TopicHeaderProps) {
|
||||
首页
|
||||
</Link>
|
||||
{paid ? (
|
||||
<a
|
||||
href="https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
<span className="hidden">
|
||||
<a
|
||||
href="https://chatwoot.hackrobot.cn/livechat?user_id=hackrobot"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</a>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQaModalOpen(true)}
|
||||
className="hidden items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:inline-flex sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</button>
|
||||
<span className="hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQaModalOpen(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--accent)]/40 bg-[var(--accent)]/10 px-3 py-1.5 text-xs font-medium text-[var(--accent)] transition hover:bg-[var(--accent)]/20 sm:px-4 sm:py-2 sm:text-sm"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
即时问答
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{config?.externalUrl ? (
|
||||
<Link
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
import Link from "next/link";
|
||||
import { getTopicConfig, getTopicCurriculum } from "../config";
|
||||
import { getLastWatched, getProgress } from "@/app/cloudphone/lib/learning";
|
||||
import { ParticleBackground } from "@/app/cloudphone/components/ParticleBackground";
|
||||
import { triggerPay } from "@/app/lib/triggerPay";
|
||||
import type { TopicPrepVariant } from "./TopicPrepThreeScene";
|
||||
|
||||
const TopicPrepThreeScene = dynamic(
|
||||
() => import("./TopicPrepThreeScene").then((m) => m.TopicPrepThreeScene),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div
|
||||
className="mx-auto mt-2 min-h-[260px] w-full max-w-2xl animate-pulse rounded-2xl border border-[var(--border)] bg-[var(--muted)]/20"
|
||||
aria-hidden
|
||||
/>
|
||||
),
|
||||
},
|
||||
);
|
||||
|
||||
type TopicHeroProps = {
|
||||
topicId: string;
|
||||
@@ -36,6 +51,10 @@ export function TopicHero({ topicId }: TopicHeroProps) {
|
||||
|
||||
const isEnrollCta = !config.externalUrl && !last;
|
||||
|
||||
const showPrepThree =
|
||||
(topicId === "indie" || topicId === "aiagent") && !curriculum?.hasContent;
|
||||
const prepVariant: TopicPrepVariant = topicId === "indie" ? "indie" : "aiagent";
|
||||
|
||||
return (
|
||||
<section className="relative overflow-hidden pt-24 pb-16 sm:pt-28 sm:pb-20 md:pt-32 md:pb-24">
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_80%_50%_at_50%_-20%,var(--gradient-accent),transparent)] animate-gradient" />
|
||||
@@ -74,6 +93,14 @@ export function TopicHero({ topicId }: TopicHeroProps) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showPrepThree ? (
|
||||
<div
|
||||
className="animate__animated animate__fadeInUp mx-auto mb-10 w-full max-w-2xl sm:mb-12"
|
||||
style={{ animationDelay: "0.5s", animationFillMode: "both" }}
|
||||
>
|
||||
<TopicPrepThreeScene variant={prepVariant} />
|
||||
</div>
|
||||
) : null}
|
||||
{curriculum?.hasContent && (
|
||||
<div
|
||||
className="animate__animated animate__fadeInDown flex flex-wrap justify-center gap-3 sm:gap-4"
|
||||
|
||||
405
app/topic/components/TopicPrepThreeScene.tsx
Normal file
405
app/topic/components/TopicPrepThreeScene.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export type TopicConfig = {
|
||||
subtitle: string;
|
||||
desc: string;
|
||||
stats: { value: string; label: string }[];
|
||||
/** 使用的 curriculum 模块索引,空则用全部 */
|
||||
/** 云手机课纲的模块索引;不传则用全部;传 [] 表示本专题暂无课程(勿套用云手机章节) */
|
||||
moduleIndices?: number[];
|
||||
/** 外部链接,有则显示「前往了解」而非课程列表 */
|
||||
externalUrl?: string;
|
||||
@@ -51,12 +51,12 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
|
||||
icon: "🛠️",
|
||||
title: "独立开发",
|
||||
subtitle: "从想法到产品,独立开发者全流程指南",
|
||||
desc: "即将上线,敬请期待。可先前往数字游民指南了解更多。",
|
||||
desc: "即将上线,敬请期待。",
|
||||
stats: [
|
||||
{ value: "—", label: "课程筹备中" },
|
||||
{ value: "—", label: "敬请期待" },
|
||||
],
|
||||
externalUrl: "https://digital.hackrobot.cn/zh",
|
||||
moduleIndices: [],
|
||||
payUrl: "/",
|
||||
},
|
||||
aiagent: {
|
||||
@@ -64,12 +64,12 @@ export const TOPIC_CONFIGS: Record<string, TopicConfig> = {
|
||||
icon: "🤖",
|
||||
title: "AI Agent",
|
||||
subtitle: "AIGC 与智能体,Vibe Coding 跨界创造",
|
||||
desc: "即将上线,敬请期待。可先前往数字游民指南了解更多。",
|
||||
desc: "即将上线,敬请期待。",
|
||||
stats: [
|
||||
{ value: "—", label: "课程筹备中" },
|
||||
{ value: "—", label: "敬请期待" },
|
||||
],
|
||||
externalUrl: "https://digital.hackrobot.cn/zh",
|
||||
moduleIndices: [],
|
||||
payUrl: "/",
|
||||
},
|
||||
};
|
||||
@@ -115,7 +115,10 @@ export function getTopicCurriculum(topicId: string) {
|
||||
if (topicId === "livestream") {
|
||||
return { modules: LIVESTREAM_CURRICULUM, hasContent: LIVESTREAM_CURRICULUM.length > 0 };
|
||||
}
|
||||
const indices = config.moduleIndices ?? CURRICULUM_CONFIG.modules.map((_, i) => i);
|
||||
const indices =
|
||||
config.moduleIndices !== undefined
|
||||
? config.moduleIndices
|
||||
: CURRICULUM_CONFIG.modules.map((_, i) => i);
|
||||
const modules = indices
|
||||
.map((i) => ({ module: CURRICULUM_CONFIG.modules[i], originalIndex: i }))
|
||||
.filter((x) => x.module)
|
||||
|
||||
Reference in New Issue
Block a user