github-actions[bot] commited on
Commit
a07d36d
·
0 Parent(s):

Sync to HuggingFace Spaces

Browse files
.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
+ ![Game Screenshot](./screenshot.png)
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
+ }