@homie-rocks/studio 0.18.1 → 0.19.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 +34 -3
- package/bin/homie-studio.mjs +54 -4
- package/lib/build.mjs +20 -2
- package/lib/perf-profile.mjs +171 -0
- package/lib/perf-stats.mjs +249 -0
- package/lib/perf.mjs +680 -0
- package/lib/scaffold.mjs +8 -0
- package/lib/template-history.json +211 -9
- package/lib/upgrade.mjs +38 -11
- package/package.json +1 -1
- package/port/room.ts +36 -9
- package/saves/SAVES.md +4 -1
- package/starters/ember-vale/src/main.ts +67 -8
- package/worker/version.mjs +1 -1
|
@@ -15,6 +15,12 @@
|
|
|
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
17
|
*
|
|
18
|
+
* NAMES: the room knows a person by their seat's name (their account's, else a two-word handle such as "Velvet
|
|
19
|
+
* Comet"); the vale shows their HERO's name, over their head and in the night's ranking, for them and for everyone
|
|
20
|
+
* else. Each hero's browser tells the host its hero's name (a `hero` event); the host keeps every seat's in the keyed
|
|
21
|
+
* state `heroes`, which a new host and a late joiner have already. On a kids server nobody's typed name reaches
|
|
22
|
+
* anyone else (NETPLAY.md section 17): there the others are shown by their handles, as the room has them.
|
|
23
|
+
*
|
|
18
24
|
* A watcher (/<game>/watch, contract revision 5) has no hero and makes none: `room.viewSeat()` is the hero it
|
|
19
25
|
* follows, drawn in gold as a hero's own browser draws itself, with that hero's panel; nobody followed, the vale.
|
|
20
26
|
* The host says who slew what (`slain`), so a watcher on Auto cuts to the kill.
|
|
@@ -31,7 +37,7 @@
|
|
|
31
37
|
* none, or between its decisions, `decide` below is the floor. A guide's line is drawn as a bubble from agents.json,
|
|
32
38
|
* never from a model; "Quiet AI" hides them.
|
|
33
39
|
*/
|
|
34
|
-
import { AI_MARK, createControls, createLabels, createRoom, createSaves, easeView, exposePort, fitView, jitter, q, standoff, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
|
|
40
|
+
import { AI_MARK, createControls, createLabels, createRoom, createSaves, easeView, exposePort, fitView, jitter, q, standoff, stripAi, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
|
|
35
41
|
import { useAgents, type Goal, type Vocabulary } from '@homie-rocks/studio/agents';
|
|
36
42
|
import vocabulary from '../agents.json';
|
|
37
43
|
|
|
@@ -46,6 +52,8 @@ const DOWN_MS = 4000;
|
|
|
46
52
|
const DOWN = 1;
|
|
47
53
|
const STRIKING = 2;
|
|
48
54
|
const BOT_NAMES = ['Rook', 'Vex', 'Moth', 'Kilo', 'Juno', 'Pike', 'Nyx', 'Ash'];
|
|
55
|
+
/** The eight spots round the camp, in the order bodies take them: any first few are spread all the way round. */
|
|
56
|
+
const SPREAD = [0, 4, 2, 6, 1, 5, 3, 7];
|
|
49
57
|
const xpFor = (level: number): number => Math.round(20 * level ** 1.5);
|
|
50
58
|
const maxHpOf = (level: number): number => 60 + level * 12;
|
|
51
59
|
const damageOf = (level: number): number => 10 + level * 3;
|
|
@@ -129,10 +137,13 @@ const room = createRoom<Body, SlimeRow[], { slimes: Slime[]; seq: number }>({
|
|
|
129
137
|
roundSeconds: 90,
|
|
130
138
|
breakSeconds: 8,
|
|
131
139
|
botName: (slot) => BOT_NAMES[slot % BOT_NAMES.length] as string,
|
|
140
|
+
// createRoom gives every body in the room its own index (a mid-round arrival the lowest free one), so no two
|
|
141
|
+
// bodies ever spawn on one spot; SPREAD puts the first few far apart round the camp (0°, 180°, 90°, 270°…).
|
|
132
142
|
spawn: (slot, i) => {
|
|
133
|
-
const a = (i /
|
|
143
|
+
const a = ((SPREAD[i % SPREAD.length] as number) / SPREAD.length) * Math.PI * 2;
|
|
144
|
+
const ring = 1 + Math.floor(i / SPREAD.length) * 0.5;
|
|
134
145
|
const lvl = slot.bot ? 1 + (slot.slot % 4) : 1;
|
|
135
|
-
const x = W / 2 + Math.cos(a) * 160; const y = H / 2 + Math.sin(a) * 120;
|
|
146
|
+
const x = W / 2 + Math.cos(a) * 160 * ring; const y = H / 2 + Math.sin(a) * 120 * ring;
|
|
136
147
|
return { slot: slot.slot, seat: slot.seat, name: slot.name, bot: slot.bot, score: 0, x, y, hp: maxHpOf(lvl), maxHp: maxHpOf(lvl), level: lvl, face: a, flags: 0, downUntil: 0, atkAt: 0, strikeAt: 0, tx: x, ty: y };
|
|
137
148
|
},
|
|
138
149
|
pack: (b) => [q(b.x, 0), q(b.y, 0), Math.max(0, Math.round(b.hp)), b.maxHp, b.level, q(b.face, 2), b.flags],
|
|
@@ -157,6 +168,51 @@ const room = createRoom<Body, SlimeRow[], { slimes: Slime[]; seq: number }>({
|
|
|
157
168
|
const net = room.net;
|
|
158
169
|
const myLevel = (): number => hero?.level ?? 1;
|
|
159
170
|
|
|
171
|
+
/* ------------------------------------------------------------------ heroes' names */
|
|
172
|
+
/** A hero's name as the vale shows it: one line of at most 20 characters, never ending in an AI or bot mark. */
|
|
173
|
+
const heroName = (raw: unknown): string =>
|
|
174
|
+
stripAi(String(raw ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g, ' ')).slice(0, 20).trim();
|
|
175
|
+
/** On a kids server nobody's typed name reaches anyone else: the room's handles stay. */
|
|
176
|
+
const kids = (): boolean => net.policy.kids === true;
|
|
177
|
+
/** Every seat's hero name, as the host keeps it (keyed state `heroes`: seat → name). */
|
|
178
|
+
const heroNames = (): Record<string, string> => net.stateOf<Record<string, string>>('heroes') ?? {};
|
|
179
|
+
/** Host: one seat's hero name (null or '': none). */
|
|
180
|
+
function setHeroName(seat: number, name: string | null): void {
|
|
181
|
+
const next = { ...heroNames() };
|
|
182
|
+
if (name && !kids()) next[seat] = name; else delete next[seat];
|
|
183
|
+
net.state('heroes', next);
|
|
184
|
+
}
|
|
185
|
+
/** The name over a body and in the ranking: a person's hero's (on your own screen, yours at once), else the room's. */
|
|
186
|
+
function nameOf(b: { seat: number | null; bot: boolean; name: string; agent?: unknown }): string {
|
|
187
|
+
// A bot or an AI is always the room's name for it: an AI's ends in " · AI", and no hero name may hide that.
|
|
188
|
+
if (b.bot || b.agent || b.seat === null || net.isAgent(b.seat)) return b.name;
|
|
189
|
+
if (!lookOnly && hero && b.seat === room.mySeat()) return heroName(hero.name) || b.name;
|
|
190
|
+
return (kids() ? '' : heroNames()[b.seat]) || b.name;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Every frame: my hero's name goes to the host until the room has it (so a new host, a reload and a lost event all
|
|
194
|
+
* heal by themselves); the host keeps only seats a person still holds.
|
|
195
|
+
*/
|
|
196
|
+
let heroToldAt = 0;
|
|
197
|
+
let heroesKeptAt = 0;
|
|
198
|
+
function keepNames(now: number): void {
|
|
199
|
+
const seat = room.mySeat();
|
|
200
|
+
if (!lookOnly && seat !== null) {
|
|
201
|
+
const want = hero && !kids() ? heroName(hero.name) : '';
|
|
202
|
+
if ((heroNames()[seat] ?? '') !== want) {
|
|
203
|
+
if (room.hosting) setHeroName(seat, want);
|
|
204
|
+
else if (now - heroToldAt > 1500) { heroToldAt = now; net.send('hero', { name: want }); }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (!room.hosting || now - heroesKeptAt < 1000) return;
|
|
208
|
+
heroesKeptAt = now;
|
|
209
|
+
const held = new Set([...net.peers.values()].filter((p) => p.seat !== null && !p.agent && !p.watch).map((p) => String(p.seat)));
|
|
210
|
+
if (seat !== null) held.add(String(seat));
|
|
211
|
+
const names = heroNames();
|
|
212
|
+
const keep = kids() ? {} : Object.fromEntries(Object.entries(names).filter(([k]) => held.has(k)));
|
|
213
|
+
if (Object.keys(keep).length !== Object.keys(names).length) net.state('heroes', keep);
|
|
214
|
+
}
|
|
215
|
+
|
|
160
216
|
/* ------------------------------------------------------------------ the guides' brain (agents.json) */
|
|
161
217
|
type PartyRow = { seat: number; dist: number; hp: number; down: boolean };
|
|
162
218
|
const greeted = new Map<number, Set<number>>();
|
|
@@ -223,6 +279,8 @@ net.on('event', (e: NetEvent) => {
|
|
|
223
279
|
if (e.k === 'loot' && d.to === room.mySeat()) gain(Math.max(0, Number(d.xp) || 0), Math.max(0, Number(d.gold) || 0));
|
|
224
280
|
if (e.k === 'down' && d.to === room.mySeat()) void fell(String(d.cause ?? 'a slime').slice(0, 40));
|
|
225
281
|
if (e.k === 'slain' && typeof (e.d as { seat?: unknown })?.seat === 'number') net.spotlight((e.d as { seat: number }).seat);
|
|
282
|
+
// A hero's browser names its hero (its own seat only: the relay stamps who sent it).
|
|
283
|
+
if (e.k === 'hero' && room.hosting && typeof e.from === 'number' && !net.isAgent(e.from)) setHeroName(e.from, heroName((e.d as { name?: unknown } | null)?.name));
|
|
226
284
|
});
|
|
227
285
|
function tell(b: Body, k: 'loot' | 'down', d: Record<string, unknown>): void {
|
|
228
286
|
if (b.bot || b.seat === null) return;
|
|
@@ -552,7 +610,7 @@ function draw(t: number): void {
|
|
|
552
610
|
ctx.fillStyle = '#7ad35a'; ctx.fillRect(x - 24, y - R - 12, (48 * clamp(b.hp, 0, b.maxHp)) / Math.max(1, b.maxHp), 5);
|
|
553
611
|
ctx.globalAlpha = 1;
|
|
554
612
|
// An AI's name already ends in " · AI" (a guide, a companion); a plain bot says bot.
|
|
555
|
-
const text = `${b
|
|
613
|
+
const text = `${nameOf(b)}${b.bot && !b.name.endsWith(AI_MARK) ? ' · bot' : ''}${isGuide ? ' · guide' : ''} · ${b.level}`;
|
|
556
614
|
const near = meAt ? Math.hypot(x - meAt.x, y - meAt.y) : 0;
|
|
557
615
|
// Its spot is above its health bar; its body (and bar) is what other names keep off. People first, then guides,
|
|
558
616
|
// then bots; nearer you first.
|
|
@@ -635,7 +693,7 @@ function hud(t: number): void {
|
|
|
635
693
|
// A watcher's panel is the followed hero's, as the room has them (their save stays in their own browser).
|
|
636
694
|
const v = lookOnly ? room.viewBody() : null;
|
|
637
695
|
const heroes = room.view().filter((b) => !b.bot).length;
|
|
638
|
-
const title = lookOnly ? (v ? `${v
|
|
696
|
+
const title = lookOnly ? (v ? `${nameOf(v)} · Lv ${v.level}` : 'Watching the vale') : hero ? `${hero.name}${hero.hardcore ? ' ☠' : ''} · Lv ${hero.level}` : heroLoaded ? 'No hero yet' : 'Loading your hero…';
|
|
639
697
|
const sub = lookOnly ? (v ? `${v.score} xp tonight${v.flags & DOWN ? ' · down' : ''}` : `${heroes} ${heroes === 1 ? 'hero' : 'heroes'} · ${(room.fast() ?? []).length} slimes`) : hero ? `${hero.gold} gold · ${hero.kills} slain` : '';
|
|
640
698
|
const bar = v ? { colour: '#7ad35a', of: clamp(v.hp, 0, v.maxHp) / Math.max(1, v.maxHp) } : hero && !lookOnly ? { colour: '#ffcf6e', of: hero.xp / xpFor(hero.level) } : null;
|
|
641
699
|
// The panel is as wide as what it says: on a phone the vale runs under it, and the clock sits beside it.
|
|
@@ -665,7 +723,7 @@ function hud(t: number): void {
|
|
|
665
723
|
ctx.fillStyle = 'rgba(10,14,9,.78)'; ctx.fillRect(cw / 2 - 150, top, 300, 30 + rows.length * 22);
|
|
666
724
|
ctx.font = TITLE; ctx.fillStyle = '#ffcf6e'; ctx.fillText('Tonight\'s hunters', cw / 2, top + 22);
|
|
667
725
|
ctx.font = '600 14px ui-sans-serif, system-ui, sans-serif'; ctx.fillStyle = '#e7d9b4';
|
|
668
|
-
rows.forEach((r, i) => ctx.fillText(`${r.place}. ${r
|
|
726
|
+
rows.forEach((r, i) => ctx.fillText(`${r.place}. ${nameOf(r)}${r.bot && !r.name.endsWith(AI_MARK) ? ' (bot)' : ''} — ${r.score} xp`, cw / 2, top + 46 + i * 22));
|
|
669
727
|
}
|
|
670
728
|
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); }
|
|
671
729
|
const st = saves.status();
|
|
@@ -797,6 +855,7 @@ function frame(t: number): void {
|
|
|
797
855
|
const dt = Math.min(0.05, (t - last) / 1000);
|
|
798
856
|
last = t;
|
|
799
857
|
stepMe(dt);
|
|
858
|
+
keepNames(t);
|
|
800
859
|
if (room.hosting) stepHost(dt);
|
|
801
860
|
draw(t);
|
|
802
861
|
frames += 1;
|
|
@@ -813,9 +872,9 @@ net.expose({
|
|
|
813
872
|
me: me.has ? { x: Math.round(me.x), y: me.y | 0 } : null,
|
|
814
873
|
log: guideLog.slice(),
|
|
815
874
|
}),
|
|
816
|
-
// Where the camera looks, and the names as drawn (
|
|
875
|
+
// Where the camera looks, and the names as drawn (the e2e probe counts overlaps, checks your own, and reads them).
|
|
817
876
|
camera: () => (cam ? { x: cam.x, y: cam.y, scale: cam.scale, follow: cam.follow } : null),
|
|
818
|
-
labels: () => shownLabels.map((l) => ({ self: Boolean(l.self), alpha: l.alpha, moved: l.moved, left: Math.round(l.left), top: Math.round(l.top), right: Math.round(l.right), bottom: Math.round(l.bottom) })),
|
|
877
|
+
labels: () => shownLabels.map((l) => ({ text: l.text, self: Boolean(l.self), alpha: l.alpha, moved: l.moved, left: Math.round(l.left), top: Math.round(l.top), right: Math.round(l.right), bottom: Math.round(l.bottom) })),
|
|
819
878
|
});
|
|
820
879
|
|
|
821
880
|
exposePort(net, {
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.19.0';
|