diff --git a/README.md b/README.md index 5eb03a7..1839306 100644 --- a/README.md +++ b/README.md @@ -1 +1,44 @@ -# skrible-2 \ No newline at end of file +# ✏️ 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. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..3a4075e --- /dev/null +++ b/package-lock.json @@ -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 + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..8c2b9cd --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/bundle.js b/public/bundle.js new file mode 100644 index 0000000..ab75216 --- /dev/null +++ b/public/bundle.js @@ -0,0 +1,1552 @@ +(() => { + var __defProp = Object.defineProperty; + var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value; + var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value); + + // src/shared/protocol.ts + var DEFAULT_SETTINGS = { + rounds: 3, + drawTime: 80, + maxPlayers: 8, + isPublic: true, + customWords: "", + customOnly: false + }; + function randomAvatar() { + 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) + }; + } + + // src/client/net.ts + var Net = class { + constructor() { + __publicField(this, "ws", null); + __publicField(this, "pingTimer", null); + __publicField(this, "onMessage", () => { + }); + __publicField(this, "onClose", () => { + }); + __publicField(this, "onOpen", () => { + }); + } + connect() { + 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" }), 5e3); + }; + this.ws.onmessage = (ev) => { + try { + const msg = JSON.parse(String(ev.data)); + this.onMessage(msg); + } catch { + } + }; + this.ws.onclose = () => { + if (this.pingTimer) { + clearInterval(this.pingTimer); + this.pingTimer = null; + } + this.onClose(); + }; + } + send(msg) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(msg)); + } + } + get connected() { + return !!this.ws && this.ws.readyState === WebSocket.OPEN; + } + }; + + // src/client/canvas.ts + var LW = 1e3; + var LH = 750; + var BATCH_MS = 40; + var DrawEngine = class { + constructor(canvas) { + __publicField(this, "canvas"); + __publicField(this, "ctx"); + __publicField(this, "ops", []); + __publicField(this, "activeLocal", null); + __publicField(this, "activeRemote", /* @__PURE__ */ new Map()); + __publicField(this, "pending", []); + __publicField(this, "batchTimer", null); + __publicField(this, "drawing", false); + __publicField(this, "lastX", 0); + __publicField(this, "lastY", 0); + __publicField(this, "cb", { onBegin: () => { + }, onPoints: () => { + }, onEnd: () => { + } }); + __publicField(this, "canDraw", false); + __publicField(this, "tool", "brush"); + __publicField(this, "color", "#000000"); + __publicField(this, "size", 12); + __publicField(this, "onLocalDraw", () => { + }); + this.canvas = canvas; + canvas.width = LW; + canvas.height = LH; + const ctx2 = canvas.getContext("2d", { willReadFrequently: true }); + if (!ctx2) throw new Error("no 2d context"); + this.ctx = ctx2; + this.clearSurface(); + this.bindInput(); + } + // ---------------- surface ---------------- + clearSurface() { + 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) { + this.ops = ops.map((o) => ({ ...o, points: o.points.slice() })); + this.activeRemote.clear(); + this.activeLocal = null; + this.redraw(); + } + redraw() { + this.clearSurface(); + for (const op of this.ops) this.applyOp(op); + } + // ---------------- stroke rendering ---------------- + strokePath(points, color, size, erase) { + const g = this.ctx; + const pts = []; + 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(); + } + applyOp(op) { + 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 ---------------- + floodFill(fx, fy, hex) { + 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) => 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) { + 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, points) { + 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) { + this.activeRemote.delete(id); + } + undo(opId) { + if (this.ops.some((o) => o.id === opId)) { + this.ops = this.ops.filter((o) => o.id !== opId); + } else { + 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 ---------------- + toNorm(e) { + 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]; + } + bindInput() { + 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 = { 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); + const dx = x - this.lastX, dy = y - this.lastY; + if (dx * dx + dy * dy < 12e-7) 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) => { + if (!this.drawing) return; + this.drawing = false; + try { + this.canvas.releasePointerCapture(e.pointerId); + } catch { + } + 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); + } + flush() { + if (this.pending.length) { + this.cb.onPoints(this.pending.slice()); + this.pending = []; + } + } + }; + + // src/client/avatar.ts + var AVATAR_COLORS = [ + "#f44336", + "#ff8f00", + "#ffd600", + "#4caf50", + "#00bcd4", + "#2196f3", + "#3f51b5", + "#9c27b0", + "#ec407a", + "#8d6e63", + "#607d8b", + "#7cb342" + ]; + var SKIN = ["#ffd9b3", "#f2b880", "#c98d5a", "#8c5a33", "#ffe0c4", "#e8b06a"]; + function drawAvatar(canvas, seed, size = 80) { + 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; + g.save(); + g.scale(s, s); + const body = AVATAR_COLORS[seed.color % AVATAR_COLORS.length]; + 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(); + 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]; + 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, happy, closed) => { + 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: + 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: + 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; + } + 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: + 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: + 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; + } + g.strokeStyle = "rgba(0,0,0,0.55)"; + switch (seed.extra % 6) { + case 1: + 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: + 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: + 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: + 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: + 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(); + } + + // src/client/sfx.ts + var ctx = null; + var muted = false; + function ac() { + try { + if (!ctx) ctx = new (window.AudioContext || window.webkitAudioContext)(); + if (ctx.state === "suspended") void ctx.resume(); + return ctx; + } catch { + return null; + } + } + function tone(freq, dur, opts = {}) { + 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(1e-4, t0); + gain.gain.exponentialRampToValueAtTime(v, t0 + 0.012); + gain.gain.exponentialRampToValueAtTime(1e-4, t0 + dur); + osc.connect(gain).connect(a.destination); + osc.start(t0); + osc.stop(t0 + dur + 0.02); + } + var sfx = { + setMuted(m) { + 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: 1e3 }); + }, + 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 }); + } + }; + + // src/client/ui.ts + var 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" + ]; + var BRUSH_SIZES = [6, 14, 26, 42]; + function el(tag, cls, html) { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (html !== void 0) e.innerHTML = html; + return e; + } + function $(id) { + const e = document.getElementById(id); + if (!e) throw new Error(`missing #${id}`); + return e; + } + function toast(text, kind = "info") { + 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); + } + function showScreen(name2) { + $("screen-home").classList.toggle("hidden", name2 !== "home"); + $("screen-game").classList.toggle("hidden", name2 !== "game"); + } + var bannerTimer = null; + function banner(text, ms = 2400) { + const b = $("banner"); + b.textContent = text; + b.classList.remove("hidden"); + if (bannerTimer) clearTimeout(bannerTimer); + bannerTimer = window.setTimeout(() => b.classList.add("hidden"), ms); + } + function confetti(parent, count = 70) { + 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); + } + } + function renderPlayers(players2, opts) { + const list = $("players-list"); + list.innerHTML = ""; + const sorted = players2.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"); + drawAvatar(cv, p.avatar, 44); + const info = el("div", "player-info"); + const name2 = el("div", "player-name"); + name2.textContent = p.name + (p.id === opts.selfId ? " (You)" : ""); + if (p.id === opts.hostId) name2.insertAdjacentHTML("afterbegin", '\u{1F451}'); + if (opts.typingIds.has(p.id)) name2.insertAdjacentHTML("beforeend", ''); + const score = el("div", "player-score", `${p.score} pts`); + info.appendChild(name2); + info.appendChild(score); + const status = el("span", "player-status"); + if (p.id === opts.drawerId) status.textContent = "\u270F\uFE0F"; + else if (p.guessed) status.textContent = "\u2705"; + 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", "\u2715"); + kick.title = `Kick ${p.name}`; + kick.onclick = () => { + if (confirm(`Kick ${p.name}?`)) opts.onKick(p.id); + }; + row.appendChild(kick); + } + list.appendChild(row); + }); + } + function addChat(opts) { + 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; + } + function setTypingText(text) { + $("typing-indicator").textContent = text; + } + function setRoomCode(code) { + $("room-code").textContent = code; + } + function setRound(text) { + $("round-display").textContent = text; + } + function setWord(mask, opts) { + const wd = $("word-display"); + wd.innerHTML = ""; + if (opts.label) wd.appendChild(el("span", "word-hint-label", opts.label)); + for (const ch of mask) { + if (ch === " ") { + wd.appendChild(el("span", "word-char space")); + continue; + } + const hidden = ch === "_"; + const span = el("span", "word-char" + (hidden ? "" : " revealed"), hidden ? " " : ch); + wd.appendChild(span); + } + } + function setTimer(left, total) { + const clock = $("clock"); + if (total <= 1) { + $("clock-num").textContent = "\u2013"; + clock.classList.remove("urgent"); + const hand0 = document.getElementById("clock-hand"); + if (hand0) hand0.style.transform = "rotate(0deg)"; + return; + } + $("clock-num").textContent = String(left); + const hand = document.getElementById("clock-hand"); + 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); + } + function buildTools(cb, initial) { + const palette = $("palette"); + palette.innerHTML = ""; + let activeSwatch = 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 = 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 = { + brush: $("tool-brush"), + eraser: $("tool-eraser"), + fill: $("tool-fill") + }; + const selectTool = (t) => { + for (const [name2, btn] of Object.entries(toolBtns)) btn.classList.toggle("active", name2 === 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) { + $("tools").classList.toggle("disabled", !v); + }, + selectTool + }; + } + function showOverlay(content) { + const ov = $("overlay"); + ov.innerHTML = ""; + ov.appendChild(content); + ov.classList.remove("hidden"); + } + function hideOverlay() { + $("overlay").classList.add("hidden"); + $("overlay").innerHTML = ""; + } + function overlayCard(cls = "") { + return el("div", `overlay-card ${cls}`); + } + function overlayWaitingChoice(drawerName) { + const card = overlayCard(); + card.appendChild(el("h2", "", `\u270F\uFE0F ${escapeHtml(drawerName)}`)); + card.appendChild(el("div", "overlay-sub", "is choosing a word\u2026")); + showOverlay(card); + } + function overlayWordChoice(words, onPick) { + 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 \u2014 or a random word is chosen for you!")); + showOverlay(card); + } + function overlayLobby(settings2, code, isHost2, playerCount, cb) { + const card = overlayCard("lobby-card"); + card.appendChild(el("h2", "", "Waiting for players\u2026")); + 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, values, current, suffix, onChange) => { + const lab = el("label"); + lab.appendChild(el("span", "", label)); + const s = el("select"); + for (const v of values) { + const o = el("option", "", `${v}${suffix}`); + o.value = String(v); + s.appendChild(o); + } + s.value = String(current); + s.disabled = !isHost2; + s.onchange = () => onChange(parseInt(s.value, 10)); + lab.appendChild(s); + return lab; + }; + grid.appendChild(sel("Rounds", [2, 3, 4, 5, 6, 7, 8], settings2.rounds, "", (v) => cb.onSettings({ rounds: v }))); + grid.appendChild(sel("Draw time (s)", [30, 40, 50, 60, 70, 80, 90, 100, 120, 150, 180], settings2.drawTime, " s", (v) => cb.onSettings({ drawTime: v }))); + grid.appendChild(sel("Max players", [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], settings2.maxPlayers, "", (v) => cb.onSettings({ maxPlayers: v }))); + const pubLab = el("label", "checkbox-label"); + const pub = el("input"); + pub.type = "checkbox"; + pub.checked = settings2.isPublic; + pub.disabled = !isHost2; + 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"); + cw.placeholder = "e.g. lightsaber, hogwarts, pizza tower\u2026"; + cw.value = settings2.customWords; + cw.disabled = !isHost2; + cw.onchange = () => cb.onSettings({ customWords: cw.value }); + cwLab.appendChild(cw); + grid.appendChild(cwLab); + const cwoLab = el("label", "checkbox-label full"); + const cwo = el("input"); + cwo.type = "checkbox"; + cwo.checked = settings2.customOnly; + cwo.disabled = !isHost2; + 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 (isHost2) { + const start = el("button", "btn btn-green btn-big", "\u25B6 Start Game"); + if (playerCount < 2) start.setAttribute("disabled", "true"); + start.onclick = () => { + cb.onStart(); + sfx.click(); + }; + actions.appendChild(start); + } + card.appendChild(actions); + if (!isHost2) card.appendChild(el("div", "waiting-text", "Waiting for the host to start the game\u2026")); + else if (playerCount < 2) card.appendChild(el("div", "waiting-text", "Invite friends with the room code \u2014 you need at least 2 players!")); + showOverlay(card); + } + function overlayTurnEnd(word, reason, scores, selfId2) { + const card = overlayCard(); + card.appendChild(el("h2", "", reason === "time up" ? "\u23F0 Time's up!" : reason === "everyone guessed" ? "\u{1F389} 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 name2 = el("span", "", escapeHtml(s.name) + (s.id === selfId2 ? " (You)" : "")); + const delta = el("span", "delta", s.delta > 0 ? `+${s.delta}` : "\u2014"); + row.appendChild(name2); + row.appendChild(delta); + recap.appendChild(row); + } + card.appendChild(recap); + showOverlay(card); + } + function overlayGameEnd(scores, selfId2) { + const card = overlayCard(); + card.appendChild(el("h2", "", "\u{1F3C6} Game Over!")); + const medals = ["\u{1F947}", "\u{1F948}", "\u{1F949}"]; + 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"); + 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 === selfId2 ? " (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\u2026")); + showOverlay(card); + confetti(document.body); + } + function renderRoomList(rooms, onJoin) { + const list = $("rooms-list"); + list.innerHTML = ""; + if (!rooms.length) { + list.appendChild(el("div", "rooms-empty", "No public rooms yet \u2014 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 \xB7 ${r.phase === "lobby" ? "in lobby" : "playing"} \xB7 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); + } + } + function escapeHtml(s) { + return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] ?? c); + } + + // src/client/main.ts + var net = new Net(); + var selfId = ""; + var name = localStorage.getItem("skribble_name") ?? ""; + var avatar = JSON.parse(localStorage.getItem("skribble_avatar") ?? "null") ?? randomAvatar(); + var inRoom = false; + var roomCode = ""; + var hostId = ""; + var phase = "lobby"; + var settings = { ...DEFAULT_SETTINGS }; + var players = []; + var drawerId = null; + var round = 0; + var totalRounds = settings.rounds; + var timerTotal = 80; + var lastTick = -1; + var typingIds = /* @__PURE__ */ new Set(); + var typingTimers = /* @__PURE__ */ new Map(); + var lobbyCtx; + var engine = new DrawEngine(document.getElementById("draw-canvas")); + var toolsApi; + var isHost = () => selfId === hostId; + var isDrawer = () => selfId === drawerId && phase === "drawing"; + function saveIdentity() { + localStorage.setItem("skribble_name", name); + localStorage.setItem("skribble_avatar", JSON.stringify(avatar)); + } + function sendHello() { + net.send({ t: "hello", name: name || `Player${Math.floor(Math.random() * 1e3)}`, avatar }); + } + function renderPlayers2() { + renderPlayers(players, { + selfId, + hostId, + drawerId, + typingIds, + onKick: (id) => net.send({ t: "kick", playerId: id }) + }); + } + function playerName(id) { + return players.find((p) => p.id === id)?.name ?? "???"; + } + function refreshLobbyOverlay() { + if (phase !== "lobby" || !inRoom) return; + overlayLobby(settings, roomCode, isHost(), players.length, lobbyCtx); + } + function setTimer2(left) { + setTimer(left, timerTotal); + if (phase === "drawing" && left <= 5 && left >= 1 && left !== lastTick) { + lastTick = left; + sfx.tick(); + } + } + net.onOpen = () => { + sendHello(); + }; + net.onClose = () => { + toast("Disconnected from server. Reload to reconnect.", "error"); + }; + net.onMessage = (msg) => { + switch (msg.t) { + case "welcome": + selfId = msg.id; + net.send({ t: "list_rooms" }); + break; + case "room_list": + renderRoomList(msg.rooms, (code) => joinRoom(code)); + break; + case "join_error": + 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; + showScreen("game"); + requestAnimationFrame(fitCanvas); + setRoomCode(r.code); + setRound(phase === "lobby" ? "Lobby" : `Round ${round} of ${totalRounds}`); + engine.reset(r.ops ?? []); + renderPlayers2(); + toast(`Joined room #${r.code}`, "good"); + sfx.join(); + applyPhaseUI(r.word, r.mask, r.timeLeft); + break; + } + case "player_joined": + players.push(msg.player); + renderPlayers2(); + refreshLobbyOverlay(); + sfx.join(); + break; + case "player_left": + players = players.filter((p) => p.id !== msg.id); + hostId = msg.hostId; + renderPlayers2(); + refreshLobbyOverlay(); + sfx.leave(); + break; + case "host": + hostId = msg.id; + renderPlayers2(); + refreshLobbyOverlay(); + if (msg.id === selfId) toast("You are now the host \u{1F451}"); + break; + case "settings": + settings = msg.settings; + refreshLobbyOverlay(); + break; + case "chat": + addChat({ name: msg.name, text: msg.text }); + sfx.pop(); + break; + case "chat_sys": + 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; + renderPlayers2(); + break; + case "typing": { + typingIds.add(msg.id); + setTypingText(`${playerName(msg.id)} is typing\u2026`); + const prev = typingTimers.get(msg.id); + if (prev) clearTimeout(prev); + typingTimers.set(msg.id, window.setTimeout(() => { + typingIds.delete(msg.id); + setTypingText(""); + renderPlayers2(); + }, 2500)); + renderPlayers2(); + break; + } + case "game_starting": + round = msg.round; + totalRounds = msg.totalRounds; + setRound(`Round ${round} of ${totalRounds}`); + hideOverlay(); + banner(`\u{1F3AC} Round ${round} of ${totalRounds}`); + drawerId = null; + engine.canDraw = false; + toolsApi.setEnabled(false); + break; + case "word_choice": + phase = "wordChoice"; + drawerId = msg.drawerId; + timerTotal = msg.timeLeft; + setTimer(msg.timeLeft, timerTotal); + renderPlayers2(); + if (msg.drawerId !== selfId) { + overlayWaitingChoice(msg.drawerName); + setWord("", { isDrawer: false, label: `${msg.drawerName} is choosing a word` }); + } + break; + case "choose_words": + sfx.yourTurn(); + overlayWordChoice(msg.words, (w) => { + net.send({ t: "pick_word", word: w }); + sfx.click(); + hideOverlay(); + 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; + hideOverlay(); + setRound(`Round ${round} of ${totalRounds}`); + engine.reset([]); + const mine = msg.drawerId === selfId; + engine.canDraw = mine; + toolsApi.setEnabled(mine); + document.getElementById("draw-canvas").classList.toggle("no-draw", !mine); + if (mine && msg.word) { + setWord(msg.word, { isDrawer: true, label: "DRAW THIS:" }); + banner(`\u270F\uFE0F Your turn! Draw: ${msg.word.toUpperCase()}`, 3e3); + sfx.drawStart(); + } else { + setWord(msg.mask, { isDrawer: false, label: "GUESS:" }); + banner(`\u270F\uFE0F ${playerName(msg.drawerId)} is drawing!`); + sfx.pop(); + } + setTimer(msg.timeLeft, timerTotal); + renderPlayers2(); + break; + } + case "hint": + if (!isDrawer()) { + setWord(msg.mask, { isDrawer: false, label: "GUESS:" }); + sfx.reveal(); + } + break; + case "timer": + setTimer2(msg.timeLeft); + break; + case "turn_end": + phase = "turnEnd"; + engine.canDraw = false; + toolsApi.setEnabled(false); + 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; + } + renderPlayers2(); + break; + case "game_end": + phase = "gameEnd"; + 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; + } + renderPlayers2(); + break; + case "back_to_lobby": + phase = "lobby"; + drawerId = null; + engine.canDraw = false; + toolsApi.setEnabled(false); + engine.reset([]); + setRound("Lobby"); + setWord("", { isDrawer: false, label: "" }); + setTimer(0, 1); + for (const p of players) p.guessed = false; + renderPlayers2(); + 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": + toast(`You were removed: ${msg.reason}`, "error"); + leaveToHome(); + break; + case "pong": + break; + } + }; + function applyPhaseUI(word, mask, timeLeft) { + engine.canDraw = false; + toolsApi.setEnabled(false); + switch (phase) { + case "lobby": + refreshLobbyOverlay(); + setWord("", { isDrawer: false }); + break; + case "wordChoice": + if (drawerId && drawerId !== selfId) overlayWaitingChoice(playerName(drawerId)); + break; + case "drawing": { + const mine = word != null; + engine.canDraw = mine; + toolsApi.setEnabled(mine); + document.getElementById("draw-canvas").classList.toggle("no-draw", !mine); + if (mine) setWord(word, { isDrawer: true, label: "DRAW THIS:" }); + else setWord(mask, { isDrawer: false, label: "GUESS:" }); + hideOverlay(); + break; + } + case "turnEnd": + case "gameEnd": + break; + } + if (timeLeft) setTimer(timeLeft, timerTotal); + } + function joinRoom(code) { + saveIdentity(); + sendHello(); + net.send({ t: "join_room", code }); + } + function initHome() { + const nameInput = document.getElementById("name-input"); + nameInput.value = name; + nameInput.oninput = () => { + name = nameInput.value; + saveIdentity(); + }; + const preview = document.getElementById("avatar-preview"); + 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"); + document.getElementById("btn-join-code").onclick = () => { + if (codeInput.value.trim().length !== 4) { + 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(); + }; + window.setInterval(() => { + if (!inRoom && net.connected) net.send({ t: "list_rooms" }); + }, 4e3); + } + function leaveToHome() { + inRoom = false; + phase = "lobby"; + players = []; + drawerId = null; + typingIds.clear(); + showScreen("home"); + hideOverlay(); + net.send({ t: "list_rooms" }); + } + function initGame() { + toolsApi = 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 = () => { + }; + const chatInput = document.getElementById("chat-input"); + 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" }); + } + }); + 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.textContent = sfx.muted ? "\u{1F507}" : "\u{1F50A}"; + if (!sfx.muted) sfx.pop(); + }; + document.getElementById("room-code-chip").onclick = () => { + void navigator.clipboard?.writeText(roomCode); + toast("Room code copied!", "good"); + sfx.pop(); + }; + document.addEventListener("keydown", (e) => { + if (e.target?.tagName === "INPUT" || e.target?.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" }) + }; + } + function fitCanvas() { + const wrap = document.getElementById("canvas-wrap"); + const cv = document.getElementById("draw-canvas"); + if (!wrap.clientWidth) return; + const scale = Math.min(wrap.clientWidth / 1e3, wrap.clientHeight / 750); + cv.style.width = `${Math.floor(1e3 * scale)}px`; + cv.style.height = `${Math.floor(750 * scale)}px`; + } + window.addEventListener("resize", fitCanvas); + initHome(); + initGame(); + showScreen("home"); + document.addEventListener("pointerdown", () => sfx.unlock(), { once: true }); + net.connect(); +})(); +//# sourceMappingURL=bundle.js.map diff --git a/public/bundle.js.map b/public/bundle.js.map new file mode 100644 index 0000000..768579e --- /dev/null +++ b/public/bundle.js.map @@ -0,0 +1,7 @@ +{ + "version": 3, + "sources": ["../src/shared/protocol.ts", "../src/client/net.ts", "../src/client/canvas.ts", "../src/client/avatar.ts", "../src/client/sfx.ts", "../src/client/ui.ts", "../src/client/main.ts"], + "sourcesContent": ["// ============================================================\n// Shared protocol types used by both server and client\n// ============================================================\n\nexport interface AvatarSeed {\n color: number; // index into palette\n eyes: number; // eye style index\n mouth: number; // mouth style index\n extra: number; // hat/hair style index\n}\n\nexport interface PlayerInfo {\n id: string;\n name: string;\n avatar: AvatarSeed;\n score: number;\n guessed: boolean; // guessed the word this turn\n connected: boolean;\n}\n\nexport interface RoomSettings {\n rounds: number; // 2..8\n drawTime: number; // seconds 30..180\n maxPlayers: number; // 2..12\n isPublic: boolean;\n customWords: string; // comma separated\n customOnly: boolean; // use custom words exclusively\n}\n\nexport interface RoomSummary {\n code: string;\n players: number;\n maxPlayers: number;\n phase: Phase;\n hostName: string;\n}\n\nexport type Phase = 'lobby' | 'wordChoice' | 'drawing' | 'turnEnd' | 'gameEnd';\n\nexport type DrawTool = 'brush' | 'eraser' | 'fill' | 'clear';\n\nexport interface DrawOp {\n id: number;\n playerId: string;\n tool: DrawTool;\n color: string;\n size: number; // brush diameter in logical px\n points: number[]; // normalized 0..1 [x0,y0,x1,y1,...]; fill uses [x,y]\n}\n\nexport interface ScoreEntry {\n id: string;\n name: string;\n avatar: AvatarSeed;\n score: number;\n delta: number;\n}\n\nexport interface RoomSnapshot {\n code: string;\n selfId: string;\n hostId: string;\n phase: Phase;\n settings: RoomSettings;\n players: PlayerInfo[];\n round: number;\n totalRounds: number;\n drawerId: string | null;\n mask: string; // hint mask for guessers\n word: string | null; // only set for the drawer\n timeLeft: number; // seconds\n ops: DrawOp[]; // canvas op log for this turn\n}\n\n// ---------------- Client -> Server ----------------\nexport type C2S =\n | { t: 'hello'; name: string; avatar: AvatarSeed }\n | { t: 'create_room'; isPublic: boolean }\n | { t: 'join_room'; code: string }\n | { t: 'quick_join' }\n | { t: 'list_rooms' }\n | { t: 'leave_room' }\n | { t: 'chat'; text: string }\n | { t: 'typing' }\n | { t: 'set_settings'; settings: Partial }\n | { t: 'start_game' }\n | { t: 'pick_word'; word: string }\n | { t: 'draw_begin'; tool: DrawTool; color: string; size: number; points: number[] }\n | { t: 'draw_points'; points: number[] }\n | { t: 'draw_end' }\n | { t: 'undo' }\n | { t: 'kick'; playerId: string }\n | { t: 'ping' };\n\n// ---------------- Server -> Client ----------------\nexport type S2C =\n | { t: 'welcome'; id: string }\n | { t: 'joined'; room: RoomSnapshot }\n | { t: 'join_error'; reason: string }\n | { t: 'room_list'; rooms: RoomSummary[] }\n | { t: 'player_joined'; player: PlayerInfo }\n | { t: 'player_left'; id: string; name: string; reason: string; hostId: string }\n | { t: 'host'; id: string }\n | { t: 'settings'; settings: RoomSettings }\n | { t: 'chat'; id: string; name: string; text: string }\n | { t: 'chat_sys'; text: string; kind: 'info' | 'good' | 'bad' | 'close' }\n | { t: 'score_update'; players: PlayerInfo[] }\n | { t: 'typing'; id: string }\n | { t: 'game_starting'; round: number; totalRounds: number }\n | { t: 'word_choice'; drawerId: string; drawerName: string; timeLeft: number }\n | { t: 'choose_words'; words: string[]; timeLeft: number }\n | { t: 'draw_start'; drawerId: string; mask: string; word: string | null; timeLeft: number; round: number; totalRounds: number }\n | { t: 'hint'; mask: string }\n | { t: 'timer'; timeLeft: number }\n | { t: 'turn_end'; word: string; reason: string; scores: ScoreEntry[] }\n | { t: 'game_end'; scores: ScoreEntry[] }\n | { t: 'back_to_lobby' }\n | { t: 'draw_begin'; op: DrawOp }\n | { t: 'draw_points'; id: number; points: number[] }\n | { t: 'draw_end'; id: number }\n | { t: 'undo'; opId: number }\n | { t: 'kicked'; reason: string }\n | { t: 'pong' };\n\nexport const DEFAULT_SETTINGS: RoomSettings = {\n rounds: 3,\n drawTime: 80,\n maxPlayers: 8,\n isPublic: true,\n customWords: '',\n customOnly: false,\n};\n\nexport function randomAvatar(): AvatarSeed {\n return {\n color: Math.floor(Math.random() * 12),\n eyes: Math.floor(Math.random() * 6),\n mouth: Math.floor(Math.random() * 6),\n extra: Math.floor(Math.random() * 6),\n };\n}\n\nexport function normalizeWord(w: string): string {\n return w.trim().toLowerCase().replace(/\\s+/g, ' ');\n}\n", "import { C2S, S2C } from '../shared/protocol';\n\nexport type MessageHandler = (msg: S2C) => void;\n\nexport class Net {\n private ws: WebSocket | null = null;\n private pingTimer: number | null = null;\n onMessage: MessageHandler = () => {};\n onClose: () => void = () => {};\n onOpen: () => void = () => {};\n\n connect(): void {\n const proto = location.protocol === 'https:' ? 'wss' : 'ws';\n this.ws = new WebSocket(`${proto}://${location.host}`);\n this.ws.onopen = () => {\n this.onOpen();\n this.pingTimer = window.setInterval(() => this.send({ t: 'ping' }), 5000);\n };\n this.ws.onmessage = (ev) => {\n try {\n const msg = JSON.parse(String(ev.data)) as S2C;\n this.onMessage(msg);\n } catch { /* ignore */ }\n };\n this.ws.onclose = () => {\n if (this.pingTimer) { clearInterval(this.pingTimer); this.pingTimer = null; }\n this.onClose();\n };\n }\n\n send(msg: C2S): void {\n if (this.ws && this.ws.readyState === WebSocket.OPEN) {\n this.ws.send(JSON.stringify(msg));\n }\n }\n\n get connected(): boolean { return !!this.ws && this.ws.readyState === WebSocket.OPEN; }\n}\n", "import { DrawOp, DrawTool } from '../shared/protocol';\n\nexport const LW = 1000; // logical canvas width\nexport const LH = 750; // logical canvas height\n\nexport interface EngineCallbacks {\n onBegin(tool: DrawTool, color: string, size: number, points: number[]): void;\n onPoints(points: number[]): void;\n onEnd(): void;\n}\n\nconst BATCH_MS = 40;\n\nexport class DrawEngine {\n canvas: HTMLCanvasElement;\n private ctx: CanvasRenderingContext2D;\n private ops: DrawOp[] = [];\n private activeLocal: DrawOp | null = null;\n private activeRemote = new Map();\n private pending: number[] = [];\n private batchTimer: number | null = null;\n private drawing = false;\n private lastX = 0; private lastY = 0;\n cb: EngineCallbacks = { onBegin: () => {}, onPoints: () => {}, onEnd: () => {} };\n\n canDraw = false;\n tool: DrawTool = 'brush';\n color = '#000000';\n size = 12;\n onLocalDraw: () => void = () => {};\n\n constructor(canvas: HTMLCanvasElement) {\n this.canvas = canvas;\n canvas.width = LW;\n canvas.height = LH;\n const ctx = canvas.getContext('2d', { willReadFrequently: true });\n if (!ctx) throw new Error('no 2d context');\n this.ctx = ctx;\n this.clearSurface();\n this.bindInput();\n }\n\n // ---------------- surface ----------------\n private clearSurface(): void {\n this.ctx.save();\n this.ctx.globalCompositeOperation = 'source-over';\n this.ctx.clearRect(0, 0, LW, LH);\n this.ctx.fillStyle = '#ffffff';\n this.ctx.fillRect(0, 0, LW, LH);\n this.ctx.restore();\n }\n\n reset(ops: DrawOp[]): void {\n this.ops = ops.map(o => ({ ...o, points: o.points.slice() }));\n this.activeRemote.clear();\n this.activeLocal = null;\n this.redraw();\n }\n\n private redraw(): void {\n this.clearSurface();\n for (const op of this.ops) this.applyOp(op);\n }\n\n // ---------------- stroke rendering ----------------\n private strokePath(points: number[], color: string, size: number, erase: boolean): void {\n const g = this.ctx;\n const pts: number[] = [];\n for (let i = 0; i + 1 < points.length; i += 2) pts.push(points[i] * LW, points[i + 1] * LH);\n const n = pts.length / 2;\n if (n === 0) return;\n g.save();\n g.globalCompositeOperation = erase ? 'destination-out' : 'source-over';\n g.strokeStyle = color;\n g.fillStyle = color;\n g.lineWidth = size;\n g.lineCap = 'round';\n g.lineJoin = 'round';\n if (n === 1) {\n g.beginPath();\n g.arc(pts[0], pts[1], size / 2, 0, Math.PI * 2);\n g.fill();\n } else {\n g.beginPath();\n g.moveTo(pts[0], pts[1]);\n if (n === 2) {\n g.lineTo(pts[2], pts[3]);\n } else {\n for (let i = 1; i < n - 1; i++) {\n const mx = (pts[i * 2] + pts[(i + 1) * 2]) / 2;\n const my = (pts[i * 2 + 1] + pts[(i + 1) * 2 + 1]) / 2;\n g.quadraticCurveTo(pts[i * 2], pts[i * 2 + 1], mx, my);\n }\n g.lineTo(pts[(n - 1) * 2], pts[(n - 1) * 2 + 1]);\n }\n g.stroke();\n }\n g.restore();\n }\n\n private applyOp(op: DrawOp): void {\n switch (op.tool) {\n case 'brush': this.strokePath(op.points, op.color, op.size, false); break;\n case 'eraser': this.strokePath(op.points, '#000', op.size * 1.6, true); break;\n case 'fill':\n if (op.points.length >= 2) this.floodFill(op.points[0] * LW, op.points[1] * LH, op.color);\n break;\n case 'clear': this.clearSurface(); break;\n }\n }\n\n // ---------------- flood fill ----------------\n private floodFill(fx: number, fy: number, hex: string): void {\n const x0 = Math.floor(fx), y0 = Math.floor(fy);\n if (x0 < 0 || y0 < 0 || x0 >= LW || y0 >= LH) return;\n const img = this.ctx.getImageData(0, 0, LW, LH);\n const data = img.data;\n const idx0 = (y0 * LW + x0) * 4;\n const tr = data[idx0], tg = data[idx0 + 1], tb = data[idx0 + 2], ta = data[idx0 + 3];\n const fr = parseInt(hex.slice(1, 3), 16), fg = parseInt(hex.slice(3, 5), 16), fb = parseInt(hex.slice(5, 7), 16);\n if (tr === fr && tg === fg && tb === fb && ta === 255) return;\n const TOL = 48;\n const match = (i: number) =>\n Math.abs(data[i] - tr) <= TOL && Math.abs(data[i + 1] - tg) <= TOL &&\n Math.abs(data[i + 2] - tb) <= TOL && Math.abs(data[i + 3] - ta) <= TOL;\n const stack = [x0, y0];\n const seen = new Uint8Array(LW * LH);\n while (stack.length) {\n const y = stack.pop()!, x = stack.pop()!;\n const p = y * LW + x;\n if (seen[p]) continue;\n seen[p] = 1;\n const i = p * 4;\n if (!match(i)) continue;\n data[i] = fr; data[i + 1] = fg; data[i + 2] = fb; data[i + 3] = 255;\n if (x > 0) stack.push(x - 1, y);\n if (x < LW - 1) stack.push(x + 1, y);\n if (y > 0) stack.push(x, y - 1);\n if (y < LH - 1) stack.push(x, y + 1);\n }\n this.ctx.putImageData(img, 0, 0);\n }\n\n // ---------------- remote ops ----------------\n remoteBegin(op: DrawOp): void {\n const copy = { ...op, points: op.points.slice() };\n if (op.tool === 'brush' || op.tool === 'eraser') {\n this.activeRemote.set(op.id, copy);\n this.ops.push(copy);\n if (copy.points.length >= 2) this.strokePath(copy.points, copy.color, copy.tool === 'eraser' ? '#000' : copy.color, copy.size, copy.tool === 'eraser');\n } else {\n if (op.tool === 'clear') {\n this.ops = [copy];\n this.redraw();\n } else {\n this.ops.push(copy);\n this.applyOp(copy);\n }\n }\n }\n\n remotePoints(id: number, points: number[]): void {\n const op = this.activeRemote.get(id);\n if (!op) return;\n op.points.push(...points);\n this.strokePath(op.points, op.tool === 'eraser' ? '#000' : op.color, op.tool === 'eraser' ? op.size * 1.6 : op.size, op.tool === 'eraser');\n }\n\n remoteEnd(id: number): void {\n this.activeRemote.delete(id);\n }\n\n undo(opId: number): void {\n if (this.ops.some(o => o.id === opId)) {\n this.ops = this.ops.filter(o => o.id !== opId);\n } else {\n // own op: we never learn the server id, remove most recent local op\n for (let i = this.ops.length - 1; i >= 0; i--) {\n if (this.ops[i].id === -1) { this.ops.splice(i, 1); break; }\n }\n }\n this.activeRemote.delete(opId);\n this.redraw();\n }\n\n // ---------------- input ----------------\n private toNorm(e: PointerEvent): [number, number] {\n const r = this.canvas.getBoundingClientRect();\n const x = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));\n const y = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));\n return [x, y];\n }\n\n private bindInput(): void {\n this.canvas.style.touchAction = 'none';\n this.canvas.addEventListener('pointerdown', (e) => {\n if (!this.canDraw || e.button === 2) return;\n e.preventDefault();\n this.canvas.setPointerCapture(e.pointerId);\n const [x, y] = this.toNorm(e);\n this.onLocalDraw();\n if (this.tool === 'fill' || this.tool === 'clear') {\n const op: DrawOp = { id: -1, playerId: '', tool: this.tool, color: this.color, size: this.size, points: [x, y] };\n if (this.tool === 'clear') { this.ops = []; this.clearSurface(); }\n else this.applyOp({ ...op, points: [x, y] });\n this.cb.onBegin(this.tool, this.color, this.size, [x, y]);\n this.cb.onEnd();\n return;\n }\n this.drawing = true;\n this.lastX = x; this.lastY = y;\n this.activeLocal = { id: -1, playerId: '', tool: this.tool, color: this.color, size: this.size, points: [x, y] };\n this.ops.push(this.activeLocal);\n this.strokePath([x, y], this.color, this.tool === 'eraser' ? this.size * 1.6 : this.size, this.tool === 'eraser');\n this.cb.onBegin(this.tool, this.color, this.size, [x, y]);\n this.pending = [];\n this.batchTimer = window.setInterval(() => this.flush(), BATCH_MS);\n });\n\n this.canvas.addEventListener('pointermove', (e) => {\n if (!this.drawing || !this.activeLocal) return;\n const events = typeof e.getCoalescedEvents === 'function' ? e.getCoalescedEvents() : [e];\n for (const ev of events) {\n const [x, y] = this.toNorm(ev as PointerEvent);\n const dx = x - this.lastX, dy = y - this.lastY;\n if (dx * dx + dy * dy < 0.0000012) continue;\n this.activeLocal.points.push(x, y);\n this.pending.push(x, y);\n this.lastX = x; this.lastY = y;\n }\n this.strokePath(this.activeLocal.points, this.color, this.tool === 'eraser' ? this.size * 1.6 : this.size, this.tool === 'eraser');\n });\n\n const end = (e: PointerEvent) => {\n if (!this.drawing) return;\n this.drawing = false;\n try { this.canvas.releasePointerCapture(e.pointerId); } catch { /* ignore */ }\n this.flush();\n if (this.batchTimer) { clearInterval(this.batchTimer); this.batchTimer = null; }\n this.activeLocal = null;\n this.cb.onEnd();\n };\n this.canvas.addEventListener('pointerup', end);\n this.canvas.addEventListener('pointercancel', end);\n }\n\n private flush(): void {\n if (this.pending.length) {\n this.cb.onPoints(this.pending.slice());\n this.pending = [];\n }\n }\n}\n", "import { AvatarSeed } from '../shared/protocol';\n\n// Cartoon avatar renderer (skribbl-style blob characters), fully procedural.\nexport const AVATAR_COLORS = [\n '#f44336', '#ff8f00', '#ffd600', '#4caf50', '#00bcd4', '#2196f3',\n '#3f51b5', '#9c27b0', '#ec407a', '#8d6e63', '#607d8b', '#7cb342',\n];\n\nconst SKIN = ['#ffd9b3', '#f2b880', '#c98d5a', '#8c5a33', '#ffe0c4', '#e8b06a'];\n\nexport function drawAvatar(canvas: HTMLCanvasElement, seed: AvatarSeed, size = 80): void {\n const dpr = Math.min(2, window.devicePixelRatio || 1);\n canvas.width = size * dpr;\n canvas.height = size * dpr;\n canvas.style.width = `${size}px`;\n canvas.style.height = `${size}px`;\n const g = canvas.getContext('2d');\n if (!g) return;\n g.scale(dpr, dpr);\n g.clearRect(0, 0, size, size);\n\n const s = size / 80; // design space 80x80\n g.save();\n g.scale(s, s);\n\n const body = AVATAR_COLORS[seed.color % AVATAR_COLORS.length];\n\n // body blob\n g.beginPath();\n g.ellipse(40, 46, 26, 24, 0, 0, Math.PI * 2);\n g.fillStyle = body;\n g.fill();\n g.lineWidth = 3;\n g.strokeStyle = 'rgba(0,0,0,0.55)';\n g.stroke();\n\n // little feet\n g.fillStyle = body;\n for (const fx of [28, 52]) {\n g.beginPath();\n g.ellipse(fx, 68, 8, 5, 0, 0, Math.PI * 2);\n g.fill();\n g.stroke();\n }\n\n const skin = SKIN[(seed.color + seed.extra) % SKIN.length];\n // face patch\n g.beginPath();\n g.ellipse(40, 42, 18, 15, 0, 0, Math.PI * 2);\n g.fillStyle = skin;\n g.fill();\n\n const eyeY = 40, eyeDX = 8;\n g.lineWidth = 2.4;\n g.strokeStyle = '#222';\n g.fillStyle = '#222';\n\n const style = seed.eyes % 6;\n const eye = (cx: number, happy: boolean, closed: boolean) => {\n if (happy) {\n g.beginPath(); g.arc(cx, eyeY + 1, 4, Math.PI * 1.1, Math.PI * 1.9); g.stroke();\n } else if (closed) {\n g.beginPath(); g.moveTo(cx - 3.5, eyeY); g.lineTo(cx + 3.5, eyeY); g.stroke();\n } else {\n g.beginPath(); g.arc(cx, eyeY, 3.6, 0, Math.PI * 2); g.fillStyle = '#fff'; g.fill(); g.stroke();\n g.beginPath(); g.arc(cx + 1, eyeY + 0.6, 1.7, 0, Math.PI * 2); g.fillStyle = '#222'; g.fill();\n }\n };\n switch (style) {\n case 0: eye(40 - eyeDX, false, false); eye(40 + eyeDX, false, false); break; // normal\n case 1: eye(40 - eyeDX, true, false); eye(40 + eyeDX, true, false); break; // happy\n case 2: eye(40 - eyeDX, false, false); eye(40 + eyeDX, false, true); break; // wink\n case 3: // glasses\n eye(40 - eyeDX, false, false); eye(40 + eyeDX, false, false);\n g.lineWidth = 2;\n g.strokeRect(40 - eyeDX - 6, eyeY - 6, 11, 10);\n g.strokeRect(40 + eyeDX - 5, eyeY - 6, 11, 10);\n g.beginPath(); g.moveTo(40 - eyeDX + 5, eyeY - 2); g.lineTo(40 + eyeDX - 5, eyeY - 2); g.stroke();\n break;\n case 4: eye(40 - eyeDX, false, true); eye(40 + eyeDX, false, true); break; // sleepy\n case 5: // surprised big eyes\n for (const cx of [40 - eyeDX, 40 + eyeDX]) {\n g.beginPath(); g.arc(cx, eyeY, 5, 0, Math.PI * 2); g.fillStyle = '#fff'; g.fill(); g.stroke();\n g.beginPath(); g.arc(cx + 1, eyeY + 1, 2.2, 0, Math.PI * 2); g.fillStyle = '#222'; g.fill();\n }\n break;\n }\n\n // mouth\n g.lineWidth = 2.4;\n g.strokeStyle = '#7a3b2e';\n const my = 51;\n switch (seed.mouth % 6) {\n case 0: g.beginPath(); g.arc(40, my - 3, 7, Math.PI * 0.15, Math.PI * 0.85); g.stroke(); break; // smile\n case 1: // open smile\n g.beginPath(); g.arc(40, my - 3, 7, Math.PI * 0.1, Math.PI * 0.9); g.closePath();\n g.fillStyle = '#7a3b2e'; g.fill();\n g.beginPath(); g.ellipse(40, my + 3, 4, 2.2, 0, 0, Math.PI * 2); g.fillStyle = '#e57373'; g.fill();\n break;\n case 2: g.beginPath(); g.moveTo(34, my); g.quadraticCurveTo(41, my + 5, 47, my - 2); g.stroke(); break; // smirk\n case 3: g.beginPath(); g.ellipse(40, my, 3.6, 4.6, 0, 0, Math.PI * 2); g.fillStyle = '#7a3b2e'; g.fill(); break; // O\n case 4: // tongue\n g.beginPath(); g.arc(40, my - 3, 6, Math.PI * 0.15, Math.PI * 0.85); g.stroke();\n g.beginPath(); g.ellipse(40, my + 3.5, 3, 3.8, 0, 0, Math.PI * 2); g.fillStyle = '#e57373'; g.fill(); g.stroke();\n break;\n case 5: g.beginPath(); g.arc(40, my + 6, 6, Math.PI * 1.15, Math.PI * 1.85); g.stroke(); break; // sad\n }\n\n // extras on head\n g.strokeStyle = 'rgba(0,0,0,0.55)';\n switch (seed.extra % 6) {\n case 1: // hair tuft\n g.fillStyle = '#4e342e';\n g.beginPath();\n g.moveTo(28, 26); g.quadraticCurveTo(34, 8, 40, 22); g.quadraticCurveTo(46, 8, 52, 26);\n g.quadraticCurveTo(40, 18, 28, 26);\n g.fill();\n break;\n case 2: // cap\n g.fillStyle = '#e53935';\n g.beginPath(); g.arc(40, 26, 15, Math.PI, 0); g.closePath(); g.fill(); g.stroke();\n g.beginPath(); g.ellipse(52, 26, 9, 4, 0.15, 0, Math.PI * 2); g.fill(); g.stroke();\n g.fillStyle = '#fff'; g.beginPath(); g.arc(40, 13, 3, 0, Math.PI * 2); g.fill();\n break;\n case 3: // antenna\n g.beginPath(); g.moveTo(40, 24); g.quadraticCurveTo(42, 12, 40, 8); g.stroke();\n g.fillStyle = '#ffd600'; g.beginPath(); g.arc(40, 7, 3.6, 0, Math.PI * 2); g.fill(); g.stroke();\n break;\n case 4: // bow\n g.fillStyle = '#ec407a';\n for (const dir of [-1, 1]) {\n g.beginPath();\n g.moveTo(40 + dir * 6, 20);\n g.lineTo(40 + dir * 16, 13);\n g.lineTo(40 + dir * 16, 27);\n g.closePath(); g.fill(); g.stroke();\n }\n g.beginPath(); g.arc(40, 20, 3.4, 0, Math.PI * 2); g.fill(); g.stroke();\n break;\n case 5: // tiny crown\n g.fillStyle = '#ffd600';\n g.beginPath();\n g.moveTo(28, 22); g.lineTo(28, 12); g.lineTo(34, 18); g.lineTo(40, 9);\n g.lineTo(46, 18); g.lineTo(52, 12); g.lineTo(52, 22); g.closePath();\n g.fill(); g.stroke();\n break;\n default: break;\n }\n\n g.restore();\n}\n", "// Synthesized sound effects via WebAudio - no assets needed.\nlet ctx: AudioContext | null = null;\nlet muted = false;\n\nfunction ac(): AudioContext | null {\n try {\n if (!ctx) ctx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();\n if (ctx.state === 'suspended') void ctx.resume();\n return ctx;\n } catch { return null; }\n}\n\ninterface ToneOpts { type?: OscillatorType; vol?: number; at?: number; slide?: number; }\n\nfunction tone(freq: number, dur: number, opts: ToneOpts = {}): void {\n if (muted) return;\n const a = ac(); if (!a) return;\n const t0 = a.currentTime + (opts.at ?? 0);\n const osc = a.createOscillator();\n const gain = a.createGain();\n osc.type = opts.type ?? 'sine';\n osc.frequency.setValueAtTime(freq, t0);\n if (opts.slide) osc.frequency.exponentialRampToValueAtTime(Math.max(30, opts.slide), t0 + dur);\n const v = opts.vol ?? 0.12;\n gain.gain.setValueAtTime(0.0001, t0);\n gain.gain.exponentialRampToValueAtTime(v, t0 + 0.012);\n gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);\n osc.connect(gain).connect(a.destination);\n osc.start(t0);\n osc.stop(t0 + dur + 0.02);\n}\n\nexport const sfx = {\n setMuted(m: boolean) { muted = m; },\n get muted() { return muted; },\n unlock() { ac(); },\n\n click() { tone(650, 0.05, { type: 'square', vol: 0.05 }); },\n pop() { tone(880, 0.06, { vol: 0.07 }); },\n join() { tone(523, 0.09, { type: 'triangle' }); tone(784, 0.13, { at: 0.08, type: 'triangle' }); },\n leave() { tone(440, 0.09, { type: 'triangle' }); tone(294, 0.14, { at: 0.08, type: 'triangle' }); },\n correct(){ [523, 659, 784, 1047].forEach((f, i) => tone(f, 0.13, { at: i * 0.07, type: 'triangle', vol: 0.12 })); },\n yourTurn(){ [392, 523, 659, 784].forEach((f, i) => tone(f, 0.12, { at: i * 0.09, type: 'square', vol: 0.07 })); },\n tick() { tone(1150, 0.035, { type: 'square', vol: 0.05 }); },\n reveal() { tone(700, 0.09, { type: 'triangle', vol: 0.1, slide: 1000 }); },\n closeGuess() { tone(500, 0.08, { type: 'sine', vol: 0.09 }); tone(650, 0.1, { at: 0.07, vol: 0.09 }); },\n turnEnd(){ tone(330, 0.15, { type: 'triangle' }); tone(262, 0.22, { at: 0.13, type: 'triangle' }); },\n gameEnd(){ [392, 523, 659, 784, 1047, 1319].forEach((f, i) => tone(f, 0.18, { at: i * 0.11, type: 'triangle', vol: 0.11 })); },\n drawStart(){ tone(600, 0.08, { type: 'square', vol: 0.06, slide: 900 }); },\n denied() { tone(220, 0.15, { type: 'sawtooth', vol: 0.07, slide: 110 }); },\n erase() { tone(300, 0.05, { type: 'sawtooth', vol: 0.04, slide: 180 }); },\n};\n", "import { PlayerInfo, RoomSettings, RoomSummary, ScoreEntry, DrawTool } from '../shared/protocol';\nimport { drawAvatar } from './avatar';\nimport { sfx } from './sfx';\n\nexport const PALETTE = [\n '#ffffff', '#c1c1c1', '#ef130b', '#ff7f00', '#ffcf00', '#64dd17', '#00c853', '#00b8d4', '#2979ff', '#651fff', '#d500f9', '#f50057', '#795548',\n '#000000', '#616161', '#a30000', '#b35c00', '#8f7200', '#2f7a00', '#00695c', '#006064', '#0d47a1', '#311b92', '#6a0080', '#880e4f', '#4e342e',\n];\nexport const BRUSH_SIZES = [6, 14, 26, 42];\n\nfunction el(tag: K, cls?: string, html?: string): HTMLElementTagNameMap[K] {\n const e = document.createElement(tag);\n if (cls) e.className = cls;\n if (html !== undefined) e.innerHTML = html;\n return e;\n}\n\nexport function $(id: string): HTMLElement {\n const e = document.getElementById(id);\n if (!e) throw new Error(`missing #${id}`);\n return e;\n}\n\n// ---------------- toasts ----------------\nexport function toast(text: string, kind: 'info' | 'error' | 'good' = 'info'): void {\n const wrap = $('toast-wrap');\n const t = el('div', `toast ${kind === 'info' ? '' : kind}`);\n t.textContent = text;\n wrap.appendChild(t);\n setTimeout(() => { t.style.opacity = '0'; t.style.transition = 'opacity .4s'; }, 2600);\n setTimeout(() => t.remove(), 3100);\n}\n\n// ---------------- screens ----------------\nexport function showScreen(name: 'home' | 'game'): void {\n $('screen-home').classList.toggle('hidden', name !== 'home');\n $('screen-game').classList.toggle('hidden', name !== 'game');\n}\n\n// ---------------- banner ----------------\nlet bannerTimer: number | null = null;\nexport function banner(text: string, ms = 2400): void {\n const b = $('banner');\n b.textContent = text;\n b.classList.remove('hidden');\n if (bannerTimer) clearTimeout(bannerTimer);\n bannerTimer = window.setTimeout(() => b.classList.add('hidden'), ms);\n}\n\n// ---------------- confetti ----------------\nexport function confetti(parent: HTMLElement, count = 70): void {\n const colors = ['#f44336', '#ff9800', '#ffeb3b', '#4caf50', '#2196f3', '#9c27b0', '#e91e63'];\n for (let i = 0; i < count; i++) {\n const c = el('div', 'confetti');\n c.style.position = 'fixed';\n c.style.left = `${Math.random() * 100}vw`;\n c.style.background = colors[i % colors.length];\n c.style.animationDuration = `${1.8 + Math.random() * 2.2}s`;\n c.style.animationDelay = `${Math.random() * 0.8}s`;\n c.style.width = `${6 + Math.random() * 8}px`;\n c.style.height = `${10 + Math.random() * 10}px`;\n parent.appendChild(c);\n setTimeout(() => c.remove(), 5200);\n }\n}\n\n// ---------------- players list ----------------\nexport interface PlayersViewOpts {\n selfId: string;\n hostId: string;\n drawerId: string | null;\n typingIds: Set;\n onKick: (id: string) => void;\n}\n\nexport function renderPlayers(players: PlayerInfo[], opts: PlayersViewOpts): void {\n const list = $('players-list');\n list.innerHTML = '';\n const sorted = players.slice().sort((a, b) => b.score - a.score);\n sorted.forEach((p, i) => {\n const row = el('li', 'player-row');\n if (p.id === opts.drawerId) row.classList.add('drawing');\n if (p.guessed) row.classList.add('guessed');\n if (p.id === opts.selfId) row.classList.add('me');\n\n const rank = el('span', 'player-rank', `#${i + 1}`);\n const cv = el('canvas') as HTMLCanvasElement;\n drawAvatar(cv, p.avatar, 44);\n\n const info = el('div', 'player-info');\n const name = el('div', 'player-name');\n name.textContent = p.name + (p.id === opts.selfId ? ' (You)' : '');\n if (p.id === opts.hostId) name.insertAdjacentHTML('afterbegin', '\uD83D\uDC51');\n if (opts.typingIds.has(p.id)) name.insertAdjacentHTML('beforeend', '');\n const score = el('div', 'player-score', `${p.score} pts`);\n info.appendChild(name);\n info.appendChild(score);\n\n const status = el('span', 'player-status');\n if (p.id === opts.drawerId) status.textContent = '\u270F\uFE0F';\n else if (p.guessed) status.textContent = '\u2705';\n\n row.appendChild(rank);\n row.appendChild(cv);\n row.appendChild(info);\n row.appendChild(status);\n\n if (opts.selfId === opts.hostId && p.id !== opts.selfId) {\n const kick = el('button', 'player-kick', '\u2715');\n kick.title = `Kick ${p.name}`;\n kick.onclick = () => { if (confirm(`Kick ${p.name}?`)) opts.onKick(p.id); };\n row.appendChild(kick);\n }\n list.appendChild(row);\n });\n}\n\n// ---------------- chat ----------------\nexport function addChat(opts: { name?: string; text: string; kind?: 'info' | 'good' | 'bad' | 'close' | 'normal' }): void {\n const box = $('chat-messages');\n const m = el('div', 'chat-msg');\n const kind = opts.kind ?? 'normal';\n if (kind !== 'normal') m.classList.add(kind === 'info' ? 'sys' : kind);\n if (opts.name) {\n const n = el('span', 'chat-name');\n n.textContent = `${opts.name}: `;\n m.appendChild(n);\n }\n m.appendChild(document.createTextNode(opts.text));\n box.appendChild(m);\n while (box.children.length > 200) box.firstChild?.remove();\n box.scrollTop = box.scrollHeight;\n}\n\nexport function setTypingText(text: string): void {\n $('typing-indicator').textContent = text;\n}\n\n// ---------------- top bar ----------------\nexport function setRoomCode(code: string): void {\n $('room-code').textContent = code;\n}\n\nexport function setRound(text: string): void {\n $('round-display').textContent = text;\n}\n\nexport function setWord(mask: string, opts: { isDrawer: boolean; label?: string }): void {\n const wd = $('word-display');\n wd.innerHTML = '';\n if (opts.label) wd.appendChild(el('span', 'word-hint-label', opts.label));\n for (const ch of mask) {\n if (ch === ' ') { wd.appendChild(el('span', 'word-char space')); continue; }\n const hidden = ch === '_';\n const span = el('span', 'word-char' + (hidden ? '' : ' revealed'), hidden ? ' ' : ch);\n wd.appendChild(span);\n }\n}\n\nexport function setTimer(left: number, total: number): void {\n const clock = $('clock');\n if (total <= 1) {\n $('clock-num').textContent = '\u2013';\n clock.classList.remove('urgent');\n const hand0 = document.getElementById('clock-hand') as unknown as SVGLineElement | null;\n if (hand0) hand0.style.transform = 'rotate(0deg)';\n return;\n }\n $('clock-num').textContent = String(left);\n const hand = document.getElementById('clock-hand') as unknown as SVGLineElement | null;\n if (hand) {\n const frac = total > 0 ? 1 - left / total : 0;\n hand.style.transform = `rotate(${frac * 360}deg)`;\n }\n clock.classList.toggle('urgent', left <= 15 && left > 0 && total > 20);\n}\n\n// ---------------- tools ----------------\nexport interface ToolsCallbacks {\n onTool(tool: DrawTool): void;\n onColor(color: string): void;\n onSize(size: number): void;\n onUndo(): void;\n onClear(): void;\n}\n\nexport function buildTools(cb: ToolsCallbacks, initial: { tool: DrawTool; color: string; size: number }): { setEnabled(v: boolean): void; selectTool(t: DrawTool): void } {\n const palette = $('palette');\n palette.innerHTML = '';\n let activeSwatch: HTMLElement | null = null;\n for (const color of PALETTE) {\n const sw = el('div', 'color-swatch');\n sw.style.background = color;\n sw.title = color;\n sw.onclick = () => {\n activeSwatch?.classList.remove('active');\n sw.classList.add('active');\n activeSwatch = sw;\n cb.onColor(color);\n sfx.click();\n };\n if (color === initial.color) { sw.classList.add('active'); activeSwatch = sw; }\n palette.appendChild(sw);\n }\n\n const sizes = $('brush-sizes');\n sizes.innerHTML = '';\n let activeSize: HTMLElement | null = null;\n for (const s of BRUSH_SIZES) {\n const b = el('button', 'size-btn');\n b.title = `Brush size ${s}`;\n const dot = el('span', 'size-dot');\n const d = 4 + (s / 42) * 20;\n dot.style.width = `${d}px`;\n dot.style.height = `${d}px`;\n b.appendChild(dot);\n b.onclick = () => {\n activeSize?.classList.remove('active');\n b.classList.add('active');\n activeSize = b;\n cb.onSize(s);\n sfx.click();\n };\n if (s === initial.size) { b.classList.add('active'); activeSize = b; }\n sizes.appendChild(b);\n }\n\n const toolBtns: Record = {\n brush: $('tool-brush'), eraser: $('tool-eraser'), fill: $('tool-fill'),\n };\n const selectTool = (t: DrawTool) => {\n for (const [name, btn] of Object.entries(toolBtns)) btn.classList.toggle('active', name === t);\n };\n $('tool-brush').onclick = () => { cb.onTool('brush'); selectTool('brush'); sfx.click(); };\n $('tool-eraser').onclick = () => { cb.onTool('eraser'); selectTool('eraser'); sfx.click(); };\n $('tool-fill').onclick = () => { cb.onTool('fill'); selectTool('fill'); sfx.click(); };\n $('tool-undo').onclick = () => { cb.onUndo(); sfx.click(); };\n $('tool-clear').onclick = () => { if (confirm('Clear the whole canvas?')) { cb.onClear(); sfx.click(); } };\n selectTool(initial.tool);\n\n return {\n setEnabled(v: boolean) { $('tools').classList.toggle('disabled', !v); },\n selectTool,\n };\n}\n\n// ---------------- overlay ----------------\nexport function showOverlay(content: HTMLElement): void {\n const ov = $('overlay');\n ov.innerHTML = '';\n ov.appendChild(content);\n ov.classList.remove('hidden');\n}\n\nexport function hideOverlay(): void {\n $('overlay').classList.add('hidden');\n $('overlay').innerHTML = '';\n}\n\nfunction overlayCard(cls = ''): HTMLElement {\n return el('div', `overlay-card ${cls}`);\n}\n\nexport function overlayWaitingChoice(drawerName: string): void {\n const card = overlayCard();\n card.appendChild(el('h2', '', `\u270F\uFE0F ${escapeHtml(drawerName)}`));\n card.appendChild(el('div', 'overlay-sub', 'is choosing a word\u2026'));\n showOverlay(card);\n}\n\nexport function overlayWordChoice(words: string[], onPick: (w: string) => void): void {\n const card = overlayCard();\n card.appendChild(el('h2', '', 'Choose a word to draw!'));\n const row = el('div', 'word-choice-row');\n for (const w of words) {\n const b = el('button', 'word-choice-btn');\n b.textContent = w;\n b.onclick = () => { onPick(w); };\n row.appendChild(b);\n }\n card.appendChild(row);\n card.appendChild(el('div', 'overlay-sub', 'Pick fast \u2014 or a random word is chosen for you!'));\n showOverlay(card);\n}\n\nexport interface LobbyCallbacks {\n onSettings(s: Partial): void;\n onStart(): void;\n}\n\nexport function overlayLobby(settings: RoomSettings, code: string, isHost: boolean, playerCount: number, cb: LobbyCallbacks): void {\n const card = overlayCard('lobby-card');\n card.appendChild(el('h2', '', 'Waiting for players\u2026'));\n\n const codeRow = el('div', 'lobby-code-row');\n codeRow.appendChild(el('span', '', 'Room code:'));\n const codeEl = el('span', 'lobby-code', code);\n codeEl.title = 'Click to copy';\n codeEl.onclick = () => { void navigator.clipboard?.writeText(code); toast('Code copied! Share it with friends.', 'good'); sfx.pop(); };\n codeRow.appendChild(codeEl);\n card.appendChild(codeRow);\n\n const grid = el('div', 'settings-grid');\n const sel = (label: string, values: number[], current: number, suffix: string, onChange: (v: number) => void) => {\n const lab = el('label');\n lab.appendChild(el('span', '', label));\n const s = el('select') as HTMLSelectElement;\n for (const v of values) {\n const o = el('option', '', `${v}${suffix}`) as HTMLOptionElement;\n o.value = String(v);\n s.appendChild(o);\n }\n s.value = String(current);\n s.disabled = !isHost;\n s.onchange = () => onChange(parseInt(s.value, 10));\n lab.appendChild(s);\n return lab;\n };\n grid.appendChild(sel('Rounds', [2, 3, 4, 5, 6, 7, 8], settings.rounds, '', v => cb.onSettings({ rounds: v })));\n 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 })));\n grid.appendChild(sel('Max players', [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], settings.maxPlayers, '', v => cb.onSettings({ maxPlayers: v })));\n\n const pubLab = el('label', 'checkbox-label');\n const pub = el('input') as HTMLInputElement;\n pub.type = 'checkbox';\n pub.checked = settings.isPublic;\n pub.disabled = !isHost;\n pub.onchange = () => cb.onSettings({ isPublic: pub.checked });\n pubLab.appendChild(pub);\n pubLab.appendChild(el('span', '', 'Public room (listed in lobby browser)'));\n grid.appendChild(pubLab);\n\n const cwLab = el('label', 'full');\n cwLab.appendChild(el('span', '', 'Custom words (comma separated, optional)'));\n const cw = el('textarea') as HTMLTextAreaElement;\n cw.placeholder = 'e.g. lightsaber, hogwarts, pizza tower\u2026';\n cw.value = settings.customWords;\n cw.disabled = !isHost;\n cw.onchange = () => cb.onSettings({ customWords: cw.value });\n cwLab.appendChild(cw);\n grid.appendChild(cwLab);\n\n const cwoLab = el('label', 'checkbox-label full');\n const cwo = el('input') as HTMLInputElement;\n cwo.type = 'checkbox';\n cwo.checked = settings.customOnly;\n cwo.disabled = !isHost;\n cwo.onchange = () => cb.onSettings({ customOnly: cwo.checked });\n cwoLab.appendChild(cwo);\n cwoLab.appendChild(el('span', '', 'Use custom words only'));\n grid.appendChild(cwoLab);\n\n card.appendChild(grid);\n\n const actions = el('div', 'lobby-actions');\n if (isHost) {\n const start = el('button', 'btn btn-green btn-big', '\u25B6 Start Game');\n if (playerCount < 2) start.setAttribute('disabled', 'true');\n start.onclick = () => { cb.onStart(); sfx.click(); };\n actions.appendChild(start);\n }\n card.appendChild(actions);\n if (!isHost) card.appendChild(el('div', 'waiting-text', 'Waiting for the host to start the game\u2026'));\n else if (playerCount < 2) card.appendChild(el('div', 'waiting-text', 'Invite friends with the room code \u2014 you need at least 2 players!'));\n\n showOverlay(card);\n}\n\nexport function overlayTurnEnd(word: string, reason: string, scores: ScoreEntry[], selfId: string): void {\n const card = overlayCard();\n card.appendChild(el('h2', '', reason === 'time up' ? \"\u23F0 Time's up!\" : reason === 'everyone guessed' ? '\uD83C\uDF89 Everyone guessed it!' : 'Turn over'));\n card.appendChild(el('div', 'overlay-sub', 'The word was'));\n card.appendChild(el('div', 'reveal-word', escapeHtml(word)));\n const recap = el('div', 'score-recap');\n for (const s of scores) {\n const row = el('div', 'score-recap-row');\n const name = el('span', '', escapeHtml(s.name) + (s.id === selfId ? ' (You)' : ''));\n const delta = el('span', 'delta', s.delta > 0 ? `+${s.delta}` : '\u2014');\n row.appendChild(name); row.appendChild(delta);\n recap.appendChild(row);\n }\n card.appendChild(recap);\n showOverlay(card);\n}\n\nexport function overlayGameEnd(scores: ScoreEntry[], selfId: string): void {\n const card = overlayCard();\n card.appendChild(el('h2', '', '\uD83C\uDFC6 Game Over!'));\n const medals = ['\uD83E\uDD47', '\uD83E\uDD48', '\uD83E\uDD49'];\n const podium = el('div', 'podium');\n scores.slice(0, 3).forEach((s, i) => {\n const slot = el('div', `podium-slot podium-${i + 1}`);\n const cv = el('canvas') as HTMLCanvasElement;\n drawAvatar(cv, s.avatar, 64);\n slot.appendChild(el('div', 'medal', medals[i]));\n slot.appendChild(cv);\n slot.appendChild(el('div', 'podium-name', escapeHtml(s.name) + (s.id === selfId ? ' (You)' : '')));\n slot.appendChild(el('div', 'podium-score', `${s.score} pts`));\n slot.appendChild(el('div', 'podium-block', String(i + 1)));\n podium.appendChild(slot);\n });\n card.appendChild(podium);\n\n const table = el('div', 'leaderboard-table');\n scores.forEach((s, i) => {\n const row = el('div', 'leaderboard-row' + (i === 0 ? ' winner' : ''));\n row.appendChild(el('span', 'leaderboard-rank', `#${i + 1}`));\n row.appendChild(el('span', 'leaderboard-name', escapeHtml(s.name)));\n row.appendChild(el('span', '', `${s.score} pts`));\n table.appendChild(row);\n });\n card.appendChild(table);\n card.appendChild(el('div', 'overlay-sub', 'Returning to lobby\u2026'));\n showOverlay(card);\n confetti(document.body);\n}\n\n// ---------------- public rooms list ----------------\nexport function renderRoomList(rooms: RoomSummary[], onJoin: (code: string) => void): void {\n const list = $('rooms-list');\n list.innerHTML = '';\n if (!rooms.length) {\n list.appendChild(el('div', 'rooms-empty', 'No public rooms yet \u2014 create one!'));\n return;\n }\n for (const r of rooms) {\n const row = el('div', 'room-row');\n const info = el('div', 'room-info');\n info.appendChild(el('div', 'room-code', `#${r.code}`));\n info.appendChild(el('div', 'room-meta', `${r.players}/${r.maxPlayers} players \u00B7 ${r.phase === 'lobby' ? 'in lobby' : 'playing'} \u00B7 host ${escapeHtml(r.hostName)}`));\n const join = el('button', 'btn btn-green btn-small', 'Join');\n join.disabled = r.players >= r.maxPlayers;\n join.onclick = () => onJoin(r.code);\n row.appendChild(info);\n row.appendChild(join);\n list.appendChild(row);\n }\n}\n\nexport function escapeHtml(s: string): string {\n return s.replace(/[&<>\"']/g, c => ({ '&': '&', '<': '<', '>': '>', '\"': '"', \"'\": ''' }[c] ?? c));\n}\n", "import {\n AvatarSeed, DrawTool, PlayerInfo, RoomSettings, RoomSnapshot, S2C, DEFAULT_SETTINGS, randomAvatar,\n} from '../shared/protocol';\nimport { Net } from './net';\nimport { DrawEngine } from './canvas';\nimport { drawAvatar } from './avatar';\nimport { sfx } from './sfx';\nimport * as ui from './ui';\n\n// ================= state =================\nconst net = new Net();\nlet selfId = '';\nlet name = localStorage.getItem('skribble_name') ?? '';\nlet avatar: AvatarSeed = JSON.parse(localStorage.getItem('skribble_avatar') ?? 'null') ?? randomAvatar();\n\nlet inRoom = false;\nlet roomCode = '';\nlet hostId = '';\nlet phase: RoomSnapshot['phase'] = 'lobby';\nlet settings: RoomSettings = { ...DEFAULT_SETTINGS };\nlet players: PlayerInfo[] = [];\nlet drawerId: string | null = null;\nlet round = 0;\nlet totalRounds = settings.rounds;\nlet timerTotal = 80;\nlet lastTick = -1;\nconst typingIds = new Set();\nconst typingTimers = new Map();\nlet lobbyCtx: { onSettings(s: Partial): void; onStart(): void };\n\nconst engine = new DrawEngine(document.getElementById('draw-canvas') as HTMLCanvasElement);\nlet toolsApi: { setEnabled(v: boolean): void; selectTool(t: DrawTool): void };\n\n// ================= helpers =================\nconst isHost = () => selfId === hostId;\nconst isDrawer = () => selfId === drawerId && phase === 'drawing';\n\nfunction saveIdentity(): void {\n localStorage.setItem('skribble_name', name);\n localStorage.setItem('skribble_avatar', JSON.stringify(avatar));\n}\n\nfunction sendHello(): void {\n net.send({ t: 'hello', name: name || `Player${Math.floor(Math.random() * 1000)}`, avatar });\n}\n\nfunction renderPlayers(): void {\n ui.renderPlayers(players, {\n selfId, hostId, drawerId, typingIds,\n onKick: (id) => net.send({ t: 'kick', playerId: id }),\n });\n}\n\nfunction playerName(id: string): string {\n return players.find(p => p.id === id)?.name ?? '???';\n}\n\nfunction refreshLobbyOverlay(): void {\n if (phase !== 'lobby' || !inRoom) return;\n ui.overlayLobby(settings, roomCode, isHost(), players.length, lobbyCtx);\n}\n\nfunction setTimer(left: number): void {\n ui.setTimer(left, timerTotal);\n if (phase === 'drawing' && left <= 5 && left >= 1 && left !== lastTick) {\n lastTick = left;\n sfx.tick();\n }\n}\n\n// ================= net handling =================\nnet.onOpen = () => {\n sendHello();\n};\n\nnet.onClose = () => {\n ui.toast('Disconnected from server. Reload to reconnect.', 'error');\n};\n\nnet.onMessage = (msg: S2C) => {\n switch (msg.t) {\n case 'welcome':\n selfId = msg.id;\n net.send({ t: 'list_rooms' });\n break;\n\n case 'room_list':\n ui.renderRoomList(msg.rooms, (code) => joinRoom(code));\n break;\n\n case 'join_error':\n ui.toast(msg.reason, 'error');\n sfx.denied();\n break;\n\n case 'joined': {\n const r = msg.room;\n inRoom = true;\n roomCode = r.code;\n hostId = r.hostId;\n phase = r.phase;\n settings = r.settings;\n players = r.players;\n drawerId = r.drawerId;\n round = r.round;\n totalRounds = r.totalRounds;\n timerTotal = r.timeLeft || settings.drawTime;\n ui.showScreen('game');\n requestAnimationFrame(fitCanvas);\n ui.setRoomCode(r.code);\n ui.setRound(phase === 'lobby' ? 'Lobby' : `Round ${round} of ${totalRounds}`);\n engine.reset(r.ops ?? []);\n renderPlayers();\n ui.toast(`Joined room #${r.code}`, 'good');\n sfx.join();\n applyPhaseUI(r.word, r.mask, r.timeLeft);\n break;\n }\n\n case 'player_joined':\n players.push(msg.player);\n renderPlayers();\n refreshLobbyOverlay();\n sfx.join();\n break;\n\n case 'player_left':\n players = players.filter(p => p.id !== msg.id);\n hostId = msg.hostId;\n renderPlayers();\n refreshLobbyOverlay();\n sfx.leave();\n break;\n\n case 'host':\n hostId = msg.id;\n renderPlayers();\n refreshLobbyOverlay();\n if (msg.id === selfId) ui.toast('You are now the host \uD83D\uDC51');\n break;\n\n case 'settings':\n settings = msg.settings;\n refreshLobbyOverlay();\n break;\n\n case 'chat':\n ui.addChat({ name: msg.name, text: msg.text });\n sfx.pop();\n break;\n\n case 'chat_sys':\n ui.addChat({ text: msg.text, kind: msg.kind });\n if (msg.kind === 'good') sfx.correct();\n else if (msg.kind === 'close') sfx.closeGuess();\n else if (msg.kind === 'bad') sfx.leave();\n break;\n\n case 'score_update':\n players = msg.players;\n renderPlayers();\n break;\n\n case 'typing': {\n typingIds.add(msg.id);\n ui.setTypingText(`${playerName(msg.id)} is typing\u2026`);\n const prev = typingTimers.get(msg.id);\n if (prev) clearTimeout(prev);\n typingTimers.set(msg.id, window.setTimeout(() => {\n typingIds.delete(msg.id);\n ui.setTypingText('');\n renderPlayers();\n }, 2500));\n renderPlayers();\n break;\n }\n\n case 'game_starting':\n round = msg.round;\n totalRounds = msg.totalRounds;\n ui.setRound(`Round ${round} of ${totalRounds}`);\n ui.hideOverlay();\n ui.banner(`\uD83C\uDFAC Round ${round} of ${totalRounds}`);\n drawerId = null;\n engine.canDraw = false;\n toolsApi.setEnabled(false);\n break;\n\n case 'word_choice':\n phase = 'wordChoice';\n drawerId = msg.drawerId;\n timerTotal = msg.timeLeft;\n ui.setTimer(msg.timeLeft, timerTotal);\n renderPlayers();\n if (msg.drawerId !== selfId) {\n ui.overlayWaitingChoice(msg.drawerName);\n ui.setWord('', { isDrawer: false, label: `${msg.drawerName} is choosing a word` });\n }\n break;\n\n case 'choose_words':\n sfx.yourTurn();\n ui.overlayWordChoice(msg.words, (w) => {\n net.send({ t: 'pick_word', word: w });\n sfx.click();\n ui.hideOverlay();\n ui.banner(`You picked: ${w.toUpperCase()}`);\n });\n break;\n\n case 'draw_start': {\n phase = 'drawing';\n drawerId = msg.drawerId;\n timerTotal = msg.timeLeft;\n lastTick = -1;\n round = msg.round;\n totalRounds = msg.totalRounds;\n ui.hideOverlay();\n ui.setRound(`Round ${round} of ${totalRounds}`);\n engine.reset([]);\n const mine = msg.drawerId === selfId;\n engine.canDraw = mine;\n toolsApi.setEnabled(mine);\n (document.getElementById('draw-canvas') as HTMLCanvasElement).classList.toggle('no-draw', !mine);\n if (mine && msg.word) {\n ui.setWord(msg.word, { isDrawer: true, label: 'DRAW THIS:' });\n ui.banner(`\u270F\uFE0F Your turn! Draw: ${msg.word.toUpperCase()}`, 3000);\n sfx.drawStart();\n } else {\n ui.setWord(msg.mask, { isDrawer: false, label: 'GUESS:' });\n ui.banner(`\u270F\uFE0F ${playerName(msg.drawerId)} is drawing!`);\n sfx.pop();\n }\n ui.setTimer(msg.timeLeft, timerTotal);\n renderPlayers();\n break;\n }\n\n case 'hint':\n if (!isDrawer()) {\n ui.setWord(msg.mask, { isDrawer: false, label: 'GUESS:' });\n sfx.reveal();\n }\n break;\n\n case 'timer':\n setTimer(msg.timeLeft);\n break;\n\n case 'turn_end':\n phase = 'turnEnd';\n engine.canDraw = false;\n toolsApi.setEnabled(false);\n ui.overlayTurnEnd(msg.word, msg.reason, msg.scores, selfId);\n sfx.turnEnd();\n for (const s of msg.scores) {\n const p = players.find(x => x.id === s.id);\n if (p) p.score = s.score;\n }\n renderPlayers();\n break;\n\n case 'game_end':\n phase = 'gameEnd';\n ui.overlayGameEnd(msg.scores, selfId);\n sfx.gameEnd();\n for (const s of msg.scores) {\n const p = players.find(x => x.id === s.id);\n if (p) p.score = s.score;\n }\n renderPlayers();\n break;\n\n case 'back_to_lobby':\n phase = 'lobby';\n drawerId = null;\n engine.canDraw = false;\n toolsApi.setEnabled(false);\n engine.reset([]);\n ui.setRound('Lobby');\n ui.setWord('', { isDrawer: false, label: '' });\n ui.setTimer(0, 1);\n for (const p of players) p.guessed = false;\n renderPlayers();\n refreshLobbyOverlay();\n break;\n\n case 'draw_begin': engine.remoteBegin(msg.op); break;\n case 'draw_points': engine.remotePoints(msg.id, msg.points); break;\n case 'draw_end': engine.remoteEnd(msg.id); break;\n case 'undo': engine.undo(msg.opId); break;\n\n case 'kicked':\n ui.toast(`You were removed: ${msg.reason}`, 'error');\n leaveToHome();\n break;\n\n case 'pong': break;\n }\n};\n\nfunction applyPhaseUI(word: string | null, mask: string, timeLeft: number): void {\n // Render UI matching current phase after (re)joining a room.\n engine.canDraw = false;\n toolsApi.setEnabled(false);\n switch (phase) {\n case 'lobby':\n refreshLobbyOverlay();\n ui.setWord('', { isDrawer: false });\n break;\n case 'wordChoice':\n if (drawerId && drawerId !== selfId) ui.overlayWaitingChoice(playerName(drawerId));\n break;\n case 'drawing': {\n const mine = word != null;\n engine.canDraw = mine;\n toolsApi.setEnabled(mine);\n (document.getElementById('draw-canvas') as HTMLCanvasElement).classList.toggle('no-draw', !mine);\n if (mine) ui.setWord(word!, { isDrawer: true, label: 'DRAW THIS:' });\n else ui.setWord(mask, { isDrawer: false, label: 'GUESS:' });\n ui.hideOverlay();\n break;\n }\n case 'turnEnd':\n case 'gameEnd':\n break;\n }\n if (timeLeft) ui.setTimer(timeLeft, timerTotal);\n}\n\n// ================= home screen =================\nfunction joinRoom(code: string): void {\n saveIdentity();\n sendHello();\n net.send({ t: 'join_room', code });\n}\n\nfunction initHome(): void {\n const nameInput = document.getElementById('name-input') as HTMLInputElement;\n nameInput.value = name;\n nameInput.oninput = () => { name = nameInput.value; saveIdentity(); };\n\n const preview = document.getElementById('avatar-preview') as HTMLCanvasElement;\n drawAvatar(preview, avatar, 80);\n document.getElementById('btn-avatar-shuffle')!.onclick = () => {\n avatar = randomAvatar();\n drawAvatar(preview, avatar, 80);\n saveIdentity();\n sfx.pop();\n };\n\n document.getElementById('btn-play')!.onclick = () => {\n saveIdentity(); sendHello();\n net.send({ t: 'quick_join' });\n sfx.click();\n };\n document.getElementById('btn-create-private')!.onclick = () => {\n saveIdentity(); sendHello();\n net.send({ t: 'create_room', isPublic: false });\n sfx.click();\n };\n const codeInput = document.getElementById('code-input') as HTMLInputElement;\n document.getElementById('btn-join-code')!.onclick = () => {\n if (codeInput.value.trim().length !== 4) { ui.toast('Enter the 4-letter room code', 'error'); return; }\n joinRoom(codeInput.value.trim());\n sfx.click();\n };\n codeInput.onkeydown = (e) => { if (e.key === 'Enter') document.getElementById('btn-join-code')!.click(); };\n document.getElementById('btn-refresh-rooms')!.onclick = () => { net.send({ t: 'list_rooms' }); sfx.click(); };\n\n // periodic lobby refresh while on home screen\n window.setInterval(() => {\n if (!inRoom && net.connected) net.send({ t: 'list_rooms' });\n }, 4000);\n}\n\nfunction leaveToHome(): void {\n inRoom = false;\n phase = 'lobby';\n players = [];\n drawerId = null;\n typingIds.clear();\n ui.showScreen('home');\n ui.hideOverlay();\n net.send({ t: 'list_rooms' });\n}\n\n// ================= game screen =================\nfunction initGame(): void {\n toolsApi = ui.buildTools({\n onTool: (t) => { engine.tool = t; },\n onColor: (c) => { engine.color = c; if (engine.tool === 'eraser') { engine.tool = 'brush'; toolsApi.selectTool('brush'); } },\n onSize: (s) => { engine.size = s; },\n onUndo: () => { if (isDrawer()) net.send({ t: 'undo' }); },\n onClear: () => {\n if (!isDrawer()) return;\n engine.reset([]);\n net.send({ t: 'draw_begin', tool: 'clear', color: engine.color, size: engine.size, points: [0.5, 0.5] });\n net.send({ t: 'draw_end' });\n },\n }, { tool: 'brush', color: '#000000', size: 14 });\n toolsApi.setEnabled(false);\n\n engine.cb = {\n onBegin: (tool, color, size, points) => net.send({ t: 'draw_begin', tool, color, size, points }),\n onPoints: (points) => net.send({ t: 'draw_points', points }),\n onEnd: () => net.send({ t: 'draw_end' }),\n };\n engine.onLocalDraw = () => {};\n\n // chat\n const chatInput = document.getElementById('chat-input') as HTMLInputElement;\n let lastTypingSent = 0;\n chatInput.addEventListener('keydown', (e) => {\n if (e.key === 'Enter') {\n const text = chatInput.value.trim();\n if (text) net.send({ t: 'chat', text });\n chatInput.value = '';\n }\n });\n chatInput.addEventListener('input', () => {\n const now = Date.now();\n if (now - lastTypingSent > 1500 && chatInput.value.trim()) {\n lastTypingSent = now;\n net.send({ t: 'typing' });\n }\n });\n\n // top bar\n document.getElementById('btn-leave')!.onclick = () => {\n net.send({ t: 'leave_room' });\n sfx.click();\n leaveToHome();\n };\n document.getElementById('btn-sound')!.onclick = (e) => {\n sfx.setMuted(!sfx.muted);\n (e.currentTarget as HTMLElement).textContent = sfx.muted ? '\uD83D\uDD07' : '\uD83D\uDD0A';\n if (!sfx.muted) sfx.pop();\n };\n document.getElementById('room-code-chip')!.onclick = () => {\n void navigator.clipboard?.writeText(roomCode);\n ui.toast('Room code copied!', 'good');\n sfx.pop();\n };\n\n // keyboard shortcuts\n document.addEventListener('keydown', (e) => {\n if ((e.target as HTMLElement)?.tagName === 'INPUT' || (e.target as HTMLElement)?.tagName === 'TEXTAREA') return;\n if (!inRoom) return;\n const k = e.key.toLowerCase();\n if (k === 'b') { engine.tool = 'brush'; toolsApi.selectTool('brush'); }\n else if (k === 'e') { engine.tool = 'eraser'; toolsApi.selectTool('eraser'); }\n else if (k === 'f') { engine.tool = 'fill'; toolsApi.selectTool('fill'); }\n else if (k === 'z' && (e.ctrlKey || e.metaKey) && isDrawer()) { e.preventDefault(); net.send({ t: 'undo' }); }\n });\n\n lobbyCtx = {\n onSettings: (s) => net.send({ t: 'set_settings', settings: s }),\n onStart: () => net.send({ t: 'start_game' }),\n };\n}\n\n// keep the canvas element at an exact 4:3 aspect so pointer mapping stays precise\nfunction fitCanvas(): void {\n const wrap = document.getElementById('canvas-wrap');\n const cv = document.getElementById('draw-canvas') as HTMLCanvasElement;\n if (!wrap.clientWidth) return;\n const scale = Math.min(wrap.clientWidth / 1000, wrap.clientHeight / 750);\n cv.style.width = `${Math.floor(1000 * scale)}px`;\n cv.style.height = `${Math.floor(750 * scale)}px`;\n}\nwindow.addEventListener('resize', fitCanvas);\n\n// ================= boot =================\ninitHome();\ninitGame();\nui.showScreen('home');\ndocument.addEventListener('pointerdown', () => sfx.unlock(), { once: true });\nnet.connect();\n"], + "mappings": "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", + "names": ["ctx", "name", "players", "settings", "isHost", "selfId", "renderPlayers", "setTimer"] +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..380bfdf --- /dev/null +++ b/public/index.html @@ -0,0 +1,110 @@ + + + + + +skribble - free multiplayer drawing & guessing game + + + + + + + + + SKRIBBLE + + + + + 🔀 + + + Play! + or + + + Join + + Create Private Room + + + + Public Rooms + ⟳ + + Loading… + + + + How to play: 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! 🎨 + + + + + + + + SKRIBBLE + #---- + Lobby + + + + + + + + – + + 🔊 + 🚪 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/style.css b/public/style.css new file mode 100644 index 0000000..26aa69f --- /dev/null +++ b/public/style.css @@ -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; } +} diff --git a/src/client/avatar.ts b/src/client/avatar.ts new file mode 100644 index 0000000..456d2c6 --- /dev/null +++ b/src/client/avatar.ts @@ -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(); +} diff --git a/src/client/canvas.ts b/src/client/canvas.ts new file mode 100644 index 0000000..bf6ec57 --- /dev/null +++ b/src/client/canvas.ts @@ -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(); + 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 = []; + } + } +} diff --git a/src/client/main.ts b/src/client/main.ts new file mode 100644 index 0000000..26d9495 --- /dev/null +++ b/src/client/main.ts @@ -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(); +const typingTimers = new Map(); +let lobbyCtx: { onSettings(s: Partial): 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(); diff --git a/src/client/net.ts b/src/client/net.ts new file mode 100644 index 0000000..00d8ac3 --- /dev/null +++ b/src/client/net.ts @@ -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; } +} diff --git a/src/client/sfx.ts b/src/client/sfx.ts new file mode 100644 index 0000000..7f6af07 --- /dev/null +++ b/src/client/sfx.ts @@ -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 }); }, +}; diff --git a/src/client/ui.ts b/src/client/ui.ts new file mode 100644 index 0000000..fc78843 --- /dev/null +++ b/src/client/ui.ts @@ -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(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; + 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', '👑'); + if (opts.typingIds.has(p.id)) name.insertAdjacentHTML('beforeend', ''); + const score = el('div', 'player-score', `${p.score} pts`); + info.appendChild(name); + info.appendChild(score); + + const status = el('span', 'player-status'); + if (p.id === opts.drawerId) status.textContent = '✏️'; + else if (p.guessed) status.textContent = '✅'; + + row.appendChild(rank); + row.appendChild(cv); + row.appendChild(info); + row.appendChild(status); + + if (opts.selfId === opts.hostId && p.id !== opts.selfId) { + const kick = el('button', 'player-kick', '✕'); + kick.title = `Kick ${p.name}`; + kick.onclick = () => { if (confirm(`Kick ${p.name}?`)) opts.onKick(p.id); }; + row.appendChild(kick); + } + list.appendChild(row); + }); +} + +// ---------------- chat ---------------- +export function addChat(opts: { name?: string; text: string; kind?: 'info' | 'good' | 'bad' | 'close' | 'normal' }): void { + const box = $('chat-messages'); + const m = el('div', 'chat-msg'); + const kind = opts.kind ?? 'normal'; + if (kind !== 'normal') m.classList.add(kind === 'info' ? 'sys' : kind); + if (opts.name) { + const n = el('span', 'chat-name'); + n.textContent = `${opts.name}: `; + m.appendChild(n); + } + m.appendChild(document.createTextNode(opts.text)); + box.appendChild(m); + while (box.children.length > 200) box.firstChild?.remove(); + box.scrollTop = box.scrollHeight; +} + +export function setTypingText(text: string): void { + $('typing-indicator').textContent = text; +} + +// ---------------- top bar ---------------- +export function setRoomCode(code: string): void { + $('room-code').textContent = code; +} + +export function setRound(text: string): void { + $('round-display').textContent = text; +} + +export function setWord(mask: string, opts: { isDrawer: boolean; label?: string }): void { + const wd = $('word-display'); + wd.innerHTML = ''; + if (opts.label) wd.appendChild(el('span', 'word-hint-label', opts.label)); + for (const ch of mask) { + if (ch === ' ') { wd.appendChild(el('span', 'word-char space')); continue; } + const hidden = ch === '_'; + const span = el('span', 'word-char' + (hidden ? '' : ' revealed'), hidden ? ' ' : ch); + wd.appendChild(span); + } +} + +export function setTimer(left: number, total: number): void { + const clock = $('clock'); + if (total <= 1) { + $('clock-num').textContent = '–'; + clock.classList.remove('urgent'); + const hand0 = document.getElementById('clock-hand') as unknown as SVGLineElement | null; + if (hand0) hand0.style.transform = 'rotate(0deg)'; + return; + } + $('clock-num').textContent = String(left); + const hand = document.getElementById('clock-hand') as unknown as SVGLineElement | null; + if (hand) { + const frac = total > 0 ? 1 - left / total : 0; + hand.style.transform = `rotate(${frac * 360}deg)`; + } + clock.classList.toggle('urgent', left <= 15 && left > 0 && total > 20); +} + +// ---------------- tools ---------------- +export interface ToolsCallbacks { + onTool(tool: DrawTool): void; + onColor(color: string): void; + onSize(size: number): void; + onUndo(): void; + onClear(): void; +} + +export function buildTools(cb: ToolsCallbacks, initial: { tool: DrawTool; color: string; size: number }): { setEnabled(v: boolean): void; selectTool(t: DrawTool): void } { + const palette = $('palette'); + palette.innerHTML = ''; + let activeSwatch: HTMLElement | null = null; + for (const color of PALETTE) { + const sw = el('div', 'color-swatch'); + sw.style.background = color; + sw.title = color; + sw.onclick = () => { + activeSwatch?.classList.remove('active'); + sw.classList.add('active'); + activeSwatch = sw; + cb.onColor(color); + sfx.click(); + }; + if (color === initial.color) { sw.classList.add('active'); activeSwatch = sw; } + palette.appendChild(sw); + } + + const sizes = $('brush-sizes'); + sizes.innerHTML = ''; + let activeSize: HTMLElement | null = null; + for (const s of BRUSH_SIZES) { + const b = el('button', 'size-btn'); + b.title = `Brush size ${s}`; + const dot = el('span', 'size-dot'); + const d = 4 + (s / 42) * 20; + dot.style.width = `${d}px`; + dot.style.height = `${d}px`; + b.appendChild(dot); + b.onclick = () => { + activeSize?.classList.remove('active'); + b.classList.add('active'); + activeSize = b; + cb.onSize(s); + sfx.click(); + }; + if (s === initial.size) { b.classList.add('active'); activeSize = b; } + sizes.appendChild(b); + } + + const toolBtns: Record = { + 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): void; + onStart(): void; +} + +export function overlayLobby(settings: RoomSettings, code: string, isHost: boolean, playerCount: number, cb: LobbyCallbacks): void { + const card = overlayCard('lobby-card'); + card.appendChild(el('h2', '', 'Waiting for players…')); + + const codeRow = el('div', 'lobby-code-row'); + codeRow.appendChild(el('span', '', 'Room code:')); + const codeEl = el('span', 'lobby-code', code); + codeEl.title = 'Click to copy'; + codeEl.onclick = () => { void navigator.clipboard?.writeText(code); toast('Code copied! Share it with friends.', 'good'); sfx.pop(); }; + codeRow.appendChild(codeEl); + card.appendChild(codeRow); + + const grid = el('div', 'settings-grid'); + const sel = (label: string, values: number[], current: number, suffix: string, onChange: (v: number) => void) => { + const lab = el('label'); + lab.appendChild(el('span', '', label)); + const s = el('select') as HTMLSelectElement; + for (const v of values) { + const o = el('option', '', `${v}${suffix}`) as HTMLOptionElement; + o.value = String(v); + s.appendChild(o); + } + s.value = String(current); + s.disabled = !isHost; + s.onchange = () => onChange(parseInt(s.value, 10)); + lab.appendChild(s); + return lab; + }; + grid.appendChild(sel('Rounds', [2, 3, 4, 5, 6, 7, 8], settings.rounds, '', v => cb.onSettings({ rounds: v }))); + grid.appendChild(sel('Draw time (s)', [30, 40, 50, 60, 70, 80, 90, 100, 120, 150, 180], settings.drawTime, ' s', v => cb.onSettings({ drawTime: v }))); + grid.appendChild(sel('Max players', [2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], settings.maxPlayers, '', v => cb.onSettings({ maxPlayers: v }))); + + const pubLab = el('label', 'checkbox-label'); + const pub = el('input') as HTMLInputElement; + pub.type = 'checkbox'; + pub.checked = settings.isPublic; + pub.disabled = !isHost; + pub.onchange = () => cb.onSettings({ isPublic: pub.checked }); + pubLab.appendChild(pub); + pubLab.appendChild(el('span', '', 'Public room (listed in lobby browser)')); + grid.appendChild(pubLab); + + const cwLab = el('label', 'full'); + cwLab.appendChild(el('span', '', 'Custom words (comma separated, optional)')); + const cw = el('textarea') as HTMLTextAreaElement; + cw.placeholder = 'e.g. lightsaber, hogwarts, pizza tower…'; + cw.value = settings.customWords; + cw.disabled = !isHost; + cw.onchange = () => cb.onSettings({ customWords: cw.value }); + cwLab.appendChild(cw); + grid.appendChild(cwLab); + + const cwoLab = el('label', 'checkbox-label full'); + const cwo = el('input') as HTMLInputElement; + cwo.type = 'checkbox'; + cwo.checked = settings.customOnly; + cwo.disabled = !isHost; + cwo.onchange = () => cb.onSettings({ customOnly: cwo.checked }); + cwoLab.appendChild(cwo); + cwoLab.appendChild(el('span', '', 'Use custom words only')); + grid.appendChild(cwoLab); + + card.appendChild(grid); + + const actions = el('div', 'lobby-actions'); + if (isHost) { + const start = el('button', 'btn btn-green btn-big', '▶ Start Game'); + if (playerCount < 2) start.setAttribute('disabled', 'true'); + start.onclick = () => { cb.onStart(); sfx.click(); }; + actions.appendChild(start); + } + card.appendChild(actions); + if (!isHost) card.appendChild(el('div', 'waiting-text', 'Waiting for the host to start the game…')); + else if (playerCount < 2) card.appendChild(el('div', 'waiting-text', 'Invite friends with the room code — you need at least 2 players!')); + + showOverlay(card); +} + +export function overlayTurnEnd(word: string, reason: string, scores: ScoreEntry[], selfId: string): void { + const card = overlayCard(); + card.appendChild(el('h2', '', reason === 'time up' ? "⏰ Time's up!" : reason === 'everyone guessed' ? '🎉 Everyone guessed it!' : 'Turn over')); + card.appendChild(el('div', 'overlay-sub', 'The word was')); + card.appendChild(el('div', 'reveal-word', escapeHtml(word))); + const recap = el('div', 'score-recap'); + for (const s of scores) { + const row = el('div', 'score-recap-row'); + const name = el('span', '', escapeHtml(s.name) + (s.id === selfId ? ' (You)' : '')); + const delta = el('span', 'delta', s.delta > 0 ? `+${s.delta}` : '—'); + row.appendChild(name); row.appendChild(delta); + recap.appendChild(row); + } + card.appendChild(recap); + showOverlay(card); +} + +export function overlayGameEnd(scores: ScoreEntry[], selfId: string): void { + const card = overlayCard(); + card.appendChild(el('h2', '', '🏆 Game Over!')); + const medals = ['🥇', '🥈', '🥉']; + const podium = el('div', 'podium'); + scores.slice(0, 3).forEach((s, i) => { + const slot = el('div', `podium-slot podium-${i + 1}`); + const cv = el('canvas') as HTMLCanvasElement; + drawAvatar(cv, s.avatar, 64); + slot.appendChild(el('div', 'medal', medals[i])); + slot.appendChild(cv); + slot.appendChild(el('div', 'podium-name', escapeHtml(s.name) + (s.id === selfId ? ' (You)' : ''))); + slot.appendChild(el('div', 'podium-score', `${s.score} pts`)); + slot.appendChild(el('div', 'podium-block', String(i + 1))); + podium.appendChild(slot); + }); + card.appendChild(podium); + + const table = el('div', 'leaderboard-table'); + scores.forEach((s, i) => { + const row = el('div', 'leaderboard-row' + (i === 0 ? ' winner' : '')); + row.appendChild(el('span', 'leaderboard-rank', `#${i + 1}`)); + row.appendChild(el('span', 'leaderboard-name', escapeHtml(s.name))); + row.appendChild(el('span', '', `${s.score} pts`)); + table.appendChild(row); + }); + card.appendChild(table); + card.appendChild(el('div', 'overlay-sub', 'Returning to lobby…')); + showOverlay(card); + confetti(document.body); +} + +// ---------------- public rooms list ---------------- +export function renderRoomList(rooms: RoomSummary[], onJoin: (code: string) => void): void { + const list = $('rooms-list'); + list.innerHTML = ''; + if (!rooms.length) { + list.appendChild(el('div', 'rooms-empty', 'No public rooms yet — create one!')); + return; + } + for (const r of rooms) { + const row = el('div', 'room-row'); + const info = el('div', 'room-info'); + info.appendChild(el('div', 'room-code', `#${r.code}`)); + info.appendChild(el('div', 'room-meta', `${r.players}/${r.maxPlayers} players · ${r.phase === 'lobby' ? 'in lobby' : 'playing'} · host ${escapeHtml(r.hostName)}`)); + const join = el('button', 'btn btn-green btn-small', 'Join'); + join.disabled = r.players >= r.maxPlayers; + join.onclick = () => onJoin(r.code); + row.appendChild(info); + row.appendChild(join); + list.appendChild(row); + } +} + +export function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] ?? c)); +} diff --git a/src/server/game.ts b/src/server/game.ts new file mode 100644 index 0000000..98c23a5 --- /dev/null +++ b/src/server/game.ts @@ -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(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(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(); + private turnGains = new Map(); + 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): 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(); + + 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; + } + } +} diff --git a/src/server/index.ts b/src/server/index.ts new file mode 100644 index 0000000..31b20cf --- /dev/null +++ b/src/server/index.ts @@ -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 = { + '.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`); +}); diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts new file mode 100644 index 0000000..61336c1 --- /dev/null +++ b/src/shared/protocol.ts @@ -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 } + | { 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, ' '); +} diff --git a/src/shared/words.ts b/src/shared/words.ts new file mode 100644 index 0000000..fea0607 --- /dev/null +++ b/src/shared/words.ts @@ -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' +]; diff --git a/test/sim.js b/test/sim.js new file mode 100644 index 0000000..6e7b937 --- /dev/null +++ b/test/sim.js @@ -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); }); diff --git a/test/sim2.js b/test/sim2.js new file mode 100644 index 0000000..dad90f9 --- /dev/null +++ b/test/sim2.js @@ -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); }); diff --git a/tsconfig.server.json b/tsconfig.server.json new file mode 100644 index 0000000..78540a0 --- /dev/null +++ b/tsconfig.server.json @@ -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"] +}