Compare commits
4 Commits
4da882f481
...
e83ec814d9
| Author | SHA1 | Date | |
|---|---|---|---|
| e83ec814d9 | |||
| 0d7bb0343c | |||
| 8b090e4d96 | |||
| 3e8c7c4057 |
@@ -23,7 +23,7 @@ yarn-error.log
|
||||
# Visual Studio Code
|
||||
.vscode/
|
||||
.history/*
|
||||
.angualar/
|
||||
.angular/
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
|
||||
@@ -1,60 +1,41 @@
|
||||
# NowchessFrontend
|
||||
# NowChess Frontend
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.3.24.
|
||||
Angular 20 frontend for the NowChess board UI.
|
||||
|
||||
## Development server
|
||||
## Tech stack
|
||||
|
||||
To start a local development server, run:
|
||||
- Angular standalone components and route-based pages
|
||||
- HTTP and streaming integration for live game updates
|
||||
- Asset sprites loaded from `arabian-chess/`
|
||||
|
||||
## Project structure
|
||||
|
||||
- `src/app/pages` page-level containers (`welcome`, `game`)
|
||||
- `src/app/components` reusable UI pieces (`chess-board`, `chess-piece`)
|
||||
- `src/app/services` API and stream integration (`GameApiService`)
|
||||
- `src/app/models` shared API/domain types
|
||||
- `src/app/core/chess` chess domain utilities (FEN parsing and square lookup)
|
||||
- `src/environments` environment-specific API base URLs
|
||||
|
||||
## Run locally
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
npm install
|
||||
npm start
|
||||
```
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
Open `http://localhost:4200`.
|
||||
|
||||
## Code scaffolding
|
||||
Development environment defaults to:
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
- API: `http://localhost:8080`
|
||||
- WebSocket: `ws://localhost:8080`
|
||||
|
||||
`src/environments/environment.ts` is production-oriented (`production: true`) and `src/environments/environment.development.ts` is development-oriented (`production: false`).
|
||||
|
||||
## Build and test
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
npm run build
|
||||
npm test
|
||||
```
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the project run:
|
||||
|
||||
```bash
|
||||
ng build
|
||||
```
|
||||
|
||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "ARABIAN CHESS",
|
||||
"input": "arabian-chess",
|
||||
"output": "/arabian-chess"
|
||||
}
|
||||
],
|
||||
@@ -90,7 +90,7 @@
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "ARABIAN CHESS",
|
||||
"input": "arabian-chess",
|
||||
"output": "/arabian-chess"
|
||||
}
|
||||
],
|
||||
|
||||
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 907 B After Width: | Height: | Size: 907 B |
|
Before Width: | Height: | Size: 919 B After Width: | Height: | Size: 919 B |
|
Before Width: | Height: | Size: 818 B After Width: | Height: | Size: 818 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 161 B After Width: | Height: | Size: 161 B |
|
Before Width: | Height: | Size: 188 B After Width: | Height: | Size: 188 B |
|
Before Width: | Height: | Size: 188 B After Width: | Height: | Size: 188 B |
|
Before Width: | Height: | Size: 237 B After Width: | Height: | Size: 237 B |
|
Before Width: | Height: | Size: 243 B After Width: | Height: | Size: 243 B |
|
Before Width: | Height: | Size: 264 B After Width: | Height: | Size: 264 B |
|
Before Width: | Height: | Size: 244 B After Width: | Height: | Size: 244 B |
|
Before Width: | Height: | Size: 240 B After Width: | Height: | Size: 240 B |
|
Before Width: | Height: | Size: 232 B After Width: | Height: | Size: 232 B |
|
Before Width: | Height: | Size: 287 B After Width: | Height: | Size: 287 B |
|
Before Width: | Height: | Size: 211 B After Width: | Height: | Size: 211 B |
|
Before Width: | Height: | Size: 238 B After Width: | Height: | Size: 238 B |
|
Before Width: | Height: | Size: 227 B After Width: | Height: | Size: 227 B |
|
Before Width: | Height: | Size: 267 B After Width: | Height: | Size: 267 B |
|
Before Width: | Height: | Size: 300 B After Width: | Height: | Size: 300 B |
|
Before Width: | Height: | Size: 218 B After Width: | Height: | Size: 218 B |
|
Before Width: | Height: | Size: 244 B After Width: | Height: | Size: 244 B |
|
Before Width: | Height: | Size: 245 B After Width: | Height: | Size: 245 B |
|
Before Width: | Height: | Size: 229 B After Width: | Height: | Size: 229 B |
|
Before Width: | Height: | Size: 286 B After Width: | Height: | Size: 286 B |
|
Before Width: | Height: | Size: 245 B After Width: | Height: | Size: 245 B |
|
Before Width: | Height: | Size: 266 B After Width: | Height: | Size: 266 B |
|
Before Width: | Height: | Size: 297 B After Width: | Height: | Size: 297 B |
|
Before Width: | Height: | Size: 258 B After Width: | Height: | Size: 258 B |
|
Before Width: | Height: | Size: 263 B After Width: | Height: | Size: 263 B |
|
Before Width: | Height: | Size: 313 B After Width: | Height: | Size: 313 B |
|
Before Width: | Height: | Size: 251 B After Width: | Height: | Size: 251 B |
|
Before Width: | Height: | Size: 275 B After Width: | Height: | Size: 275 B |
|
Before Width: | Height: | Size: 305 B After Width: | Height: | Size: 305 B |
|
Before Width: | Height: | Size: 281 B After Width: | Height: | Size: 281 B |
|
Before Width: | Height: | Size: 280 B After Width: | Height: | Size: 280 B |
@@ -14,6 +14,7 @@
|
||||
"@angular/forms": "^20.3.0",
|
||||
"@angular/platform-browser": "^20.3.0",
|
||||
"@angular/router": "^20.3.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0"
|
||||
@@ -2969,6 +2970,17 @@
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
@@ -3820,6 +3832,25 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/bootstrap": {
|
||||
"version": "5.3.8",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz",
|
||||
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/twbs"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/bootstrap"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@popperjs/core": "^2.11.8"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"@angular/forms": "^20.3.0",
|
||||
"@angular/platform-browser": "^20.3.0",
|
||||
"@angular/router": "^20.3.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0"
|
||||
@@ -45,4 +46,4 @@
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.9.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,28 @@
|
||||
.piece {
|
||||
width: clamp(40px, 9cqh, 130px);
|
||||
height: clamp(40px, 9cqh, 130px);
|
||||
width: clamp(50px, 11cqh, 160px);
|
||||
height: clamp(50px, 11cqh, 160px);
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 991px) {
|
||||
.piece {
|
||||
width: clamp(40px, 9cqh, 130px);
|
||||
height: clamp(40px, 9cqh, 130px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.piece {
|
||||
width: clamp(32px, 8cqh, 100px);
|
||||
height: clamp(32px, 8cqh, 100px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.piece {
|
||||
width: clamp(24px, 6cqh, 75px);
|
||||
height: clamp(24px, 6cqh, 75px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { GameTurn } from '../../models/game.models';
|
||||
|
||||
export function getPieceAtSquare(fen: string, targetSquare: string): string | null {
|
||||
const placement = fen.split(' ')[0] ?? '';
|
||||
const rows = placement.split('/');
|
||||
if (rows.length !== 8 || targetSquare.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const file = targetSquare.charCodeAt(0) - 97;
|
||||
const rank = Number(targetSquare[1]);
|
||||
const rowIndex = 8 - rank;
|
||||
|
||||
if (Number.isNaN(rank) || file < 0 || file > 7 || rowIndex < 0 || rowIndex > 7) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let column = 0;
|
||||
for (const symbol of rows[rowIndex]) {
|
||||
if (symbol >= '1' && symbol <= '8') {
|
||||
column += Number(symbol);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (column === file) {
|
||||
return symbol;
|
||||
}
|
||||
|
||||
column += 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function isPieceColor(pieceCode: string, turn: GameTurn): boolean {
|
||||
const isWhitePiece = pieceCode === pieceCode.toUpperCase();
|
||||
return (turn === 'white' && isWhitePiece) || (turn === 'black' && !isWhitePiece);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export function getErrorMessage(error: unknown, fallback: string): string {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const httpError = error as { error?: { message?: unknown } };
|
||||
return typeof httpError.error?.message === 'string' ? httpError.error.message : fallback;
|
||||
}
|
||||
@@ -1,161 +1,253 @@
|
||||
.game-shell {
|
||||
height: 100dvh;
|
||||
min-height: 100dvh;
|
||||
padding: clamp(0.75rem, 2vw, 1.5rem);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.game-card {
|
||||
max-width: 1100px;
|
||||
height: 100%;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
background: #F3C8A0;
|
||||
border: 2px solid #5A2C28;
|
||||
border-radius: 12px;
|
||||
padding: clamp(0.75rem, 1.5vw, 1.25rem);
|
||||
padding: clamp(1rem, 2vw, 1.5rem);
|
||||
box-shadow: 0 8px 24px rgba(90, 44, 40, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
color: #5A2C28;
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: clamp(1.5rem, 4vw, 2rem);
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin: 0;
|
||||
color: #5A2C28;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #5A2C28;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.top-section {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.content-layout {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 0.95fr) minmax(0, 2.4fr) minmax(220px, 0.95fr);
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.center-column {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: #F3C8A0;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #5A2C28;
|
||||
}
|
||||
|
||||
.move-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.move-form label {
|
||||
color: #5A2C28;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.board-hint {
|
||||
margin: 0;
|
||||
color: #5A2C28;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.center-column {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.board-section {
|
||||
flex: 1 1 auto;
|
||||
min-height: 180px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
container-type: size;
|
||||
padding: clamp(0.35rem, 1vw, 0.75rem);
|
||||
border-radius: 10px;
|
||||
border: 2px solid #5A2C28;
|
||||
background: #B9DAD1;
|
||||
overflow: hidden;
|
||||
border: 2px solid #5A2C28;
|
||||
border-radius: 10px;
|
||||
padding: clamp(0.5rem, 1vw, 1rem);
|
||||
min-height: 400px;
|
||||
container-type: size;
|
||||
}
|
||||
|
||||
.import-card {
|
||||
background: #E1EAA9;
|
||||
border: 2px solid #5A2C28;
|
||||
border-radius: 10px;
|
||||
padding: 0.65rem;
|
||||
display: grid;
|
||||
align-self: start;
|
||||
align-content: start;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.import-card label {
|
||||
color: #5A2C28;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
input {
|
||||
.import-card textarea {
|
||||
border: 2px solid #5A2C28;
|
||||
border-radius: 10px;
|
||||
background: #B9DAD1;
|
||||
padding: 0.6rem 0.75rem;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
textarea {
|
||||
border: 2px solid #5A2C28;
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
background: #B9DAD1;
|
||||
padding: 0.6rem 0.75rem;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
min-height: 100px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
.import-card input {
|
||||
border: 2px solid #5A2C28;
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
background: #B9DAD1;
|
||||
padding: 0.6rem 0.75rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.btn {
|
||||
border: 2px solid #5A2C28;
|
||||
border-radius: 8px;
|
||||
background: #C19EF5;
|
||||
color: #5A2C28;
|
||||
padding: 0.6rem 1rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
transition: background-color 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
.btn:hover {
|
||||
background: #BA6D4B;
|
||||
color: #F3C8A0;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 0.5rem;
|
||||
color: #5A2C28;
|
||||
font-weight: 700;
|
||||
.alert {
|
||||
border-radius: 8px;
|
||||
border: 2px solid #5A2C28;
|
||||
}
|
||||
|
||||
@media (max-width: 920px) {
|
||||
.content-layout {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-areas:
|
||||
"center"
|
||||
"fen"
|
||||
"pgn";
|
||||
@media (max-width: 991px) {
|
||||
.game-card {
|
||||
padding: clamp(0.75rem, 1.5vw, 1rem);
|
||||
}
|
||||
|
||||
.center-column {
|
||||
grid-area: center;
|
||||
.board-section {
|
||||
min-height: 350px;
|
||||
}
|
||||
|
||||
.fen-card {
|
||||
grid-area: fen;
|
||||
}
|
||||
|
||||
.pgn-card {
|
||||
grid-area: pgn;
|
||||
h1,
|
||||
h2 {
|
||||
font-size: clamp(1.25rem, 3vw, 1.75rem);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.game-shell {
|
||||
padding: clamp(0.5rem, 1.5vw, 1rem);
|
||||
}
|
||||
|
||||
.game-card {
|
||||
padding: clamp(0.5rem, 1vw, 0.75rem);
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.top-section {
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.move-form {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.move-form label {
|
||||
flex-basis: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.board-section {
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
.import-card {
|
||||
padding: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.import-card label {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.import-card textarea,
|
||||
.import-card input {
|
||||
min-height: 70px;
|
||||
font-size: 0.9rem;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.game-shell {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.game-card {
|
||||
padding: 0.5rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
header {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.top-section {
|
||||
padding: 0.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.board-section {
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
.import-card {
|
||||
padding: 0.5rem;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.import-card textarea,
|
||||
.import-card input {
|
||||
min-height: 50px;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +1,80 @@
|
||||
<main class="game-shell">
|
||||
<section class="game-card">
|
||||
<header>
|
||||
<header class="mb-3">
|
||||
<a routerLink="/" class="back-link">Back</a>
|
||||
<h1>1 vs 1 Game</h1>
|
||||
<p class="meta">Game ID: <strong>{{ gameId }}</strong></p>
|
||||
<h1 class="mb-2">1 vs 1 Game</h1>
|
||||
<p class="meta mb-0">Game ID: <strong>{{ facade.gameId }}</strong></p>
|
||||
</header>
|
||||
|
||||
@if (loading) {
|
||||
@if (facade.loading) {
|
||||
<p>Loading game state...</p>
|
||||
} @else if (state) {
|
||||
<section class="content-layout">
|
||||
<aside class="import-card fen-card">
|
||||
<label for="fenImport">Import FEN</label>
|
||||
<textarea
|
||||
id="fenImport"
|
||||
name="fenImport"
|
||||
[(ngModel)]="fenInput"
|
||||
rows="4"
|
||||
placeholder="rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
></textarea>
|
||||
<button type="button" (click)="importFen()">Load FEN</button>
|
||||
</aside>
|
||||
} @else if (facade.state) {
|
||||
<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">
|
||||
<aside class="import-card fen-card h-100">
|
||||
<label for="fenImport">Import FEN</label>
|
||||
<textarea
|
||||
id="fenImport"
|
||||
name="fenImport"
|
||||
[(ngModel)]="facade.fenInput"
|
||||
rows="4"
|
||||
placeholder="rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
|
||||
></textarea>
|
||||
<button type="button" class="btn w-100" (click)="facade.importFen()">Load FEN</button>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section class="center-column">
|
||||
<section class="top-section">
|
||||
<form class="move-form" (ngSubmit)="submitMove()">
|
||||
<label for="uciMove">Play move (UCI)</label>
|
||||
<input id="uciMove" name="uciMove" [(ngModel)]="moveInput" placeholder="e2e4" />
|
||||
<button type="submit">Send Move</button>
|
||||
</form>
|
||||
<p class="board-hint">Click your piece to highlight legal targets.</p>
|
||||
</section>
|
||||
<!-- 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="top-section mb-3">
|
||||
<form class="move-form d-flex flex-wrap gap-2 mb-2" (ngSubmit)="facade.submitMove()">
|
||||
<label for="uciMove" class="form-label mb-0">Play move (UCI)</label>
|
||||
<input
|
||||
id="uciMove"
|
||||
name="uciMove"
|
||||
class="form-control flex-grow-1"
|
||||
[(ngModel)]="facade.moveInput"
|
||||
placeholder="e2e4"
|
||||
/>
|
||||
<button type="submit" class="btn">Send Move</button>
|
||||
</form>
|
||||
<p class="board-hint small mb-0">Click your piece to highlight legal targets.</p>
|
||||
</div>
|
||||
|
||||
<section class="board-section">
|
||||
<app-chess-board
|
||||
[fen]="state.fen"
|
||||
[selectedSquare]="selectedSquare"
|
||||
[highlightedSquares]="highlightedSquares"
|
||||
(squareSelected)="onBoardSquareSelected($event)"
|
||||
/>
|
||||
</section>
|
||||
</section>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="import-card pgn-card">
|
||||
<label for="pgnImport">Import PGN</label>
|
||||
<textarea
|
||||
id="pgnImport"
|
||||
name="pgnImport"
|
||||
[(ngModel)]="pgnInput"
|
||||
rows="4"
|
||||
placeholder="1. e4 e5 2. Nf3 Nc6 *"
|
||||
></textarea>
|
||||
<button type="button" (click)="importPgn()">Load PGN</button>
|
||||
</aside>
|
||||
</section>
|
||||
<!-- Right Sidebar - PGN Import -->
|
||||
<div class="col-lg-3 col-md-6 col-12 order-lg-3 order-3">
|
||||
<aside class="import-card pgn-card h-100">
|
||||
<label for="pgnImport">Import PGN</label>
|
||||
<textarea
|
||||
id="pgnImport"
|
||||
name="pgnImport"
|
||||
[(ngModel)]="facade.pgnInput"
|
||||
rows="4"
|
||||
placeholder="1. e4 e5 2. Nf3 Nc6 *"
|
||||
></textarea>
|
||||
<button type="button" class="btn w-100" (click)="facade.importPgn()">Load PGN</button>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (errorMessage) {
|
||||
<p class="error">{{ errorMessage }}</p>
|
||||
@if (facade.errorMessage) {
|
||||
<p class="alert alert-danger mt-3 mb-0">{{ facade.errorMessage }}</p>
|
||||
}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -1,272 +1,34 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Component, DestroyRef, OnInit, inject } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
|
||||
import { interval, startWith, Subscription, switchMap } from 'rxjs';
|
||||
import { ActivatedRoute, RouterLink } from '@angular/router';
|
||||
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
|
||||
import { GameFull, GameState, GameStreamEvent, LegalMove } from '../../models/game.models';
|
||||
import { GameApiService } from '../../services/game-api.service';
|
||||
import { GameFacade } from './game.facade';
|
||||
|
||||
@Component({
|
||||
selector: 'app-game',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule, RouterLink, ChessBoardComponent],
|
||||
providers: [GameFacade],
|
||||
templateUrl: './game.component.html',
|
||||
styleUrl: './game.component.css'
|
||||
})
|
||||
export class GameComponent implements OnInit, OnDestroy {
|
||||
gameId = '';
|
||||
game: GameFull | null = null;
|
||||
errorMessage = '';
|
||||
moveInput = '';
|
||||
fenInput = '';
|
||||
pgnInput = '';
|
||||
loading = true;
|
||||
selectedSquare: string | null = null;
|
||||
highlightedSquares: string[] = [];
|
||||
|
||||
private selectedSquareMoves: LegalMove[] = [];
|
||||
private streamSubscription: Subscription | null = null;
|
||||
private pollSubscription: Subscription | null = null;
|
||||
private routeSubscription: Subscription | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly router: Router,
|
||||
private readonly gameApi: GameApiService
|
||||
) {}
|
||||
|
||||
get state(): GameState | null {
|
||||
return this.game?.state ?? null;
|
||||
}
|
||||
export class GameComponent implements OnInit {
|
||||
private readonly route = inject(ActivatedRoute);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
readonly facade = inject(GameFacade);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.routeSubscription = this.route.paramMap.subscribe((paramMap) => {
|
||||
this.route.paramMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((paramMap) => {
|
||||
const id = paramMap.get('gameId');
|
||||
if (!id) {
|
||||
this.errorMessage = 'Missing gameId in route.';
|
||||
this.loading = false;
|
||||
this.facade.errorMessage = 'Missing gameId in route.';
|
||||
this.facade.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.gameId = id;
|
||||
this.loadGame();
|
||||
this.facade.setGameId(id);
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.routeSubscription?.unsubscribe();
|
||||
this.streamSubscription?.unsubscribe();
|
||||
this.pollSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
onBoardSquareSelected(square: string): void {
|
||||
if (!this.state) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selectedSquare && this.highlightedSquares.includes(square)) {
|
||||
const selectedMove = this.selectedSquareMoves.find((move) => move.to === square);
|
||||
if (selectedMove) {
|
||||
this.moveInput = selectedMove.uci;
|
||||
this.submitMove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const piece = this.getPieceAtSquare(this.state.fen, square);
|
||||
if (!piece || !this.isCurrentTurnPiece(piece)) {
|
||||
this.clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage = '';
|
||||
this.gameApi.getLegalMoves(this.gameId, square).subscribe({
|
||||
next: (response) => {
|
||||
this.selectedSquare = square;
|
||||
this.selectedSquareMoves = response.moves;
|
||||
this.highlightedSquares = response.moves.map((move) => move.to);
|
||||
},
|
||||
error: () => {
|
||||
this.clearSelection();
|
||||
this.errorMessage = 'Could not load legal moves for selected square.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
submitMove(): void {
|
||||
const uci = this.moveInput.trim();
|
||||
if (!uci) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage = '';
|
||||
this.gameApi.makeMove(this.gameId, uci).subscribe({
|
||||
next: (state) => {
|
||||
if (this.game) {
|
||||
this.game = { ...this.game, state };
|
||||
}
|
||||
this.moveInput = '';
|
||||
this.clearSelection();
|
||||
},
|
||||
error: (error: { error?: { message?: string } }) => {
|
||||
this.errorMessage = error.error?.message ?? 'Move rejected.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
importFen(): void {
|
||||
const fen = this.fenInput.trim();
|
||||
if (!fen) {
|
||||
this.errorMessage = 'Please provide a FEN string.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage = '';
|
||||
this.gameApi.importFen(fen).subscribe({
|
||||
next: (game) => {
|
||||
this.fenInput = '';
|
||||
this.pgnInput = '';
|
||||
void this.router.navigate(['/game', game.gameId]);
|
||||
},
|
||||
error: (error: { error?: { message?: string } }) => {
|
||||
this.errorMessage = error.error?.message ?? 'FEN import failed.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
importPgn(): void {
|
||||
const pgn = this.pgnInput.trim();
|
||||
if (!pgn) {
|
||||
this.errorMessage = 'Please provide a PGN string.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage = '';
|
||||
this.gameApi.importPgn(pgn).subscribe({
|
||||
next: (game) => {
|
||||
this.pgnInput = '';
|
||||
this.fenInput = '';
|
||||
void this.router.navigate(['/game', game.gameId]);
|
||||
},
|
||||
error: (error: { error?: { message?: string } }) => {
|
||||
this.errorMessage = error.error?.message ?? 'PGN import failed.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadGame(): void {
|
||||
this.loading = true;
|
||||
this.errorMessage = '';
|
||||
this.clearSelection();
|
||||
this.streamSubscription?.unsubscribe();
|
||||
this.pollSubscription?.unsubscribe();
|
||||
|
||||
this.gameApi.getGame(this.gameId).subscribe({
|
||||
next: (game) => {
|
||||
this.game = game;
|
||||
this.loading = false;
|
||||
this.startStream();
|
||||
this.startPolling();
|
||||
},
|
||||
error: (error: { error?: { message?: string } }) => {
|
||||
this.errorMessage = error.error?.message ?? `Could not load game ${this.gameId}.`;
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startStream(): void {
|
||||
this.streamSubscription = this.gameApi.streamGame(this.gameId).subscribe({
|
||||
next: (event) => this.applyStreamEvent(event),
|
||||
error: () => {
|
||||
this.errorMessage = 'Live stream disconnected.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
this.pollSubscription = interval(1500)
|
||||
.pipe(
|
||||
startWith(0),
|
||||
switchMap(() => this.gameApi.getGame(this.gameId))
|
||||
)
|
||||
.subscribe({
|
||||
next: (game) => {
|
||||
const previousMoves = this.game?.state.moves.join(',') ?? '';
|
||||
this.game = game;
|
||||
if (previousMoves !== game.state.moves.join(',')) {
|
||||
this.clearSelection();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private applyStreamEvent(event: GameStreamEvent): void {
|
||||
if (event.type === 'gameFull') {
|
||||
this.game = event.game;
|
||||
this.clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'gameState' && this.game) {
|
||||
const moveCountBefore = this.game.state.moves.length;
|
||||
this.game = { ...this.game, state: event.state };
|
||||
if (event.state.moves.length !== moveCountBefore) {
|
||||
this.clearSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'error') {
|
||||
this.errorMessage = event.error.message;
|
||||
}
|
||||
}
|
||||
|
||||
private clearSelection(): void {
|
||||
this.selectedSquare = null;
|
||||
this.selectedSquareMoves = [];
|
||||
this.highlightedSquares = [];
|
||||
}
|
||||
|
||||
private isCurrentTurnPiece(pieceCode: string): boolean {
|
||||
if (!this.state) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isWhitePiece = pieceCode === pieceCode.toUpperCase();
|
||||
return (this.state.turn === 'white' && isWhitePiece) || (this.state.turn === 'black' && !isWhitePiece);
|
||||
}
|
||||
|
||||
private getPieceAtSquare(fen: string, targetSquare: string): string | null {
|
||||
const placement = fen.split(' ')[0] ?? '';
|
||||
const rows = placement.split('/');
|
||||
if (rows.length !== 8 || targetSquare.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const file = targetSquare.charCodeAt(0) - 97;
|
||||
const rank = Number(targetSquare[1]);
|
||||
const rowIndex = 8 - rank;
|
||||
|
||||
if (Number.isNaN(rank) || file < 0 || file > 7 || rowIndex < 0 || rowIndex > 7) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let column = 0;
|
||||
for (const symbol of rows[rowIndex]) {
|
||||
if (symbol >= '1' && symbol <= '8') {
|
||||
column += Number(symbol);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (column === file) {
|
||||
return symbol;
|
||||
}
|
||||
|
||||
column += 1;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import { DestroyRef, Injectable, OnDestroy, inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { interval, startWith, Subscription, switchMap } from 'rxjs';
|
||||
import { getPieceAtSquare, isPieceColor } from '../../core/chess/fen.utils';
|
||||
import { getErrorMessage } from '../../core/http/error-message.util';
|
||||
import { GameFull, GameState, GameStreamEvent, LegalMove } from '../../models/game.models';
|
||||
import { GameApiService } from '../../services/game-api.service';
|
||||
|
||||
@Injectable()
|
||||
export class GameFacade implements OnDestroy {
|
||||
gameId = '';
|
||||
game: GameFull | null = null;
|
||||
errorMessage = '';
|
||||
moveInput = '';
|
||||
fenInput = '';
|
||||
pgnInput = '';
|
||||
loading = true;
|
||||
selectedSquare: string | null = null;
|
||||
highlightedSquares: string[] = [];
|
||||
|
||||
private selectedSquareMoves: LegalMove[] = [];
|
||||
private readonly router = inject(Router);
|
||||
private readonly gameApi = inject(GameApiService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private streamSubscription: Subscription | null = null;
|
||||
private pollSubscription: Subscription | null = null;
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.streamSubscription?.unsubscribe();
|
||||
this.pollSubscription?.unsubscribe();
|
||||
}
|
||||
|
||||
get state(): GameState | null {
|
||||
return this.game?.state ?? null;
|
||||
}
|
||||
|
||||
setGameId(gameId: string): void {
|
||||
this.gameId = gameId;
|
||||
this.loadGame();
|
||||
}
|
||||
|
||||
onBoardSquareSelected(square: string): void {
|
||||
if (!this.state) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selectedSquare && this.highlightedSquares.includes(square)) {
|
||||
const selectedMove = this.selectedSquareMoves.find((move) => move.to === square);
|
||||
if (selectedMove) {
|
||||
this.moveInput = selectedMove.uci;
|
||||
this.submitMove();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const piece = getPieceAtSquare(this.state.fen, square);
|
||||
if (!piece || !isPieceColor(piece, this.state.turn)) {
|
||||
this.clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage = '';
|
||||
this.gameApi
|
||||
.getLegalMoves(this.gameId, square)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (response) => {
|
||||
this.selectedSquare = square;
|
||||
this.selectedSquareMoves = response.moves;
|
||||
this.highlightedSquares = response.moves.map((move) => move.to);
|
||||
},
|
||||
error: () => {
|
||||
this.clearSelection();
|
||||
this.errorMessage = 'Could not load legal moves for selected square.';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
submitMove(): void {
|
||||
const uci = this.moveInput.trim();
|
||||
if (!uci) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage = '';
|
||||
this.gameApi
|
||||
.makeMove(this.gameId, uci)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (state) => {
|
||||
if (this.game) {
|
||||
this.game = { ...this.game, state };
|
||||
}
|
||||
this.moveInput = '';
|
||||
this.clearSelection();
|
||||
},
|
||||
error: (error) => {
|
||||
this.errorMessage = getErrorMessage(error, 'Move rejected.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
importFen(): void {
|
||||
const fen = this.fenInput.trim();
|
||||
if (!fen) {
|
||||
this.errorMessage = 'Please provide a FEN string.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage = '';
|
||||
this.gameApi
|
||||
.importFen(fen)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (game) => {
|
||||
this.fenInput = '';
|
||||
this.pgnInput = '';
|
||||
void this.router.navigate(['/game', game.gameId]);
|
||||
},
|
||||
error: (error) => {
|
||||
this.errorMessage = getErrorMessage(error, 'FEN import failed.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
importPgn(): void {
|
||||
const pgn = this.pgnInput.trim();
|
||||
if (!pgn) {
|
||||
this.errorMessage = 'Please provide a PGN string.';
|
||||
return;
|
||||
}
|
||||
|
||||
this.errorMessage = '';
|
||||
this.gameApi
|
||||
.importPgn(pgn)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (game) => {
|
||||
this.pgnInput = '';
|
||||
this.fenInput = '';
|
||||
void this.router.navigate(['/game', game.gameId]);
|
||||
},
|
||||
error: (error) => {
|
||||
this.errorMessage = getErrorMessage(error, 'PGN import failed.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadGame(): void {
|
||||
this.loading = true;
|
||||
this.errorMessage = '';
|
||||
this.clearSelection();
|
||||
this.streamSubscription?.unsubscribe();
|
||||
this.pollSubscription?.unsubscribe();
|
||||
this.pollSubscription = null;
|
||||
|
||||
this.gameApi
|
||||
.getGame(this.gameId)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (game) => {
|
||||
this.game = game;
|
||||
this.loading = false;
|
||||
this.startStream();
|
||||
},
|
||||
error: (error) => {
|
||||
this.errorMessage = getErrorMessage(error, `Could not load game ${this.gameId}.`);
|
||||
this.loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startStream(): void {
|
||||
this.streamSubscription = this.gameApi
|
||||
.streamGame(this.gameId)
|
||||
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||
.subscribe({
|
||||
next: (event) => this.applyStreamEvent(event),
|
||||
error: () => {
|
||||
this.errorMessage = 'Live stream disconnected. Falling back to polling.';
|
||||
this.startPolling();
|
||||
},
|
||||
complete: () => {
|
||||
this.errorMessage = 'Live stream ended. Falling back to polling.';
|
||||
this.startPolling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
if (this.pollSubscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pollSubscription = interval(1500)
|
||||
.pipe(
|
||||
startWith(0),
|
||||
switchMap(() => this.gameApi.getGame(this.gameId)),
|
||||
takeUntilDestroyed(this.destroyRef)
|
||||
)
|
||||
.subscribe({
|
||||
next: (game) => {
|
||||
const previousMoves = this.game?.state.moves.join(',') ?? '';
|
||||
this.game = game;
|
||||
if (previousMoves !== game.state.moves.join(',')) {
|
||||
this.clearSelection();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private applyStreamEvent(event: GameStreamEvent): void {
|
||||
if (event.type === 'gameFull') {
|
||||
this.game = event.game;
|
||||
this.clearSelection();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'gameState' && this.game) {
|
||||
const moveCountBefore = this.game.state.moves.length;
|
||||
this.game = { ...this.game, state: event.state };
|
||||
if (event.state.moves.length !== moveCountBefore) {
|
||||
this.clearSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'error') {
|
||||
this.errorMessage = event.error.message;
|
||||
}
|
||||
}
|
||||
|
||||
private clearSelection(): void {
|
||||
this.selectedSquare = null;
|
||||
this.selectedSquareMoves = [];
|
||||
this.highlightedSquares = [];
|
||||
}
|
||||
}
|
||||
@@ -67,3 +67,77 @@ p {
|
||||
font-weight: 700;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.welcome-shell {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.welcome-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.mode-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.mode {
|
||||
padding: 0.75rem;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.mode span {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.mode small {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.error {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.welcome-shell {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.welcome-card {
|
||||
padding: 1rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.25rem;
|
||||
margin: 0 0 0.15rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.8rem;
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
.mode-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mode {
|
||||
padding: 0.65rem;
|
||||
}
|
||||
|
||||
.mode span {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { Component } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { finalize } from 'rxjs';
|
||||
import { getErrorMessage } from '../../core/http/error-message.util';
|
||||
import { GameApiService } from '../../services/game-api.service';
|
||||
|
||||
@Component({
|
||||
@@ -35,8 +36,8 @@ export class WelcomeComponent {
|
||||
next: (game) => {
|
||||
void this.router.navigate(['/game', game.gameId]);
|
||||
},
|
||||
error: (error: { error?: { message?: string } }) => {
|
||||
this.errorMessage = error.error?.message ?? 'Unable to create a game.';
|
||||
error: (error) => {
|
||||
this.errorMessage = getErrorMessage(error, 'Unable to create a game.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiBaseUrl: 'http://localhost:8080',
|
||||
wsBaseUrl: 'ws://localhost:8080'
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
apiBaseUrl: 'http://localhost:8080',
|
||||
wsBaseUrl: 'ws://localhost:8080'
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
|
||||
:root {
|
||||
--warm-primary: #BA6D4B;
|
||||
--warm-dark: #5A2C28;
|
||||
|
||||