@homie-rocks/studio 0.14.4 → 0.16.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/README.md +43 -6
- package/bin/homie-studio.mjs +59 -3
- package/lib/build.mjs +48 -0
- package/lib/office.mjs +7 -6
- package/lib/scaffold.mjs +50 -3
- package/lib/servers.mjs +133 -0
- package/lib/template-history.json +55 -0
- package/netplay/NETPLAY.md +317 -21
- package/netplay/netplay.ts +649 -33
- package/package.json +1 -1
- package/port/bots.ts +25 -2
- package/port/hud.ts +10 -7
- package/port/index.ts +5 -2
- package/port/room.ts +48 -10
- package/port/skill.ts +28 -0
- package/port/touch.ts +4 -1
- package/site/SITE.md +71 -0
- package/starters/ember-vale/src/main.ts +58 -12
- package/starters/gem-rush/src/main.ts +128 -33
- package/worker/account-page.mjs +26 -0
- package/worker/agents.mjs +238 -0
- package/worker/index.mjs +353 -68
- package/worker/office-page.mjs +178 -10
- package/worker/office.mjs +294 -25
- package/worker/pages.mjs +622 -18
- package/worker/players.mjs +11 -3
- package/worker/room.mjs +547 -41
- package/worker/saves.mjs +4 -0
- package/worker/servers.mjs +445 -0
- package/worker/site.mjs +166 -10
- package/worker/stats.mjs +9 -3
- package/worker/version.mjs +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, optional R2 storage for large media, deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
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/hud.ts
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* results card between rounds. Everything is text on small translucent chips;
|
|
5
5
|
* nothing opaque sits in the middle of the screen during play; names are set
|
|
6
6
|
* with textContent (people type them). A big screen (a spectator) gets the
|
|
7
|
-
* same board without any "you".
|
|
7
|
+
* same board without any "you"; a watcher following a player (NETPLAY.md
|
|
8
|
+
* section 16) sees that player's row and result marked the way "you" is.
|
|
8
9
|
*
|
|
9
10
|
* A game with its own HUD should draw the same facts itself (clock from
|
|
10
11
|
* room.clock(), results from room.results()) instead.
|
|
@@ -59,19 +60,21 @@ export function createHud<B extends BodyBase>(room: Room<B, any, any>, opts: Hud
|
|
|
59
60
|
const paint = (): void => {
|
|
60
61
|
const c = room.clock();
|
|
61
62
|
clock.textContent = c.phase === 'none' ? 'Joining…' : c.phase === 'live' ? `Round ${c.n} · ${Math.floor(c.secondsLeft / 60)}:${String(c.secondsLeft % 60).padStart(2, '0')}` : `Next round in ${c.secondsLeft}`;
|
|
62
|
-
|
|
63
|
+
// A watcher's followed player is marked as "you" is (net.watchedSeat says it without claiming the camera follows).
|
|
64
|
+
const me = room.net.watching ? room.net.watchedSeat : room.mySeat();
|
|
65
|
+
const watchingSeat = room.net.watching && me !== null;
|
|
63
66
|
const rows = room.view().slice().sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot)).slice(0, phone ? 4 : 5);
|
|
64
67
|
board.textContent = '';
|
|
65
68
|
for (const b of rows) {
|
|
66
69
|
const d = document.createElement('div');
|
|
67
|
-
const mine = !spectator() && me !== null && b.seat === me && !b.bot;
|
|
70
|
+
const mine = (watchingSeat || !spectator()) && me !== null && b.seat === me && !b.bot;
|
|
68
71
|
d.className = mine ? 'me' : b.bot ? 'bot' : '';
|
|
69
|
-
d.textContent = `${mine ? 'You' : b.name}${b.bot ? ' · bot' : ''} ${b.score}`;
|
|
72
|
+
d.textContent = `${mine && !watchingSeat ? 'You' : b.name}${b.bot ? ' · bot' : ''} ${b.score}`;
|
|
70
73
|
board.append(d);
|
|
71
74
|
}
|
|
72
75
|
const r = room.round;
|
|
73
76
|
if (r && r.phase === 'over' && r.results) {
|
|
74
|
-
const key = `${r.n}:${r.endsAt}`;
|
|
77
|
+
const key = `${r.n}:${r.endsAt}:${me}`;
|
|
75
78
|
if (key !== lastResults) {
|
|
76
79
|
lastResults = key;
|
|
77
80
|
results.textContent = '';
|
|
@@ -79,8 +82,8 @@ export function createHud<B extends BodyBase>(room: Room<B, any, any>, opts: Hud
|
|
|
79
82
|
const ol = document.createElement('ol');
|
|
80
83
|
for (const row of r.results.slice(0, 6)) {
|
|
81
84
|
const li = document.createElement('li');
|
|
82
|
-
const mine = !spectator() && me !== null && row.seat === me && !row.bot;
|
|
83
|
-
li.textContent = `${mine ? 'You' : row.name}${row.bot ? ' (bot)' : ''} — ${row.score}${opts.unit ? ` ${opts.unit}` : ''}`;
|
|
85
|
+
const mine = (watchingSeat || !spectator()) && me !== null && row.seat === me && !row.bot;
|
|
86
|
+
li.textContent = `${mine && !watchingSeat ? 'You' : row.name}${row.bot ? ' (bot)' : ''} — ${row.score}${opts.unit ? ` ${opts.unit}` : ''}`;
|
|
84
87
|
if (mine) li.style.color = 'var(--hp-accent,#ffd166)';
|
|
85
88
|
ol.append(li);
|
|
86
89
|
}
|
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 }
|
|
@@ -83,6 +89,16 @@ export interface Room<B extends BodyBase, F = unknown, S = unknown> {
|
|
|
83
89
|
readonly round: RoundInfo | null;
|
|
84
90
|
/** My seat (0 when offline: a solo game with bots, the way a plain file or a dev server plays). */
|
|
85
91
|
mySeat(): number | null;
|
|
92
|
+
/**
|
|
93
|
+
* WATCHING (NETPLAY.md section 16): whose camera and HUD to draw. My own seat when I play (0 offline); for a
|
|
94
|
+
* watcher, the seat it follows, or null for the overview camera. Point your camera at `viewBody()` (for your own
|
|
95
|
+
* seat, keep drawing your local body) and your HUD at its numbers; null: your spectator overview.
|
|
96
|
+
*/
|
|
97
|
+
viewSeat(): number | null;
|
|
98
|
+
/** The body of viewSeat() as everyone draws it (host: the real one; others: interpolated), or null. */
|
|
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;
|
|
86
102
|
/** Host: my own body. Replica: null (draw yours from your local state). */
|
|
87
103
|
mine(): B | null;
|
|
88
104
|
/** Host, every frame: the round clock and the snapshot. Replica: no-op. Call AFTER your rules moved the bodies. */
|
|
@@ -123,7 +139,9 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
123
139
|
const angles = new Set(opts.angles ?? []);
|
|
124
140
|
const discrete = new Set(opts.discrete ?? []);
|
|
125
141
|
|
|
126
|
-
|
|
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 });
|
|
127
145
|
let bodies = new Map<number, B>();
|
|
128
146
|
let round: RoundInfo | null = null;
|
|
129
147
|
let hosting = false;
|
|
@@ -134,6 +152,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
134
152
|
snapshotHz: 20, inputHz: 20, checkpointMs: 1000,
|
|
135
153
|
checkpoint: () => ({ round, roster: roster.toJSON(), bodies: [...bodies.values()].map((b) => ({ ...b })), world: opts.saveWorld ? opts.saveWorld() : null, tick }),
|
|
136
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])],
|
|
137
157
|
});
|
|
138
158
|
|
|
139
159
|
const mySeat = (): number | null => (net.offline ? 0 : net.seat);
|
|
@@ -149,8 +169,9 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
149
169
|
for (const s of roster.slots) {
|
|
150
170
|
seen.add(s.slot);
|
|
151
171
|
const b = bodies.get(s.slot);
|
|
152
|
-
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; }
|
|
153
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;
|
|
154
175
|
}
|
|
155
176
|
for (const k of [...bodies.keys()]) if (!seen.has(k)) bodies.delete(k);
|
|
156
177
|
}
|
|
@@ -177,6 +198,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
177
198
|
for (const s of [...roster.slots].sort((a, c) => a.slot - c.slot)) {
|
|
178
199
|
const fresh = opts.spawn(s, i++);
|
|
179
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 };
|
|
180
202
|
bodies.set(s.slot, fresh);
|
|
181
203
|
}
|
|
182
204
|
round = { n, phase: 'live', startedAt: now, endsAt: now + roundMs };
|
|
@@ -189,7 +211,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
189
211
|
|
|
190
212
|
function rank(): RoundResult[] {
|
|
191
213
|
const ranked = [...bodies.values()].sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot) || a.slot - b.slot);
|
|
192
|
-
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 } : {}) }));
|
|
193
215
|
}
|
|
194
216
|
|
|
195
217
|
function endRound(): void {
|
|
@@ -204,12 +226,12 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
204
226
|
function restore(e: RoleChange<RoomSnap<F>, RoomCkpt<B, S>>): void {
|
|
205
227
|
const ck = e.ckpt?.d ?? null;
|
|
206
228
|
if (ck) {
|
|
207
|
-
roster = Roster.from(ck.roster, { min, max, botName });
|
|
229
|
+
roster = Roster.from(ck.roster, { min, max, botName, policy });
|
|
208
230
|
bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
|
|
209
231
|
round = ck.round;
|
|
210
232
|
tick = ck.tick;
|
|
211
233
|
} else {
|
|
212
|
-
roster = Roster.from(e.roster ?? [], { min, max, botName });
|
|
234
|
+
roster = Roster.from(e.roster ?? [], { min, max, botName, policy });
|
|
213
235
|
bodies = new Map();
|
|
214
236
|
syncBodies();
|
|
215
237
|
}
|
|
@@ -244,14 +266,14 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
244
266
|
hosting = true;
|
|
245
267
|
if (e.promoted) restore(e);
|
|
246
268
|
else {
|
|
247
|
-
roster = new Roster({ min, max, botName });
|
|
269
|
+
roster = new Roster({ min, max, botName, policy });
|
|
248
270
|
bodies = new Map();
|
|
249
271
|
const seat = mySeat();
|
|
250
272
|
if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
|
|
251
273
|
syncBodies();
|
|
252
274
|
startRound((e.round?.n ?? 0) + 1);
|
|
253
275
|
}
|
|
254
|
-
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 }));
|
|
255
277
|
const { claimed } = roster.reconcile(peers);
|
|
256
278
|
syncBodies();
|
|
257
279
|
for (const s of claimed) { const b = bodies.get(s.slot); if (b && b.seat !== mySeat()) moved(b); }
|
|
@@ -266,7 +288,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
266
288
|
});
|
|
267
289
|
net.on('join', (p) => {
|
|
268
290
|
if (!hosting || p.seat === null) return;
|
|
269
|
-
|
|
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);
|
|
270
293
|
if (!c) return; // full: they watch
|
|
271
294
|
syncBodies();
|
|
272
295
|
const b = bodies.get(c.slot.slot);
|
|
@@ -283,6 +306,13 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
283
306
|
syncBodies();
|
|
284
307
|
net.roster(roster.toJSON());
|
|
285
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
|
+
});
|
|
286
316
|
net.on('control', (e) => {
|
|
287
317
|
if (!e.reset || !opts.adopt) return;
|
|
288
318
|
const snap = e.snap as Snapshot<RoomSnap<F>> | null;
|
|
@@ -320,6 +350,14 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
320
350
|
get bodies() { return bodies; },
|
|
321
351
|
get round() { return hosting ? round : (net.roundInfo ?? round); },
|
|
322
352
|
mySeat,
|
|
353
|
+
viewSeat: () => (net.offline ? 0 : net.viewSeat),
|
|
354
|
+
viewBody() {
|
|
355
|
+
const s = net.offline ? 0 : net.viewSeat;
|
|
356
|
+
if (s === null) return null;
|
|
357
|
+
for (const b of room.view()) if (!b.bot && b.seat === s) return b;
|
|
358
|
+
return null;
|
|
359
|
+
},
|
|
360
|
+
skillOf: (body) => net.skillOf(body.slot),
|
|
323
361
|
mine: () => (hosting ? bodyOfSeat(mySeat()) : null),
|
|
324
362
|
update() {
|
|
325
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/port/touch.ts
CHANGED
|
@@ -113,7 +113,10 @@ function isTouchDevice(): boolean {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
export function createTouchControls(opts: TouchOptions = {}): TouchControls {
|
|
116
|
-
|
|
116
|
+
// A spectator (a big screen, or a watcher: NETPLAY.md section 16) plays nothing: no stick, no buttons on its glass.
|
|
117
|
+
const shell = (globalThis as { HOMIE_NET?: { want?: string; watch?: boolean } }).HOMIE_NET;
|
|
118
|
+
const spectator = shell?.watch === true || shell?.want === 'screen';
|
|
119
|
+
const enabled = Boolean(opts.force) || (isTouchDevice() && !spectator);
|
|
117
120
|
type StickCfg = { zone: [number, number, number, number]; radius: number; dead: number; keys: Partial<Record<Dir, string>> | false; fourWay: boolean };
|
|
118
121
|
const stickOpt: StickCfg | null = opts.stick === false ? null : { zone: [0, 0.3, 0.55, 1], radius: 56, dead: 7, keys: false, fourWay: false, ...(opts.stick ?? {}) } as StickCfg;
|
|
119
122
|
const lookOpt = opts.look ? { zone: [0.45, 0, 1, 1] as [number, number, number, number], ...opts.look } : null;
|
package/site/SITE.md
CHANGED
|
@@ -29,6 +29,8 @@ songs (each with a `cover`), videos (each with a `poster`), the latest posts, an
|
|
|
29
29
|
(`homie-studio stats share on`) it also says "played this week": `played` for the whole studio and each game's own
|
|
30
30
|
(`games[].played`, `{ "days": 7, "plays", "rounds" }`). To keep the studio's rooms off the hub, studio.json takes
|
|
31
31
|
`"rooms": { "share": false }`: the manifest then names no `rooms` (the studio's own pages still list them).
|
|
32
|
+
Each room in `/api/rooms` carries `play` and, unless its game says `"watch": false`, `watch` (its watch door), so
|
|
33
|
+
the hub offers Watch beside Join.
|
|
32
34
|
|
|
33
35
|
## A game's landing: `/<id>/`
|
|
34
36
|
|
|
@@ -164,6 +166,75 @@ The game runs in a sandboxed frame with an opaque origin, which is why its allow
|
|
|
164
166
|
(`fullscreen *; autoplay *; gamepad *`): Safari refuses a bare `gamepad` there (`getGamepads()` throws a
|
|
165
167
|
SecurityError), so a controller would not work.
|
|
166
168
|
|
|
169
|
+
## The watch door: `/<id>/watch?room=<room>`
|
|
170
|
+
|
|
171
|
+
Anyone can watch a live room from any player's view (NETPLAY.md section 16). Every room row (Home, Rooms, a
|
|
172
|
+
game's landing) has a **Watch** button beside Join, and a landing offers "Watch a live room" while somebody plays.
|
|
173
|
+
The page is the game itself, drawn by the watcher's own browser as a watcher: a screen that never takes a seat.
|
|
174
|
+
|
|
175
|
+
- **The top bar:** LIVE, the game and the room, the round's clock, how many are watching, and **Play** (into
|
|
176
|
+
this room). **The caption** says whose view this is ("Watching Velvet Comet", "Auto · following the action").
|
|
177
|
+
- **The strip** at the bottom: **Auto**, each player (their colour, name and live score when the game exposes
|
|
178
|
+
`scores`; the leader's score lit), and **Whole room**. A tap switches; on a computer keys 1-9 pick the n-th
|
|
179
|
+
player, A is Auto, O (or 0) the whole room, the arrows the next or previous player, F full screen. The address
|
|
180
|
+
keeps the view (`&follow=<seat>|auto|overview`), so a copied link watches the same player.
|
|
181
|
+
- It fits a phone (the strip scrolls sideways), a computer, and a TV-sized screen (bigger type; `?hand=tv`). The
|
|
182
|
+
bars fade after five seconds without a touch, a move or a key, and come back with one.
|
|
183
|
+
- With no `room`, it watches the busiest public room (`/<id>/api/watch`, which reserves nothing); with nobody
|
|
184
|
+
playing it says so, offers Play, and finds a room the moment one starts. A room everyone left, a kick, a closed
|
|
185
|
+
room and a full one are each said plainly, with the way on.
|
|
186
|
+
- **What a game decides.** A game that draws `net.viewSeat` lets the watcher switch; one that does not is watched
|
|
187
|
+
as its overview, and the strip only names the players. game.json `"watch": "overview"` keeps watchers on the
|
|
188
|
+
whole room (hidden hands or roles); `"watch": false` removes the door (the page says the game is played, not
|
|
189
|
+
watched, and the socket is refused).
|
|
190
|
+
- **The same door as Play:** a private or invite-only game is watched only by those it lets in, with their
|
|
191
|
+
ticket; a kick holds the browser out of watching that room too. A browser that holds a seat in a room watches
|
|
192
|
+
that room as the whole room only, so a second tab is not a peek.
|
|
193
|
+
- Each opening counts as a `watch` in the studio's stats.
|
|
194
|
+
|
|
195
|
+
## Servers: `/<id>/servers/` and `/<id>/s/<server>/`
|
|
196
|
+
|
|
197
|
+
A game's **servers** are named, lasting pools of rooms with their own rules (`worker/servers.mjs`; NETPLAY.md
|
|
198
|
+
section 17 for the room's side). Strangers are matched only inside one server. The game's own public rooms are
|
|
199
|
+
its **Quick play** server (`public`), so every old link keeps working.
|
|
200
|
+
|
|
201
|
+
- **The landing's Servers band** shows when the game has a server besides Quick play: a card per server (its
|
|
202
|
+
name, its policy's badge and one line, who is on it now: "9 playing · 4 AI · 41 members", Play and About),
|
|
203
|
+
and "Quick play →" under it. `/<id>/servers/` lists them all (and every policy's line). A server that is
|
|
204
|
+
closed, not listed, or has a door (an account or an invite) is left out of the Rooms lists and `/api/rooms`.
|
|
205
|
+
- **The badge and its line**, everywhere a server is shown: *Open* ("anyone can play. AI players are always
|
|
206
|
+
marked AI."), *Humans only* ("every player here is a person. AI can't join."), *Hybrid · N* ("N seats in every
|
|
207
|
+
room are AI companions, always marked AI. Your party sets their level."), *Beginner* ("for new players. AI guides
|
|
208
|
+
help you learn. Chat is quick lines only."); a kids server adds "Players have handles, not names."
|
|
209
|
+
- **A server's page** (`/<id>/s/<server>/`): its name, blurb, badge and line; its live rooms (AI marked); how many
|
|
210
|
+
belong to it; "☆ Make this my home" for a signed-in player (and Leave); and a big **Play**
|
|
211
|
+
(`/<id>/s/<server>/play`). A player's home server is where a bare `/<id>/play` takes them; their account page
|
|
212
|
+
lists their servers.
|
|
213
|
+
- **The door**, then the play page: *open* lets anyone in; *accounts* asks the visitor to sign in with a passkey
|
|
214
|
+
("Sign in to play on Night Shift"); *invite* shows the invite-code box (`homie-studio office invite <id> --server
|
|
215
|
+
<server>` makes codes and links for one server); *beginner* lets in guests and accounts younger than its days
|
|
216
|
+
(a veteran is told "First Light is for new players… try another server or Quick play"; a mentor gets in, with a
|
|
217
|
+
Mentor badge). A server whose rooms are all full (its `rooms`) sends the visitor to its fullest room, where they
|
|
218
|
+
wait for a seat ("First Light is full. You're next for a seat."). The owner always comes in. The watch door is
|
|
219
|
+
the same door, except that watching a beginner room needs no new account.
|
|
220
|
+
- **When the owner hides Quick play** (`homie-studio servers close <id> public`), Play shows the servers to pick
|
|
221
|
+
from, and a named `?room=` is off (except for the owner).
|
|
222
|
+
- **The play shell** on a server: a pill beside the room button ("First Light · Beginner"; tap: the policy's line,
|
|
223
|
+
"AI level: Steady" with **Change** when the game's bots read the dial, and **Quiet AI**, which hides AI chat on
|
|
224
|
+
this screen only); the chip says "4 playing · 2 AI"; every AI row and result carries an AI pill.
|
|
225
|
+
- **The vote card** ("How strong should the AI be?", five levels, "the middle vote wins") opens by itself at a
|
|
226
|
+
party's first round with AI seats, from the pill's Change, or from the game; a lone player's tap decides at once,
|
|
227
|
+
every seated person's vote closes it early, else 15 s. Keys 1-5 on a computer; a bottom sheet on a phone. The
|
|
228
|
+
result is a toast ("The party set the AI to Steady (3 votes)."). A game that draws its own card says game.json
|
|
229
|
+
`"agents": { "vote": "game" }` (`false`: none).
|
|
230
|
+
- `/<id>/api/servers` is the servers with their live counts (cacheable 15 s). `/api/rooms` rows say their
|
|
231
|
+
`server`, `policy` and `ai`.
|
|
232
|
+
|
|
233
|
+
**An AI's seat** (an agent pass from `homie-studio agents pass`): `POST /<id>/api/agent` with `Authorization:
|
|
234
|
+
Bearer <pass>` and `{ "server": "<id>" }` answers a room with people in it (never an empty one), a ticket, its
|
|
235
|
+
socket and (an AI that runs the game itself) the frame to load. A humans-only server answers 403 "This server is
|
|
236
|
+
for humans only", and so does its socket.
|
|
237
|
+
|
|
167
238
|
## Player accounts: `/account/`
|
|
168
239
|
|
|
169
240
|
A studio whose games keep saves (game.json `"saves": true`, [../saves/SAVES.md](../saves/SAVES.md)) has a player
|
|
@@ -14,8 +14,16 @@
|
|
|
14
14
|
* The host decides who killed what and sends the killer a `loot` event; only the hero's own browser changes the
|
|
15
15
|
* hero and saves it. A hardcore hero who falls becomes a memorial in the Hall of the Fallen, and its save is wiped
|
|
16
16
|
* in the same step (saves.fall). Canvas 2D on purpose: the point is the pattern, in a few hundred lines.
|
|
17
|
+
*
|
|
18
|
+
* A watcher (/<game>/watch, contract revision 5) has no hero and makes none: `room.viewSeat()` is the hero it
|
|
19
|
+
* follows, drawn in gold as a hero's own browser draws itself, with that hero's panel; nobody followed, the vale.
|
|
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.
|
|
17
25
|
*/
|
|
18
|
-
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';
|
|
19
27
|
|
|
20
28
|
/* ------------------------------------------------------------------ rules */
|
|
21
29
|
const W = 1600;
|
|
@@ -35,11 +43,14 @@ const clamp = (v: number, a: number, b: number): number => Math.max(a, Math.min(
|
|
|
35
43
|
|
|
36
44
|
/** The hero: the one thing that lasts. Kept in the save key 'hero'. */
|
|
37
45
|
interface Hero { v: 1; name: string; level: number; xp: number; gold: number; hardcore: boolean; kills: number; deaths: number; born: number }
|
|
38
|
-
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 }
|
|
39
47
|
interface Slime { id: number; x: number; y: number; hp: number; maxHp: number; size: number; hitAt: number; lastHit: number }
|
|
40
48
|
type SlimeRow = [id: number, x: number, y: number, hp: number, size: number];
|
|
41
49
|
|
|
42
50
|
/* ------------------------------------------------------------------ the save */
|
|
51
|
+
/** A spectator (a watcher, or a big screen) has no hero of its own: it never loads, makes or saves one. */
|
|
52
|
+
const shellCfg = (globalThis as { HOMIE_NET?: { want?: string; watch?: boolean } }).HOMIE_NET;
|
|
53
|
+
const lookOnly = shellCfg?.watch === true || shellCfg?.want === 'screen';
|
|
43
54
|
const saves = createSaves({ game: 'ember-vale' });
|
|
44
55
|
let hero: Hero | null = null;
|
|
45
56
|
let heroLoaded = false;
|
|
@@ -50,6 +61,7 @@ function saveSoon(): void {
|
|
|
50
61
|
saveTimer = setTimeout(() => { if (hero) void saves.set('hero', hero); }, 1000);
|
|
51
62
|
}
|
|
52
63
|
async function loadHero(): Promise<void> {
|
|
64
|
+
if (lookOnly) { heroLoaded = true; ui.paint(); return; }
|
|
53
65
|
const h = await saves.get<Hero>('hero');
|
|
54
66
|
hero = h && h.v === 1 ? h : null;
|
|
55
67
|
heroLoaded = true;
|
|
@@ -140,6 +152,7 @@ net.on('event', (e: NetEvent) => {
|
|
|
140
152
|
const d = (e.d ?? {}) as { xp?: number; gold?: number; cause?: string; to?: number };
|
|
141
153
|
if (e.k === 'loot' && d.to === room.mySeat()) gain(Math.max(0, Number(d.xp) || 0), Math.max(0, Number(d.gold) || 0));
|
|
142
154
|
if (e.k === 'down' && d.to === room.mySeat()) void fell(String(d.cause ?? 'a slime').slice(0, 40));
|
|
155
|
+
if (e.k === 'slain' && typeof (e.d as { seat?: unknown })?.seat === 'number') net.spotlight((e.d as { seat: number }).seat);
|
|
143
156
|
});
|
|
144
157
|
function tell(b: Body, k: 'loot' | 'down', d: Record<string, unknown>): void {
|
|
145
158
|
if (b.bot || b.seat === null) return;
|
|
@@ -158,6 +171,8 @@ function strike(b: Body, now: number): void {
|
|
|
158
171
|
const gold = s.size === 3 ? 50 : 2 + Math.floor(Math.random() * 4) * s.size;
|
|
159
172
|
b.score += xp;
|
|
160
173
|
tell(b, 'loot', { xp, gold });
|
|
174
|
+
// Everyone hears who slew it (a watcher on Auto cuts to them); the loot itself is the slayer's alone.
|
|
175
|
+
if (!b.bot && b.seat !== null) room.send('slain', { seat: b.seat });
|
|
161
176
|
}
|
|
162
177
|
}
|
|
163
178
|
slimes = slimes.filter((s) => s.hp > 0);
|
|
@@ -179,14 +194,23 @@ function stepHost(dt: number): void {
|
|
|
179
194
|
}
|
|
180
195
|
let wants = false;
|
|
181
196
|
if (b.bot) {
|
|
182
|
-
// 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);
|
|
183
201
|
const near = nearestSlime(b.x, b.y);
|
|
184
|
-
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;
|
|
185
209
|
const tx = t ? t.x : b.tx; const ty = t ? t.y : b.ty;
|
|
186
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); }
|
|
187
211
|
const dx = tx - b.x; const dy = ty - b.y; const dist = Math.hypot(dx, dy) || 1;
|
|
188
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); }
|
|
189
|
-
wants = Boolean(t && dist < STRIKE_RANGE && now - b.atkAt > 900);
|
|
213
|
+
wants = Boolean(t && dist < STRIKE_RANGE && now - b.atkAt > 1400 - 900 * s.aggression);
|
|
190
214
|
} else if (b.seat === myS) {
|
|
191
215
|
b.x = me.x; b.y = me.y; b.face = me.face; b.level = myLevel(); b.maxHp = maxHpOf(b.level);
|
|
192
216
|
wants = me.strikes > 0; me.strikes = 0;
|
|
@@ -299,10 +323,12 @@ function draw(t: number): void {
|
|
|
299
323
|
ctx.fillStyle = 'rgba(0,0,0,.5)'; ctx.fillRect(x - r, y + r + 4, r * 2, 4);
|
|
300
324
|
ctx.fillStyle = '#ff7a59'; ctx.fillRect(x - r, y + r + 4, (r * 2 * clamp(hp, 0, max)) / max, 4);
|
|
301
325
|
}
|
|
302
|
-
|
|
326
|
+
// The hero whose view this is: my own, or the one a watcher follows (null: the vale, nobody in gold).
|
|
327
|
+
const viewS = room.viewSeat();
|
|
303
328
|
for (const b of room.view()) {
|
|
304
|
-
const self = !b.bot && b.seat ===
|
|
305
|
-
const
|
|
329
|
+
const self = !b.bot && b.seat === viewS;
|
|
330
|
+
const local = self && !room.hosting && !net.watching;
|
|
331
|
+
const x = local ? me.x : b.x; const y = local ? me.y : b.y;
|
|
306
332
|
const isDown = Boolean(b.flags & DOWN);
|
|
307
333
|
ctx.globalAlpha = isDown ? 0.35 : 1;
|
|
308
334
|
if ((b.flags & STRIKING) || (self && performance.now() - me.flashAt < 160)) { ctx.strokeStyle = 'rgba(255,207,110,.7)'; ctx.lineWidth = 4; ctx.beginPath(); ctx.arc(x, y, STRIKE_RANGE * 0.8, 0, Math.PI * 2); ctx.stroke(); }
|
|
@@ -313,7 +339,8 @@ function draw(t: number): void {
|
|
|
313
339
|
ctx.fillStyle = '#7ad35a'; ctx.fillRect(x - 24, y - R - 12, (48 * clamp(b.hp, 0, b.maxHp)) / Math.max(1, b.maxHp), 5);
|
|
314
340
|
ctx.globalAlpha = 1;
|
|
315
341
|
ctx.font = '600 18px ui-sans-serif, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = self ? '#ffcf6e' : '#e7d9b4';
|
|
316
|
-
|
|
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);
|
|
317
344
|
}
|
|
318
345
|
ctx.restore();
|
|
319
346
|
hud(t);
|
|
@@ -325,8 +352,23 @@ function hud(t: number): void {
|
|
|
325
352
|
const x0 = 14; const y0 = 14;
|
|
326
353
|
ctx.fillStyle = 'rgba(10,14,9,.62)'; ctx.fillRect(x0, y0, 240, 62);
|
|
327
354
|
ctx.textAlign = 'left'; ctx.font = '700 16px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#ffcf6e';
|
|
328
|
-
|
|
329
|
-
|
|
355
|
+
if (lookOnly) {
|
|
356
|
+
// A watcher's panel is the followed hero's, as the room has them (their save stays in their own browser).
|
|
357
|
+
const v = room.viewBody();
|
|
358
|
+
if (v) {
|
|
359
|
+
ctx.fillText(`${v.name} · Lv ${v.level}`, x0 + 10, y0 + 22);
|
|
360
|
+
ctx.fillStyle = 'rgba(255,255,255,.12)'; ctx.fillRect(x0 + 10, y0 + 32, 140, 6);
|
|
361
|
+
ctx.fillStyle = '#7ad35a'; ctx.fillRect(x0 + 10, y0 + 32, (140 * clamp(v.hp, 0, v.maxHp)) / Math.max(1, v.maxHp), 6);
|
|
362
|
+
ctx.font = '600 13px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
|
|
363
|
+
ctx.fillText(`${v.score} xp tonight${v.flags & DOWN ? ' · down' : ''}`, x0 + 10, y0 + 54);
|
|
364
|
+
} else {
|
|
365
|
+
const heroes = room.view().filter((b) => !b.bot).length;
|
|
366
|
+
ctx.fillText('Watching the vale', x0 + 10, y0 + 22);
|
|
367
|
+
ctx.font = '600 13px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
|
|
368
|
+
ctx.fillText(`${heroes} ${heroes === 1 ? 'hero' : 'heroes'} · ${(room.fast() ?? []).length} slimes`, x0 + 10, y0 + 46);
|
|
369
|
+
}
|
|
370
|
+
} else ctx.fillText(hero ? `${hero.name}${hero.hardcore ? ' ☠' : ''} · Lv ${hero.level}` : heroLoaded ? 'No hero yet' : 'Loading your hero…', x0 + 10, y0 + 22);
|
|
371
|
+
if (hero && !lookOnly) {
|
|
330
372
|
ctx.fillStyle = 'rgba(255,255,255,.12)'; ctx.fillRect(x0 + 10, y0 + 32, 140, 6);
|
|
331
373
|
ctx.fillStyle = '#ffcf6e'; ctx.fillRect(x0 + 10, y0 + 32, (140 * hero.xp) / xpFor(hero.level), 6);
|
|
332
374
|
ctx.font = '600 13px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
|
|
@@ -341,7 +383,7 @@ function hud(t: number): void {
|
|
|
341
383
|
ctx.fillStyle = 'rgba(10,14,9,.78)'; ctx.fillRect(cw / 2 - 150, 44, 300, 30 + rows.length * 22);
|
|
342
384
|
ctx.fillStyle = '#ffcf6e'; ctx.fillText('Tonight\'s hunters', cw / 2, 66);
|
|
343
385
|
ctx.font = '600 14px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
|
|
344
|
-
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));
|
|
345
387
|
}
|
|
346
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); }
|
|
347
389
|
const st = saves.status();
|
|
@@ -359,6 +401,7 @@ const ui = {
|
|
|
359
401
|
paint(): void {
|
|
360
402
|
const p = saves.player;
|
|
361
403
|
$('who').textContent = p.local ? 'Playing here only' : p.signedIn ? `${p.name}${p.owner ? ' · owner' : ''}` : 'Guest · keep my hero';
|
|
404
|
+
$('who').hidden = lookOnly;
|
|
362
405
|
},
|
|
363
406
|
make(): void {
|
|
364
407
|
($('hero-name') as HTMLInputElement).value = '';
|
|
@@ -416,6 +459,9 @@ function frame(t: number): void {
|
|
|
416
459
|
requestAnimationFrame(frame);
|
|
417
460
|
}
|
|
418
461
|
|
|
462
|
+
// Tonight's experience per hero: the watch page's live scores, and who Auto follows when nobody is slaying.
|
|
463
|
+
net.expose({ scores: () => room.view().map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })) });
|
|
464
|
+
|
|
419
465
|
exposePort(net, {
|
|
420
466
|
view: 'top',
|
|
421
467
|
self: () => (me.has && room.mySeat() !== null ? { x: me.x, y: me.y } : null),
|