frontend progression, timer, api
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
6ea57e5667056293c39adfcaf64e9976d497c5a9- Parents
-
49d9fb6 - Tree
d7f0f4a
6ea57e5
6ea57e5667056293c39adfcaf64e9976d497c5a949d9fb6
d7f0f4a| Status | File | + | - |
|---|---|---|---|
| M |
frontend/src/components/Game.tsx
|
116 | 13 |
| A |
frontend/src/components/TimerDisplay.tsx
|
98 | 0 |
| M |
frontend/src/lib/api.ts
|
75 | 0 |
frontend/src/components/Game.tsxmodified@@ -3,6 +3,7 @@ | ||
| 3 | 3 | import React, { useState, useEffect, useRef } from 'react'; |
| 4 | 4 | import Image from 'next/image'; |
| 5 | 5 | import TreeVisualizer from './TreeVisualizer'; |
| 6 | +import TimerDisplay from './TimerDisplay'; | |
| 6 | 7 | import { gameApi, FileSystemTree, FHSDirectory, CommandReferenceResponse, MoleDirection, TreeNode } from '@/lib/api'; |
| 7 | 8 | |
| 8 | 9 | interface CommandHistoryEntry { |
@@ -92,9 +93,15 @@ const Game: React.FC = () => { | ||
| 92 | 93 | locationContext = "You've been placed somewhere in the filesystem. "; |
| 93 | 94 | } |
| 94 | 95 | |
| 96 | + // Add timer info to starting message | |
| 97 | + let timerInfo = ''; | |
| 98 | + if (response.initial_timer && response.timer_reason) { | |
| 99 | + timerInfo = `\nTimer: ${response.initial_timer}s (mole is ${response.timer_reason})`; | |
| 100 | + } | |
| 101 | + | |
| 95 | 102 | setCommandHistory([{ |
| 96 | 103 | command: 'Hunt started!', |
| 97 | - 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.`, | |
| 104 | + output: `${response.mole_hint}\n${locationContext}Your home directory is ${homeDir}.\nUse 'pwd' to see where you are, 'cd ~' to go home.${timerInfo}\nType "help" for available commands.`, | |
| 98 | 105 | success: true, |
| 99 | 106 | }]); |
| 100 | 107 | setHints([]); |
@@ -160,10 +167,21 @@ const Game: React.FC = () => { | ||
| 160 | 167 | gameState.sessionId || undefined |
| 161 | 168 | ); |
| 162 | 169 | |
| 170 | + // Build output with timer warnings | |
| 171 | + let fullOutput = response.output; | |
| 172 | + | |
| 173 | + // Add timer warnings if present | |
| 174 | + if (response.timer_warnings && response.timer_warnings.length > 0) { | |
| 175 | + const warnings = response.timer_warnings.map(w => | |
| 176 | + `⚠️ ${w.level}: ${w.message}` | |
| 177 | + ).join('\n'); | |
| 178 | + fullOutput = warnings + (fullOutput ? '\n' + fullOutput : ''); | |
| 179 | + } | |
| 180 | + | |
| 163 | 181 | // Update command history |
| 164 | 182 | setCommandHistory(prev => [...prev, { |
| 165 | 183 | command: cmd, |
| 166 | - output: response.output, | |
| 184 | + output: fullOutput, | |
| 167 | 185 | success: response.success, |
| 168 | 186 | }]); |
| 169 | 187 | |
@@ -225,6 +243,11 @@ const Game: React.FC = () => { | ||
| 225 | 243 | // Update score and moles killed |
| 226 | 244 | if (response.score !== undefined) setScore(response.score); |
| 227 | 245 | if (response.moles_killed !== undefined) setMolesKilled(response.moles_killed); |
| 246 | + | |
| 247 | + // Format the output to include timer info on new line | |
| 248 | + if (response.timer_reason && !response.output.includes('New mole detected')) { | |
| 249 | + response.output += `\nNew mole detected ${response.timer_reason}!`; | |
| 250 | + } | |
| 228 | 251 | } |
| 229 | 252 | |
| 230 | 253 | // Legacy: Check if game won (for old backend compatibility) |
@@ -417,15 +440,76 @@ const Game: React.FC = () => { | ||
| 417 | 440 | </div> |
| 418 | 441 | )} |
| 419 | 442 | |
| 420 | - {/* Score Display - Top Right */} | |
| 421 | - {molesKilled > 0 && ( | |
| 422 | - <div className="absolute top-4 right-4 bg-black/80 backdrop-blur-sm border border-green-500 rounded-lg p-3 shadow-2xl z-30"> | |
| 423 | - <div className="text-green-400 font-terminal text-sm"> | |
| 424 | - <div>Score: {score}</div> | |
| 425 | - <div>Moles: {molesKilled}</div> | |
| 443 | + {/* Score and Timer Display - Top Right */} | |
| 444 | + <div className="absolute top-4 right-4 flex flex-col gap-3 z-30"> | |
| 445 | + {/* Timer */} | |
| 446 | + <TimerDisplay | |
| 447 | + gameTreeId={gameState.tree?.id || null} | |
| 448 | + sessionId={gameState.sessionId} | |
| 449 | + onTimerExpire={async () => { | |
| 450 | + // Handle timer expiration - check for mole escape | |
| 451 | + if (gameState.tree) { | |
| 452 | + try { | |
| 453 | + const response = await gameApi.checkTimer(gameState.tree.id, gameState.sessionId || undefined); | |
| 454 | + if (response.mole_escaped) { | |
| 455 | + // Build the escape message | |
| 456 | + let escapeMessage = response.message || 'The mole escaped!'; | |
| 457 | + | |
| 458 | + // Add distance info for new mole if available | |
| 459 | + if (response.escape_data?.timer_reason) { | |
| 460 | + escapeMessage += `\nNew mole detected ${response.escape_data.timer_reason}!`; | |
| 461 | + } | |
| 462 | + | |
| 463 | + // Update command history with escape message | |
| 464 | + setCommandHistory(prev => [...prev, { | |
| 465 | + command: 'Mole escaped!', | |
| 466 | + output: escapeMessage, | |
| 467 | + success: false, | |
| 468 | + }]); | |
| 469 | + | |
| 470 | + // Update mole direction if provided | |
| 471 | + if (response.escape_data?.new_location) { | |
| 472 | + // Update tree to show new mole location | |
| 473 | + const treeWithNewMole = updateTreeDataToShowMole( | |
| 474 | + removeMoleFromTree(gameState.tree.tree_data), | |
| 475 | + response.escape_data.new_location | |
| 476 | + ); | |
| 477 | + | |
| 478 | + setGameState(prev => ({ | |
| 479 | + ...prev, | |
| 480 | + tree: prev.tree ? { | |
| 481 | + ...prev.tree, | |
| 482 | + tree_data: treeWithNewMole, | |
| 483 | + } : null, | |
| 484 | + })); | |
| 485 | + | |
| 486 | + // Show mole direction indicator if provided | |
| 487 | + if (response.escape_data?.mole_direction) { | |
| 488 | + setMoleDirection(response.escape_data.mole_direction); | |
| 489 | + // Hide direction indicator after 5 seconds | |
| 490 | + setTimeout(() => { | |
| 491 | + setMoleDirection(null); | |
| 492 | + }, 5000); | |
| 493 | + } | |
| 494 | + } | |
| 495 | + } | |
| 496 | + } catch (error) { | |
| 497 | + console.error('Failed to check timer:', error); | |
| 498 | + } | |
| 499 | + } | |
| 500 | + }} | |
| 501 | + /> | |
| 502 | + | |
| 503 | + {/* Score */} | |
| 504 | + {molesKilled > 0 && ( | |
| 505 | + <div className="bg-black/80 backdrop-blur-sm border border-green-500 rounded-lg p-3 shadow-2xl"> | |
| 506 | + <div className="text-green-400 font-terminal text-sm"> | |
| 507 | + <div>Score: {score}</div> | |
| 508 | + <div>Moles: {molesKilled}</div> | |
| 509 | + </div> | |
| 426 | 510 | </div> |
| 427 | - </div> | |
| 428 | - )} | |
| 511 | + )} | |
| 512 | + </div> | |
| 429 | 513 | |
| 430 | 514 | {/* Floating Terminal - Top Left */} |
| 431 | 515 | <div className={`absolute top-4 left-4 ${terminalColors.frame} rounded-lg shadow-2xl border transition-all duration-300 z-30 ${ |
@@ -487,9 +571,28 @@ const Game: React.FC = () => { | ||
| 487 | 571 | </div> |
| 488 | 572 | {entry.output && ( |
| 489 | 573 | <div className={`${entry.success ? 'text-gray-300' : 'text-red-400'} ml-0 mt-1 font-terminal whitespace-pre-wrap`}> |
| 490 | - {entry.output.split('\n').map((line, i) => ( | |
| 491 | - <div key={i}>{line}</div> | |
| 492 | - ))} | |
| 574 | + {entry.output.split('\n').map((line, i) => { | |
| 575 | + // Special coloring for mole detection messages | |
| 576 | + let lineClass = ''; | |
| 577 | + if (line.includes('New mole detected')) { | |
| 578 | + lineClass = 'text-yellow-400'; | |
| 579 | + } else if (line.includes('⚠️')) { | |
| 580 | + // Timer warnings | |
| 581 | + if (line.includes('CRITICAL')) { | |
| 582 | + lineClass = 'text-red-500'; | |
| 583 | + } else if (line.includes('ALERT')) { | |
| 584 | + lineClass = 'text-orange-400'; | |
| 585 | + } else if (line.includes('WARNING')) { | |
| 586 | + lineClass = 'text-yellow-400'; | |
| 587 | + } | |
| 588 | + } | |
| 589 | + | |
| 590 | + return ( | |
| 591 | + <div key={i} className={lineClass || ''}> | |
| 592 | + {line} | |
| 593 | + </div> | |
| 594 | + ); | |
| 595 | + })} | |
| 493 | 596 | </div> |
| 494 | 597 | )} |
| 495 | 598 | </div> |
frontend/src/components/TimerDisplay.tsxadded@@ -0,0 +1,98 @@ | ||
| 1 | +import React, { useEffect, useState } from 'react'; | |
| 2 | + | |
| 3 | +interface TimerDisplayProps { | |
| 4 | + gameTreeId: number | null; | |
| 5 | + sessionId: number | null; | |
| 6 | + onTimerExpire?: () => void; | |
| 7 | +} | |
| 8 | + | |
| 9 | +const TimerDisplay: React.FC<TimerDisplayProps> = ({ | |
| 10 | + gameTreeId, | |
| 11 | + sessionId, | |
| 12 | + onTimerExpire | |
| 13 | +}) => { | |
| 14 | + const [timerData, setTimerData] = useState({ | |
| 15 | + remaining: 60, | |
| 16 | + warningLevel: null as string | null, | |
| 17 | + expired: false, | |
| 18 | + paused: false | |
| 19 | + }); | |
| 20 | + | |
| 21 | + useEffect(() => { | |
| 22 | + if (!gameTreeId) return; | |
| 23 | + | |
| 24 | + const checkTimer = async () => { | |
| 25 | + try { | |
| 26 | + const response = await fetch( | |
| 27 | + `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api'}/trees/filesystem-trees/${gameTreeId}/timer_status/` | |
| 28 | + ); | |
| 29 | + | |
| 30 | + if (response.ok) { | |
| 31 | + const data = await response.json(); | |
| 32 | + setTimerData({ | |
| 33 | + remaining: data.remaining, | |
| 34 | + warningLevel: data.warning_level, | |
| 35 | + expired: data.expired, | |
| 36 | + paused: data.paused | |
| 37 | + }); | |
| 38 | + | |
| 39 | + // Notify parent if timer expired | |
| 40 | + if (data.expired && onTimerExpire) { | |
| 41 | + onTimerExpire(); | |
| 42 | + } | |
| 43 | + } | |
| 44 | + } catch (error) { | |
| 45 | + console.error('Failed to check timer:', error); | |
| 46 | + } | |
| 47 | + }; | |
| 48 | + | |
| 49 | + // Check immediately | |
| 50 | + checkTimer(); | |
| 51 | + | |
| 52 | + // Then check every second | |
| 53 | + const interval = setInterval(checkTimer, 1000); | |
| 54 | + | |
| 55 | + return () => clearInterval(interval); | |
| 56 | + }, [gameTreeId, sessionId, onTimerExpire]); | |
| 57 | + | |
| 58 | + // Determine display based on warning level | |
| 59 | + let borderColor = 'border-green-500'; | |
| 60 | + let textColor = 'text-green-400'; | |
| 61 | + let statusMessage = null; | |
| 62 | + let pulseAnimation = ''; | |
| 63 | + | |
| 64 | + if (timerData.expired || timerData.remaining <= 0) { | |
| 65 | + borderColor = 'border-red-600'; | |
| 66 | + textColor = 'text-red-600'; | |
| 67 | + statusMessage = 'Escaped!'; | |
| 68 | + } else if (timerData.warningLevel === 'critical') { | |
| 69 | + borderColor = 'border-red-500'; | |
| 70 | + textColor = 'text-red-400'; | |
| 71 | + statusMessage = 'Escaping!'; | |
| 72 | + pulseAnimation = 'animate-pulse'; | |
| 73 | + } else if (timerData.warningLevel === 'alert') { | |
| 74 | + borderColor = 'border-orange-500'; | |
| 75 | + textColor = 'text-orange-400'; | |
| 76 | + statusMessage = 'Burrowing!'; | |
| 77 | + } else if (timerData.warningLevel === 'warning') { | |
| 78 | + borderColor = 'border-yellow-500'; | |
| 79 | + textColor = 'text-yellow-400'; | |
| 80 | + statusMessage = 'Alert!'; | |
| 81 | + } | |
| 82 | + | |
| 83 | + return ( | |
| 84 | + <div className={`bg-black/80 backdrop-blur-sm border ${borderColor} rounded-lg p-3 shadow-2xl ${pulseAnimation}`}> | |
| 85 | + <div className={`${textColor} font-terminal text-sm`}> | |
| 86 | + <div className="flex items-center justify-between"> | |
| 87 | + <span>Time:</span> | |
| 88 | + <span className="font-bold">{Math.max(0, timerData.remaining)}s</span> | |
| 89 | + </div> | |
| 90 | + {statusMessage && ( | |
| 91 | + <div className="text-center text-xs mt-1">{statusMessage}</div> | |
| 92 | + )} | |
| 93 | + </div> | |
| 94 | + </div> | |
| 95 | + ); | |
| 96 | +}; | |
| 97 | + | |
| 98 | +export default TimerDisplay; | |
frontend/src/lib/api.tsmodified@@ -95,6 +95,69 @@ export interface CommandReferenceResponse { | ||
| 95 | 95 | special_paths: SpecialPath[]; |
| 96 | 96 | } |
| 97 | 97 | |
| 98 | +export interface TimerWarning { | |
| 99 | + level: string; | |
| 100 | + message: string; | |
| 101 | +} | |
| 102 | + | |
| 103 | +export interface CommandResponse { | |
| 104 | + command: string; | |
| 105 | + success: boolean; | |
| 106 | + output: string; | |
| 107 | + current_path: string; | |
| 108 | + game_won?: boolean; | |
| 109 | + mole_spawned?: boolean; | |
| 110 | + mole_direction?: MoleDirection | null; | |
| 111 | + score?: number; | |
| 112 | + moles_killed?: number; | |
| 113 | + new_mole_location?: string; | |
| 114 | + timer_remaining?: number; | |
| 115 | + timer_warnings?: TimerWarning[]; | |
| 116 | + new_timer?: number; | |
| 117 | + timer_reason?: string; | |
| 118 | + timer_distance?: number; | |
| 119 | +} | |
| 120 | + | |
| 121 | +export interface TimerStatusResponse { | |
| 122 | + remaining: number; | |
| 123 | + total: number; | |
| 124 | + percentage: number; | |
| 125 | + warning_level: string | null; | |
| 126 | + expired: boolean; | |
| 127 | + paused: boolean; | |
| 128 | +} | |
| 129 | + | |
| 130 | +export interface EscapeData { | |
| 131 | + escaped: boolean; | |
| 132 | + old_location: string; | |
| 133 | + new_location: string; | |
| 134 | + total_escapes: number; | |
| 135 | + new_timer: number; | |
| 136 | + timer_reason: string; | |
| 137 | + distance: number; | |
| 138 | + mole_direction?: MoleDirection | null; | |
| 139 | +} | |
| 140 | + | |
| 141 | +export interface CheckTimerResponse { | |
| 142 | + timer_remaining: number; | |
| 143 | + timer_expired: boolean; | |
| 144 | + mole_location: string; | |
| 145 | + timer_paused: boolean; | |
| 146 | + mole_escaped?: boolean; | |
| 147 | + escape_data?: EscapeData; | |
| 148 | + message?: string; | |
| 149 | +} | |
| 150 | + | |
| 151 | +export interface GameCreationResponse { | |
| 152 | + tree: FileSystemTree; | |
| 153 | + session_id: number; | |
| 154 | + mole_hint: string; | |
| 155 | + home_directory: string; | |
| 156 | + initial_timer?: number; | |
| 157 | + timer_reason?: string; | |
| 158 | + timer_distance?: number; | |
| 159 | +} | |
| 160 | + | |
| 98 | 161 | export const gameApi = { |
| 99 | 162 | createGame: async (playerName: string = 'Anonymous'): Promise<GameCreationResponse> => { |
| 100 | 163 | const response = await api.post('/trees/filesystem-trees/create_game/', { |
@@ -136,4 +199,16 @@ export const gameApi = { | ||
| 136 | 199 | const response = await api.get('/trees/filesystem-trees/command_reference/'); |
| 137 | 200 | return response.data; |
| 138 | 201 | }, |
| 202 | + | |
| 203 | + getTimerStatus: async (treeId: number): Promise<TimerStatusResponse> => { | |
| 204 | + const response = await api.get(`/trees/filesystem-trees/${treeId}/timer_status/`); | |
| 205 | + return response.data; | |
| 206 | + }, | |
| 207 | + | |
| 208 | + checkTimer: async (treeId: number, sessionId?: number): Promise<CheckTimerResponse> => { | |
| 209 | + const url = `/trees/filesystem-trees/${treeId}/check_timer/`; | |
| 210 | + const params = sessionId ? `?session_id=${sessionId}` : ''; | |
| 211 | + const response = await api.get(url + params); | |
| 212 | + return response.data; | |
| 213 | + }, | |
| 139 | 214 | }; |