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
package/client/room.js
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
/* tickcli room.js - play a one-file browser game together, live. MIT. Load it BEFORE game.js and pass
|
|
2
|
+
multiplayer: {max, player:{field:[low, high]}} to Game({...}); the game shell (game.js) does the lobby, start, reconnects and notices.
|
|
3
|
+
This file is the low-level part the shell uses (window.tickcli). Pages normally never call it directly.
|
|
4
|
+
|
|
5
|
+
How it works: the page may be sandboxed with no network, so it says hello to the window around it (or to itself, when it is the top page); a relay
|
|
6
|
+
there (relay.js) answers with a private channel and keeps the one WebSocket to the server. The server decides who each player is, keeps the shared state and the order of changes, and picks one player as host.
|
|
7
|
+
Each player writes only their own state; the host writes the shared state; anyone can ask the host to do something (an action). */
|
|
8
|
+
(function () {
|
|
9
|
+
"use strict";
|
|
10
|
+
// Others are drawn a little in the past, so their movement is smooth. How far is measured per room (Mirror's rule): the mean delay of the last 40
|
|
11
|
+
// updates (their arrival minus the sender's own sample time), plus 2 send intervals, plus the delay's spread, kept between 100 and 500 ms. The
|
|
12
|
+
// drawing clock follows that target by running 2% fast or 4% slow, never by a jump. With no newer update it goes on at the last speed for 100 ms,
|
|
13
|
+
// then holds.
|
|
14
|
+
var DELAY = 120, lags = [], drawT = 0, drawAt = 0;
|
|
15
|
+
var SEND_MS = 80; // own state goes out at most 12 times a second (10 in rooms over 8 players); set() can be called every frame
|
|
16
|
+
var listeners = {}, port = null, info = null, ready = [], nonce = Math.random().toString(36).slice(2);
|
|
17
|
+
var R = {
|
|
18
|
+
available: false, status: "idle", you: null, code: null, priv: false, match: 0, term: 0, host: null, started: false, boot: null, max: 0,
|
|
19
|
+
players: {}, shared: {}, spec: null, guest: false, closed: null, saved: null, resumed: false, saveBusy: false, saveError: false, newer: false,
|
|
20
|
+
};
|
|
21
|
+
var prof = null, profOff = false, profPend = null, profWait = [], profT = 0, profGaveUp = false, profLate = null;
|
|
22
|
+
var roomsWait = [], roomsLast = null; // rooms(): waiting callers, and the last answer (asked at most once a second)
|
|
23
|
+
var fxQ = [], fxTimes = []; // effects over 20 a second wait for the next send slot (the server drops messages over its rate)
|
|
24
|
+
var FEATS = ["fxb"]; // told to the server in join: effects come inside the delta frame
|
|
25
|
+
var sentMe = {}, sentShared = {}; // what the server has from us (as sent, after rounding): only values that differ go out
|
|
26
|
+
var meAt = 0, sharedAt = 0; // when the values waiting to go out were set (shared clock): sent along as their sample time
|
|
27
|
+
var mePend = null, sharedPend = null, lastSend = 0, flushT = 0, aliveAt = 0, actSeq = 0, acts = {}, offset = 0, syncAsked = 0;
|
|
28
|
+
// the clock: the last 8 ping answers {rtt, off, at}; the offset of the fastest one is the target, and the offset in use moves toward it over a second
|
|
29
|
+
var clk = [], offTarget = 0, offAt = 0, clockSet = false;
|
|
30
|
+
|
|
31
|
+
function emit(name, a, b, c, d) { var l = listeners[name] || []; for (var i = 0; i < l.length; i++) try { l[i](a, b, c, d); } catch (e) { console.error(e); } }
|
|
32
|
+
function post(m) { if (port) port.postMessage(m); }
|
|
33
|
+
function send(m) { post({ k: "send", m: m }); }
|
|
34
|
+
var copy = function (v) { return v == null ? v : JSON.parse(JSON.stringify(v)); };
|
|
35
|
+
var isObj = function (v) { return !!v && typeof v === "object" && !Array.isArray(v); };
|
|
36
|
+
/** Writes merge deeply, as on the server: {hp: {a: 80}} changes only a's hp, null removes a key, lists are replaced whole. */
|
|
37
|
+
function merge(into, patch) {
|
|
38
|
+
for (var k in patch) {
|
|
39
|
+
var v = patch[k];
|
|
40
|
+
if (v === null) delete into[k];
|
|
41
|
+
else if (isObj(v)) { if (!isObj(into[k])) into[k] = {}; merge(into[k], v); }
|
|
42
|
+
else into[k] = v;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// The shared state is never changed in place: a write makes new objects only along the paths it touches and reuses the rest (each one frozen).
|
|
46
|
+
// So a buffered copy for smoothing is just a reference, two versions differ only where === differs, and the game can read it without a copy.
|
|
47
|
+
function frozen(v) {
|
|
48
|
+
if (!v || typeof v !== "object" || Object.isFrozen(v)) return v;
|
|
49
|
+
for (var k in v) frozen(v[k]);
|
|
50
|
+
return Object.freeze(v);
|
|
51
|
+
}
|
|
52
|
+
/** A new version of `base` with `patch` merged in (same rules as merge); untouched branches are the same objects as in base. */
|
|
53
|
+
function cow(base, patch) {
|
|
54
|
+
var out = {}, k;
|
|
55
|
+
if (isObj(base)) for (k in base) out[k] = base[k];
|
|
56
|
+
for (k in patch) {
|
|
57
|
+
var v = patch[k];
|
|
58
|
+
if (v === null) delete out[k];
|
|
59
|
+
else if (isObj(v)) out[k] = cow(out[k], v);
|
|
60
|
+
else out[k] = v && typeof v === "object" ? frozen(copy(v)) : v;
|
|
61
|
+
}
|
|
62
|
+
return Object.freeze(out);
|
|
63
|
+
}
|
|
64
|
+
/** The part of a patch that changes `cur` (equal values left out, a null for a missing key too, lists compared as text); null = nothing. */
|
|
65
|
+
function diff(cur, patch) {
|
|
66
|
+
var out = null;
|
|
67
|
+
for (var k in patch) {
|
|
68
|
+
var v = patch[k], has = Object.prototype.hasOwnProperty.call(cur, k), c = has ? cur[k] : undefined, keep;
|
|
69
|
+
if (v === null || v === undefined) { if (!has) continue; keep = null; }
|
|
70
|
+
else if (isObj(v)) { if (isObj(c)) { keep = diff(c, v); if (!keep) continue; } else keep = v; }
|
|
71
|
+
else if (Array.isArray(v)) { if (Array.isArray(c) && JSON.stringify(c) === JSON.stringify(v)) continue; keep = v; }
|
|
72
|
+
else { if (c === v) continue; keep = v; }
|
|
73
|
+
(out = out || {})[k] = keep;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
/** Two patches going out together (nulls kept). */
|
|
78
|
+
function join(into, patch) { for (var k in patch) { var v = patch[k]; if (isObj(v) && isObj(into[k])) join(into[k], v); else into[k] = v === undefined ? null : copy(v); } }
|
|
79
|
+
// The server's limits, counted the way it counts them: bytes (a Vietnamese letter is 3, an emoji 4), not characters.
|
|
80
|
+
var LIMIT = 12000; // the server takes 16 KB per message: a bigger update goes out in several parts of at most this many bytes (the envelope fits in the rest)
|
|
81
|
+
var enc = typeof TextEncoder === "function" ? new TextEncoder() : null;
|
|
82
|
+
function size(v) { var t = JSON.stringify(v); return enc ? enc.encode(t).length : unescape(encodeURIComponent(t)).length; }
|
|
83
|
+
/** Split a patch into parts of at most LIMIT bytes. A big object (60 monsters) is split by its keys, at any depth; each part merges into the same
|
|
84
|
+
place. A single value that cannot be split (one long list or text) is not sent: tooBig(path) is called instead. */
|
|
85
|
+
function chunks(p, tooBig) {
|
|
86
|
+
var leaves = [];
|
|
87
|
+
(function walk(v, at) {
|
|
88
|
+
for (var k in v) {
|
|
89
|
+
var path = at.concat(k), one = wrap(path, v[k]), len = size(one);
|
|
90
|
+
if (len <= LIMIT) leaves.push({ one: one, len: len });
|
|
91
|
+
else if (isObj(v[k])) walk(v[k], path);
|
|
92
|
+
else if (tooBig) tooBig(path.join("."));
|
|
93
|
+
}
|
|
94
|
+
})(p, []);
|
|
95
|
+
var out = [], cur = null, used = 0;
|
|
96
|
+
for (var i = 0; i < leaves.length; i++) {
|
|
97
|
+
if (cur && used + leaves[i].len > LIMIT) { out.push(cur); cur = null; used = 0; }
|
|
98
|
+
if (!cur) cur = {};
|
|
99
|
+
join(cur, leaves[i].one); used += leaves[i].len; // the sum of the parts is never smaller than them put together
|
|
100
|
+
}
|
|
101
|
+
if (cur) out.push(cur);
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
function wrap(path, v) { var o = {}, at = o; for (var i = 0; i < path.length - 1; i++) { at[path[i]] = {}; at = at[path[i]]; } at[path[path.length - 1]] = v; return o; }
|
|
105
|
+
// The shape the server takes (hub.ts SHAPE): text up to 200 characters, lists up to 64 items, 64 keys per object, 4 levels deep, keys up to 40
|
|
106
|
+
// characters, finite numbers. A write that breaks any of them is refused whole, so it is checked here first and never applied or sent.
|
|
107
|
+
var BAD = { __proto__: 1, constructor: 1, prototype: 1 };
|
|
108
|
+
function badPath(v, depth, at) {
|
|
109
|
+
if (v === null || v === undefined || typeof v === "boolean") return null; // undefined: JSON leaves it out (a removal when it goes out)
|
|
110
|
+
if (typeof v === "number") return isFinite(v) ? null : at;
|
|
111
|
+
if (typeof v === "string") return v.length <= 200 ? null : at;
|
|
112
|
+
if (depth >= 4) return at;
|
|
113
|
+
var i, b;
|
|
114
|
+
if (Array.isArray(v)) {
|
|
115
|
+
if (v.length > 64) return at;
|
|
116
|
+
for (i = 0; i < v.length; i++) { b = badPath(v[i], depth + 1, at ? at + "." + i : String(i)); if (b !== null) return b; }
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
if (typeof v !== "object" || Object.getPrototypeOf(v) !== Object.prototype) return at;
|
|
120
|
+
var keys = Object.keys(v);
|
|
121
|
+
if (keys.length > 64) return at;
|
|
122
|
+
for (i = 0; i < keys.length; i++) {
|
|
123
|
+
var k = keys[i], here = at ? at + "." + k : k;
|
|
124
|
+
if (k.length > 40 || BAD[k] === 1) return here;
|
|
125
|
+
b = badPath(v[k], depth + 1, here);
|
|
126
|
+
if (b !== null) return b;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
/** A write the server would refuse: tell the game (error event: code, path) instead of sending it. True when the patch is fine. */
|
|
131
|
+
function checked(patch) {
|
|
132
|
+
var b = badPath(patch, 0, "");
|
|
133
|
+
if (b === null) return true;
|
|
134
|
+
emit("error", "bad_value", b);
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
// Moving things in the shared state (monsters, minions, shots: any number named x, y, z, angle or rot) are drawn smoothly on every screen but the
|
|
138
|
+
// host's, the same way as players: a little in the past, eased between the two updates around that moment.
|
|
139
|
+
// A rotation in 3D is a unit quaternion in four fields qx, qy, qz, qw (three.js order): eased as one (slerp, the short way round), never per field.
|
|
140
|
+
var LERP = { x: 1, y: 1, z: 1, angle: 1, rot: 1, qx: 1, qy: 1, qz: 1, qw: 1 }, QK = { qx: 1, qy: 1, qz: 1, qw: 1 };
|
|
141
|
+
// Positions go out as whole numbers (pixels) unless the game asks for decimals (precision(2): a 3D world in metres keeps centimetres).
|
|
142
|
+
var POS = 1;
|
|
143
|
+
// angle and rot are radians: they ease the short way round (from 3.1 to -3.1 is a small turn, not a full spin backwards)
|
|
144
|
+
var TURN = { angle: 1, rot: 1 }, TAU = Math.PI * 2;
|
|
145
|
+
function ease(k, a, b, f) {
|
|
146
|
+
if (!TURN[k]) return a + (b - a) * f;
|
|
147
|
+
var d = (b - a) % TAU; if (d > Math.PI) d -= TAU; else if (d < -Math.PI) d += TAU;
|
|
148
|
+
return a + d * f;
|
|
149
|
+
}
|
|
150
|
+
/** out's quaternion (qx, qy, qz, qw) set to the slerp from A's to B's at f (f may run a little past 1: going on at the last speed). Needs all four in
|
|
151
|
+
both; q and -q are the same turn, so the short way round is taken. The result is always of length 1. */
|
|
152
|
+
function quatEase(out, A, B, f) {
|
|
153
|
+
var ax = A.qx, ay = A.qy, az = A.qz, aw = A.qw, bx = B.qx, by = B.qy, bz = B.qz, bw = B.qw;
|
|
154
|
+
if (typeof ax !== "number" || typeof ay !== "number" || typeof az !== "number" || typeof aw !== "number" ||
|
|
155
|
+
typeof bx !== "number" || typeof by !== "number" || typeof bz !== "number" || typeof bw !== "number") return;
|
|
156
|
+
var d = ax * bx + ay * by + az * bz + aw * bw;
|
|
157
|
+
if (d < 0) { d = -d; bx = -bx; by = -by; bz = -bz; bw = -bw; }
|
|
158
|
+
var s0 = 1 - f, s1 = f;
|
|
159
|
+
if (d < 0.9995) { var th = Math.acos(Math.min(1, d)), sn = Math.sin(th); s0 = Math.sin((1 - f) * th) / sn; s1 = Math.sin(f * th) / sn; }
|
|
160
|
+
var x = s0 * ax + s1 * bx, y = s0 * ay + s1 * by, z = s0 * az + s1 * bz, w = s0 * aw + s1 * bw, l = Math.sqrt(x * x + y * y + z * z + w * w);
|
|
161
|
+
if (!(l > 1e-9)) return;
|
|
162
|
+
out.qx = x / l; out.qy = y / l; out.qz = z / l; out.qw = w / l;
|
|
163
|
+
}
|
|
164
|
+
var sbuf = [];
|
|
165
|
+
/** Where two versions (made by cow()) differ in a moving number: {key: 1 (the number) or {...} (inside)}, null = nothing moves. Only branches that
|
|
166
|
+
differ (!==) are looked at; worked out once per pair of updates, not per frame. */
|
|
167
|
+
function movers(A, B, depth) {
|
|
168
|
+
if (A === B || !isObj(A)) return null;
|
|
169
|
+
var plan = null;
|
|
170
|
+
for (var k in B) {
|
|
171
|
+
var a = A[k], b = B[k];
|
|
172
|
+
if (a === b) continue;
|
|
173
|
+
if (typeof b === "number" && typeof a === "number") { if (LERP[k] && a !== b) (plan = plan || {})[k] = 1; }
|
|
174
|
+
else if (depth < 4 && isObj(b) && isObj(a)) { var sub = movers(a, b, depth + 1); if (sub) (plan = plan || {})[k] = sub; }
|
|
175
|
+
}
|
|
176
|
+
return plan;
|
|
177
|
+
}
|
|
178
|
+
/** B with the moving numbers in plan eased from A: new objects only along those paths, everything else is B's own. */
|
|
179
|
+
function eased(A, B, plan, f) {
|
|
180
|
+
var out = {}, k;
|
|
181
|
+
for (k in B) out[k] = B[k];
|
|
182
|
+
for (k in plan) out[k] = plan[k] === 1 ? ease(k, A[k], B[k], f) : eased(A[k], B[k], plan[k], f);
|
|
183
|
+
if (plan.qx || plan.qy || plan.qz || plan.qw) quatEase(out, A, B, f);
|
|
184
|
+
return Object.freeze(out);
|
|
185
|
+
}
|
|
186
|
+
var pA = null, pB = null, pPlan = null;
|
|
187
|
+
/** Where between two buffered updates (at) the drawing time t falls: 0..1 between them, up to 100 ms past the newer one (going on at the last
|
|
188
|
+
speed), then held there. */
|
|
189
|
+
function frac(t, A, B) { return Math.max(0, (Math.min(t, B.at + 100) - A.at) / (B.at - A.at)); }
|
|
190
|
+
function sharedView() {
|
|
191
|
+
if (R.host === R.you || sbuf.length < 2) return R.shared;
|
|
192
|
+
var t = drawTime(), i = sbuf.length - 1;
|
|
193
|
+
while (i > 0 && sbuf[i - 1].at > t) i--;
|
|
194
|
+
var A = sbuf[Math.max(0, i - 1)], B = sbuf[i];
|
|
195
|
+
if (A === B || B.at <= A.at) return R.shared;
|
|
196
|
+
if (pA !== A.s || pB !== B.s) { pA = A.s; pB = B.s; pPlan = movers(A.s, B.s, 0); }
|
|
197
|
+
return pPlan ? eased(A.s, B.s, pPlan, frac(t, A, B)) : B.s;
|
|
198
|
+
}
|
|
199
|
+
/** Buffer entries are kept in time order: a stamp at or before the last one is moved just after it. */
|
|
200
|
+
function push(buf, at, s, max) { var last = buf[buf.length - 1]; if (last && at <= last.at) at = last.at + 1; buf.push({ at: at, s: s }); if (buf.length > max) buf.splice(0, buf.length - max); }
|
|
201
|
+
function keepShared(at) { push(sbuf, at, R.shared, 8); }
|
|
202
|
+
/** One update arrived whose values were sampled at `stamp` (the sender's time on the shared clock). */
|
|
203
|
+
function lagOf(stamp) {
|
|
204
|
+
lags.push(serverMs() - stamp); if (lags.length > 40) lags.shift();
|
|
205
|
+
if (lags.length < 4) return;
|
|
206
|
+
var mean = 0, v = 0, i; for (i = 0; i < lags.length; i++) mean += lags[i]; mean /= lags.length;
|
|
207
|
+
for (i = 0; i < lags.length; i++) v += (lags[i] - mean) * (lags[i] - mean);
|
|
208
|
+
// on a slower update rate (Q.rate > 1) frames come further apart: the delay covers 2 of those gaps instead, up to 800 ms
|
|
209
|
+
var gap = Math.max(sendMs(), Q.every || 0);
|
|
210
|
+
DELAY = Math.max(100, Math.min(Q.rate > 1 ? 800 : 500, mean + 2 * gap + Math.sqrt(v / lags.length)));
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---- update rate: fewer frames for a slow link or a slow device ----------------------------------------------------------------------
|
|
214
|
+
// The hub watches its own send queue to this page; the page watches two things the hub cannot see: how late frames arrive (the shared clock
|
|
215
|
+
// minus the hub's send time: on a link that cannot keep up this grows as messages queue, while a merely far-away player keeps a steady value),
|
|
216
|
+
// and how fast the game draws (game.js reports frames a second). When either is bad for 2 s the page asks for half the rate (then a quarter);
|
|
217
|
+
// after 15 s of good readings it asks one step back. The hub uses the slower of its reading and the page's ask and says which (rate {div}).
|
|
218
|
+
// navigator.connection (Chrome, Edge, Android) only sets the starting point: saveData or 3g = half, 2g = a quarter.
|
|
219
|
+
var Q = { rate: 1, every: 0, ask: 1, net: 1, dev: 1, lateMs: 0, fps: 0, rtt: 0 };
|
|
220
|
+
var late = [], lateBase = [], badSince = 0, goodSince = 0, devBad = 0, devGood = 0, askedAt = 0;
|
|
221
|
+
try {
|
|
222
|
+
var nc = navigator.connection;
|
|
223
|
+
if (nc) Q.net = nc.effectiveType === "slow-2g" || nc.effectiveType === "2g" ? 4 : nc.saveData || nc.effectiveType === "3g" ? 2 : 1;
|
|
224
|
+
} catch (e) { /* no Network Information API */ }
|
|
225
|
+
Q.ask = Q.net;
|
|
226
|
+
function askRate() {
|
|
227
|
+
var want = Math.max(Q.net, Q.dev);
|
|
228
|
+
if (want === Q.ask) return;
|
|
229
|
+
Q.ask = want; askedAt = Date.now();
|
|
230
|
+
send({ t: "rate", div: want });
|
|
231
|
+
emit("quality", quality());
|
|
232
|
+
}
|
|
233
|
+
function quality() { return { rate: Q.rate, ask: Q.ask, lateMs: Math.round(Q.lateMs), fps: Q.fps, rtt: Q.rtt, every: Q.every }; }
|
|
234
|
+
/** One frame's lateness (ms): the shared clock now minus the hub's send time. */
|
|
235
|
+
function lateSample(at) {
|
|
236
|
+
if (typeof document !== "undefined" && document.hidden) return;
|
|
237
|
+
var n = Date.now(), x = serverMs() - at;
|
|
238
|
+
late.push(x); if (late.length > 10) late.shift();
|
|
239
|
+
// the floor: the smallest lateness of each 2 s over the last 30 s (a far player's steady delay, which no rate change can fix)
|
|
240
|
+
var b = lateBase[lateBase.length - 1];
|
|
241
|
+
if (!b || n - b.at >= 2000) lateBase.push({ at: n, v: x }); else if (x < b.v) b.v = x;
|
|
242
|
+
while (lateBase.length > 15) lateBase.shift();
|
|
243
|
+
if (late.length < 5 || lateBase.length < 2) return;
|
|
244
|
+
var floor = Infinity; for (var i = 0; i < lateBase.length; i++) floor = Math.min(floor, lateBase[i].v);
|
|
245
|
+
var sorted = late.slice().sort(function (a, c) { return a - c; });
|
|
246
|
+
Q.lateMs = sorted[sorted.length >> 1] - floor;
|
|
247
|
+
if (Q.lateMs > 250) { goodSince = 0; if (!badSince) badSince = n; }
|
|
248
|
+
else if (Q.lateMs < 60) { badSince = 0; if (!goodSince) goodSince = n; }
|
|
249
|
+
else { badSince = 0; goodSince = 0; }
|
|
250
|
+
if (badSince && n - badSince >= 2000 && n - askedAt >= 3000 && Q.net < 4) { Q.net *= 2; badSince = 0; late = []; askRate(); }
|
|
251
|
+
else if (goodSince && n - goodSince >= 15000 && Q.net > 1) { Q.net /= 2; goodSince = 0; askRate(); }
|
|
252
|
+
}
|
|
253
|
+
/** game.js: frames drawn a second, about once a second while playing. Under 20 for 3 s: half rate; over 40 for 15 s: back. */
|
|
254
|
+
function deviceFps(fps) {
|
|
255
|
+
var n = Date.now();
|
|
256
|
+
Q.fps = Math.round(fps);
|
|
257
|
+
if (fps < 20) { devGood = 0; if (!devBad) devBad = n; } else if (fps > 40) { devBad = 0; if (!devGood) devGood = n; } else { devBad = 0; devGood = 0; }
|
|
258
|
+
if (devBad && n - devBad >= 3000 && Q.dev < 2) { Q.dev = 2; devBad = 0; askRate(); }
|
|
259
|
+
else if (devGood && n - devGood >= 15000 && Q.dev > 1) { Q.dev = 1; devGood = 0; askRate(); }
|
|
260
|
+
}
|
|
261
|
+
/** The time others are drawn at: the shared clock minus the measured delay, reached by running 2% fast or 4% slow (a jump only when far off). */
|
|
262
|
+
function drawTime() {
|
|
263
|
+
var now = serverMs(), want = now - DELAY;
|
|
264
|
+
if (!drawAt || Math.abs(drawT - want) > 1000) { drawT = want; drawAt = now; return drawT; }
|
|
265
|
+
var el = Math.max(0, now - drawAt), d = want - drawT;
|
|
266
|
+
drawAt = now;
|
|
267
|
+
drawT += el * (d > 2 ? 1.02 : d < -2 ? 0.96 : 1);
|
|
268
|
+
return drawT;
|
|
269
|
+
}
|
|
270
|
+
/** Own state and host writes go out every 80 ms, every 100 ms in rooms over 8 players (the server sends those every 100 ms). */
|
|
271
|
+
function sendMs() { var n = 0; for (var k in R.players) n++; return n > 8 ? 100 : SEND_MS; }
|
|
272
|
+
/** How often own and host writes go out: as sendMs, but once a second while this page is the only one in the room (playing alone, or
|
|
273
|
+
* waiting for friends): nobody needs them sooner, and the server's copy (saves, a reload) is at most a second old. */
|
|
274
|
+
var ALONE_MS = 1000;
|
|
275
|
+
function sendGap() { var n = 0; for (var k in R.players) n++; return n <= 1 ? ALONE_MS : sendMs(); }
|
|
276
|
+
|
|
277
|
+
// ---- hello: ask the window around us for a private port (retry: it may not be listening yet) -----------------------------------------
|
|
278
|
+
var tries = 0, helloT = setInterval(function () {
|
|
279
|
+
if (port || ++tries > 20) { clearInterval(helloT); if (!port) { R.status = "unavailable"; emit("status", R.status); } return; }
|
|
280
|
+
try { parent.postMessage({ tickcli: "hello", n: nonce }, "*"); } catch (e) { /* no parent */ }
|
|
281
|
+
}, 250);
|
|
282
|
+
try { parent.postMessage({ tickcli: "hello", n: nonce }, "*"); } catch (e) { /* no parent */ }
|
|
283
|
+
addEventListener("message", function (e) {
|
|
284
|
+
var d = e.data;
|
|
285
|
+
if (port || e.source !== parent || !d || d.tickcli !== "port" || d.n !== nonce || !e.ports || !e.ports[0]) return;
|
|
286
|
+
port = e.ports[0];
|
|
287
|
+
port.onmessage = onPort;
|
|
288
|
+
clearInterval(helloT);
|
|
289
|
+
R.available = true;
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
function onPort(e) {
|
|
293
|
+
var d = e.data;
|
|
294
|
+
if (!d || typeof d !== "object") return;
|
|
295
|
+
if (d.k === "info") { info = d; R.guest = !!d.guest; R.anyone = !!d.anyone; var q = ready; ready = []; for (var i = 0; i < q.length; i++) q[i](d); emit("ready", d); return; }
|
|
296
|
+
if (d.k === "status") {
|
|
297
|
+
R.status = d.s;
|
|
298
|
+
if (d.s === "closed") R.closed = { code: d.code, reason: d.reason };
|
|
299
|
+
emit("status", d.s, d);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
if (d.k === "invite") { emit("invite", d.ok); return; }
|
|
303
|
+
if (d.k === "msg") onServer(d.m);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---- clock: the server's time, from ping answers (keep the fastest round trip; half of it is the one-way guess) -------------------------
|
|
307
|
+
function serverMs() {
|
|
308
|
+
var n = Date.now();
|
|
309
|
+
if (offAt && offset !== offTarget) offset = Math.abs(offTarget - offset) > 1000 ? offTarget : offset + (offTarget - offset) * Math.min(1, (n - offAt) / 1000);
|
|
310
|
+
offAt = n;
|
|
311
|
+
return n + offset;
|
|
312
|
+
}
|
|
313
|
+
/** A ping answer: keep the last 8 (none older than 60 s) and aim at the offset of the one with the shortest round trip (NTP's clock filter). */
|
|
314
|
+
function clockSample(c, srv) {
|
|
315
|
+
var n = Date.now(), rtt = n - c;
|
|
316
|
+
if (!(rtt >= 0) || rtt > 30000 || typeof srv !== "number") return;
|
|
317
|
+
clk.push({ rtt: rtt, off: srv + rtt / 2 - n, at: n });
|
|
318
|
+
while (clk.length > 8 || (clk.length && n - clk[0].at > 60000)) clk.shift();
|
|
319
|
+
var best = clk[0]; for (var i = 1; i < clk.length; i++) if (clk[i].rtt <= best.rtt) best = clk[i];
|
|
320
|
+
if (Math.abs(best.off - offTarget) > 50) { late = []; lateBase = []; } // a new clock offset: lateness readings before it do not compare
|
|
321
|
+
offTarget = best.off; Q.rtt = best.rtt;
|
|
322
|
+
if (!clockSet) { offset = offTarget; clockSet = true; }
|
|
323
|
+
offAt = offAt || n;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---- server messages ---------------------------------------------------------------------------------------------------------------
|
|
327
|
+
function setPlayer(p) {
|
|
328
|
+
var cur = R.players[p.id];
|
|
329
|
+
var buf = cur ? cur.buf : [];
|
|
330
|
+
R.players[p.id] = { id: p.id, name: p.name, online: p.online, hidden: p.hidden, guest: p.guest, state: p.state || {}, buf: buf };
|
|
331
|
+
if (!cur) R.players[p.id].buf = [{ at: serverMs(), s: copy(p.state || {}) }];
|
|
332
|
+
}
|
|
333
|
+
var firstVer = ""; // the game version this page joined first: a reconnect into another one means the page itself is out of date
|
|
334
|
+
function full(m) {
|
|
335
|
+
var prevBoot = R.boot, wasStarted = R.started, prevMatch = R.match, prevYou = R.you;
|
|
336
|
+
var asked = m.t === "sync" && syncAsked > 0; // the answer to our own sync request (a write was refused): the server's copy wins everywhere
|
|
337
|
+
if (asked) syncAsked = 0;
|
|
338
|
+
R.you = m.you; R.code = m.code; R.priv = !!m.private; R.match = m.match; R.term = m.term; R.host = m.host; R.started = !!m.started; R.boot = m.boot; R.max = m.max;
|
|
339
|
+
if (typeof m.now === "number" && !clockSet) { offset = offTarget = m.now - Date.now(); offAt = Date.now(); }
|
|
340
|
+
var mine = prevYou === m.you ? R.players[R.you] : null;
|
|
341
|
+
R.players = {};
|
|
342
|
+
for (var i = 0; i < m.players.length; i++) setPlayer(m.players[i]);
|
|
343
|
+
sentMe = R.players[R.you] ? copy(R.players[R.you].state) : {};
|
|
344
|
+
sentShared = copy(m.shared || {});
|
|
345
|
+
// kept: the server restarted but took the room over (same match, same shared state): the page carries on
|
|
346
|
+
var r = { restarted: !!prevBoot && prevBoot !== m.boot && wasStarted && !m.kept, newMatch: prevMatch && prevMatch !== m.match, kept: !!m.kept };
|
|
347
|
+
// our own state: keep what we hold locally if we were here before (it is newer than the server's copy). After a long drop the server may have
|
|
348
|
+
// forgotten it (the seat was given up): send it all again, so things set once (colour, score, health) show on the other screens too.
|
|
349
|
+
if (mine && R.players[R.you] && !asked) {
|
|
350
|
+
var srv = R.players[R.you].state;
|
|
351
|
+
R.players[R.you].state = mine.state;
|
|
352
|
+
if (m.t === "welcome" && !r.restarted && !r.newMatch && JSON.stringify(srv) !== JSON.stringify(mine.state)) {
|
|
353
|
+
mePend = mePend || {};
|
|
354
|
+
join(mePend, shape(mine.state));
|
|
355
|
+
schedule();
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
R.shared = frozen(m.shared || {});
|
|
359
|
+
R.saved = m.saved || null; R.resumed = !!m.resumed; R.saveBusy = !!m.saveBusy; R.saveError = !!m.saveError;
|
|
360
|
+
sharedPend = null;
|
|
361
|
+
sbuf = []; keepShared(serverMs());
|
|
362
|
+
return r;
|
|
363
|
+
}
|
|
364
|
+
/** The server refused a write: our copy and everyone else's differ. Ask for the server's copy (once per second at most). */
|
|
365
|
+
function resync() {
|
|
366
|
+
var n = Date.now();
|
|
367
|
+
if (n - syncAsked < 1000) return;
|
|
368
|
+
syncAsked = n;
|
|
369
|
+
send({ t: "sync" });
|
|
370
|
+
}
|
|
371
|
+
function onServer(m) {
|
|
372
|
+
if (!m || typeof m.t !== "string") return;
|
|
373
|
+
switch (m.t) {
|
|
374
|
+
case "welcome": case "sync": {
|
|
375
|
+
if (typeof m.ver === "string" && m.ver) {
|
|
376
|
+
if (!firstVer) firstVer = m.ver;
|
|
377
|
+
else if (m.ver !== firstVer) { // the game was edited while this page was open: never play the old code in the new version's room
|
|
378
|
+
send({ t: "leave" }); R.closed = { code: 4007, reason: "this page has an older version of the game" }; R.status = "closed"; emit("status", "closed", R.closed); break;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (m.t === "welcome") { R.idleAt = 0; Q.rate = 1; Q.every = 0; if (Q.ask > 1) send({ t: "rate", div: Q.ask }); } // a new socket starts at the full rate
|
|
382
|
+
var r = full(m); emit(m.t, m, r); emit("players"); emit("shared"); emit("host", R.host); break; }
|
|
383
|
+
case "idle": R.idleAt = typeof m.closesAt === "number" ? m.closesAt : 0; emit("idle", R.idleAt); break; // nobody did anything: the room closes then (0 = not any more)
|
|
384
|
+
case "rate": Q.rate = m.div > 1 ? m.div : 1; Q.every = typeof m.every === "number" ? m.every : 0; emit("quality", quality()); break;
|
|
385
|
+
case "rooms": {
|
|
386
|
+
var w = roomsWait; roomsWait = [];
|
|
387
|
+
var list = m.off ? null : Array.isArray(m.rooms) ? m.rooms : [];
|
|
388
|
+
for (var ri = 0; ri < w.length; ri++) { clearTimeout(w[ri].t); w[ri].done(list); }
|
|
389
|
+
emit("rooms", list);
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
case "time": clockSample(m.c, m.s); break;
|
|
393
|
+
case "delta": {
|
|
394
|
+
if (typeof m.at === "number") lateSample(m.at);
|
|
395
|
+
// each update is buffered at the time its sender sampled it (pt, sc); a hub without them: the time it sent the frame
|
|
396
|
+
if (m.players) for (var id in m.players) {
|
|
397
|
+
if (id === R.you || !R.players[id]) continue;
|
|
398
|
+
var p = R.players[id], st = m.pt && typeof m.pt[id] === "number" ? m.pt[id] : m.at;
|
|
399
|
+
merge(p.state, m.players[id]);
|
|
400
|
+
push(p.buf, st, copy(p.state), 12);
|
|
401
|
+
lagOf(st);
|
|
402
|
+
}
|
|
403
|
+
// shared changes we wrote ourselves as host in this term come back to the others only (we already applied them)
|
|
404
|
+
if (m.shared && !(R.host === R.you && m.st === R.term)) { var sst = typeof m.sc === "number" ? m.sc : m.at; R.shared = cow(R.shared, m.shared); merge(sentShared, copy(m.shared)); keepShared(sst); lagOf(sst); emit("shared"); }
|
|
405
|
+
// effects ride the frame: after the players and the shared state of the same frame; our own were already played here
|
|
406
|
+
if (m.fx) for (var fi = 0; fi < m.fx.length; fi++) { var f = m.fx[fi]; if (f && f.from !== R.you) emit("fx", f.from, f.name, f.data); }
|
|
407
|
+
break;
|
|
408
|
+
}
|
|
409
|
+
case "joined": setPlayer(m.player); if (flushT) { clearTimeout(flushT); flushT = 0; schedule(); } emit("players"); emit("joined", m.player); break; // not alone any more: a write waiting on the once-a-second pace goes now
|
|
410
|
+
case "online": if (R.players[m.player.id]) { var q = R.players[m.player.id]; q.name = m.player.name; q.online = m.player.online; q.hidden = m.player.hidden; } emit("players"); break;
|
|
411
|
+
case "left": delete R.players[m.player]; emit("players"); emit("left", m.player); break;
|
|
412
|
+
case "host": {
|
|
413
|
+
var was = R.host;
|
|
414
|
+
R.host = m.player; R.term = m.term;
|
|
415
|
+
if (m.player === R.you) sentShared = copy(R.shared); // what everyone has: our next writes are compared with it
|
|
416
|
+
if (sharedPend && m.player !== R.you) sharedPend = null; // writes from an old term would be refused anyway
|
|
417
|
+
emit("host", m.player, was);
|
|
418
|
+
break;
|
|
419
|
+
}
|
|
420
|
+
case "action": emit("action", m.id, m.from, m.name, m.data); break;
|
|
421
|
+
case "newer": R.newer = true; emit("newer"); break; // someone opened the edited game with this room's code: a new room has it now
|
|
422
|
+
case "result": { var a = acts[m.id]; if (a) { delete acts[m.id]; clearTimeout(a.t); a.done({ ok: !!m.ok, error: m.error }); } break; }
|
|
423
|
+
case "fx": emit("fx", m.from, m.name, m.data); break;
|
|
424
|
+
case "saved": R.saved = m.at ? { at: m.at, v: typeof m.v === "number" ? m.v : 1 } : null; emit("saved"); break;
|
|
425
|
+
case "savestate": R.saveError = !!m.error; emit("saved"); break; // the saved game could not be read (error) or was read after all
|
|
426
|
+
case "profile": {
|
|
427
|
+
// an answer after the game stopped waiting does not switch saving on by itself: the game adopts it (adoptProfile) only if it has not written yet
|
|
428
|
+
if (!m.off) R.profV = typeof m.v === "number" ? m.v : 1;
|
|
429
|
+
if (m.off) profOff = true; else if (profGaveUp && !profWait.length) { profLate = isObj(m.p) ? m.p : {}; emit("profile"); break; } else prof = isObj(m.p) ? m.p : {};
|
|
430
|
+
var w = profWait; profWait = [];
|
|
431
|
+
for (var wi = 0; wi < w.length; wi++) { clearTimeout(w[wi].t); w[wi].res(prof ? copy(prof) : null); }
|
|
432
|
+
break;
|
|
433
|
+
}
|
|
434
|
+
case "started": R.started = true; R.resumed = !!m.resumed; R.match = m.match; R.shared = frozen(m.shared || {}); sentShared = copy(R.shared); sbuf = []; keepShared(serverMs()); emit("shared"); emit("started", m.match); break;
|
|
435
|
+
case "ended": R.started = false; R.resumed = false; if (m.shared) { R.shared = frozen(m.shared); sentShared = copy(m.shared); } emit("shared"); emit("ended", m.match); break;
|
|
436
|
+
case "error":
|
|
437
|
+
if (m.code === "bad_value" || m.code === "too_big" || m.code === "out_of_range" || m.code === "too_fast") resync();
|
|
438
|
+
emit("error", m.code, m.ref);
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ---- sending: own state sampled and merged, shared state with the host term --------------------------------------------------------
|
|
444
|
+
function shape(patch) {
|
|
445
|
+
var out = {}, spec = (R.spec && R.spec.player) || {};
|
|
446
|
+
for (var k in patch) {
|
|
447
|
+
var v = patch[k];
|
|
448
|
+
if (typeof v === "number" && spec[k]) {
|
|
449
|
+
var lo = spec[k][0], hi = spec[k][1];
|
|
450
|
+
v = Math.min(hi, Math.max(lo, v));
|
|
451
|
+
// positions as whole numbers: less data, same picture; quaternion fields to 3 decimals (2 would wobble by about a degree)
|
|
452
|
+
v = QK[k] ? Math.round(v * 1000) / 1000 : hi - lo >= 100 ? Math.round(v) : Math.round(v * 100) / 100;
|
|
453
|
+
}
|
|
454
|
+
out[k] = v;
|
|
455
|
+
}
|
|
456
|
+
return out;
|
|
457
|
+
}
|
|
458
|
+
// What the host sends is trimmed: positions (x, y, z) as whole numbers (or the decimals asked with precision()), quaternion fields to 3 decimals,
|
|
459
|
+
// other numbers to 2. The host keeps its exact values; the picture is the same, and 32 moving monsters take about half the data (measured:
|
|
460
|
+
// 2,860 -> 1,563 bytes per update).
|
|
461
|
+
function rnd(k, x) { return QK[k] ? Math.round(x * 1000) / 1000 : LERP[k] && k !== "angle" && k !== "rot" ? Math.round(x * POS) / POS : Math.round(x * 100) / 100; }
|
|
462
|
+
function trim(v, depth) {
|
|
463
|
+
if (!isObj(v) || depth > 4) return v;
|
|
464
|
+
var out = {};
|
|
465
|
+
for (var k in v) { var x = v[k]; out[k] = typeof x === "number" ? rnd(k, x) : isObj(x) ? trim(x, depth + 1) : x; }
|
|
466
|
+
return out;
|
|
467
|
+
}
|
|
468
|
+
function flush() {
|
|
469
|
+
flushT = 0;
|
|
470
|
+
lastSend = Date.now();
|
|
471
|
+
if (fxQ.length) {
|
|
472
|
+
var fn = lastSend; while (fxTimes.length && fn - fxTimes[0] >= 1000) fxTimes.shift();
|
|
473
|
+
var room = Math.max(1, 20 - fxTimes.length); // at least one per slot, so the queue always drains
|
|
474
|
+
while (fxQ.length && room-- > 0) { fxTimes.push(fn); send(fxQ.shift()); }
|
|
475
|
+
if (fxQ.length) schedule();
|
|
476
|
+
}
|
|
477
|
+
if (mePend) { var md = diff(sentMe, mePend); if (md) { send({ t: "me", s: md, c: Math.round(meAt) }); merge(sentMe, copy(md)); } mePend = null; }
|
|
478
|
+
if (profPend) { var pp = chunks(profPend, tooBig); for (var pi = 0; pi < pp.length; pi++) send({ t: "save", p: pp[pi] }); profPend = null; }
|
|
479
|
+
if (sharedPend && R.host === R.you) {
|
|
480
|
+
var sd = diff(sentShared, trim(sharedPend, 0));
|
|
481
|
+
if (sd) { var parts = chunks(sd, tooBig); for (var i = 0; i < parts.length; i++) send({ t: "shared", term: R.term, p: parts[i], c: Math.round(sharedAt) }); merge(sentShared, copy(sd)); }
|
|
482
|
+
}
|
|
483
|
+
sharedPend = null;
|
|
484
|
+
}
|
|
485
|
+
function tooBig(path) { emit("error", "too_big", path); resync(); }
|
|
486
|
+
function schedule() {
|
|
487
|
+
if (flushT) return;
|
|
488
|
+
var wait = Math.max(0, sendGap() - (Date.now() - lastSend));
|
|
489
|
+
flushT = setTimeout(flush, wait);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** The others, as they should be drawn now: numbers of declared fields eased between the two updates around (server time - DELAY). */
|
|
493
|
+
function view() {
|
|
494
|
+
var t = drawTime(), out = [], spec = (R.spec && R.spec.player) || {};
|
|
495
|
+
for (var id in R.players) {
|
|
496
|
+
var p = R.players[id], s = p.state;
|
|
497
|
+
if (id !== R.you && p.buf.length > 1) {
|
|
498
|
+
var b = p.buf, i = b.length - 1;
|
|
499
|
+
while (i > 0 && b[i - 1].at > t) i--;
|
|
500
|
+
var A = b[Math.max(0, i - 1)], B = b[i];
|
|
501
|
+
if (A !== B && B.at > A.at) {
|
|
502
|
+
var f = frac(t, A, B);
|
|
503
|
+
s = copy(B.s);
|
|
504
|
+
for (var k in spec) if (typeof A.s[k] === "number" && typeof B.s[k] === "number") s[k] = ease(k, A.s[k], B.s[k], f);
|
|
505
|
+
if (spec.qx || spec.qw) quatEase(s, A.s, B.s, f);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
out.push({ id: id, name: p.name, online: p.online, away: !!p.hidden, me: id === R.you, host: id === R.host, state: s });
|
|
509
|
+
}
|
|
510
|
+
out.sort(function (a, b) { return a.me ? -1 : b.me ? 1 : a.name < b.name ? -1 : 1; });
|
|
511
|
+
return out;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
window.tickcli = {
|
|
515
|
+
state: R,
|
|
516
|
+
on: function (name, fn) { (listeners[name] = listeners[name] || []).push(fn); },
|
|
517
|
+
/** Resolves once the app answered (or with null after ~10 s: no app around the page, e.g. a downloaded copy). */
|
|
518
|
+
ready: function () {
|
|
519
|
+
return new Promise(function (res) {
|
|
520
|
+
if (info) return res(info);
|
|
521
|
+
if (R.status === "unavailable") return res(null);
|
|
522
|
+
ready.push(res);
|
|
523
|
+
listeners.status = listeners.status || [];
|
|
524
|
+
listeners.status.push(function (s) { if (s === "unavailable") res(null); });
|
|
525
|
+
});
|
|
526
|
+
},
|
|
527
|
+
join: function (o) { R.spec = o.spec; R.closed = null; post({ k: "open", join: { spec: o.spec, code: o.code, priv: !!o.priv, alone: !!o.alone, name: o.name, f: FEATS } }); },
|
|
528
|
+
leave: function () { send({ t: "leave" }); R.status = "idle"; },
|
|
529
|
+
me: function () { return R.players[R.you] || null; },
|
|
530
|
+
setMe: function (patch) {
|
|
531
|
+
var p = R.players[R.you];
|
|
532
|
+
if (!p || !patch || typeof patch !== "object") return;
|
|
533
|
+
var s = shape(patch);
|
|
534
|
+
if (!checked(s)) return;
|
|
535
|
+
merge(p.state, s);
|
|
536
|
+
mePend = mePend || {};
|
|
537
|
+
join(mePend, s);
|
|
538
|
+
meAt = serverMs();
|
|
539
|
+
schedule();
|
|
540
|
+
},
|
|
541
|
+
setShared: function (patch) {
|
|
542
|
+
if (R.host !== R.you || !patch || typeof patch !== "object") return false;
|
|
543
|
+
if (!checked(patch)) return false;
|
|
544
|
+
R.shared = cow(R.shared, patch);
|
|
545
|
+
sharedPend = sharedPend || {};
|
|
546
|
+
join(sharedPend, patch);
|
|
547
|
+
sharedAt = serverMs();
|
|
548
|
+
schedule();
|
|
549
|
+
return true;
|
|
550
|
+
},
|
|
551
|
+
act: function (name, data) {
|
|
552
|
+
return new Promise(function (res) {
|
|
553
|
+
var id = ++actSeq;
|
|
554
|
+
acts[id] = { done: res, t: setTimeout(function () { delete acts[id]; res({ ok: false, error: "no answer" }); }, 8000) };
|
|
555
|
+
send({ t: "act", id: id, name: String(name), data: data === undefined ? null : data });
|
|
556
|
+
});
|
|
557
|
+
},
|
|
558
|
+
answer: function (id, ok, error) { send({ t: "result", id: id, ok: !!ok, error: error ? String(error).slice(0, 80) : undefined }); },
|
|
559
|
+
fx: function (name, data) {
|
|
560
|
+
var m = { t: "fx", name: String(name), data: data === undefined ? null : data }, n = Date.now();
|
|
561
|
+
while (fxTimes.length && n - fxTimes[0] >= 1000) fxTimes.shift();
|
|
562
|
+
if (fxTimes.length < 20 && !fxQ.length) { fxTimes.push(n); return send(m); }
|
|
563
|
+
if (fxQ.length >= 64) fxQ.shift(); // cosmetic: the oldest waiting one goes
|
|
564
|
+
fxQ.push(m); schedule();
|
|
565
|
+
},
|
|
566
|
+
/** The host's game loop calls this every frame; once a second it tells the server the game is running. */
|
|
567
|
+
alive: function () { var n = Date.now(); if (n - aliveAt >= 1000) { aliveAt = n; send({ t: "alive", term: R.term }); } },
|
|
568
|
+
/** Continue the saved world (resume true) or start a new game. */
|
|
569
|
+
start: function (resume) { if (sharedPend || profPend) flush(); send(resume ? { t: "start", resume: true } : { t: "start" }); },
|
|
570
|
+
/** The person's saved profile (an object), or null when saving is off here. Asked once; answers within 3 s. */
|
|
571
|
+
profile: function () {
|
|
572
|
+
return new Promise(function (res) {
|
|
573
|
+
if (prof) return res(copy(prof));
|
|
574
|
+
if (profOff) return res(null);
|
|
575
|
+
var w = { res: res, t: setTimeout(function () { var i = profWait.indexOf(w); if (i >= 0) profWait.splice(i, 1); if (!profWait.length) profGaveUp = true; res(null); }, 3000) };
|
|
576
|
+
profWait.push(w);
|
|
577
|
+
if (profWait.length === 1) send({ t: "profile" });
|
|
578
|
+
});
|
|
579
|
+
},
|
|
580
|
+
/** A profile that arrived after profile() gave up: take it (saving switches on) and get a copy, or null when there is none. */
|
|
581
|
+
adoptProfile: function () { if (!profLate) return null; prof = profLate; profLate = null; profGaveUp = false; return copy(prof); },
|
|
582
|
+
/** Merge into the saved profile (same rules as shared writes); goes out within a second. False when saving is off. */
|
|
583
|
+
setProfile: function (patch) {
|
|
584
|
+
if (!prof || !patch || typeof patch !== "object") return false;
|
|
585
|
+
if (!checked(patch)) return false;
|
|
586
|
+
merge(prof, copy(patch));
|
|
587
|
+
profPend = profPend || {};
|
|
588
|
+
join(profPend, patch);
|
|
589
|
+
if (!profT) profT = setTimeout(function () { profT = 0; flush(); }, 1000);
|
|
590
|
+
return true;
|
|
591
|
+
},
|
|
592
|
+
/** Host: the saved game is over (won, lost): forget the world save; nothing is saved again until the next start. */
|
|
593
|
+
clearWorld: function () { send({ t: "world", clear: true }); },
|
|
594
|
+
/** Send what is pending now (own state, host writes, profile). */
|
|
595
|
+
flush: function () { if (mePend || sharedPend || profPend) flush(); }, // seats and brackets the host just wrote arrive before the start
|
|
596
|
+
end: function () { if (sharedPend || profPend) flush(); send({ t: "end" }); },
|
|
597
|
+
kick: function (id) { send({ t: "kick", player: id }); },
|
|
598
|
+
rename: function (n) { send({ t: "name", name: String(n).slice(0, 40) }); },
|
|
599
|
+
invite: function () { if (R.code) post({ k: "invite", code: R.code }); },
|
|
600
|
+
/** Only when state.anyone (the host app offers it): the app shows how to let people without an account play (its public link). */
|
|
601
|
+
inviteAnyone: function () { if (R.anyone) post({ k: "invite-anyone" }); },
|
|
602
|
+
/** Where a patch breaks the limits the server keeps (a path such as "mobs.12.name"), or null when it is fine. */
|
|
603
|
+
check: function (patch) { return badPath(patch, 0, ""); },
|
|
604
|
+
/** The open rooms of this game you could join instead (needs a joined room; at most once a second, more often answers from the last list).
|
|
605
|
+
* Resolves [{code, host, players, away, max, started, match, ping, slowed, full, you?}] (ping: the host's round trip to the server in ms,
|
|
606
|
+
* 0 = not known yet), null when the server does not list rooms, or [] after 3 s without an answer. */
|
|
607
|
+
rooms: function () {
|
|
608
|
+
return new Promise(function (res) {
|
|
609
|
+
if (R.status !== "open") return res([]);
|
|
610
|
+
var n = Date.now();
|
|
611
|
+
if (roomsLast && n - roomsLast.at < 1000) return res(roomsLast.list);
|
|
612
|
+
var w = { done: function (list) { roomsLast = { at: Date.now(), list: list }; res(list); }, t: 0 };
|
|
613
|
+
w.t = setTimeout(function () { var i = roomsWait.indexOf(w); if (i >= 0) roomsWait.splice(i, 1); res([]); }, 3000);
|
|
614
|
+
roomsWait.push(w);
|
|
615
|
+
if (roomsWait.length === 1) send({ t: "rooms", spec: R.spec });
|
|
616
|
+
});
|
|
617
|
+
},
|
|
618
|
+
/** How this page is doing: {rate (1 = every update, 2 = every second one, 4), ask, lateMs (frames late beyond this link's usual, ms), fps,
|
|
619
|
+
* rtt (round trip to the server, ms), every (ms between updates on a slower rate)}. Event "quality" when it changes. */
|
|
620
|
+
quality: quality,
|
|
621
|
+
/** game.js tells the page how fast it draws (frames a second). */
|
|
622
|
+
frameRate: deviceFps,
|
|
623
|
+
/** The person pressed "keep this room" after an idle warning (any move, action or effect counts too). */
|
|
624
|
+
stay: function () { send({ t: "stay" }); },
|
|
625
|
+
now: function () { return serverMs() / 1000; },
|
|
626
|
+
/** Decimals kept on positions (x, y, z) the host shares: 0 (default, pixels) to 3. Player fields follow their declared range instead. */
|
|
627
|
+
precision: function (n) { n = Math.max(0, Math.min(3, Math.floor(Number(n)) || 0)); POS = Math.pow(10, n); },
|
|
628
|
+
view: view,
|
|
629
|
+
/** The shared state as it should be drawn now (positions eased on every screen but the host's). */
|
|
630
|
+
sharedView: sharedView,
|
|
631
|
+
};
|
|
632
|
+
})();
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/** The command line as options; throws a message for anything it does not understand. */
|
|
3
|
+
export declare function parseArgs(argv: string[], env?: Record<string, string | undefined>): {
|
|
4
|
+
games: string;
|
|
5
|
+
port: number;
|
|
6
|
+
host?: string;
|
|
7
|
+
saves?: string;
|
|
8
|
+
title?: string;
|
|
9
|
+
proxy?: boolean;
|
|
10
|
+
help: boolean;
|
|
11
|
+
};
|