188 lines
6.0 KiB
TypeScript
188 lines
6.0 KiB
TypeScript
"use client"
|
|
import { useEffect, useRef } from "react"
|
|
// import "@lottiefiles/dotlottie-wc" // Removed static import to fix SSR error
|
|
|
|
const STOP_DELAY = 700
|
|
const LADDER_OFFSET_BOTTOM = 80
|
|
const LADDER_OFFSET_TOP = 20 // Buffer to keep character from going off-screen at the top
|
|
|
|
export default function LadderClimb() {
|
|
const charRef = useRef<HTMLDivElement>(null)
|
|
const ladderRef = useRef<HTMLDivElement>(null)
|
|
|
|
const idleRef = useRef<HTMLDivElement>(null)
|
|
const climbRef = useRef<HTMLDivElement>(null)
|
|
const jumpRef = useRef<HTMLDivElement>(null)
|
|
|
|
const ladderMaxTravel = useRef(0)
|
|
const activeState = useRef<"idle" | "climbing" | "jumping">("idle")
|
|
const scrollTimeout = useRef<NodeJS.Timeout | null>(null)
|
|
const isFalling = useRef(false)
|
|
|
|
const switchAnimation = (newState: "idle" | "climbing" | "jumping") => {
|
|
if (activeState.current === newState) return
|
|
activeState.current = newState
|
|
|
|
if (!idleRef.current || !climbRef.current || !jumpRef.current) return
|
|
|
|
idleRef.current.style.display = "none"
|
|
climbRef.current.style.display = "none"
|
|
jumpRef.current.style.display = "none"
|
|
|
|
if (newState === "idle") idleRef.current.style.display = "block"
|
|
else if (newState === "climbing") climbRef.current.style.display = "block"
|
|
else if (newState === "jumping") jumpRef.current.style.display = "block"
|
|
}
|
|
|
|
const triggerFall = () => {
|
|
if (!charRef.current || isFalling.current) return
|
|
|
|
const style = window.getComputedStyle(charRef.current)
|
|
const matrix = new DOMMatrix(style.transform)
|
|
const currentVisualY = Math.abs(matrix.m42)
|
|
|
|
if (currentVisualY < 5) {
|
|
switchAnimation("idle")
|
|
return
|
|
}
|
|
|
|
isFalling.current = true
|
|
switchAnimation("jumping")
|
|
|
|
charRef.current.classList.add("falling")
|
|
charRef.current.style.transform = `translateY(0px)`
|
|
|
|
const handleLanding = (e: TransitionEvent) => {
|
|
if (e.propertyName !== "transform") return
|
|
isFalling.current = false
|
|
charRef.current?.classList.remove("falling")
|
|
// Force idle animation upon landing
|
|
switchAnimation("idle")
|
|
charRef.current?.removeEventListener("transitionend", handleLanding)
|
|
}
|
|
charRef.current.addEventListener("transitionend", handleLanding)
|
|
}
|
|
|
|
const updateDimensions = () => {
|
|
if (ladderRef.current) {
|
|
// Logic: The character can only travel the height of the ladder minus the offsets
|
|
ladderMaxTravel.current = ladderRef.current.clientHeight - LADDER_OFFSET_BOTTOM - LADDER_OFFSET_TOP
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
import("@lottiefiles/dotlottie-wc")
|
|
updateDimensions()
|
|
window.addEventListener("resize", updateDimensions)
|
|
|
|
const handleScroll = () => {
|
|
if (!charRef.current) return
|
|
|
|
const scrollTop = window.scrollY
|
|
const docHeight = document.body.scrollHeight - window.innerHeight
|
|
let progress = docHeight > 0 ? scrollTop / docHeight : 0
|
|
|
|
// Clamp progress between 0 and 1
|
|
progress = Math.max(0, Math.min(1, progress))
|
|
|
|
// Calculate Target Y based on the ladder's actual height constraint
|
|
const targetY = progress * ladderMaxTravel.current
|
|
|
|
// Interrupt fall if user starts scrolling
|
|
if (isFalling.current) {
|
|
isFalling.current = false
|
|
charRef.current.classList.remove("falling")
|
|
}
|
|
|
|
// Apply transformation (constrained to the top of the ladder)
|
|
charRef.current.style.transform = `translateY(-${targetY}px)`
|
|
|
|
// Set climbing animation
|
|
if (activeState.current !== "climbing") switchAnimation("climbing")
|
|
|
|
// Stop detection
|
|
if (scrollTimeout.current) clearTimeout(scrollTimeout.current)
|
|
scrollTimeout.current = setTimeout(() => {
|
|
if (targetY > 2) {
|
|
triggerFall()
|
|
} else {
|
|
switchAnimation("idle")
|
|
}
|
|
}, STOP_DELAY)
|
|
}
|
|
|
|
window.addEventListener("scroll", handleScroll, { passive: true })
|
|
handleScroll()
|
|
|
|
return () => {
|
|
window.removeEventListener("scroll", handleScroll)
|
|
window.removeEventListener("resize", updateDimensions)
|
|
if (scrollTimeout.current) clearTimeout(scrollTimeout.current)
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<>
|
|
<div
|
|
ref={ladderRef}
|
|
style={{
|
|
position: "fixed",
|
|
right: 10,
|
|
top: 80, // Top margin of the ladder rail
|
|
height: "calc(100vh - 80px)", // Ladder length
|
|
width: "40px",
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
alignItems: "flex-end",
|
|
zIndex: 999,
|
|
pointerEvents: "none"
|
|
}}
|
|
>
|
|
{/* The Visual Ladder */}
|
|
<div
|
|
style={{
|
|
height: "100%",
|
|
width: "30px",
|
|
background: "repeating-linear-gradient(to bottom, #444 0, #444 4px, transparent 4px, transparent 30px)",
|
|
borderLeft: "4px solid #444",
|
|
borderRight: "4px solid #444",
|
|
zIndex: 1
|
|
}}
|
|
/>
|
|
|
|
{/* The Character Container */}
|
|
<div
|
|
ref={charRef}
|
|
style={{
|
|
position: "absolute",
|
|
bottom: 0, // Starts at the bottom of the ladderRef
|
|
left: "50%",
|
|
marginLeft: "-90px",
|
|
zIndex: 10,
|
|
willChange: "transform"
|
|
}}
|
|
>
|
|
<style>{`
|
|
.falling {
|
|
transition: transform 0.7s cubic-bezier(0.55, 0.055, 0.675, 0.19) !important;
|
|
}
|
|
`}</style>
|
|
|
|
<div ref={idleRef} style={{ display: 'block' }}>
|
|
<dotlottie-wc src="https://lottie.host/13145c5f-8acc-4ea9-8842-58a458ef5553/b1W7KoG3lt.lottie" autoplay loop style={{ width: '180px' }} />
|
|
</div>
|
|
|
|
<div ref={climbRef} style={{ display: 'none' }}>
|
|
<dotlottie-wc src="https://lottie.host/97e44afd-2200-44a5-8d60-8db614c54362/cqzrUWVWdo.lottie" autoplay loop style={{ width: '180px' }} />
|
|
</div>
|
|
|
|
<div ref={jumpRef} style={{ display: 'none' }}>
|
|
<dotlottie-wc src="https://lottie.host/5fc8dea4-e109-4c3b-813d-afd4da96973b/fdslVCb92l.lottie" autoplay style={{ width: '180px' }} />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</>
|
|
)
|
|
}
|