offerpk3 commited on
Commit
9caa3cb
·
verified ·
1 Parent(s): b4b4d21

Upload 30 files

Browse files
.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>&copy; {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
+ &larr; {/* 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
+ &times;
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
- title: Dogewhalewar
3
- emoji: 🐢
4
- colorFrom: green
5
- colorTo: pink
6
- sdk: docker
7
- pinned: false
8
- short_description: 'combat game '
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
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
+ });