Compare commits

..

5 commits

Author SHA1 Message Date
e72944d177
Merge pull request 'feat(dice): enhance game UI and add sound effects' (!216) from task/CAS-75/UpdateFrontendDice into main
All checks were successful
Release / Release (push) Successful in 1m1s
Release / Build Backend Image (push) Successful in 24s
Release / Build Frontend Image (push) Successful in 29s
Reviewed-on: #216
Reviewed-by: Jan K9f <jan@kjan.email>
Reviewed-by: Constantin Simonis <constantin@simonis.lol>
2025-05-21 12:21:12 +00:00
da90a332dc
style(dice.component.html): fix whitespace in HTML file
All checks were successful
CI / Get Changed Files (pull_request) Successful in 13s
CI / Checkstyle Main (pull_request) Has been skipped
CI / Docker backend validation (pull_request) Has been skipped
CI / oxlint (pull_request) Successful in 34s
CI / eslint (pull_request) Successful in 45s
CI / prettier (pull_request) Successful in 33s
CI / Docker frontend validation (pull_request) Successful in 1m14s
CI / test-build (pull_request) Successful in 41s
2025-05-21 14:07:03 +02:00
ed252696c4
Merge branch 'main' into task/CAS-75/UpdateFrontendDice
Some checks failed
CI / Get Changed Files (pull_request) Successful in 8s
CI / Checkstyle Main (pull_request) Has been skipped
CI / Docker backend validation (pull_request) Has been skipped
CI / oxlint (pull_request) Successful in 21s
CI / eslint (pull_request) Successful in 30s
CI / prettier (pull_request) Failing after 29s
CI / Docker frontend validation (pull_request) Successful in 1m9s
CI / test-build (pull_request) Successful in 1m12s
2025-05-21 12:06:41 +00:00
a1997537eb
style(dice.component.html): Update layout of game instructions
Some checks failed
CI / Get Changed Files (pull_request) Successful in 31s
CI / Checkstyle Main (pull_request) Has been skipped
CI / Docker backend validation (pull_request) Has been skipped
CI / eslint (pull_request) Successful in 28s
CI / prettier (pull_request) Failing after 29s
CI / oxlint (pull_request) Successful in 31s
CI / Docker frontend validation (pull_request) Successful in 48s
CI / test-build (pull_request) Successful in 36s
2025-05-21 14:02:47 +02:00
1849500d74
feat(dice): enhance game UI and add sound effects
All checks were successful
CI / Get Changed Files (pull_request) Successful in 8s
CI / Checkstyle Main (pull_request) Has been skipped
CI / Docker backend validation (pull_request) Has been skipped
CI / oxlint (pull_request) Successful in 18s
CI / eslint (pull_request) Successful in 25s
CI / prettier (pull_request) Successful in 25s
CI / Docker frontend validation (pull_request) Successful in 45s
CI / test-build (pull_request) Successful in 45s
2025-05-21 13:52:16 +02:00
4 changed files with 374 additions and 108 deletions

View file

@ -1,121 +1,305 @@
<div class="container mx-auto px-4 py-8 space-y-8"> <div class="container mx-auto px-4 py-6 space-y-8">
<h1 class="text-3xl font-bold text-white mb-6">Dice Game</h1> <h1 class="text-3xl font-bold text-white mb-6">Dice</h1>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div class="lg:col-span-3 space-y-6 flex flex-col gap-4">
<form [formGroup]="diceForm">
<div class="card p-6">
<div class="space-y-4">
<div class="flex justify-between items-center mb-2">
<h3 class="text-white font-semibold">
Zielwert:
<span class="text-white">{{
diceForm.get('targetValue')?.value | number: '1.0-2'
}}</span>
</h3>
</div>
<div class="grid grid-cols-1 lg:grid-cols-4 gap-8"> <div class="relative py-4">
<div class="lg:col-span-1 card p-8 space-y-6"> <div class="flex justify-between text-xs text-text-secondary px-1 mb-2">
<form [formGroup]="diceForm" (ngSubmit)="roll()" class="space-y-6"> <span>0</span>
<div class="controls space-y-4"> <span>25</span>
<div> <span>50</span>
<label for="betAmount" class="block text-text-secondary mb-2">Bet Amount:</label> <span>75</span>
<input <span>100</span>
id="betAmount" </div>
type="number"
formControlName="betAmount" <style>
min="0.01" @keyframes fade-out {
step="0.01" from {
class="w-full bg-deep-blue-light text-white rounded-lg p-2 focus:outline-none focus:ring-1 focus:ring-emerald" opacity: 0.4;
/>
@if (hasError('betAmount', 'required')) {
<span class="text-accent-red text-sm mt-1 block">Bet Amount is required</span>
} }
@if (hasError('betAmount', 'min')) { to {
<span class="text-accent-red text-sm mt-1 block" opacity: 0;
>Bet Amount must be at least 0.01</span }
}
.result-marker {
transition: left 0.8s cubic-bezier(0.68, -0.55, 0.27, 1.55);
}
.win-display {
transition: all 0.5s ease;
}
</style>
<div class="relative h-12 bg-deep-blue-dark rounded-xl overflow-hidden shadow-inner">
<div
class="absolute top-0 left-0 w-full h-full transition-all duration-300 ease-in-out"
[style.background]="getTrackGradient()"
></div>
<input
id="targetValue"
type="range"
formControlName="targetValue"
min="1"
max="99"
step="1"
class="w-full h-full absolute top-0 left-0 opacity-0 cursor-pointer"
[appDragSound]="diceForm.get('targetValue')"
/>
<div
class="absolute top-1/2 -translate-y-1/2 w-10 h-10 bg-white rounded-full flex items-center justify-center shadow-lg pointer-events-none border-2 ease-in-out"
[ngClass]="{
'border-emerald': diceForm.get('rollOver')?.value,
'border-accent-red': !diceForm.get('rollOver')?.value,
}"
[style.left]="'calc(' + (diceForm.get('targetValue')?.value ?? 50) + '% - 20px)'"
> >
<div
class="absolute -top-12 left-1/2 -translate-x-1/2 bg-white text-deep-blue-contrast px-3 py-1 rounded-md text-sm font-bold shadow transition-all duration-300 ease-in-out win-display"
>
{{ potentialWin() | currency: 'EUR' : 'symbol' : '1.2-2' }}
</div>
<span class="text-deep-blue-contrast text-sm font-extrabold select-none">
{{ diceForm.get('rollOver')?.value ? '>' : '<' }}
</span>
</div>
<div class="hidden">
<div class="relative flex items-center justify-center">
<svg width="60" height="60" viewBox="0 0 100 100" class="filter drop-shadow-md">
<polygon points="50,0 100,25 100,75 50,100 0,75 0,25" fill="white" />
</svg>
<span
class="absolute text-deep-blue-contrast font-bold"
style="font-size: 14px"
>
{{ potentialWin() | currency: 'EUR' : 'symbol' : '1.2-2' }}
</span>
</div>
</div>
@if (rolledValue() !== null) {
<div
class="absolute top-0 h-full z-20 result-marker"
[style.left]="rolledValue() + '%'"
style="transition: left 0.8s cubic-bezier(0.68, -0.55, 0.27, 1.55)"
>
<div
class="h-full w-2 pointer-events-none animate-pulse"
[ngClass]="{
'bg-emerald': win(),
'bg-accent-red': !win(),
}"
></div>
</div>
} }
</div> </div>
<div> <div class="relative h-1 mt-1">
<div class="block text-text-secondary mb-2">Roll Mode:</div> @for (i of [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100]; track i) {
<div class="roll-mode flex rounded-lg overflow-hidden"> <div
class="absolute top-0 w-0.5 h-1 bg-text-tertiary"
[style.left]="i + '%'"
></div>
}
</div>
</div>
@if (
hasError('targetValue', 'required') ||
hasError('targetValue', 'min') ||
hasError('targetValue', 'max')
) {
<div class="p-2 bg-accent-red/10 border border-accent-red/20 rounded-lg">
@if (hasError('targetValue', 'required')) {
<span class="text-accent-red text-sm block">Zielwert ist erforderlich</span>
}
@if (hasError('targetValue', 'min')) {
<span class="text-accent-red text-sm block">Zielwert muss mindestens 1 sein</span>
}
@if (hasError('targetValue', 'max')) {
<span class="text-accent-red text-sm block">Zielwert darf höchstens 99 sein</span>
}
</div>
}
</div>
<div class="flex justify-center gap-4 mt-8">
<button <button
type="button" type="button"
(click)="toggleRollMode()" (click)="toggleRollMode()"
[ngClass]="{ [ngClass]="{
'bg-emerald text-white': diceForm.get('rollOver')?.value, 'bg-emerald text-white': diceForm.get('rollOver')?.value,
'bg-deep-blue-light text-text-secondary': !diceForm.get('rollOver')?.value, 'bg-deep-blue-light text-text-secondary hover:bg-deep-blue-light/80':
!diceForm.get('rollOver')?.value,
}" }"
class="flex-1 py-2 text-center font-semibold transition-colors duration-200" class="py-3 px-8 rounded-lg text-lg"
appPlaySound
> >
Roll Over Über Zielwert
</button> </button>
<button <button
type="button" type="button"
(click)="toggleRollMode()" (click)="toggleRollMode()"
[ngClass]="{ [ngClass]="{
'bg-emerald text-white': !diceForm.get('rollOver')?.value, 'bg-emerald text-white': !diceForm.get('rollOver')?.value,
'bg-deep-blue-light text-text-secondary': diceForm.get('rollOver')?.value, 'bg-deep-blue-light text-text-secondary hover:bg-deep-blue-light/80':
diceForm.get('rollOver')?.value,
}" }"
class="flex-1 py-2 text-center font-semibold transition-colors duration-200" class="py-3 px-8 rounded-lg text-lg"
appPlaySound
> >
Roll Under Unter Zielwert
</button> </button>
</div> </div>
</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> </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) { @if (rolledValue() !== null) {
<div class="result max-w-sm mx-auto card p-6 mt-8 text-center"> <div class="card p-4">
<div class="flex items-center justify-center">
@if (win()) { @if (win()) {
<svg
class="w-6 h-6 text-emerald mr-2"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
clip-rule="evenodd"
></path>
</svg>
<p class="text-emerald text-base font-semibold"> <p class="text-emerald text-base font-semibold">
You Won! Payout: {{ payout() | currency: 'EUR' : 'symbol' : '1.2-2' }} Du hast gewonnen! Auszahlung: {{ payout() | currency: 'EUR' : 'symbol' : '1.2-2' }}
</p> </p>
} @else { } @else {
<p class="text-accent-red text-base font-semibold">You Lost.</p> <svg
class="w-6 h-6 text-accent-red mr-2"
fill="currentColor"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
clip-rule="evenodd"
></path>
</svg>
<p class="text-accent-red text-base font-semibold">Du hast verloren.</p>
} }
</div> </div>
</div>
} }
</div>
<div class="lg:col-span-1 space-y-6">
<div class="card p-4">
<h3 class="section-heading text-xl mb-4">Spielinformationen</h3>
<form [formGroup]="diceForm" (ngSubmit)="roll()" class="space-y-4">
<div class="flex justify-between items-center">
<span class="text-text-secondary">Möglicher Gewinn:</span>
<span class="text-emerald">{{
potentialWin() | currency: 'EUR' : 'symbol' : '1.2-2'
}}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-text-secondary">Gewinnchance:</span>
<span class="text-white">{{ winChance() | number: '1.0-2' }}%</span>
</div>
<div class="grid grid-cols-2 gap-2 mb-4">
<button
type="button"
(click)="setBetAmount(0.1)"
class="button-primary py-2 text-sm"
appPlaySound
>
10%
</button>
<button
type="button"
(click)="setBetAmount(0.25)"
class="button-primary py-2 text-sm"
appPlaySound
>
25%
</button>
<button
type="button"
(click)="setBetAmount(0.5)"
class="button-primary py-2 text-sm"
appPlaySound
>
50%
</button>
<button
type="button"
(click)="setBetAmount(1)"
class="button-primary py-2 text-sm"
appPlaySound
>
100%
</button>
</div>
<div class="space-y-1">
<div class="flex justify-between">
<label for="betAmount" class="text-sm text-text-secondary">Einsatzbetrag</label>
</div>
<input
id="betAmount"
type="number"
formControlName="betAmount"
min="0.01"
step="0.01"
class="w-full px-3 py-2 bg-deep-blue-light text-white rounded focus:outline-none focus:ring-2 ring-emerald"
/>
@if (hasError('betAmount', 'required')) {
<span class="text-accent-red text-xs mt-1 block">Einsatz ist erforderlich</span>
}
@if (hasError('betAmount', 'min')) {
<span class="text-accent-red text-xs mt-1 block"
>Einsatz muss mindestens 0.01 sein</span
>
}
</div>
<button
type="submit"
class="button-primary w-full py-3 font-bold flex items-center justify-center"
appPlaySound
>
Würfeln
</button>
<div class="mt-6 pt-4 border-t border-gray-700">
<h4 class="text-lg font-semibold mb-2">Spielanleitung</h4>
<ul class="text-sm text-text-secondary space-y-1">
<li>• Setze deinen Einsatz und Zielwert</li>
<li>• Wähle "Über Zielwert" oder "Unter Zielwert"</li>
<li>• Gewinne, wenn der Würfel zu deinen Gunsten fällt</li>
<li>• Höheres Risiko = höhere Belohnung</li>
</ul>
</div>
</form>
</div>
</div>
</div>
</div> </div>

View file

@ -11,6 +11,9 @@ import { DiceService } from './dice.service';
import { DiceDto, DiceResult } from './dice.model'; import { DiceDto, DiceResult } from './dice.model';
import { tap } from 'rxjs/operators'; import { tap } from 'rxjs/operators';
import { UserService } from '@service/user.service'; import { UserService } from '@service/user.service';
import { PlaySoundDirective } from '@shared/directives/play-sound.directive';
import { DragSoundDirective } from '@shared/directives/drag-sound.directive';
import { AudioService } from '@shared/services/audio.service';
type DiceFormGroup = FormGroup<{ type DiceFormGroup = FormGroup<{
betAmount: FormControl<number | null>; betAmount: FormControl<number | null>;
@ -21,13 +24,14 @@ type DiceFormGroup = FormGroup<{
@Component({ @Component({
selector: 'app-dice', selector: 'app-dice',
standalone: true, standalone: true,
imports: [CommonModule, ReactiveFormsModule], imports: [CommonModule, ReactiveFormsModule, PlaySoundDirective, DragSoundDirective],
templateUrl: './dice.component.html', templateUrl: './dice.component.html',
}) })
export default class DiceComponent implements OnInit { export default class DiceComponent implements OnInit {
private readonly formBuilder = inject(FormBuilder); private readonly formBuilder = inject(FormBuilder);
private readonly diceService = inject(DiceService); private readonly diceService = inject(DiceService);
private readonly userService = inject(UserService); private readonly userService = inject(UserService);
private readonly audioService = inject(AudioService);
rolledValue = signal<number | null>(null); rolledValue = signal<number | null>(null);
win = signal<boolean | null>(null); win = signal<boolean | null>(null);
@ -49,23 +53,23 @@ export default class DiceComponent implements OnInit {
createDiceForm(): DiceFormGroup { createDiceForm(): DiceFormGroup {
return this.formBuilder.group({ return this.formBuilder.group({
betAmount: new FormControl<number | null>(1.0, { betAmount: new FormControl<number | null>(1, {
validators: [Validators.required, Validators.min(0.01)], validators: [Validators.required, Validators.min(1)],
nonNullable: true, nonNullable: true,
}), }),
rollOver: new FormControl<boolean>(true, { rollOver: new FormControl<boolean>(true, {
validators: [Validators.required], validators: [Validators.required],
nonNullable: true, nonNullable: true,
}), }),
targetValue: new FormControl<number | null>(50.5, { targetValue: new FormControl<number | null>(50, {
validators: [Validators.required, Validators.min(1), Validators.max(100)], validators: [Validators.required, Validators.min(1), Validators.max(99)],
nonNullable: true, nonNullable: true,
}), }),
}); });
} }
toggleRollMode(): void { toggleRollMode(): void {
const currentMode = this.diceForm.get('rollOver')?.value; const currentMode = this.diceForm.get('rollOver')?.value ?? true;
this.diceForm.get('rollOver')?.setValue(!currentMode); this.diceForm.get('rollOver')?.setValue(!currentMode);
} }
@ -104,6 +108,11 @@ export default class DiceComponent implements OnInit {
this.rolledValue.set(result.rolledValue); this.rolledValue.set(result.rolledValue);
this.win.set(result.win); this.win.set(result.win);
this.payout.set(result.payout); this.payout.set(result.payout);
if (result.win) {
this.audioService.playWinSound();
}
this.userService.refreshCurrentUser(); this.userService.refreshCurrentUser();
}, },
error: (error) => { error: (error) => {
@ -112,6 +121,29 @@ export default class DiceComponent implements OnInit {
}); });
} }
setBetAmount(percentage: number): void {
const user = this.userService['authService'].currentUserValue;
if (!user) return;
const balance = user.balance || 0;
const newBet = Math.max(1, Math.floor(balance * percentage * 100) / 100);
this.diceForm.get('betAmount')?.setValue(newBet);
this.calculateWinChanceAndPotentialWin();
}
getTrackGradient(): string {
const targetValue = this.diceForm.get('targetValue')?.value ?? 50;
const isRollOver = this.diceForm.get('rollOver')?.value ?? true;
if (isRollOver) {
return `linear-gradient(to right, var(--color-accent-red) ${targetValue}%, var(--color-emerald) ${targetValue}%)`;
} else {
return `linear-gradient(to right, var(--color-accent-red) ${targetValue}%, var(--color-emerald) ${targetValue}%)`;
}
}
hasError(controlName: string, errorName: string): boolean { hasError(controlName: string, errorName: string): boolean {
const control = this.diceForm.get(controlName); const control = this.diceForm.get(controlName);
return control !== null && control.touched && control.hasError(errorName); return control !== null && control.touched && control.hasError(errorName);

View file

@ -0,0 +1,39 @@
import { Directive, ElementRef, HostListener, inject, Input, OnInit } from '@angular/core';
import { AudioService } from '../services/audio.service';
import { AbstractControl } from '@angular/forms';
@Directive({
selector: '[appDragSound]',
standalone: true,
})
export class DragSoundDirective implements OnInit {
private audioService = inject(AudioService);
private elementRef = inject(ElementRef);
private lastValue: number | null = null;
@Input('appDragSound') formControl: AbstractControl | null = null;
ngOnInit() {
if (this.formControl) {
this.lastValue = this.formControl.value;
this.formControl.valueChanges.subscribe((newValue) => {
if (this.lastValue !== newValue) {
this.playSound();
this.lastValue = newValue;
}
});
}
}
private playSound() {
this.audioService.playDragStepSound();
}
@HostListener('input')
onInput() {
if (!this.formControl) {
this.playSound();
}
}
}

View file

@ -27,4 +27,15 @@ export class AudioService {
audio.currentTime = 0; audio.currentTime = 0;
audio.play().catch((error) => console.error('Error playing win sound:', error)); audio.play().catch((error) => console.error('Error playing win sound:', error));
} }
getDragSound(): HTMLAudioElement {
return this.getAudio('drag.mp3');
}
playDragStepSound(): void {
const audio = this.getAudio('drag.mp3');
audio.currentTime = 0;
audio.volume = 0.5;
audio.play().catch((error) => console.error('Error playing drag step sound:', error));
}
} }