@homie-rocks/studio 0.39.0 → 0.40.0
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 +28 -1
- package/lib/build.mjs +1 -0
- package/lib/port-check.mjs +6 -3
- package/lib/rules-build.mjs +8 -1
- package/lib/rules-check-worker.mjs +3 -3
- package/lib/rules-check.mjs +3 -1
- package/lib/template-history.json +41 -0
- package/netplay/NETPLAY.md +74 -2
- package/package.json +1 -1
- package/rules/collision.ts +115 -0
- package/rules/core.ts +65 -25
- package/rules/host.ts +5 -1
- package/rules/math.ts +15 -5
- package/rules/rules.ts +33 -10
- package/rules/types.ts +2 -2
- package/rules/view.ts +126 -32
- package/starters/ember-vale/game.json +16 -4
- package/starters/ember-vale/map/main.json +66 -0
- package/starters/ember-vale/src/move.ts +31 -0
- package/starters/ember-vale/src/rules.ts +244 -0
- package/starters/ember-vale/src/{main.ts → view.ts} +89 -574
- package/starters/ember-vale/tunables.json +100 -11
- package/starters/gem-rush/src/move.ts +9 -1
- package/starters/gem-rush/src/view.ts +2 -1
- package/starters/gem-rush-3d/game.json +23 -5
- package/starters/gem-rush-3d/map/main.json +153 -0
- package/starters/gem-rush-3d/src/move.ts +37 -0
- package/starters/gem-rush-3d/src/rules.ts +133 -0
- package/starters/gem-rush-3d/src/{main.ts → view.ts} +63 -671
- package/starters/gem-rush-3d/tunables.json +98 -11
- package/starters/hero-rush-3d/game.json +23 -5
- package/starters/hero-rush-3d/map/main.json +153 -0
- package/starters/hero-rush-3d/src/move.ts +49 -0
- package/starters/hero-rush-3d/src/rules.ts +200 -0
- package/starters/hero-rush-3d/src/{main.ts → view.ts} +83 -976
- package/starters/hero-rush-3d/tunables.json +260 -30
- package/worker/version.mjs +1 -1
|
@@ -1,43 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* GEM RUSH 3D — Gem Rush (the netplay v1 reference game: the gem-rush starter and
|
|
3
|
-
* @homie-rocks/studio/netplay/NETPLAY.md) drawn in three.js and dressed with free CC0 models.
|
|
4
|
-
*
|
|
5
|
-
* THE RULES ARE GEM RUSH'S, line for line: move, collect gems, score; 60 s rounds that cycle forever; the round
|
|
6
|
-
* starts the moment the first browser arrives, with two bots, and every human who arrives takes a bot's body. One
|
|
7
|
-
* browser hosts (pickups, scoring, bots, the clock) and broadcasts snapshots; everyone else moves their own animal
|
|
8
|
-
* locally and draws the rest from interpolated snapshots; a promoted host continues the same round. A wave knocks
|
|
9
|
-
* nearby bodies back (the host TAKES a client-owned body, drives the knockback, and GIVES it back); the hot zone
|
|
10
|
-
* (keyed state) scores double; a watcher follows any player (`net.viewSeat`, `net.spotlight`); bots play at the
|
|
11
|
-
* room's skill dial; the knock is tuned in the Game Lab (tunables.json, lab.json). Only the units changed: the
|
|
12
|
-
* world is in metres, a 26 x 16 m clearing with a few bushes, rocks and a tree in it (solid: bodies go round them), x across and y deep (y is
|
|
13
|
-
* three.js's z; the ground is y = 0 in three.js).
|
|
14
|
-
*
|
|
15
|
-
* THE LOOK comes from files beside game.json:
|
|
16
|
-
* style.json the palette, fonts, light and camera (the art direction's tokens: `homie-studio style` writes it);
|
|
17
|
-
* everything the code draws, and the repainted models, take their colours from it
|
|
18
|
-
* public/models/ the animals, the gem, trees, rocks, flowers and the fence: Kenney's CC0 packs from the Homie
|
|
19
|
-
* starter library, phone-sized, fetched by `game new` (the repository keeps no model files).
|
|
20
|
-
* assets/manifest.json names each library item and its SHA-256; assets/RIGHTS.md and credits.json
|
|
21
|
-
* say what its licence allows.
|
|
22
|
-
* Every model loads through @homie-rocks/studio/assets (createModels: each file is checked before three.js reads a
|
|
23
|
-
* byte). A model that is refused or not there is drawn as a stand-in in the same colours (see "models" below): a
|
|
24
|
-
* round never waits on, or breaks for, a model.
|
|
25
|
-
*
|
|
26
|
-
* HEADINGS, AND THERE ARE TWO: a body's `yaw` is `Math.atan2(dx, dy)` in the game's own x and y, so yaw 0 faces +y
|
|
27
|
-
* (three.js's +z, the way the models are built) and it goes straight into `rotation.y`. That is also the yaw
|
|
28
|
-
* `@homie-rocks/camera` means (0 faces +Z, positive turns toward +X, radians), so a follow or chase camera from that
|
|
29
|
-
* package takes a body's `yaw` as it stands. The knock and aim angles (`ang`, `base`) are the OTHER kind,
|
|
30
|
-
* `Math.atan2(dy, dx)`: 0 faces +x. Never hand one of those to a camera or to `rotation.y` without converting it:
|
|
31
|
-
* `headingFrom({ zero: '+x', toward: '+z' })` from `@homie-rocks/camera/heading.js` is the adapter.
|
|
32
|
-
*
|
|
33
|
-
* PHONE BUDGETS: under 100 draw calls and 150k triangles a frame (the `drawCalls` and `triangles` probes read
|
|
34
|
-
* renderer.info), the pixel ratio at most 1.5 on a phone and 2 on a computer, and no shadow map on a phone: a soft
|
|
35
|
-
* disc under each body, gem, tree and rock (a computer draws the sun's real shadows). Repeated things (gems, trees, flowers, the fence, the discs) are one
|
|
36
|
-
* InstancedMesh per model.
|
|
37
|
-
*
|
|
38
|
-
* The HUD (clock, scores, names, results, "Reconnecting…", the controls hint, the role badge) is a 2D canvas over
|
|
39
|
-
* the world, in style.json's fonts and colours.
|
|
40
|
-
*/
|
|
1
|
+
/** Gem Rush 3D: the room runs the rules; this view draws the meadow and its animals. */
|
|
41
2
|
import {
|
|
42
3
|
AmbientLight, AnimationClip, AnimationMixer, Box3, BoxGeometry, BufferAttribute, BufferGeometry, CanvasTexture, CircleGeometry, Color, ConeGeometry, CylinderGeometry,
|
|
43
4
|
DodecahedronGeometry, IcosahedronGeometry, OctahedronGeometry,
|
|
@@ -49,17 +10,20 @@ import {
|
|
|
49
10
|
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
|
|
50
11
|
// The one model loader every studio game uses: checks each file, decodes meshopt and WebP, copies for placing.
|
|
51
12
|
import { createModels, instancedCopies, placeCopy, repaint, stylize, type Copies } from '@homie-rocks/studio/assets';
|
|
52
|
-
import {
|
|
13
|
+
import { guardGestures, PALETTE, AI_MARK, type RoundInfo, type Slot } from '@homie-rocks/studio/netplay';
|
|
53
14
|
// The port toolkit: its probe (what `homie-studio port check` and `perf` read, and sandbox + audio shims), and name
|
|
54
15
|
// labels that never pile up (port/view.ts).
|
|
55
16
|
import { createBubbles, createLabels, exposePort, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
|
|
56
17
|
// The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
|
|
57
18
|
import { lab } from '@homie-rocks/studio/lab';
|
|
58
|
-
import
|
|
19
|
+
import { openRoom, type Entity } from '@homie-rocks/studio/rules/view';
|
|
20
|
+
import type rules from './rules';
|
|
21
|
+
const room = openRoom<typeof rules>();
|
|
22
|
+
const net = room.net;
|
|
23
|
+
guardGestures({ touch: 'canvas' });
|
|
59
24
|
import styleFile from '../style.json';
|
|
60
25
|
import gameFile from '../game.json';
|
|
61
26
|
|
|
62
|
-
/* ------------------------------------------------------------------ rules */
|
|
63
27
|
const W = 26;
|
|
64
28
|
const H = 16;
|
|
65
29
|
const R_AV = 0.44;
|
|
@@ -73,16 +37,6 @@ const OBSTACLES: readonly { x: number; y: number; r: number; kind: 'camp' | 'bus
|
|
|
73
37
|
{ x: 4.6, y: 3.8, r: 0.75, kind: 'bush' }, { x: 21.4, y: 4.6, r: 0.55, kind: 'rock' }, { x: 6.0, y: 12.6, r: 0.5, kind: 'rock' },
|
|
74
38
|
{ x: 20.2, y: 12.2, r: 0.75, kind: 'bush' }, { x: 9.6, y: 13.4, r: 0.45, kind: 'stone' }, { x: 23.6, y: 2.0, r: 0.5, kind: 'tree' },
|
|
75
39
|
];
|
|
76
|
-
/** A position kept in the clearing and out of its features (pushed to their edge, so a body slides round them). */
|
|
77
|
-
function bound(x: number, y: number): { x: number; y: number } {
|
|
78
|
-
x = Math.max(R_AV, Math.min(W - R_AV, x)); y = Math.max(R_AV, Math.min(H - R_AV, y));
|
|
79
|
-
for (const o of OBSTACLES) {
|
|
80
|
-
const dx = x - o.x; const dy = y - o.y; const d = Math.hypot(dx, dy); const min = o.r + R_AV;
|
|
81
|
-
if (d >= min) continue;
|
|
82
|
-
if (d > 1e-6) { x = o.x + (dx / d) * min; y = o.y + (dy / d) * min; } else x = o.x + min;
|
|
83
|
-
}
|
|
84
|
-
return { x, y };
|
|
85
|
-
}
|
|
86
40
|
const R_GEM = 0.26;
|
|
87
41
|
const SPEED = 6.8;
|
|
88
42
|
const BOT_SPEED = 5;
|
|
@@ -92,7 +46,7 @@ const GEM_COUNT = 14;
|
|
|
92
46
|
const MIN_SLOTS = 3; // 1 human + 2 bots from the first frame
|
|
93
47
|
const MAX_SLOTS = 8;
|
|
94
48
|
/** The knock's numbers (tunables.json): the file's values, or the Game Lab's sliders while it plays a take. */
|
|
95
|
-
const T =
|
|
49
|
+
const T = room.tune as Record<string, number>;
|
|
96
50
|
const ZONE_MS = 12_000;
|
|
97
51
|
/** The contract's 12 colours (PALETTE, NETPLAY.md section 3): a person wears their seat's, so the watch page's strip matches. */
|
|
98
52
|
const colourOf = (slot: number, seat: number | null): string => PALETTE[(seat ?? slot) % PALETTE.length] as string;
|
|
@@ -101,282 +55,46 @@ const botName = (slot: number): string => BOT_NAMES[slot % BOT_NAMES.length] as
|
|
|
101
55
|
/** An AI's name already ends in " · AI" (the relay sees to it); a plain bot says bot. */
|
|
102
56
|
const label = (name: string, bot: boolean): string => (name.endsWith(AI_MARK) ? name : bot ? `${name} · bot` : name);
|
|
103
57
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
interface Snap { r: [n: number, phase: number, startedAt: number, endsAt: number]; p: P[]; g: G[] }
|
|
109
|
-
/**
|
|
110
|
-
* Replica input: the avatar (owner movement) AND the stick intent (host movement), so either mode reads the same
|
|
111
|
-
* frame. The helper stamps the reset epoch the replica has adopted.
|
|
112
|
-
*/
|
|
113
|
-
type Avatar = [x: number, y: number, vx: number, vy: number, mx: number, my: number];
|
|
114
|
-
/** A body's bump, while it lasts: its direction (kvx, kvy), where its slide starts (kx, ky) and when (kat, after the hit-stop). */
|
|
115
|
-
interface Knock { kvx: number; kvy: number; kx: number; ky: number; kat: number; knockUntil: number }
|
|
116
|
-
interface Body extends Knock { slot: number; seat: number | null; name: string; bot: boolean; x: number; y: number; vx: number; vy: number; score: number; tx: number; ty: number }
|
|
117
|
-
interface Gem { id: number; x: number; y: number }
|
|
118
|
-
/** Slow state, on the keyed state channel (net.state('zone', ...)), not in the 20 Hz snapshot. */
|
|
119
|
-
interface Zone { n: number; x: number; y: number; r: number; until: number }
|
|
120
|
-
interface Ckpt { round: RoundInfo; bodies: Body[]; gems: Gem[]; roster: Slot[]; tick: number; gemSeq: number; occ?: [number, number][] }
|
|
121
|
-
|
|
122
|
-
/* --------------------------------------------------------------- the net */
|
|
123
|
-
/**
|
|
124
|
-
* `?movement=host` plays host movement: the host moves every body from the seats' stick intents (its rules own
|
|
125
|
-
* movement), and each replica PREDICTS its own body by replaying its unacknowledged intents on the host's position.
|
|
126
|
-
* Default `owner`: each browser moves its own body and the host bounds it.
|
|
127
|
-
*/
|
|
128
|
-
const MOVEMENT: 'owner' | 'host' = (() => { try { return new URLSearchParams(location.search).get('movement') === 'host' ? 'host' : 'owner'; } catch { return 'owner'; } })();
|
|
129
|
-
// caps: its bots read the dial ('skill'), and its Roster takes an AI's slot for it ('agents': the join passes p.agent).
|
|
130
|
-
const net = createNetplay<Snap, Avatar, Ckpt>({ game: 'gem-rush-3d', maxPlayers: MAX_SLOTS, movement: MOVEMENT, snapshotHz: 20, inputHz: 20, checkpointMs: 1000, caps: ['skill', 'agents'], checkpoint: () => checkpoint() });
|
|
131
|
-
// A touch game guards its own page (NETPLAY.md section 24): a long press on the canvas never selects text or raises
|
|
132
|
-
// the copy/paste callout on a phone, and a touch on it never pans or zooms the page.
|
|
133
|
-
guardGestures({ touch: 'canvas' });
|
|
134
|
-
/** The Roster keeps the server's AI seats (revision 6): the policy is read whenever it fills. */
|
|
135
|
-
const policy = () => net.policy;
|
|
136
|
-
|
|
137
|
-
/* ------------------------------------------------------------ host state */
|
|
138
|
-
let roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
|
|
139
|
-
let bodies = new Map<number, Body>();
|
|
140
|
-
let gems: Gem[] = [];
|
|
141
|
-
let gemSeq = 0;
|
|
142
|
-
let round: RoundInfo | null = null;
|
|
143
|
-
let zone: Zone | null = null;
|
|
144
|
-
let tick = 0;
|
|
145
|
-
let hosting = false;
|
|
146
|
-
let cheatResets = 0;
|
|
147
|
-
let controlResets = 0;
|
|
148
|
-
let predictionError = 0;
|
|
149
|
-
/** Gems each slot picked up this round (a gem, whatever it scored): the dial's numbers (`pickups` probe). */
|
|
150
|
-
let pickups = new Map<number, number>();
|
|
151
|
-
|
|
152
|
-
/* ------------------------------------------------ this browser's avatar */
|
|
153
|
-
const me = { x: W / 2, y: H / 2, vx: 0, vy: 0, kvx: 0, kvy: 0, kx: 0, ky: 0, kat: 0, knockUntil: 0, has: false };
|
|
154
|
-
/** Offline (no shell) plays as a local seat 0, with keys or touch. */
|
|
155
|
-
const mySeat = (): number | null => (net.offline ? 0 : net.seat);
|
|
156
|
-
/** Whose view to draw: my own seat, or for a watcher the player it follows (null: the whole arena). */
|
|
157
|
-
const viewSeat = (): number | null => (net.offline ? 0 : net.viewSeat);
|
|
158
|
-
|
|
159
|
-
/* ------------------------------------------------------- replica view */
|
|
58
|
+
const mySeat = (): number | null => room.seat;
|
|
59
|
+
const viewSeat = (): number | null => net.viewSeat;
|
|
60
|
+
const me = { x: 13, y: 8, has: false };
|
|
61
|
+
const poses = new Map<number, Entity>();
|
|
160
62
|
const drawn = new Map<number, { x: number; y: number; seat: number; score: number; slot: number }>();
|
|
161
63
|
const waves: { x: number; y: number; at: number; colour: string; knock?: boolean }[] = [];
|
|
162
|
-
let wavesSeen = 0;
|
|
163
|
-
let
|
|
164
|
-
let
|
|
165
|
-
let
|
|
166
|
-
let
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
const
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
for (const k of [...bodies.keys()]) if (!seen.has(k)) bodies.delete(k);
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function publishRoster(): void { net.roster(roster.toJSON()); }
|
|
196
|
-
|
|
197
|
-
/** The host moved body `b` itself: tell its owner (a replica adopts it; the host's own body is `me`). */
|
|
198
|
-
function hostMoved(b: Body): void {
|
|
199
|
-
if (b.bot || b.seat === null) return;
|
|
200
|
-
if (b.seat === mySeat()) { me.x = b.x; me.y = b.y; me.vx = 0; me.vy = 0; me.has = true; return; }
|
|
201
|
-
net.reset(b.seat);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
function newZone(n: number): Zone {
|
|
205
|
-
return { n, x: rnd(4.4, W - 4.4), y: rnd(4, H - 4), r: 3, until: net.now() + ZONE_MS };
|
|
206
|
-
}
|
|
207
|
-
function setZone(z: Zone): void { zone = z; net.state('zone', z); }
|
|
208
|
-
|
|
209
|
-
function startRound(n: number, rollover = false): void {
|
|
210
|
-
const now = net.now();
|
|
211
|
-
roster.trim();
|
|
212
|
-
// The next round of a running room: the trim is what makes room (the seats a server no longer keeps for AI go
|
|
213
|
-
// here), so whoever was seated with no body is given one now, before the bodies are placed.
|
|
214
|
-
if (rollover && !net.offline) seatWaiting();
|
|
215
|
-
syncBodiesFromRoster();
|
|
216
|
-
let i = 0;
|
|
217
|
-
for (const b of [...bodies.values()].sort((a, c) => a.slot - c.slot)) {
|
|
218
|
-
const sp = spawnPoint(i++);
|
|
219
|
-
b.x = sp.x; b.y = sp.y; b.vx = 0; b.vy = 0; b.score = 0; b.tx = sp.x; b.ty = sp.y; b.kvx = 0; b.kvy = 0; b.knockUntil = 0;
|
|
220
|
-
hostMoved(b);
|
|
221
|
-
}
|
|
222
|
-
gems = Array.from({ length: GEM_COUNT }, newGem);
|
|
223
|
-
pickups = new Map();
|
|
224
|
-
sight.clear();
|
|
225
|
-
round = { n, phase: 'live', startedAt: now, endsAt: now + ROUND_MS };
|
|
226
|
-
if (lab.stage === 'dummy') stageDummy();
|
|
227
|
-
setZone(newZone((zone?.n ?? 0) + 1));
|
|
228
|
-
net.round(round);
|
|
229
|
-
publishRoster();
|
|
230
|
-
net.snapshot(buildSnap(), tick, true);
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function endRound(): void {
|
|
234
|
-
if (!round) return;
|
|
235
|
-
const now = net.now();
|
|
236
|
-
const ranked = [...bodies.values()].sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot) || a.slot - b.slot);
|
|
237
|
-
const agentSlot = (slot: number): boolean => Boolean(roster.slots.find((x) => x.slot === slot)?.agent);
|
|
238
|
-
const results: RoundResult[] = ranked.map((b, i) => ({ slot: b.slot, seat: b.seat, name: b.name, score: b.score, bot: b.bot, place: i + 1, ...(agentSlot(b.slot) ? { agent: true as const } : {}) }));
|
|
239
|
-
round = { n: round.n, phase: 'over', startedAt: now, endsAt: now + BREAK_MS, results };
|
|
240
|
-
net.round(round);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/* ---------------------------------------------------- becoming the host */
|
|
244
|
-
function becomeHost(e: RoleChange<Snap, Ckpt>): void {
|
|
245
|
-
hosting = true;
|
|
246
|
-
if (e.promoted) restore(e);
|
|
247
|
-
else {
|
|
248
|
-
roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
|
|
249
|
-
bodies = new Map();
|
|
250
|
-
const seat = mySeat();
|
|
251
|
-
if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
|
|
252
|
-
syncBodiesFromRoster();
|
|
253
|
-
startRound((e.round?.n ?? 0) + 1);
|
|
254
|
-
}
|
|
255
|
-
// Whoever is connected now is who plays: seats that left during the gap become bots.
|
|
256
|
-
const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null).map((p) => ({ seat: p.seat, name: p.name, agent: p.agent ?? null, occ: p.occ ?? null }));
|
|
257
|
-
// `occ` (NETPLAY.md section 25): which stay in its seat each peer's is, so a seat number that changed hands while
|
|
258
|
-
// nobody was hosting is claimed afresh instead of inheriting the last player's body in silence.
|
|
259
|
-
const { claimed } = roster.reconcile(peers);
|
|
260
|
-
syncBodiesFromRoster();
|
|
261
|
-
for (const s of claimed) { const b = bodies.get(s.slot); if (b && b.seat !== mySeat()) hostMoved(b); }
|
|
262
|
-
// My own body is wherever my local avatar is: it was client-owned a moment ago and still is.
|
|
263
|
-
const mine = mySeat() !== null ? [...bodies.values()].find((b) => b.seat === mySeat()) : undefined;
|
|
264
|
-
if (mine && me.has) { mine.x = me.x; mine.y = me.y; }
|
|
265
|
-
else if (mine) { me.x = mine.x; me.y = mine.y; me.has = true; }
|
|
266
|
-
// The hot zone is keyed state: the relay handed it over with the role.
|
|
267
|
-
zone = net.stateOf<Zone>('zone') ?? zone;
|
|
268
|
-
if (!zone && round) setZone(newZone(1));
|
|
269
|
-
publishRoster();
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
function restore(e: RoleChange<Snap, Ckpt>): void {
|
|
273
|
-
const ck = e.ckpt?.d ?? null;
|
|
274
|
-
if (ck) {
|
|
275
|
-
roster = Roster.from(ck.roster, { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy }, ck.occ ?? null);
|
|
276
|
-
bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
|
|
277
|
-
gems = ck.gems.map((g) => ({ ...g }));
|
|
278
|
-
gemSeq = ck.gemSeq;
|
|
279
|
-
round = ck.round;
|
|
280
|
-
tick = ck.tick;
|
|
281
|
-
} else {
|
|
282
|
-
roster = Roster.from(e.roster ?? [], { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
|
|
283
|
-
bodies = new Map();
|
|
284
|
-
syncBodiesFromRoster();
|
|
285
|
-
}
|
|
286
|
-
// The snapshot is newer than the checkpoint (20 Hz vs 1 Hz): positions, scores and gems from it.
|
|
287
|
-
const s = e.snap;
|
|
288
|
-
if (s && (!e.ckpt || s.st >= e.ckpt.st)) {
|
|
289
|
-
for (const [slot, seat, x, y, score] of s.d.p) {
|
|
290
|
-
let b = bodies.get(slot);
|
|
291
|
-
if (!b) {
|
|
292
|
-
const rs0 = roster.slots.find((r) => r.slot === slot) ?? { slot, seat: seat >= 0 ? seat : null, name: seat >= 0 ? `Player ${seat + 1}` : botName(slot), bot: seat < 0 };
|
|
293
|
-
b = bodyFor(rs0);
|
|
294
|
-
bodies.set(slot, b);
|
|
295
|
-
}
|
|
296
|
-
b.x = x; b.y = y; b.score = score; b.tx = x; b.ty = y;
|
|
297
|
-
}
|
|
298
|
-
gems = s.d.g.map(([id, x, y]) => ({ id, x, y }));
|
|
299
|
-
gemSeq = Math.max(gemSeq, ...gems.map((g) => g.id));
|
|
300
|
-
const [n, ph, startedAt, endsAt] = s.d.r;
|
|
301
|
-
round = { n, phase: ph ? 'over' : 'live', startedAt, endsAt, ...(round?.n === n && round.results ? { results: round.results } : {}) };
|
|
302
|
-
tick = Math.max(tick, s.k);
|
|
64
|
+
let wavesSeen = 0, knocksSeen = 0, frames = 0;
|
|
65
|
+
let firstStateAt = 0, firstSnapAt = 0;
|
|
66
|
+
let gems: { x: number; y: number; id: number }[] = [];
|
|
67
|
+
let round: RoundInfo | null = null;
|
|
68
|
+
let zone: { x: number; y: number; r: number } | null = null;
|
|
69
|
+
const slotOf = (e: Entity): number => e.kind === 'runner' ? e.seat ?? 0 : 0;
|
|
70
|
+
function updateView(): void {
|
|
71
|
+
const v = moveVector(); room.input({ ax: Math.round(v.x * 127), ay: Math.round(v.y * 127) });
|
|
72
|
+
const own = room.me; me.has = Boolean(own);
|
|
73
|
+
if (own) { me.x = own.pos.x; me.y = own.pos.y; }
|
|
74
|
+
drawn.clear(); poses.clear(); room.each('runner', e => poses.set(slotOf(e), e));
|
|
75
|
+
room.each('runner', e => drawn.set(slotOf(e), { slot: slotOf(e), seat: e.driver === 'bot' ? -1 : e.seat ?? -1, x: e.pos.x, y: e.pos.y, score: Number(e.score) }));
|
|
76
|
+
gems = []; room.each('gem', e => gems.push({ id: Number(e.serial), x: e.pos.x, y: e.pos.y }));
|
|
77
|
+
const z = room.shared.zone as { x: number; y: number } | undefined;
|
|
78
|
+
zone = z ? { x: z.x, y: z.y, r: 3 } : null;
|
|
79
|
+
if (own && !firstSnapAt) firstSnapAt = performance.now();
|
|
80
|
+
if (z && !firstStateAt) firstStateAt = performance.now();
|
|
81
|
+
const r = room.round;
|
|
82
|
+
round = r ? { ...r, startedAt: 0, endsAt: net.now() + r.secondsLeft * 1000 } : null;
|
|
83
|
+
}
|
|
84
|
+
function effect(name: 'wave' | 'knock', e: { id?: string; at?: { x: number; y: number } | null; dir?: { x: number; y: number }; by?: string }): void {
|
|
85
|
+
const body = e.id ? room.get(e.id) : null;
|
|
86
|
+
if (!body || body.kind !== 'runner' || !e.at) return;
|
|
87
|
+
const slot = slotOf(body), at = { x: body.pos.x, y: body.pos.y }, now = performance.now();
|
|
88
|
+
waves.push({ ...at, at: now, colour: name === 'knock' ? '#ffffff' : colourOf(slot, body.seat ?? null), knock: name === 'knock' });
|
|
89
|
+
if (name === 'wave') { net.spotlight(body.driver === 'person' ? body.seat ?? null : null); wavesSeen++; pushes.set(slot, now); }
|
|
90
|
+
else if (e.dir) {
|
|
91
|
+
const by = e.by ? room.get(e.by) : null;
|
|
92
|
+
knocksSeen++; bumped({ slot, dx: e.dir.x, dy: e.dir.y, by: by?.kind === 'runner' ? by.seat : undefined }, at);
|
|
93
|
+
if (lab.on) Object.assign(subject, { slot, at: net.now(), x0: at.x, y0: at.y, px: at.x, py: at.y, has: true });
|
|
303
94
|
}
|
|
304
|
-
// A round message the relay kept is at least as authoritative as the checkpoint's copy.
|
|
305
|
-
if (e.round && (!round || e.round.n > round.n || (e.round.n === round.n && e.round.phase === 'over' && round.phase === 'live'))) round = e.round;
|
|
306
|
-
if (!round) { startRound(1); return; }
|
|
307
|
-
net.round(round);
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
function checkpoint(): Ckpt {
|
|
311
|
-
return {
|
|
312
|
-
round: round ?? { n: 0, phase: 'live', startedAt: 0, endsAt: 0 },
|
|
313
|
-
bodies: [...bodies.values()].map((b) => ({ ...b })),
|
|
314
|
-
gems: gems.map((g) => ({ ...g })),
|
|
315
|
-
roster: roster.toJSON(),
|
|
316
|
-
// Whose stay each body is, beside the roster: how the next host tells a player who came back from a new one.
|
|
317
|
-
occ: roster.occupants(),
|
|
318
|
-
tick,
|
|
319
|
-
gemSeq,
|
|
320
|
-
};
|
|
321
95
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
if (e.role === 'host') becomeHost(e);
|
|
325
|
-
else { hosting = false; }
|
|
326
|
-
});
|
|
327
|
-
/** Host: a body for somebody the relay seated. False when every body is somebody's (they wait: see seatWaiting). */
|
|
328
|
-
function giveBody(p: Peer): boolean {
|
|
329
|
-
if (p.seat === null) return false;
|
|
330
|
-
// An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).
|
|
331
|
-
const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
|
|
332
|
-
if (!c) return false;
|
|
333
|
-
syncBodiesFromRoster();
|
|
334
|
-
const b = bodies.get(c.slot.slot);
|
|
335
|
-
// The arriving human takes over the bot's body where it stands, score and all (reset = "adopt this position").
|
|
336
|
-
if (b) { b.vx = 0; b.vy = 0; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; hostMoved(b); }
|
|
337
|
-
return true;
|
|
338
|
-
}
|
|
339
|
-
/**
|
|
340
|
-
* SEAT WHOEVER IS WAITING (NETPLAY.md section 28). The relay seats a person; this host gives them a body. A claim that
|
|
341
|
-
* found every body taken (a server's AI seats were still kept, say) used to be tried once: that player watched for the
|
|
342
|
-
* rest of the visit, online, with nothing said. It is tried again whenever a body frees up and at every round start.
|
|
343
|
-
*/
|
|
344
|
-
function seatWaiting(): boolean {
|
|
345
|
-
let any = false;
|
|
346
|
-
for (const p of net.peers.values()) if (p.seat !== null && !roster.bySeat(p.seat) && giveBody(p)) any = true;
|
|
347
|
-
return any;
|
|
348
|
-
}
|
|
349
|
-
net.on('join', (p) => {
|
|
350
|
-
if (!hosting || !giveBody(p)) return;
|
|
351
|
-
publishRoster();
|
|
352
|
-
net.snapshot(buildSnap(), tick, true);
|
|
353
|
-
});
|
|
354
|
-
net.on('leave', (p) => {
|
|
355
|
-
if (!hosting || p.seat === null) return;
|
|
356
|
-
roster.release(p.seat); // their body stays, driven by a bot
|
|
357
|
-
syncBodiesFromRoster();
|
|
358
|
-
// A body just went to a bot: somebody seated with none takes it now.
|
|
359
|
-
const seated = seatWaiting();
|
|
360
|
-
publishRoster();
|
|
361
|
-
if (seated) net.snapshot(buildSnap(), tick, true);
|
|
362
|
-
});
|
|
363
|
-
// The server's policy changed: its AI seats come (or go between rounds) at once.
|
|
364
|
-
net.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });
|
|
365
|
-
net.on('event', (e) => {
|
|
366
|
-
if (e.k === 'wave') addWave(e.d as { slot: number });
|
|
367
|
-
if (e.k === 'knock') addWave(e.d as { slot: number }, true);
|
|
368
|
-
});
|
|
369
|
-
net.on('round', (r) => { round = hosting ? round : r; });
|
|
370
|
-
net.on('state', (e) => { if (e.k === 'zone' && !hosting) zone = (e.d as Zone | null) ?? null; if (!firstStateAt) firstStateAt = performance.now(); });
|
|
371
|
-
net.on('snapshot', () => { if (!firstSnapAt) firstSnapAt = performance.now(); });
|
|
372
|
-
/** My body's control changed: on a reset, stand where the host put me (a new round, a takeover, a knockback's end). */
|
|
373
|
-
net.on('control', (e) => {
|
|
374
|
-
if (!e.reset) return;
|
|
375
|
-
controlResets += 1;
|
|
376
|
-
const p = (e.snap as Snapshot<Snap>).d.p.find((x) => x[1] === net.seat);
|
|
377
|
-
if (p) { me.x = p[2]; me.y = p[3]; me.vx = 0; me.vy = 0; me.has = true; }
|
|
378
|
-
});
|
|
379
|
-
|
|
96
|
+
room.on('wave', e => effect('wave', e));
|
|
97
|
+
room.on('knock', e => effect('knock', e));
|
|
380
98
|
/* ----------------------------------------------------------------- input */
|
|
381
99
|
// The camera looks across the meadow from its near edge, square to it: screen right is +x and screen down is +y, so
|
|
382
100
|
// keys and the stick move a body exactly as in Gem Rush.
|
|
@@ -416,284 +134,14 @@ function moveVector(): { x: number; y: number } {
|
|
|
416
134
|
if (len > 6) { const m = Math.min(1, len / 56); x += (dx / len) * m; y += (dy / len) * m; }
|
|
417
135
|
}
|
|
418
136
|
const len = Math.hypot(x, y);
|
|
419
|
-
|
|
137
|
+
if (len > 1) { x /= len; y /= len; }
|
|
138
|
+
const c = Math.cos(view3.yaw), s = Math.sin(view3.yaw);
|
|
139
|
+
return { x: x * c + y * s, y: -x * s + y * c };
|
|
420
140
|
}
|
|
421
141
|
|
|
422
142
|
function wave(): void {
|
|
423
|
-
const
|
|
424
|
-
if (seat === null) return;
|
|
425
|
-
if (hosting) {
|
|
426
|
-
const b = [...bodies.values()].find((x) => x.seat === seat);
|
|
427
|
-
if (b) hostWave(b);
|
|
428
|
-
} else net.press('wave');
|
|
143
|
+
const v = moveVector(); room.input({ ax: Math.round(v.x * 127), ay: Math.round(v.y * 127), wave: true });
|
|
429
144
|
}
|
|
430
|
-
/** Host: a wave rings out, and knocks back every body within reach (bots, the host itself, and replicas). */
|
|
431
|
-
function hostWave(from: Body): void {
|
|
432
|
-
addWave({ slot: from.slot }); net.send('wave', { slot: from.slot });
|
|
433
|
-
for (const b of bodies.values()) {
|
|
434
|
-
if (b === from || Math.hypot(b.x - from.x, b.y - from.y) > T.knockRange) continue;
|
|
435
|
-
knock(b, from.x, from.y, from.slot);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
/**
|
|
439
|
-
* Host: knock a body back. The hit LANDS first: for T.hitStopMs the body holds where it was hit (it flashes, squashes
|
|
440
|
-
* and shakes on every screen), then it SLIDES T.knockDistance along the hit, fast at first and easing into the stop
|
|
441
|
-
* (T.knockEase), so it arrives at rest, the same distance at any frame rate, and its owner steers again with no pop.
|
|
442
|
-
* A replica's body is TAKEN for the whole bump (its owner's avatar frames are ignored; the host drives it; the owner
|
|
443
|
-
* draws its body from snapshots), then the helper GIVES it back with a reset, so the owner stands where it ended.
|
|
444
|
-
*/
|
|
445
|
-
function knock(b: Body, fx: number, fy: number, by: number): void {
|
|
446
|
-
let dx = b.x - fx; let dy = b.y - fy;
|
|
447
|
-
const len = Math.hypot(dx, dy);
|
|
448
|
-
if (len < 0.02) { const a = Math.random() * Math.PI * 2; dx = Math.cos(a); dy = Math.sin(a); } else { dx /= len; dy /= len; }
|
|
449
|
-
const at = net.now() + T.hitStopMs;
|
|
450
|
-
const o: Knock & { x: number; y: number } = !b.bot && b.seat !== null && b.seat === mySeat() ? me : b;
|
|
451
|
-
o.kvx = dx; o.kvy = dy; o.kx = o.x; o.ky = o.y; o.kat = at; o.knockUntil = at + T.knockMs;
|
|
452
|
-
if (o === b && !b.bot && b.seat !== null) net.take(b.seat, T.hitStopMs + T.knockMs);
|
|
453
|
-
labKnock(b);
|
|
454
|
-
const d = { slot: b.slot, dx: q(dx, 2), dy: q(dy, 2), by };
|
|
455
|
-
addWave(d, true); net.send('knock', d);
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
/** Where a bumped body is now: held through the hit-stop, then eased out along the hit. Sets its velocity too. */
|
|
459
|
-
function slide(o: Knock & { x: number; y: number; vx: number; vy: number }, now: number, dt: number): void {
|
|
460
|
-
const u = Number.isFinite(o.kat) ? Math.max(0, Math.min(1, (now - o.kat) / Math.max(1, T.knockMs))) : 1;
|
|
461
|
-
const e = 1 - (1 - u) ** Math.max(1, T.knockEase);
|
|
462
|
-
const { x, y } = bound(o.kx + o.kvx * T.knockDistance * e, o.ky + o.kvy * T.knockDistance * e);
|
|
463
|
-
if (u >= 1) { o.vx = 0; o.vy = 0; } else if (dt > 0) { o.vx = (x - o.x) / dt; o.vy = (y - o.y) / dt; }
|
|
464
|
-
o.x = x; o.y = y;
|
|
465
|
-
}
|
|
466
|
-
/** The frame a bump is over: the body lands exactly where its slide ends, at rest (at 12 fps the last step is long). */
|
|
467
|
-
function landed(o: Knock & { x: number; y: number; vx: number; vy: number }): void {
|
|
468
|
-
if (!o.kat) return;
|
|
469
|
-
slide(o, o.kat + T.knockMs, 0);
|
|
470
|
-
o.kat = 0;
|
|
471
|
-
}
|
|
472
|
-
function addWave(d: { slot: number; dx?: number; dy?: number; by?: number }, isKnock = false): void {
|
|
473
|
-
const pos = hosting ? bodies.get(d.slot) : [...drawn.values()].find((x) => x.slot === d.slot);
|
|
474
|
-
// The action, for a watcher on Auto: whoever waved (a person, never a bot).
|
|
475
|
-
const waver = pos && 'bot' in pos ? (pos.bot ? null : pos.seat) : pos && pos.seat >= 0 ? pos.seat : null;
|
|
476
|
-
if (!isKnock) net.spotlight(waver);
|
|
477
|
-
const mineSlot = hosting ? [...bodies.values()].find((x) => x.seat !== null && x.seat === mySeat())?.slot : [...drawn.values()].find((x) => x.seat === net.seat)?.slot;
|
|
478
|
-
const at = mineSlot === d.slot && me.has ? me : pos;
|
|
479
|
-
if (!at) return;
|
|
480
|
-
waves.push({ x: at.x, y: at.y, at: performance.now(), colour: isKnock ? '#ffffff' : PALETTE[d.slot % PALETTE.length] as string, knock: isKnock });
|
|
481
|
-
if (isKnock) knocksSeen += 1; else wavesSeen += 1;
|
|
482
|
-
if (isKnock) bumped(d, at); else pushes.set(d.slot, performance.now());
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
/* ---------------------------------------------------------------- simulate */
|
|
486
|
-
/** The one movement rule, shared by the owner, the host (host movement) and a replica's prediction. */
|
|
487
|
-
function integrate(o: { x: number; y: number; vx: number; vy: number }, mx: number, my: number, dt: number): void {
|
|
488
|
-
mx = Number(mx) || 0; my = Number(my) || 0;
|
|
489
|
-
const len = Math.hypot(mx, my);
|
|
490
|
-
if (len > 1) { mx /= len; my /= len; } // an intent is at most full stick: no speed hack through intents
|
|
491
|
-
const k = Math.min(1, dt * 14);
|
|
492
|
-
o.vx += (mx * SPEED - o.vx) * k;
|
|
493
|
-
o.vy += (my * SPEED - o.vy) * k;
|
|
494
|
-
const p = bound(o.x + o.vx * dt, o.y + o.vy * dt);
|
|
495
|
-
o.x = p.x; o.y = p.y;
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
function stepMe(dt: number): void {
|
|
499
|
-
const seat = mySeat();
|
|
500
|
-
if (seat === null) { me.has = false; return; }
|
|
501
|
-
// Host-driven (host movement, or knocked back on a replica): stepReplica predicts me instead.
|
|
502
|
-
if (!net.owned) return;
|
|
503
|
-
if (me.knockUntil > net.now()) { slide(me, net.now(), dt); return; }
|
|
504
|
-
landed(me);
|
|
505
|
-
const v = moveVector();
|
|
506
|
-
integrate(me, v.x, v.y, dt);
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
/**
|
|
510
|
-
* Replica, host-driven body. My stick moves me locally every frame (instant response), with the same rule the host
|
|
511
|
-
* runs. When a new snapshot lands: start from where the host has me, replay every intent it had not acknowledged,
|
|
512
|
-
* and pull toward that by 30% (or jump, if it is far). The host stays the truth; I never wait for it.
|
|
513
|
-
*/
|
|
514
|
-
let predictedK = -1;
|
|
515
|
-
function predictMe(dt: number): void {
|
|
516
|
-
const v = moveVector();
|
|
517
|
-
integrate(me, v.x, v.y, dt);
|
|
518
|
-
const snap = net.latest();
|
|
519
|
-
if (!snap || snap.k === predictedK) return;
|
|
520
|
-
predictedK = snap.k;
|
|
521
|
-
const p = snap.d.p.find((x) => x[1] === net.seat);
|
|
522
|
-
if (!p) return;
|
|
523
|
-
const pred = { x: p[2], y: p[3], vx: p[5] ?? 0, vy: p[6] ?? 0 };
|
|
524
|
-
for (const f of net.pending()) {
|
|
525
|
-
let left = Math.min(0.25, f.dt / 1000);
|
|
526
|
-
while (left > 0) { const h = Math.min(left, 1 / 60); integrate(pred, f.a[4], f.a[5], h); left -= h; }
|
|
527
|
-
}
|
|
528
|
-
const err = Math.hypot(pred.x - me.x, pred.y - me.y);
|
|
529
|
-
predictionError = err;
|
|
530
|
-
if (!me.has || err > 3) { me.x = pred.x; me.y = pred.y; me.vx = pred.vx; me.vy = pred.vy; }
|
|
531
|
-
else { me.x += (pred.x - me.x) * 0.3; me.y += (pred.y - me.y) * 0.3; }
|
|
532
|
-
me.has = true;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
function stepKnocked(b: Body, dt: number): void { slide(b, net.now(), dt); }
|
|
536
|
-
|
|
537
|
-
/**
|
|
538
|
-
* THE BOTS, AT THE ROOM'S DIAL (NETPLAY.md section 17). A bot re-reads the world only every `reactionMs` (what it
|
|
539
|
-
* last noticed is where it heads, and a miss costs it that long again), aims up to 4 m off at aimNoise 1, leaves
|
|
540
|
-
* the hot zone to the people at positioning 0 and fights for it at 1, and waves at a rival in reach about
|
|
541
|
-
* `aggression x 0.8` times a second. Rookie is beatable by anyone, Maxed by few.
|
|
542
|
-
*/
|
|
543
|
-
const sight = new Map<number, { at: number; tx: number; ty: number; arrived?: boolean }>();
|
|
544
|
-
|
|
545
|
-
function stepBots(dt: number): void {
|
|
546
|
-
const taken = new Set<number>();
|
|
547
|
-
const now = net.now();
|
|
548
|
-
for (const b of bodies.values()) {
|
|
549
|
-
if (!b.bot) continue;
|
|
550
|
-
if (b.knockUntil > now) { stepKnocked(b, dt); continue; }
|
|
551
|
-
landed(b);
|
|
552
|
-
if (lab.stage === 'dummy') { standStill(b, dt); continue; }
|
|
553
|
-
const s: Skill = net.skillOf(b.slot); // Fair when nobody set a dial
|
|
554
|
-
let eye = sight.get(b.slot);
|
|
555
|
-
if (!eye || now - eye.at >= s.reactionMs) { // REACTION TIME
|
|
556
|
-
const g = pickGem(b, taken, s); // POSITIONING
|
|
557
|
-
const miss = 4 * s.aimNoise; // AIM NOISE: up to 4 m off at 1
|
|
558
|
-
eye = { at: now, tx: g ? g.x + (Math.random() - 0.5) * miss : b.tx, ty: g ? g.y + (Math.random() - 0.5) * miss : b.ty };
|
|
559
|
-
sight.set(b.slot, eye);
|
|
560
|
-
if (g) taken.add(g.id);
|
|
561
|
-
}
|
|
562
|
-
b.tx = eye.tx; b.ty = eye.ty;
|
|
563
|
-
const rival = nearestBody(b, T.knockRange); // AGGRESSION: waves at a rival in reach
|
|
564
|
-
if (rival && round?.phase === 'live' && Math.random() < s.aggression * 0.8 * dt) hostWave(b);
|
|
565
|
-
const dx = b.tx - b.x; const dy = b.ty - b.y; const dist = Math.hypot(dx, dy);
|
|
566
|
-
const len = dist || 1;
|
|
567
|
-
// Arrived where it aimed: it stands there, and notices what it missed only a reaction later.
|
|
568
|
-
if (dist < 0.12 && !eye.arrived) { eye.arrived = true; eye.at = now; }
|
|
569
|
-
const speed = dist < 0.12 ? 0 : BOT_SPEED;
|
|
570
|
-
const k = Math.min(1, dt * 5);
|
|
571
|
-
b.vx += ((dx / len) * speed - b.vx) * k;
|
|
572
|
-
b.vy += ((dy / len) * speed - b.vy) * k;
|
|
573
|
-
const p = bound(b.x + b.vx * dt, b.y + b.vy * dt);
|
|
574
|
-
b.x = p.x; b.y = p.y;
|
|
575
|
-
}
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
/** Positioning: 0 leaves the hot zone to the people, 1 fights for it. */
|
|
579
|
-
function pickGem(b: Body, taken: Set<number>, s: Skill): Gem | null {
|
|
580
|
-
let best: Gem | null = null; let bd = Infinity;
|
|
581
|
-
for (const g of gems) {
|
|
582
|
-
if (taken.has(g.id)) continue;
|
|
583
|
-
const hot = zone !== null && Math.hypot(g.x - zone.x, g.y - zone.y) < zone.r;
|
|
584
|
-
const d = Math.hypot(g.x - b.x, g.y - b.y) * (hot ? 1.5 - s.positioning : 1);
|
|
585
|
-
if (d < bd) { bd = d; best = g; }
|
|
586
|
-
}
|
|
587
|
-
return best;
|
|
588
|
-
}
|
|
589
|
-
|
|
590
|
-
/** The nearest other body within `range` (a bot's rival), or null. */
|
|
591
|
-
function nearestBody(b: Body, range: number): Body | null {
|
|
592
|
-
let best: Body | null = null; let bd = range;
|
|
593
|
-
for (const o of bodies.values()) {
|
|
594
|
-
if (o === b) continue;
|
|
595
|
-
const d = Math.hypot(o.x - b.x, o.y - b.y);
|
|
596
|
-
if (d < bd) { bd = d; best = o; }
|
|
597
|
-
}
|
|
598
|
-
return best;
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
function stepHost(dt: number): void {
|
|
602
|
-
tick += 1;
|
|
603
|
-
const seat = mySeat();
|
|
604
|
-
const now0 = net.now();
|
|
605
|
-
for (const b of bodies.values()) {
|
|
606
|
-
if (b.bot) continue;
|
|
607
|
-
if (b.seat === seat) {
|
|
608
|
-
me.has = true;
|
|
609
|
-
b.x = me.x; b.y = me.y; b.vx = me.vx; b.vy = me.vy;
|
|
610
|
-
continue;
|
|
611
|
-
}
|
|
612
|
-
if (b.seat === null) continue;
|
|
613
|
-
const ctl = net.control(b.seat);
|
|
614
|
-
if (ctl.taken || b.knockUntil > now0) { stepKnocked(b, dt); if (net.takePresses(b.seat)['wave']) hostWave(b); continue; } // the host drives the knockback
|
|
615
|
-
if (MOVEMENT === 'host') {
|
|
616
|
-
// Host movement: the seat sends intents; the rules move the body.
|
|
617
|
-
const f = net.inputOf(b.seat);
|
|
618
|
-
integrate(b, f ? Number(f.a?.[4]) : 0, f ? Number(f.a?.[5]) : 0, dt);
|
|
619
|
-
if (net.takePresses(b.seat)['wave']) hostWave(b);
|
|
620
|
-
continue;
|
|
621
|
-
}
|
|
622
|
-
const a = net.avatar(b.seat); // null while taken, or until the owner has adopted the last reset
|
|
623
|
-
if (a && Array.isArray(a)) {
|
|
624
|
-
// Client-owned movement, bounded: to the arena, and to 1.3x top speed per frame. A legitimate avatar catches
|
|
625
|
-
// up within a frame; a teleport crawls, and a claim more than half a second of running away is reset.
|
|
626
|
-
const claim = bound(Number(a[0]) || 0, Number(a[1]) || 0);
|
|
627
|
-
const m = capMove(b, claim, SPEED * 1.3 * dt + 0.12);
|
|
628
|
-
if (m.over > SPEED * 0.5) { net.reset(b.seat); cheatResets += 1; }
|
|
629
|
-
else { b.x = m.x; b.y = m.y; b.vx = Number(a[2]) || 0; b.vy = Number(a[3]) || 0; }
|
|
630
|
-
}
|
|
631
|
-
if (net.takePresses(b.seat)['wave']) hostWave(b);
|
|
632
|
-
}
|
|
633
|
-
stepBots(dt);
|
|
634
|
-
const now = net.now();
|
|
635
|
-
if (round && round.phase === 'live') {
|
|
636
|
-
if (!zone || now >= zone.until) setZone(newZone((zone?.n ?? 0) + 1));
|
|
637
|
-
for (const b of bodies.values()) {
|
|
638
|
-
for (let i = 0; i < gems.length; i += 1) {
|
|
639
|
-
const g = gems[i] as Gem;
|
|
640
|
-
if (Math.hypot(g.x - b.x, g.y - b.y) < R_AV + R_GEM) {
|
|
641
|
-
b.score += zone && Math.hypot(g.x - zone.x, g.y - zone.y) < zone.r ? 2 : 1;
|
|
642
|
-
pickups.set(b.slot, (pickups.get(b.slot) ?? 0) + 1);
|
|
643
|
-
gems[i] = newGem();
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
if (now >= round.endsAt) endRound();
|
|
648
|
-
} else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1, true);
|
|
649
|
-
if (net.snapshotDue()) net.snapshot(buildSnap(), tick);
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
function buildSnap(): Snap {
|
|
653
|
-
const r = round ?? { n: 0, phase: 'live', startedAt: 0, endsAt: 0 };
|
|
654
|
-
return {
|
|
655
|
-
r: [r.n, r.phase === 'over' ? 1 : 0, r.startedAt, r.endsAt],
|
|
656
|
-
p: [...bodies.values()].map((b) => [b.slot, b.seat ?? -1, q(b.x, 2), q(b.y, 2), b.score, q(b.vx, 1), q(b.vy, 1)] as P),
|
|
657
|
-
g: gems.map((g) => [g.id, q(g.x, 2), q(g.y, 2)] as G),
|
|
658
|
-
};
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
function stepReplica(dt: number): void {
|
|
662
|
-
const seat = net.seat;
|
|
663
|
-
if (seat !== null) {
|
|
664
|
-
// Host-driven (host movement, or taken for a knockback): predict from the host's position and my intents.
|
|
665
|
-
if (!net.owned) predictMe(dt);
|
|
666
|
-
// Only once I have a body (the first control event adopts it): before that my position is nobody's.
|
|
667
|
-
if (me.has) {
|
|
668
|
-
const v = moveVector();
|
|
669
|
-
// Stick direction as held "buttons": a new direction is a press edge, so it is sent within 16 ms.
|
|
670
|
-
const held = [v.x > 0.2 ? 'R' : v.x < -0.2 ? 'L' : '', v.y > 0.2 ? 'D' : v.y < -0.2 ? 'U' : ''].filter(Boolean);
|
|
671
|
-
net.input([q(me.x, 2), q(me.y, 2), q(me.vx, 1), q(me.vy, 1), q(v.x, 2), q(v.y, 2)], held);
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
const smp = net.sample();
|
|
675
|
-
drawn.clear();
|
|
676
|
-
if (!smp) return;
|
|
677
|
-
const bySlot = new Map<number, P>(smp.a.d.p.map((p) => [p[0], p]));
|
|
678
|
-
for (const pb of smp.b.d.p) {
|
|
679
|
-
const pa = bySlot.get(pb[0]) ?? pb;
|
|
680
|
-
drawn.set(pb[0], { slot: pb[0], seat: pb[1], x: lerp(pa[2], pb[2], smp.alpha), y: lerp(pa[3], pb[3], smp.alpha), score: pb[4] });
|
|
681
|
-
}
|
|
682
|
-
const [n, ph, startedAt, endsAt] = smp.b.d.r;
|
|
683
|
-
if (!round || round.n !== n || (round.phase === 'over') !== (ph === 1)) {
|
|
684
|
-
const kept = net.roundInfo && net.roundInfo.n === n ? net.roundInfo : null;
|
|
685
|
-
round = kept ?? { n, phase: ph ? 'over' : 'live', startedAt, endsAt };
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
/* -------------------------------------------------------------- how a bump looks */
|
|
690
|
-
/*
|
|
691
|
-
* Every screen draws a bump from the knock event (the host's own, or the one it sends): the hit lands (the animal
|
|
692
|
-
* flashes white and holds, squashed against the hit, shaking), flies (stretched along the hit, less as it slows),
|
|
693
|
-
* stops (squashed, then a wobble that dies away: overlap), throws sparks, and kicks the camera of whoever was in it.
|
|
694
|
-
* The waver's own body puffs up a little when it waves. Sparks and the camera's kick roll lab.random(): their own
|
|
695
|
-
* dice, so the world's (Math.random) stay the same as a build without them, which the Game Lab needs to compare two.
|
|
696
|
-
*/
|
|
697
145
|
const bumps = new Map<number, { at: number; dx: number; dy: number }>();
|
|
698
146
|
const pushes = new Map<number, number>();
|
|
699
147
|
const sparks: { x: number; y: number; h: number; vx: number; vy: number; vh: number; at: number; life: number; gold?: boolean }[] = [];
|
|
@@ -710,7 +158,7 @@ function bumped(d: { slot: number; dx?: number; dy?: number; by?: number }, at:
|
|
|
710
158
|
}
|
|
711
159
|
if (sparks.length > 160) sparks.splice(0, sparks.length - 160);
|
|
712
160
|
// The camera kicks for whoever was in it: the body bumped, or the one who waved.
|
|
713
|
-
const mine =
|
|
161
|
+
const mine = [...drawn.values()].find((x) => x.seat === net.seat)?.slot;
|
|
714
162
|
if (mine !== undefined && (d.slot === mine || d.by === mine)) kickAt = now;
|
|
715
163
|
}
|
|
716
164
|
/** A gem taken: a little gold burst where it was (drawn on every screen from the gem list; the rules never see it). */
|
|
@@ -753,36 +201,8 @@ function bodyFx(slot: number, t: number): { ox: number; oy: number; sx: number;
|
|
|
753
201
|
* labKnock and labReport never run.
|
|
754
202
|
*/
|
|
755
203
|
type Pose = { x: number; y: number; sx?: number; sy?: number; a?: number };
|
|
756
|
-
/**
|
|
757
|
-
* The take's stage "dummy" (lab.stage, only ever set by the lab): you stand left of the middle with a bot a short step
|
|
758
|
-
* to your right, and every bot stands still unless it is bumped, like a training dummy. The knock alone, every time.
|
|
759
|
-
*/
|
|
760
|
-
function stageDummy(): void {
|
|
761
|
-
const seat = mySeat();
|
|
762
|
-
const list = [...bodies.values()].sort((a, c) => a.slot - c.slot);
|
|
763
|
-
const mine = list.find((b) => !b.bot && b.seat === seat);
|
|
764
|
-
const bots = list.filter((b) => b.bot);
|
|
765
|
-
if (mine) { mine.x = W / 2 - 3.2; mine.y = H / 2; hostMoved(mine); }
|
|
766
|
-
bots.forEach((b, i) => { b.x = i === 0 ? W / 2 - 3.2 + 1.84 : W - 3.6; b.y = i === 0 ? H / 2 : 3 + (i - 1) * 14; b.tx = b.x; b.ty = b.y; });
|
|
767
|
-
}
|
|
768
|
-
/** A dummy at rest: it eases to a stop wherever the last bump left it. */
|
|
769
|
-
function standStill(b: Body, dt: number): void {
|
|
770
|
-
const k = Math.min(1, dt * 5);
|
|
771
|
-
b.vx -= b.vx * k; b.vy -= b.vy * k;
|
|
772
|
-
const p = bound(b.x + b.vx * dt, b.y + b.vy * dt); b.x = p.x; b.y = p.y;
|
|
773
|
-
}
|
|
774
204
|
const subject = { slot: -1, at: 0, x0: 0, y0: 0, px: 0, py: 0, has: false };
|
|
775
|
-
function
|
|
776
|
-
if (!lab.on) return;
|
|
777
|
-
subject.slot = b.slot; subject.at = net.now(); subject.x0 = b.x; subject.y0 = b.y; subject.px = b.x; subject.py = b.y; subject.has = true;
|
|
778
|
-
}
|
|
779
|
-
/** Where the subject is now (the host's own body is `me`), or null. */
|
|
780
|
-
function subjectAt(): Pose | null {
|
|
781
|
-
if (!subject.has) return null;
|
|
782
|
-
const b = bodies.get(subject.slot);
|
|
783
|
-
if (!b) return null;
|
|
784
|
-
return !b.bot && b.seat !== null && b.seat === mySeat() ? me : b;
|
|
785
|
-
}
|
|
205
|
+
function subjectAt(): Pose | null { return subject.has ? drawn.get(subject.slot) ?? null : null; }
|
|
786
206
|
function labReport(dt: number): void {
|
|
787
207
|
const at = subjectAt();
|
|
788
208
|
if (!at || dt <= 0) { lab.phase(null); return; }
|
|
@@ -801,7 +221,6 @@ function labReport(dt: number): void {
|
|
|
801
221
|
}
|
|
802
222
|
/** The lab's views: the game's own camera, close on the knock, the whole arena. */
|
|
803
223
|
const labView = lab.camera<{ zoom?: number; whole?: boolean } | null>({ game: null, close: { zoom: 2.4 }, arena: { whole: true } });
|
|
804
|
-
|
|
805
224
|
/* ================================================================== THE LOOK */
|
|
806
225
|
|
|
807
226
|
/* ------------------------------------------------------------------ style.json */
|
|
@@ -1509,8 +928,9 @@ function poseAnimal(a: Animal, at: { x: number; y: number }, t: number, dt: numb
|
|
|
1509
928
|
a.speed += (Math.min(sp, 12) - a.speed) * Math.min(1, dt * 10);
|
|
1510
929
|
a.px = at.x; a.py = at.y;
|
|
1511
930
|
// It faces where it goes, but not while flying from a bump (it is knocked backwards, not turned round).
|
|
1512
|
-
|
|
1513
|
-
|
|
931
|
+
const pose = poses.get(a.slot);
|
|
932
|
+
if (pose) {
|
|
933
|
+
const want = Math.atan2(pose.heading.x, pose.heading.y);
|
|
1514
934
|
let d = want - a.yaw; d = Math.atan2(Math.sin(d), Math.cos(d));
|
|
1515
935
|
a.yaw += d * Math.min(1, dt * 14);
|
|
1516
936
|
}
|
|
@@ -1705,7 +1125,6 @@ let lastDraw = 0;
|
|
|
1705
1125
|
/** Where a seat's body is drawn now (host: the real body; replica: interpolated), or null. */
|
|
1706
1126
|
function seatPos(seat: number): { x: number; y: number } | null {
|
|
1707
1127
|
if (seat === mySeat() && me.has && !net.watching) return me;
|
|
1708
|
-
if (hosting) { const b = [...bodies.values()].find((x) => !x.bot && x.seat === seat); return b ? { x: b.x, y: b.y } : null; }
|
|
1709
1128
|
const d = [...drawn.values()].find((x) => x.seat === seat);
|
|
1710
1129
|
return d ? { x: d.x, y: d.y } : null;
|
|
1711
1130
|
}
|
|
@@ -1752,7 +1171,7 @@ function draw(t: number, dt: number): void {
|
|
|
1752
1171
|
}
|
|
1753
1172
|
|
|
1754
1173
|
// gems: spinning, bobbing, glowing; a taken one bursts into gold where it was
|
|
1755
|
-
const gemList: { x: number; y: number; id: number }[] =
|
|
1174
|
+
const gemList: { x: number; y: number; id: number }[] = gems;
|
|
1756
1175
|
const rn = round?.n ?? -1;
|
|
1757
1176
|
const seenNow = new Set<number>();
|
|
1758
1177
|
let nb = 0;
|
|
@@ -1789,13 +1208,11 @@ function draw(t: number, dt: number): void {
|
|
|
1789
1208
|
for (let i = nw; i < wavePool.length; i += 1) (wavePool[i] as Mesh).visible = false;
|
|
1790
1209
|
|
|
1791
1210
|
// the animals
|
|
1792
|
-
const names = new Map<number, Slot>((net.slots ??
|
|
1211
|
+
const names = new Map<number, Slot>((net.slots ?? []).map((s) => [s.slot, s]));
|
|
1793
1212
|
// `mine`: the body whose view this is: my own, or the player a watcher follows (named, never "You").
|
|
1794
1213
|
const list: { slot: number; seat: number | null; x: number; y: number; bot: boolean; name: string; mine: boolean }[] = [];
|
|
1795
1214
|
const isView = (seat: number | null, bot: boolean): boolean => !bot && seat !== null && seat === view;
|
|
1796
|
-
|
|
1797
|
-
for (const b of bodies.values()) list.push({ slot: b.slot, seat: b.seat, x: b.x, y: b.y, bot: b.bot, name: b.name, mine: isView(b.seat, b.bot) });
|
|
1798
|
-
} else {
|
|
1215
|
+
{
|
|
1799
1216
|
for (const d of drawn.values()) {
|
|
1800
1217
|
const own = !net.watching && d.seat >= 0 && d.seat === net.seat;
|
|
1801
1218
|
const s = names.get(d.slot);
|
|
@@ -1984,8 +1401,7 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; seat:
|
|
|
1984
1401
|
// scores: the play page's room button (and a server's pill beside it) sit at the top right (game.json screen.share's
|
|
1985
1402
|
// default): the scores start under that band, so the buttons never cover a score.
|
|
1986
1403
|
const scores = new Map<number, number>();
|
|
1987
|
-
|
|
1988
|
-
else for (const d of drawn.values()) scores.set(d.slot, d.score);
|
|
1404
|
+
for (const d of drawn.values()) scores.set(d.slot, d.score);
|
|
1989
1405
|
// A phone's board is compact (the top three, names without " · bot": a bot's dot is hollow), so a game's own HUD at
|
|
1990
1406
|
// the top left (a basket, a timer) has room beside it.
|
|
1991
1407
|
const rows = [...list].sort((a, b) => (scores.get(b.slot) ?? 0) - (scores.get(a.slot) ?? 0)).slice(0, phone ? 3 : 6);
|
|
@@ -2073,9 +1489,7 @@ function frame(t: number): void {
|
|
|
2073
1489
|
const dt = lab.time.dt(t, 0.05);
|
|
2074
1490
|
// The look's own clock (the animals' clips): real seconds, at most a tenth, whatever the rules' step.
|
|
2075
1491
|
const ddt = lastT < 0 ? 0 : Math.min(0.1, Math.max(0, (t - lastT) / 1000)); lastT = t;
|
|
2076
|
-
|
|
2077
|
-
if (hosting) stepHost(dt);
|
|
2078
|
-
else stepReplica(dt);
|
|
1492
|
+
updateView();
|
|
2079
1493
|
if (lab.on) labReport(dt);
|
|
2080
1494
|
draw(t, lab.on ? dt : ddt);
|
|
2081
1495
|
frames += 1;
|
|
@@ -2087,24 +1501,16 @@ net.expose({
|
|
|
2087
1501
|
bubbles: () => shownBubbles.map((b) => ({ seat: b.key, text: b.lines.join(' '), alpha: b.alpha, left: Math.round(b.left), top: Math.round(b.top), right: Math.round(b.right), bottom: Math.round(b.bottom) })),
|
|
2088
1502
|
self: () => (me.has ? { x: me.x, y: me.y } : null),
|
|
2089
1503
|
peer: (seat: number) => {
|
|
2090
|
-
if (hosting) { const b = [...bodies.values()].find((x) => x.seat === seat); return b ? { x: b.x, y: b.y } : null; }
|
|
2091
1504
|
const d = [...drawn.values()].find((x) => x.seat === seat);
|
|
2092
1505
|
return d ? { x: d.x, y: d.y } : null;
|
|
2093
1506
|
},
|
|
2094
1507
|
frames: () => frames,
|
|
2095
|
-
scores: () =>
|
|
2096
|
-
|
|
2097
|
-
pickups: () => (hosting ? { round: round ? { n: round.n, phase: round.phase, ageMs: net.now() - round.startedAt } : null, slots: [...bodies.values()].map((b) => ({ slot: b.slot, bot: b.bot, gems: pickups.get(b.slot) ?? 0, level: b.bot ? net.skillOf(b.slot).level : null })) } : null),
|
|
2098
|
-
/** Host: when each bot last looked (its reaction clock). */
|
|
2099
|
-
sight: () => (hosting ? [...sight.entries()].map(([slot, e]) => ({ slot, at: e.at })) : null),
|
|
1508
|
+
scores: () => [...drawn.values()].map(d => ({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, bot: d.seat < 0, score: d.score })),
|
|
1509
|
+
pickups: () => { const slots: unknown[] = []; room.each('runner', e => slots.push({ slot: e.seat, bot: e.driver === 'bot', gems: e.pickups })); return { round, slots }; },
|
|
2100
1510
|
waves: () => wavesSeen,
|
|
2101
1511
|
knocks: () => knocksSeen,
|
|
2102
1512
|
zone: () => zone,
|
|
2103
|
-
|
|
2104
|
-
cheatResets: () => cheatResets,
|
|
2105
|
-
movement: () => MOVEMENT,
|
|
2106
|
-
controlResets: () => controlResets,
|
|
2107
|
-
predictionError: () => predictionError,
|
|
1513
|
+
movement: () => 'server',
|
|
2108
1514
|
/** Where the camera looks (rules metres), how far away it is, its turn, and whose view it is. */
|
|
2109
1515
|
camera: () => ({ x: view3.x, y: view3.y, dist: view3.dist, yaw: view3.yaw, view: viewSeat() }),
|
|
2110
1516
|
/** The names as drawn (boxes, never the text): the e2e probe counts overlaps and checks your own. */
|
|
@@ -2116,31 +1522,17 @@ net.expose({
|
|
|
2116
1522
|
triangles: () => renderer.info.render.triangles,
|
|
2117
1523
|
/** The models: how many loaded, their triangles, texture memory and bytes, and any refused (with why). */
|
|
2118
1524
|
models: () => ({ ...models.stats(), standIns: [...standIns], animals: [...animals.values()].map((a) => ({ slot: a.slot, animal: ANIMALS[a.kind], standIn: a.model?.name === 'stand-in' })) }),
|
|
2119
|
-
|
|
2120
|
-
debugKnock: (seat: number) => {
|
|
2121
|
-
if (!hosting) return false;
|
|
2122
|
-
const b = [...bodies.values()].find((x) => x.seat === seat);
|
|
2123
|
-
if (!b) return false;
|
|
2124
|
-
knock(b, b.x < W / 2 ? b.x - 0.05 : b.x + 0.05, b.y, -1); // push toward the middle, away from the nearer wall (bumped by nobody)
|
|
2125
|
-
return true;
|
|
2126
|
-
},
|
|
1525
|
+
|
|
2127
1526
|
});
|
|
2128
1527
|
|
|
2129
1528
|
exposePort(net, {
|
|
2130
1529
|
view: 'top',
|
|
2131
1530
|
self: () => (me.has && mySeat() !== null ? { x: me.x, y: me.y } : null),
|
|
2132
1531
|
size: R_AV,
|
|
2133
|
-
score: () => { const seat = mySeat(); const b =
|
|
1532
|
+
score: () => { const seat = mySeat(); const b = [...drawn.values()].find((x) => x.seat === seat); return b ? b.score : null; },
|
|
2134
1533
|
// The renderer's own counters, by the names `homie-studio perf` and the playtest read on the port probe (measured
|
|
2135
1534
|
// scene cost while playing; port/probe.ts PortExtra). Nobody dies in this game, so there is no `alive` to say.
|
|
2136
1535
|
extra: { drawCalls: () => renderer.info.render.calls, triangles: () => renderer.info.render.triangles },
|
|
2137
1536
|
});
|
|
2138
1537
|
|
|
2139
|
-
void net.ready.then(() => {
|
|
2140
|
-
const seat = mySeat();
|
|
2141
|
-
if (seat !== null && hosting) {
|
|
2142
|
-
const b = [...bodies.values()].find((x) => x.seat === seat);
|
|
2143
|
-
if (b) { me.x = b.x; me.y = b.y; me.has = true; }
|
|
2144
|
-
}
|
|
2145
|
-
});
|
|
2146
1538
|
requestAnimationFrame(frame);
|