feat: NCS-63 User account implementation (#2)
User Profile info, no game before login/register, menu bar --------- Co-authored-by: Lala, Shahd <Shahd.Lala@sybit.de> Co-authored-by: shahdlala66 <shahd.lala66@gmail.com> Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
@@ -1,6 +1,16 @@
|
||||
@import '../../button-template.css';
|
||||
|
||||
.game-shell {
|
||||
min-height: 100dvh;
|
||||
padding: clamp(var(--size-md), 2vw, var(--size-xl));
|
||||
background: linear-gradient(180deg, var(--color-primary-light) 0%, var(--color-secondary-mint) 100%);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
:host-context(html[data-theme='dark']) .game-shell {
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(185, 194, 218, 0.16) 0%, transparent 35%),
|
||||
linear-gradient(180deg, #0f1f2e 0%, #17293d 52%, #0b1420 100%);
|
||||
}
|
||||
|
||||
.game-card {
|
||||
@@ -13,8 +23,14 @@
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
:host-context(html[data-theme='dark']) .game-shell .game-card {
|
||||
background: rgba(26, 47, 71, 0.88);
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.34);
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--size-xl);
|
||||
margin-bottom: var(--size-xl);
|
||||
}
|
||||
|
||||
h1,
|
||||
@@ -22,9 +38,13 @@ h2 {
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 var(--size-md);
|
||||
font-size: var(--heading-h1);
|
||||
color: var(--color-text-primary);
|
||||
margin: 0 0 var(--size-md);
|
||||
font-size: var(--heading-h1);
|
||||
}
|
||||
|
||||
.meta {
|
||||
color: var(--color-text-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
@@ -33,6 +53,8 @@ h2 {
|
||||
display: inline-block;
|
||||
margin-bottom: var(--size-sm);
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: var(--size-sm);
|
||||
color: var(--color-text-primary);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -48,6 +70,39 @@ h2 {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.board-theme-card {
|
||||
background: var(--color-bg-card);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: var(--size-md-padding);
|
||||
display: grid;
|
||||
gap: var(--size-sm);
|
||||
}
|
||||
|
||||
.board-theme-card h3 {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.board-theme-group {
|
||||
display: flex;
|
||||
gap: var(--size-md);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.board-theme-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--size-xs);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.board-theme-option input {
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.move-card {
|
||||
padding: var(--size-lg-padding);
|
||||
}
|
||||
@@ -62,6 +117,10 @@ h2 {
|
||||
}
|
||||
|
||||
.board-section {
|
||||
background: var(--color-bg-board);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: clamp(var(--size-sm), 1vw, var(--size-lg));
|
||||
background: var(--color-bg-board);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
@@ -70,6 +129,148 @@ h2 {
|
||||
container-type: size;
|
||||
}
|
||||
|
||||
:host-context(html[data-theme='dark']) .game-shell .board-section,
|
||||
:host-context(html[data-theme='dark']) .game-shell .timer-card,
|
||||
:host-context(html[data-theme='dark']) .game-shell .history-card,
|
||||
:host-context(html[data-theme='dark']) .game-shell .export-card,
|
||||
:host-context(html[data-theme='dark']) .game-shell .board-theme-card,
|
||||
:host-context(html[data-theme='dark']) .game-shell .player-timer {
|
||||
background: rgba(45, 74, 111, 0.72);
|
||||
}
|
||||
|
||||
:host-context(html[data-theme='dark']) .game-shell .export-text {
|
||||
background: rgba(26, 47, 71, 0.9);
|
||||
}
|
||||
|
||||
:host-context(html[data-theme='dark']) .game-shell .game-completion-alert {
|
||||
background: linear-gradient(135deg, rgba(74, 124, 124, 0.35) 0%, rgba(90, 111, 165, 0.35) 100%);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.timer-card,
|
||||
.history-card,
|
||||
.export-card {
|
||||
background: var(--color-bg-card);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: var(--size-lg-padding);
|
||||
display: grid;
|
||||
gap: var(--size-md);
|
||||
}
|
||||
|
||||
.timer-card h2,
|
||||
.history-card h2,
|
||||
.export-card h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.history-list {
|
||||
margin: 0;
|
||||
padding-left: 1.1rem;
|
||||
display: grid;
|
||||
gap: var(--size-xs);
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.history-list li {
|
||||
color: var(--color-text-primary);
|
||||
display: flex;
|
||||
gap: var(--size-sm);
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.history-number {
|
||||
font-weight: 700;
|
||||
min-width: 1.8rem;
|
||||
}
|
||||
|
||||
.history-move {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.history-empty {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.player-timer {
|
||||
background: var(--color-bg-input);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: var(--size-md-padding);
|
||||
}
|
||||
|
||||
.active-timer {
|
||||
box-shadow: 0 0 0 3px rgba(185, 218, 209, 0.25);
|
||||
}
|
||||
|
||||
.timer-label {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.timer-value {
|
||||
margin: var(--size-xs) 0 0;
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.export-mode-group {
|
||||
display: flex;
|
||||
gap: var(--size-lg);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.export-mode-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--size-sm);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.export-mode-option input {
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.export-text {
|
||||
width: 100%;
|
||||
min-height: 140px;
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
border-radius: var(--border-radius-md);
|
||||
background: var(--color-bg-input);
|
||||
color: var(--color-text-primary);
|
||||
padding: var(--size-md-padding);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.export-button {
|
||||
width: fit-content;
|
||||
border: var(--button-border);
|
||||
border-radius: var(--button-radius);
|
||||
background: var(--color-bg-button);
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 700;
|
||||
padding: var(--button-padding);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.export-button:hover {
|
||||
background: var(--color-bg-button-hover);
|
||||
color: var(--color-text-button-hover);
|
||||
}
|
||||
|
||||
.export-note {
|
||||
margin: 0;
|
||||
color: var(--color-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.alert {
|
||||
border-radius: var(--border-radius-sm);
|
||||
border: var(--border-width) solid var(--color-border);
|
||||
@@ -126,6 +327,7 @@ h2 {
|
||||
@media (max-width: 991px) {
|
||||
.game-card {
|
||||
padding: clamp(var(--size-md), 1.5vw, var(--size-lg));
|
||||
padding: clamp(var(--size-md), 1.5vw, var(--size-lg));
|
||||
}
|
||||
|
||||
.board-section {
|
||||
@@ -135,25 +337,30 @@ h2 {
|
||||
h1,
|
||||
h2 {
|
||||
font-size: var(--heading-h1-tablet);
|
||||
font-size: var(--heading-h1-tablet);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.game-shell {
|
||||
padding: clamp(var(--size-sm), 1.5vw, var(--size-lg));
|
||||
padding: clamp(var(--size-sm), 1.5vw, var(--size-lg));
|
||||
}
|
||||
|
||||
.game-card {
|
||||
padding: clamp(var(--size-sm), 1vw, var(--size-md));
|
||||
padding: clamp(var(--size-sm), 1vw, var(--size-md));
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--size-lg);
|
||||
margin-bottom: var(--size-lg);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-size: var(--heading-h1-mobile);
|
||||
font-size: var(--heading-h1-mobile);
|
||||
}
|
||||
|
||||
.meta {
|
||||
@@ -163,6 +370,8 @@ h2 {
|
||||
.top-section {
|
||||
gap: var(--size-xs);
|
||||
margin-bottom: var(--size-xs);
|
||||
gap: var(--size-xs);
|
||||
margin-bottom: var(--size-xs);
|
||||
}
|
||||
|
||||
.board-section {
|
||||
@@ -173,19 +382,24 @@ h2 {
|
||||
@media (max-width: 480px) {
|
||||
.game-shell {
|
||||
padding: var(--size-sm);
|
||||
padding: var(--size-sm);
|
||||
}
|
||||
|
||||
.game-card {
|
||||
padding: var(--size-sm);
|
||||
border-radius: var(--border-radius-md);
|
||||
padding: var(--size-sm);
|
||||
border-radius: var(--border-radius-md);
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: var(--size-md);
|
||||
margin-bottom: var(--size-md);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: var(--heading-h1-small);
|
||||
font-size: var(--heading-h1-small);
|
||||
}
|
||||
|
||||
.meta {
|
||||
@@ -195,6 +409,8 @@ h2 {
|
||||
.top-section {
|
||||
gap: var(--size-xs-gap);
|
||||
margin-bottom: var(--size-xs);
|
||||
gap: var(--size-xs-gap);
|
||||
margin-bottom: var(--size-xs);
|
||||
}
|
||||
|
||||
.board-section {
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
<main class="game-shell">
|
||||
<app-promotion-dialog
|
||||
[isOpen]="facade.isPromotionDialogOpen"
|
||||
(promotionSelected)="facade.onPromotionSelected($event)"
|
||||
(closed)="facade.onPromotionClosed()"
|
||||
/>
|
||||
<main class="game-shell" [class.theme-dark]="isDarkMode">
|
||||
<app-promotion-dialog [isOpen]="facade.isPromotionDialogOpen" (promotionSelected)="facade.onPromotionSelected($event)"
|
||||
(closed)="facade.onPromotionClosed()" />
|
||||
|
||||
<section class="game-card">
|
||||
<header class="mb-3">
|
||||
@@ -13,79 +10,126 @@
|
||||
</header>
|
||||
|
||||
@if (facade.loading) {
|
||||
<p>Loading game state...</p>
|
||||
<p>Loading game state...</p>
|
||||
} @else if (facade.state) {
|
||||
@if (facade.isGameFinished && facade.gameCompletionMessage) {
|
||||
<div class="game-completion-alert alert alert-success mb-3">
|
||||
<h2 class="completion-title">{{ facade.gameCompletionMessage }}</h2>
|
||||
<p class="completion-subtitle mb-0">
|
||||
<a routerLink="/" class="completion-link">Start a new game</a>
|
||||
</p>
|
||||
@if (facade.isGameFinished && facade.gameCompletionMessage) {
|
||||
<div class="game-completion-alert alert alert-success mb-3">
|
||||
<h2 class="completion-title">{{ facade.gameCompletionMessage }}</h2>
|
||||
<p class="completion-subtitle mb-0">
|
||||
<a routerLink="/" class="completion-link">Start a new game</a>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
@if (facade.isGameFinished && facade.gameCompletionMessage) {
|
||||
<div class="game-completion-alert alert alert-success mb-3">
|
||||
<h2 class="completion-title">{{ facade.gameCompletionMessage }}</h2>
|
||||
<p class="completion-subtitle mb-0">
|
||||
<a routerLink="/" class="completion-link">Start a new game</a>
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
<div class="container-fluid">
|
||||
<div class="row g-3">
|
||||
<!-- Left Sidebar - Dummy Timers -->
|
||||
<div class="col-lg-3 col-md-6 col-12 order-lg-1 order-2">
|
||||
<section class="timer-card">
|
||||
<h2>Timers</h2>
|
||||
<div class="player-timer" [class.active-timer]="facade.state.turn === 'white'">
|
||||
<p class="timer-label">White</p>
|
||||
<p class="timer-value">{{ formatTimer(whiteTimerSeconds) }}</p>
|
||||
</div>
|
||||
<div class="player-timer" [class.active-timer]="facade.state.turn === 'black'">
|
||||
<p class="timer-label">Black</p>
|
||||
<p class="timer-value">{{ formatTimer(blackTimerSeconds) }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
}
|
||||
<div class="container-fluid">
|
||||
<div class="row g-3">
|
||||
<!-- Left Sidebar - FEN Import -->
|
||||
<div class="col-lg-3 col-md-6 col-12 order-lg-1 order-2">
|
||||
<app-input-card
|
||||
label="Import FEN"
|
||||
placeholder="rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
buttonLabel="Load FEN"
|
||||
inputType="textarea"
|
||||
[value]="facade.fenInput"
|
||||
cardClass="fen-card"
|
||||
(valueChange)="facade.fenInput = $event"
|
||||
(buttonClick)="facade.importFen()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Center - Chess Board -->
|
||||
<div class="col-lg-6 col-md-12 col-12 order-lg-2 order-1">
|
||||
<section class="center-column d-flex flex-column h-100">
|
||||
<div class="board-section flex-grow-1 d-flex align-items-center justify-content-center">
|
||||
<app-chess-board
|
||||
[fen]="facade.state.fen"
|
||||
[selectedSquare]="facade.selectedSquare"
|
||||
[highlightedSquares]="facade.highlightedSquares"
|
||||
(squareSelected)="facade.onBoardSquareSelected($event)"
|
||||
/>
|
||||
</div>
|
||||
<!-- Center - Chess Board -->
|
||||
<div class="col-lg-6 col-md-12 col-12 order-lg-2 order-1">
|
||||
<section class="center-column d-flex flex-column h-100">
|
||||
<div class="board-section flex-grow-1 d-flex align-items-center justify-content-center">
|
||||
<app-chess-board [fen]="facade.state.fen" [selectedSquare]="facade.selectedSquare"
|
||||
[highlightedSquares]="facade.highlightedSquares" [boardTheme]="boardTheme"
|
||||
(squareSelected)="facade.onBoardSquareSelected($event)" />
|
||||
</div>
|
||||
|
||||
<section class="top-section">
|
||||
<app-input-card
|
||||
label="Play move (UCI)"
|
||||
placeholder="e2e4"
|
||||
buttonLabel="Send Move"
|
||||
inputType="input"
|
||||
[value]="facade.moveInput"
|
||||
cardClass="move-card"
|
||||
hintText="Click your piece to highlight legal targets."
|
||||
(valueChange)="facade.moveInput = $event"
|
||||
(buttonClick)="facade.submitMove()"
|
||||
/>
|
||||
<section class="top-section">
|
||||
<section class="board-theme-card" aria-label="Board design chooser">
|
||||
<h3>Board Design</h3>
|
||||
<div class="board-theme-group" role="radiogroup" aria-label="Board design">
|
||||
<label class="board-theme-option">
|
||||
<input type="radio" name="boardTheme" [checked]="boardTheme === 'arabian'"
|
||||
(change)="setBoardTheme('arabian')" />
|
||||
<span>Arabian</span>
|
||||
</label>
|
||||
|
||||
<label class="board-theme-option">
|
||||
<input type="radio" name="boardTheme" [checked]="boardTheme === 'classic'"
|
||||
(change)="setBoardTheme('classic')" />
|
||||
<span>Classic</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Right Sidebar - PGN Import -->
|
||||
<div class="col-lg-3 col-md-6 col-12 order-lg-3 order-3">
|
||||
<app-input-card
|
||||
label="Import PGN"
|
||||
placeholder="1. e4 e5 2. Nf3 Nc6 *"
|
||||
buttonLabel="Load PGN"
|
||||
inputType="textarea"
|
||||
[value]="facade.pgnInput"
|
||||
cardClass="pgn-card"
|
||||
(valueChange)="facade.pgnInput = $event"
|
||||
(buttonClick)="facade.importPgn()"
|
||||
/>
|
||||
</div>
|
||||
<app-input-card label="Play move (UCI)" placeholder="e2e4" buttonLabel="Send Move" inputType="input"
|
||||
[value]="facade.moveInput" cardClass="move-card" hintText="Click your piece to highlight legal targets."
|
||||
(valueChange)="facade.moveInput = $event" (buttonClick)="facade.submitMove()" />
|
||||
</section>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Right Sidebar - Export -->
|
||||
<div class="col-lg-3 col-md-6 col-12 order-lg-3 order-3">
|
||||
<section class="history-card">
|
||||
<h2>Move History</h2>
|
||||
|
||||
@if (facade.state.moves.length === 0) {
|
||||
<p class="history-empty">No moves yet.</p>
|
||||
} @else {
|
||||
<ol class="history-list">
|
||||
@for (move of facade.state.moves; track $index) {
|
||||
<li>
|
||||
<span class="history-number">{{ $index + 1 }}.</span>
|
||||
<span class="history-move">{{ move }}</span>
|
||||
</li>
|
||||
}
|
||||
</ol>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="export-card">
|
||||
<h2>Export</h2>
|
||||
<div class="export-mode-group" role="radiogroup" aria-label="Export mode">
|
||||
<label class="export-mode-option">
|
||||
<input type="radio" name="exportType" [checked]="exportType === 'fen'"
|
||||
(change)="setExportType('fen')" />
|
||||
<span>FEN</span>
|
||||
</label>
|
||||
<label class="export-mode-option">
|
||||
<input type="radio" name="exportType" [checked]="exportType === 'pgn'"
|
||||
(change)="setExportType('pgn')" />
|
||||
<span>PGN</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<textarea class="export-text" [value]="exportValue"
|
||||
[placeholder]="exportType === 'fen' ? 'FEN will appear here' : 'PGN will appear here'" rows="8"
|
||||
readonly></textarea>
|
||||
|
||||
<button type="button" class="app-btn w-100" (click)="completeExport()">Done</button>
|
||||
|
||||
@if (exportNotice) {
|
||||
<p class="export-note">{{ exportNotice }}</p>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (facade.errorMessage) {
|
||||
<p class="alert alert-danger mt-3 mb-0">{{ facade.errorMessage }}</p>
|
||||
<p class="alert alert-danger mt-3 mb-0">{{ facade.errorMessage }}</p>
|
||||
}
|
||||
</section>
|
||||
</main>
|
||||
</main>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
|
||||
import { Component, DestroyRef, OnDestroy, OnInit, inject } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
@@ -8,6 +8,16 @@ import { InputCardComponent } from '../../components/input-card/input-card.compo
|
||||
import { PromotionDialogComponent } from '../../components/promotion-dialog/promotion-dialog.component';
|
||||
import { GameFacade } from './game.facade';
|
||||
|
||||
type TimerTurn = 'white' | 'black';
|
||||
type BoardTheme = 'arabian' | 'classic';
|
||||
|
||||
interface TimerSnapshot {
|
||||
whiteSeconds: number;
|
||||
blackSeconds: number;
|
||||
turn: TimerTurn;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-game',
|
||||
standalone: true,
|
||||
@@ -16,12 +26,28 @@ import { GameFacade } from './game.facade';
|
||||
templateUrl: './game.component.html',
|
||||
styleUrl: './game.component.css'
|
||||
})
|
||||
export class GameComponent implements OnInit {
|
||||
export class GameComponent implements OnInit, OnDestroy {
|
||||
private static readonly TIMER_START_SECONDS = 10 * 60;
|
||||
private static readonly BOARD_THEME_STORAGE_KEY = 'nowchess.boardTheme';
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
readonly facade = inject(GameFacade);
|
||||
whiteTimerSeconds = GameComponent.TIMER_START_SECONDS;
|
||||
blackTimerSeconds = GameComponent.TIMER_START_SECONDS;
|
||||
exportType: 'fen' | 'pgn' = 'fen';
|
||||
boardTheme: BoardTheme = 'arabian';
|
||||
isDarkMode = false;
|
||||
exportValue = '';
|
||||
exportNotice = '';
|
||||
private timerIntervalId: number | null = null;
|
||||
private activeGameId = '';
|
||||
|
||||
ngOnInit(): void {
|
||||
this.applyIncomingTheme();
|
||||
this.syncThemeFromDocument();
|
||||
this.boardTheme = this.resolveStoredBoardTheme();
|
||||
this.startDummyTimers();
|
||||
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((paramMap) => {
|
||||
const id = paramMap.get('gameId');
|
||||
if (!id) {
|
||||
@@ -30,7 +56,203 @@ export class GameComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeGameId = id;
|
||||
this.restoreTimers(id);
|
||||
this.facade.setGameId(id);
|
||||
this.syncExportValue();
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.timerIntervalId !== null) {
|
||||
window.clearInterval(this.timerIntervalId);
|
||||
}
|
||||
|
||||
this.persistTimers(this.resolveCurrentTurn());
|
||||
}
|
||||
|
||||
private syncThemeFromDocument(): void {
|
||||
this.isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
}
|
||||
|
||||
private applyIncomingTheme(): void {
|
||||
const incomingTheme = window.history.state?.theme;
|
||||
if (incomingTheme === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
return;
|
||||
}
|
||||
|
||||
if (incomingTheme === 'light') {
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
localStorage.removeItem('theme');
|
||||
}
|
||||
}
|
||||
|
||||
setExportType(type: 'fen' | 'pgn'): void {
|
||||
this.exportType = type;
|
||||
this.exportNotice = '';
|
||||
this.syncExportValue();
|
||||
}
|
||||
|
||||
setBoardTheme(theme: BoardTheme): void {
|
||||
this.boardTheme = theme;
|
||||
localStorage.setItem(GameComponent.BOARD_THEME_STORAGE_KEY, theme);
|
||||
}
|
||||
|
||||
completeExport(): void {
|
||||
this.syncExportValue();
|
||||
if (!this.exportValue.trim()) {
|
||||
this.exportNotice = 'Nothing to export yet.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.clipboard?.writeText) {
|
||||
this.exportNotice = 'Export is ready in the text box.';
|
||||
return;
|
||||
}
|
||||
|
||||
void navigator.clipboard
|
||||
.writeText(this.exportValue)
|
||||
.then(() => {
|
||||
this.exportNotice = 'Copied to clipboard.';
|
||||
})
|
||||
.catch(() => {
|
||||
this.exportNotice = 'Export is ready in the text box.';
|
||||
});
|
||||
}
|
||||
|
||||
formatTimer(totalSeconds: number): string {
|
||||
const safeSeconds = Math.max(0, totalSeconds);
|
||||
const minutes = Math.floor(safeSeconds / 60)
|
||||
.toString()
|
||||
.padStart(2, '0');
|
||||
const seconds = (safeSeconds % 60).toString().padStart(2, '0');
|
||||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
private startDummyTimers(): void {
|
||||
if (this.timerIntervalId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.timerIntervalId = window.setInterval(() => {
|
||||
this.tickDummyTimers();
|
||||
this.syncExportValue();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
private tickDummyTimers(): void {
|
||||
const state = this.facade.state;
|
||||
if (!state || this.facade.loading || this.facade.isGameFinished) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.turn === 'white') {
|
||||
this.whiteTimerSeconds = Math.max(0, this.whiteTimerSeconds - 1);
|
||||
this.persistTimers('white');
|
||||
return;
|
||||
}
|
||||
|
||||
this.blackTimerSeconds = Math.max(0, this.blackTimerSeconds - 1);
|
||||
this.persistTimers('black');
|
||||
}
|
||||
|
||||
private syncExportValue(): void {
|
||||
const state = this.facade.state;
|
||||
if (!state) {
|
||||
this.exportValue = '';
|
||||
return;
|
||||
}
|
||||
|
||||
this.exportValue = this.exportType === 'fen' ? state.fen : state.pgn;
|
||||
}
|
||||
|
||||
private restoreTimers(gameId: string): void {
|
||||
const fallbackTurn = this.resolveCurrentTurn();
|
||||
const rawSnapshot = localStorage.getItem(this.getTimerStorageKey(gameId));
|
||||
if (!rawSnapshot) {
|
||||
this.resetTimers();
|
||||
this.persistTimers(fallbackTurn);
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot = this.parseSnapshot(rawSnapshot);
|
||||
if (!snapshot) {
|
||||
this.resetTimers();
|
||||
this.persistTimers(fallbackTurn);
|
||||
return;
|
||||
}
|
||||
|
||||
this.applySnapshot(snapshot);
|
||||
this.persistTimers(snapshot.turn);
|
||||
}
|
||||
|
||||
private parseSnapshot(rawSnapshot: string): TimerSnapshot | null {
|
||||
try {
|
||||
const parsed = JSON.parse(rawSnapshot) as Partial<TimerSnapshot>;
|
||||
if (
|
||||
typeof parsed.whiteSeconds !== 'number' ||
|
||||
typeof parsed.blackSeconds !== 'number' ||
|
||||
(parsed.turn !== 'white' && parsed.turn !== 'black') ||
|
||||
typeof parsed.savedAt !== 'number'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
whiteSeconds: Math.max(0, Math.floor(parsed.whiteSeconds)),
|
||||
blackSeconds: Math.max(0, Math.floor(parsed.blackSeconds)),
|
||||
turn: parsed.turn,
|
||||
savedAt: parsed.savedAt
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private applySnapshot(snapshot: TimerSnapshot): void {
|
||||
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - snapshot.savedAt) / 1000));
|
||||
this.whiteTimerSeconds = snapshot.whiteSeconds;
|
||||
this.blackTimerSeconds = snapshot.blackSeconds;
|
||||
|
||||
if (snapshot.turn === 'white') {
|
||||
this.whiteTimerSeconds = Math.max(0, this.whiteTimerSeconds - elapsedSeconds);
|
||||
return;
|
||||
}
|
||||
|
||||
this.blackTimerSeconds = Math.max(0, this.blackTimerSeconds - elapsedSeconds);
|
||||
}
|
||||
|
||||
private persistTimers(turn: TimerTurn): void {
|
||||
if (!this.activeGameId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const snapshot: TimerSnapshot = {
|
||||
whiteSeconds: this.whiteTimerSeconds,
|
||||
blackSeconds: this.blackTimerSeconds,
|
||||
turn,
|
||||
savedAt: Date.now()
|
||||
};
|
||||
localStorage.setItem(this.getTimerStorageKey(this.activeGameId), JSON.stringify(snapshot));
|
||||
}
|
||||
|
||||
private resolveCurrentTurn(): TimerTurn {
|
||||
return this.facade.state?.turn ?? 'white';
|
||||
}
|
||||
|
||||
private resetTimers(): void {
|
||||
this.whiteTimerSeconds = GameComponent.TIMER_START_SECONDS;
|
||||
this.blackTimerSeconds = GameComponent.TIMER_START_SECONDS;
|
||||
}
|
||||
|
||||
private getTimerStorageKey(gameId: string): string {
|
||||
return `nowchess.timer.${gameId}`;
|
||||
}
|
||||
|
||||
private resolveStoredBoardTheme(): BoardTheme {
|
||||
const stored = localStorage.getItem(GameComponent.BOARD_THEME_STORAGE_KEY);
|
||||
return stored === 'classic' ? 'classic' : 'arabian';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
@import '../welcome/welcome.component.css';
|
||||
|
||||
.profile-building-container {
|
||||
position: absolute;
|
||||
bottom: 10%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
.building-wrapper {
|
||||
position: relative;
|
||||
width: clamp(300px, 80vw, 600px);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.building-structure {
|
||||
background: linear-gradient(135deg, var(--bldg-body) 0%, var(--bldg-mid) 50%, var(--bldg-lit) 100%);
|
||||
border: 2px solid var(--dlg-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: var(--bb-glow), inset 0 0 20px rgba(0, 210, 255, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cityscape-shell.sunset .building-structure {
|
||||
box-shadow: var(--bb-glow), inset 0 0 20px rgba(255, 120, 40, 0.1);
|
||||
}
|
||||
|
||||
.building-top {
|
||||
height: 30px;
|
||||
background: linear-gradient(180deg, var(--bldg-mid) 0%, var(--bldg-body) 100%);
|
||||
border-bottom: 1px solid rgba(0, 210, 255, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
color: var(--bb-tag);
|
||||
font-family: 'Bebas Neue', sans-serif;
|
||||
letter-spacing: 2px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cityscape-shell.sunset .building-top {
|
||||
border-bottom-color: rgba(255, 120, 40, 0.3);
|
||||
}
|
||||
|
||||
.building-top::after {
|
||||
content: 'MY PROFILE';
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.building-main {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr 80px;
|
||||
gap: 0;
|
||||
padding: 20px;
|
||||
min-height: 200px;
|
||||
background: linear-gradient(90deg, rgba(0, 210, 255, 0.03) 0%, transparent 15%, transparent 85%, rgba(0, 210, 255, 0.03) 100%);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cityscape-shell.sunset .building-main {
|
||||
background: linear-gradient(90deg, rgba(255, 120, 40, 0.05) 0%, transparent 15%, transparent 85%, rgba(255, 120, 40, 0.05) 100%);
|
||||
}
|
||||
|
||||
.building-side {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.building-side.left-side {
|
||||
border-right: 1px solid rgba(0, 210, 255, 0.2);
|
||||
padding-right: 15px;
|
||||
}
|
||||
|
||||
.cityscape-shell.sunset .building-side.left-side {
|
||||
border-right-color: rgba(255, 120, 40, 0.2);
|
||||
}
|
||||
|
||||
.building-side.right-side {
|
||||
border-left: 1px solid rgba(0, 210, 255, 0.2);
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.cityscape-shell.sunset .building-side.right-side {
|
||||
border-left-color: rgba(255, 120, 40, 0.2);
|
||||
}
|
||||
|
||||
.window {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: linear-gradient(135deg, var(--win-cool) 0%, var(--win-cool) 100%);
|
||||
border: 1px solid rgba(0, 210, 255, 0.5);
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 0 10px rgba(0, 210, 255, 0.4), inset 0 0 8px rgba(0, 210, 255, 0.2);
|
||||
position: relative;
|
||||
animation: windowFlicker 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.cityscape-shell.sunset .window {
|
||||
background: linear-gradient(135deg, var(--win-warm) 0%, var(--win-warm) 100%);
|
||||
border-color: rgba(255, 120, 40, 0.5);
|
||||
box-shadow: 0 0 10px rgba(255, 120, 40, 0.4), inset 0 0 8px rgba(255, 120, 40, 0.2);
|
||||
animation: windowFlickerSunset 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes windowFlicker {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 10px rgba(0, 210, 255, 0.4), inset 0 0 8px rgba(0, 210, 255, 0.2);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 15px rgba(0, 210, 255, 0.6), inset 0 0 12px rgba(0, 210, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes windowFlickerSunset {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 10px rgba(255, 120, 40, 0.4), inset 0 0 8px rgba(255, 120, 40, 0.2);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 15px rgba(255, 120, 40, 0.6), inset 0 0 12px rgba(255, 120, 40, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.building-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.player-display-window {
|
||||
background: linear-gradient(135deg, rgba(0, 210, 255, 0.1) 0%, rgba(0, 210, 255, 0.05) 100%);
|
||||
border: 2px solid var(--bb-tag);
|
||||
border-radius: 4px;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
box-shadow: 0 0 20px rgba(0, 210, 255, 0.3), inset 0 0 15px rgba(0, 210, 255, 0.1);
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.cityscape-shell.sunset .player-display-window {
|
||||
background: linear-gradient(135deg, rgba(182, 64, 255, 0.1) 0%, rgba(182, 64, 255, 0.05) 100%);
|
||||
box-shadow: 0 0 20px rgba(255, 64, 249, 0.3), inset 0 0 15px rgba(255, 64, 249, 0.1);
|
||||
}
|
||||
|
||||
.player-avatar {
|
||||
font-size: 4rem;
|
||||
margin-bottom: 15px;
|
||||
display: block;
|
||||
animation: avatarPulse 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes avatarPulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-family: 'Bebas Neue', sans-serif;
|
||||
font-size: 28px;
|
||||
font-weight: bold;
|
||||
color: var(--bb-title);
|
||||
letter-spacing: 1px;
|
||||
margin-bottom: 20px;
|
||||
text-transform: uppercase;
|
||||
background: linear-gradient(90deg, var(--bb-title), var(--bb-tag));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.player-name:hover {
|
||||
filter: brightness(1.2);
|
||||
background: linear-gradient(90deg, var(--bb-tag), var(--bb-title));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.player-name.copied {
|
||||
background: linear-gradient(90deg, #4caf50, #66bb6a);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
box-shadow: 0 0 15px rgba(76, 175, 80, 0.5);
|
||||
}
|
||||
|
||||
.player-id-label {
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
text-transform: uppercase;
|
||||
color: var(--bb-tag);
|
||||
margin-bottom: 8px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.player-id-value {
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
color: var(--bb-tag);
|
||||
background: rgba(0, 210, 255, 0.15);
|
||||
padding: 12px 16px;
|
||||
border: 1.5px solid var(--bb-tag);
|
||||
border-radius: 2px;
|
||||
word-break: break-all;
|
||||
box-shadow: 0 0 15px rgba(0, 210, 255, 0.4);
|
||||
text-shadow: 0 0 10px rgba(0, 210, 255, 0.5);
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.cityscape-shell.sunset .player-id-value {
|
||||
background: rgba(182, 64, 255, 0.15);
|
||||
box-shadow: 0 0 15px rgba(255, 64, 249, 0.4);
|
||||
text-shadow: 0 0 10px rgba(255, 64, 249, 0.5);
|
||||
}
|
||||
|
||||
.player-id-value:hover {
|
||||
background: rgba(0, 210, 255, 0.25);
|
||||
box-shadow: 0 0 20px rgba(0, 210, 255, 0.6);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.cityscape-shell.sunset .player-id-value:hover {
|
||||
background: rgba(182, 64, 255, 0.25);
|
||||
box-shadow: 0 0 20px rgba(255, 64, 249, 0.6);
|
||||
}
|
||||
|
||||
.player-id-value.copied {
|
||||
background: rgba(76, 175, 80, 0.2);
|
||||
border-color: #4caf50;
|
||||
color: #4caf50;
|
||||
box-shadow: 0 0 15px rgba(76, 175, 80, 0.5);
|
||||
}
|
||||
|
||||
.copy-notification {
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
color: #4caf50;
|
||||
margin-top: 12px;
|
||||
animation: copyNotification 2s ease-in-out forwards;
|
||||
}
|
||||
|
||||
@keyframes copyNotification {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
80% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
.building-base {
|
||||
padding: 20px;
|
||||
background: linear-gradient(180deg, rgba(0, 210, 255, 0.05) 0%, rgba(0, 210, 255, 0.02) 100%);
|
||||
border-top: 1px solid rgba(0, 210, 255, 0.2);
|
||||
}
|
||||
|
||||
.cityscape-shell.sunset .building-base {
|
||||
background: linear-gradient(180deg, rgba(255, 120, 40, 0.05) 0%, rgba(255, 120, 40, 0.02) 100%);
|
||||
border-top-color: rgba(255, 120, 40, 0.2);
|
||||
}
|
||||
|
||||
.stat-panel {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
color: var(--bb-tag);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--bb-title);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1px;
|
||||
height: 30px;
|
||||
background: rgba(0, 210, 255, 0.2);
|
||||
}
|
||||
|
||||
.cityscape-shell.sunset .stat-divider {
|
||||
background: rgba(255, 120, 40, 0.2);
|
||||
}
|
||||
|
||||
.building-door {
|
||||
height: 60px;
|
||||
background: linear-gradient(90deg, var(--bldg-mid) 0%, var(--bldg-body) 50%, var(--bldg-mid) 100%);
|
||||
border-top: 1px solid rgba(0, 210, 255, 0.2);
|
||||
border-radius: 0 0 8px 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cityscape-shell.sunset .building-door {
|
||||
border-top-color: rgba(255, 120, 40, 0.2);
|
||||
}
|
||||
|
||||
.building-door::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 90%;
|
||||
height: 100%;
|
||||
border: 1.5px solid rgba(0, 210, 255, 0.3);
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(90deg, rgba(0, 210, 255, 0.05), rgba(0, 210, 255, 0.02));
|
||||
}
|
||||
|
||||
.cityscape-shell.sunset .building-door::before {
|
||||
border-color: rgba(255, 120, 40, 0.3);
|
||||
background: linear-gradient(90deg, rgba(255, 120, 40, 0.05), rgba(255, 120, 40, 0.02));
|
||||
}
|
||||
|
||||
.door-handle {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: radial-gradient(circle at 30% 30%, rgba(0, 210, 255, 0.8), rgba(0, 210, 255, 0.4));
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 10px rgba(0, 210, 255, 0.6);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-left: 80px;
|
||||
animation: handleGlow 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.cityscape-shell.sunset .door-handle {
|
||||
background: radial-gradient(circle at 30% 30%, rgba(255, 120, 40, 0.8), rgba(255, 120, 40, 0.4));
|
||||
box-shadow: 0 0 10px rgba(255, 120, 40, 0.6);
|
||||
animation: handleGlowSunset 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes handleGlow {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 10px rgba(0, 210, 255, 0.6);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(0, 210, 255, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes handleGlowSunset {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 10px rgba(255, 120, 40, 0.6);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 20px rgba(255, 120, 40, 0.9);
|
||||
}
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
position: absolute;
|
||||
top: -50px;
|
||||
left: 0;
|
||||
background: transparent;
|
||||
color: var(--bb-title);
|
||||
border: 1px solid var(--dlg-border);
|
||||
border-radius: 2px;
|
||||
padding: 0.5rem 1rem;
|
||||
font-family: 'Space Mono', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: rgba(0, 210, 255, 0.1);
|
||||
border-color: var(--bb-tag);
|
||||
color: var(--bb-tag);
|
||||
box-shadow: 0 0 15px rgba(0, 210, 255, 0.3);
|
||||
}
|
||||
|
||||
.cityscape-shell.sunset .back-btn:hover {
|
||||
background: rgba(182, 64, 255, 0.1);
|
||||
box-shadow: 0 0 15px rgba(255, 64, 249, 0.3);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.building-structure {
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.building-main {
|
||||
grid-template-columns: 50px 1fr 50px;
|
||||
padding: 15px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.window {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.player-display-window {
|
||||
min-width: 180px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.player-avatar {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.player-name {
|
||||
font-size: 20px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.player-id-value {
|
||||
font-size: 14px;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.building-base {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.stat-panel {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
top: -45px;
|
||||
font-size: 9px;
|
||||
padding: 0.4rem 0.8rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<div class="cityscape-shell" [class.sunset]="isSunsetMode">
|
||||
|
||||
<div class="scene">
|
||||
<div class="sky">
|
||||
<div class="stars-layer">
|
||||
<div class="star" *ngFor="let star of stars" [ngStyle]="star.style"></div>
|
||||
</div>
|
||||
<div class="sun"></div>
|
||||
<div class="cloud-wrap">
|
||||
<div class="cloud cloud-a"></div>
|
||||
<div class="cloud cloud-b"></div>
|
||||
<div class="cloud cloud-c"></div>
|
||||
<div class="cloud cloud-d"></div>
|
||||
<div class="cloud cloud-e"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-layer">
|
||||
<div class="bg-bldg" *ngFor="let building of bgBuildings" [ngStyle]="building.style"></div>
|
||||
</div>
|
||||
|
||||
<div class="main-layer">
|
||||
<div class="profile-building-container">
|
||||
@if (currentUser; as user) {
|
||||
<!-- Player Building with Info Inside -->
|
||||
<div class="building-wrapper">
|
||||
<div class="building-structure">
|
||||
<!-- Building top -->
|
||||
<div class="building-top"></div>
|
||||
|
||||
<!-- Building main section with user inside -->
|
||||
<div class="building-main">
|
||||
<!-- Left side windows -->
|
||||
<div class="building-side left-side">
|
||||
<div class="window"></div>
|
||||
<div class="window"></div>
|
||||
<div class="window"></div>
|
||||
<div class="window"></div>
|
||||
</div>
|
||||
|
||||
<!-- Center section - User Info -->
|
||||
<div class="building-center">
|
||||
<div class="player-display-window">
|
||||
<div class="player-avatar">👤</div>
|
||||
<div class="player-name" (click)="copyUsername(user.username)" [class.copied]="usernameCopied" title="Click to copy">{{ user.username }}</div>
|
||||
<div class="player-id-label">PLAYER ID</div>
|
||||
<div class="player-id-value" (click)="copyPlayerId(user.id)" [class.copied]="idCopied" title="Click to copy">{{ user.id }}</div>
|
||||
@if (idCopied) {
|
||||
<div class="copy-notification">✓ COPIED</div>
|
||||
}
|
||||
@if (usernameCopied) {
|
||||
<div class="copy-notification">✓ COPIED</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right side windows -->
|
||||
<div class="building-side right-side">
|
||||
<div class="window"></div>
|
||||
<div class="window"></div>
|
||||
<div class="window"></div>
|
||||
<div class="window"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Building base with stats -->
|
||||
<div class="building-base">
|
||||
<div class="stat-panel">
|
||||
<div class="stat">
|
||||
<span class="stat-label">RATING</span>
|
||||
<span class="stat-value">{{ user.rating }}</span>
|
||||
</div>
|
||||
<div class="stat-divider"></div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">MEMBER SINCE</span>
|
||||
<span class="stat-value">{{ user.createdAt | date: 'MMM dd, yyyy' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Building door -->
|
||||
<div class="building-door">
|
||||
<div class="door-handle"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Back button -->
|
||||
<button type="button" class="back-btn" (click)="goBack()">← BACK</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="haze"></div>
|
||||
<div class="ground"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Component, OnInit, DestroyRef, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { AuthService } from '../../services/auth.service';
|
||||
import { ThemeService } from '../../services/theme.service';
|
||||
import { CurrentUser } from '../../models/auth.models';
|
||||
|
||||
interface Star {
|
||||
style: Record<string, string>;
|
||||
}
|
||||
|
||||
interface BackgroundBuilding {
|
||||
style: Record<string, string>;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './profile.component.html',
|
||||
styleUrl: './profile.component.css'
|
||||
})
|
||||
export class ProfileComponent implements OnInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly authService = inject(AuthService);
|
||||
private readonly themeService = inject(ThemeService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
currentUser: CurrentUser | null = null;
|
||||
isSunsetMode = false;
|
||||
idCopied = false;
|
||||
usernameCopied = false;
|
||||
|
||||
stars: Star[] = [];
|
||||
bgBuildings: BackgroundBuilding[] = [];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.authService.currentUser$
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((user) => {
|
||||
this.currentUser = user;
|
||||
if (!user) {
|
||||
this.router.navigate(['']);
|
||||
}
|
||||
});
|
||||
|
||||
this.themeService.darkMode$
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe((isDarkMode) => {
|
||||
this.isSunsetMode = !isDarkMode;
|
||||
});
|
||||
|
||||
this.generateStars(220);
|
||||
this.generateBackgroundBuildings();
|
||||
}
|
||||
|
||||
goBack(): void {
|
||||
this.router.navigate(['']);
|
||||
}
|
||||
|
||||
copyPlayerId(id: string): void {
|
||||
navigator.clipboard.writeText(id).then(() => {
|
||||
this.idCopied = true;
|
||||
setTimeout(() => {
|
||||
this.idCopied = false;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
copyUsername(username: string): void {
|
||||
navigator.clipboard.writeText(username).then(() => {
|
||||
this.usernameCopied = true;
|
||||
setTimeout(() => {
|
||||
this.usernameCopied = false;
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
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' },
|
||||
{ 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' },
|
||||
{ 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' },
|
||||
{ l: '94%', w: '6%', h: '27vh' }
|
||||
];
|
||||
|
||||
this.bgBuildings = specs.map((spec) => ({
|
||||
style: { left: spec.l, width: spec.w, height: spec.h }
|
||||
}));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,99 +1,262 @@
|
||||
<main class="welcome-shell">
|
||||
<div class="theme-toggle-container">
|
||||
<div class="switch">
|
||||
<input type="checkbox" class="switch__input" id="themeToggle" (change)="toggleDarkMode()" [checked]="!isDarkMode()">
|
||||
<label class="switch__label" for="themeToggle">
|
||||
<span class="switch__indicator"></span>
|
||||
<span class="switch__decoration"></span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="clouds-container">
|
||||
<div class="cloud cloud-left">
|
||||
<img src="arabian-chess/player-one.gif" alt="Player One" class="cloud-gif" />
|
||||
</div>
|
||||
<div class="plane plane-left">
|
||||
<img src="arabian-chess/plane.png" alt="Plane" class="plane-body" />
|
||||
<img src="arabian-chess/raf.gif" alt="Raf" class="plane-gif" />
|
||||
</div>
|
||||
<div class="cloud cloud-right">
|
||||
<div class="gif-with-halo">
|
||||
<img src="arabian-chess/player-two.gif" alt="Player Two" class="cloud-gif" />
|
||||
<div class="cityscape-shell" [class.sunset]="isSunsetMode">
|
||||
|
||||
<div class="scene">
|
||||
<div class="sky">
|
||||
<div class="stars-layer">
|
||||
<div class="star" *ngFor="let star of stars" [ngStyle]="star.style"></div>
|
||||
</div>
|
||||
<div class="sun"></div>
|
||||
<div class="cloud-wrap">
|
||||
<div class="cloud cloud-a"></div>
|
||||
<div class="cloud cloud-b"></div>
|
||||
<div class="cloud cloud-c"></div>
|
||||
<div class="cloud cloud-d"></div>
|
||||
<div class="cloud cloud-e"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<section class="welcome-card">
|
||||
<h1>Welcome to NowChess</h1>
|
||||
<p>Pick a mode to begin.</p>
|
||||
|
||||
<div class="mode-grid">
|
||||
<button type="button" class="mode mode-active" (click)="toggleDifficultySelector()" [disabled]="creating">
|
||||
<span>Bot</span>
|
||||
<small>{{ creating ? 'Creating game...' : 'Choose difficulty' }}</small>
|
||||
</button>
|
||||
<div class="bg-layer">
|
||||
<div class="bg-bldg" *ngFor="let building of bgBuildings" [ngStyle]="building.style"></div>
|
||||
</div>
|
||||
|
||||
@if (showDifficultySelector) {
|
||||
<div class="difficulty-selector">
|
||||
<p>Select difficulty:</p>
|
||||
<div class="difficulty-buttons">
|
||||
<button type="button" class="difficulty-btn easy" (click)="startVsBot('easy')" [disabled]="creating">
|
||||
Easy
|
||||
</button>
|
||||
<button type="button" class="difficulty-btn medium" (click)="startVsBot('medium')" [disabled]="creating">
|
||||
Medium
|
||||
</button>
|
||||
<button type="button" class="difficulty-btn hard" (click)="startVsBot('hard')" [disabled]="creating">
|
||||
Hard
|
||||
</button>
|
||||
<div class="main-layer">
|
||||
<div class="bwrap" style="left:2.5%;width:16%;">
|
||||
<div class="ant" style="height:38px;"></div>
|
||||
<div class="bpart" style="width:55%;margin:0 auto;height:7vh;">
|
||||
<div class="wins" style="grid-template-columns:repeat(3,1fr);height:100%;align-content:start;">
|
||||
<div class="w" *ngFor="let win of windows['wA1']" [ngStyle]="win.style"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<button type="button" class="mode mode-active" (click)="startOneVsOne()" [disabled]="creating">
|
||||
<span>1 vs 1</span>
|
||||
<small>{{ creating ? 'Creating game...' : 'Start now' }}</small>
|
||||
</button>
|
||||
|
||||
<button type="button" class="mode mode-active" (click)="toggleJoinGameForm()" [disabled]="joiningGame">
|
||||
<span>Join Game</span>
|
||||
<small>{{ joiningGame ? 'Joining...' : 'Enter game ID' }}</small>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (showJoinGameForm) {
|
||||
<div class="join-game-form">
|
||||
<p>Enter the game ID:</p>
|
||||
<div class="join-game-input-group">
|
||||
<input
|
||||
type="text"
|
||||
class="join-game-input"
|
||||
[(ngModel)]="gameIdInput"
|
||||
placeholder="Paste game ID here"
|
||||
[disabled]="joiningGame"
|
||||
(keyup.enter)="joinGame()"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="join-game-btn join"
|
||||
(click)="joinGame()"
|
||||
[disabled]="joiningGame || !gameIdInput.trim()"
|
||||
>
|
||||
{{ joiningGame ? 'Joining...' : 'Join' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="join-game-btn cancel"
|
||||
(click)="clearJoinGameForm()"
|
||||
[disabled]="joiningGame"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div class="bpart" style="width:80%;margin:0 auto;height:9vh;">
|
||||
<div class="wins" style="grid-template-columns:repeat(4,1fr);align-content:start;height:100%;">
|
||||
<div class="w" *ngFor="let win of windows['wA2']" [ngStyle]="win.style"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bpart" style="height:36vh;">
|
||||
<div>
|
||||
<div class="bb">
|
||||
<div class="bb-tag">JOIN</div>
|
||||
<div class="bb-title">JOIN<br />GAME</div>
|
||||
<button type="button" class="app-btn" (click)="openJoinDialog()" [disabled]="joiningGame">
|
||||
{{ joiningGame ? 'JOINING...' : 'JOIN GAME →' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wins" style="grid-template-columns:repeat(5,1fr);">
|
||||
<div class="w" *ngFor="let win of windows['wA3']" [ngStyle]="win.style"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bwrap" style="left:21%;width:15%;">
|
||||
<div class="bpart" style="height:5vh;width:90%;margin:0 auto;">
|
||||
<div class="wins" style="grid-template-columns:repeat(4,1fr);height:100%;align-content:start;">
|
||||
<div class="w" *ngFor="let win of windows['wB1']" [ngStyle]="win.style"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bpart" style="height:44vh;">
|
||||
<div style="padding:5px 5px 0;">
|
||||
<div style="text-align:center;padding:4px 0 6px;">
|
||||
<span class="neon">OPEN 24/7</span>
|
||||
</div>
|
||||
<div class="bb">
|
||||
<div class="bb-tag">BOT</div>
|
||||
<div class="bb-title">PLAY WITH<br />A BOT</div>
|
||||
<button type="button" class="app-btn" (click)="openDifficultyDialog()" [disabled]="creating">
|
||||
{{ creating ? 'CREATING...' : 'GET STARTED →' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wins" style="grid-template-columns:repeat(5,1fr);">
|
||||
<div class="w" *ngFor="let win of windows['wB2']" [ngStyle]="win.style"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bwrap" style="left:39%;width:20%;">
|
||||
<img class="playerone-gif" src="/assets/arabian-chess/player-one.gif" alt="Player One" />
|
||||
<div style="display:flex;justify-content:center;gap:16px;margin-bottom:0;">
|
||||
<div class="ant" style="height:65px;margin-top:-15px;"></div>
|
||||
</div>
|
||||
<div class="bpart" style="height:6vh;width:70%;margin:0 auto;border-radius:2px 2px 0 0;">
|
||||
<div class="wins" style="grid-template-columns:repeat(5,1fr);height:100%;align-content:start;">
|
||||
<div class="w" *ngFor="let win of windows['wC1']" [ngStyle]="win.style"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bpart" style="height:10vh;">
|
||||
<div class="wins" style="grid-template-columns:repeat(6,1fr);align-content:start;height:100%;">
|
||||
<div class="w" *ngFor="let win of windows['wC2']" [ngStyle]="win.style"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bpart" style="height:48vh;">
|
||||
<div style="padding:6px 6px 0;">
|
||||
<div class="bb" style="padding:14px 14px 12px;">
|
||||
<div class="bb-tag">WELCOME</div>
|
||||
<div class="bb-title" style="font-size:clamp(16px,1.8vw,26px);">WELCOME TO<br />NOWCHESS</div>
|
||||
<div class="bb-subtitle">Play your next move from the skyline.</div>
|
||||
<button type="button" class="app-btn" (click)="startOneVsOne()" [disabled]="creating">
|
||||
{{ creating ? 'CREATING...' : 'START NOW →' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wins" style="grid-template-columns:repeat(7,1fr);">
|
||||
<div class="w" *ngFor="let win of windows['wC3']" [ngStyle]="win.style"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bwrap" style="left:63%;width:18%;">
|
||||
<div class="ant" style="height:30px;"></div>
|
||||
<div class="bpart" style="height:5vh;width:110%;margin-left:-5%;">
|
||||
<div class="wins" style="grid-template-columns:repeat(6,1fr);height:100%;align-content:start;">
|
||||
<div class="w" *ngFor="let win of windows['wD1']" [ngStyle]="win.style"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bpart" style="height:42vh;">
|
||||
<div style="padding:5px 5px 0;">
|
||||
<div style="text-align:center;padding:4px 0 6px;">
|
||||
<span class="neon">MORE</span>
|
||||
</div>
|
||||
<div class="bb">
|
||||
<div class="bb-tag">OPTIONS</div>
|
||||
<div class="bb-title">MORE<br />OPTIONS</div>
|
||||
<button type="button" class="app-btn" (click)="openOptionsDialog()">OPEN MENU →</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wins" style="grid-template-columns:repeat(6,1fr);">
|
||||
<div class="w" *ngFor="let win of windows['wD2']" [ngStyle]="win.style"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bwrap" style="left:83%;width:10%;">
|
||||
<div class="bpart" style="height:30vh;">
|
||||
<div class="wins" style="grid-template-columns:repeat(3,1fr);height:100%;align-content:start;">
|
||||
<div class="w" *ngFor="let win of windows['wE1']" [ngStyle]="win.style"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Speech Bubble -->
|
||||
@if (showSpeechBubble) {
|
||||
<div class="speech-bubble-container" (click)="onSpeechBubbleClick()">
|
||||
<div class="speech-bubble">
|
||||
<div class="bubble-text">{{ bubbleMessage }}</div>
|
||||
<div class="bubble-tail"></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (errorMessage) {
|
||||
<p class="error">{{ errorMessage }}</p>
|
||||
<!-- Zoomed Window View -->
|
||||
@if (isZoomedIn) {
|
||||
<div class="zoom-overlay" (click)="onZoomedViewClick()" (mousemove)="onMouseMove($event)" (mouseup)="onMouseUp()"
|
||||
(mouseleave)="onMouseUp()">
|
||||
<div class="zoom-window-wrapper" (click)="$event.stopPropagation()">
|
||||
<div class="zoom-window-frame">
|
||||
<div class="zoom-player-2">
|
||||
<img src="/assets/arabian-chess/player-two.gif" alt="Player 2" class="player-2-gif"
|
||||
(click)="$event.stopPropagation()" />
|
||||
@if (showSecondSpeechBubble) {
|
||||
<div class="second-speech-bubble">
|
||||
<div class="bubble-text">Feed me! 🍖</div>
|
||||
<div class="bubble-tail"></div>
|
||||
</div>
|
||||
}
|
||||
@if (showHappyBubble) {
|
||||
<div class="happy-speech-bubble">
|
||||
<div class="bubble-text">Happy meow! 😸</div>
|
||||
<div class="bubble-tail"></div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Draggable Meat Emoji -->
|
||||
@if (showMeatEmoji) {
|
||||
<div class="meat-emoji" [style.left.px]="meatX" [style.top.px]="meatY" (mousedown)="onMeatMouseDown($event)">
|
||||
🍖
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="haze"></div>
|
||||
<div class="ground"></div>
|
||||
</div>
|
||||
|
||||
@if (showDifficultyDialog) {
|
||||
<div class="dialog-overlay" (click)="closeDifficultyDialog()">
|
||||
<div class="dialog-card" (click)="$event.stopPropagation()">
|
||||
<div class="dialog-title">SELECT DIFFICULTY</div>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="app-btn" (click)="startVsBot('easy')" [disabled]="creating">EASY</button>
|
||||
<button type="button" class="app-btn" (click)="startVsBot('medium')" [disabled]="creating">MEDIUM</button>
|
||||
<button type="button" class="app-btn" (click)="startVsBot('hard')" [disabled]="creating">HARD</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showOptionsDialog) {
|
||||
<div class="dialog-overlay" (click)="closeOptionsDialog()">
|
||||
<div class="dialog-card" (click)="$event.stopPropagation()">
|
||||
<div class="dialog-title">MORE OPTIONS</div>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="app-btn" (click)="openImportDialog()">IMPORT GAME</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showJoinDialog) {
|
||||
<div class="dialog-overlay" (click)="closeJoinDialog()">
|
||||
<div class="dialog-card" (click)="$event.stopPropagation()">
|
||||
<div class="dialog-title">JOIN GAME</div>
|
||||
<input type="text" class="dialog-input" [(ngModel)]="gameIdInput" placeholder="Paste game ID here"
|
||||
[disabled]="joiningGame" (keyup.enter)="submitJoinGame()" />
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="app-btn" (click)="submitJoinGame()"
|
||||
[disabled]="joiningGame || !gameIdInput.trim()">
|
||||
{{ joiningGame ? 'JOINING...' : 'JOIN' }}
|
||||
</button>
|
||||
<button type="button" class="app-btn" (click)="closeJoinDialog()" [disabled]="joiningGame">CANCEL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (showImportDialog) {
|
||||
<div class="dialog-overlay" (click)="closeImportDialog()">
|
||||
<div class="dialog-card" (click)="$event.stopPropagation()">
|
||||
<div class="dialog-title">IMPORT GAME</div>
|
||||
<div class="import-mode-group" role="radiogroup" aria-label="Import mode">
|
||||
<label class="import-mode-option">
|
||||
<input type="radio" name="importMode" [checked]="importMode === 'fen'" (change)="setImportMode('fen')"
|
||||
[disabled]="importing" />
|
||||
<span>FEN</span>
|
||||
</label>
|
||||
<label class="import-mode-option">
|
||||
<input type="radio" name="importMode" [checked]="importMode === 'pgn'" (change)="setImportMode('pgn')"
|
||||
[disabled]="importing" />
|
||||
<span>PGN</span>
|
||||
</label>
|
||||
</div>
|
||||
<textarea class="dialog-input dialog-textarea" [(ngModel)]="importText"
|
||||
[placeholder]="importMode === 'fen' ? 'Paste FEN here' : 'Paste PGN here'" [disabled]="importing"
|
||||
rows="5"></textarea>
|
||||
<div class="dialog-actions">
|
||||
<button type="button" class="app-btn" (click)="submitImportGame()" [disabled]="importing || !importText.trim()">
|
||||
{{ importing ? 'IMPORTING...' : 'IMPORT' }}
|
||||
</button>
|
||||
<button type="button" class="app-btn" (click)="closeImportDialog()" [disabled]="importing">CANCEL</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (errorMessage) {
|
||||
<p class="error-banner">{{ errorMessage }}</p>
|
||||
}
|
||||
</div>
|
||||
@@ -1,41 +1,342 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
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';
|
||||
|
||||
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],
|
||||
templateUrl: './welcome.component.html',
|
||||
styleUrl: './welcome.component.css'
|
||||
styleUrls: ['./welcome.component.css']
|
||||
})
|
||||
export class WelcomeComponent {
|
||||
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;
|
||||
errorMessage = '';
|
||||
showDifficultySelector = false;
|
||||
showJoinGameForm = false;
|
||||
gameIdInput = '';
|
||||
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
|
||||
) {
|
||||
this.initTheme();
|
||||
}
|
||||
|
||||
private initTheme(): void {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
if (savedTheme === 'dark') {
|
||||
document.documentElement.setAttribute('data-theme', 'dark');
|
||||
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;
|
||||
}
|
||||
@@ -48,7 +349,9 @@ export class WelcomeComponent {
|
||||
.pipe(finalize(() => (this.creating = false)))
|
||||
.subscribe({
|
||||
next: (game) => {
|
||||
void this.router.navigate(['/game', game.gameId]);
|
||||
void this.router.navigate(['/game', game.gameId], {
|
||||
state: { theme: this.isSunsetMode ? 'light' : 'dark' }
|
||||
});
|
||||
},
|
||||
error: (error) => {
|
||||
this.errorMessage = getErrorMessage(error, 'Unable to create a game.');
|
||||
@@ -56,21 +359,23 @@ export class WelcomeComponent {
|
||||
});
|
||||
}
|
||||
|
||||
startVsBot(difficulty: 'easy' | 'medium' | 'hard'): void {
|
||||
private performStartVsBot(difficulty: Difficulty): void {
|
||||
if (this.creating) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage = '';
|
||||
this.creating = true;
|
||||
this.showDifficultySelector = false;
|
||||
this.showDifficultyDialog = false;
|
||||
|
||||
this.gameApi
|
||||
.createGameVsBot(difficulty)
|
||||
.pipe(finalize(() => (this.creating = false)))
|
||||
.subscribe({
|
||||
next: (game) => {
|
||||
void this.router.navigate(['/game', game.gameId]);
|
||||
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.');
|
||||
@@ -78,21 +383,9 @@ export class WelcomeComponent {
|
||||
});
|
||||
}
|
||||
|
||||
toggleDifficultySelector(): void {
|
||||
this.showDifficultySelector = !this.showDifficultySelector;
|
||||
this.showJoinGameForm = false;
|
||||
this.errorMessage = '';
|
||||
}
|
||||
|
||||
toggleJoinGameForm(): void {
|
||||
this.showJoinGameForm = !this.showJoinGameForm;
|
||||
this.showDifficultySelector = false;
|
||||
this.errorMessage = '';
|
||||
this.gameIdInput = '';
|
||||
}
|
||||
|
||||
joinGame(): void {
|
||||
if (this.joiningGame || !this.gameIdInput.trim()) {
|
||||
private performSubmitJoinGame(): void {
|
||||
const gameId = this.gameIdInput.trim();
|
||||
if (this.joiningGame || !gameId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -100,11 +393,14 @@ export class WelcomeComponent {
|
||||
this.joiningGame = true;
|
||||
|
||||
this.gameApi
|
||||
.getGame(this.gameIdInput.trim())
|
||||
.getGame(gameId)
|
||||
.pipe(finalize(() => (this.joiningGame = false)))
|
||||
.subscribe({
|
||||
next: (game) => {
|
||||
void this.router.navigate(['/game', game.gameId]);
|
||||
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.');
|
||||
@@ -112,26 +408,193 @@ export class WelcomeComponent {
|
||||
});
|
||||
}
|
||||
|
||||
clearJoinGameForm(): void {
|
||||
this.showJoinGameForm = false;
|
||||
this.gameIdInput = '';
|
||||
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 = '';
|
||||
}
|
||||
|
||||
toggleDarkMode(): void {
|
||||
const htmlElement = document.documentElement;
|
||||
const isDarkMode = htmlElement.getAttribute('data-theme') === 'dark';
|
||||
|
||||
if (isDarkMode) {
|
||||
htmlElement.removeAttribute('data-theme');
|
||||
localStorage.removeItem('theme');
|
||||
} else {
|
||||
htmlElement.setAttribute('data-theme', 'dark');
|
||||
localStorage.setItem('theme', 'dark');
|
||||
}
|
||||
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`
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
isDarkMode(): boolean {
|
||||
return document.documentElement.getAttribute('data-theme') === 'dark';
|
||||
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 = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user