diff --git a/public/raven-silhouette.svg b/public/raven-silhouette.svg new file mode 100644 index 0000000..0c90393 --- /dev/null +++ b/public/raven-silhouette.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/index.css b/src/index.css index 13f3f7c..7a0e1ba 100644 --- a/src/index.css +++ b/src/index.css @@ -564,7 +564,7 @@ html.gate-transit-lock body { position: absolute; inset: 0; perspective: 1400px; - perspective-origin: 50% 46%; + perspective-origin: 50% 50%; } .gate-transit__hero { @@ -582,8 +582,21 @@ html.gate-transit-lock body { user-select: none; object-fit: contain; filter: drop-shadow(0 10px 32px rgba(0, 0, 0, 0.6)); + z-index: 1; } +.gate-transit__ravens { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; +} + +.gate-transit__ravens canvas { + display: block; + width: 100% !important; + height: 100% !important; +} .gate-transit__veil { position: absolute; diff --git a/src/vfx/GateTransitProvider.tsx b/src/vfx/GateTransitProvider.tsx index 3a3d6a1..cc10278 100644 --- a/src/vfx/GateTransitProvider.tsx +++ b/src/vfx/GateTransitProvider.tsx @@ -1,5 +1,7 @@ import { createContext, + lazy, + Suspense, useCallback, useContext, useEffect, @@ -17,6 +19,11 @@ import { createReducedLogoFlyTimeline, type LogoFlyTargets, } from './gateTimeline' +import { capturePortalScreen, defaultRavenFx, type RavenFx } from './ravenFx' + +const RavenBurstCanvas = lazy(() => + import('./RavenBurstCanvas').then((m) => ({ default: m.RavenBurstCanvas })), +) type StartResult = { ok: boolean @@ -54,7 +61,6 @@ function collectUiFade(gate: HTMLElement, logo: HTMLElement | null): HTMLElement function placeHeroOverLogo(hero: HTMLElement, logo: HTMLElement) { const rect = logo.getBoundingClientRect() - // Geometry only — keep invisible until the original logo is hidden in the same frame gsap.set(hero, { position: 'fixed', top: rect.top, @@ -62,7 +68,7 @@ function placeHeroOverLogo(hero: HTMLElement, logo: HTMLElement) { width: rect.width, height: rect.height, margin: 0, - zIndex: 2, + zIndex: 1, opacity: 0, visibility: 'hidden', scale: 1, @@ -70,7 +76,7 @@ function placeHeroOverLogo(hero: HTMLElement, logo: HTMLElement) { z: 0, filter: 'drop-shadow(0 10px 32px rgba(0, 0, 0, 0.6))', objectFit: 'contain', - transformOrigin: '50% 46%', + transformOrigin: '50% 50%', transformPerspective: 1400, force3D: true, willChange: 'transform, filter, opacity', @@ -86,10 +92,13 @@ export function GateTransitProvider({ children }: { children: ReactNode }) { const navigate = useNavigate() const [phase, setPhase] = useState('idle') const [showOverlay, setShowOverlay] = useState(false) + const [mountRavens, setMountRavens] = useState(false) const veilRef = useRef(null) const stageRef = useRef(null) const heroRef = useRef(null) + const ravenWrapRef = useRef(null) + const ravenFxRef = useRef({ ...defaultRavenFx }) const timelineRef = useRef(null) const acceptPromiseRef = useRef | null>(null) const acceptErrorRef = useRef(null) @@ -116,7 +125,9 @@ export function GateTransitProvider({ children }: { children: ReactNode }) { (result: StartResult) => { timelineRef.current?.kill() timelineRef.current = null + setMountRavens(false) setShowOverlay(false) + Object.assign(ravenFxRef.current, defaultRavenFx) setPhase(result.ok ? 'done' : 'error') cleanupDomLocks() resolveStartRef.current?.(result) @@ -191,18 +202,24 @@ export function GateTransitProvider({ children }: { children: ReactNode }) { placeHeroOverLogo(hero, logo) - // One frame with hero invisible + correct box, then atomic swap with the page logo requestAnimationFrame(() => { if (timelineRef.current) return placeHeroOverLogo(hero, logo) revealHeroOverLogo(hero, logo) + // Lock cone apex to the portal (zoom origin) before scale starts + const portal = capturePortalScreen(hero) + ravenFxRef.current.apexSx = portal.x + ravenFxRef.current.apexSy = portal.y + const targets: LogoFlyTargets = { hero, uiFade: collectUiFade(gate, logo), originalLogo: logo, veil: veilRef.current, stage: stageRef.current, + ravenLayer: ravenWrapRef.current, + ravenFx: ravenFxRef.current, } const handlers = { @@ -236,6 +253,12 @@ export function GateTransitProvider({ children }: { children: ReactNode }) { finish({ ok: false, error: 'Не удалось запустить переход' }) return } + // Wait a beat for lazy raven canvas when enabled + if (mountRavens && !ravenWrapRef.current && tries < 40) { + tries += 1 + window.setTimeout(tick, 40) + return + } startTimeline() } @@ -244,7 +267,7 @@ export function GateTransitProvider({ children }: { children: ReactNode }) { cancelled = true window.clearTimeout(id) } - }, [phase, showOverlay, startTimeline, finish]) + }, [phase, showOverlay, mountRavens, startTimeline, finish]) const start = useCallback( (runAccept: () => Promise) => { @@ -252,29 +275,44 @@ export function GateTransitProvider({ children }: { children: ReactNode }) { return Promise.resolve({ ok: false, error: 'Переход уже идёт' }) } - reducedRef.current = prefersReducedMotion() - setShowOverlay(true) - setPhase('playing') - document.documentElement.classList.add('gate-transit-lock') - - acceptOkRef.current = false - acceptErrorRef.current = null - navigatedRef.current = false - timelineRef.current = null - - const acceptPromise = runAccept() - .then(() => { - acceptOkRef.current = true - }) - .catch((err: unknown) => { - acceptErrorRef.current = err instanceof Error ? err.message : 'Ошибка' - throw err - }) - acceptPromiseRef.current = acceptPromise - void acceptPromise.catch(() => undefined) - return new Promise((resolve) => { resolveStartRef.current = resolve + + void (async () => { + const reduced = prefersReducedMotion() + reducedRef.current = reduced + Object.assign(ravenFxRef.current, defaultRavenFx) + + let ravens = !reduced + if (ravens) { + try { + await import('./RavenBurstCanvas') + } catch { + ravens = false + } + } + + setMountRavens(ravens) + setShowOverlay(true) + setPhase('playing') + document.documentElement.classList.add('gate-transit-lock') + + acceptOkRef.current = false + acceptErrorRef.current = null + navigatedRef.current = false + timelineRef.current = null + + const acceptPromise = runAccept() + .then(() => { + acceptOkRef.current = true + }) + .catch((err: unknown) => { + acceptErrorRef.current = err instanceof Error ? err.message : 'Ошибка' + throw err + }) + acceptPromiseRef.current = acceptPromise + void acceptPromise.catch(() => undefined) + })() }) }, [phase], @@ -314,6 +352,11 @@ export function GateTransitProvider({ children }: { children: ReactNode }) { draggable={false} /> + {mountRavens && ( + + + + )}
, document.body, diff --git a/src/vfx/RavenBurstCanvas.tsx b/src/vfx/RavenBurstCanvas.tsx new file mode 100644 index 0000000..f483edf --- /dev/null +++ b/src/vfx/RavenBurstCanvas.tsx @@ -0,0 +1,32 @@ +import { Suspense, type MutableRefObject, type RefObject } from 'react' +import { Canvas } from '@react-three/fiber' +import type { RavenFx } from './ravenFx' +import { RavenFlock } from './RavenFlock' + +type RavenBurstCanvasProps = { + fxRef: MutableRefObject + wrapRef: RefObject +} + +export function RavenBurstCanvas({ fxRef, wrapRef }: RavenBurstCanvasProps) { + return ( +
+ + + + + +
+ ) +} diff --git a/src/vfx/RavenFlock.tsx b/src/vfx/RavenFlock.tsx new file mode 100644 index 0000000..1665533 --- /dev/null +++ b/src/vfx/RavenFlock.tsx @@ -0,0 +1,170 @@ +import { useLayoutEffect, useMemo, useRef } from 'react' +import { useFrame, useLoader, useThree } from '@react-three/fiber' +import * as THREE from 'three' +import type { MutableRefObject } from 'react' +import type { RavenFx } from './ravenFx' + +const COUNT = 18 +const APEX_Z = -2.8 +const CONE_SLOPE = 0.42 +const AXIS_SPEED = 5.2 +const CAM_Z = 5.2 + +type Bird = { + active: boolean + born: boolean + depth: number + theta: number + phase: number + flapSpeed: number + size: number + flip: number + delay: number + speed: number + slope: number +} + +function createBirds(): Bird[] { + const birds: Bird[] = [] + for (let i = 0; i < COUNT; i++) { + const wave = Math.floor(i / 3) + const theta = (i / COUNT) * Math.PI * 2 + (Math.random() - 0.5) * 0.25 + birds.push({ + active: false, + born: false, + depth: 0, + theta, + phase: Math.random() * Math.PI * 2, + flapSpeed: 10 + Math.random() * 6, + size: 0.2 + Math.random() * 0.28, + flip: i % 2 === 0 ? -1 : 1, + delay: 0.03 * wave + Math.random() * 0.04, + speed: AXIS_SPEED * (0.85 + Math.random() * 0.35), + slope: CONE_SLOPE * (0.9 + Math.random() * 0.25), + }) + } + return birds +} + +/** Viewport CSS px → world on the apex plane. Uses the raven canvas rect for correct NDC. */ +function screenToApexWorld( + sx: number, + sy: number, + camera: THREE.PerspectiveCamera, + glDom: HTMLCanvasElement | null, + out: THREE.Vector3, +) { + const canvasRect = glDom?.getBoundingClientRect() + const left = canvasRect?.left ?? 0 + const top = canvasRect?.top ?? 0 + const viewW = canvasRect?.width || window.innerWidth + const viewH = canvasRect?.height || window.innerHeight + + const ndcX = ((sx - left) / viewW) * 2 - 1 + const ndcY = -((sy - top) / viewH) * 2 + 1 + + const dist = Math.abs(CAM_Z - APEX_Z) + const vFov = THREE.MathUtils.degToRad(camera.fov) + const halfH = Math.tan(vFov / 2) * dist + const halfW = halfH * (viewW / viewH) + out.set(ndcX * halfW, ndcY * halfH, APEX_Z) +} + +type RavenFlockProps = { + fx: MutableRefObject +} + +export function RavenFlock({ fx }: RavenFlockProps) { + const meshRef = useRef(null) + const birds = useMemo(() => createBirds(), []) + const dummy = useMemo(() => new THREE.Object3D(), []) + const apex = useMemo(() => new THREE.Vector3(0, 0, APEX_Z), []) + const texture = useLoader(THREE.TextureLoader, '/raven-silhouette.svg') + const gl = useThree((s) => s.gl) + + useLayoutEffect(() => { + texture.colorSpace = THREE.SRGBColorSpace + texture.anisotropy = 4 + texture.needsUpdate = true + }, [texture]) + + useFrame((state, delta) => { + const mesh = meshRef.current + if (!mesh) return + + const cam = state.camera as THREE.PerspectiveCamera + // Fixed camera — do NOT recenter on apex (that pinned birds to screen center) + cam.position.set(0, 0, CAM_Z) + cam.lookAt(0, 0, 0) + cam.updateMatrixWorld() + + const { burst, opacity, apexSx, apexSy } = fx.current + if (Number.isFinite(apexSx) && Number.isFinite(apexSy)) { + screenToApexWorld(apexSx, apexSy, cam, gl.domElement, apex) + } + + const mat = mesh.material as THREE.MeshBasicMaterial + mat.opacity = opacity + + const dt = Math.min(delta, 0.05) + const t = state.clock.elapsedTime + + for (let i = 0; i < COUNT; i++) { + const bird = birds[i] + + if (!bird.born && burst > bird.delay) { + bird.born = true + bird.active = true + bird.depth = 0.02 + Math.random() * 0.06 + } + + if (!bird.active) { + dummy.position.set(0, -40, 0) + dummy.scale.set(0, 0, 0) + dummy.updateMatrix() + mesh.setMatrixAt(i, dummy.matrix) + continue + } + + bird.depth += bird.speed * dt + + // Cone tip at portal; base // screen (XY) + const radius = bird.depth * bird.slope + const x = apex.x + Math.cos(bird.theta) * radius + const y = apex.y + Math.sin(bird.theta) * radius + const z = apex.z + bird.depth + + const flap = 1 + Math.sin(t * bird.flapSpeed + bird.phase) * 0.18 + const grow = THREE.MathUtils.smoothstep(bird.depth, 0.15, 3.2) + const s = bird.size * flap * (0.15 + grow * 1.55) + + dummy.position.set(x, y, z) + dummy.quaternion.copy(cam.quaternion) + dummy.rotateY(bird.flip < 0 ? Math.PI : 0) + dummy.rotateZ(Math.sin(t * bird.flapSpeed + bird.phase) * 0.16) + dummy.scale.set(s * 1.45, s, 1) + dummy.updateMatrix() + mesh.setMatrixAt(i, dummy.matrix) + + if (bird.depth > 7.5) { + bird.active = false + } + } + + mesh.instanceMatrix.needsUpdate = true + }) + + return ( + + + + + ) +} diff --git a/src/vfx/gateTimeline.ts b/src/vfx/gateTimeline.ts index 81b72e0..b7eaa38 100644 --- a/src/vfx/gateTimeline.ts +++ b/src/vfx/gateTimeline.ts @@ -1,4 +1,5 @@ import gsap from 'gsap' +import { defaultRavenFx, type RavenFx } from './ravenFx' export type GateTimelineHandlers = { onBlackout: () => void @@ -12,14 +13,20 @@ export type LogoFlyTargets = { originalLogo: HTMLElement | null veil: HTMLElement | null stage: HTMLElement | null + ravenLayer: HTMLElement | null + ravenFx: RavenFx } /** Continuously fly into logo.png (central black arch), then void → home. */ export function createLogoFlyTimeline(targets: LogoFlyTargets, handlers: GateTimelineHandlers) { - const { hero, uiFade, veil, stage } = targets + const { hero, uiFade, veil, stage, ravenLayer, ravenFx } = targets + + // Reset animatable raven fields only — keep captured portal screen position + const { apexSx, apexSy } = ravenFx + Object.assign(ravenFx, defaultRavenFx, { apexSx, apexSy }) gsap.set(hero, { - transformOrigin: '50% 46%', + transformOrigin: '50% 50%', transformPerspective: 1400, force3D: true, scale: 1, @@ -36,6 +43,10 @@ export function createLogoFlyTimeline(targets: LogoFlyTargets, handlers: GateTim gsap.set(veil, { opacity: 0 }) } + if (ravenLayer) { + gsap.set(ravenLayer, { opacity: 1 }) + } + const tl = gsap.timeline({ onComplete: () => handlers.onComplete(), }) @@ -69,6 +80,35 @@ export function createLogoFlyTimeline(targets: LogoFlyTargets, handlers: GateTim 0.05, ) + // Ravens burst immediately as the zoom starts (apex locked to portal) + tl.to( + ravenFx, + { + opacity: 1, + duration: 0.2, + ease: 'power1.out', + }, + 0.05, + ) + tl.to( + ravenFx, + { + burst: 1, + duration: 0.85, + ease: 'power2.out', + }, + 0.08, + ) + tl.to( + ravenFx, + { + opacity: 0, + duration: 0.4, + ease: 'power2.in', + }, + 1.55, + ) + tl.to( hero, { @@ -103,7 +143,7 @@ export function createReducedLogoFlyTimeline( ) { const { hero, uiFade, veil } = targets - gsap.set(hero, { transformOrigin: '50% 46%', scale: 1 }) + gsap.set(hero, { transformOrigin: '50% 50%', scale: 1 }) if (veil) gsap.set(veil, { opacity: 0 }) const tl = gsap.timeline({ onComplete: () => handlers.onComplete() }) diff --git a/src/vfx/ravenFx.ts b/src/vfx/ravenFx.ts new file mode 100644 index 0000000..3bfb658 --- /dev/null +++ b/src/vfx/ravenFx.ts @@ -0,0 +1,32 @@ +export type RavenFx = { + /** 0–1 spawn/burst progress */ + burst: number + /** Layer opacity */ + opacity: number + /** + * Fixed screen-pixel position of the gate image center. + * Captured once at scale=1; stays locked during zoom. + * NaN = not set yet. + */ + apexSx: number + apexSy: number +} + +export const defaultRavenFx: RavenFx = { + burst: 0, + opacity: 0, + apexSx: Number.NaN, + apexSy: Number.NaN, +} + +/** Point on the gate image: horizontal center, 50% from top. */ +export const PORTAL_ORIGIN = { x: 0.5, y: 0.5 } as const + +/** Center of the gate/logo image in viewport CSS pixels. */ +export function capturePortalScreen(el: HTMLElement): { x: number; y: number } { + const rect = el.getBoundingClientRect() + return { + x: rect.left + rect.width * PORTAL_ORIGIN.x, + y: rect.top + rect.height * PORTAL_ORIGIN.y, + } +}