@homie-rocks/studio 0.24.5 → 0.26.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 +54 -1
- package/README.md +56 -6
- package/assets/animate.ts +521 -0
- package/assets/render-page.ts +77 -4
- package/bin/homie-studio.mjs +13 -4
- package/lib/art-cli.mjs +105 -5
- package/lib/art-tools.mjs +168 -1
- package/lib/asset-check.mjs +72 -5
- package/lib/characters.mjs +380 -0
- package/lib/clips.mjs +417 -0
- package/lib/decisions.mjs +38 -8
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +2 -2
- package/lib/optimise.mjs +79 -3
- package/lib/perf.mjs +15 -7
- package/lib/render3d.mjs +2 -0
- package/lib/rig.mjs +431 -0
- package/lib/scaffold.mjs +7 -0
- package/lib/site.mjs +14 -3
- package/lib/studio.mjs +18 -3
- package/lib/style-board.mjs +3 -1
- package/lib/template-history.json +78 -0
- package/mcp/ui/animation.js +56 -0
- package/mcp/ui/card.css +4 -0
- package/mcp/ui/cast.js +36 -2
- package/mcp/ui/lineup.js +7 -7
- package/netplay/NETPLAY.md +38 -2
- package/netplay/netplay.ts +58 -3
- package/package.json +5 -1
- package/site/SITE.md +19 -1
- package/starters/gem-rush/game.json +2 -1
- package/starters/gem-rush-3d/game.json +2 -1
- package/starters/hero-rush-3d/assets/RIGHTS.md +163 -0
- package/starters/hero-rush-3d/assets/manifest.json +1471 -0
- package/starters/hero-rush-3d/credits.json +40 -0
- package/starters/hero-rush-3d/game.json +12 -0
- package/starters/hero-rush-3d/index.html +20 -0
- package/starters/hero-rush-3d/lab.json +32 -0
- package/starters/hero-rush-3d/src/main.ts +2588 -0
- package/starters/hero-rush-3d/style.json +68 -0
- package/starters/hero-rush-3d/tunables.json +32 -0
- package/worker/arrival.mjs +207 -0
- package/worker/pages.mjs +23 -5
- package/worker/site.mjs +26 -4
- package/worker/version.mjs +1 -1
package/lib/site.mjs
CHANGED
|
@@ -419,8 +419,19 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
|
|
|
419
419
|
// The landing's own colours (landing.theme): only colours, and the ink on its own accent worked out like the studio's.
|
|
420
420
|
// Else the game's own palette (games/<id>/style.json, its art-direction decisions): one look from the game to its page.
|
|
421
421
|
const style = !L.theme ? readJson(join(g.dir, 'style.json'))?.palette : null;
|
|
422
|
-
//
|
|
423
|
-
|
|
422
|
+
// 0.26.0: the whole landing in that palette (its paper as the page, its ink as the words, its accents) when the game
|
|
423
|
+
// sets no landing colours or scheme of its own and its ink reads on its paper (4.5:1): a bright game is no longer
|
|
424
|
+
// shown on the studio's dark page. The scheme follows the paper. Anything less readable: its accents only, on the
|
|
425
|
+
// studio's scheme, as before.
|
|
426
|
+
let fromStyle = style && typeof style === 'object' ? { accent: style.accent, glow: style.accent2 } : null;
|
|
427
|
+
let ownScheme = null;
|
|
428
|
+
if (fromStyle && L.scheme === undefined) {
|
|
429
|
+
const paper = luminance(style.bg); const ink = luminance(style.ink);
|
|
430
|
+
if (paper !== null && ink !== null && (Math.max(paper, ink) + 0.05) / (Math.min(paper, ink) + 0.05) >= 4.5) {
|
|
431
|
+
fromStyle = { ...fromStyle, bg: style.bg, fg: style.ink };
|
|
432
|
+
ownScheme = paper > ink ? 'light' : 'dark';
|
|
433
|
+
}
|
|
434
|
+
}
|
|
424
435
|
const raw = L.theme && typeof L.theme === 'object' ? L.theme : fromStyle;
|
|
425
436
|
const theme = raw ? Object.fromEntries(['accent', 'glow', 'bg', 'fg'].filter((k) => COLOR.test(String(raw[k] ?? ''))).map((k) => [k, raw[k]])) : null;
|
|
426
437
|
if (theme?.accent) { const l = luminance(theme.accent); if (l !== null) theme.accentInk = l > 0.4 ? '#0b0b10' : '#ffffff'; }
|
|
@@ -443,7 +454,7 @@ export function landingOf(g, out, { videos = [], songs = [], log = () => {} } =
|
|
|
443
454
|
source: g.share?.source !== false,
|
|
444
455
|
tv: g.screen?.tv !== false && L.tv !== false,
|
|
445
456
|
theme: theme && Object.keys(theme).length ? theme : null,
|
|
446
|
-
...(L.scheme === 'light' || L.scheme === 'dark' ? { scheme: L.scheme } : {}),
|
|
457
|
+
...(L.scheme === 'light' || L.scheme === 'dark' ? { scheme: L.scheme } : ownScheme ? { scheme: ownScheme, schemeFrom: 'style' } : {}),
|
|
447
458
|
};
|
|
448
459
|
}
|
|
449
460
|
|
package/lib/studio.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* current directory, and its games.
|
|
4
4
|
*/
|
|
5
5
|
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
6
|
-
import { dirname, join, resolve, sep } from 'node:path';
|
|
6
|
+
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { httpsPage, licenseOf, pageOfSource, remixAllowed, remixCredit, remixRow } from '../worker/license.mjs';
|
|
9
9
|
|
|
@@ -208,7 +208,10 @@ async function fetchStarterModels(dest, only = null) {
|
|
|
208
208
|
}
|
|
209
209
|
const missing = [];
|
|
210
210
|
let fetched = 0;
|
|
211
|
-
|
|
211
|
+
// A character the starter names with a `rig` recipe goes through the character pipeline (lib/characters.mjs): made
|
|
212
|
+
// phone-sized, its held things picked, its clips baked into its skeleton's clip library. Everything else is copied.
|
|
213
|
+
const rigged = wanted.filter((a) => a.rig && ['character', 'creature'].includes(a.kind));
|
|
214
|
+
for (const a of wanted.filter((x) => !rigged.includes(x))) {
|
|
212
215
|
const item = index.items.find((x) => x.id === a.from.item);
|
|
213
216
|
const f = a.files.find((x) => x.role === 'model');
|
|
214
217
|
if (!item) { missing.push({ asset: a.id, item: a.from.item, why: `the library has no ${a.from.item}` }); continue; }
|
|
@@ -222,7 +225,19 @@ async function fetchStarterModels(dest, only = null) {
|
|
|
222
225
|
} catch (error) { missing.push({ asset: a.id, item: a.from.item, why: error.message }); }
|
|
223
226
|
}
|
|
224
227
|
writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
225
|
-
|
|
228
|
+
let baked = 0;
|
|
229
|
+
if (rigged.length) {
|
|
230
|
+
const { addCharacter } = await import('./characters.mjs');
|
|
231
|
+
const root = join(dest, '..', '..');
|
|
232
|
+
const game = basename(dest);
|
|
233
|
+
for (const a of rigged) {
|
|
234
|
+
try {
|
|
235
|
+
await addCharacter(root, game, { item: a.from.item, as: a.id, kind: a.kind, card: a.card ?? null, height: a.rig.heightM ?? null, keep: a.rig.keep ?? null, verbs: a.rig.verbs ?? [], lib, index });
|
|
236
|
+
fetched += 1; baked += 1;
|
|
237
|
+
} catch (error) { missing.push({ asset: a.id, item: a.from.item, why: error.message }); }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return { fetched, missing, from: lib.base, ...(baked ? { characters: baked } : {}) };
|
|
226
241
|
}
|
|
227
242
|
|
|
228
243
|
/**
|
package/lib/style-board.mjs
CHANGED
|
@@ -170,7 +170,9 @@ export async function assetsLineup(root, id, { log = () => {} } = {}) {
|
|
|
170
170
|
// `inGame`: what the game's code does to it at runtime (drawn at another height, repainted from style.json): the
|
|
171
171
|
// lineup draws it the same way (the repaint as the board's re-tint, the nearest the game's own swap can be shown).
|
|
172
172
|
const scale = a.inGame?.heightM && a.measured?.heightM ? a.inGame.heightM / a.measured.heightM : null;
|
|
173
|
-
|
|
173
|
+
// A character stands in its idle pose from its skeleton's clip library (not its bind pose: arms out in a T).
|
|
174
|
+
const anims = a.rig?.anims && existsSync(join(root, 'games', id, a.rig.anims)) ? readFileSync(join(root, 'games', id, a.rig.anims)).toString('base64') : null;
|
|
175
|
+
models.push({ a, input: modelIn(a.id, readFileSync(abs), { label: a.card ? String(a.card).split('/').pop() : a.id, ...(scale ? { scale } : {}), ...(anims ? { anims, pose: 'idle', poseAt: 0.3 } : {}), ...(a.inGame?.tint ? { tint: lightColours({ sky: a.inGame.tint, ground: a.inGame.tint }, tokens.palette).sky } : a.inGame?.repaint ? { retint: true } : Number(a.inGame?.pull) > 0 ? { pull: Number(a.inGame.pull) } : {}) }), abs });
|
|
174
176
|
}
|
|
175
177
|
if (!models.length) return { ok: false, command: 'assets lineup', id, why: `games/${id} has no recorded models yet (assets add, or the models skill)` };
|
|
176
178
|
const dir = join(root, '.studio', 'art', id);
|
|
@@ -1195,6 +1195,84 @@
|
|
|
1195
1195
|
}
|
|
1196
1196
|
}
|
|
1197
1197
|
}
|
|
1198
|
+
},
|
|
1199
|
+
"0.25.0": {
|
|
1200
|
+
"files": {
|
|
1201
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1202
|
+
"README.md": "684b3cca8001673c",
|
|
1203
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
1204
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1205
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1206
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1207
|
+
"posts/README.md": "37841aae476bd540",
|
|
1208
|
+
"site/README.md": "ca131cd0472464fb",
|
|
1209
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
1210
|
+
},
|
|
1211
|
+
"sections": {
|
|
1212
|
+
"AGENTS.md": {
|
|
1213
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1214
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1215
|
+
"## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
|
|
1216
|
+
"## The site": "ab922bb6bde23293",
|
|
1217
|
+
"## Making games": "abf2f0d59341512d",
|
|
1218
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1219
|
+
"## Art direction and models": "aaea65236db52126",
|
|
1220
|
+
"## Running live games (the back office)": "4aa5f513a7cd2c3b",
|
|
1221
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1222
|
+
"## Selling things (the shop)": "b04b0a2b25f524f2",
|
|
1223
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1224
|
+
"## Rules": "7e8458adc1b81ece",
|
|
1225
|
+
"## Beta": "9d02638207a26e73"
|
|
1226
|
+
}
|
|
1227
|
+
},
|
|
1228
|
+
"named": {
|
|
1229
|
+
"Homie Arcade": {
|
|
1230
|
+
"sections": {
|
|
1231
|
+
"AGENTS.md": {
|
|
1232
|
+
"## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
},
|
|
1238
|
+
"0.26.0": {
|
|
1239
|
+
"files": {
|
|
1240
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1241
|
+
"README.md": "684b3cca8001673c",
|
|
1242
|
+
"HANDOFF.md": "ce7a8fd91c6bbee2",
|
|
1243
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1244
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1245
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1246
|
+
"posts/README.md": "37841aae476bd540",
|
|
1247
|
+
"site/README.md": "ca131cd0472464fb",
|
|
1248
|
+
"site/src/worker.mjs": "ca3f1630f2420ad3"
|
|
1249
|
+
},
|
|
1250
|
+
"sections": {
|
|
1251
|
+
"AGENTS.md": {
|
|
1252
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1253
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1254
|
+
"## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
|
|
1255
|
+
"## The site": "ab922bb6bde23293",
|
|
1256
|
+
"## Making games": "abf2f0d59341512d",
|
|
1257
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1258
|
+
"## Art direction and models": "aaea65236db52126",
|
|
1259
|
+
"## Running live games (the back office)": "4aa5f513a7cd2c3b",
|
|
1260
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1261
|
+
"## Selling things (the shop)": "b04b0a2b25f524f2",
|
|
1262
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1263
|
+
"## Rules": "7e8458adc1b81ece",
|
|
1264
|
+
"## Beta": "9d02638207a26e73"
|
|
1265
|
+
}
|
|
1266
|
+
},
|
|
1267
|
+
"named": {
|
|
1268
|
+
"Homie Arcade": {
|
|
1269
|
+
"sections": {
|
|
1270
|
+
"AGENTS.md": {
|
|
1271
|
+
"## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1198
1276
|
}
|
|
1199
1277
|
}
|
|
1200
1278
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/* The animation card: each character's clips against the verbs the game needs, every one a small looping preview drawn
|
|
2
|
+
* by the game engine under the game's light (anim_preview), where it came from (its own, or the CC0 library's clips
|
|
3
|
+
* retargeted onto its skeleton), the verbs it lacks with Add (anim_add: free, baked into its skeleton's clip library),
|
|
4
|
+
* and Feel: the Game Lab on the move that plays it (game_lab with its take), New beside Today. */
|
|
5
|
+
(function () {
|
|
6
|
+
var C = Card;
|
|
7
|
+
var state = { busy: null };
|
|
8
|
+
function tile(r, c, sc) {
|
|
9
|
+
var t = C.el('div', 'clip' + (c.have ? '' : ' missing'));
|
|
10
|
+
var art = C.el('div', 'art');
|
|
11
|
+
var src = r.previews && r.previews[c.verb];
|
|
12
|
+
var i = src ? C.img(src, r.id + ' ' + c.verb) : null;
|
|
13
|
+
if (i) art.appendChild(i); else art.appendChild(C.el('span', '', c.have ? '…' : '+'));
|
|
14
|
+
t.appendChild(art);
|
|
15
|
+
t.appendChild(C.el('b', '', c.verb + (c.loop ? ' ↻' : '')));
|
|
16
|
+
t.appendChild(C.el('span', '', c.have ? (c.source ? c.source + (c.retargeted ? ' · retargeted' : '') : 'its own') : 'missing'));
|
|
17
|
+
if (!c.have) t.appendChild(C.btn(state.busy === r.id + c.verb ? 'Adding…' : 'Add (free)', 'small', function () {
|
|
18
|
+
state.busy = r.id + c.verb; C.render();
|
|
19
|
+
C.call('anim_add', { game: sc.game, asset: r.id, verbs: [c.verb] }).then(function (res) { state.busy = null; C.tell('clip:' + r.id + c.verb, 'The person added the ' + c.verb + ' clip to ' + r.id + ' on the animation card (free, retargeted onto its skeleton).'); C.take(res); }).catch(function () { state.busy = null; C.render(); });
|
|
20
|
+
}));
|
|
21
|
+
var take = sc.feel && sc.feel[c.verb];
|
|
22
|
+
if (c.have && take) t.appendChild(C.btn('Feel', 'ghost small', function () {
|
|
23
|
+
state.busy = 'Opening the Game Lab on “' + take + '”…'; C.render();
|
|
24
|
+
C.tell('feel:' + c.verb, 'The person pressed Feel on ' + r.id + '\'s ' + c.verb + ' clip: the Game Lab opens on the "' + take + '" take, New beside Today; its Motion sliders (fade, actSpeed, jumpStretch, landSquash, lean, ...) tune how it feels.');
|
|
25
|
+
C.call('game_lab', { game: sc.game, take: take }).then(function (res) { state.busy = null; C.take(res); }).catch(function () { state.busy = 'The Game Lab did not start.'; C.render(); });
|
|
26
|
+
}));
|
|
27
|
+
return t;
|
|
28
|
+
}
|
|
29
|
+
function render(sc) {
|
|
30
|
+
var card = C.el('main', 'card');
|
|
31
|
+
var rows = sc.rows || [];
|
|
32
|
+
var missing = rows.reduce(function (n, r) { return n + (r.missing || []).length; }, 0);
|
|
33
|
+
C.add(card, C.top('Clips', C.add(C.el('div', 'actions'), C.pill(missing ? missing + ' missing' : 'All there', missing ? 'warn' : 'ok'), C.fullscreenButton())),
|
|
34
|
+
C.el('h1', '', 'How the characters of ' + sc.game + ' move'),
|
|
35
|
+
C.el('p', 'sub', 'The game needs ' + (sc.verbs || []).join(', ') + '. Clips are baked into one library per skeleton at build time; every character on that skeleton shares it.'));
|
|
36
|
+
if (state.busy && !/^[a-z0-9-]+[a-z]+$/.test(state.busy)) card.appendChild(C.el('p', 'note', state.busy));
|
|
37
|
+
rows.forEach(function (r) {
|
|
38
|
+
var sec = C.el('section', 'chars');
|
|
39
|
+
sec.appendChild(C.add(C.el('div', 'dirhead'), C.el('b', '', r.id), C.el('span', '', r.familyLabel + ' · ' + r.bones + ' bones' + (r.animsKB ? ' · clips ' + r.animsKB + ' KB' : '') + ' · ' + r.route)));
|
|
40
|
+
if (r.shown) {
|
|
41
|
+
var grid = C.el('div', 'clips');
|
|
42
|
+
(r.clips || []).forEach(function (c) { grid.appendChild(tile(r, c, sc)); });
|
|
43
|
+
(r.extra || []).forEach(function (v) { grid.appendChild(tile(r, { verb: v, have: true, loop: false, source: null }, sc)); });
|
|
44
|
+
sec.appendChild(grid);
|
|
45
|
+
if (!r.previewed) sec.appendChild(C.btn(state.busy === 'draw' + r.id ? 'Drawing…' : 'Draw its clips (free)', 'ghost small', function () { state.busy = 'draw' + r.id; C.render(); C.call('anim_preview', { game: sc.game, asset: r.id }).then(function (res) { state.busy = null; C.take(res); }).catch(function () { state.busy = null; C.render(); }); }));
|
|
46
|
+
} else sec.appendChild(C.btn('Show its clips', 'ghost small', function () { C.call('anim_plan', { game: sc.game, asset: r.id }).then(C.take).catch(C.render); }));
|
|
47
|
+
card.appendChild(sec);
|
|
48
|
+
});
|
|
49
|
+
(sc.unrigged || []).forEach(function (u) { card.appendChild(C.el('p', 'fine', u + ' has no rig: a still model (the animate guide says how to rig one).')); });
|
|
50
|
+
var foot = C.el('div', 'foot');
|
|
51
|
+
foot.appendChild(C.el('span', 'when', Object.keys(sc.feel || {}).length ? 'Feel opens the Game Lab on this computer' : 'No Game Lab take yet: the lab guide adds one'));
|
|
52
|
+
card.appendChild(foot);
|
|
53
|
+
return card;
|
|
54
|
+
}
|
|
55
|
+
C.start({ kind: 'animation', label: 'Clips', fullscreen: true, render: render, poll: null });
|
|
56
|
+
})();
|
package/mcp/ui/card.css
CHANGED
|
@@ -88,3 +88,7 @@ input[type=text]{flex:1;min-width:0;font:14px/1.2 var(--f-sans);padding:8px 10px
|
|
|
88
88
|
.budget .track{height:8px;border-radius:4px;background:var(--c-bg3);overflow:hidden}.budget .track i{display:block;height:100%;background:var(--c-ok)}.budget .track i.over{background:var(--c-bad)}
|
|
89
89
|
details summary{cursor:pointer;color:var(--c-fg2);font-size:13px;margin-top:10px}
|
|
90
90
|
@media (max-width:420px){.card{padding:14px 12px 12px;border-radius:var(--r-md)}h1{font-size:18px}.stages{grid-auto-flow:row;grid-auto-columns:auto}.frame{height:520px}}
|
|
91
|
+
.chars{margin:12px 0 0;padding:10px;border-radius:var(--r-md);background:var(--c-bg2)}.chars .dirhead{margin-bottom:8px}
|
|
92
|
+
.clips{display:grid;grid-template-columns:repeat(auto-fill,minmax(104px,1fr));gap:8px}
|
|
93
|
+
.clip{display:grid;gap:3px;align-content:start;padding:6px;border-radius:var(--r-sm);background:var(--c-bg)}.clip .art{aspect-ratio:1;border-radius:var(--r-sm);background:var(--c-bg3);display:grid;place-items:center;overflow:hidden;color:var(--c-fg3);font-size:20px}.clip .art img{width:100%;height:100%;object-fit:cover}
|
|
94
|
+
.clip b{font-size:13px}.clip span{font-size:11.5px;color:var(--c-fg2);overflow-wrap:anywhere}.clip.missing{opacity:.8;box-shadow:inset 0 0 0 1px var(--c-line2)}.clip button{justify-self:start}
|
package/mcp/ui/cast.js
CHANGED
|
@@ -48,12 +48,46 @@
|
|
|
48
48
|
card.appendChild(grid);
|
|
49
49
|
card.appendChild(C.el('p', 'fine', 'Every item is CC0 (free for any use, no credit required), already phone-sized. Adding one copies it into the game: it never loads from anywhere else.'));
|
|
50
50
|
}
|
|
51
|
+
// The characters: the decisions that shape them, then each one made or planned, with its skeleton and clips.
|
|
52
|
+
function characters(card, sc) {
|
|
53
|
+
var d = sc.decisions || {};
|
|
54
|
+
var facts = C.el('ul', 'list decisions');
|
|
55
|
+
[['style.proportions', 'Proportions'], ['style.shape', 'Shape and silhouette'], ['cast.family', 'Library family'], ['rig.skeleton', 'Skeleton'], ['rig.source', 'Rigs from'], ['rig.bones', 'Bones']].forEach(function (k) {
|
|
56
|
+
var v = d[k[0]]; if (!v) return;
|
|
57
|
+
var li = C.el('li', v.state === 'locked' ? 'locked' : '');
|
|
58
|
+
li.appendChild(C.add(C.el('div', 'cb'), C.el('b', '', k[1] + ': ' + v.label), C.el('span', '', v.why || '')));
|
|
59
|
+
li.appendChild(C.pill(v.state, v.state === 'locked' ? 'ok' : ''));
|
|
60
|
+
facts.appendChild(li);
|
|
61
|
+
});
|
|
62
|
+
card.appendChild(facts);
|
|
63
|
+
var chips = C.el('div', 'chips');
|
|
64
|
+
(sc.palette || []).forEach(function (hex) { var s = C.el('span', 'swatch'); s.style.background = hex; s.title = hex; chips.appendChild(s); });
|
|
65
|
+
card.appendChild(C.add(C.el('p', 'fine', 'Palette and silhouette rule: ' + (sc.silhouette || '') + ' '), chips));
|
|
66
|
+
var list = C.el('ul', 'list cast');
|
|
67
|
+
(sc.rows || []).forEach(function (r) {
|
|
68
|
+
var li = C.el('li', r.state === 'made' ? 'done' : 'todo');
|
|
69
|
+
li.appendChild(thumb(r.thumb, r.id));
|
|
70
|
+
li.appendChild(C.add(C.el('div', 'cb'), C.el('b', '', r.id + (r.card ? ' · ' + r.card : '')), C.el('span', '', r.state === 'made' ? r.kind + ' · ' + (r.route === 'generated' ? 'generated (US$' + r.usd + ')' : r.route + (r.source ? ': ' + r.source : '')) + ' · ' + r.family + ' skeleton, ' + r.bones + ' bones · ' + r.tris + ' triangles · ' + r.heightM + ' m' : r.kind + ' · planned, ' + (r.heightM ? r.heightM + ' m' : 'no model yet')), r.verbs && r.verbs.length ? C.el('span', '', 'Clips: ' + r.verbs.join(', ')) : null));
|
|
71
|
+
var side = C.el('div', 'actions');
|
|
72
|
+
if (r.state === 'made') side.appendChild(C.btn('Clips', 'ghost small', function () { C.call('anim_plan', { game: sc.game, asset: r.id }).then(C.take).catch(C.render); }));
|
|
73
|
+
else {
|
|
74
|
+
side.appendChild(C.btn('Find in the library', 'small', function () { C.call('assets_find', { game: sc.game, query: r.id.replace(/-/g, ' '), kind: r.kind }).then(C.take).catch(C.render); }));
|
|
75
|
+
side.appendChild(C.btn('Generate', 'ghost small', function () { C.tell('char:' + r.id, 'The person wants ' + r.id + ' (' + (r.card || r.kind) + ') generated as a rigged character: price it with character_make (a dry run, about US$1.44) and ask before paying.'); busy[r.id] = 'Asked your AI to price it'; C.render(); }));
|
|
76
|
+
}
|
|
77
|
+
if (busy[r.id]) side.appendChild(C.el('span', 'fine', busy[r.id]));
|
|
78
|
+
li.appendChild(side);
|
|
79
|
+
list.appendChild(li);
|
|
80
|
+
});
|
|
81
|
+
card.appendChild(list);
|
|
82
|
+
card.appendChild(C.el('p', 'fine', 'Library characters are free (CC0); a generated one is about US$1.44 on your own fal account, priced and asked for first. Spent so far: US$' + (sc.spend ? sc.spend.used.toFixed(2) : '0.00') + '.'));
|
|
83
|
+
}
|
|
51
84
|
function render(sc) {
|
|
52
85
|
var card = C.el('main', 'card');
|
|
53
|
-
var title = sc.mode === 'find' ? 'Free models for “' + sc.query + '”' : sc.mode === 'added' ? 'Added ' + (sc.added && sc.added.asset) : 'The cast of ' + sc.game;
|
|
54
|
-
C.add(card, C.top('Cast', sc.mode === 'plan' ? C.pill((sc.rows || []).filter(function (r) { return r.state === 'made'; }).length + ' of ' + (sc.rows || []).length + ' made', '') : null), C.el('h1', '', title));
|
|
86
|
+
var title = sc.mode === 'find' ? 'Free models for “' + sc.query + '”' : sc.mode === 'added' ? 'Added ' + (sc.added && sc.added.asset) : sc.mode === 'characters' ? 'The characters of ' + sc.game : 'The cast of ' + sc.game;
|
|
87
|
+
C.add(card, C.top('Cast', sc.mode === 'characters' ? C.pill((sc.rows || []).filter(function (r) { return r.state === 'made'; }).length + ' made', '') : sc.mode === 'plan' ? C.pill((sc.rows || []).filter(function (r) { return r.state === 'made'; }).length + ' of ' + (sc.rows || []).length + ' made', '') : null), C.el('h1', '', title));
|
|
55
88
|
if (sc.library && sc.library.error) card.appendChild(C.el('p', 'note', 'Starter library: ' + sc.library.error));
|
|
56
89
|
if (sc.mode === 'plan') plan(card, sc);
|
|
90
|
+
else if (sc.mode === 'characters') characters(card, sc);
|
|
57
91
|
else if (sc.mode === 'find') find(card, sc);
|
|
58
92
|
else if (sc.added) card.appendChild(C.el('p', 'sub', sc.added.asset + ' is in games/' + sc.game + (sc.added.after ? ': ' + sc.added.after.tris + ' triangles, ' + sc.added.after.kb + ' KB' : '') + '. Licence: ' + (sc.added.license || 'CC0') + '.'));
|
|
59
93
|
return card;
|
package/mcp/ui/lineup.js
CHANGED
|
@@ -37,25 +37,25 @@
|
|
|
37
37
|
var a = { game: sc.game, asset: sc.asset };
|
|
38
38
|
for (var q in (sc.request || {})) a[q] = sc.request[q];
|
|
39
39
|
for (var k in args) a[k] = args[k];
|
|
40
|
-
C.call('asset_make', a).then(function (r) { state.busy = null; C.take(r); }).catch(function () { state.busy = 'That did not reach the studio.'; C.render(); });
|
|
40
|
+
C.call(sc.character ? 'character_make' : 'asset_make', a).then(function (r) { state.busy = null; C.take(r); }).catch(function () { state.busy = 'That did not reach the studio.'; C.render(); });
|
|
41
41
|
};
|
|
42
42
|
if (sc.concept) { var i = C.img(sc.concept, 'The concept for ' + sc.asset); if (i) { i.style.maxHeight = '360px'; i.style.objectFit = 'contain'; card.appendChild(C.add(C.el('div', 'preview'), i)); } }
|
|
43
43
|
if (sc.dryRun) {
|
|
44
|
-
card.appendChild(C.el('p', 'sub', (sc.step === 'mesh' ? 'The 3D model' : 'The concept image') + ' costs about US$' + (sc.price ? sc.price.usd : '?') + (sc.total ? '; the whole prop about US$' + sc.total : '') + ', on your own fal account.'));
|
|
44
|
+
card.appendChild(C.el('p', 'sub', (sc.step === 'mesh' ? (sc.character ? 'The rigged character' : 'The 3D model') : 'The concept image') + ' costs about US$' + (sc.price ? sc.price.usd : '?') + (sc.total ? '; the whole ' + (sc.character ? 'character' : 'prop') + ' about US$' + sc.total : '') + ', on your own fal account.'));
|
|
45
45
|
card.appendChild(C.add(C.el('div', 'actions'), C.btn('Make it (US$' + (sc.price ? sc.price.usd : '?') + ')', '', function () { C.tell('approve:' + sc.asset + sc.step, 'The person approved US$' + (sc.price && sc.price.usd) + ' for the ' + sc.step + ' of ' + sc.asset + ' on the card.'); run({ step: sc.step, approve: true }, 'Making it…'); })));
|
|
46
46
|
} else if (sc.needs === 'approval' || sc.why) card.appendChild(C.el('p', 'note', sc.why || 'Needs the person\'s yes.'));
|
|
47
47
|
else if ((sc.step === 'concept' || sc.step === 'look') && sc.concept) {
|
|
48
|
-
card.appendChild(C.el('p', 'sub', 'Look at it: the thing, whole, in the game\'s style, on a plain background? Then the 3D model (about US$' + (sc.then ? sc.then.usd : '0.50') + ').'));
|
|
49
|
-
card.appendChild(C.add(C.el('div', 'actions'), C.btn('Make the 3D model', '', function () { C.tell('mesh:' + sc.asset, 'The person approved the 3D model of ' + sc.asset + ' (about US$' + (sc.then ? sc.then.usd : '0.50') + ') on the card.'); run({ step: 'mesh', approve: true }, 'Making the model (1 to 3 minutes)…'); })));
|
|
48
|
+
card.appendChild(C.el('p', 'sub', sc.character ? 'Look at it: one character, whole, in an A-pose, in the game\'s style, on a plain background? Then the rigged 3D character (about US$' + (sc.then ? sc.then.usd : '1.40') + '); its clips come from the free library.' : 'Look at it: the thing, whole, in the game\'s style, on a plain background? Then the 3D model (about US$' + (sc.then ? sc.then.usd : '0.50') + ').'));
|
|
49
|
+
card.appendChild(C.add(C.el('div', 'actions'), C.btn(sc.character ? 'Make the rigged character' : 'Make the 3D model', '', function () { C.tell('mesh:' + sc.asset, 'The person approved the ' + (sc.character ? 'rigged character' : '3D model') + ' of ' + sc.asset + ' (about US$' + (sc.then ? sc.then.usd : sc.character ? '1.40' : '0.50') + ') on the card.'); run({ step: 'mesh', approve: true }, sc.character ? 'Making the rigged character (2 to 5 minutes)…' : 'Making the model (1 to 3 minutes)…'); })));
|
|
50
50
|
} else if (sc.step === 'mesh' && sc.after) {
|
|
51
|
-
card.appendChild(C.el('p', 'note ok', sc.asset + ': ' + sc.before.tris + ' → ' + sc.after.tris + ' triangles, ' + sc.after.kb + ' KB, ' + sc.after.heightM + ' m. Recorded with its receipts and licence.'));
|
|
52
|
-
card.appendChild(C.add(C.el('div', 'actions'), C.btn('Show the lineup', 'ghost', function () { state.busy = 'Drawing the lineup…'; C.render(); C.call('asset_lineup', { game: sc.game }).then(function (r) { state.busy = null; C.take(r); }); })));
|
|
51
|
+
card.appendChild(C.el('p', 'note ok', sc.asset + ': ' + sc.before.tris + ' → ' + sc.after.tris + ' triangles, ' + sc.after.kb + ' KB, ' + sc.after.heightM + ' m. Recorded with its receipts and licence.' + (sc.character && sc.verbs && sc.verbs.length ? ' Clips (the library\'s, retargeted, free): ' + sc.verbs.join(', ') + '.' : '')));
|
|
52
|
+
card.appendChild(C.add(C.el('div', 'actions'), C.btn('Show the lineup', 'ghost', function () { state.busy = 'Drawing the lineup…'; C.render(); C.call('asset_lineup', { game: sc.game }).then(function (r) { state.busy = null; C.take(r); }); }), sc.character ? C.btn('Show its clips', 'ghost', function () { state.busy = 'Drawing its clips…'; C.render(); C.call('anim_preview', { game: sc.game, asset: sc.asset }).then(function (r) { state.busy = null; C.take(r); }); }) : null));
|
|
53
53
|
}
|
|
54
54
|
}
|
|
55
55
|
function render(sc) {
|
|
56
56
|
var card = C.el('main', 'card');
|
|
57
57
|
var flagged = sc.mode === 'lineup' ? sc.flagged : null;
|
|
58
|
-
C.add(card, C.top(sc.mode === 'make' ? 'Make a prop' : 'Lineup', C.add(C.el('div', 'actions'), flagged !== null && flagged !== undefined ? C.pill(flagged ? flagged + ' flagged' : 'One look', flagged ? 'warn' : 'ok') : null, C.fullscreenButton())),
|
|
58
|
+
C.add(card, C.top(sc.mode === 'make' ? (sc.character ? 'Make a character' : 'Make a prop') : 'Lineup', C.add(C.el('div', 'actions'), flagged !== null && flagged !== undefined ? C.pill(flagged ? flagged + ' flagged' : 'One look', flagged ? 'warn' : 'ok') : null, C.fullscreenButton())),
|
|
59
59
|
C.el('h1', '', sc.mode === 'make' ? sc.asset : 'The cast of ' + sc.game + ', at true scale'));
|
|
60
60
|
if (state.busy) card.appendChild(C.el('p', 'note', state.busy));
|
|
61
61
|
if (sc.mode === 'make') make(card, sc); else lineup(card, sc);
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -583,13 +583,16 @@ Measured, with Gem Rush's bump (owner movement, 30–50 ms one way):
|
|
|
583
583
|
overrides). `?screen=1` makes a spectator that shows a QR code to the game URL.
|
|
584
584
|
`/<game>/watch?room=` makes a watcher, with a strip of the players (section 16).
|
|
585
585
|
8. **Show room facts** from the watch feed or the frame's messages: the lobby line,
|
|
586
|
-
results, and with `?debug=1` the debug strip.
|
|
586
|
+
results, and with `?debug=1` the debug strip. Until the game is playable, show them on
|
|
587
|
+
the arrival card, in the game's own look (section 21), never a blank screen.
|
|
587
588
|
9. **One matcher:** every Play door for a game lands in the same rooms (the `Lobby`).
|
|
588
589
|
|
|
589
590
|
### The game (a vendored `netplay.ts`)
|
|
590
591
|
|
|
591
592
|
1. **Call `createNetplay({ game, maxPlayers, movement, checkpoint })` once** and render
|
|
592
|
-
from the first frame. Never wait for `ready`.
|
|
593
|
+
from the first frame. Never wait for `ready`. A game that loads for seconds after its
|
|
594
|
+
first state passes `arrival: 'game'` and calls `net.playable()` once its world and the
|
|
595
|
+
player's own body are drawn (section 21): the play page's arrival card covers the wait.
|
|
593
596
|
2. **Handle `role` at any time.**
|
|
594
597
|
- Promoted: restore (section 9).
|
|
595
598
|
- Host but not promoted: start a fresh round.
|
|
@@ -1422,3 +1425,36 @@ account or typed text.
|
|
|
1422
1425
|
the host asks how the slimes hunt (chase, surround, gang up on the most hurt, regroup at the King),
|
|
1423
1426
|
whether a wave comes, and how hard to push; every screen shows what the slimes are up to when a model
|
|
1424
1427
|
chose it. The floor is the vale as it always played.
|
|
1428
|
+
|
|
1429
|
+
## 21. The arrival (0.26.0)
|
|
1430
|
+
|
|
1431
|
+
The play page and the big screen never open on a blank screen. From the page's first paint until
|
|
1432
|
+
the game says it is playable, an **arrival card** covers the game's frame in the game's own look:
|
|
1433
|
+
its title and pitch, its key art (the landing's hero still, a trailer's poster or its cover,
|
|
1434
|
+
drifting slowly), a progress line that says what is happening ("Finding a room…", "Room 4 · 3
|
|
1435
|
+
playing · 2 AI", "Loading the game…", "Joining Room 4…", the game's own "Loading the heroes…
|
|
1436
|
+
60%") and the controls for this device (game.json `landing.controls`). Then it fades and the
|
|
1437
|
+
game has the screen. Its colours are the game's palette (style.json), else its landing's, else
|
|
1438
|
+
the studio's (`worker/arrival.mjs`). Nothing here crosses the relay: it is between the game's frame
|
|
1439
|
+
and its page, so the wire stays revision 8.
|
|
1440
|
+
|
|
1441
|
+
| From the helper to its page | When |
|
|
1442
|
+
|---|---|
|
|
1443
|
+
| `{ what: 'attached', …, arrival: 'auto' \| 'game' }` | the helper starts (a helper older than 0.26.0 sends no `arrival`) |
|
|
1444
|
+
| `{ what: 'loading', p, label? }` | the game's `net.loading(p, label)`: 0 to 1, at most ten a second, never after `playable` |
|
|
1445
|
+
| `{ what: 'playable', by: 'game' \| 'auto' }` | once: the game's `net.playable()`, or (`arrival: 'auto'`) the helper's own |
|
|
1446
|
+
|
|
1447
|
+
- **`arrival: 'auto'`** (the default, so every game gets the card with no code): the helper says
|
|
1448
|
+
`playable` once this browser has its role and the room's state (a host at once, anyone else at
|
|
1449
|
+
its first snapshot), two animation frames later, so the game has drawn with it.
|
|
1450
|
+
- **`arrival: 'game'`**: the game says when, with `net.playable()` once its world and the player's
|
|
1451
|
+
own body are drawn (Hero Rush 3D: its own hero with its real model, not the stand-in), and
|
|
1452
|
+
`net.loading(p, 'the heroes')` while it loads. A game whose own loading takes seconds after the
|
|
1453
|
+
first state (models, textures, a baked terrain) uses this, so the player never sees stand-ins.
|
|
1454
|
+
- **The page's fallbacks**, so a game is never hidden behind the card: a helper older than 0.26.0
|
|
1455
|
+
lifts it 0.6 s after the browser has a seat; a frame with no helper, 8 s after it loaded; any game,
|
|
1456
|
+
15 s after its helper attached; and 30 s after the page opened, whatever happens. A kick or a
|
|
1457
|
+
closed room lifts it for its notice. `?arrive=0` leaves it out.
|
|
1458
|
+
- **Measured** by `homie-studio perf` (`load.look`: the card's first frame, the first meaningful
|
|
1459
|
+
frame; `load.playable`: seated, the body drawn and the card lifted), from the page's own marks
|
|
1460
|
+
`homie:look` and `homie:playable` and `window.__shell.arrival`.
|
package/netplay/netplay.ts
CHANGED
|
@@ -50,6 +50,11 @@
|
|
|
50
50
|
* them), messages taken down (`on('unchat')`), and the game's own chat UI
|
|
51
51
|
* (`net.say(text)`, `net.sayLine(id)`, `net.react(kind)`); the room's rules
|
|
52
52
|
* are `net.chatRules`. The play page's chat panel needs none of it.
|
|
53
|
+
* - The arrival (0.26.0, NETPLAY.md section 21): tells its page when the game
|
|
54
|
+
* is playable, so the play page's arrival card (the game's title, art and a
|
|
55
|
+
* progress line) gives it the screen: by itself once seated with the room's
|
|
56
|
+
* state, or the game's own `net.playable()` (`arrival: 'game'`), with
|
|
57
|
+
* `net.loading(p, what)` while it loads. Nothing crosses the relay.
|
|
53
58
|
*
|
|
54
59
|
* WHAT IT DOES NOT DO: rendering, physics, input devices, bots. `Roster` below
|
|
55
60
|
* is the bot-yield bookkeeping a host needs; the bots themselves are the game's.
|
|
@@ -451,6 +456,14 @@ export interface NetplayOptions<C = unknown> {
|
|
|
451
456
|
* moves an AI's body with its own bot code (a Roster that passes `p.agent`). Reading the dial declares 'skill' too.
|
|
452
457
|
*/
|
|
453
458
|
caps?: ('skill' | 'agents')[];
|
|
459
|
+
/**
|
|
460
|
+
* THE ARRIVAL (NETPLAY.md section 21): who says when the play page's arrival card (the game's title, art and a
|
|
461
|
+
* progress line while the room connects and the game loads) gives the screen to the game. 'auto' (the default): this
|
|
462
|
+
* helper, once this browser has its role and the room's state (a host at once, anyone else at its first snapshot),
|
|
463
|
+
* two animation frames later. 'game': the game itself, with `net.playable()` once its world and the player's own body
|
|
464
|
+
* are drawn (the page lifts the card anyway 12 s after the helper attached, so a game that never says is never hidden).
|
|
465
|
+
*/
|
|
466
|
+
arrival?: 'auto' | 'game';
|
|
454
467
|
}
|
|
455
468
|
|
|
456
469
|
type WebSocketCtor = new (url: string) => WebSocketLike;
|
|
@@ -609,6 +622,17 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
|
609
622
|
hushed: boolean;
|
|
610
623
|
/** This browser plays as an AI (the frame of an agent pass). */
|
|
611
624
|
readonly asAgent: boolean;
|
|
625
|
+
/**
|
|
626
|
+
* THE ARRIVAL (section 21): the world and the player's own body are drawn, so the play page's arrival card gives the
|
|
627
|
+
* screen to the game. Once; later calls do nothing. Needed only with `arrival: 'game'` (an 'auto' game may call it
|
|
628
|
+
* earlier than the helper would).
|
|
629
|
+
*/
|
|
630
|
+
playable(): void;
|
|
631
|
+
/**
|
|
632
|
+
* While it loads: how far along the game is (0 to 1) and what it is loading ("the heroes"), for the arrival card's
|
|
633
|
+
* progress line. At most ten a second reach the page; nothing after `playable()`.
|
|
634
|
+
*/
|
|
635
|
+
loading(fraction: number, what?: string): void;
|
|
612
636
|
/** Resolves with the first role (welcome, or offline fallback). */
|
|
613
637
|
readonly ready: Promise<RoleChange<S, C>>;
|
|
614
638
|
on<K extends keyof NetHandlers<S, A, C>>(kind: K, fn: NetHandlers<S, A, C>[K]): () => void;
|
|
@@ -1110,6 +1134,26 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1110
1134
|
let idleSkipped = 0;
|
|
1111
1135
|
/** Why this client stopped for good (a FINAL_ERRORS refusal), or null while it plays or reconnects. */
|
|
1112
1136
|
let closedWhy: string | null = null;
|
|
1137
|
+
// The arrival (section 21): the play page's card lifts at `playable` (the game's word, or this helper's).
|
|
1138
|
+
const arrival: 'auto' | 'game' = opts.arrival === 'game' ? 'game' : 'auto';
|
|
1139
|
+
let playableSent = false;
|
|
1140
|
+
let autoArmed = false;
|
|
1141
|
+
let snapSeen = false;
|
|
1142
|
+
let loadingAt = 0;
|
|
1143
|
+
function sayPlayable(by: 'game' | 'auto'): void {
|
|
1144
|
+
if (playableSent) return;
|
|
1145
|
+
playableSent = true;
|
|
1146
|
+
post?.({ what: 'playable', by });
|
|
1147
|
+
}
|
|
1148
|
+
/** 'auto': a host at once, anyone else at its first snapshot, then two animation frames (the game drew with it). */
|
|
1149
|
+
function autoPlayable(): void {
|
|
1150
|
+
if (arrival !== 'auto' || playableSent || autoArmed || !roleKnown) return;
|
|
1151
|
+
if (role !== 'host' && !snapSeen) return;
|
|
1152
|
+
autoArmed = true;
|
|
1153
|
+
const raf = (globalThis as { requestAnimationFrame?: (cb: () => void) => number }).requestAnimationFrame;
|
|
1154
|
+
const later = (fn: () => void): void => { if (typeof raf === 'function') raf(fn); else setTimeout(fn, 16); };
|
|
1155
|
+
later(() => later(() => sayPlayable('auto')));
|
|
1156
|
+
}
|
|
1113
1157
|
/** The studio's announcement now showing, and the seats the studio muted (until when, server ms). */
|
|
1114
1158
|
let announcement: Announcement | null = null;
|
|
1115
1159
|
const muted = new Map<number, number>();
|
|
@@ -1354,6 +1398,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1354
1398
|
emit('role', e);
|
|
1355
1399
|
resolveView('seat');
|
|
1356
1400
|
notifyStats(true);
|
|
1401
|
+
autoPlayable();
|
|
1357
1402
|
}
|
|
1358
1403
|
|
|
1359
1404
|
/** The newest snapshot this browser holds, if recent: a promoted host's restore when the relay has none. */
|
|
@@ -1584,7 +1629,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1584
1629
|
replaceState(m['state']);
|
|
1585
1630
|
}
|
|
1586
1631
|
rebase = true;
|
|
1587
|
-
|
|
1632
|
+
// The relay's last snapshot comes with the welcome: the room's state is in (the arrival, section 21).
|
|
1633
|
+
if (snap && next !== 'host' && acceptSnap(snap, JSON.stringify(snap).length, false)) snapSeen = true;
|
|
1588
1634
|
if (next === 'host' && !snap && !continuing) snap = localSnap();
|
|
1589
1635
|
offline = false;
|
|
1590
1636
|
connected = true;
|
|
@@ -1685,7 +1731,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1685
1731
|
if (role === 'host') return; // a stale frame from a previous host
|
|
1686
1732
|
const s: Snapshot<S> = { k: Number(m['k']) || 0, st: Number(m['st']), d: m['d'] as S, from: typeof m['from'] === 'number' ? m['from'] : null };
|
|
1687
1733
|
if (Array.isArray(m['c'])) s.c = m['c'] as ControlWire[];
|
|
1688
|
-
if (acceptSnap(s, bytes, true)) emit('snapshot', s);
|
|
1734
|
+
if (acceptSnap(s, bytes, true)) { emit('snapshot', s); if (!snapSeen) { snapSeen = true; autoPlayable(); } }
|
|
1689
1735
|
}
|
|
1690
1736
|
|
|
1691
1737
|
function onInput(m: Record<string, unknown>): void {
|
|
@@ -1877,7 +1923,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1877
1923
|
// No shell (a plain file, a dev server): the game is its own host, offline.
|
|
1878
1924
|
queueMicrotask(() => goOfflineHost('offline'));
|
|
1879
1925
|
} else {
|
|
1880
|
-
post?.({ what: 'attached', v: NETPLAY_VERSION, rev: NETPLAY_REVISION, mark: NETPLAY_MARK });
|
|
1926
|
+
post?.({ what: 'attached', v: NETPLAY_VERSION, rev: NETPLAY_REVISION, mark: NETPLAY_MARK, arrival });
|
|
1881
1927
|
open();
|
|
1882
1928
|
// A relay that never answers must not leave a game on a black screen. But the wait is counted only while
|
|
1883
1929
|
// this page is able to listen: a phone compiling shaders under load is blocked for seconds at a time, and
|
|
@@ -2050,6 +2096,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2050
2096
|
get announcement() { return announcement && (announcement.until === undefined || announcement.until > now()) ? announcement : null; },
|
|
2051
2097
|
isMuted(s: number | null): boolean { return s !== null && (muted.get(s) ?? 0) > now(); },
|
|
2052
2098
|
pickPlayer(s: number | null): void { post?.({ what: 'pick', seat: typeof s === 'number' ? s : null }); },
|
|
2099
|
+
playable(): void { sayPlayable('game'); },
|
|
2100
|
+
loading(fraction: number, what?: string): void {
|
|
2101
|
+
if (playableSent) return;
|
|
2102
|
+
const t = wall();
|
|
2103
|
+
const p = Math.max(0, Math.min(1, Number(fraction) || 0));
|
|
2104
|
+
if (t - loadingAt < 100 && p < 1) return;
|
|
2105
|
+
loadingAt = t;
|
|
2106
|
+
post?.({ what: 'loading', p: Math.round(p * 100) / 100, ...(typeof what === 'string' && what ? { label: what.slice(0, 40) } : {}) });
|
|
2107
|
+
},
|
|
2053
2108
|
get watching() { return watching; },
|
|
2054
2109
|
get viewSeat() { markFollows(); return watching ? viewNow : (offline ? null : seat); },
|
|
2055
2110
|
get following() { return watching ? following : seat; },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
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",
|
|
@@ -46,6 +46,10 @@
|
|
|
46
46
|
"types": "./assets/assets.ts",
|
|
47
47
|
"default": "./assets/assets.ts"
|
|
48
48
|
},
|
|
49
|
+
"./animate": {
|
|
50
|
+
"types": "./assets/animate.ts",
|
|
51
|
+
"default": "./assets/animate.ts"
|
|
52
|
+
},
|
|
49
53
|
"./worker": "./worker/index.mjs",
|
|
50
54
|
"./package.json": "./package.json"
|
|
51
55
|
},
|
package/site/SITE.md
CHANGED
|
@@ -47,7 +47,13 @@ site's pages only); the owner's tools are `/_studio/api/chat`, `/_studio/api/cha
|
|
|
47
47
|
Made from the game's own files, nothing invented:
|
|
48
48
|
|
|
49
49
|
- **The hero**, full-bleed: the game's footage when it has some, else its cover (or key art) moving slowly, tinted
|
|
50
|
-
with the studio's colour so the words read.
|
|
50
|
+
with the studio's colour so the words read. **In the game's own palette (0.26.0):** a game with a style.json (its
|
|
51
|
+
art direction) and no `landing.scheme` or `landing.theme` of its own gets its whole landing in that palette, its
|
|
52
|
+
paper as the page, its ink as the words, its accents, light or dark as its paper is, when the ink reads on the paper
|
|
53
|
+
(4.5:1); its footage then keeps its colour (a third of the tint) and the words sit on a soft panel of its paper. A
|
|
54
|
+
bright game is no longer shown on the studio's dark page. `landing.scheme`, `landing.theme`, a `site/pages/<id>/`
|
|
55
|
+
page and `site/theme.css` are the owner's and always win; a palette whose ink does not read keeps its accents only,
|
|
56
|
+
on the studio's scheme, as before. A game whose picture is white or cream (a light arena) takes
|
|
51
57
|
`"scheme": "light"` in its `landing` block: its landing is drawn light, the hero tinted and shaded with a light
|
|
52
58
|
background and the words dark, where a dark studio's tint would turn it grey (`"dark"` is the other way round,
|
|
53
59
|
for a dark game in a light studio). `landing.theme` colours still win, and the rest of the site keeps its look. Footage is looked for in this order: `landing.hero` in game.json;
|
|
@@ -143,6 +149,18 @@ A studio with its own landing in `site/` keeps it: the template never replaces w
|
|
|
143
149
|
|
|
144
150
|
## The play page
|
|
145
151
|
|
|
152
|
+
**The arrival (0.26.0).** The play page and the big screen never open blank. From the first paint, an arrival
|
|
153
|
+
card covers the game's frame in the game's own look (worker/arrival.mjs): its title, its pitch (`landing.pitch`, else
|
|
154
|
+
the blurb's first sentence), its key art (the landing's hero still, a trailer's poster or its cover, drifting slowly;
|
|
155
|
+
`landing.hero.tallImage` on an upright phone when there is one), a progress line that says what is happening
|
|
156
|
+
("Finding a room…", "Room 4 · 3 playing · 2 AI", "Loading the game…", "Joining Room 4…", and the game's own
|
|
157
|
+
"Loading the heroes… 60%"), and the controls for this device (`landing.controls.phone`, `.computer`, `.tv`). Its
|
|
158
|
+
colours are the game's palette (style.json), else the landing's (`landing.theme`, `landing.scheme`), else the
|
|
159
|
+
studio's. It lifts when the game says it is playable (NETPLAY.md section 21): by itself once seated with the room's
|
|
160
|
+
state, or at the game's own `net.playable()`; a game built before 0.26.0 lifts it once it has a seat, and nothing
|
|
161
|
+
keeps a game behind it longer than 15 s after the game started (30 s after the page opened). `?arrive=0` leaves it
|
|
162
|
+
out. `homie-studio perf` reads its first frame as the time to the first meaningful frame (`load.look`).
|
|
163
|
+
|
|
146
164
|
`/<id>/play` puts the visitor in a public room at once and writes the room into the address, so a reload comes
|
|
147
165
|
back to it and a copied address brings a friend into it. A small room button at the edge (top right by default)
|
|
148
166
|
opens: **Invite** (the phone's share sheet, or the link copied), **Big screen** (`/<id>/tv` of this room, on
|
|
@@ -5,5 +5,6 @@
|
|
|
5
5
|
"players": { "min": 1, "max": 8 },
|
|
6
6
|
"roundSeconds": 60,
|
|
7
7
|
"entry": "src/main.ts",
|
|
8
|
-
"netplay": { "v": 1, "public": true, "movement": "owner" }
|
|
8
|
+
"netplay": { "v": 1, "public": true, "movement": "owner" },
|
|
9
|
+
"landing": { "pitch": "Grab the gems. Bump your rivals away.", "controls": { "phone": "Drag to move; a second finger bumps.", "computer": "WASD or arrows to move, Space to bump." } }
|
|
9
10
|
}
|
|
@@ -7,5 +7,6 @@
|
|
|
7
7
|
"entry": "src/main.ts",
|
|
8
8
|
"netplay": { "v": 1, "public": true, "movement": "owner" },
|
|
9
9
|
"needs": { "three": "0.185.1" },
|
|
10
|
-
"assets": "library"
|
|
10
|
+
"assets": "library",
|
|
11
|
+
"landing": { "pitch": "Grab the gems. Bump your rivals away.", "controls": { "phone": "Drag to move; a second finger bumps.", "computer": "WASD or arrows to move, Space to bump." } }
|
|
11
12
|
}
|