tickcli-js 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +360 -2
- package/client/game.js +1003 -0
- package/client/game3d.js +109 -0
- package/client/relay.js +196 -0
- package/client/room.js +632 -0
- package/dist/cli.d.ts +11 -0
- package/dist/cli.js +82 -0
- package/dist/cli.js.map +1 -0
- package/dist/hub.d.ts +619 -0
- package/dist/hub.js +1777 -0
- package/dist/hub.js.map +1 -0
- package/dist/node.d.ts +106 -0
- package/dist/node.js +301 -0
- package/dist/node.js.map +1 -0
- package/dist/remote.d.ts +75 -0
- package/dist/remote.js +214 -0
- package/dist/remote.js.map +1 -0
- package/dist/serve.d.ts +42 -0
- package/dist/serve.js +231 -0
- package/dist/serve.js.map +1 -0
- package/examples/games/arena.html +108 -0
- package/examples/games/arena3d.html +110 -0
- package/examples/games/chess.html +132 -0
- package/examples/games/cup.html +52 -0
- package/examples/games/delve.html +137 -0
- package/examples/games/dungeon.html +110 -0
- package/examples/games/neon-paint.html +79 -0
- package/examples/games/own-engine.html +41 -0
- package/examples/games/race.html +83 -0
- package/examples/games/shooter.html +95 -0
- package/examples/games/sky-duel.html +136 -0
- package/examples/games/space-squad.html +3627 -0
- package/examples/games/tag.html +51 -0
- package/examples/games/tictactoe.html +53 -0
- package/package.json +101 -4
- package/src/cli.ts +59 -0
- package/src/hub.ts +1521 -0
- package/src/node.ts +245 -0
- package/src/remote.ts +236 -0
- package/src/serve.ts +224 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Tag</title></head><body>
|
|
2
|
+
<script src="lib:room"></script>
|
|
3
|
+
<script src="lib:game"></script>
|
|
4
|
+
<script>
|
|
5
|
+
// Tag for 2 to 8 players. Whoever is "it" chases the others; staying free earns points. The host decides who is it and keeps the score.
|
|
6
|
+
const W = 800, H = 600, SPEED = 230, ROUND = 60;
|
|
7
|
+
let x, y, flash = null;
|
|
8
|
+
Game({
|
|
9
|
+
title: "Tag", help: "Arrow keys, WASD or the pad to run. Red ring = it: touch someone to pass it on. Free players score every second.", aspect: W / H,
|
|
10
|
+
multiplayer: { max: 8, player: { x: [0, W], y: [0, H] } },
|
|
11
|
+
setup(g) { x = 60 + Math.random() * (W - 120); y = 60 + Math.random() * (H - 120); g.me.set({ x, y }); },
|
|
12
|
+
update(g, dt) {
|
|
13
|
+
const a = g.axis();
|
|
14
|
+
x = Math.max(12, Math.min(W - 12, x + a.x * SPEED * dt));
|
|
15
|
+
y = Math.max(12, Math.min(H - 12, y + a.y * SPEED * dt));
|
|
16
|
+
g.me.set({ x, y });
|
|
17
|
+
},
|
|
18
|
+
host(g, dt) {
|
|
19
|
+
const s = g.shared, live = g.players.filter((p) => p.online && typeof p.state.x === "number");
|
|
20
|
+
if (s.endsAt == null) return g.shared.set({ endsAt: g.now + ROUND, it: live.length ? live[Math.floor(Math.random() * live.length)].id : null, score: {}, safeUntil: 0 });
|
|
21
|
+
if (g.now >= s.endsAt) return g.gameOver();
|
|
22
|
+
let it = live.find((p) => p.id === s.it);
|
|
23
|
+
if (!it && live.length) return g.shared.set({ it: live[0].id, safeUntil: g.now + 1.5 });
|
|
24
|
+
const score = {};
|
|
25
|
+
for (const p of live) if (p.id !== s.it) score[p.id] = ((s.score && s.score[p.id]) || 0) + dt;
|
|
26
|
+
g.shared.set({ score });
|
|
27
|
+
if (!it || g.now < (s.safeUntil || 0)) return;
|
|
28
|
+
for (const p of live) if (p.id !== it.id && Math.hypot(p.state.x - it.state.x, p.state.y - it.state.y) < 28) { g.shared.set({ it: p.id, safeUntil: g.now + 1.5 }); g.fx("tag", { x: p.state.x, y: p.state.y }); break; }
|
|
29
|
+
},
|
|
30
|
+
onFx(g, from, name, d) { if (name === "tag") flash = { x: d.x, y: d.y, until: performance.now() + 400 }; },
|
|
31
|
+
draw(g, ctx, w, h) {
|
|
32
|
+
const k = w / W;
|
|
33
|
+
ctx.fillStyle = "#0f172a"; ctx.fillRect(0, 0, w, h);
|
|
34
|
+
const s = g.shared || {};
|
|
35
|
+
for (const p of g.players) {
|
|
36
|
+
if (typeof p.state.x !== "number") continue;
|
|
37
|
+
ctx.globalAlpha = p.online ? 1 : 0.4;
|
|
38
|
+
ctx.fillStyle = p.me ? "#60a5fa" : "#e2e8f0";
|
|
39
|
+
ctx.beginPath(); ctx.arc(p.state.x * k, p.state.y * k, 12 * k, 0, 7); ctx.fill();
|
|
40
|
+
if (p.id === s.it) { ctx.strokeStyle = "#ef4444"; ctx.lineWidth = 4 * k; ctx.beginPath(); ctx.arc(p.state.x * k, p.state.y * k, 18 * k, 0, 7); ctx.stroke(); }
|
|
41
|
+
ctx.fillStyle = "#cbd5e1"; ctx.font = `${12 * k}px system-ui`; ctx.textAlign = "center"; ctx.fillText(p.name, p.state.x * k, (p.state.y - 22) * k);
|
|
42
|
+
}
|
|
43
|
+
ctx.globalAlpha = 1;
|
|
44
|
+
if (flash && performance.now() < flash.until) { ctx.strokeStyle = "#fbbf24"; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(flash.x * k, flash.y * k, 30 * k, 0, 7); ctx.stroke(); }
|
|
45
|
+
ctx.fillStyle = "#f8fafc"; ctx.font = `${16 * k}px system-ui`; ctx.textAlign = "left";
|
|
46
|
+
if (s.endsAt != null) ctx.fillText(`Time ${Math.max(0, Math.ceil(s.endsAt - g.now))}`, 12 * k, 22 * k);
|
|
47
|
+
const mine = s.score && s.score[g.me.id];
|
|
48
|
+
ctx.textAlign = "right"; ctx.fillText(`You ${Math.floor(mine || 0)}`, w - 12 * k, 22 * k);
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
</script></body></html>
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Tic-tac-toe</title></head><body>
|
|
2
|
+
<script src="lib:room"></script>
|
|
3
|
+
<script src="lib:game"></script>
|
|
4
|
+
<script>
|
|
5
|
+
// Turn game for 2 players. Players ask the host to place a mark; the host checks whose turn it is and who won.
|
|
6
|
+
const LINES = [[0,1,2],[3,4,5],[6,7,8],[0,3,6],[1,4,7],[2,5,8],[0,4,8],[2,4,6]];
|
|
7
|
+
let cursor = 4, size = 300, wired = false;
|
|
8
|
+
Game({
|
|
9
|
+
title: "Tic-tac-toe", help: "Tap a square, or move with the arrows and press Space. Three in a row wins.", aspect: 1,
|
|
10
|
+
multiplayer: { max: 2, player: {} },
|
|
11
|
+
setup(g) {
|
|
12
|
+
cursor = 4;
|
|
13
|
+
if (!wired) { wired = true; g.canvas.addEventListener("pointerup", (e) => { const r = g.canvas.getBoundingClientRect(); const c = Math.floor((e.clientX - r.left) / r.width * 3), w = Math.floor((e.clientY - r.top) / r.height / 0.9 * 3); if (c >= 0 && c < 3 && w >= 0 && w < 3) { cursor = w * 3 + c; place(g); } }); }
|
|
14
|
+
},
|
|
15
|
+
update() {},
|
|
16
|
+
host(g) {
|
|
17
|
+
const s = g.shared, ids = g.players.map((p) => p.id);
|
|
18
|
+
if (!s.board) return g.shared.set({ board: Array(9).fill(""), marks: { [ids[0]]: "X", ...(ids[1] ? { [ids[1]]: "O" } : {}) }, turn: ids[0], winner: null });
|
|
19
|
+
// a second player joined after the start: give them O
|
|
20
|
+
for (const id of ids) if (!s.marks[id] && Object.keys(s.marks).length < 2) g.shared.set({ marks: { [id]: "O" } });
|
|
21
|
+
},
|
|
22
|
+
onAction(g, from, name, d) {
|
|
23
|
+
const s = g.shared;
|
|
24
|
+
if (name !== "place" || s.winner || from !== s.turn || !s.board || s.board[d.i] !== "" || !s.marks[from]) return false;
|
|
25
|
+
const board = s.board.slice(); board[d.i] = s.marks[from];
|
|
26
|
+
const other = Object.keys(s.marks).find((id) => id !== from) || from;
|
|
27
|
+
const won = LINES.some((l) => l.every((i) => board[i] === s.marks[from]));
|
|
28
|
+
const full = board.every((c) => c);
|
|
29
|
+
g.shared.set({ board, turn: other, winner: won ? from : full ? "draw" : null, score: won ? { [from]: 1 } : {} });
|
|
30
|
+
if (won || full) g.gameOver();
|
|
31
|
+
},
|
|
32
|
+
onInput(g, a) {
|
|
33
|
+
if (a === "left") cursor = cursor % 3 ? cursor - 1 : cursor;
|
|
34
|
+
if (a === "right") cursor = cursor % 3 < 2 ? cursor + 1 : cursor;
|
|
35
|
+
if (a === "up") cursor = cursor >= 3 ? cursor - 3 : cursor;
|
|
36
|
+
if (a === "down") cursor = cursor < 6 ? cursor + 3 : cursor;
|
|
37
|
+
if (a === "action") place(g);
|
|
38
|
+
},
|
|
39
|
+
draw(g, ctx, w, h) {
|
|
40
|
+
const s = g.shared || {}, c = w / 3, bh = h * 0.9, ch = bh / 3;
|
|
41
|
+
ctx.fillStyle = "#0f172a"; ctx.fillRect(0, 0, w, h);
|
|
42
|
+
ctx.strokeStyle = "#475569"; ctx.lineWidth = 3;
|
|
43
|
+
for (let i = 1; i < 3; i++) { ctx.beginPath(); ctx.moveTo(i * c, 8); ctx.lineTo(i * c, bh - 8); ctx.moveTo(8, i * ch); ctx.lineTo(w - 8, i * ch); ctx.stroke(); }
|
|
44
|
+
ctx.strokeStyle = "#60a5fa"; ctx.strokeRect((cursor % 3) * c + 4, Math.floor(cursor / 3) * ch + 4, c - 8, ch - 8);
|
|
45
|
+
ctx.font = `${Math.min(c, ch) * 0.6}px system-ui`; ctx.textAlign = "center"; ctx.textBaseline = "middle";
|
|
46
|
+
(s.board || []).forEach((m, i) => { if (m) { ctx.fillStyle = m === "X" ? "#f87171" : "#34d399"; ctx.fillText(m, (i % 3) * c + c / 2, Math.floor(i / 3) * ch + ch / 2); } });
|
|
47
|
+
ctx.font = `${w * 0.045}px system-ui`; ctx.fillStyle = "#f8fafc";
|
|
48
|
+
const me = s.marks && s.marks[g.me.id];
|
|
49
|
+
ctx.fillText(s.winner ? (s.winner === "draw" ? "Draw" : s.winner === g.me.id ? "You win" : "You lose") : s.turn === g.me.id ? `Your turn (${me})` : "Their turn", w / 2, h - 16);
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
function place(g) { if (g.state === "playing") g.act("place", { i: cursor }); }
|
|
53
|
+
</script></body></html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,103 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tickcli-js",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Live rooms for browser games: one player's browser runs the rules, a small Node hub keeps the shared state, membership and order. No game code on the server.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/tickclidev/tickcli.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://tickcli.com/docs/",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/tickclidev/tickcli/issues"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"multiplayer",
|
|
16
|
+
"websocket",
|
|
17
|
+
"browser-games",
|
|
18
|
+
"game-server",
|
|
19
|
+
"rooms",
|
|
20
|
+
"realtime",
|
|
21
|
+
"html5-games"
|
|
22
|
+
],
|
|
23
|
+
"type": "module",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./dist/hub.d.ts",
|
|
27
|
+
"default": "./dist/hub.js"
|
|
28
|
+
},
|
|
29
|
+
"./node": {
|
|
30
|
+
"types": "./dist/node.d.ts",
|
|
31
|
+
"default": "./dist/node.js"
|
|
32
|
+
},
|
|
33
|
+
"./serve": {
|
|
34
|
+
"types": "./dist/serve.d.ts",
|
|
35
|
+
"default": "./dist/serve.js"
|
|
36
|
+
},
|
|
37
|
+
"./remote": {
|
|
38
|
+
"types": "./dist/remote.d.ts",
|
|
39
|
+
"default": "./dist/remote.js"
|
|
40
|
+
},
|
|
41
|
+
"./client/*": "./client/*",
|
|
42
|
+
"./package.json": "./package.json"
|
|
43
|
+
},
|
|
44
|
+
"bin": {
|
|
45
|
+
"tickcli": "dist/cli.js"
|
|
46
|
+
},
|
|
47
|
+
"files": [
|
|
48
|
+
"dist",
|
|
49
|
+
"src",
|
|
50
|
+
"client",
|
|
51
|
+
"examples/games",
|
|
52
|
+
"README.md",
|
|
53
|
+
"LICENSE",
|
|
54
|
+
"CHANGELOG.md"
|
|
55
|
+
],
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=22"
|
|
58
|
+
},
|
|
59
|
+
"sideEffects": [
|
|
60
|
+
"./client/*.js"
|
|
61
|
+
],
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
|
64
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
65
|
+
"test": "node --test test/*.test.ts test/engine/*.test.ts",
|
|
66
|
+
"test:critical": "node --test test/*.test.ts",
|
|
67
|
+
"demo": "node src/cli.ts examples/games",
|
|
68
|
+
"prepublishOnly": "npm run typecheck && npm test && npm run build",
|
|
69
|
+
"e2e": "node e2e/quick.ts --all",
|
|
70
|
+
"e2e:quick": "node e2e/quick.ts",
|
|
71
|
+
"e2e:serial": "node e2e/quickstart.ts && node e2e/demo.ts && node e2e/saves.ts && node e2e/rooms.ts && node e2e/remote.ts && node e2e/three.ts && node e2e/demos.ts && node e2e/tutorials.ts"
|
|
72
|
+
},
|
|
73
|
+
"peerDependencies": {
|
|
74
|
+
"three": ">=0.171.0",
|
|
75
|
+
"ws": "^8.18.0",
|
|
76
|
+
"phaser": ">=4.0.0",
|
|
77
|
+
"pixi.js": ">=8.0.0"
|
|
78
|
+
},
|
|
79
|
+
"peerDependenciesMeta": {
|
|
80
|
+
"ws": {
|
|
81
|
+
"optional": true
|
|
82
|
+
},
|
|
83
|
+
"three": {
|
|
84
|
+
"optional": true
|
|
85
|
+
},
|
|
86
|
+
"phaser": {
|
|
87
|
+
"optional": true
|
|
88
|
+
},
|
|
89
|
+
"pixi.js": {
|
|
90
|
+
"optional": true
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"devDependencies": {
|
|
94
|
+
"@types/node": "^22.18.0",
|
|
95
|
+
"@types/ws": "8.18.1",
|
|
96
|
+
"phaser": "4.2.1",
|
|
97
|
+
"pixi.js": "8.22.0",
|
|
98
|
+
"playwright": "1.64.0",
|
|
99
|
+
"three": "0.186.1",
|
|
100
|
+
"typescript": "5.9.3",
|
|
101
|
+
"ws": "8.22.0"
|
|
102
|
+
}
|
|
103
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// npx tickcli-js [folder] [--port 8080] [--host 0.0.0.0] [--saves folder] [--title "My games"]
|
|
3
|
+
// Serves every .html game in the folder with live rooms. TICKCLI_SECRET (any long random text) keeps players' seats across restarts.
|
|
4
|
+
import { realpathSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { createGameServer } from "./serve.ts";
|
|
7
|
+
|
|
8
|
+
const HELP = `tickcli: live rooms for the .html games in a folder
|
|
9
|
+
|
|
10
|
+
npx tickcli-js [folder] [options]
|
|
11
|
+
|
|
12
|
+
folder games to serve, one .html file each (default: this folder)
|
|
13
|
+
--port <n> port to listen on (default 8080, or $PORT)
|
|
14
|
+
--host <addr> address to listen on (default: all)
|
|
15
|
+
--saves <folder> keep saved profiles and worlds there (default: none)
|
|
16
|
+
--title <text> heading of the game list
|
|
17
|
+
--behind-proxy an https proxy on this machine or network (Caddy, nginx) is in front: count players by the address it saw, and
|
|
18
|
+
mark the pass cookie Secure
|
|
19
|
+
-h, --help this text
|
|
20
|
+
|
|
21
|
+
TICKCLI_SECRET set it to keep players' seats and saves across restarts
|
|
22
|
+
`;
|
|
23
|
+
|
|
24
|
+
/** The command line as options; throws a message for anything it does not understand. */
|
|
25
|
+
export function parseArgs(argv: string[], env: Record<string, string | undefined> = {}) {
|
|
26
|
+
const o: { games: string; port: number; host?: string; saves?: string; title?: string; proxy?: boolean; help: boolean } = { games: ".", port: Number(env.PORT) || 8080, help: false };
|
|
27
|
+
let folder = false;
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const a = argv[i];
|
|
30
|
+
const val = () => { const v = argv[++i]; if (v === undefined || v.startsWith("--")) throw new Error(`${a} needs a value`); return v; };
|
|
31
|
+
if (a === "-h" || a === "--help") o.help = true;
|
|
32
|
+
else if (a === "--port") { o.port = Number(val()); if (!Number.isInteger(o.port) || o.port < 0 || o.port > 65535) throw new Error("--port is a number from 0 to 65535"); }
|
|
33
|
+
else if (a === "--host") o.host = val();
|
|
34
|
+
else if (a === "--saves") o.saves = val();
|
|
35
|
+
else if (a === "--title") o.title = val();
|
|
36
|
+
else if (a === "--behind-proxy") o.proxy = true;
|
|
37
|
+
else if (a.startsWith("-")) throw new Error(`unknown option ${a}`);
|
|
38
|
+
else if (!folder) { o.games = a; folder = true; }
|
|
39
|
+
else throw new Error(`one folder only (got ${o.games} and ${a})`);
|
|
40
|
+
}
|
|
41
|
+
return o;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function main() {
|
|
45
|
+
let o;
|
|
46
|
+
try { o = parseArgs(process.argv.slice(2), process.env); } catch (e) { console.error(`tickcli: ${(e as Error).message}\n\n${HELP}`); process.exit(2); }
|
|
47
|
+
if (o.help) { console.log(HELP); return; }
|
|
48
|
+
const app = createGameServer({ games: o.games, saves: o.saves, title: o.title, secret: process.env.TICKCLI_SECRET || undefined, trustProxy: !!o.proxy, secure: !!o.proxy });
|
|
49
|
+
const url = await app.listen(o.port, o.host);
|
|
50
|
+
console.log(`tickcli: serving ${o.games} on ${url}`);
|
|
51
|
+
if (!process.env.TICKCLI_SECRET) console.log("tickcli: no TICKCLI_SECRET set, so players get new seats after a restart");
|
|
52
|
+
const stop = () => { void app.close().then(() => process.exit(0)); };
|
|
53
|
+
process.once("SIGINT", stop);
|
|
54
|
+
process.once("SIGTERM", stop);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// run when started as a program (node src/cli.ts, the npx bin), not when imported by a test
|
|
58
|
+
const self = fileURLToPath(import.meta.url);
|
|
59
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(self)) void main().catch((e) => { console.error(`tickcli: ${(e as Error).message}`); process.exit(1); });
|