@homie-rocks/studio 0.37.2 → 0.39.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 +61 -0
- package/bin/homie-studio.mjs +16 -4
- package/lib/build.mjs +3 -0
- package/lib/cloudflare.mjs +2 -2
- package/lib/dev.mjs +12 -1
- package/lib/mcp-tools.mjs +10 -0
- package/lib/perf.mjs +17 -10
- package/lib/port-check.mjs +101 -19
- package/lib/rules-check-worker.mjs +15 -2
- package/lib/scaffold.mjs +19 -6
- package/lib/shoot.mjs +11 -5
- package/lib/studio.mjs +1 -1
- package/lib/template-history.json +85 -0
- package/lib/tools-build.mjs +55 -0
- package/lib/tools-cli.mjs +55 -0
- package/lib/worker-build.mjs +35 -0
- package/netplay/NETPLAY.md +20 -6
- package/netplay/netplay.ts +3 -1
- package/package.json +17 -6
- package/port/probe.ts +2 -1
- package/rules/core.ts +3 -1
- package/rules/host.ts +3 -1
- package/rules/pack.ts +30 -3
- package/rules/view.ts +232 -40
- package/starters/coin-dash/lab.json +17 -0
- package/starters/coin-dash/src/rules.ts +3 -3
- package/starters/gem-rush/game.json +19 -4
- package/starters/gem-rush/map/main.json +50 -0
- package/starters/gem-rush/src/move.ts +23 -0
- package/starters/gem-rush/src/rules.ts +118 -0
- package/starters/gem-rush/src/view.ts +429 -0
- package/starters/gem-rush/tunables.json +98 -11
- package/tools/TOOLS.md +113 -0
- package/tools/tools.ts +28 -0
- package/worker/app-records.mjs +8 -0
- package/worker/index.mjs +26 -0
- package/worker/mcp-room.mjs +61 -0
- package/worker/mcp-store.mjs +45 -0
- package/worker/mcp-tools.mjs +105 -0
- package/worker/mcp.mjs +159 -0
- package/worker/office-page.mjs +1 -1
- package/worker/office.mjs +2 -1
- package/worker/purchase-mcp.mjs +3 -3
- package/worker/tool-events.mjs +14 -0
- package/worker/tool-identity.mjs +4 -0
- package/worker/tool-webhook.mjs +28 -0
- package/worker/version.mjs +1 -1
- package/starters/gem-rush/src/main.ts +0 -1087
package/rules/view.ts
CHANGED
|
@@ -21,11 +21,9 @@ import { rulesCaps, rulesRates } from '../worker/limits.mjs';
|
|
|
21
21
|
* room.command(name, data) a reliable one-shot request to the player's own body
|
|
22
22
|
* room.round, room.roster, room.shared, room.seat, room.status
|
|
23
23
|
*
|
|
24
|
-
* THE OWN BODY.
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* on the next drawn frame. Everything the rules decide (score, pickups, rounds, where a body is placed) comes from the
|
|
28
|
-
* server and cannot be changed from here. A later release has the server move every body and this library predict.
|
|
24
|
+
* THE OWN BODY. This browser predicts its own movement with the server's guarded `move` module. Snapshots are
|
|
25
|
+
* authoritative: the view replays pending inputs from each snapshot and eases the visual correction. Large
|
|
26
|
+
* disagreements draw the corrected path at catch-up speed. Scores, pickups and effects come only from the host.
|
|
29
27
|
*
|
|
30
28
|
* THE CLOCK AND THE INPUT (rooms-milestone-1-design.md, sections 4.4 and 6.1). Input is one step a tick, stamped with
|
|
31
29
|
* its tick. This browser steps a little ahead of the server so an entry arrives before its tick runs, and holds that
|
|
@@ -43,7 +41,7 @@ export function setAgentFactory(factory: typeof useAgents): void { agentFactory
|
|
|
43
41
|
import { createNetplay } from '../netplay/netplay.ts';
|
|
44
42
|
import type { Netplay, NetplayOptions, RulesHostFactory, RoundInfo, Snapshot, StepEntry } from '../netplay/netplay.ts';
|
|
45
43
|
import { lab } from '../lab/lab.ts';
|
|
46
|
-
import { exposePort } from '../port/probe.ts';
|
|
44
|
+
import { exposePort, type PortProbeOptions } from '../port/probe.ts';
|
|
47
45
|
import { BudgetError } from './guard.ts';
|
|
48
46
|
import { moveContext } from './math.ts';
|
|
49
47
|
import { coerce, dir, stepMove, thawFields, unpackEntity, unpackFields, unpackVec, vec3 } from './pack.ts';
|
|
@@ -68,7 +66,16 @@ export interface Entity {
|
|
|
68
66
|
export type RoomStatus = 'connecting' | 'playing' | 'offline' | 'closed';
|
|
69
67
|
export interface RoomRound { n: number; phase: 'live' | 'over'; endsAt: number; secondsLeft: number; results?: RoundInfo['results'] }
|
|
70
68
|
export interface RosterRow { seat: number; name: string; driver: 'person' | 'bot' | 'ai'; score: number; me: boolean }
|
|
71
|
-
export interface OpenRoomOptions {
|
|
69
|
+
export interface OpenRoomOptions {
|
|
70
|
+
game?: GameData;
|
|
71
|
+
move?: Record<string, MoveFn>;
|
|
72
|
+
net?: NetplayOptions;
|
|
73
|
+
/** Camera axes on the rules' x/y plane for the testing tools. Default: x right, y up. */
|
|
74
|
+
screenBasis?: PortProbeOptions['basis'];
|
|
75
|
+
/** Tests: a clock in ms and a way to run without timers. */
|
|
76
|
+
now?: () => number;
|
|
77
|
+
timers?: boolean;
|
|
78
|
+
}
|
|
72
79
|
export interface Room<R = unknown> {
|
|
73
80
|
readonly status: RoomStatus;
|
|
74
81
|
readonly seat: number | null;
|
|
@@ -113,7 +120,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
113
120
|
const period = 1000 / tickHz;
|
|
114
121
|
const sendEvery = Math.ceil(tickHz / Math.max(1, schema.settings.inputHz));
|
|
115
122
|
const clock = opts.now ?? ((): number => (typeof performance !== 'undefined' ? performance.now() : Date.now()));
|
|
116
|
-
const net = createNetplay<unknown, unknown, unknown>({ game: game.id, maxPlayers: schema.seats, snapshotHz: tickHz, heartbeatMs: 0, rulesHost: { mode: schema.settings.host, offline: schema.settings.offline, load: current?.load }, ...opts.net, rules: true, rulesLimits: { output: rulesOutput, bytes: rulesCaps(schema.seats), rates: rulesRates(tickHz, schema.seats) } });
|
|
123
|
+
const net = createNetplay<unknown, unknown, unknown>({ game: game.id, maxPlayers: schema.seats, snapshotHz: tickHz, interpFloorMs: schema.settings.predict.interpMs ?? period * sendEvery, heartbeatMs: 0, rulesHost: { mode: schema.settings.host, offline: schema.settings.offline, load: current?.load }, ...opts.net, rules: true, rulesLimits: { output: rulesOutput, bytes: rulesCaps(schema.seats), rates: rulesRates(tickHz, schema.seats) } });
|
|
117
124
|
const agents = game.vocab && agentFactory ? agentFactory(net, game.vocab, { roles: ['guide', 'party'], view: () => ({}), manual: true, viewOnly: true }) : null;
|
|
118
125
|
const kindOf = new Map(schema.kinds.map((k) => [k.name, k]));
|
|
119
126
|
const listeners = new Map<string, Set<(e: any) => void>>();
|
|
@@ -157,7 +164,10 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
157
164
|
/** The clock: the room tick this browser is on at `at` (ms), and the newest step it has run. */
|
|
158
165
|
let base: { tick: number; at: number } | null = null;
|
|
159
166
|
let stepped = 0;
|
|
167
|
+
// Lead observations use one clock phase: steering our clock is not network jitter.
|
|
160
168
|
const leads: number[] = [];
|
|
169
|
+
const phases: { tick: number; at: number; rate: number }[] = [];
|
|
170
|
+
const hostAges: number[] = [];
|
|
161
171
|
/** What the player holds, the presses since the last step, what the server was last told, and the entries of this send period. */
|
|
162
172
|
let sample: Record<string, unknown> = {};
|
|
163
173
|
let pressed = new Set<string>();
|
|
@@ -165,52 +175,133 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
165
175
|
let lastSendAt = 0;
|
|
166
176
|
let entries: { t: number; row: number[] }[] = [];
|
|
167
177
|
let hiddenSent = false;
|
|
168
|
-
const
|
|
178
|
+
const history = new Map<number, Mine>();
|
|
179
|
+
let pending: { t: number; input: Readonly<Record<string, unknown>> }[] = [];
|
|
180
|
+
let replayHeld: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
181
|
+
let speed = 1;
|
|
182
|
+
let leadAfter = 0;
|
|
183
|
+
let observedLeadAt = 0;
|
|
184
|
+
let catchTick: number | null = null;
|
|
185
|
+
let catchAt = 0;
|
|
186
|
+
let offsets: { delta: Vec3; at: number; path: Vec3; fade: number; last: number }[] = [];
|
|
187
|
+
const predict = schema.settings.predict;
|
|
188
|
+
const quantile = (list: number[], q: number): number => [...list].sort((a, b) => a - b)[Math.floor((list.length - 1) * q)] ?? 0;
|
|
189
|
+
const targetLead = (): number => 0.5 + Math.max(0, quantile(leads, 0.5) - quantile(leads, 0.05));
|
|
190
|
+
const copyMine = (m: Mine): Mine => ({ ...m, motion: motionOf(m.kind, m.motion) });
|
|
191
|
+
const distance = (a: Vec3, b: Vec3): number => Math.hypot(a.x - b.x, a.y - b.y, a.z - b.z);
|
|
192
|
+
const correction = { count: 0, max: 0, last: 0, catches: 0, snaps: 0, rebases: 0, rebaseReason: '', rebaseError: 0 };
|
|
193
|
+
function visualOffset(now: number, path?: Vec3, settled = false): Vec3 {
|
|
194
|
+
if (predict.blendMs <= 0) { offsets = []; return { x: 0, y: 0, z: 0 }; }
|
|
195
|
+
offsets = offsets.filter(o => o.fade > 0);
|
|
196
|
+
const sum = { x: 0, y: 0, z: 0 };
|
|
197
|
+
let waiting = false;
|
|
198
|
+
for (const o of offsets) {
|
|
199
|
+
if (path) {
|
|
200
|
+
// Loss can make even a small correction longer than the distance travelled in
|
|
201
|
+
// 100 ms. Bound its fade by actual progress, or it pulls steady movement backwards.
|
|
202
|
+
// Only one queued fade advances at a time. A stopped body finishes its correction.
|
|
203
|
+
if (waiting) o.at = now;
|
|
204
|
+
else {
|
|
205
|
+
const length = Math.hypot(o.delta.x, o.delta.y, o.delta.z);
|
|
206
|
+
const elapsed = Math.max(0, now - o.last);
|
|
207
|
+
// A knock may travel faster than normal movement, and catch-up already accelerates it.
|
|
208
|
+
// Fading the offset must not add another fraction of that accelerated speed.
|
|
209
|
+
const allowance = length ? (myKind()?.maxSpeed ?? 0) * elapsed / 1000 / length : 1;
|
|
210
|
+
const release = settled && catchTick === null ? Math.min(allowance, elapsed / predict.blendMs) : 0;
|
|
211
|
+
const progress = length ? Math.min(allowance, distance(path, o.path) * 0.8 / length) : 1;
|
|
212
|
+
const nominal = Math.max(0, Math.min(1, 1 - (now - o.at) / predict.blendMs));
|
|
213
|
+
o.fade = Math.max(nominal, o.fade - Math.max(release, progress));
|
|
214
|
+
}
|
|
215
|
+
o.path = path; o.last = now;
|
|
216
|
+
}
|
|
217
|
+
waiting = true;
|
|
218
|
+
sum.x += o.delta.x * o.fade; sum.y += o.delta.y * o.fade; sum.z += o.delta.z * o.fade;
|
|
219
|
+
}
|
|
220
|
+
return sum;
|
|
221
|
+
}
|
|
222
|
+
|
|
169
223
|
const myKind = (): (typeof schema.kinds)[number] | null => (mine ? kindOf.get(mine.kind) ?? null : null);
|
|
170
|
-
const tickAt = (now: number): number => (base ? base.tick + (now - base.at) / period : 0);
|
|
224
|
+
const tickAt = (now: number): number => (base ? base.tick + (now - base.at) / period * speed : 0);
|
|
171
225
|
/** The tick `move` is being run for: the step just taken, or the one after it while the own body is drawn between ticks. */
|
|
172
226
|
let moveTick = 0;
|
|
173
227
|
const moveCtx = moveContext({ tick: () => moveTick, tickHz, tune, map, name: game.map.name ?? 'main', spots, radius: () => myKind()?.radius ?? 0, dims });
|
|
174
|
-
function
|
|
175
|
-
|
|
176
|
-
|
|
228
|
+
function authorityRtt(): number {
|
|
229
|
+
// The helper's ping ends at the relay. A browser host adds another network leg in both directions.
|
|
230
|
+
// Snapshot stamps use the relay clock, so their observed age measures the complete downstream path.
|
|
231
|
+
const relayRtt = net.stats().rtt ?? 100;
|
|
232
|
+
return net.offline ? 0 : schema.settings.host === 'browser' ? Math.max(relayRtt, 2 * quantile(hostAges, 0.5)) : relayRtt;
|
|
233
|
+
}
|
|
234
|
+
function phaseAt(at: number): number {
|
|
235
|
+
let p = phases[0];
|
|
236
|
+
for (let i = phases.length - 1; i >= 0; i--) if (phases[i].at <= at) { p = phases[i]; break; }
|
|
237
|
+
return p ? p.tick + (at - p.at) / period * p.rate - at / period : 0;
|
|
238
|
+
}
|
|
239
|
+
const medianLead = (): number => leads.length ? quantile(leads.slice(-8), 0.5) + phaseAt(clock()) : 0;
|
|
240
|
+
function steer(now: number): void {
|
|
241
|
+
if (!base || !leads.length) return;
|
|
242
|
+
base = { tick: tickAt(now), at: now };
|
|
243
|
+
speed = 1 + Math.max(-0.05, Math.min(0.05, (targetLead() - medianLead()) * 0.05));
|
|
244
|
+
if (phases[phases.length - 1]?.rate !== speed) phases.push({ ...base, rate: speed });
|
|
245
|
+
while (phases.length > 2 && phases[1].at < now - Math.max(1000, 2 * authorityRtt() + period)) phases.shift();
|
|
246
|
+
}
|
|
247
|
+
function rebase(k: number, reason = 'placement'): void {
|
|
248
|
+
// Recalibrating an input clock is not a placement. Keep the drawn pose while
|
|
249
|
+
// the new prediction catches up, just as for a snapshot reconciliation.
|
|
250
|
+
const shown = reason === 'lead' ? meNow()?.pos : null;
|
|
251
|
+
correction.rebaseReason = reason; correction.rebaseError = medianLead() - targetLead();
|
|
252
|
+
const rtt = authorityRtt();
|
|
253
|
+
// Lead samples refer to the clock which stamped them. Do not mix its offset with this clock's jitter.
|
|
254
|
+
const target = targetLead();
|
|
255
|
+
leads.length = 0;
|
|
256
|
+
leadAfter = clock() + 2 * rtt + period;
|
|
257
|
+
base = { tick: k + Math.ceil(rtt / period + target) + sendEvery - 1, at: clock() };
|
|
177
258
|
stepped = Math.floor(base.tick) - 1;
|
|
178
|
-
entries = []; lastSent = ''; leads.length = 0; pressed = new Set();
|
|
259
|
+
entries = []; pending = []; history.clear(); replayHeld = {}; held = {}; lastSent = ''; leads.length = 0; pressed = new Set();
|
|
260
|
+
speed = 1; phases.length = 0; phases.push({ ...base, rate: 1 }); catchTick = null; offsets = []; correction.rebases += 1;
|
|
261
|
+
const u = mine && latest?.ents.get(mine.id); if (u) adopt(u);
|
|
262
|
+
if (mine) {
|
|
263
|
+
history.set(stepped, copyMine(mine));
|
|
264
|
+
if (shown) offsets = [{ delta: { x: shown.x - mine.pos.x, y: shown.y - mine.pos.y, z: shown.z - mine.pos.z }, at: clock(), path: mine.pos, fade: 1, last: clock() }];
|
|
265
|
+
}
|
|
179
266
|
}
|
|
180
267
|
/** `motion` as this browser's own `move` may change it in place: what a snapshot carries is frozen, so every list, map and struct in it is copied. */
|
|
181
268
|
const motionOf = (kindName: string, motion: Record<string, unknown>): Record<string, unknown> => thawFields(kindOf.get(kindName)?.motion ?? [], motion);
|
|
182
269
|
function adopt(u: Unpacked): void {
|
|
183
270
|
mine = { id: u.id, kind: u.kind, r: u.r, pos: u.pos, vel: u.vel, heading: u.heading, grounded: u.grounded, motion: motionOf(u.kind, u.motion) };
|
|
184
|
-
lastSent = '';
|
|
185
271
|
}
|
|
186
272
|
/** The game's guarded `move` for one tick, counted as the server counts it, with the result rounded as the server rounds it. */
|
|
187
273
|
function runMove(kindName: string, body: { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown> }, input: Readonly<Record<string, unknown>>, t: number): typeof body {
|
|
188
274
|
const fn = moves[kindName];
|
|
189
275
|
if (!fn) return body;
|
|
190
276
|
moveTick = t;
|
|
191
|
-
|
|
277
|
+
const out = stepMove(fn, body, input, moveCtx, Math.max(1, Math.floor(schema.settings.budget.tick / 4)), kindOf.get(kindName)?.motion ?? [], dims, (err) => { if (!(err instanceof BudgetError)) console.warn('[room] move', err); });
|
|
278
|
+
const r = kindOf.get(kindName)?.radius ?? 0;
|
|
279
|
+
out.pos = vec3({ x: Math.max(map.bounds.min.x + r, Math.min(map.bounds.max.x - r, out.pos.x)), y: Math.max(map.bounds.min.y + r, Math.min(map.bounds.max.y - r, out.pos.y)), z: out.pos.z }, dims);
|
|
280
|
+
return out;
|
|
192
281
|
}
|
|
193
282
|
/** The input values the last step held (a press is never held). */
|
|
194
283
|
let held: Readonly<Record<string, unknown>> = Object.freeze({});
|
|
195
284
|
/** One step of the own body on tick `t`: the input step, `move`, and an entry when the sample or the claim changed. */
|
|
196
|
-
function step(t: number): void {
|
|
285
|
+
function step(t: number, fresh = true): void {
|
|
197
286
|
const kind = myKind();
|
|
198
287
|
if (!mine || !kind) return;
|
|
199
288
|
const values: number[] = [];
|
|
200
289
|
const input: Record<string, unknown> = {};
|
|
201
290
|
let press = false;
|
|
202
291
|
for (const [name, fd] of kind.input) {
|
|
203
|
-
if (fd.t === 'press') { const on = pressed.has(name); input[name] = on; values.push(on ? 1 : 0); if (on) press = true; } else { const v = coerce(fd, sample[name] ?? fd.init, dims); input[name] = v; values.push(v === true ? 1 : v === false ? 0 : v as number); }
|
|
292
|
+
if (fd.t === 'press') { const on = fresh && pressed.has(name); input[name] = on; values.push(on ? 1 : 0); if (on) press = true; } else { const v = coerce(fd, (fresh ? sample[name] : held[name]) ?? fd.init, dims); input[name] = v; values.push(v === true ? 1 : v === false ? 0 : v as number); }
|
|
204
293
|
}
|
|
205
|
-
pressed = new Set();
|
|
294
|
+
if (fresh) pressed = new Set();
|
|
206
295
|
held = Object.freeze({ ...input });
|
|
207
|
-
|
|
296
|
+
{
|
|
208
297
|
const body = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: mine.motion }, held, t);
|
|
209
298
|
mine.pos = body.pos; mine.vel = body.vel; mine.heading = body.heading; mine.grounded = body.grounded; mine.motion = body.motion;
|
|
210
299
|
}
|
|
211
300
|
const claim = kind.owner ? [mine.pos.x, mine.pos.y, mine.pos.z, mine.vel.x, mine.vel.y, mine.vel.z, mine.heading.x, mine.heading.y, mine.heading.z] : [];
|
|
212
301
|
const sig = JSON.stringify([values, claim]);
|
|
213
|
-
if (sig !== lastSent || press) { entries.push({ t, row: [...values, ...claim] }); lastSent = sig; }
|
|
302
|
+
if (sig !== lastSent || press) { entries.push({ t, row: [...values, ...claim] }); pending.push({ t, input: held }); lastSent = sig; }
|
|
303
|
+
history.set(t, copyMine(mine));
|
|
304
|
+
for (const k of history.keys()) if (k < t - tickHz && (catchTick === null || k < Math.floor(catchTick))) history.delete(k);
|
|
214
305
|
}
|
|
215
306
|
/**
|
|
216
307
|
* The end of a send period (`sendEvery` ticks, counted from tick 0): one frame with the entries made in it, `k` the
|
|
@@ -232,7 +323,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
232
323
|
if (!mine || !kind) return;
|
|
233
324
|
const values = kind.input.map(([name, fd]) => (fd.t === 'press' ? 0 : Number(coerce(fd, sample[name] ?? fd.init, dims))));
|
|
234
325
|
const claim = kind.owner ? [mine.pos.x, mine.pos.y, mine.pos.z, mine.vel.x, mine.vel.y, mine.vel.z, mine.heading.x, mine.heading.y, mine.heading.z] : [];
|
|
235
|
-
entries = [{ t, row: [...values, ...claim] }]; lastSent = JSON.stringify([values, claim]);
|
|
326
|
+
entries = [{ t, row: [...values, ...claim] }]; pending.push({ t, input: Object.fromEntries(kind.input.map(([name, fd], i) => [name, fd.t === 'press' ? false : values[i]])) }); lastSent = JSON.stringify([values, claim]);
|
|
236
327
|
}
|
|
237
328
|
function pump(): void {
|
|
238
329
|
if (closed) return;
|
|
@@ -250,12 +341,15 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
250
341
|
return;
|
|
251
342
|
}
|
|
252
343
|
hiddenSent = false;
|
|
344
|
+
// Feedback can be sparse (idle keepalives or dropped snapshots). Re-evaluate the
|
|
345
|
+
// steering every frame so a stale +/-5% command cannot drive past its target.
|
|
346
|
+
steer(now);
|
|
253
347
|
let t = Math.floor(tickAt(now));
|
|
254
348
|
// A frame longer than a quarter of a second: set the clock again from the newest snapshot and carry on from there.
|
|
255
|
-
if (t - stepped > Math.ceil(tickHz / 4) && latest) { rebase(latest.k); t = Math.floor(tickAt(now)); }
|
|
349
|
+
if (t - stepped > Math.ceil(tickHz / 4) && latest) { rebase(latest.k, 'frame'); t = Math.floor(tickAt(now)); }
|
|
256
350
|
while (stepped < t) {
|
|
257
351
|
stepped += 1;
|
|
258
|
-
step(stepped);
|
|
352
|
+
step(stepped, stepped === t);
|
|
259
353
|
if (stepped % sendEvery === sendEvery - 1) flush(now, stepped);
|
|
260
354
|
}
|
|
261
355
|
}
|
|
@@ -264,24 +358,99 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
264
358
|
const f = frameOf(s);
|
|
265
359
|
if (!f) return;
|
|
266
360
|
const before = latest;
|
|
361
|
+
if (before && before.e === f.e && f.k <= before.k) return;
|
|
267
362
|
latest = f;
|
|
363
|
+
if (before && before.e !== f.e) hostAges.length = 0;
|
|
364
|
+
hostAges.push(Math.max(0, net.now() - s.st)); if (hostAges.length > 40) hostAges.shift();
|
|
365
|
+
if (before && before.e !== f.e) fxQueue.length = 0;
|
|
268
366
|
const changedEpoch = f.e !== epoch;
|
|
269
367
|
epoch = f.e;
|
|
270
368
|
const seat = net.seat;
|
|
271
369
|
let me: Unpacked | null = null;
|
|
272
|
-
if (seat !== null) for (const u of f.ents.values()) if (u.seat === seat && u.driver
|
|
370
|
+
if (seat !== null) for (const u of f.ents.values()) if (u.seat === seat && u.driver === 'person') { me = u; break; }
|
|
273
371
|
if (me) {
|
|
274
|
-
// The server placed the body (a spawn, a round reset, a seat taken over), or the room began again: jump there.
|
|
275
372
|
const placed = !mine || mine.id !== me.id || mine.r !== me.r || changedEpoch;
|
|
276
|
-
if (placed) { adopt(me); emit('placed', entityOf(me, true)); }
|
|
277
|
-
else if (mine) { mine.motion = motionOf(me.kind, me.motion); if (!kindOf.get(me.kind)?.owner) { mine.pos = me.pos; mine.vel = me.vel; mine.heading = me.heading; mine.grounded = me.grounded; } }
|
|
278
|
-
if (!base || changedEpoch) rebase(f.k);
|
|
279
373
|
const row = f.rows.find((x) => x[0] === seat);
|
|
280
|
-
if (
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
374
|
+
if (placed || !base) {
|
|
375
|
+
adopt(me); rebase(f.k);
|
|
376
|
+
if (placed) emit('placed', entityOf(me, true));
|
|
377
|
+
} else if (kindOf.get(me.kind)?.owner) {
|
|
378
|
+
mine!.motion = motionOf(me.kind, me.motion);
|
|
379
|
+
} else {
|
|
380
|
+
const previous = mine!.pos;
|
|
381
|
+
const shown = meNow()?.pos ?? previous;
|
|
382
|
+
const oldOffset = visualOffset(clock()), oldOffsets = offsets;
|
|
383
|
+
adopt(me);
|
|
384
|
+
history.set(f.k, copyMine(mine!));
|
|
385
|
+
const ack = row?.[2] ?? 0;
|
|
386
|
+
const late = new Set<string>();
|
|
387
|
+
for (const entry of pending) if (entry.t <= f.k) {
|
|
388
|
+
replayHeld = entry.input;
|
|
389
|
+
if (entry.t > ack && f.k + 1 - entry.t <= Math.ceil(tickHz / 4))
|
|
390
|
+
for (const [name, fd] of myKind()!.input) if (fd.t === 'press' && entry.input[name]) late.add(name);
|
|
391
|
+
}
|
|
392
|
+
let input = replayHeld;
|
|
393
|
+
for (let t = f.k + 1; t <= stepped; t++) {
|
|
394
|
+
const presses = new Set(t === f.k + 1 ? late : []);
|
|
395
|
+
for (const entry of pending) if (entry.t === t) {
|
|
396
|
+
input = entry.input;
|
|
397
|
+
for (const [name, fd] of myKind()!.input) if (fd.t === 'press' && entry.input[name]) presses.add(name);
|
|
398
|
+
}
|
|
399
|
+
const stepInput = { ...input };
|
|
400
|
+
for (const [name, fd] of myKind()!.input) if (fd.t === 'press') stepInput[name] = presses.has(name);
|
|
401
|
+
Object.assign(mine!, runMove(me.kind, mine!, Object.freeze(stepInput), t));
|
|
402
|
+
history.set(t, copyMine(mine!));
|
|
403
|
+
}
|
|
404
|
+
pending = pending.filter(e => e.t > f.k || e.t > ack && f.k + 1 - e.t <= Math.ceil(tickHz / 4));
|
|
405
|
+
const error = distance(previous, mine!.pos);
|
|
406
|
+
correction.last = error;
|
|
407
|
+
if (error > 0.00001) { correction.count++; correction.max = Math.max(correction.max, error); }
|
|
408
|
+
// A snapshot can replace the historical segment being drawn even when replay reaches
|
|
409
|
+
// the same current position. Preserve that drawn pose on every catch-up reconciliation.
|
|
410
|
+
if (error > 0.00001 || catchTick !== null) {
|
|
411
|
+
if (catchTick === null && error > (predict.catchM ?? myKind()!.maxSpeed * 3 / tickHz) + 0.00001) {
|
|
412
|
+
catchTick = f.k; catchAt = clock(); correction.catches++;
|
|
413
|
+
}
|
|
414
|
+
// Older history belongs to the previous reconciliation. Crossing from it into
|
|
415
|
+
// the newly adopted snapshot can draw a backwards segment even though both
|
|
416
|
+
// paths move forward. Start on the new path and preserve the shown pose below.
|
|
417
|
+
if (catchTick !== null && catchTick < f.k) {
|
|
418
|
+
catchTick = f.k; catchAt = clock();
|
|
419
|
+
}
|
|
420
|
+
offsets = [];
|
|
421
|
+
const corrected = meNow()?.pos ?? mine!.pos;
|
|
422
|
+
// Limit the new reconciliation, not an offset already being blended (which
|
|
423
|
+
// can include a clock recalibration). Clipping that carried offset would
|
|
424
|
+
// turn a small new error into a visible snap.
|
|
425
|
+
const change = { x: shown.x - corrected.x - oldOffset.x, y: shown.y - corrected.y - oldOffset.y, z: shown.z - corrected.z - oldOffset.z };
|
|
426
|
+
const gap = Math.hypot(change.x, change.y, change.z);
|
|
427
|
+
const ahead = Math.max(0, tickAt(clock()) - f.k) / tickHz;
|
|
428
|
+
const cap = predict.snapM ?? myKind()!.maxSpeed * (2 * ahead + 0.1);
|
|
429
|
+
const scale = gap > cap && gap > 0 ? cap / gap : 1;
|
|
430
|
+
if (scale < 1) correction.snaps++;
|
|
431
|
+
const delta = { x: oldOffset.x + change.x * scale, y: oldOffset.y + change.y * scale, z: oldOffset.z + change.z * scale };
|
|
432
|
+
if (catchTick !== null || scale < 1) offsets = [{ delta, at: clock(), path: corrected, fade: 1, last: clock() }];
|
|
433
|
+
else {
|
|
434
|
+
// Each small correction gets its own blend. Restarting one fade for their accumulated
|
|
435
|
+
// offset can erase several ticks of travel in 100 ms and send a steadily moving body backwards.
|
|
436
|
+
offsets = oldOffsets;
|
|
437
|
+
for (const o of offsets) { o.path = corrected; o.last = clock(); }
|
|
438
|
+
offsets.push({ delta: { x: delta.x - oldOffset.x, y: delta.y - oldOffset.y, z: delta.z - oldOffset.z }, at: clock(), path: corrected, fade: 1, last: clock() });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (row && row[3] !== -128 && base && clock() >= leadAfter) {
|
|
443
|
+
const now = clock(), rtt = authorityRtt();
|
|
444
|
+
// A reported lead describes an input sent roughly one authority round trip ago. Remove that
|
|
445
|
+
// clock's phase before measuring jitter; project the control median onto today's phase. Otherwise
|
|
446
|
+
// our own +/-5% steering inflates the 40-sample jitter window and winds the clock ever further ahead.
|
|
447
|
+
observedLeadAt = now;
|
|
448
|
+
leads.push(row[3] / 16 - phaseAt(now - rtt)); if (leads.length > 40) leads.shift();
|
|
449
|
+
const med = medianLead(), target = targetLead();
|
|
450
|
+
if (leads.length >= 8 && Math.abs(med - target) > 4) rebase(f.k, 'lead');
|
|
451
|
+
else {
|
|
452
|
+
steer(now);
|
|
453
|
+
}
|
|
285
454
|
}
|
|
286
455
|
} else mine = null;
|
|
287
456
|
if (before) {
|
|
@@ -302,13 +471,16 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
302
471
|
const serverTick = latest.k + ((net.connected || net.rulesHosting) ? (clock() - latest.at) / period : 0);
|
|
303
472
|
return { ...round, secondsLeft: round.endsAt ? Math.max(0, (round.endsAt - serverTick) / tickHz) : Infinity, ...(results ? { results } : {}) };
|
|
304
473
|
}
|
|
305
|
-
/** Effects wait for
|
|
474
|
+
/** Effects wait for their authoritative snapshot and the body's drawn tick, including its catch-up path. */
|
|
306
475
|
function fire(): void {
|
|
307
476
|
if (!fxQueue.length) return;
|
|
308
477
|
const s = net.sample();
|
|
309
478
|
const drawn = s ? lerp(s.a.k, s.b.k, s.alpha) : latest ? latest.k : 0;
|
|
310
|
-
|
|
311
|
-
const x = fxQueue
|
|
479
|
+
for (let i = 0; i < fxQueue.length;) {
|
|
480
|
+
const x = fxQueue[i];
|
|
481
|
+
const ownTick = catchTick === null ? tickAt(clock()) : catchTick + Math.max(0, clock() - catchAt) / period * predict.catchUp;
|
|
482
|
+
if (x.tick > (x.at === mine?.id ? Math.min(latest?.k ?? 0, ownTick) : drawn)) { i++; continue; }
|
|
483
|
+
fxQueue.splice(i, 1);
|
|
312
484
|
const id = typeof x.at === 'string' ? x.at : null;
|
|
313
485
|
const where = id ? get(id)?.pos ?? null : unpackVec(x.at, dims);
|
|
314
486
|
emit(x.name, Object.freeze({ ...x.data, at: where, ...(id ? { id } : {}), tick: x.tick }));
|
|
@@ -361,14 +533,32 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
361
533
|
const kind = kindOf.get(mine.kind);
|
|
362
534
|
let pos = mine.pos;
|
|
363
535
|
let heading = mine.heading;
|
|
364
|
-
|
|
536
|
+
let settled = true;
|
|
537
|
+
let preview: { pos: Vec3; heading: Vec3 } | null = null;
|
|
538
|
+
if (kind && base) {
|
|
365
539
|
const a = Math.max(0, Math.min(1, tickAt(clock()) - stepped));
|
|
366
540
|
const now: Record<string, unknown> = {};
|
|
367
541
|
for (const [name, fd] of kind.input) now[name] = fd.t === 'press' ? pressed.has(name) : coerce(fd, sample[name] ?? fd.init, dims);
|
|
368
542
|
const next = runMove(kind.name, { pos: mine.pos, vel: mine.vel, heading: mine.heading, grounded: mine.grounded, motion: motionOf(mine.kind, mine.motion) }, Object.freeze(now), stepped + 1);
|
|
369
543
|
pos = vec3({ x: lerp(mine.pos.x, next.pos.x, a), y: lerp(mine.pos.y, next.pos.y, a), z: lerp(mine.pos.z, next.pos.z, a) }, dims);
|
|
370
544
|
heading = next.heading;
|
|
545
|
+
preview = next;
|
|
546
|
+
settled = distance(next.pos, mine.pos) < 0.00001;
|
|
547
|
+
}
|
|
548
|
+
if (catchTick !== null && base) {
|
|
549
|
+
const now = clock();
|
|
550
|
+
catchTick += Math.max(0, now - catchAt) / period * predict.catchUp; catchAt = now;
|
|
551
|
+
if (catchTick >= tickAt(now)) catchTick = null;
|
|
552
|
+
else {
|
|
553
|
+
// Catch-up can reach the fractional current tick before its next whole step exists.
|
|
554
|
+
// Use the same preview as normal drawing, rather than hold and jump when that step arrives.
|
|
555
|
+
const t = Math.floor(catchTick), a = history.get(t), b = history.get(t + 1) ?? (t === stepped ? preview : null);
|
|
556
|
+
if (a && b) { const f = catchTick - t; pos = { x: lerp(a.pos.x, b.pos.x, f), y: lerp(a.pos.y, b.pos.y, f), z: lerp(a.pos.z, b.pos.z, f) }; heading = b.heading; }
|
|
557
|
+
else if (a) pos = a.pos;
|
|
558
|
+
}
|
|
371
559
|
}
|
|
560
|
+
const offset = visualOffset(clock(), pos, settled);
|
|
561
|
+
pos = { x: pos.x + offset.x, y: pos.y + offset.y, z: pos.z + offset.z };
|
|
372
562
|
return entityOf({ ...server, pos, vel: mine.vel, heading, grounded: mine.grounded, motion: mine.motion }, true);
|
|
373
563
|
}
|
|
374
564
|
function drawn(id: string): Entity | null {
|
|
@@ -379,7 +569,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
379
569
|
const ea = a?.ents.get(id); const eb = b?.ents.get(id);
|
|
380
570
|
if (!ea || !eb) return ea ? entityOf(ea) : eb && s && s.alpha >= 1 ? entityOf(eb) : eb && !a?.ents.size ? entityOf(eb) : null;
|
|
381
571
|
// A placed body jumps: nobody's view glides across a placement.
|
|
382
|
-
if (ea.r !== eb.r || !s) return entityOf(eb);
|
|
572
|
+
if (ea.r !== eb.r || a?.e !== b?.e || !s) return entityOf(eb);
|
|
383
573
|
const t = s.alpha;
|
|
384
574
|
const h = dir({ x: lerp(ea.heading.x, eb.heading.x, t), y: lerp(ea.heading.y, eb.heading.y, t), z: lerp(ea.heading.z, eb.heading.z, t) }, dims);
|
|
385
575
|
return entityOf({ ...eb, pos: vec3({ x: lerp(ea.pos.x, eb.pos.x, t), y: lerp(ea.pos.y, eb.pos.y, t), z: lerp(ea.pos.z, eb.pos.z, t) }, dims), vel: eb.vel, heading: h });
|
|
@@ -408,13 +598,15 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
408
598
|
net.expose({
|
|
409
599
|
self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y, z: m.pos.z } : null; },
|
|
410
600
|
peer: (seat: number) => { if (!latest) return null; for (const u of latest.ents.values()) if (u.seat === seat) return { x: u.pos.x, y: u.pos.y, z: u.pos.z }; return null; },
|
|
601
|
+
prediction: () => ({ ...correction, observedLeadAt, lastSendAt, now: clock(), serverTick: latest?.k, tick: stepped, catchTick, lead: targetLead(), medianLead: medianLead(), rate: speed, rtt: authorityRtt(), hostAge: quantile(hostAges, 0.5), pending: pending.length }),
|
|
411
602
|
scores: () => roster().map((r) => ({ seat: r.seat, score: r.score })),
|
|
412
603
|
score: myScore, tick: () => latest?.k ?? 0, epoch: () => epoch, hosted: () => net.rulesHosting ? 'browser' : schema.settings.host, status: () => status,
|
|
413
604
|
});
|
|
414
605
|
if (typeof window !== 'undefined') {
|
|
415
606
|
exposePort(net as Netplay<unknown, unknown, unknown>, {
|
|
416
607
|
view: 'top', size: schema.kinds.find((k) => k.player)?.radius ?? 0.5,
|
|
417
|
-
self: () => { const m = meNow(); return m ? { x: m.pos.x, y:
|
|
608
|
+
self: () => { const m = meNow(); return m ? { x: m.pos.x, y: m.pos.y } : null; },
|
|
609
|
+
basis: opts.screenBasis ?? (() => ({ right: [1, 0], up: [0, 1] })),
|
|
418
610
|
score: myScore, busy: () => (typeof extra.busy === 'function' ? Boolean(extra.busy()) : busy()),
|
|
419
611
|
extra: new Proxy({}, { get: (_t, key) => (typeof key === 'string' ? extra[key] : undefined), ownKeys: () => Object.keys(extra), getOwnPropertyDescriptor: (_t, key) => (typeof key === 'string' && extra[key] ? { enumerable: true, configurable: true, value: extra[key] } : undefined) }) as Record<string, () => unknown>,
|
|
420
612
|
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"v": 1,
|
|
3
|
+
"default": "run",
|
|
4
|
+
"takes": {
|
|
5
|
+
"run": {
|
|
6
|
+
"note": "Run right, then turn upward on the same rules clock",
|
|
7
|
+
"seconds": 2,
|
|
8
|
+
"seed": 7,
|
|
9
|
+
"device": "desk",
|
|
10
|
+
"fps": 60,
|
|
11
|
+
"inputs": [
|
|
12
|
+
{ "at": 0.2, "key": "ArrowRight", "hold": 0.6 },
|
|
13
|
+
{ "at": 1, "key": "ArrowUp", "hold": 0.6 }
|
|
14
|
+
]
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* on a later tick. So a runner does not take a coin: it asks the coin (`take`), the coin answers the first one that
|
|
11
11
|
* asked (`score`) and is gone, and a second `take` finds nobody there.
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* The server moves every runner with src/move.ts. The view predicts its own runner using that same module,
|
|
14
|
+
* so controls answer immediately while the server decides what is true.
|
|
15
15
|
*/
|
|
16
16
|
import { defineRules, f } from '@homie-rocks/studio/rules';
|
|
17
17
|
import { move } from './move';
|
|
@@ -31,7 +31,7 @@ export default defineRules({
|
|
|
31
31
|
fields: { score: f.u16({ score: true }) },
|
|
32
32
|
motion: { frozenUntil: f.tick() },
|
|
33
33
|
input: { ax: f.i8(), ay: f.i8() },
|
|
34
|
-
body: { shape: 'circle', radius: 0.5, maxSpeed: 6
|
|
34
|
+
body: { shape: 'circle', radius: 0.5, maxSpeed: 6 },
|
|
35
35
|
tick(world, self) {
|
|
36
36
|
if (world.round.phase !== 'live') return;
|
|
37
37
|
// A second `take` sent before the coin is gone is dropped: the coin no longer exists.
|
|
@@ -2,9 +2,24 @@
|
|
|
2
2
|
"id": "gem-rush",
|
|
3
3
|
"name": "Gem Rush",
|
|
4
4
|
"blurb": "Grab gems, knock rivals away. Sixty-second rounds that never stop, and everyone who presses Play is in the same room.",
|
|
5
|
-
"players": {
|
|
5
|
+
"players": {
|
|
6
|
+
"min": 1,
|
|
7
|
+
"max": 8
|
|
8
|
+
},
|
|
6
9
|
"roundSeconds": 60,
|
|
7
|
-
"entry": "src/
|
|
8
|
-
"netplay": {
|
|
9
|
-
|
|
10
|
+
"entry": "src/view.ts",
|
|
11
|
+
"netplay": {
|
|
12
|
+
"v": 1,
|
|
13
|
+
"public": true
|
|
14
|
+
},
|
|
15
|
+
"landing": {
|
|
16
|
+
"pitch": "Grab the gems. Bump your rivals away.",
|
|
17
|
+
"controls": {
|
|
18
|
+
"phone": "Drag to move; a second finger bumps.",
|
|
19
|
+
"computer": "WASD or arrows to move, Space to bump."
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"room": {
|
|
23
|
+
"host": "server"
|
|
24
|
+
}
|
|
10
25
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bounds": {
|
|
3
|
+
"min": [
|
|
4
|
+
0,
|
|
5
|
+
0
|
|
6
|
+
],
|
|
7
|
+
"max": [
|
|
8
|
+
32,
|
|
9
|
+
20
|
|
10
|
+
]
|
|
11
|
+
},
|
|
12
|
+
"boxes": [],
|
|
13
|
+
"circles": [],
|
|
14
|
+
"spots": {
|
|
15
|
+
"start": [
|
|
16
|
+
[
|
|
17
|
+
22.6,
|
|
18
|
+
10.0
|
|
19
|
+
],
|
|
20
|
+
[
|
|
21
|
+
20.66690475583121,
|
|
22
|
+
13.535533905932738
|
|
23
|
+
],
|
|
24
|
+
[
|
|
25
|
+
16.0,
|
|
26
|
+
15.0
|
|
27
|
+
],
|
|
28
|
+
[
|
|
29
|
+
11.333095244168787,
|
|
30
|
+
13.535533905932738
|
|
31
|
+
],
|
|
32
|
+
[
|
|
33
|
+
9.4,
|
|
34
|
+
10.0
|
|
35
|
+
],
|
|
36
|
+
[
|
|
37
|
+
11.333095244168785,
|
|
38
|
+
6.464466094067262
|
|
39
|
+
],
|
|
40
|
+
[
|
|
41
|
+
15.999999999999998,
|
|
42
|
+
5.0
|
|
43
|
+
],
|
|
44
|
+
[
|
|
45
|
+
20.66690475583121,
|
|
46
|
+
6.464466094067261
|
|
47
|
+
]
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { defineMove } from '@homie-rocks/studio/rules';
|
|
2
|
+
|
|
3
|
+
export const move = defineMove({
|
|
4
|
+
runner(body, input, ctx) {
|
|
5
|
+
const M = ctx.math, m = body.motion, T = ctx.tune;
|
|
6
|
+
if (m.knockAt > 0) {
|
|
7
|
+
const last = ctx.tick + 1 >= m.knockUntil;
|
|
8
|
+
const u = M.clamp((ctx.tick - m.knockAt) / (m.knockUntil - m.knockAt), 0, 1);
|
|
9
|
+
const ease = last ? 1 : 1 - M.pow(1 - u, T.knockEase);
|
|
10
|
+
const to = M.add(m.knockFrom, M.scale(m.knockDir, T.knockDistance * ease));
|
|
11
|
+
const delta = M.sub(to, body.pos);
|
|
12
|
+
ctx.map.sweep(body, delta);
|
|
13
|
+
body.vel = last ? { x: 0, y: 0, z: 0 } : M.scale(delta, 1 / ctx.dt);
|
|
14
|
+
if (last) m.knockAt = 0;
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const want = M.scale(M.clampLen({ x: input.ax / 127, y: input.ay / 127, z: 0 }, 1), m.speed);
|
|
18
|
+
const keep = M.pow(1 - m.accel / 60, ctx.dt * 60);
|
|
19
|
+
body.vel = M.add(want, M.scale(M.sub(body.vel, want), keep));
|
|
20
|
+
if (M.len(body.vel) > 0.6) body.heading = M.norm(body.vel);
|
|
21
|
+
ctx.map.sweep(body, M.scale(body.vel, ctx.dt));
|
|
22
|
+
},
|
|
23
|
+
});
|