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()) {
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Game Information
+
+
+
+
Current Bet:
+
0 ? 'text-accent-red' : 'text-text-secondary'">
+ €
+
+
+
+
+
+ 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}"