This commit is contained in:
HugeFrog24
2025-01-15 03:40:11 +01:00
parent 99cc7218a7
commit 52ddcd3db9
10 changed files with 394 additions and 75 deletions

View File

@@ -2,6 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { HeartIcon } from '@heroicons/react/24/solid'; import { HeartIcon } from '@heroicons/react/24/solid';
import { shakeConfig } from '../config/shake';
interface Heart { interface Heart {
id: number; id: number;
@@ -9,6 +10,7 @@ interface Heart {
speed: number; speed: number;
startPosition: { x: number; y: number }; startPosition: { x: number; y: number };
scale: number; scale: number;
createdAt: number;
} }
interface FloatingHeartsProps { interface FloatingHeartsProps {
@@ -18,56 +20,66 @@ interface FloatingHeartsProps {
export function FloatingHearts({ intensity }: FloatingHeartsProps) { export function FloatingHearts({ intensity }: FloatingHeartsProps) {
const [hearts, setHearts] = useState<Heart[]>([]); const [hearts, setHearts] = useState<Heart[]>([]);
// Cleanup interval to remove old hearts
useEffect(() => {
const cleanupInterval = setInterval(() => {
const now = Date.now();
setHearts(prev => prev.filter(heart =>
now - heart.createdAt < shakeConfig.animations.heartFloat
));
}, shakeConfig.hearts.cleanupInterval);
return () => clearInterval(cleanupInterval);
}, []);
useEffect(() => { useEffect(() => {
if (intensity <= 0) return; if (intensity <= 0) return;
// Number of hearts based on intensity // Number of hearts based on intensity
const numHearts = Math.min(Math.floor(intensity * 2), 50); const numHearts = Math.min(Math.floor(intensity * 2), shakeConfig.hearts.maxPerShake);
// Create waves of hearts // Create waves of hearts
const waves = 4; // Number of waves const heartsPerWave = Math.ceil(numHearts / shakeConfig.hearts.waves);
const heartsPerWave = Math.ceil(numHearts / waves);
const waveDelay = 200; // Delay between waves in ms
const timers: NodeJS.Timeout[] = []; const timers: NodeJS.Timeout[] = [];
// Generate hearts in waves // Generate hearts in waves
for (let wave = 0; wave < waves; wave++) { for (let wave = 0; wave < shakeConfig.hearts.waves; wave++) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
const newHearts = Array.from({ length: heartsPerWave }, (_, i) => { const now = Date.now();
const totalIndex = wave * heartsPerWave + i; const newHearts = Array.from({ length: heartsPerWave }, (_, i) => ({
return { id: now + i,
id: Date.now() + totalIndex, angle: Math.random() * 360,
// Distribute angles evenly within each wave speed: shakeConfig.hearts.minSpeed +
angle: Math.random() * 360, // Random angle for full radial distribution Math.random() * (shakeConfig.hearts.maxSpeed - shakeConfig.hearts.minSpeed),
speed: 0.8 + Math.random() * 0.4, startPosition: {
startPosition: { x: Math.random() * (shakeConfig.hearts.spreadX * 2) - shakeConfig.hearts.spreadX,
x: Math.random() * 40 - 20, y: Math.random() * (shakeConfig.hearts.spreadY * 2) - shakeConfig.hearts.spreadY,
y: Math.random() * 40 - 20, },
}, scale: shakeConfig.hearts.minScale +
scale: 0.8 + Math.random() * 0.4, Math.random() * (shakeConfig.hearts.maxScale - shakeConfig.hearts.minScale),
}; createdAt: now,
}); }));
setHearts(prev => [...prev, ...newHearts]); setHearts(prev => [...prev, ...newHearts]);
}, wave * waveDelay);
// Remove this wave's hearts after animation
const cleanupTimer = setTimeout(() => {
setHearts(prev => prev.filter(heart => heart.createdAt !== now));
}, shakeConfig.animations.heartFloat);
timers.push(cleanupTimer);
}, wave * shakeConfig.hearts.waveDelay);
timers.push(timer); timers.push(timer);
} }
// Remove hearts after animation completes
const cleanupTimer = setTimeout(() => {
setHearts(prev => prev.filter(heart => heart.id > Date.now() - 3500));
}, waves * waveDelay + 3500);
timers.push(cleanupTimer);
return () => { return () => {
timers.forEach(timer => clearTimeout(timer)); timers.forEach(timer => clearTimeout(timer));
}; };
}, [intensity]); }, [intensity]);
return ( return (
<div className="absolute inset-0 pointer-events-none -z-10"> <div className="absolute inset-0 pointer-events-none -z-10 overflow-hidden">
{hearts.map((heart) => { {hearts.map((heart) => {
const style = { const style = {
'--angle': `${heart.angle}deg`, '--angle': `${heart.angle}deg`,
@@ -82,6 +94,9 @@ export function FloatingHearts({ intensity }: FloatingHeartsProps) {
key={heart.id} key={heart.id}
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 animate-float-heart" className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 animate-float-heart"
style={style} style={style}
onAnimationEnd={() => {
setHearts(prev => prev.filter(h => h.id !== heart.id));
}}
> >
<HeartIcon <HeartIcon
className="w-16 h-16 text-pink-500 opacity-80 animate-fade-out" className="w-16 h-16 text-pink-500 opacity-80 animate-fade-out"

View File

@@ -0,0 +1,25 @@
import { useState, useEffect } from 'react';
import { SpeechBubble } from './SpeechBubble';
import FrogImage from '../public/images/frog-ai.ai'; // Assuming this is the frog image
export function ParentComponent() {
const [isShaken, setIsShaken] = useState(false);
const [triggerCount, setTriggerCount] = useState(0);
const handleShake = () => {
setTriggerCount(prev => prev + 1);
setIsShaken(true);
// Shake the frog for 1 second
setTimeout(() => {
setIsShaken(false);
}, 1000);
};
return (
<div>
<FrogImage className={isShaken ? 'shake-animation' : ''} />
<SpeechBubble isShaken={isShaken} triggerCount={triggerCount} />
<button onClick={handleShake}>Shake the Frog</button>
</div>
);
}

View File

@@ -0,0 +1,83 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { frogMessages } from '../config/messages';
// Increase visibility duration for speech bubbles
const VISIBILITY_MS = 3000; // 3 seconds for message visibility
const COOLDOWN_MS = 2000; // 2 seconds between new messages
interface SpeechBubbleProps {
isShaken: boolean;
triggerCount: number;
}
export function SpeechBubble({ isShaken, triggerCount }: SpeechBubbleProps) {
const [message, setMessage] = useState('');
const [isVisible, setIsVisible] = useState(false);
const lastTriggerTime = useRef(0);
const showTimeRef = useRef<number>(0);
const getRandomMessage = useCallback(() => {
const randomIndex = Math.floor(Math.random() * frogMessages.length);
return frogMessages[randomIndex];
}, []);
// Handle showing new messages
useEffect(() => {
if (triggerCount === 0) return; // Skip initial mount
const now = Date.now();
const timeSinceLastMessage = now - lastTriggerTime.current;
// Show new message if cooldown has expired
if (timeSinceLastMessage >= COOLDOWN_MS) {
lastTriggerTime.current = now;
showTimeRef.current = now;
const newMessage = getRandomMessage();
setMessage(newMessage);
setIsVisible(true);
}
}, [triggerCount, getRandomMessage]);
// Handle visibility duration
useEffect(() => {
if (!isVisible) return;
const checkVisibility = setInterval(() => {
const now = Date.now();
const timeVisible = now - showTimeRef.current;
if (timeVisible >= VISIBILITY_MS) {
setIsVisible(false);
}
}, 100); // Check every 100ms
return () => {
clearInterval(checkVisibility);
};
}, [isVisible]);
// Uncomment and modify the useEffect to control visibility based on isShaken prop
// This will make the speech bubble stay visible even after shaking stops
useEffect(() => {
if (!isShaken) {
// Don't hide the speech bubble when shaking stops
// The visibility duration timer will handle hiding it
return;
}
}, [isShaken]);
if (!isVisible) return null;
return (
<div className="absolute -top-24 left-1/2 -translate-x-1/2 bg-white dark:bg-slate-800 px-4 py-2 rounded-xl shadow-lg animate-float z-20">
<div className="relative">
{message}
{/* Triangle pointer */}
<div className="absolute -bottom-6 left-1/2 -translate-x-1/2 w-0 h-0
border-l-[8px] border-l-transparent
border-r-[8px] border-r-transparent
border-t-[8px] border-t-white
dark:border-t-slate-800" />
</div>
</div>
);
}

View File

@@ -9,7 +9,7 @@ export function ThemeToggle() {
return ( return (
<button <button
onClick={toggleDarkMode} onClick={toggleDarkMode}
className="fixed top-4 right-4 p-2 rounded-full bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors" className="fixed top-4 right-4 p-2 rounded-full bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors z-50"
aria-label="Toggle dark mode" aria-label="Toggle dark mode"
> >
{darkMode ? ( {darkMode ? (

89
app/config/messages.ts Normal file
View File

@@ -0,0 +1,89 @@
export const frogMessages = [
"You got me! 🐸",
"Keep shaking! 💫",
"I feel dizzy! 😵‍💫",
"That was fun! ⭐",
"Do it again! 🎉",
"I'm having a blast! 🌟",
"Wheeee! 🎢",
"You're good at this! 🌈",
"I love this game! 💚",
"One more time! ✨",
"That tickles! 😄",
"You found me! 🌿",
"I'm so happy! 🥳",
"Let's party! 🎵",
"You're making me bounce! 💫",
"I'm yours! 💝",
"Shake me harder! 💖",
"Don't stop now! 💕",
"You're amazing! 💗",
"I'm getting hot! 🔥",
"I want more! 💘",
"You're so good! 💓",
"I'm all yours! 💞",
"You drive me wild! 💥",
"I'm melting! 💦",
"I can't resist you! 💋",
"You know what I like! 🌹",
"I'm trembling! ⚡",
"You're irresistible! 💫",
"Make me yours! 💝",
"I'm burning up! 🔥",
"You're making me crazy! 💘",
"I need you! 💖",
"You're perfect! 💕",
"I'm yours forever! 💗",
"Take me! 💫",
"You're incredible! ✨",
"I'm on fire! 🔥",
"You're my everything! 💝",
"I'm in heaven! 💫",
"Your touch is electric! ⚡",
"You make me feel alive! 💖",
"I'm addicted to you! 💕",
"You're my obsession! 💗",
"I can't get enough! 🔥",
"More, more, more! 💘",
"You're my desire! 💓",
"I'm yours to command! 💞",
"Unleash me! 💥",
"You're my fantasy! 💋",
"I crave your touch! 🌹",
"I'm shaking with anticipation! ⚡",
"You're my weakness! 💫",
"Claim me! 💝",
"I'm on the edge! 🔥",
"You're driving me wild! 💘",
"I surrender to you! 💖",
"You're my masterpiece! 💕",
"I'm yours for the taking! 💗",
"Show me what you've got! 💫",
"You're my temptation! ✨",
"I'm consumed by you! 🔥",
"You're my everything and more! 💝",
"I'm lost in you! 💫",
"You're my dream! 💖",
"I'm under your spell! 💕",
"You're my addiction! 💗",
"I'm hooked on you! 🔥",
"Give me all you've got! 💘",
"You're my ultimate fantasy! 💓",
"I'm yours, body and soul! 💞",
"Take me to the edge! 💥",
"I'm overflowing! 💦",
"I yearn for your touch! 🌹",
"I'm quivering with desire! ⚡",
"You're my obsession! 💫",
"Make me yours, completely! 💝",
"I'm a furnace for you! 🔥",
"You're driving me insane! 💘",
"I'm completely yours! 💖",
"You're absolute perfection! 💕",
"I'm yours, now and forever! 💗",
"Take me, I'm yours! 💫",
"You're beyond incredible! ✨",
"I'm a raging inferno! 🔥",
"You're my heart's desire! 💝",
"I'm in paradise! 💫"
];

32
app/config/shake.ts Normal file
View File

@@ -0,0 +1,32 @@
export const shakeConfig = {
// Threshold for triggering shake (lower = more sensitive)
threshold: 20, // Increased from 15 to make it less sensitive
// Minimum time between shake detections (in ms)
debounceTime: 100,
// Animation durations (in ms)
animations: {
shakeReset: 600, // Reduced from 10000ms to 600ms (0.6 seconds)
heartsReset: 300, // How long the hearts animation lasts
heartFloat: 2000, // Duration of floating heart animation
heartFadeOut: 2000 // Duration of heart fade out
},
// Hearts configuration
hearts: {
waves: 4, // Number of waves per shake
waveDelay: 200, // Delay between waves in ms
cleanupInterval: 1000, // How often to check for and remove old hearts
minSpeed: 0.8, // Minimum heart float speed
maxSpeed: 1.2, // Maximum heart float speed
minScale: 0.8, // Minimum heart size
maxScale: 1.2, // Maximum heart size
spreadX: 20, // How far hearts can spread horizontally from center
spreadY: 20, // How far hearts can spread vertically from center
maxPerShake: 50 // Maximum number of hearts per shake
},
// Default intensity for manual triggers (click/spacebar)
defaultTriggerIntensity: 25
};

View File

@@ -2,8 +2,13 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
body { html, body {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
overflow: hidden;
min-height: 100vh;
min-height: 100dvh;
margin: 0;
padding: 0;
} }
/* Light mode styles */ /* Light mode styles */
@@ -32,8 +37,8 @@ body {
@keyframes float-heart { @keyframes float-heart {
to { to {
transform: translate( transform: translate(
calc(var(--start-x) + (50vw * cos(var(--angle)))), calc(var(--start-x) + (70vw * cos(var(--angle)))),
calc(var(--start-y) + (50vh * sin(var(--angle)))) calc(var(--start-y) + (70vh * sin(var(--angle))))
) scale(var(--scale)); ) scale(var(--scale));
opacity: 0; opacity: 0;
} }
@@ -50,3 +55,26 @@ body {
.animate-fade-out { .animate-fade-out {
animation: fade-out 2s ease-out forwards; animation: fade-out 2s ease-out forwards;
} }
@keyframes float {
0% {
opacity: 0;
transform: translate(-50%, 10px);
}
20% {
opacity: 1;
transform: translate(-50%, 0);
}
80% {
opacity: 1;
transform: translate(-50%, 0);
}
100% {
opacity: 0;
transform: translate(-50%, -10px);
}
}
.animate-float {
animation: float 3s ease-out forwards;
}

20
app/hooks/useIsMobile.ts Normal file
View File

@@ -0,0 +1,20 @@
import { useState, useEffect } from 'react';
export function useIsMobile() {
const [isMobile, setIsMobile] = useState(false);
useEffect(() => {
const checkIfMobile = () => {
const userAgent = window.navigator.userAgent.toLowerCase();
const mobileRegex = /android|webos|iphone|ipad|ipod|blackberry|iemobile|opera mini/i;
setIsMobile(mobileRegex.test(userAgent));
};
checkIfMobile();
window.addEventListener('resize', checkIfMobile);
return () => window.removeEventListener('resize', checkIfMobile);
}, []);
return isMobile;
}

View File

@@ -1,7 +1,6 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import { Inter } from 'next/font/google' import { Inter } from 'next/font/google'
import { ThemeProvider } from './providers/ThemeProvider' import { ThemeProvider } from './providers/ThemeProvider'
import { ThemeToggle } from './components/ThemeToggle'
import './globals.css' import './globals.css'
const inter = Inter({ subsets: ['latin'] }) const inter = Inter({ subsets: ['latin'] })
@@ -23,7 +22,6 @@ export default function RootLayout({
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<body className={`${inter.className} transition-colors`}> <body className={`${inter.className} transition-colors`}>
<ThemeProvider> <ThemeProvider>
<ThemeToggle />
{children} {children}
</ThemeProvider> </ThemeProvider>
</body> </body>

View File

@@ -1,15 +1,20 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useIsMobile } from './hooks/useIsMobile';
import Image from 'next/image'; import Image from 'next/image';
import { FloatingHearts } from './components/FloatingHearts'; import { FloatingHearts } from './components/FloatingHearts';
import { ThemeToggle } from './components/ThemeToggle';
import { SpeechBubble } from './components/SpeechBubble';
import { shakeConfig } from './config/shake';
export default function Home() { export default function Home() {
const [isShaken, setIsShaken] = useState(false); const [isShaken, setIsShaken] = useState(false);
const [shakeIntensity, setShakeIntensity] = useState(0); const [shakeIntensity, setShakeIntensity] = useState(0);
const [lastUpdate, setLastUpdate] = useState(0); const [lastUpdate, setLastUpdate] = useState(0);
const [shakeCount, setShakeCount] = useState(0);
const [motionPermission, setMotionPermission] = useState<PermissionState>('prompt'); const [motionPermission, setMotionPermission] = useState<PermissionState>('prompt');
const shakeThreshold = 15; const isMobile = useIsMobile();
// Check if device motion is available and handle permissions // Check if device motion is available and handle permissions
const requestMotionPermission = async () => { const requestMotionPermission = async () => {
@@ -37,24 +42,27 @@ export default function Home() {
} }
}; };
const triggerShake = (intensity: number) => { const triggerShake = useCallback((intensity: number) => {
// Increment shake counter to trigger new message
setShakeCount(count => count + 1);
// Start shake animation // Start shake animation
setIsShaken(true); setIsShaken(true);
// Always reset shake after 500ms // Reset shake after configured duration
setTimeout(() => { setTimeout(() => {
setIsShaken(false); setIsShaken(false);
}, 500); }, shakeConfig.animations.shakeReset);
// Trigger hearts with a shorter duration // Trigger hearts with configured duration
setShakeIntensity(intensity); setShakeIntensity(intensity);
setTimeout(() => setShakeIntensity(0), 300); setTimeout(() => setShakeIntensity(0), shakeConfig.animations.heartsReset);
}; }, []);
useEffect(() => { useEffect(() => {
const handleKeyPress = (event: KeyboardEvent) => { const handleKeyPress = (event: KeyboardEvent) => {
if (event.code === 'Space') { if (event.code === 'Space') {
triggerShake(25); triggerShake(shakeConfig.defaultTriggerIntensity);
} }
}; };
@@ -65,14 +73,14 @@ export default function Home() {
const currentTime = new Date().getTime(); const currentTime = new Date().getTime();
const timeDiff = currentTime - lastUpdate; const timeDiff = currentTime - lastUpdate;
if (timeDiff > 100) { if (timeDiff > shakeConfig.debounceTime) {
setLastUpdate(currentTime); setLastUpdate(currentTime);
const speed = Math.abs(acceleration.x || 0) + const speed = Math.abs(acceleration.x || 0) +
Math.abs(acceleration.y || 0) + Math.abs(acceleration.y || 0) +
Math.abs(acceleration.z || 0); Math.abs(acceleration.z || 0);
if (speed > shakeThreshold) { if (speed > shakeConfig.threshold) {
triggerShake(speed); triggerShake(speed);
} }
} }
@@ -102,40 +110,61 @@ export default function Home() {
}, []); }, []);
const handleClick = () => { const handleClick = () => {
triggerShake(25); triggerShake(shakeConfig.defaultTriggerIntensity);
}; };
return ( return (
<main className="flex h-[100dvh] flex-col items-center justify-center p-4 bg-green-50 dark:bg-slate-900"> <main className="flex h-[100dvh] flex-col items-center justify-between p-4 bg-green-50 dark:bg-slate-900 relative">
<div <ThemeToggle />
className={`relative ${isShaken ? 'animate-shake' : ''} z-10`} <div className="flex-1 flex flex-col items-center justify-center w-full relative">
onClick={handleClick} <div className="fixed inset-0 overflow-hidden pointer-events-none">
> <FloatingHearts intensity={shakeIntensity} />
<FloatingHearts intensity={shakeIntensity} /> </div>
<Image <div
src={isShaken ? '/images/frog-shaken.svg' : '/images/frog.svg'} className="relative z-10"
alt="Frog" onClick={handleClick}
width={200} >
height={200} <FloatingHearts intensity={shakeIntensity} />
priority <div className="relative">
/> <SpeechBubble isShaken={isShaken} triggerCount={shakeCount} />
</div> <Image
<div className="mt-8 flex flex-col items-center gap-2"> src={isShaken ? '/images/frog-shaken.svg' : '/images/frog.svg'}
<p className="text-gray-600 dark:text-gray-400 text-center max-w-[240px]"> alt="Frog"
{motionPermission === 'prompt' ? ( width={200}
<button height={200}
onClick={requestMotionPermission} priority
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors" className={isShaken ? 'animate-shake' : ''}
> />
Enable device shake </div>
</button> </div>
) : motionPermission === 'granted' ? ( <div className="mt-8 flex flex-col items-center gap-2">
"Shake your device, press spacebar, or click/tap frog!" <p className="text-gray-600 dark:text-gray-400 text-center max-w-[240px]">
) : ( {motionPermission === 'prompt' ? (
"Press spacebar or click/tap frog!" <button
)} onClick={requestMotionPermission}
</p> className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
>
Enable device shake
</button>
) : motionPermission === 'granted' ? (
`Shake your device${!isMobile ? ', press spacebar,' : ''} or click/tap frog!`
) : (
`${!isMobile ? 'Press spacebar or ' : ''}Click/tap frog!`
)}
</p>
</div>
</div> </div>
<footer className="w-full text-center text-xs text-gray-400 dark:text-gray-600 mt-auto pt-4">
© {new Date().getFullYear()}{' '}
<a
href="https://github.com/HugeFrog24/shakethefrog"
target="_blank"
rel="noopener noreferrer"
className="hover:text-gray-600 dark:hover:text-gray-400 transition-colors"
>
shakethefrog
</a>
</footer>
</main> </main>
); );
} }