fix: timer now in sync with backend
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -20,30 +20,24 @@
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@if (facade.isGameFinished && facade.gameCompletionMessage) {
|
||||
<div class="game-completion-alert alert alert-success mb-3">
|
||||
<h2 class="completion-title">{{ facade.gameCompletionMessage }}</h2>
|
||||
<p class="completion-subtitle mb-0">
|
||||
<a routerLink="/" class="completion-link">Start a new game</a>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
<div class="container-fluid">
|
||||
<div class="row g-3">
|
||||
<!-- Left Sidebar - Dummy Timers -->
|
||||
<!-- Left Sidebar - Timers -->
|
||||
@if (hasTimer) {
|
||||
<div class="col-lg-3 col-md-6 col-12 order-lg-1 order-2">
|
||||
<section class="timer-card">
|
||||
<h2>Timers</h2>
|
||||
<div class="player-timer" [class.active-timer]="facade.state.turn === 'white'">
|
||||
<p class="timer-label">White</p>
|
||||
<p class="timer-value">{{ formatTimer(whiteTimerSeconds) }}</p>
|
||||
<p class="timer-value">{{ formatTimer(whiteTimerMs) }}</p>
|
||||
</div>
|
||||
<div class="player-timer" [class.active-timer]="facade.state.turn === 'black'">
|
||||
<p class="timer-label">Black</p>
|
||||
<p class="timer-value">{{ formatTimer(blackTimerSeconds) }}</p>
|
||||
<p class="timer-value">{{ formatTimer(blackTimerMs) }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Center - Chess Board -->
|
||||
<div class="col-lg-6 col-md-12 col-12 order-lg-2 order-1">
|
||||
|
||||
@@ -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<TimerSnapshot>;
|
||||
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';
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user