Add raven animation feature with SVG assets and Three.js integration

- Introduced a new SVG asset for the raven silhouette.
- Updated CSS to position and style the raven animation layer.
- Implemented `RavenBurstCanvas` and `RavenFlock` components for rendering animated ravens using Three.js.
- Enhanced `GateTransitProvider` to manage raven animation state and integrate with existing transition logic.
- Added `ravenFx` type and default values for controlling raven animation properties.
- Adjusted transform origins and z-index for improved visual alignment during transitions.
This commit is contained in:
Redsandy
2026-08-12 12:41:12 +03:00
parent 59d4a691d6
commit dfe445349b
7 changed files with 380 additions and 30 deletions

View File

@@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="160" viewBox="0 0 256 160">
<g fill="#0a0b08">
<!-- body -->
<ellipse cx="128" cy="88" rx="28" ry="16" />
<!-- neck + head -->
<path d="M100 82c-10-4-22-4-32 2-3 2-7 1-8-2 6-10 20-16 34-14 6 1 10 6 11 12 0 2-2 3-5 2z" />
<ellipse cx="78" cy="78" rx="14" ry="10" transform="rotate(-18 78 78)" />
<!-- beak -->
<path d="M64 76l-18 2 18 5z" />
<!-- left wing -->
<path d="M120 80c-28-22-62-36-96-34-6 0-8-6-3-9 38-16 78-8 108 14 8 6 10 16 4 22-4 3-9 4-13 7z" />
<!-- right wing -->
<path d="M136 78c26-24 60-40 96-42 6 0 9 6 4 9-34 18-66 34-90 52-6 4-14 2-16-4-2-6 2-12 6-15z" />
<!-- tail -->
<path d="M148 92c16 8 28 24 30 40 0 4-5 5-7 2-8-14-18-26-32-34-3-2-2-7 2-8 2 0 5 0 7 0z" />
<!-- legs tucked -->
<path d="M122 100c2 10 1 18-2 24-1 2-4 1-4-1 2-8 2-15 0-22 0-2 3-3 6-1z" />
</g>
<circle cx="72" cy="74" r="2.6" fill="#c8d97d" />
</svg>

After

Width:  |  Height:  |  Size: 968 B

View File

@@ -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;

View File

@@ -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<GateTransitPhase>('idle')
const [showOverlay, setShowOverlay] = useState(false)
const [mountRavens, setMountRavens] = useState(false)
const veilRef = useRef<HTMLDivElement | null>(null)
const stageRef = useRef<HTMLDivElement | null>(null)
const heroRef = useRef<HTMLImageElement | null>(null)
const ravenWrapRef = useRef<HTMLDivElement | null>(null)
const ravenFxRef = useRef<RavenFx>({ ...defaultRavenFx })
const timelineRef = useRef<gsap.core.Timeline | null>(null)
const acceptPromiseRef = useRef<Promise<void> | null>(null)
const acceptErrorRef = useRef<string | null>(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<void>) => {
@@ -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<StartResult>((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}
/>
</div>
{mountRavens && (
<Suspense fallback={null}>
<RavenBurstCanvas fxRef={ravenFxRef} wrapRef={ravenWrapRef} />
</Suspense>
)}
<div className="gate-transit__veil" ref={veilRef} />
</div>,
document.body,

View File

@@ -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<RavenFx>
wrapRef: RefObject<HTMLDivElement | null>
}
export function RavenBurstCanvas({ fxRef, wrapRef }: RavenBurstCanvasProps) {
return (
<div className="gate-transit__ravens" ref={wrapRef}>
<Canvas
dpr={[1, 1.25]}
gl={{
alpha: true,
antialias: false,
powerPreference: 'high-performance',
stencil: false,
depth: true,
}}
camera={{ position: [0, 0, 5.2], fov: 42, near: 0.05, far: 40 }}
style={{ background: 'transparent' }}
>
<Suspense fallback={null}>
<RavenFlock fx={fxRef} />
</Suspense>
</Canvas>
</div>
)
}

170
src/vfx/RavenFlock.tsx Normal file
View File

@@ -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<RavenFx>
}
export function RavenFlock({ fx }: RavenFlockProps) {
const meshRef = useRef<THREE.InstancedMesh>(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 (
<instancedMesh ref={meshRef} args={[undefined, undefined, COUNT]} frustumCulled={false}>
<planeGeometry args={[1, 0.55]} />
<meshBasicMaterial
map={texture}
color="#0a0b08"
transparent
depthWrite={false}
side={THREE.DoubleSide}
toneMapped={false}
/>
</instancedMesh>
)
}

View File

@@ -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() })

32
src/vfx/ravenFx.ts Normal file
View File

@@ -0,0 +1,32 @@
export type RavenFx = {
/** 01 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,
}
}