feat(blackjack): add action indicators and loading states

This commit is contained in:
Jan-Marlon Leibl 2025-03-27 15:44:38 +01:00
parent d2b22b561d
commit acdbea5a99
Signed by: jleibl
GPG key ID: 300B2F906DC6F1D5
7 changed files with 208 additions and 27 deletions

View file

@ -5,10 +5,22 @@
<div class="lg:col-span-3 space-y-6 flex flex-col gap-4"> <div class="lg:col-span-3 space-y-6 flex flex-col gap-4">
<app-dealer-hand [cards]="dealerCards()"></app-dealer-hand> <app-dealer-hand [cards]="dealerCards()"></app-dealer-hand>
<app-player-hand [cards]="playerCards()"></app-player-hand> <app-player-hand [cards]="playerCards()"></app-player-hand>
<!-- Action message indicator -->
@if (isActionInProgress()) {
<div class="flex justify-center">
<div class="card p-4 flex items-center gap-3 animate-pulse bg-deep-blue-light border border-deep-blue-light/50">
<div class="w-5 h-5 rounded-full border-2 border-white border-t-transparent animate-spin"></div>
<span>{{ currentAction() }}</span>
</div>
</div>
}
@if (gameInProgress()) { @if (gameInProgress()) {
<app-game-controls <app-game-controls
[playerCards]="playerCards()" [playerCards]="playerCards()"
[gameState]="gameState()" [gameState]="gameState()"
[isActionInProgress]="isActionInProgress()"
(hit)="onHit()" (hit)="onHit()"
(stand)="onStand()" (stand)="onStand()"
(doubleDown)="onDoubleDown()" (doubleDown)="onDoubleDown()"
@ -22,6 +34,7 @@
[balance]="balance()" [balance]="balance()"
[currentBet]="currentBet()" [currentBet]="currentBet()"
[gameInProgress]="gameInProgress()" [gameInProgress]="gameInProgress()"
[isActionInProgress]="isActionInProgress()"
(newGame)="onNewGame($event)" (newGame)="onNewGame($event)"
></app-game-info> ></app-game-info>
</div> </div>

View file

@ -42,6 +42,10 @@ export default class BlackjackComponent {
gameInProgress = signal(false); gameInProgress = signal(false);
gameState = signal<string>('IN_PROGRESS'); gameState = signal<string>('IN_PROGRESS');
showGameResult = signal(false); showGameResult = signal(false);
// Add loading state trackers
isActionInProgress = signal(false);
currentAction = signal<string>('');
constructor() { constructor() {
this.refreshUserBalance(); this.refreshUserBalance();
@ -89,64 +93,85 @@ export default class BlackjackComponent {
} }
onNewGame(bet: number): void { onNewGame(bet: number): void {
this.isActionInProgress.set(true);
this.currentAction.set('Spiel wird gestartet...');
this.blackjackService.startGame(bet).subscribe({ this.blackjackService.startGame(bet).subscribe({
next: (game) => { next: (game) => {
this.updateGameState(game); this.updateGameState(game);
this.refreshUserBalance(); this.refreshUserBalance();
this.isActionInProgress.set(false);
}, },
error: (error) => { error: (error) => {
console.error('Failed to start game:', error); console.error('Failed to start game:', error);
this.isActionInProgress.set(false);
}, },
}); });
} }
onHit(): void { onHit(): void {
if (!this.currentGameId()) return; if (!this.currentGameId() || this.isActionInProgress()) return;
this.isActionInProgress.set(true);
this.currentAction.set('Karte wird gezogen...');
this.blackjackService.hit(this.currentGameId()!).subscribe({ this.blackjackService.hit(this.currentGameId()!).subscribe({
next: (game) => { next: (game) => {
this.updateGameState(game); this.updateGameState(game);
this.isActionInProgress.set(false);
}, },
error: (error) => { error: (error) => {
console.error('Failed to hit:', error); console.error('Failed to hit:', error);
this.handleGameError(error); this.handleGameError(error);
this.isActionInProgress.set(false);
}, },
}); });
} }
onStand(): void { onStand(): void {
if (!this.currentGameId()) return; if (!this.currentGameId() || this.isActionInProgress()) return;
if (this.gameState() !== 'IN_PROGRESS') { if (this.gameState() !== 'IN_PROGRESS') {
console.log('Cannot stand: game is not in progress'); console.log('Cannot stand: game is not in progress');
return; return;
} }
this.isActionInProgress.set(true);
this.currentAction.set('Dealer zieht Karten...');
this.blackjackService.stand(this.currentGameId()!).subscribe({ this.blackjackService.stand(this.currentGameId()!).subscribe({
next: (game) => { next: (game) => {
this.updateGameState(game); this.updateGameState(game);
this.isActionInProgress.set(false);
}, },
error: (error) => { error: (error) => {
console.error('Failed to stand:', error); console.error('Failed to stand:', error);
this.handleGameError(error); this.handleGameError(error);
this.isActionInProgress.set(false);
}, },
}); });
} }
onDoubleDown(): void { onDoubleDown(): void {
if (!this.currentGameId()) return; if (!this.currentGameId() || this.isActionInProgress()) return;
if (this.gameState() !== 'IN_PROGRESS' || this.playerCards().length !== 2) { if (this.gameState() !== 'IN_PROGRESS' || this.playerCards().length !== 2) {
console.log('Cannot double down: game is not in progress or more than 2 cards');
return; return;
} }
this.isActionInProgress.set(true);
this.currentAction.set('Einsatz wird verdoppelt...');
this.blackjackService.doubleDown(this.currentGameId()!).subscribe({ this.blackjackService.doubleDown(this.currentGameId()!).subscribe({
next: (game) => { next: (game) => {
this.updateGameState(game); this.updateGameState(game);
this.isActionInProgress.set(false);
}, },
error: (error) => { error: (error) => {
console.error('Failed to double down:', error); console.error('Failed to double down:', error);
this.handleGameError(error); this.handleGameError(error);
this.isActionInProgress.set(false);
}, },
}); });
} }

View file

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { PlayingCardComponent } from '../playing-card/playing-card.component'; import { PlayingCardComponent } from '../playing-card/playing-card.component';
import { Card } from '../../models/blackjack.model'; import { Card } from '../../models/blackjack.model';
@ -13,11 +13,12 @@ import { Card } from '../../models/blackjack.model';
<div class="card p-6 !bg-accent-red"> <div class="card p-6 !bg-accent-red">
<div class="flex justify-center gap-4 min-h-[160px] p-4 border-2 border-red-400 rounded-lg"> <div class="flex justify-center gap-4 min-h-[160px] p-4 border-2 border-red-400 rounded-lg">
@if (cards.length > 0) { @if (cards.length > 0) {
@for (card of cards; track card) { @for (card of cardsWithState; track card.id) {
<app-playing-card <app-playing-card
[rank]="card.rank" [rank]="card.rank"
[suit]="card.suit" [suit]="card.suit"
[hidden]="card.hidden" [hidden]="card.hidden"
[isNew]="card.isNew"
></app-playing-card> ></app-playing-card>
} }
} @else { } @else {
@ -31,6 +32,33 @@ import { Card } from '../../models/blackjack.model';
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class DealerHandComponent { export class DealerHandComponent implements OnChanges {
@Input() cards: Card[] = []; @Input() cards: Card[] = [];
cardsWithState: (Card & { isNew: boolean; id: string })[] = [];
private lastCardCount = 0;
ngOnChanges(changes: SimpleChanges): void {
if (changes['cards']) {
this.updateCardsWithState();
}
}
private updateCardsWithState(): void {
const newCards = this.cards.length > this.lastCardCount;
this.cardsWithState = this.cards.map((card, index) => {
// Consider a card new if it's added after the initial state and is the latest card
const isNew = newCards && index >= this.lastCardCount;
return {
...card,
isNew,
// Generate a unique ID to help Angular track the cards
id: `${card.suit}-${card.rank}-${index}`
};
});
this.lastCardCount = this.cards.length;
}
} }

View file

@ -19,28 +19,48 @@ import { Card } from '../../models/blackjack.model';
<div class="flex justify-center gap-4"> <div class="flex justify-center gap-4">
<button <button
(click)="hit.emit()" (click)="hit.emit()"
class="button-primary px-8 py-4 text-lg font-medium min-w-[120px]" class="button-primary px-8 py-4 text-lg font-medium min-w-[120px] relative"
[disabled]="gameState !== 'IN_PROGRESS'" [disabled]="gameState !== 'IN_PROGRESS' || isActionInProgress"
[class.opacity-50]="isActionInProgress"
> >
Ziehen <span [class.invisible]="isActionInProgress">Ziehen</span>
@if (isActionInProgress) {
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
</div>
}
</button> </button>
<button <button
(click)="stand.emit()" (click)="stand.emit()"
class="button-primary px-8 py-4 text-lg font-medium min-w-[120px]" class="button-primary px-8 py-4 text-lg font-medium min-w-[120px] relative"
[disabled]="gameState !== 'IN_PROGRESS'" [disabled]="gameState !== 'IN_PROGRESS' || isActionInProgress"
[class.opacity-50]="isActionInProgress"
> >
Halten <span [class.invisible]="isActionInProgress">Halten</span>
@if (isActionInProgress) {
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
</div>
}
</button> </button>
<button <button
(click)="doubleDown.emit()" (click)="doubleDown.emit()"
class="button-primary px-8 py-4 text-lg font-medium min-w-[120px]" class="button-primary px-8 py-4 text-lg font-medium min-w-[120px] relative"
[disabled]="gameState !== 'IN_PROGRESS' || playerCards.length !== 2" [disabled]="gameState !== 'IN_PROGRESS' || playerCards.length !== 2 || isActionInProgress"
[class.opacity-50]="isActionInProgress"
> >
Verdoppeln <span [class.invisible]="isActionInProgress">Verdoppeln</span>
@if (isActionInProgress) {
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
</div>
}
</button> </button>
<button <button
(click)="leave.emit()" (click)="leave.emit()"
class="bg-accent-red hover:bg-accent-red/80 px-8 py-4 rounded text-lg font-medium min-w-[120px] transition-all duration-300" class="bg-accent-red hover:bg-accent-red/80 px-8 py-4 rounded text-lg font-medium min-w-[120px] transition-all duration-300"
[disabled]="isActionInProgress"
[class.opacity-50]="isActionInProgress"
> >
Abbrechen Abbrechen
</button> </button>
@ -52,6 +72,7 @@ import { Card } from '../../models/blackjack.model';
export class GameControlsComponent { export class GameControlsComponent {
@Input() playerCards: Card[] = []; @Input() playerCards: Card[] = [];
@Input() gameState: string = 'IN_PROGRESS'; @Input() gameState: string = 'IN_PROGRESS';
@Input() isActionInProgress: boolean = false;
@Output() hit = new EventEmitter<void>(); @Output() hit = new EventEmitter<void>();
@Output() stand = new EventEmitter<void>(); @Output() stand = new EventEmitter<void>();

View file

@ -86,10 +86,15 @@ import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angula
</div> </div>
<button <button
type="submit" type="submit"
class="button-primary w-full py-2" class="button-primary w-full py-2 relative"
[disabled]="!betForm.valid || gameInProgress" [disabled]="!betForm.valid || gameInProgress || isActionInProgress"
> >
Neues Spiel <span [class.invisible]="isActionInProgress">Neues Spiel</span>
@if (isActionInProgress) {
<div class="absolute inset-0 flex items-center justify-center">
<div class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
</div>
}
</button> </button>
</form> </form>
</div> </div>
@ -101,6 +106,7 @@ export class GameInfoComponent implements OnChanges {
@Input() balance = 0; @Input() balance = 0;
@Input() currentBet = 0; @Input() currentBet = 0;
@Input() gameInProgress = false; @Input() gameInProgress = false;
@Input() isActionInProgress = false;
@Output() newGame = new EventEmitter<number>(); @Output() newGame = new EventEmitter<number>();
betForm: FormGroup; betForm: FormGroup;

View file

@ -1,4 +1,4 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { PlayingCardComponent } from '../playing-card/playing-card.component'; import { PlayingCardComponent } from '../playing-card/playing-card.component';
import { Card } from '../../models/blackjack.model'; import { Card } from '../../models/blackjack.model';
@ -15,11 +15,12 @@ import { Card } from '../../models/blackjack.model';
class="flex justify-center gap-4 min-h-[160px] p-4 border-2 border-emerald-400 rounded-lg" class="flex justify-center gap-4 min-h-[160px] p-4 border-2 border-emerald-400 rounded-lg"
> >
@if (cards.length > 0) { @if (cards.length > 0) {
@for (card of cards; track card) { @for (card of cardsWithState; track card.id) {
<app-playing-card <app-playing-card
[rank]="card.rank" [rank]="card.rank"
[suit]="card.suit" [suit]="card.suit"
[hidden]="card.hidden" [hidden]="card.hidden"
[isNew]="card.isNew"
></app-playing-card> ></app-playing-card>
} }
} @else { } @else {
@ -33,6 +34,33 @@ import { Card } from '../../models/blackjack.model';
`, `,
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class PlayerHandComponent { export class PlayerHandComponent implements OnChanges {
@Input() cards: Card[] = []; @Input() cards: Card[] = [];
cardsWithState: (Card & { isNew: boolean; id: string })[] = [];
private lastCardCount = 0;
ngOnChanges(changes: SimpleChanges): void {
if (changes['cards']) {
this.updateCardsWithState();
}
}
private updateCardsWithState(): void {
const newCards = this.cards.length > this.lastCardCount;
this.cardsWithState = this.cards.map((card, index) => {
// Consider a card new if it's added after the initial state and is the latest card
const isNew = newCards && index >= this.lastCardCount;
return {
...card,
isNew,
// Generate a unique ID to help Angular track the cards
id: `${card.suit}-${card.rank}-${index}`
};
});
this.lastCardCount = this.cards.length;
}
} }

View file

@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; import { ChangeDetectionStrategy, Component, Input, AfterViewInit, ElementRef, OnChanges, SimpleChanges } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { suitSymbols, Suit } from '../../models/blackjack.model'; import { suitSymbols, Suit } from '../../models/blackjack.model';
import { gsap } from 'gsap';
@Component({ @Component({
selector: 'app-playing-card', selector: 'app-playing-card',
@ -8,31 +9,90 @@ import { suitSymbols, Suit } from '../../models/blackjack.model';
imports: [CommonModule], imports: [CommonModule],
template: ` template: `
<div <div
class="w-24 h-36 rounded-lg p-2 relative flex flex-col justify-between shadow-lg" #cardElement
class="w-24 h-36 rounded-lg p-2 relative flex flex-col justify-between shadow-lg card-element"
[class]="hidden ? 'bg-red-800' : 'bg-white'" [class]="hidden ? 'bg-red-800' : 'bg-white'"
> >
@if (!hidden) { @if (!hidden) {
<span class="text-xl font-bold text-accent-red">{{ getDisplayRank(rank) }}</span> <span class="text-xl font-bold" [class]="isRedSuit ? 'text-accent-red' : 'text-black'">{{ getDisplayRank(rank) }}</span>
} }
@if (!hidden) { @if (!hidden) {
<span <span
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-3xl text-accent-red" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-3xl"
[class]="isRedSuit ? 'text-accent-red' : 'text-black'"
>{{ getSuitSymbol(suit) }}</span >{{ getSuitSymbol(suit) }}</span
> >
} }
@if (!hidden) { @if (!hidden) {
<span class="text-xl font-bold text-accent-red self-end rotate-180">{{ <span class="text-xl font-bold self-end rotate-180" [class]="isRedSuit ? 'text-accent-red' : 'text-black'">{{
getDisplayRank(rank) getDisplayRank(rank)
}}</span> }}</span>
} }
</div> </div>
`, `,
styles: [`
.card-element {
transform-style: preserve-3d;
backface-visibility: hidden;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class PlayingCardComponent { export class PlayingCardComponent implements AfterViewInit, OnChanges {
@Input({ required: true }) rank!: string; @Input({ required: true }) rank!: string;
@Input({ required: true }) suit!: Suit; @Input({ required: true }) suit!: Suit;
@Input({ required: true }) hidden!: boolean; @Input({ required: true }) hidden!: boolean;
@Input() isNew: boolean = false;
constructor(private elementRef: ElementRef) {}
get isRedSuit(): boolean {
return this.suit === 'HEARTS' || this.suit === 'DIAMONDS';
}
ngAfterViewInit(): void {
if (this.isNew) {
this.animateNewCard();
}
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['hidden'] && !changes['hidden'].firstChange) {
this.animateCardFlip();
}
}
private animateNewCard(): void {
const cardElement = this.elementRef.nativeElement.querySelector('.card-element');
gsap.fromTo(
cardElement,
{
y: -100,
opacity: 0,
rotation: -10,
scale: 0.7
},
{
y: 0,
opacity: 1,
rotation: 0,
scale: 1,
duration: 0.5,
ease: 'power2.out'
}
);
}
private animateCardFlip(): void {
const cardElement = this.elementRef.nativeElement.querySelector('.card-element');
gsap.to(cardElement, {
rotationY: 180,
duration: 0.3,
onComplete: () => {
gsap.set(cardElement, { rotationY: 0 });
}
});
}
protected getSuitSymbol(suit: Suit): string { protected getSuitSymbol(suit: Suit): string {
return suitSymbols[suit]; return suitSymbols[suit];