inital commit

This commit is contained in:
2026-07-18 20:41:22 -05:00
parent 30e34255c5
commit f73aa0d759
20 changed files with 5249 additions and 1 deletions
+44 -1
View File
@@ -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.
+550
View File
@@ -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
}
}
}
}
}
+24
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+110
View File
@@ -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>
+372
View File
@@ -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; }
}
+151
View File
@@ -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();
}
+253
View File
@@ -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 = [];
}
}
}
+479
View File
@@ -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();
+38
View File
@@ -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; }
}
+52
View File
@@ -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 }); },
};
+442
View File
@@ -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 ? '&nbsp;' : 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] ?? c));
}
+623
View File
@@ -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;
}
}
}
+76
View File
@@ -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`);
});
+145
View File
@@ -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, ' ');
}
+63
View File
@@ -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
View File
@@ -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); });
+69
View File
@@ -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); });
+17
View File
@@ -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"]
}