@homie-rocks/studio 0.47.0 → 0.48.1
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 +91 -0
- package/guides/game/RULES.md +189 -4
- package/guides/game/SKILL.md +27 -0
- package/lib/doctor.mjs +8 -6
- package/lib/mcp-tools.mjs +2 -1
- package/lib/physics-mesh.mjs +40 -0
- package/lib/provider-key.mjs +21 -0
- package/lib/rules-build.mjs +18 -10
- package/lib/rules-check-worker.mjs +3 -0
- package/lib/rules-check.mjs +1 -1
- package/lib/rules-guard.mjs +4 -3
- package/lib/rules-types.mjs +1 -1
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +20 -0
- package/package.json +11 -2
- package/rules/core.ts +63 -4
- package/rules/host.ts +38 -9
- package/rules/math.ts +10 -1
- package/rules/pack.ts +4 -2
- package/rules/physics-engine.ts +73 -0
- package/rules/physics.ts +26 -0
- package/rules/rules.ts +14 -4
- package/rules/substeps.ts +39 -0
- package/rules/types.ts +3 -2
- package/rules/view.ts +150 -44
- package/worker/build-id.mjs +1 -1
- package/worker/hosted.mjs +16 -0
- package/worker/index.mjs +19 -4
- package/worker/version.mjs +1 -1
|
@@ -2943,6 +2943,90 @@
|
|
|
2943
2943
|
}
|
|
2944
2944
|
}
|
|
2945
2945
|
}
|
|
2946
|
+
},
|
|
2947
|
+
"0.48.0": {
|
|
2948
|
+
"files": {
|
|
2949
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2950
|
+
"README.md": "684b3cca8001673c",
|
|
2951
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2952
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2953
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2954
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2955
|
+
"posts/README.md": "37841aae476bd540",
|
|
2956
|
+
"site/README.md": "732894273a32dc3f",
|
|
2957
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
2958
|
+
},
|
|
2959
|
+
"sections": {
|
|
2960
|
+
"AGENTS.md": {
|
|
2961
|
+
"(intro)": "394cbbe934e80722",
|
|
2962
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
2963
|
+
"## Layout": "f3a280b8c96cd068",
|
|
2964
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
2965
|
+
"## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
|
|
2966
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
2967
|
+
"## Making games": "bb715272fd1a615b",
|
|
2968
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
2969
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
2970
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
2971
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
2972
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
2973
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
2974
|
+
"## Rules": "27881d11d3ff3803",
|
|
2975
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
2976
|
+
"## Beta": "9d02638207a26e73"
|
|
2977
|
+
}
|
|
2978
|
+
},
|
|
2979
|
+
"named": {
|
|
2980
|
+
"Homie Arcade": {
|
|
2981
|
+
"sections": {
|
|
2982
|
+
"AGENTS.md": {
|
|
2983
|
+
"## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
},
|
|
2989
|
+
"0.48.1": {
|
|
2990
|
+
"files": {
|
|
2991
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
2992
|
+
"README.md": "684b3cca8001673c",
|
|
2993
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
2994
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
2995
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
2996
|
+
"videos/README.md": "16babc3437d5545b",
|
|
2997
|
+
"posts/README.md": "37841aae476bd540",
|
|
2998
|
+
"site/README.md": "732894273a32dc3f",
|
|
2999
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3000
|
+
},
|
|
3001
|
+
"sections": {
|
|
3002
|
+
"AGENTS.md": {
|
|
3003
|
+
"(intro)": "394cbbe934e80722",
|
|
3004
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3005
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3006
|
+
"## Apps: one screen, roles and parts": "8596f2b25edda3e2",
|
|
3007
|
+
"## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
|
|
3008
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3009
|
+
"## Making games": "bb715272fd1a615b",
|
|
3010
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3011
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3012
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3013
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3014
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
3015
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3016
|
+
"## Rules": "27881d11d3ff3803",
|
|
3017
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3018
|
+
"## Beta": "9d02638207a26e73"
|
|
3019
|
+
}
|
|
3020
|
+
},
|
|
3021
|
+
"named": {
|
|
3022
|
+
"Homie Arcade": {
|
|
3023
|
+
"sections": {
|
|
3024
|
+
"AGENTS.md": {
|
|
3025
|
+
"## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
2946
3030
|
}
|
|
2947
3031
|
}
|
|
2948
3032
|
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -2329,3 +2329,23 @@ private round imports no room save, match score or simulation state into the onl
|
|
|
2329
2329
|
room. When a body is assigned, the private round closes and the view joins the
|
|
2330
2330
|
online match. Ember Vale retains its separate cloud hero: experience earned in
|
|
2331
2331
|
solo play still belongs to that hero, whose level is sent through the usual command.
|
|
2332
|
+
|
|
2333
|
+
### Rules input history trailer (studio 0.47.1)
|
|
2334
|
+
|
|
2335
|
+
A rules step keeps its `[tickOffset, ...fields, ...ownerClaim]` prefix. Current
|
|
2336
|
+
views append `[-471, sequence, changeCount, ...changes]`, where each change is
|
|
2337
|
+
`[fieldIndex, changeSequence, fractionOfTick, value]`. All components are numbers.
|
|
2338
|
+
Input frames repeat recent entries, sorted by tick; the retained window is
|
|
2339
|
+
4–64 ticks based on measured authority RTT and jitter. The host deduplicates
|
|
2340
|
+
entry sequences, including history received after a missing frame. Duplicate
|
|
2341
|
+
history is not a fresh lead observation. Frames without the trailer keep the
|
|
2342
|
+
legacy input semantics.
|
|
2343
|
+
|
|
2344
|
+
Control rows keep their first six columns and append
|
|
2345
|
+
`[471, sequence, appliedTick, ...]`. Positive sequences identify input entries;
|
|
2346
|
+
negative sequences identify the independently numbered reliable pulse stream.
|
|
2347
|
+
The last 64 receipts repeat in snapshots, including projected Gate snapshots.
|
|
2348
|
+
An on-time entry names its predicted tick; a safely rewound movement entry names
|
|
2349
|
+
that historical tick; an entry beyond retained movement history or an authority
|
|
2350
|
+
boundary names the next tick. Entry receipts describe coalesced per-tick input;
|
|
2351
|
+
command/effect handlers are not rerun in the past.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.48.1",
|
|
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",
|
|
@@ -92,7 +92,8 @@
|
|
|
92
92
|
"./functions": {
|
|
93
93
|
"types": "./functions/functions.ts",
|
|
94
94
|
"default": "./functions/functions.ts"
|
|
95
|
-
}
|
|
95
|
+
},
|
|
96
|
+
"./rules/physics-engine": "./rules/physics-engine.ts"
|
|
96
97
|
},
|
|
97
98
|
"files": [
|
|
98
99
|
"docs/",
|
|
@@ -165,5 +166,13 @@
|
|
|
165
166
|
"scripts": {
|
|
166
167
|
"test": "node --test --test-concurrency=2 --test-timeout=1800000 test/*.test.mjs",
|
|
167
168
|
"prepack": "node lib/build-identity.mjs --check && node ../../scripts/authoring-guides.mjs --check"
|
|
169
|
+
},
|
|
170
|
+
"peerDependencies": {
|
|
171
|
+
"@homie-rocks/physics": "0.1.2"
|
|
172
|
+
},
|
|
173
|
+
"peerDependenciesMeta": {
|
|
174
|
+
"@homie-rocks/physics": {
|
|
175
|
+
"optional": true
|
|
176
|
+
}
|
|
168
177
|
}
|
|
169
178
|
}
|
package/rules/core.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { physicsWorld } from './physics.ts';
|
|
2
|
+
import { substeps } from './substeps.ts';
|
|
1
3
|
import { prepareMapReplacement, type MapRevision } from './map-state.ts';
|
|
2
4
|
import { tileApi } from './tiles.ts';
|
|
3
5
|
import { boundPosition, periodicDelta, relativePoint, blendPosition, wrapPoint } from './topology.mjs';
|
|
@@ -143,6 +145,7 @@ export interface SavedCore {
|
|
|
143
145
|
v: number; tick: number; epoch: number; rng: number; nextId: number; seq: number;
|
|
144
146
|
round: [number, number, number, number]; overAt: number; match: [number, number]; trips: number;
|
|
145
147
|
shared: unknown[]; policy: CorePolicy; intent?: [number, number, number?];
|
|
148
|
+
physics?: number[];
|
|
146
149
|
collision?: [number,number];
|
|
147
150
|
maps?: {current:MapRevision|null;pending:MapRevision|null};
|
|
148
151
|
history?: [number, [string, number, number, number, number][]][];
|
|
@@ -155,6 +158,7 @@ export interface Core {
|
|
|
155
158
|
readonly tick: number;
|
|
156
159
|
readonly epoch: number;
|
|
157
160
|
step(inputs?: ReadonlyMap<number, StepInput>, guides?: () => void): void;
|
|
161
|
+
reviseMovement(seat: number, at: number, values: Record<string, unknown>): boolean;
|
|
158
162
|
guide(seat: number, floor?: unknown): Record<string, unknown> | null;
|
|
159
163
|
vocabulary(value: Vocabulary | null): void;
|
|
160
164
|
goal(seat: number, value?: unknown): unknown;
|
|
@@ -174,10 +178,11 @@ export interface Core {
|
|
|
174
178
|
listeners(): { seat: number; id: string; pos: Vec3 }[];
|
|
175
179
|
/** The body in a seat, as the host runtime needs it for the input protocol. */
|
|
176
180
|
character(seat: number): import('./rules.ts').Character | null;
|
|
177
|
-
bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; driver: Driver } | null;
|
|
181
|
+
bodyOf(seat: number): { id: string; kind: KindTable; r: number; away: boolean; controlled: boolean; driver: Driver } | null;
|
|
178
182
|
/** What happened since the last call: rounds, effects, a changed roster, changed shared state, a new epoch, a failure. */
|
|
179
183
|
drain(): CoreOut[];
|
|
180
184
|
save(): SavedCore;
|
|
185
|
+
dispose(): void;
|
|
181
186
|
/** The `world` object rules are handed (a test walks it to prove nothing else is reachable). */
|
|
182
187
|
readonly world: object;
|
|
183
188
|
/**
|
|
@@ -195,6 +200,8 @@ export interface Core {
|
|
|
195
200
|
* `Adjusted`; also `effect`, `think` and `decision`): the handler, what was done, the field's name and what was written.
|
|
196
201
|
*/
|
|
197
202
|
export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number) => import('./rules.ts').Character | null | undefined; characterSaved?: (seat: number, character: import('./rules.ts').Character) => void; rewindTicks?: (seat: number)=>number; label?: (seat:number,driver:Driver)=>string; moved?: (kind: string, tick: number, input: Readonly<Record<string, unknown>>, before: import('./pack.ts').MoveBody, after: import('./pack.ts').MoveBody, geometry: import('./math.ts').MapShapes, id: string) => void; observe?: (kind: string, handler: string, error?: string) => void; noted?: (kind: string, handler: string, what: string, at: string, written: string) => void; seed?: number; epoch?: number; restore?: SavedCore | null; restoreEpoch?: number; stage?: string; decisions?: boolean } = {}): Core {
|
|
203
|
+
if (opts.restore && (c.map.physics !== undefined) !== (opts.restore.physics !== undefined)) throw new Error('physics: save and game differ');
|
|
204
|
+
const physics = physicsWorld(c.map.physics, opts.restore?.physics);
|
|
198
205
|
let currentMap=c.map;
|
|
199
206
|
let mapRevision:MapRevision|null=null,pendingMap:MapRevision|null=null;
|
|
200
207
|
let pendingGeometry:typeof currentMap|null=null,mapCells=0,currentMapCells=0,pendingMapCells=0;
|
|
@@ -553,6 +560,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
553
560
|
tick: () => tick, dt: () => dt, round: () => roundApi, shared: sharedView, level: () => policy.level, levelMax: () => policy.levelMax, guideLevel: () => policy.guideLevel ?? policy.level, guideSeats: () => policy.guideSeats ?? EMPTY_GUIDE_SEATS, kids: () => Boolean(policy.kids), levelSet: () => Boolean(policy.levelSet),
|
|
554
561
|
// What the running handler may still spend (0.46.3): a handler that plans can stop short of its share instead of being stopped.
|
|
555
562
|
unitsLeft: () => Math.max(0, Math.min(2147483647, Math.floor(G.left))),
|
|
563
|
+
...(physics ? {physics: () => physics.api} : {}),
|
|
556
564
|
stage: () => stage, tune: () => c.tune, math: () => math, map: () => mapApi,
|
|
557
565
|
};
|
|
558
566
|
for (const [name, get] of Object.entries(getters)) Object.defineProperty(world, name, { get, enumerable: true });
|
|
@@ -824,7 +832,8 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
824
832
|
let moveShape = { shape: 'sphere', radius: 0, height: 0 };
|
|
825
833
|
const moveCtx = brand({} as Record<string, unknown>);
|
|
826
834
|
for (const [name, get] of Object.entries({ tick: () => tick, dt: () => dt, tune: () => c.publicTune, math: () => math, map: () => moveMap, world: () => moveWorld })) Object.defineProperty(moveCtx, name, { get, enumerable: true });
|
|
827
|
-
Object.assign(moveCtx, { ticks: (seconds: unknown): number => { charge(1); return ticks(seconds); } });
|
|
835
|
+
Object.assign(moveCtx, { substeps: substeps(c.settings.tickHz), ticks: (seconds: unknown): number => { charge(1); return ticks(seconds); } });
|
|
836
|
+
if (physics) Object.assign(moveCtx, {physics: physics.movement});
|
|
828
837
|
Object.freeze(moveCtx);
|
|
829
838
|
const joinCtx = brand({} as Record<string, unknown>);
|
|
830
839
|
for (const [name, get] of Object.entries({ map: () => mapApi, shared: sharedView, tune: () => c.tune, math: () => math, round: () => roundApi })) Object.defineProperty(joinCtx, name, { get, enumerable: true });
|
|
@@ -1083,7 +1092,46 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1083
1092
|
// Inside the cone: the angle to its axis is at most half its opening, compared by cosine.
|
|
1084
1093
|
return (dx * a.dir.x + dy * a.dir.y + dz * a.dir.z) / l >= exact.cos(a.half);
|
|
1085
1094
|
}
|
|
1095
|
+
// Movement-only rewind never repeats effects or commands. External body writes fence history.
|
|
1096
|
+
type MovementState = {pos: Vec3; vel: Vec3; heading: Vec3; grounded: boolean; motion: Record<string, unknown>};
|
|
1097
|
+
type MovementFrame = {tick: number; r: number; before: MovementState; after: MovementState; input: Readonly<Record<string, unknown>>; geometry: typeof moveGeometry; map: typeof currentMap};
|
|
1098
|
+
const movementHistory = new Map<string, MovementFrame[]>();
|
|
1099
|
+
const movementState = (e: Ent): MovementState => ({pos:e.pos,vel:e.vel,heading:e.heading,grounded:e.grounded,motion:{...e.m}});
|
|
1100
|
+
const sameMovement = (a: MovementState,b: MovementState): boolean => JSON.stringify(a) === JSON.stringify(b);
|
|
1101
|
+
function putMovement(e: Ent, b: MovementState): void {
|
|
1102
|
+
e.pos=b.pos;e.vel=b.vel;e.heading=b.heading;e.grounded=b.grounded;
|
|
1103
|
+
Object.assign(e.m,b.motion);
|
|
1104
|
+
}
|
|
1105
|
+
function reviseMovement(seat: number, at: number, values: Record<string, unknown>): boolean {
|
|
1106
|
+
const id=seats.get(seat)?.id, e=id?ents.get(id):null;
|
|
1107
|
+
if(!e || !e.kind.move || e.kind.body?.owner || e.away || e.driver!=='person') return false;
|
|
1108
|
+
const frames=movementHistory.get(e.id), start=frames?.findIndex(f=>f.tick===at)??-1;
|
|
1109
|
+
if(!frames || start<0 || frames.at(-1)!.tick!==tick || frames[start].r!==e.r || !sameMovement(frames.at(-1)!.after,movementState(e)))return false;
|
|
1110
|
+
const control=e.kind.player?.control;
|
|
1111
|
+
if(control && frames.slice(start).some(f=>!f.before.motion[control] || !f.after.motion[control]))return false;
|
|
1112
|
+
for(let i=start+1;i<frames.length;i++)if(frames[i].r!==e.r || !sameMovement(frames[i-1].after,frames[i].before))return false;
|
|
1113
|
+
const saved=movementState(e), savedTick=tick, geometry=moveGeometry, map=currentMap;
|
|
1114
|
+
const old=frames.map(f=>({...f}));
|
|
1115
|
+
try {
|
|
1116
|
+
putMovement(e,frames[start].before);
|
|
1117
|
+
for(let i=start;i<frames.length;i++){
|
|
1118
|
+
const f=frames[i];tick=f.tick;moveGeometry=f.geometry;currentMap=f.map;moveShape=e.kind.body!;
|
|
1119
|
+
f.before=movementState(e);
|
|
1120
|
+
// An entry holds until the next explicitly different input.
|
|
1121
|
+
if(i===start || JSON.stringify(old[i].input)===JSON.stringify(old[i-1].input))f.input=Object.freeze(coerceFields(e.kind.input,values,dims));else values={...f.input};
|
|
1122
|
+
const b={pos:e.pos,vel:e.vel,heading:e.heading,grounded:e.grounded,motion:e.mself};
|
|
1123
|
+
const errors=stats.errors, stops=stats.budgetStops;
|
|
1124
|
+
if(!run(e.kind.name,'move',e,'move',()=>e.kind.move!(b,f.input,moveCtx),'body'))throw new Error('rewind budget');
|
|
1125
|
+
if(stats.errors!==errors || stats.budgetStops!==stops)throw new Error('rewind failed');
|
|
1126
|
+
e.pos=clampIn(V(own(b,'pos')),e.kind.body!.radius,e.kind.body!.height||2*e.kind.body!.radius);e.vel=V(own(b,'vel'));e.heading=dir(own(b,'heading'),dims);e.grounded=own(b,'grounded')===true;
|
|
1127
|
+
f.after=movementState(e);
|
|
1128
|
+
}
|
|
1129
|
+
return true;
|
|
1130
|
+
} catch {putMovement(e,saved);movementHistory.set(e.id,old);return false;}
|
|
1131
|
+
finally {tick=savedTick;moveGeometry=geometry;currentMap=map;}
|
|
1132
|
+
}
|
|
1086
1133
|
function step(inputs: ReadonlyMap<number, StepInput> = new Map(), guides?: () => void): void {
|
|
1134
|
+
for(const id of movementHistory.keys())if(!ents.has(id))movementHistory.delete(id);
|
|
1087
1135
|
tick = (tick + 1) >>> 0;
|
|
1088
1136
|
if(pendingMap&&pendingMap[0]<=tick){currentMap=pendingGeometry!;mapRevision=pendingMap;currentMapCells=pendingMapCells;mapCells=currentMapCells;pendingMapCells=0;pendingMap=null;pendingGeometry=null;moveGeometry=currentMap;}
|
|
1089
1137
|
// The tick pays first for the state it sends: what the last snapshot packed. A room that holds more than a tick can send has nothing left for its handlers, and ends as any room does whose budget trips on every tick.
|
|
@@ -1197,6 +1245,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1197
1245
|
}
|
|
1198
1246
|
if (!k.move) continue;
|
|
1199
1247
|
const b = { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: e.mself };
|
|
1248
|
+
const rewindBefore = k.player && e.driver === 'person' && !e.away ? movementState(e) : null;
|
|
1200
1249
|
const beforeMove = opts.moved ? { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } } : null;
|
|
1201
1250
|
moveShape = body;
|
|
1202
1251
|
run(k.name, 'move', e, 'move', () => (k.move as NonNullable<KindTable['move']>)(b, e.input, moveCtx), 'body');
|
|
@@ -1207,6 +1256,11 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1207
1256
|
naming('body', 'pos'); e.pos = clampIn(V(own(b, 'pos')), body.radius, body.height || 2 * body.radius); naming('body', 'vel'); e.vel = V(own(b, 'vel')); naming('body', 'heading'); e.heading = dir(own(b, 'heading'), dims); naming();
|
|
1208
1257
|
G.note = null;
|
|
1209
1258
|
e.grounded = own(b, 'grounded') === true;
|
|
1259
|
+
if (rewindBefore) {
|
|
1260
|
+
let frames=movementHistory.get(e.id);if(!frames){frames=[];movementHistory.set(e.id,frames);}
|
|
1261
|
+
frames.push({tick,r:e.r,before:rewindBefore,after:movementState(e),input:e.input,geometry:moveGeometry,map:currentMap});
|
|
1262
|
+
while(frames.length>tickHz)frames.shift();
|
|
1263
|
+
}
|
|
1210
1264
|
if (beforeMove) opts.moved!(k.name, tick, e.input, beforeMove, { pos: e.pos, vel: e.vel, heading: e.heading, grounded: e.grounded, motion: { ...e.m } }, moveGeometry, e.id);
|
|
1211
1265
|
}
|
|
1212
1266
|
// Phase 2: for every entity, its commands and then its tick, starting from a different entity each tick.
|
|
@@ -1281,6 +1335,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1281
1335
|
}
|
|
1282
1336
|
function save(): SavedCore {
|
|
1283
1337
|
return {
|
|
1338
|
+
...(physics ? {physics: physics.save()} : {}),
|
|
1284
1339
|
v: SAVE_REVISION, tick, epoch, rng, nextId, seq, round: [round.n, round.phase === 'live' ? 1 : 0, round.endsAt, round.startedAt], overAt, match: [playing, restartAt], trips,
|
|
1285
1340
|
...((hasColliders||mapRevision||pendingMap)?{collision:collisionState().slice(0,2) as [number,number]}:{}),
|
|
1286
1341
|
...((mapRevision||pendingMap)?{maps:{current:mapRevision,pending:pendingMap}}:{}),
|
|
@@ -1307,7 +1362,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1307
1362
|
for(const [name,value] of Object.entries(r.maps)){if(value===null)continue;check(Array.isArray(value)&&value.length===2&&uint(value[0])&&(name==='pending'?value[0]===r.tick+1:value[0]<=r.tick));prepareMapReplacement(value[1],c.map);}
|
|
1308
1363
|
}
|
|
1309
1364
|
if(r.collision!==undefined)check(Array.isArray(r.collision)&&r.collision.length===2&&uint(r.collision[0])&&uint(r.collision[1])&&r.collision[0]<=r.tick&&r.collision[1]===r.collision[0]+1);
|
|
1310
|
-
check(Object.keys(r).every(k => ['v', 'tick', 'epoch', 'rng', 'nextId', 'seq', 'round', 'overAt', 'match', 'trips', 'shared', 'policy', 'intent', 'asks', 'guideViews', 'ents', 'spawns', 'seats', 'queue', 'areas', 'ops', 'history', 'collision', 'maps'].includes(k)));
|
|
1365
|
+
check(Object.keys(r).every(k => ['v', 'tick', 'epoch', 'rng', 'nextId', 'seq', 'round', 'overAt', 'match', 'trips', 'shared', 'policy', 'intent', 'asks', 'guideViews', 'ents', 'spawns', 'seats', 'queue', 'areas', 'ops', 'history', 'collision', 'maps', 'physics'].includes(k)));
|
|
1311
1366
|
const askNames = new Set();
|
|
1312
1367
|
check(Array.isArray(r.asks));
|
|
1313
1368
|
for (const a of r.asks) {
|
|
@@ -1429,6 +1484,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1429
1484
|
}
|
|
1430
1485
|
}
|
|
1431
1486
|
const r = opts.restore;
|
|
1487
|
+
try {
|
|
1432
1488
|
if (r) {
|
|
1433
1489
|
if (r.v !== SAVE_REVISION) throw new Error('this save was written by another version of the runtime');
|
|
1434
1490
|
validateSave(r);
|
|
@@ -1466,6 +1522,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1466
1522
|
ops = r.ops as typeof ops;
|
|
1467
1523
|
if(hasColliders||mapRevision||pendingMap)collisionState();
|
|
1468
1524
|
} else startMatch();
|
|
1525
|
+
} catch (error) { physics?.dispose(); throw error; }
|
|
1469
1526
|
|
|
1470
1527
|
function decisionResult(value: unknown): Record<string, unknown> {
|
|
1471
1528
|
const v = plainData(value, { n: ANSWER_MAX });
|
|
@@ -1481,6 +1538,7 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1481
1538
|
return {
|
|
1482
1539
|
round: () => ({ t: 'round', ...round, ...(round.phase === 'over' && !overAt ? { results: results() } : {}) }),
|
|
1483
1540
|
get tick() { return tick; },
|
|
1541
|
+
reviseMovement,
|
|
1484
1542
|
get epoch() { return epoch; },
|
|
1485
1543
|
step,
|
|
1486
1544
|
answer: (n, result) => { const a = pendingAsks.find((x) => x.n === n); if (a && !a.result) a.result = decisionResult(result); },
|
|
@@ -1557,9 +1615,10 @@ export function createCore(c: Compiled, opts: { characterForSeat?: (seat: number
|
|
|
1557
1615
|
character: (seat) => { const e = playerBodies().find(e => e.seat === seat); return e ? JSON.parse(JSON.stringify({ kind: e.kind.name, at: e.pos, heading: e.heading, fields: e.f, motion: e.m })) : null; },
|
|
1558
1616
|
bodies: () => playerBodies().map((e) => ({ seat: e.seat, id: e.id, kind: e.kind.name, driver: e.driver, owner: e.owner, away: e.away, score: e.kind.score ? Number(e.f[e.kind.score]) || 0 : 0, r: e.r })),
|
|
1559
1617
|
listeners: () => playerBodies().filter((e) => e.driver === 'person').map((e) => ({ seat: e.seat, id: e.id, pos: e.pos })),
|
|
1560
|
-
bodyOf: (seat) => { const s = seats.get(seat); const e = s?.id ? ents.get(s.id) : null; return e ? { id: e.id, kind: e.kind, r: e.r, away: e.away, driver: e.driver } : null; },
|
|
1618
|
+
bodyOf: (seat) => { const s = seats.get(seat); const e = s?.id ? ents.get(s.id) : null; return e ? { id: e.id, kind: e.kind, r: e.r, away: e.away, controlled: !e.kind.player?.control || Boolean(e.m[e.kind.player.control]), driver: e.driver } : null; },
|
|
1561
1619
|
drain: () => { const o = out; out = []; return o; },
|
|
1562
1620
|
save,
|
|
1621
|
+
dispose: () => physics?.dispose(),
|
|
1563
1622
|
world,
|
|
1564
1623
|
stats,
|
|
1565
1624
|
};
|
package/rules/host.ts
CHANGED
|
@@ -135,8 +135,11 @@ export interface Host {
|
|
|
135
135
|
readonly core: Core;
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
interface Entry { at: number; values: Record<string, unknown>; presses: (string | [string, number])[]; claim: StepInput['claim']; /** How many ticks before its own tick it arrived (0.46.3): the time the input itself waited here. */ early?: number }
|
|
138
|
+
interface Entry { placement?: number; source?: number; at: number; values: Record<string, unknown>; presses: (string | [string, number])[]; claim: StepInput['claim']; /** How many ticks before its own tick it arrived (0.46.3): the time the input itself waited here. */ early?: number }
|
|
139
139
|
interface SeatIn {
|
|
140
|
+
heldAt?: number;
|
|
141
|
+
receipts?: [number, number][];
|
|
142
|
+
received?: number[];
|
|
140
143
|
/** A valued pulse, and the tick its browser predicted it on (0: none given, act at once). */
|
|
141
144
|
pulses?: [string,number,number?,number?][];
|
|
142
145
|
/** The wait of the entry now held, in ticks (see Entry.early). */
|
|
@@ -259,9 +262,11 @@ export function createHost(o: HostOptions): Host {
|
|
|
259
262
|
check(Array.isArray(saved.inputs) && saved.inputs.length <= c.seats);
|
|
260
263
|
for (const [seat, q] of saved.inputs) {
|
|
261
264
|
check(uint(seat) && seat < c.seats && q && valuesOk(seat, q.held) && claimOk(q.claim) && uint(q.newest) && uint(q.ack) && uint(q.lastFrameTick) && Number.isInteger(q.lead) && q.lead >= -127 && q.lead <= 127);
|
|
265
|
+
if(q.received!==undefined)check(Array.isArray(q.received)&&q.received.length<=256&&q.received.every(uint));
|
|
266
|
+
if(q.receipts!==undefined)check(Array.isArray(q.receipts)&&q.receipts.length<=64&&q.receipts.every(r=>Array.isArray(r)&&r.length===2&&Number.isSafeInteger(r[0])&&r[0]!==0&&uint(r[1])));
|
|
262
267
|
check(Array.isArray(q.stamps) && q.stamps.length <= 256 && q.stamps.every(uint) && Array.isArray(q.entries) && q.entries.length <= c.settings.tickHz + 1);
|
|
263
268
|
if(q.pulses!==undefined)check(Array.isArray(q.pulses)&&q.pulses.length<=16&&q.pulses.every(p=>Array.isArray(p)&&(p.length===2||p.length>=3&&p.length<=4&&uint(p[2])&&(p[3]===undefined||uint(p[3])))&&c.kinds.some(k=>k.input.some(([n,f])=>n===p[0]&&f.t==='pulse'))&&Number.isInteger(p[1])&&p[1]>0&&p[1]<=255));
|
|
264
|
-
for (const e of q.entries) check(uint(e.at) && valuesOk(seat, e.values) && claimOk(e.claim) && Array.isArray(e.presses) && e.presses.every((p) => c.kinds.some((k) => k.input.some(([name, fd]) => (typeof p === 'string' ? name === p && fd.t === 'press' : Array.isArray(p) && p.length === 2 && name === p[0] && fd.t === 'pulse' && Number.isInteger(p[1]) && p[1] > 0 && p[1] <= 255)))));
|
|
269
|
+
for (const e of q.entries) check((e.placement === undefined || uint(e.placement)) && uint(e.at) && valuesOk(seat, e.values) && claimOk(e.claim) && Array.isArray(e.presses) && e.presses.every((p) => c.kinds.some((k) => k.input.some(([name, fd]) => (typeof p === 'string' ? name === p && fd.t === 'press' : Array.isArray(p) && p.length === 2 && name === p[0] && fd.t === 'pulse' && Number.isInteger(p[1]) && p[1] > 0 && p[1] <= 255)))));
|
|
265
270
|
}
|
|
266
271
|
}
|
|
267
272
|
}
|
|
@@ -474,8 +479,8 @@ export function createHost(o: HostOptions): Host {
|
|
|
474
479
|
stats.ins += 1;
|
|
475
480
|
q.lastFrameTick = K;
|
|
476
481
|
// Lead: from this frame's arrival to the moment its first tick was due, in sixteenths of a tick. Negative is late.
|
|
477
|
-
const lead = Math.max(-127, Math.min(126, Math.round(((base.ms + (k - base.tick) * period - o.clock.now()) / period) * 16)));
|
|
478
|
-
|
|
482
|
+
const lead = Math.max(-127, Math.min(126, Math.round(((base.ms + (k + num(m.s.at(-1)?.[0]) - base.tick) * period - o.clock.now()) / period) * 16)));
|
|
483
|
+
|
|
479
484
|
const r = num(m.r) & 0xffff;
|
|
480
485
|
let before = -1;
|
|
481
486
|
for (const raw of (m.s as unknown[]).slice(0, 64)) {
|
|
@@ -485,10 +490,18 @@ export function createHost(o: HostOptions): Host {
|
|
|
485
490
|
before = off;
|
|
486
491
|
const j = (k + off) >>> 0;
|
|
487
492
|
// A duplicate: not above the newest stamp this seat has sent. It still counted as the player being there.
|
|
488
|
-
|
|
493
|
+
const marker = 1 + kind.input.length + (kind.body?.owner ? 9 : 0);
|
|
494
|
+
const seq = raw[marker] === -471 && Number.isSafeInteger(raw[marker+1]) && raw[marker+1] > 0 ? raw[marker+1] as number : 0;
|
|
495
|
+
// A discrete action sent before the client learns of a placement still
|
|
496
|
+
// belongs to its player. Held state from that old placement does not.
|
|
497
|
+
const action = kind.input.some(([, fd], index) => fd.t === 'press' && raw[index + 1] === 1);
|
|
498
|
+
if (seq && r !== body.r && !action) continue;
|
|
499
|
+
if (seq ? q.received?.includes(seq) : j <= q.newest) continue;
|
|
489
500
|
// Too early: more than a second ahead. This entry and the rest of the frame are dropped.
|
|
490
501
|
if (j > K + tickHz) { stats.dropped += 1; break; }
|
|
491
|
-
q.newest =
|
|
502
|
+
if(j >= q.newest && lead < q.lead)q.lead=lead;
|
|
503
|
+
q.newest = Math.max(q.newest,j);
|
|
504
|
+
if(seq){q.received??=[];q.received.push(seq);if(q.received.length>256)q.received.shift();}
|
|
492
505
|
const values: Record<string, unknown> = {};
|
|
493
506
|
const presses: (string | [string, number])[] = [];
|
|
494
507
|
let i = 1;
|
|
@@ -499,9 +512,22 @@ export function createHost(o: HostOptions): Host {
|
|
|
499
512
|
}
|
|
500
513
|
const claim = kind.body?.owner && raw.length >= i + 9 ? { pos: unpackVec([raw[i], raw[i + 1], raw[i + 2]], c.dims), vel: unpackVec([raw[i + 3], raw[i + 4], raw[i + 5]], c.dims), heading: unpackVec([raw[i + 6], raw[i + 7], raw[i + 8]], c.dims), r } : null;
|
|
501
514
|
q.stamps.push(j);
|
|
502
|
-
if
|
|
515
|
+
if(seq){q.receipts??=[];q.receipts.push([seq,j>K?j:K+1]);if(q.receipts.length>64)q.receipts.shift();}
|
|
516
|
+
if (j > K) { q.entries.push({ ...(seq ? { placement: body.r } : {}), source: j, at: j, values, presses, claim, early: Math.min(lateTicks, j - K) }); continue; }
|
|
503
517
|
// Late: its values hold from the next tick, and its presses fire there if that is within a quarter of a second of its stamp.
|
|
504
518
|
stats.lateEntries += 1;
|
|
519
|
+
if(seq){
|
|
520
|
+
if(!presses.length && K-j<tickHz && core.reviseMovement(seat as number,j,values))q.receipts![q.receipts!.length-1][1]=j;
|
|
521
|
+
const at=K+1;
|
|
522
|
+
const same=q.entries.find(e=>e.at===at);
|
|
523
|
+
if(j >= (q.heldAt??0) && (!same || j >= (same.source??0))){
|
|
524
|
+
if(same){same.placement=body.r;same.source=j;same.values=values;same.claim=claim;same.presses.push(...presses);}else q.entries.push({placement:body.r,source:j,at,values,claim,presses});
|
|
525
|
+
} else if(presses.length){
|
|
526
|
+
if(same)same.presses.push(...presses);else q.entries.push({placement:body.r,source:q.heldAt,at,values:{...q.held},claim:q.claim,presses});
|
|
527
|
+
}
|
|
528
|
+
q.entries.sort((a,b)=>a.at-b.at);
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
505
531
|
const book = heldBook.get(seat as number);
|
|
506
532
|
if (book) for (const row of book.ran) if (row[0] >= j) { for (const name of heldOf(kind)) { const meant = Number(values[name]) || 0; book.debt[name] = (book.debt[name] ?? 0) + meant - (row[1][name] ?? 0); row[1][name] = meant; } book.until = K + 1 + lateTicks; }
|
|
507
533
|
const at = (K + 1) >>> 0;
|
|
@@ -704,7 +730,8 @@ export function createHost(o: HostOptions): Host {
|
|
|
704
730
|
const presses: (string | [string, number])[] = [];
|
|
705
731
|
while (q.entries.length && q.entries[0].at <= t) {
|
|
706
732
|
const e = q.entries.shift() as Entry;
|
|
707
|
-
|
|
733
|
+
if (e.placement !== undefined && e.placement !== body.r) continue;
|
|
734
|
+
q.heldAt=e.source??e.at; q.held = e.values; q.claim = e.claim; q.early = e.early ?? 1;
|
|
708
735
|
if (e.at === t) for (const p of e.presses) presses.push(p);
|
|
709
736
|
}
|
|
710
737
|
// Silence: no frame for a second. Its input is neutral until the next entry.
|
|
@@ -723,7 +750,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
723
750
|
}
|
|
724
751
|
for (const p of presses) { if (typeof p === 'string') values[p] = true; else values[p[0]] = p[1]; }
|
|
725
752
|
// One pulse a tick, in order: the head waits for its own tick when that is no more than a quarter of a second ahead.
|
|
726
|
-
const head=q.pulses?.[0];if(head
|
|
753
|
+
const head=q.pulses?.[0];if(head&&(body.controlled||body.kind.player?.pulses!=='hold')&&(head[2]??0)<=t){q.pulses!.shift();values[head[0]]=head[1];if(head[3]){pulsesTaken.set(seat,head[3]);q.receipts??=[];q.receipts.push([-head[3],t]);if(q.receipts.length>64)q.receipts.shift();}}
|
|
727
754
|
inputs.set(seat, { values, claim: q.claim });
|
|
728
755
|
}
|
|
729
756
|
if (roundRebasePending) publishRebase(now);
|
|
@@ -760,6 +787,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
760
787
|
// ray may look right now, so its browser can predict against the pose the server will judge, not a guess at it.
|
|
761
788
|
if (historyTicks) row.push(taken ?? 0, Math.min(historyTicks, rewindTicks(b.seat)));
|
|
762
789
|
else if (taken !== undefined) row.push(taken);
|
|
790
|
+
if(q?.receipts){while(row.length<6)row.push(0);row.push(471,...q.receipts.flat());}
|
|
763
791
|
rows.push(row);
|
|
764
792
|
if (q) q.lead = 127;
|
|
765
793
|
}
|
|
@@ -932,6 +960,7 @@ export function createHost(o: HostOptions): Host {
|
|
|
932
960
|
}
|
|
933
961
|
function stop(): void {
|
|
934
962
|
running = false; paused = false; ended = true;
|
|
963
|
+
core.dispose();
|
|
935
964
|
disarm();
|
|
936
965
|
agents?.stop();
|
|
937
966
|
for (let i = recent.length - 1; i >= 0; i -= 1) if (recent[i].who === self) recent.splice(i, 1);
|
package/rules/math.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { physicsWorld } from './physics.ts';
|
|
2
|
+
import { substeps } from './substeps.ts';
|
|
1
3
|
import { wrapPoint } from './topology.mjs';
|
|
2
4
|
import { tileApi } from './tiles.ts';
|
|
3
5
|
import { nearbyMap } from './map-index.ts';
|
|
@@ -299,15 +301,22 @@ export function sweepMap(map: MapShapes, body: unknown, delta: unknown, radius:
|
|
|
299
301
|
* map. One function, so that the browser (view.ts) and the person the build check plays hold the same one. The
|
|
300
302
|
* server's own (core.ts `moveCtx`) is made beside the rest of its world, and charges as that does.
|
|
301
303
|
*/
|
|
304
|
+
const physicsContexts = new WeakMap<object, () => void>();
|
|
305
|
+
export function disposeMoveContext(context: object): void { physicsContexts.get(context)?.(); physicsContexts.delete(context); }
|
|
302
306
|
export function moveContext(o: { tick: () => number; tickHz: number; tune: unknown; map: MapShapes; name: string; spots: Readonly<Record<string, readonly unknown[]>>; radius: () => number; shape?: () => BodyShape; dims: number; geometry?: () => MapShapes; self?: () => string | undefined }): unknown {
|
|
307
|
+
const physics = physicsWorld((o.map as import('./rules.ts').GameMap).physics);
|
|
303
308
|
const none = Object.freeze([]);
|
|
304
309
|
const currentMap = () => (o.geometry?.()?.staticMap ?? o.geometry?.() ?? o.map) as import('./rules.ts').GameMap;
|
|
305
310
|
const queries = collisionQueries(() => o.geometry?.() ?? o.map, () => o.shape?.() ?? {shape: 'sphere', radius: o.radius(), height: 2 * o.radius()}, o.dims, o.self);
|
|
306
|
-
|
|
311
|
+
const context = brand(Object.freeze({
|
|
312
|
+
...(physics ? {physics: physics.movement} : {}),
|
|
313
|
+
substeps: substeps(o.tickHz),
|
|
307
314
|
get tick() { return o.tick(); }, dt: 1 / o.tickHz, tune: o.tune, math,
|
|
308
315
|
// As the server's `ctx.ticks` reads it: a plain number, or nothing.
|
|
309
316
|
ticks: (seconds: unknown): number => { const s = num(seconds); const n = Math.round(s * o.tickHz); return s > 0 && Number.isFinite(n) ? Math.max(1, n) : 0; },
|
|
310
317
|
world: brand(Object.freeze(queries)),
|
|
311
318
|
map: brand(Object.freeze({ ...tileApi(currentMap), name: o.name, spot: (name: string) => (own(currentMap().spots ?? o.spots, name) as readonly unknown[] | undefined)?.[0], spots: (name: string) => own(currentMap().spots ?? o.spots, name) ?? none, ...queries })),
|
|
312
319
|
}));
|
|
320
|
+
if (physics) physicsContexts.set(context, physics.dispose);
|
|
321
|
+
return context;
|
|
313
322
|
}
|
package/rules/pack.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { takeSubsteps, type StepPose } from './substeps.ts';
|
|
1
2
|
/*
|
|
2
3
|
* pack.ts — declared state in and out: holding a value to its declared type, and packing it for the wire and the save.
|
|
3
4
|
* =============================================================================
|
|
@@ -262,7 +263,7 @@ export interface MoveBody { pos: Vec3; vel: Vec3; heading: Vec3; grounded: boole
|
|
|
262
263
|
* left rounded to 32-bit floats and held to the declared shapes as the server does it, so both hold the same numbers.
|
|
263
264
|
* `used` is the units it took. What it throws goes to `failed` (the budget's own stop too) and the body is still read.
|
|
264
265
|
*/
|
|
265
|
-
export function stepMove(fn: (body: MoveBody, input: unknown, ctx: unknown) => void, body: MoveBody, input: Readonly<Record<string, unknown>>, ctx: unknown, quota: number, motion: FieldList, dims: number, failed: (error: unknown) => void): MoveBody & { used: number } {
|
|
266
|
+
export function stepMove(fn: (body: MoveBody, input: unknown, ctx: unknown) => void, body: MoveBody, input: Readonly<Record<string, unknown>>, ctx: unknown, quota: number, motion: FieldList, dims: number, failed: (error: unknown) => void): MoveBody & { used: number; steps?: readonly StepPose[] } {
|
|
266
267
|
G.queryEpoch++; G.left = quota;
|
|
267
268
|
const bag = coerceFields(motion, body.motion, dims);
|
|
268
269
|
const target: Record<string, unknown> = {};
|
|
@@ -296,7 +297,8 @@ export function stepMove(fn: (body: MoveBody, input: unknown, ctx: unknown) => v
|
|
|
296
297
|
const used = quota - (G.left > 0 ? G.left : 0);
|
|
297
298
|
G.left = Infinity;
|
|
298
299
|
naming('body', 'pos'); const pos = vec3(body.pos, dims); naming('body', 'vel'); const vel = vec3(body.vel, dims); naming('body', 'heading'); const heading = dir(body.heading, dims); naming();
|
|
299
|
-
|
|
300
|
+
const steps = takeSubsteps(body);
|
|
301
|
+
return { pos, vel, heading, grounded: body.grounded === true, motion: thawFields(motion, bag), used, ...(steps ? {steps} : {}) };
|
|
300
302
|
}
|
|
301
303
|
|
|
302
304
|
/** How many values `pack` has packed since this was last set to 0: the core reads it to charge a tick for the state it sends. */
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/** Loaded only by games declaring a baked physics level. */
|
|
2
|
+
import { createWorld } from '@homie-rocks/physics/World.js';
|
|
3
|
+
import { initPhysics } from '@homie-rocks/physics/Worker.js';
|
|
4
|
+
import { brand, charge } from './guard.ts';
|
|
5
|
+
import { registerPhysics, type PhysicsMovement, type RulesPhysics } from './physics.ts';
|
|
6
|
+
|
|
7
|
+
// CPU calibration is recorded in docs/physics-048-calibration.md. Charges precede work.
|
|
8
|
+
export const PHYSICS_UNITS = Object.freeze({ cast: 768, ray: 640, overlap: 640, step: 2400 });
|
|
9
|
+
export async function initializePhysics(source: WebAssembly.Module | BufferSource): Promise<void> {
|
|
10
|
+
await initPhysics(source);
|
|
11
|
+
registerPhysics((mesh, saved) => {
|
|
12
|
+
const world = createWorld({ up: 'z', fixedDt: 1 / 300, maxSubsteps: 128, bounds: null });
|
|
13
|
+
try {
|
|
14
|
+
if (saved) world.restore(Uint8Array.from(saved));
|
|
15
|
+
else world.createBody({ type: 'static', colliders: [{ shape: { kind: 'mesh', vertices: Float32Array.from(mesh.vertices), indices: Uint32Array.from(mesh.indices) } }] });
|
|
16
|
+
const count = Math.max(1, Math.ceil(mesh.indices.length / (1754 * 3)));
|
|
17
|
+
const convex = (shape: {kind:string}) => { if (!['box','sphere','capsule'].includes(shape.kind)) throw new Error('rules physics queries use a box, sphere or capsule'); };
|
|
18
|
+
const movement: PhysicsMovement = {
|
|
19
|
+
cast(...args) { charge(PHYSICS_UNITS.cast * count); convex(args[0]); return world.castShape(...args); },
|
|
20
|
+
ray(...args) { charge(PHYSICS_UNITS.ray * count); return world.raycast(...args); },
|
|
21
|
+
overlap(...args) { charge(PHYSICS_UNITS.overlap * count); convex(args[0]); return world.overlaps(...args); },
|
|
22
|
+
sweep(body, delta, radius, height) {
|
|
23
|
+
charge(64);
|
|
24
|
+
let left = { ...delta }, pos = { ...body.pos }, vel = { ...body.vel };
|
|
25
|
+
let grounded = false;
|
|
26
|
+
for (let n = 0; n < 4; n++) {
|
|
27
|
+
const length = Math.hypot(left.x, left.y, left.z);
|
|
28
|
+
if (length < 1e-9) break;
|
|
29
|
+
charge(PHYSICS_UNITS.cast * count);
|
|
30
|
+
const hit = world.castCapsule(pos, left, radius, height);
|
|
31
|
+
const fraction = hit ? Math.max(0, hit.fraction) : 1;
|
|
32
|
+
pos = { x: pos.x + left.x * fraction, y: pos.y + left.y * fraction, z: pos.z + left.z * fraction };
|
|
33
|
+
if (!hit) break;
|
|
34
|
+
if (hit.normal.z > .65 && delta.z <= 0) grounded = true;
|
|
35
|
+
const normal = hit.normal;
|
|
36
|
+
const dot = left.x * normal.x + left.y * normal.y + left.z * normal.z;
|
|
37
|
+
const speed = vel.x * normal.x + vel.y * normal.y + vel.z * normal.z;
|
|
38
|
+
if (normal.z > .65 && delta.z <= 0) {
|
|
39
|
+
// Lift the requested horizontal travel along a walkable slope.
|
|
40
|
+
// Projecting gravity onto its normal would make an idle capsule drift.
|
|
41
|
+
left = { x: left.x * (1-fraction), y: left.y * (1-fraction), z: 0 };
|
|
42
|
+
left.z = -(left.x * normal.x + left.y * normal.y) / normal.z;
|
|
43
|
+
vel = { x: vel.x, y: vel.y, z: Math.max(0, vel.z) };
|
|
44
|
+
} else {
|
|
45
|
+
left = { x: (left.x-normal.x*Math.min(0,dot))*(1-fraction), y: (left.y-normal.y*Math.min(0,dot))*(1-fraction), z: (left.z-normal.z*Math.min(0,dot))*(1-fraction) };
|
|
46
|
+
vel = { x:vel.x-normal.x*Math.min(0,speed), y:vel.y-normal.y*Math.min(0,speed), z:vel.z-normal.z*Math.min(0,speed) };
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
body.pos = pos; body.vel = vel; body.grounded = grounded;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
const api: RulesPhysics = {
|
|
53
|
+
...movement,
|
|
54
|
+
step(dt) {
|
|
55
|
+
charge(8);
|
|
56
|
+
if (!Number.isFinite(dt) || dt < 0) throw new Error('physics: dt must be finite and nonnegative');
|
|
57
|
+
charge(PHYSICS_UNITS.step * count * Math.max(1, Math.ceil(dt * 300)));
|
|
58
|
+
while (dt > .1) { world.step(.1); dt -= .1; }
|
|
59
|
+
world.step(dt);
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
let disposed = false, finalSave: number[] | undefined;
|
|
63
|
+
return { api: brand(Object.freeze(api)), movement: brand(Object.freeze(movement)),
|
|
64
|
+
save: () => finalSave ? [...finalSave] : Array.from(world.snapshot()),
|
|
65
|
+
dispose: () => {
|
|
66
|
+
if (disposed) return;
|
|
67
|
+
try { finalSave = Array.from(world.snapshot()); } catch { /* A failed engine has no final checkpoint. */ }
|
|
68
|
+
world.dispose(); disposed = true;
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
} catch (error) { world.dispose(); throw error; }
|
|
72
|
+
});
|
|
73
|
+
}
|
package/rules/physics.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Opt-in boundary. Importing the rules kernel never imports or initializes WASM. */
|
|
2
|
+
import type { Vec3 } from './rules.ts';
|
|
3
|
+
export interface PhysicsMesh { vertices: readonly number[]; indices: readonly number[] }
|
|
4
|
+
// Structural query types keep the ordinary rules types independent of the optional package.
|
|
5
|
+
export type PhysicsShape = {kind: 'box'; halfExtents: Vec3} | {kind: 'sphere'; radius: number} | {kind: 'capsule'; radius: number; halfHeight: number};
|
|
6
|
+
export interface PhysicsRotation {x: number; y: number; z: number; w: number}
|
|
7
|
+
export interface PhysicsFilter {layers?: {membership: number; filter: number}; excludeBody?: number; excludeCollider?: number; sensors?: boolean}
|
|
8
|
+
export interface PhysicsHit {collider: number; body: number; point: Vec3; normal: Vec3}
|
|
9
|
+
export interface PhysicsMovement {
|
|
10
|
+
sweep(body: {pos: Vec3; vel: Vec3; grounded: boolean}, delta: Vec3, radius: number, height: number): void;
|
|
11
|
+
ray(origin: Vec3, direction: Vec3, distance: number, filter?: PhysicsFilter): (PhysicsHit & {distance: number}) | null;
|
|
12
|
+
overlap(shape: PhysicsShape, position: Vec3, orientation?: PhysicsRotation, filter?: PhysicsFilter): number[];
|
|
13
|
+
cast(shape: PhysicsShape, position: Vec3, delta: Vec3, orientation?: PhysicsRotation, filter?: PhysicsFilter): (PhysicsHit & {fraction: number}) | null;
|
|
14
|
+
}
|
|
15
|
+
export interface RulesPhysics extends PhysicsMovement {
|
|
16
|
+
step(seconds: number): void;
|
|
17
|
+
}
|
|
18
|
+
export interface PhysicsBridge { api: RulesPhysics; movement: PhysicsMovement; save(): number[]; dispose(): void }
|
|
19
|
+
let factory: ((mesh: PhysicsMesh, saved?: number[]) => PhysicsBridge) | undefined;
|
|
20
|
+
export function registerPhysics(value: NonNullable<typeof factory>): void { factory = value; }
|
|
21
|
+
export function physicsWorld(mesh?: PhysicsMesh, saved?: number[]): PhysicsBridge | undefined {
|
|
22
|
+
if (!mesh) { if (saved !== undefined) throw new Error('physics save requires a physics level'); return; }
|
|
23
|
+
if (saved !== undefined && (!Array.isArray(saved) || saved.some(n => !Number.isInteger(n) || n < 0 || n > 255))) throw new Error('physics: invalid saved bytes');
|
|
24
|
+
if (!factory) throw new Error('physics: initialize the game before starting its room');
|
|
25
|
+
return factory(mesh, saved);
|
|
26
|
+
}
|