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:
20
public/raven-silhouette.svg
Normal file
20
public/raven-silhouette.svg
Normal 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 |
@@ -564,7 +564,7 @@ html.gate-transit-lock body {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
perspective: 1400px;
|
perspective: 1400px;
|
||||||
perspective-origin: 50% 46%;
|
perspective-origin: 50% 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gate-transit__hero {
|
.gate-transit__hero {
|
||||||
@@ -582,8 +582,21 @@ html.gate-transit-lock body {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
filter: drop-shadow(0 10px 32px rgba(0, 0, 0, 0.6));
|
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 {
|
.gate-transit__veil {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
@@ -17,6 +19,11 @@ import {
|
|||||||
createReducedLogoFlyTimeline,
|
createReducedLogoFlyTimeline,
|
||||||
type LogoFlyTargets,
|
type LogoFlyTargets,
|
||||||
} from './gateTimeline'
|
} from './gateTimeline'
|
||||||
|
import { capturePortalScreen, defaultRavenFx, type RavenFx } from './ravenFx'
|
||||||
|
|
||||||
|
const RavenBurstCanvas = lazy(() =>
|
||||||
|
import('./RavenBurstCanvas').then((m) => ({ default: m.RavenBurstCanvas })),
|
||||||
|
)
|
||||||
|
|
||||||
type StartResult = {
|
type StartResult = {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
@@ -54,7 +61,6 @@ function collectUiFade(gate: HTMLElement, logo: HTMLElement | null): HTMLElement
|
|||||||
|
|
||||||
function placeHeroOverLogo(hero: HTMLElement, logo: HTMLElement) {
|
function placeHeroOverLogo(hero: HTMLElement, logo: HTMLElement) {
|
||||||
const rect = logo.getBoundingClientRect()
|
const rect = logo.getBoundingClientRect()
|
||||||
// Geometry only — keep invisible until the original logo is hidden in the same frame
|
|
||||||
gsap.set(hero, {
|
gsap.set(hero, {
|
||||||
position: 'fixed',
|
position: 'fixed',
|
||||||
top: rect.top,
|
top: rect.top,
|
||||||
@@ -62,7 +68,7 @@ function placeHeroOverLogo(hero: HTMLElement, logo: HTMLElement) {
|
|||||||
width: rect.width,
|
width: rect.width,
|
||||||
height: rect.height,
|
height: rect.height,
|
||||||
margin: 0,
|
margin: 0,
|
||||||
zIndex: 2,
|
zIndex: 1,
|
||||||
opacity: 0,
|
opacity: 0,
|
||||||
visibility: 'hidden',
|
visibility: 'hidden',
|
||||||
scale: 1,
|
scale: 1,
|
||||||
@@ -70,7 +76,7 @@ function placeHeroOverLogo(hero: HTMLElement, logo: HTMLElement) {
|
|||||||
z: 0,
|
z: 0,
|
||||||
filter: 'drop-shadow(0 10px 32px rgba(0, 0, 0, 0.6))',
|
filter: 'drop-shadow(0 10px 32px rgba(0, 0, 0, 0.6))',
|
||||||
objectFit: 'contain',
|
objectFit: 'contain',
|
||||||
transformOrigin: '50% 46%',
|
transformOrigin: '50% 50%',
|
||||||
transformPerspective: 1400,
|
transformPerspective: 1400,
|
||||||
force3D: true,
|
force3D: true,
|
||||||
willChange: 'transform, filter, opacity',
|
willChange: 'transform, filter, opacity',
|
||||||
@@ -86,10 +92,13 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [phase, setPhase] = useState<GateTransitPhase>('idle')
|
const [phase, setPhase] = useState<GateTransitPhase>('idle')
|
||||||
const [showOverlay, setShowOverlay] = useState(false)
|
const [showOverlay, setShowOverlay] = useState(false)
|
||||||
|
const [mountRavens, setMountRavens] = useState(false)
|
||||||
|
|
||||||
const veilRef = useRef<HTMLDivElement | null>(null)
|
const veilRef = useRef<HTMLDivElement | null>(null)
|
||||||
const stageRef = useRef<HTMLDivElement | null>(null)
|
const stageRef = useRef<HTMLDivElement | null>(null)
|
||||||
const heroRef = useRef<HTMLImageElement | 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 timelineRef = useRef<gsap.core.Timeline | null>(null)
|
||||||
const acceptPromiseRef = useRef<Promise<void> | null>(null)
|
const acceptPromiseRef = useRef<Promise<void> | null>(null)
|
||||||
const acceptErrorRef = useRef<string | null>(null)
|
const acceptErrorRef = useRef<string | null>(null)
|
||||||
@@ -116,7 +125,9 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
|
|||||||
(result: StartResult) => {
|
(result: StartResult) => {
|
||||||
timelineRef.current?.kill()
|
timelineRef.current?.kill()
|
||||||
timelineRef.current = null
|
timelineRef.current = null
|
||||||
|
setMountRavens(false)
|
||||||
setShowOverlay(false)
|
setShowOverlay(false)
|
||||||
|
Object.assign(ravenFxRef.current, defaultRavenFx)
|
||||||
setPhase(result.ok ? 'done' : 'error')
|
setPhase(result.ok ? 'done' : 'error')
|
||||||
cleanupDomLocks()
|
cleanupDomLocks()
|
||||||
resolveStartRef.current?.(result)
|
resolveStartRef.current?.(result)
|
||||||
@@ -191,18 +202,24 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
placeHeroOverLogo(hero, logo)
|
placeHeroOverLogo(hero, logo)
|
||||||
|
|
||||||
// One frame with hero invisible + correct box, then atomic swap with the page logo
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if (timelineRef.current) return
|
if (timelineRef.current) return
|
||||||
placeHeroOverLogo(hero, logo)
|
placeHeroOverLogo(hero, logo)
|
||||||
revealHeroOverLogo(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 = {
|
const targets: LogoFlyTargets = {
|
||||||
hero,
|
hero,
|
||||||
uiFade: collectUiFade(gate, logo),
|
uiFade: collectUiFade(gate, logo),
|
||||||
originalLogo: logo,
|
originalLogo: logo,
|
||||||
veil: veilRef.current,
|
veil: veilRef.current,
|
||||||
stage: stageRef.current,
|
stage: stageRef.current,
|
||||||
|
ravenLayer: ravenWrapRef.current,
|
||||||
|
ravenFx: ravenFxRef.current,
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlers = {
|
const handlers = {
|
||||||
@@ -236,6 +253,12 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
|
|||||||
finish({ ok: false, error: 'Не удалось запустить переход' })
|
finish({ ok: false, error: 'Не удалось запустить переход' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Wait a beat for lazy raven canvas when enabled
|
||||||
|
if (mountRavens && !ravenWrapRef.current && tries < 40) {
|
||||||
|
tries += 1
|
||||||
|
window.setTimeout(tick, 40)
|
||||||
|
return
|
||||||
|
}
|
||||||
startTimeline()
|
startTimeline()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,7 +267,7 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
window.clearTimeout(id)
|
window.clearTimeout(id)
|
||||||
}
|
}
|
||||||
}, [phase, showOverlay, startTimeline, finish])
|
}, [phase, showOverlay, mountRavens, startTimeline, finish])
|
||||||
|
|
||||||
const start = useCallback(
|
const start = useCallback(
|
||||||
(runAccept: () => Promise<void>) => {
|
(runAccept: () => Promise<void>) => {
|
||||||
@@ -252,7 +275,24 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
|
|||||||
return Promise.resolve({ ok: false, error: 'Переход уже идёт' })
|
return Promise.resolve({ ok: false, error: 'Переход уже идёт' })
|
||||||
}
|
}
|
||||||
|
|
||||||
reducedRef.current = prefersReducedMotion()
|
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)
|
setShowOverlay(true)
|
||||||
setPhase('playing')
|
setPhase('playing')
|
||||||
document.documentElement.classList.add('gate-transit-lock')
|
document.documentElement.classList.add('gate-transit-lock')
|
||||||
@@ -272,9 +312,7 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
|
|||||||
})
|
})
|
||||||
acceptPromiseRef.current = acceptPromise
|
acceptPromiseRef.current = acceptPromise
|
||||||
void acceptPromise.catch(() => undefined)
|
void acceptPromise.catch(() => undefined)
|
||||||
|
})()
|
||||||
return new Promise<StartResult>((resolve) => {
|
|
||||||
resolveStartRef.current = resolve
|
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
[phase],
|
[phase],
|
||||||
@@ -314,6 +352,11 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
|
|||||||
draggable={false}
|
draggable={false}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{mountRavens && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<RavenBurstCanvas fxRef={ravenFxRef} wrapRef={ravenWrapRef} />
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
<div className="gate-transit__veil" ref={veilRef} />
|
<div className="gate-transit__veil" ref={veilRef} />
|
||||||
</div>,
|
</div>,
|
||||||
document.body,
|
document.body,
|
||||||
|
|||||||
32
src/vfx/RavenBurstCanvas.tsx
Normal file
32
src/vfx/RavenBurstCanvas.tsx
Normal 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
170
src/vfx/RavenFlock.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import gsap from 'gsap'
|
import gsap from 'gsap'
|
||||||
|
import { defaultRavenFx, type RavenFx } from './ravenFx'
|
||||||
|
|
||||||
export type GateTimelineHandlers = {
|
export type GateTimelineHandlers = {
|
||||||
onBlackout: () => void
|
onBlackout: () => void
|
||||||
@@ -12,14 +13,20 @@ export type LogoFlyTargets = {
|
|||||||
originalLogo: HTMLElement | null
|
originalLogo: HTMLElement | null
|
||||||
veil: HTMLElement | null
|
veil: HTMLElement | null
|
||||||
stage: HTMLElement | null
|
stage: HTMLElement | null
|
||||||
|
ravenLayer: HTMLElement | null
|
||||||
|
ravenFx: RavenFx
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Continuously fly into logo.png (central black arch), then void → home. */
|
/** Continuously fly into logo.png (central black arch), then void → home. */
|
||||||
export function createLogoFlyTimeline(targets: LogoFlyTargets, handlers: GateTimelineHandlers) {
|
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, {
|
gsap.set(hero, {
|
||||||
transformOrigin: '50% 46%',
|
transformOrigin: '50% 50%',
|
||||||
transformPerspective: 1400,
|
transformPerspective: 1400,
|
||||||
force3D: true,
|
force3D: true,
|
||||||
scale: 1,
|
scale: 1,
|
||||||
@@ -36,6 +43,10 @@ export function createLogoFlyTimeline(targets: LogoFlyTargets, handlers: GateTim
|
|||||||
gsap.set(veil, { opacity: 0 })
|
gsap.set(veil, { opacity: 0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ravenLayer) {
|
||||||
|
gsap.set(ravenLayer, { opacity: 1 })
|
||||||
|
}
|
||||||
|
|
||||||
const tl = gsap.timeline({
|
const tl = gsap.timeline({
|
||||||
onComplete: () => handlers.onComplete(),
|
onComplete: () => handlers.onComplete(),
|
||||||
})
|
})
|
||||||
@@ -69,6 +80,35 @@ export function createLogoFlyTimeline(targets: LogoFlyTargets, handlers: GateTim
|
|||||||
0.05,
|
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(
|
tl.to(
|
||||||
hero,
|
hero,
|
||||||
{
|
{
|
||||||
@@ -103,7 +143,7 @@ export function createReducedLogoFlyTimeline(
|
|||||||
) {
|
) {
|
||||||
const { hero, uiFade, veil } = targets
|
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 })
|
if (veil) gsap.set(veil, { opacity: 0 })
|
||||||
|
|
||||||
const tl = gsap.timeline({ onComplete: () => handlers.onComplete() })
|
const tl = gsap.timeline({ onComplete: () => handlers.onComplete() })
|
||||||
|
|||||||
32
src/vfx/ravenFx.ts
Normal file
32
src/vfx/ravenFx.ts
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user