From ca4e3edb5f3ed963e50da94fd49462ac33b0713c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Viktor=20R=C3=A5dberg?= <66582202+Vikeo@users.noreply.github.com> Date: Mon, 17 Nov 2025 19:53:17 +0100 Subject: [PATCH] Feature/game score tracking (#48) --- package.json | 2 +- src/Components/Dialogs/SettingsDialog.tsx | 17 ++++ src/Components/GameOver/GameOver.tsx | 85 ++++++++++++++++++++ src/Components/LifeCounter/LifeCounter.tsx | 41 +++++++++- src/Components/Players/PlayerMenu.tsx | 77 +++++++++++++++++- src/Components/Players/Players.tsx | 7 +- src/Components/ScoreDisplay/ScoreDisplay.tsx | 71 ++++++++++++++++ src/Components/Views/Play.tsx | 67 ++++++++++++++- src/Components/Views/StartMenu/StartMenu.tsx | 32 ++++++-- src/Contexts/GlobalSettingsContext.tsx | 8 ++ src/Providers/GlobalSettingsProvider.tsx | 20 +++++ src/Providers/PlayersProvider.tsx | 6 +- src/Types/Settings.ts | 3 + 13 files changed, 422 insertions(+), 14 deletions(-) create mode 100644 src/Components/GameOver/GameOver.tsx create mode 100644 src/Components/ScoreDisplay/ScoreDisplay.tsx diff --git a/package.json b/package.json index aabd4d8..eea55a4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "life-trinket", "private": true, - "version": "1.0.1", + "version": "1.0.2", "type": "commonjs", "engines": { "node": ">=20", diff --git a/src/Components/Dialogs/SettingsDialog.tsx b/src/Components/Dialogs/SettingsDialog.tsx index 0c369eb..6838adb 100644 --- a/src/Components/Dialogs/SettingsDialog.tsx +++ b/src/Components/Dialogs/SettingsDialog.tsx @@ -242,6 +242,23 @@ export const SettingsDialog = ({ + + + + { + setSettings({ + ...settings, + showMatchScore: !settings.showMatchScore, + }); + }} + /> + + + Shows a score badge on each player's card to track wins across multiple games. + +
+ + @@ -559,6 +592,48 @@ const PlayerMenu = ({
+ + forfeitGameDialogRef.current?.close()} + > +
+
+

+ Forfeit Game? +

+
+ + +
+
+
+
); diff --git a/src/Components/Players/Players.tsx b/src/Components/Players/Players.tsx index 262828e..4db2c26 100644 --- a/src/Components/Players/Players.tsx +++ b/src/Components/Players/Players.tsx @@ -31,7 +31,7 @@ const PlayersWrapper = twc.div`w-full h-full bg-black`; export const Players = ({ gridLayout }: { gridLayout: GridLayout }) => { const { players } = usePlayers(); - const { playing, settings, preStartCompleted } = useGlobalSettings(); + const { playing, settings, preStartCompleted, gameScore } = useGlobalSettings(); return ( @@ -48,6 +48,11 @@ export const Players = ({ gridLayout }: { gridLayout: GridLayout }) => { opponents={players.filter( (opponent) => opponent.index !== player.index )} + matchScore={ + settings.showMatchScore + ? gameScore[player.index] + : undefined + } /> {settings.preStartMode === PreStartMode.RandomKing && diff --git a/src/Components/ScoreDisplay/ScoreDisplay.tsx b/src/Components/ScoreDisplay/ScoreDisplay.tsx new file mode 100644 index 0000000..e42f8e4 --- /dev/null +++ b/src/Components/ScoreDisplay/ScoreDisplay.tsx @@ -0,0 +1,71 @@ +import { twc } from 'react-twc'; +import { Player } from '../../Types/Player'; +import { GameScore } from '../../Contexts/GlobalSettingsContext'; + +const ScoreContainer = twc.div` + absolute bottom-4 left-1/2 -translate-x-1/2 + bg-background-default/90 backdrop-blur-sm + rounded-lg p-4 + shadow-lg + z-40 + min-w-[200px] +`; + +const Title = twc.h3` + text-sm font-semibold text-text-secondary + uppercase tracking-wide mb-3 +`; + +const ScoreList = twc.div` + flex flex-col gap-2 +`; + +const ScoreItem = twc.div` + flex items-center justify-between gap-4 +`; + +const PlayerInfo = twc.div` + flex items-center gap-2 +`; + +const PlayerColor = twc.div` + w-4 h-4 rounded-full +`; + +const PlayerName = twc.span` + text-text-primary font-medium +`; + +const Score = twc.span` + text-text-primary font-bold text-lg +`; + +type ScoreDisplayProps = { + players: Player[]; + gameScore: GameScore; +}; + +export const ScoreDisplay = ({ players, gameScore }: ScoreDisplayProps) => { + const hasAnyScore = Object.values(gameScore).some((score) => score > 0); + + if (!hasAnyScore) { + return null; + } + + return ( + + Match Score + + {players.map((player) => ( + + + + {player.name || `Player ${player.index + 1}`} + + {gameScore[player.index] || 0} + + ))} + + + ); +}; diff --git a/src/Components/Views/Play.tsx b/src/Components/Views/Play.tsx index 6b83c97..bd9394a 100644 --- a/src/Components/Views/Play.tsx +++ b/src/Components/Views/Play.tsx @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { twc } from 'react-twc'; import { twGridTemplateAreas } from '../../../tailwind.config'; import { useGlobalSettings } from '../../Hooks/useGlobalSettings'; @@ -6,6 +6,7 @@ import { usePlayers } from '../../Hooks/usePlayers'; import { Orientation, PreStartMode } from '../../Types/Settings'; import { Players } from '../Players/Players'; import { PreStart } from '../PreStartGame/PreStart'; +import { GameOver } from '../GameOver/GameOver'; const MainWrapper = twc.div`w-[100dvmax] h-[100dvmin] overflow-hidden, setPlayers`; @@ -14,9 +15,10 @@ type GridTemplateAreasKeys = keyof typeof twGridTemplateAreas; export type GridLayout = `grid-areas-${GridTemplateAreasKeys}`; export const Play = () => { - const { players, setPlayers } = usePlayers(); - const { initialGameSettings, playing, settings, preStartCompleted } = + const { players, setPlayers, resetCurrentGame, setStartingPlayerIndex } = usePlayers(); + const { initialGameSettings, playing, settings, preStartCompleted, gameScore, setGameScore } = useGlobalSettings(); + const [winner, setWinner] = useState(null); let gridLayout: GridLayout; switch (players.length) { @@ -94,6 +96,57 @@ export const Play = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Check for game over when only one player remains + useEffect(() => { + if (players.length < 2 || winner !== null || !settings.showMatchScore) { + return; + } + + const activePlayers = players.filter((p) => !p.hasLost); + + // If only one player is alive, they are the winner + if (activePlayers.length === 1) { + setWinner(activePlayers[0].index); + } + }, [players, winner, settings.showMatchScore]); + + const handleStartNextGame = () => { + if (winner === null) return; + + // Update score + const newScore = { ...gameScore }; + newScore[winner] = (newScore[winner] || 0) + 1; + setGameScore(newScore); + + // Set the loser as the starting player for next game + const loserIndex = players.find((p) => p.index !== winner)?.index ?? 0; + setStartingPlayerIndex(loserIndex); + + // Reset game + resetCurrentGame(); + setWinner(null); + }; + + const handleStay = () => { + if (winner === null) return; + + // Update score + const newScore = { ...gameScore }; + newScore[winner] = (newScore[winner] || 0) + 1; + setGameScore(newScore); + + // Reset hasLost state for all players + setPlayers( + players.map((p) => ({ + ...p, + hasLost: false, + })) + ); + + // Clear winner to allow new game over detection + setWinner(null); + }; + return ( {players.length > 1 && @@ -103,6 +156,14 @@ export const Play = () => { settings.showStartingPlayer && } + + {winner !== null && ( + + )} ); }; diff --git a/src/Components/Views/StartMenu/StartMenu.tsx b/src/Components/Views/StartMenu/StartMenu.tsx index d13f6cb..b820544 100644 --- a/src/Components/Views/StartMenu/StartMenu.tsx +++ b/src/Components/Views/StartMenu/StartMenu.tsx @@ -64,6 +64,7 @@ const Start = () => { setPlaying, savedGame, saveCurrentGame, + setGameScore, } = useGlobalSettings(); const infoDialogRef = useRef(null); @@ -213,6 +214,9 @@ const Start = () => { setInitialGameSettings(savedGame.initialGameSettings); setPlayers(savedGame.players); + if (savedGame.gameScore) { + setGameScore(savedGame.gameScore); + } saveCurrentGame(null); setRandomizingPlayer(false); setShowPlay(true); @@ -407,15 +411,31 @@ const Start = () => { {savedGame && ( )} diff --git a/src/Contexts/GlobalSettingsContext.tsx b/src/Contexts/GlobalSettingsContext.tsx index 252ed9d..813db6b 100644 --- a/src/Contexts/GlobalSettingsContext.tsx +++ b/src/Contexts/GlobalSettingsContext.tsx @@ -12,8 +12,13 @@ type Version = { export type SavedGame = { initialGameSettings: InitialGameSettings; players: Player[]; + gameScore?: GameScore; } | null; +export type GameScore = { + [playerIndex: number]: number; +}; + export type GlobalSettingsContextType = { fullscreen: { isFullscreen: boolean; @@ -45,6 +50,9 @@ export type GlobalSettingsContextType = { version: Version; savedGame: SavedGame; saveCurrentGame: (currentGame: SavedGame) => void; + gameScore: GameScore; + setGameScore: (score: GameScore) => void; + resetGameScore: () => void; }; export const GlobalSettingsContext = diff --git a/src/Providers/GlobalSettingsProvider.tsx b/src/Providers/GlobalSettingsProvider.tsx index 8ed1731..1c306f4 100644 --- a/src/Providers/GlobalSettingsProvider.tsx +++ b/src/Providers/GlobalSettingsProvider.tsx @@ -1,6 +1,7 @@ import { ReactNode, useEffect, useMemo, useState } from 'react'; import { useWakeLock } from 'react-screen-wake-lock'; import { + GameScore, GlobalSettingsContext, GlobalSettingsContextType, SavedGame, @@ -94,6 +95,19 @@ export const GlobalSettingsProvider = ({ ); }; + const savedGameScore = localStorage.getItem('gameScore'); + const [gameScore, setGameScore] = useState( + savedGameScore ? JSON.parse(savedGameScore) : {} + ); + const setGameScoreAndLocalStorage = (score: GameScore) => { + setGameScore(score); + localStorage.setItem('gameScore', JSON.stringify(score)); + }; + const resetGameScore = () => { + setGameScore({}); + localStorage.removeItem('gameScore'); + }; + // Set settings if they are not valid useEffect(() => { // If there are no saved settings, set default settings @@ -171,11 +185,13 @@ export const GlobalSettingsProvider = ({ localStorage.removeItem('playing'); localStorage.removeItem('showPlay'); localStorage.removeItem('preStartComplete'); + localStorage.removeItem('gameScore'); setPlaying(false); setShowPlay(false); setPreStartCompleted(false); setSettings({ ...settings, useMonarch: false }); + setGameScore({}); }; const goToStart = async () => { @@ -299,6 +315,9 @@ export const GlobalSettingsProvider = ({ isLatest: isLatestVersion, checkForNewVersion, }, + gameScore, + setGameScore: setGameScoreAndLocalStorage, + resetGameScore, }; }, [ isFullscreen, @@ -317,6 +336,7 @@ export const GlobalSettingsProvider = ({ remoteVersion, isLatestVersion, analytics, + gameScore, ]); return ( diff --git a/src/Providers/PlayersProvider.tsx b/src/Providers/PlayersProvider.tsx index b5048ae..03ae820 100644 --- a/src/Providers/PlayersProvider.tsx +++ b/src/Providers/PlayersProvider.tsx @@ -61,7 +61,11 @@ export const PlayersProvider = ({ children }: { children: ReactNode }) => { return; } - const newStartingPlayerIndex = Math.floor(Math.random() * players.length); + // Use the saved starting player index if available, otherwise random + const newStartingPlayerIndex = + startingPlayerIndex >= 0 + ? startingPlayerIndex + : Math.floor(Math.random() * players.length); players.forEach((player: Player) => { player.commanderDamage.map((damage) => { diff --git a/src/Types/Settings.ts b/src/Types/Settings.ts index 63ffb93..a5486d2 100644 --- a/src/Types/Settings.ts +++ b/src/Types/Settings.ts @@ -27,6 +27,7 @@ export type Settings = { preStartMode: PreStartMode; showAnimations: boolean; useMonarch: boolean; + showMatchScore: boolean; }; export type InitialGameSettings = { @@ -61,6 +62,7 @@ export const settingsSchema = z.object({ preStartMode: z.nativeEnum(PreStartMode), showAnimations: z.boolean(), useMonarch: z.boolean().default(false), + showMatchScore: z.boolean().default(true), }); export const defaultSettings: Settings = { @@ -71,4 +73,5 @@ export const defaultSettings: Settings = { preStartMode: PreStartMode.None, showAnimations: true, useMonarch: false, + showMatchScore: true, };