TypeScript · 14506 bytes Raw Blame History
1 'use client';
2
3 // src/components/Game.tsx
4 import React, { useState, useEffect, useRef } from 'react';
5 import TreeVisualizer from './TreeVisualizer';
6 import { gameApi, FileSystemTree } from '@/lib/api';
7
8 interface CommandHistoryEntry {
9 command: string;
10 output: string;
11 success: boolean;
12 }
13
14 const Game: React.FC = () => {
15 const [gameState, setGameState] = useState<{
16 tree: FileSystemTree | null;
17 sessionId: number | null;
18 loading: boolean;
19 error: string | null;
20 }>({
21 tree: null,
22 sessionId: null,
23 loading: false,
24 error: null,
25 });
26
27 const [command, setCommand] = useState('');
28 const [commandHistory, setCommandHistory] = useState<CommandHistoryEntry[]>([]);
29 const [executing, setExecuting] = useState(false);
30 const [showHints, setShowHints] = useState(false);
31 const [hints, setHints] = useState<string[]>([]);
32 const [terminalMinimized, setTerminalMinimized] = useState(true);
33 const [hasPlayedIntro, setHasPlayedIntro] = useState(false);
34
35 const terminalRef = useRef<HTMLDivElement>(null);
36 const inputRef = useRef<HTMLInputElement>(null);
37
38 // Auto-scroll terminal to bottom
39 useEffect(() => {
40 if (terminalRef.current) {
41 terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
42 }
43 }, [commandHistory]);
44
45 // Auto-focus input after command execution
46 useEffect(() => {
47 if (!executing && inputRef.current && !terminalMinimized) {
48 inputRef.current.focus();
49 }
50 }, [executing, terminalMinimized]);
51
52 // Start a new game
53 const startNewGame = async () => {
54 try {
55 setGameState({ ...gameState, loading: true, error: null });
56 const response = await gameApi.createGame('Player1');
57 setGameState({
58 tree: response.tree,
59 sessionId: response.session_id,
60 loading: false,
61 error: null,
62 });
63
64 // Create a more dynamic starting message based on random location
65 const startLocation = response.tree.player_location;
66 const homeDir = response.home_directory || '/home';
67 let locationContext = '';
68
69 if (startLocation.startsWith('/home')) {
70 locationContext = "You've been dropped in someone's home directory. ";
71 } else if (startLocation.startsWith('/usr')) {
72 locationContext = "You're in the system's usr hierarchy. ";
73 } else if (startLocation.startsWith('/var')) {
74 locationContext = "You're somewhere in the variable data area. ";
75 } else if (startLocation.startsWith('/opt')) {
76 locationContext = "You're in the optional packages directory. ";
77 } else {
78 locationContext = "You've been placed somewhere in the filesystem. ";
79 }
80
81 setCommandHistory([{
82 command: 'Hunt started!',
83 output: `${response.mole_hint}\n${locationContext}Your home directory is ${homeDir}.\nUse 'pwd' to see where you are, 'cd ~' to go home.\nType "help" for available commands.`,
84 success: true,
85 }]);
86 setHints([]);
87 setShowHints(false);
88 setTerminalMinimized(true); // Keep terminal minimized on new game
89 setHasPlayedIntro(false); // Reset intro for new game
90 } catch (error) {
91 setGameState({
92 ...gameState,
93 loading: false,
94 error: 'Failed to start game. Is the backend running on http://localhost:8000?',
95 });
96 }
97 };
98
99 // Get hints
100 const getHints = async () => {
101 if (!gameState.tree) return;
102
103 try {
104 const response = await gameApi.getHint(gameState.tree.id);
105 setHints(response.hints);
106 setShowHints(true);
107 } catch (error) {
108 console.error('Failed to get hints', error);
109 }
110 };
111
112 // Execute a command
113 const executeCommand = async (cmd: string) => {
114 if (!gameState.tree || !cmd.trim() || executing) return;
115
116 setExecuting(true);
117 try {
118 const response = await gameApi.executeCommand(
119 gameState.tree.id,
120 cmd,
121 gameState.sessionId || undefined
122 );
123
124 // Update command history
125 setCommandHistory(prev => [...prev, {
126 command: cmd,
127 output: response.output,
128 success: response.success,
129 }]);
130
131 // Update player location if moved
132 if (response.current_path !== gameState.tree.player_location) {
133 setGameState(prev => ({
134 ...prev,
135 tree: prev.tree ? {
136 ...prev.tree,
137 player_location: response.current_path,
138 } : null,
139 }));
140 }
141
142 // Check if game won
143 if (response.game_won) {
144 setGameState(prev => ({
145 ...prev,
146 tree: prev.tree ? {
147 ...prev.tree,
148 is_completed: true,
149 tree_data: updateTreeDataToShowMole(prev.tree!.tree_data, prev.tree!.player_location),
150 } : null,
151 }));
152 }
153
154 setCommand('');
155 } catch (error) {
156 setCommandHistory(prev => [...prev, {
157 command: cmd,
158 output: 'Error: Failed to execute command. Check your connection.',
159 success: false,
160 }]);
161 } finally {
162 setExecuting(false);
163 // Focus will be restored by useEffect
164 }
165 };
166
167 // Update tree data to show mole when game is won
168 const updateTreeDataToShowMole = (treeData: any, molePath: string): any => {
169 if (treeData.path === molePath) {
170 return { ...treeData, has_mole: true };
171 }
172 if (treeData.children) {
173 return {
174 ...treeData,
175 children: treeData.children.map((child: any) =>
176 updateTreeDataToShowMole(child, molePath)
177 ),
178 };
179 }
180 return treeData;
181 };
182
183
184
185 // Handle node click in visualizer
186 const handleNodeClick = (path: string) => {
187 executeCommand(`cd ${path}`);
188 };
189
190 // Start game on mount
191 useEffect(() => {
192 startNewGame();
193 }, []);
194
195 // Mark intro as played after first render
196 useEffect(() => {
197 if (gameState.tree && !hasPlayedIntro) {
198 // Set timeout to mark intro as played after animation completes
199 const timer = setTimeout(() => {
200 setHasPlayedIntro(true);
201 }, 6500); // Total intro duration
202 return () => clearTimeout(timer);
203 }
204 }, [gameState.tree, hasPlayedIntro]);
205
206 if (gameState.loading) {
207 return (
208 <div className="flex items-center justify-center min-h-screen bg-gray-900 text-white">
209 <div className="text-center">
210 <div className="text-2xl mb-4">Loading Bashamole...</div>
211 <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white mx-auto"></div>
212 </div>
213 </div>
214 );
215 }
216
217 if (gameState.error) {
218 return (
219 <div className="flex items-center justify-center min-h-screen bg-gray-900 text-white">
220 <div className="text-center">
221 <div className="text-red-400 mb-4">{gameState.error}</div>
222 <button
223 onClick={startNewGame}
224 className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition"
225 >
226 Try Again
227 </button>
228 </div>
229 </div>
230 );
231 }
232
233 if (!gameState.tree) {
234 return (
235 <div className="flex items-center justify-center min-h-screen bg-gray-900 text-white">
236 <div className="text-center">
237 <h1 className="text-4xl font-bold mb-4">Bashamole</h1>
238 <p className="text-gray-400 mb-8">Hunt the mole in the Unix filesystem!</p>
239 <button
240 onClick={startNewGame}
241 className="px-8 py-4 bg-green-600 text-white rounded-lg hover:bg-green-700 text-xl transition transform hover:scale-105"
242 >
243 Start New Game
244 </button>
245 </div>
246 </div>
247 );
248 }
249
250 return (
251 <div className="relative min-h-screen bg-gray-900 text-white overflow-hidden">
252 {/* Tree Canvas - Full Screen Background */}
253 <div className="absolute inset-0 bg-gray-900">
254 <TreeVisualizer
255 treeData={gameState.tree.tree_data}
256 playerLocation={gameState.tree.player_location}
257 onNodeClick={handleNodeClick}
258 playIntro={!hasPlayedIntro}
259 />
260 </div>
261
262 {/* Floating Terminal - Top Left */}
263 <div className={`absolute top-4 left-4 bg-gray-900 rounded-lg shadow-2xl border border-gray-800 transition-all duration-300 z-30 ${
264 terminalMinimized ? 'w-80' : 'w-[700px]'
265 }`}>
266 {/* Terminal Header */}
267 <div className="flex items-center justify-between bg-gray-800 px-4 py-2 rounded-t-lg border-b border-gray-700">
268 <div className="flex items-center gap-2">
269 <div className="flex gap-1.5">
270 <div className="w-3 h-3 bg-red-500 rounded-full"></div>
271 <div className="w-3 h-3 bg-yellow-500 rounded-full"></div>
272 <div className="w-3 h-3 bg-green-500 rounded-full"></div>
273 </div>
274 <h3 className="text-sm font-medium text-gray-300 ml-2">bash</h3>
275 </div>
276 <button
277 onClick={() => setTerminalMinimized(!terminalMinimized)}
278 className="text-gray-400 hover:text-white transition"
279 >
280 {terminalMinimized ? '▼' : '▲'}
281 </button>
282 </div>
283
284 {/* Terminal Content */}
285 {!terminalMinimized && (
286 <div
287 ref={terminalRef}
288 className="bg-black p-4 font-mono text-base h-[350px] overflow-y-auto scrollbar-thin scrollbar-thumb-gray-700"
289 onClick={() => inputRef.current?.focus()}
290 >
291 {commandHistory.map((entry, index) => (
292 <div key={index} className="mb-1">
293 <div className="flex items-start">
294 <span className="text-green-400">groundskeeper@molehill</span>
295 <span className="text-gray-400 mx-1">::</span>
296 <span className="text-blue-400">{entry.command.startsWith('Hunt started!') ? '~' : gameState.tree?.player_location || '~'}</span>
297 <span className="text-gray-400 ml-1">$</span>
298 <span className={`ml-2 ${entry.command.startsWith('Hunt started!') ? 'text-yellow-400' : 'text-gray-300'}`}>
299 {entry.command.startsWith('Hunt started!') ? '' : entry.command}
300 </span>
301 </div>
302 {entry.output && (
303 <div className={`${entry.success ? 'text-gray-300' : 'text-red-400'} ml-0 mt-1`}>
304 {entry.output.split('\n').map((line, i) => (
305 <div key={i}>{line}</div>
306 ))}
307 </div>
308 )}
309 </div>
310 ))}
311
312 {/* Current input line */}
313 <div className="flex items-start">
314 <span className="text-green-400">groundskeeper@molehill</span>
315 <span className="text-gray-400 mx-1">::</span>
316 <span className="text-blue-400">{gameState.tree?.player_location || '~'}</span>
317 <span className="text-gray-400 ml-1">$</span>
318 <div className="flex-1 ml-2">
319 <div className="relative">
320 <input
321 ref={inputRef}
322 type="text"
323 value={command}
324 onChange={(e) => setCommand(e.target.value)}
325 onKeyDown={(e) => {
326 if (e.key === 'Enter') {
327 e.preventDefault();
328 executeCommand(command);
329 }
330 }}
331 disabled={executing || gameState.tree?.is_completed}
332 className="w-full bg-transparent text-gray-300 outline-none caret-transparent"
333 placeholder=""
334 autoFocus
335 spellCheck={false}
336 autoComplete="off"
337 autoCorrect="off"
338 autoCapitalize="off"
339 />
340 {/* Blinking cursor */}
341 <span
342 className="absolute text-gray-300 pointer-events-none"
343 style={{
344 left: `${command.length * 0.6}em`,
345 animation: 'blink 1s step-end infinite'
346 }}
347 >
348 _
349 </span>
350 </div>
351 </div>
352 </div>
353 </div>
354 )}
355 </div>
356
357 {/* Hints Popup */}
358 {showHints && hints.length > 0 && (
359 <div className="absolute top-20 left-1/2 transform -translate-x-1/2 bg-yellow-900/95 backdrop-blur-sm border border-yellow-600 rounded-lg p-4 max-w-md z-30 shadow-2xl">
360 <button
361 onClick={() => setShowHints(false)}
362 className="absolute top-2 right-2 text-yellow-400 hover:text-yellow-300"
363 >
364
365 </button>
366 <h3 className="text-yellow-400 font-bold mb-2">Hints:</h3>
367 {hints.map((hint, index) => (
368 <p key={index} className="text-yellow-200 text-sm">{hint}</p>
369 ))}
370 </div>
371 )}
372
373 {/* Bottom Game Bar */}
374 <div className="absolute bottom-0 left-0 right-0 bg-gray-900/90 backdrop-blur-sm border-t border-gray-700 p-4 z-20">
375 <div className="max-w-7xl mx-auto flex justify-between items-center">
376 <div className="flex items-center gap-4">
377 <h1 className="text-2xl font-bold">Bashamole</h1>
378 <div className="text-sm text-gray-400">
379 Location: <span className="font-mono text-blue-400">{gameState.tree.player_location}</span>
380 </div>
381 </div>
382
383 <div className="flex items-center gap-3">
384 {gameState.tree.is_completed ? (
385 <div className="text-green-400 font-bold animate-pulse">
386 You found the mole!
387 </div>
388 ) : (
389 <>
390 <button
391 onClick={getHints}
392 className="px-3 py-1.5 bg-yellow-600 text-white text-sm rounded hover:bg-yellow-700 transition"
393 >
394 Get Hint
395 </button>
396 <div className="text-xs text-gray-500">
397 Click nodes or use terminal
398 </div>
399 </>
400 )}
401 <button
402 onClick={startNewGame}
403 className="px-3 py-1.5 bg-gray-700 text-white text-sm rounded hover:bg-gray-600 transition"
404 >
405 New Game
406 </button>
407 </div>
408 </div>
409 </div>
410 </div>
411 );
412 };
413
414 export default Game;