@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,69 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
* HERO RUSH 3D — Gem Rush's arena with ANIMATED HEROES: knights, barbarians, mages and rogues run for the coins, JUMP,
|
|
3
|
-
* and SWING to knock each other's run loose; skeletons fill the empty seats. The start for a 3D game with characters.
|
|
4
|
-
*
|
|
5
|
-
* THE RULES are Gem Rush's (the netplay v1 reference game): move, collect coins, score; 60 s rounds that cycle forever;
|
|
6
|
-
* the round starts the moment the first browser arrives, with two bots, and every human who arrives takes a bot's body.
|
|
7
|
-
* One browser hosts (pickups, scoring, bots, the clock) and broadcasts snapshots; everyone else moves their own hero
|
|
8
|
-
* locally and draws the rest from interpolated snapshots; a promoted host continues the same round. Two verbs on top:
|
|
9
|
-
* JUMP (Space, or the Jump button): a hero in the air is out of a swing's reach. Its height rides in the avatar and
|
|
10
|
-
* the snapshot, so every screen draws the jump, and the host decides what a swing hits by it.
|
|
11
|
-
* SWING (F, a click, or the Swing button): every screen plays the swing at once; the host lands it T.windupMs later
|
|
12
|
-
* (the anticipation), knocking every body in front of the swinger and within reach that is on the ground.
|
|
13
|
-
* The hot zone (keyed state) scores double; a watcher follows any player (`net.viewSeat`, `net.spotlight`); the swing
|
|
14
|
-
* and the jump are tuned in the Game Lab (tunables.json, lab.json). The world is in metres, a 26 x 16 m clearing, x
|
|
15
|
-
* across and y deep (y is three.js's z).
|
|
16
|
-
*
|
|
17
|
-
* THE BOTS never swamp a new player. They play at the room's skill dial (NETPLAY.md section 17; they jump a swing they
|
|
18
|
-
* see coming), with two things on top (THE BOTS, below, says exactly how):
|
|
19
|
-
* - In somebody's first round in the room, when nobody set the dial (no vote, no owner), they play a level easier.
|
|
20
|
-
* - A RUBBER BAND: a bot ahead of the best person in the room by more than its dial allows eases off (it walks,
|
|
21
|
-
* reacts later, swings less) and comes to race near the people, so a rival is on their screen; otherwise it plays
|
|
22
|
-
* at its dial. The lead it may hold grows with the dial; at Maxed there is no rubber band (a party that voted
|
|
23
|
-
* Maxed asked for it).
|
|
24
|
-
* Scores stay honest: nobody's score is ever changed, no coin is given, taken or faked; the bots just play softer.
|
|
25
|
-
* And a bot is always labelled a bot (an AI agent "· AI") wherever its name or score shows, the phone's board too.
|
|
26
|
-
*
|
|
27
|
-
* THE ARRIVAL: the play page shows the game's arrival card (its title, art and a progress line this game feeds with
|
|
28
|
-
* `net.loading`) until the game says `net.playable()`: the round's state is in and your own hero is drawn in its real
|
|
29
|
-
* model (THE ARRIVAL, by the HUD). So the round opens on your hero, with no title card over it. On a phone held
|
|
30
|
-
* upright the camera follows low and close (30 degrees, about 5.5 m across: the camera's comment says why), so every
|
|
31
|
-
* hero reads as a character, a mage too, and your name chip says which you are ("You · Mage").
|
|
32
|
-
*
|
|
33
|
-
* THE CHARACTERS are KayKit's CC0 adventurers and skeletons from the Homie starter library, made phone-sized by `game new`
|
|
34
|
-
* (lib/characters.mjs): each one skinned mesh, one draw call, its joints named by the skeleton standard, and every clip
|
|
35
|
-
* in one shared clip library per skeleton (public/anims/<skeleton>.glb). They move through @homie-rocks/studio/animate:
|
|
36
|
-
* idle, walk and run blended by ground speed, the swing on the upper body while running, an additive flinch and a
|
|
37
|
-
* hit-stop when hit, jump, fall and land with squash and stretch, a lean into turns, a cheer for the round's winner.
|
|
38
|
-
* Every number of that is a Game Lab tunable (the "Motion" group of tunables.json).
|
|
39
|
-
*
|
|
40
|
-
* THE LOOK comes from files beside game.json:
|
|
41
|
-
* style.json the palette, fonts, light and camera (the art direction's tokens: `homie-studio style` writes it);
|
|
42
|
-
* everything the code draws takes its colours from it
|
|
43
|
-
* public/models/ the heroes, the skeletons, the coin, the chest, trees, rocks and barrels: KayKit's CC0 packs from the
|
|
44
|
-
* Homie starter library (one family, so they fit), fetched by `game new` (the repository keeps no model
|
|
45
|
-
* files). assets/manifest.json names each library item and its SHA-256; assets/RIGHTS.md and
|
|
46
|
-
* credits.json say what its licence allows.
|
|
47
|
-
* public/anims/ the clip libraries, baked by `game new` from the characters' own CC0 clips.
|
|
48
|
-
* Every model loads through @homie-rocks/studio/assets (createModels: each file is checked before three.js reads a
|
|
49
|
-
* byte). A model that is refused or not there is drawn as a stand-in in the same colours: a round never waits on, or
|
|
50
|
-
* breaks for, a model.
|
|
51
|
-
*
|
|
52
|
-
* HEADINGS, AND THERE ARE TWO: a body's facing (`fa`, drawn as `yaw`) is `Math.atan2(dx, dy)` in the game's own x and y, so yaw 0 faces +y
|
|
53
|
-
* (three.js's +z, the way the models are built) and it goes straight into `rotation.y`. That is also the yaw
|
|
54
|
-
* `@homie-rocks/camera` means (0 faces +Z, positive turns toward +X, radians), so a follow or chase camera from that
|
|
55
|
-
* package takes a body's facing as it stands. The knock and aim angles (`ang`, `base`) are the OTHER kind,
|
|
56
|
-
* `Math.atan2(dy, dx)`: 0 faces +x. Never hand one of those to a camera or to `rotation.y` without converting it:
|
|
57
|
-
* `headingFrom({ zero: '+x', toward: '+z' })` from `@homie-rocks/camera/heading.js` is the adapter.
|
|
58
|
-
*
|
|
59
|
-
* PHONE BUDGETS: under 100 draw calls and 150k triangles a frame (the `drawCalls` and `triangles` probes read
|
|
60
|
-
* 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
|
|
61
|
-
* disc under each body, coin, tree and rock (a computer draws the sun's real shadows). Repeated things are one
|
|
62
|
-
* InstancedMesh per model; far heroes pose less often (animate's crowd()).
|
|
63
|
-
*
|
|
64
|
-
* The HUD (clock, scores, names, results, "Reconnecting…", the controls hint and the touch buttons) is a 2D canvas over
|
|
65
|
-
* the world, in style.json's fonts and colours.
|
|
66
|
-
*/
|
|
1
|
+
/** Hero Rush 3D: server rules, predicted jumps, and the original meadow and heroes. */
|
|
67
2
|
import {
|
|
68
3
|
AmbientLight, Box3, BoxGeometry, BufferAttribute, BufferGeometry, CanvasTexture, CircleGeometry, Color, ConeGeometry, CylinderGeometry,
|
|
69
4
|
DodecahedronGeometry, IcosahedronGeometry,
|
|
@@ -77,17 +12,20 @@ import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js
|
|
|
77
12
|
import { createModels, instancedCopies, placeCopy, repaint, stylize, type Copies } from '@homie-rocks/studio/assets';
|
|
78
13
|
// Characters that move well: clips from the skeleton's clip library, blended, layered and tuned (Game Lab tunables).
|
|
79
14
|
import { crowd, loadCharacter, type Character } from '@homie-rocks/studio/animate';
|
|
80
|
-
import {
|
|
15
|
+
import { guardGestures, PALETTE, AI_MARK, type RoundInfo, type Slot } from '@homie-rocks/studio/netplay';
|
|
81
16
|
// The port toolkit: its probe (what `homie-studio port check` and `perf` read, and sandbox + audio shims), and name
|
|
82
17
|
// labels that never pile up (port/view.ts).
|
|
83
18
|
import { createBubbles, createLabels, exposePort, paintBubbles, type BubbleIn, type BubbleOut, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
|
|
84
19
|
// The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
|
|
85
20
|
import { lab } from '@homie-rocks/studio/lab';
|
|
86
|
-
import
|
|
21
|
+
import { openRoom, type Entity } from '@homie-rocks/studio/rules/view';
|
|
22
|
+
import type rules from './rules';
|
|
23
|
+
const room = openRoom<typeof rules>({ net: { game: 'hero-rush-3d', arrival: 'game' } });
|
|
24
|
+
const net = room.net;
|
|
25
|
+
guardGestures({ touch: 'canvas' });
|
|
87
26
|
import styleFile from '../style.json';
|
|
88
27
|
import gameFile from '../game.json';
|
|
89
28
|
|
|
90
|
-
/* ------------------------------------------------------------------ rules */
|
|
91
29
|
const W = 26;
|
|
92
30
|
const H = 16;
|
|
93
31
|
const R_AV = 0.44;
|
|
@@ -101,39 +39,16 @@ const OBSTACLES: readonly { x: number; y: number; r: number; kind: 'camp' | 'bus
|
|
|
101
39
|
{ 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' },
|
|
102
40
|
{ 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' },
|
|
103
41
|
];
|
|
104
|
-
/** A position kept in the clearing and out of its features (pushed to their edge, so a body slides round them). */
|
|
105
|
-
function bound(x: number, y: number): { x: number; y: number } {
|
|
106
|
-
x = Math.max(R_AV, Math.min(W - R_AV, x)); y = Math.max(R_AV, Math.min(H - R_AV, y));
|
|
107
|
-
for (const o of OBSTACLES) {
|
|
108
|
-
const dx = x - o.x; const dy = y - o.y; const d = Math.hypot(dx, dy); const min = o.r + R_AV;
|
|
109
|
-
if (d >= min) continue;
|
|
110
|
-
if (d > 1e-6) { x = o.x + (dx / d) * min; y = o.y + (dy / d) * min; } else x = o.x + min;
|
|
111
|
-
}
|
|
112
|
-
return { x, y };
|
|
113
|
-
}
|
|
114
42
|
const R_GEM = 0.3;
|
|
115
43
|
const SPEED = 6.2;
|
|
116
|
-
const BOT_SPEED =
|
|
117
|
-
/** A hero this high or higher is in the air: a swing passes under it. */
|
|
118
|
-
const CLEAR_M = 0.3;
|
|
44
|
+
const BOT_SPEED = 5;
|
|
119
45
|
const ROUND_MS = 60_000;
|
|
120
46
|
const BREAK_MS = 7_000;
|
|
121
47
|
const GEM_COUNT = 14;
|
|
122
48
|
const MIN_SLOTS = 3; // 1 human + 2 bots from the first frame
|
|
123
49
|
const MAX_SLOTS = 8;
|
|
124
|
-
/** The
|
|
125
|
-
const T =
|
|
126
|
-
/** The jump as a curve of time: up at the speed that reaches T.jumpHeight in T.jumpRise seconds, down T.fallFaster harder. */
|
|
127
|
-
const jumpGravity = (): number => (2 * T.jumpHeight) / Math.max(0.05, T.jumpRise) ** 2;
|
|
128
|
-
const jumpSpeed = (): number => jumpGravity() * Math.max(0.05, T.jumpRise);
|
|
129
|
-
/** One jump step for anything with a height (h, metres up) and a vertical speed (vh): the same rule on every screen. */
|
|
130
|
-
function fall(o: { h: number; vh: number }, dt: number): boolean {
|
|
131
|
-
if (o.h <= 0 && o.vh <= 0) { o.h = 0; o.vh = 0; return false; }
|
|
132
|
-
const g = jumpGravity() * (o.vh < 0 ? T.fallFaster : 1);
|
|
133
|
-
o.vh -= g * dt; o.h += o.vh * dt;
|
|
134
|
-
if (o.h <= 0) { o.h = 0; o.vh = 0; return true; } // landed this step
|
|
135
|
-
return false;
|
|
136
|
-
}
|
|
50
|
+
/** The knock's numbers (tunables.json): the file's values, or the Game Lab's sliders while it plays a take. */
|
|
51
|
+
const T = room.tune as Record<string, number>;
|
|
137
52
|
const ZONE_MS = 12_000;
|
|
138
53
|
/** The contract's 12 colours (PALETTE, NETPLAY.md section 3): a person wears their seat's, so the watch page's strip matches. */
|
|
139
54
|
const colourOf = (slot: number, seat: number | null): string => PALETTE[(seat ?? slot) % PALETTE.length] as string;
|
|
@@ -142,306 +57,49 @@ const botName = (slot: number): string => BOT_NAMES[slot % BOT_NAMES.length] as
|
|
|
142
57
|
/** An AI's name already ends in " · AI" (the relay sees to it); a plain bot says bot. */
|
|
143
58
|
const label = (name: string, bot: boolean): string => (name.endsWith(AI_MARK) ? name : bot ? `${name} · bot` : name);
|
|
144
59
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Replica input: the avatar (owner movement) AND the stick intent (host movement), so either mode reads the same
|
|
152
|
-
* frame. The helper stamps the reset epoch the replica has adopted.
|
|
153
|
-
*/
|
|
154
|
-
type Avatar = [x: number, y: number, vx: number, vy: number, mx: number, my: number, h: number];
|
|
155
|
-
/** A body's bump, while it lasts: its direction (kvx, kvy), where its slide starts (kx, ky) and when (kat, after the hit-stop). */
|
|
156
|
-
interface Knock { kvx: number; kvy: number; kx: number; ky: number; kat: number; knockUntil: number }
|
|
157
|
-
/** A body: where it is, its height off the ground (h) and vertical speed (vh), and which way it faces (fa, radians). */
|
|
158
|
-
interface Body extends Knock { slot: number; seat: number | null; name: string; bot: boolean; x: number; y: number; vx: number; vy: number; h: number; vh: number; fa: number; score: number; tx: number; ty: number }
|
|
159
|
-
interface Gem { id: number; x: number; y: number }
|
|
160
|
-
/** Slow state, on the keyed state channel (net.state('zone', ...)), not in the 20 Hz snapshot. */
|
|
161
|
-
interface Zone { n: number; x: number; y: number; r: number; until: number }
|
|
162
|
-
/** `firstRound`: each person's first whole round here (seat, round), so a promoted host eases the same round. */
|
|
163
|
-
interface Ckpt { round: RoundInfo; bodies: Body[]; gems: Gem[]; roster: Slot[]; tick: number; gemSeq: number; occ?: [number, number][]; firstRound?: [number, number][] }
|
|
164
|
-
|
|
165
|
-
/* --------------------------------------------------------------- the net */
|
|
166
|
-
/**
|
|
167
|
-
* `?movement=host` plays host movement: the host moves every body from the seats' stick intents (its rules own
|
|
168
|
-
* movement), and each replica PREDICTS its own body by replaying its unacknowledged intents on the host's position.
|
|
169
|
-
* Default `owner`: each browser moves its own body and the host bounds it.
|
|
170
|
-
*/
|
|
171
|
-
const MOVEMENT: 'owner' | 'host' = (() => { try { return new URLSearchParams(location.search).get('movement') === 'host' ? 'host' : 'owner'; } catch { return 'owner'; } })();
|
|
172
|
-
// caps: its bots read the dial ('skill'), and its Roster takes an AI's slot for it ('agents': the join passes p.agent).
|
|
173
|
-
// arrival 'game': the play page's arrival card stays until this game says it is playable (THE ARRIVAL, by the HUD).
|
|
174
|
-
const net = createNetplay<Snap, Avatar, Ckpt>({ game: 'hero-rush-3d', maxPlayers: MAX_SLOTS, movement: MOVEMENT, snapshotHz: 20, inputHz: 20, checkpointMs: 1000, caps: ['skill', 'agents'], arrival: 'game', checkpoint: () => checkpoint() });
|
|
175
|
-
// A touch game guards its own page (NETPLAY.md section 24): a long press on the canvas never selects text or raises
|
|
176
|
-
// the copy/paste callout on a phone, and a touch on it never pans or zooms the page.
|
|
177
|
-
guardGestures({ touch: 'canvas' });
|
|
178
|
-
/** The Roster keeps the server's AI seats (revision 6): the policy is read whenever it fills. */
|
|
179
|
-
const policy = () => net.policy;
|
|
180
|
-
|
|
181
|
-
/* ------------------------------------------------------------ host state */
|
|
182
|
-
let roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
|
|
183
|
-
let bodies = new Map<number, Body>();
|
|
184
|
-
let gems: Gem[] = [];
|
|
185
|
-
let gemSeq = 0;
|
|
186
|
-
let round: RoundInfo | null = null;
|
|
187
|
-
let zone: Zone | null = null;
|
|
188
|
-
let tick = 0;
|
|
189
|
-
let hosting = false;
|
|
190
|
-
let cheatResets = 0;
|
|
191
|
-
let controlResets = 0;
|
|
192
|
-
let predictionError = 0;
|
|
193
|
-
/** Gems each slot picked up this round (a gem, whatever it scored): the dial's numbers (`pickups` probe). */
|
|
194
|
-
let pickups = new Map<number, number>();
|
|
195
|
-
|
|
196
|
-
/* ------------------------------------------------ this browser's avatar */
|
|
197
|
-
const me = { x: W / 2, y: H / 2, vx: 0, vy: 0, h: 0, vh: 0, kvx: 0, kvy: 0, kx: 0, ky: 0, kat: 0, knockUntil: 0, has: false };
|
|
198
|
-
/** Offline (no shell) plays as a local seat 0, with keys or touch. */
|
|
199
|
-
const mySeat = (): number | null => (net.offline ? 0 : net.seat);
|
|
200
|
-
/** Whose view to draw: my own seat, or for a watcher the player it follows (null: the whole arena). */
|
|
201
|
-
const viewSeat = (): number | null => (net.offline ? 0 : net.viewSeat);
|
|
202
|
-
|
|
203
|
-
/* ------------------------------------------------------- replica view */
|
|
60
|
+
const jumpSpeed = (): number => 2 * T.jumpHeight / T.jumpRise;
|
|
61
|
+
const mySeat = (): number | null => room.seat;
|
|
62
|
+
const viewSeat = (): number | null => net.viewSeat;
|
|
63
|
+
const me = { x: 13, y: 8, h: 0, vh: 0, has: false };
|
|
64
|
+
const poses = new Map<number, Entity>();
|
|
204
65
|
const drawn = new Map<number, { x: number; y: number; h: number; seat: number; score: number; slot: number }>();
|
|
205
66
|
const waves: { x: number; y: number; at: number; colour: string; knock?: boolean; fa?: number; slot?: number }[] = [];
|
|
206
|
-
let wavesSeen = 0;
|
|
207
|
-
let
|
|
208
|
-
let
|
|
209
|
-
let
|
|
210
|
-
let
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
};
|
|
219
|
-
const
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
if (
|
|
236
|
-
b.seat = s.seat; b.name = s.name; b.bot = s.bot;
|
|
237
|
-
}
|
|
238
|
-
for (const k of [...bodies.keys()]) if (!seen.has(k)) bodies.delete(k);
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function publishRoster(): void { net.roster(roster.toJSON()); }
|
|
242
|
-
|
|
243
|
-
/** The host moved body `b` itself: tell its owner (a replica adopts it; the host's own body is `me`). */
|
|
244
|
-
function hostMoved(b: Body): void {
|
|
245
|
-
if (b.bot || b.seat === null) return;
|
|
246
|
-
if (b.seat === mySeat()) { me.x = b.x; me.y = b.y; me.vx = 0; me.vy = 0; me.h = 0; me.vh = 0; me.has = true; return; }
|
|
247
|
-
net.reset(b.seat);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function newZone(n: number): Zone {
|
|
251
|
-
return { n, x: rnd(4.4, W - 4.4), y: rnd(4, H - 4), r: 3, until: net.now() + ZONE_MS };
|
|
252
|
-
}
|
|
253
|
-
function setZone(z: Zone): void { zone = z; net.state('zone', z); }
|
|
254
|
-
|
|
255
|
-
/** A round starts with every hero on its spawn ring facing the camera (fa 0): a line-up the hero intro opens on. */
|
|
256
|
-
function startRound(n: number, rollover = false): void {
|
|
257
|
-
const now = net.now();
|
|
258
|
-
roster.trim();
|
|
259
|
-
// The next round of a running room: the trim is what makes room (the seats a server no longer keeps for AI go
|
|
260
|
-
// here), so whoever was seated with no body is given one now, before the bodies are placed.
|
|
261
|
-
if (rollover && !net.offline) seatWaiting();
|
|
262
|
-
syncBodiesFromRoster();
|
|
263
|
-
let i = 0;
|
|
264
|
-
for (const b of [...bodies.values()].sort((a, c) => a.slot - c.slot)) {
|
|
265
|
-
const sp = spawnPoint(i++);
|
|
266
|
-
b.x = sp.x; b.y = sp.y; b.vx = 0; b.vy = 0; b.h = 0; b.vh = 0; b.fa = 0; b.score = 0; b.tx = sp.x; b.ty = sp.y; b.kvx = 0; b.kvy = 0; b.knockUntil = 0;
|
|
267
|
-
hostMoved(b);
|
|
268
|
-
}
|
|
269
|
-
gems = Array.from({ length: GEM_COUNT }, newGem);
|
|
270
|
-
pickups = new Map();
|
|
271
|
-
sight.clear();
|
|
272
|
-
swings.length = 0;
|
|
273
|
-
round = { n, phase: 'live', startedAt: now, endsAt: now + ROUND_MS };
|
|
274
|
-
if (lab.stage === 'dummy' || lab.stage === 'jump') stageDummy();
|
|
275
|
-
setZone(newZone((zone?.n ?? 0) + 1));
|
|
276
|
-
net.round(round);
|
|
277
|
-
publishRoster();
|
|
278
|
-
net.snapshot(buildSnap(), tick, true);
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
function endRound(): void {
|
|
282
|
-
if (!round) return;
|
|
283
|
-
const now = net.now();
|
|
284
|
-
const ranked = [...bodies.values()].sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot) || a.slot - b.slot);
|
|
285
|
-
const agentSlot = (slot: number): boolean => Boolean(roster.slots.find((x) => x.slot === slot)?.agent);
|
|
286
|
-
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 } : {}) }));
|
|
287
|
-
round = { n: round.n, phase: 'over', startedAt: now, endsAt: now + BREAK_MS, results };
|
|
288
|
-
net.round(round);
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/* ---------------------------------------------------- becoming the host */
|
|
292
|
-
function becomeHost(e: RoleChange<Snap, Ckpt>): void {
|
|
293
|
-
hosting = true;
|
|
294
|
-
if (e.promoted) restore(e);
|
|
295
|
-
else {
|
|
296
|
-
roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
|
|
297
|
-
bodies = new Map();
|
|
298
|
-
const seat = mySeat();
|
|
299
|
-
if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
|
|
300
|
-
syncBodiesFromRoster();
|
|
301
|
-
firstRound = new Map();
|
|
302
|
-
startRound((e.round?.n ?? 0) + 1);
|
|
303
|
-
welcome(seat);
|
|
304
|
-
}
|
|
305
|
-
// Whoever is connected now is who plays: seats that left during the gap become bots.
|
|
306
|
-
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 }));
|
|
307
|
-
// `occ` (NETPLAY.md section 25): which stay in its seat each peer's is, so a seat number that changed hands while
|
|
308
|
-
// nobody was hosting is claimed afresh instead of inheriting the last player's body in silence.
|
|
309
|
-
const { claimed } = roster.reconcile(peers);
|
|
310
|
-
syncBodiesFromRoster();
|
|
311
|
-
for (const s of claimed) welcome(s.seat);
|
|
312
|
-
for (const s of claimed) { const b = bodies.get(s.slot); if (b && b.seat !== mySeat()) hostMoved(b); }
|
|
313
|
-
// My own body is wherever my local avatar is: it was client-owned a moment ago and still is.
|
|
314
|
-
const mine = mySeat() !== null ? [...bodies.values()].find((b) => b.seat === mySeat()) : undefined;
|
|
315
|
-
if (mine && me.has) { mine.x = me.x; mine.y = me.y; }
|
|
316
|
-
else if (mine) { me.x = mine.x; me.y = mine.y; me.has = true; }
|
|
317
|
-
// The hot zone is keyed state: the relay handed it over with the role.
|
|
318
|
-
zone = net.stateOf<Zone>('zone') ?? zone;
|
|
319
|
-
if (!zone && round) setZone(newZone(1));
|
|
320
|
-
publishRoster();
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function restore(e: RoleChange<Snap, Ckpt>): void {
|
|
324
|
-
const ck = e.ckpt?.d ?? null;
|
|
325
|
-
if (ck) {
|
|
326
|
-
roster = Roster.from(ck.roster, { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy }, ck.occ ?? null);
|
|
327
|
-
bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
|
|
328
|
-
gems = ck.gems.map((g) => ({ ...g }));
|
|
329
|
-
gemSeq = ck.gemSeq;
|
|
330
|
-
round = ck.round;
|
|
331
|
-
tick = ck.tick;
|
|
332
|
-
firstRound = new Map(Array.isArray(ck.firstRound) ? ck.firstRound : []);
|
|
333
|
-
} else {
|
|
334
|
-
roster = Roster.from(e.roster ?? [], { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
|
|
335
|
-
bodies = new Map();
|
|
336
|
-
syncBodiesFromRoster();
|
|
337
|
-
}
|
|
338
|
-
// The snapshot is newer than the checkpoint (20 Hz vs 1 Hz): positions, scores and gems from it.
|
|
339
|
-
const s = e.snap;
|
|
340
|
-
if (s && (!e.ckpt || s.st >= e.ckpt.st)) {
|
|
341
|
-
for (const [slot, seat, x, y, score, , , h] of s.d.p) {
|
|
342
|
-
let b = bodies.get(slot);
|
|
343
|
-
if (!b) {
|
|
344
|
-
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 };
|
|
345
|
-
b = bodyFor(rs0);
|
|
346
|
-
bodies.set(slot, b);
|
|
347
|
-
}
|
|
348
|
-
b.x = x; b.y = y; b.h = Number(h) || 0; b.score = score; b.tx = x; b.ty = y;
|
|
349
|
-
}
|
|
350
|
-
gems = s.d.g.map(([id, x, y]) => ({ id, x, y }));
|
|
351
|
-
gemSeq = Math.max(gemSeq, ...gems.map((g) => g.id));
|
|
352
|
-
const [n, ph, startedAt, endsAt] = s.d.r;
|
|
353
|
-
round = { n, phase: ph ? 'over' : 'live', startedAt, endsAt, ...(round?.n === n && round.results ? { results: round.results } : {}) };
|
|
354
|
-
tick = Math.max(tick, s.k);
|
|
67
|
+
let wavesSeen = 0, knocksSeen = 0, frames = 0, jumpsSeen = 0, dodgesSeen = 0;
|
|
68
|
+
let firstStateAt = 0, firstSnapAt = 0;
|
|
69
|
+
let gems: { x: number; y: number; id: number }[] = [];
|
|
70
|
+
let round: RoundInfo | null = null;
|
|
71
|
+
let zone: { x: number; y: number; r: number } | null = null;
|
|
72
|
+
const slotOf = (e: Entity): number => e.kind === 'runner' ? e.seat ?? 0 : 0;
|
|
73
|
+
function updateView(): void {
|
|
74
|
+
const v = moveVector(); room.input({ ax: Math.round(v.x * 127), ay: Math.round(v.y * 127) });
|
|
75
|
+
const own = room.me; me.has = Boolean(own);
|
|
76
|
+
if (own) { me.x = own.pos.x; me.y = own.pos.y; me.h = own.pos.z; me.vh = own.vel.z; }
|
|
77
|
+
drawn.clear(); poses.clear(); room.each('runner', e => poses.set(slotOf(e), e));
|
|
78
|
+
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, h: e.pos.z, score: Number(e.score) }));
|
|
79
|
+
gems = []; room.each('gem', e => gems.push({ id: Number(e.serial), x: e.pos.x, y: e.pos.y }));
|
|
80
|
+
const z = room.shared.zone as { x: number; y: number } | undefined;
|
|
81
|
+
zone = z ? { x: z.x, y: z.y, r: 3 } : null;
|
|
82
|
+
if (own && !firstSnapAt) firstSnapAt = performance.now();
|
|
83
|
+
if (z && !firstStateAt) firstStateAt = performance.now();
|
|
84
|
+
const r = room.round;
|
|
85
|
+
round = r ? { ...r, startedAt: 0, endsAt: net.now() + r.secondsLeft * 1000 } : null;
|
|
86
|
+
}
|
|
87
|
+
function effect(name: 'swing' | 'knock', e: { id?: string; at?: { x: number; y: number } | null; dir?: { x: number; y: number }; by?: string }): void {
|
|
88
|
+
const body = e.id ? room.get(e.id) : null;
|
|
89
|
+
if (!body || body.kind !== 'runner' || !e.at) return;
|
|
90
|
+
const slot = slotOf(body), at = { x: body.pos.x, y: body.pos.y }, now = performance.now();
|
|
91
|
+
waves.push({ ...at, at: now, colour: name === 'knock' ? '#ffffff' : colourOf(slot, body.seat ?? null), knock: name === 'knock', slot, fa: e.dir ? Math.atan2(e.dir.x, e.dir.y) : 0 });
|
|
92
|
+
if (name === 'swing') { net.spotlight(body.driver === 'person' ? body.seat ?? null : null); wavesSeen++; swung(slot); }
|
|
93
|
+
else if (e.dir) {
|
|
94
|
+
const by = e.by ? room.get(e.by) : null;
|
|
95
|
+
knocksSeen++; bumped({ slot, dx: e.dir.x, dy: e.dir.y, by: by?.kind === 'runner' ? by.seat : undefined }, at);
|
|
96
|
+
if (lab.on) Object.assign(subject, { slot, at: net.now(), x0: at.x, y0: at.y, px: at.x, py: at.y, has: true });
|
|
355
97
|
}
|
|
356
|
-
// A round message the relay kept is at least as authoritative as the checkpoint's copy.
|
|
357
|
-
if (e.round && (!round || e.round.n > round.n || (e.round.n === round.n && e.round.phase === 'over' && round.phase === 'live'))) round = e.round;
|
|
358
|
-
// Anyone already playing whose first round the checkpoint does not say is no newcomer: the dial stays exact for them.
|
|
359
|
-
for (const r of roster.slots) if (!r.bot && r.seat !== null && !firstRound.has(r.seat)) firstRound.set(r.seat, 0);
|
|
360
|
-
if (!round) { startRound(1); return; }
|
|
361
|
-
net.round(round);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
function checkpoint(): Ckpt {
|
|
365
|
-
return {
|
|
366
|
-
round: round ?? { n: 0, phase: 'live', startedAt: 0, endsAt: 0 },
|
|
367
|
-
bodies: [...bodies.values()].map((b) => ({ ...b })),
|
|
368
|
-
gems: gems.map((g) => ({ ...g })),
|
|
369
|
-
roster: roster.toJSON(),
|
|
370
|
-
// Whose stay each body is, beside the roster: how the next host tells a player who came back from a new one.
|
|
371
|
-
occ: roster.occupants(),
|
|
372
|
-
tick,
|
|
373
|
-
gemSeq,
|
|
374
|
-
firstRound: [...firstRound],
|
|
375
|
-
};
|
|
376
98
|
}
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
});
|
|
382
|
-
/** Host: a body for somebody the relay seated. False when every body is somebody's (they wait: see seatWaiting). */
|
|
383
|
-
function giveBody(p: Peer): boolean {
|
|
384
|
-
if (p.seat === null) return false;
|
|
385
|
-
// An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).
|
|
386
|
-
const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null, p.occ ?? null);
|
|
387
|
-
if (!c) return false;
|
|
388
|
-
welcome(p.seat);
|
|
389
|
-
syncBodiesFromRoster();
|
|
390
|
-
const b = bodies.get(c.slot.slot);
|
|
391
|
-
// The arriving human takes over the bot's body where it stands, score and all (reset = "adopt this position").
|
|
392
|
-
if (b) { b.vx = 0; b.vy = 0; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; hostMoved(b); }
|
|
393
|
-
return true;
|
|
394
|
-
}
|
|
395
|
-
/**
|
|
396
|
-
* SEAT WHOEVER IS WAITING (NETPLAY.md section 28). The relay seats a person; this host gives them a body. A claim that
|
|
397
|
-
* found every body taken (a server's AI seats were still kept, say) used to be tried once: that player watched for the
|
|
398
|
-
* rest of the visit, online, with nothing said. It is tried again whenever a body frees up and at every round start.
|
|
399
|
-
*/
|
|
400
|
-
function seatWaiting(): boolean {
|
|
401
|
-
let any = false;
|
|
402
|
-
for (const p of net.peers.values()) if (p.seat !== null && !roster.bySeat(p.seat) && giveBody(p)) any = true;
|
|
403
|
-
return any;
|
|
404
|
-
}
|
|
405
|
-
net.on('join', (p) => {
|
|
406
|
-
if (!hosting || !giveBody(p)) return;
|
|
407
|
-
publishRoster();
|
|
408
|
-
net.snapshot(buildSnap(), tick, true);
|
|
409
|
-
});
|
|
410
|
-
net.on('leave', (p) => {
|
|
411
|
-
if (!hosting || p.seat === null) return;
|
|
412
|
-
roster.release(p.seat); // their body stays, driven by a bot
|
|
413
|
-
firstRound.delete(p.seat); // whoever sits in this seat next is new here
|
|
414
|
-
syncBodiesFromRoster();
|
|
415
|
-
// A body just went to a bot: somebody seated with none takes it now.
|
|
416
|
-
const seated = seatWaiting();
|
|
417
|
-
publishRoster();
|
|
418
|
-
if (seated) net.snapshot(buildSnap(), tick, true);
|
|
419
|
-
});
|
|
420
|
-
// The server's policy changed: its AI seats come (or go between rounds) at once.
|
|
421
|
-
net.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });
|
|
422
|
-
net.on('event', (e) => {
|
|
423
|
-
if (e.k === 'swing') addWave(e.d as { slot: number; fa?: number });
|
|
424
|
-
if (e.k === 'knock') addWave(e.d as { slot: number }, true);
|
|
425
|
-
if (e.k === 'jump') {
|
|
426
|
-
const slot = Number((e.d as { slot?: number })?.slot);
|
|
427
|
-
// A player's jump comes to the host from its own screen (its height is its own): the host passes it on to everyone,
|
|
428
|
-
// only for that player's own body.
|
|
429
|
-
if (hosting && e.from !== null) { const b = bodies.get(slot); if (!b || b.bot || b.seat !== e.from) return; net.send('jump', { slot }); }
|
|
430
|
-
jumped(slot);
|
|
431
|
-
}
|
|
432
|
-
if (e.k === 'dodge') dodged((e.d as { slot: number }).slot);
|
|
433
|
-
});
|
|
434
|
-
net.on('round', (r) => { round = hosting ? round : r; });
|
|
435
|
-
net.on('state', (e) => { if (e.k === 'zone' && !hosting) zone = (e.d as Zone | null) ?? null; if (!firstStateAt) firstStateAt = performance.now(); });
|
|
436
|
-
net.on('snapshot', () => { if (!firstSnapAt) firstSnapAt = performance.now(); });
|
|
437
|
-
/** My body's control changed: on a reset, stand where the host put me (a new round, a takeover, a knockback's end). */
|
|
438
|
-
net.on('control', (e) => {
|
|
439
|
-
if (!e.reset) return;
|
|
440
|
-
controlResets += 1;
|
|
441
|
-
const p = (e.snap as Snapshot<Snap>).d.p.find((x) => x[1] === net.seat);
|
|
442
|
-
if (p) { me.x = p[2]; me.y = p[3]; me.vx = 0; me.vy = 0; me.h = Number(p[7]) || 0; me.vh = 0; me.has = true; }
|
|
443
|
-
});
|
|
444
|
-
|
|
99
|
+
room.on('swing', e => effect('swing', e));
|
|
100
|
+
room.on('knock', e => effect('knock', e));
|
|
101
|
+
room.on('jump', e => { const b = e.id ? room.get(e.id) : null; if (b) jumped(slotOf(b)); });
|
|
102
|
+
room.on('dodge', e => { const b = e.id ? room.get(e.id) : null; if (b) dodged(slotOf(b)); });
|
|
445
103
|
/* ----------------------------------------------------------------- input */
|
|
446
104
|
// The camera looks across the meadow from its near edge, square to it: screen right is +x and screen down is +y, so
|
|
447
105
|
// keys and the stick move a body exactly as in Gem Rush. Space jumps; F (or J, Enter, a click) swings. On a phone the
|
|
@@ -491,483 +149,13 @@ function moveVector(): { x: number; y: number } {
|
|
|
491
149
|
if (len > 6) { const m = Math.min(1, len / 56); x += (dx / len) * m; y += (dy / len) * m; }
|
|
492
150
|
}
|
|
493
151
|
const len = Math.hypot(x, y);
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
/** My jump: my body's height is mine (it rides in my avatar), so it starts here at once; every screen is told. */
|
|
498
|
-
function jump(): void {
|
|
499
|
-
const seat = mySeat();
|
|
500
|
-
if (seat === null || !me.has || me.h > 0 || me.knockUntil > net.now()) return;
|
|
501
|
-
me.vh = jumpSpeed(); me.h = 1e-3;
|
|
502
|
-
const mine = hosting ? [...bodies.values()].find((x) => x.seat === seat) : [...drawn.values()].find((x) => x.seat === seat);
|
|
503
|
-
if (mine) { jumped(mine.slot); net.send('jump', { slot: mine.slot }); }
|
|
504
|
-
if (!hosting && MOVEMENT === 'host') net.press('jump');
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/** My swing: the host lands it; every screen sees it start at once. */
|
|
508
|
-
function swing(): void {
|
|
509
|
-
const seat = mySeat();
|
|
510
|
-
if (seat === null) return;
|
|
511
|
-
if (hosting) {
|
|
512
|
-
const b = [...bodies.values()].find((x) => x.seat === seat);
|
|
513
|
-
if (b) startSwing(b);
|
|
514
|
-
} else net.press('swing');
|
|
515
|
-
}
|
|
516
|
-
/**
|
|
517
|
-
* Host: a swing starts. Every screen plays it now (the swing event); it LANDS T.windupMs later (the anticipation the
|
|
518
|
-
* Game Lab tunes): every other body within T.knockRange, in front of the swinger (within T.swingArc degrees of where it
|
|
519
|
-
* faces) and on the ground then, is knocked back. A body in the air is missed: it dodged.
|
|
520
|
-
*/
|
|
521
|
-
const swings: { slot: number; at: number }[] = [];
|
|
522
|
-
const swingReady = new Map<number, number>();
|
|
523
|
-
function startSwing(from: Body): void {
|
|
524
|
-
const now = net.now();
|
|
525
|
-
if ((swingReady.get(from.slot) ?? 0) > now || from.knockUntil > now) return;
|
|
526
|
-
swingReady.set(from.slot, now + T.windupMs + T.swingRestMs);
|
|
527
|
-
const d = { slot: from.slot, fa: q(from.fa, 2) };
|
|
528
|
-
addWave(d); net.send('swing', d);
|
|
529
|
-
swings.push({ slot: from.slot, at: now + T.windupMs });
|
|
530
|
-
botsSee(from);
|
|
531
|
-
}
|
|
532
|
-
function landSwings(): void {
|
|
533
|
-
const now = net.now();
|
|
534
|
-
for (let i = swings.length - 1; i >= 0; i -= 1) {
|
|
535
|
-
const s = swings[i] as (typeof swings)[number];
|
|
536
|
-
if (now < s.at) continue;
|
|
537
|
-
swings.splice(i, 1);
|
|
538
|
-
const from = bodies.get(s.slot);
|
|
539
|
-
if (!from || from.knockUntil > now) continue;
|
|
540
|
-
const half = (T.swingArc * Math.PI) / 360;
|
|
541
|
-
for (const b of bodies.values()) {
|
|
542
|
-
if (b === from) continue;
|
|
543
|
-
const dx = b.x - from.x; const dy = b.y - from.y; const d = Math.hypot(dx, dy);
|
|
544
|
-
if (d > T.knockRange) continue;
|
|
545
|
-
let off = Math.atan2(dx, dy) - from.fa; off = Math.atan2(Math.sin(off), Math.cos(off));
|
|
546
|
-
if (d > R_AV * 1.5 && Math.abs(off) > half) continue;
|
|
547
|
-
const h = !b.bot && b.seat !== null && b.seat === mySeat() ? me.h : b.h;
|
|
548
|
-
if (h >= CLEAR_M) { dodged(b.slot); net.send('dodge', { slot: b.slot }); continue; }
|
|
549
|
-
knock(b, from.x, from.y, from.slot);
|
|
550
|
-
}
|
|
551
|
-
}
|
|
552
|
-
}
|
|
553
|
-
/**
|
|
554
|
-
* Host: knock a body back. The hit LANDS first: for T.hitStopMs the body holds where it was hit (it flashes, flinches
|
|
555
|
-
* and shakes on every screen), then it SLIDES T.knockDistance along the hit, fast at first and easing into the stop
|
|
556
|
-
* (T.knockEase), so it arrives at rest, the same distance at any frame rate, and its owner steers again with no pop.
|
|
557
|
-
* A replica's body is TAKEN for the whole bump (its owner's avatar frames are ignored; the host drives it; the owner
|
|
558
|
-
* draws its body from snapshots), then the helper GIVES it back with a reset, so the owner stands where it ended.
|
|
559
|
-
*/
|
|
560
|
-
function knock(b: Body, fx: number, fy: number, by: number): void {
|
|
561
|
-
let dx = b.x - fx; let dy = b.y - fy;
|
|
562
|
-
const len = Math.hypot(dx, dy);
|
|
563
|
-
if (len < 0.02) { const a = Math.random() * Math.PI * 2; dx = Math.cos(a); dy = Math.sin(a); } else { dx /= len; dy /= len; }
|
|
564
|
-
const at = net.now() + T.hitStopMs;
|
|
565
|
-
const o: Knock & { x: number; y: number } = !b.bot && b.seat !== null && b.seat === mySeat() ? me : b;
|
|
566
|
-
o.kvx = dx; o.kvy = dy; o.kx = o.x; o.ky = o.y; o.kat = at; o.knockUntil = at + T.knockMs;
|
|
567
|
-
if (o === b && !b.bot && b.seat !== null) net.take(b.seat, T.hitStopMs + T.knockMs);
|
|
568
|
-
labKnock(b);
|
|
569
|
-
const d = { slot: b.slot, dx: q(dx, 2), dy: q(dy, 2), by };
|
|
570
|
-
addWave(d, true); net.send('knock', d);
|
|
571
|
-
}
|
|
572
|
-
|
|
573
|
-
/** Where a bumped body is now: held through the hit-stop, then eased out along the hit. Sets its velocity too. */
|
|
574
|
-
function slide(o: Knock & { x: number; y: number; vx: number; vy: number }, now: number, dt: number): void {
|
|
575
|
-
const u = Number.isFinite(o.kat) ? Math.max(0, Math.min(1, (now - o.kat) / Math.max(1, T.knockMs))) : 1;
|
|
576
|
-
const e = 1 - (1 - u) ** Math.max(1, T.knockEase);
|
|
577
|
-
const { x, y } = bound(o.kx + o.kvx * T.knockDistance * e, o.ky + o.kvy * T.knockDistance * e);
|
|
578
|
-
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; }
|
|
579
|
-
o.x = x; o.y = y;
|
|
580
|
-
}
|
|
581
|
-
/** The frame a bump is over: the body lands exactly where its slide ends, at rest (at 12 fps the last step is long). */
|
|
582
|
-
function landed(o: Knock & { x: number; y: number; vx: number; vy: number }): void {
|
|
583
|
-
if (!o.kat) return;
|
|
584
|
-
slide(o, o.kat + T.knockMs, 0);
|
|
585
|
-
o.kat = 0;
|
|
586
|
-
}
|
|
587
|
-
function addWave(d: { slot: number; dx?: number; dy?: number; by?: number; fa?: number }, isKnock = false): void {
|
|
588
|
-
const pos = hosting ? bodies.get(d.slot) : [...drawn.values()].find((x) => x.slot === d.slot);
|
|
589
|
-
// The action, for a watcher on Auto: whoever swung (a person, never a bot).
|
|
590
|
-
const waver = pos && 'bot' in pos ? (pos.bot ? null : pos.seat) : pos && pos.seat >= 0 ? pos.seat : null;
|
|
591
|
-
if (!isKnock) net.spotlight(waver);
|
|
592
|
-
const mineSlot = hosting ? [...bodies.values()].find((x) => x.seat !== null && x.seat === mySeat())?.slot : [...drawn.values()].find((x) => x.seat === net.seat)?.slot;
|
|
593
|
-
const at = mineSlot === d.slot && me.has ? me : pos;
|
|
594
|
-
if (!at) return;
|
|
595
|
-
waves.push({ x: at.x, y: at.y, at: performance.now(), colour: isKnock ? '#ffffff' : PALETTE[d.slot % PALETTE.length] as string, knock: isKnock, fa: d.fa, slot: d.slot });
|
|
596
|
-
if (isKnock) knocksSeen += 1; else wavesSeen += 1;
|
|
597
|
-
if (isKnock) bumped(d, at); else swung(d.slot);
|
|
152
|
+
if (len > 1) { x /= len; y /= len; }
|
|
153
|
+
const c = Math.cos(view3.yaw), s = Math.sin(view3.yaw);
|
|
154
|
+
return { x: x * c + y * s, y: -x * s + y * c };
|
|
598
155
|
}
|
|
599
156
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
function integrate(o: { x: number; y: number; vx: number; vy: number }, mx: number, my: number, dt: number): void {
|
|
603
|
-
mx = Number(mx) || 0; my = Number(my) || 0;
|
|
604
|
-
const len = Math.hypot(mx, my);
|
|
605
|
-
if (len > 1) { mx /= len; my /= len; } // an intent is at most full stick: no speed hack through intents
|
|
606
|
-
const k = Math.min(1, dt * 14);
|
|
607
|
-
o.vx += (mx * SPEED - o.vx) * k;
|
|
608
|
-
o.vy += (my * SPEED - o.vy) * k;
|
|
609
|
-
const p = bound(o.x + o.vx * dt, o.y + o.vy * dt);
|
|
610
|
-
o.x = p.x; o.y = p.y;
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
function stepMe(dt: number): void {
|
|
614
|
-
const seat = mySeat();
|
|
615
|
-
if (seat === null) { me.has = false; return; }
|
|
616
|
-
// Host-driven (host movement, or knocked back on a replica): stepReplica predicts me instead.
|
|
617
|
-
if (!net.owned) { fall(me, dt); return; }
|
|
618
|
-
fall(me, dt);
|
|
619
|
-
if (me.knockUntil > net.now()) { slide(me, net.now(), dt); return; }
|
|
620
|
-
landed(me);
|
|
621
|
-
const v = moveVector();
|
|
622
|
-
integrate(me, v.x, v.y, dt);
|
|
623
|
-
}
|
|
624
|
-
/** Which way a body faces: where it goes, once it moves (a knocked body keeps its facing: it flies backwards). */
|
|
625
|
-
function faceOf(o: { vx: number; vy: number; fa: number; knockUntil: number }, now: number): void {
|
|
626
|
-
if (o.knockUntil > now) return;
|
|
627
|
-
if (Math.hypot(o.vx, o.vy) > 0.6) o.fa = Math.atan2(o.vx, o.vy);
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
/**
|
|
631
|
-
* Replica, host-driven body. My stick moves me locally every frame (instant response), with the same rule the host
|
|
632
|
-
* runs. When a new snapshot lands: start from where the host has me, replay every intent it had not acknowledged,
|
|
633
|
-
* and pull toward that by 30% (or jump, if it is far). The host stays the truth; I never wait for it.
|
|
634
|
-
*/
|
|
635
|
-
let predictedK = -1;
|
|
636
|
-
function predictMe(dt: number): void {
|
|
637
|
-
const v = moveVector();
|
|
638
|
-
integrate(me, v.x, v.y, dt);
|
|
639
|
-
const snap = net.latest();
|
|
640
|
-
if (!snap || snap.k === predictedK) return;
|
|
641
|
-
predictedK = snap.k;
|
|
642
|
-
const p = snap.d.p.find((x) => x[1] === net.seat);
|
|
643
|
-
if (!p) return;
|
|
644
|
-
const pred = { x: p[2], y: p[3], vx: p[5] ?? 0, vy: p[6] ?? 0 };
|
|
645
|
-
for (const f of net.pending()) {
|
|
646
|
-
let left = Math.min(0.25, f.dt / 1000);
|
|
647
|
-
while (left > 0) { const h = Math.min(left, 1 / 60); integrate(pred, f.a[4], f.a[5], h); left -= h; }
|
|
648
|
-
}
|
|
649
|
-
const err = Math.hypot(pred.x - me.x, pred.y - me.y);
|
|
650
|
-
predictionError = err;
|
|
651
|
-
if (!me.has || err > 3) { me.x = pred.x; me.y = pred.y; me.vx = pred.vx; me.vy = pred.vy; }
|
|
652
|
-
else { me.x += (pred.x - me.x) * 0.3; me.y += (pred.y - me.y) * 0.3; }
|
|
653
|
-
me.has = true;
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
function stepKnocked(b: Body, dt: number): void { slide(b, net.now(), dt); }
|
|
657
|
-
|
|
658
|
-
/**
|
|
659
|
-
* THE BOTS, AT THE ROOM'S DIAL (NETPLAY.md section 17), AND NEVER RUNNING AWAY WITH A NEW PLAYER'S ROUND.
|
|
660
|
-
*
|
|
661
|
-
* The dial: a bot re-reads the world only every `reactionMs` (what it last noticed is where it heads, and a miss costs
|
|
662
|
-
* it that long again), aims up to 4 m off at aimNoise 1, leaves the hot zone to the people at positioning 0 and fights
|
|
663
|
-
* for it at 1, and swings at a rival in reach about `aggression x 0.8` times a second. Rookie is beatable by anyone,
|
|
664
|
-
* Maxed by few.
|
|
665
|
-
*
|
|
666
|
-
* On top of the dial, in plain words:
|
|
667
|
-
* A PLAYER'S FIRST ROUND. While somebody plays their first round in this room and nobody has set the dial (no vote,
|
|
668
|
-
* no owner: `net.policy.by` is absent), every bot plays one level easier for that round (Fair plays as Steady). A
|
|
669
|
-
* dial somebody set is always kept exactly.
|
|
670
|
-
* THE RUBBER BAND. Each bot compares its score with the best person's in the room (people only: never a bot, never
|
|
671
|
-
* an AI). Up to LEAD[level] points ahead of them it plays at its dial (2 at Fair, 5 at Strong; a Rookie eases off
|
|
672
|
-
* even a point behind them). Further ahead it eases off, the more the further (LEAD_BAND points more and it is all
|
|
673
|
-
* the way soft): it slows to a walk, notices things later, swings less, and races for the coins near the nearest
|
|
674
|
-
* person rather than the far ones. All the way soft it goes for no coin at all: it keeps near that person, a step
|
|
675
|
-
* up their screen, where a rival is seen (jogging after them when they run off), and goes round the coins on its
|
|
676
|
-
* way. The lead allowed grows with the dial; Maxed has no rubber band (a party that voted Maxed asked for the real
|
|
677
|
-
* thing). Within its lead, a bot always plays at its dial.
|
|
678
|
-
* HONEST. No score is ever changed: no coin is given, taken or faked, and a soft bot still picks up any coin it is
|
|
679
|
-
* knocked or walks onto, like anyone. Bots only play softer. Every bot is labelled where its name or score shows
|
|
680
|
-
* ("· bot"; an AI agent's name ends in "· AI"), on the phone's compact board too.
|
|
681
|
-
*/
|
|
682
|
-
const sight = new Map<number, { at: number; tx: number; ty: number; arrived?: boolean; near?: number }>();
|
|
683
|
-
/**
|
|
684
|
-
* The lead (points over the best person) a bot may hold before it eases off, by level: Rookie (it eases off a point
|
|
685
|
-
* BEHIND them: beatable by anyone), Steady, Fair, Strong, Maxed (no rubber band at all).
|
|
686
|
-
*/
|
|
687
|
-
const LEAD = [-1, 1, 2, 5, Infinity] as const;
|
|
688
|
-
/** Points past that lead over which a bot goes from its dial to all the way soft. */
|
|
689
|
-
const LEAD_BAND = 2;
|
|
690
|
-
/**
|
|
691
|
-
* Each seated person and the first round they play whole in this room (the round they sat down in, or the next when
|
|
692
|
-
* less than half of it was left). While one of them is in it, nobody set the dial and the bots play a level easier.
|
|
693
|
-
* Kept in the checkpoint, so a promoted host goes on easing the same round.
|
|
694
|
-
*/
|
|
695
|
-
let firstRound = new Map<number, number>();
|
|
696
|
-
/** How each bot plays now (its level this round, and how soft the rubber band has it): the `pickups` probe. */
|
|
697
|
-
const botPlay = new Map<number, { level: number; soft: number }>();
|
|
698
|
-
|
|
699
|
-
/** A person sat down: their first whole round here is this one, or the next if less than half of this one is left. */
|
|
700
|
-
function welcome(seat: number | null): void {
|
|
701
|
-
if (seat === null || firstRound.has(seat)) return;
|
|
702
|
-
const live = round?.phase === 'live';
|
|
703
|
-
firstRound.set(seat, !round ? 1 : live && round.endsAt - net.now() > ROUND_MS / 2 ? round.n : round.n + 1);
|
|
704
|
-
}
|
|
705
|
-
/** The people in the room: seated, not a bot, not an AI (an agent that plays its own seat is labelled · AI). */
|
|
706
|
-
function people(): Body[] {
|
|
707
|
-
return [...bodies.values()].filter((b) => !b.bot && b.seat !== null && !b.name.endsWith(AI_MARK) && !roster.slots.find((x) => x.slot === b.slot)?.agent);
|
|
708
|
-
}
|
|
709
|
-
/** Somebody is playing their first round in this room now. */
|
|
710
|
-
function newcomerRound(folk: Body[]): boolean {
|
|
711
|
-
return Boolean(round) && folk.some((b) => (firstRound.get(b.seat as number) ?? 0) >= (round as RoundInfo).n);
|
|
712
|
-
}
|
|
713
|
-
/** The dial a bot plays at this round: the room's, or a level easier in a newcomer's first round when nobody set it. */
|
|
714
|
-
function dialOf(slot: number, easy: boolean): Skill {
|
|
715
|
-
const s: Skill = net.skillOf(slot); // Fair when nobody set a dial
|
|
716
|
-
return easy && !net.policy.by && s.level > 1 ? skillPreset(s.level - 1, net.policy.kids) : s;
|
|
717
|
-
}
|
|
718
|
-
/** The rubber band: 0 plays at the dial, 1 all the way soft, from how far this bot leads the best person. */
|
|
719
|
-
function softness(b: Body, s: Skill, best: number | null): number {
|
|
720
|
-
const allowed = LEAD[Math.max(1, Math.min(5, s.level)) - 1] as number;
|
|
721
|
-
if (best === null || !Number.isFinite(allowed)) return 0;
|
|
722
|
-
return Math.max(0, Math.min(1, (b.score - best - allowed) / LEAD_BAND));
|
|
723
|
-
}
|
|
724
|
-
/** The nearest of `list` to `b`, or null. */
|
|
725
|
-
function nearestOf(list: Body[], b: Body): Body | null {
|
|
726
|
-
let best: Body | null = null; let bd = Infinity;
|
|
727
|
-
for (const o of list) { const d = Math.hypot(o.x - b.x, o.y - b.y); if (d < bd) { bd = d; best = o; } }
|
|
728
|
-
return best;
|
|
729
|
-
}
|
|
730
|
-
/**
|
|
731
|
-
* All the way soft: a spot near person `p`, 2.2 to 3 m off (just past a swing's reach) and a step up their screen
|
|
732
|
-
* (where even a phone held upright shows a rival), turned a little each time it looks, so it keeps round them like a
|
|
733
|
-
* rival waiting for a chance. It leaves the coins to them: of a few such spots, the first it can walk to without
|
|
734
|
-
* running over a coin.
|
|
735
|
-
*/
|
|
736
|
-
function beside(b: Body, p: Body): { x: number; y: number } {
|
|
737
|
-
const clear = R_AV + R_GEM + 0.15;
|
|
738
|
-
let spot = { x: b.x, y: b.y };
|
|
739
|
-
for (let i = 0; i < 6; i += 1) {
|
|
740
|
-
let a = Math.atan2(Math.min(b.y - p.y, -0.6), b.x - p.x) + (Math.random() - 0.5) * (1.2 + i * 0.5);
|
|
741
|
-
a = Math.max(-Math.PI + 0.7, Math.min(-0.7, a));
|
|
742
|
-
const r = 2.2 + Math.random() * 0.8;
|
|
743
|
-
spot = bound(p.x + Math.cos(a) * r, p.y + Math.sin(a) * r);
|
|
744
|
-
const dx = spot.x - b.x; const dy = spot.y - b.y; const len2 = dx * dx + dy * dy || 1;
|
|
745
|
-
// The nearest point of the walk to each coin: none closer than a pickup.
|
|
746
|
-
if (!gems.some((g) => { const u = Math.max(0, Math.min(1, ((g.x - b.x) * dx + (g.y - b.y) * dy) / len2)); return Math.hypot(b.x + dx * u - g.x, b.y + dy * u - g.y) < clear; })) break;
|
|
747
|
-
}
|
|
748
|
-
return spot;
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
function stepBots(dt: number): void {
|
|
752
|
-
const taken = new Set<number>();
|
|
753
|
-
const now = net.now();
|
|
754
|
-
const folk = people();
|
|
755
|
-
const best = folk.length ? Math.max(...folk.map((p) => p.score)) : null;
|
|
756
|
-
const easy = newcomerRound(folk);
|
|
757
|
-
for (const b of bodies.values()) {
|
|
758
|
-
if (!b.bot) continue;
|
|
759
|
-
fall(b, dt);
|
|
760
|
-
const leap = dodgeAt.get(b.slot);
|
|
761
|
-
if (leap !== undefined && now >= leap) { dodgeAt.delete(b.slot); if (b.h <= 0 && b.knockUntil <= now) { b.vh = jumpSpeed(); b.h = 1e-3; jumped(b.slot); net.send('jump', { slot: b.slot }); } }
|
|
762
|
-
if (b.knockUntil > now) { stepKnocked(b, dt); continue; }
|
|
763
|
-
landed(b);
|
|
764
|
-
if (lab.stage === 'dummy' || lab.stage === 'jump') { standStill(b, dt); continue; }
|
|
765
|
-
const s = dialOf(b.slot, easy);
|
|
766
|
-
const soft = softness(b, s, best); // THE RUBBER BAND
|
|
767
|
-
botPlay.set(b.slot, { level: s.level, soft });
|
|
768
|
-
let eye = sight.get(b.slot);
|
|
769
|
-
// REACTION TIME: later when soft (it notices coins late); all the way soft it only keeps up with its person.
|
|
770
|
-
if (!eye || now - eye.at >= s.reactionMs * (soft >= 1 ? 1 : 1 + 2.5 * soft)) {
|
|
771
|
-
const near = soft > 0 ? nearestOf(folk, b) : null;
|
|
772
|
-
let g: Gem | null = null;
|
|
773
|
-
let tx = b.tx; let ty = b.ty;
|
|
774
|
-
if (near && soft >= 1) { const at = beside(b, near); tx = at.x; ty = at.y; } // all the way soft: no coin, near them
|
|
775
|
-
else {
|
|
776
|
-
g = pickGem(b, taken, s, near, soft); // POSITIONING (soft: the coins near the nearest person)
|
|
777
|
-
const miss = 4 * s.aimNoise; // AIM NOISE: up to 4 m off at 1
|
|
778
|
-
if (g) { tx = g.x + (Math.random() - 0.5) * miss; ty = g.y + (Math.random() - 0.5) * miss; }
|
|
779
|
-
}
|
|
780
|
-
eye = { at: now, tx, ty, ...(near && soft >= 1 ? { near: near.slot } : {}) };
|
|
781
|
-
sight.set(b.slot, eye);
|
|
782
|
-
if (g) taken.add(g.id);
|
|
783
|
-
}
|
|
784
|
-
b.tx = eye.tx; b.ty = eye.ty;
|
|
785
|
-
const rival = nearestBody(b, T.knockRange * 0.9); // AGGRESSION: swings at a rival in reach, turning to it (less when soft)
|
|
786
|
-
if (rival && round?.phase === 'live' && Math.random() < s.aggression * (1 - 0.75 * soft) * 0.8 * dt) { b.fa = Math.atan2(rival.x - b.x, rival.y - b.y); startSwing(b); }
|
|
787
|
-
const dx = b.tx - b.x; const dy = b.ty - b.y; const dist = Math.hypot(dx, dy);
|
|
788
|
-
const len = dist || 1;
|
|
789
|
-
// Arrived where it aimed: it stands there, and notices what it missed only a reaction later.
|
|
790
|
-
if (dist < 0.12 && !eye.arrived) { eye.arrived = true; eye.at = now; }
|
|
791
|
-
// Soft: down to a walk; all the way soft it jogs back to its person when they have run off (a rival in view).
|
|
792
|
-
const kept = eye.near !== undefined ? bodies.get(eye.near) : undefined;
|
|
793
|
-
const far = kept ? Math.hypot(kept.x - b.x, kept.y - b.y) > 3.5 : false;
|
|
794
|
-
const speed = dist < 0.12 ? 0 : BOT_SPEED * (far ? 1 : 1 - 0.5 * soft);
|
|
795
|
-
const k = Math.min(1, dt * 5);
|
|
796
|
-
b.vx += ((dx / len) * speed - b.vx) * k;
|
|
797
|
-
b.vy += ((dy / len) * speed - b.vy) * k;
|
|
798
|
-
// All the way soft it steps round a coin in its way (turning a little, or a lot, or it waits): it leaves them to
|
|
799
|
-
// the people. It still takes one it is knocked onto, or one that lands under it: the rules' pickups are anyone's.
|
|
800
|
-
if (kept) sidestep(b, dt);
|
|
801
|
-
const p = bound(b.x + b.vx * dt, b.y + b.vy * dt);
|
|
802
|
-
b.x = p.x; b.y = p.y;
|
|
803
|
-
faceOf(b, now);
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
/** A soft bot's step that would touch a coin turns aside (60, then 120 degrees, either way), or it stands this frame. */
|
|
807
|
-
function sidestep(b: Body, dt: number): void {
|
|
808
|
-
const reach = R_AV + R_GEM + 0.06;
|
|
809
|
-
const look = Math.max(0.25, Math.hypot(b.vx, b.vy) * dt * 4); // a few frames ahead
|
|
810
|
-
const touches = (vx: number, vy: number): boolean => {
|
|
811
|
-
const sp = Math.hypot(vx, vy) || 1; const x = b.x + (vx / sp) * look; const y = b.y + (vy / sp) * look;
|
|
812
|
-
return gems.some((g) => Math.hypot(g.x - x, g.y - y) < reach && Math.hypot(g.x - x, g.y - y) < Math.hypot(g.x - b.x, g.y - b.y));
|
|
813
|
-
};
|
|
814
|
-
if (Math.hypot(b.vx, b.vy) < 0.05 || !touches(b.vx, b.vy)) return;
|
|
815
|
-
for (const a of [1.05, -1.05, 2.1, -2.1]) {
|
|
816
|
-
const c = Math.cos(a); const sn = Math.sin(a);
|
|
817
|
-
const vx = b.vx * c - b.vy * sn; const vy = b.vx * sn + b.vy * c;
|
|
818
|
-
if (!touches(vx, vy)) { b.vx = vx; b.vy = vy; return; }
|
|
819
|
-
}
|
|
820
|
-
b.vx = 0; b.vy = 0;
|
|
821
|
-
}
|
|
822
|
-
/**
|
|
823
|
-
* A bot sees a swing start near it: it may JUMP it, after its reaction time (half of it: a swing is loud), the more
|
|
824
|
-
* likely the better its aim at the dial (a Rookie is caught, a Maxed bot hops most of them; a soft one less often).
|
|
825
|
-
*/
|
|
826
|
-
const dodgeAt = new Map<number, number>();
|
|
827
|
-
function botsSee(from: Body): void {
|
|
828
|
-
const easy = newcomerRound(people());
|
|
829
|
-
for (const b of bodies.values()) {
|
|
830
|
-
if (!b.bot || b === from || lab.stage === 'dummy' || lab.stage === 'jump') continue;
|
|
831
|
-
if (Math.hypot(b.x - from.x, b.y - from.y) > T.knockRange + 0.6) continue;
|
|
832
|
-
const s = dialOf(b.slot, easy);
|
|
833
|
-
const soft = botPlay.get(b.slot)?.soft ?? 0;
|
|
834
|
-
if (Math.random() < (0.15 + 0.6 * (1 - s.aimNoise)) * (1 - 0.5 * soft)) dodgeAt.set(b.slot, net.now() + Math.max(40, s.reactionMs * 0.5 - 60));
|
|
835
|
-
}
|
|
836
|
-
}
|
|
837
|
-
|
|
838
|
-
/**
|
|
839
|
-
* Positioning: 0 leaves the hot zone to the people, 1 fights for it. A soft bot (`pull` 0 to 1) weighs how far each
|
|
840
|
-
* coin is from the person `near` it too, so it races for theirs, where they can see it and beat it to them.
|
|
841
|
-
*/
|
|
842
|
-
function pickGem(b: Body, taken: Set<number>, s: Skill, near: Body | null = null, pull = 0): Gem | null {
|
|
843
|
-
let best: Gem | null = null; let bd = Infinity;
|
|
844
|
-
for (const g of gems) {
|
|
845
|
-
if (taken.has(g.id)) continue;
|
|
846
|
-
const hot = zone !== null && Math.hypot(g.x - zone.x, g.y - zone.y) < zone.r;
|
|
847
|
-
const d = Math.hypot(g.x - b.x, g.y - b.y) * (hot ? 1.5 - s.positioning : 1) + (near ? 2 * pull * Math.hypot(g.x - near.x, g.y - near.y) : 0);
|
|
848
|
-
if (d < bd) { bd = d; best = g; }
|
|
849
|
-
}
|
|
850
|
-
return best;
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
/** The nearest other body within `range` (a bot's rival), or null. */
|
|
854
|
-
function nearestBody(b: Body, range: number): Body | null {
|
|
855
|
-
let best: Body | null = null; let bd = range;
|
|
856
|
-
for (const o of bodies.values()) {
|
|
857
|
-
if (o === b) continue;
|
|
858
|
-
const d = Math.hypot(o.x - b.x, o.y - b.y);
|
|
859
|
-
if (d < bd) { bd = d; best = o; }
|
|
860
|
-
}
|
|
861
|
-
return best;
|
|
862
|
-
}
|
|
863
|
-
|
|
864
|
-
function stepHost(dt: number): void {
|
|
865
|
-
tick += 1;
|
|
866
|
-
const seat = mySeat();
|
|
867
|
-
const now0 = net.now();
|
|
868
|
-
for (const b of bodies.values()) {
|
|
869
|
-
if (b.bot) continue;
|
|
870
|
-
if (b.seat === seat) {
|
|
871
|
-
me.has = true;
|
|
872
|
-
b.x = me.x; b.y = me.y; b.vx = me.vx; b.vy = me.vy; b.h = me.h; b.vh = me.vh;
|
|
873
|
-
faceOf(b, now0);
|
|
874
|
-
continue;
|
|
875
|
-
}
|
|
876
|
-
if (b.seat === null) continue;
|
|
877
|
-
const ctl = net.control(b.seat);
|
|
878
|
-
if (ctl.taken || b.knockUntil > now0) { stepKnocked(b, dt); fall(b, dt); if (net.takePresses(b.seat)['swing']) startSwing(b); continue; } // the host drives the knockback
|
|
879
|
-
if (MOVEMENT === 'host') {
|
|
880
|
-
// Host movement: the seat sends intents; the rules move the body (and jump it).
|
|
881
|
-
const f = net.inputOf(b.seat);
|
|
882
|
-
integrate(b, f ? Number(f.a?.[4]) : 0, f ? Number(f.a?.[5]) : 0, dt);
|
|
883
|
-
const pr = net.takePresses(b.seat);
|
|
884
|
-
if (pr['jump'] && b.h <= 0) { b.vh = jumpSpeed(); b.h = 1e-3; }
|
|
885
|
-
fall(b, dt);
|
|
886
|
-
faceOf(b, now0);
|
|
887
|
-
if (pr['swing']) startSwing(b);
|
|
888
|
-
continue;
|
|
889
|
-
}
|
|
890
|
-
const a = net.avatar(b.seat); // null while taken, or until the owner has adopted the last reset
|
|
891
|
-
if (a && Array.isArray(a)) {
|
|
892
|
-
// Client-owned movement, bounded: to the arena, and to 1.3x top speed per frame. A legitimate avatar catches
|
|
893
|
-
// up within a frame; a teleport crawls, and a claim more than half a second of running away is reset.
|
|
894
|
-
const claim = bound(Number(a[0]) || 0, Number(a[1]) || 0);
|
|
895
|
-
const m = capMove(b, claim, SPEED * 1.3 * dt + 0.12);
|
|
896
|
-
if (m.over > SPEED * 0.5) { net.reset(b.seat); cheatResets += 1; }
|
|
897
|
-
else { b.x = m.x; b.y = m.y; b.vx = Number(a[2]) || 0; b.vy = Number(a[3]) || 0; }
|
|
898
|
-
// Its height is the owner's too, bounded by how high a jump goes.
|
|
899
|
-
b.h = Math.max(0, Math.min(T.jumpHeight * 1.15 + 0.05, Number(a[6]) || 0));
|
|
900
|
-
faceOf(b, now0);
|
|
901
|
-
}
|
|
902
|
-
if (net.takePresses(b.seat)['swing']) startSwing(b);
|
|
903
|
-
}
|
|
904
|
-
stepBots(dt);
|
|
905
|
-
landSwings();
|
|
906
|
-
const now = net.now();
|
|
907
|
-
if (round && round.phase === 'live') {
|
|
908
|
-
if (!zone || now >= zone.until) setZone(newZone((zone?.n ?? 0) + 1));
|
|
909
|
-
for (const b of bodies.values()) {
|
|
910
|
-
for (let i = 0; i < gems.length; i += 1) {
|
|
911
|
-
const g = gems[i] as Gem;
|
|
912
|
-
if (Math.hypot(g.x - b.x, g.y - b.y) < R_AV + R_GEM) {
|
|
913
|
-
b.score += zone && Math.hypot(g.x - zone.x, g.y - zone.y) < zone.r ? 2 : 1;
|
|
914
|
-
pickups.set(b.slot, (pickups.get(b.slot) ?? 0) + 1);
|
|
915
|
-
gems[i] = newGem();
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
}
|
|
919
|
-
if (now >= round.endsAt) endRound();
|
|
920
|
-
} else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1, true);
|
|
921
|
-
if (net.snapshotDue()) net.snapshot(buildSnap(), tick);
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
function buildSnap(): Snap {
|
|
925
|
-
const r = round ?? { n: 0, phase: 'live', startedAt: 0, endsAt: 0 };
|
|
926
|
-
return {
|
|
927
|
-
r: [r.n, r.phase === 'over' ? 1 : 0, r.startedAt, r.endsAt],
|
|
928
|
-
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), q(b.h, 2)] as P),
|
|
929
|
-
g: gems.map((g) => [g.id, q(g.x, 2), q(g.y, 2)] as G),
|
|
930
|
-
};
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
function stepReplica(dt: number): void {
|
|
934
|
-
const seat = net.seat;
|
|
935
|
-
if (seat !== null) {
|
|
936
|
-
// Host-driven (host movement, or taken for a knockback): predict from the host's position and my intents.
|
|
937
|
-
if (!net.owned) predictMe(dt);
|
|
938
|
-
// Only once I have a body (the first control event adopts it): before that my position is nobody's.
|
|
939
|
-
if (me.has) {
|
|
940
|
-
const v = moveVector();
|
|
941
|
-
// Stick direction as held "buttons": a new direction is a press edge, so it is sent within 16 ms.
|
|
942
|
-
const held = [v.x > 0.2 ? 'R' : v.x < -0.2 ? 'L' : '', v.y > 0.2 ? 'D' : v.y < -0.2 ? 'U' : ''].filter(Boolean);
|
|
943
|
-
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), q(me.h, 2)], held);
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
const smp = net.sample();
|
|
947
|
-
drawn.clear();
|
|
948
|
-
if (!smp) return;
|
|
949
|
-
const bySlot = new Map<number, P>(smp.a.d.p.map((p) => [p[0], p]));
|
|
950
|
-
for (const pb of smp.b.d.p) {
|
|
951
|
-
const pa = bySlot.get(pb[0]) ?? pb;
|
|
952
|
-
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), h: Math.max(0, lerp(Number(pa[7]) || 0, Number(pb[7]) || 0, smp.alpha)), score: pb[4] });
|
|
953
|
-
}
|
|
954
|
-
const [n, ph, startedAt, endsAt] = smp.b.d.r;
|
|
955
|
-
if (!round || round.n !== n || (round.phase === 'over') !== (ph === 1)) {
|
|
956
|
-
const kept = net.roundInfo && net.roundInfo.n === n ? net.roundInfo : null;
|
|
957
|
-
round = kept ?? { n, phase: ph ? 'over' : 'live', startedAt, endsAt };
|
|
958
|
-
}
|
|
959
|
-
}
|
|
960
|
-
|
|
961
|
-
/* -------------------------------------------------------------- how a swing, a hit and a jump look */
|
|
962
|
-
/*
|
|
963
|
-
* Every screen draws a swing from the swing event (the host's own, or the one it sends): the hero plays its attack (on
|
|
964
|
-
* the upper body when it runs) and a slash arcs in front of it. A bump comes from the knock event: the hit lands (the
|
|
965
|
-
* hero flashes white, flinches and holds through the hit-stop, shaking), flies (stretched along the hit, less as it
|
|
966
|
-
* slows), stops (squashed, then a wobble that dies away: overlap), throws sparks, and kicks the camera of whoever was
|
|
967
|
-
* in it. A jump plays from the jump event and the height in the snapshot; a dodge (a swing passing under a jumper)
|
|
968
|
-
* pops a word. Sparks and the camera's kick roll lab.random(): their own dice, so the world's (Math.random) stay the
|
|
969
|
-
* same as a build without them, which the Game Lab needs to compare two.
|
|
970
|
-
*/
|
|
157
|
+
function jump(): void { const v = moveVector(); room.input({ ax: Math.round(v.x * 127), ay: Math.round(v.y * 127), jump: true }); }
|
|
158
|
+
function swing(): void { const v = moveVector(); room.input({ ax: Math.round(v.x * 127), ay: Math.round(v.y * 127), swing: true }); }
|
|
971
159
|
const bumps = new Map<number, { at: number; dx: number; dy: number }>();
|
|
972
160
|
const pushes = new Map<number, number>();
|
|
973
161
|
const sparks: { x: number; y: number; h: number; vx: number; vy: number; vh: number; at: number; life: number; gold?: boolean }[] = [];
|
|
@@ -984,7 +172,7 @@ function bumped(d: { slot: number; dx?: number; dy?: number; by?: number }, at:
|
|
|
984
172
|
}
|
|
985
173
|
if (sparks.length > 160) sparks.splice(0, sparks.length - 160);
|
|
986
174
|
// The camera kicks for whoever was in it: the body bumped, or the one who waved.
|
|
987
|
-
const mine =
|
|
175
|
+
const mine = [...drawn.values()].find((x) => x.seat === net.seat)?.slot;
|
|
988
176
|
if (mine !== undefined && (d.slot === mine || d.by === mine)) kickAt = now;
|
|
989
177
|
const hero = heroes.get(d.slot)?.char;
|
|
990
178
|
if (hero) hero.hit(dx, dy, 1);
|
|
@@ -996,10 +184,9 @@ function swung(slot: number): void {
|
|
|
996
184
|
h.char.act(h.char.has(HERO_ACTION[h.kind] ?? 'attack') ? (HERO_ACTION[h.kind] ?? 'attack') : 'attack', { from: T.swingSkip });
|
|
997
185
|
labSwing(slot);
|
|
998
186
|
}
|
|
999
|
-
/**
|
|
187
|
+
/** Count authoritative jumps; the clip starts with the drawn takeoff, including prediction. */
|
|
1000
188
|
function jumped(slot: number): void {
|
|
1001
189
|
jumpsSeen += 1;
|
|
1002
|
-
heroes.get(slot)?.char?.jump();
|
|
1003
190
|
}
|
|
1004
191
|
/** A swing passed under a jumper: a word pops over it. */
|
|
1005
192
|
const pops: { slot: number; at: number; text: string }[] = [];
|
|
@@ -1040,51 +227,11 @@ function bodyFx(slot: number, t: number): { ox: number; oy: number; sx: number;
|
|
|
1040
227
|
return out;
|
|
1041
228
|
}
|
|
1042
229
|
|
|
1043
|
-
/* -------------------------------------------------------------- the Game Lab */
|
|
1044
|
-
/*
|
|
1045
|
-
* What the lab shows (lab.json's takes):
|
|
1046
|
-
* "swing" stage dummy: you swing at the dummy beside you. WINDUP (from the press to the moment it lands: the
|
|
1047
|
-
* anticipation), then the dummy's HIT-STOP, LAUNCH, SLIDE and SETTLE. Tracks its speed and distance.
|
|
1048
|
-
* "jump" stage jump: you jump in an empty space. TAKE-OFF, RISE, HANG (near the top), FALL, LAND. Tracks the height
|
|
1049
|
-
* and the body's stretch (the animator's squash and stretch).
|
|
1050
|
-
* The onion skin and the spacing arc (dots far apart: fast; close together: slow) are drawn as lines on the meadow.
|
|
1051
|
-
* Only in the lab: outside it, labKnock, labSwing and labReport never run.
|
|
1052
|
-
*/
|
|
1053
230
|
type Pose = { x: number; y: number; sx?: number; sy?: number; a?: number };
|
|
1054
|
-
/**
|
|
1055
|
-
* The take's stage "dummy" (lab.stage, only ever set by the lab): you stand left of the middle, facing a bot a short
|
|
1056
|
-
* step to your right, and every bot stands still unless it is bumped, like a training dummy. Stage "jump": you stand in
|
|
1057
|
-
* the middle with nobody near.
|
|
1058
|
-
*/
|
|
1059
|
-
function stageDummy(): void {
|
|
1060
|
-
const seat = mySeat();
|
|
1061
|
-
const list = [...bodies.values()].sort((a, c) => a.slot - c.slot);
|
|
1062
|
-
const mine = list.find((b) => !b.bot && b.seat === seat);
|
|
1063
|
-
const bots = list.filter((b) => b.bot);
|
|
1064
|
-
const jumpStage = lab.stage === 'jump';
|
|
1065
|
-
if (mine) { mine.x = W / 2 - (jumpStage ? 0 : 3.2); mine.y = H / 2; mine.fa = Math.PI / 2; hostMoved(mine); }
|
|
1066
|
-
bots.forEach((b, i) => { b.x = i === 0 && !jumpStage ? W / 2 - 3.2 + 1.5 : W - 3.6; b.y = i === 0 && !jumpStage ? H / 2 : 3 + i * 5; b.tx = b.x; b.ty = b.y; b.fa = -Math.PI / 2; });
|
|
1067
|
-
}
|
|
1068
|
-
/** A dummy at rest: it eases to a stop wherever the last bump left it. */
|
|
1069
|
-
function standStill(b: Body, dt: number): void {
|
|
1070
|
-
const k = Math.min(1, dt * 5);
|
|
1071
|
-
b.vx -= b.vx * k; b.vy -= b.vy * k;
|
|
1072
|
-
const p = bound(b.x + b.vx * dt, b.y + b.vy * dt); b.x = p.x; b.y = p.y;
|
|
1073
|
-
}
|
|
1074
231
|
const subject = { slot: -1, at: 0, x0: 0, y0: 0, px: 0, py: 0, has: false };
|
|
1075
232
|
const swinger = { slot: -1, at: 0 };
|
|
1076
|
-
function labKnock(b: Body): void {
|
|
1077
|
-
if (!lab.on) return;
|
|
1078
|
-
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;
|
|
1079
|
-
}
|
|
1080
233
|
function labSwing(slot: number): void { if (lab.on) { swinger.slot = slot; swinger.at = net.now(); } }
|
|
1081
|
-
|
|
1082
|
-
function subjectAt(): Pose | null {
|
|
1083
|
-
if (!subject.has) return null;
|
|
1084
|
-
const b = bodies.get(subject.slot);
|
|
1085
|
-
if (!b) return null;
|
|
1086
|
-
return !b.bot && b.seat !== null && b.seat === mySeat() ? me : b;
|
|
1087
|
-
}
|
|
234
|
+
function subjectAt(): Pose | null { return subject.has ? drawn.get(subject.slot) ?? null : null; }
|
|
1088
235
|
let apex = 0;
|
|
1089
236
|
function labReport(dt: number): void {
|
|
1090
237
|
if (dt <= 0) return;
|
|
@@ -1092,7 +239,7 @@ function labReport(dt: number): void {
|
|
|
1092
239
|
const hero = [...heroes.values()].find((x) => x.mine)?.char ?? null;
|
|
1093
240
|
lab.track('height', me.h, 'm');
|
|
1094
241
|
lab.track('stretch', hero ? (hero.root.children[0]?.scale.y ?? 1) * 100 - 100 : 0, '%');
|
|
1095
|
-
if (me
|
|
242
|
+
if (room.me?.grounded) { lab.phase(hero?.state === 'LAND' ? 'LAND' : null, 'Squash on touch-down, then back to the run'); apex = 0; }
|
|
1096
243
|
else if (me.vh > jumpSpeed() * 0.55) lab.phase('TAKE-OFF', 'Leaves fast and stretched');
|
|
1097
244
|
else if (me.vh > jumpSpeed() * 0.15) lab.phase('RISE', 'Slows as it climbs');
|
|
1098
245
|
else if (me.vh > -jumpSpeed() * 0.25) { lab.phase('HANG', 'A moment at the top to read it'); apex = Math.max(apex, me.h); }
|
|
@@ -1808,7 +955,7 @@ interface Hero {
|
|
|
1808
955
|
slot: number; kind: string; mine: boolean;
|
|
1809
956
|
root: Group; tilt: Group; squash: Group; untilt: Group;
|
|
1810
957
|
char: Character | null; stand: Mesh | null; mats: MeshStandardMaterial[];
|
|
1811
|
-
px: number; py: number; ph: number; speed: number; seen: number; yaw: number; held: string | null;
|
|
958
|
+
px: number; py: number; ph: number; grounded: boolean; speed: number; seen: number; yaw: number; held: string | null;
|
|
1812
959
|
}
|
|
1813
960
|
const heroes = new Map<number, Hero>();
|
|
1814
961
|
function heroFor(slot: number, kind: string, colour: string): Hero {
|
|
@@ -1818,7 +965,7 @@ function heroFor(slot: number, kind: string, colour: string): Hero {
|
|
|
1818
965
|
const root = new Group(); const tilt = new Group(); const squash = new Group(); const untilt = new Group();
|
|
1819
966
|
root.add(tilt); tilt.add(squash); squash.add(untilt);
|
|
1820
967
|
scene.add(root);
|
|
1821
|
-
h = { slot, kind, mine: false, root, tilt, squash, untilt, char: null, stand: null, mats: [], px: NaN, py: NaN, ph: 0, speed: 0, seen: 0, yaw: 0, held: null };
|
|
968
|
+
h = { slot, kind, mine: false, root, tilt, squash, untilt, char: null, stand: null, mats: [], px: NaN, py: NaN, ph: 0, grounded: true, speed: 0, seen: 0, yaw: 0, held: null };
|
|
1822
969
|
heroes.set(slot, h);
|
|
1823
970
|
}
|
|
1824
971
|
h.kind = kind;
|
|
@@ -1871,7 +1018,8 @@ function poseHero(h: Hero, at: { x: number; y: number; h: number }, t: number, d
|
|
|
1871
1018
|
const vh = dt > 0 ? (at.h - h.ph) / dt : 0;
|
|
1872
1019
|
h.px = at.x; h.py = at.y; h.ph = at.h;
|
|
1873
1020
|
// It faces where it goes, but not while flying from a bump (it is knocked backwards, not turned round).
|
|
1874
|
-
|
|
1021
|
+
const pose = poses.get(h.slot);
|
|
1022
|
+
if (pose) h.yaw = Math.atan2(pose.heading.x, pose.heading.y);
|
|
1875
1023
|
h.tilt.rotation.y = -fx.ang; h.untilt.rotation.y = fx.ang;
|
|
1876
1024
|
h.squash.scale.set(fx.sx, fx.up, fx.sy);
|
|
1877
1025
|
for (const m of h.mats) if (m.emissive) m.emissive.setScalar(fx.flash * 0.9);
|
|
@@ -1879,7 +1027,9 @@ function poseHero(h: Hero, at: { x: number; y: number; h: number }, t: number, d
|
|
|
1879
1027
|
if (!c) { if (h.stand) h.stand.rotation.y = h.yaw; return; }
|
|
1880
1028
|
c.face(h.yaw, dt);
|
|
1881
1029
|
c.move(bumps.has(h.slot) ? 0 : h.speed);
|
|
1882
|
-
|
|
1030
|
+
const grounded = pose?.grounded ?? at.h <= 0.002, vertical = pose?.vel.z ?? vh;
|
|
1031
|
+
if (h.grounded && !grounded && vertical > 0) c.jump();
|
|
1032
|
+
c.air(grounded, vertical); h.grounded = grounded;
|
|
1883
1033
|
// The round's end: its winner cheers (held until the next round), everyone else stands.
|
|
1884
1034
|
const want = over && place === 1 ? 'win' : null;
|
|
1885
1035
|
if (want !== h.held) { if (want) c.hold(want); else if (h.held) c.hold(''); h.held = want; }
|
|
@@ -2072,7 +1222,6 @@ let lastDraw = 0;
|
|
|
2072
1222
|
/** Where a seat's body is drawn now (host: the real body; replica: interpolated), or null. */
|
|
2073
1223
|
function seatPos(seat: number): { x: number; y: number } | null {
|
|
2074
1224
|
if (seat === mySeat() && me.has && !net.watching) return me;
|
|
2075
|
-
if (hosting) { const b = [...bodies.values()].find((x) => !x.bot && x.seat === seat); return b ? { x: b.x, y: b.y } : null; }
|
|
2076
1225
|
const d = [...drawn.values()].find((x) => x.seat === seat);
|
|
2077
1226
|
return d ? { x: d.x, y: d.y } : null;
|
|
2078
1227
|
}
|
|
@@ -2143,7 +1292,7 @@ function draw(t: number, dt: number): void {
|
|
|
2143
1292
|
}
|
|
2144
1293
|
|
|
2145
1294
|
// gems: spinning, bobbing, glowing; a taken one bursts into gold where it was
|
|
2146
|
-
const gemList: { x: number; y: number; id: number }[] =
|
|
1295
|
+
const gemList: { x: number; y: number; id: number }[] = gems;
|
|
2147
1296
|
const rn = round?.n ?? -1;
|
|
2148
1297
|
const seenNow = new Set<number>();
|
|
2149
1298
|
let nb = 0;
|
|
@@ -2182,8 +1331,7 @@ function draw(t: number, dt: number): void {
|
|
|
2182
1331
|
const mat = m.material as MeshBasicMaterial; mat.color.set(w.colour); mat.opacity = 1 - age;
|
|
2183
1332
|
} else {
|
|
2184
1333
|
const m = slashMesh(ns2++);
|
|
2185
|
-
const
|
|
2186
|
-
const fa = w.fa ?? pos?.fa ?? 0;
|
|
1334
|
+
const fa = w.fa ?? 0;
|
|
2187
1335
|
const r = 0.55 + Math.min(1, age * 2.2) * (T.knockRange - 0.55);
|
|
2188
1336
|
m.visible = true; m.position.set(wx, 0.55, wz); m.rotation.set(0, fa, 0); m.scale.set(r, 1, r);
|
|
2189
1337
|
const mat = m.material as MeshBasicMaterial; mat.color.set(w.colour); mat.opacity = 0.85 * (1 - age) ** 1.5;
|
|
@@ -2193,16 +1341,11 @@ function draw(t: number, dt: number): void {
|
|
|
2193
1341
|
for (let i = ns2; i < slashPool.length; i += 1) (slashPool[i] as Mesh).visible = false;
|
|
2194
1342
|
|
|
2195
1343
|
// the heroes
|
|
2196
|
-
const names = new Map<number, Slot>((net.slots ??
|
|
1344
|
+
const names = new Map<number, Slot>((net.slots ?? []).map((s) => [s.slot, s]));
|
|
2197
1345
|
// `mine`: the body whose view this is: my own, or the player a watcher follows (named, never "You").
|
|
2198
1346
|
const list: { slot: number; seat: number | null; x: number; y: number; h: number; bot: boolean; name: string; mine: boolean }[] = [];
|
|
2199
1347
|
const isView = (seat: number | null, bot: boolean): boolean => !bot && seat !== null && seat === view;
|
|
2200
|
-
|
|
2201
|
-
for (const b of bodies.values()) {
|
|
2202
|
-
const own = !b.bot && b.seat !== null && b.seat === mySeat() && me.has;
|
|
2203
|
-
list.push({ slot: b.slot, seat: b.seat, x: own ? me.x : b.x, y: own ? me.y : b.y, h: own ? me.h : b.h, bot: b.bot, name: b.name, mine: isView(b.seat, b.bot) });
|
|
2204
|
-
}
|
|
2205
|
-
} else {
|
|
1348
|
+
{
|
|
2206
1349
|
for (const d of drawn.values()) {
|
|
2207
1350
|
const own = !net.watching && d.seat >= 0 && d.seat === net.seat;
|
|
2208
1351
|
const s = names.get(d.slot);
|
|
@@ -2405,7 +1548,7 @@ function arrival(t: number): void {
|
|
|
2405
1548
|
const view = [...heroes.values()].find((h) => h.mine) ?? null;
|
|
2406
1549
|
if (!modelAt && view?.char && !net.watching) { modelAt = t; try { performance.mark('hero:model'); } catch { /* old browser */ } }
|
|
2407
1550
|
if (playableAt) return;
|
|
2408
|
-
const stateIn = Boolean(round) && (
|
|
1551
|
+
const stateIn = Boolean(round) && (drawn.size > 0);
|
|
2409
1552
|
const dressed = view ? Boolean(view.char) : heroes.size > 0 && [...heroes.values()].every((h) => h.char);
|
|
2410
1553
|
if (!stateIn || (!dressed && performance.now() - BOOT_AT < 5000)) return;
|
|
2411
1554
|
playableAt = t;
|
|
@@ -2433,8 +1576,7 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; seat:
|
|
|
2433
1576
|
// scores: the play page's room button (and a server's pill beside it) sit at the top right (game.json screen.share's
|
|
2434
1577
|
// default): the scores start under that band, so the buttons never cover a score.
|
|
2435
1578
|
const scores = new Map<number, number>();
|
|
2436
|
-
|
|
2437
|
-
else for (const d of drawn.values()) scores.set(d.slot, d.score);
|
|
1579
|
+
for (const d of drawn.values()) scores.set(d.slot, d.score);
|
|
2438
1580
|
// A phone's board is compact (the top three, a short name and a small "bot" or "AI" tag after it; a bot's dot is
|
|
2439
1581
|
// hollow), so a game's own HUD at the top left (a basket, a timer) has room beside it. Every bot and AI says so.
|
|
2440
1582
|
const rows = [...list].sort((a, b) => (scores.get(b.slot) ?? 0) - (scores.get(a.slot) ?? 0)).slice(0, phone ? 3 : 6);
|
|
@@ -2534,11 +1676,9 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; seat:
|
|
|
2534
1676
|
let lastT = -1;
|
|
2535
1677
|
function frame(t: number): void {
|
|
2536
1678
|
const dt = lab.time.dt(t, 0.05);
|
|
2537
|
-
// The look's own clock (the
|
|
1679
|
+
// The look's own clock (the animals' clips): real seconds, at most a tenth, whatever the rules' step.
|
|
2538
1680
|
const ddt = lastT < 0 ? 0 : Math.min(0.1, Math.max(0, (t - lastT) / 1000)); lastT = t;
|
|
2539
|
-
|
|
2540
|
-
if (hosting) stepHost(dt);
|
|
2541
|
-
else stepReplica(dt);
|
|
1681
|
+
updateView();
|
|
2542
1682
|
if (lab.on) labReport(dt);
|
|
2543
1683
|
draw(t, lab.on ? dt : ddt);
|
|
2544
1684
|
frames += 1;
|
|
@@ -2548,37 +1688,18 @@ function frame(t: number): void {
|
|
|
2548
1688
|
net.expose({
|
|
2549
1689
|
// Room chat's bubbles on this screen now: whose, what, and where (an end-to-end test reads them).
|
|
2550
1690
|
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) })),
|
|
2551
|
-
self: () => (me.has ? { x: me.x, y: me.y } : null),
|
|
1691
|
+
self: () => (me.has ? { x: me.x, y: me.y, z: me.h } : null),
|
|
2552
1692
|
peer: (seat: number) => {
|
|
2553
|
-
if (hosting) { const b = [...bodies.values()].find((x) => x.seat === seat); return b ? { x: b.x, y: b.y } : null; }
|
|
2554
1693
|
const d = [...drawn.values()].find((x) => x.seat === seat);
|
|
2555
|
-
return d ? { x: d.x, y: d.y } : null;
|
|
1694
|
+
return d ? { x: d.x, y: d.y, z: d.h } : null;
|
|
2556
1695
|
},
|
|
2557
1696
|
frames: () => frames,
|
|
2558
|
-
scores: () =>
|
|
2559
|
-
|
|
2560
|
-
* Host: gems each slot picked up this round, the round's age, and how each bot plays: the room's dial (`dial`), the
|
|
2561
|
-
* level it plays at this round (`level`: a level easier in a newcomer's first round) and the rubber band (`soft`, 0
|
|
2562
|
-
* at its dial to 1 all the way soft). `newcomer`: somebody is in their first round here.
|
|
2563
|
-
*/
|
|
2564
|
-
pickups: () => (hosting ? {
|
|
2565
|
-
round: round ? { n: round.n, phase: round.phase, ageMs: net.now() - round.startedAt } : null,
|
|
2566
|
-
newcomer: newcomerRound(people()),
|
|
2567
|
-
slots: [...bodies.values()].map((b) => ({ slot: b.slot, bot: b.bot, gems: pickups.get(b.slot) ?? 0, dial: b.bot ? net.skillOf(b.slot).level : null, level: b.bot ? (botPlay.get(b.slot)?.level ?? net.skillOf(b.slot).level) : null, soft: b.bot ? +(botPlay.get(b.slot)?.soft ?? 0).toFixed(2) : null })),
|
|
2568
|
-
} : null),
|
|
2569
|
-
/** Where the coins are now (rules metres): what a harness's coin-chasing player steers by. */
|
|
2570
|
-
gems: () => (hosting ? gems.map((g) => ({ id: g.id, x: g.x, y: g.y })) : (net.latest()?.d.g ?? []).map(([id, x, y]) => ({ id, x, y }))),
|
|
2571
|
-
/** Host: when each bot last looked (its reaction clock). */
|
|
2572
|
-
sight: () => (hosting ? [...sight.entries()].map(([slot, e]) => ({ slot, at: e.at })) : null),
|
|
1697
|
+
scores: () => [...drawn.values()].map(d => ({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, bot: d.seat < 0, score: d.score })),
|
|
1698
|
+
pickups: () => { const slots: unknown[] = []; room.each('runner', e => slots.push({ slot: e.seat, bot: e.driver === 'bot', gems: e.pickups })); return { round, slots }; },
|
|
2573
1699
|
waves: () => wavesSeen,
|
|
2574
|
-
swings: () => wavesSeen,
|
|
2575
1700
|
knocks: () => knocksSeen,
|
|
2576
1701
|
zone: () => zone,
|
|
2577
|
-
|
|
2578
|
-
cheatResets: () => cheatResets,
|
|
2579
|
-
movement: () => MOVEMENT,
|
|
2580
|
-
controlResets: () => controlResets,
|
|
2581
|
-
predictionError: () => predictionError,
|
|
1702
|
+
movement: () => 'server',
|
|
2582
1703
|
/** Where the camera looks (rules metres), how far away it is, its turn, and whose view it is. */
|
|
2583
1704
|
camera: () => ({ x: view3.x, y: view3.y, dist: view3.dist, yaw: view3.yaw, view: viewSeat() }),
|
|
2584
1705
|
/** The names as drawn (boxes, never the text): the e2e probe counts overlaps and checks your own. */
|
|
@@ -2589,39 +1710,25 @@ net.expose({
|
|
|
2589
1710
|
drawCalls: () => renderer.info.render.calls,
|
|
2590
1711
|
triangles: () => renderer.info.render.triangles,
|
|
2591
1712
|
/** The models: how many loaded, their triangles, texture memory and bytes, and any refused (with why). */
|
|
2592
|
-
models: () => ({ ...models.stats(), standIns: [...standIns], heroes: [...heroes.values()].map(
|
|
2593
|
-
|
|
1713
|
+
models: () => ({ ...models.stats(), standIns: [...standIns], heroes: [...heroes.values()].map(h => ({ slot: h.slot, state: h.char?.state })) }),
|
|
1714
|
+
|
|
2594
1715
|
heroes: () => [...heroes.values()].map((h) => ({ slot: h.slot, hero: h.kind, state: h.char?.state ?? 'LOADING', verbs: h.char?.verbs ?? [], lod: h.char?.lod ?? null, family: h.char?.family ?? null })),
|
|
2595
1716
|
jumps: () => jumpsSeen,
|
|
2596
1717
|
dodges: () => dodgesSeen,
|
|
2597
1718
|
/** Harness hooks for this screen's own player: jump, swing. */
|
|
2598
|
-
debugJump: () => { jump(); return
|
|
1719
|
+
debugJump: () => { const accepted = Boolean(room.me?.grounded); jump(); return accepted; },
|
|
2599
1720
|
debugSwing: () => { swing(); return true; },
|
|
2600
|
-
|
|
2601
|
-
debugKnock: (seat: number) => {
|
|
2602
|
-
if (!hosting) return false;
|
|
2603
|
-
const b = [...bodies.values()].find((x) => x.seat === seat);
|
|
2604
|
-
if (!b) return false;
|
|
2605
|
-
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)
|
|
2606
|
-
return true;
|
|
2607
|
-
},
|
|
1721
|
+
|
|
2608
1722
|
});
|
|
2609
1723
|
|
|
2610
1724
|
exposePort(net, {
|
|
2611
1725
|
view: 'top',
|
|
2612
1726
|
self: () => (me.has && mySeat() !== null ? { x: me.x, y: me.y } : null),
|
|
2613
1727
|
size: R_AV,
|
|
2614
|
-
score: () => { const seat = mySeat(); const b =
|
|
1728
|
+
score: () => { const seat = mySeat(); const b = [...drawn.values()].find((x) => x.seat === seat); return b ? b.score : null; },
|
|
2615
1729
|
// The renderer's own counters, by the names `homie-studio perf` and the playtest read on the port probe (measured
|
|
2616
1730
|
// scene cost while playing; port/probe.ts PortExtra). Nobody dies in this game, so there is no `alive` to say.
|
|
2617
1731
|
extra: { drawCalls: () => renderer.info.render.calls, triangles: () => renderer.info.render.triangles },
|
|
2618
1732
|
});
|
|
2619
1733
|
|
|
2620
|
-
void net.ready.then(() => {
|
|
2621
|
-
const seat = mySeat();
|
|
2622
|
-
if (seat !== null && hosting) {
|
|
2623
|
-
const b = [...bodies.values()].find((x) => x.seat === seat);
|
|
2624
|
-
if (b) { me.x = b.x; me.y = b.y; me.has = true; }
|
|
2625
|
-
}
|
|
2626
|
-
});
|
|
2627
1734
|
requestAnimationFrame(frame);
|