Spaces:
Configuration error
Configuration error
Upload 30 files
Browse files- .env.local +1 -0
- .gitignore +24 -0
- AdminLoginModal.tsx +81 -0
- AdminPanelModal.tsx +1182 -0
- App.tsx +854 -0
- DogeWhaleEscapeModal.tsx +619 -0
- GameCanvas.tsx +1303 -0
- Modal.tsx +107 -0
- NftMarketplaceModal.tsx +365 -0
- NftStakingModal.tsx +199 -0
- NotificationToast.tsx +47 -0
- PlayerProfileModal.tsx +371 -0
- README.md +14 -11
- SoundControlBar.tsx +44 -0
- SpinToWinModal.tsx +1006 -0
- WalletModal.tsx +302 -0
- WhaleCombatGuideModal.tsx +203 -0
- app.css +172 -0
- combatwhalegame.tsx +143 -0
- constants.ts +980 -0
- geminiApi.ts +72 -0
- index.html +436 -0
- index.tsx +16 -0
- metadata.json +6 -0
- package.json +23 -0
- sounds.ts +129 -0
- tsconfig.json +30 -0
- types.ts +349 -0
- utils.ts +4 -0
- vite.config.ts +17 -0
.env.local
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
GEMINI_API_KEY=PLACEHOLDER_API_KEY
|
.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
AdminLoginModal.tsx
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect } from 'react';
|
| 2 |
+
import { Modal } from '../Modal'; // Assuming Modal.tsx is in components/
|
| 3 |
+
|
| 4 |
+
interface AdminLoginModalProps {
|
| 5 |
+
isOpen: boolean;
|
| 6 |
+
onClose: () => void;
|
| 7 |
+
onLoginSuccess: () => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export const AdminLoginModal: React.FC<AdminLoginModalProps> = ({ isOpen, onClose, onLoginSuccess }) => {
|
| 11 |
+
const [password, setPassword] = useState('');
|
| 12 |
+
const [loginError, setLoginError] = useState<string | null>(null);
|
| 13 |
+
const [isShaking, setIsShaking] = useState(false);
|
| 14 |
+
|
| 15 |
+
useEffect(() => {
|
| 16 |
+
if (!isOpen) {
|
| 17 |
+
// Reset state when modal is closed
|
| 18 |
+
setPassword('');
|
| 19 |
+
setLoginError(null);
|
| 20 |
+
setIsShaking(false);
|
| 21 |
+
}
|
| 22 |
+
}, [isOpen]);
|
| 23 |
+
|
| 24 |
+
const handleLogin = (e: React.FormEvent) => {
|
| 25 |
+
e.preventDefault();
|
| 26 |
+
if (password === '143143143') {
|
| 27 |
+
onLoginSuccess();
|
| 28 |
+
onClose();
|
| 29 |
+
} else {
|
| 30 |
+
setLoginError('Invalid password. Please try again.');
|
| 31 |
+
setIsShaking(true);
|
| 32 |
+
// Remove shake animation after it plays
|
| 33 |
+
setTimeout(() => setIsShaking(false), 500); // Match animation duration
|
| 34 |
+
}
|
| 35 |
+
setPassword('');
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
const handlePasswordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 39 |
+
setPassword(e.target.value);
|
| 40 |
+
if (loginError) {
|
| 41 |
+
setLoginError(null); // Clear error when user starts typing again
|
| 42 |
+
}
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
if (!isOpen) {
|
| 46 |
+
return null;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return (
|
| 50 |
+
<Modal isOpen={isOpen} onClose={onClose} title="Admin Login 🔑">
|
| 51 |
+
<form onSubmit={handleLogin} className={`space-y-6 text-theme-text ${isShaking ? 'animate-shake' : ''}`}>
|
| 52 |
+
<div>
|
| 53 |
+
<label htmlFor="admin-password" className="block text-sm font-medium text-gray-300 mb-1">Password:</label>
|
| 54 |
+
<input
|
| 55 |
+
type="password"
|
| 56 |
+
id="admin-password"
|
| 57 |
+
value={password}
|
| 58 |
+
onChange={handlePasswordChange}
|
| 59 |
+
className={`mt-1 block w-full p-3 rounded-md bg-theme-dark border text-white focus:ring-2 focus:border-transparent transition-all ${loginError ? 'border-theme-danger focus:ring-theme-danger' : 'border-theme-border focus:ring-pink-500'}`}
|
| 60 |
+
required
|
| 61 |
+
aria-label="Admin password"
|
| 62 |
+
autoComplete="current-password"
|
| 63 |
+
aria-invalid={!!loginError}
|
| 64 |
+
aria-describedby={loginError ? "admin-password-error" : undefined}
|
| 65 |
+
/>
|
| 66 |
+
{loginError && (
|
| 67 |
+
<p id="admin-password-error" className="mt-2 text-sm text-theme-danger" role="alert">
|
| 68 |
+
{loginError}
|
| 69 |
+
</p>
|
| 70 |
+
)}
|
| 71 |
+
</div>
|
| 72 |
+
<button
|
| 73 |
+
type="submit"
|
| 74 |
+
className="w-full py-2.5 px-5 bg-pink-600 hover:bg-pink-700 text-white font-bold rounded-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-pink-500 focus:ring-offset-2 focus:ring-offset-theme-dark"
|
| 75 |
+
>
|
| 76 |
+
Login
|
| 77 |
+
</button>
|
| 78 |
+
</form>
|
| 79 |
+
</Modal>
|
| 80 |
+
);
|
| 81 |
+
};
|
AdminPanelModal.tsx
ADDED
|
@@ -0,0 +1,1182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
| 3 |
+
import { Modal } from '../Modal';
|
| 4 |
+
import { AdminConfig, NFT, GameCharacter, WeaponType, DecorativeFishType, BubbleSize, SavedReply } from '../../types';
|
| 5 |
+
import { generateImageWithGemini, GeminiImageResponse } from '../../geminiApi';
|
| 6 |
+
import { SoundEvent } from '../../sounds';
|
| 7 |
+
import { COOLDOWN_RED_ARROW_DEFENSE, RED_ARROW_PROJECTILE_SPEED, RED_ARROW_PROJECTILE_DAMAGE, PLAYER_SPEED, RED_ARROW_DEFAULT_SHOT_COUNT, RED_ARROW_PROJECTILE_IMAGE_URL, DOGE_WHALE_ESCAPE_PLAYER_IMAGE_URL, DOGE_WHALE_ESCAPE_WALL_IMAGE_URL, DOGE_WHALE_ESCAPE_GOAL_IMAGE_URL, DOGE_WHALE_ESCAPE_MINE_IMAGE_URL, DOGE_WHALE_ESCAPE_BOMB_IMAGE_URL, DOGE_WHALE_ESCAPE_JELLYFISH_IMAGE_URL, DOGE_WHALE_ESCAPE_CROCODILE_IMAGE_URL, DOGE_WHALE_ESCAPE_ALGAE_IMAGE_URL, DOGE_WHALE_ESCAPE_CURRENT_N_IMAGE_URL, DOGE_WHALE_ESCAPE_CURRENT_S_IMAGE_URL, DOGE_WHALE_ESCAPE_CURRENT_E_IMAGE_URL, DOGE_WHALE_ESCAPE_CURRENT_W_IMAGE_URL, DOGE_WHALE_ESCAPE_SNAKE_IMAGE_URL, DOGE_WHALE_ESCAPE_LIFEUP_IMAGE_URL } from '../../constants';
|
| 8 |
+
import { GoogleGenAI, GenerateContentResponse } from "@google/genai";
|
| 9 |
+
import { generateId } from '../../utils';
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
interface AdminPanelModalProps {
|
| 13 |
+
isOpen: boolean;
|
| 14 |
+
onClose: () => void;
|
| 15 |
+
adminConfig: AdminConfig;
|
| 16 |
+
runtimeNftDefinitions: Record<string, NFT>;
|
| 17 |
+
runtimeGameCharacters: Record<string, GameCharacter>;
|
| 18 |
+
onUpdateAdminConfig: (newConfig: Partial<AdminConfig>) => void;
|
| 19 |
+
onUpdateNftImage: (nftId: string, newImageUrl: string) => void;
|
| 20 |
+
onUpdateCharacterAttributes: (characterId: string, updates: Partial<GameCharacter>) => void;
|
| 21 |
+
showNotification: (message: string, type?: 'success' | 'error' | 'info' | 'warning' | 'special') => void;
|
| 22 |
+
isMaximizable?: boolean;
|
| 23 |
+
isMaximized?: boolean;
|
| 24 |
+
onToggleMaximize?: () => void;
|
| 25 |
+
onLogout: () => void; // New prop for logout
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
type AdminTab = 'aiGenerator' | 'nfts' | 'characters' | 'staking' | 'assets' | 'gameplay' | 'sounds' | 'effectsVisuals' | 'aiChat' | 'knowledgeBase' | 'dogeEscapeAssets';
|
| 29 |
+
|
| 30 |
+
type SoundUrlKey = Extract<keyof AdminConfig, `sound${string}Url`>;
|
| 31 |
+
type AdminConfigImageKey = Extract<keyof AdminConfig, `${string}Image${string}` | `${string}ImageUrl`>;
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
const convertFileToBase64 = (file: File): Promise<string> => {
|
| 35 |
+
return new Promise((resolve, reject) => {
|
| 36 |
+
const reader = new FileReader();
|
| 37 |
+
reader.readAsDataURL(file);
|
| 38 |
+
reader.onload = () => resolve(reader.result as string);
|
| 39 |
+
reader.onerror = error => reject(error);
|
| 40 |
+
});
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
interface CharacterAttributeInputs {
|
| 44 |
+
name: string;
|
| 45 |
+
imageURL: string;
|
| 46 |
+
imageBase64?: string | null;
|
| 47 |
+
defaultWeaponType?: WeaponType;
|
| 48 |
+
autoFireHealthThreshold?: number;
|
| 49 |
+
baseHealth?: number;
|
| 50 |
+
baseSpeed?: number;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const formatWeaponTypeName = (enumKey: string) => {
|
| 54 |
+
return enumKey.replace(/([A-Z])/g, ' $1').trim().replace(/_/g, ' ');
|
| 55 |
+
};
|
| 56 |
+
|
| 57 |
+
const formatSoundEventName = (event: SoundEvent): string => {
|
| 58 |
+
return event
|
| 59 |
+
.split('_')
|
| 60 |
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
| 61 |
+
.join(' ');
|
| 62 |
+
};
|
| 63 |
+
|
| 64 |
+
interface AIChatMessage {
|
| 65 |
+
id: string;
|
| 66 |
+
sender: 'user' | 'ai' | 'error';
|
| 67 |
+
text: string;
|
| 68 |
+
lang?: string; // Language of the user's query
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
const languageOptions = [
|
| 72 |
+
{ name: 'English', code: 'en' },
|
| 73 |
+
{ name: 'Spanish', code: 'es' },
|
| 74 |
+
{ name: 'French', code: 'fr' },
|
| 75 |
+
{ name: 'German', code: 'de' },
|
| 76 |
+
{ name: 'Japanese', code: 'ja' },
|
| 77 |
+
{ name: 'Chinese', code: 'zh' },
|
| 78 |
+
{ name: 'Korean', code: 'ko' },
|
| 79 |
+
];
|
| 80 |
+
|
| 81 |
+
const dogeEscapeAssetConfigKeys: Array<{key: AdminConfigImageKey, displayName: string, defaultUrl: string}> = [
|
| 82 |
+
{ key: 'dogeEscapePlayerImageUrl', displayName: 'Doge Escape Player', defaultUrl: DOGE_WHALE_ESCAPE_PLAYER_IMAGE_URL },
|
| 83 |
+
{ key: 'dogeEscapeWallImageUrl', displayName: 'Doge Escape Wall', defaultUrl: DOGE_WHALE_ESCAPE_WALL_IMAGE_URL },
|
| 84 |
+
{ key: 'dogeEscapeGoalImageUrl', displayName: 'Doge Escape Goal', defaultUrl: DOGE_WHALE_ESCAPE_GOAL_IMAGE_URL },
|
| 85 |
+
{ key: 'dogeEscapeMineImageUrl', displayName: 'Doge Escape Mine', defaultUrl: DOGE_WHALE_ESCAPE_MINE_IMAGE_URL },
|
| 86 |
+
{ key: 'dogeEscapeBombImageUrl', displayName: 'Doge Escape Bomb', defaultUrl: DOGE_WHALE_ESCAPE_BOMB_IMAGE_URL },
|
| 87 |
+
{ key: 'dogeEscapeJellyfishImageUrl', displayName: 'Doge Escape Jellyfish', defaultUrl: DOGE_WHALE_ESCAPE_JELLYFISH_IMAGE_URL },
|
| 88 |
+
{ key: 'dogeEscapeCrocodileImageUrl', displayName: 'Doge Escape Crocodile', defaultUrl: DOGE_WHALE_ESCAPE_CROCODILE_IMAGE_URL },
|
| 89 |
+
{ key: 'dogeEscapeAlgaeImageUrl', displayName: 'Doge Escape Toxic Algae', defaultUrl: DOGE_WHALE_ESCAPE_ALGAE_IMAGE_URL },
|
| 90 |
+
{ key: 'dogeEscapeCurrentNImageUrl', displayName: 'Doge Escape Current (North)', defaultUrl: DOGE_WHALE_ESCAPE_CURRENT_N_IMAGE_URL },
|
| 91 |
+
{ key: 'dogeEscapeCurrentSImageUrl', displayName: 'Doge Escape Current (South)', defaultUrl: DOGE_WHALE_ESCAPE_CURRENT_S_IMAGE_URL },
|
| 92 |
+
{ key: 'dogeEscapeCurrentEImageUrl', displayName: 'Doge Escape Current (East)', defaultUrl: DOGE_WHALE_ESCAPE_CURRENT_E_IMAGE_URL },
|
| 93 |
+
{ key: 'dogeEscapeCurrentWImageUrl', displayName: 'Doge Escape Current (West)', defaultUrl: DOGE_WHALE_ESCAPE_CURRENT_W_IMAGE_URL },
|
| 94 |
+
{ key: 'dogeEscapeSnakeImageUrl', displayName: 'Doge Escape Snake', defaultUrl: DOGE_WHALE_ESCAPE_SNAKE_IMAGE_URL },
|
| 95 |
+
{ key: 'dogeEscapeLifeUpImageUrl', displayName: 'Doge Escape Life-Up', defaultUrl: DOGE_WHALE_ESCAPE_LIFEUP_IMAGE_URL },
|
| 96 |
+
];
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
export const AdminPanelModal: React.FC<AdminPanelModalProps> = ({
|
| 100 |
+
isOpen,
|
| 101 |
+
onClose,
|
| 102 |
+
adminConfig,
|
| 103 |
+
runtimeNftDefinitions,
|
| 104 |
+
runtimeGameCharacters,
|
| 105 |
+
onUpdateAdminConfig,
|
| 106 |
+
onUpdateNftImage,
|
| 107 |
+
onUpdateCharacterAttributes,
|
| 108 |
+
showNotification,
|
| 109 |
+
isMaximizable,
|
| 110 |
+
isMaximized,
|
| 111 |
+
onToggleMaximize,
|
| 112 |
+
onLogout, // Destructure new prop
|
| 113 |
+
}) => {
|
| 114 |
+
const [activeTab, setActiveTab] = useState<AdminTab>('aiChat');
|
| 115 |
+
const [localAdminConfig, setLocalAdminConfig] = useState<AdminConfig>(adminConfig);
|
| 116 |
+
|
| 117 |
+
const [nftImageInputs, setNftImageInputs] = useState<Record<string, string>>({});
|
| 118 |
+
const [pendingNftImageChanges, setPendingNftImageChanges] = useState<Record<string, string | null>>({});
|
| 119 |
+
|
| 120 |
+
const [characterAttributeInputs, setCharacterAttributeInputs] = useState<Record<string, CharacterAttributeInputs>>({});
|
| 121 |
+
|
| 122 |
+
const [aiPrompts, setAiPrompts] = useState<Record<string, string>>({});
|
| 123 |
+
const [aiGeneratedImagePreviews, setAiGeneratedImagePreviews] = useState<Record<string, string | null>>({});
|
| 124 |
+
const [isGenerating, setIsGenerating] = useState<Record<string, boolean>>({});
|
| 125 |
+
const [superAiAgentEnabled, setSuperAiAgentEnabled] = useState<boolean>(true);
|
| 126 |
+
|
| 127 |
+
const [aiSuggestionDimensions, setAiSuggestionDimensions] = useState<string>('');
|
| 128 |
+
const [aiSuggestionDirection, setAiSuggestionDirection] = useState<string>('any');
|
| 129 |
+
const [aiSuggestionStyle, setAiSuggestionStyle] = useState<string>('any');
|
| 130 |
+
const [aiSuggestionBackground, setAiSuggestionBackground] = useState<string>('any');
|
| 131 |
+
|
| 132 |
+
const [adminAssetUrlInputs, setAdminAssetUrlInputs] = useState<Record<AdminConfigImageKey, string>>({} as Record<AdminConfigImageKey, string>);
|
| 133 |
+
const [pendingAdminAssetBase64, setPendingAdminAssetBase64] = useState<Partial<Record<AdminConfigImageKey, string | null>>>({});
|
| 134 |
+
|
| 135 |
+
const [pendingSoundUploads, setPendingSoundUploads] = useState<Partial<Record<SoundUrlKey, string>>>({});
|
| 136 |
+
|
| 137 |
+
// AI Chat Bot State
|
| 138 |
+
const [aiChatMessages, setAiChatMessages] = useState<AIChatMessage[]>([]);
|
| 139 |
+
const [currentAiQuery, setCurrentAiQuery] = useState('');
|
| 140 |
+
const [selectedAiLanguage, setSelectedAiLanguage] = useState(languageOptions[0].code); // Default to English code
|
| 141 |
+
const [isAiResponding, setIsAiResponding] = useState(false);
|
| 142 |
+
const aiChatLogRef = useRef<HTMLDivElement>(null);
|
| 143 |
+
const geminiAi = useMemo(() => new GoogleGenAI({ apiKey: process.env.API_KEY! }), []);
|
| 144 |
+
|
| 145 |
+
// Knowledge Base State
|
| 146 |
+
const [newReplyTopic, setNewReplyTopic] = useState<string>('');
|
| 147 |
+
const [newReplyAnswer, setNewReplyAnswer] = useState<string>('');
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
useEffect(() => {
|
| 151 |
+
if (isOpen) {
|
| 152 |
+
const newLocalAdminConfig = JSON.parse(JSON.stringify(adminConfig));
|
| 153 |
+
if (!newLocalAdminConfig.savedReplies) { // Initialize if undefined
|
| 154 |
+
newLocalAdminConfig.savedReplies = [];
|
| 155 |
+
}
|
| 156 |
+
setLocalAdminConfig(newLocalAdminConfig);
|
| 157 |
+
|
| 158 |
+
const initialNftImageInputs: Record<string, string> = {};
|
| 159 |
+
const initialPendingNftChanges: Record<string, string | null> = {};
|
| 160 |
+
Object.keys(runtimeNftDefinitions).forEach(nftId => {
|
| 161 |
+
const img = runtimeNftDefinitions[nftId].image;
|
| 162 |
+
initialNftImageInputs[nftId] = img.startsWith('data:image') ? '' : img;
|
| 163 |
+
initialPendingNftChanges[nftId] = img.startsWith('data:image') ? img : null;
|
| 164 |
+
});
|
| 165 |
+
setNftImageInputs(initialNftImageInputs);
|
| 166 |
+
setPendingNftImageChanges(initialPendingNftChanges);
|
| 167 |
+
|
| 168 |
+
const initialCharAttrInputs: Record<string, CharacterAttributeInputs> = {};
|
| 169 |
+
Object.keys(runtimeGameCharacters).forEach(charId => {
|
| 170 |
+
const char = runtimeGameCharacters[charId];
|
| 171 |
+
initialCharAttrInputs[charId] = {
|
| 172 |
+
name: char.name,
|
| 173 |
+
imageURL: char.image.startsWith('data:image') ? '' : char.image,
|
| 174 |
+
imageBase64: char.image.startsWith('data:image') ? char.image : null,
|
| 175 |
+
defaultWeaponType: char.defaultWeaponType || WeaponType.StandardBullet,
|
| 176 |
+
autoFireHealthThreshold: char.autoFireHealthThreshold || 0,
|
| 177 |
+
baseHealth: char.baseHealth || (char.type === 'player' ? 100 : (char.type === 'enemy' ? 25 : 100)),
|
| 178 |
+
baseSpeed: char.baseSpeed || (char.type === 'enemy' ? 1 : PLAYER_SPEED),
|
| 179 |
+
};
|
| 180 |
+
});
|
| 181 |
+
setCharacterAttributeInputs(initialCharAttrInputs);
|
| 182 |
+
|
| 183 |
+
const currentAdminAssetUrlInputs: Record<AdminConfigImageKey, string> = {} as Record<AdminConfigImageKey, string>;
|
| 184 |
+
const currentPendingAdminAssetBase64: Partial<Record<AdminConfigImageKey, string | null>> = {};
|
| 185 |
+
|
| 186 |
+
(Object.keys(newLocalAdminConfig) as Array<keyof AdminConfig>).forEach(key => {
|
| 187 |
+
if ((key.toLowerCase().includes('image') || key.toLowerCase().includes('imageurl')) && typeof newLocalAdminConfig[key] === 'string') {
|
| 188 |
+
const imgVal = newLocalAdminConfig[key] as string;
|
| 189 |
+
if (imgVal.startsWith('data:image')) {
|
| 190 |
+
currentPendingAdminAssetBase64[key as AdminConfigImageKey] = imgVal;
|
| 191 |
+
currentAdminAssetUrlInputs[key as AdminConfigImageKey] = '';
|
| 192 |
+
} else {
|
| 193 |
+
currentAdminAssetUrlInputs[key as AdminConfigImageKey] = imgVal;
|
| 194 |
+
currentPendingAdminAssetBase64[key as AdminConfigImageKey] = null;
|
| 195 |
+
}
|
| 196 |
+
}
|
| 197 |
+
});
|
| 198 |
+
setAdminAssetUrlInputs(currentAdminAssetUrlInputs);
|
| 199 |
+
setPendingAdminAssetBase64(currentPendingAdminAssetBase64);
|
| 200 |
+
|
| 201 |
+
setAiPrompts({});
|
| 202 |
+
setAiGeneratedImagePreviews({});
|
| 203 |
+
setIsGenerating({});
|
| 204 |
+
setPendingSoundUploads({});
|
| 205 |
+
|
| 206 |
+
// Reset AI Chat state
|
| 207 |
+
setAiChatMessages([]);
|
| 208 |
+
setCurrentAiQuery('');
|
| 209 |
+
setIsAiResponding(false);
|
| 210 |
+
|
| 211 |
+
// Reset Knowledge Base input fields
|
| 212 |
+
setNewReplyTopic('');
|
| 213 |
+
setNewReplyAnswer('');
|
| 214 |
+
}
|
| 215 |
+
}, [isOpen, adminConfig, runtimeNftDefinitions, runtimeGameCharacters]);
|
| 216 |
+
|
| 217 |
+
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
| 218 |
+
const { name, value, type } = e.target;
|
| 219 |
+
const checked = (e.target as HTMLInputElement).checked;
|
| 220 |
+
setLocalAdminConfig(prev => ({
|
| 221 |
+
...prev,
|
| 222 |
+
[name]: type === 'checkbox' ? checked : type === 'number' ? parseFloat(value) : value,
|
| 223 |
+
}));
|
| 224 |
+
};
|
| 225 |
+
|
| 226 |
+
const handleNftImageUrlChange = (nftId: string, value: string) => {
|
| 227 |
+
setNftImageInputs(prev => ({ ...prev, [nftId]: value }));
|
| 228 |
+
setPendingNftImageChanges(prev => ({...prev, [nftId]: null}));
|
| 229 |
+
};
|
| 230 |
+
const handleNftImageFileUpload = async (nftId: string, file: File | null) => {
|
| 231 |
+
if (file) {
|
| 232 |
+
try {
|
| 233 |
+
const base64String = await convertFileToBase64(file);
|
| 234 |
+
setPendingNftImageChanges(prev => ({...prev, [nftId]: base64String}));
|
| 235 |
+
setNftImageInputs(prev => ({ ...prev, [nftId]: '' }));
|
| 236 |
+
showNotification(`Image ready for NFT: ${runtimeNftDefinitions[nftId]?.name}. Click 'Apply Custom Image'.`, 'info');
|
| 237 |
+
} catch (error) { console.error("Error converting NFT image:", error); showNotification('Failed to load image file.', 'error');}
|
| 238 |
+
}
|
| 239 |
+
};
|
| 240 |
+
const handleApplyCustomNftImage = (nftId: string) => {
|
| 241 |
+
const pendingBase64 = pendingNftImageChanges[nftId];
|
| 242 |
+
const urlInput = nftImageInputs[nftId];
|
| 243 |
+
if (pendingBase64) {
|
| 244 |
+
onUpdateNftImage(nftId, pendingBase64);
|
| 245 |
+
} else if (urlInput && urlInput.trim() !== '') {
|
| 246 |
+
onUpdateNftImage(nftId, urlInput);
|
| 247 |
+
} else {
|
| 248 |
+
showNotification('No new image (URL or file) to apply.', 'warning');
|
| 249 |
+
}
|
| 250 |
+
};
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
const handleCharacterAttributeChange = (characterId: string, field: keyof CharacterAttributeInputs, value: string | number | WeaponType | undefined) => {
|
| 254 |
+
setCharacterAttributeInputs(prev => ({
|
| 255 |
+
...prev,
|
| 256 |
+
[characterId]: {
|
| 257 |
+
...(prev[characterId] || {} as CharacterAttributeInputs),
|
| 258 |
+
[field]: value,
|
| 259 |
+
...(field === 'imageURL' && { imageBase64: null })
|
| 260 |
+
}
|
| 261 |
+
}));
|
| 262 |
+
};
|
| 263 |
+
|
| 264 |
+
const handleCharacterSelectChange = (characterId: string, field: keyof CharacterAttributeInputs, value: string) => {
|
| 265 |
+
const weaponTypeEnumValue = WeaponType[value as keyof typeof WeaponType];
|
| 266 |
+
handleCharacterAttributeChange(characterId, field, weaponTypeEnumValue || WeaponType.StandardBullet);
|
| 267 |
+
};
|
| 268 |
+
|
| 269 |
+
const handleCharacterImageFileUpload = async (characterId: string, file: File | null) => {
|
| 270 |
+
if (file) {
|
| 271 |
+
try {
|
| 272 |
+
const base64String = await convertFileToBase64(file);
|
| 273 |
+
setCharacterAttributeInputs(prev => ({
|
| 274 |
+
...prev,
|
| 275 |
+
[characterId]: {
|
| 276 |
+
...(prev[characterId] || {} as CharacterAttributeInputs),
|
| 277 |
+
imageBase64: base64String,
|
| 278 |
+
imageURL: ''
|
| 279 |
+
}
|
| 280 |
+
}));
|
| 281 |
+
showNotification(`Image ready for Character: ${runtimeGameCharacters[characterId]?.name}. Click 'Apply Custom Image'.`, 'info');
|
| 282 |
+
} catch (error) { console.error("Error converting character image:", error); showNotification('Failed to load image file.', 'error');}
|
| 283 |
+
}
|
| 284 |
+
};
|
| 285 |
+
|
| 286 |
+
const handleApplyCustomCharacterImage = (characterId: string) => {
|
| 287 |
+
const currentInputs = characterAttributeInputs[characterId];
|
| 288 |
+
if (!currentInputs) return;
|
| 289 |
+
if (currentInputs.imageBase64) {
|
| 290 |
+
onUpdateCharacterAttributes(characterId, { image: currentInputs.imageBase64 });
|
| 291 |
+
} else if (currentInputs.imageURL && currentInputs.imageURL.trim() !== '') {
|
| 292 |
+
onUpdateCharacterAttributes(characterId, { image: currentInputs.imageURL });
|
| 293 |
+
} else {
|
| 294 |
+
showNotification('No new image (URL or file) to apply for character.', 'warning');
|
| 295 |
+
}
|
| 296 |
+
};
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
const handleApplyCharacterNonImageChanges = (characterId: string) => {
|
| 300 |
+
const currentInputs = characterAttributeInputs[characterId];
|
| 301 |
+
if (!currentInputs) return;
|
| 302 |
+
const updates: Partial<GameCharacter> = {};
|
| 303 |
+
const originalChar = runtimeGameCharacters[characterId];
|
| 304 |
+
|
| 305 |
+
if (currentInputs.name !== originalChar.name) updates.name = currentInputs.name;
|
| 306 |
+
if (currentInputs.baseHealth !== originalChar.baseHealth && typeof currentInputs.baseHealth === 'number') updates.baseHealth = currentInputs.baseHealth;
|
| 307 |
+
if (currentInputs.baseSpeed !== originalChar.baseSpeed && typeof currentInputs.baseSpeed === 'number') updates.baseSpeed = currentInputs.baseSpeed;
|
| 308 |
+
|
| 309 |
+
if (originalChar.type === 'player') {
|
| 310 |
+
if (currentInputs.defaultWeaponType !== (originalChar.defaultWeaponType || WeaponType.StandardBullet)) updates.defaultWeaponType = currentInputs.defaultWeaponType;
|
| 311 |
+
const newThreshold = Number(currentInputs.autoFireHealthThreshold);
|
| 312 |
+
if (newThreshold !== (originalChar.autoFireHealthThreshold || 0) && !isNaN(newThreshold)) updates.autoFireHealthThreshold = Math.max(0, Math.min(100, newThreshold));
|
| 313 |
+
}
|
| 314 |
+
if (Object.keys(updates).length > 0) onUpdateCharacterAttributes(characterId, updates);
|
| 315 |
+
else showNotification('No non-image attributes changed.', 'info');
|
| 316 |
+
};
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
const handleAdminConfigAssetUrlChange = (configKey: AdminConfigImageKey, value: string) => {
|
| 320 |
+
setAdminAssetUrlInputs(prev => ({ ...prev, [configKey]: value }));
|
| 321 |
+
setPendingAdminAssetBase64(prev => ({ ...prev, [configKey]: null }));
|
| 322 |
+
setLocalAdminConfig(prev => ({ ...prev, [configKey]: value }));
|
| 323 |
+
};
|
| 324 |
+
|
| 325 |
+
const handleAdminConfigAssetFileUpload = async (configKey: AdminConfigImageKey, file: File | null) => {
|
| 326 |
+
if (file) {
|
| 327 |
+
try {
|
| 328 |
+
const base64String = await convertFileToBase64(file);
|
| 329 |
+
setPendingAdminAssetBase64(prev => ({ ...prev, [configKey]: base64String }));
|
| 330 |
+
setAdminAssetUrlInputs(prev => ({ ...prev, [configKey]: '' }));
|
| 331 |
+
setLocalAdminConfig(prev => ({ ...prev, [configKey]: base64String }));
|
| 332 |
+
showNotification(`Image ready for ${configKey}. Save all admin changes to finalize.`, 'info');
|
| 333 |
+
} catch (error) { console.error(`Error converting ${configKey} image:`, error); showNotification('Failed to load image file.', 'error'); }
|
| 334 |
+
}
|
| 335 |
+
};
|
| 336 |
+
|
| 337 |
+
const handleApplyCustomAdminConfigAssetImage = (configKey: AdminConfigImageKey) => {
|
| 338 |
+
const pendingBase64 = pendingAdminAssetBase64[configKey];
|
| 339 |
+
const urlInput = adminAssetUrlInputs[configKey];
|
| 340 |
+
let applied = false;
|
| 341 |
+
if (pendingBase64) {
|
| 342 |
+
setLocalAdminConfig(prev => ({ ...prev, [configKey]: pendingBase64 }));
|
| 343 |
+
applied = true;
|
| 344 |
+
} else if (urlInput && urlInput.trim() !== '') {
|
| 345 |
+
setLocalAdminConfig(prev => ({ ...prev, [configKey]: urlInput }));
|
| 346 |
+
applied = true;
|
| 347 |
+
}
|
| 348 |
+
if (applied) showNotification(`Preview updated for ${configKey}. Save all admin changes to finalize.`, 'info');
|
| 349 |
+
else showNotification(`No new image (URL or file) to apply for ${configKey}.`, 'warning');
|
| 350 |
+
};
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
const handleSoundFileUpload = async (eventKey: SoundEvent, file: File | null) => {
|
| 354 |
+
if (file) {
|
| 355 |
+
try {
|
| 356 |
+
const base64String = await convertFileToBase64(file);
|
| 357 |
+
const adminSoundKeyPattern = `sound${eventKey.charAt(0).toUpperCase() + eventKey.slice(1).replace(/_([a-z])/g, (g) => g[1].toUpperCase())}Url`;
|
| 358 |
+
const adminSoundKey = adminSoundKeyPattern as SoundUrlKey;
|
| 359 |
+
setPendingSoundUploads(prev => ({...prev, [adminSoundKey]: base64String}));
|
| 360 |
+
setLocalAdminConfig(prev => ({ ...prev, [adminSoundKey]: base64String }));
|
| 361 |
+
showNotification(`${formatSoundEventName(eventKey)} sound updated. Save all admin changes to finalize.`, 'info');
|
| 362 |
+
} catch (error) { console.error(`Error converting sound file for ${eventKey}:`, error); showNotification(`Failed to load sound for ${eventKey}.`, 'error');}
|
| 363 |
+
}
|
| 364 |
+
};
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
const handleSaveAllAdminChanges = () => {
|
| 368 |
+
const finalConfig = {...localAdminConfig};
|
| 369 |
+
(Object.keys(pendingAdminAssetBase64) as Array<AdminConfigImageKey>).forEach(key => {
|
| 370 |
+
if (pendingAdminAssetBase64[key]) {
|
| 371 |
+
finalConfig[key] = pendingAdminAssetBase64[key]!;
|
| 372 |
+
} else if (adminAssetUrlInputs[key] && adminAssetUrlInputs[key].trim() !== '') {
|
| 373 |
+
finalConfig[key] = adminAssetUrlInputs[key];
|
| 374 |
+
}
|
| 375 |
+
});
|
| 376 |
+
Object.keys(pendingSoundUploads).forEach(stringKey => {
|
| 377 |
+
const key = stringKey as SoundUrlKey;
|
| 378 |
+
if (pendingSoundUploads[key]) {
|
| 379 |
+
finalConfig[key] = pendingSoundUploads[key]!;
|
| 380 |
+
}
|
| 381 |
+
});
|
| 382 |
+
onUpdateAdminConfig(finalConfig);
|
| 383 |
+
};
|
| 384 |
+
|
| 385 |
+
const handleAiPromptChange = (assetKey: string, prompt: string) => {
|
| 386 |
+
setAiPrompts(prev => ({ ...prev, [assetKey]: prompt }));
|
| 387 |
+
};
|
| 388 |
+
|
| 389 |
+
const generateCharacterPromptFromName = (
|
| 390 |
+
characterName: string,
|
| 391 |
+
characterType: 'player' | 'enemy'
|
| 392 |
+
): string => {
|
| 393 |
+
const cleanName = characterName.replace(/\p{Emoji}/gu, '').trim();
|
| 394 |
+
let baseDescription = "";
|
| 395 |
+
|
| 396 |
+
if (characterType === 'player') {
|
| 397 |
+
baseDescription = `A game character asset, a player Doge Whale, visually representing: ${cleanName}.`;
|
| 398 |
+
} else {
|
| 399 |
+
baseDescription = `A game character asset, an enemy sea creature, visually representing: ${cleanName}.`;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
let prompt = `${baseDescription}`;
|
| 403 |
+
|
| 404 |
+
if (aiSuggestionStyle && aiSuggestionStyle !== 'any') {
|
| 405 |
+
prompt += ` Style: ${aiSuggestionStyle}.`;
|
| 406 |
+
} else {
|
| 407 |
+
prompt += ` Style: 3D render.`;
|
| 408 |
+
}
|
| 409 |
+
if (aiSuggestionDimensions) {
|
| 410 |
+
prompt += ` Dimensions: ${aiSuggestionDimensions}.`;
|
| 411 |
+
}
|
| 412 |
+
if (aiSuggestionDirection && aiSuggestionDirection !== 'any') {
|
| 413 |
+
prompt += ` Direction: ${aiSuggestionDirection}.`;
|
| 414 |
+
}
|
| 415 |
+
if (aiSuggestionBackground && aiSuggestionBackground !== 'any') {
|
| 416 |
+
prompt += ` Background: ${aiSuggestionBackground}.`;
|
| 417 |
+
} else {
|
| 418 |
+
prompt += ` Background: transparent background for versatility.`;
|
| 419 |
+
}
|
| 420 |
+
return prompt.trim();
|
| 421 |
+
};
|
| 422 |
+
|
| 423 |
+
const handleAutoGenerateForCharacter = async (characterId: string) => {
|
| 424 |
+
const character = runtimeGameCharacters[characterId];
|
| 425 |
+
if (!character) return;
|
| 426 |
+
|
| 427 |
+
const assetKey = `char_${characterId}`;
|
| 428 |
+
const generatedPrompt = generateCharacterPromptFromName(
|
| 429 |
+
character.name,
|
| 430 |
+
character.type
|
| 431 |
+
);
|
| 432 |
+
|
| 433 |
+
setAiPrompts(prev => ({ ...prev, [assetKey]: generatedPrompt }));
|
| 434 |
+
showNotification(`Prompt suggested for ${character.name}: "${generatedPrompt.substring(0,50)}..."`, 'info');
|
| 435 |
+
|
| 436 |
+
await handleGenerateAiImage(assetKey, 'character', characterId);
|
| 437 |
+
};
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
const handleGenerateAiImage = async (assetKey: string, itemType: string, idOrConfigKey: string) => {
|
| 441 |
+
const userPrompt = aiPrompts[assetKey];
|
| 442 |
+
if (!userPrompt) {
|
| 443 |
+
showNotification("Please enter or auto-generate a prompt for the AI.", "warning");
|
| 444 |
+
return;
|
| 445 |
+
}
|
| 446 |
+
|
| 447 |
+
setIsGenerating(prev => ({ ...prev, [assetKey]: true }));
|
| 448 |
+
setAiGeneratedImagePreviews(prev => ({ ...prev, [assetKey]: null }));
|
| 449 |
+
|
| 450 |
+
let fullPrompt = "";
|
| 451 |
+
let itemNameForNotification = idOrConfigKey;
|
| 452 |
+
|
| 453 |
+
switch (itemType) {
|
| 454 |
+
case 'nft':
|
| 455 |
+
fullPrompt = `A digital art NFT for a game, depicting a Doge Whale character. The NFT should represent: ${userPrompt}.`;
|
| 456 |
+
itemNameForNotification = runtimeNftDefinitions[idOrConfigKey]?.name || idOrConfigKey;
|
| 457 |
+
break;
|
| 458 |
+
case 'character':
|
| 459 |
+
fullPrompt = userPrompt;
|
| 460 |
+
itemNameForNotification = runtimeGameCharacters[idOrConfigKey]?.name || idOrConfigKey;
|
| 461 |
+
break;
|
| 462 |
+
case 'adminConfigAsset': // Generic handler for AdminConfig assets based on idOrConfigKey
|
| 463 |
+
const configKeyName = idOrConfigKey as keyof AdminConfig;
|
| 464 |
+
const displayNameForKey = configKeyName
|
| 465 |
+
.replace('adminConfig_', '')
|
| 466 |
+
.replace('ImageUrl', '')
|
| 467 |
+
.replace('Image', '')
|
| 468 |
+
.replace(/([A-Z])/g, ' $1').trim();
|
| 469 |
+
|
| 470 |
+
if (configKeyName.includes('dogeEscape')) {
|
| 471 |
+
fullPrompt = `A game asset for 'Doge Whale Escape', a 2D maze game. The asset is a ${displayNameForKey}. Appearance: ${userPrompt}. Style: Clear, slightly pixelated or cartoonish, suitable for a grid-based game.`;
|
| 472 |
+
} else if (configKeyName === 'gameBackgroundImageUrl') {
|
| 473 |
+
fullPrompt = `A vibrant and immersive game background for an underwater space MMORPG featuring Doge Whales. The scene should be: ${userPrompt}. Scenic and detailed.`;
|
| 474 |
+
} else if (configKeyName === 'defaultEnemyImageUrl') {
|
| 475 |
+
fullPrompt = `A generic enemy creature for an underwater space game. Appearance: ${userPrompt}. Needs to be distinct and somewhat menacing.`;
|
| 476 |
+
} else if (configKeyName === 'seaTokenImageUrl') {
|
| 477 |
+
fullPrompt = `A single, distinct game currency item called a "Sea Token". It should look like: ${userPrompt}. Small, clear, iconic design.`;
|
| 478 |
+
} else if (configKeyName === 'redArrowImageUrl') {
|
| 479 |
+
fullPrompt = `A sleek, powerful red arrow projectile for a defensive weapon system in a space game. Appearance: ${userPrompt}. Dynamic, sci-fi style, glowing red.`;
|
| 480 |
+
} else if (configKeyName === 'flameParticleImageUrl') {
|
| 481 |
+
fullPrompt = `Small, bright, fiery particles or hot embers, suitable for a flame cone weapon effect in a game. Details: ${userPrompt}. Style: VFX sprite, clear background if possible.`;
|
| 482 |
+
} else if (configKeyName === 'enemyBurningEffectImageUrl') {
|
| 483 |
+
fullPrompt = `A visual effect for a game, representing an enemy character on fire. Could be a sprite sheet for an animation or a static overlay. Details: ${userPrompt}. Style: Game visual effect, transparent background suitable for overlay.`;
|
| 484 |
+
} else if (configKeyName === 'fireballProjectileImageUrl') {
|
| 485 |
+
fullPrompt = `A dynamic and impactful fireball projectile for a fantasy or sci-fi game weapon. Details: ${userPrompt}. Style: Magical, fiery, possibly with a trailing effect, on a transparent background.`;
|
| 486 |
+
} else if (configKeyName.startsWith('decorativeFish')) {
|
| 487 |
+
const fishType = displayNameForKey.replace('Decorative Fish ', '');
|
| 488 |
+
fullPrompt = `A decorative ${fishType.toLowerCase()} fish for an underwater space game. Appearance: ${userPrompt}.`;
|
| 489 |
+
} else if (configKeyName.startsWith('bubble')) {
|
| 490 |
+
const bubbleSize = displayNameForKey.replace('Bubble ', '');
|
| 491 |
+
fullPrompt = `A ${bubbleSize.toLowerCase()} bubble effect for an underwater game. Appearance: ${userPrompt}.`;
|
| 492 |
+
} else {
|
| 493 |
+
fullPrompt = `A game asset: ${displayNameForKey}. Description: ${userPrompt}.`;
|
| 494 |
+
}
|
| 495 |
+
itemNameForNotification = displayNameForKey;
|
| 496 |
+
break;
|
| 497 |
+
default:
|
| 498 |
+
showNotification("Unknown item type for AI generation.", "error");
|
| 499 |
+
setIsGenerating(prev => ({ ...prev, [assetKey]: false }));
|
| 500 |
+
return;
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
const result: GeminiImageResponse = await generateImageWithGemini(fullPrompt);
|
| 504 |
+
setIsGenerating(prev => ({ ...prev, [assetKey]: false }));
|
| 505 |
+
|
| 506 |
+
if (result.success && result.imageUrl) {
|
| 507 |
+
setAiGeneratedImagePreviews(prev => ({ ...prev, [assetKey]: result.imageUrl! }));
|
| 508 |
+
if (superAiAgentEnabled) {
|
| 509 |
+
handleApplyAiImage(assetKey, itemType, idOrConfigKey, result.imageUrl);
|
| 510 |
+
showNotification(`AI image generated and auto-applied to ${itemNameForNotification}!`, "success");
|
| 511 |
+
} else {
|
| 512 |
+
showNotification(`Image generated for ${itemNameForNotification}! Preview below. Click 'Apply AI Image'.`, "success");
|
| 513 |
+
}
|
| 514 |
+
} else {
|
| 515 |
+
if (result.errorType === 'quota_exhausted') {
|
| 516 |
+
showNotification(result.message || 'API Quota Exceeded. Check your plan or wait for reset.', 'error');
|
| 517 |
+
} else {
|
| 518 |
+
showNotification(result.message || `Failed to generate image for ${itemNameForNotification}. Check console.`, 'error');
|
| 519 |
+
}
|
| 520 |
+
}
|
| 521 |
+
};
|
| 522 |
+
|
| 523 |
+
const handleApplyAiImage = (assetKey: string, itemType: string, idOrConfigKey: string, imageUrlToApply?: string) => {
|
| 524 |
+
const imageUrl = imageUrlToApply || aiGeneratedImagePreviews[assetKey];
|
| 525 |
+
if (!imageUrl) {
|
| 526 |
+
showNotification("No AI-generated image to apply.", "warning");
|
| 527 |
+
return;
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
let itemNameForNotification = idOrConfigKey;
|
| 531 |
+
|
| 532 |
+
if (itemType === 'nft') {
|
| 533 |
+
onUpdateNftImage(idOrConfigKey, imageUrl);
|
| 534 |
+
itemNameForNotification = runtimeNftDefinitions[idOrConfigKey]?.name || idOrConfigKey;
|
| 535 |
+
showNotification(`AI image applied to NFT: ${itemNameForNotification}.`, "success");
|
| 536 |
+
} else if (itemType === 'character') {
|
| 537 |
+
onUpdateCharacterAttributes(idOrConfigKey, { image: imageUrl });
|
| 538 |
+
itemNameForNotification = runtimeGameCharacters[idOrConfigKey]?.name || idOrConfigKey;
|
| 539 |
+
showNotification(`AI image applied to Character: ${itemNameForNotification}.`, "success");
|
| 540 |
+
} else if (itemType === 'adminConfigAsset') { // Updated for generic admin config assets
|
| 541 |
+
const configKey = idOrConfigKey as AdminConfigImageKey;
|
| 542 |
+
setLocalAdminConfig(prev => ({ ...prev, [configKey]: imageUrl }));
|
| 543 |
+
setPendingAdminAssetBase64(prev => ({ ...prev, [configKey]: imageUrl }));
|
| 544 |
+
setAdminAssetUrlInputs(prev => ({ ...prev, [configKey]: '' }));
|
| 545 |
+
|
| 546 |
+
itemNameForNotification = configKey.replace('adminConfig_', '').replace('ImageUrl', '').replace('Image', '').replace(/([A-Z])/g, ' $1').trim();
|
| 547 |
+
showNotification(`AI image applied to ${itemNameForNotification}. Save all admin changes to finalize.`, "success");
|
| 548 |
+
} else {
|
| 549 |
+
showNotification("Unknown item type for applying AI image.", "error");
|
| 550 |
+
return;
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
if (!superAiAgentEnabled || imageUrlToApply) {
|
| 554 |
+
setAiGeneratedImagePreviews(prev => ({ ...prev, [assetKey]: null }));
|
| 555 |
+
}
|
| 556 |
+
};
|
| 557 |
+
|
| 558 |
+
// AI Chat Bot Logic
|
| 559 |
+
const handleSendAiQuery = async () => {
|
| 560 |
+
if (currentAiQuery.trim() === '' || isAiResponding) return;
|
| 561 |
+
|
| 562 |
+
const userMessage: AIChatMessage = {
|
| 563 |
+
id: generateId(),
|
| 564 |
+
sender: 'user',
|
| 565 |
+
text: currentAiQuery,
|
| 566 |
+
lang: languageOptions.find(l => l.code === selectedAiLanguage)?.name || selectedAiLanguage,
|
| 567 |
+
};
|
| 568 |
+
setAiChatMessages(prev => [...prev, userMessage]);
|
| 569 |
+
setIsAiResponding(true);
|
| 570 |
+
setCurrentAiQuery('');
|
| 571 |
+
|
| 572 |
+
try {
|
| 573 |
+
const languageName = languageOptions.find(l => l.code === selectedAiLanguage)?.name || 'English';
|
| 574 |
+
let knowledgeBaseText = "";
|
| 575 |
+
if (localAdminConfig.savedReplies && localAdminConfig.savedReplies.length > 0) {
|
| 576 |
+
knowledgeBaseText = "You have access to the following predefined knowledge base. Use this information when relevant to the user's query:\n";
|
| 577 |
+
knowledgeBaseText += JSON.stringify(localAdminConfig.savedReplies.map(r => ({topic: r.topic, answer: r.answer}))) + "\n---\n";
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
const systemInstruction = `${knowledgeBaseText}You are a helpful AI assistant for a game administrator. Respond to the user's query in ${languageName}. Be concise and helpful.`;
|
| 581 |
+
|
| 582 |
+
const response: GenerateContentResponse = await geminiAi.models.generateContent({
|
| 583 |
+
model: 'gemini-2.5-flash-preview-04-17',
|
| 584 |
+
contents: userMessage.text,
|
| 585 |
+
config: { systemInstruction: systemInstruction },
|
| 586 |
+
});
|
| 587 |
+
|
| 588 |
+
const aiTextResponse = response.text;
|
| 589 |
+
const aiMessage: AIChatMessage = {
|
| 590 |
+
id: generateId(),
|
| 591 |
+
sender: 'ai',
|
| 592 |
+
text: aiTextResponse,
|
| 593 |
+
};
|
| 594 |
+
setAiChatMessages(prev => [...prev, aiMessage]);
|
| 595 |
+
|
| 596 |
+
} catch (error: any) {
|
| 597 |
+
console.error("Error with AI Chat:", error);
|
| 598 |
+
const errorMessage: AIChatMessage = {
|
| 599 |
+
id: generateId(),
|
| 600 |
+
sender: 'error',
|
| 601 |
+
text: `AI Error: ${error.message || 'Could not get a response.'}`,
|
| 602 |
+
};
|
| 603 |
+
setAiChatMessages(prev => [...prev, errorMessage]);
|
| 604 |
+
} finally {
|
| 605 |
+
setIsAiResponding(false);
|
| 606 |
+
}
|
| 607 |
+
};
|
| 608 |
+
|
| 609 |
+
useEffect(() => {
|
| 610 |
+
if (aiChatLogRef.current) {
|
| 611 |
+
aiChatLogRef.current.scrollTop = aiChatLogRef.current.scrollHeight;
|
| 612 |
+
}
|
| 613 |
+
}, [aiChatMessages]);
|
| 614 |
+
|
| 615 |
+
// Knowledge Base Management
|
| 616 |
+
const handleAddReply = () => {
|
| 617 |
+
if (!newReplyTopic.trim() || !newReplyAnswer.trim()) {
|
| 618 |
+
showNotification("Topic and Answer cannot be empty.", "warning");
|
| 619 |
+
return;
|
| 620 |
+
}
|
| 621 |
+
setLocalAdminConfig(prev => ({
|
| 622 |
+
...prev,
|
| 623 |
+
savedReplies: [
|
| 624 |
+
...(prev.savedReplies || []),
|
| 625 |
+
{ id: generateId(), topic: newReplyTopic.trim(), answer: newReplyAnswer.trim() }
|
| 626 |
+
]
|
| 627 |
+
}));
|
| 628 |
+
setNewReplyTopic('');
|
| 629 |
+
setNewReplyAnswer('');
|
| 630 |
+
showNotification("Reply added to knowledge base. Save all admin changes to finalize.", "info");
|
| 631 |
+
};
|
| 632 |
+
|
| 633 |
+
const handleDeleteReply = (replyId: string) => {
|
| 634 |
+
setLocalAdminConfig(prev => ({
|
| 635 |
+
...prev,
|
| 636 |
+
savedReplies: (prev.savedReplies || []).filter(reply => reply.id !== replyId)
|
| 637 |
+
}));
|
| 638 |
+
showNotification("Reply removed from knowledge base. Save all admin changes to finalize.", "info");
|
| 639 |
+
};
|
| 640 |
+
|
| 641 |
+
|
| 642 |
+
const renderAssetImageEditor = (
|
| 643 |
+
editorAssetKey: string,
|
| 644 |
+
displayName: string,
|
| 645 |
+
itemType: 'nft' | 'character' | 'adminConfigAsset',
|
| 646 |
+
idOrConfigKey: string,
|
| 647 |
+
currentImageUrl: string | undefined
|
| 648 |
+
) => {
|
| 649 |
+
const assetSpecificPrompt = aiPrompts[editorAssetKey] || '';
|
| 650 |
+
const isAssetGenerating = isGenerating[editorAssetKey] || false;
|
| 651 |
+
const assetAiPreview = aiGeneratedImagePreviews[editorAssetKey] || null;
|
| 652 |
+
const character = itemType === 'character' ? runtimeGameCharacters[idOrConfigKey] : null;
|
| 653 |
+
|
| 654 |
+
let assetUrlInputValue = '';
|
| 655 |
+
if (itemType === 'adminConfigAsset') {
|
| 656 |
+
assetUrlInputValue = adminAssetUrlInputs[idOrConfigKey as AdminConfigImageKey] || '';
|
| 657 |
+
} else if (itemType === 'nft') {
|
| 658 |
+
assetUrlInputValue = nftImageInputs[idOrConfigKey] || '';
|
| 659 |
+
} else if (itemType === 'character') {
|
| 660 |
+
assetUrlInputValue = characterAttributeInputs[idOrConfigKey]?.imageURL || '';
|
| 661 |
+
}
|
| 662 |
+
|
| 663 |
+
|
| 664 |
+
return (
|
| 665 |
+
<div className="mb-8 p-4 border rounded-lg bg-theme-dark/40 border-theme-border/40">
|
| 666 |
+
<h5 className="text-lg font-medium text-gray-100 mb-3">{displayName}</h5>
|
| 667 |
+
<img
|
| 668 |
+
src={currentImageUrl || 'https://via.placeholder.com/150?text=No+Image'}
|
| 669 |
+
alt={`${displayName} current preview`}
|
| 670 |
+
className="w-32 h-32 object-contain rounded-md bg-black/40 border border-theme-border/60 mb-3"
|
| 671 |
+
onError={(e) => (e.currentTarget.src = 'https://via.placeholder.com/150?text=Error')}
|
| 672 |
+
/>
|
| 673 |
+
<div className="mb-3">
|
| 674 |
+
<label htmlFor={`ai-prompt-${editorAssetKey}`} className="block text-sm font-medium text-gray-300 mb-1">AI Prompt:</label>
|
| 675 |
+
<textarea id={`ai-prompt-${editorAssetKey}`} rows={3} value={assetSpecificPrompt} onChange={(e) => handleAiPromptChange(editorAssetKey, e.target.value)} placeholder="Describe the image or click Auto-Generate..." className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/>
|
| 676 |
+
<div className="flex flex-wrap gap-2 mt-1.5">
|
| 677 |
+
{itemType === 'character' && character && (
|
| 678 |
+
<button
|
| 679 |
+
onClick={() => handleAutoGenerateForCharacter(idOrConfigKey)}
|
| 680 |
+
disabled={isAssetGenerating}
|
| 681 |
+
className="py-2 px-4 bg-teal-600 hover:bg-teal-700 text-white text-sm font-semibold rounded-md transition-colors disabled:bg-teal-400 flex items-center"
|
| 682 |
+
title={`Auto-generate prompt & image for ${character.name}`}
|
| 683 |
+
>
|
| 684 |
+
✨ Auto-Generate Art
|
| 685 |
+
</button>
|
| 686 |
+
)}
|
| 687 |
+
<button onClick={() => handleGenerateAiImage(editorAssetKey, itemType, idOrConfigKey)} disabled={isAssetGenerating} className="py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white text-sm font-semibold rounded-md transition-colors disabled:bg-pink-400 disabled:text-gray-100 flex items-center">
|
| 688 |
+
{isAssetGenerating && <div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"></div>}Generate AI Image
|
| 689 |
+
</button>
|
| 690 |
+
</div>
|
| 691 |
+
{assetAiPreview && (
|
| 692 |
+
<div className="mt-2">
|
| 693 |
+
<img src={assetAiPreview} alt="AI Preview" className="w-28 h-28 object-contain rounded-md bg-black/40 border border-theme-border/60"/>
|
| 694 |
+
{!superAiAgentEnabled && (
|
| 695 |
+
<button onClick={() => handleApplyAiImage(editorAssetKey, itemType, idOrConfigKey)} className="mt-1.5 py-1.5 px-3 bg-pink-600 hover:bg-pink-700 text-white text-sm font-semibold rounded-md transition-colors">Apply AI Image</button>
|
| 696 |
+
)}
|
| 697 |
+
</div>
|
| 698 |
+
)}
|
| 699 |
+
</div>
|
| 700 |
+
<div className="mb-3">
|
| 701 |
+
<label htmlFor={`file-${editorAssetKey}`} className="block text-sm font-medium text-gray-300 mb-1">Upload Image:</label>
|
| 702 |
+
<input type="file" id={`file-${editorAssetKey}`} accept="image/*,image/gif"
|
| 703 |
+
onChange={(e) => {
|
| 704 |
+
const file = e.target.files ? e.target.files[0] : null;
|
| 705 |
+
if (itemType === 'nft') handleNftImageFileUpload(idOrConfigKey, file);
|
| 706 |
+
else if (itemType === 'character') handleCharacterImageFileUpload(idOrConfigKey, file);
|
| 707 |
+
else if (itemType === 'adminConfigAsset') handleAdminConfigAssetFileUpload(idOrConfigKey as AdminConfigImageKey, file);
|
| 708 |
+
}}
|
| 709 |
+
className="w-full text-base text-gray-300 file:mr-3 file:py-2 file:px-4 file:rounded-md file:border-0 file:text-sm file:font-semibold file:bg-pink-600 file:text-white hover:file:bg-pink-700"/>
|
| 710 |
+
</div>
|
| 711 |
+
<div className="mb-3">
|
| 712 |
+
<label htmlFor={`url-${editorAssetKey}`} className="block text-sm font-medium text-gray-300 mb-1">Image Source URL or Local Path:</label>
|
| 713 |
+
<input type="text" id={`url-${editorAssetKey}`} value={assetUrlInputValue}
|
| 714 |
+
onChange={(e) => {
|
| 715 |
+
if (itemType === 'nft') handleNftImageUrlChange(idOrConfigKey, e.target.value);
|
| 716 |
+
else if (itemType === 'character') handleCharacterAttributeChange(idOrConfigKey, 'imageURL', e.target.value);
|
| 717 |
+
else if (itemType === 'adminConfigAsset') handleAdminConfigAssetUrlChange(idOrConfigKey as AdminConfigImageKey, e.target.value);
|
| 718 |
+
}}
|
| 719 |
+
className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500" placeholder="Enter image URL or local path"/>
|
| 720 |
+
<p className="text-xs text-gray-400 mt-1">Enter URL (https://...), local path (e.g., /assets/images/image.png), or upload a file.</p>
|
| 721 |
+
</div>
|
| 722 |
+
{(itemType === 'nft' || itemType === 'character' || itemType === 'adminConfigAsset') && (
|
| 723 |
+
<button
|
| 724 |
+
onClick={() => {
|
| 725 |
+
if (itemType === 'nft') handleApplyCustomNftImage(idOrConfigKey);
|
| 726 |
+
else if (itemType === 'character') handleApplyCustomCharacterImage(idOrConfigKey);
|
| 727 |
+
else if (itemType === 'adminConfigAsset') handleApplyCustomAdminConfigAssetImage(idOrConfigKey as AdminConfigImageKey);
|
| 728 |
+
}}
|
| 729 |
+
className="mt-1.5 py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white text-sm font-semibold rounded-md transition-colors"
|
| 730 |
+
>
|
| 731 |
+
Apply Custom Image (Upload/URL)
|
| 732 |
+
</button>
|
| 733 |
+
)}
|
| 734 |
+
</div>
|
| 735 |
+
);
|
| 736 |
+
};
|
| 737 |
+
|
| 738 |
+
|
| 739 |
+
const renderNftManagement = () => (
|
| 740 |
+
<div className="space-y-6 max-h-[60vh] overflow-y-auto pr-3">
|
| 741 |
+
<h3 className="text-xl font-semibold text-theme-accent mb-4">Manage NFT Images</h3>
|
| 742 |
+
{Object.values(runtimeNftDefinitions).map(nft => (
|
| 743 |
+
renderAssetImageEditor(`nft_${nft.id}`, nft.name, 'nft', nft.id, pendingNftImageChanges[nft.id] || nftImageInputs[nft.id] || nft.image)
|
| 744 |
+
))}
|
| 745 |
+
</div>
|
| 746 |
+
);
|
| 747 |
+
|
| 748 |
+
const renderCharacterManagement = () => (
|
| 749 |
+
<div className="space-y-6 max-h-[60vh] overflow-y-auto pr-3">
|
| 750 |
+
<h3 className="text-xl font-semibold text-theme-accent mb-4">Manage Game Characters</h3>
|
| 751 |
+
{Object.keys(runtimeGameCharacters).map(charId => {
|
| 752 |
+
const character = runtimeGameCharacters[charId];
|
| 753 |
+
const currentInputs = characterAttributeInputs[charId] || { name: '', imageURL: '', defaultWeaponType: WeaponType.StandardBullet, autoFireHealthThreshold: 0, baseHealth: 100, baseSpeed: 1 };
|
| 754 |
+
const charEditorKey = `char_${charId}`;
|
| 755 |
+
let currentImageDisplay = character.image;
|
| 756 |
+
if (currentInputs.imageBase64) currentImageDisplay = currentInputs.imageBase64;
|
| 757 |
+
else if (currentInputs.imageURL) currentImageDisplay = currentInputs.imageURL;
|
| 758 |
+
|
| 759 |
+
return (
|
| 760 |
+
<div key={character.id} className="p-4 bg-theme-dark/60 rounded-lg border border-theme-border/60">
|
| 761 |
+
<h4 className="text-lg font-medium text-gray-100 mb-3">{currentInputs.name || character.name} <span className="text-sm text-gray-400">({character.type})</span></h4>
|
| 762 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 items-start mb-3">
|
| 763 |
+
<div><label htmlFor={`charName-${charId}`} className="block text-sm font-medium text-gray-300 mb-1">Name:</label><input type="text" id={`charName-${charId}`} value={currentInputs.name} onChange={(e) => handleCharacterAttributeChange(charId, 'name', e.target.value)} className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div>
|
| 764 |
+
<div><label htmlFor={`charBaseHealth-${charId}`} className="block text-sm font-medium text-gray-300 mb-1">Base Health:</label><input type="number" id={`charBaseHealth-${charId}`} value={currentInputs.baseHealth} onChange={(e) => handleCharacterAttributeChange(charId, 'baseHealth', parseInt(e.target.value, 10))} className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div>
|
| 765 |
+
{character.type === 'enemy' && (
|
| 766 |
+
<div><label htmlFor={`charBaseSpeed-${charId}`} className="block text-sm font-medium text-gray-300 mb-1">Base Speed:</label><input type="number" step="0.1" id={`charBaseSpeed-${charId}`} value={currentInputs.baseSpeed} onChange={(e) => handleCharacterAttributeChange(charId, 'baseSpeed', parseFloat(e.target.value))} className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div>
|
| 767 |
+
)}
|
| 768 |
+
{character.type === 'player' && (
|
| 769 |
+
<>
|
| 770 |
+
<div><label htmlFor={`charWeaponType-${charId}`} className="block text-sm font-medium text-gray-300 mb-1">Weapon:</label><select id={`charWeaponType-${charId}`} value={Object.keys(WeaponType).find(key => WeaponType[key as keyof typeof WeaponType] === currentInputs.defaultWeaponType) || 'StandardBullet'} onChange={(e) => handleCharacterSelectChange(charId, 'defaultWeaponType', e.target.value)} className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500">{Object.keys(WeaponType).map(weaponKey => (<option key={weaponKey} value={weaponKey}>{formatWeaponTypeName(weaponKey)}</option>))}</select></div>
|
| 771 |
+
<div><label htmlFor={`charAutoFire-${charId}`} className="block text-sm font-medium text-gray-300 mb-1">Auto-Fire Threshold (%):</label><input type="number" id={`charAutoFire-${charId}`} value={currentInputs.autoFireHealthThreshold || 0} min="0" max="100" onChange={(e) => handleCharacterAttributeChange(charId, 'autoFireHealthThreshold', parseInt(e.target.value, 10))} className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div>
|
| 772 |
+
</>
|
| 773 |
+
)}
|
| 774 |
+
</div>
|
| 775 |
+
<button onClick={() => handleApplyCharacterNonImageChanges(charId)} className="mt-2 py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white text-sm font-semibold rounded-md transition-colors">Apply Attribute Changes</button>
|
| 776 |
+
|
| 777 |
+
{renderAssetImageEditor(
|
| 778 |
+
charEditorKey,
|
| 779 |
+
`Image for ${currentInputs.name || character.name}`,
|
| 780 |
+
'character',
|
| 781 |
+
charId,
|
| 782 |
+
currentImageDisplay
|
| 783 |
+
)}
|
| 784 |
+
</div>
|
| 785 |
+
)
|
| 786 |
+
})}
|
| 787 |
+
</div>
|
| 788 |
+
);
|
| 789 |
+
|
| 790 |
+
const renderStakingRates = () => (
|
| 791 |
+
<div className="space-y-6">
|
| 792 |
+
<h3 className="text-xl font-semibold text-theme-accent mb-4">Staking Configuration</h3>
|
| 793 |
+
<div><label htmlFor="globalNftStakingRate" className="block text-base font-medium text-gray-200 mb-1.5">Global NFT Staking Reward Rate (Sea Tokens per NFT per hour):</label><input type="number" id="globalNftStakingRate" name="globalNftStakingRate" value={localAdminConfig.globalNftStakingRate} onChange={handleInputChange} step="0.01" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/><p className="text-sm text-gray-400 mt-1.5">Note: Slot staking uses its own base rate. This may apply to other/future staking.</p></div>
|
| 794 |
+
<div><label htmlFor="globalTokenStakingRate" className="block text-base font-medium text-gray-200 mb-1.5">Sea Token Staking Reward Rate (per hour, e.g., 0.05 for 5%):</label><input type="number" id="globalTokenStakingRate" name="globalTokenStakingRate" value={localAdminConfig.globalTokenStakingRate} onChange={handleInputChange} step="0.001" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div>
|
| 795 |
+
</div>
|
| 796 |
+
);
|
| 797 |
+
|
| 798 |
+
const renderGameAssets = () => (
|
| 799 |
+
<div className="space-y-8 max-h-[60vh] overflow-y-auto pr-3">
|
| 800 |
+
<p className="text-base text-gray-300">Asset images are now primarily managed in the "🤖 AI Img Gen" tab. This tab shows current settings and allows specific URL overrides if needed. Ensure images exist at the specified paths if using local assets (e.g., /assets/images/your-file.png).</p>
|
| 801 |
+
|
| 802 |
+
{(['gameBackgroundImageUrl', 'defaultEnemyImageUrl', 'seaTokenImageUrl', 'redArrowImageUrl', 'flameParticleImageUrl', 'enemyBurningEffectImageUrl', 'fireballProjectileImageUrl'] as AdminConfigImageKey[]).map(configKey => {
|
| 803 |
+
const displayName = configKey
|
| 804 |
+
.replace('Url', '')
|
| 805 |
+
.replace('Image', '')
|
| 806 |
+
.replace(/([A-Z])/g, ' $1')
|
| 807 |
+
.replace(/^./, str => str.toUpperCase())
|
| 808 |
+
.trim();
|
| 809 |
+
|
| 810 |
+
return (
|
| 811 |
+
<div key={configKey} className="p-4 bg-theme-dark/60 rounded-lg border border-theme-border/60">
|
| 812 |
+
<h4 className="text-lg font-medium text-gray-100 mb-2">{displayName}</h4>
|
| 813 |
+
<img
|
| 814 |
+
src={pendingAdminAssetBase64[configKey] || adminAssetUrlInputs[configKey] || localAdminConfig[configKey] || 'https://via.placeholder.com/150?text=No+Image'}
|
| 815 |
+
alt={`${displayName} Preview`}
|
| 816 |
+
className="w-32 h-32 object-contain rounded-md bg-black/40 border border-theme-border/60 mb-2"
|
| 817 |
+
onError={(e) => (e.currentTarget.src = 'https://via.placeholder.com/150?text=Error')}
|
| 818 |
+
/>
|
| 819 |
+
<div>
|
| 820 |
+
<label htmlFor={`asset-url-${configKey}`} className="block text-sm font-medium text-gray-300 mb-1">Image Source URL or Local Path:</label>
|
| 821 |
+
<input
|
| 822 |
+
type="text"
|
| 823 |
+
id={`asset-url-${configKey}`}
|
| 824 |
+
value={adminAssetUrlInputs[configKey] || ''}
|
| 825 |
+
onChange={(e) => handleAdminConfigAssetUrlChange(configKey, e.target.value)}
|
| 826 |
+
className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"
|
| 827 |
+
placeholder="Enter image URL or local path"
|
| 828 |
+
/>
|
| 829 |
+
<p className="text-xs text-gray-400 mt-1">Enter URL (https://...), local path (e.g., /assets/images/image.png), or upload via AI Gen tab.</p>
|
| 830 |
+
</div>
|
| 831 |
+
</div>
|
| 832 |
+
);
|
| 833 |
+
})}
|
| 834 |
+
|
| 835 |
+
<div>
|
| 836 |
+
<h3 className="text-xl font-semibold text-theme-accent mb-3">Decorative Fish & Bubbles</h3>
|
| 837 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 838 |
+
{(['decorativeFishGoldenImage', 'decorativeFishStarImage', 'decorativeFishBluefinImage', 'bubbleSmallImage', 'bubbleMediumImage', 'bubbleLargeImage'] as AdminConfigImageKey[]).map(key => (
|
| 839 |
+
<div key={key} className="p-4 bg-theme-dark/60 rounded-lg border border-theme-border/60">
|
| 840 |
+
<h4 className="text-lg font-medium text-gray-100 mb-2">{key.replace(/([A-Z])/g, ' $1').replace('Image','').trim()}:</h4>
|
| 841 |
+
<img src={pendingAdminAssetBase64[key] || adminAssetUrlInputs[key] || localAdminConfig[key] || `https://via.placeholder.com/100?text=${key.substring(0,10)}`} alt={`${key} preview`} className="w-24 h-24 object-contain rounded-lg bg-black/40 border border-theme-border/60 mb-2" onError={(e) => (e.currentTarget.src = 'https://via.placeholder.com/100?text=Error')}/>
|
| 842 |
+
<div>
|
| 843 |
+
<label htmlFor={`asset-url-${key}`} className="block text-sm font-medium text-gray-300 mb-1">Image Source URL or Local Path:</label>
|
| 844 |
+
<input
|
| 845 |
+
type="text"
|
| 846 |
+
id={`asset-url-${key}`}
|
| 847 |
+
value={adminAssetUrlInputs[key] || ''}
|
| 848 |
+
onChange={(e) => handleAdminConfigAssetUrlChange(key, e.target.value)}
|
| 849 |
+
className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"
|
| 850 |
+
placeholder="Enter image URL or local path"
|
| 851 |
+
/>
|
| 852 |
+
<p className="text-xs text-gray-400 mt-1">Enter URL (https://...), local path (e.g., /assets/images/image.png), or upload via AI Gen tab.</p>
|
| 853 |
+
</div>
|
| 854 |
+
</div>
|
| 855 |
+
))}
|
| 856 |
+
</div>
|
| 857 |
+
</div>
|
| 858 |
+
</div>
|
| 859 |
+
);
|
| 860 |
+
|
| 861 |
+
const renderDogeEscapeAssets = () => (
|
| 862 |
+
<div className="space-y-8 max-h-[60vh] overflow-y-auto pr-3">
|
| 863 |
+
<h3 className="text-xl font-semibold text-theme-accent mb-4">Doge Whale Escape Game Assets</h3>
|
| 864 |
+
<p className="text-sm text-gray-300 mb-6">Customize the images used in the Doge Whale Escape mini-game. Use the AI Generator, upload files, or provide URLs.</p>
|
| 865 |
+
{dogeEscapeAssetConfigKeys.map(({ key, displayName, defaultUrl }) =>
|
| 866 |
+
renderAssetImageEditor(
|
| 867 |
+
`adminConfig_${key}`,
|
| 868 |
+
displayName,
|
| 869 |
+
'adminConfigAsset',
|
| 870 |
+
key,
|
| 871 |
+
(pendingAdminAssetBase64[key] as string | undefined) || (adminAssetUrlInputs[key] as string | undefined) || (localAdminConfig[key] as string | undefined) || defaultUrl
|
| 872 |
+
)
|
| 873 |
+
)}
|
| 874 |
+
</div>
|
| 875 |
+
);
|
| 876 |
+
|
| 877 |
+
|
| 878 |
+
const renderGameplayMechanics = () => (
|
| 879 |
+
<div className="space-y-6 max-h-[60vh] overflow-y-auto pr-3">
|
| 880 |
+
<h3 className="text-xl font-semibold text-theme-accent mb-4">Core Gameplay Mechanics</h3>
|
| 881 |
+
<div className="flex items-center justify-between p-4 bg-theme-dark/60 rounded-lg"><label htmlFor="playerAutoFire" className="text-base font-medium text-gray-200">Player Auto-Fire (for relevant weapons):</label><input type="checkbox" id="playerAutoFire" name="playerAutoFire" checked={localAdminConfig.playerAutoFire} onChange={handleInputChange} className="form-checkbox h-6 w-6 text-pink-600 bg-gray-700 border-gray-600 rounded focus:ring-pink-500"/></div>
|
| 882 |
+
<div className="flex items-center justify-between p-4 bg-theme-dark/60 rounded-lg"><label htmlFor="playerAutoTargets" className="text-base font-medium text-gray-200">Player Aim Assist (auto-targets nearest enemy):</label><input type="checkbox" id="playerAutoTargets" name="playerAutoTargets" checked={localAdminConfig.playerAutoTargets || false} onChange={handleInputChange} className="form-checkbox h-6 w-6 text-pink-600 bg-gray-700 border-gray-600 rounded focus:ring-pink-500"/></div>
|
| 883 |
+
<div className="flex items-center justify-between p-4 bg-theme-dark/60 rounded-lg"><label htmlFor="enemiesAutoTarget" className="text-base font-medium text-gray-200">Enemies Auto-Target Player:</label><input type="checkbox" id="enemiesAutoTarget" name="enemiesAutoTarget" checked={localAdminConfig.enemiesAutoTarget} onChange={handleInputChange} className="form-checkbox h-6 w-6 text-pink-600 bg-gray-700 border-gray-600 rounded focus:ring-pink-500"/></div>
|
| 884 |
+
<div className="p-4 bg-theme-dark/60 rounded-lg">
|
| 885 |
+
<label htmlFor="enemyFireCooldownMs" className="block text-base font-medium text-gray-200 mb-1.5">Enemy Fire Cooldown (ms):</label>
|
| 886 |
+
<input type="number" id="enemyFireCooldownMs" name="enemyFireCooldownMs" value={localAdminConfig.enemyFireCooldownMs} onChange={handleInputChange} step="100" min="100" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/>
|
| 887 |
+
<p className="text-sm text-gray-400 mt-1.5">Default: 2200ms. Lower is faster.</p>
|
| 888 |
+
</div>
|
| 889 |
+
|
| 890 |
+
<h3 className="text-xl font-semibold text-theme-accent mt-6 mb-4">Red Arrow Defense System</h3>
|
| 891 |
+
<div className="flex items-center justify-between p-4 bg-theme-dark/60 rounded-lg"><label htmlFor="redArrowActive" className="text-base font-medium text-gray-200">Enable Red Arrow System (for Guardian Whale):</label><input type="checkbox" id="redArrowActive" name="redArrowActive" checked={localAdminConfig.redArrowActive} onChange={handleInputChange} className="form-checkbox h-6 w-6 text-pink-600 bg-gray-700 border-gray-600 rounded focus:ring-pink-500"/></div>
|
| 892 |
+
<div className="p-4 bg-theme-dark/60 rounded-lg">
|
| 893 |
+
<label htmlFor="redArrowCooldownMs" className="block text-base font-medium text-gray-200 mb-1.5">Red Arrow Cooldown (ms per shot):</label>
|
| 894 |
+
<input type="number" id="redArrowCooldownMs" name="redArrowCooldownMs" value={localAdminConfig.redArrowCooldownMs} onChange={handleInputChange} step="50" min="50" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/>
|
| 895 |
+
<p className="text-sm text-gray-400 mt-1.5">Default: {COOLDOWN_RED_ARROW_DEFENSE}ms (2 shots per second).</p>
|
| 896 |
+
</div>
|
| 897 |
+
<div className="p-4 bg-theme-dark/60 rounded-lg">
|
| 898 |
+
<label htmlFor="redArrowShotCount" className="block text-base font-medium text-gray-200 mb-1.5">Red Arrows Fired Per Burst:</label>
|
| 899 |
+
<input type="number" id="redArrowShotCount" name="redArrowShotCount" value={localAdminConfig.redArrowShotCount || RED_ARROW_DEFAULT_SHOT_COUNT} onChange={handleInputChange} step="1" min="1" max="10" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/>
|
| 900 |
+
<p className="text-sm text-gray-400 mt-1.5">Default: {RED_ARROW_DEFAULT_SHOT_COUNT}. Number of arrows fired at once by Guardian Whale.</p>
|
| 901 |
+
</div>
|
| 902 |
+
<div className="p-4 bg-theme-dark/60 rounded-lg">
|
| 903 |
+
<label htmlFor="redArrowProjectileSpeed" className="block text-base font-medium text-gray-200 mb-1.5">Red Arrow Projectile Speed:</label>
|
| 904 |
+
<input type="number" id="redArrowProjectileSpeed" name="redArrowProjectileSpeed" value={localAdminConfig.redArrowProjectileSpeed} onChange={handleInputChange} step="0.5" min="1" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/>
|
| 905 |
+
<p className="text-sm text-gray-400 mt-1.5">Default: {RED_ARROW_PROJECTILE_SPEED}.</p>
|
| 906 |
+
</div>
|
| 907 |
+
<div className="p-4 bg-theme-dark/60 rounded-lg">
|
| 908 |
+
<label htmlFor="redArrowDamage" className="block text-base font-medium text-gray-200 mb-1.5">Red Arrow Damage:</label>
|
| 909 |
+
<input type="number" id="redArrowDamage" name="redArrowDamage" value={localAdminConfig.redArrowDamage} onChange={handleInputChange} step="10" min="0" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/>
|
| 910 |
+
<p className="text-sm text-gray-400 mt-1.5">Default: {RED_ARROW_PROJECTILE_DAMAGE} (High damage for intercept/kill effect).</p>
|
| 911 |
+
</div>
|
| 912 |
+
</div>
|
| 913 |
+
);
|
| 914 |
+
|
| 915 |
+
|
| 916 |
+
const renderAiAssetGenerator = () => (
|
| 917 |
+
<div className="space-y-8 max-h-[60vh] overflow-y-auto pr-3">
|
| 918 |
+
<div className="p-5 bg-theme-dark/80 rounded-xl shadow-xl mb-6">
|
| 919 |
+
<h4 className="text-lg font-semibold text-theme-accent mb-4">AI Generation Global Suggestions</h4>
|
| 920 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 921 |
+
<div><label htmlFor="aiDimensions" className="block text-sm font-medium text-gray-300 mb-1">Dimensions/Aspect:</label><input type="text" id="aiDimensions" value={aiSuggestionDimensions} onChange={e => setAiSuggestionDimensions(e.target.value)} placeholder="e.g., 512x512, wide, portrait" className="w-full p-2.5 text-base rounded-md bg-theme-dark border-theme-border focus:ring-pink-500 focus:border-pink-500"/></div>
|
| 922 |
+
<div><label htmlFor="aiDirection" className="block text-sm font-medium text-gray-300 mb-1">Facing Direction:</label><select id="aiDirection" value={aiSuggestionDirection} onChange={e => setAiSuggestionDirection(e.target.value)} className="w-full p-2.5 text-base rounded-md bg-theme-dark border-theme-border focus:ring-pink-500 focus:border-pink-500"><option value="any">Any</option><option value="facing right">Facing Right</option><option value="facing left">Facing Left</option><option value="front view">Front View</option><option value="side view">Side View</option></select></div>
|
| 923 |
+
<div><label htmlFor="aiStyle" className="block text-sm font-medium text-gray-300 mb-1">Art Style:</label><select id="aiStyle" value={aiSuggestionStyle} onChange={e => setAiSuggestionStyle(e.target.value)} className="w-full p-2.5 text-base rounded-md bg-theme-dark border-theme-border focus:ring-pink-500 focus:border-pink-500"><option value="any">Any</option><option value="3D render">3D Render</option><option value="photorealistic">Photorealistic</option><option value="cartoon">Cartoon</option><option value="pixel art">Pixel Art</option><option value="epic fantasy">Epic Fantasy</option><option value="sci-fi">Sci-Fi</option><option value="watercolor">Watercolor</option><option value="abstract">Abstract</option></select></div>
|
| 924 |
+
<div><label htmlFor="aiBackground" className="block text-sm font-medium text-gray-300 mb-1">Background Type:</label><select id="aiBackground" value={aiSuggestionBackground} onChange={e => setAiSuggestionBackground(e.target.value)} className="w-full p-2.5 text-base rounded-md bg-theme-dark border-theme-border focus:ring-pink-500 focus:border-pink-500"><option value="any">Any</option><option value="transparent background">Transparent</option><option value="simple color background">Simple Color</option><option value="detailed environment">Detailed Environment</option><option value="nebula">Nebula</option><option value="underwater scene">Underwater Scene</option></select></div>
|
| 925 |
+
</div>
|
| 926 |
+
<div className="flex items-center justify-between mt-4 pt-4 border-t border-theme-border/60">
|
| 927 |
+
<label htmlFor="superAiAgentToggle" className="text-sm font-medium text-gray-200">Super AI Agent (Auto-apply generated images):</label>
|
| 928 |
+
<input type="checkbox" id="superAiAgentToggle" checked={superAiAgentEnabled} onChange={(e) => setSuperAiAgentEnabled(e.target.checked)} className="form-checkbox h-5 w-5 text-pink-600"/>
|
| 929 |
+
</div>
|
| 930 |
+
</div>
|
| 931 |
+
|
| 932 |
+
<h3 className="text-xl font-semibold text-theme-accent mt-6 mb-4">Generate & Manage Asset Images</h3>
|
| 933 |
+
<div className="p-4 bg-black/30 rounded-lg"><h4 className="text-lg font-semibold text-gray-200 mb-3 border-b border-theme-secondary/40 pb-2">NFT Images</h4>{Object.values(runtimeNftDefinitions).map(nft => renderAssetImageEditor(`nft_${nft.id}`, `NFT: ${nft.name}`, 'nft', nft.id, pendingNftImageChanges[nft.id] || nftImageInputs[nft.id] || nft.image ))}</div>
|
| 934 |
+
<div className="p-4 bg-black/30 rounded-lg"><h4 className="text-lg font-semibold text-gray-200 mb-3 border-b border-theme-secondary/40 pb-2">Character Images</h4>{Object.values(runtimeGameCharacters).map(char => renderAssetImageEditor(`char_${char.id}`, `Character: ${char.name} (${char.type})`, 'character', char.id, (characterAttributeInputs[char.id]?.imageBase64 || characterAttributeInputs[char.id]?.imageURL || char.image) ))}</div>
|
| 935 |
+
<div className="p-4 bg-black/30 rounded-lg"><h4 className="text-lg font-semibold text-gray-200 mb-3 border-b border-theme-secondary/40 pb-2">Core Game Visuals</h4>
|
| 936 |
+
{renderAssetImageEditor('adminConfig_gameBackgroundImageUrl', 'Game Background', 'adminConfigAsset', 'gameBackgroundImageUrl', localAdminConfig.gameBackgroundImageUrl)}
|
| 937 |
+
{renderAssetImageEditor('adminConfig_defaultEnemyImageUrl', 'Default Enemy Fallback', 'adminConfigAsset', 'defaultEnemyImageUrl', localAdminConfig.defaultEnemyImageUrl)}
|
| 938 |
+
{renderAssetImageEditor('adminConfig_seaTokenImageUrl', 'Sea Token', 'adminConfigAsset', 'seaTokenImageUrl', localAdminConfig.seaTokenImageUrl)}
|
| 939 |
+
{renderAssetImageEditor('adminConfig_redArrowImageUrl', 'Red Arrow Projectile', 'adminConfigAsset', 'redArrowImageUrl', localAdminConfig.redArrowImageUrl || RED_ARROW_PROJECTILE_IMAGE_URL)}
|
| 940 |
+
</div>
|
| 941 |
+
<div className="p-4 bg-black/30 rounded-lg">
|
| 942 |
+
<h4 className="text-lg font-semibold text-gray-200 mb-3 border-b border-theme-secondary/40 pb-2">Fire Effect Visuals 🔥</h4>
|
| 943 |
+
{renderAssetImageEditor('adminConfig_flameParticleImageUrl', 'Flame Particle Effect (for Flame Cone)', 'adminConfigAsset', 'flameParticleImageUrl', localAdminConfig.flameParticleImageUrl)}
|
| 944 |
+
{renderAssetImageEditor('adminConfig_enemyBurningEffectImageUrl', 'Enemy Burning Effect (Overlay/Sprite)', 'adminConfigAsset', 'enemyBurningEffectImageUrl', localAdminConfig.enemyBurningEffectImageUrl)}
|
| 945 |
+
{renderAssetImageEditor('adminConfig_fireballProjectileImageUrl', 'Fireball Projectile Image', 'adminConfigAsset', 'fireballProjectileImageUrl', localAdminConfig.fireballProjectileImageUrl)}
|
| 946 |
+
</div>
|
| 947 |
+
<div className="p-4 bg-black/30 rounded-lg"><h4 className="text-lg font-semibold text-gray-200 mb-3 border-b border-theme-secondary/40 pb-2">Decorative Fish Visuals</h4>
|
| 948 |
+
{renderAssetImageEditor('adminConfig_decorativeFishGoldenImage', 'Golden Fish', 'adminConfigAsset', 'decorativeFishGoldenImage', localAdminConfig.decorativeFishGoldenImage)}
|
| 949 |
+
{renderAssetImageEditor('adminConfig_decorativeFishStarImage', 'Star Fish', 'adminConfigAsset', 'decorativeFishStarImage', localAdminConfig.decorativeFishStarImage)}
|
| 950 |
+
{renderAssetImageEditor('adminConfig_decorativeFishBluefinImage', 'Bluefin Fish', 'adminConfigAsset', 'decorativeFishBluefinImage', localAdminConfig.decorativeFishBluefinImage)}
|
| 951 |
+
</div>
|
| 952 |
+
<div className="p-4 bg-black/30 rounded-lg"><h4 className="text-lg font-semibold text-gray-200 mb-3 border-b border-theme-secondary/40 pb-2">Bubble Visuals</h4>
|
| 953 |
+
{renderAssetImageEditor('adminConfig_bubbleSmallImage', 'Small Bubble', 'adminConfigAsset', 'bubbleSmallImage', localAdminConfig.bubbleSmallImage)}
|
| 954 |
+
{renderAssetImageEditor('adminConfig_bubbleMediumImage', 'Medium Bubble', 'adminConfigAsset', 'bubbleMediumImage', localAdminConfig.bubbleMediumImage)}
|
| 955 |
+
{renderAssetImageEditor('adminConfig_bubbleLargeImage', 'Large Bubble', 'adminConfigAsset', 'bubbleLargeImage', localAdminConfig.bubbleLargeImage)}
|
| 956 |
+
</div>
|
| 957 |
+
</div>
|
| 958 |
+
);
|
| 959 |
+
|
| 960 |
+
const renderSoundManagement = () => (
|
| 961 |
+
<div className="space-y-6 max-h-[60vh] overflow-y-auto pr-3">
|
| 962 |
+
<h3 className="text-xl font-semibold text-theme-accent mb-3">Manage Sound Effects</h3>
|
| 963 |
+
<p className="text-sm text-gray-300 mb-5">Upload custom sound files (MP3, WAV, OGG recommended). Changes are applied with "Save All Global Admin Config Changes".</p>
|
| 964 |
+
{Object.values(SoundEvent).map(eventKey => {
|
| 965 |
+
const formattedName = formatSoundEventName(eventKey);
|
| 966 |
+
const adminSoundKeyPattern = `sound${eventKey.charAt(0).toUpperCase() + eventKey.slice(1).replace(/_([a-z])/g, (g) => g[1].toUpperCase())}Url`;
|
| 967 |
+
const adminSoundKey = adminSoundKeyPattern as SoundUrlKey;
|
| 968 |
+
const currentSoundSrc = localAdminConfig[adminSoundKey] as string | undefined;
|
| 969 |
+
const displaySrc = currentSoundSrc && currentSoundSrc.startsWith('data:audio')
|
| 970 |
+
? 'Custom Sound Loaded'
|
| 971 |
+
: (currentSoundSrc && currentSoundSrc.trim() !== '' ? currentSoundSrc.substring(0, 40) + (currentSoundSrc.length > 40 ? "..." : "") : 'No Sound Set');
|
| 972 |
+
|
| 973 |
+
return (
|
| 974 |
+
<div key={eventKey} className="p-4 bg-theme-dark/60 rounded-lg border border-theme-border/60">
|
| 975 |
+
<label htmlFor={`soundFile-${eventKey}`} className="block text-base font-medium text-gray-200 mb-1.5">
|
| 976 |
+
{formattedName}: <span className="text-sm text-gray-400 ml-1.5">({displaySrc})</span>
|
| 977 |
+
</label>
|
| 978 |
+
<input
|
| 979 |
+
type="file"
|
| 980 |
+
id={`soundFile-${eventKey}`}
|
| 981 |
+
accept="audio/*"
|
| 982 |
+
onChange={(e) => handleSoundFileUpload(eventKey, e.target.files ? e.target.files[0] : null)}
|
| 983 |
+
className="w-full text-base text-gray-300 file:mr-3 file:py-2 file:px-4 file:rounded-md file:border-0 file:font-semibold file:bg-pink-600 file:text-white hover:file:bg-pink-700"
|
| 984 |
+
/>
|
| 985 |
+
{currentSoundSrc && currentSoundSrc.startsWith('data:audio') && (
|
| 986 |
+
<audio controls src={currentSoundSrc} className="mt-2.5 w-full h-12">Preview</audio>
|
| 987 |
+
)}
|
| 988 |
+
</div>);
|
| 989 |
+
})}
|
| 990 |
+
</div>
|
| 991 |
+
);
|
| 992 |
+
|
| 993 |
+
const renderEffectsAndVisuals = () => (
|
| 994 |
+
<div className="space-y-8 max-h-[60vh] overflow-y-auto pr-3">
|
| 995 |
+
<h3 className="text-2xl font-semibold text-theme-accent mb-5">Effects & Visuals Configuration</h3>
|
| 996 |
+
<div className="p-5 bg-theme-dark/60 rounded-xl border border-theme-border/60 mb-8">
|
| 997 |
+
<h4 className="text-xl font-medium text-gray-100 mb-3">Blood Particle Effects</h4>
|
| 998 |
+
<div><label htmlFor="bloodParticleLifespanSeconds" className="block text-base font-medium text-gray-200 mb-1.5">Blood Particle Lifespan (seconds):</label><input type="number" id="bloodParticleLifespanSeconds" name="bloodParticleLifespanSeconds" value={localAdminConfig.bloodParticleLifespanSeconds} onChange={handleInputChange} step="0.1" min="0.1" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/><p className="text-sm text-gray-400 mt-1.5">Default: 4 seconds.</p></div>
|
| 999 |
+
</div>
|
| 1000 |
+
<div className="p-5 bg-theme-dark/60 rounded-xl border border-theme-border/60 mb-8">
|
| 1001 |
+
<h4 className="text-xl font-medium text-gray-100 mb-4">Rain Effect Settings</h4>
|
| 1002 |
+
<div className="flex items-center justify-between mb-4"><label htmlFor="rainEffectEnabled" className="text-base font-medium text-gray-200">Enable Rain Effect:</label><input type="checkbox" id="rainEffectEnabled" name="rainEffectEnabled" checked={localAdminConfig.rainEffectEnabled} onChange={handleInputChange} className="form-checkbox h-6 w-6 text-pink-600 bg-gray-700 border-gray-600 rounded focus:ring-pink-500"/></div>
|
| 1003 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
|
| 1004 |
+
<div><label htmlFor="rainDropSpeedMin" className="block text-base font-medium text-gray-200 mb-1.5">Raindrop Min Speed:</label><input type="number" id="rainDropSpeedMin" name="rainDropSpeedMin" value={localAdminConfig.rainDropSpeedMin} onChange={handleInputChange} step="0.1" min="0.1" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div>
|
| 1005 |
+
<div><label htmlFor="rainDropSpeedMax" className="block text-base font-medium text-gray-200 mb-1.5">Raindrop Max Speed:</label><input type="number" id="rainDropSpeedMax" name="rainDropSpeedMax" value={localAdminConfig.rainDropSpeedMax} onChange={handleInputChange} step="0.1" min="0.1" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div>
|
| 1006 |
+
<div><label htmlFor="rainDurationSeconds" className="block text-base font-medium text-gray-200 mb-1.5">Rain Duration (seconds):</label><input type="number" id="rainDurationSeconds" name="rainDurationSeconds" value={localAdminConfig.rainDurationSeconds} onChange={handleInputChange} step="1" min="1" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div>
|
| 1007 |
+
<div><label htmlFor="rainIntervalSeconds" className="block text-base font-medium text-gray-200 mb-1.5">Rain Interval (seconds):</label><input type="number" id="rainIntervalSeconds" name="rainIntervalSeconds" value={localAdminConfig.rainIntervalSeconds} onChange={handleInputChange} step="1" min="1" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div>
|
| 1008 |
+
<div className="md:col-span-2"><label htmlFor="rainIntensity" className="block text-base font-medium text-gray-200 mb-1.5">Rain Intensity (number of drops):</label><input type="number" id="rainIntensity" name="rainIntensity" value={localAdminConfig.rainIntensity} onChange={handleInputChange} step="10" min="10" className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/></div>
|
| 1009 |
+
</div>
|
| 1010 |
+
<p className="text-sm text-gray-400 mt-4">Rain cycles on and off based on duration and interval.</p>
|
| 1011 |
+
</div>
|
| 1012 |
+
</div>
|
| 1013 |
+
);
|
| 1014 |
+
|
| 1015 |
+
const renderAdminAiChat = () => (
|
| 1016 |
+
<div className="flex flex-col h-[70vh] max-h-[75vh]">
|
| 1017 |
+
<div className="mb-4 flex items-center gap-4 p-3 bg-theme-dark/60 rounded-lg border border-theme-border/50">
|
| 1018 |
+
<label htmlFor="aiLanguageSelect" className="text-sm font-medium text-gray-200">Response Language:</label>
|
| 1019 |
+
<select
|
| 1020 |
+
id="aiLanguageSelect"
|
| 1021 |
+
value={selectedAiLanguage}
|
| 1022 |
+
onChange={(e) => setSelectedAiLanguage(e.target.value)}
|
| 1023 |
+
className="p-2.5 text-sm rounded-md bg-theme-dark border-theme-border text-white focus:ring-pink-500 focus:border-pink-500 flex-grow"
|
| 1024 |
+
aria-label="Select AI response language"
|
| 1025 |
+
>
|
| 1026 |
+
{languageOptions.map(lang => <option key={lang.code} value={lang.code}>{lang.name}</option>)}
|
| 1027 |
+
</select>
|
| 1028 |
+
</div>
|
| 1029 |
+
<div
|
| 1030 |
+
ref={aiChatLogRef}
|
| 1031 |
+
className="flex-grow overflow-y-auto p-4 rounded-lg border border-theme-border/30 mb-4 text-sm space-y-3 bg-black/20 shadow-inner"
|
| 1032 |
+
aria-live="polite"
|
| 1033 |
+
style={{minHeight: '200px'}}
|
| 1034 |
+
>
|
| 1035 |
+
{aiChatMessages.map((msg) => (
|
| 1036 |
+
<div key={msg.id} className={`chat-message ${msg.sender === 'user' ? 'text-right' : 'text-left'}`}>
|
| 1037 |
+
<span className={`relative inline-block p-3 rounded-xl max-w-[85%] shadow-md ${
|
| 1038 |
+
msg.sender === 'user' ? 'bg-pink-600 text-white' :
|
| 1039 |
+
msg.sender === 'ai' ? 'bg-blue-600 text-white' :
|
| 1040 |
+
'bg-red-600 text-white' // Error
|
| 1041 |
+
}`}>
|
| 1042 |
+
<span className="block text-xs font-semibold text-gray-300/90 mb-1">
|
| 1043 |
+
{msg.sender === 'user' ? `You (${msg.lang || 'Unknown'})` : msg.sender === 'ai' ? 'Admin AI' : 'Error'}
|
| 1044 |
+
</span>
|
| 1045 |
+
{msg.text.split('\n').map((line, idx) => <p key={idx} className="m-0 leading-relaxed">{line}</p>)}
|
| 1046 |
+
</span>
|
| 1047 |
+
</div>
|
| 1048 |
+
))}
|
| 1049 |
+
{aiChatMessages.length === 0 && <p className="text-gray-400 text-center italic p-4">Admin AI is ready. Ask about game configuration, best practices, or technical aspects.</p>}
|
| 1050 |
+
{isAiResponding && (
|
| 1051 |
+
<div className="text-left">
|
| 1052 |
+
<span className="relative inline-block p-3 rounded-xl max-w-[85%] shadow-md bg-blue-500 text-white">
|
| 1053 |
+
<span className="block text-xs font-semibold text-gray-200/90 mb-1">Admin AI typing...</span>
|
| 1054 |
+
<div className="flex items-center space-x-1.5">
|
| 1055 |
+
<div className="w-2.5 h-2.5 bg-pink-300 rounded-full animate-pulse" style={{animationDelay: '0s'}}></div>
|
| 1056 |
+
<div className="w-2.5 h-2.5 bg-pink-300 rounded-full animate-pulse" style={{animationDelay: '0.2s'}}></div>
|
| 1057 |
+
<div className="w-2.5 h-2.5 bg-pink-300 rounded-full animate-pulse" style={{animationDelay: '0.4s'}}></div>
|
| 1058 |
+
</div>
|
| 1059 |
+
</span>
|
| 1060 |
+
</div>
|
| 1061 |
+
)}
|
| 1062 |
+
</div>
|
| 1063 |
+
<div className="flex gap-3 items-center mt-auto">
|
| 1064 |
+
<input
|
| 1065 |
+
type="text"
|
| 1066 |
+
value={currentAiQuery}
|
| 1067 |
+
onChange={(e) => setCurrentAiQuery(e.target.value)}
|
| 1068 |
+
onKeyPress={(e) => e.key === 'Enter' && handleSendAiQuery()}
|
| 1069 |
+
placeholder="Ask the Admin AI..."
|
| 1070 |
+
className="flex-grow p-3 text-base rounded-lg bg-theme-dark/70 border-2 border-theme-border/60 text-white focus:ring-2 focus:ring-pink-500 focus:border-transparent transition-all placeholder-gray-400"
|
| 1071 |
+
aria-label="Admin AI chat message input"
|
| 1072 |
+
disabled={isAiResponding}
|
| 1073 |
+
/>
|
| 1074 |
+
<button
|
| 1075 |
+
onClick={handleSendAiQuery}
|
| 1076 |
+
className="py-3 px-6 bg-pink-600 hover:bg-pink-700 text-white font-semibold rounded-lg shadow-md transition-all duration-200 transform hover:scale-105 disabled:opacity-60 disabled:hover:scale-100"
|
| 1077 |
+
disabled={isAiResponding || currentAiQuery.trim() === ''}
|
| 1078 |
+
>
|
| 1079 |
+
{isAiResponding ? 'Sending...' : 'Send'}
|
| 1080 |
+
</button>
|
| 1081 |
+
</div>
|
| 1082 |
+
</div>
|
| 1083 |
+
);
|
| 1084 |
+
|
| 1085 |
+
const renderKnowledgeBaseManagement = () => (
|
| 1086 |
+
<div className="space-y-6 max-h-[60vh] overflow-y-auto pr-3">
|
| 1087 |
+
<h3 className="text-xl font-semibold text-theme-accent mb-4">Manage AI Knowledge Base</h3>
|
| 1088 |
+
<p className="text-sm text-gray-300 mb-4">Provide predefined topics and answers to enhance the AI's responses in both Player Profile Chat and Admin AI Chat. This helps the AI provide consistent and accurate information.</p>
|
| 1089 |
+
|
| 1090 |
+
<div className="p-4 bg-theme-dark/60 rounded-lg border border-theme-border/60">
|
| 1091 |
+
<h4 className="text-lg font-medium text-gray-100 mb-3">Add New Reply</h4>
|
| 1092 |
+
<div className="mb-3">
|
| 1093 |
+
<label htmlFor="newReplyTopic" className="block text-sm font-medium text-gray-300 mb-1">Topic/Question:</label>
|
| 1094 |
+
<input type="text" id="newReplyTopic" value={newReplyTopic} onChange={(e) => setNewReplyTopic(e.target.value)} placeholder="e.g., How to get Sea Tokens?" className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/>
|
| 1095 |
+
</div>
|
| 1096 |
+
<div className="mb-3">
|
| 1097 |
+
<label htmlFor="newReplyAnswer" className="block text-sm font-medium text-gray-300 mb-1">Answer/Information:</label>
|
| 1098 |
+
<textarea id="newReplyAnswer" value={newReplyAnswer} onChange={(e) => setNewReplyAnswer(e.target.value)} placeholder="e.g., You can get Sea Tokens by defeating enemies..." rows={3} className="w-full p-2.5 rounded-md bg-theme-dark border border-theme-border text-white text-base focus:ring-pink-500 focus:border-pink-500"/>
|
| 1099 |
+
</div>
|
| 1100 |
+
<button onClick={handleAddReply} className="py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white text-sm font-semibold rounded-md transition-colors">Add Reply</button>
|
| 1101 |
+
</div>
|
| 1102 |
+
|
| 1103 |
+
<div className="mt-6">
|
| 1104 |
+
<h4 className="text-lg font-medium text-gray-100 mb-3">Existing Replies:</h4>
|
| 1105 |
+
{(localAdminConfig.savedReplies && localAdminConfig.savedReplies.length > 0) ? (
|
| 1106 |
+
<ul className="space-y-3">
|
| 1107 |
+
{localAdminConfig.savedReplies.map(reply => (
|
| 1108 |
+
<li key={reply.id} className="p-3 bg-theme-dark/40 rounded-md border border-theme-border/40">
|
| 1109 |
+
<p className="font-semibold text-gray-200 text-sm">Topic: <span className="font-normal text-gray-300">{reply.topic}</span></p>
|
| 1110 |
+
<p className="font-semibold text-gray-200 text-sm mt-1">Answer: <span className="font-normal text-gray-300">{reply.answer}</span></p>
|
| 1111 |
+
<button onClick={() => handleDeleteReply(reply.id)} className="mt-2 py-1 px-3 bg-red-600 hover:bg-red-700 text-white text-xs font-semibold rounded-md transition-colors">Delete</button>
|
| 1112 |
+
</li>
|
| 1113 |
+
))}
|
| 1114 |
+
</ul>
|
| 1115 |
+
) : (
|
| 1116 |
+
<p className="text-gray-400">No saved replies yet.</p>
|
| 1117 |
+
)}
|
| 1118 |
+
</div>
|
| 1119 |
+
</div>
|
| 1120 |
+
);
|
| 1121 |
+
|
| 1122 |
+
|
| 1123 |
+
const tabs: { id: AdminTab, label: string, content: () => React.ReactNode, icon?: string }[] = [
|
| 1124 |
+
{ id: 'aiChat', label: 'Admin AI Chat', content: renderAdminAiChat, icon: '💬' },
|
| 1125 |
+
{ id: 'knowledgeBase', label: 'Knowledge Base', content: renderKnowledgeBaseManagement, icon: '📚' },
|
| 1126 |
+
{ id: 'aiGenerator', label: 'AI Img Gen', content: renderAiAssetGenerator, icon: '🤖' },
|
| 1127 |
+
{ id: 'dogeEscapeAssets', label: '🐾 Doge Escape Assets', content: renderDogeEscapeAssets, icon: '🐾' },
|
| 1128 |
+
{ id: 'nfts', label: 'NFTs (Legacy)', content: renderNftManagement, icon: '🖼️' },
|
| 1129 |
+
{ id: 'characters', label: 'Characters (Legacy)', content: renderCharacterManagement, icon: '👾' },
|
| 1130 |
+
{ id: 'assets', label: 'Assets (Legacy)', content: renderGameAssets, icon: '🎨'},
|
| 1131 |
+
{ id: 'gameplay', label: 'Gameplay', content: renderGameplayMechanics, icon: '⚙️' },
|
| 1132 |
+
{ id: 'sounds', label: 'Sounds', content: renderSoundManagement, icon: '🔊' },
|
| 1133 |
+
{ id: 'effectsVisuals', label: 'Visual Effects', content: renderEffectsAndVisuals, icon: '✨' },
|
| 1134 |
+
{ id: 'staking', label: 'Staking Rates', content: renderStakingRates, icon: '💰' },
|
| 1135 |
+
];
|
| 1136 |
+
|
| 1137 |
+
return (
|
| 1138 |
+
<Modal
|
| 1139 |
+
isOpen={isOpen}
|
| 1140 |
+
onClose={onClose}
|
| 1141 |
+
title="🛠️ Doge Whale - Admin Super Panel"
|
| 1142 |
+
size={isMaximized ? "xl" : "xl"} // Always use XL unless maximized which handles its own sizing
|
| 1143 |
+
isMaximizable={isMaximizable}
|
| 1144 |
+
isMaximized={isMaximized}
|
| 1145 |
+
onToggleMaximize={onToggleMaximize}
|
| 1146 |
+
>
|
| 1147 |
+
<div className="flex flex-col md:flex-row gap-4 md:gap-6">
|
| 1148 |
+
<div className={`md:w-1/4 space-y-2 ${isMaximized ? 'md:w-1/5' : ''}`}>
|
| 1149 |
+
{tabs.map(tab => (
|
| 1150 |
+
<button
|
| 1151 |
+
key={tab.id}
|
| 1152 |
+
onClick={() => setActiveTab(tab.id)}
|
| 1153 |
+
className={`w-full text-left py-2.5 px-4 text-sm font-medium rounded-md transition-colors duration-200 flex items-center gap-2
|
| 1154 |
+
${activeTab === tab.id ? 'bg-pink-700 text-white shadow-lg' : 'bg-pink-500 hover:bg-pink-600 text-white'}`}
|
| 1155 |
+
aria-pressed={activeTab === tab.id}
|
| 1156 |
+
>
|
| 1157 |
+
{tab.icon && <span className="text-lg">{tab.icon}</span>}
|
| 1158 |
+
{tab.label}
|
| 1159 |
+
</button>
|
| 1160 |
+
))}
|
| 1161 |
+
</div>
|
| 1162 |
+
<div className={`md:w-3/4 ${isMaximized ? 'md:w-4/5' : ''} bg-theme-primary/20 p-4 rounded-lg shadow-inner`}>
|
| 1163 |
+
{tabs.find(tab => tab.id === activeTab)?.content()}
|
| 1164 |
+
</div>
|
| 1165 |
+
</div>
|
| 1166 |
+
<div className="mt-6 pt-4 border-t border-theme-border flex flex-col sm:flex-row justify-between items-center gap-3">
|
| 1167 |
+
<button
|
| 1168 |
+
onClick={handleSaveAllAdminChanges}
|
| 1169 |
+
className="w-full sm:w-auto order-1 sm:order-1 py-3 px-6 bg-pink-600 hover:bg-pink-700 text-white font-bold rounded-lg transition-colors duration-200 shadow-md transform hover:scale-102"
|
| 1170 |
+
>
|
| 1171 |
+
Save All Global Admin Config Changes
|
| 1172 |
+
</button>
|
| 1173 |
+
<button
|
| 1174 |
+
onClick={onLogout}
|
| 1175 |
+
className="w-full sm:w-auto order-2 sm:order-2 py-3 px-6 bg-red-600 hover:bg-red-700 text-white font-bold rounded-lg transition-colors duration-200 shadow-md transform hover:scale-102"
|
| 1176 |
+
>
|
| 1177 |
+
Logout Admin
|
| 1178 |
+
</button>
|
| 1179 |
+
</div>
|
| 1180 |
+
</Modal>
|
| 1181 |
+
);
|
| 1182 |
+
};
|
App.tsx
ADDED
|
@@ -0,0 +1,854 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
| 3 |
+
import { GameState, PlayerData, ModalType, NotificationMessage, NFT, Rarity, AdminConfig, NFTCoinEffect, GameCharacter, BreedingResult, WeaponType, SeaTokenItem } from './types.ts';
|
| 4 |
+
import { INITIAL_PLAYER_DATA as ORIGINAL_INITIAL_PLAYER_DATA, NFT_DEFINITIONS as ORIGINAL_NFT_DEFINITIONS, GAME_CHARACTERS as ORIGINAL_GAME_CHARACTERS, LEVEL_XP_THRESHOLDS, NFT_BUBBLE_SPAWN_INTERVAL_MS, NFT_BUBBLE_SPAWN_CHANCE, TOKEN_VALUE_ON_COLLECT, INITIAL_ADMIN_CONFIG, BREEDING_COMBINATIONS, BREEDING_TIME_SECONDS, MAX_STAKING_SLOTS, BASE_STAKING_REWARD_PER_MINUTE, INITIAL_MINING_POWER, MINING_POWER_UPGRADE_COST_BASE, MINING_POWER_UPGRADE_COST_FACTOR, DOGE_PROJECTILE_IMAGE_URL, BONE_PROJECTILE_IMAGE_URL } from './constants.ts';
|
| 5 |
+
import { NftMarketplaceModal } from './components/modals/NftMarketplaceModal.tsx';
|
| 6 |
+
import { WalletModal } from './components/modals/WalletModal.tsx';
|
| 7 |
+
import { PlayerProfileModal } from './components/modals/PlayerProfileModal.tsx';
|
| 8 |
+
import { NftStakingModal } from './components/modals/NftStakingModal.tsx';
|
| 9 |
+
import { AdminLoginModal } from './components/modals/AdminLoginModal.tsx'; // Ensure .tsx extension
|
| 10 |
+
import { AdminPanelModal } from './components/modals/AdminPanelModal.tsx';
|
| 11 |
+
import { WhaleCombatGuideModal } from './components/modals/WhaleCombatGuideModal.tsx';
|
| 12 |
+
import { CombatWhaleGameModal } from './components/modals/combatwhalegame.tsx'; // Asset Loading Modal
|
| 13 |
+
import { SpinToWinModal } from './components/modals/SpinToWinModal.tsx'; // Import the new modal
|
| 14 |
+
import { DogeWhaleEscapeModal } from './components/modals/DogeWhaleEscapeModal.tsx'; // Import Doge Whale Escape Modal
|
| 15 |
+
import { GameCanvas } from './components/GameCanvas.tsx';
|
| 16 |
+
import { NotificationHost } from './components/NotificationToast.tsx';
|
| 17 |
+
import { generateId } from './utils.ts';
|
| 18 |
+
import { playSound, SoundEvent, setGlobalVolume, toggleGlobalMute, setGlobalMute, getGlobalVolume, isGlobalMuted as getGlobalMuteState } from './sounds.ts';
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
const DEFAULT_PLAYER_CHAR_ID_FOR_APP = 'classic_doge_whale';
|
| 22 |
+
|
| 23 |
+
const createNewInitialPlayerData = (): PlayerData => {
|
| 24 |
+
const charId = Object.keys(ORIGINAL_GAME_CHARACTERS).includes(DEFAULT_PLAYER_CHAR_ID_FOR_APP)
|
| 25 |
+
? DEFAULT_PLAYER_CHAR_ID_FOR_APP
|
| 26 |
+
: Object.keys(ORIGINAL_GAME_CHARACTERS).find(id => ORIGINAL_GAME_CHARACTERS[id].type === 'player') || 'classic_doge_whale';
|
| 27 |
+
|
| 28 |
+
const baseChar = ORIGINAL_GAME_CHARACTERS[charId];
|
| 29 |
+
return {
|
| 30 |
+
...ORIGINAL_INITIAL_PLAYER_DATA,
|
| 31 |
+
id: generateId(),
|
| 32 |
+
characterId: charId,
|
| 33 |
+
health: baseChar?.baseHealth || ORIGINAL_INITIAL_PLAYER_DATA.maxHealth,
|
| 34 |
+
maxHealth: baseChar?.baseHealth || ORIGINAL_INITIAL_PLAYER_DATA.maxHealth,
|
| 35 |
+
seaTokens: ORIGINAL_INITIAL_PLAYER_DATA.seaTokens,
|
| 36 |
+
stakedNftSlots: Array(MAX_STAKING_SLOTS).fill(null),
|
| 37 |
+
nftMiningPowers: {},
|
| 38 |
+
};
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
const App: React.FC = () => {
|
| 43 |
+
const [playerData, setPlayerData] = useState<PlayerData>(() => {
|
| 44 |
+
const savedPlayerData = localStorage.getItem('playerData');
|
| 45 |
+
if (savedPlayerData) {
|
| 46 |
+
try {
|
| 47 |
+
const parsedData = JSON.parse(savedPlayerData) as PlayerData;
|
| 48 |
+
if (parsedData &&
|
| 49 |
+
typeof parsedData.name === 'string' &&
|
| 50 |
+
typeof parsedData.characterId === 'string' &&
|
| 51 |
+
typeof parsedData.seaTokens === 'number' &&
|
| 52 |
+
Array.isArray(parsedData.nftInventory) &&
|
| 53 |
+
Array.isArray(parsedData.stakedNftSlots) &&
|
| 54 |
+
typeof parsedData.nftMiningPowers === 'object'
|
| 55 |
+
) {
|
| 56 |
+
// Ensure stakedNftSlots has the correct length if it was saved with a different MAX_STAKING_SLOTS
|
| 57 |
+
if (parsedData.stakedNftSlots.length !== MAX_STAKING_SLOTS) {
|
| 58 |
+
const newSlots = Array(MAX_STAKING_SLOTS).fill(null);
|
| 59 |
+
for (let i = 0; i < Math.min(parsedData.stakedNftSlots.length, MAX_STAKING_SLOTS); i++) {
|
| 60 |
+
newSlots[i] = parsedData.stakedNftSlots[i];
|
| 61 |
+
}
|
| 62 |
+
parsedData.stakedNftSlots = newSlots;
|
| 63 |
+
}
|
| 64 |
+
// 'savedReplies' is part of AdminConfig, not PlayerData.
|
| 65 |
+
// Ensure it's handled during AdminConfig loading.
|
| 66 |
+
return parsedData;
|
| 67 |
+
} else {
|
| 68 |
+
console.warn("Saved playerData has invalid structure, falling back to default.");
|
| 69 |
+
}
|
| 70 |
+
} catch (e) {
|
| 71 |
+
console.error("Failed to parse saved playerData from localStorage, falling back to default.", e);
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
return createNewInitialPlayerData();
|
| 75 |
+
});
|
| 76 |
+
|
| 77 |
+
const [runtimeNftDefinitions, setRuntimeNftDefinitions] = useState<Record<string, NFT>>(() => {
|
| 78 |
+
const savedNfts = localStorage.getItem('runtimeNftDefinitions');
|
| 79 |
+
if (savedNfts) {
|
| 80 |
+
try { return JSON.parse(savedNfts); }
|
| 81 |
+
catch (e) { console.error("Failed to parse saved NFTs from localStorage", e); }
|
| 82 |
+
}
|
| 83 |
+
return JSON.parse(JSON.stringify(ORIGINAL_NFT_DEFINITIONS));
|
| 84 |
+
});
|
| 85 |
+
|
| 86 |
+
const [runtimeGameCharacters, setRuntimeGameCharacters] = useState<Record<string, GameCharacter>>(() => {
|
| 87 |
+
const savedChars = localStorage.getItem('runtimeGameCharacters');
|
| 88 |
+
if (savedChars) {
|
| 89 |
+
try { return JSON.parse(savedChars); }
|
| 90 |
+
catch (e) { console.error("Failed to parse saved Game Characters from localStorage", e); }
|
| 91 |
+
}
|
| 92 |
+
return JSON.parse(JSON.stringify(ORIGINAL_GAME_CHARACTERS));
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
const [adminConfig, setAdminConfig] = useState<AdminConfig>(() => {
|
| 96 |
+
const savedConfig = localStorage.getItem('adminConfig');
|
| 97 |
+
if (savedConfig) {
|
| 98 |
+
try {
|
| 99 |
+
const parsedConfig = JSON.parse(savedConfig) as AdminConfig;
|
| 100 |
+
// Ensure savedReplies is an array
|
| 101 |
+
if (parsedConfig.hasOwnProperty('savedReplies') && !Array.isArray(parsedConfig.savedReplies)) {
|
| 102 |
+
parsedConfig.savedReplies = [];
|
| 103 |
+
} else if (!parsedConfig.hasOwnProperty('savedReplies')) {
|
| 104 |
+
parsedConfig.savedReplies = [];
|
| 105 |
+
}
|
| 106 |
+
return parsedConfig;
|
| 107 |
+
}
|
| 108 |
+
catch (e) { console.error("Failed to parse saved Admin Config from localStorage", e); }
|
| 109 |
+
}
|
| 110 |
+
const defaultConfig = JSON.parse(JSON.stringify(INITIAL_ADMIN_CONFIG));
|
| 111 |
+
if (!defaultConfig.savedReplies) {
|
| 112 |
+
defaultConfig.savedReplies = [];
|
| 113 |
+
}
|
| 114 |
+
return defaultConfig;
|
| 115 |
+
});
|
| 116 |
+
|
| 117 |
+
const [masterVolume, setMasterVolumeState] = useState<number>(() => {
|
| 118 |
+
const savedVolume = localStorage.getItem('masterVolume');
|
| 119 |
+
if (savedVolume !== null) {
|
| 120 |
+
const vol = parseFloat(savedVolume);
|
| 121 |
+
if (!isNaN(vol) && vol >= 0 && vol <= 1) return vol;
|
| 122 |
+
}
|
| 123 |
+
return 0.5; // Default volume
|
| 124 |
+
});
|
| 125 |
+
|
| 126 |
+
const [isAppMuted, setIsAppMutedState] = useState<boolean>(() => {
|
| 127 |
+
const savedMuteState = localStorage.getItem('isAppMuted');
|
| 128 |
+
if (savedMuteState !== null) return JSON.parse(savedMuteState);
|
| 129 |
+
return false; // Default not muted
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
// Persist states to localStorage on change
|
| 133 |
+
useEffect(() => { localStorage.setItem('playerData', JSON.stringify(playerData)); }, [playerData]);
|
| 134 |
+
useEffect(() => { localStorage.setItem('runtimeNftDefinitions', JSON.stringify(runtimeNftDefinitions)); }, [runtimeNftDefinitions]);
|
| 135 |
+
useEffect(() => { localStorage.setItem('runtimeGameCharacters', JSON.stringify(runtimeGameCharacters)); }, [runtimeGameCharacters]);
|
| 136 |
+
useEffect(() => { localStorage.setItem('adminConfig', JSON.stringify(adminConfig)); }, [adminConfig]);
|
| 137 |
+
useEffect(() => { localStorage.setItem('masterVolume', masterVolume.toString()); }, [masterVolume]);
|
| 138 |
+
useEffect(() => { localStorage.setItem('isAppMuted', JSON.stringify(isAppMuted)); }, [isAppMuted]);
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
const [gameState, setGameState] = useState<GameState>(GameState.CharacterSelection);
|
| 142 |
+
const [activeModal, setActiveModal] = useState<ModalType>(null);
|
| 143 |
+
const [notifications, setNotifications] = useState<NotificationMessage[]>([]);
|
| 144 |
+
const [loadedGameAssets, setLoadedGameAssets] = useState<Record<string, HTMLImageElement | null> | null>(null);
|
| 145 |
+
const [isCanvasMaximized, setIsCanvasMaximized] = useState(false);
|
| 146 |
+
const [maximizedModalType, setMaximizedModalType] = useState<ModalType | null>(null);
|
| 147 |
+
|
| 148 |
+
const [charNameInput, setCharNameInput] = useState(playerData.name || 'Doge Adventurer');
|
| 149 |
+
const [selectedCharIdInput, setSelectedCharIdInput] = useState<string>(playerData.characterId || DEFAULT_PLAYER_CHAR_ID_FOR_APP);
|
| 150 |
+
|
| 151 |
+
// Admin Authentication State
|
| 152 |
+
const [isAdminAuthenticated, setIsAdminAuthenticated] = useState<boolean>(false);
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
useEffect(() => {
|
| 156 |
+
setGlobalVolume(masterVolume);
|
| 157 |
+
setGlobalMute(isAppMuted);
|
| 158 |
+
}, []);
|
| 159 |
+
|
| 160 |
+
const handleVolumeChange = (newVolume: number) => {
|
| 161 |
+
setGlobalVolume(newVolume);
|
| 162 |
+
setMasterVolumeState(newVolume);
|
| 163 |
+
if (newVolume > 0 && isAppMuted) {
|
| 164 |
+
handleMuteToggle();
|
| 165 |
+
}
|
| 166 |
+
};
|
| 167 |
+
|
| 168 |
+
const handleMuteToggle = () => {
|
| 169 |
+
const newMuteState = toggleGlobalMute();
|
| 170 |
+
setIsAppMutedState(newMuteState);
|
| 171 |
+
};
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
const showNotification = useCallback((message: string, type: NotificationMessage['type'] = 'info') => {
|
| 175 |
+
const newNotification: NotificationMessage = { id: generateId(), message, type };
|
| 176 |
+
setNotifications(prev => [...prev, newNotification]);
|
| 177 |
+
}, []);
|
| 178 |
+
|
| 179 |
+
const dismissNotification = useCallback((id: string) => {
|
| 180 |
+
setNotifications(prev => prev.filter(n => n.id !== id));
|
| 181 |
+
}, []);
|
| 182 |
+
|
| 183 |
+
const getActiveNftEffects = useCallback((): NFTCoinEffect | null => {
|
| 184 |
+
if (playerData.equippedNftId) {
|
| 185 |
+
const activeNFT = runtimeNftDefinitions[playerData.equippedNftId];
|
| 186 |
+
return activeNFT?.effect || null;
|
| 187 |
+
}
|
| 188 |
+
return null;
|
| 189 |
+
}, [playerData.equippedNftId, runtimeNftDefinitions]);
|
| 190 |
+
|
| 191 |
+
const handleStartGame = () => {
|
| 192 |
+
if (!charNameInput.trim()) {
|
| 193 |
+
showNotification("Please set your whale's name in your Profile!", "warning");
|
| 194 |
+
setActiveModal('playerProfile');
|
| 195 |
+
return;
|
| 196 |
+
}
|
| 197 |
+
const selectedCharacter = runtimeGameCharacters[selectedCharIdInput];
|
| 198 |
+
if (!selectedCharacter) {
|
| 199 |
+
showNotification("Invalid character selected. Please choose one in your Profile.", "error");
|
| 200 |
+
setActiveModal('playerProfile');
|
| 201 |
+
return;
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
const baseHealth = selectedCharacter.baseHealth || runtimeGameCharacters[DEFAULT_PLAYER_CHAR_ID_FOR_APP]?.baseHealth || 100;
|
| 205 |
+
setPlayerData(prev => ({ // Keep persistent data, reset game-specific stats
|
| 206 |
+
...createNewInitialPlayerData(), // Gets new ID, default level/xp/score etc.
|
| 207 |
+
name: charNameInput, // Use current input name
|
| 208 |
+
characterId: selectedCharIdInput, // Use current input character
|
| 209 |
+
whaleColor: prev.whaleColor, // Keep color
|
| 210 |
+
seaTokens: prev.seaTokens,
|
| 211 |
+
nftInventory: prev.nftInventory,
|
| 212 |
+
equippedNftId: prev.equippedNftId,
|
| 213 |
+
stakedTokens: prev.stakedTokens,
|
| 214 |
+
stakingStartTime: prev.stakingStartTime,
|
| 215 |
+
stakedNftSlots: prev.stakedNftSlots,
|
| 216 |
+
nftMiningPowers: prev.nftMiningPowers,
|
| 217 |
+
lastNftRewardClaimTime: prev.lastNftRewardClaimTime,
|
| 218 |
+
totalStakingRewards: prev.totalStakingRewards,
|
| 219 |
+
maxHealth: baseHealth, // Set based on selected character
|
| 220 |
+
health: baseHealth, // Set based on selected character
|
| 221 |
+
}));
|
| 222 |
+
|
| 223 |
+
setActiveModal('combatLoading');
|
| 224 |
+
};
|
| 225 |
+
|
| 226 |
+
const handleGameAssetsLoaded = (loadedAssets: Record<string, HTMLImageElement | null>) => {
|
| 227 |
+
setLoadedGameAssets(loadedAssets);
|
| 228 |
+
setActiveModal(null);
|
| 229 |
+
setGameState(GameState.Playing);
|
| 230 |
+
// setIsCanvasMaximized(true); // Optionally auto-maximize game on start
|
| 231 |
+
};
|
| 232 |
+
|
| 233 |
+
const handleGameOver = useCallback((finalScore: number) => {
|
| 234 |
+
showNotification(`Game Over! Your score: ${finalScore}`, 'special');
|
| 235 |
+
playSound(SoundEvent.GameOver, adminConfig);
|
| 236 |
+
setGameState(GameState.GameOver);
|
| 237 |
+
setPlayerData(prev => ({ ...prev, score: finalScore, health: 0 }));
|
| 238 |
+
setIsCanvasMaximized(false);
|
| 239 |
+
}, [showNotification, adminConfig]);
|
| 240 |
+
|
| 241 |
+
const gainXP = useCallback((amount: number) => {
|
| 242 |
+
setPlayerData(prev => {
|
| 243 |
+
let newXP = prev.xp + amount;
|
| 244 |
+
let newLevel = prev.level;
|
| 245 |
+
let newMaxXP = prev.maxXP;
|
| 246 |
+
let newHealth = prev.health;
|
| 247 |
+
let newMaxHealth = prev.maxHealth;
|
| 248 |
+
|
| 249 |
+
while (newXP >= newMaxXP && newLevel < LEVEL_XP_THRESHOLDS.length) {
|
| 250 |
+
newXP -= newMaxXP;
|
| 251 |
+
newLevel++;
|
| 252 |
+
newMaxXP = LEVEL_XP_THRESHOLDS[newLevel - 1] || newMaxXP * 1.5;
|
| 253 |
+
newMaxHealth += 10;
|
| 254 |
+
newHealth = newMaxHealth;
|
| 255 |
+
showNotification(`Level Up! You are now Level ${newLevel}! Max HP increased!`, 'success');
|
| 256 |
+
playSound(SoundEvent.LevelUp, adminConfig);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
if (newLevel >= LEVEL_XP_THRESHOLDS.length && newXP >= newMaxXP) {
|
| 260 |
+
newXP = newMaxXP;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
return { ...prev, xp: newXP, level: newLevel, maxXP: newMaxXP, health: newHealth, maxHealth: newMaxHealth };
|
| 264 |
+
});
|
| 265 |
+
}, [showNotification, adminConfig]);
|
| 266 |
+
|
| 267 |
+
const handleGainSeaTokensAndXP = useCallback((tokenValue: number, xpAmount?: number) => {
|
| 268 |
+
const activeEffects = getActiveNftEffects();
|
| 269 |
+
const effectiveTokenValue = Math.round(tokenValue * (activeEffects?.coins || 1) * (activeEffects?.allStats || 1));
|
| 270 |
+
setPlayerData(prev => ({ ...prev, seaTokens: prev.seaTokens + effectiveTokenValue }));
|
| 271 |
+
if (effectiveTokenValue > 0) {
|
| 272 |
+
playSound(SoundEvent.TokenCollect, adminConfig);
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
const baseXPGain = xpAmount !== undefined ? xpAmount : tokenValue;
|
| 276 |
+
const effectiveXPGain = Math.round(baseXPGain * (activeEffects?.xp || 1) * (activeEffects?.allStats || 1));
|
| 277 |
+
gainXP(effectiveXPGain);
|
| 278 |
+
}, [gainXP, getActiveNftEffects, adminConfig]);
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
const handleCollectNftBubble = useCallback((rarity: Rarity) => {
|
| 282 |
+
playSound(SoundEvent.NftBubbleCollect, adminConfig);
|
| 283 |
+
const potentialNfts = Object.values(runtimeNftDefinitions).filter(nft => {
|
| 284 |
+
if (rarity === Rarity.Legendary) return nft.rarity === Rarity.Legendary;
|
| 285 |
+
if (rarity === Rarity.Rare) return nft.rarity === Rarity.Rare || nft.rarity === Rarity.Legendary;
|
| 286 |
+
return true;
|
| 287 |
+
});
|
| 288 |
+
|
| 289 |
+
if (potentialNfts.length > 0 && Math.random() < 0.3) {
|
| 290 |
+
const randomNft = potentialNfts[Math.floor(Math.random() * potentialNfts.length)];
|
| 291 |
+
if (!playerData.nftInventory.includes(randomNft.id)) {
|
| 292 |
+
setPlayerData(prev => ({
|
| 293 |
+
...prev,
|
| 294 |
+
nftInventory: [...prev.nftInventory, randomNft.id]
|
| 295 |
+
}));
|
| 296 |
+
showNotification(`You found a ${randomNft.rarity} NFT: ${randomNft.name}!`, 'special');
|
| 297 |
+
} else {
|
| 298 |
+
const seaTokenBonus = rarity === Rarity.Legendary ? 100 : rarity === Rarity.Rare ? 50 : 10;
|
| 299 |
+
handleGainSeaTokensAndXP(seaTokenBonus, 0);
|
| 300 |
+
showNotification(`NFT Bubble: +${seaTokenBonus} Sea Tokens! (Already Owned ${randomNft.name})`, 'success');
|
| 301 |
+
}
|
| 302 |
+
} else {
|
| 303 |
+
const seaTokenBonus = rarity === Rarity.Legendary ? 50 : rarity === Rarity.Rare ? 25 : 5;
|
| 304 |
+
handleGainSeaTokensAndXP(seaTokenBonus, 0);
|
| 305 |
+
showNotification(`NFT Bubble: +${seaTokenBonus} Sea Tokens!`, 'info');
|
| 306 |
+
}
|
| 307 |
+
gainXP(rarity === Rarity.Legendary ? 100 : rarity === Rarity.Rare ? 50 : 20);
|
| 308 |
+
}, [playerData.nftInventory, runtimeNftDefinitions, showNotification, gainXP, handleGainSeaTokensAndXP, adminConfig]);
|
| 309 |
+
|
| 310 |
+
const handleRestartGame = () => {
|
| 311 |
+
const selectedCharacter = runtimeGameCharacters[playerData.characterId];
|
| 312 |
+
const baseHealth = selectedCharacter?.baseHealth || runtimeGameCharacters[DEFAULT_PLAYER_CHAR_ID_FOR_APP]?.baseHealth || 100;
|
| 313 |
+
|
| 314 |
+
setPlayerData(prev => ({ // Keep persistent data, reset game-specific stats
|
| 315 |
+
...createNewInitialPlayerData(), // Gets new ID, default level/xp/score etc.
|
| 316 |
+
name: prev.name, // Keep existing name
|
| 317 |
+
characterId: prev.characterId, // Keep existing character ID
|
| 318 |
+
whaleColor: prev.whaleColor, // Keep color
|
| 319 |
+
seaTokens: prev.seaTokens,
|
| 320 |
+
nftInventory: prev.nftInventory,
|
| 321 |
+
equippedNftId: prev.equippedNftId,
|
| 322 |
+
stakedTokens: prev.stakedTokens,
|
| 323 |
+
stakingStartTime: prev.stakingStartTime,
|
| 324 |
+
stakedNftSlots: prev.stakedNftSlots,
|
| 325 |
+
nftMiningPowers: prev.nftMiningPowers,
|
| 326 |
+
lastNftRewardClaimTime: prev.lastNftRewardClaimTime,
|
| 327 |
+
totalStakingRewards: prev.totalStakingRewards,
|
| 328 |
+
maxHealth: baseHealth, // Set based on selected character
|
| 329 |
+
health: baseHealth, // Set based on selected character
|
| 330 |
+
}));
|
| 331 |
+
setLoadedGameAssets(null);
|
| 332 |
+
setActiveModal('combatLoading');
|
| 333 |
+
};
|
| 334 |
+
|
| 335 |
+
const handlePurchaseNft = (nftId: string) => {
|
| 336 |
+
const nft = runtimeNftDefinitions[nftId];
|
| 337 |
+
if (!nft || nft.price <= 0) {
|
| 338 |
+
showNotification("This NFT is not for sale or is invalid.", "error");
|
| 339 |
+
return;
|
| 340 |
+
}
|
| 341 |
+
if (playerData.seaTokens >= nft.price && !playerData.nftInventory.includes(nftId)) {
|
| 342 |
+
setPlayerData(prev => ({
|
| 343 |
+
...prev,
|
| 344 |
+
seaTokens: prev.seaTokens - nft.price,
|
| 345 |
+
nftInventory: [...prev.nftInventory, nftId]
|
| 346 |
+
}));
|
| 347 |
+
playSound(SoundEvent.TokenCollect, adminConfig);
|
| 348 |
+
showNotification(`Purchased ${nft.name}!`, 'success');
|
| 349 |
+
} else if (playerData.nftInventory.includes(nftId)) {
|
| 350 |
+
showNotification(`You already own ${nft.name}.`, 'info');
|
| 351 |
+
} else {
|
| 352 |
+
showNotification("Not enough Sea Tokens to purchase.", "error");
|
| 353 |
+
}
|
| 354 |
+
};
|
| 355 |
+
|
| 356 |
+
const handleEquipNft = (nftId: string) => {
|
| 357 |
+
if (playerData.stakedNftSlots.includes(nftId)) {
|
| 358 |
+
showNotification("Cannot equip a staked NFT. Unstake it from its slot first.", "warning");
|
| 359 |
+
return;
|
| 360 |
+
}
|
| 361 |
+
if (playerData.equippedNftId === nftId) {
|
| 362 |
+
setPlayerData(prev => ({ ...prev, equippedNftId: null }));
|
| 363 |
+
showNotification(`${runtimeNftDefinitions[nftId].name} unequipped.`, 'info');
|
| 364 |
+
} else {
|
| 365 |
+
setPlayerData(prev => ({ ...prev, equippedNftId: nftId }));
|
| 366 |
+
showNotification(`${runtimeNftDefinitions[nftId].name} equipped!`, 'success');
|
| 367 |
+
}
|
| 368 |
+
};
|
| 369 |
+
|
| 370 |
+
const handleBreedNfts = (bredNftIdFromMarketplace: string) => {
|
| 371 |
+
let bredNftDefinition: NFT | null = null;
|
| 372 |
+
|
| 373 |
+
if (ORIGINAL_NFT_DEFINITIONS[bredNftIdFromMarketplace]) {
|
| 374 |
+
bredNftDefinition = { ...ORIGINAL_NFT_DEFINITIONS[bredNftIdFromMarketplace] };
|
| 375 |
+
} else {
|
| 376 |
+
let resultFromCombination: BreedingResult | null = null;
|
| 377 |
+
for (const key in BREEDING_COMBINATIONS) {
|
| 378 |
+
const potentialResult = BREEDING_COMBINATIONS[key];
|
| 379 |
+
if (potentialResult.name.toLowerCase().replace(/\s+/g, '_') === bredNftIdFromMarketplace) {
|
| 380 |
+
resultFromCombination = potentialResult;
|
| 381 |
+
break;
|
| 382 |
+
}
|
| 383 |
+
}
|
| 384 |
+
if (resultFromCombination) {
|
| 385 |
+
bredNftDefinition = {
|
| 386 |
+
id: bredNftIdFromMarketplace,
|
| 387 |
+
name: resultFromCombination.name,
|
| 388 |
+
description: resultFromCombination.description,
|
| 389 |
+
price: 0, // Bred NFTs are not sold by default
|
| 390 |
+
rarity: resultFromCombination.rarity,
|
| 391 |
+
image: resultFromCombination.image,
|
| 392 |
+
effect: resultFromCombination.effect || {},
|
| 393 |
+
};
|
| 394 |
+
}
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
if (!bredNftDefinition) {
|
| 399 |
+
const p1Name = playerData.nftInventory.length > 0 ? runtimeNftDefinitions[playerData.nftInventory[0]]?.name.substring(0,3) || "Whale1" : "Whale1";
|
| 400 |
+
const p2Name = playerData.nftInventory.length > 1 ? runtimeNftDefinitions[playerData.nftInventory[1]]?.name.substring(0,3) || "Whale2" : "Whale2";
|
| 401 |
+
bredNftDefinition = {
|
| 402 |
+
id: bredNftIdFromMarketplace,
|
| 403 |
+
name: `Hybrid ${p1Name}-${p2Name}`,
|
| 404 |
+
description: "A uniquely bred whale!",
|
| 405 |
+
price: 0,
|
| 406 |
+
rarity: Rarity.Rare,
|
| 407 |
+
image: runtimeNftDefinitions.doge_whale?.image || `${ORIGINAL_NFT_DEFINITIONS.doge_whale.image}/bredhybrid/200/150`, // Fallback
|
| 408 |
+
effect: {},
|
| 409 |
+
};
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
// Add to runtime definitions if it doesn't exist, and to player inventory
|
| 413 |
+
if (!runtimeNftDefinitions[bredNftDefinition.id]) {
|
| 414 |
+
setRuntimeNftDefinitions(prev => ({...prev, [bredNftDefinition!.id]: bredNftDefinition!}));
|
| 415 |
+
}
|
| 416 |
+
setPlayerData(prev => ({
|
| 417 |
+
...prev,
|
| 418 |
+
nftInventory: [...new Set([...prev.nftInventory, bredNftDefinition!.id])]
|
| 419 |
+
}));
|
| 420 |
+
|
| 421 |
+
showNotification(`Successfully bred: ${bredNftDefinition.name}! Added to your collection.`, 'special');
|
| 422 |
+
gainXP(50); // XP bonus for breeding
|
| 423 |
+
};
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
const handleStakeTokens = (amount: number) => {
|
| 427 |
+
if (amount > 0 && amount <= playerData.seaTokens) {
|
| 428 |
+
setPlayerData(prev => ({
|
| 429 |
+
...prev,
|
| 430 |
+
seaTokens: prev.seaTokens - amount,
|
| 431 |
+
stakedTokens: prev.stakedTokens + amount,
|
| 432 |
+
stakingStartTime: prev.stakedTokens === 0 ? Date.now() : prev.stakingStartTime // Set start time if first stake
|
| 433 |
+
}));
|
| 434 |
+
showNotification(`${amount} Sea Tokens staked!`, 'success');
|
| 435 |
+
} else {
|
| 436 |
+
showNotification("Invalid stake amount.", "error");
|
| 437 |
+
}
|
| 438 |
+
};
|
| 439 |
+
|
| 440 |
+
const handleUnstakeTokens = (amount: number) => {
|
| 441 |
+
if (amount > 0 && amount <= playerData.stakedTokens) {
|
| 442 |
+
const hoursStaked = playerData.stakingStartTime ? (Date.now() - playerData.stakingStartTime) / (1000 * 60 * 60) : 0;
|
| 443 |
+
const rewardsForUnstakedAmount = Math.floor(amount * adminConfig.globalTokenStakingRate * hoursStaked);
|
| 444 |
+
|
| 445 |
+
setPlayerData(prev => ({
|
| 446 |
+
...prev,
|
| 447 |
+
seaTokens: prev.seaTokens + amount + rewardsForUnstakedAmount,
|
| 448 |
+
stakedTokens: prev.stakedTokens - amount,
|
| 449 |
+
stakingStartTime: (prev.stakedTokens - amount) === 0 ? null : prev.stakingStartTime, // Clear if no tokens left
|
| 450 |
+
totalStakingRewards: prev.totalStakingRewards + rewardsForUnstakedAmount,
|
| 451 |
+
}));
|
| 452 |
+
showNotification(`${amount} Sea Tokens unstaked. Rewards of ${rewardsForUnstakedAmount} Sea Tokens claimed!`, 'success');
|
| 453 |
+
} else {
|
| 454 |
+
showNotification("Invalid unstake amount.", 'error');
|
| 455 |
+
}
|
| 456 |
+
};
|
| 457 |
+
|
| 458 |
+
const handleClaimTokenStakingRewards = () => {
|
| 459 |
+
if (playerData.stakingStartTime && playerData.stakedTokens > 0) {
|
| 460 |
+
const hoursStaked = (Date.now() - playerData.stakingStartTime) / (1000 * 60 * 60);
|
| 461 |
+
const rewards = Math.floor(playerData.stakedTokens * adminConfig.globalTokenStakingRate * hoursStaked);
|
| 462 |
+
if (rewards > 0) {
|
| 463 |
+
setPlayerData(prev => ({
|
| 464 |
+
...prev,
|
| 465 |
+
seaTokens: prev.seaTokens + rewards,
|
| 466 |
+
stakingStartTime: Date.now(), // Reset staking start time for future rewards
|
| 467 |
+
totalStakingRewards: prev.totalStakingRewards + rewards,
|
| 468 |
+
}));
|
| 469 |
+
showNotification(`Claimed ${rewards} Sea Tokens from staking!`, 'success');
|
| 470 |
+
} else {
|
| 471 |
+
showNotification("No Sea Token staking rewards to claim yet.", 'info');
|
| 472 |
+
}
|
| 473 |
+
} else {
|
| 474 |
+
showNotification("No Sea Tokens currently staked.", 'info');
|
| 475 |
+
}
|
| 476 |
+
};
|
| 477 |
+
|
| 478 |
+
const handleWalletTransaction = (type: 'deposit' | 'withdraw', amount: number) => {
|
| 479 |
+
// Placeholder for actual wallet integration (e.g., with a backend or crypto wallet)
|
| 480 |
+
if (type === 'deposit') {
|
| 481 |
+
showNotification(`${amount} Sea Tokens would be deposited. (Feature Coming Soon!)`, 'info');
|
| 482 |
+
// setPlayerData(prev => ({ ...prev, seaTokens: prev.seaTokens + amount }));
|
| 483 |
+
} else {
|
| 484 |
+
showNotification(`${amount} Sea Tokens would be withdrawn. (Feature Coming Soon!)`, 'info');
|
| 485 |
+
// if (playerData.seaTokens >= amount) {
|
| 486 |
+
// setPlayerData(prev => ({ ...prev, seaTokens: prev.seaTokens - amount }));
|
| 487 |
+
// } else {
|
| 488 |
+
// showNotification("Insufficient Sea Tokens for withdrawal.", "error");
|
| 489 |
+
// }
|
| 490 |
+
}
|
| 491 |
+
};
|
| 492 |
+
|
| 493 |
+
const handleStakeNftInSlot = (nftId: string, slotIndex: number) => {
|
| 494 |
+
setPlayerData(prev => {
|
| 495 |
+
const newStakedNftSlots = [...prev.stakedNftSlots];
|
| 496 |
+
const wasEquipped = prev.equippedNftId === nftId;
|
| 497 |
+
|
| 498 |
+
if (newStakedNftSlots[slotIndex] === null) {
|
| 499 |
+
newStakedNftSlots[slotIndex] = nftId;
|
| 500 |
+
showNotification(`${runtimeNftDefinitions[nftId].name} staked into Slot ${slotIndex + 1}.`, 'success');
|
| 501 |
+
const newMiningPowers = { ...prev.nftMiningPowers };
|
| 502 |
+
if (!newMiningPowers[nftId]) {
|
| 503 |
+
newMiningPowers[nftId] = INITIAL_MINING_POWER;
|
| 504 |
+
}
|
| 505 |
+
return {
|
| 506 |
+
...prev,
|
| 507 |
+
stakedNftSlots: newStakedNftSlots,
|
| 508 |
+
equippedNftId: wasEquipped ? null : prev.equippedNftId, // Unequip if it was equipped
|
| 509 |
+
nftMiningPowers: newMiningPowers,
|
| 510 |
+
lastNftRewardClaimTime: prev.stakedNftSlots.every(s => s === null) ? Date.now() : prev.lastNftRewardClaimTime, // Set if this is the first NFT staked in any slot
|
| 511 |
+
};
|
| 512 |
+
} else {
|
| 513 |
+
showNotification(`Slot ${slotIndex + 1} is already occupied.`, 'warning');
|
| 514 |
+
return prev;
|
| 515 |
+
}
|
| 516 |
+
});
|
| 517 |
+
};
|
| 518 |
+
|
| 519 |
+
const handleUnstakeNftFromSlot = (nftId: string, slotIndex: number) => {
|
| 520 |
+
setPlayerData(prev => {
|
| 521 |
+
const newStakedNftSlots = [...prev.stakedNftSlots];
|
| 522 |
+
if (newStakedNftSlots[slotIndex] === nftId) {
|
| 523 |
+
// Claim rewards before unstaking this specific NFT
|
| 524 |
+
let rewardsForThisNft = 0;
|
| 525 |
+
if (prev.lastNftRewardClaimTime) {
|
| 526 |
+
const minutesStakedGlobal = (Date.now() - prev.lastNftRewardClaimTime) / (1000 * 60);
|
| 527 |
+
const miningPower = prev.nftMiningPowers[nftId] || INITIAL_MINING_POWER;
|
| 528 |
+
rewardsForThisNft = Math.floor(minutesStakedGlobal * BASE_STAKING_REWARD_PER_MINUTE * miningPower);
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
newStakedNftSlots[slotIndex] = null;
|
| 532 |
+
showNotification(`${runtimeNftDefinitions[nftId].name} unstaked from Slot ${slotIndex + 1}. Any pending rewards for this NFT were claimed.`, 'success');
|
| 533 |
+
|
| 534 |
+
const allSlotsNowEmpty = newStakedNftSlots.every(s => s === null);
|
| 535 |
+
|
| 536 |
+
return {
|
| 537 |
+
...prev,
|
| 538 |
+
seaTokens: prev.seaTokens + (rewardsForThisNft > 0 ? rewardsForThisNft : 0),
|
| 539 |
+
totalStakingRewards: prev.totalStakingRewards + (rewardsForThisNft > 0 ? rewardsForThisNft : 0),
|
| 540 |
+
stakedNftSlots: newStakedNftSlots,
|
| 541 |
+
lastNftRewardClaimTime: allSlotsNowEmpty ? null : Date.now(), // Reset global claim time as rewards for this slot are now claimed
|
| 542 |
+
};
|
| 543 |
+
}
|
| 544 |
+
return prev;
|
| 545 |
+
});
|
| 546 |
+
};
|
| 547 |
+
|
| 548 |
+
const handleClaimNftSlotStakingRewards = () => {
|
| 549 |
+
setPlayerData(prev => {
|
| 550 |
+
if (!prev.lastNftRewardClaimTime || !prev.stakedNftSlots.some(id => id !== null)) {
|
| 551 |
+
showNotification("No NFTs staked in slots or no rewards to claim.", 'info');
|
| 552 |
+
return prev;
|
| 553 |
+
}
|
| 554 |
+
const now = Date.now();
|
| 555 |
+
const minutesStakedGlobal = (now - prev.lastNftRewardClaimTime) / (1000 * 60);
|
| 556 |
+
let totalRewardsFromAllSlots = 0;
|
| 557 |
+
|
| 558 |
+
prev.stakedNftSlots.forEach(nftId => {
|
| 559 |
+
if (nftId) {
|
| 560 |
+
const miningPower = prev.nftMiningPowers[nftId] || INITIAL_MINING_POWER;
|
| 561 |
+
totalRewardsFromAllSlots += Math.floor(minutesStakedGlobal * BASE_STAKING_REWARD_PER_MINUTE * miningPower);
|
| 562 |
+
}
|
| 563 |
+
});
|
| 564 |
+
|
| 565 |
+
if (totalRewardsFromAllSlots > 0) {
|
| 566 |
+
showNotification(`Claimed ${totalRewardsFromAllSlots.toFixed(0)} Sea Tokens from NFT slot staking!`, 'success');
|
| 567 |
+
playSound(SoundEvent.TokenCollect, adminConfig);
|
| 568 |
+
return {
|
| 569 |
+
...prev,
|
| 570 |
+
seaTokens: prev.seaTokens + totalRewardsFromAllSlots,
|
| 571 |
+
totalStakingRewards: prev.totalStakingRewards + totalRewardsFromAllSlots,
|
| 572 |
+
lastNftRewardClaimTime: now, // Reset timer for next claim period
|
| 573 |
+
};
|
| 574 |
+
} else {
|
| 575 |
+
showNotification("No NFT slot staking rewards to claim at this moment.", 'info');
|
| 576 |
+
return prev;
|
| 577 |
+
}
|
| 578 |
+
});
|
| 579 |
+
};
|
| 580 |
+
|
| 581 |
+
const handleUpgradeNftMiningPower = (nftId: string, slotIndex: number) => {
|
| 582 |
+
setPlayerData(prev => {
|
| 583 |
+
const currentPower = prev.nftMiningPowers[nftId] || INITIAL_MINING_POWER;
|
| 584 |
+
const upgradeCost = Math.floor(MINING_POWER_UPGRADE_COST_BASE * (MINING_POWER_UPGRADE_COST_FACTOR ** (currentPower - 1)));
|
| 585 |
+
|
| 586 |
+
if (prev.seaTokens < upgradeCost) {
|
| 587 |
+
showNotification("Not enough Sea Tokens to upgrade Mining Power.", "error");
|
| 588 |
+
return prev;
|
| 589 |
+
}
|
| 590 |
+
if (prev.stakedNftSlots[slotIndex] !== nftId) {
|
| 591 |
+
showNotification("NFT is not staked in the expected slot for upgrade.", "error");
|
| 592 |
+
return prev;
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
const newMiningPower = currentPower + 1;
|
| 596 |
+
showNotification(`${runtimeNftDefinitions[nftId].name}'s Mining Power upgraded to ${newMiningPower}x!`, "success");
|
| 597 |
+
playSound(SoundEvent.LevelUp, adminConfig); // Reuse level up sound
|
| 598 |
+
|
| 599 |
+
return {
|
| 600 |
+
...prev,
|
| 601 |
+
seaTokens: prev.seaTokens - upgradeCost,
|
| 602 |
+
nftMiningPowers: {
|
| 603 |
+
...prev.nftMiningPowers,
|
| 604 |
+
[nftId]: newMiningPower
|
| 605 |
+
}
|
| 606 |
+
};
|
| 607 |
+
});
|
| 608 |
+
};
|
| 609 |
+
|
| 610 |
+
const handleUpdateAdminConfig = (newConfigPartial: Partial<AdminConfig>) => {
|
| 611 |
+
setAdminConfig(prev => ({ ...prev, ...newConfigPartial }));
|
| 612 |
+
showNotification("Admin configuration updated. Changes will apply globally.", 'success');
|
| 613 |
+
};
|
| 614 |
+
const handleUpdateNftImage = (nftId: string, newImageUrl: string) => {
|
| 615 |
+
setRuntimeNftDefinitions(prev => {
|
| 616 |
+
if (prev[nftId]) {
|
| 617 |
+
return { ...prev, [nftId]: { ...prev[nftId], image: newImageUrl }};
|
| 618 |
+
}
|
| 619 |
+
return prev;
|
| 620 |
+
});
|
| 621 |
+
showNotification(`Image updated for NFT: ${runtimeNftDefinitions[nftId]?.name || nftId}.`, "success");
|
| 622 |
+
};
|
| 623 |
+
const handleUpdateCharacterAttributes = (charId: string, updates: Partial<GameCharacter>) => {
|
| 624 |
+
setRuntimeGameCharacters(prev => {
|
| 625 |
+
if (prev[charId]) {
|
| 626 |
+
const originalChar = prev[charId];
|
| 627 |
+
const updatedChar = { ...originalChar, ...updates };
|
| 628 |
+
|
| 629 |
+
// Specific logic for player base health sync if it changed
|
| 630 |
+
if (updates.baseHealth !== undefined && charId === playerData.characterId) {
|
| 631 |
+
setPlayerData(pData => ({
|
| 632 |
+
...pData,
|
| 633 |
+
maxHealth: updatedChar.baseHealth || pData.maxHealth,
|
| 634 |
+
health: Math.min(pData.health, updatedChar.baseHealth || pData.maxHealth) // Keep current health if lower, else cap at new max
|
| 635 |
+
}));
|
| 636 |
+
}
|
| 637 |
+
return { ...prev, [charId]: updatedChar };
|
| 638 |
+
}
|
| 639 |
+
return prev;
|
| 640 |
+
});
|
| 641 |
+
showNotification(`Attributes updated for character: ${runtimeGameCharacters[charId]?.name || charId}.`, "success");
|
| 642 |
+
};
|
| 643 |
+
|
| 644 |
+
const handleToggleModalMaximize = (modalToToggle: ModalType) => {
|
| 645 |
+
if (maximizedModalType === modalToToggle) {
|
| 646 |
+
setMaximizedModalType(null); // Restore
|
| 647 |
+
} else {
|
| 648 |
+
setMaximizedModalType(modalToToggle); // Maximize
|
| 649 |
+
}
|
| 650 |
+
};
|
| 651 |
+
|
| 652 |
+
const handleSpinToWinSeaTokenAdjustment = (amount: number) => {
|
| 653 |
+
setPlayerData(prev => ({...prev, seaTokens: Math.max(0, prev.seaTokens + amount)}));
|
| 654 |
+
// Notifications are handled within SpinToWinModal
|
| 655 |
+
};
|
| 656 |
+
|
| 657 |
+
// Admin Panel Access & Logout
|
| 658 |
+
const handleOpenAdminPanel = () => {
|
| 659 |
+
if (isAdminAuthenticated) {
|
| 660 |
+
setActiveModal('adminPanel');
|
| 661 |
+
} else {
|
| 662 |
+
setActiveModal('adminLogin');
|
| 663 |
+
}
|
| 664 |
+
};
|
| 665 |
+
|
| 666 |
+
const handleAdminLoginSuccess = () => {
|
| 667 |
+
setIsAdminAuthenticated(true);
|
| 668 |
+
setActiveModal('adminPanel');
|
| 669 |
+
showNotification("Admin login successful!", "success");
|
| 670 |
+
};
|
| 671 |
+
|
| 672 |
+
const handleAdminLogout = () => {
|
| 673 |
+
setIsAdminAuthenticated(false);
|
| 674 |
+
setActiveModal(null);
|
| 675 |
+
showNotification("Admin logged out.", "info");
|
| 676 |
+
};
|
| 677 |
+
|
| 678 |
+
|
| 679 |
+
const assetsToLoadForGame = useMemo(() => {
|
| 680 |
+
const assets: Record<string, string> = {};
|
| 681 |
+
if (adminConfig.gameBackgroundImageUrl) assets['background'] = adminConfig.gameBackgroundImageUrl;
|
| 682 |
+
if (adminConfig.seaTokenImageUrl) assets['seaToken'] = adminConfig.seaTokenImageUrl;
|
| 683 |
+
|
| 684 |
+
Object.values(runtimeGameCharacters).forEach(char => {
|
| 685 |
+
if (char.type === 'player' && char.image) assets[`playerChar_${char.id}`] = char.image;
|
| 686 |
+
else if (char.type === 'enemy' && char.image) assets[`enemyChar_${char.id}`] = char.image;
|
| 687 |
+
});
|
| 688 |
+
if (adminConfig.defaultEnemyImageUrl) assets['defaultEnemy'] = adminConfig.defaultEnemyImageUrl;
|
| 689 |
+
|
| 690 |
+
if (adminConfig.redArrowImageUrl) assets['redArrowImage'] = adminConfig.redArrowImageUrl;
|
| 691 |
+
|
| 692 |
+
// Add DogeLauncher projectile images
|
| 693 |
+
assets['dogeProjectile'] = DOGE_PROJECTILE_IMAGE_URL;
|
| 694 |
+
assets['boneProjectile'] = BONE_PROJECTILE_IMAGE_URL;
|
| 695 |
+
|
| 696 |
+
// Fire effect assets
|
| 697 |
+
if (adminConfig.flameParticleImageUrl) assets['flameParticle'] = adminConfig.flameParticleImageUrl;
|
| 698 |
+
if (adminConfig.enemyBurningEffectImageUrl) assets['enemyBurningEffect'] = adminConfig.enemyBurningEffectImageUrl;
|
| 699 |
+
if (adminConfig.fireballProjectileImageUrl) assets['fireballProjectile'] = adminConfig.fireballProjectileImageUrl;
|
| 700 |
+
|
| 701 |
+
|
| 702 |
+
return assets;
|
| 703 |
+
}, [adminConfig, runtimeGameCharacters]);
|
| 704 |
+
|
| 705 |
+
const navButtonBaseClasses = "flex items-center w-full text-left py-2.5 px-4 rounded-md font-semibold transition-all duration-200 shadow-md hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-opacity-75";
|
| 706 |
+
|
| 707 |
+
|
| 708 |
+
return (
|
| 709 |
+
<div className={`min-h-screen flex flex-row font-sans transition-all duration-300 ${isCanvasMaximized ? 'bg-black' : 'bg-gradient-to-b from-blue-900 via-indigo-900 to-purple-900'}`}>
|
| 710 |
+
|
| 711 |
+
{/* Left Navigation Panel (only if not maximized) */}
|
| 712 |
+
{!isCanvasMaximized && (
|
| 713 |
+
<aside className="w-64 bg-black/50 text-white p-4 shadow-2xl backdrop-blur-lg flex flex-col border-r-2 border-theme-border/30">
|
| 714 |
+
<h1 className="text-2xl font-bold text-theme-accent font-['Orbitron',_sans-serif] animate-pulseCustom mb-6 text-center">
|
| 715 |
+
Doge Whale Wars 🛸
|
| 716 |
+
</h1>
|
| 717 |
+
<nav className="flex flex-col gap-2.5 flex-grow">
|
| 718 |
+
<button onClick={() => setActiveModal('nftMarketplace')} className={`${navButtonBaseClasses} bg-pink-600 hover:bg-pink-700 text-white focus:ring-pink-400`}>
|
| 719 |
+
<i className="fas fa-store mr-3 w-5 text-center"></i>NFT Hub
|
| 720 |
+
</button>
|
| 721 |
+
<button onClick={() => setActiveModal('wallet')} className={`${navButtonBaseClasses} bg-pink-600 hover:bg-pink-700 text-white focus:ring-pink-400`}>
|
| 722 |
+
<i className="fas fa-wallet mr-3 w-5 text-center"></i>Wallet
|
| 723 |
+
</button>
|
| 724 |
+
<button onClick={() => setActiveModal('playerProfile')} className={`${navButtonBaseClasses} bg-pink-600 hover:bg-pink-700 text-white focus:ring-pink-400`}>
|
| 725 |
+
<i className="fas fa-user-astronaut mr-3 w-5 text-center"></i>Profile
|
| 726 |
+
</button>
|
| 727 |
+
<button onClick={() => setActiveModal('nftStaking')} className={`${navButtonBaseClasses} bg-pink-600 hover:bg-pink-700 text-white focus:ring-pink-400`}>
|
| 728 |
+
<i className="fas fa-gem mr-3 w-5 text-center"></i>NFT Vault
|
| 729 |
+
</button>
|
| 730 |
+
<button onClick={() => setActiveModal('spinToWin')} className={`${navButtonBaseClasses} bg-yellow-500 hover:bg-yellow-600 text-theme-dark focus:ring-yellow-300 animate-pulseCustom`}>
|
| 731 |
+
<i className="fas fa-dice mr-3 w-5 text-center"></i>Arcade
|
| 732 |
+
</button>
|
| 733 |
+
<button onClick={() => setActiveModal('whaleCombatGuide')} className={`${navButtonBaseClasses} bg-blue-600 hover:bg-blue-700 text-white focus:ring-blue-400`}>
|
| 734 |
+
<i className="fas fa-book-open mr-3 w-5 text-center"></i>Guide
|
| 735 |
+
</button>
|
| 736 |
+
<button onClick={() => setActiveModal('dogeWhaleEscape')} className={`${navButtonBaseClasses} bg-teal-500 hover:bg-teal-600 text-white focus:ring-teal-300`}>
|
| 737 |
+
<i className="fas fa-water mr-3 w-5 text-center"></i>Doge Escape
|
| 738 |
+
</button>
|
| 739 |
+
<div className="mt-auto"> {/* Pushes Admin button to the bottom */}
|
| 740 |
+
<button onClick={handleOpenAdminPanel} className={`${navButtonBaseClasses} bg-red-600 hover:bg-red-700 text-white focus:ring-red-400`}>
|
| 741 |
+
<i className="fas fa-tools mr-3 w-5 text-center"></i>Admin
|
| 742 |
+
</button>
|
| 743 |
+
</div>
|
| 744 |
+
</nav>
|
| 745 |
+
</aside>
|
| 746 |
+
)}
|
| 747 |
+
|
| 748 |
+
{/* Main Content Area (takes remaining space, contains game and footer) */}
|
| 749 |
+
<div className="flex-grow flex flex-col overflow-hidden">
|
| 750 |
+
{/* Game Content */}
|
| 751 |
+
<main className={`flex-grow flex items-center justify-center p-4 ${isCanvasMaximized ? 'p-0' : ''} transition-all duration-300`}>
|
| 752 |
+
{gameState === GameState.CharacterSelection && !isCanvasMaximized && (
|
| 753 |
+
<div className="text-center bg-theme-dark/70 p-6 sm:p-8 rounded-xl shadow-2xl backdrop-blur-md border-2 border-theme-border max-w-lg w-full animate-fadeIn">
|
| 754 |
+
<h2 className="text-3xl sm:text-4xl font-bold mb-6 text-theme-accent font-['Orbitron',_sans-serif]">Prepare for Battle!</h2>
|
| 755 |
+
<div className="mb-6">
|
| 756 |
+
<label htmlFor="playerNameInput" className="block text-md font-medium text-gray-200 mb-2">Enter Your Whale's Name:</label>
|
| 757 |
+
<input
|
| 758 |
+
id="playerNameInput"
|
| 759 |
+
type="text"
|
| 760 |
+
value={charNameInput}
|
| 761 |
+
onChange={(e) => setCharNameInput(e.target.value)}
|
| 762 |
+
placeholder="Cosmic Doge Commander"
|
| 763 |
+
className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white focus:ring-2 focus:ring-pink-500 focus:border-transparent transition-all"
|
| 764 |
+
/>
|
| 765 |
+
</div>
|
| 766 |
+
<div className="mb-8">
|
| 767 |
+
<label htmlFor="playerCharacterSelect" className="block text-md font-medium text-gray-200 mb-2">Select Your Whale Class:</label>
|
| 768 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 max-h-72 overflow-y-auto pr-1">
|
| 769 |
+
{Object.values(runtimeGameCharacters).filter(char => char.type === 'player').map(char => (
|
| 770 |
+
<button
|
| 771 |
+
key={char.id}
|
| 772 |
+
onClick={() => setSelectedCharIdInput(char.id)}
|
| 773 |
+
className={`p-3 rounded-lg border-2 transition-all duration-200 flex flex-col items-center text-left ${selectedCharIdInput === char.id ? 'bg-pink-700 border-pink-400 ring-2 ring-pink-400' : 'bg-pink-500 border-pink-600 hover:bg-pink-600'}`}
|
| 774 |
+
aria-pressed={selectedCharIdInput === char.id}
|
| 775 |
+
>
|
| 776 |
+
<img src={char.image} alt={char.name} className="w-20 h-20 object-contain rounded-md mb-2 border border-theme-border/50 bg-black/20" />
|
| 777 |
+
<span className="font-semibold text-sm text-white">{char.name}</span>
|
| 778 |
+
<span className="text-xs text-gray-300 mt-1">HP: {char.baseHealth || 100}</span>
|
| 779 |
+
<span className="text-xs text-gray-300 capitalize">Weapon: {char.defaultWeaponType?.replace(/_/g, ' ') || 'Standard'}</span>
|
| 780 |
+
{char.canOneHitKill && <span className="text-xs text-yellow-400">Special: One-Hit Kill!</span>}
|
| 781 |
+
</button>
|
| 782 |
+
))}
|
| 783 |
+
</div>
|
| 784 |
+
</div>
|
| 785 |
+
<div className="space-y-3">
|
| 786 |
+
<button onClick={handleStartGame} className="w-full py-3 px-6 bg-gradient-to-r from-pink-600 to-purple-700 hover:from-pink-700 hover:to-purple-800 text-white font-bold text-lg rounded-lg transition-all duration-300 shadow-xl hover:shadow-2xl transform hover:scale-105">
|
| 787 |
+
<i className="fas fa-rocket mr-2"></i>Launch Expedition!
|
| 788 |
+
</button>
|
| 789 |
+
<button onClick={() => setActiveModal('whaleCombatGuide')} className="w-full py-2 px-5 bg-gray-600 hover:bg-gray-700 text-white font-semibold rounded-lg transition-colors">
|
| 790 |
+
<i className="fas fa-book-open mr-2"></i>View Combat Guide
|
| 791 |
+
</button>
|
| 792 |
+
</div>
|
| 793 |
+
</div>
|
| 794 |
+
)}
|
| 795 |
+
|
| 796 |
+
{gameState === GameState.Playing && loadedGameAssets && (
|
| 797 |
+
<div className={`game-container-outer ${isCanvasMaximized ? 'w-screen h-screen' : 'max-w-full max-h-full'}`}>
|
| 798 |
+
<GameCanvas
|
| 799 |
+
playerData={playerData}
|
| 800 |
+
onGameOver={handleGameOver}
|
| 801 |
+
onGainSeaTokensAndXP={handleGainSeaTokensAndXP}
|
| 802 |
+
onCollectNftBubble={handleCollectNftBubble}
|
| 803 |
+
activeNftEffects={getActiveNftEffects()}
|
| 804 |
+
runtimeGameCharacters={runtimeGameCharacters}
|
| 805 |
+
adminConfig={adminConfig}
|
| 806 |
+
loadedGameAssets={loadedGameAssets}
|
| 807 |
+
/>
|
| 808 |
+
</div>
|
| 809 |
+
)}
|
| 810 |
+
|
| 811 |
+
{gameState === GameState.GameOver && !isCanvasMaximized && (
|
| 812 |
+
<div className="text-center bg-theme-dark/80 p-6 sm:p-10 rounded-xl shadow-2xl backdrop-blur-md border-2 border-red-500 max-w-md w-full animate-fadeIn">
|
| 813 |
+
<h2 className="text-3xl sm:text-4xl font-bold mb-4 text-red-400 font-['Orbitron',_sans-serif]">Mission Failed!</h2>
|
| 814 |
+
<p className="text-xl text-gray-200 mb-6">Your final score: <span className="font-bold text-yellow-400">{playerData.score}</span></p>
|
| 815 |
+
<img src={runtimeGameCharacters[playerData.characterId]?.image || ''} alt="Defeated Whale" className="w-32 h-32 object-contain rounded-full mx-auto mb-6 border-4 border-red-600 opacity-70" />
|
| 816 |
+
<div className="space-y-3">
|
| 817 |
+
<button onClick={handleRestartGame} className="w-full py-3 px-6 bg-gradient-to-r from-pink-600 to-purple-700 hover:from-pink-700 hover:to-purple-800 text-white font-bold text-lg rounded-lg transition-all duration-300 shadow-xl hover:shadow-2xl transform hover:scale-105">
|
| 818 |
+
<i className="fas fa-redo mr-2"></i>Try Again?
|
| 819 |
+
</button>
|
| 820 |
+
<button onClick={() => setGameState(GameState.CharacterSelection)} className="w-full py-2 px-5 bg-gray-600 hover:bg-gray-700 text-white font-semibold rounded-lg transition-colors">
|
| 821 |
+
<i className="fas fa-bars mr-2"></i>Main Menu
|
| 822 |
+
</button>
|
| 823 |
+
</div>
|
| 824 |
+
</div>
|
| 825 |
+
)}
|
| 826 |
+
</main>
|
| 827 |
+
|
| 828 |
+
{/* Footer (only if not maximized) */}
|
| 829 |
+
{!isCanvasMaximized && (
|
| 830 |
+
<footer className="bg-black/30 text-gray-400 p-2 text-center text-xs shadow-inner">
|
| 831 |
+
<p>© {new Date().getFullYear()} Doge Whale Wars - MMORPG. All rights reserved. v1.0 Stellar Surge</p>
|
| 832 |
+
</footer>
|
| 833 |
+
)}
|
| 834 |
+
</div>
|
| 835 |
+
|
| 836 |
+
{/* Modals */}
|
| 837 |
+
{activeModal === 'nftMarketplace' && <NftMarketplaceModal isOpen={true} onClose={() => setActiveModal(null)} playerData={playerData} runtimeNftDefinitions={runtimeNftDefinitions} onPurchaseNft={handlePurchaseNft} onEquipNft={handleEquipNft} onBreedNfts={handleBreedNfts} onStakeNft={handleStakeNftInSlot} onUnstakeNft={handleUnstakeNftFromSlot} />}
|
| 838 |
+
{activeModal === 'wallet' && <WalletModal isOpen={true} onClose={() => setActiveModal(null)} playerData={playerData} adminConfig={adminConfig} onStakeTokens={handleStakeTokens} onUnstakeTokens={handleUnstakeTokens} onClaimRewards={handleClaimTokenStakingRewards} onTransaction={handleWalletTransaction} />}
|
| 839 |
+
{activeModal === 'playerProfile' && <PlayerProfileModal isOpen={true} onClose={() => setActiveModal(null)} playerData={playerData} runtimeNftDefinitions={runtimeNftDefinitions} adminConfig={adminConfig} onClaimNftStakingRewards={handleClaimNftSlotStakingRewards} initialPlayerName={charNameInput} initialSelectedCharacterId={selectedCharIdInput} onUpdatePlayerName={setCharNameInput} onUpdateSelectedCharacterId={setSelectedCharIdInput} runtimeGameCharacters={runtimeGameCharacters} masterVolume={masterVolume} isAppMuted={isAppMuted} onVolumeChange={handleVolumeChange} onMuteToggle={handleMuteToggle}/>}
|
| 840 |
+
{activeModal === 'nftStaking' && <NftStakingModal isOpen={true} onClose={() => setActiveModal(null)} playerData={playerData} runtimeNftDefinitions={runtimeNftDefinitions} adminConfig={adminConfig} onStakeNftInSlot={handleStakeNftInSlot} onUnstakeNftFromSlot={handleUnstakeNftFromSlot} onClaimRewards={handleClaimNftSlotStakingRewards} onUpgradeNftMiningPower={handleUpgradeNftMiningPower}/>}
|
| 841 |
+
{activeModal === 'adminLogin' && <AdminLoginModal isOpen={true} onClose={() => setActiveModal(null)} onLoginSuccess={handleAdminLoginSuccess} />}
|
| 842 |
+
{activeModal === 'adminPanel' && isAdminAuthenticated && <AdminPanelModal isOpen={true} onClose={() => setActiveModal(null)} adminConfig={adminConfig} runtimeNftDefinitions={runtimeNftDefinitions} runtimeGameCharacters={runtimeGameCharacters} onUpdateAdminConfig={handleUpdateAdminConfig} onUpdateNftImage={handleUpdateNftImage} onUpdateCharacterAttributes={handleUpdateCharacterAttributes} showNotification={showNotification} isMaximizable={true} isMaximized={maximizedModalType === 'adminPanel'} onToggleMaximize={() => handleToggleModalMaximize('adminPanel')} onLogout={handleAdminLogout} />}
|
| 843 |
+
{activeModal === 'whaleCombatGuide' && <WhaleCombatGuideModal isOpen={true} onClose={() => setActiveModal(null)} />}
|
| 844 |
+
{activeModal === 'combatLoading' && <CombatWhaleGameModal isOpen={true} onClose={() => setActiveModal(null)} assetsToLoad={assetsToLoadForGame} onLoadingComplete={handleGameAssetsLoaded} playerName={charNameInput} playerCharacterImage={runtimeGameCharacters[selectedCharIdInput]?.image || ''} />}
|
| 845 |
+
{activeModal === 'spinToWin' && <SpinToWinModal isOpen={true} onClose={() => setActiveModal(null)} playerSeaTokens={playerData.seaTokens} onAdjustSeaTokens={handleSpinToWinSeaTokenAdjustment} showNotification={showNotification} adminConfig={adminConfig} />}
|
| 846 |
+
{activeModal === 'dogeWhaleEscape' && <DogeWhaleEscapeModal isOpen={true} onClose={() => setActiveModal(null)} adminConfig={adminConfig} />}
|
| 847 |
+
|
| 848 |
+
|
| 849 |
+
{/* Notification Host */}
|
| 850 |
+
<NotificationHost notifications={notifications} onDismiss={dismissNotification} />
|
| 851 |
+
</div>
|
| 852 |
+
);
|
| 853 |
+
};
|
| 854 |
+
export default App;
|
DogeWhaleEscapeModal.tsx
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
| 3 |
+
import { Modal } from '../Modal';
|
| 4 |
+
import { DogeWhaleEscapeLevel, MazeCellType, AdminConfig } from '../../types';
|
| 5 |
+
import {
|
| 6 |
+
DOGE_WHALE_ESCAPE_LEVELS, DOGE_ESCAPE_GRID_CELL_SIZE, INITIAL_DOGE_ESCAPE_LIVES, MAX_DOGE_ESCAPE_LIVES,
|
| 7 |
+
DOGE_WHALE_ESCAPE_PLAYER_IMAGE_URL, DOGE_WHALE_ESCAPE_MINE_IMAGE_URL, DOGE_WHALE_ESCAPE_GOAL_IMAGE_URL,
|
| 8 |
+
DOGE_WHALE_ESCAPE_WALL_IMAGE_URL,
|
| 9 |
+
DOGE_WHALE_ESCAPE_BOMB_IMAGE_URL, DOGE_WHALE_ESCAPE_JELLYFISH_IMAGE_URL, DOGE_WHALE_ESCAPE_CROCODILE_IMAGE_URL,
|
| 10 |
+
DOGE_WHALE_ESCAPE_ALGAE_IMAGE_URL, DOGE_WHALE_ESCAPE_CURRENT_N_IMAGE_URL, DOGE_WHALE_ESCAPE_CURRENT_S_IMAGE_URL,
|
| 11 |
+
DOGE_WHALE_ESCAPE_CURRENT_E_IMAGE_URL, DOGE_WHALE_ESCAPE_CURRENT_W_IMAGE_URL,
|
| 12 |
+
DOGE_WHALE_ESCAPE_SNAKE_IMAGE_URL, DOGE_WHALE_ESCAPE_LIFEUP_IMAGE_URL,
|
| 13 |
+
REVEAL_SNAKE_CHANCE, REVEAL_LIFEUP_CHANCE
|
| 14 |
+
} from '../../constants';
|
| 15 |
+
import { playSound, SoundEvent } from '../../sounds';
|
| 16 |
+
|
| 17 |
+
interface DogeWhaleEscapeModalProps {
|
| 18 |
+
isOpen: boolean;
|
| 19 |
+
onClose: () => void;
|
| 20 |
+
adminConfig: AdminConfig;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
type PlayerDirection = 'up' | 'down' | 'left' | 'right';
|
| 24 |
+
|
| 25 |
+
interface GameImageAssets {
|
| 26 |
+
whale?: HTMLImageElement;
|
| 27 |
+
wall?: HTMLImageElement;
|
| 28 |
+
mine?: HTMLImageElement;
|
| 29 |
+
goal?: HTMLImageElement;
|
| 30 |
+
bomb?: HTMLImageElement;
|
| 31 |
+
jellyfish?: HTMLImageElement;
|
| 32 |
+
crocodile?: HTMLImageElement;
|
| 33 |
+
algae?: HTMLImageElement;
|
| 34 |
+
currentN?: HTMLImageElement;
|
| 35 |
+
currentS?: HTMLImageElement;
|
| 36 |
+
currentE?: HTMLImageElement;
|
| 37 |
+
currentW?: HTMLImageElement;
|
| 38 |
+
snake?: HTMLImageElement;
|
| 39 |
+
lifeUp?: HTMLImageElement;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
interface FireProjectile {
|
| 43 |
+
startPos: { x: number; y: number }; // center of whale cell
|
| 44 |
+
targetCell: { row: number; col: number }; // target cell indices
|
| 45 |
+
targetPos: { x: number; y: number }; // center of target cell
|
| 46 |
+
progress: number; // 0 to 1
|
| 47 |
+
color: string;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
interface DestructionParticle {
|
| 51 |
+
x: number;
|
| 52 |
+
y: number;
|
| 53 |
+
dx: number;
|
| 54 |
+
dy: number;
|
| 55 |
+
life: number;
|
| 56 |
+
color: string;
|
| 57 |
+
size: number;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
const FIRE_PROJECTILE_DURATION_FRAMES = 8; // Animation duration in frames
|
| 61 |
+
const PARTICLE_MAX_LIFE = 20; // Animation duration in frames
|
| 62 |
+
const PARTICLE_COUNT = 10;
|
| 63 |
+
const WHALE_BOB_MAX_OFFSET = 2; // pixels
|
| 64 |
+
const WHALE_BOB_SPEED = 0.15; // pixels per frame
|
| 65 |
+
|
| 66 |
+
export const DogeWhaleEscapeModal: React.FC<DogeWhaleEscapeModalProps> = ({ isOpen, onClose, adminConfig }) => {
|
| 67 |
+
const [currentLevelIndex, setCurrentLevelIndex] = useState(0);
|
| 68 |
+
const [levelData, setLevelData] = useState<DogeWhaleEscapeLevel>(DOGE_WHALE_ESCAPE_LEVELS[currentLevelIndex]);
|
| 69 |
+
const [currentLayout, setCurrentLayout] = useState<MazeCellType[][]>(levelData.layout);
|
| 70 |
+
const [whalePos, setWhalePos] = useState<{ row: number; col: number }>(levelData.startPos);
|
| 71 |
+
const [playerDirection, setPlayerDirection] = useState<PlayerDirection>('right');
|
| 72 |
+
const [lives, setLives] = useState(INITIAL_DOGE_ESCAPE_LIVES);
|
| 73 |
+
const [message, setMessage] = useState(levelData.message || 'Use arrow keys to move, Spacebar to fire! Find the Treasure!');
|
| 74 |
+
const [gameOver, setGameOver] = useState(false);
|
| 75 |
+
const [gameWon, setGameWon] = useState(false);
|
| 76 |
+
const [assetsLoaded, setAssetsLoaded] = useState(false);
|
| 77 |
+
|
| 78 |
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 79 |
+
const gameAssetsRef = useRef<GameImageAssets>({});
|
| 80 |
+
const animationFrameIdRef = useRef<number>();
|
| 81 |
+
|
| 82 |
+
const [activeFireProjectile, setActiveFireProjectile] = useState<FireProjectile | null>(null);
|
| 83 |
+
const [destructionParticles, setDestructionParticles] = useState<DestructionParticle[]>([]);
|
| 84 |
+
const [whaleBobOffset, setWhaleBobOffset] = useState(0);
|
| 85 |
+
const [whaleBobDirection, setWhaleBobDirection] = useState(1); // 1 for down, -1 for up
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
const createDestructionParticles = useCallback((row: number, col: number, baseColors: string[]) => {
|
| 89 |
+
const newParticles: DestructionParticle[] = [];
|
| 90 |
+
const centerX = col * DOGE_ESCAPE_GRID_CELL_SIZE + DOGE_ESCAPE_GRID_CELL_SIZE / 2;
|
| 91 |
+
const centerY = row * DOGE_ESCAPE_GRID_CELL_SIZE + DOGE_ESCAPE_GRID_CELL_SIZE / 2;
|
| 92 |
+
|
| 93 |
+
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
| 94 |
+
newParticles.push({
|
| 95 |
+
x: centerX,
|
| 96 |
+
y: centerY,
|
| 97 |
+
dx: (Math.random() - 0.5) * 3, // Random horizontal speed
|
| 98 |
+
dy: (Math.random() - 0.5) * 3, // Random vertical speed
|
| 99 |
+
life: Math.random() * PARTICLE_MAX_LIFE * 0.5 + PARTICLE_MAX_LIFE * 0.5, // Random life
|
| 100 |
+
color: baseColors[Math.floor(Math.random() * baseColors.length)],
|
| 101 |
+
size: Math.random() * 3 + 2,
|
| 102 |
+
});
|
| 103 |
+
}
|
| 104 |
+
setDestructionParticles(prev => [...prev, ...newParticles]);
|
| 105 |
+
}, []);
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
const loadAndInitializeLevel: (levelIdxParam: number) => void = useCallback(
|
| 109 |
+
(levelIdxParam: number) => {
|
| 110 |
+
const levelToUse = levelIdxParam;
|
| 111 |
+
const newLevelData = DOGE_WHALE_ESCAPE_LEVELS[levelToUse % DOGE_WHALE_ESCAPE_LEVELS.length];
|
| 112 |
+
|
| 113 |
+
setLevelData(newLevelData);
|
| 114 |
+
setCurrentLayout(JSON.parse(JSON.stringify(newLevelData.layout)));
|
| 115 |
+
setWhalePos(newLevelData.startPos);
|
| 116 |
+
setPlayerDirection('right');
|
| 117 |
+
setLives(INITIAL_DOGE_ESCAPE_LIVES);
|
| 118 |
+
setMessage(newLevelData.message || `Use arrow keys to move, Spacebar to fire! Find the Treasure! (Level ${newLevelData.id})`);
|
| 119 |
+
setGameOver(false);
|
| 120 |
+
setGameWon(false);
|
| 121 |
+
setActiveFireProjectile(null);
|
| 122 |
+
setDestructionParticles([]);
|
| 123 |
+
setWhaleBobOffset(0);
|
| 124 |
+
setWhaleBobDirection(1);
|
| 125 |
+
},
|
| 126 |
+
[]
|
| 127 |
+
);
|
| 128 |
+
|
| 129 |
+
useEffect(() => {
|
| 130 |
+
if (isOpen) {
|
| 131 |
+
loadAndInitializeLevel(currentLevelIndex);
|
| 132 |
+
setAssetsLoaded(false);
|
| 133 |
+
|
| 134 |
+
const imagesToLoad: { key: keyof GameImageAssets; src: string }[] = [
|
| 135 |
+
{ key: 'whale', src: adminConfig.dogeEscapePlayerImageUrl || DOGE_WHALE_ESCAPE_PLAYER_IMAGE_URL },
|
| 136 |
+
{ key: 'wall', src: adminConfig.dogeEscapeWallImageUrl || DOGE_WHALE_ESCAPE_WALL_IMAGE_URL },
|
| 137 |
+
{ key: 'mine', src: adminConfig.dogeEscapeMineImageUrl || DOGE_WHALE_ESCAPE_MINE_IMAGE_URL },
|
| 138 |
+
{ key: 'goal', src: adminConfig.dogeEscapeGoalImageUrl || DOGE_WHALE_ESCAPE_GOAL_IMAGE_URL },
|
| 139 |
+
{ key: 'bomb', src: adminConfig.dogeEscapeBombImageUrl || DOGE_WHALE_ESCAPE_BOMB_IMAGE_URL },
|
| 140 |
+
{ key: 'jellyfish', src: adminConfig.dogeEscapeJellyfishImageUrl || DOGE_WHALE_ESCAPE_JELLYFISH_IMAGE_URL },
|
| 141 |
+
{ key: 'crocodile', src: adminConfig.dogeEscapeCrocodileImageUrl || DOGE_WHALE_ESCAPE_CROCODILE_IMAGE_URL },
|
| 142 |
+
{ key: 'algae', src: adminConfig.dogeEscapeAlgaeImageUrl || DOGE_WHALE_ESCAPE_ALGAE_IMAGE_URL },
|
| 143 |
+
{ key: 'currentN', src: adminConfig.dogeEscapeCurrentNImageUrl || DOGE_WHALE_ESCAPE_CURRENT_N_IMAGE_URL },
|
| 144 |
+
{ key: 'currentS', src: adminConfig.dogeEscapeCurrentSImageUrl || DOGE_WHALE_ESCAPE_CURRENT_S_IMAGE_URL },
|
| 145 |
+
{ key: 'currentE', src: adminConfig.dogeEscapeCurrentEImageUrl || DOGE_WHALE_ESCAPE_CURRENT_E_IMAGE_URL },
|
| 146 |
+
{ key: 'currentW', src: adminConfig.dogeEscapeCurrentWImageUrl || DOGE_WHALE_ESCAPE_CURRENT_W_IMAGE_URL },
|
| 147 |
+
{ key: 'snake', src: adminConfig.dogeEscapeSnakeImageUrl || DOGE_WHALE_ESCAPE_SNAKE_IMAGE_URL },
|
| 148 |
+
{ key: 'lifeUp', src: adminConfig.dogeEscapeLifeUpImageUrl || DOGE_WHALE_ESCAPE_LIFEUP_IMAGE_URL },
|
| 149 |
+
];
|
| 150 |
+
|
| 151 |
+
let loadedCount = 0;
|
| 152 |
+
const totalImages = imagesToLoad.length;
|
| 153 |
+
const tempAssets: Partial<GameImageAssets> = {};
|
| 154 |
+
|
| 155 |
+
imagesToLoad.forEach(imgData => {
|
| 156 |
+
const img = new Image();
|
| 157 |
+
img.onload = () => {
|
| 158 |
+
loadedCount++;
|
| 159 |
+
tempAssets[imgData.key] = img;
|
| 160 |
+
if (loadedCount === totalImages) {
|
| 161 |
+
gameAssetsRef.current = tempAssets as GameImageAssets;
|
| 162 |
+
setAssetsLoaded(true);
|
| 163 |
+
}
|
| 164 |
+
};
|
| 165 |
+
img.onerror = () => {
|
| 166 |
+
loadedCount++;
|
| 167 |
+
console.error(`Failed to load image: ${imgData.src}`);
|
| 168 |
+
tempAssets[imgData.key] = undefined;
|
| 169 |
+
if (loadedCount === totalImages) {
|
| 170 |
+
gameAssetsRef.current = tempAssets as GameImageAssets;
|
| 171 |
+
setAssetsLoaded(true);
|
| 172 |
+
}
|
| 173 |
+
};
|
| 174 |
+
img.src = imgData.src;
|
| 175 |
+
});
|
| 176 |
+
} else {
|
| 177 |
+
setAssetsLoaded(false);
|
| 178 |
+
}
|
| 179 |
+
}, [isOpen, currentLevelIndex, loadAndInitializeLevel, adminConfig]);
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
const drawGame = useCallback(() => {
|
| 183 |
+
const canvas = canvasRef.current;
|
| 184 |
+
const ctx = canvas?.getContext('2d');
|
| 185 |
+
if (!ctx || !currentLayout || !assetsLoaded) return;
|
| 186 |
+
|
| 187 |
+
const rows = currentLayout.length;
|
| 188 |
+
const cols = currentLayout[0].length;
|
| 189 |
+
|
| 190 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 191 |
+
ctx.font = `${DOGE_ESCAPE_GRID_CELL_SIZE * 0.6}px sans-serif`;
|
| 192 |
+
|
| 193 |
+
// Whale Bobbing Logic
|
| 194 |
+
let newWhaleBobOffset = whaleBobOffset + whaleBobDirection * WHALE_BOB_SPEED;
|
| 195 |
+
if (Math.abs(newWhaleBobOffset) > WHALE_BOB_MAX_OFFSET) {
|
| 196 |
+
newWhaleBobOffset = WHALE_BOB_MAX_OFFSET * whaleBobDirection;
|
| 197 |
+
setWhaleBobDirection(prev => -prev);
|
| 198 |
+
}
|
| 199 |
+
setWhaleBobOffset(newWhaleBobOffset);
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
for (let r = 0; r < rows; r++) {
|
| 203 |
+
for (let c = 0; c < cols; c++) {
|
| 204 |
+
const cellType = currentLayout[r][c];
|
| 205 |
+
const x = c * DOGE_ESCAPE_GRID_CELL_SIZE;
|
| 206 |
+
const y = r * DOGE_ESCAPE_GRID_CELL_SIZE;
|
| 207 |
+
let imgToDraw: HTMLImageElement | undefined;
|
| 208 |
+
|
| 209 |
+
ctx.fillStyle = '#415A77';
|
| 210 |
+
ctx.fillRect(x, y, DOGE_ESCAPE_GRID_CELL_SIZE, DOGE_ESCAPE_GRID_CELL_SIZE);
|
| 211 |
+
|
| 212 |
+
switch (cellType) {
|
| 213 |
+
case MazeCellType.Wall:
|
| 214 |
+
imgToDraw = gameAssetsRef.current.wall;
|
| 215 |
+
if (!imgToDraw) { ctx.fillStyle = '#1B263B'; ctx.fillRect(x, y, DOGE_ESCAPE_GRID_CELL_SIZE, DOGE_ESCAPE_GRID_CELL_SIZE); }
|
| 216 |
+
break;
|
| 217 |
+
case MazeCellType.Path: case MazeCellType.Start: break;
|
| 218 |
+
case MazeCellType.Mine: imgToDraw = gameAssetsRef.current.mine; if (!imgToDraw) { ctx.fillStyle = 'red'; ctx.beginPath(); ctx.arc(x + DOGE_ESCAPE_GRID_CELL_SIZE / 2, y + DOGE_ESCAPE_GRID_CELL_SIZE / 2, DOGE_ESCAPE_GRID_CELL_SIZE / 3, 0, Math.PI * 2); ctx.fill(); } break;
|
| 219 |
+
case MazeCellType.Goal: imgToDraw = gameAssetsRef.current.goal; if (!imgToDraw) { ctx.fillStyle = 'green'; ctx.fillRect(x + 5, y + 5, DOGE_ESCAPE_GRID_CELL_SIZE - 10, DOGE_ESCAPE_GRID_CELL_SIZE - 10); } break;
|
| 220 |
+
case MazeCellType.Bomb: imgToDraw = gameAssetsRef.current.bomb; if (!imgToDraw) { ctx.fillStyle = 'darkred'; ctx.beginPath(); ctx.arc(x + DOGE_ESCAPE_GRID_CELL_SIZE / 2, y + DOGE_ESCAPE_GRID_CELL_SIZE / 2, DOGE_ESCAPE_GRID_CELL_SIZE / 2.5, 0, Math.PI * 2); ctx.fill(); } break;
|
| 221 |
+
case MazeCellType.Jellyfish: imgToDraw = gameAssetsRef.current.jellyfish; if (!imgToDraw) { ctx.fillStyle = 'purple'; ctx.fillRect(x + 7, y + 7, DOGE_ESCAPE_GRID_CELL_SIZE - 14, DOGE_ESCAPE_GRID_CELL_SIZE - 14); } break;
|
| 222 |
+
case MazeCellType.Crocodile: imgToDraw = gameAssetsRef.current.crocodile; if (!imgToDraw) { ctx.fillStyle = 'darkgreen'; ctx.fillRect(x + 3, y + 10, DOGE_ESCAPE_GRID_CELL_SIZE - 6, DOGE_ESCAPE_GRID_CELL_SIZE - 20); } break;
|
| 223 |
+
case MazeCellType.ToxicAlgae: imgToDraw = gameAssetsRef.current.algae; if (!imgToDraw) { ctx.fillStyle = 'lime'; ctx.globalAlpha = 0.5; ctx.fillRect(x,y,DOGE_ESCAPE_GRID_CELL_SIZE,DOGE_ESCAPE_GRID_CELL_SIZE); ctx.globalAlpha = 1.0;} break;
|
| 224 |
+
case MazeCellType.CurrentN: imgToDraw = gameAssetsRef.current.currentN; if (!imgToDraw) { ctx.fillStyle = 'cyan'; ctx.fillText('↑', x + 10, y + 20);} break;
|
| 225 |
+
case MazeCellType.CurrentS: imgToDraw = gameAssetsRef.current.currentS; if (!imgToDraw) { ctx.fillStyle = 'cyan'; ctx.fillText('↓', x + 10, y + 20);} break;
|
| 226 |
+
case MazeCellType.CurrentE: imgToDraw = gameAssetsRef.current.currentE; if (!imgToDraw) { ctx.fillStyle = 'cyan'; ctx.fillText('→', x + 10, y + 20);} break;
|
| 227 |
+
case MazeCellType.CurrentW: imgToDraw = gameAssetsRef.current.currentW; if (!imgToDraw) { ctx.fillStyle = 'cyan'; ctx.fillText('←', x + 10, y + 20);} break;
|
| 228 |
+
case MazeCellType.Snake: imgToDraw = gameAssetsRef.current.snake; if (!imgToDraw) { ctx.fillStyle = 'darkorange'; ctx.fillText('S', x + 10, y + 20); } break;
|
| 229 |
+
case MazeCellType.LifeUp: imgToDraw = gameAssetsRef.current.lifeUp; if (!imgToDraw) { ctx.fillStyle = 'pink'; ctx.fillText('♥', x + 8, y + 22); } break;
|
| 230 |
+
}
|
| 231 |
+
if (imgToDraw) {
|
| 232 |
+
ctx.drawImage(imgToDraw, x + 2, y + 2, DOGE_ESCAPE_GRID_CELL_SIZE - 4, DOGE_ESCAPE_GRID_CELL_SIZE - 4);
|
| 233 |
+
}
|
| 234 |
+
}
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
// Draw Whale with bobbing
|
| 238 |
+
const whaleDrawY = whalePos.row * DOGE_ESCAPE_GRID_CELL_SIZE + 2 + whaleBobOffset;
|
| 239 |
+
if (gameAssetsRef.current.whale) {
|
| 240 |
+
ctx.drawImage(gameAssetsRef.current.whale, whalePos.col * DOGE_ESCAPE_GRID_CELL_SIZE + 2, whaleDrawY, DOGE_ESCAPE_GRID_CELL_SIZE - 4, DOGE_ESCAPE_GRID_CELL_SIZE - 4);
|
| 241 |
+
} else {
|
| 242 |
+
ctx.fillStyle = 'yellow';
|
| 243 |
+
ctx.fillRect(whalePos.col * DOGE_ESCAPE_GRID_CELL_SIZE + 5, whaleDrawY + 3, DOGE_ESCAPE_GRID_CELL_SIZE - 10, DOGE_ESCAPE_GRID_CELL_SIZE - 10);
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
// Draw and update fire projectile
|
| 247 |
+
if (activeFireProjectile) {
|
| 248 |
+
const fp = activeFireProjectile;
|
| 249 |
+
fp.progress += 1 / FIRE_PROJECTILE_DURATION_FRAMES;
|
| 250 |
+
|
| 251 |
+
const currentX = fp.startPos.x + (fp.targetPos.x - fp.startPos.x) * fp.progress;
|
| 252 |
+
const currentY = fp.startPos.y + (fp.targetPos.y - fp.startPos.y) * fp.progress;
|
| 253 |
+
|
| 254 |
+
ctx.fillStyle = fp.color;
|
| 255 |
+
ctx.fillRect(currentX - 3, currentY - 3, 6, 6); // Small square projectile
|
| 256 |
+
|
| 257 |
+
if (fp.progress >= 1) {
|
| 258 |
+
// Projectile reached target, now apply effect
|
| 259 |
+
const {row, col} = fp.targetCell;
|
| 260 |
+
const targetCellType = currentLayout[row][col];
|
| 261 |
+
const newLayout = currentLayout.map(r => [...r]);
|
| 262 |
+
let particleColors = ['#808080']; // Default grey
|
| 263 |
+
|
| 264 |
+
if (targetCellType === MazeCellType.Wall) {
|
| 265 |
+
playSound(SoundEvent.EnemyKill, adminConfig); // Use enemy kill for block break
|
| 266 |
+
particleColors = ['#8B4513', '#A9A9A9', '#696969']; // Brown/Grey
|
| 267 |
+
const rand = Math.random();
|
| 268 |
+
if (rand < REVEAL_SNAKE_CHANCE) {
|
| 269 |
+
newLayout[row][col] = MazeCellType.Snake;
|
| 270 |
+
setMessage("A snake was hiding in the wall!");
|
| 271 |
+
} else if (rand < REVEAL_SNAKE_CHANCE + REVEAL_LIFEUP_CHANCE) {
|
| 272 |
+
newLayout[row][col] = MazeCellType.LifeUp;
|
| 273 |
+
setMessage("You found a Life-Up Heart in the wall!");
|
| 274 |
+
} else {
|
| 275 |
+
newLayout[row][col] = MazeCellType.Path;
|
| 276 |
+
setMessage("Wall destroyed!");
|
| 277 |
+
}
|
| 278 |
+
setCurrentLayout(newLayout);
|
| 279 |
+
createDestructionParticles(row, col, particleColors);
|
| 280 |
+
} else if (targetCellType === MazeCellType.Snake) {
|
| 281 |
+
playSound(SoundEvent.EnemyKill, adminConfig);
|
| 282 |
+
particleColors = ['#228B22', '#32CD32', '#006400']; // Greens
|
| 283 |
+
newLayout[row][col] = MazeCellType.Path;
|
| 284 |
+
setCurrentLayout(newLayout);
|
| 285 |
+
setMessage("Snake neutralized!");
|
| 286 |
+
createDestructionParticles(row, col, particleColors);
|
| 287 |
+
} else if (targetCellType === MazeCellType.LifeUp) {
|
| 288 |
+
playSound(SoundEvent.EnemyKill, adminConfig); // Firing at LifeUp destroys it
|
| 289 |
+
particleColors = ['#FFC0CB', '#FF69B4', '#DB7093']; // Pinks
|
| 290 |
+
newLayout[row][col] = MazeCellType.Path;
|
| 291 |
+
setCurrentLayout(newLayout);
|
| 292 |
+
setMessage("Life-Up Heart destroyed by fire!");
|
| 293 |
+
createDestructionParticles(row, col, particleColors);
|
| 294 |
+
}
|
| 295 |
+
setActiveFireProjectile(null);
|
| 296 |
+
} else {
|
| 297 |
+
setActiveFireProjectile(fp); // Update state with new progress
|
| 298 |
+
}
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
// Draw and update destruction particles
|
| 302 |
+
setDestructionParticles(prevParticles => {
|
| 303 |
+
const updatedParticles = prevParticles.map(p => ({
|
| 304 |
+
...p,
|
| 305 |
+
x: p.x + p.dx,
|
| 306 |
+
y: p.y + p.dy,
|
| 307 |
+
life: p.life - 1,
|
| 308 |
+
})).filter(p => p.life > 0);
|
| 309 |
+
|
| 310 |
+
updatedParticles.forEach(p => {
|
| 311 |
+
ctx.fillStyle = p.color;
|
| 312 |
+
ctx.globalAlpha = p.life / PARTICLE_MAX_LIFE; // Fade out
|
| 313 |
+
ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size);
|
| 314 |
+
});
|
| 315 |
+
ctx.globalAlpha = 1.0; // Reset alpha
|
| 316 |
+
return updatedParticles;
|
| 317 |
+
});
|
| 318 |
+
|
| 319 |
+
}, [currentLayout, whalePos, assetsLoaded, activeFireProjectile, adminConfig, createDestructionParticles, whaleBobOffset, whaleBobDirection]);
|
| 320 |
+
|
| 321 |
+
useEffect(() => {
|
| 322 |
+
if (isOpen && assetsLoaded && !gameOver && !gameWon) {
|
| 323 |
+
const gameLoop = () => {
|
| 324 |
+
drawGame();
|
| 325 |
+
animationFrameIdRef.current = requestAnimationFrame(gameLoop);
|
| 326 |
+
};
|
| 327 |
+
animationFrameIdRef.current = requestAnimationFrame(gameLoop);
|
| 328 |
+
} else if (animationFrameIdRef.current) {
|
| 329 |
+
cancelAnimationFrame(animationFrameIdRef.current);
|
| 330 |
+
}
|
| 331 |
+
return () => {
|
| 332 |
+
if (animationFrameIdRef.current) {
|
| 333 |
+
cancelAnimationFrame(animationFrameIdRef.current);
|
| 334 |
+
}
|
| 335 |
+
};
|
| 336 |
+
}, [isOpen, drawGame, assetsLoaded, gameOver, gameWon]);
|
| 337 |
+
|
| 338 |
+
const processMove = useCallback((targetRow: number, targetCol: number) => {
|
| 339 |
+
if (activeFireProjectile) return; // Don't process move if projectile is active
|
| 340 |
+
|
| 341 |
+
const targetCell = currentLayout[targetRow][targetCol];
|
| 342 |
+
let finalRow = targetRow;
|
| 343 |
+
let finalCol = targetCol;
|
| 344 |
+
let hitHazard = false;
|
| 345 |
+
let reachedGoal = false;
|
| 346 |
+
let gainedLife = false;
|
| 347 |
+
let tempMessage = "Keep going...";
|
| 348 |
+
let particleColors: string[] = [];
|
| 349 |
+
|
| 350 |
+
switch (targetCell) {
|
| 351 |
+
case MazeCellType.Mine:
|
| 352 |
+
case MazeCellType.Bomb:
|
| 353 |
+
case MazeCellType.Jellyfish:
|
| 354 |
+
case MazeCellType.Crocodile:
|
| 355 |
+
case MazeCellType.ToxicAlgae:
|
| 356 |
+
case MazeCellType.Snake:
|
| 357 |
+
hitHazard = true;
|
| 358 |
+
playSound(SoundEvent.EnemyKill, adminConfig);
|
| 359 |
+
particleColors = ['#FF0000', '#FF4500', '#DC143C']; // Reds for hazard hit
|
| 360 |
+
createDestructionParticles(targetRow, targetCol, particleColors);
|
| 361 |
+
break;
|
| 362 |
+
case MazeCellType.Goal:
|
| 363 |
+
if (levelData.requireAllSnakesNeutralized) {
|
| 364 |
+
const snakesRemaining = currentLayout.flat().some(cell => cell === MazeCellType.Snake);
|
| 365 |
+
if (snakesRemaining) {
|
| 366 |
+
setMessage("The exit is sealed! Neutralize all lurking snakes to open the path.");
|
| 367 |
+
// Keep player on Goal tile but don't win yet
|
| 368 |
+
setWhalePos({ row: targetRow, col: targetCol });
|
| 369 |
+
return; // Exit early, don't process as win
|
| 370 |
+
}
|
| 371 |
+
}
|
| 372 |
+
reachedGoal = true;
|
| 373 |
+
playSound(SoundEvent.LevelUp, adminConfig);
|
| 374 |
+
particleColors = ['#FFD700', '#FFFF00', '#ADFF2F']; // Golds/Yellows for goal
|
| 375 |
+
createDestructionParticles(targetRow, targetCol, particleColors);
|
| 376 |
+
break;
|
| 377 |
+
case MazeCellType.LifeUp:
|
| 378 |
+
gainedLife = true;
|
| 379 |
+
playSound(SoundEvent.TokenCollect, adminConfig);
|
| 380 |
+
particleColors = ['#FFD700', '#FFFFE0', '#FFFACD']; // Gold/Yellow for life up collect
|
| 381 |
+
createDestructionParticles(finalRow, finalCol, particleColors); // Particles at collection point
|
| 382 |
+
break;
|
| 383 |
+
case MazeCellType.CurrentN:
|
| 384 |
+
case MazeCellType.CurrentS:
|
| 385 |
+
case MazeCellType.CurrentE:
|
| 386 |
+
case MazeCellType.CurrentW:
|
| 387 |
+
playSound(SoundEvent.TokenCollect, adminConfig);
|
| 388 |
+
let pushedRow = targetRow;
|
| 389 |
+
let pushedCol = targetCol;
|
| 390 |
+
if (targetCell === MazeCellType.CurrentN) pushedRow--;
|
| 391 |
+
else if (targetCell === MazeCellType.CurrentS) pushedRow++;
|
| 392 |
+
else if (targetCell === MazeCellType.CurrentE) pushedCol++;
|
| 393 |
+
else if (targetCell === MazeCellType.CurrentW) pushedCol--;
|
| 394 |
+
|
| 395 |
+
if (
|
| 396 |
+
pushedRow >= 0 && pushedRow < currentLayout.length &&
|
| 397 |
+
pushedCol >= 0 && pushedCol < currentLayout[0].length &&
|
| 398 |
+
currentLayout[pushedRow][pushedCol] !== MazeCellType.Wall
|
| 399 |
+
) {
|
| 400 |
+
finalRow = pushedRow;
|
| 401 |
+
finalCol = pushedCol;
|
| 402 |
+
tempMessage = "Woosh! Carried by a current!";
|
| 403 |
+
createDestructionParticles(targetRow, targetCol, ['#00FFFF', '#AFEEEE', '#7FFFD4']); // Cyan for current
|
| 404 |
+
|
| 405 |
+
const cellAfterCurrent = currentLayout[finalRow][finalCol];
|
| 406 |
+
if ([MazeCellType.Mine, MazeCellType.Bomb, MazeCellType.Jellyfish, MazeCellType.Crocodile, MazeCellType.ToxicAlgae, MazeCellType.Snake].includes(cellAfterCurrent)) {
|
| 407 |
+
hitHazard = true;
|
| 408 |
+
playSound(SoundEvent.EnemyKill, adminConfig);
|
| 409 |
+
createDestructionParticles(finalRow, finalCol, ['#FF0000', '#FF4500', '#DC143C']);
|
| 410 |
+
} else if (cellAfterCurrent === MazeCellType.Goal) {
|
| 411 |
+
if (levelData.requireAllSnakesNeutralized) {
|
| 412 |
+
const snakesRemaining = currentLayout.flat().some(cell => cell === MazeCellType.Snake);
|
| 413 |
+
if (snakesRemaining) {
|
| 414 |
+
setMessage("The exit is sealed! Neutralize all lurking snakes to open the path.");
|
| 415 |
+
setWhalePos({ row: finalRow, col: finalCol }); // Stay on goal
|
| 416 |
+
return;
|
| 417 |
+
}
|
| 418 |
+
}
|
| 419 |
+
reachedGoal = true;
|
| 420 |
+
playSound(SoundEvent.LevelUp, adminConfig);
|
| 421 |
+
createDestructionParticles(finalRow, finalCol, ['#FFD700', '#FFFF00', '#ADFF2F']);
|
| 422 |
+
} else if (cellAfterCurrent === MazeCellType.LifeUp) {
|
| 423 |
+
gainedLife = true;
|
| 424 |
+
playSound(SoundEvent.TokenCollect, adminConfig);
|
| 425 |
+
createDestructionParticles(finalRow, finalCol, ['#FFD700', '#FFFFE0', '#FFFACD']);
|
| 426 |
+
}
|
| 427 |
+
} else {
|
| 428 |
+
tempMessage = "Current pushed into a wall! Safe for now.";
|
| 429 |
+
}
|
| 430 |
+
break;
|
| 431 |
+
default:
|
| 432 |
+
// playSound for path move if desired
|
| 433 |
+
break;
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
setWhalePos({ row: finalRow, col: finalCol });
|
| 437 |
+
|
| 438 |
+
if (hitHazard) {
|
| 439 |
+
const newLives = lives - 1;
|
| 440 |
+
setLives(newLives);
|
| 441 |
+
if (newLives <= 0) {
|
| 442 |
+
setMessage('Game Over! Out of lives.');
|
| 443 |
+
setGameOver(true);
|
| 444 |
+
playSound(SoundEvent.GameOver, adminConfig);
|
| 445 |
+
} else {
|
| 446 |
+
setMessage(`Ouch! Hit a hazard! ${newLives} live(s) left. Back to start!`);
|
| 447 |
+
setWhalePos(levelData.startPos);
|
| 448 |
+
}
|
| 449 |
+
} else if (reachedGoal) {
|
| 450 |
+
setMessage(`Level ${levelData.id} Complete! Well Done!`);
|
| 451 |
+
setGameWon(true);
|
| 452 |
+
} else if (gainedLife) {
|
| 453 |
+
const newLives = Math.min(lives + 1, MAX_DOGE_ESCAPE_LIVES);
|
| 454 |
+
setLives(newLives);
|
| 455 |
+
setMessage(`Life Up! You have ${newLives} lives.`);
|
| 456 |
+
const newLayout = currentLayout.map(row => [...row]);
|
| 457 |
+
newLayout[finalRow][finalCol] = MazeCellType.Path;
|
| 458 |
+
setCurrentLayout(newLayout);
|
| 459 |
+
} else {
|
| 460 |
+
setMessage(tempMessage);
|
| 461 |
+
}
|
| 462 |
+
}, [lives, adminConfig, currentLayout, levelData, activeFireProjectile, createDestructionParticles]);
|
| 463 |
+
|
| 464 |
+
const handleFire = useCallback(() => {
|
| 465 |
+
if (gameOver || gameWon || !currentLayout || !assetsLoaded || activeFireProjectile) return;
|
| 466 |
+
|
| 467 |
+
let targetRow = whalePos.row;
|
| 468 |
+
let targetCol = whalePos.col;
|
| 469 |
+
|
| 470 |
+
switch (playerDirection) {
|
| 471 |
+
case 'up': targetRow--; break;
|
| 472 |
+
case 'down': targetRow++; break;
|
| 473 |
+
case 'left': targetCol--; break;
|
| 474 |
+
case 'right': targetCol++; break;
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
if (
|
| 478 |
+
targetRow >= 0 && targetRow < currentLayout.length &&
|
| 479 |
+
targetCol >= 0 && targetCol < currentLayout[0].length
|
| 480 |
+
) {
|
| 481 |
+
const targetCellType = currentLayout[targetRow][targetCol];
|
| 482 |
+
if (targetCellType === MazeCellType.Wall || targetCellType === MazeCellType.Snake || targetCellType === MazeCellType.LifeUp) {
|
| 483 |
+
playSound(SoundEvent.Fire, adminConfig);
|
| 484 |
+
setActiveFireProjectile({
|
| 485 |
+
startPos: {
|
| 486 |
+
x: whalePos.col * DOGE_ESCAPE_GRID_CELL_SIZE + DOGE_ESCAPE_GRID_CELL_SIZE / 2,
|
| 487 |
+
y: whalePos.row * DOGE_ESCAPE_GRID_CELL_SIZE + DOGE_ESCAPE_GRID_CELL_SIZE / 2 + whaleBobOffset,
|
| 488 |
+
},
|
| 489 |
+
targetCell: { row: targetRow, col: targetCol },
|
| 490 |
+
targetPos: {
|
| 491 |
+
x: targetCol * DOGE_ESCAPE_GRID_CELL_SIZE + DOGE_ESCAPE_GRID_CELL_SIZE / 2,
|
| 492 |
+
y: targetRow * DOGE_ESCAPE_GRID_CELL_SIZE + DOGE_ESCAPE_GRID_CELL_SIZE / 2,
|
| 493 |
+
},
|
| 494 |
+
progress: 0,
|
| 495 |
+
color: '#FFA500', // Orange color for fireball
|
| 496 |
+
});
|
| 497 |
+
// Actual layout change and particle creation will happen in drawGame when projectile 'hits'
|
| 498 |
+
} else {
|
| 499 |
+
setMessage("Fire had no effect on that.");
|
| 500 |
+
}
|
| 501 |
+
} else {
|
| 502 |
+
setMessage("Cannot fire out of bounds.");
|
| 503 |
+
}
|
| 504 |
+
}, [gameOver, gameWon, currentLayout, assetsLoaded, whalePos, playerDirection, adminConfig, activeFireProjectile, whaleBobOffset]);
|
| 505 |
+
|
| 506 |
+
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
| 507 |
+
if (gameOver || gameWon || !currentLayout || !assetsLoaded || activeFireProjectile) return;
|
| 508 |
+
|
| 509 |
+
let nextRow = whalePos.row;
|
| 510 |
+
let nextCol = whalePos.col;
|
| 511 |
+
let newDirection = playerDirection;
|
| 512 |
+
|
| 513 |
+
if (e.key === ' ') {
|
| 514 |
+
e.preventDefault();
|
| 515 |
+
handleFire();
|
| 516 |
+
return;
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
switch (e.key) {
|
| 520 |
+
case 'ArrowUp': case 'w': nextRow--; newDirection = 'up'; break;
|
| 521 |
+
case 'ArrowDown': case 's': nextRow++; newDirection = 'down'; break;
|
| 522 |
+
case 'ArrowLeft': case 'a': nextCol--; newDirection = 'left'; break;
|
| 523 |
+
case 'ArrowRight': case 'd': nextCol++; newDirection = 'right'; break;
|
| 524 |
+
default: return;
|
| 525 |
+
}
|
| 526 |
+
e.preventDefault();
|
| 527 |
+
setPlayerDirection(newDirection);
|
| 528 |
+
|
| 529 |
+
if (
|
| 530 |
+
nextRow >= 0 && nextRow < currentLayout.length &&
|
| 531 |
+
nextCol >= 0 && nextCol < currentLayout[0].length
|
| 532 |
+
) {
|
| 533 |
+
const targetCellType = currentLayout[nextRow][nextCol];
|
| 534 |
+
if (targetCellType === MazeCellType.Wall) {
|
| 535 |
+
setMessage('Bonk! Hit a wall.');
|
| 536 |
+
createDestructionParticles(nextRow, nextCol, ['#D3D3D3', '#808080']); // Wall bonk particles
|
| 537 |
+
return;
|
| 538 |
+
}
|
| 539 |
+
processMove(nextRow, nextCol);
|
| 540 |
+
}
|
| 541 |
+
}, [whalePos, gameOver, gameWon, currentLayout, assetsLoaded, processMove, playerDirection, handleFire, activeFireProjectile, createDestructionParticles]);
|
| 542 |
+
|
| 543 |
+
useEffect(() => {
|
| 544 |
+
if (isOpen && assetsLoaded) {
|
| 545 |
+
window.addEventListener('keydown', handleKeyDown);
|
| 546 |
+
}
|
| 547 |
+
return () => {
|
| 548 |
+
window.removeEventListener('keydown', handleKeyDown);
|
| 549 |
+
};
|
| 550 |
+
}, [isOpen, assetsLoaded, handleKeyDown]);
|
| 551 |
+
|
| 552 |
+
const handleRestart = () => {
|
| 553 |
+
loadAndInitializeLevel(currentLevelIndex);
|
| 554 |
+
};
|
| 555 |
+
|
| 556 |
+
const handleNextLevel = () => {
|
| 557 |
+
const nextLevelIdx = (currentLevelIndex + 1);
|
| 558 |
+
if (nextLevelIdx >= DOGE_WHALE_ESCAPE_LEVELS.length) {
|
| 559 |
+
setMessage("Congratulations! You've completed all levels!");
|
| 560 |
+
setGameWon(true);
|
| 561 |
+
setGameOver(false);
|
| 562 |
+
setCurrentLevelIndex(DOGE_WHALE_ESCAPE_LEVELS.length -1);
|
| 563 |
+
} else {
|
| 564 |
+
setCurrentLevelIndex(nextLevelIdx);
|
| 565 |
+
// loadAndInitializeLevel will be called by the useEffect watching currentLevelIndex
|
| 566 |
+
}
|
| 567 |
+
};
|
| 568 |
+
|
| 569 |
+
const canvasWidth = currentLayout ? currentLayout[0].length * DOGE_ESCAPE_GRID_CELL_SIZE : 300;
|
| 570 |
+
const canvasHeight = currentLayout ? currentLayout.length * DOGE_ESCAPE_GRID_CELL_SIZE : 200;
|
| 571 |
+
|
| 572 |
+
|
| 573 |
+
return (
|
| 574 |
+
<Modal isOpen={isOpen} onClose={onClose} title={`Doge Whale Escape - Level ${levelData?.id}: ${levelData?.name || ''}`} size="xl">
|
| 575 |
+
<div className="flex flex-col items-center space-y-4 text-theme-text">
|
| 576 |
+
<div className="flex justify-between w-full max-w-lg text-lg">
|
| 577 |
+
<span>Lives: <span className="font-bold text-theme-warning">{lives} ❤️</span></span>
|
| 578 |
+
<span>Level: <span className="font-bold text-theme-accent">{levelData?.id || 1} / {DOGE_WHALE_ESCAPE_LEVELS.length}</span></span>
|
| 579 |
+
</div>
|
| 580 |
+
|
| 581 |
+
{!assetsLoaded &&
|
| 582 |
+
<div className="text-center text-yellow-400 py-10">
|
| 583 |
+
<div className="w-8 h-8 border-4 border-yellow-400 border-t-transparent rounded-full animate-spin mx-auto mb-2"></div>
|
| 584 |
+
Loading Game Assets...
|
| 585 |
+
</div>
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
<canvas
|
| 589 |
+
ref={canvasRef}
|
| 590 |
+
width={canvasWidth}
|
| 591 |
+
height={canvasHeight}
|
| 592 |
+
className={`border-2 border-theme-border rounded-lg shadow-lg ${!assetsLoaded ? 'hidden' : ''}`}
|
| 593 |
+
aria-label="Doge Whale Escape game board"
|
| 594 |
+
/>
|
| 595 |
+
|
| 596 |
+
<p className="text-center min-h-[2em]">{message}</p>
|
| 597 |
+
|
| 598 |
+
{(gameOver || gameWon) && (
|
| 599 |
+
<div className="mt-4 space-x-4">
|
| 600 |
+
<button onClick={handleRestart} className="py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white font-bold rounded-lg">
|
| 601 |
+
Restart Level
|
| 602 |
+
</button>
|
| 603 |
+
{gameWon && currentLevelIndex < DOGE_WHALE_ESCAPE_LEVELS.length -1 && (
|
| 604 |
+
<button onClick={handleNextLevel} className="py-2 px-4 bg-green-600 hover:bg-green-700 text-white font-bold rounded-lg">
|
| 605 |
+
Next Level
|
| 606 |
+
</button>
|
| 607 |
+
)}
|
| 608 |
+
{gameWon && currentLevelIndex >= DOGE_WHALE_ESCAPE_LEVELS.length -1 && (
|
| 609 |
+
<p className="text-green-400 font-bold">ALL LEVELS CLEARED!</p>
|
| 610 |
+
)}
|
| 611 |
+
</div>
|
| 612 |
+
)}
|
| 613 |
+
<button onClick={onClose} className="mt-2 py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white font-bold rounded-lg">
|
| 614 |
+
Close Game
|
| 615 |
+
</button>
|
| 616 |
+
</div>
|
| 617 |
+
</Modal>
|
| 618 |
+
);
|
| 619 |
+
};
|
GameCanvas.tsx
ADDED
|
@@ -0,0 +1,1303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useRef, useEffect, useCallback, useState } from 'react';
|
| 3 |
+
import {
|
| 4 |
+
PlayerData, FloatingText, NftBubbleItem, AdminConfig, WeaponType, SeaTokenItem,
|
| 5 |
+
Projectile as GlobalProjectileType, FishMinion, ExplosionEffect,
|
| 6 |
+
GameCharacter, NFT, EnemyEffectState
|
| 7 |
+
} from '../types.ts';
|
| 8 |
+
import { generateId } from '../utils.ts';
|
| 9 |
+
import {
|
| 10 |
+
PLAYER_SIZE, PLAYER_SPEED, ENEMY_SIZE, MAX_ENEMIES, TOKEN_VALUE_ON_COLLECT, FLOATING_TEXT_DURATION_MS,
|
| 11 |
+
STANDARD_PROJECTILE_SPEED, HEAVY_PROJECTILE_SPEED, ROCKET_PROJECTILE_SPEED, LASER_PROJECTILE_SPEED, MISSILE_SPEED,
|
| 12 |
+
SWORD_THROW_SPEED, SWORD_PROJECTILE_WIDTH, SWORD_PROJECTILE_HEIGHT,
|
| 13 |
+
AUTO_ROCKET_COOLDOWN_MS, AUTO_ROCKET_RANGE, AUTO_ROCKET_SPEED,
|
| 14 |
+
ENEMY_PROJECTILE_SPEED, ENEMY_PROJECTILE_SIZE, ENEMY_BASE_HEALTH,
|
| 15 |
+
COOLDOWN_STANDARD_BULLET, COOLDOWN_HEAVY_BULLET, COOLDOWN_ROCKET, COOLDOWN_LASER, COOLDOWN_SWORD_THROW, COOLDOWN_AUTO_TARGET_MISSILE,
|
| 16 |
+
BOMB_PROJECTILE_WIDTH, BOMB_PROJECTILE_HEIGHT, BOMB_PROJECTILE_INITIAL_SPEED_Y, BOMB_PROJECTILE_SPEED_X_FACTOR, BOMB_GRAVITY, BOMB_FUSE_MS, BOMB_EXPLOSION_RADIUS, BOMB_EXPLOSION_DURATION_MS, BOMB_DAMAGE,
|
| 17 |
+
FISH_MINION_SIZE, FISH_MINION_MAX_HEALTH, FISH_MINION_ATTACK_RANGE, FISH_MINION_PROJECTILE_SPEED, FISH_MINION_PROJECTILE_SIZE, FISH_MINION_FIRE_COOLDOWN_MS, FISH_MINION_BULLET_DAMAGE, MAX_FISH_MINIONS_PER_PLAYER, FISH_MINION_FOLLOW_DISTANCE, FISH_MINION_FOLLOW_LERP_FACTOR,
|
| 18 |
+
COOLDOWN_BOMB, COOLDOWN_SUMMON_FISH_MINION,
|
| 19 |
+
SEA_TOKEN_SIZE, SEA_TOKEN_COLOR, SEA_TOKEN_ATTRACT_SPEED, MAX_SEA_TOKENS_ON_SCREEN,
|
| 20 |
+
COOLDOWN_RED_ARROW_DEFENSE, RED_ARROW_PROJECTILE_SPEED, RED_ARROW_PROJECTILE_DAMAGE, RED_ARROW_PROJECTILE_WIDTH, RED_ARROW_PROJECTILE_HEIGHT, RED_ARROW_DEFAULT_SHOT_COUNT,
|
| 21 |
+
CONE_PROJECTILE_COUNT_STANDARD, CONE_PROJECTILE_COUNT_HEAVY, CONE_ANGLE_DEGREES_MULTI_SHOT,
|
| 22 |
+
FLAME_CONE_ANGLE_DEGREES, FLAME_CONE_RANGE, FLAME_CONE_DURATION_MS, FLAME_CONE_DAMAGE_INITIAL, FLAME_CONE_DOT_DAMAGE_PER_TICK, FLAME_CONE_DOT_TOTAL_TICKS, FLAME_CONE_DOT_TICK_INTERVAL_MS, COOLDOWN_FLAME_CONE,
|
| 23 |
+
EXPLOSIVE_CORE_THRESHOLD_MS, EXPLOSIVE_CORE_DAMAGE, EXPLOSIVE_CORE_RADIUS, EXPLOSIVE_CORE_SELF_DAMAGE_MULTIPLIER, EXPLOSIVE_CORE_EXPLOSION_COLOR,
|
| 24 |
+
DOGE_LAUNCHER_PROJECTILE_SPEED, DOGE_LAUNCHER_DAMAGE, COOLDOWN_DOGE_LAUNCHER, DOGE_PROJECTILE_WIDTH, DOGE_PROJECTILE_HEIGHT, BONE_PROJECTILE_WIDTH, BONE_PROJECTILE_HEIGHT
|
| 25 |
+
} from '../constants.ts';
|
| 26 |
+
import { playSound, SoundEvent } from '../sounds.ts';
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
interface GameCanvasProps {
|
| 30 |
+
playerData: PlayerData;
|
| 31 |
+
onGameOver: (score: number) => void;
|
| 32 |
+
onGainSeaTokensAndXP: (tokenValue: number, xpAmount?: number) => void;
|
| 33 |
+
onCollectNftBubble: (rarity: 'common' | 'rare' | 'legendary') => void;
|
| 34 |
+
activeNftEffects: NFT['effect'] | null;
|
| 35 |
+
runtimeGameCharacters: Record<string, GameCharacter>;
|
| 36 |
+
adminConfig: AdminConfig;
|
| 37 |
+
loadedGameAssets: Record<string, HTMLImageElement | null> | null;
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
interface Enemy extends EnemyEffectState { // Incorporate EnemyEffectState
|
| 41 |
+
id: string;
|
| 42 |
+
x: number;
|
| 43 |
+
y: number;
|
| 44 |
+
size: number;
|
| 45 |
+
speedX: number;
|
| 46 |
+
speedY: number;
|
| 47 |
+
characterId: string;
|
| 48 |
+
lastFireTime: number;
|
| 49 |
+
health: number;
|
| 50 |
+
maxHealth: number;
|
| 51 |
+
isFacingRight: boolean;
|
| 52 |
+
baseSpeed: number; // From GameCharacter definition
|
| 53 |
+
// accumulatedFireExposureMs is now part of EnemyEffectState, no need to redeclare
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
type Projectile = GlobalProjectileType;
|
| 57 |
+
|
| 58 |
+
interface Raindrop {
|
| 59 |
+
x: number;
|
| 60 |
+
y: number;
|
| 61 |
+
length: number;
|
| 62 |
+
speed: number;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
interface BloodParticle {
|
| 66 |
+
id: string;
|
| 67 |
+
x: number;
|
| 68 |
+
y: number;
|
| 69 |
+
size: number;
|
| 70 |
+
speedX: number;
|
| 71 |
+
speedY: number;
|
| 72 |
+
opacity: number;
|
| 73 |
+
life: number;
|
| 74 |
+
creationTime: number;
|
| 75 |
+
color: string;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
interface ActiveFlameCone {
|
| 79 |
+
x: number; // Origin x
|
| 80 |
+
y: number; // Origin y
|
| 81 |
+
angle: number; // Center angle of the cone in radians
|
| 82 |
+
range: number; // How far the cone reaches
|
| 83 |
+
coneAngleRad: number; // Width of the cone in radians
|
| 84 |
+
endTime: number; // When the visual effect should stop
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
// Define GameContext type for internal state, used by triggerExplosiveCore and others
|
| 88 |
+
interface GameContext {
|
| 89 |
+
playerPos: { x: number; y: number };
|
| 90 |
+
enemies: Enemy[];
|
| 91 |
+
projectiles: Projectile[];
|
| 92 |
+
fishMinions: FishMinion[];
|
| 93 |
+
activeExplosions: ExplosionEffect[];
|
| 94 |
+
floatingTexts: FloatingText[];
|
| 95 |
+
seaTokensOnScreen: SeaTokenItem[];
|
| 96 |
+
bloodParticles: BloodParticle[];
|
| 97 |
+
keysPressed: Record<string, boolean>;
|
| 98 |
+
score: number;
|
| 99 |
+
playerHealth: number;
|
| 100 |
+
lastPlayerShotTime: number;
|
| 101 |
+
lastAutoRocketFireTime: number;
|
| 102 |
+
lastRedArrowShotTime: number;
|
| 103 |
+
lastHorizontalDirection: 'left' | 'right';
|
| 104 |
+
playerSpeedX: number;
|
| 105 |
+
raindrops: Raindrop[];
|
| 106 |
+
isRaining: boolean;
|
| 107 |
+
rainCycleIntervalId?: NodeJS.Timeout;
|
| 108 |
+
rainStopTimeoutId?: NodeJS.Timeout;
|
| 109 |
+
gameSpeedMultiplier: number;
|
| 110 |
+
speedChangeActiveUntil: number;
|
| 111 |
+
activeFlameCone: ActiveFlameCone | null;
|
| 112 |
+
lastFiredDogeLauncherProjectileType: 'doge' | 'bone';
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
const NORMAL_SPEED_MULTIPLIER = 1.0;
|
| 117 |
+
const FAST_SPEED_MULTIPLIER = 1.25;
|
| 118 |
+
const KILL_SPEED_MULTIPLIER = 1.4;
|
| 119 |
+
const SLOW_SPEED_MULTIPLIER = 0.65;
|
| 120 |
+
const SPEED_CHANGE_DURATION_MS = 1200;
|
| 121 |
+
const KILL_SPEED_DURATION_MS = 1800;
|
| 122 |
+
|
| 123 |
+
const CANVAS_BORDER_THICKNESS = 3;
|
| 124 |
+
const CANVAS_BORDER_COLOR = 'rgba(0, 169, 224, 0.5)';
|
| 125 |
+
|
| 126 |
+
export const GameCanvas: React.FC<GameCanvasProps> = ({
|
| 127 |
+
playerData,
|
| 128 |
+
onGameOver,
|
| 129 |
+
onGainSeaTokensAndXP,
|
| 130 |
+
onCollectNftBubble,
|
| 131 |
+
activeNftEffects,
|
| 132 |
+
runtimeGameCharacters,
|
| 133 |
+
adminConfig,
|
| 134 |
+
loadedGameAssets
|
| 135 |
+
}) => {
|
| 136 |
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 137 |
+
const gameContextRef = useRef<GameContext | null>(null);
|
| 138 |
+
|
| 139 |
+
const getPlayerSpeed = useCallback(() => {
|
| 140 |
+
return PLAYER_SPEED * (activeNftEffects?.speed || 1) * (activeNftEffects?.allStats || 1);
|
| 141 |
+
}, [activeNftEffects]);
|
| 142 |
+
|
| 143 |
+
const getPlayerDamageMultiplier = useCallback(() => {
|
| 144 |
+
return (activeNftEffects?.damage || 1) * (activeNftEffects?.allStats || 1);
|
| 145 |
+
}, [activeNftEffects]);
|
| 146 |
+
|
| 147 |
+
const getPlayerShootCooldown = useCallback(() => {
|
| 148 |
+
const playerChar = runtimeGameCharacters[playerData.characterId];
|
| 149 |
+
const weaponType = playerChar?.defaultWeaponType || WeaponType.StandardBullet;
|
| 150 |
+
switch (weaponType) {
|
| 151 |
+
case WeaponType.Laser: return COOLDOWN_LASER;
|
| 152 |
+
case WeaponType.HeavyBullet: return COOLDOWN_HEAVY_BULLET;
|
| 153 |
+
case WeaponType.SwordThrow: return COOLDOWN_SWORD_THROW;
|
| 154 |
+
case WeaponType.Rocket: return COOLDOWN_ROCKET;
|
| 155 |
+
case WeaponType.AutoTargetMissile: return COOLDOWN_AUTO_TARGET_MISSILE;
|
| 156 |
+
case WeaponType.Bomb: return COOLDOWN_BOMB;
|
| 157 |
+
case WeaponType.SummonFishMinion: return COOLDOWN_SUMMON_FISH_MINION;
|
| 158 |
+
case WeaponType.FlameCone: return COOLDOWN_FLAME_CONE;
|
| 159 |
+
case WeaponType.DogeLauncher: return COOLDOWN_DOGE_LAUNCHER;
|
| 160 |
+
// RedArrowDefense is passive, so its cooldown is handled separately
|
| 161 |
+
default: return COOLDOWN_STANDARD_BULLET;
|
| 162 |
+
}
|
| 163 |
+
}, [playerData.characterId, runtimeGameCharacters]);
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
useEffect(() => {
|
| 167 |
+
const canvas = canvasRef.current;
|
| 168 |
+
if (!canvas) return;
|
| 169 |
+
|
| 170 |
+
if (!gameContextRef.current || gameContextRef.current.playerHealth <= 0) {
|
| 171 |
+
gameContextRef.current = {
|
| 172 |
+
playerPos: { x: canvas.width / 2, y: canvas.height / 2 },
|
| 173 |
+
enemies: [],
|
| 174 |
+
projectiles: [],
|
| 175 |
+
fishMinions: [],
|
| 176 |
+
activeExplosions: [],
|
| 177 |
+
floatingTexts: [],
|
| 178 |
+
seaTokensOnScreen: [],
|
| 179 |
+
bloodParticles: [],
|
| 180 |
+
keysPressed: {},
|
| 181 |
+
score: 0,
|
| 182 |
+
playerHealth: playerData.maxHealth,
|
| 183 |
+
lastPlayerShotTime: 0,
|
| 184 |
+
lastAutoRocketFireTime: 0,
|
| 185 |
+
lastRedArrowShotTime: 0,
|
| 186 |
+
lastHorizontalDirection: 'right',
|
| 187 |
+
playerSpeedX: 0,
|
| 188 |
+
raindrops: [],
|
| 189 |
+
isRaining: false,
|
| 190 |
+
gameSpeedMultiplier: NORMAL_SPEED_MULTIPLIER,
|
| 191 |
+
speedChangeActiveUntil: 0,
|
| 192 |
+
activeFlameCone: null,
|
| 193 |
+
lastFiredDogeLauncherProjectileType: 'bone', // Start with bone so first shot is doge
|
| 194 |
+
};
|
| 195 |
+
|
| 196 |
+
if (adminConfig.rainEffectEnabled) {
|
| 197 |
+
const startRainCycle = () => {
|
| 198 |
+
if (!gameContextRef.current || !canvasRef.current) return;
|
| 199 |
+
const gameCtx = gameContextRef.current;
|
| 200 |
+
const currentCanvas = canvasRef.current;
|
| 201 |
+
|
| 202 |
+
if (gameCtx.rainCycleIntervalId) clearInterval(gameCtx.rainCycleIntervalId);
|
| 203 |
+
if (gameCtx.rainStopTimeoutId) clearTimeout(gameCtx.rainStopTimeoutId);
|
| 204 |
+
|
| 205 |
+
gameCtx.rainCycleIntervalId = setInterval(() => {
|
| 206 |
+
if (!gameContextRef.current) return;
|
| 207 |
+
gameContextRef.current.isRaining = true;
|
| 208 |
+
const newRaindrops: Raindrop[] = [];
|
| 209 |
+
for (let i = 0; i < adminConfig.rainIntensity; i++) {
|
| 210 |
+
newRaindrops.push({
|
| 211 |
+
x: Math.random() * currentCanvas.width,
|
| 212 |
+
y: Math.random() * currentCanvas.height,
|
| 213 |
+
length: Math.random() * 20 + 10,
|
| 214 |
+
speed: Math.random() * (adminConfig.rainDropSpeedMax - adminConfig.rainDropSpeedMin) + adminConfig.rainDropSpeedMin
|
| 215 |
+
});
|
| 216 |
+
}
|
| 217 |
+
gameContextRef.current.raindrops = newRaindrops;
|
| 218 |
+
|
| 219 |
+
if (gameContextRef.current.rainStopTimeoutId) clearTimeout(gameContextRef.current.rainStopTimeoutId);
|
| 220 |
+
gameContextRef.current.rainStopTimeoutId = setTimeout(() => {
|
| 221 |
+
if (gameContextRef.current) gameContextRef.current.isRaining = false;
|
| 222 |
+
}, adminConfig.rainDurationSeconds * 1000);
|
| 223 |
+
}, adminConfig.rainIntervalSeconds * 1000);
|
| 224 |
+
};
|
| 225 |
+
startRainCycle();
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
const handleKeyDown = (e: KeyboardEvent) => {
|
| 230 |
+
if (gameContextRef.current) gameContextRef.current.keysPressed[e.key] = true;
|
| 231 |
+
};
|
| 232 |
+
const handleKeyUp = (e: KeyboardEvent) => {
|
| 233 |
+
if (gameContextRef.current) gameContextRef.current.keysPressed[e.key] = false;
|
| 234 |
+
};
|
| 235 |
+
|
| 236 |
+
window.addEventListener('keydown', handleKeyDown);
|
| 237 |
+
window.addEventListener('keyup', handleKeyUp);
|
| 238 |
+
|
| 239 |
+
return () => {
|
| 240 |
+
window.removeEventListener('keydown', handleKeyDown);
|
| 241 |
+
window.removeEventListener('keyup', handleKeyUp);
|
| 242 |
+
if (gameContextRef.current?.rainCycleIntervalId) clearInterval(gameContextRef.current.rainCycleIntervalId);
|
| 243 |
+
if (gameContextRef.current?.rainStopTimeoutId) clearTimeout(gameContextRef.current.rainStopTimeoutId);
|
| 244 |
+
};
|
| 245 |
+
}, [playerData.maxHealth, adminConfig]);
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
const addFloatingText = useCallback((text: string, x: number, y: number, color: string = 'white') => {
|
| 249 |
+
if (!gameContextRef.current) return;
|
| 250 |
+
gameContextRef.current.floatingTexts.push({ id: generateId(), text, x, y, color, timestamp: Date.now() });
|
| 251 |
+
}, []);
|
| 252 |
+
|
| 253 |
+
const spawnBlood = useCallback((centerX: number, centerY: number, count: number, color: string = 'rgba(139,0,0,') => {
|
| 254 |
+
if (!gameContextRef.current) return;
|
| 255 |
+
for (let i = 0; i < count; i++) {
|
| 256 |
+
gameContextRef.current.bloodParticles.push({
|
| 257 |
+
id: generateId(),
|
| 258 |
+
x: centerX,
|
| 259 |
+
y: centerY,
|
| 260 |
+
size: Math.random() * 3 + 2,
|
| 261 |
+
speedX: (Math.random() - 0.5) * 3,
|
| 262 |
+
speedY: Math.random() * 1.5 + 1,
|
| 263 |
+
opacity: 1,
|
| 264 |
+
life: adminConfig.bloodParticleLifespanSeconds * 1000,
|
| 265 |
+
creationTime: Date.now(),
|
| 266 |
+
color: color
|
| 267 |
+
});
|
| 268 |
+
}
|
| 269 |
+
}, [adminConfig.bloodParticleLifespanSeconds]);
|
| 270 |
+
|
| 271 |
+
const createExplosion = useCallback((
|
| 272 |
+
x: number, y: number, radius: number, duration: number, color: string,
|
| 273 |
+
damageToApply: number,
|
| 274 |
+
onHitCallback?: (enemy: Enemy) => void
|
| 275 |
+
) => {
|
| 276 |
+
if (!gameContextRef.current) return;
|
| 277 |
+
const gameCtx = gameContextRef.current;
|
| 278 |
+
gameCtx.activeExplosions.push({
|
| 279 |
+
id: generateId(), x, y, currentRadius: 0, maxRadius: radius,
|
| 280 |
+
creationTime: Date.now(), duration, color,
|
| 281 |
+
});
|
| 282 |
+
// AoE Damage application
|
| 283 |
+
gameCtx.enemies.forEach(enemy => {
|
| 284 |
+
if (enemy.health <= 0) return; // Don't damage already dead enemies
|
| 285 |
+
const distSq = (enemy.x + enemy.size / 2 - x) ** 2 + (enemy.y + enemy.size / 2 - y) ** 2;
|
| 286 |
+
if (distSq <= radius * radius) {
|
| 287 |
+
const actualDamage = damageToApply * ( (gameCtx.playerPos && x === gameCtx.playerPos.x + PLAYER_SIZE / 2 && y === gameCtx.playerPos.y + PLAYER_SIZE / 2) ? getPlayerDamageMultiplier() : 1);
|
| 288 |
+
enemy.health -= actualDamage;
|
| 289 |
+
addFloatingText(`-${Math.round(actualDamage)}💥`, enemy.x, enemy.y, 'orange');
|
| 290 |
+
spawnBlood(enemy.x + enemy.size / 2, enemy.y + enemy.size / 2, 5, 'rgba(255,165,0,'); // Orange blood for explosion
|
| 291 |
+
|
| 292 |
+
if (enemy.health > 0) {
|
| 293 |
+
// Speed change for non-lethal hits, only if original bomb was player's
|
| 294 |
+
if (gameCtx.playerPos && x === gameCtx.playerPos.x + PLAYER_SIZE / 2 && y === gameCtx.playerPos.y + PLAYER_SIZE / 2) {
|
| 295 |
+
gameCtx.gameSpeedMultiplier = FAST_SPEED_MULTIPLIER;
|
| 296 |
+
gameCtx.speedChangeActiveUntil = Date.now() + SPEED_CHANGE_DURATION_MS;
|
| 297 |
+
}
|
| 298 |
+
}
|
| 299 |
+
if (onHitCallback) {
|
| 300 |
+
onHitCallback(enemy);
|
| 301 |
+
}
|
| 302 |
+
}
|
| 303 |
+
});
|
| 304 |
+
}, [getPlayerDamageMultiplier, addFloatingText, spawnBlood]);
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
const triggerExplosiveCore = useCallback((
|
| 308 |
+
enemyToExplode: Enemy,
|
| 309 |
+
alreadyChained: Set<string>,
|
| 310 |
+
gameCtx: GameContext // Pass gameCtx explicitly
|
| 311 |
+
) => {
|
| 312 |
+
if (!enemyToExplode || enemyToExplode.health <= 0 || alreadyChained.has(enemyToExplode.id)) {
|
| 313 |
+
return;
|
| 314 |
+
}
|
| 315 |
+
alreadyChained.add(enemyToExplode.id);
|
| 316 |
+
|
| 317 |
+
const selfDamage = EXPLOSIVE_CORE_DAMAGE * EXPLOSIVE_CORE_SELF_DAMAGE_MULTIPLIER;
|
| 318 |
+
enemyToExplode.health -= selfDamage;
|
| 319 |
+
addFloatingText(`-${Math.round(selfDamage)}💥!`, enemyToExplode.x + enemyToExplode.size / 2, enemyToExplode.y - 10, 'red');
|
| 320 |
+
spawnBlood(enemyToExplode.x + enemyToExplode.size / 2, enemyToExplode.y + enemyToExplode.size / 2, 15, 'rgba(255,0,0,');
|
| 321 |
+
|
| 322 |
+
// Create the visual explosion and deal AoE damage, triggering chains via callback
|
| 323 |
+
createExplosion(
|
| 324 |
+
enemyToExplode.x + enemyToExplode.size / 2,
|
| 325 |
+
enemyToExplode.y + enemyToExplode.size / 2,
|
| 326 |
+
EXPLOSIVE_CORE_RADIUS,
|
| 327 |
+
BOMB_EXPLOSION_DURATION_MS, // Use existing bomb duration or new constant
|
| 328 |
+
EXPLOSIVE_CORE_EXPLOSION_COLOR,
|
| 329 |
+
EXPLOSIVE_CORE_DAMAGE,
|
| 330 |
+
(damagedEnemy: Enemy) => { // This is the onHitCallback
|
| 331 |
+
// If the damaged enemy is not the one that just exploded, is alive, and not already chained...
|
| 332 |
+
if (damagedEnemy.id !== enemyToExplode.id && damagedEnemy.health > 0 && !alreadyChained.has(damagedEnemy.id)) {
|
| 333 |
+
// Recursively call triggerExplosiveCore for the chained enemy
|
| 334 |
+
triggerExplosiveCore(damagedEnemy, alreadyChained, gameCtx);
|
| 335 |
+
}
|
| 336 |
+
}
|
| 337 |
+
);
|
| 338 |
+
playSound(SoundEvent.EnemyKill, adminConfig); // Use enemy kill sound or a new explosion sound
|
| 339 |
+
|
| 340 |
+
// Reset state for the enemy that just exploded
|
| 341 |
+
enemyToExplode.isBurning = false;
|
| 342 |
+
enemyToExplode.accumulatedFireExposureMs = 0;
|
| 343 |
+
enemyToExplode.burnTicksRemaining = 0;
|
| 344 |
+
|
| 345 |
+
}, [createExplosion, addFloatingText, spawnBlood, adminConfig]); // Dependencies of triggerExplosiveCore
|
| 346 |
+
|
| 347 |
+
const spawnEnemy = useCallback(() => {
|
| 348 |
+
const canvas = canvasRef.current;
|
| 349 |
+
if (!canvas || !gameContextRef.current || gameContextRef.current.enemies.length >= MAX_ENEMIES || !loadedGameAssets) return;
|
| 350 |
+
|
| 351 |
+
const availableEnemyTypes = Object.values(runtimeGameCharacters).filter(char =>
|
| 352 |
+
char.type === 'enemy' &&
|
| 353 |
+
(loadedGameAssets[`enemyChar_${char.id}`] || loadedGameAssets['defaultEnemy'])
|
| 354 |
+
);
|
| 355 |
+
|
| 356 |
+
if (availableEnemyTypes.length === 0) return;
|
| 357 |
+
const randomEnemyCharDef = availableEnemyTypes[Math.floor(Math.random() * availableEnemyTypes.length)];
|
| 358 |
+
|
| 359 |
+
const enemySize = randomEnemyCharDef.baseSpeed || ENEMY_SIZE; // Use actual enemy size for positioning if available
|
| 360 |
+
const x = canvas.width; // Always spawn from the right
|
| 361 |
+
const y = Math.random() * (canvas.height - enemySize * 2) + enemySize; // Ensure full enemy is visible vertically
|
| 362 |
+
|
| 363 |
+
const enemyBaseSpeed = randomEnemyCharDef.baseSpeed || 1 + Math.random() * 1.0;
|
| 364 |
+
const speedX = -enemyBaseSpeed; // Always move left
|
| 365 |
+
const speedY = (Math.random() - 0.5) * (enemyBaseSpeed * 0.3); // Vertical drift
|
| 366 |
+
|
| 367 |
+
gameContextRef.current.enemies.push({
|
| 368 |
+
id: generateId(), x, y,
|
| 369 |
+
size: ENEMY_SIZE,
|
| 370 |
+
speedX, speedY,
|
| 371 |
+
characterId: randomEnemyCharDef.id, lastFireTime: 0,
|
| 372 |
+
health: randomEnemyCharDef.baseHealth || ENEMY_BASE_HEALTH,
|
| 373 |
+
maxHealth: randomEnemyCharDef.baseHealth || ENEMY_BASE_HEALTH,
|
| 374 |
+
isFacingRight: false, // Always facing left initially because moving left
|
| 375 |
+
baseSpeed: enemyBaseSpeed,
|
| 376 |
+
isBurning: false,
|
| 377 |
+
burnDamagePerTick: 0,
|
| 378 |
+
burnTicksRemaining: 0,
|
| 379 |
+
burnTickCooldownRemaining: 0,
|
| 380 |
+
accumulatedFireExposureMs: 0, // Initialize for explosive core
|
| 381 |
+
});
|
| 382 |
+
}, [runtimeGameCharacters, loadedGameAssets]);
|
| 383 |
+
|
| 384 |
+
const findNearestEntity = useCallback(<T extends { id: string; x: number; y: number; width?: number; height?: number; size?: number }>(
|
| 385 |
+
originX: number,
|
| 386 |
+
originY: number,
|
| 387 |
+
entities: T[],
|
| 388 |
+
range?: number,
|
| 389 |
+
excludeIds?: string[]
|
| 390 |
+
): T | null => {
|
| 391 |
+
if (!entities || entities.length === 0) return null;
|
| 392 |
+
let nearestEntity: T | null = null;
|
| 393 |
+
let minDistanceSq = range ? range * range : Infinity;
|
| 394 |
+
|
| 395 |
+
entities.forEach(entity => {
|
| 396 |
+
if (excludeIds && excludeIds.includes(entity.id)) return;
|
| 397 |
+
|
| 398 |
+
const entitySize = entity.size || Math.max(entity.width || 0, entity.height || 0);
|
| 399 |
+
const entityCenterX = entity.x + entitySize / 2;
|
| 400 |
+
const entityCenterY = entity.y + entitySize / 2;
|
| 401 |
+
const distanceSq = (originX - entityCenterX) ** 2 + (originY - entityCenterY) ** 2;
|
| 402 |
+
if (distanceSq < minDistanceSq) {
|
| 403 |
+
minDistanceSq = distanceSq;
|
| 404 |
+
nearestEntity = entity;
|
| 405 |
+
}
|
| 406 |
+
});
|
| 407 |
+
return nearestEntity;
|
| 408 |
+
}, []);
|
| 409 |
+
|
| 410 |
+
const findNearestEnemy = useCallback((originX: number, originY: number, range?: number, excludeIds?: string[]): Enemy | null => {
|
| 411 |
+
if (!gameContextRef.current) return null;
|
| 412 |
+
return findNearestEntity(originX, originY, gameContextRef.current.enemies, range, excludeIds);
|
| 413 |
+
}, [findNearestEntity]); // Added findNearestEntity dependency
|
| 414 |
+
|
| 415 |
+
const findNearestEnemyProjectile = useCallback((originX: number, originY: number, range?: number, excludeIds?: string[]): Projectile | null => {
|
| 416 |
+
if (!gameContextRef.current) return null;
|
| 417 |
+
const enemyProjectiles = gameContextRef.current.projectiles.filter(p => p.owner === 'enemy');
|
| 418 |
+
return findNearestEntity(originX, originY, enemyProjectiles, range, excludeIds);
|
| 419 |
+
}, [findNearestEntity]); // Added findNearestEntity dependency
|
| 420 |
+
|
| 421 |
+
|
| 422 |
+
const firePlayerProjectile = useCallback(() => {
|
| 423 |
+
if (!gameContextRef.current || !playerData || !loadedGameAssets) return;
|
| 424 |
+
const gameCtx = gameContextRef.current;
|
| 425 |
+
const now = Date.now();
|
| 426 |
+
if (now - gameCtx.lastPlayerShotTime < getPlayerShootCooldown() / gameCtx.gameSpeedMultiplier) return;
|
| 427 |
+
|
| 428 |
+
const playerChar = runtimeGameCharacters[playerData.characterId];
|
| 429 |
+
const weaponType = playerChar?.defaultWeaponType || WeaponType.StandardBullet;
|
| 430 |
+
|
| 431 |
+
if (weaponType === WeaponType.RedArrowDefense) return;
|
| 432 |
+
|
| 433 |
+
const { playerPos, lastHorizontalDirection, playerSpeedX, enemies, lastFiredDogeLauncherProjectileType } = gameCtx;
|
| 434 |
+
const playerCenterX = playerPos.x + PLAYER_SIZE / 2;
|
| 435 |
+
const playerCenterY = playerPos.y + PLAYER_SIZE / 2;
|
| 436 |
+
const isFacingRight = lastHorizontalDirection === 'right';
|
| 437 |
+
|
| 438 |
+
if (weaponType === WeaponType.DogeLauncher) {
|
| 439 |
+
const nextProjectileImageType = lastFiredDogeLauncherProjectileType === 'doge' ? 'bone' : 'doge';
|
| 440 |
+
const imageKey = nextProjectileImageType === 'doge' ? 'dogeProjectile' : 'boneProjectile';
|
| 441 |
+
const projectileWidth = nextProjectileImageType === 'doge' ? DOGE_PROJECTILE_WIDTH : BONE_PROJECTILE_WIDTH;
|
| 442 |
+
const projectileHeight = nextProjectileImageType === 'doge' ? DOGE_PROJECTILE_HEIGHT : BONE_PROJECTILE_HEIGHT;
|
| 443 |
+
|
| 444 |
+
let aimAngle = isFacingRight ? 0 : Math.PI;
|
| 445 |
+
if (adminConfig.playerAutoTargets) {
|
| 446 |
+
const targetEnemy = findNearestEnemy(playerCenterX, playerCenterY);
|
| 447 |
+
if (targetEnemy) {
|
| 448 |
+
aimAngle = Math.atan2(targetEnemy.y + targetEnemy.size / 2 - playerCenterY, targetEnemy.x + targetEnemy.size / 2 - playerCenterX);
|
| 449 |
+
}
|
| 450 |
+
}
|
| 451 |
+
|
| 452 |
+
const newLauncherProjectile: Projectile = {
|
| 453 |
+
id: generateId(),
|
| 454 |
+
x: playerCenterX - projectileWidth / 2,
|
| 455 |
+
y: playerCenterY - projectileHeight / 2,
|
| 456 |
+
width: projectileWidth,
|
| 457 |
+
height: projectileHeight,
|
| 458 |
+
dx: Math.cos(aimAngle) * DOGE_LAUNCHER_PROJECTILE_SPEED,
|
| 459 |
+
dy: Math.sin(aimAngle) * DOGE_LAUNCHER_PROJECTILE_SPEED,
|
| 460 |
+
rotation: aimAngle,
|
| 461 |
+
type: WeaponType.DogeLauncher,
|
| 462 |
+
owner: 'player',
|
| 463 |
+
ownerPlayerId: playerData.id,
|
| 464 |
+
damage: DOGE_LAUNCHER_DAMAGE * getPlayerDamageMultiplier(),
|
| 465 |
+
imageKey: imageKey,
|
| 466 |
+
isHoming: false, // DogeLauncher projectiles are not homing by default
|
| 467 |
+
};
|
| 468 |
+
gameCtx.projectiles.push(newLauncherProjectile);
|
| 469 |
+
gameCtx.lastFiredDogeLauncherProjectileType = nextProjectileImageType;
|
| 470 |
+
gameCtx.lastPlayerShotTime = now;
|
| 471 |
+
playSound(SoundEvent.Fire, adminConfig);
|
| 472 |
+
return;
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
|
| 476 |
+
if (weaponType === WeaponType.FlameCone) {
|
| 477 |
+
const coneOriginX = playerCenterX;
|
| 478 |
+
const coneOriginY = playerCenterY;
|
| 479 |
+
const coneDirectionAngle = isFacingRight ? 0 : Math.PI; // 0 for right, PI for left
|
| 480 |
+
const coneAngleRad = FLAME_CONE_ANGLE_DEGREES * (Math.PI / 180);
|
| 481 |
+
|
| 482 |
+
gameCtx.activeFlameCone = {
|
| 483 |
+
x: coneOriginX,
|
| 484 |
+
y: coneOriginY,
|
| 485 |
+
angle: coneDirectionAngle,
|
| 486 |
+
range: FLAME_CONE_RANGE,
|
| 487 |
+
coneAngleRad: coneAngleRad,
|
| 488 |
+
endTime: now + FLAME_CONE_DURATION_MS,
|
| 489 |
+
};
|
| 490 |
+
|
| 491 |
+
enemies.forEach(enemy => {
|
| 492 |
+
if (enemy.health <= 0) return;
|
| 493 |
+
const enemyCenterX = enemy.x + enemy.size / 2;
|
| 494 |
+
const enemyCenterY = enemy.y + enemy.size / 2;
|
| 495 |
+
const dx = enemyCenterX - coneOriginX;
|
| 496 |
+
const dy = enemyCenterY - coneOriginY;
|
| 497 |
+
const distanceToEnemy = Math.sqrt(dx * dx + dy * dy);
|
| 498 |
+
|
| 499 |
+
if (distanceToEnemy <= FLAME_CONE_RANGE + enemy.size / 2) {
|
| 500 |
+
const angleToEnemy = Math.atan2(dy, dx);
|
| 501 |
+
let angleDifference = Math.abs(coneDirectionAngle - angleToEnemy);
|
| 502 |
+
if (angleDifference > Math.PI) angleDifference = 2 * Math.PI - angleDifference;
|
| 503 |
+
|
| 504 |
+
if (angleDifference <= coneAngleRad / 2) {
|
| 505 |
+
const initialDamage = FLAME_CONE_DAMAGE_INITIAL * getPlayerDamageMultiplier();
|
| 506 |
+
enemy.health -= initialDamage;
|
| 507 |
+
addFloatingText(`-${Math.round(initialDamage)}`, enemy.x, enemy.y - 15, 'orange');
|
| 508 |
+
spawnBlood(enemyCenterX, enemyCenterY, 3, 'rgba(255,100,0,');
|
| 509 |
+
|
| 510 |
+
enemy.isBurning = true;
|
| 511 |
+
enemy.burnDamagePerTick = FLAME_CONE_DOT_DAMAGE_PER_TICK * getPlayerDamageMultiplier();
|
| 512 |
+
enemy.burnTicksRemaining = FLAME_CONE_DOT_TOTAL_TICKS;
|
| 513 |
+
enemy.burnTickCooldownRemaining = FLAME_CONE_DOT_TICK_INTERVAL_MS;
|
| 514 |
+
// accumulatedFireExposureMs is handled in the main loop based on isBurning
|
| 515 |
+
|
| 516 |
+
if (enemy.health > 0) {
|
| 517 |
+
gameCtx.gameSpeedMultiplier = FAST_SPEED_MULTIPLIER;
|
| 518 |
+
gameCtx.speedChangeActiveUntil = Date.now() + SPEED_CHANGE_DURATION_MS;
|
| 519 |
+
}
|
| 520 |
+
}
|
| 521 |
+
}
|
| 522 |
+
});
|
| 523 |
+
gameCtx.lastPlayerShotTime = now;
|
| 524 |
+
playSound(SoundEvent.Fire, adminConfig);
|
| 525 |
+
return;
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
// Existing projectile logic for other weapon types
|
| 530 |
+
let baseAimAngle: number;
|
| 531 |
+
let generalTargetEnemy: Enemy | null = null;
|
| 532 |
+
let generalAngleToTarget: number | null = null;
|
| 533 |
+
|
| 534 |
+
if (adminConfig.playerAutoTargets) {
|
| 535 |
+
generalTargetEnemy = findNearestEnemy(playerCenterX, playerCenterY);
|
| 536 |
+
if (generalTargetEnemy) {
|
| 537 |
+
const targetCenterX = generalTargetEnemy.x + generalTargetEnemy.size / 2;
|
| 538 |
+
const targetCenterY = generalTargetEnemy.y + generalTargetEnemy.size / 2;
|
| 539 |
+
generalAngleToTarget = Math.atan2(targetCenterY - playerCenterY, targetCenterX - playerCenterX);
|
| 540 |
+
baseAimAngle = generalAngleToTarget;
|
| 541 |
+
} else {
|
| 542 |
+
baseAimAngle = isFacingRight ? 0 : Math.PI;
|
| 543 |
+
}
|
| 544 |
+
} else {
|
| 545 |
+
baseAimAngle = isFacingRight ? 0 : Math.PI;
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
if (weaponType === WeaponType.StandardBullet || weaponType === WeaponType.HeavyBullet) {
|
| 549 |
+
const projectileCount = weaponType === WeaponType.StandardBullet ? CONE_PROJECTILE_COUNT_STANDARD : CONE_PROJECTILE_COUNT_HEAVY;
|
| 550 |
+
const coneAngleTotalRadians = CONE_ANGLE_DEGREES_MULTI_SHOT * (Math.PI / 180);
|
| 551 |
+
|
| 552 |
+
for (let i = 0; i < projectileCount; i++) {
|
| 553 |
+
let currentProjectileAngle: number;
|
| 554 |
+
if (projectileCount > 1) {
|
| 555 |
+
const offsetFraction = (i / (projectileCount - 1)) - 0.5;
|
| 556 |
+
currentProjectileAngle = baseAimAngle + offsetFraction * coneAngleTotalRadians;
|
| 557 |
+
} else {
|
| 558 |
+
currentProjectileAngle = baseAimAngle;
|
| 559 |
+
}
|
| 560 |
+
|
| 561 |
+
let newConeProjectile: Partial<Projectile> & { type: WeaponType } = {
|
| 562 |
+
id: generateId(), x: playerCenterX, y: playerCenterY, type: weaponType, owner: 'player', ownerPlayerId: playerData.id, isHoming: false,
|
| 563 |
+
};
|
| 564 |
+
|
| 565 |
+
if (weaponType === WeaponType.StandardBullet) {
|
| 566 |
+
newConeProjectile.width = 10; newConeProjectile.height = 5;
|
| 567 |
+
newConeProjectile.dx = Math.cos(currentProjectileAngle) * STANDARD_PROJECTILE_SPEED;
|
| 568 |
+
newConeProjectile.dy = Math.sin(currentProjectileAngle) * STANDARD_PROJECTILE_SPEED;
|
| 569 |
+
newConeProjectile.rotation = currentProjectileAngle;
|
| 570 |
+
newConeProjectile.damage = 10 * getPlayerDamageMultiplier();
|
| 571 |
+
} else { // HeavyBullet
|
| 572 |
+
newConeProjectile.width = 12; newConeProjectile.height = 7;
|
| 573 |
+
newConeProjectile.dx = Math.cos(currentProjectileAngle) * HEAVY_PROJECTILE_SPEED;
|
| 574 |
+
newConeProjectile.dy = Math.sin(currentProjectileAngle) * HEAVY_PROJECTILE_SPEED;
|
| 575 |
+
newConeProjectile.rotation = currentProjectileAngle;
|
| 576 |
+
newConeProjectile.damage = 15 * getPlayerDamageMultiplier();
|
| 577 |
+
}
|
| 578 |
+
newConeProjectile.x! -= newConeProjectile.width! / 2;
|
| 579 |
+
newConeProjectile.y! -= newConeProjectile.height! / 2;
|
| 580 |
+
gameCtx.projectiles.push(newConeProjectile as Projectile);
|
| 581 |
+
}
|
| 582 |
+
gameCtx.lastPlayerShotTime = now;
|
| 583 |
+
playSound(SoundEvent.Fire, adminConfig);
|
| 584 |
+
|
| 585 |
+
} else {
|
| 586 |
+
let newSingleProjectile: Partial<Projectile> = {
|
| 587 |
+
id: generateId(), x: playerCenterX, y: playerCenterY, type: weaponType, owner: 'player', ownerPlayerId: playerData.id, isHoming: false,
|
| 588 |
+
};
|
| 589 |
+
|
| 590 |
+
switch (weaponType) {
|
| 591 |
+
case WeaponType.Rocket:
|
| 592 |
+
newSingleProjectile.width = 16; newSingleProjectile.height = 8;
|
| 593 |
+
if (adminConfig.playerAutoTargets && generalAngleToTarget !== null) {
|
| 594 |
+
newSingleProjectile.dx = Math.cos(generalAngleToTarget) * ROCKET_PROJECTILE_SPEED;
|
| 595 |
+
newSingleProjectile.dy = Math.sin(generalAngleToTarget) * ROCKET_PROJECTILE_SPEED;
|
| 596 |
+
newSingleProjectile.rotation = generalAngleToTarget;
|
| 597 |
+
} else {
|
| 598 |
+
newSingleProjectile.dx = isFacingRight ? ROCKET_PROJECTILE_SPEED : -ROCKET_PROJECTILE_SPEED;
|
| 599 |
+
newSingleProjectile.dy = 0;
|
| 600 |
+
newSingleProjectile.rotation = isFacingRight ? 0 : Math.PI;
|
| 601 |
+
}
|
| 602 |
+
newSingleProjectile.damage = 25 * getPlayerDamageMultiplier();
|
| 603 |
+
break;
|
| 604 |
+
case WeaponType.Laser:
|
| 605 |
+
newSingleProjectile.width = 20; newSingleProjectile.height = 3;
|
| 606 |
+
if (adminConfig.playerAutoTargets && generalAngleToTarget !== null) {
|
| 607 |
+
newSingleProjectile.dx = Math.cos(generalAngleToTarget) * LASER_PROJECTILE_SPEED;
|
| 608 |
+
newSingleProjectile.dy = Math.sin(generalAngleToTarget) * LASER_PROJECTILE_SPEED;
|
| 609 |
+
newSingleProjectile.rotation = generalAngleToTarget;
|
| 610 |
+
} else {
|
| 611 |
+
newSingleProjectile.dx = isFacingRight ? LASER_PROJECTILE_SPEED : -LASER_PROJECTILE_SPEED;
|
| 612 |
+
newSingleProjectile.dy = 0;
|
| 613 |
+
newSingleProjectile.rotation = isFacingRight ? 0 : Math.PI;
|
| 614 |
+
}
|
| 615 |
+
newSingleProjectile.damage = 12 * getPlayerDamageMultiplier();
|
| 616 |
+
break;
|
| 617 |
+
case WeaponType.AutoTargetMissile:
|
| 618 |
+
const targetForManualMissile = findNearestEnemy(playerCenterX, playerCenterY);
|
| 619 |
+
newSingleProjectile.width = 12; newSingleProjectile.height = 6;
|
| 620 |
+
newSingleProjectile.dx = isFacingRight ? MISSILE_SPEED : -MISSILE_SPEED;
|
| 621 |
+
newSingleProjectile.dy = 0;
|
| 622 |
+
newSingleProjectile.targetEnemyId = targetForManualMissile?.id;
|
| 623 |
+
newSingleProjectile.isHoming = true;
|
| 624 |
+
newSingleProjectile.rotation = targetForManualMissile ? Math.atan2(targetForManualMissile.y + targetForManualMissile.size / 2 - playerCenterY, targetForManualMissile.x + targetForManualMissile.size / 2 - playerCenterX) : (isFacingRight ? 0 : Math.PI);
|
| 625 |
+
newSingleProjectile.damage = 18 * getPlayerDamageMultiplier();
|
| 626 |
+
break;
|
| 627 |
+
case WeaponType.SwordThrow:
|
| 628 |
+
newSingleProjectile.width = SWORD_PROJECTILE_HEIGHT; newSingleProjectile.height = SWORD_PROJECTILE_WIDTH;
|
| 629 |
+
if (adminConfig.playerAutoTargets && generalAngleToTarget !== null) {
|
| 630 |
+
newSingleProjectile.dx = Math.cos(generalAngleToTarget) * SWORD_THROW_SPEED;
|
| 631 |
+
newSingleProjectile.dy = Math.sin(generalAngleToTarget) * SWORD_THROW_SPEED;
|
| 632 |
+
newSingleProjectile.rotation = generalAngleToTarget + Math.PI / 2;
|
| 633 |
+
} else {
|
| 634 |
+
newSingleProjectile.dx = isFacingRight ? SWORD_THROW_SPEED : -SWORD_THROW_SPEED;
|
| 635 |
+
newSingleProjectile.dy = 0;
|
| 636 |
+
newSingleProjectile.rotation = Math.PI / 2;
|
| 637 |
+
}
|
| 638 |
+
newSingleProjectile.damage = 20 * getPlayerDamageMultiplier();
|
| 639 |
+
break;
|
| 640 |
+
case WeaponType.Bomb:
|
| 641 |
+
newSingleProjectile.width = BOMB_PROJECTILE_WIDTH; newSingleProjectile.height = BOMB_PROJECTILE_HEIGHT;
|
| 642 |
+
const baseBombSpeedX = (playerSpeedX !== 0 ? playerSpeedX * BOMB_PROJECTILE_SPEED_X_FACTOR : (isFacingRight ? 1 : -1) * (PLAYER_SPEED / 2) * BOMB_PROJECTILE_SPEED_X_FACTOR);
|
| 643 |
+
newSingleProjectile.dx = baseBombSpeedX;
|
| 644 |
+
newSingleProjectile.dy = BOMB_PROJECTILE_INITIAL_SPEED_Y;
|
| 645 |
+
newSingleProjectile.gravityEffect = true;
|
| 646 |
+
newSingleProjectile.creationTime = now;
|
| 647 |
+
newSingleProjectile.rotation = 0;
|
| 648 |
+
break;
|
| 649 |
+
case WeaponType.SummonFishMinion:
|
| 650 |
+
const currentMinions = gameCtx.fishMinions.filter(fm => fm.ownerPlayerId === playerData.id);
|
| 651 |
+
if (currentMinions.length < MAX_FISH_MINIONS_PER_PLAYER) {
|
| 652 |
+
const angleOffset = (Math.PI * 2 / MAX_FISH_MINIONS_PER_PLAYER) * currentMinions.length;
|
| 653 |
+
gameCtx.fishMinions.push({ id: generateId(), x: playerCenterX + Math.cos(angleOffset) * FISH_MINION_FOLLOW_DISTANCE, y: playerCenterY + Math.sin(angleOffset) * FISH_MINION_FOLLOW_DISTANCE, health: FISH_MINION_MAX_HEALTH, maxHealth: FISH_MINION_MAX_HEALTH, targetEnemyId: null, lastFireTime: 0, ownerPlayerId: playerData.id, size: FISH_MINION_SIZE, angleToPlayer: angleOffset, distanceFromPlayer: FISH_MINION_FOLLOW_DISTANCE });
|
| 654 |
+
gameCtx.lastPlayerShotTime = now;
|
| 655 |
+
}
|
| 656 |
+
return;
|
| 657 |
+
default: // Should technically be StandardBullet if not FlameCone or others handled
|
| 658 |
+
newSingleProjectile.type = WeaponType.StandardBullet;
|
| 659 |
+
newSingleProjectile.width = 10; newSingleProjectile.height = 5;
|
| 660 |
+
if (adminConfig.playerAutoTargets && generalAngleToTarget !== null) {
|
| 661 |
+
newSingleProjectile.dx = Math.cos(generalAngleToTarget) * STANDARD_PROJECTILE_SPEED;
|
| 662 |
+
newSingleProjectile.dy = Math.sin(generalAngleToTarget) * STANDARD_PROJECTILE_SPEED;
|
| 663 |
+
newSingleProjectile.rotation = generalAngleToTarget;
|
| 664 |
+
} else {
|
| 665 |
+
newSingleProjectile.dx = isFacingRight ? STANDARD_PROJECTILE_SPEED : -STANDARD_PROJECTILE_SPEED;
|
| 666 |
+
newSingleProjectile.dy = 0;
|
| 667 |
+
newSingleProjectile.rotation = isFacingRight ? 0 : Math.PI;
|
| 668 |
+
}
|
| 669 |
+
newSingleProjectile.damage = 10 * getPlayerDamageMultiplier();
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
if (newSingleProjectile.type !== WeaponType.SummonFishMinion) {
|
| 673 |
+
newSingleProjectile.x! -= newSingleProjectile.width! / 2;
|
| 674 |
+
newSingleProjectile.y! -= newSingleProjectile.height! / 2;
|
| 675 |
+
gameCtx.projectiles.push(newSingleProjectile as Projectile);
|
| 676 |
+
gameCtx.lastPlayerShotTime = now;
|
| 677 |
+
playSound(SoundEvent.Fire, adminConfig);
|
| 678 |
+
}
|
| 679 |
+
}
|
| 680 |
+
}, [playerData, runtimeGameCharacters, getPlayerShootCooldown, findNearestEnemy, getPlayerDamageMultiplier, createExplosion, adminConfig, addFloatingText, spawnBlood, loadedGameAssets]);
|
| 681 |
+
|
| 682 |
+
const fireEnemyProjectile = useCallback((enemy: Enemy) => {
|
| 683 |
+
if (!gameContextRef.current) return;
|
| 684 |
+
const { playerPos } = gameContextRef.current;
|
| 685 |
+
const playerX = playerPos.x + PLAYER_SIZE / 2; const playerY = playerPos.y + PLAYER_SIZE / 2;
|
| 686 |
+
const enemyCenterX = enemy.x + enemy.size / 2; const enemyCenterY = enemy.y + enemy.size / 2;
|
| 687 |
+
const angle = Math.atan2(playerY - enemyCenterY, playerX - enemyCenterX);
|
| 688 |
+
const dx = Math.cos(angle) * ENEMY_PROJECTILE_SPEED; const dy = Math.sin(angle) * ENEMY_PROJECTILE_SPEED;
|
| 689 |
+
gameContextRef.current.projectiles.push({
|
| 690 |
+
id: generateId(),
|
| 691 |
+
x: enemyCenterX - ENEMY_PROJECTILE_SIZE / 2,
|
| 692 |
+
y: enemyCenterY - ENEMY_PROJECTILE_SIZE / 2,
|
| 693 |
+
dx, dy,
|
| 694 |
+
width: ENEMY_PROJECTILE_SIZE,
|
| 695 |
+
height: ENEMY_PROJECTILE_SIZE,
|
| 696 |
+
type: WeaponType.EnemyBullet,
|
| 697 |
+
owner: 'enemy',
|
| 698 |
+
ownerEnemyId: enemy.id,
|
| 699 |
+
damage: 10,
|
| 700 |
+
rotation: angle
|
| 701 |
+
});
|
| 702 |
+
enemy.lastFireTime = Date.now();
|
| 703 |
+
}, []);
|
| 704 |
+
|
| 705 |
+
const fireFishMinionProjectile = useCallback((minion: FishMinion, target: Enemy) => {
|
| 706 |
+
if (!gameContextRef.current) return;
|
| 707 |
+
const targetCenterX = target.x + target.size / 2; const targetCenterY = target.y + target.size / 2;
|
| 708 |
+
const minionCenterX = minion.x + minion.size / 2; const minionCenterY = minion.y + minion.size / 2;
|
| 709 |
+
const angle = Math.atan2(targetCenterY - minionCenterY, targetCenterX - minionCenterX);
|
| 710 |
+
const dx = Math.cos(angle) * FISH_MINION_PROJECTILE_SPEED; const dy = Math.sin(angle) * FISH_MINION_PROJECTILE_SPEED;
|
| 711 |
+
gameContextRef.current.projectiles.push({
|
| 712 |
+
id: generateId(),
|
| 713 |
+
x: minionCenterX - FISH_MINION_PROJECTILE_SIZE / 2,
|
| 714 |
+
y: minionCenterY - FISH_MINION_PROJECTILE_SIZE / 2,
|
| 715 |
+
dx, dy,
|
| 716 |
+
width: FISH_MINION_PROJECTILE_SIZE,
|
| 717 |
+
height: FISH_MINION_PROJECTILE_SIZE,
|
| 718 |
+
type: WeaponType.FishMinionBullet,
|
| 719 |
+
owner: 'player',
|
| 720 |
+
ownerPlayerId: minion.ownerPlayerId,
|
| 721 |
+
damage: FISH_MINION_BULLET_DAMAGE * getPlayerDamageMultiplier(),
|
| 722 |
+
rotation: angle + Math.PI / 2
|
| 723 |
+
});
|
| 724 |
+
minion.lastFireTime = Date.now();
|
| 725 |
+
}, [getPlayerDamageMultiplier]);
|
| 726 |
+
|
| 727 |
+
|
| 728 |
+
useEffect(() => {
|
| 729 |
+
if (!loadedGameAssets) return;
|
| 730 |
+
|
| 731 |
+
const canvas = canvasRef.current;
|
| 732 |
+
const ctx = canvas?.getContext('2d');
|
| 733 |
+
if (!ctx || !gameContextRef.current) return;
|
| 734 |
+
|
| 735 |
+
let animationFrameId: number;
|
| 736 |
+
let lastEnemySpawnTime = 0;
|
| 737 |
+
const enemySpawnInterval = 2000;
|
| 738 |
+
|
| 739 |
+
const gameLoop = (timestamp: number) => {
|
| 740 |
+
const gameCtx = gameContextRef.current;
|
| 741 |
+
if (!gameCtx || !playerData ) { animationFrameId = requestAnimationFrame(gameLoop); return; }
|
| 742 |
+
|
| 743 |
+
const now = Date.now();
|
| 744 |
+
const timeDelta = (1000 / 60) * gameCtx.gameSpeedMultiplier; // Approx frame time in ms
|
| 745 |
+
|
| 746 |
+
if (gameCtx.speedChangeActiveUntil > 0 && now > gameCtx.speedChangeActiveUntil) {
|
| 747 |
+
gameCtx.gameSpeedMultiplier = NORMAL_SPEED_MULTIPLIER;
|
| 748 |
+
gameCtx.speedChangeActiveUntil = 0;
|
| 749 |
+
}
|
| 750 |
+
const currentSpeedMultiplier = gameCtx.gameSpeedMultiplier;
|
| 751 |
+
|
| 752 |
+
const { playerPos, enemies, projectiles, fishMinions, activeExplosions, floatingTexts, seaTokensOnScreen, bloodParticles, keysPressed, score, lastAutoRocketFireTime, raindrops, isRaining, playerSpeedX, lastHorizontalDirection, lastRedArrowShotTime, activeFlameCone } = gameCtx;
|
| 753 |
+
const playerChar = runtimeGameCharacters[playerData.characterId];
|
| 754 |
+
|
| 755 |
+
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
| 756 |
+
|
| 757 |
+
const bgImage = loadedGameAssets['background'];
|
| 758 |
+
if (bgImage) {
|
| 759 |
+
ctx.drawImage(bgImage, 0, 0, ctx.canvas.width, ctx.canvas.height);
|
| 760 |
+
}
|
| 761 |
+
|
| 762 |
+
ctx.fillStyle = CANVAS_BORDER_COLOR;
|
| 763 |
+
ctx.fillRect(0, 0, ctx.canvas.width, CANVAS_BORDER_THICKNESS);
|
| 764 |
+
ctx.fillRect(0, ctx.canvas.height - CANVAS_BORDER_THICKNESS, ctx.canvas.width, CANVAS_BORDER_THICKNESS);
|
| 765 |
+
|
| 766 |
+
|
| 767 |
+
if (adminConfig.rainEffectEnabled && isRaining && raindrops.length > 0) {
|
| 768 |
+
ctx.strokeStyle = 'rgba(174,194,224,0.5)';
|
| 769 |
+
ctx.lineWidth = 1;
|
| 770 |
+
ctx.lineCap = 'round';
|
| 771 |
+
raindrops.forEach(drop => {
|
| 772 |
+
ctx.beginPath();
|
| 773 |
+
ctx.moveTo(drop.x, drop.y);
|
| 774 |
+
const rainAngle = (playerSpeedX || 0) * 0.02;
|
| 775 |
+
ctx.lineTo(drop.x + rainAngle, drop.y + drop.length);
|
| 776 |
+
ctx.stroke();
|
| 777 |
+
drop.y += drop.speed * currentSpeedMultiplier;
|
| 778 |
+
if (drop.y > ctx.canvas.height) {
|
| 779 |
+
drop.y = Math.random() * -ctx.canvas.height * 0.5;
|
| 780 |
+
drop.x = Math.random() * ctx.canvas.width;
|
| 781 |
+
}
|
| 782 |
+
});
|
| 783 |
+
}
|
| 784 |
+
|
| 785 |
+
for (let i = bloodParticles.length - 1; i >= 0; i--) {
|
| 786 |
+
const p = bloodParticles[i];
|
| 787 |
+
p.x += p.speedX * currentSpeedMultiplier; p.y += p.speedY * currentSpeedMultiplier;
|
| 788 |
+
const lifetimeProgress = (now - p.creationTime) / p.life;
|
| 789 |
+
p.opacity = Math.max(0, 1 - lifetimeProgress);
|
| 790 |
+
if (p.opacity <= 0 || p.y > ctx.canvas.height + p.size || p.x < -p.size || p.x > ctx.canvas.width + p.size) {
|
| 791 |
+
bloodParticles.splice(i, 1); continue;
|
| 792 |
+
}
|
| 793 |
+
ctx.fillStyle = `${p.color}${p.opacity.toFixed(2)})`;
|
| 794 |
+
ctx.beginPath(); ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); ctx.fill();
|
| 795 |
+
}
|
| 796 |
+
|
| 797 |
+
for (let i = seaTokensOnScreen.length - 1; i >= 0; i--) {
|
| 798 |
+
const token = seaTokensOnScreen[i];
|
| 799 |
+
if (token.isAttracting) {
|
| 800 |
+
const playerCenterX = playerPos.x + PLAYER_SIZE / 2; const playerCenterY = playerPos.y + PLAYER_SIZE / 2;
|
| 801 |
+
const angle = Math.atan2(playerCenterY - token.y, playerCenterX - token.x);
|
| 802 |
+
token.x += Math.cos(angle) * SEA_TOKEN_ATTRACT_SPEED * currentSpeedMultiplier;
|
| 803 |
+
token.y += Math.sin(angle) * SEA_TOKEN_ATTRACT_SPEED * currentSpeedMultiplier;
|
| 804 |
+
if (Math.abs(token.x - playerCenterX) < PLAYER_SIZE / 2 && Math.abs(token.y - playerCenterY) < PLAYER_SIZE / 2) {
|
| 805 |
+
onGainSeaTokensAndXP(token.value, TOKEN_VALUE_ON_COLLECT / 2);
|
| 806 |
+
addFloatingText(`+${token.value} ST`, token.x, token.y, 'yellow');
|
| 807 |
+
seaTokensOnScreen.splice(i, 1); continue;
|
| 808 |
+
}
|
| 809 |
+
} else { token.y += 0.5 * currentSpeedMultiplier; }
|
| 810 |
+
const tokenImage = loadedGameAssets?.['seaToken'];
|
| 811 |
+
if (tokenImage) ctx.drawImage(tokenImage, token.x - SEA_TOKEN_SIZE, token.y - SEA_TOKEN_SIZE, SEA_TOKEN_SIZE * 2, SEA_TOKEN_SIZE * 2);
|
| 812 |
+
else { ctx.fillStyle = SEA_TOKEN_COLOR; ctx.beginPath(); ctx.arc(token.x, token.y, SEA_TOKEN_SIZE, 0, Math.PI * 2); ctx.fill(); }
|
| 813 |
+
if (token.y > ctx.canvas.height + SEA_TOKEN_SIZE * 2) seaTokensOnScreen.splice(i,1);
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
const currentBaseSpeed = getPlayerSpeed();
|
| 817 |
+
const actualMovementSpeed = currentBaseSpeed * currentSpeedMultiplier;
|
| 818 |
+
let actualPlayerSpeedXUnmultiplied = 0;
|
| 819 |
+
if (keysPressed['ArrowLeft'] || keysPressed['a']) { playerPos.x -= actualMovementSpeed; actualPlayerSpeedXUnmultiplied = -currentBaseSpeed; gameCtx.lastHorizontalDirection = 'left';}
|
| 820 |
+
if (keysPressed['ArrowRight'] || keysPressed['d']) { playerPos.x += actualMovementSpeed; actualPlayerSpeedXUnmultiplied = currentBaseSpeed; gameCtx.lastHorizontalDirection = 'right';}
|
| 821 |
+
if (keysPressed['ArrowUp'] || keysPressed['w']) playerPos.y -= actualMovementSpeed;
|
| 822 |
+
if (keysPressed['ArrowDown'] || keysPressed['s']) playerPos.y += actualMovementSpeed;
|
| 823 |
+
gameCtx.playerSpeedX = actualPlayerSpeedXUnmultiplied;
|
| 824 |
+
playerPos.x = Math.max(0, Math.min(ctx.canvas.width - PLAYER_SIZE, playerPos.x));
|
| 825 |
+
playerPos.y = Math.max(0, Math.min(ctx.canvas.height - PLAYER_SIZE, playerPos.y));
|
| 826 |
+
const playerImageToRender = loadedGameAssets[`playerChar_${playerData.characterId}`];
|
| 827 |
+
ctx.save(); ctx.translate(playerPos.x + PLAYER_SIZE / 2, playerPos.y + PLAYER_SIZE / 2);
|
| 828 |
+
if (gameCtx.lastHorizontalDirection === 'left' && playerImageToRender) ctx.scale(-1,1);
|
| 829 |
+
if (playerImageToRender) ctx.drawImage(playerImageToRender, -PLAYER_SIZE / 2, -PLAYER_SIZE / 2, PLAYER_SIZE, PLAYER_SIZE);
|
| 830 |
+
else { ctx.fillStyle = playerData.whaleColor; ctx.fillRect(-PLAYER_SIZE / 2, -PLAYER_SIZE / 2, PLAYER_SIZE, PLAYER_SIZE); }
|
| 831 |
+
ctx.restore();
|
| 832 |
+
|
| 833 |
+
// Draw Active Flame Cone
|
| 834 |
+
if (activeFlameCone && now < activeFlameCone.endTime) {
|
| 835 |
+
ctx.save();
|
| 836 |
+
ctx.globalAlpha = 0.6;
|
| 837 |
+
const gradient = ctx.createRadialGradient(activeFlameCone.x, activeFlameCone.y, 0, activeFlameCone.x, activeFlameCone.y, activeFlameCone.range * 0.8);
|
| 838 |
+
gradient.addColorStop(0, 'rgba(255, 165, 0, 0.8)');
|
| 839 |
+
gradient.addColorStop(0.5, 'rgba(255, 100, 0, 0.6)');
|
| 840 |
+
gradient.addColorStop(1, 'rgba(255, 69, 0, 0.2)');
|
| 841 |
+
ctx.fillStyle = gradient;
|
| 842 |
+
|
| 843 |
+
ctx.beginPath();
|
| 844 |
+
ctx.moveTo(activeFlameCone.x, activeFlameCone.y);
|
| 845 |
+
const angle1 = activeFlameCone.angle - activeFlameCone.coneAngleRad / 2;
|
| 846 |
+
const angle2 = activeFlameCone.angle + activeFlameCone.coneAngleRad / 2;
|
| 847 |
+
ctx.lineTo(activeFlameCone.x + activeFlameCone.range * Math.cos(angle1), activeFlameCone.y + activeFlameCone.range * Math.sin(angle1));
|
| 848 |
+
ctx.arc(activeFlameCone.x, activeFlameCone.y, activeFlameCone.range, angle1, angle2);
|
| 849 |
+
ctx.lineTo(activeFlameCone.x, activeFlameCone.y);
|
| 850 |
+
ctx.closePath();
|
| 851 |
+
ctx.fill();
|
| 852 |
+
ctx.restore();
|
| 853 |
+
} else if (activeFlameCone && now >= activeFlameCone.endTime) {
|
| 854 |
+
gameCtx.activeFlameCone = null;
|
| 855 |
+
}
|
| 856 |
+
|
| 857 |
+
|
| 858 |
+
if (playerChar?.defaultWeaponType === WeaponType.AutoHomingRocket && now - lastAutoRocketFireTime > AUTO_ROCKET_COOLDOWN_MS / currentSpeedMultiplier) {
|
| 859 |
+
const targetEnemy = findNearestEnemy(playerPos.x + PLAYER_SIZE / 2, playerPos.y + PLAYER_SIZE / 2, AUTO_ROCKET_RANGE);
|
| 860 |
+
if (targetEnemy) {
|
| 861 |
+
projectiles.push({
|
| 862 |
+
id: generateId(), x: playerPos.x + PLAYER_SIZE / 2, y: playerPos.y + PLAYER_SIZE / 2,
|
| 863 |
+
width: 6, height: 12, type: WeaponType.AutoHomingRocket, targetEnemyId: targetEnemy.id,
|
| 864 |
+
dx: lastHorizontalDirection === 'right' ? AUTO_ROCKET_SPEED : -AUTO_ROCKET_SPEED, dy: 0,
|
| 865 |
+
rotation: lastHorizontalDirection === 'right' ? 0 : Math.PI, owner: 'player',
|
| 866 |
+
ownerPlayerId: playerData.id, damage: 15 * getPlayerDamageMultiplier(), isHoming: true,
|
| 867 |
+
});
|
| 868 |
+
gameCtx.lastAutoRocketFireTime = now; playSound(SoundEvent.Fire, adminConfig);
|
| 869 |
+
}
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
if (adminConfig.redArrowActive && playerChar?.defaultWeaponType === WeaponType.RedArrowDefense &&
|
| 873 |
+
now - lastRedArrowShotTime > (adminConfig.redArrowCooldownMs || COOLDOWN_RED_ARROW_DEFENSE) / currentSpeedMultiplier) {
|
| 874 |
+
|
| 875 |
+
const shotCount = adminConfig.redArrowShotCount || RED_ARROW_DEFAULT_SHOT_COUNT;
|
| 876 |
+
const alreadyTargetedIds: string[] = [];
|
| 877 |
+
let firedThisBurst = false;
|
| 878 |
+
|
| 879 |
+
for (let shotNum = 0; shotNum < shotCount; shotNum++) {
|
| 880 |
+
let targetDetails: { x: number, y: number, id?: string, isProjectileTarget?: boolean } | null = null;
|
| 881 |
+
|
| 882 |
+
const nearestEnemyProj = findNearestEnemyProjectile(playerPos.x + PLAYER_SIZE / 2, playerPos.y + PLAYER_SIZE / 2, undefined, alreadyTargetedIds);
|
| 883 |
+
if (nearestEnemyProj) {
|
| 884 |
+
targetDetails = {
|
| 885 |
+
x: nearestEnemyProj.x + nearestEnemyProj.width / 2,
|
| 886 |
+
y: nearestEnemyProj.y + nearestEnemyProj.height / 2,
|
| 887 |
+
id: nearestEnemyProj.id,
|
| 888 |
+
isProjectileTarget: true
|
| 889 |
+
};
|
| 890 |
+
if (nearestEnemyProj.id) alreadyTargetedIds.push(nearestEnemyProj.id);
|
| 891 |
+
} else {
|
| 892 |
+
const nearestEnemyEntity = findNearestEnemy(playerPos.x + PLAYER_SIZE / 2, playerPos.y + PLAYER_SIZE / 2, undefined, alreadyTargetedIds);
|
| 893 |
+
if (nearestEnemyEntity) {
|
| 894 |
+
targetDetails = {
|
| 895 |
+
x: nearestEnemyEntity.x + nearestEnemyEntity.size / 2,
|
| 896 |
+
y: nearestEnemyEntity.y + nearestEnemyEntity.size / 2,
|
| 897 |
+
id: nearestEnemyEntity.id,
|
| 898 |
+
isProjectileTarget: false
|
| 899 |
+
};
|
| 900 |
+
if (nearestEnemyEntity.id) alreadyTargetedIds.push(nearestEnemyEntity.id);
|
| 901 |
+
}
|
| 902 |
+
}
|
| 903 |
+
|
| 904 |
+
if (targetDetails) {
|
| 905 |
+
const projectileStartX = playerPos.x + PLAYER_SIZE / 2;
|
| 906 |
+
const projectileStartY = playerPos.y + PLAYER_SIZE / 2;
|
| 907 |
+
const angleToTarget = Math.atan2(targetDetails.y - projectileStartY, targetDetails.x - projectileStartX);
|
| 908 |
+
const speed = adminConfig.redArrowProjectileSpeed || RED_ARROW_PROJECTILE_SPEED;
|
| 909 |
+
|
| 910 |
+
projectiles.push({
|
| 911 |
+
id: generateId(),
|
| 912 |
+
x: projectileStartX - RED_ARROW_PROJECTILE_WIDTH / 2,
|
| 913 |
+
y: projectileStartY - RED_ARROW_PROJECTILE_HEIGHT / 2,
|
| 914 |
+
width: RED_ARROW_PROJECTILE_WIDTH,
|
| 915 |
+
height: RED_ARROW_PROJECTILE_HEIGHT,
|
| 916 |
+
dx: Math.cos(angleToTarget) * speed,
|
| 917 |
+
dy: Math.sin(angleToTarget) * speed,
|
| 918 |
+
rotation: angleToTarget,
|
| 919 |
+
type: WeaponType.RedArrowProjectile,
|
| 920 |
+
owner: 'player',
|
| 921 |
+
ownerPlayerId: playerData.id,
|
| 922 |
+
damage: adminConfig.redArrowDamage || RED_ARROW_PROJECTILE_DAMAGE,
|
| 923 |
+
isHoming: true,
|
| 924 |
+
targetProjectileId: targetDetails.isProjectileTarget ? targetDetails.id : undefined,
|
| 925 |
+
targetEnemyId: !targetDetails.isProjectileTarget ? targetDetails.id : undefined,
|
| 926 |
+
});
|
| 927 |
+
firedThisBurst = true;
|
| 928 |
+
}
|
| 929 |
+
}
|
| 930 |
+
|
| 931 |
+
if (firedThisBurst) {
|
| 932 |
+
gameCtx.lastRedArrowShotTime = now;
|
| 933 |
+
playSound(SoundEvent.Fire, adminConfig);
|
| 934 |
+
}
|
| 935 |
+
}
|
| 936 |
+
|
| 937 |
+
|
| 938 |
+
const shouldPlayerAutoFire = () => {
|
| 939 |
+
if (!adminConfig.playerAutoFire || !playerChar || playerChar.type !== 'player') return false;
|
| 940 |
+
if (playerChar.defaultWeaponType === WeaponType.AutoHomingRocket ||
|
| 941 |
+
playerChar.defaultWeaponType === WeaponType.SummonFishMinion ||
|
| 942 |
+
playerChar.defaultWeaponType === WeaponType.RedArrowDefense) return false;
|
| 943 |
+
if (playerChar.autoFireHealthThreshold !== undefined && playerChar.autoFireHealthThreshold > 0) { const healthPercentage = (gameCtx.playerHealth / playerData.maxHealth) * 100; return healthPercentage <= playerChar.autoFireHealthThreshold; }
|
| 944 |
+
return playerChar.autoFireHealthThreshold === 0 || playerChar.autoFireHealthThreshold === undefined;
|
| 945 |
+
};
|
| 946 |
+
if (shouldPlayerAutoFire()) firePlayerProjectile();
|
| 947 |
+
if (keysPressed[' '] && playerChar?.defaultWeaponType !== WeaponType.AutoHomingRocket && playerChar?.defaultWeaponType !== WeaponType.RedArrowDefense) firePlayerProjectile();
|
| 948 |
+
|
| 949 |
+
for (let i = projectiles.length - 1; i >= 0; i--) {
|
| 950 |
+
const p = projectiles[i];
|
| 951 |
+
if (p.gravityEffect) p.dy = (p.dy || 0) + BOMB_GRAVITY * currentSpeedMultiplier;
|
| 952 |
+
|
| 953 |
+
let projSpeedMultiplier = currentSpeedMultiplier;
|
| 954 |
+
|
| 955 |
+
if (p.owner === 'player') {
|
| 956 |
+
if (p.isHoming) {
|
| 957 |
+
let targetEntity: Enemy | Projectile | null = null;
|
| 958 |
+
if (p.type === WeaponType.RedArrowProjectile) {
|
| 959 |
+
if (p.targetProjectileId) {
|
| 960 |
+
targetEntity = projectiles.find(ep => ep.id === p.targetProjectileId && ep.owner === 'enemy') || null;
|
| 961 |
+
if (!targetEntity) {
|
| 962 |
+
const newTargetProj = findNearestEnemyProjectile(p.x, p.y, undefined, projectiles.filter(proj => proj.targetProjectileId === p.targetProjectileId && proj.id !== p.id).map(proj => proj.targetProjectileId!));
|
| 963 |
+
if (newTargetProj) { p.targetProjectileId = newTargetProj.id; targetEntity = newTargetProj; }
|
| 964 |
+
else { p.targetProjectileId = undefined; p.targetEnemyId = findNearestEnemy(p.x, p.y, undefined, projectiles.filter(proj => proj.targetEnemyId === p.targetEnemyId && proj.id !== p.id).map(proj => proj.targetEnemyId!))?.id; }
|
| 965 |
+
}
|
| 966 |
+
}
|
| 967 |
+
if (!targetEntity && p.targetEnemyId) {
|
| 968 |
+
targetEntity = enemies.find(e => e.id === p.targetEnemyId) || null;
|
| 969 |
+
if (!targetEntity) p.targetEnemyId = findNearestEnemy(p.x, p.y, undefined, projectiles.filter(proj => proj.targetEnemyId === p.targetEnemyId && proj.id !== p.id).map(proj => proj.targetEnemyId!))?.id;
|
| 970 |
+
}
|
| 971 |
+
} else if (p.targetEnemyId) {
|
| 972 |
+
targetEntity = enemies.find(e => e.id === p.targetEnemyId) || null;
|
| 973 |
+
if (!targetEntity && (p.type === WeaponType.AutoTargetMissile || p.type === WeaponType.AutoHomingRocket)) {
|
| 974 |
+
p.targetEnemyId = findNearestEnemy(p.x, p.y)?.id;
|
| 975 |
+
}
|
| 976 |
+
}
|
| 977 |
+
|
| 978 |
+
if (targetEntity) {
|
| 979 |
+
const targetSize = (targetEntity as Enemy).size || (targetEntity as Projectile).width;
|
| 980 |
+
const targetCenterX = targetEntity.x + targetSize / 2;
|
| 981 |
+
const targetCenterY = targetEntity.y + targetSize / 2;
|
| 982 |
+
const angle = Math.atan2(targetCenterY - (p.y + p.height/2) , targetCenterX - (p.x + p.width/2));
|
| 983 |
+
const speed = p.type === WeaponType.RedArrowProjectile ? (adminConfig.redArrowProjectileSpeed || RED_ARROW_PROJECTILE_SPEED) : (p.type === WeaponType.AutoHomingRocket ? AUTO_ROCKET_SPEED : MISSILE_SPEED);
|
| 984 |
+
p.dx = Math.cos(angle) * speed; p.dy = Math.sin(angle) * speed;
|
| 985 |
+
p.rotation = angle;
|
| 986 |
+
} else {
|
| 987 |
+
if (p.isHoming && !p.dx && !p.dy) {
|
| 988 |
+
const speed = p.type === WeaponType.RedArrowProjectile ? (adminConfig.redArrowProjectileSpeed || RED_ARROW_PROJECTILE_SPEED) : (p.type === WeaponType.AutoHomingRocket ? AUTO_ROCKET_SPEED : MISSILE_SPEED);
|
| 989 |
+
p.dx = lastHorizontalDirection === 'right' ? speed : -speed;
|
| 990 |
+
p.dy = 0;
|
| 991 |
+
p.rotation = lastHorizontalDirection === 'right' ? 0 : Math.PI;
|
| 992 |
+
}
|
| 993 |
+
}
|
| 994 |
+
}
|
| 995 |
+
p.x += (p.dx || 0) * projSpeedMultiplier; p.y += (p.dy || 0) * projSpeedMultiplier;
|
| 996 |
+
|
| 997 |
+
ctx.save(); ctx.translate(p.x + p.width / 2, p.y + p.height / 2);
|
| 998 |
+
ctx.rotate(p.rotation || 0 );
|
| 999 |
+
|
| 1000 |
+
const projectileImage = p.imageKey ? loadedGameAssets?.[p.imageKey] : null;
|
| 1001 |
+
if (projectileImage && projectileImage.complete && projectileImage.naturalHeight !== 0) {
|
| 1002 |
+
ctx.drawImage(projectileImage, -p.width / 2, -p.height / 2, p.width, p.height);
|
| 1003 |
+
} else if (p.type === WeaponType.RedArrowProjectile) {
|
| 1004 |
+
const redArrowImg = loadedGameAssets['redArrowImage'];
|
| 1005 |
+
if (redArrowImg && redArrowImg.complete && redArrowImg.naturalHeight !== 0) {
|
| 1006 |
+
ctx.drawImage(redArrowImg, -p.width / 2, -p.height / 2, p.width, p.height);
|
| 1007 |
+
} else {
|
| 1008 |
+
ctx.fillStyle = "red";
|
| 1009 |
+
ctx.beginPath();
|
| 1010 |
+
ctx.moveTo(p.height / 2, 0);
|
| 1011 |
+
ctx.lineTo(-p.height / 2, -p.width / 2);
|
| 1012 |
+
ctx.lineTo(-p.height / 2, p.width / 2);
|
| 1013 |
+
ctx.closePath();
|
| 1014 |
+
ctx.fill();
|
| 1015 |
+
}
|
| 1016 |
+
} else {
|
| 1017 |
+
let projColor = 'yellow';
|
| 1018 |
+
if (p.type === WeaponType.Rocket || p.type === WeaponType.AutoTargetMissile || p.type === WeaponType.AutoHomingRocket) projColor = 'orange';
|
| 1019 |
+
else if (p.type === WeaponType.Laser) projColor = 'red';
|
| 1020 |
+
else if (p.type === WeaponType.HeavyBullet) projColor = '#ADD8E6';
|
| 1021 |
+
else if (p.type === WeaponType.SwordThrow) projColor = 'silver';
|
| 1022 |
+
else if (p.type === WeaponType.Bomb) projColor = 'darkred';
|
| 1023 |
+
else if (p.type === WeaponType.FishMinionBullet) projColor = 'cyan';
|
| 1024 |
+
|
| 1025 |
+
ctx.fillStyle = projColor;
|
| 1026 |
+
ctx.fillRect(-p.width / 2, -p.height / 2, p.width, p.height);
|
| 1027 |
+
}
|
| 1028 |
+
ctx.restore();
|
| 1029 |
+
|
| 1030 |
+
|
| 1031 |
+
if (p.type === WeaponType.RedArrowProjectile) {
|
| 1032 |
+
let redArrowHit = false;
|
| 1033 |
+
for (let k = projectiles.length - 1; k >= 0; k--) {
|
| 1034 |
+
if (i === k) continue;
|
| 1035 |
+
const otherProj = projectiles[k];
|
| 1036 |
+
if (otherProj.owner === 'enemy' &&
|
| 1037 |
+
p.x < otherProj.x + otherProj.width && p.x + p.width > otherProj.x &&
|
| 1038 |
+
p.y < otherProj.y + otherProj.height && p.y + p.height > otherProj.y) {
|
| 1039 |
+
projectiles.splice(k, 1);
|
| 1040 |
+
projectiles.splice(i, 1);
|
| 1041 |
+
redArrowHit = true;
|
| 1042 |
+
playSound(SoundEvent.EnemyKill, adminConfig);
|
| 1043 |
+
break;
|
| 1044 |
+
}
|
| 1045 |
+
}
|
| 1046 |
+
if (redArrowHit) continue;
|
| 1047 |
+
|
| 1048 |
+
for (let j = enemies.length - 1; j >= 0; j--) {
|
| 1049 |
+
const enemy = enemies[j];
|
| 1050 |
+
if (p.x < enemy.x + enemy.size && p.x + p.width > enemy.x &&
|
| 1051 |
+
p.y < enemy.y + enemy.size && p.y + p.height > enemy.y) {
|
| 1052 |
+
const damageDealt = p.damage || RED_ARROW_PROJECTILE_DAMAGE;
|
| 1053 |
+
enemy.health -= damageDealt;
|
| 1054 |
+
addFloatingText(`-${Math.round(damageDealt)}`, enemy.x + enemy.size / 2, enemy.y, 'red');
|
| 1055 |
+
spawnBlood(enemy.x + enemy.size / 2, enemy.y + enemy.size / 2, 10);
|
| 1056 |
+
projectiles.splice(i, 1);
|
| 1057 |
+
if (enemy.health <= 0) { /* enemy death handled below */ }
|
| 1058 |
+
redArrowHit = true;
|
| 1059 |
+
break;
|
| 1060 |
+
}
|
| 1061 |
+
}
|
| 1062 |
+
if (redArrowHit) continue;
|
| 1063 |
+
}
|
| 1064 |
+
|
| 1065 |
+
|
| 1066 |
+
if (p.type === WeaponType.Bomb && p.creationTime && now - p.creationTime >= BOMB_FUSE_MS) {
|
| 1067 |
+
createExplosion(p.x + p.width/2, p.y + p.height/2, BOMB_EXPLOSION_RADIUS, BOMB_EXPLOSION_DURATION_MS, 'rgba(255,165,0,0.7)', BOMB_DAMAGE);
|
| 1068 |
+
playSound(SoundEvent.EnemyKill, adminConfig);
|
| 1069 |
+
projectiles.splice(i, 1); continue;
|
| 1070 |
+
}
|
| 1071 |
+
|
| 1072 |
+
if (p.type !== WeaponType.RedArrowProjectile) {
|
| 1073 |
+
for (let j = enemies.length - 1; j >= 0; j--) {
|
| 1074 |
+
const enemy = enemies[j];
|
| 1075 |
+
if (p.x < enemy.x + enemy.size && p.x + p.width > enemy.x &&
|
| 1076 |
+
p.y < enemy.y + enemy.size && p.y + p.height > enemy.y) {
|
| 1077 |
+
|
| 1078 |
+
const damageDealt = p.damage || 10;
|
| 1079 |
+
enemy.health -= damageDealt;
|
| 1080 |
+
addFloatingText(`-${Math.round(damageDealt)}`, enemy.x + enemy.size / 2, enemy.y, 'red');
|
| 1081 |
+
spawnBlood(enemy.x + enemy.size / 2, enemy.y + enemy.size / 2, 5);
|
| 1082 |
+
|
| 1083 |
+
if (p.type !== WeaponType.Laser && p.type !== WeaponType.Bomb && p.type !== WeaponType.FishMinionBullet && p.type !== WeaponType.DogeLauncher) {
|
| 1084 |
+
projectiles.splice(i, 1);
|
| 1085 |
+
} else if (p.type === WeaponType.DogeLauncher && p.imageKey) { // DogeLauncher projectiles are consumed on hit
|
| 1086 |
+
projectiles.splice(i,1);
|
| 1087 |
+
}
|
| 1088 |
+
|
| 1089 |
+
|
| 1090 |
+
if (enemy.health <= 0) {
|
| 1091 |
+
} else {
|
| 1092 |
+
gameCtx.gameSpeedMultiplier = FAST_SPEED_MULTIPLIER;
|
| 1093 |
+
gameCtx.speedChangeActiveUntil = now + SPEED_CHANGE_DURATION_MS;
|
| 1094 |
+
}
|
| 1095 |
+
break;
|
| 1096 |
+
}
|
| 1097 |
+
}
|
| 1098 |
+
}
|
| 1099 |
+
|
| 1100 |
+
} else { // Enemy projectile
|
| 1101 |
+
p.x += (p.dx || 0) * projSpeedMultiplier; p.y += (p.dy || 0) * projSpeedMultiplier;
|
| 1102 |
+
ctx.fillStyle = 'magenta';
|
| 1103 |
+
ctx.save(); ctx.translate(p.x + p.width / 2, p.y + p.height / 2); ctx.rotate(p.rotation || 0);
|
| 1104 |
+
ctx.fillRect(-p.width / 2, -p.height / 2, p.width, p.height);
|
| 1105 |
+
ctx.restore();
|
| 1106 |
+
|
| 1107 |
+
if (p.x < playerPos.x + PLAYER_SIZE && p.x + p.width > playerPos.x &&
|
| 1108 |
+
p.y < playerPos.y + PLAYER_SIZE && p.y + p.height > playerPos.y) {
|
| 1109 |
+
projectiles.splice(i, 1);
|
| 1110 |
+
gameCtx.playerHealth -= (p.damage || 10);
|
| 1111 |
+
spawnBlood(playerPos.x + PLAYER_SIZE/2, playerPos.y + PLAYER_SIZE/2, 8, 'rgba(0,100,255,');
|
| 1112 |
+
addFloatingText(`-${p.damage || 10}`, playerPos.x, playerPos.y, 'orange');
|
| 1113 |
+
gameCtx.gameSpeedMultiplier = SLOW_SPEED_MULTIPLIER;
|
| 1114 |
+
gameCtx.speedChangeActiveUntil = now + SPEED_CHANGE_DURATION_MS;
|
| 1115 |
+
if (gameCtx.playerHealth <= 0) { onGameOver(score); return; }
|
| 1116 |
+
}
|
| 1117 |
+
}
|
| 1118 |
+
if (p.x > ctx.canvas.width || p.x + p.width < 0 || p.y > ctx.canvas.height || p.y + p.height < 0) {
|
| 1119 |
+
projectiles.splice(i, 1);
|
| 1120 |
+
}
|
| 1121 |
+
}
|
| 1122 |
+
|
| 1123 |
+
for (let i = activeExplosions.length - 1; i >= 0; i--) {
|
| 1124 |
+
const explosion = activeExplosions[i]; const elapsed = now - explosion.creationTime;
|
| 1125 |
+
if (elapsed >= explosion.duration) { activeExplosions.splice(i, 1); continue; }
|
| 1126 |
+
const progress = elapsed / explosion.duration; explosion.currentRadius = explosion.maxRadius * progress;
|
| 1127 |
+
const opacity = 1 - progress;
|
| 1128 |
+
ctx.beginPath(); ctx.arc(explosion.x, explosion.y, explosion.currentRadius, 0, Math.PI * 2);
|
| 1129 |
+
ctx.fillStyle = explosion.color.replace(/[^,]+(?=\))/, opacity.toFixed(2)); ctx.fill();
|
| 1130 |
+
}
|
| 1131 |
+
|
| 1132 |
+
for (let i = fishMinions.length - 1; i >= 0; i--) {
|
| 1133 |
+
const minion = fishMinions[i];
|
| 1134 |
+
const targetX = playerPos.x + PLAYER_SIZE / 2 + Math.cos(minion.angleToPlayer) * minion.distanceFromPlayer;
|
| 1135 |
+
const targetY = playerPos.y + PLAYER_SIZE / 2 + Math.sin(minion.angleToPlayer) * minion.distanceFromPlayer;
|
| 1136 |
+
minion.x += (targetX - minion.x) * FISH_MINION_FOLLOW_LERP_FACTOR * currentSpeedMultiplier;
|
| 1137 |
+
minion.y += (targetY - minion.y) * FISH_MINION_FOLLOW_LERP_FACTOR * currentSpeedMultiplier;
|
| 1138 |
+
minion.angleToPlayer += 0.005 * currentSpeedMultiplier;
|
| 1139 |
+
const minionImage = loadedGameAssets?.['seaToken'] || null;
|
| 1140 |
+
if (minionImage) ctx.drawImage(minionImage, minion.x, minion.y, minion.size, minion.size);
|
| 1141 |
+
else { ctx.fillStyle = 'aqua'; ctx.beginPath(); ctx.arc(minion.x + minion.size / 2, minion.y + minion.size / 2, minion.size / 2, 0, Math.PI * 2); ctx.fill(); }
|
| 1142 |
+
ctx.fillStyle = 'grey'; ctx.fillRect(minion.x, minion.y - 8, minion.size, 4);
|
| 1143 |
+
ctx.fillStyle = 'green'; ctx.fillRect(minion.x, minion.y - 8, minion.size * (minion.health / minion.maxHealth), 4);
|
| 1144 |
+
if (!minion.targetEnemyId || !enemies.find(e => e.id === minion.targetEnemyId)) minion.targetEnemyId = findNearestEnemy(minion.x, minion.y, FISH_MINION_ATTACK_RANGE)?.id || null;
|
| 1145 |
+
if (minion.targetEnemyId && now - minion.lastFireTime > FISH_MINION_FIRE_COOLDOWN_MS / currentSpeedMultiplier) {
|
| 1146 |
+
const target = enemies.find(e => e.id === minion.targetEnemyId);
|
| 1147 |
+
if (target) fireFishMinionProjectile(minion, target); else minion.targetEnemyId = null;
|
| 1148 |
+
}
|
| 1149 |
+
}
|
| 1150 |
+
|
| 1151 |
+
if (now - lastEnemySpawnTime > enemySpawnInterval / currentSpeedMultiplier) { spawnEnemy(); lastEnemySpawnTime = now; }
|
| 1152 |
+
|
| 1153 |
+
for (let i = enemies.length - 1; i >= 0; i--) {
|
| 1154 |
+
const enemy = enemies[i];
|
| 1155 |
+
if (!enemy) continue; // Should not happen, but defensive check
|
| 1156 |
+
|
| 1157 |
+
enemy.x += enemy.speedX * currentSpeedMultiplier;
|
| 1158 |
+
enemy.y += enemy.speedY * currentSpeedMultiplier;
|
| 1159 |
+
enemy.isFacingRight = enemy.speedX > 0;
|
| 1160 |
+
|
| 1161 |
+
// Process Burn DoT & Fire Exposure
|
| 1162 |
+
if (enemy.isBurning && enemy.health > 0) {
|
| 1163 |
+
enemy.accumulatedFireExposureMs = (enemy.accumulatedFireExposureMs || 0) + timeDelta;
|
| 1164 |
+
enemy.burnTickCooldownRemaining = (enemy.burnTickCooldownRemaining || 0) - timeDelta;
|
| 1165 |
+
|
| 1166 |
+
if (enemy.burnTickCooldownRemaining <= 0) {
|
| 1167 |
+
if (enemy.burnTicksRemaining && enemy.burnTicksRemaining > 0) {
|
| 1168 |
+
const burnDmg = enemy.burnDamagePerTick || 0;
|
| 1169 |
+
enemy.health -= burnDmg;
|
| 1170 |
+
addFloatingText(`-${Math.round(burnDmg)}🔥`, enemy.x + enemy.size / 2, enemy.y - 5, 'red');
|
| 1171 |
+
spawnBlood(enemy.x + enemy.size / 2, enemy.y + enemy.size / 2, 1, 'rgba(255,0,0,');
|
| 1172 |
+
enemy.burnTicksRemaining--;
|
| 1173 |
+
enemy.burnTickCooldownRemaining = FLAME_CONE_DOT_TICK_INTERVAL_MS;
|
| 1174 |
+
}
|
| 1175 |
+
if (!enemy.burnTicksRemaining || enemy.burnTicksRemaining <= 0) {
|
| 1176 |
+
enemy.isBurning = false;
|
| 1177 |
+
// If burn ended naturally and didn't explode, reset exposure
|
| 1178 |
+
if ( (enemy.accumulatedFireExposureMs || 0) < EXPLOSIVE_CORE_THRESHOLD_MS) {
|
| 1179 |
+
enemy.accumulatedFireExposureMs = 0;
|
| 1180 |
+
}
|
| 1181 |
+
}
|
| 1182 |
+
}
|
| 1183 |
+
} else if (enemy.health > 0) { // Not burning
|
| 1184 |
+
// If not burning and didn't explode, ensure exposure is reset.
|
| 1185 |
+
// This mainly catches cases where an enemy might have had exposure but burn stopped.
|
| 1186 |
+
if ((enemy.accumulatedFireExposureMs || 0) > 0 && (enemy.accumulatedFireExposureMs || 0) < EXPLOSIVE_CORE_THRESHOLD_MS){
|
| 1187 |
+
enemy.accumulatedFireExposureMs = 0;
|
| 1188 |
+
}
|
| 1189 |
+
}
|
| 1190 |
+
|
| 1191 |
+
// Check for Explosive Core
|
| 1192 |
+
if (enemy.health > 0 && (enemy.accumulatedFireExposureMs || 0) >= EXPLOSIVE_CORE_THRESHOLD_MS) {
|
| 1193 |
+
const alreadyChainedThisExplosion = new Set<string>();
|
| 1194 |
+
triggerExplosiveCore(enemy, alreadyChainedThisExplosion, gameCtx);
|
| 1195 |
+
// triggerExplosiveCore will set enemy.isBurning = false and reset enemy.accumulatedFireExposureMs
|
| 1196 |
+
}
|
| 1197 |
+
|
| 1198 |
+
|
| 1199 |
+
// Drawing enemy and health bar
|
| 1200 |
+
const enemyCharDetails = runtimeGameCharacters[enemy.characterId];
|
| 1201 |
+
const enemyImage = enemyCharDetails && loadedGameAssets[`enemyChar_${enemyCharDetails.id}`] ? loadedGameAssets[`enemyChar_${enemyCharDetails.id}`] : loadedGameAssets['defaultEnemy'];
|
| 1202 |
+
|
| 1203 |
+
ctx.save();
|
| 1204 |
+
const heatAmount = (enemy.health > 0 && (enemy.accumulatedFireExposureMs || 0) > 0) ? Math.min(1, (enemy.accumulatedFireExposureMs || 0) / EXPLOSIVE_CORE_THRESHOLD_MS) : 0;
|
| 1205 |
+
|
| 1206 |
+
if (enemy.isBurning || heatAmount > 0) {
|
| 1207 |
+
ctx.globalAlpha = 1.0; // Reset global alpha before specific drawing
|
| 1208 |
+
if (enemy.isBurning) {
|
| 1209 |
+
for (let k = 0; k < 3; k++) {
|
| 1210 |
+
ctx.fillStyle = Math.random() < 0.5 ? 'rgba(255,165,0,0.7)' : 'rgba(255,0,0,0.6)';
|
| 1211 |
+
const particleX = enemy.x + Math.random() * enemy.size;
|
| 1212 |
+
const particleY = enemy.y + Math.random() * enemy.size - (now % 1000 / 100);
|
| 1213 |
+
ctx.beginPath();
|
| 1214 |
+
ctx.arc(particleX, particleY, Math.random() * 2 + 1, 0, Math.PI * 2);
|
| 1215 |
+
ctx.fill();
|
| 1216 |
+
}
|
| 1217 |
+
}
|
| 1218 |
+
}
|
| 1219 |
+
|
| 1220 |
+
ctx.translate(enemy.x + enemy.size / 2, enemy.y + enemy.size / 2);
|
| 1221 |
+
if (!enemy.isFacingRight && enemyImage) ctx.scale(-1, 1);
|
| 1222 |
+
|
| 1223 |
+
if (enemyImage) {
|
| 1224 |
+
ctx.drawImage(enemyImage, -enemy.size / 2, -enemy.size / 2, enemy.size, enemy.size);
|
| 1225 |
+
if (heatAmount > 0) { // Apply orange tint for heating up
|
| 1226 |
+
ctx.globalAlpha = heatAmount * 0.4; // Tint intensity
|
| 1227 |
+
ctx.fillStyle = 'orange';
|
| 1228 |
+
ctx.fillRect(-enemy.size / 2, -enemy.size / 2, enemy.size, enemy.size);
|
| 1229 |
+
}
|
| 1230 |
+
} else {
|
| 1231 |
+
ctx.font = `${enemy.size * 0.8}px Arial`;
|
| 1232 |
+
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
| 1233 |
+
const name = enemyCharDetails?.name || "E";
|
| 1234 |
+
const emojiMatch = name.match(/\p{Emoji}/u);
|
| 1235 |
+
const charToDraw = emojiMatch ? emojiMatch[0] : name.charAt(0);
|
| 1236 |
+
ctx.fillText(charToDraw, 0, 0);
|
| 1237 |
+
if (heatAmount > 0) { // Apply orange tint for heating up (fallback)
|
| 1238 |
+
ctx.globalAlpha = heatAmount * 0.4;
|
| 1239 |
+
ctx.fillStyle = 'orange';
|
| 1240 |
+
// Approximate fill for text based character
|
| 1241 |
+
const textMetrics = ctx.measureText(charToDraw);
|
| 1242 |
+
const actualWidth = textMetrics.actualBoundingBoxRight - textMetrics.actualBoundingBoxLeft;
|
| 1243 |
+
const actualHeight = textMetrics.actualBoundingBoxAscent + textMetrics.actualBoundingBoxDescent;
|
| 1244 |
+
ctx.fillRect(-actualWidth / 2, -actualHeight / 2, actualWidth, actualHeight);
|
| 1245 |
+
}
|
| 1246 |
+
}
|
| 1247 |
+
ctx.restore();
|
| 1248 |
+
|
| 1249 |
+
if (enemy.health > 0) { // Only draw health bar for living enemies
|
| 1250 |
+
ctx.fillStyle = 'grey'; ctx.fillRect(enemy.x, enemy.y - 8, enemy.size, 4);
|
| 1251 |
+
ctx.fillStyle = enemy.isBurning || heatAmount > 0.5 ? 'darkorange' : 'red';
|
| 1252 |
+
ctx.fillRect(enemy.x, enemy.y - 8, enemy.size * (enemy.health / Math.max(1, enemy.maxHealth)), 4);
|
| 1253 |
+
}
|
| 1254 |
+
|
| 1255 |
+
|
| 1256 |
+
// Death and Off-screen Removal
|
| 1257 |
+
if (enemy.health <= 0) {
|
| 1258 |
+
onGainSeaTokensAndXP(TOKEN_VALUE_ON_COLLECT, (runtimeGameCharacters[enemy.characterId]?.baseHealth || ENEMY_BASE_HEALTH) / 2);
|
| 1259 |
+
gameCtx.score += 10;
|
| 1260 |
+
addFloatingText('+10 Score', enemy.x, enemy.y - 10, 'lime');
|
| 1261 |
+
enemies.splice(i, 1);
|
| 1262 |
+
playSound(SoundEvent.EnemyKill, adminConfig);
|
| 1263 |
+
gameCtx.gameSpeedMultiplier = KILL_SPEED_MULTIPLIER;
|
| 1264 |
+
gameCtx.speedChangeActiveUntil = now + KILL_SPEED_DURATION_MS;
|
| 1265 |
+
if (gameCtx.seaTokensOnScreen.length < MAX_SEA_TOKENS_ON_SCREEN) {
|
| 1266 |
+
gameCtx.seaTokensOnScreen.push({
|
| 1267 |
+
id: generateId(), x: enemy.x + enemy.size / 2, y: enemy.y + enemy.size / 2,
|
| 1268 |
+
value: TOKEN_VALUE_ON_COLLECT, creationTime: now, isAttracting: true
|
| 1269 |
+
});
|
| 1270 |
+
}
|
| 1271 |
+
} else if ((enemy.speedX < 0 && enemy.x + enemy.size < 0) || (enemy.speedX > 0 && enemy.x > ctx.canvas.width) || enemy.y + enemy.size < 0 || enemy.y > ctx.canvas.height) {
|
| 1272 |
+
enemies.splice(i, 1);
|
| 1273 |
+
}
|
| 1274 |
+
|
| 1275 |
+
if (enemy.health > 0 && adminConfig.enemiesAutoTarget && now - enemy.lastFireTime > adminConfig.enemyFireCooldownMs / currentSpeedMultiplier) fireEnemyProjectile(enemy);
|
| 1276 |
+
};
|
| 1277 |
+
|
| 1278 |
+
floatingTexts.forEach((ft, index) => {
|
| 1279 |
+
ft.y -= 0.5 * currentSpeedMultiplier; ctx.fillStyle = ft.color; ctx.font = 'bold 12px Orbitron, sans-serif';
|
| 1280 |
+
ctx.fillText(ft.text, ft.x, ft.y);
|
| 1281 |
+
if (now - ft.timestamp > FLOATING_TEXT_DURATION_MS) floatingTexts.splice(index, 1);
|
| 1282 |
+
});
|
| 1283 |
+
|
| 1284 |
+
animationFrameId = requestAnimationFrame(gameLoop);
|
| 1285 |
+
};
|
| 1286 |
+
animationFrameId = requestAnimationFrame(gameLoop);
|
| 1287 |
+
return () => { cancelAnimationFrame(animationFrameId); };
|
| 1288 |
+
}, [
|
| 1289 |
+
playerData, onGameOver, onGainSeaTokensAndXP, addFloatingText, fireEnemyProjectile,
|
| 1290 |
+
firePlayerProjectile, spawnEnemy, findNearestEnemy, findNearestEnemyProjectile, getPlayerSpeed, getPlayerDamageMultiplier,
|
| 1291 |
+
createExplosion, runtimeGameCharacters, adminConfig, loadedGameAssets, fireFishMinionProjectile, spawnBlood, triggerExplosiveCore
|
| 1292 |
+
]);
|
| 1293 |
+
|
| 1294 |
+
return (
|
| 1295 |
+
<canvas
|
| 1296 |
+
ref={canvasRef}
|
| 1297 |
+
width={800}
|
| 1298 |
+
height={600}
|
| 1299 |
+
className="border-2 border-theme-accent shadow-2xl rounded-lg bg-transparent"
|
| 1300 |
+
aria-label="Doge Whale Wars Game Canvas"
|
| 1301 |
+
/>
|
| 1302 |
+
);
|
| 1303 |
+
};
|
Modal.tsx
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React from 'react';
|
| 3 |
+
|
| 4 |
+
interface ModalProps {
|
| 5 |
+
isOpen: boolean;
|
| 6 |
+
onClose: () => void;
|
| 7 |
+
title: string;
|
| 8 |
+
children: React.ReactNode;
|
| 9 |
+
size?: 'sm' | 'md' | 'lg' | 'xl';
|
| 10 |
+
isMaximizable?: boolean;
|
| 11 |
+
isMaximized?: boolean;
|
| 12 |
+
onToggleMaximize?: () => void;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
const MaximizeIcon: React.FC = () => (
|
| 16 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-4 h-4">
|
| 17 |
+
<path d="M8 3H5a2 2 0 0 0-2 2v3m18 0V5a2 2 0 0 0-2-2h-3m0 18h3a2 2 0 0 0 2-2v-3M3 16v3a2 2 0 0 0 2 2h3"></path>
|
| 18 |
+
</svg>
|
| 19 |
+
);
|
| 20 |
+
|
| 21 |
+
const RestoreIcon: React.FC = () => (
|
| 22 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="w-4 h-4">
|
| 23 |
+
<path d="M8 3v3a2 2 0 0 1-2 2H3m18 0h-3a2 2 0 0 1-2-2V3m0 18v-3a2 2 0 0 0-2-2h-3M3 16h3a2 2 0 0 0 2-2v-3"></path>
|
| 24 |
+
</svg>
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
export const Modal: React.FC<ModalProps> = ({
|
| 29 |
+
isOpen,
|
| 30 |
+
onClose,
|
| 31 |
+
title,
|
| 32 |
+
children,
|
| 33 |
+
size = 'md',
|
| 34 |
+
isMaximizable = false,
|
| 35 |
+
isMaximized = false,
|
| 36 |
+
onToggleMaximize
|
| 37 |
+
}) => {
|
| 38 |
+
if (!isOpen) return null;
|
| 39 |
+
|
| 40 |
+
const sizeClasses = {
|
| 41 |
+
sm: 'max-w-md',
|
| 42 |
+
md: 'max-w-xl',
|
| 43 |
+
lg: 'max-w-3xl',
|
| 44 |
+
xl: 'max-w-5xl',
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
const titleId = 'modal-title-id'; // Unique ID for aria-labelledby
|
| 48 |
+
|
| 49 |
+
const outerDialogClasses = [
|
| 50 |
+
"fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm animate-fadeIn",
|
| 51 |
+
isMaximized ? "p-0" : "p-4",
|
| 52 |
+
].join(' ');
|
| 53 |
+
|
| 54 |
+
const innerDialogClasses = [
|
| 55 |
+
"bg-theme-dark border-2 border-theme-border shadow-2xl relative flex flex-col",
|
| 56 |
+
isMaximized
|
| 57 |
+
? "w-screen h-screen max-w-full max-h-full rounded-none p-4"
|
| 58 |
+
: `rounded-xl p-6 ${sizeClasses[size]} max-h-[90vh] w-full`,
|
| 59 |
+
"animate-slideDown",
|
| 60 |
+
].join(' ');
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
return (
|
| 64 |
+
<div
|
| 65 |
+
className={outerDialogClasses}
|
| 66 |
+
role="dialog"
|
| 67 |
+
aria-modal="true"
|
| 68 |
+
aria-labelledby={titleId}
|
| 69 |
+
>
|
| 70 |
+
<div className={innerDialogClasses}>
|
| 71 |
+
<div className="flex justify-between items-center mb-4 pb-4 border-b border-theme-border">
|
| 72 |
+
<div className="flex items-center gap-3">
|
| 73 |
+
<button
|
| 74 |
+
onClick={onClose}
|
| 75 |
+
className="w-8 h-8 flex items-center justify-center rounded-full bg-pink-600 hover:bg-pink-700 text-white text-lg transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
| 76 |
+
aria-label="Go back"
|
| 77 |
+
>
|
| 78 |
+
← {/* Left arrow HTML entity */}
|
| 79 |
+
</button>
|
| 80 |
+
<h2 id={titleId} className="text-2xl font-bold text-theme-accent">{title}</h2>
|
| 81 |
+
</div>
|
| 82 |
+
<div className="flex items-center gap-2">
|
| 83 |
+
{isMaximizable && onToggleMaximize && (
|
| 84 |
+
<button
|
| 85 |
+
onClick={onToggleMaximize}
|
| 86 |
+
className="w-8 h-8 flex items-center justify-center rounded-full bg-pink-600 hover:bg-pink-700 text-white transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
| 87 |
+
aria-label={isMaximized ? "Restore modal size" : "Maximize modal"}
|
| 88 |
+
>
|
| 89 |
+
{isMaximized ? <RestoreIcon /> : <MaximizeIcon />}
|
| 90 |
+
</button>
|
| 91 |
+
)}
|
| 92 |
+
<button
|
| 93 |
+
onClick={onClose}
|
| 94 |
+
className="w-8 h-8 flex items-center justify-center rounded-full bg-pink-600 hover:bg-pink-700 text-white text-xl transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
| 95 |
+
aria-label="Close modal"
|
| 96 |
+
>
|
| 97 |
+
×
|
| 98 |
+
</button>
|
| 99 |
+
</div>
|
| 100 |
+
</div>
|
| 101 |
+
<div className="overflow-y-auto pr-2 flex-grow">
|
| 102 |
+
{children}
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
);
|
| 107 |
+
};
|
NftMarketplaceModal.tsx
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect, useCallback } from 'react';
|
| 3 |
+
import { Modal } from '../Modal.tsx';
|
| 4 |
+
import { NFT, Rarity, PlayerData, BreedingParents, BreedingResult, MarketplaceTabType, AdminConfig, NFTCoinEffect } from '../../types.ts';
|
| 5 |
+
import { BREEDING_COMBINATIONS, BREEDING_TIME_SECONDS } from '../../constants.ts';
|
| 6 |
+
|
| 7 |
+
interface NftMarketplaceModalProps {
|
| 8 |
+
isOpen: boolean;
|
| 9 |
+
onClose: () => void;
|
| 10 |
+
playerData: PlayerData;
|
| 11 |
+
runtimeNftDefinitions: Record<string, NFT>;
|
| 12 |
+
onPurchaseNft: (nftId: string) => void;
|
| 13 |
+
onEquipNft: (nftId: string) => void;
|
| 14 |
+
onBreedNfts: (nftId: string) => void;
|
| 15 |
+
onStakeNft: (nftId: string, slotIndex?: number) => void; // slotIndex is optional here for compatibility
|
| 16 |
+
onUnstakeNft: (nftId: string, slotIndex?: number) => void; // slotIndex is optional
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const NFTCard: React.FC<{
|
| 20 |
+
nft: NFT;
|
| 21 |
+
isOwned: boolean;
|
| 22 |
+
isEquipped: boolean;
|
| 23 |
+
isStakedInSlot: boolean; // Updated prop
|
| 24 |
+
canAfford: boolean;
|
| 25 |
+
onPurchase?: () => void;
|
| 26 |
+
onEquip?: () => void;
|
| 27 |
+
onSelectForBreeding?: () => void;
|
| 28 |
+
isSelectedForBreeding?: boolean;
|
| 29 |
+
isBreedingMode?: boolean;
|
| 30 |
+
isMarketView?: boolean;
|
| 31 |
+
onGoToStaking?: () => void;
|
| 32 |
+
}> = ({ nft, isOwned, isEquipped, isStakedInSlot, canAfford, onPurchase, onEquip, onSelectForBreeding, isSelectedForBreeding, isBreedingMode, isMarketView, onGoToStaking }) => {
|
| 33 |
+
|
| 34 |
+
const rarityColor = {
|
| 35 |
+
[Rarity.Common]: 'bg-theme-success', // This is not a button, so not changing
|
| 36 |
+
[Rarity.Rare]: 'bg-theme-accent animate-pulseCustom', // This is not a button
|
| 37 |
+
[Rarity.Legendary]: 'bg-theme-warning animate-spin', // This is not a button
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
const cardClasses = [
|
| 41 |
+
'nft-card bg-theme-dark border border-theme-border rounded-lg p-4 flex flex-col justify-between transition-all duration-300 hover:shadow-xl hover:-translate-y-1',
|
| 42 |
+
isSelectedForBreeding ? 'ring-2 ring-pink-500' : '', // Changed ring color
|
| 43 |
+
isStakedInSlot && !isBreedingMode ? 'border-green-500 shadow-green-500/30' : ''
|
| 44 |
+
].join(' ');
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
return (
|
| 48 |
+
<div className={cardClasses}>
|
| 49 |
+
<div className="relative">
|
| 50 |
+
<img src={nft.image} alt={nft.name} className="w-full h-40 object-contain rounded-md mb-2 bg-black/20" />
|
| 51 |
+
<span className={`token-counter absolute top-1 right-1 px-2 py-1 text-xs font-bold text-white rounded ${rarityColor[nft.rarity]}`}>
|
| 52 |
+
{nft.rarity.toUpperCase()}
|
| 53 |
+
</span>
|
| 54 |
+
{isStakedInSlot && (
|
| 55 |
+
<span className="absolute top-1 left-1 px-2 py-1 text-xs font-bold text-theme-dark bg-green-400 rounded">STAKED IN SLOT</span>
|
| 56 |
+
)}
|
| 57 |
+
</div>
|
| 58 |
+
<h4 className="text-lg font-semibold text-theme-accent mb-1">{nft.name}</h4>
|
| 59 |
+
<p className="text-xs text-gray-400 mb-1 h-10 overflow-y-auto">{nft.description}</p>
|
| 60 |
+
{!isBreedingMode && nft.price > 0 && <p className="text-sm font-bold text-theme-warning mb-2">Price: {nft.price} Sea Tokens</p>}
|
| 61 |
+
|
| 62 |
+
<div className="mt-auto space-y-2">
|
| 63 |
+
{isBreedingMode ? (
|
| 64 |
+
<button
|
| 65 |
+
onClick={onSelectForBreeding}
|
| 66 |
+
disabled={isStakedInSlot}
|
| 67 |
+
className={`w-full py-2 px-4 text-sm font-bold rounded-md transition-colors duration-200 text-white ${isSelectedForBreeding ? 'bg-pink-700' : 'bg-pink-600 hover:bg-pink-700'} ${isStakedInSlot ? 'bg-pink-400 text-gray-100 cursor-not-allowed' : ''}`}
|
| 68 |
+
title={isStakedInSlot ? "Unstake NFT from Staking Vault slot to use for breeding" : ""}
|
| 69 |
+
>
|
| 70 |
+
{isStakedInSlot ? 'Staked (Unstake to Breed)' : (isSelectedForBreeding ? 'Selected' : 'Select for Breeding')}
|
| 71 |
+
</button>
|
| 72 |
+
) : isOwned ? (
|
| 73 |
+
<>
|
| 74 |
+
{isEquipped ? (
|
| 75 |
+
<span className="block w-full py-2 px-4 text-sm font-bold rounded-md bg-pink-700/30 text-pink-400 text-center">Equipped</span> // Adjusted equipped span
|
| 76 |
+
) : (
|
| 77 |
+
<button
|
| 78 |
+
onClick={onEquip}
|
| 79 |
+
disabled={isStakedInSlot}
|
| 80 |
+
className={`w-full py-2 px-4 text-sm font-bold rounded-md text-white transition-colors duration-200 ${isStakedInSlot ? 'bg-pink-400 text-gray-100 opacity-50 cursor-not-allowed' : 'bg-pink-600 hover:bg-pink-700'}`}
|
| 81 |
+
title={isStakedInSlot ? "Unstake NFT from Staking Vault slot to equip" : "Equip this NFT"}
|
| 82 |
+
>
|
| 83 |
+
{isStakedInSlot ? 'Staked (Unstake to Equip)' : 'Equip'}
|
| 84 |
+
</button>
|
| 85 |
+
)}
|
| 86 |
+
{!isMarketView && isStakedInSlot && onGoToStaking && (
|
| 87 |
+
<button
|
| 88 |
+
onClick={onGoToStaking}
|
| 89 |
+
className="w-full py-2 px-4 text-sm font-bold rounded-md bg-pink-600 hover:bg-pink-700 text-white transition-colors duration-200"
|
| 90 |
+
>
|
| 91 |
+
Manage Staking
|
| 92 |
+
</button>
|
| 93 |
+
)}
|
| 94 |
+
{!isMarketView && !isStakedInSlot && onGoToStaking && (
|
| 95 |
+
<button
|
| 96 |
+
onClick={onGoToStaking}
|
| 97 |
+
className="w-full py-2 px-4 text-sm font-bold rounded-md bg-pink-600 hover:bg-pink-700 text-white transition-colors duration-200"
|
| 98 |
+
>
|
| 99 |
+
Go to Stake
|
| 100 |
+
</button>
|
| 101 |
+
)}
|
| 102 |
+
</>
|
| 103 |
+
) : (
|
| 104 |
+
<button
|
| 105 |
+
onClick={onPurchase}
|
| 106 |
+
disabled={!canAfford}
|
| 107 |
+
className={`w-full py-2 px-4 text-sm font-bold rounded-md transition-colors duration-200 text-white ${canAfford ? 'bg-pink-600 hover:bg-pink-700' : 'bg-pink-400 text-gray-100 cursor-not-allowed'}`}
|
| 108 |
+
>
|
| 109 |
+
{canAfford ? 'Buy Now' : 'Too Expensive'}
|
| 110 |
+
</button>
|
| 111 |
+
)}
|
| 112 |
+
{isOwned && !isBreedingMode && (
|
| 113 |
+
<p className={`text-xs text-center mt-1 ${isStakedInSlot ? 'text-green-400' : 'text-gray-500'}`}>
|
| 114 |
+
{isStakedInSlot ? 'Status: Staked in Slot' : 'Status: Owned'}
|
| 115 |
+
</p>
|
| 116 |
+
)}
|
| 117 |
+
</div>
|
| 118 |
+
</div>
|
| 119 |
+
);
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
export const NftMarketplaceModal: React.FC<NftMarketplaceModalProps> = ({ isOpen, onClose, playerData, runtimeNftDefinitions, onPurchaseNft, onEquipNft, onBreedNfts, onStakeNft, onUnstakeNft }) => {
|
| 124 |
+
const [activeTab, setActiveTab] = useState<MarketplaceTabType>('market');
|
| 125 |
+
const [breedingParents, setBreedingParents] = useState<BreedingParents>({ parent1: null, parent2: null });
|
| 126 |
+
const [breedingResultPreview, setBreedingResultPreview] = useState<BreedingResult | null>(null);
|
| 127 |
+
const [isBreeding, setIsBreeding] = useState(false);
|
| 128 |
+
const [breedingProgress, setBreedingProgress] = useState(0);
|
| 129 |
+
const [breedingTimeLeft, setBreedingTimeLeft] = useState(BREEDING_TIME_SECONDS);
|
| 130 |
+
|
| 131 |
+
const ownedNfts = playerData.nftInventory.map(id => runtimeNftDefinitions[id]).filter(Boolean);
|
| 132 |
+
|
| 133 |
+
const handlePurchase = (nftId: string) => {
|
| 134 |
+
if (playerData.seaTokens >= runtimeNftDefinitions[nftId].price) {
|
| 135 |
+
onPurchaseNft(nftId);
|
| 136 |
+
}
|
| 137 |
+
};
|
| 138 |
+
|
| 139 |
+
const handleSelectForBreeding = (nftId: string) => {
|
| 140 |
+
if (playerData.stakedNftSlots.includes(nftId)) return;
|
| 141 |
+
|
| 142 |
+
setBreedingParents(prev => {
|
| 143 |
+
if (prev.parent1 === nftId) return { ...prev, parent1: null };
|
| 144 |
+
if (prev.parent2 === nftId) return { ...prev, parent2: null };
|
| 145 |
+
if (!prev.parent1) return { ...prev, parent1: nftId };
|
| 146 |
+
if (!prev.parent2) return { ...prev, parent2: nftId };
|
| 147 |
+
return prev;
|
| 148 |
+
});
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
+
useEffect(() => {
|
| 152 |
+
if (breedingParents.parent1 && breedingParents.parent2) {
|
| 153 |
+
const sortedIds = [breedingParents.parent1, breedingParents.parent2].sort();
|
| 154 |
+
const combinationKey = `${sortedIds[0]}_${sortedIds[1]}`;
|
| 155 |
+
const result = BREEDING_COMBINATIONS[combinationKey];
|
| 156 |
+
if (result) {
|
| 157 |
+
setBreedingResultPreview(result);
|
| 158 |
+
} else {
|
| 159 |
+
const p1 = runtimeNftDefinitions[breedingParents.parent1];
|
| 160 |
+
const p2 = runtimeNftDefinitions[breedingParents.parent2];
|
| 161 |
+
setBreedingResultPreview({
|
| 162 |
+
name: `Hybrid ${p1.name.substring(0,3)}-${p2.name.substring(0,3)}`,
|
| 163 |
+
description: "A new unique whale!",
|
| 164 |
+
rarity: Math.random() > 0.5 ? p1.rarity : p2.rarity,
|
| 165 |
+
image: runtimeNftDefinitions.doge_whale.image,
|
| 166 |
+
effect: {},
|
| 167 |
+
});
|
| 168 |
+
}
|
| 169 |
+
} else {
|
| 170 |
+
setBreedingResultPreview(null);
|
| 171 |
+
}
|
| 172 |
+
}, [breedingParents, runtimeNftDefinitions]);
|
| 173 |
+
|
| 174 |
+
const startBreedingProcess = useCallback(() => {
|
| 175 |
+
if (!breedingParents.parent1 || !breedingParents.parent2 || !breedingResultPreview) return;
|
| 176 |
+
if (playerData.stakedNftSlots.includes(breedingParents.parent1) || playerData.stakedNftSlots.includes(breedingParents.parent2) ) {
|
| 177 |
+
alert("Cannot breed with staked NFTs. Please unstake them first from the Staking Vault slots.");
|
| 178 |
+
return;
|
| 179 |
+
}
|
| 180 |
+
setIsBreeding(true);
|
| 181 |
+
setBreedingProgress(0);
|
| 182 |
+
setBreedingTimeLeft(BREEDING_TIME_SECONDS);
|
| 183 |
+
|
| 184 |
+
const interval = setInterval(() => {
|
| 185 |
+
setBreedingProgress(prev => {
|
| 186 |
+
const newProgress = prev + (100 / BREEDING_TIME_SECONDS);
|
| 187 |
+
if (newProgress >= 100) {
|
| 188 |
+
clearInterval(interval);
|
| 189 |
+
setIsBreeding(false);
|
| 190 |
+
const bredNftId = breedingResultPreview.name.toLowerCase().replace(/\s+/g, '_');
|
| 191 |
+
onBreedNfts(bredNftId);
|
| 192 |
+
setBreedingParents({ parent1: null, parent2: null });
|
| 193 |
+
setBreedingResultPreview(null);
|
| 194 |
+
return 100;
|
| 195 |
+
}
|
| 196 |
+
return newProgress;
|
| 197 |
+
});
|
| 198 |
+
setBreedingTimeLeft(prev => Math.max(0, prev - 1));
|
| 199 |
+
}, 1000);
|
| 200 |
+
}, [breedingParents, breedingResultPreview, onBreedNfts, playerData.stakedNftSlots]);
|
| 201 |
+
|
| 202 |
+
const resetModalState = () => {
|
| 203 |
+
setActiveTab('market');
|
| 204 |
+
setBreedingParents({ parent1: null, parent2: null });
|
| 205 |
+
setBreedingResultPreview(null);
|
| 206 |
+
setIsBreeding(false);
|
| 207 |
+
setBreedingProgress(0);
|
| 208 |
+
};
|
| 209 |
+
|
| 210 |
+
const handleClose = () => {
|
| 211 |
+
resetModalState();
|
| 212 |
+
onClose();
|
| 213 |
+
};
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
return (
|
| 217 |
+
<Modal isOpen={isOpen} onClose={handleClose} title="Doge Whale NFT Hub 🧬" size="xl">
|
| 218 |
+
<div className="flex mb-4 border-b border-theme-border">
|
| 219 |
+
{(['market', 'my-nfts', 'breeding'] as MarketplaceTabType[]).map(tab => (
|
| 220 |
+
<button
|
| 221 |
+
key={tab}
|
| 222 |
+
onClick={() => setActiveTab(tab)}
|
| 223 |
+
className={`py-2 px-4 font-semibold transition-colors duration-200 text-white ${activeTab === tab ? 'bg-pink-700 border-b-2 border-pink-400' : 'bg-pink-500 hover:bg-pink-600'}`}
|
| 224 |
+
>
|
| 225 |
+
{tab.charAt(0).toUpperCase() + tab.slice(1).replace('-',' ')}
|
| 226 |
+
</button>
|
| 227 |
+
))}
|
| 228 |
+
</div>
|
| 229 |
+
|
| 230 |
+
{activeTab === 'market' && (
|
| 231 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
| 232 |
+
{Object.values(runtimeNftDefinitions).filter(nft => nft.price > 0).map(nft => (
|
| 233 |
+
<NFTCard
|
| 234 |
+
key={nft.id}
|
| 235 |
+
nft={nft}
|
| 236 |
+
isOwned={playerData.nftInventory.includes(nft.id)}
|
| 237 |
+
isEquipped={playerData.equippedNftId === nft.id}
|
| 238 |
+
isStakedInSlot={playerData.stakedNftSlots.includes(nft.id)}
|
| 239 |
+
canAfford={playerData.seaTokens >= nft.price}
|
| 240 |
+
onPurchase={() => handlePurchase(nft.id)}
|
| 241 |
+
onEquip={() => onEquipNft(nft.id)}
|
| 242 |
+
isMarketView={true}
|
| 243 |
+
/>
|
| 244 |
+
))}
|
| 245 |
+
</div>
|
| 246 |
+
)}
|
| 247 |
+
|
| 248 |
+
{activeTab === 'my-nfts' && (
|
| 249 |
+
ownedNfts.length > 0 ? (
|
| 250 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
| 251 |
+
{ownedNfts.map(nft => (
|
| 252 |
+
<NFTCard
|
| 253 |
+
key={nft.id}
|
| 254 |
+
nft={nft}
|
| 255 |
+
isOwned={true}
|
| 256 |
+
isEquipped={playerData.equippedNftId === nft.id}
|
| 257 |
+
isStakedInSlot={playerData.stakedNftSlots.includes(nft.id)}
|
| 258 |
+
canAfford={true}
|
| 259 |
+
onEquip={() => onEquipNft(nft.id)}
|
| 260 |
+
isMarketView={false}
|
| 261 |
+
onGoToStaking={() => { /* Consider a callback here to open staking modal from App.tsx */ setActiveTab('market'); onClose(); /* Then App.tsx opens staking */ }}
|
| 262 |
+
/>
|
| 263 |
+
))}
|
| 264 |
+
</div>
|
| 265 |
+
) : (
|
| 266 |
+
<p className="text-gray-400 text-center py-8">You don't own any NFTs yet. Visit the market!</p>
|
| 267 |
+
)
|
| 268 |
+
)}
|
| 269 |
+
|
| 270 |
+
{activeTab === 'breeding' && (
|
| 271 |
+
<div className="breeding-section p-4 bg-theme-primary/30 rounded-lg">
|
| 272 |
+
<h3 className="text-xl font-bold mb-4 text-theme-accent">Breeding Lab 🧬</h3>
|
| 273 |
+
{isBreeding ? (
|
| 274 |
+
<div className="text-center">
|
| 275 |
+
<p className="text-lg mb-2">Breeding in progress...</p>
|
| 276 |
+
<div className="w-full bg-gray-700 rounded-full h-6 mb-2">
|
| 277 |
+
<div className="bg-pink-600 h-6 rounded-full transition-all duration-1000 ease-linear" style={{ width: `${breedingProgress}%` }}> {/* Changed progress bar color */}
|
| 278 |
+
<span className="text-xs font-medium text-white px-2">{Math.round(breedingProgress)}%</span>
|
| 279 |
+
</div>
|
| 280 |
+
</div>
|
| 281 |
+
<p className="text-theme-accent">Time Left: {breedingTimeLeft}s</p>
|
| 282 |
+
{breedingResultPreview && (
|
| 283 |
+
<div className="mt-4 p-4 bg-theme-dark rounded-lg border border-theme-border">
|
| 284 |
+
<h4 className="text-lg font-semibold text-theme-warning">Creating: {breedingResultPreview.name}</h4>
|
| 285 |
+
<img src={breedingResultPreview.image} alt={breedingResultPreview.name} className="w-32 h-32 object-contain rounded-md mx-auto my-2 bg-black/30" />
|
| 286 |
+
</div>
|
| 287 |
+
)}
|
| 288 |
+
</div>
|
| 289 |
+
) : (
|
| 290 |
+
<>
|
| 291 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
|
| 292 |
+
<div>
|
| 293 |
+
<h4 className="font-semibold mb-2">Parent 1 Slot</h4>
|
| 294 |
+
<div className={`p-2 border-2 ${breedingParents.parent1 ? 'border-pink-500' : 'border-dashed border-theme-border'} rounded-lg min-h-[100px] flex items-center justify-center bg-theme-dark`}>
|
| 295 |
+
{breedingParents.parent1 && runtimeNftDefinitions[breedingParents.parent1] ? (
|
| 296 |
+
<div className="text-center">
|
| 297 |
+
<img src={runtimeNftDefinitions[breedingParents.parent1].image} alt={runtimeNftDefinitions[breedingParents.parent1].name} className="w-20 h-20 object-contain mx-auto"/>
|
| 298 |
+
<p className="text-sm mt-1">{runtimeNftDefinitions[breedingParents.parent1].name}</p>
|
| 299 |
+
</div>
|
| 300 |
+
) : <p className="text-gray-500">Select an NFT from below</p>}
|
| 301 |
+
</div>
|
| 302 |
+
</div>
|
| 303 |
+
<div>
|
| 304 |
+
<h4 className="font-semibold mb-2">Parent 2 Slot</h4>
|
| 305 |
+
<div className={`p-2 border-2 ${breedingParents.parent2 ? 'border-pink-500' : 'border-dashed border-theme-border'} rounded-lg min-h-[100px] flex items-center justify-center bg-theme-dark`}>
|
| 306 |
+
{breedingParents.parent2 && runtimeNftDefinitions[breedingParents.parent2] ? (
|
| 307 |
+
<div className="text-center">
|
| 308 |
+
<img src={runtimeNftDefinitions[breedingParents.parent2].image} alt={runtimeNftDefinitions[breedingParents.parent2].name} className="w-20 h-20 object-contain mx-auto"/>
|
| 309 |
+
<p className="text-sm mt-1">{runtimeNftDefinitions[breedingParents.parent2].name}</p>
|
| 310 |
+
</div>
|
| 311 |
+
) : <p className="text-gray-500">Select an NFT from below</p>}
|
| 312 |
+
</div>
|
| 313 |
+
</div>
|
| 314 |
+
</div>
|
| 315 |
+
|
| 316 |
+
<h4 className="font-semibold mb-2 text-lg">Select Your NFTs for Breeding:</h4>
|
| 317 |
+
{ownedNfts.filter(nft => !playerData.stakedNftSlots.includes(nft.id)).length >= 1 ? (
|
| 318 |
+
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 mb-6 max-h-60 overflow-y-auto p-2 bg-black/20 rounded">
|
| 319 |
+
{ownedNfts.filter(nft => !playerData.stakedNftSlots.includes(nft.id)).map(nft => (
|
| 320 |
+
<NFTCard
|
| 321 |
+
key={nft.id}
|
| 322 |
+
nft={nft}
|
| 323 |
+
isOwned={true}
|
| 324 |
+
isEquipped={false}
|
| 325 |
+
isStakedInSlot={false}
|
| 326 |
+
canAfford={true}
|
| 327 |
+
onSelectForBreeding={() => handleSelectForBreeding(nft.id)}
|
| 328 |
+
isSelectedForBreeding={breedingParents.parent1 === nft.id || breedingParents.parent2 === nft.id}
|
| 329 |
+
isBreedingMode={true}
|
| 330 |
+
/>
|
| 331 |
+
))}
|
| 332 |
+
</div>
|
| 333 |
+
) : (
|
| 334 |
+
<p className="text-gray-400 text-center py-4">You need at least 2 unstaked NFTs to breed. Check the Staking Vault or acquire more NFTs.</p>
|
| 335 |
+
)}
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
{breedingResultPreview && (
|
| 339 |
+
<div className="my-6 p-4 bg-theme-dark rounded-lg border border-theme-border">
|
| 340 |
+
<h4 className="text-lg font-semibold text-theme-warning">Possible Offspring:</h4>
|
| 341 |
+
<div className="flex items-center gap-4 mt-2">
|
| 342 |
+
<img src={breedingResultPreview.image} alt={breedingResultPreview.name} className="w-24 h-24 object-contain rounded-md bg-black/30" />
|
| 343 |
+
<div>
|
| 344 |
+
<p className="font-bold">{breedingResultPreview.name}</p>
|
| 345 |
+
<p className="text-sm text-gray-300">{breedingResultPreview.description}</p>
|
| 346 |
+
<p className="text-xs text-gray-400">Rarity: {breedingResultPreview.rarity}</p>
|
| 347 |
+
</div>
|
| 348 |
+
</div>
|
| 349 |
+
</div>
|
| 350 |
+
)}
|
| 351 |
+
|
| 352 |
+
<button
|
| 353 |
+
onClick={startBreedingProcess}
|
| 354 |
+
disabled={!breedingParents.parent1 || !breedingParents.parent2 || !breedingResultPreview || isBreeding}
|
| 355 |
+
className="w-full py-3 px-6 bg-pink-600 hover:bg-pink-700 text-white font-bold rounded-lg transition-all duration-300 disabled:bg-pink-400 disabled:text-gray-100 disabled:cursor-not-allowed"
|
| 356 |
+
>
|
| 357 |
+
Start Breeding ({BREEDING_TIME_SECONDS}s)
|
| 358 |
+
</button>
|
| 359 |
+
</>
|
| 360 |
+
)}
|
| 361 |
+
</div>
|
| 362 |
+
)}
|
| 363 |
+
</Modal>
|
| 364 |
+
);
|
| 365 |
+
};
|
NftStakingModal.tsx
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import { Modal } from '../Modal';
|
| 4 |
+
import { PlayerData, NFT, Rarity, AdminConfig } from '../../types';
|
| 5 |
+
import { MAX_STAKING_SLOTS, BASE_STAKING_REWARD_PER_MINUTE, INITIAL_MINING_POWER, MINING_POWER_UPGRADE_COST_BASE, MINING_POWER_UPGRADE_COST_FACTOR } from '../../constants';
|
| 6 |
+
|
| 7 |
+
interface NftStakingModalProps {
|
| 8 |
+
isOpen: boolean;
|
| 9 |
+
onClose: () => void;
|
| 10 |
+
playerData: PlayerData;
|
| 11 |
+
runtimeNftDefinitions: Record<string, NFT>;
|
| 12 |
+
adminConfig: AdminConfig;
|
| 13 |
+
onStakeNftInSlot: (nftId: string, slotIndex: number) => void;
|
| 14 |
+
onUnstakeNftFromSlot: (nftId: string, slotIndex: number) => void;
|
| 15 |
+
onClaimRewards: () => void;
|
| 16 |
+
onUpgradeNftMiningPower: (nftId: string, slotIndex: number) => void;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export const NftStakingModal: React.FC<NftStakingModalProps> = ({
|
| 20 |
+
isOpen,
|
| 21 |
+
onClose,
|
| 22 |
+
playerData,
|
| 23 |
+
runtimeNftDefinitions,
|
| 24 |
+
adminConfig,
|
| 25 |
+
onStakeNftInSlot,
|
| 26 |
+
onUnstakeNftFromSlot,
|
| 27 |
+
onClaimRewards,
|
| 28 |
+
onUpgradeNftMiningPower
|
| 29 |
+
}) => {
|
| 30 |
+
const [claimableRewards, setClaimableRewards] = useState(0);
|
| 31 |
+
const [selectedNftToStake, setSelectedNftToStake] = useState<string | null>(null);
|
| 32 |
+
|
| 33 |
+
useEffect(() => {
|
| 34 |
+
if (isOpen) {
|
| 35 |
+
if (playerData.lastNftRewardClaimTime) {
|
| 36 |
+
const now = Date.now();
|
| 37 |
+
const minutesStakedGlobal = (now - playerData.lastNftRewardClaimTime) / (1000 * 60);
|
| 38 |
+
let totalRewards = 0;
|
| 39 |
+
playerData.stakedNftSlots.forEach(nftId => {
|
| 40 |
+
if (nftId) {
|
| 41 |
+
const miningPower = playerData.nftMiningPowers[nftId] || INITIAL_MINING_POWER;
|
| 42 |
+
const nftReward = Math.floor(minutesStakedGlobal * BASE_STAKING_REWARD_PER_MINUTE * miningPower);
|
| 43 |
+
totalRewards += nftReward;
|
| 44 |
+
}
|
| 45 |
+
});
|
| 46 |
+
setClaimableRewards(totalRewards > 0 ? totalRewards : 0);
|
| 47 |
+
} else {
|
| 48 |
+
setClaimableRewards(0);
|
| 49 |
+
}
|
| 50 |
+
setSelectedNftToStake(null); // Reset selection when modal opens
|
| 51 |
+
}
|
| 52 |
+
}, [isOpen, playerData.stakedNftSlots, playerData.nftMiningPowers, playerData.lastNftRewardClaimTime]);
|
| 53 |
+
|
| 54 |
+
const ownedUnstakedNfts = playerData.nftInventory
|
| 55 |
+
.map(id => runtimeNftDefinitions[id])
|
| 56 |
+
.filter(nft => nft && !playerData.stakedNftSlots.includes(nft.id));
|
| 57 |
+
|
| 58 |
+
const getUpgradeCost = (nftId: string): number => {
|
| 59 |
+
const currentPower = playerData.nftMiningPowers[nftId] || INITIAL_MINING_POWER;
|
| 60 |
+
return Math.floor(MINING_POWER_UPGRADE_COST_BASE * (MINING_POWER_UPGRADE_COST_FACTOR ** (currentPower -1)));
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
const handleSelectNftForStaking = (nftId: string) => {
|
| 64 |
+
setSelectedNftToStake(nftId === selectedNftToStake ? null : nftId);
|
| 65 |
+
};
|
| 66 |
+
|
| 67 |
+
const handleStakeIntoSlot = (slotIndex: number) => {
|
| 68 |
+
if (selectedNftToStake) {
|
| 69 |
+
onStakeNftInSlot(selectedNftToStake, slotIndex);
|
| 70 |
+
setSelectedNftToStake(null); // Reset selection after staking
|
| 71 |
+
}
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
return (
|
| 76 |
+
<Modal isOpen={isOpen} onClose={onClose} title="NFT Staking Vault 💎" size="xl">
|
| 77 |
+
<div className="space-y-6">
|
| 78 |
+
{/* Staking Information */}
|
| 79 |
+
<div className="p-4 bg-theme-primary/30 rounded-lg">
|
| 80 |
+
<h4 className="text-lg font-semibold mb-2 text-theme-accent">NFT Slot Staking: Power Up Your Earnings!</h4>
|
| 81 |
+
<ul className="list-disc list-inside text-sm text-gray-300 space-y-1">
|
| 82 |
+
<li>Stake your NFTs into one of the {MAX_STAKING_SLOTS} available slots to earn Sea Tokens.</li>
|
| 83 |
+
<li>Base Reward: <span className="font-bold text-theme-warning">{BASE_STAKING_REWARD_PER_MINUTE} Sea Token per minute</span> for each staked NFT.</li>
|
| 84 |
+
<li>Mining Power: Each NFT starts with <span className="font-bold text-theme-info">{INITIAL_MINING_POWER}x</span> Mining Power. Rewards are multiplied by this power.</li>
|
| 85 |
+
<li>Upgrade Power: Spend Sea Tokens to increase an NFT's Mining Power and boost its earnings. Costs increase per level.</li>
|
| 86 |
+
<li>Staked NFTs in these slots cannot be equipped or used for breeding.</li>
|
| 87 |
+
<li>Unstake NFTs at any time. Rewards must be claimed manually.</li>
|
| 88 |
+
</ul>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
{/* Claim Rewards Section */}
|
| 92 |
+
<div className="p-4 bg-theme-primary/30 rounded-lg">
|
| 93 |
+
<h4 className="text-lg font-semibold mb-2 text-theme-accent">Claim Your NFT Staking Rewards</h4>
|
| 94 |
+
<p className="text-gray-300">
|
| 95 |
+
Claimable Rewards from Slots: <span className="font-bold text-theme-success">{claimableRewards.toFixed(0)} Sea Tokens</span>
|
| 96 |
+
</p>
|
| 97 |
+
<button
|
| 98 |
+
onClick={onClaimRewards}
|
| 99 |
+
disabled={claimableRewards <= 0}
|
| 100 |
+
className="mt-3 w-full py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white font-bold rounded-md transition-colors duration-200 disabled:bg-pink-400 disabled:text-gray-100 disabled:cursor-not-allowed"
|
| 101 |
+
>
|
| 102 |
+
Claim All Slot Staking Rewards
|
| 103 |
+
</button>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
{/* Staking Slots Display */}
|
| 107 |
+
<div className="p-4 bg-theme-primary/30 rounded-lg">
|
| 108 |
+
<h4 className="text-lg font-semibold mb-3 text-theme-accent">Your Staking Slots ({playerData.stakedNftSlots.filter(id => id !== null).length}/{MAX_STAKING_SLOTS} Used)</h4>
|
| 109 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
| 110 |
+
{playerData.stakedNftSlots.map((nftId, index) => {
|
| 111 |
+
const nft = nftId ? runtimeNftDefinitions[nftId] : null;
|
| 112 |
+
const miningPower = nftId ? (playerData.nftMiningPowers[nftId] || INITIAL_MINING_POWER) : 0;
|
| 113 |
+
const upgradeCost = nftId ? getUpgradeCost(nftId) : 0;
|
| 114 |
+
|
| 115 |
+
return (
|
| 116 |
+
<div key={`slot-${index}`} className={`p-3 border-2 rounded-lg flex flex-col items-center text-center min-h-[280px] justify-between ${nft ? 'border-pink-500 bg-theme-dark/70' : 'border-dashed border-theme-border bg-theme-dark/30'}`}>
|
| 117 |
+
{nft && nftId ? (
|
| 118 |
+
<>
|
| 119 |
+
<div>
|
| 120 |
+
<img src={nft.image} alt={nft.name} className="w-24 h-24 object-contain rounded-md mb-2 bg-black/30" />
|
| 121 |
+
<p className="text-sm font-semibold text-theme-white truncate w-full" title={nft.name}>{nft.name}</p>
|
| 122 |
+
<p className="text-xs text-gray-400 capitalize mb-1">{nft.rarity}</p>
|
| 123 |
+
<p className="text-xs text-theme-info">Mining Power: {miningPower}x</p>
|
| 124 |
+
<p className="text-xs text-yellow-400">Earning: {(BASE_STAKING_REWARD_PER_MINUTE * miningPower).toFixed(1)} ST/min</p>
|
| 125 |
+
</div>
|
| 126 |
+
<div className="space-y-1.5 mt-2 w-full">
|
| 127 |
+
<button
|
| 128 |
+
onClick={() => onUpgradeNftMiningPower(nftId, index)}
|
| 129 |
+
disabled={playerData.seaTokens < upgradeCost}
|
| 130 |
+
className="w-full py-1.5 px-2 text-xs font-bold rounded-md bg-pink-600 hover:bg-pink-700 text-white transition-colors duration-200 disabled:bg-pink-400 disabled:text-gray-100 disabled:cursor-not-allowed"
|
| 131 |
+
title={`Cost: ${upgradeCost} Sea Tokens`}
|
| 132 |
+
>
|
| 133 |
+
Upgrade Power ({upgradeCost} ST)
|
| 134 |
+
</button>
|
| 135 |
+
<button
|
| 136 |
+
onClick={() => onUnstakeNftFromSlot(nftId, index)}
|
| 137 |
+
className="w-full py-1.5 px-2 text-xs font-bold rounded-md bg-pink-600 hover:bg-pink-700 text-white transition-colors duration-200"
|
| 138 |
+
>
|
| 139 |
+
Unstake
|
| 140 |
+
</button>
|
| 141 |
+
</div>
|
| 142 |
+
</>
|
| 143 |
+
) : (
|
| 144 |
+
<div className="flex flex-col items-center justify-center h-full">
|
| 145 |
+
<p className="text-gray-500 mb-2">Empty Slot {index + 1}</p>
|
| 146 |
+
{selectedNftToStake ? (
|
| 147 |
+
<button
|
| 148 |
+
onClick={() => handleStakeIntoSlot(index)}
|
| 149 |
+
className="py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white font-semibold rounded-md text-sm"
|
| 150 |
+
>
|
| 151 |
+
Stake '{runtimeNftDefinitions[selectedNftToStake]?.name.substring(0,15)}...' Here
|
| 152 |
+
</button>
|
| 153 |
+
) : (
|
| 154 |
+
<p className="text-xs text-gray-600">(Select an NFT below to stake here)</p>
|
| 155 |
+
)}
|
| 156 |
+
</div>
|
| 157 |
+
)}
|
| 158 |
+
</div>
|
| 159 |
+
);
|
| 160 |
+
})}
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
|
| 164 |
+
{/* Your Unstaked NFTs */}
|
| 165 |
+
<div className="p-4 bg-theme-primary/30 rounded-lg">
|
| 166 |
+
<h4 className="text-lg font-semibold mb-3 text-theme-accent">Your Unstaked NFTs (Available to Stake in Slots)</h4>
|
| 167 |
+
{ownedUnstakedNfts.length > 0 ? (
|
| 168 |
+
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3 max-h-60 overflow-y-auto pr-2">
|
| 169 |
+
{ownedUnstakedNfts.map(nft => (
|
| 170 |
+
nft ? (
|
| 171 |
+
<div
|
| 172 |
+
key={nft.id}
|
| 173 |
+
onClick={() => handleSelectNftForStaking(nft.id)}
|
| 174 |
+
className={`p-2 border rounded-lg flex flex-col items-center text-center cursor-pointer transition-all
|
| 175 |
+
${playerData.equippedNftId === nft.id ? 'opacity-70 border-yellow-500' : 'border-theme-border hover:border-pink-500'}
|
| 176 |
+
${selectedNftToStake === nft.id ? 'ring-2 ring-pink-500 bg-pink-700/20' : 'bg-theme-dark/50'}`}
|
| 177 |
+
title={playerData.equippedNftId === nft.id ? "Unequip to stake if desired for other effects (auto-unequips on stake)" : `Select ${nft.name} to stake`}
|
| 178 |
+
>
|
| 179 |
+
<img src={nft.image} alt={nft.name} className="w-20 h-20 object-contain rounded-md mb-1 bg-black/30" />
|
| 180 |
+
<p className="text-xs font-semibold text-theme-white truncate w-full" title={nft.name}>{nft.name}</p>
|
| 181 |
+
<p className="text-xxs text-gray-400 capitalize">{nft.rarity}</p>
|
| 182 |
+
{playerData.equippedNftId === nft.id && <p className="text-xxs text-yellow-500">(Equipped)</p>}
|
| 183 |
+
</div>
|
| 184 |
+
) : null
|
| 185 |
+
))}
|
| 186 |
+
</div>
|
| 187 |
+
) : (
|
| 188 |
+
<p className="text-gray-400 text-center py-4">You have no NFTs available to stake in slots. Either all are staked, or you need to acquire more!</p>
|
| 189 |
+
)}
|
| 190 |
+
{selectedNftToStake && (
|
| 191 |
+
<p className="text-center mt-3 text-sm text-theme-accent">
|
| 192 |
+
Selected: <span className="font-bold">{runtimeNftDefinitions[selectedNftToStake]?.name}</span>. Now click an empty slot above to stake it.
|
| 193 |
+
</p>
|
| 194 |
+
)}
|
| 195 |
+
</div>
|
| 196 |
+
</div>
|
| 197 |
+
</Modal>
|
| 198 |
+
);
|
| 199 |
+
};
|
NotificationToast.tsx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useEffect } from 'react';
|
| 3 |
+
import { NotificationMessage } from '../types.ts';
|
| 4 |
+
|
| 5 |
+
interface NotificationToastProps {
|
| 6 |
+
notification: NotificationMessage;
|
| 7 |
+
onDismiss: (id: string) => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export const NotificationToast: React.FC<NotificationToastProps> = ({ notification, onDismiss }) => {
|
| 11 |
+
useEffect(() => {
|
| 12 |
+
const timer = setTimeout(() => {
|
| 13 |
+
onDismiss(notification.id);
|
| 14 |
+
}, 3000);
|
| 15 |
+
return () => clearTimeout(timer);
|
| 16 |
+
}, [notification.id, onDismiss]);
|
| 17 |
+
|
| 18 |
+
const baseClasses = "fixed bottom-5 left-1/2 transform -translate-x-1/2 p-4 rounded-lg shadow-lg text-white font-semibold z-[1000] animate-fadeIn";
|
| 19 |
+
const typeClasses = {
|
| 20 |
+
success: "bg-theme-success text-theme-dark",
|
| 21 |
+
error: "bg-theme-danger text-theme-white",
|
| 22 |
+
info: "bg-theme-primary text-theme-white",
|
| 23 |
+
warning: "bg-theme-warning text-theme-dark",
|
| 24 |
+
special: "bg-theme-accent text-theme-dark animate-pulseCustom",
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
return (
|
| 28 |
+
<div className={`${baseClasses} ${typeClasses[notification.type]}`}>
|
| 29 |
+
{notification.message}
|
| 30 |
+
</div>
|
| 31 |
+
);
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
interface NotificationHostProps {
|
| 35 |
+
notifications: NotificationMessage[];
|
| 36 |
+
onDismiss: (id: string) => void;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
export const NotificationHost: React.FC<NotificationHostProps> = ({ notifications, onDismiss }) => {
|
| 40 |
+
return (
|
| 41 |
+
<>
|
| 42 |
+
{notifications.map((notification) => (
|
| 43 |
+
<NotificationToast key={notification.id} notification={notification} onDismiss={onDismiss} />
|
| 44 |
+
))}
|
| 45 |
+
</>
|
| 46 |
+
);
|
| 47 |
+
};
|
PlayerProfileModal.tsx
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
| 3 |
+
import { Modal } from '../Modal.tsx';
|
| 4 |
+
import { PlayerData, NFT, AdminConfig, GameCharacter, WeaponType, SavedReply } from '../../types.ts';
|
| 5 |
+
import { LEVEL_XP_THRESHOLDS, BASE_STAKING_REWARD_PER_MINUTE, INITIAL_MINING_POWER } from '../../constants.ts';
|
| 6 |
+
import { SoundControlBar } from '../SoundControlBar.tsx';
|
| 7 |
+
import { generateId } from '../../utils.ts';
|
| 8 |
+
import { GoogleGenAI, GenerateContentResponse } from "@google/genai";
|
| 9 |
+
|
| 10 |
+
interface PlayerProfileModalProps {
|
| 11 |
+
isOpen: boolean;
|
| 12 |
+
onClose: () => void;
|
| 13 |
+
playerData: PlayerData;
|
| 14 |
+
runtimeNftDefinitions: Record<string, NFT>;
|
| 15 |
+
adminConfig: AdminConfig;
|
| 16 |
+
onClaimNftStakingRewards: () => void;
|
| 17 |
+
initialPlayerName: string;
|
| 18 |
+
initialSelectedCharacterId: string;
|
| 19 |
+
onUpdatePlayerName: (name: string) => void;
|
| 20 |
+
onUpdateSelectedCharacterId: (charId: string) => void;
|
| 21 |
+
runtimeGameCharacters: Record<string, GameCharacter>;
|
| 22 |
+
masterVolume: number;
|
| 23 |
+
isAppMuted: boolean;
|
| 24 |
+
onVolumeChange: (volume: number) => void;
|
| 25 |
+
onMuteToggle: () => void;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
interface AiChatMessage {
|
| 29 |
+
id: string;
|
| 30 |
+
sender: 'You' | 'AI Game Guide' | 'Error';
|
| 31 |
+
text: string;
|
| 32 |
+
timestamp: number;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const StatItem: React.FC<{ label: string; value: string | number; className?: string }> = ({ label, value, className }) => (
|
| 36 |
+
<div className={`flex justify-between py-2 border-b border-theme-primary/50 ${className}`}>
|
| 37 |
+
<span className="text-gray-400">{label}:</span>
|
| 38 |
+
<span className="font-semibold text-theme-white">{value}</span>
|
| 39 |
+
</div>
|
| 40 |
+
);
|
| 41 |
+
|
| 42 |
+
export const PlayerProfileModal: React.FC<PlayerProfileModalProps> = ({
|
| 43 |
+
isOpen,
|
| 44 |
+
onClose,
|
| 45 |
+
playerData,
|
| 46 |
+
runtimeNftDefinitions,
|
| 47 |
+
adminConfig,
|
| 48 |
+
onClaimNftStakingRewards,
|
| 49 |
+
initialPlayerName,
|
| 50 |
+
initialSelectedCharacterId,
|
| 51 |
+
onUpdatePlayerName,
|
| 52 |
+
onUpdateSelectedCharacterId,
|
| 53 |
+
runtimeGameCharacters,
|
| 54 |
+
masterVolume,
|
| 55 |
+
isAppMuted,
|
| 56 |
+
onVolumeChange,
|
| 57 |
+
onMuteToggle,
|
| 58 |
+
}) => {
|
| 59 |
+
const equippedNft = playerData.equippedNftId ? runtimeNftDefinitions[playerData.equippedNftId] : null;
|
| 60 |
+
const ownedNfts: NFT[] = playerData.nftInventory.map(id => runtimeNftDefinitions[id]).filter(Boolean) as NFT[];
|
| 61 |
+
const [claimableNftRewards, setClaimableNftRewards] = useState(0);
|
| 62 |
+
|
| 63 |
+
const [modalPlayerName, setModalPlayerName] = useState(initialPlayerName);
|
| 64 |
+
const [modalSelectedCharacterId, setModalSelectedCharacterId] = useState(initialSelectedCharacterId);
|
| 65 |
+
|
| 66 |
+
// AI Player Chat State
|
| 67 |
+
const [aiChatMessages, setAiChatMessages] = useState<AiChatMessage[]>([]);
|
| 68 |
+
const [currentPlayerMessage, setCurrentPlayerMessage] = useState('');
|
| 69 |
+
const [isAiResponding, setIsAiResponding] = useState(false);
|
| 70 |
+
const playerChatLogRef = useRef<HTMLDivElement>(null);
|
| 71 |
+
const geminiAi = useMemo(() => new GoogleGenAI({ apiKey: process.env.API_KEY! }), []);
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
useEffect(() => {
|
| 75 |
+
if (isOpen) {
|
| 76 |
+
setModalPlayerName(initialPlayerName);
|
| 77 |
+
setModalSelectedCharacterId(initialSelectedCharacterId);
|
| 78 |
+
// Reset AI chat when modal opens
|
| 79 |
+
setAiChatMessages([]);
|
| 80 |
+
setCurrentPlayerMessage('');
|
| 81 |
+
setIsAiResponding(false);
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
if (playerData.stakedNftSlots.some(id => id !== null) && playerData.lastNftRewardClaimTime) {
|
| 85 |
+
const now = Date.now();
|
| 86 |
+
const minutesStakedGlobal = (now - playerData.lastNftRewardClaimTime) / (1000 * 60);
|
| 87 |
+
let totalRewards = 0;
|
| 88 |
+
playerData.stakedNftSlots.forEach(nftId => {
|
| 89 |
+
if (nftId) {
|
| 90 |
+
const miningPower = playerData.nftMiningPowers[nftId] || INITIAL_MINING_POWER;
|
| 91 |
+
const nftReward = Math.floor(minutesStakedGlobal * BASE_STAKING_REWARD_PER_MINUTE * miningPower);
|
| 92 |
+
totalRewards += nftReward;
|
| 93 |
+
}
|
| 94 |
+
});
|
| 95 |
+
setClaimableNftRewards(totalRewards > 0 ? totalRewards : 0);
|
| 96 |
+
} else {
|
| 97 |
+
setClaimableNftRewards(0);
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
}, [isOpen, initialPlayerName, initialSelectedCharacterId, playerData.stakedNftSlots, playerData.nftMiningPowers, playerData.lastNftRewardClaimTime, adminConfig.globalNftStakingRate]);
|
| 101 |
+
|
| 102 |
+
const handleSaveChanges = () => {
|
| 103 |
+
if (!modalPlayerName.trim()) {
|
| 104 |
+
alert("Player name cannot be empty.");
|
| 105 |
+
return;
|
| 106 |
+
}
|
| 107 |
+
onUpdatePlayerName(modalPlayerName);
|
| 108 |
+
onUpdateSelectedCharacterId(modalSelectedCharacterId);
|
| 109 |
+
onClose();
|
| 110 |
+
};
|
| 111 |
+
|
| 112 |
+
const handlePlayerSendMessage = async () => {
|
| 113 |
+
if (currentPlayerMessage.trim() === '' || isAiResponding) return;
|
| 114 |
+
|
| 115 |
+
const userMessage: AiChatMessage = {
|
| 116 |
+
id: generateId(),
|
| 117 |
+
sender: 'You',
|
| 118 |
+
text: currentPlayerMessage,
|
| 119 |
+
timestamp: Date.now(),
|
| 120 |
+
};
|
| 121 |
+
setAiChatMessages(prev => [...prev, userMessage]);
|
| 122 |
+
setIsAiResponding(true);
|
| 123 |
+
const currentQuery = currentPlayerMessage;
|
| 124 |
+
setCurrentPlayerMessage('');
|
| 125 |
+
|
| 126 |
+
try {
|
| 127 |
+
let knowledgeBaseText = "";
|
| 128 |
+
if (adminConfig.savedReplies && adminConfig.savedReplies.length > 0) {
|
| 129 |
+
knowledgeBaseText = "You have access to the following predefined knowledge base. Use this information when relevant to the user's query:\n";
|
| 130 |
+
adminConfig.savedReplies.forEach(reply => {
|
| 131 |
+
knowledgeBaseText += `- Topic/Question: ${reply.topic}\n Answer/Information: ${reply.answer}\n`;
|
| 132 |
+
});
|
| 133 |
+
knowledgeBaseText += "---\n";
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
const systemInstruction = `${knowledgeBaseText}You are a friendly and helpful game guide for 'Doge Whale Wars', an underwater MMORPG. Players control Doge Whales, fight enemies, collect NFTs, and earn Sea Tokens. Answer player questions about gameplay, features, tips, or any aspect of the game. Keep your responses concise and easy to understand.`;
|
| 137 |
+
|
| 138 |
+
const response: GenerateContentResponse = await geminiAi.models.generateContent({
|
| 139 |
+
model: 'gemini-2.5-flash-preview-04-17',
|
| 140 |
+
contents: currentQuery,
|
| 141 |
+
config: { systemInstruction: systemInstruction },
|
| 142 |
+
});
|
| 143 |
+
|
| 144 |
+
const aiTextResponse = response.text;
|
| 145 |
+
const aiMessage: AiChatMessage = {
|
| 146 |
+
id: generateId(),
|
| 147 |
+
sender: 'AI Game Guide',
|
| 148 |
+
text: aiTextResponse,
|
| 149 |
+
timestamp: Date.now(),
|
| 150 |
+
};
|
| 151 |
+
setAiChatMessages(prev => [...prev, aiMessage]);
|
| 152 |
+
|
| 153 |
+
} catch (error: any) {
|
| 154 |
+
console.error("Error with Player AI Chat:", error);
|
| 155 |
+
const errorMessage: AiChatMessage = {
|
| 156 |
+
id: generateId(),
|
| 157 |
+
sender: 'Error',
|
| 158 |
+
text: `AI Error: ${error.message || 'Could not get a response from the game guide.'}`,
|
| 159 |
+
timestamp: Date.now(),
|
| 160 |
+
};
|
| 161 |
+
setAiChatMessages(prev => [...prev, errorMessage]);
|
| 162 |
+
} finally {
|
| 163 |
+
setIsAiResponding(false);
|
| 164 |
+
}
|
| 165 |
+
};
|
| 166 |
+
|
| 167 |
+
useEffect(() => {
|
| 168 |
+
if (playerChatLogRef.current) {
|
| 169 |
+
playerChatLogRef.current.scrollTop = playerChatLogRef.current.scrollHeight;
|
| 170 |
+
}
|
| 171 |
+
}, [aiChatMessages]);
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
return (
|
| 175 |
+
<Modal isOpen={isOpen} onClose={onClose} title="Crypto Cetacean Chronicles 📊" size="lg">
|
| 176 |
+
<div className="space-y-6">
|
| 177 |
+
|
| 178 |
+
{/* Sound Control Section */}
|
| 179 |
+
<div className="p-4 bg-theme-secondary/40 rounded-lg">
|
| 180 |
+
<h4 className="text-xl font-semibold mb-3 text-theme-accent border-b border-theme-border pb-2">Sound Settings</h4>
|
| 181 |
+
<div className="flex items-center justify-center">
|
| 182 |
+
<SoundControlBar
|
| 183 |
+
volume={masterVolume}
|
| 184 |
+
isMuted={isAppMuted}
|
| 185 |
+
onVolumeChange={onVolumeChange}
|
| 186 |
+
onMuteToggle={onMuteToggle}
|
| 187 |
+
/>
|
| 188 |
+
</div>
|
| 189 |
+
</div>
|
| 190 |
+
|
| 191 |
+
{/* Character Customization Section */}
|
| 192 |
+
<div className="p-4 bg-theme-secondary/40 rounded-lg">
|
| 193 |
+
<h4 className="text-xl font-semibold mb-4 text-theme-accent border-b border-theme-border pb-2">Customize Your Whale</h4>
|
| 194 |
+
<div className="mb-6">
|
| 195 |
+
<label htmlFor="modalPlayerNameInput" className="block text-md font-medium text-gray-200 mb-2">Whale's Name:</label>
|
| 196 |
+
<input
|
| 197 |
+
id="modalPlayerNameInput"
|
| 198 |
+
type="text"
|
| 199 |
+
value={modalPlayerName}
|
| 200 |
+
onChange={(e) => setModalPlayerName(e.target.value)}
|
| 201 |
+
placeholder="Enter your whale's name"
|
| 202 |
+
className="w-full p-3 rounded-md bg-theme-dark border border-theme-border text-white focus:ring-2 focus:ring-pink-500 focus:border-transparent transition-all"
|
| 203 |
+
aria-label="Enter your whale's name"
|
| 204 |
+
/>
|
| 205 |
+
</div>
|
| 206 |
+
|
| 207 |
+
<div className="mb-6">
|
| 208 |
+
<label className="block text-md font-medium text-gray-200 mb-3">Choose Your Character:</label>
|
| 209 |
+
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 max-h-72 overflow-y-auto pr-1">
|
| 210 |
+
{Object.values(runtimeGameCharacters).filter(char => char.type === 'player').map(char => (
|
| 211 |
+
<button
|
| 212 |
+
key={char.id}
|
| 213 |
+
onClick={() => setModalSelectedCharacterId(char.id)}
|
| 214 |
+
className={`p-3 rounded-lg border-2 transition-all duration-200 flex flex-col items-center text-left text-white ${modalSelectedCharacterId === char.id ? 'bg-pink-700 border-pink-400 ring-2 ring-pink-400' : 'bg-pink-500 border-pink-600 hover:bg-pink-600'}`}
|
| 215 |
+
aria-pressed={modalSelectedCharacterId === char.id}
|
| 216 |
+
aria-label={`Select character ${char.name}`}
|
| 217 |
+
>
|
| 218 |
+
<img src={char.image} alt={char.name} className="w-20 h-20 object-contain rounded-md mb-2 border border-theme-border/50 bg-black/20" />
|
| 219 |
+
<span className="font-semibold text-sm text-white">{char.name}</span>
|
| 220 |
+
<span className="text-xs text-gray-300 mt-1">Health: {char.baseHealth || 100}</span>
|
| 221 |
+
<span className="text-xs text-gray-300 capitalize">Weapon: {char.defaultWeaponType?.replace(/_/g, ' ') || 'Standard'}</span>
|
| 222 |
+
{char.canOneHitKill && <span className="text-xs text-yellow-400">Special: One-Hit Kill!</span>}
|
| 223 |
+
</button>
|
| 224 |
+
))}
|
| 225 |
+
</div>
|
| 226 |
+
</div>
|
| 227 |
+
<button
|
| 228 |
+
onClick={handleSaveChanges}
|
| 229 |
+
className="w-full py-2.5 px-5 bg-pink-600 hover:bg-pink-700 text-white font-bold rounded-lg transition-colors duration-200"
|
| 230 |
+
>
|
| 231 |
+
Save Profile Settings
|
| 232 |
+
</button>
|
| 233 |
+
</div>
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
{/* Player Info Section */}
|
| 237 |
+
<div className="p-4 bg-theme-primary/30 rounded-lg">
|
| 238 |
+
<h4 className="text-lg font-semibold mb-3 text-theme-accent">Player Stats</h4>
|
| 239 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
| 240 |
+
<StatItem label="Current Name" value={playerData.name} />
|
| 241 |
+
<StatItem label="Level" value={playerData.level} />
|
| 242 |
+
<StatItem label="XP" value={`${playerData.xp} / ${playerData.maxXP}`} />
|
| 243 |
+
<StatItem label="Health" value={`${playerData.health} / ${playerData.maxHealth}`} />
|
| 244 |
+
<StatItem label="Sea Token Balance" value={playerData.seaTokens.toFixed(2)} />
|
| 245 |
+
<StatItem label="Score" value={playerData.score} />
|
| 246 |
+
<div className="md:col-span-2">
|
| 247 |
+
<label className="block text-sm font-medium text-gray-400 mb-1">XP Progress</label>
|
| 248 |
+
<div className="w-full bg-gray-700 rounded-full h-4">
|
| 249 |
+
<div
|
| 250 |
+
className="bg-theme-accent h-4 rounded-full transition-all duration-500"
|
| 251 |
+
style={{ width: `${(playerData.xp / playerData.maxXP) * 100}%` }}
|
| 252 |
+
>
|
| 253 |
+
<span className="text-xs text-theme-dark font-medium pl-2">{`${Math.round((playerData.xp / playerData.maxXP) * 100)}%`}</span>
|
| 254 |
+
</div>
|
| 255 |
+
</div>
|
| 256 |
+
</div>
|
| 257 |
+
</div>
|
| 258 |
+
</div>
|
| 259 |
+
|
| 260 |
+
{/* NFT Statistics & Staking */}
|
| 261 |
+
<div className="p-4 bg-theme-primary/30 rounded-lg">
|
| 262 |
+
<h4 className="text-lg font-semibold mb-3 text-theme-accent">NFT & Staking Stats</h4>
|
| 263 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6">
|
| 264 |
+
<StatItem label="Total NFTs Owned" value={playerData.nftInventory.length} />
|
| 265 |
+
<StatItem label="Legendary NFTs" value={playerData.nftInventory.filter(id => runtimeNftDefinitions[id]?.rarity === 'legendary').length} />
|
| 266 |
+
<StatItem label="Equipped NFT" value={equippedNft ? equippedNft.name : 'None'} />
|
| 267 |
+
{equippedNft && <StatItem label="Equipped Effect" value={equippedNft.description} className="md:col-span-2"/>}
|
| 268 |
+
<StatItem label="Sea Tokens Staked (Direct)" value={playerData.stakedTokens.toFixed(2)} />
|
| 269 |
+
<StatItem label="NFTs Staked in Slots" value={playerData.stakedNftSlots.filter(id => id !== null).length} />
|
| 270 |
+
<StatItem label="Total Sea Token Staking Rewards" value={playerData.totalStakingRewards.toFixed(2)} />
|
| 271 |
+
|
| 272 |
+
{playerData.stakedNftSlots.some(id => id !== null) && (
|
| 273 |
+
<div className="md:col-span-2 mt-3 pt-3 border-t border-theme-border/50">
|
| 274 |
+
<p className="text-sm text-gray-300 mb-1">Claimable NFT Slot Staking Rewards: <span className="font-bold text-theme-success">{claimableNftRewards.toFixed(0)} Sea Tokens</span></p>
|
| 275 |
+
<p className="text-xs text-gray-400"> (Base Rate: {BASE_STAKING_REWARD_PER_MINUTE} ST/min/NFT, modified by Mining Power)</p>
|
| 276 |
+
<button
|
| 277 |
+
onClick={onClaimNftStakingRewards}
|
| 278 |
+
disabled={claimableNftRewards <= 0}
|
| 279 |
+
className="w-full mt-1 py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white font-bold rounded transition-colors duration-200 disabled:bg-pink-400 disabled:text-gray-100 disabled:cursor-not-allowed"
|
| 280 |
+
>
|
| 281 |
+
Claim NFT Slot Rewards
|
| 282 |
+
</button>
|
| 283 |
+
</div>
|
| 284 |
+
)}
|
| 285 |
+
</div>
|
| 286 |
+
</div>
|
| 287 |
+
|
| 288 |
+
{/* NFT Inventory */}
|
| 289 |
+
<div className="p-4 bg-theme-primary/30 rounded-lg">
|
| 290 |
+
<h4 className="text-lg font-semibold mb-3 text-theme-accent">My NFT Collection 🎴</h4>
|
| 291 |
+
{ownedNfts.length > 0 ? (
|
| 292 |
+
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3 max-h-60 overflow-y-auto pr-2">
|
| 293 |
+
{ownedNfts.map(nft => (
|
| 294 |
+
<div key={nft.id} className={`p-2 rounded-lg border bg-theme-dark ${playerData.equippedNftId === nft.id ? 'border-pink-500 ring-2 ring-pink-500' : playerData.stakedNftSlots.includes(nft.id) ? 'border-green-500 ring-2 ring-green-500' : 'border-theme-border'}`}>
|
| 295 |
+
<img src={nft.image} alt={nft.name} className="w-full h-20 object-contain rounded-md mb-1 bg-black/20"/>
|
| 296 |
+
<p className="text-xs font-semibold text-center text-theme-white truncate" title={nft.name}>{nft.name}</p>
|
| 297 |
+
<p className={`text-xs text-center ${ playerData.equippedNftId === nft.id ? 'text-pink-400 font-bold' : playerData.stakedNftSlots.includes(nft.id) ? 'text-green-400 font-bold' : 'text-gray-400'}`}>
|
| 298 |
+
{playerData.equippedNftId === nft.id ? 'Equipped' : playerData.stakedNftSlots.includes(nft.id) ? 'Staked in Slot' : nft.rarity}
|
| 299 |
+
</p>
|
| 300 |
+
</div>
|
| 301 |
+
))}
|
| 302 |
+
</div>
|
| 303 |
+
) : (
|
| 304 |
+
<p className="text-gray-400 text-center py-4">No NFTs in your collection yet.</p>
|
| 305 |
+
)}
|
| 306 |
+
</div>
|
| 307 |
+
|
| 308 |
+
{/* Player AI Chat Section */}
|
| 309 |
+
<div className="p-6 bg-gradient-to-b from-theme-secondary/60 to-theme-primary/80 rounded-xl shadow-2xl border border-theme-border/50">
|
| 310 |
+
<h4 className="text-2xl font-bold mb-4 text-theme-accent border-b-2 border-theme-accent/50 pb-3">AI Game Guide Chat 💬</h4>
|
| 311 |
+
<div
|
| 312 |
+
ref={playerChatLogRef}
|
| 313 |
+
className="h-80 overflow-y-auto p-4 rounded-lg border border-theme-border/30 mb-4 text-sm space-y-3 bg-cover bg-center relative"
|
| 314 |
+
style={{ backgroundImage: "url('https://images.pexels.com/photos/14089636/pexels-photo-14089636.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1')" }}
|
| 315 |
+
aria-live="polite"
|
| 316 |
+
>
|
| 317 |
+
{/* Optional: Semi-transparent overlay for better readability if needed, though solid bubble backgrounds should suffice
|
| 318 |
+
<div className="absolute inset-0 bg-black/20 rounded-lg"></div>
|
| 319 |
+
*/}
|
| 320 |
+
{aiChatMessages.map((msg) => (
|
| 321 |
+
<div key={msg.id} className={`chat-message ${msg.sender === 'You' ? 'text-right' : 'text-left'}`}>
|
| 322 |
+
<span className={`relative inline-block p-3 rounded-xl max-w-[85%] shadow-md ${
|
| 323 |
+
msg.sender === 'You' ? 'bg-gradient-to-br from-pink-500 to-purple-600 text-white' :
|
| 324 |
+
msg.sender === 'AI Game Guide' ? 'bg-gradient-to-br from-blue-500 to-teal-600 text-white' :
|
| 325 |
+
'bg-gradient-to-br from-red-500 to-orange-600 text-white' // Error
|
| 326 |
+
}`}>
|
| 327 |
+
<span className="block text-xs font-semibold text-gray-200/90 mb-1">
|
| 328 |
+
{msg.sender} - {new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
| 329 |
+
</span>
|
| 330 |
+
{msg.text.split('\n').map((line, idx) => <p key={idx} className="m-0 leading-relaxed">{line}</p>)}
|
| 331 |
+
</span>
|
| 332 |
+
</div>
|
| 333 |
+
))}
|
| 334 |
+
{aiChatMessages.length === 0 && <p className="text-gray-200 text-center italic p-4 bg-black/30 rounded-lg">The AI Game Guide awaits your questions about Doge Whale Wars!</p>}
|
| 335 |
+
{isAiResponding && (
|
| 336 |
+
<div className="text-left">
|
| 337 |
+
<span className="relative inline-block p-3 rounded-xl max-w-[85%] shadow-md bg-gradient-to-br from-blue-400 to-teal-500 text-white">
|
| 338 |
+
<span className="block text-xs font-semibold text-gray-200/90 mb-1">AI Game Guide typing...</span>
|
| 339 |
+
<div className="flex items-center space-x-1.5">
|
| 340 |
+
<div className="w-2.5 h-2.5 bg-pink-300 rounded-full animate-pulse" style={{animationDelay: '0s'}}></div>
|
| 341 |
+
<div className="w-2.5 h-2.5 bg-pink-300 rounded-full animate-pulse" style={{animationDelay: '0.2s'}}></div>
|
| 342 |
+
<div className="w-2.5 h-2.5 bg-pink-300 rounded-full animate-pulse" style={{animationDelay: '0.4s'}}></div>
|
| 343 |
+
</div>
|
| 344 |
+
</span>
|
| 345 |
+
</div>
|
| 346 |
+
)}
|
| 347 |
+
</div>
|
| 348 |
+
<div className="flex gap-3 items-center">
|
| 349 |
+
<input
|
| 350 |
+
type="text"
|
| 351 |
+
value={currentPlayerMessage}
|
| 352 |
+
onChange={(e) => setCurrentPlayerMessage(e.target.value)}
|
| 353 |
+
onKeyPress={(e) => e.key === 'Enter' && handlePlayerSendMessage()}
|
| 354 |
+
placeholder="Ask the AI Game Guide..."
|
| 355 |
+
className="flex-grow p-3 text-base rounded-lg bg-theme-dark/70 border-2 border-theme-border/60 text-white focus:ring-2 focus:ring-pink-500 focus:border-transparent transition-all placeholder-gray-400"
|
| 356 |
+
aria-label="Chat message input"
|
| 357 |
+
disabled={isAiResponding}
|
| 358 |
+
/>
|
| 359 |
+
<button
|
| 360 |
+
onClick={handlePlayerSendMessage}
|
| 361 |
+
className="py-3 px-6 bg-gradient-to-r from-pink-500 to-red-500 hover:from-pink-600 hover:to-red-600 text-white font-semibold rounded-lg shadow-md transition-all duration-200 transform hover:scale-105 disabled:opacity-60 disabled:hover:scale-100 disabled:cursor-not-allowed"
|
| 362 |
+
disabled={isAiResponding || currentPlayerMessage.trim() === ''}
|
| 363 |
+
>
|
| 364 |
+
{isAiResponding ? 'Sending...' : 'Send'}
|
| 365 |
+
</button>
|
| 366 |
+
</div>
|
| 367 |
+
</div>
|
| 368 |
+
</div>
|
| 369 |
+
</Modal>
|
| 370 |
+
);
|
| 371 |
+
};
|
README.md
CHANGED
|
@@ -1,11 +1,14 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Run and deploy your AI Studio app
|
| 2 |
+
|
| 3 |
+
This contains everything you need to run your app locally.
|
| 4 |
+
|
| 5 |
+
## Run Locally
|
| 6 |
+
|
| 7 |
+
**Prerequisites:** Node.js
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
1. Install dependencies:
|
| 11 |
+
`npm install`
|
| 12 |
+
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
| 13 |
+
3. Run the app:
|
| 14 |
+
`npm run dev`
|
SoundControlBar.tsx
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
|
| 3 |
+
interface SoundControlBarProps {
|
| 4 |
+
volume: number; // 0 to 1
|
| 5 |
+
isMuted: boolean;
|
| 6 |
+
onVolumeChange: (volume: number) => void;
|
| 7 |
+
onMuteToggle: () => void;
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export const SoundControlBar: React.FC<SoundControlBarProps> = ({
|
| 11 |
+
volume,
|
| 12 |
+
isMuted,
|
| 13 |
+
onVolumeChange,
|
| 14 |
+
onMuteToggle,
|
| 15 |
+
}) => {
|
| 16 |
+
return (
|
| 17 |
+
<div className="flex items-center gap-2 p-1 bg-theme-primary/30 rounded-lg shadow">
|
| 18 |
+
<button
|
| 19 |
+
onClick={onMuteToggle}
|
| 20 |
+
className="px-2 py-1 text-lg bg-pink-600 hover:bg-pink-700 text-white rounded-md transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-pink-500"
|
| 21 |
+
aria-label={isMuted ? 'Unmute game sounds' : 'Mute game sounds'}
|
| 22 |
+
aria-pressed={isMuted}
|
| 23 |
+
>
|
| 24 |
+
{isMuted ? '🔇' : '🔊'}
|
| 25 |
+
</button>
|
| 26 |
+
<input
|
| 27 |
+
type="range"
|
| 28 |
+
min="0"
|
| 29 |
+
max="100"
|
| 30 |
+
value={isMuted ? 0 : Math.round(volume * 100)}
|
| 31 |
+
onChange={(e) => {
|
| 32 |
+
const newVolume = parseInt(e.target.value, 10) / 100;
|
| 33 |
+
if (isMuted && newVolume > 0) { // If user interacts with slider while muted, unmute
|
| 34 |
+
onMuteToggle(); // This will trigger a re-render, onVolumeChange will apply the new volume
|
| 35 |
+
}
|
| 36 |
+
onVolumeChange(newVolume);
|
| 37 |
+
}}
|
| 38 |
+
className="w-20 h-2 accent-pink-500 cursor-pointer"
|
| 39 |
+
aria-label="Game volume"
|
| 40 |
+
disabled={isMuted} // Optionally disable slider when muted, or let it unmute
|
| 41 |
+
/>
|
| 42 |
+
</div>
|
| 43 |
+
);
|
| 44 |
+
};
|
SpinToWinModal.tsx
ADDED
|
@@ -0,0 +1,1006 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
| 2 |
+
import { Modal } from '../Modal';
|
| 3 |
+
import { NotificationMessage, AdminConfig } from '../../types'; // Added AdminConfig
|
| 4 |
+
import { generateId } from '../../utils';
|
| 5 |
+
import { playSound, SoundEvent } from '../../sounds'; // Added playSound and SoundEvent
|
| 6 |
+
|
| 7 |
+
interface SpinToWinModalProps {
|
| 8 |
+
isOpen: boolean;
|
| 9 |
+
onClose: () => void;
|
| 10 |
+
playerSeaTokens: number;
|
| 11 |
+
onAdjustSeaTokens: (amount: number) => void;
|
| 12 |
+
showNotification: (message: string, type?: NotificationMessage['type']) => void;
|
| 13 |
+
adminConfig: AdminConfig; // Added adminConfig
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
type SpinToWinTab = 'lottery' | 'spin' | 'rewards';
|
| 17 |
+
|
| 18 |
+
interface LotteryActivity {
|
| 19 |
+
id: string;
|
| 20 |
+
ticketNumber: number;
|
| 21 |
+
message: string;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
interface SpinActivity {
|
| 25 |
+
id: string;
|
| 26 |
+
spinNumber: number;
|
| 27 |
+
message: string;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
interface WheelPrize {
|
| 31 |
+
type: 'dwhl' | 'extra_spin' | 'lottery_ticket';
|
| 32 |
+
value?: number; // For DWHL
|
| 33 |
+
display: string; // Text on the wheel
|
| 34 |
+
id: string; // Unique ID for the prize
|
| 35 |
+
isJackpot?: boolean; // For special win announcements
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
interface SpinGameState {
|
| 39 |
+
balance: number;
|
| 40 |
+
lastSpinTime: number;
|
| 41 |
+
spinning: boolean;
|
| 42 |
+
lottery: {
|
| 43 |
+
ticketPrice: number;
|
| 44 |
+
userTicketsThisSession: number;
|
| 45 |
+
currentTicketPrizes: Array<number | string>; // string for "Try Again" or "Miss"
|
| 46 |
+
isTicketActive: boolean;
|
| 47 |
+
revealedCardIndex: number | null;
|
| 48 |
+
activityLog: LotteryActivity[];
|
| 49 |
+
ticketsOnHand: number;
|
| 50 |
+
lastDailyTicketClaimTime: number; // New
|
| 51 |
+
lotteryWins: number; // New for trophies
|
| 52 |
+
};
|
| 53 |
+
spin: {
|
| 54 |
+
activityLog: SpinActivity[];
|
| 55 |
+
userSpinsThisSession: number;
|
| 56 |
+
hasExtraSpin?: boolean;
|
| 57 |
+
};
|
| 58 |
+
rewards: {
|
| 59 |
+
lastClaimTime: number; // For daily DWHL reward
|
| 60 |
+
streak: number;
|
| 61 |
+
};
|
| 62 |
+
referral: { // New for referral system
|
| 63 |
+
playerReferralCode: string;
|
| 64 |
+
enteredReferralCode: string | null;
|
| 65 |
+
referralBonusClaimed: boolean;
|
| 66 |
+
};
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
// Constants for the Spin & Win game
|
| 70 |
+
const SPIN_COST = 5;
|
| 71 |
+
const SPIN_COOLDOWN = 24 * 60 * 60 * 1000;
|
| 72 |
+
const LOTTERY_TICKET_PRICE = 10;
|
| 73 |
+
const POSSIBLE_LOTTERY_PRIZES = [5, 10, 20, 50, 100];
|
| 74 |
+
const DAILY_DWHL_REWARD_COOLDOWN = 24 * 60 * 60 * 1000;
|
| 75 |
+
const DAILY_TICKET_COOLDOWN = 24 * 60 * 60 * 1000;
|
| 76 |
+
const MAX_LOTTERY_LOG_ENTRIES = 5;
|
| 77 |
+
const MAX_SPIN_LOG_ENTRIES = 5;
|
| 78 |
+
|
| 79 |
+
const WHEEL_PRIZES: WheelPrize[] = [
|
| 80 |
+
{ type: 'dwhl', value: 1, display: '1 ST', id: 'ST_1' },
|
| 81 |
+
{ type: 'extra_spin', display: '🔁 Spin Again!', id: 'EXTRA_SPIN' },
|
| 82 |
+
{ type: 'dwhl', value: 5, display: '5 ST', id: 'ST_5' },
|
| 83 |
+
{ type: 'lottery_ticket', display: '🎫 Lottery Ticket', id: 'LOTTERY_TICKET' },
|
| 84 |
+
{ type: 'dwhl', value: 10, display: '10 ST', id: 'ST_10' },
|
| 85 |
+
{ type: 'dwhl', value: 20, display: '20 ST', id: 'ST_20' },
|
| 86 |
+
{ type: 'dwhl', value: 50, display: '50 ST', id: 'ST_50', isJackpot: true },
|
| 87 |
+
{ type: 'dwhl', value: 2, display: '2 ST', id: 'ST_2' }, // Changed one prize for variety
|
| 88 |
+
];
|
| 89 |
+
|
| 90 |
+
const WHEEL_COLORS = [
|
| 91 |
+
'bg-gradient-to-br from-red-500 to-red-600',
|
| 92 |
+
'bg-gradient-to-br from-blue-500 to-blue-600',
|
| 93 |
+
'bg-gradient-to-br from-green-500 to-green-600',
|
| 94 |
+
'bg-gradient-to-br from-yellow-500 to-yellow-600',
|
| 95 |
+
'bg-gradient-to-br from-purple-500 to-purple-600',
|
| 96 |
+
'bg-gradient-to-br from-pink-500 to-pink-600',
|
| 97 |
+
'bg-gradient-to-br from-indigo-500 to-indigo-600',
|
| 98 |
+
'bg-gradient-to-br from-teal-500 to-teal-600'
|
| 99 |
+
];
|
| 100 |
+
|
| 101 |
+
const DAILY_REWARDS_STREAK = [
|
| 102 |
+
5, 7, 10, 12, 15, 18, 30, 10, 13, 16, 19, 22, 25, 40,
|
| 103 |
+
15, 18, 21, 24, 27, 30, 50, 20, 24, 28, 32, 36, 40, 75,
|
| 104 |
+
50, 150
|
| 105 |
+
];
|
| 106 |
+
|
| 107 |
+
const WHALE_BACKGROUND_IMAGE_URL = 'https://www.transparentpng.com/thumb/whale/GJTwCR-whale-vector-graphics-ocean-sea-transparent-background.png';
|
| 108 |
+
|
| 109 |
+
const TROPHY_THRESHOLDS = { bronze: 3, silver: 7, gold: 15 };
|
| 110 |
+
|
| 111 |
+
export const SpinToWinModal: React.FC<SpinToWinModalProps> = ({
|
| 112 |
+
isOpen,
|
| 113 |
+
onClose,
|
| 114 |
+
playerSeaTokens,
|
| 115 |
+
onAdjustSeaTokens,
|
| 116 |
+
showNotification,
|
| 117 |
+
adminConfig,
|
| 118 |
+
}) => {
|
| 119 |
+
const [activeTab, setActiveTab] = useState<SpinToWinTab>('lottery');
|
| 120 |
+
const [numTicketsToBuyInput, setNumTicketsToBuyInput] = useState<string>("1");
|
| 121 |
+
const [enteredReferralCodeInput, setEnteredReferralCodeInput] = useState<string>("");
|
| 122 |
+
const [rewardRevealMessage, setRewardRevealMessage] = useState<string>(""); // For "Opening reward..."
|
| 123 |
+
|
| 124 |
+
const [spinGameState, setSpinGameState] = useState<SpinGameState>(() => {
|
| 125 |
+
const savedState = localStorage.getItem('dwhlSpinGameStateV7'); // Incremented version
|
| 126 |
+
const defaultPlayerReferralCode = `USER-${generateId().substring(0, 6).toUpperCase()}`;
|
| 127 |
+
if (savedState) {
|
| 128 |
+
try {
|
| 129 |
+
const parsed = JSON.parse(savedState) as SpinGameState;
|
| 130 |
+
// Ensure all new fields exist
|
| 131 |
+
return {
|
| 132 |
+
balance: playerSeaTokens,
|
| 133 |
+
lastSpinTime: parsed.lastSpinTime || 0,
|
| 134 |
+
spinning: false,
|
| 135 |
+
lottery: {
|
| 136 |
+
ticketPrice: parsed.lottery?.ticketPrice || LOTTERY_TICKET_PRICE,
|
| 137 |
+
userTicketsThisSession: parsed.lottery?.userTicketsThisSession || 0,
|
| 138 |
+
currentTicketPrizes: parsed.lottery?.currentTicketPrizes || [],
|
| 139 |
+
isTicketActive: parsed.lottery?.isTicketActive || false,
|
| 140 |
+
revealedCardIndex: parsed.lottery?.revealedCardIndex !== undefined ? parsed.lottery.revealedCardIndex : null,
|
| 141 |
+
activityLog: parsed.lottery?.activityLog || [],
|
| 142 |
+
ticketsOnHand: parsed.lottery?.ticketsOnHand || 0,
|
| 143 |
+
lastDailyTicketClaimTime: parsed.lottery?.lastDailyTicketClaimTime || 0,
|
| 144 |
+
lotteryWins: parsed.lottery?.lotteryWins || 0,
|
| 145 |
+
},
|
| 146 |
+
spin: {
|
| 147 |
+
activityLog: parsed.spin?.activityLog || [],
|
| 148 |
+
userSpinsThisSession: parsed.spin?.userSpinsThisSession || 0,
|
| 149 |
+
hasExtraSpin: parsed.spin?.hasExtraSpin || false,
|
| 150 |
+
},
|
| 151 |
+
rewards: {
|
| 152 |
+
lastClaimTime: parsed.rewards?.lastClaimTime || 0,
|
| 153 |
+
streak: parsed.rewards?.streak || 0,
|
| 154 |
+
},
|
| 155 |
+
referral: {
|
| 156 |
+
playerReferralCode: parsed.referral?.playerReferralCode || defaultPlayerReferralCode,
|
| 157 |
+
enteredReferralCode: parsed.referral?.enteredReferralCode || null,
|
| 158 |
+
referralBonusClaimed: parsed.referral?.referralBonusClaimed || false,
|
| 159 |
+
}
|
| 160 |
+
};
|
| 161 |
+
} catch (e) {
|
| 162 |
+
console.error("Failed to parse spin game state V7 from localStorage", e);
|
| 163 |
+
}
|
| 164 |
+
}
|
| 165 |
+
return {
|
| 166 |
+
balance: playerSeaTokens,
|
| 167 |
+
lastSpinTime: 0,
|
| 168 |
+
spinning: false,
|
| 169 |
+
lottery: {
|
| 170 |
+
ticketPrice: LOTTERY_TICKET_PRICE,
|
| 171 |
+
userTicketsThisSession: 0,
|
| 172 |
+
currentTicketPrizes: [],
|
| 173 |
+
isTicketActive: false,
|
| 174 |
+
revealedCardIndex: null,
|
| 175 |
+
activityLog: [],
|
| 176 |
+
ticketsOnHand: 0,
|
| 177 |
+
lastDailyTicketClaimTime: 0,
|
| 178 |
+
lotteryWins: 0,
|
| 179 |
+
},
|
| 180 |
+
spin: {
|
| 181 |
+
activityLog: [],
|
| 182 |
+
userSpinsThisSession: 0,
|
| 183 |
+
hasExtraSpin: false,
|
| 184 |
+
},
|
| 185 |
+
rewards: {
|
| 186 |
+
lastClaimTime: 0,
|
| 187 |
+
streak: 0,
|
| 188 |
+
},
|
| 189 |
+
referral: {
|
| 190 |
+
playerReferralCode: defaultPlayerReferralCode,
|
| 191 |
+
enteredReferralCode: null,
|
| 192 |
+
referralBonusClaimed: false,
|
| 193 |
+
}
|
| 194 |
+
};
|
| 195 |
+
});
|
| 196 |
+
|
| 197 |
+
const wheelRef = useRef<HTMLDivElement>(null);
|
| 198 |
+
const confettiContainerRef = useRef<HTMLDivElement>(null);
|
| 199 |
+
const resultAmountRef = useRef<HTMLDivElement>(null);
|
| 200 |
+
const resultMessageRef = useRef<HTMLDivElement>(null);
|
| 201 |
+
const spinTimerRef = useRef<HTMLDivElement>(null);
|
| 202 |
+
const spinButtonRef = useRef<HTMLButtonElement>(null);
|
| 203 |
+
const lotteryResultMsgRef = useRef<HTMLDivElement>(null);
|
| 204 |
+
|
| 205 |
+
useEffect(() => {
|
| 206 |
+
if (isOpen) {
|
| 207 |
+
if (spinGameState.balance !== playerSeaTokens) {
|
| 208 |
+
setSpinGameState(prev => ({ ...prev, balance: playerSeaTokens }));
|
| 209 |
+
}
|
| 210 |
+
}
|
| 211 |
+
}, [isOpen, playerSeaTokens, spinGameState.balance]);
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
useEffect(() => {
|
| 215 |
+
localStorage.setItem('dwhlSpinGameStateV7', JSON.stringify(spinGameState));
|
| 216 |
+
}, [spinGameState]);
|
| 217 |
+
|
| 218 |
+
const updateInternalBalance = useCallback((amount: number) => {
|
| 219 |
+
setSpinGameState(prev => {
|
| 220 |
+
const newBalance = Math.max(0, prev.balance + amount);
|
| 221 |
+
onAdjustSeaTokens(newBalance - prev.balance); // Adjust player's global balance
|
| 222 |
+
return { ...prev, balance: newBalance };
|
| 223 |
+
});
|
| 224 |
+
}, [onAdjustSeaTokens]);
|
| 225 |
+
|
| 226 |
+
const addLotteryLogEntry = useCallback((message: string, ticketNumber: number) => {
|
| 227 |
+
setSpinGameState(prev => {
|
| 228 |
+
const newLogEntry: LotteryActivity = { id: generateId(), ticketNumber, message };
|
| 229 |
+
const updatedLog = [newLogEntry, ...prev.lottery.activityLog].slice(0, MAX_LOTTERY_LOG_ENTRIES);
|
| 230 |
+
return {
|
| 231 |
+
...prev,
|
| 232 |
+
lottery: { ...prev.lottery, activityLog: updatedLog }
|
| 233 |
+
};
|
| 234 |
+
});
|
| 235 |
+
}, []);
|
| 236 |
+
|
| 237 |
+
const initWheel = useCallback(() => {
|
| 238 |
+
if (!wheelRef.current) return;
|
| 239 |
+
wheelRef.current.innerHTML = '';
|
| 240 |
+
WHEEL_PRIZES.forEach((prize, i) => {
|
| 241 |
+
const section = document.createElement('div');
|
| 242 |
+
section.className = `wheel-section ${WHEEL_COLORS[i % WHEEL_COLORS.length]}`;
|
| 243 |
+
section.style.transform = `rotate(${i * (360 / WHEEL_PRIZES.length)}deg)`;
|
| 244 |
+
section.style.outline = '1px solid rgba(255,255,255,0.2)';
|
| 245 |
+
|
| 246 |
+
const span = document.createElement('span');
|
| 247 |
+
span.textContent = prize.display;
|
| 248 |
+
span.style.fontSize = '0.7rem'; // Slightly smaller for better fit
|
| 249 |
+
span.style.transform = `rotate(${(360 / WHEEL_PRIZES.length) / 2}deg) translateX(55px) translateY(-50%) rotate(-${(360 / WHEEL_PRIZES.length) / 2}deg)`;
|
| 250 |
+
span.style.position = 'absolute';
|
| 251 |
+
span.style.top = '50%';
|
| 252 |
+
span.style.left = '5%';
|
| 253 |
+
span.style.fontWeight = 'bold';
|
| 254 |
+
span.style.color = 'white';
|
| 255 |
+
span.style.textShadow = '1px 1px 3px rgba(0,0,0,0.8)';
|
| 256 |
+
|
| 257 |
+
section.appendChild(span);
|
| 258 |
+
section.dataset.value = prize.id;
|
| 259 |
+
wheelRef.current?.appendChild(section);
|
| 260 |
+
});
|
| 261 |
+
}, []);
|
| 262 |
+
|
| 263 |
+
useEffect(() => {
|
| 264 |
+
if (isOpen && activeTab === 'spin') {
|
| 265 |
+
initWheel();
|
| 266 |
+
}
|
| 267 |
+
}, [isOpen, activeTab, initWheel]);
|
| 268 |
+
|
| 269 |
+
const createConfetti = useCallback(() => {
|
| 270 |
+
if (!confettiContainerRef.current) return;
|
| 271 |
+
confettiContainerRef.current.innerHTML = ''; // Clear previous confetti
|
| 272 |
+
const colors = ['#ef4444', '#3b82f6', '#22c55e', '#eab308', '#a855f7', '#ec4899', '#f97316', '#14b8a6'];
|
| 273 |
+
for (let i = 0; i < 150; i++) { // Increased confetti count
|
| 274 |
+
const confetti = document.createElement('div');
|
| 275 |
+
confetti.className = 'confetti'; // Animation applied via CSS
|
| 276 |
+
const size = Math.random() * 12 + 6; // Slightly larger confetti
|
| 277 |
+
const color = colors[Math.floor(Math.random() * colors.length)];
|
| 278 |
+
const left = Math.random() * 100;
|
| 279 |
+
const delay = Math.random() * 2.5; // Increased delay spread
|
| 280 |
+
const duration = Math.random() * 2 + 2; // Slightly longer duration
|
| 281 |
+
|
| 282 |
+
confetti.style.width = `${size}px`;
|
| 283 |
+
confetti.style.height = `${size}px`;
|
| 284 |
+
confetti.style.backgroundColor = color;
|
| 285 |
+
confetti.style.left = `${left}%`;
|
| 286 |
+
// Initial position and opacity set by CSS keyframes
|
| 287 |
+
confetti.style.animationDelay = `${delay}s`;
|
| 288 |
+
confetti.style.animationDuration = `${duration}s`;
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
if (Math.random() > 0.5) confetti.style.borderRadius = '50%';
|
| 292 |
+
else { // Add more shapes
|
| 293 |
+
const clipPaths = [
|
| 294 |
+
'polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%)', // Star
|
| 295 |
+
'rect(0,0,100%,100%)' // Rectangle (default if not circle)
|
| 296 |
+
];
|
| 297 |
+
confetti.style.clipPath = clipPaths[Math.floor(Math.random()*clipPaths.length)];
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
confettiContainerRef.current?.appendChild(confetti);
|
| 301 |
+
|
| 302 |
+
// Auto-remove after animation + a small buffer
|
| 303 |
+
setTimeout(() => confetti.remove(), (duration + delay + 0.5) * 1000);
|
| 304 |
+
}
|
| 305 |
+
}, []);
|
| 306 |
+
|
| 307 |
+
|
| 308 |
+
const showSpinResultDisplay = useCallback((prize: WheelPrize) => {
|
| 309 |
+
setRewardRevealMessage("Revealing your prize...");
|
| 310 |
+
if (resultAmountRef.current) resultAmountRef.current.textContent = ""; // Clear previous
|
| 311 |
+
|
| 312 |
+
setTimeout(() => { // Short delay for "Revealing..."
|
| 313 |
+
if (resultAmountRef.current) resultAmountRef.current.textContent = prize.display;
|
| 314 |
+
if (resultMessageRef.current) {
|
| 315 |
+
if (prize.isJackpot) {
|
| 316 |
+
resultMessageRef.current.textContent = '🎉 JACKPOT! 🎉 You hit the big one!';
|
| 317 |
+
resultAmountRef.current?.classList.add('animate-bounce');
|
| 318 |
+
playSound(SoundEvent.SpinWin, adminConfig); // Jackpot sound
|
| 319 |
+
setTimeout(() => resultAmountRef.current?.classList.remove('animate-bounce'), 3000);
|
| 320 |
+
} else {
|
| 321 |
+
resultMessageRef.current.textContent = 'Congratulations! You won:';
|
| 322 |
+
playSound(SoundEvent.SpinWin, adminConfig);
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
createConfetti();
|
| 326 |
+
setRewardRevealMessage(""); // Clear reveal message
|
| 327 |
+
}, 1000); // 1 second suspense
|
| 328 |
+
}, [createConfetti, adminConfig]);
|
| 329 |
+
|
| 330 |
+
const spinWheel = useCallback(() => {
|
| 331 |
+
if (spinGameState.spinning) return;
|
| 332 |
+
|
| 333 |
+
const isExtraSpin = spinGameState.spin.hasExtraSpin;
|
| 334 |
+
|
| 335 |
+
if (!isExtraSpin) {
|
| 336 |
+
if (spinGameState.balance < SPIN_COST) {
|
| 337 |
+
showNotification(`You need at least ${SPIN_COST} ST to spin!`, 'error');
|
| 338 |
+
return;
|
| 339 |
+
}
|
| 340 |
+
const now = Date.now();
|
| 341 |
+
const timeRemaining = spinGameState.lastSpinTime + SPIN_COOLDOWN - now;
|
| 342 |
+
if (timeRemaining > 0) {
|
| 343 |
+
showNotification(`Next spin available in: ${Math.ceil(timeRemaining / (1000 * 60))} minutes.`, 'warning');
|
| 344 |
+
return;
|
| 345 |
+
}
|
| 346 |
+
updateInternalBalance(-SPIN_COST);
|
| 347 |
+
setSpinGameState(prev => ({ ...prev, lastSpinTime: now }));
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
setSpinGameState(prev => ({ ...prev, spinning: true, spin: { ...prev.spin, hasExtraSpin: false } }));
|
| 351 |
+
|
| 352 |
+
if (spinButtonRef.current) spinButtonRef.current.disabled = true;
|
| 353 |
+
setRewardRevealMessage("Spinning the wheel..."); // Initial message
|
| 354 |
+
if (resultAmountRef.current) resultAmountRef.current.textContent = '';
|
| 355 |
+
if (wheelRef.current) wheelRef.current.classList.add('spinning');
|
| 356 |
+
|
| 357 |
+
const winningIndex = Math.floor(Math.random() * WHEEL_PRIZES.length);
|
| 358 |
+
const winningPrize = WHEEL_PRIZES[winningIndex];
|
| 359 |
+
const segmentAngle = 360 / WHEEL_PRIZES.length;
|
| 360 |
+
const rotations = 5;
|
| 361 |
+
const segmentCenterOffset = (Math.random() * (segmentAngle * 0.8) - (segmentAngle * 0.4));
|
| 362 |
+
const winningAngle = -(winningIndex * segmentAngle) + segmentCenterOffset;
|
| 363 |
+
const totalRotation = rotations * 360 + winningAngle;
|
| 364 |
+
|
| 365 |
+
setTimeout(() => {
|
| 366 |
+
if (wheelRef.current) {
|
| 367 |
+
wheelRef.current.classList.remove('spinning');
|
| 368 |
+
wheelRef.current.style.transform = `rotate(${totalRotation}deg)`;
|
| 369 |
+
wheelRef.current.style.transition = 'transform 4s cubic-bezier(0.25, 0.1, 0.25, 1)'; // Slower spin result
|
| 370 |
+
}
|
| 371 |
+
setTimeout(() => {
|
| 372 |
+
showSpinResultDisplay(winningPrize);
|
| 373 |
+
let spinLogMessage = "";
|
| 374 |
+
|
| 375 |
+
switch (winningPrize.type) {
|
| 376 |
+
case 'dwhl':
|
| 377 |
+
updateInternalBalance(winningPrize.value!);
|
| 378 |
+
showNotification(`You won ${winningPrize.value} ST from the wheel!`, 'success');
|
| 379 |
+
spinLogMessage = `Won ${winningPrize.value} ST!`;
|
| 380 |
+
break;
|
| 381 |
+
case 'extra_spin':
|
| 382 |
+
setSpinGameState(prev => ({ ...prev, spin: { ...prev.spin, hasExtraSpin: true } }));
|
| 383 |
+
showNotification('You won an Extra Spin!', 'special');
|
| 384 |
+
spinLogMessage = `Won an Extra Spin!`;
|
| 385 |
+
playSound(SoundEvent.SpinWin, adminConfig); // Win sound for extra spin
|
| 386 |
+
break;
|
| 387 |
+
case 'lottery_ticket':
|
| 388 |
+
setSpinGameState(prev => ({ ...prev, lottery: { ...prev.lottery, ticketsOnHand: prev.lottery.ticketsOnHand + 1 } }));
|
| 389 |
+
showNotification('You won a Lottery Ticket!', 'success');
|
| 390 |
+
spinLogMessage = `Won a Lottery Ticket!`;
|
| 391 |
+
playSound(SoundEvent.SpinWin, adminConfig); // Win sound for ticket
|
| 392 |
+
break;
|
| 393 |
+
}
|
| 394 |
+
if (winningPrize.type !== 'dwhl' && winningPrize.type !== 'extra_spin' && winningPrize.type !== 'lottery_ticket') {
|
| 395 |
+
playSound(SoundEvent.SpinLose, adminConfig); // If it's not a direct win, play lose sound
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
|
| 399 |
+
setSpinGameState(prev => {
|
| 400 |
+
const newSpinNumberThisSession = prev.spin.userSpinsThisSession + 1;
|
| 401 |
+
const newLogEntry: SpinActivity = {
|
| 402 |
+
id: generateId(),
|
| 403 |
+
spinNumber: newSpinNumberThisSession,
|
| 404 |
+
message: `Spin #${newSpinNumberThisSession}: ${spinLogMessage}`
|
| 405 |
+
};
|
| 406 |
+
const updatedLog = [newLogEntry, ...prev.spin.activityLog].slice(0, MAX_SPIN_LOG_ENTRIES);
|
| 407 |
+
return {
|
| 408 |
+
...prev,
|
| 409 |
+
spinning: false,
|
| 410 |
+
spin: {
|
| 411 |
+
...prev.spin,
|
| 412 |
+
userSpinsThisSession: newSpinNumberThisSession,
|
| 413 |
+
activityLog: updatedLog,
|
| 414 |
+
}
|
| 415 |
+
};
|
| 416 |
+
});
|
| 417 |
+
if (spinButtonRef.current) spinButtonRef.current.disabled = false;
|
| 418 |
+
}, 4500); // Adjusted for slower spin
|
| 419 |
+
}, 1000); // Initial delay before spin starts visually
|
| 420 |
+
}, [spinGameState, showNotification, updateInternalBalance, showSpinResultDisplay, adminConfig]);
|
| 421 |
+
|
| 422 |
+
useEffect(() => {
|
| 423 |
+
if (!isOpen || activeTab !== 'spin' || !spinTimerRef.current || !spinButtonRef.current) return;
|
| 424 |
+
|
| 425 |
+
const updateSpinStatus = () => {
|
| 426 |
+
if (spinGameState.spin.hasExtraSpin) {
|
| 427 |
+
if (spinTimerRef.current) spinTimerRef.current.textContent = 'You have an Extra Spin!';
|
| 428 |
+
spinButtonRef.current!.disabled = spinGameState.spinning;
|
| 429 |
+
if (!spinButtonRef.current!.disabled) spinButtonRef.current!.classList.add('pulse-spin-button');
|
| 430 |
+
else spinButtonRef.current!.classList.remove('pulse-spin-button');
|
| 431 |
+
return;
|
| 432 |
+
}
|
| 433 |
+
|
| 434 |
+
const now = Date.now();
|
| 435 |
+
const timeRemaining = spinGameState.lastSpinTime + SPIN_COOLDOWN - now;
|
| 436 |
+
if (timeRemaining <= 0) {
|
| 437 |
+
if (spinTimerRef.current) spinTimerRef.current.textContent = 'Spin available now!';
|
| 438 |
+
spinButtonRef.current!.disabled = spinGameState.spinning || spinGameState.balance < SPIN_COST;
|
| 439 |
+
if (!spinButtonRef.current!.disabled) spinButtonRef.current!.classList.add('pulse-spin-button');
|
| 440 |
+
else spinButtonRef.current!.classList.remove('pulse-spin-button');
|
| 441 |
+
} else {
|
| 442 |
+
const hours = Math.floor(timeRemaining / (1000 * 60 * 60));
|
| 443 |
+
const minutes = Math.floor((timeRemaining % (1000 * 60 * 60)) / (1000 * 60));
|
| 444 |
+
const seconds = Math.floor((timeRemaining % (1000 * 60)) / 1000);
|
| 445 |
+
if (spinTimerRef.current) spinTimerRef.current.textContent = `Next spin in: ${hours}h ${minutes}m ${seconds}s`;
|
| 446 |
+
spinButtonRef.current!.disabled = true;
|
| 447 |
+
spinButtonRef.current!.classList.remove('pulse-spin-button');
|
| 448 |
+
}
|
| 449 |
+
};
|
| 450 |
+
|
| 451 |
+
updateSpinStatus();
|
| 452 |
+
const intervalId = setInterval(updateSpinStatus, 1000);
|
| 453 |
+
return () => clearInterval(intervalId);
|
| 454 |
+
}, [isOpen, activeTab, spinGameState]);
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
// Lottery Logic
|
| 458 |
+
const handlePlayNextTicket = useCallback(() => {
|
| 459 |
+
if (spinGameState.lottery.ticketsOnHand <= 0) {
|
| 460 |
+
showNotification("No tickets on hand. Buy some first!", 'warning');
|
| 461 |
+
return;
|
| 462 |
+
}
|
| 463 |
+
setRewardRevealMessage("Preparing your ticket...");
|
| 464 |
+
|
| 465 |
+
setTimeout(() => {
|
| 466 |
+
const winAmount = POSSIBLE_LOTTERY_PRIZES[Math.floor(Math.random() * POSSIBLE_LOTTERY_PRIZES.length)];
|
| 467 |
+
// Ensure only one actual prize, others are "misses"
|
| 468 |
+
let prizes: Array<number | string> = ["Miss", "Miss", "Miss"];
|
| 469 |
+
const winPosition = Math.floor(Math.random() * prizes.length);
|
| 470 |
+
prizes[winPosition] = Math.random() < 0.33 ? winAmount : "Miss"; // ~33% chance to win the actual prize
|
| 471 |
+
|
| 472 |
+
const newTicketNumberThisSession = spinGameState.lottery.userTicketsThisSession + 1;
|
| 473 |
+
|
| 474 |
+
setSpinGameState(prev => ({
|
| 475 |
+
...prev,
|
| 476 |
+
lottery: {
|
| 477 |
+
...prev.lottery,
|
| 478 |
+
currentTicketPrizes: prizes,
|
| 479 |
+
isTicketActive: true,
|
| 480 |
+
revealedCardIndex: null,
|
| 481 |
+
userTicketsThisSession: newTicketNumberThisSession,
|
| 482 |
+
ticketsOnHand: prev.lottery.ticketsOnHand - 1,
|
| 483 |
+
}
|
| 484 |
+
}));
|
| 485 |
+
if(lotteryResultMsgRef.current) lotteryResultMsgRef.current.textContent = `Ticket #${newTicketNumberThisSession}! Flip ONE card.`;
|
| 486 |
+
setRewardRevealMessage("");
|
| 487 |
+
}, 700); // Suspense delay
|
| 488 |
+
}, [spinGameState.lottery.ticketsOnHand, spinGameState.lottery.userTicketsThisSession, showNotification]);
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
const handleBuyMultipleTickets = useCallback(() => {
|
| 492 |
+
const numToBuy = parseInt(numTicketsToBuyInput, 10);
|
| 493 |
+
if (isNaN(numToBuy) || numToBuy <= 0 || numToBuy > 100) {
|
| 494 |
+
showNotification("Please enter a valid number of tickets to buy (1-100).", 'error');
|
| 495 |
+
return;
|
| 496 |
+
}
|
| 497 |
+
const totalCost = numToBuy * LOTTERY_TICKET_PRICE;
|
| 498 |
+
if (spinGameState.balance < totalCost) {
|
| 499 |
+
showNotification(`Not enough ST! ${numToBuy} tickets cost ${totalCost} ST.`, 'error');
|
| 500 |
+
return;
|
| 501 |
+
}
|
| 502 |
+
|
| 503 |
+
updateInternalBalance(-totalCost);
|
| 504 |
+
setSpinGameState(prev => ({
|
| 505 |
+
...prev,
|
| 506 |
+
lottery: {
|
| 507 |
+
...prev.lottery,
|
| 508 |
+
ticketsOnHand: prev.lottery.ticketsOnHand + numToBuy,
|
| 509 |
+
}
|
| 510 |
+
}));
|
| 511 |
+
showNotification(`${numToBuy} ticket${numToBuy > 1 ? 's' : ''} purchased! Ready to play.`, 'success');
|
| 512 |
+
playSound(SoundEvent.TokenCollect, adminConfig); // Sound for buying tickets
|
| 513 |
+
setNumTicketsToBuyInput("1");
|
| 514 |
+
if(lotteryResultMsgRef.current) lotteryResultMsgRef.current.textContent = `${numToBuy} tickets added. Play your next ticket!`;
|
| 515 |
+
|
| 516 |
+
}, [numTicketsToBuyInput, spinGameState.balance, updateInternalBalance, showNotification, adminConfig]);
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
const revealLotteryCard = useCallback((index: number) => {
|
| 520 |
+
if (!spinGameState.lottery.isTicketActive || spinGameState.lottery.revealedCardIndex !== null) return;
|
| 521 |
+
|
| 522 |
+
setSpinGameState(prev => ({
|
| 523 |
+
...prev,
|
| 524 |
+
lottery: { ...prev.lottery, revealedCardIndex: index }
|
| 525 |
+
}));
|
| 526 |
+
|
| 527 |
+
// After flip animation (CSS handles timing)
|
| 528 |
+
setTimeout(() => {
|
| 529 |
+
const prize = spinGameState.lottery.currentTicketPrizes[index];
|
| 530 |
+
let currentCardMessage = '';
|
| 531 |
+
const currentTicketNumber = spinGameState.lottery.userTicketsThisSession;
|
| 532 |
+
|
| 533 |
+
if (typeof prize === 'number' && prize > 0) {
|
| 534 |
+
updateInternalBalance(prize);
|
| 535 |
+
currentCardMessage = `🎉 Congratulations! You won ${prize} ST! 🎉`;
|
| 536 |
+
showNotification(currentCardMessage, 'success');
|
| 537 |
+
playSound(SoundEvent.LotteryWin, adminConfig);
|
| 538 |
+
setSpinGameState(prev => ({...prev, lottery: {...prev.lottery, lotteryWins: prev.lottery.lotteryWins + 1}}));
|
| 539 |
+
createConfetti();
|
| 540 |
+
addLotteryLogEntry(`Ticket #${currentTicketNumber}: Won ${prize} ST!`, currentTicketNumber);
|
| 541 |
+
} else {
|
| 542 |
+
currentCardMessage = `Better luck next time!`;
|
| 543 |
+
showNotification(currentCardMessage, 'info');
|
| 544 |
+
playSound(SoundEvent.LotteryLose, adminConfig);
|
| 545 |
+
addLotteryLogEntry(`Ticket #${currentTicketNumber}: Better luck next time.`, currentTicketNumber);
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
if (lotteryResultMsgRef.current) lotteryResultMsgRef.current.textContent = currentCardMessage;
|
| 549 |
+
|
| 550 |
+
setSpinGameState(prev => ({
|
| 551 |
+
...prev,
|
| 552 |
+
lottery: { ...prev.lottery, isTicketActive: false } // Ticket is now used
|
| 553 |
+
}));
|
| 554 |
+
}, 600); // Duration of flip animation
|
| 555 |
+
|
| 556 |
+
}, [spinGameState.lottery, updateInternalBalance, showNotification, createConfetti, addLotteryLogEntry, adminConfig]);
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
const [rewardTimerText, setRewardTimerText] = useState('');
|
| 560 |
+
const [dailyTicketTimerText, setDailyTicketTimerText] = useState('');
|
| 561 |
+
|
| 562 |
+
|
| 563 |
+
const claimDailyReward = useCallback(() => {
|
| 564 |
+
setRewardRevealMessage("Claiming your daily ST...");
|
| 565 |
+
setTimeout(() => {
|
| 566 |
+
const now = Date.now();
|
| 567 |
+
if (now - spinGameState.rewards.lastClaimTime < DAILY_DWHL_REWARD_COOLDOWN) {
|
| 568 |
+
showNotification("You've already claimed your daily ST reward today!", 'warning');
|
| 569 |
+
setRewardRevealMessage("");
|
| 570 |
+
return;
|
| 571 |
+
}
|
| 572 |
+
|
| 573 |
+
let newStreak = spinGameState.rewards.streak;
|
| 574 |
+
if (now - spinGameState.rewards.lastClaimTime >= DAILY_DWHL_REWARD_COOLDOWN * 2) {
|
| 575 |
+
newStreak = 0;
|
| 576 |
+
showNotification("ST Streak reset due to missing a day.", 'info');
|
| 577 |
+
}
|
| 578 |
+
|
| 579 |
+
const currentStreakDayIndex = newStreak % DAILY_REWARDS_STREAK.length;
|
| 580 |
+
const rewardAmount = DAILY_REWARDS_STREAK[currentStreakDayIndex];
|
| 581 |
+
const displayDay = currentStreakDayIndex + 1;
|
| 582 |
+
|
| 583 |
+
updateInternalBalance(rewardAmount);
|
| 584 |
+
setSpinGameState(prev => ({
|
| 585 |
+
...prev,
|
| 586 |
+
rewards: { lastClaimTime: now, streak: displayDay }
|
| 587 |
+
}));
|
| 588 |
+
showNotification(`Daily ST Reward (Day ${displayDay}): +${rewardAmount} ST!`, 'special');
|
| 589 |
+
playSound(SoundEvent.DailyRewardClaim, adminConfig);
|
| 590 |
+
createConfetti();
|
| 591 |
+
setRewardRevealMessage("");
|
| 592 |
+
}, 700);
|
| 593 |
+
}, [spinGameState.rewards, updateInternalBalance, showNotification, createConfetti, adminConfig]);
|
| 594 |
+
|
| 595 |
+
const claimDailyTicket = useCallback(() => {
|
| 596 |
+
setRewardRevealMessage("Claiming your daily ticket...");
|
| 597 |
+
setTimeout(() => {
|
| 598 |
+
const now = Date.now();
|
| 599 |
+
if (now - spinGameState.lottery.lastDailyTicketClaimTime < DAILY_TICKET_COOLDOWN) {
|
| 600 |
+
showNotification("You've already claimed your free daily ticket!", 'warning');
|
| 601 |
+
setRewardRevealMessage("");
|
| 602 |
+
return;
|
| 603 |
+
}
|
| 604 |
+
setSpinGameState(prev => ({
|
| 605 |
+
...prev,
|
| 606 |
+
lottery: {
|
| 607 |
+
...prev.lottery,
|
| 608 |
+
ticketsOnHand: prev.lottery.ticketsOnHand + 1,
|
| 609 |
+
lastDailyTicketClaimTime: now,
|
| 610 |
+
}
|
| 611 |
+
}));
|
| 612 |
+
showNotification("You received 1 Free Daily Lottery Ticket!", 'special');
|
| 613 |
+
playSound(SoundEvent.DailyRewardClaim, adminConfig); // Can use same sound or new one
|
| 614 |
+
createConfetti();
|
| 615 |
+
setRewardRevealMessage("");
|
| 616 |
+
}, 700);
|
| 617 |
+
}, [spinGameState.lottery, showNotification, createConfetti, adminConfig]);
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
const handleClaimReferralBonus = () => {
|
| 621 |
+
if (spinGameState.referral.referralBonusClaimed) {
|
| 622 |
+
showNotification("Referral bonus already claimed.", "info");
|
| 623 |
+
return;
|
| 624 |
+
}
|
| 625 |
+
if (!enteredReferralCodeInput.trim() || enteredReferralCodeInput.trim().toUpperCase() === spinGameState.referral.playerReferralCode) {
|
| 626 |
+
showNotification("Please enter a valid referral code (cannot be your own).", "warning");
|
| 627 |
+
return;
|
| 628 |
+
}
|
| 629 |
+
// Simple simulation: any non-empty, non-own code works once.
|
| 630 |
+
setSpinGameState(prev => ({
|
| 631 |
+
...prev,
|
| 632 |
+
lottery: {...prev.lottery, ticketsOnHand: prev.lottery.ticketsOnHand + 1},
|
| 633 |
+
referral: {...prev.referral, referralBonusClaimed: true, enteredReferralCode: enteredReferralCodeInput.trim().toUpperCase()}
|
| 634 |
+
}));
|
| 635 |
+
showNotification("Referral bonus claimed! You got 1 free lottery ticket.", "success");
|
| 636 |
+
playSound(SoundEvent.DailyRewardClaim, adminConfig);
|
| 637 |
+
setEnteredReferralCodeInput("");
|
| 638 |
+
};
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
useEffect(() => {
|
| 642 |
+
if (!isOpen || activeTab !== 'rewards') return;
|
| 643 |
+
const intervalId = setInterval(() => {
|
| 644 |
+
const now = Date.now();
|
| 645 |
+
// Daily ST Reward Timer
|
| 646 |
+
const timeRemainingReward = spinGameState.rewards.lastClaimTime + DAILY_DWHL_REWARD_COOLDOWN - now;
|
| 647 |
+
if (timeRemainingReward <= 0) {
|
| 648 |
+
setRewardTimerText('Daily ST Reward available now!');
|
| 649 |
+
} else {
|
| 650 |
+
const hours = Math.floor(timeRemainingReward / (1000 * 60 * 60));
|
| 651 |
+
const minutes = Math.floor((timeRemainingReward % (1000 * 60 * 60)) / (1000 * 60));
|
| 652 |
+
const seconds = Math.floor((timeRemainingReward % (1000 * 60)) / 1000);
|
| 653 |
+
setRewardTimerText(`Next ST reward in: ${hours}h ${minutes}m ${seconds}s`);
|
| 654 |
+
}
|
| 655 |
+
// Daily Ticket Timer
|
| 656 |
+
const timeRemainingTicket = spinGameState.lottery.lastDailyTicketClaimTime + DAILY_TICKET_COOLDOWN - now;
|
| 657 |
+
if (timeRemainingTicket <= 0) {
|
| 658 |
+
setDailyTicketTimerText('Free Daily Ticket available now!');
|
| 659 |
+
} else {
|
| 660 |
+
const hours = Math.floor(timeRemainingTicket / (1000 * 60 * 60));
|
| 661 |
+
const minutes = Math.floor((timeRemainingTicket % (1000 * 60 * 60)) / (1000 * 60));
|
| 662 |
+
const seconds = Math.floor((timeRemainingTicket % (1000 * 60)) / 1000);
|
| 663 |
+
setDailyTicketTimerText(`Next free ticket in: ${hours}h ${minutes}m ${seconds}s`);
|
| 664 |
+
}
|
| 665 |
+
|
| 666 |
+
}, 1000);
|
| 667 |
+
return () => clearInterval(intervalId);
|
| 668 |
+
}, [isOpen, activeTab, spinGameState.rewards.lastClaimTime, spinGameState.lottery.lastDailyTicketClaimTime]);
|
| 669 |
+
|
| 670 |
+
const canClaimDailyST = Date.now() - (spinGameState.rewards.lastClaimTime || 0) >= DAILY_DWHL_REWARD_COOLDOWN;
|
| 671 |
+
const canClaimDailyFreeTicket = Date.now() - (spinGameState.lottery.lastDailyTicketClaimTime || 0) >= DAILY_TICKET_COOLDOWN;
|
| 672 |
+
|
| 673 |
+
|
| 674 |
+
const handleFullScreen = () => {
|
| 675 |
+
const elem = document.documentElement;
|
| 676 |
+
if (!document.fullscreenElement) {
|
| 677 |
+
if (elem.requestFullscreen) {
|
| 678 |
+
elem.requestFullscreen().catch(err => {
|
| 679 |
+
showNotification(`Error attempting to enable full-screen mode: ${err.message} (${err.name})`, "error");
|
| 680 |
+
});
|
| 681 |
+
} else if ((elem as any).mozRequestFullScreen) { /* Firefox */
|
| 682 |
+
(elem as any).mozRequestFullScreen();
|
| 683 |
+
} else if ((elem as any).webkitRequestFullscreen) { /* Chrome, Safari & Opera */
|
| 684 |
+
(elem as any).webkitRequestFullscreen();
|
| 685 |
+
} else if ((elem as any).msRequestFullscreen) { /* IE/Edge */
|
| 686 |
+
(elem as any).msRequestFullscreen();
|
| 687 |
+
} else {
|
| 688 |
+
showNotification("Fullscreen API is not supported by your browser.", "warning");
|
| 689 |
+
}
|
| 690 |
+
} else {
|
| 691 |
+
if (document.exitFullscreen) {
|
| 692 |
+
document.exitFullscreen();
|
| 693 |
+
}
|
| 694 |
+
}
|
| 695 |
+
};
|
| 696 |
+
const TrophyDisplay: React.FC<{wins: number}> = ({ wins }) => {
|
| 697 |
+
let trophies = { bronze: false, silver: false, gold: false };
|
| 698 |
+
if (wins >= TROPHY_THRESHOLDS.bronze) trophies.bronze = true;
|
| 699 |
+
if (wins >= TROPHY_THRESHOLDS.silver) trophies.silver = true;
|
| 700 |
+
if (wins >= TROPHY_THRESHOLDS.gold) trophies.gold = true;
|
| 701 |
+
|
| 702 |
+
const getProgressBarWidth = (currentWins: number, nextThreshold: number) => {
|
| 703 |
+
if (currentWins >= nextThreshold) return 100;
|
| 704 |
+
const prevThreshold = nextThreshold === TROPHY_THRESHOLDS.bronze ? 0 :
|
| 705 |
+
nextThreshold === TROPHY_THRESHOLDS.silver ? TROPHY_THRESHOLDS.bronze :
|
| 706 |
+
TROPHY_THRESHOLDS.silver;
|
| 707 |
+
const range = nextThreshold - prevThreshold;
|
| 708 |
+
const progressInRange = currentWins - prevThreshold;
|
| 709 |
+
return Math.max(0, Math.min(100, (progressInRange / range) * 100));
|
| 710 |
+
};
|
| 711 |
+
|
| 712 |
+
return (
|
| 713 |
+
<div className="mt-3 space-y-2">
|
| 714 |
+
<div className="flex items-center">
|
| 715 |
+
<span className={`mr-2 text-xl ${trophies.bronze ? 'text-yellow-600' : 'text-gray-500'}`}>🥉</span>
|
| 716 |
+
<span className="text-sm">Bronze ({TROPHY_THRESHOLDS.bronze} wins):</span>
|
| 717 |
+
<div className="progress-container ml-2 flex-1"><div className="progress-bar bg-yellow-600" style={{width: `${getProgressBarWidth(wins, TROPHY_THRESHOLDS.bronze)}%`}}></div></div>
|
| 718 |
+
</div>
|
| 719 |
+
<div className="flex items-center">
|
| 720 |
+
<span className={`mr-2 text-xl ${trophies.silver ? 'text-gray-400' : 'text-gray-500'}`}>🥈</span>
|
| 721 |
+
<span className="text-sm">Silver ({TROPHY_THRESHOLDS.silver} wins):</span>
|
| 722 |
+
<div className="progress-container ml-2 flex-1"><div className="progress-bar bg-gray-400" style={{width: `${getProgressBarWidth(wins, TROPHY_THRESHOLDS.silver)}%`}}></div></div>
|
| 723 |
+
</div>
|
| 724 |
+
<div className="flex items-center">
|
| 725 |
+
<span className={`mr-2 text-xl ${trophies.gold ? 'text-yellow-400' : 'text-gray-500'}`}>🥇</span>
|
| 726 |
+
<span className="text-sm">Gold ({TROPHY_THRESHOLDS.gold} wins):</span>
|
| 727 |
+
<div className="progress-container ml-2 flex-1"><div className="progress-bar bg-yellow-400" style={{width: `${getProgressBarWidth(wins, TROPHY_THRESHOLDS.gold)}%`}}></div></div>
|
| 728 |
+
</div>
|
| 729 |
+
</div>
|
| 730 |
+
);
|
| 731 |
+
};
|
| 732 |
+
|
| 733 |
+
|
| 734 |
+
const renderSpinContent = () => {
|
| 735 |
+
const spinButtonText = spinGameState.spin.hasExtraSpin ? "USE EXTRA SPIN" : `SPIN (${SPIN_COST} ST)`;
|
| 736 |
+
return (
|
| 737 |
+
<div className="w-full text-center text-white">
|
| 738 |
+
{rewardRevealMessage && <p className="text-lg text-yellow-300 mb-2 animate-pulseCustom">{rewardRevealMessage}</p>}
|
| 739 |
+
<div className="relative w-64 h-64 md:w-80 md:h-80 mb-8 mx-auto">
|
| 740 |
+
<div className="absolute -top-4 left-1/2 transform -translate-x-1/2 w-0 h-0
|
| 741 |
+
border-l-[15px] border-r-[15px] border-b-[30px]
|
| 742 |
+
border-l-transparent border-r-transparent border-b-yellow-300
|
| 743 |
+
z-20 shadow-md"
|
| 744 |
+
style={{ filter: 'drop-shadow(0 3px 3px rgba(0,0,0,0.6))' }}
|
| 745 |
+
></div>
|
| 746 |
+
<div ref={wheelRef} id="spin-wheel-react" className="w-full h-full rounded-full bg-gray-800 relative overflow-hidden shadow-xl border-4 border-yellow-500/50">
|
| 747 |
+
</div>
|
| 748 |
+
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-12 h-12 md:w-16 md:h-16 rounded-full bg-gradient-to-br from-gray-200 to-gray-400 border-4 border-yellow-400 shadow-md z-10"></div>
|
| 749 |
+
</div>
|
| 750 |
+
<button ref={spinButtonRef} onClick={spinWheel} disabled={spinGameState.spinning || (!spinGameState.spin.hasExtraSpin && spinGameState.balance < SPIN_COST)}
|
| 751 |
+
className="px-8 py-3 mb-8 rounded-full bg-gradient-to-br from-purple-600 to-pink-600 text-xl font-bold shadow-lg hover:shadow-xl transition-all duration-300 w-full max-w-xs mx-auto block disabled:opacity-50 disabled:cursor-not-allowed">
|
| 752 |
+
{spinButtonText}
|
| 753 |
+
</button>
|
| 754 |
+
<div className="w-full max-w-md bg-theme-dark bg-opacity-70 rounded-xl p-6 backdrop-blur-sm border border-theme-border shadow-lg mx-auto">
|
| 755 |
+
<div ref={resultAmountRef} className="text-3xl font-bold text-center mb-2 text-yellow-400 h-10"></div>
|
| 756 |
+
<div ref={resultMessageRef} className="text-center text-gray-200">Spin the wheel to win prizes!</div>
|
| 757 |
+
<div ref={spinTimerRef} className="text-center text-sm text-gray-400 mt-4"></div>
|
| 758 |
+
<div className="mt-6 pt-4 border-t border-gray-700">
|
| 759 |
+
<h4 className="text-md font-semibold mb-2 text-gray-300">Recent Spin Activity:</h4>
|
| 760 |
+
{spinGameState.spin.activityLog.length > 0 ? (
|
| 761 |
+
<ul className="text-xs text-left space-y-1 max-h-24 overflow-y-auto bg-black/20 p-2 rounded-md">
|
| 762 |
+
{spinGameState.spin.activityLog.map(log => (
|
| 763 |
+
<li key={log.id} className="text-gray-400">{log.message}</li>
|
| 764 |
+
))}
|
| 765 |
+
</ul>
|
| 766 |
+
) : (
|
| 767 |
+
<p className="text-xs text-gray-500">No spin activity this session yet.</p>
|
| 768 |
+
)}
|
| 769 |
+
</div>
|
| 770 |
+
</div>
|
| 771 |
+
</div>
|
| 772 |
+
);
|
| 773 |
+
};
|
| 774 |
+
|
| 775 |
+
const renderLotteryContent = () => (
|
| 776 |
+
<div className="w-full text-white text-center">
|
| 777 |
+
{rewardRevealMessage && <p className="text-lg text-yellow-300 mb-2 animate-pulseCustom">{rewardRevealMessage}</p>}
|
| 778 |
+
<div className="bg-theme-dark bg-opacity-70 rounded-xl p-6 mb-6 border border-theme-border shadow-lg">
|
| 779 |
+
<h3 className="text-xl font-bold mb-2">Whale Lottery</h3>
|
| 780 |
+
<p className="text-sm text-gray-400 mb-1">Tickets played this session: {spinGameState.lottery.userTicketsThisSession}</p>
|
| 781 |
+
<p className="text-gray-300">Balance: <span className="font-bold text-yellow-400">{spinGameState.balance.toFixed(0)} ST</span></p>
|
| 782 |
+
{(spinGameState.lottery.ticketsOnHand > 0 || spinGameState.lottery.isTicketActive) && (
|
| 783 |
+
<p className="text-sm text-green-400 mb-2">Tickets on Hand: {spinGameState.lottery.ticketsOnHand}</p>
|
| 784 |
+
)}
|
| 785 |
+
<p className="text-theme-accent my-3 h-6" ref={lotteryResultMsgRef}>
|
| 786 |
+
{lotteryResultMsgRef.current?.textContent ||
|
| 787 |
+
(spinGameState.lottery.isTicketActive ? `Ticket #${spinGameState.lottery.userTicketsThisSession}! Flip ONE card.` :
|
| 788 |
+
spinGameState.lottery.ticketsOnHand > 0 ? "Ready to play your next ticket!" :
|
| 789 |
+
"Buy tickets to start scratching!")}
|
| 790 |
+
</p>
|
| 791 |
+
|
| 792 |
+
<div className="flex justify-center gap-2 sm:gap-4 mb-8 items-center min-h-[130px]">
|
| 793 |
+
{spinGameState.lottery.isTicketActive || spinGameState.lottery.revealedCardIndex !== null ? (
|
| 794 |
+
spinGameState.lottery.currentTicketPrizes.map((prize, index) => (
|
| 795 |
+
<div key={`card-container-${index}`} className="lottery-card-container">
|
| 796 |
+
<div
|
| 797 |
+
className={`lottery-card ${spinGameState.lottery.revealedCardIndex === index ? 'flipped' : ''}`}
|
| 798 |
+
onClick={() => revealLotteryCard(index)}
|
| 799 |
+
onKeyPress={(e) => e.key === 'Enter' && revealLotteryCard(index)}
|
| 800 |
+
role="button"
|
| 801 |
+
tabIndex={spinGameState.lottery.isTicketActive && spinGameState.lottery.revealedCardIndex === null ? 0 : -1}
|
| 802 |
+
aria-label={`Lottery card ${index + 1}`}
|
| 803 |
+
>
|
| 804 |
+
<div className="lottery-card-face lottery-card-front">
|
| 805 |
+
<i className="fas fa-ticket-alt"></i>
|
| 806 |
+
<span className="ticket-text">Flip Me!</span>
|
| 807 |
+
</div>
|
| 808 |
+
<div className="lottery-card-face lottery-card-back">
|
| 809 |
+
{typeof prize === 'number' && prize > 0 ? (
|
| 810 |
+
<><i className="fas fa-coins text-yellow-400"></i> {prize} ST</>
|
| 811 |
+
) : (
|
| 812 |
+
<><i className="fas fa-times text-red-400"></i> Miss</>
|
| 813 |
+
)}
|
| 814 |
+
</div>
|
| 815 |
+
</div>
|
| 816 |
+
</div>
|
| 817 |
+
))
|
| 818 |
+
): null}
|
| 819 |
+
</div>
|
| 820 |
+
|
| 821 |
+
|
| 822 |
+
{!spinGameState.lottery.isTicketActive && (
|
| 823 |
+
spinGameState.lottery.ticketsOnHand > 0 ? (
|
| 824 |
+
<button onClick={handlePlayNextTicket}
|
| 825 |
+
className="px-6 py-3 rounded-full bg-gradient-to-br from-blue-600 to-blue-700 text-lg font-bold shadow-lg hover:shadow-xl transition-all duration-300 w-full max-w-xs mx-auto block">
|
| 826 |
+
Play Next Ticket ({spinGameState.lottery.ticketsOnHand} left)
|
| 827 |
+
</button>
|
| 828 |
+
) : (
|
| 829 |
+
<div className="mt-4">
|
| 830 |
+
<div className="flex items-center justify-center gap-2 mb-2">
|
| 831 |
+
<label htmlFor="numTicketsInput" className="text-sm text-gray-300">Tickets:</label>
|
| 832 |
+
<input
|
| 833 |
+
type="number"
|
| 834 |
+
id="numTicketsInput"
|
| 835 |
+
value={numTicketsToBuyInput}
|
| 836 |
+
onChange={(e) => setNumTicketsToBuyInput(e.target.value)}
|
| 837 |
+
min="1" max="100"
|
| 838 |
+
className="w-20 p-2 rounded bg-theme-dark border border-theme-border text-white focus:ring-pink-500 focus:border-pink-500 text-center"
|
| 839 |
+
/>
|
| 840 |
+
</div>
|
| 841 |
+
<button onClick={handleBuyMultipleTickets} disabled={spinGameState.balance < (parseInt(numTicketsToBuyInput,10) || 1) * LOTTERY_TICKET_PRICE}
|
| 842 |
+
className="px-6 py-3 rounded-full bg-gradient-to-br from-green-600 to-green-700 text-lg font-bold shadow-lg hover:shadow-xl transition-all duration-300 w-full max-w-xs mx-auto block disabled:opacity-50">
|
| 843 |
+
Buy Ticket(s)
|
| 844 |
+
</button>
|
| 845 |
+
</div>
|
| 846 |
+
)
|
| 847 |
+
)}
|
| 848 |
+
|
| 849 |
+
<div className="mt-6 pt-4 border-t border-gray-700">
|
| 850 |
+
<h4 className="text-md font-semibold mb-2 text-gray-300">Recent Lottery Activity:</h4>
|
| 851 |
+
{spinGameState.lottery.activityLog.length > 0 ? (
|
| 852 |
+
<ul className="text-xs text-left space-y-1 max-h-24 overflow-y-auto bg-black/20 p-2 rounded-md">
|
| 853 |
+
{spinGameState.lottery.activityLog.map(log => (
|
| 854 |
+
<li key={log.id} className="text-gray-400">{log.message}</li>
|
| 855 |
+
))}
|
| 856 |
+
</ul>
|
| 857 |
+
) : (
|
| 858 |
+
<p className="text-xs text-gray-500">No lottery activity this session yet.</p>
|
| 859 |
+
)}
|
| 860 |
+
</div>
|
| 861 |
+
</div>
|
| 862 |
+
</div>
|
| 863 |
+
);
|
| 864 |
+
|
| 865 |
+
const renderRewardsContent = () => (
|
| 866 |
+
<div className="w-full text-white text-center">
|
| 867 |
+
{rewardRevealMessage && <p className="text-lg text-yellow-300 mb-4 animate-pulseCustom">{rewardRevealMessage}</p>}
|
| 868 |
+
{/* Daily ST Reward */}
|
| 869 |
+
<div className="bg-theme-dark bg-opacity-70 rounded-xl p-6 mb-6 border border-theme-border shadow-lg">
|
| 870 |
+
<h3 className="text-xl font-bold mb-2">Daily ST Reward</h3>
|
| 871 |
+
<p className="text-sm text-gray-400 mb-2">Claim daily ST. Streak ({spinGameState.rewards.streak} days) resets if you miss a day.</p>
|
| 872 |
+
<div className="grid grid-cols-5 sm:grid-cols-6 md:grid-cols-7 gap-1.5 sm:gap-2 mb-4 max-h-40 overflow-y-auto p-2 bg-black/20 rounded-lg">
|
| 873 |
+
{DAILY_REWARDS_STREAK.map((reward, index) => {
|
| 874 |
+
const dayNumber = index + 1;
|
| 875 |
+
const isClaimedCurrentStreak = !canClaimDailyST && spinGameState.rewards.streak >= dayNumber;
|
| 876 |
+
const currentCycleStreak = spinGameState.rewards.streak % DAILY_REWARDS_STREAK.length;
|
| 877 |
+
const isNextToClaim = canClaimDailyST && currentCycleStreak === index;
|
| 878 |
+
|
| 879 |
+
let bgColor = 'bg-indigo-900 bg-opacity-50 border-indigo-700';
|
| 880 |
+
if (isClaimedCurrentStreak) bgColor = 'bg-green-700 border-green-500';
|
| 881 |
+
else if (isNextToClaim) bgColor = 'bg-yellow-600 border-yellow-400 animate-pulseCustom';
|
| 882 |
+
else if (canClaimDailyST && index < currentCycleStreak) bgColor = 'bg-gray-700 border-gray-600 opacity-60';
|
| 883 |
+
else if (!canClaimDailyST && dayNumber > spinGameState.rewards.streak) bgColor = 'bg-indigo-900 bg-opacity-50 border-indigo-700';
|
| 884 |
+
|
| 885 |
+
return (
|
| 886 |
+
<div key={`day-st-${dayNumber}`} className={`rounded-lg p-1.5 sm:p-2 text-center border ${bgColor}`}>
|
| 887 |
+
<div className="text-xxs sm:text-xs text-gray-300">Day {dayNumber}</div>
|
| 888 |
+
<div className="font-bold text-yellow-400 text-xs sm:text-sm">{reward}</div>
|
| 889 |
+
<div className="text-xxs text-gray-400">ST</div>
|
| 890 |
+
</div>
|
| 891 |
+
);
|
| 892 |
+
})}
|
| 893 |
+
</div>
|
| 894 |
+
<button onClick={claimDailyReward} disabled={!canClaimDailyST || !!rewardRevealMessage}
|
| 895 |
+
className="px-6 py-3 rounded-full bg-gradient-to-br from-purple-600 to-pink-600 text-lg font-bold shadow-lg hover:shadow-xl transition-all duration-300 w-full max-w-xs mx-auto block disabled:opacity-50 disabled:cursor-not-allowed">
|
| 896 |
+
{canClaimDailyST ? `Claim Daily ST (Day ${(spinGameState.rewards.streak % DAILY_REWARDS_STREAK.length) + 1})` : "ST Claimed Today"}
|
| 897 |
+
</button>
|
| 898 |
+
<p className="text-center text-sm text-yellow-300 font-medium mt-2" id="next-reward-time-react">{rewardTimerText}</p>
|
| 899 |
+
</div>
|
| 900 |
+
|
| 901 |
+
{/* Daily Lottery Ticket */}
|
| 902 |
+
<div className="bg-theme-dark bg-opacity-70 rounded-xl p-6 mb-6 border border-theme-border shadow-lg">
|
| 903 |
+
<h3 className="text-xl font-bold mb-2">Daily Free Lottery Ticket</h3>
|
| 904 |
+
<p className="text-sm text-gray-400 mb-4">Claim one free lottery ticket every 24 hours!</p>
|
| 905 |
+
<button onClick={claimDailyTicket} disabled={!canClaimDailyFreeTicket || !!rewardRevealMessage}
|
| 906 |
+
className="px-6 py-3 rounded-full bg-gradient-to-br from-blue-600 to-teal-600 text-lg font-bold shadow-lg hover:shadow-xl transition-all duration-300 w-full max-w-xs mx-auto block disabled:opacity-50 disabled:cursor-not-allowed">
|
| 907 |
+
{canClaimDailyFreeTicket ? "Claim Free Daily Ticket" : "Ticket Claimed Today"}
|
| 908 |
+
</button>
|
| 909 |
+
<p className="text-center text-sm text-teal-300 font-medium mt-2">{dailyTicketTimerText}</p>
|
| 910 |
+
</div>
|
| 911 |
+
|
| 912 |
+
{/* Lottery Trophies */}
|
| 913 |
+
<div className="bg-theme-dark bg-opacity-70 rounded-xl p-6 mb-6 border border-theme-border shadow-lg">
|
| 914 |
+
<h3 className="text-xl font-bold mb-2">Lottery Trophies</h3>
|
| 915 |
+
<p className="text-sm text-gray-400 mb-2">Win lottery prizes (ST) to earn trophies. Current Wins: {spinGameState.lottery.lotteryWins}</p>
|
| 916 |
+
<TrophyDisplay wins={spinGameState.lottery.lotteryWins} />
|
| 917 |
+
</div>
|
| 918 |
+
|
| 919 |
+
{/* Referral System (Simplified) */}
|
| 920 |
+
<div className="bg-theme-dark bg-opacity-70 rounded-xl p-6 border border-theme-border shadow-lg">
|
| 921 |
+
<h3 className="text-xl font-bold mb-2">Referral Bonus</h3>
|
| 922 |
+
<p className="text-sm text-gray-400 mb-2">Your Referral Code: <strong className="text-yellow-400 select-all">{spinGameState.referral.playerReferralCode}</strong> (Share with friends!)</p>
|
| 923 |
+
{!spinGameState.referral.referralBonusClaimed ? (
|
| 924 |
+
<>
|
| 925 |
+
<input
|
| 926 |
+
type="text"
|
| 927 |
+
value={enteredReferralCodeInput}
|
| 928 |
+
onChange={(e) => setEnteredReferralCodeInput(e.target.value)}
|
| 929 |
+
placeholder="Enter Friend's Referral Code"
|
| 930 |
+
className="w-full max-w-xs p-2 rounded bg-theme-dark border border-theme-border text-white focus:ring-pink-500 focus:border-pink-500 text-center mb-2"
|
| 931 |
+
/>
|
| 932 |
+
<button onClick={handleClaimReferralBonus} disabled={!!rewardRevealMessage}
|
| 933 |
+
className="px-6 py-2 rounded-full bg-gradient-to-br from-green-500 to-lime-600 text-md font-bold shadow-lg hover:shadow-xl transition-all duration-300 w-full max-w-xs mx-auto block disabled:opacity-50">
|
| 934 |
+
Claim Referral Bonus Ticket
|
| 935 |
+
</button>
|
| 936 |
+
</>
|
| 937 |
+
) : (
|
| 938 |
+
<p className="text-green-400">Referral bonus (1 ticket) successfully claimed for code: {spinGameState.referral.enteredReferralCode}!</p>
|
| 939 |
+
)}
|
| 940 |
+
</div>
|
| 941 |
+
</div>
|
| 942 |
+
);
|
| 943 |
+
|
| 944 |
+
|
| 945 |
+
return (
|
| 946 |
+
<Modal isOpen={isOpen} onClose={onClose} title="DWHL Arcade Zone 🎰" size="lg">
|
| 947 |
+
<div className="container mx-auto px-2 py-4 sm:px-4 sm:py-8 text-white font-sans relative">
|
| 948 |
+
<div
|
| 949 |
+
className="absolute inset-0 -z-10 opacity-5 pointer-events-none"
|
| 950 |
+
style={{
|
| 951 |
+
backgroundImage: `url(${WHALE_BACKGROUND_IMAGE_URL})`,
|
| 952 |
+
backgroundRepeat: 'no-repeat',
|
| 953 |
+
backgroundPosition: 'center center',
|
| 954 |
+
backgroundSize: 'contain',
|
| 955 |
+
}}
|
| 956 |
+
aria-hidden="true"
|
| 957 |
+
></div>
|
| 958 |
+
|
| 959 |
+
<div ref={confettiContainerRef} className="fixed inset-0 pointer-events-none overflow-hidden z-[1000]"></div>
|
| 960 |
+
|
| 961 |
+
<div className="flex items-center justify-between mb-4 sm:mb-6">
|
| 962 |
+
<div className="bg-theme-dark bg-opacity-30 rounded-full px-4 py-2 flex items-center shadow-lg">
|
| 963 |
+
<div className="w-6 h-6 sm:w-8 sm:h-8 rounded-full bg-yellow-500 flex items-center justify-center mr-2 sm:mr-3">
|
| 964 |
+
<i className="fas fa-coins text-yellow-800 text-sm sm:text-base"></i>
|
| 965 |
+
</div>
|
| 966 |
+
<div>
|
| 967 |
+
<div className="text-xs text-gray-300">Your Balance</div>
|
| 968 |
+
<div className="font-bold text-lg sm:text-xl" id="wallet-balance-react">{spinGameState.balance.toFixed(0)} ST</div>
|
| 969 |
+
</div>
|
| 970 |
+
</div>
|
| 971 |
+
<button
|
| 972 |
+
onClick={handleFullScreen}
|
| 973 |
+
title={document.fullscreenElement ? "Exit fullscreen" : "Make the app fullscreen"}
|
| 974 |
+
className="px-3 py-2 rounded-full bg-gradient-to-br from-teal-500 to-cyan-600 text-white font-bold shadow-lg hover:shadow-xl transition-all duration-300 text-sm focus:outline-none focus:ring-2 focus:ring-cyan-400"
|
| 975 |
+
aria-label={document.fullscreenElement ? "Exit fullscreen mode" : "Enter fullscreen mode"}
|
| 976 |
+
>
|
| 977 |
+
<i className={`fas ${document.fullscreenElement ? "fa-compress" : "fa-expand"} mr-1 sm:mr-2`}></i>
|
| 978 |
+
<span className="hidden sm:inline">{document.fullscreenElement ? "Window" : "Fullscreen"}</span>
|
| 979 |
+
</button>
|
| 980 |
+
</div>
|
| 981 |
+
|
| 982 |
+
|
| 983 |
+
<div className="flex border-b border-gray-700 mb-6 sm:mb-8 w-full max-w-md mx-auto">
|
| 984 |
+
{([
|
| 985 |
+
{ id: 'lottery', label: 'Whale Lottery' },
|
| 986 |
+
{ id: 'spin', label: 'Whale Spin' },
|
| 987 |
+
{ id: 'rewards', label: 'Whale Rewards' }
|
| 988 |
+
] as {id: SpinToWinTab, label:string}[]).map(tabInfo => (
|
| 989 |
+
<button
|
| 990 |
+
key={tabInfo.id}
|
| 991 |
+
onClick={() => setActiveTab(tabInfo.id)}
|
| 992 |
+
className={`flex-1 py-2 sm:py-3 px-2 sm:px-4 font-semibold transition-colors duration-200 border-b-2 hover:text-yellow-400
|
| 993 |
+
${activeTab === tabInfo.id ? 'text-yellow-400 border-yellow-400' : 'text-gray-300 border-transparent'}`}
|
| 994 |
+
>
|
| 995 |
+
{tabInfo.label}
|
| 996 |
+
</button>
|
| 997 |
+
))}
|
| 998 |
+
</div>
|
| 999 |
+
|
| 1000 |
+
{activeTab === 'spin' && renderSpinContent()}
|
| 1001 |
+
{activeTab === 'lottery' && renderLotteryContent()}
|
| 1002 |
+
{activeTab === 'rewards' && renderRewardsContent()}
|
| 1003 |
+
</div>
|
| 1004 |
+
</Modal>
|
| 1005 |
+
);
|
| 1006 |
+
};
|
WalletModal.tsx
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect } from 'react';
|
| 3 |
+
import { Modal } from '../Modal.tsx';
|
| 4 |
+
import { PlayerData, AdminConfig } from '../../types.ts';
|
| 5 |
+
|
| 6 |
+
interface WalletModalProps {
|
| 7 |
+
isOpen: boolean;
|
| 8 |
+
onClose: () => void;
|
| 9 |
+
playerData: PlayerData;
|
| 10 |
+
adminConfig: AdminConfig;
|
| 11 |
+
onStakeTokens: (amount: number) => void;
|
| 12 |
+
onUnstakeTokens: (amount: number) => void;
|
| 13 |
+
onClaimRewards: () => void;
|
| 14 |
+
onTransaction: (type: 'deposit' | 'withdraw', amount: number) => void;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const DEPOSIT_COMING_SOON_WHALE_IMAGE_URL = 'https://www.transparentpng.com/thumb/whale/cartoon-whale-hd-image-U3wM6x.png';
|
| 18 |
+
const WITHDRAW_COMING_SOON_WHALE_IMAGE_URL = 'https://www.transparentpng.com/thumb/whale/blue-whale-transparent-background-jEY0W9.png';
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
export const WalletModal: React.FC<WalletModalProps> = ({ isOpen, onClose, playerData, adminConfig, onStakeTokens, onUnstakeTokens, onClaimRewards, onTransaction }) => {
|
| 22 |
+
const [stakeAmount, setStakeAmount] = useState('');
|
| 23 |
+
const [unstakeAmount, setUnstakeAmount] = useState('');
|
| 24 |
+
const [withdrawAmount, setWithdrawAmount] = useState('');
|
| 25 |
+
const [depositAmount, setDepositAmount] = useState('');
|
| 26 |
+
const [availableTokenRewards, setAvailableTokenRewards] = useState(0);
|
| 27 |
+
const [showDepositComingSoonNotice, setShowDepositComingSoonNotice] = useState(false);
|
| 28 |
+
const [showWithdrawComingSoonNotice, setShowWithdrawComingSoonNotice] = useState(false);
|
| 29 |
+
|
| 30 |
+
// New state for crypto prices
|
| 31 |
+
const [bitcoinPrice, setBitcoinPrice] = useState<string | null>(null);
|
| 32 |
+
const [dogecoinPrice, setDogecoinPrice] = useState<string | null>(null);
|
| 33 |
+
const [cryptoLoading, setCryptoLoading] = useState<boolean>(false);
|
| 34 |
+
const [cryptoError, setCryptoError] = useState<string | null>(null);
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
if (isOpen) {
|
| 38 |
+
if (playerData.stakingStartTime && playerData.stakedTokens > 0) {
|
| 39 |
+
const hoursStaked = (Date.now() - playerData.stakingStartTime) / (1000 * 60 * 60);
|
| 40 |
+
const rewards = Math.floor(playerData.stakedTokens * adminConfig.globalTokenStakingRate * hoursStaked);
|
| 41 |
+
setAvailableTokenRewards(rewards);
|
| 42 |
+
} else {
|
| 43 |
+
setAvailableTokenRewards(0);
|
| 44 |
+
}
|
| 45 |
+
// Reset coming soon notices when modal is re-opened
|
| 46 |
+
setShowDepositComingSoonNotice(false);
|
| 47 |
+
setShowWithdrawComingSoonNotice(false);
|
| 48 |
+
|
| 49 |
+
// Fetch crypto prices
|
| 50 |
+
const fetchCryptoPrices = async () => {
|
| 51 |
+
setCryptoLoading(true);
|
| 52 |
+
setCryptoError(null);
|
| 53 |
+
setBitcoinPrice(null);
|
| 54 |
+
setDogecoinPrice(null);
|
| 55 |
+
try {
|
| 56 |
+
const response = await fetch('https://api.coingecko.com/api/v3/simple/price?ids=bitcoin,dogecoin&vs_currencies=usd');
|
| 57 |
+
if (!response.ok) {
|
| 58 |
+
throw new Error(`API Error: ${response.status} ${response.statusText}`);
|
| 59 |
+
}
|
| 60 |
+
const data = await response.json();
|
| 61 |
+
setBitcoinPrice(data.bitcoin?.usd?.toLocaleString('en-US', { style: 'currency', currency: 'USD' }) || 'N/A');
|
| 62 |
+
setDogecoinPrice(data.dogecoin?.usd?.toLocaleString('en-US', { style: 'currency', currency: 'USD' }) || 'N/A');
|
| 63 |
+
} catch (error: any) {
|
| 64 |
+
setCryptoError(error.message || 'Error fetching crypto prices.');
|
| 65 |
+
console.error('Error fetching crypto prices:', error);
|
| 66 |
+
} finally {
|
| 67 |
+
setCryptoLoading(false);
|
| 68 |
+
}
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
fetchCryptoPrices();
|
| 72 |
+
}
|
| 73 |
+
}, [isOpen, playerData.stakingStartTime, playerData.stakedTokens, adminConfig.globalTokenStakingRate]);
|
| 74 |
+
|
| 75 |
+
const displayDepositComingSoon = () => {
|
| 76 |
+
setShowWithdrawComingSoonNotice(false);
|
| 77 |
+
setShowDepositComingSoonNotice(true);
|
| 78 |
+
};
|
| 79 |
+
|
| 80 |
+
const displayWithdrawComingSoon = () => {
|
| 81 |
+
setShowDepositComingSoonNotice(false);
|
| 82 |
+
setShowWithdrawComingSoonNotice(true);
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
const handleStake = () => {
|
| 86 |
+
const amount = parseInt(stakeAmount);
|
| 87 |
+
if (!isNaN(amount) && amount > 0 && amount <= playerData.seaTokens) {
|
| 88 |
+
onStakeTokens(amount);
|
| 89 |
+
setStakeAmount('');
|
| 90 |
+
}
|
| 91 |
+
};
|
| 92 |
+
|
| 93 |
+
const handleUnstake = () => {
|
| 94 |
+
const amount = parseInt(unstakeAmount);
|
| 95 |
+
if (!isNaN(amount) && amount > 0 && amount <= playerData.stakedTokens) {
|
| 96 |
+
onUnstakeTokens(amount);
|
| 97 |
+
setUnstakeAmount('');
|
| 98 |
+
}
|
| 99 |
+
};
|
| 100 |
+
|
| 101 |
+
const handleDeposit = () => {
|
| 102 |
+
setDepositAmount('');
|
| 103 |
+
displayDepositComingSoon();
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
const handleWithdraw = () => {
|
| 107 |
+
setWithdrawAmount('');
|
| 108 |
+
displayWithdrawComingSoon();
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
const stakeAmountNum = parseInt(stakeAmount);
|
| 112 |
+
const isStakeInvalid = stakeAmount === '' || isNaN(stakeAmountNum) || stakeAmountNum <= 0 || stakeAmountNum > playerData.seaTokens;
|
| 113 |
+
|
| 114 |
+
const unstakeAmountNum = parseInt(unstakeAmount);
|
| 115 |
+
const isUnstakeInvalid = unstakeAmount === '' || isNaN(unstakeAmountNum) || unstakeAmountNum <= 0 || unstakeAmountNum > playerData.stakedTokens;
|
| 116 |
+
|
| 117 |
+
const closeModalAndNotices = () => {
|
| 118 |
+
setShowDepositComingSoonNotice(false);
|
| 119 |
+
setShowWithdrawComingSoonNotice(false);
|
| 120 |
+
onClose();
|
| 121 |
+
};
|
| 122 |
+
|
| 123 |
+
if (showDepositComingSoonNotice) {
|
| 124 |
+
return (
|
| 125 |
+
<Modal isOpen={isOpen} onClose={closeModalAndNotices} title="Whale Wallet 🐳" size="md">
|
| 126 |
+
<div className="relative text-center p-6 bg-theme-primary/60 rounded-lg shadow-inner min-h-[300px] flex flex-col items-center justify-center overflow-hidden animate-fadeIn">
|
| 127 |
+
<img
|
| 128 |
+
src={DEPOSIT_COMING_SOON_WHALE_IMAGE_URL}
|
| 129 |
+
alt="Whale"
|
| 130 |
+
className="absolute inset-0 w-full h-full object-contain opacity-15 z-0 transform scale-105"
|
| 131 |
+
aria-hidden="true"
|
| 132 |
+
/>
|
| 133 |
+
<div className="relative z-10">
|
| 134 |
+
<h4 className="text-2xl font-bold text-theme-accent mb-4 animate-pulseCustom">Hold Your Fins!</h4>
|
| 135 |
+
<p className="text-theme-text mb-6 text-md">
|
| 136 |
+
This feature is swimming your way in the next phase! You'll soon be able to use your Sea Tokens to buy awesome in-game assets and power up your whale with cool upgrades in the store.
|
| 137 |
+
</p>
|
| 138 |
+
<button
|
| 139 |
+
onClick={() => setShowDepositComingSoonNotice(false)}
|
| 140 |
+
className="py-2 px-8 bg-pink-600 hover:bg-pink-700 text-white font-bold rounded-lg transition-colors duration-200 transform hover:scale-105"
|
| 141 |
+
>
|
| 142 |
+
Got It!
|
| 143 |
+
</button>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
</Modal>
|
| 147 |
+
);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
if (showWithdrawComingSoonNotice) {
|
| 151 |
+
return (
|
| 152 |
+
<Modal isOpen={isOpen} onClose={closeModalAndNotices} title="Whale Wallet 🐳" size="md">
|
| 153 |
+
<div
|
| 154 |
+
className="relative text-center p-8 rounded-lg shadow-2xl min-h-[350px] flex flex-col items-center justify-center overflow-hidden animate-fadeIn"
|
| 155 |
+
style={{ background: 'linear-gradient(135deg, var(--theme-primary) 0%, var(--theme-info) 100%)' }}
|
| 156 |
+
>
|
| 157 |
+
<img
|
| 158 |
+
src={WITHDRAW_COMING_SOON_WHALE_IMAGE_URL}
|
| 159 |
+
alt="Diving Whale"
|
| 160 |
+
className="absolute bottom-0 right-0 w-2/3 h-2/3 object-contain opacity-20 z-0 transform translate-x-1/4 translate-y-1/4 scale-110"
|
| 161 |
+
aria-hidden="true"
|
| 162 |
+
/>
|
| 163 |
+
<div className="relative z-10">
|
| 164 |
+
<h4 className="text-3xl font-bold text-theme-white mb-5" style={{ textShadow: '1px 1px 3px rgba(0,0,0,0.5)' }}>
|
| 165 |
+
Withdrawal Feature - Diving In Soon! <span role="img" aria-label="whale emoji">🐋</span>
|
| 166 |
+
</h4>
|
| 167 |
+
<p className="text-theme-text mb-8 text-lg leading-relaxed">
|
| 168 |
+
Securely withdrawing your hard-earned Sea Tokens will be available in our next major update. Get ready to take your treasures to new depths! We're working on making it super smooth and safe for you.
|
| 169 |
+
</p>
|
| 170 |
+
<button
|
| 171 |
+
onClick={() => setShowWithdrawComingSoonNotice(false)}
|
| 172 |
+
className="py-3 px-10 bg-pink-600 hover:bg-pink-700 text-white font-extrabold rounded-lg transition-colors duration-200 transform hover:scale-105 shadow-xl text-lg"
|
| 173 |
+
>
|
| 174 |
+
Sounds Great!
|
| 175 |
+
</button>
|
| 176 |
+
</div>
|
| 177 |
+
</div>
|
| 178 |
+
</Modal>
|
| 179 |
+
);
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
return (
|
| 184 |
+
<Modal isOpen={isOpen} onClose={closeModalAndNotices} title="Whale Wallet 🐳" size="md">
|
| 185 |
+
<div className="space-y-6">
|
| 186 |
+
{/* Wallet Balance */}
|
| 187 |
+
<div className="p-4 bg-theme-primary/30 rounded-lg">
|
| 188 |
+
<h4 className="text-lg font-semibold mb-2 text-theme-accent">Balance 💰</h4>
|
| 189 |
+
<p className="text-3xl font-bold text-theme-warning">{playerData.seaTokens.toFixed(2)} <span className="text-xl">Sea Tokens</span></p>
|
| 190 |
+
<p className="text-xs text-gray-400 mt-1">(Balance updates live based on your in-game actions)</p>
|
| 191 |
+
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 192 |
+
<div>
|
| 193 |
+
<label htmlFor="depositAmount" className="block text-sm font-medium text-gray-300 mb-1">Deposit Sea Tokens</label>
|
| 194 |
+
<input
|
| 195 |
+
type="number"
|
| 196 |
+
id="depositAmount"
|
| 197 |
+
value={depositAmount}
|
| 198 |
+
onChange={(e) => setDepositAmount(e.target.value)}
|
| 199 |
+
placeholder="Amount"
|
| 200 |
+
className="w-full p-2 rounded bg-theme-dark border border-theme-border text-white focus:ring-pink-500 focus:border-pink-500"
|
| 201 |
+
/>
|
| 202 |
+
<button onClick={handleDeposit} className="mt-2 w-full py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white font-bold rounded transition-colors duration-200">
|
| 203 |
+
Deposit
|
| 204 |
+
</button>
|
| 205 |
+
</div>
|
| 206 |
+
<div>
|
| 207 |
+
<label htmlFor="withdrawAmount" className="block text-sm font-medium text-gray-300 mb-1">Withdraw Sea Tokens</label>
|
| 208 |
+
<input
|
| 209 |
+
type="number"
|
| 210 |
+
id="withdrawAmount"
|
| 211 |
+
value={withdrawAmount}
|
| 212 |
+
onChange={(e) => setWithdrawAmount(e.target.value)}
|
| 213 |
+
placeholder="Amount"
|
| 214 |
+
className="w-full p-2 rounded bg-theme-dark border border-theme-border text-white focus:ring-pink-500 focus:border-pink-500"
|
| 215 |
+
/>
|
| 216 |
+
<button onClick={handleWithdraw} className="mt-2 w-full py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white font-bold rounded transition-colors duration-200">
|
| 217 |
+
Withdraw
|
| 218 |
+
</button>
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
</div>
|
| 222 |
+
|
| 223 |
+
{/* Live Crypto Prices Section */}
|
| 224 |
+
<div className="p-4 bg-theme-primary/30 rounded-lg">
|
| 225 |
+
<h4 className="text-lg font-semibold mb-2 text-theme-accent">Live Crypto Prices 📈</h4>
|
| 226 |
+
{cryptoLoading && <p className="text-sm text-gray-400 italic">Loading prices...</p>}
|
| 227 |
+
{cryptoError && <p className="text-sm text-theme-danger">{cryptoError}</p>}
|
| 228 |
+
{!cryptoLoading && !cryptoError && (
|
| 229 |
+
<div className="space-y-1 text-sm">
|
| 230 |
+
<p className="text-gray-300">Bitcoin (BTC): <span className="font-bold text-theme-white">{bitcoinPrice || 'N/A'}</span></p>
|
| 231 |
+
<p className="text-gray-300">Dogecoin (DOGE): <span className="font-bold text-theme-white">{dogecoinPrice || 'N/A'}</span></p>
|
| 232 |
+
</div>
|
| 233 |
+
)}
|
| 234 |
+
<p className="text-xs text-gray-500 mt-2">Prices via CoinGecko API. Not financial advice.</p>
|
| 235 |
+
</div>
|
| 236 |
+
|
| 237 |
+
{/* Token Staking Section */}
|
| 238 |
+
<div className="p-4 bg-theme-primary/30 rounded-lg">
|
| 239 |
+
<h4 className="text-lg font-semibold mb-2 text-theme-accent">Sea Token Staking 📈</h4>
|
| 240 |
+
<div className="flex justify-between mb-2 text-sm text-gray-300">
|
| 241 |
+
<p>Total Staked: <span className="font-bold text-theme-white">{playerData.stakedTokens} Sea Tokens</span></p>
|
| 242 |
+
<p>Reward Rate: <span className="font-bold text-theme-white">{(adminConfig.globalTokenStakingRate * 100).toFixed(1)}% / hour</span></p>
|
| 243 |
+
</div>
|
| 244 |
+
|
| 245 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
| 246 |
+
<div>
|
| 247 |
+
<label htmlFor="stakeAmount" className="block text-sm font-medium text-gray-300 mb-1">Stake Sea Tokens</label>
|
| 248 |
+
<input
|
| 249 |
+
type="number"
|
| 250 |
+
id="stakeAmount"
|
| 251 |
+
value={stakeAmount}
|
| 252 |
+
onChange={(e) => setStakeAmount(e.target.value)}
|
| 253 |
+
placeholder="Amount to Stake"
|
| 254 |
+
className="w-full p-2 rounded bg-theme-dark border border-theme-border text-white focus:ring-pink-500 focus:border-pink-500"
|
| 255 |
+
min="1"
|
| 256 |
+
max={playerData.seaTokens}
|
| 257 |
+
/>
|
| 258 |
+
<button
|
| 259 |
+
onClick={handleStake}
|
| 260 |
+
className="mt-2 w-full py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white font-bold rounded transition-colors duration-200 disabled:bg-pink-400 disabled:text-gray-100"
|
| 261 |
+
disabled={isStakeInvalid}
|
| 262 |
+
>
|
| 263 |
+
Stake
|
| 264 |
+
</button>
|
| 265 |
+
</div>
|
| 266 |
+
<div>
|
| 267 |
+
<label htmlFor="unstakeAmount" className="block text-sm font-medium text-gray-300 mb-1">Unstake Sea Tokens</label>
|
| 268 |
+
<input
|
| 269 |
+
type="number"
|
| 270 |
+
id="unstakeAmount"
|
| 271 |
+
value={unstakeAmount}
|
| 272 |
+
onChange={(e) => setUnstakeAmount(e.target.value)}
|
| 273 |
+
placeholder="Amount to Unstake"
|
| 274 |
+
className="w-full p-2 rounded bg-theme-dark border border-theme-border text-white focus:ring-pink-500 focus:border-pink-500"
|
| 275 |
+
min="1"
|
| 276 |
+
max={playerData.stakedTokens}
|
| 277 |
+
/>
|
| 278 |
+
<button
|
| 279 |
+
onClick={handleUnstake}
|
| 280 |
+
className="mt-2 w-full py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white font-bold rounded transition-colors duration-200 disabled:bg-pink-400 disabled:text-gray-100"
|
| 281 |
+
disabled={isUnstakeInvalid}
|
| 282 |
+
>
|
| 283 |
+
Unstake
|
| 284 |
+
</button>
|
| 285 |
+
</div>
|
| 286 |
+
</div>
|
| 287 |
+
|
| 288 |
+
<div className="mt-4 pt-4 border-t border-theme-border">
|
| 289 |
+
<p className="text-sm text-gray-300">Available Sea Token Rewards: <span className="font-bold text-theme-success">{availableTokenRewards.toFixed(2)} Sea Tokens</span></p>
|
| 290 |
+
<button
|
| 291 |
+
onClick={onClaimRewards}
|
| 292 |
+
className="mt-2 w-full py-2 px-4 bg-pink-600 hover:bg-pink-700 text-white font-bold rounded transition-colors duration-200 disabled:bg-pink-400 disabled:text-gray-100"
|
| 293 |
+
disabled={availableTokenRewards <= 0}
|
| 294 |
+
>
|
| 295 |
+
Claim Sea Token Rewards
|
| 296 |
+
</button>
|
| 297 |
+
</div>
|
| 298 |
+
</div>
|
| 299 |
+
</div>
|
| 300 |
+
</Modal>
|
| 301 |
+
);
|
| 302 |
+
};
|
WhaleCombatGuideModal.tsx
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React from 'react';
|
| 3 |
+
import { Modal } from '../Modal';
|
| 4 |
+
import { MAX_STAKING_SLOTS } from '../../constants';
|
| 5 |
+
|
| 6 |
+
interface WhaleCombatGuideModalProps {
|
| 7 |
+
isOpen: boolean;
|
| 8 |
+
onClose: () => void;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export const WhaleCombatGuideModal: React.FC<WhaleCombatGuideModalProps> = ({ isOpen, onClose }) => {
|
| 12 |
+
return (
|
| 13 |
+
<Modal isOpen={isOpen} onClose={onClose} title="📜 Doge Whales - The Ultimate Combat & Feature Guide" size="xl">
|
| 14 |
+
<div className="space-y-6 text-gray-300 text-sm leading-relaxed">
|
| 15 |
+
|
| 16 |
+
<section>
|
| 17 |
+
<h3 className="text-xl font-semibold text-theme-accent mb-2 border-b border-theme-border pb-1">🚀 Welcome, Doge Whale Commander!</h3>
|
| 18 |
+
<p>Embark on an epic journey through the crypto-ocean! This guide is your key to mastering Doge Whale Wars, understanding its rich features, and conquering the cosmic depths. Prepare for battle, collect legendary NFTs, and build your fortune in Sea Tokens!</p>
|
| 19 |
+
</section>
|
| 20 |
+
|
| 21 |
+
<section>
|
| 22 |
+
<h3 className="text-lg font-semibold text-theme-accent mb-2">🎯 Core Objective</h3>
|
| 23 |
+
<p>Your mission, should you choose to accept it:</p>
|
| 24 |
+
<ul className="list-disc list-inside ml-4 space-y-1">
|
| 25 |
+
<li><strong>Survive & Thrive:</strong> Navigate treacherous waters and fend off hostile cosmic creatures.</li>
|
| 26 |
+
<li><strong>Amass Wealth:</strong> Defeat enemies to earn points and valuable Sea Tokens.</li>
|
| 27 |
+
<li><strong>Collectibles:</strong> Snag Sea Tokens dropped by foes or floating freely. Keep an eager eye out for rare NFT Bubbles containing treasures!</li>
|
| 28 |
+
<li><strong>Dominate:</strong> Level up your Whale, enhance your gear with NFTs, and become a legend of the deep.</li>
|
| 29 |
+
</ul>
|
| 30 |
+
</section>
|
| 31 |
+
|
| 32 |
+
<section>
|
| 33 |
+
<h3 className="text-lg font-semibold text-theme-accent mb-2">🕹️ Mastering the Controls</h3>
|
| 34 |
+
<ul className="list-disc list-inside ml-4 space-y-1">
|
| 35 |
+
<li><strong>Movement:</strong> Pilot your Whale using <kbd className="px-2 py-1 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-300 rounded-md">W</kbd>, <kbd className="px-2 py-1 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-300 rounded-md">A</kbd>, <kbd className="px-2 py-1 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-300 rounded-md">S</kbd>, <kbd className="px-2 py-1 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-300 rounded-md">D</kbd> keys or the Arrow Keys (<kbd className="px-2 py-1 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-300 rounded-md">↑</kbd> <kbd className="px-2 py-1 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-300 rounded-md">←</kbd> <kbd className="px-2 py-1 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-300 rounded-md">↓</kbd> <kbd className="px-2 py-1 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-300 rounded-md">→</kbd>).</li>
|
| 36 |
+
<li><strong>Primary Attack:</strong> Unleash your Whale's weaponry by pressing the <kbd className="px-2 py-1 text-xs font-semibold text-gray-800 bg-gray-100 border border-gray-300 rounded-md">Spacebar</kbd>. Some Whales have auto-fire capabilities under certain conditions or by default.</li>
|
| 37 |
+
</ul>
|
| 38 |
+
</section>
|
| 39 |
+
|
| 40 |
+
<section>
|
| 41 |
+
<h3 className="text-lg font-semibold text-theme-accent mb-2">🐳 Your Whale Avatar</h3>
|
| 42 |
+
<ul className="list-disc list-inside ml-4 space-y-1">
|
| 43 |
+
<li><strong>Character Choice:</strong> Select from a diverse roster of Doge Whale characters, each with unique base stats, starting weapons, and potential special abilities (like one-hit kills or auto-fire thresholds). Customize your Whale's name in the Profile section!</li>
|
| 44 |
+
<li><strong>Health (HP):</strong> Your lifeline! Displayed in the HUD. If it drops to zero, your current expedition ends.</li>
|
| 45 |
+
<li><strong>NFT Power-Ups:</strong> Equipped NFTs are crucial! They can significantly boost your Whale's speed, damage, Sea Token collection rate, XP gain, or provide an all-around statistical advantage.</li>
|
| 46 |
+
</ul>
|
| 47 |
+
</section>
|
| 48 |
+
|
| 49 |
+
<section>
|
| 50 |
+
<h3 className="text-lg font-semibold text-theme-accent mb-2">💣 Weapon Arsenal Overview</h3>
|
| 51 |
+
<p>Your chosen Whale character comes with a default weapon, but various systems exist:</p>
|
| 52 |
+
<ul className="list-disc list-inside ml-4 space-y-1">
|
| 53 |
+
<li><strong>Standard & Heavy Bullets:</strong> Reliable projectiles, often fired in a cone spread.</li>
|
| 54 |
+
<li><strong>Rockets:</strong> Powerful, manually aimed or auto-targeting based on configuration.</li>
|
| 55 |
+
<li><strong>Lasers:</strong> Fast-moving energy beams.</li>
|
| 56 |
+
<li><strong>Sword Throw:</strong> Unique projectile type with good damage.</li>
|
| 57 |
+
<li><strong>Missiles (Auto-Target/Homing):</strong> Some Whales can fire missiles that seek out the nearest enemy or are passively launched.</li>
|
| 58 |
+
<li><strong>Bombs:</strong> Area-of-effect (AoE) projectiles that detonate after a fuse time, affected by gravity.</li>
|
| 59 |
+
<li><strong>Summon Fish Minions:</strong> Command allied fish to fight alongside you.</li>
|
| 60 |
+
<li><strong>Flame Cone:</strong> A short-range cone of fire that can apply burning damage over time (DoT) and potentially cause enemies to explode if exposed long enough!</li>
|
| 61 |
+
<li><strong>Red Arrow Defense:</strong> A sophisticated system (primarily for the Guardian Whale) that automatically targets and destroys incoming enemy projectiles or damages enemies.</li>
|
| 62 |
+
<li><strong>Doge Launcher:</strong> A special, powerful weapon that fires iconic Doge and Bone images for devastating (often one-hit kill) effects.</li>
|
| 63 |
+
</ul>
|
| 64 |
+
<p className="mt-1 text-xs text-gray-400">Weapon specifics like cooldown, damage, and special properties are defined by the Whale character and global admin settings.</p>
|
| 65 |
+
</section>
|
| 66 |
+
|
| 67 |
+
<section>
|
| 68 |
+
<h3 className="text-lg font-semibold text-theme-accent mb-2">🦑 Enemies of the Crypto-Deep</h3>
|
| 69 |
+
<ul className="list-disc list-inside ml-4 space-y-1">
|
| 70 |
+
<li><strong>Diverse Threats:</strong> From nimble Space Minnows to formidable Mecha Octopuses, the ocean is teeming with unique adversaries.</li>
|
| 71 |
+
<li><strong>Attack Patterns:</strong> Most enemies will advance towards your Whale and may fire projectiles. Direct collision is also damaging!</li>
|
| 72 |
+
<li><strong>Rewards:</strong> Vanquishing foes yields score points, XP, and precious Sea Tokens.</li>
|
| 73 |
+
</ul>
|
| 74 |
+
</section>
|
| 75 |
+
|
| 76 |
+
<section>
|
| 77 |
+
<h3 className="text-lg font-semibold text-theme-accent mb-2">💰 Pickups & Treasures</h3>
|
| 78 |
+
<ul className="list-disc list-inside ml-4 space-y-1">
|
| 79 |
+
<li><strong>Sea Tokens (ST):</strong> The lifeblood of the Doge Whale economy! Collect them by maneuvering your Whale over them. If many tokens are on screen, they'll be automatically drawn to you!</li>
|
| 80 |
+
<li><strong>NFT Bubbles:</strong> These rare, shimmering bubbles are a sight to behold. Pop them for a chance to acquire new NFTs directly or receive a handsome bonus of Sea Tokens. Rarity (Common, Rare, Legendary) influences the potential reward.</li>
|
| 81 |
+
</ul>
|
| 82 |
+
</section>
|
| 83 |
+
|
| 84 |
+
<section>
|
| 85 |
+
<h3 className="text-lg font-semibold text-theme-accent mb-2">📊 Understanding Your HUD (Heads-Up Display)</h3>
|
| 86 |
+
<p>Located at the top-right, your HUD provides critical real-time game information:</p>
|
| 87 |
+
<ul className="list-disc list-inside ml-4 space-y-1">
|
| 88 |
+
<li><strong>Health (HP):</strong> Current/Max health of your Whale.</li>
|
| 89 |
+
<li><strong>Experience (XP):</strong> Your progress bar towards the next level.</li>
|
| 90 |
+
<li><strong>Sea Tokens:</strong> Your accumulated ST balance for the current session.</li>
|
| 91 |
+
<li><strong>Equipped NFT:</strong> Name of your currently active NFT (if any).</li>
|
| 92 |
+
<li><strong>Level & Score:</strong> Your Whale's current level and total score for this run.</li>
|
| 93 |
+
</ul>
|
| 94 |
+
</section>
|
| 95 |
+
|
| 96 |
+
<section>
|
| 97 |
+
<h3 className="text-lg font-semibold text-theme-accent mb-2">🧬 The NFT Ecosystem</h3>
|
| 98 |
+
<p>NFTs (Non-Fungible Tokens) are unique digital assets that provide powerful in-game advantages.</p>
|
| 99 |
+
<ul className="list-disc list-inside ml-4 space-y-1">
|
| 100 |
+
<li><strong>NFT Hub (Marketplace):</strong>
|
| 101 |
+
<ul className="list-circle list-inside ml-4 space-y-0.5">
|
| 102 |
+
<li><strong>Market Tab:</strong> Browse and purchase NFTs using your Sea Tokens. Each NFT has a price, rarity, and specific effect.</li>
|
| 103 |
+
<li><strong>My NFTs Tab:</strong> View your collected NFTs. From here, you can equip an NFT to gain its benefits or select them for breeding if they are not staked in a Vault Slot.</li>
|
| 104 |
+
<li><strong>Breeding Tab:</strong> Combine two compatible parent NFTs from your collection to create a new, potentially rarer or more powerful, hybrid NFT! Breeding takes time and has specific combinations for unique outcomes.</li>
|
| 105 |
+
</ul>
|
| 106 |
+
</li>
|
| 107 |
+
<li><strong>NFT Effects:</strong> These vary widely:
|
| 108 |
+
<ul className="list-circle list-inside ml-4 space-y-0.5">
|
| 109 |
+
<li><strong className="text-yellow-400">Coins %:</strong> Increases Sea Tokens collected from enemies.</li>
|
| 110 |
+
<li><strong className="text-blue-400">XP %:</strong> Boosts experience points gained.</li>
|
| 111 |
+
<li><strong className="text-red-400">Damage %:</strong> Enhances your weapon damage.</li>
|
| 112 |
+
<li><strong className="text-green-400">Speed %:</strong> Increases your Whale's movement speed.</li>
|
| 113 |
+
<li><strong className="text-purple-400">All Stats %:</strong> Provides a general boost to all the above attributes.</li>
|
| 114 |
+
</ul>
|
| 115 |
+
</li>
|
| 116 |
+
<li><strong>Rarity:</strong> NFTs come in Common, Rare, and Legendary rarities, influencing their power and value.</li>
|
| 117 |
+
</ul>
|
| 118 |
+
</section>
|
| 119 |
+
|
| 120 |
+
<section>
|
| 121 |
+
<h3 className="text-lg font-semibold text-theme-accent mb-2">📈 Staking & Passive Income</h3>
|
| 122 |
+
<p>Put your assets to work for you!</p>
|
| 123 |
+
<ul className="list-disc list-inside ml-4 space-y-1">
|
| 124 |
+
<li><strong>Sea Token Staking (Wallet):</strong> Stake your Sea Tokens directly in the Wallet modal to earn a percentage-based return over time. You can unstake and claim rewards at any point.</li>
|
| 125 |
+
<li><strong>NFT Vault (Slot Staking):</strong>
|
| 126 |
+
<ul className="list-circle list-inside ml-4 space-y-0.5">
|
| 127 |
+
<li>Stake your owned (and unequipped) NFTs into one of {MAX_STAKING_SLOTS} available Vault Slots.</li>
|
| 128 |
+
<li>Each staked NFT generates Sea Tokens passively based on a base rate and its individual <strong className="text-cyan-400">Mining Power</strong>.</li>
|
| 129 |
+
<li><strong className="text-cyan-400">Upgrade Mining Power:</strong> Spend Sea Tokens to increase an NFT's Mining Power, thereby boosting its ST generation rate in the Vault. Costs increase with each power level.</li>
|
| 130 |
+
<li>Claim accumulated rewards from all staked NFT slots periodically.</li>
|
| 131 |
+
<li>NFTs staked in Vault Slots cannot be equipped or used for breeding until unstaked.</li>
|
| 132 |
+
</ul>
|
| 133 |
+
</li>
|
| 134 |
+
</ul>
|
| 135 |
+
</section>
|
| 136 |
+
|
| 137 |
+
<section>
|
| 138 |
+
<h3 className="text-lg font-semibold text-theme-accent mb-2">👤 Player Profile & Customization</h3>
|
| 139 |
+
<p>Access the Profile modal to:</p>
|
| 140 |
+
<ul className="list-disc list-inside ml-4 space-y-1">
|
| 141 |
+
<li><strong>Set Your Name:</strong> Choose a name for your Whale adventurer.</li>
|
| 142 |
+
<li><strong>Select Character:</strong> Pick your preferred Whale class before starting a game. Each has different stats and default weapons.</li>
|
| 143 |
+
<li><strong>View Stats:</strong> Check your current level, XP, health, Sea Token balance, score, and NFT collection details.</li>
|
| 144 |
+
<li><strong>Claim NFT Staking Rewards:</strong> Collect Sea Tokens earned from your NFTs staked in the Vault.</li>
|
| 145 |
+
<li><strong>Sound Settings:</strong> Adjust master volume and mute/unmute game sounds.</li>
|
| 146 |
+
<li><strong>AI Game Guide Chat:</strong> Ask the friendly AI assistant for tips, game information, or help with features.</li>
|
| 147 |
+
</ul>
|
| 148 |
+
</section>
|
| 149 |
+
|
| 150 |
+
<section>
|
| 151 |
+
<h3 className="text-lg font-semibold text-theme-accent mb-2">🎰 DWHL Arcade Zone</h3>
|
| 152 |
+
<p>Take a break from combat and try your luck at the Arcade!</p>
|
| 153 |
+
<ul className="list-disc list-inside ml-4 space-y-1">
|
| 154 |
+
<li><strong>Whale Lottery:</strong>
|
| 155 |
+
<ul className="list-circle list-inside ml-4 space-y-0.5">
|
| 156 |
+
<li>Buy lottery tickets using Sea Tokens.</li>
|
| 157 |
+
<li>Play by flipping one of three cards to reveal a prize, which could be Sea Tokens or a "Miss."</li>
|
| 158 |
+
<li>Track your lottery activity and aim for Trophies by winning ST prizes!</li>
|
| 159 |
+
</ul>
|
| 160 |
+
</li>
|
| 161 |
+
<li><strong>Whale Spin:</strong>
|
| 162 |
+
<ul className="list-circle list-inside ml-4 space-y-0.5">
|
| 163 |
+
<li>Spend Sea Tokens for a chance to spin the wheel (cooldown applies after each paid spin).</li>
|
| 164 |
+
<li>Prizes include Sea Tokens, an Extra Spin, or Lottery Tickets. Land on the Jackpot for a big ST reward!</li>
|
| 165 |
+
</ul>
|
| 166 |
+
</li>
|
| 167 |
+
<li><strong>Whale Rewards (Daily Claims):</strong>
|
| 168 |
+
<ul className="list-circle list-inside ml-4 space-y-0.5">
|
| 169 |
+
<li><strong>Daily ST Reward:</strong> Claim a free amount of Sea Tokens every 24 hours. Maintain a streak for increasing rewards!</li>
|
| 170 |
+
<li><strong>Daily Free Lottery Ticket:</strong> Get one free lottery ticket every 24 hours.</li>
|
| 171 |
+
</ul>
|
| 172 |
+
</li>
|
| 173 |
+
<li><strong>Referral System:</strong>
|
| 174 |
+
<ul className="list-circle list-inside ml-4 space-y-0.5">
|
| 175 |
+
<li>Share your unique referral code with friends.</li>
|
| 176 |
+
<li>Enter a friend's valid referral code (once) to claim a bonus (e.g., a free lottery ticket).</li>
|
| 177 |
+
</ul>
|
| 178 |
+
</li>
|
| 179 |
+
</ul>
|
| 180 |
+
</section>
|
| 181 |
+
|
| 182 |
+
<section>
|
| 183 |
+
<h3 className="text-lg font-semibold text-theme-accent mb-2">✨ Visuals & Atmosphere</h3>
|
| 184 |
+
<p>Immerse yourself in the Doge Whale Wars universe with dynamic visual effects such as blood particles from hits, explosions from bombs and special enemy KOs, and even atmospheric rain during your cosmic voyages.</p>
|
| 185 |
+
</section>
|
| 186 |
+
|
| 187 |
+
<section>
|
| 188 |
+
<h3 className="text-lg font-semibold text-theme-accent mb-2">💡 Pro Tips for Dominance</h3>
|
| 189 |
+
<ul className="list-disc list-inside ml-4 space-y-1">
|
| 190 |
+
<li><strong>Stay Agile:</strong> Perpetual motion is your best defense. Weave through enemy fire!</li>
|
| 191 |
+
<li><strong>Threat Assessment:</strong> Prioritize dangerous or close-range enemies first.</li>
|
| 192 |
+
<li><strong>Smart Collection:</strong> Grab those Sea Tokens, but not at the cost of heavy damage. Remember the auto-collect feature for ST!</li>
|
| 193 |
+
<li><strong>NFT Synergy:</strong> Experiment! Find NFT combinations that amplify your chosen Whale's strengths or cover its weaknesses.</li>
|
| 194 |
+
<li><strong>Master Your Whale:</strong> Learn the nuances of your selected Whale character's default weapon and abilities.</li>
|
| 195 |
+
<li><strong>Visit the Arcade:</strong> Don't forget your daily rewards and spins/tickets for extra ST and fun!</li>
|
| 196 |
+
</ul>
|
| 197 |
+
</section>
|
| 198 |
+
|
| 199 |
+
<p className="text-center font-bold text-theme-warning mt-8 text-lg">Now, go forth, Commander, and make waves in the crypto-ocean! May your aim be true and your wallet ever full!</p>
|
| 200 |
+
</div>
|
| 201 |
+
</Modal>
|
| 202 |
+
);
|
| 203 |
+
};
|
app.css
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* app.css */
|
| 2 |
+
|
| 3 |
+
/* Basic Reset */
|
| 4 |
+
*,
|
| 5 |
+
*::before,
|
| 6 |
+
*::after {
|
| 7 |
+
margin: 0;
|
| 8 |
+
padding: 0;
|
| 9 |
+
box-sizing: border-box;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
html {
|
| 13 |
+
scroll-behavior: smooth;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
body {
|
| 17 |
+
font-family: var(--font-sans, 'Inter', sans-serif);
|
| 18 |
+
background-color: var(--theme-dark); /* Fallback if gradient doesn't load */
|
| 19 |
+
color: var(--theme-text);
|
| 20 |
+
line-height: 1.6;
|
| 21 |
+
-webkit-font-smoothing: antialiased;
|
| 22 |
+
-moz-osx-font-smoothing: grayscale;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/* Headings styling with Orbitron */
|
| 26 |
+
h1, h2, h3, h4, h5, h6 {
|
| 27 |
+
font-family: 'Orbitron', sans-serif;
|
| 28 |
+
color: var(--theme-accent);
|
| 29 |
+
margin-bottom: 0.75rem;
|
| 30 |
+
line-height: 1.3;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
h1 { font-size: 2.25rem; } /* Corresponds to Tailwind text-4xl */
|
| 34 |
+
h2 { font-size: 1.875rem; } /* Corresponds to Tailwind text-3xl */
|
| 35 |
+
h3 { font-size: 1.5rem; } /* Corresponds to Tailwind text-2xl */
|
| 36 |
+
h4 { font-size: 1.25rem; } /* Corresponds to Tailwind text-xl */
|
| 37 |
+
|
| 38 |
+
p {
|
| 39 |
+
margin-bottom: 1rem;
|
| 40 |
+
color: var(--theme-text);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
a {
|
| 44 |
+
color: var(--theme-accent);
|
| 45 |
+
text-decoration: none;
|
| 46 |
+
transition: color 0.2s ease-in-out;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
a:hover {
|
| 50 |
+
color: var(--theme-hover);
|
| 51 |
+
text-decoration: underline;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/* Basic button styling (can be overridden by Tailwind or specific component styles) */
|
| 55 |
+
button,
|
| 56 |
+
input[type="button"],
|
| 57 |
+
input[type="submit"],
|
| 58 |
+
input[type="reset"] {
|
| 59 |
+
font-family: 'Orbitron', sans-serif;
|
| 60 |
+
cursor: pointer;
|
| 61 |
+
padding: 0.65rem 1.25rem; /* Slightly more padding */
|
| 62 |
+
border-radius: 0.375rem; /* rounded-md */
|
| 63 |
+
border: 1px solid transparent;
|
| 64 |
+
font-weight: 600; /* semi-bold */
|
| 65 |
+
text-transform: uppercase;
|
| 66 |
+
letter-spacing: 0.05em;
|
| 67 |
+
transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out, border-color 0.2s ease-in-out, transform 0.1s ease, box-shadow 0.2s ease;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/* Default button theme (if no Tailwind bg-* class is applied) */
|
| 71 |
+
button:not([class*="bg-"]),
|
| 72 |
+
input[type="button"]:not([class*="bg-"]),
|
| 73 |
+
input[type="submit"]:not([class*="bg-"]),
|
| 74 |
+
input[type="reset"]:not([class*="bg-"]) {
|
| 75 |
+
background-color: var(--theme-primary);
|
| 76 |
+
color: var(--theme-text);
|
| 77 |
+
border-color: var(--theme-border);
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
button:not([class*="bg-"]):hover,
|
| 81 |
+
input[type="button"]:not([class*="bg-"]):hover,
|
| 82 |
+
input[type="submit"]:not([class*="bg-"]):hover,
|
| 83 |
+
input[type="reset"]:not([class*="bg-"]):hover {
|
| 84 |
+
background-color: var(--theme-secondary);
|
| 85 |
+
border-color: var(--theme-accent);
|
| 86 |
+
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
button:active,
|
| 90 |
+
input[type="button"]:active,
|
| 91 |
+
input[type="submit"]:active,
|
| 92 |
+
input[type="reset"]:active {
|
| 93 |
+
transform: scale(0.98);
|
| 94 |
+
box-shadow: inset 0 1px 3px rgba(0,0,0,0.3);
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
/* Input field styling */
|
| 98 |
+
input[type="text"],
|
| 99 |
+
input[type="number"],
|
| 100 |
+
input[type="email"],
|
| 101 |
+
input[type="password"],
|
| 102 |
+
textarea,
|
| 103 |
+
select {
|
| 104 |
+
background-color: var(--theme-dark);
|
| 105 |
+
color: var(--theme-text);
|
| 106 |
+
border: 1px solid var(--theme-border);
|
| 107 |
+
padding: 0.65rem 0.85rem;
|
| 108 |
+
border-radius: 0.375rem; /* rounded-md */
|
| 109 |
+
transition: border-color 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
|
| 110 |
+
font-family: var(--font-sans, 'Inter', sans-serif);
|
| 111 |
+
font-size: 0.9rem;
|
| 112 |
+
width: 100%; /* Make inputs take full width by default within their container */
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
input[type="text"]:focus,
|
| 116 |
+
input[type="number"]:focus,
|
| 117 |
+
input[type="email"]:focus,
|
| 118 |
+
input[type="password"]:focus,
|
| 119 |
+
textarea:focus,
|
| 120 |
+
select:focus {
|
| 121 |
+
outline: none;
|
| 122 |
+
border-color: var(--theme-accent);
|
| 123 |
+
box-shadow: 0 0 0 3px rgba(var(--theme-accent-rgb, 0, 169, 224), 0.3); /* Uses --theme-accent-rgb from index.html */
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/* Placeholder styling */
|
| 127 |
+
::placeholder {
|
| 128 |
+
color: var(--theme-secondary);
|
| 129 |
+
opacity: 0.7;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
/* Custom Scrollbar (more global) */
|
| 133 |
+
::-webkit-scrollbar {
|
| 134 |
+
width: 12px; /* Slightly wider scrollbar */
|
| 135 |
+
height: 12px;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
::-webkit-scrollbar-track {
|
| 139 |
+
background: rgba(0,0,0,0.1); /* More subtle track */
|
| 140 |
+
border-radius: 10px;
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
::-webkit-scrollbar-thumb {
|
| 144 |
+
background-color: var(--theme-primary);
|
| 145 |
+
border-radius: 10px;
|
| 146 |
+
border: 3px solid transparent; /* Creates padding around thumb */
|
| 147 |
+
background-clip: content-box; /* Important for border to act as padding */
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
::-webkit-scrollbar-thumb:hover {
|
| 151 |
+
background-color: var(--theme-secondary);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
/* Container utility */
|
| 155 |
+
.container {
|
| 156 |
+
width: 90%;
|
| 157 |
+
max-width: 1200px; /* Standard max-width */
|
| 158 |
+
margin-left: auto;
|
| 159 |
+
margin-right: auto;
|
| 160 |
+
padding-left: 1rem;
|
| 161 |
+
padding-right: 1rem;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
/* Add any other global styles or utility classes here */
|
| 165 |
+
/* Example: A class for a subtle card-like element */
|
| 166 |
+
.card-base {
|
| 167 |
+
background-color: var(--theme-dark);
|
| 168 |
+
border: 1px solid var(--theme-border);
|
| 169 |
+
border-radius: 0.5rem; /* rounded-lg */
|
| 170 |
+
padding: 1rem;
|
| 171 |
+
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
| 172 |
+
}
|
combatwhalegame.tsx
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React, { useState, useEffect, useMemo } from 'react';
|
| 3 |
+
import { Modal } from '../Modal'; // Assuming Modal.tsx is in ../Modal
|
| 4 |
+
|
| 5 |
+
interface CombatWhaleGameModalProps {
|
| 6 |
+
isOpen: boolean;
|
| 7 |
+
onClose: () => void; // Though it might primarily close via onLoadingComplete
|
| 8 |
+
assetsToLoad: Record<string, string>; // key: assetId, value: URL
|
| 9 |
+
onLoadingComplete: (loadedAssets: Record<string, HTMLImageElement | null>) => void;
|
| 10 |
+
playerName: string;
|
| 11 |
+
playerCharacterImage: string; // URL for the player's chosen character image for display
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
// Helper to load an image and return a promise
|
| 15 |
+
const loadImageAsset = (src: string): Promise<HTMLImageElement> => {
|
| 16 |
+
return new Promise((resolve, reject) => {
|
| 17 |
+
if (!src || src.trim() === '') {
|
| 18 |
+
// Resolve with a dummy image to avoid Promise.all failure on empty string
|
| 19 |
+
const dummyImg = new Image(1,1); // 1x1 transparent pixel
|
| 20 |
+
dummyImg.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
| 21 |
+
resolve(dummyImg); // Still resolve so Promise.all doesn't break, map will contain this placeholder
|
| 22 |
+
return;
|
| 23 |
+
}
|
| 24 |
+
const img = new Image();
|
| 25 |
+
img.onload = () => resolve(img);
|
| 26 |
+
img.onerror = (eventOrMessage: Event | string) => {
|
| 27 |
+
let errorDetails = "Unknown error during image load.";
|
| 28 |
+
if (typeof eventOrMessage === 'string') {
|
| 29 |
+
errorDetails = eventOrMessage;
|
| 30 |
+
} else if (eventOrMessage && (eventOrMessage instanceof Event)) {
|
| 31 |
+
const targetElement = eventOrMessage.target as HTMLImageElement | null;
|
| 32 |
+
errorDetails = `Event type: ${eventOrMessage.type}. Target src: ${targetElement ? targetElement.src : 'N/A'}`;
|
| 33 |
+
}
|
| 34 |
+
console.error(`Failed to load image asset: ${src}. Details: ${errorDetails}`, eventOrMessage);
|
| 35 |
+
// Resolve with a dummy image to avoid Promise.all failure
|
| 36 |
+
const dummyImg = new Image(1,1); // 1x1 transparent pixel
|
| 37 |
+
dummyImg.src = "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
| 38 |
+
resolve(dummyImg); // Resolve with placeholder, error logged.
|
| 39 |
+
};
|
| 40 |
+
img.src = src;
|
| 41 |
+
});
|
| 42 |
+
};
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
export const CombatWhaleGameModal: React.FC<CombatWhaleGameModalProps> = ({
|
| 46 |
+
isOpen,
|
| 47 |
+
onClose,
|
| 48 |
+
assetsToLoad,
|
| 49 |
+
onLoadingComplete,
|
| 50 |
+
playerName,
|
| 51 |
+
playerCharacterImage,
|
| 52 |
+
}) => {
|
| 53 |
+
const [loadingProgress, setLoadingProgress] = useState(0);
|
| 54 |
+
const [currentAssetMessage, setCurrentAssetMessage] = useState('Initializing...');
|
| 55 |
+
|
| 56 |
+
// Filter out invalid asset sources upfront
|
| 57 |
+
const assetEntries = useMemo(() =>
|
| 58 |
+
Object.entries(assetsToLoad).filter(([_, src]) => src && src.trim() !== ''),
|
| 59 |
+
[assetsToLoad]
|
| 60 |
+
);
|
| 61 |
+
const totalAssets = useMemo(() => assetEntries.length, [assetEntries]);
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
useEffect(() => {
|
| 65 |
+
if (!isOpen) return;
|
| 66 |
+
|
| 67 |
+
setLoadingProgress(0);
|
| 68 |
+
setCurrentAssetMessage('Initializing...');
|
| 69 |
+
|
| 70 |
+
if (totalAssets === 0) {
|
| 71 |
+
setCurrentAssetMessage('No valid assets to load or all assets are empty.');
|
| 72 |
+
setLoadingProgress(100);
|
| 73 |
+
setTimeout(() => {
|
| 74 |
+
onLoadingComplete({}); // Pass empty map if no assets
|
| 75 |
+
}, 100);
|
| 76 |
+
return;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
let loadedCount = 0;
|
| 80 |
+
const loadedAssetsMap: Record<string, HTMLImageElement | null> = {};
|
| 81 |
+
|
| 82 |
+
// Use the filtered assetEntries
|
| 83 |
+
assetEntries.forEach(([key, src], index) => {
|
| 84 |
+
setCurrentAssetMessage(`Loading ${key} (${index + 1}/${totalAssets})...`);
|
| 85 |
+
loadImageAsset(src)
|
| 86 |
+
.then(img => {
|
| 87 |
+
// Check if it's not the dummy image before storing, or store as null if it is
|
| 88 |
+
if (img.src !== "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7") {
|
| 89 |
+
loadedAssetsMap[key] = img;
|
| 90 |
+
} else {
|
| 91 |
+
loadedAssetsMap[key] = null; // Indicates an empty or failed load
|
| 92 |
+
}
|
| 93 |
+
})
|
| 94 |
+
// loadImageAsset now always resolves, so .catch is less critical here
|
| 95 |
+
.catch(() => { // Should not be reached if loadImageAsset always resolves
|
| 96 |
+
loadedAssetsMap[key] = null;
|
| 97 |
+
})
|
| 98 |
+
.finally(() => {
|
| 99 |
+
loadedCount++;
|
| 100 |
+
setLoadingProgress((loadedCount / totalAssets) * 100);
|
| 101 |
+
if (loadedCount === totalAssets) {
|
| 102 |
+
setCurrentAssetMessage('All assets processed!');
|
| 103 |
+
setTimeout(() => {
|
| 104 |
+
onLoadingComplete(loadedAssetsMap);
|
| 105 |
+
}, 500);
|
| 106 |
+
}
|
| 107 |
+
});
|
| 108 |
+
});
|
| 109 |
+
}, [isOpen, assetEntries, totalAssets, onLoadingComplete]); // Dependencies updated
|
| 110 |
+
|
| 111 |
+
if (!isOpen) return null;
|
| 112 |
+
|
| 113 |
+
return (
|
| 114 |
+
<Modal isOpen={isOpen} onClose={onClose} title={`Get Ready, ${playerName}!`} size="md">
|
| 115 |
+
<div className="flex flex-col items-center justify-center p-6 space-y-6">
|
| 116 |
+
<h3 className="text-xl font-semibold text-theme-accent">Entering the Crypto-Ocean...</h3>
|
| 117 |
+
{playerCharacterImage && (
|
| 118 |
+
<img
|
| 119 |
+
src={playerCharacterImage}
|
| 120 |
+
alt={`${playerName}'s Whale`}
|
| 121 |
+
className="w-32 h-32 md:w-40 md:h-40 object-contain rounded-lg border-2 border-theme-accent shadow-lg animate-pulseCustom"
|
| 122 |
+
/>
|
| 123 |
+
)}
|
| 124 |
+
<div className="w-full bg-theme-primary/50 rounded-full h-6 overflow-hidden border border-theme-border">
|
| 125 |
+
<div
|
| 126 |
+
className="bg-theme-success h-full rounded-full text-xs font-medium text-theme-dark text-center p-0.5 leading-none transition-all duration-300 ease-linear"
|
| 127 |
+
style={{ width: `${loadingProgress}%` }}
|
| 128 |
+
aria-valuenow={loadingProgress}
|
| 129 |
+
aria-valuemin={0}
|
| 130 |
+
aria-valuemax={100}
|
| 131 |
+
role="progressbar"
|
| 132 |
+
>
|
| 133 |
+
{Math.round(loadingProgress)}%
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
<p className="text-sm text-gray-300">{currentAssetMessage}</p>
|
| 137 |
+
{loadingProgress < 100 && totalAssets > 0 && (
|
| 138 |
+
<div className="w-10 h-10 border-4 border-theme-accent border-t-transparent rounded-full animate-spin mt-4" aria-label="Loading assets"></div>
|
| 139 |
+
)}
|
| 140 |
+
</div>
|
| 141 |
+
</Modal>
|
| 142 |
+
);
|
| 143 |
+
};
|
constants.ts
ADDED
|
@@ -0,0 +1,980 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
|
| 3 |
+
import { PlayerData, NFT, GameState, Rarity, BreedingResult, NFTCoinEffect, AdminConfig, GameCharacter, WeaponType, DogeWhaleEscapeLevel, MazeCellType } from './types.ts';
|
| 4 |
+
import { generateId } from './utils.ts';
|
| 5 |
+
|
| 6 |
+
// Simple constant exports moved higher
|
| 7 |
+
export const NFT_PLACEHOLDER_IMAGE_URL = 'https://picsum.photos/seed';
|
| 8 |
+
export const MAX_STAKING_SLOTS = 5;
|
| 9 |
+
export const BASE_STAKING_REWARD_PER_MINUTE = 1; // Sea Tokens per minute per NFT
|
| 10 |
+
export const INITIAL_MINING_POWER = 1;
|
| 11 |
+
export const MINING_POWER_UPGRADE_COST_BASE = 50; // Sea Tokens for first upgrade (to power 2)
|
| 12 |
+
export const MINING_POWER_UPGRADE_COST_FACTOR = 1.5; // Cost multiplies by this for each level
|
| 13 |
+
export const LEVEL_XP_THRESHOLDS = [100, 250, 500, 1000, 2000];
|
| 14 |
+
export const STAKING_REWARD_RATE_PER_HOUR = 0.05; // For Sea Tokens (token-only staking)
|
| 15 |
+
export const BREEDING_TIME_SECONDS = 10;
|
| 16 |
+
export const NFT_BUBBLE_SPAWN_INTERVAL_MS = 5000;
|
| 17 |
+
export const NFT_BUBBLE_SPAWN_CHANCE = 0.2;
|
| 18 |
+
export const PLAYER_SIZE = 30;
|
| 19 |
+
export const PLAYER_SPEED = 4;
|
| 20 |
+
export const ENEMY_SIZE = 25; // Default enemy size, can be overridden by character def
|
| 21 |
+
export const MAX_ENEMIES = 10; // Increased max enemies for more challenge
|
| 22 |
+
export const TOKEN_VALUE_ON_COLLECT = 5;
|
| 23 |
+
export const FLOATING_TEXT_DURATION_MS = 1000;
|
| 24 |
+
export const SEA_TOKEN_SIZE = 8;
|
| 25 |
+
export const SEA_TOKEN_COLOR = 'gold';
|
| 26 |
+
export const SEA_TOKEN_ATTRACT_SPEED = 2.5;
|
| 27 |
+
export const MAX_SEA_TOKENS_ON_SCREEN = 50;
|
| 28 |
+
export const STANDARD_PROJECTILE_SPEED = 7;
|
| 29 |
+
export const HEAVY_PROJECTILE_SPEED = 5;
|
| 30 |
+
export const ROCKET_PROJECTILE_SPEED = 6;
|
| 31 |
+
export const LASER_PROJECTILE_SPEED = 10;
|
| 32 |
+
export const MISSILE_SPEED = 4;
|
| 33 |
+
export const SWORD_THROW_SPEED = 8;
|
| 34 |
+
export const SWORD_PROJECTILE_WIDTH = 8;
|
| 35 |
+
export const SWORD_PROJECTILE_HEIGHT = 20;
|
| 36 |
+
export const BOMB_PROJECTILE_WIDTH = 12;
|
| 37 |
+
export const BOMB_PROJECTILE_HEIGHT = 12;
|
| 38 |
+
export const BOMB_PROJECTILE_INITIAL_SPEED_Y = -5;
|
| 39 |
+
export const BOMB_PROJECTILE_SPEED_X_FACTOR = 0.8;
|
| 40 |
+
export const BOMB_GRAVITY = 0.12;
|
| 41 |
+
export const BOMB_FUSE_MS = 2500;
|
| 42 |
+
export const BOMB_EXPLOSION_RADIUS = 70;
|
| 43 |
+
export const BOMB_EXPLOSION_DURATION_MS = 400;
|
| 44 |
+
export const BOMB_DAMAGE = 35;
|
| 45 |
+
export const FISH_MINION_SIZE = 15;
|
| 46 |
+
export const FISH_MINION_MAX_HEALTH = 25;
|
| 47 |
+
export const FISH_MINION_ATTACK_RANGE = 180;
|
| 48 |
+
export const FISH_MINION_PROJECTILE_SPEED = 6;
|
| 49 |
+
export const FISH_MINION_PROJECTILE_SIZE = 5;
|
| 50 |
+
export const FISH_MINION_FIRE_COOLDOWN_MS = 1200;
|
| 51 |
+
export const FISH_MINION_BULLET_DAMAGE = 8;
|
| 52 |
+
export const MAX_FISH_MINIONS_PER_PLAYER = 3;
|
| 53 |
+
export const FISH_MINION_FOLLOW_DISTANCE = 50;
|
| 54 |
+
export const FISH_MINION_FOLLOW_LERP_FACTOR = 0.03;
|
| 55 |
+
export const AUTO_ROCKET_COOLDOWN_MS = 2500;
|
| 56 |
+
export const AUTO_ROCKET_RANGE = 250;
|
| 57 |
+
export const AUTO_ROCKET_SPEED = 3;
|
| 58 |
+
export const ENEMY_PROJECTILE_SPEED = 3;
|
| 59 |
+
export const ENEMY_PROJECTILE_SIZE = 5;
|
| 60 |
+
export const ENEMY_BASE_HEALTH = 25; // Default, can be overridden
|
| 61 |
+
export const COOLDOWN_STANDARD_BULLET = 250;
|
| 62 |
+
export const COOLDOWN_HEAVY_BULLET = 400;
|
| 63 |
+
export const COOLDOWN_ROCKET = 600;
|
| 64 |
+
export const COOLDOWN_LASER = 500;
|
| 65 |
+
export const COOLDOWN_SWORD_THROW = 700;
|
| 66 |
+
export const COOLDOWN_AUTO_TARGET_MISSILE = 300;
|
| 67 |
+
export const COOLDOWN_BOMB = 1800;
|
| 68 |
+
export const COOLDOWN_SUMMON_FISH_MINION = 6000;
|
| 69 |
+
|
| 70 |
+
// Cone Fire Constants for StandardBullet & HeavyBullet (multi-shot)
|
| 71 |
+
export const CONE_PROJECTILE_COUNT_STANDARD = 3;
|
| 72 |
+
export const CONE_PROJECTILE_COUNT_HEAVY = 3;
|
| 73 |
+
export const CONE_ANGLE_DEGREES_MULTI_SHOT = 30; // Total angle of the cone spread for multi-shot
|
| 74 |
+
|
| 75 |
+
// FlameCone Constants
|
| 76 |
+
export const FLAME_CONE_ANGLE_DEGREES = 45; // Wider cone for flame effect
|
| 77 |
+
export const FLAME_CONE_RANGE = 120; // How far the flame cone reaches
|
| 78 |
+
export const FLAME_CONE_DURATION_MS = 200; // How long the visual effect of the cone lasts
|
| 79 |
+
export const FLAME_CONE_DAMAGE_INITIAL = 8;
|
| 80 |
+
export const FLAME_CONE_DOT_DAMAGE_PER_TICK = 3;
|
| 81 |
+
export const FLAME_CONE_DOT_TOTAL_TICKS = 5; // Number of times burn damage is applied
|
| 82 |
+
export const FLAME_CONE_DOT_TICK_INTERVAL_MS = 500; // Time between each burn damage tick
|
| 83 |
+
export const COOLDOWN_FLAME_CONE = 450;
|
| 84 |
+
|
| 85 |
+
// FlameCone Explosive Core Constants
|
| 86 |
+
export const EXPLOSIVE_CORE_THRESHOLD_MS = 1500; // 1.5 seconds of fire exposure
|
| 87 |
+
export const EXPLOSIVE_CORE_DAMAGE = 30; // Damage of the mini-blast
|
| 88 |
+
export const EXPLOSIVE_CORE_RADIUS = 60; // Radius of the mini-blast
|
| 89 |
+
export const EXPLOSIVE_CORE_SELF_DAMAGE_MULTIPLIER = 1.5; // Multiplier for damage to the exploding enemy itself
|
| 90 |
+
export const EXPLOSIVE_CORE_EXPLOSION_COLOR = 'rgba(255,100,0,0.8)'; // Fiery orange-red for explosion visual
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
// Red Arrow Defense System Constants
|
| 94 |
+
export const COOLDOWN_RED_ARROW_DEFENSE = 500; // Fires 2 per second (500ms per projectile)
|
| 95 |
+
export const RED_ARROW_PROJECTILE_SPEED = 9;
|
| 96 |
+
export const RED_ARROW_PROJECTILE_DAMAGE = 1000; // High damage to "kill"
|
| 97 |
+
export const RED_ARROW_PROJECTILE_WIDTH = 10; // Visual size
|
| 98 |
+
export const RED_ARROW_PROJECTILE_HEIGHT = 20; // Visual size
|
| 99 |
+
export const RED_ARROW_PROJECTILE_IMAGE_URL = `https://via.placeholder.com/50x100/FF0000/FFFFFF?text=Arrow`;
|
| 100 |
+
export const RED_ARROW_DEFAULT_SHOT_COUNT = 5;
|
| 101 |
+
|
| 102 |
+
// DogeLauncher Weapon Constants
|
| 103 |
+
export const DOGE_PROJECTILE_IMAGE_URL = 'https://via.placeholder.com/50/FFFF00/000000?text=DOGE'; // Placeholder
|
| 104 |
+
export const BONE_PROJECTILE_IMAGE_URL = 'https://via.placeholder.com/50x30/FFFFFF/000000?text=BONE'; // Placeholder
|
| 105 |
+
export const DOGE_PROJECTILE_WIDTH = 25;
|
| 106 |
+
export const DOGE_PROJECTILE_HEIGHT = 25;
|
| 107 |
+
export const BONE_PROJECTILE_WIDTH = 25;
|
| 108 |
+
export const BONE_PROJECTILE_HEIGHT = 15;
|
| 109 |
+
export const DOGE_LAUNCHER_PROJECTILE_SPEED = 9;
|
| 110 |
+
export const DOGE_LAUNCHER_DAMAGE = 9999; // One-hit kill damage
|
| 111 |
+
export const COOLDOWN_DOGE_LAUNCHER = 350;
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
export const NFT_DEFINITIONS: Record<string, NFT> = {
|
| 115 |
+
doge_whale: {
|
| 116 |
+
id: 'doge_whale',
|
| 117 |
+
name: 'Doge Whale NFT',
|
| 118 |
+
description: '+5% Sea Token Collection', // Updated
|
| 119 |
+
price: 100, // Price in Sea Tokens
|
| 120 |
+
rarity: Rarity.Common,
|
| 121 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/dogewhalenft/200/150`,
|
| 122 |
+
effect: { coins: 1.05 }
|
| 123 |
+
},
|
| 124 |
+
node_doge_whale: {
|
| 125 |
+
id: 'node_doge_whale',
|
| 126 |
+
name: 'Node Doge NFT',
|
| 127 |
+
description: '+10% XP Gain',
|
| 128 |
+
price: 200, // Price in Sea Tokens
|
| 129 |
+
rarity: Rarity.Common,
|
| 130 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/nodedogenft/200/150`,
|
| 131 |
+
effect: { xp: 1.10 }
|
| 132 |
+
},
|
| 133 |
+
mutant_rune_whale: {
|
| 134 |
+
id: 'mutant_rune_whale',
|
| 135 |
+
name: 'Mutant Rune NFT',
|
| 136 |
+
description: '+15% Damage',
|
| 137 |
+
price: 300, // Price in Sea Tokens
|
| 138 |
+
rarity: Rarity.Rare,
|
| 139 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/mutantrunenft/200/150`,
|
| 140 |
+
effect: { damage: 1.15 }
|
| 141 |
+
},
|
| 142 |
+
crystal_whale: {
|
| 143 |
+
id: 'crystal_whale',
|
| 144 |
+
name: '3D Crystal NFT',
|
| 145 |
+
description: '+20% Movement Speed',
|
| 146 |
+
price: 400, // Price in Sea Tokens
|
| 147 |
+
rarity: Rarity.Rare,
|
| 148 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/crystalwhalenft/200/150`,
|
| 149 |
+
effect: { speed: 1.20 }
|
| 150 |
+
},
|
| 151 |
+
quantum_whale: {
|
| 152 |
+
id: 'quantum_whale',
|
| 153 |
+
name: 'Quantum NFT',
|
| 154 |
+
description: '+10% All Stats',
|
| 155 |
+
price: 600, // Price in Sea Tokens
|
| 156 |
+
rarity: Rarity.Legendary,
|
| 157 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/quantumnft/200/150`,
|
| 158 |
+
effect: { allStats: 1.10 }
|
| 159 |
+
},
|
| 160 |
+
smart_whale: {
|
| 161 |
+
id: 'smart_whale',
|
| 162 |
+
name: 'Smart NFT (Bred)',
|
| 163 |
+
description: '+7% Sea Token & +5% XP', // Updated
|
| 164 |
+
price: 0, // Bred NFTs are not typically priced for market
|
| 165 |
+
rarity: Rarity.Rare,
|
| 166 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/smartwhalenft/200/150`,
|
| 167 |
+
effect: { coins: 1.07, xp: 1.05 }
|
| 168 |
+
},
|
| 169 |
+
hyper_whale: {
|
| 170 |
+
id: 'hyper_whale',
|
| 171 |
+
name: 'Hyper NFT (Bred)',
|
| 172 |
+
description: '+15% All Stats',
|
| 173 |
+
price: 0, // Bred NFTs are not typically priced for market
|
| 174 |
+
rarity: Rarity.Legendary,
|
| 175 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/hyperwhalenft/200/150`,
|
| 176 |
+
effect: { allStats: 1.15 }
|
| 177 |
+
}
|
| 178 |
+
};
|
| 179 |
+
|
| 180 |
+
export const GAME_CHARACTERS: Record<string, GameCharacter> = {
|
| 181 |
+
// Player Characters
|
| 182 |
+
classic_doge_whale: {
|
| 183 |
+
id: 'classic_doge_whale',
|
| 184 |
+
name: 'Doge Launcher Whale 🦴', // Renamed and weapon changed
|
| 185 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/nodedogeplayer/100/100`,
|
| 186 |
+
type: 'player',
|
| 187 |
+
defaultWeaponType: WeaponType.DogeLauncher, // Changed to DogeLauncher
|
| 188 |
+
autoFireHealthThreshold: 0,
|
| 189 |
+
baseHealth: 100,
|
| 190 |
+
canOneHitKill: false, // Weapon handles the one-hit kill
|
| 191 |
+
},
|
| 192 |
+
armored_doge_whale: {
|
| 193 |
+
id: 'armored_doge_whale',
|
| 194 |
+
name: 'Armored Doge Whale',
|
| 195 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/armoredplayer/100/100`,
|
| 196 |
+
type: 'player',
|
| 197 |
+
defaultWeaponType: WeaponType.HeavyBullet,
|
| 198 |
+
autoFireHealthThreshold: 25,
|
| 199 |
+
baseHealth: 150,
|
| 200 |
+
canOneHitKill: true,
|
| 201 |
+
},
|
| 202 |
+
laser_sniper_whale: {
|
| 203 |
+
id: 'laser_sniper_whale',
|
| 204 |
+
name: 'Laser Sniper Whale',
|
| 205 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/lasersniperplayer/100/100`,
|
| 206 |
+
type: 'player',
|
| 207 |
+
defaultWeaponType: WeaponType.Laser,
|
| 208 |
+
autoFireHealthThreshold: 50,
|
| 209 |
+
baseHealth: 80,
|
| 210 |
+
canOneHitKill: false,
|
| 211 |
+
},
|
| 212 |
+
demolition_doge_whale: {
|
| 213 |
+
id: 'demolition_doge_whale',
|
| 214 |
+
name: 'Demolition Doge Whale',
|
| 215 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/demodogeplayer/100/100`,
|
| 216 |
+
type: 'player',
|
| 217 |
+
defaultWeaponType: WeaponType.Bomb,
|
| 218 |
+
autoFireHealthThreshold: 10,
|
| 219 |
+
baseHealth: 120,
|
| 220 |
+
canOneHitKill: false,
|
| 221 |
+
},
|
| 222 |
+
summoner_doge_whale: {
|
| 223 |
+
id: 'summoner_doge_whale',
|
| 224 |
+
name: 'Summoner Doge Whale',
|
| 225 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/summonerdogeplayer/100/100`,
|
| 226 |
+
type: 'player',
|
| 227 |
+
defaultWeaponType: WeaponType.SummonFishMinion,
|
| 228 |
+
autoFireHealthThreshold: 75,
|
| 229 |
+
baseHealth: 90,
|
| 230 |
+
canOneHitKill: false,
|
| 231 |
+
},
|
| 232 |
+
three_d_crystal_whale: {
|
| 233 |
+
id: 'three_d_crystal_whale',
|
| 234 |
+
name: '3D Crystal Whale',
|
| 235 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/3dcrystalplayer/100/100`,
|
| 236 |
+
type: 'player',
|
| 237 |
+
defaultWeaponType: WeaponType.Laser,
|
| 238 |
+
autoFireHealthThreshold: 60,
|
| 239 |
+
baseHealth: 90,
|
| 240 |
+
canOneHitKill: false,
|
| 241 |
+
},
|
| 242 |
+
original_doge_whale: {
|
| 243 |
+
id: 'original_doge_whale',
|
| 244 |
+
name: 'Doge Whale (Standard)',
|
| 245 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/dogewhaleplayer/100/100`,
|
| 246 |
+
type: 'player',
|
| 247 |
+
defaultWeaponType: WeaponType.StandardBullet,
|
| 248 |
+
autoFireHealthThreshold: 0,
|
| 249 |
+
baseHealth: 100,
|
| 250 |
+
canOneHitKill: false,
|
| 251 |
+
},
|
| 252 |
+
guardian_whale: {
|
| 253 |
+
id: 'guardian_whale',
|
| 254 |
+
name: 'Guardian Whale 🛡️',
|
| 255 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/guardianwhale/100/100`,
|
| 256 |
+
type: 'player',
|
| 257 |
+
defaultWeaponType: WeaponType.RedArrowDefense,
|
| 258 |
+
autoFireHealthThreshold: 0,
|
| 259 |
+
baseHealth: 120,
|
| 260 |
+
canOneHitKill: false,
|
| 261 |
+
},
|
| 262 |
+
|
| 263 |
+
// Enemy Characters (Original + New)
|
| 264 |
+
minnow_enemy: {
|
| 265 |
+
id: 'minnow_enemy',
|
| 266 |
+
name: 'Space Minnow',
|
| 267 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/minnowenemy/80/80`,
|
| 268 |
+
type: 'enemy',
|
| 269 |
+
baseHealth: ENEMY_BASE_HEALTH,
|
| 270 |
+
baseSpeed: 1.5,
|
| 271 |
+
},
|
| 272 |
+
piranha_enemy: {
|
| 273 |
+
id: 'piranha_enemy',
|
| 274 |
+
name: 'Void Piranha',
|
| 275 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/piranhaenemy/80/80`,
|
| 276 |
+
type: 'enemy',
|
| 277 |
+
baseHealth: ENEMY_BASE_HEALTH + 10,
|
| 278 |
+
baseSpeed: 2,
|
| 279 |
+
},
|
| 280 |
+
cosmic_jellyfish_enemy: {
|
| 281 |
+
id: 'cosmic_jellyfish_enemy',
|
| 282 |
+
name: 'Cosmic Jellyfish',
|
| 283 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/cosmicjelly/80/80`,
|
| 284 |
+
type: 'enemy',
|
| 285 |
+
baseHealth: ENEMY_BASE_HEALTH + 5,
|
| 286 |
+
baseSpeed: 1.2,
|
| 287 |
+
},
|
| 288 |
+
starlight_angler_enemy: {
|
| 289 |
+
id: 'starlight_angler_enemy',
|
| 290 |
+
name: 'Starlight Angler',
|
| 291 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/starlightangler/80/80`,
|
| 292 |
+
type: 'enemy',
|
| 293 |
+
baseHealth: ENEMY_BASE_HEALTH + 15,
|
| 294 |
+
baseSpeed: 1.8,
|
| 295 |
+
},
|
| 296 |
+
void_barracuda_enemy: {
|
| 297 |
+
id: 'void_barracuda_enemy',
|
| 298 |
+
name: 'Void Barracuda',
|
| 299 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/voidbarracuda/80/80`,
|
| 300 |
+
type: 'enemy',
|
| 301 |
+
baseHealth: ENEMY_BASE_HEALTH + 20,
|
| 302 |
+
baseSpeed: 2.5,
|
| 303 |
+
},
|
| 304 |
+
asteroid_crab_enemy: {
|
| 305 |
+
id: 'asteroid_crab_enemy',
|
| 306 |
+
name: 'Asteroid Crab',
|
| 307 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/asteroidcrab/80/80`,
|
| 308 |
+
type: 'enemy',
|
| 309 |
+
baseHealth: ENEMY_BASE_HEALTH + 30,
|
| 310 |
+
baseSpeed: 1.0,
|
| 311 |
+
},
|
| 312 |
+
// New Enemies from Snippet
|
| 313 |
+
shark_bot_enemy: {
|
| 314 |
+
id: 'shark_bot_enemy',
|
| 315 |
+
name: 'Shark Bot 🦈',
|
| 316 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/sharkbot/80/80`, // Placeholder
|
| 317 |
+
type: 'enemy',
|
| 318 |
+
baseHealth: 10 * 5, // damage: 10
|
| 319 |
+
baseSpeed: 2.5,
|
| 320 |
+
},
|
| 321 |
+
giant_squid_enemy: {
|
| 322 |
+
id: 'giant_squid_enemy',
|
| 323 |
+
name: 'Giant Squid 🦑',
|
| 324 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/giantsquid/80/80`,
|
| 325 |
+
type: 'enemy',
|
| 326 |
+
baseHealth: 20 * 5, // damage: 20
|
| 327 |
+
baseSpeed: 1.8,
|
| 328 |
+
},
|
| 329 |
+
electric_eel_enemy: {
|
| 330 |
+
id: 'electric_eel_enemy',
|
| 331 |
+
name: 'Electric Eel ⚡',
|
| 332 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/electriceel/80/80`,
|
| 333 |
+
type: 'enemy',
|
| 334 |
+
baseHealth: 15 * 5, // damage: 15
|
| 335 |
+
baseSpeed: 3.0,
|
| 336 |
+
},
|
| 337 |
+
deep_sea_serpent_enemy: {
|
| 338 |
+
id: 'deep_sea_serpent_enemy',
|
| 339 |
+
name: 'Deep Sea Serpent 🐍',
|
| 340 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/deepseaserpent/80/80`,
|
| 341 |
+
type: 'enemy',
|
| 342 |
+
baseHealth: 25 * 5, // damage: 25
|
| 343 |
+
baseSpeed: 4.0,
|
| 344 |
+
},
|
| 345 |
+
submarine_drone_enemy: {
|
| 346 |
+
id: 'submarine_drone_enemy',
|
| 347 |
+
name: 'Submarine Drone 🤖',
|
| 348 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/submarinedrone/80/80`,
|
| 349 |
+
type: 'enemy',
|
| 350 |
+
baseHealth: 10 * 5, // damage: 10
|
| 351 |
+
baseSpeed: 2.2,
|
| 352 |
+
},
|
| 353 |
+
underwater_mine_enemy: {
|
| 354 |
+
id: 'underwater_mine_enemy',
|
| 355 |
+
name: 'Underwater Mine 💣',
|
| 356 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/underwatermine/80/80`,
|
| 357 |
+
type: 'enemy',
|
| 358 |
+
baseHealth: 30 * 3, // damage: 30 (less health as it might be stationary or slow)
|
| 359 |
+
baseSpeed: 0.5, // Slow or stationary
|
| 360 |
+
},
|
| 361 |
+
mecha_octopus_enemy: {
|
| 362 |
+
id: 'mecha_octopus_enemy',
|
| 363 |
+
name: 'Mecha Octopus 🐙',
|
| 364 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/mechaoctopus/100/100`, // Larger
|
| 365 |
+
type: 'enemy',
|
| 366 |
+
baseHealth: 40 * 5, // damage: 40 (Boss-like)
|
| 367 |
+
baseSpeed: 1.2,
|
| 368 |
+
},
|
| 369 |
+
alien_crab_enemy: {
|
| 370 |
+
id: 'alien_crab_enemy',
|
| 371 |
+
name: 'Alien Crab 🦀',
|
| 372 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/aliencrab/80/80`,
|
| 373 |
+
type: 'enemy',
|
| 374 |
+
baseHealth: 20 * 5, // damage: 20
|
| 375 |
+
baseSpeed: 2.7,
|
| 376 |
+
},
|
| 377 |
+
toxic_jellyfish_enemy: {
|
| 378 |
+
id: 'toxic_jellyfish_enemy',
|
| 379 |
+
name: 'Toxic Jellyfish ☠️',
|
| 380 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/toxicjellyfish/80/80`,
|
| 381 |
+
type: 'enemy',
|
| 382 |
+
baseHealth: 10 * 4, // damage: 10
|
| 383 |
+
baseSpeed: 1.5,
|
| 384 |
+
},
|
| 385 |
+
radioactive_piranha_enemy: {
|
| 386 |
+
id: 'radioactive_piranha_enemy',
|
| 387 |
+
name: 'Radioactive Piranha 🐟',
|
| 388 |
+
image: `${NFT_PLACEHOLDER_IMAGE_URL}/radioactivepiranha/80/80`,
|
| 389 |
+
type: 'enemy',
|
| 390 |
+
baseHealth: 5 * 5, // damage: 5 (swarm implies quantity over quality)
|
| 391 |
+
baseSpeed: 3.5,
|
| 392 |
+
},
|
| 393 |
+
};
|
| 394 |
+
|
| 395 |
+
export const BREEDING_COMBINATIONS: Record<string, BreedingResult> = {
|
| 396 |
+
"doge_whale_node_doge_whale": {
|
| 397 |
+
name: "Smart NFT (Bred)",
|
| 398 |
+
description: "+7% Sea Token & +5% XP", // Updated
|
| 399 |
+
image: NFT_DEFINITIONS.smart_whale.image,
|
| 400 |
+
rarity: Rarity.Rare,
|
| 401 |
+
effect: { coins: 1.07, xp: 1.05 }
|
| 402 |
+
},
|
| 403 |
+
"crystal_whale_quantum_whale": {
|
| 404 |
+
name: "Hyper NFT (Bred)",
|
| 405 |
+
description: "+15% All Stats",
|
| 406 |
+
image: NFT_DEFINITIONS.hyper_whale.image,
|
| 407 |
+
rarity: Rarity.Legendary,
|
| 408 |
+
effect: { allStats: 1.15 }
|
| 409 |
+
}
|
| 410 |
+
};
|
| 411 |
+
|
| 412 |
+
const DEFAULT_PLAYER_CHAR_ID = 'classic_doge_whale';
|
| 413 |
+
const DEFAULT_PLAYER_CHAR_DETAILS = GAME_CHARACTERS[DEFAULT_PLAYER_CHAR_ID];
|
| 414 |
+
|
| 415 |
+
export const INITIAL_PLAYER_DATA: PlayerData = {
|
| 416 |
+
id: generateId(),
|
| 417 |
+
name: '',
|
| 418 |
+
characterId: DEFAULT_PLAYER_CHAR_ID,
|
| 419 |
+
whaleColor: '#0074D9',
|
| 420 |
+
level: 1,
|
| 421 |
+
xp: 0,
|
| 422 |
+
maxXP: 100,
|
| 423 |
+
health: DEFAULT_PLAYER_CHAR_DETAILS?.baseHealth || 100,
|
| 424 |
+
maxHealth: DEFAULT_PLAYER_CHAR_DETAILS?.baseHealth || 100,
|
| 425 |
+
seaTokens: 500, // Renamed from dwhl
|
| 426 |
+
equippedNftId: 'doge_whale',
|
| 427 |
+
nftInventory: ['doge_whale'],
|
| 428 |
+
stakedTokens: 0,
|
| 429 |
+
stakingStartTime: null,
|
| 430 |
+
stakedNftSlots: Array(MAX_STAKING_SLOTS).fill(null),
|
| 431 |
+
nftMiningPowers: {},
|
| 432 |
+
lastNftRewardClaimTime: null,
|
| 433 |
+
totalStakingRewards: 0,
|
| 434 |
+
score: 0,
|
| 435 |
+
};
|
| 436 |
+
|
| 437 |
+
// --- Doge Whale Escape Game Constant URLs (will be used as fallbacks if not set in AdminConfig) ---
|
| 438 |
+
export const DOGE_WHALE_ESCAPE_PLAYER_IMAGE_URL = 'https://via.placeholder.com/32/FFFF00/000000?text=Doge';
|
| 439 |
+
export const DOGE_WHALE_ESCAPE_MINE_IMAGE_URL = 'https://via.placeholder.com/32/FF0000/FFFFFF?text=M';
|
| 440 |
+
export const DOGE_WHALE_ESCAPE_GOAL_IMAGE_URL = 'https://via.placeholder.com/32/00FF00/FFFFFF?text=G';
|
| 441 |
+
export const DOGE_WHALE_ESCAPE_WALL_IMAGE_URL = 'https://via.placeholder.com/32/808080/FFFFFF?text=W';
|
| 442 |
+
export const DOGE_WHALE_ESCAPE_BOMB_IMAGE_URL = 'https://via.placeholder.com/32/800000/FFFFFF?text=B';
|
| 443 |
+
export const DOGE_WHALE_ESCAPE_JELLYFISH_IMAGE_URL = 'https://via.placeholder.com/32/800080/FFFFFF?text=J';
|
| 444 |
+
export const DOGE_WHALE_ESCAPE_CROCODILE_IMAGE_URL = 'https://via.placeholder.com/32/008000/FFFFFF?text=C';
|
| 445 |
+
export const DOGE_WHALE_ESCAPE_ALGAE_IMAGE_URL = 'https://via.placeholder.com/32/32CD32/FFFFFF?text=A';
|
| 446 |
+
export const DOGE_WHALE_ESCAPE_CURRENT_N_IMAGE_URL = 'https://via.placeholder.com/32/00FFFF/000000?text=N%E2%86%91';
|
| 447 |
+
export const DOGE_WHALE_ESCAPE_CURRENT_S_IMAGE_URL = 'https://via.placeholder.com/32/00FFFF/000000?text=S%E2%86%93';
|
| 448 |
+
export const DOGE_WHALE_ESCAPE_CURRENT_E_IMAGE_URL = 'https://via.placeholder.com/32/00FFFF/000000?text=E%E2%86%92';
|
| 449 |
+
export const DOGE_WHALE_ESCAPE_CURRENT_W_IMAGE_URL = 'https://via.placeholder.com/32/00FFFF/000000?text=%E2%86%90W';
|
| 450 |
+
export const DOGE_WHALE_ESCAPE_SNAKE_IMAGE_URL = 'https://via.placeholder.com/32/FFA500/000000?text=S';
|
| 451 |
+
export const DOGE_WHALE_ESCAPE_LIFEUP_IMAGE_URL = 'https://via.placeholder.com/32/FFC0CB/000000?text=L%E2%99%A5';
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
export const INITIAL_ADMIN_CONFIG: AdminConfig = {
|
| 455 |
+
globalNftStakingRate: 0.1,
|
| 456 |
+
globalTokenStakingRate: STAKING_REWARD_RATE_PER_HOUR,
|
| 457 |
+
gameBackgroundImageUrl: 'https://images.pexels.com/photos/1666021/pexels-photo-1666021.jpeg?auto=compress&cs=tinysrgb&w=1260&h=750&dpr=1', // Default background
|
| 458 |
+
defaultEnemyImageUrl: 'https://via.placeholder.com/80/FF6347/FFFFFF?text=Enemy', // Default enemy image
|
| 459 |
+
playerAutoFire: true,
|
| 460 |
+
enemiesAutoTarget: true,
|
| 461 |
+
playerAutoTargets: true,
|
| 462 |
+
enemyFireCooldownMs: 2200,
|
| 463 |
+
decorativeFishGoldenImage: 'https://via.placeholder.com/50/FFD700/000000?text=GoldFish',
|
| 464 |
+
decorativeFishStarImage: 'https://via.placeholder.com/50/FF4500/FFFFFF?text=StarFish',
|
| 465 |
+
decorativeFishBluefinImage: 'https://via.placeholder.com/50/1E90FF/FFFFFF?text=Bluefin',
|
| 466 |
+
bubbleSmallImage: 'https://via.placeholder.com/20/ADD8E6/000000?text=o',
|
| 467 |
+
bubbleMediumImage: 'https://via.placeholder.com/30/ADD8E6/000000?text=O',
|
| 468 |
+
bubbleLargeImage: 'https://via.placeholder.com/40/ADD8E6/000000?text=OO',
|
| 469 |
+
seaTokenImageUrl: 'https://via.placeholder.com/24/FFD700/000000?text=ST', // Default Sea Token image
|
| 470 |
+
savedReplies: [],
|
| 471 |
+
|
| 472 |
+
soundFireUrl: '',
|
| 473 |
+
soundEnemyKillUrl: '',
|
| 474 |
+
soundTokenCollectUrl: '',
|
| 475 |
+
soundNftBubbleCollectUrl: '',
|
| 476 |
+
soundLevelUpUrl: '',
|
| 477 |
+
soundGameOverUrl: '',
|
| 478 |
+
soundGameWinUrl: '',
|
| 479 |
+
soundSpinWinUrl: '',
|
| 480 |
+
soundSpinLoseUrl: '',
|
| 481 |
+
soundLotteryWinUrl: '',
|
| 482 |
+
soundLotteryLoseUrl: '',
|
| 483 |
+
soundDailyRewardClaimUrl: '',
|
| 484 |
+
|
| 485 |
+
// Visual Effect Defaults
|
| 486 |
+
bloodParticleLifespanSeconds: 4,
|
| 487 |
+
rainEffectEnabled: true,
|
| 488 |
+
rainDropSpeedMin: 1,
|
| 489 |
+
rainDropSpeedMax: 2.5,
|
| 490 |
+
rainDurationSeconds: 5,
|
| 491 |
+
rainIntervalSeconds: 7,
|
| 492 |
+
rainIntensity: 100,
|
| 493 |
+
|
| 494 |
+
// Red Arrow Defense System Config Defaults
|
| 495 |
+
redArrowActive: true,
|
| 496 |
+
redArrowCooldownMs: COOLDOWN_RED_ARROW_DEFENSE,
|
| 497 |
+
redArrowProjectileSpeed: RED_ARROW_PROJECTILE_SPEED,
|
| 498 |
+
redArrowDamage: RED_ARROW_PROJECTILE_DAMAGE,
|
| 499 |
+
redArrowImageUrl: RED_ARROW_PROJECTILE_IMAGE_URL,
|
| 500 |
+
redArrowShotCount: RED_ARROW_DEFAULT_SHOT_COUNT,
|
| 501 |
+
|
| 502 |
+
// Fire image assets
|
| 503 |
+
flameParticleImageUrl: 'https://via.placeholder.com/20/FFA500/000000?text=F', // Example
|
| 504 |
+
enemyBurningEffectImageUrl: 'https://via.placeholder.com/80/FF4500/FFFFFF?text=Burning', // Example
|
| 505 |
+
fireballProjectileImageUrl: 'https://via.placeholder.com/40/FF6347/FFFFFF?text=Ball', // Example
|
| 506 |
+
|
| 507 |
+
// Doge Whale Escape Game Asset URLs
|
| 508 |
+
dogeEscapePlayerImageUrl: DOGE_WHALE_ESCAPE_PLAYER_IMAGE_URL,
|
| 509 |
+
dogeEscapeWallImageUrl: DOGE_WHALE_ESCAPE_WALL_IMAGE_URL,
|
| 510 |
+
dogeEscapeGoalImageUrl: DOGE_WHALE_ESCAPE_GOAL_IMAGE_URL,
|
| 511 |
+
dogeEscapeMineImageUrl: DOGE_WHALE_ESCAPE_MINE_IMAGE_URL,
|
| 512 |
+
dogeEscapeBombImageUrl: DOGE_WHALE_ESCAPE_BOMB_IMAGE_URL,
|
| 513 |
+
dogeEscapeJellyfishImageUrl: DOGE_WHALE_ESCAPE_JELLYFISH_IMAGE_URL,
|
| 514 |
+
dogeEscapeCrocodileImageUrl: DOGE_WHALE_ESCAPE_CROCODILE_IMAGE_URL,
|
| 515 |
+
dogeEscapeAlgaeImageUrl: DOGE_WHALE_ESCAPE_ALGAE_IMAGE_URL,
|
| 516 |
+
dogeEscapeCurrentNImageUrl: DOGE_WHALE_ESCAPE_CURRENT_N_IMAGE_URL,
|
| 517 |
+
dogeEscapeCurrentSImageUrl: DOGE_WHALE_ESCAPE_CURRENT_S_IMAGE_URL,
|
| 518 |
+
dogeEscapeCurrentEImageUrl: DOGE_WHALE_ESCAPE_CURRENT_E_IMAGE_URL,
|
| 519 |
+
dogeEscapeCurrentWImageUrl: DOGE_WHALE_ESCAPE_CURRENT_W_IMAGE_URL,
|
| 520 |
+
dogeEscapeSnakeImageUrl: DOGE_WHALE_ESCAPE_SNAKE_IMAGE_URL,
|
| 521 |
+
dogeEscapeLifeUpImageUrl: DOGE_WHALE_ESCAPE_LIFEUP_IMAGE_URL,
|
| 522 |
+
};
|
| 523 |
+
|
| 524 |
+
// --- Doge Whale Escape Game Constants ---
|
| 525 |
+
export const DOGE_ESCAPE_GRID_CELL_SIZE = 32;
|
| 526 |
+
export const INITIAL_DOGE_ESCAPE_LIVES = 3;
|
| 527 |
+
export const MAX_DOGE_ESCAPE_LIVES = 5;
|
| 528 |
+
|
| 529 |
+
// Probabilities for revealing items from destroyed walls
|
| 530 |
+
export const REVEAL_SNAKE_CHANCE = 0.15; // 15%
|
| 531 |
+
export const REVEAL_LIFEUP_CHANCE = 0.20; // 20%
|
| 532 |
+
// Nothing revealed chance is 1 - (REVEAL_SNAKE_CHANCE + REVEAL_LIFEUP_CHANCE) = 0.65 or 65%
|
| 533 |
+
|
| 534 |
+
|
| 535 |
+
const P = MazeCellType.Path;
|
| 536 |
+
const W = MazeCellType.Wall;
|
| 537 |
+
const S = MazeCellType.Start;
|
| 538 |
+
const G = MazeCellType.Goal;
|
| 539 |
+
const M = MazeCellType.Mine;
|
| 540 |
+
const B = MazeCellType.Bomb;
|
| 541 |
+
const J = MazeCellType.Jellyfish;
|
| 542 |
+
const C = MazeCellType.Crocodile;
|
| 543 |
+
const A = MazeCellType.ToxicAlgae;
|
| 544 |
+
const CN = MazeCellType.CurrentN;
|
| 545 |
+
const CS = MazeCellType.CurrentS;
|
| 546 |
+
const CE = MazeCellType.CurrentE;
|
| 547 |
+
const CW = MazeCellType.CurrentW;
|
| 548 |
+
const K = MazeCellType.Snake;
|
| 549 |
+
const L = MazeCellType.LifeUp;
|
| 550 |
+
|
| 551 |
+
export const DOGE_WHALE_ESCAPE_LEVELS: DogeWhaleEscapeLevel[] = [
|
| 552 |
+
// Level 1: Modified for Fire Power & Snake Neutralization
|
| 553 |
+
{
|
| 554 |
+
id: 1, name: "The Shallows", startPos: { row: 0, col: 0 }, layout: [
|
| 555 |
+
[S,P,P,W,P,P,P,W,P,K,P,P], // Added a Snake (K)
|
| 556 |
+
[W,W,P,W,P,W,P,W,P,W,W,W],
|
| 557 |
+
[P,P,P,M,P,W,P,M,P,P,P,G],
|
| 558 |
+
[P,W,W,W,P,W,W,W,W,W,P,W],
|
| 559 |
+
[P,P,M,P,P,P,P,P,P,P,P,P],
|
| 560 |
+
[W,W,P,W,W,W,W,P,W,W,W,W],
|
| 561 |
+
[P,M,W,P,P,M,P,P,P,P,M,P],
|
| 562 |
+
[P,W,W,W,W,W,W,W,W,W,W,P],
|
| 563 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 564 |
+
[W,W,W,W,W,W,P,W,W,W,W,W],
|
| 565 |
+
], message: "Use Arrow Keys to move, Spacebar to fire! Neutralize Snakes (K) to unlock the Goal (G).",
|
| 566 |
+
requireAllSnakesNeutralized: true, // New win condition
|
| 567 |
+
},
|
| 568 |
+
// Levels 2-5: Basic Mines
|
| 569 |
+
{
|
| 570 |
+
id: 2, name: "Coral Corridors", startPos: { row: 9, col: 0 }, layout: [
|
| 571 |
+
[P,W,P,P,P,W,P,P,P,P,W,G],
|
| 572 |
+
[P,W,P,W,P,W,P,W,W,W,W,P],
|
| 573 |
+
[P,M,P,W,P,P,P,P,M,P,P,P],
|
| 574 |
+
[W,W,W,W,M,W,W,W,W,W,P,W],
|
| 575 |
+
[P,P,P,P,P,P,P,W,P,P,P,P],
|
| 576 |
+
[P,W,W,W,W,W,M,W,P,W,W,W],
|
| 577 |
+
[P,M,P,P,P,W,P,P,P,P,P,P],
|
| 578 |
+
[S,W,P,W,P,P,P,W,W,W,W,W],
|
| 579 |
+
[P,W,P,M,W,P,M,W,P,P,P,P],
|
| 580 |
+
[P,P,P,P,W,P,P,P,P,W,W,W],
|
| 581 |
+
]
|
| 582 |
+
},
|
| 583 |
+
{
|
| 584 |
+
id: 3, name: "Minefield Maze", startPos: {row: 0, col: 5}, layout: [
|
| 585 |
+
[W,W,W,W,W,S,W,W,W,W,W,W],
|
| 586 |
+
[P,M,P,M,P,P,P,M,P,M,P,P],
|
| 587 |
+
[P,W,P,W,W,W,W,W,P,W,W,P],
|
| 588 |
+
[P,M,P,P,P,M,P,P,P,M,P,P],
|
| 589 |
+
[W,W,W,P,W,W,W,P,W,W,W,P],
|
| 590 |
+
[P,P,P,P,M,P,M,P,P,P,P,W],
|
| 591 |
+
[P,W,M,W,W,P,W,W,M,W,P,P],
|
| 592 |
+
[P,P,M,P,P,P,M,P,P,M,P,G],
|
| 593 |
+
[W,W,W,W,W,W,W,W,W,W,W,W],
|
| 594 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 595 |
+
]
|
| 596 |
+
},
|
| 597 |
+
{
|
| 598 |
+
id: 4, name: "Twisty Passages", startPos: {row: 4, col: 0}, layout: [
|
| 599 |
+
[W,W,W,P,P,P,P,P,P,W,W,W],
|
| 600 |
+
[W,M,P,P,W,W,W,W,P,M,W,W],
|
| 601 |
+
[G,P,W,W,W,P,P,W,W,P,P,W],
|
| 602 |
+
[P,P,P,W,P,P,M,P,W,M,P,W],
|
| 603 |
+
[S,W,P,W,P,W,W,P,W,P,P,W],
|
| 604 |
+
[P,W,P,P,P,W,P,P,P,P,W,W],
|
| 605 |
+
[P,W,M,W,W,W,P,W,W,P,P,P],
|
| 606 |
+
[P,P,P,P,M,P,P,P,W,W,W,P],
|
| 607 |
+
[W,M,W,W,W,W,W,P,M,P,P,P],
|
| 608 |
+
[W,W,W,W,W,W,W,W,W,W,W,W],
|
| 609 |
+
]
|
| 610 |
+
},
|
| 611 |
+
{
|
| 612 |
+
id: 5, name: "Central Chamber", startPos: {row: 0, col: 0}, layout: [
|
| 613 |
+
[S,P,P,P,W,W,W,P,P,P,P,G],
|
| 614 |
+
[W,W,W,P,P,M,P,P,W,W,W,P],
|
| 615 |
+
[P,P,P,P,W,M,W,P,P,P,P,P],
|
| 616 |
+
[P,W,W,W,W,M,W,W,W,W,W,P],
|
| 617 |
+
[P,P,P,W,M,M,M,W,P,P,P,P],
|
| 618 |
+
[W,W,P,W,M,M,M,W,P,W,W,W],
|
| 619 |
+
[P,P,P,W,W,M,W,W,P,P,P,P],
|
| 620 |
+
[P,W,W,W,P,M,P,W,W,W,W,W],
|
| 621 |
+
[P,M,P,P,P,P,P,P,M,P,P,P],
|
| 622 |
+
[W,W,W,W,W,W,W,W,W,W,W,W],
|
| 623 |
+
]
|
| 624 |
+
},
|
| 625 |
+
// Levels 6-10: Introduce Bombs (B)
|
| 626 |
+
{
|
| 627 |
+
id: 6, name: "Bombs Away!", startPos: {row: 0, col: 0}, layout: [
|
| 628 |
+
[S,B,P,W,P,P,P,W,P,P,P,G],
|
| 629 |
+
[W,W,P,W,P,W,P,W,P,W,W,P],
|
| 630 |
+
[P,P,P,B,P,W,P,B,P,P,P,P],
|
| 631 |
+
[P,W,W,W,P,W,W,W,W,W,P,W],
|
| 632 |
+
[P,P,M,P,B,P,P,P,P,M,P,P],
|
| 633 |
+
[W,W,P,W,W,W,W,B,W,W,W,W],
|
| 634 |
+
[P,B,P,P,P,B,P,P,P,P,B,P],
|
| 635 |
+
[W,W,W,W,W,W,W,W,W,W,W,W],
|
| 636 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 637 |
+
[P,B,P,B,P,B,P,B,P,B,P,P],
|
| 638 |
+
], message: "Watch out for Bombs (B) and Mines (M)!"
|
| 639 |
+
},
|
| 640 |
+
{
|
| 641 |
+
id: 7, name: "Explosive Escape", startPos: {row: 9, col: 11}, layout: [
|
| 642 |
+
[P,P,P,P,W,W,P,P,P,P,P,P],
|
| 643 |
+
[P,W,B,W,P,M,P,W,B,W,W,P],
|
| 644 |
+
[P,W,P,W,P,W,P,W,P,P,P,P],
|
| 645 |
+
[P,B,P,P,B,P,B,P,P,B,P,W],
|
| 646 |
+
[W,W,W,P,W,W,W,P,W,W,W,P],
|
| 647 |
+
[P,P,P,P,M,P,M,B,P,P,P,S],
|
| 648 |
+
[P,W,M,W,W,B,W,W,M,W,P,W],
|
| 649 |
+
[G,P,M,P,P,P,M,P,P,M,P,P],
|
| 650 |
+
[W,B,W,W,B,W,W,B,W,W,B,W],
|
| 651 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 652 |
+
]
|
| 653 |
+
},
|
| 654 |
+
{
|
| 655 |
+
id: 8, name: "Blast Zone", startPos: {row: 4, col: 5}, layout: [
|
| 656 |
+
[W,W,W,W,P,P,P,W,W,W,W,W],
|
| 657 |
+
[P,B,P,B,P,S,P,B,P,B,P,P],
|
| 658 |
+
[P,W,P,W,W,W,W,W,P,W,W,P],
|
| 659 |
+
[P,M,B,P,P,B,P,P,B,M,P,P],
|
| 660 |
+
[W,W,W,B,W,B,W,B,W,W,W,P],
|
| 661 |
+
[P,P,P,P,M,B,M,P,P,P,P,W],
|
| 662 |
+
[P,W,M,W,W,P,W,W,M,W,P,P],
|
| 663 |
+
[G,P,M,B,P,B,M,B,P,M,P,P],
|
| 664 |
+
[W,B,W,W,B,W,W,B,W,W,B,W],
|
| 665 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 666 |
+
]
|
| 667 |
+
},
|
| 668 |
+
{
|
| 669 |
+
id: 9, name: "Chain Reaction", startPos: {row: 0, col: 11}, layout: [
|
| 670 |
+
[P,P,B,M,P,W,P,M,B,P,P,S],
|
| 671 |
+
[P,W,W,W,P,W,P,W,W,W,P,W],
|
| 672 |
+
[P,B,P,P,B,P,P,P,B,P,P,P],
|
| 673 |
+
[W,M,W,P,W,B,W,P,W,M,W,P],
|
| 674 |
+
[P,P,P,B,P,P,B,P,P,P,B,P],
|
| 675 |
+
[P,W,W,W,W,B,W,W,W,W,W,W],
|
| 676 |
+
[B,M,P,P,P,M,P,P,P,M,B,P],
|
| 677 |
+
[P,W,W,B,W,W,W,B,W,W,P,G],
|
| 678 |
+
[P,P,M,P,P,P,P,P,M,P,P,P],
|
| 679 |
+
[W,W,W,W,W,W,W,W,W,W,W,W],
|
| 680 |
+
]
|
| 681 |
+
},
|
| 682 |
+
{
|
| 683 |
+
id: 10, name: "Detonation Alley", startPos: {row: 5, col: 0}, layout: [
|
| 684 |
+
[G,P,W,B,W,B,W,B,W,B,W,P],
|
| 685 |
+
[P,P,B,P,B,P,B,P,B,P,B,P],
|
| 686 |
+
[W,P,W,M,W,M,W,M,W,M,W,P],
|
| 687 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 688 |
+
[W,B,W,B,W,B,W,B,W,B,W,B],
|
| 689 |
+
[S,P,P,P,P,P,P,P,P,P,P,P],
|
| 690 |
+
[W,M,W,M,W,M,W,M,W,M,W,M],
|
| 691 |
+
[P,P,B,P,B,P,B,P,B,P,B,P],
|
| 692 |
+
[W,P,W,B,W,B,W,B,W,B,W,P],
|
| 693 |
+
[P,P,P,P,P,P,P,P,P,P,P,G], // Second Goal for a twist? Or make one a W.
|
| 694 |
+
]
|
| 695 |
+
},
|
| 696 |
+
// Levels 11-15: Introduce Jellyfish (J)
|
| 697 |
+
{
|
| 698 |
+
id: 11, name: "Jellyfish Jive", startPos: { row: 0, col: 0 }, layout: [
|
| 699 |
+
[S,J,P,W,P,P,P,W,J,P,P,G],
|
| 700 |
+
[W,W,P,W,P,W,P,W,P,W,W,P],
|
| 701 |
+
[P,P,P,J,P,W,P,J,P,P,P,P],
|
| 702 |
+
[P,W,W,W,P,W,W,W,W,W,P,W],
|
| 703 |
+
[P,P,M,P,J,P,P,P,P,M,P,P],
|
| 704 |
+
[W,W,J,W,W,W,W,J,W,W,W,W],
|
| 705 |
+
[P,B,P,J,P,B,P,J,P,P,B,P],
|
| 706 |
+
[W,W,W,W,W,W,W,W,W,W,W,P],
|
| 707 |
+
[P,J,P,J,P,J,P,J,P,J,P,P],
|
| 708 |
+
[P,P,P,P,P,P,P,P,P,P,P,W],
|
| 709 |
+
], message: "Steer clear of the stinging Jellyfish (J)!"
|
| 710 |
+
},
|
| 711 |
+
{
|
| 712 |
+
id: 12, name: "Shocking Straits", startPos: {row: 9, col: 5}, layout: [
|
| 713 |
+
[P,J,P,J,P,W,P,J,P,J,P,G],
|
| 714 |
+
[W,P,W,P,W,P,W,P,W,P,W,P],
|
| 715 |
+
[P,J,P,J,P,P,P,J,P,J,P,P],
|
| 716 |
+
[P,W,M,W,B,W,J,W,M,W,B,P],
|
| 717 |
+
[P,J,P,J,W,S,W,J,P,J,P,P],
|
| 718 |
+
[P,B,W,M,W,J,W,M,W,B,W,P],
|
| 719 |
+
[P,J,P,J,P,P,P,J,P,J,P,P],
|
| 720 |
+
[W,P,W,P,W,P,W,P,W,P,W,P],
|
| 721 |
+
[P,J,P,J,P,W,P,J,P,J,P,P],
|
| 722 |
+
[W,W,W,W,W,W,W,W,W,W,W,W],
|
| 723 |
+
]
|
| 724 |
+
},
|
| 725 |
+
{
|
| 726 |
+
id: 13, name: "Gelatinous Gauntlet", startPos: {row: 0, col: 0}, layout: [
|
| 727 |
+
[S,P,J,P,J,P,J,P,J,P,J,G],
|
| 728 |
+
[W,P,W,P,W,P,W,P,W,P,W,P],
|
| 729 |
+
[J,P,J,P,J,P,J,P,J,P,J,P],
|
| 730 |
+
[P,W,P,W,P,W,P,W,P,W,P,W],
|
| 731 |
+
[J,M,J,B,J,M,J,B,J,M,J,B],
|
| 732 |
+
[W,P,W,P,W,P,W,P,W,P,W,P],
|
| 733 |
+
[P,J,P,J,P,J,P,J,P,J,P,J],
|
| 734 |
+
[P,W,P,W,P,W,P,W,P,W,P,W],
|
| 735 |
+
[J,B,J,M,J,B,J,M,J,B,J,M],
|
| 736 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 737 |
+
]
|
| 738 |
+
},
|
| 739 |
+
{
|
| 740 |
+
id: 14, name: "Zapper Zone", startPos: {row: 4, col: 0}, layout: [
|
| 741 |
+
[W,J,W,J,W,J,W,J,W,J,W,G],
|
| 742 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 743 |
+
[W,J,W,J,M,B,M,J,W,J,W,W],
|
| 744 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 745 |
+
[S,J,W,J,W,J,W,J,W,J,W,W],
|
| 746 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 747 |
+
[W,J,W,J,B,M,B,J,W,J,W,W],
|
| 748 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 749 |
+
[W,J,W,J,W,J,W,J,W,J,W,W],
|
| 750 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 751 |
+
]
|
| 752 |
+
},
|
| 753 |
+
{
|
| 754 |
+
id: 15, name: "Tentacle Terror", startPos: {row: 8, col: 11}, layout: [
|
| 755 |
+
[P,J,P,W,P,P,J,P,W,P,J,P],
|
| 756 |
+
[P,W,P,W,P,W,P,W,P,W,P,W],
|
| 757 |
+
[J,P,J,P,J,P,J,P,J,P,J,P],
|
| 758 |
+
[W,P,W,M,W,B,W,J,W,M,W,G],
|
| 759 |
+
[P,J,P,W,P,P,J,P,W,P,J,P],
|
| 760 |
+
[B,W,B,W,B,W,B,W,B,W,B,W],
|
| 761 |
+
[P,J,P,W,P,P,J,P,W,P,J,P],
|
| 762 |
+
[M,P,M,P,M,P,M,P,M,P,M,S],
|
| 763 |
+
[P,J,P,W,J,P,J,P,W,J,P,P],
|
| 764 |
+
[W,W,W,W,W,W,W,W,W,W,W,W],
|
| 765 |
+
]
|
| 766 |
+
},
|
| 767 |
+
// Levels 16-20: Introduce Crocodiles (C)
|
| 768 |
+
{
|
| 769 |
+
id: 16, name: "Croc Creek", startPos: { row: 0, col: 0 }, layout: [
|
| 770 |
+
[S,C,P,W,P,P,P,W,C,P,P,G],
|
| 771 |
+
[W,W,P,W,P,W,P,W,P,W,W,P],
|
| 772 |
+
[P,P,P,C,P,W,P,C,P,P,P,P],
|
| 773 |
+
[P,W,W,W,P,W,W,W,W,W,P,W],
|
| 774 |
+
[P,P,M,P,C,P,J,P,P,M,P,P],
|
| 775 |
+
[W,W,C,W,W,W,W,C,W,W,W,W],
|
| 776 |
+
[P,B,P,C,P,B,P,C,P,P,B,P],
|
| 777 |
+
[P,C,P,C,P,C,P,C,P,C,P,C],
|
| 778 |
+
[W,W,W,W,W,W,W,W,W,W,W,P],
|
| 779 |
+
[P,P,P,P,P,P,P,P,P,P,P,W],
|
| 780 |
+
], message: "Mind the Crocodiles (C)! They bite!"
|
| 781 |
+
},
|
| 782 |
+
{
|
| 783 |
+
id: 17, name: "Gator Alley", startPos: {row: 5, col: 5}, layout: [
|
| 784 |
+
[C,C,C,C,C,C,C,C,C,C,C,G],
|
| 785 |
+
[C,P,P,P,P,P,P,P,P,P,P,C],
|
| 786 |
+
[C,P,W,W,W,P,W,W,W,P,W,C],
|
| 787 |
+
[C,P,W,M,B,P,B,M,W,P,W,C],
|
| 788 |
+
[C,P,W,M,P,S,P,M,W,P,W,C],
|
| 789 |
+
[C,P,W,B,P,P,P,B,W,P,W,C],
|
| 790 |
+
[C,P,W,W,W,J,W,W,W,P,W,C],
|
| 791 |
+
[C,P,P,P,P,P,P,P,P,P,P,C],
|
| 792 |
+
[C,C,C,C,C,C,C,C,C,C,C,C],
|
| 793 |
+
[P,P,P,P,P,P,P,P,P,P,P,P], // Removed second start from original error
|
| 794 |
+
]
|
| 795 |
+
},
|
| 796 |
+
{
|
| 797 |
+
id: 18, name: "Reptilian River", startPos: {row: 9, col: 0}, layout: [
|
| 798 |
+
[P,C,P,C,P,C,P,C,P,C,P,G],
|
| 799 |
+
[P,W,P,W,P,W,P,W,P,W,P,W],
|
| 800 |
+
[C,P,C,P,C,P,C,P,C,P,C,P],
|
| 801 |
+
[W,P,W,M,W,B,W,J,W,M,W,P],
|
| 802 |
+
[P,C,P,C,P,C,P,C,P,C,P,C],
|
| 803 |
+
[B,W,B,W,B,W,B,W,B,W,B,W],
|
| 804 |
+
[C,P,C,P,C,P,C,P,C,P,C,P],
|
| 805 |
+
[M,P,M,P,M,P,M,P,M,P,M,P],
|
| 806 |
+
[P,C,P,C,P,C,P,C,P,C,P,C],
|
| 807 |
+
[S,P,P,P,P,P,P,P,P,P,P,P],
|
| 808 |
+
]
|
| 809 |
+
},
|
| 810 |
+
{
|
| 811 |
+
id: 19, name: "Snapping Swamp", startPos: {row: 0, col: 11}, layout: [
|
| 812 |
+
[W,C,W,C,W,C,W,C,W,C,W,S],
|
| 813 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 814 |
+
[W,C,W,M,B,J,B,M,W,C,W,W],
|
| 815 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 816 |
+
[W,C,W,C,W,C,W,C,W,C,W,G],
|
| 817 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 818 |
+
[W,C,W,J,B,M,B,J,W,C,W,W],
|
| 819 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 820 |
+
[W,C,W,C,W,C,W,C,W,C,W,W],
|
| 821 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 822 |
+
]
|
| 823 |
+
},
|
| 824 |
+
{
|
| 825 |
+
id: 20, name: "Predator's Path", startPos: {row: 4, col: 11}, layout: [
|
| 826 |
+
[P,C,P,J,P,B,P,M,P,W,P,G],
|
| 827 |
+
[P,W,P,W,P,W,P,W,P,W,P,W],
|
| 828 |
+
[C,P,J,P,B,P,M,P,W,P,C,P],
|
| 829 |
+
[W,P,W,P,W,P,W,P,W,P,W,P],
|
| 830 |
+
[P,J,P,B,P,M,P,W,P,C,P,S],
|
| 831 |
+
[B,W,B,W,B,W,B,W,B,W,B,W],
|
| 832 |
+
[P,M,P,W,P,C,P,J,P,B,P,M],
|
| 833 |
+
[M,P,M,P,M,P,M,P,M,P,M,P],
|
| 834 |
+
[P,W,P,C,P,J,P,B,P,M,P,W],
|
| 835 |
+
[W,W,W,W,W,W,W,W,W,W,W,W],
|
| 836 |
+
]
|
| 837 |
+
},
|
| 838 |
+
// Levels 21-25: Introduce Currents (CN, CS, CE, CW)
|
| 839 |
+
{
|
| 840 |
+
id: 21, name: "Rapid Ride", startPos: { row: 0, col: 0 }, layout: [
|
| 841 |
+
[S,CE,CE,P,W,P,CN,P,W,P,P,G],
|
| 842 |
+
[W,W,W,P,W,P,W,P,W,P,W,P],
|
| 843 |
+
[P,P,CS,P,W,P,CE,P,J,P,P,P],
|
| 844 |
+
[P,W,W,P,W,P,W,W,W,M,W,P],
|
| 845 |
+
[P,CN,P,CE,CE,CE,CW,CW,P,C,P,P],
|
| 846 |
+
[W,P,W,W,W,W,W,P,W,W,W,W],
|
| 847 |
+
[P,P,P,M,B,P,CS,P,P,P,B,P],
|
| 848 |
+
[W,W,CS,W,W,P,W,P,W,W,W,P],
|
| 849 |
+
[P,P,P,P,P,P,CN,P,P,P,P,P],
|
| 850 |
+
[P,CE,CE,CE,CE,CW,CW,P,W,W,W,W],
|
| 851 |
+
], message: "Currents (Arrows) will push you! Plan your moves."
|
| 852 |
+
},
|
| 853 |
+
{
|
| 854 |
+
id: 22, name: "Whirlpool Way", startPos: {row: 4, col: 5}, layout: [
|
| 855 |
+
[P,W,CN,P,W,CE,W,CS,P,W,P,G],
|
| 856 |
+
[P,CE,S,CW,P,P,P,P,CS,P,P,P],
|
| 857 |
+
[P,W,CS,P,W,CW,W,CN,P,W,P,P],
|
| 858 |
+
[M,P,P,P,P,P,P,P,P,P,B,P],
|
| 859 |
+
[W,W,W,W,W,P,W,W,W,W,W,W],
|
| 860 |
+
[P,CN,CE,CW,CS,P,CN,CE,CW,CS,P,P],
|
| 861 |
+
[P,P,W,J,P,P,P,W,M,P,P,W],
|
| 862 |
+
[P,CS,P,P,P,P,P,P,P,CN,C,P],
|
| 863 |
+
[W,W,W,W,W,W,W,W,W,W,W,P],
|
| 864 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 865 |
+
]
|
| 866 |
+
},
|
| 867 |
+
{
|
| 868 |
+
id: 23, name: "Crosscurrent Chaos", startPos: {row: 0, col: 0}, layout: [
|
| 869 |
+
[S,CE,P,W,CS,P,W,CN,P,CE,P,G],
|
| 870 |
+
[CS,P,W,P,P,CE,P,W,P,P,CW,P],
|
| 871 |
+
[P,W,CN,P,W,P,CW,CN,W,P,P,P],
|
| 872 |
+
[CE,P,P,CW,P,W,P,P,CE,P,W,CS],
|
| 873 |
+
[P,CN,W,P,CS,P,M,W,P,CW,P,P],
|
| 874 |
+
[CW,P,CE,P,W,B,P,CS,P,P,CN,P],
|
| 875 |
+
[P,W,P,CN,P,P,CW,P,W,J,P,CS],
|
| 876 |
+
[CS,CE,CW,P,W,P,P,CN,P,P,CE,P],
|
| 877 |
+
[P,P,P,W,CN,P,CS,P,W,C,P,CW],
|
| 878 |
+
[W,W,W,W,W,W,W,W,W,W,W,P],
|
| 879 |
+
]
|
| 880 |
+
},
|
| 881 |
+
{
|
| 882 |
+
id: 24, name: "Streamline Speedway", startPos: {row: 9, col: 0}, layout: [
|
| 883 |
+
[P,P,W,CN,P,P,W,CS,P,P,W,G],
|
| 884 |
+
[CE,CE,CE,P,CW,CW,CW,P,CE,CE,CE,P],
|
| 885 |
+
[P,P,W,CS,P,P,W,CN,P,P,W,P],
|
| 886 |
+
[CS,CS,CS,P,CN,CN,CN,P,CS,CS,CS,P],
|
| 887 |
+
[P,P,W,M,P,B,W,J,P,C,W,P],
|
| 888 |
+
[CE,CW,CE,P,CS,CN,CS,P,CE,CW,CE,P],
|
| 889 |
+
[P,P,W,P,P,P,W,P,P,P,W,P],
|
| 890 |
+
[CW,CE,CW,P,CN,CS,CN,P,CW,CE,CW,P],
|
| 891 |
+
[P,P,W,P,P,P,W,P,P,P,W,P],
|
| 892 |
+
[S,CE,P,W,CS,P,W,CN,P,CE,P,P],
|
| 893 |
+
]
|
| 894 |
+
},
|
| 895 |
+
{
|
| 896 |
+
id: 25, name: "Current Conundrum", startPos: {row: 0, col: 11}, layout: [
|
| 897 |
+
[W,W,W,W,W,W,W,P,CN,P,P,S],
|
| 898 |
+
[P,CE,P,M,B,J,P,CS,P,W,P,W],
|
| 899 |
+
[P,W,CN,P,W,P,CW,P,P,CS,P,P],
|
| 900 |
+
[P,CS,P,W,CE,P,W,P,W,P,W,P],
|
| 901 |
+
[P,P,CW,P,P,CN,P,M,P,CW,P,P],
|
| 902 |
+
[W,P,W,CS,P,W,CE,P,B,P,CS,P],
|
| 903 |
+
[P,CN,P,W,CW,P,W,J,P,W,P,P],
|
| 904 |
+
[P,P,CE,P,P,CS,P,C,P,CE,P,G],
|
| 905 |
+
[W,W,W,W,W,W,W,W,W,W,W,P],
|
| 906 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 907 |
+
]
|
| 908 |
+
},
|
| 909 |
+
// Levels 26-30: Introduce ToxicAlgae (A) and Mix all
|
| 910 |
+
{
|
| 911 |
+
id: 26, name: "Algae Bloom", startPos: { row: 0, col: 0 }, layout: [
|
| 912 |
+
[S,A,P,W,P,P,A,W,P,P,P,G],
|
| 913 |
+
[W,W,A,W,P,A,P,W,A,W,W,P],
|
| 914 |
+
[P,P,P,A,P,W,P,A,P,P,P,P],
|
| 915 |
+
[P,W,W,W,A,W,A,W,W,W,P,W],
|
| 916 |
+
[P,P,M,A,C,P,J,A,P,M,P,P],
|
| 917 |
+
[W,W,A,W,W,A,W,A,W,W,W,W],
|
| 918 |
+
[P,B,P,A,P,B,A,P,A,P,B,P],
|
| 919 |
+
[A,C,A,C,A,C,A,C,A,C,A,A],
|
| 920 |
+
[W,W,W,W,W,W,W,W,W,W,W,P],
|
| 921 |
+
[P,A,P,A,P,A,P,A,P,A,P,W],
|
| 922 |
+
], message: "Toxic Algae (A) is dangerous! Currents might help or hinder."
|
| 923 |
+
},
|
| 924 |
+
{
|
| 925 |
+
id: 27, name: "Hazardous Waters", startPos: {row: 5, col: 5}, layout: [
|
| 926 |
+
[A,C,J,B,M,A,M,B,J,C,A,G],
|
| 927 |
+
[A,P,P,P,P,P,P,P,P,P,P,A],
|
| 928 |
+
[A,P,W,W,W,A,W,W,W,P,W,A],
|
| 929 |
+
[A,P,W,M,B,P,B,M,W,P,W,A],
|
| 930 |
+
[A,P,W,A,P,S,P,A,W,P,W,A],
|
| 931 |
+
[A,P,W,B,P,P,P,B,W,P,W,A],
|
| 932 |
+
[A,P,W,A,W,J,W,A,W,P,W,A],
|
| 933 |
+
[A,P,A,P,A,P,A,P,A,P,A,A],
|
| 934 |
+
[A,A,A,A,A,A,A,A,A,A,A,A],
|
| 935 |
+
[P,P,P,P,P,P,P,P,P,P,P,P], // Removed second start
|
| 936 |
+
]
|
| 937 |
+
},
|
| 938 |
+
{
|
| 939 |
+
id: 28, name: "The Gauntlet", startPos: {row: 9, col: 0}, layout: [
|
| 940 |
+
[P,A,P,C,P,J,P,B,P,M,P,G],
|
| 941 |
+
[P,W,CE,W,CS,W,CN,W,CW,W,P,W],
|
| 942 |
+
[A,P,A,P,C,P,J,P,B,P,M,P],
|
| 943 |
+
[W,CN,W,M,W,A,W,J,W,CS,W,P],
|
| 944 |
+
[P,A,P,C,P,A,P,A,P,J,P,A],
|
| 945 |
+
[B,W,CS,W,B,A,B,W,CN,W,B,W],
|
| 946 |
+
[A,P,A,P,C,A,J,P,A,P,M,P],
|
| 947 |
+
[M,CE,M,P,M,A,M,CW,M,P,CN,P],
|
| 948 |
+
[P,A,P,C,P,J,P,B,P,A,P,A],
|
| 949 |
+
[S,P,P,P,P,P,P,P,P,P,P,P],
|
| 950 |
+
]
|
| 951 |
+
},
|
| 952 |
+
{
|
| 953 |
+
id: 29, name: "Final Challenge", startPos: {row: 0, col: 11}, layout: [
|
| 954 |
+
[W,A,W,C,W,J,W,B,W,M,W,S],
|
| 955 |
+
[P,P,P,P,CE,P,CW,P,P,P,P,P],
|
| 956 |
+
[W,C,W,A,B,J,B,A,W,C,W,W],
|
| 957 |
+
[P,P,CS,P,P,A,P,P,CN,P,P,P],
|
| 958 |
+
[W,J,W,M,W,A,W,M,W,J,W,G],
|
| 959 |
+
[P,P,CN,P,P,A,P,P,CS,P,P,P],
|
| 960 |
+
[W,B,W,J,A,M,A,J,W,B,W,W],
|
| 961 |
+
[P,P,CW,P,CE,A,CE,P,CW,P,P,P],
|
| 962 |
+
[W,M,W,C,W,A,W,C,W,M,W,W],
|
| 963 |
+
[P,P,P,P,P,A,P,P,P,P,P,A],
|
| 964 |
+
]
|
| 965 |
+
},
|
| 966 |
+
{
|
| 967 |
+
id: 30, name: "Doge's Triumph", startPos: {row: 4, col: 5}, layout: [
|
| 968 |
+
[G,CE,CE,CE,CE,P,CW,CW,CW,CW,CW,G],
|
| 969 |
+
[CS,A,M,B,J,S,C,A,M,B,J,CS],
|
| 970 |
+
[CS,P,W,W,W,P,W,W,W,W,P,CS],
|
| 971 |
+
[CS,P,W,A,C,P,J,B,M,W,P,CS],
|
| 972 |
+
[CS,P,W,M,J,P,C,A,W,P,W,CS],
|
| 973 |
+
[CS,P,W,B,C,P,A,J,M,W,P,CS],
|
| 974 |
+
[CS,P,W,W,W,P,W,W,W,W,P,CS],
|
| 975 |
+
[CS,A,M,B,J,P,C,A,M,B,J,CS],
|
| 976 |
+
[CN,CN,CN,CN,CN,P,CE,CE,CE,CE,CE,CN],
|
| 977 |
+
[P,P,P,P,P,P,P,P,P,P,P,P],
|
| 978 |
+
], message: "You've mastered the depths! Or have you? (Find both goals!)"
|
| 979 |
+
}
|
| 980 |
+
];
|
geminiApi.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { GoogleGenAI } from "@google/genai";
|
| 2 |
+
|
| 3 |
+
// API key must be set in the environment variables for this to work.
|
| 4 |
+
// const API_KEY = process.env.API_KEY;
|
| 5 |
+
// if (!API_KEY) {
|
| 6 |
+
// console.error("API_KEY environment variable not set.");
|
| 7 |
+
// }
|
| 8 |
+
|
| 9 |
+
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY! });
|
| 10 |
+
|
| 11 |
+
export interface GeminiImageResponse {
|
| 12 |
+
success: boolean;
|
| 13 |
+
imageUrl?: string;
|
| 14 |
+
errorType?: 'quota_exhausted' | 'empty_prompt' | 'api_error' | 'generic';
|
| 15 |
+
message?: string;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/**
|
| 19 |
+
* Generates an image using the Gemini API based on a text prompt.
|
| 20 |
+
* @param prompt The text prompt to generate the image from.
|
| 21 |
+
* @returns A Promise that resolves to an object indicating success or failure.
|
| 22 |
+
*/
|
| 23 |
+
export const generateImageWithGemini = async (prompt: string): Promise<GeminiImageResponse> => {
|
| 24 |
+
if (!prompt || prompt.trim() === "") {
|
| 25 |
+
console.error("Prompt cannot be empty for image generation.");
|
| 26 |
+
return {
|
| 27 |
+
success: false,
|
| 28 |
+
errorType: 'empty_prompt',
|
| 29 |
+
message: "Prompt cannot be empty for image generation."
|
| 30 |
+
};
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
try {
|
| 34 |
+
console.log(`Attempting to generate image with prompt: "${prompt}"`);
|
| 35 |
+
const response = await ai.models.generateImages({
|
| 36 |
+
model: 'imagen-3.0-generate-002',
|
| 37 |
+
prompt: prompt,
|
| 38 |
+
config: { numberOfImages: 1, outputMimeType: 'image/jpeg' },
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
if (response.generatedImages && response.generatedImages.length > 0 && response.generatedImages[0].image?.imageBytes) {
|
| 42 |
+
const base64ImageBytes = response.generatedImages[0].image.imageBytes;
|
| 43 |
+
const imageUrl = `data:image/jpeg;base64,${base64ImageBytes}`;
|
| 44 |
+
console.log("Image generated successfully.");
|
| 45 |
+
return { success: true, imageUrl: imageUrl };
|
| 46 |
+
} else {
|
| 47 |
+
console.error("Gemini API did not return a valid image.", response);
|
| 48 |
+
return {
|
| 49 |
+
success: false,
|
| 50 |
+
errorType: 'api_error',
|
| 51 |
+
message: "Gemini API did not return a valid image. See console for details."
|
| 52 |
+
};
|
| 53 |
+
}
|
| 54 |
+
} catch (error: any) {
|
| 55 |
+
console.error("Error generating image with Gemini API:", error);
|
| 56 |
+
|
| 57 |
+
const errorMessage = String(error?.message || error.toString()).toLowerCase();
|
| 58 |
+
if (errorMessage.includes('429') || errorMessage.includes('resource_exhausted') || errorMessage.includes('quota')) {
|
| 59 |
+
return {
|
| 60 |
+
success: false,
|
| 61 |
+
errorType: 'quota_exhausted',
|
| 62 |
+
message: 'API Quota Exceeded. You may need to check your Google AI plan or wait for your quota to reset. More info: https://ai.google.dev/gemini-api/docs/rate-limits'
|
| 63 |
+
};
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
return {
|
| 67 |
+
success: false,
|
| 68 |
+
errorType: 'generic',
|
| 69 |
+
message: `Failed to generate image: ${error.message || 'Unknown error. Check console.'}`
|
| 70 |
+
};
|
| 71 |
+
}
|
| 72 |
+
};
|
index.html
ADDED
|
@@ -0,0 +1,436 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>Doge Whale Wars</title>
|
| 7 |
+
<script src="https://cdn.tailwindcss.com"></script>
|
| 8 |
+
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
| 9 |
+
<link rel="stylesheet" href="app.css">
|
| 10 |
+
<script src="https://accounts.google.com/gsi/client" async></script>
|
| 11 |
+
<style>
|
| 12 |
+
body {
|
| 13 |
+
margin: 0;
|
| 14 |
+
padding: 0;
|
| 15 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
| 16 |
+
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
| 17 |
+
sans-serif;
|
| 18 |
+
-webkit-font-smoothing: antialiased;
|
| 19 |
+
-moz-osx-font-smoothing: grayscale;
|
| 20 |
+
background: linear-gradient(to bottom, #1e90ff, #000428);
|
| 21 |
+
color: var(--theme-text); /* Keep text color from theme */
|
| 22 |
+
overflow: hidden; /* Prevent scrollbars from base layout */
|
| 23 |
+
min-height: 100vh;
|
| 24 |
+
}
|
| 25 |
+
#root {
|
| 26 |
+
display: flex;
|
| 27 |
+
flex-direction: column;
|
| 28 |
+
min-height: 100vh;
|
| 29 |
+
position: relative; /* Ensure root is part of stacking context if needed */
|
| 30 |
+
z-index: 3; /* Above background fish, below modals */
|
| 31 |
+
}
|
| 32 |
+
/* Custom scrollbar for modal content if needed */
|
| 33 |
+
.overflow-y-auto::-webkit-scrollbar {
|
| 34 |
+
width: 8px;
|
| 35 |
+
}
|
| 36 |
+
.overflow-y-auto::-webkit-scrollbar-track {
|
| 37 |
+
background: #2d3748; /* theme-primary/50 or similar */
|
| 38 |
+
border-radius: 10px;
|
| 39 |
+
}
|
| 40 |
+
.overflow-y-auto::-webkit-scrollbar-thumb {
|
| 41 |
+
background: #4a5568; /* theme-border or similar */
|
| 42 |
+
border-radius: 10px;
|
| 43 |
+
}
|
| 44 |
+
.overflow-y-auto::-webkit-scrollbar-thumb:hover {
|
| 45 |
+
background: #718096; /* A lighter shade */
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.nav-button, .nav-button-admin {
|
| 49 |
+
/* Tailwind classes are now applied directly in App.tsx for these */
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.game-container-outer {
|
| 53 |
+
/* For the outer border/glow effect when playing */
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.game-container-outer.playing {
|
| 57 |
+
/* Styles when game is active, e.g., border */
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/* Tailwind custom animations if needed */
|
| 61 |
+
@keyframes fadeIn {
|
| 62 |
+
from { opacity: 0; }
|
| 63 |
+
to { opacity: 1; }
|
| 64 |
+
}
|
| 65 |
+
.animate-fadeIn {
|
| 66 |
+
animation: fadeIn 0.3s ease-out forwards;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
@keyframes slideDown {
|
| 70 |
+
from { transform: translateY(-20px); opacity: 0.8; }
|
| 71 |
+
to { transform: translateY(0); opacity: 1; }
|
| 72 |
+
}
|
| 73 |
+
.animate-slideDown {
|
| 74 |
+
animation: slideDown 0.3s ease-out forwards;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
@keyframes pulseCustom {
|
| 78 |
+
0%, 100% { opacity: 1; transform: scale(1); }
|
| 79 |
+
50% { opacity: 0.8; transform: scale(1.02); }
|
| 80 |
+
}
|
| 81 |
+
.animate-pulseCustom {
|
| 82 |
+
animation: pulseCustom 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
canvas {
|
| 86 |
+
display: block;
|
| 87 |
+
image-rendering: -moz-crisp-edges;
|
| 88 |
+
image-rendering: -webkit-crisp-edges;
|
| 89 |
+
image-rendering: pixelated;
|
| 90 |
+
image-rendering: crisp-edges;
|
| 91 |
+
max-width: 100%;
|
| 92 |
+
max-height: 100%;
|
| 93 |
+
object-fit: contain; /* Ensures canvas scales within bounds maintaining aspect ratio */
|
| 94 |
+
position: relative;
|
| 95 |
+
z-index: 2;
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
.text-xxs {
|
| 99 |
+
font-size: 0.65rem;
|
| 100 |
+
line-height: 0.85rem;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
:root {
|
| 104 |
+
/* --theme-background: #0D1B2A; /* Deep Dark Blue - Body background is now gradient */
|
| 105 |
+
--theme-dark: #1B263B; /* Dark Blue-Gray */
|
| 106 |
+
--theme-primary: #415A77; /* Muted Blue */
|
| 107 |
+
--theme-secondary: #778DA9; /* Light Steel Blue */
|
| 108 |
+
--theme-accent: #00A9E0; /* Bright Cyan/Sky Blue */
|
| 109 |
+
--theme-hover: #00CFFF; /* Lighter Cyan for hover */
|
| 110 |
+
--theme-border: #5A7D9A; /* Mid-tone blue for borders */
|
| 111 |
+
--theme-text: #E0E1DD; /* Off-white/Light Gray */
|
| 112 |
+
--theme-success: #3FB950; /* Green */
|
| 113 |
+
--theme-danger: #F85149; /* Red */
|
| 114 |
+
--theme-warning: #F3B50C; /* Yellow/Gold */
|
| 115 |
+
--theme-info: #58A6FF; /* Bright Blue */
|
| 116 |
+
--theme-white: #FFFFFF;
|
| 117 |
+
--font-sans: 'Inter', sans-serif;
|
| 118 |
+
|
| 119 |
+
/* Layout Variables */
|
| 120 |
+
--header-height: 3.5rem; /* Approx 56px for p-3 header with text */
|
| 121 |
+
--footer-height: 2rem; /* Approx 32px for p-2 footer with text */
|
| 122 |
+
--theme-accent-rgb: 0, 169, 224; /* For app.css focus rings */
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
/* Updated fish styles based on user's new HTML */
|
| 126 |
+
.fish { /* Follower fish */
|
| 127 |
+
position: absolute;
|
| 128 |
+
width: 60px;
|
| 129 |
+
height: auto;
|
| 130 |
+
pointer-events: none;
|
| 131 |
+
transition: transform 0.1s linear;
|
| 132 |
+
z-index: 45; /* Maintained z-index from app's original design for correct layering */
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.bg-fish { /* Random background fish */
|
| 136 |
+
position: absolute;
|
| 137 |
+
width: 50px;
|
| 138 |
+
height: auto;
|
| 139 |
+
pointer-events: none;
|
| 140 |
+
z-index: 1; /* Lowest layer, behind #root (3) */
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
@keyframes swimRandom { /* Updated animation from user's new HTML */
|
| 144 |
+
0% {
|
| 145 |
+
transform: translate(0, 0) rotate(0deg);
|
| 146 |
+
opacity: 0;
|
| 147 |
+
}
|
| 148 |
+
10% {
|
| 149 |
+
opacity: 1;
|
| 150 |
+
}
|
| 151 |
+
100% {
|
| 152 |
+
transform: translate(var(--x), var(--y)) rotate(var(--r));
|
| 153 |
+
opacity: 0;
|
| 154 |
+
}
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
/* Spin & Win Styles */
|
| 158 |
+
@keyframes spin {
|
| 159 |
+
0% { transform: rotate(0deg); }
|
| 160 |
+
100% { transform: rotate(360deg); }
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.wheel-section {
|
| 164 |
+
position: absolute;
|
| 165 |
+
width: 50%;
|
| 166 |
+
height: 50%;
|
| 167 |
+
transform-origin: bottom right;
|
| 168 |
+
clip-path: polygon(0 0, 100% 0, 0 100%);
|
| 169 |
+
display: flex;
|
| 170 |
+
align-items: center;
|
| 171 |
+
justify-content: center;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.wheel-section span {
|
| 175 |
+
position: absolute;
|
| 176 |
+
transform: rotate(45deg) translateX(70px) rotate(-45deg); /* Adjusted for Tailwind's transform-origin */
|
| 177 |
+
font-weight: bold;
|
| 178 |
+
color: white;
|
| 179 |
+
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.7);
|
| 180 |
+
font-size: 0.75rem; /* Smaller font for better fit */
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.spinning {
|
| 184 |
+
animation: spin 0.5s linear infinite;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.pulse-spin-button { /* More specific to avoid conflict if 'pulse' is used elsewhere */
|
| 188 |
+
animation: pulseCustom 1.5s infinite;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.confetti {
|
| 192 |
+
position: absolute;
|
| 193 |
+
width: 10px;
|
| 194 |
+
height: 10px;
|
| 195 |
+
background-color: #f00; /* Will be overridden by JS */
|
| 196 |
+
opacity: 0;
|
| 197 |
+
animation: confetti-fall 3s ease-out forwards; /* Apply animation directly */
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/* Removed .confetti-animation class as animation is applied directly */
|
| 201 |
+
|
| 202 |
+
@keyframes confetti-fall {
|
| 203 |
+
0% {
|
| 204 |
+
transform: translateY(-100px) rotate(0deg);
|
| 205 |
+
opacity: 1;
|
| 206 |
+
}
|
| 207 |
+
100% {
|
| 208 |
+
transform: translateY(100vh) rotate(720deg); /* More spin */
|
| 209 |
+
opacity: 0;
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
/* Lottery Card Flip Styles */
|
| 214 |
+
.lottery-card-container {
|
| 215 |
+
perspective: 1000px;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
.lottery-card {
|
| 219 |
+
position: relative;
|
| 220 |
+
width: 80px;
|
| 221 |
+
height: 120px;
|
| 222 |
+
cursor: pointer;
|
| 223 |
+
transform-style: preserve-3d;
|
| 224 |
+
transition: transform 0.6s;
|
| 225 |
+
border-radius: 0.5rem; /* rounded-lg */
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
.lottery-card.flipped {
|
| 229 |
+
transform: rotateY(180deg);
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
.lottery-card-face {
|
| 233 |
+
position: absolute;
|
| 234 |
+
width: 100%;
|
| 235 |
+
height: 100%;
|
| 236 |
+
backface-visibility: hidden;
|
| 237 |
+
border-radius: 0.5rem; /* rounded-lg */
|
| 238 |
+
display: flex;
|
| 239 |
+
justify-content: center;
|
| 240 |
+
align-items: center;
|
| 241 |
+
flex-direction: column; /* For icon and text */
|
| 242 |
+
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); /* shadow-md */
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
.lottery-card-front {
|
| 246 |
+
background: linear-gradient(135deg, #f59e0b, #f97316); /* Example Tailwind amber-500 to orange-500 */
|
| 247 |
+
color: white;
|
| 248 |
+
font-size: 1.875rem; /* text-3xl for icon */
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.lottery-card-front i { /* For Font Awesome icon if used */
|
| 252 |
+
font-size: 2.5rem; /* text-5xl */
|
| 253 |
+
}
|
| 254 |
+
.lottery-card-front .ticket-text {
|
| 255 |
+
font-size: 0.65rem; /* text-xxs */
|
| 256 |
+
font-weight: bold;
|
| 257 |
+
margin-top: 0.25rem; /* mt-1 */
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
.lottery-card-back {
|
| 262 |
+
background: linear-gradient(135deg, #3b82f6, #6366f1); /* Example Tailwind blue-500 to indigo-500 */
|
| 263 |
+
color: white;
|
| 264 |
+
transform: rotateY(180deg);
|
| 265 |
+
font-size: 1rem; /* text-base */
|
| 266 |
+
font-weight: bold;
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
.lottery-card-back i { /* For prize icon */
|
| 270 |
+
font-size: 2rem; /* text-4xl */
|
| 271 |
+
margin-bottom: 0.5rem; /* mb-2 */
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
/* Progress bar for lottery (can be Tailwind, but custom gives more control) */
|
| 275 |
+
.progress-container {
|
| 276 |
+
width: 100%;
|
| 277 |
+
height: 10px;
|
| 278 |
+
background-color: rgba(255, 255, 255, 0.1); /* bg-white/10 */
|
| 279 |
+
border-radius: 0.375rem; /* rounded-md */
|
| 280 |
+
margin: 1rem 0; /* my-4 */
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
.progress-bar {
|
| 284 |
+
height: 100%;
|
| 285 |
+
background: linear-gradient(90deg, #f59e0b, #f97316); /* Example Tailwind amber-500 to orange-500 */
|
| 286 |
+
border-radius: 0.375rem; /* rounded-md */
|
| 287 |
+
transition: width 0.3s ease;
|
| 288 |
+
}
|
| 289 |
+
/* Ensure Font Awesome icons are vertically centered in buttons */
|
| 290 |
+
.nav-button i, .nav-button-admin i {
|
| 291 |
+
vertical-align: middle;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
</style>
|
| 295 |
+
<script>
|
| 296 |
+
tailwind.config = {
|
| 297 |
+
theme: {
|
| 298 |
+
extend: {
|
| 299 |
+
colors: {
|
| 300 |
+
/* 'theme-background': 'var(--theme-background)', // Body background is now gradient */
|
| 301 |
+
'theme-dark': 'var(--theme-dark)',
|
| 302 |
+
'theme-primary': 'var(--theme-primary)',
|
| 303 |
+
'theme-secondary': 'var(--theme-secondary)',
|
| 304 |
+
'theme-accent': 'var(--theme-accent)',
|
| 305 |
+
'theme-hover': 'var(--theme-hover)',
|
| 306 |
+
'theme-border': 'var(--theme-border)',
|
| 307 |
+
'theme-text': 'var(--theme-text)',
|
| 308 |
+
'theme-success': 'var(--theme-success)',
|
| 309 |
+
'theme-danger': 'var(--theme-danger)',
|
| 310 |
+
'theme-warning': 'var(--theme-warning)',
|
| 311 |
+
'theme-info': 'var(--theme-info)',
|
| 312 |
+
'theme-white': 'var(--theme-white)',
|
| 313 |
+
},
|
| 314 |
+
fontFamily: {
|
| 315 |
+
sans: ['Inter', 'ui-sans-serif', 'system-ui'],
|
| 316 |
+
},
|
| 317 |
+
animation: {
|
| 318 |
+
fadeIn: 'fadeIn 0.3s ease-out forwards',
|
| 319 |
+
slideDown: 'slideDown 0.3s ease-out forwards',
|
| 320 |
+
pulseCustom: 'pulseCustom 2s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
| 321 |
+
spin: 'spin 1s linear infinite', /* Added for wheel, JS can override duration */
|
| 322 |
+
'confetti-fall': 'confetti-fall 3s ease-out forwards',
|
| 323 |
+
shake: 'shake 0.5s cubic-bezier(.36,.07,.19,.97) both', // Added shake animation
|
| 324 |
+
},
|
| 325 |
+
keyframes: {
|
| 326 |
+
fadeIn: {
|
| 327 |
+
'0%': { opacity: '0' },
|
| 328 |
+
'100%': { opacity: '1' },
|
| 329 |
+
},
|
| 330 |
+
slideDown: {
|
| 331 |
+
'0%': { transform: 'translateY(-20px)', opacity: '0.8' },
|
| 332 |
+
'100%': { transform: 'translateY(0)', opacity: '1' },
|
| 333 |
+
},
|
| 334 |
+
pulseCustom: {
|
| 335 |
+
'0%, 100%': { opacity: '1', transform: 'scale(1)' },
|
| 336 |
+
'50%': { opacity: '0.8', transform: 'scale(1.02)' },
|
| 337 |
+
},
|
| 338 |
+
spin: { /* Added for wheel */
|
| 339 |
+
'0%': { transform: 'rotate(0deg)' },
|
| 340 |
+
'100%': { transform: 'rotate(360deg)' },
|
| 341 |
+
},
|
| 342 |
+
'confetti-fall': { /* Added for confetti */
|
| 343 |
+
'0%': { transform: 'translateY(-100px) rotate(0deg)', opacity: '1'},
|
| 344 |
+
'100%': { transform: 'translateY(100vh) rotate(720deg)', opacity: '0'},
|
| 345 |
+
},
|
| 346 |
+
shake: { // Added shake keyframes
|
| 347 |
+
'10%, 90%': { transform: 'translate3d(-1px, 0, 0)' },
|
| 348 |
+
'20%, 80%': { transform: 'translate3d(2px, 0, 0)' },
|
| 349 |
+
'30%, 50%, 70%': { transform: 'translate3d(-4px, 0, 0)' },
|
| 350 |
+
'40%, 60%': { transform: 'translate3d(4px, 0, 0)' }
|
| 351 |
+
}
|
| 352 |
+
}
|
| 353 |
+
}
|
| 354 |
+
}
|
| 355 |
+
}
|
| 356 |
+
</script>
|
| 357 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 358 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 359 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Orbitron:wght@400;500;700&display=swap" rel="stylesheet">
|
| 360 |
+
|
| 361 |
+
<script type="importmap">
|
| 362 |
+
{
|
| 363 |
+
"imports": {
|
| 364 |
+
"react": "https://esm.sh/react@^19.1.0",
|
| 365 |
+
"react-dom/": "https://esm.sh/react-dom@^19.1.0/",
|
| 366 |
+
"react/": "https://esm.sh/react@^19.1.0/",
|
| 367 |
+
"@google/genai": "https://esm.sh/@google/genai@^1.1.0",
|
| 368 |
+
"@/constants": "/constants.ts",
|
| 369 |
+
"@/components/modals/AdminLoginModal": "./components/modals/AdminLoginModal.tsx"
|
| 370 |
+
}
|
| 371 |
+
}
|
| 372 |
+
</script>
|
| 373 |
+
</head>
|
| 374 |
+
<body>
|
| 375 |
+
<noscript>You need to enable JavaScript to run this app.</noscript>
|
| 376 |
+
|
| 377 |
+
<!-- Blue fish that follow the cursor - updated src from user's HTML -->
|
| 378 |
+
<img src="https://pngimg.com/uploads/fish/fish_PNG1151.png" class="fish" id="fish1" alt=""/>
|
| 379 |
+
<img src="https://pngimg.com/uploads/fish/fish_PNG1151.png" class="fish" id="fish2" alt=""/>
|
| 380 |
+
|
| 381 |
+
<div id="root"></div>
|
| 382 |
+
|
| 383 |
+
<!-- Default Sound Effect Placeholders REMOVED
|
| 384 |
+
Sound playback is now managed by sounds.ts and AdminConfig.
|
| 385 |
+
-->
|
| 386 |
+
|
| 387 |
+
<script type="module" src="/index.tsx"></script>
|
| 388 |
+
|
| 389 |
+
<!-- Updated fish animation script based on user's new HTML -->
|
| 390 |
+
<script>
|
| 391 |
+
const fish1Global = document.getElementById("fish1");
|
| 392 |
+
const fish2Global = document.getElementById("fish2");
|
| 393 |
+
|
| 394 |
+
document.addEventListener("mousemove", (e) => {
|
| 395 |
+
const x = e.clientX;
|
| 396 |
+
const y = e.clientY;
|
| 397 |
+
|
| 398 |
+
if (fish1Global) fish1Global.style.transform = `translate(${x}px, ${y}px)`;
|
| 399 |
+
setTimeout(() => {
|
| 400 |
+
if (fish2Global) fish2Global.style.transform = `translate(${x - 40}px, ${y + 40}px)`;
|
| 401 |
+
}, 100);
|
| 402 |
+
});
|
| 403 |
+
|
| 404 |
+
// Background random swimming fish - updated src to yellow fish
|
| 405 |
+
const fishImageGlobal = 'https://www.transparentpng.com/thumb/fish/yellow-tang-fish-transparent-background-Q9PKK1.png';
|
| 406 |
+
|
| 407 |
+
function createBackgroundFish() {
|
| 408 |
+
const fishElement = document.createElement('img');
|
| 409 |
+
fishElement.src = fishImageGlobal;
|
| 410 |
+
fishElement.classList.add('bg-fish');
|
| 411 |
+
fishElement.setAttribute('alt', ''); // Decorative image
|
| 412 |
+
|
| 413 |
+
fishElement.style.top = Math.floor(Math.random() * window.innerHeight) + 'px';
|
| 414 |
+
fishElement.style.left = Math.floor(Math.random() * window.innerWidth) + 'px';
|
| 415 |
+
|
| 416 |
+
const moveX = Math.floor(Math.random() * 1000 - 500) + 'px';
|
| 417 |
+
const moveY = Math.floor(Math.random() * 1000 - 500) + 'px';
|
| 418 |
+
const rotate = Math.floor(Math.random() * 360) + 'deg';
|
| 419 |
+
|
| 420 |
+
fishElement.style.setProperty('--x', moveX);
|
| 421 |
+
fishElement.style.setProperty('--y', moveY);
|
| 422 |
+
fishElement.style.setProperty('--r', rotate);
|
| 423 |
+
fishElement.style.animation = 'swimRandom 14s linear forwards';
|
| 424 |
+
|
| 425 |
+
document.body.appendChild(fishElement);
|
| 426 |
+
|
| 427 |
+
setTimeout(() => {
|
| 428 |
+
fishElement.remove();
|
| 429 |
+
}, 14000);
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
setInterval(createBackgroundFish, 1000);
|
| 433 |
+
</script>
|
| 434 |
+
</body>
|
| 435 |
+
</html><link rel="stylesheet" href="index.css">
|
| 436 |
+
<script src="index.tsx" type="module"></script>
|
index.tsx
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
import React from 'react';
|
| 3 |
+
import ReactDOM from 'react-dom/client';
|
| 4 |
+
import App from './App.tsx';
|
| 5 |
+
|
| 6 |
+
const rootElement = document.getElementById('root');
|
| 7 |
+
if (!rootElement) {
|
| 8 |
+
throw new Error("Could not find root element to mount to");
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
const root = ReactDOM.createRoot(rootElement);
|
| 12 |
+
root.render(
|
| 13 |
+
<React.StrictMode>
|
| 14 |
+
<App />
|
| 15 |
+
</React.StrictMode>
|
| 16 |
+
);
|
metadata.json
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "Doge Whales - The MMORPG",
|
| 3 |
+
"description": "An immersive MMORPG where players control Doge Whales, collect NFTs, stake tokens, and explore a vast underwater world. Features include an NFT marketplace, breeding system, and token-based economy.",
|
| 4 |
+
"requestFramePermissions": [],
|
| 5 |
+
"prompt": ""
|
| 6 |
+
}
|
package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "doge-whales---the-mmorpg",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"preview": "vite preview"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"react": "^19.1.0",
|
| 13 |
+
"react-dom": "^19.1.0",
|
| 14 |
+
"@google/genai": "^1.1.0",
|
| 15 |
+
"@/constants": "latest",
|
| 16 |
+
"@/components/modals/AdminLoginModal": "latest"
|
| 17 |
+
},
|
| 18 |
+
"devDependencies": {
|
| 19 |
+
"@types/node": "^22.14.0",
|
| 20 |
+
"typescript": "~5.7.2",
|
| 21 |
+
"vite": "^6.2.0"
|
| 22 |
+
}
|
| 23 |
+
}
|
sounds.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { AdminConfig } from './types';
|
| 2 |
+
|
| 3 |
+
export enum SoundEvent {
|
| 4 |
+
Fire = 'fire',
|
| 5 |
+
EnemyKill = 'enemy_kill',
|
| 6 |
+
TokenCollect = 'token_collect',
|
| 7 |
+
NftBubbleCollect = 'nft_bubble_collect',
|
| 8 |
+
LevelUp = 'level_up',
|
| 9 |
+
GameOver = 'game_over',
|
| 10 |
+
GameWin = 'game_win', // Placeholder
|
| 11 |
+
SpinWin = 'spin_win',
|
| 12 |
+
SpinLose = 'spin_lose', // If you want a specific sound for non-DWHL prizes or "Try Again"
|
| 13 |
+
LotteryWin = 'lottery_win',
|
| 14 |
+
LotteryLose = 'lottery_lose',
|
| 15 |
+
DailyRewardClaim = 'daily_reward_claim',
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
let dedicatedSoundPlayer: HTMLAudioElement | null = null;
|
| 19 |
+
let masterVolume: number = 0.5; // Default volume (0.0 to 1.0)
|
| 20 |
+
let isGloballyMuted: boolean = false;
|
| 21 |
+
|
| 22 |
+
function getDedicatedSoundPlayer(): HTMLAudioElement {
|
| 23 |
+
if (!dedicatedSoundPlayer) {
|
| 24 |
+
dedicatedSoundPlayer = new Audio();
|
| 25 |
+
}
|
| 26 |
+
return dedicatedSoundPlayer;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* Sets the global master volume for all game sounds.
|
| 31 |
+
* @param volume The new volume level (0.0 to 1.0).
|
| 32 |
+
*/
|
| 33 |
+
export const setGlobalVolume = (volume: number): void => {
|
| 34 |
+
masterVolume = Math.max(0, Math.min(1, volume)); // Clamp between 0 and 1
|
| 35 |
+
const player = getDedicatedSoundPlayer();
|
| 36 |
+
if (player) {
|
| 37 |
+
player.volume = masterVolume;
|
| 38 |
+
}
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* Toggles the global mute state for all game sounds.
|
| 43 |
+
* @returns The new mute state (true if muted, false otherwise).
|
| 44 |
+
*/
|
| 45 |
+
export const toggleGlobalMute = (): boolean => {
|
| 46 |
+
isGloballyMuted = !isGloballyMuted;
|
| 47 |
+
return isGloballyMuted;
|
| 48 |
+
};
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Explicitly sets the global mute state.
|
| 52 |
+
* @param mute True to mute, false to unmute.
|
| 53 |
+
*/
|
| 54 |
+
export const setGlobalMute = (mute: boolean): void => {
|
| 55 |
+
isGloballyMuted = mute;
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
/**
|
| 59 |
+
* Gets the current global master volume.
|
| 60 |
+
* @returns The current volume (0.0 to 1.0).
|
| 61 |
+
*/
|
| 62 |
+
export const getGlobalVolume = (): number => {
|
| 63 |
+
return masterVolume;
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
/**
|
| 67 |
+
* Checks if the game sounds are globally muted.
|
| 68 |
+
* @returns True if muted, false otherwise.
|
| 69 |
+
*/
|
| 70 |
+
export const isGlobalMuted = (): boolean => {
|
| 71 |
+
return isGloballyMuted;
|
| 72 |
+
};
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
/**
|
| 76 |
+
* Plays a sound event using a dedicated Audio element.
|
| 77 |
+
* Sounds are played based on the URL or Base64 string provided in AdminConfig.
|
| 78 |
+
* If no sound is configured for an event in AdminConfig, no sound will be played.
|
| 79 |
+
* Respects global volume and mute settings.
|
| 80 |
+
* @param event The SoundEvent to play.
|
| 81 |
+
* @param adminConfig AdminConfig containing sound URLs or Base64 strings.
|
| 82 |
+
*/
|
| 83 |
+
export const playSound = (
|
| 84 |
+
event: SoundEvent,
|
| 85 |
+
adminConfig?: AdminConfig
|
| 86 |
+
) => {
|
| 87 |
+
if (isGloballyMuted) {
|
| 88 |
+
return; // Don't play sound if globally muted
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
const adminSoundKey = `sound${event.charAt(0).toUpperCase() +
|
| 92 |
+
event.slice(1).replace(/_([a-z])/g, (match, char) => char.toUpperCase())}Url` as keyof AdminConfig;
|
| 93 |
+
|
| 94 |
+
const soundSrcFromAdmin = adminConfig?.[adminSoundKey] as string | undefined;
|
| 95 |
+
|
| 96 |
+
if (soundSrcFromAdmin && soundSrcFromAdmin.trim() !== '') {
|
| 97 |
+
const player = getDedicatedSoundPlayer();
|
| 98 |
+
player.volume = masterVolume; // Apply master volume
|
| 99 |
+
|
| 100 |
+
const isDataURI = soundSrcFromAdmin.startsWith('data:audio/');
|
| 101 |
+
|
| 102 |
+
if (isDataURI) {
|
| 103 |
+
const base64Prefix = "base64,";
|
| 104 |
+
const base64StartIndex = soundSrcFromAdmin.indexOf(base64Prefix);
|
| 105 |
+
if (base64StartIndex === -1) {
|
| 106 |
+
console.error(`Malformed data URI (missing 'base64,') for sound event '${event}': ${soundSrcFromAdmin}. Skipping playback.`);
|
| 107 |
+
return;
|
| 108 |
+
}
|
| 109 |
+
// Extract the actual base64 data part
|
| 110 |
+
const actualBase64Data = soundSrcFromAdmin.substring(base64StartIndex + base64Prefix.length);
|
| 111 |
+
if (actualBase64Data.trim() === '') {
|
| 112 |
+
console.error(`Malformed base64 data part for sound event '${event}': Is empty. Full URI: ${soundSrcFromAdmin}. Skipping playback.`);
|
| 113 |
+
return;
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
// No specific validation for non-data URLs, browser will attempt to load them.
|
| 117 |
+
|
| 118 |
+
if (player.src !== soundSrcFromAdmin) {
|
| 119 |
+
player.src = soundSrcFromAdmin;
|
| 120 |
+
}
|
| 121 |
+
player.currentTime = 0;
|
| 122 |
+
|
| 123 |
+
player.play().catch(error => {
|
| 124 |
+
console.error(`Error playing sound '${event}' from source '${soundSrcFromAdmin}':`, error);
|
| 125 |
+
});
|
| 126 |
+
} else {
|
| 127 |
+
// console.log(`Sound event '${event}' triggered, but no sound source configured in AdminConfig.`);
|
| 128 |
+
}
|
| 129 |
+
};
|
tsconfig.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2020",
|
| 4 |
+
"experimentalDecorators": true,
|
| 5 |
+
"useDefineForClassFields": false,
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
| 8 |
+
"skipLibCheck": true,
|
| 9 |
+
|
| 10 |
+
/* Bundler mode */
|
| 11 |
+
"moduleResolution": "bundler",
|
| 12 |
+
"allowImportingTsExtensions": true,
|
| 13 |
+
"isolatedModules": true,
|
| 14 |
+
"moduleDetection": "force",
|
| 15 |
+
"noEmit": true,
|
| 16 |
+
"allowJs": true,
|
| 17 |
+
"jsx": "react-jsx",
|
| 18 |
+
|
| 19 |
+
/* Linting */
|
| 20 |
+
"strict": true,
|
| 21 |
+
"noUnusedLocals": true,
|
| 22 |
+
"noUnusedParameters": true,
|
| 23 |
+
"noFallthroughCasesInSwitch": true,
|
| 24 |
+
"noUncheckedSideEffectImports": true,
|
| 25 |
+
|
| 26 |
+
"paths": {
|
| 27 |
+
"@/*" : ["./*"]
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
}
|
types.ts
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
|
| 3 |
+
export enum GameState {
|
| 4 |
+
CharacterSelection = 'CharacterSelection',
|
| 5 |
+
Playing = 'Playing',
|
| 6 |
+
GameOver = 'GameOver',
|
| 7 |
+
Paused = 'Paused',
|
| 8 |
+
}
|
| 9 |
+
|
| 10 |
+
export enum Rarity {
|
| 11 |
+
Common = 'common',
|
| 12 |
+
Rare = 'rare',
|
| 13 |
+
Legendary = 'legendary',
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export enum WeaponType {
|
| 17 |
+
StandardBullet = 'standard_bullet',
|
| 18 |
+
HeavyBullet = 'heavy_bullet',
|
| 19 |
+
Rocket = 'rocket', // Manually aimed rocket
|
| 20 |
+
Laser = 'laser',
|
| 21 |
+
SwordThrow = 'sword_throw',
|
| 22 |
+
AutoTargetMissile = 'auto_target_missile', // Fires towards nearest enemy, requires manual trigger for now
|
| 23 |
+
AutoHomingRocket = 'auto_homing_rocket', // Passively fires homing rockets
|
| 24 |
+
EnemyBullet = 'enemy_bullet',
|
| 25 |
+
Bomb = 'bomb',
|
| 26 |
+
SummonFishMinion = 'summon_fish_minion',
|
| 27 |
+
FishMinionBullet = 'fish_minion_bullet',
|
| 28 |
+
RedArrowDefense = 'red_arrow_defense', // Player's defensive weapon system
|
| 29 |
+
RedArrowProjectile = 'red_arrow_projectile', // The projectile fired by RedArrowDefense
|
| 30 |
+
FlameCone = 'flame_cone', // New cone-shaped fire weapon
|
| 31 |
+
DogeLauncher = 'doge_launcher', // New weapon that fires Doge/Bone images
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export interface NFTCoinEffect {
|
| 35 |
+
coins?: number; // This will represent Sea Token collection multiplier
|
| 36 |
+
xp?: number;
|
| 37 |
+
damage?: number;
|
| 38 |
+
speed?: number;
|
| 39 |
+
allStats?: number;
|
| 40 |
+
}
|
| 41 |
+
export interface NFT {
|
| 42 |
+
id: string;
|
| 43 |
+
name: string;
|
| 44 |
+
description: string;
|
| 45 |
+
price: number; // Price in Sea Tokens
|
| 46 |
+
rarity: Rarity;
|
| 47 |
+
image: string; // Can be URL or Base64 string
|
| 48 |
+
effect?: NFTCoinEffect;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
export interface GameCharacter {
|
| 52 |
+
id: string;
|
| 53 |
+
name: string;
|
| 54 |
+
image: string; // URL or Base64
|
| 55 |
+
type: 'player' | 'enemy';
|
| 56 |
+
defaultWeaponType?: WeaponType; // For player characters
|
| 57 |
+
autoFireHealthThreshold?: number; // For player characters, percentage (0-100)
|
| 58 |
+
baseHealth?: number; // Optional: base health for this character type
|
| 59 |
+
baseSpeed?: number; // Optional: base speed for this character type, especially enemies
|
| 60 |
+
canOneHitKill?: boolean; // Optional: for special player abilities
|
| 61 |
+
// Future: baseDamage?: number, etc.
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
export interface PlayerData {
|
| 65 |
+
id: string; // Add a unique ID for the player instance, useful for multiplayer later
|
| 66 |
+
name: string; // Player's custom name
|
| 67 |
+
characterId: string; // ID of the chosen GameCharacter (whale type)
|
| 68 |
+
whaleColor: string; // Fallback or tint color
|
| 69 |
+
level: number;
|
| 70 |
+
xp: number;
|
| 71 |
+
maxXP: number;
|
| 72 |
+
health: number;
|
| 73 |
+
maxHealth: number;
|
| 74 |
+
seaTokens: number; // Renamed from dwhl
|
| 75 |
+
equippedNftId: string | null;
|
| 76 |
+
nftInventory: string[];
|
| 77 |
+
stakedTokens: number; // Tokens staked (Sea Tokens)
|
| 78 |
+
stakingStartTime: number | null;
|
| 79 |
+
|
| 80 |
+
// New NFT Staking Slot System
|
| 81 |
+
stakedNftSlots: Array<string | null>; // Array of 5, stores NFT ID or null for each slot
|
| 82 |
+
nftMiningPowers: Record<string, number>; // Key: NFT ID, Value: mining power level
|
| 83 |
+
|
| 84 |
+
lastNftRewardClaimTime: number | null; // For NFT staking rewards (now primarily for slot-based system)
|
| 85 |
+
totalStakingRewards: number; // Total rewards claimed (Sea Tokens, from both token and NFT staking)
|
| 86 |
+
score: number;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
export type ModalType = 'nftMarketplace' | 'wallet' | 'playerProfile' | 'nftStaking' | 'adminPanel' | 'whaleCombatGuide' | 'combatLoading' | 'spinToWin' | 'adminLogin' | 'dogeWhaleEscape' | null;
|
| 90 |
+
|
| 91 |
+
export interface NotificationMessage {
|
| 92 |
+
id: string;
|
| 93 |
+
message: string;
|
| 94 |
+
type: 'success' | 'error' | 'info' | 'warning' | 'special';
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
export type MarketplaceTabType = 'market' | 'my-nfts' | 'breeding';
|
| 98 |
+
|
| 99 |
+
export interface BreedingParents {
|
| 100 |
+
parent1: string | null;
|
| 101 |
+
parent2: string | null;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
export interface BreedingResult extends Omit<NFT, 'price' | 'id' | 'effect'> {
|
| 105 |
+
id?: string;
|
| 106 |
+
effect?: NFTCoinEffect;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
export interface FloatingText {
|
| 110 |
+
id: string;
|
| 111 |
+
text: string;
|
| 112 |
+
x: number;
|
| 113 |
+
y: number;
|
| 114 |
+
color: string;
|
| 115 |
+
timestamp: number;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
export interface NftBubbleItem {
|
| 119 |
+
id: string;
|
| 120 |
+
x: number;
|
| 121 |
+
y: number;
|
| 122 |
+
rarity: Rarity;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
// This type seems unused, but keeping for context. Consider removing if truly not needed.
|
| 126 |
+
export interface WalletTokenItem {
|
| 127 |
+
id: string;
|
| 128 |
+
x: number;
|
| 129 |
+
y: number;
|
| 130 |
+
value: number;
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
export interface SeaTokenItem {
|
| 134 |
+
id: string;
|
| 135 |
+
x: number;
|
| 136 |
+
y: number;
|
| 137 |
+
value: number;
|
| 138 |
+
isAttracting?: boolean; // True if moving towards player due to auto-collect
|
| 139 |
+
attractSpeedX?: number;
|
| 140 |
+
attractSpeedY?: number;
|
| 141 |
+
creationTime: number; // To potentially prioritize older tokens or for other effects
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
export interface SavedReply {
|
| 145 |
+
id: string;
|
| 146 |
+
topic: string;
|
| 147 |
+
answer: string;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
export interface AdminConfig {
|
| 151 |
+
globalNftStakingRate: number; // This rate becomes less relevant for the 5-slot system, might be for other future staking features.
|
| 152 |
+
globalTokenStakingRate: number; // This rate applies to Sea Tokens
|
| 153 |
+
gameBackgroundImageUrl: string; // Can be URL or Base64 string
|
| 154 |
+
defaultEnemyImageUrl?: string; // Can be URL or Base64 string for default enemy
|
| 155 |
+
playerAutoFire: boolean; // Global toggle for player auto-fire
|
| 156 |
+
enemiesAutoTarget: boolean;
|
| 157 |
+
playerAutoTargets?: boolean; // Global toggle for player projectiles to auto-target nearest enemy
|
| 158 |
+
enemyFireCooldownMs: number; // Cooldown for enemy firing in milliseconds
|
| 159 |
+
decorativeFishGoldenImage?: string; // URL or Base64 for Golden Fish
|
| 160 |
+
decorativeFishStarImage?: string; // URL or Base64 for Star Fish
|
| 161 |
+
decorativeFishBluefinImage?: string; // URL or Base64 for Bluefin Fish
|
| 162 |
+
bubbleSmallImage?: string; // URL or Base64 for Small Bubble
|
| 163 |
+
bubbleMediumImage?: string; // URL or Base64 for Medium Bubble
|
| 164 |
+
bubbleLargeImage?: string; // URL or Base64 for Large Bubble
|
| 165 |
+
seaTokenImageUrl?: string; // Optional custom image for Sea Tokens
|
| 166 |
+
savedReplies?: SavedReply[]; // For Admin Panel Knowledge Base
|
| 167 |
+
|
| 168 |
+
// Sound effect URLs (can be file paths or base64 data URLs)
|
| 169 |
+
soundFireUrl?: string;
|
| 170 |
+
soundEnemyKillUrl?: string;
|
| 171 |
+
soundTokenCollectUrl?: string;
|
| 172 |
+
soundNftBubbleCollectUrl?: string;
|
| 173 |
+
soundLevelUpUrl?: string;
|
| 174 |
+
soundGameOverUrl?: string;
|
| 175 |
+
soundGameWinUrl?: string; // Placeholder for future use
|
| 176 |
+
soundSpinWinUrl?: string;
|
| 177 |
+
soundSpinLoseUrl?: string;
|
| 178 |
+
soundLotteryWinUrl?: string;
|
| 179 |
+
soundLotteryLoseUrl?: string;
|
| 180 |
+
soundDailyRewardClaimUrl?: string;
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
// New Visual Effect Settings
|
| 184 |
+
bloodParticleLifespanSeconds: number;
|
| 185 |
+
rainEffectEnabled: boolean;
|
| 186 |
+
rainDropSpeedMin: number;
|
| 187 |
+
rainDropSpeedMax: number;
|
| 188 |
+
rainDurationSeconds: number;
|
| 189 |
+
rainIntervalSeconds: number;
|
| 190 |
+
rainIntensity: number; // Number of raindrops
|
| 191 |
+
|
| 192 |
+
// Red Arrow Defense System Config
|
| 193 |
+
redArrowActive: boolean; // To globally enable/disable this system for players who have it
|
| 194 |
+
redArrowCooldownMs: number;
|
| 195 |
+
redArrowProjectileSpeed: number;
|
| 196 |
+
redArrowDamage: number;
|
| 197 |
+
redArrowImageUrl?: string; // URL or Base64 for the Red Arrow projectile image
|
| 198 |
+
redArrowShotCount?: number; // Number of red arrows to fire at once
|
| 199 |
+
|
| 200 |
+
// Fire image assets
|
| 201 |
+
flameParticleImageUrl?: string; // For flame cone weapon effect particles
|
| 202 |
+
enemyBurningEffectImageUrl?: string; // For overlay/animation on burning enemies
|
| 203 |
+
fireballProjectileImageUrl?: string; // For a fireball projectile
|
| 204 |
+
|
| 205 |
+
// Doge Whale Escape Game Asset URLs
|
| 206 |
+
dogeEscapePlayerImageUrl?: string;
|
| 207 |
+
dogeEscapeWallImageUrl?: string;
|
| 208 |
+
dogeEscapeGoalImageUrl?: string;
|
| 209 |
+
dogeEscapeMineImageUrl?: string;
|
| 210 |
+
dogeEscapeBombImageUrl?: string;
|
| 211 |
+
dogeEscapeJellyfishImageUrl?: string;
|
| 212 |
+
dogeEscapeCrocodileImageUrl?: string;
|
| 213 |
+
dogeEscapeAlgaeImageUrl?: string;
|
| 214 |
+
dogeEscapeCurrentNImageUrl?: string;
|
| 215 |
+
dogeEscapeCurrentSImageUrl?: string;
|
| 216 |
+
dogeEscapeCurrentEImageUrl?: string;
|
| 217 |
+
dogeEscapeCurrentWImageUrl?: string;
|
| 218 |
+
dogeEscapeSnakeImageUrl?: string;
|
| 219 |
+
dogeEscapeLifeUpImageUrl?: string;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
export interface Projectile {
|
| 223 |
+
id: string;
|
| 224 |
+
x: number;
|
| 225 |
+
y: number;
|
| 226 |
+
type: WeaponType;
|
| 227 |
+
width: number;
|
| 228 |
+
height: number;
|
| 229 |
+
dx?: number;
|
| 230 |
+
dy?: number;
|
| 231 |
+
targetEnemyId?: string | null; // For homing missiles against enemies
|
| 232 |
+
targetProjectileId?: string | null; // For RedArrowProjectile targeting enemy projectiles
|
| 233 |
+
rotation?: number;
|
| 234 |
+
owner: 'player' | 'enemy';
|
| 235 |
+
ownerPlayerId?: string; // For player-owned things like minion bullets or RedArrow
|
| 236 |
+
ownerEnemyId?: string; // For enemy-owned projectiles
|
| 237 |
+
creationTime?: number; // For bomb fuse or other time-based effects
|
| 238 |
+
gravityEffect?: boolean; // True if projectile is affected by gravity (e.g., bombs)
|
| 239 |
+
damage?: number; // Optional specific damage for this projectile instance
|
| 240 |
+
isHoming?: boolean; // General flag for homing behavior
|
| 241 |
+
imageKey?: string; // Optional key to look up an image in loadedGameAssets
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
export interface FishMinion {
|
| 245 |
+
id: string;
|
| 246 |
+
x: number;
|
| 247 |
+
y: number;
|
| 248 |
+
health: number;
|
| 249 |
+
maxHealth: number;
|
| 250 |
+
targetEnemyId: string | null;
|
| 251 |
+
lastFireTime: number;
|
| 252 |
+
ownerPlayerId: string; // ID of the PlayerData instance that summoned this minion
|
| 253 |
+
size: number;
|
| 254 |
+
angleToPlayer: number; // Relative angle to player for positioning
|
| 255 |
+
distanceFromPlayer: number; // Distance to maintain from player
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
export interface ExplosionEffect {
|
| 259 |
+
id: string;
|
| 260 |
+
x: number;
|
| 261 |
+
y: number;
|
| 262 |
+
currentRadius: number;
|
| 263 |
+
maxRadius: number;
|
| 264 |
+
creationTime: number;
|
| 265 |
+
duration: number;
|
| 266 |
+
color: string;
|
| 267 |
+
// Optional: damage, if not handled separately. For chain combustion, direct damage is better.
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
export enum DecorativeFishType {
|
| 271 |
+
Golden = 'golden',
|
| 272 |
+
Star = 'star',
|
| 273 |
+
Bluefin = 'bluefin',
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
export interface DecorativeFish {
|
| 277 |
+
id: string;
|
| 278 |
+
x: number;
|
| 279 |
+
y: number;
|
| 280 |
+
size: number;
|
| 281 |
+
speedX: number;
|
| 282 |
+
speedY: number;
|
| 283 |
+
type: DecorativeFishType;
|
| 284 |
+
opacity: number;
|
| 285 |
+
rotation: number; // For star fish
|
| 286 |
+
lastDirectionChangeTime: number;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
export enum BubbleSize {
|
| 290 |
+
Small = 'small',
|
| 291 |
+
Medium = 'medium',
|
| 292 |
+
Large = 'large',
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
export interface BackgroundBubble {
|
| 296 |
+
id: string;
|
| 297 |
+
x: number;
|
| 298 |
+
y: number;
|
| 299 |
+
sizeCategory: BubbleSize;
|
| 300 |
+
actualRadius: number;
|
| 301 |
+
speedY: number;
|
| 302 |
+
speedX: number;
|
| 303 |
+
opacity: number;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
// Enemy specific state for effects like burning
|
| 307 |
+
export interface EnemyEffectState {
|
| 308 |
+
isBurning?: boolean;
|
| 309 |
+
burnDamagePerTick?: number;
|
| 310 |
+
burnTicksRemaining?: number;
|
| 311 |
+
burnTickCooldownRemaining?: number; // Time until the next burn tick damage
|
| 312 |
+
accumulatedFireExposureMs?: number; // For FlameCone explosive core
|
| 313 |
+
// Add other effects like frozen, poisoned etc. here
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
// For Google Sign-In
|
| 317 |
+
declare global {
|
| 318 |
+
interface Window {
|
| 319 |
+
google: any; // Basic type for google global object
|
| 320 |
+
}
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
// --- Doge Whale Escape Game Types ---
|
| 324 |
+
export enum MazeCellType {
|
| 325 |
+
Path = 'P',
|
| 326 |
+
Wall = 'W',
|
| 327 |
+
Start = 'S',
|
| 328 |
+
Goal = 'G',
|
| 329 |
+
Mine = 'M', // Classic Mine
|
| 330 |
+
Bomb = 'B', // New Bomb hazard
|
| 331 |
+
Jellyfish = 'J', // New Jellyfish hazard
|
| 332 |
+
Crocodile = 'C', // New Crocodile hazard
|
| 333 |
+
ToxicAlgae = 'A', // New Toxic Algae hazard
|
| 334 |
+
CurrentN = 'CN', // Current pushing North
|
| 335 |
+
CurrentS = 'CS', // Current pushing South
|
| 336 |
+
CurrentE = 'CE', // Current pushing East
|
| 337 |
+
CurrentW = 'CW', // Current pushing West
|
| 338 |
+
Snake = 'K', // New Snake hazard
|
| 339 |
+
LifeUp = 'L', // New LifeUp reward
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
export interface DogeWhaleEscapeLevel {
|
| 343 |
+
id: number;
|
| 344 |
+
name: string;
|
| 345 |
+
layout: MazeCellType[][];
|
| 346 |
+
startPos: { row: number; col: number };
|
| 347 |
+
message?: string; // Optional custom message for the level
|
| 348 |
+
requireAllSnakesNeutralized?: boolean; // New property for win condition
|
| 349 |
+
}
|
utils.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
export const generateId = (): string => Math.random().toString(36).substr(2, 9);
|
| 3 |
+
|
| 4 |
+
// Add other utility functions here as needed
|
vite.config.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import path from 'path';
|
| 2 |
+
import { defineConfig, loadEnv } from 'vite';
|
| 3 |
+
|
| 4 |
+
export default defineConfig(({ mode }) => {
|
| 5 |
+
const env = loadEnv(mode, '.', '');
|
| 6 |
+
return {
|
| 7 |
+
define: {
|
| 8 |
+
'process.env.API_KEY': JSON.stringify(env.GEMINI_API_KEY),
|
| 9 |
+
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY)
|
| 10 |
+
},
|
| 11 |
+
resolve: {
|
| 12 |
+
alias: {
|
| 13 |
+
'@': path.resolve(__dirname, '.'),
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
};
|
| 17 |
+
});
|