Multiplayer mode: server, room tokens, docker setup, and client UI
This commit is contained in:
1 parent
176d7cc300
commit
434c81d0b6
19 files changed
+2563
-382
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
node_modules/
|
||||||
|
server/data/
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app/server
|
||||||
|
|
||||||
|
ENV PORT=8080
|
||||||
|
|
||||||
|
COPY server/package.json server/pnpm-lock.yaml ./
|
||||||
|
RUN corepack pnpm@9 install --prod
|
||||||
|
|
||||||
|
COPY server/*.js ./
|
||||||
|
COPY public/ /app/public
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Tic Tac Toe
|
||||||
|
|
||||||
|
A tic-tac-toe game with two modes: **CPU** (play against a minimax AI) and **Online** (real-time 2-player over WebSocket).
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Frontend: vanilla JS + CSS (no build step)
|
||||||
|
- Backend: Node.js + Express (serves static files) + `ws` (WebSocket) + `sql.js` (SQLite, in-memory)
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
```
|
||||||
|
public/ frontend (index.html, app.js, game.js, multiplayer.js, style.css)
|
||||||
|
server/ backend (server.js, db.js, package.json, pnpm-lock.yaml)
|
||||||
|
docs/ design docs
|
||||||
|
Dockerfile production image
|
||||||
|
docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
The backend lives in `server/` (the frontend has no build step).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# install backend deps (pnpm)
|
||||||
|
cd server && pnpm install
|
||||||
|
|
||||||
|
# start (default port 8080)
|
||||||
|
node server/server.js
|
||||||
|
# or with an explicit port
|
||||||
|
PORT=8080 node server/server.js
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open <http://localhost:8080/>.
|
||||||
|
|
||||||
|
> The server reads `PORT` (default `8080`) and optional `BASE_PATH` (default empty).
|
||||||
|
|
||||||
|
## Run with Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
or build and run the image directly:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t tictactoe .
|
||||||
|
docker run -p 8080:8080 tictactoe
|
||||||
|
```
|
||||||
|
|
||||||
|
Then open <http://localhost:8080/>.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- `server/data/` holds the SQLite file and is git-ignored.
|
||||||
|
- Online games persist to SQLite; a host who reloads can re-enter via the saved token.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
ttt:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8080:8080"
|
||||||
|
environment:
|
||||||
|
- BASE_PATH=/game
|
||||||
|
volumes:
|
||||||
|
- ./server/data:/app/server/data
|
||||||
|
restart: unless-stopped
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
# Tic-Tac-Toe — Online Multiplayer (Player vs Player) — Implementation Plan
|
||||||
|
|
||||||
|
Adds a **player-vs-player online mode** to the existing tic-tac-toe app. Two human
|
||||||
|
players connect over the internet, are matched through a shareable invite link, and
|
||||||
|
take turns on a shared board that stays in sync in real time. All existing game rules
|
||||||
|
(3×3 board, X moves first, win/draw detection, scoring, win-line, confetti) apply
|
||||||
|
unchanged.
|
||||||
|
|
||||||
|
This plan is written to be followed step-by-step by a junior developer. Each step says
|
||||||
|
**what** to build, **why**, and **how to verify it**. Follow the steps in order.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Goals & Non-Goals
|
||||||
|
|
||||||
|
### Goals (MVP)
|
||||||
|
- A **menu screen** where the player chooses: **Play vs CPU** (existing mode) or
|
||||||
|
**New Online Game** (new mode).
|
||||||
|
- **Host flow**: clicking *New Online Game* creates a game on the server and returns a
|
||||||
|
**shareable link** containing a **token**. The host copies/sends that link by any
|
||||||
|
channel (chat, email, etc.).
|
||||||
|
- **Joiner flow**: the second player opens the link in a browser. The app reads the
|
||||||
|
token from the URL and connects to the **same game session** the host created.
|
||||||
|
- Once both players are connected, the game starts. **Host is X (moves first)**,
|
||||||
|
**joiner is O**. Players alternate turns by tapping the board.
|
||||||
|
- **Real-time sync**: every move made by one player appears on the other player's
|
||||||
|
board immediately (sub-second).
|
||||||
|
- All normal rules apply: 8 winning lines, draw detection, win-line draw, confetti,
|
||||||
|
per-match scoring, "Play Again" for a new round.
|
||||||
|
- State is persisted in **SQLite** so invite tokens remain valid across server
|
||||||
|
restarts.
|
||||||
|
- Runnable locally with one command; deployable as a **Docker** container.
|
||||||
|
|
||||||
|
### Non-Goals (kept simple on purpose)
|
||||||
|
- No accounts, login, or player names. Players are anonymous "Player 1 / Player 2".
|
||||||
|
- No reconnection/resume after a dropped connection (a dropped player is notified;
|
||||||
|
the match is marked ended). Rejoining is possible for the joiner via the token link,
|
||||||
|
but not a first-class feature.
|
||||||
|
- No matchmaking/lobby, no rooms list, no chat, no spectators.
|
||||||
|
- No fairness randomization of who is X (host is always X for simplicity).
|
||||||
|
- No HTTPS/TLS termination in the app itself (put a reverse proxy / Caddy in front for
|
||||||
|
public internet use).
|
||||||
|
- No Postgres yet — SQLite now, Postgres later (see §12).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Tech Stack & Why
|
||||||
|
|
||||||
|
| Layer | Choice | Why |
|
||||||
|
|-------|--------|-----|
|
||||||
|
| Backend runtime | **Node.js 20** | Already installed; the user proposed Node. |
|
||||||
|
| HTTP server | **Express 5** | Minimal, well-known; serves the static frontend + a tiny REST API. |
|
||||||
|
| Real-time | **ws** (WebSocket) | Bidirectional, low-latency, the standard for real-time sync. Far simpler than WebRTC for a turn-based game. |
|
||||||
|
| Database | **SQLite via `sql.js`** (WASM) | Real SQLite with **zero native compilation**. The native `better-sqlite3` build segfaults in this environment (ABI/glibc mismatch); `sql.js` is the same SQLite engine compiled to WebAssembly — no build step, no native deps, and it stores a real `.db` file. Migrates to Postgres later (§12). |
|
||||||
|
| Frontend | **Vanilla HTML/CSS/JS** (existing) | Kept as-is per requirements; no framework, no build step. |
|
||||||
|
| Package manager | **pnpm** (via corepack) | `npm` is not on PATH in this environment; `corepack pnpm` works and is fast. |
|
||||||
|
| Deploy | **Docker** (`node:20-alpine`) | One-container deploy; SQLite file persists on a volume. |
|
||||||
|
|
||||||
|
> **Why not Spring Boot?** A JVM/Spring stack is heavyweight for this MVP: long
|
||||||
|
> build times, large images, and far more boilerplate for a 2-player turn-based game.
|
||||||
|
> Node + Express + ws + SQLite is the leanest stack that meets every requirement.
|
||||||
|
|
||||||
|
### Dependencies (server only)
|
||||||
|
- `express` — HTTP + static serving + REST API
|
||||||
|
- `ws` — WebSocket server
|
||||||
|
- `sql.js` — SQLite (WASM)
|
||||||
|
|
||||||
|
No frontend dependencies. No build step for the frontend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Project Structure (after this feature)
|
||||||
|
|
||||||
|
```
|
||||||
|
game1/
|
||||||
|
├── public/ # Frontend (served by the Node server)
|
||||||
|
│ ├── index.html # Page: menu + waiting + game screens
|
||||||
|
│ ├── style.css # All styling (existing + menu/waiting styles)
|
||||||
|
│ ├── app.js # NEW: screens, board, shared rendering, menu, routing
|
||||||
|
│ ├── game.js # CPU mode engine (refactored from the original)
|
||||||
|
│ └── multiplayer.js # NEW: online mode engine (WebSocket client)
|
||||||
|
├── server/
|
||||||
|
│ ├── package.json # express, ws, sql.js
|
||||||
|
│ ├── server.js # HTTP server, REST API, WebSocket server, game loop
|
||||||
|
│ ├── db.js # SQLite wrapper (schema, queries, persistence)
|
||||||
|
│ └── data/ # games.db lives here (gitignored)
|
||||||
|
├── docs/
|
||||||
|
│ ├── PLAN.md # Original (CPU mode) plan
|
||||||
|
│ ├── MULTIPLAYER_PLAN.md # THIS document
|
||||||
|
│ ├── mockup.svg # Original UI mockup
|
||||||
|
│ ├── menu-mockup.svg # NEW: menu screen design
|
||||||
|
│ ├── waiting-mockup.svg # NEW: "waiting for opponent" + invite link
|
||||||
|
│ └── game-mockup.svg # NEW: multiplayer game screen design
|
||||||
|
├── Dockerfile
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. High-Level Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ Node server │
|
||||||
|
│ (Express + ws + SQLite) │
|
||||||
|
└─────────────────────────────┘
|
||||||
|
▲ ▲
|
||||||
|
HTTP POST /api/games │ │ WebSocket (real-time)
|
||||||
|
│ │
|
||||||
|
┌──────────────┐ │ │ ┌──────────────┐
|
||||||
|
│ HOST │───────────┘ │ │ JOINER │
|
||||||
|
│ (Player 1, │ creates game, gets │ │ (Player 2, │
|
||||||
|
│ is X) │ token + link, waits │ │ is O) │
|
||||||
|
└──────────────┘ │ └──────────────┘
|
||||||
|
sends link ──────────────────────────► opens link, token in URL,
|
||||||
|
connects, joins game
|
||||||
|
```
|
||||||
|
|
||||||
|
1. **Host** opens the app → sees the **menu**.
|
||||||
|
2. Host clicks **New Online Game** → `POST /api/games` → server creates a game row
|
||||||
|
(id, token, status=`waiting`) and returns `{ id, token, link }`.
|
||||||
|
3. Host's app opens a **WebSocket** to the server, registers as the **host** of that
|
||||||
|
game, and shows the **waiting screen** with the invite link + a **Copy** button and
|
||||||
|
"Waiting for opponent…".
|
||||||
|
4. **Joiner** opens the link (`…/?token=abc123`). The app detects the `token` query
|
||||||
|
param, opens a **WebSocket**, and sends `{ type:'join', token }`.
|
||||||
|
5. Server validates the token, links the two connections, sets status=`playing`, and
|
||||||
|
broadcasts **start** to both. Host = X (turn), joiner = O.
|
||||||
|
6. Players tap cells. Each tap → client sends `{ type:'move', cell }` → server
|
||||||
|
validates (right turn, empty cell, game active) → updates board → **broadcasts**
|
||||||
|
`{ type:'move', cell, mark }` to **both** clients → both render the mark.
|
||||||
|
7. When a line is completed or the board fills, server sets status=`ended`,
|
||||||
|
broadcasts `{ type:'end', winner, line }` → both show the result overlay.
|
||||||
|
8. **Play Again** (either player) → server resets the board (X first), status=`playing`,
|
||||||
|
broadcasts `{ type:'new-round' }` → both clear the board.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Data Model (SQLite)
|
||||||
|
|
||||||
|
One table. The board and turn are stored as JSON/scalars so a game can be restored
|
||||||
|
after a server restart.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS games (
|
||||||
|
id TEXT PRIMARY KEY, -- UUID v4
|
||||||
|
token TEXT UNIQUE NOT NULL, -- invite token (16 random hex chars)
|
||||||
|
status TEXT NOT NULL DEFAULT 'waiting', -- 'waiting' | 'playing' | 'ended'
|
||||||
|
board TEXT NOT NULL DEFAULT 'null', -- JSON array of 9: null | 'X' | 'O'
|
||||||
|
turn TEXT NOT NULL DEFAULT 'X', -- whose turn: 'X' | 'O'
|
||||||
|
created_at TEXT NOT NULL, -- ISO-8601 timestamp
|
||||||
|
updated_at TEXT NOT NULL -- ISO-8601 timestamp
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
- **id**: `crypto.randomUUID()` (built into Node 20).
|
||||||
|
- **token**: 16 random hex chars from `crypto.randomBytes(8).toString('hex')`.
|
||||||
|
Short enough to type/share, unique enough for an MVP. (For production, use 32+ chars
|
||||||
|
or a signed token.)
|
||||||
|
- **board**: JSON string of the 9-cell array, e.g. `["X",null,"O",...]`.
|
||||||
|
- The in-memory `games` Map (see §7) holds the **live** WebSocket connections and is
|
||||||
|
the source of truth during play. The DB is the durable record and the source of
|
||||||
|
truth for **token validation**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. REST API
|
||||||
|
|
||||||
|
Base URL: the host serving the app (e.g. `http://localhost:3000`).
|
||||||
|
|
||||||
|
### `POST /api/games` — create a game (host)
|
||||||
|
- **Request**: no body (or `{}`).
|
||||||
|
- **Response 201**:
|
||||||
|
```json
|
||||||
|
{ "id": "uuid", "token": "abc123def456", "link": "http://<host>:3000/?token=abc123def456" }
|
||||||
|
```
|
||||||
|
- The server inserts a `games` row with `status='waiting'`, `board='null'`, `turn='X'`.
|
||||||
|
- `link` is built from the request origin: `new URL(req.originalUrl).origin + '/?token=' + token`.
|
||||||
|
(The single-page app reads `?token=` to enter joiner mode — see §8.)
|
||||||
|
|
||||||
|
### `GET /api/health` — liveness probe (for Docker)
|
||||||
|
- **Response 200**: `{ "ok": true }`.
|
||||||
|
|
||||||
|
> That's the entire REST surface. All gameplay happens over the WebSocket.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. WebSocket Protocol
|
||||||
|
|
||||||
|
Endpoint: `ws://<host>:3000/ws`
|
||||||
|
|
||||||
|
The server keeps an in-memory map:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// gameId -> {
|
||||||
|
// id, token, board: Array(9), turn: 'X'|'O', status: 'waiting'|'playing'|'ended',
|
||||||
|
// players: { X: ws|null, O: ws|null } // live socket per seat
|
||||||
|
// }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client → Server messages
|
||||||
|
| type | payload | meaning |
|
||||||
|
|------|---------|---------|
|
||||||
|
| `host` | `{ gameId }` | Host registers as seat **X** for `gameId`. |
|
||||||
|
| `join` | `{ token }` | Joiner requests seat **O** for the game with that token. |
|
||||||
|
| `move` | `{ cell }` | Player places a mark in `cell` (0–8). |
|
||||||
|
| `new-round` | `{}` | Request a fresh round (after the game ended). |
|
||||||
|
| `leave` | `{}` | Player intentionally leaves. |
|
||||||
|
|
||||||
|
### Server → Client messages
|
||||||
|
| type | payload | meaning |
|
||||||
|
|------|---------|---------|
|
||||||
|
| `joined` | `{ you, board, turn }` | Sent to the **joiner** on successful join (and to host as `opponent-joined`). |
|
||||||
|
| `opponent-joined` | `{ you }` | Sent to the **host** when the joiner connects; game becomes `playing`. |
|
||||||
|
| `start` | `{ you, board, turn }` | Both clients: game is live. `you` is `'X'` or `'O'`. |
|
||||||
|
| `move` | `{ cell, mark }` | Broadcast to **both**: a mark was placed. |
|
||||||
|
| `end` | `{ winner, line }` | Broadcast to both: `winner` is `'X'`/`'O'`/`'draw'`, `line` is `[i,j,k]` or `null`. |
|
||||||
|
| `new-round` | `{ board, turn }` | Broadcast to both: board cleared, X to move. |
|
||||||
|
| `opponent-left` | `{}` | The other player disconnected/left. |
|
||||||
|
| `error` | `{ message }` | A request was invalid (bad token, wrong turn, occupied cell, etc.). |
|
||||||
|
|
||||||
|
### Move validation (server-side — the source of truth)
|
||||||
|
A `move` is accepted only if **all** are true:
|
||||||
|
1. Game `status === 'playing'`.
|
||||||
|
2. The sender's seat mark equals `game.turn` (it's their turn).
|
||||||
|
3. `board[cell] === null` (cell empty).
|
||||||
|
4. `cell` is an integer 0–8.
|
||||||
|
|
||||||
|
If any check fails, send `{ type:'error', message }` to the sender and **do not**
|
||||||
|
change state. This prevents race conditions from double-taps or stale clients.
|
||||||
|
|
||||||
|
On a valid move:
|
||||||
|
1. `board[cell] = turnMark`.
|
||||||
|
2. `turn = (turn === 'X') ? 'O' : 'X'`.
|
||||||
|
3. Persist to DB (board + turn + status).
|
||||||
|
4. Check for a winner/draw (same 8 lines as the frontend).
|
||||||
|
5. Broadcast `{ type:'move', cell, mark }` to both.
|
||||||
|
6. If the game ended: set `status='ended'`, persist, broadcast
|
||||||
|
`{ type:'end', winner, line }`.
|
||||||
|
|
||||||
|
### Turn order
|
||||||
|
- **Host = X** (always moves first), **joiner = O**. `turn` starts at `'X'` and
|
||||||
|
alternates. This matches the existing CPU game where X moves first.
|
||||||
|
|
||||||
|
### New round
|
||||||
|
- On `new-round` (accepted only when `status === 'ended'`): reset `board` to all
|
||||||
|
`null`, `turn='X'`, `status='playing'`, persist, broadcast `{ type:'new-round' }`.
|
||||||
|
Either player may trigger it; the first request wins, a second is ignored (already
|
||||||
|
reset).
|
||||||
|
|
||||||
|
### Disconnection
|
||||||
|
- On a socket `close`/`leave`: remove that socket from `game.players[seat]`.
|
||||||
|
- If the game was `playing`, notify the other player `{ type:'opponent-left' }` and set
|
||||||
|
`status='ended'` (the match can't continue without both players). Persist.
|
||||||
|
- The remaining client shows "Opponent left" and disables the board.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Frontend Architecture
|
||||||
|
|
||||||
|
The frontend is three JS files + HTML + CSS, no framework, no build step.
|
||||||
|
|
||||||
|
### `index.html` — screens
|
||||||
|
The page contains three top-level **screens** (only one visible at a time):
|
||||||
|
|
||||||
|
1. **`#screen-menu`** — the landing screen:
|
||||||
|
- Title "Tic-Tac-Toe"
|
||||||
|
- Button **Play vs CPU** (ghost)
|
||||||
|
- Button **New Online Game** (primary)
|
||||||
|
|
||||||
|
2. **`#screen-waiting`** — shown to the **host** after creating a game:
|
||||||
|
- Title "Share this link with your opponent"
|
||||||
|
- A read-only **link box** showing the invite link
|
||||||
|
- **Copy Link** button (uses `navigator.clipboard`, with a `prompt()` fallback)
|
||||||
|
- Pulsing status "Waiting for opponent…"
|
||||||
|
- **Back to Menu** button
|
||||||
|
|
||||||
|
3. **`#screen-game`** — the actual game (shared by CPU and online modes):
|
||||||
|
- Scoreboard (You / Opponent / Draws) — labels adapt per mode
|
||||||
|
- Status bar ("Your turn" / "Opponent's turn" / result)
|
||||||
|
- 3×3 board + win-line SVG
|
||||||
|
- Buttons: **New Game** / **Play Again** (primary), **Reset Scores** (ghost),
|
||||||
|
**Back to Menu** (ghost)
|
||||||
|
- Result overlay (win/loss/draw) + confetti layer + toast
|
||||||
|
|
||||||
|
> The game screen's DOM (board, cells, scoreboard, status, overlay) is **shared**
|
||||||
|
> between CPU and online modes. The JS modules drive it.
|
||||||
|
|
||||||
|
### `app.js` — shared logic + routing (NEW)
|
||||||
|
Responsibilities:
|
||||||
|
- **Routing on load**: read `new URLSearchParams(location.search).get('token')`.
|
||||||
|
- If a `token` is present → **joiner flow**: call
|
||||||
|
`Multiplayer.init({ asJoiner: true, token })` and show the game screen (in a
|
||||||
|
"connecting" state).
|
||||||
|
- Otherwise → show **`#screen-menu`**.
|
||||||
|
- **Screen management**: `showScreen('menu' | 'waiting' | 'game')` toggles
|
||||||
|
`display` on the three screens.
|
||||||
|
- **Board building**: create the 9 `<button class="cell">` cells **once** and own the
|
||||||
|
cell `click` handler. The handler delegates to the active mode:
|
||||||
|
```js
|
||||||
|
function onCellTap(i) {
|
||||||
|
if (activeMode === 'cpu') CpuGame.handleMove(i);
|
||||||
|
if (activeMode === 'online') Multiplayer.handleMove(i);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- **Shared rendering** (used by both modes): `renderMark(i, mark)`,
|
||||||
|
`renderStatus(text, mode)`, `drawWinLine(line)`, `clearWinLine()`,
|
||||||
|
`showOverlay(title, winner)`, `hideOverlay()`, `burstConfetti()`,
|
||||||
|
`renderScores(changed)`, `clearBoard()` (reset all cells to empty), `resetScores()`.
|
||||||
|
- **Mode lifecycle**:
|
||||||
|
- `startCpuMode()`: show game screen, `activeMode='cpu'`, `CpuGame.init()`.
|
||||||
|
- `startOnlineHost({ id, token, link })`: show waiting screen, `activeMode='online'`,
|
||||||
|
`Multiplayer.init({ asHost: true, id, token, link })`.
|
||||||
|
- `backToMenu()`: destroy the active mode (`CpuGame.destroy()` /
|
||||||
|
`Multiplayer.destroy()`), `activeMode=null`, show menu.
|
||||||
|
- **Scoreboard labels**: in CPU mode "You (X) / CPU (O)"; in online mode
|
||||||
|
"You (X) / Opponent (O)" for the host, "Opponent (X) / You (O)" for the joiner.
|
||||||
|
|
||||||
|
### `game.js` — CPU engine (refactored)
|
||||||
|
The original monolith is refactored into a module exposing a global `CpuGame`:
|
||||||
|
- `CpuGame.init()` — set state (`board`, `turn='X'`, `gameOver=false`), load scores
|
||||||
|
from `localStorage`, render. **Does not auto-run on page load** (the menu comes
|
||||||
|
first). Must be idempotent: `clearBoard()` before building state.
|
||||||
|
- `CpuGame.handleMove(i)` — the local player's X move: validate (not game over,
|
||||||
|
`turn==='X'`, cell empty), place X via `App.renderMark`, check winner; if not over,
|
||||||
|
set `turn='O'`, show "CPU is thinking…", schedule `cpuMove` after 600 ms.
|
||||||
|
- `CpuGame.cpuMove()` — pick a random empty cell, place O via `App.renderMark`, check
|
||||||
|
end; else `turn='X'`, "Your turn".
|
||||||
|
- `CpuGame.newRound()` — clear board, `turn='X'`, `gameOver=false`, hide overlay.
|
||||||
|
- `CpuGame.resetScores()` — zero scores, persist, render.
|
||||||
|
- `CpuGame.destroy()` — clear the CPU timer, reset state.
|
||||||
|
- All rendering goes through `App.*` (shared). The win-detection (`checkWinner`,
|
||||||
|
`WIN_LINES`) lives here and is reused by the online mode via `App` (see below).
|
||||||
|
|
||||||
|
> **Win detection is shared.** `checkWinner(board)` and `WIN_LINES` are defined once
|
||||||
|
> (in `game.js`) and exposed as `App.checkWinner` / `App.WIN_LINES` so `multiplayer.js`
|
||||||
|
> can use the exact same logic for local prediction/animation. The **server** has its
|
||||||
|
> own identical copy (it must, since it's the source of truth).
|
||||||
|
|
||||||
|
### `multiplayer.js` — online engine (NEW)
|
||||||
|
A module exposing a global `Multiplayer`:
|
||||||
|
- `Multiplayer.init({ asHost, asJoiner, id, token, link })`:
|
||||||
|
- Store config. Open `new WebSocket('ws(s)://' + location.host + '/ws')`.
|
||||||
|
- On `open`:
|
||||||
|
- Host → send `{ type:'host', gameId: id }`.
|
||||||
|
- Joiner → send `{ type:'join', token }`.
|
||||||
|
- Message handlers (see table below) update local state and call `App.*` to render.
|
||||||
|
- Track `myMark` (`'X'` for host, `'O'` for joiner) and `myTurn` (derived from
|
||||||
|
`game.turn === myMark`).
|
||||||
|
- `Multiplayer.handleMove(i)`:
|
||||||
|
- Local guard: game active, `myTurn`, cell empty. (The server re-validates.)
|
||||||
|
- **Optimistic** local render for snappy feel, then send `{ type:'move', cell:i }`.
|
||||||
|
(The authoritative `move` broadcast from the server re-renders both sides
|
||||||
|
idempotently — `renderMark` overwrites, so no conflict.)
|
||||||
|
- `Multiplayer.newRound()`: send `{ type:'new-round' }`.
|
||||||
|
- `Multiplayer.destroy()`: send `{ type:'leave' }`, close the socket, reset state.
|
||||||
|
- **Waiting screen** (host only): show the link + Copy button + "Waiting for
|
||||||
|
opponent…". When `opponent-joined`/`start` arrives, switch to the game screen.
|
||||||
|
- **Message handlers**:
|
||||||
|
| msg | action |
|
||||||
|
|-----|--------|
|
||||||
|
| `joined` / `start` | set `myMark`, board, turn; render board; show game screen; status "Your turn"/"Opponent's turn". |
|
||||||
|
| `opponent-joined` | (host) game is live; show game screen. |
|
||||||
|
| `move` | `App.renderMark(cell, mark)`; update local board; if `cell` is mine, it's now opponent's turn; re-render status. |
|
||||||
|
| `end` | update score, `App.drawWinLine(line)` if line, show overlay (confetti if I won), status text. |
|
||||||
|
| `new-round` | `App.clearBoard()`; reset local board/turn; status "Your turn"/"Opponent's turn". |
|
||||||
|
| `opponent-left` | show "Opponent left" overlay/toast; disable board. |
|
||||||
|
| `error` | toast the message. |
|
||||||
|
| socket `close` | toast "Connection lost"; show back-to-menu. |
|
||||||
|
- **Scoring in online mode**: track wins/losses/draws **for the match** (not
|
||||||
|
`localStorage`) — reset when the match ends. Display in the scoreboard. (Persisting
|
||||||
|
online scores to `localStorage` is a possible later enhancement; keep it in-memory
|
||||||
|
for the MVP.)
|
||||||
|
|
||||||
|
### `style.css` — additions
|
||||||
|
- Styles for `#screen-menu` (two big buttons, centered), `#screen-waiting` (link box,
|
||||||
|
copy button, pulsing "waiting" text), and the "Back to Menu" ghost button.
|
||||||
|
- Reuse the existing glassmorphism theme tokens (`.btn`, `.chip`, `.cell`, overlay,
|
||||||
|
confetti, toast) — the game screen is unchanged visually.
|
||||||
|
- A `.link-box` (read-only, selectable text, monospace-ish, with a copy affordance).
|
||||||
|
- `.waiting-pulse` reuses the existing `status-pulse` keyframes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Step-by-Step Implementation (for a junior dev)
|
||||||
|
|
||||||
|
> Work top-down. After each step, run the **Verify** item before moving on.
|
||||||
|
|
||||||
|
### Step 0 — Bootstrap the server
|
||||||
|
- Create `server/package.json`:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "ttt-server",
|
||||||
|
"private": true,
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": { "start": "node server.js" },
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"ws": "^8.21.3",
|
||||||
|
"sql.js": "^1.14.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
- Install: `cd server && corepack pnpm@9 install` (or `pnpm install` if pnpm is on PATH).
|
||||||
|
- **Verify**: `node -e "require('express');require('ws');require('sql.js');console.log('ok')"`
|
||||||
|
prints `ok` (add `process.exit(0)` if the process lingers).
|
||||||
|
|
||||||
|
### Step 1 — `server/db.js` (SQLite wrapper)
|
||||||
|
- Use `sql.js`. On startup: load `data/games.db` if it exists, else create a new DB and
|
||||||
|
run the `CREATE TABLE games (...)` schema (§5).
|
||||||
|
- Provide helpers:
|
||||||
|
- `createGame({ id, token })` → insert row (`status='waiting'`, `board='null'`,
|
||||||
|
`turn='X'`, timestamps).
|
||||||
|
- `getGameByToken(token)` → row or `null`.
|
||||||
|
- `getGameById(id)` → row or `null`.
|
||||||
|
- `updateGame(id, { status, board, turn })` → `UPDATE`.
|
||||||
|
- `persist()` → `db.export()` → write bytes to `data/games.db`.
|
||||||
|
- Persist after every mutation (cheap for a tiny DB).
|
||||||
|
- **Verify**: a tiny script that creates a game, reads it back by token, updates it,
|
||||||
|
re-loads the file, and confirms the row survived.
|
||||||
|
|
||||||
|
### Step 2 — `server/server.js` (HTTP + WS + game loop)
|
||||||
|
- Express app:
|
||||||
|
- `app.use(express.static(path.join(__dirname, '../public')))`.
|
||||||
|
- `app.post('/api/games', ...)` → create game via `db.createGame`, return
|
||||||
|
`{ id, token, link }` (201). Build `link` from `req` origin.
|
||||||
|
- `app.get('/api/health', ...)` → `{ ok: true }`.
|
||||||
|
- SPA fallback: `app.get('*', ...)` → serve `public/index.html` (so `/?token=…`
|
||||||
|
loads the app).
|
||||||
|
- WebSocket server (`ws`):
|
||||||
|
- Attach to the HTTP server on path `/ws`.
|
||||||
|
- Maintain the `games` Map (§7). On `host`/`join` messages, resolve the game (from
|
||||||
|
memory or DB), attach the socket to the seat, and when both seats are filled set
|
||||||
|
`status='playing'` and broadcast `start`.
|
||||||
|
- Implement move validation (§7), new-round, disconnection handling.
|
||||||
|
- **Verify** (with two browser tabs or `wscat`):
|
||||||
|
- Tab A: create game (via the UI or `curl -X POST localhost:3000/api/games`), see the
|
||||||
|
link.
|
||||||
|
- Tab B: open the link, both tabs show "start", play a few moves — both boards update
|
||||||
|
in real time. Test a win (win-line draws on both), a draw, and "Play Again".
|
||||||
|
|
||||||
|
### Step 3 — Frontend restructure
|
||||||
|
- Move `index.html`, `style.css`, `game.js` into `public/` (already done via `git mv`).
|
||||||
|
- Rewrite `index.html` to contain the three screens (§8) with only `#screen-menu`
|
||||||
|
visible initially. Keep the existing board/scoreboard/overlay markup inside
|
||||||
|
`#screen-game`. Add `<script>` tags for `app.js`, `game.js`, `multiplayer.js`
|
||||||
|
(order: `game.js`, `multiplayer.js`, then `app.js` so `App` is defined last and can
|
||||||
|
reference the others — or use `defer` and call `App.init()` on `DOMContentLoaded`).
|
||||||
|
- **Verify**: opening the app shows the menu with two buttons; no console errors.
|
||||||
|
|
||||||
|
### Step 4 — `public/app.js` (shared + routing)
|
||||||
|
- Implement screen management, board building, shared rendering (move the rendering
|
||||||
|
functions out of `game.js` into `app.js`), cell-click delegation, mode lifecycle,
|
||||||
|
and token-based routing (§8).
|
||||||
|
- **Verify**: "Play vs CPU" starts the existing CPU game exactly as before (win, draw,
|
||||||
|
confetti, scores persist). "Back to Menu" returns to the menu and re-entering CPU
|
||||||
|
mode works cleanly (no duplicate cells).
|
||||||
|
|
||||||
|
### Step 5 — `public/game.js` (CPU refactor)
|
||||||
|
- Refactor the original into the `CpuGame` module (§8): remove the auto `init()` call,
|
||||||
|
expose `init/handleMove/cpuMove/newRound/resetScores/destroy`, and route all
|
||||||
|
rendering through `App.*`.
|
||||||
|
- **Verify**: CPU mode is unchanged in behavior (see Step 4 verify).
|
||||||
|
|
||||||
|
### Step 6 — `public/multiplayer.js` (online engine)
|
||||||
|
- Implement `Multiplayer` (§8): WebSocket connect, host/join handshake, message
|
||||||
|
handlers, optimistic move, waiting screen, scoring, disconnection handling.
|
||||||
|
- **Verify** (two tabs / two browsers):
|
||||||
|
- Host: New Online Game → link appears → copy.
|
||||||
|
- Joiner: open link → both show the board → host is X, joiner is O.
|
||||||
|
- Alternate moves; both boards stay in sync in real time.
|
||||||
|
- Win → win-line + confetti on the winner's side, "You Win"/"Opponent wins" on each.
|
||||||
|
- Draw → "It's a Draw" on both.
|
||||||
|
- Play Again → both boards clear, X (host) to move.
|
||||||
|
- Close one tab → the other shows "Opponent left".
|
||||||
|
|
||||||
|
### Step 7 — Styling
|
||||||
|
- Add menu/waiting/back-button styles (§8). Match the existing glassmorphism theme.
|
||||||
|
- **Verify**: at 360 px width, no overflow; buttons are big touch targets; the link box
|
||||||
|
is tappable to select/copy.
|
||||||
|
|
||||||
|
### Step 8 — Docker
|
||||||
|
- `Dockerfile` (multi-stage or single-stage on `node:20-alpine`):
|
||||||
|
- `WORKDIR /app/server`
|
||||||
|
- Copy `server/package.json` + `server/package-lock` (pnpm-lock.yaml), install deps.
|
||||||
|
- Copy `server/*.js`.
|
||||||
|
- Copy `../public` to `/app/public`.
|
||||||
|
- `EXPOSE 3000`, `CMD ["node","server.js"]`.
|
||||||
|
- For `sql.js`, the WASM file is in `node_modules/sql.js/dist` — it's loaded from
|
||||||
|
`node_modules`, so copying `node_modules` (or installing in the image) is required.
|
||||||
|
- `docker-compose.yml`: one service, `build: .`, `ports: ["3000:3000"]`, a volume for
|
||||||
|
`./server/data:/app/server/data` (SQLite persistence), `restart: unless-stopped`.
|
||||||
|
- **Verify**: `docker compose up --build` → open `http://localhost:3000` → full
|
||||||
|
multiplayer flow works. Restart the container → an existing invite token still
|
||||||
|
resolves (DB persisted on the volume).
|
||||||
|
|
||||||
|
### Step 9 — Final test pass
|
||||||
|
- Full end-to-end on a mid-range phone + desktop: create, share, join, play, win,
|
||||||
|
draw, play again, disconnect, reload.
|
||||||
|
- Check the 360 px viewport, fast taps (no double placement — the server rejects
|
||||||
|
out-of-turn/occupied moves), and connection loss.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Security & Robustness Notes (MVP-aware)
|
||||||
|
- **Server is the source of truth.** The client's optimistic render is cosmetic; the
|
||||||
|
server validates every move (§7). A tampered client cannot cheat.
|
||||||
|
- **Tokens** are 16 hex chars (64 bits) — fine for an MVP. For public internet use,
|
||||||
|
lengthen them and/or add a TTL (expire `waiting` games after e.g. 10 min) and an
|
||||||
|
`expires_at` column.
|
||||||
|
- **No auth** by design (anonymous 2-player game). Don't expose personal data.
|
||||||
|
- **Rate limiting / abuse**: out of scope for the MVP; add in front of a reverse proxy
|
||||||
|
for public use.
|
||||||
|
- **HTTPS**: terminate TLS at a reverse proxy (Caddy/nginx) for internet play; the
|
||||||
|
app itself serves plain HTTP for LAN/dev.
|
||||||
|
- **CORS**: same-origin (the server serves the frontend), so no CORS config needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Testing Checklist
|
||||||
|
- [ ] Menu shows two options; no console errors.
|
||||||
|
- [ ] CPU mode unchanged (win/draw/confetti/scores/back-to-menu).
|
||||||
|
- [ ] New Online Game returns a valid link; Copy works.
|
||||||
|
- [ ] Joiner opens link → connects to the host's game (host = X, joiner = O).
|
||||||
|
- [ ] Moves sync in real time on both boards.
|
||||||
|
- [ ] Out-of-turn / occupied / double-tap moves are rejected by the server.
|
||||||
|
- [ ] Win → win-line + confetti on winner, correct status on both.
|
||||||
|
- [ ] Draw → "It's a Draw" on both.
|
||||||
|
- [ ] Play Again resets both boards, X (host) to move.
|
||||||
|
- [ ] Disconnect → the other player is notified; match ends.
|
||||||
|
- [ ] Server restart → an existing token still resolves (SQLite persisted).
|
||||||
|
- [ ] 360 px viewport: no overflow, big touch targets, no zoom on tap.
|
||||||
|
- [ ] Docker container starts and serves the full flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Future Work (post-MVP)
|
||||||
|
- **Postgres**: swap the `db.js` wrapper's storage from `sql.js` to `pg`/`postgres`.
|
||||||
|
The schema (§5) and query helpers stay the same shape; only the driver changes. The
|
||||||
|
in-memory `games` Map and WebSocket layer are unaffected.
|
||||||
|
- Reconnection/resume (client re-attaches to a `playing` game by token/id).
|
||||||
|
- Fair X/O randomization per round; player names; match history/leaderboard in the DB.
|
||||||
|
- Token TTL + cleanup job; matchmaking beyond a single invite link.
|
||||||
|
- HTTPS/TLS, rate limiting, and a reverse proxy for public internet deployment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. How to Run (local, no Docker)
|
||||||
|
```bash
|
||||||
|
# 1. Install server deps (once)
|
||||||
|
cd server && corepack pnpm@9 install && cd ..
|
||||||
|
|
||||||
|
# 2. Start the server (serves the frontend + API + WebSocket on :3000)
|
||||||
|
node server/server.js
|
||||||
|
# → http://localhost:3000
|
||||||
|
|
||||||
|
# 3. Play:
|
||||||
|
# - Open http://localhost:3000 in two browser tabs (or two browsers / a phone on
|
||||||
|
# the same network using http://<machine-ip>:3000).
|
||||||
|
# - Tab A: New Online Game → copy the link.
|
||||||
|
# - Tab B: open the link → play together.
|
||||||
|
```
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="420" height="780" viewBox="0 0 420 780" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#0d0f24"/>
|
||||||
|
<stop offset="1" stop-color="#1c1140"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="titleGrad" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0" stop-color="#22d3ee"/>
|
||||||
|
<stop offset="1" stop-color="#f472b6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="btnGrad" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0" stop-color="#22d3ee"/>
|
||||||
|
<stop offset="1" stop-color="#f472b6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="blob" x="-80%" y="-80%" width="260%" height="260%">
|
||||||
|
<feGaussianBlur stdDeviation="55"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="glow" x="-60%" y="-60%" width="220%" height="220%">
|
||||||
|
<feGaussianBlur stdDeviation="5" result="blur"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur"/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="420" height="780" fill="url(#bgGrad)"/>
|
||||||
|
<circle cx="70" cy="170" r="130" fill="#22d3ee" opacity="0.22" filter="url(#blob)"/>
|
||||||
|
<circle cx="350" cy="580" r="150" fill="#f472b6" opacity="0.18" filter="url(#blob)"/>
|
||||||
|
|
||||||
|
<!-- title -->
|
||||||
|
<text x="210" y="66" text-anchor="middle" font-size="30" font-weight="700" fill="url(#titleGrad)">Tic-Tac-Toe</text>
|
||||||
|
<text x="210" y="88" text-anchor="middle" font-size="12" fill="#ffffff" opacity="0.45">Online · You are X</text>
|
||||||
|
|
||||||
|
<!-- scoreboard -->
|
||||||
|
<g>
|
||||||
|
<rect x="18" y="104" width="120" height="64" rx="16" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.14"/>
|
||||||
|
<text x="78" y="128" text-anchor="middle" font-size="11" letter-spacing="1.5" fill="#ffffff" opacity="0.55">YOU (X)</text>
|
||||||
|
<text x="78" y="156" text-anchor="middle" font-size="22" font-weight="700" fill="#22d3ee">1</text>
|
||||||
|
|
||||||
|
<rect x="150" y="104" width="120" height="64" rx="16" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.14"/>
|
||||||
|
<text x="210" y="128" text-anchor="middle" font-size="11" letter-spacing="1.5" fill="#ffffff" opacity="0.55">FRIEND (O)</text>
|
||||||
|
<text x="210" y="156" text-anchor="middle" font-size="22" font-weight="700" fill="#f472b6">0</text>
|
||||||
|
|
||||||
|
<rect x="282" y="104" width="120" height="64" rx="16" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.14"/>
|
||||||
|
<text x="342" y="128" text-anchor="middle" font-size="11" letter-spacing="1.5" fill="#ffffff" opacity="0.55">DRAWS</text>
|
||||||
|
<text x="342" y="156" text-anchor="middle" font-size="22" font-weight="700" fill="#ffffff">0</text>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- status -->
|
||||||
|
<text x="210" y="200" text-anchor="middle" font-size="14" fill="#22d3ee" opacity="0.9">Your turn</text>
|
||||||
|
|
||||||
|
<!-- board panel -->
|
||||||
|
<rect x="40" y="220" width="340" height="340" rx="24" fill="#ffffff" fill-opacity="0.05" stroke="#ffffff" stroke-opacity="0.12"/>
|
||||||
|
|
||||||
|
<!-- cells -->
|
||||||
|
<g fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.10">
|
||||||
|
<rect x="54" y="234" width="96" height="96" rx="16"/>
|
||||||
|
<rect x="162" y="234" width="96" height="96" rx="16"/>
|
||||||
|
<rect x="270" y="234" width="96" height="96" rx="16"/>
|
||||||
|
<rect x="54" y="342" width="96" height="96" rx="16"/>
|
||||||
|
<rect x="162" y="342" width="96" height="96" rx="16"/>
|
||||||
|
<rect x="270" y="342" width="96" height="96" rx="16"/>
|
||||||
|
<rect x="54" y="450" width="96" height="96" rx="16"/>
|
||||||
|
<rect x="162" y="450" width="96" height="96" rx="16"/>
|
||||||
|
<rect x="270" y="450" width="96" height="96" rx="16"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- marks -->
|
||||||
|
<g filter="url(#glow)" stroke="#22d3ee" stroke-width="7" stroke-linecap="round">
|
||||||
|
<line x1="80" y1="260" x2="124" y2="304"/>
|
||||||
|
<line x1="124" y1="260" x2="80" y2="304"/>
|
||||||
|
</g>
|
||||||
|
<circle cx="210" cy="282" r="24" fill="none" stroke="#f472b6" stroke-width="7" filter="url(#glow)"/>
|
||||||
|
<circle cx="102" cy="390" r="24" fill="none" stroke="#f472b6" stroke-width="7" filter="url(#glow)"/>
|
||||||
|
<g filter="url(#glow)" stroke="#22d3ee" stroke-width="7" stroke-linecap="round">
|
||||||
|
<line x1="188" y1="368" x2="232" y2="412"/>
|
||||||
|
<line x1="232" y1="368" x2="188" y2="412"/>
|
||||||
|
</g>
|
||||||
|
<g filter="url(#glow)" stroke="#22d3ee" stroke-width="7" stroke-linecap="round">
|
||||||
|
<line x1="296" y1="368" x2="340" y2="412"/>
|
||||||
|
<line x1="340" y1="368" x2="296" y2="412"/>
|
||||||
|
</g>
|
||||||
|
<circle cx="210" cy="498" r="24" fill="none" stroke="#f472b6" stroke-width="7" filter="url(#glow)"/>
|
||||||
|
|
||||||
|
<!-- buttons -->
|
||||||
|
<rect x="60" y="600" width="300" height="54" rx="27" fill="url(#btnGrad)"/>
|
||||||
|
<text x="210" y="633" text-anchor="middle" font-size="16" font-weight="700" fill="#0d1025">Play Again</text>
|
||||||
|
|
||||||
|
<rect x="60" y="668" width="300" height="48" rx="24" fill="#ffffff" fill-opacity="0.05" stroke="#ffffff" stroke-opacity="0.18"/>
|
||||||
|
<text x="210" y="697" text-anchor="middle" font-size="14" fill="#ffffff" opacity="0.7">Back to Menu</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.8 KiB |
@@ -0,0 +1,63 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="420" height="780" viewBox="0 0 420 780" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#0d0f24"/>
|
||||||
|
<stop offset="1" stop-color="#1c1140"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="titleGrad" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0" stop-color="#22d3ee"/>
|
||||||
|
<stop offset="1" stop-color="#f472b6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="btnGrad" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0" stop-color="#22d3ee"/>
|
||||||
|
<stop offset="1" stop-color="#f472b6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="blob" x="-80%" y="-80%" width="260%" height="260%">
|
||||||
|
<feGaussianBlur stdDeviation="55"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="glow" x="-60%" y="-60%" width="220%" height="220%">
|
||||||
|
<feGaussianBlur stdDeviation="5" result="blur"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur"/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- background -->
|
||||||
|
<rect width="420" height="780" fill="url(#bgGrad)"/>
|
||||||
|
<circle cx="70" cy="170" r="130" fill="#22d3ee" opacity="0.22" filter="url(#blob)"/>
|
||||||
|
<circle cx="350" cy="580" r="150" fill="#f472b6" opacity="0.18" filter="url(#blob)"/>
|
||||||
|
|
||||||
|
<!-- title -->
|
||||||
|
<text x="210" y="150" text-anchor="middle" font-size="34" font-weight="800" fill="url(#titleGrad)">Tic-Tac-Toe</text>
|
||||||
|
<text x="210" y="184" text-anchor="middle" font-size="14" fill="#ffffff" opacity="0.5">Choose how to play</text>
|
||||||
|
|
||||||
|
<!-- decorative mini board hint -->
|
||||||
|
<g opacity="0.5">
|
||||||
|
<rect x="150" y="230" width="120" height="120" rx="18" fill="#ffffff" fill-opacity="0.05" stroke="#ffffff" stroke-opacity="0.12"/>
|
||||||
|
<g stroke="#ffffff" stroke-opacity="0.18" stroke-width="2">
|
||||||
|
<line x1="190" y1="240" x2="190" y2="340"/>
|
||||||
|
<line x1="230" y1="240" x2="230" y2="340"/>
|
||||||
|
<line x1="160" y1="270" x2="340" y2="270" transform="translate(-10,0)"/>
|
||||||
|
<line x1="160" y1="310" x2="340" y2="310" transform="translate(-10,0)"/>
|
||||||
|
</g>
|
||||||
|
<g filter="url(#glow)" stroke="#22d3ee" stroke-width="5" stroke-linecap="round">
|
||||||
|
<line x1="168" y1="252" x2="186" y2="270"/>
|
||||||
|
<line x1="186" y1="252" x2="168" y2="270"/>
|
||||||
|
</g>
|
||||||
|
<circle cx="272" cy="322" r="12" fill="none" stroke="#f472b6" stroke-width="5" filter="url(#glow)"/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- buttons -->
|
||||||
|
<!-- primary: New Online Game -->
|
||||||
|
<rect x="60" y="470" width="300" height="58" rx="29" fill="url(#btnGrad)"/>
|
||||||
|
<text x="210" y="505" text-anchor="middle" font-size="17" font-weight="700" fill="#0d1025">New Online Game</text>
|
||||||
|
|
||||||
|
<!-- ghost: Play vs CPU -->
|
||||||
|
<rect x="60" y="548" width="300" height="54" rx="27" fill="#ffffff" fill-opacity="0.05" stroke="#ffffff" stroke-opacity="0.18"/>
|
||||||
|
<text x="210" y="580" text-anchor="middle" font-size="16" font-weight="600" fill="#ffffff" opacity="0.75">Play vs CPU</text>
|
||||||
|
|
||||||
|
<!-- hint -->
|
||||||
|
<text x="210" y="648" text-anchor="middle" font-size="12" fill="#ffffff" opacity="0.4">Online: share a link and play a friend</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,51 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="420" height="780" viewBox="0 0 420 780" font-family="system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bgGrad" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#0d0f24"/>
|
||||||
|
<stop offset="1" stop-color="#1c1140"/>
|
||||||
|
</linearGradient>
|
||||||
|
<linearGradient id="titleGrad" x1="0" y1="0" x2="1" y2="0">
|
||||||
|
<stop offset="0" stop-color="#22d3ee"/>
|
||||||
|
<stop offset="1" stop-color="#f472b6"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="blob" x="-80%" y="-80%" width="260%" height="260%">
|
||||||
|
<feGaussianBlur stdDeviation="55"/>
|
||||||
|
</filter>
|
||||||
|
<filter id="glow" x="-60%" y="-60%" width="220%" height="220%">
|
||||||
|
<feGaussianBlur stdDeviation="5" result="blur"/>
|
||||||
|
<feMerge>
|
||||||
|
<feMergeNode in="blur"/>
|
||||||
|
<feMergeNode in="SourceGraphic"/>
|
||||||
|
</feMerge>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<rect width="420" height="780" fill="url(#bgGrad)"/>
|
||||||
|
<circle cx="70" cy="170" r="130" fill="#22d3ee" opacity="0.22" filter="url(#blob)"/>
|
||||||
|
<circle cx="350" cy="580" r="150" fill="#f472b6" opacity="0.18" filter="url(#blob)"/>
|
||||||
|
|
||||||
|
<!-- header -->
|
||||||
|
<text x="210" y="120" text-anchor="middle" font-size="28" font-weight="800" fill="url(#titleGrad)">Invite a friend</text>
|
||||||
|
<text x="210" y="152" text-anchor="middle" font-size="14" fill="#ffffff" opacity="0.5">Share this link to start playing</text>
|
||||||
|
|
||||||
|
<!-- link card -->
|
||||||
|
<rect x="50" y="200" width="320" height="70" rx="16" fill="#ffffff" fill-opacity="0.06" stroke="#ffffff" stroke-opacity="0.16"/>
|
||||||
|
<text x="210" y="232" text-anchor="middle" font-size="14" fill="#22d3ee" font-family="monospace">https://game1.app/?token=a3f9c21b7e4d08f5</text>
|
||||||
|
<text x="210" y="254" text-anchor="middle" font-size="11" fill="#ffffff" opacity="0.4">Your invite link</text>
|
||||||
|
|
||||||
|
<!-- copy button -->
|
||||||
|
<rect x="110" y="300" width="200" height="50" rx="25" fill="url(#titleGrad)"/>
|
||||||
|
<text x="210" y="331" text-anchor="middle" font-size="15" font-weight="700" fill="#0d1025">Copy link</text>
|
||||||
|
|
||||||
|
<!-- waiting indicator -->
|
||||||
|
<g>
|
||||||
|
<circle cx="185" cy="430" r="8" fill="#22d3ee" filter="url(#glow)" opacity="0.9"/>
|
||||||
|
<circle cx="210" cy="430" r="8" fill="#22d3ee" filter="url(#glow)" opacity="0.55"/>
|
||||||
|
<circle cx="235" cy="430" r="8" fill="#22d3ee" filter="url(#glow)" opacity="0.3"/>
|
||||||
|
</g>
|
||||||
|
<text x="210" y="472" text-anchor="middle" font-size="15" fill="#ffffff" opacity="0.65">Waiting for opponent to join...</text>
|
||||||
|
|
||||||
|
<!-- back button -->
|
||||||
|
<rect x="130" y="640" width="160" height="48" rx="24" fill="#ffffff" fill-opacity="0.05" stroke="#ffffff" stroke-opacity="0.18"/>
|
||||||
|
<text x="210" y="670" text-anchor="middle" font-size="14" font-weight="600" fill="#ffffff" opacity="0.7">Back to menu</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -1,315 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
/* ---------- 1. DOM refs ---------- */
|
|
||||||
const boardEl = document.getElementById('board');
|
|
||||||
const statusEl = document.getElementById('status');
|
|
||||||
const statusTextEl = document.getElementById('status-text');
|
|
||||||
const overlayEl = document.getElementById('overlay');
|
|
||||||
const overlayTitleEl = document.getElementById('overlay-title');
|
|
||||||
const winLineEl = document.getElementById('win-line-el');
|
|
||||||
const confettiLayer = document.getElementById('confetti-layer');
|
|
||||||
const toastEl = document.getElementById('toast');
|
|
||||||
|
|
||||||
const scoreEls = {
|
|
||||||
you: document.getElementById('score-you'),
|
|
||||||
cpu: document.getElementById('score-cpu'),
|
|
||||||
draws: document.getElementById('score-draws'),
|
|
||||||
};
|
|
||||||
|
|
||||||
const cells = [];
|
|
||||||
|
|
||||||
/* ---------- state ---------- */
|
|
||||||
const state = {
|
|
||||||
board: Array(9).fill(null),
|
|
||||||
turn: 'X',
|
|
||||||
gameOver: false,
|
|
||||||
scores: { you: 0, cpu: 0, draws: 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
let cpuTimer = 0;
|
|
||||||
let toastTimer = 0;
|
|
||||||
|
|
||||||
const WIN_LINES = [
|
|
||||||
[0, 1, 2], [3, 4, 5], [6, 7, 8],
|
|
||||||
[0, 3, 6], [1, 4, 7], [2, 5, 8],
|
|
||||||
[0, 4, 8], [2, 4, 6],
|
|
||||||
];
|
|
||||||
|
|
||||||
const SCORES_KEY = 'ttt-scores';
|
|
||||||
const CPU_DELAY = 600;
|
|
||||||
|
|
||||||
const X_SVG =
|
|
||||||
'<svg viewBox="0 0 100 100" class="mark mark-x" aria-hidden="true">' +
|
|
||||||
'<line x1="24" y1="24" x2="76" y2="76"></line>' +
|
|
||||||
'<line x1="76" y1="24" x2="24" y2="76"></line>' +
|
|
||||||
'</svg>';
|
|
||||||
|
|
||||||
const O_SVG =
|
|
||||||
'<svg viewBox="0 0 100 100" class="mark mark-o" aria-hidden="true">' +
|
|
||||||
'<circle cx="50" cy="50" r="30"></circle>' +
|
|
||||||
'</svg>';
|
|
||||||
|
|
||||||
/* ---------- 2. Win detection ---------- */
|
|
||||||
function checkWinner(board) {
|
|
||||||
for (const line of WIN_LINES) {
|
|
||||||
const [a, b, c] = line;
|
|
||||||
if (board[a] && board[a] === board[b] && board[a] === board[c]) {
|
|
||||||
return { winner: board[a], line };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (board.every((v) => v !== null)) return { winner: 'draw' };
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 3. Player move ---------- */
|
|
||||||
function onCellTap(i) {
|
|
||||||
if (state.gameOver || state.turn !== 'X' || state.board[i]) return;
|
|
||||||
placeMark(i, 'X');
|
|
||||||
const result = checkWinner(state.board);
|
|
||||||
if (result) {
|
|
||||||
endRound(result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.turn = 'O';
|
|
||||||
renderStatus('CPU is thinking…', 'thinking');
|
|
||||||
cpuTimer = setTimeout(cpuMove, CPU_DELAY);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 4. CPU move ---------- */
|
|
||||||
function cpuMove() {
|
|
||||||
if (state.gameOver) return;
|
|
||||||
const empty = [];
|
|
||||||
state.board.forEach((v, i) => {
|
|
||||||
if (v === null) empty.push(i);
|
|
||||||
});
|
|
||||||
if (!empty.length) return;
|
|
||||||
const i = empty[Math.floor(Math.random() * empty.length)];
|
|
||||||
placeMark(i, 'O');
|
|
||||||
const result = checkWinner(state.board);
|
|
||||||
if (result) {
|
|
||||||
endRound(result);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.turn = 'X';
|
|
||||||
renderStatus('Your turn', 'normal');
|
|
||||||
}
|
|
||||||
|
|
||||||
function placeMark(i, mark) {
|
|
||||||
state.board[i] = mark;
|
|
||||||
renderMark(i, mark);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 5. Render ---------- */
|
|
||||||
function renderMark(i, mark) {
|
|
||||||
const cell = cells[i];
|
|
||||||
cell.classList.remove('empty');
|
|
||||||
cell.classList.add(mark === 'X' ? 'has-x' : 'has-o');
|
|
||||||
cell.setAttribute('aria-label', mark === 'X' ? 'X' : 'O');
|
|
||||||
cell.innerHTML = mark === 'X' ? X_SVG : O_SVG;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderStatus(text, mode) {
|
|
||||||
mode = mode || 'normal';
|
|
||||||
statusEl.classList.toggle('thinking', mode === 'thinking');
|
|
||||||
if (statusTextEl.textContent === text) return;
|
|
||||||
statusEl.classList.add('fading');
|
|
||||||
setTimeout(() => {
|
|
||||||
statusTextEl.textContent = text;
|
|
||||||
statusEl.classList.remove('fading');
|
|
||||||
}, 150);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderScores(changed) {
|
|
||||||
for (const k of ['you', 'cpu', 'draws']) {
|
|
||||||
scoreEls[k].textContent = state.scores[k];
|
|
||||||
if (k === changed) {
|
|
||||||
scoreEls[k].classList.remove('pop');
|
|
||||||
void scoreEls[k].offsetWidth;
|
|
||||||
scoreEls[k].classList.add('pop');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cellCenter(i, boardRect) {
|
|
||||||
const r = cells[i].getBoundingClientRect();
|
|
||||||
return {
|
|
||||||
x: r.left + r.width / 2 - boardRect.left,
|
|
||||||
y: r.top + r.height / 2 - boardRect.top,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function drawWinLine(line) {
|
|
||||||
const boardRect = boardEl.getBoundingClientRect();
|
|
||||||
const p1 = cellCenter(line[0], boardRect);
|
|
||||||
const p2 = cellCenter(line[2], boardRect);
|
|
||||||
const dx = p2.x - p1.x;
|
|
||||||
const dy = p2.y - p1.y;
|
|
||||||
const len = Math.hypot(dx, dy) || 1;
|
|
||||||
const ext = 16;
|
|
||||||
const ux = dx / len;
|
|
||||||
const uy = dy / len;
|
|
||||||
const x1 = p1.x - ux * ext;
|
|
||||||
const y1 = p1.y - uy * ext;
|
|
||||||
const x2 = p2.x + ux * ext;
|
|
||||||
const y2 = p2.y + uy * ext;
|
|
||||||
|
|
||||||
winLineEl.setAttribute('x1', x1);
|
|
||||||
winLineEl.setAttribute('y1', y1);
|
|
||||||
winLineEl.setAttribute('x2', x2);
|
|
||||||
winLineEl.setAttribute('y2', y2);
|
|
||||||
|
|
||||||
const total = Math.hypot(x2 - x1, y2 - y1);
|
|
||||||
winLineEl.style.transition = 'none';
|
|
||||||
winLineEl.style.strokeDasharray = total;
|
|
||||||
winLineEl.style.strokeDashoffset = total;
|
|
||||||
winLineEl.style.opacity = '1';
|
|
||||||
void winLineEl.getBoundingClientRect();
|
|
||||||
winLineEl.style.transition = 'stroke-dashoffset 400ms ease';
|
|
||||||
winLineEl.style.strokeDashoffset = '0';
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearWinLine() {
|
|
||||||
winLineEl.style.transition = 'none';
|
|
||||||
winLineEl.style.opacity = '0';
|
|
||||||
winLineEl.style.strokeDashoffset = winLineEl.style.strokeDasharray;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 6. Round end ---------- */
|
|
||||||
function endRound(result) {
|
|
||||||
state.gameOver = true;
|
|
||||||
let title;
|
|
||||||
let key;
|
|
||||||
if (result.winner === 'X') {
|
|
||||||
key = 'you';
|
|
||||||
title = 'You Win! 🎉';
|
|
||||||
renderStatus('You win! 🎉', 'normal');
|
|
||||||
} else if (result.winner === 'O') {
|
|
||||||
key = 'cpu';
|
|
||||||
title = 'CPU Wins';
|
|
||||||
renderStatus('CPU wins', 'normal');
|
|
||||||
boardEl.classList.add('shake');
|
|
||||||
} else {
|
|
||||||
key = 'draws';
|
|
||||||
title = "It's a Draw";
|
|
||||||
renderStatus("It's a draw", 'normal');
|
|
||||||
boardEl.classList.add('pulse');
|
|
||||||
}
|
|
||||||
|
|
||||||
state.scores[key]++;
|
|
||||||
persistScores();
|
|
||||||
renderScores(key);
|
|
||||||
|
|
||||||
if (result.line) drawWinLine(result.line);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
overlayTitleEl.textContent = title;
|
|
||||||
overlayEl.classList.add('show');
|
|
||||||
if (result.winner === 'X') burstConfetti();
|
|
||||||
}, result.winner === 'draw' ? 400 : 600);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 7. Confetti ---------- */
|
|
||||||
const CONFETTI_COLORS = ['#22d3ee', '#f472b6', '#a78bfa', '#fbbf24', '#34d399', '#ffffff'];
|
|
||||||
|
|
||||||
function burstConfetti() {
|
|
||||||
const pieces = [];
|
|
||||||
for (let i = 0; i < 40; i++) {
|
|
||||||
const el = document.createElement('div');
|
|
||||||
el.className = 'confetti';
|
|
||||||
const size = 6 + Math.random() * 8;
|
|
||||||
el.style.width = size + 'px';
|
|
||||||
el.style.height = size * 1.4 + 'px';
|
|
||||||
el.style.left = Math.random() * 100 + 'vw';
|
|
||||||
el.style.background = CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)];
|
|
||||||
el.style.setProperty('--drift', (Math.random() * 160 - 80) + 'px');
|
|
||||||
el.style.setProperty('--spin', (360 + Math.random() * 720) + 'deg');
|
|
||||||
el.style.animationDuration = 1.3 + Math.random() * 0.5 + 's';
|
|
||||||
el.style.animationDelay = Math.random() * 0.25 + 's';
|
|
||||||
confettiLayer.appendChild(el);
|
|
||||||
pieces.push(el);
|
|
||||||
}
|
|
||||||
setTimeout(() => pieces.forEach((p) => p.remove()), 2200);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 8. Reset ---------- */
|
|
||||||
function newRound() {
|
|
||||||
clearTimeout(cpuTimer);
|
|
||||||
state.board = Array(9).fill(null);
|
|
||||||
state.turn = 'X';
|
|
||||||
state.gameOver = false;
|
|
||||||
cells.forEach((cell) => {
|
|
||||||
cell.innerHTML = '';
|
|
||||||
cell.classList.add('empty');
|
|
||||||
cell.classList.remove('has-x', 'has-o');
|
|
||||||
cell.setAttribute('aria-label', 'Empty cell');
|
|
||||||
});
|
|
||||||
clearWinLine();
|
|
||||||
boardEl.classList.remove('shake', 'pulse');
|
|
||||||
overlayEl.classList.remove('show');
|
|
||||||
confettiLayer.innerHTML = '';
|
|
||||||
renderStatus('Your turn', 'normal');
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetScores() {
|
|
||||||
state.scores = { you: 0, cpu: 0, draws: 0 };
|
|
||||||
persistScores();
|
|
||||||
renderScores();
|
|
||||||
showToast('Scores reset');
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistScores() {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(SCORES_KEY, JSON.stringify(state.scores));
|
|
||||||
} catch (e) {
|
|
||||||
/* storage unavailable */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadScores() {
|
|
||||||
try {
|
|
||||||
const raw = localStorage.getItem(SCORES_KEY);
|
|
||||||
if (raw) {
|
|
||||||
const s = JSON.parse(raw);
|
|
||||||
state.scores = {
|
|
||||||
you: Number(s.you) || 0,
|
|
||||||
cpu: Number(s.cpu) || 0,
|
|
||||||
draws: Number(s.draws) || 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
/* corrupted storage — keep defaults */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(msg) {
|
|
||||||
toastEl.textContent = msg;
|
|
||||||
toastEl.classList.add('show');
|
|
||||||
clearTimeout(toastTimer);
|
|
||||||
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 1600);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- 9. Init ---------- */
|
|
||||||
function buildBoard() {
|
|
||||||
for (let i = 0; i < 9; i++) {
|
|
||||||
const cell = document.createElement('button');
|
|
||||||
cell.type = 'button';
|
|
||||||
cell.className = 'cell empty';
|
|
||||||
cell.dataset.index = String(i);
|
|
||||||
cell.setAttribute('aria-label', 'Empty cell');
|
|
||||||
cell.addEventListener('click', () => onCellTap(i));
|
|
||||||
boardEl.appendChild(cell);
|
|
||||||
cells.push(cell);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function init() {
|
|
||||||
buildBoard();
|
|
||||||
loadScores();
|
|
||||||
renderScores();
|
|
||||||
document.getElementById('btn-new').addEventListener('click', newRound);
|
|
||||||
document.getElementById('btn-reset').addEventListener('click', resetScores);
|
|
||||||
document.getElementById('btn-again').addEventListener('click', newRound);
|
|
||||||
renderStatus('Your turn', 'normal');
|
|
||||||
}
|
|
||||||
|
|
||||||
init();
|
|
||||||
-67
@@ -1,67 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
||||||
<meta name="theme-color" content="#0d0f24">
|
|
||||||
<title>Tic-Tac-Toe</title>
|
|
||||||
<link rel="stylesheet" href="style.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="bg-blob blob-cyan" aria-hidden="true"></div>
|
|
||||||
<div class="bg-blob blob-magenta" aria-hidden="true"></div>
|
|
||||||
|
|
||||||
<main class="app">
|
|
||||||
<h1 class="title">Tic-Tac-Toe</h1>
|
|
||||||
|
|
||||||
<div class="scoreboard">
|
|
||||||
<div class="chip" id="chip-you">
|
|
||||||
<span class="chip-label">You (X)</span>
|
|
||||||
<span class="chip-value chip-x" id="score-you">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="chip" id="chip-cpu">
|
|
||||||
<span class="chip-label">CPU (O)</span>
|
|
||||||
<span class="chip-value chip-o" id="score-cpu">0</span>
|
|
||||||
</div>
|
|
||||||
<div class="chip" id="chip-draws">
|
|
||||||
<span class="chip-label">Draws</span>
|
|
||||||
<span class="chip-value" id="score-draws">0</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status" id="status">
|
|
||||||
<span class="status-text" id="status-text">Your turn</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="board-wrap">
|
|
||||||
<div class="board" id="board" aria-label="Tic-tac-toe board"></div>
|
|
||||||
<svg class="win-line" id="win-line" aria-hidden="true">
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="win-grad" x1="0" y1="0" x2="1" y2="1">
|
|
||||||
<stop offset="0" stop-color="#22d3ee"></stop>
|
|
||||||
<stop offset="1" stop-color="#f472b6"></stop>
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
<line id="win-line-el" stroke="url(#win-grad)" stroke-width="6" stroke-linecap="round" opacity="0"></line>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="buttons">
|
|
||||||
<button class="btn primary" id="btn-new" type="button">New Game</button>
|
|
||||||
<button class="btn ghost" id="btn-reset" type="button">Reset Scores</button>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
<div class="overlay" id="overlay" aria-hidden="true">
|
|
||||||
<div class="modal" role="dialog" aria-modal="true">
|
|
||||||
<div class="modal-title" id="overlay-title">You Win! 🎉</div>
|
|
||||||
<button class="btn primary" id="btn-again" type="button">Play Again</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="confetti-layer" aria-hidden="true"></div>
|
|
||||||
<div class="toast" id="toast" aria-live="polite"></div>
|
|
||||||
|
|
||||||
<script src="game.js"></script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
+305
@@ -0,0 +1,305 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BASE = location.pathname === '/' ? '' : location.pathname.replace(/\/$/, '');
|
||||||
|
|
||||||
|
const App = (() => {
|
||||||
|
const boardEl = document.getElementById('board');
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
const statusTextEl = document.getElementById('status-text');
|
||||||
|
const overlayEl = document.getElementById('overlay');
|
||||||
|
const overlayTitleEl = document.getElementById('overlay-title');
|
||||||
|
const winLineEl = document.getElementById('win-line-el');
|
||||||
|
const confettiLayer = document.getElementById('confetti-layer');
|
||||||
|
const toastEl = document.getElementById('toast');
|
||||||
|
|
||||||
|
const scoreEls = {
|
||||||
|
you: document.getElementById('score-you'),
|
||||||
|
opponent: document.getElementById('score-opponent'),
|
||||||
|
draws: document.getElementById('score-draws'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelYou = document.getElementById('label-you');
|
||||||
|
const labelOpponent = document.getElementById('label-opponent');
|
||||||
|
|
||||||
|
const cells = [];
|
||||||
|
let toastTimer = 0;
|
||||||
|
let activeMode = null;
|
||||||
|
|
||||||
|
const WIN_LINES = [
|
||||||
|
[0, 1, 2], [3, 4, 5], [6, 7, 8],
|
||||||
|
[0, 3, 6], [1, 4, 7], [2, 5, 8],
|
||||||
|
[0, 4, 8], [2, 4, 6],
|
||||||
|
];
|
||||||
|
|
||||||
|
const X_SVG =
|
||||||
|
'<svg viewBox="0 0 100 100" class="mark mark-x" aria-hidden="true">' +
|
||||||
|
'<line x1="24" y1="24" x2="76" y2="76"></line>' +
|
||||||
|
'<line x1="76" y1="24" x2="24" y2="76"></line>' +
|
||||||
|
'</svg>';
|
||||||
|
|
||||||
|
const O_SVG =
|
||||||
|
'<svg viewBox="0 0 100 100" class="mark mark-o" aria-hidden="true">' +
|
||||||
|
'<circle cx="50" cy="50" r="30"></circle>' +
|
||||||
|
'</svg>';
|
||||||
|
|
||||||
|
function checkWinner(board) {
|
||||||
|
for (const line of WIN_LINES) {
|
||||||
|
const [a, b, c] = line;
|
||||||
|
if (board[a] && board[a] === board[b] && board[a] === board[c]) {
|
||||||
|
return { winner: board[a], line };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (board.every((v) => v !== null)) return { winner: 'draw' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showScreen(name) {
|
||||||
|
document.getElementById('screen-menu').style.display = name === 'menu' ? '' : 'none';
|
||||||
|
document.getElementById('screen-waiting').style.display = name === 'waiting' ? '' : 'none';
|
||||||
|
document.getElementById('screen-game').style.display = name === 'game' ? '' : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBoard() {
|
||||||
|
boardEl.innerHTML = '';
|
||||||
|
cells.length = 0;
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
const cell = document.createElement('button');
|
||||||
|
cell.type = 'button';
|
||||||
|
cell.className = 'cell empty';
|
||||||
|
cell.dataset.index = String(i);
|
||||||
|
cell.setAttribute('aria-label', 'Empty cell');
|
||||||
|
cell.addEventListener('click', () => onCellTap(i));
|
||||||
|
boardEl.appendChild(cell);
|
||||||
|
cells.push(cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCellTap(i) {
|
||||||
|
if (activeMode === 'cpu') CpuGame.handleMove(i);
|
||||||
|
if (activeMode === 'online') Multiplayer.handleMove(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMark(i, mark) {
|
||||||
|
const cell = cells[i];
|
||||||
|
cell.classList.remove('empty');
|
||||||
|
cell.classList.add(mark === 'X' ? 'has-x' : 'has-o');
|
||||||
|
cell.setAttribute('aria-label', mark === 'X' ? 'X' : 'O');
|
||||||
|
cell.innerHTML = mark === 'X' ? X_SVG : O_SVG;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatus(text, mode) {
|
||||||
|
mode = mode || 'normal';
|
||||||
|
statusEl.classList.toggle('thinking', mode === 'thinking');
|
||||||
|
if (statusTextEl.textContent === text) return;
|
||||||
|
statusEl.classList.add('fading');
|
||||||
|
setTimeout(() => {
|
||||||
|
statusTextEl.textContent = text;
|
||||||
|
statusEl.classList.remove('fading');
|
||||||
|
}, 150);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScores(scores) {
|
||||||
|
scoreEls.you.textContent = scores.you || 0;
|
||||||
|
scoreEls.opponent.textContent = scores.opponent || 0;
|
||||||
|
scoreEls.draws.textContent = scores.draws || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cellCenter(i, boardRect) {
|
||||||
|
const r = cells[i].getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: r.left + r.width / 2 - boardRect.left,
|
||||||
|
y: r.top + r.height / 2 - boardRect.top,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWinLine(line) {
|
||||||
|
const boardRect = boardEl.getBoundingClientRect();
|
||||||
|
const p1 = cellCenter(line[0], boardRect);
|
||||||
|
const p2 = cellCenter(line[2], boardRect);
|
||||||
|
const dx = p2.x - p1.x;
|
||||||
|
const dy = p2.y - p1.y;
|
||||||
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
|
const ext = 16;
|
||||||
|
const ux = dx / len;
|
||||||
|
const uy = dy / len;
|
||||||
|
const x1 = p1.x - ux * ext;
|
||||||
|
const y1 = p1.y - uy * ext;
|
||||||
|
const x2 = p2.x + ux * ext;
|
||||||
|
const y2 = p2.y + uy * ext;
|
||||||
|
|
||||||
|
winLineEl.setAttribute('x1', x1);
|
||||||
|
winLineEl.setAttribute('y1', y1);
|
||||||
|
winLineEl.setAttribute('x2', x2);
|
||||||
|
winLineEl.setAttribute('y2', y2);
|
||||||
|
|
||||||
|
const total = Math.hypot(x2 - x1, y2 - y1);
|
||||||
|
winLineEl.style.transition = 'none';
|
||||||
|
winLineEl.style.strokeDasharray = total;
|
||||||
|
winLineEl.style.strokeDashoffset = total;
|
||||||
|
winLineEl.style.opacity = '1';
|
||||||
|
void winLineEl.getBoundingClientRect();
|
||||||
|
winLineEl.style.transition = 'stroke-dashoffset 400ms ease';
|
||||||
|
winLineEl.style.strokeDashoffset = '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearWinLine() {
|
||||||
|
winLineEl.style.transition = 'none';
|
||||||
|
winLineEl.style.opacity = '0';
|
||||||
|
winLineEl.style.strokeDashoffset = winLineEl.style.strokeDasharray;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showOverlay(title) {
|
||||||
|
overlayTitleEl.textContent = title;
|
||||||
|
overlayEl.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideOverlay() {
|
||||||
|
overlayEl.classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFETTI_COLORS = ['#22d3ee', '#f472b6', '#a78bfa', '#fbbf24', '#34d399', '#ffffff'];
|
||||||
|
|
||||||
|
function burstConfetti() {
|
||||||
|
const pieces = [];
|
||||||
|
for (let i = 0; i < 40; i++) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'confetti';
|
||||||
|
const size = 6 + Math.random() * 8;
|
||||||
|
el.style.width = size + 'px';
|
||||||
|
el.style.height = size * 1.4 + 'px';
|
||||||
|
el.style.left = Math.random() * 100 + 'vw';
|
||||||
|
el.style.background = CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)];
|
||||||
|
el.style.setProperty('--drift', (Math.random() * 160 - 80) + 'px');
|
||||||
|
el.style.setProperty('--spin', (360 + Math.random() * 720) + 'deg');
|
||||||
|
el.style.animationDuration = 1.3 + Math.random() * 0.5 + 's';
|
||||||
|
el.style.animationDelay = Math.random() * 0.25 + 's';
|
||||||
|
confettiLayer.appendChild(el);
|
||||||
|
pieces.push(el);
|
||||||
|
}
|
||||||
|
setTimeout(() => pieces.forEach((p) => p.remove()), 2200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBoard() {
|
||||||
|
cells.forEach((cell) => {
|
||||||
|
cell.innerHTML = '';
|
||||||
|
cell.classList.add('empty');
|
||||||
|
cell.classList.remove('has-x', 'has-o');
|
||||||
|
cell.setAttribute('aria-label', 'Empty cell');
|
||||||
|
});
|
||||||
|
clearWinLine();
|
||||||
|
boardEl.classList.remove('shake', 'pulse');
|
||||||
|
hideOverlay();
|
||||||
|
confettiLayer.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg) {
|
||||||
|
toastEl.textContent = msg;
|
||||||
|
toastEl.classList.add('show');
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => toastEl.classList.remove('show'), 1600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setScoreboardLabels(youLabel, oppLabel) {
|
||||||
|
labelYou.textContent = youLabel;
|
||||||
|
labelOpponent.textContent = oppLabel;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCpuMode() {
|
||||||
|
activeMode = 'cpu';
|
||||||
|
showScreen('game');
|
||||||
|
setScoreboardLabels('You (X)', 'CPU (O)');
|
||||||
|
CpuGame.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOnlineHost({ id, token, link }) {
|
||||||
|
activeMode = 'online';
|
||||||
|
showScreen('waiting');
|
||||||
|
document.getElementById('link-text').textContent = link;
|
||||||
|
Multiplayer.init({ asHost: true, id, token, link });
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOnlineJoiner(token) {
|
||||||
|
activeMode = 'online';
|
||||||
|
showScreen('game');
|
||||||
|
setScoreboardLabels('Opponent (X)', 'You (O)');
|
||||||
|
renderStatus('Connecting…', 'thinking');
|
||||||
|
Multiplayer.init({ asJoiner: true, token });
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToMenu() {
|
||||||
|
if (activeMode === 'cpu') CpuGame.destroy();
|
||||||
|
if (activeMode === 'online') Multiplayer.destroy();
|
||||||
|
activeMode = null;
|
||||||
|
clearBoard();
|
||||||
|
hideOverlay();
|
||||||
|
showScreen('menu');
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
buildBoard();
|
||||||
|
|
||||||
|
document.getElementById('btn-cpu').addEventListener('click', startCpuMode);
|
||||||
|
document.getElementById('btn-online').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(BASE + '/api/games', { method: 'POST' });
|
||||||
|
if (!res.ok) throw new Error('Server returned ' + res.status);
|
||||||
|
const data = await res.json();
|
||||||
|
startOnlineHost(data);
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Error: ' + e.message);
|
||||||
|
console.error('Create game failed:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-copy').addEventListener('click', async () => {
|
||||||
|
const link = document.getElementById('link-text').textContent;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(link);
|
||||||
|
showToast('Link copied!');
|
||||||
|
} catch (e) {
|
||||||
|
prompt('Copy this link:', link);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('btn-back-menu-waiting').addEventListener('click', backToMenu);
|
||||||
|
document.getElementById('btn-new-game').addEventListener('click', () => {
|
||||||
|
if (activeMode === 'cpu') CpuGame.newRound();
|
||||||
|
if (activeMode === 'online') Multiplayer.newRound();
|
||||||
|
});
|
||||||
|
document.getElementById('btn-again-overlay').addEventListener('click', () => {
|
||||||
|
if (activeMode === 'cpu') CpuGame.newRound();
|
||||||
|
if (activeMode === 'online') Multiplayer.newRound();
|
||||||
|
});
|
||||||
|
document.getElementById('btn-reset').addEventListener('click', () => {
|
||||||
|
if (activeMode === 'cpu') CpuGame.resetScores();
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = new URLSearchParams(location.search).get('token');
|
||||||
|
if (token) {
|
||||||
|
startOnlineJoiner(token);
|
||||||
|
} else {
|
||||||
|
showScreen('menu');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
showScreen,
|
||||||
|
renderMark,
|
||||||
|
renderStatus,
|
||||||
|
renderScores,
|
||||||
|
drawWinLine,
|
||||||
|
clearWinLine,
|
||||||
|
showOverlay,
|
||||||
|
hideOverlay,
|
||||||
|
burstConfetti,
|
||||||
|
clearBoard,
|
||||||
|
showToast,
|
||||||
|
setScoreboardLabels,
|
||||||
|
checkWinner,
|
||||||
|
WIN_LINES,
|
||||||
|
get activeMode() { return activeMode; },
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
App.init();
|
||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const CpuGame = (() => {
|
||||||
|
const SCORES_KEY = 'ttt-scores';
|
||||||
|
const CPU_DELAY = 600;
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
board: Array(9).fill(null),
|
||||||
|
turn: 'X',
|
||||||
|
gameOver: false,
|
||||||
|
scores: { you: 0, opponent: 0, draws: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
let cpuTimer = 0;
|
||||||
|
|
||||||
|
function persistScores() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SCORES_KEY, JSON.stringify(state.scores));
|
||||||
|
} catch (e) { /* storage unavailable */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadScores() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(SCORES_KEY);
|
||||||
|
if (raw) {
|
||||||
|
const s = JSON.parse(raw);
|
||||||
|
state.scores = {
|
||||||
|
you: Number(s.you) || 0,
|
||||||
|
opponent: Number(s.cpu || s.opponent) || 0,
|
||||||
|
draws: Number(s.draws) || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) { /* corrupted storage */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMove(i) {
|
||||||
|
if (state.gameOver || state.turn !== 'X' || state.board[i]) return;
|
||||||
|
state.board[i] = 'X';
|
||||||
|
App.renderMark(i, 'X');
|
||||||
|
const result = App.checkWinner(state.board);
|
||||||
|
if (result) {
|
||||||
|
endRound(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.turn = 'O';
|
||||||
|
App.renderStatus('CPU is thinking…', 'thinking');
|
||||||
|
cpuTimer = setTimeout(cpuMove, CPU_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cpuMove() {
|
||||||
|
if (state.gameOver) return;
|
||||||
|
const empty = [];
|
||||||
|
state.board.forEach((v, i) => { if (v === null) empty.push(i); });
|
||||||
|
if (!empty.length) return;
|
||||||
|
const i = empty[Math.floor(Math.random() * empty.length)];
|
||||||
|
state.board[i] = 'O';
|
||||||
|
App.renderMark(i, 'O');
|
||||||
|
const result = App.checkWinner(state.board);
|
||||||
|
if (result) {
|
||||||
|
endRound(result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.turn = 'X';
|
||||||
|
App.renderStatus('Your turn', 'normal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function endRound(result) {
|
||||||
|
state.gameOver = true;
|
||||||
|
let title;
|
||||||
|
let key;
|
||||||
|
if (result.winner === 'X') {
|
||||||
|
key = 'you';
|
||||||
|
title = 'You Win!';
|
||||||
|
App.renderStatus('You win!', 'normal');
|
||||||
|
} else if (result.winner === 'O') {
|
||||||
|
key = 'opponent';
|
||||||
|
title = 'CPU Wins';
|
||||||
|
App.renderStatus('CPU wins', 'normal');
|
||||||
|
document.getElementById('board').classList.add('shake');
|
||||||
|
} else {
|
||||||
|
key = 'draws';
|
||||||
|
title = "It's a Draw";
|
||||||
|
App.renderStatus("It's a draw", 'normal');
|
||||||
|
document.getElementById('board').classList.add('pulse');
|
||||||
|
}
|
||||||
|
|
||||||
|
state.scores[key]++;
|
||||||
|
persistScores();
|
||||||
|
App.renderScores(state.scores);
|
||||||
|
|
||||||
|
if (result.line) App.drawWinLine(result.line);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
App.showOverlay(title);
|
||||||
|
if (result.winner === 'X') App.burstConfetti();
|
||||||
|
}, result.winner === 'draw' ? 400 : 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
function newRound() {
|
||||||
|
clearTimeout(cpuTimer);
|
||||||
|
state.board = Array(9).fill(null);
|
||||||
|
state.turn = 'X';
|
||||||
|
state.gameOver = false;
|
||||||
|
App.clearBoard();
|
||||||
|
App.renderStatus('Your turn', 'normal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetScores() {
|
||||||
|
state.scores = { you: 0, opponent: 0, draws: 0 };
|
||||||
|
persistScores();
|
||||||
|
App.renderScores(state.scores);
|
||||||
|
App.showToast('Scores reset');
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
clearTimeout(cpuTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
clearTimeout(cpuTimer);
|
||||||
|
App.clearBoard();
|
||||||
|
state.board = Array(9).fill(null);
|
||||||
|
state.turn = 'X';
|
||||||
|
state.gameOver = false;
|
||||||
|
loadScores();
|
||||||
|
App.renderScores(state.scores);
|
||||||
|
App.renderStatus('Your turn', 'normal');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init, handleMove, newRound, resetScores, destroy };
|
||||||
|
})();
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#0d0f24">
|
||||||
|
<title>Tic-Tac-Toe</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="bg-blob blob-cyan" aria-hidden="true"></div>
|
||||||
|
<div class="bg-blob blob-magenta" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<main class="app">
|
||||||
|
|
||||||
|
<section id="screen-menu" class="screen">
|
||||||
|
<h1 class="title">Tic-Tac-Toe</h1>
|
||||||
|
<div class="menu-buttons">
|
||||||
|
<button class="btn ghost" id="btn-cpu" type="button">Play vs CPU</button>
|
||||||
|
<button class="btn primary" id="btn-online" type="button">New Online Game</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screen-waiting" class="screen" style="display:none">
|
||||||
|
<h1 class="title">Share this link</h1>
|
||||||
|
<p class="waiting-subtitle">Send this link to your opponent</p>
|
||||||
|
<div class="link-box" id="link-box">
|
||||||
|
<span id="link-text"></span>
|
||||||
|
</div>
|
||||||
|
<button class="btn primary" id="btn-copy" type="button">Copy Link</button>
|
||||||
|
<div class="status waiting-pulse" id="waiting-status">
|
||||||
|
<span class="status-text">Waiting for opponent…</span>
|
||||||
|
</div>
|
||||||
|
<button class="btn ghost" id="btn-back-menu-waiting" type="button">Back to Menu</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="screen-game" class="screen" style="display:none">
|
||||||
|
<div class="scoreboard">
|
||||||
|
<div class="chip" id="chip-you">
|
||||||
|
<span class="chip-label" id="label-you">You (X)</span>
|
||||||
|
<span class="chip-value chip-x" id="score-you">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="chip" id="chip-opponent">
|
||||||
|
<span class="chip-label" id="label-opponent">CPU (O)</span>
|
||||||
|
<span class="chip-value chip-o" id="score-opponent">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="chip" id="chip-draws">
|
||||||
|
<span class="chip-label">Draws</span>
|
||||||
|
<span class="chip-value" id="score-draws">0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status" id="status">
|
||||||
|
<span class="status-text" id="status-text">Your turn</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="board-wrap">
|
||||||
|
<div class="board" id="board" aria-label="Tic-tac-toe board"></div>
|
||||||
|
<svg class="win-line" id="win-line" aria-hidden="true">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="win-grad" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#22d3ee"></stop>
|
||||||
|
<stop offset="1" stop-color="#f472b6"></stop>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<line id="win-line-el" stroke="url(#win-grad)" stroke-width="6" stroke-linecap="round" opacity="0"></line>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<button class="btn" id="btn-reset" type="button">Reset Scores</button>
|
||||||
|
<button class="btn primary" id="btn-new-game" type="button">New Game</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="overlay" id="overlay" aria-hidden="true">
|
||||||
|
<div class="modal" role="dialog" aria-modal="true">
|
||||||
|
<div class="modal-title" id="overlay-title">You Win!</div>
|
||||||
|
<button class="btn primary" id="btn-again-overlay" type="button">Play Again</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="confetti-layer" aria-hidden="true"></div>
|
||||||
|
<div class="toast" id="toast" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<script src="game.js"></script>
|
||||||
|
<script src="multiplayer.js"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const Multiplayer = (() => {
|
||||||
|
let ws = null;
|
||||||
|
let asHost = false;
|
||||||
|
let gameId = null;
|
||||||
|
let myMark = null;
|
||||||
|
let myTurn = false;
|
||||||
|
let board = Array(9).fill(null);
|
||||||
|
let gameOver = false;
|
||||||
|
let scores = { you: 0, opponent: 0, draws: 0 };
|
||||||
|
|
||||||
|
function connect(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
ws = new WebSocket(url);
|
||||||
|
ws.onopen = () => {
|
||||||
|
if (asHost) {
|
||||||
|
ws.send(JSON.stringify({ type: 'host', id: gameId }));
|
||||||
|
} else {
|
||||||
|
ws.send(JSON.stringify({ type: 'join', token: gameId }));
|
||||||
|
}
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
ws.onerror = (e) => {
|
||||||
|
App.showToast('Connection failed');
|
||||||
|
reject(e);
|
||||||
|
};
|
||||||
|
ws.onclose = () => {
|
||||||
|
if (!gameOver) {
|
||||||
|
App.renderStatus('Connection lost', 'normal');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
handleMessage(msg);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMessage(msg) {
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'joined':
|
||||||
|
myMark = msg.mark;
|
||||||
|
myTurn = msg.mark === 'X';
|
||||||
|
App.setScoreboardLabels(
|
||||||
|
myMark === 'X' ? 'You (X)' : 'Opponent (X)',
|
||||||
|
myMark === 'O' ? 'You (O)' : 'Opponent (O)'
|
||||||
|
);
|
||||||
|
App.renderStatus(myTurn ? 'Your turn' : 'Waiting for opponent…', myTurn ? 'normal' : 'thinking');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'opponent-joined':
|
||||||
|
App.showScreen('game');
|
||||||
|
App.setScoreboardLabels(
|
||||||
|
myMark === 'X' ? 'You (X)' : 'Opponent (X)',
|
||||||
|
myMark === 'O' ? 'You (O)' : 'Opponent (O)'
|
||||||
|
);
|
||||||
|
App.renderStatus('Your turn', 'normal');
|
||||||
|
myTurn = true;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'start':
|
||||||
|
board = Array(9).fill(null);
|
||||||
|
gameOver = false;
|
||||||
|
myMark = msg.mark;
|
||||||
|
myTurn = msg.first === myMark;
|
||||||
|
App.setScoreboardLabels(
|
||||||
|
myMark === 'X' ? 'You (X)' : 'Opponent (X)',
|
||||||
|
myMark === 'O' ? 'You (O)' : 'Opponent (O)'
|
||||||
|
);
|
||||||
|
App.clearBoard();
|
||||||
|
App.renderStatus(myTurn ? 'Your turn' : 'Waiting for opponent…', myTurn ? 'normal' : 'thinking');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'move': {
|
||||||
|
board[msg.index] = msg.mark;
|
||||||
|
App.renderMark(msg.index, msg.mark);
|
||||||
|
if (!gameOver && msg.mark !== myMark) {
|
||||||
|
myTurn = !myTurn;
|
||||||
|
App.renderStatus(myTurn ? 'Your turn' : 'Opponent is thinking…', myTurn ? 'normal' : 'thinking');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'end':
|
||||||
|
gameOver = true;
|
||||||
|
if (msg.winner === 'draw') {
|
||||||
|
scores.draws++;
|
||||||
|
App.renderStatus("It's a draw", 'normal');
|
||||||
|
document.getElementById('board').classList.add('pulse');
|
||||||
|
} else {
|
||||||
|
const iWon = msg.winner === myMark;
|
||||||
|
scores[iWon ? 'you' : 'opponent']++;
|
||||||
|
App.renderStatus(iWon ? 'You win!' : 'Opponent wins', 'normal');
|
||||||
|
if (!iWon) document.getElementById('board').classList.add('shake');
|
||||||
|
}
|
||||||
|
App.renderScores(scores);
|
||||||
|
if (msg.line) App.drawWinLine(msg.line);
|
||||||
|
setTimeout(() => {
|
||||||
|
const title = msg.winner === 'draw' ? "It's a Draw" : (msg.winner === myMark ? 'You Win!' : 'Opponent Wins');
|
||||||
|
App.showOverlay(title);
|
||||||
|
if (msg.winner === myMark) App.burstConfetti();
|
||||||
|
}, 600);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'new-round':
|
||||||
|
board = Array(9).fill(null);
|
||||||
|
gameOver = false;
|
||||||
|
myTurn = msg.first === myMark;
|
||||||
|
App.hideOverlay();
|
||||||
|
App.clearBoard();
|
||||||
|
App.renderStatus(myTurn ? 'Your turn' : 'Waiting for opponent…', myTurn ? 'normal' : 'thinking');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'opponent-left':
|
||||||
|
gameOver = true;
|
||||||
|
App.renderStatus('Opponent left', 'error');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
App.showToast(msg.message || 'Error');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMove(i) {
|
||||||
|
if (gameOver || !myTurn || board[i] || myMark === null) return;
|
||||||
|
board[i] = myMark;
|
||||||
|
App.renderMark(i, myMark);
|
||||||
|
ws.send(JSON.stringify({ type: 'move', index: i }));
|
||||||
|
myTurn = false;
|
||||||
|
App.renderStatus('Waiting for opponent…', 'thinking');
|
||||||
|
}
|
||||||
|
|
||||||
|
function newRound() {
|
||||||
|
ws.send(JSON.stringify({ type: 'new-round' }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroy() {
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
myMark = null;
|
||||||
|
myTurn = false;
|
||||||
|
board = Array(9).fill(null);
|
||||||
|
gameOver = false;
|
||||||
|
scores = { you: 0, opponent: 0, draws: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function init(opts) {
|
||||||
|
asHost = opts.asHost;
|
||||||
|
gameId = opts.asHost ? opts.id : opts.token;
|
||||||
|
const base = location.pathname === '/' ? '' : location.pathname.replace(/\/$/, '');
|
||||||
|
const wsUrl = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.host + base + '/ws';
|
||||||
|
connect(wsUrl).catch((err) => {
|
||||||
|
console.error('Connection failed:', err);
|
||||||
|
App.renderStatus('Connection failed', 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init, handleMove, newRound, destroy };
|
||||||
|
})();
|
||||||
@@ -429,3 +429,47 @@ body {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(-50%) translateY(0);
|
transform: translateX(-50%) translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- screens ---------- */
|
||||||
|
.screen {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 24px;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-buttons {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waiting-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.link-box {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: var(--glass);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
backdrop-filter: blur(14px);
|
||||||
|
-webkit-backdrop-filter: blur(14px);
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: monospace;
|
||||||
|
word-break: break-all;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.waiting-pulse {
|
||||||
|
animation: status-pulse 1200ms ease-in-out infinite;
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const initSqlJs = require('sql.js');
|
||||||
|
|
||||||
|
const DATA_DIR = path.join(__dirname, 'data');
|
||||||
|
const DB_PATH = path.join(DATA_DIR, 'games.db');
|
||||||
|
|
||||||
|
let db = null;
|
||||||
|
|
||||||
|
async function init() {
|
||||||
|
if (!fs.existsSync(DATA_DIR)) {
|
||||||
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const SQL = await initSqlJs();
|
||||||
|
|
||||||
|
if (fs.existsSync(DB_PATH)) {
|
||||||
|
const buf = fs.readFileSync(DB_PATH);
|
||||||
|
db = new SQL.Database(buf);
|
||||||
|
} else {
|
||||||
|
db = new SQL.Database();
|
||||||
|
db.run(`CREATE TABLE IF NOT EXISTS games (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
token TEXT UNIQUE NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'waiting',
|
||||||
|
board TEXT NOT NULL DEFAULT 'null',
|
||||||
|
turn TEXT NOT NULL DEFAULT 'X',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)`);
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGame({ id, token }) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
db.run(
|
||||||
|
`INSERT INTO games (id, token, status, board, turn, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 'waiting', 'null', 'X', ?, ?)`,
|
||||||
|
[id, token, now, now]
|
||||||
|
);
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGameByToken(token) {
|
||||||
|
const stmt = db.prepare(`SELECT * FROM games WHERE token = ?`);
|
||||||
|
stmt.bind([token]);
|
||||||
|
if (stmt.step()) {
|
||||||
|
const row = stmt.getAsObject();
|
||||||
|
stmt.free();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
stmt.free();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGameById(id) {
|
||||||
|
const stmt = db.prepare(`SELECT * FROM games WHERE id = ?`);
|
||||||
|
stmt.bind([id]);
|
||||||
|
if (stmt.step()) {
|
||||||
|
const row = stmt.getAsObject();
|
||||||
|
stmt.free();
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
stmt.free();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGame(id, { status, board, turn }) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
db.run(
|
||||||
|
`UPDATE games SET status = ?, board = ?, turn = ?, updated_at = ? WHERE id = ?`,
|
||||||
|
[status, board, turn, now, id]
|
||||||
|
);
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
|
||||||
|
function persist() {
|
||||||
|
const data = db.export();
|
||||||
|
fs.writeFileSync(DB_PATH, Buffer.from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { init, createGame, getGameByToken, getGameById, updateGame, persist };
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "ttt-server",
|
||||||
|
"private": true,
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": { "start": "node server.js" },
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"ws": "^8.21.3",
|
||||||
|
"sql.js": "^1.14.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+597
@@ -0,0 +1,597 @@
|
|||||||
|
lockfileVersion: '9.0'
|
||||||
|
|
||||||
|
settings:
|
||||||
|
autoInstallPeers: true
|
||||||
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
importers:
|
||||||
|
|
||||||
|
.:
|
||||||
|
dependencies:
|
||||||
|
express:
|
||||||
|
specifier: ^5.2.1
|
||||||
|
version: 5.2.1
|
||||||
|
sql.js:
|
||||||
|
specifier: ^1.14.2
|
||||||
|
version: 1.14.2
|
||||||
|
ws:
|
||||||
|
specifier: ^8.21.3
|
||||||
|
version: 8.21.3
|
||||||
|
|
||||||
|
packages:
|
||||||
|
|
||||||
|
accepts@2.0.0:
|
||||||
|
resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
body-parser@2.3.0:
|
||||||
|
resolution: {integrity: sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
bytes@3.1.2:
|
||||||
|
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
call-bind-apply-helpers@1.0.2:
|
||||||
|
resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
call-bound@1.0.4:
|
||||||
|
resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
content-disposition@1.1.0:
|
||||||
|
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
content-type@1.0.5:
|
||||||
|
resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
content-type@2.1.0:
|
||||||
|
resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
cookie-signature@1.2.2:
|
||||||
|
resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
|
||||||
|
engines: {node: '>=6.6.0'}
|
||||||
|
|
||||||
|
cookie@0.7.2:
|
||||||
|
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
debug@4.4.3:
|
||||||
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
|
engines: {node: '>=6.0'}
|
||||||
|
peerDependencies:
|
||||||
|
supports-color: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
supports-color:
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
depd@2.0.0:
|
||||||
|
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
dunder-proto@1.0.1:
|
||||||
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
ee-first@1.1.1:
|
||||||
|
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||||
|
|
||||||
|
encodeurl@2.0.0:
|
||||||
|
resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
es-define-property@1.0.1:
|
||||||
|
resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-errors@1.3.0:
|
||||||
|
resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
es-object-atoms@1.1.2:
|
||||||
|
resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
escape-html@1.0.3:
|
||||||
|
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||||
|
|
||||||
|
etag@1.8.1:
|
||||||
|
resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
express@5.2.1:
|
||||||
|
resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
|
finalhandler@2.1.1:
|
||||||
|
resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
|
||||||
|
engines: {node: '>= 18.0.0'}
|
||||||
|
|
||||||
|
forwarded@0.2.0:
|
||||||
|
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
fresh@2.0.0:
|
||||||
|
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
function-bind@1.1.2:
|
||||||
|
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
||||||
|
|
||||||
|
get-intrinsic@1.3.0:
|
||||||
|
resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
get-proto@1.0.1:
|
||||||
|
resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
gopd@1.2.0:
|
||||||
|
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
has-symbols@1.1.0:
|
||||||
|
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
hasown@2.0.4:
|
||||||
|
resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
http-errors@2.0.1:
|
||||||
|
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
iconv-lite@0.7.3:
|
||||||
|
resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
inherits@2.0.4:
|
||||||
|
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||||
|
|
||||||
|
ipaddr.js@1.9.1:
|
||||||
|
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||||
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
|
is-promise@4.0.0:
|
||||||
|
resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
|
||||||
|
|
||||||
|
math-intrinsics@1.1.0:
|
||||||
|
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
media-typer@1.1.1:
|
||||||
|
resolution: {integrity: sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
merge-descriptors@2.0.0:
|
||||||
|
resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
mime-db@1.54.0:
|
||||||
|
resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
mime-types@3.0.2:
|
||||||
|
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
ms@2.1.3:
|
||||||
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
|
negotiator@1.1.0:
|
||||||
|
resolution: {integrity: sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==}
|
||||||
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
object-inspect@1.13.4:
|
||||||
|
resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
on-finished@2.4.1:
|
||||||
|
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
once@1.4.0:
|
||||||
|
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||||
|
|
||||||
|
parseurl@1.3.3:
|
||||||
|
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
path-to-regexp@8.4.2:
|
||||||
|
resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
|
||||||
|
|
||||||
|
proxy-addr@2.0.8:
|
||||||
|
resolution: {integrity: sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==}
|
||||||
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
|
qs@6.16.0:
|
||||||
|
resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
|
||||||
|
engines: {node: '>=0.6'}
|
||||||
|
|
||||||
|
range-parser@1.3.0:
|
||||||
|
resolution: {integrity: sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==}
|
||||||
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
raw-body@3.0.2:
|
||||||
|
resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
|
||||||
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
|
router@2.2.0:
|
||||||
|
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
|
safer-buffer@2.1.2:
|
||||||
|
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||||
|
|
||||||
|
send@1.2.1:
|
||||||
|
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
|
serve-static@2.2.1:
|
||||||
|
resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
|
setprototypeof@1.2.0:
|
||||||
|
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||||
|
|
||||||
|
side-channel-list@1.0.1:
|
||||||
|
resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
side-channel-map@1.0.1:
|
||||||
|
resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
side-channel-weakmap@1.0.2:
|
||||||
|
resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
side-channel@1.1.1:
|
||||||
|
resolution: {integrity: sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==}
|
||||||
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
sql.js@1.14.2:
|
||||||
|
resolution: {integrity: sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==}
|
||||||
|
|
||||||
|
statuses@2.0.2:
|
||||||
|
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
toidentifier@1.0.1:
|
||||||
|
resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
|
||||||
|
engines: {node: '>=0.6'}
|
||||||
|
|
||||||
|
type-is@2.1.0:
|
||||||
|
resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
|
||||||
|
engines: {node: '>= 18'}
|
||||||
|
|
||||||
|
unpipe@1.0.0:
|
||||||
|
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
vary@1.1.2:
|
||||||
|
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||||
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
wrappy@1.0.2:
|
||||||
|
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||||
|
|
||||||
|
ws@8.21.3:
|
||||||
|
resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
|
||||||
|
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
|
||||||
|
|
||||||
|
snapshots:
|
||||||
|
|
||||||
|
accepts@2.0.0:
|
||||||
|
dependencies:
|
||||||
|
mime-types: 3.0.2
|
||||||
|
negotiator: 1.1.0
|
||||||
|
|
||||||
|
body-parser@2.3.0:
|
||||||
|
dependencies:
|
||||||
|
bytes: 3.1.2
|
||||||
|
content-type: 2.1.0
|
||||||
|
debug: 4.4.3
|
||||||
|
http-errors: 2.0.1
|
||||||
|
iconv-lite: 0.7.3
|
||||||
|
on-finished: 2.4.1
|
||||||
|
qs: 6.16.0
|
||||||
|
raw-body: 3.0.2
|
||||||
|
type-is: 2.1.0
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
bytes@3.1.2: {}
|
||||||
|
|
||||||
|
call-bind-apply-helpers@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
es-errors: 1.3.0
|
||||||
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
call-bound@1.0.4:
|
||||||
|
dependencies:
|
||||||
|
call-bind-apply-helpers: 1.0.2
|
||||||
|
get-intrinsic: 1.3.0
|
||||||
|
|
||||||
|
content-disposition@1.1.0: {}
|
||||||
|
|
||||||
|
content-type@1.0.5: {}
|
||||||
|
|
||||||
|
content-type@2.1.0: {}
|
||||||
|
|
||||||
|
cookie-signature@1.2.2: {}
|
||||||
|
|
||||||
|
cookie@0.7.2: {}
|
||||||
|
|
||||||
|
debug@4.4.3:
|
||||||
|
dependencies:
|
||||||
|
ms: 2.1.3
|
||||||
|
|
||||||
|
depd@2.0.0: {}
|
||||||
|
|
||||||
|
dunder-proto@1.0.1:
|
||||||
|
dependencies:
|
||||||
|
call-bind-apply-helpers: 1.0.2
|
||||||
|
es-errors: 1.3.0
|
||||||
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
ee-first@1.1.1: {}
|
||||||
|
|
||||||
|
encodeurl@2.0.0: {}
|
||||||
|
|
||||||
|
es-define-property@1.0.1: {}
|
||||||
|
|
||||||
|
es-errors@1.3.0: {}
|
||||||
|
|
||||||
|
es-object-atoms@1.1.2:
|
||||||
|
dependencies:
|
||||||
|
es-errors: 1.3.0
|
||||||
|
|
||||||
|
escape-html@1.0.3: {}
|
||||||
|
|
||||||
|
etag@1.8.1: {}
|
||||||
|
|
||||||
|
express@5.2.1:
|
||||||
|
dependencies:
|
||||||
|
accepts: 2.0.0
|
||||||
|
body-parser: 2.3.0
|
||||||
|
content-disposition: 1.1.0
|
||||||
|
content-type: 1.0.5
|
||||||
|
cookie: 0.7.2
|
||||||
|
cookie-signature: 1.2.2
|
||||||
|
debug: 4.4.3
|
||||||
|
depd: 2.0.0
|
||||||
|
encodeurl: 2.0.0
|
||||||
|
escape-html: 1.0.3
|
||||||
|
etag: 1.8.1
|
||||||
|
finalhandler: 2.1.1
|
||||||
|
fresh: 2.0.0
|
||||||
|
http-errors: 2.0.1
|
||||||
|
merge-descriptors: 2.0.0
|
||||||
|
mime-types: 3.0.2
|
||||||
|
on-finished: 2.4.1
|
||||||
|
once: 1.4.0
|
||||||
|
parseurl: 1.3.3
|
||||||
|
proxy-addr: 2.0.8
|
||||||
|
qs: 6.16.0
|
||||||
|
range-parser: 1.3.0
|
||||||
|
router: 2.2.0
|
||||||
|
send: 1.2.1
|
||||||
|
serve-static: 2.2.1
|
||||||
|
statuses: 2.0.2
|
||||||
|
type-is: 2.1.0
|
||||||
|
vary: 1.1.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
finalhandler@2.1.1:
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.3
|
||||||
|
encodeurl: 2.0.0
|
||||||
|
escape-html: 1.0.3
|
||||||
|
on-finished: 2.4.1
|
||||||
|
parseurl: 1.3.3
|
||||||
|
statuses: 2.0.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
forwarded@0.2.0: {}
|
||||||
|
|
||||||
|
fresh@2.0.0: {}
|
||||||
|
|
||||||
|
function-bind@1.1.2: {}
|
||||||
|
|
||||||
|
get-intrinsic@1.3.0:
|
||||||
|
dependencies:
|
||||||
|
call-bind-apply-helpers: 1.0.2
|
||||||
|
es-define-property: 1.0.1
|
||||||
|
es-errors: 1.3.0
|
||||||
|
es-object-atoms: 1.1.2
|
||||||
|
function-bind: 1.1.2
|
||||||
|
get-proto: 1.0.1
|
||||||
|
gopd: 1.2.0
|
||||||
|
has-symbols: 1.1.0
|
||||||
|
hasown: 2.0.4
|
||||||
|
math-intrinsics: 1.1.0
|
||||||
|
|
||||||
|
get-proto@1.0.1:
|
||||||
|
dependencies:
|
||||||
|
dunder-proto: 1.0.1
|
||||||
|
es-object-atoms: 1.1.2
|
||||||
|
|
||||||
|
gopd@1.2.0: {}
|
||||||
|
|
||||||
|
has-symbols@1.1.0: {}
|
||||||
|
|
||||||
|
hasown@2.0.4:
|
||||||
|
dependencies:
|
||||||
|
function-bind: 1.1.2
|
||||||
|
|
||||||
|
http-errors@2.0.1:
|
||||||
|
dependencies:
|
||||||
|
depd: 2.0.0
|
||||||
|
inherits: 2.0.4
|
||||||
|
setprototypeof: 1.2.0
|
||||||
|
statuses: 2.0.2
|
||||||
|
toidentifier: 1.0.1
|
||||||
|
|
||||||
|
iconv-lite@0.7.3:
|
||||||
|
dependencies:
|
||||||
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
|
inherits@2.0.4: {}
|
||||||
|
|
||||||
|
ipaddr.js@1.9.1: {}
|
||||||
|
|
||||||
|
is-promise@4.0.0: {}
|
||||||
|
|
||||||
|
math-intrinsics@1.1.0: {}
|
||||||
|
|
||||||
|
media-typer@1.1.1: {}
|
||||||
|
|
||||||
|
merge-descriptors@2.0.0: {}
|
||||||
|
|
||||||
|
mime-db@1.54.0: {}
|
||||||
|
|
||||||
|
mime-types@3.0.2:
|
||||||
|
dependencies:
|
||||||
|
mime-db: 1.54.0
|
||||||
|
|
||||||
|
ms@2.1.3: {}
|
||||||
|
|
||||||
|
negotiator@1.1.0:
|
||||||
|
dependencies:
|
||||||
|
content-type: 2.1.0
|
||||||
|
|
||||||
|
object-inspect@1.13.4: {}
|
||||||
|
|
||||||
|
on-finished@2.4.1:
|
||||||
|
dependencies:
|
||||||
|
ee-first: 1.1.1
|
||||||
|
|
||||||
|
once@1.4.0:
|
||||||
|
dependencies:
|
||||||
|
wrappy: 1.0.2
|
||||||
|
|
||||||
|
parseurl@1.3.3: {}
|
||||||
|
|
||||||
|
path-to-regexp@8.4.2: {}
|
||||||
|
|
||||||
|
proxy-addr@2.0.8:
|
||||||
|
dependencies:
|
||||||
|
forwarded: 0.2.0
|
||||||
|
ipaddr.js: 1.9.1
|
||||||
|
|
||||||
|
qs@6.16.0:
|
||||||
|
dependencies:
|
||||||
|
es-define-property: 1.0.1
|
||||||
|
side-channel: 1.1.1
|
||||||
|
|
||||||
|
range-parser@1.3.0: {}
|
||||||
|
|
||||||
|
raw-body@3.0.2:
|
||||||
|
dependencies:
|
||||||
|
bytes: 3.1.2
|
||||||
|
http-errors: 2.0.1
|
||||||
|
iconv-lite: 0.7.3
|
||||||
|
unpipe: 1.0.0
|
||||||
|
|
||||||
|
router@2.2.0:
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.3
|
||||||
|
depd: 2.0.0
|
||||||
|
is-promise: 4.0.0
|
||||||
|
parseurl: 1.3.3
|
||||||
|
path-to-regexp: 8.4.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
safer-buffer@2.1.2: {}
|
||||||
|
|
||||||
|
send@1.2.1:
|
||||||
|
dependencies:
|
||||||
|
debug: 4.4.3
|
||||||
|
encodeurl: 2.0.0
|
||||||
|
escape-html: 1.0.3
|
||||||
|
etag: 1.8.1
|
||||||
|
fresh: 2.0.0
|
||||||
|
http-errors: 2.0.1
|
||||||
|
mime-types: 3.0.2
|
||||||
|
ms: 2.1.3
|
||||||
|
on-finished: 2.4.1
|
||||||
|
range-parser: 1.3.0
|
||||||
|
statuses: 2.0.2
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
serve-static@2.2.1:
|
||||||
|
dependencies:
|
||||||
|
encodeurl: 2.0.0
|
||||||
|
escape-html: 1.0.3
|
||||||
|
parseurl: 1.3.3
|
||||||
|
send: 1.2.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- supports-color
|
||||||
|
|
||||||
|
setprototypeof@1.2.0: {}
|
||||||
|
|
||||||
|
side-channel-list@1.0.1:
|
||||||
|
dependencies:
|
||||||
|
es-errors: 1.3.0
|
||||||
|
object-inspect: 1.13.4
|
||||||
|
|
||||||
|
side-channel-map@1.0.1:
|
||||||
|
dependencies:
|
||||||
|
call-bound: 1.0.4
|
||||||
|
es-errors: 1.3.0
|
||||||
|
get-intrinsic: 1.3.0
|
||||||
|
object-inspect: 1.13.4
|
||||||
|
|
||||||
|
side-channel-weakmap@1.0.2:
|
||||||
|
dependencies:
|
||||||
|
call-bound: 1.0.4
|
||||||
|
es-errors: 1.3.0
|
||||||
|
get-intrinsic: 1.3.0
|
||||||
|
object-inspect: 1.13.4
|
||||||
|
side-channel-map: 1.0.1
|
||||||
|
|
||||||
|
side-channel@1.1.1:
|
||||||
|
dependencies:
|
||||||
|
es-errors: 1.3.0
|
||||||
|
object-inspect: 1.13.4
|
||||||
|
side-channel-list: 1.0.1
|
||||||
|
side-channel-map: 1.0.1
|
||||||
|
side-channel-weakmap: 1.0.2
|
||||||
|
|
||||||
|
sql.js@1.14.2: {}
|
||||||
|
|
||||||
|
statuses@2.0.2: {}
|
||||||
|
|
||||||
|
toidentifier@1.0.1: {}
|
||||||
|
|
||||||
|
type-is@2.1.0:
|
||||||
|
dependencies:
|
||||||
|
content-type: 2.1.0
|
||||||
|
media-typer: 1.1.1
|
||||||
|
mime-types: 3.0.2
|
||||||
|
|
||||||
|
unpipe@1.0.0: {}
|
||||||
|
|
||||||
|
vary@1.1.2: {}
|
||||||
|
|
||||||
|
wrappy@1.0.2: {}
|
||||||
|
|
||||||
|
ws@8.21.3: {}
|
||||||
@@ -0,0 +1,288 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
const http = require('http');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const express = require('express');
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
const db = require('./db');
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 8080;
|
||||||
|
const BASE_PATH = process.env.BASE_PATH || '';
|
||||||
|
|
||||||
|
const WIN_LINES = [
|
||||||
|
[0, 1, 2], [3, 4, 5], [6, 7, 8],
|
||||||
|
[0, 3, 6], [1, 4, 7], [2, 5, 8],
|
||||||
|
[0, 4, 8], [2, 4, 6],
|
||||||
|
];
|
||||||
|
|
||||||
|
function checkWinner(board) {
|
||||||
|
for (const line of WIN_LINES) {
|
||||||
|
const [a, b, c] = line;
|
||||||
|
if (board[a] && board[a] === board[b] && board[a] === board[c]) {
|
||||||
|
return { winner: board[a], line };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (board.every((v) => v !== null)) return { winner: 'draw' };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const wss = new WebSocket.Server({ server, path: '/ws' });
|
||||||
|
|
||||||
|
const games = new Map();
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname, '../public')));
|
||||||
|
|
||||||
|
app.post('/api/games', (req, res) => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const token = crypto.randomBytes(8).toString('hex');
|
||||||
|
db.createGame({ id, token });
|
||||||
|
const link = `${req.get('origin') || req.protocol + '://' + req.get('host')}${BASE_PATH}/?token=${token}`;
|
||||||
|
res.status(201).json({ id, token, link });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/health', (req, res) => {
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
if (req.method === 'GET' && !req.path.startsWith('/api/')) {
|
||||||
|
res.sendFile(path.join(__dirname, '../public', 'index.html'));
|
||||||
|
} else {
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function send(ws, obj) {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(obj));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(game, obj) {
|
||||||
|
for (const seat of ['X', 'O']) {
|
||||||
|
const p = game.players[seat];
|
||||||
|
if (p) send(p, obj);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
let msg;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(raw.toString());
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (msg.type) {
|
||||||
|
case 'host':
|
||||||
|
handleHost(ws, msg);
|
||||||
|
break;
|
||||||
|
case 'join':
|
||||||
|
handleJoin(ws, msg);
|
||||||
|
break;
|
||||||
|
case 'move':
|
||||||
|
handleMove(ws, msg);
|
||||||
|
break;
|
||||||
|
case 'new-round':
|
||||||
|
handleNewRound(ws, msg);
|
||||||
|
break;
|
||||||
|
case 'leave':
|
||||||
|
handleLeave(ws);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
handleDisconnect(ws);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleHost(ws, msg) {
|
||||||
|
const row = db.getGameById(msg.id);
|
||||||
|
if (!row) {
|
||||||
|
send(ws, { type: 'error', message: 'Game not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let game = games.get(row.id);
|
||||||
|
if (!game) {
|
||||||
|
const board = row.board === 'null' ? Array(9).fill(null) : JSON.parse(row.board);
|
||||||
|
game = {
|
||||||
|
id: row.id,
|
||||||
|
token: row.token,
|
||||||
|
board,
|
||||||
|
turn: row.turn,
|
||||||
|
status: row.status,
|
||||||
|
players: { X: null, O: null },
|
||||||
|
};
|
||||||
|
games.set(game.id, game);
|
||||||
|
}
|
||||||
|
|
||||||
|
game.players.X = ws;
|
||||||
|
ws._gameId = game.id;
|
||||||
|
ws._seat = 'X';
|
||||||
|
|
||||||
|
if (game.status === 'waiting') {
|
||||||
|
send(ws, { type: 'joined', mark: 'X', board: game.board, turn: game.turn });
|
||||||
|
} else if (game.status === 'playing' && game.players.O) {
|
||||||
|
send(ws, { type: 'start', mark: 'X', first: 'X', board: game.board, turn: game.turn });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleJoin(ws, msg) {
|
||||||
|
const row = db.getGameByToken(msg.token);
|
||||||
|
if (!row) {
|
||||||
|
send(ws, { type: 'error', message: 'Invalid or expired link' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let game = games.get(row.id);
|
||||||
|
|
||||||
|
if (!game) {
|
||||||
|
const board = row.board === 'null' ? Array(9).fill(null) : JSON.parse(row.board);
|
||||||
|
game = {
|
||||||
|
id: row.id,
|
||||||
|
token: row.token,
|
||||||
|
board,
|
||||||
|
turn: row.turn,
|
||||||
|
status: row.status,
|
||||||
|
players: { X: null, O: ws },
|
||||||
|
};
|
||||||
|
games.set(game.id, game);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game.status === 'ended') {
|
||||||
|
send(ws, { type: 'error', message: 'Game already ended' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game.players.O) {
|
||||||
|
send(ws, { type: 'error', message: 'Game already full' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
game.players.O = ws;
|
||||||
|
ws._gameId = game.id;
|
||||||
|
ws._seat = 'O';
|
||||||
|
|
||||||
|
if (game.players.X) {
|
||||||
|
game.status = 'playing';
|
||||||
|
db.updateGame(game.id, { status: 'playing', board: JSON.stringify(game.board), turn: game.turn });
|
||||||
|
send(game.players.X, { type: 'opponent-joined', you: 'X' });
|
||||||
|
send(ws, { type: 'start', mark: 'O', first: 'X', board: game.board, turn: game.turn });
|
||||||
|
} else {
|
||||||
|
send(ws, { type: 'joined', mark: 'O', board: game.board, turn: game.turn });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMove(ws, msg) {
|
||||||
|
const game = games.get(ws._gameId);
|
||||||
|
if (!game) {
|
||||||
|
send(ws, { type: 'error', message: 'Game not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game.status !== 'playing') {
|
||||||
|
send(ws, { type: 'error', message: 'Game is not in progress' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const seat = ws._seat;
|
||||||
|
if (game.turn !== seat) {
|
||||||
|
send(ws, { type: 'error', message: 'Not your turn' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cell = msg.index;
|
||||||
|
if (!Number.isInteger(cell) || cell < 0 || cell > 8) {
|
||||||
|
send(ws, { type: 'error', message: 'Invalid cell' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game.board[cell] !== null) {
|
||||||
|
send(ws, { type: 'error', message: 'Cell already occupied' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
game.board[cell] = seat;
|
||||||
|
game.turn = seat === 'X' ? 'O' : 'X';
|
||||||
|
db.updateGame(game.id, { status: game.status, board: JSON.stringify(game.board), turn: game.turn });
|
||||||
|
|
||||||
|
const result = checkWinner(game.board);
|
||||||
|
|
||||||
|
broadcast(game, { type: 'move', index: cell, mark: seat });
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
game.status = 'ended';
|
||||||
|
db.updateGame(game.id, { status: 'ended', board: JSON.stringify(game.board), turn: game.turn });
|
||||||
|
broadcast(game, { type: 'end', winner: result.winner, line: result.line || null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNewRound(ws, msg) {
|
||||||
|
const game = games.get(ws._gameId);
|
||||||
|
if (!game) {
|
||||||
|
send(ws, { type: 'error', message: 'Game not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game.status !== 'ended') {
|
||||||
|
send(ws, { type: 'error', message: 'Game has not ended' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
game.board = Array(9).fill(null);
|
||||||
|
game.turn = 'X';
|
||||||
|
game.status = 'playing';
|
||||||
|
db.updateGame(game.id, { status: 'playing', board: JSON.stringify(game.board), turn: game.turn });
|
||||||
|
|
||||||
|
broadcast(game, { type: 'new-round', first: 'X', board: game.board, turn: game.turn });
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLeave(ws) {
|
||||||
|
handleDisconnect(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDisconnect(ws) {
|
||||||
|
const gameId = ws._gameId;
|
||||||
|
if (!gameId) return;
|
||||||
|
|
||||||
|
const game = games.get(gameId);
|
||||||
|
if (!game) return;
|
||||||
|
|
||||||
|
const seat = ws._seat;
|
||||||
|
if (game.players[seat] === ws) {
|
||||||
|
game.players[seat] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (game.status === 'playing') {
|
||||||
|
game.status = 'ended';
|
||||||
|
db.updateGame(game.id, { status: 'ended', board: JSON.stringify(game.board), turn: game.turn });
|
||||||
|
|
||||||
|
const otherSeat = seat === 'X' ? 'O' : 'X';
|
||||||
|
const other = game.players[otherSeat];
|
||||||
|
if (other) {
|
||||||
|
send(other, { type: 'opponent-left' });
|
||||||
|
}
|
||||||
|
} else if (game.status === 'waiting' && seat === 'X') {
|
||||||
|
game.status = 'ended';
|
||||||
|
db.updateGame(game.id, { status: 'ended', board: JSON.stringify(game.board), turn: game.turn });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!game.players.X && !game.players.O) {
|
||||||
|
games.delete(game.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
db.init().then(() => {
|
||||||
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
}).catch((err) => {
|
||||||
|
console.error('Failed to initialize database:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user