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,110 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Torchlight Crawl</title></head><body>
|
|
2
|
+
<script src="lib:room"></script>
|
|
3
|
+
<script src="lib:game"></script>
|
|
4
|
+
<script>
|
|
5
|
+
// Co-op dungeon for 1 to 4 players: survive five waves of monsters together. Fallen players get back up when a friend stands next to them.
|
|
6
|
+
// The host runs the monsters, damage, loot and waves; players move themselves and ask the host to swing or pick something up.
|
|
7
|
+
const W = 1200, H = 1200, SPEED = 190, SWING = 90, WAVES = 5;
|
|
8
|
+
let x = 600, y = 600, seq = 0, cool = {}, swings = [];
|
|
9
|
+
const dist = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
|
|
10
|
+
Game({
|
|
11
|
+
title: "Torchlight Crawl", help: "Move with arrows, WASD or the pad. Space or tap swings at monsters around you; walk over potions to drink them. Stand next to a fallen friend to lift them.", aspect: 1,
|
|
12
|
+
multiplayer: { max: 4, player: { x: [0, W], y: [0, H] } },
|
|
13
|
+
setup(g) { x = 560 + Math.random() * 80; y = 560 + Math.random() * 80; g.me.set({ x, y }); swings = []; },
|
|
14
|
+
update(g, dt) {
|
|
15
|
+
const s = g.shared;
|
|
16
|
+
if (s.down && s.down[g.me.id]) return; // fallen: wait for a friend
|
|
17
|
+
const a = g.axis();
|
|
18
|
+
x = Math.max(30, Math.min(W - 30, x + a.x * SPEED * dt)); y = Math.max(30, Math.min(H - 30, y + a.y * SPEED * dt));
|
|
19
|
+
g.me.set({ x, y });
|
|
20
|
+
for (const [id, l] of Object.entries(s.loot || {})) if (dist(l, { x, y }) < 26 && !(cool["p" + id] > performance.now())) { cool["p" + id] = performance.now() + 1000; g.act("drink", { id }); }
|
|
21
|
+
},
|
|
22
|
+
onInput(g, a) { if (a === "action") { g.act("swing"); g.fx("swing", { x, y }); } },
|
|
23
|
+
host(g, dt) {
|
|
24
|
+
const s = g.shared;
|
|
25
|
+
if (!s.wave) return g.shared.set({ wave: 1, mobs: spawn(1), hp: {}, down: {}, lift: {}, loot: {}, score: {}, result: null });
|
|
26
|
+
if (s.result) return;
|
|
27
|
+
const players = g.players.filter((p) => p.online && typeof p.state.x === "number");
|
|
28
|
+
const hp = {}, down = {}, lift = {}, mobs = JSON.parse(JSON.stringify(s.mobs || {}));
|
|
29
|
+
for (const p of players) if (s.hp[p.id] == null && !s.down[p.id]) hp[p.id] = 100;
|
|
30
|
+
const up = players.filter((p) => !s.down[p.id]);
|
|
31
|
+
// monsters chase the nearest standing player and bite on contact
|
|
32
|
+
for (const m of Object.values(mobs)) {
|
|
33
|
+
let t = null, td = 1e9;
|
|
34
|
+
for (const p of up) { const d = dist(p.state, m); if (d < td) { t = p; td = d; } }
|
|
35
|
+
if (!t) continue;
|
|
36
|
+
if (td > 26) { m.x += (t.state.x - m.x) / td * m.speed * dt; m.y += (t.state.y - m.y) / td * m.speed * dt; }
|
|
37
|
+
else hp[t.id] = (hp[t.id] ?? s.hp[t.id] ?? 100) - m.bite * dt;
|
|
38
|
+
}
|
|
39
|
+
for (const [id, v] of Object.entries(hp)) if (v != null && v <= 0) { hp[id] = null; down[id] = true; lift[id] = 0; }
|
|
40
|
+
// a friend standing next to a fallen player for 2 seconds lifts them back up with half health
|
|
41
|
+
for (const p of players) if (s.down[p.id]) {
|
|
42
|
+
const near = up.some((q) => q.id !== p.id && dist(q.state, p.state) < 50);
|
|
43
|
+
const l = near ? (s.lift[p.id] || 0) + dt : 0;
|
|
44
|
+
if (l >= 2) { down[p.id] = null; lift[p.id] = null; hp[p.id] = 50; } else lift[p.id] = l;
|
|
45
|
+
}
|
|
46
|
+
const patch = { mobs: { ...mobs }, hp, down, lift };
|
|
47
|
+
for (const id in s.mobs) if (!mobs[id]) patch.mobs[id] = null;
|
|
48
|
+
const standing = players.filter((p) => !(down[p.id] ?? s.down[p.id]));
|
|
49
|
+
if (players.length && !standing.length) patch.result = "lost";
|
|
50
|
+
else if (!Object.keys(mobs).length) {
|
|
51
|
+
if (s.wave >= WAVES) patch.result = "won";
|
|
52
|
+
else { patch.wave = s.wave + 1; patch.mobs = spawn(s.wave + 1); patch.loot = { [`l${++seq}${Math.random().toString(36).slice(2, 5)}`]: { x: 400 + Math.random() * 400, y: 400 + Math.random() * 400 } }; }
|
|
53
|
+
}
|
|
54
|
+
g.shared.set(patch);
|
|
55
|
+
if (patch.result) g.gameOver();
|
|
56
|
+
},
|
|
57
|
+
onAction(g, from, name, d) {
|
|
58
|
+
const s = g.shared, me = g.players.find((p) => p.id === from);
|
|
59
|
+
if (!me || s.down[from] || s.result) return false;
|
|
60
|
+
if (name === "drink") {
|
|
61
|
+
const l = s.loot[d && d.id];
|
|
62
|
+
if (!l || dist(l, me.state) > 40) return false; // someone else drank it first
|
|
63
|
+
return void g.shared.set({ loot: { [d.id]: null }, hp: { [from]: Math.min(100, (s.hp[from] ?? 100) + 40) } });
|
|
64
|
+
}
|
|
65
|
+
if (name !== "swing" || (cool[from] || 0) > g.now) return false;
|
|
66
|
+
cool[from] = g.now + 0.45;
|
|
67
|
+
const mobs = {}; let kills = 0;
|
|
68
|
+
for (const [id, m] of Object.entries(s.mobs)) if (dist(m, me.state) < SWING) { const left = m.hp - 20; mobs[id] = left <= 0 ? null : { hp: left }; if (left <= 0) kills++; }
|
|
69
|
+
if (!Object.keys(mobs).length) return false;
|
|
70
|
+
g.shared.set({ mobs, score: { [from]: (s.score[from] || 0) + kills } });
|
|
71
|
+
},
|
|
72
|
+
onFx(g, from, name, d) { if (name === "swing" && swings.length < 20) swings.push({ x: d.x, y: d.y, t: performance.now() }); },
|
|
73
|
+
draw(g, ctx, w, h) {
|
|
74
|
+
const s = g.shared || {}, k = w / 700, me = g.players.find((p) => p.me);
|
|
75
|
+
const cx = Math.max(0, Math.min(W - 700, (me && me.state.x || 600) - 350)), cy = Math.max(0, Math.min(H - 700, (me && me.state.y || 600) - 350));
|
|
76
|
+
ctx.fillStyle = "#120d0a"; ctx.fillRect(0, 0, w, h);
|
|
77
|
+
ctx.save(); ctx.scale(k, k); ctx.translate(-cx, -cy);
|
|
78
|
+
ctx.fillStyle = "#1f1712"; for (let i = 0; i < W; i += 60) for (let j = (i / 60) % 2 * 60; j < H; j += 120) ctx.fillRect(i, j, 60, 60);
|
|
79
|
+
ctx.strokeStyle = "#78350f"; ctx.lineWidth = 8; ctx.strokeRect(4, 4, W - 8, H - 8);
|
|
80
|
+
for (const l of Object.values(s.loot || {})) { ctx.fillStyle = "#c026d3"; ctx.beginPath(); ctx.arc(l.x, l.y, 9, 0, 7); ctx.fill(); }
|
|
81
|
+
for (const m of Object.values(s.mobs || {})) { ctx.fillStyle = m.kind === "brute" ? "#b91c1c" : "#65a30d"; ctx.beginPath(); ctx.arc(m.x, m.y, m.kind === "brute" ? 20 : 13, 0, 7); ctx.fill(); bar(ctx, m.x - 14, m.y - 26, 28, m.hp / m.max); }
|
|
82
|
+
for (const p of g.players) {
|
|
83
|
+
if (typeof p.state.x !== "number") continue;
|
|
84
|
+
const fallen = s.down && s.down[p.id];
|
|
85
|
+
ctx.fillStyle = fallen ? "#57534e" : p.me ? "#fbbf24" : "#e7e5e4"; ctx.beginPath(); ctx.arc(p.state.x, p.state.y, 15, 0, 7); ctx.fill();
|
|
86
|
+
if (fallen) { ctx.strokeStyle = "#fbbf24"; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(p.state.x, p.state.y, 24, -Math.PI / 2, -Math.PI / 2 + Math.PI * ((s.lift && s.lift[p.id]) || 0)); ctx.stroke(); }
|
|
87
|
+
else bar(ctx, p.state.x - 18, p.state.y - 28, 36, ((s.hp && s.hp[p.id]) ?? 100) / 100);
|
|
88
|
+
ctx.fillStyle = "#e7e5e4"; ctx.font = "12px system-ui"; ctx.textAlign = "center"; ctx.fillText(p.name, p.state.x, p.state.y + 32);
|
|
89
|
+
}
|
|
90
|
+
const now = performance.now(); swings = swings.filter((sw) => now - sw.t < 220);
|
|
91
|
+
for (const sw of swings) { ctx.strokeStyle = "rgba(253,224,71,.7)"; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(sw.x, sw.y, SWING * (now - sw.t) / 220, 0, 7); ctx.stroke(); }
|
|
92
|
+
ctx.restore();
|
|
93
|
+
// torchlight: dark around the edges of the view
|
|
94
|
+
const grd = ctx.createRadialGradient(w / 2, h / 2, w * 0.25, w / 2, h / 2, w * 0.75); grd.addColorStop(0, "rgba(0,0,0,0)"); grd.addColorStop(1, "rgba(0,0,0,.7)"); ctx.fillStyle = grd; ctx.fillRect(0, 0, w, h);
|
|
95
|
+
ctx.fillStyle = "#fef3c7"; ctx.font = `${16 * k}px system-ui`; ctx.textAlign = "left";
|
|
96
|
+
ctx.fillText(`Wave ${s.wave || 1} of ${WAVES} · monsters ${Object.keys(s.mobs || {}).length}`, 10 * k, 22 * k);
|
|
97
|
+
if (s.result) { ctx.textAlign = "center"; ctx.font = `${30 * k}px system-ui`; ctx.fillText(s.result === "won" ? "The dungeon is cleared" : "Everyone fell", w / 2, h / 2); }
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
function spawn(wave) {
|
|
101
|
+
const mobs = {}, n = Math.min(30, 4 + wave * 4);
|
|
102
|
+
for (let i = 0; i < n; i++) {
|
|
103
|
+
const side = i % 4, r = Math.random() * 1100 + 50, brute = wave >= 3 && i % 5 === 0;
|
|
104
|
+
const p = side === 0 ? { x: r, y: 50 } : side === 1 ? { x: 1150, y: r } : side === 2 ? { x: r, y: 1150 } : { x: 50, y: r };
|
|
105
|
+
mobs[`m${++seq}${Math.random().toString(36).slice(2, 5)}`] = { ...p, kind: brute ? "brute" : "slime", hp: brute ? 80 : 30, max: brute ? 80 : 30, speed: brute ? 45 : 70 + wave * 5, bite: brute ? 20 : 8 };
|
|
106
|
+
}
|
|
107
|
+
return mobs;
|
|
108
|
+
}
|
|
109
|
+
function bar(ctx, x0, y0, w0, f) { ctx.fillStyle = "#292524"; ctx.fillRect(x0, y0, w0, 4); ctx.fillStyle = f > 0.5 ? "#22c55e" : f > 0.25 ? "#eab308" : "#ef4444"; ctx.fillRect(x0, y0, w0 * Math.max(0, Math.min(1, f)), 4); }
|
|
110
|
+
</script></body></html>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Neon Paint</title>
|
|
2
|
+
<style>html,body{margin:0;height:100%;background:#04050d;overflow:hidden}canvas{display:block;touch-action:none}</style></head><body>
|
|
3
|
+
<script src="lib:pixi"></script>
|
|
4
|
+
<script src="lib:room"></script>
|
|
5
|
+
<script src="lib:game"></script>
|
|
6
|
+
<script>
|
|
7
|
+
// Neon Paint, 1 to 8 people, made with PixiJS. Draw on one wall together: every stroke glows in your colour and fades after 8 seconds, and you
|
|
8
|
+
// see everyone's pen move live. Strokes travel as effects (g.fx: sent once, never stored), pens as each player's own state.
|
|
9
|
+
// PixiJS draws and reads the pointer; tickcli runs headless (the lobby over the page, the room).
|
|
10
|
+
const BW = 1000, BH = 625, FADE = 8, COLORS = [0x22d3ee, 0xf472b6, 0xa3e635, 0xfacc15, 0xc084fc, 0xfb923c, 0x2dd4bf, 0xf87171];
|
|
11
|
+
const g = Game({
|
|
12
|
+
headless: true, title: "Neon Paint", help: "Hold and drag to paint. Every stroke glows and fades after 8 seconds.",
|
|
13
|
+
multiplayer: { max: 8, player: { x: [0, BW], y: [0, BH], down: [0, 1] } },
|
|
14
|
+
onFx(g, from, name, d) { if (name === "ink" && Array.isArray(d)) paint(from, d); },
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
let app, wall, strokes, pens, scale = 1, ox = 0, oy = 0, last = null, sentAt = 0;
|
|
18
|
+
const colorOf = (id) => COLORS[Math.max(0, (g.players || []).findIndex((p) => p.id === id)) % COLORS.length];
|
|
19
|
+
/** One stroke segment [x0, y0, x1, y1, width] in board units: a wide faint line under a thin bright one, both added (they glow where they cross). */
|
|
20
|
+
function paint(from, d) {
|
|
21
|
+
if (!strokes) return;
|
|
22
|
+
const [x0, y0, x1, y1, w] = d.map(Number), c = colorOf(from), s = new PIXI.Graphics();
|
|
23
|
+
s.moveTo(x0, y0).lineTo(x1, y1).stroke({ width: w * 3.2, color: c, alpha: 0.18, cap: "round" });
|
|
24
|
+
s.moveTo(x0, y0).lineTo(x1, y1).stroke({ width: w, color: c, alpha: 0.95, cap: "round" });
|
|
25
|
+
s.moveTo(x0, y0).lineTo(x1, y1).stroke({ width: Math.max(1, w * 0.3), color: 0xffffff, alpha: 0.8, cap: "round" });
|
|
26
|
+
s.blendMode = "add"; s.born = performance.now();
|
|
27
|
+
strokes.addChild(s);
|
|
28
|
+
if (strokes.children.length > 1500) strokes.removeChildAt(0).destroy();
|
|
29
|
+
}
|
|
30
|
+
(async () => {
|
|
31
|
+
app = new PIXI.Application();
|
|
32
|
+
await app.init({ resizeTo: window, background: "#04050d", antialias: true });
|
|
33
|
+
document.body.appendChild(app.canvas);
|
|
34
|
+
wall = new PIXI.Container(); app.stage.addChild(wall);
|
|
35
|
+
// the wall: a faint grid and a soft frame
|
|
36
|
+
const grid = new PIXI.Graphics();
|
|
37
|
+
for (let x = 0; x <= BW; x += 50) grid.moveTo(x, 0).lineTo(x, BH);
|
|
38
|
+
for (let y = 0; y <= BH; y += 50) grid.moveTo(0, y).lineTo(BW, y);
|
|
39
|
+
grid.stroke({ width: 1, color: 0x1e293b, alpha: 0.6 });
|
|
40
|
+
grid.roundRect(0, 0, BW, BH, 18).stroke({ width: 2, color: 0x334155, alpha: 0.9 });
|
|
41
|
+
wall.addChild(grid);
|
|
42
|
+
strokes = new PIXI.Container(); wall.addChild(strokes);
|
|
43
|
+
pens = new PIXI.Container(); wall.addChild(pens);
|
|
44
|
+
const fit = () => { scale = Math.min(app.screen.width / BW, app.screen.height / BH) * 0.94; ox = (app.screen.width - BW * scale) / 2; oy = (app.screen.height - BH * scale) / 2; wall.scale.set(scale); wall.position.set(ox, oy); };
|
|
45
|
+
fit(); addEventListener("resize", fit);
|
|
46
|
+
const toBoard = (e) => ({ x: Math.max(0, Math.min(BW, (e.global.x - ox) / scale)), y: Math.max(0, Math.min(BH, (e.global.y - oy) / scale)) });
|
|
47
|
+
app.stage.eventMode = "static"; app.stage.hitArea = app.screen;
|
|
48
|
+
app.stage.on("pointerdown", (e) => { last = toBoard(e); g.me.set({ x: last.x, y: last.y, down: 1 }); });
|
|
49
|
+
app.stage.on("pointermove", (e) => {
|
|
50
|
+
const p = toBoard(e); g.me.set({ x: p.x, y: p.y, down: last ? 1 : 0 });
|
|
51
|
+
if (!last || g.state !== "playing") return;
|
|
52
|
+
const now = performance.now();
|
|
53
|
+
if (now - sentAt < 55 || Math.hypot(p.x - last.x, p.y - last.y) < 3) return; // about 18 segments a second at most
|
|
54
|
+
sentAt = now;
|
|
55
|
+
g.fx("ink", [Math.round(last.x), Math.round(last.y), Math.round(p.x), Math.round(p.y), 6]);
|
|
56
|
+
last = p;
|
|
57
|
+
});
|
|
58
|
+
const up = () => { last = null; g.me.set({ down: 0 }); };
|
|
59
|
+
app.stage.on("pointerup", up); app.stage.on("pointerupoutside", up);
|
|
60
|
+
app.ticker.add(() => {
|
|
61
|
+
const now = performance.now();
|
|
62
|
+
for (let i = strokes.children.length - 1; i >= 0; i--) {
|
|
63
|
+
const s = strokes.children[i], age = (now - s.born) / 1000;
|
|
64
|
+
if (age > FADE) { strokes.removeChildAt(i).destroy(); continue; }
|
|
65
|
+
s.alpha = age < FADE - 2 ? 1 : (FADE - age) / 2;
|
|
66
|
+
}
|
|
67
|
+
// everyone else's pen: a glowing ring with their name, bigger while they draw
|
|
68
|
+
g.sync((p) => {
|
|
69
|
+
const pen = new PIXI.Container(), ring = new PIXI.Graphics(), c = colorOf(p.id);
|
|
70
|
+
ring.circle(0, 0, 16).fill({ color: c, alpha: 0.18 }).circle(0, 0, 7).fill({ color: c, alpha: 0.9 }).circle(0, 0, 3).fill({ color: 0xffffff });
|
|
71
|
+
ring.blendMode = "add";
|
|
72
|
+
const name = new PIXI.Text({ text: p.name, style: { fill: c, fontSize: 15, fontFamily: "system-ui, sans-serif", fontWeight: "600" } });
|
|
73
|
+
name.position.set(14, -26); pen.addChild(ring, name); pens.addChild(pen); pen.ring = ring;
|
|
74
|
+
return pen;
|
|
75
|
+
}, (pen, st) => { if (typeof st.x === "number") pen.position.set(st.x, st.y); pen.ring.scale.set(st.down ? 1.35 : 1); }, (pen) => pen.destroy({ children: true }));
|
|
76
|
+
});
|
|
77
|
+
})();
|
|
78
|
+
</script>
|
|
79
|
+
</body></html>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Own Engine</title>
|
|
2
|
+
<style>html,body{margin:0;height:100%;background:#f1f5f9;font-family:system-ui,sans-serif}canvas{display:block;width:100vw;height:100vh;touch-action:none}</style></head><body>
|
|
3
|
+
<canvas id="stage"></canvas>
|
|
4
|
+
<script src="lib:room"></script>
|
|
5
|
+
<script src="lib:game"></script>
|
|
6
|
+
<script>
|
|
7
|
+
// A page with its own engine: its own canvas, its own requestAnimationFrame loop and its own input (here plain canvas code, the same shape as a
|
|
8
|
+
// Phaser scene's update() or a PixiJS ticker). tickcli runs headless: the lobby shows over this page, and the room is a few lines in the loop.
|
|
9
|
+
// Click or tap anywhere to walk there; everyone sees everyone.
|
|
10
|
+
const cv = document.getElementById("stage"), ctx = cv.getContext("2d"), W = 800, H = 600;
|
|
11
|
+
const me = { x: 400, y: 300, tx: 400, ty: 300 };
|
|
12
|
+
|
|
13
|
+
const g = Game({
|
|
14
|
+
headless: true, title: "Own Engine", help: "Click or tap to walk there.",
|
|
15
|
+
multiplayer: { max: 8, player: { x: [0, W], y: [0, H] } },
|
|
16
|
+
setup(g) { me.x = me.tx = 100 + Math.random() * 600; me.y = me.ty = 100 + Math.random() * 400; g.me.set({ x: me.x, y: me.y }); },
|
|
17
|
+
host(g) { if (!g.shared.started) g.shared.set({ started: Math.round(g.now) }); },
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// ---- the engine: none of this knows about rooms except the two marked lines
|
|
21
|
+
cv.addEventListener("pointerdown", (e) => { const r = cv.getBoundingClientRect(); me.tx = (e.clientX - r.left) / r.width * W; me.ty = (e.clientY - r.top) / r.height * H; });
|
|
22
|
+
let last = 0;
|
|
23
|
+
function frame(t) {
|
|
24
|
+
const dt = Math.min(0.05, (t - (last || t)) / 1000); last = t;
|
|
25
|
+
const dpr = devicePixelRatio || 1; if (cv.width !== Math.round(innerWidth * dpr)) { cv.width = Math.round(innerWidth * dpr); cv.height = Math.round(innerHeight * dpr); }
|
|
26
|
+
const dx = me.tx - me.x, dy = me.ty - me.y, d = Math.hypot(dx, dy), step = Math.min(d, 260 * dt);
|
|
27
|
+
if (d > 0.5) { me.x += dx / d * step; me.y += dy / d * step; }
|
|
28
|
+
if (g.state === "playing") g.me.set({ x: me.x, y: me.y }); // room line 1: share where I am
|
|
29
|
+
const others = g.sync((p) => ({ name: p.name }), (o, st) => { o.x = st.x; o.y = st.y; }, () => {}); // room line 2: the others, eased
|
|
30
|
+
window.drawnOthers = others.size;
|
|
31
|
+
ctx.setTransform(cv.width / W, 0, 0, cv.height / H, 0, 0);
|
|
32
|
+
ctx.fillStyle = "#e2e8f0"; ctx.fillRect(0, 0, W, H);
|
|
33
|
+
const dot = (x, y, name, fill) => { if (typeof x !== "number") return; ctx.fillStyle = fill; ctx.beginPath(); ctx.arc(x, y, 16, 0, Math.PI * 2); ctx.fill();
|
|
34
|
+
ctx.fillStyle = "#0f172a"; ctx.font = "14px system-ui"; ctx.textAlign = "center"; ctx.fillText(name, x, y - 24); };
|
|
35
|
+
for (const o of others.values()) dot(o.x, o.y, o.name, "#dc2626");
|
|
36
|
+
if (g.state === "playing") dot(me.x, me.y, g.me.name || "You", "#2563eb");
|
|
37
|
+
requestAnimationFrame(frame);
|
|
38
|
+
}
|
|
39
|
+
requestAnimationFrame(frame);
|
|
40
|
+
</script>
|
|
41
|
+
</body></html>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Loop Dash</title></head><body>
|
|
2
|
+
<script src="lib:room"></script>
|
|
3
|
+
<script src="lib:game"></script>
|
|
4
|
+
<script>
|
|
5
|
+
// Top-down car race for 1 to 8 players: 3 laps round a loop, a 3-2-1 start on the shared clock, a camera that follows your car.
|
|
6
|
+
// Each player drives their own car (position, angle, lap and checkpoint are their own state); the host only watches the laps and names the winner.
|
|
7
|
+
const W = 2400, H = 1600, LAPS = 3, ROAD = 100;
|
|
8
|
+
const P = [[420, 300], [1200, 230], [1980, 300], [2170, 800], [1980, 1300], [1200, 1370], [420, 1300], [230, 800]]; // the loop's middle line; also the checkpoints
|
|
9
|
+
const COLORS = ["#60a5fa", "#f87171", "#34d399", "#fbbf24", "#c084fc", "#fb923c", "#22d3ee", "#f472b6"];
|
|
10
|
+
let x, y, angle, speed, lap, cp, boost;
|
|
11
|
+
function offRoad(px, py) {
|
|
12
|
+
let best = 1e9;
|
|
13
|
+
for (let i = 0; i < P.length; i++) {
|
|
14
|
+
const a = P[i], b = P[(i + 1) % P.length], dx = b[0] - a[0], dy = b[1] - a[1];
|
|
15
|
+
const t = Math.max(0, Math.min(1, ((px - a[0]) * dx + (py - a[1]) * dy) / (dx * dx + dy * dy)));
|
|
16
|
+
best = Math.min(best, Math.hypot(px - a[0] - dx * t, py - a[1] - dy * t));
|
|
17
|
+
}
|
|
18
|
+
return best > ROAD;
|
|
19
|
+
}
|
|
20
|
+
const slotOf = (g) => Math.max(0, g.players.map((p) => p.id).sort().indexOf(g.me.id));
|
|
21
|
+
const colorOf = (g, id) => COLORS[Math.max(0, g.players.map((p) => p.id).sort().indexOf(id)) % COLORS.length];
|
|
22
|
+
const progress = (st) => (st.lap || 0) * 100 + ((((st.cp || 1) + P.length - 1) % P.length));
|
|
23
|
+
Game({
|
|
24
|
+
title: "Loop Dash", help: "Up to drive, left and right to steer, down to brake, Shift (or Boost) for a short boost. First to finish 3 laps wins.",
|
|
25
|
+
size: [800, 600], world: [W, H], buttons: [{ id: "boost", label: "Boost", key: "Shift" }],
|
|
26
|
+
multiplayer: { max: 8, countdown: 3, player: { x: [0, W], y: [0, H], angle: [-4, 4], lap: [0, 9], cp: [0, 8] } },
|
|
27
|
+
setup(g) {
|
|
28
|
+
const s = slotOf(g); // a start grid: two cars a row, behind the line
|
|
29
|
+
x = 700 - (s >> 1) * 80; y = 300 + (s & 1 ? 44 : -36) - (x - 420) * 0.09; angle = -0.09; speed = 0; lap = 0; cp = 1; boost = 1;
|
|
30
|
+
const ahead = g.late && g.players.filter((p) => !p.me && p.online && typeof p.state.x === "number" && typeof p.state.angle === "number");
|
|
31
|
+
if (ahead && ahead.length) { // came into a race already running: start just behind a car on the road, on its lap, so you race it on screen
|
|
32
|
+
const t = ahead[Math.floor(Math.random() * ahead.length)].state;
|
|
33
|
+
x = t.x - Math.cos(t.angle) * 70; y = t.y - Math.sin(t.angle) * 70; angle = t.angle; lap = t.lap || 0; cp = t.cp || 1;
|
|
34
|
+
}
|
|
35
|
+
g.me.set({ x, y, angle, lap, cp }); g.camera.follow(x, y);
|
|
36
|
+
if (g.isHost) g.shared.set({ winner: "", score: {} });
|
|
37
|
+
},
|
|
38
|
+
update(g, dt) {
|
|
39
|
+
const a = g.axis(), fast = g.held.boost && boost > 0 && speed > 50;
|
|
40
|
+
if (g.shared.winner) speed *= 0.97;
|
|
41
|
+
else speed += ((g.held.up ? 420 : 0) - (g.held.down ? 520 : 0) + (fast ? 380 : 0)) * dt;
|
|
42
|
+
boost = Math.max(0, Math.min(1, boost + (fast ? -0.6 : 0.12) * dt));
|
|
43
|
+
const top = (offRoad(x, y) ? 150 : 430) + (fast ? 170 : 0);
|
|
44
|
+
speed = Math.max(-80, Math.min(top, speed - speed * 0.6 * dt));
|
|
45
|
+
angle += a.x * 2.6 * dt * Math.min(1, Math.abs(speed) / 140) * (speed < 0 ? -1 : 1);
|
|
46
|
+
if (angle > Math.PI) angle -= 2 * Math.PI; else if (angle < -Math.PI) angle += 2 * Math.PI;
|
|
47
|
+
x = Math.max(20, Math.min(W - 20, x + Math.cos(angle) * speed * dt)); y = Math.max(20, Math.min(H - 20, y + Math.sin(angle) * speed * dt));
|
|
48
|
+
if (Math.hypot(x - P[cp][0], y - P[cp][1]) < 230) { cp = (cp + 1) % P.length; if (cp === 1) lap++; }
|
|
49
|
+
g.me.set({ x, y, angle, lap, cp }); g.camera.follow(x, y);
|
|
50
|
+
},
|
|
51
|
+
host(g) {
|
|
52
|
+
const s = g.shared; if (s.winner) { if (g.now > s.endAt) g.gameOver(s.winner); return; }
|
|
53
|
+
const score = {}; let first = null;
|
|
54
|
+
for (const p of g.players) { score[p.id] = progress(p.state); if ((p.state.lap || 0) >= LAPS && (!first || score[p.id] > score[first])) first = p.id; }
|
|
55
|
+
if (first) g.shared.set({ winner: first, endAt: g.now + 3, score });
|
|
56
|
+
},
|
|
57
|
+
draw(g, ctx) {
|
|
58
|
+
ctx.fillStyle = "#14532d"; ctx.fillRect(0, 0, W, H);
|
|
59
|
+
const road = (width, style, dash) => { ctx.beginPath(); P.forEach((p, i) => (i ? ctx.lineTo(p[0], p[1]) : ctx.moveTo(p[0], p[1]))); ctx.closePath(); ctx.lineJoin = "round"; ctx.lineWidth = width; ctx.strokeStyle = style; ctx.setLineDash(dash || []); ctx.stroke(); ctx.setLineDash([]); };
|
|
60
|
+
road(ROAD * 2 + 16, "#f8fafc"); road(ROAD * 2, "#334155"); road(4, "#94a3b8", [28, 28]);
|
|
61
|
+
ctx.fillStyle = "#f8fafc"; for (let i = 0; i < 10; i++) for (let j = 0; j < 2; j++) if ((i + j) % 2) ctx.fillRect(760 + j * 14, 170 + i * 20, 14, 20); // start and finish line
|
|
62
|
+
for (const p of g.players) {
|
|
63
|
+
const st = p.state; if (typeof st.x !== "number") continue;
|
|
64
|
+
ctx.save(); ctx.translate(st.x, st.y); ctx.rotate(st.angle || 0);
|
|
65
|
+
ctx.fillStyle = colorOf(g, p.id); ctx.fillRect(-17, -10, 34, 20); ctx.fillStyle = "#0f172a"; ctx.fillRect(2, -8, 9, 16);
|
|
66
|
+
ctx.restore();
|
|
67
|
+
ctx.fillStyle = "#f8fafc"; ctx.font = "13px system-ui"; ctx.textAlign = "center"; ctx.fillText(p.me ? "You" : p.name, st.x, st.y - 22);
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
overlay(g, ctx, w, h) {
|
|
71
|
+
const order = g.players.slice().sort((a, b) => progress(b.state) - progress(a.state)), place = order.findIndex((p) => p.me) + 1;
|
|
72
|
+
ctx.fillStyle = "rgba(8,12,24,.7)"; ctx.fillRect(8, 8, 168, 58); ctx.fillStyle = "#f8fafc"; ctx.textAlign = "left"; ctx.textBaseline = "alphabetic";
|
|
73
|
+
ctx.font = "700 20px system-ui"; ctx.fillText(`Lap ${Math.min(LAPS, lap + 1)} of ${LAPS}`, 18, 33);
|
|
74
|
+
ctx.font = "14px system-ui"; ctx.fillText(`Place ${place} of ${order.length}`, 18, 55);
|
|
75
|
+
ctx.fillStyle = "rgba(148,163,184,.3)"; ctx.fillRect(w - 128, h - 22, 112, 10); ctx.fillStyle = "#fbbf24"; ctx.fillRect(w - 128, h - 22, 112 * boost, 10);
|
|
76
|
+
const k = 0.06, mx = w - 8 - W * k, my = 8; // mini map
|
|
77
|
+
ctx.fillStyle = "rgba(8,12,24,.7)"; ctx.fillRect(mx, my, W * k, H * k);
|
|
78
|
+
ctx.beginPath(); P.forEach((p, i) => (i ? ctx.lineTo(mx + p[0] * k, my + p[1] * k) : ctx.moveTo(mx + p[0] * k, my + p[1] * k))); ctx.closePath(); ctx.strokeStyle = "#94a3b8"; ctx.lineWidth = 5; ctx.stroke();
|
|
79
|
+
for (const p of g.players) if (typeof p.state.x === "number") { ctx.fillStyle = colorOf(g, p.id); ctx.beginPath(); ctx.arc(mx + p.state.x * k, my + p.state.y * k, p.me ? 4 : 3, 0, 7); ctx.fill(); }
|
|
80
|
+
if (g.shared.winner) { const p = g.players.find((q) => q.id === g.shared.winner); ctx.fillStyle = "rgba(8,12,24,.8)"; ctx.fillRect(0, h / 2 - 34, w, 68); ctx.fillStyle = "#f8fafc"; ctx.font = "700 28px system-ui"; ctx.textAlign = "center"; ctx.fillText((p ? (p.me ? "You" : p.name) : "A driver") + (p && p.me ? " win!" : " wins!"), w / 2, h / 2 + 10); }
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
</script></body></html>
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Star Skirmish</title></head><body>
|
|
2
|
+
<script src="lib:room"></script>
|
|
3
|
+
<script src="lib:game"></script>
|
|
4
|
+
<script>
|
|
5
|
+
// Space shooter for 1 to 8 players, everyone for themselves. Fly, turn and fire; three hits take a ship out for 3 seconds; first to 5 wins.
|
|
6
|
+
// Shots are g.shoot(): sent once, then every screen moves them by itself, so there can be many. The host decides what they hit (g.hit) and keeps
|
|
7
|
+
// health, respawns and the score in g.shared. Drifting rocks are the host's too.
|
|
8
|
+
const W = 2000, H = 1400, GOAL = 5, COLORS = ["#60a5fa", "#f87171", "#34d399", "#fbbf24", "#c084fc", "#fb923c", "#22d3ee", "#f472b6"];
|
|
9
|
+
let x, y, vx, vy, angle, cool, sparks, wasDead, seq = 0;
|
|
10
|
+
const stars = Array.from({ length: 140 }, (_, i) => ({ x: (i * 7919) % W, y: (i * 104729) % H, r: 1 + (i % 3) * 0.6 }));
|
|
11
|
+
const colorOf = (g, id) => COLORS[Math.max(0, g.players.map((p) => p.id).sort().indexOf(id)) % COLORS.length];
|
|
12
|
+
const isDead = (g, id) => !!(g.shared.dead && g.shared.dead[id]);
|
|
13
|
+
function spawn(g) { ({ x, y } = g.spawnNear({ x: [120, W - 120], y: [120, H - 120], gap: 170, skip: (p) => isDead(g, p.id) })); vx = vy = 0; // where the others are on screen
|
|
14
|
+
angle = Math.random() * 6.28 - 3.14; g.me.set({ x, y, angle }); }
|
|
15
|
+
function fire(g) {
|
|
16
|
+
if (cool > 0 || isDead(g, g.me.id)) return;
|
|
17
|
+
if (g.shoot({ x: x + Math.cos(angle) * 22, y: y + Math.sin(angle) * 22, vx: Math.cos(angle) * 620 + vx, vy: Math.sin(angle) * 620 + vy, life: 1.4, r: 4, kind: "laser" })) cool = 0.22;
|
|
18
|
+
}
|
|
19
|
+
Game({
|
|
20
|
+
title: "Star Skirmish", help: "Left and right to turn, up to fly, Space (or Fire) to shoot. Three hits take a ship out. First to 5 wins.",
|
|
21
|
+
size: [800, 560], world: [W, H], buttons: [{ id: "fire", label: "Fire", key: "f" }],
|
|
22
|
+
multiplayer: { max: 8, countdown: 3, player: { x: [0, W], y: [0, H], angle: [-4, 4] } },
|
|
23
|
+
setup(g) { cool = 0; sparks = []; wasDead = false; spawn(g); g.camera.follow(x, y); },
|
|
24
|
+
update(g, dt) {
|
|
25
|
+
cool -= dt;
|
|
26
|
+
const dead = isDead(g, g.me.id);
|
|
27
|
+
if (dead) { wasDead = true; return; }
|
|
28
|
+
if (wasDead) { wasDead = false; spawn(g); } // back in: next to someone still flying
|
|
29
|
+
const a = g.axis();
|
|
30
|
+
angle += a.x * 3.4 * dt; if (angle > Math.PI) angle -= 2 * Math.PI; else if (angle < -Math.PI) angle += 2 * Math.PI;
|
|
31
|
+
if (g.held.up) { vx += Math.cos(angle) * 520 * dt; vy += Math.sin(angle) * 520 * dt; }
|
|
32
|
+
vx -= vx * 0.9 * dt; vy -= vy * 0.9 * dt;
|
|
33
|
+
x += vx * dt; y += vy * dt;
|
|
34
|
+
if (x < 20 || x > W - 20) { vx = -vx * 0.5; x = Math.max(20, Math.min(W - 20, x)); }
|
|
35
|
+
if (y < 20 || y > H - 20) { vy = -vy * 0.5; y = Math.max(20, Math.min(H - 20, y)); }
|
|
36
|
+
if (g.held.fire) fire(g);
|
|
37
|
+
g.me.set({ x, y, angle }); g.camera.follow(x, y);
|
|
38
|
+
},
|
|
39
|
+
onInput(g, a) { if (a === "action" || a === "fire") fire(g); },
|
|
40
|
+
host(g, dt) {
|
|
41
|
+
const s = g.shared;
|
|
42
|
+
if (!s.hp) { const rocks = {}; for (let i = 0; i < 10; i++) rocks["r" + (++seq) + Math.random().toString(36).slice(2, 5)] = { x: 150 + Math.random() * (W - 300), y: 150 + Math.random() * (H - 300), dx: Math.random() * 60 - 30, dy: Math.random() * 60 - 30, r: 26 + Math.random() * 22 }; return g.shared.set({ hp: {}, dead: {}, score: {}, rocks, winner: "" }); }
|
|
43
|
+
if (s.winner) { if (g.now > s.endAt) g.gameOver(s.winner); return; }
|
|
44
|
+
const hp = {}, dead = {}, score = {}, rocks = {};
|
|
45
|
+
for (const [id, r] of Object.entries(s.rocks || {})) { // rocks drift and bounce off the edges
|
|
46
|
+
let nx = r.x + r.dx * dt, ny = r.y + r.dy * dt, dx = r.dx, dy = r.dy;
|
|
47
|
+
if (nx < r.r || nx > W - r.r) dx = -dx; if (ny < r.r || ny > H - r.r) dy = -dy;
|
|
48
|
+
rocks[id] = { x: nx, y: ny, dx, dy };
|
|
49
|
+
}
|
|
50
|
+
for (const id in s.dead) if (g.now > s.dead[id]) { dead[id] = null; hp[id] = 3; }
|
|
51
|
+
for (const shot of g.shots.slice()) {
|
|
52
|
+
let gone = Object.values(s.rocks || {}).some((r) => Math.hypot(r.x - shot.x, r.y - shot.y) < r.r + shot.r);
|
|
53
|
+
for (const p of g.players) {
|
|
54
|
+
if (gone || p.id === shot.from || s.dead[p.id] || dead[p.id] || typeof p.state.x !== "number" || Math.hypot(p.state.x - shot.x, p.state.y - shot.y) > 18 + shot.r) continue;
|
|
55
|
+
gone = true;
|
|
56
|
+
const left = (hp[p.id] !== undefined ? hp[p.id] : s.hp[p.id] !== undefined ? s.hp[p.id] : 3) - 1;
|
|
57
|
+
hp[p.id] = left; g.fx("spark", { x: Math.round(shot.x), y: Math.round(shot.y) });
|
|
58
|
+
if (left <= 0) { dead[p.id] = Math.round((g.now + 3) * 100) / 100; score[shot.from] = (score[shot.from] !== undefined ? score[shot.from] : s.score[shot.from] || 0) + 1; }
|
|
59
|
+
}
|
|
60
|
+
if (gone) g.hit(shot);
|
|
61
|
+
}
|
|
62
|
+
const patch = { rocks, hp, dead, score };
|
|
63
|
+
const top = Object.entries({ ...s.score, ...score }).sort((a, b) => b[1] - a[1])[0];
|
|
64
|
+
if (top && top[1] >= GOAL) { patch.winner = top[0]; patch.endAt = g.now + 3; }
|
|
65
|
+
g.shared.set(patch);
|
|
66
|
+
},
|
|
67
|
+
onFx(g, from, name, d) { if (name === "spark" && d) for (let i = 0; i < 8 && sparks.length < 80; i++) sparks.push({ x: d.x, y: d.y, dx: Math.random() * 240 - 120, dy: Math.random() * 240 - 120, t: 0.4 }); },
|
|
68
|
+
draw(g, ctx) {
|
|
69
|
+
const s = g.shared;
|
|
70
|
+
ctx.fillStyle = "#020617"; ctx.fillRect(0, 0, W, H);
|
|
71
|
+
ctx.fillStyle = "#94a3b8"; for (const st of stars) ctx.fillRect(st.x, st.y, st.r, st.r);
|
|
72
|
+
ctx.strokeStyle = "#334155"; ctx.lineWidth = 6; ctx.strokeRect(3, 3, W - 6, H - 6);
|
|
73
|
+
for (const r of Object.values(s.rocks || {})) { ctx.fillStyle = "#475569"; ctx.beginPath(); ctx.arc(r.x, r.y, r.r, 0, 7); ctx.fill(); ctx.strokeStyle = "#64748b"; ctx.lineWidth = 3; ctx.stroke(); }
|
|
74
|
+
for (const shot of g.shots) { ctx.fillStyle = colorOf(g, shot.from); ctx.beginPath(); ctx.arc(shot.x, shot.y, shot.r, 0, 7); ctx.fill(); }
|
|
75
|
+
for (const p of g.players) {
|
|
76
|
+
const st = p.state; if (typeof st.x !== "number" || isDead(g, p.id)) continue;
|
|
77
|
+
ctx.save(); ctx.translate(st.x, st.y); ctx.rotate(st.angle || 0);
|
|
78
|
+
ctx.fillStyle = colorOf(g, p.id); ctx.beginPath(); ctx.moveTo(20, 0); ctx.lineTo(-14, -12); ctx.lineTo(-8, 0); ctx.lineTo(-14, 12); ctx.closePath(); ctx.fill();
|
|
79
|
+
ctx.restore();
|
|
80
|
+
const hp = s.hp && s.hp[p.id] !== undefined ? s.hp[p.id] : 3;
|
|
81
|
+
ctx.fillStyle = "#f8fafc"; ctx.font = "13px system-ui"; ctx.textAlign = "center"; ctx.fillText(p.me ? "You" : p.name, st.x, st.y - 34);
|
|
82
|
+
for (let i = 0; i < 3; i++) { ctx.fillStyle = i < hp ? "#34d399" : "rgba(148,163,184,.35)"; ctx.fillRect(st.x - 17 + i * 12, st.y - 28, 10, 4); }
|
|
83
|
+
}
|
|
84
|
+
for (let i = sparks.length - 1; i >= 0; i--) { const k = sparks[i]; k.t -= 1 / 60; k.x += k.dx / 60; k.y += k.dy / 60; if (k.t <= 0) { sparks.splice(i, 1); continue; } ctx.fillStyle = "#fbbf24"; ctx.fillRect(k.x, k.y, 3, 3); }
|
|
85
|
+
},
|
|
86
|
+
overlay(g, ctx, w, h) {
|
|
87
|
+
const s = g.shared, rows = g.players.map((p) => ({ p, n: (s.score && s.score[p.id]) || 0 })).sort((a, b) => b.n - a.n).slice(0, 5);
|
|
88
|
+
ctx.fillStyle = "rgba(8,12,24,.7)"; ctx.fillRect(8, 8, 170, 14 + rows.length * 18); ctx.font = "14px system-ui"; ctx.textAlign = "left"; ctx.textBaseline = "alphabetic";
|
|
89
|
+
rows.forEach((r, i) => { ctx.fillStyle = colorOf(g, r.p.id); ctx.fillText(`${r.n} ${r.p.me ? "You" : r.p.name}`.slice(0, 22), 16, 28 + i * 18); });
|
|
90
|
+
const banner = (t) => { ctx.fillStyle = "rgba(8,12,24,.8)"; ctx.fillRect(0, h / 2 - 34, w, 68); ctx.fillStyle = "#f8fafc"; ctx.font = "700 26px system-ui"; ctx.textAlign = "center"; ctx.fillText(t, w / 2, h / 2 + 9); };
|
|
91
|
+
if (s.winner) { const p = g.players.find((q) => q.id === s.winner); banner(p && p.me ? "You win!" : (p ? p.name : "A pilot") + " wins!"); }
|
|
92
|
+
else if (isDead(g, g.me.id)) banner("Back in " + Math.max(1, Math.ceil(s.dead[g.me.id] - g.now)));
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
</script></body></html>
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Sky Duel</title>
|
|
2
|
+
<style>html,body{margin:0;height:100%;background:#060814;overflow:hidden}#stage{position:fixed;inset:0}</style></head><body>
|
|
3
|
+
<div id="stage"></div>
|
|
4
|
+
<script src="lib:phaser"></script>
|
|
5
|
+
<script src="lib:room"></script>
|
|
6
|
+
<script src="lib:game"></script>
|
|
7
|
+
<script>
|
|
8
|
+
// Sky Duel, 1 to 8 pilots, made with Phaser. Drones pour down from the top; shoot them for points, dodge them or lose points. 90 seconds, best
|
|
9
|
+
// score wins. Phaser draws, moves and reads the keys; tickcli runs headless: the lobby over the page, the room, the host's drones and score.
|
|
10
|
+
// Keys: arrows or WASD to fly, Space to fire. Touch: hold a finger where you want to fly; you fire while you hold.
|
|
11
|
+
const W = 800, H = 600, ROUND = 90, COLORS = [0x38bdf8, 0xf472b6, 0xa3e635, 0xfbbf24, 0xc084fc, 0x2dd4bf, 0xfb7185, 0xf97316];
|
|
12
|
+
const me = { x: 400, y: 520 };
|
|
13
|
+
let scene = null;
|
|
14
|
+
|
|
15
|
+
const g = Game({
|
|
16
|
+
headless: true, title: "Sky Duel", help: "Arrows or WASD to fly, Space to fire (on a phone: hold where you want to fly). Shoot the drones, dodge them. 90 seconds.",
|
|
17
|
+
multiplayer: { max: 8, countdown: 3, player: { x: [0, W], y: [0, H], tilt: [-1, 1] } },
|
|
18
|
+
setup(g) { me.x = 120 + Math.random() * 560; me.y = 520; g.me.set({ x: me.x, y: me.y, tilt: 0 }); if (scene) scene.reset(); },
|
|
19
|
+
host(g, dt) {
|
|
20
|
+
const s = g.shared;
|
|
21
|
+
if (!s.endsAt) return g.shared.set({ endsAt: g.now + ROUND, mobs: {}, score: {} });
|
|
22
|
+
if (g.now >= s.endsAt) return g.gameOver();
|
|
23
|
+
const mobs = s.mobs || {}, patch = {}, score = {}, live = Object.keys(mobs).length, t = ROUND - (s.endsAt - g.now);
|
|
24
|
+
// more drones, faster, as the round goes on
|
|
25
|
+
if (live < Math.min(18, 5 + t / 8) && Math.random() < dt * (1.2 + t / 30)) {
|
|
26
|
+
const id = "d" + Math.random().toString(36).slice(2, 7), big = Math.random() < 0.25;
|
|
27
|
+
patch[id] = { x: Math.round(40 + Math.random() * (W - 80)), y: -30, vx: Math.round((Math.random() - 0.5) * 80), vy: Math.round(70 + Math.random() * 60 + t), hp: big ? 3 : 1, big: big ? 1 : 0, rot: 0 };
|
|
28
|
+
}
|
|
29
|
+
const gone = {};
|
|
30
|
+
for (const id in mobs) {
|
|
31
|
+
const m = mobs[id], x = m.x + m.vx * dt, y = m.y + m.vy * dt;
|
|
32
|
+
if (y > H + 40) { patch[id] = null; gone[id] = 1; continue; }
|
|
33
|
+
patch[id] = { x: x < 20 || x > W - 20 ? m.x : x, y, vx: x < 20 || x > W - 20 ? -m.vx : m.vx, rot: (m.rot || 0) + dt * 2 };
|
|
34
|
+
}
|
|
35
|
+
const add = (id, n) => { score[id] = Math.max(0, (score[id] ?? (s.score && s.score[id]) ?? 0) + n); };
|
|
36
|
+
for (const shot of g.shots) for (const id in mobs) {
|
|
37
|
+
const m = mobs[id]; if (gone[id] || Math.hypot(shot.x - m.x, shot.y - m.y) > (m.big ? 30 : 20)) continue;
|
|
38
|
+
g.hit(shot);
|
|
39
|
+
if (m.hp > 1) { patch[id] = Object.assign(patch[id] || {}, { hp: m.hp - 1 }); g.fx("spark", { x: Math.round(m.x), y: Math.round(m.y) }); }
|
|
40
|
+
else { patch[id] = null; gone[id] = 1; add(shot.from, m.big ? 30 : 10); g.fx("boom", { x: Math.round(m.x), y: Math.round(m.y), big: m.big, by: shot.from, pts: m.big ? 30 : 10 }); }
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
for (const p of g.players) for (const id in mobs) {
|
|
44
|
+
const m = mobs[id]; if (gone[id] || typeof p.state.x !== "number" || Math.hypot(p.state.x - m.x, p.state.y - m.y) > 30) continue;
|
|
45
|
+
patch[id] = null; gone[id] = 1; add(p.id, -15); g.fx("crash", { x: Math.round(m.x), y: Math.round(m.y), who: p.id });
|
|
46
|
+
}
|
|
47
|
+
g.shared.set({ mobs: patch, score });
|
|
48
|
+
},
|
|
49
|
+
onFx(g, from, name, d) { if (scene) scene.fx(name, d); },
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// ---- Phaser: everything you see. Textures are drawn in code (no image files).
|
|
53
|
+
class Sky extends Phaser.Scene {
|
|
54
|
+
create() {
|
|
55
|
+
scene = this;
|
|
56
|
+
const tex = (key, w, h, draw) => { const gr = this.make.graphics({ x: 0, y: 0 }, false); draw(gr); gr.generateTexture(key, w, h); gr.destroy(); };
|
|
57
|
+
tex("ship", 44, 44, (gr) => { gr.fillStyle(0xffffff); gr.fillTriangle(22, 2, 40, 38, 22, 30); gr.fillTriangle(22, 2, 4, 38, 22, 30); gr.fillStyle(0xcbd5e1); gr.fillRect(19, 26, 6, 14); });
|
|
58
|
+
tex("glow", 64, 64, (gr) => { for (let r = 32; r > 0; r -= 2) { gr.fillStyle(0xffffff, 0.05 + (32 - r) / 400); gr.fillCircle(32, 32, r); } });
|
|
59
|
+
tex("drone", 40, 40, (gr) => { gr.fillStyle(0xffffff); gr.fillPoints([{ x: 20, y: 2 }, { x: 38, y: 20 }, { x: 20, y: 38 }, { x: 2, y: 20 }], true); gr.fillStyle(0x0f172a); gr.fillCircle(20, 20, 7); gr.fillStyle(0xffffff); gr.fillCircle(20, 20, 3); });
|
|
60
|
+
tex("laser", 6, 22, (gr) => { gr.fillStyle(0xffffff); gr.fillRoundedRect(0, 0, 6, 22, 3); });
|
|
61
|
+
tex("spark", 8, 8, (gr) => { gr.fillStyle(0xffffff); gr.fillCircle(4, 4, 4); });
|
|
62
|
+
tex("star", 2, 2, (gr) => { gr.fillStyle(0xffffff); gr.fillRect(0, 0, 2, 2); });
|
|
63
|
+
// sky: a gradient and three layers of stars moving at different speeds
|
|
64
|
+
const bg = this.add.graphics(); bg.fillGradientStyle(0x0b1030, 0x0b1030, 0x1e1b4b, 0x312e81, 1); bg.fillRect(0, 0, W, H);
|
|
65
|
+
this.layers = [0.35, 0.7, 1.4].map((speed, i) => {
|
|
66
|
+
const stars = []; for (let k = 0; k < 40 - i * 10; k++) stars.push(this.add.image(Math.random() * W, Math.random() * H, "star").setAlpha(0.25 + i * 0.3).setScale(1 + i * 0.5));
|
|
67
|
+
return { speed, stars };
|
|
68
|
+
});
|
|
69
|
+
this.sparks = this.add.particles(0, 0, "spark", { speed: { min: 60, max: 260 }, lifespan: { min: 250, max: 650 }, scale: { start: 0.9, end: 0 }, blendMode: "ADD", emitting: false });
|
|
70
|
+
this.trail = this.add.particles(0, 0, "spark", { speed: { min: 20, max: 60 }, angle: { min: 80, max: 100 }, lifespan: 260, scale: { start: 0.55, end: 0 }, alpha: { start: 0.7, end: 0 }, blendMode: "ADD", frequency: 28 });
|
|
71
|
+
this.myShip = this.ship(COLORS[0]);
|
|
72
|
+
this.trail.startFollow(this.myShip.body, 0, 16);
|
|
73
|
+
this.mobs = new Map(); this.shots = new Map();
|
|
74
|
+
this.hud = this.add.text(14, 10, "", { fontFamily: "system-ui, sans-serif", fontSize: "16px", color: "#e2e8f0", lineSpacing: 4 }).setDepth(10);
|
|
75
|
+
this.clock = this.add.text(W / 2, 12, "", { fontFamily: "system-ui, sans-serif", fontSize: "22px", fontStyle: "bold", color: "#f8fafc" }).setOrigin(0.5, 0).setDepth(10);
|
|
76
|
+
this.keys = this.input.keyboard.addKeys("UP,DOWN,LEFT,RIGHT,W,A,S,D,SPACE");
|
|
77
|
+
this.input.addPointer(1);
|
|
78
|
+
this.cool = 0;
|
|
79
|
+
}
|
|
80
|
+
ship(color) {
|
|
81
|
+
const glow = this.add.image(0, 0, "glow").setTint(color).setBlendMode("ADD").setScale(1.4).setAlpha(0.8);
|
|
82
|
+
const body = this.add.image(0, 0, "ship").setTint(color);
|
|
83
|
+
return { glow, body, color, place(x, y, tilt) { glow.setPosition(x, y + 4); body.setPosition(x, y).setRotation((tilt || 0) * 0.35); }, destroy() { glow.destroy(); body.destroy(); } };
|
|
84
|
+
}
|
|
85
|
+
reset() { for (const s of this.mobs.values()) s.destroy(); this.mobs.clear(); }
|
|
86
|
+
fx(name, d) {
|
|
87
|
+
if (name === "boom") { this.sparks.explode(d.big ? 46 : 26, d.x, d.y); this.cameras.main.shake(d.big ? 180 : 90, d.big ? 0.008 : 0.004); this.float(d.x, d.y, "+" + d.pts, d.by === g.me.id ? "#fde68a" : "#94a3b8"); }
|
|
88
|
+
if (name === "spark") this.sparks.explode(8, d.x, d.y);
|
|
89
|
+
if (name === "crash") { this.sparks.explode(30, d.x, d.y); if (d.who === g.me.id) { this.cameras.main.shake(260, 0.014); this.cameras.main.flash(160, 248, 113, 113); this.float(d.x, d.y, "-15", "#fca5a5"); } }
|
|
90
|
+
}
|
|
91
|
+
float(x, y, text, color) {
|
|
92
|
+
const t = this.add.text(x, y, text, { fontFamily: "system-ui, sans-serif", fontSize: "18px", fontStyle: "bold", color }).setOrigin(0.5).setDepth(9);
|
|
93
|
+
this.tweens.add({ targets: t, y: y - 40, alpha: 0, duration: 700, ease: "Cubic.easeOut", onComplete: () => t.destroy() });
|
|
94
|
+
}
|
|
95
|
+
update(time, ms) {
|
|
96
|
+
const dt = Math.min(0.05, ms / 1000), playing = g.state === "playing" && !(g.countdown > 0);
|
|
97
|
+
for (const l of this.layers) for (const s of l.stars) { s.y += l.speed * 60 * dt * (playing ? 2 : 1); if (s.y > H) { s.y = 0; s.x = Math.random() * W; } }
|
|
98
|
+
// fly: keys, or toward a held finger
|
|
99
|
+
const k = this.keys, p = this.input.activePointer, touch = p.isDown && p.pointerType !== "mouse";
|
|
100
|
+
let ax = (k.RIGHT.isDown || k.D.isDown ? 1 : 0) - (k.LEFT.isDown || k.A.isDown ? 1 : 0), ay = (k.DOWN.isDown || k.S.isDown ? 1 : 0) - (k.UP.isDown || k.W.isDown ? 1 : 0);
|
|
101
|
+
if (touch) { const dx = p.worldX - me.x, dy = p.worldY - 40 - me.y, d = Math.hypot(dx, dy); if (d > 6) { ax = dx / d; ay = dy / d; } }
|
|
102
|
+
const len = Math.hypot(ax, ay) || 1;
|
|
103
|
+
if (playing) { me.x = Phaser.Math.Clamp(me.x + ax / len * 330 * dt, 24, W - 24); me.y = Phaser.Math.Clamp(me.y + ay / len * 300 * dt, 60, H - 30); }
|
|
104
|
+
const tilt = playing ? ax / len : 0;
|
|
105
|
+
if (g.state === "playing") g.me.set({ x: me.x, y: me.y, tilt: Math.round(tilt * 100) / 100 });
|
|
106
|
+
this.cool -= dt;
|
|
107
|
+
if (playing && (k.SPACE.isDown || touch) && this.cool <= 0) { this.cool = 0.16; g.shoot({ x: me.x, y: me.y - 24, vx: 0, vy: -560, life: 1.2, r: 4, kind: "laser" }); }
|
|
108
|
+
// me, the others, drones, shots
|
|
109
|
+
const idx = (id) => Math.max(0, (g.players || []).findIndex((q) => q.id === id));
|
|
110
|
+
this.myShip.body.setTint(COLORS[idx(g.me.id) % 8]); this.myShip.glow.setTint(COLORS[idx(g.me.id) % 8]);
|
|
111
|
+
this.myShip.place(me.x, me.y, tilt); this.myShip.body.setVisible(g.state === "playing"); this.myShip.glow.setVisible(g.state === "playing");
|
|
112
|
+
g.sync((q) => this.ship(COLORS[idx(q.id) % 8]), (s, st) => s.place(st.x, st.y, st.tilt), (s) => s.destroy());
|
|
113
|
+
const mobs = (g.shared && g.shared.mobs) || {};
|
|
114
|
+
for (const id in mobs) {
|
|
115
|
+
let s = this.mobs.get(id);
|
|
116
|
+
if (!s) { s = this.add.image(0, 0, "drone").setTint(mobs[id].big ? 0xf43f5e : 0xe879f9); this.mobs.set(id, s); }
|
|
117
|
+
s.setPosition(mobs[id].x, mobs[id].y).setRotation(mobs[id].rot || 0).setScale(mobs[id].big ? 1.35 : 0.9).setAlpha(mobs[id].hp > 1 || !mobs[id].big ? 1 : 0.7);
|
|
118
|
+
}
|
|
119
|
+
for (const [id, s] of this.mobs) if (!mobs[id]) { s.destroy(); this.mobs.delete(id); }
|
|
120
|
+
const seen = new Set();
|
|
121
|
+
for (const sh of g.shots || []) {
|
|
122
|
+
seen.add(sh.id); let s = this.shots.get(sh.id);
|
|
123
|
+
if (!s) { s = this.add.image(sh.x, sh.y, "laser").setBlendMode("ADD").setTint(COLORS[idx(sh.from) % 8]); this.shots.set(sh.id, s); }
|
|
124
|
+
s.setPosition(sh.x, sh.y);
|
|
125
|
+
}
|
|
126
|
+
for (const [id, s] of this.shots) if (!seen.has(id)) { s.destroy(); this.shots.delete(id); }
|
|
127
|
+
// score list and clock
|
|
128
|
+
const sc = (g.shared && g.shared.score) || {};
|
|
129
|
+
this.hud.setText((g.players || []).map((q) => ({ n: q.me ? q.name + " (you)" : q.name, s: sc[q.id] || 0 })).sort((a, b) => b.s - a.s).map((r) => `${r.s} ${r.n}`).join("\n"));
|
|
130
|
+
const left = g.shared && g.shared.endsAt ? Math.max(0, Math.ceil(g.shared.endsAt - g.now)) : 0;
|
|
131
|
+
this.clock.setText(g.state === "playing" ? `${left}s` : "");
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
new Phaser.Game({ type: Phaser.AUTO, parent: "stage", width: W, height: H, backgroundColor: "#060814", scale: { mode: Phaser.Scale.FIT, autoCenter: Phaser.Scale.CENTER_BOTH }, scene: Sky });
|
|
135
|
+
</script>
|
|
136
|
+
</body></html>
|