-
+
+ @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();