feat: 1vs BOT
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { DestroyRef, Injectable, OnDestroy, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { interval, startWith, Subscription, switchMap } from 'rxjs';
|
||||
import { interval, startWith, Subscription, switchMap, delay } from 'rxjs';
|
||||
import { getPieceAtSquare, isPieceColor } from '../../core/chess/fen.utils';
|
||||
import { getErrorMessage } from '../../core/http/error-message.util';
|
||||
import { GameFull, GameState, GameStreamEvent, LegalMove } from '../../models/game.models';
|
||||
@@ -25,16 +25,37 @@ export class GameFacade implements OnDestroy {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private streamSubscription: Subscription | null = null;
|
||||
private pollSubscription: Subscription | null = null;
|
||||
private botMoveSubscription: Subscription | null = null;
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.streamSubscription?.unsubscribe();
|
||||
this.pollSubscription?.unsubscribe();
|
||||
this.botMoveSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
get state(): GameState | null {
|
||||
return this.game?.state ?? null;
|
||||
}
|
||||
|
||||
private isBotPlayer(playerId: string): boolean {
|
||||
return playerId.startsWith('bot-');
|
||||
}
|
||||
|
||||
private isPlayingAgainstBot(): boolean {
|
||||
if (!this.game) {
|
||||
return false;
|
||||
}
|
||||
return this.isBotPlayer(this.game.white.id) || this.isBotPlayer(this.game.black.id);
|
||||
}
|
||||
|
||||
private isCurrentPlayerBot(): boolean {
|
||||
if (!this.game || !this.state) {
|
||||
return false;
|
||||
}
|
||||
const currentPlayer = this.state.turn === 'white' ? this.game.white : this.game.black;
|
||||
return this.isBotPlayer(currentPlayer.id);
|
||||
}
|
||||
|
||||
setGameId(gameId: string): void {
|
||||
this.gameId = gameId;
|
||||
this.loadGame();
|
||||
@@ -94,6 +115,7 @@ export class GameFacade implements OnDestroy {
|
||||
}
|
||||
this.moveInput = '';
|
||||
this.clearSelection();
|
||||
this.tryMakeBotMove();
|
||||
},
|
||||
error: (error) => {
|
||||
this.errorMessage = getErrorMessage(error, 'Move rejected.');
|
||||
@@ -153,6 +175,7 @@ export class GameFacade implements OnDestroy {
|
||||
this.clearSelection();
|
||||
this.streamSubscription?.unsubscribe();
|
||||
this.pollSubscription?.unsubscribe();
|
||||
this.botMoveSubscription?.unsubscribe();
|
||||
this.pollSubscription = null;
|
||||
|
||||
this.gameApi
|
||||
@@ -163,6 +186,7 @@ export class GameFacade implements OnDestroy {
|
||||
this.game = game;
|
||||
this.loading = false;
|
||||
this.startStream();
|
||||
this.tryMakeBotMove();
|
||||
},
|
||||
error: (error) => {
|
||||
this.errorMessage = getErrorMessage(error, `Could not load game ${this.gameId}.`);
|
||||
@@ -205,6 +229,7 @@ export class GameFacade implements OnDestroy {
|
||||
this.game = game;
|
||||
if (previousMoves !== game.state.moves.join(',')) {
|
||||
this.clearSelection();
|
||||
this.tryMakeBotMove();
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -214,6 +239,7 @@ export class GameFacade implements OnDestroy {
|
||||
if (event.type === 'gameFull') {
|
||||
this.game = event.game;
|
||||
this.clearSelection();
|
||||
this.tryMakeBotMove();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -222,6 +248,7 @@ export class GameFacade implements OnDestroy {
|
||||
this.game = { ...this.game, state: event.state };
|
||||
if (event.state.moves.length !== moveCountBefore) {
|
||||
this.clearSelection();
|
||||
this.tryMakeBotMove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -231,6 +258,45 @@ export class GameFacade implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private tryMakeBotMove(): void {
|
||||
if (!this.isPlayingAgainstBot() || !this.isCurrentPlayerBot() || !this.state) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.botMoveSubscription?.unsubscribe();
|
||||
this.botMoveSubscription = this.gameApi
|
||||
.getLegalMoves(this.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(this.gameId, botMove.uci)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (state) => {
|
||||
if (this.game) {
|
||||
this.game = { ...this.game, state };
|
||||
}
|
||||
this.clearSelection();
|
||||
},
|
||||
error: (error) => {
|
||||
this.errorMessage = getErrorMessage(error, 'Bot move failed.');
|
||||
}
|
||||
});
|
||||
},
|
||||
error: () => {
|
||||
this.errorMessage = 'Could not get legal moves for bot move.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private clearSelection(): void {
|
||||
this.selectedSquare = null;
|
||||
this.selectedSquareMoves = [];
|
||||
|
||||
@@ -142,3 +142,69 @@ p {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
.difficulty-selector {
|
||||
grid-column: 1 / -1;
|
||||
background: var(--color-bg-card);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: var(--size-lg-padding);
|
||||
margin: var(--size-md) 0;
|
||||
}
|
||||
|
||||
.difficulty-selector p {
|
||||
margin: 0 0 var(--size-md);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.difficulty-buttons {
|
||||
display: flex;
|
||||
gap: var(--size-md-gap);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.difficulty-btn {
|
||||
flex: 1;
|
||||
min-width: 80px;
|
||||
padding: var(--size-md-padding);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.difficulty-btn.easy {
|
||||
background: var(--color-success-light, #d4edda);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.difficulty-btn.easy:hover:enabled {
|
||||
background: var(--color-success, #28a745);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.difficulty-btn.medium {
|
||||
background: var(--color-warning-light, #fff3cd);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.difficulty-btn.medium:hover:enabled {
|
||||
background: var(--color-warning, #ffc107);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.difficulty-btn.hard {
|
||||
background: var(--color-danger-light, #f8d7da);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.difficulty-btn.hard:hover:enabled {
|
||||
background: var(--color-danger, #dc3545);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.difficulty-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,28 @@
|
||||
<p>Pick a mode to begin.</p>
|
||||
|
||||
<div class="mode-grid">
|
||||
<button type="button" class="mode mode-disabled" disabled>
|
||||
<button type="button" class="mode mode-active" (click)="toggleDifficultySelector()" [disabled]="creating">
|
||||
<span>Bot</span>
|
||||
<small>Coming soon</small>
|
||||
<small>{{ creating ? 'Creating game...' : 'Choose difficulty' }}</small>
|
||||
</button>
|
||||
|
||||
@if (showDifficultySelector) {
|
||||
<div class="difficulty-selector">
|
||||
<p>Select difficulty:</p>
|
||||
<div class="difficulty-buttons">
|
||||
<button type="button" class="difficulty-btn easy" (click)="startVsBot('easy')" [disabled]="creating">
|
||||
Easy
|
||||
</button>
|
||||
<button type="button" class="difficulty-btn medium" (click)="startVsBot('medium')" [disabled]="creating">
|
||||
Medium
|
||||
</button>
|
||||
<button type="button" class="difficulty-btn hard" (click)="startVsBot('hard')" [disabled]="creating">
|
||||
Hard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button type="button" class="mode mode-active" (click)="startOneVsOne()" [disabled]="creating">
|
||||
<span>1 vs 1</span>
|
||||
<small>{{ creating ? 'Creating game...' : 'Start now' }}</small>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { GameApiService } from '../../services/game-api.service';
|
||||
export class WelcomeComponent {
|
||||
creating = false;
|
||||
errorMessage = '';
|
||||
showDifficultySelector = false;
|
||||
|
||||
constructor(
|
||||
private readonly router: Router,
|
||||
@@ -41,4 +42,31 @@ 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.errorMessage = '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
GameFull,
|
||||
GameState,
|
||||
GameStreamEvent,
|
||||
LegalMovesResponse
|
||||
LegalMovesResponse,
|
||||
PlayerInfo
|
||||
} from '../models/game.models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
@@ -21,6 +22,25 @@ export class GameApiService {
|
||||
return this.http.post<GameFull>(`${this.apiBase}/api/board/game`, {});
|
||||
}
|
||||
|
||||
createGameVsBot(difficulty: 'easy' | 'medium' | 'hard' = 'medium'): Observable<GameFull> {
|
||||
const playerColor = Math.random() > 0.5 ? 'white' : 'black';
|
||||
const playerInfo: PlayerInfo = {
|
||||
id: `player-${Date.now()}`,
|
||||
displayName: 'You'
|
||||
};
|
||||
const botInfo: PlayerInfo = {
|
||||
id: `bot-${difficulty}`,
|
||||
displayName: `Bot (${difficulty})`
|
||||
};
|
||||
|
||||
const payload =
|
||||
playerColor === 'white'
|
||||
? { white: playerInfo, black: botInfo }
|
||||
: { white: botInfo, black: playerInfo };
|
||||
|
||||
return this.http.post<GameFull>(`${this.apiBase}/api/board/game`, payload);
|
||||
}
|
||||
|
||||
getGame(gameId: string): Observable<GameFull> {
|
||||
return this.http.get<GameFull>(`${this.apiBase}/api/board/game/${gameId}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user