Feat: Adding a launch animation for Catalysis launch. - #61
Conversation
|
@Akshat-Raj is attempting to deploy a commit to the Genesis' projects Team on Vercel. A member of the Team first needs to authorize it. |
There was a problem hiding this comment.
Pull request overview
Adds a new /genesis route that plays a full-screen “Catalysis launch” animation using Framer Motion.
Changes:
- Introduces
GenesisAnimationclient component implementing the multi-phase launch sequence (stars, lightning, Pokéball split, logo/CTA reveal). - Adds
/genesispage and a dedicated layout intended to remove global chrome (Navbar/Footer/AudioPlayer). - Adds
framer-motiondependency to support the animation.
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| src/components/ui/GenesisAnimation.tsx | Implements the full-screen launch animation and click-to-start gate. |
| src/app/genesis/page.tsx | Adds the /genesis page that renders the animation component. |
| src/app/genesis/layout.tsx | Adds a route-segment layout intended to provide a clean full-screen canvas. |
| package.json | Adds framer-motion dependency. |
| package-lock.json | Locks framer-motion and related dependencies. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return ( | ||
| <html lang="en" className="h-full"> | ||
| <body className="h-full m-0 p-0 overflow-hidden bg-black"> | ||
| {children} | ||
| </body> | ||
| </html> | ||
| ); |
There was a problem hiding this comment.
In a route segment layout (src/app/genesis/layout.tsx), returning and is not supported by Next.js app router—only the root layout should define those tags. This can cause build/runtime errors. Wrap children in a div/section and move any body-level styling to that wrapper (or use a template), leaving / in src/app/layout.tsx only.
| return ( | |
| <html lang="en" className="h-full"> | |
| <body className="h-full m-0 p-0 overflow-hidden bg-black"> | |
| {children} | |
| </body> | |
| </html> | |
| ); | |
| return <div className="h-full m-0 p-0 overflow-hidden bg-black">{children}</div>; |
| import GenesisAnimation from "@/components/ui/GenesisAnimation"; | ||
|
|
||
| /** | ||
| * /genesis — Full-screen 5-phase Catalysis fight-to-logo animation. |
There was a problem hiding this comment.
The JSDoc says this is a “5-phase” animation, but GenesisAnimation runs more phases (up through Phase 9 + idle loop). Please update the comment so it accurately describes the current sequence to avoid confusing future edits.
| * /genesis — Full-screen 5-phase Catalysis fight-to-logo animation. | |
| * /genesis — Full-screen Catalysis fight-to-logo animation sequence | |
| * that progresses through Phase 9 before entering an idle loop. |
| // Precomputed at module level — avoids Math.random() inside render (react-hooks/purity) | ||
| const STARS = Array.from({ length: 70 }, (_, i) => ({ | ||
| id: i, | ||
| top: `${Math.random() * 100}%`, | ||
| left: `${Math.random() * 100}%`, | ||
| size: Math.random() * 2.5 + 0.5, | ||
| delay: Math.random() * 4, | ||
| dur: Math.random() * 3 + 2, | ||
| })); | ||
| import Image from "next/image"; | ||
|
|
There was a problem hiding this comment.
Imports are split by executable code: STARS is declared before import Image from "next/image". This is inconsistent with the rest of the codebase’s import-at-top style (e.g., src/components/ui/SplashScreen.tsx:1-5) and can trip lint rules like import/first. Move the Image import up with the other imports before declaring module-level constants.
| const triggerShake = () => { | ||
| setShaking(true); | ||
| setTimeout(() => setShaking(false), 260); | ||
| }; | ||
|
|
||
| // Animation only runs after the user clicks the Pokéball | ||
| useEffect(() => { | ||
| if (!started) return; | ||
|
|
||
| const run = async () => { | ||
| // ── PHASE 1: Pokémon + Pikachu pop in ──────────────────────────────── | ||
| await Promise.all([ | ||
| animate(".pikachu", { opacity: 1, scale: [0, 1.05, 1] }, { duration: 0.4 }), | ||
| ...POKEMONS.map((_, i) => | ||
| animate(`.pokemon-${i}`, { opacity: 1, scale: [0, 1.12, 1] }, { duration: 0.4, delay: 0.06 + i * 0.1 }) | ||
| ), | ||
| ]); | ||
|
|
||
| // ── PHASE 2: Pikachu charges up ─────────────────────────────────────── | ||
| setCharged(true); | ||
| await new Promise<void>((r) => setTimeout(r, 700)); | ||
|
|
||
| // ── PHASE 3: Five sequential lightning strikes ───────────────────────── | ||
| for (let i = 0; i < POKEMONS.length; i++) { | ||
| setLightningPhase(i); | ||
| setHitSet((prev) => new Set([...prev, i])); | ||
| triggerShake(); | ||
| setGlobalFlash(true); | ||
| setTimeout(() => setGlobalFlash(false), 160); | ||
| await new Promise<void>((r) => setTimeout(r, 370)); |
There was a problem hiding this comment.
The animation effect schedules multiple setTimeouts (e.g., in triggerShake and the lightning loop) and also starts an infinite animation on .catalysis-logo without any cleanup. If the user navigates away mid-sequence, this can trigger state updates after unmount and leave animations running. Add a cleanup function in the useEffect that tracks/clears all timeouts and stops any repeat: Infinity animation (store the returned controls and call stop()), guarded by an aborted flag.
| {/* ───────────────────────────────────────────────────────────────────── | ||
| ANIMATION CONTENT — always in DOM, starts invisible, driven by | ||
| the useEffect animation sequence that begins on `started = true`. | ||
| ───────────────────────────────────────────────────────────────────── */} | ||
|
|
||
|
|
||
| {/* Global flash overlay */} | ||
| <motion.div | ||
| className="absolute inset-0 pointer-events-none" | ||
| style={{ | ||
| zIndex: 50, | ||
| background: "radial-gradient(ellipse at center, rgba(255,255,160,0.95) 0%, rgba(255,220,0,0.6) 35%, transparent 65%)", | ||
| }} | ||
| animate={{ opacity: globalFlash ? 0.75 : 0 }} | ||
| transition={{ duration: globalFlash ? 0.05 : 0.25 }} | ||
| /> | ||
|
|
||
| {/* Lightning SVG */} | ||
| <div className="absolute inset-0 pointer-events-none" style={{ zIndex: 30 }}> | ||
| <svg width="100%" height="100%" viewBox={`0 0 ${dims.w} ${dims.h}`}> | ||
| <defs> | ||
| <filter id="bolt-glow" x="-60%" y="-60%" width="220%" height="220%"> | ||
| <feGaussianBlur stdDeviation="5" result="blur" /> | ||
| <feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge> | ||
| </filter> | ||
| <filter id="core-glow" x="-40%" y="-40%" width="180%" height="180%"> | ||
| <feGaussianBlur stdDeviation="2" result="blur" /> | ||
| <feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge> | ||
| </filter> | ||
| </defs> | ||
| {POKEMONS.map((_, i) => ( | ||
| <g key={i}> | ||
| <motion.path d={lightningPaths[i]} stroke="#FFD700" strokeWidth="10" strokeLinecap="round" fill="none" filter="url(#bolt-glow)" | ||
| initial={{ pathLength: 0, opacity: 0 }} | ||
| animate={i <= lightningPhase ? { pathLength: [0, 1], opacity: [0, 0.55, 0.4] } : { pathLength: 0, opacity: 0 }} | ||
| transition={i <= lightningPhase ? { duration: 0.22, ease: "easeOut" } : { duration: 0.15 }} | ||
| /> | ||
| <motion.path d={lightningPaths[i]} stroke="#FFFFF0" strokeWidth="2.5" strokeLinecap="round" fill="none" filter="url(#core-glow)" | ||
| initial={{ pathLength: 0, opacity: 0 }} | ||
| animate={i <= lightningPhase ? { pathLength: [0, 1], opacity: [0, 1, 0.9] } : { pathLength: 0, opacity: 0 }} | ||
| transition={i <= lightningPhase ? { duration: 0.22, ease: "easeOut" } : { duration: 0.15 }} | ||
| /> | ||
| </g> | ||
| ))} | ||
| </svg> | ||
| </div> | ||
|
|
||
| {/* Fighters */} | ||
| <div className="fighters absolute inset-0 pointer-events-none" style={{ zIndex: 15 }}> | ||
| {/* Pikachu — center */} | ||
| <div style={{ position: "absolute", left: "50%", top: "50%", transform: "translate(-50%, -50%)" }}> | ||
| <div className="pikachu" style={{ opacity: 0, textAlign: "center", position: "relative" }}> | ||
| {charged && ( | ||
| <> | ||
| <motion.div style={{ position: "absolute", bottom: 26, left: 6, width: 14, height: 10, borderRadius: "50%", background: "radial-gradient(circle, #FFE000, #FF9500)", filter: "blur(3px)" }} | ||
| animate={{ opacity: [0.5, 1, 0.5], scale: [0.9, 1.35, 0.9] }} | ||
| transition={{ duration: 0.38, repeat: Infinity }} /> | ||
| <motion.div style={{ position: "absolute", bottom: 26, right: 6, width: 14, height: 10, borderRadius: "50%", background: "radial-gradient(circle, #FFE000, #FF9500)", filter: "blur(3px)" }} | ||
| animate={{ opacity: [0.5, 1, 0.5], scale: [0.9, 1.35, 0.9] }} | ||
| transition={{ duration: 0.38, repeat: Infinity, delay: 0.1 }} /> | ||
| <motion.div style={{ position: "absolute", inset: -14, borderRadius: "50%", border: "2px solid rgba(255,220,0,0.6)", boxShadow: "0 0 18px rgba(255,220,0,0.5), inset 0 0 18px rgba(255,220,0,0.1)" }} | ||
| animate={{ opacity: [0.3, 0.9, 0.3], scale: [1, 1.09, 1] }} | ||
| transition={{ duration: 0.35, repeat: Infinity }} /> | ||
| </> | ||
| )} | ||
| {/* eslint-disable-next-line @next/next/no-img-element */} | ||
| <img src="https://play.pokemonshowdown.com/sprites/ani/pikachu.gif" alt="Pikachu" width={100} height={100} | ||
| style={{ imageRendering: "pixelated", display: "block", filter: charged ? "drop-shadow(0 0 22px rgba(255,220,0,1)) drop-shadow(0 0 44px rgba(255,160,0,0.8))" : "drop-shadow(0 0 6px rgba(255,220,0,0.4))", transition: "filter 0.4s ease" }} /> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Surrounding Pokémon */} | ||
| {POKEMONS.map((p, i) => ( | ||
| <div key={i} style={{ position: "absolute", left: "50%", top: "50%", transform: `translate(calc(-50% + ${p.dx}px), calc(-50% + ${p.dy}px))` }}> | ||
| <div className={`pokemon-${i}`} style={{ opacity: 0, position: "relative" }}> | ||
| {hitSet.has(i) && ( | ||
| <motion.div style={{ position: "absolute", inset: -4, background: "white", borderRadius: 8, zIndex: 5, pointerEvents: "none" }} | ||
| initial={{ opacity: 0.95 }} animate={{ opacity: 0 }} transition={{ duration: 0.35 }} /> | ||
| )} | ||
| {/* eslint-disable-next-line @next/next/no-img-element */} | ||
| <img src={p.src} alt={p.name} width={p.size} height={p.size} | ||
| style={{ imageRendering: "pixelated", display: "block", filter: `drop-shadow(0 0 14px ${p.glow})` }} /> | ||
| </div> | ||
| </div> | ||
| ))} | ||
| </div> |
There was a problem hiding this comment.
The “click gate” start screen is rendered on top, but the fighters/lightning/logo DOM is still mounted underneath, so the remote Pokémon GIFs will start downloading immediately on page load (before the user clicks). If the intent is to defer network + CPU until interaction, conditionally render the animation content only when started is true (or at least lazy-load the sprite s / gate them behind
started).
| <motion.a | ||
| href="/" | ||
| initial={{ opacity: 0, y: 10 }} | ||
| animate={{ opacity: 1, y: 0 }} | ||
| transition={{ duration: 0.6 }} |
There was a problem hiding this comment.
The CTA uses <motion.a href="/">, which will trigger a full document navigation in the Next.js app router. Use next/link (e.g., wrap with Link and render motion as a child) or useRouter().push("/") to keep client-side navigation and preserve app state.
| {/* Global flash overlay */} | ||
| <motion.div | ||
| className="absolute inset-0 pointer-events-none" | ||
| style={{ | ||
| zIndex: 50, | ||
| background: "radial-gradient(ellipse at center, rgba(255,255,160,0.95) 0%, rgba(255,220,0,0.6) 35%, transparent 65%)", | ||
| }} | ||
| animate={{ opacity: globalFlash ? 0.75 : 0 }} | ||
| transition={{ duration: globalFlash ? 0.05 : 0.25 }} | ||
| /> |
There was a problem hiding this comment.
This component includes rapid flashing (globalFlash) and shaking, plus multiple looping animations, but doesn’t respect prefers-reduced-motion. For accessibility, consider using Framer Motion’s useReducedMotion() (or a CSS media query) to disable/soften shake + flash + infinite loops and/or provide a visible “Skip animation” option when reduced motion is requested.
Adding a launch animation for Catalysis launch.