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 |
+
}
|