my-dosa-place-cinematic/src/components/EntranceExperience.tsx

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>
);
}