410 lines
14 KiB
TypeScript
410 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useRef, useState, Suspense, useMemo } from "react";
|
|
import gsap from "gsap";
|
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|
import { motion, AnimatePresence } from "framer-motion";
|
|
import { Canvas, useFrame, useThree } from "@react-three/fiber";
|
|
import { useTexture } from "@react-three/drei";
|
|
import * as THREE from "three";
|
|
import Image from "next/image";
|
|
|
|
gsap.registerPlugin(ScrollTrigger);
|
|
|
|
// Custom shader source for storefront door clipping
|
|
const vertexShader = `
|
|
varying vec2 vUv;
|
|
void main() {
|
|
vUv = uv;
|
|
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
|
|
}
|
|
`;
|
|
|
|
const facadeFragmentShader = `
|
|
uniform sampler2D uTexture;
|
|
uniform float uOpacity;
|
|
varying vec2 vUv;
|
|
void main() {
|
|
vec4 col = texture2D(uTexture, vUv);
|
|
// Discard double doors in center of storefront
|
|
if (vUv.x >= 0.468 && vUv.x <= 0.532 && vUv.y >= 0.15 && vUv.y <= 0.50) {
|
|
discard;
|
|
}
|
|
gl_FragColor = vec4(col.rgb, col.a * uOpacity);
|
|
}
|
|
`;
|
|
|
|
const leftDoorFragmentShader = `
|
|
uniform sampler2D uTexture;
|
|
uniform float uOpacity;
|
|
varying vec2 vUv;
|
|
void main() {
|
|
// Crop texture to left door leaf boundaries
|
|
vec2 uv = vec2(0.468 + vUv.x * 0.032, 0.15 + vUv.y * 0.35);
|
|
vec4 col = texture2D(uTexture, uv);
|
|
gl_FragColor = vec4(col.rgb, col.a * uOpacity);
|
|
}
|
|
`;
|
|
|
|
const rightDoorFragmentShader = `
|
|
uniform sampler2D uTexture;
|
|
uniform float uOpacity;
|
|
varying vec2 vUv;
|
|
void main() {
|
|
// Crop texture to right door leaf boundaries
|
|
vec2 uv = vec2(0.50 + vUv.x * 0.032, 0.15 + vUv.y * 0.35);
|
|
vec4 col = texture2D(uTexture, uv);
|
|
gl_FragColor = vec4(col.rgb, col.a * uOpacity);
|
|
}
|
|
`;
|
|
|
|
// Helper notifier to hide CSS loader once textures resolve
|
|
function LoaderNotifier({ onLoad }: { onLoad: () => void }) {
|
|
useEffect(() => {
|
|
onLoad();
|
|
}, [onLoad]);
|
|
return null;
|
|
}
|
|
|
|
// 3D Cinematic flight renderer
|
|
function CinematicFlight({ progress }: { progress: number }) {
|
|
const exteriorTex = useTexture("/images/my_dosa_place_exterior_webgl.jpg");
|
|
const interiorTex = useTexture("/images/my_dosa_place_interior_webgl.jpg");
|
|
const foodTex = useTexture("/images/my_dosa_place_food_webgl.jpg");
|
|
|
|
const leftDoorRef = useRef<THREE.Group>(null);
|
|
const rightDoorRef = useRef<THREE.Group>(null);
|
|
|
|
// Set texture filtering to high quality linear
|
|
useEffect(() => {
|
|
[exteriorTex, interiorTex, foodTex].forEach((tex) => {
|
|
tex.minFilter = THREE.LinearFilter;
|
|
tex.magFilter = THREE.LinearFilter;
|
|
tex.generateMipmaps = false;
|
|
});
|
|
}, [exteriorTex, interiorTex, foodTex]);
|
|
|
|
const { width, height } = useThree((state) => state.viewport);
|
|
|
|
// Compute facade dimensions based on dusk photo aspect ratio
|
|
const aspect = 1920 / 864;
|
|
const planeH = height;
|
|
const planeW = planeH * aspect;
|
|
|
|
// Door dimensions matching facade coordinate percentages
|
|
const dw = 0.032 * planeW;
|
|
const dh = 0.35 * planeH;
|
|
const doorY = -0.175 * planeH;
|
|
const leftHingeX = -0.032 * planeW;
|
|
const rightHingeX = 0.032 * planeW;
|
|
|
|
const extGroupRef = useRef<THREE.Group>(null);
|
|
const intMeshRef = useRef<THREE.Mesh>(null);
|
|
const foodMeshRef = useRef<THREE.Mesh>(null);
|
|
|
|
// Refs for materials to update opacities in the high-performance frame loop
|
|
const facadeMatRef = useRef<THREE.ShaderMaterial>(null);
|
|
const leftDoorMatRef = useRef<THREE.ShaderMaterial>(null);
|
|
const rightDoorMatRef = useRef<THREE.ShaderMaterial>(null);
|
|
const intMatRef = useRef<THREE.MeshBasicMaterial>(null);
|
|
const foodMatRef = useRef<THREE.MeshBasicMaterial>(null);
|
|
|
|
// Smooth frame loop camera zoom and door swing animations
|
|
useFrame(() => {
|
|
// 1. Storefront zoom and centering translation
|
|
// Scale goes from 1.0 to 5.5
|
|
const targetExtScale = 1.0 + progress * 4.5;
|
|
if (extGroupRef.current) {
|
|
extGroupRef.current.scale.setScalar(THREE.MathUtils.lerp(extGroupRef.current.scale.x, targetExtScale, 0.08));
|
|
// Pan Y upward to center the doors as we scale
|
|
const currentScale = extGroupRef.current.scale.x;
|
|
const targetY = -doorY * (currentScale - 1.0);
|
|
extGroupRef.current.position.y = THREE.MathUtils.lerp(extGroupRef.current.position.y, targetY, 0.08);
|
|
}
|
|
|
|
// 2. Door opening rotation (swing inward Y-axis)
|
|
let doorProgress = 0;
|
|
if (progress >= 0.08 && progress <= 0.25) {
|
|
doorProgress = (progress - 0.08) / 0.17;
|
|
} else if (progress > 0.25) {
|
|
doorProgress = 1;
|
|
}
|
|
const angle = (Math.PI / 1.8) * doorProgress;
|
|
|
|
if (leftDoorRef.current) {
|
|
leftDoorRef.current.rotation.y = THREE.MathUtils.lerp(leftDoorRef.current.rotation.y, angle, 0.08);
|
|
}
|
|
if (rightDoorRef.current) {
|
|
rightDoorRef.current.rotation.y = THREE.MathUtils.lerp(rightDoorRef.current.rotation.y, -angle, 0.08);
|
|
}
|
|
|
|
// 3. Interior Dining Hall zoom
|
|
// Scale goes from 1.0 to 2.6
|
|
let intProgress = 0;
|
|
if (progress > 0.08) {
|
|
intProgress = Math.min(1, (progress - 0.08) / 0.62);
|
|
}
|
|
const targetIntScale = 1.0 + intProgress * 1.6;
|
|
if (intMeshRef.current) {
|
|
intMeshRef.current.scale.setScalar(THREE.MathUtils.lerp(intMeshRef.current.scale.x, targetIntScale, 0.08));
|
|
}
|
|
|
|
// 4. Food Platter landing zoom
|
|
// Scale goes from 0.8 to 1.15
|
|
let foodProgress = 0;
|
|
if (progress > 0.58) {
|
|
foodProgress = Math.min(1, (progress - 0.58) / 0.27);
|
|
}
|
|
const targetFoodScale = 0.8 + foodProgress * 0.35;
|
|
if (foodMeshRef.current) {
|
|
foodMeshRef.current.scale.setScalar(THREE.MathUtils.lerp(foodMeshRef.current.scale.x, targetFoodScale, 0.08));
|
|
}
|
|
|
|
// 5. Reactive Opacity Updates inside useFrame to prevent unmounting stutters
|
|
const extOpacity = progress < 0.25 ? 1 : Math.max(0, 1 - (progress - 0.25) / 0.15);
|
|
|
|
let intOpacity = 0;
|
|
if (progress >= 0.08 && progress <= 0.25) {
|
|
intOpacity = (progress - 0.08) / 0.17;
|
|
} else if (progress > 0.25 && progress < 0.60) {
|
|
intOpacity = 1;
|
|
} else if (progress >= 0.60 && progress <= 0.78) {
|
|
intOpacity = Math.max(0, 1 - (progress - 0.60) / 0.18);
|
|
} else if (progress > 0.78) {
|
|
intOpacity = 0;
|
|
}
|
|
|
|
const foodOpacity = progress < 0.58 ? 0 : Math.min(1, (progress - 0.58) / 0.20);
|
|
|
|
// Apply opacities to materials
|
|
if (facadeMatRef.current) facadeMatRef.current.uniforms.uOpacity.value = extOpacity;
|
|
if (leftDoorMatRef.current) leftDoorMatRef.current.uniforms.uOpacity.value = extOpacity;
|
|
if (rightDoorMatRef.current) rightDoorMatRef.current.uniforms.uOpacity.value = extOpacity;
|
|
if (intMatRef.current) intMatRef.current.opacity = intOpacity;
|
|
if (foodMatRef.current) foodMatRef.current.opacity = foodOpacity;
|
|
});
|
|
|
|
// Memoize initial shader uniforms
|
|
const facadeUniforms = useMemo(() => ({
|
|
uTexture: { value: exteriorTex },
|
|
uOpacity: { value: 1 }
|
|
}), [exteriorTex]);
|
|
|
|
const leftDoorUniforms = useMemo(() => ({
|
|
uTexture: { value: exteriorTex },
|
|
uOpacity: { value: 1 }
|
|
}), [exteriorTex]);
|
|
|
|
const rightDoorUniforms = useMemo(() => ({
|
|
uTexture: { value: exteriorTex },
|
|
uOpacity: { value: 1 }
|
|
}), [exteriorTex]);
|
|
|
|
return (
|
|
<>
|
|
<ambientLight intensity={1.5} />
|
|
<directionalLight position={[0, 5, 5]} intensity={1.5} />
|
|
|
|
{/* Plane 1: Storefront Facade & 3D Swinging Door Panels (Z = 0.02) */}
|
|
<group ref={extGroupRef} position={[0, 0, 0.02]}>
|
|
<mesh>
|
|
<planeGeometry args={[planeW, planeH]} />
|
|
<shaderMaterial
|
|
ref={facadeMatRef}
|
|
vertexShader={vertexShader}
|
|
fragmentShader={facadeFragmentShader}
|
|
uniforms={facadeUniforms}
|
|
transparent
|
|
depthWrite={false}
|
|
/>
|
|
</mesh>
|
|
|
|
{/* Left Door Hinge Pivot Group */}
|
|
<group ref={leftDoorRef} position={[leftHingeX, doorY, 0.002]}>
|
|
<mesh position={[dw / 2, 0, 0]}>
|
|
<planeGeometry args={[dw, dh]} />
|
|
<shaderMaterial
|
|
ref={leftDoorMatRef}
|
|
vertexShader={vertexShader}
|
|
fragmentShader={leftDoorFragmentShader}
|
|
uniforms={leftDoorUniforms}
|
|
transparent
|
|
depthWrite={false}
|
|
/>
|
|
</mesh>
|
|
</group>
|
|
|
|
{/* Right Door Hinge Pivot Group */}
|
|
<group ref={rightDoorRef} position={[rightHingeX, doorY, 0.002]}>
|
|
<mesh position={[-dw / 2, 0, 0]}>
|
|
<planeGeometry args={[dw, dh]} />
|
|
<shaderMaterial
|
|
ref={rightDoorMatRef}
|
|
vertexShader={vertexShader}
|
|
fragmentShader={rightDoorFragmentShader}
|
|
uniforms={rightDoorUniforms}
|
|
transparent
|
|
depthWrite={false}
|
|
/>
|
|
</mesh>
|
|
</group>
|
|
</group>
|
|
|
|
{/* Plane 2: Pillars Dining Hall (Z = 0.01) */}
|
|
<mesh ref={intMeshRef} position={[0, 0, 0.01]}>
|
|
<planeGeometry args={[planeW * 1.1, planeH * 1.1]} />
|
|
<meshBasicMaterial
|
|
ref={intMatRef}
|
|
map={interiorTex}
|
|
transparent
|
|
opacity={0}
|
|
depthWrite={false}
|
|
/>
|
|
</mesh>
|
|
|
|
{/* Plane 3: South Indian Platter Table (Z = 0.0) */}
|
|
<mesh ref={foodMeshRef} position={[0, 0, 0.0]}>
|
|
<planeGeometry args={[planeW * 1.1, planeH * 1.1]} />
|
|
<meshBasicMaterial
|
|
ref={foodMatRef}
|
|
map={foodTex}
|
|
transparent
|
|
opacity={0}
|
|
depthWrite={false}
|
|
/>
|
|
</mesh>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function EntranceExperience() {
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const scrollIndicatorRef = useRef<HTMLDivElement>(null);
|
|
|
|
const [scrollProgress, setScrollProgress] = useState(0);
|
|
const [loaded, setLoaded] = useState(false);
|
|
const [isMounted, setIsMounted] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setIsMounted(true);
|
|
}, []);
|
|
|
|
// Sync GSAP pinning scrub triggers
|
|
useEffect(() => {
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
const ctx = gsap.context(() => {
|
|
const scrollIndicator = scrollIndicatorRef.current;
|
|
|
|
const tl = gsap.timeline({
|
|
scrollTrigger: {
|
|
trigger: container,
|
|
start: "top top",
|
|
end: "+=450%",
|
|
scrub: 1.2,
|
|
pin: true,
|
|
onUpdate: (self) => {
|
|
setScrollProgress(self.progress);
|
|
},
|
|
},
|
|
});
|
|
|
|
// Initial fade-out of the scroll indicator
|
|
if (scrollIndicator) {
|
|
tl.to(scrollIndicator, {
|
|
opacity: 0,
|
|
y: -25,
|
|
duration: 1,
|
|
});
|
|
}
|
|
}, container);
|
|
|
|
return () => ctx.revert();
|
|
}, []);
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="relative w-full h-screen overflow-hidden bg-[#050c08] select-none"
|
|
>
|
|
{/* Traditional loading spinner overlay */}
|
|
{!loaded && (
|
|
<div className="absolute inset-0 flex flex-col justify-center items-center z-50 bg-[#050c08]">
|
|
<div className="w-12 h-12 rounded-full border-2 border-gold/30 border-t-gold animate-spin mb-4" />
|
|
<span className="font-serif text-gold text-xs tracking-[0.3em] uppercase animate-pulse">
|
|
Entering My Dosa Place...
|
|
</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Three.js R3F WebGL Scene */}
|
|
{isMounted && (
|
|
<div className="absolute inset-0 w-full h-full z-0">
|
|
<Canvas>
|
|
<Suspense fallback={null}>
|
|
<CinematicFlight progress={scrollProgress} />
|
|
<LoaderNotifier onLoad={() => setLoaded(true)} />
|
|
</Suspense>
|
|
</Canvas>
|
|
</div>
|
|
)}
|
|
|
|
{/* Screen edge lighting overlays */}
|
|
<div className="absolute inset-0 bg-[radial-gradient(circle,_transparent_40%,_rgba(0,0,0,0.5)_95%)] pointer-events-none z-10" />
|
|
|
|
{/* ----------------- OVERLAY HERO ELEMENT (OUTSIDE STATE) ----------------- */}
|
|
<div className="absolute inset-0 flex flex-col justify-end items-center pb-20 px-6 z-30 pointer-events-none">
|
|
{/* Scroll Indicator */}
|
|
<div
|
|
ref={scrollIndicatorRef}
|
|
className="flex flex-col items-center gap-3 animate-bounce"
|
|
>
|
|
<div className="w-6 h-10 rounded-full border border-gold/45 flex justify-center p-1">
|
|
<div className="w-1.5 h-2.5 bg-gold rounded-full animate-[scroll-dot_1.5s_infinite_linear]" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ----------------- FOOD PLATTER STORYTELLING CARD (REVEALS AT END) ----------------- */}
|
|
<AnimatePresence>
|
|
{scrollProgress > 0.75 && (
|
|
<motion.div
|
|
initial={{ opacity: 0, x: -50, scale: 0.95 }}
|
|
animate={{ opacity: 1, x: 0, scale: 1 }}
|
|
exit={{ opacity: 0, x: -50, scale: 0.95 }}
|
|
transition={{ duration: 0.8, ease: "easeOut" }}
|
|
className="absolute top-28 left-8 z-30 w-[340px] sm:w-[400px] max-w-[calc(100vw-32px)] max-sm:top-auto max-sm:bottom-16 max-sm:left-4 max-sm:right-4 max-sm:mx-auto bg-[#fcfaf2]/95 border-2 border-double border-gold/45 rounded-3xl p-6 sm:p-8 shadow-2xl pointer-events-none"
|
|
>
|
|
{/* Inner traditional gold frame */}
|
|
<div className="absolute top-2 left-2 right-2 bottom-2 border border-gold/15 pointer-events-none rounded-[22px]" />
|
|
|
|
<span className="font-sans text-[#188754] text-[10px] sm:text-[11px] tracking-[0.4em] uppercase font-bold block mb-2.5 text-center mt-1">
|
|
P U R E V E G E T A R I A N F E A S T
|
|
</span>
|
|
|
|
<h3 className="font-serif text-[#188754] text-xl sm:text-2xl tracking-[0.15em] uppercase font-bold mb-3 text-center">
|
|
A Royal Banquet
|
|
</h3>
|
|
|
|
<div className="w-24 h-[1px] bg-gradient-to-r from-transparent via-[#d8c396] to-transparent mx-auto mb-4" />
|
|
|
|
<p className="font-sans text-xs sm:text-[13px] text-stone-800 leading-relaxed tracking-wider font-medium text-justify">
|
|
Your table is set. Experience a royal banquet served on fresh banana leaves, where crispy ghee-roasted dosas, steaming lentil sambar, and hand-ground chutneys await you.
|
|
</p>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Styled inline keyframes animations */}
|
|
<style jsx>{`
|
|
@keyframes scroll-dot {
|
|
0% { transform: translateY(0); opacity: 1; }
|
|
50% { transform: translateY(6px); opacity: 0.3; }
|
|
100% { transform: translateY(0); opacity: 1; }
|
|
}
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|