diff --git a/src/app/components/promotion-dialog/promotion-dialog.component.css b/src/app/components/promotion-dialog/promotion-dialog.component.css new file mode 100644 index 0000000..578f349 --- /dev/null +++ b/src/app/components/promotion-dialog/promotion-dialog.component.css @@ -0,0 +1,131 @@ +.promotion-dialog-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s, visibility 0.2s; + z-index: 1000; + + &.open { + opacity: 1; + visibility: visible; + } +} + +.promotion-dialog { + background: white; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); + max-width: 400px; + width: 90%; + animation: slideUp 0.3s ease; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.promotion-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px; + border-bottom: 1px solid #e0e0e0; + + h3 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #333; + } + + .close-btn { + background: none; + border: none; + font-size: 24px; + cursor: pointer; + color: #999; + padding: 0; + width: 30px; + height: 30px; + display: flex; + align-items: center; + justify-content: center; + transition: color 0.2s; + + &:hover { + color: #333; + } + } +} + +.promotion-body { + padding: 20px; +} + +.promotion-prompt { + margin: 0 0 20px 0; + text-align: center; + color: #666; + font-size: 14px; +} + +.promotion-options { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} + +.promotion-button { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + padding: 16px; + background: #f5f5f5; + border: 2px solid #ddd; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + font-family: inherit; + + &:hover { + background: #e8e8e8; + border-color: #999; + transform: translateY(-2px); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + } + + &:active { + transform: translateY(0); + box-shadow: none; + } + + .piece-symbol { + font-size: 32px; + line-height: 1; + } + + .piece-label { + font-size: 12px; + font-weight: 500; + color: #666; + text-transform: uppercase; + letter-spacing: 0.5px; + } +} diff --git a/src/app/components/promotion-dialog/promotion-dialog.component.html b/src/app/components/promotion-dialog/promotion-dialog.component.html new file mode 100644 index 0000000..7cdb899 --- /dev/null +++ b/src/app/components/promotion-dialog/promotion-dialog.component.html @@ -0,0 +1,26 @@ +
+
+
+

Pawn Promotion

+ +
+ +
+

Choose a piece to promote your pawn to:

+ +
+ @for (piece of promotionPieces; track piece.type) { + + } +
+
+
+
diff --git a/src/app/components/promotion-dialog/promotion-dialog.component.ts b/src/app/components/promotion-dialog/promotion-dialog.component.ts new file mode 100644 index 0000000..279339a --- /dev/null +++ b/src/app/components/promotion-dialog/promotion-dialog.component.ts @@ -0,0 +1,39 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +type PromotionPieceType = 'queen' | 'rook' | 'bishop' | 'knight'; +interface PromotionPieceOption { + type: PromotionPieceType; + label: string; + symbol: string; +} + +@Component({ + selector: 'app-promotion-dialog', + standalone: true, + imports: [CommonModule], + templateUrl: './promotion-dialog.component.html', + styleUrl: './promotion-dialog.component.css' +}) +export class PromotionDialogComponent { + @Input() isOpen = false; + @Output() promotionSelected = new EventEmitter(); + @Output() closed = new EventEmitter(); + + promotionPieces: PromotionPieceOption[] = [ + { type: 'queen', label: 'Queen', symbol: '♕' }, + { type: 'rook', label: 'Rook', symbol: '♖' }, + { type: 'bishop', label: 'Bishop', symbol: '♗' }, + { type: 'knight', label: 'Knight', symbol: '♘' } + ]; + + selectPromotion(type: PromotionPieceType): void { + this.promotionSelected.emit(type); + this.isOpen = false; + } + + close(): void { + this.closed.emit(); + this.isOpen = false; + } +} diff --git a/src/app/pages/game/game.component.html b/src/app/pages/game/game.component.html index 8f2b10a..04c93b2 100644 --- a/src/app/pages/game/game.component.html +++ b/src/app/pages/game/game.component.html @@ -1,4 +1,10 @@
+ +
Back diff --git a/src/app/pages/game/game.component.ts b/src/app/pages/game/game.component.ts index f7e4271..307aed4 100644 --- a/src/app/pages/game/game.component.ts +++ b/src/app/pages/game/game.component.ts @@ -4,13 +4,14 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { FormsModule } from '@angular/forms'; import { ActivatedRoute, RouterLink } from '@angular/router'; import { ChessBoardComponent } from '../../components/chess-board/chess-board.component'; - import { InputCardComponent } from '../../components/input-card/input-card.component'; +import { InputCardComponent } from '../../components/input-card/input-card.component'; +import { PromotionDialogComponent } from '../../components/promotion-dialog/promotion-dialog.component'; import { GameFacade } from './game.facade'; @Component({ selector: 'app-game', standalone: true, - imports: [CommonModule, FormsModule, RouterLink, ChessBoardComponent, InputCardComponent], + imports: [CommonModule, FormsModule, RouterLink, ChessBoardComponent, InputCardComponent, PromotionDialogComponent], providers: [GameFacade], templateUrl: './game.component.html', styleUrl: './game.component.css' diff --git a/src/app/pages/game/game.facade.ts b/src/app/pages/game/game.facade.ts index 78b1729..d31ee04 100644 --- a/src/app/pages/game/game.facade.ts +++ b/src/app/pages/game/game.facade.ts @@ -1,7 +1,7 @@ import { DestroyRef, Injectable, OnDestroy, inject } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { getErrorMessage } from '../../core/http/error-message.util'; -import { GameFull, GameState, GameStreamEvent } from '../../models/game.models'; +import { GameFull, GameState, GameStreamEvent, LegalMove } from '../../models/game.models'; import { GameApiService } from '../../services/game-api.service'; import { BotMoveService } from '../../services/bot-move.service'; import { GameCompletionService } from '../../services/game-completion.service'; @@ -20,12 +20,14 @@ export class GameFacade implements OnDestroy { loading = true; gameCompletionMessage = ''; isGameFinished = false; + isPromotionDialogOpen = false; private boardSelection: BoardSelection = { selectedSquare: null, highlightedSquares: [], selectedSquareMoves: [] }; + private pendingPromotionMoves: LegalMove[] = []; private readonly gameApi = inject(GameApiService); private readonly destroyRef = inject(DestroyRef); @@ -66,6 +68,15 @@ export class GameFacade implements OnDestroy { if (this.boardSelection.selectedSquare && this.boardSelection.highlightedSquares.includes(square)) { const selectedMove = this.boardSelection.selectedSquareMoves.find((move) => move.to === square); if (selectedMove) { + // If multiple promotion outcomes exist for the target, ask player to choose one. + const promotionMoves = this.boardSelection.selectedSquareMoves.filter( + (move) => move.to === square && !!move.promotion + ); + if (promotionMoves.length > 0) { + this.pendingPromotionMoves = promotionMoves; + this.isPromotionDialogOpen = true; + return; + } this.moveInput = selectedMove.uci; this.submitMove(); } @@ -134,6 +145,28 @@ export class GameFacade implements OnDestroy { }); } + onPromotionSelected(promotionPiece: 'queen' | 'rook' | 'bishop' | 'knight'): void { + const selectedPromotionMove = this.pendingPromotionMoves.find((move) => move.promotion === promotionPiece); + if (!selectedPromotionMove) { + this.errorMessage = 'Selected promotion move is unavailable.'; + this.isPromotionDialogOpen = false; + this.pendingPromotionMoves = []; + return; + } + + this.moveInput = selectedPromotionMove.uci; + this.isPromotionDialogOpen = false; + this.boardSelection = this.boardSelectionService.clearSelection(); + this.pendingPromotionMoves = []; + this.submitMove(); + } + + onPromotionClosed(): void { + this.isPromotionDialogOpen = false; + this.boardSelection = this.boardSelectionService.clearSelection(); + this.pendingPromotionMoves = []; + } + importFen(): void { this.errorMessage = ''; this.importService.importFen(