Files
bozoclicker/src/App.tsx
2025-08-04 06:57:41 +00:00

350 lines
14 KiB
TypeScript

import { useEffect, useState, useRef } from 'react';
import { usePartyKit } from './hooks/usePartyKit';
import { ClickButton } from './components/ClickButton';
import { Counter } from './components/Counter';
import { UpgradeShop } from './components/UpgradeShop';
import { Milestones } from './components/Milestones';
import { Leaderboard } from './components/Leaderboard';
import { Background } from './components/Background';
import { MILESTONES } from './config/milestones';
import { SignedIn, SignedOut, SignInButton, UserButton } from '@clerk/clerk-react';
import { useAuth } from '@clerk/clerk-react';
import { ClickableMascot } from './components/ClickableMascot'; // Import ClickableMascot component
import { ClickableMascot as ClickableMascotType, Upgrade } from './types'; // Import ClickableMascotType and Upgrade
import { UPGRADES } from './config/upgrades'; // Import UPGRADES for upgrade config
import { useLocation, Link } from 'wouter'; // Import wouter hooks
import AdminPage from './components/AdminPage'; // Import AdminPage
import { NewsMarquee } from './components/NewsMarquee'; // Import NewsMarquee
import YouTube from 'react-youtube'; // Import YouTube component
function App() {
const { isSignedIn, isLoaded, userId: clerkUserId } = useAuth(); // Get clerkUserId from useAuth
const { gameState, sendClick, purchaseUpgrade, userId, sendMascotClickBonus, lastMessage } = usePartyKit(); // Renamed sendShoominionClickBonus
const [celebrationMessage, setCelebrationMessage] = useState<string | null>(null);
const [previousMilestones, setPreviousMilestones] = useState<Record<string, boolean>>({});
const [mascotEntities, setMascotEntities] = useState<ClickableMascotType[]>([]);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const gameStateRef = useRef(gameState); // Ref to hold the latest gameState
const [location, setLocation] = useLocation(); // Get current location from wouter
const [adminBroadcastMessage, setAdminBroadcastMessage] = useState<string | null>(null); // New state for admin messages
const [showSecretVideo, setShowSecretVideo] = useState(false); // New state for secret video
// Admin user ID from .env
const CLERK_ADMIN_USERID = import.meta.env.VITE_CLERK_ADMIN_USERID;
const isAdmin = clerkUserId === CLERK_ADMIN_USERID;
// Effect for milestone celebrations
useEffect(() => {
if (gameState) {
const newMilestones = Object.keys(gameState.milestones).filter(
(milestoneId) =>
gameState.milestones[milestoneId] &&
!previousMilestones[milestoneId]
);
if (newMilestones.length > 0) {
const milestone = MILESTONES.find(m => m.id === newMilestones[0]);
if (milestone) {
setCelebrationMessage(milestone.reward);
setTimeout(() => setCelebrationMessage(null), 5000);
}
}
setPreviousMilestones(gameState.milestones);
}
}, [gameState, previousMilestones]);
// Effect for secret video
useEffect(() => {
if (gameState?.upgrades['secretVideo']?.owned > 0) {
setShowSecretVideo(true);
}
}, [gameState?.upgrades['secretVideo']?.owned]);
// Effect for receiving admin broadcast messages
useEffect(() => {
if (lastMessage) {
try {
const parsedMessage = JSON.parse(lastMessage);
if (parsedMessage.type === 'admin-message') {
setAdminBroadcastMessage(parsedMessage.message);
setTimeout(() => setAdminBroadcastMessage(null), 7000); // Message disappears after 7 seconds
}
} catch (error) {
console.error('Failed to parse last message in App.tsx:', error);
}
}
}, [lastMessage]);
// Effect to keep gameStateRef updated
useEffect(() => {
gameStateRef.current = gameState;
}, [gameState]);
// Effect for spawning mascots
useEffect(() => {
// Use gameStateRef.current for initial check and to get latest state inside spawnMascot
if (!gameStateRef.current) return;
const friendBoostUpgrade = UPGRADES.find(u => u.id === 'friendBoost') as Upgrade | undefined;
const ownedFriendBoost = gameStateRef.current.upgrades['friendBoost']?.owned || 0;
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
if (ownedFriendBoost > 0 && friendBoostUpgrade && friendBoostUpgrade.mascotTiers) {
const baseInterval = 18000; // Further increased base interval for less frequent spawns
const minInterval = 1000; // Increased min interval
const interval = Math.max(minInterval, baseInterval / (1 + ownedFriendBoost * 0.2)); // Adjusted scaling
console.log(`Spawning mascots every ${interval} ms for friendBoost level ${ownedFriendBoost}`);
const currentMascotTiers = friendBoostUpgrade.mascotTiers; // Ensure mascotTiers is not undefined
const spawnMascot = () => {
// Access the latest gameState from the ref inside the closure
const currentGameState = gameStateRef.current;
if (!currentGameState) return; // Should not happen if initial check passes
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
// Spawn anywhere on the viewport, with a small padding to avoid edges
const padding = 50;
const randomX = Math.random() * (viewportWidth - padding * 2) + padding;
const randomY = Math.random() * (viewportHeight - padding * 2) + padding;
// Filter available mascots based on ownedFriendBoost level
const availableMascots = currentMascotTiers.filter(
(tier) => ownedFriendBoost >= tier.level
);
if (availableMascots.length === 0) {
console.warn('No mascots available to spawn for current friendBoost level.');
timeoutRef.current = setTimeout(spawnMascot, interval);
return;
}
// Weighted random selection based on rarity
const totalRarity = availableMascots.reduce((sum, tier) => sum + tier.rarity, 0);
let randomWeight = Math.random() * totalRarity;
let selectedMascotTier = availableMascots[0]; // Default to first available
for (const tier of availableMascots) {
randomWeight -= tier.rarity;
if (randomWeight <= 0) {
selectedMascotTier = tier;
break;
}
}
const newMascot: ClickableMascotType = {
id: `mascot-${Date.now()}-${Math.random()}`,
x: randomX,
y: randomY,
multiplierBonus: selectedMascotTier.multiplier,
imageSrc: selectedMascotTier.imageSrc,
};
setMascotEntities((prev) => [...prev, newMascot]);
timeoutRef.current = setTimeout(spawnMascot, interval);
};
spawnMascot(); // Start the first spawn
} else {
setMascotEntities([]); // Clear all mascots if friendBoost is 0
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [gameState?.upgrades['friendBoost']?.owned]); // Only depend on friendBoost level
const handleMascotClick = (id: string, multiplierBonus: number) => { // Renamed handler
sendMascotClickBonus(multiplierBonus); // Renamed function call
setMascotEntities((prev) => prev.filter((m) => m.id !== id)); // Update state variable
};
const handleMascotRemove = (id: string) => { // Renamed handler
setMascotEntities((prev) => prev.filter((m) => m.id !== id)); // Update state variable
};
if (!isLoaded || !gameState) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-purple-900 to-pink-900">
<div className="text-white text-2xl font-bold animate-pulse">
Connecting to the Bozo Network...
</div>
</div>
);
}
const userBonusMultiplier = isSignedIn && userId ? gameState.users[userId]?.bonusMultiplier || 1 : 1;
const effectiveClickMultiplier = gameState.clickMultiplier * userBonusMultiplier;
// Render the AdminPage if the current location is /admin
if (location === '/admin') {
return <AdminPage />;
}
return (
<div className="min-h-screen relative overflow-x-hidden">
<Background background={gameState.currentBackground} />
{/* User Button and Admin Link */}
<div className="absolute top-4 right-4 z-50 flex items-center space-x-4">
{isAdmin && (
<Link href="/admin">
<a className="text-white bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-md font-bold transition duration-300 ease-in-out">
Admin Page
</a>
</Link>
)}
<SignedIn>
<UserButton />
</SignedIn>
</div>
{/* Celebration Message */}
{celebrationMessage && (
<div className="fixed top-0 left-0 right-0 z-50 flex justify-center pt-8">
<div className="bg-gradient-to-r from-yellow-400 via-pink-500 to-purple-600 text-white px-8 py-4 rounded-full text-2xl font-bold shadow-2xl animate-bounce border-4 border-white">
🎉 {celebrationMessage} 🎉
</div>
</div>
)}
{/* Admin Broadcast Message */}
{adminBroadcastMessage && (
<div className="fixed top-20 left-0 right-0 z-50 flex justify-center pt-8">
<div className="bg-red-600 text-white px-8 py-4 rounded-full text-2xl font-bold shadow-2xl animate-pulse border-4 border-white">
📢 Admin Message: {adminBroadcastMessage} 📢
</div>
</div>
)}
{/* News Marquee */}
{gameState.upgrades['news']?.owned > 0 && UPGRADES.find(u => u.id === 'news')?.newsTitles && (
<NewsMarquee titles={UPGRADES.find(u => u.id === 'news')!.newsTitles!} />
)}
<div className="container mx-auto px-4 py-8 relative z-10">
{/* Header */}
<div className="mb-8">
<Counter
totalClicks={gameState.totalClicks}
autoClickRate={gameState.autoClickRate}
/>
</div>
{/* Main Content */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Left Column - Click Button */}
<div id="click-button-container" className="lg:col-span-1 flex flex-col items-center justify-center relative"> {/* Added ref, id and relative positioning */}
<ClickButton
onClick={sendClick}
imageUrl={gameState.currentClickImage}
clickMultiplier={effectiveClickMultiplier}
/>
{/* Render Mascots */}
{mascotEntities.map((mascot: ClickableMascotType) => (
<ClickableMascot
key={mascot.id}
id={mascot.id}
x={mascot.x}
y={mascot.y}
multiplierBonus={mascot.multiplierBonus}
imageSrc={mascot.imageSrc}
onClick={handleMascotClick}
onRemove={handleMascotRemove}
/>
))}
</div>
{/* Middle Column - Upgrades */}
<div className="lg:col-span-1">
<UpgradeShop
gameState={gameState}
totalClicks={gameState.totalClicks}
onPurchase={purchaseUpgrade}
/>
</div>
{/* Right Column - Milestones and Leaderboard */}
<div className="lg:col-span-1 space-y-6">
<Milestones gameState={gameState} />
<Leaderboard gameState={gameState} currentUserId={userId || ''} />
</div>
</div>
{/* Footer */}
<div className="mt-12 text-center">
<div className="bg-gradient-to-r from-pink-500 via-purple-500 to-cyan-500 p-4 rounded-xl border-4 border-yellow-300">
<p className="text-white text-xl font-bold" style={{ fontFamily: 'Comic Sans MS, cursive' }}>
Keep clicking, fellow bozos!
</p>
<p className="text-yellow-200 text-sm mt-2">
This game is synchronized in real-time with all players!
</p>
</div>
</div>
</div>
{/* Secret Video Player */}
{showSecretVideo && (
<div className="fixed bottom-4 left-4 z-50">
<YouTube
videoId={UPGRADES.find(u => u.id === 'secretVideo')?.youtubeId || ''}
opts={{
height: '195',
width: '320',
playerVars: {
autoplay: 1,
},
}}
/>
</div>
)}
{/* Sparkle Effects */}
<div className="fixed inset-0 pointer-events-none z-20">
{[...Array(10)].map((_, i) => (
<div
key={i}
className="absolute text-2xl opacity-70 animate-pulse"
style={{
left: `${Math.random() * 100}%`,
top: `${Math.random() * 100}%`,
animationDelay: `${Math.random() * 3}s`,
animationDuration: `${2 + Math.random() * 3}s`
}}
>
</div>
))}
</div>
{/* If not logged in, show popup overlaying the game to login */}
<SignedOut>
<div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50">
<div className="bg-white p-8 rounded-lg shadow-lg text-center">
<h2 className="text-2xl font-bold mb-4">Welcome to Bozo Clicker!</h2>
<p className="mb-6">Please sign in to start clicking!</p>
<SignInButton mode="modal">
<button className="bg-blue-500 text-white px-6 py-2 rounded-lg hover:bg-blue-600 transition">
Sign In
</button>
</SignInButton>
</div>
</div>
</SignedOut>
</div>
);
}
export default App;