Files
bozoclicker/src/App.tsx
2025-08-04 02:17:50 +05:30

337 lines
13 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
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 clickButtonContainerRef = useRef<HTMLDivElement>(null); // New ref for the click button container
const [location] = useLocation(); // Get current location from wouter
const [adminBroadcastMessage, setAdminBroadcastMessage] = useState<string | null>(null); // New state for admin messages
// 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 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 = 12000; // 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 buttonContainer = clickButtonContainerRef.current; // Use the ref
if (!buttonContainer) {
console.warn('Click button container ref not found!');
timeoutRef.current = setTimeout(spawnMascot, 1000); // Retry after 1 second
return;
}
const rect = buttonContainer.getBoundingClientRect();
const spawnAreaPadding = 150;
const minX = rect.left - spawnAreaPadding;
const maxX = rect.right + spawnAreaPadding;
const minY = rect.top - spawnAreaPadding;
const maxY = rect.bottom + spawnAreaPadding;
const viewportWidth = window.innerWidth;
const viewportHeight = window.innerHeight;
const randomX = Math.max(0, Math.min(viewportWidth, minX + Math.random() * (maxX - minX)));
const randomY = Math.max(0, Math.min(viewportHeight, minY + Math.random() * (maxY - minY)));
// 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 ref={clickButtonContainerRef} 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>
{/* 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;