Merge pull request 'feat: implement dice page (CAS-75)' (!209) from feat-dice-frontend into main
All checks were successful
Release / Release (push) Successful in 1m0s
Release / Build Backend Image (push) Successful in 24s
Release / Build Frontend Image (push) Successful in 29s

Reviewed-on: #209
Reviewed-by: Jan K9f <jan@kjan.email>
This commit is contained in:
Phan Huy Tran 2025-05-21 09:11:20 +00:00
commit 3eea955c56
No known key found for this signature in database
GPG key ID: 944223E4D46B7412
7 changed files with 282 additions and 5 deletions

View file

@ -61,4 +61,9 @@ export const routes: Routes = [
loadComponent: () => import('./feature/lootboxes/lootbox-opening/lootbox-opening.component'), loadComponent: () => import('./feature/lootboxes/lootbox-opening/lootbox-opening.component'),
canActivate: [authGuard], canActivate: [authGuard],
}, },
{
path: 'game/dice',
loadComponent: () => import('./feature/game/dice/dice.component').then((m) => m.DiceComponent),
canActivate: [authGuard],
},
]; ];

View file

@ -0,0 +1,121 @@
<div class="container mx-auto px-4 py-8 space-y-8">
<h1 class="text-3xl font-bold text-white mb-6">Dice Game</h1>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8">
<div class="lg:col-span-1 card p-8 space-y-6">
<form [formGroup]="diceForm" (ngSubmit)="roll()" class="space-y-6">
<div class="controls space-y-4">
<div>
<label for="betAmount" class="block text-text-secondary mb-2">Bet Amount:</label>
<input
id="betAmount"
type="number"
formControlName="betAmount"
min="0.01"
step="0.01"
class="w-full bg-deep-blue-light text-white rounded-lg p-2 focus:outline-none focus:ring-1 focus:ring-emerald"
/>
@if (hasError('betAmount', 'required')) {
<span class="text-accent-red text-sm mt-1 block">Bet Amount is required</span>
}
@if (hasError('betAmount', 'min')) {
<span class="text-accent-red text-sm mt-1 block"
>Bet Amount must be at least 0.01</span
>
}
</div>
<div>
<div class="block text-text-secondary mb-2">Roll Mode:</div>
<div class="roll-mode flex rounded-lg overflow-hidden">
<button
type="button"
(click)="toggleRollMode()"
[ngClass]="{
'bg-emerald text-white': diceForm.get('rollOver')?.value,
'bg-deep-blue-light text-text-secondary': !diceForm.get('rollOver')?.value,
}"
class="flex-1 py-2 text-center font-semibold transition-colors duration-200"
>
Roll Over
</button>
<button
type="button"
(click)="toggleRollMode()"
[ngClass]="{
'bg-emerald text-white': !diceForm.get('rollOver')?.value,
'bg-deep-blue-light text-text-secondary': diceForm.get('rollOver')?.value,
}"
class="flex-1 py-2 text-center font-semibold transition-colors duration-200"
>
Roll Under
</button>
</div>
</div>
<div>
<label for="targetValue" class="block text-text-secondary mb-2"
>Target Value: {{ diceForm.get('targetValue')?.value | number: '1.0-2' }}</label
>
<input
id="targetValue"
type="range"
formControlName="targetValue"
min="1"
max="100"
step="0.01"
class="w-full h-2 bg-deep-blue-light rounded-lg appearance-none cursor-pointer range-lg accent-emerald"
/>
@if (hasError('targetValue', 'required')) {
<span class="text-accent-red text-sm mt-1 block">Target Value is required</span>
}
@if (hasError('targetValue', 'min')) {
<span class="text-accent-red text-sm mt-1 block"
>Target Value must be at least 1</span
>
}
@if (hasError('targetValue', 'max')) {
<span class="text-accent-red text-sm mt-1 block"
>Target Value must be at most 100</span
>
}
</div>
</div>
<div class="info space-y-2 text-text-secondary">
<p>
Win Chance: <span class="text-white">{{ winChance() | number: '1.0-2' }}%</span>
</p>
<p>
Potential Win:
<span class="text-white">{{
potentialWin() | currency: 'EUR' : 'symbol' : '1.2-2'
}}</span>
</p>
</div>
<button type="submit" class="button-primary w-full py-2 font-bold">Roll Dice</button>
</form>
</div>
<div class="lg:col-span-3 card p-8 flex items-center justify-center">
@if (rolledValue() !== null) {
<div class="text-white text-center text-8xl font-bold">
{{ rolledValue() }}
</div>
}
</div>
</div>
@if (rolledValue() !== null) {
<div class="result max-w-sm mx-auto card p-6 mt-8 text-center">
@if (win()) {
<p class="text-emerald text-base font-semibold">
You Won! Payout: {{ payout() | currency: 'EUR' : 'symbol' : '1.2-2' }}
</p>
} @else {
<p class="text-accent-red text-base font-semibold">You Lost.</p>
}
</div>
}
</div>

View file

@ -0,0 +1,122 @@
import { Component, signal, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
FormBuilder,
FormControl,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import { DiceService } from './dice.service';
import { DiceDto, DiceResult } from './dice.model';
import { debounceTime, tap } from 'rxjs/operators';
import { UserService } from '@service/user.service';
type DiceFormGroup = FormGroup<{
betAmount: FormControl<number | null>;
rollOver: FormControl<boolean>;
targetValue: FormControl<number | null>;
}>;
@Component({
selector: 'app-dice',
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './dice.component.html',
})
export class DiceComponent implements OnInit {
private readonly formBuilder = inject(FormBuilder);
private readonly diceService = inject(DiceService);
private readonly userService = inject(UserService);
rolledValue = signal<number | null>(null);
win = signal<boolean | null>(null);
payout = signal<number | null>(null);
winChance = signal(0);
potentialWin = signal(0);
readonly diceForm: DiceFormGroup = this.createDiceForm();
private readonly MAX_DICE_VALUE = 100;
ngOnInit(): void {
this.diceForm.valueChanges
.pipe(
debounceTime(100),
tap(() => this.calculateWinChanceAndPotentialWin())
)
.subscribe();
this.calculateWinChanceAndPotentialWin();
}
createDiceForm(): DiceFormGroup {
return this.formBuilder.group({
betAmount: new FormControl<number | null>(1.0, {
validators: [Validators.required, Validators.min(0.01)],
nonNullable: true,
}),
rollOver: new FormControl<boolean>(true, {
validators: [Validators.required],
nonNullable: true,
}),
targetValue: new FormControl<number | null>(50.5, {
validators: [Validators.required, Validators.min(1), Validators.max(100)],
nonNullable: true,
}),
});
}
toggleRollMode(): void {
const currentMode = this.diceForm.get('rollOver')?.value;
this.diceForm.get('rollOver')?.setValue(!currentMode);
}
calculateWinChanceAndPotentialWin(): void {
const formValues = this.diceForm.value;
const target = formValues.targetValue ?? 0;
const bet = formValues.betAmount ?? 0;
const isOver = formValues.rollOver ?? true;
const calculatedWinChance = isOver ? this.MAX_DICE_VALUE - target : target - 1;
this.winChance.set(Math.max(0, calculatedWinChance));
let multiplier = 0;
if (calculatedWinChance > 0) {
multiplier = (this.MAX_DICE_VALUE - 1) / calculatedWinChance;
}
this.potentialWin.set(bet * multiplier);
}
roll(): void {
if (this.diceForm.invalid) {
this.diceForm.markAllAsTouched();
return;
}
const diceDto: DiceDto = this.diceForm.getRawValue() as DiceDto;
this.rolledValue.set(null);
this.win.set(null);
this.payout.set(null);
this.diceService.rollDice(diceDto).subscribe({
next: (result: DiceResult) => {
this.rolledValue.set(result.rolledValue);
this.win.set(result.win);
this.payout.set(result.payout);
this.userService.refreshCurrentUser();
},
error: (error) => {
console.error('Dice roll failed:', error);
},
});
}
hasError(controlName: string, errorName: string): boolean {
const control = this.diceForm.get(controlName);
return control !== null && control.touched && control.hasError(errorName);
}
}

View file

@ -0,0 +1,11 @@
export interface DiceDto {
betAmount: number;
rollOver: boolean;
targetValue: number;
}
export interface DiceResult {
win: boolean;
payout: number;
rolledValue: number;
}

View file

@ -0,0 +1,18 @@
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { DiceDto, DiceResult } from './dice.model';
import { environment } from '@environments/environment';
@Injectable({
providedIn: 'root',
})
export class DiceService {
private apiUrl = `${environment.apiUrl}/dice`;
constructor(private http: HttpClient) {}
rollDice(diceDto: DiceDto): Observable<DiceResult> {
return this.http.post<DiceResult>(this.apiUrl, diceDto);
}
}

View file

@ -72,9 +72,9 @@ export default class HomeComponent implements OnInit {
}, },
{ {
id: '5', id: '5',
name: 'Liars Dice', name: 'Dice',
image: '/liars-dice.webp', image: '/liars-dice.webp',
route: '/game/liars-dice', route: '/game/dice',
}, },
{ {
id: '6', id: '6',

View file

@ -91,10 +91,10 @@
</div> </div>
<div class="card"> <div class="card">
<div class="game-card-content"> <div class="game-card-content">
<h3 class="game-heading-sm">Liars Dice</h3> <h3 class="game-heading-sm">Dice</h3>
<p class="game-text">Würfelspiel mit Strategie</p> <p class="game-text">Würfelspiel</p>
<a <a
routerLink="/game/liars-dice" routerLink="/game/dice"
class="button-primary w-full py-2 inline-block text-center" class="button-primary w-full py-2 inline-block text-center"
>Jetzt Spielen</a >Jetzt Spielen</a
> >