Compare commits

...

3 commits

Author SHA1 Message Date
adc5bbd345
feat(blackjack): add action indicators and loading states
Some checks failed
CI / Get Changed Files (pull_request) Successful in 7s
CI / prettier (pull_request) Failing after 1m0s
CI / eslint (pull_request) Failing after 1m3s
CI / test-build (pull_request) Successful in 1m18s
CI / Checkstyle Main (pull_request) Successful in 1m31s
2025-03-27 15:44:38 +01:00
c9f71f70fa
feat: add double down feature to blackjack game 2025-03-27 15:40:26 +01:00
68ea66d4f9
feat: add stand and get game features to blackjack game 2025-03-27 15:22:24 +01:00
17 changed files with 795 additions and 68 deletions

View file

@ -10,3 +10,11 @@ Content-Type: application/json
POST http://localhost:8080/blackjack/202/hit
Authorization: Bearer {{token}}
###
POST http://localhost:8080/blackjack/202/stand
Authorization: Bearer {{token}}
###
GET http://localhost:8080/blackjack/202
Authorization: Bearer {{token}}

View file

@ -26,6 +26,23 @@ public class BlackJackGameController {
this.userService = userService;
}
@GetMapping("/blackjack/{id}")
public ResponseEntity<Object> getGame(@PathVariable Long id, @RequestHeader("Authorization") String token) {
Optional<UserEntity> optionalUser = userService.getCurrentUser(token);
if (optionalUser.isEmpty()) {
return ResponseEntity.notFound().build();
}
UserEntity user = optionalUser.get();
BlackJackGameEntity game = blackJackService.getBlackJackGame(id);
if (game == null || !Objects.equals(game.getUserId(), user.getId())) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(game);
}
@PostMapping("/blackjack/{id}/hit")
public ResponseEntity<Object> hit(@PathVariable Long id, @RequestHeader("Authorization") String token) {
Optional<UserEntity> optionalUser = userService.getCurrentUser(token);
@ -40,13 +57,41 @@ public class BlackJackGameController {
return ResponseEntity.notFound().build();
}
if (game.getState() != BlackJackState.IN_PROGRESS) {
Map<String, String> errorResponse = new HashMap<>();
errorResponse.put("error", "Invalid state");
return ResponseEntity.badRequest().body(errorResponse);
return ResponseEntity.ok(blackJackService.hit(game));
}
return ResponseEntity.ok(blackJackService.hit(game));
@PostMapping("/blackjack/{id}/stand")
public ResponseEntity<Object> stand(@PathVariable Long id, @RequestHeader("Authorization") String token) {
Optional<UserEntity> optionalUser = userService.getCurrentUser(token);
if (optionalUser.isEmpty()) {
return ResponseEntity.notFound().build();
}
UserEntity user = optionalUser.get();
BlackJackGameEntity game = blackJackService.getBlackJackGame(id);
if (game == null || !Objects.equals(game.getUserId(), user.getId())) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(blackJackService.stand(game));
}
@PostMapping("/blackjack/{id}/doubleDown")
public ResponseEntity<Object> doubleDown(@PathVariable Long id, @RequestHeader("Authorization") String token) {
Optional<UserEntity> optionalUser = userService.getCurrentUser(token);
if (optionalUser.isEmpty()) {
return ResponseEntity.notFound().build();
}
UserEntity user = optionalUser.get();
BlackJackGameEntity game = blackJackService.getBlackJackGame(id);
if (game == null || !Objects.equals(game.getUserId(), user.getId())) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(blackJackService.doubleDown(game));
}
@PostMapping("/blackjack/start")

View file

@ -24,12 +24,15 @@ public class BlackJackGameEntity {
@GeneratedValue
private Long id;
@Version
@JsonIgnore
private Long version;
@ManyToOne
@JoinColumn(name = "user_id", nullable = false)
@JsonIgnore
private UserEntity user;
// Expose UserID to JSON output
public Long getUserId() {
return user != null ? user.getId() : null;
}

View file

@ -3,6 +3,7 @@ package de.szut.casino.blackjack;
import de.szut.casino.user.UserEntity;
import de.szut.casino.user.UserRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@ -13,52 +14,168 @@ import java.util.Random;
public class BlackJackService {
private final BlackJackGameRepository blackJackGameRepository;
private final UserRepository userRepository;
private final Random random = new Random();
public BlackJackService(BlackJackGameRepository blackJackGameRepository, UserRepository userRepository) {
this.blackJackGameRepository = blackJackGameRepository;
this.userRepository = userRepository;
}
private final Random random = new Random();
public BlackJackGameEntity getBlackJackGame(Long id) {
Optional<BlackJackGameEntity> optionalBlackJackGame = blackJackGameRepository.findById(id);
return optionalBlackJackGame.orElse(null);
return blackJackGameRepository.findById(id).orElse(null);
}
@Transactional
public BlackJackGameEntity createBlackJackGame(UserEntity user, BigDecimal betAmount) {
BlackJackGameEntity game = new BlackJackGameEntity();
game.setUser(user);
game.setBet(betAmount);
initializeDeck(game);
for (int i = 0; i < 2; i++) {
CardEntity playerCard = drawCardFromDeck(game);
playerCard.setCardType(CardType.PLAYER);
game.getPlayerCards().add(playerCard);
}
CardEntity dealerCard = drawCardFromDeck(game);
dealerCard.setCardType(CardType.DEALER);
game.getDealerCards().add(dealerCard);
dealInitialCards(game);
game.setState(getState(game));
user.setBalance(user.getBalance().subtract(betAmount));
deductBetFromBalance(user, betAmount);
userRepository.save(user);
blackJackGameRepository.save(game);
return blackJackGameRepository.save(game);
}
@Transactional
public BlackJackGameEntity hit(BlackJackGameEntity game) {
game = refreshGameState(game);
if (game.getState() != BlackJackState.IN_PROGRESS) {
return game;
}
dealCardToPlayer(game);
updateGameStateAndBalance(game);
return blackJackGameRepository.save(game);
}
@Transactional
public BlackJackGameEntity stand(BlackJackGameEntity game) {
game = refreshGameState(game);
if (game.getState() != BlackJackState.IN_PROGRESS) {
return game;
}
dealCardsToDealerUntilMinimumScore(game);
determineWinnerAndUpdateBalance(game);
return blackJackGameRepository.save(game);
}
@Transactional
public BlackJackGameEntity doubleDown(BlackJackGameEntity game) {
game = refreshGameState(game);
if (game.getState() != BlackJackState.IN_PROGRESS || game.getPlayerCards().size() != 2) {
return game;
}
UserEntity user = getUserWithFreshData(game.getUser());
BigDecimal additionalBet = game.getBet();
if (user.getBalance().compareTo(additionalBet) < 0) {
return game;
}
deductBetFromBalance(user, additionalBet);
game.setBet(game.getBet().add(additionalBet));
dealCardToPlayer(game);
updateGameStateAndBalance(game);
if (game.getState() == BlackJackState.IN_PROGRESS) {
return stand(game);
}
return game;
}
public BlackJackGameEntity hit(BlackJackGameEntity game) {
CardEntity drawnCard = drawCardFromDeck(game);
drawnCard.setCardType(CardType.PLAYER);
game.getPlayerCards().add(drawnCard);
private BlackJackGameEntity refreshGameState(BlackJackGameEntity game) {
return blackJackGameRepository.findById(game.getId()).orElse(game);
}
private UserEntity getUserWithFreshData(UserEntity user) {
return userRepository.findById(user.getId()).orElse(user);
}
private void dealInitialCards(BlackJackGameEntity game) {
for (int i = 0; i < 2; i++) {
dealCardToPlayer(game);
}
dealCardToDealer(game);
}
private void dealCardToPlayer(BlackJackGameEntity game) {
CardEntity card = drawCardFromDeck(game);
card.setCardType(CardType.PLAYER);
game.getPlayerCards().add(card);
}
private void dealCardToDealer(BlackJackGameEntity game) {
CardEntity card = drawCardFromDeck(game);
card.setCardType(CardType.DEALER);
game.getDealerCards().add(card);
}
private void dealCardsToDealerUntilMinimumScore(BlackJackGameEntity game) {
while (calculateHandValue(game.getDealerCards()) < 17) {
dealCardToDealer(game);
}
}
private void updateGameStateAndBalance(BlackJackGameEntity game) {
game.setState(getState(game));
return blackJackGameRepository.save(game);
if (game.getState() == BlackJackState.PLAYER_WON) {
updateUserBalance(game, true);
} else if (game.getState() == BlackJackState.PLAYER_LOST) {
updateUserBalance(game, false);
}
}
private void determineWinnerAndUpdateBalance(BlackJackGameEntity game) {
int playerValue = calculateHandValue(game.getPlayerCards());
int dealerValue = calculateHandValue(game.getDealerCards());
if (dealerValue > 21 || playerValue > dealerValue) {
game.setState(BlackJackState.PLAYER_WON);
updateUserBalance(game, true);
} else if (playerValue < dealerValue) {
game.setState(BlackJackState.PLAYER_LOST);
updateUserBalance(game, false);
} else {
game.setState(BlackJackState.DRAW);
updateUserBalance(game, false); // For draw, player gets their bet back
}
}
private void deductBetFromBalance(UserEntity user, BigDecimal betAmount) {
user.setBalance(user.getBalance().subtract(betAmount));
userRepository.save(user);
}
@Transactional
private void updateUserBalance(BlackJackGameEntity game, boolean isWin) {
UserEntity user = getUserWithFreshData(game.getUser());
BigDecimal betAmount = game.getBet();
BigDecimal balance = user.getBalance();
if (isWin) {
balance = balance.add(betAmount.multiply(BigDecimal.valueOf(2)));
} else if (game.getState() == BlackJackState.DRAW) {
balance = balance.add(betAmount);
}
user.setBalance(balance);
userRepository.save(user);
}
private void initializeDeck(BlackJackGameEntity game) {
@ -99,6 +216,7 @@ public class BlackJackService {
private int calculateHandValue(List<CardEntity> hand) {
int sum = 0;
int aceCount = 0;
for (CardEntity card : hand) {
sum += card.getRank().getValue();
if (card.getRank() == Rank.ACE) {

View file

@ -4,4 +4,5 @@ public enum BlackJackState {
PLAYER_WON,
IN_PROGRESS,
PLAYER_LOST,
DRAW,
}

View file

@ -19,6 +19,10 @@ public class CardEntity {
@JsonIgnore
private Long id;
@Version
@JsonIgnore
private Long version;
@ManyToOne
@JoinColumn(name = "game_id", nullable = false)
@JsonBackReference

View file

@ -5,10 +5,25 @@
<div class="lg:col-span-3 space-y-6 flex flex-col gap-4">
<app-dealer-hand [cards]="dealerCards()"></app-dealer-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()) {
<app-game-controls
[playerCards]="playerCards()"
[gameState]="gameState()"
[isActionInProgress]="isActionInProgress()"
(hit)="onHit()"
(stand)="onStand()"
(doubleDown)="onDoubleDown()"
(leave)="leaveGame()"
></app-game-controls>
}
@ -19,8 +34,17 @@
[balance]="balance()"
[currentBet]="currentBet()"
[gameInProgress]="gameInProgress()"
[isActionInProgress]="isActionInProgress()"
(newGame)="onNewGame($event)"
></app-game-info>
</div>
</div>
</div>
<!-- Game Result Modal -->
<app-game-result
[gameState]="gameState()"
[amount]="currentBet()"
[show]="showGameResult()"
(close)="onCloseGameResult()"
></app-game-result>

View file

@ -10,6 +10,8 @@ import { GameControlsComponent } from './components/game-controls/game-controls.
import { GameInfoComponent } from './components/game-info/game-info.component';
import { Card, BlackjackGame } from './models/blackjack.model';
import { BlackjackService } from './services/blackjack.service';
import { HttpErrorResponse } from '@angular/common/http';
import { GameResultComponent } from './components/game-result/game-result.component';
@Component({
selector: 'app-blackjack',
@ -22,6 +24,7 @@ import { BlackjackService } from './services/blackjack.service';
PlayerHandComponent,
GameControlsComponent,
GameInfoComponent,
GameResultComponent,
],
templateUrl: './blackjack.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
@ -37,8 +40,18 @@ export default class BlackjackComponent {
balance = signal(0);
currentGameId = signal<number | undefined>(undefined);
gameInProgress = signal(false);
gameState = signal<string>('IN_PROGRESS');
showGameResult = signal(false);
// Add loading state trackers
isActionInProgress = signal(false);
currentAction = signal<string>('');
constructor() {
this.refreshUserBalance();
}
private refreshUserBalance(): void {
this.userService.getCurrentUser().subscribe((user) => {
this.balance.set(user?.balance ?? 0);
});
@ -49,11 +62,15 @@ export default class BlackjackComponent {
this.currentGameId.set(game.id);
this.currentBet.set(game.bet);
this.gameInProgress.set(game.state === 'IN_PROGRESS');
this.gameState.set(game.state);
// When game ends, make sure all dealer cards are visible
const isGameOver = game.state !== 'IN_PROGRESS';
this.dealerCards.set(
game.dealerCards.map((card, index) => ({
...card,
hidden: index === 1 && game.state === 'IN_PROGRESS',
hidden: !isGameOver && index === 1 && game.state === 'IN_PROGRESS',
}))
);
@ -63,45 +80,139 @@ export default class BlackjackComponent {
hidden: false,
}))
);
// Only refresh and show game result if the game has ended
if (isGameOver) {
console.log('Game is over, state:', game.state);
this.refreshUserBalance();
// Show result immediately without resetting first
this.showGameResult.set(true);
console.log('Game result dialog should be shown now');
}
}
onNewGame(bet: number): void {
this.isActionInProgress.set(true);
this.currentAction.set('Spiel wird gestartet...');
this.blackjackService.startGame(bet).subscribe({
next: (game) => {
this.updateGameState(game);
this.refreshUserBalance();
this.isActionInProgress.set(false);
},
error: (error) => {
console.error('Failed to start game:', error);
this.isActionInProgress.set(false);
},
});
}
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({
next: (game) => {
this.updateGameState(game);
this.isActionInProgress.set(false);
},
error: (error) => {
console.error('Failed to hit:', error);
this.handleGameError(error);
this.isActionInProgress.set(false);
},
});
}
onStand(): void {
if (!this.currentGameId()) return;
if (!this.currentGameId() || this.isActionInProgress()) return;
if (this.gameState() !== 'IN_PROGRESS') {
console.log('Cannot stand: game is not in progress');
return;
}
this.isActionInProgress.set(true);
this.currentAction.set('Dealer zieht Karten...');
this.blackjackService.stand(this.currentGameId()!).subscribe({
next: (game) => {
this.updateGameState(game);
this.isActionInProgress.set(false);
},
error: (error) => {
console.error('Failed to stand:', error);
this.handleGameError(error);
this.isActionInProgress.set(false);
},
});
}
onDoubleDown(): void {
if (!this.currentGameId() || this.isActionInProgress()) return;
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;
}
this.isActionInProgress.set(true);
this.currentAction.set('Einsatz wird verdoppelt...');
this.blackjackService.doubleDown(this.currentGameId()!).subscribe({
next: (game) => {
this.updateGameState(game);
this.isActionInProgress.set(false);
},
error: (error) => {
console.error('Failed to double down:', error);
this.handleGameError(error);
this.isActionInProgress.set(false);
},
});
}
onCloseGameResult(): void {
console.log('Closing game result dialog');
this.showGameResult.set(false);
}
private handleGameError(error: any): void {
if (error instanceof HttpErrorResponse) {
if (error.status === 400 && error.error?.error === 'Invalid state') {
this.gameInProgress.set(false);
this.refreshUserBalance();
}
else if (error.status === 500) {
console.log('Server error occurred. The game may have been updated in another session.');
this.gameInProgress.set(false);
this.refreshUserBalance();
if (this.currentGameId()) {
this.refreshGameState(this.currentGameId()!);
}
}
}
}
private refreshGameState(gameId: number): void {
this.blackjackService.getGame(gameId).subscribe({
next: (game) => {
this.updateGameState(game);
},
error: (err) => {
console.error('Failed to refresh game state:', err);
}
});
}
leaveGame(): void {
this.router.navigate(['/home']);
}

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 { PlayingCardComponent } from '../playing-card/playing-card.component';
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="flex justify-center gap-4 min-h-[160px] p-4 border-2 border-red-400 rounded-lg">
@if (cards.length > 0) {
@for (card of cards; track card) {
@for (card of cardsWithState; track card.id) {
<app-playing-card
[rank]="card.rank"
[suit]="card.suit"
[hidden]="card.hidden"
[isNew]="card.isNew"
></app-playing-card>
}
} @else {
@ -31,6 +32,33 @@ import { Card } from '../../models/blackjack.model';
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class DealerHandComponent {
export class DealerHandComponent implements OnChanges {
@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,36 +1,147 @@
import { ChangeDetectionStrategy, Component, EventEmitter, Output } from '@angular/core';
import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Card } from '../../models/blackjack.model';
@Component({
selector: 'app-game-controls',
standalone: true,
imports: [CommonModule],
template: `
<div class="flex flex-col gap-4">
<div class="flex justify-center text-lg mb-5">
<div class="card p-4">
<div class="text-emerald font-bold mb-1">Deine Punkte: {{ calculateHandValue(playerCards) }}</div>
<div class="text-text-secondary">
Status: <span [class]="getStatusClass(gameState)">{{ getStatusText(gameState) }}</span>
</div>
</div>
</div>
<div class="flex justify-center gap-4">
<button
(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' || 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
(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' || 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
(click)="doubleDown.emit()"
class="button-primary px-8 py-4 text-lg font-medium min-w-[120px] relative"
[disabled]="gameState !== 'IN_PROGRESS' || playerCards.length !== 2 || isActionInProgress"
[class.opacity-50]="isActionInProgress"
>
<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
(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"
[disabled]="isActionInProgress"
[class.opacity-50]="isActionInProgress"
>
Abbrechen
</button>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class GameControlsComponent {
@Input() playerCards: Card[] = [];
@Input() gameState: string = 'IN_PROGRESS';
@Input() isActionInProgress: boolean = false;
@Output() hit = new EventEmitter<void>();
@Output() stand = new EventEmitter<void>();
@Output() doubleDown = new EventEmitter<void>();
@Output() leave = new EventEmitter<void>();
calculateHandValue(cards: Card[]): number {
let sum = 0;
let aceCount = 0;
const rankValues: Record<string, number> = {
TWO: 2,
THREE: 3,
FOUR: 4,
FIVE: 5,
SIX: 6,
SEVEN: 7,
EIGHT: 8,
NINE: 9,
TEN: 10,
JACK: 10,
QUEEN: 10,
KING: 10,
ACE: 11
};
for (const card of cards) {
if (!card.hidden) {
const value = rankValues[card.rank] || 0;
sum += value;
if (card.rank === 'ACE') {
aceCount++;
}
}
}
while (sum > 21 && aceCount > 0) {
sum -= 10;
aceCount--;
}
return sum;
}
getStatusText(state: string): string {
switch (state) {
case 'IN_PROGRESS':
return 'Spiel läuft';
case 'PLAYER_WON':
return 'Gewonnen!';
case 'PLAYER_LOST':
return 'Verloren!';
case 'DRAW':
return 'Unentschieden!';
default:
return state;
}
}
getStatusClass(state: string): string {
switch (state) {
case 'PLAYER_WON':
return 'text-emerald';
case 'PLAYER_LOST':
return 'text-accent-red';
case 'DRAW':
return 'text-yellow-400';
default:
return 'text-white';
}
}
}

View file

@ -86,10 +86,15 @@ import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angula
</div>
<button
type="submit"
class="button-primary w-full py-2"
[disabled]="!betForm.valid || gameInProgress"
class="button-primary w-full py-2 relative"
[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>
</form>
</div>
@ -101,6 +106,7 @@ export class GameInfoComponent implements OnChanges {
@Input() balance = 0;
@Input() currentBet = 0;
@Input() gameInProgress = false;
@Input() isActionInProgress = false;
@Output() newGame = new EventEmitter<number>();
betForm: FormGroup;

View file

@ -0,0 +1 @@
/* No custom styles needed */

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GameResultComponent } from './game-result.component';
describe('GameResultComponent', () => {
let component: GameResultComponent;
let fixture: ComponentFixture<GameResultComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GameResultComponent]
})
.compileComponents();
fixture = TestBed.createComponent(GameResultComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,134 @@
import { ChangeDetectionStrategy, Component, Input, Output, EventEmitter } from '@angular/core';
import { CommonModule, CurrencyPipe } from '@angular/common';
import { animate, style, transition, trigger } from '@angular/animations';
@Component({
selector: 'app-game-result',
standalone: true,
imports: [CommonModule, CurrencyPipe],
template: `
<div
*ngIf="visible"
[@fadeInOut]
class="modal-bg"
style="z-index: 1000; position: fixed;"
>
<div
class="modal-card"
[@cardAnimation]
>
<h2 class="modal-heading" [class]="getResultClass()">{{ getResultTitle() }}</h2>
<p class="py-2 text-text-secondary mb-4">{{ getResultMessage() }}</p>
<div class="bg-deep-blue-light/50 rounded-lg p-5 mb-6 shadow-inner border border-deep-blue-light/30">
<div class="grid grid-cols-2 gap-4">
<div class="text-text-secondary">Einsatz:</div>
<div class="font-medium text-right">{{ amount }} </div>
<div class="text-text-secondary">{{ isDraw ? 'Zurückgegeben:' : (isWin ? 'Gewonnen:' : 'Verloren:') }}</div>
<div
class="font-medium text-right"
[ngClass]="{
'text-emerald': isWin,
'text-accent-red': isLoss,
'text-yellow-400': isDraw
}"
>
{{ isLoss ? '-' : '+' }}{{ amount }}
</div>
<div class="text-text-secondary border-t border-text-secondary/20 pt-3 font-medium">Gesamt:</div>
<div
class="font-medium text-right border-t border-text-secondary/20 pt-3"
[ngClass]="{
'text-emerald': isWin,
'text-accent-red': isLoss,
'text-white': isDraw
}"
>
{{ isWin ? '+' : (isLoss ? '-' : '') }}{{ amount }}
</div>
</div>
</div>
<button
type="button"
(click)="closeDialog()"
class="button-primary w-full py-2"
>
Verstanden
</button>
</div>
</div>
`,
styleUrls: ['./game-result.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush,
animations: [
trigger('fadeInOut', [
transition(':enter', [
style({ opacity: 0 }),
animate('150ms ease-out', style({ opacity: 1 }))
]),
transition(':leave', [
animate('150ms ease-in', style({ opacity: 0 }))
])
]),
trigger('cardAnimation', [
transition(':enter', [
style({ opacity: 0, transform: 'scale(0.95)' }),
animate('200ms ease-out', style({ opacity: 1, transform: 'scale(1)' }))
])
])
]
})
export class GameResultComponent {
@Input() gameState: string = '';
@Input() amount: number = 0;
@Input() set show(value: boolean) {
console.log('GameResultComponent show input changed:', value, 'gameState:', this.gameState);
this.visible = value;
}
@Output() close = new EventEmitter<void>();
visible = false;
get isWin(): boolean {
return this.gameState === 'PLAYER_WON';
}
get isLoss(): boolean {
return this.gameState === 'PLAYER_LOST';
}
get isDraw(): boolean {
return this.gameState === 'DRAW';
}
getResultTitle(): string {
if (this.isWin) return 'Gewonnen!';
if (this.isLoss) return 'Verloren!';
if (this.isDraw) return 'Unentschieden!';
return '';
}
getResultMessage(): string {
if (this.isWin) return 'Glückwunsch! Du hast diese Runde gewonnen.';
if (this.isLoss) return 'Schade! Du hast diese Runde verloren.';
if (this.isDraw) return 'Diese Runde endet unentschieden. Dein Einsatz wurde zurückgegeben.';
return '';
}
getResultClass(): string {
if (this.isWin) return 'text-emerald';
if (this.isLoss) return 'text-accent-red';
if (this.isDraw) return 'text-yellow-400';
return '';
}
closeDialog(): void {
this.visible = false;
this.close.emit();
console.log('Dialog closed by user');
}
}

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 { PlayingCardComponent } from '../playing-card/playing-card.component';
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"
>
@if (cards.length > 0) {
@for (card of cards; track card) {
@for (card of cardsWithState; track card.id) {
<app-playing-card
[rank]="card.rank"
[suit]="card.suit"
[hidden]="card.hidden"
[isNew]="card.isNew"
></app-playing-card>
}
} @else {
@ -33,6 +34,33 @@ import { Card } from '../../models/blackjack.model';
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlayerHandComponent {
export class PlayerHandComponent implements OnChanges {
@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 { suitSymbols, Suit } from '../../models/blackjack.model';
import { gsap } from 'gsap';
@Component({
selector: 'app-playing-card',
@ -8,31 +9,90 @@ import { suitSymbols, Suit } from '../../models/blackjack.model';
imports: [CommonModule],
template: `
<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'"
>
@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) {
<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
>
}
@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)
}}</span>
}
</div>
`,
styles: [`
.card-element {
transform-style: preserve-3d;
backface-visibility: hidden;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PlayingCardComponent {
export class PlayingCardComponent implements AfterViewInit, OnChanges {
@Input({ required: true }) rank!: string;
@Input({ required: true }) suit!: Suit;
@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 {
return suitSymbols[suit];

View file

@ -41,4 +41,26 @@ export class BlackjackService {
})
);
}
doubleDown(gameId: number): Observable<BlackjackGame> {
return this.http
.post<BlackjackGame>(`/backend/blackjack/${gameId}/doubleDown`, {}, { responseType: 'json' })
.pipe(
catchError((error) => {
console.error('Double Down error:', error);
throw error;
})
);
}
getGame(gameId: number): Observable<BlackjackGame> {
return this.http
.get<BlackjackGame>(`/backend/blackjack/${gameId}`, { responseType: 'json' })
.pipe(
catchError((error) => {
console.error('Get game error:', error);
throw error;
})
);
}
}