@homie-rocks/studio 0.39.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -1
- package/lib/build.mjs +1 -0
- package/lib/port-check.mjs +6 -3
- package/lib/rules-build.mjs +8 -1
- package/lib/rules-check-worker.mjs +3 -3
- package/lib/rules-check.mjs +3 -1
- package/lib/template-history.json +41 -0
- package/netplay/NETPLAY.md +74 -2
- package/package.json +1 -1
- package/rules/collision.ts +115 -0
- package/rules/core.ts +65 -25
- package/rules/host.ts +5 -1
- package/rules/math.ts +15 -5
- package/rules/rules.ts +33 -10
- package/rules/types.ts +2 -2
- package/rules/view.ts +126 -32
- package/starters/ember-vale/game.json +16 -4
- package/starters/ember-vale/map/main.json +66 -0
- package/starters/ember-vale/src/move.ts +31 -0
- package/starters/ember-vale/src/rules.ts +244 -0
- package/starters/ember-vale/src/{main.ts → view.ts} +89 -574
- package/starters/ember-vale/tunables.json +100 -11
- package/starters/gem-rush/src/move.ts +9 -1
- package/starters/gem-rush/src/view.ts +2 -1
- package/starters/gem-rush-3d/game.json +23 -5
- package/starters/gem-rush-3d/map/main.json +153 -0
- package/starters/gem-rush-3d/src/move.ts +37 -0
- package/starters/gem-rush-3d/src/rules.ts +133 -0
- package/starters/gem-rush-3d/src/{main.ts → view.ts} +63 -671
- package/starters/gem-rush-3d/tunables.json +98 -11
- package/starters/hero-rush-3d/game.json +23 -5
- package/starters/hero-rush-3d/map/main.json +153 -0
- package/starters/hero-rush-3d/src/move.ts +49 -0
- package/starters/hero-rush-3d/src/rules.ts +200 -0
- package/starters/hero-rush-3d/src/{main.ts → view.ts} +83 -976
- package/starters/hero-rush-3d/tunables.json +260 -30
- package/worker/version.mjs +1 -1
|
@@ -1,62 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
* Two kinds of state, kept apart on purpose:
|
|
5
|
-
*
|
|
6
|
-
* THE ROOM (createRoom, netplay): tonight's hunt. Bodies, slimes, the clock, who struck what. Rounds ("nights")
|
|
7
|
-
* start the moment the first browser arrives (bots in the empty seats), arrivals take a bot's place, and a room
|
|
8
|
-
* forgets everything 60 s after its last player leaves. That is fine: nothing here has to last.
|
|
9
|
-
*
|
|
10
|
-
* THE SAVE (createSaves): your hero. Name, level, experience, gold, hardcore or not. Loaded when you arrive,
|
|
11
|
-
* saved when it changes, on this device at once and in the studio's cloud when online, so it follows you to
|
|
12
|
-
* every device you sign in on. Lifetime stats (kills, gold, time played) only add up and outlive any hero.
|
|
13
|
-
*
|
|
14
|
-
* The host decides who killed what and sends the killer a `loot` event; only the hero's own browser changes the
|
|
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
|
-
* in the same step (saves.fall). Canvas 2D on purpose: the point is the pattern, in a few hundred lines.
|
|
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
|
-
*
|
|
24
|
-
* A watcher (/<game>/watch, contract revision 5) has no hero and makes none: `room.viewSeat()` is the hero it
|
|
25
|
-
* follows, drawn in gold as a hero's own browser draws itself, with that hero's panel; nobody followed, the vale.
|
|
26
|
-
* The host says who slew what (`slain`), so a watcher on Auto cuts to the kill.
|
|
27
|
-
*
|
|
28
|
-
* Servers and the skill dial (contract revision 6): the vale's bots hunt at the room's dial (`room.skillOf(body)`:
|
|
29
|
-
* how soon they notice a slime, how close they stand, how often they strike), and a beginner server's AI guide seats
|
|
30
|
-
* are kept as AI bodies, marked " · AI". createRoom does the seats; the bots below read the dial.
|
|
31
|
-
*
|
|
32
|
-
* AI GUIDES (contract revision 7, @homie-rocks/studio/agents): on a beginner server the guide seats get a brain. The
|
|
33
|
-
* vale's own words for it are ../agents.json: the goals a guide may take (follow, quest, lead, guard, back), the lines
|
|
34
|
-
* it may say, and the asks a new hero can tap ("Help me with King Slime", "Take me to camp", "No thanks"). The HANDS
|
|
35
|
-
* below play every frame at the dial: they walk to the goal, hunt what the goal says, and tell agents.done() when it is
|
|
36
|
-
* done. The BRAIN is the server's (the Table's Workers AI, the owner's key, or the owner's own Claude in the seat); with
|
|
37
|
-
* none, or between its decisions, `decide` below is the floor. A guide's line is drawn as a bubble from agents.json,
|
|
38
|
-
* never from a model; "Quiet AI" hides them.
|
|
39
|
-
*
|
|
40
|
-
* THE GAME LAB (`homie-studio lab ember-vale`): the strike is tuned in the lab. Its numbers are tunables.json (read
|
|
41
|
-
* with lab.tunables: the lab's sliders move them and write kept values back), and the game tells the lab what a strike
|
|
42
|
-
* is doing: its phases, the struck slime's hit points, squash and push, poses for the onion skin and arcs, and views.
|
|
43
|
-
* The take "strike" (lab.json) stands a big training slime in front of the hero (lab.stage "dummy"). Outside the lab
|
|
44
|
-
* every lab call is a no-op.
|
|
45
|
-
*/
|
|
46
|
-
import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, createRoom, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, jitter, q, standoff, stripAi, type BodyBase, type DecidePicks, type Fit, type LabelIn, type LabelOut, type NetEvent, type Skill } from '@homie-rocks/studio/port';
|
|
47
|
-
import { useAgents, type Goal, type Vocabulary } from '@homie-rocks/studio/agents';
|
|
48
|
-
import vocabulary from '../agents.json';
|
|
1
|
+
/** Ember Vale: server-run hunts, with the hero save and memorials owned by its player. */
|
|
2
|
+
import { AI_MARK, BUBBLE_FONT, createBubbles, createControls, createLabels, paintBubbles, type BubbleIn, type BubbleOut, createSaves, easeView, exposePort, fitView, stripAi, type BodyBase, type Fit, type LabelIn, type LabelOut, type NetEvent } from '@homie-rocks/studio/port';
|
|
49
3
|
// The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
|
|
50
4
|
import { lab } from '@homie-rocks/studio/lab';
|
|
51
|
-
import
|
|
5
|
+
import { openRoom, type Entity } from '@homie-rocks/studio/rules/view';
|
|
6
|
+
import type rules from './rules';
|
|
7
|
+
const room = openRoom<typeof rules>({ fallback: 'solo' });
|
|
8
|
+
const net = room.net;
|
|
52
9
|
|
|
53
|
-
/* ------------------------------------------------------------------ rules */
|
|
54
10
|
const W = 1600;
|
|
55
11
|
const H = 1000;
|
|
56
12
|
const R = 22;
|
|
57
13
|
const SPEED = 300;
|
|
58
14
|
/** The strike's numbers (tunables.json): the file's values, or the Game Lab's sliders while it plays a take. */
|
|
59
|
-
const T =
|
|
15
|
+
const T = { ...room.tune, strikeRange: room.tune.strikeRange * 50, slimePush: room.tune.slimePush * 50, lunge: room.tune.lunge * 50 };
|
|
60
16
|
const DOWN_MS = 4000;
|
|
61
17
|
const DOWN = 1;
|
|
62
18
|
const STRIKING = 2;
|
|
@@ -129,404 +85,55 @@ async function fell(cause: string): Promise<void> {
|
|
|
129
85
|
void ui.hall(`${h.name} has fallen`, `Level ${h.level}, ${h.kills} slimes, ${h.gold} gold, slain by ${cause}. The Hall remembers.`, true);
|
|
130
86
|
}
|
|
131
87
|
|
|
132
|
-
/* ------------------------------------------------------------------ the room */
|
|
133
|
-
let slimes: Slime[] = [];
|
|
134
|
-
let slimeSeq = 0;
|
|
135
|
-
let spawnAt = 0;
|
|
136
|
-
let kingAt = 0;
|
|
137
88
|
const me = { x: W / 2, y: H / 2, face: 0, has: false, strikes: 0, flashAt: 0 };
|
|
138
|
-
/** Where a guide can lead the party (agents.json "lead"). */
|
|
139
89
|
const PLACES: Record<string, { x: number; y: number }> = { camp: { x: W / 2, y: H / 2 }, king: { x: W / 2, y: 120 }, 'east-woods': { x: W - 200, y: H / 2 + 30 } };
|
|
140
|
-
/** When the last King Slime fell (host): a guide on the King's quest is done. */
|
|
141
|
-
let kingSlainAt = 0;
|
|
142
|
-
|
|
143
|
-
const room = createRoom<Body, SlimeRow[], { slimes: Slime[]; seq: number }>({
|
|
144
|
-
game: 'ember-vale',
|
|
145
|
-
maxPlayers: 8,
|
|
146
|
-
minBodies: 3,
|
|
147
|
-
roundSeconds: 90,
|
|
148
|
-
breakSeconds: 8,
|
|
149
|
-
botName: (slot) => BOT_NAMES[slot % BOT_NAMES.length] as string,
|
|
150
|
-
// createRoom gives every body in the room its own index (a mid-round arrival the lowest free one), so no two
|
|
151
|
-
// bodies ever spawn on one spot; SPREAD puts the first few far apart round the camp (0°, 180°, 90°, 270°…).
|
|
152
|
-
spawn: (slot, i) => {
|
|
153
|
-
const a = ((SPREAD[i % SPREAD.length] as number) / SPREAD.length) * Math.PI * 2;
|
|
154
|
-
const ring = 1 + Math.floor(i / SPREAD.length) * 0.5;
|
|
155
|
-
const lvl = slot.bot ? 1 + (slot.slot % 4) : 1;
|
|
156
|
-
const x = W / 2 + Math.cos(a) * 160 * ring; const y = H / 2 + Math.sin(a) * 120 * ring;
|
|
157
|
-
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 };
|
|
158
|
-
},
|
|
159
|
-
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],
|
|
160
|
-
unpack: (f, b) => { b.x = f[0] as number; b.y = f[1] as number; b.hp = f[2] as number; b.maxHp = f[3] as number; b.level = f[4] as number; b.face = f[5] as number; b.flags = f[6] as number; },
|
|
161
|
-
angles: [5],
|
|
162
|
-
discrete: [2, 3, 4, 6],
|
|
163
|
-
onRoundStart: () => { slimes = []; spawnAt = 0; kingAt = room.net.now() + 45_000; },
|
|
164
|
-
fastWorld: () => slimes.map((s) => [s.id, Math.round(s.x), Math.round(s.y), Math.max(0, Math.round(s.hp)), s.size]),
|
|
165
|
-
saveWorld: () => ({ slimes, seq: slimeSeq }),
|
|
166
|
-
loadWorld: (w, fast) => {
|
|
167
|
-
slimes = w?.slimes?.map((s) => ({ ...s })) ?? [];
|
|
168
|
-
slimeSeq = Math.max(w?.seq ?? 0, ...slimes.map((s) => s.id));
|
|
169
|
-
for (const [id, x, y, hp, size] of fast ?? []) {
|
|
170
|
-
const s = slimes.find((o) => o.id === id);
|
|
171
|
-
if (s) { s.x = x; s.y = y; s.hp = hp; } else slimes.push({ id, x, y, hp, maxHp: hp, size, hitAt: 0, lastHit: -1 });
|
|
172
|
-
}
|
|
173
|
-
},
|
|
174
|
-
adopt: (b) => { me.x = b.x; me.y = b.y; me.has = true; },
|
|
175
|
-
local: () => (me.has ? ({ ...mine(), x: me.x, y: me.y } as Body) : null),
|
|
176
|
-
onTakeover: (b) => { b.score = 0; b.flags = 0; b.hp = b.maxHp; },
|
|
177
|
-
// SEAT OR SOLO (NETPLAY.md section 28). A hero who is in the room but has no body in tonight's hunt (every body is
|
|
178
|
-
// somebody's, or every seat is taken) is not left watching: after four seconds they hunt on their own with bots,
|
|
179
|
-
// the line over the game says so, and they step into the room's hunt the moment it has a body for them.
|
|
180
|
-
fallback: 'solo',
|
|
181
|
-
});
|
|
182
|
-
const net = room.net;
|
|
183
90
|
const myLevel = (): number => hero?.level ?? 1;
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const next = { ...heroNames() };
|
|
196
|
-
if (name && !kids()) next[seat] = name; else delete next[seat];
|
|
197
|
-
net.state('heroes', next);
|
|
91
|
+
function viewBodies(): Body[] {
|
|
92
|
+
const bodies: Body[] = [];
|
|
93
|
+
room.each('hero', e => {
|
|
94
|
+
const row = room.status === 'offline' ? undefined : (net.slots ?? []).find(s => s.slot === e.seat);
|
|
95
|
+
const name = room.roster.find(s => s.seat === e.seat)?.name ?? `Player ${e.seat + 1}`;
|
|
96
|
+
bodies.push({ slot: e.seat, seat: e.driver === 'bot' ? null : e.seat, name, bot: e.driver !== 'person', agent: row?.agent,
|
|
97
|
+
score: e.score, x: e.pos.x * 50, y: e.pos.y * 50, hp: e.hp, maxHp: e.maxHp, level: e.level, face: Math.atan2(e.heading.y, e.heading.x),
|
|
98
|
+
flags: e.hp === 0 ? DOWN : e.striking ? STRIKING : 0,
|
|
99
|
+
downUntil: e.motion.downUntil * 50, atkAt: e.motion.attackTick * 50, strikeAt: e.motion.attackTick * 50, tx: e.target.x * 50, ty: e.target.y * 50 });
|
|
100
|
+
});
|
|
101
|
+
return bodies;
|
|
198
102
|
}
|
|
199
|
-
|
|
103
|
+
function slimeRows(): SlimeRow[] { const rows: SlimeRow[] = []; room.each('slime', e => rows.push([e.serial, e.pos.x * 50, e.pos.y * 50, e.hp, e.size])); return rows; }
|
|
104
|
+
function viewBody(): Body | null { return viewBodies().find(b => b.seat === net.viewSeat && !b.bot) ?? null; }
|
|
105
|
+
function guideId(slot: number): string | null { let id: string | null = null; room.each('hero', e => { if (e.seat === slot) id = e.id; }); return id; }
|
|
106
|
+
const heroName = (raw: unknown): string => stripAi(String(raw ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u2028-\u202e\u2060-\u206f\ufeff]/g, ' ')).slice(0, 20).trim();
|
|
107
|
+
const kids = (): boolean => net.policy.kids === true;
|
|
108
|
+
function heroNames(): Record<string, string> { const names: Record<string, string> = {}; room.each('hero', e => { if (e.driver === 'person' && e.name) names[e.seat] = heroName(e.name); }); return names; }
|
|
200
109
|
function nameOf(b: { seat: number | null; bot: boolean; name: string; agent?: unknown }): string {
|
|
201
110
|
// 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.
|
|
202
111
|
if (b.bot || b.agent || b.seat === null || net.isAgent(b.seat)) return b.name;
|
|
203
|
-
if (!lookOnly && hero && b.seat === room.
|
|
112
|
+
if (!lookOnly && hero && b.seat === room.seat) return heroName(hero.name) || b.name;
|
|
204
113
|
return (kids() ? '' : heroNames()[b.seat]) || b.name;
|
|
205
114
|
}
|
|
206
|
-
|
|
207
|
-
* 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
|
|
208
|
-
* heal by themselves); the host keeps only seats a person still holds.
|
|
209
|
-
*/
|
|
210
|
-
let heroToldAt = 0;
|
|
211
|
-
let heroesKeptAt = 0;
|
|
115
|
+
let heroToldAt = -1e9;
|
|
212
116
|
function keepNames(now: number): void {
|
|
213
|
-
const
|
|
214
|
-
if (
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if (room.hosting) setHeroName(seat, want);
|
|
218
|
-
else if (now - heroToldAt > 1500) { heroToldAt = now; net.send('hero', { name: want }); }
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
if (!room.hosting || now - heroesKeptAt < 1000) return;
|
|
222
|
-
heroesKeptAt = now;
|
|
223
|
-
const held = new Set([...net.peers.values()].filter((p) => p.seat !== null && !p.agent && !p.watch).map((p) => String(p.seat)));
|
|
224
|
-
if (seat !== null) held.add(String(seat));
|
|
225
|
-
const names = heroNames();
|
|
226
|
-
const keep = kids() ? {} : Object.fromEntries(Object.entries(names).filter(([k]) => held.has(k)));
|
|
227
|
-
if (Object.keys(keep).length !== Object.keys(names).length) net.state('heroes', keep);
|
|
117
|
+
const own = room.me;
|
|
118
|
+
if (lookOnly || !own || now - heroToldAt < 1500) return;
|
|
119
|
+
const name = hero && !kids() ? heroName(hero.name) : '', level = Math.min(99, myLevel());
|
|
120
|
+
if (own.name !== name || own.level !== level) { heroToldAt = now; room.command('hero', { name, level }); }
|
|
228
121
|
}
|
|
229
|
-
|
|
230
|
-
/* ------------------------------------------------------------------ the guides' brain (agents.json) */
|
|
231
|
-
type PartyRow = { seat: number; dist: number; hp: number; down: boolean };
|
|
232
|
-
const greeted = new Map<number, Set<number>>();
|
|
233
|
-
const warnedAt = new Map<number, number>();
|
|
234
|
-
const agents = useAgents(net, vocabulary as unknown as Vocabulary, {
|
|
235
|
-
// What a guide sees (host, at most every 2 s, under 2 KB): the vale near it, never a name, an account or typed text.
|
|
236
|
-
view: (slot) => {
|
|
237
|
-
const b = room.bodies.get(slot);
|
|
238
|
-
if (!b) return {};
|
|
239
|
-
const party: PartyRow[] = [...room.bodies.values()].filter((o) => !o.bot && o.seat !== null)
|
|
240
|
-
.map((o) => ({ seat: o.seat as number, dist: Math.round(Math.hypot(o.x - b.x, o.y - b.y)), hp: Math.round((100 * o.hp) / Math.max(1, o.maxHp)), down: Boolean(o.flags & DOWN) }))
|
|
241
|
-
.filter((p) => p.dist < 900).sort((x, y) => x.dist - y.dist).slice(0, 6);
|
|
242
|
-
const king = slimes.find((o) => o.size === 3);
|
|
243
|
-
const near = slimes.filter((o) => Math.hypot(o.x - b.x, o.y - b.y) < 350).length;
|
|
244
|
-
const c = room.clock();
|
|
245
|
-
return {
|
|
246
|
-
me: { hp: Math.round((100 * b.hp) / Math.max(1, b.maxHp)), down: Boolean(b.flags & DOWN) },
|
|
247
|
-
zone: zoneOf(b.x, b.y),
|
|
248
|
-
// A brain thinks again when this changes, so it changes only for what matters: the King close, or a swarm.
|
|
249
|
-
danger: king && Math.hypot(king.x - b.x, king.y - b.y) < 450 ? 'the King Slime' : near >= 5 ? 'slimes' : null,
|
|
250
|
-
party, quests: openQuests(), slimes: { near, king: Boolean(king) }, round: { phase: c.phase, left: c.secondsLeft },
|
|
251
|
-
};
|
|
252
|
-
},
|
|
253
|
-
// The scripted floor: with no AI, over budget, and between an AI's decisions. Synchronous; it never waits.
|
|
254
|
-
decide: (v, { slot, goal }) => {
|
|
255
|
-
const a = (v['asks'] as { k: string; args: Record<string, unknown> }[] | undefined)?.[0];
|
|
256
|
-
if (a?.k === 'ask_help') return { goal: 'quest', args: { quest: a.args['quest'] }, say: 'quest_help', sayArgs: { quest: a.args['quest'] } };
|
|
257
|
-
if (a?.k === 'lead_me') return { goal: 'lead', args: { place: a.args['place'] }, say: 'follow_me', sayArgs: { place: a.args['place'] } };
|
|
258
|
-
if (a?.k === 'no_thanks') return { goal: 'back', say: 'bye' };
|
|
259
|
-
const party = (v['party'] as PartyRow[] | undefined) ?? [];
|
|
260
|
-
// What a hero asked for is carried through until it is done.
|
|
261
|
-
if (goal && goal.state === 'active' && ['quest', 'lead', 'back'].includes(goal.goal)) return null;
|
|
262
|
-
const seen = greeted.get(slot) ?? new Set<number>();
|
|
263
|
-
greeted.set(slot, seen);
|
|
264
|
-
const fresh = party.find((p) => !seen.has(p.seat) && p.dist < 420);
|
|
265
|
-
if (fresh) { seen.add(fresh.seat); return { goal: 'follow', args: { seat: fresh.seat }, say: 'hello', sayArgs: { player: fresh.seat } }; }
|
|
266
|
-
if (v['danger'] === 'the King Slime') {
|
|
267
|
-
const warn = room.net.now() - (warnedAt.get(slot) ?? 0) > 30_000;
|
|
268
|
-
if (warn) warnedAt.set(slot, room.net.now());
|
|
269
|
-
return { goal: 'guard', ...(warn ? { say: 'careful', sayArgs: { thing: 'the King Slime' } } : {}) };
|
|
270
|
-
}
|
|
271
|
-
const hurt = party.find((p) => !p.down && p.hp < 40);
|
|
272
|
-
if (hurt) return { goal: 'follow', args: { seat: hurt.seat } };
|
|
273
|
-
if (party[0]) return { goal: 'follow', args: { seat: party[0].seat } };
|
|
274
|
-
return { goal: 'lead', args: { place: 'camp' } };
|
|
275
|
-
},
|
|
276
|
-
});
|
|
277
|
-
/** A guide's line over its head for a few seconds: the game's own words (agents.json), never a model's. */
|
|
278
122
|
const bubbles = new Map<number, { text: string; until: number }>();
|
|
279
|
-
/** What the e2e probe reads: goal changes (with the ask that led to one), asks, lines. Never a name or an account. */
|
|
280
123
|
const guideLog: Record<string, unknown>[] = [];
|
|
281
124
|
const logGuide = (row: Record<string, unknown>): void => { guideLog.push({ at: Date.now(), ...row }); if (guideLog.length > 80) guideLog.shift(); };
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
const d = (
|
|
293
|
-
|
|
294
|
-
if (e.k === 'down' && d.to === room.mySeat()) void fell(String(d.cause ?? 'a slime').slice(0, 40));
|
|
295
|
-
if (e.k === 'slain' && typeof (e.d as { seat?: unknown })?.seat === 'number') net.spotlight((e.d as { seat: number }).seat);
|
|
296
|
-
// A hero's browser names its hero (its own seat only: the relay stamps who sent it).
|
|
297
|
-
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));
|
|
298
|
-
});
|
|
299
|
-
function tell(b: Body, k: 'loot' | 'down', d: Record<string, unknown>): void {
|
|
300
|
-
if (b.bot || b.seat === null) return;
|
|
301
|
-
if (b.seat === room.mySeat()) { if (k === 'loot') gain(Number(d.xp), Number(d.gold)); else void fell(String(d.cause)); return; }
|
|
302
|
-
net.send(k, { ...d, to: b.seat }, b.seat);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
function strike(b: Body, now: number): void {
|
|
306
|
-
b.atkAt = now; b.strikeAt = now;
|
|
307
|
-
const hits: Slime[] = [];
|
|
308
|
-
for (const s of slimes) {
|
|
309
|
-
if (s.hp <= 0 || Math.hypot(s.x - b.x, s.y - b.y) > T.strikeRange + s.size * 10) continue;
|
|
310
|
-
s.hp -= damageOf(b.level);
|
|
311
|
-
s.lastHit = b.slot;
|
|
312
|
-
// The hit lands, then pushes: the slime holds for the hit-stop, then eases back along the strike.
|
|
313
|
-
const d = Math.hypot(s.x - b.x, s.y - b.y) || 1;
|
|
314
|
-
s.kx = s.x; s.ky = s.y; s.kdx = (s.x - b.x) / d; s.kdy = (s.y - b.y) / d; s.kat = now + T.hitStopMs;
|
|
315
|
-
hits.push(s);
|
|
316
|
-
if (s.hp <= 0) {
|
|
317
|
-
const xp = s.size === 3 ? 60 : 6 * s.size;
|
|
318
|
-
const gold = s.size === 3 ? 50 : 2 + Math.floor(Math.random() * 4) * s.size;
|
|
319
|
-
b.score += xp;
|
|
320
|
-
if (s.size === 3) kingSlainAt = now;
|
|
321
|
-
tell(b, 'loot', { xp, gold });
|
|
322
|
-
// Everyone hears who slew it (a watcher on Auto cuts to them); the loot itself is the slayer's alone.
|
|
323
|
-
if (!b.bot && b.seat !== null) room.send('slain', { seat: b.seat });
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
if (lab.on && b.seat !== null && b.seat === room.mySeat()) labStrike(b, now, hits);
|
|
327
|
-
slimes = slimes.filter((s) => s.hp > 0);
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function down(b: Body, now: number, cause: string): void {
|
|
331
|
-
b.flags = DOWN; b.hp = 0; b.downUntil = now + DOWN_MS;
|
|
332
|
-
tell(b, 'down', { cause });
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
function stepHost(dt: number): void {
|
|
336
|
-
const now = net.now();
|
|
337
|
-
const live = room.round?.phase === 'live';
|
|
338
|
-
const myS = room.mySeat();
|
|
339
|
-
for (const b of room.bodies.values()) {
|
|
340
|
-
if (b.flags & DOWN) {
|
|
341
|
-
if (now >= b.downUntil) { b.flags = 0; b.hp = b.maxHp; b.x = W / 2 + (Math.random() - 0.5) * 300; b.y = H / 2 + (Math.random() - 0.5) * 200; room.moved(b); }
|
|
342
|
-
continue;
|
|
343
|
-
}
|
|
344
|
-
let wants = false;
|
|
345
|
-
const guide = b.bot && b.agent?.role === 'guide' ? agents.goalOf(b.slot) : null;
|
|
346
|
-
if (guide) wants = stepGuide(b, guide, room.skillOf(b), dt, now);
|
|
347
|
-
else if (b.bot && lab.stage === 'dummy') wants = false; // the lab's training stage: bots stand by
|
|
348
|
-
else if (b.bot) {
|
|
349
|
-
// Bots are company, not carries: they hunt only what is near them, and strike slower than a person. At the
|
|
350
|
-
// room's dial (section 17): they notice a slime `reactionMs` late, hunt farther afield the more they lean to
|
|
351
|
-
// the front (positioning), aim a little off (aimNoise), and strike sooner the more aggressive they are.
|
|
352
|
-
const s = room.skillOf(b);
|
|
353
|
-
const near = nearestSlime(b.x, b.y);
|
|
354
|
-
const reach = standoff(s, 460, 220);
|
|
355
|
-
if (!b.seenAt || now - b.seenAt >= s.reactionMs) {
|
|
356
|
-
b.seenAt = now;
|
|
357
|
-
const t0 = near && Math.hypot(near.x - b.x, near.y - b.y) < reach ? near : null;
|
|
358
|
-
b.ax = t0 ? t0.x + jitter(s, 40) : undefined; b.ay = t0 ? t0.y + jitter(s, 40) : undefined;
|
|
359
|
-
}
|
|
360
|
-
const t = b.ax !== undefined && b.ay !== undefined ? { x: b.ax, y: b.ay } : null;
|
|
361
|
-
const tx = t ? t.x : b.tx; const ty = t ? t.y : b.ty;
|
|
362
|
-
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); }
|
|
363
|
-
const dx = tx - b.x; const dy = ty - b.y; const dist = Math.hypot(dx, dy) || 1;
|
|
364
|
-
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); }
|
|
365
|
-
wants = Boolean(t && dist < T.strikeRange && now - b.atkAt > 1400 - 900 * s.aggression);
|
|
366
|
-
} else if (b.seat === myS) {
|
|
367
|
-
b.x = me.x; b.y = me.y; b.face = me.face; b.level = myLevel(); b.maxHp = maxHpOf(b.level);
|
|
368
|
-
wants = me.strikes > 0; me.strikes = 0;
|
|
369
|
-
} else {
|
|
370
|
-
const a = room.avatar(b);
|
|
371
|
-
if (a) {
|
|
372
|
-
room.bound(b, { x: Number(a[0]), y: Number(a[1]) }, SPEED, dt);
|
|
373
|
-
b.face = Number(a[2]) || 0;
|
|
374
|
-
// The hero's level comes from its own browser (it owns the save). A game with stakes would check it.
|
|
375
|
-
b.level = clamp(Math.floor(Number(a[3]) || 1), 1, 99); b.maxHp = maxHpOf(b.level);
|
|
376
|
-
}
|
|
377
|
-
wants = Boolean(room.presses(b)['strike']);
|
|
378
|
-
}
|
|
379
|
-
b.hp = Math.min(b.hp, b.maxHp);
|
|
380
|
-
b.flags = now - b.strikeAt < T.flashMs ? STRIKING : 0;
|
|
381
|
-
if (wants && live && now - b.atkAt > T.strikeMs) strike(b, now);
|
|
382
|
-
}
|
|
383
|
-
if (live) { stepDirector(now); stepSlimes(dt, now); }
|
|
384
|
-
// Every browser's ask buttons offer the quests open now (slow keyed state: sent only when it changes).
|
|
385
|
-
net.state('quests', openQuests());
|
|
386
|
-
room.update();
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/* ------------------------------------------------------------------ the slimes' director (section 20, opt-in) */
|
|
390
|
-
/**
|
|
391
|
-
* Every 6 s of a live night the host asks the studio's decision model how the slimes should hunt (`net.decide`: Clef,
|
|
392
|
-
* three typed questions, never text): a tactic, whether a wave comes now, how hard to push. It runs only when game.json
|
|
393
|
-
* says "decide": true (the room answers "off" otherwise, and asks are spent from the studio's AI brains' day); without it,
|
|
394
|
-
* over budget or slow, the floor answers: chase, no wave, steady, which is how the vale always played. On a kids server
|
|
395
|
-
* the slimes never gang up on the most hurt hero and the vale never pushes past steady. The heroes' dial is untouched:
|
|
396
|
-
* this is the slimes' mind, not the bots'.
|
|
397
|
-
*/
|
|
398
|
-
type Tactic = 'chase' | 'surround' | 'weakest' | 'regroup';
|
|
399
|
-
const TACTICS: Record<Tactic, string> = {
|
|
400
|
-
chase: 'Rush the nearest hero',
|
|
401
|
-
surround: 'Spread out and close in on a hero from every side',
|
|
402
|
-
weakest: 'Gang up on the most hurt hero',
|
|
403
|
-
regroup: 'Fall back to the King Slime and gather before the next push',
|
|
404
|
-
};
|
|
405
|
-
/** How often a slime comes, by pressure (0 a breather .. 4 fierce). 1100 ms at 2 is the vale as it always was. */
|
|
406
|
-
const SPAWN_MS = [1900, 1450, 1100, 850, 700];
|
|
407
|
-
const DIRECTOR_MS = 6000;
|
|
408
|
-
const director = { tactic: 'chase' as Tactic, pressure: 2, waveLeft: 0, by: 'floor', at: 0, asking: false, log: [] as Record<string, unknown>[] };
|
|
409
|
-
const directorFloor = (): DecidePicks => ({ tactic: 'chase', wave: false, pressure: 2 });
|
|
410
|
-
function stepDirector(now: number): void {
|
|
411
|
-
if (!room.hosting || director.asking || now < director.at) return;
|
|
412
|
-
director.at = now + DIRECTOR_MS;
|
|
413
|
-
director.asking = true;
|
|
414
|
-
const kidsRoom = kids();
|
|
415
|
-
const king = slimes.find((o) => o.size === 3);
|
|
416
|
-
const heroes = [...room.bodies.values()].filter((b) => !b.bot && b.seat !== null);
|
|
417
|
-
const state = {
|
|
418
|
-
heroes: heroes.map((b) => ({ hp: Math.round((100 * b.hp) / Math.max(1, b.maxHp)), down: Boolean(b.flags & DOWN), level: b.level, nearKing: king ? Math.round(Math.hypot(king.x - b.x, king.y - b.y)) : null })),
|
|
419
|
-
slimes: { count: slimes.length, big: slimes.filter((o) => o.size === 2).length, king: king ? Math.round((100 * king.hp) / king.maxHp) : null },
|
|
420
|
-
night: { secondsLeft: room.clock().secondsLeft }, now: { tactic: director.tactic, pressure: director.pressure },
|
|
421
|
-
...(kidsRoom ? { note: 'Young players: keep it gentle' } : {}),
|
|
422
|
-
};
|
|
423
|
-
const tactics = Object.fromEntries(Object.entries(TACTICS).filter(([k]) => !(kidsRoom && k === 'weakest')));
|
|
424
|
-
const asked = performance.now();
|
|
425
|
-
void net.decide(state, {
|
|
426
|
-
tactic: { type: 'choice', instructions: 'How should the slimes hunt the heroes for the next few seconds? A fun fight, not a massacre: hurt heroes deserve a breather.', criteria: tactics },
|
|
427
|
-
wave: { type: 'noul', instructions: 'Should a wave of extra slimes come now?', criteria: { true: 'The heroes are healthy and it has gone quiet', false: 'The heroes are hurt, down or few' } },
|
|
428
|
-
pressure: { type: 'score', instructions: 'How hard should the vale push the heroes right now?', criteria: ['A breather', 'Easy', 'Steady', 'Hard', 'Fierce'] },
|
|
429
|
-
}, { floor: directorFloor }).then((d) => {
|
|
430
|
-
director.asking = false;
|
|
431
|
-
const t = String(d.picks['tactic'] ?? 'chase') as Tactic;
|
|
432
|
-
director.tactic = t in tactics ? t : 'chase';
|
|
433
|
-
director.pressure = clamp(Math.round(Number(d.picks['pressure'] ?? 2)), 0, kidsRoom ? 2 : 4);
|
|
434
|
-
if (d.picks['wave'] === true && director.waveLeft === 0) director.waveLeft = 3;
|
|
435
|
-
director.by = d.by;
|
|
436
|
-
director.log.push({ at: Date.now(), by: d.by, why: d.why ?? null, ms: Math.round(performance.now() - asked), tactic: director.tactic, pressure: director.pressure, wave: d.picks['wave'] === true });
|
|
437
|
-
if (director.log.length > 40) director.log.shift();
|
|
438
|
-
// Every screen sees what the slimes are up to (only when a model decided; the floor is the vale as always).
|
|
439
|
-
net.state('slimes', d.by === 'floor' ? null : { tactic: director.tactic, pressure: director.pressure });
|
|
440
|
-
});
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
/* ------------------------------------------------------------------ the guides' hands */
|
|
444
|
-
const bodyOfSeat = (seat: unknown): Body | null => { for (const o of room.bodies.values()) if (!o.bot && o.seat === seat) return o; return null; };
|
|
445
|
-
const people = (): Body[] => [...room.bodies.values()].filter((o) => !o.bot && o.seat !== null && !(o.flags & DOWN));
|
|
446
|
-
const huntFrom = new Map<number, number>();
|
|
447
|
-
const arrivedAt = new Map<number, number>();
|
|
448
|
-
/**
|
|
449
|
-
* A guide's body, every host frame, doing its goal at the dial (`s`): where it stands (positioning), how soon it
|
|
450
|
-
* notices (reactionMs), how far off it strikes (aimNoise), how eagerly (aggression). Returns whether it strikes now.
|
|
451
|
-
*/
|
|
452
|
-
function stepGuide(b: Body, g: Goal, s: Skill, dt: number, now: number): boolean {
|
|
453
|
-
let tx = b.x; let ty = b.y; let hunt: Slime | null = null;
|
|
454
|
-
const nearTo = (x: number, y: number, r: number): Slime | null => { const n = nearestSlime(x, y); return n && Math.hypot(n.x - x, n.y - y) < r ? n : null; };
|
|
455
|
-
switch (g.goal) {
|
|
456
|
-
case 'follow': {
|
|
457
|
-
const p = bodyOfSeat(g.args['seat']);
|
|
458
|
-
if (!p) { agents.done(b.slot, false); break; }
|
|
459
|
-
// A step behind the hero (closer at a higher dial), each guide to its own side, and the slime that comes for them.
|
|
460
|
-
const back = standoff(s, 70, 150);
|
|
461
|
-
const side = (b.slot % 2 ? 1 : -1) * (0.5 + (b.slot % 3) * 0.25);
|
|
462
|
-
tx = p.x - Math.cos(p.face + side) * back; ty = p.y - Math.sin(p.face + side) * back;
|
|
463
|
-
hunt = nearTo(p.x, p.y, 200);
|
|
464
|
-
break;
|
|
465
|
-
}
|
|
466
|
-
case 'quest': {
|
|
467
|
-
const quest = g.args['quest'];
|
|
468
|
-
if (quest === 'king-slime') {
|
|
469
|
-
if (kingSlainAt > g.at) { agents.done(b.slot, true); break; }
|
|
470
|
-
hunt = slimes.find((o) => o.size === 3) ?? null;
|
|
471
|
-
if (!hunt) { tx = PLACES['king']!.x; ty = PLACES['king']!.y + 70; }
|
|
472
|
-
} else if (quest === 'big-slime') {
|
|
473
|
-
hunt = slimes.filter((o) => o.size === 2).sort((x, y) => Math.hypot(x.x - b.x, x.y - b.y) - Math.hypot(y.x - b.x, y.y - b.y))[0] ?? null;
|
|
474
|
-
if (!hunt) agents.done(b.slot, true);
|
|
475
|
-
} else {
|
|
476
|
-
// A slime hunt: the slimes near the party, three of them.
|
|
477
|
-
if (!huntFrom.has(b.slot) || huntFrom.get(b.slot)! > b.score) huntFrom.set(b.slot, b.score);
|
|
478
|
-
if (b.score - huntFrom.get(b.slot)! >= 18) { huntFrom.delete(b.slot); agents.done(b.slot, true); break; }
|
|
479
|
-
const ps = people();
|
|
480
|
-
const cx = ps.length ? ps.reduce((a, o) => a + o.x, 0) / ps.length : b.x;
|
|
481
|
-
const cy = ps.length ? ps.reduce((a, o) => a + o.y, 0) / ps.length : b.y;
|
|
482
|
-
hunt = nearTo(cx, cy, 520) ?? nearestSlime(b.x, b.y);
|
|
483
|
-
}
|
|
484
|
-
break;
|
|
485
|
-
}
|
|
486
|
-
case 'lead': {
|
|
487
|
-
const at = PLACES[String(g.args['place'])] ?? PLACES['camp']!;
|
|
488
|
-
tx = at.x; ty = at.y;
|
|
489
|
-
// Done when it is there and a hero came along; given up when nobody came within 20 s of it arriving.
|
|
490
|
-
if (Math.hypot(at.x - b.x, at.y - b.y) < 60) {
|
|
491
|
-
if (!arrivedAt.has(b.slot)) arrivedAt.set(b.slot, now);
|
|
492
|
-
if (people().some((o) => Math.hypot(o.x - at.x, o.y - at.y) < 280)) { arrivedAt.delete(b.slot); agents.done(b.slot, true); }
|
|
493
|
-
else if (now - arrivedAt.get(b.slot)! > 20_000) { arrivedAt.delete(b.slot); agents.done(b.slot, false); }
|
|
494
|
-
} else arrivedAt.delete(b.slot);
|
|
495
|
-
hunt = nearTo(b.x, b.y, 110);
|
|
496
|
-
break;
|
|
497
|
-
}
|
|
498
|
-
case 'guard': {
|
|
499
|
-
const ps = people().filter((o) => Math.hypot(o.x - b.x, o.y - b.y) < 700);
|
|
500
|
-
const cx = ps.length ? ps.reduce((a, o) => a + o.x, 0) / ps.length : b.x;
|
|
501
|
-
const cy = ps.length ? ps.reduce((a, o) => a + o.y, 0) / ps.length : b.y;
|
|
502
|
-
tx = cx + 50; ty = cy + 40;
|
|
503
|
-
hunt = nearTo(cx, cy, standoff(s, 260, 160));
|
|
504
|
-
break;
|
|
505
|
-
}
|
|
506
|
-
case 'back': {
|
|
507
|
-
const at = PLACES['camp']!;
|
|
508
|
-
tx = at.x; ty = at.y;
|
|
509
|
-
if (Math.hypot(at.x - b.x, at.y - b.y) < 50) agents.done(b.slot, true);
|
|
510
|
-
break;
|
|
511
|
-
}
|
|
512
|
-
default: break;
|
|
513
|
-
}
|
|
514
|
-
// Two guides with the same goal never stand in one spot: each keeps its own place around it.
|
|
515
|
-
if (!hunt && g.goal !== 'follow') { const k = b.slot * 2.4; tx += Math.cos(k) * 46; ty += Math.sin(k) * 46; }
|
|
516
|
-
// Reaction time: a guide re-aims only every reactionMs, a little off at a low dial.
|
|
517
|
-
if (hunt) {
|
|
518
|
-
if (!b.seenAt || now - b.seenAt >= s.reactionMs) { b.seenAt = now; b.ax = hunt.x + jitter(s, 40); b.ay = hunt.y + jitter(s, 40); }
|
|
519
|
-
tx = b.ax ?? hunt.x; ty = b.ay ?? hunt.y;
|
|
520
|
-
}
|
|
521
|
-
const dx = tx - b.x; const dy = ty - b.y; const dist = Math.hypot(dx, dy) || 1;
|
|
522
|
-
if (dist > (hunt ? 50 : 24)) { const v = Math.min(SPEED * 0.85, dist / Math.max(dt, 1e-3)); b.x = clamp(b.x + (dx / dist) * v * dt, R, W - R); b.y = clamp(b.y + (dy / dist) * v * dt, R, H - R); b.face = Math.atan2(dy, dx); }
|
|
523
|
-
return Boolean(hunt && Math.hypot(hunt.x - b.x, hunt.y - b.y) < T.strikeRange + hunt.size * 10 && now - b.atkAt > 1400 - 900 * s.aggression);
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
/** The quests open now: a slime hunt always, the King Slime when it is out or close to coming, a big slime when one is. */
|
|
527
|
-
function openQuests(): string[] {
|
|
528
|
-
const now = net.now();
|
|
529
|
-
return ['slime-hunt', ...(slimes.some((o) => o.size === 3) || kingAt - now < 30_000 ? ['king-slime'] : []), ...(slimes.some((o) => o.size === 2) ? ['big-slime'] : [])];
|
|
125
|
+
room.on('say', e => { if (e.slot < 0) return; bubbles.set(e.slot, { text: e.text, until: performance.now() + 3600 }); logGuide({ ev: 'say', slot: e.slot, line: e.line }); });
|
|
126
|
+
room.on('goal', e => logGuide({ ev: 'goal', slot: e.slot, goal: e.goal.goal, args: e.goal.args, from: e.goal.from }));
|
|
127
|
+
room.on('ask', e => logGuide({ ev: 'ask', slot: e.slot, k: e.k, args: e.args, from: e.from }));
|
|
128
|
+
room.on('loot', e => { if (e.id === room.me?.id && !lookOnly) { gain(e.xp, e.gold); net.spotlight(room.seat); } });
|
|
129
|
+
room.on('down', e => { if (e.id === room.me?.id && !lookOnly) void fell(e.cause); });
|
|
130
|
+
const directorLog: Record<string, unknown>[] = [];
|
|
131
|
+
room.on('director', e => { directorLog.push({ at: Date.now(), tactic: e.tactic, pressure: e.pressure, by: e.by }); if (directorLog.length > 40) directorLog.shift(); });
|
|
132
|
+
function mine(): Body { return viewBodies().find(b => b.seat === room.seat && !b.bot) ?? { slot: -1, seat: room.seat, name: net.name, bot: false, score: 0, x: me.x, y: me.y, hp: maxHpOf(myLevel()), maxHp: maxHpOf(myLevel()), level: myLevel(), face: me.face, flags: 0, downUntil: 0, atkAt: 0, strikeAt: 0, tx: 0, ty: 0 }; }
|
|
133
|
+
function nearestSlime(x: number, y: number): Slime | null {
|
|
134
|
+
let best: Slime | null = null, distance = Infinity;
|
|
135
|
+
for (const [id, sx, sy, hp, size] of slimeRows()) { const d = Math.hypot(sx - x, sy - y); if (d < distance) { distance = d; best = { id, x: sx, y: sy, hp, size, maxHp: hp, hitAt: 0, lastHit: -1 }; } }
|
|
136
|
+
return best;
|
|
530
137
|
}
|
|
531
138
|
const zoneOf = (x: number, y: number): string => {
|
|
532
139
|
let best = 'vale'; let bd = 260;
|
|
@@ -534,98 +141,19 @@ const zoneOf = (x: number, y: number): string => {
|
|
|
534
141
|
return best;
|
|
535
142
|
};
|
|
536
143
|
|
|
537
|
-
function nearestSlime(x: number, y: number): Slime | null {
|
|
538
|
-
let best: Slime | null = null; let bd = Infinity;
|
|
539
|
-
for (const s of slimes) { const d = Math.hypot(s.x - x, s.y - y); if (d < bd) { bd = d; best = s; } }
|
|
540
|
-
return best;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
/** A struck slime: held through the hit-stop, then eased back along the strike. True while it is (it does not chase). */
|
|
544
|
-
function pushed(s: Slime, now: number): boolean {
|
|
545
|
-
if (s.kat === undefined || s.kx === undefined || s.ky === undefined) return false;
|
|
546
|
-
if (now < s.kat) return true;
|
|
547
|
-
const u = Math.min(1, (now - s.kat) / Math.max(1, T.pushMs));
|
|
548
|
-
const e = 1 - (1 - u) ** 3;
|
|
549
|
-
s.x = clamp(s.kx + (s.kdx ?? 0) * T.slimePush * e, 20, W - 20); s.y = clamp(s.ky + (s.kdy ?? 0) * T.slimePush * e, 20, H - 20);
|
|
550
|
-
// The push ends exactly where it should, at any frame rate; then the slime chases again.
|
|
551
|
-
if (u >= 1) { delete s.kat; return false; }
|
|
552
|
-
return true;
|
|
553
|
-
}
|
|
554
|
-
function stepSlimes(dt: number, now: number): void {
|
|
555
|
-
const struck = new Set(slimes.filter((s) => pushed(s, now)).map((s) => s.id));
|
|
556
|
-
if (lab.stage === 'dummy') return; // the lab's training slime stands still and never bites
|
|
557
|
-
const bodies = [...room.bodies.values()].filter((b) => !(b.flags & DOWN));
|
|
558
|
-
const people = bodies.filter((b) => !b.bot).length;
|
|
559
|
-
// The director's pressure (0 a breather .. 4 fierce; 2 is how the vale always played) sets how fast and how many.
|
|
560
|
-
const cap = Math.max(4, 6 + people * 2 + (director.pressure - 2) * 2) + (director.waveLeft > 0 ? 3 : 0);
|
|
561
|
-
if ((now >= spawnAt || director.waveLeft > 0) && slimes.length < cap) {
|
|
562
|
-
spawnAt = now + SPAWN_MS[director.pressure]!;
|
|
563
|
-
if (director.waveLeft > 0) director.waveLeft -= 1;
|
|
564
|
-
const edge = Math.floor(Math.random() * 4);
|
|
565
|
-
const x = edge === 0 ? 30 : edge === 1 ? W - 30 : Math.random() * W;
|
|
566
|
-
const y = edge === 2 ? 30 : edge === 3 ? H - 30 : Math.random() * H;
|
|
567
|
-
const size = Math.random() < 0.25 ? 2 : 1;
|
|
568
|
-
slimes.push({ id: ++slimeSeq, x, y, hp: 18 * size, maxHp: 18 * size, size, hitAt: 0, lastHit: -1 });
|
|
569
|
-
}
|
|
570
|
-
if (now >= kingAt && !slimes.some((s) => s.size === 3)) {
|
|
571
|
-
kingAt = now + 60_000;
|
|
572
|
-
slimes.push({ id: ++slimeSeq, x: W / 2, y: 40, hp: 160, maxHp: 160, size: 3, hitAt: 0, lastHit: -1 });
|
|
573
|
-
room.send('king', {});
|
|
574
|
-
}
|
|
575
|
-
// Slimes hunt heroes: the nearest person, and a bot only when no person is up.
|
|
576
|
-
const prey = bodies.some((b) => !b.bot) ? bodies.filter((b) => !b.bot) : bodies;
|
|
577
|
-
for (const s of slimes) {
|
|
578
|
-
if (struck.has(s.id)) continue;
|
|
579
|
-
let target: Body | null = null; let bd = Infinity;
|
|
580
|
-
for (const b of prey) { const d = Math.hypot(b.x - s.x, b.y - s.y); if (d < bd) { bd = d; target = b; } }
|
|
581
|
-
// The director's tactic (section 20; chase is how the vale always played): the most hurt hero, a ring round the
|
|
582
|
-
// nearest, or back to the King to gather (a hero close by is still bitten).
|
|
583
|
-
if (director.tactic === 'weakest' && prey.length > 1) target = prey.reduce((a, b) => (b.hp / b.maxHp < a.hp / a.maxHp ? b : a));
|
|
584
|
-
const speed = s.size === 3 ? 70 : 95 - s.size * 10;
|
|
585
|
-
if (target) {
|
|
586
|
-
let gx = target.x; let gy = target.y;
|
|
587
|
-
const king = slimes.find((o) => o.size === 3);
|
|
588
|
-
if (director.tactic === 'surround' && bd > 90) { const a = s.id * 2.39996; gx += Math.cos(a) * 80; gy += Math.sin(a) * 80; }
|
|
589
|
-
if (director.tactic === 'regroup' && king && s !== king && bd > 140) { gx = king.x + Math.cos(s.id) * 70; gy = king.y + Math.sin(s.id) * 70; }
|
|
590
|
-
const dx = gx - s.x; const dy = gy - s.y; const dist = Math.hypot(target.x - s.x, target.y - s.y) || 1;
|
|
591
|
-
const step = Math.hypot(dx, dy) || 1;
|
|
592
|
-
if (step > 4) { s.x += (dx / step) * speed * dt; s.y += (dy / step) * speed * dt; }
|
|
593
|
-
if (dist < R + 10 + s.size * 10 && now - s.hitAt > 800) {
|
|
594
|
-
s.hitAt = now;
|
|
595
|
-
target.hp -= 5 * s.size + (s.size === 3 ? 10 : 0);
|
|
596
|
-
if (target.hp <= 0) down(target, now, s.size === 3 ? 'the King Slime' : s.size === 2 ? 'a big slime' : 'a slime');
|
|
597
|
-
}
|
|
598
|
-
} else { s.x += Math.sin(now / 900 + s.id) * 20 * dt; s.y += Math.cos(now / 1100 + s.id) * 20 * dt; }
|
|
599
|
-
s.x = clamp(s.x, 20, W - 20); s.y = clamp(s.y, 20, H - 20);
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
/* ------------------------------------------------------------------ input */
|
|
604
144
|
const input = createControls({ actions: { strike: ['Space', 'KeyJ', 'Enter'] }, touch: { buttons: [{ id: 'strike', label: 'STRIKE' }] } });
|
|
605
145
|
// A touch screen has STRIKE at the bottom right: the ask panel stands right above it (index.html, .touch).
|
|
606
146
|
document.body.classList.toggle('touch', input.touch.enabled);
|
|
607
|
-
|
|
608
|
-
const
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
const
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
}
|
|
617
|
-
function stepMe(dt: number): void {
|
|
618
|
-
const b = room.hosting ? room.mine() : null;
|
|
619
|
-
const isDown = b ? Boolean(b.flags & DOWN) : Boolean(myViewBody()?.flags && (myViewBody()!.flags & DOWN));
|
|
620
|
-
const typing = !ui.idle();
|
|
621
|
-
const m = typing || isDown ? { x: 0, y: 0 } : input.move();
|
|
622
|
-
if (Math.hypot(m.x, m.y) > 0.05) { me.face = Math.atan2(m.y, m.x); me.x = clamp(me.x + m.x * SPEED * dt, R, W - R); me.y = clamp(me.y + m.y * SPEED * dt, R, H - R); me.has = true; }
|
|
623
|
-
if (!typing && input.pressed('strike')) { if (room.hosting) me.strikes += 1; else room.press('strike'); me.flashAt = performance.now(); if (!isDown) { lunge.at = performance.now(); lunge.face = me.face; lunge.done = 0; } }
|
|
624
|
-
if (!isDown) stepLunge();
|
|
625
|
-
room.input([q(me.x, 0), q(me.y, 0), q(me.face, 2), myLevel()]);
|
|
626
|
-
}
|
|
627
|
-
function myViewBody(): Body | null { const s = room.mySeat(); return room.view().find((b) => b.seat === s && !b.bot) ?? null; }
|
|
628
|
-
|
|
147
|
+
function stepMe(): void {
|
|
148
|
+
const typing = !ui.idle(), own = room.me;
|
|
149
|
+
const m = typing || own?.hp === 0 ? { x: 0, y: 0 } : input.move();
|
|
150
|
+
const strike = !typing && input.pressed('strike');
|
|
151
|
+
room.input({ ax: Math.round(m.x * 127), ay: Math.round(m.y * 127), strike });
|
|
152
|
+
const body = room.me; me.has = Boolean(body);
|
|
153
|
+
if (body) { me.x = body.pos.x * 50; me.y = body.pos.y * 50; me.face = Math.atan2(body.heading.y, body.heading.x); }
|
|
154
|
+
if (strike) { me.flashAt = performance.now(); if (lab.on) labStrike(mine(), net.now(), []); }
|
|
155
|
+
}
|
|
156
|
+
function myViewBody(): Body | null { return viewBodies().find(b => b.seat === room.seat && !b.bot) ?? null; }
|
|
629
157
|
/* ------------------------------------------------------------------ drawing */
|
|
630
158
|
const canvas = document.getElementById('c') as HTMLCanvasElement;
|
|
631
159
|
const ctx = canvas.getContext('2d', { alpha: false }) as CanvasRenderingContext2D;
|
|
@@ -662,7 +190,7 @@ let kickAt = -1e9;
|
|
|
662
190
|
function striker(x: number, y: number, bodies: Body[]): Body | null {
|
|
663
191
|
let best: Body | null = null; let bd = Infinity;
|
|
664
192
|
for (const b of bodies) {
|
|
665
|
-
const mineNow = !b.bot && b.seat === room.
|
|
193
|
+
const mineNow = !b.bot && b.seat === room.seat && performance.now() - me.flashAt < T.flashMs;
|
|
666
194
|
if (!(b.flags & STRIKING) && !mineNow) continue;
|
|
667
195
|
const d = Math.hypot(b.x - x, b.y - y);
|
|
668
196
|
if (d < bd && d < T.strikeRange + 60) { bd = d; best = b; }
|
|
@@ -670,18 +198,19 @@ function striker(x: number, y: number, bodies: Body[]): Body | null {
|
|
|
670
198
|
return best;
|
|
671
199
|
}
|
|
672
200
|
function watchSlimes(t: number, bodies: Body[]): void {
|
|
673
|
-
const rows =
|
|
201
|
+
const rows = slimeRows() ?? [];
|
|
674
202
|
const here = new Set<number>();
|
|
675
203
|
for (const [id, x, y, hp, size] of rows) {
|
|
676
204
|
here.add(id);
|
|
677
205
|
const s = seen.get(id);
|
|
678
206
|
if (!s) { seen.set(id, { x, y, hp, size, hitAt: -1e9, dx: 0, dy: 0 }); continue; }
|
|
679
207
|
if (hp < s.hp) {
|
|
208
|
+
if (lab.on && subject.id === id) subject.hit = true;
|
|
680
209
|
const by = striker(x, y, bodies);
|
|
681
210
|
const d = by ? Math.hypot(x - by.x, y - by.y) || 1 : 1;
|
|
682
211
|
s.hitAt = t; s.dx = by ? (x - by.x) / d : 0; s.dy = by ? (y - by.y) / d : -1;
|
|
683
212
|
numbers.push({ x: x + ((numbers.length % 3) - 1) * 16, y: y - (12 + size * 10), text: `-${Math.round(s.hp - hp)}`, at: t });
|
|
684
|
-
if (by && !by.bot && by.seat === room.
|
|
213
|
+
if (by && !by.bot && by.seat === room.seat) kickAt = t;
|
|
685
214
|
}
|
|
686
215
|
s.x = x; s.y = y; s.hp = hp; s.size = size;
|
|
687
216
|
}
|
|
@@ -690,9 +219,10 @@ function watchSlimes(t: number, bodies: Body[]): void {
|
|
|
690
219
|
seen.delete(id);
|
|
691
220
|
const by = striker(s.x, s.y, bodies);
|
|
692
221
|
if (!by) continue; // gone without a strike: the night ended
|
|
222
|
+
if (lab.on && subject.id === id) subject.hit = true;
|
|
693
223
|
pops.push({ x: s.x, y: s.y, size: s.size, at: t });
|
|
694
224
|
numbers.push({ x: s.x, y: s.y - (12 + s.size * 10), text: `-${Math.round(s.hp)}`, at: t });
|
|
695
|
-
if (!by.bot && by.seat === room.
|
|
225
|
+
if (!by.bot && by.seat === room.seat) kickAt = t;
|
|
696
226
|
for (let i = 0; i < 10; i += 1) { const a = lab.random() * Math.PI * 2; const v = 120 + lab.random() * 260; drops.push({ x: s.x, y: s.y, vx: Math.cos(a) * v, vy: Math.sin(a) * v - 80, at: t, life: 260 + lab.random() * 200, colour: SLIME[s.size - 1] ?? '#7ad35a' }); }
|
|
697
227
|
}
|
|
698
228
|
if (drops.length > 200) drops.splice(0, drops.length - 200);
|
|
@@ -791,7 +321,7 @@ net.on('unchat', (e) => { for (const id of e.ids) chatBubbles.remove(id); });
|
|
|
791
321
|
let shownChat: BubbleOut[] = [];
|
|
792
322
|
let shownLabels: LabelOut[] = [];
|
|
793
323
|
function aim(): Fit {
|
|
794
|
-
const v = lookOnly ?
|
|
324
|
+
const v = lookOnly ? viewBody() : null;
|
|
795
325
|
const focus = !lookOnly && me.has ? { x: me.x, y: me.y } : v ? { x: v.x, y: v.y } : null;
|
|
796
326
|
const phone = Math.min(vw, vh) <= 540;
|
|
797
327
|
// The Game Lab's views (outside the lab: the game's own camera, always).
|
|
@@ -820,8 +350,8 @@ function draw(t: number): void {
|
|
|
820
350
|
ctx.fillStyle = i % 5 ? 'rgba(122,211,90,.10)' : 'rgba(255,170,80,.35)';
|
|
821
351
|
ctx.beginPath(); ctx.arc(x, H - y, i % 5 ? 26 : 2.5, 0, Math.PI * 2); ctx.fill();
|
|
822
352
|
}
|
|
823
|
-
const fast =
|
|
824
|
-
const everyone =
|
|
353
|
+
const fast = slimeRows() ?? [];
|
|
354
|
+
const everyone = viewBodies();
|
|
825
355
|
watchSlimes(t, everyone);
|
|
826
356
|
for (const [id, x, y, hp, size] of fast) {
|
|
827
357
|
const r = 12 + size * 10;
|
|
@@ -839,7 +369,7 @@ function draw(t: number): void {
|
|
|
839
369
|
ctx.fillStyle = '#ff7a59'; ctx.fillRect(x - r, y + r + 4, (r * 2 * clamp(hp, 0, max)) / max, 4);
|
|
840
370
|
}
|
|
841
371
|
// The hero whose view this is: my own, or the one a watcher follows (null: the vale, nobody in gold).
|
|
842
|
-
const viewS =
|
|
372
|
+
const viewS = net.viewSeat;
|
|
843
373
|
const sx = (x: number): number => vw / 2 + (x - (cam as Fit).x) * s;
|
|
844
374
|
const sy = (y: number): number => vh / 2 + (y - (cam as Fit).y) * s;
|
|
845
375
|
// Names are drawn after the vale, on the screen, at a size that reads on any screen; they never pile up.
|
|
@@ -849,10 +379,10 @@ function draw(t: number): void {
|
|
|
849
379
|
const said: { x: number; y: number; head: number; text: string }[] = [];
|
|
850
380
|
const heads = new Map<number, { x: number; y: number; seat: number; self: boolean }>();
|
|
851
381
|
const mine: ScreenBox[] = []; // your own hero and its name: no bubble covers them
|
|
852
|
-
const meAt = (() => { const v =
|
|
853
|
-
for (const b of
|
|
382
|
+
const meAt = (() => { const v = viewBodies().find((b) => !b.bot && b.seat === viewS); return v ? { x: v.x, y: v.y } : null; })();
|
|
383
|
+
for (const b of viewBodies()) {
|
|
854
384
|
const self = !b.bot && b.seat === viewS;
|
|
855
|
-
const local = self && !
|
|
385
|
+
const local = self && !net.watching;
|
|
856
386
|
const x = local ? me.x : b.x; const y = local ? me.y : b.y;
|
|
857
387
|
const isDown = Boolean(b.flags & DOWN);
|
|
858
388
|
ctx.globalAlpha = isDown ? 0.35 : 1;
|
|
@@ -862,7 +392,7 @@ function draw(t: number): void {
|
|
|
862
392
|
if (striking && (!sw || t - sw.at > T.strikeMs * 0.9)) swings.set(b.slot, { at: self && performance.now() - me.flashAt < T.flashMs ? t - (performance.now() - me.flashAt) : t, face: self ? me.face : b.face });
|
|
863
393
|
const swing = swings.get(b.slot);
|
|
864
394
|
if (swing) drawSwing(x, y, swing.face, t - swing.at);
|
|
865
|
-
const isGuide =
|
|
395
|
+
const isGuide = b.agent?.role === 'guide';
|
|
866
396
|
ctx.fillStyle = self ? '#ffcf6e' : isGuide ? '#7fd8c8' : b.bot ? '#8aa0b8' : '#f3ead2';
|
|
867
397
|
ctx.beginPath(); ctx.arc(x, y, R, 0, Math.PI * 2); ctx.fill();
|
|
868
398
|
ctx.fillStyle = '#0b0f0a'; ctx.beginPath(); ctx.arc(x + Math.cos(b.face) * 10, y + Math.sin(b.face) * 10, 5, 0, Math.PI * 2); ctx.fill();
|
|
@@ -964,10 +494,10 @@ function hud(t: number): void {
|
|
|
964
494
|
const TITLE = '700 16px ui-sans-serif, system-ui, sans-serif';
|
|
965
495
|
const SMALL = '600 13px ui-sans-serif, system-ui, sans-serif';
|
|
966
496
|
// A watcher's panel is the followed hero's, as the room has them (their save stays in their own browser).
|
|
967
|
-
const v = lookOnly ?
|
|
968
|
-
const heroes =
|
|
497
|
+
const v = lookOnly ? viewBody() : null;
|
|
498
|
+
const heroes = viewBodies().filter((b) => !b.bot).length;
|
|
969
499
|
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…';
|
|
970
|
-
const sub = lookOnly ? (v ? `${v.score} xp tonight${v.flags & DOWN ? ' · down' : ''}` : `${heroes} ${heroes === 1 ? 'hero' : 'heroes'} · ${(
|
|
500
|
+
const sub = lookOnly ? (v ? `${v.score} xp tonight${v.flags & DOWN ? ' · down' : ''}` : `${heroes} ${heroes === 1 ? 'hero' : 'heroes'} · ${(slimeRows() ?? []).length} slimes`) : hero ? `${hero.gold} gold · ${hero.kills} slain` : '';
|
|
971
501
|
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;
|
|
972
502
|
// The panel is as wide as what it says: on a phone the vale runs under it, and the clock sits beside it.
|
|
973
503
|
ctx.font = TITLE; const titleW = ctx.measureText(title).width;
|
|
@@ -981,15 +511,16 @@ function hud(t: number): void {
|
|
|
981
511
|
ctx.fillStyle = bar.colour; ctx.fillRect(x0 + 10, y0 + 32, 140 * clamp(bar.of, 0, 1), 6);
|
|
982
512
|
}
|
|
983
513
|
if (sub) { ctx.font = SMALL; ctx.fillStyle = '#e7d9b4'; ctx.fillText(sub, x0 + 10, y0 + (bar ? 54 : 46)); }
|
|
984
|
-
const c = room.
|
|
514
|
+
const c = (room.round ?? { phase: 'live', secondsLeft: 90, n: 1 });
|
|
985
515
|
ctx.font = TITLE; ctx.fillStyle = '#f3ead2';
|
|
986
516
|
const cw = canvas.width / k;
|
|
987
|
-
const
|
|
517
|
+
const secondsLeft = Math.max(0, Math.ceil(c.secondsLeft));
|
|
518
|
+
const clock = c.phase === 'over' ? `Dawn · night ${c.n + 1} in ${secondsLeft}s` : `Night ${c.n} · ${Math.floor(secondsLeft / 60)}:${String(secondsLeft % 60).padStart(2, '0')}`;
|
|
988
519
|
// Top centre; on a narrow screen where that would touch the panel, just under it.
|
|
989
520
|
if (x0 + pw + 10 > cw / 2 - ctx.measureText(clock).width / 2) { ctx.textAlign = 'left'; ctx.fillText(clock, x0 + 10, y0 + 62 + 20); }
|
|
990
521
|
else { ctx.textAlign = 'center'; ctx.fillText(clock, cw / 2, 30); }
|
|
991
522
|
// What the slimes are up to, when the studio's decision model chose it (section 20; nothing shows on the floor).
|
|
992
|
-
const mind = c.phase === 'live' ?
|
|
523
|
+
const mind = c.phase === 'live' ? (room.shared.directorBy === 'floor' ? null : { tactic: room.shared.tactic, pressure: room.shared.pressure }) : null;
|
|
993
524
|
if (mind) {
|
|
994
525
|
const words: Record<string, string> = { chase: 'rushing you', surround: 'closing in from every side', weakest: 'hunting the hurt', regroup: 'gathering round the King' };
|
|
995
526
|
ctx.font = SMALL; ctx.fillStyle = '#e7a86b'; ctx.textAlign = 'center';
|
|
@@ -997,7 +528,7 @@ function hud(t: number): void {
|
|
|
997
528
|
}
|
|
998
529
|
ctx.textAlign = 'center';
|
|
999
530
|
if (c.phase === 'over') {
|
|
1000
|
-
const rows = room.results
|
|
531
|
+
const rows = (room.round?.results ?? []).slice(0, 5);
|
|
1001
532
|
// Under the page's own pills on a narrow screen (they are buttons, above the canvas), else under the clock.
|
|
1002
533
|
const top = vw <= 540 ? 132 * (dpr / k) : 44;
|
|
1003
534
|
ctx.fillStyle = 'rgba(10,14,9,.78)'; ctx.fillRect(cw / 2 - 150, top, 300, 30 + rows.length * 22);
|
|
@@ -1073,13 +604,12 @@ let asksSig = '';
|
|
|
1073
604
|
const askEls = new Map<string, HTMLButtonElement>();
|
|
1074
605
|
function paintAsks(): void {
|
|
1075
606
|
const panel = $('asks');
|
|
1076
|
-
const mySeat = room.
|
|
607
|
+
const mySeat = room.seat;
|
|
1077
608
|
const here = me.has && mySeat !== null && !lookOnly && ui.idle();
|
|
1078
609
|
let best: { slot: number; name: string; d: number } | null = null;
|
|
1079
610
|
if (here) {
|
|
1080
|
-
for (const b of
|
|
1081
|
-
|
|
1082
|
-
if (!sl?.agent || sl.agent.role !== 'guide') continue;
|
|
611
|
+
for (const b of viewBodies()) {
|
|
612
|
+
if (b.agent?.role !== 'guide') continue;
|
|
1083
613
|
// The guide the panel is for keeps it while it is in reach, unless another is much nearer: two guides at the
|
|
1084
614
|
// party's side would otherwise swap it four times a second, and a thumb's tap would land on a button just redrawn.
|
|
1085
615
|
const d = Math.hypot(b.x - me.x, b.y - me.y);
|
|
@@ -1088,10 +618,11 @@ function paintAsks(): void {
|
|
|
1088
618
|
}
|
|
1089
619
|
}
|
|
1090
620
|
if (!best) { panel.hidden = true; asksFor = -1; asksSig = ''; return; }
|
|
1091
|
-
const quests =
|
|
621
|
+
const quests = room.shared.quests.slice(0, 3);
|
|
1092
622
|
// Help with each open quest, one place to be led to (not the one I am in), and "No thanks"; three on a phone (either way up).
|
|
1093
623
|
const zone = zoneOf(me.x, me.y);
|
|
1094
|
-
const
|
|
624
|
+
const id = guideId(best.slot);
|
|
625
|
+
const all = id ? room.askButtons(id, { quests }) : [];
|
|
1095
626
|
const lead = all.filter((b) => b.k === 'lead_me' && b.args['place'] !== zone).slice(0, 1);
|
|
1096
627
|
const order = [...all.filter((b) => b.k === 'ask_help'), ...lead, ...all.filter((b) => b.k === 'no_thanks')];
|
|
1097
628
|
const buttons = order.slice(0, Math.min(innerWidth, innerHeight) < 540 ? 3 : 5);
|
|
@@ -1116,7 +647,8 @@ function paintAsks(): void {
|
|
|
1116
647
|
btn.dataset['ask'] = b.k;
|
|
1117
648
|
btn.addEventListener('click', () => {
|
|
1118
649
|
if (asksFor < 0) return;
|
|
1119
|
-
|
|
650
|
+
const id = guideId(asksFor);
|
|
651
|
+
if (id) { room.ask(id, b.k, b.args); logGuide({ ev: 'asked', slot: asksFor, k: b.k, args: b.args }); btn.classList.add('sent'); setTimeout(() => btn.classList.remove('sent'), 900); }
|
|
1120
652
|
});
|
|
1121
653
|
askEls.set(key, btn);
|
|
1122
654
|
el = btn;
|
|
@@ -1143,7 +675,7 @@ function labStrike(b: Body, now: number, hits: Slime[]): void {
|
|
|
1143
675
|
}
|
|
1144
676
|
/** The subject slime as drawn (the fast world's row), or null once it is gone. */
|
|
1145
677
|
function subjectSlime(): { x: number; y: number; hp: number; size: number } | null {
|
|
1146
|
-
const row = (
|
|
678
|
+
const row = (slimeRows() ?? []).find((r) => r[0] === subject.id);
|
|
1147
679
|
return row ? { x: row[1], y: row[2], hp: row[3], size: row[4] } : null;
|
|
1148
680
|
}
|
|
1149
681
|
function labReport(): void {
|
|
@@ -1162,15 +694,6 @@ function labReport(): void {
|
|
|
1162
694
|
else lab.phase(null);
|
|
1163
695
|
if (sl) lab.pose('slime', { x: sl.x, y: sl.y, r: 12 + sl.size * 10 });
|
|
1164
696
|
}
|
|
1165
|
-
/** The lab's training stage: the hero at camp facing right, a big slime a step away, the bots off to the side. */
|
|
1166
|
-
let staged = false;
|
|
1167
|
-
function stageDummy(): void {
|
|
1168
|
-
staged = true;
|
|
1169
|
-
me.x = W / 2 - 70; me.y = H / 2; me.face = 0; me.has = true;
|
|
1170
|
-
let i = 0;
|
|
1171
|
-
for (const b of room.bodies.values()) if (b.bot) { b.x = 200 + (i % 2) * 1200; b.y = 160 + Math.floor(i / 2) * 680; b.tx = b.x; b.ty = b.y; i += 1; room.moved(b); }
|
|
1172
|
-
slimes = [{ id: ++slimeSeq, x: W / 2 + 30, y: H / 2, hp: 36, maxHp: 36, size: 2, hitAt: 0, lastHit: -1 }];
|
|
1173
|
-
}
|
|
1174
697
|
const labView = lab.camera<{ zoom?: number; whole?: boolean } | null>({ game: null, close: { zoom: 2.2 }, whole: { whole: true } });
|
|
1175
698
|
lab.overlay('onion', (c, k) => {
|
|
1176
699
|
const g = c as CanvasRenderingContext2D; const s = Number(k) || 1;
|
|
@@ -1198,10 +721,8 @@ lab.overlay('reach', (c, k) => {
|
|
|
1198
721
|
let frames = 0;
|
|
1199
722
|
function frame(t: number): void {
|
|
1200
723
|
const dt = lab.time.dt(t, 0.05);
|
|
1201
|
-
|
|
1202
|
-
stepMe(dt);
|
|
724
|
+
stepMe();
|
|
1203
725
|
keepNames(t);
|
|
1204
|
-
if (room.hosting) stepHost(dt);
|
|
1205
726
|
draw(t);
|
|
1206
727
|
if (lab.on) labReport();
|
|
1207
728
|
frames += 1;
|
|
@@ -1212,16 +733,11 @@ function frame(t: number): void {
|
|
|
1212
733
|
net.expose({
|
|
1213
734
|
// Room chat's bubbles on this screen now: whose, what, and where (an end-to-end test reads them).
|
|
1214
735
|
chatBubbles: () => shownChat.map((b) => ({ seat: b.key, text: b.lines.join(' '), alpha: b.alpha, left: Math.round(b.left), top: Math.round(b.top), right: Math.round(b.right), bottom: Math.round(b.bottom) })),
|
|
1215
|
-
scores: () =>
|
|
736
|
+
scores: () => viewBodies().map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })),
|
|
1216
737
|
// The guides, for the e2e probe: each guide's goal now, and the log of goals, asks and lines (seats and ids only).
|
|
1217
|
-
guides: () => ({
|
|
1218
|
-
hosting: room.hosting, talking: agents.talking, stats: agents.stats(),
|
|
1219
|
-
now: agents.guides().map((g) => { const v = room.view().find((b) => b.slot === g.slot); return { slot: g.slot, seat: g.agent?.seat ?? null, name: g.name, goal: agents.goalOf(g.slot), x: v ? Math.round(v.x) : null, y: v ? Math.round(v.y) : null }; }),
|
|
1220
|
-
me: me.has ? { x: Math.round(me.x), y: me.y | 0 } : null,
|
|
1221
|
-
log: guideLog.slice(),
|
|
1222
|
-
}),
|
|
738
|
+
guides: () => ({ now: viewBodies().filter(b => b.agent).map(b => { const e = room.get(guideId(b.slot) ?? ''); return { slot: b.slot, x: b.x, y: b.y, goal: e?.kind === 'hero' ? e.goal : null }; }), log: guideLog.slice() }),
|
|
1223
739
|
// The slimes' director (section 20): its tactic now, and each decision (who answered, how long it took).
|
|
1224
|
-
director: () => ({ tactic:
|
|
740
|
+
director: () => ({ tactic: room.shared.tactic, pressure: room.shared.pressure, by: room.shared.directorBy, log: directorLog.slice() }),
|
|
1225
741
|
// Where the camera looks, and the names as drawn (the e2e probe counts overlaps, checks your own, and reads them).
|
|
1226
742
|
camera: () => (cam ? { x: cam.x, y: cam.y, scale: cam.scale, follow: cam.follow } : null),
|
|
1227
743
|
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) })),
|
|
@@ -1229,14 +745,13 @@ net.expose({
|
|
|
1229
745
|
|
|
1230
746
|
exposePort(net, {
|
|
1231
747
|
view: 'top',
|
|
1232
|
-
self: () => (me.has && room.
|
|
748
|
+
self: () => (me.has && room.seat !== null ? { x: me.x, y: me.y } : null),
|
|
1233
749
|
size: R,
|
|
1234
|
-
score: () => { const s = room.
|
|
750
|
+
score: () => { const s = room.seat; return viewBodies().find((b) => b.seat === s && !b.bot)?.score ?? null; },
|
|
1235
751
|
busy: () => !ui.idle(),
|
|
1236
752
|
// `alive` is read by name by the playtest (port/probe.ts PortExtra): a downed hero is waiting to stand up again, and a
|
|
1237
753
|
// press that moves nothing then is not a broken control.
|
|
1238
754
|
extra: { alive: () => !((myViewBody()?.flags ?? 0) & DOWN), level: () => hero?.level ?? 0, frames: () => frames, saves: () => saves.status().mode },
|
|
1239
755
|
});
|
|
1240
756
|
|
|
1241
|
-
void net.ready.then(() => { const b = room.mine(); if (b) { me.x = b.x; me.y = b.y; me.has = true; } });
|
|
1242
757
|
requestAnimationFrame(frame);
|