From a69f841927b70bf578eef95fad68b32bc9153356 Mon Sep 17 00:00:00 2001 From: Simantak Dabhade Date: Sun, 1 Mar 2026 01:45:47 -0800 Subject: [PATCH] added the enchange image overlay system --- app/.gitignore | 3 + app/app/api/enhance-image/route.ts | 14 ++- app/app/game/page.tsx | 113 ++++++++++++++++++++++++ app/app/globals.css | 134 +++++++++++++++++++++++++++-- app/app/hooks/useImageEnhancer.ts | 60 ++++++++----- app/app/hooks/useLocationNav.ts | 2 +- app/app/hooks/useThreeScene.ts | 8 +- app/app/layout.tsx | 4 +- app/app/page.tsx | 100 +++++++++++++++++++-- app/public/enhanced-cache/.gitkeep | 0 10 files changed, 393 insertions(+), 45 deletions(-) create mode 100644 app/app/game/page.tsx create mode 100644 app/public/enhanced-cache/.gitkeep diff --git a/app/.gitignore b/app/.gitignore index 5ef6a52..2fe4f22 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# enhanced image cache (generated at runtime) +/public/enhanced-cache/*.png diff --git a/app/app/api/enhance-image/route.ts b/app/app/api/enhance-image/route.ts index a981b05..3d40113 100644 --- a/app/app/api/enhance-image/route.ts +++ b/app/app/api/enhance-image/route.ts @@ -1,12 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import { generateImageFromBase64 } from "@/app/utils/gemini"; +import { writeFile, mkdir } from "fs/promises"; +import { join } from "path"; export async function POST(req: NextRequest) { - const { imageDataUrl } = await req.json(); + const { imageDataUrl, locationKey } = await req.json(); const base64 = imageDataUrl.replace(/^data:image\/\w+;base64,/, ""); const result = await generateImageFromBase64( base64, "This image is blurry. Can you please clear it up? Make it feel a bit darker and gloomier.", ); + + // Persist to disk so the cache survives server restarts. + if (locationKey && result.imageDataUrl) { + const imgBase64 = result.imageDataUrl.replace(/^data:image\/\w+;base64,/, ""); + const buffer = Buffer.from(imgBase64, "base64"); + const cacheDir = join(process.cwd(), "public", "enhanced-cache"); + await mkdir(cacheDir, { recursive: true }); + await writeFile(join(cacheDir, `${locationKey}.png`), buffer); + } + return NextResponse.json({ imageDataUrl: result.imageDataUrl }); } diff --git a/app/app/game/page.tsx b/app/app/game/page.tsx new file mode 100644 index 0000000..97e775f --- /dev/null +++ b/app/app/game/page.tsx @@ -0,0 +1,113 @@ +"use client"; + +import Link from "next/link"; + +export default function LandingPage() { + return ( +
+ + {/* Film grain */} +
+ ); +} diff --git a/app/app/globals.css b/app/app/globals.css index a2dc41e..72c4d15 100644 --- a/app/app/globals.css +++ b/app/app/globals.css @@ -1,8 +1,8 @@ @import "tailwindcss"; :root { - --background: #ffffff; - --foreground: #171717; + --background: #000000; + --foreground: #ededed; } @theme inline { @@ -12,15 +12,131 @@ --font-mono: var(--font-geist-mono); } -@media (prefers-color-scheme: dark) { - :root { - --background: #0a0a0a; - --foreground: #ededed; - } -} - body { background: var(--background); color: var(--foreground); font-family: Arial, Helvetica, sans-serif; } + +/* ─── Landing page animations ─────────────────────────────────────────────── */ + +@keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(28px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Occasional flicker on the title — subtle, not seizure-inducing */ +@keyframes flicker { + 0%, + 94%, + 96%, + 100% { + opacity: 1; + } + 95% { + opacity: 0.75; + } + 97% { + opacity: 0.6; + } + 98%, + 99% { + opacity: 0.9; + } +} + +/* Chromatic-aberration glitch that fires every ~7s */ +@keyframes glitch { + 0%, + 82%, + 100% { + text-shadow: none; + transform: skewX(0deg) translateX(0); + } + 83% { + text-shadow: 0.05em 0 0 rgba(220, 38, 38, 0.85), + -0.05em 0 0 rgba(0, 210, 255, 0.45); + transform: skewX(-2deg) translateX(-4px); + } + 85% { + text-shadow: -0.05em 0 0 rgba(220, 38, 38, 0.85), + 0.05em 0 0 rgba(0, 210, 255, 0.45); + transform: skewX(1.5deg) translateX(4px); + } + 87% { + text-shadow: 0.02em 0 0 rgba(220, 38, 38, 0.6); + transform: skewX(0deg) translateX(-2px); + } + 89% { + text-shadow: none; + transform: none; + } +} + +/* Pulsing red glow on the CTA button */ +@keyframes pulse-glow { + 0%, + 100% { + box-shadow: 0 0 8px rgba(153, 27, 27, 0.3); + } + 50% { + box-shadow: 0 0 28px rgba(153, 27, 27, 0.65), + 0 0 60px rgba(153, 27, 27, 0.2); + } +} + +/* Film grain — SVG noise tile shifts position every frame */ +@keyframes grain { + 0%, + 100% { + transform: translate(0, 0); + } + 20% { + transform: translate(-6%, -10%); + } + 40% { + transform: translate(8%, 6%); + } + 60% { + transform: translate(-4%, 12%); + } + 80% { + transform: translate(10%, -8%); + } +} + +/* ─── Landing page overlays ───────────────────────────────────────────────── */ + +/* Film grain */ +.grain-overlay { + position: fixed; + inset: -50%; + width: 200%; + height: 200%; + z-index: 5; + pointer-events: none; + opacity: 0.045; + background-image: url("data:image/svg+xml,"); + animation: grain 0.7s steps(2) infinite; +} + +/* CRT scanlines */ +.scanlines-overlay { + position: fixed; + inset: 0; + z-index: 6; + pointer-events: none; + background: repeating-linear-gradient( + to bottom, + transparent, + transparent 3px, + rgba(0, 0, 0, 0.06) 3px, + rgba(0, 0, 0, 0.06) 4px + ); +} diff --git a/app/app/hooks/useImageEnhancer.ts b/app/app/hooks/useImageEnhancer.ts index 01900a5..e43ad4e 100644 --- a/app/app/hooks/useImageEnhancer.ts +++ b/app/app/hooks/useImageEnhancer.ts @@ -6,15 +6,24 @@ * Manages the full lifecycle of capturing, enhancing, and displaying an * AI-generated overlay image when the player arrives at a location. * + * Cache layers (checked in order): + * 1. In-memory (useRef Map) — instant, lives for the session. + * 2. Disk (public/enhanced-cache/{key}.png) — survives server restarts. + * 3. Gemini API — called only on a full cache miss. + * * Flow on first visit to a location: * 1. captureCanvas() grabs a PNG of the current Three.js frame. * 2. The PNG is POSTed to /api/enhance-image (which calls the Gemini API). - * 3. The returned enhanced image URL is stored in the in-memory cache. - * 4. The overlay fades in over the 3D scene. + * 3. The server writes the result to public/enhanced-cache/{key}.png. + * 4. The returned enhanced image URL is stored in the in-memory cache. + * 5. The overlay fades in over the 3D scene. + * + * Flow on repeat visits (same session): + * - In-memory cache hit → overlay shows immediately, no network call. * - * Flow on repeat visits: - * - The cache is checked first. If a URL exists for that location key, - * the overlay shows immediately with no network call. + * Flow on repeat visits (after restart): + * - HEAD /enhanced-cache/{key}.png returns 200 → use the static file URL, + * populate in-memory cache, show overlay. No Gemini call. * * Dismissal: * - dismissOverlay() starts a CSS opacity transition to 0, then clears the @@ -27,7 +36,7 @@ export function useImageEnhancer(captureCanvas: () => string) { // Whether the Gemini API call is in flight. Drives the "Enhancing..." badge. const [isEnhancing, setIsEnhancing] = useState(false); - // The data URL of the enhanced image, or null when no overlay is active. + // The data URL (or static path) of the enhanced image, or null when no overlay is active. const [enhancedImageUrl, setEnhancedImageUrl] = useState(null); // Controls the CSS opacity transition. Separated from enhancedImageUrl so we @@ -35,7 +44,7 @@ export function useImageEnhancer(captureCanvas: () => string) { // to finish before the is unmounted). const [overlayVisible, setOverlayVisible] = useState(false); - // Per-location cache: locationKey → enhanced image data URL. + // Per-location cache: locationKey → enhanced image URL (data URL or static path). // Stored in a ref so it persists across renders without triggering re-renders. const imageCache = useRef>(new Map()); @@ -51,24 +60,36 @@ export function useImageEnhancer(captureCanvas: () => string) { setTimeout(() => setEnhancedImageUrl(null), 500); }; + const showImage = (url: string) => { + setEnhancedImageUrl(url); + // requestAnimationFrame defers the opacity change by one paint cycle, + // which is required for the CSS transition to fire (the element must be + // rendered at opacity-0 before we flip to opacity-100). + requestAnimationFrame(() => setOverlayVisible(true)); + }; + // Called when the camera arrives at a location (via useLocationNav's onArrival). // locationKey matches the keys in locations.json (e.g. "kitchen", "hallway"). const enhanceForLocation = async (locationKey: string) => { // Don't stack multiple enhancement calls. if (isEnhancingRef.current) return; - // Cache hit: show the stored image immediately, no API call needed. + // L1 — In-memory cache: show immediately, no network call. if (imageCache.current.has(locationKey)) { - setEnhancedImageUrl(imageCache.current.get(locationKey)!); - // requestAnimationFrame defers the opacity change by one paint cycle, - // which is required for the CSS transition to fire (the element must be - // rendered at opacity-0 before we flip to opacity-100). - requestAnimationFrame(() => setOverlayVisible(true)); + showImage(imageCache.current.get(locationKey)!); + return; + } + + // L2 — Disk cache: check if the file was saved from a previous session. + const diskUrl = `/enhanced-cache/${locationKey}.png`; + const diskCheck = await fetch(diskUrl, { method: "HEAD" }); + if (diskCheck.ok) { + imageCache.current.set(locationKey, diskUrl); + showImage(diskUrl); return; } - // Capture the current frame before we start the async work. - // Returns "" if the renderer isn't ready, in which case we bail out. + // L3 — Full miss: capture the frame, call Gemini, persist to disk. const dataUrl = captureCanvas(); if (!dataUrl) return; @@ -78,12 +99,11 @@ export function useImageEnhancer(captureCanvas: () => string) { const res = await fetch("/api/enhance-image", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ imageDataUrl: dataUrl }), + body: JSON.stringify({ imageDataUrl: dataUrl, locationKey }), }); const { imageDataUrl } = await res.json(); - imageCache.current.set(locationKey, imageDataUrl); // populate cache - setEnhancedImageUrl(imageDataUrl); - requestAnimationFrame(() => setOverlayVisible(true)); + imageCache.current.set(locationKey, imageDataUrl); + showImage(imageDataUrl); } finally { // Always clear the in-flight flag, even if the request failed. isEnhancingRef.current = false; @@ -92,7 +112,7 @@ export function useImageEnhancer(captureCanvas: () => string) { }; return { - enhancedImageUrl, // data URL to pass to , or null + enhancedImageUrl, // URL to pass to , or null overlayVisible, // drives the opacity class (true = opacity-100) isEnhancing, // true while the API call is in flight enhanceForLocation, // call with a location key to trigger the flow diff --git a/app/app/hooks/useLocationNav.ts b/app/app/hooks/useLocationNav.ts index 38a8892..8ecfc61 100644 --- a/app/app/hooks/useLocationNav.ts +++ b/app/app/hooks/useLocationNav.ts @@ -96,7 +96,7 @@ export function useLocationNav({ // 0.05 units is close enough to consider "arrived" — small enough to be // imperceptible to the player but large enough to trigger reliably given // the 0.02 lerp alpha (camera never reaches exact target with lerp). - if (Math.sqrt(dx * dx + dy * dy + dz * dz) < 0.05) { + if (Math.sqrt(dx * dx + dy * dy + dz * dz) < 0.15) { const key = pendingCaptureLocationRef.current; pendingCaptureLocationRef.current = null; // stop watching onArrivalRef.current(key); diff --git a/app/app/hooks/useThreeScene.ts b/app/app/hooks/useThreeScene.ts index ed7887e..a89fcbf 100644 --- a/app/app/hooks/useThreeScene.ts +++ b/app/app/hooks/useThreeScene.ts @@ -168,10 +168,10 @@ export function useThreeScene() { // lower = smoother/slower, higher = snappier/faster. renderer.setAnimationLoop(() => { // Smoothly walk the camera toward its target position and rotation. - camera.position.lerp(cameraTarget, 0.02); - camera.rotation.x = THREE.MathUtils.lerp(camera.rotation.x, rotationTargetRef.current!.x, 0.02); - camera.rotation.y = THREE.MathUtils.lerp(camera.rotation.y, rotationTargetRef.current!.y, 0.02); - camera.rotation.z = THREE.MathUtils.lerp(camera.rotation.z, rotationTargetRef.current!.z, 0.02); + camera.position.lerp(cameraTarget, 0.03); + camera.rotation.x = THREE.MathUtils.lerp(camera.rotation.x, rotationTargetRef.current!.x, 0.03); + camera.rotation.y = THREE.MathUtils.lerp(camera.rotation.y, rotationTargetRef.current!.y, 0.03); + camera.rotation.z = THREE.MathUtils.lerp(camera.rotation.z, rotationTargetRef.current!.z, 0.03); // Smoothly move the splat mesh toward its target position. splatMesh.position.lerp(objectTargetRef.current, 0.05); diff --git a/app/app/layout.tsx b/app/app/layout.tsx index f7fa87e..e119b20 100644 --- a/app/app/layout.tsx +++ b/app/app/layout.tsx @@ -13,8 +13,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "GET OUT", + description: "A dangerous room. An endless nightmare. Guide Kyle to safety — if you can.", }; export default function RootLayout({ diff --git a/app/app/page.tsx b/app/app/page.tsx index ca068fe..c2f7a75 100644 --- a/app/app/page.tsx +++ b/app/app/page.tsx @@ -19,11 +19,99 @@ * dismissOverlay ──► useLocationNav (onNavigate callback) */ -import { useState } from "react"; +import { useState, useRef, useEffect } from "react"; import { useThreeScene } from "./hooks/useThreeScene"; import { useImageEnhancer } from "./hooks/useImageEnhancer"; import { useLocationNav } from "./hooks/useLocationNav"; +function easeOutCubic(t: number) { + return 1 - Math.pow(1 - t, 3); +} + +// Reveals an image by expanding ink-splotch blobs — like paper dropped in water. +// Blurry expanding ellipses form a soft mask; the image shows only where they cover. +function SplotchReveal({ src, visible }: { src: string; visible: boolean }) { + const canvasRef = useRef(null); + const animRef = useRef(0); + + useEffect(() => { + if (!visible) return; + + const canvas = canvasRef.current; + if (!canvas) return; + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + const dpr = window.devicePixelRatio || 1; + const W = canvas.clientWidth; + const H = canvas.clientHeight; + if (W === 0 || H === 0) return; + + canvas.width = W * dpr; + canvas.height = H * dpr; + ctx.scale(dpr, dpr); + + const maxDim = Math.max(W, H); + const splotches = Array.from({ length: 18 }, () => ({ + x: Math.random() * W, + y: Math.random() * H, + rx: 0.75 + Math.random() * 0.5, + ry: 0.75 + Math.random() * 0.5, + angle: Math.random() * Math.PI, + maxR: maxDim * (0.22 + Math.random() * 0.38), + delay: Math.random() * 0.45, + dur: 0.55 + Math.random() * 0.7, + })); + + const img = new Image(); + img.onload = () => { + const start = Date.now(); + const scale = Math.max(W / img.naturalWidth, H / img.naturalHeight); + const iw = img.naturalWidth * scale; + const ih = img.naturalHeight * scale; + const ix = (W - iw) / 2; + const iy = (H - ih) / 2; + + const frame = () => { + const elapsed = (Date.now() - start) / 1000; + ctx.clearRect(0, 0, W, H); + + // Draw blurry splotches as a white mask + ctx.globalCompositeOperation = "source-over"; + ctx.filter = "blur(14px)"; + ctx.fillStyle = "white"; + let done = true; + for (const s of splotches) { + const t = Math.max(0, Math.min(1, (elapsed - s.delay) / s.dur)); + if (t < 1) done = false; + const r = easeOutCubic(t) * s.maxR; + ctx.beginPath(); + ctx.ellipse(s.x, s.y, r * s.rx, r * s.ry, s.angle, 0, Math.PI * 2); + ctx.fill(); + } + ctx.filter = "none"; + + // Clip image to wherever splotches exist + ctx.globalCompositeOperation = "source-in"; + ctx.drawImage(img, ix, iy, iw, ih); + + if (!done) animRef.current = requestAnimationFrame(frame); + }; + animRef.current = requestAnimationFrame(frame); + }; + img.src = src; + + return () => cancelAnimationFrame(animRef.current); + }, [src, visible]); + + return ( + + ); +} + export default function Home() { // X/Y/Z inputs — used by the manual "Move Camera" and "Move Object" buttons. const [x, setX] = useState("0"); @@ -145,15 +233,11 @@ export default function Home() { {/* Three.js mounts its into this div */}
- {/* Enhanced image overlay — fades in/out via CSS transition on opacity. - The is only in the DOM while enhancedImageUrl is set, which + {/* Enhanced image overlay — revealed via ink-splotch canvas animation. + The canvas is only in the DOM while enhancedImageUrl is set, which prevents a flash of the previous image during navigation. */} {enhancedImageUrl && ( - Enhanced view + )} ); diff --git a/app/public/enhanced-cache/.gitkeep b/app/public/enhanced-cache/.gitkeep new file mode 100644 index 0000000..e69de29