fix: NCWF-1 401 #5

Merged
shosho996 merged 36 commits from fix/NCWF-1 into main 2026-05-12 23:11:20 +02:00
6 changed files with 239 additions and 3 deletions
Showing only changes of commit 4f76bcc7c6 - Show all commits
@@ -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;
}
}
@@ -0,0 +1,26 @@
<div class="promotion-dialog-overlay" [class.open]="isOpen">
<div class="promotion-dialog">
<div class="promotion-header">
<h3>Pawn Promotion</h3>
<button class="close-btn" (click)="close()" aria-label="Close">&times;</button>
</div>
<div class="promotion-body">
<p class="promotion-prompt">Choose a piece to promote your pawn to:</p>
<div class="promotion-options">
@for (piece of promotionPieces; track piece.type) {
<button
class="promotion-button"
[attr.data-piece]="piece.type"
(click)="selectPromotion(piece.type)"
[title]="piece.label"
>
<span class="piece-symbol">{{ piece.symbol }}</span>
<span class="piece-label">{{ piece.label }}</span>
</button>
}
</div>
</div>
</div>
</div>
@@ -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<PromotionPieceType>();
@Output() closed = new EventEmitter<void>();
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;
}
}
+6
View File
@@ -1,4 +1,10 @@
<main class="game-shell"> <main class="game-shell">
<app-promotion-dialog
[isOpen]="facade.isPromotionDialogOpen"
(promotionSelected)="facade.onPromotionSelected($event)"
(closed)="facade.onPromotionClosed()"
/>
<section class="game-card"> <section class="game-card">
<header class="mb-3"> <header class="mb-3">
<a routerLink="/" class="back-link">Back</a> <a routerLink="/" class="back-link">Back</a>
+3 -2
View File
@@ -4,13 +4,14 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { ActivatedRoute, RouterLink } from '@angular/router'; import { ActivatedRoute, RouterLink } from '@angular/router';
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component'; 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'; import { GameFacade } from './game.facade';
@Component({ @Component({
selector: 'app-game', selector: 'app-game',
standalone: true, standalone: true,
imports: [CommonModule, FormsModule, RouterLink, ChessBoardComponent, InputCardComponent], imports: [CommonModule, FormsModule, RouterLink, ChessBoardComponent, InputCardComponent, PromotionDialogComponent],
providers: [GameFacade], providers: [GameFacade],
templateUrl: './game.component.html', templateUrl: './game.component.html',
styleUrl: './game.component.css' styleUrl: './game.component.css'
+34 -1
View File
@@ -1,7 +1,7 @@
import { DestroyRef, Injectable, OnDestroy, inject } from '@angular/core'; import { DestroyRef, Injectable, OnDestroy, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { getErrorMessage } from '../../core/http/error-message.util'; 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 { GameApiService } from '../../services/game-api.service';
import { BotMoveService } from '../../services/bot-move.service'; import { BotMoveService } from '../../services/bot-move.service';
import { GameCompletionService } from '../../services/game-completion.service'; import { GameCompletionService } from '../../services/game-completion.service';
@@ -20,12 +20,14 @@ export class GameFacade implements OnDestroy {
loading = true; loading = true;
gameCompletionMessage = ''; gameCompletionMessage = '';
isGameFinished = false; isGameFinished = false;
isPromotionDialogOpen = false;
private boardSelection: BoardSelection = { private boardSelection: BoardSelection = {
selectedSquare: null, selectedSquare: null,
highlightedSquares: [], highlightedSquares: [],
selectedSquareMoves: [] selectedSquareMoves: []
}; };
private pendingPromotionMoves: LegalMove[] = [];
private readonly gameApi = inject(GameApiService); private readonly gameApi = inject(GameApiService);
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@@ -66,6 +68,15 @@ export class GameFacade implements OnDestroy {
if (this.boardSelection.selectedSquare && this.boardSelection.highlightedSquares.includes(square)) { if (this.boardSelection.selectedSquare && this.boardSelection.highlightedSquares.includes(square)) {
const selectedMove = this.boardSelection.selectedSquareMoves.find((move) => move.to === square); const selectedMove = this.boardSelection.selectedSquareMoves.find((move) => move.to === square);
if (selectedMove) { 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.moveInput = selectedMove.uci;
this.submitMove(); 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 { importFen(): void {
this.errorMessage = ''; this.errorMessage = '';
this.importService.importFen( this.importService.importFen(