feat(story): reel scrollytelling scrubbed por scroll com palco WebGL 3D
This commit is contained in:
1 parent
52960313f0
commit
8be2ba572d
4 files changed
+595
-1
No files matched your search
@@ -9,6 +9,7 @@ import type {
|
||||
import Hero from "../Hero";
|
||||
import Projects from "../Projects";
|
||||
import { SectionLoading } from "../SectionLoading";
|
||||
import ScrollStory from "../story/ScrollStory";
|
||||
import CinematicSection from "./CinematicSection";
|
||||
|
||||
const About = dynamic(() => import("../About"), {
|
||||
@@ -49,7 +50,7 @@ export default function CinematicHome({
|
||||
skillCategories: PortfolioSkillCategory[];
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col overflow-x-hidden">
|
||||
<div className="flex min-h-screen flex-col overflow-x-clip">
|
||||
<main className="ds-main">
|
||||
<Hero />
|
||||
|
||||
@@ -58,6 +59,16 @@ export default function CinematicHome({
|
||||
<ScrollVelocityText text="SOFTWARE ENGINEERING" baseVelocity={-1.5} />
|
||||
</div>
|
||||
|
||||
{/* Reel scrollytelling: timeline scrubbed pelo scroll (estilo product-page) */}
|
||||
<ScrollStory
|
||||
projectsCount={projects.length}
|
||||
experiencesCount={experiences.length}
|
||||
skillsCount={skillCategories.reduce(
|
||||
(acc, cat) => acc + cat.skills.length,
|
||||
0,
|
||||
)}
|
||||
/>
|
||||
|
||||
<CinematicSection effect="parallax-depth">
|
||||
<About />
|
||||
</CinematicSection>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import ScrollStory from "./ScrollStory";
|
||||
|
||||
// gsap é carregado via import() dinâmico dentro do effect; o contrato testado
|
||||
// aqui é markup do reel (cenas/HUD/alvos), não a timeline — que é verificada
|
||||
// no browser via probe CDP. Mock leve mantém a suíte em jsdom.
|
||||
jest.mock("gsap", () => {
|
||||
const chain = { set: jest.fn(), fromTo: jest.fn(), to: jest.fn() };
|
||||
Object.assign(chain, {
|
||||
set: jest.fn(() => chain),
|
||||
fromTo: jest.fn(() => chain),
|
||||
to: jest.fn(() => chain),
|
||||
});
|
||||
return {
|
||||
gsap: {
|
||||
registerPlugin: jest.fn(),
|
||||
set: jest.fn(),
|
||||
timeline: jest.fn(() => chain),
|
||||
ticker: { add: jest.fn(), remove: jest.fn(), lagSmoothing: jest.fn() },
|
||||
utils: { toArray: jest.fn(() => []) },
|
||||
context: jest.fn((fn: () => void) => {
|
||||
fn();
|
||||
return { revert: jest.fn() };
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
jest.mock("gsap/ScrollTrigger", () => ({
|
||||
ScrollTrigger: { getAll: jest.fn(() => []) },
|
||||
}));
|
||||
|
||||
const props = { projectsCount: 5, experiencesCount: 6, skillsCount: 18 };
|
||||
|
||||
function mockMatchMedia(matches: boolean) {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query: string) => ({
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
describe("ScrollStory", () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
mockMatchMedia(false);
|
||||
});
|
||||
|
||||
it("renderiza fallback empilhado com conteúdo real com reduced motion", () => {
|
||||
mockMatchMedia(true);
|
||||
const { container } = render(<ScrollStory {...props} />);
|
||||
// sem palco scrub: capítulos legíveis no fluxo, números reais do portfólio
|
||||
expect(container.querySelector("[data-story]")).not.toBeInTheDocument();
|
||||
expect(container).toHaveTextContent("01 — Visão geral");
|
||||
expect(container).toHaveTextContent("04 — Próximo ciclo");
|
||||
expect(container).toHaveTextContent("5");
|
||||
expect(screen.getAllByText(/sistemas ativos no portfólio/)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("renderiza o reel com 4 cenas, HUD e índice quando o engine liga", async () => {
|
||||
mockMatchMedia(false);
|
||||
const { container } = render(<ScrollStory {...props} />);
|
||||
await waitFor(() =>
|
||||
expect(container.querySelector("[data-story]")).toBeInTheDocument(),
|
||||
);
|
||||
expect(container.querySelectorAll("[data-story-scene]")).toHaveLength(4);
|
||||
expect(container.querySelector("[data-story-index]")).toHaveTextContent("01");
|
||||
expect(container.querySelector("[data-story-bar]")).toBeInTheDocument();
|
||||
// count-up é alimentado pela timeline: markup carrega o alvo real
|
||||
const num = container.querySelector("[data-story-num]");
|
||||
expect(num?.getAttribute("data-story-num-to")).toBe("5");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,377 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { buildWhatsAppUrl } from "@/lib/whatsapp";
|
||||
|
||||
const StoryStage3D = dynamic(() => import("./StoryStage3D"), { ssr: false });
|
||||
|
||||
type Chapter = {
|
||||
kicker: string;
|
||||
lines: string[];
|
||||
body?: string;
|
||||
stats?: Array<{ value: number; suffix: string; label: string }>;
|
||||
cta?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reel scrollytelling da home: palco sticky de 100dvh dirigido por uma única
|
||||
* timeline GSAP com scrub — o scroll é o controle de play/rebobinar.
|
||||
* Sem JS ou com prefers-reduced-motion: capítulos renderizam empilhados,
|
||||
* inteiros e legíveis (mesma regra de fail-soft do hero).
|
||||
*/
|
||||
export default function ScrollStory({
|
||||
projectsCount,
|
||||
experiencesCount,
|
||||
skillsCount,
|
||||
}: {
|
||||
projectsCount: number;
|
||||
experiencesCount: number;
|
||||
skillsCount: number;
|
||||
}) {
|
||||
const chapters: Chapter[] = [
|
||||
{
|
||||
kicker: "01 — Visão geral",
|
||||
lines: ["Software que opera.", "Não só roda."],
|
||||
body: "Role para rebobinar a engenharia por trás de cada sistema — a mesma página, agora em tomada contínua.",
|
||||
},
|
||||
{
|
||||
kicker: "02 — Arquitetura",
|
||||
lines: ["Da borda", "ao painel."],
|
||||
body: "Dispositivo, integração, API e interface girando juntos: contratos claros entre camada física e produto.",
|
||||
},
|
||||
{
|
||||
kicker: "03 — Em produção",
|
||||
lines: ["Entrega com", "critério real."],
|
||||
stats: [
|
||||
{ value: projectsCount, suffix: "", label: "sistemas ativos no portfólio" },
|
||||
{ value: experiencesCount, suffix: "", label: "ciclos de produto entregues" },
|
||||
{ value: skillsCount, suffix: "", label: "tecnologias em operação" },
|
||||
],
|
||||
},
|
||||
{
|
||||
kicker: "04 — Próximo ciclo",
|
||||
lines: ["Vamos girar o", "seu próximo sistema."],
|
||||
cta: true,
|
||||
},
|
||||
];
|
||||
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
const progressRef = useRef(0);
|
||||
const [engine, setEngine] = useState(false);
|
||||
const [webgl, setWebgl] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reduced) return undefined;
|
||||
setEngine(true);
|
||||
setWebgl(
|
||||
!window.matchMedia("(pointer: coarse)").matches && window.innerWidth >= 768,
|
||||
);
|
||||
return undefined;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!engine) return undefined;
|
||||
const el = sectionRef.current;
|
||||
if (!el) return undefined;
|
||||
|
||||
let cleanup = () => {};
|
||||
let cancelled = false;
|
||||
|
||||
const setup = async () => {
|
||||
const [{ gsap }, { ScrollTrigger }] = await Promise.all([
|
||||
import("gsap"),
|
||||
import("gsap/ScrollTrigger"),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
|
||||
const ctx = gsap.context(() => {
|
||||
const scenes = gsap.utils.toArray<HTMLElement>("[data-story-scene]");
|
||||
const bar = el.querySelector("[data-story-bar]");
|
||||
const indexEl = el.querySelector<HTMLElement>("[data-story-index]");
|
||||
const hint = el.querySelector("[data-story-hint]");
|
||||
|
||||
gsap.set(scenes.slice(1), { autoAlpha: 0 });
|
||||
gsap.set(scenes, { yPercent: 0 });
|
||||
|
||||
const tl = gsap.timeline({
|
||||
defaults: { ease: "none" },
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: 0.85,
|
||||
onUpdate: (self) => {
|
||||
progressRef.current = self.progress;
|
||||
if (indexEl) {
|
||||
const i = Math.min(
|
||||
scenes.length,
|
||||
Math.floor(self.progress * scenes.length) + 1,
|
||||
);
|
||||
indexEl.textContent = String(i).padStart(2, "0");
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const span = 1 / scenes.length;
|
||||
scenes.forEach((scene, i) => {
|
||||
const at = i * span;
|
||||
const inner = scene.querySelectorAll("[data-story-line]");
|
||||
const kicker = scene.querySelector("[data-story-kicker]");
|
||||
const body = scene.querySelectorAll("[data-story-body]");
|
||||
const stats = scene.querySelectorAll("[data-story-stat]");
|
||||
|
||||
tl.fromTo(
|
||||
inner,
|
||||
{ yPercent: 118 },
|
||||
{ yPercent: 0, duration: span * 0.34, stagger: span * 0.05 },
|
||||
at + span * 0.02,
|
||||
);
|
||||
if (kicker)
|
||||
tl.fromTo(
|
||||
kicker,
|
||||
{ autoAlpha: 0, y: 10 },
|
||||
{ autoAlpha: 1, y: 0, duration: span * 0.16 },
|
||||
at,
|
||||
);
|
||||
if (body.length)
|
||||
tl.fromTo(
|
||||
body,
|
||||
{ autoAlpha: 0, y: 22 },
|
||||
{ autoAlpha: 1, y: 0, duration: span * 0.22, stagger: span * 0.04 },
|
||||
at + span * 0.18,
|
||||
);
|
||||
if (stats.length)
|
||||
tl.fromTo(
|
||||
stats,
|
||||
{ autoAlpha: 0, y: 26, filter: "blur(6px)" },
|
||||
{
|
||||
autoAlpha: 1,
|
||||
y: 0,
|
||||
filter: "blur(0px)",
|
||||
duration: span * 0.24,
|
||||
stagger: span * 0.05,
|
||||
},
|
||||
at + span * 0.2,
|
||||
);
|
||||
// entrada da cena + saída — janelas sobrepostas p/ crossfade contínuo
|
||||
tl.fromTo(
|
||||
scene,
|
||||
{ autoAlpha: 0 },
|
||||
{ autoAlpha: 1, duration: span * 0.18 },
|
||||
Math.max(0, at - span * 0.08),
|
||||
);
|
||||
if (i < scenes.length - 1)
|
||||
tl.to(scene, { autoAlpha: 0, yPercent: -5, duration: span * 0.18 }, at + span * 0.8);
|
||||
|
||||
if (stats.length) {
|
||||
stats.forEach((statEl) => {
|
||||
const numEl = statEl.querySelector<HTMLElement>("[data-story-num]");
|
||||
if (!numEl) return;
|
||||
const target = Number(numEl.dataset.storyNumTo ?? "0");
|
||||
const counter = { v: 0 };
|
||||
tl.to(
|
||||
counter,
|
||||
{
|
||||
v: target,
|
||||
duration: span * 0.3,
|
||||
ease: "power2.out",
|
||||
onUpdate: () => {
|
||||
numEl.textContent = String(Math.round(counter.v));
|
||||
},
|
||||
},
|
||||
at + span * 0.22,
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (bar) tl.fromTo(bar, { scaleX: 0 }, { scaleX: 1, duration: 1 }, 0);
|
||||
if (hint) tl.to(hint, { autoAlpha: 0, duration: span * 0.1 }, span * 0.12);
|
||||
}, el);
|
||||
|
||||
cleanup = () => ctx.revert();
|
||||
};
|
||||
|
||||
void setup();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cleanup();
|
||||
};
|
||||
}, [engine]);
|
||||
|
||||
// ---- Fallback estático: capítulos empilhados, conteúdo inteiro ----
|
||||
if (!engine) {
|
||||
return (
|
||||
<section
|
||||
id="story"
|
||||
className="border-b border-[rgba(255,255,255,.06)] bg-[var(--ink)] text-[var(--paper)]"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[1240px] px-6 py-16">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[var(--slash)]">
|
||||
O reel
|
||||
</div>
|
||||
{chapters.map((ch) => (
|
||||
<article key={ch.kicker} className="mt-10 border-l border-[rgba(255,255,255,.1)] pl-6">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.5)]">
|
||||
{ch.kicker}
|
||||
</div>
|
||||
<h3 className="mt-3 font-display text-[clamp(1.6rem,4vw,2.4rem)] font-extrabold leading-[1.05] tracking-[-0.02em]">
|
||||
{ch.lines.join(" ")}
|
||||
</h3>
|
||||
{ch.body && (
|
||||
<p className="mt-3 max-w-[58ch] text-[14px] leading-7 text-[rgba(244,242,237,.72)]">
|
||||
{ch.body}
|
||||
</p>
|
||||
)}
|
||||
{ch.stats && (
|
||||
<ul className="mt-4 flex flex-wrap gap-6">
|
||||
{ch.stats.map((s) => (
|
||||
<li key={s.label}>
|
||||
<div className="font-display text-[2rem] font-extrabold text-[var(--slash)]">
|
||||
{s.value}
|
||||
{s.suffix}
|
||||
</div>
|
||||
<div className="text-[12px] text-[rgba(244,242,237,.6)]">{s.label}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{ch.cta && (
|
||||
<a
|
||||
href={buildWhatsAppUrl(
|
||||
"Olá! Vi o reel do portfólio e quero conversar sobre um sistema.",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-5 inline-flex items-center justify-center bg-[var(--slash)] px-6 py-3 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[var(--ink)]"
|
||||
>
|
||||
Conversar agora
|
||||
</a>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Modo reel: palco sticky com cenas em crossfade ----
|
||||
return (
|
||||
<section
|
||||
id="story"
|
||||
ref={sectionRef}
|
||||
data-story
|
||||
className="relative h-[460vh] bg-[var(--ink)]"
|
||||
aria-label="Apresentação em scroll contínuo"
|
||||
>
|
||||
<div className="sticky top-0 h-[100dvh] w-full overflow-hidden">
|
||||
{/* fundo: gradiente radial + brilho do WebGL */}
|
||||
<div className="absolute inset-0 bg-[radial-gradient(ellipse_70%_55%_at_62%_45%,rgba(204,255,0,0.07),transparent_65%)]" />
|
||||
{webgl && (
|
||||
<div className="absolute inset-0">
|
||||
<StoryStage3D progress={progressRef} />
|
||||
</div>
|
||||
)}
|
||||
{/* legibilidade do texto sobre a cena */}
|
||||
<div className="pointer-events-none absolute inset-0 bg-[linear-gradient(90deg,rgba(10,10,10,0.88)_0%,rgba(10,10,10,0.55)_42%,transparent_70%)]" />
|
||||
|
||||
{/* cenas */}
|
||||
<div className="absolute inset-0">
|
||||
{chapters.map((ch, i) => (
|
||||
<div
|
||||
key={ch.kicker}
|
||||
data-story-scene
|
||||
className="absolute inset-0 flex items-center"
|
||||
style={{ zIndex: 10 - i }}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[1240px] px-6 lg:px-16">
|
||||
<div className="max-w-[720px]">
|
||||
<div
|
||||
data-story-kicker
|
||||
className="font-mono text-[11px] uppercase tracking-[0.16em] text-[var(--slash)]"
|
||||
>
|
||||
{ch.kicker}
|
||||
</div>
|
||||
<h2 className="mt-4 font-display text-[clamp(2.4rem,7vw,5.2rem)] font-extrabold uppercase leading-[0.98] tracking-[-0.03em] text-[var(--paper)]">
|
||||
{ch.lines.map((line) => (
|
||||
<span key={line} className="block overflow-hidden pb-[0.06em]">
|
||||
<span data-story-line className="block will-change-transform">
|
||||
{line}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</h2>
|
||||
{ch.body && (
|
||||
<p
|
||||
data-story-body
|
||||
className="mt-6 max-w-[54ch] text-[clamp(14px,1.4vw,17px)] leading-8 text-[rgba(244,242,237,.78)]"
|
||||
>
|
||||
{ch.body}
|
||||
</p>
|
||||
)}
|
||||
{ch.stats && (
|
||||
<ul className="mt-8 flex flex-wrap gap-x-12 gap-y-6">
|
||||
{ch.stats.map((s) => (
|
||||
<li key={s.label} data-story-stat className="min-w-[132px]">
|
||||
<div className="font-display text-[clamp(2.2rem,4.5vw,3.4rem)] font-extrabold leading-none text-[var(--slash)]">
|
||||
<span data-story-num data-story-num-to={s.value}>
|
||||
0
|
||||
</span>
|
||||
{s.suffix}
|
||||
</div>
|
||||
<div className="mt-2 font-mono text-[10px] uppercase tracking-[0.12em] text-[rgba(244,242,237,.62)]">
|
||||
{s.label}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{ch.cta && (
|
||||
<a
|
||||
data-story-body
|
||||
href={buildWhatsAppUrl(
|
||||
"Olá! Vi o reel do portfólio e quero conversar sobre um sistema.",
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="pointer-events-auto mt-8 inline-flex items-center justify-center bg-[var(--slash)] px-8 py-4 font-mono text-[11px] font-bold uppercase tracking-[0.1em] text-[var(--ink)] transition hover:bg-white"
|
||||
>
|
||||
Conversar agora
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* HUD: índice de capítulo + barra de progresso do reel */}
|
||||
<div className="pointer-events-none absolute bottom-6 left-6 z-20 lg:left-16">
|
||||
<div className="flex items-center gap-3 font-mono text-[10px] uppercase tracking-[0.14em] text-[rgba(244,242,237,.55)]">
|
||||
<span data-story-index className="text-[var(--slash)]">
|
||||
01
|
||||
</span>
|
||||
<span>/ 0{chapters.length}</span>
|
||||
</div>
|
||||
<div className="mt-2 h-px w-40 bg-[rgba(255,255,255,.14)]">
|
||||
<div
|
||||
data-story-bar
|
||||
className="h-full w-full origin-left bg-[var(--slash)]"
|
||||
style={{ transform: "scaleX(0)" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
data-story-hint
|
||||
className="pointer-events-none absolute right-6 top-1/2 z-20 -translate-y-1/2 font-mono text-[10px] uppercase tracking-[0.18em] text-[rgba(244,242,237,.5)] lg:right-16"
|
||||
>
|
||||
↕ scroll = play / rewind
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { Canvas, useFrame } from "@react-three/fiber";
|
||||
import { Environment, Lightformer, RoundedBox, Sparkles } from "@react-three/drei";
|
||||
import { useRef } from "react";
|
||||
import * as THREE from "three";
|
||||
|
||||
type ProgressRef = { current: number };
|
||||
|
||||
/**
|
||||
* Palco WebGL do scrollytelling: um "dispositivo" 3D procedural que gira,
|
||||
* inclina e aproxima conforme o progresso do scroll (ref compartilhada — sem
|
||||
* re-render do React por frame). O lerp interno suaviza o scrub em touch.
|
||||
* Composição estilo product-page: dispositivo no terço direito, texto à esquerda.
|
||||
*/
|
||||
function DeviceRig({ progress }: { progress: ProgressRef }) {
|
||||
const rig = useRef<THREE.Group>(null);
|
||||
const halo = useRef<THREE.Mesh>(null);
|
||||
const screenMat = useRef<THREE.MeshStandardMaterial>(null);
|
||||
const glow = useRef<THREE.PointLight>(null);
|
||||
const smooth = useRef(0);
|
||||
|
||||
useFrame((_, dt) => {
|
||||
smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7);
|
||||
const p = smooth.current;
|
||||
const g = rig.current;
|
||||
if (!g) return;
|
||||
|
||||
// tomada contínua: ~360° + um extra, como um turntable pré-renderizado
|
||||
g.rotation.y = -0.7 + p * Math.PI * 2.2;
|
||||
g.rotation.x = 0.1 + Math.sin(p * Math.PI) * 0.28;
|
||||
g.rotation.z = Math.sin(p * Math.PI * 2) * 0.06;
|
||||
g.position.x = 2.1;
|
||||
g.position.y = Math.sin(p * Math.PI * 2) * 0.22;
|
||||
// respiro de escala durante o reel (zoom de câmera sutil)
|
||||
g.scale.setScalar(0.62 + Math.sin(Math.min(1, p * 1.05) * Math.PI) * 0.1);
|
||||
|
||||
if (halo.current) {
|
||||
halo.current.rotation.z += dt * 0.25;
|
||||
const m = halo.current.material as THREE.MeshBasicMaterial;
|
||||
m.opacity = 0.1 + p * 0.08;
|
||||
}
|
||||
if (screenMat.current) {
|
||||
// tela "liga" cena a cena: 4 pulsos ao longo do reel
|
||||
screenMat.current.emissiveIntensity =
|
||||
0.12 + Math.abs(Math.sin(p * Math.PI * 4)) * 0.5;
|
||||
}
|
||||
if (glow.current) {
|
||||
glow.current.intensity = 2.2 + Math.sin(p * Math.PI * 4) * 1.4;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<group ref={rig}>
|
||||
{/* corpo do dispositivo */}
|
||||
<RoundedBox args={[1.62, 3.32, 0.18]} radius={0.16} smoothness={5}>
|
||||
<meshStandardMaterial color="#141417" metalness={0.92} roughness={0.26} />
|
||||
</RoundedBox>
|
||||
{/* borda luminosa (bezel slash) */}
|
||||
<RoundedBox args={[1.7, 3.4, 0.1]} radius={0.17} smoothness={4} position={[0, 0, -0.01]}>
|
||||
<meshStandardMaterial
|
||||
color="#0a0a0a"
|
||||
emissive="#ccff00"
|
||||
emissiveIntensity={0.22}
|
||||
metalness={0.6}
|
||||
roughness={0.5}
|
||||
/>
|
||||
</RoundedBox>
|
||||
{/* tela emissiva */}
|
||||
<mesh position={[0, 0, 0.098]}>
|
||||
<planeGeometry args={[1.42, 3.06]} />
|
||||
<meshStandardMaterial
|
||||
ref={screenMat}
|
||||
color="#050505"
|
||||
emissive="#ccff00"
|
||||
emissiveIntensity={0.3}
|
||||
toneMapped={false}
|
||||
transparent
|
||||
opacity={0.92}
|
||||
roughness={0.3}
|
||||
metalness={0.1}
|
||||
/>
|
||||
</mesh>
|
||||
{/* halo orbital */}
|
||||
<mesh ref={halo} rotation={[Math.PI / 2.15, 0, 0]} position={[0, -0.1, -0.3]}>
|
||||
<torusGeometry args={[1.9, 0.014, 10, 128]} />
|
||||
<meshBasicMaterial color="#ccff00" transparent opacity={0.2} toneMapped={false} />
|
||||
</mesh>
|
||||
<pointLight ref={glow} position={[0, 0, 1.6]} color="#ccff00" intensity={2.4} distance={6} />
|
||||
</group>
|
||||
);
|
||||
}
|
||||
|
||||
function CameraRig({ progress }: { progress: ProgressRef }) {
|
||||
const smooth = useRef(0);
|
||||
useFrame(({ camera }, dt) => {
|
||||
smooth.current += (progress.current - smooth.current) * Math.min(1, dt * 7);
|
||||
const p = smooth.current;
|
||||
// leve steadicam: aproxima no meio da história
|
||||
camera.position.z = 7.8 - Math.sin(Math.min(1, p * 1.1) * Math.PI) * 0.8;
|
||||
camera.position.y = -0.3 + p * 0.6;
|
||||
camera.lookAt(0, 0, 0);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function StoryStage3D({ progress }: { progress: ProgressRef }) {
|
||||
return (
|
||||
<Canvas
|
||||
dpr={[1, 1.75]}
|
||||
camera={{ position: [0, 0, 7.8], fov: 30 }}
|
||||
gl={{ antialias: true, alpha: true, powerPreference: "high-performance" }}
|
||||
>
|
||||
<ambientLight intensity={0.32} />
|
||||
<directionalLight position={[4, 6, 5]} intensity={1.1} />
|
||||
<DeviceRig progress={progress} />
|
||||
<CameraRig progress={progress} />
|
||||
<Sparkles count={70} scale={[9, 5.5, 3.2]} size={2.4} speed={0.32} opacity={0.5} color="#ccff00" />
|
||||
<Environment resolution={64} frames={1}>
|
||||
<Lightformer intensity={1.6} position={[0, 4, 4]} scale={[8, 3, 1]} color="#f4f2ed" />
|
||||
<Lightformer intensity={1.1} position={[-5, -1, 3]} scale={[3, 6, 1]} color="#ccff00" />
|
||||
<Lightformer intensity={0.8} position={[5, 0, -4]} scale={[3, 6, 1]} color="#8f8fff" />
|
||||
</Environment>
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user