diff --git a/next.config.ts b/next.config.ts index ebd91da..a2888ed 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,4 +5,4 @@ const nextConfig: NextConfig = { devIndicators: false, }; -export default nextConfig; +export default nextConfig; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 9730c32..ec4a1b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,7 +69,6 @@ "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", - "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -332,7 +331,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -355,7 +353,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -378,7 +375,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -395,7 +391,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -412,7 +407,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -429,7 +423,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -446,7 +439,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -463,7 +455,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -480,7 +471,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -497,7 +487,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -514,7 +503,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -531,7 +519,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -548,7 +535,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -571,7 +557,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -594,7 +579,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -617,7 +601,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -640,7 +623,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -663,7 +645,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -686,7 +667,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -709,7 +689,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0", "optional": true, "os": [ @@ -732,7 +711,6 @@ "cpu": [ "wasm32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { @@ -752,7 +730,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -772,7 +749,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -792,7 +768,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "Apache-2.0 AND LGPL-3.0-or-later", "optional": true, "os": [ @@ -5033,7 +5008,6 @@ "version": "8.5.14", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", - "dev": true, "funding": [ { "type": "opencollective", diff --git a/src/app/api/overlay-state/route.ts b/src/app/api/overlay-state/route.ts index d8cfc49..2dd1d3c 100644 --- a/src/app/api/overlay-state/route.ts +++ b/src/app/api/overlay-state/route.ts @@ -3,6 +3,11 @@ import { readFileSync, statSync } from 'fs'; import path from 'path'; import { OverlayState } from '../../lib/overlayState'; +// Fuel count files inside the game file folder. +// Change these if your sim writes fuel counts under different names. +const FUEL_FILE_RED = 'Fuel_R.txt'; +const FUEL_FILE_BLUE = 'Fuel_B.txt'; + // File cache to avoid unnecessary reads interface FileCacheEntry { content: string | { red: { username: string; score: number }[], blue: { username: string; score: number }[] }; @@ -32,6 +37,11 @@ let overlayState: OverlayState = { redSeriesScore: 0, blueSeriesScore: 0, allianceBranding: false, + // Fuel RP + fuelRPEnabled: true, + fuelRPThresholds: [100], + redFuel: 0, + blueFuel: 0, // Tournament mode tournamentModeEnabled: false, tournamentPath: '', @@ -45,6 +55,8 @@ let overlayState: OverlayState = { field2GameState: '', field2RedScore: 0, field2BlueScore: 0, + field2RedFuel: 0, + field2BlueFuel: 0, field2RedOPR: [], field2BlueOPR: [], field2SeriesEnabled: false, @@ -222,12 +234,14 @@ export async function GET() { const basePath = overlayState.gameFileLocation; // Read all files in parallel for maximum speed - const [newRedScore, newBlueScore, newMatchTime, newOPR, newGameState] = await Promise.all([ + const [newRedScore, newBlueScore, newMatchTime, newOPR, newGameState, newRedFuel, newBlueFuel] = await Promise.all([ Promise.resolve(readScore(path.join(basePath, 'Score_R.txt'))), Promise.resolve(readScore(path.join(basePath, 'Score_B.txt'))), Promise.resolve(readTimer(path.join(basePath, 'Timer.txt'))), Promise.resolve(readOPR(path.join(basePath, 'OPR.txt'))), - Promise.resolve(readTimer(path.join(basePath, 'GameState.txt'))) + Promise.resolve(readTimer(path.join(basePath, 'GameState.txt'))), + Promise.resolve(readScore(path.join(basePath, FUEL_FILE_RED))), + Promise.resolve(readScore(path.join(basePath, FUEL_FILE_BLUE))) ]); // Check if values actually changed using efficient comparison @@ -236,6 +250,8 @@ export async function GET() { overlayState.blueScore !== newBlueScore || overlayState.matchTime !== newMatchTime || overlayState.gameState !== newGameState || + overlayState.redFuel !== newRedFuel || + overlayState.blueFuel !== newBlueFuel || !oprArraysEqual(overlayState.redOPR || [], newOPR.red) || !oprArraysEqual(overlayState.blueOPR || [], newOPR.blue) ) { @@ -246,6 +262,8 @@ export async function GET() { blueScore: newBlueScore, matchTime: newMatchTime, gameState: newGameState, + redFuel: newRedFuel, + blueFuel: newBlueFuel, redOPR: newOPR.red, blueOPR: newOPR.blue, }; @@ -261,12 +279,14 @@ export async function GET() { const basePath = overlayState.field2GameFileLocation; // Read all files in parallel for maximum speed - const [newRedScore, newBlueScore, newMatchTime, newOPR, newGameState] = await Promise.all([ + const [newRedScore, newBlueScore, newMatchTime, newOPR, newGameState, newRedFuel, newBlueFuel] = await Promise.all([ Promise.resolve(readScore(path.join(basePath, 'Score_R.txt'))), Promise.resolve(readScore(path.join(basePath, 'Score_B.txt'))), Promise.resolve(readTimer(path.join(basePath, 'Timer.txt'))), Promise.resolve(readOPR(path.join(basePath, 'OPR.txt'))), - Promise.resolve(readTimer(path.join(basePath, 'GameState.txt'))) + Promise.resolve(readTimer(path.join(basePath, 'GameState.txt'))), + Promise.resolve(readScore(path.join(basePath, FUEL_FILE_RED))), + Promise.resolve(readScore(path.join(basePath, FUEL_FILE_BLUE))) ]); // Check if values actually changed using efficient comparison @@ -275,6 +295,8 @@ export async function GET() { overlayState.field2BlueScore !== newBlueScore || overlayState.field2MatchTime !== newMatchTime || overlayState.field2GameState !== newGameState || + overlayState.field2RedFuel !== newRedFuel || + overlayState.field2BlueFuel !== newBlueFuel || !oprArraysEqual(overlayState.field2RedOPR || [], newOPR.red) || !oprArraysEqual(overlayState.field2BlueOPR || [], newOPR.blue) ) { @@ -285,6 +307,8 @@ export async function GET() { field2BlueScore: newBlueScore, field2MatchTime: newMatchTime, field2GameState: newGameState, + field2RedFuel: newRedFuel, + field2BlueFuel: newBlueFuel, field2RedOPR: newOPR.red, field2BlueOPR: newOPR.blue, }; diff --git a/src/app/lib/gameConfig.ts b/src/app/lib/gameConfig.ts new file mode 100644 index 0000000..ec5ba9c --- /dev/null +++ b/src/app/lib/gameConfig.ts @@ -0,0 +1 @@ +export const GAME_MODE: string = 'REBUILT'; \ No newline at end of file diff --git a/src/app/lib/overlayState.ts b/src/app/lib/overlayState.ts index 6bfb4b8..fa20f35 100644 --- a/src/app/lib/overlayState.ts +++ b/src/app/lib/overlayState.ts @@ -26,6 +26,11 @@ export interface OverlayState { blueSecondaryColor?: string; allianceBranding: boolean; flippedTeams?: boolean; + // Fuel RP + fuelRPEnabled?: boolean; + fuelRPThresholds?: number[]; // Fuel counts that earn an RP, e.g. [100] or [100, 360] + redFuel?: number; + blueFuel?: number; // Tournament mode tournamentModeEnabled?: boolean; tournamentPath?: string; @@ -39,6 +44,8 @@ export interface OverlayState { field2GameState?: string; // Track Field 2 game state field2RedScore?: number; field2BlueScore?: number; + field2RedFuel?: number; + field2BlueFuel?: number; field2RedOPR?: { username: string; score: number }[]; field2BlueOPR?: { username: string; score: number }[]; field2SeriesEnabled?: boolean; @@ -55,6 +62,7 @@ export interface OverlayState { field2BlueSecondaryColor?: string; field2AllianceBranding?: boolean; field2FlippedTeams?: boolean; + differentialData?: { redScore: number; blueScore: number; differential: number; gameTime: string; timestamp: number }[]; } const defaultState: OverlayState = { @@ -76,6 +84,11 @@ const defaultState: OverlayState = { blueSeriesScore: 0, allianceBranding: false, flippedTeams: false, + // Fuel RP defaults + fuelRPEnabled: true, + fuelRPThresholds: [100], + redFuel: 0, + blueFuel: 0, // Tournament mode defaults tournamentModeEnabled: false, tournamentPath: '', @@ -88,6 +101,8 @@ const defaultState: OverlayState = { field2GameFileLocation: '', field2RedScore: 0, field2BlueScore: 0, + field2RedFuel: 0, + field2BlueFuel: 0, field2RedOPR: [{ username: '', score: 0 }, { username: '', score: 0 }, { username: '', score: 0 }], field2BlueOPR: [{ username: '', score: 0 }, { username: '', score: 0 }, { username: '', score: 0 }], field2SeriesEnabled: false, @@ -98,6 +113,7 @@ const defaultState: OverlayState = { field2BlueSeriesScore: 0, field2AllianceBranding: false, field2FlippedTeams: false, + differentialData: [], }; export const getOverlayState = async (): Promise => { diff --git a/src/app/overlay/components/AllianceIndicators.tsx b/src/app/overlay/components/AllianceIndicators.tsx new file mode 100644 index 0000000..0371c31 --- /dev/null +++ b/src/app/overlay/components/AllianceIndicators.tsx @@ -0,0 +1,61 @@ +interface AllianceIndicatorsProps { + isRed: boolean | undefined; + hubActive: boolean; + energized: boolean; + supercharged: boolean; + traversal: boolean; + align: 'left' | 'right'; +} + +export default function AllianceIndicators({ + isRed, hubActive, energized, supercharged, traversal, align +}: AllianceIndicatorsProps) { + const activeBg = isRed ? 'bg-red-500/80' : 'bg-blue-500/80'; + const inactiveBg = 'bg-gray-700/50'; + + const rps = [ + { label: 'โšก', name: 'Energized', active: energized }, + { label: 'โšกโšก', name: 'Supercharged', active: supercharged }, + { label: '๐Ÿ”', name: 'Traversal', active: traversal }, + ]; + + return ( +
+ {/* Shift arrow */} +
+ Shift + + {align === 'left' ? 'โ—€' : 'โ–ถ'} + +
+ + {/* RP indicators */} +
+ {rps.map(rp => ( +
+ {rp.label} + {rp.name.slice(0, 5)} +
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/src/app/overlay/components/FuelGauge.tsx b/src/app/overlay/components/FuelGauge.tsx new file mode 100644 index 0000000..4a80dba --- /dev/null +++ b/src/app/overlay/components/FuelGauge.tsx @@ -0,0 +1,141 @@ +'use client'; + +import React from 'react'; + +interface FuelGaugeProps { + /** Current fuel count for this alliance */ + value: number; + /** Fuel counts that earn an RP, e.g. [100] or [100, 360]. Gauge fills toward the next unearned one. */ + thresholds?: number[]; + /** Tile background (use the alliance color) */ + backgroundColor: string; + /** Rendered height in px; width follows the tile's aspect ratio */ + height?: number; + label?: string; +} + +// Geometry (viewBox units) +const VB_W = 100; +const VB_H = 118; +const CX = 50; +const CY = 48; +const R = 34; +const STROKE = 8; +const START_DEG = 135; // bottom-left (screen coords, clockwise from 3 o'clock) +const SWEEP_DEG = 270; // leaves a gap at the bottom like the reference + +const polar = (deg: number) => { + const rad = (deg * Math.PI) / 180; + return { x: CX + R * Math.cos(rad), y: CY + R * Math.sin(rad) }; +}; + +const start = polar(START_DEG); +const end = polar(START_DEG + SWEEP_DEG); +const ARC_PATH = `M ${start.x} ${start.y} A ${R} ${R} 0 1 1 ${end.x} ${end.y}`; + +export default function FuelGauge({ + value, + thresholds = [100], + backgroundColor, + height = 110, + label = 'FUEL', +}: FuelGaugeProps) { + const safeValue = Math.max(0, Math.round(value || 0)); + const sorted = [...thresholds].filter((t) => t > 0).sort((a, b) => a - b); + + const earned = sorted.filter((t) => safeValue >= t).length; + const next = sorted[earned]; + const prev = earned > 0 ? sorted[earned - 1] : 0; + const progress = + next === undefined ? 1 : Math.min(1, Math.max(0, (safeValue - prev) / (next - prev))); + + // pathLength=100 lets dashoffset be a simple percentage + const dashOffset = 100 - progress * 100; + + // RP pips sit in the gap under the arc (only shown when there's more than one RP to earn) + const pipGap = 7; + const pipStartX = CX - ((sorted.length - 1) * pipGap) / 2; + + return ( +
+ + {/* Track */} + + + {/* Progress */} + 0 ? 1 : 0} + style={{ transition: 'stroke-dashoffset 400ms ease-out' }} + /> + + {/* Fuel count */} + = 1000 ? 20 : 26} + fontWeight={800} + fontFamily="inherit" + > + {safeValue} + + + {/* RP pips */} + {sorted.length > 1 && + sorted.map((t, i) => ( + + ))} + + {/* Label */} + + {label} + + +
+ ); +} diff --git a/src/app/overlay/components/MatchView.tsx b/src/app/overlay/components/MatchView.tsx index 6332214..5d48ea8 100644 --- a/src/app/overlay/components/MatchView.tsx +++ b/src/app/overlay/components/MatchView.tsx @@ -3,264 +3,280 @@ import { useEffect, useState, useMemo } from "react"; import Image from "next/image"; import { pointsDifferentialTracker } from "../../lib/pointsDifferentialTracker"; import { Team, loadTeams } from "../../lib/teams"; +import { setOverlayState } from "../../lib/overlayState"; +import AllianceIndicators from './AllianceIndicators'; +import FuelGauge from './FuelGauge'; +import { GAME_MODE } from '../../lib/gameConfig'; interface MatchViewProps { state: OverlayState; currentTime: string; } +// โ”€โ”€โ”€ Fuel RP thresholds (used by both the RP indicators and the fuel gauge) โ”€โ”€โ”€โ”€ +const ENERGIZED_FUEL = 560; +const SUPERCHARGED_FUEL = 700; +const FUEL_RP_THRESHOLDS = [ENERGIZED_FUEL, SUPERCHARGED_FUEL]; + +// Fuel gauge tile colors (same as the score boxes: bg-red-600/80 and bg-blue-600/80) +const RED_TILE = 'rgba(220, 38, 38, 0.8)'; +const BLUE_TILE = 'rgba(37, 99, 235, 0.8)'; +const SCORE_BOX_HEIGHT = 124; // p-8 (32px top + bottom) + text-6xl line (60px) + export default function MatchView({ state, currentTime }: MatchViewProps) { + + // โ”€โ”€โ”€ UI State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const isRebuilt = GAME_MODE === 'REBUILT'; const [isVisible, setIsVisible] = useState(false); + const [showAutonomous, setShowAutonomous] = useState(false); + const [showTimer, setShowTimer] = useState(false); + const [gameState, setGameState] = useState(''); + const [teams, setTeams] = useState([]); + + // โ”€โ”€โ”€ Score Animation State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const [prevScores, setPrevScores] = useState({ red: state.redScore, blue: state.blueScore }); const [scoreChanged, setScoreChanged] = useState({ red: false, blue: false }); const [animatingScores, setAnimatingScores] = useState({ red: state.redScore, blue: state.blueScore }); + + // โ”€โ”€โ”€ OPR Animation State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const [prevOPR, setPrevOPR] = useState({ red: state.redOPR, blue: state.blueOPR }); const [oprChanged, setOprChanged] = useState({ red: new Set(), blue: new Set() }); const [animatingOPR, setAnimatingOPR] = useState({ red: state.redOPR, blue: state.blueOPR }); - const [showAutonomous, setShowAutonomous] = useState(false); - const [gameState, setGameState] = useState(''); - const [showTimer, setShowTimer] = useState(false); - const [teams, setTeams] = useState([]); - // Memoize sorted OPR arrays to prevent unnecessary re-calculations - const sortedRedOPR = useMemo(() => { - return [...animatingOPR.red] - .filter(player => player.username && player.username.trim() !== '') - .sort((a, b) => b.score - a.score) - .slice(0, 3); - }, [animatingOPR.red]); - - const sortedBlueOPR = useMemo(() => { - return [...animatingOPR.blue] - .filter(player => player.username && player.username.trim() !== '') - .sort((a, b) => b.score - a.score) - .slice(0, 3); - }, [animatingOPR.blue]); + // โ”€โ”€โ”€ RP / Shift State โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const [autoFuelRed, setAutoFuelRed] = useState(0); + const [autoFuelBlue, setAutoFuelBlue] = useState(0); + const [totalFuelRed, setTotalFuelRed] = useState(0); + const [totalFuelBlue, setTotalFuelBlue] = useState(0); + const [towerPointsRed, setTowerPointsRed] = useState(0); + const [towerPointsBlue, setTowerPointsBlue] = useState(0); - // Get teams by ID - const redTeam = useMemo(() => - teams.find(t => t.id === state.redTeamId), - [teams, state.redTeamId] - ); - - const blueTeam = useMemo(() => - teams.find(t => t.id === state.blueTeamId), - [teams, state.blueTeamId] - ); - - // Swap team data and components when flipped - const leftTeam = useMemo(() => - state.flippedTeams ? blueTeam : redTeam, - [state.flippedTeams, redTeam, blueTeam] - ); - - const rightTeam = useMemo(() => - state.flippedTeams ? redTeam : blueTeam, - [state.flippedTeams, redTeam, blueTeam] - ); - - - const leftOPR = useMemo(() => - state.flippedTeams ? sortedBlueOPR : sortedRedOPR, - [state.flippedTeams, sortedRedOPR, sortedBlueOPR] - ); - - const rightOPR = useMemo(() => - state.flippedTeams ? sortedRedOPR : sortedBlueOPR, - [state.flippedTeams, sortedRedOPR, sortedBlueOPR] - ); + // โ”€โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const parseTimeToSeconds = (timeStr: string): number => { + const [minutes, seconds] = timeStr.split(':').map(Number); + return (minutes || 0) * 60 + (seconds || 0); + }; - const leftOPRChanged = useMemo(() => - state.flippedTeams ? oprChanged.blue : oprChanged.red, - [state.flippedTeams, oprChanged.red, oprChanged.blue] - ); - - const rightOPRChanged = useMemo(() => - state.flippedTeams ? oprChanged.red : oprChanged.blue, - [state.flippedTeams, oprChanged.red, oprChanged.blue] + // โ”€โ”€โ”€ Memoized OPR Arrays โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const sortedRedOPR = useMemo(() => + [...animatingOPR.red] + .filter(p => p.username && p.username.trim() !== '') + .sort((a, b) => b.score - a.score) + .slice(0, 3), + [animatingOPR.red] ); - const leftAnimatingScore = useMemo(() => - state.flippedTeams ? animatingScores.blue : animatingScores.red, - [state.flippedTeams, animatingScores.red, animatingScores.blue] - ); - - const rightAnimatingScore = useMemo(() => - state.flippedTeams ? animatingScores.red : animatingScores.blue, - [state.flippedTeams, animatingScores.red, animatingScores.blue] + const sortedBlueOPR = useMemo(() => + [...animatingOPR.blue] + .filter(p => p.username && p.username.trim() !== '') + .sort((a, b) => b.score - a.score) + .slice(0, 3), + [animatingOPR.blue] ); - const leftScoreChanged = useMemo(() => - state.flippedTeams ? scoreChanged.blue : scoreChanged.red, - [state.flippedTeams, scoreChanged.red, scoreChanged.blue] - ); - - const rightScoreChanged = useMemo(() => - state.flippedTeams ? scoreChanged.red : scoreChanged.blue, - [state.flippedTeams, scoreChanged.red, scoreChanged.blue] + // โ”€โ”€โ”€ Team Lookups โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const redTeam = useMemo(() => + teams.find(t => t.id === state.redTeamId), + [teams, state.redTeamId] ); - const leftAllianceName = useMemo(() => - state.flippedTeams ? state.blueAllianceName : state.redAllianceName, - [state.flippedTeams, state.redAllianceName, state.blueAllianceName] - ); - - const rightAllianceName = useMemo(() => - state.flippedTeams ? state.redAllianceName : state.blueAllianceName, - [state.flippedTeams, state.redAllianceName, state.blueAllianceName] + const blueTeam = useMemo(() => + teams.find(t => t.id === state.blueTeamId), + [teams, state.blueTeamId] ); + // โ”€โ”€โ”€ Flip-aware Derived Values โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const leftIsRed = !state.flippedTeams; - const rightIsRed = state.flippedTeams; - - // Parse time string to seconds - const parseTimeToSeconds = (timeStr: string): number => { - const [minutes, seconds] = timeStr.split(':').map(Number); - return (minutes || 0) * 60 + (seconds || 0); - }; + const rightIsRed = !!state.flippedTeams; + + const leftTeam = state.flippedTeams ? blueTeam : redTeam; + const rightTeam = state.flippedTeams ? redTeam : blueTeam; + const leftOPR = state.flippedTeams ? sortedBlueOPR : sortedRedOPR; + const rightOPR = state.flippedTeams ? sortedRedOPR : sortedBlueOPR; + const leftOPRChanged = state.flippedTeams ? oprChanged.blue : oprChanged.red; + const rightOPRChanged = state.flippedTeams ? oprChanged.red : oprChanged.blue; + const leftAnimatingScore = state.flippedTeams ? animatingScores.blue : animatingScores.red; + const rightAnimatingScore = state.flippedTeams ? animatingScores.red : animatingScores.blue; + const leftScoreChanged = state.flippedTeams ? scoreChanged.blue : scoreChanged.red; + const rightScoreChanged = state.flippedTeams ? scoreChanged.red : scoreChanged.blue; + const leftAllianceName = state.flippedTeams ? state.blueAllianceName : state.redAllianceName; + const rightAllianceName = state.flippedTeams ? state.redAllianceName : state.blueAllianceName; + + // โ”€โ”€โ”€ RP / Shift Computed Values โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + const autoWinner: 'red' | 'blue' | null = useMemo(() => + autoFuelRed > autoFuelBlue ? 'red' : + autoFuelBlue > autoFuelRed ? 'blue' : null, + [autoFuelRed, autoFuelBlue] + ); - // Determine timer color based on time + const totalSeconds = parseTimeToSeconds(state.matchTime); + + const redHubActive = useMemo(() => { + if (totalSeconds === 0) return false; + if (totalSeconds > 140) return true; // AUTO + if (totalSeconds > 130) return true; // TRANSITION + if (totalSeconds > 105) return autoWinner === 'blue'; // SHIFT 1 + if (totalSeconds > 80) return autoWinner !== 'blue'; // SHIFT 2 + if (totalSeconds > 55) return autoWinner === 'blue'; // SHIFT 3 + if (totalSeconds > 30) return autoWinner !== 'blue'; // SHIFT 4 + return true; // END GAME + }, [totalSeconds, autoWinner]); + + const blueHubActive = useMemo(() => { + if (totalSeconds === 0) return false; + if (totalSeconds > 140) return true; + if (totalSeconds > 130) return true; + if (totalSeconds > 105) return autoWinner !== 'blue'; // SHIFT 1 + if (totalSeconds > 80) return autoWinner === 'blue'; // SHIFT 2 + if (totalSeconds > 55) return autoWinner !== 'blue'; // SHIFT 3 + if (totalSeconds > 30) return autoWinner === 'blue'; // SHIFT 4 + return true; + }, [totalSeconds, autoWinner]); + + const redEnergized = totalFuelRed >= ENERGIZED_FUEL; + const redSupercharged = totalFuelRed >= SUPERCHARGED_FUEL; + const redTraversal = towerPointsRed >= 50; + const blueEnergized = totalFuelBlue >= ENERGIZED_FUEL; + const blueSupercharged = totalFuelBlue >= SUPERCHARGED_FUEL; + const blueTraversal = towerPointsBlue >= 50; + + const leftHubActive = state.flippedTeams ? blueHubActive : redHubActive; + const rightHubActive = state.flippedTeams ? redHubActive : blueHubActive; + const leftEnergized = state.flippedTeams ? blueEnergized : redEnergized; + const leftSupercharged = state.flippedTeams ? blueSupercharged : redSupercharged; + const leftTraversal = state.flippedTeams ? blueTraversal : redTraversal; + const rightEnergized = state.flippedTeams ? redEnergized : blueEnergized; + const rightSupercharged = state.flippedTeams ? redSupercharged : blueSupercharged; + const rightTraversal = state.flippedTeams ? redTraversal : blueTraversal; + + // Fuel gauge values (gauge always sits on the outer side of each score) + const leftFuel = state.flippedTeams ? totalFuelBlue : totalFuelRed; + const rightFuel = state.flippedTeams ? totalFuelRed : totalFuelBlue; + + // โ”€โ”€โ”€ Timer Color โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ const getTimerColor = useMemo(() => { - const totalSeconds = parseTimeToSeconds(state.matchTime); if (totalSeconds === 0) return 'text-red-500'; if (totalSeconds <= 30) return 'text-yellow-400'; - if (totalSeconds <= 150 && totalSeconds > 135) return 'text-green-400'; // Auto period + if (totalSeconds <= 160 && totalSeconds > 140) return 'text-green-400'; return 'text-white'; - }, [state.matchTime]); + }, [totalSeconds]); - // Load teams data + // Load teams useEffect(() => { loadTeams().then(setTeams); }, []); + // Entrance animation useEffect(() => { - // Entrance animation with delay - const timer = setTimeout(() => { - setIsVisible(true); - }, 100); - + const timer = setTimeout(() => setIsVisible(true), 100); return () => clearTimeout(timer); }, []); + // Auto banner useEffect(() => { - // Autonomous period animation - const totalSeconds = parseTimeToSeconds(state.matchTime); - if (totalSeconds <= 150 && totalSeconds > 135) { // 2:30 to 2:15 - setShowAutonomous(true); - } else { - setShowAutonomous(false); - } + const secs = parseTimeToSeconds(state.matchTime); + setShowAutonomous(secs <= 160 && secs > 145); }, [state.matchTime]); + // Game state polling useEffect(() => { - // Read GameState.txt for debugging - if (state.gameFileLocation) { - const readGameState = async () => { - try { - const response = await fetch(`/api/read-file?path=${encodeURIComponent(state.gameFileLocation + '/GameState.txt')}`, { - cache: 'no-store', - headers: { 'Cache-Control': 'no-cache' } - }); - if (response.ok) { - const text = await response.text(); - const trimmedState = text.trim(); - setGameState(trimmedState); - // Show/hide timer based on game state - setShowTimer(trimmedState !== 'FINISHED'); - - // Start/stop differential tracking based on game state - if (trimmedState !== 'FINISHED' && !pointsDifferentialTracker.isActive()) { - pointsDifferentialTracker.startLogging(state.gameFileLocation); - } else if (trimmedState === 'FINISHED' && pointsDifferentialTracker.isActive()) { - pointsDifferentialTracker.stopLogging(); - } - } - } catch { - setGameState('Error reading GameState.txt'); - // If we can't read the game state, show the timer by default - setShowTimer(true); - } - }; - - readGameState(); - const interval = setInterval(readGameState, 100); - return () => clearInterval(interval); - } else { - // If no game file location, show timer by default + if (!state.gameFileLocation) { setShowTimer(true); setGameState(''); + return; } + + const readGameState = async () => { + try { + const response = await fetch( + `/api/read-file?path=${encodeURIComponent(state.gameFileLocation + '/GameState.txt')}`, + { cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } } + ); + if (response.ok) { + const trimmedState = (await response.text()).trim(); + setGameState(trimmedState); + setShowTimer(trimmedState !== 'FINISHED'); + + if (trimmedState !== 'FINISHED' && !pointsDifferentialTracker.isActive()) { + pointsDifferentialTracker.startLogging(state.gameFileLocation); + } else if (trimmedState === 'FINISHED' && pointsDifferentialTracker.isActive()) { + pointsDifferentialTracker.stopLogging(); + setOverlayState({ differentialData: pointsDifferentialTracker.getData() }); + } + } + } catch { + setGameState('Error reading GameState.txt'); + setShowTimer(true); + } + }; + + readGameState(); + const interval = setInterval(readGameState, 100); + return () => clearInterval(interval); }, [state.gameFileLocation]); + // Score animation useEffect(() => { - // Score change animation if (prevScores.red !== state.redScore) { setScoreChanged(prev => ({ ...prev, red: true })); setAnimatingScores(prev => ({ ...prev, red: state.redScore })); setTimeout(() => setScoreChanged(prev => ({ ...prev, red: false })), 500); } - if (prevScores.blue !== state.blueScore) { setScoreChanged(prev => ({ ...prev, blue: true })); setAnimatingScores(prev => ({ ...prev, blue: state.blueScore })); setTimeout(() => setScoreChanged(prev => ({ ...prev, blue: false })), 500); } - - // Log points differential when scores change pointsDifferentialTracker.logPoint(state.redScore, state.blueScore, state.matchTime, gameState); - setPrevScores({ red: state.redScore, blue: state.blueScore }); }, [state.redScore, state.blueScore, state.matchTime, gameState]); + // OPR animation useEffect(() => { - // Update animating OPR to match current state setAnimatingOPR({ red: state.redOPR, blue: state.blueOPR }); - - // OPR change animation + const animateOPRChanges = (alliance: 'red' | 'blue') => { const currentOPR = state[alliance === 'red' ? 'redOPR' : 'blueOPR']; const prevOPRData = prevOPR[alliance]; const changedPlayers = new Set(); - + currentOPR.forEach((player, index) => { const prevPlayer = prevOPRData[index]; if (prevPlayer && prevPlayer.username === player.username && prevPlayer.score !== player.score) { changedPlayers.add(player.username); - - // Animate from old score to new score + const startScore = prevPlayer.score; const endScore = player.score; const startTime = Date.now(); const duration = 200; - + const animateScore = () => { const elapsed = Date.now() - startTime; const progress = Math.min(elapsed / duration, 1); - const easeOutCubic = 1 - Math.pow(1 - progress, 3); const currentScore = Math.round(startScore + (endScore - startScore) * easeOutCubic); - + setAnimatingOPR(prev => ({ ...prev, - [alliance]: prev[alliance].map(p => + [alliance]: prev[alliance].map(p => p.username === player.username ? { ...p, score: currentScore } : p ) })); - + if (progress < 1) { requestAnimationFrame(animateScore); } else { setOprChanged(prev => ({ ...prev, - [alliance]: new Set([...prev[alliance]].filter(name => name !== player.username)) + [alliance]: new Set([...prev[alliance]].filter(n => n !== player.username)) })); } }; - + requestAnimationFrame(animateScore); } }); - + if (changedPlayers.size > 0) { setOprChanged(prev => ({ ...prev, @@ -268,65 +284,97 @@ export default function MatchView({ state, currentTime }: MatchViewProps) { })); } }; - + animateOPRChanges('red'); animateOPRChanges('blue'); - setPrevOPR({ red: state.redOPR, blue: state.blueOPR }); }, [state.redOPR, state.blueOPR]); + // RP file polling (REBUILT only) + useEffect(() => { + if (!isRebuilt) return; + if (!state.gameFileLocation || !state.gameFileLocation.trim()) return; + + const read = (file: string) => + fetch(`/api/read-file?path=${encodeURIComponent(state.gameFileLocation + '/' + file)}`, { + cache: 'no-store', headers: { 'Cache-Control': 'no-cache' } + }) + .then(r => r.ok ? r.text() : '0') + .then(t => parseFloat(t.trim()) || 0) + .catch(() => 0); + + const readRPFiles = async () => { + const [autoFR, autoFB, teleFR, teleFB, endR, endB, autoL1R, autoL1B] = await Promise.all([ + read('Auto_Fuel_R.txt'), + read('Auto_Fuel_B.txt'), + read('Tele_Fuel_R.txt'), + read('Tele_Fuel_B.txt'), + read('End_R.txt'), + read('End_B.txt'), + read('Auto_lvl_1_R.txt'), + read('Auto_lvl_1_B.txt'), + ]); + + setAutoFuelRed(autoFR); + setAutoFuelBlue(autoFB); + setTotalFuelRed(autoFR + teleFR); + setTotalFuelBlue(autoFB + teleFB); + setTowerPointsRed(endR + autoL1R * 15); + setTowerPointsBlue(endB + autoL1B * 15); + }; + readRPFiles(); + const interval = setInterval(readRPFiles, 500); + return () => clearInterval(interval); + }, [isRebuilt, state.gameFileLocation]); + + // โ”€โ”€โ”€ Render โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ return ( <>
-
- {state.matchTitle} -
+
{state.matchTitle}
{state.matchNumber && state.matchNumber.trim() && ( -
- {state.matchNumber} -
+
{state.matchNumber}
)}
-
- {currentTime} -
+
{currentTime}
-
-
+ {/* Timer Container */} -
- {/* Autonomous Animation - Above timer */} +
AUTO
-
-
{state.matchTime}
+
+ {state.matchTime} +
- + {/* Score Container */} -
- {/* Left Side Logo */} {leftTeam?.logo && (
{`${leftAllianceName}
)} - - {/* Right Side Logo */} {rightTeam?.logo && (
{`${rightAllianceName}
@@ -366,7 +407,7 @@ export default function MatchView({ state, currentTime }: MatchViewProps) { )} - {/* Logo centered */} + {/* Center Logo */}
- +
+ {/* Left side */}
- {/* Left Alliance OPR */} + {/* Left OPR */}
{leftOPR.map((player, index) => ( -
+
{player.username} {player.score} + } ${leftOPRChanged.has(player.username) ? 'animate-score-text-change' : ''}`}> + {player.score} +
))}
- - {/* Left Score */} -
-
{leftAnimatingScore}
+ + {/* Left Score + Indicators */} +
+ {isRebuilt && ( + + )} +
+ {/* Left Fuel Gauge (outer side) */} + {isRebuilt && ( +
+ +
+ )} +
+
{leftAnimatingScore}
+
+
- - {/* Center logo space */} + + {/* Center space */}
- + {/* Right side */}
- {/* Right Score */} -
-
{rightAnimatingScore}
+ {/* Right Score + Indicators */} +
+ {isRebuilt && ( + + )} +
+
+
{rightAnimatingScore}
+
+ {/* Right Fuel Gauge (outer side) */} + {isRebuilt && ( +
+ +
+ )} +
- - {/* Right Alliance OPR */} + + {/* Right OPR */}
{rightOPR.map((player, index) => ( -
+
{player.score} + } ${rightOPRChanged.has(player.username) ? 'animate-score-text-change' : ''}`}> + {player.score} + {player.username}
))}
+
@@ -445,79 +532,31 @@ export default function MatchView({ state, currentTime }: MatchViewProps) { ); diff --git a/src/app/overlay/components/Results.tsx b/src/app/overlay/components/Results.tsx index 019fe73..2c9d62b 100644 --- a/src/app/overlay/components/Results.tsx +++ b/src/app/overlay/components/Results.tsx @@ -63,13 +63,15 @@ export default function Results({ state }: ResultsProps) { useEffect(() => { - // Show immediately, no delay setIsVisible(true); - - // Get differential data from tracker - setDifferentialData(pointsDifferentialTracker.getData()); }, []); + useEffect(() => { + if (state.differentialData && state.differentialData.length > 0) { + setDifferentialData(state.differentialData); + } + }, [state.differentialData]); + useEffect(() => { // Read files in background after render if (state.gameFileLocation) {