@homie-rocks/studio 0.18.0 → 0.18.2
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 +12 -5
- package/lib/brain-probe.mjs +154 -0
- package/lib/doctor.mjs +18 -2
- package/lib/template-history.json +202 -9
- package/lib/upgrade.mjs +38 -11
- package/package.json +1 -1
- package/port/index.ts +4 -0
- package/port/room.ts +36 -9
- package/port/view.ts +213 -0
- package/saves/SAVES.md +4 -1
- package/starters/ember-vale/index.html +8 -3
- package/starters/ember-vale/src/main.ts +233 -65
- package/starters/gem-rush/src/main.ts +46 -9
- package/worker/version.mjs +1 -1
package/lib/upgrade.mjs
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* added a file, an AGENTS.md section, a .gitignore line or a package.json script the studio does not have;
|
|
11
11
|
* updated a file or an AGENTS.md section the studio never changed since an older template wrote it (its text
|
|
12
|
-
* is exactly an earlier version's: lib/template-history.json keeps a fingerprint of every one
|
|
13
|
-
* template's newer text replaces it;
|
|
12
|
+
* is exactly an earlier version's: lib/template-history.json keeps a fingerprint of every one, and of
|
|
13
|
+
* every one as a studio the template itself names reads it), so the template's newer text replaces it;
|
|
14
14
|
* kept anything the studio changed, or wrote itself: never touched. The plan says it differs, and
|
|
15
15
|
* `--diff` prints the difference, so the person can take the template's words by hand.
|
|
16
16
|
*
|
|
@@ -42,6 +42,14 @@ export const ADD_ONLY = ['music/manifest.json', 'videos/manifest.json', '.claude
|
|
|
42
42
|
const MIGRATIONS = /^site\/migrations\/[^/]+\.sql$/;
|
|
43
43
|
/** The name and slug stand-ins the history is fingerprinted with. */
|
|
44
44
|
export const STAND_IN = { name: '{{studio.name}}', slug: '{{studio.slug}}' };
|
|
45
|
+
/**
|
|
46
|
+
* Studios the template's own words name: `demo` plays a live game "on Homie Arcade" (the Commands section). A
|
|
47
|
+
* studio's text is compared with its own name and slug made neutral (normalize), so for the studio that IS Homie
|
|
48
|
+
* Arcade that mention turns into the stand-in too, and no stand-in fingerprint could ever match its untouched text:
|
|
49
|
+
* it read as changed by the studio, and kept the template's older words for good. The history also keeps, for each of
|
|
50
|
+
* these names, the fingerprints that differ when the template is written for that studio (`named`).
|
|
51
|
+
*/
|
|
52
|
+
export const NAMED_IN_TEMPLATE = Object.freeze([Object.freeze({ name: 'Homie Arcade', slug: 'homie-arcade' })]);
|
|
45
53
|
const INTRO = '(intro)';
|
|
46
54
|
|
|
47
55
|
/** A text as the template compares it: line ends, trailing spaces and the studio's own name and slug made neutral. */
|
|
@@ -64,9 +72,9 @@ export function sections(text) {
|
|
|
64
72
|
return out.map((s) => ({ heading: s.heading, text: s.lines.join('\n') })).filter((s, i) => i > 0 || s.text.trim());
|
|
65
73
|
}
|
|
66
74
|
|
|
67
|
-
/** What the history keeps of one template: a fingerprint of every whole file and every AGENTS.md section
|
|
68
|
-
|
|
69
|
-
|
|
75
|
+
/** What the history keeps of one template: a fingerprint of every whole file and every AGENTS.md section, as `who`
|
|
76
|
+
* (the stand-ins, or a studio the template names) reads them. */
|
|
77
|
+
export function templatePrint(files, who = STAND_IN) {
|
|
70
78
|
return {
|
|
71
79
|
files: Object.fromEntries(TEMPLATE_FILES.filter((f) => f !== 'AGENTS.md' && files[f] !== undefined).map((f) => [f, fingerprint(files[f], who)])),
|
|
72
80
|
sections: { 'AGENTS.md': Object.fromEntries(sections(files['AGENTS.md']).map((s) => [s.heading, fingerprint(s.text, who)])) },
|
|
@@ -77,14 +85,33 @@ export function readHistory() {
|
|
|
77
85
|
try { return JSON.parse(readFileSync(new URL('./template-history.json', import.meta.url), 'utf8')); } catch { return { v: 1, versions: {} }; }
|
|
78
86
|
}
|
|
79
87
|
|
|
80
|
-
/**
|
|
81
|
-
|
|
88
|
+
/**
|
|
89
|
+
* A template as the studios it names read it (NAMED_IN_TEMPLATE): for each, the fingerprints that differ from the
|
|
90
|
+
* stand-ins' (only a file or section that names it does). `render(who)` is what `homie-studio new` writes for `who`.
|
|
91
|
+
*/
|
|
92
|
+
export function namedPrint(render, plain = templatePrint(render(STAND_IN))) {
|
|
93
|
+
const named = {};
|
|
94
|
+
for (const who of NAMED_IN_TEMPLATE) {
|
|
95
|
+
const p = templatePrint(render(who), who);
|
|
96
|
+
const files = Object.fromEntries(Object.entries(p.files).filter(([f, h]) => plain.files[f] !== h));
|
|
97
|
+
const secs = Object.fromEntries(Object.entries(p.sections['AGENTS.md']).filter(([s, h]) => plain.sections['AGENTS.md'][s] !== h));
|
|
98
|
+
if (Object.keys(files).length || Object.keys(secs).length) named[who.name] = { ...(Object.keys(files).length ? { files } : {}), sections: { 'AGENTS.md': secs } };
|
|
99
|
+
}
|
|
100
|
+
return named;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Every fingerprint an older template wrote, per file and per AGENTS.md section, as this studio (`who`) reads it. */
|
|
104
|
+
function known(history, who = {}) {
|
|
82
105
|
const files = new Map();
|
|
83
106
|
const secs = new Map();
|
|
84
107
|
for (const v of Object.values(history.versions ?? {})) {
|
|
85
|
-
// A
|
|
86
|
-
|
|
87
|
-
for (const
|
|
108
|
+
// A studio the template names reads those mentions as its own name: its fingerprints are kept beside the rest.
|
|
109
|
+
const as = who.name && v.named && Object.hasOwn(v.named, who.name) ? v.named[who.name] : null;
|
|
110
|
+
for (const p of as ? [v, as] : [v]) {
|
|
111
|
+
// A version may hold more than one hash for a file (two templates published under it): a list.
|
|
112
|
+
for (const [f, h] of Object.entries(p.files ?? {})) for (const x of [h].flat()) (files.get(f) ?? files.set(f, new Set()).get(f)).add(x);
|
|
113
|
+
for (const [heading, h] of Object.entries(p.sections?.['AGENTS.md'] ?? {})) for (const x of [h].flat()) (secs.get(heading) ?? secs.set(heading, new Set()).get(heading)).add(x);
|
|
114
|
+
}
|
|
88
115
|
}
|
|
89
116
|
return { files, secs };
|
|
90
117
|
}
|
|
@@ -137,7 +164,7 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
|
|
|
137
164
|
if (!name || !slug) return { ok: false, command: 'upgrade', why: 'studio.json has no name or slug; this does not look like a studio made with homie-studio new' };
|
|
138
165
|
const who = { name, slug };
|
|
139
166
|
const tmpl = studioFiles({ name, slug, homie: studio.homie?.directory ?? 'https://homie.rocks' });
|
|
140
|
-
const { files: oldFiles, secs: oldSecs } = known(history);
|
|
167
|
+
const { files: oldFiles, secs: oldSecs } = known(history, who);
|
|
141
168
|
const changes = [];
|
|
142
169
|
const kept = [];
|
|
143
170
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.2",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/port/index.ts
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* keys on computers, a floating stick and small buttons on phones
|
|
9
9
|
* groundBasis / screenToGround / PlayerYaw
|
|
10
10
|
* the camera rules: input on screen axes, a yaw only the player turns
|
|
11
|
+
* fitView / easeView / toScreen / createLabels
|
|
12
|
+
* a flat world on every screen (the whole of it, or filling an upright phone and following the
|
|
13
|
+
* player) and name labels that never pile up
|
|
11
14
|
* exposePort what `homie-studio port check` reads (owner tests)
|
|
12
15
|
* createHud a minimal clock / scores / results overlay
|
|
13
16
|
* BotBrain, seek, nearest, rubberBand
|
|
@@ -26,6 +29,7 @@ export { createKeys, synthKey, holdKey, tapKey, keyInfo, releaseAllSynthKeys, ty
|
|
|
26
29
|
export { createTouchControls, type TouchControls, type TouchOptions, type TouchButton } from './touch';
|
|
27
30
|
export { createControls, type Controls, type ControlsOptions } from './controls';
|
|
28
31
|
export { groundBasis, screenToGround, PlayerYaw, type GroundBasis, type CameraLike } from './camera';
|
|
32
|
+
export { fitView, easeView, toScreen, createLabels, type Fit, type FitOptions, type LabelIn, type LabelOut, type LabelOptions, type LabelBox } from './view';
|
|
29
33
|
export { exposePort, type PortProbeOptions, type View } from './probe';
|
|
30
34
|
export { createRoom, type Room, type RoomOptions, type BodyBase, type RoomSnap, type RoomCkpt } from './room';
|
|
31
35
|
export { createHud, type HudOptions } from './hud';
|
package/port/room.ts
CHANGED
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
export interface BodyBase { slot: number; seat: number | null; name: string; bot: boolean; score: number; agent?: Slot['agent'] }
|
|
35
35
|
|
|
36
36
|
export interface RoomSnap<F = unknown> { r: [n: number, phase: number, startedAt: number, endsAt: number]; b: number[][]; w?: F }
|
|
37
|
-
export interface RoomCkpt<B, S> { round: RoundInfo | null; roster: Slot[]; bodies: B[]; world: S | null; tick: number }
|
|
37
|
+
export interface RoomCkpt<B, S> { round: RoundInfo | null; roster: Slot[]; bodies: B[]; world: S | null; tick: number; spawns?: [slot: number, index: number][] }
|
|
38
38
|
|
|
39
39
|
export interface RoomOptions<B extends BodyBase, F = unknown, S = unknown> {
|
|
40
40
|
/** The game id (the relay's logs). */
|
|
@@ -50,7 +50,11 @@ export interface RoomOptions<B extends BodyBase, F = unknown, S = unknown> {
|
|
|
50
50
|
/** Results on screen between rounds (default 7). */
|
|
51
51
|
breakSeconds?: number;
|
|
52
52
|
botName?: (slot: number) => string;
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* A fresh body for a slot. Spread the spawns with `index`: at a round start the bodies get 0, 1, 2… in slot order; a
|
|
55
|
+
* body that arrives mid-round (a joiner with no bot to take over, a guide's seat) gets the lowest index no body in
|
|
56
|
+
* the room holds, so two bodies never get the same one.
|
|
57
|
+
*/
|
|
54
58
|
spawn: (slot: Slot, index: number) => B;
|
|
55
59
|
/** The body's fast fields for the snapshot, after [slot, seat, score]: small numbers only (use q()). */
|
|
56
60
|
pack: (b: B) => number[];
|
|
@@ -143,6 +147,8 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
143
147
|
const policy = (): Policy => net.policy;
|
|
144
148
|
let roster = new Roster({ min, max, botName, policy });
|
|
145
149
|
let bodies = new Map<number, B>();
|
|
150
|
+
/** The index each body was spawned with (slot → index), so a body that arrives mid-round never gets one in use. */
|
|
151
|
+
let spawned = new Map<number, number>();
|
|
146
152
|
let round: RoundInfo | null = null;
|
|
147
153
|
let hosting = false;
|
|
148
154
|
let tick = 0;
|
|
@@ -150,7 +156,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
150
156
|
const net = createNetplay<RoomSnap<F>, unknown[], RoomCkpt<B, S>>({
|
|
151
157
|
game: opts.game, maxPlayers: max, movement: opts.movement ?? 'owner',
|
|
152
158
|
snapshotHz: 20, inputHz: 20, checkpointMs: 1000,
|
|
153
|
-
checkpoint: () => ({ round, roster: roster.toJSON(), bodies: [...bodies.values()].map((b) => ({ ...b })), world: opts.saveWorld ? opts.saveWorld() : null, tick }),
|
|
159
|
+
checkpoint: () => ({ round, roster: roster.toJSON(), bodies: [...bodies.values()].map((b) => ({ ...b })), world: opts.saveWorld ? opts.saveWorld() : null, tick, spawns: [...spawned].filter(([slot]) => bodies.has(slot)) }),
|
|
154
160
|
...(opts.netplay ?? {}),
|
|
155
161
|
// createRoom claims an AI's slot for it (its join passes `p.agent`), so its host can move an AI's body.
|
|
156
162
|
caps: [...new Set([...(opts.netplay?.caps ?? []), 'agents' as const])],
|
|
@@ -163,17 +169,31 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
163
169
|
return null;
|
|
164
170
|
};
|
|
165
171
|
|
|
166
|
-
|
|
172
|
+
/** The lowest spawn index no body in the room holds. */
|
|
173
|
+
function freeIndex(): number {
|
|
174
|
+
const held = new Set<number>();
|
|
175
|
+
for (const slot of bodies.keys()) { const i = spawned.get(slot); if (i !== undefined) held.add(i); }
|
|
176
|
+
let i = 0;
|
|
177
|
+
while (held.has(i)) i += 1;
|
|
178
|
+
return i;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function syncBodies(): void {
|
|
167
182
|
const seen = new Set<number>();
|
|
168
|
-
let i = atIndex;
|
|
169
183
|
for (const s of roster.slots) {
|
|
170
184
|
seen.add(s.slot);
|
|
171
185
|
const b = bodies.get(s.slot);
|
|
172
|
-
if (!b) {
|
|
186
|
+
if (!b) {
|
|
187
|
+
// Mid-round (a joiner, a guide's seat): never an index another body holds, so never another body's spot.
|
|
188
|
+
const i = freeIndex();
|
|
189
|
+
const nb = opts.spawn(s, i); nb.slot = s.slot; nb.seat = s.seat; nb.name = s.name; nb.bot = s.bot; nb.score = nb.score ?? 0; if (s.agent) nb.agent = { ...s.agent };
|
|
190
|
+
bodies.set(s.slot, nb); spawned.set(s.slot, i);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
173
193
|
b.seat = s.seat; b.name = s.name; b.bot = s.bot;
|
|
174
194
|
if (s.agent) b.agent = { ...s.agent }; else delete b.agent;
|
|
175
195
|
}
|
|
176
|
-
for (const k of [...bodies.keys()]) if (!seen.has(k)) bodies.delete(k);
|
|
196
|
+
for (const k of [...bodies.keys()]) if (!seen.has(k)) { bodies.delete(k); spawned.delete(k); }
|
|
177
197
|
}
|
|
178
198
|
|
|
179
199
|
function moved(b: B): void {
|
|
@@ -195,7 +215,9 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
195
215
|
roster.trim();
|
|
196
216
|
syncBodies();
|
|
197
217
|
let i = 0;
|
|
218
|
+
spawned = new Map();
|
|
198
219
|
for (const s of [...roster.slots].sort((a, c) => a.slot - c.slot)) {
|
|
220
|
+
spawned.set(s.slot, i);
|
|
199
221
|
const fresh = opts.spawn(s, i++);
|
|
200
222
|
fresh.slot = s.slot; fresh.seat = s.seat; fresh.name = s.name; fresh.bot = s.bot; fresh.score = 0;
|
|
201
223
|
if (s.agent) fresh.agent = { ...s.agent };
|
|
@@ -228,11 +250,14 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
228
250
|
if (ck) {
|
|
229
251
|
roster = Roster.from(ck.roster, { min, max, botName, policy });
|
|
230
252
|
bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
|
|
253
|
+
// A checkpoint from before spawns were kept: the round start's order (slot order) is the best guess.
|
|
254
|
+
spawned = new Map(ck.spawns ?? [...bodies.keys()].sort((a, c) => a - c).map((slot, i) => [slot, i]));
|
|
231
255
|
round = ck.round;
|
|
232
256
|
tick = ck.tick;
|
|
233
257
|
} else {
|
|
234
258
|
roster = Roster.from(e.roster ?? [], { min, max, botName, policy });
|
|
235
259
|
bodies = new Map();
|
|
260
|
+
spawned = new Map();
|
|
236
261
|
syncBodies();
|
|
237
262
|
}
|
|
238
263
|
// The snapshot is newer than the checkpoint (20 Hz vs 1 Hz): positions and scores from it.
|
|
@@ -243,8 +268,9 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
243
268
|
let b = bodies.get(slot);
|
|
244
269
|
if (!b) {
|
|
245
270
|
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 };
|
|
246
|
-
|
|
247
|
-
|
|
271
|
+
const i = freeIndex();
|
|
272
|
+
b = opts.spawn(sl, i); b.slot = slot; b.seat = sl.seat; b.name = sl.name; b.bot = sl.bot;
|
|
273
|
+
bodies.set(slot, b); spawned.set(slot, i);
|
|
248
274
|
}
|
|
249
275
|
b.score = score;
|
|
250
276
|
opts.unpack(row.slice(3), b);
|
|
@@ -268,6 +294,7 @@ export function createRoom<B extends BodyBase, F = unknown, S = unknown>(opts: R
|
|
|
268
294
|
else {
|
|
269
295
|
roster = new Roster({ min, max, botName, policy });
|
|
270
296
|
bodies = new Map();
|
|
297
|
+
spawned = new Map();
|
|
271
298
|
const seat = mySeat();
|
|
272
299
|
if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
|
|
273
300
|
syncBodies();
|
package/port/view.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* A flat world on every screen: a phone held upright, the same phone on its side, a computer and a television.
|
|
3
|
+
* Two pieces for a top-down canvas game (both starters use them):
|
|
4
|
+
*
|
|
5
|
+
* fitView(o) where the camera looks and how close. The whole world where it fits at a size that reads
|
|
6
|
+
* (a computer, a TV); elsewhere (most of all a phone held upright, where "fit" drew a strip
|
|
7
|
+
* a quarter of the screen tall) the world FILLS the screen and follows the player, never
|
|
8
|
+
* past the world's edge, except as far as it takes to keep the player clear of the HUD.
|
|
9
|
+
* createLabels() name labels that never pile up. Each label wants its spot above its body; when that spot
|
|
10
|
+
* is taken it tries a row higher, then under the body, else it fades out. The player's own
|
|
11
|
+
* label is placed first and nothing covers it; a moved label keeps its new spot while it is
|
|
12
|
+
* free (no flicker), and labels fade in and out rather than pop.
|
|
13
|
+
*
|
|
14
|
+
* Everything is in CSS pixels (screen) and world units; the game draws. See the starters (gem-rush, ember-vale).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface FitOptions {
|
|
18
|
+
/** The world's size, in world units. */
|
|
19
|
+
world: { w: number; h: number };
|
|
20
|
+
/** The screen (the canvas's CSS size). */
|
|
21
|
+
screen: { w: number; h: number };
|
|
22
|
+
/** CSS px per world unit below which the whole world is too small to play (bodies too small to see, names too small
|
|
23
|
+
* to read): under it the camera follows `focus`. 0: never show the whole world unless `whole`. */
|
|
24
|
+
readable: number;
|
|
25
|
+
/** How close a following camera is, in CSS px per world unit (default `readable`). It is never less than what
|
|
26
|
+
* fills the screen, so a following camera never shows an empty band beside the world. */
|
|
27
|
+
zoom?: number;
|
|
28
|
+
/** Where a following camera looks, in world units (the player's body, or the player a watcher follows). null: the
|
|
29
|
+
* world's middle. */
|
|
30
|
+
focus?: { x: number; y: number } | null;
|
|
31
|
+
/** Show the whole world whatever its size (an overview: a watcher following nobody). */
|
|
32
|
+
whole?: boolean;
|
|
33
|
+
/** CSS px of HUD along each edge: a following camera slides past the world's edge (up to this much) only when the
|
|
34
|
+
* player would otherwise be under the HUD, at the very edge of the world. */
|
|
35
|
+
inset?: { top?: number; right?: number; bottom?: number; left?: number };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface Fit {
|
|
39
|
+
/** CSS px per world unit. */
|
|
40
|
+
scale: number;
|
|
41
|
+
/** The world point at the screen's centre. */
|
|
42
|
+
x: number;
|
|
43
|
+
y: number;
|
|
44
|
+
/** True when the camera follows (the whole world is not on screen). */
|
|
45
|
+
follow: boolean;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Where the camera should look this frame (ease towards it: a cut only at a round start or a respawn). */
|
|
49
|
+
export function fitView(o: FitOptions): Fit {
|
|
50
|
+
const W = o.world.w; const H = o.world.h;
|
|
51
|
+
const sw = Math.max(1, o.screen.w); const sh = Math.max(1, o.screen.h);
|
|
52
|
+
const contain = Math.min(sw / W, sh / H);
|
|
53
|
+
if (o.whole || (o.readable > 0 && contain >= o.readable)) return { scale: contain, x: W / 2, y: H / 2, follow: false };
|
|
54
|
+
const cover = Math.max(sw / W, sh / H);
|
|
55
|
+
const scale = Math.max(cover, o.zoom ?? o.readable);
|
|
56
|
+
const f = o.focus ?? { x: W / 2, y: H / 2 };
|
|
57
|
+
const i = { top: 0, right: 0, bottom: 0, left: 0, ...(o.inset ?? {}) };
|
|
58
|
+
const hw = sw / 2 / scale; const hh = sh / 2 / scale;
|
|
59
|
+
const within = (v: number, lo: number, hi: number): number => (lo > hi ? (lo + hi) / 2 : Math.max(lo, Math.min(hi, v)));
|
|
60
|
+
// Centred on the focus, kept inside the world; then moved just enough that the focus is clear of the HUD's margins.
|
|
61
|
+
const clear = (c: number, focus: number, half: number, lo: number, hi: number): number => {
|
|
62
|
+
const at = (half + focus - c) * scale; // the focus on screen, from the near edge
|
|
63
|
+
const span = 2 * half * scale;
|
|
64
|
+
if (at < lo) return c - (lo - at) / scale;
|
|
65
|
+
if (at > span - hi) return c + (at - (span - hi)) / scale;
|
|
66
|
+
return c;
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
scale,
|
|
70
|
+
x: clear(within(f.x, hw, W - hw), f.x, hw, i.left, i.right),
|
|
71
|
+
y: clear(within(f.y, hh, H - hh), f.y, hh, i.top, i.bottom),
|
|
72
|
+
follow: true,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** A world point on screen (CSS px) under a camera. */
|
|
77
|
+
export function toScreen(fit: Fit, screen: { w: number; h: number }, x: number, y: number): { x: number; y: number } {
|
|
78
|
+
return { x: screen.w / 2 + (x - fit.x) * fit.scale, y: screen.h / 2 + (y - fit.y) * fit.scale };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Ease a camera towards where it should be (frame-rate independent; `rate` about 8 to 12 a second). */
|
|
82
|
+
export function easeView(cam: Fit | null, want: Fit, dt: number, rate = 10): Fit {
|
|
83
|
+
if (!cam) return { ...want };
|
|
84
|
+
const k = 1 - Math.exp(-Math.max(0, dt) * rate);
|
|
85
|
+
return { scale: cam.scale + (want.scale - cam.scale) * k, x: cam.x + (want.x - cam.x) * k, y: cam.y + (want.y - cam.y) * k, follow: want.follow };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface LabelBox { left: number; top: number; right: number; bottom: number }
|
|
89
|
+
|
|
90
|
+
export interface LabelIn {
|
|
91
|
+
/** Stable per body (its slot): a label keeps its spot and its fade from frame to frame. */
|
|
92
|
+
key: string | number;
|
|
93
|
+
text: string;
|
|
94
|
+
/** The label's centre x and its BOTTOM edge where it wants to sit (above its body), CSS px. */
|
|
95
|
+
x: number;
|
|
96
|
+
y: number;
|
|
97
|
+
/** Its size as drawn, CSS px (measureText's width; the font's line height). */
|
|
98
|
+
w: number;
|
|
99
|
+
h: number;
|
|
100
|
+
/** Where its bottom edge goes if it sits under its body instead (CSS px); omitted: never under. */
|
|
101
|
+
below?: number;
|
|
102
|
+
/** Its body on screen (with its bars): other labels keep off it when they can. */
|
|
103
|
+
body?: LabelBox;
|
|
104
|
+
/** The player's own label (or the player a watcher follows): placed first, never covered, never faded. */
|
|
105
|
+
self?: boolean;
|
|
106
|
+
/** Lower first: who keeps their spot when two want it (people before AI before bots, near before far). */
|
|
107
|
+
rank?: number;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface LabelOut extends LabelIn, LabelBox {
|
|
111
|
+
/** The text's centre (draw with textAlign center, textBaseline middle). */
|
|
112
|
+
cx: number;
|
|
113
|
+
cy: number;
|
|
114
|
+
/** 0..1: 1 shown, 0 gone (no room for it). */
|
|
115
|
+
alpha: number;
|
|
116
|
+
/** Not in its own spot above its body (draw a thin line to the body, so whose it is stays plain). */
|
|
117
|
+
moved: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface LabelOptions {
|
|
121
|
+
/** CSS px kept clear between two labels (default 3). */
|
|
122
|
+
gap?: number;
|
|
123
|
+
/** CSS px kept clear round the player's own label, for a chip drawn behind it (default 4). */
|
|
124
|
+
selfPad?: number;
|
|
125
|
+
/** How fast a label fades in (default 8 a second). One with no room left goes at once if it would touch another. */
|
|
126
|
+
fade?: number;
|
|
127
|
+
/** The screen, so a label is never pushed off it (default the window). */
|
|
128
|
+
screen?: () => { w: number; h: number };
|
|
129
|
+
/** Boxes labels must keep clear of (the game's own speech bubbles, a panel), CSS px. */
|
|
130
|
+
avoid?: () => LabelBox[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Name labels that never pile up. Call `place(labels, dt)` each frame with every label that wants drawing; draw what
|
|
135
|
+
* comes back (skip alpha 0). The order of the input does not matter: `self` first, then `rank`.
|
|
136
|
+
*
|
|
137
|
+
* Each label tries, in order: its own spot above its body, a row higher, under its body, two rows higher; first
|
|
138
|
+
* clear of every placed label and of other bodies, then clear of labels only. A label that moved keeps its new spot
|
|
139
|
+
* until its own is clear by a margin, so labels do not flicker between two spots as bodies jostle.
|
|
140
|
+
*/
|
|
141
|
+
export function createLabels(opts: LabelOptions = {}): { place(labels: LabelIn[], dt: number): LabelOut[]; reset(): void } {
|
|
142
|
+
const gap = opts.gap ?? 3;
|
|
143
|
+
const selfPad = opts.selfPad ?? 4;
|
|
144
|
+
const fadeRate = opts.fade ?? 8;
|
|
145
|
+
const state = new Map<string | number, { spot: number; alpha: number }>();
|
|
146
|
+
const screen = opts.screen ?? ((): { w: number; h: number } => ({ w: typeof innerWidth === 'number' ? innerWidth : 1e9, h: typeof innerHeight === 'number' ? innerHeight : 1e9 }));
|
|
147
|
+
const hits = (a: LabelBox, b: LabelBox, m = gap): boolean => a.left < b.right + m && b.left < a.right + m && a.top < b.bottom + m && b.top < a.bottom + m;
|
|
148
|
+
return {
|
|
149
|
+
reset() { state.clear(); },
|
|
150
|
+
place(labels, dt) {
|
|
151
|
+
const { w: sw, h: sh } = screen();
|
|
152
|
+
const order = [...labels].sort((a, b) => Number(Boolean(b.self)) - Number(Boolean(a.self)) || (a.rank ?? 0) - (b.rank ?? 0));
|
|
153
|
+
const taken: LabelBox[] = [...(opts.avoid?.() ?? [])];
|
|
154
|
+
const bodies = labels.filter((l) => l.body).map((l) => ({ key: l.key, box: l.body as LabelBox }));
|
|
155
|
+
const out: LabelOut[] = [];
|
|
156
|
+
const seen = new Set<string | number>();
|
|
157
|
+
const k = 1 - Math.exp(-Math.max(0, dt) * fadeRate);
|
|
158
|
+
for (const l of order) {
|
|
159
|
+
seen.add(l.key);
|
|
160
|
+
// Spots: 0 its own (above the body), 1 a row higher, 2 under the body, 3 two rows higher.
|
|
161
|
+
const bottoms = [l.y, l.y - l.h - gap, l.below, l.y - 2 * (l.h + gap)];
|
|
162
|
+
const boxAt = (spot: number): LabelBox | null => {
|
|
163
|
+
const b = bottoms[spot];
|
|
164
|
+
if (b === undefined) return null;
|
|
165
|
+
const half = l.w / 2;
|
|
166
|
+
// Kept on screen sideways; a spot off the top or the bottom is not a spot.
|
|
167
|
+
const cx = Math.max(half + 2, Math.min(sw - half - 2, l.x));
|
|
168
|
+
const box = { left: cx - half, top: b - l.h, right: cx + half, bottom: b };
|
|
169
|
+
return box.top < 0 || box.bottom > sh ? null : box;
|
|
170
|
+
};
|
|
171
|
+
const clear = (b: LabelBox, strict: boolean, m = gap): boolean => !taken.some((t) => hits(b, t, m)) && (!strict || !bodies.some((o) => o.key !== l.key && hits(b, o.box, 0)));
|
|
172
|
+
const st = state.get(l.key) ?? { spot: 0, alpha: l.self ? 1 : 0 };
|
|
173
|
+
let spot = -1; let box: LabelBox | null = null;
|
|
174
|
+
if (l.self) {
|
|
175
|
+
spot = boxAt(0) ? 0 : boxAt(2) ? 2 : 0;
|
|
176
|
+
box = boxAt(spot) ?? { left: l.x - l.w / 2, top: l.y - l.h, right: l.x + l.w / 2, bottom: l.y };
|
|
177
|
+
} else {
|
|
178
|
+
// Home first (back home only with room to spare, once moved), then last frame's spot, then the rest.
|
|
179
|
+
const home = boxAt(0);
|
|
180
|
+
const tries = [st.spot, 1, 2, 3].filter((x, i, all) => x !== 0 && all.indexOf(x) === i);
|
|
181
|
+
for (const strict of [true, false]) {
|
|
182
|
+
if (home && clear(home, strict, st.spot === 0 ? gap : gap + 8)) { spot = 0; box = home; break; }
|
|
183
|
+
for (const x of tries) { const b = boxAt(x); if (b && clear(b, strict)) { spot = x; box = b; break; } }
|
|
184
|
+
if (box) break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (box) {
|
|
188
|
+
st.spot = spot;
|
|
189
|
+
st.alpha = l.self ? 1 : st.alpha + (1 - st.alpha) * k;
|
|
190
|
+
taken.push(l.self ? { left: box.left - selfPad, top: box.top - selfPad, right: box.right + selfPad, bottom: box.bottom + selfPad } : box);
|
|
191
|
+
} else {
|
|
192
|
+
// No room: it fades where it was, or goes at once if it would touch a label that has room.
|
|
193
|
+
const was = boxAt(st.spot);
|
|
194
|
+
st.alpha = !was || taken.some((t) => hits(was, t, 0)) ? 0 : st.alpha * (1 - k);
|
|
195
|
+
if (st.alpha < 0.05) st.alpha = 0;
|
|
196
|
+
}
|
|
197
|
+
state.set(l.key, st);
|
|
198
|
+
const shown = box ?? boxAt(st.spot) ?? boxAt(0) ?? { left: l.x - l.w / 2, top: l.y - l.h, right: l.x + l.w / 2, bottom: l.y };
|
|
199
|
+
out.push({ ...l, ...shown, cx: (shown.left + shown.right) / 2, cy: (shown.top + shown.bottom) / 2, alpha: Math.round(st.alpha * 100) / 100, moved: st.spot !== 0, ...(box ? {} : { fading: true }) });
|
|
200
|
+
}
|
|
201
|
+
// A name fading out never touches a name that has its place, nor another one fading (whatever their order).
|
|
202
|
+
const kept: LabelBox[] = [...taken];
|
|
203
|
+
for (const o of out as (LabelOut & { fading?: boolean })[]) {
|
|
204
|
+
if (!o.fading) continue;
|
|
205
|
+
delete o.fading;
|
|
206
|
+
if (o.alpha > 0 && kept.some((t) => hits(o, t, 0))) { o.alpha = 0; (state.get(o.key) as { alpha: number }).alpha = 0; }
|
|
207
|
+
if (o.alpha > 0) kept.push(o);
|
|
208
|
+
}
|
|
209
|
+
for (const key of [...state.keys()]) if (!seen.has(key)) state.delete(key);
|
|
210
|
+
return out;
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
}
|
package/saves/SAVES.md
CHANGED
|
@@ -23,7 +23,10 @@ The pattern (the `ember-vale` starter does exactly this):
|
|
|
23
23
|
1. When the player arrives, **load** the hero from saves; until it loads, play as a level-1 body.
|
|
24
24
|
2. The host decides what happened (who killed what) and tells **that player's own browser** (a netplay event
|
|
25
25
|
to its seat). Only the hero's own browser changes the hero and **saves** it.
|
|
26
|
-
3. The room carries what the others need to see (my level, so my health bar is right
|
|
26
|
+
3. The room carries what the others need to see (my level, so my health bar is right; my hero's name, so it is
|
|
27
|
+
the name over my head), never the save itself. The name goes to the host as an event, and the host keeps every
|
|
28
|
+
seat's in keyed state (`net.state('heroes', { [seat]: name })`), which a new host and a late joiner already have.
|
|
29
|
+
On a kids server nobody's typed name reaches anyone else: the others keep seeing the room's handles.
|
|
27
30
|
|
|
28
31
|
## Quick start
|
|
29
32
|
|
|
@@ -21,20 +21,25 @@
|
|
|
21
21
|
.panel li { margin: 4px 0; color: #e7d9b4; }
|
|
22
22
|
.panel li small { color: #a39a80; }
|
|
23
23
|
.panel .close { float: right; background: none; border: 0; color: #c9c0a6; font-size: 20px; cursor: pointer; }
|
|
24
|
-
/* Asking a guide: buttons (agents.json "asks"), never a text box.
|
|
24
|
+
/* Asking a guide: buttons (agents.json "asks"), never a text box. On a computer: bottom centre. */
|
|
25
25
|
.asks { left: 50%; transform: translateX(-50%); bottom: calc(14px + env(safe-area-inset-bottom)); max-width: min(560px, calc(100vw - 200px)); box-sizing: border-box; padding: 8px 10px; border-radius: 14px; background: rgba(10,20,18,.84); border: 1px solid rgba(127,216,200,.45); text-align: center; }
|
|
26
26
|
.asks .who { font: 700 12.5px/1.2 inherit; font-family: inherit; color: #7fd8c8; margin: 0 0 6px; }
|
|
27
27
|
.asks .list { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
|
|
28
28
|
.asks button { min-height: 34px; padding: 0 12px; border-radius: 999px; border: 1px solid rgba(127,216,200,.5); background: rgba(127,216,200,.12); color: #e9fff9; font: 600 13px/1 inherit; font-family: inherit; cursor: pointer; }
|
|
29
29
|
.asks button.sent { background: #7fd8c8; color: #0b1512; }
|
|
30
|
-
|
|
30
|
+
/* On a touch screen (body.touch: the touch kit drew STRIKE), either way up: a column right above STRIKE (68 px, 24 px
|
|
31
|
+
up from the bottom, 18 px in), where the thumb that strikes reaches it, clear of the stick and of the vale's
|
|
32
|
+
middle, where your hero is. */
|
|
33
|
+
.touch .asks { left: auto; transform: none; right: calc(12px + env(safe-area-inset-right)); bottom: calc(106px + env(safe-area-inset-bottom)); width: min(232px, calc(100vw - 24px)); max-width: none; max-height: calc(100vh - 166px); overflow: auto; }
|
|
34
|
+
.touch .asks .list { flex-direction: column; flex-wrap: nowrap; }
|
|
35
|
+
.touch .asks button { min-height: 40px; padding: 4px 10px; line-height: 1.15; }
|
|
31
36
|
[hidden] { display: none !important; }
|
|
32
37
|
</style>
|
|
33
38
|
</head>
|
|
34
39
|
<body>
|
|
35
40
|
<canvas id="c"></canvas>
|
|
36
41
|
<div class="ui pill"><button type="button" id="who"></button><button type="button" id="hall-btn">Hall of the Fallen</button></div>
|
|
37
|
-
<div class="ui asks" id="asks" hidden aria-live="polite"><p class="who" id="asks-who"></p><div class="list" id="asks-list"></div></div>
|
|
42
|
+
<div class="ui asks" id="asks" hidden aria-live="polite" data-touch-pass><p class="who" id="asks-who"></p><div class="list" id="asks-list"></div></div>
|
|
38
43
|
<div class="ui panel" id="make" hidden>
|
|
39
44
|
<h2>A new hero</h2>
|
|
40
45
|
<p>Your hero keeps every level and coin, on every device you play on.</p>
|