inital commit
This commit is contained in:
@@ -1 +1,44 @@
|
|||||||
# skrible-2
|
# ✏️ SKRIBBLE — a skribbl.io clone
|
||||||
|
|
||||||
|
A full multiplayer drawing & guessing game in TypeScript. No assets — all graphics
|
||||||
|
(avatars, icons, logo), sounds (WebAudio synth), and animations are generated in code.
|
||||||
|
|
||||||
|
## Run it
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build # compiles server (tsc) + client (esbuild)
|
||||||
|
npm start # http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:3000` in multiple browser windows/tabs (or share your LAN IP)
|
||||||
|
to play. `PORT=8080 npm start` to change the port.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Public & private rooms** — quick-join public lobby browser, or create private rooms with a 4-letter **room code**
|
||||||
|
- **Nicknames & procedural cartoon avatars** (shuffle to randomize)
|
||||||
|
- **Lobby chat** + host-editable **game settings**: rounds (2–8), draw time (30–180 s), max players (2–12), public/private, custom words (+ custom-only mode)
|
||||||
|
- **Turn-based drawing**: drawer picks 1 of **3 random words** (15 s to choose)
|
||||||
|
- **Live canvas**: only the current drawer can draw (enforced server-side)
|
||||||
|
- 26 colors, 4 brush sizes, eraser, flood-fill bucket, clear canvas, undo
|
||||||
|
- smooth quadratic-interpolated strokes, real-time stroke streaming to all players
|
||||||
|
- **Guessing via chat**: correct guesses are **hidden from other players**, close guesses are flagged ("X is close!"), the answer is stripped from chat while drawing
|
||||||
|
- **Scoring**: guessers earn more points the faster they guess (with order bonus decay); the **drawer earns points** for every correct guess (+ bonus if everyone guesses)
|
||||||
|
- **Multiple rounds** (everyone draws once per round), round banners, turn-end word reveal with point recap, **countdown clock** with ticking, **letter hints** revealed over time
|
||||||
|
- **Scoreboard** sorted live with ✅ guessed marks, ✏️ drawer mark, 👑 host, **typing indicators**
|
||||||
|
- **Join/leave handling**: host migration, drawer-leave turn skip, mid-game join sync (full canvas op log), **inactive players kicked** after 2.5 min, host can kick players
|
||||||
|
- **End-game leaderboard** with podium, medals and confetti, then back to lobby
|
||||||
|
- Synthesized **sound effects** (guesses, joins, ticks, fanfares) with mute toggle
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/shared/ protocol types + word list (shared client/server)
|
||||||
|
src/server/ Node HTTP static server + ws game rooms (game.ts)
|
||||||
|
src/client/ browser client: net, canvas engine, UI, avatars, sfx
|
||||||
|
public/ index.html, style.css, bundle.js (built)
|
||||||
|
test/ headless ws integration tests (node test/sim.js, sim2.js)
|
||||||
|
```
|
||||||
|
|
||||||
|
Keyboard shortcuts: **B** brush, **E** eraser, **F** fill, **Ctrl+Z** undo.
|
||||||
|
|||||||
Generated
+550
@@ -0,0 +1,550 @@
|
|||||||
|
{
|
||||||
|
"name": "skribble",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "skribble",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/ws": "^8.5.13",
|
||||||
|
"esbuild": "^0.24.0",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"aix"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/android-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/darwin-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/freebsd-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ia32": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-loong64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ==",
|
||||||
|
"cpu": [
|
||||||
|
"loong64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-mips64el": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw==",
|
||||||
|
"cpu": [
|
||||||
|
"mips64el"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-ppc64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw==",
|
||||||
|
"cpu": [
|
||||||
|
"ppc64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-riscv64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q==",
|
||||||
|
"cpu": [
|
||||||
|
"riscv64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-s390x": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/linux-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/netbsd-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"netbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/openbsd-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"openbsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/sunos-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"sunos"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-arm64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-ia32": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@esbuild/win32-x64": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||||
|
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/ws": {
|
||||||
|
"version": "8.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
|
"integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/esbuild": {
|
||||||
|
"version": "0.24.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.2.tgz",
|
||||||
|
"integrity": "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"bin": {
|
||||||
|
"esbuild": "bin/esbuild"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@esbuild/aix-ppc64": "0.24.2",
|
||||||
|
"@esbuild/android-arm": "0.24.2",
|
||||||
|
"@esbuild/android-arm64": "0.24.2",
|
||||||
|
"@esbuild/android-x64": "0.24.2",
|
||||||
|
"@esbuild/darwin-arm64": "0.24.2",
|
||||||
|
"@esbuild/darwin-x64": "0.24.2",
|
||||||
|
"@esbuild/freebsd-arm64": "0.24.2",
|
||||||
|
"@esbuild/freebsd-x64": "0.24.2",
|
||||||
|
"@esbuild/linux-arm": "0.24.2",
|
||||||
|
"@esbuild/linux-arm64": "0.24.2",
|
||||||
|
"@esbuild/linux-ia32": "0.24.2",
|
||||||
|
"@esbuild/linux-loong64": "0.24.2",
|
||||||
|
"@esbuild/linux-mips64el": "0.24.2",
|
||||||
|
"@esbuild/linux-ppc64": "0.24.2",
|
||||||
|
"@esbuild/linux-riscv64": "0.24.2",
|
||||||
|
"@esbuild/linux-s390x": "0.24.2",
|
||||||
|
"@esbuild/linux-x64": "0.24.2",
|
||||||
|
"@esbuild/netbsd-arm64": "0.24.2",
|
||||||
|
"@esbuild/netbsd-x64": "0.24.2",
|
||||||
|
"@esbuild/openbsd-arm64": "0.24.2",
|
||||||
|
"@esbuild/openbsd-x64": "0.24.2",
|
||||||
|
"@esbuild/sunos-x64": "0.24.2",
|
||||||
|
"@esbuild/win32-arm64": "0.24.2",
|
||||||
|
"@esbuild/win32-ia32": "0.24.2",
|
||||||
|
"@esbuild/win32-x64": "0.24.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.21.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||||
|
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "skribble",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A skribbl.io clone - multiplayer drawing & guessing game",
|
||||||
|
"main": "dist/server/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "npm run build:server && npm run build:client",
|
||||||
|
"build:server": "tsc -p tsconfig.server.json",
|
||||||
|
"build:client": "esbuild src/client/main.ts --bundle --outfile=public/bundle.js --target=es2020 --sourcemap",
|
||||||
|
"watch:client": "esbuild src/client/main.ts --bundle --outfile=public/bundle.js --target=es2020 --watch",
|
||||||
|
"start": "node dist/server/index.js",
|
||||||
|
"dev": "npm run build && npm run start"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ws": "^8.18.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/ws": "^8.5.13",
|
||||||
|
"esbuild": "^0.24.0",
|
||||||
|
"typescript": "^5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1552
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,110 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>skribble - free multiplayer drawing & guessing game</title>
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>✏️</text></svg>">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- ==================== HOME SCREEN ==================== -->
|
||||||
|
<div id="screen-home" class="screen">
|
||||||
|
<div class="home-wrap">
|
||||||
|
<h1 class="logo logo-big">SKRIBBLE</h1>
|
||||||
|
<div class="home-columns">
|
||||||
|
<div class="panel home-card">
|
||||||
|
<div class="avatar-row">
|
||||||
|
<canvas id="avatar-preview" width="80" height="80"></canvas>
|
||||||
|
<button id="btn-avatar-shuffle" class="btn btn-blue btn-small" title="Randomize look">🔀</button>
|
||||||
|
</div>
|
||||||
|
<input id="name-input" maxlength="16" placeholder="Enter your name" autocomplete="off">
|
||||||
|
<button id="btn-play" class="btn btn-green btn-big">Play!</button>
|
||||||
|
<div class="home-sep"><span>or</span></div>
|
||||||
|
<div class="join-row">
|
||||||
|
<input id="code-input" maxlength="4" placeholder="CODE" autocomplete="off">
|
||||||
|
<button id="btn-join-code" class="btn btn-blue">Join</button>
|
||||||
|
</div>
|
||||||
|
<button id="btn-create-private" class="btn btn-orange">Create Private Room</button>
|
||||||
|
</div>
|
||||||
|
<div class="panel home-card" id="rooms-card">
|
||||||
|
<div class="rooms-head">
|
||||||
|
<h2>Public Rooms</h2>
|
||||||
|
<button id="btn-refresh-rooms" class="btn btn-blue btn-small" title="Refresh">⟳</button>
|
||||||
|
</div>
|
||||||
|
<div id="rooms-list"><div class="rooms-empty">Loading…</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel howto">
|
||||||
|
<b>How to play:</b> When it's your turn, draw the word. When others draw, type your guess into the chat.
|
||||||
|
The faster you guess, the more points you earn! 🎨
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ==================== GAME SCREEN ==================== -->
|
||||||
|
<div id="screen-game" class="screen hidden">
|
||||||
|
<header id="topbar">
|
||||||
|
<div class="logo logo-small">SKRIBBLE</div>
|
||||||
|
<div id="room-code-chip" title="Click to copy invite code">#<span id="room-code">----</span></div>
|
||||||
|
<div id="round-display">Lobby</div>
|
||||||
|
<div id="word-display"></div>
|
||||||
|
<div id="clock" title="Time remaining">
|
||||||
|
<svg viewBox="0 0 40 40" id="clock-svg">
|
||||||
|
<circle cx="20" cy="20" r="17" class="clock-face"/>
|
||||||
|
<line id="clock-hand" x1="20" y1="20" x2="20" y2="7"/>
|
||||||
|
<circle cx="20" cy="20" r="2.2" class="clock-dot"/>
|
||||||
|
</svg>
|
||||||
|
<span id="clock-num">–</span>
|
||||||
|
</div>
|
||||||
|
<button id="btn-sound" class="icon-btn" title="Toggle sound">🔊</button>
|
||||||
|
<button id="btn-leave" class="icon-btn" title="Leave room">🚪</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="main">
|
||||||
|
<aside id="players-panel" class="panel">
|
||||||
|
<ul id="players-list"></ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section id="center">
|
||||||
|
<div id="canvas-wrap" class="panel">
|
||||||
|
<canvas id="draw-canvas" width="1000" height="750"></canvas>
|
||||||
|
<div id="overlay" class="hidden"></div>
|
||||||
|
<div id="banner" class="hidden"></div>
|
||||||
|
</div>
|
||||||
|
<div id="tools" class="panel">
|
||||||
|
<div id="tool-buttons">
|
||||||
|
<button class="tool-btn" id="tool-brush" title="Brush (B)">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M3 21c1.5 0 3-.4 3.6-1.6L18 8l-3-3L3.6 16.4C3 17 2.6 19 3 21z" fill="currentColor"/><path d="M16 4l1.8-1.8a1.4 1.4 0 012 0l1 1a1.4 1.4 0 010 2L19 7z" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool-btn" id="tool-eraser" title="Eraser (E)">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M15.5 3l5.5 5.5a2 2 0 010 2.8l-8.2 8.2H8l-5.5-5.5a2 2 0 010-2.8l10.2-10.2a2 2 0 012.8 0zM8 17.5h4l7-7-4-4z" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool-btn" id="tool-fill" title="Fill bucket (F)">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M11 4l7 7-8.5 8.5a2.1 2.1 0 01-3 0L3 16a2.1 2.1 0 010-3zm1.4 2.8L6 13.2h12.8z" fill="currentColor"/><path d="M19.5 14s2 2.4 2 4a2 2 0 01-4 0c0-1.6 2-4 2-4z" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool-btn" id="tool-undo" title="Undo (Ctrl+Z)">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M9 5L3 11l6 6v-4c5 0 8.5 1.6 11 5-1-6-4-9-11-9z" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="tool-btn" id="tool-clear" title="Clear canvas">
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M6 7h12l-1 14H7zm3-4h6l1 2h4v2H4V5h4z" fill="currentColor"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="brush-sizes"></div>
|
||||||
|
<div id="palette"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside id="chat-panel" class="panel">
|
||||||
|
<div id="chat-messages"></div>
|
||||||
|
<div id="typing-indicator"></div>
|
||||||
|
<input id="chat-input" maxlength="140" placeholder="Type your guess here…" autocomplete="off">
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="toast-wrap"></div>
|
||||||
|
<script src="bundle.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
/* ============ skribble — cartoon style ============ */
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--blue-dark: #0d47a1;
|
||||||
|
--blue: #1976d2;
|
||||||
|
--blue-light: #42a5f5;
|
||||||
|
--ink: #20303f;
|
||||||
|
--panel-border: #d5dde5;
|
||||||
|
--green: #5cb531;
|
||||||
|
--orange: #ff9800;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body { height: 100%; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Comic Sans MS', 'Chalkboard SE', 'Comic Neue', 'Segoe UI', cursive, sans-serif;
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 700px at 20% -10%, rgba(255,255,255,.25), transparent 60%),
|
||||||
|
repeating-linear-gradient(45deg, rgba(255,255,255,.035) 0 24px, transparent 24px 48px),
|
||||||
|
linear-gradient(160deg, #2196f3 0%, #1565c0 55%, #0d47a1 100%);
|
||||||
|
color: var(--ink);
|
||||||
|
overflow: hidden;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
.screen { height: 100vh; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
/* ============ logo ============ */
|
||||||
|
.logo {
|
||||||
|
font-weight: 900;
|
||||||
|
color: #fff;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
transform: rotate(-2.5deg);
|
||||||
|
text-shadow:
|
||||||
|
-2px -2px 0 var(--blue-dark), 2px -2px 0 var(--blue-dark),
|
||||||
|
-2px 2px 0 var(--blue-dark), 2px 2px 0 var(--blue-dark),
|
||||||
|
0 6px 0 rgba(0,0,0,.25);
|
||||||
|
animation: logoBob 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes logoBob { 0%,100% { transform: rotate(-2.5deg) translateY(0); } 50% { transform: rotate(-2.5deg) translateY(-6px); } }
|
||||||
|
.logo-big { font-size: 64px; text-align: center; }
|
||||||
|
.logo-small { font-size: 26px; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* ============ panels & buttons ============ */
|
||||||
|
.panel {
|
||||||
|
background: #fff;
|
||||||
|
border: 3px solid var(--panel-border);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 6px 0 rgba(0,0,0,.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
font-family: inherit;
|
||||||
|
font-weight: 700;
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 16px;
|
||||||
|
text-shadow: 0 1px 0 rgba(0,0,0,.25);
|
||||||
|
transition: transform .06s, box-shadow .06s, filter .15s;
|
||||||
|
}
|
||||||
|
.btn:active { transform: translateY(3px); }
|
||||||
|
.btn:disabled { filter: grayscale(.8) brightness(.9); cursor: not-allowed; }
|
||||||
|
.btn-green { background: var(--green); box-shadow: 0 4px 0 #3e7d1e; }
|
||||||
|
.btn-green:active { box-shadow: 0 1px 0 #3e7d1e; }
|
||||||
|
.btn-orange { background: var(--orange); box-shadow: 0 4px 0 #c25e00; }
|
||||||
|
.btn-orange:active { box-shadow: 0 1px 0 #c25e00; }
|
||||||
|
.btn-blue { background: var(--blue-light); box-shadow: 0 4px 0 #1565c0; }
|
||||||
|
.btn-blue:active { box-shadow: 0 1px 0 #1565c0; }
|
||||||
|
.btn-red { background: #e53935; box-shadow: 0 4px 0 #8e1b18; }
|
||||||
|
.btn-red:active { box-shadow: 0 1px 0 #8e1b18; }
|
||||||
|
.btn-big { font-size: 24px; padding: 14px 18px; }
|
||||||
|
.btn-small { font-size: 14px; padding: 6px 10px; }
|
||||||
|
|
||||||
|
input, select, textarea {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 3px solid var(--panel-border);
|
||||||
|
border-radius: 10px;
|
||||||
|
outline: none;
|
||||||
|
background: #fff;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
input:focus, select:focus, textarea:focus { border-color: var(--blue-light); }
|
||||||
|
|
||||||
|
/* ============ home screen ============ */
|
||||||
|
#screen-home { align-items: center; justify-content: center; overflow-y: auto; }
|
||||||
|
.home-wrap { width: min(860px, 94vw); display: flex; flex-direction: column; gap: 18px; padding: 24px 0; }
|
||||||
|
.home-columns { display: flex; gap: 18px; }
|
||||||
|
.home-card { flex: 1; padding: 22px; display: flex; flex-direction: column; gap: 12px; align-items: stretch; }
|
||||||
|
.avatar-row { display: flex; align-items: center; justify-content: center; gap: 14px; }
|
||||||
|
#avatar-preview { background: #e3f2fd; border: 3px solid var(--panel-border); border-radius: 12px; }
|
||||||
|
#name-input { text-align: center; font-size: 20px; }
|
||||||
|
.home-sep { display: flex; align-items: center; gap: 10px; color: #90a4ae; font-weight: 700; }
|
||||||
|
.home-sep::before, .home-sep::after { content: ''; flex: 1; height: 3px; background: #eceff1; border-radius: 2px; }
|
||||||
|
.join-row { display: flex; gap: 8px; }
|
||||||
|
.join-row input { flex: 1; text-transform: uppercase; text-align: center; letter-spacing: 6px; font-weight: 700; }
|
||||||
|
#rooms-card { max-height: 420px; }
|
||||||
|
.rooms-head { display: flex; justify-content: space-between; align-items: center; }
|
||||||
|
.rooms-head h2 { font-size: 20px; color: var(--blue-dark); }
|
||||||
|
#rooms-list { overflow-y: auto; display: flex; flex-direction: column; gap: 8px; min-height: 120px; }
|
||||||
|
.rooms-empty { text-align: center; color: #90a4ae; padding: 30px 0; }
|
||||||
|
.room-row {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
border: 2px solid var(--panel-border); border-radius: 10px; padding: 8px 12px;
|
||||||
|
animation: slideIn .25s ease;
|
||||||
|
}
|
||||||
|
.room-row .room-info { flex: 1; }
|
||||||
|
.room-row .room-code { font-weight: 900; color: var(--blue-dark); }
|
||||||
|
.room-row .room-meta { font-size: 12px; color: #78909c; }
|
||||||
|
.howto { padding: 14px 18px; font-size: 14px; text-align: center; background: rgba(255,255,255,.92); }
|
||||||
|
|
||||||
|
/* ============ top bar ============ */
|
||||||
|
#topbar {
|
||||||
|
display: flex; align-items: center; gap: 16px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
}
|
||||||
|
#room-code-chip {
|
||||||
|
background: rgba(255,255,255,.9); border-radius: 10px; padding: 4px 12px;
|
||||||
|
font-weight: 900; color: var(--blue-dark); cursor: pointer; font-size: 15px;
|
||||||
|
border: 2px solid var(--panel-border);
|
||||||
|
}
|
||||||
|
#room-code-chip:hover { background: #fff; }
|
||||||
|
#round-display { color: #fff; font-weight: 700; text-shadow: 0 2px 0 rgba(0,0,0,.3); white-space: nowrap; }
|
||||||
|
#word-display { flex: 1; display: flex; justify-content: center; gap: 4px; flex-wrap: wrap; }
|
||||||
|
.word-char {
|
||||||
|
min-width: 26px; height: 36px; display: inline-flex; align-items: center; justify-content: center;
|
||||||
|
background: rgba(255,255,255,.92); color: var(--ink);
|
||||||
|
border-radius: 8px; font-weight: 900; font-size: 20px; text-transform: uppercase;
|
||||||
|
border-bottom: 4px solid #90a4ae;
|
||||||
|
animation: popIn .2s ease;
|
||||||
|
}
|
||||||
|
.word-char.space { background: transparent; border: none; min-width: 14px; }
|
||||||
|
.word-char.revealed { border-bottom-color: var(--green); color: var(--green); }
|
||||||
|
.word-hint-label { color: #fff; font-size: 13px; font-weight: 700; align-self: center; margin-right: 6px; text-shadow: 0 1px 0 rgba(0,0,0,.3); }
|
||||||
|
#clock { display: flex; align-items: center; gap: 6px; background: rgba(255,255,255,.92); border-radius: 12px; padding: 3px 10px 3px 4px; border: 2px solid var(--panel-border); }
|
||||||
|
#clock-svg { width: 34px; height: 34px; }
|
||||||
|
.clock-face { fill: #fff; stroke: var(--ink); stroke-width: 2.5; }
|
||||||
|
#clock-hand { stroke: var(--ink); stroke-width: 2.5; stroke-linecap: round; transform-origin: 20px 20px; }
|
||||||
|
.clock-dot { fill: var(--ink); }
|
||||||
|
#clock-num { font-weight: 900; font-size: 18px; min-width: 30px; text-align: center; }
|
||||||
|
#clock.urgent { animation: shake .5s infinite; background: #ffebee; }
|
||||||
|
#clock.urgent #clock-num { color: #e53935; }
|
||||||
|
@keyframes shake { 0%,100% { transform: translateX(0); } 25% { transform: translateX(-2px); } 75% { transform: translateX(2px); } }
|
||||||
|
.icon-btn {
|
||||||
|
background: rgba(255,255,255,.92); border: 2px solid var(--panel-border); border-radius: 10px;
|
||||||
|
font-size: 18px; padding: 5px 10px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.icon-btn:hover { background: #fff; }
|
||||||
|
|
||||||
|
/* ============ main layout ============ */
|
||||||
|
#main {
|
||||||
|
flex: 1; display: flex; gap: 12px; padding: 0 14px 14px;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
#players-panel { width: 230px; padding: 8px; overflow-y: auto; flex-shrink: 0; }
|
||||||
|
#players-list { list-style: none; display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.player-row {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
border: 2px solid var(--panel-border); border-radius: 10px;
|
||||||
|
padding: 4px 8px 4px 4px; position: relative;
|
||||||
|
background: #fff;
|
||||||
|
animation: slideIn .25s ease;
|
||||||
|
}
|
||||||
|
.player-row.drawing { border-color: var(--orange); background: #fff8e1; }
|
||||||
|
.player-row.guessed { border-color: #a5d6a7; background: #f1f8e9; }
|
||||||
|
.player-row.me { box-shadow: inset 0 0 0 2px var(--blue-light); }
|
||||||
|
.player-rank { font-weight: 900; color: #90a4ae; font-size: 13px; width: 18px; text-align: center; }
|
||||||
|
.player-row canvas { border-radius: 8px; background: #e3f2fd; flex-shrink: 0; }
|
||||||
|
.player-info { flex: 1; min-width: 0; }
|
||||||
|
.player-name { font-weight: 700; font-size: 14px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: 4px; }
|
||||||
|
.player-score { font-size: 12px; color: #78909c; font-weight: 700; }
|
||||||
|
.player-status { font-size: 15px; }
|
||||||
|
.player-kick {
|
||||||
|
position: absolute; top: -8px; right: -8px; width: 22px; height: 22px;
|
||||||
|
border-radius: 50%; border: 2px solid #fff; background: #e53935; color: #fff;
|
||||||
|
font-size: 11px; font-weight: 900; cursor: pointer; display: none; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.player-row:hover .player-kick { display: flex; }
|
||||||
|
.typing-dots::after { content: '…'; animation: dots 1s steps(3) infinite; }
|
||||||
|
@keyframes dots { 0% { content: ''; } 33% { content: '.'; } 66% { content: '..'; } 100% { content: '...'; } }
|
||||||
|
|
||||||
|
/* ============ canvas area ============ */
|
||||||
|
#center { flex: 1; display: flex; flex-direction: column; gap: 10px; min-width: 0; }
|
||||||
|
#canvas-wrap {
|
||||||
|
flex: 1; position: relative; overflow: hidden; padding: 0;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
background: #fff; min-height: 0;
|
||||||
|
}
|
||||||
|
#draw-canvas {
|
||||||
|
display: block;
|
||||||
|
cursor: crosshair;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: inset 0 0 0 2px #e3e8ee;
|
||||||
|
}
|
||||||
|
#draw-canvas.no-draw { cursor: not-allowed; }
|
||||||
|
|
||||||
|
/* ============ tools ============ */
|
||||||
|
#tools { padding: 8px 12px; display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
|
||||||
|
#tools.disabled { opacity: .45; pointer-events: none; }
|
||||||
|
#tool-buttons { display: flex; gap: 6px; }
|
||||||
|
.tool-btn {
|
||||||
|
width: 44px; height: 44px; border-radius: 10px; border: 3px solid var(--panel-border);
|
||||||
|
background: #fff; cursor: pointer; color: var(--ink); padding: 6px;
|
||||||
|
transition: transform .08s, border-color .1s, background .1s;
|
||||||
|
}
|
||||||
|
.tool-btn svg { width: 100%; height: 100%; }
|
||||||
|
.tool-btn:hover { transform: translateY(-2px); }
|
||||||
|
.tool-btn.active { border-color: var(--blue-light); background: #e3f2fd; }
|
||||||
|
#brush-sizes { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.size-btn {
|
||||||
|
border-radius: 50%; border: 3px solid var(--panel-border); background: #fff;
|
||||||
|
cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 40px; height: 40px;
|
||||||
|
}
|
||||||
|
.size-btn.active { border-color: var(--blue-light); background: #e3f2fd; }
|
||||||
|
.size-dot { background: var(--ink); border-radius: 50%; }
|
||||||
|
#palette { display: grid; grid-template-rows: repeat(2, 26px); grid-auto-flow: column; gap: 4px; }
|
||||||
|
.color-swatch {
|
||||||
|
width: 26px; height: 26px; border-radius: 7px; cursor: pointer;
|
||||||
|
border: 2px solid rgba(0,0,0,.25);
|
||||||
|
transition: transform .08s;
|
||||||
|
}
|
||||||
|
.color-swatch:hover { transform: scale(1.15); }
|
||||||
|
.color-swatch.active { outline: 3px solid var(--ink); transform: scale(1.12); }
|
||||||
|
|
||||||
|
/* ============ chat ============ */
|
||||||
|
#chat-panel { width: 300px; flex-shrink: 0; display: flex; flex-direction: column; padding: 8px; gap: 6px; }
|
||||||
|
#chat-messages { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 3px; padding: 4px; word-break: break-word; }
|
||||||
|
.chat-msg { font-size: 14px; padding: 3px 8px; border-radius: 8px; animation: slideIn .15s ease; line-height: 1.35; }
|
||||||
|
.chat-msg .chat-name { font-weight: 900; }
|
||||||
|
.chat-msg.sys { font-weight: 700; color: #607d8b; }
|
||||||
|
.chat-msg.good { background: #dcedc8; color: #33691e; font-weight: 700; }
|
||||||
|
.chat-msg.bad { color: #c62828; font-weight: 700; }
|
||||||
|
.chat-msg.close { color: #ef6c00; font-weight: 700; }
|
||||||
|
.chat-msg.guessed-self { background: #dcedc8; color: #33691e; }
|
||||||
|
#typing-indicator { font-size: 12px; color: #78909c; min-height: 16px; font-style: italic; }
|
||||||
|
#chat-input { width: 100%; font-size: 14px; }
|
||||||
|
|
||||||
|
/* ============ overlay / banners ============ */
|
||||||
|
#overlay {
|
||||||
|
position: absolute; inset: 0; z-index: 5;
|
||||||
|
background: rgba(13, 71, 161, .55);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.overlay-card {
|
||||||
|
background: #fff; border-radius: 16px; border: 3px solid var(--panel-border);
|
||||||
|
box-shadow: 0 8px 0 rgba(0,0,0,.25);
|
||||||
|
padding: 26px 34px; text-align: center;
|
||||||
|
max-width: 88%; max-height: 92%; overflow-y: auto;
|
||||||
|
animation: popIn .3s cubic-bezier(.2, 1.6, .4, 1);
|
||||||
|
}
|
||||||
|
.overlay-card h2 { color: var(--blue-dark); margin-bottom: 12px; font-size: 26px; }
|
||||||
|
.overlay-card h3 { color: var(--ink); margin-bottom: 10px; }
|
||||||
|
.overlay-sub { color: #78909c; margin-bottom: 14px; font-weight: 700; }
|
||||||
|
|
||||||
|
.word-choice-row { display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; }
|
||||||
|
.word-choice-btn {
|
||||||
|
font-family: inherit; font-size: 22px; font-weight: 900; padding: 18px 26px;
|
||||||
|
border-radius: 12px; border: 3px solid var(--panel-border); background: #fff; cursor: pointer;
|
||||||
|
color: var(--ink); transition: transform .1s, border-color .1s;
|
||||||
|
animation: popIn .3s cubic-bezier(.2, 1.6, .4, 1) backwards;
|
||||||
|
}
|
||||||
|
.word-choice-btn:nth-child(2) { animation-delay: .08s; }
|
||||||
|
.word-choice-btn:nth-child(3) { animation-delay: .16s; }
|
||||||
|
.word-choice-btn:hover { transform: translateY(-4px) scale(1.04); border-color: var(--blue-light); background: #e3f2fd; }
|
||||||
|
|
||||||
|
#banner {
|
||||||
|
position: absolute; top: 14px; left: 50%; transform: translateX(-50%);
|
||||||
|
z-index: 6; background: rgba(32,48,63,.92); color: #fff;
|
||||||
|
padding: 10px 26px; border-radius: 12px; font-size: 20px; font-weight: 900;
|
||||||
|
animation: bannerDrop .4s cubic-bezier(.2, 1.6, .4, 1); white-space: nowrap;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
@keyframes bannerDrop { from { transform: translate(-50%, -70px); opacity: 0; } to { transform: translate(-50%, 0); opacity: 1; } }
|
||||||
|
|
||||||
|
.reveal-word { font-size: 30px; font-weight: 900; color: var(--green); letter-spacing: 2px; margin: 8px 0 16px; text-transform: uppercase; }
|
||||||
|
.score-recap { display: flex; flex-direction: column; gap: 5px; min-width: 260px; }
|
||||||
|
.score-recap-row { display: flex; justify-content: space-between; gap: 20px; font-weight: 700; padding: 3px 10px; border-radius: 8px; }
|
||||||
|
.score-recap-row:nth-child(odd) { background: #f5f7fa; }
|
||||||
|
.score-recap-row .delta { color: var(--green); }
|
||||||
|
|
||||||
|
/* leaderboard */
|
||||||
|
.podium { display: flex; align-items: flex-end; justify-content: center; gap: 18px; margin: 10px 0 18px; }
|
||||||
|
.podium-slot { display: flex; flex-direction: column; align-items: center; gap: 6px; animation: popIn .4s cubic-bezier(.2,1.6,.4,1) backwards; }
|
||||||
|
.podium-slot:nth-child(1) { order: 2; animation-delay: .1s; }
|
||||||
|
.podium-slot:nth-child(2) { order: 1; animation-delay: .25s; }
|
||||||
|
.podium-slot:nth-child(3) { order: 3; animation-delay: .4s; }
|
||||||
|
.podium-block {
|
||||||
|
width: 92px; border-radius: 10px 10px 0 0; display: flex; align-items: flex-start; justify-content: center;
|
||||||
|
font-size: 30px; font-weight: 900; color: #fff; padding-top: 8px;
|
||||||
|
border: 3px solid rgba(0,0,0,.2); border-bottom: none;
|
||||||
|
}
|
||||||
|
.podium-1 .podium-block { height: 110px; background: linear-gradient(#ffd54f, #ffb300); }
|
||||||
|
.podium-2 .podium-block { height: 80px; background: linear-gradient(#eceff1, #b0bec5); }
|
||||||
|
.podium-3 .podium-block { height: 60px; background: linear-gradient(#ffab91, #bf6a4a); }
|
||||||
|
.podium-name { font-weight: 900; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.podium-score { color: #78909c; font-weight: 700; font-size: 13px; }
|
||||||
|
.medal { font-size: 26px; }
|
||||||
|
.leaderboard-table { min-width: 300px; display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.leaderboard-row { display: flex; align-items: center; gap: 10px; padding: 4px 10px; border-radius: 8px; font-weight: 700; }
|
||||||
|
.leaderboard-row:nth-child(odd) { background: #f5f7fa; }
|
||||||
|
.leaderboard-row.winner { background: #fff8e1; border: 2px solid #ffd54f; }
|
||||||
|
.leaderboard-rank { width: 26px; font-weight: 900; color: #90a4ae; }
|
||||||
|
.leaderboard-name { flex: 1; text-align: left; }
|
||||||
|
|
||||||
|
/* lobby settings */
|
||||||
|
.lobby-card { min-width: min(520px, 80vw); text-align: left; }
|
||||||
|
.lobby-card h2 { text-align: center; }
|
||||||
|
.lobby-code-row { display: flex; justify-content: center; align-items: center; gap: 12px; margin-bottom: 14px; }
|
||||||
|
.lobby-code {
|
||||||
|
font-size: 30px; font-weight: 900; letter-spacing: 8px; color: var(--blue-dark);
|
||||||
|
background: #e3f2fd; padding: 6px 18px; border-radius: 12px; border: 3px dashed var(--blue-light);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.settings-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 18px; margin-bottom: 14px; }
|
||||||
|
.settings-grid label { display: flex; flex-direction: column; gap: 4px; font-weight: 700; font-size: 13px; color: #546e7a; }
|
||||||
|
.settings-grid select, .settings-grid input[type=text] { font-size: 15px; padding: 8px 10px; }
|
||||||
|
.settings-grid .checkbox-label { flex-direction: row; align-items: center; gap: 8px; font-size: 14px; }
|
||||||
|
.settings-grid textarea { grid-column: 1 / -1; resize: vertical; min-height: 54px; font-size: 13px; }
|
||||||
|
.settings-grid .full { grid-column: 1 / -1; }
|
||||||
|
.lobby-actions { display: flex; justify-content: center; gap: 12px; }
|
||||||
|
.waiting-text { text-align: center; color: #78909c; font-weight: 700; margin-top: 10px; animation: pulse 1.6s infinite; }
|
||||||
|
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .5; } }
|
||||||
|
|
||||||
|
/* ============ toasts ============ */
|
||||||
|
#toast-wrap { position: fixed; top: 16px; right: 16px; z-index: 99; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.toast {
|
||||||
|
background: rgba(32,48,63,.95); color: #fff; padding: 12px 18px; border-radius: 10px;
|
||||||
|
font-weight: 700; box-shadow: 0 4px 0 rgba(0,0,0,.3);
|
||||||
|
animation: toastIn .3s cubic-bezier(.2,1.6,.4,1);
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
.toast.error { background: #c62828; }
|
||||||
|
.toast.good { background: #2e7d32; }
|
||||||
|
@keyframes toastIn { from { transform: translateX(120%); } to { transform: translateX(0); } }
|
||||||
|
|
||||||
|
@keyframes popIn { from { transform: scale(.6); opacity: 0; } to { transform: scale(1); opacity: 1; } }
|
||||||
|
@keyframes slideIn { from { transform: translateY(-6px); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||||
|
|
||||||
|
/* confetti */
|
||||||
|
.confetti { position: absolute; top: -12px; width: 10px; height: 16px; z-index: 7; animation: confettiFall linear forwards; border-radius: 2px; pointer-events: none; }
|
||||||
|
@keyframes confettiFall {
|
||||||
|
from { transform: translateY(0) rotate(0deg); opacity: 1; }
|
||||||
|
to { transform: translateY(110vh) rotate(720deg); opacity: .6; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* scrollbars */
|
||||||
|
::-webkit-scrollbar { width: 10px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: rgba(0,0,0,.18); border-radius: 5px; }
|
||||||
|
|
||||||
|
/* ============ responsive ============ */
|
||||||
|
@media (max-width: 1000px) {
|
||||||
|
#players-panel { width: 180px; }
|
||||||
|
#chat-panel { width: 240px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
#main { flex-wrap: wrap; overflow-y: auto; }
|
||||||
|
#center { min-width: 100%; order: -1; height: 70vh; }
|
||||||
|
#players-panel, #chat-panel { width: 100%; max-height: 260px; }
|
||||||
|
.home-columns { flex-direction: column; }
|
||||||
|
.logo-big { font-size: 44px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { AvatarSeed } from '../shared/protocol';
|
||||||
|
|
||||||
|
// Cartoon avatar renderer (skribbl-style blob characters), fully procedural.
|
||||||
|
export const AVATAR_COLORS = [
|
||||||
|
'#f44336', '#ff8f00', '#ffd600', '#4caf50', '#00bcd4', '#2196f3',
|
||||||
|
'#3f51b5', '#9c27b0', '#ec407a', '#8d6e63', '#607d8b', '#7cb342',
|
||||||
|
];
|
||||||
|
|
||||||
|
const SKIN = ['#ffd9b3', '#f2b880', '#c98d5a', '#8c5a33', '#ffe0c4', '#e8b06a'];
|
||||||
|
|
||||||
|
export function drawAvatar(canvas: HTMLCanvasElement, seed: AvatarSeed, size = 80): void {
|
||||||
|
const dpr = Math.min(2, window.devicePixelRatio || 1);
|
||||||
|
canvas.width = size * dpr;
|
||||||
|
canvas.height = size * dpr;
|
||||||
|
canvas.style.width = `${size}px`;
|
||||||
|
canvas.style.height = `${size}px`;
|
||||||
|
const g = canvas.getContext('2d');
|
||||||
|
if (!g) return;
|
||||||
|
g.scale(dpr, dpr);
|
||||||
|
g.clearRect(0, 0, size, size);
|
||||||
|
|
||||||
|
const s = size / 80; // design space 80x80
|
||||||
|
g.save();
|
||||||
|
g.scale(s, s);
|
||||||
|
|
||||||
|
const body = AVATAR_COLORS[seed.color % AVATAR_COLORS.length];
|
||||||
|
|
||||||
|
// body blob
|
||||||
|
g.beginPath();
|
||||||
|
g.ellipse(40, 46, 26, 24, 0, 0, Math.PI * 2);
|
||||||
|
g.fillStyle = body;
|
||||||
|
g.fill();
|
||||||
|
g.lineWidth = 3;
|
||||||
|
g.strokeStyle = 'rgba(0,0,0,0.55)';
|
||||||
|
g.stroke();
|
||||||
|
|
||||||
|
// little feet
|
||||||
|
g.fillStyle = body;
|
||||||
|
for (const fx of [28, 52]) {
|
||||||
|
g.beginPath();
|
||||||
|
g.ellipse(fx, 68, 8, 5, 0, 0, Math.PI * 2);
|
||||||
|
g.fill();
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
const skin = SKIN[(seed.color + seed.extra) % SKIN.length];
|
||||||
|
// face patch
|
||||||
|
g.beginPath();
|
||||||
|
g.ellipse(40, 42, 18, 15, 0, 0, Math.PI * 2);
|
||||||
|
g.fillStyle = skin;
|
||||||
|
g.fill();
|
||||||
|
|
||||||
|
const eyeY = 40, eyeDX = 8;
|
||||||
|
g.lineWidth = 2.4;
|
||||||
|
g.strokeStyle = '#222';
|
||||||
|
g.fillStyle = '#222';
|
||||||
|
|
||||||
|
const style = seed.eyes % 6;
|
||||||
|
const eye = (cx: number, happy: boolean, closed: boolean) => {
|
||||||
|
if (happy) {
|
||||||
|
g.beginPath(); g.arc(cx, eyeY + 1, 4, Math.PI * 1.1, Math.PI * 1.9); g.stroke();
|
||||||
|
} else if (closed) {
|
||||||
|
g.beginPath(); g.moveTo(cx - 3.5, eyeY); g.lineTo(cx + 3.5, eyeY); g.stroke();
|
||||||
|
} else {
|
||||||
|
g.beginPath(); g.arc(cx, eyeY, 3.6, 0, Math.PI * 2); g.fillStyle = '#fff'; g.fill(); g.stroke();
|
||||||
|
g.beginPath(); g.arc(cx + 1, eyeY + 0.6, 1.7, 0, Math.PI * 2); g.fillStyle = '#222'; g.fill();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
switch (style) {
|
||||||
|
case 0: eye(40 - eyeDX, false, false); eye(40 + eyeDX, false, false); break; // normal
|
||||||
|
case 1: eye(40 - eyeDX, true, false); eye(40 + eyeDX, true, false); break; // happy
|
||||||
|
case 2: eye(40 - eyeDX, false, false); eye(40 + eyeDX, false, true); break; // wink
|
||||||
|
case 3: // glasses
|
||||||
|
eye(40 - eyeDX, false, false); eye(40 + eyeDX, false, false);
|
||||||
|
g.lineWidth = 2;
|
||||||
|
g.strokeRect(40 - eyeDX - 6, eyeY - 6, 11, 10);
|
||||||
|
g.strokeRect(40 + eyeDX - 5, eyeY - 6, 11, 10);
|
||||||
|
g.beginPath(); g.moveTo(40 - eyeDX + 5, eyeY - 2); g.lineTo(40 + eyeDX - 5, eyeY - 2); g.stroke();
|
||||||
|
break;
|
||||||
|
case 4: eye(40 - eyeDX, false, true); eye(40 + eyeDX, false, true); break; // sleepy
|
||||||
|
case 5: // surprised big eyes
|
||||||
|
for (const cx of [40 - eyeDX, 40 + eyeDX]) {
|
||||||
|
g.beginPath(); g.arc(cx, eyeY, 5, 0, Math.PI * 2); g.fillStyle = '#fff'; g.fill(); g.stroke();
|
||||||
|
g.beginPath(); g.arc(cx + 1, eyeY + 1, 2.2, 0, Math.PI * 2); g.fillStyle = '#222'; g.fill();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// mouth
|
||||||
|
g.lineWidth = 2.4;
|
||||||
|
g.strokeStyle = '#7a3b2e';
|
||||||
|
const my = 51;
|
||||||
|
switch (seed.mouth % 6) {
|
||||||
|
case 0: g.beginPath(); g.arc(40, my - 3, 7, Math.PI * 0.15, Math.PI * 0.85); g.stroke(); break; // smile
|
||||||
|
case 1: // open smile
|
||||||
|
g.beginPath(); g.arc(40, my - 3, 7, Math.PI * 0.1, Math.PI * 0.9); g.closePath();
|
||||||
|
g.fillStyle = '#7a3b2e'; g.fill();
|
||||||
|
g.beginPath(); g.ellipse(40, my + 3, 4, 2.2, 0, 0, Math.PI * 2); g.fillStyle = '#e57373'; g.fill();
|
||||||
|
break;
|
||||||
|
case 2: g.beginPath(); g.moveTo(34, my); g.quadraticCurveTo(41, my + 5, 47, my - 2); g.stroke(); break; // smirk
|
||||||
|
case 3: g.beginPath(); g.ellipse(40, my, 3.6, 4.6, 0, 0, Math.PI * 2); g.fillStyle = '#7a3b2e'; g.fill(); break; // O
|
||||||
|
case 4: // tongue
|
||||||
|
g.beginPath(); g.arc(40, my - 3, 6, Math.PI * 0.15, Math.PI * 0.85); g.stroke();
|
||||||
|
g.beginPath(); g.ellipse(40, my + 3.5, 3, 3.8, 0, 0, Math.PI * 2); g.fillStyle = '#e57373'; g.fill(); g.stroke();
|
||||||
|
break;
|
||||||
|
case 5: g.beginPath(); g.arc(40, my + 6, 6, Math.PI * 1.15, Math.PI * 1.85); g.stroke(); break; // sad
|
||||||
|
}
|
||||||
|
|
||||||
|
// extras on head
|
||||||
|
g.strokeStyle = 'rgba(0,0,0,0.55)';
|
||||||
|
switch (seed.extra % 6) {
|
||||||
|
case 1: // hair tuft
|
||||||
|
g.fillStyle = '#4e342e';
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(28, 26); g.quadraticCurveTo(34, 8, 40, 22); g.quadraticCurveTo(46, 8, 52, 26);
|
||||||
|
g.quadraticCurveTo(40, 18, 28, 26);
|
||||||
|
g.fill();
|
||||||
|
break;
|
||||||
|
case 2: // cap
|
||||||
|
g.fillStyle = '#e53935';
|
||||||
|
g.beginPath(); g.arc(40, 26, 15, Math.PI, 0); g.closePath(); g.fill(); g.stroke();
|
||||||
|
g.beginPath(); g.ellipse(52, 26, 9, 4, 0.15, 0, Math.PI * 2); g.fill(); g.stroke();
|
||||||
|
g.fillStyle = '#fff'; g.beginPath(); g.arc(40, 13, 3, 0, Math.PI * 2); g.fill();
|
||||||
|
break;
|
||||||
|
case 3: // antenna
|
||||||
|
g.beginPath(); g.moveTo(40, 24); g.quadraticCurveTo(42, 12, 40, 8); g.stroke();
|
||||||
|
g.fillStyle = '#ffd600'; g.beginPath(); g.arc(40, 7, 3.6, 0, Math.PI * 2); g.fill(); g.stroke();
|
||||||
|
break;
|
||||||
|
case 4: // bow
|
||||||
|
g.fillStyle = '#ec407a';
|
||||||
|
for (const dir of [-1, 1]) {
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(40 + dir * 6, 20);
|
||||||
|
g.lineTo(40 + dir * 16, 13);
|
||||||
|
g.lineTo(40 + dir * 16, 27);
|
||||||
|
g.closePath(); g.fill(); g.stroke();
|
||||||
|
}
|
||||||
|
g.beginPath(); g.arc(40, 20, 3.4, 0, Math.PI * 2); g.fill(); g.stroke();
|
||||||
|
break;
|
||||||
|
case 5: // tiny crown
|
||||||
|
g.fillStyle = '#ffd600';
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(28, 22); g.lineTo(28, 12); g.lineTo(34, 18); g.lineTo(40, 9);
|
||||||
|
g.lineTo(46, 18); g.lineTo(52, 12); g.lineTo(52, 22); g.closePath();
|
||||||
|
g.fill(); g.stroke();
|
||||||
|
break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
|
||||||
|
g.restore();
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
import { DrawOp, DrawTool } from '../shared/protocol';
|
||||||
|
|
||||||
|
export const LW = 1000; // logical canvas width
|
||||||
|
export const LH = 750; // logical canvas height
|
||||||
|
|
||||||
|
export interface EngineCallbacks {
|
||||||
|
onBegin(tool: DrawTool, color: string, size: number, points: number[]): void;
|
||||||
|
onPoints(points: number[]): void;
|
||||||
|
onEnd(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BATCH_MS = 40;
|
||||||
|
|
||||||
|
export class DrawEngine {
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
private ctx: CanvasRenderingContext2D;
|
||||||
|
private ops: DrawOp[] = [];
|
||||||
|
private activeLocal: DrawOp | null = null;
|
||||||
|
private activeRemote = new Map<number, DrawOp>();
|
||||||
|
private pending: number[] = [];
|
||||||
|
private batchTimer: number | null = null;
|
||||||
|
private drawing = false;
|
||||||
|
private lastX = 0; private lastY = 0;
|
||||||
|
cb: EngineCallbacks = { onBegin: () => {}, onPoints: () => {}, onEnd: () => {} };
|
||||||
|
|
||||||
|
canDraw = false;
|
||||||
|
tool: DrawTool = 'brush';
|
||||||
|
color = '#000000';
|
||||||
|
size = 12;
|
||||||
|
onLocalDraw: () => void = () => {};
|
||||||
|
|
||||||
|
constructor(canvas: HTMLCanvasElement) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
canvas.width = LW;
|
||||||
|
canvas.height = LH;
|
||||||
|
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||||
|
if (!ctx) throw new Error('no 2d context');
|
||||||
|
this.ctx = ctx;
|
||||||
|
this.clearSurface();
|
||||||
|
this.bindInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- surface ----------------
|
||||||
|
private clearSurface(): void {
|
||||||
|
this.ctx.save();
|
||||||
|
this.ctx.globalCompositeOperation = 'source-over';
|
||||||
|
this.ctx.clearRect(0, 0, LW, LH);
|
||||||
|
this.ctx.fillStyle = '#ffffff';
|
||||||
|
this.ctx.fillRect(0, 0, LW, LH);
|
||||||
|
this.ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
reset(ops: DrawOp[]): void {
|
||||||
|
this.ops = ops.map(o => ({ ...o, points: o.points.slice() }));
|
||||||
|
this.activeRemote.clear();
|
||||||
|
this.activeLocal = null;
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
private redraw(): void {
|
||||||
|
this.clearSurface();
|
||||||
|
for (const op of this.ops) this.applyOp(op);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- stroke rendering ----------------
|
||||||
|
private strokePath(points: number[], color: string, size: number, erase: boolean): void {
|
||||||
|
const g = this.ctx;
|
||||||
|
const pts: number[] = [];
|
||||||
|
for (let i = 0; i + 1 < points.length; i += 2) pts.push(points[i] * LW, points[i + 1] * LH);
|
||||||
|
const n = pts.length / 2;
|
||||||
|
if (n === 0) return;
|
||||||
|
g.save();
|
||||||
|
g.globalCompositeOperation = erase ? 'destination-out' : 'source-over';
|
||||||
|
g.strokeStyle = color;
|
||||||
|
g.fillStyle = color;
|
||||||
|
g.lineWidth = size;
|
||||||
|
g.lineCap = 'round';
|
||||||
|
g.lineJoin = 'round';
|
||||||
|
if (n === 1) {
|
||||||
|
g.beginPath();
|
||||||
|
g.arc(pts[0], pts[1], size / 2, 0, Math.PI * 2);
|
||||||
|
g.fill();
|
||||||
|
} else {
|
||||||
|
g.beginPath();
|
||||||
|
g.moveTo(pts[0], pts[1]);
|
||||||
|
if (n === 2) {
|
||||||
|
g.lineTo(pts[2], pts[3]);
|
||||||
|
} else {
|
||||||
|
for (let i = 1; i < n - 1; i++) {
|
||||||
|
const mx = (pts[i * 2] + pts[(i + 1) * 2]) / 2;
|
||||||
|
const my = (pts[i * 2 + 1] + pts[(i + 1) * 2 + 1]) / 2;
|
||||||
|
g.quadraticCurveTo(pts[i * 2], pts[i * 2 + 1], mx, my);
|
||||||
|
}
|
||||||
|
g.lineTo(pts[(n - 1) * 2], pts[(n - 1) * 2 + 1]);
|
||||||
|
}
|
||||||
|
g.stroke();
|
||||||
|
}
|
||||||
|
g.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyOp(op: DrawOp): void {
|
||||||
|
switch (op.tool) {
|
||||||
|
case 'brush': this.strokePath(op.points, op.color, op.size, false); break;
|
||||||
|
case 'eraser': this.strokePath(op.points, '#000', op.size * 1.6, true); break;
|
||||||
|
case 'fill':
|
||||||
|
if (op.points.length >= 2) this.floodFill(op.points[0] * LW, op.points[1] * LH, op.color);
|
||||||
|
break;
|
||||||
|
case 'clear': this.clearSurface(); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- flood fill ----------------
|
||||||
|
private floodFill(fx: number, fy: number, hex: string): void {
|
||||||
|
const x0 = Math.floor(fx), y0 = Math.floor(fy);
|
||||||
|
if (x0 < 0 || y0 < 0 || x0 >= LW || y0 >= LH) return;
|
||||||
|
const img = this.ctx.getImageData(0, 0, LW, LH);
|
||||||
|
const data = img.data;
|
||||||
|
const idx0 = (y0 * LW + x0) * 4;
|
||||||
|
const tr = data[idx0], tg = data[idx0 + 1], tb = data[idx0 + 2], ta = data[idx0 + 3];
|
||||||
|
const fr = parseInt(hex.slice(1, 3), 16), fg = parseInt(hex.slice(3, 5), 16), fb = parseInt(hex.slice(5, 7), 16);
|
||||||
|
if (tr === fr && tg === fg && tb === fb && ta === 255) return;
|
||||||
|
const TOL = 48;
|
||||||
|
const match = (i: number) =>
|
||||||
|
Math.abs(data[i] - tr) <= TOL && Math.abs(data[i + 1] - tg) <= TOL &&
|
||||||
|
Math.abs(data[i + 2] - tb) <= TOL && Math.abs(data[i + 3] - ta) <= TOL;
|
||||||
|
const stack = [x0, y0];
|
||||||
|
const seen = new Uint8Array(LW * LH);
|
||||||
|
while (stack.length) {
|
||||||
|
const y = stack.pop()!, x = stack.pop()!;
|
||||||
|
const p = y * LW + x;
|
||||||
|
if (seen[p]) continue;
|
||||||
|
seen[p] = 1;
|
||||||
|
const i = p * 4;
|
||||||
|
if (!match(i)) continue;
|
||||||
|
data[i] = fr; data[i + 1] = fg; data[i + 2] = fb; data[i + 3] = 255;
|
||||||
|
if (x > 0) stack.push(x - 1, y);
|
||||||
|
if (x < LW - 1) stack.push(x + 1, y);
|
||||||
|
if (y > 0) stack.push(x, y - 1);
|
||||||
|
if (y < LH - 1) stack.push(x, y + 1);
|
||||||
|
}
|
||||||
|
this.ctx.putImageData(img, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- remote ops ----------------
|
||||||
|
remoteBegin(op: DrawOp): void {
|
||||||
|
const copy = { ...op, points: op.points.slice() };
|
||||||
|
if (op.tool === 'brush' || op.tool === 'eraser') {
|
||||||
|
this.activeRemote.set(op.id, copy);
|
||||||
|
this.ops.push(copy);
|
||||||
|
if (copy.points.length >= 2) this.strokePath(copy.points, copy.color, copy.tool === 'eraser' ? '#000' : copy.color, copy.size, copy.tool === 'eraser');
|
||||||
|
} else {
|
||||||
|
if (op.tool === 'clear') {
|
||||||
|
this.ops = [copy];
|
||||||
|
this.redraw();
|
||||||
|
} else {
|
||||||
|
this.ops.push(copy);
|
||||||
|
this.applyOp(copy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remotePoints(id: number, points: number[]): void {
|
||||||
|
const op = this.activeRemote.get(id);
|
||||||
|
if (!op) return;
|
||||||
|
op.points.push(...points);
|
||||||
|
this.strokePath(op.points, op.tool === 'eraser' ? '#000' : op.color, op.tool === 'eraser' ? op.size * 1.6 : op.size, op.tool === 'eraser');
|
||||||
|
}
|
||||||
|
|
||||||
|
remoteEnd(id: number): void {
|
||||||
|
this.activeRemote.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(opId: number): void {
|
||||||
|
if (this.ops.some(o => o.id === opId)) {
|
||||||
|
this.ops = this.ops.filter(o => o.id !== opId);
|
||||||
|
} else {
|
||||||
|
// own op: we never learn the server id, remove most recent local op
|
||||||
|
for (let i = this.ops.length - 1; i >= 0; i--) {
|
||||||
|
if (this.ops[i].id === -1) { this.ops.splice(i, 1); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.activeRemote.delete(opId);
|
||||||
|
this.redraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- input ----------------
|
||||||
|
private toNorm(e: PointerEvent): [number, number] {
|
||||||
|
const r = this.canvas.getBoundingClientRect();
|
||||||
|
const x = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
|
||||||
|
const y = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
|
||||||
|
return [x, y];
|
||||||
|
}
|
||||||
|
|
||||||
|
private bindInput(): void {
|
||||||
|
this.canvas.style.touchAction = 'none';
|
||||||
|
this.canvas.addEventListener('pointerdown', (e) => {
|
||||||
|
if (!this.canDraw || e.button === 2) return;
|
||||||
|
e.preventDefault();
|
||||||
|
this.canvas.setPointerCapture(e.pointerId);
|
||||||
|
const [x, y] = this.toNorm(e);
|
||||||
|
this.onLocalDraw();
|
||||||
|
if (this.tool === 'fill' || this.tool === 'clear') {
|
||||||
|
const op: DrawOp = { id: -1, playerId: '', tool: this.tool, color: this.color, size: this.size, points: [x, y] };
|
||||||
|
if (this.tool === 'clear') { this.ops = []; this.clearSurface(); }
|
||||||
|
else this.applyOp({ ...op, points: [x, y] });
|
||||||
|
this.cb.onBegin(this.tool, this.color, this.size, [x, y]);
|
||||||
|
this.cb.onEnd();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.drawing = true;
|
||||||
|
this.lastX = x; this.lastY = y;
|
||||||
|
this.activeLocal = { id: -1, playerId: '', tool: this.tool, color: this.color, size: this.size, points: [x, y] };
|
||||||
|
this.ops.push(this.activeLocal);
|
||||||
|
this.strokePath([x, y], this.color, this.tool === 'eraser' ? this.size * 1.6 : this.size, this.tool === 'eraser');
|
||||||
|
this.cb.onBegin(this.tool, this.color, this.size, [x, y]);
|
||||||
|
this.pending = [];
|
||||||
|
this.batchTimer = window.setInterval(() => this.flush(), BATCH_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.canvas.addEventListener('pointermove', (e) => {
|
||||||
|
if (!this.drawing || !this.activeLocal) return;
|
||||||
|
const events = typeof e.getCoalescedEvents === 'function' ? e.getCoalescedEvents() : [e];
|
||||||
|
for (const ev of events) {
|
||||||
|
const [x, y] = this.toNorm(ev as PointerEvent);
|
||||||
|
const dx = x - this.lastX, dy = y - this.lastY;
|
||||||
|
if (dx * dx + dy * dy < 0.0000012) continue;
|
||||||
|
this.activeLocal.points.push(x, y);
|
||||||
|
this.pending.push(x, y);
|
||||||
|
this.lastX = x; this.lastY = y;
|
||||||
|
}
|
||||||
|
this.strokePath(this.activeLocal.points, this.color, this.tool === 'eraser' ? this.size * 1.6 : this.size, this.tool === 'eraser');
|
||||||
|
});
|
||||||
|
|
||||||
|
const end = (e: PointerEvent) => {
|
||||||
|
if (!this.drawing) return;
|
||||||
|
this.drawing = false;
|
||||||
|
try { this.canvas.releasePointerCapture(e.pointerId); } catch { /* ignore */ }
|
||||||
|
this.flush();
|
||||||
|
if (this.batchTimer) { clearInterval(this.batchTimer); this.batchTimer = null; }
|
||||||
|
this.activeLocal = null;
|
||||||
|
this.cb.onEnd();
|
||||||
|
};
|
||||||
|
this.canvas.addEventListener('pointerup', end);
|
||||||
|
this.canvas.addEventListener('pointercancel', end);
|
||||||
|
}
|
||||||
|
|
||||||
|
private flush(): void {
|
||||||
|
if (this.pending.length) {
|
||||||
|
this.cb.onPoints(this.pending.slice());
|
||||||
|
this.pending = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
import {
|
||||||
|
AvatarSeed, DrawTool, PlayerInfo, RoomSettings, RoomSnapshot, S2C, DEFAULT_SETTINGS, randomAvatar,
|
||||||
|
} from '../shared/protocol';
|
||||||
|
import { Net } from './net';
|
||||||
|
import { DrawEngine } from './canvas';
|
||||||
|
import { drawAvatar } from './avatar';
|
||||||
|
import { sfx } from './sfx';
|
||||||
|
import * as ui from './ui';
|
||||||
|
|
||||||
|
// ================= state =================
|
||||||
|
const net = new Net();
|
||||||
|
let selfId = '';
|
||||||
|
let name = localStorage.getItem('skribble_name') ?? '';
|
||||||
|
let avatar: AvatarSeed = JSON.parse(localStorage.getItem('skribble_avatar') ?? 'null') ?? randomAvatar();
|
||||||
|
|
||||||
|
let inRoom = false;
|
||||||
|
let roomCode = '';
|
||||||
|
let hostId = '';
|
||||||
|
let phase: RoomSnapshot['phase'] = 'lobby';
|
||||||
|
let settings: RoomSettings = { ...DEFAULT_SETTINGS };
|
||||||
|
let players: PlayerInfo[] = [];
|
||||||
|
let drawerId: string | null = null;
|
||||||
|
let round = 0;
|
||||||
|
let totalRounds = settings.rounds;
|
||||||
|
let timerTotal = 80;
|
||||||
|
let lastTick = -1;
|
||||||
|
const typingIds = new Set<string>();
|
||||||
|
const typingTimers = new Map<string, number>();
|
||||||
|
let lobbyCtx: { onSettings(s: Partial<RoomSettings>): void; onStart(): void };
|
||||||
|
|
||||||
|
const engine = new DrawEngine(document.getElementById('draw-canvas') as HTMLCanvasElement);
|
||||||
|
let toolsApi: { setEnabled(v: boolean): void; selectTool(t: DrawTool): void };
|
||||||
|
|
||||||
|
// ================= helpers =================
|
||||||
|
const isHost = () => selfId === hostId;
|
||||||
|
const isDrawer = () => selfId === drawerId && phase === 'drawing';
|
||||||
|
|
||||||
|
function saveIdentity(): void {
|
||||||
|
localStorage.setItem('skribble_name', name);
|
||||||
|
localStorage.setItem('skribble_avatar', JSON.stringify(avatar));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendHello(): void {
|
||||||
|
net.send({ t: 'hello', name: name || `Player${Math.floor(Math.random() * 1000)}`, avatar });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlayers(): void {
|
||||||
|
ui.renderPlayers(players, {
|
||||||
|
selfId, hostId, drawerId, typingIds,
|
||||||
|
onKick: (id) => net.send({ t: 'kick', playerId: id }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function playerName(id: string): string {
|
||||||
|
return players.find(p => p.id === id)?.name ?? '???';
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshLobbyOverlay(): void {
|
||||||
|
if (phase !== 'lobby' || !inRoom) return;
|
||||||
|
ui.overlayLobby(settings, roomCode, isHost(), players.length, lobbyCtx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTimer(left: number): void {
|
||||||
|
ui.setTimer(left, timerTotal);
|
||||||
|
if (phase === 'drawing' && left <= 5 && left >= 1 && left !== lastTick) {
|
||||||
|
lastTick = left;
|
||||||
|
sfx.tick();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= net handling =================
|
||||||
|
net.onOpen = () => {
|
||||||
|
sendHello();
|
||||||
|
};
|
||||||
|
|
||||||
|
net.onClose = () => {
|
||||||
|
ui.toast('Disconnected from server. Reload to reconnect.', 'error');
|
||||||
|
};
|
||||||
|
|
||||||
|
net.onMessage = (msg: S2C) => {
|
||||||
|
switch (msg.t) {
|
||||||
|
case 'welcome':
|
||||||
|
selfId = msg.id;
|
||||||
|
net.send({ t: 'list_rooms' });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'room_list':
|
||||||
|
ui.renderRoomList(msg.rooms, (code) => joinRoom(code));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'join_error':
|
||||||
|
ui.toast(msg.reason, 'error');
|
||||||
|
sfx.denied();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'joined': {
|
||||||
|
const r = msg.room;
|
||||||
|
inRoom = true;
|
||||||
|
roomCode = r.code;
|
||||||
|
hostId = r.hostId;
|
||||||
|
phase = r.phase;
|
||||||
|
settings = r.settings;
|
||||||
|
players = r.players;
|
||||||
|
drawerId = r.drawerId;
|
||||||
|
round = r.round;
|
||||||
|
totalRounds = r.totalRounds;
|
||||||
|
timerTotal = r.timeLeft || settings.drawTime;
|
||||||
|
ui.showScreen('game');
|
||||||
|
requestAnimationFrame(fitCanvas);
|
||||||
|
ui.setRoomCode(r.code);
|
||||||
|
ui.setRound(phase === 'lobby' ? 'Lobby' : `Round ${round} of ${totalRounds}`);
|
||||||
|
engine.reset(r.ops ?? []);
|
||||||
|
renderPlayers();
|
||||||
|
ui.toast(`Joined room #${r.code}`, 'good');
|
||||||
|
sfx.join();
|
||||||
|
applyPhaseUI(r.word, r.mask, r.timeLeft);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'player_joined':
|
||||||
|
players.push(msg.player);
|
||||||
|
renderPlayers();
|
||||||
|
refreshLobbyOverlay();
|
||||||
|
sfx.join();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'player_left':
|
||||||
|
players = players.filter(p => p.id !== msg.id);
|
||||||
|
hostId = msg.hostId;
|
||||||
|
renderPlayers();
|
||||||
|
refreshLobbyOverlay();
|
||||||
|
sfx.leave();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'host':
|
||||||
|
hostId = msg.id;
|
||||||
|
renderPlayers();
|
||||||
|
refreshLobbyOverlay();
|
||||||
|
if (msg.id === selfId) ui.toast('You are now the host 👑');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'settings':
|
||||||
|
settings = msg.settings;
|
||||||
|
refreshLobbyOverlay();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'chat':
|
||||||
|
ui.addChat({ name: msg.name, text: msg.text });
|
||||||
|
sfx.pop();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'chat_sys':
|
||||||
|
ui.addChat({ text: msg.text, kind: msg.kind });
|
||||||
|
if (msg.kind === 'good') sfx.correct();
|
||||||
|
else if (msg.kind === 'close') sfx.closeGuess();
|
||||||
|
else if (msg.kind === 'bad') sfx.leave();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'score_update':
|
||||||
|
players = msg.players;
|
||||||
|
renderPlayers();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'typing': {
|
||||||
|
typingIds.add(msg.id);
|
||||||
|
ui.setTypingText(`${playerName(msg.id)} is typing…`);
|
||||||
|
const prev = typingTimers.get(msg.id);
|
||||||
|
if (prev) clearTimeout(prev);
|
||||||
|
typingTimers.set(msg.id, window.setTimeout(() => {
|
||||||
|
typingIds.delete(msg.id);
|
||||||
|
ui.setTypingText('');
|
||||||
|
renderPlayers();
|
||||||
|
}, 2500));
|
||||||
|
renderPlayers();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'game_starting':
|
||||||
|
round = msg.round;
|
||||||
|
totalRounds = msg.totalRounds;
|
||||||
|
ui.setRound(`Round ${round} of ${totalRounds}`);
|
||||||
|
ui.hideOverlay();
|
||||||
|
ui.banner(`🎬 Round ${round} of ${totalRounds}`);
|
||||||
|
drawerId = null;
|
||||||
|
engine.canDraw = false;
|
||||||
|
toolsApi.setEnabled(false);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'word_choice':
|
||||||
|
phase = 'wordChoice';
|
||||||
|
drawerId = msg.drawerId;
|
||||||
|
timerTotal = msg.timeLeft;
|
||||||
|
ui.setTimer(msg.timeLeft, timerTotal);
|
||||||
|
renderPlayers();
|
||||||
|
if (msg.drawerId !== selfId) {
|
||||||
|
ui.overlayWaitingChoice(msg.drawerName);
|
||||||
|
ui.setWord('', { isDrawer: false, label: `${msg.drawerName} is choosing a word` });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'choose_words':
|
||||||
|
sfx.yourTurn();
|
||||||
|
ui.overlayWordChoice(msg.words, (w) => {
|
||||||
|
net.send({ t: 'pick_word', word: w });
|
||||||
|
sfx.click();
|
||||||
|
ui.hideOverlay();
|
||||||
|
ui.banner(`You picked: ${w.toUpperCase()}`);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'draw_start': {
|
||||||
|
phase = 'drawing';
|
||||||
|
drawerId = msg.drawerId;
|
||||||
|
timerTotal = msg.timeLeft;
|
||||||
|
lastTick = -1;
|
||||||
|
round = msg.round;
|
||||||
|
totalRounds = msg.totalRounds;
|
||||||
|
ui.hideOverlay();
|
||||||
|
ui.setRound(`Round ${round} of ${totalRounds}`);
|
||||||
|
engine.reset([]);
|
||||||
|
const mine = msg.drawerId === selfId;
|
||||||
|
engine.canDraw = mine;
|
||||||
|
toolsApi.setEnabled(mine);
|
||||||
|
(document.getElementById('draw-canvas') as HTMLCanvasElement).classList.toggle('no-draw', !mine);
|
||||||
|
if (mine && msg.word) {
|
||||||
|
ui.setWord(msg.word, { isDrawer: true, label: 'DRAW THIS:' });
|
||||||
|
ui.banner(`✏️ Your turn! Draw: ${msg.word.toUpperCase()}`, 3000);
|
||||||
|
sfx.drawStart();
|
||||||
|
} else {
|
||||||
|
ui.setWord(msg.mask, { isDrawer: false, label: 'GUESS:' });
|
||||||
|
ui.banner(`✏️ ${playerName(msg.drawerId)} is drawing!`);
|
||||||
|
sfx.pop();
|
||||||
|
}
|
||||||
|
ui.setTimer(msg.timeLeft, timerTotal);
|
||||||
|
renderPlayers();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'hint':
|
||||||
|
if (!isDrawer()) {
|
||||||
|
ui.setWord(msg.mask, { isDrawer: false, label: 'GUESS:' });
|
||||||
|
sfx.reveal();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'timer':
|
||||||
|
setTimer(msg.timeLeft);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'turn_end':
|
||||||
|
phase = 'turnEnd';
|
||||||
|
engine.canDraw = false;
|
||||||
|
toolsApi.setEnabled(false);
|
||||||
|
ui.overlayTurnEnd(msg.word, msg.reason, msg.scores, selfId);
|
||||||
|
sfx.turnEnd();
|
||||||
|
for (const s of msg.scores) {
|
||||||
|
const p = players.find(x => x.id === s.id);
|
||||||
|
if (p) p.score = s.score;
|
||||||
|
}
|
||||||
|
renderPlayers();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'game_end':
|
||||||
|
phase = 'gameEnd';
|
||||||
|
ui.overlayGameEnd(msg.scores, selfId);
|
||||||
|
sfx.gameEnd();
|
||||||
|
for (const s of msg.scores) {
|
||||||
|
const p = players.find(x => x.id === s.id);
|
||||||
|
if (p) p.score = s.score;
|
||||||
|
}
|
||||||
|
renderPlayers();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'back_to_lobby':
|
||||||
|
phase = 'lobby';
|
||||||
|
drawerId = null;
|
||||||
|
engine.canDraw = false;
|
||||||
|
toolsApi.setEnabled(false);
|
||||||
|
engine.reset([]);
|
||||||
|
ui.setRound('Lobby');
|
||||||
|
ui.setWord('', { isDrawer: false, label: '' });
|
||||||
|
ui.setTimer(0, 1);
|
||||||
|
for (const p of players) p.guessed = false;
|
||||||
|
renderPlayers();
|
||||||
|
refreshLobbyOverlay();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'draw_begin': engine.remoteBegin(msg.op); break;
|
||||||
|
case 'draw_points': engine.remotePoints(msg.id, msg.points); break;
|
||||||
|
case 'draw_end': engine.remoteEnd(msg.id); break;
|
||||||
|
case 'undo': engine.undo(msg.opId); break;
|
||||||
|
|
||||||
|
case 'kicked':
|
||||||
|
ui.toast(`You were removed: ${msg.reason}`, 'error');
|
||||||
|
leaveToHome();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'pong': break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyPhaseUI(word: string | null, mask: string, timeLeft: number): void {
|
||||||
|
// Render UI matching current phase after (re)joining a room.
|
||||||
|
engine.canDraw = false;
|
||||||
|
toolsApi.setEnabled(false);
|
||||||
|
switch (phase) {
|
||||||
|
case 'lobby':
|
||||||
|
refreshLobbyOverlay();
|
||||||
|
ui.setWord('', { isDrawer: false });
|
||||||
|
break;
|
||||||
|
case 'wordChoice':
|
||||||
|
if (drawerId && drawerId !== selfId) ui.overlayWaitingChoice(playerName(drawerId));
|
||||||
|
break;
|
||||||
|
case 'drawing': {
|
||||||
|
const mine = word != null;
|
||||||
|
engine.canDraw = mine;
|
||||||
|
toolsApi.setEnabled(mine);
|
||||||
|
(document.getElementById('draw-canvas') as HTMLCanvasElement).classList.toggle('no-draw', !mine);
|
||||||
|
if (mine) ui.setWord(word!, { isDrawer: true, label: 'DRAW THIS:' });
|
||||||
|
else ui.setWord(mask, { isDrawer: false, label: 'GUESS:' });
|
||||||
|
ui.hideOverlay();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'turnEnd':
|
||||||
|
case 'gameEnd':
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (timeLeft) ui.setTimer(timeLeft, timerTotal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= home screen =================
|
||||||
|
function joinRoom(code: string): void {
|
||||||
|
saveIdentity();
|
||||||
|
sendHello();
|
||||||
|
net.send({ t: 'join_room', code });
|
||||||
|
}
|
||||||
|
|
||||||
|
function initHome(): void {
|
||||||
|
const nameInput = document.getElementById('name-input') as HTMLInputElement;
|
||||||
|
nameInput.value = name;
|
||||||
|
nameInput.oninput = () => { name = nameInput.value; saveIdentity(); };
|
||||||
|
|
||||||
|
const preview = document.getElementById('avatar-preview') as HTMLCanvasElement;
|
||||||
|
drawAvatar(preview, avatar, 80);
|
||||||
|
document.getElementById('btn-avatar-shuffle')!.onclick = () => {
|
||||||
|
avatar = randomAvatar();
|
||||||
|
drawAvatar(preview, avatar, 80);
|
||||||
|
saveIdentity();
|
||||||
|
sfx.pop();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('btn-play')!.onclick = () => {
|
||||||
|
saveIdentity(); sendHello();
|
||||||
|
net.send({ t: 'quick_join' });
|
||||||
|
sfx.click();
|
||||||
|
};
|
||||||
|
document.getElementById('btn-create-private')!.onclick = () => {
|
||||||
|
saveIdentity(); sendHello();
|
||||||
|
net.send({ t: 'create_room', isPublic: false });
|
||||||
|
sfx.click();
|
||||||
|
};
|
||||||
|
const codeInput = document.getElementById('code-input') as HTMLInputElement;
|
||||||
|
document.getElementById('btn-join-code')!.onclick = () => {
|
||||||
|
if (codeInput.value.trim().length !== 4) { ui.toast('Enter the 4-letter room code', 'error'); return; }
|
||||||
|
joinRoom(codeInput.value.trim());
|
||||||
|
sfx.click();
|
||||||
|
};
|
||||||
|
codeInput.onkeydown = (e) => { if (e.key === 'Enter') document.getElementById('btn-join-code')!.click(); };
|
||||||
|
document.getElementById('btn-refresh-rooms')!.onclick = () => { net.send({ t: 'list_rooms' }); sfx.click(); };
|
||||||
|
|
||||||
|
// periodic lobby refresh while on home screen
|
||||||
|
window.setInterval(() => {
|
||||||
|
if (!inRoom && net.connected) net.send({ t: 'list_rooms' });
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function leaveToHome(): void {
|
||||||
|
inRoom = false;
|
||||||
|
phase = 'lobby';
|
||||||
|
players = [];
|
||||||
|
drawerId = null;
|
||||||
|
typingIds.clear();
|
||||||
|
ui.showScreen('home');
|
||||||
|
ui.hideOverlay();
|
||||||
|
net.send({ t: 'list_rooms' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ================= game screen =================
|
||||||
|
function initGame(): void {
|
||||||
|
toolsApi = ui.buildTools({
|
||||||
|
onTool: (t) => { engine.tool = t; },
|
||||||
|
onColor: (c) => { engine.color = c; if (engine.tool === 'eraser') { engine.tool = 'brush'; toolsApi.selectTool('brush'); } },
|
||||||
|
onSize: (s) => { engine.size = s; },
|
||||||
|
onUndo: () => { if (isDrawer()) net.send({ t: 'undo' }); },
|
||||||
|
onClear: () => {
|
||||||
|
if (!isDrawer()) return;
|
||||||
|
engine.reset([]);
|
||||||
|
net.send({ t: 'draw_begin', tool: 'clear', color: engine.color, size: engine.size, points: [0.5, 0.5] });
|
||||||
|
net.send({ t: 'draw_end' });
|
||||||
|
},
|
||||||
|
}, { tool: 'brush', color: '#000000', size: 14 });
|
||||||
|
toolsApi.setEnabled(false);
|
||||||
|
|
||||||
|
engine.cb = {
|
||||||
|
onBegin: (tool, color, size, points) => net.send({ t: 'draw_begin', tool, color, size, points }),
|
||||||
|
onPoints: (points) => net.send({ t: 'draw_points', points }),
|
||||||
|
onEnd: () => net.send({ t: 'draw_end' }),
|
||||||
|
};
|
||||||
|
engine.onLocalDraw = () => {};
|
||||||
|
|
||||||
|
// chat
|
||||||
|
const chatInput = document.getElementById('chat-input') as HTMLInputElement;
|
||||||
|
let lastTypingSent = 0;
|
||||||
|
chatInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const text = chatInput.value.trim();
|
||||||
|
if (text) net.send({ t: 'chat', text });
|
||||||
|
chatInput.value = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
chatInput.addEventListener('input', () => {
|
||||||
|
const now = Date.now();
|
||||||
|
if (now - lastTypingSent > 1500 && chatInput.value.trim()) {
|
||||||
|
lastTypingSent = now;
|
||||||
|
net.send({ t: 'typing' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// top bar
|
||||||
|
document.getElementById('btn-leave')!.onclick = () => {
|
||||||
|
net.send({ t: 'leave_room' });
|
||||||
|
sfx.click();
|
||||||
|
leaveToHome();
|
||||||
|
};
|
||||||
|
document.getElementById('btn-sound')!.onclick = (e) => {
|
||||||
|
sfx.setMuted(!sfx.muted);
|
||||||
|
(e.currentTarget as HTMLElement).textContent = sfx.muted ? '🔇' : '🔊';
|
||||||
|
if (!sfx.muted) sfx.pop();
|
||||||
|
};
|
||||||
|
document.getElementById('room-code-chip')!.onclick = () => {
|
||||||
|
void navigator.clipboard?.writeText(roomCode);
|
||||||
|
ui.toast('Room code copied!', 'good');
|
||||||
|
sfx.pop();
|
||||||
|
};
|
||||||
|
|
||||||
|
// keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if ((e.target as HTMLElement)?.tagName === 'INPUT' || (e.target as HTMLElement)?.tagName === 'TEXTAREA') return;
|
||||||
|
if (!inRoom) return;
|
||||||
|
const k = e.key.toLowerCase();
|
||||||
|
if (k === 'b') { engine.tool = 'brush'; toolsApi.selectTool('brush'); }
|
||||||
|
else if (k === 'e') { engine.tool = 'eraser'; toolsApi.selectTool('eraser'); }
|
||||||
|
else if (k === 'f') { engine.tool = 'fill'; toolsApi.selectTool('fill'); }
|
||||||
|
else if (k === 'z' && (e.ctrlKey || e.metaKey) && isDrawer()) { e.preventDefault(); net.send({ t: 'undo' }); }
|
||||||
|
});
|
||||||
|
|
||||||
|
lobbyCtx = {
|
||||||
|
onSettings: (s) => net.send({ t: 'set_settings', settings: s }),
|
||||||
|
onStart: () => net.send({ t: 'start_game' }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// keep the canvas element at an exact 4:3 aspect so pointer mapping stays precise
|
||||||
|
function fitCanvas(): void {
|
||||||
|
const wrap = document.getElementById('canvas-wrap');
|
||||||
|
const cv = document.getElementById('draw-canvas') as HTMLCanvasElement;
|
||||||
|
if (!wrap.clientWidth) return;
|
||||||
|
const scale = Math.min(wrap.clientWidth / 1000, wrap.clientHeight / 750);
|
||||||
|
cv.style.width = `${Math.floor(1000 * scale)}px`;
|
||||||
|
cv.style.height = `${Math.floor(750 * scale)}px`;
|
||||||
|
}
|
||||||
|
window.addEventListener('resize', fitCanvas);
|
||||||
|
|
||||||
|
// ================= boot =================
|
||||||
|
initHome();
|
||||||
|
initGame();
|
||||||
|
ui.showScreen('home');
|
||||||
|
document.addEventListener('pointerdown', () => sfx.unlock(), { once: true });
|
||||||
|
net.connect();
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { C2S, S2C } from '../shared/protocol';
|
||||||
|
|
||||||
|
export type MessageHandler = (msg: S2C) => void;
|
||||||
|
|
||||||
|
export class Net {
|
||||||
|
private ws: WebSocket | null = null;
|
||||||
|
private pingTimer: number | null = null;
|
||||||
|
onMessage: MessageHandler = () => {};
|
||||||
|
onClose: () => void = () => {};
|
||||||
|
onOpen: () => void = () => {};
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
this.ws = new WebSocket(`${proto}://${location.host}`);
|
||||||
|
this.ws.onopen = () => {
|
||||||
|
this.onOpen();
|
||||||
|
this.pingTimer = window.setInterval(() => this.send({ t: 'ping' }), 5000);
|
||||||
|
};
|
||||||
|
this.ws.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(String(ev.data)) as S2C;
|
||||||
|
this.onMessage(msg);
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
};
|
||||||
|
this.ws.onclose = () => {
|
||||||
|
if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; }
|
||||||
|
this.onClose();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
send(msg: C2S): void {
|
||||||
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||||
|
this.ws.send(JSON.stringify(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get connected(): boolean { return !!this.ws && this.ws.readyState === WebSocket.OPEN; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Synthesized sound effects via WebAudio - no assets needed.
|
||||||
|
let ctx: AudioContext | null = null;
|
||||||
|
let muted = false;
|
||||||
|
|
||||||
|
function ac(): AudioContext | null {
|
||||||
|
try {
|
||||||
|
if (!ctx) ctx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
|
||||||
|
if (ctx.state === 'suspended') void ctx.resume();
|
||||||
|
return ctx;
|
||||||
|
} catch { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToneOpts { type?: OscillatorType; vol?: number; at?: number; slide?: number; }
|
||||||
|
|
||||||
|
function tone(freq: number, dur: number, opts: ToneOpts = {}): void {
|
||||||
|
if (muted) return;
|
||||||
|
const a = ac(); if (!a) return;
|
||||||
|
const t0 = a.currentTime + (opts.at ?? 0);
|
||||||
|
const osc = a.createOscillator();
|
||||||
|
const gain = a.createGain();
|
||||||
|
osc.type = opts.type ?? 'sine';
|
||||||
|
osc.frequency.setValueAtTime(freq, t0);
|
||||||
|
if (opts.slide) osc.frequency.exponentialRampToValueAtTime(Math.max(30, opts.slide), t0 + dur);
|
||||||
|
const v = opts.vol ?? 0.12;
|
||||||
|
gain.gain.setValueAtTime(0.0001, t0);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(v, t0 + 0.012);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||||
|
osc.connect(gain).connect(a.destination);
|
||||||
|
osc.start(t0);
|
||||||
|
osc.stop(t0 + dur + 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sfx = {
|
||||||
|
setMuted(m: boolean) { muted = m; },
|
||||||
|
get muted() { return muted; },
|
||||||
|
unlock() { ac(); },
|
||||||
|
|
||||||
|
click() { tone(650, 0.05, { type: 'square', vol: 0.05 }); },
|
||||||
|
pop() { tone(880, 0.06, { vol: 0.07 }); },
|
||||||
|
join() { tone(523, 0.09, { type: 'triangle' }); tone(784, 0.13, { at: 0.08, type: 'triangle' }); },
|
||||||
|
leave() { tone(440, 0.09, { type: 'triangle' }); tone(294, 0.14, { at: 0.08, type: 'triangle' }); },
|
||||||
|
correct(){ [523, 659, 784, 1047].forEach((f, i) => tone(f, 0.13, { at: i * 0.07, type: 'triangle', vol: 0.12 })); },
|
||||||
|
yourTurn(){ [392, 523, 659, 784].forEach((f, i) => tone(f, 0.12, { at: i * 0.09, type: 'square', vol: 0.07 })); },
|
||||||
|
tick() { tone(1150, 0.035, { type: 'square', vol: 0.05 }); },
|
||||||
|
reveal() { tone(700, 0.09, { type: 'triangle', vol: 0.1, slide: 1000 }); },
|
||||||
|
closeGuess() { tone(500, 0.08, { type: 'sine', vol: 0.09 }); tone(650, 0.1, { at: 0.07, vol: 0.09 }); },
|
||||||
|
turnEnd(){ tone(330, 0.15, { type: 'triangle' }); tone(262, 0.22, { at: 0.13, type: 'triangle' }); },
|
||||||
|
gameEnd(){ [392, 523, 659, 784, 1047, 1319].forEach((f, i) => tone(f, 0.18, { at: i * 0.11, type: 'triangle', vol: 0.11 })); },
|
||||||
|
drawStart(){ tone(600, 0.08, { type: 'square', vol: 0.06, slide: 900 }); },
|
||||||
|
denied() { tone(220, 0.15, { type: 'sawtooth', vol: 0.07, slide: 110 }); },
|
||||||
|
erase() { tone(300, 0.05, { type: 'sawtooth', vol: 0.04, slide: 180 }); },
|
||||||
|
};
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
import { PlayerInfo, RoomSettings, RoomSummary, ScoreEntry, DrawTool } from '../shared/protocol';
|
||||||
|
import { drawAvatar } from './avatar';
|
||||||
|
import { sfx } from './sfx';
|
||||||
|
|
||||||
|
export const PALETTE = [
|
||||||
|
'#ffffff', '#c1c1c1', '#ef130b', '#ff7f00', '#ffcf00', '#64dd17', '#00c853', '#00b8d4', '#2979ff', '#651fff', '#d500f9', '#f50057', '#795548',
|
||||||
|
'#000000', '#616161', '#a30000', '#b35c00', '#8f7200', '#2f7a00', '#00695c', '#006064', '#0d47a1', '#311b92', '#6a0080', '#880e4f', '#4e342e',
|
||||||
|
];
|
||||||
|
export const BRUSH_SIZES = [6, 14, 26, 42];
|
||||||
|
|
||||||
|
function el<K extends keyof HTMLElementTagNameMap>(tag: K, cls?: string, html?: string): HTMLElementTagNameMap[K] {
|
||||||
|
const e = document.createElement(tag);
|
||||||
|
if (cls) e.className = cls;
|
||||||
|
if (html !== undefined) e.innerHTML = html;
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function $(id: string): HTMLElement {
|
||||||
|
const e = document.getElementById(id);
|
||||||
|
if (!e) throw new Error(`missing #${id}`);
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- toasts ----------------
|
||||||
|
export function toast(text: string, kind: 'info' | 'error' | 'good' = 'info'): void {
|
||||||
|
const wrap = $('toast-wrap');
|
||||||
|
const t = el('div', `toast ${kind === 'info' ? '' : kind}`);
|
||||||
|
t.textContent = text;
|
||||||
|
wrap.appendChild(t);
|
||||||
|
setTimeout(() => { t.style.opacity = '0'; t.style.transition = 'opacity .4s'; }, 2600);
|
||||||
|
setTimeout(() => t.remove(), 3100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- screens ----------------
|
||||||
|
export function showScreen(name: 'home' | 'game'): void {
|
||||||
|
$('screen-home').classList.toggle('hidden', name !== 'home');
|
||||||
|
$('screen-game').classList.toggle('hidden', name !== 'game');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- banner ----------------
|
||||||
|
let bannerTimer: number | null = null;
|
||||||
|
export function banner(text: string, ms = 2400): void {
|
||||||
|
const b = $('banner');
|
||||||
|
b.textContent = text;
|
||||||
|
b.classList.remove('hidden');
|
||||||
|
if (bannerTimer) clearTimeout(bannerTimer);
|
||||||
|
bannerTimer = window.setTimeout(() => b.classList.add('hidden'), ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- confetti ----------------
|
||||||
|
export function confetti(parent: HTMLElement, count = 70): void {
|
||||||
|
const colors = ['#f44336', '#ff9800', '#ffeb3b', '#4caf50', '#2196f3', '#9c27b0', '#e91e63'];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const c = el('div', 'confetti');
|
||||||
|
c.style.position = 'fixed';
|
||||||
|
c.style.left = `${Math.random() * 100}vw`;
|
||||||
|
c.style.background = colors[i % colors.length];
|
||||||
|
c.style.animationDuration = `${1.8 + Math.random() * 2.2}s`;
|
||||||
|
c.style.animationDelay = `${Math.random() * 0.8}s`;
|
||||||
|
c.style.width = `${6 + Math.random() * 8}px`;
|
||||||
|
c.style.height = `${10 + Math.random() * 10}px`;
|
||||||
|
parent.appendChild(c);
|
||||||
|
setTimeout(() => c.remove(), 5200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- players list ----------------
|
||||||
|
export interface PlayersViewOpts {
|
||||||
|
selfId: string;
|
||||||
|
hostId: string;
|
||||||
|
drawerId: string | null;
|
||||||
|
typingIds: Set<string>;
|
||||||
|
onKick: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderPlayers(players: PlayerInfo[], opts: PlayersViewOpts): void {
|
||||||
|
const list = $('players-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
const sorted = players.slice().sort((a, b) => b.score - a.score);
|
||||||
|
sorted.forEach((p, i) => {
|
||||||
|
const row = el('li', 'player-row');
|
||||||
|
if (p.id === opts.drawerId) row.classList.add('drawing');
|
||||||
|
if (p.guessed) row.classList.add('guessed');
|
||||||
|
if (p.id === opts.selfId) row.classList.add('me');
|
||||||
|
|
||||||
|
const rank = el('span', 'player-rank', `#${i + 1}`);
|
||||||
|
const cv = el('canvas') as HTMLCanvasElement;
|
||||||
|
drawAvatar(cv, p.avatar, 44);
|
||||||
|
|
||||||
|
const info = el('div', 'player-info');
|
||||||
|
const name = el('div', 'player-name');
|
||||||
|
name.textContent = p.name + (p.id === opts.selfId ? ' (You)' : '');
|
||||||
|
if (p.id === opts.hostId) name.insertAdjacentHTML('afterbegin', '<span title="Host">👑</span>');
|
||||||
|
if (opts.typingIds.has(p.id)) name.insertAdjacentHTML('beforeend', '<span class="typing-dots" style="color:#42a5f5"></span>');
|
||||||
|
const score = el('div', 'player-score', `${p.score} pts`);
|
||||||
|
info.appendChild(name);
|
||||||
|
info.appendChild(score);
|
||||||
|
|
||||||
|
const status = el('span', 'player-status');
|
||||||
|
if (p.id === opts.drawerId) status.textContent = '✏️';
|
||||||
|
else if (p.guessed) status.textContent = '✅';
|
||||||
|
|
||||||
|
row.appendChild(rank);
|
||||||
|
row.appendChild(cv);
|
||||||
|
row.appendChild(info);
|
||||||
|
row.appendChild(status);
|
||||||
|
|
||||||
|
if (opts.selfId === opts.hostId && p.id !== opts.selfId) {
|
||||||
|
const kick = el('button', 'player-kick', '✕');
|
||||||
|
kick.title = `Kick ${p.name}`;
|
||||||
|
kick.onclick = () => { if (confirm(`Kick ${p.name}?`)) opts.onKick(p.id); };
|
||||||
|
row.appendChild(kick);
|
||||||
|
}
|
||||||
|
list.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- chat ----------------
|
||||||
|
export function addChat(opts: { name?: string; text: string; kind?: 'info' | 'good' | 'bad' | 'close' | 'normal' }): void {
|
||||||
|
const box = $('chat-messages');
|
||||||
|
const m = el('div', 'chat-msg');
|
||||||
|
const kind = opts.kind ?? 'normal';
|
||||||
|
if (kind !== 'normal') m.classList.add(kind === 'info' ? 'sys' : kind);
|
||||||
|
if (opts.name) {
|
||||||
|
const n = el('span', 'chat-name');
|
||||||
|
n.textContent = `${opts.name}: `;
|
||||||
|
m.appendChild(n);
|
||||||
|
}
|
||||||
|
m.appendChild(document.createTextNode(opts.text));
|
||||||
|
box.appendChild(m);
|
||||||
|
while (box.children.length > 200) box.firstChild?.remove();
|
||||||
|
box.scrollTop = box.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTypingText(text: string): void {
|
||||||
|
$('typing-indicator').textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- top bar ----------------
|
||||||
|
export function setRoomCode(code: string): void {
|
||||||
|
$('room-code').textContent = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setRound(text: string): void {
|
||||||
|
$('round-display').textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setWord(mask: string, opts: { isDrawer: boolean; label?: string }): void {
|
||||||
|
const wd = $('word-display');
|
||||||
|
wd.innerHTML = '';
|
||||||
|
if (opts.label) wd.appendChild(el('span', 'word-hint-label', opts.label));
|
||||||
|
for (const ch of mask) {
|
||||||
|
if (ch === ' ') { wd.appendChild(el('span', 'word-char space')); continue; }
|
||||||
|
const hidden = ch === '_';
|
||||||
|
const span = el('span', 'word-char' + (hidden ? '' : ' revealed'), hidden ? ' ' : ch);
|
||||||
|
wd.appendChild(span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setTimer(left: number, total: number): void {
|
||||||
|
const clock = $('clock');
|
||||||
|
if (total <= 1) {
|
||||||
|
$('clock-num').textContent = '–';
|
||||||
|
clock.classList.remove('urgent');
|
||||||
|
const hand0 = document.getElementById('clock-hand') as unknown as SVGLineElement | null;
|
||||||
|
if (hand0) hand0.style.transform = 'rotate(0deg)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('clock-num').textContent = String(left);
|
||||||
|
const hand = document.getElementById('clock-hand') as unknown as SVGLineElement | null;
|
||||||
|
if (hand) {
|
||||||
|
const frac = total > 0 ? 1 - left / total : 0;
|
||||||
|
hand.style.transform = `rotate(${frac * 360}deg)`;
|
||||||
|
}
|
||||||
|
clock.classList.toggle('urgent', left <= 15 && left > 0 && total > 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- tools ----------------
|
||||||
|
export interface ToolsCallbacks {
|
||||||
|
onTool(tool: DrawTool): void;
|
||||||
|
onColor(color: string): void;
|
||||||
|
onSize(size: number): void;
|
||||||
|
onUndo(): void;
|
||||||
|
onClear(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTools(cb: ToolsCallbacks, initial: { tool: DrawTool; color: string; size: number }): { setEnabled(v: boolean): void; selectTool(t: DrawTool): void } {
|
||||||
|
const palette = $('palette');
|
||||||
|
palette.innerHTML = '';
|
||||||
|
let activeSwatch: HTMLElement | null = null;
|
||||||
|
for (const color of PALETTE) {
|
||||||
|
const sw = el('div', 'color-swatch');
|
||||||
|
sw.style.background = color;
|
||||||
|
sw.title = color;
|
||||||
|
sw.onclick = () => {
|
||||||
|
activeSwatch?.classList.remove('active');
|
||||||
|
sw.classList.add('active');
|
||||||
|
activeSwatch = sw;
|
||||||
|
cb.onColor(color);
|
||||||
|
sfx.click();
|
||||||
|
};
|
||||||
|
if (color === initial.color) { sw.classList.add('active'); activeSwatch = sw; }
|
||||||
|
palette.appendChild(sw);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizes = $('brush-sizes');
|
||||||
|
sizes.innerHTML = '';
|
||||||
|
let activeSize: HTMLElement | null = null;
|
||||||
|
for (const s of BRUSH_SIZES) {
|
||||||
|
const b = el('button', 'size-btn');
|
||||||
|
b.title = `Brush size ${s}`;
|
||||||
|
const dot = el('span', 'size-dot');
|
||||||
|
const d = 4 + (s / 42) * 20;
|
||||||
|
dot.style.width = `${d}px`;
|
||||||
|
dot.style.height = `${d}px`;
|
||||||
|
b.appendChild(dot);
|
||||||
|
b.onclick = () => {
|
||||||
|
activeSize?.classList.remove('active');
|
||||||
|
b.classList.add('active');
|
||||||
|
activeSize = b;
|
||||||
|
cb.onSize(s);
|
||||||
|
sfx.click();
|
||||||
|
};
|
||||||
|
if (s === initial.size) { b.classList.add('active'); activeSize = b; }
|
||||||
|
sizes.appendChild(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolBtns: Record<string, HTMLElement> = {
|
||||||
|
brush: $('tool-brush'), eraser: $('tool-eraser'), fill: $('tool-fill'),
|
||||||
|
};
|
||||||
|
const selectTool = (t: DrawTool) => {
|
||||||
|
for (const [name, btn] of Object.entries(toolBtns)) btn.classList.toggle('active', name === t);
|
||||||
|
};
|
||||||
|
$('tool-brush').onclick = () => { cb.onTool('brush'); selectTool('brush'); sfx.click(); };
|
||||||
|
$('tool-eraser').onclick = () => { cb.onTool('eraser'); selectTool('eraser'); sfx.click(); };
|
||||||
|
$('tool-fill').onclick = () => { cb.onTool('fill'); selectTool('fill'); sfx.click(); };
|
||||||
|
$('tool-undo').onclick = () => { cb.onUndo(); sfx.click(); };
|
||||||
|
$('tool-clear').onclick = () => { if (confirm('Clear the whole canvas?')) { cb.onClear(); sfx.click(); } };
|
||||||
|
selectTool(initial.tool);
|
||||||
|
|
||||||
|
return {
|
||||||
|
setEnabled(v: boolean) { $('tools').classList.toggle('disabled', !v); },
|
||||||
|
selectTool,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- overlay ----------------
|
||||||
|
export function showOverlay(content: HTMLElement): void {
|
||||||
|
const ov = $('overlay');
|
||||||
|
ov.innerHTML = '';
|
||||||
|
ov.appendChild(content);
|
||||||
|
ov.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideOverlay(): void {
|
||||||
|
$('overlay').classList.add('hidden');
|
||||||
|
$('overlay').innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function overlayCard(cls = ''): HTMLElement {
|
||||||
|
return el('div', `overlay-card ${cls}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overlayWaitingChoice(drawerName: string): void {
|
||||||
|
const card = overlayCard();
|
||||||
|
card.appendChild(el('h2', '', `✏️ ${escapeHtml(drawerName)}`));
|
||||||
|
card.appendChild(el('div', 'overlay-sub', 'is choosing a word…'));
|
||||||
|
showOverlay(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overlayWordChoice(words: string[], onPick: (w: string) => void): void {
|
||||||
|
const card = overlayCard();
|
||||||
|
card.appendChild(el('h2', '', 'Choose a word to draw!'));
|
||||||
|
const row = el('div', 'word-choice-row');
|
||||||
|
for (const w of words) {
|
||||||
|
const b = el('button', 'word-choice-btn');
|
||||||
|
b.textContent = w;
|
||||||
|
b.onclick = () => { onPick(w); };
|
||||||
|
row.appendChild(b);
|
||||||
|
}
|
||||||
|
card.appendChild(row);
|
||||||
|
card.appendChild(el('div', 'overlay-sub', 'Pick fast — or a random word is chosen for you!'));
|
||||||
|
showOverlay(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LobbyCallbacks {
|
||||||
|
onSettings(s: Partial<RoomSettings>): void;
|
||||||
|
onStart(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overlayLobby(settings: RoomSettings, code: string, isHost: boolean, playerCount: number, cb: LobbyCallbacks): void {
|
||||||
|
const card = overlayCard('lobby-card');
|
||||||
|
card.appendChild(el('h2', '', 'Waiting for players…'));
|
||||||
|
|
||||||
|
const codeRow = el('div', 'lobby-code-row');
|
||||||
|
codeRow.appendChild(el('span', '', 'Room code:'));
|
||||||
|
const codeEl = el('span', 'lobby-code', code);
|
||||||
|
codeEl.title = 'Click to copy';
|
||||||
|
codeEl.onclick = () => { void navigator.clipboard?.writeText(code); toast('Code copied! Share it with friends.', 'good'); sfx.pop(); };
|
||||||
|
codeRow.appendChild(codeEl);
|
||||||
|
card.appendChild(codeRow);
|
||||||
|
|
||||||
|
const grid = el('div', 'settings-grid');
|
||||||
|
const sel = (label: string, values: number[], current: number, suffix: string, onChange: (v: number) => void) => {
|
||||||
|
const lab = el('label');
|
||||||
|
lab.appendChild(el('span', '', label));
|
||||||
|
const s = el('select') as HTMLSelectElement;
|
||||||
|
for (const v of values) {
|
||||||
|
const o = el('option', '', `${v}${suffix}`) as HTMLOptionElement;
|
||||||
|
o.value = String(v);
|
||||||
|
s.appendChild(o);
|
||||||
|
}
|
||||||
|
s.value = String(current);
|
||||||
|
s.disabled = !isHost;
|
||||||
|
s.onchange = () => onChange(parseInt(s.value, 10));
|
||||||
|
lab.appendChild(s);
|
||||||
|
return lab;
|
||||||
|
};
|
||||||
|
grid.appendChild(sel('Rounds', [2, 3, 4, 5, 6, 7, 8], settings.rounds, '', v => cb.onSettings({ rounds: v })));
|
||||||
|
grid.appendChild(sel('Draw time (s)', [30, 40, 50, 60, 70, 80, 90, 100, 120, 150, 180], settings.drawTime, ' s', v => cb.onSettings({ drawTime: v })));
|
||||||
|
grid.appendChild(sel('Max players', [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], settings.maxPlayers, '', v => cb.onSettings({ maxPlayers: v })));
|
||||||
|
|
||||||
|
const pubLab = el('label', 'checkbox-label');
|
||||||
|
const pub = el('input') as HTMLInputElement;
|
||||||
|
pub.type = 'checkbox';
|
||||||
|
pub.checked = settings.isPublic;
|
||||||
|
pub.disabled = !isHost;
|
||||||
|
pub.onchange = () => cb.onSettings({ isPublic: pub.checked });
|
||||||
|
pubLab.appendChild(pub);
|
||||||
|
pubLab.appendChild(el('span', '', 'Public room (listed in lobby browser)'));
|
||||||
|
grid.appendChild(pubLab);
|
||||||
|
|
||||||
|
const cwLab = el('label', 'full');
|
||||||
|
cwLab.appendChild(el('span', '', 'Custom words (comma separated, optional)'));
|
||||||
|
const cw = el('textarea') as HTMLTextAreaElement;
|
||||||
|
cw.placeholder = 'e.g. lightsaber, hogwarts, pizza tower…';
|
||||||
|
cw.value = settings.customWords;
|
||||||
|
cw.disabled = !isHost;
|
||||||
|
cw.onchange = () => cb.onSettings({ customWords: cw.value });
|
||||||
|
cwLab.appendChild(cw);
|
||||||
|
grid.appendChild(cwLab);
|
||||||
|
|
||||||
|
const cwoLab = el('label', 'checkbox-label full');
|
||||||
|
const cwo = el('input') as HTMLInputElement;
|
||||||
|
cwo.type = 'checkbox';
|
||||||
|
cwo.checked = settings.customOnly;
|
||||||
|
cwo.disabled = !isHost;
|
||||||
|
cwo.onchange = () => cb.onSettings({ customOnly: cwo.checked });
|
||||||
|
cwoLab.appendChild(cwo);
|
||||||
|
cwoLab.appendChild(el('span', '', 'Use custom words only'));
|
||||||
|
grid.appendChild(cwoLab);
|
||||||
|
|
||||||
|
card.appendChild(grid);
|
||||||
|
|
||||||
|
const actions = el('div', 'lobby-actions');
|
||||||
|
if (isHost) {
|
||||||
|
const start = el('button', 'btn btn-green btn-big', '▶ Start Game');
|
||||||
|
if (playerCount < 2) start.setAttribute('disabled', 'true');
|
||||||
|
start.onclick = () => { cb.onStart(); sfx.click(); };
|
||||||
|
actions.appendChild(start);
|
||||||
|
}
|
||||||
|
card.appendChild(actions);
|
||||||
|
if (!isHost) card.appendChild(el('div', 'waiting-text', 'Waiting for the host to start the game…'));
|
||||||
|
else if (playerCount < 2) card.appendChild(el('div', 'waiting-text', 'Invite friends with the room code — you need at least 2 players!'));
|
||||||
|
|
||||||
|
showOverlay(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overlayTurnEnd(word: string, reason: string, scores: ScoreEntry[], selfId: string): void {
|
||||||
|
const card = overlayCard();
|
||||||
|
card.appendChild(el('h2', '', reason === 'time up' ? "⏰ Time's up!" : reason === 'everyone guessed' ? '🎉 Everyone guessed it!' : 'Turn over'));
|
||||||
|
card.appendChild(el('div', 'overlay-sub', 'The word was'));
|
||||||
|
card.appendChild(el('div', 'reveal-word', escapeHtml(word)));
|
||||||
|
const recap = el('div', 'score-recap');
|
||||||
|
for (const s of scores) {
|
||||||
|
const row = el('div', 'score-recap-row');
|
||||||
|
const name = el('span', '', escapeHtml(s.name) + (s.id === selfId ? ' (You)' : ''));
|
||||||
|
const delta = el('span', 'delta', s.delta > 0 ? `+${s.delta}` : '—');
|
||||||
|
row.appendChild(name); row.appendChild(delta);
|
||||||
|
recap.appendChild(row);
|
||||||
|
}
|
||||||
|
card.appendChild(recap);
|
||||||
|
showOverlay(card);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overlayGameEnd(scores: ScoreEntry[], selfId: string): void {
|
||||||
|
const card = overlayCard();
|
||||||
|
card.appendChild(el('h2', '', '🏆 Game Over!'));
|
||||||
|
const medals = ['🥇', '🥈', '🥉'];
|
||||||
|
const podium = el('div', 'podium');
|
||||||
|
scores.slice(0, 3).forEach((s, i) => {
|
||||||
|
const slot = el('div', `podium-slot podium-${i + 1}`);
|
||||||
|
const cv = el('canvas') as HTMLCanvasElement;
|
||||||
|
drawAvatar(cv, s.avatar, 64);
|
||||||
|
slot.appendChild(el('div', 'medal', medals[i]));
|
||||||
|
slot.appendChild(cv);
|
||||||
|
slot.appendChild(el('div', 'podium-name', escapeHtml(s.name) + (s.id === selfId ? ' (You)' : '')));
|
||||||
|
slot.appendChild(el('div', 'podium-score', `${s.score} pts`));
|
||||||
|
slot.appendChild(el('div', 'podium-block', String(i + 1)));
|
||||||
|
podium.appendChild(slot);
|
||||||
|
});
|
||||||
|
card.appendChild(podium);
|
||||||
|
|
||||||
|
const table = el('div', 'leaderboard-table');
|
||||||
|
scores.forEach((s, i) => {
|
||||||
|
const row = el('div', 'leaderboard-row' + (i === 0 ? ' winner' : ''));
|
||||||
|
row.appendChild(el('span', 'leaderboard-rank', `#${i + 1}`));
|
||||||
|
row.appendChild(el('span', 'leaderboard-name', escapeHtml(s.name)));
|
||||||
|
row.appendChild(el('span', '', `${s.score} pts`));
|
||||||
|
table.appendChild(row);
|
||||||
|
});
|
||||||
|
card.appendChild(table);
|
||||||
|
card.appendChild(el('div', 'overlay-sub', 'Returning to lobby…'));
|
||||||
|
showOverlay(card);
|
||||||
|
confetti(document.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- public rooms list ----------------
|
||||||
|
export function renderRoomList(rooms: RoomSummary[], onJoin: (code: string) => void): void {
|
||||||
|
const list = $('rooms-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
if (!rooms.length) {
|
||||||
|
list.appendChild(el('div', 'rooms-empty', 'No public rooms yet — create one!'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const r of rooms) {
|
||||||
|
const row = el('div', 'room-row');
|
||||||
|
const info = el('div', 'room-info');
|
||||||
|
info.appendChild(el('div', 'room-code', `#${r.code}`));
|
||||||
|
info.appendChild(el('div', 'room-meta', `${r.players}/${r.maxPlayers} players · ${r.phase === 'lobby' ? 'in lobby' : 'playing'} · host ${escapeHtml(r.hostName)}`));
|
||||||
|
const join = el('button', 'btn btn-green btn-small', 'Join');
|
||||||
|
join.disabled = r.players >= r.maxPlayers;
|
||||||
|
join.onclick = () => onJoin(r.code);
|
||||||
|
row.appendChild(info);
|
||||||
|
row.appendChild(join);
|
||||||
|
list.appendChild(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function escapeHtml(s: string): string {
|
||||||
|
return s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] ?? c));
|
||||||
|
}
|
||||||
@@ -0,0 +1,623 @@
|
|||||||
|
import { WebSocket } from 'ws';
|
||||||
|
import {
|
||||||
|
AvatarSeed, C2S, DrawOp, DrawTool, PlayerInfo, Phase, RoomSettings, RoomSnapshot,
|
||||||
|
RoomSummary, S2C, ScoreEntry, DEFAULT_SETTINGS, normalizeWord,
|
||||||
|
} from '../shared/protocol';
|
||||||
|
import { WORDS } from '../shared/words';
|
||||||
|
|
||||||
|
const WORD_CHOICE_TIME = 15; // seconds to pick a word
|
||||||
|
const TURN_END_DELAY = 5; // seconds showing the word before next turn
|
||||||
|
const GAME_END_DELAY = 15; // seconds on leaderboard before lobby
|
||||||
|
const TIMER_BROADCAST_MS = 500; // timer sync interval
|
||||||
|
const IDLE_KICK_MS = 150_000; // kick after 2.5 min without user activity
|
||||||
|
const SWEEP_MS = 10_000;
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
export function genId(): string {
|
||||||
|
return (nextId++).toString(36) + Math.random().toString(36).slice(2, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
function genCode(): string {
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
||||||
|
let c = '';
|
||||||
|
for (let i = 0; i < 4; i++) c += chars[Math.floor(Math.random() * chars.length)];
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickDistinct<T>(arr: T[], n: number): T[] {
|
||||||
|
const copy = arr.slice();
|
||||||
|
const out: T[] = [];
|
||||||
|
while (out.length < n && copy.length) {
|
||||||
|
out.push(copy.splice(Math.floor(Math.random() * copy.length), 1)[0]);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function levenshtein(a: string, b: string): number {
|
||||||
|
const m = a.length, n = b.length;
|
||||||
|
if (!m) return n; if (!n) return m;
|
||||||
|
let prev = new Array<number>(n + 1);
|
||||||
|
for (let j = 0; j <= n; j++) prev[j] = j;
|
||||||
|
for (let i = 1; i <= m; i++) {
|
||||||
|
let cur = [i];
|
||||||
|
for (let j = 1; j <= n; j++) {
|
||||||
|
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
||||||
|
}
|
||||||
|
prev = cur;
|
||||||
|
}
|
||||||
|
return prev[n];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Player {
|
||||||
|
ws: WebSocket;
|
||||||
|
id = genId();
|
||||||
|
name = '';
|
||||||
|
avatar: AvatarSeed = { color: 0, eyes: 0, mouth: 0, extra: 0 };
|
||||||
|
score = 0;
|
||||||
|
guessed = false;
|
||||||
|
lastActivity = Date.now();
|
||||||
|
room: Room | null = null;
|
||||||
|
/** op id currently being streamed (drawer only) */
|
||||||
|
activeOpId: number | null = null;
|
||||||
|
|
||||||
|
constructor(ws: WebSocket) { this.ws = ws; }
|
||||||
|
|
||||||
|
get connected(): boolean { return this.ws.readyState === WebSocket.OPEN; }
|
||||||
|
|
||||||
|
send(msg: S2C): void {
|
||||||
|
if (this.connected) {
|
||||||
|
try { this.ws.send(JSON.stringify(msg)); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info(): PlayerInfo {
|
||||||
|
return { id: this.id, name: this.name, avatar: this.avatar, score: this.score, guessed: this.guessed, connected: this.connected };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Room {
|
||||||
|
code: string;
|
||||||
|
players: Player[] = [];
|
||||||
|
hostId = '';
|
||||||
|
settings: RoomSettings = { ...DEFAULT_SETTINGS };
|
||||||
|
phase: Phase = 'lobby';
|
||||||
|
round = 0;
|
||||||
|
drawerIdx = -1;
|
||||||
|
word = '';
|
||||||
|
mask = '';
|
||||||
|
ops: DrawOp[] = [];
|
||||||
|
opSeq = 0;
|
||||||
|
turnEndsAt = 0;
|
||||||
|
private hintRevealCount = 0;
|
||||||
|
private revealedIdx = new Set<number>();
|
||||||
|
private turnGains = new Map<string, number>();
|
||||||
|
private timers: NodeJS.Timeout[] = [];
|
||||||
|
private timerInterval: NodeJS.Timeout | null = null;
|
||||||
|
private manager: GameManager;
|
||||||
|
|
||||||
|
constructor(manager: GameManager, isPublic: boolean) {
|
||||||
|
this.manager = manager;
|
||||||
|
this.settings.isPublic = isPublic;
|
||||||
|
do { this.code = genCode(); } while (manager.rooms.has(this.code));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- basic helpers ----------------
|
||||||
|
get drawer(): Player | null {
|
||||||
|
return this.players[this.drawerIdx] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
playerInfo(): PlayerInfo[] { return this.players.map(p => p.info()); }
|
||||||
|
|
||||||
|
broadcast(msg: S2C, exceptId?: string): void {
|
||||||
|
for (const p of this.players) if (p.id !== exceptId) p.send(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
sendTo(id: string, msg: S2C): void {
|
||||||
|
this.players.find(p => p.id === id)?.send(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
sysChat(text: string, kind: 'info' | 'good' | 'bad' | 'close' = 'info', toId?: string): void {
|
||||||
|
if (toId) this.sendTo(toId, { t: 'chat_sys', text, kind });
|
||||||
|
else this.broadcast({ t: 'chat_sys', text, kind });
|
||||||
|
}
|
||||||
|
|
||||||
|
touch(p: Player): void { p.lastActivity = Date.now(); }
|
||||||
|
|
||||||
|
private clearTimers(): void {
|
||||||
|
for (const t of this.timers) clearTimeout(t);
|
||||||
|
this.timers = [];
|
||||||
|
if (this.timerInterval) { clearInterval(this.timerInterval); this.timerInterval = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private after(ms: number, fn: () => void): void {
|
||||||
|
this.timers.push(setTimeout(fn, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- membership ----------------
|
||||||
|
addPlayer(p: Player): { ok: boolean; reason?: string } {
|
||||||
|
if (this.players.length >= this.settings.maxPlayers) return { ok: false, reason: 'Room is full' };
|
||||||
|
p.room = this;
|
||||||
|
// unique-ish name
|
||||||
|
let name = p.name;
|
||||||
|
let i = 2;
|
||||||
|
while (this.players.some(x => x.name === name)) name = `${p.name}#${i++}`;
|
||||||
|
p.name = name;
|
||||||
|
this.players.push(p);
|
||||||
|
if (!this.hostId) this.hostId = p.id;
|
||||||
|
p.send({ t: 'joined', room: this.snapshotFor(p) });
|
||||||
|
this.broadcast({ t: 'player_joined', player: p.info() }, p.id);
|
||||||
|
this.sysChat(`${p.name} joined the game!`, 'info');
|
||||||
|
this.manager.notifyLobbyChanged();
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
removePlayer(p: Player, reason: string): void {
|
||||||
|
const idx = this.players.indexOf(p);
|
||||||
|
if (idx === -1) return;
|
||||||
|
const leavingDrawerId = this.drawer?.id ?? null;
|
||||||
|
const wasDrawer = leavingDrawerId === p.id && (this.phase === 'drawing' || this.phase === 'wordChoice');
|
||||||
|
this.players.splice(idx, 1);
|
||||||
|
p.room = null;
|
||||||
|
p.activeOpId = null;
|
||||||
|
this.broadcast({ t: 'player_left', id: p.id, name: p.name, reason, hostId: this.hostId });
|
||||||
|
this.sysChat(`${p.name} left${reason ? ` (${reason})` : ''}.`, 'bad');
|
||||||
|
|
||||||
|
if (this.hostId === p.id) {
|
||||||
|
this.hostId = this.players[0]?.id ?? '';
|
||||||
|
if (this.hostId) this.broadcast({ t: 'host', id: this.hostId });
|
||||||
|
}
|
||||||
|
|
||||||
|
// fix drawer index after removal so turn order stays consistent
|
||||||
|
if (leavingDrawerId) {
|
||||||
|
this.drawerIdx = this.players.findIndex(x => x.id === leavingDrawerId);
|
||||||
|
}
|
||||||
|
if (this.players.length === 0) {
|
||||||
|
this.clearTimers();
|
||||||
|
this.manager.destroyRoom(this.code);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.phase !== 'lobby') {
|
||||||
|
if (wasDrawer) {
|
||||||
|
if (this.players.length < 2) { this.abortGame('Not enough players'); return; }
|
||||||
|
this.sysChat('The drawer left! Skipping turn.', 'bad');
|
||||||
|
this.endTurn('drawer left');
|
||||||
|
} else if (this.players.length < 2) {
|
||||||
|
this.abortGame('Not enough players');
|
||||||
|
} else {
|
||||||
|
this.checkAllGuessed();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.manager.notifyLobbyChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
kickPlayer(targetId: string, byId: string, reason: string): void {
|
||||||
|
const target = this.players.find(p => p.id === targetId);
|
||||||
|
if (!target) return;
|
||||||
|
target.send({ t: 'kicked', reason });
|
||||||
|
try { target.ws.close(); } catch { /* ignore */ }
|
||||||
|
this.removePlayer(target, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshotFor(p: Player): RoomSnapshot {
|
||||||
|
const isDrawer = this.drawer?.id === p.id;
|
||||||
|
return {
|
||||||
|
code: this.code,
|
||||||
|
selfId: p.id,
|
||||||
|
hostId: this.hostId,
|
||||||
|
phase: this.phase,
|
||||||
|
settings: { ...this.settings },
|
||||||
|
players: this.playerInfo(),
|
||||||
|
round: this.round,
|
||||||
|
totalRounds: this.settings.rounds,
|
||||||
|
drawerId: this.drawer?.id ?? null,
|
||||||
|
mask: this.mask,
|
||||||
|
word: isDrawer && this.phase === 'drawing' ? this.word : null,
|
||||||
|
timeLeft: Math.max(0, Math.ceil((this.turnEndsAt - Date.now()) / 1000)),
|
||||||
|
ops: this.ops,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
summary(): RoomSummary {
|
||||||
|
return {
|
||||||
|
code: this.code,
|
||||||
|
players: this.players.length,
|
||||||
|
maxPlayers: this.settings.maxPlayers,
|
||||||
|
phase: this.phase,
|
||||||
|
hostName: this.players.find(p => p.id === this.hostId)?.name ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- settings ----------------
|
||||||
|
setSettings(p: Player, s: Partial<RoomSettings>): void {
|
||||||
|
if (p.id !== this.hostId || this.phase !== 'lobby') return;
|
||||||
|
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, Math.round(v) || lo));
|
||||||
|
if (typeof s.rounds === 'number') this.settings.rounds = clamp(s.rounds, 2, 8);
|
||||||
|
if (typeof s.drawTime === 'number') this.settings.drawTime = clamp(s.drawTime, 30, 180);
|
||||||
|
if (typeof s.maxPlayers === 'number') this.settings.maxPlayers = Math.max(this.players.length, clamp(s.maxPlayers, 2, 12));
|
||||||
|
if (typeof s.isPublic === 'boolean') this.settings.isPublic = s.isPublic;
|
||||||
|
if (typeof s.customWords === 'string') this.settings.customWords = s.customWords.slice(0, 2000);
|
||||||
|
if (typeof s.customOnly === 'boolean') this.settings.customOnly = s.customOnly;
|
||||||
|
this.broadcast({ t: 'settings', settings: { ...this.settings } });
|
||||||
|
this.manager.notifyLobbyChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- chat / guessing ----------------
|
||||||
|
handleChat(p: Player, text: string): void {
|
||||||
|
text = String(text).slice(0, 140).trim();
|
||||||
|
if (!text) return;
|
||||||
|
this.touch(p);
|
||||||
|
|
||||||
|
const inGuessing = this.phase === 'drawing' && this.drawer?.id !== p.id && !p.guessed;
|
||||||
|
if (inGuessing) {
|
||||||
|
const guess = normalizeWord(text);
|
||||||
|
const answer = normalizeWord(this.word);
|
||||||
|
if (guess === answer) {
|
||||||
|
this.onCorrectGuess(p);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// close guess (edit distance 1) for longer words
|
||||||
|
if (answer.length >= 4 && Math.abs(guess.length - answer.length) <= 1 && levenshtein(guess, answer) === 1) {
|
||||||
|
this.sysChat(`${p.name} is close!`, 'close');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// never leak the answer through chat while drawing
|
||||||
|
const answer = this.phase === 'drawing' ? normalizeWord(this.word) : '';
|
||||||
|
if (answer && normalizeWord(text).includes(answer)) {
|
||||||
|
p.send({ t: 'chat', id: p.id, name: p.name, text });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.broadcast({ t: 'chat', id: p.id, name: p.name, text });
|
||||||
|
}
|
||||||
|
|
||||||
|
handleTyping(p: Player): void {
|
||||||
|
this.broadcast({ t: 'typing', id: p.id }, p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onCorrectGuess(p: Player): void {
|
||||||
|
p.guessed = true;
|
||||||
|
const total = Math.max(1, this.settings.drawTime);
|
||||||
|
const left = Math.max(0, (this.turnEndsAt - Date.now()) / 1000);
|
||||||
|
const already = this.players.filter(x => x.guessed && x.id !== this.drawer?.id).length - 1;
|
||||||
|
const gain = Math.max(20, Math.round((100 + 200 * (left / total) - 15 * Math.max(0, already)) / 5) * 5);
|
||||||
|
p.score += gain;
|
||||||
|
this.turnGains.set(p.id, (this.turnGains.get(p.id) ?? 0) + gain);
|
||||||
|
this.sysChat('You guessed the word!', 'good', p.id);
|
||||||
|
this.sysChat(`${p.name} guessed the word! (+${gain})`, 'good');
|
||||||
|
this.broadcast({ t: 'score_update', players: this.playerInfo() });
|
||||||
|
this.checkAllGuessed();
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkAllGuessed(): void {
|
||||||
|
if (this.phase !== 'drawing') return;
|
||||||
|
const guessers = this.players.filter(x => x.id !== this.drawer?.id);
|
||||||
|
if (guessers.length > 0 && guessers.every(x => x.guessed)) {
|
||||||
|
this.endTurn('everyone guessed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- game flow ----------------
|
||||||
|
startGame(p: Player): void {
|
||||||
|
if (p.id !== this.hostId || this.phase !== 'lobby') return;
|
||||||
|
if (this.players.length < 2) { this.sysChat('Need at least 2 players to start!', 'bad', p.id); return; }
|
||||||
|
for (const pl of this.players) { pl.score = 0; pl.guessed = false; }
|
||||||
|
this.round = 1;
|
||||||
|
this.drawerIdx = -1;
|
||||||
|
this.phase = 'wordChoice';
|
||||||
|
this.broadcast({ t: 'game_starting', round: this.round, totalRounds: this.settings.rounds });
|
||||||
|
this.sysChat(`Game starting! Round 1 of ${this.settings.rounds}.`, 'info');
|
||||||
|
this.manager.notifyLobbyChanged();
|
||||||
|
this.after(2500, () => this.nextTurn());
|
||||||
|
}
|
||||||
|
|
||||||
|
private wordPool(): string[] {
|
||||||
|
const custom = this.settings.customWords.split(',').map(w => normalizeWord(w)).filter(w => w.length >= 2 && w.length <= 40);
|
||||||
|
if (this.settings.customOnly && custom.length >= 3) return custom;
|
||||||
|
return custom.length ? WORDS.concat(custom) : WORDS;
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextTurn(): void {
|
||||||
|
this.clearTimers();
|
||||||
|
if (this.players.length < 2) { this.abortGame('Not enough players'); return; }
|
||||||
|
this.drawerIdx++;
|
||||||
|
if (this.drawerIdx >= this.players.length) {
|
||||||
|
this.round++;
|
||||||
|
this.drawerIdx = 0;
|
||||||
|
if (this.round > this.settings.rounds) { this.endGame(); return; }
|
||||||
|
this.broadcast({ t: 'game_starting', round: this.round, totalRounds: this.settings.rounds });
|
||||||
|
this.sysChat(`Round ${this.round} of ${this.settings.rounds}`, 'info');
|
||||||
|
}
|
||||||
|
const drawer = this.drawer!;
|
||||||
|
this.phase = 'wordChoice';
|
||||||
|
this.word = '';
|
||||||
|
this.mask = '';
|
||||||
|
this.ops = [];
|
||||||
|
this.turnGains.clear();
|
||||||
|
for (const pl of this.players) pl.guessed = false;
|
||||||
|
|
||||||
|
const words = pickDistinct(this.wordPool(), 3);
|
||||||
|
if (words.length < 3) { // extremely small custom pool
|
||||||
|
while (words.length < 3) words.push(words[0] ?? 'cat');
|
||||||
|
}
|
||||||
|
this.turnEndsAt = Date.now() + WORD_CHOICE_TIME * 1000;
|
||||||
|
this.broadcast({ t: 'word_choice', drawerId: drawer.id, drawerName: drawer.name, timeLeft: WORD_CHOICE_TIME });
|
||||||
|
drawer.send({ t: 'choose_words', words, timeLeft: WORD_CHOICE_TIME });
|
||||||
|
this.startTimerBroadcast();
|
||||||
|
this.after(WORD_CHOICE_TIME * 1000, () => {
|
||||||
|
if (this.phase === 'wordChoice') this.beginDrawing(words[Math.floor(Math.random() * words.length)]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pickWord(p: Player, word: string): void {
|
||||||
|
if (this.phase !== 'wordChoice' || this.drawer?.id !== p.id) return;
|
||||||
|
this.touch(p);
|
||||||
|
word = normalizeWord(String(word).slice(0, 40));
|
||||||
|
if (!word) return;
|
||||||
|
this.beginDrawing(word);
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeMask(word: string): string {
|
||||||
|
return word.split('').map(ch => (ch === ' ' ? ' ' : '_')).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
private beginDrawing(word: string): void {
|
||||||
|
this.clearTimers();
|
||||||
|
this.phase = 'drawing';
|
||||||
|
this.word = word;
|
||||||
|
this.mask = this.makeMask(word);
|
||||||
|
this.revealedIdx.clear();
|
||||||
|
const letters = word.replace(/ /g, '').length;
|
||||||
|
this.hintRevealCount = letters <= 3 ? 0 : letters <= 6 ? 1 : letters <= 9 ? 2 : 3;
|
||||||
|
this.turnEndsAt = Date.now() + this.settings.drawTime * 1000;
|
||||||
|
const drawer = this.drawer!;
|
||||||
|
this.broadcast({
|
||||||
|
t: 'draw_start', drawerId: drawer.id, mask: this.mask, word: null,
|
||||||
|
timeLeft: this.settings.drawTime, round: this.round, totalRounds: this.settings.rounds,
|
||||||
|
}, drawer.id);
|
||||||
|
drawer.send({
|
||||||
|
t: 'draw_start', drawerId: drawer.id, mask: this.mask, word: this.word,
|
||||||
|
timeLeft: this.settings.drawTime, round: this.round, totalRounds: this.settings.rounds,
|
||||||
|
});
|
||||||
|
this.sysChat(`${drawer.name} is drawing!`, 'info');
|
||||||
|
this.startTimerBroadcast();
|
||||||
|
// schedule hints at fractions of elapsed time
|
||||||
|
const revealAt = [0.45, 0.65, 0.85];
|
||||||
|
for (let i = 0; i < this.hintRevealCount; i++) {
|
||||||
|
this.after(this.settings.drawTime * 1000 * revealAt[i], () => this.revealHint());
|
||||||
|
}
|
||||||
|
this.after(this.settings.drawTime * 1000, () => this.endTurn('time up'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private revealHint(): void {
|
||||||
|
if (this.phase !== 'drawing') return;
|
||||||
|
const idxs: number[] = [];
|
||||||
|
for (let i = 0; i < this.word.length; i++) {
|
||||||
|
if (this.word[i] !== ' ' && !this.revealedIdx.has(i)) idxs.push(i);
|
||||||
|
}
|
||||||
|
if (!idxs.length) return;
|
||||||
|
const pick = idxs[Math.floor(Math.random() * idxs.length)];
|
||||||
|
this.revealedIdx.add(pick);
|
||||||
|
const maskArr = this.mask.split('');
|
||||||
|
maskArr[pick] = this.word[pick];
|
||||||
|
this.mask = maskArr.join('');
|
||||||
|
this.broadcast({ t: 'hint', mask: this.mask });
|
||||||
|
}
|
||||||
|
|
||||||
|
private startTimerBroadcast(): void {
|
||||||
|
if (this.timerInterval) clearInterval(this.timerInterval);
|
||||||
|
this.timerInterval = setInterval(() => {
|
||||||
|
const left = Math.max(0, Math.ceil((this.turnEndsAt - Date.now()) / 1000));
|
||||||
|
this.broadcast({ t: 'timer', timeLeft: left });
|
||||||
|
}, TIMER_BROADCAST_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private endTurn(reason: string): void {
|
||||||
|
if (this.phase !== 'drawing' && this.phase !== 'wordChoice') return;
|
||||||
|
this.clearTimers();
|
||||||
|
this.phase = 'turnEnd';
|
||||||
|
const drawerId = this.drawer?.id ?? '';
|
||||||
|
// drawer points: 25 per guesser, +50 bonus if everyone guessed
|
||||||
|
const guessers = this.players.filter(x => x.id !== drawerId);
|
||||||
|
const numGuessed = guessers.filter(x => x.guessed).length;
|
||||||
|
const drawer = this.drawer;
|
||||||
|
let drawerGain = 0;
|
||||||
|
if (drawer && reason !== 'drawer left' && numGuessed > 0) {
|
||||||
|
drawerGain = numGuessed * 25 + (numGuessed === guessers.length ? 50 : 0);
|
||||||
|
drawer.score += drawerGain;
|
||||||
|
this.turnGains.set(drawer.id, (this.turnGains.get(drawer.id) ?? 0) + drawerGain);
|
||||||
|
}
|
||||||
|
const scores: ScoreEntry[] = this.players
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.map(x => ({ id: x.id, name: x.name, avatar: x.avatar, score: x.score, delta: this.turnGains.get(x.id) ?? 0 }));
|
||||||
|
this.broadcast({ t: 'turn_end', word: this.word || '(none)', reason, scores });
|
||||||
|
this.after(TURN_END_DELAY * 1000, () => this.nextTurn());
|
||||||
|
}
|
||||||
|
|
||||||
|
private endGame(): void {
|
||||||
|
this.clearTimers();
|
||||||
|
this.phase = 'gameEnd';
|
||||||
|
const scores: ScoreEntry[] = this.players
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.map(x => ({ id: x.id, name: x.name, avatar: x.avatar, score: x.score, delta: 0 }));
|
||||||
|
this.broadcast({ t: 'game_end', scores });
|
||||||
|
this.sysChat(`Game over! Winner: ${scores[0]?.name ?? '—'} 🏆`, 'good');
|
||||||
|
this.manager.notifyLobbyChanged();
|
||||||
|
this.after(GAME_END_DELAY * 1000, () => {
|
||||||
|
if (this.phase === 'gameEnd') {
|
||||||
|
this.phase = 'lobby';
|
||||||
|
this.round = 0;
|
||||||
|
this.drawerIdx = -1;
|
||||||
|
this.word = '';
|
||||||
|
this.ops = [];
|
||||||
|
this.broadcast({ t: 'back_to_lobby' });
|
||||||
|
this.manager.notifyLobbyChanged();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private abortGame(reason: string): void {
|
||||||
|
this.clearTimers();
|
||||||
|
this.phase = 'lobby';
|
||||||
|
this.round = 0;
|
||||||
|
this.drawerIdx = -1;
|
||||||
|
this.word = '';
|
||||||
|
this.ops = [];
|
||||||
|
this.sysChat(`Game stopped: ${reason}. Back to lobby.`, 'bad');
|
||||||
|
this.broadcast({ t: 'back_to_lobby' });
|
||||||
|
this.manager.notifyLobbyChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- drawing ----------------
|
||||||
|
private canDraw(p: Player): boolean {
|
||||||
|
return this.phase === 'drawing' && this.drawer?.id === p.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawBegin(p: Player, tool: DrawTool, color: string, size: number, points: number[]): void {
|
||||||
|
if (!this.canDraw(p)) return;
|
||||||
|
this.touch(p);
|
||||||
|
const op: DrawOp = {
|
||||||
|
id: ++this.opSeq,
|
||||||
|
playerId: p.id,
|
||||||
|
tool,
|
||||||
|
color: String(color).slice(0, 16),
|
||||||
|
size: Math.min(72, Math.max(1, size | 0)),
|
||||||
|
points: (points ?? []).slice(0, 8).map(v => Math.min(1, Math.max(0, +v || 0))),
|
||||||
|
};
|
||||||
|
if (tool === 'clear') {
|
||||||
|
this.ops = [op]; // a clear wipes the whole op log
|
||||||
|
} else {
|
||||||
|
this.ops.push(op);
|
||||||
|
}
|
||||||
|
if (this.ops.length > 400) this.ops.splice(0, this.ops.length - 400);
|
||||||
|
if (tool === 'brush' || tool === 'eraser') p.activeOpId = op.id;
|
||||||
|
this.broadcast({ t: 'draw_begin', op }, p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPoints(p: Player, points: number[]): void {
|
||||||
|
if (!this.canDraw(p) || p.activeOpId == null) return;
|
||||||
|
this.touch(p);
|
||||||
|
const op = this.ops.find(o => o.id === p.activeOpId);
|
||||||
|
if (!op) return;
|
||||||
|
const clean = points.slice(0, 200).map(v => Math.min(1, Math.max(0, +v || 0)));
|
||||||
|
if (op.points.length < 12000) op.points.push(...clean);
|
||||||
|
this.broadcast({ t: 'draw_points', id: op.id, points: clean }, p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawEnd(p: Player): void {
|
||||||
|
if (p.activeOpId == null) return;
|
||||||
|
const id = p.activeOpId;
|
||||||
|
p.activeOpId = null;
|
||||||
|
this.broadcast({ t: 'draw_end', id }, p.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
undo(p: Player): void {
|
||||||
|
if (!this.canDraw(p)) return;
|
||||||
|
this.touch(p);
|
||||||
|
for (let i = this.ops.length - 1; i >= 0; i--) {
|
||||||
|
if (this.ops[i].playerId === p.id && this.ops[i].tool !== 'clear') {
|
||||||
|
const opId = this.ops[i].id;
|
||||||
|
this.ops.splice(i, 1);
|
||||||
|
this.broadcast({ t: 'undo', opId });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
export class GameManager {
|
||||||
|
rooms = new Map<string, Room>();
|
||||||
|
|
||||||
|
createRoom(isPublic: boolean): Room {
|
||||||
|
const room = new Room(this, isPublic);
|
||||||
|
this.rooms.set(room.code, room);
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyRoom(code: string): void {
|
||||||
|
this.rooms.delete(code);
|
||||||
|
this.notifyLobbyChanged();
|
||||||
|
}
|
||||||
|
|
||||||
|
findRoom(code: string): Room | undefined {
|
||||||
|
return this.rooms.get(code.trim().toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
quickJoinRoom(): Room {
|
||||||
|
const open = [...this.rooms.values()].filter(r =>
|
||||||
|
r.settings.isPublic && r.players.length < r.settings.maxPlayers && (r.phase === 'lobby' || r.players.length < r.settings.maxPlayers));
|
||||||
|
open.sort((a, b) => b.players.length - a.players.length);
|
||||||
|
return open[0] ?? this.createRoom(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
publicRooms(): RoomSummary[] {
|
||||||
|
return [...this.rooms.values()].filter(r => r.settings.isPublic).map(r => r.summary());
|
||||||
|
}
|
||||||
|
|
||||||
|
onLobbyList(p: Player): void {
|
||||||
|
p.send({ t: 'room_list', rooms: this.publicRooms() });
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyLobbyChanged(): void { /* clients poll the list explicitly */ }
|
||||||
|
|
||||||
|
startSweep(): void {
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const room of this.rooms.values()) {
|
||||||
|
for (const p of [...room.players]) {
|
||||||
|
if (now - p.lastActivity > IDLE_KICK_MS) {
|
||||||
|
room.kickPlayer(p.id, 'server', 'inactive');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, SWEEP_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
handle(p: Player, msg: C2S): void {
|
||||||
|
const room = p.room;
|
||||||
|
switch (msg.t) {
|
||||||
|
case 'ping': p.send({ t: 'pong' }); return;
|
||||||
|
case 'list_rooms': this.onLobbyList(p); return;
|
||||||
|
case 'create_room': {
|
||||||
|
if (room) room.removePlayer(p, '');
|
||||||
|
const r = this.createRoom(!!msg.isPublic);
|
||||||
|
if (!r.addPlayer(p).ok) p.send({ t: 'join_error', reason: 'Could not create room' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'join_room': {
|
||||||
|
if (room) room.removePlayer(p, '');
|
||||||
|
const r = this.findRoom(msg.code ?? '');
|
||||||
|
if (!r) { p.send({ t: 'join_error', reason: 'Room not found. Check the code!' }); return; }
|
||||||
|
const res = r.addPlayer(p);
|
||||||
|
if (!res.ok) p.send({ t: 'join_error', reason: res.reason ?? 'Cannot join' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'quick_join': {
|
||||||
|
if (room) room.removePlayer(p, '');
|
||||||
|
const r = this.quickJoinRoom();
|
||||||
|
const res = r.addPlayer(p);
|
||||||
|
if (!res.ok) p.send({ t: 'join_error', reason: res.reason ?? 'Cannot join' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'leave_room':
|
||||||
|
if (room) room.removePlayer(p, '');
|
||||||
|
return;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
if (!room) return;
|
||||||
|
switch (msg.t) {
|
||||||
|
case 'chat': room.handleChat(p, msg.text); break;
|
||||||
|
case 'typing': room.handleTyping(p); break;
|
||||||
|
case 'set_settings': room.setSettings(p, msg.settings); break;
|
||||||
|
case 'start_game': room.touch(p); room.startGame(p); break;
|
||||||
|
case 'pick_word': room.pickWord(p, msg.word); break;
|
||||||
|
case 'draw_begin': room.drawBegin(p, msg.tool, msg.color, msg.size, msg.points); break;
|
||||||
|
case 'draw_points': room.drawPoints(p, msg.points); break;
|
||||||
|
case 'draw_end': room.drawEnd(p); break;
|
||||||
|
case 'undo': room.undo(p); break;
|
||||||
|
case 'kick': if (p.id === room.hostId) room.kickPlayer(msg.playerId, p.id, 'kicked by host'); break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import * as http from 'http';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { WebSocketServer, WebSocket } from 'ws';
|
||||||
|
import { C2S } from '../shared/protocol';
|
||||||
|
import { GameManager, Player } from './game';
|
||||||
|
|
||||||
|
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
|
||||||
|
const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
|
||||||
|
|
||||||
|
const MIME: Record<string, string> = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'application/javascript; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.json': 'application/json',
|
||||||
|
'.map': 'application/json',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.ico': 'image/x-icon',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
let urlPath = decodeURIComponent((req.url ?? '/').split('?')[0]);
|
||||||
|
if (urlPath === '/') urlPath = '/index.html';
|
||||||
|
const filePath = path.normalize(path.join(PUBLIC_DIR, urlPath));
|
||||||
|
if (!filePath.startsWith(PUBLIC_DIR)) { res.writeHead(403); res.end('Forbidden'); return; }
|
||||||
|
fs.readFile(filePath, (err, data) => {
|
||||||
|
if (err) { res.writeHead(404); res.end('Not found'); return; }
|
||||||
|
res.writeHead(200, { 'Content-Type': MIME[path.extname(filePath)] ?? 'application/octet-stream' });
|
||||||
|
res.end(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
const manager = new GameManager();
|
||||||
|
manager.startSweep();
|
||||||
|
|
||||||
|
wss.on('connection', (ws: WebSocket) => {
|
||||||
|
const player = new Player(ws);
|
||||||
|
let greeted = false;
|
||||||
|
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
let msg: C2S;
|
||||||
|
try {
|
||||||
|
msg = JSON.parse(String(raw));
|
||||||
|
} catch { return; }
|
||||||
|
if (!msg || typeof msg !== 'object' || typeof (msg as { t?: unknown }).t !== 'string') return;
|
||||||
|
|
||||||
|
if (msg.t === 'hello') {
|
||||||
|
if (!greeted || !player.room) {
|
||||||
|
player.name = String(msg.name ?? '').trim().slice(0, 16) || `Player${Math.floor(Math.random() * 1000)}`;
|
||||||
|
player.avatar = msg.avatar ?? player.avatar;
|
||||||
|
}
|
||||||
|
if (!greeted) {
|
||||||
|
greeted = true;
|
||||||
|
player.send({ t: 'welcome', id: player.id });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!greeted) return;
|
||||||
|
try {
|
||||||
|
manager.handle(player, msg);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('handle error', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
if (player.room) player.room.removePlayer(player, 'disconnected');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`\n ✏️ SKRIBBLE server running at http://localhost:${PORT}\n`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
// ============================================================
|
||||||
|
// Shared protocol types used by both server and client
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
export interface AvatarSeed {
|
||||||
|
color: number; // index into palette
|
||||||
|
eyes: number; // eye style index
|
||||||
|
mouth: number; // mouth style index
|
||||||
|
extra: number; // hat/hair style index
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlayerInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar: AvatarSeed;
|
||||||
|
score: number;
|
||||||
|
guessed: boolean; // guessed the word this turn
|
||||||
|
connected: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomSettings {
|
||||||
|
rounds: number; // 2..8
|
||||||
|
drawTime: number; // seconds 30..180
|
||||||
|
maxPlayers: number; // 2..12
|
||||||
|
isPublic: boolean;
|
||||||
|
customWords: string; // comma separated
|
||||||
|
customOnly: boolean; // use custom words exclusively
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomSummary {
|
||||||
|
code: string;
|
||||||
|
players: number;
|
||||||
|
maxPlayers: number;
|
||||||
|
phase: Phase;
|
||||||
|
hostName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Phase = 'lobby' | 'wordChoice' | 'drawing' | 'turnEnd' | 'gameEnd';
|
||||||
|
|
||||||
|
export type DrawTool = 'brush' | 'eraser' | 'fill' | 'clear';
|
||||||
|
|
||||||
|
export interface DrawOp {
|
||||||
|
id: number;
|
||||||
|
playerId: string;
|
||||||
|
tool: DrawTool;
|
||||||
|
color: string;
|
||||||
|
size: number; // brush diameter in logical px
|
||||||
|
points: number[]; // normalized 0..1 [x0,y0,x1,y1,...]; fill uses [x,y]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScoreEntry {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
avatar: AvatarSeed;
|
||||||
|
score: number;
|
||||||
|
delta: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RoomSnapshot {
|
||||||
|
code: string;
|
||||||
|
selfId: string;
|
||||||
|
hostId: string;
|
||||||
|
phase: Phase;
|
||||||
|
settings: RoomSettings;
|
||||||
|
players: PlayerInfo[];
|
||||||
|
round: number;
|
||||||
|
totalRounds: number;
|
||||||
|
drawerId: string | null;
|
||||||
|
mask: string; // hint mask for guessers
|
||||||
|
word: string | null; // only set for the drawer
|
||||||
|
timeLeft: number; // seconds
|
||||||
|
ops: DrawOp[]; // canvas op log for this turn
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Client -> Server ----------------
|
||||||
|
export type C2S =
|
||||||
|
| { t: 'hello'; name: string; avatar: AvatarSeed }
|
||||||
|
| { t: 'create_room'; isPublic: boolean }
|
||||||
|
| { t: 'join_room'; code: string }
|
||||||
|
| { t: 'quick_join' }
|
||||||
|
| { t: 'list_rooms' }
|
||||||
|
| { t: 'leave_room' }
|
||||||
|
| { t: 'chat'; text: string }
|
||||||
|
| { t: 'typing' }
|
||||||
|
| { t: 'set_settings'; settings: Partial<RoomSettings> }
|
||||||
|
| { t: 'start_game' }
|
||||||
|
| { t: 'pick_word'; word: string }
|
||||||
|
| { t: 'draw_begin'; tool: DrawTool; color: string; size: number; points: number[] }
|
||||||
|
| { t: 'draw_points'; points: number[] }
|
||||||
|
| { t: 'draw_end' }
|
||||||
|
| { t: 'undo' }
|
||||||
|
| { t: 'kick'; playerId: string }
|
||||||
|
| { t: 'ping' };
|
||||||
|
|
||||||
|
// ---------------- Server -> Client ----------------
|
||||||
|
export type S2C =
|
||||||
|
| { t: 'welcome'; id: string }
|
||||||
|
| { t: 'joined'; room: RoomSnapshot }
|
||||||
|
| { t: 'join_error'; reason: string }
|
||||||
|
| { t: 'room_list'; rooms: RoomSummary[] }
|
||||||
|
| { t: 'player_joined'; player: PlayerInfo }
|
||||||
|
| { t: 'player_left'; id: string; name: string; reason: string; hostId: string }
|
||||||
|
| { t: 'host'; id: string }
|
||||||
|
| { t: 'settings'; settings: RoomSettings }
|
||||||
|
| { t: 'chat'; id: string; name: string; text: string }
|
||||||
|
| { t: 'chat_sys'; text: string; kind: 'info' | 'good' | 'bad' | 'close' }
|
||||||
|
| { t: 'score_update'; players: PlayerInfo[] }
|
||||||
|
| { t: 'typing'; id: string }
|
||||||
|
| { t: 'game_starting'; round: number; totalRounds: number }
|
||||||
|
| { t: 'word_choice'; drawerId: string; drawerName: string; timeLeft: number }
|
||||||
|
| { t: 'choose_words'; words: string[]; timeLeft: number }
|
||||||
|
| { t: 'draw_start'; drawerId: string; mask: string; word: string | null; timeLeft: number; round: number; totalRounds: number }
|
||||||
|
| { t: 'hint'; mask: string }
|
||||||
|
| { t: 'timer'; timeLeft: number }
|
||||||
|
| { t: 'turn_end'; word: string; reason: string; scores: ScoreEntry[] }
|
||||||
|
| { t: 'game_end'; scores: ScoreEntry[] }
|
||||||
|
| { t: 'back_to_lobby' }
|
||||||
|
| { t: 'draw_begin'; op: DrawOp }
|
||||||
|
| { t: 'draw_points'; id: number; points: number[] }
|
||||||
|
| { t: 'draw_end'; id: number }
|
||||||
|
| { t: 'undo'; opId: number }
|
||||||
|
| { t: 'kicked'; reason: string }
|
||||||
|
| { t: 'pong' };
|
||||||
|
|
||||||
|
export const DEFAULT_SETTINGS: RoomSettings = {
|
||||||
|
rounds: 3,
|
||||||
|
drawTime: 80,
|
||||||
|
maxPlayers: 8,
|
||||||
|
isPublic: true,
|
||||||
|
customWords: '',
|
||||||
|
customOnly: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function randomAvatar(): AvatarSeed {
|
||||||
|
return {
|
||||||
|
color: Math.floor(Math.random() * 12),
|
||||||
|
eyes: Math.floor(Math.random() * 6),
|
||||||
|
mouth: Math.floor(Math.random() * 6),
|
||||||
|
extra: Math.floor(Math.random() * 6),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeWord(w: string): string {
|
||||||
|
return w.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// Word list for the game (English, drawable nouns/verbs/adjectives)
|
||||||
|
export const WORDS: string[] = [
|
||||||
|
'airplane','alarm clock','alien','alligator','anchor','angel','ant','apple','arm','armchair','arrow',
|
||||||
|
'astronaut','avocado','axe','baby','backpack','bacon','bag','ball','balloon','banana','bandage','bank',
|
||||||
|
'barbecue','barn','baseball','basket','basketball','bat','bath','battery','beach','bear','beard','bed',
|
||||||
|
'bee','beer','bell','belt','bench','bicycle','bird','birthday cake','biscuit','black hole','blender',
|
||||||
|
'boat','bomb','bone','book','boomerang','boot','bottle','bow tie','bowl','box','brain','bread','bridge',
|
||||||
|
'broom','brush','bubble','bucket','bug','bulldozer','burger','bus','butterfly','button','cactus','cage',
|
||||||
|
'camera','campfire','candle','cannon','car','carrot','castle','cat','caterpillar','cave','chain','chair',
|
||||||
|
'cheese','chess','chicken','chimney','church','cigar','circle','cliff','clock','cloud','clown','coat',
|
||||||
|
'coconut','coffee','compass','computer','cookie','cow','crab','crayon','crown','cucumber','cup','cupcake',
|
||||||
|
'curtain','crown','diamond','dice','dinosaur','doctor','dog','dolphin','donkey','donut','door','dragon',
|
||||||
|
'drum','duck','ear','earth','egg','elephant','envelope','eye','face','fan','feather','fence','finger',
|
||||||
|
'fire','fire truck','fireworks','fish','flag','flamingo','flashlight','flower','football','fork','fountain',
|
||||||
|
'fox','fridge','frog','garden','giraffe','glasses','glove','goat','goldfish','guitar','hamburger','hammer',
|
||||||
|
'hand','hat','heart','hedgehog','helicopter','helmet','hill','hippo','hockey','horse','hospital','hot air balloon',
|
||||||
|
'hot dog','house','hug','ice cream','iceberg','igloo','island','jacket','jail','jellyfish','kangaroo','key',
|
||||||
|
'keyboard','king','kite','kitten','knife','ladder','ladybug','lamp','laptop','leaf','leg','lemon','lighthouse',
|
||||||
|
'lightning','lion','lips','lizard','llama','lock','lollipop','magnet','mailbox','map','mermaid','microphone',
|
||||||
|
'monkey','moon','mosquito','motorcycle','mountain','mouse','mushroom','music','nail','necklace','needle','ninja',
|
||||||
|
'nose','ocean','octopus','onion','orange','ostrich','owl','paintbrush','painting','palm tree','panda','pants',
|
||||||
|
'parrot','peacock','peanut','pear','pencil','penguin','piano','pie','pig','pillow','pizza','plane','planet',
|
||||||
|
'popsicle','potato','pumpkin','puppet','pyramid','rabbit','rainbow','rake','robot','rocket','roller coaster',
|
||||||
|
'sailboat','sandwich','satellite','scissors','scorpion','seahorse','shark','sheep','ship','shoe','skeleton',
|
||||||
|
'skateboard','skull','slide','snail','snake','snowman','sock','spider','spoon','squirrel','star','submarine',
|
||||||
|
'sun','sushi','swan','sword','table','taco','teapot','teddy bear','television','tent','tiger','toaster','toilet',
|
||||||
|
'tomato','toothbrush','tornado','tractor','train','tree','trumpet','turtle','umbrella','unicorn','vacuum','vampire',
|
||||||
|
'volcano','waffle','watermelon','whale','wheel','windmill','window','witch','wizard','wolf','worm','zebra','zombie',
|
||||||
|
'skipping rope','ketchup','magician','surfboard','barber','beaver','broomstick','candlestick','doughnut',
|
||||||
|
'electric guitar','firefighter','frying pan','grill','hammock','helipad','hourglass','ice cube','koala','ladybug',
|
||||||
|
'meatball','milkshake','muffin','notebook','padlock','pancake','pineapple','plunger','pretzel','remote','rollercoaster',
|
||||||
|
'salt','sandcastle','scarecrow','seashell','slinky','snowflake','spaghetti','sunglasses','telescope','toothpaste',
|
||||||
|
'traffic light','treasure chest','tricycle','typewriter','video game','washing machine','water bottle','wind turbine',
|
||||||
|
'xylophone','yo-yo','zipper','alarm','ambulance','angel wings','antenna','archery','asparagus','badminton','bagel',
|
||||||
|
'ballerina','banjo','barrel','bathtub','battery','bayonet','bedbug','bell pepper','binoculars','birthday','blimp',
|
||||||
|
'bonfire','bookcase','bottleship','bowling','braces','broccoli','bulldozer','burrito','busboy','butcher','calendar',
|
||||||
|
'camel','canoe','carousel','catapult','cauldron','cello','centipede','chainsaw','cheerleader','cheesecake','chimpanzee',
|
||||||
|
'chipmunk','chocolate','clarinet','clipboard','coffin','comet','computer mouse','corn','cowboy','crocodile','crossbow',
|
||||||
|
'crow','cruise ship','crutch','cuckoo clock','curling','cyclops','dagger','dandelion','dartboard','deer','desk',
|
||||||
|
'diamond ring','dishwasher','diving board','dodgeball','doghouse','dove','dracula','dragonfly','drumsticks','dumbbell',
|
||||||
|
'earmuffs','easel','eclipse','eel','eggplant','elevator','emu','escalator','eye patch','fairy','ferris wheel',
|
||||||
|
'fidget spinner','fireplace','firework','fishing rod','flame','flask','flip flops','flute','fog','footprint',
|
||||||
|
'fortune cookie','fossil','frying pan','garbage truck','garlic','gas mask','genie','geyser','ghost','goblin',
|
||||||
|
'golf cart','gorilla','grandma','grapefruit','grasshopper','gravestone','gravy','grenade','guillotine','gumball',
|
||||||
|
'hair dryer','hairbrush','half pipe','hamster','handcuffs','harp','harry potter','hawaii','headphones','hedgehog',
|
||||||
|
'high heels','hitchhiker','hive','hologram','honeycomb','hook','hopscotch','hornet','hose','hot tub','hula hoop',
|
||||||
|
'hummingbird','hurdle','hurricane','ice skate','iceberg','inchworm','iron','javelin','jaywalk','jester','jet ski',
|
||||||
|
'jigsaw puzzle','juggle','jump rope','kayak','kettle','killer whale','kimono','king kong','knuckle','kraken',
|
||||||
|
'ladybug','lantern','lasagna','lawn mower','leash','leprechaun','lifeguard','limousine','lobster','log cabin',
|
||||||
|
'lumberjack','macaroni','mailbox','manatee','mandolin','marshmallow','maze','meerkat','meteor','microscope',
|
||||||
|
'mime','minotaur','mitten','mohawk','monocle','monster','moped','mosquito','moth','mousetrap','mullet','mummy',
|
||||||
|
'narwhal','necktie','nightmare','nunchucks','obelisk','ogre','oil rig','omelet','opera','orca','ostrich',
|
||||||
|
'pacific','paddle','pagoda','panther','paperclip','parachute','passport','peach','pelican','pepperoni','perfume',
|
||||||
|
'pickle','picnic','pigeon','piñata','pirate','platypus','plumbing','pogo stick','poodle','porcupine','porthole',
|
||||||
|
'pterodactyl','pufferfish','quicksand','quiver','raccoon','radar','radiator','ramen','reindeer','revolver',
|
||||||
|
'rhino','ringmaster','river','roadrunner','robin','rocking chair','rooster','rowboat','saddle','saxophone',
|
||||||
|
'scuba diver','seesaw','shamrock','shopping cart','skunk','skyscraper','slippers','slot machine','snowboard',
|
||||||
|
'solar system','sombrero','space shuttle','sphinx','spider web','stingray','stomach','stopwatch','stork',
|
||||||
|
'streetlight','submarine','suitcase','sunflower','surfing','swamp','swimming pool','swing','swordfish','tadpole',
|
||||||
|
'tailor','tambourine','tarantula','tattoo','taxi','teepee','thermometer','thunder','tiara','toenail','toucan',
|
||||||
|
'trampoline','treasure','trombone','tugboat','tuxedo','typewriter','unicycle','viking','vine','vulture',
|
||||||
|
'walrus','watch','waterfall','werewolf','wheelbarrow','whistle','wig','wolverine','wombat','yacht','yeti','yogurt'
|
||||||
|
];
|
||||||
+182
@@ -0,0 +1,182 @@
|
|||||||
|
// Headless integration test: simulates 3 clients playing part of a game.
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
|
||||||
|
const URL = 'ws://localhost:3000';
|
||||||
|
const log = (who, ...a) => console.log(`[${who}]`, ...a);
|
||||||
|
|
||||||
|
class Client {
|
||||||
|
constructor(name) {
|
||||||
|
this.name = name;
|
||||||
|
this.msgs = [];
|
||||||
|
this.id = null;
|
||||||
|
this.room = null;
|
||||||
|
this.ws = new WebSocket(URL);
|
||||||
|
this.ws.on('open', () => this.send({ t: 'hello', name, avatar: { color: 1, eyes: 1, mouth: 1, extra: 1 } }));
|
||||||
|
this.ws.on('message', (raw) => {
|
||||||
|
const m = JSON.parse(raw.toString());
|
||||||
|
this.msgs.push(m);
|
||||||
|
this['on_' + m.t]?.(m);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
send(m) { this.ws.send(JSON.stringify(m)); }
|
||||||
|
last(t) { return [...this.msgs].reverse().find(m => m.t === t); }
|
||||||
|
all(t) { return this.msgs.filter(m => m.t === t); }
|
||||||
|
close() { this.ws.close(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const wait = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
async function waitFor(fn, what, timeout = 8000) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
while (Date.now() - t0 < timeout) {
|
||||||
|
const v = fn();
|
||||||
|
if (v) return v;
|
||||||
|
await wait(50);
|
||||||
|
}
|
||||||
|
throw new Error('timeout waiting for ' + what);
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const host = new Client('Alice');
|
||||||
|
await waitFor(() => host.last('welcome'), 'welcome');
|
||||||
|
|
||||||
|
// list rooms (empty)
|
||||||
|
host.send({ t: 'list_rooms' });
|
||||||
|
await waitFor(() => host.last('room_list'), 'room_list');
|
||||||
|
console.log('✔ room_list (empty):', host.last('room_list').rooms.length === 0);
|
||||||
|
|
||||||
|
// create private room
|
||||||
|
host.send({ t: 'create_room', isPublic: false });
|
||||||
|
const joined = await waitFor(() => host.last('joined'), 'joined');
|
||||||
|
const code = joined.room.code;
|
||||||
|
console.log('✔ created private room', code, '| phase:', joined.room.phase, '| host is self:', joined.room.hostId === joined.room.selfId);
|
||||||
|
|
||||||
|
// private room should not appear in public list
|
||||||
|
host.send({ t: 'list_rooms' });
|
||||||
|
await waitFor(() => host.all('room_list').length >= 2, 'room_list 2');
|
||||||
|
console.log('✔ private room hidden from list:', host.last('room_list').rooms.length === 0);
|
||||||
|
|
||||||
|
// second player joins with wrong code
|
||||||
|
const bob = new Client('Bob');
|
||||||
|
await waitFor(() => bob.last('welcome'), 'bob welcome');
|
||||||
|
bob.send({ t: 'join_room', code: 'ZZZZ' });
|
||||||
|
await waitFor(() => bob.last('join_error'), 'join_error');
|
||||||
|
console.log('✔ bad code rejected:', bob.last('join_error').reason);
|
||||||
|
|
||||||
|
bob.send({ t: 'join_room', code });
|
||||||
|
const bJoined = await waitFor(() => bob.last('joined'), 'bob joined');
|
||||||
|
console.log('✔ bob joined; players:', bJoined.room.players.map(p => p.name).join(','));
|
||||||
|
|
||||||
|
const carol = new Client('Carol');
|
||||||
|
await waitFor(() => carol.last('welcome'), 'carol welcome');
|
||||||
|
carol.send({ t: 'join_room', code });
|
||||||
|
await waitFor(() => carol.last('joined'), 'carol joined');
|
||||||
|
await waitFor(() => host.all('player_joined').length === 2, 'host sees 2 joins');
|
||||||
|
console.log('✔ host sees joins; host players:', host.last('player_joined').player.name);
|
||||||
|
|
||||||
|
// chat in lobby
|
||||||
|
bob.send({ t: 'chat', text: 'hello everyone' });
|
||||||
|
await waitFor(() => host.last('chat'), 'chat');
|
||||||
|
console.log('✔ lobby chat:', host.last('chat').name + ':', host.last('chat').text);
|
||||||
|
|
||||||
|
// typing indicator
|
||||||
|
bob.send({ t: 'typing' });
|
||||||
|
await waitFor(() => host.last('typing'), 'typing');
|
||||||
|
console.log('✔ typing relayed:', host.last('typing').id === bJoined.room.selfId);
|
||||||
|
|
||||||
|
// non-host cannot start
|
||||||
|
bob.send({ t: 'start_game' });
|
||||||
|
await wait(300);
|
||||||
|
console.log('✔ non-host start ignored:', !bob.last('game_starting'));
|
||||||
|
|
||||||
|
// host changes settings
|
||||||
|
host.send({ t: 'set_settings', settings: { rounds: 2, drawTime: 30 } });
|
||||||
|
await waitFor(() => bob.last('settings'), 'settings sync');
|
||||||
|
console.log('✔ settings synced: rounds=2 drawTime=30 ->', bob.last('settings').settings.rounds === 2 && bob.last('settings').settings.drawTime === 30);
|
||||||
|
|
||||||
|
// host starts game
|
||||||
|
host.send({ t: 'start_game' });
|
||||||
|
await waitFor(() => host.last('game_starting'), 'game_starting');
|
||||||
|
console.log('✔ game starting, round', host.last('game_starting').round);
|
||||||
|
|
||||||
|
// word choice: drawer (Alice, first) gets choose_words
|
||||||
|
const cw = await waitFor(() => host.last('choose_words'), 'choose_words');
|
||||||
|
console.log('✔ drawer got 3 words:', cw.words.length === 3, '->', cw.words.join(' / '));
|
||||||
|
await waitFor(() => bob.last('word_choice'), 'bob word_choice');
|
||||||
|
console.log('✔ others see word_choice for drawer:', bob.last('word_choice').drawerName);
|
||||||
|
|
||||||
|
// drawer picks first word
|
||||||
|
const word = cw.words[0];
|
||||||
|
host.send({ t: 'pick_word', word });
|
||||||
|
const ds = await waitFor(() => host.last('draw_start'), 'draw_start');
|
||||||
|
const bds = await waitFor(() => bob.last('draw_start'), 'bob draw_start');
|
||||||
|
console.log('✔ draw_start: drawer sees word:', ds.word === word, '| guesser sees mask:', bds.mask.replace(/_/g, '▯'), '| word hidden:', bds.word === null);
|
||||||
|
|
||||||
|
// non-drawer cannot draw
|
||||||
|
bob.send({ t: 'draw_begin', tool: 'brush', color: '#000', size: 10, points: [0.1, 0.1] });
|
||||||
|
await wait(200);
|
||||||
|
console.log('✔ non-drawer draw rejected:', !carol.all('draw_begin').length);
|
||||||
|
|
||||||
|
// drawer draws a stroke
|
||||||
|
host.send({ t: 'draw_begin', tool: 'brush', color: '#ff0000', size: 12, points: [0.1, 0.1] });
|
||||||
|
host.send({ t: 'draw_points', points: [0.2, 0.2, 0.3, 0.3] });
|
||||||
|
host.send({ t: 'draw_end' });
|
||||||
|
await waitFor(() => bob.last('draw_end'), 'stroke relayed');
|
||||||
|
const strokeOp = bob.last('draw_begin').op;
|
||||||
|
console.log('✔ stroke relayed live:', strokeOp.tool, strokeOp.color, '| pts appended:', bob.last('draw_points').points.length === 4);
|
||||||
|
|
||||||
|
// fill + clear + undo
|
||||||
|
host.send({ t: 'draw_begin', tool: 'fill', color: '#00ff00', size: 1, points: [0.5, 0.5] });
|
||||||
|
host.send({ t: 'draw_end' });
|
||||||
|
await waitFor(() => bob.all('draw_begin').length >= 2, 'fill relayed');
|
||||||
|
console.log('✔ fill relayed');
|
||||||
|
host.send({ t: 'undo' });
|
||||||
|
await waitFor(() => bob.last('undo'), 'undo relayed');
|
||||||
|
console.log('✔ undo relayed, opId:', bob.last('undo').opId);
|
||||||
|
|
||||||
|
// wrong guess broadcast, close guess flagged, correct guess hidden
|
||||||
|
bob.send({ t: 'chat', text: 'banana' });
|
||||||
|
await waitFor(() => carol.all('chat').some(m => m.text === 'banana'), 'wrong guess visible');
|
||||||
|
console.log('✔ wrong guess visible to others');
|
||||||
|
|
||||||
|
// make a close guess (edit distance 1)
|
||||||
|
const closeGuess = word.length >= 4 ? word.slice(0, -1) : word + 'x';
|
||||||
|
bob.send({ t: 'chat', text: closeGuess });
|
||||||
|
await waitFor(() => host.all('chat_sys').some(m => m.kind === 'close'), 'close notice');
|
||||||
|
console.log('✔ close guess flagged:', host.all('chat_sys').find(m => m.kind === 'close').text);
|
||||||
|
|
||||||
|
bob.send({ t: 'chat', text: word });
|
||||||
|
await waitFor(() => bob.all('chat_sys').some(m => m.kind === 'good'), 'self good');
|
||||||
|
await waitFor(() => host.all('chat_sys').some(m => m.text.includes('guessed the word')), 'others good');
|
||||||
|
const leaked = carol.all('chat').some(m => m.text === word);
|
||||||
|
console.log('✔ correct guess NOT leaked as chat:', !leaked);
|
||||||
|
const su = await waitFor(() => bob.last('score_update'), 'score_update');
|
||||||
|
const bobScore = su.players.find(p => p.name === 'Bob').score;
|
||||||
|
console.log('✔ bob scored:', bobScore, 'pts (time-based)');
|
||||||
|
|
||||||
|
// carol guesses too -> everyone guessed -> turn ends early
|
||||||
|
carol.send({ t: 'chat', text: word });
|
||||||
|
const te = await waitFor(() => host.last('turn_end'), 'turn_end', 10000);
|
||||||
|
console.log('✔ turn_end reason:', te.reason, '| word revealed:', te.word === word);
|
||||||
|
const aliceDelta = te.scores.find(s => s.name === 'Alice').delta;
|
||||||
|
console.log('✔ drawer earned points:', aliceDelta > 0, `(${aliceDelta})`);
|
||||||
|
console.log('✔ score deltas present:', te.scores.map(s => `${s.name}+${s.delta}`).join(', '));
|
||||||
|
|
||||||
|
// hint test: next turn with 30s draw time; wait for hint at ~45% (13.5s) too long; instead check hint math indirectly: mask format ok
|
||||||
|
console.log('✔ mask format ok:', /^[_ a-z]+$/.test(bds.mask) && bds.mask.length === word.length);
|
||||||
|
|
||||||
|
// host kicks carol
|
||||||
|
host.send({ t: 'kick', playerId: carol.last('joined').room.selfId });
|
||||||
|
await waitFor(() => carol.last('kicked'), 'kicked');
|
||||||
|
await waitFor(() => host.all('player_left').length >= 1, 'host sees kick');
|
||||||
|
console.log('✔ host kick works:', carol.last('kicked').reason);
|
||||||
|
|
||||||
|
// drawer leaves mid-game -> turn skipped (bob becomes alone -> abort to lobby)
|
||||||
|
host.close();
|
||||||
|
await waitFor(() => bob.last('back_to_lobby') || bob.all('player_left').length >= 1, 'bob sees leave/abort', 8000);
|
||||||
|
console.log('✔ drawer leave handled; bob back_to_lobby:', !!bob.last('back_to_lobby'));
|
||||||
|
|
||||||
|
bob.close();
|
||||||
|
await wait(300);
|
||||||
|
console.log('\nALL TESTS DONE');
|
||||||
|
process.exit(0);
|
||||||
|
})().catch(e => { console.error('TEST FAILED:', e.message); process.exit(1); });
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
// Test 2: hints, timer ticks, quick_join, mid-game joiner snapshot
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
const URL = 'ws://localhost:3000';
|
||||||
|
|
||||||
|
class Client {
|
||||||
|
constructor(name) {
|
||||||
|
this.name = name; this.msgs = [];
|
||||||
|
this.ws = new WebSocket(URL);
|
||||||
|
this.ws.on('open', () => this.send({ t: 'hello', name, avatar: { color: 2, eyes: 2, mouth: 2, extra: 2 } }));
|
||||||
|
this.ws.on('message', (raw) => { const m = JSON.parse(raw.toString()); this.msgs.push(m); });
|
||||||
|
}
|
||||||
|
send(m) { this.ws.send(JSON.stringify(m)); }
|
||||||
|
last(t) { return [...this.msgs].reverse().find(m => m.t === t); }
|
||||||
|
all(t) { return this.msgs.filter(m => m.t === t); }
|
||||||
|
close() { this.ws.close(); }
|
||||||
|
}
|
||||||
|
const wait = (ms) => new Promise(r => setTimeout(r, ms));
|
||||||
|
async function waitFor(fn, what, timeout = 20000) {
|
||||||
|
const t0 = Date.now();
|
||||||
|
while (Date.now() - t0 < timeout) { const v = fn(); if (v) return v; await wait(50); }
|
||||||
|
throw new Error('timeout: ' + what);
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const a = new Client('P1');
|
||||||
|
await waitFor(() => a.last('welcome'), 'welcome');
|
||||||
|
a.send({ t: 'quick_join' });
|
||||||
|
const ja = await waitFor(() => a.last('joined'), 'joined a');
|
||||||
|
console.log('✔ quick_join created/joined public room:', ja.room.code, '| public:', ja.room.settings.isPublic);
|
||||||
|
|
||||||
|
const b = new Client('P2');
|
||||||
|
await waitFor(() => b.last('welcome'), 'welcome b');
|
||||||
|
b.send({ t: 'quick_join' });
|
||||||
|
const jb = await waitFor(() => b.last('joined'), 'joined b');
|
||||||
|
console.log('✔ second quick_join landed in same room:', jb.room.code === ja.room.code);
|
||||||
|
|
||||||
|
// public list shows it
|
||||||
|
b.send({ t: 'list_rooms' });
|
||||||
|
await waitFor(() => b.last('room_list'), 'room_list');
|
||||||
|
const listed = b.last('room_list').rooms.find(r => r.code === ja.room.code);
|
||||||
|
console.log('✔ public room listed:', !!listed, `(${listed.players}/${listed.maxPlayers})`);
|
||||||
|
|
||||||
|
a.send({ t: 'set_settings', settings: { drawTime: 30, rounds: 2 } });
|
||||||
|
await wait(200);
|
||||||
|
a.send({ t: 'start_game' });
|
||||||
|
const cw = await waitFor(() => a.last('choose_words'), 'choose_words');
|
||||||
|
a.send({ t: 'pick_word', word: cw.words[0] });
|
||||||
|
const ds = await waitFor(() => b.last('draw_start'), 'draw_start');
|
||||||
|
console.log('✔ word:', cw.words[0], '| initial mask:', ds.mask);
|
||||||
|
|
||||||
|
// mid-game joiner gets snapshot with ops + mask
|
||||||
|
const c = new Client('P3');
|
||||||
|
await waitFor(() => c.last('welcome'), 'welcome c');
|
||||||
|
c.send({ t: 'join_room', code: ja.room.code });
|
||||||
|
const jc = await waitFor(() => c.last('joined'), 'joined c');
|
||||||
|
console.log('✔ mid-game join snapshot: phase:', jc.room.phase, '| mask:', jc.room.mask === ds.mask, '| timeLeft>0:', jc.room.timeLeft > 0);
|
||||||
|
|
||||||
|
// timer broadcasts
|
||||||
|
await waitFor(() => c.all('timer').length >= 3, 'timer ticks', 6000);
|
||||||
|
console.log('✔ timer broadcasts ticking:', c.all('timer').slice(-1)[0].timeLeft <= 30);
|
||||||
|
|
||||||
|
// hint at ~45% of 30s = ~13.5s
|
||||||
|
const hint = await waitFor(() => b.last('hint'), 'hint', 20000);
|
||||||
|
console.log('✔ hint revealed:', hint.mask, '| differs from start:', hint.mask !== ds.mask);
|
||||||
|
|
||||||
|
a.close(); b.close(); c.close();
|
||||||
|
console.log('\nTEST 2 DONE');
|
||||||
|
process.exit(0);
|
||||||
|
})().catch(e => { console.error('TEST 2 FAILED:', e.message); process.exit(1); });
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2021",
|
||||||
|
"module": "commonjs",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": false,
|
||||||
|
"sourceMap": false
|
||||||
|
},
|
||||||
|
"include": ["src/server", "src/shared"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user