Loading game state...
} @else if (facade.state) { + @if (facade.isGameFinished && facade.gameCompletionMessage) { +{{ facade.gameCompletionMessage }}
++ Start a new game +
+Click your piece to highlight legal targets.
-
+
+
+
+ Welcome to NowChess
Pick a mode to begin.
Enter the game ID:
+{{ errorMessage }}
} diff --git a/src/app/pages/welcome/welcome.component.ts b/src/app/pages/welcome/welcome.component.ts index 0b64679..3f3c149 100644 --- a/src/app/pages/welcome/welcome.component.ts +++ b/src/app/pages/welcome/welcome.component.ts @@ -1,5 +1,6 @@ import { CommonModule } from '@angular/common'; import { Component } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { Router } from '@angular/router'; import { finalize } from 'rxjs'; import { getErrorMessage } from '../../core/http/error-message.util'; @@ -8,18 +9,31 @@ import { GameApiService } from '../../services/game-api.service'; @Component({ selector: 'app-welcome', standalone: true, - imports: [CommonModule], + imports: [CommonModule, FormsModule], templateUrl: './welcome.component.html', styleUrl: './welcome.component.css' }) export class WelcomeComponent { creating = false; errorMessage = ''; + showDifficultySelector = false; + showJoinGameForm = false; + gameIdInput = ''; + joiningGame = false; constructor( private readonly router: Router, private readonly gameApi: GameApiService - ) {} + ) { + this.initTheme(); + } + + private initTheme(): void { + const savedTheme = localStorage.getItem('theme'); + if (savedTheme === 'dark') { + document.documentElement.setAttribute('data-theme', 'dark'); + } + } startOneVsOne(): void { if (this.creating) { @@ -41,4 +55,83 @@ export class WelcomeComponent { } }); } + + startVsBot(difficulty: 'easy' | 'medium' | 'hard'): void { + if (this.creating) { + return; + } + + this.errorMessage = ''; + this.creating = true; + this.showDifficultySelector = false; + + this.gameApi + .createGameVsBot(difficulty) + .pipe(finalize(() => (this.creating = false))) + .subscribe({ + next: (game) => { + void this.router.navigate(['/game', game.gameId]); + }, + error: (error) => { + this.errorMessage = getErrorMessage(error, 'Unable to create a game against bot.'); + } + }); + } + + toggleDifficultySelector(): void { + this.showDifficultySelector = !this.showDifficultySelector; + this.showJoinGameForm = false; + this.errorMessage = ''; + } + + toggleJoinGameForm(): void { + this.showJoinGameForm = !this.showJoinGameForm; + this.showDifficultySelector = false; + this.errorMessage = ''; + this.gameIdInput = ''; + } + + joinGame(): void { + if (this.joiningGame || !this.gameIdInput.trim()) { + return; + } + + this.errorMessage = ''; + this.joiningGame = true; + + this.gameApi + .getGame(this.gameIdInput.trim()) + .pipe(finalize(() => (this.joiningGame = false))) + .subscribe({ + next: (game) => { + void this.router.navigate(['/game', game.gameId]); + }, + error: (error) => { + this.errorMessage = getErrorMessage(error, 'Unable to find or join the game.'); + } + }); + } + + clearJoinGameForm(): void { + this.showJoinGameForm = false; + this.gameIdInput = ''; + this.errorMessage = ''; + } + + toggleDarkMode(): void { + const htmlElement = document.documentElement; + const isDarkMode = htmlElement.getAttribute('data-theme') === 'dark'; + + if (isDarkMode) { + htmlElement.removeAttribute('data-theme'); + localStorage.removeItem('theme'); + } else { + htmlElement.setAttribute('data-theme', 'dark'); + localStorage.setItem('theme', 'dark'); + } + } + + isDarkMode(): boolean { + return document.documentElement.getAttribute('data-theme') === 'dark'; + } } diff --git a/src/app/services/board-selection.service.ts b/src/app/services/board-selection.service.ts new file mode 100644 index 0000000..54de571 --- /dev/null +++ b/src/app/services/board-selection.service.ts @@ -0,0 +1,64 @@ +import { Injectable, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { GameApiService } from './game-api.service'; +import { getPieceAtSquare, isPieceColor } from '../core/chess/fen.utils'; +import { GameState, LegalMove } from '../models/game.models'; + +export interface BoardSelection { + selectedSquare: string | null; + highlightedSquares: string[]; + selectedSquareMoves: LegalMove[]; +} + +@Injectable({ providedIn: 'root' }) +export class BoardSelectionService { + private readonly gameApi = inject(GameApiService); + private readonly destroyRef = inject(DestroyRef); + + handleSquareSelection( + square: string, + gameId: string, + state: GameState | null, + currentSelection: BoardSelection, + onMovesLoaded: (moves: LegalMove[]) => void, + onError: (error: string) => void + ): BoardSelection { + if (!state) { + return currentSelection; + } + + // If clicking on a highlighted square, it's a move + if (currentSelection.selectedSquare && currentSelection.highlightedSquares.includes(square)) { + const selectedMove = currentSelection.selectedSquareMoves.find((move) => move.to === square); + if (selectedMove) { + return { selectedSquare: null, highlightedSquares: [], selectedSquareMoves: [] }; + } + return currentSelection; + } + + // Check if square has a piece of the correct color + const piece = getPieceAtSquare(state.fen, square); + if (!piece || !isPieceColor(piece, state.turn)) { + return { selectedSquare: null, highlightedSquares: [], selectedSquareMoves: [] }; + } + + // Load legal moves for this square + this.gameApi + .getLegalMoves(gameId, square) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (response) => { + onMovesLoaded(response.moves); + }, + error: () => { + onError('Could not load legal moves for selected square.'); + } + }); + + return { selectedSquare: square, highlightedSquares: [], selectedSquareMoves: [] }; + } + + clearSelection(): BoardSelection { + return { selectedSquare: null, highlightedSquares: [], selectedSquareMoves: [] }; + } +} diff --git a/src/app/services/bot-move.service.ts b/src/app/services/bot-move.service.ts new file mode 100644 index 0000000..e625d39 --- /dev/null +++ b/src/app/services/bot-move.service.ts @@ -0,0 +1,78 @@ +import { Injectable, DestroyRef, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { delay, Subscription } from 'rxjs'; +import { GameApiService } from './game-api.service'; +import { getErrorMessage } from '../core/http/error-message.util'; +import { GameFull, GameState } from '../models/game.models'; + +@Injectable({ providedIn: 'root' }) +export class BotMoveService { + private readonly gameApi = inject(GameApiService); + private readonly destroyRef = inject(DestroyRef); + private botMoveSubscription: Subscription | null = null; + + private isBotPlayer(playerId: string): boolean { + return playerId.startsWith('bot-'); + } + + isPlayingAgainstBot(game: GameFull | null): boolean { + if (!game) { + return false; + } + return this.isBotPlayer(game.white.id) || this.isBotPlayer(game.black.id); + } + + isCurrentPlayerBot(game: GameFull | null, state: GameState | null): boolean { + if (!game || !state) { + return false; + } + const currentPlayer = state.turn === 'white' ? game.white : game.black; + return this.isBotPlayer(currentPlayer.id); + } + + tryMakeBotMove( + gameId: string, + game: GameFull | null, + state: GameState | null, + onSuccess: (updatedState: GameState) => void, + onError: (error: string) => void + ): void { + if (!this.isPlayingAgainstBot(game) || !this.isCurrentPlayerBot(game, state) || !state) { + return; + } + + this.botMoveSubscription?.unsubscribe(); + this.botMoveSubscription = this.gameApi + .getLegalMoves(gameId) + .pipe( + delay(1000), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe({ + next: (response) => { + if (response.moves.length === 0) { + return; + } + const botMove = response.moves[Math.floor(Math.random() * response.moves.length)]; + this.gameApi + .makeMove(gameId, botMove.uci) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe({ + next: (updatedState) => { + onSuccess(updatedState); + }, + error: (error) => { + onError(getErrorMessage(error, 'Bot move failed.')); + } + }); + }, + error: () => { + onError('Could not get legal moves for bot move.'); + } + }); + } + + cleanup(): void { + this.botMoveSubscription?.unsubscribe(); + } +} diff --git a/src/app/services/game-api.service.ts b/src/app/services/game-api.service.ts index 5508da3..c362f58 100644 --- a/src/app/services/game-api.service.ts +++ b/src/app/services/game-api.service.ts @@ -1,32 +1,54 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { HttpClient, HttpParams } from '@angular/common/http'; import { Observable } from 'rxjs'; import { environment } from '../../environments/environment'; import { - ErrorEvent, GameFull, GameState, GameStreamEvent, - LegalMovesResponse + LegalMovesResponse, + PlayerInfo } from '../models/game.models'; +import { StreamHandlerService } from './stream-handler.service'; @Injectable({ providedIn: 'root' }) export class GameApiService { private readonly apiBase = environment.apiBaseUrl; private readonly wsBase = environment.wsBaseUrl; + private readonly apiPath = environment.apiPath; + private readonly streamHandler = inject(StreamHandlerService); constructor(private readonly http: HttpClient) {} createGame(): Observable