refactor: remove multiplayer and Socket.IO in favor of single-player AI games
- Remove Socket.IO dependency, server multiplayer logic (lobby, room management), and client-side socket service - Replace multiplayer-only flow with single-player AI gameplay; add `OpponentSelectScene` for AI configuration - Simplify game registry to use `minOpponents`/`maxOpponents` instead of `supportsMultiplayer`/`multiplayerOnly` - Update game registration to remove multiplayer flags and adjust `games` table schema to hardcode `supports_multiplayer = 0` - Remove `SOCKET_IO_CORS_ORIGIN` from environment configuration - Update documentation to reflect single-player architecture and simplified game creation process - Fix minor UI issues: add width to buttons in auth scenes, fix auth listener cleanup in LandingScene, restore DOM layer visibility in Modal
This commit is contained in:
parent
afd8c78253
commit
9c3774fb1f
157
README.md
157
README.md
|
|
@ -2,10 +2,10 @@
|
|||
|
||||
A Phaser 3.90 framework for classic tabletop games (Backgammon, Parchisi, ...)
|
||||
and casino games (Blackjack, Texas Hold 'Em, ...), with accounts, profiles,
|
||||
match history, and multiplayer lobbies.
|
||||
and match history. Games are single-player against AI opponents.
|
||||
|
||||
The frontend uses **native browser ES modules** — no bundler, no build step.
|
||||
The backend is Node.js + Express + Socket.IO with SQLite for persistence.
|
||||
The backend is Node.js + Express with SQLite for persistence.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -19,7 +19,6 @@ The backend is Node.js + Express + Socket.IO with SQLite for persistence.
|
|||
- [Project layout](#project-layout)
|
||||
- [Database schema](#database-schema)
|
||||
- [REST API](#rest-api)
|
||||
- [Socket.IO events](#socketio-events)
|
||||
- [Frontend architecture](#frontend-architecture)
|
||||
- [Adding a new game](#adding-a-new-game)
|
||||
- [Email verification](#email-verification)
|
||||
|
|
@ -36,11 +35,9 @@ The backend is Node.js + Express + Socket.IO with SQLite for persistence.
|
|||
to the console when SMTP is not set up
|
||||
- Session cookies backed by SQLite (httpOnly, SameSite=Lax)
|
||||
- Profile management: display name, bio, avatar upload (PNG / JPEG / WebP)
|
||||
- Match history (wins / losses / draws) ready to be populated by games
|
||||
- Multiplayer lobby system over Socket.IO with rooms, presence, and broadcast
|
||||
- Match history (wins / losses / draws) recorded for single-player games
|
||||
- Pluggable game registry — register tabletop or casino games server-side
|
||||
- Base classes (`TabletopGame`, `CasinoGame`) that handle the turn loop /
|
||||
betting loop scaffolding so new games only implement rules
|
||||
- Single-player games against configurable AI opponents
|
||||
- 1920×1080 canvas that scales to any viewport via `Phaser.Scale.FIT`
|
||||
- Vector-only placeholder graphics — drop sprites in later without
|
||||
refactoring scenes
|
||||
|
|
@ -137,12 +134,6 @@ the template. Fields:
|
|||
| `SMTP_FROM` | *(see example.env)* | `From:` header for outgoing mail. |
|
||||
| `VERIFICATION_TOKEN_TTL_HOURS` | `24` | How long verification links remain valid. |
|
||||
|
||||
### Multiplayer
|
||||
|
||||
| Variable | Default | Description |
|
||||
|-------------------------|---------------------------|------------------------------------------------------------|
|
||||
| `SOCKET_IO_CORS_ORIGIN` | `http://localhost:3000` | CORS origin for the Socket.IO server. Use `*` only in dev. |
|
||||
|
||||
---
|
||||
|
||||
## Running the server
|
||||
|
|
@ -153,8 +144,8 @@ npm start # plain node, production-style
|
|||
npm run migrate # apply any pending DB migrations
|
||||
```
|
||||
|
||||
The server serves both the API (`/api/*`), static frontend (`/`, `/src/...`,
|
||||
`/uploads/...`), and the Socket.IO endpoint (`/socket.io`) on the same port.
|
||||
The server serves both the API (`/api/*`) and the static frontend (`/`,
|
||||
`/src/...`, `/uploads/...`) on the same port.
|
||||
|
||||
After it starts you should see:
|
||||
|
||||
|
|
@ -174,7 +165,7 @@ fertig-classic-games/
|
|||
├── README.md
|
||||
│
|
||||
├── server/ Backend (Node.js, ES modules)
|
||||
│ ├── index.js Express + Socket.IO bootstrap
|
||||
│ ├── index.js Express bootstrap
|
||||
│ ├── config.js Loads & validates .env
|
||||
│ ├── db/
|
||||
│ │ ├── index.js better-sqlite3 connection singleton
|
||||
|
|
@ -192,10 +183,8 @@ fertig-classic-games/
|
|||
│ │ └── routes.js /api/history
|
||||
│ ├── email/
|
||||
│ │ └── mailer.js Nodemailer wrapper with console fallback
|
||||
│ └── multiplayer/
|
||||
│ ├── index.js Socket.IO server + auth handshake
|
||||
│ ├── lobby.js Room manager (create/join/leave)
|
||||
│ └── gameRegistry.js Game definitions
|
||||
│ └── games/
|
||||
│ └── registry.js Game definitions
|
||||
│
|
||||
├── public/ Frontend, served as static files
|
||||
│ ├── index.html Loads Phaser via importmap
|
||||
|
|
@ -206,8 +195,7 @@ fertig-classic-games/
|
|||
│ ├── config.js UI colors, dimensions, API base
|
||||
│ ├── services/
|
||||
│ │ ├── api.js fetch wrapper
|
||||
│ │ ├── auth.js Client-side auth store
|
||||
│ │ └── socket.js socket.io-client
|
||||
│ │ └── auth.js Client-side auth store
|
||||
│ ├── ui/
|
||||
│ │ ├── Button.js
|
||||
│ │ ├── TextInput.js DOM-overlay input that follows canvas scale
|
||||
|
|
@ -221,12 +209,9 @@ fertig-classic-games/
|
|||
│ │ ├── VerifyScene.js
|
||||
│ │ ├── ProfileScene.js
|
||||
│ │ ├── GameMenuScene.js
|
||||
│ │ ├── LobbyScene.js
|
||||
│ │ ├── OpponentSelectScene.js
|
||||
│ │ └── GameRoomScene.js
|
||||
│ └── games/
|
||||
│ ├── BaseGame.js
|
||||
│ ├── tabletop/TabletopGame.js
|
||||
│ └── casino/CasinoGame.js
|
||||
│ └── games/ One subdirectory per game (uno/, blackjack/, ...)
|
||||
│
|
||||
└── data/ SQLite database (gitignored)
|
||||
└── fertig.sqlite
|
||||
|
|
@ -289,65 +274,22 @@ automatically; the client uses `credentials: 'same-origin'` in fetch calls.
|
|||
| Method | Path | Auth | Description |
|
||||
|--------|----------------|------|------------------------------------------------------|
|
||||
| GET | `/api/health` | — | `{ ok: true }`. |
|
||||
| GET | `/api/games` | — | Lists registered games from `gameRegistry.js`. |
|
||||
|
||||
---
|
||||
|
||||
## Socket.IO events
|
||||
|
||||
Clients connect to the same origin as the page. The server reads the session
|
||||
cookie during the handshake and rejects unauthenticated connections.
|
||||
|
||||
### Server → client
|
||||
|
||||
| Event | Payload | When |
|
||||
|-----------------|----------------------------------------------------|---------------------------------------------------|
|
||||
| `hello` | `{ user, games }` | On successful connection. |
|
||||
| `lobby:update` | `Room[]` | After a room is created, joined, or left. |
|
||||
| `room:update` | `Room` | When the room's players list changes. |
|
||||
| `room:message` | `{ from: { id, username }, payload, at }` | Chat / game messages routed within a room. |
|
||||
|
||||
### Client → server
|
||||
|
||||
| Event | Payload | Ack | Notes |
|
||||
|--------------------|-------------------------------|----------------------------------|----------------------------------------|
|
||||
| `lobby:subscribe` | `gameSlug` | — | Joins the lobby for that game slug. |
|
||||
| `lobby:unsubscribe`| `gameSlug` | — | |
|
||||
| `room:create` | `{ gameSlug, name? }` | `{ ok, room? , error? }` | Creates a room; you join automatically.|
|
||||
| `room:join` | `{ roomId }` | `{ ok, room?, error? }` | |
|
||||
| `room:leave` | `{ roomId }` | `{ ok }` | |
|
||||
| `room:message` | `{ roomId, payload }` | — | Broadcast to other room members. |
|
||||
|
||||
`Room` shape:
|
||||
|
||||
```js
|
||||
{
|
||||
id: string,
|
||||
gameSlug: string,
|
||||
name: string,
|
||||
hostId: number,
|
||||
players: [{ id: number, username: string, seat: number }],
|
||||
maxPlayers: number,
|
||||
status: 'waiting' | 'in_progress' | 'completed'
|
||||
}
|
||||
```
|
||||
| GET | `/api/games` | — | Lists registered games from `games/registry.js`. |
|
||||
|
||||
---
|
||||
|
||||
## Frontend architecture
|
||||
|
||||
- **No bundler.** `public/index.html` uses an `<script type="importmap">` to
|
||||
resolve `phaser` and `socket.io-client` to CDN ES modules. The rest of the
|
||||
app uses relative ES imports.
|
||||
resolve `phaser` to a CDN ES module. The rest of the app uses relative ES
|
||||
imports.
|
||||
- **Scenes** live in `public/src/scenes/`. The flow is:
|
||||
`Boot → Preload → Landing → (Login | Register | Verify) → Profile |
|
||||
GameMenu → Lobby → GameRoom`.
|
||||
GameMenu → OpponentSelect → GameRoom`.
|
||||
- **`auth` store** (`services/auth.js`) is a tiny pub-sub the scenes
|
||||
subscribe to so they re-render when the signed-in user changes.
|
||||
- **`api`** (`services/api.js`) is a thin fetch wrapper that throws on
|
||||
non-2xx responses with `err.status` and `err.data` attached.
|
||||
- **`socket`** (`services/socket.js`) is a lazily-connected singleton; scenes
|
||||
call `connectSocket()` when entering a multiplayer flow.
|
||||
- **DOM-overlay inputs.** Phaser doesn't have a native text input, so
|
||||
`ui/TextInput.js` positions a real `<input>` element over the canvas and
|
||||
repositions it on scale-resize. The `#dom-layer` div has
|
||||
|
|
@ -358,49 +300,47 @@ cookie during the handshake and rejects unauthenticated connections.
|
|||
|
||||
## Adding a new game
|
||||
|
||||
1. **Register the game on the server.** In `server/multiplayer/gameRegistry.js`:
|
||||
1. **Register the game on the server.** In `server/games/registry.js`:
|
||||
|
||||
```js
|
||||
registerGame({
|
||||
slug: 'cribbage',
|
||||
name: 'Cribbage',
|
||||
category: 'tabletop', // or 'casino'
|
||||
category: 'tabletop', // or 'casino' or 'cards'
|
||||
minPlayers: 2,
|
||||
maxPlayers: 4,
|
||||
supportsMultiplayer: true,
|
||||
minOpponents: 1,
|
||||
maxOpponents: 3,
|
||||
});
|
||||
```
|
||||
|
||||
The lobby and game menu will pick it up automatically.
|
||||
The game menu picks it up automatically.
|
||||
|
||||
2. **Add a row in the `games` table** if you want to record matches in
|
||||
history. Either insert in a new migration or via a one-off statement.
|
||||
|
||||
3. **Implement the game scene.** Extend `TabletopGame` or `CasinoGame`:
|
||||
2. **Implement the game scene.** Each game is a `Phaser.Scene` that reads its
|
||||
setup from the data passed by `GameRoomScene`:
|
||||
|
||||
```js
|
||||
// public/src/games/tabletop/CribbageGame.js
|
||||
import TabletopGame from './TabletopGame.js';
|
||||
// public/src/games/cribbage/CribbageGame.js
|
||||
import * as Phaser from 'phaser';
|
||||
|
||||
export default class CribbageGame extends TabletopGame {
|
||||
export default class CribbageGame extends Phaser.Scene {
|
||||
constructor() { super('CribbageGame'); }
|
||||
createBoard() { /* render felt, pegs, cards (placeholder shapes ok) */ }
|
||||
applyBoardState(state) { /* reconcile pieces & turn */ }
|
||||
init(data) {
|
||||
this.gameDef = data.game;
|
||||
this.opponents = data.opponents; // selected AI opponents
|
||||
this.playfield = data.playfield;
|
||||
this.cardBack = data.cardBack;
|
||||
}
|
||||
create() { /* render board, run the game + AI locally */ }
|
||||
}
|
||||
```
|
||||
|
||||
4. **Route to it from `GameRoomScene`.** Either switch on `game.slug` and
|
||||
start the right scene, or load the module dynamically:
|
||||
3. **Register the scene and route to it.** Add the scene to the `scene` array
|
||||
in `public/src/main.js`, then add its slug → scene-key mapping to the
|
||||
`slugDispatch` object in `public/src/scenes/GameRoomScene.js`.
|
||||
|
||||
```js
|
||||
const mod = await import(`../games/${category}/${ClassName}.js`);
|
||||
this.scene.add(ClassName, mod.default, true, { game, room, socket, user });
|
||||
```
|
||||
|
||||
5. **Send/receive game state.** Use `sendAction({ type, ... })` from the base
|
||||
class. The server should listen for `game:<slug>:action` and respond with
|
||||
`game:<slug>:state` (public) and `game:<slug>:private` (per-seat hidden
|
||||
info, for casino games).
|
||||
4. **Record results (optional).** When a match finishes, `POST` to
|
||||
`/api/history/single-player` to record the win / loss / draw for history.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -490,10 +430,6 @@ email is sent; check the server console for the dev link. If SMTP *is* set,
|
|||
check provider auth (Gmail requires an app password, not your account
|
||||
password) and `SMTP_PORT` / `SMTP_SECURE` for your provider.
|
||||
|
||||
**Socket connection rejected with `Not authenticated`** — sign in first; the
|
||||
Socket.IO handshake reads the session cookie. Make sure
|
||||
`SOCKET_IO_CORS_ORIGIN` matches the origin you're browsing from.
|
||||
|
||||
**Avatar upload fails with `Unsupported image type`** — only the MIME types
|
||||
in `ALLOWED_UPLOAD_MIME` are accepted. Add more if needed.
|
||||
|
||||
|
|
@ -505,16 +441,9 @@ beat; if it persists, file an issue.
|
|||
|
||||
## Roadmap
|
||||
|
||||
The framework is intentionally game-agnostic. Concrete games come next:
|
||||
- [ ] Smarter AI opponents per game
|
||||
- [ ] Richer match history views and per-game stats
|
||||
- [ ] Replace placeholder vector graphics with sprites
|
||||
|
||||
- [ ] **Backgammon** — 2-player turn-based on `TabletopGame`
|
||||
- [ ] **Parchisi** — up to 4 players on `TabletopGame`
|
||||
- [ ] **Blackjack** — 1–6 players on `CasinoGame`
|
||||
- [ ] **Texas Hold 'Em** — 2–8 players on `CasinoGame`
|
||||
- [ ] Server-authoritative game state, per-seat private state for casino games
|
||||
- [ ] Match recording into `matches` / `match_players` for history
|
||||
- [ ] Friends list + invites
|
||||
- [ ] Spectator mode
|
||||
|
||||
Contributions welcome — start by registering a game in `gameRegistry.js` and
|
||||
extending one of the base classes.
|
||||
Contributions welcome — start by registering a game in `games/registry.js`
|
||||
(see [Adding a new game](#adding-a-new-game)).
|
||||
|
|
|
|||
|
|
@ -37,10 +37,6 @@ SMTP_PASS=
|
|||
SMTP_FROM="Fertig Classic Games <no-reply@fertigclassicgames.local>"
|
||||
VERIFICATION_TOKEN_TTL_HOURS=24
|
||||
|
||||
# ---- Multiplayer (Socket.IO) ----
|
||||
# CORS origin for the socket server. Use "*" only in development.
|
||||
SOCKET_IO_CORS_ORIGIN=http://localhost:3000
|
||||
|
||||
# ---- Logging ----
|
||||
# One of: error, warn, info, debug
|
||||
LOG_LEVEL=info
|
||||
|
|
|
|||
|
|
@ -10,13 +10,11 @@
|
|||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"cookie": "^0.6.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"multer": "^2.0.0",
|
||||
"nodemailer": "^6.9.14",
|
||||
"socket.io": "^4.7.5"
|
||||
"nodemailer": "^6.9.14"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
|
@ -41,35 +39,6 @@
|
|||
"node-pre-gyp": "bin/node-pre-gyp"
|
||||
}
|
||||
},
|
||||
"node_modules/@socket.io/component-emitter": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="
|
||||
},
|
||||
"node_modules/@types/cors": {
|
||||
"version": "2.8.19",
|
||||
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
|
||||
"integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
|
||||
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.18.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
|
||||
|
|
@ -179,14 +148,6 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
"node_modules/base64id": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz",
|
||||
"integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==",
|
||||
"engines": {
|
||||
"node": "^4.5.0 || >= 5.9"
|
||||
}
|
||||
},
|
||||
"node_modules/bcrypt": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
|
||||
|
|
@ -393,14 +354,6 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
"version": "1.4.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
|
||||
|
|
@ -426,22 +379,6 @@
|
|||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.6",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
|
||||
"integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
|
|
@ -552,63 +489,6 @@
|
|||
"once": "^1.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io": {
|
||||
"version": "6.6.7",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.7.tgz",
|
||||
"integrity": "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==",
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.12",
|
||||
"@types/node": ">=10.0.0",
|
||||
"@types/ws": "^8.5.12",
|
||||
"accepts": "~1.3.4",
|
||||
"base64id": "2.0.0",
|
||||
"cookie": "~0.7.2",
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io-parser": "~5.2.1",
|
||||
"ws": "~8.18.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io-parser": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
|
|
@ -1666,107 +1546,6 @@
|
|||
"simple-concat": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz",
|
||||
"integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.4",
|
||||
"base64id": "~2.0.0",
|
||||
"cors": "~2.8.5",
|
||||
"debug": "~4.4.1",
|
||||
"engine.io": "~6.6.0",
|
||||
"socket.io-adapter": "~2.5.2",
|
||||
"socket.io-parser": "~4.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz",
|
||||
"integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==",
|
||||
"dependencies": {
|
||||
"debug": "~4.4.1",
|
||||
"ws": "~8.18.3"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-adapter/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/socket.io-parser": {
|
||||
"version": "4.2.6",
|
||||
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
|
||||
"integrity": "sha512-asJqbVBDsBCJx0pTqw3WfesSY0iRX+2xzWEWzrpcH7L6fLzrhyF8WPI8UaeM4YCuDfpwA/cgsdugMsmtz8EJeg==",
|
||||
"dependencies": {
|
||||
"@socket.io/component-emitter": "~3.1.0",
|
||||
"debug": "~4.4.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io-parser/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/socket.io/node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||
"dependencies": {
|
||||
"ms": "^2.1.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"supports-color": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/socket.io/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
|
|
@ -1928,11 +1707,6 @@
|
|||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA=="
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
|
|
@ -1989,26 +1763,6 @@
|
|||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.18.3",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -15,12 +15,10 @@
|
|||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"better-sqlite3": "^11.3.0",
|
||||
"cookie": "^0.6.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"multer": "^2.0.0",
|
||||
"nodemailer": "^6.9.14",
|
||||
"socket.io": "^4.7.5"
|
||||
"nodemailer": "^6.9.14"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,8 +13,7 @@
|
|||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"phaser": "https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.esm.js",
|
||||
"socket.io-client": "https://cdn.jsdelivr.net/npm/socket.io-client@4.7.5/+esm"
|
||||
"phaser": "https://cdn.jsdelivr.net/npm/phaser@3.90.0/dist/phaser.esm.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
import * as Phaser from 'phaser';
|
||||
|
||||
// Common lifecycle and helpers for any game. Concrete games extend
|
||||
// TabletopGame or CasinoGame, which extend this.
|
||||
//
|
||||
// Subclasses should implement:
|
||||
// - createBoard() set up persistent visuals
|
||||
// - onState(state) apply an authoritative state update from the server
|
||||
// - onInput(action) optional, for client-side input handling
|
||||
export default class BaseGame extends Phaser.Scene {
|
||||
constructor(key) {
|
||||
super(key);
|
||||
this.gameDef = null;
|
||||
this.room = null;
|
||||
this.socket = null;
|
||||
this.localUser = null;
|
||||
}
|
||||
|
||||
init(data = {}) {
|
||||
this.gameDef = data.game;
|
||||
this.room = data.room ?? null;
|
||||
this.socket = data.socket ?? null;
|
||||
this.localUser = data.user ?? null;
|
||||
}
|
||||
|
||||
create() {
|
||||
this.createBoard?.();
|
||||
this.bindNetwork();
|
||||
}
|
||||
|
||||
bindNetwork() {
|
||||
if (!this.socket) return;
|
||||
this.socket.on(`game:${this.gameDef.slug}:state`, this.handleState);
|
||||
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
|
||||
this.socket.off(`game:${this.gameDef.slug}:state`, this.handleState);
|
||||
});
|
||||
}
|
||||
|
||||
handleState = (state) => {
|
||||
this.onState?.(state);
|
||||
};
|
||||
|
||||
sendAction(action) {
|
||||
if (!this.socket || !this.room) return;
|
||||
this.socket.emit(`game:${this.gameDef.slug}:action`, {
|
||||
roomId: this.room.id,
|
||||
action,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import BaseGame from '../BaseGame.js';
|
||||
|
||||
// Base for casino games (Blackjack, Texas Hold 'Em, ...).
|
||||
//
|
||||
// Casino games differ from tabletop games in two important ways:
|
||||
// - Hidden information per seat (your hand vs. opponents').
|
||||
// - A betting/round structure separate from board moves.
|
||||
//
|
||||
// Subclasses typically need:
|
||||
// - createTable() layout: felt, seats, chip stacks, dealer area
|
||||
// - onState(state) reconcile public state (community cards, pot, ...)
|
||||
// - onPrivateState(state) reconcile your private hand
|
||||
// - bet(amount) sendAction({ type: 'bet', amount })
|
||||
// - fold/check/call/raise sendAction with matching type
|
||||
export default class CasinoGame extends BaseGame {
|
||||
constructor(key) {
|
||||
super(key);
|
||||
this.pot = 0;
|
||||
this.round = null;
|
||||
this.privateState = null;
|
||||
}
|
||||
|
||||
bindNetwork() {
|
||||
super.bindNetwork();
|
||||
if (!this.socket) return;
|
||||
const slug = this.gameDef.slug;
|
||||
this.socket.on(`game:${slug}:private`, this.handlePrivate);
|
||||
this.events.once('shutdown', () => {
|
||||
this.socket.off(`game:${slug}:private`, this.handlePrivate);
|
||||
});
|
||||
}
|
||||
|
||||
handlePrivate = (state) => {
|
||||
this.privateState = state;
|
||||
this.onPrivateState?.(state);
|
||||
};
|
||||
|
||||
bet(amount) { this.sendAction({ type: 'bet', amount }); }
|
||||
fold() { this.sendAction({ type: 'fold' }); }
|
||||
check() { this.sendAction({ type: 'check' }); }
|
||||
call() { this.sendAction({ type: 'call' }); }
|
||||
raise(amt) { this.sendAction({ type: 'raise', amount: amt }); }
|
||||
}
|
||||
|
|
@ -382,7 +382,7 @@ export default class HoldemGame extends Phaser.Scene {
|
|||
const modalItems = [overlay, panel, title, balanceTxt, buyInTxt];
|
||||
|
||||
if (this.globalChips <= 0) {
|
||||
this.add.text(CX, CY + 40, 'You have no chips! Return to the lobby.', {
|
||||
this.add.text(CX, CY + 40, 'You have no chips! Return to the menu.', {
|
||||
fontFamily: '"Julius Sans One"', fontSize: '20px', color: COLORS.dangerHex,
|
||||
}).setOrigin(0.5).setDepth(D.modal);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -1,34 +0,0 @@
|
|||
import BaseGame from '../BaseGame.js';
|
||||
|
||||
// Base for turn-based tabletop games (Backgammon, Parchisi, Chess, ...).
|
||||
//
|
||||
// Subclasses typically need:
|
||||
// - createBoard() lay out the board, pieces, dice (placeholder shapes ok)
|
||||
// - onState(state) reconcile pieces, current turn, dice rolls
|
||||
// - requestMove(move) call sendAction({ type: 'move', ...move })
|
||||
//
|
||||
// The framework here provides the turn loop scaffolding: who's on move, a
|
||||
// pending-move queue, and a helper to lock input when it's not your turn.
|
||||
export default class TabletopGame extends BaseGame {
|
||||
constructor(key) {
|
||||
super(key);
|
||||
this.currentTurnUserId = null;
|
||||
this.pendingMove = null;
|
||||
}
|
||||
|
||||
isMyTurn() {
|
||||
return this.localUser && this.currentTurnUserId === this.localUser.id;
|
||||
}
|
||||
|
||||
onState(state) {
|
||||
this.currentTurnUserId = state.currentTurnUserId ?? null;
|
||||
this.applyBoardState?.(state);
|
||||
}
|
||||
|
||||
requestMove(move) {
|
||||
if (!this.isMyTurn()) return false;
|
||||
this.pendingMove = move;
|
||||
this.sendAction({ type: 'move', move });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@ import VerifyScene from './scenes/VerifyScene.js';
|
|||
import ProfileScene from './scenes/ProfileScene.js';
|
||||
import GameMenuScene from './scenes/GameMenuScene.js';
|
||||
import OpponentSelectScene from './scenes/OpponentSelectScene.js';
|
||||
import LobbyScene from './scenes/LobbyScene.js';
|
||||
import GameRoomScene from './scenes/GameRoomScene.js';
|
||||
import BackgammonGame from './games/backgammon/BackgammonGame.js';
|
||||
import HoldemGame from './games/holdem/HoldemGame.js';
|
||||
|
|
@ -43,7 +42,6 @@ const config = {
|
|||
ProfileScene,
|
||||
GameMenuScene,
|
||||
OpponentSelectScene,
|
||||
LobbyScene,
|
||||
GameRoomScene,
|
||||
BackgammonGame,
|
||||
HoldemGame,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { api } from '../services/api.js';
|
||||
import { auth } from '../services/auth.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { playMenuMusic } from '../ui/MenuMusic.js';
|
||||
|
||||
|
|
@ -64,11 +63,6 @@ export default class GameMenuScene extends Phaser.Scene {
|
|||
}
|
||||
|
||||
openGame(game) {
|
||||
if (game.multiplayerOnly) {
|
||||
if (!auth.user) { this.scene.start('Login'); return; }
|
||||
this.scene.start('Lobby', { game });
|
||||
} else {
|
||||
this.scene.start('OpponentSelect', { game });
|
||||
}
|
||||
this.scene.start('OpponentSelect', { game });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,14 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { getSocket } from '../services/socket.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
|
||||
// Generic room shell. A concrete game (Backgammon, Blackjack, etc.) will
|
||||
// later be instantiated here based on `data.game.slug`, taking over the
|
||||
// rendering and input handling.
|
||||
// Generic room shell. Dispatches to the concrete game scene
|
||||
// (Backgammon, Blackjack, etc.) based on `data.game.slug`.
|
||||
export default class GameRoomScene extends Phaser.Scene {
|
||||
constructor() { super('GameRoom'); }
|
||||
|
||||
init(data) {
|
||||
this.game = data.game;
|
||||
this.room = data.room ?? null;
|
||||
this.opponents = data.opponents ?? [];
|
||||
this.playfield = data.playfield ?? null;
|
||||
this.cardBack = data.cardBack ?? null;
|
||||
|
|
@ -36,9 +33,7 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.add.text(cx, 150, this.room
|
||||
? `Table: ${this.room.name}`
|
||||
: 'Single-player table',
|
||||
this.add.text(cx, 150, 'Single-player table',
|
||||
{ fontSize: '24px', color: COLORS.mutedHex }).setOrigin(0.5);
|
||||
|
||||
// Placeholder table felt
|
||||
|
|
@ -50,34 +45,6 @@ export default class GameRoomScene extends Phaser.Scene {
|
|||
align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.seatsText = this.add.text(cx, GAME_HEIGHT - 220, this.formatSeats(this.room), {
|
||||
fontSize: '22px', color: COLORS.textHex, align: 'center',
|
||||
}).setOrigin(0.5);
|
||||
|
||||
new Button(this, cx, GAME_HEIGHT - 100, 'Leave table', () => this.leave());
|
||||
|
||||
if (this.room) {
|
||||
const socket = getSocket();
|
||||
socket.on('room:update', this.onRoomUpdate = (room) => {
|
||||
if (room.id !== this.room.id) return;
|
||||
this.room = room;
|
||||
this.seatsText.setText(this.formatSeats(room));
|
||||
});
|
||||
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
|
||||
socket.off('room:update', this.onRoomUpdate);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
formatSeats(room) {
|
||||
if (!room) return 'Solo session';
|
||||
const seats = room.players.map((p) => `Seat ${p.seat + 1}: ${p.username}`).join(' · ');
|
||||
return `${room.players.length}/${room.maxPlayers} players\n${seats}`;
|
||||
}
|
||||
|
||||
leave() {
|
||||
const socket = getSocket();
|
||||
if (this.room) socket.emit('room:leave', { roomId: this.room.id }, () => {});
|
||||
this.scene.start('GameMenu');
|
||||
new Button(this, cx, GAME_HEIGHT - 100, 'Leave table', () => this.scene.start('GameMenu'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,11 +37,15 @@ export default class LandingScene extends Phaser.Scene {
|
|||
|
||||
this.renderButtons();
|
||||
|
||||
auth.subscribe(() => {
|
||||
if (this._authUnsub) this._authUnsub();
|
||||
this._authUnsub = auth.subscribe(() => {
|
||||
if (!this.scene.isActive('Landing')) return;
|
||||
this.children.removeAll();
|
||||
this.create();
|
||||
});
|
||||
this.events.once(Phaser.Scenes.Events.SHUTDOWN, () => {
|
||||
if (this._authUnsub) { this._authUnsub(); this._authUnsub = null; }
|
||||
});
|
||||
}
|
||||
|
||||
renderButtons() {
|
||||
|
|
@ -100,15 +104,15 @@ export default class LandingScene extends Phaser.Scene {
|
|||
}).setOrigin(0.5);
|
||||
}
|
||||
|
||||
new Button(this, cx, 810, 'Play', () => this.scene.start('GameMenu'));
|
||||
new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'));
|
||||
new Button(this, cx, 810, 'Play', () => this.scene.start('GameMenu'), { width: 480 });
|
||||
new Button(this, cx, 890, 'Profile', () => this.scene.start('Profile'), { width: 480 });
|
||||
new Button(this, cx, 970, 'Sign out', async () => {
|
||||
await auth.logout();
|
||||
}, { variant: 'ghost' });
|
||||
}, { variant: 'ghost', width: 480 });
|
||||
} else {
|
||||
new Button(this, cx, 690, 'Sign in', () => this.scene.start('Login'));
|
||||
new Button(this, cx, 770, 'Create account', () => this.scene.start('Register'));
|
||||
new Button(this, cx, 850, 'Continue as guest', () => this.scene.start('GameMenu'), { variant: 'ghost' });
|
||||
new Button(this, cx, 690, 'Sign in', () => this.scene.start('Login'), { width: 480 });
|
||||
new Button(this, cx, 770, 'Create account', () => this.scene.start('Register'), { width: 480 });
|
||||
new Button(this, cx, 850, 'Continue as guest', () => this.scene.start('GameMenu'), { variant: 'ghost', width: 480 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,87 +0,0 @@
|
|||
import * as Phaser from 'phaser';
|
||||
import { GAME_HEIGHT, GAME_WIDTH, COLORS } from '../config.js';
|
||||
import { connectSocket, getSocket } from '../services/socket.js';
|
||||
import { Button } from '../ui/Button.js';
|
||||
import { Modal } from '../ui/Modal.js';
|
||||
|
||||
export default class LobbyScene extends Phaser.Scene {
|
||||
constructor() { super('Lobby'); }
|
||||
|
||||
init(data) { this.game = data.game; }
|
||||
|
||||
create() {
|
||||
const cx = GAME_WIDTH / 2;
|
||||
|
||||
this.add.text(cx, 100, `${this.game.name} — Lobby`, {
|
||||
fontFamily: 'Righteous',
|
||||
fontSize: '52px',
|
||||
color: COLORS.textHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.listText = this.add.text(cx, 220, 'Connecting…', {
|
||||
fontFamily: '"Julius Sans One"',
|
||||
fontSize: '24px',
|
||||
color: COLORS.mutedHex,
|
||||
}).setOrigin(0.5);
|
||||
|
||||
this.roomContainer = this.add.container(0, 280);
|
||||
|
||||
new Button(this, cx - 220, GAME_HEIGHT - 120, 'Create table', () => this.createRoom());
|
||||
new Button(this, cx + 220, GAME_HEIGHT - 120, 'Back', () => this.exitLobby(), { variant: 'ghost' });
|
||||
|
||||
const socket = connectSocket();
|
||||
socket.on('connect_error', (err) => this.listText.setText(`Socket error: ${err.message}`));
|
||||
socket.on('connect', () => {
|
||||
this.listText.setText('No tables yet. Create one to start.');
|
||||
socket.emit('lobby:subscribe', this.game.slug);
|
||||
});
|
||||
socket.on('lobby:update', (rooms) => this.renderRooms(rooms));
|
||||
}
|
||||
|
||||
exitLobby() {
|
||||
const socket = getSocket();
|
||||
socket.emit('lobby:unsubscribe', this.game.slug);
|
||||
this.scene.start('GameMenu');
|
||||
}
|
||||
|
||||
renderRooms(rooms) {
|
||||
this.roomContainer.removeAll(true);
|
||||
if (!rooms.length) {
|
||||
this.listText.setText('No tables yet. Create one to start.');
|
||||
return;
|
||||
}
|
||||
this.listText.setText(`${rooms.length} table(s) open`);
|
||||
const cx = GAME_WIDTH / 2;
|
||||
rooms.forEach((room, i) => {
|
||||
const y = i * 90;
|
||||
const bg = this.add.rectangle(cx, y, 1200, 70, COLORS.panel).setStrokeStyle(1, COLORS.accent);
|
||||
const label = this.add.text(cx - 580, y, `${room.name} · ${room.players.length}/${room.maxPlayers}`, {
|
||||
fontSize: '24px', color: COLORS.textHex,
|
||||
}).setOrigin(0, 0.5);
|
||||
const joinBtn = new Button(this, cx + 500, y, 'Join', () => this.joinRoom(room.id), { width: 140, height: 52, fontSize: 22 });
|
||||
this.roomContainer.add([bg, label, joinBtn]);
|
||||
});
|
||||
}
|
||||
|
||||
createRoom() {
|
||||
const socket = getSocket();
|
||||
socket.emit('room:create', { gameSlug: this.game.slug }, (resp) => {
|
||||
if (!resp?.ok) {
|
||||
new Modal(this, resp?.error ?? 'Could not create table.', { color: COLORS.dangerHex, autoCloseMs: 2400 });
|
||||
return;
|
||||
}
|
||||
this.scene.start('GameRoom', { game: this.game, room: resp.room });
|
||||
});
|
||||
}
|
||||
|
||||
joinRoom(roomId) {
|
||||
const socket = getSocket();
|
||||
socket.emit('room:join', { roomId }, (resp) => {
|
||||
if (!resp?.ok) {
|
||||
new Modal(this, resp?.error ?? 'Could not join.', { color: COLORS.dangerHex, autoCloseMs: 2400 });
|
||||
return;
|
||||
}
|
||||
this.scene.start('GameRoom', { game: this.game, room: resp.room });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -23,9 +23,9 @@ export default class LoginScene extends Phaser.Scene {
|
|||
this.add.text(cx - 180, 460, 'Password', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
|
||||
const passwordInput = new TextInput(this, cx, 500, { width: 480, type: 'password', autocomplete: 'current-password' });
|
||||
|
||||
const submit = new Button(this, cx, 620, 'Sign in', () => this.attemptLogin(identifierInput, passwordInput, submit));
|
||||
new Button(this, cx, 700, 'Need an account?', () => this.scene.start('Register'), { variant: 'ghost' });
|
||||
new Button(this, cx, 780, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
|
||||
const submit = new Button(this, cx, 620, 'Sign in', () => this.attemptLogin(identifierInput, passwordInput, submit), { width: 480 });
|
||||
new Button(this, cx, 700, 'Need an account?', () => this.scene.start('Register'), { variant: 'ghost', width: 480 });
|
||||
new Button(this, cx, 780, 'Back', () => this.scene.start('Landing'), { variant: 'ghost', width: 480 });
|
||||
|
||||
this.input.keyboard.on('keydown-ENTER', () => this.attemptLogin(identifierInput, passwordInput, submit));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@ export default class RegisterScene extends Phaser.Scene {
|
|||
this.add.text(cx - 180, 540, 'Password (min 8 chars)', { fontSize: '22px', color: COLORS.mutedHex }).setOrigin(0, 0.5);
|
||||
const passwordInput = new TextInput(this, cx, 580, { width: 480, type: 'password', autocomplete: 'new-password' });
|
||||
|
||||
const submit = new Button(this, cx, 700, 'Create account', () => this.attempt(emailInput, usernameInput, passwordInput, submit));
|
||||
new Button(this, cx, 780, 'Already have an account?', () => this.scene.start('Login'), { variant: 'ghost' });
|
||||
new Button(this, cx, 860, 'Back', () => this.scene.start('Landing'), { variant: 'ghost' });
|
||||
const submit = new Button(this, cx, 700, 'Create account', () => this.attempt(emailInput, usernameInput, passwordInput, submit), { width: 480 });
|
||||
new Button(this, cx, 780, 'Already have an account?', () => this.scene.start('Login'), { variant: 'ghost', width: 480 });
|
||||
new Button(this, cx, 860, 'Back', () => this.scene.start('Landing'), { variant: 'ghost', width: 480 });
|
||||
|
||||
this.input.keyboard.on('keydown-ENTER', () => this.attempt(emailInput, usernameInput, passwordInput, submit));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ export default class VerifyScene extends Phaser.Scene {
|
|||
new Button(this, cx, 640, 'I have verified — refresh', async () => {
|
||||
await auth.refresh();
|
||||
this.scene.start('Landing');
|
||||
});
|
||||
new Button(this, cx, 720, 'Continue without verifying', () => this.scene.start('Landing'), { variant: 'ghost' });
|
||||
}, { width: 480 });
|
||||
new Button(this, cx, 720, 'Continue without verifying', () => this.scene.start('Landing'), { variant: 'ghost', width: 480 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ class AuthStore {
|
|||
}
|
||||
|
||||
emit() {
|
||||
for (const fn of this.listeners) fn(this.user);
|
||||
for (const fn of [...this.listeners]) fn(this.user);
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
import { io } from 'socket.io-client';
|
||||
|
||||
let socket = null;
|
||||
|
||||
export function getSocket() {
|
||||
if (!socket) {
|
||||
socket = io({ withCredentials: true, autoConnect: false });
|
||||
}
|
||||
return socket;
|
||||
}
|
||||
|
||||
export function connectSocket() {
|
||||
const s = getSocket();
|
||||
if (!s.connected) s.connect();
|
||||
return s;
|
||||
}
|
||||
|
||||
export function disconnectSocket() {
|
||||
if (socket?.connected) socket.disconnect();
|
||||
}
|
||||
|
|
@ -20,10 +20,15 @@ export class Modal extends Phaser.GameObjects.Container {
|
|||
this.add([overlay, panel, text]);
|
||||
scene.add.existing(this);
|
||||
|
||||
const domLayer = document.getElementById('dom-layer');
|
||||
if (domLayer) domLayer.style.visibility = 'hidden';
|
||||
const restore = () => { if (domLayer) domLayer.style.visibility = ''; };
|
||||
|
||||
if (options.autoCloseMs) {
|
||||
scene.time.delayedCall(options.autoCloseMs, () => this.destroy());
|
||||
scene.time.delayedCall(options.autoCloseMs, () => { restore(); this.destroy(); });
|
||||
} else {
|
||||
overlay.on('pointerdown', () => this.destroy());
|
||||
overlay.on('pointerdown', () => { restore(); this.destroy(); });
|
||||
}
|
||||
this.once('destroy', restore);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,10 +58,6 @@ const config = {
|
|||
verificationTtlHours: int(process.env.VERIFICATION_TOKEN_TTL_HOURS, 24),
|
||||
},
|
||||
|
||||
socket: {
|
||||
corsOrigin: process.env.SOCKET_IO_CORS_ORIGIN ?? 'http://localhost:3000',
|
||||
},
|
||||
|
||||
publicDir: path.join(ROOT, 'public'),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -24,12 +24,17 @@ export async function sendVerificationEmail(to, link) {
|
|||
return { delivered: false, devLogged: true };
|
||||
}
|
||||
|
||||
await transporter.sendMail({
|
||||
from: config.email.from,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
return { delivered: true, devLogged: false };
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: config.email.from,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
});
|
||||
return { delivered: true, devLogged: false };
|
||||
} catch (err) {
|
||||
console.error(`[mailer] Failed to send verification email to ${to}:`, err);
|
||||
return { delivered: false, devLogged: false, error: err.message };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,8 +11,6 @@ export function registerGame(definition) {
|
|||
maxPlayers: definition.maxPlayers ?? 2,
|
||||
minOpponents: definition.minOpponents ?? 1,
|
||||
maxOpponents: definition.maxOpponents ?? 1,
|
||||
supportsMultiplayer: definition.supportsMultiplayer ?? true,
|
||||
multiplayerOnly: definition.multiplayerOnly ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -24,14 +22,14 @@ export function getGame(slug) {
|
|||
return registry.get(slug) ?? null;
|
||||
}
|
||||
|
||||
// Built-in placeholders so the menu has something to show.
|
||||
registerGame({ slug: 'backgammon', name: 'Backgammon', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1, multiplayerOnly: false });
|
||||
registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });
|
||||
registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4, multiplayerOnly: false });
|
||||
registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 8, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });
|
||||
registerGame({ slug: 'yatzi', name: 'Yatzi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
|
||||
registerGame({ slug: 'skipbo', name: 'Skip-Bo', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
|
||||
registerGame({ slug: 'phase10', name: 'Phase 10', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3, multiplayerOnly: false });
|
||||
registerGame({ slug: 'chinesecheckers', name: 'Chinese Checkers', category: 'tabletop', minPlayers: 6, maxPlayers: 6, minOpponents: 5, maxOpponents: 5, multiplayerOnly: false });
|
||||
registerGame({ slug: 'gofish', name: 'Go Fish', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });
|
||||
registerGame({ slug: 'uno', name: 'Uno', category: 'cards', cardGame: false, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3, multiplayerOnly: false });
|
||||
// Built-in catalog so the menu has something to show.
|
||||
registerGame({ slug: 'backgammon', name: 'Backgammon', category: 'tabletop', minPlayers: 2, maxPlayers: 2, minOpponents: 1, maxOpponents: 1 });
|
||||
registerGame({ slug: 'parchisi', name: 'Parchisi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });
|
||||
registerGame({ slug: 'blackjack', name: 'Blackjack', category: 'casino', cardGame: true, minPlayers: 1, maxPlayers: 5, minOpponents: 0, maxOpponents: 4 });
|
||||
registerGame({ slug: 'holdem', name: "Texas Hold 'Em", category: 'casino', cardGame: true, minPlayers: 2, maxPlayers: 8, minOpponents: 3, maxOpponents: 3 });
|
||||
registerGame({ slug: 'yatzi', name: 'Yatzi', category: 'tabletop', minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
|
||||
registerGame({ slug: 'skipbo', name: 'Skip-Bo', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
|
||||
registerGame({ slug: 'phase10', name: 'Phase 10', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 1, maxOpponents: 3 });
|
||||
registerGame({ slug: 'chinesecheckers', name: 'Chinese Checkers', category: 'tabletop', minPlayers: 6, maxPlayers: 6, minOpponents: 5, maxOpponents: 5 });
|
||||
registerGame({ slug: 'gofish', name: 'Go Fish', category: 'cards', cardGame: true, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });
|
||||
registerGame({ slug: 'uno', name: 'Uno', category: 'cards', cardGame: false, minPlayers: 1, maxPlayers: 4, minOpponents: 3, maxOpponents: 3 });
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { Router } from 'express';
|
||||
import db from '../db/index.js';
|
||||
import { requireAuth } from '../auth/middleware.js';
|
||||
import { getGame } from '../multiplayer/gameRegistry.js';
|
||||
import { getGame } from '../games/registry.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
|
|
@ -34,9 +34,9 @@ router.post('/single-player', requireAuth, (req, res) => {
|
|||
const tx = db.transaction(() => {
|
||||
db.prepare(
|
||||
`INSERT INTO games (slug, name, category, max_players, supports_multiplayer)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, 0)
|
||||
ON CONFLICT(slug) DO NOTHING`,
|
||||
).run(def.slug, def.name, def.category, def.maxPlayers, def.supportsMultiplayer ? 1 : 0);
|
||||
).run(def.slug, def.name, def.category, def.maxPlayers);
|
||||
|
||||
const gameRow = db.prepare('SELECT id FROM games WHERE slug = ?').get(def.slug);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
import express from 'express';
|
||||
import cookieParser from 'cookie-parser';
|
||||
|
|
@ -9,8 +8,7 @@ import authRoutes from './auth/routes.js';
|
|||
import profileRoutes from './profile/routes.js';
|
||||
import historyRoutes from './history/routes.js';
|
||||
import historyRecordRoutes from './history/recordRoutes.js';
|
||||
import { listGames } from './multiplayer/gameRegistry.js';
|
||||
import { attachMultiplayer } from './multiplayer/index.js';
|
||||
import { listGames } from './games/registry.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
|
|
@ -37,9 +35,6 @@ app.use((err, _req, res, _next) => {
|
|||
res.status(500).json({ error: 'Internal server error.' });
|
||||
});
|
||||
|
||||
const server = http.createServer(app);
|
||||
attachMultiplayer(server);
|
||||
|
||||
server.listen(config.port, config.host, () => {
|
||||
app.listen(config.port, config.host, () => {
|
||||
console.log(`[server] listening on http://${config.host}:${config.port}`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,101 +0,0 @@
|
|||
import { Server as SocketIOServer } from 'socket.io';
|
||||
import cookie from 'cookie';
|
||||
import config from '../config.js';
|
||||
import { findSessionUser } from '../auth/service.js';
|
||||
import { lobby } from './lobby.js';
|
||||
import { getGame, listGames } from './gameRegistry.js';
|
||||
|
||||
function broadcastRoom(io, room) {
|
||||
io.to(`room:${room.id}`).emit('room:update', room);
|
||||
}
|
||||
|
||||
function broadcastLobby(io, gameSlug) {
|
||||
io.to(`lobby:${gameSlug}`).emit('lobby:update', lobby.list(gameSlug));
|
||||
}
|
||||
|
||||
export function attachMultiplayer(httpServer) {
|
||||
const io = new SocketIOServer(httpServer, {
|
||||
cors: { origin: config.socket.corsOrigin, credentials: true },
|
||||
});
|
||||
|
||||
io.use((socket, next) => {
|
||||
const raw = socket.handshake.headers.cookie ?? '';
|
||||
const cookies = cookie.parse(raw || '');
|
||||
const sid = cookies[config.auth.cookieName];
|
||||
const user = findSessionUser(sid);
|
||||
if (!user) return next(new Error('Not authenticated.'));
|
||||
socket.data.user = user;
|
||||
next();
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
const { user } = socket.data;
|
||||
|
||||
socket.emit('hello', { user, games: listGames() });
|
||||
|
||||
socket.on('lobby:subscribe', (gameSlug) => {
|
||||
if (!getGame(gameSlug)) return;
|
||||
socket.join(`lobby:${gameSlug}`);
|
||||
socket.emit('lobby:update', lobby.list(gameSlug));
|
||||
});
|
||||
|
||||
socket.on('lobby:unsubscribe', (gameSlug) => {
|
||||
socket.leave(`lobby:${gameSlug}`);
|
||||
});
|
||||
|
||||
socket.on('room:create', ({ gameSlug, name }, ack) => {
|
||||
try {
|
||||
const room = lobby.create({ gameSlug, hostUser: user, name });
|
||||
socket.join(`room:${room.id}`);
|
||||
broadcastLobby(io, gameSlug);
|
||||
ack?.({ ok: true, room });
|
||||
} catch (err) {
|
||||
ack?.({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('room:join', ({ roomId }, ack) => {
|
||||
try {
|
||||
const room = lobby.join(roomId, user);
|
||||
socket.join(`room:${room.id}`);
|
||||
broadcastRoom(io, room);
|
||||
broadcastLobby(io, room.gameSlug);
|
||||
ack?.({ ok: true, room });
|
||||
} catch (err) {
|
||||
ack?.({ ok: false, error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('room:leave', ({ roomId }, ack) => {
|
||||
const before = lobby.get(roomId);
|
||||
const room = lobby.leave(roomId, user.id);
|
||||
socket.leave(`room:${roomId}`);
|
||||
if (room) broadcastRoom(io, room);
|
||||
if (before) broadcastLobby(io, before.gameSlug);
|
||||
ack?.({ ok: true });
|
||||
});
|
||||
|
||||
socket.on('room:message', ({ roomId, payload }) => {
|
||||
const room = lobby.get(roomId);
|
||||
if (!room || !room.players.some((p) => p.userId === user.id)) return;
|
||||
io.to(`room:${roomId}`).emit('room:message', {
|
||||
from: { id: user.id, username: user.username },
|
||||
payload,
|
||||
at: Date.now(),
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
// Drop the user from any rooms they were in.
|
||||
for (const room of [...lobby.rooms.values()]) {
|
||||
if (room.players.some((p) => p.userId === user.id)) {
|
||||
const updated = lobby.leave(room.id, user.id);
|
||||
if (updated) broadcastRoom(io, updated);
|
||||
broadcastLobby(io, room.gameSlug);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return io;
|
||||
}
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
import crypto from 'node:crypto';
|
||||
import { getGame } from './gameRegistry.js';
|
||||
|
||||
export class LobbyManager {
|
||||
constructor() {
|
||||
this.rooms = new Map();
|
||||
}
|
||||
|
||||
list(gameSlug) {
|
||||
return [...this.rooms.values()]
|
||||
.filter((r) => !gameSlug || r.gameSlug === gameSlug)
|
||||
.map((r) => this.summary(r));
|
||||
}
|
||||
|
||||
summary(room) {
|
||||
return {
|
||||
id: room.id,
|
||||
gameSlug: room.gameSlug,
|
||||
name: room.name,
|
||||
hostId: room.hostId,
|
||||
players: room.players.map((p) => ({ id: p.userId, username: p.username, seat: p.seat })),
|
||||
maxPlayers: room.maxPlayers,
|
||||
status: room.status,
|
||||
};
|
||||
}
|
||||
|
||||
create({ gameSlug, hostUser, name }) {
|
||||
const game = getGame(gameSlug);
|
||||
if (!game) throw new Error(`Unknown game: ${gameSlug}`);
|
||||
if (!game.supportsMultiplayer) throw new Error(`${game.name} is not multiplayer.`);
|
||||
const id = crypto.randomBytes(6).toString('hex');
|
||||
const room = {
|
||||
id,
|
||||
gameSlug,
|
||||
name: name ?? `${hostUser.username}'s table`,
|
||||
hostId: hostUser.id,
|
||||
players: [{ userId: hostUser.id, username: hostUser.username, seat: 0 }],
|
||||
maxPlayers: game.maxPlayers,
|
||||
status: 'waiting',
|
||||
};
|
||||
this.rooms.set(id, room);
|
||||
return room;
|
||||
}
|
||||
|
||||
join(roomId, user) {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) throw new Error('Room not found.');
|
||||
if (room.players.some((p) => p.userId === user.id)) return room;
|
||||
if (room.players.length >= room.maxPlayers) throw new Error('Room is full.');
|
||||
const usedSeats = new Set(room.players.map((p) => p.seat));
|
||||
let seat = 0;
|
||||
while (usedSeats.has(seat)) seat += 1;
|
||||
room.players.push({ userId: user.id, username: user.username, seat });
|
||||
return room;
|
||||
}
|
||||
|
||||
leave(roomId, userId) {
|
||||
const room = this.rooms.get(roomId);
|
||||
if (!room) return null;
|
||||
room.players = room.players.filter((p) => p.userId !== userId);
|
||||
if (!room.players.length) {
|
||||
this.rooms.delete(roomId);
|
||||
return null;
|
||||
}
|
||||
if (room.hostId === userId) room.hostId = room.players[0].userId;
|
||||
return room;
|
||||
}
|
||||
|
||||
get(roomId) {
|
||||
return this.rooms.get(roomId) ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
export const lobby = new LobbyManager();
|
||||
Loading…
Reference in New Issue