@homie-rocks/studio 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -2
- package/bin/homie-studio.mjs +34 -3
- package/lib/build.mjs +83 -21
- package/lib/port-check.mjs +651 -0
- package/lib/port.mjs +277 -0
- package/lib/scaffold.mjs +6 -0
- package/netplay/netplay.ts +93 -8
- package/package.json +11 -2
- package/port/audio.ts +96 -0
- package/port/bots.ts +97 -0
- package/port/camera.ts +75 -0
- package/port/controls.ts +48 -0
- package/port/early.ts +14 -0
- package/port/global.ts +9 -0
- package/port/hud.ts +96 -0
- package/port/index.ts +29 -0
- package/port/keys.ts +102 -0
- package/port/probe.ts +126 -0
- package/port/room.ts +356 -0
- package/port/sandbox.ts +112 -0
- package/port/touch.ts +378 -0
- package/starters/gem-rush/src/main.ts +9 -0
- package/worker/index.mjs +19 -0
- package/worker/pages.mjs +30 -10
- package/worker/qr.mjs +501 -0
package/port/room.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* createRoom — the host scaffold every netplay port needs, so a port writes
|
|
3
|
+
* its GAME, not its plumbing. It is the reference game's (Gem Rush's) room
|
|
4
|
+
* logic made generic, and it is the part ports most often got subtly wrong:
|
|
5
|
+
*
|
|
6
|
+
* - Instant start: the first browser hosts and starts round 1 at once, with
|
|
7
|
+
* bots in the empty seats (minBodies).
|
|
8
|
+
* - Join in progress: an arriving person takes a bot's body where it stands
|
|
9
|
+
* (the host resets that body so the new browser adopts it); a person who
|
|
10
|
+
* leaves hands the body back to a bot.
|
|
11
|
+
* - Rounds that never stop: live until endsAt, results for breakSeconds,
|
|
12
|
+
* then the next round, forever, with round numbers that only go up.
|
|
13
|
+
* - Host migration: a checkpoint every second; a promoted browser restores
|
|
14
|
+
* it, overlays the newer snapshot, reconciles who is still here, resets
|
|
15
|
+
* every claimed seat and keeps its own body where it was, then continues
|
|
16
|
+
* the SAME round.
|
|
17
|
+
* - Snapshots: one small row per body [slot, seat (-1 = bot), score, ...your
|
|
18
|
+
* fields], plus your fast world, at 20 Hz; replicas interpolate them.
|
|
19
|
+
*
|
|
20
|
+
* What stays the game's: the rules (what a body does per frame), the bots'
|
|
21
|
+
* brains, the drawing, and the camera. See the port skill's recipe.
|
|
22
|
+
*/
|
|
23
|
+
import {
|
|
24
|
+
capMove, createNetplay, lerp, lerpAngle, Roster,
|
|
25
|
+
type Movement, type Netplay, type NetplayOptions, type RoleChange, type RoundInfo, type RoundResult, type Slot, type Snapshot,
|
|
26
|
+
} from '../netplay/netplay';
|
|
27
|
+
|
|
28
|
+
export interface BodyBase { slot: number; seat: number | null; name: string; bot: boolean; score: number }
|
|
29
|
+
|
|
30
|
+
export interface RoomSnap<F = unknown> { r: [n: number, phase: number, startedAt: number, endsAt: number]; b: number[][]; w?: F }
|
|
31
|
+
export interface RoomCkpt<B, S> { round: RoundInfo | null; roster: Slot[]; bodies: B[]; world: S | null; tick: number }
|
|
32
|
+
|
|
33
|
+
export interface RoomOptions<B extends BodyBase, F = unknown, S = unknown> {
|
|
34
|
+
/** The game id (the relay's logs). */
|
|
35
|
+
game: string;
|
|
36
|
+
/** Seats in a room (game.json players.max). */
|
|
37
|
+
maxPlayers: number;
|
|
38
|
+
/** Bodies from the first frame, people plus bots (default min(4, maxPlayers)). A lone visitor never plays alone. */
|
|
39
|
+
minBodies?: number;
|
|
40
|
+
/** 'owner' (default): each browser moves its own body at once, the host bounds it — action games.
|
|
41
|
+
* 'host': the host's rules move every body from intents and the seat predicts — turn-based, grid, and rule-heavy games. */
|
|
42
|
+
movement?: Movement;
|
|
43
|
+
roundSeconds: number;
|
|
44
|
+
/** Results on screen between rounds (default 7). */
|
|
45
|
+
breakSeconds?: number;
|
|
46
|
+
botName?: (slot: number) => string;
|
|
47
|
+
/** A fresh body for a slot. `index` counts bodies at a round start (spread the spawns with it). */
|
|
48
|
+
spawn: (slot: Slot, index: number) => B;
|
|
49
|
+
/** The body's fast fields for the snapshot, after [slot, seat, score]: small numbers only (use q()). */
|
|
50
|
+
pack: (b: B) => number[];
|
|
51
|
+
/** Those fields back onto a body (replicas draw it; an adopting owner stands there). */
|
|
52
|
+
unpack: (fields: number[], into: B) => void;
|
|
53
|
+
/** Indexes (into pack()'s array) that are angles in radians: interpolated the short way round. */
|
|
54
|
+
angles?: number[];
|
|
55
|
+
/** Indexes that must NOT be interpolated (flags, animation frames, counters). */
|
|
56
|
+
discrete?: number[];
|
|
57
|
+
/** Host: round n starts; every body was just respawned. Reset the world here. */
|
|
58
|
+
onRoundStart?: (n: number) => void;
|
|
59
|
+
onRoundEnd?: (results: RoundResult[]) => void;
|
|
60
|
+
/** Host: a person took over `body` (it was a bot). Default: keep where it stands, score 0. */
|
|
61
|
+
onTakeover?: (body: B) => void;
|
|
62
|
+
/** Host: the fast world beyond bodies (projectiles, pickups), sent with every snapshot. Keep it under ~1.5 KB. */
|
|
63
|
+
fastWorld?: () => F;
|
|
64
|
+
/** Host: everything else the rules need to continue (for checkpoints). */
|
|
65
|
+
saveWorld?: () => S;
|
|
66
|
+
/** A promoted host: put the world back (the snapshot's fast world is newer than the checkpoint's). */
|
|
67
|
+
loadWorld?: (world: S | null, fast: F | undefined) => void;
|
|
68
|
+
/** My own body was placed by the host (round start, takeover, the end of a knockback): stand exactly there. */
|
|
69
|
+
adopt?: (body: B) => void;
|
|
70
|
+
/** A replica's own body as it sees it right now (owner movement): a promotion keeps it exactly there. */
|
|
71
|
+
local?: () => B | null;
|
|
72
|
+
/** Extra netplay options (snapshotHz, inputHz, config for tests). */
|
|
73
|
+
netplay?: Partial<NetplayOptions<RoomCkpt<B, S>>>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface Room<B extends BodyBase, F = unknown, S = unknown> {
|
|
77
|
+
readonly net: Netplay<RoomSnap<F>, unknown[], RoomCkpt<B, S>>;
|
|
78
|
+
/** True while this browser runs the rules. */
|
|
79
|
+
readonly hosting: boolean;
|
|
80
|
+
/** Host: every body by slot. */
|
|
81
|
+
readonly bodies: Map<number, B>;
|
|
82
|
+
/** The live round (host: authoritative; others: the relay's copy). */
|
|
83
|
+
readonly round: RoundInfo | null;
|
|
84
|
+
/** My seat (0 when offline: a solo game with bots, the way a plain file or a dev server plays). */
|
|
85
|
+
mySeat(): number | null;
|
|
86
|
+
/** Host: my own body. Replica: null (draw yours from your local state). */
|
|
87
|
+
mine(): B | null;
|
|
88
|
+
/** Host, every frame: the round clock and the snapshot. Replica: no-op. Call AFTER your rules moved the bodies. */
|
|
89
|
+
update(): void;
|
|
90
|
+
/** Host: the owner's latest avatar for this body's seat (owner movement), or null while taken/stale. */
|
|
91
|
+
avatar(b: B): unknown[] | null;
|
|
92
|
+
/** Host: take a claimed position if it is plausible; a teleport resets the owner. true when accepted. */
|
|
93
|
+
bound(b: B & { x: number; y: number; z?: number }, claim: { x: number; y: number; z?: number }, maxSpeed: number, dt: number): boolean;
|
|
94
|
+
/** Host: I moved this person's body myself (respawn, teleport): its owner adopts the new spot. */
|
|
95
|
+
moved(b: B): void;
|
|
96
|
+
/** Host: presses a seat sent since the last call ({ jump: 1 }). */
|
|
97
|
+
presses(b: B): Record<string, number>;
|
|
98
|
+
/** Replica, every frame: my avatar/intent and held buttons for the host (the helper paces and stamps it). */
|
|
99
|
+
input(frame: unknown[], held?: Iterable<string>): void;
|
|
100
|
+
/** Replica: a button press (never lost, even between snapshots). */
|
|
101
|
+
press(id: string): void;
|
|
102
|
+
/** Everyone: the bodies to draw. Host: the real ones; replica: interpolated from snapshots. */
|
|
103
|
+
view(): B[];
|
|
104
|
+
/** Replica: the newest fast world (projectiles, pickups) from the host. Host: fastWorld(). */
|
|
105
|
+
fast(): F | undefined;
|
|
106
|
+
/** Seconds left in this phase, and the phase. */
|
|
107
|
+
clock(): { n: number; phase: 'live' | 'over' | 'none'; secondsLeft: number };
|
|
108
|
+
/** Rank bodies by score (ties: people first). */
|
|
109
|
+
results(): RoundResult[];
|
|
110
|
+
/** One-shot events to everyone (sounds, hits, effects). */
|
|
111
|
+
send(kind: string, data?: unknown): void;
|
|
112
|
+
on: Netplay<RoomSnap<F>, unknown[], RoomCkpt<B, S>>['on'];
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const BOT_NAMES = ['Rook', 'Vex', 'Moth', 'Kilo', 'Juno', 'Pike', 'Nyx', 'Ash', 'Oslo', 'Wren', 'Brio', 'Tace', 'Lumo', 'Fitz', 'Sable', 'Quill'];
|
|
116
|
+
|
|
117
|
+
export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: RoomOptions<B, F, S>): Room<B, F, S> {
|
|
118
|
+
const max = Math.max(1, opts.maxPlayers);
|
|
119
|
+
const min = Math.max(1, Math.min(max, opts.minBodies ?? Math.min(4, max)));
|
|
120
|
+
const roundMs = Math.max(10, opts.roundSeconds) * 1000;
|
|
121
|
+
const breakMs = Math.max(3, opts.breakSeconds ?? 7) * 1000;
|
|
122
|
+
const botName = opts.botName ?? ((slot: number) => BOT_NAMES[slot % BOT_NAMES.length] as string);
|
|
123
|
+
const angles = new Set(opts.angles ?? []);
|
|
124
|
+
const discrete = new Set(opts.discrete ?? []);
|
|
125
|
+
|
|
126
|
+
let roster = new Roster({ min, max, botName });
|
|
127
|
+
let bodies = new Map<number, B>();
|
|
128
|
+
let round: RoundInfo | null = null;
|
|
129
|
+
let hosting = false;
|
|
130
|
+
let tick = 0;
|
|
131
|
+
|
|
132
|
+
const net = createNetplay<RoomSnap<F>, unknown[], RoomCkpt<B, S>>({
|
|
133
|
+
game: opts.game, maxPlayers: max, movement: opts.movement ?? 'owner',
|
|
134
|
+
snapshotHz: 20, inputHz: 20, checkpointMs: 1000,
|
|
135
|
+
checkpoint: () => ({ round, roster: roster.toJSON(), bodies: [...bodies.values()].map((b) => ({ ...b })), world: opts.saveWorld ? opts.saveWorld() : null, tick }),
|
|
136
|
+
...(opts.netplay ?? {}),
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const mySeat = (): number | null => (net.offline ? 0 : net.seat);
|
|
140
|
+
const bodyOfSeat = (seat: number | null): B | null => {
|
|
141
|
+
if (seat === null) return null;
|
|
142
|
+
for (const b of bodies.values()) if (!b.bot && b.seat === seat) return b;
|
|
143
|
+
return null;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
function syncBodies(atIndex = 0): void {
|
|
147
|
+
const seen = new Set<number>();
|
|
148
|
+
let i = atIndex;
|
|
149
|
+
for (const s of roster.slots) {
|
|
150
|
+
seen.add(s.slot);
|
|
151
|
+
const b = bodies.get(s.slot);
|
|
152
|
+
if (!b) { const nb = opts.spawn(s, i++); nb.slot = s.slot; nb.seat = s.seat; nb.name = s.name; nb.bot = s.bot; nb.score = nb.score ?? 0; bodies.set(s.slot, nb); continue; }
|
|
153
|
+
b.seat = s.seat; b.name = s.name; b.bot = s.bot;
|
|
154
|
+
}
|
|
155
|
+
for (const k of [...bodies.keys()]) if (!seen.has(k)) bodies.delete(k);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function moved(b: B): void {
|
|
159
|
+
if (b.bot || b.seat === null) return;
|
|
160
|
+
if (b.seat === mySeat()) { opts.adopt?.(b); return; }
|
|
161
|
+
net.reset(b.seat);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const packRow = (b: B): number[] => [b.slot, b.seat ?? -1, b.score, ...opts.pack(b)];
|
|
165
|
+
function buildSnap(): RoomSnap<F> {
|
|
166
|
+
const r = round ?? { n: 0, phase: 'live', startedAt: 0, endsAt: 0 };
|
|
167
|
+
const snap: RoomSnap<F> = { r: [r.n, r.phase === 'over' ? 1 : 0, r.startedAt, r.endsAt], b: [...bodies.values()].map(packRow) };
|
|
168
|
+
if (opts.fastWorld) snap.w = opts.fastWorld();
|
|
169
|
+
return snap;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function startRound(n: number): void {
|
|
173
|
+
const now = net.now();
|
|
174
|
+
roster.trim();
|
|
175
|
+
syncBodies();
|
|
176
|
+
let i = 0;
|
|
177
|
+
for (const s of [...roster.slots].sort((a, c) => a.slot - c.slot)) {
|
|
178
|
+
const fresh = opts.spawn(s, i++);
|
|
179
|
+
fresh.slot = s.slot; fresh.seat = s.seat; fresh.name = s.name; fresh.bot = s.bot; fresh.score = 0;
|
|
180
|
+
bodies.set(s.slot, fresh);
|
|
181
|
+
}
|
|
182
|
+
round = { n, phase: 'live', startedAt: now, endsAt: now + roundMs };
|
|
183
|
+
opts.onRoundStart?.(n);
|
|
184
|
+
for (const b of bodies.values()) moved(b);
|
|
185
|
+
net.round(round);
|
|
186
|
+
net.roster(roster.toJSON());
|
|
187
|
+
net.snapshot(buildSnap(), tick, true);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function rank(): RoundResult[] {
|
|
191
|
+
const ranked = [...bodies.values()].sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot) || a.slot - b.slot);
|
|
192
|
+
return ranked.map((b, i) => ({ slot: b.slot, seat: b.seat, name: b.name, score: b.score, bot: b.bot, place: i + 1 }));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function endRound(): void {
|
|
196
|
+
if (!round) return;
|
|
197
|
+
const now = net.now();
|
|
198
|
+
const results = rank();
|
|
199
|
+
round = { n: round.n, phase: 'over', startedAt: now, endsAt: now + breakMs, results };
|
|
200
|
+
opts.onRoundEnd?.(results);
|
|
201
|
+
net.round(round);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function restore(e: RoleChange<RoomSnap<F>, RoomCkpt<B, S>>): void {
|
|
205
|
+
const ck = e.ckpt?.d ?? null;
|
|
206
|
+
if (ck) {
|
|
207
|
+
roster = Roster.from(ck.roster, { min, max, botName });
|
|
208
|
+
bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
|
|
209
|
+
round = ck.round;
|
|
210
|
+
tick = ck.tick;
|
|
211
|
+
} else {
|
|
212
|
+
roster = Roster.from(e.roster ?? [], { min, max, botName });
|
|
213
|
+
bodies = new Map();
|
|
214
|
+
syncBodies();
|
|
215
|
+
}
|
|
216
|
+
// The snapshot is newer than the checkpoint (20 Hz vs 1 Hz): positions and scores from it.
|
|
217
|
+
const s = e.snap;
|
|
218
|
+
if (s && (!e.ckpt || s.st >= e.ckpt.st)) {
|
|
219
|
+
for (const row of s.d.b) {
|
|
220
|
+
const [slot, seat, score] = row as [number, number, number];
|
|
221
|
+
let b = bodies.get(slot);
|
|
222
|
+
if (!b) {
|
|
223
|
+
const sl = roster.slots.find((r) => r.slot === slot) ?? { slot, seat: seat >= 0 ? seat : null, name: seat >= 0 ? `Player ${seat + 1}` : botName(slot), bot: seat < 0 };
|
|
224
|
+
b = opts.spawn(sl, slot); b.slot = slot; b.seat = sl.seat; b.name = sl.name; b.bot = sl.bot;
|
|
225
|
+
bodies.set(slot, b);
|
|
226
|
+
}
|
|
227
|
+
b.score = score;
|
|
228
|
+
opts.unpack(row.slice(3), b);
|
|
229
|
+
}
|
|
230
|
+
const [n, ph, startedAt, endsAt] = s.d.r;
|
|
231
|
+
if (!round || n >= round.n) round = { n, phase: ph ? 'over' : 'live', startedAt, endsAt, ...(round?.n === n && round.results ? { results: round.results } : {}) };
|
|
232
|
+
tick = Math.max(tick, s.k);
|
|
233
|
+
}
|
|
234
|
+
// A round the relay kept is at least as new as the checkpoint's.
|
|
235
|
+
if (e.round && (!round || e.round.n > round.n || (e.round.n === round.n && e.round.phase === 'over' && round.phase === 'live'))) round = e.round;
|
|
236
|
+
opts.loadWorld?.(ck?.world ?? null, s?.d.w);
|
|
237
|
+
if (!round) { startRound(1); return; }
|
|
238
|
+
net.round(round);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function becomeHost(e: RoleChange<RoomSnap<F>, RoomCkpt<B, S>>): void {
|
|
242
|
+
// My own body was mine a moment ago: keep it exactly where I see it.
|
|
243
|
+
const before = !hosting && mySeat() !== null && opts.local ? opts.local() : null;
|
|
244
|
+
hosting = true;
|
|
245
|
+
if (e.promoted) restore(e);
|
|
246
|
+
else {
|
|
247
|
+
roster = new Roster({ min, max, botName });
|
|
248
|
+
bodies = new Map();
|
|
249
|
+
const seat = mySeat();
|
|
250
|
+
if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
|
|
251
|
+
syncBodies();
|
|
252
|
+
startRound((e.round?.n ?? 0) + 1);
|
|
253
|
+
}
|
|
254
|
+
const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null);
|
|
255
|
+
const { claimed } = roster.reconcile(peers);
|
|
256
|
+
syncBodies();
|
|
257
|
+
for (const s of claimed) { const b = bodies.get(s.slot); if (b && b.seat !== mySeat()) moved(b); }
|
|
258
|
+
const mine = bodyOfSeat(mySeat());
|
|
259
|
+
if (mine && before) opts.unpack(opts.pack(before), mine);
|
|
260
|
+
net.roster(roster.toJSON());
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
net.on('role', (e) => {
|
|
264
|
+
if (e.role === 'host') becomeHost(e);
|
|
265
|
+
else hosting = false;
|
|
266
|
+
});
|
|
267
|
+
net.on('join', (p) => {
|
|
268
|
+
if (!hosting || p.seat === null) return;
|
|
269
|
+
const c = roster.claim(p.seat, p.name);
|
|
270
|
+
if (!c) return; // full: they watch
|
|
271
|
+
syncBodies();
|
|
272
|
+
const b = bodies.get(c.slot.slot);
|
|
273
|
+
if (b) {
|
|
274
|
+
if (opts.onTakeover) opts.onTakeover(b); else b.score = 0;
|
|
275
|
+
moved(b);
|
|
276
|
+
}
|
|
277
|
+
net.roster(roster.toJSON());
|
|
278
|
+
net.snapshot(buildSnap(), tick, true);
|
|
279
|
+
});
|
|
280
|
+
net.on('leave', (p) => {
|
|
281
|
+
if (!hosting || p.seat === null) return;
|
|
282
|
+
roster.release(p.seat); // the body stays, a bot drives it now
|
|
283
|
+
syncBodies();
|
|
284
|
+
net.roster(roster.toJSON());
|
|
285
|
+
});
|
|
286
|
+
net.on('control', (e) => {
|
|
287
|
+
if (!e.reset || !opts.adopt) return;
|
|
288
|
+
const snap = e.snap as Snapshot<RoomSnap<F>> | null;
|
|
289
|
+
const row = snap?.d?.b?.find((r) => r[1] === net.seat);
|
|
290
|
+
if (!row) return;
|
|
291
|
+
const b = { slot: row[0], seat: row[1], score: row[2], name: net.name, bot: false } as unknown as B;
|
|
292
|
+
opts.unpack(row.slice(3), b);
|
|
293
|
+
opts.adopt(b);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
function interpolated(): B[] {
|
|
297
|
+
const smp = net.sample();
|
|
298
|
+
if (!smp) return [];
|
|
299
|
+
const bySlot = new Map<number, number[]>(smp.a.d.b.map((r) => [r[0] as number, r]));
|
|
300
|
+
const names = new Map<number, Slot>((net.slots ?? []).map((s) => [s.slot, s]));
|
|
301
|
+
const out: B[] = [];
|
|
302
|
+
for (const rb of smp.b.d.b) {
|
|
303
|
+
const ra = bySlot.get(rb[0] as number) ?? rb;
|
|
304
|
+
const fields: number[] = [];
|
|
305
|
+
for (let i = 3; i < rb.length; i++) {
|
|
306
|
+
const a = ra[i] as number; const b = rb[i] as number; const k = i - 3;
|
|
307
|
+
fields.push(discrete.has(k) || !Number.isFinite(a) ? b : angles.has(k) ? lerpAngle(a, b, smp.alpha) : lerp(a, b, smp.alpha));
|
|
308
|
+
}
|
|
309
|
+
const slot = rb[0] as number; const seat = rb[1] as number; const sl = names.get(slot);
|
|
310
|
+
const body = { slot, seat: seat >= 0 ? seat : null, score: rb[2] as number, bot: seat < 0, name: sl?.name ?? (seat >= 0 ? `Player ${seat + 1}` : botName(slot)) } as unknown as B;
|
|
311
|
+
opts.unpack(fields, body);
|
|
312
|
+
out.push(body);
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const room: Room<B, F, S> = {
|
|
318
|
+
net,
|
|
319
|
+
get hosting() { return hosting; },
|
|
320
|
+
get bodies() { return bodies; },
|
|
321
|
+
get round() { return hosting ? round : (net.roundInfo ?? round); },
|
|
322
|
+
mySeat,
|
|
323
|
+
mine: () => (hosting ? bodyOfSeat(mySeat()) : null),
|
|
324
|
+
update() {
|
|
325
|
+
if (!hosting) return;
|
|
326
|
+
tick += 1;
|
|
327
|
+
const now = net.now();
|
|
328
|
+
if (round && round.phase === 'live' && now >= round.endsAt) endRound();
|
|
329
|
+
else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1);
|
|
330
|
+
if (net.snapshotDue()) net.snapshot(buildSnap(), tick);
|
|
331
|
+
},
|
|
332
|
+
avatar: (b) => (b.bot || b.seat === null ? null : (net.avatar(b.seat) as unknown[] | null)),
|
|
333
|
+
bound(b, claim, maxSpeed, dt) {
|
|
334
|
+
if (b.seat === null) return false;
|
|
335
|
+
const m = capMove(b, claim, maxSpeed * 1.3 * dt + maxSpeed * 0.02);
|
|
336
|
+
if (m.over > maxSpeed * 0.5) { net.reset(b.seat); return false; }
|
|
337
|
+
b.x = m.x; b.y = m.y; if (m.z !== undefined) b.z = m.z;
|
|
338
|
+
return true;
|
|
339
|
+
},
|
|
340
|
+
moved,
|
|
341
|
+
presses: (b) => (b.seat === null || b.bot ? {} : net.takePresses(b.seat)),
|
|
342
|
+
input: (frame, held) => { if (!hosting && net.seat !== null) net.input(frame, held ? [...held] : []); },
|
|
343
|
+
press: (id) => { if (!hosting) net.press(id); },
|
|
344
|
+
view: () => (hosting ? [...bodies.values()] : interpolated()),
|
|
345
|
+
fast: () => (hosting ? opts.fastWorld?.() : net.latest()?.d.w),
|
|
346
|
+
clock() {
|
|
347
|
+
const r = room.round;
|
|
348
|
+
if (!r) return { n: 0, phase: 'none', secondsLeft: 0 };
|
|
349
|
+
return { n: r.n, phase: r.phase, secondsLeft: Math.max(0, Math.ceil((r.endsAt - net.now()) / 1000)) };
|
|
350
|
+
},
|
|
351
|
+
results: () => (hosting ? rank() : (room.round?.results ?? [])),
|
|
352
|
+
send: (kind, data) => net.send(kind, data),
|
|
353
|
+
on: net.on.bind(net) as Room<B, F, S>['on'],
|
|
354
|
+
};
|
|
355
|
+
return room;
|
|
356
|
+
}
|
package/port/sandbox.ts
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The studio's play page runs every game in a sandboxed frame WITHOUT
|
|
3
|
+
* allow-same-origin: an opaque origin, so a stranger's game can never read the
|
|
4
|
+
* site's storage or cookies. The price: web storage, cookies and (on WebKit)
|
|
5
|
+
* the Gamepad API throw a SecurityError the moment a game touches them, and a
|
|
6
|
+
* single-player game that saves a high score in localStorage dies at boot.
|
|
7
|
+
*
|
|
8
|
+
* installSandboxShims() puts working in-memory stand-ins in their place, only
|
|
9
|
+
* where the real thing throws. Call it before the game's own code runs: a
|
|
10
|
+
* bundled game imports '@homie-rocks/studio/port/early' first; a static game loads
|
|
11
|
+
* `homie-port.js` as the first script in <head> (`homie-studio port import`
|
|
12
|
+
* puts it there). Scores then last for the visit, not forever: the port plan
|
|
13
|
+
* says so, and the game can keep a best score in the room's keyed state.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export interface SandboxReport {
|
|
17
|
+
localStorage: 'native' | 'memory';
|
|
18
|
+
sessionStorage: 'native' | 'memory';
|
|
19
|
+
cookie: 'native' | 'memory';
|
|
20
|
+
gamepads: 'native' | 'guarded' | 'absent';
|
|
21
|
+
indexedDB: 'native' | 'blocked' | 'absent';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A Storage that lives for the page: getItem/setItem/removeItem/clear/key/length, and `storage.key = v` too. */
|
|
25
|
+
function memoryStorage(): Storage {
|
|
26
|
+
const data = new Map<string, string>();
|
|
27
|
+
const api = {
|
|
28
|
+
get length() { return data.size; },
|
|
29
|
+
key(i: number): string | null { return [...data.keys()][i] ?? null; },
|
|
30
|
+
getItem(k: string): string | null { return data.has(String(k)) ? (data.get(String(k)) as string) : null; },
|
|
31
|
+
setItem(k: string, v: unknown): void { data.set(String(k), String(v)); },
|
|
32
|
+
removeItem(k: string): void { data.delete(String(k)); },
|
|
33
|
+
clear(): void { data.clear(); },
|
|
34
|
+
};
|
|
35
|
+
// Old games write `localStorage.best = 12` and read `localStorage['best']`.
|
|
36
|
+
return new Proxy(api, {
|
|
37
|
+
get(target, prop) {
|
|
38
|
+
if (typeof prop === 'symbol' || prop in target) return Reflect.get(target, prop);
|
|
39
|
+
return data.has(prop) ? data.get(prop) : undefined;
|
|
40
|
+
},
|
|
41
|
+
set(target, prop, value) {
|
|
42
|
+
if (typeof prop === 'symbol' || prop in target) return false;
|
|
43
|
+
data.set(prop, String(value));
|
|
44
|
+
return true;
|
|
45
|
+
},
|
|
46
|
+
deleteProperty(_target, prop) { if (typeof prop === 'string') data.delete(prop); return true; },
|
|
47
|
+
has(target, prop) { return prop in target || (typeof prop === 'string' && data.has(prop)); },
|
|
48
|
+
ownKeys() { return [...data.keys()]; },
|
|
49
|
+
getOwnPropertyDescriptor(_target, prop) {
|
|
50
|
+
if (typeof prop === 'string' && data.has(prop)) return { value: data.get(prop), writable: true, enumerable: true, configurable: true };
|
|
51
|
+
return undefined;
|
|
52
|
+
},
|
|
53
|
+
}) as unknown as Storage;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function works(read: () => unknown): boolean {
|
|
57
|
+
try { read(); return true; } catch { return false; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let report: SandboxReport | null = null;
|
|
61
|
+
|
|
62
|
+
/** Replace what an opaque-origin frame refuses with in-memory stand-ins. Safe to call twice; returns what it did. */
|
|
63
|
+
export function installSandboxShims(): SandboxReport {
|
|
64
|
+
if (report) return report;
|
|
65
|
+
const w = globalThis as unknown as Window & typeof globalThis;
|
|
66
|
+
const out: SandboxReport = { localStorage: 'native', sessionStorage: 'native', cookie: 'native', gamepads: 'absent', indexedDB: 'absent' };
|
|
67
|
+
if (typeof window === 'undefined') { report = out; return out; }
|
|
68
|
+
for (const name of ['localStorage', 'sessionStorage'] as const) {
|
|
69
|
+
if (works(() => { const s = w[name]; s.getItem('__homie_probe__'); })) continue;
|
|
70
|
+
try {
|
|
71
|
+
Object.defineProperty(w, name, { value: memoryStorage(), configurable: true, enumerable: true, writable: false });
|
|
72
|
+
out[name] = 'memory';
|
|
73
|
+
} catch { /* leave it: the port plan flags storage use */ }
|
|
74
|
+
}
|
|
75
|
+
if (typeof document !== 'undefined' && !works(() => document.cookie)) {
|
|
76
|
+
let jar = '';
|
|
77
|
+
try {
|
|
78
|
+
Object.defineProperty(document, 'cookie', {
|
|
79
|
+
configurable: true,
|
|
80
|
+
get: () => jar,
|
|
81
|
+
set: (v: string) => {
|
|
82
|
+
const [pair] = String(v).split(';');
|
|
83
|
+
const [k] = String(pair).split('=');
|
|
84
|
+
const rest = jar.split('; ').filter((c) => c && !c.startsWith(`${String(k).trim()}=`));
|
|
85
|
+
jar = [...rest, String(pair).trim()].join('; ');
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
out.cookie = 'memory';
|
|
89
|
+
} catch { /* ignore */ }
|
|
90
|
+
}
|
|
91
|
+
const nav = w.navigator as Navigator & { getGamepads?: () => (Gamepad | null)[] };
|
|
92
|
+
if (nav && typeof nav.getGamepads === 'function') {
|
|
93
|
+
const real = nav.getGamepads.bind(nav);
|
|
94
|
+
let dead = false;
|
|
95
|
+
out.gamepads = 'native';
|
|
96
|
+
try {
|
|
97
|
+
// WebKit throws a SecurityError on EVERY poll in a sandboxed frame; one throw and it stops asking.
|
|
98
|
+
Object.defineProperty(nav, 'getGamepads', {
|
|
99
|
+
configurable: true,
|
|
100
|
+
value: () => {
|
|
101
|
+
if (dead) return [];
|
|
102
|
+
try { return real(); } catch { dead = true; out.gamepads = 'guarded'; return []; }
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
} catch { /* ignore */ }
|
|
106
|
+
}
|
|
107
|
+
if ('indexedDB' in w) out.indexedDB = works(() => w.indexedDB.open('__homie_probe__')) ? 'native' : 'blocked';
|
|
108
|
+
report = out;
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function sandboxReport(): SandboxReport | null { return report; }
|