602 lines
17 KiB
TypeScript
602 lines
17 KiB
TypeScript
import { CommonModule } from '@angular/common';
|
|
import { Component, DestroyRef, OnDestroy, OnInit, inject } from '@angular/core';
|
|
import { FormsModule } from '@angular/forms';
|
|
import { Router } from '@angular/router';
|
|
import { finalize } from 'rxjs';
|
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
|
import { getErrorMessage } from '../../core/http/error-message.util';
|
|
import { CurrentUser } from '../../models/auth.models';
|
|
import { AuthDialogService } from '../../services/auth-dialog.service';
|
|
import { AuthService } from '../../services/auth.service';
|
|
import { GameApiService } from '../../services/game-api.service';
|
|
import { ThemeService } from '../../services/theme.service';
|
|
import { ChallengeCreateDialogComponent } from '../../components/challenge-create-dialog/challenge-create-dialog.component';
|
|
|
|
type Difficulty = 'easy' | 'medium' | 'hard';
|
|
type ImportMode = 'fen' | 'pgn';
|
|
|
|
interface Star {
|
|
style: Record<string, string>;
|
|
}
|
|
|
|
interface BackgroundBuilding {
|
|
style: Record<string, string>;
|
|
}
|
|
|
|
interface WindowCell {
|
|
state: 'off' | 'on';
|
|
color?: string;
|
|
glowColor?: string;
|
|
style: Record<string, string>;
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-welcome',
|
|
standalone: true,
|
|
imports: [CommonModule, FormsModule, ChallengeCreateDialogComponent],
|
|
templateUrl: './welcome.component.html',
|
|
styleUrls: ['./welcome.component.css']
|
|
})
|
|
export class WelcomeComponent implements OnInit, OnDestroy {
|
|
private readonly destroyRef = inject(DestroyRef);
|
|
private readonly authService = inject(AuthService);
|
|
private readonly authDialogService = inject(AuthDialogService);
|
|
private readonly themeService = inject(ThemeService);
|
|
|
|
creating = false;
|
|
joiningGame = false;
|
|
importing = false;
|
|
errorMessage = '';
|
|
|
|
showDifficultyDialog = false;
|
|
showOptionsDialog = false;
|
|
showJoinDialog = false;
|
|
showImportDialog = false;
|
|
|
|
gameIdInput = '';
|
|
importMode: ImportMode = 'fen';
|
|
importText = '';
|
|
|
|
isSunsetMode = false;
|
|
modeBadge = 'NIGHT MODE';
|
|
currentUser: CurrentUser | null = null;
|
|
private authDialogState: 'login' | 'register' | null = null;
|
|
private pendingAction: (() => void) | null = null;
|
|
|
|
// Speech bubble and zoom features
|
|
showSpeechBubble = false;
|
|
isZoomedIn = false;
|
|
showSecondSpeechBubble = false;
|
|
showHappyBubble = false;
|
|
showMeatEmoji = false;
|
|
bubbleMessage = 'meow';
|
|
|
|
// Meat emoji drag state
|
|
meatX = 0;
|
|
meatY = 0;
|
|
isDraggingMeat = false;
|
|
meatDragOffsetX = 0;
|
|
meatDragOffsetY = 0;
|
|
|
|
stars: Star[] = [];
|
|
bgBuildings: BackgroundBuilding[] = [];
|
|
windows: Record<string, WindowCell[]> = {};
|
|
|
|
private flickerIntervalId: ReturnType<typeof setInterval> | undefined;
|
|
private speechBubbleTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
private zoomTimeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
|
|
private coolColors = ['#7de8ff', '#00d5ff', '#5bc0de', '#31b0d5', '#4fc3f7', '#29b6f6'];
|
|
private coolGlowColors = ['#00d5ff', '#00d5ff', '#31b0d5', '#31b0d5', '#03a9f4', '#0288d1'];
|
|
|
|
private warmColors = ['#ffe88a', '#ffcc30', '#f0ad4e', '#ec971f', '#ffb74d', '#ffa726'];
|
|
private warmGlowColors = ['#ffcc30', '#ffcc30', '#ec971f', '#ec971f', '#ff9800', '#fb8c00'];
|
|
|
|
constructor(
|
|
private readonly router: Router,
|
|
private readonly gameApi: GameApiService
|
|
) {
|
|
}
|
|
|
|
ngOnInit(): void {
|
|
this.themeService.darkMode$
|
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
.subscribe((isDarkMode) => {
|
|
this.isSunsetMode = !isDarkMode;
|
|
this.modeBadge = this.isSunsetMode ? 'SUNSET MODE' : 'NIGHT MODE';
|
|
});
|
|
|
|
this.authService.currentUser$
|
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
.subscribe((user) => {
|
|
this.currentUser = user;
|
|
this.maybeRunPendingAction();
|
|
});
|
|
|
|
this.authDialogService.dialogState$
|
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
|
.subscribe((state) => {
|
|
this.authDialogState = state;
|
|
this.maybeRunPendingAction();
|
|
});
|
|
|
|
this.generateStars(220);
|
|
this.generateBackgroundBuildings();
|
|
this.generateWindowsForAllBuildings();
|
|
this.startWindowFlicker();
|
|
|
|
// Show speech bubble after 5 seconds
|
|
this.speechBubbleTimeoutId = setTimeout(() => {
|
|
this.showSpeechBubble = true;
|
|
}, 5000);
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
this.stopWindowFlicker();
|
|
if (this.speechBubbleTimeoutId) {
|
|
clearTimeout(this.speechBubbleTimeoutId);
|
|
}
|
|
if (this.zoomTimeoutId) {
|
|
clearTimeout(this.zoomTimeoutId);
|
|
}
|
|
}
|
|
|
|
openDifficultyDialog(): void {
|
|
if (!this.requireAuth(() => this.showDifficultyDialog = true)) {
|
|
return;
|
|
}
|
|
|
|
this.closeAllDialogs();
|
|
this.showDifficultyDialog = true;
|
|
}
|
|
|
|
closeDifficultyDialog(): void {
|
|
this.showDifficultyDialog = false;
|
|
this.errorMessage = '';
|
|
}
|
|
|
|
openOptionsDialog(): void {
|
|
this.closeAllDialogs();
|
|
this.showOptionsDialog = true;
|
|
}
|
|
|
|
closeOptionsDialog(): void {
|
|
this.showOptionsDialog = false;
|
|
this.errorMessage = '';
|
|
}
|
|
|
|
openJoinDialog(): void {
|
|
if (!this.requireAuth(() => this.showJoinDialog = true)) {
|
|
return;
|
|
}
|
|
|
|
this.closeAllDialogs();
|
|
this.showJoinDialog = true;
|
|
}
|
|
|
|
closeJoinDialog(): void {
|
|
if (this.joiningGame) {
|
|
return;
|
|
}
|
|
this.showJoinDialog = false;
|
|
this.gameIdInput = '';
|
|
this.errorMessage = '';
|
|
}
|
|
|
|
openImportDialog(): void {
|
|
if (!this.requireAuth(() => this.showImportDialog = true)) {
|
|
return;
|
|
}
|
|
|
|
this.closeAllDialogs();
|
|
this.showImportDialog = true;
|
|
}
|
|
|
|
closeImportDialog(): void {
|
|
if (this.importing) {
|
|
return;
|
|
}
|
|
this.showImportDialog = false;
|
|
this.importText = '';
|
|
this.importMode = 'fen';
|
|
this.errorMessage = '';
|
|
}
|
|
|
|
setImportMode(mode: ImportMode): void {
|
|
this.importMode = mode;
|
|
this.errorMessage = '';
|
|
}
|
|
|
|
startOneVsOne(): void {
|
|
if (!this.requireAuth(() => this.performStartOneVsOne())) {
|
|
return;
|
|
}
|
|
|
|
this.performStartOneVsOne();
|
|
}
|
|
|
|
startVsBot(difficulty: Difficulty): void {
|
|
if (!this.requireAuth(() => this.performStartVsBot(difficulty))) {
|
|
return;
|
|
}
|
|
|
|
this.performStartVsBot(difficulty);
|
|
}
|
|
|
|
submitJoinGame(): void {
|
|
if (!this.requireAuth(() => this.performSubmitJoinGame())) {
|
|
return;
|
|
}
|
|
|
|
this.performSubmitJoinGame();
|
|
}
|
|
|
|
submitImportGame(): void {
|
|
if (!this.requireAuth(() => this.performSubmitImportGame())) {
|
|
return;
|
|
}
|
|
|
|
this.performSubmitImportGame();
|
|
}
|
|
|
|
onSpeechBubbleClick(): void {
|
|
this.showSpeechBubble = false;
|
|
this.isZoomedIn = true;
|
|
this.bubbleMessage = 'meow';
|
|
this.showMeatEmoji = true;
|
|
this.showHappyBubble = false;
|
|
this.showSecondSpeechBubble = true;
|
|
|
|
// Reset meat position
|
|
this.meatX = window.innerWidth / 2 - 100;
|
|
this.meatY = window.innerHeight / 2 + 150;
|
|
}
|
|
|
|
onZoomedViewClick(): void {
|
|
this.isZoomedIn = false;
|
|
this.showSecondSpeechBubble = false;
|
|
this.showHappyBubble = false;
|
|
this.showMeatEmoji = false;
|
|
this.bubbleMessage = 'meow';
|
|
|
|
if (this.zoomTimeoutId) {
|
|
clearTimeout(this.zoomTimeoutId);
|
|
}
|
|
}
|
|
|
|
onMeatMouseDown(event: MouseEvent): void {
|
|
this.isDraggingMeat = true;
|
|
const rect = (event.target as HTMLElement).getBoundingClientRect();
|
|
this.meatDragOffsetX = event.clientX - rect.left;
|
|
this.meatDragOffsetY = event.clientY - rect.top;
|
|
}
|
|
|
|
onMouseMove(event: MouseEvent): void {
|
|
if (!this.isDraggingMeat) {
|
|
return;
|
|
}
|
|
|
|
this.meatX = event.clientX - this.meatDragOffsetX;
|
|
this.meatY = event.clientY - this.meatDragOffsetY;
|
|
|
|
const gifElement = document.querySelector('.player-2-gif') as HTMLElement;
|
|
if (!gifElement) {
|
|
return;
|
|
}
|
|
|
|
const gifRect = gifElement.getBoundingClientRect();
|
|
const gifCenterX = gifRect.left + gifRect.width / 2;
|
|
const gifCenterY = gifRect.top + gifRect.height / 2;
|
|
|
|
const meatElement = document.querySelector('.meat-emoji') as HTMLElement;
|
|
if (!meatElement) {
|
|
return;
|
|
}
|
|
|
|
const meatRect = meatElement.getBoundingClientRect();
|
|
const meatCenterX = meatRect.left + meatRect.width / 2;
|
|
const meatCenterY = meatRect.top + meatRect.height / 2;
|
|
|
|
const distance = Math.sqrt(
|
|
Math.pow(meatCenterX - gifCenterX, 2) + Math.pow(meatCenterY - gifCenterY, 2)
|
|
);
|
|
|
|
if (distance < 50) {
|
|
this.onMeatFed();
|
|
}
|
|
}
|
|
|
|
onMouseUp(): void {
|
|
this.isDraggingMeat = false;
|
|
}
|
|
|
|
onMeatFed(): void {
|
|
this.showMeatEmoji = false;
|
|
this.showSecondSpeechBubble = false;
|
|
this.showHappyBubble = true;
|
|
this.isDraggingMeat = false;
|
|
}
|
|
|
|
private requireAuth(action: () => void): boolean {
|
|
if (this.authService.isLoggedIn()) {
|
|
return true;
|
|
}
|
|
|
|
this.pendingAction = action;
|
|
this.authDialogService.openLogin();
|
|
return false;
|
|
}
|
|
|
|
private maybeRunPendingAction(): void {
|
|
if (!this.currentUser || this.authDialogState !== null || !this.pendingAction) {
|
|
return;
|
|
}
|
|
|
|
const action = this.pendingAction;
|
|
this.pendingAction = null;
|
|
action();
|
|
}
|
|
|
|
private performStartOneVsOne(): void {
|
|
if (this.creating) {
|
|
return;
|
|
}
|
|
|
|
this.errorMessage = '';
|
|
this.creating = true;
|
|
|
|
this.gameApi
|
|
.createGame()
|
|
.pipe(finalize(() => (this.creating = false)))
|
|
.subscribe({
|
|
next: (game) => {
|
|
void this.router.navigate(['/game', game.gameId], {
|
|
state: { theme: this.isSunsetMode ? 'light' : 'dark' }
|
|
});
|
|
},
|
|
error: (error) => {
|
|
this.errorMessage = getErrorMessage(error, 'Unable to create a game.');
|
|
}
|
|
});
|
|
}
|
|
|
|
private performStartVsBot(difficulty: Difficulty): void {
|
|
if (this.creating) {
|
|
return;
|
|
}
|
|
|
|
this.errorMessage = '';
|
|
this.creating = true;
|
|
this.showDifficultyDialog = false;
|
|
|
|
this.gameApi
|
|
.createGameVsBot(difficulty)
|
|
.pipe(finalize(() => (this.creating = false)))
|
|
.subscribe({
|
|
next: (game) => {
|
|
void this.router.navigate(['/game', game.gameId], {
|
|
state: { theme: this.isSunsetMode ? 'light' : 'dark' }
|
|
});
|
|
},
|
|
error: (error) => {
|
|
this.errorMessage = getErrorMessage(error, 'Unable to create a game against bot.');
|
|
}
|
|
});
|
|
}
|
|
|
|
private performSubmitJoinGame(): void {
|
|
const gameId = this.gameIdInput.trim();
|
|
if (this.joiningGame || !gameId) {
|
|
return;
|
|
}
|
|
|
|
this.errorMessage = '';
|
|
this.joiningGame = true;
|
|
|
|
this.gameApi
|
|
.getGame(gameId)
|
|
.pipe(finalize(() => (this.joiningGame = false)))
|
|
.subscribe({
|
|
next: (game) => {
|
|
this.closeJoinDialog();
|
|
void this.router.navigate(['/game', game.gameId], {
|
|
state: { theme: this.isSunsetMode ? 'light' : 'dark' }
|
|
});
|
|
},
|
|
error: (error) => {
|
|
this.errorMessage = getErrorMessage(error, 'Unable to find or join the game.');
|
|
}
|
|
});
|
|
}
|
|
|
|
private performSubmitImportGame(): void {
|
|
const trimmedImport = this.importText.trim();
|
|
if (this.importing || !trimmedImport) {
|
|
return;
|
|
}
|
|
|
|
this.errorMessage = '';
|
|
this.importing = true;
|
|
|
|
const importRequest =
|
|
this.importMode === 'fen' ? this.gameApi.importFen(trimmedImport) : this.gameApi.importPgn(trimmedImport);
|
|
|
|
importRequest.pipe(finalize(() => (this.importing = false))).subscribe({
|
|
next: (game) => {
|
|
this.closeImportDialog();
|
|
void this.router.navigate(['/game', game.gameId], {
|
|
state: { theme: this.isSunsetMode ? 'light' : 'dark' }
|
|
});
|
|
},
|
|
error: (error) => {
|
|
const defaultMessage = this.importMode === 'fen' ? 'Unable to import FEN.' : 'Unable to import PGN.';
|
|
this.errorMessage = getErrorMessage(error, defaultMessage);
|
|
}
|
|
});
|
|
}
|
|
|
|
private closeAllDialogs(): void {
|
|
this.showDifficultyDialog = false;
|
|
this.showOptionsDialog = false;
|
|
this.showJoinDialog = false;
|
|
this.showImportDialog = false;
|
|
this.errorMessage = '';
|
|
}
|
|
|
|
private generateStars(count: number): void {
|
|
this.stars = Array.from({ length: count }, () => {
|
|
const size = Math.random() * 2 + 0.5;
|
|
return {
|
|
style: {
|
|
width: `${size}px`,
|
|
height: `${size}px`,
|
|
left: `${Math.random() * 100}%`,
|
|
top: `${Math.random() * 62}%`,
|
|
'--d': `${(Math.random() * 3 + 1.5).toFixed(1)}s`,
|
|
'--dl': `${-(Math.random() * 6).toFixed(1)}s`
|
|
}
|
|
};
|
|
});
|
|
}
|
|
|
|
private generateBackgroundBuildings(): void {
|
|
const specs = [
|
|
{ l: '0%', w: '7%', h: '30vh' },
|
|
{ l: '3%', w: '4%', h: '18vh' }, // New building
|
|
{ l: '7%', w: '5%', h: '22vh' },
|
|
{ l: '11%', w: '8%', h: '28vh' },
|
|
{ l: '15%', w: '6%', h: '20vh' },
|
|
{ l: '18.5%', w: '4%', h: '18vh' },
|
|
{ l: '22.5%', w: '6%', h: '26vh' },
|
|
{ l: '28%', w: '5%', h: '25vh' },
|
|
{ l: '32%', w: '4%', h: '15vh' },
|
|
{ l: '35.5%', w: '4.5%', h: '20vh' },
|
|
{ l: '42%', w: '5%', h: '28vh' },
|
|
{ l: '47%', w: '5%', h: '22vh' }, // New building
|
|
{ l: '50%', w: '7%', h: '30vh' },
|
|
{ l: '55%', w: '6%', h: '27vh' },
|
|
{ l: '60.5%', w: '5%', h: '24vh' },
|
|
{ l: '64.5%', w: '3.5%', h: '17vh' },
|
|
{ l: '70%', w: '6%', h: '23vh' },
|
|
{ l: '75%', w: '4%', h: '19vh' },
|
|
{ l: '80.5%', w: '4%', h: '21vh' },
|
|
{ l: '85.5%', w: '9%', h: '32vh' },
|
|
{ l: '88%', w: '5%', h: '20vh' },
|
|
{ l: '91%', w: '3%', h: '16vh' }, // New building
|
|
{ l: '94%', w: '6%', h: '27vh' }
|
|
];
|
|
|
|
this.bgBuildings = specs.map((spec) => ({
|
|
style: { left: spec.l, width: spec.w, height: spec.h }
|
|
}));
|
|
}
|
|
|
|
private generateWindowsForAllBuildings(): void {
|
|
this.windows = {
|
|
wA1: this.generateWindows(3, 4, 0.6),
|
|
wA2: this.generateWindows(4, 5, 0.55),
|
|
wA3: this.generateWindows(5, 18, 0.5),
|
|
wB1: this.generateWindows(4, 3, 0.6),
|
|
wB2: this.generateWindows(5, 20, 0.55),
|
|
wC1: this.generateWindows(5, 3, 0.7),
|
|
wC2: this.generateWindows(6, 5, 0.65),
|
|
wC3: this.generateWindows(7, 24, 0.6),
|
|
wD1: this.generateWindows(6, 3, 0.6),
|
|
wD2: this.generateWindows(6, 20, 0.5),
|
|
wE1: this.generateWindows(3, 16, 0.45)
|
|
};
|
|
}
|
|
|
|
private generateWindows(cols: number, rows: number, litRate: number): WindowCell[] {
|
|
const total = cols * rows;
|
|
return Array.from({ length: total }, () => this.createWindowCell(litRate));
|
|
}
|
|
|
|
private createWindowCell(litRate: number): WindowCell {
|
|
const random = Math.random();
|
|
let state: WindowCell['state'] = 'off';
|
|
let color: string | undefined;
|
|
let glowColor: string | undefined;
|
|
|
|
if (random < litRate * 0.58) { // Cool color
|
|
state = 'on';
|
|
const coolIndex = Math.floor(Math.random() * this.coolColors.length);
|
|
color = this.coolColors[coolIndex];
|
|
glowColor = this.coolGlowColors[coolIndex];
|
|
} else if (random < litRate) { // Warm color
|
|
state = 'on';
|
|
const warmIndex = Math.floor(Math.random() * this.warmColors.length);
|
|
color = this.warmColors[warmIndex];
|
|
glowColor = this.warmGlowColors[warmIndex];
|
|
}
|
|
|
|
if (state === 'off') {
|
|
return { state, style: {} };
|
|
}
|
|
|
|
const baseDuration = (color && this.coolColors.includes(color)) ? 3 : 4;
|
|
return {
|
|
state,
|
|
color,
|
|
glowColor,
|
|
style: {
|
|
'background-color': color || '',
|
|
'box-shadow': glowColor ? `0 0 6px ${glowColor}, 0 0 16px ${glowColor}35` : '',
|
|
'--wd': `${(Math.random() * 4 + baseDuration).toFixed(1)}s`,
|
|
'--wdl': `${-(Math.random() * 8).toFixed(1)}s`
|
|
}
|
|
};
|
|
}
|
|
|
|
private startWindowFlicker(): void {
|
|
this.flickerIntervalId = setInterval(() => {
|
|
this.randomFlicker();
|
|
}, 2800);
|
|
}
|
|
|
|
private stopWindowFlicker(): void {
|
|
if (this.flickerIntervalId === undefined) {
|
|
return;
|
|
}
|
|
clearInterval(this.flickerIntervalId);
|
|
this.flickerIntervalId = undefined;
|
|
}
|
|
|
|
private randomFlicker(): void {
|
|
const allWindows = Object.values(this.windows).flat();
|
|
if (allWindows.length === 0) {
|
|
return;
|
|
}
|
|
|
|
const pickCount = Math.floor(Math.random() * 6) + 1;
|
|
for (let i = 0; i < pickCount; i += 1) {
|
|
const target = allWindows[Math.floor(Math.random() * allWindows.length)];
|
|
if (!target) {
|
|
continue;
|
|
}
|
|
|
|
if (target.state === 'off') {
|
|
target.state = 'on';
|
|
const isCool = Math.random() < 0.5;
|
|
const colors = isCool ? this.coolColors : this.warmColors;
|
|
const glowColors = isCool ? this.coolGlowColors : this.warmGlowColors;
|
|
const index = Math.floor(Math.random() * colors.length);
|
|
|
|
target.color = colors[index];
|
|
target.glowColor = glowColors[index];
|
|
target.style = {
|
|
'background-color': target.color || '',
|
|
'box-shadow': target.glowColor ? `0 0 6px ${target.glowColor}, 0 0 16px ${target.glowColor}35` : '',
|
|
'--wd': `${(Math.random() * 4 + (isCool ? 3 : 4)).toFixed(1)}s`,
|
|
'--wdl': `${-(Math.random() * 8).toFixed(1)}s`
|
|
};
|
|
} else {
|
|
target.state = 'off';
|
|
target.color = undefined;
|
|
target.glowColor = undefined;
|
|
target.style = {};
|
|
}
|
|
}
|
|
}
|
|
}
|