diff --git a/src/app/models/game.models.ts b/src/app/models/game.models.ts index 8caaa84..a79face 100644 --- a/src/app/models/game.models.ts +++ b/src/app/models/game.models.ts @@ -1,5 +1,10 @@ export type GameTurn = 'white' | 'black'; +export interface ClockState { + whiteRemainingMs: number; + blackRemainingMs: number; +} + export type GameStatus = | 'started' | 'check' @@ -26,6 +31,7 @@ export interface GameState { moves: string[]; undoAvailable: boolean; redoAvailable: boolean; + clock: ClockState | null; } export interface GameFull { diff --git a/src/app/pages/game/game.component.html b/src/app/pages/game/game.component.html index dac651b..e1d8167 100644 --- a/src/app/pages/game/game.component.html +++ b/src/app/pages/game/game.component.html @@ -20,30 +20,24 @@

} - @if (facade.isGameFinished && facade.gameCompletionMessage) { -
-

{{ facade.gameCompletionMessage }}

-

- Start a new game -

-
- }
- + + @if (hasTimer) {

Timers

White

-

{{ formatTimer(whiteTimerSeconds) }}

+

{{ formatTimer(whiteTimerMs) }}

Black

-

{{ formatTimer(blackTimerSeconds) }}

+

{{ formatTimer(blackTimerMs) }}

+ }
diff --git a/src/app/pages/game/game.component.ts b/src/app/pages/game/game.component.ts index a7c14fb..fa12690 100644 --- a/src/app/pages/game/game.component.ts +++ b/src/app/pages/game/game.component.ts @@ -8,16 +8,8 @@ import { InputCardComponent } from '../../components/input-card/input-card.compo import { PromotionDialogComponent } from '../../components/promotion-dialog/promotion-dialog.component'; import { GameFacade } from './game.facade'; -type TimerTurn = 'white' | 'black'; type BoardTheme = 'arabian' | 'classic'; -interface TimerSnapshot { - whiteSeconds: number; - blackSeconds: number; - turn: TimerTurn; - savedAt: number; -} - @Component({ selector: 'app-game', standalone: true, @@ -27,26 +19,28 @@ interface TimerSnapshot { styleUrl: './game.component.css' }) export class GameComponent implements OnInit, OnDestroy { - private static readonly TIMER_START_SECONDS = 10 * 60; private static readonly BOARD_THEME_STORAGE_KEY = 'nowchess.boardTheme'; private readonly route = inject(ActivatedRoute); private readonly destroyRef = inject(DestroyRef); readonly facade = inject(GameFacade); - whiteTimerSeconds = GameComponent.TIMER_START_SECONDS; - blackTimerSeconds = GameComponent.TIMER_START_SECONDS; + whiteTimerMs: number | null = null; + blackTimerMs: number | null = null; exportType: 'fen' | 'pgn' = 'fen'; boardTheme: BoardTheme = 'arabian'; isDarkMode = false; exportValue = ''; exportNotice = ''; private timerIntervalId: number | null = null; - private activeGameId = ''; + + get hasTimer(): boolean { + return this.facade.state?.clock != null; + } ngOnInit(): void { this.applyIncomingTheme(); this.syncThemeFromDocument(); this.boardTheme = this.resolveStoredBoardTheme(); - this.startDummyTimers(); + this.startClock(); this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((paramMap) => { const id = paramMap.get('gameId'); @@ -56,8 +50,6 @@ export class GameComponent implements OnInit, OnDestroy { return; } - this.activeGameId = id; - this.restoreTimers(id); this.facade.setGameId(id); this.syncExportValue(); }); @@ -67,8 +59,6 @@ export class GameComponent implements OnInit, OnDestroy { if (this.timerIntervalId !== null) { window.clearInterval(this.timerIntervalId); } - - this.persistTimers(this.resolveCurrentTurn()); } private syncThemeFromDocument(): void { @@ -122,40 +112,42 @@ export class GameComponent implements OnInit, OnDestroy { }); } - formatTimer(totalSeconds: number): string { - const safeSeconds = Math.max(0, totalSeconds); - const minutes = Math.floor(safeSeconds / 60) - .toString() - .padStart(2, '0'); - const seconds = (safeSeconds % 60).toString().padStart(2, '0'); + formatTimer(ms: number | null): string { + if (ms === null) { + return '--:--'; + } + if (ms < 0) { + return '—'; + } + const totalSeconds = Math.floor(ms / 1000); + const minutes = Math.floor(totalSeconds / 60).toString().padStart(2, '0'); + const seconds = (totalSeconds % 60).toString().padStart(2, '0'); return `${minutes}:${seconds}`; } - private startDummyTimers(): void { + private startClock(): void { if (this.timerIntervalId !== null) { return; } - - this.timerIntervalId = window.setInterval(() => { - this.tickDummyTimers(); - this.syncExportValue(); - }, 1000); + this.timerIntervalId = window.setInterval(() => this.tickClock(), 200); } - private tickDummyTimers(): void { + private tickClock(): void { const state = this.facade.state; - if (!state || this.facade.loading || this.facade.isGameFinished) { + const clock = state?.clock; + if (!clock || this.facade.isGameFinished) { + this.whiteTimerMs = null; + this.blackTimerMs = null; return; } - if (state.turn === 'white') { - this.whiteTimerSeconds = Math.max(0, this.whiteTimerSeconds - 1); - this.persistTimers('white'); - return; - } - - this.blackTimerSeconds = Math.max(0, this.blackTimerSeconds - 1); - this.persistTimers('black'); + const elapsed = Math.max(0, Date.now() - this.facade.clockSyncedAt); + const activeIsWhite = state!.turn === 'white'; + this.whiteTimerMs = + clock.whiteRemainingMs < 0 ? -1 : Math.max(0, clock.whiteRemainingMs - (activeIsWhite ? elapsed : 0)); + this.blackTimerMs = + clock.blackRemainingMs < 0 ? -1 : Math.max(0, clock.blackRemainingMs - (!activeIsWhite ? elapsed : 0)); + this.syncExportValue(); } private syncExportValue(): void { @@ -168,89 +160,6 @@ export class GameComponent implements OnInit, OnDestroy { this.exportValue = this.exportType === 'fen' ? state.fen : state.pgn; } - private restoreTimers(gameId: string): void { - const fallbackTurn = this.resolveCurrentTurn(); - const rawSnapshot = localStorage.getItem(this.getTimerStorageKey(gameId)); - if (!rawSnapshot) { - this.resetTimers(); - this.persistTimers(fallbackTurn); - return; - } - - const snapshot = this.parseSnapshot(rawSnapshot); - if (!snapshot) { - this.resetTimers(); - this.persistTimers(fallbackTurn); - return; - } - - this.applySnapshot(snapshot); - this.persistTimers(snapshot.turn); - } - - private parseSnapshot(rawSnapshot: string): TimerSnapshot | null { - try { - const parsed = JSON.parse(rawSnapshot) as Partial; - if ( - typeof parsed.whiteSeconds !== 'number' || - typeof parsed.blackSeconds !== 'number' || - (parsed.turn !== 'white' && parsed.turn !== 'black') || - typeof parsed.savedAt !== 'number' - ) { - return null; - } - - return { - whiteSeconds: Math.max(0, Math.floor(parsed.whiteSeconds)), - blackSeconds: Math.max(0, Math.floor(parsed.blackSeconds)), - turn: parsed.turn, - savedAt: parsed.savedAt - }; - } catch { - return null; - } - } - - private applySnapshot(snapshot: TimerSnapshot): void { - const elapsedSeconds = Math.max(0, Math.floor((Date.now() - snapshot.savedAt) / 1000)); - this.whiteTimerSeconds = snapshot.whiteSeconds; - this.blackTimerSeconds = snapshot.blackSeconds; - - if (snapshot.turn === 'white') { - this.whiteTimerSeconds = Math.max(0, this.whiteTimerSeconds - elapsedSeconds); - return; - } - - this.blackTimerSeconds = Math.max(0, this.blackTimerSeconds - elapsedSeconds); - } - - private persistTimers(turn: TimerTurn): void { - if (!this.activeGameId) { - return; - } - - const snapshot: TimerSnapshot = { - whiteSeconds: this.whiteTimerSeconds, - blackSeconds: this.blackTimerSeconds, - turn, - savedAt: Date.now() - }; - localStorage.setItem(this.getTimerStorageKey(this.activeGameId), JSON.stringify(snapshot)); - } - - private resolveCurrentTurn(): TimerTurn { - return this.facade.state?.turn ?? 'white'; - } - - private resetTimers(): void { - this.whiteTimerSeconds = GameComponent.TIMER_START_SECONDS; - this.blackTimerSeconds = GameComponent.TIMER_START_SECONDS; - } - - private getTimerStorageKey(gameId: string): string { - return `nowchess.timer.${gameId}`; - } - private resolveStoredBoardTheme(): BoardTheme { const stored = localStorage.getItem(GameComponent.BOARD_THEME_STORAGE_KEY); return stored === 'classic' ? 'classic' : 'arabian'; diff --git a/src/app/pages/game/game.facade.ts b/src/app/pages/game/game.facade.ts index d31ee04..90f1ca8 100644 --- a/src/app/pages/game/game.facade.ts +++ b/src/app/pages/game/game.facade.ts @@ -13,6 +13,7 @@ import { GameStreamService } from '../../services/game-stream.service'; export class GameFacade implements OnDestroy { gameId = ''; game: GameFull | null = null; + clockSyncedAt = 0; errorMessage = ''; moveInput = ''; fenInput = ''; @@ -119,6 +120,7 @@ export class GameFacade implements OnDestroy { next: (state) => { if (this.game) { this.game = { ...this.game, state }; + this.clockSyncedAt = Date.now(); this.updateGameCompletion(); } this.moveInput = ''; @@ -207,6 +209,7 @@ export class GameFacade implements OnDestroy { .subscribe({ next: (game) => { this.game = game; + this.clockSyncedAt = Date.now(); this.loading = false; this.updateGameCompletion(); this.startStreaming(); @@ -232,6 +235,7 @@ export class GameFacade implements OnDestroy { private applyStreamEvent(event: GameStreamEvent): void { if (event.type === 'gameFull') { this.game = event.game; + this.clockSyncedAt = Date.now(); this.boardSelection = this.boardSelectionService.clearSelection(); this.updateGameCompletion(); this.tryMakeBotMove(); @@ -241,6 +245,7 @@ export class GameFacade implements OnDestroy { if (event.type === 'gameState' && this.game) { const moveCountBefore = this.game.state.moves.length; this.game = { ...this.game, state: event.state }; + this.clockSyncedAt = Date.now(); this.updateGameCompletion(); if (event.state.moves.length !== moveCountBefore) { this.boardSelection = this.boardSelectionService.clearSelection();