@homie-rocks/studio 0.39.0 → 0.40.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 +28 -1
- package/lib/build.mjs +1 -0
- package/lib/port-check.mjs +6 -3
- package/lib/rules-build.mjs +8 -1
- package/lib/rules-check-worker.mjs +3 -3
- package/lib/rules-check.mjs +3 -1
- package/lib/template-history.json +41 -0
- package/netplay/NETPLAY.md +74 -2
- package/package.json +1 -1
- package/rules/collision.ts +115 -0
- package/rules/core.ts +65 -25
- package/rules/host.ts +5 -1
- package/rules/math.ts +15 -5
- package/rules/rules.ts +33 -10
- package/rules/types.ts +2 -2
- package/rules/view.ts +126 -32
- package/starters/ember-vale/game.json +16 -4
- package/starters/ember-vale/map/main.json +66 -0
- package/starters/ember-vale/src/move.ts +31 -0
- package/starters/ember-vale/src/rules.ts +244 -0
- package/starters/ember-vale/src/{main.ts → view.ts} +89 -574
- package/starters/ember-vale/tunables.json +100 -11
- package/starters/gem-rush/src/move.ts +9 -1
- package/starters/gem-rush/src/view.ts +2 -1
- package/starters/gem-rush-3d/game.json +23 -5
- package/starters/gem-rush-3d/map/main.json +153 -0
- package/starters/gem-rush-3d/src/move.ts +37 -0
- package/starters/gem-rush-3d/src/rules.ts +133 -0
- package/starters/gem-rush-3d/src/{main.ts → view.ts} +63 -671
- package/starters/gem-rush-3d/tunables.json +98 -11
- package/starters/hero-rush-3d/game.json +23 -5
- package/starters/hero-rush-3d/map/main.json +153 -0
- package/starters/hero-rush-3d/src/move.ts +49 -0
- package/starters/hero-rush-3d/src/rules.ts +200 -0
- package/starters/hero-rush-3d/src/{main.ts → view.ts} +83 -976
- package/starters/hero-rush-3d/tunables.json +260 -30
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,33 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.40.0] - 2026-10-09
|
|
21
|
+
|
|
22
|
+
**Plugin 0.40.0**
|
|
23
|
+
|
|
24
|
+
New 3D games keep their matches on the server and answer your controls immediately.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Rules games can move and face in three dimensions, jump and land, and collide with boxes, spheres, upright capsules and sloped height tiles. Declared level data reaches the server and the browser together and is checked with the game's save and restore.
|
|
29
|
+
- Prediction follows vertical movement and facing, eases corrections, and draws other players' poses and animation state smoothly. The build reports the completed local room-capacity trial separately from its generated game check.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Gem Rush 3D, Hero Rush 3D and Ember Vale now use rules plus view with the server keeping the match, including bots, companions, pickups and combat. Ember Vale keeps its saved hero. All five game starters now use the rules runtime; Ember Vale keeps its two-dimensional play.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- Predicted movement respects the server's floor and ceiling, and catch-up animation follows the pose being drawn.
|
|
38
|
+
- Knockback no longer stores blocked travel and bursts around obstacles. A jump or direction change partway through a tick preserves the current drawn position before advancing.
|
|
39
|
+
- Phone control checks start measuring with the first directional touch movement, so slow browser automation cannot cross the arena before measurement begins.
|
|
40
|
+
- Gem Rush keeps sliding along arena walls under thumb input, and a delayed animation frame cannot give a newly arrived effect a negative radius.
|
|
41
|
+
|
|
42
|
+
### Upgrade notes
|
|
43
|
+
|
|
44
|
+
- Existing games keep their source and their hosting choice. The game skill is unchanged; its authoring update is the next slice.
|
|
45
|
+
- Capacity measurements in this release are local. Cloudflare capacity and billing have not been measured.
|
|
46
|
+
|
|
20
47
|
## [0.39.0] - 2026-10-09
|
|
21
48
|
|
|
22
49
|
**Plugin 0.39.0** · [#78](https://github.com/homie-rocks/homie/pull/78)
|
|
@@ -48,7 +75,7 @@ Your studio has its own MCP server. Connect your AI with one browser approval an
|
|
|
48
75
|
|
|
49
76
|
## [0.38.0] - 2026-10-09
|
|
50
77
|
|
|
51
|
-
**Plugin 0.39.0**
|
|
78
|
+
**Plugin 0.39.0** · [#77](https://github.com/homie-rocks/homie/pull/77)
|
|
52
79
|
|
|
53
80
|
Your own character answers as soon as you press, even when the room runs on the server.
|
|
54
81
|
|
package/lib/build.mjs
CHANGED
|
@@ -544,6 +544,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
544
544
|
if (seats.asked > SEAT_MAX) log(`warning: games/${g.id} asks for ${seats.asked} players; a room holds at most ${SEAT_MAX}, so its rooms have ${SEAT_MAX} seats`);
|
|
545
545
|
built.push({
|
|
546
546
|
id: g.id, name: g.name, mode, bytes, ms: Date.now() - started, warnings, seats: seats.max,
|
|
547
|
+
...(rules ? { capacityTrial: rules.check.capacityTrial } : {}),
|
|
547
548
|
...(bundle ? { bundle } : {}), ...(chunks.length ? { chunks: chunks.length, chunkBytes: later } : {}),
|
|
548
549
|
});
|
|
549
550
|
log(`built ${g.id} (${mode}, ${Math.round(bytes / 1024)} KB${chunks.length ? ` + ${chunks.length} additional ${chunks.length === 1 ? 'chunk' : 'chunks'}, ${Math.max(1, Math.round(later / 1024))} KB` : ''})`);
|
package/lib/port-check.mjs
CHANGED
|
@@ -314,10 +314,10 @@ async function focusGame(h) {
|
|
|
314
314
|
async function holdKey(h, code, ms, ready) { await h.page.keyboard.down(code); if (ready) await ready(); await sleep(ms); await h.page.keyboard.up(code); }
|
|
315
315
|
|
|
316
316
|
function thumbAt(h, i) { const t = i?.thumb ?? [0.24, 0.74]; return [Math.round(h.vp.width * t[0]), Math.round(h.vp.height * t[1])]; }
|
|
317
|
-
async function touchDrag(h, from, dir, reach, holdMs, ready) {
|
|
317
|
+
async function touchDrag(h, from, dir, reach, holdMs, ready, steps = 8) {
|
|
318
318
|
const [x0, y0] = from; const u = DIRV[dir];
|
|
319
319
|
await h.touch('touchStart', [{ id: 1, x: x0, y: y0 }]);
|
|
320
|
-
for (let k = 1; k <=
|
|
320
|
+
for (let k = 1; k <= steps; k++) { await h.touch('touchMove', [{ id: 1, x: Math.round(x0 + (u[0] * reach * k) / steps), y: Math.round(y0 + (u[1] * reach * k) / steps) }]); await sleep(12); }
|
|
321
321
|
if (ready) await ready();
|
|
322
322
|
const end = Date.now() + holdMs;
|
|
323
323
|
let w = 0;
|
|
@@ -369,7 +369,10 @@ export async function measuredPress(h, how, code, from, dir) {
|
|
|
369
369
|
b = await frameNow(h);
|
|
370
370
|
};
|
|
371
371
|
if (how === 'keys') await holdKey(h, code, 0, ready);
|
|
372
|
-
|
|
372
|
+
// One real move establishes the measured direction. An eight-event ramp can
|
|
373
|
+
// take seconds on software Chrome and drive all the way into a wall before
|
|
374
|
+
// its final-point receipt starts the measurement. Holds still exercise ramps.
|
|
375
|
+
else await touchDrag(h, from, dir, 70, 0, ready, 1);
|
|
373
376
|
return { dir, a, b };
|
|
374
377
|
}
|
|
375
378
|
|
package/lib/rules-build.mjs
CHANGED
|
@@ -33,6 +33,10 @@ import { typecheckRules } from './typecheck.mjs';
|
|
|
33
33
|
export { isRulesGame };
|
|
34
34
|
export { stateHash } from './rules-check.mjs';
|
|
35
35
|
|
|
36
|
+
// A reference workload, not a promise about every game's rules or a Cloudflare measurement.
|
|
37
|
+
// Receipt and method: docs/rooms-slice-7-notes.md, "Budget measurements".
|
|
38
|
+
export const RULES_CAPACITY_TRIAL = Object.freeze({ game: 'hero-rush-3d', seats: 32, tickHz: 30, minutes: 20, host: 'Node', platform: 'macOS arm64', cloudflare: false });
|
|
39
|
+
|
|
36
40
|
const readJson = (path) => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } };
|
|
37
41
|
const RULES_FILES = { [RULES_MODULE]: 'rules.ts', [GUARD_MODULE]: 'guard.ts', [`${RULES_MODULE}/view`]: 'view.ts', [`${RULES_MODULE}/host`]: 'host.ts' };
|
|
38
42
|
|
|
@@ -150,6 +154,9 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
|
|
|
150
154
|
for (const p of checked.problems) log(`warning: games/${g.id}/game.json: ${p}`);
|
|
151
155
|
for (const line of checked.information) log(`info: ${line}`);
|
|
152
156
|
const { settings, schema, publicTune, rounds, stateHash } = checked;
|
|
157
|
+
const trial = RULES_CAPACITY_TRIAL;
|
|
158
|
+
log(`info: rules runtime reference trial: ${trial.game}, ${trial.seats} players at ${trial.tickHz} Hz for ${trial.minutes} minutes on ${trial.host} (${trial.platform}). Cloudflare capacity and billing are unmeasured.`);
|
|
159
|
+
if (settings.tickHz > trial.tickHz) log(`info: games/${g.id} asks for ${settings.tickHz} Hz, above the completed local reference trial's ${trial.tickHz} Hz. This game's generated check does not establish live room capacity.`);
|
|
153
160
|
const { def, R, H } = await loadRules(esbuild, root, g.id, guarded.code);
|
|
154
161
|
const compiled = R.compileRules(def, { tune, map: R.compileMap(map, map.name), settings, seats });
|
|
155
162
|
const stats = smokeRun(H, compiled, g.id, { timer: () => 0, vocab: data.vocab });
|
|
@@ -171,7 +178,7 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
|
|
|
171
178
|
build: createHash('sha256').update(guarded.code).update(JSON.stringify(built)).digest('hex'),
|
|
172
179
|
snapshotCap, checkpointCap, snapshotBytes: stats.snapshotBytes, checkpointBytes: stats.checkpointBytes,
|
|
173
180
|
units: checked.maxUnits, tickUnits: checked.maxTickUnits,
|
|
174
|
-
check: { information: checked.information, plays: checked.plays, ticks: checked.ticks, rounds: checked.roundsPlayed, restores: checked.restores, units: checked.units, largestSaveBytes: checked.largestSaveBytes },
|
|
181
|
+
check: { information: checked.information, capacityTrial: trial, plays: checked.plays, ticks: checked.ticks, rounds: checked.roundsPlayed, restores: checked.restores, units: checked.units, largestSaveBytes: checked.largestSaveBytes },
|
|
175
182
|
};
|
|
176
183
|
}
|
|
177
184
|
|
|
@@ -236,7 +236,7 @@ function play(c, companions, allow) {
|
|
|
236
236
|
const ends = (fd) => (fd.t === 'bit' || fd.t === 'press' ? [false, true] : fd.t === 'fix' ? [-1, 1] : [L.P.coerce(fd, -Infinity, c.dims), L.P.coerce(fd, Infinity, c.dims)]);
|
|
237
237
|
const steering = new Map();
|
|
238
238
|
let moveTick = 0;
|
|
239
|
-
const moveCtx = new Map(c.kinds.filter((k) => k.move && k.body).map((k) => [k.name, L.M.moveContext({ tick: () => moveTick, tickHz, tune: c.publicTune, map: c.map, name: c.map.name, spots: c.map.spots, radius: () => k.body.radius, dims: c.dims })]));
|
|
239
|
+
const moveCtx = new Map(c.kinds.filter((k) => k.move && k.body).map((k) => [k.name, L.M.moveContext({ tick: () => moveTick, tickHz, tune: c.publicTune, map: c.map, name: c.map.name, spots: c.map.spots, radius: () => k.body.radius, shape: () => k.body, dims: c.dims })]));
|
|
240
240
|
/**
|
|
241
241
|
* An owner-moved body's own browser, for one tick: the game's `move` from where the last snapshot has the body, by
|
|
242
242
|
* the step a browser takes (pack.ts `stepMove`), and the claim of where that leaves it. Its units are the play's.
|
|
@@ -350,8 +350,8 @@ function play(c, companions, allow) {
|
|
|
350
350
|
const result = L.P.stepMove(kind.move, before, input, moveCtx.get(name), Math.max(1, Math.floor(budget / 4)), kind.motion, c.dims,
|
|
351
351
|
error => { fault ??= `${siteOf(name, 'move')} ${name}.move: prediction failed: ${error.message}.${when()}`; });
|
|
352
352
|
units += result.used;
|
|
353
|
-
const r = kind.body.radius, bounds = c.map.bounds;
|
|
354
|
-
result.pos = L.P.vec3({ x: Math.max(bounds.min.x + r, Math.min(bounds.max.x - r, result.pos.x)), y: Math.max(bounds.min.y + r, Math.min(bounds.max.y - r, result.pos.y)), z: result.pos.z }, c.dims);
|
|
353
|
+
const r = kind.body.radius, height = kind.body.height || 2 * r, bounds = c.map.bounds;
|
|
354
|
+
result.pos = L.P.vec3({ x: Math.max(bounds.min.x + r, Math.min(bounds.max.x - r, result.pos.x)), y: Math.max(bounds.min.y + r, Math.min(bounds.max.y - r, result.pos.y)), z: c.dims === 3 ? Math.max(bounds.min.z, Math.min(bounds.max.z - height, result.pos.z)) : 0 }, c.dims);
|
|
355
355
|
delete result.used;
|
|
356
356
|
if (JSON.stringify(result) !== JSON.stringify(after)) fault ??= `${siteOf(name, 'move')} ${name}.move: prediction and the server produced different bodies from the same input.${when()}`;
|
|
357
357
|
}
|
package/lib/rules-check.mjs
CHANGED
|
@@ -28,7 +28,9 @@ export const LONG_ALLOWANCE = Object.freeze({ ...ALLOWANCE, ticks: 8 * ALLOWANCE
|
|
|
28
28
|
|
|
29
29
|
/** The ordered declarations a save depends on. Functions contribute their presence, never their implementation. */
|
|
30
30
|
export function stateHash(c) {
|
|
31
|
-
const { kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats, save } = c;
|
|
31
|
+
const { kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map: geometry, dims, contract, seats, save } = c;
|
|
32
|
+
// Keep existing 2D save compatibility hashes when the 3D map vocabulary grows.
|
|
33
|
+
const map = dims === 2 ? { name: geometry.name, bounds: geometry.bounds, boxes: geometry.boxes, circles: geometry.circles, spots: geometry.spots } : geometry;
|
|
32
34
|
return createHash('sha256').update(JSON.stringify({ kinds, events, commands, effects, shared, rounds, bots, roomOn, asks, map, dims, contract, seats, tickHz: c.settings.tickHz, save }, (k, v) => typeof v === 'function' ? true : v)).digest('hex').slice(0, 32);
|
|
33
35
|
}
|
|
34
36
|
|
|
@@ -2197,6 +2197,47 @@
|
|
|
2197
2197
|
}
|
|
2198
2198
|
}
|
|
2199
2199
|
}
|
|
2200
|
+
},
|
|
2201
|
+
"0.40.0": {
|
|
2202
|
+
"files": {
|
|
2203
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2204
|
+
"README.md": "684b3cca8001673c",
|
|
2205
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2206
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2207
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2208
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2209
|
+
"posts/README.md": "37841aae476bd540",
|
|
2210
|
+
"site/README.md": "732894273a32dc3f",
|
|
2211
|
+
"site/src/worker.mjs": "34444a6ca4b14437"
|
|
2212
|
+
},
|
|
2213
|
+
"sections": {
|
|
2214
|
+
"AGENTS.md": {
|
|
2215
|
+
"(intro)": "214aa46eb3a895d5",
|
|
2216
|
+
"## Layout": "c18362ed28975d81",
|
|
2217
|
+
"## Apps: one screen, roles and parts": "be6f8fa45f93211a",
|
|
2218
|
+
"## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
|
|
2219
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2220
|
+
"## Making games": "2da7fa74c8884834",
|
|
2221
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2222
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2223
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2224
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
2225
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2226
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2227
|
+
"## Rules": "2e6b29afb85dcf7e",
|
|
2228
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2229
|
+
"## Beta": "9d02638207a26e73"
|
|
2230
|
+
}
|
|
2231
|
+
},
|
|
2232
|
+
"named": {
|
|
2233
|
+
"Homie Arcade": {
|
|
2234
|
+
"sections": {
|
|
2235
|
+
"AGENTS.md": {
|
|
2236
|
+
"## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2200
2241
|
}
|
|
2201
2242
|
}
|
|
2202
2243
|
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -2163,8 +2163,13 @@ Small errors fade as a visual offset with a nominal 100 ms blend. Overlapping sm
|
|
|
2163
2163
|
queue their individual fades, so their accumulated offset is not erased all at once. A disagreement
|
|
2164
2164
|
beyond three ticks of travel plays the corrected path from the snapshot at 1.25 times speed,
|
|
2165
2165
|
showing the hold and slide of a knock. Offset fades are limited by progress along the drawn
|
|
2166
|
-
path and
|
|
2167
|
-
|
|
2166
|
+
path and by normal movement speed, so a late-input correction cannot erase more travel than
|
|
2167
|
+
the player makes or amplify a fast knockback. When movement stops after catch-up, the remaining
|
|
2168
|
+
fade uses the configured blend time with the same speed limit; large offsets can take longer.
|
|
2169
|
+
Catch-up preserves the drawn pose when a snapshot replaces its history and uses the next
|
|
2170
|
+
movement preview when its next whole-tick history entry is not yet available.
|
|
2171
|
+
The fade's speed limit has a 1 m/s floor so even a body with zero declared running speed can
|
|
2172
|
+
finish a vertical correction. An effect on the body waits for its drawn tick. Placements, epoch changes and clock rebases
|
|
2168
2173
|
jump deliberately. Other bodies keep the helper's adaptive interpolation, with one send period
|
|
2169
2174
|
as the default minimum delay. The `room.predict` settings customize these defaults.
|
|
2170
2175
|
|
|
@@ -2390,3 +2395,70 @@ A missing offline module leaves the connection helper alive and says the game ne
|
|
|
2390
2395
|
connection. A failed offline runtime stops locally while reconnection continues. An online
|
|
2391
2396
|
runtime failure ends the room. Snapshots never grow above 16,384 bytes, including above
|
|
2392
2397
|
sixteen seats.
|
|
2398
|
+
|
|
2399
|
+
### 29.8 Three dimensions, level data and animation
|
|
2400
|
+
|
|
2401
|
+
All five shipped starters are rules games with `room.host: 'server'`. Existing games
|
|
2402
|
+
made with `port` keep their version 1 callbacks and browser hosting. Ember Vale keeps
|
|
2403
|
+
its cloud character saves; the room save holds the shared fight and companion state.
|
|
2404
|
+
|
|
2405
|
+
A rules declaration with `space: { dims: 3 }` uses metres and seconds, with **z up**.
|
|
2406
|
+
`pos` is the body's feet, `vel` has three components, `heading` is a unit direction,
|
|
2407
|
+
and `grounded` says a supporting collision has an upward normal greater than 0.5.
|
|
2408
|
+
The guarded `move` gets these values and declared `motion`, input, public tuning,
|
|
2409
|
+
`ctx.dt`, deterministic maths and `ctx.map.sweep`. The host and prediction run the
|
|
2410
|
+
same function. Gravity, jump impulse, acceleration and facing are game decisions,
|
|
2411
|
+
not a second physics loop in the view. Camera-relative controls are converted to
|
|
2412
|
+
world-space input in the view before calling `room.input`.
|
|
2413
|
+
|
|
2414
|
+
Bodies may be upright capsules (`radius`, full `height`), spheres (`radius`), or
|
|
2415
|
+
axis-aligned boxes (`radius` is horizontal half-width, `height` is full height).
|
|
2416
|
+
Sweeps are continuous: a fast body cannot skip a thin solid. `ctx.map.sweep` moves
|
|
2417
|
+
against the static map and returns its contact normal; a second sweep along that
|
|
2418
|
+
normal's tangent slides. `world.sweep` also considers entity bodies and accepts
|
|
2419
|
+
`ignore` ids. `world.ray`, `near`, `inBox` and areas consider height in a 3D game.
|
|
2420
|
+
A placement is a discontinuity, with a new spawn revision, rather than a sweep.
|
|
2421
|
+
|
|
2422
|
+
The game's declared map directory contains `main.json`. Static data is compiled
|
|
2423
|
+
into both the host and view and included in the state compatibility hash. It is
|
|
2424
|
+
not copied into each room save. Changing geometry makes old room state incompatible,
|
|
2425
|
+
just as changing a field does. Named `spots` provide spawn points and other fixed
|
|
2426
|
+
positions. A map has finite, increasing `bounds.min`/`max` vectors and at most 4,096
|
|
2427
|
+
solid shapes across these lists:
|
|
2428
|
+
|
|
2429
|
+
| List | Declaration | Meaning in 3D |
|
|
2430
|
+
|---|---|---|
|
|
2431
|
+
| `boxes` | `{ min: [x,y,z], max: [x,y,z] }` | Axis-aligned solid |
|
|
2432
|
+
| `circles` | `{ at: [x,y,z], r }` | Vertical column through the map, for arena obstacles |
|
|
2433
|
+
| `spheres` | `{ at: [x,y,z], r }` | Centre and radius |
|
|
2434
|
+
| `capsules` | `{ at: [x,y,z], r, height }` | Feet, radius, full upright height (at least twice the radius) |
|
|
2435
|
+
| `heightTiles` | `{ at: [x,y,z], size: [width,depth], heights: [h00,h10,h01,h11] }` | Relative corner heights, split on the 00–11 diagonal |
|
|
2436
|
+
|
|
2437
|
+
Height tiles support the foot point, with open edges and no underside. They are
|
|
2438
|
+
terrain, not a triangle-mesh collider or a solid platform; use boxes for platforms
|
|
2439
|
+
and ceilings. Every tile, solid and bounded convex-cast iteration is charged to
|
|
2440
|
+
the handler budget. The build check uses the same map and body shape as the host
|
|
2441
|
+
and predictor, measures full-room steps, and restores the complete declared state.
|
|
2442
|
+
The two meadow starters' playable ground is flat; their rolling scenery is visual.
|
|
2443
|
+
|
|
2444
|
+
The view interpolates remote three-axis position and velocity, and takes the shortest
|
|
2445
|
+
arc between unit headings, including half turns. Discrete animation fields stay at
|
|
2446
|
+
the earlier snapshot until their tick. Takeoff becomes airborne as the interpolated
|
|
2447
|
+
pose rises; landing waits for contact. Own-body reconciliation eases all three axes
|
|
2448
|
+
and heading. A renderer reads `room.me`/`room.each`, maps rules `(x,y,z)` to its
|
|
2449
|
+
renderer axes, and feeds those poses and grounded/vertical-speed values to animation.
|
|
2450
|
+
Effects such as strikes and hits still come from the authoritative room.
|
|
2451
|
+
|
|
2452
|
+
Rules can read `world.kids` and `world.levelSet` (an explicitly set party dial).
|
|
2453
|
+
`world.level` is the party's skill dial; `world.guideLevel` is the server's own level
|
|
2454
|
+
for guides. `world.guideSeats` is the frozen list of guide seats, assigned by the
|
|
2455
|
+
host from admitted agents and reserved seats; party companions use `world.level`.
|
|
2456
|
+
These policy values survive saves without changing older saves' defaults.
|
|
2457
|
+
|
|
2458
|
+
A view may opt into `openRoom({ fallback: 'solo' })` when its room allows offline
|
|
2459
|
+
play. After four active seconds without a body in a connected room, it plays a
|
|
2460
|
+
private rules round while the original connection keeps waiting for a seat. The
|
|
2461
|
+
private round imports no room save, match score or simulation state into the online
|
|
2462
|
+
room. When a body is assigned, the private round closes and the view joins the
|
|
2463
|
+
online match. Ember Vale retains its separate cloud hero: experience earned in
|
|
2464
|
+
solo play still belongs to that hero, whose level is sent through the usual command.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.40.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",
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/** Upright 3D collision. Positions are feet; a rounded box is a box plus a sphere.
|
|
2
|
+
* Capsules are a vertical segment plus a sphere. No engine-specific maths or time.
|
|
3
|
+
*/
|
|
4
|
+
import { charge } from './guard.ts';
|
|
5
|
+
import type { Vec3, MapBox, MapCircle, MapHeightTile } from './rules.ts';
|
|
6
|
+
|
|
7
|
+
export interface BodyShape { shape: string; radius: number; height: number }
|
|
8
|
+
export interface Solid { min: Vec3; max: Vec3; r: number }
|
|
9
|
+
export interface Hit3 { t: number; nx: number; ny: number; nz: number; id?: string }
|
|
10
|
+
export interface Map3 {
|
|
11
|
+
bounds: MapBox; boxes: readonly MapBox[]; circles: readonly MapCircle[];
|
|
12
|
+
heightTiles?: readonly MapHeightTile[];
|
|
13
|
+
spheres?: readonly MapCircle[];
|
|
14
|
+
capsules?: readonly (MapCircle & { height: number })[];
|
|
15
|
+
}
|
|
16
|
+
const EPS = 1e-7;
|
|
17
|
+
const point = (x: number, y: number, z: number): Vec3 => ({ x, y, z });
|
|
18
|
+
export function solidAt(at: Vec3, body: BodyShape): Solid {
|
|
19
|
+
const r = body.radius, height = body.height || 2 * r;
|
|
20
|
+
if (body.shape === 'box') return { min: point(at.x - r, at.y - r, at.z), max: point(at.x + r, at.y + r, at.z + height), r: 0 };
|
|
21
|
+
return { min: point(at.x, at.y, at.z + r), max: point(at.x, at.y, at.z + (body.shape === 'sphere' ? r : height - r)), r };
|
|
22
|
+
}
|
|
23
|
+
/** Closest vector between the cores of two rounded boxes. At most one core has
|
|
24
|
+
* horizontal extent, except box/box, so this represents all supported pairs exactly.
|
|
25
|
+
*/
|
|
26
|
+
function separation(a: Solid, b: Solid, d: Vec3, t: number): Vec3 {
|
|
27
|
+
const gap = (lo: number, hi: number, min: number, max: number): number => lo > max ? lo - max : hi < min ? hi - min : 0;
|
|
28
|
+
return point(gap(a.min.x + d.x * t, a.max.x + d.x * t, b.min.x, b.max.x), gap(a.min.y + d.y * t, a.max.y + d.y * t, b.min.y, b.max.y), gap(a.min.z + d.z * t, a.max.z + d.z * t, b.min.z, b.max.z));
|
|
29
|
+
}
|
|
30
|
+
/** Continuous convex cast: each separating plane gives a lower bound on impact.
|
|
31
|
+
* Every iteration is charged, and a fixed cap bounds grazing contacts as well.
|
|
32
|
+
*/
|
|
33
|
+
export function castSolid(a: Solid, d: Vec3, b: Solid): Hit3 | null {
|
|
34
|
+
charge(30);
|
|
35
|
+
let t = 0;
|
|
36
|
+
const r = a.r + b.r;
|
|
37
|
+
let normal = point(0, 0, 0);
|
|
38
|
+
// Sharp boxes need the slab test: their closest vector vanishes at a face.
|
|
39
|
+
if (r === 0) {
|
|
40
|
+
let enter = 0, leave = 1;
|
|
41
|
+
for (const axis of ['x', 'y', 'z'] as const) {
|
|
42
|
+
const lo = b.min[axis] - a.max[axis], hi = b.max[axis] - a.min[axis], v = d[axis];
|
|
43
|
+
if (v === 0) { if (lo >= 0 || hi <= 0) return null; continue; }
|
|
44
|
+
const near = (v > 0 ? lo : hi) / v, far = (v > 0 ? hi : lo) / v;
|
|
45
|
+
if (near >= enter) { enter = near; normal = point(0, 0, 0); normal[axis] = v > 0 ? -1 : 1; }
|
|
46
|
+
leave = Math.min(leave, far);
|
|
47
|
+
if (enter > leave) return null;
|
|
48
|
+
}
|
|
49
|
+
return enter >= 0 && enter <= 1 && normal.x + normal.y + normal.z !== 0 ? { t: enter, nx: normal.x, ny: normal.y, nz: normal.z } : null;
|
|
50
|
+
}
|
|
51
|
+
for (let i = 0; i < 32; i++) {
|
|
52
|
+
charge(24);
|
|
53
|
+
const s = separation(a, b, d, t), length = Math.sqrt(s.x * s.x + s.y * s.y + s.z * s.z);
|
|
54
|
+
const distance = length - r;
|
|
55
|
+
// Let an initially overlapping body escape. Placements are not collision moves.
|
|
56
|
+
if (i === 0 && distance < -EPS) return null;
|
|
57
|
+
if (length > 0) normal = point(s.x / length, s.y / length, s.z / length);
|
|
58
|
+
const approach = -(normal.x * d.x + normal.y * d.y + normal.z * d.z);
|
|
59
|
+
if (approach <= EPS) return null;
|
|
60
|
+
if (distance <= EPS) return { t, nx: normal.x, ny: normal.y, nz: normal.z };
|
|
61
|
+
t += distance / approach;
|
|
62
|
+
if (t > 1) return null;
|
|
63
|
+
}
|
|
64
|
+
// At the iteration cap, stop conservatively on the separating plane.
|
|
65
|
+
return { t, nx: normal.x, ny: normal.y, nz: normal.z };
|
|
66
|
+
}
|
|
67
|
+
/** Height tiles support a body's foot point. Each tile is split along its
|
|
68
|
+
* 00–11 diagonal; unlike bilinear sampling each half has a constant slope and
|
|
69
|
+
* the sweep has an exact intersection. Borders are open, so a body can walk off.
|
|
70
|
+
*/
|
|
71
|
+
function castHeightTile(tile: MapHeightTile, p: Vec3, d: Vec3): Hit3 | null {
|
|
72
|
+
charge(80);
|
|
73
|
+
const [h00, h10, h01, h11] = tile.heights, sx = tile.size.x, sy = tile.size.y;
|
|
74
|
+
const x = p.x - tile.at.x, y = p.y - tile.at.y;
|
|
75
|
+
let best: Hit3 | null = null;
|
|
76
|
+
for (const half of [0, 1]) {
|
|
77
|
+
const gx = half === 0 ? (h10 - h00) / sx : (h11 - h01) / sx;
|
|
78
|
+
const gy = half === 0 ? (h11 - h10) / sy : (h01 - h00) / sy;
|
|
79
|
+
const gap = p.z - tile.at.z - h00 - gx * x - gy * y;
|
|
80
|
+
const closing = gx * d.x + gy * d.y - d.z;
|
|
81
|
+
if (gap < -EPS || closing <= EPS) continue;
|
|
82
|
+
const t = Math.max(0, gap / closing);
|
|
83
|
+
if (t > 1 || best && t >= best.t) continue;
|
|
84
|
+
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;
|
|
86
|
+
const length = Math.sqrt(gx * gx + gy * gy + 1);
|
|
87
|
+
best = { t, nx: -gx / length, ny: -gy / length, nz: 1 / length };
|
|
88
|
+
}
|
|
89
|
+
return best;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function castMap3(map: Map3, p: Vec3, d: Vec3, body: BodyShape): Hit3 | null {
|
|
93
|
+
charge(24);
|
|
94
|
+
const a = solidAt(p, body), r = body.radius, height = body.height || r * 2;
|
|
95
|
+
let best: Hit3 | null = null;
|
|
96
|
+
const take = (h: Hit3 | null): void => { if (h && h.t >= 0 && h.t <= 1 && (!best || h.t < best.t)) best = h; };
|
|
97
|
+
for (const axis of ['x', 'y', 'z'] as const) {
|
|
98
|
+
const low = map.bounds.min[axis] + (axis === 'z' ? 0 : r);
|
|
99
|
+
const high = map.bounds.max[axis] - (axis === 'z' ? height : r);
|
|
100
|
+
if (d[axis] === 0) continue;
|
|
101
|
+
const n = point(0, 0, 0); n[axis] = d[axis] > 0 ? -1 : 1;
|
|
102
|
+
take({ t: ((d[axis] > 0 ? high : low) - p[axis]) / d[axis], nx: n.x, ny: n.y, nz: n.z });
|
|
103
|
+
}
|
|
104
|
+
for (const tile of map.heightTiles ?? []) take(castHeightTile(tile, p, d));
|
|
105
|
+
for (const b of map.boxes) take(castSolid(a, d, { ...b, r: 0 }));
|
|
106
|
+
// Legacy map circles are vertical columns in a 3D map.
|
|
107
|
+
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 }));
|
|
108
|
+
for (const s of map.spheres ?? []) take(castSolid(a, d, { min: s.at, max: s.at, r: s.r }));
|
|
109
|
+
for (const c of map.capsules ?? []) take(castSolid(a, d, solidAt(c.at, { shape: 'capsule', radius: c.r, height: c.height })));
|
|
110
|
+
return best;
|
|
111
|
+
}
|
|
112
|
+
export function restsOnMap(map: Map3, p: Vec3, body: BodyShape): boolean {
|
|
113
|
+
const h = castMap3(map, p, point(0, 0, -0.002), body);
|
|
114
|
+
return Boolean(h && h.nz > 0.5);
|
|
115
|
+
}
|