337 lines
13 KiB
TypeScript
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;
|