diff --git a/frontend/public/coinflip.png b/frontend/public/coinflip.png new file mode 100644 index 0000000..0f39ca8 Binary files /dev/null and b/frontend/public/coinflip.png differ diff --git a/frontend/public/poker.webp b/frontend/public/poker.webp deleted file mode 100644 index 329f4da..0000000 Binary files a/frontend/public/poker.webp and /dev/null differ diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index b8adc5f..6e9f9b7 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -45,6 +45,11 @@ export const routes: Routes = [ loadComponent: () => import('./feature/game/blackjack/blackjack.component'), canActivate: [authGuard], }, + { + path: 'game/coinflip', + loadComponent: () => import('./feature/game/coinflip/coinflip.component'), + canActivate: [authGuard], + }, { path: 'game/slots', loadComponent: () => import('./feature/game/slots/slots.component'), diff --git a/frontend/src/app/feature/game/coinflip/coinflip.component.css b/frontend/src/app/feature/game/coinflip/coinflip.component.css new file mode 100644 index 0000000..06c9671 --- /dev/null +++ b/frontend/src/app/feature/game/coinflip/coinflip.component.css @@ -0,0 +1,117 @@ +/* Custom CSS for 3D Transformations and Coin Flip */ +@keyframes flipToHeads { + 0% { + transform: rotateY(0); + } + 100% { + transform: rotateY(1800deg); /* End with heads facing up (even number of Y rotations) */ + } +} + +@keyframes flipToTails { + 0% { + transform: rotateY(0); + } + 100% { + transform: rotateY(1980deg); /* End with tails facing up (odd number of Y rotations) */ + } +} + +.coin-container { + width: 180px; + height: 180px; + perspective: 1000px; + margin: 20px auto; +} + +.coin { + width: 100%; + height: 100%; + position: relative; + transform-style: preserve-3d; + transition: transform 0.01s; + transform: rotateY(0deg); + box-shadow: 0 0 30px rgba(0, 0, 0, 0.4); +} + +.coin-side { + width: 100%; + height: 100%; + position: absolute; + backface-visibility: hidden; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: bold; + font-size: 24px; + box-shadow: inset 0 0 15px rgba(0, 0, 0, 0.2); + border: 8px solid rgba(255, 255, 255, 0.2); +} + +.front { + background: linear-gradient(45deg, #ffd700, #ffb700); + color: #333; + z-index: 2; + transform: rotateY(0); +} + +.back { + background: linear-gradient(45deg, #5a5a5a, #333333); + color: white; + z-index: 1; + transform: rotateY(180deg); +} + +/* We apply transform directly to the SVG element in HTML */ + +/* Text for both sides */ +.coin-text { + /* Ensure text is readable */ + user-select: none; + pointer-events: none; +} + +/* Animation classes */ +.coin.animate-to-heads { + animation: flipToHeads 1s ease-in-out forwards; +} + +.coin.animate-to-tails { + animation: flipToTails 1s ease-in-out forwards; +} + +/* Make the buttons more responsive */ +button:not([disabled]) { + cursor: pointer; + transition: all 0.2s ease; +} + +button:not([disabled]):hover { + transform: translateY(-2px); + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.2); +} + +button:not([disabled]):active { + transform: translateY(1px); +} + +/* Animation for results */ +@keyframes popIn { + 0% { + transform: scale(0.8); + opacity: 0; + } + 70% { + transform: scale(1.1); + opacity: 1; + } + 100% { + transform: scale(1); + opacity: 1; + } +} + +.result-text { + animation: popIn 0.5s ease-out forwards; +} diff --git a/frontend/src/app/feature/game/coinflip/coinflip.component.html b/frontend/src/app/feature/game/coinflip/coinflip.component.html new file mode 100644 index 0000000..671bc31 --- /dev/null +++ b/frontend/src/app/feature/game/coinflip/coinflip.component.html @@ -0,0 +1,143 @@ +
+
+
+ + @if (gameResult()) { +
+

+ {{ gameResult()?.isWin ? 'You Won!' : 'You Lost' }} +

+

+ Coin landed on: + {{ + gameResult()?.coinSide === 'HEAD' ? 'HEAD' : 'TAILS' + }} +

+ @if (gameResult()?.isWin) { +

+ +{{ gameResult()?.payout | currency: 'EUR' }} +

+ } +
+ } + + + @if (errorMessage()) { +
+

{{ errorMessage() }}

+
+ } + + +
+
+ +
+
HEAD
+
+ + +
+ + TAILS +
+
+
+ + +
+ + +
+
+ + +
+
+

Game Information

+
+ +
+ Current Bet: + + € + +
+ + +
+ Your Balance: + + {{ balance() | currency: 'EUR' }} + +
+ + + @if (!gameInProgress()) { +
+ + + + +
+ } + + +
+
+ + Cannot exceed balance +
+ +
+ + +
+

How to Play

+
    +
  • • Choose your bet amount
  • +
  • • Select Heads or Tails
  • +
  • • Win double your bet if correct
  • +
+
+
+
+
+
+
diff --git a/frontend/src/app/feature/game/coinflip/coinflip.component.ts b/frontend/src/app/feature/game/coinflip/coinflip.component.ts new file mode 100644 index 0000000..1766e4d --- /dev/null +++ b/frontend/src/app/feature/game/coinflip/coinflip.component.ts @@ -0,0 +1,248 @@ +import { NgClass, NgIf, CurrencyPipe, CommonModule } from '@angular/common'; +import { HttpClient } from '@angular/common/http'; +import { FormsModule } from '@angular/forms'; +import { + ChangeDetectionStrategy, + Component, + ElementRef, + inject, + OnInit, + signal, + ViewChild, +} from '@angular/core'; +import { AnimatedNumberComponent } from '@blackjack/components/animated-number/animated-number.component'; +import { catchError, finalize } from 'rxjs/operators'; +import { of } from 'rxjs'; +import { AuthService } from '@service/auth.service'; +import { AudioService } from '@shared/services/audio.service'; +import { CoinflipGame, CoinflipRequest } from './models/coinflip.model'; + +@Component({ + selector: 'app-coinflip', + standalone: true, + imports: [AnimatedNumberComponent, CurrencyPipe, FormsModule, CommonModule, NgIf, NgClass], + templateUrl: './coinflip.component.html', + styleUrl: './coinflip.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export default class CoinflipComponent implements OnInit { + currentBet = signal(10); + balance = signal(0); + gameInProgress = signal(false); + isActionInProgress = signal(false); + gameResult = signal(null); + betInputValue = signal(10); + errorMessage = signal(''); + isInvalidBet = signal(false); + + @ViewChild('coinElement') coinElement?: ElementRef; + + audioService = inject(AudioService); + authService = inject(AuthService); + private http = inject(HttpClient); + + private coinflipSound?: HTMLAudioElement; + + ngOnInit(): void { + // Subscribe to user updates for real-time balance changes + this.authService.userSubject.subscribe((user) => { + if (user) { + this.balance.set(user.balance); + } + }); + + // Initialize coinflip sound + this.coinflipSound = new Audio('/sounds/coinflip.mp3'); + } + + setBetAmount(percentage: number) { + const newBet = Math.floor(this.balance() * percentage); + this.betInputValue.set(newBet > 0 ? newBet : 1); + this.currentBet.set(this.betInputValue()); + } + + updateBet(event: Event) { + const inputElement = event.target as HTMLInputElement; + let value = Number(inputElement.value); + + // Reset invalid bet state + this.isInvalidBet.set(false); + + // Enforce minimum bet of 1 + if (value <= 0) { + value = 1; + } + + // Cap bet at available balance and show feedback + if (value > this.balance()) { + value = this.balance(); + // Show visual feedback + this.isInvalidBet.set(true); + // Indicate the error briefly + setTimeout(() => this.isInvalidBet.set(false), 800); + // Update the input field directly to show the user the max value + inputElement.value = String(value); + } + + // Update signals + this.betInputValue.set(value); + this.currentBet.set(value); + } + + betHeads() { + this.placeBet('HEAD'); + } + + betTails() { + this.placeBet('TAILS'); + } + + private placeBet(side: 'HEAD' | 'TAILS') { + if (this.gameInProgress() || this.isActionInProgress()) return; + + // Reset previous result + this.gameResult.set(null); + this.errorMessage.set(''); + + // Set game state + this.gameInProgress.set(true); + this.isActionInProgress.set(true); + + // Play bet sound + this.audioService.playBetSound(); + + // Create bet request + const request: CoinflipRequest = { + betAmount: this.currentBet(), + coinSide: side, + }; + + // Call API + this.http + .post('/backend/coinflip', request) + .pipe( + catchError((error) => { + console.error('Error playing coinflip:', error); + + if (error.status === 400 && error.error.message.includes('insufficient')) { + this.errorMessage.set('Insufficient funds'); + } else { + this.errorMessage.set('An error occurred. Please try again.'); + } + + this.gameInProgress.set(false); + return of(null); + }), + finalize(() => { + this.isActionInProgress.set(false); + }) + ) + .subscribe((result) => { + if (!result) return; + + console.log('API response:', result); + + // Fix potential property naming inconsistency from the backend + const fixedResult: CoinflipGame = { + isWin: result.isWin ?? result.win, + payout: result.payout, + coinSide: result.coinSide, + }; + + console.log('Fixed result:', fixedResult); + + // Play coin flip animation and sound + this.playCoinFlipAnimation(fixedResult.coinSide); + + // Set result after animation completes + setTimeout(() => { + this.gameResult.set(fixedResult); + + // Update balance with new value from auth service + this.authService.loadCurrentUser(); + + // Play win sound if player won + if (fixedResult.isWin) { + this.audioService.playWinSound(); + } + + // Reset game state after showing result + setTimeout(() => { + this.gameInProgress.set(false); + }, 1500); + }, 1100); // Just after animation ends + }); + } + + private playCoinFlipAnimation(result: 'HEAD' | 'TAILS') { + if (!this.coinElement) return; + + const coinEl = this.coinElement.nativeElement; + + // Reset any existing animations + coinEl.classList.remove('animate-to-heads', 'animate-to-tails'); + + // Reset any inline styles from previous animations + coinEl.style.transform = ''; + + // Force a reflow to restart animation + void coinEl.offsetWidth; + + // Play flip sound + if (this.coinflipSound) { + this.coinflipSound.currentTime = 0; + this.coinflipSound.play().catch((err) => console.error('Error playing sound:', err)); + } + + // Add appropriate animation class based on result + if (result === 'HEAD') { + coinEl.classList.add('animate-to-heads'); + } else { + coinEl.classList.add('animate-to-tails'); + } + + console.log(`Animation applied for result: ${result}`); + } + + /** + * Validates input as the user types to prevent invalid values + */ + validateBetInput(event: KeyboardEvent) { + // Allow navigation keys (arrows, delete, backspace, tab) + const navigationKeys = ['ArrowLeft', 'ArrowRight', 'Delete', 'Backspace', 'Tab']; + if (navigationKeys.includes(event.key)) { + return; + } + + // Only allow numbers + if (!/^\d$/.test(event.key)) { + event.preventDefault(); + return; + } + + // Get the value that would result after the keypress + const input = event.target as HTMLInputElement; + const currentValue = input.value; + const cursorPosition = input.selectionStart || 0; + const newValue = + currentValue.substring(0, cursorPosition) + + event.key + + currentValue.substring(input.selectionEnd || cursorPosition); + const numValue = Number(newValue); + + // Prevent values greater than balance + if (numValue > this.balance()) { + event.preventDefault(); + } + } + + // We removed the paste handler for simplicity since the updateBet method + // will handle any value that gets into the input field + + getResultClass() { + if (!this.gameResult()) return ''; + const result = this.gameResult(); + const isWinner = result?.isWin || result?.win; + return isWinner ? 'text-emerald-500' : 'text-accent-red'; + } +} diff --git a/frontend/src/app/feature/game/coinflip/models/coinflip.model.ts b/frontend/src/app/feature/game/coinflip/models/coinflip.model.ts new file mode 100644 index 0000000..f87a3f9 --- /dev/null +++ b/frontend/src/app/feature/game/coinflip/models/coinflip.model.ts @@ -0,0 +1,11 @@ +export interface CoinflipGame { + isWin?: boolean; + win?: boolean; + payout: number; + coinSide: 'HEAD' | 'TAILS'; +} + +export interface CoinflipRequest { + betAmount: number; + coinSide: 'HEAD' | 'TAILS'; +} diff --git a/frontend/src/app/feature/home/home.component.ts b/frontend/src/app/feature/home/home.component.ts index 494c03f..1fad4cb 100644 --- a/frontend/src/app/feature/home/home.component.ts +++ b/frontend/src/app/feature/home/home.component.ts @@ -48,9 +48,9 @@ export default class HomeComponent implements OnInit { featuredGames: Game[] = [ { id: '1', - name: 'Poker', - image: '/poker.webp', - route: '/game/poker', + name: 'Coinflip', + image: '/coinflip.png', + route: '/game/coinflip', }, { id: '2', diff --git a/justfile b/justfile index 1d68253..4fc777c 100644 --- a/justfile +++ b/justfile @@ -21,3 +21,7 @@ build-be: # Builds the frontend docker image build-fe: docker buildx build -f frontend/.docker/Dockerfile -t git.kjan.de/szut/casino-frontend:latest frontend + +# Formats the code duh +format: + cd frontend && bunx prettier --write "src/**/*.{ts,html,css,scss}"