- 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.
171 lines
4.8 KiB
TypeScript
171 lines
4.8 KiB
TypeScript
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>
|
|
)
|
|
}
|