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;
|
||||
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;
|
||||
|
||||
@@ -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,7 +275,24 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
|
||||
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)
|
||||
setPhase('playing')
|
||||
document.documentElement.classList.add('gate-transit-lock')
|
||||
@@ -272,9 +312,7 @@ export function GateTransitProvider({ children }: { children: ReactNode }) {
|
||||
})
|
||||
acceptPromiseRef.current = acceptPromise
|
||||
void acceptPromise.catch(() => undefined)
|
||||
|
||||
return new Promise<StartResult>((resolve) => {
|
||||
resolveStartRef.current = resolve
|
||||
})()
|
||||
})
|
||||
},
|
||||
[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,
|
||||
|
||||
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 { 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
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