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,
+ }
+}