feat(blackjack): add animated number component and usage

This commit is contained in:
Jan-Marlon Leibl 2025-04-03 10:02:15 +02:00
parent a2f1a40931
commit 4b70a4ac4a
Signed by: jleibl
GPG key ID: 300B2F906DC6F1D5
11 changed files with 127 additions and 63 deletions

View file

@ -21,6 +21,7 @@
"@tailwindcss/postcss": "^4.0.3", "@tailwindcss/postcss": "^4.0.3",
"ajv": "8.17.1", "ajv": "8.17.1",
"ajv-formats": "3.0.1", "ajv-formats": "3.0.1",
"countup.js": "^2.8.0",
"gsap": "^3.12.7", "gsap": "^3.12.7",
"keycloak-angular": "^19.0.0", "keycloak-angular": "^19.0.0",
"keycloak-js": "^26.0.0", "keycloak-js": "^26.0.0",
@ -897,6 +898,13 @@
"cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="], "cosmiconfig": ["cosmiconfig@9.0.0", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg=="],
<<<<<<< HEAD
=======
"countup.js": ["countup.js@2.8.0", "", {}, "sha512-f7xEhX0awl4NOElHulrl4XRfKoNH3rB+qfNSZZyjSZhaAoUk6elvhH+MNxMmlmuUJ2/QNTWPSA7U4mNtIAKljQ=="],
"critters": ["critters@0.0.24", "", { "dependencies": { "chalk": "^4.1.0", "css-select": "^5.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.2", "htmlparser2": "^8.0.2", "postcss": "^8.4.23", "postcss-media-query-parser": "^0.2.3" } }, "sha512-Oyqew0FGM0wYUSNqR0L6AteO5MpMoUU0rhKRieXeiKs+PmRTxiJMyaunYB2KF6fQ3dzChXKCpbFOEJx3OQ1v/Q=="],
>>>>>>> f2d447a (feat(blackjack): add animated number component and usage)
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"css-loader": ["css-loader@7.1.2", "", { "dependencies": { "icss-utils": "^5.1.0", "postcss": "^8.4.33", "postcss-modules-extract-imports": "^3.1.0", "postcss-modules-local-by-default": "^4.0.5", "postcss-modules-scope": "^3.2.0", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", "semver": "^7.5.4" }, "peerDependencies": { "@rspack/core": "0.x || 1.x", "webpack": "^5.27.0" }, "optionalPeers": ["@rspack/core", "webpack"] }, "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA=="], "css-loader": ["css-loader@7.1.2", "", { "dependencies": { "icss-utils": "^5.1.0", "postcss": "^8.4.33", "postcss-modules-extract-imports": "^3.1.0", "postcss-modules-local-by-default": "^4.0.5", "postcss-modules-scope": "^3.2.0", "postcss-modules-values": "^4.0.0", "postcss-value-parser": "^4.2.0", "semver": "^7.5.4" }, "peerDependencies": { "@rspack/core": "0.x || 1.x", "webpack": "^5.27.0" }, "optionalPeers": ["@rspack/core", "webpack"] }, "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA=="],

View file

@ -30,6 +30,7 @@
"@tailwindcss/postcss": "^4.0.3", "@tailwindcss/postcss": "^4.0.3",
"ajv": "8.17.1", "ajv": "8.17.1",
"ajv-formats": "3.0.1", "ajv-formats": "3.0.1",
"countup.js": "^2.8.0",
"gsap": "^3.12.7", "gsap": "^3.12.7",
"keycloak-angular": "^19.0.0", "keycloak-angular": "^19.0.0",
"keycloak-js": "^26.0.0", "keycloak-js": "^26.0.0",

View file

@ -6,19 +6,6 @@
<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>
@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()"

View file

@ -48,7 +48,6 @@ export default class BlackjackComponent implements OnInit {
showGameResult = signal(false); showGameResult = signal(false);
isActionInProgress = signal(false); isActionInProgress = signal(false);
currentAction = signal<string>('');
showDebtDialog = signal(false); showDebtDialog = signal(false);
debtAmount = signal(0); debtAmount = signal(0);
@ -96,7 +95,6 @@ export default class BlackjackComponent implements OnInit {
onNewGame(bet: number): void { onNewGame(bet: number): void {
this.isActionInProgress.set(true); this.isActionInProgress.set(true);
this.currentAction.set('Spiel wird gestartet...');
this.blackjackService.startGame(bet).subscribe({ this.blackjackService.startGame(bet).subscribe({
next: (game) => { next: (game) => {
@ -115,7 +113,6 @@ export default class BlackjackComponent implements OnInit {
if (!this.currentGameId() || this.isActionInProgress()) return; if (!this.currentGameId() || this.isActionInProgress()) return;
this.isActionInProgress.set(true); 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) => {
@ -142,7 +139,6 @@ export default class BlackjackComponent implements OnInit {
} }
this.isActionInProgress.set(true); 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) => {
@ -167,7 +163,6 @@ export default class BlackjackComponent implements OnInit {
} }
this.isActionInProgress.set(true); 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) => {

View file

@ -0,0 +1,80 @@
import { ChangeDetectionStrategy, Component, Input, OnChanges, SimpleChanges, ElementRef, ViewChild, AfterViewInit } from '@angular/core';
import { CommonModule, CurrencyPipe } from '@angular/common';
import { CountUp } from 'countup.js';
@Component({
selector: 'app-animated-number',
standalone: true,
imports: [CommonModule, CurrencyPipe],
template: `
<span #numberElement>{{ formattedValue }}</span>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AnimatedNumberComponent implements OnChanges, AfterViewInit {
@Input() value = 0;
@Input() duration = 1;
@Input() ease = 'power1.out';
@ViewChild('numberElement') numberElement!: ElementRef;
private countUp: CountUp | null = null;
private previousValue = 0;
formattedValue = '0,00 €';
ngAfterViewInit(): void {
this.initializeCountUp();
if (this.countUp && this.value !== 0) {
this.countUp.start(() => {
this.previousValue = this.value;
});
}
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['value']) {
if (this.countUp) {
const startVal = this.previousValue;
const endVal = this.value;
// Update the CountUp instance with new start and end values
this.countUp.update(endVal);
this.previousValue = endVal;
} else {
// Format the initial value if CountUp is not yet initialized
this.formattedValue = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(this.value);
}
}
}
private initializeCountUp(): void {
if (this.numberElement) {
this.countUp = new CountUp(this.numberElement.nativeElement, this.value, {
startVal: this.previousValue,
duration: this.duration,
easingFn: (t, b, c, d) => {
// Custom easing function based on the input ease type
if (this.ease === 'power1.out') {
return c * (1 - Math.pow(1 - t / d, 1)) + b;
}
return c * (t / d) + b; // linear fallback
},
formattingFn: (value) => {
const formatted = new Intl.NumberFormat('de-DE', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(value);
this.formattedValue = formatted;
return formatted;
},
});
}
}
}

View file

@ -28,54 +28,27 @@ import { GameControlsService } from '@blackjack/services/game-controls.service';
(click)="hit.emit()" (click)="hit.emit()"
class="button-primary px-8 py-4 text-lg font-medium min-w-[120px] relative" class="button-primary px-8 py-4 text-lg font-medium min-w-[120px] relative"
[disabled]="gameState !== GameState.IN_PROGRESS || isActionInProgress" [disabled]="gameState !== GameState.IN_PROGRESS || isActionInProgress"
[class.opacity-50]="isActionInProgress"
> >
<span [class.invisible]="isActionInProgress">Ziehen</span> <span>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] relative" class="button-primary px-8 py-4 text-lg font-medium min-w-[120px] relative"
[disabled]="gameState !== GameState.IN_PROGRESS || isActionInProgress" [disabled]="gameState !== GameState.IN_PROGRESS || isActionInProgress"
[class.opacity-50]="isActionInProgress"
> >
<span [class.invisible]="isActionInProgress">Halten</span> <span>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] relative" class="button-primary px-8 py-4 text-lg font-medium min-w-[120px] relative"
[disabled]=" [disabled]="!canDoubleDown || isActionInProgress"
gameState !== GameState.IN_PROGRESS || playerCards.length !== 2 || isActionInProgress
"
[class.opacity-50]="isActionInProgress"
> >
<span [class.invisible]="isActionInProgress">Verdoppeln</span> <span>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" [disabled]="isActionInProgress"
[class.opacity-50]="isActionInProgress"
> >
Abbrechen Abbrechen
</button> </button>
@ -97,4 +70,12 @@ export class GameControlsComponent {
protected readonly GameState = GameState; protected readonly GameState = GameState;
constructor(protected gameControlsService: GameControlsService) {} constructor(protected gameControlsService: GameControlsService) {}
get canDoubleDown(): boolean {
return (
this.gameState === GameState.IN_PROGRESS &&
this.playerCards.length === 2 &&
!this.isActionInProgress
);
}
} }

View file

@ -11,11 +11,12 @@ import {
import { CommonModule, CurrencyPipe } from '@angular/common'; import { CommonModule, CurrencyPipe } from '@angular/common';
import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { FormGroup, ReactiveFormsModule } from '@angular/forms';
import { BettingService } from '@blackjack/services/betting.service'; import { BettingService } from '@blackjack/services/betting.service';
import { AnimatedNumberComponent } from '../animated-number/animated-number.component';
@Component({ @Component({
selector: 'app-game-info', selector: 'app-game-info',
standalone: true, standalone: true,
imports: [CommonModule, CurrencyPipe, ReactiveFormsModule], imports: [CommonModule, CurrencyPipe, ReactiveFormsModule, AnimatedNumberComponent],
template: ` template: `
<div class="card p-4"> <div class="card p-4">
<h3 class="section-heading text-xl mb-4">Spiel Informationen</h3> <h3 class="section-heading text-xl mb-4">Spiel Informationen</h3>
@ -23,7 +24,7 @@ import { BettingService } from '@blackjack/services/betting.service';
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="text-text-secondary">Aktuelle Wette:</span> <span class="text-text-secondary">Aktuelle Wette:</span>
<span [class]="currentBet > 0 ? 'text-accent-red' : 'text-text-secondary'"> <span [class]="currentBet > 0 ? 'text-accent-red' : 'text-text-secondary'">
{{ currentBet | currency: 'EUR' }} <app-animated-number [value]="currentBet" [duration]="0.5"></app-animated-number>
</span> </span>
</div> </div>

View file

@ -2,11 +2,12 @@ import { ChangeDetectionStrategy, Component, Input, Output, EventEmitter } from
import { CommonModule, CurrencyPipe } from '@angular/common'; import { CommonModule, CurrencyPipe } from '@angular/common';
import { animate, style, transition, trigger } from '@angular/animations'; import { animate, style, transition, trigger } from '@angular/animations';
import { GameState } from '../../enum/gameState'; import { GameState } from '../../enum/gameState';
import { AnimatedNumberComponent } from '../animated-number/animated-number.component';
@Component({ @Component({
selector: 'app-game-result', selector: 'app-game-result',
standalone: true, standalone: true,
imports: [CommonModule, CurrencyPipe], imports: [CommonModule, CurrencyPipe, AnimatedNumberComponent],
template: ` template: `
<div *ngIf="visible" [@fadeInOut] class="modal-bg" style="z-index: 1000; position: fixed;"> <div *ngIf="visible" [@fadeInOut] class="modal-bg" style="z-index: 1000; position: fixed;">
<div class="modal-card" [@cardAnimation]> <div class="modal-card" [@cardAnimation]>
@ -18,7 +19,9 @@ import { GameState } from '../../enum/gameState';
> >
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="text-text-secondary">Einsatz:</div> <div class="text-text-secondary">Einsatz:</div>
<div class="font-medium text-right">{{ amount | currency: 'EUR' }}</div> <div class="font-medium text-right">
<app-animated-number [value]="amount" [duration]="0.5"></app-animated-number>
</div>
<div class="text-text-secondary"> <div class="text-text-secondary">
{{ isDraw ? 'Zurückgegeben:' : isWin ? 'Gewonnen:' : 'Verloren:' }} {{ isDraw ? 'Zurückgegeben:' : isWin ? 'Gewonnen:' : 'Verloren:' }}
@ -31,9 +34,13 @@ import { GameState } from '../../enum/gameState';
'text-yellow-400': isDraw, 'text-yellow-400': isDraw,
}" }"
> >
{{ isLoss ? '-' : '+' }}{{ isWin ? amount * 2 : (amount | currency: 'EUR') }} {{ isLoss ? '-' : '+' }}
<app-animated-number
[value]="isWin ? amount * 2 : amount"
[duration]="0.5"
></app-animated-number>
<div *ngIf="isWin" class="text-xs text-text-secondary"> <div *ngIf="isWin" class="text-xs text-text-secondary">
(Einsatz {{ amount | currency: 'EUR' }} × 2) (Einsatz <app-animated-number [value]="amount" [duration]="0.5"></app-animated-number> × 2)
</div> </div>
</div> </div>
@ -41,7 +48,7 @@ import { GameState } from '../../enum/gameState';
Kontostand: Kontostand:
</div> </div>
<div class="font-medium text-right border-t border-text-secondary/20 pt-3"> <div class="font-medium text-right border-t border-text-secondary/20 pt-3">
{{ balance | currency: 'EUR' }} <app-animated-number [value]="balance" [duration]="0.5"></app-animated-number>
</div> </div>
</div> </div>
</div> </div>

View file

@ -11,18 +11,19 @@ import {
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { animate, style, transition, trigger } from '@angular/animations'; import { animate, style, transition, trigger } from '@angular/animations';
import { interval, Subscription, takeWhile } from 'rxjs'; import { interval, Subscription, takeWhile } from 'rxjs';
import { AnimatedNumberComponent } from '@blackjack/components/animated-number/animated-number.component';
@Component({ @Component({
selector: 'app-debt-dialog', selector: 'app-debt-dialog',
standalone: true, standalone: true,
imports: [CommonModule], imports: [CommonModule, AnimatedNumberComponent],
template: ` template: `
<div *ngIf="visible" [@fadeInOut] class="modal-bg" style="z-index: 1000; position: fixed;"> <div *ngIf="visible" [@fadeInOut] class="modal-bg" style="z-index: 1000; position: fixed;">
<div class="modal-card" [@cardAnimation]> <div class="modal-card" [@cardAnimation]>
<h2 class="modal-heading text-accent-red">WARNUNG!</h2> <h2 class="modal-heading text-accent-red">WARNUNG!</h2>
<p class="py-2 text-text-secondary mb-4"> <p class="py-2 text-text-secondary mb-4">
Du hast nicht genug Geld für den Double Down. Du bist jetzt im Minus und schuldest uns Du hast nicht genug Geld für den Double Down. Du bist jetzt im Minus und schuldest uns
{{ amount | currency: 'EUR' }}. <app-animated-number [value]="amount" [duration]="0.5"></app-animated-number>.
</p> </p>
<p class="py-2 text-accent-red mb-4 font-bold"> <p class="py-2 text-accent-red mb-4 font-bold">
Liefer das Geld sofort an den Dead Drop oder es wird unangenehme Konsequenzen geben! Liefer das Geld sofort an den Dead Drop oder es wird unangenehme Konsequenzen geben!
@ -32,7 +33,9 @@ import { interval, Subscription, takeWhile } from 'rxjs';
> >
<div class="grid grid-cols-2 gap-4"> <div class="grid grid-cols-2 gap-4">
<div class="text-text-secondary">Schulden:</div> <div class="text-text-secondary">Schulden:</div>
<div class="font-medium text-right text-accent-red">{{ amount | currency: 'EUR' }}</div> <div class="font-medium text-right text-accent-red">
<app-animated-number [value]="amount" [duration]="0.5"></app-animated-number>
</div>
</div> </div>
</div> </div>
<div class="text-center mb-6"> <div class="text-center mb-6">

View file

@ -19,9 +19,9 @@
class="text-white font-bold bg-deep-blue-contrast rounded-full px-4 py-2 text-sm hover:bg-deep-blue-contrast/80 hover:cursor-pointer hover:scale-105 transition-all active:scale-95 select-none duration-300" class="text-white font-bold bg-deep-blue-contrast rounded-full px-4 py-2 text-sm hover:bg-deep-blue-contrast/80 hover:cursor-pointer hover:scale-105 transition-all active:scale-95 select-none duration-300"
routerLink="/home" routerLink="/home"
> >
<span [class]="balance() < 0 ? 'text-accent-red' : ''">{{ <span [class]="balance() < 0 ? 'text-accent-red' : ''">
balance() | currency: 'EUR' : 'symbol' : '1.2-2' <app-animated-number [value]="balance()" [duration]="0.5"></app-animated-number>
}}</span> </span>
</div> </div>
<button (click)="logout()" class="button-primary px-4 py-1.5">Abmelden</button> <button (click)="logout()" class="button-primary px-4 py-1.5">Abmelden</button>
} }

View file

@ -11,12 +11,13 @@ import { KeycloakService } from 'keycloak-angular';
import { CurrencyPipe } from '@angular/common'; import { CurrencyPipe } from '@angular/common';
import { UserService } from '@service/user.service'; import { UserService } from '@service/user.service';
import { Subscription } from 'rxjs'; import { Subscription } from 'rxjs';
import { AnimatedNumberComponent } from '@blackjack/components/animated-number/animated-number.component';
@Component({ @Component({
selector: 'app-navbar', selector: 'app-navbar',
templateUrl: './navbar.component.html', templateUrl: './navbar.component.html',
standalone: true, standalone: true,
imports: [RouterModule, CurrencyPipe], imports: [RouterModule, CurrencyPipe, AnimatedNumberComponent],
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
}) })
export class NavbarComponent implements OnInit, OnDestroy { export class NavbarComponent implements OnInit, OnDestroy {