@homie-rocks/studio 0.44.1 → 0.45.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 +41 -0
- package/README.md +20 -22
- package/guides/game/REWRITE.md +20 -2
- package/guides/game/RULES.md +51 -14
- package/guides/game/SKILL.md +12 -8
- package/lib/build.mjs +11 -8
- package/lib/cloudflare.mjs +6 -4
- package/lib/dev.mjs +4 -2
- package/lib/mcp.mjs +1 -1
- package/lib/rules-build.mjs +2 -1
- package/lib/scaffold.mjs +9 -9
- package/lib/template-history.json +97 -0
- package/lib/typecheck.mjs +16 -2
- package/lib/worker-build.mjs +18 -4
- package/netplay/NETPLAY.md +22 -177
- package/netplay/netplay.ts +14 -9
- package/package.json +1 -1
- package/rules/collision.ts +10 -4
- package/rules/core.ts +3 -4
- package/rules/host.ts +2 -0
- package/rules/interest.mjs +106 -21
- package/rules/live.ts +6 -4
- package/rules/map-index.ts +51 -0
- package/rules/math.ts +5 -2
- package/rules/rules.ts +15 -7
- package/rules/terrain.ts +93 -0
- package/starters/coin-dash/game.json +14 -4
- package/starters/ember-vale/game.json +0 -4
- package/starters/ember-vale/src/view.ts +2 -2
- package/starters/gem-rush/game.json +0 -4
- package/starters/gem-rush-3d/game.json +0 -4
- package/starters/hero-rush-3d/game.json +0 -4
- package/starters/hero-rush-3d/src/move.ts +2 -0
- package/worker/gate.mjs +151 -0
- package/worker/index.mjs +21 -11
- package/worker/mcp-room.mjs +0 -1
- package/worker/office.mjs +3 -3
- package/worker/room.mjs +35 -19
- package/worker/seats.mjs +9 -13
- package/worker/servers.mjs +6 -6
- package/worker/version.mjs +1 -1
|
@@ -2428,6 +2428,58 @@
|
|
|
2428
2428
|
}
|
|
2429
2429
|
},
|
|
2430
2430
|
"0.44.1": {
|
|
2431
|
+
"files": {
|
|
2432
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2433
|
+
"README.md": "684b3cca8001673c",
|
|
2434
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2435
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2436
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2437
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2438
|
+
"posts/README.md": "37841aae476bd540",
|
|
2439
|
+
"site/README.md": "732894273a32dc3f",
|
|
2440
|
+
"site/src/worker.mjs": "34444a6ca4b14437"
|
|
2441
|
+
},
|
|
2442
|
+
"sections": {
|
|
2443
|
+
"AGENTS.md": {
|
|
2444
|
+
"(intro)": "214aa46eb3a895d5",
|
|
2445
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
2446
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2447
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
2448
|
+
"## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
|
|
2449
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2450
|
+
"## Making games": [
|
|
2451
|
+
"78950c6b4eb98ea6",
|
|
2452
|
+
"796cc9389236c713",
|
|
2453
|
+
"9e44559de535d842"
|
|
2454
|
+
],
|
|
2455
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2456
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2457
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2458
|
+
"## Servers and AI seats": [
|
|
2459
|
+
"1ccfd549d2369a60",
|
|
2460
|
+
"d0028361fe567add"
|
|
2461
|
+
],
|
|
2462
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2463
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2464
|
+
"## Rules": [
|
|
2465
|
+
"2e6b29afb85dcf7e",
|
|
2466
|
+
"c48971c1437414c2"
|
|
2467
|
+
],
|
|
2468
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2469
|
+
"## Beta": "9d02638207a26e73"
|
|
2470
|
+
}
|
|
2471
|
+
},
|
|
2472
|
+
"named": {
|
|
2473
|
+
"Homie Arcade": {
|
|
2474
|
+
"sections": {
|
|
2475
|
+
"AGENTS.md": {
|
|
2476
|
+
"## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
},
|
|
2482
|
+
"0.44.3": {
|
|
2431
2483
|
"files": {
|
|
2432
2484
|
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2433
2485
|
"README.md": "684b3cca8001673c",
|
|
@@ -2468,6 +2520,51 @@
|
|
|
2468
2520
|
}
|
|
2469
2521
|
}
|
|
2470
2522
|
}
|
|
2523
|
+
},
|
|
2524
|
+
"0.45.0": {
|
|
2525
|
+
"files": {
|
|
2526
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2527
|
+
"README.md": "684b3cca8001673c",
|
|
2528
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2529
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2530
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2531
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2532
|
+
"posts/README.md": "37841aae476bd540",
|
|
2533
|
+
"site/README.md": "732894273a32dc3f",
|
|
2534
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
2535
|
+
},
|
|
2536
|
+
"sections": {
|
|
2537
|
+
"AGENTS.md": {
|
|
2538
|
+
"(intro)": "214aa46eb3a895d5",
|
|
2539
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
2540
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2541
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
2542
|
+
"## Commands (all through the pinned CLI in node_modules)": "159151b8861fc051",
|
|
2543
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2544
|
+
"## Making games": [
|
|
2545
|
+
"78950c6b4eb98ea6",
|
|
2546
|
+
"bb715272fd1a615b"
|
|
2547
|
+
],
|
|
2548
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2549
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2550
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2551
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
2552
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2553
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2554
|
+
"## Rules": "c48971c1437414c2",
|
|
2555
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2556
|
+
"## Beta": "9d02638207a26e73"
|
|
2557
|
+
}
|
|
2558
|
+
},
|
|
2559
|
+
"named": {
|
|
2560
|
+
"Homie Arcade": {
|
|
2561
|
+
"sections": {
|
|
2562
|
+
"AGENTS.md": {
|
|
2563
|
+
"## Commands (all through the pinned CLI in node_modules)": "2cdd530e48170bfd"
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
2471
2568
|
}
|
|
2472
2569
|
}
|
|
2473
2570
|
}
|
package/lib/typecheck.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {listOwnParts, readOrigins, vendorDir} from './parts.mjs';
|
|
2
|
+
import {resolvePartImport} from './parts-build.mjs';
|
|
1
3
|
import { runRulesCompiler } from './rules-compiler.mjs';
|
|
2
4
|
/**
|
|
3
5
|
* `homie-studio build --types` — the games' TypeScript, checked before anything is built.
|
|
@@ -7,7 +9,7 @@ import { runRulesCompiler } from './rules-compiler.mjs';
|
|
|
7
9
|
* of its own to run `npx tsc` with.
|
|
8
10
|
*
|
|
9
11
|
* - The compiler is the STUDIO'S OWN (`typescript` in its node_modules, which a new studio's package.json asks
|
|
10
|
-
* for). This optional check for legacy games keeps using that compiler. Rules games use the toolkit's own
|
|
12
|
+
* for). This optional check for legacy games and local apps keeps using that compiler. Rules games use the toolkit's own
|
|
11
13
|
* pinned compiler through typecheckRules below, on every build.
|
|
12
14
|
* - A game with a tsconfig.json of its own is checked with it. Any other game is checked the way esbuild reads
|
|
13
15
|
* it: its entry and whatever that imports, as ES2022 for a browser, strictly, with the pictures, sounds and
|
|
@@ -15,7 +17,7 @@ import { runRulesCompiler } from './rules-compiler.mjs';
|
|
|
15
17
|
* - Only errors in the game's own files stop the build. A game imports the toolkit's helpers as TypeScript
|
|
16
18
|
* source, and a creator can do nothing about a line in node_modules; those are counted and said, never fatal.
|
|
17
19
|
*
|
|
18
|
-
* The legacy check runs only when asked with --types. The rules check is mandatory.
|
|
20
|
+
* The legacy/app check runs only when asked with --types. The rules check is mandatory.
|
|
19
21
|
*/
|
|
20
22
|
import { spawnSync } from 'node:child_process';
|
|
21
23
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
@@ -116,8 +118,17 @@ export async function typecheckRules(root, g, checked, tune, { log = () => {} }
|
|
|
116
118
|
writeFileSync(join(dir, 'rules.d.ts'), rulesTypes({ ...checked, answerScopes: await rulesAnswerScopes(join(g.dir, 'src/rules.ts')) }, tune));
|
|
117
119
|
writeFileSync(join(dir, 'view.d.ts'), rulesViewTypes(checked));
|
|
118
120
|
writeFileSync(join(dir, 'files.d.ts'), AMBIENT);
|
|
121
|
+
const partPaths = {};
|
|
122
|
+
const origins = readOrigins(root);
|
|
123
|
+
for (const ref of [...listOwnParts(root).map(p => p.id), ...Object.keys(origins.parts)]) {
|
|
124
|
+
const base = origins.parts[ref] ? vendorDir(root, ref) : join(root, 'parts', ref);
|
|
125
|
+
partPaths[`@parts/${ref}/*`] = [join(base, '*')];
|
|
126
|
+
const entry = resolvePartImport(root, `@parts/${ref}`, origins);
|
|
127
|
+
if (entry.path) partPaths[`@parts/${ref}`] = [entry.path];
|
|
128
|
+
}
|
|
119
129
|
const project = join(dir, 'tsconfig.json');
|
|
120
130
|
writeFileSync(project, `${JSON.stringify({ compilerOptions: { ...OPTIONS, checkJs: true, paths: {
|
|
131
|
+
...partPaths,
|
|
121
132
|
...Object.fromEntries(Object.entries(JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')).exports)
|
|
122
133
|
.filter(([, entry]) => entry?.types).map(([name, entry]) => [`@homie-rocks/studio/${name.slice(2)}`, [join(PACKAGE_ROOT, entry.types)]])),
|
|
123
134
|
'@homie-rocks/studio/rules': [join(dir, 'rules.d.ts')],
|
|
@@ -131,6 +142,9 @@ export async function typecheckRules(root, g, checked, tune, { log = () => {} }
|
|
|
131
142
|
const at = /^(.+?)\((\d+),(\d+)\): error (TS\d+): (.*)$/.exec(line);
|
|
132
143
|
if (at) {
|
|
133
144
|
const file = resolve(at[1]);
|
|
145
|
+
const missingPart = /Cannot find module '(@parts\/[^']+)'/.exec(at[5]);
|
|
146
|
+
if (missingPart) { const result = resolvePartImport(root, missingPart[1]); if (result.error) at[5] = result.error; }
|
|
147
|
+
|
|
134
148
|
// Imported game helpers count too. Only the toolkit's own implementation is outside the author's remit.
|
|
135
149
|
if (!file.startsWith(`${PACKAGE_ROOT}${sep}`) && !file.includes(`${sep}node_modules${sep}`) || file.startsWith(`${g.dir}${sep}`)) {
|
|
136
150
|
const sourceLine = readFileSync(file,'utf8').split(/\r?\n/)[Number(at[2])-1] ?? '';
|
package/lib/worker-build.mjs
CHANGED
|
@@ -2,9 +2,9 @@ import {createHash} from 'node:crypto';
|
|
|
2
2
|
import {refreshWorkerConfig} from './worker-config.mjs';
|
|
3
3
|
import {pathToFileURL} from 'node:url';
|
|
4
4
|
import {existsSync,mkdirSync,writeFileSync,renameSync,readFileSync,mkdtempSync,rmSync} from 'node:fs';
|
|
5
|
-
import {join,relative,basename,sep} from 'node:path';
|
|
5
|
+
import {join,relative,basename,sep,resolve} from 'node:path';
|
|
6
6
|
import {readConfig} from './routes.mjs';
|
|
7
|
-
import {workerDir} from './studio.mjs';
|
|
7
|
+
import {PACKAGE_ROOT,workerDir} from './studio.mjs';
|
|
8
8
|
import {paidReleases} from './parts-upload.mjs';
|
|
9
9
|
/** Preserve dynamic imports as real Worker modules: page startup never evaluates MCP libraries. */
|
|
10
10
|
export async function buildWorker(root,esbuild) {
|
|
@@ -15,7 +15,10 @@ export async function buildWorker(root,esbuild) {
|
|
|
15
15
|
const toolModule=join(root,'site/src/tools/index.mjs');
|
|
16
16
|
const pricedTools=existsSync(toolModule) && (await import(pathToFileURL(toolModule).href+'?worker='+Date.now())).default.some(tool=>tool.price);
|
|
17
17
|
if(paidReleases(root).length || existsSync(join(root,'shop.json')) || pricedTools)alias['@homie-rocks/studio/worker']='@homie-rocks/studio/worker/selling';
|
|
18
|
-
|
|
18
|
+
// Older studio entries export only Table and Lobby. Supply the new toolkit-owned
|
|
19
|
+
// classes in the generated bundle without rewriting the studio's source/customizations.
|
|
20
|
+
const contents=`export * from ${JSON.stringify(source)};\nexport { default } from ${JSON.stringify(source)};\nexport { Gate, Concentrator } from ${JSON.stringify(join(PACKAGE_ROOT,'worker/gate.mjs'))};\n`;
|
|
21
|
+
const result=await esbuild.build({stdin:{contents,resolveDir:root,sourcefile:'worker.mjs'},entryNames:'worker',absWorkingDir:root,alias,outdir:out,bundle:true,splitting:true,format:'esm',platform:'browser',mainFields:['module','main'],conditions:['workerd','worker','browser'],external:['node:*','cloudflare:*'],target:'es2022',write:false,minify:true,metafile:true,logLevel:'silent'});
|
|
19
22
|
// A watcher may already have read the previous entry while this build is
|
|
20
23
|
// publishing. Keep its entire graph available. Publish immutable modules first,
|
|
21
24
|
// then switch the single entry atomically; never remove a running graph.
|
|
@@ -38,7 +41,9 @@ export async function buildWorker(root,esbuild) {
|
|
|
38
41
|
}
|
|
39
42
|
// Wrangler's module root is the immutable generation, so old generations are
|
|
40
43
|
// retained locally for in-flight reloads but are never added to a deployment.
|
|
41
|
-
refreshWorkerConfig(root
|
|
44
|
+
refreshWorkerConfig(root);
|
|
45
|
+
const current=join(out,'current.json'), pending=`${current}.${process.pid}.tmp`;
|
|
46
|
+
writeFileSync(pending,JSON.stringify({main:join(dir,'worker.js')})+'\n');renameSync(pending,current);
|
|
42
47
|
// Dev sometimes watches a copy with routes/remote AI removed. Publish its
|
|
43
48
|
// entry too, keeping its local bindings and its module root at one generation.
|
|
44
49
|
const local=join(workerDir(root),'.wrangler','homie-dev.wrangler.json');
|
|
@@ -54,3 +59,12 @@ export async function buildWorker(root,esbuild) {
|
|
|
54
59
|
writeFileSync(join(out,'modules.json'),JSON.stringify(sizes,null,2)+'\n');
|
|
55
60
|
return sizes;
|
|
56
61
|
}
|
|
62
|
+
|
|
63
|
+
/** Local immutable entry; the committed config always names the stable shim. */
|
|
64
|
+
export function workerEntry(root) {
|
|
65
|
+
const main=readConfig(root)?.main;
|
|
66
|
+
// Explicit custom entry points remain the owner's choice.
|
|
67
|
+
if(typeof main!=='string' || resolve(workerDir(root),main)!==resolve(root,'site/src/runtime/worker.js'))return null;
|
|
68
|
+
const file=join(root,'site/src/runtime/current.json');
|
|
69
|
+
return existsSync(file)?JSON.parse(readFileSync(file,'utf8')).main:null;
|
|
70
|
+
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
> 0.45.0: this document describes the internal transport used by rules rooms.
|
|
2
|
+
> Games have one authoring path: rules plus view with `openRoom`. Browser/offline
|
|
3
|
+
> hosting uses that same contract. Existing netplay builds and port host helpers
|
|
4
|
+
> remain supported with their original 32-seat limit. Historical protocol revisions below explain wire fields, not an
|
|
5
|
+
> alternative way to author new games. `players.max` chooses the room size without
|
|
6
|
+
> a 32-seat ceiling for rules games. Revision 12 adds roster patches and ordered chained deltas.
|
|
7
|
+
|
|
1
8
|
# Homie netplay contract, v1 (revision 11)
|
|
2
9
|
|
|
3
10
|
**Authoring now:** New games use rules plus view and the server as host. Read the
|
|
@@ -55,11 +62,9 @@ revision-8 relay ignore every new field and frame):
|
|
|
55
62
|
because the frame has no storage), `net.params` (the address's switches), `net.shell` (where the
|
|
56
63
|
page's own controls sit over the game), `guardGestures()` for a touch game.
|
|
57
64
|
- **Whose body** (section 25): `peer.occ` (which stay in a seat), `Roster.claim(…, occ)`,
|
|
58
|
-
|
|
59
|
-
checkpoint and for a host that reconnects as host.
|
|
65
|
+
and restoring a room's holders from its checkpoint.
|
|
60
66
|
- **Places** (section 26): a tie policy for results. **The relay's log** (section 27).
|
|
61
|
-
- **Seat or solo** (section 28, added after 0.31.0 with no change to the wire): `
|
|
62
|
-
`room.standing`, `net.full`, `net.line()`, a bound on `reconnecting` (`reconnectMaxMs`), and typed
|
|
67
|
+
- **Seat or solo** (section 28, added after 0.31.0 with no change to the wire): `net.full`, `net.line()`, a bound on `reconnecting` (`reconnectMaxMs`), and typed
|
|
63
68
|
readers for `net.prefs` (section 24).
|
|
64
69
|
- New optional fields `hello.ver`, `hello.feat`, `welcome.ver`, `welcome.stale`, `welcome.stall`,
|
|
65
70
|
`peer.occ`, `peer.ver`, `peer.feat`, `snap.hb`; a relay → client `stale` frame; the final refusal
|
|
@@ -255,7 +260,7 @@ code.
|
|
|
255
260
|
- **Room size.** `maxPlayers` comes from the game's netplay manifest: game.json's
|
|
256
261
|
`netplay.maxPlayers`, or a `netplay.json` beside game.json or in the game's build
|
|
257
262
|
(`maxPlayers` or `players.max`), else game.json's `players.max`, else 8. **A room holds
|
|
258
|
-
|
|
263
|
+
chosen by the studio** (`players.max`). `homie-studio build` writes the number into the catalogue and
|
|
259
264
|
the site's Table takes it from there; the first visitor of an empty room may lower it
|
|
260
265
|
(`hello.max`), never raise it.
|
|
261
266
|
- **A dropped seat is held 60 s** for its token, which covers a reload or a network blip.
|
|
@@ -1099,12 +1104,8 @@ function stepBots(dt: number): void {
|
|
|
1099
1104
|
| `aiName(label)`, `stripAi(name)`, `AI_MARK`, `SKILLS`, `skillPreset(n, kids?)` | Names and the dial. |
|
|
1100
1105
|
| `Roster({ …, policy })` | Keeps `aiSeats + guides` slots marked `agent` (a person never takes one); `claim(seat, name, agent?)` (hands `host`: the slot stays a bot, `agent.seat` set; hands `self`: claimed like a person's); `release(seat)` gives an agent's slot back as a seat kept for AI; `bots: 'off'` adds no filler. |
|
|
1101
1106
|
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
takes `skill: () => room.skillOf(body)` and maps it: `reactionMs`, an aim error of
|
|
1105
|
-
`aimNoise × 0.8` rad, and a commitment of `2500 × (1.3 − 0.6 × aggression)` ms; `port/skill.ts`
|
|
1106
|
-
adds `jitter(s, maxPx)`, `engages(s, dt)` and `standoff(s, near, far)`. A ported game built on
|
|
1107
|
-
`BotBrain` gets the dial with a rebuild.
|
|
1107
|
+
Rules rooms provide the agent roster and skill dial. Declare deterministic bot inputs
|
|
1108
|
+
in `think`; presentation and controls remain in the view.
|
|
1108
1109
|
|
|
1109
1110
|
**Old games and old relays.**
|
|
1110
1111
|
- A game built with a helper before revision 6 plays on every server: doors, the human seat
|
|
@@ -1572,7 +1573,7 @@ and its page, so the wire did not change for it.
|
|
|
1572
1573
|
that was, null when it was not late); on the play page `window.__shell.arrival` has `mode`,
|
|
1573
1574
|
`by`, `explicitMs` (the page's clock) and `lateMs`, and `window.__shell.ready` the message
|
|
1574
1575
|
itself. The fix is one option: `createNetplay({ arrival: 'game' })`, or in a port
|
|
1575
|
-
|
|
1576
|
+
the rules view's transport options.
|
|
1576
1577
|
|
|
1577
1578
|
## 22. The link, said out loud (revision 9)
|
|
1578
1579
|
|
|
@@ -1608,7 +1609,7 @@ A page stands in one of six places with its room, and the helper says which:
|
|
|
1608
1609
|
| `alone`, a room still on the old build (`room-stale`) | `alone` | Playing on your own · this room opens when its players have the new version |
|
|
1609
1610
|
| `alone`, the room did not come back | `alone` | Playing on your own · the room dropped, still trying… |
|
|
1610
1611
|
| `online`, every seat taken (`net.full`) | `full` | This room is full · watching until a seat is free |
|
|
1611
|
-
| `online`, the game's own sentence (`net.line(text)
|
|
1612
|
+
| `online`, the game's own sentence (`net.line(text)`) | `seat` | for example: Playing on your own until the next round |
|
|
1612
1613
|
| `closed`: `room-full`, `too-many` | `closed` | This room is full. (with "· playing on your own" once it does) |
|
|
1613
1614
|
| `closed`: `replaced`, `kicked`, `room-closed`, `version` | `closed` | This game is open in another tab. / This room is closed. / This game needs a reload to play online. |
|
|
1614
1615
|
| `closed`: `stale`, or a newer build is live | `stale` | This game was updated. Tap to reload. |
|
|
@@ -1625,9 +1626,7 @@ A page stands in one of six places with its room, and the helper says which:
|
|
|
1625
1626
|
`reconnectMaxMs`, in `alone` (before this bound a replica whose room never came back showed a
|
|
1626
1627
|
frozen round and "Reconnecting…" for good). Going `alone` from a room is a `role` event (`role:
|
|
1627
1628
|
'host'`, `why: 'reconnect-timeout'`), also for a page that was the room's host: its seat went
|
|
1628
|
-
with the room. `
|
|
1629
|
-
everybody else's body a bot's); a game on raw `createNetplay` starts a round of its own, as it
|
|
1630
|
-
does for `relay-timeout`. `closed` is said in words and the play page offers the way out.
|
|
1629
|
+
with the room. Rules views start their offline rules runtime when going alone. `closed` is said in words and the play page offers the way out.
|
|
1631
1630
|
- **`net.full`** is true when this browser asked to play and every seat is taken: it is in the
|
|
1632
1631
|
room with no seat (`seat === null`, role `screen`) and the relay seats it when one frees up (a
|
|
1633
1632
|
`role` event with `why: 'seated'`). Section 28 says what a game does meanwhile.
|
|
@@ -1875,114 +1874,11 @@ const off = guardGestures({ touch: 'canvas, [data-action]' }); // once, early;
|
|
|
1875
1874
|
listeners and the stylesheet are tested, and emulated touch does not raise the callout, so try
|
|
1876
1875
|
a real long press on a real iPhone and Android phone before calling it done.
|
|
1877
1876
|
|
|
1878
|
-
## 25.
|
|
1879
|
-
|
|
1880
|
-
A seat number is not a person. The relay reuses a number when its player has been gone a minute
|
|
1881
|
-
(or sooner, in a full room), a host can be cut off while people come and go, and a round can
|
|
1882
|
-
outlive everyone in it. Three cases went wrong in real games; all three now run the same code as
|
|
1883
|
-
an ordinary join.
|
|
1884
|
-
|
|
1885
|
-
**`peer.occ`: which stay in the seat.** The relay numbers every stay: a new number whenever a seat
|
|
1886
|
-
is given to a new token, the same number across that browser's reloads and reconnects, kept
|
|
1887
|
-
through a deploy. `Roster.claim(seat, name, agent, occ)` and `Roster.reconcile(peers)` (pass
|
|
1888
|
-
`occ`) use it: the same stay keeps its body; a seat that changed hands gives the old body back to
|
|
1889
|
-
a bot and admits the newcomer like any arrival. `roster.occupants()` belongs in the checkpoint
|
|
1890
|
-
beside `roster.toJSON()`, and `Roster.from(slots, opts, occupants)` reads it back. With a relay
|
|
1891
|
-
that says no `occ`, a seat is taken to be the same player's, as before, and the helper still knows
|
|
1892
|
-
its own: `net.resumed` is true when its welcome gave back the seat its token named.
|
|
1893
|
-
|
|
1894
|
-
**The takeover callbacks cover every way a body changes hands** (`createRoom`):
|
|
1895
|
-
|
|
1896
|
-
| How | `onTakeover(body, info)` | `adopt(body)` |
|
|
1897
|
-
|---|---|---|
|
|
1898
|
-
| A person joins a running round | `{ why: 'join', own: false }` | on the joiner, when the host resets its body |
|
|
1899
|
-
| A visitor revives a room from its checkpoint | `{ why: 'restore', own: true }` for its own body, and for anyone claimed with it | on the new host, with the body it took |
|
|
1900
|
-
| A replica is promoted and someone arrived since the last checkpoint | `{ why: 'migrate' }` | on that player, by reset |
|
|
1901
|
-
| A host reconnects as host and someone arrived while it was away | `{ why: 'join' }` (the missed `join` is replayed) | on that player, by reset |
|
|
1902
|
-
| The same player comes back to the body they held | not called when the slot never left them; called with `back: true` when they left and returned (a reload) | as before |
|
|
1903
|
-
|
|
1904
|
-
`info.back` is true when the player held that body before. The default (no `onTakeover`) is still
|
|
1905
|
-
"score 0, where it stands".
|
|
1906
|
-
|
|
1907
|
-
**The restored room.** Everyone leaves; the relay keeps the round for 60 s; a new visitor arrives
|
|
1908
|
-
before that. It is welcomed as host with the checkpoint (`why: 'resumed'`), and the round goes on
|
|
1909
|
-
with the clock where it was. Before revision 9 the newcomer inherited a departed player's body
|
|
1910
|
-
without `onTakeover` (their score, with eleven seconds left) and without `adopt` (its local pose
|
|
1911
|
-
stayed at a default, so it could not move until the next round). Now `createRoom` hands the
|
|
1912
|
-
departed players' bodies back to bots, claims one for the newcomer through `admit`, calls
|
|
1913
|
-
`onTakeover` and then `adopt`. A player who comes back to their own room (their token) keeps
|
|
1914
|
-
their body and score, and is adopted onto it. A game on raw `createNetplay` does the same with
|
|
1915
|
-
`roster.reconcile(peers)` in its `role` handler: treat every slot in `claimed` as a takeover.
|
|
1916
|
-
|
|
1917
|
-
**The host that reconnects as host.** A host whose socket drops and comes back while nobody else
|
|
1918
|
-
could host gets a welcome, not a `role` (its role did not change), so its `role` handler never
|
|
1919
|
-
ran, and the relay never told it who came or went meanwhile. A player who arrived in that window
|
|
1920
|
-
had no body for the rest of the visit, and one who left kept a frozen one. The helper now says
|
|
1921
|
-
the difference as the events it would have been: `leave` (with `why: 'gone'`) and `join`, by seat,
|
|
1922
|
-
with a seat whose `occ` changed as both. Nothing to write: the handlers a game already has do it.
|
|
1923
|
-
The same change stopped `createRoom` calling `onTakeover` (and zeroing the score) for a player
|
|
1924
|
-
whose body never left them.
|
|
1925
|
-
|
|
1926
|
-
**`admit`: which body an arrival takes.** By default a newcomer takes the lowest bot's body. In an
|
|
1927
|
-
elimination round that can be a dead one while living bots stand by.
|
|
1928
|
-
|
|
1929
|
-
```ts
|
|
1930
|
-
createRoom({
|
|
1931
|
-
admit: (candidates, who) => candidates.find((b) => b.alive) ?? null, // null: none will do
|
|
1932
|
-
});
|
|
1933
|
-
new Roster({ min, max, admit: (slots, who) => pickSlotId(slots) }); // raw: slots in, a slot id out
|
|
1934
|
-
```
|
|
1935
|
-
|
|
1936
|
-
- Asked for every NEW arrival: a fresh join, a restored room's claims, a promoted host's
|
|
1937
|
-
reconcile, a missed join replayed. Return the body (the slot id, on `Roster`), `undefined` for the
|
|
1938
|
-
default, or `null` for "none of these": a new body is spawned while the room has space, and when
|
|
1939
|
-
it has none the default applies (a seated person always gets a body).
|
|
1940
|
-
- Never asked for a player returning to the body they held: an eliminated player who reloads gets
|
|
1941
|
-
their own dead body back, not a way into the round. `occ` is how the roster knows.
|
|
1942
|
-
- Never offered a seat kept for AI when a person arrives; an agent is offered the kept seats first
|
|
1943
|
-
(the reservation of section 17 holds).
|
|
1944
|
-
- Decide from the bodies' own state so the next host makes the same choice. The host still resets
|
|
1945
|
-
the body (`net.reset`), announces the roster and checkpoints, as for any claim.
|
|
1946
|
-
|
|
1947
|
-
**The late-join checklist.** A game is not done with joining until each of these is true in two
|
|
1948
|
-
real browsers:
|
|
1949
|
-
|
|
1950
|
-
1. A joiner mid-round takes a bot's body, the right one (`admit`), is reset onto it and can move.
|
|
1951
|
-
2. The host leaves; the other browser continues the same round; a joiner after that still works.
|
|
1952
|
-
3. A joiner who reloads comes back to the same body (and the same score, if the game keeps it:
|
|
1953
|
-
`info.back`).
|
|
1954
|
-
4. **Everyone leaves, and a new visitor enters before the room is forgotten (60 s).** The round is
|
|
1955
|
-
the same one; the visitor has a body it did not inherit silently (`onTakeover` ran, `adopt` ran),
|
|
1956
|
-
can move at once, and is ranked as a newcomer.
|
|
1957
|
-
5. The same, but the visitor is one of the players who left (same tab, reloaded): their own body
|
|
1958
|
-
and score.
|
|
1959
|
-
6. The host's network drops for a second while a third browser joins: after it reconnects, three
|
|
1960
|
-
people, three bodies, and nobody's score was reset.
|
|
1961
|
-
7. A deploy with tabs open: section 23.
|
|
1962
|
-
8. **A slow browser joins mid-round** (throttle one to "4x slowdown" and open the room's link
|
|
1963
|
-
while a round runs, then again during the results). Within a few seconds it is in one of the
|
|
1964
|
-
places section 28 names, and the screen says which: playing in the room on its own body,
|
|
1965
|
-
playing on its own with bots, or watching with a line that says until when. Never a body-less
|
|
1966
|
-
camera with nothing said, never "Reconnecting…" on a page that was never connected, and never
|
|
1967
|
-
a line left up after the link came back. `room.standing` (or `net.link`, `net.full` and your
|
|
1968
|
-
own roster on raw `createNetplay`) is what a test asserts.
|
|
1969
|
-
|
|
1970
|
-
## 26. Places and ties (revision 9)
|
|
1971
|
-
|
|
1972
|
-
`createRoom` ranks a round's results by score. The order of the rows was always: higher score
|
|
1973
|
-
first, then **people before bots**, then **the lower slot**. Two bodies on 61 points were shown
|
|
1974
|
-
first and second with nothing saying why. The order is the tiebreaker and is unchanged; what
|
|
1975
|
-
place a tie gets is now the game's to say:
|
|
1976
|
-
|
|
1977
|
-
| `createRoom({ ties })` | 61, 61, 40, 12 | |
|
|
1978
|
-
|---|---|---|
|
|
1979
|
-
| `'order'` (default) | 1, 2, 3, 4 | What results always were: the order above is the place. Say so to players if you keep it ("ties go to the player, then the earlier seat"). |
|
|
1980
|
-
| `'shared'` | 1, 1, 3, 4 | Standard competition places: equal scores share one and the next is skipped. |
|
|
1981
|
-
| `'dense'` | 1, 1, 2, 3 | Equal scores share one and none is skipped. |
|
|
1877
|
+
## 25–26. Player bodies and places
|
|
1982
1878
|
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1879
|
+
Rules own player bodies, arrival, departure and round results. Use the rules
|
|
1880
|
+
contract and `openRoom` view. The port-owned `createRoom` host scaffold and its
|
|
1881
|
+
callbacks remain available for existing games in 0.45.0. New games use rules plus view.
|
|
1986
1882
|
|
|
1987
1883
|
## 27. The relay's log (revision 9)
|
|
1988
1884
|
|
|
@@ -2009,61 +1905,10 @@ and `failed`.
|
|
|
2009
1905
|
host is closed, it comes from somewhere these boundaries do not cover; the `leave` and
|
|
2010
1906
|
`socket-gone` lines beside it say what the room was doing at that second.
|
|
2011
1907
|
|
|
2012
|
-
## 28.
|
|
1908
|
+
## 28. A page waiting for its body
|
|
2013
1909
|
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
| How | What the page had | What happens now |
|
|
2018
|
-
|---|---|---|
|
|
2019
|
-
| The relay seated the player and the host had no body free (every body was somebody's: a server stopped keeping seats for AI mid-round, or the game has fewer bodies than the room has seats). The claim was tried once, at the join. | a seat, role `replica`, no body, for the rest of the visit | The host seats everybody who is waiting **whenever a body frees up and at every round start** (`createRoom`; the starters on raw `createNetplay` do the same in `seatWaiting()`). |
|
|
2020
|
-
| Every seat is taken. The relay lets the page in with no seat. | `seat === null`, role `screen`, nothing said | `net.full` is true and the line says so; the relay seats it when a seat frees up, as before. |
|
|
2021
|
-
| The page was reset by its host before a snapshot showed its body. | a body it never adopted (it could not move until the next round) | `createRoom` owes the adoption until a snapshot shows the body. |
|
|
2022
|
-
|
|
2023
|
-
**The recipe is an option, not prose.** `createRoom` decides what a body-less page does, after
|
|
2024
|
-
`seatWaitMs` (default 4000, 1000 to 30000) of time it could actually listen:
|
|
2025
|
-
|
|
2026
|
-
```ts
|
|
2027
|
-
const room = createRoom({
|
|
2028
|
-
// …
|
|
2029
|
-
fallback: 'solo', // 'wait' (default) | 'solo' | 'spectate'
|
|
2030
|
-
seatWaitMs: 4000,
|
|
2031
|
-
onStanding: (s) => hud.say(s.line), // optional: the line over the game says it already
|
|
2032
|
-
});
|
|
2033
|
-
room.standing; // { state: 'joining' | 'playing' | 'solo' | 'watching' | 'waiting' | 'closed', why, line }
|
|
2034
|
-
```
|
|
2035
|
-
|
|
2036
|
-
| `fallback` | What the page does while the room has no body for it | The line |
|
|
2037
|
-
|---|---|---|
|
|
2038
|
-
| `'wait'` (default) | What a game did before: it waits. Now it says so, and the host seats it as above. | Waiting for a place in this round… |
|
|
2039
|
-
| `'solo'` | A private round with bots, here: `room.hosting` is true, `room.mine()` is its body, `room.solo` is true. Nothing of it is sent (no round, roster, snapshot or `room.send`). The moment the room has a body for it, the private round ends and `adopt` is called with the room's body. | Playing on your own until the next round (in a full room: This room is full · playing on your own until a seat is free) |
|
|
2040
|
-
| `'spectate'` | No body; `room.viewBody()` follows a person in the room until it has one. | Watching until the next round |
|
|
2041
|
-
|
|
2042
|
-
`room.standing` ties it to the link states of section 22, so a game (and a test) asks one thing:
|
|
2043
|
-
|
|
2044
|
-
| `standing.state` | `why` | `net.link` | Can the player play? |
|
|
2045
|
-
|---|---|---|---|
|
|
2046
|
-
| `joining` | `connecting`, `no-body`, `full`, `reconnecting` | `connecting`, or `online` inside `seatWaitMs` | not yet; a line after 0.7 s ("Joining the round…") |
|
|
2047
|
-
| `playing` | `seated`, `reconnecting` | `online` (or `reconnecting`, bounded) | yes, in the room |
|
|
2048
|
-
| `solo` | `alone`, `offline` (the link); `no-body`, `full` (the fallback) | `alone`, `offline`, or `online` | yes, on their own, with bots |
|
|
2049
|
-
| `watching` | `screen` (a watcher or a big screen, by choice); `no-body`, `full` (`spectate`) | `online` | no; the line says until when |
|
|
2050
|
-
| `waiting` | `no-body`, `full` (`wait`) | `online` | no; the line says so |
|
|
2051
|
-
| `closed` | `net.closedWhy` | `closed` | no; the line says why and the play page offers another room |
|
|
2052
|
-
|
|
2053
|
-
- **Solo is the game's own host code, run privately.** `onRoundStart` runs for the private round
|
|
2054
|
-
(with the room's round number, a clock of its own) and the game's rules move its bots, exactly
|
|
2055
|
-
as offline. A score made there stays there: the room's body starts as the host made it. Events
|
|
2056
|
-
from the real room still arrive on `room.on('event')` while solo; a game that plays sounds for
|
|
2057
|
-
them checks `room.solo`.
|
|
2058
|
-
- **The wait counts only time the page could listen**, like the wait for the welcome: a page
|
|
2059
|
-
blocked for six seconds by its own boot runs its timers before the snapshots queued behind
|
|
2060
|
-
them, and a body that is already on its way is not "missing".
|
|
2061
|
-
- **The default changes nothing a working game did.** A page that gets its body with its first
|
|
2062
|
-
snapshots never sees any of this; `'wait'` only adds the line and the host's second try.
|
|
2063
|
-
- **On raw `createNetplay`** there is no option: do what the starters do. Host: try the claim
|
|
2064
|
-
again on `leave` and at a round's rollover (`seatWaiting()`). Everyone: test
|
|
2065
|
-
`net.link === 'reconnecting'` for your own "Reconnecting…", read `net.full`, and say where the
|
|
2066
|
-
player stands with `net.line('…')`.
|
|
1910
|
+
The rules view owns connecting, playing, offline and closed states. The same
|
|
1911
|
+
rules run for offline/browser play; a separate port host is not supported.
|
|
2067
1912
|
|
|
2068
1913
|
## 29. The server as host (revision 10)
|
|
2069
1914
|
|
package/netplay/netplay.ts
CHANGED
|
@@ -88,9 +88,9 @@
|
|
|
88
88
|
|
|
89
89
|
export const NETPLAY_VERSION = 1;
|
|
90
90
|
/** The contract revision this helper speaks (NETPLAY.md): its hello says so (`rev`), and so does every build of it. */
|
|
91
|
-
export const NETPLAY_REVISION =
|
|
91
|
+
export const NETPLAY_REVISION = 12;
|
|
92
92
|
/** In every bundle that includes the helper: `homie-studio build` reads it to tell the office which revision a build speaks. */
|
|
93
|
-
export const NETPLAY_MARK = 'homie-netplay-rev:
|
|
93
|
+
export const NETPLAY_MARK = 'homie-netplay-rev:12';
|
|
94
94
|
|
|
95
95
|
export type Role = 'host' | 'replica' | 'screen';
|
|
96
96
|
export type Device = 'phone' | 'desk' | 'tv';
|
|
@@ -802,13 +802,13 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
|
802
802
|
/**
|
|
803
803
|
* This browser asked to play and every seat is taken: it is in the room with no seat (`seat === null`, role
|
|
804
804
|
* `screen`) and the relay seats it when one frees up (a `role` event with `why: 'seated'`). The line over the game
|
|
805
|
-
* says so;
|
|
805
|
+
* says so; the rules view decides what the page shows meanwhile.
|
|
806
806
|
*/
|
|
807
807
|
readonly full: boolean;
|
|
808
808
|
/**
|
|
809
809
|
* The line over the game is the game's to use too: one short sentence about where this player stands ("Playing
|
|
810
810
|
* on your own until the next round"), or null to take it away. Shown while the link is `online`; a link that is
|
|
811
|
-
* down says its own line first.
|
|
811
|
+
* down says its own line first.
|
|
812
812
|
*/
|
|
813
813
|
line(text: string | null): void;
|
|
814
814
|
/** `connectClock: 'game'`: the game has finished booting; the wait for the room's welcome starts now. Once. */
|
|
@@ -1046,7 +1046,7 @@ export interface RosterOptions {
|
|
|
1046
1046
|
max: number;
|
|
1047
1047
|
botName?: (slot: number) => string;
|
|
1048
1048
|
/**
|
|
1049
|
-
* Revision 6: the room's policy (
|
|
1049
|
+
* Revision 6: the room's policy (read from `net.policy`). A hybrid or beginner server keeps
|
|
1050
1050
|
* `aiSeats + guides` slots for AI (marked `agent`, never given to a person); `bots: 'off'` adds no other filler.
|
|
1051
1051
|
*/
|
|
1052
1052
|
policy?: () => Policy | null;
|
|
@@ -1371,7 +1371,7 @@ export function cleanFeatures(list: unknown): string[] {
|
|
|
1371
1371
|
|
|
1372
1372
|
/**
|
|
1373
1373
|
* PLACES (NETPLAY.md section 26): rows ranked by score, with a tie policy said out loud. `rows` must already be in the
|
|
1374
|
-
* order to show them (the tiebreak is the caller's
|
|
1374
|
+
* order to show them (the tiebreak is the caller's).
|
|
1375
1375
|
* `'order'` (the default, what results always were): places 1, 2, 3… in that order, so two equal scores get different
|
|
1376
1376
|
* places. `'shared'`: standard competition places, equal scores share one and the next is skipped (1, 1, 3).
|
|
1377
1377
|
* `'dense'`: equal scores share one and none is skipped (1, 1, 2).
|
|
@@ -1799,7 +1799,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1799
1799
|
const late = playableSent && arrived.by === 'auto' && arrived.playableMs !== null ? arrived.explicitMs - arrived.playableMs : null;
|
|
1800
1800
|
if (late !== null && late >= 250) {
|
|
1801
1801
|
arrived.lateMs = late;
|
|
1802
|
-
console.warn(`[netplay] net.playable() came ${late} ms after the arrival card had already lifted: this game uses the automatic arrival (seated, with the room's state), so its own word changed nothing and the card left before the game was ready. Pass createNetplay({ arrival: 'game' })
|
|
1802
|
+
console.warn(`[netplay] net.playable() came ${late} ms after the arrival card had already lifted: this game uses the automatic arrival (seated, with the room's state), so its own word changed nothing and the card left before the game was ready. Pass createNetplay({ arrival: 'game' }) so the card waits for net.playable().`);
|
|
1803
1803
|
}
|
|
1804
1804
|
post?.({ what: 'ready', by: 'game', mode: arrival, ms: arrived.explicitMs, ...(arrived.lateMs !== null ? { lateMs: arrived.lateMs } : {}) });
|
|
1805
1805
|
sayPlayable('game');
|
|
@@ -2437,7 +2437,12 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2437
2437
|
return;
|
|
2438
2438
|
}
|
|
2439
2439
|
case 'roster': {
|
|
2440
|
-
|
|
2440
|
+
if (m['patch'] === true) {
|
|
2441
|
+
const next = new Map((slots ?? []).map(row => [row.slot, row]));
|
|
2442
|
+
for (const slot of (m['removed'] as number[] ?? [])) next.delete(slot);
|
|
2443
|
+
for (const row of (m['slots'] as Slot[] ?? [])) next.set(row.slot, row);
|
|
2444
|
+
slots = [...next.values()].sort((a, b) => a.slot - b.slot);
|
|
2445
|
+
} else slots = (m['slots'] as Slot[]) ?? null;
|
|
2441
2446
|
post?.({ what: 'roster', slots });
|
|
2442
2447
|
if (slots) emit('roster', slots);
|
|
2443
2448
|
return;
|
|
@@ -2965,7 +2970,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
2965
2970
|
if (full) { full = false; post?.({ what: 'full', full: false }); }
|
|
2966
2971
|
aloneWhy = why;
|
|
2967
2972
|
// A page that gives up on a room it WAS in says so as a role event even when it was that room's host: its seat is
|
|
2968
|
-
// gone with the room, and the game seats itself again for a round of its own
|
|
2973
|
+
// gone with the room, and the game seats itself again for a round of its own.
|
|
2969
2974
|
if (!roleKnown || role !== 'host' || (rulesGame && !rulesHost) || why === 'reconnect-timeout') setRole(canOffline ? 'host' : 'replica', why, rulesGame && kept ? { ckpt: { k: tick, st: now(), d: kept as C } } : {});
|
|
2970
2975
|
// No shell at all is `offline` from the start; a room that never answered is `alone` (and still knocked at). A
|
|
2971
2976
|
// page the relay refused for good stays `closed` (nothing is knocking): it plays by itself and its line says why.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.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",
|
package/rules/collision.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { castTerrain } from './terrain.ts';
|
|
2
|
+
import { nearbyMap } from './map-index.ts';
|
|
1
3
|
/** Upright 3D collision. Positions are feet; a rounded box is a box plus a sphere.
|
|
2
4
|
* Capsules are a vertical segment plus a sphere. No engine-specific maths or time.
|
|
3
5
|
*/
|
|
@@ -8,6 +10,7 @@ export interface BodyShape { shape: string; radius: number; height: number }
|
|
|
8
10
|
export interface Solid { min: Vec3; max: Vec3; r: number }
|
|
9
11
|
export interface Hit3 { t: number; nx: number; ny: number; nz: number; id?: string }
|
|
10
12
|
export interface Map3 {
|
|
13
|
+
staticMap?: Map3;
|
|
11
14
|
bounds: MapBox; boxes: readonly MapBox[]; circles: readonly MapCircle[];
|
|
12
15
|
heightTiles?: readonly MapHeightTile[];
|
|
13
16
|
spheres?: readonly MapCircle[];
|
|
@@ -74,15 +77,17 @@ function castHeightTile(tile: MapHeightTile, p: Vec3, d: Vec3): Hit3 | null {
|
|
|
74
77
|
const x = p.x - tile.at.x, y = p.y - tile.at.y;
|
|
75
78
|
let best: Hit3 | null = null;
|
|
76
79
|
for (const half of [0, 1]) {
|
|
80
|
+
const alternate = tile.diagonal === '10-01';
|
|
77
81
|
const gx = half === 0 ? (h10 - h00) / sx : (h11 - h01) / sx;
|
|
78
|
-
const gy = half === 0 ?
|
|
79
|
-
const
|
|
82
|
+
const gy = alternate ? (half === 0 ? h01 - h00 : h11 - h10) / sy : (half === 0 ? h11 - h10 : h01 - h00) / sy;
|
|
83
|
+
const base = alternate && half === 1 ? h10 + h01 - h11 : h00;
|
|
84
|
+
const gap = p.z - tile.at.z - base - gx * x - gy * y;
|
|
80
85
|
const closing = gx * d.x + gy * d.y - d.z;
|
|
81
86
|
if (gap < -EPS || closing <= EPS) continue;
|
|
82
87
|
const t = Math.max(0, gap / closing);
|
|
83
88
|
if (t > 1 || best && t >= best.t) continue;
|
|
84
89
|
const u = (x + d.x * t) / sx, v = (y + d.y * t) / sy;
|
|
85
|
-
if (u < -EPS || u > 1 + EPS || v < -EPS || v > 1 + EPS || (half === 0 ? v > u + EPS : u > v + EPS)) continue;
|
|
90
|
+
if (u < -EPS || u > 1 + EPS || v < -EPS || v > 1 + EPS || (alternate ? (half === 0 ? u + v > 1 + EPS : u + v < 1 - EPS) : (half === 0 ? v > u + EPS : u > v + EPS))) continue;
|
|
86
91
|
const length = Math.sqrt(gx * gx + gy * gy + 1);
|
|
87
92
|
best = { t, nx: -gx / length, ny: -gy / length, nz: 1 / length };
|
|
88
93
|
}
|
|
@@ -90,6 +95,7 @@ function castHeightTile(tile: MapHeightTile, p: Vec3, d: Vec3): Hit3 | null {
|
|
|
90
95
|
}
|
|
91
96
|
|
|
92
97
|
export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | null {
|
|
98
|
+
map = nearbyMap(map, p, d, body.radius, body.height || body.radius * 2);
|
|
93
99
|
charge(24);
|
|
94
100
|
const a = solidAt(p, body), r = body.radius, height = body.height || r * 2;
|
|
95
101
|
let best: Hit3 | null = null;
|
|
@@ -101,7 +107,7 @@ export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | n
|
|
|
101
107
|
const n = point(0, 0, 0); n[axis] = d[axis] > 0 ? -1 : 1;
|
|
102
108
|
take({ t: ((d[axis] > 0 ? high : low) - p[axis]) / d[axis], nx: n.x, ny: n.y, nz: n.z });
|
|
103
109
|
}
|
|
104
|
-
for (const tile of map.heightTiles ?? []) take(castHeightTile(tile, p, d));
|
|
110
|
+
for (const tile of map.heightTiles ?? []) take(tile.base === undefined ? castHeightTile(tile, p, d) : castTerrain(tile, a, d));
|
|
105
111
|
for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }), (b as any).id);
|
|
106
112
|
// Legacy map circles are vertical columns in a 3D map.
|
|
107
113
|
for (const c of map.circles) take(castSolid(a, d, { min: point(c.at.x, c.at.y, map.bounds.min.z - r), max: point(c.at.x, c.at.y, map.bounds.max.z + r), r: c.r }), (c as any).id);
|