diff --git a/frontend/public/images/1-box.png b/frontend/public/images/1-box.png new file mode 100644 index 0000000..b343dbd Binary files /dev/null and b/frontend/public/images/1-box.png differ diff --git a/frontend/public/images/2-box.png b/frontend/public/images/2-box.png new file mode 100644 index 0000000..0ec4438 Binary files /dev/null and b/frontend/public/images/2-box.png differ diff --git a/frontend/public/images/3-box.png b/frontend/public/images/3-box.png new file mode 100644 index 0000000..87a8401 Binary files /dev/null and b/frontend/public/images/3-box.png differ diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index ce4451c..a207b91 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -26,4 +26,15 @@ export const routes: Routes = [ loadComponent: () => import('./feature/game/slots/slots.component'), canActivate: [authGuard], }, + { + path: 'game/lootboxes', + loadComponent: () => + import('./feature/lootboxes/lootbox-selection/lootbox-selection.component'), + canActivate: [authGuard], + }, + { + path: 'game/lootboxes/open/:id', + loadComponent: () => import('./feature/lootboxes/lootbox-opening/lootbox-opening.component'), + canActivate: [authGuard], + }, ]; diff --git a/frontend/src/app/feature/home/home.component.ts b/frontend/src/app/feature/home/home.component.ts index be84450..5571d2b 100644 --- a/frontend/src/app/feature/home/home.component.ts +++ b/frontend/src/app/feature/home/home.component.ts @@ -82,7 +82,7 @@ export default class HomeComponent implements OnInit { id: '6', name: 'Lootboxen', image: '/lootbox.webp', - route: '/game/lootbox', + route: '/game/lootboxes', }, ]; diff --git a/frontend/src/app/feature/lootboxes/lootbox-opening/lootbox-opening.component.css b/frontend/src/app/feature/lootboxes/lootbox-opening/lootbox-opening.component.css new file mode 100644 index 0000000..1272a49 --- /dev/null +++ b/frontend/src/app/feature/lootboxes/lootbox-opening/lootbox-opening.component.css @@ -0,0 +1,320 @@ +body { + background: linear-gradient(to bottom, #181c2a, #232c43); +} + +/* Color classes */ +.text-yellow-400 { + color: #facc15; +} +.text-purple-400 { + color: #a78bfa; +} +.text-blue-400 { + color: #60a5fa; +} + +.border-yellow-400 { + border-color: #facc15; +} +.border-purple-400 { + border-color: #a78bfa; +} +.border-blue-400 { + border-color: #60a5fa; +} + +/* Loader animation */ +.loader { + border: 4px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top: 4px solid #fff; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* Open button styling */ +.open-btn { + background: linear-gradient(90deg, #4338ca 0%, #8b5cf6 100%); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3); + transition: all 0.2s ease; +} +.open-btn:hover { + background: linear-gradient(90deg, #4f46e5 0%, #a78bfa 100%); + transform: translateY(-2px); + box-shadow: 0 6px 8px rgba(0, 0, 0, 0.3); +} + +/* CSGO-style case opening display */ +.case-container { + position: relative; + width: 100%; + background: rgba(26, 31, 48, 0.6); + border-radius: 8px; + box-shadow: 0 0 20px rgba(0, 0, 0, 0.3); + overflow: hidden; + margin-bottom: 20px; + display: flex; + justify-content: center; +} + +.case-indicator { + position: absolute; + top: 50%; + left: 50%; /* Back to center - we'll adjust the animation instead */ + transform: translate(-50%, -50%); /* Center precisely */ + width: 6px; + height: 100%; + background: #facc15; + box-shadow: + 0 0 10px #facc15, + 0 0 15px rgba(255, 255, 255, 0.5); + z-index: 3; + animation: indicator-pulse 1.5s ease-in-out 10s infinite alternate; +} + +@keyframes indicator-pulse { + 0% { + opacity: 0.6; + box-shadow: + 0 0 10px #facc15, + 0 0 15px rgba(255, 255, 255, 0.3); + } + 100% { + opacity: 1; + box-shadow: + 0 0 15px #facc15, + 0 0 20px rgba(255, 255, 255, 0.7); + } +} + +.case-items-container { + position: relative; + z-index: 1; + padding: 10px 5px; + margin: 0 auto; + width: 100%; + height: 150px; /* Fixed height for the horizontal row */ + overflow: hidden; /* Hide scrollbar */ + display: flex; + justify-content: center; /* Center the items container */ + align-items: center; +} + +.case-items { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 8px; + padding: 5px 0; + height: 100%; + width: 100%; + animation: slide-in 10s cubic-bezier(0.33, 0.9, 0.3, 1) forwards; + transform: translateX(4500px); + position: relative; +} + +@keyframes slide-in { + 0% { + transform: translateX(4500px); + } + 100% { + transform: translateX(-37.5px); + } +} + +.case-item { + transition: all 0.2s ease; + padding: 2px; + animation: item-flash 0.3s ease-out forwards; + animation-play-state: paused; + width: 69px; + flex-shrink: 0; +} + +@keyframes item-flash { + 0% { + filter: brightness(1); + } + 50% { + filter: brightness(1.8); + } + 100% { + filter: brightness(1.2); + } +} + +.case-item-inner { + background: #232c43; + border: 2px solid #2d3748; + border-radius: 8px; + padding: 10px 5px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; +} + +.case-item-won { + z-index: 2; + animation: highlight-winner 1s ease-out 10s forwards; +} + +.winning-prize { + border: 2px solid transparent; + transition: all 0.5s ease; +} + +.winning-prize.highlight { + border-color: #fff !important; + box-shadow: 0 0 8px rgba(255, 255, 255, 0.6) !important; +} + +/* Specific ID for the winning item to ensure it's visible */ +#winning-item { + z-index: 5; /* Higher than indicator */ +} + +@keyframes highlight-winner { + 0% { + transform: scale(1); + filter: brightness(1); + } + 10% { + transform: scale(1.1); + filter: brightness(1.5); + } + 20% { + transform: scale(1.05); + filter: brightness(1.3); + } + 30% { + transform: scale(1.1); + filter: brightness(1.5); + } + 40% { + transform: scale(1.05); + filter: brightness(1.3); + } + 50% { + transform: scale(1.1); + filter: brightness(1.5); + } + 60% { + transform: scale(1.05); + filter: brightness(1.3); + } + 70% { + transform: scale(1.1); + filter: brightness(1.5); + } + 80% { + transform: scale(1.05); + filter: brightness(1.3); + } + 90% { + transform: scale(1.1); + filter: brightness(1.5); + } + 100% { + transform: scale(1.05); + filter: brightness(1.3); + } +} + +.amount { + font-size: 1rem; + font-weight: bold; + margin-bottom: 4px; +} + +.rarity { + font-size: 0.75rem; + opacity: 0.7; +} + +/* Prize animation */ +.prize-reel { + animation: slide-prizes 10s cubic-bezier(0.05, 0.82, 0.17, 1) forwards; +} + +@keyframes slide-prizes { + 0% { + transform: translateX(800px); + } + 85% { + transform: translateX(-120px); /* Small overshoot */ + } + 92% { + transform: translateX(-90px); /* Bounce back */ + } + 100% { + transform: translateX(-100px); /* Final position centered */ + } +} + +.animate-item-flash { + animation: item-flash 0.5s ease-out alternate infinite; +} + +.highlight { + animation: highlight-winner 1s ease-out forwards; +} + +/* Reward rarity classes */ +.text-common { + color: #ffffff; +} + +.text-uncommon { + color: #4ade80; +} + +.text-rare { + color: #60a5fa; +} + +.text-epic { + color: #a78bfa; +} + +.text-legendary { + color: #facc15; +} + +.text-mythic { + color: #f87171; +} + +.text-emerald { + color: #10b981; +} + +.text-accent-red { + color: #ef4444; +} + +.animation-fade { + opacity: 0; + transform: translateY(10px); + transition: + opacity 0.5s ease-out, + transform 0.5s ease-out; + transition-delay: 0.5s; +} + +.animation-fade.visible { + opacity: 1; + transform: translateY(0); +} diff --git a/frontend/src/app/feature/lootboxes/lootbox-opening/lootbox-opening.component.html b/frontend/src/app/feature/lootboxes/lootbox-opening/lootbox-opening.component.html new file mode 100644 index 0000000..0da3ed8 --- /dev/null +++ b/frontend/src/app/feature/lootboxes/lootbox-opening/lootbox-opening.component.html @@ -0,0 +1,150 @@ + + +
+

Lootbox Öffnen

+ +
+
+
+ +
+ {{ error }} +
+ +
+
+
+
+ +
+ {{ lootbox.price | currency: 'EUR' }} +
+
+ +
+

{{ lootbox.name }}

+ +
+ +
+ +
+
+
Öffne Lootbox...
+
+ +
+
+

Dein Gewinn:

+
+ {{ wonReward?.value | currency: 'EUR' }} +
+
+ +
+
+ +
+
+
+
+
+ {{ reward.value | currency: 'EUR' }} +
+
+ {{ reward.probability * 100 | number: '1.0-0' }}% +
+
+
+
+
+
+ +
+ + +
+
+
+ +
+
+

Fairness garantiert - Alle Ergebnisse werden transparent berechnet.

+
+
+
+
+ +
+
+

Mögliche Gewinne:

+
    +
  • + {{ + reward.value | currency: 'EUR' + }} + {{ reward.probability * 100 | number: '1.0-0' }}% +
  • +
+ +
+

Gewinn-Details:

+
+
+ Kosten: + {{ lootbox.price | currency: 'EUR' }} +
+
+ Gewinn: + {{ wonReward.value | currency: 'EUR' }} +
+
+ Profit: + + {{ wonReward.value - (lootbox.price || 0) | currency: 'EUR' }} + +
+
+
+
+
+
+
diff --git a/frontend/src/app/feature/lootboxes/lootbox-opening/lootbox-opening.component.ts b/frontend/src/app/feature/lootboxes/lootbox-opening/lootbox-opening.component.ts new file mode 100644 index 0000000..cd3ed1f --- /dev/null +++ b/frontend/src/app/feature/lootboxes/lootbox-opening/lootbox-opening.component.ts @@ -0,0 +1,171 @@ +import { Component, ChangeDetectorRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ActivatedRoute, Router } from '@angular/router'; +import { LootboxService } from '../services/lootbox.service'; +import { LootBox, Reward } from 'app/model/LootBox'; +import { NavbarComponent } from '@shared/components/navbar/navbar.component'; + +@Component({ + selector: 'app-lootbox-opening', + standalone: true, + imports: [CommonModule, NavbarComponent], + templateUrl: './lootbox-opening.component.html', + styleUrls: ['./lootbox-opening.component.css'], +}) +export default class LootboxOpeningComponent { + lootbox: LootBox | null = null; + isLoading = true; + error = ''; + isOpening = false; + isOpen = false; + wonReward: Reward | null = null; + prizeList: Reward[] = []; + animationCompleted = false; + + constructor( + private route: ActivatedRoute, + private router: Router, + private lootboxService: LootboxService, + private cdr: ChangeDetectorRef + ) { + this.loadLootbox(); + } + + private loadLootbox(): void { + const idParam = this.route.snapshot.paramMap.get('id'); + if (!idParam) { + this.error = 'Invalid lootbox ID'; + this.isLoading = false; + return; + } + + const lootboxId = parseInt(idParam, 10); + this.lootboxService.getAllLootBoxes().subscribe({ + next: (lootboxes) => { + this.lootbox = lootboxes.find((box) => box.id === lootboxId) || null; + this.isLoading = false; + this.cdr.detectChanges(); + }, + error: () => { + this.error = 'Failed to load lootbox data'; + this.isLoading = false; + this.cdr.detectChanges(); + }, + }); + } + + openLootbox(): void { + if (!this.lootbox || this.isOpening) return; + + this.resetState(true); + + setTimeout(() => { + this.lootboxService.purchaseLootBox(this.lootbox!.id).subscribe({ + next: this.handleRewardSuccess.bind(this), + error: this.handleRewardError.bind(this), + }); + }, 100); + } + + private handleRewardSuccess(reward: Reward): void { + this.wonReward = reward; + this.generateCasePrizes(reward); + this.isOpening = false; + this.isOpen = true; + this.cdr.detectChanges(); + } + + private handleRewardError(): void { + if (!this.lootbox) return; + + const rewards = this.lootbox.rewards; + const fallback = rewards[Math.floor(Math.random() * rewards.length)]; + this.handleRewardSuccess(fallback); + } + + private resetState(isOpening = false): void { + this.isOpening = isOpening; + this.isOpen = false; + this.wonReward = null; + this.prizeList = []; + this.animationCompleted = false; + this.cdr.detectChanges(); + } + + generateCasePrizes(wonReward: Reward): void { + if (!this.lootbox) return; + + const prizeCount = 120; + const winningPosition = Math.floor(prizeCount / 2); + const possibleRewards = this.lootbox.rewards; + const items: Reward[] = []; + + for (let i = 0; i < prizeCount; i++) { + if (i === winningPosition) { + items.push({ ...wonReward }); + } else { + items.push(this.getWeightedRandomReward(possibleRewards)); + } + } + + this.prizeList = items; + + setTimeout(() => { + this.animationCompleted = true; + this.cdr.detectChanges(); + }, 10000); + } + + getWeightedRandomReward(rewards: Reward[]): Reward { + const totalProbability = rewards.reduce((sum, reward) => sum + reward.probability, 0); + const randomValue = Math.random() * totalProbability; + let cumulativeProbability = 0; + + for (const reward of rewards) { + cumulativeProbability += reward.probability; + if (randomValue <= cumulativeProbability) { + return { ...reward }; + } + } + + return { ...rewards[0] }; + } + + openAgain(): void { + this.resetState(); + this.openLootbox(); + } + + getBoxImage(id: number): string { + return `/images/${id}-box.png`; + } + + goBack(): void { + this.router.navigate(['/game/lootboxes']); + } + + isWonReward(reward: Reward): boolean { + if (!this.wonReward || !this.prizeList.length) return false; + + const middleIndex = Math.floor(this.prizeList.length / 2); + return this.prizeList.indexOf(reward) === middleIndex; + } + + getRewardRarityClass(reward: Reward): string { + if (!reward) return 'text-common'; + + const probability = reward.probability; + + if (probability < 0.01) return 'text-mythic'; + if (probability < 0.05) return 'text-legendary'; + if (probability < 0.1) return 'text-epic'; + if (probability < 0.2) return 'text-rare'; + if (probability < 0.4) return 'text-uncommon'; + return 'text-common'; + } + + getRewardClass(): string { + if (!this.wonReward || !this.lootbox) return ''; + return this.wonReward.value > (this.lootbox.price || 0) ? 'text-emerald' : 'text-accent-red'; + } +} diff --git a/frontend/src/app/feature/lootboxes/lootbox-selection/lootbox-selection.component.css b/frontend/src/app/feature/lootboxes/lootbox-selection/lootbox-selection.component.css new file mode 100644 index 0000000..878220b --- /dev/null +++ b/frontend/src/app/feature/lootboxes/lootbox-selection/lootbox-selection.component.css @@ -0,0 +1,28 @@ +.loader { + border: 4px solid rgba(255, 255, 255, 0.3); + border-radius: 50%; + border-top: 4px solid #fff; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +.card { + transition: + transform 0.3s ease, + box-shadow 0.3s ease; +} + +.card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.5); +} diff --git a/frontend/src/app/feature/lootboxes/lootbox-selection/lootbox-selection.component.html b/frontend/src/app/feature/lootboxes/lootbox-selection/lootbox-selection.component.html new file mode 100644 index 0000000..bda9028 --- /dev/null +++ b/frontend/src/app/feature/lootboxes/lootbox-selection/lootbox-selection.component.html @@ -0,0 +1,65 @@ + +
+

Lootboxen

+ +
+ isLoading: {{ isLoading }} | error: {{ error }} | lootboxes: {{ lootboxes.length }} +
+ +
+
+
+ +
+ {{ error }} +
+ +
+
+
+ +
+ {{ lootbox.price | currency: 'EUR' }} +
+
+ +
+

{{ lootbox.name }}

+ +
+

Mögliche Gewinne:

+
    +
  • + {{ + reward.value | currency: 'EUR' + }} + {{ formatProbability(reward.probability) }} +
  • +
+
+ +
+ +
+
+ +
+
+

Fairness garantiert - Alle Ergebnisse werden transparent berechnet.

+
+
+
+
+
diff --git a/frontend/src/app/feature/lootboxes/lootbox-selection/lootbox-selection.component.ts b/frontend/src/app/feature/lootboxes/lootbox-selection/lootbox-selection.component.ts new file mode 100644 index 0000000..217af16 --- /dev/null +++ b/frontend/src/app/feature/lootboxes/lootbox-selection/lootbox-selection.component.ts @@ -0,0 +1,139 @@ +import { Component, OnInit, ChangeDetectorRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { NavbarComponent } from '@shared/components/navbar/navbar.component'; +import { LootboxService } from '../services/lootbox.service'; +import { LootBox } from 'app/model/LootBox'; +import { Router } from '@angular/router'; +import { timeout } from 'rxjs'; + +@Component({ + selector: 'app-lootbox-selection', + standalone: true, + imports: [CommonModule, NavbarComponent], + templateUrl: './lootbox-selection.component.html', + styleUrls: ['./lootbox-selection.component.css'], +}) +export default class LootboxSelectionComponent implements OnInit { + lootboxes: LootBox[] = []; + isLoading = true; + error = ''; + + // Fallback data in case the API call fails + fallbackLootboxes: LootBox[] = [ + { + id: 1, + name: 'Basic LootBox', + price: 2.0, + rewards: [ + { + id: 1, + value: 0.5, + probability: 0.7, + }, + { + id: 5, + value: 5.0, + probability: 0.3, + }, + ], + }, + { + id: 2, + name: 'Premium LootBox', + price: 5.0, + rewards: [ + { + id: 4, + value: 2.0, + probability: 0.6, + }, + { + id: 5, + value: 5.0, + probability: 0.3, + }, + { + id: 6, + value: 15.0, + probability: 0.1, + }, + ], + }, + { + id: 3, + name: 'Legendäre LootBox', + price: 15.0, + rewards: [ + { + id: 4, + value: 2.0, + probability: 0.6, + }, + { + id: 5, + value: 5.0, + probability: 0.3, + }, + { + id: 6, + value: 15.0, + probability: 0.1, + }, + ], + }, + ]; + + constructor( + private lootboxService: LootboxService, + private router: Router, + private cdr: ChangeDetectorRef + ) {} + + ngOnInit(): void { + this.loadLootboxes(); + } + + loadLootboxes(): void { + this.isLoading = true; + this.lootboxService + .getAllLootBoxes() + .pipe(timeout(5000)) + .subscribe({ + next: (data) => { + console.log('Received lootboxes:', data); + this.lootboxes = data; + this.isLoading = false; + this.cdr.detectChanges(); + }, + error: (err) => { + this.error = 'Konnte keine Verbindung zum Backend herstellen. Zeige Demo-Daten.'; + this.lootboxes = this.fallbackLootboxes; + this.isLoading = false; + this.cdr.detectChanges(); + console.error('Failed to load lootboxes:', err); + }, + }); + } + + getBoxImage(id: number): string { + return `/images/${id}-box.png`; + } + + openLootbox(lootboxId: number): void { + this.router.navigate(['/game/lootboxes/open', lootboxId]); + } + + getRarityClass(probability: number): string { + if (probability <= 0.1) { + return 'text-yellow-400'; // Legendary + } else if (probability <= 0.3) { + return 'text-purple-400'; // Rare + } else { + return 'text-blue-400'; // Common + } + } + + formatProbability(probability: number): string { + return (probability * 100).toFixed(0) + '%'; + } +} diff --git a/frontend/src/app/feature/lootboxes/services/lootbox.service.ts b/frontend/src/app/feature/lootboxes/services/lootbox.service.ts new file mode 100644 index 0000000..b0fb58d --- /dev/null +++ b/frontend/src/app/feature/lootboxes/services/lootbox.service.ts @@ -0,0 +1,31 @@ +import { Injectable, inject } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable, catchError } from 'rxjs'; +import { LootBox, Reward } from 'app/model/LootBox'; + +@Injectable({ + providedIn: 'root', +}) +export class LootboxService { + private http = inject(HttpClient); + + getAllLootBoxes(): Observable { + return this.http.get('/backend/lootboxes', { responseType: 'json' }).pipe( + catchError((error) => { + console.error('Get lootboxes error:', error); + throw error; + }) + ); + } + + purchaseLootBox(lootBoxId: number): Observable { + return this.http + .post(`/backend/lootboxes/${lootBoxId}`, {}, { responseType: 'json' }) + .pipe( + catchError((error) => { + console.error('Purchase lootbox error:', error); + throw error; + }) + ); + } +} diff --git a/frontend/src/app/model/LootBox.ts b/frontend/src/app/model/LootBox.ts new file mode 100644 index 0000000..ebcf6e8 --- /dev/null +++ b/frontend/src/app/model/LootBox.ts @@ -0,0 +1,12 @@ +export interface Reward { + id: number; + value: number; + probability: number; +} + +export interface LootBox { + id: number; + name: string; + price: number; + rewards: Reward[]; +}