Spaces:
Sleeping
Sleeping
github-actions[bot]
commited on
Commit
·
a07d36d
0
Parent(s):
Sync to HuggingFace Spaces
Browse files- .github/workflows/on-push-to-main.yml +17 -0
- .gitignore +5 -0
- .npmrc +1 -0
- .prettierrc +3 -0
- Dockerfile +14 -0
- README.md +59 -0
- hf-space-config.yml +7 -0
- license.txt +21 -0
- package-lock.json +0 -0
- package-scripts/downloadGameServer.ts +22 -0
- package-scripts/zip.ts +58 -0
- package.json +52 -0
- public/index.html +195 -0
- public/table.webp +0 -0
- rollup.config.ts +52 -0
- screenshot.png +0 -0
- src/client.ts +433 -0
- src/server.ts +513 -0
- src/shared.ts +53 -0
- src/types.d.ts +26 -0
- tsconfig.json +11 -0
.github/workflows/on-push-to-main.yml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: On Push To Main
|
| 2 |
+
on:
|
| 3 |
+
push:
|
| 4 |
+
branches: ["main"]
|
| 5 |
+
jobs:
|
| 6 |
+
sync-to-hf:
|
| 7 |
+
name: Sync to HuggingFace Spaces
|
| 8 |
+
runs-on: ubuntu-latest
|
| 9 |
+
steps:
|
| 10 |
+
- uses: actions/checkout@v4
|
| 11 |
+
- uses: JacobLinCool/huggingface-sync@v1
|
| 12 |
+
with:
|
| 13 |
+
github: ${{ secrets.GITHUB_TOKEN }}
|
| 14 |
+
user: ${{ vars.HF_SPACE_OWNER }}
|
| 15 |
+
space: ${{ vars.HF_SPACE_NAME }}
|
| 16 |
+
token: ${{ secrets.HF_TOKEN }}
|
| 17 |
+
configuration: "hf-space-config.yml"
|
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/node_modules
|
| 2 |
+
/js13kserver
|
| 3 |
+
/*.zip
|
| 4 |
+
/.vscode
|
| 5 |
+
.DS_Store
|
.npmrc
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
legacy-peer-deps = true
|
.prettierrc
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"printWidth": 120
|
| 3 |
+
}
|
Dockerfile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-slim
|
| 2 |
+
ENV PORT ${PORT:-7860}
|
| 3 |
+
EXPOSE ${PORT}
|
| 4 |
+
ARG USERNAME=node
|
| 5 |
+
USER ${USERNAME}
|
| 6 |
+
WORKDIR /home/${USERNAME}/app
|
| 7 |
+
COPY --chown=${USERNAME}:${USERNAME} ./package.json ./package.json
|
| 8 |
+
COPY --chown=${USERNAME}:${USERNAME} ./package-lock.json ./package-lock.json
|
| 9 |
+
COPY --chown=${USERNAME}:${USERNAME} ./.npmrc ./.npmrc
|
| 10 |
+
RUN npm ci
|
| 11 |
+
COPY --chown=${USERNAME}:${USERNAME} . .
|
| 12 |
+
RUN npm run build
|
| 13 |
+
WORKDIR /home/${USERNAME}/app/js13kserver
|
| 14 |
+
CMD [ "index.js" ]
|
README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: YoYo Haku Pool
|
| 3 |
+
emoji: 🪀🎱
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
short_description: You in control of a yoyo on a multiplayer pool table!
|
| 7 |
+
pinned: false
|
| 8 |
+
sdk: docker
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# YoYo Haku Pool - A game for JS13K 2022
|
| 12 |
+
|
| 13 |
+
YoYo Haku Pool puts you in control of a yoyo on a multiplayer pool table!
|
| 14 |
+
|
| 15 |
+

|
| 16 |
+
|
| 17 |
+
The goal is to keep the highest score as long as possible.
|
| 18 |
+
|
| 19 |
+
Click or touch the table to pull your yoyo.
|
| 20 |
+
|
| 21 |
+
Each ball has a value, and you should use yoyo maneuvers to bring them into the corner pockets.
|
| 22 |
+
|
| 23 |
+
If you push another yoyo into a corner pocket, you get part of their score, implying that you also lose part of your score if you end up in a corner pocket.
|
| 24 |
+
|
| 25 |
+
When the table is clean, balls are brought back to the table. Tip: Focus on pocketing the balls with high value first.
|
| 26 |
+
|
| 27 |
+
There are several tables in the room, and you can communicate with players from other tables through the chat area.
|
| 28 |
+
|
| 29 |
+
You can also run the following commands there:
|
| 30 |
+
|
| 31 |
+
> Command: /nick <nickname>
|
| 32 |
+
> Effect: Changes your nickname.
|
| 33 |
+
|
| 34 |
+
> Command: /newtable
|
| 35 |
+
> Effect: Starts a new game on an empty table.
|
| 36 |
+
|
| 37 |
+
> Command: /jointable <number>
|
| 38 |
+
> Effect: Joins the game from a specific table.
|
| 39 |
+
|
| 40 |
+
> Command: /soundon
|
| 41 |
+
> Effect: Enables sounds.
|
| 42 |
+
|
| 43 |
+
> Command: /soundoff
|
| 44 |
+
> Effect: Disables sounds.
|
| 45 |
+
|
| 46 |
+
_The game follows the rules of [JS13K Server Category](https://github.com/js13kGames/js13kserver), which requires us to [host the server on Heroku](https://github.com/js13kGames/js13kserver#deploy-to-heroku)._
|
| 47 |
+
|
| 48 |
+
## Credits
|
| 49 |
+
|
| 50 |
+
- Pool Table from the [8 Ball Pool SMS Asset Pack by chasersgaming](https://chasersgaming.itch.io/asset-pack-8-ball-pool-sms)
|
| 51 |
+
- Several NPM Packages, which are listed on [package.json](./package.json)
|
| 52 |
+
|
| 53 |
+
## Tools used during development
|
| 54 |
+
|
| 55 |
+
- [Gitpod - Ready-to-code developer environments in the cloud](https://gitpod.io)
|
| 56 |
+
- [Piskel - Free online editor for animated sprites & pixel art](https://www.piskelapp.com)
|
| 57 |
+
- [Squoosh - Reduce file size from a image while maintain high quality](https://squoosh.app)
|
| 58 |
+
- [vConsole - Front-end developer tool for mobile web pages](https://github.com/Tencent/vConsole)
|
| 59 |
+
- [CSS Grid Layout generator](https://vue-grid-generator.netlify.app)
|
hf-space-config.yml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
title: YoYo Haku Pool
|
| 2 |
+
emoji: 🪀🎱
|
| 3 |
+
colorFrom: green
|
| 4 |
+
colorTo: indigo
|
| 5 |
+
short_description: You in control of a yoyo on a multiplayer pool table!
|
| 6 |
+
pinned: false
|
| 7 |
+
sdk: docker
|
license.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2022 Victor Nogueira
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package-scripts/downloadGameServer.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { existsSync } from "node:fs";
|
| 2 |
+
import { resolve } from "node:path";
|
| 3 |
+
import { execSync } from "node:child_process";
|
| 4 |
+
import download from "download";
|
| 5 |
+
|
| 6 |
+
const serverFolder = resolve(__dirname, "..", "js13kserver");
|
| 7 |
+
|
| 8 |
+
if (existsSync(serverFolder)) process.exit();
|
| 9 |
+
|
| 10 |
+
(async () => {
|
| 11 |
+
await download(
|
| 12 |
+
"https://github.com/js13kGames/js13kserver/archive/63a3f1631aaad819d50b5f1b0478f26be3d4700a.zip",
|
| 13 |
+
serverFolder,
|
| 14 |
+
{
|
| 15 |
+
extract: true,
|
| 16 |
+
strip: 1,
|
| 17 |
+
}
|
| 18 |
+
);
|
| 19 |
+
console.log("Finished downloading the game server.");
|
| 20 |
+
execSync("npm ci", { cwd: serverFolder });
|
| 21 |
+
console.log("Finished installing game server dependencies.");
|
| 22 |
+
})();
|
package-scripts/zip.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { resolve } from "node:path";
|
| 2 |
+
import { createWriteStream, statSync } from "node:fs";
|
| 3 |
+
import { EOL } from "node:os";
|
| 4 |
+
import archiver from "archiver";
|
| 5 |
+
import tasuku from "tasuku";
|
| 6 |
+
import { greenBright, redBright } from "colorette";
|
| 7 |
+
// @ts-ignore
|
| 8 |
+
import crossExecFile from "cross-exec-file";
|
| 9 |
+
// @ts-ignore
|
| 10 |
+
import efficientCompressionTool from "ect-bin";
|
| 11 |
+
// @ts-ignore
|
| 12 |
+
import zipstats from "zipstats";
|
| 13 |
+
|
| 14 |
+
const publicFolderPath = resolve(__dirname, "..", "js13kserver", "public");
|
| 15 |
+
const zipFilePath = resolve(__dirname, "..", "game.zip");
|
| 16 |
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
| 17 |
+
|
| 18 |
+
tasuku.group((task) => [
|
| 19 |
+
task("Creating zip file", async () => {
|
| 20 |
+
return new Promise((resolve, reject) => {
|
| 21 |
+
const output = createWriteStream(zipFilePath);
|
| 22 |
+
output.on("close", resolve);
|
| 23 |
+
output.on("error", reject);
|
| 24 |
+
archive.pipe(output);
|
| 25 |
+
archive.directory(publicFolderPath, "");
|
| 26 |
+
archive.finalize();
|
| 27 |
+
});
|
| 28 |
+
}),
|
| 29 |
+
task("Optimizing zip file", async ({ setOutput, setError }) => {
|
| 30 |
+
const result: { stdout: string; stderr: string } = await crossExecFile(efficientCompressionTool, [
|
| 31 |
+
"-9",
|
| 32 |
+
"-zip",
|
| 33 |
+
zipFilePath,
|
| 34 |
+
]);
|
| 35 |
+
|
| 36 |
+
if (result.stderr.length) {
|
| 37 |
+
setError(result.stderr);
|
| 38 |
+
} else {
|
| 39 |
+
setOutput(result.stdout);
|
| 40 |
+
}
|
| 41 |
+
}),
|
| 42 |
+
task("Checking zip file", async ({ setOutput }) => {
|
| 43 |
+
setOutput(zipstats(zipFilePath));
|
| 44 |
+
}),
|
| 45 |
+
task("Checking size limit", async ({ setOutput, setError }) => {
|
| 46 |
+
const maxSizeAllowed = 13 * 1024;
|
| 47 |
+
const fileSize = statSync(zipFilePath).size;
|
| 48 |
+
const fileSizeDifference = Math.abs(maxSizeAllowed - fileSize);
|
| 49 |
+
const isUnderSizeLimit = fileSize <= maxSizeAllowed;
|
| 50 |
+
const message = `${fileSizeDifference} bytes ${isUnderSizeLimit ? "under" : "over"} the 13KB limit!${EOL}`;
|
| 51 |
+
|
| 52 |
+
if (isUnderSizeLimit) {
|
| 53 |
+
setOutput(greenBright(message));
|
| 54 |
+
} else {
|
| 55 |
+
setError(redBright(message));
|
| 56 |
+
}
|
| 57 |
+
}),
|
| 58 |
+
]);
|
package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "js13k-2022",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"license": "MIT",
|
| 5 |
+
"private": true,
|
| 6 |
+
"scripts": {
|
| 7 |
+
"start": "npm run build && cd js13kserver && node index.js",
|
| 8 |
+
"dev": "npm run build && npm-run-all --parallel --race rollup-watch nodemon",
|
| 9 |
+
"build": "npm-run-all --sequential download-game-server clear-public-folder rollup-build minify-html roadroller zip",
|
| 10 |
+
"nodemon": "cd js13kserver && npx nodemon",
|
| 11 |
+
"rollup-build": "rollup -c",
|
| 12 |
+
"rollup-watch": "rollup -c -w",
|
| 13 |
+
"download-game-server": "ts-node package-scripts/downloadGameServer",
|
| 14 |
+
"zip": "ts-node package-scripts/zip.ts",
|
| 15 |
+
"clear-public-folder": "del js13kserver/public",
|
| 16 |
+
"minify-html": "html-minifier --collapse-whitespace --remove-comments --remove-optional-tags --remove-redundant-attributes --remove-script-type-attributes --use-short-doctype --minify-css true --minify-js true public/index.html > js13kserver/public/index.html",
|
| 17 |
+
"roadroller": "cd js13kserver/public/ && roadroller client.js -o client.js && roadroller server.js -o server.js"
|
| 18 |
+
},
|
| 19 |
+
"devDependencies": {
|
| 20 |
+
"@rollup/plugin-commonjs": "^22.0.2",
|
| 21 |
+
"@rollup/plugin-node-resolve": "^13.3.0",
|
| 22 |
+
"@rollup/plugin-typescript": "^8.3.4",
|
| 23 |
+
"@types/archiver": "^5.3.1",
|
| 24 |
+
"@types/download": "^8.0.1",
|
| 25 |
+
"@types/mainloop.js": "^1.0.5",
|
| 26 |
+
"archiver": "^5.3.1",
|
| 27 |
+
"colorette": "^2.0.19",
|
| 28 |
+
"create-pubsub": "^1.4.0",
|
| 29 |
+
"cross-exec-file": "^2.0.0",
|
| 30 |
+
"del-cli": "^5.0.0",
|
| 31 |
+
"download": "^8.0.0",
|
| 32 |
+
"ect-bin": "^1.4.1",
|
| 33 |
+
"html-minifier": "^4.0.0",
|
| 34 |
+
"kontra": "^8.0.0",
|
| 35 |
+
"mainloop.js": "^1.0.4",
|
| 36 |
+
"npm-run-all": "^4.1.5",
|
| 37 |
+
"pocket-physics": "^10.1.1",
|
| 38 |
+
"roadroller": "^2.1.0",
|
| 39 |
+
"rollup": "^2.78.0",
|
| 40 |
+
"rollup-plugin-copy": "^3.4.0",
|
| 41 |
+
"rollup-plugin-terser": "^7.0.2",
|
| 42 |
+
"rollup-plugin-watch": "^1.0.1",
|
| 43 |
+
"socket.io": "^4.5.1",
|
| 44 |
+
"socket.io-client": "^4.5.1",
|
| 45 |
+
"tasuku": "^2.0.0",
|
| 46 |
+
"ts-node": "^10.9.1",
|
| 47 |
+
"tslib": "^2.4.0",
|
| 48 |
+
"typescript": "^4.7.4",
|
| 49 |
+
"zipstats": "^1.1.0",
|
| 50 |
+
"zzfx": "^1.1.8"
|
| 51 |
+
}
|
| 52 |
+
}
|
public/index.html
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link
|
| 6 |
+
rel="icon"
|
| 7 |
+
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎱</text></svg>"
|
| 8 |
+
/>
|
| 9 |
+
<meta name="viewport" content="width=device-width, user-scalable=no" />
|
| 10 |
+
<meta name="monetization" content="$ilp.uphold.com/JKriL7R2DZfa" />
|
| 11 |
+
<style>
|
| 12 |
+
:root {
|
| 13 |
+
--inner-height: 100vh;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
* {
|
| 17 |
+
margin: 0;
|
| 18 |
+
padding: 0;
|
| 19 |
+
outline: none;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
html,
|
| 23 |
+
body {
|
| 24 |
+
width: 100vw;
|
| 25 |
+
height: var(--inner-height);
|
| 26 |
+
overflow: hidden;
|
| 27 |
+
background-color: #370000;
|
| 28 |
+
touch-action: none;
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
img {
|
| 32 |
+
display: none;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
button {
|
| 36 |
+
cursor: pointer;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
#a {
|
| 40 |
+
display: grid;
|
| 41 |
+
width: 100%;
|
| 42 |
+
height: 100%;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
@media (orientation: landscape) {
|
| 46 |
+
#a {
|
| 47 |
+
grid-template-areas: "a1 a2";
|
| 48 |
+
grid-template-columns: 300px 1fr;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
#canvas {
|
| 52 |
+
max-width: calc(100vw - 300px);
|
| 53 |
+
max-height: var(--inner-height);
|
| 54 |
+
}
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
@media (orientation: portrait) {
|
| 58 |
+
#a {
|
| 59 |
+
grid-template-areas:
|
| 60 |
+
"a1"
|
| 61 |
+
"a2";
|
| 62 |
+
grid-template-rows: 200px 1fr;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
#canvas {
|
| 66 |
+
max-width: 100vw;
|
| 67 |
+
max-height: calc(var(--inner-height) - 200px);
|
| 68 |
+
}
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
#a1 {
|
| 72 |
+
grid-area: a1;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
#a2 {
|
| 76 |
+
grid-area: a2;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
#b {
|
| 80 |
+
display: grid;
|
| 81 |
+
width: 100%;
|
| 82 |
+
height: 100%;
|
| 83 |
+
max-height: var(--inner-height);
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
@media (orientation: landscape) {
|
| 87 |
+
#b {
|
| 88 |
+
grid-template-areas:
|
| 89 |
+
"b1 b1"
|
| 90 |
+
"b2 b2"
|
| 91 |
+
"b3 b4";
|
| 92 |
+
grid-template-columns: 1fr 56px;
|
| 93 |
+
grid-template-rows: 1fr 2fr 36px;
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
@media (orientation: portrait) {
|
| 98 |
+
#b {
|
| 99 |
+
grid-template-areas:
|
| 100 |
+
"b2 b2 b1"
|
| 101 |
+
"b3 b4 b1";
|
| 102 |
+
grid-template-columns: 1fr 56px 1fr;
|
| 103 |
+
grid-template-rows: 1fr 36px;
|
| 104 |
+
}
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
#b1 {
|
| 108 |
+
grid-area: b1;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
#b2 {
|
| 112 |
+
grid-area: b2;
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
#b1,
|
| 116 |
+
#b2 {
|
| 117 |
+
background-color: #550000;
|
| 118 |
+
padding: 5px;
|
| 119 |
+
border: 3px solid #aa0000;
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
#b3 {
|
| 123 |
+
grid-area: b3;
|
| 124 |
+
background-color: #550000;
|
| 125 |
+
padding: 0 5px;
|
| 126 |
+
border: 3px solid #aa0000;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
#b4 {
|
| 130 |
+
grid-area: b4;
|
| 131 |
+
border: 3px solid #aa0000;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
#b textarea,
|
| 135 |
+
#b input,
|
| 136 |
+
#b button {
|
| 137 |
+
width: 100%;
|
| 138 |
+
height: 100%;
|
| 139 |
+
border: none;
|
| 140 |
+
color: white;
|
| 141 |
+
font-family: "Courier New", Courier, monospace;
|
| 142 |
+
font-weight: bold;
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
#b input {
|
| 146 |
+
background-color: #550000;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
#b textarea {
|
| 150 |
+
background-color: #550000;
|
| 151 |
+
resize: none;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
#b textarea:disabled {
|
| 155 |
+
opacity: 1;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
#b button {
|
| 159 |
+
background-color: #55aa00;
|
| 160 |
+
text-align: center;
|
| 161 |
+
display: inline-block;
|
| 162 |
+
}
|
| 163 |
+
|
| 164 |
+
#b1 textarea {
|
| 165 |
+
white-space: pre;
|
| 166 |
+
}
|
| 167 |
+
</style>
|
| 168 |
+
</head>
|
| 169 |
+
<body>
|
| 170 |
+
<div id="a">
|
| 171 |
+
<div id="a1">
|
| 172 |
+
<div id="b">
|
| 173 |
+
<div id="b1">
|
| 174 |
+
<textarea disabled="disabled" wrap="off">CONNECTING...</textarea>
|
| 175 |
+
</div>
|
| 176 |
+
<div id="b2">
|
| 177 |
+
<textarea disabled="disabled"></textarea>
|
| 178 |
+
</div>
|
| 179 |
+
<div id="b3">
|
| 180 |
+
<input placeholder="Message" type="text" autocomplete="off" autocapitalize="off" />
|
| 181 |
+
</div>
|
| 182 |
+
<div id="b4">
|
| 183 |
+
<button>Send</button>
|
| 184 |
+
</div>
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
<div id="a2">
|
| 188 |
+
<canvas id="canvas"></canvas>
|
| 189 |
+
</div>
|
| 190 |
+
</div>
|
| 191 |
+
<img src="table.webp" />
|
| 192 |
+
<script src="socket.io/socket.io.js"></script>
|
| 193 |
+
<script src="client.js"></script>
|
| 194 |
+
</body>
|
| 195 |
+
</html>
|
public/table.webp
ADDED
|
rollup.config.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from "rollup";
|
| 2 |
+
import { terser } from "rollup-plugin-terser";
|
| 3 |
+
import { nodeResolve } from "@rollup/plugin-node-resolve";
|
| 4 |
+
import typescript from "@rollup/plugin-typescript";
|
| 5 |
+
import commonjs from "@rollup/plugin-commonjs";
|
| 6 |
+
import copy from "rollup-plugin-copy";
|
| 7 |
+
import watch from "rollup-plugin-watch";
|
| 8 |
+
|
| 9 |
+
export default defineConfig([
|
| 10 |
+
{
|
| 11 |
+
input: "src/server.ts",
|
| 12 |
+
output: [
|
| 13 |
+
{
|
| 14 |
+
file: "js13kserver/public/server.js",
|
| 15 |
+
format: "commonjs",
|
| 16 |
+
exports: "default",
|
| 17 |
+
},
|
| 18 |
+
],
|
| 19 |
+
plugins: [
|
| 20 |
+
typescript(),
|
| 21 |
+
nodeResolve(),
|
| 22 |
+
commonjs(),
|
| 23 |
+
terser({
|
| 24 |
+
format: {
|
| 25 |
+
comments: false,
|
| 26 |
+
},
|
| 27 |
+
}),
|
| 28 |
+
],
|
| 29 |
+
},
|
| 30 |
+
{
|
| 31 |
+
input: "src/client.ts",
|
| 32 |
+
output: [
|
| 33 |
+
{
|
| 34 |
+
file: "js13kserver/public/client.js",
|
| 35 |
+
format: "iife",
|
| 36 |
+
name: "client",
|
| 37 |
+
},
|
| 38 |
+
],
|
| 39 |
+
plugins: [
|
| 40 |
+
typescript(),
|
| 41 |
+
nodeResolve(),
|
| 42 |
+
commonjs(),
|
| 43 |
+
terser({
|
| 44 |
+
format: {
|
| 45 |
+
comments: false,
|
| 46 |
+
},
|
| 47 |
+
}),
|
| 48 |
+
watch({ dir: "public" }),
|
| 49 |
+
copy({ targets: [{ src: "public/**/*", dest: "js13kserver/public" }] }),
|
| 50 |
+
],
|
| 51 |
+
},
|
| 52 |
+
]);
|
screenshot.png
ADDED
|
src/client.ts
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Socket } from "socket.io-client";
|
| 2 |
+
import { createPubSub } from "create-pubsub";
|
| 3 |
+
import {
|
| 4 |
+
init,
|
| 5 |
+
GameLoop,
|
| 6 |
+
Vector,
|
| 7 |
+
Text,
|
| 8 |
+
Sprite,
|
| 9 |
+
initPointer,
|
| 10 |
+
onPointer,
|
| 11 |
+
getPointer,
|
| 12 |
+
degToRad,
|
| 13 |
+
radToDeg,
|
| 14 |
+
Pool,
|
| 15 |
+
} from "kontra";
|
| 16 |
+
import {
|
| 17 |
+
BallsPositions,
|
| 18 |
+
ballsPositionsUpdatesPerSecond,
|
| 19 |
+
Ball,
|
| 20 |
+
squareCanvasSizeInPixels,
|
| 21 |
+
ServerToClientEvents,
|
| 22 |
+
ClientToServerEvents,
|
| 23 |
+
ballRadius,
|
| 24 |
+
ClientToServerEventName,
|
| 25 |
+
ServerToClientEventName,
|
| 26 |
+
Scoreboard,
|
| 27 |
+
} from "./shared";
|
| 28 |
+
import { zzfx } from "zzfx";
|
| 29 |
+
|
| 30 |
+
const gameName = "YoYo Haku Pool";
|
| 31 |
+
|
| 32 |
+
const gameFramesPerSecond = 60;
|
| 33 |
+
|
| 34 |
+
const gameStateUpdateFramesInterval = gameFramesPerSecond / ballsPositionsUpdatesPerSecond;
|
| 35 |
+
|
| 36 |
+
const ballIdToBallSpriteMap = new Map<number, Sprite>();
|
| 37 |
+
|
| 38 |
+
const { canvas, context } = init(document.querySelector("#canvas") as HTMLCanvasElement);
|
| 39 |
+
|
| 40 |
+
const scoreboardTextArea = document.querySelector("#b1 textarea") as HTMLTextAreaElement;
|
| 41 |
+
|
| 42 |
+
const chatHistory = document.querySelector("#b2 textarea") as HTMLTextAreaElement;
|
| 43 |
+
|
| 44 |
+
const chatInputField = document.querySelector("#b3 input") as HTMLInputElement;
|
| 45 |
+
|
| 46 |
+
const chatButton = document.querySelector("#b4 button") as HTMLButtonElement;
|
| 47 |
+
|
| 48 |
+
const tableImage = document.querySelector("img[src='table.webp']") as HTMLImageElement;
|
| 49 |
+
|
| 50 |
+
const socket = io({ upgrade: false, transports: ["websocket"] }) as Socket<ServerToClientEvents, ClientToServerEvents>;
|
| 51 |
+
|
| 52 |
+
const [publishMainLoopUpdate, subscribeToMainLoopUpdate] = createPubSub<number>();
|
| 53 |
+
|
| 54 |
+
const [publishMainLoopDraw, subscribeToMainLoopDraw] = createPubSub<number>();
|
| 55 |
+
|
| 56 |
+
const [publishPageWithImagesLoaded, subscribeToPageWithImagesLoaded] = createPubSub<Event>();
|
| 57 |
+
|
| 58 |
+
const [publishGamePreparationComplete, subscribeToGamePreparationCompleted] = createPubSub();
|
| 59 |
+
|
| 60 |
+
const [publishPointerPressed, subscribeToPointerPressed, isPointerPressed] = createPubSub(false);
|
| 61 |
+
|
| 62 |
+
const [setOwnSprite, , getOwnSprite] = createPubSub<Sprite | null>(null);
|
| 63 |
+
|
| 64 |
+
const [setLastTimeEmittedPointerPressed, , getLastTimeEmittedPointerPressed] = createPubSub(Date.now());
|
| 65 |
+
|
| 66 |
+
const [publishSoundEnabled, , isSoundEnabled] = createPubSub(false);
|
| 67 |
+
|
| 68 |
+
const messageReceivedSound = [2.01, , 773, 0.02, 0.01, 0.01, 1, 1.14, 44, -27, , , , , 0.9, , 0.18, 0.81, 0.01];
|
| 69 |
+
|
| 70 |
+
const scoreIncreasedSound = [
|
| 71 |
+
1.35,
|
| 72 |
+
,
|
| 73 |
+
151,
|
| 74 |
+
0.1,
|
| 75 |
+
0.17,
|
| 76 |
+
0.26,
|
| 77 |
+
1,
|
| 78 |
+
0.34,
|
| 79 |
+
-4.1,
|
| 80 |
+
-5,
|
| 81 |
+
-225,
|
| 82 |
+
0.02,
|
| 83 |
+
0.14,
|
| 84 |
+
0.1,
|
| 85 |
+
,
|
| 86 |
+
0.1,
|
| 87 |
+
0.13,
|
| 88 |
+
0.9,
|
| 89 |
+
0.22,
|
| 90 |
+
0.17,
|
| 91 |
+
];
|
| 92 |
+
|
| 93 |
+
const acceleratingSound = [, , 999, 0.2, 0.04, 0.15, 4, 2.66, -0.5, 22, , , , 0.1, , , , , 0.02];
|
| 94 |
+
|
| 95 |
+
const scoreDecreasedSound = [, , 727, 0.02, 0.03, 0, 3, 0.09, 4.4, -62, , , , , , , 0.19, 0.65, 0.2, 0.51];
|
| 96 |
+
|
| 97 |
+
const tableSprite = Sprite({
|
| 98 |
+
image: tableImage,
|
| 99 |
+
});
|
| 100 |
+
|
| 101 |
+
const scoreTextPool = Pool({
|
| 102 |
+
create: Text as any,
|
| 103 |
+
});
|
| 104 |
+
|
| 105 |
+
function setCanvasWidthAndHeight() {
|
| 106 |
+
canvas.width = canvas.height = squareCanvasSizeInPixels;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
function prepareGame() {
|
| 110 |
+
updateDocumentTitleWithGameName();
|
| 111 |
+
printWelcomeMessage();
|
| 112 |
+
setCanvasWidthAndHeight();
|
| 113 |
+
handleWindowResized();
|
| 114 |
+
initPointer({ radius: 0 });
|
| 115 |
+
publishGamePreparationComplete();
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
function emitPointerPressedIfNeeded() {
|
| 119 |
+
if (!isPointerPressed() || Date.now() - getLastTimeEmittedPointerPressed() < 1000 / ballsPositionsUpdatesPerSecond)
|
| 120 |
+
return;
|
| 121 |
+
const { x, y } = getPointer();
|
| 122 |
+
socket.emit(ClientToServerEventName.Click, Math.trunc(x), Math.trunc(y));
|
| 123 |
+
setLastTimeEmittedPointerPressed(Date.now());
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
function updateScene() {
|
| 127 |
+
emitPointerPressedIfNeeded();
|
| 128 |
+
|
| 129 |
+
ballIdToBallSpriteMap.forEach((sprite) => {
|
| 130 |
+
const newRotationDegree = radToDeg(sprite.rotation) + (Math.abs(sprite.dx) + Math.abs(sprite.dy)) * 7;
|
| 131 |
+
sprite.rotation = degToRad(newRotationDegree > 360 ? newRotationDegree - 360 : newRotationDegree);
|
| 132 |
+
sprite.update();
|
| 133 |
+
});
|
| 134 |
+
|
| 135 |
+
scoreTextPool.update();
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
function drawLine(fromPoint: { x: number; y: number }, toPoint: { x: number; y: number }) {
|
| 139 |
+
context.beginPath();
|
| 140 |
+
context.strokeStyle = "#fff";
|
| 141 |
+
context.moveTo(fromPoint.x, fromPoint.y);
|
| 142 |
+
context.lineTo(toPoint.x, toPoint.y);
|
| 143 |
+
context.stroke();
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
function renderOtherSprites() {
|
| 147 |
+
ballIdToBallSpriteMap.forEach((sprite) => {
|
| 148 |
+
if (sprite !== getOwnSprite()) sprite.render();
|
| 149 |
+
});
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
function renderOwnSpritePossiblyWithWire() {
|
| 153 |
+
const ownSprite = getOwnSprite();
|
| 154 |
+
|
| 155 |
+
if (!ownSprite) return;
|
| 156 |
+
|
| 157 |
+
if (isPointerPressed()) drawLine(ownSprite.position, getPointer());
|
| 158 |
+
|
| 159 |
+
ownSprite.render();
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
function renderScene() {
|
| 163 |
+
tableSprite.render();
|
| 164 |
+
renderOtherSprites();
|
| 165 |
+
renderOwnSpritePossiblyWithWire();
|
| 166 |
+
scoreTextPool.render();
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
function startMainLoop() {
|
| 170 |
+
return GameLoop({ update: publishMainLoopUpdate, render: publishMainLoopDraw }).start();
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
function fitCanvasInsideItsParent(canvasElement: HTMLCanvasElement) {
|
| 174 |
+
if (!canvasElement.parentElement) return;
|
| 175 |
+
const { width, height, style, parentElement } = canvasElement;
|
| 176 |
+
const { clientWidth, clientHeight } = parentElement;
|
| 177 |
+
const widthScale = clientWidth / width;
|
| 178 |
+
const heightScale = clientHeight / height;
|
| 179 |
+
const scale = widthScale < heightScale ? widthScale : heightScale;
|
| 180 |
+
style.marginTop = `${(clientHeight - height * scale) / 2}px`;
|
| 181 |
+
style.marginLeft = `${(clientWidth - width * scale) / 2}px`;
|
| 182 |
+
style.width = `${width * scale}px`;
|
| 183 |
+
style.height = `${height * scale}px`;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
function handleBallsPositionsReceived(balls: Ball[]) {
|
| 187 |
+
ballIdToBallSpriteMap.clear();
|
| 188 |
+
|
| 189 |
+
balls.forEach((ball) => createSpriteForBall(ball));
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
function createSpriteForBall(ball: Ball) {
|
| 193 |
+
const sprite = Sprite({
|
| 194 |
+
x: squareCanvasSizeInPixels / 2,
|
| 195 |
+
y: squareCanvasSizeInPixels / 2,
|
| 196 |
+
anchor: { x: 0.5, y: 0.5 },
|
| 197 |
+
render: () => {
|
| 198 |
+
sprite.context.fillStyle = ball.color;
|
| 199 |
+
sprite.context.beginPath();
|
| 200 |
+
sprite.context.arc(0, 0, ballRadius, 0, 2 * Math.PI);
|
| 201 |
+
sprite.context.fill();
|
| 202 |
+
},
|
| 203 |
+
});
|
| 204 |
+
|
| 205 |
+
const whiteCircle = Sprite({
|
| 206 |
+
anchor: { x: 0.5, y: 0.5 },
|
| 207 |
+
render: () => {
|
| 208 |
+
sprite.context.fillStyle = "#fff";
|
| 209 |
+
sprite.context.beginPath();
|
| 210 |
+
sprite.context.arc(0, 0, ballRadius / 1.5, 0, 2 * Math.PI);
|
| 211 |
+
sprite.context.fill();
|
| 212 |
+
},
|
| 213 |
+
});
|
| 214 |
+
sprite.addChild(whiteCircle);
|
| 215 |
+
|
| 216 |
+
const ballLabel = Text({
|
| 217 |
+
text: ball.label,
|
| 218 |
+
font: `${ballRadius}px monospace`,
|
| 219 |
+
color: "black",
|
| 220 |
+
anchor: { x: 0.5, y: 0.5 },
|
| 221 |
+
textAlign: "center",
|
| 222 |
+
});
|
| 223 |
+
sprite.addChild(ballLabel);
|
| 224 |
+
|
| 225 |
+
ballIdToBallSpriteMap.set(ball.id, sprite);
|
| 226 |
+
|
| 227 |
+
if (ball.ownerSocketId === socket.id) setOwnSprite(sprite);
|
| 228 |
+
|
| 229 |
+
return sprite;
|
| 230 |
+
}
|
| 231 |
+
|
| 232 |
+
function setSpriteVelocity(expectedPosition: Vector, sprite: Sprite) {
|
| 233 |
+
const difference = expectedPosition.subtract(sprite.position);
|
| 234 |
+
sprite.dx = difference.x / gameStateUpdateFramesInterval;
|
| 235 |
+
sprite.dy = difference.y / gameStateUpdateFramesInterval;
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
function stopSprite(sprite: Sprite) {
|
| 239 |
+
sprite.ddx = sprite.ddy = sprite.dx = sprite.dy = 0;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
function handleChatMessageReceived(message: string) {
|
| 243 |
+
playSound(messageReceivedSound);
|
| 244 |
+
chatHistory.value += `${getHoursFromLocalTime()}:${getMinutesFromLocalTime()} ${message}\n`;
|
| 245 |
+
if (chatHistory !== document.activeElement) chatHistory.scrollTop = chatHistory.scrollHeight;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
function getMinutesFromLocalTime() {
|
| 249 |
+
return new Date().getMinutes().toString().padStart(2, "0");
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
function getHoursFromLocalTime() {
|
| 253 |
+
return new Date().getHours().toString().padStart(2, "0");
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
function sendChatMessage() {
|
| 257 |
+
const messageToSend = chatInputField.value.trim();
|
| 258 |
+
chatInputField.value = "";
|
| 259 |
+
if (!messageToSend.length) {
|
| 260 |
+
return;
|
| 261 |
+
} else if (messageToSend.startsWith("/help")) {
|
| 262 |
+
return printHelpText();
|
| 263 |
+
} else if (messageToSend.startsWith("/soundon")) {
|
| 264 |
+
handleChatMessageReceived("📢 Sounds enabled.");
|
| 265 |
+
return publishSoundEnabled(true);
|
| 266 |
+
} else if (messageToSend.startsWith("/soundoff")) {
|
| 267 |
+
handleChatMessageReceived("📢 Sounds disabled.");
|
| 268 |
+
return publishSoundEnabled(false);
|
| 269 |
+
} else {
|
| 270 |
+
socket.emit(ClientToServerEventName.Message, messageToSend);
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
function handleKeyPressedOnChatInputField(event: KeyboardEvent) {
|
| 275 |
+
if (event.key === "Enter") sendChatMessage();
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
function updateInnerHeightProperty() {
|
| 279 |
+
document.documentElement.style.setProperty("--inner-height", `${window.innerHeight}px`);
|
| 280 |
+
}
|
| 281 |
+
|
| 282 |
+
function handleWindowResized() {
|
| 283 |
+
updateInnerHeightProperty();
|
| 284 |
+
fitCanvasInsideItsParent(canvas);
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
function playSound(sound: (number | undefined)[]) {
|
| 288 |
+
if (isSoundEnabled()) zzfx(...sound);
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
function enableSounds() {
|
| 292 |
+
publishSoundEnabled(true);
|
| 293 |
+
playSound(messageReceivedSound);
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
function handlePointerDown() {
|
| 297 |
+
if (!getOwnSprite()) return;
|
| 298 |
+
playSound(acceleratingSound);
|
| 299 |
+
publishPointerPressed(true);
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
function handlePointerUp() {
|
| 303 |
+
publishPointerPressed(false);
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
function handleObjectDeleted(id: number) {
|
| 307 |
+
const spriteToDelete = ballIdToBallSpriteMap.get(id);
|
| 308 |
+
|
| 309 |
+
if (!spriteToDelete) return;
|
| 310 |
+
|
| 311 |
+
if (spriteToDelete === getOwnSprite()) setOwnSprite(null);
|
| 312 |
+
|
| 313 |
+
ballIdToBallSpriteMap.delete(id);
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
function handlePositionsUpdated(positions: BallsPositions): void {
|
| 317 |
+
positions.forEach(([objectId, x, y]) => {
|
| 318 |
+
const sprite = ballIdToBallSpriteMap.get(objectId);
|
| 319 |
+
if (sprite) {
|
| 320 |
+
const expectedPosition = Vector(x, y);
|
| 321 |
+
expectedPosition.distance(sprite.position) != 0
|
| 322 |
+
? setSpriteVelocity(expectedPosition, sprite)
|
| 323 |
+
: stopSprite(sprite);
|
| 324 |
+
}
|
| 325 |
+
});
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
function handleScoreboardUpdated(overallScoreboard: Scoreboard, tableScoreboard: Scoreboard): void {
|
| 329 |
+
let zeroPaddingLengthForScore = 0;
|
| 330 |
+
|
| 331 |
+
if (overallScoreboard[0]) {
|
| 332 |
+
const [, score] = overallScoreboard[0];
|
| 333 |
+
zeroPaddingLengthForScore = score.toString().length;
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
const maxNickLength = overallScoreboard.reduce((maxLength, [nick]) => Math.max(maxLength, nick.length), 0);
|
| 337 |
+
|
| 338 |
+
scoreboardTextArea.value = "TABLE SCOREBOARD\n\n";
|
| 339 |
+
|
| 340 |
+
function writeScore([nick, score, tableId]: [nick: string, score: number, tableId: number]) {
|
| 341 |
+
const formattedScore = String(score).padStart(zeroPaddingLengthForScore, "0");
|
| 342 |
+
const formattedNick = nick.padEnd(maxNickLength, " ");
|
| 343 |
+
scoreboardTextArea.value += `${formattedScore} | ${formattedNick} | Table ${tableId}\n`;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
tableScoreboard.forEach(writeScore);
|
| 347 |
+
|
| 348 |
+
scoreboardTextArea.value += "\n\nOVERALL SCOREBOARD\n\n";
|
| 349 |
+
|
| 350 |
+
overallScoreboard.forEach(writeScore);
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
function handleScoredEvent(value: number, x: number, y: number) {
|
| 354 |
+
playSound(value < 0 ? scoreDecreasedSound : scoreIncreasedSound);
|
| 355 |
+
|
| 356 |
+
const scoreText = scoreTextPool.get({
|
| 357 |
+
text: `${value > 0 ? "+" : ""}${value}${value > 0 ? "✨" : "💀"}`,
|
| 358 |
+
font: "36px monospace",
|
| 359 |
+
color: value > 0 ? "#F9D82B" : "#3B3B3B",
|
| 360 |
+
x,
|
| 361 |
+
y,
|
| 362 |
+
anchor: { x: 0.5, y: 0.5 },
|
| 363 |
+
textAlign: "center",
|
| 364 |
+
dy: -1,
|
| 365 |
+
dx: 1,
|
| 366 |
+
update: () => {
|
| 367 |
+
scoreText.advance();
|
| 368 |
+
|
| 369 |
+
scoreText.opacity -= 0.01;
|
| 370 |
+
|
| 371 |
+
if (scoreText.opacity < 0) scoreText.ttl = 0;
|
| 372 |
+
|
| 373 |
+
if (scoreText.x > x + 2 || scoreText.x < x - 2) scoreText.dx *= -1;
|
| 374 |
+
},
|
| 375 |
+
}) as Text;
|
| 376 |
+
}
|
| 377 |
+
|
| 378 |
+
function handlePointerPressed(isPointerPressed: boolean) {
|
| 379 |
+
canvas.style.cursor = isPointerPressed ? "grabbing" : "grab";
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
function printWelcomeMessage() {
|
| 383 |
+
return handleChatMessageReceived(
|
| 384 |
+
`👋 Welcome to ${gameName}!\n\nℹ️ New to this game? Enter /help in the message field below to learn about it.\n`
|
| 385 |
+
);
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
function updateDocumentTitleWithGameName() {
|
| 389 |
+
document.title = gameName;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
function printHelpText() {
|
| 393 |
+
handleChatMessageReceived(
|
| 394 |
+
`ℹ️ ${gameName} puts you in control of a yoyo on a multiplayer pool table!\n\n` +
|
| 395 |
+
`The goal is to keep the highest score as long as possible.\n\n` +
|
| 396 |
+
`Click or touch the table to pull your yoyo.\n\n` +
|
| 397 |
+
`Each ball has a value, and you should use yoyo maneuvers to bring them into the corner pockets.\n\n` +
|
| 398 |
+
`If you push another yoyo into a corner pocket, you get part of their score, implying that you also lose part of your score if you end up in a corner pocket.\n\n` +
|
| 399 |
+
`When the table is clean, balls are brought back to the table. Tip: Focus on pocketing the balls with high value first.\n\n` +
|
| 400 |
+
`There are several tables in the room, and you can communicate with players from other tables through this chat.\n\n` +
|
| 401 |
+
`You can also run the following commands here:\n\n` +
|
| 402 |
+
`Command: /nick <nickname>\n` +
|
| 403 |
+
`Effect: Changes your nickname.\n\n` +
|
| 404 |
+
`Command: /newtable\n` +
|
| 405 |
+
`Effect: Starts a new game on an empty table.\n\n` +
|
| 406 |
+
`Command: /jointable <number>\n` +
|
| 407 |
+
`Effect: Joins the game from a specific table.\n\n` +
|
| 408 |
+
`Command: /soundon\n` +
|
| 409 |
+
`Effect: Enables sounds.\n\n` +
|
| 410 |
+
`Command: /soundoff\n` +
|
| 411 |
+
`Effect: Disables sounds.\n`
|
| 412 |
+
);
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
subscribeToMainLoopUpdate(updateScene);
|
| 416 |
+
subscribeToMainLoopDraw(renderScene);
|
| 417 |
+
subscribeToGamePreparationCompleted(startMainLoop);
|
| 418 |
+
subscribeToPageWithImagesLoaded(prepareGame);
|
| 419 |
+
subscribeToPointerPressed(handlePointerPressed);
|
| 420 |
+
onPointer("down", handlePointerDown);
|
| 421 |
+
onPointer("up", handlePointerUp);
|
| 422 |
+
window.addEventListener("load", publishPageWithImagesLoaded);
|
| 423 |
+
window.addEventListener("resize", handleWindowResized);
|
| 424 |
+
window.addEventListener("click", enableSounds, { once: true });
|
| 425 |
+
chatButton.addEventListener("click", sendChatMessage);
|
| 426 |
+
chatInputField.addEventListener("keyup", handleKeyPressedOnChatInputField);
|
| 427 |
+
socket.on(ServerToClientEventName.Message, handleChatMessageReceived);
|
| 428 |
+
socket.on(ServerToClientEventName.Objects, handleBallsPositionsReceived);
|
| 429 |
+
socket.on(ServerToClientEventName.Positions, handlePositionsUpdated);
|
| 430 |
+
socket.on(ServerToClientEventName.Creation, createSpriteForBall);
|
| 431 |
+
socket.on(ServerToClientEventName.Deletion, handleObjectDeleted);
|
| 432 |
+
socket.on(ServerToClientEventName.Scored, handleScoredEvent);
|
| 433 |
+
socket.on(ServerToClientEventName.Scoreboard, handleScoreboardUpdated);
|
src/server.ts
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Socket } from "socket.io";
|
| 2 |
+
import type { DefaultEventsMap } from "socket.io/dist/typed-events";
|
| 3 |
+
import MainLoop from "mainloop.js";
|
| 4 |
+
import {
|
| 5 |
+
accelerate,
|
| 6 |
+
add,
|
| 7 |
+
collideCircleCircle,
|
| 8 |
+
collideCircleEdge,
|
| 9 |
+
inertia,
|
| 10 |
+
normalize,
|
| 11 |
+
overlapCircleCircle,
|
| 12 |
+
rewindToCollisionPoint,
|
| 13 |
+
sub,
|
| 14 |
+
v2,
|
| 15 |
+
Vector2,
|
| 16 |
+
distance,
|
| 17 |
+
scale,
|
| 18 |
+
} from "pocket-physics";
|
| 19 |
+
import {
|
| 20 |
+
Ball,
|
| 21 |
+
ballsPositionsUpdatesPerSecond,
|
| 22 |
+
squareCanvasSizeInPixels,
|
| 23 |
+
ballRadius,
|
| 24 |
+
ClientToServerEvents,
|
| 25 |
+
ServerToClientEvents,
|
| 26 |
+
ServerToClientEventName,
|
| 27 |
+
ClientToServerEventName,
|
| 28 |
+
BallsPositions,
|
| 29 |
+
Scoreboard,
|
| 30 |
+
} from "./shared";
|
| 31 |
+
|
| 32 |
+
type GameSocketData = {
|
| 33 |
+
ball: Ball;
|
| 34 |
+
nickname: string;
|
| 35 |
+
score: number;
|
| 36 |
+
table: Table;
|
| 37 |
+
};
|
| 38 |
+
|
| 39 |
+
type GameSocket = Socket<ClientToServerEvents, ServerToClientEvents, DefaultEventsMap, GameSocketData>;
|
| 40 |
+
|
| 41 |
+
type Table = {
|
| 42 |
+
id: number;
|
| 43 |
+
sockets: Map<string, GameSocket>;
|
| 44 |
+
balls: Map<number, Ball>;
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
let lastScoreboardEmitted = "";
|
| 48 |
+
|
| 49 |
+
let uniqueIdCounter = 1;
|
| 50 |
+
|
| 51 |
+
let timePassedSinceLastStateUpdateEmitted = 0;
|
| 52 |
+
|
| 53 |
+
let timePassedSinceLastScoreboardUpdate = 0;
|
| 54 |
+
|
| 55 |
+
const nonPlayableBallsValuesRange = [1, 8] as [min: number, max: number];
|
| 56 |
+
|
| 57 |
+
const maxSocketsPerTable = 4;
|
| 58 |
+
|
| 59 |
+
const scoreboardUpdateMillisecondsInterval = 1000;
|
| 60 |
+
|
| 61 |
+
const objectsPositionsUpdateMillisecondsInterval = 1000 / ballsPositionsUpdatesPerSecond;
|
| 62 |
+
|
| 63 |
+
const massOfImmovableObjects = -1;
|
| 64 |
+
|
| 65 |
+
const tables = new Map<number, Table>();
|
| 66 |
+
|
| 67 |
+
const ballColors = ["#fff", "#ffff00", "#0000ff", "#ff0000", "#aa00aa", "#ffaa00", "#1f952f", "#550000", "#1a191e"];
|
| 68 |
+
|
| 69 |
+
const collisionDamping = 0.9;
|
| 70 |
+
|
| 71 |
+
const cornerPocketSize = 100;
|
| 72 |
+
|
| 73 |
+
const tablePadding = 64;
|
| 74 |
+
|
| 75 |
+
const maximumNicknameLength = 21;
|
| 76 |
+
|
| 77 |
+
const tableLeftRailPoints = [
|
| 78 |
+
v2(tablePadding, cornerPocketSize),
|
| 79 |
+
v2(tablePadding, squareCanvasSizeInPixels - cornerPocketSize),
|
| 80 |
+
] as [Vector2, Vector2];
|
| 81 |
+
|
| 82 |
+
const tableRightRailPoints = [
|
| 83 |
+
v2(squareCanvasSizeInPixels - tablePadding, cornerPocketSize),
|
| 84 |
+
v2(squareCanvasSizeInPixels - tablePadding, squareCanvasSizeInPixels - cornerPocketSize),
|
| 85 |
+
] as [Vector2, Vector2];
|
| 86 |
+
|
| 87 |
+
const tableTopRailPoints = [
|
| 88 |
+
v2(cornerPocketSize, tablePadding),
|
| 89 |
+
v2(squareCanvasSizeInPixels - cornerPocketSize, tablePadding),
|
| 90 |
+
] as [Vector2, Vector2];
|
| 91 |
+
|
| 92 |
+
const tableBottomRailPoints = [
|
| 93 |
+
v2(cornerPocketSize, squareCanvasSizeInPixels - tablePadding),
|
| 94 |
+
v2(squareCanvasSizeInPixels - cornerPocketSize, squareCanvasSizeInPixels - tablePadding),
|
| 95 |
+
] as [Vector2, Vector2];
|
| 96 |
+
|
| 97 |
+
const tableRails = [tableLeftRailPoints, tableRightRailPoints, tableTopRailPoints, tableBottomRailPoints];
|
| 98 |
+
|
| 99 |
+
const scoreLineDistanceFromCorner = 140;
|
| 100 |
+
|
| 101 |
+
const scoreLines = [
|
| 102 |
+
[v2(0, scoreLineDistanceFromCorner), v2(scoreLineDistanceFromCorner, 0)],
|
| 103 |
+
[
|
| 104 |
+
v2(squareCanvasSizeInPixels - scoreLineDistanceFromCorner, 0),
|
| 105 |
+
v2(squareCanvasSizeInPixels, scoreLineDistanceFromCorner),
|
| 106 |
+
],
|
| 107 |
+
[
|
| 108 |
+
v2(0, squareCanvasSizeInPixels - scoreLineDistanceFromCorner),
|
| 109 |
+
v2(scoreLineDistanceFromCorner, squareCanvasSizeInPixels),
|
| 110 |
+
],
|
| 111 |
+
[
|
| 112 |
+
v2(squareCanvasSizeInPixels, squareCanvasSizeInPixels - scoreLineDistanceFromCorner),
|
| 113 |
+
v2(squareCanvasSizeInPixels - scoreLineDistanceFromCorner, squareCanvasSizeInPixels),
|
| 114 |
+
],
|
| 115 |
+
] as [Vector2, Vector2][];
|
| 116 |
+
|
| 117 |
+
function getUniqueId() {
|
| 118 |
+
const id = uniqueIdCounter;
|
| 119 |
+
id < Number.MAX_SAFE_INTEGER ? uniqueIdCounter++ : 1;
|
| 120 |
+
return id;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
function getRandomElementFrom(object: any[] | string) {
|
| 124 |
+
return object[Math.floor(Math.random() * object.length)];
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
function getRandomTextualSmile() {
|
| 128 |
+
return `${getRandomElementFrom(":=")}${getRandomElementFrom("POD)]")}`;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
function addBallToTable(table: Table, properties?: Partial<Ball>) {
|
| 132 |
+
const ball = {
|
| 133 |
+
id: getUniqueId(),
|
| 134 |
+
cpos: v2(),
|
| 135 |
+
ppos: v2(),
|
| 136 |
+
acel: v2(),
|
| 137 |
+
radius: 1,
|
| 138 |
+
mass: 1,
|
| 139 |
+
value: 0,
|
| 140 |
+
label: getRandomTextualSmile(),
|
| 141 |
+
lastTouchedTimestamp: Date.now(),
|
| 142 |
+
...properties,
|
| 143 |
+
} as Ball;
|
| 144 |
+
|
| 145 |
+
placeBallInRandomPosition(ball);
|
| 146 |
+
|
| 147 |
+
table.balls.set(ball.id, ball);
|
| 148 |
+
|
| 149 |
+
table.sockets.forEach((socket) => socket.emit(ServerToClientEventName.Creation, ball));
|
| 150 |
+
|
| 151 |
+
return ball;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
function getRandomPositionForBallOnTable() {
|
| 155 |
+
return (
|
| 156 |
+
tablePadding + ballRadius + Math.floor(Math.random() * (squareCanvasSizeInPixels - (tablePadding + ballRadius) * 2))
|
| 157 |
+
);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
function placeBallInRandomPosition(ball: Ball) {
|
| 161 |
+
const x = getRandomPositionForBallOnTable();
|
| 162 |
+
const y = getRandomPositionForBallOnTable();
|
| 163 |
+
ball.cpos = v2(x, y);
|
| 164 |
+
ball.ppos = v2(x, y);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
function isColliding(firstObject: Ball, secondObject: Ball) {
|
| 168 |
+
return overlapCircleCircle(
|
| 169 |
+
firstObject.cpos.x,
|
| 170 |
+
firstObject.cpos.y,
|
| 171 |
+
firstObject.radius,
|
| 172 |
+
secondObject.cpos.x,
|
| 173 |
+
secondObject.cpos.y,
|
| 174 |
+
secondObject.radius
|
| 175 |
+
);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
function handleCollision(firstObject: Ball, secondObject: Ball) {
|
| 179 |
+
if (firstObject.ownerSocketId || secondObject.ownerSocketId) {
|
| 180 |
+
if (firstObject.ownerSocketId) secondObject.lastTouchedBySocketId = firstObject.ownerSocketId;
|
| 181 |
+
|
| 182 |
+
if (secondObject.ownerSocketId) firstObject.lastTouchedBySocketId = secondObject.ownerSocketId;
|
| 183 |
+
} else {
|
| 184 |
+
if (firstObject.lastTouchedTimestamp > secondObject.lastTouchedTimestamp) {
|
| 185 |
+
secondObject.lastTouchedBySocketId = firstObject.lastTouchedBySocketId;
|
| 186 |
+
} else {
|
| 187 |
+
firstObject.lastTouchedBySocketId = secondObject.lastTouchedBySocketId;
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
firstObject.lastTouchedTimestamp = secondObject.lastTouchedTimestamp = Date.now();
|
| 192 |
+
|
| 193 |
+
collideCircleCircle(
|
| 194 |
+
firstObject,
|
| 195 |
+
firstObject.radius,
|
| 196 |
+
firstObject.mass,
|
| 197 |
+
secondObject,
|
| 198 |
+
secondObject.radius,
|
| 199 |
+
secondObject.mass,
|
| 200 |
+
true,
|
| 201 |
+
collisionDamping
|
| 202 |
+
);
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
function createBallForSocket(socket: GameSocket) {
|
| 206 |
+
if (!socket.data.table) return;
|
| 207 |
+
|
| 208 |
+
socket.data.ball = addBallToTable(socket.data.table, {
|
| 209 |
+
radius: ballRadius,
|
| 210 |
+
ownerSocketId: socket.id,
|
| 211 |
+
color: getRandomHexColor(),
|
| 212 |
+
value: 9,
|
| 213 |
+
});
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
function deleteBallFromSocket(socket: GameSocket) {
|
| 217 |
+
if (!socket.data.table || !socket.data.ball) return;
|
| 218 |
+
|
| 219 |
+
deleteBallFromTable(socket.data.ball, socket.data.table);
|
| 220 |
+
|
| 221 |
+
socket.data.ball = undefined;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
function getNumberOfNonPlayableBallsOnTable(table: Table) {
|
| 225 |
+
return Array.from(table.balls.values()).filter((ball) => !ball.ownerSocketId).length;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
function handleSocketConnected(socket: GameSocket) {
|
| 229 |
+
socket.data.nickname = `Player ${getUniqueId()}`;
|
| 230 |
+
socket.data.score = 0;
|
| 231 |
+
|
| 232 |
+
const table =
|
| 233 |
+
Array.from(tables.values()).find((currentTable) => currentTable.sockets.size < maxSocketsPerTable) ?? createTable();
|
| 234 |
+
|
| 235 |
+
addSocketToTable(socket, table);
|
| 236 |
+
|
| 237 |
+
setupSocketListeners(socket);
|
| 238 |
+
}
|
| 239 |
+
|
| 240 |
+
function handleSocketDisconnected(socket: GameSocket) {
|
| 241 |
+
if (!socket.data.table) return;
|
| 242 |
+
removeSocketFromTable(socket, socket.data.table);
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
function broadcastChatMessageToTable(message: string, table: Table) {
|
| 246 |
+
return table.sockets.forEach((socket) => socket.emit(ServerToClientEventName.Message, message));
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
function broadcastChatMessageToAllTables(message: string) {
|
| 250 |
+
return tables.forEach((table) => broadcastChatMessageToTable(message, table));
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
function accelerateBallFromSocket(x: number, y: number, socket: GameSocket) {
|
| 254 |
+
if (!socket.data.ball) return;
|
| 255 |
+
const accelerationVector = v2();
|
| 256 |
+
sub(accelerationVector, v2(x, y), socket.data.ball.cpos);
|
| 257 |
+
normalize(accelerationVector, accelerationVector);
|
| 258 |
+
const elasticityFactor = 20 * (distance(v2(x, y), socket.data.ball.cpos) / squareCanvasSizeInPixels);
|
| 259 |
+
scale(accelerationVector, accelerationVector, elasticityFactor);
|
| 260 |
+
add(socket.data.ball.acel, socket.data.ball.acel, accelerationVector);
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
function handleMessageReceivedFromSocket(message: string, socket: GameSocket) {
|
| 264 |
+
if (message.startsWith("/nick ")) {
|
| 265 |
+
const trimmedNickname = message.replace("/nick ", "").trim().substring(0, maximumNicknameLength);
|
| 266 |
+
|
| 267 |
+
if (trimmedNickname.length) {
|
| 268 |
+
broadcastChatMessageToAllTables(`📢 ${socket.data.nickname} is now known as ${trimmedNickname}!`);
|
| 269 |
+
socket.data.nickname = trimmedNickname;
|
| 270 |
+
}
|
| 271 |
+
} else if (message.startsWith("/newtable")) {
|
| 272 |
+
removeSocketFromTable(socket, socket.data.table);
|
| 273 |
+
addSocketToTable(socket, createTable());
|
| 274 |
+
} else if (message.startsWith("/jointable ")) {
|
| 275 |
+
const tableId = Number(message.replace("/jointable ", "").trim());
|
| 276 |
+
|
| 277 |
+
if (isNaN(tableId) || !tables.has(tableId)) {
|
| 278 |
+
socket.emit(ServerToClientEventName.Message, `📢 Table not found!`);
|
| 279 |
+
} else if (tables.get(tableId) === socket.data.table) {
|
| 280 |
+
socket.emit(ServerToClientEventName.Message, `📢 Already on table ${tableId}!`);
|
| 281 |
+
} else if ((tables.get(tableId) as Table).sockets.size >= maxSocketsPerTable) {
|
| 282 |
+
socket.emit(ServerToClientEventName.Message, `📢 Table is full!`);
|
| 283 |
+
} else {
|
| 284 |
+
removeSocketFromTable(socket, socket.data.table);
|
| 285 |
+
addSocketToTable(socket, tables.get(tableId) as Table);
|
| 286 |
+
}
|
| 287 |
+
} else {
|
| 288 |
+
broadcastChatMessageToAllTables(`💬 ${socket.data.nickname}: ${message}`);
|
| 289 |
+
}
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
function setupSocketListeners(socket: GameSocket) {
|
| 293 |
+
socket.on("disconnect", () => handleSocketDisconnected(socket));
|
| 294 |
+
socket.on(ClientToServerEventName.Message, (message) => handleMessageReceivedFromSocket(message, socket));
|
| 295 |
+
socket.on(ClientToServerEventName.Click, (x, y) => accelerateBallFromSocket(x, y, socket));
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
function checkCollisionWithTableRails(ball: Ball) {
|
| 299 |
+
tableRails.forEach(([pointA, pointB]) => {
|
| 300 |
+
if (rewindToCollisionPoint(ball, ball.radius, pointA, pointB))
|
| 301 |
+
collideCircleEdge(
|
| 302 |
+
ball,
|
| 303 |
+
ball.radius,
|
| 304 |
+
ball.mass,
|
| 305 |
+
{
|
| 306 |
+
cpos: pointA,
|
| 307 |
+
ppos: pointA,
|
| 308 |
+
},
|
| 309 |
+
massOfImmovableObjects,
|
| 310 |
+
{
|
| 311 |
+
cpos: pointB,
|
| 312 |
+
ppos: pointB,
|
| 313 |
+
},
|
| 314 |
+
massOfImmovableObjects,
|
| 315 |
+
true,
|
| 316 |
+
collisionDamping
|
| 317 |
+
);
|
| 318 |
+
});
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
function deleteBallFromTable(ball: Ball, table: Table) {
|
| 322 |
+
if (table.balls.has(ball.id)) {
|
| 323 |
+
table.balls.delete(ball.id);
|
| 324 |
+
table.sockets.forEach((targetSocket) => targetSocket.emit(ServerToClientEventName.Deletion, ball.id));
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
if (getNumberOfNonPlayableBallsOnTable(table) == 0) addNonPlayableBallsToTable(table);
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
function checkCollisionWithScoreLines(ball: Ball, table: Table) {
|
| 331 |
+
scoreLines.forEach(([pointA, pointB]) => {
|
| 332 |
+
if (rewindToCollisionPoint(ball, ball.radius, pointA, pointB)) {
|
| 333 |
+
deleteBallFromTable(ball, table);
|
| 334 |
+
|
| 335 |
+
if (ball.ownerSocketId) {
|
| 336 |
+
const socket = table.sockets.get(ball.ownerSocketId);
|
| 337 |
+
|
| 338 |
+
if (socket) {
|
| 339 |
+
const negativeScore = -ball.value;
|
| 340 |
+
socket.data.score = Math.max(0, (socket.data.score as number) + negativeScore);
|
| 341 |
+
socket.emit(ServerToClientEventName.Scored, negativeScore, ball.cpos.x, ball.cpos.y);
|
| 342 |
+
createBallForSocket(socket);
|
| 343 |
+
}
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
if (ball.lastTouchedBySocketId) {
|
| 347 |
+
const socket = table.sockets.get(ball.lastTouchedBySocketId);
|
| 348 |
+
|
| 349 |
+
if (socket) {
|
| 350 |
+
socket.data.score = (socket.data.score as number) + ball.value;
|
| 351 |
+
socket.emit(ServerToClientEventName.Scored, ball.value, ball.cpos.x, ball.cpos.y);
|
| 352 |
+
}
|
| 353 |
+
}
|
| 354 |
+
}
|
| 355 |
+
});
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
function emitObjectsPositionsToConnectedSockets() {
|
| 359 |
+
Array.from(tables.values())
|
| 360 |
+
.filter((table) => table.balls.size)
|
| 361 |
+
.forEach((table) => {
|
| 362 |
+
const positions = Array.from(table.balls.values()).reduce<BallsPositions>((resultArray, ball) => {
|
| 363 |
+
resultArray.push([ball.id, Math.trunc(ball.cpos.x), Math.trunc(ball.cpos.y)]);
|
| 364 |
+
return resultArray;
|
| 365 |
+
}, []);
|
| 366 |
+
|
| 367 |
+
table.sockets.forEach((socket) => {
|
| 368 |
+
socket.emit(ServerToClientEventName.Positions, positions);
|
| 369 |
+
});
|
| 370 |
+
});
|
| 371 |
+
}
|
| 372 |
+
|
| 373 |
+
function emitScoreboardToConnectedSockets() {
|
| 374 |
+
const tableIdPerScoreboardMap = new Map<number, Scoreboard>();
|
| 375 |
+
|
| 376 |
+
tables.forEach((table) => {
|
| 377 |
+
const tableScoreboard = Array.from(table.sockets.values())
|
| 378 |
+
.sort((a, b) => (b.data.score as number) - (a.data.score as number))
|
| 379 |
+
.reduce<Scoreboard>((scoreboard, socket) => {
|
| 380 |
+
scoreboard.push([socket.data.nickname as string, socket.data.score as number, table.id as number]);
|
| 381 |
+
return scoreboard;
|
| 382 |
+
}, []);
|
| 383 |
+
|
| 384 |
+
tableIdPerScoreboardMap.set(table.id, tableScoreboard);
|
| 385 |
+
});
|
| 386 |
+
|
| 387 |
+
const overallScoreboard = [] as Scoreboard;
|
| 388 |
+
|
| 389 |
+
tableIdPerScoreboardMap.forEach((tableScoreboard) => overallScoreboard.push(...tableScoreboard));
|
| 390 |
+
|
| 391 |
+
overallScoreboard.sort(([, scoreA], [, scoreB]) => scoreB - scoreA);
|
| 392 |
+
|
| 393 |
+
const scoreBoardToEmit = JSON.stringify(overallScoreboard);
|
| 394 |
+
|
| 395 |
+
if (lastScoreboardEmitted === scoreBoardToEmit) return;
|
| 396 |
+
|
| 397 |
+
lastScoreboardEmitted = scoreBoardToEmit;
|
| 398 |
+
|
| 399 |
+
tables.forEach((table) => {
|
| 400 |
+
table.sockets.forEach((socket) => {
|
| 401 |
+
let tableScoreboard = [] as Scoreboard;
|
| 402 |
+
if (socket.data.table && tableIdPerScoreboardMap.has(socket.data.table.id)) {
|
| 403 |
+
tableScoreboard = tableIdPerScoreboardMap.get(socket.data.table.id) as Scoreboard;
|
| 404 |
+
}
|
| 405 |
+
socket.emit(ServerToClientEventName.Scoreboard, overallScoreboard, tableScoreboard);
|
| 406 |
+
});
|
| 407 |
+
});
|
| 408 |
+
}
|
| 409 |
+
|
| 410 |
+
function repositionBallIfItIsOutOfTable(ball: Ball) {
|
| 411 |
+
if (
|
| 412 |
+
ball.cpos.x < 0 ||
|
| 413 |
+
ball.cpos.x > squareCanvasSizeInPixels ||
|
| 414 |
+
ball.cpos.y < 0 ||
|
| 415 |
+
ball.cpos.y > squareCanvasSizeInPixels
|
| 416 |
+
) {
|
| 417 |
+
placeBallInRandomPosition(ball);
|
| 418 |
+
}
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
function updatePhysics(deltaTime: number) {
|
| 422 |
+
tables.forEach((table) => {
|
| 423 |
+
Array.from(table.balls.values()).forEach((ball, _, balls) => {
|
| 424 |
+
repositionBallIfItIsOutOfTable(ball);
|
| 425 |
+
|
| 426 |
+
accelerate(ball, deltaTime);
|
| 427 |
+
|
| 428 |
+
balls
|
| 429 |
+
.filter((otherBalls) => ball !== otherBalls && isColliding(ball, otherBalls))
|
| 430 |
+
.forEach((otherBall) => handleCollision(ball, otherBall));
|
| 431 |
+
|
| 432 |
+
checkCollisionWithTableRails(ball);
|
| 433 |
+
|
| 434 |
+
checkCollisionWithScoreLines(ball, table);
|
| 435 |
+
|
| 436 |
+
inertia(ball);
|
| 437 |
+
});
|
| 438 |
+
});
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
function getRandomHexColor() {
|
| 442 |
+
const randomInteger = (max: number) => Math.floor(Math.random() * (max + 1));
|
| 443 |
+
const randomRgbColor = () => [randomInteger(255), randomInteger(255), randomInteger(255)];
|
| 444 |
+
const [r, g, b] = randomRgbColor();
|
| 445 |
+
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
function handleMainLoopUpdate(deltaTime: number) {
|
| 449 |
+
updatePhysics(deltaTime);
|
| 450 |
+
|
| 451 |
+
timePassedSinceLastStateUpdateEmitted += deltaTime;
|
| 452 |
+
if (timePassedSinceLastStateUpdateEmitted > objectsPositionsUpdateMillisecondsInterval) {
|
| 453 |
+
timePassedSinceLastStateUpdateEmitted -= objectsPositionsUpdateMillisecondsInterval;
|
| 454 |
+
emitObjectsPositionsToConnectedSockets();
|
| 455 |
+
}
|
| 456 |
+
|
| 457 |
+
timePassedSinceLastScoreboardUpdate += deltaTime;
|
| 458 |
+
if (timePassedSinceLastScoreboardUpdate > scoreboardUpdateMillisecondsInterval) {
|
| 459 |
+
timePassedSinceLastScoreboardUpdate -= scoreboardUpdateMillisecondsInterval;
|
| 460 |
+
emitScoreboardToConnectedSockets();
|
| 461 |
+
}
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
function addNonPlayableBallsToTable(table: Table) {
|
| 465 |
+
const [min, max] = nonPlayableBallsValuesRange;
|
| 466 |
+
for (let value = min; value <= max; value++) {
|
| 467 |
+
addBallToTable(table, {
|
| 468 |
+
radius: ballRadius,
|
| 469 |
+
value,
|
| 470 |
+
label: `${value}`,
|
| 471 |
+
color: ballColors[value],
|
| 472 |
+
});
|
| 473 |
+
}
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
function addSocketToTable(socket: GameSocket, table: Table) {
|
| 477 |
+
table.sockets.set(socket.id, socket);
|
| 478 |
+
socket.data.table = table;
|
| 479 |
+
createBallForSocket(socket);
|
| 480 |
+
socket.emit(ServerToClientEventName.Objects, Array.from(table.balls.values()));
|
| 481 |
+
broadcastChatMessageToAllTables(`📢 ${socket.data.nickname} joined Table ${table.id}!`);
|
| 482 |
+
}
|
| 483 |
+
|
| 484 |
+
function removeSocketFromTable(socket: GameSocket, table?: Table) {
|
| 485 |
+
if (!table) return;
|
| 486 |
+
deleteBallFromSocket(socket);
|
| 487 |
+
table.sockets.delete(socket.id);
|
| 488 |
+
socket.data.table = undefined;
|
| 489 |
+
if (!table.sockets.size) deleteTable(table);
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
function createTable() {
|
| 493 |
+
const table = {
|
| 494 |
+
id: getUniqueId(),
|
| 495 |
+
sockets: new Map<string, GameSocket>(),
|
| 496 |
+
balls: new Map<number, Ball>(),
|
| 497 |
+
} as Table;
|
| 498 |
+
|
| 499 |
+
tables.set(table.id, table);
|
| 500 |
+
|
| 501 |
+
addNonPlayableBallsToTable(table);
|
| 502 |
+
|
| 503 |
+
return table;
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
function deleteTable(table: Table) {
|
| 507 |
+
Array.from(table.balls.values()).forEach((ball) => deleteBallFromTable(ball, table));
|
| 508 |
+
tables.delete(table.id);
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
MainLoop.setUpdate(handleMainLoopUpdate).start();
|
| 512 |
+
|
| 513 |
+
export default { io: handleSocketConnected };
|
src/shared.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Integratable } from "pocket-physics";
|
| 2 |
+
|
| 3 |
+
export const ballsPositionsUpdatesPerSecond = 8;
|
| 4 |
+
|
| 5 |
+
export const ballRadius = 14;
|
| 6 |
+
|
| 7 |
+
export const squareCanvasSizeInPixels = 680;
|
| 8 |
+
|
| 9 |
+
export type Ball = Integratable & {
|
| 10 |
+
id: number;
|
| 11 |
+
radius: number;
|
| 12 |
+
mass: number;
|
| 13 |
+
value: number;
|
| 14 |
+
label: string;
|
| 15 |
+
color: string;
|
| 16 |
+
lastTouchedTimestamp: number;
|
| 17 |
+
lastTouchedBySocketId?: string;
|
| 18 |
+
ownerSocketId?: string;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
export type BallsPositions = [objectId: number, x: number, y: number][];
|
| 22 |
+
|
| 23 |
+
export type Scoreboard = [nick: string, score: number, tableId: number][];
|
| 24 |
+
|
| 25 |
+
export enum ServerToClientEventName {
|
| 26 |
+
Message = "A",
|
| 27 |
+
Objects = "B",
|
| 28 |
+
Creation = "C",
|
| 29 |
+
Deletion = "D",
|
| 30 |
+
Scored = "E",
|
| 31 |
+
Positions = "F",
|
| 32 |
+
Scoreboard = "G",
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
export enum ClientToServerEventName {
|
| 36 |
+
Message = "A",
|
| 37 |
+
Click = "B",
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export interface ServerToClientEvents {
|
| 41 |
+
[ServerToClientEventName.Message]: (message: string) => void;
|
| 42 |
+
[ServerToClientEventName.Objects]: (objects: Ball[]) => void;
|
| 43 |
+
[ServerToClientEventName.Creation]: (object: Ball) => void;
|
| 44 |
+
[ServerToClientEventName.Deletion]: (id: number) => void;
|
| 45 |
+
[ServerToClientEventName.Scored]: (value: number, positionX: number, positionY: number) => void;
|
| 46 |
+
[ServerToClientEventName.Positions]: (ballsPositions: BallsPositions) => void;
|
| 47 |
+
[ServerToClientEventName.Scoreboard]: (overallScoreboard: Scoreboard, tableScoreboard: Scoreboard) => void;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
export interface ClientToServerEvents {
|
| 51 |
+
[ClientToServerEventName.Message]: (message: string) => void;
|
| 52 |
+
[ClientToServerEventName.Click]: (positionX: number, positionY: number) => void;
|
| 53 |
+
}
|
src/types.d.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
declare const io: typeof import("socket.io-client").io;
|
| 2 |
+
|
| 3 |
+
declare module "zzfx" {
|
| 4 |
+
export function zzfx(
|
| 5 |
+
volume?: number,
|
| 6 |
+
randomness?: number,
|
| 7 |
+
frequency?: number,
|
| 8 |
+
attack?: number,
|
| 9 |
+
sustain?: number,
|
| 10 |
+
release?: number,
|
| 11 |
+
shape?: number,
|
| 12 |
+
shapeCurve?: number,
|
| 13 |
+
slide?: number,
|
| 14 |
+
deltaSlide?: number,
|
| 15 |
+
pitchJump?: number,
|
| 16 |
+
pitchJumpTime?: number,
|
| 17 |
+
repeatTime?: number,
|
| 18 |
+
noise?: number,
|
| 19 |
+
modulation?: number,
|
| 20 |
+
bitCrush?: number,
|
| 21 |
+
delay?: number,
|
| 22 |
+
sustainVolume?: number,
|
| 23 |
+
decay?: number,
|
| 24 |
+
tremolo?: number
|
| 25 |
+
): AudioBufferSourceNode;
|
| 26 |
+
}
|
tsconfig.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ESNext",
|
| 4 |
+
"moduleResolution": "node",
|
| 5 |
+
"allowSyntheticDefaultImports": true,
|
| 6 |
+
"esModuleInterop": true,
|
| 7 |
+
"forceConsistentCasingInFileNames": true,
|
| 8 |
+
"strict": true,
|
| 9 |
+
"skipLibCheck": true
|
| 10 |
+
}
|
| 11 |
+
}
|