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/game.js
ADDED
|
@@ -0,0 +1,1003 @@
|
|
|
1
|
+
/* tickcli game.js - a shell for one-file mini games, alone or together. MIT. Load it (after room.js for multiplayer), then call Game({...}) once.
|
|
2
|
+
It owns: fitting the board to the space that is left (never clipped), crisp canvas on high-DPI screens, the score / best / pause bar, start, pause and game-over
|
|
3
|
+
screens, keyboard (arrows, WASD, space), swipe and an on-screen d-pad on touch devices, no phone zoom (quick double or triple taps, tap-then-hold, pinch,
|
|
4
|
+
long-press menu), pause when the tab is hidden, and a fixed-step loop.
|
|
5
|
+
You write: setup, update and draw.
|
|
6
|
+
|
|
7
|
+
Game({
|
|
8
|
+
title: "Snake", help: "Arrow keys or swipe to steer.", // shown on the start screen
|
|
9
|
+
aspect: 1, // board width / height (1 = square)
|
|
10
|
+
dpad: "auto", // "auto" (touch screens only) | "always" | "never"
|
|
11
|
+
step: 1 / 60, // seconds per update call
|
|
12
|
+
exit: "/", // multiplayer: an "Exit" button in the top bar leaves the room and goes to this URL (also <meta name="tickcli-exit"
|
|
13
|
+
// content="/"> in the page). Without it the button reads "Leave": out of this room into a new private one (lobby)
|
|
14
|
+
scores: false, // true = at game over, hand the score to window.gameScores (a high-score board the host app provides, if any)
|
|
15
|
+
scoresBetter: "high", // "low" when a smaller score wins (a time)
|
|
16
|
+
setup(g) { g.score = 0; ... }, // start or restart: reset ALL state here (multiplayer: the host may also set starting g.shared values here)
|
|
17
|
+
update(g, dt) { ... g.gameOver() ... }, // fixed step; g.score += 1 changes the score
|
|
18
|
+
draw(g, ctx, w, h) { ... }, // w, h are CSS pixels; ctx is already scaled
|
|
19
|
+
onInput(g, action) { ... } // "up" "down" "left" "right" "action" (space, tap)
|
|
20
|
+
})
|
|
21
|
+
Action games: g.held.left/right/up/down are true while a direction is held (keys or the touch pad); g.axis() gives {x, y} to move by.
|
|
22
|
+
g.state is "ready" | "playing" | "paused" | "over". g.best is kept in memory only. g.start() g.pause() g.restart() g.gameOver().
|
|
23
|
+
|
|
24
|
+
3D (three.js or plain WebGL): context: "webgl". The shell then takes no 2D context: g.canvas is yours (new THREE.WebGLRenderer({canvas: g.canvas})),
|
|
25
|
+
draw(g, null, w, h) runs each frame with w, h in CSS pixels, and the drawing size is yours too: when g.w, g.h or g.dpr change (the quality ladder
|
|
26
|
+
lowers g.dpr on a slow device) call renderer.setPixelRatio(g.dpr) and renderer.setSize(g.w, g.h, false). client/three.js (TickcliThree) does that
|
|
27
|
+
and more. No overlay() here (draw a HUD in the scene or in HTML). g.pointer.nx, ny are -1..1 (y up), ready for a raycaster. Positions the host
|
|
28
|
+
shares keep 2 decimals (metres to the centimetre; multiplayer.decimals changes it). Rotations: a quaternion in qx, qy, qz, qw (players declare
|
|
29
|
+
each as [-1, 1]) is eased as one turn on other screens.
|
|
30
|
+
|
|
31
|
+
Your own engine (Phaser, PixiJS, Babylon.js, Kaplay, PlayCanvas, a loop of your own): headless: true. The shell then adds only the lobby, the
|
|
32
|
+
notices and the countdown over your page (clicks pass through everywhere else) and leaves the page, its canvas, loop and touches alone. It still
|
|
33
|
+
runs the room: setup(g) at each match start, host(g, dt) on the host, onAction, onFx, g.me.set, g.shared.set, g.players (eased), g.gameOver().
|
|
34
|
+
From your engine's own update, read g.state ("playing" while a match runs), g.players and g.shared, and call g.me.set({x, y}). No draw, no
|
|
35
|
+
board, no d-pad here (g.held and g.axis() still follow the keys). g.sync(make, place, remove) keeps one engine object per other player:
|
|
36
|
+
if (g.state === "playing") g.me.set({x: hero.x, y: hero.y});
|
|
37
|
+
g.sync(function (p) { return addSprite(p); }, function (s, st) { s.x = st.x; s.y = st.y; }, function (s) { s.destroy(); });
|
|
38
|
+
|
|
39
|
+
Playing together (also load room.js before this file): pass multiplayer: {max: 4, player: {x: [0, 800], y: [0, 600]}}.
|
|
40
|
+
The shell then shows the lobby (room code, invite link, players, Start for the host), reconnects, and shows notices. Your code:
|
|
41
|
+
update(g, dt) every player: move yourself, g.me.set({x, y}) (sent 12 times a second at most, clamped to the declared ranges)
|
|
42
|
+
host(g, dt) runs on ONE player only (the host): rounds, timers, health, score, who won: g.shared.set({...}); g.gameOver() ends the match
|
|
43
|
+
onAction(g, from, name, data) host only: a player called g.act(name, data); return false to refuse
|
|
44
|
+
onFx(g, from, name, data) everyone: a cosmetic effect someone sent with g.fx(name, data) (sounds, sparks), yours included (others' arrive
|
|
45
|
+
with the next frame). g.shared.set may get the whole object every step: only changed values are sent
|
|
46
|
+
draw(g, ctx, w, h) draw g.players ([{id, name, me, host, online, away, state}]) and g.shared; both are read-only (frozen: copy before changing, e.g. list.slice().sort())
|
|
47
|
+
g.me = {id, name, team, state, set}, g.isHost, g.now (shared clock, seconds), g.kick(id) (host), g.room = {code, match, players}.
|
|
48
|
+
Teams: multiplayer: {teams: 2, ...}: the lobby splits players evenly (anyone can switch before the start), g.me.team and each player's team
|
|
49
|
+
(0, 1, ...), g.teams = [[ids of team 0], ...], g.teamColor(i). Things the host moves in g.shared (monsters, minions, shots) are drawn smoothly
|
|
50
|
+
on every screen when their position fields are named x and y (also z, angle, rot). Writes merge: g.shared.set({hp: {[id]: 80}}) changes one entry.
|
|
51
|
+
setup(g) runs for everyone at the start of each match (also on a page that reloads into a running match: its g.shared writes are then ignored).
|
|
52
|
+
g.gameOver() counts once per match. A held action key is one press. With no app around the page (a downloaded copy) it runs as a one-player game.
|
|
53
|
+
|
|
54
|
+
Board units, big worlds, pointer, extra buttons (all optional, alone or together):
|
|
55
|
+
size: [800, 600] draw in these units whatever the screen size (draw gets w = 800, h = 600; aspect follows)
|
|
56
|
+
world: [2400, 1600] a world bigger than the board: call g.camera.follow(x, y) in update; draw in world units; overlay(g, ctx, w, h) draws on top
|
|
57
|
+
in board units (scores, lap counter, mini map)
|
|
58
|
+
g.pointer = {x, y, down} in board (world) units; onPointer(g, x, y) for a click or tap (chess squares, aiming)
|
|
59
|
+
buttons: [{id: "dash", label: "Dash", key: "Shift"}] up to 4 extra buttons: g.held.dash, onInput(g, "dash"); shown on touch screens
|
|
60
|
+
More for playing together:
|
|
61
|
+
multiplayer.seats: 2 only that many play; everyone else in the room (up to max) watches. g.seats = [id or null], g.me.seat (-1 = watching),
|
|
62
|
+
g.me.watching, player.seat. seatNames: ["White", "Black"]. Watchers get no update(), input or actions. min: 2 = players needed.
|
|
63
|
+
multiplayer.cup: true with seats: a knockout tournament. The shell draws the bracket, seats each match and moves winners on; the game only
|
|
64
|
+
plays one match and ends it with g.gameOver(winnerId) (no winner given: the best g.shared.score among the seated).
|
|
65
|
+
multiplayer.countdown: 3 3-2-1 before each match on the shared clock; update() and host() wait for it (g.countdown = seconds left)
|
|
66
|
+
g.shoot({x, y, vx, vy, life: 2, r: 4, kind: "laser"}) a shot every screen moves by itself (no shared state): draw g.shots
|
|
67
|
+
([{id, from, team, kind, r, x, y, vx, vy, age}]); in host() test them against targets and call g.hit(shot) to remove one.
|
|
68
|
+
g.late true in setup() on a page that came into a match already running (a late joiner, a reload): start next to
|
|
69
|
+
the others instead of on the start line.
|
|
70
|
+
g.spawnNear({x: [0, W], y: [0, H], gap: 120}) {x, y} to start or respawn where the others can be seen: at the start of a match a ring
|
|
71
|
+
round the middle (or at: [x, y]); later (a late joiner, a respawn) gap away from a player still in the game.
|
|
72
|
+
keys: ["x", "z"] for 3D, skip(player) leaves someone out (e.g. a dead player).
|
|
73
|
+
Angles named angle or rot are radians and turn the short way round on other screens.
|
|
74
|
+
|
|
75
|
+
One game, both ways: a game with multiplayer: {...} also plays alone. The lobby offers "Play alone" (a private room of one: starts at once, keeps the
|
|
76
|
+
player's saved profile and saved game; min must be 1, no seats short of it, no cup) and, in a room of one, "Play with others"; End in the bar (alone only) ends the game. "Invite anyone" (when the host app offers it) asks the app
|
|
77
|
+
to let people without an account in (its public link). With no host
|
|
78
|
+
app around the page it simply runs as the one-player game. Write the game for g.players (1 to max) and it works in both.
|
|
79
|
+
|
|
80
|
+
Saved games (together mode, with a host app that keeps saves; otherwise everything below is simply off and the game starts fresh each time):
|
|
81
|
+
g.profile = {...} the player's own saved data (stats, level, items, weapons, outfits, hp, mp, lives left): a read-only copy, loaded before
|
|
82
|
+
setup() runs. g.profile.set({level: 3, items: {sword: 1}}) merges (like shared writes; null removes a key), is kept a
|
|
83
|
+
second later and when a match ends. Plain JSON, 16 KB, nesting 4 deep. Read g.profile each time (set() makes a new copy).
|
|
84
|
+
g.saves is true when saving works. What a player shows others still goes through g.me.set.
|
|
85
|
+
multiplayer.save: true the host's g.shared (map level, monsters and their stats, progress) is kept every few seconds and when a match ends.
|
|
86
|
+
A new room then offers Continue and New game; Continue starts from the saved state and g.resumed is true: setup() must
|
|
87
|
+
not reset what is in g.shared (writes to g.shared inside setup() are ignored while g.resumed). g.clearSave() (host)
|
|
88
|
+
forgets the saved game, e.g. when the dungeon is cleared or everyone died. New game over a save asks first. One room
|
|
89
|
+
at a time plays a save: a second room of the same game plays unsaved and its lobby says so. A save that
|
|
90
|
+
could not be read is never written over: the room plays unsaved, the lobby says so, and Continue appears
|
|
91
|
+
once a retry reads it.
|
|
92
|
+
|
|
93
|
+
Limits of every write (g.shared.set, g.me.set, g.profile.set): text up to 200 characters, lists up to 64 items, 64 keys per object, 4 levels deep,
|
|
94
|
+
2 KB per player, 64 KB shared. A write that breaks one is refused whole: nothing changes, nobody gets it, the screen says so once and the console
|
|
95
|
+
says where ("room: bad_value at mobs.12.name"). Sizes count bytes (non-English letters 2 to 4 each). */
|
|
96
|
+
(function () {
|
|
97
|
+
// The page itself (full screen, dark) in its own style element: a headless game (your own engine) keeps its page as it is.
|
|
98
|
+
var pageCss = "html,body{margin:0;height:100%;overflow:hidden;background:var(--bg,#0b1020);color:var(--ink,#e8edf7);font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif}";
|
|
99
|
+
var css = ".tg{position:fixed;inset:0;background:var(--bg,#0b1020);display:flex;flex-direction:column;user-select:none;-webkit-user-select:none;-webkit-touch-callout:none;-webkit-tap-highlight-color:transparent;touch-action:none}" +
|
|
100
|
+
// A box that scrolls or clips starts its own touch-action, so the overlays and the lobby say again that a quick double tap never zooms.
|
|
101
|
+
".tg-ov,.tg-lob{touch-action:pan-y}.tg button{touch-action:manipulation}.tg-pad button,.tg-acts button{touch-action:none}.tg input{user-select:text;-webkit-user-select:text;-webkit-touch-callout:default}" +
|
|
102
|
+
".tg-bar{flex:none;display:flex;align-items:center;gap:12px;padding:8px 12px;font-size:15px;border-bottom:1px solid rgba(148,163,184,.25)}" +
|
|
103
|
+
".tg-bar b{font-size:17px;margin-right:auto}.tg-bar span{opacity:.8;font-variant-numeric:tabular-nums}.tg-bar button.lv{padding:0 12px}.tg-bar button{min-height:36px;min-width:36px;border-radius:10px;border:1px solid rgba(148,163,184,.35);background:transparent;color:inherit;font:inherit;cursor:pointer}" +
|
|
104
|
+
".tg-area{flex:1 1 auto;min-height:0;display:flex;align-items:center;justify-content:center;padding:8px}" +
|
|
105
|
+
".tg-board{position:relative;display:block;container-type:size}.tg-board canvas{display:block;width:100%;height:100%;border-radius:10px;background:rgba(15,23,42,.35)}" +
|
|
106
|
+
".tg-ov{position:absolute;inset:0;display:none;flex-direction:column;align-items:center;gap:clamp(4px,2.5cqmin,10px);text-align:center;padding:clamp(6px,4cqmin,16px);border-radius:10px;background:rgba(8,12,24,.78);color:#f1f5f9;overflow:hidden}" +
|
|
107
|
+
".tg-ov>:first-child{margin-top:auto}.tg-ov>:last-child{margin-bottom:auto}" +
|
|
108
|
+
".tg-ov.on{display:flex}.tg-ov h2{flex:none;margin:0;font-size:clamp(15px,7cqmin,30px);line-height:1.15}.tg-ov p{margin:0;opacity:.85;max-width:30ch;flex:0 1 auto;min-height:0;overflow-y:auto;overscroll-behavior:contain;touch-action:pan-y;font-size:clamp(11px,3.8cqmin,16px);line-height:1.35}.tg-ov button{flex:none;min-height:clamp(36px,10cqmin,44px);padding:0 22px;border-radius:12px;border:0;font:600 clamp(14px,4.2cqmin,17px) system-ui;background:var(--accent,#60a5fa);color:var(--accent-ink,#0b1020);cursor:pointer}" +
|
|
109
|
+
".tg-ctl{flex:none;display:flex;align-items:center;justify-content:space-evenly}" +
|
|
110
|
+
".tg-pad{flex:none;display:none;grid-template-columns:repeat(3,56px);grid-template-rows:repeat(2,52px);gap:6px;justify-content:center;padding:6px 0 10px}" +
|
|
111
|
+
".tg-pad.on{display:grid}.tg-pad button{border-radius:14px;border:1px solid rgba(148,163,184,.35);background:rgba(148,163,184,.12);color:inherit;font-size:22px;cursor:pointer;touch-action:none}.tg-pad .u{grid-column:2;grid-row:1}.tg-pad .l{grid-column:1;grid-row:2}.tg-pad .d{grid-column:2;grid-row:2}.tg-pad .r{grid-column:3;grid-row:2}" +
|
|
112
|
+
".tg:has(.tg-lob.on) .tg-ctl{visibility:hidden}.tg-area{position:relative}.tg-lob{position:absolute;inset:0;z-index:5;display:none;overflow-y:auto;overscroll-behavior:contain;touch-action:pan-y;padding:16px;background:rgba(8,12,24,.94);color:#f1f5f9;font-size:16px;line-height:1.4;text-align:left}" +
|
|
113
|
+
".tg-lob.on{display:flex}.tg-lob .card{margin:auto;width:100%;max-width:26rem;display:flex;flex-direction:column;gap:14px}.tg-lob h2{margin:0;font-size:1.6rem;line-height:1.2;text-align:center}" +
|
|
114
|
+
".tg-lob p{margin:0}.tg-lob .status{text-align:center;font-size:1.05rem;color:#e2e8f0}.tg-lob .help{text-align:center;font-size:.95rem;color:#cbd5e1}.tg-lob .note{text-align:center;font-size:.85rem;color:#94a3b8}" +
|
|
115
|
+
".tg-lob .box{display:flex;flex-direction:column;gap:10px;padding:12px 14px;border-radius:14px;background:rgba(148,163,184,.1);border:1px solid rgba(148,163,184,.18)}" +
|
|
116
|
+
".tg-lob .lbl{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:24px;font-size:.78rem;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:#94a3b8}.tg-lob .sub{font-size:.85rem;font-weight:700}" +
|
|
117
|
+
".tg-lob .invite{flex-direction:row;align-items:center;justify-content:space-between;flex-wrap:wrap}.tg-lob .code{font:700 2rem ui-monospace,Menlo,monospace;letter-spacing:.16em;line-height:1.1;color:#f8fafc}" +
|
|
118
|
+
".tg-lob ul{list-style:none;margin:0;padding:0;display:flex;flex-wrap:wrap;gap:6px}.tg-lob .who li{padding:6px 12px;border-radius:999px;background:rgba(148,163,184,.16);font-size:.95rem}.tg-lob .who li.me{box-shadow:inset 0 0 0 2px var(--accent,#60a5fa)}.tg-lob li.away{opacity:.55}" +
|
|
119
|
+
".tg-lob button{min-height:44px;padding:0 18px;border-radius:12px;border:0;font:600 1rem system-ui,sans-serif;background:var(--accent,#60a5fa);color:var(--accent-ink,#0b1020);cursor:pointer}.tg-lob button.sec{background:rgba(148,163,184,.2);color:inherit}" +
|
|
120
|
+
".tg-lob button.big{width:100%;min-height:52px;font-size:1.1rem}.tg-lob button.link{min-height:32px;padding:0 4px;background:none;color:var(--accent,#60a5fa);font-weight:500;text-decoration:underline;text-underline-offset:3px}.tg-lob .lbl button.link{text-transform:none;letter-spacing:0;font-size:.85rem}" +
|
|
121
|
+
".tg-lob button:disabled{opacity:.45;cursor:default}.tg-lob button:focus-visible,.tg-lob input:focus-visible{outline:3px solid var(--accent,#60a5fa);outline-offset:2px}" +
|
|
122
|
+
".tg-lob .row{display:flex;gap:8px;flex-wrap:wrap}.tg-lob .row>button{flex:1 1 auto}.tg-lob .links{display:flex;flex-wrap:wrap;justify-content:center;gap:4px 16px}" +
|
|
123
|
+
".tg-lob input{flex:1 1 8em;min-width:0;min-height:44px;padding:0 12px;border-radius:12px;border:1px solid rgba(148,163,184,.45);background:rgba(15,23,42,.7);color:inherit;font:inherit}" +
|
|
124
|
+
".tg-lob ul.rooms{flex-direction:column;flex-wrap:nowrap}.tg-lob .rooms li{display:flex;align-items:center;gap:10px;padding:8px 8px 8px 12px;border-radius:12px;background:rgba(148,163,184,.1)}" +
|
|
125
|
+
".tg-lob .rooms .t{flex:1;min-width:0;display:flex;flex-direction:column}.tg-lob .rooms b{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tg-lob .rooms small{font-size:.85rem;color:#94a3b8}" +
|
|
126
|
+
".tg-lob .warn{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:12px;background:rgba(250,204,21,.12);border:1px solid rgba(250,204,21,.45);color:#fde68a;font-size:.92rem}.tg-lob .warn span{flex:1}" +
|
|
127
|
+
".tg-lob .cup{font-size:.9rem;text-align:center;opacity:.95}.tg-lob .cup b{color:var(--accent,#60a5fa)}" +
|
|
128
|
+
".tg-count{position:absolute;inset:0;display:none;align-items:center;justify-content:center;font:800 clamp(48px,34cqmin,180px) system-ui;color:#f8fafc;text-shadow:0 4px 24px rgba(0,0,0,.7);pointer-events:none}.tg-count.on{display:flex}" +
|
|
129
|
+
".tg-acts{flex:none;display:none;gap:8px;flex-wrap:wrap;justify-content:center;max-width:50%;padding:6px 0 10px}.tg-acts.on{display:flex}.tg-acts button{min-height:48px;min-width:72px;padding:0 14px;border-radius:14px;border:1px solid rgba(148,163,184,.35);background:rgba(148,163,184,.12);color:inherit;font:600 15px system-ui;cursor:pointer;touch-action:none}" +
|
|
130
|
+
".tg-toast{position:absolute;z-index:6;left:50%;top:8px;transform:translateX(-50%);max-width:90%;padding:6px 12px;border-radius:10px;background:rgba(15,23,42,.92);color:#f8fafc;font-size:13px;pointer-events:none;opacity:0;transition:opacity .25s}.tg-toast.on{opacity:1}" +
|
|
131
|
+
// headless: only the lobby, notices and the countdown, over the page's own engine; clicks pass through everywhere else
|
|
132
|
+
".tg.hl{background:transparent;pointer-events:none;z-index:2147483000;font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:14px;color:#e8edf7}.tg.hl>.tg-bar,.tg.hl .tg-board,.tg.hl .tg-ctl{display:none}.tg.hl .tg-area{padding:0}.tg.hl .tg-lob{pointer-events:auto}";
|
|
133
|
+
var st = document.createElement("style"); st.textContent = css; document.head.insertBefore(st, document.head.firstChild);
|
|
134
|
+
var stPage = document.createElement("style"); stPage.textContent = pageCss; document.head.insertBefore(stPage, st);
|
|
135
|
+
|
|
136
|
+
window.Game = function (o) {
|
|
137
|
+
// Called from a script in <head> (or before <body>): wait for the page body instead of failing; window.game is set once it runs.
|
|
138
|
+
if (!document.body) { document.addEventListener("DOMContentLoaded", function () { window.Game(o); }, { once: true }); return null; }
|
|
139
|
+
o = o || {};
|
|
140
|
+
var HL = !!o.headless; // your own engine (Phaser, PixiJS, Babylon, Kaplay...) draws, loops and reads input; the shell keeps rooms, lobby and host()
|
|
141
|
+
if (HL && stPage.parentNode) stPage.parentNode.removeChild(stPage);
|
|
142
|
+
var step = o.step || 1 / 60;
|
|
143
|
+
var two = function (v) { return Array.isArray(v) && v[0] > 0 && v[1] > 0 ? [Number(v[0]), Number(v[1])] : null; };
|
|
144
|
+
var WORLD = two(o.world), VIEW = two(o.size) || (WORLD ? [800, Math.round(800 / (o.aspect || 1))] : null);
|
|
145
|
+
var aspect = VIEW ? VIEW[0] / VIEW[1] : o.aspect || 1;
|
|
146
|
+
var g = { state: "ready", score: 0, best: 0, w: 0, h: 0, ctx: null, canvas: null, time: 0, opts: o, quality: { tier: "high", fps: 0, scale: 1, halfRate: false, rate: 1, rtt: 0 } };
|
|
147
|
+
// The camera: the top-left corner of the board inside the world, kept inside it.
|
|
148
|
+
g.camera = { x: 0, y: 0, follow: function (x, y) {
|
|
149
|
+
var vw = VIEW ? VIEW[0] : g.w, vh = VIEW ? VIEW[1] : g.h, ww = WORLD ? WORLD[0] : vw, wh = WORLD ? WORLD[1] : vh;
|
|
150
|
+
this.x = Math.max(0, Math.min(ww - vw, (Number(x) || 0) - vw / 2)); this.y = Math.max(0, Math.min(wh - vh, (Number(y) || 0) - vh / 2));
|
|
151
|
+
} };
|
|
152
|
+
g.pointer = { x: 0, y: 0, sx: 0, sy: 0, nx: 0, ny: 0, down: false };
|
|
153
|
+
// Your engine's objects for the other players, kept in step: g.sync(make(player) -> sprite, place(sprite, state, player), remove(sprite)) once a
|
|
154
|
+
// frame. New players get make(), everyone gets place() with their eased state, players who left get remove(). Returns the Map (id -> sprite).
|
|
155
|
+
var synced = new Map();
|
|
156
|
+
g.sync = function (make, place, remove) {
|
|
157
|
+
var seen = {}, list = g.players || [];
|
|
158
|
+
for (var i = 0; i < list.length; i++) {
|
|
159
|
+
var p = list[i]; if (p.me || !p.state) continue;
|
|
160
|
+
seen[p.id] = 1;
|
|
161
|
+
var s = synced.get(p.id);
|
|
162
|
+
if (s === undefined) { try { s = make ? make(p) : null; } catch (e) { console.error(e); s = null; } if (s == null) continue; synced.set(p.id, s); }
|
|
163
|
+
if (place) try { place(s, p.state, p); } catch (e) { console.error(e); }
|
|
164
|
+
}
|
|
165
|
+
synced.forEach(function (s, id) { if (!seen[id]) { synced.delete(id); if (remove) try { remove(s); } catch (e) { console.error(e); } } });
|
|
166
|
+
return synced;
|
|
167
|
+
};
|
|
168
|
+
var root = document.createElement("div"); root.className = HL ? "tg hl" : "tg";
|
|
169
|
+
root.innerHTML = '<div class="tg-bar"><b></b><span class="s">Score 0</span><span class="b">Best 0</span><button class="p" aria-label="Pause">II</button></div>' +
|
|
170
|
+
'<div class="tg-area"><div class="tg-board"><canvas></canvas><div class="tg-ov on"><h2></h2><p></p><button></button></div></div></div>' +
|
|
171
|
+
'<div class="tg-pad"><button class="u" aria-label="Up">▲</button><button class="l" aria-label="Left">◀</button><button class="d" aria-label="Down">▼</button><button class="r" aria-label="Right">▶</button></div>';
|
|
172
|
+
document.body.appendChild(root);
|
|
173
|
+
var q = function (s) { return root.querySelector(s); };
|
|
174
|
+
var bar = q(".tg-bar b"), sEl = q(".s"), bEl = q(".b"), pBtn = q(".p"), area = q(".tg-area"), board = q(".tg-board"), canvas = q("canvas"), ov = q(".tg-ov"), pad = q(".tg-pad");
|
|
175
|
+
var ovH = ov.querySelector("h2"), ovP = ov.querySelector("p"), ovB = ov.querySelector("button");
|
|
176
|
+
// Shared high scores on: a trophy button opens the board (pausing the game first).
|
|
177
|
+
if (o.scores && window.gameScores && !window.gameScores.disabled) {
|
|
178
|
+
var tBtn = document.createElement("button"); tBtn.className = "t"; tBtn.setAttribute("aria-label", "High scores"); tBtn.title = "High scores"; tBtn.textContent = "\uD83C\uDFC6";
|
|
179
|
+
pBtn.parentNode.insertBefore(tBtn, pBtn);
|
|
180
|
+
tBtn.addEventListener("click", function () { if (g.state === "playing") g.pause(); window.gameScores.board(); });
|
|
181
|
+
}
|
|
182
|
+
var GL = o.context === "webgl"; // 3D: the game makes its own WebGL context on g.canvas and sizes the drawing buffer itself
|
|
183
|
+
var ctx = GL || HL ? null : canvas.getContext("2d"); g.canvas = HL ? null : canvas; g.ctx = ctx; g.webgl = GL; g.headless = HL;
|
|
184
|
+
bar.textContent = o.title || document.title || "Game";
|
|
185
|
+
var coarse = false; try { coarse = matchMedia("(pointer:coarse)").matches; } catch (e) {}
|
|
186
|
+
var wantPad = o.dpad === "always" || (o.dpad !== "never" && coarse && !!(o.update || o.onInput)); // a tap-only game (a board game) gets no pad
|
|
187
|
+
pad.classList.toggle("on", wantPad);
|
|
188
|
+
|
|
189
|
+
// ---- drawing speed: a device that cannot keep up draws at a lower resolution first (the screen's pixel ratio capped at 2, 1.5, 1, then
|
|
190
|
+
// 0.75), then at 30 frames a second (the game itself still steps at its normal rate). Measured each second while playing: under 45 frames a
|
|
191
|
+
// second or over 12 ms of drawing for 3 s in a row = one step down; 55+ frames and under 5 ms of drawing for 15 s = one step back up. The
|
|
192
|
+
// frame rate also goes to room.js, which asks the server for fewer updates when it stays under 20. quality: false turns this off.
|
|
193
|
+
var QL = { levels: [], li: 0, half: false, skip: false, frames: 0, paint: 0, t0: 0, bad: 0, good: 0, fps: 0 };
|
|
194
|
+
(function () {
|
|
195
|
+
var top = Math.min(3, window.devicePixelRatio || 1); QL.levels.push(top);
|
|
196
|
+
[2, 1.5, 1, 0.75].forEach(function (v) { if (v < QL.levels[QL.levels.length - 1] - 0.01) QL.levels.push(v); });
|
|
197
|
+
})();
|
|
198
|
+
function qualityNow() {
|
|
199
|
+
var net = mp && window.tickcli && window.tickcli.quality ? window.tickcli.quality() : null;
|
|
200
|
+
var low = QL.half || QL.li >= QL.levels.length - 1 || (net && net.rate >= 4), high = !QL.half && QL.li === 0 && (!net || net.rate === 1);
|
|
201
|
+
g.quality = { tier: high ? "high" : low ? "low" : "medium", fps: QL.fps, scale: QL.levels[QL.li], halfRate: QL.half, rate: net ? net.rate : 1, rtt: net ? net.rtt : 0 };
|
|
202
|
+
}
|
|
203
|
+
function measure(ts, paintMs) {
|
|
204
|
+
if (o.quality === false) return;
|
|
205
|
+
if (!QL.t0) { QL.t0 = ts; QL.frames = 0; QL.paint = 0; return; }
|
|
206
|
+
QL.frames++; QL.paint += paintMs;
|
|
207
|
+
var el = ts - QL.t0;
|
|
208
|
+
if (el < 1000) return;
|
|
209
|
+
if (el > 3000) { QL.t0 = ts; QL.frames = 0; QL.paint = 0; return; } // the tab was away or the loop stalled: not a reading
|
|
210
|
+
var fps = QL.frames * 1000 / el, pm = QL.paint / Math.max(1, QL.frames);
|
|
211
|
+
QL.fps = Math.round(fps); QL.t0 = ts; QL.frames = 0; QL.paint = 0;
|
|
212
|
+
if (mp && window.tickcli && window.tickcli.frameRate) window.tickcli.frameRate(fps);
|
|
213
|
+
var bad = QL.half ? fps < 25 || pm > 25 : fps < 45 || pm > 12, good = QL.half ? pm < 6 : fps >= 55 && pm < 5;
|
|
214
|
+
QL.bad = bad ? QL.bad + 1 : 0; QL.good = good ? QL.good + 1 : 0;
|
|
215
|
+
if (QL.bad >= 3) { QL.bad = 0; if (QL.li < QL.levels.length - 1) { QL.li++; fit(); } else QL.half = true; }
|
|
216
|
+
else if (QL.good >= 15) { QL.good = 0; if (QL.half) QL.half = false; else if (QL.li > 0) { QL.li--; fit(); } }
|
|
217
|
+
qualityNow();
|
|
218
|
+
}
|
|
219
|
+
function fit() {
|
|
220
|
+
if (HL) { g.w = innerWidth; g.h = innerHeight; g.dpr = QL.levels[QL.li]; return; }
|
|
221
|
+
var cs = getComputedStyle(area), pw = parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight), ph = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
|
|
222
|
+
var aw = area.clientWidth - pw, ah = area.clientHeight - ph;
|
|
223
|
+
var w = Math.max(120, Math.min(aw, ah * aspect)), h = w / aspect;
|
|
224
|
+
board.style.width = Math.floor(w) + "px"; board.style.height = Math.floor(h) + "px";
|
|
225
|
+
var dpr = QL.levels[QL.li];
|
|
226
|
+
if (!GL) { canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); ctx.setTransform(dpr, 0, 0, dpr, 0, 0); }
|
|
227
|
+
g.w = w; g.h = h; g.dpr = dpr; paint();
|
|
228
|
+
}
|
|
229
|
+
function paint() {
|
|
230
|
+
try {
|
|
231
|
+
if (!o.draw) return;
|
|
232
|
+
if (GL || HL || !VIEW) return void o.draw(g, ctx, g.w, g.h);
|
|
233
|
+
var k = (g.w / VIEW[0]) * (g.dpr || 1); // board units to device pixels; the transform is set fresh each frame, so a throw in draw cannot leave it shifted
|
|
234
|
+
ctx.setTransform(k, 0, 0, k, -g.camera.x * k, -g.camera.y * k); o.draw(g, ctx, VIEW[0], VIEW[1]);
|
|
235
|
+
if (o.overlay) { ctx.setTransform(k, 0, 0, k, 0, 0); o.overlay(g, ctx, VIEW[0], VIEW[1]); }
|
|
236
|
+
} catch (e) { console.error(e); }
|
|
237
|
+
}
|
|
238
|
+
function point(e) {
|
|
239
|
+
var r = canvas.getBoundingClientRect(); if (!r.width || !r.height) return;
|
|
240
|
+
var p = g.pointer; p.sx = (e.clientX - r.left) / r.width * (VIEW ? VIEW[0] : g.w); p.sy = (e.clientY - r.top) / r.height * (VIEW ? VIEW[1] : g.h);
|
|
241
|
+
p.x = p.sx + g.camera.x; p.y = p.sy + g.camera.y;
|
|
242
|
+
p.nx = (e.clientX - r.left) / r.width * 2 - 1; p.ny = 1 - (e.clientY - r.top) / r.height * 2; // -1..1, y up (three.js Raycaster.setFromCamera)
|
|
243
|
+
}
|
|
244
|
+
function hud() { if (g.score > g.best) g.best = g.score; sEl.textContent = "Score " + g.score; bEl.textContent = "Best " + g.best; pBtn.textContent = g.state === "paused" ? "▶" : "II"; }
|
|
245
|
+
function screen(h, p, b) { ovH.textContent = h; ovP.textContent = p; ovB.textContent = b; ov.classList.add("on"); }
|
|
246
|
+
function hide() { ov.classList.remove("on"); }
|
|
247
|
+
|
|
248
|
+
g.restart = function () { g.score = 0; g.time = 0; acc = 0; try { if (o.setup) o.setup(g); } catch (e) { console.error(e); } g.state = "playing"; hide(); hud(); paint(); root.focus && root.focus(); };
|
|
249
|
+
g.start = function () { if (mp) return; if (g.state === "ready" || g.state === "over") g.restart(); else if (g.state === "paused") g.pause(); };
|
|
250
|
+
g.pause = function () {
|
|
251
|
+
if (mp) return; // a shared game keeps running for the others
|
|
252
|
+
if (g.state === "playing") { g.state = "paused"; screen("Paused", "Press Space or tap Resume.", "Resume"); }
|
|
253
|
+
else if (g.state === "paused") { g.state = "playing"; hide(); last = 0; }
|
|
254
|
+
hud();
|
|
255
|
+
};
|
|
256
|
+
g.gameOver = function (winner) {
|
|
257
|
+
if (mp) return mp.end(winner);
|
|
258
|
+
if (g.state !== "playing") return;
|
|
259
|
+
g.state = "over"; if (g.score > g.best) g.best = g.score;
|
|
260
|
+
screen("Game over", "Score " + g.score + " · Best " + g.best, "Play again"); hud();
|
|
261
|
+
// Opt-in high scores (Game({scores:true}) plus a board the host app provides as window.gameScores): a popup says whether it is a record or a personal best
|
|
262
|
+
// and asks the player to submit (or not); after saving, the game-over text shows the rank.
|
|
263
|
+
if (o.scores && window.gameScores) {
|
|
264
|
+
setTimeout(function () {
|
|
265
|
+
if (g.state !== "over") return;
|
|
266
|
+
window.gameScores.finish(g.score, { better: o.scoresBetter }).then(function (r) {
|
|
267
|
+
if (r && r.submitted && r.me && g.state === "over") ovP.textContent = "Score " + g.score + " · Best " + g.best + " · Rank " + r.me.rank + " of " + r.players;
|
|
268
|
+
});
|
|
269
|
+
}, 450);
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
ovB.addEventListener("click", function () { g.state === "paused" ? g.pause() : g.restart(); });
|
|
273
|
+
pBtn.addEventListener("click", function () { if (g.state === "playing" || g.state === "paused") g.pause(); else g.start(); });
|
|
274
|
+
|
|
275
|
+
// ---- playing together (multiplayer option + room.js) --------------------------------------------------------------------------------
|
|
276
|
+
var mp = o.multiplayer ? multiplayer() : null;
|
|
277
|
+
function multiplayer() {
|
|
278
|
+
var R = window.tickcli, spec = { max: Math.max(1, Math.min(16, Math.floor(o.multiplayer.max) || 4)), player: o.multiplayer.player || {}, save: o.multiplayer.save === true, saveVersion: Math.max(1, Math.floor(o.multiplayer.saveVersion) || 1) };
|
|
279
|
+
var SAVE = spec.save, SAVEV = spec.saveVersion;
|
|
280
|
+
/** The saved world's format: the same as this page's, or older with a migrateSave(old, fromVersion) to bring it up to date. */
|
|
281
|
+
function savedV() { var s = R && R.state.saved; return s && typeof s.v === "number" ? s.v : 1; }
|
|
282
|
+
function canContinue() { var v = savedV(); return v === SAVEV || (v < SAVEV && typeof o.migrateSave === "function"); }
|
|
283
|
+
/** Host: a continued save from an older version goes through migrateSave once, before setup(); its result replaces the game's own keys. */
|
|
284
|
+
function migrateWorld() {
|
|
285
|
+
var v = m.resumeV; m.resumeV = 0;
|
|
286
|
+
if (!v || v >= SAVEV || typeof o.migrateSave !== "function" || !g.isHost) return;
|
|
287
|
+
var old = JSON.parse(JSON.stringify(S.shared || {})), nu = null;
|
|
288
|
+
try { nu = o.migrateSave(old, v); } catch (e) { console.error(e); }
|
|
289
|
+
if (!nu || typeof nu !== "object" || Array.isArray(nu)) return;
|
|
290
|
+
var gone = {}, keep = {}; for (var k in S.shared) if (k.charAt(0) !== "_") gone[k] = null;
|
|
291
|
+
for (var k2 in nu) if (k2.charAt(0) !== "_") keep[k2] = nu[k2];
|
|
292
|
+
rawShared(gone); rawShared(keep);
|
|
293
|
+
}
|
|
294
|
+
var TEAMS = Math.max(0, Math.min(4, Math.floor(o.multiplayer.teams) || 0)), COLORS = ["#60a5fa", "#f87171", "#34d399", "#fbbf24"];
|
|
295
|
+
var SEATS = Math.max(0, Math.min(spec.max, Math.floor(o.multiplayer.seats) || 0)), CUP = !!o.multiplayer.cup && SEATS > 0, SN = Array.isArray(o.multiplayer.seatNames) ? o.multiplayer.seatNames : null;
|
|
296
|
+
if (SEATS) { g.seats = []; for (var s0 = 0; s0 < SEATS; s0++) g.seats.push(null); } // there before the first draw (no one seated yet)
|
|
297
|
+
var COUNT = Math.max(0, Math.min(10, Number(o.multiplayer.countdown) || 0)), MIN = Math.max(1, Math.floor(o.multiplayer.min) || 1);
|
|
298
|
+
var LIST = o.multiplayer.roomList !== false; // the lobby lists other open rooms of this game
|
|
299
|
+
if (!R) console.error("multiplayer needs room.js loaded before game.js; running as a one-player game");
|
|
300
|
+
var DEC = o.multiplayer.decimals != null ? o.multiplayer.decimals : o.context === "webgl" ? 2 : 0; // decimals on the positions the host shares
|
|
301
|
+
if (R && R.precision) R.precision(DEC);
|
|
302
|
+
var m = { R: R, alone: false, autoStart: false, solo: !R, inHost: false, phase: "connecting", warned: {}, toastT: 0, lastBoot: null };
|
|
303
|
+
sEl.style.display = bEl.style.display = pBtn.style.display = "none";
|
|
304
|
+
var info = document.createElement("span"); info.className = "room"; pBtn.parentNode.insertBefore(info, pBtn);
|
|
305
|
+
// Alone (a room of one, or no app around the page): End game goes back to the lobby (Play again, New game, Play with others). A second tap within
|
|
306
|
+
// 3 s confirms, so a stray tap does not throw a game away. Together, the game ends by itself (one player cannot end it for everyone).
|
|
307
|
+
var xBtn = document.createElement("button"); xBtn.className = "x"; xBtn.title = "End game"; xBtn.setAttribute("aria-label", "End game"); xBtn.textContent = "End"; xBtn.hidden = true;
|
|
308
|
+
pBtn.parentNode.insertBefore(xBtn, pBtn);
|
|
309
|
+
var xArm = 0;
|
|
310
|
+
// Leave / Exit: out of this room at any time. With an exit URL (Game({exit}) or <meta name="tickcli-exit">) it goes there; without one the
|
|
311
|
+
// player lands in a new private room of their own (the lobby: other open rooms, join with a code, play alone). Mid-match with others, a second
|
|
312
|
+
// tap within 3 s confirms, so a stray tap does not drop someone out of a running game.
|
|
313
|
+
var exitMeta = document.querySelector('meta[name="tickcli-exit"]');
|
|
314
|
+
var EXIT = typeof o.exit === "string" && o.exit ? o.exit : exitMeta && exitMeta.content ? exitMeta.content : "";
|
|
315
|
+
var lBtn = document.createElement("button"); lBtn.className = "lv"; var lWord = EXIT ? "Exit" : "Leave";
|
|
316
|
+
lBtn.textContent = lWord; lBtn.title = EXIT ? "Leave this room and exit" : "Leave this room"; lBtn.setAttribute("aria-label", lBtn.title); lBtn.hidden = !R;
|
|
317
|
+
pBtn.parentNode.insertBefore(lBtn, pBtn);
|
|
318
|
+
var lArm = 0;
|
|
319
|
+
lBtn.addEventListener("click", function () {
|
|
320
|
+
var busy = g.state === "playing" && g.players.length > 1;
|
|
321
|
+
if (busy && Date.now() - lArm > 3000) { lArm = Date.now(); lBtn.textContent = lWord + "?"; setTimeout(function () { if (Date.now() - lArm >= 3000) lBtn.textContent = lWord; }, 3100); return; }
|
|
322
|
+
lArm = 0; lBtn.textContent = lWord;
|
|
323
|
+
if (m.solo && !EXIT) return;
|
|
324
|
+
if (EXIT) { try { if (S.code) R.leave(); } catch (e) { /* closed already */ } location.href = EXIT; return; }
|
|
325
|
+
if (S.code) R.leave(); // free the seat at once (switching rooms alone would hold it for seatHoldMs)
|
|
326
|
+
try { if (/room=/.test(location.hash)) history.replaceState(null, "", location.pathname + location.search); } catch (e) { /* sandboxed */ }
|
|
327
|
+
join({ priv: true });
|
|
328
|
+
});
|
|
329
|
+
xBtn.addEventListener("click", function () {
|
|
330
|
+
if (Date.now() - xArm > 3000) { xArm = Date.now(); xBtn.textContent = "End game?"; setTimeout(function () { if (Date.now() - xArm >= 3000) xBtn.textContent = "End"; }, 3100); return; }
|
|
331
|
+
xArm = 0; xBtn.textContent = "End"; if (g.state === "playing" && g.isHost) m.end();
|
|
332
|
+
});
|
|
333
|
+
var showEnd = function () { var on = (m.solo || m.alone) && g.state === "playing"; if (xBtn.hidden === on) xBtn.hidden = !on; };
|
|
334
|
+
var lob = document.createElement("div"); lob.className = "tg-lob"; area.appendChild(lob); // the whole play area: room for the lobby on an upright phone
|
|
335
|
+
var toast = document.createElement("div"); toast.className = "tg-toast"; toast.setAttribute("role", "status"); area.appendChild(toast); // above the lobby
|
|
336
|
+
var cnt = document.createElement("div"); cnt.className = "tg-count"; cnt.setAttribute("aria-live", "polite"); (HL ? area : board).appendChild(cnt);
|
|
337
|
+
ov.classList.remove("on");
|
|
338
|
+
/** A short notice. keep: an important one (the server restarted) that "Ana joined" may not replace while it shows. */
|
|
339
|
+
function say(t, keep) {
|
|
340
|
+
if (!keep && m.toastKeep > performance.now()) return;
|
|
341
|
+
m.toastKeep = keep ? performance.now() + 3200 : 0;
|
|
342
|
+
toast.textContent = t; toast.classList.add("on"); clearTimeout(m.toastT); m.toastT = setTimeout(function () { toast.classList.remove("on"); }, 3200);
|
|
343
|
+
}
|
|
344
|
+
function warn(k, t) { if (!m.warned[k]) { m.warned[k] = 1; console.warn(t); } }
|
|
345
|
+
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; }
|
|
346
|
+
function btn(text, fn, cls) { var b = el("button", cls || "", text); b.addEventListener("click", fn); return b; }
|
|
347
|
+
var S = m.solo ? { you: "me", host: "me", code: "", match: 1, term: 1, started: false, players: { me: { name: "You", state: {} } }, shared: {} } : R.state;
|
|
348
|
+
function teamsOf() { var t = (S.shared && S.shared._teams) || {}; return t && typeof t === "object" ? t : {}; }
|
|
349
|
+
function players() {
|
|
350
|
+
var list = m.solo ? [{ id: "me", name: "You", online: true, away: false, me: true, host: true, state: S.players.me.state }] : R.view();
|
|
351
|
+
if (TEAMS) { var t = teamsOf(); for (var i = 0; i < list.length; i++) list[i].team = typeof t[list[i].id] === "number" ? t[list[i].id] : 0; }
|
|
352
|
+
if (SEATS) {
|
|
353
|
+
var st = seatsOf(), sv = function (id) { return typeof st[id] === "number" && st[id] >= 0 ? st[id] : -1; };
|
|
354
|
+
for (var j = 0; j < list.length; j++) { list[j].seat = sv(list[j].id); list[j].watching = list[j].seat < 0; }
|
|
355
|
+
g.seats = []; for (var q2 = 0; q2 < SEATS; q2++) g.seats.push(null);
|
|
356
|
+
for (var id2 in st) if (st[id2] >= 0 && st[id2] < SEATS) g.seats[st[id2]] = id2;
|
|
357
|
+
if (g.me) { g.me.seat = sv(S.you); g.me.watching = g.me.seat < 0; }
|
|
358
|
+
}
|
|
359
|
+
var mine = g.me && S.players[S.you]; if (mine && mine.state) g.me.state = Object.assign({}, mine.state); // your own state as you last set it, every frame
|
|
360
|
+
return list;
|
|
361
|
+
}
|
|
362
|
+
function seatsOf() { var t = (S.shared && S.shared._seats) || {}; return t && typeof t === "object" ? t : {}; }
|
|
363
|
+
function isCup() { return CUP && !m.solo; }
|
|
364
|
+
function cup() { var c = S.shared && S.shared._cup; return c && typeof c === "object" && c.g && typeof c.g === "object" ? c : null; }
|
|
365
|
+
function cupGame(v) { var a = String(v == null ? "" : v).split("|"); return { p: a[0] ? a[0].split(",") : [], w: a[1] || "" }; }
|
|
366
|
+
function cupKeys(c) { return Object.keys(c.g).sort(function (a, b) { var x = a.split("-"), y = b.split("-"); return x[0] - y[0] || x[1] - y[1]; }); }
|
|
367
|
+
/** Host: a new knockout bracket from everyone in the room, in random order, SEATS players a match. */
|
|
368
|
+
function cupNew() {
|
|
369
|
+
var ids = Object.keys(S.players), old = cup(), gp = {}, names = {}, i;
|
|
370
|
+
for (i = ids.length - 1; i > 0; i--) { var r = Math.floor(Math.random() * (i + 1)), tmp = ids[i]; ids[i] = ids[r]; ids[r] = tmp; }
|
|
371
|
+
if (old) for (var k in old.g) gp[k] = null;
|
|
372
|
+
for (i = 0; i * SEATS < ids.length; i++) gp["0-" + i] = ids.slice(i * SEATS, (i + 1) * SEATS).join(",") + "|";
|
|
373
|
+
for (i = 0; i < ids.length; i++) names[ids[i]] = String(nameOf(ids[i])).slice(0, 24);
|
|
374
|
+
rawShared({ _cup: { live: "", champ: "", names: old ? Object.assign(Object.keys(old.names || {}).reduce(function (o2, k2) { o2[k2] = null; return o2; }, {}), names) : names, g: gp } });
|
|
375
|
+
return cupNext();
|
|
376
|
+
}
|
|
377
|
+
/** Host: seat the next match that can be played. A match whose players left but one is a walkover; a finished round makes the next one from
|
|
378
|
+
its winners; one winner left is the champion. True when a match is seated and ready to start. */
|
|
379
|
+
function cupNext() {
|
|
380
|
+
for (var guard = 0; guard < 80; guard++) {
|
|
381
|
+
var c = cup(); if (!c) return false;
|
|
382
|
+
var keys = cupKeys(c), round = keys.length ? Number(keys[keys.length - 1].split("-")[0]) : 0, cur = keys.filter(function (k) { return Number(k.split("-")[0]) === round; });
|
|
383
|
+
var open = null, winners = [], i;
|
|
384
|
+
for (i = 0; i < cur.length; i++) {
|
|
385
|
+
var gm = cupGame(c.g[cur[i]]);
|
|
386
|
+
if (gm.w) { if (gm.w !== "-" && S.players[gm.w]) winners.push(gm.w); continue; }
|
|
387
|
+
var here = gm.p.filter(function (id) { return !!S.players[id]; });
|
|
388
|
+
if (here.length <= 1) { var wp = {}; wp[cur[i]] = gm.p.join(",") + "|" + (here[0] || "-"); rawShared({ _cup: { g: wp } }); open = "again"; break; }
|
|
389
|
+
open = cur[i]; var sp = {}, old = seatsOf(); for (var k in old) sp[k] = null; for (var j = 0; j < here.length; j++) sp[here[j]] = j;
|
|
390
|
+
rawShared({ _cup: { live: open }, _seats: sp }); return true;
|
|
391
|
+
}
|
|
392
|
+
if (open === "again") continue;
|
|
393
|
+
if (winners.length <= 1) { var sp2 = {}, o3 = seatsOf(); for (var k3 in o3) sp2[k3] = null; rawShared({ _cup: { live: "", champ: winners[0] || "-" }, _seats: sp2 }); return false; }
|
|
394
|
+
var np = {}; for (i = 0; i * SEATS < winners.length; i++) np[(round + 1) + "-" + i] = winners.slice(i * SEATS, (i + 1) * SEATS).join(",") + "|";
|
|
395
|
+
rawShared({ _cup: { g: np } });
|
|
396
|
+
}
|
|
397
|
+
return false;
|
|
398
|
+
}
|
|
399
|
+
/** Host, at the end of a cup match: write the winner (the game's, else the best score among the seated) and seat the next match. */
|
|
400
|
+
function cupResult(w) {
|
|
401
|
+
var c = cup(); if (!c || !c.live || !c.g[c.live]) return;
|
|
402
|
+
var gm = cupGame(c.g[c.live]);
|
|
403
|
+
if (gm.p.indexOf(w) < 0) {
|
|
404
|
+
var sc = (S.shared && S.shared.score) || {}, best = null;
|
|
405
|
+
for (var i = 0; i < gm.p.length; i++) if (S.players[gm.p[i]] && (best === null || (Number(sc[gm.p[i]]) || 0) > (Number(sc[best]) || 0))) best = gm.p[i];
|
|
406
|
+
w = best || gm.p[0];
|
|
407
|
+
}
|
|
408
|
+
var gp = {}; gp[c.live] = gm.p.join(",") + "|" + w; rawShared({ _cup: { g: gp } }); cupNext();
|
|
409
|
+
}
|
|
410
|
+
function cupLines() {
|
|
411
|
+
var c = cup(), out = []; if (!c) return out;
|
|
412
|
+
var keys = cupKeys(c), nm = function (id) { return id === "-" ? "nobody" : (c.names && c.names[id]) || nameOf(id); }, rounds = {};
|
|
413
|
+
for (var i = 0; i < keys.length; i++) { var r = keys[i].split("-")[0], gm = cupGame(c.g[keys[i]]); (rounds[r] = rounds[r] || []).push((keys[i] === c.live && !gm.w ? "\u25b6 " : "") + (gm.p.length > 1 ? gm.p.map(nm).join(" vs ") : nm(gm.p[0]) + " (bye)") + (gm.w && gm.p.length > 1 ? " \u2192 " + nm(gm.w) : "")); }
|
|
414
|
+
var rk = Object.keys(rounds), last = rk.length - 1;
|
|
415
|
+
for (var j = 0; j < rk.length; j++) out.push((rounds[rk[j]].length === 1 && j === last && j > 0 ? "Final" : "Round " + (j + 1)) + ": " + rounds[rk[j]].join(" \u00b7 "));
|
|
416
|
+
return out;
|
|
417
|
+
}
|
|
418
|
+
/** Host only: seats for the players who want one, first come first; a seat that frees up before the start goes to the next watcher. */
|
|
419
|
+
function seatBalance() {
|
|
420
|
+
if (!SEATS || isCup() || S.started) return;
|
|
421
|
+
var s = seatsOf(), next = {}, used = [], changed = false, ids = m.solo ? ["me"] : Object.keys(S.players), i, v;
|
|
422
|
+
for (i = 0; i < ids.length; i++) { v = s[ids[i]]; if (v === -1) next[ids[i]] = -1; else if (typeof v === "number" && v >= 0 && v < SEATS && !used[v]) { next[ids[i]] = v; used[v] = 1; } }
|
|
423
|
+
for (i = 0; i < ids.length; i++) if (next[ids[i]] === undefined) { var f = 0; while (f < SEATS && used[f]) f++; if (f < SEATS) { next[ids[i]] = f; used[f] = 1; changed = true; } else if (s[ids[i]] !== undefined) { next[ids[i]] = null; changed = true; } }
|
|
424
|
+
for (var k in s) if (ids.indexOf(k) < 0) { next[k] = null; changed = true; }
|
|
425
|
+
if (changed) rawShared({ _seats: next });
|
|
426
|
+
}
|
|
427
|
+
/** Host only: every player in a team (the smallest one first), players who left out of the list. The shell's own key, kept across matches. */
|
|
428
|
+
function balance() {
|
|
429
|
+
if (!g.isHost) return;
|
|
430
|
+
seatBalance();
|
|
431
|
+
if (!TEAMS) return;
|
|
432
|
+
var t = teamsOf(), next = {}, count = [], changed = false, ids = m.solo ? ["me"] : Object.keys(S.players), i;
|
|
433
|
+
for (i = 0; i < TEAMS; i++) count[i] = 0;
|
|
434
|
+
for (i = 0; i < ids.length; i++) if (typeof t[ids[i]] === "number" && t[ids[i]] < TEAMS) { next[ids[i]] = t[ids[i]]; count[t[ids[i]]]++; }
|
|
435
|
+
for (i = 0; i < ids.length; i++) if (next[ids[i]] === undefined) { var best = 0; for (var j = 1; j < TEAMS; j++) if (count[j] < count[best]) best = j; next[ids[i]] = best; count[best]++; changed = true; }
|
|
436
|
+
for (var k in t) if (next[k] === undefined) { next[k] = null; changed = true; }
|
|
437
|
+
if (changed) rawShared({ _teams: next });
|
|
438
|
+
}
|
|
439
|
+
function shellOnly() { for (var k in S.shared || {}) if (k.charAt(0) !== "_") return false; return true; }
|
|
440
|
+
function rawShared(patch) { var was = m.inHost; m.inHost = true; try { setShared(patch); } finally { m.inHost = was; } }
|
|
441
|
+
function nameOf(id) { var p = S.players[id]; return p ? p.name : "A player"; }
|
|
442
|
+
// The shared state is never changed in place (room.js, and cow() here when alone): every object in it is frozen, so g.shared is a frozen top
|
|
443
|
+
// level over the same objects, made in O(top-level keys), not a deep copy per frame or per write.
|
|
444
|
+
function sharedOf(src) {
|
|
445
|
+
var c = {}; src = src || {};
|
|
446
|
+
for (var k in src) c[k] = src[k];
|
|
447
|
+
Object.defineProperty(c, "set", { value: setShared, enumerable: false });
|
|
448
|
+
return Object.freeze(c);
|
|
449
|
+
}
|
|
450
|
+
function rebuildShared() { g.shared = sharedOf(S.shared); }
|
|
451
|
+
/** Profile writes merge in place (the profile is copied for the game): null removes a key, lists are replaced whole. */
|
|
452
|
+
function soloMerge(into, patch) {
|
|
453
|
+
for (var k in patch) { var v = patch[k]; if (v === null) delete into[k]; else if (v && typeof v === "object" && !Array.isArray(v)) { if (!into[k] || typeof into[k] !== "object" || Array.isArray(into[k])) into[k] = {}; soloMerge(into[k], v); } else into[k] = v; }
|
|
454
|
+
}
|
|
455
|
+
var isObj = function (v) { return !!v && typeof v === "object" && !Array.isArray(v); };
|
|
456
|
+
function frozen(v) { if (!v || typeof v !== "object" || Object.isFrozen(v)) return v; for (var k in v) frozen(v[k]); return Object.freeze(v); }
|
|
457
|
+
/** Alone, writes merge the same way as on the server ({hp: {a: 80}} changes one entry, null removes a key, lists are replaced whole), into a new
|
|
458
|
+
version that reuses the untouched branches. */
|
|
459
|
+
function cow(base, patch) {
|
|
460
|
+
var out = {}, k;
|
|
461
|
+
if (isObj(base)) for (k in base) out[k] = base[k];
|
|
462
|
+
for (k in patch) { var v = patch[k]; if (v === null) delete out[k]; else if (isObj(v)) out[k] = cow(out[k], v); else out[k] = v && typeof v === "object" ? frozen(JSON.parse(JSON.stringify(v))) : v; }
|
|
463
|
+
return Object.freeze(out);
|
|
464
|
+
}
|
|
465
|
+
function setShared(patch) {
|
|
466
|
+
// setup() runs on everyone; only the host's starting values count, never over a continued game, and only on a page that saw this match start
|
|
467
|
+
// (a reload, a second tab or a phone that threw the tab away lands in a running match: its setup must not reset the live game for everyone)
|
|
468
|
+
if (m.inSetup && (!g.isHost || g.resumed || !(m.sawStart || shellOnly()))) return;
|
|
469
|
+
if (!m.inHost && !(m.inSetup && g.isHost)) return warn("shared", "g.shared.set() only works inside host(), onAction() or setup(): ask the host with g.act() instead");
|
|
470
|
+
if (m.solo) S.shared = cow(S.shared, patch); else R.setShared(patch);
|
|
471
|
+
rebuildShared();
|
|
472
|
+
}
|
|
473
|
+
function refresh() {
|
|
474
|
+
g.isHost = S.host === S.you;
|
|
475
|
+
g.players = players();
|
|
476
|
+
var mine = S.players[S.you];
|
|
477
|
+
g.me.id = S.you; g.me.name = mine ? mine.name : ""; g.me.state = mine ? JSON.parse(JSON.stringify(mine.state)) : {};
|
|
478
|
+
if (TEAMS) { var tt = teamsOf(); g.me.team = typeof tt[S.you] === "number" ? tt[S.you] : 0; g.teams = []; for (var ti = 0; ti < TEAMS; ti++) g.teams.push(g.players.filter(function (p) { return p.team === ti; }).map(function (p) { return p.id; })); }
|
|
479
|
+
g.room = { code: S.code, match: S.match, players: g.players.length };
|
|
480
|
+
info.textContent = m.solo ? "" : m.alone ? "Playing alone" : (S.code ? "Room " + S.code + " \u00b7 " : "") + g.players.length + (g.players.length === 1 ? " player" : " players") + (SEATS && S.started && g.me.watching ? " \u00b7 watching" : "");
|
|
481
|
+
}
|
|
482
|
+
g.multiplayer = true;
|
|
483
|
+
g.me = { id: null, name: "", state: {}, set: function (patch) {
|
|
484
|
+
if (m.solo) { var st = S.players.me.state; for (var k in patch) { if (patch[k] === null) delete st[k]; else st[k] = patch[k]; } return; }
|
|
485
|
+
R.setMe(patch);
|
|
486
|
+
} };
|
|
487
|
+
g.players = []; g.isHost = false; rebuildShared();
|
|
488
|
+
// The player's saved profile: loaded once per page (before the first setup), merged in place, written by the room library a second later.
|
|
489
|
+
var prof = {}; g.saves = false; g.resumed = false;
|
|
490
|
+
function setProfView() {
|
|
491
|
+
var c = JSON.parse(JSON.stringify(prof));
|
|
492
|
+
Object.defineProperty(c, "set", { value: function (patch) {
|
|
493
|
+
if (!patch || typeof patch !== "object" || Array.isArray(patch)) return;
|
|
494
|
+
if (!m.solo && g.saves && R.check && R.check(patch) !== null) return void R.setProfile(patch); // refused whole (the room library says why)
|
|
495
|
+
soloMerge(prof, JSON.parse(JSON.stringify(patch)));
|
|
496
|
+
if (!m.solo && g.saves) R.setProfile(patch);
|
|
497
|
+
else m.profWritten = true; // not saving here: a profile that arrives later is not adopted over what the game already changed
|
|
498
|
+
setProfView();
|
|
499
|
+
}, enumerable: false });
|
|
500
|
+
g.profile = Object.freeze(c);
|
|
501
|
+
}
|
|
502
|
+
setProfView();
|
|
503
|
+
/** A profile saved by an older version of the game: migrateProfile(old, fromVersion) when the game has one (the result replaces it), else as is. */
|
|
504
|
+
function migrateProf(p) {
|
|
505
|
+
var v = R.state.profV || 1;
|
|
506
|
+
if (v >= SAVEV || typeof o.migrateProfile !== "function") return p;
|
|
507
|
+
var nu = null; try { nu = o.migrateProfile(JSON.parse(JSON.stringify(p)), v); } catch (e) { console.error(e); }
|
|
508
|
+
if (!nu || typeof nu !== "object" || Array.isArray(nu)) return p;
|
|
509
|
+
var gone = {}; for (var k in p) gone[k] = null;
|
|
510
|
+
R.setProfile(gone); R.setProfile(nu);
|
|
511
|
+
return nu;
|
|
512
|
+
}
|
|
513
|
+
m.profState = m.solo ? "done" : "idle";
|
|
514
|
+
/** Run fn once the profile is here (asked at the first welcome; a slow store waits 3 s at most, then the game starts without saving). */
|
|
515
|
+
m.whenProf = function (fn) {
|
|
516
|
+
if (m.profState === "done") return fn();
|
|
517
|
+
if (m.profState === "idle") {
|
|
518
|
+
m.profState = "loading";
|
|
519
|
+
R.profile().then(function (p) { if (p) { prof = migrateProf(p); g.saves = true; setProfView(); paint(); } m.profState = "done"; var q = m.profQ || []; m.profQ = []; q.forEach(function (f) { f(); }); });
|
|
520
|
+
}
|
|
521
|
+
(m.profQ = m.profQ || []).push(fn);
|
|
522
|
+
};
|
|
523
|
+
Object.defineProperty(g, "now", { get: function () { return m.solo ? performance.now() / 1000 : R.now(); } });
|
|
524
|
+
g.act = function (name, data) {
|
|
525
|
+
if (m.solo) { var ok; m.inHost = true; try { ok = o.onAction ? o.onAction(g, "me", name, data) !== false : true; } catch (e) { console.error(e); ok = false; } m.inHost = false; return Promise.resolve({ ok: ok }); }
|
|
526
|
+
return R.act(name, data);
|
|
527
|
+
};
|
|
528
|
+
g.fx = function (name, data) { if (!m.solo) R.fx(name, data); try { if (o.onFx) o.onFx(g, S.you, name, data); } catch (e) { console.error(e); } };
|
|
529
|
+
g.clearSave = function () { if (!m.solo && g.isHost && SAVE) R.clearWorld(); };
|
|
530
|
+
g.kick = function (id) { if (!m.solo && g.isHost) R.kick(id); };
|
|
531
|
+
g.teamColor = function (i) { return COLORS[(Number(i) || 0) % COLORS.length]; };
|
|
532
|
+
g.teams = [];
|
|
533
|
+
// Shots: sent once (start, speed, the shared clock's time), then every screen moves them by itself. The host decides what they hit.
|
|
534
|
+
var shots = [], shotSeq = 0, shotAt = 0; g.shots = shots;
|
|
535
|
+
var num = function (v, lo, hi) { v = Number(v); return v === v ? Math.max(lo, Math.min(hi, v)) : 0; };
|
|
536
|
+
function addShot(from, d) {
|
|
537
|
+
if (!d || typeof d !== "object" || typeof d.i !== "string") return;
|
|
538
|
+
if (shots.length >= 300) shots.shift();
|
|
539
|
+
var tm = teamsOf();
|
|
540
|
+
shots.push({ id: d.i, from: from, team: typeof tm[from] === "number" ? tm[from] : 0, kind: String(d.k || ""), r: num(d.r, 1, 200) || 4, x0: num(d.x, -1e5, 1e5), y0: num(d.y, -1e5, 1e5),
|
|
541
|
+
vx: num(d.vx, -5000, 5000), vy: num(d.vy, -5000, 5000), t0: Number(d.t) || g.now, life: num(d.l, 0.05, 8) || 2, x: num(d.x, -1e5, 1e5), y: num(d.y, -1e5, 1e5), age: 0 });
|
|
542
|
+
}
|
|
543
|
+
function moveShots() {
|
|
544
|
+
var now = g.now;
|
|
545
|
+
for (var i = shots.length - 1; i >= 0; i--) { var s = shots[i], a = now - s.t0; if (a > s.life || a < -2) shots.splice(i, 1); else { a = Math.max(0, a); s.age = a; s.x = s.x0 + s.vx * a; s.y = s.y0 + s.vy * a; } }
|
|
546
|
+
}
|
|
547
|
+
/** A place to start or come back in where the others can be seen (see the header). At the start of a match everyone runs setup() at once, so
|
|
548
|
+
they stand in a ring round o.at (default: the middle of o.x/o.y); a late joiner or a respawn lands o.gap away from someone still playing. */
|
|
549
|
+
g.spawnNear = function (o) {
|
|
550
|
+
o = o || {};
|
|
551
|
+
var kx = (o.keys || [])[0] || "x", ky = (o.keys || [])[1] || "y", gap = o.gap || 100, bx = o.x, by = o.y;
|
|
552
|
+
var mid = o.at || [bx ? (bx[0] + bx[1]) / 2 : 0, by ? (by[0] + by[1]) / 2 : 0];
|
|
553
|
+
var clamp = function (v, r) { return r ? Math.min(r[1], Math.max(r[0], v)) : v; };
|
|
554
|
+
var num = function (v) { return typeof v === "number" && isFinite(v); };
|
|
555
|
+
var all = g.players.filter(function (p) { return !p.watching; });
|
|
556
|
+
var live = all.filter(function (p) { return !p.me && p.online !== false && !p.away && p.state && num(p.state[kx]) && num(p.state[ky]) && (!o.skip || !o.skip(p)); });
|
|
557
|
+
var cx, cy, a, r;
|
|
558
|
+
if (live.length && !(m.inSetup && m.sawStart)) {
|
|
559
|
+
var t = live[Math.floor(Math.random() * live.length)];
|
|
560
|
+
cx = t.state[kx]; cy = t.state[ky]; a = Math.random() * Math.PI * 2; r = gap * (1 + Math.random() * 0.4);
|
|
561
|
+
} else {
|
|
562
|
+
var ids = all.map(function (p) { return String(p.id); }).sort(), i = Math.max(0, ids.indexOf(String(g.me.id))), n = Math.max(1, ids.length);
|
|
563
|
+
cx = mid[0]; cy = mid[1]; a = (i / n) * Math.PI * 2; r = n > 1 ? gap / (2 * Math.sin(Math.PI / n)) : 0; // neighbours gap apart
|
|
564
|
+
}
|
|
565
|
+
// next to a wall the spot is pulled back in: take the side that stays farther from the one we stand next to
|
|
566
|
+
var at = function (ang) { var q = {}; q[kx] = clamp(cx + Math.cos(ang) * r, bx); q[ky] = clamp(cy + Math.sin(ang) * r, by); return q; };
|
|
567
|
+
var d = function (q) { return Math.hypot(q[kx] - cx, q[ky] - cy); };
|
|
568
|
+
var p1 = at(a), p2 = at(a + Math.PI);
|
|
569
|
+
return d(p2) > d(p1) + 1 ? p2 : p1;
|
|
570
|
+
};
|
|
571
|
+
g.shoot = function (s) {
|
|
572
|
+
if (g.state !== "playing" || !s || m.watching() || m.count() > 0) return null;
|
|
573
|
+
var t = performance.now(); if (t - shotAt < 80) return null; shotAt = t; // 12 a second at most, like own state
|
|
574
|
+
var d = { i: String(S.you).slice(-6) + (++shotSeq).toString(36), x: Math.round(Number(s.x) || 0), y: Math.round(Number(s.y) || 0), vx: Math.round(Number(s.vx) || 0), vy: Math.round(Number(s.vy) || 0),
|
|
575
|
+
l: Math.round((Number(s.life) || 2) * 100) / 100, r: Number(s.r) || 4, k: String(s.kind || "").slice(0, 16), t: Math.round(g.now * 1000) / 1000 };
|
|
576
|
+
addShot(S.you, d); if (!m.solo) R.fx("_shot", d);
|
|
577
|
+
return d.i;
|
|
578
|
+
};
|
|
579
|
+
g.hit = function (shot) {
|
|
580
|
+
var id = shot && typeof shot === "object" ? shot.id : shot; if (!g.isHost || typeof id !== "string") return;
|
|
581
|
+
for (var i = shots.length - 1; i >= 0; i--) if (shots[i].id === id) shots.splice(i, 1);
|
|
582
|
+
if (!m.solo) R.fx("_hit", { i: id });
|
|
583
|
+
};
|
|
584
|
+
/** Seconds until the match goes live (0 = live). The host writes the go time once per match; until it arrives the others wait (2 s at most). */
|
|
585
|
+
m.count = function () {
|
|
586
|
+
if (!COUNT || !S.started) return 0;
|
|
587
|
+
var go = S.shared && S.shared._go;
|
|
588
|
+
if (!go || go.m !== S.match) return performance.now() - m.beganAt < 2000 ? COUNT : 0;
|
|
589
|
+
return Math.max(0, go.at - g.now);
|
|
590
|
+
};
|
|
591
|
+
m.watching = function () { return SEATS > 0 && S.started && !(seatsOf()[S.you] >= 0); };
|
|
592
|
+
function begin(fresh) { m.sawStart = !!fresh; g.late = !fresh; m.matchN = (m.matchN || 0) + 1; g.resumed = !m.solo && !!R.state.resumed; g.score = 0; g.time = 0; acc = 0; shots.length = 0; m.beganAt = performance.now(); rebuildShared(); refresh();
|
|
593
|
+
if (fresh && COUNT && g.isHost) rawShared({ _go: { m: S.match, at: Math.round((g.now + COUNT) * 1000) / 1000 } });
|
|
594
|
+
if (g.resumed && fresh) migrateWorld();
|
|
595
|
+
m.inSetup = true; try { if (o.setup) o.setup(g); } catch (e) { console.error(e); } finally { m.inSetup = false; } g.state = "playing"; m.phase = "playing"; lob.classList.remove("on"); hud(); paint(); }
|
|
596
|
+
// The lobby: one card, top to bottom: the game's name and one line saying what happens next, the room code with the invite, who is here,
|
|
597
|
+
// ONE main button (Start for the host; nothing to press for the others, the line says who starts), then the less common choices as small
|
|
598
|
+
// links (join with a code, private room, play alone, invite anyone) and other open rooms. It covers the whole play area, not just the
|
|
599
|
+
// board, so it has room on a phone held upright; buttons are at least 44 px high.
|
|
600
|
+
function lobby() {
|
|
601
|
+
var typed = {}, olds = lob.querySelectorAll("input"); // the lobby is redrawn when someone joins: keep what is being typed
|
|
602
|
+
for (var oi = 0; oi < olds.length; oi++) if (document.activeElement === olds[oi]) typed[olds[oi].getAttribute("aria-label")] = olds[oi].value;
|
|
603
|
+
var scrollY = lob.scrollTop;
|
|
604
|
+
lob.innerHTML = "";
|
|
605
|
+
lob.classList.add("on");
|
|
606
|
+
var card = el("div", "card"); lob.appendChild(card);
|
|
607
|
+
var add = function (e) { card.appendChild(e); return e; };
|
|
608
|
+
setTimeout(function () { lob.scrollTop = scrollY; var ins = lob.querySelectorAll("input"); for (var ii = 0; ii < ins.length; ii++) { var was = typed[ins[ii].getAttribute("aria-label")]; if (was !== undefined) { ins[ii].value = was; ins[ii].focus(); } } }, 0);
|
|
609
|
+
var ph = m.phase;
|
|
610
|
+
if (ph === "connecting") { add(el("h2", "", o.title || "Game")); add(el("p", "status", "Joining a room...")); return; }
|
|
611
|
+
if (ph === "closed") {
|
|
612
|
+
var c = (R.state.closed || {}).code, msgs = { 4001: "This game is no longer shared.", 4002: "This room is full.", 4003: "You opened this game in another tab or window.", 4005: "Something went wrong with the connection.", 4006: "The host removed you from this room.", 4007: "This game was changed. Press restart above to load the new version.", 4010: "This room closed because nobody played for a while." };
|
|
613
|
+
add(el("h2", "", c === 4010 ? "Room closed" : "Not connected"));
|
|
614
|
+
// tickcli-js/remote refusals name the deployment ("this site is not allowed for deployment dep_x"): the author needs that exact reason
|
|
615
|
+
var why = String((R.state.closed && R.state.closed.reason) || "");
|
|
616
|
+
var dep = c === 4001 && /deployment/.test(why) ? why.charAt(0).toUpperCase() + why.slice(1) + "." : "";
|
|
617
|
+
add(el("p", "status", dep || msgs[c] || why || "The connection closed."));
|
|
618
|
+
if (c === 4002 || c === 4010) add(btn(c === 4010 ? "Start a new room" : "Find another room", function () { join({}); }, "big"));
|
|
619
|
+
if (c === 4003 || c === 4005 || !c) add(btn(c === 4003 ? "Play here instead" : "Try again", function () { join({ code: S.code }); }, "big"));
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
var list = players(), host = list.filter(function (p) { return p.host; })[0];
|
|
623
|
+
var cupNow = isCup() ? cup() : null, champ = cupNow && cupNow.champ && cupNow.champ !== "-" ? cupNow.champ : "";
|
|
624
|
+
var shared = !m.solo && !m.alone && S.code; // a room others can come into
|
|
625
|
+
var seated = SEATS && !TEAMS && !isCup() ? list.filter(function (p) { return p.seat >= 0; }).length : list.length;
|
|
626
|
+
var need = isCup() ? (cupNow && cupNow.live && !cupNow.champ ? 0 : 2 - list.length) : MIN - seated;
|
|
627
|
+
if (m.solo) need = 0;
|
|
628
|
+
|
|
629
|
+
// ---- what this is, and what happens next
|
|
630
|
+
add(el("h2", "", champ ? "\uD83C\uDFC6 " + ((cupNow.names && cupNow.names[champ]) || nameOf(champ)) + " wins the tournament" : ph === "over" ? (cupNow ? "Match over" : "Game over") : (o.title || "Game")));
|
|
631
|
+
var next = m.solo ? "Playing alone on this device." : need > 0 ? "Waiting for " + need + " more " + (need === 1 ? "player" : "players") + "." : g.isHost ? (ph === "over" ? "Play again when everyone is ready." : "Everyone here? Press Start.") : host ? "Waiting for " + host.name + " to start." : "Waiting for a player to come back.";
|
|
632
|
+
add(el("p", "status", next)).setAttribute("aria-live", "polite");
|
|
633
|
+
if (ph === "over" && !cupNow && g.shared.score && typeof g.shared.score === "object") {
|
|
634
|
+
var sc = list.map(function (p) { return { n: p.name, s: Number(g.shared.score[p.id]) || 0 }; }).sort(function (a, b) { return b.s - a.s; });
|
|
635
|
+
add(el("p", "help", sc.map(function (x) { return x.n + " " + x.s; }).join(" \u00b7 ")));
|
|
636
|
+
} else if (ph !== "over" && o.help) add(el("p", "help", o.help));
|
|
637
|
+
if (cupNow) { var cl = cupLines(); for (var ci = 0; ci < cl.length; ci++) add(el("p", "cup", cl[ci])); }
|
|
638
|
+
if (m.solo && R) add(el("p", "note", "To play with others, open this game from the app it was shared in."));
|
|
639
|
+
|
|
640
|
+
// ---- the room closes soon: nobody did anything
|
|
641
|
+
if (!m.solo && R.state.idleAt) {
|
|
642
|
+
var iw = add(el("div", "warn")); iw.setAttribute("role", "alert");
|
|
643
|
+
iw.appendChild(el("span", "", "Nobody has played for a while, so this room closes in about a minute."));
|
|
644
|
+
iw.appendChild(btn("Keep it", function () { R.stay(); }, "sec"));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// ---- the code to read out, and the invite
|
|
648
|
+
if (shared) {
|
|
649
|
+
var inv = add(el("div", "box invite"));
|
|
650
|
+
var iv = inv.appendChild(el("div", ""));
|
|
651
|
+
iv.appendChild(el("div", "lbl", S.priv ? "Private room code" : "Room code"));
|
|
652
|
+
iv.appendChild(el("div", "code", S.code));
|
|
653
|
+
inv.appendChild(btn("Copy invite link", function () { R.invite(); }, "sec"));
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
// ---- who is here
|
|
657
|
+
if (!m.solo) {
|
|
658
|
+
var who = add(el("div", "box"));
|
|
659
|
+
var wl = who.appendChild(el("div", "lbl"));
|
|
660
|
+
wl.appendChild(el("span", "", "Players " + list.length + (S.max ? " of " + S.max : "")));
|
|
661
|
+
if (R.state.guest) wl.appendChild(btn(m.editName ? "Cancel" : "Change my name", function () { m.editName = !m.editName; lobby(); }, "link"));
|
|
662
|
+
if (R.state.guest && m.editName) {
|
|
663
|
+
var nm = el("input"); nm.maxLength = 20; nm.placeholder = "Your name"; nm.value = g.me.name || ""; nm.setAttribute("aria-label", "Your name");
|
|
664
|
+
var saveName = function () { var v = nm.value.trim(); if (v) { R.rename(v); m.editName = false; lobby(); } };
|
|
665
|
+
nm.addEventListener("keydown", function (e) { if (e.key === "Enter") saveName(); });
|
|
666
|
+
var nr = who.appendChild(el("div", "row")); nr.appendChild(nm); nr.appendChild(btn("Save", saveName));
|
|
667
|
+
setTimeout(function () { if (document.activeElement !== nm && typed["Your name"] === undefined) nm.focus(); }, 0);
|
|
668
|
+
}
|
|
669
|
+
var groups = [], labels = [], gi;
|
|
670
|
+
if (TEAMS) for (gi = 0; gi < TEAMS; gi++) { groups.push(list.filter(function (p) { return p.team === gi; })); labels.push("Team " + (gi + 1)); }
|
|
671
|
+
else if (SEATS && (!isCup() || (cupNow && cupNow.live))) {
|
|
672
|
+
groups.push(list.filter(function (p) { return p.seat >= 0; }).sort(function (a, b) { return a.seat - b.seat; })); labels.push(isCup() ? (ph === "over" ? "Next match" : "This match") : "Playing");
|
|
673
|
+
groups.push(list.filter(function (p) { return p.seat < 0; })); labels.push("Watching");
|
|
674
|
+
} else groups.push(list);
|
|
675
|
+
for (gi = 0; gi < groups.length; gi++) {
|
|
676
|
+
if (labels[gi] && (TEAMS || groups[gi].length)) { var th = who.appendChild(el("div", "sub", labels[gi])); if (TEAMS) th.style.color = COLORS[gi]; }
|
|
677
|
+
var ul = el("ul", "who");
|
|
678
|
+
for (var i = 0; i < groups[gi].length; i++) {
|
|
679
|
+
var p = groups[gi][i], li = el("li", (p.me ? "me" : "") + (p.away || !p.online ? " away" : ""), (p.host ? "\uD83D\uDC51 " : "") + (SN && !TEAMS && p.seat >= 0 && SN[p.seat] ? SN[p.seat] + ": " : "") + p.name + (p.me ? " (you)" : "") + (!p.online ? " \u00b7 reconnecting" : p.away ? " \u00b7 away" : ""));
|
|
680
|
+
if (p.host) li.title = "Host: starts the game";
|
|
681
|
+
if (TEAMS) li.style.boxShadow = "inset 4px 0 0 " + COLORS[gi];
|
|
682
|
+
ul.appendChild(li);
|
|
683
|
+
}
|
|
684
|
+
if (groups[gi].length) who.appendChild(ul);
|
|
685
|
+
}
|
|
686
|
+
var tr = el("div", "row");
|
|
687
|
+
if (TEAMS > 1 && ph !== "over") tr.appendChild(btn("Switch team", function () { R.act("_team", { to: ((g.me.team || 0) + 1) % TEAMS }); }, "sec"));
|
|
688
|
+
if (SEATS && !isCup() && !S.started) {
|
|
689
|
+
if (g.me.seat >= 0) tr.appendChild(btn("Watch instead", function () { R.act("_seat", { sit: false }); }, "sec"));
|
|
690
|
+
else if (g.seats.indexOf(null) >= 0) tr.appendChild(btn("Take a seat", function () { R.act("_seat", { sit: true }); }, "sec"));
|
|
691
|
+
}
|
|
692
|
+
if (tr.childNodes.length) who.appendChild(tr);
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ---- the one main action (the host's); saved games: Continue first
|
|
696
|
+
var go = function () { if (m.solo) soloStart(); else R.start(); };
|
|
697
|
+
if (g.isHost && isCup()) {
|
|
698
|
+
if (cupNow && cupNow.live && !cupNow.champ) add(btn(ph === "over" ? "Next match" : "Start match", function () { if (cupNext()) go(); else { refresh(); lobby(); } }, "big"));
|
|
699
|
+
else if (list.length >= 2) add(btn(cupNow && cupNow.champ ? "New tournament" : "Start tournament", function () { if (cupNew()) go(); else { refresh(); lobby(); } }, "big"));
|
|
700
|
+
} else if (g.isHost && seated >= MIN) {
|
|
701
|
+
var saved = SAVE && !m.solo && R.state.saved && R.state.saved.at;
|
|
702
|
+
// a save from another version of the game that it cannot read: only New game (the old save stays as the earlier copy)
|
|
703
|
+
if (saved && !canContinue()) add(el("p", "note", "This saved game was made by " + (savedV() < SAVEV ? "an older" : "another") + " version of the game, so it cannot be continued."));
|
|
704
|
+
if (saved && m.askNew) { // a new game replaces the saved one at its first save: ask first
|
|
705
|
+
add(el("p", "help", "Start over? This replaces the saved game from " + when(saved) + "."));
|
|
706
|
+
var yr = add(el("div", "row")); yr.appendChild(btn("Yes, new game", function () { m.askNew = false; go(); })); yr.appendChild(btn("Keep it", function () { m.askNew = false; lobby(); }, "sec"));
|
|
707
|
+
} else {
|
|
708
|
+
if (saved && canContinue()) add(btn("Continue (saved " + when(saved) + ")", function () { m.askNew = false; m.resumeV = savedV(); R.start(true); }, "big"));
|
|
709
|
+
add(btn(saved ? "New game" : ph === "over" ? "Play again" : "Start game", saved ? function () { m.askNew = true; lobby(); } : go, saved && canContinue() ? "sec big" : "big"));
|
|
710
|
+
}
|
|
711
|
+
} else if (g.isHost && need > 0) add(btn("Start game", function () {}, "big")).disabled = true; // shown, so it is clear what is missing
|
|
712
|
+
if (m.alone && (ph === "over" || !S.started)) add(btn("Play with others", function () { join({}); }, "sec big"));
|
|
713
|
+
|
|
714
|
+
// ---- less common: small links, one line; the code field opens on demand
|
|
715
|
+
var links = el("div", "links");
|
|
716
|
+
if (shared && !S.started && ph !== "over") links.appendChild(btn(m.codeOpen ? "Hide the code field" : "Join with a code", function () { m.codeOpen = !m.codeOpen; lobby(); }, "link"));
|
|
717
|
+
if (!m.solo && !m.alone && R.state.anyone && !R.state.guest) links.appendChild(btn("Invite anyone", function () { R.inviteAnyone(); }, "link")); // people without an account: the app's public link
|
|
718
|
+
if (!m.solo && !S.priv && ph !== "over" && !m.alone) links.appendChild(btn("Make it private", function () { join({ priv: true }); }, "link"));
|
|
719
|
+
if (!m.solo && !m.alone && MIN <= 1 && !CUP && o.multiplayer.solo !== false) links.appendChild(btn("Play alone", function () { join({ alone: true }); }, "link"));
|
|
720
|
+
if (shared && !S.started && ph !== "over" && (m.codeOpen || typed["Room code"] !== undefined)) { // a friend on another device (a phone, a laptop) read out their code
|
|
721
|
+
var jc = el("input"); jc.maxLength = 6; jc.placeholder = "Friend's code"; jc.setAttribute("aria-label", "Room code"); jc.setAttribute("autocapitalize", "characters"); jc.setAttribute("autocomplete", "off");
|
|
722
|
+
// room codes never use I, O, 0 or 1
|
|
723
|
+
var joinIt = function () { var v = jc.value.trim().toUpperCase(); if (!/^[A-HJ-NP-Z2-9]{4,6}$/.test(v)) return say("That is not a room code. Look at the big code on your friend's screen."); if (v !== S.code) join({ code: v }); };
|
|
724
|
+
jc.addEventListener("keydown", function (e) { if (e.key === "Enter") joinIt(); });
|
|
725
|
+
var jr = add(el("div", "row")); jr.appendChild(jc); jr.appendChild(btn("Join room", joinIt));
|
|
726
|
+
if (typed["Room code"] === undefined) setTimeout(function () { jc.focus(); }, 0);
|
|
727
|
+
}
|
|
728
|
+
if (links.childNodes.length) add(links);
|
|
729
|
+
|
|
730
|
+
if (LIST && !m.solo && !m.alone) openRooms(add);
|
|
731
|
+
if (SAVE && !m.solo && R.state.saveBusy) add(el("p", "note", "A saved game is being played in another room, so this room will not be saved."));
|
|
732
|
+
if (SAVE && !m.solo && R.state.saveError) add(el("p", "note", "The saved game could not be opened, so this game is not being saved."));
|
|
733
|
+
if (SAVE && !m.solo && R.state.saved && R.state.saved.at && !g.isHost && canContinue()) add(el("p", "note", "A saved game is waiting. " + (host ? host.name : "The host") + " can continue it."));
|
|
734
|
+
}
|
|
735
|
+
// ---- other open rooms of this game (multiplayer.roomList: false hides them): asked every 5 s while this lobby shows and the page is visible.
|
|
736
|
+
// Only rooms you can join are shown (full ones are left out, with a count), each as one row: who hosts it, how many, waiting or playing,
|
|
737
|
+
// how quick the host's connection is, and Join.
|
|
738
|
+
function openRooms(add) {
|
|
739
|
+
if (!m.roomsT) m.roomsT = setInterval(pollRooms, 5000);
|
|
740
|
+
if (!m.rooms) pollRooms();
|
|
741
|
+
var others = (m.rooms || []).filter(function (r) { return !r.you; }), open = others.filter(function (r) { return !r.full; }).slice(0, 6), full = others.length - open.length;
|
|
742
|
+
if (!open.length) return;
|
|
743
|
+
var box = add(el("div", "box"));
|
|
744
|
+
box.appendChild(el("div", "lbl", "Other rooms" + (full ? " \u00b7 " + full + " full not shown" : "")));
|
|
745
|
+
var ul = box.appendChild(el("ul", "rooms"));
|
|
746
|
+
open.forEach(function (r) {
|
|
747
|
+
var li = ul.appendChild(el("li"));
|
|
748
|
+
var t = li.appendChild(el("div", "t"));
|
|
749
|
+
t.appendChild(el("b", "", r.host ? r.host + "'s room" : "Room " + r.code));
|
|
750
|
+
var speed = !r.ping ? "" : r.ping < 80 ? "fast connection" : r.ping < 200 ? "ok connection" : "slow connection";
|
|
751
|
+
t.appendChild(el("small", "", [r.players + " of " + r.max, r.started ? "playing now" : "waiting", speed, r.slowed ? "busy" : ""].filter(Boolean).join(" \u00b7 ")));
|
|
752
|
+
if (r.ping) li.title = "The host's connection: " + r.ping + " ms";
|
|
753
|
+
li.appendChild(btn("Join", function () { join({ code: r.code }); }, "sec"));
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
function pollRooms() {
|
|
757
|
+
var on = lob.classList.contains("on") && (m.phase === "waiting" || m.phase === "over") && !m.solo && !m.alone;
|
|
758
|
+
if (!on) { clearInterval(m.roomsT); m.roomsT = 0; m.rooms = null; return; }
|
|
759
|
+
if (document.hidden || !R.rooms) return;
|
|
760
|
+
R.rooms().then(function (list) {
|
|
761
|
+
var was = JSON.stringify(m.rooms || []); m.rooms = list || [];
|
|
762
|
+
if (JSON.stringify(m.rooms) !== was && lob.classList.contains("on") && m.phase !== "connecting" && m.phase !== "closed") lobby();
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
function when(t) { try { return new Date(t).toLocaleDateString(undefined, { month: "short", day: "numeric" }); } catch (e) { return "earlier"; } }
|
|
766
|
+
function soloStart() { g.resumed = false; S.started = true; var keep = {}; for (var k in S.shared) if (k.charAt(0) === "_") keep[k] = S.shared[k]; S.shared = keep; begin(true); }
|
|
767
|
+
function join(x) {
|
|
768
|
+
m.alone = !!x.alone; m.autoStart = m.alone && !SAVE; // alone: a private room of one that starts by itself (with saves the lobby offers Continue first)
|
|
769
|
+
if (m.alone && S.code) R.leave();
|
|
770
|
+
m.phase = "connecting"; lobby(); R.join({ spec: spec, code: x.code, priv: x.priv || m.alone, alone: m.alone, name: x.name });
|
|
771
|
+
}
|
|
772
|
+
m.lobby = lobby; m.begin = begin; m.refresh = refresh;
|
|
773
|
+
m.over = function () {
|
|
774
|
+
g.state = "over"; m.phase = "over"; refresh(); showEnd(); lobby();
|
|
775
|
+
if (o.scores && window.gameScores && g.shared.score && typeof g.shared.score === "object" && typeof g.shared.score[g.me.id] === "number") {
|
|
776
|
+
var mine = g.shared.score[g.me.id];
|
|
777
|
+
setTimeout(function () { if (g.state === "over") window.gameScores.finish(mine, { better: o.scoresBetter }); }, 600);
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
m.end = function (winner) {
|
|
781
|
+
if (!g.isHost) return warn("over", "g.gameOver() ends the match only on the host: call it inside host()");
|
|
782
|
+
// host() keeps running until the server's "ended" comes back: a second call must not record a cup result again (for the next pairing)
|
|
783
|
+
if (m.endedN === m.matchN) return;
|
|
784
|
+
m.endedN = m.matchN;
|
|
785
|
+
if (isCup()) cupResult(winner);
|
|
786
|
+
if (m.solo) { S.started = false; m.over(); } else R.end();
|
|
787
|
+
};
|
|
788
|
+
m.step = function (dt) {
|
|
789
|
+
showEnd();
|
|
790
|
+
g.players = players(); g.isHost = S.host === S.you; moveShots();
|
|
791
|
+
if ((g.countdown = m.count()) > 0) return;
|
|
792
|
+
if (o.update && !m.watching()) o.update(g, dt);
|
|
793
|
+
if (g.isHost && o.host && (m.solo || S.started)) { m.inHost = true; try { o.host(g, dt); } finally { m.inHost = false; } }
|
|
794
|
+
};
|
|
795
|
+
m.frame = function () { if (!m.solo && S.host === S.you) R.alive(); };
|
|
796
|
+
/** Before each drawn frame: others as they should be seen now; the shared state eased too (except on the host, which holds the real one). */
|
|
797
|
+
m.pre = function () {
|
|
798
|
+
g.players = players(); g.isHost = S.host === S.you; moveShots();
|
|
799
|
+
var left = g.countdown = m.count(), txt = left > 0 ? String(Math.ceil(left)) : "";
|
|
800
|
+
if (cnt.textContent !== txt) { cnt.textContent = txt; cnt.classList.toggle("on", !!txt); }
|
|
801
|
+
if (!m.solo && !g.isHost) g.shared = sharedOf(R.sharedView());
|
|
802
|
+
};
|
|
803
|
+
if (m.solo) { m.phase = "waiting"; refresh(); balance(); refresh(); lobby(); return m; }
|
|
804
|
+
R.on("welcome", function (w, r) {
|
|
805
|
+
refresh(); rebuildShared();
|
|
806
|
+
if (r && r.restarted) say("The server restarted, so a new match started.", true);
|
|
807
|
+
else if (r && r.kept) say("Back after a short pause.", true);
|
|
808
|
+
// back after a dropped connection into the same match: carry on; anything else (a new match, a restart) starts fresh
|
|
809
|
+
balance();
|
|
810
|
+
m.whenProf(function () {
|
|
811
|
+
if (S.started) { if (!(g.state === "playing" && m.phase === "playing" && r && !r.restarted && !r.newMatch)) begin(); }
|
|
812
|
+
else { if (g.state === "playing") g.state = "ready"; m.phase = "waiting"; lobby(); if (m.autoStart && g.isHost && MIN <= 1) { m.autoStart = false; R.start(); } }
|
|
813
|
+
});
|
|
814
|
+
});
|
|
815
|
+
R.on("sync", function () { refresh(); rebuildShared(); });
|
|
816
|
+
// the saved profile came after the 3 s wait: use it if the game has not changed its own empty copy yet, else keep saving off for this page
|
|
817
|
+
R.on("profile", function () {
|
|
818
|
+
if (g.saves || m.solo) return;
|
|
819
|
+
if (m.profWritten) { if (!m.warned.profLate) { m.warned.profLate = 1; say("Progress is not being saved in this game."); } return; }
|
|
820
|
+
var p = R.adoptProfile && R.adoptProfile();
|
|
821
|
+
if (p) { prof = p; g.saves = true; setProfView(); paint(); }
|
|
822
|
+
});
|
|
823
|
+
R.on("players", function () { refresh(); balance(); if (m.phase !== "playing") lobby(); });
|
|
824
|
+
R.on("joined", function (p) { say(p.name + " joined"); });
|
|
825
|
+
R.on("newer", function () { say("This game was changed. Press restart above to play the new version with the others.", true); });
|
|
826
|
+
R.on("left", function (id) { say(nameOf(id) + " left"); });
|
|
827
|
+
R.on("shared", function () { rebuildShared(); if ((TEAMS || SEATS) && m.phase !== "playing") { refresh(); lobby(); } });
|
|
828
|
+
R.on("host", function (id, was) { refresh(); balance(); if (was !== undefined && id && was && id !== was) say(id === S.you ? "You are the host now" : nameOf(id) + " is the host now"); if (m.phase !== "playing") lobby(); });
|
|
829
|
+
R.on("started", function () { m.whenProf(function () { begin(true); }); });
|
|
830
|
+
R.on("ended", function () { R.flush(); m.over(); });
|
|
831
|
+
R.on("saved", function () { if (m.phase !== "playing") lobby(); });
|
|
832
|
+
// nobody did anything for a long time: the lobby shows "closes in about a minute" with Keep it; in a match a notice (any move keeps the room)
|
|
833
|
+
R.on("idle", function (at) { if (m.phase !== "playing") lobby(); else if (at) say("Nobody has moved for a while, so this room closes in a minute.", true); });
|
|
834
|
+
R.on("action", function (id, from, name, data) {
|
|
835
|
+
if (!g.isHost) return R.answer(id, false, "not host");
|
|
836
|
+
if (name === "_team") { // the shell's own: switch team in the lobby, if that team has room
|
|
837
|
+
var to = data && Math.floor(data.to), size = Math.ceil(spec.max / TEAMS), n = g.players.filter(function (p) { return p.team === to; }).length;
|
|
838
|
+
if (!TEAMS || S.started || !(to >= 0 && to < TEAMS) || n >= size) return R.answer(id, false, "full");
|
|
839
|
+
var patch = {}; patch[from] = to; rawShared({ _teams: patch }); refresh(); lobby(); return R.answer(id, true); // the host's own lobby shows it too
|
|
840
|
+
}
|
|
841
|
+
if (name === "_seat") { // the shell's own: sit down or watch, before the start
|
|
842
|
+
if (!SEATS || isCup() || S.started) return R.answer(id, false, "started");
|
|
843
|
+
var sp = {}, used = seatsOf(), free = -1;
|
|
844
|
+
if (data && data.sit) { for (var f = 0; f < SEATS && free < 0; f++) { var taken = false; for (var u in used) if (used[u] === f && S.players[u]) taken = true; if (!taken) free = f; } if (free < 0) return R.answer(id, false, "full"); sp[from] = free; }
|
|
845
|
+
else sp[from] = -1;
|
|
846
|
+
rawShared({ _seats: sp }); seatBalance(); refresh(); lobby(); return R.answer(id, true);
|
|
847
|
+
}
|
|
848
|
+
if (name.charAt(0) === "_") return R.answer(id, false, "unknown");
|
|
849
|
+
if (SEATS && S.started && !(seatsOf()[from] >= 0)) return R.answer(id, false, "watching");
|
|
850
|
+
var ok; m.inHost = true;
|
|
851
|
+
try { ok = o.onAction ? o.onAction(g, from, name, data) !== false : true; } catch (e) { console.error(e); ok = false; } finally { m.inHost = false; }
|
|
852
|
+
R.answer(id, ok);
|
|
853
|
+
});
|
|
854
|
+
R.on("fx", function (from, name, data) {
|
|
855
|
+
if (name === "_shot") return addShot(from, data);
|
|
856
|
+
if (name === "_hit") { if (from === S.host && data) for (var i = shots.length - 1; i >= 0; i--) if (shots[i].id === data.i) shots.splice(i, 1); return; }
|
|
857
|
+
if (name.charAt(0) === "_") return;
|
|
858
|
+
try { if (o.onFx) o.onFx(g, from, name, data); } catch (e) { console.error(e); }
|
|
859
|
+
});
|
|
860
|
+
R.on("invite", function (ok) { say(ok ? "Invite link copied" : "Copy the page address and add #room=" + S.code); });
|
|
861
|
+
// A write the room refused (too big, too long, too deep, a number out of its range) is refused whole and the others never get it: say so once
|
|
862
|
+
// per kind on screen, and in the console with where and why, so whoever fixes the game has something to read.
|
|
863
|
+
var WHY = { too_big: "something too big", bad_value: "something too long or too deep", out_of_range: "a number outside its range" };
|
|
864
|
+
R.on("error", function (code, ref) {
|
|
865
|
+
if (code === "not_host" || code === "old_term") return;
|
|
866
|
+
if (code === "too_much_data") return warn("data", "room: this game sends more than the server allows for one room, so its updates now go out less often. Write only what changes, fewer times a second (g.shared.set inside host() runs every frame: keep big lists out of it).");
|
|
867
|
+
var at = typeof ref === "string" && ref ? ref : "";
|
|
868
|
+
warn("e" + code + at, "room: " + code + (at ? " at " + at : "") + (WHY[code] ? " (limits: text 200 characters, lists 64 items, 64 keys per object, 4 levels deep, 2 KB per player, 64 KB shared; a write that breaks one is refused whole)" : ""));
|
|
869
|
+
if (WHY[code] && !m.warned["s" + code + at]) { m.warned["s" + code + at] = 1; say("This game tried to share " + WHY[code] + (at ? ": " + at : "") + ". The other players did not get it."); }
|
|
870
|
+
});
|
|
871
|
+
R.on("status", function (s, d) {
|
|
872
|
+
if (s === "retry") say("Connection lost, reconnecting...");
|
|
873
|
+
else if (s === "closed") { m.phase = "closed"; g.state = "over"; lobby(); }
|
|
874
|
+
else if (s === "unavailable") { m.solo = true; m.profState = "done"; S = { you: "me", host: "me", code: "", match: 1, term: 1, started: false, players: { me: { name: "You", state: {} } }, shared: {} }; m.phase = "waiting"; refresh(); balance(); refresh(); lobby(); }
|
|
875
|
+
void d;
|
|
876
|
+
});
|
|
877
|
+
lobby();
|
|
878
|
+
R.ready().then(function (inf) { if (inf) join({}); });
|
|
879
|
+
return m;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function input(a) {
|
|
883
|
+
if (mp) { if (g.state === "playing" && !mp.watching() && !(mp.count() > 0)) try { if (o.onInput) o.onInput(g, a); } catch (e) { console.error(e); } return; }
|
|
884
|
+
if (g.state === "ready" || g.state === "over") { if (a === "action") g.start(); return; }
|
|
885
|
+
if (g.state === "paused") { if (a === "action") g.pause(); return; }
|
|
886
|
+
try { if (o.onInput) o.onInput(g, a); } catch (e) { console.error(e); }
|
|
887
|
+
}
|
|
888
|
+
var KEYS = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", w: "up", s: "down", a: "left", d: "right", W: "up", S: "down", A: "left", D: "right", " ": "action", Enter: "action" };
|
|
889
|
+
// Held directions for action games: g.held.left (keys or the on-screen pad), g.axis() = {x, y} with length 0 or 1.
|
|
890
|
+
g.held = { up: false, down: false, left: false, right: false };
|
|
891
|
+
g.axis = function () { var x = (g.held.right ? 1 : 0) - (g.held.left ? 1 : 0), y = (g.held.down ? 1 : 0) - (g.held.up ? 1 : 0), l = Math.hypot(x, y) || 1; return { x: x / l, y: y / l }; };
|
|
892
|
+
// Extra buttons (Game({buttons: [{id, label, key}]})): held like the directions, pressed through onInput; on touch screens a row under the pad.
|
|
893
|
+
var BTN = (Array.isArray(o.buttons) ? o.buttons : []).filter(function (b) { return b && typeof b.id === "string" && !KEYS[b.id] && !/^(up|down|left|right|action)$/.test(b.id); }).slice(0, 4);
|
|
894
|
+
// On a phone the pad sits under the left thumb and the extra buttons under the right one.
|
|
895
|
+
var ctl = document.createElement("div"); ctl.className = "tg-ctl"; root.appendChild(ctl); ctl.appendChild(pad);
|
|
896
|
+
var acts = document.createElement("div"); acts.className = "tg-acts"; ctl.appendChild(acts);
|
|
897
|
+
function btnOf(e) { for (var i = 0; i < BTN.length; i++) { var k = BTN[i].key; if (k && (e.key === k || String(e.key).toLowerCase() === String(k).toLowerCase())) return BTN[i].id; } return null; }
|
|
898
|
+
BTN.forEach(function (b) {
|
|
899
|
+
g.held[b.id] = false;
|
|
900
|
+
var e = document.createElement("button"); e.textContent = b.label || b.id; e.setAttribute("data-btn", b.id);
|
|
901
|
+
e.addEventListener("pointerdown", function (ev) { g.held[b.id] = true; input(b.id); ev.preventDefault(); });
|
|
902
|
+
["pointerup", "pointercancel", "pointerleave"].forEach(function (n) { e.addEventListener(n, function () { g.held[b.id] = false; }); });
|
|
903
|
+
acts.appendChild(e);
|
|
904
|
+
});
|
|
905
|
+
acts.classList.toggle("on", wantPad && BTN.length > 0);
|
|
906
|
+
document.addEventListener("keyup", function (e) { var a = KEYS[e.key]; if (a && a !== "action") g.held[a] = false; var b = btnOf(e); if (b) g.held[b] = false; });
|
|
907
|
+
addEventListener("blur", function () { for (var k in g.held) g.held[k] = false; g.pointer.down = false; });
|
|
908
|
+
// headless: the page's engine reads the same keys, and some (Phaser) skip a key event that was already cancelled, so the shell never cancels them
|
|
909
|
+
var keep = function (e) { if (!HL) e.preventDefault(); };
|
|
910
|
+
document.addEventListener("keydown", function (e) {
|
|
911
|
+
var typing = e.target && /^(INPUT|TEXTAREA)$/.test(e.target.tagName);
|
|
912
|
+
var h = KEYS[e.key]; if (h && h !== "action" && !typing) g.held[h] = true;
|
|
913
|
+
var xb = typing ? null : btnOf(e);
|
|
914
|
+
if (xb) { if (!e.repeat) { g.held[xb] = true; if (g.state === "playing") input(xb); } keep(e); return; }
|
|
915
|
+
if (!mp && (e.key === "p" || e.key === "P" || e.key === "Escape")) { if (g.state === "playing" || g.state === "paused") { g.pause(); keep(e); } return; }
|
|
916
|
+
var a = KEYS[e.key]; if (!a) return;
|
|
917
|
+
if (e.target && /^(INPUT|TEXTAREA)$/.test(e.target.tagName)) return; // typing a name in the lobby
|
|
918
|
+
if (a === "action" && g.state === "playing") { if (e.repeat) { keep(e); return; } // a held key is one press (auto-fire: read g.held in update)
|
|
919
|
+
if (!(mp && (mp.watching() || mp.count() > 0))) try { if (o.onInput) o.onInput(g, "action"); } catch (x) { console.error(x); } keep(e); return; }
|
|
920
|
+
input(a); keep(e);
|
|
921
|
+
});
|
|
922
|
+
var padDir = function (b) { return b.className.indexOf("u") >= 0 ? "up" : b.className.indexOf("d") >= 0 ? "down" : b.className.indexOf("l") >= 0 ? "left" : "right"; };
|
|
923
|
+
// Each finger holds its own direction, so a phone can steer and drive at once: lifting one finger lets go of that direction only.
|
|
924
|
+
var padUp = function (e) { var b = e.target && e.target.closest ? e.target.closest("button") : null; if (b) g.held[padDir(b)] = false; else g.held.up = g.held.down = g.held.left = g.held.right = false; };
|
|
925
|
+
pad.addEventListener("pointerup", padUp); pad.addEventListener("pointercancel", padUp);
|
|
926
|
+
pad.addEventListener("pointerdown", function (e) { var b = e.target.closest("button"); if (!b) return; g.held[padDir(b)] = true; try { b.setPointerCapture(e.pointerId); } catch (x) { /* a made-up pointer */ } input(padDir(b)); e.preventDefault(); });
|
|
927
|
+
var sx = 0, sy = 0, sd = false;
|
|
928
|
+
board.addEventListener("pointerdown", function (e) { sx = e.clientX; sy = e.clientY; sd = true; if (e.target === canvas) { point(e); g.pointer.down = true; } });
|
|
929
|
+
canvas.addEventListener("pointermove", point);
|
|
930
|
+
addEventListener("pointerup", function () { g.pointer.down = false; }); addEventListener("pointercancel", function () { g.pointer.down = false; });
|
|
931
|
+
board.addEventListener("pointerup", function (e) {
|
|
932
|
+
if (!sd) return; sd = false; var dx = e.clientX - sx, dy = e.clientY - sy;
|
|
933
|
+
if (Math.max(Math.abs(dx), Math.abs(dy)) < 24) {
|
|
934
|
+
if (e.target !== canvas) return;
|
|
935
|
+
point(e);
|
|
936
|
+
// a click or tap: the game's onPointer(g, x, y) in board units when it has one (and the player is in the match), else the "action" input
|
|
937
|
+
if (o.onPointer && g.state === "playing") { if (!(mp && (mp.watching() || mp.count() > 0))) try { o.onPointer(g, g.pointer.x, g.pointer.y); } catch (x) { console.error(x); } }
|
|
938
|
+
else input("action");
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
input(Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "right" : "left") : (dy > 0 ? "down" : "up"));
|
|
942
|
+
});
|
|
943
|
+
document.addEventListener("visibilitychange", function () { if (!mp && document.hidden && g.state === "playing") g.pause(); });
|
|
944
|
+
if (!HL) noZoom(); // headless: the page's engine handles its own touches
|
|
945
|
+
/* Phones, iPhone Safari above all: a quick double or triple tap zooms, tap-then-hold zooms, and a long press shows a magnifier or a menu, even with
|
|
946
|
+
touch-action none and the pointer events cancelled. Only a cancelled touch event stops those, so: the d-pad and ability buttons cancel every touch
|
|
947
|
+
(they work on pointer events), any touch that starts within 400 ms of the last one is cancelled (a button tapped that fast is clicked here, since a
|
|
948
|
+
cancelled touch gives no click), and so is a second finger, a pinch and the long-press menu. Text fields and plain swipes in a scrolling list keep
|
|
949
|
+
their touches. The viewport is also told not to zoom (a page opened in its own tab). */
|
|
950
|
+
function noZoom() {
|
|
951
|
+
var vp = document.querySelector('meta[name="viewport"]');
|
|
952
|
+
if (!vp) { vp = document.createElement("meta"); vp.name = "viewport"; document.head.appendChild(vp); }
|
|
953
|
+
vp.content = "width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover";
|
|
954
|
+
var at = function (t, sel) { return t && t.closest ? t.closest(sel) : null; };
|
|
955
|
+
var typing = function (t) { return at(t, "input,textarea,select,[contenteditable]"); };
|
|
956
|
+
var ctl = function (t) { return at(t, ".tg-pad,.tg-acts"); };
|
|
957
|
+
var lastEnd = 0, eaten = false, opt = { passive: false, capture: true };
|
|
958
|
+
document.addEventListener("touchstart", function (e) {
|
|
959
|
+
var t = e.target; eaten = false; if (typing(t)) return;
|
|
960
|
+
var quick = Date.now() - lastEnd < 400, swipeList = at(t, ".tg-lob,.tg-ov p") && !at(t, "button");
|
|
961
|
+
eaten = !!(ctl(t) || e.touches.length > 1 || (quick && !swipeList));
|
|
962
|
+
if (eaten && e.cancelable) e.preventDefault();
|
|
963
|
+
}, opt);
|
|
964
|
+
document.addEventListener("touchend", function (e) {
|
|
965
|
+
var t = e.target; lastEnd = Date.now();
|
|
966
|
+
if (!eaten || typing(t) || ctl(t) || !e.cancelable) return;
|
|
967
|
+
e.preventDefault();
|
|
968
|
+
var b = at(t, "button"); if (b && !b.disabled && !e.touches.length) b.click();
|
|
969
|
+
}, opt);
|
|
970
|
+
document.addEventListener("gesturestart", function (e) { e.preventDefault(); }, opt);
|
|
971
|
+
document.addEventListener("dblclick", function (e) { if (!typing(e.target)) e.preventDefault(); }, opt);
|
|
972
|
+
document.addEventListener("contextmenu", function (e) { if (!typing(e.target)) e.preventDefault(); }, opt);
|
|
973
|
+
document.addEventListener("selectstart", function (e) { if (!typing(e.target)) e.preventDefault(); }, opt);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
var last = 0, acc = 0;
|
|
977
|
+
function loop(ts) {
|
|
978
|
+
if (g.state === "playing") {
|
|
979
|
+
if (mp) mp.pre();
|
|
980
|
+
if (!last) last = ts;
|
|
981
|
+
acc += Math.min(0.25, (ts - last) / 1000); last = ts;
|
|
982
|
+
var n = 0;
|
|
983
|
+
while (acc >= step && n++ < 8 && g.state === "playing") {
|
|
984
|
+
g.time += step;
|
|
985
|
+
try { if (mp) mp.step(step); else if (o.update) o.update(g, step); } catch (e) { console.error(e); if (!mp) g.state = "paused"; }
|
|
986
|
+
acc -= step;
|
|
987
|
+
}
|
|
988
|
+
if (n >= 8) acc = 0;
|
|
989
|
+
QL.skip = QL.half && !QL.skip;
|
|
990
|
+
if (!QL.skip) { var p0 = performance.now(); hud(); paint(); measure(ts, performance.now() - p0); }
|
|
991
|
+
} else { last = 0; QL.t0 = 0; }
|
|
992
|
+
if (mp) mp.frame();
|
|
993
|
+
requestAnimationFrame(loop);
|
|
994
|
+
}
|
|
995
|
+
addEventListener("resize", fit);
|
|
996
|
+
if (window.ResizeObserver) new ResizeObserver(fit).observe(area);
|
|
997
|
+
if (!mp) screen(o.title || "Ready", o.help || "Arrow keys or swipe to play.", "Start");
|
|
998
|
+
root.tabIndex = -1; if (mp) mp.inSetup = true; try { if (o.setup) o.setup(g); } catch (e) { console.error(e); } finally { if (mp) mp.inSetup = false; } // state exists before the first draw (the start screen shows the board behind it)
|
|
999
|
+
fit(); hud(); requestAnimationFrame(loop);
|
|
1000
|
+
window.game = g;
|
|
1001
|
+
return g;
|
|
1002
|
+
};
|
|
1003
|
+
})();
|