diff --git a/src/app/components/chess-board/chess-board.component.css b/src/app/components/chess-board/chess-board.component.css index a86b1b1..4eec498 100644 --- a/src/app/components/chess-board/chess-board.component.css +++ b/src/app/components/chess-board/chess-board.component.css @@ -27,6 +27,20 @@ cursor: pointer; } +.square[draggable='true'] { + cursor: grab; +} + +.square.drag-source { + opacity: 0.65; + cursor: grabbing; +} + +.square.drag-over { + outline: 3px dashed var(--color-primary); + outline-offset: -4px; +} + .square.light { background-image: url('/arabian-chess/sprites/board/board_square_white.png'); } diff --git a/src/app/components/chess-board/chess-board.component.html b/src/app/components/chess-board/chess-board.component.html index 7bc8e4a..3644300 100644 --- a/src/app/components/chess-board/chess-board.component.html +++ b/src/app/components/chess-board/chess-board.component.html @@ -8,10 +8,19 @@ [class.dark]="!square.isLight" [class.selected]="isSelected(square)" [class.highlighted]="isHighlighted(square)" + [class.drag-source]="isDraggingSource(square)" + [class.drag-over]="isDragOver(square)" [attr.data-square]="square.coordinate" (click)="onSquareClick(square)" + (dragover)="onSquareDragOver($event, square)" + (drop)="onSquareDrop($event, square)" > - + } diff --git a/src/app/components/chess-board/chess-board.component.ts b/src/app/components/chess-board/chess-board.component.ts index 179ad63..dc38701 100644 --- a/src/app/components/chess-board/chess-board.component.ts +++ b/src/app/components/chess-board/chess-board.component.ts @@ -22,6 +22,9 @@ export class ChessBoardComponent implements OnChanges { squares: BoardSquare[] = []; private highlightedSquareSet = new Set(); + private draggingFromSquare: string | null = null; + private dragOverSquare: string | null = null; + private suppressNextClick = false; ngOnChanges(changes: SimpleChanges): void { if (changes['fen']) { @@ -38,9 +41,61 @@ export class ChessBoardComponent implements OnChanges { } onSquareClick(square: BoardSquare): void { + if (this.suppressNextClick) { + this.suppressNextClick = false; + return; + } + this.squareSelected.emit(square.coordinate); } + onPieceDragStart(event: DragEvent, square: BoardSquare): void { + if (!square.pieceCode) { + event.preventDefault(); + return; + } + + this.draggingFromSquare = square.coordinate; + if (event.dataTransfer) { + event.dataTransfer.setData('text/plain', square.coordinate); + event.dataTransfer.effectAllowed = 'move'; + } + this.squareSelected.emit(square.coordinate); + } + + onSquareDragOver(event: DragEvent, square: BoardSquare): void { + if (!this.draggingFromSquare) { + return; + } + + event.preventDefault(); + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'move'; + } + this.dragOverSquare = square.coordinate === this.draggingFromSquare ? null : square.coordinate; + } + + onSquareDrop(event: DragEvent, square: BoardSquare): void { + event.preventDefault(); + if (!this.draggingFromSquare) { + return; + } + + const fromSquare = this.draggingFromSquare; + this.clearDragState(); + + if (fromSquare === square.coordinate) { + return; + } + + this.suppressNextClick = true; + this.squareSelected.emit(square.coordinate); + } + + onSquareDragEnd(): void { + this.clearDragState(); + } + isSelected(square: BoardSquare): boolean { return this.selectedSquare === square.coordinate; } @@ -49,6 +104,14 @@ export class ChessBoardComponent implements OnChanges { return this.highlightedSquareSet.has(square.coordinate); } + isDraggingSource(square: BoardSquare): boolean { + return this.draggingFromSquare === square.coordinate; + } + + isDragOver(square: BoardSquare): boolean { + return this.dragOverSquare === square.coordinate; + } + private buildSquares(fen: string): BoardSquare[] { const placement = fen.split(' ')[0] ?? ''; const rows = placement.split('/'); @@ -87,4 +150,9 @@ export class ChessBoardComponent implements OnChanges { pieceCode }; } + + private clearDragState(): void { + this.draggingFromSquare = null; + this.dragOverSquare = null; + } } diff --git a/src/app/components/chess-piece/chess-piece.component.css b/src/app/components/chess-piece/chess-piece.component.css index 4a364f3..f876000 100644 --- a/src/app/components/chess-piece/chess-piece.component.css +++ b/src/app/components/chess-piece/chess-piece.component.css @@ -3,7 +3,15 @@ height: clamp(40px, 8cqh, 120px); display: block; object-fit: contain; - pointer-events: none; + pointer-events: auto; +} + +.piece[draggable='true'] { + cursor: grab; +} + +.piece[draggable='true']:active { + cursor: grabbing; } @media (max-width: 991px) { diff --git a/src/app/components/chess-piece/chess-piece.component.html b/src/app/components/chess-piece/chess-piece.component.html index 42766a1..7d085f4 100644 --- a/src/app/components/chess-piece/chess-piece.component.html +++ b/src/app/components/chess-piece/chess-piece.component.html @@ -1,3 +1,10 @@ @if (pieceCode) { - + } diff --git a/src/app/components/chess-piece/chess-piece.component.ts b/src/app/components/chess-piece/chess-piece.component.ts index 2804978..bdfb755 100644 --- a/src/app/components/chess-piece/chess-piece.component.ts +++ b/src/app/components/chess-piece/chess-piece.component.ts @@ -1,4 +1,4 @@ -import { Component, Input } from '@angular/core'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; @Component({ selector: 'app-chess-piece', @@ -8,6 +8,22 @@ import { Component, Input } from '@angular/core'; }) export class ChessPieceComponent { @Input({ required: true }) pieceCode: string | null = null; + @Input() draggable = false; + @Output() pieceDragStart = new EventEmitter(); + @Output() pieceDragEnd = new EventEmitter(); + + onDragStart(event: DragEvent): void { + if (!this.draggable) { + event.preventDefault(); + return; + } + + this.pieceDragStart.emit(event); + } + + onDragEnd(): void { + this.pieceDragEnd.emit(); + } get spriteUrl(): string { if (!this.pieceCode) { diff --git a/src/app/pages/game/game.component.ts b/src/app/pages/game/game.component.ts index a51e0cd..67e4e69 100644 --- a/src/app/pages/game/game.component.ts +++ b/src/app/pages/game/game.component.ts @@ -8,6 +8,15 @@ 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'; + +interface TimerSnapshot { + whiteSeconds: number; + blackSeconds: number; + turn: TimerTurn; + savedAt: number; +} + @Component({ selector: 'app-game', standalone: true, @@ -17,15 +26,17 @@ import { GameFacade } from './game.facade'; styleUrl: './game.component.css' }) export class GameComponent implements OnInit, OnDestroy { + private static readonly TIMER_START_SECONDS = 10 * 60; private readonly route = inject(ActivatedRoute); private readonly destroyRef = inject(DestroyRef); readonly facade = inject(GameFacade); - whiteTimerSeconds = 10 * 60; - blackTimerSeconds = 10 * 60; + whiteTimerSeconds = GameComponent.TIMER_START_SECONDS; + blackTimerSeconds = GameComponent.TIMER_START_SECONDS; exportType: 'fen' | 'pgn' = 'fen'; exportValue = ''; exportNotice = ''; private timerIntervalId: number | null = null; + private activeGameId = ''; ngOnInit(): void { this.startDummyTimers(); @@ -38,6 +49,8 @@ export class GameComponent implements OnInit, OnDestroy { return; } + this.activeGameId = id; + this.restoreTimers(id); this.facade.setGameId(id); this.syncExportValue(); }); @@ -47,6 +60,8 @@ export class GameComponent implements OnInit, OnDestroy { if (this.timerIntervalId !== null) { window.clearInterval(this.timerIntervalId); } + + this.persistTimers(this.resolveCurrentTurn()); } setExportType(type: 'fen' | 'pgn'): void { @@ -105,10 +120,12 @@ export class GameComponent implements OnInit, OnDestroy { 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'); } private syncExportValue(): void { @@ -120,4 +137,87 @@ 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; + 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}`; + } }