@homie-rocks/studio 0.15.0 → 0.16.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -4
- package/bin/homie-studio.mjs +59 -3
- package/lib/build.mjs +45 -0
- package/lib/office.mjs +7 -6
- package/lib/scaffold.mjs +41 -2
- package/lib/servers.mjs +133 -0
- package/lib/template-history.json +56 -0
- package/netplay/NETPLAY.md +207 -17
- package/netplay/netplay.ts +360 -30
- package/package.json +1 -1
- package/port/bots.ts +25 -2
- package/port/index.ts +5 -2
- package/port/room.ts +33 -10
- package/port/skill.ts +28 -0
- package/site/SITE.md +49 -0
- package/starters/ember-vale/src/main.ts +21 -7
- package/starters/gem-rush/src/main.ts +88 -20
- package/worker/account-page.mjs +26 -0
- package/worker/agents.mjs +238 -0
- package/worker/index.mjs +307 -74
- package/worker/office-page.mjs +414 -12
- package/worker/office.mjs +294 -25
- package/worker/pages.mjs +226 -17
- package/worker/players.mjs +11 -3
- package/worker/room.mjs +491 -44
- package/worker/saves.mjs +4 -0
- package/worker/servers.mjs +445 -0
- package/worker/site.mjs +138 -7
- package/worker/stats.mjs +6 -1
- package/worker/version.mjs +1 -1
package/port/bots.ts
CHANGED
|
@@ -13,7 +13,11 @@
|
|
|
13
13
|
* - They are kind to newcomers: no bot targets a person in their first
|
|
14
14
|
* ~15 s, and a person's first minute is never a pile-on.
|
|
15
15
|
* - A body a person left keeps playing as a bot, where it stands.
|
|
16
|
+
* - They play at the room's skill dial (NETPLAY.md section 17): give BotBrain
|
|
17
|
+
* `skill: () => room.skillOf(body)` and the party's vote changes how fast it
|
|
18
|
+
* reacts, how well it aims and how long it commits, with no other change.
|
|
16
19
|
*/
|
|
20
|
+
import type { Skill } from '../netplay/netplay';
|
|
17
21
|
|
|
18
22
|
export interface V2 { x: number; y: number }
|
|
19
23
|
|
|
@@ -45,9 +49,20 @@ export function nearest<T extends V2>(from: V2, items: Iterable<T>, taken?: Set<
|
|
|
45
49
|
return best;
|
|
46
50
|
}
|
|
47
51
|
|
|
52
|
+
/**
|
|
53
|
+
* What a skill level means to a bot's brain (section 17): it reacts `reactionMs` late, its aim errs by
|
|
54
|
+
* `aimNoise x 0.8` radians, and it commits to a target for `2500 x (1.3 - 0.6 x aggression)` ms (an aggressive bot
|
|
55
|
+
* changes its mind sooner). Level 3, Fair, is 250 ms, 0.12 rad and 2.5 s: what every port's bots always were.
|
|
56
|
+
*/
|
|
57
|
+
export function brainOf(s: Skill): { reactionMs: number; aimError: number; commitMs: number } {
|
|
58
|
+
return { reactionMs: s.reactionMs, aimError: s.aimNoise * 0.8, commitMs: 2500 * (1.3 - 0.6 * s.aggression) };
|
|
59
|
+
}
|
|
60
|
+
|
|
48
61
|
/**
|
|
49
62
|
* A bot's intent with a person's limits: it sees the world `reactionMs` late, commits to a target for `commitMs`,
|
|
50
63
|
* and notices when it is stuck. Call `think(now, pos, choose)` every frame; it returns the stick to push.
|
|
64
|
+
* `skill` (a Skill, or a function that says the current one): the room's dial sets reaction, aim and commitment;
|
|
65
|
+
* explicit `reactionMs`, `commitMs` or `aimError` still win.
|
|
51
66
|
*/
|
|
52
67
|
export class BotBrain<T extends V2 = V2> {
|
|
53
68
|
target: T | null = null;
|
|
@@ -56,10 +71,18 @@ export class BotBrain<T extends V2 = V2> {
|
|
|
56
71
|
private unstickUntil = 0;
|
|
57
72
|
private unstick: V2 = { x: 0, y: 0 };
|
|
58
73
|
private queue: { at: number; v: V2 }[] = [];
|
|
59
|
-
constructor(readonly opts: { reactionMs?: number; commitMs?: number; stuckMs?: number; stuckDist?: number; aimError?: number } = {}) {}
|
|
74
|
+
constructor(readonly opts: { reactionMs?: number; commitMs?: number; stuckMs?: number; stuckDist?: number; aimError?: number; skill?: Skill | (() => Skill) } = {}) {}
|
|
75
|
+
|
|
76
|
+
/** The dial this brain plays at now (Fair with none). */
|
|
77
|
+
skill(): Skill | null {
|
|
78
|
+
const k = this.opts.skill;
|
|
79
|
+
try { return typeof k === 'function' ? k() : k ?? null; } catch { return null; }
|
|
80
|
+
}
|
|
60
81
|
|
|
61
82
|
think(now: number, pos: V2, choose: () => T | null, arrive = 0): V2 {
|
|
62
|
-
const
|
|
83
|
+
const sk = this.skill();
|
|
84
|
+
const dial = sk ? brainOf(sk) : { reactionMs: 250, commitMs: 2500, aimError: 0.12 };
|
|
85
|
+
const { reactionMs = dial.reactionMs, commitMs = dial.commitMs, stuckMs = 3000, stuckDist = 24, aimError = dial.aimError } = this.opts;
|
|
63
86
|
if (!this.target || now - this.chosenAt > commitMs) { this.target = choose(); this.chosenAt = now; }
|
|
64
87
|
if (!this.lastCheck || now - this.lastCheck.at > stuckMs) {
|
|
65
88
|
if (this.lastCheck && Math.hypot(pos.x - this.lastCheck.x, pos.y - this.lastCheck.y) < stuckDist && this.target) {
|
package/port/index.ts
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* exposePort what `homie-studio port check` reads (owner tests)
|
|
12
12
|
* createHud a minimal clock / scores / results overlay
|
|
13
13
|
* BotBrain, seek, nearest, rubberBand
|
|
14
|
-
* bot scaffolds
|
|
14
|
+
* bot scaffolds; `skill` makes a bot play at the room's dial (NETPLAY.md section 17)
|
|
15
|
+
* jitter, engages, standoff
|
|
16
|
+
* the dial for the rest of a bot's decisions (port/skill.ts)
|
|
15
17
|
* createSaves player accounts and cloud saves (a character that lasts days, on every device; saves/SAVES.md)
|
|
16
18
|
* and everything from @homie-rocks/studio/netplay.
|
|
17
19
|
*/
|
|
@@ -27,5 +29,6 @@ export { groundBasis, screenToGround, PlayerYaw, type GroundBasis, type CameraLi
|
|
|
27
29
|
export { exposePort, type PortProbeOptions, type View } from './probe';
|
|
28
30
|
export { createRoom, type Room, type RoomOptions, type BodyBase, type RoomSnap, type RoomCkpt } from './room';
|
|
29
31
|
export { createHud, type HudOptions } from './hud';
|
|
30
|
-
export { BotBrain, seek, flee, wanderer, nearest, rubberBand, protectedNewcomer, type V2 } from './bots';
|
|
32
|
+
export { BotBrain, brainOf, seek, flee, wanderer, nearest, rubberBand, protectedNewcomer, type V2 } from './bots';
|
|
33
|
+
export { jitter, engages, standoff } from './skill';
|
|
31
34
|
export { createSaves, SAVES_VERSION, type Saves, type SavesOptions, type Player, type SaveEntry, type SetResult, type Memorial, type SavesStatus, type Conflict } from '../saves/saves';
|
package/port/room.ts
CHANGED
|
@@ -17,15 +17,21 @@
|
|
|
17
17
|
* - Snapshots: one small row per body [slot, seat (-1 = bot), score, ...your
|
|
18
18
|
* fields], plus your fast world, at 20 Hz; replicas interpolate them.
|
|
19
19
|
*
|
|
20
|
+
* - Servers and agent seats (NETPLAY.md section 17): a hybrid server's AI
|
|
21
|
+
* seats are kept as AI bodies (the game's bots move them, marked AI), an AI
|
|
22
|
+
* that sits takes one, the server's `bots: 'off'` adds no filler bots, and
|
|
23
|
+
* `room.skillOf(body)` is the dial a bot plays at (the party votes it).
|
|
24
|
+
*
|
|
20
25
|
* What stays the game's: the rules (what a body does per frame), the bots'
|
|
21
26
|
* brains, the drawing, and the camera. See the port skill's recipe.
|
|
22
27
|
*/
|
|
23
28
|
import {
|
|
24
29
|
capMove, createNetplay, lerp, lerpAngle, Roster,
|
|
25
|
-
type Movement, type Netplay, type NetplayOptions, type RoleChange, type RoundInfo, type RoundResult, type Slot, type Snapshot,
|
|
30
|
+
type Movement, type Netplay, type NetplayOptions, type Policy, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot,
|
|
26
31
|
} from '../netplay/netplay';
|
|
27
32
|
|
|
28
|
-
|
|
33
|
+
/** A body. `agent` (revision 6): this body is an AI's (a seat kept for AI, or an agent's), always marked AI. */
|
|
34
|
+
export interface BodyBase { slot: number; seat: number | null; name: string; bot: boolean; score: number; agent?: Slot['agent'] }
|
|
29
35
|
|
|
30
36
|
export interface RoomSnap<F = unknown> { r: [n: number, phase: number, startedAt: number, endsAt: number]; b: number[][]; w?: F }
|
|
31
37
|
export interface RoomCkpt<B, S> { round: RoundInfo | null; roster: Slot[]; bodies: B[]; world: S | null; tick: number }
|
|
@@ -91,6 +97,8 @@ export interface Room<B extends BodyBase, F = unknown, S = unknown> {
|
|
|
91
97
|
viewSeat(): number | null;
|
|
92
98
|
/** The body of viewSeat() as everyone draws it (host: the real one; others: interpolated), or null. */
|
|
93
99
|
viewBody(): B | null;
|
|
100
|
+
/** The skill dial a bot body plays at now (section 17): the party's vote, a guide's server level, else Fair. */
|
|
101
|
+
skillOf(body: { slot: number }): Skill;
|
|
94
102
|
/** Host: my own body. Replica: null (draw yours from your local state). */
|
|
95
103
|
mine(): B | null;
|
|
96
104
|
/** Host, every frame: the round clock and the snapshot. Replica: no-op. Call AFTER your rules moved the bodies. */
|
|
@@ -131,7 +139,9 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
131
139
|
const angles = new Set(opts.angles ?? []);
|
|
132
140
|
const discrete = new Set(opts.discrete ?? []);
|
|
133
141
|
|
|
134
|
-
|
|
142
|
+
// The roster keeps the server's AI seats (section 17); the policy is the room's, read when it fills.
|
|
143
|
+
const policy = (): Policy => net.policy;
|
|
144
|
+
let roster = new Roster({ min, max, botName, policy });
|
|
135
145
|
let bodies = new Map<number, B>();
|
|
136
146
|
let round: RoundInfo | null = null;
|
|
137
147
|
let hosting = false;
|
|
@@ -142,6 +152,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
142
152
|
snapshotHz: 20, inputHz: 20, checkpointMs: 1000,
|
|
143
153
|
checkpoint: () => ({ round, roster: roster.toJSON(), bodies: [...bodies.values()].map((b) => ({ ...b })), world: opts.saveWorld ? opts.saveWorld() : null, tick }),
|
|
144
154
|
...(opts.netplay ?? {}),
|
|
155
|
+
// createRoom claims an AI's slot for it (its join passes `p.agent`), so its host can move an AI's body.
|
|
156
|
+
caps: [...new Set([...(opts.netplay?.caps ?? []), 'agents' as const])],
|
|
145
157
|
});
|
|
146
158
|
|
|
147
159
|
const mySeat = (): number | null => (net.offline ? 0 : net.seat);
|
|
@@ -157,8 +169,9 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
157
169
|
for (const s of roster.slots) {
|
|
158
170
|
seen.add(s.slot);
|
|
159
171
|
const b = bodies.get(s.slot);
|
|
160
|
-
if (!b) { const nb = opts.spawn(s, i++); nb.slot = s.slot; nb.seat = s.seat; nb.name = s.name; nb.bot = s.bot; nb.score = nb.score ?? 0; bodies.set(s.slot, nb); continue; }
|
|
172
|
+
if (!b) { const nb = opts.spawn(s, i++); nb.slot = s.slot; nb.seat = s.seat; nb.name = s.name; nb.bot = s.bot; nb.score = nb.score ?? 0; if (s.agent) nb.agent = { ...s.agent }; bodies.set(s.slot, nb); continue; }
|
|
161
173
|
b.seat = s.seat; b.name = s.name; b.bot = s.bot;
|
|
174
|
+
if (s.agent) b.agent = { ...s.agent }; else delete b.agent;
|
|
162
175
|
}
|
|
163
176
|
for (const k of [...bodies.keys()]) if (!seen.has(k)) bodies.delete(k);
|
|
164
177
|
}
|
|
@@ -185,6 +198,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
185
198
|
for (const s of [...roster.slots].sort((a, c) => a.slot - c.slot)) {
|
|
186
199
|
const fresh = opts.spawn(s, i++);
|
|
187
200
|
fresh.slot = s.slot; fresh.seat = s.seat; fresh.name = s.name; fresh.bot = s.bot; fresh.score = 0;
|
|
201
|
+
if (s.agent) fresh.agent = { ...s.agent };
|
|
188
202
|
bodies.set(s.slot, fresh);
|
|
189
203
|
}
|
|
190
204
|
round = { n, phase: 'live', startedAt: now, endsAt: now + roundMs };
|
|
@@ -197,7 +211,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
197
211
|
|
|
198
212
|
function rank(): RoundResult[] {
|
|
199
213
|
const ranked = [...bodies.values()].sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot) || a.slot - b.slot);
|
|
200
|
-
return ranked.map((b, i) => ({ slot: b.slot, seat: b.seat, name: b.name, score: b.score, bot: b.bot, place: i + 1 }));
|
|
214
|
+
return ranked.map((b, i) => ({ slot: b.slot, seat: b.seat, name: b.name, score: b.score, bot: b.bot, place: i + 1, ...(b.agent ? { agent: true as const } : {}) }));
|
|
201
215
|
}
|
|
202
216
|
|
|
203
217
|
function endRound(): void {
|
|
@@ -212,12 +226,12 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
212
226
|
function restore(e: RoleChange<RoomSnap<F>, RoomCkpt<B, S>>): void {
|
|
213
227
|
const ck = e.ckpt?.d ?? null;
|
|
214
228
|
if (ck) {
|
|
215
|
-
roster = Roster.from(ck.roster, { min, max, botName });
|
|
229
|
+
roster = Roster.from(ck.roster, { min, max, botName, policy });
|
|
216
230
|
bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
|
|
217
231
|
round = ck.round;
|
|
218
232
|
tick = ck.tick;
|
|
219
233
|
} else {
|
|
220
|
-
roster = Roster.from(e.roster ?? [], { min, max, botName });
|
|
234
|
+
roster = Roster.from(e.roster ?? [], { min, max, botName, policy });
|
|
221
235
|
bodies = new Map();
|
|
222
236
|
syncBodies();
|
|
223
237
|
}
|
|
@@ -252,14 +266,14 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
252
266
|
hosting = true;
|
|
253
267
|
if (e.promoted) restore(e);
|
|
254
268
|
else {
|
|
255
|
-
roster = new Roster({ min, max, botName });
|
|
269
|
+
roster = new Roster({ min, max, botName, policy });
|
|
256
270
|
bodies = new Map();
|
|
257
271
|
const seat = mySeat();
|
|
258
272
|
if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
|
|
259
273
|
syncBodies();
|
|
260
274
|
startRound((e.round?.n ?? 0) + 1);
|
|
261
275
|
}
|
|
262
|
-
const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null);
|
|
276
|
+
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 }));
|
|
263
277
|
const { claimed } = roster.reconcile(peers);
|
|
264
278
|
syncBodies();
|
|
265
279
|
for (const s of claimed) { const b = bodies.get(s.slot); if (b && b.seat !== mySeat()) moved(b); }
|
|
@@ -274,7 +288,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
274
288
|
});
|
|
275
289
|
net.on('join', (p) => {
|
|
276
290
|
if (!hosting || p.seat === null) return;
|
|
277
|
-
|
|
291
|
+
// An AI takes a seat kept for AI (its hands say who moves the body); a person never does (section 17).
|
|
292
|
+
const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null);
|
|
278
293
|
if (!c) return; // full: they watch
|
|
279
294
|
syncBodies();
|
|
280
295
|
const b = bodies.get(c.slot.slot);
|
|
@@ -291,6 +306,13 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
291
306
|
syncBodies();
|
|
292
307
|
net.roster(roster.toJSON());
|
|
293
308
|
});
|
|
309
|
+
// The server's policy changed (a new server stamp, the owner): its AI seats come or go at once, filler at the round.
|
|
310
|
+
net.on('policy', () => {
|
|
311
|
+
if (!hosting) return;
|
|
312
|
+
roster.fill();
|
|
313
|
+
syncBodies();
|
|
314
|
+
net.roster(roster.toJSON());
|
|
315
|
+
});
|
|
294
316
|
net.on('control', (e) => {
|
|
295
317
|
if (!e.reset || !opts.adopt) return;
|
|
296
318
|
const snap = e.snap as Snapshot<RoomSnap<F>> | null;
|
|
@@ -335,6 +357,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
335
357
|
for (const b of room.view()) if (!b.bot && b.seat === s) return b;
|
|
336
358
|
return null;
|
|
337
359
|
},
|
|
360
|
+
skillOf: (body) => net.skillOf(body.slot),
|
|
338
361
|
mine: () => (hosting ? bodyOfSeat(mySeat()) : null),
|
|
339
362
|
update() {
|
|
340
363
|
if (!hosting) return;
|
package/port/skill.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The skill dial for a port's own bot code (NETPLAY.md section 17). A level is { reactionMs, aimNoise, aggression,
|
|
3
|
+
* positioning }; `room.skillOf(body)` (or `net.skillOf(slot)`) says which one a bot plays at now. BotBrain reads
|
|
4
|
+
* reaction, aim and commitment by itself; these three are for the rest of a bot's decisions:
|
|
5
|
+
*
|
|
6
|
+
* jitter(s, maxPx) how far off its aim lands (aimNoise of maxPx, either way)
|
|
7
|
+
* engages(s, dt) whether it starts a fight this frame (aggression, about once a second at most)
|
|
8
|
+
* standoff(s, near, far) how close to the action it stands: far at positioning 0, near at 1
|
|
9
|
+
*
|
|
10
|
+
* Level 1 (Rookie) stays at the back and misses a lot; 5 (Maxed) is a front-line tank that rarely misses.
|
|
11
|
+
*/
|
|
12
|
+
import type { Skill } from '../netplay/netplay';
|
|
13
|
+
|
|
14
|
+
/** An aim offset: up to `maxPx x aimNoise` either way (0 at a perfect aim). */
|
|
15
|
+
export function jitter(s: Skill, maxPx: number): number {
|
|
16
|
+
return (Math.random() - 0.5) * 2 * maxPx * Math.max(0, Math.min(1, s.aimNoise));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Whether a bot starts a fight (or a wave, a shot, a tackle) this frame: about `aggression` times a second. */
|
|
20
|
+
export function engages(s: Skill, dt: number): boolean {
|
|
21
|
+
return Math.random() < Math.max(0, Math.min(1, s.aggression)) * Math.max(0, dt);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** How far from the action to stand: `far` at positioning 0 (behind the party), `near` at 1 (the front line). */
|
|
25
|
+
export function standoff(s: Skill, near: number, far: number): number {
|
|
26
|
+
const t = Math.max(0, Math.min(1, s.positioning));
|
|
27
|
+
return far + (near - far) * t;
|
|
28
|
+
}
|
package/site/SITE.md
CHANGED
|
@@ -157,6 +157,8 @@ where the game has room, per device:
|
|
|
157
157
|
- It shows the room's name ("Room 7") for a few seconds, then shrinks to a round icon until it is touched;
|
|
158
158
|
`"label": false` keeps it the icon always (the code is in its sheet), for a corner with little room.
|
|
159
159
|
- A button at the bottom left sends the "finding a room" chip to the bottom right.
|
|
160
|
+
- On a server, the server's pill sits beside the button, on its inner side in the same band, never under it: on a
|
|
161
|
+
phone held upright it is one more dot; elsewhere its name shows for a few seconds, then it is a dot too.
|
|
160
162
|
|
|
161
163
|
Look at the play page on a computer and a phone (both ways up) once a round is on: the button must not sit on a
|
|
162
164
|
score, a timer or a bar. A room code the relay cannot use (1 to 32 letters, digits, `-` or `_`) is refused
|
|
@@ -192,6 +194,53 @@ The page is the game itself, drawn by the watcher's own browser as a watcher: a
|
|
|
192
194
|
that room as the whole room only, so a second tab is not a peek.
|
|
193
195
|
- Each opening counts as a `watch` in the studio's stats.
|
|
194
196
|
|
|
197
|
+
## Servers: `/<id>/servers/` and `/<id>/s/<server>/`
|
|
198
|
+
|
|
199
|
+
A game's **servers** are named, lasting pools of rooms with their own rules (`worker/servers.mjs`; NETPLAY.md
|
|
200
|
+
section 17 for the room's side). Strangers are matched only inside one server. The game's own public rooms are
|
|
201
|
+
its **Quick play** server (`public`), so every old link keeps working.
|
|
202
|
+
|
|
203
|
+
- **The landing's Servers band** shows when the game has a server besides Quick play: a card per server (its
|
|
204
|
+
name, its policy's badge and one line, who is on it now: "9 playing · 4 AI · 41 members", Play and About),
|
|
205
|
+
and "Quick play →" under it. `/<id>/servers/` lists them all (and every policy's line). A server that is
|
|
206
|
+
closed, not listed, or has a door (an account or an invite) is left out of the Rooms lists and `/api/rooms`.
|
|
207
|
+
- **The badge and its line**, everywhere a server is shown: *Open* ("anyone can play. AI players are always
|
|
208
|
+
marked AI."), *Humans only* ("every player here is a person. AI can't join."), *Hybrid · N* ("N seats in every
|
|
209
|
+
room are AI companions, always marked AI. Your party sets their level."), *Beginner* ("for new players. AI guides
|
|
210
|
+
help you learn. Chat is quick lines only."); a kids server adds "Players have handles, not names."
|
|
211
|
+
- **A server's page** (`/<id>/s/<server>/`): its name, blurb, badge and line; its live rooms (AI marked); how many
|
|
212
|
+
belong to it; "☆ Make this my home" for a signed-in player (and Leave); and a big **Play**
|
|
213
|
+
(`/<id>/s/<server>/play`). A player's home server is where a bare `/<id>/play` takes them; their account page
|
|
214
|
+
lists their servers.
|
|
215
|
+
- **The door**, then the play page: *open* lets anyone in; *accounts* asks the visitor to sign in with a passkey
|
|
216
|
+
("Sign in to play on Night Shift"); *invite* shows the invite-code box (`homie-studio office invite <id> --server
|
|
217
|
+
<server>` makes codes and links for one server); *beginner* lets in guests and accounts younger than its days
|
|
218
|
+
(a veteran is told "First Light is for new players… try another server or Quick play"; a mentor gets in, with a
|
|
219
|
+
Mentor badge). A server whose rooms are all full (its `rooms`) sends the visitor to its fullest room, where they
|
|
220
|
+
wait for a seat ("First Light is full. You're next for a seat."). The owner always comes in. The watch door is
|
|
221
|
+
the same door, except that watching a beginner room needs no new account.
|
|
222
|
+
- **When the owner hides Quick play** (`homie-studio servers close <id> public`), Play shows the servers to pick
|
|
223
|
+
from, and a named `?room=` is off (except for the owner).
|
|
224
|
+
- **The play shell** on a server: a pill beside the room button, in the same band at the room button's place
|
|
225
|
+
(`screen.share`), never under it ("First Light · Beginner", then a dot after a few seconds; on a phone held
|
|
226
|
+
upright, the dot from the start; tap: the server's name, the policy's line, "AI level: Steady" with **Change**
|
|
227
|
+
when the game's bots read the dial, and **Quiet AI**, which hides AI chat on this screen only). A server room's
|
|
228
|
+
button says "Room 2", as Quick play's does. The chip says "First Light · 4 playing · 2 AI"; every AI row and result
|
|
229
|
+
carries an AI pill. A game's HUD keeps that band clear (Gem Rush starts its scores under it).
|
|
230
|
+
- **The vote card** ("How strong should the AI be?", five levels, "the middle vote wins") opens by itself at a
|
|
231
|
+
party's first round with AI seats, from the pill's Change, or from the game; a lone player's tap decides at once,
|
|
232
|
+
every seated person's vote closes it early, else 15 s. Keys 1-5 on a computer; a bottom sheet on a phone. The card
|
|
233
|
+
is opaque: nothing of the game shows through it. The
|
|
234
|
+
result is a toast ("The party set the AI to Steady (3 votes)."). A game that draws its own card says game.json
|
|
235
|
+
`"agents": { "vote": "game" }` (`false`: none).
|
|
236
|
+
- `/<id>/api/servers` is the servers with their live counts (cacheable 15 s). `/api/rooms` rows say their
|
|
237
|
+
`server`, `policy` and `ai`.
|
|
238
|
+
|
|
239
|
+
**An AI's seat** (an agent pass from `homie-studio agents pass`): `POST /<id>/api/agent` with `Authorization:
|
|
240
|
+
Bearer <pass>` and `{ "server": "<id>" }` answers a room with people in it (never an empty one), a ticket, its
|
|
241
|
+
socket and (an AI that runs the game itself) the frame to load. A humans-only server answers 403 "This server is
|
|
242
|
+
for humans only", and so does its socket.
|
|
243
|
+
|
|
195
244
|
## Player accounts: `/account/`
|
|
196
245
|
|
|
197
246
|
A studio whose games keep saves (game.json `"saves": true`, [../saves/SAVES.md](../saves/SAVES.md)) has a player
|
|
@@ -18,8 +18,12 @@
|
|
|
18
18
|
* A watcher (/<game>/watch, contract revision 5) has no hero and makes none: `room.viewSeat()` is the hero it
|
|
19
19
|
* follows, drawn in gold as a hero's own browser draws itself, with that hero's panel; nobody followed, the vale.
|
|
20
20
|
* The host says who slew what (`slain`), so a watcher on Auto cuts to the kill.
|
|
21
|
+
*
|
|
22
|
+
* Servers and the skill dial (contract revision 6): the vale's bots hunt at the room's dial (`room.skillOf(body)`:
|
|
23
|
+
* how soon they notice a slime, how close they stand, how often they strike), and a beginner server's AI guide seats
|
|
24
|
+
* are kept as AI bodies, marked " · AI". createRoom does the seats; the bots below read the dial.
|
|
21
25
|
*/
|
|
22
|
-
import { createControls, createRoom, createSaves, exposePort, q, type BodyBase, type NetEvent } from '@homie-rocks/studio/port';
|
|
26
|
+
import { AI_MARK, createControls, createRoom, createSaves, exposePort, jitter, q, standoff, type BodyBase, type NetEvent } from '@homie-rocks/studio/port';
|
|
23
27
|
|
|
24
28
|
/* ------------------------------------------------------------------ rules */
|
|
25
29
|
const W = 1600;
|
|
@@ -39,7 +43,7 @@ const clamp = (v: number, a: number, b: number): number => Math.max(a, Math.min(
|
|
|
39
43
|
|
|
40
44
|
/** The hero: the one thing that lasts. Kept in the save key 'hero'. */
|
|
41
45
|
interface Hero { v: 1; name: string; level: number; xp: number; gold: number; hardcore: boolean; kills: number; deaths: number; born: number }
|
|
42
|
-
interface Body extends BodyBase { x: number; y: number; hp: number; maxHp: number; level: number; face: number; flags: number; downUntil: number; atkAt: number; strikeAt: number; tx: number; ty: number }
|
|
46
|
+
interface Body extends BodyBase { x: number; y: number; hp: number; maxHp: number; level: number; face: number; flags: number; downUntil: number; atkAt: number; strikeAt: number; tx: number; ty: number; seenAt?: number; ax?: number; ay?: number }
|
|
43
47
|
interface Slime { id: number; x: number; y: number; hp: number; maxHp: number; size: number; hitAt: number; lastHit: number }
|
|
44
48
|
type SlimeRow = [id: number, x: number, y: number, hp: number, size: number];
|
|
45
49
|
|
|
@@ -190,14 +194,23 @@ function stepHost(dt: number): void {
|
|
|
190
194
|
}
|
|
191
195
|
let wants = false;
|
|
192
196
|
if (b.bot) {
|
|
193
|
-
// Bots are company, not carries: they hunt only what is near them, and strike slower than a person.
|
|
197
|
+
// Bots are company, not carries: they hunt only what is near them, and strike slower than a person. At the
|
|
198
|
+
// room's dial (section 17): they notice a slime `reactionMs` late, hunt farther afield the more they lean to
|
|
199
|
+
// the front (positioning), aim a little off (aimNoise), and strike sooner the more aggressive they are.
|
|
200
|
+
const s = room.skillOf(b);
|
|
194
201
|
const near = nearestSlime(b.x, b.y);
|
|
195
|
-
const
|
|
202
|
+
const reach = standoff(s, 460, 220);
|
|
203
|
+
if (!b.seenAt || now - b.seenAt >= s.reactionMs) {
|
|
204
|
+
b.seenAt = now;
|
|
205
|
+
const t0 = near && Math.hypot(near.x - b.x, near.y - b.y) < reach ? near : null;
|
|
206
|
+
b.ax = t0 ? t0.x + jitter(s, 40) : undefined; b.ay = t0 ? t0.y + jitter(s, 40) : undefined;
|
|
207
|
+
}
|
|
208
|
+
const t = b.ax !== undefined && b.ay !== undefined ? { x: b.ax, y: b.ay } : null;
|
|
196
209
|
const tx = t ? t.x : b.tx; const ty = t ? t.y : b.ty;
|
|
197
210
|
if (!t && Math.hypot(b.tx - b.x, b.ty - b.y) < 30) { b.tx = 200 + Math.random() * (W - 400); b.ty = 150 + Math.random() * (H - 300); }
|
|
198
211
|
const dx = tx - b.x; const dy = ty - b.y; const dist = Math.hypot(dx, dy) || 1;
|
|
199
212
|
if (dist > 60) { b.x += (dx / dist) * SPEED * 0.75 * dt; b.y += (dy / dist) * SPEED * 0.75 * dt; b.face = Math.atan2(dy, dx); }
|
|
200
|
-
wants = Boolean(t && dist < STRIKE_RANGE && now - b.atkAt > 900);
|
|
213
|
+
wants = Boolean(t && dist < STRIKE_RANGE && now - b.atkAt > 1400 - 900 * s.aggression);
|
|
201
214
|
} else if (b.seat === myS) {
|
|
202
215
|
b.x = me.x; b.y = me.y; b.face = me.face; b.level = myLevel(); b.maxHp = maxHpOf(b.level);
|
|
203
216
|
wants = me.strikes > 0; me.strikes = 0;
|
|
@@ -326,7 +339,8 @@ function draw(t: number): void {
|
|
|
326
339
|
ctx.fillStyle = '#7ad35a'; ctx.fillRect(x - 24, y - R - 12, (48 * clamp(b.hp, 0, b.maxHp)) / Math.max(1, b.maxHp), 5);
|
|
327
340
|
ctx.globalAlpha = 1;
|
|
328
341
|
ctx.font = '600 18px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = self ? '#ffcf6e' : '#e7d9b4';
|
|
329
|
-
|
|
342
|
+
// An AI's name already ends in " · AI" (a guide, a companion); a plain bot says bot.
|
|
343
|
+
ctx.fillText(`${b.name}${b.bot && !b.name.endsWith(AI_MARK) ? ' · bot' : ''} · ${b.level}`, x, y - R - 18);
|
|
330
344
|
}
|
|
331
345
|
ctx.restore();
|
|
332
346
|
hud(t);
|
|
@@ -369,7 +383,7 @@ function hud(t: number): void {
|
|
|
369
383
|
ctx.fillStyle = 'rgba(10,14,9,.78)'; ctx.fillRect(cw / 2 - 150, 44, 300, 30 + rows.length * 22);
|
|
370
384
|
ctx.fillStyle = '#ffcf6e'; ctx.fillText('Tonight\'s hunters', cw / 2, 66);
|
|
371
385
|
ctx.font = '600 14px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
|
|
372
|
-
rows.forEach((r, i) => ctx.fillText(`${r.place}. ${r.name}${r.bot ? ' (bot)' : ''} — ${r.score} xp`, cw / 2, 90 + i * 22));
|
|
386
|
+
rows.forEach((r, i) => ctx.fillText(`${r.place}. ${r.name}${r.bot && !r.name.endsWith(AI_MARK) ? ' (bot)' : ''} — ${r.score} xp`, cw / 2, 90 + i * 22));
|
|
373
387
|
}
|
|
374
388
|
if (performance.now() < bannerUntil) { ctx.font = '800 22px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#ffcf6e'; ctx.fillText(bannerText, cw / 2, canvas.height / k - 90); }
|
|
375
389
|
const st = saves.status();
|
|
@@ -22,9 +22,16 @@
|
|
|
22
22
|
* nobody followed the whole arena shows. Waves call `net.spotlight(seat)`, so a
|
|
23
23
|
* watcher on Auto cuts to whoever just bumped.
|
|
24
24
|
*
|
|
25
|
+
* SERVERS AND THE SKILL DIAL (contract revision 6): the bots play at the room's
|
|
26
|
+
* dial (`net.skillOf(slot)`: how late they see, how well they aim, whether they
|
|
27
|
+
* fight for the hot zone, how often they bump), which the party votes in the
|
|
28
|
+
* play page; a hybrid server's AI seats are kept as AI bodies (the Roster's
|
|
29
|
+
* reserve, marked " · AI"), an AI with a pass takes one, and a humans-only
|
|
30
|
+
* server's bots are off. The game says so: `caps: ['skill', 'agents']`.
|
|
31
|
+
*
|
|
25
32
|
* Canvas 2D on purpose: the point is the contract, in ~700 readable lines.
|
|
26
33
|
*/
|
|
27
|
-
import { createNetplay, Roster, q, lerp, capMove, PALETTE, type RoleChange, type RoundInfo, type RoundResult, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
|
|
34
|
+
import { createNetplay, Roster, q, lerp, capMove, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
|
|
28
35
|
// The port toolkit's probe: what `homie-studio port check` reads for the owner tests (and sandbox + audio shims).
|
|
29
36
|
import { exposePort } from '@homie-rocks/studio/port';
|
|
30
37
|
|
|
@@ -48,7 +55,8 @@ const ZONE_MS = 12_000;
|
|
|
48
55
|
const colourOf = (slot: number, seat: number | null): string => PALETTE[(seat ?? slot) % PALETTE.length] as string;
|
|
49
56
|
const BOT_NAMES = ['Rook', 'Vex', 'Moth', 'Kilo', 'Juno', 'Pike', 'Nyx', 'Ash'];
|
|
50
57
|
const botName = (slot: number): string => BOT_NAMES[slot % BOT_NAMES.length] as string;
|
|
51
|
-
|
|
58
|
+
/** An AI's name already ends in " · AI" (the relay sees to it); a plain bot says bot. */
|
|
59
|
+
const label = (name: string, bot: boolean): string => (name.endsWith(AI_MARK) ? name : bot ? `${name} · bot` : name);
|
|
52
60
|
|
|
53
61
|
/* ------------------------------------------------------------ wire shapes */
|
|
54
62
|
/** Snapshot: compact arrays, quantized. ~40 B per body, ~16 B per gem. (Reset epochs ride in the helper's `c` table.) */
|
|
@@ -73,10 +81,13 @@ interface Ckpt { round: RoundInfo; bodies: Body[]; gems: Gem[]; roster: Slot[];
|
|
|
73
81
|
* Default `owner`: each browser moves its own body and the host bounds it.
|
|
74
82
|
*/
|
|
75
83
|
const MOVEMENT: 'owner' | 'host' = (() => { try { return new URLSearchParams(location.search).get('movement') === 'host' ? 'host' : 'owner'; } catch { return 'owner'; } })();
|
|
76
|
-
|
|
84
|
+
// caps: its bots read the dial ('skill'), and its Roster takes an AI's slot for it ('agents': the join passes p.agent).
|
|
85
|
+
const net = createNetplay<Snap, Avatar, Ckpt>({ game: 'gem-rush', maxPlayers: MAX_SLOTS, movement: MOVEMENT, snapshotHz: 20, inputHz: 20, checkpointMs: 1000, caps: ['skill', 'agents'], checkpoint: () => checkpoint() });
|
|
86
|
+
/** The Roster keeps the server's AI seats (revision 6): the policy is read whenever it fills. */
|
|
87
|
+
const policy = () => net.policy;
|
|
77
88
|
|
|
78
89
|
/* ------------------------------------------------------------ host state */
|
|
79
|
-
let roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName });
|
|
90
|
+
let roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
|
|
80
91
|
let bodies = new Map<number, Body>();
|
|
81
92
|
let gems: Gem[] = [];
|
|
82
93
|
let gemSeq = 0;
|
|
@@ -87,6 +98,8 @@ let hosting = false;
|
|
|
87
98
|
let cheatResets = 0;
|
|
88
99
|
let controlResets = 0;
|
|
89
100
|
let predictionError = 0;
|
|
101
|
+
/** Gems each slot picked up this round (a gem, whatever it scored): the dial's numbers (`pickups` probe). */
|
|
102
|
+
let pickups = new Map<number, number>();
|
|
90
103
|
|
|
91
104
|
/* ------------------------------------------------ this browser's avatar */
|
|
92
105
|
const me = { x: W / 2, y: H / 2, vx: 0, vy: 0, kvx: 0, kvy: 0, knockUntil: 0, has: false };
|
|
@@ -152,6 +165,8 @@ function startRound(n: number): void {
|
|
|
152
165
|
hostMoved(b);
|
|
153
166
|
}
|
|
154
167
|
gems = Array.from({ length: GEM_COUNT }, newGem);
|
|
168
|
+
pickups = new Map();
|
|
169
|
+
sight.clear();
|
|
155
170
|
round = { n, phase: 'live', startedAt: now, endsAt: now + ROUND_MS };
|
|
156
171
|
setZone(newZone((zone?.n ?? 0) + 1));
|
|
157
172
|
net.round(round);
|
|
@@ -163,7 +178,8 @@ function endRound(): void {
|
|
|
163
178
|
if (!round) return;
|
|
164
179
|
const now = net.now();
|
|
165
180
|
const ranked = [...bodies.values()].sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot) || a.slot - b.slot);
|
|
166
|
-
const
|
|
181
|
+
const agentSlot = (slot: number): boolean => Boolean(roster.slots.find((x) => x.slot === slot)?.agent);
|
|
182
|
+
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 } : {}) }));
|
|
167
183
|
round = { n: round.n, phase: 'over', startedAt: now, endsAt: now + BREAK_MS, results };
|
|
168
184
|
net.round(round);
|
|
169
185
|
}
|
|
@@ -173,7 +189,7 @@ function becomeHost(e: RoleChange<Snap, Ckpt>): void {
|
|
|
173
189
|
hosting = true;
|
|
174
190
|
if (e.promoted) restore(e);
|
|
175
191
|
else {
|
|
176
|
-
roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName });
|
|
192
|
+
roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
|
|
177
193
|
bodies = new Map();
|
|
178
194
|
const seat = mySeat();
|
|
179
195
|
if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
|
|
@@ -181,7 +197,7 @@ function becomeHost(e: RoleChange<Snap, Ckpt>): void {
|
|
|
181
197
|
startRound((e.round?.n ?? 0) + 1);
|
|
182
198
|
}
|
|
183
199
|
// Whoever is connected now is who plays: seats that left during the gap become bots.
|
|
184
|
-
const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null);
|
|
200
|
+
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 }));
|
|
185
201
|
const { claimed } = roster.reconcile(peers);
|
|
186
202
|
syncBodiesFromRoster();
|
|
187
203
|
for (const s of claimed) { const b = bodies.get(s.slot); if (b && b.seat !== mySeat()) hostMoved(b); }
|
|
@@ -198,14 +214,14 @@ function becomeHost(e: RoleChange<Snap, Ckpt>): void {
|
|
|
198
214
|
function restore(e: RoleChange<Snap, Ckpt>): void {
|
|
199
215
|
const ck = e.ckpt?.d ?? null;
|
|
200
216
|
if (ck) {
|
|
201
|
-
roster = Roster.from(ck.roster, { min: MIN_SLOTS, max: MAX_SLOTS, botName });
|
|
217
|
+
roster = Roster.from(ck.roster, { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
|
|
202
218
|
bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
|
|
203
219
|
gems = ck.gems.map((g) => ({ ...g }));
|
|
204
220
|
gemSeq = ck.gemSeq;
|
|
205
221
|
round = ck.round;
|
|
206
222
|
tick = ck.tick;
|
|
207
223
|
} else {
|
|
208
|
-
roster = Roster.from(e.roster ?? [], { min: MIN_SLOTS, max: MAX_SLOTS, botName });
|
|
224
|
+
roster = Roster.from(e.roster ?? [], { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
|
|
209
225
|
bodies = new Map();
|
|
210
226
|
syncBodiesFromRoster();
|
|
211
227
|
}
|
|
@@ -250,7 +266,8 @@ net.on('role', (e) => {
|
|
|
250
266
|
});
|
|
251
267
|
net.on('join', (p) => {
|
|
252
268
|
if (!hosting || p.seat === null) return;
|
|
253
|
-
|
|
269
|
+
// An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).
|
|
270
|
+
const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null);
|
|
254
271
|
if (!c) return; // full: they watch
|
|
255
272
|
syncBodiesFromRoster();
|
|
256
273
|
const b = bodies.get(c.slot.slot);
|
|
@@ -265,6 +282,8 @@ net.on('leave', (p) => {
|
|
|
265
282
|
syncBodiesFromRoster();
|
|
266
283
|
publishRoster();
|
|
267
284
|
});
|
|
285
|
+
// The server's policy changed: its AI seats come (or go between rounds) at once.
|
|
286
|
+
net.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });
|
|
268
287
|
net.on('event', (e) => {
|
|
269
288
|
if (e.k === 'wave') addWave(e.d as { slot: number });
|
|
270
289
|
if (e.k === 'knock') addWave(e.d as { slot: number }, true);
|
|
@@ -420,28 +439,69 @@ function stepKnocked(b: Body, dt: number): void {
|
|
|
420
439
|
b.vx = b.kvx; b.vy = b.kvy;
|
|
421
440
|
}
|
|
422
441
|
|
|
442
|
+
/**
|
|
443
|
+
* THE BOTS, AT THE ROOM'S DIAL (NETPLAY.md section 17). A bot re-reads the world only every `reactionMs` (what it
|
|
444
|
+
* last noticed is where it heads, and a miss costs it that long again), aims up to 200 px off at aimNoise 1, leaves
|
|
445
|
+
* the hot zone to the people at positioning 0 and fights for it at 1, and waves at a rival in reach about
|
|
446
|
+
* `aggression x 0.8` times a second. Rookie is beatable by anyone, Maxed by few: measured with the
|
|
447
|
+
* `pickups` probe over 60 s rounds, a Maxed bot picks up a little over twice the gems of a Rookie.
|
|
448
|
+
*/
|
|
449
|
+
const sight = new Map<number, { at: number; tx: number; ty: number; arrived?: boolean }>();
|
|
450
|
+
|
|
423
451
|
function stepBots(dt: number): void {
|
|
424
452
|
const taken = new Set<number>();
|
|
425
453
|
const now = net.now();
|
|
426
454
|
for (const b of bodies.values()) {
|
|
427
455
|
if (!b.bot) continue;
|
|
428
456
|
if (b.knockUntil > now) { stepKnocked(b, dt); continue; }
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const
|
|
433
|
-
|
|
457
|
+
const s: Skill = net.skillOf(b.slot); // Fair when nobody set a dial
|
|
458
|
+
let eye = sight.get(b.slot);
|
|
459
|
+
if (!eye || now - eye.at >= s.reactionMs) { // REACTION TIME
|
|
460
|
+
const g = pickGem(b, taken, s); // POSITIONING
|
|
461
|
+
const miss = 200 * s.aimNoise; // AIM NOISE: up to 200 px off at 1
|
|
462
|
+
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 };
|
|
463
|
+
sight.set(b.slot, eye);
|
|
464
|
+
if (g) taken.add(g.id);
|
|
434
465
|
}
|
|
435
|
-
|
|
436
|
-
const
|
|
466
|
+
b.tx = eye.tx; b.ty = eye.ty;
|
|
467
|
+
const rival = nearestBody(b, KNOCK_RANGE); // AGGRESSION: waves at a rival in reach
|
|
468
|
+
if (rival && round?.phase === 'live' && Math.random() < s.aggression * 0.8 * dt) hostWave(b);
|
|
469
|
+
const dx = b.tx - b.x; const dy = b.ty - b.y; const dist = Math.hypot(dx, dy);
|
|
470
|
+
const len = dist || 1;
|
|
471
|
+
// Arrived where it aimed: it stands there, and notices what it missed only a reaction later.
|
|
472
|
+
if (dist < 6 && !eye.arrived) { eye.arrived = true; eye.at = now; }
|
|
473
|
+
const speed = dist < 6 ? 0 : BOT_SPEED;
|
|
437
474
|
const k = Math.min(1, dt * 5);
|
|
438
|
-
b.vx += ((dx / len) *
|
|
439
|
-
b.vy += ((dy / len) *
|
|
475
|
+
b.vx += ((dx / len) * speed - b.vx) * k;
|
|
476
|
+
b.vy += ((dy / len) * speed - b.vy) * k;
|
|
440
477
|
b.x = Math.max(R_AV, Math.min(W - R_AV, b.x + b.vx * dt));
|
|
441
478
|
b.y = Math.max(R_AV, Math.min(H - R_AV, b.y + b.vy * dt));
|
|
442
479
|
}
|
|
443
480
|
}
|
|
444
481
|
|
|
482
|
+
/** Positioning: 0 leaves the hot zone to the people, 1 fights for it. */
|
|
483
|
+
function pickGem(b: Body, taken: Set<number>, s: Skill): Gem | null {
|
|
484
|
+
let best: Gem | null = null; let bd = Infinity;
|
|
485
|
+
for (const g of gems) {
|
|
486
|
+
if (taken.has(g.id)) continue;
|
|
487
|
+
const hot = zone !== null && Math.hypot(g.x - zone.x, g.y - zone.y) < zone.r;
|
|
488
|
+
const d = Math.hypot(g.x - b.x, g.y - b.y) * (hot ? 1.5 - s.positioning : 1);
|
|
489
|
+
if (d < bd) { bd = d; best = g; }
|
|
490
|
+
}
|
|
491
|
+
return best;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/** The nearest other body within `range` (a bot's rival), or null. */
|
|
495
|
+
function nearestBody(b: Body, range: number): Body | null {
|
|
496
|
+
let best: Body | null = null; let bd = range;
|
|
497
|
+
for (const o of bodies.values()) {
|
|
498
|
+
if (o === b) continue;
|
|
499
|
+
const d = Math.hypot(o.x - b.x, o.y - b.y);
|
|
500
|
+
if (d < bd) { bd = d; best = o; }
|
|
501
|
+
}
|
|
502
|
+
return best;
|
|
503
|
+
}
|
|
504
|
+
|
|
445
505
|
function stepHost(dt: number): void {
|
|
446
506
|
tick += 1;
|
|
447
507
|
const seat = mySeat();
|
|
@@ -483,6 +543,7 @@ function stepHost(dt: number): void {
|
|
|
483
543
|
const g = gems[i] as Gem;
|
|
484
544
|
if (Math.hypot(g.x - b.x, g.y - b.y) < R_AV + R_GEM) {
|
|
485
545
|
b.score += zone && Math.hypot(g.x - zone.x, g.y - zone.y) < zone.r ? 2 : 1;
|
|
546
|
+
pickups.set(b.slot, (pickups.get(b.slot) ?? 0) + 1);
|
|
486
547
|
gems[i] = newGem();
|
|
487
548
|
}
|
|
488
549
|
}
|
|
@@ -665,9 +726,12 @@ function hud(cw: number, ch: number, phone: boolean, list: { slot: number; name:
|
|
|
665
726
|
const rows = [...list].sort((a, b) => (scores.get(b.slot) ?? 0) - (scores.get(a.slot) ?? 0)).slice(0, phone ? 4 : 6);
|
|
666
727
|
ctx.font = `600 ${phone ? 14 : 16}px ui-sans-serif, system-ui, sans-serif`;
|
|
667
728
|
ctx.textAlign = 'right';
|
|
729
|
+
// The play page's room button (and a server's pill beside it) sit at the top right (game.json screen.share's
|
|
730
|
+
// default): the scores start under that band, so the buttons never cover a score.
|
|
731
|
+
const board = top + 18 + 44;
|
|
668
732
|
rows.forEach((a, i) => {
|
|
669
733
|
ctx.fillStyle = a.mine ? '#ffffff' : a.bot ? 'rgba(232,236,245,0.55)' : 'rgba(232,236,245,0.85)';
|
|
670
|
-
ctx.fillText(`${a.mine && !net.watching ? 'You' : label(a.name, a.bot)} ${scores.get(a.slot) ?? 0}`, cw - pad,
|
|
734
|
+
ctx.fillText(`${a.mine && !net.watching ? 'You' : label(a.name, a.bot)} ${scores.get(a.slot) ?? 0}`, cw - pad, board + i * (phone ? 20 : 22));
|
|
671
735
|
});
|
|
672
736
|
// role badge
|
|
673
737
|
ctx.textAlign = 'left'; ctx.font = '600 11px ui-monospace, Menlo, monospace'; ctx.fillStyle = 'rgba(125,240,255,0.7)';
|
|
@@ -719,6 +783,10 @@ net.expose({
|
|
|
719
783
|
},
|
|
720
784
|
frames: () => frames,
|
|
721
785
|
scores: () => (hosting ? [...bodies.values()].map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })) : [...drawn.values()].map((d) => ({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, bot: d.seat < 0, score: d.score }))),
|
|
786
|
+
/** Host: gems each slot picked up this round, the round's age, and the dial each bot plays at (the dial's numbers). */
|
|
787
|
+
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),
|
|
788
|
+
/** Host: when each bot last looked (its reaction clock). */
|
|
789
|
+
sight: () => (hosting ? [...sight.entries()].map(([slot, e]) => ({ slot, at: e.at })) : null),
|
|
722
790
|
waves: () => wavesSeen,
|
|
723
791
|
knocks: () => knocksSeen,
|
|
724
792
|
zone: () => zone,
|