@irtio/client 0.7.0 → 0.8.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/dist/{chunk-XWVXZRBS.js → chunk-5Z4DHUA3.js} +1 -0
- package/dist/{chunk-DABQDR3S.js → chunk-XFD6WYQW.js} +7 -8
- package/dist/index.d.ts +370 -7
- package/dist/index.js +258 -5
- package/dist/{physics-RT5T36P5.js → physics-4SGKNBAC.js} +2 -2
- package/dist/physics-rapier2d-TBOAJMVM.js +186 -0
- package/dist/{physics2d-HOFMWPZV.js → physics2d-CNSEWKP4.js} +2 -2
- package/package.json +8 -3
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
RESIM_DEPTH,
|
|
6
6
|
SMOOTHING_HALF_LIFE_MS,
|
|
7
7
|
SMOOTHING_SNAP_UNITS
|
|
8
|
-
} from "./chunk-
|
|
8
|
+
} from "./chunk-5Z4DHUA3.js";
|
|
9
9
|
|
|
10
10
|
// src/predictor.ts
|
|
11
11
|
var MAX_FREE_STEPS_PER_FRAME = 5;
|
|
@@ -133,6 +133,7 @@ var Predictor = class _Predictor {
|
|
|
133
133
|
resimSteps: 0,
|
|
134
134
|
rebases: 0,
|
|
135
135
|
snaps: 0,
|
|
136
|
+
leadClamped: 0,
|
|
136
137
|
suppressed: 0,
|
|
137
138
|
overCap: 0,
|
|
138
139
|
proxies: 0,
|
|
@@ -951,8 +952,8 @@ var Predictor = class _Predictor {
|
|
|
951
952
|
* written, so plain state holds exactly what the server said), then re-step the world by the
|
|
952
953
|
* client's lead, applying to each re-stepped tick the intent that was in force *at that tick*:
|
|
953
954
|
* the newest buffered unjudged write stamped at or before it, or the baseline (the newest
|
|
954
|
-
* judged write) before the first of them. Bounded by `MAX_LEAD`: an outrun lead
|
|
955
|
-
*
|
|
955
|
+
* judged write) before the first of them. Bounded by `MAX_LEAD`: an outrun lead re-steps that
|
|
956
|
+
* many and counts (`stats.leadClamped`, bugs.md #71).
|
|
956
957
|
*/
|
|
957
958
|
rebase() {
|
|
958
959
|
if (!this.worldReady) return;
|
|
@@ -966,7 +967,9 @@ var Predictor = class _Predictor {
|
|
|
966
967
|
const k = key(entry.desc.name, entry.id);
|
|
967
968
|
this.predictedTicks.set(k, Math.max(this.authorityTick, this.authorityTicks.get(k) ?? 0));
|
|
968
969
|
}
|
|
969
|
-
const
|
|
970
|
+
const wanted = this.leadTicks();
|
|
971
|
+
const lead = Math.min(wanted, MAX_LEAD);
|
|
972
|
+
if (wanted > MAX_LEAD) this.stats.leadClamped++;
|
|
970
973
|
if (this.lastLead !== void 0 && lead !== this.lastLead && this.gapMeasured) {
|
|
971
974
|
const delta = lead - this.lastLead;
|
|
972
975
|
this.stampGap = Math.min(RESIM_DEPTH, Math.max(0, this.stampGap + delta));
|
|
@@ -975,10 +978,6 @@ var Predictor = class _Predictor {
|
|
|
975
978
|
}
|
|
976
979
|
this.lastLead = lead;
|
|
977
980
|
this.headTick = this.authorityTick;
|
|
978
|
-
if (lead > MAX_LEAD) {
|
|
979
|
-
this.stats.snaps++;
|
|
980
|
-
return;
|
|
981
|
-
}
|
|
982
981
|
const replays = /* @__PURE__ */ new Map();
|
|
983
982
|
for (const entry of this.bodies.values()) {
|
|
984
983
|
if (!entry.owned) continue;
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { AnySchema, PlainState, CollectionDesc, Delta, EntityCollection, PhysicsBodyChannel,
|
|
1
|
+
import { AnySchema, PlainState, CollectionDesc, Delta, EntityCollection, PhysicsBodyChannel, ClientImplementations, ClientRpcs, SchemaRpc, RoleOf, VisibleKeys, SchemaDefs, EntityDef, ReadonlyCollection, DeepReadonly, InferFields, Owned, SingletonDef, ClientCallProxy, MessageChannels } from '@irtio/schema';
|
|
2
2
|
import * as _irtio_protocol from '@irtio/protocol';
|
|
3
3
|
import { PresenceRecord, ProfileLedger, ProfileSnapshot } from '@irtio/protocol';
|
|
4
|
+
import RAPIER2D from '@dimforge/rapier2d-compat';
|
|
4
5
|
import * as MATTER from 'matter-js';
|
|
5
6
|
import RAPIER from '@dimforge/rapier3d-compat';
|
|
6
7
|
|
|
@@ -345,8 +346,21 @@ interface PredictionStats {
|
|
|
345
346
|
resimSteps: number;
|
|
346
347
|
/** Rebase passes (one per authoritative arrival batch). */
|
|
347
348
|
rebases: number;
|
|
348
|
-
/**
|
|
349
|
+
/**
|
|
350
|
+
* Rebases that rendered every body at authority. Since bugs.md #71 an outrun lead clamps
|
|
351
|
+
* instead of snapping (`leadClamped`), so nothing increments this today; the field stays
|
|
352
|
+
* because a zero here is a fence several suites hold, and a future hard-snap path would count
|
|
353
|
+
* here again.
|
|
354
|
+
*/
|
|
349
355
|
snaps: number;
|
|
356
|
+
/**
|
|
357
|
+
* Rebases whose lead outran `MAX_LEAD` and were re-stepped that many ticks instead (bugs.md
|
|
358
|
+
* #71). The local character still answers input; the anchor sits closer to authority than the
|
|
359
|
+
* round trip wants, so releases overshoot by the difference. A value that climbs and keeps
|
|
360
|
+
* climbing means the measured rtt is past the cap's horizon (667 ms at 60 Hz) — degraded
|
|
361
|
+
* prediction, where a snap would have been none.
|
|
362
|
+
*/
|
|
363
|
+
leadClamped: number;
|
|
350
364
|
/** Corrections whose values matched the local prediction within epsilon. */
|
|
351
365
|
suppressed: number;
|
|
352
366
|
/**
|
|
@@ -788,8 +802,8 @@ declare class Predictor {
|
|
|
788
802
|
* written, so plain state holds exactly what the server said), then re-step the world by the
|
|
789
803
|
* client's lead, applying to each re-stepped tick the intent that was in force *at that tick*:
|
|
790
804
|
* the newest buffered unjudged write stamped at or before it, or the baseline (the newest
|
|
791
|
-
* judged write) before the first of them. Bounded by `MAX_LEAD`: an outrun lead
|
|
792
|
-
*
|
|
805
|
+
* judged write) before the first of them. Bounded by `MAX_LEAD`: an outrun lead re-steps that
|
|
806
|
+
* many and counts (`stats.leadClamped`, bugs.md #71).
|
|
793
807
|
*/
|
|
794
808
|
private rebase;
|
|
795
809
|
/**
|
|
@@ -826,6 +840,126 @@ declare class Predictor {
|
|
|
826
840
|
private isF32;
|
|
827
841
|
}
|
|
828
842
|
|
|
843
|
+
/**
|
|
844
|
+
* The rapier2d half of client-side physics prediction: `joinRoom({ physics2d: { engine:
|
|
845
|
+
* 'rapier2d', … } })`'s option type, the lazy `@dimforge/rapier2d-compat` load, and the adapter
|
|
846
|
+
* that is the only place in the client where a rapier2d type is touched.
|
|
847
|
+
*
|
|
848
|
+
* The loop is `predictor.ts`, shared byte for byte with the other two engines. This file is the
|
|
849
|
+
* seam list, and it is transcribed from `physics.ts` — the Rapier one — rather than from
|
|
850
|
+
* `physics2d.ts`, for the same reason `core/rapier2d.ts` is transcribed from `core/physics.ts`:
|
|
851
|
+
*
|
|
852
|
+
* - **The world applies gravity**, so there is no `settle` hook here and there must not be one.
|
|
853
|
+
* `EngineAdapter.settle` exists for matter2d, whose engine gravity is usually zero and whose
|
|
854
|
+
* rooms apply it per body; a rapier2d world that gained one would fall twice as fast on the
|
|
855
|
+
* client and be corrected every tick.
|
|
856
|
+
* - **Velocities are per second**, so `velocityTolerance` is `epsilon / dt`, the 3D answer, not
|
|
857
|
+
* matter's per-step one.
|
|
858
|
+
* - **The step is `world.step()`** against a `world.timestep` fixed once at start.
|
|
859
|
+
*
|
|
860
|
+
* What it takes from the 2D side is only the plane: `{ x, y }` gravity, a scalar rotation and a
|
|
861
|
+
* scalar spin, mapped onto the thirteen 3D pose numbers through `@irtio/schema`'s `channelOf2d` /
|
|
862
|
+
* `applyChannel2d` / `angleFrom2d`. That mapping is written once, in the schema package, and this
|
|
863
|
+
* file reads it rather than restating it — a second copy is a way for the two sides to disagree
|
|
864
|
+
* by a sign.
|
|
865
|
+
*
|
|
866
|
+
* There is no planar-lock warning here (bug 6's friction trap needs a third axis to lock, and this
|
|
867
|
+
* engine has none), and the engine is loaded lazily, so a game predicting with matter2d or rapier3d
|
|
868
|
+
* never pulls the 2D WASM in.
|
|
869
|
+
*/
|
|
870
|
+
|
|
871
|
+
type ClientRapier2dModule = typeof RAPIER2D;
|
|
872
|
+
type ClientRapier2dWorld = RAPIER2D.World;
|
|
873
|
+
type ClientRapier2dBody = RAPIER2D.RigidBody;
|
|
874
|
+
interface ClientVector2d {
|
|
875
|
+
readonly x: number;
|
|
876
|
+
readonly y: number;
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* What a client-side rapier2d body factory returns — the same shape the room config's factories
|
|
880
|
+
* use (`Rapier2dBodySpec` in `@irtio/server`). Rapier bodies *have* colliders rather than being
|
|
881
|
+
* their geometry, which is the one structural difference from the matter2d spec.
|
|
882
|
+
*/
|
|
883
|
+
interface ClientRapier2dBodySpec {
|
|
884
|
+
readonly body: RAPIER2D.RigidBodyDesc;
|
|
885
|
+
readonly colliders?: readonly RAPIER2D.ColliderDesc[];
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Method-syntax members check bivariantly, so a builder's factory or intent hook written against
|
|
889
|
+
* its own instance type (`(body, ball: Ball) => …`) is accepted — the values really passed are
|
|
890
|
+
* the schema's records for that collection.
|
|
891
|
+
*/
|
|
892
|
+
type ClientRapier2dBodyFactory = {
|
|
893
|
+
factory(rapier: ClientRapier2dModule, instance: AnyRecord$2, id: string): ClientRapier2dBodySpec;
|
|
894
|
+
}['factory'];
|
|
895
|
+
/**
|
|
896
|
+
* The client's half of `Rapier2dIntentHook`. Same five parameters in the same order as the server
|
|
897
|
+
* type, so one hook exported from a shared world module typechecks against both sides without a
|
|
898
|
+
* cast.
|
|
899
|
+
*/
|
|
900
|
+
type ClientRapier2dIntentHook = {
|
|
901
|
+
hook(body: ClientRapier2dBody, instance: AnyRecord$2, rapier: ClientRapier2dModule, world: ClientRapier2dWorld, timestep: number): void;
|
|
902
|
+
}['hook'];
|
|
903
|
+
/**
|
|
904
|
+
* `joinRoom({ physics2d: { engine: 'rapier2d', … } })` — the client half of the shared
|
|
905
|
+
* world-builder contract for a rapier2d room.
|
|
906
|
+
*
|
|
907
|
+
* It rides inside the `physics2d` option rather than beside it because it is the same *option*:
|
|
908
|
+
* a planar world, a planar gravity, one 2D engine. `engine` is the discriminant, and it is
|
|
909
|
+
* required here and optional (defaulting to `'matter2d'`) on the matter2d member, so every
|
|
910
|
+
* matter2d room written before this engine existed still compiles unchanged.
|
|
911
|
+
*
|
|
912
|
+
* There is no `settle`. See the module docblock.
|
|
913
|
+
*/
|
|
914
|
+
interface ClientRapier2dOptions {
|
|
915
|
+
/** The discriminant. Required: `physics2d` without one is a matter2d world. */
|
|
916
|
+
readonly engine: 'rapier2d';
|
|
917
|
+
/** Must equal the room config's gravity. Rapier's convention is y-up; nothing flips it. */
|
|
918
|
+
readonly gravity: ClientVector2d;
|
|
919
|
+
/** Seconds per step. Defaults to the room's tick interval (from `WELCOME`). */
|
|
920
|
+
readonly timestep?: number;
|
|
921
|
+
/** The shared static-geometry builder (the room's `physics.setup`). */
|
|
922
|
+
readonly setup?: (world: ClientRapier2dWorld, rapier: ClientRapier2dModule) => void;
|
|
923
|
+
/** Shape factories for the collections this client predicts (the room's `physics.bodies`). */
|
|
924
|
+
readonly bodies?: Readonly<Record<string, ClientRapier2dBodyFactory>>;
|
|
925
|
+
/**
|
|
926
|
+
* Intent → force, applied before every predicted step for bodies this client owns — the same
|
|
927
|
+
* function the room's `tick()` calls per instance, shared so both simulations agree.
|
|
928
|
+
*/
|
|
929
|
+
readonly intents?: Readonly<Record<string, ClientRapier2dIntentHook>>;
|
|
930
|
+
/**
|
|
931
|
+
* D21 cap: how many **non-owned** predicted bodies this client simulates ahead. Default 64.
|
|
932
|
+
* Over-cap instances get a kinematic proxy instead (see `maxProxyBodies`), so a predicted body
|
|
933
|
+
* still stands on them. Counted as `stats.overCap`.
|
|
934
|
+
*/
|
|
935
|
+
readonly maxPredictedBodies?: number;
|
|
936
|
+
/**
|
|
937
|
+
* D71 cap: how many **kinematic proxies** this client keeps. Default `MAX_PROXY_BODIES`. Past
|
|
938
|
+
* it, instances are absent from the local world and predicted bodies pass through them (warned,
|
|
939
|
+
* counted as `stats.absent`). See `ClientPhysicsOptions.maxProxyBodies` for why it is its own
|
|
940
|
+
* number.
|
|
941
|
+
*/
|
|
942
|
+
readonly maxProxyBodies?: number;
|
|
943
|
+
/**
|
|
944
|
+
* A body-field correction whose every value is within this tolerance of the local prediction is
|
|
945
|
+
* *suppressed*: authority still applies, but it is not a misprediction. Positions compare
|
|
946
|
+
* against `epsilon` world units; velocity channels against `epsilon / timestep`, because a
|
|
947
|
+
* rapier2d velocity is per second. Default 0.05, which is sized for a metre-scale world.
|
|
948
|
+
*/
|
|
949
|
+
readonly epsilon?: number;
|
|
950
|
+
/**
|
|
951
|
+
* How fast the drawn position eases back onto the simulation after a re-simulation moved it, as
|
|
952
|
+
* a half-life in milliseconds. `0` turns the smoothing off. Default 70. See
|
|
953
|
+
* `ClientPhysicsOptions.smoothingHalfLifeMs` for why this smooths the error and not the motion.
|
|
954
|
+
*/
|
|
955
|
+
readonly smoothingHalfLifeMs?: number;
|
|
956
|
+
/**
|
|
957
|
+
* How far the drawn position may be held from the simulation while an offset eases away, in
|
|
958
|
+
* world units. Past it the offset is dropped and the body appears where it is. Default 4.
|
|
959
|
+
*/
|
|
960
|
+
readonly smoothingSnapUnits?: number;
|
|
961
|
+
}
|
|
962
|
+
|
|
829
963
|
/**
|
|
830
964
|
* The matter2d half of client-side physics prediction (D45, D57): `joinRoom({ physics2d })`'s
|
|
831
965
|
* option type, the lazy `matter-js` load, and the adapter that is the only place in the client
|
|
@@ -900,6 +1034,11 @@ type ClientIntent2dHook = {
|
|
|
900
1034
|
* Every function here should be the very export the room config imports.
|
|
901
1035
|
*/
|
|
902
1036
|
interface ClientPhysics2dOptions {
|
|
1037
|
+
/**
|
|
1038
|
+
* The `physics2d` discriminant. Optional and defaulting to `'matter2d'`, so every matter2d
|
|
1039
|
+
* client written before rapier2d existed keeps compiling and keeps predicting with matter.
|
|
1040
|
+
*/
|
|
1041
|
+
readonly engine?: 'matter2d';
|
|
903
1042
|
/** Must equal the room config's gravity, in matter's own convention (y is down). */
|
|
904
1043
|
readonly gravity: ClientVector2;
|
|
905
1044
|
/** Seconds per step. Defaults to the room's tick interval (from `WELCOME`). */
|
|
@@ -1505,7 +1644,7 @@ interface JoinOptions<S, Role extends string = string> {
|
|
|
1505
1644
|
* take different functions; passing both is an error at join. The wire carries no engine
|
|
1506
1645
|
* name, so passing the one that does not match the room is a game bug the client cannot see.
|
|
1507
1646
|
*/
|
|
1508
|
-
readonly physics2d?: ClientPhysics2dOptions;
|
|
1647
|
+
readonly physics2d?: ClientPhysics2dOptions | ClientRapier2dOptions;
|
|
1509
1648
|
/** @internal */
|
|
1510
1649
|
readonly transport?: Transport;
|
|
1511
1650
|
/** @internal */
|
|
@@ -1934,6 +2073,230 @@ declare function matchRoom<S extends AnySchema, Role extends string = RoleOf<S>
|
|
|
1934
2073
|
} & {
|
|
1935
2074
|
readonly identity?: boolean;
|
|
1936
2075
|
}): Promise<Room<S, Role>>;
|
|
2076
|
+
/** What a `mode: "public"` answer carries. */
|
|
2077
|
+
interface PublicTicket {
|
|
2078
|
+
readonly room: string;
|
|
2079
|
+
readonly queue: string;
|
|
2080
|
+
/** How many players the queue this lobby belongs to seats. */
|
|
2081
|
+
readonly size: number;
|
|
2082
|
+
/** True when this caller minted the room rather than joining one somebody else was waiting in. */
|
|
2083
|
+
readonly created: boolean;
|
|
2084
|
+
}
|
|
2085
|
+
/**
|
|
2086
|
+
* Asks the control plane for a public room of `queue`: the oldest open one, or a fresh code.
|
|
2087
|
+
*
|
|
2088
|
+
* The lower half of {@link joinPublic}, exported because the element's chooser is driven by events
|
|
2089
|
+
* rather than by a call, and an app that wires its own **Join a game** button may want the code
|
|
2090
|
+
* without the join.
|
|
2091
|
+
*
|
|
2092
|
+
* `exclude` names a room this caller already failed to join, so the second answer is a different
|
|
2093
|
+
* one. See `joinPublic` for when that happens.
|
|
2094
|
+
*/
|
|
2095
|
+
declare function findPublic(project: string, options?: Pick<MatchOptions, 'queue' | 'controlUrl' | 'fetch'> & {
|
|
2096
|
+
readonly identity?: string;
|
|
2097
|
+
readonly exclude?: string;
|
|
2098
|
+
}): Promise<PublicTicket>;
|
|
2099
|
+
/**
|
|
2100
|
+
* Join a game: land in the next open public lobby, or open one and wait in it.
|
|
2101
|
+
*
|
|
2102
|
+
* ```ts
|
|
2103
|
+
* const room = await joinPublic(schema); // the default queue
|
|
2104
|
+
* const room = await joinPublic(schema, { queue: '4p', identity: true });
|
|
2105
|
+
* ```
|
|
2106
|
+
*
|
|
2107
|
+
* This is the other shape of matchmaking and it is deliberately not `matchRoom`. `matchRoom`
|
|
2108
|
+
* holds a long poll until a whole party is there and answers `E_NO_MATCH` if one never is;
|
|
2109
|
+
* `joinPublic` resolves on the first round trip and puts the player in a room that says "1/4
|
|
2110
|
+
* players". Waiting in a lobby you can see is a different experience from waiting on a spinner,
|
|
2111
|
+
* and it is the one a **Join a game** button should give.
|
|
2112
|
+
*
|
|
2113
|
+
* Nothing about the join differs from a friend sharing a link — the room neither knows nor cares
|
|
2114
|
+
* that the registry sent you — which is the property every part of this feature is built on.
|
|
2115
|
+
*
|
|
2116
|
+
* **The one retry.** The registry answers from an occupancy reading a few seconds old, so the room
|
|
2117
|
+
* may have filled on the way there. That is refused with `E_ROOM_FULL`, and the honest recovery is
|
|
2118
|
+
* to ask once more, naming the room that did not work so the second answer cannot be the same one.
|
|
2119
|
+
* Once, and never a loop: a retry that kept going would turn a busy game into a client that
|
|
2120
|
+
* hammers control, and a second failure means something a third attempt will not fix.
|
|
2121
|
+
*/
|
|
2122
|
+
declare function joinPublic<S extends AnySchema, Role extends string = RoleOf<S> & string>(schema: S, options?: Pick<MatchOptions, 'queue' | 'controlUrl' | 'fetch'> & JoinOptions<S, Role> & {
|
|
2123
|
+
role?: RoleOf<S> & string;
|
|
2124
|
+
} & {
|
|
2125
|
+
readonly identity?: boolean;
|
|
2126
|
+
}): Promise<Room<S, Role>>;
|
|
2127
|
+
|
|
2128
|
+
/**
|
|
2129
|
+
* D75 (M6 lane H): the client's half of the lobby, and the glue that wires `<irt-lobby>` up.
|
|
2130
|
+
*
|
|
2131
|
+
* Two exports and a clean division between them.
|
|
2132
|
+
*
|
|
2133
|
+
* `lobbyOf(room)` is the **view**: it reads the lobby fragment out of ordinary room state and
|
|
2134
|
+
* answers the structural shape `@irtio/lobby`'s `AttachableRoom.lobby` asks for. It is a reader
|
|
2135
|
+
* plus one owner write (the ready flag), and it holds nothing the room does not already say.
|
|
2136
|
+
*
|
|
2137
|
+
* `attachLobby(element, schema, options)` is the **glue**: it listens for the panel's two chooser
|
|
2138
|
+
* events, makes the calls the panel deliberately cannot make itself, and attaches the room it gets
|
|
2139
|
+
* back. Sugar, not capability — the same rule `matchRoom` is held to. An app that wants to answer
|
|
2140
|
+
* the events itself keeps every part of this and needs none of it.
|
|
2141
|
+
*
|
|
2142
|
+
* ## Why the view polls
|
|
2143
|
+
*
|
|
2144
|
+
* `room.on(...)` has no "state changed" event: the client SDK publishes `status`, `clients`, `rtt`,
|
|
2145
|
+
* `error` and `correct`, and lobby state is ordinary entity state that arrives in a `DELTA` like
|
|
2146
|
+
* everything else. So `on('change')` samples — on every `clients` event, and on a cheap timer in
|
|
2147
|
+
* between — and fires only when a small signature actually differs.
|
|
2148
|
+
*
|
|
2149
|
+
* That is a real cost and it is written here rather than hidden: one shallow string build per
|
|
2150
|
+
* sample, over at most `maxClients` records, for as long as somebody is watching. It is the honest
|
|
2151
|
+
* price of the element importing nothing from this package. A `state` event on `Room` would replace
|
|
2152
|
+
* the timer with a subscription and is recorded as a debt in the lane report rather than smuggled
|
|
2153
|
+
* into this lane's protocol-free budget.
|
|
2154
|
+
*/
|
|
2155
|
+
|
|
2156
|
+
/**
|
|
2157
|
+
* The two collection names the lobby fragment declares.
|
|
2158
|
+
*
|
|
2159
|
+
* Literals here rather than an import from `@irtio/server`, which is where they are defined. The
|
|
2160
|
+
* client SDK ships to browsers and depends on `@irtio/schema` and `@irtio/protocol` alone; taking
|
|
2161
|
+
* a dependency on the room-file API to read two strings would put the whole of it in every game's
|
|
2162
|
+
* bundle. This is the same fence `@irtio/server`'s own `MAX_AWAKE_MAX` sits behind, and it is
|
|
2163
|
+
* pinned the same way: `test/lobby-view.test.ts` imports both and asserts the pairs equal, from a
|
|
2164
|
+
* package that legitimately depends on both, so a rename is a failing test rather than a lobby
|
|
2165
|
+
* that silently never appears.
|
|
2166
|
+
*/
|
|
2167
|
+
declare const LOBBY_STATE = "irtLobby";
|
|
2168
|
+
declare const LOBBY_MEMBERS = "irtLobbyMembers";
|
|
2169
|
+
/** How often the view samples for a change between `clients` events. */
|
|
2170
|
+
declare const LOBBY_POLL_MS = 120;
|
|
2171
|
+
/** One row of the lobby roster, in join order. */
|
|
2172
|
+
interface LobbyPlayer {
|
|
2173
|
+
readonly clientId: string;
|
|
2174
|
+
readonly ready: boolean;
|
|
2175
|
+
readonly connected: boolean;
|
|
2176
|
+
/** True for the row belonging to the client holding this view. */
|
|
2177
|
+
readonly me: boolean;
|
|
2178
|
+
}
|
|
2179
|
+
/** What `lobbyOf` answers: `@irtio/lobby`'s structural `lobby` member, with names. */
|
|
2180
|
+
interface LobbyView {
|
|
2181
|
+
readonly phase: 'lobby' | 'started';
|
|
2182
|
+
readonly players: readonly LobbyPlayer[];
|
|
2183
|
+
readonly public: boolean;
|
|
2184
|
+
readonly readyUi: boolean;
|
|
2185
|
+
/** How many the room starts at under `'when-full'`, or 0 when the room has not said. */
|
|
2186
|
+
readonly capacity: number;
|
|
2187
|
+
ready(value: boolean): void;
|
|
2188
|
+
setPublic?(value: boolean): void;
|
|
2189
|
+
on(event: 'change', cb: () => void): () => void;
|
|
2190
|
+
}
|
|
2191
|
+
interface StateLike {
|
|
2192
|
+
[collection: string]: unknown;
|
|
2193
|
+
}
|
|
2194
|
+
interface RoomLike {
|
|
2195
|
+
readonly me: string;
|
|
2196
|
+
readonly state: StateLike;
|
|
2197
|
+
readonly clients: readonly {
|
|
2198
|
+
clientId: string;
|
|
2199
|
+
connected: boolean;
|
|
2200
|
+
}[];
|
|
2201
|
+
on(event: 'clients', cb: (clients: readonly {
|
|
2202
|
+
clientId: string;
|
|
2203
|
+
connected: boolean;
|
|
2204
|
+
}[]) => void): () => void;
|
|
2205
|
+
flush(): void;
|
|
2206
|
+
}
|
|
2207
|
+
/**
|
|
2208
|
+
* Does this room carry a lobby?
|
|
2209
|
+
*
|
|
2210
|
+
* A schema that did not spread `lobbyCollections` has neither collection, and the honest answer for
|
|
2211
|
+
* such a room is that it has no lobby rather than an empty one — `<irt-lobby>` renders no lobby UI
|
|
2212
|
+
* at all in that case, which is what keeps every existing consumer unchanged.
|
|
2213
|
+
*/
|
|
2214
|
+
declare function hasLobby(room: unknown): boolean;
|
|
2215
|
+
/**
|
|
2216
|
+
* Reads a joined room's lobby.
|
|
2217
|
+
*
|
|
2218
|
+
* ```ts
|
|
2219
|
+
* const room = await joinPublic(schema);
|
|
2220
|
+
* lobby.attach(room, { lobby: lobbyOf(room) });
|
|
2221
|
+
* ```
|
|
2222
|
+
*
|
|
2223
|
+
* `setPublic` is present only when the caller supplies one, and that is a consequence of there
|
|
2224
|
+
* being no built-in RPC for it. The platform never takes a public toggle from a client — that
|
|
2225
|
+
* would be a protocol change — so the room's own game code owns the door. A game that wants the
|
|
2226
|
+
* panel's toggle to work declares its own RPC and passes it here:
|
|
2227
|
+
*
|
|
2228
|
+
* ```ts
|
|
2229
|
+
* lobbyOf(room, { setPublic: (value) => void room.call.setPublic({ value }) })
|
|
2230
|
+
* ```
|
|
2231
|
+
*
|
|
2232
|
+
* Without one, the panel renders the room's public state and no toggle, which is the truthful
|
|
2233
|
+
* rendering of a room whose code has not offered the control.
|
|
2234
|
+
*/
|
|
2235
|
+
declare function lobbyOf(room: unknown, options?: {
|
|
2236
|
+
readonly setPublic?: (value: boolean) => void;
|
|
2237
|
+
}): LobbyView | undefined;
|
|
2238
|
+
/** What the panel's `quickmatch` event carries. */
|
|
2239
|
+
interface QuickMatchDetail {
|
|
2240
|
+
readonly queue?: string;
|
|
2241
|
+
}
|
|
2242
|
+
/** The element surface `attachLobby` needs. Structural, so a test can pass a stand-in. */
|
|
2243
|
+
interface LobbyElementLike {
|
|
2244
|
+
addEventListener(type: string, cb: (event: Event) => void): void;
|
|
2245
|
+
removeEventListener(type: string, cb: (event: Event) => void): void;
|
|
2246
|
+
setAttribute(name: string, value: string): void;
|
|
2247
|
+
removeAttribute(name: string): void;
|
|
2248
|
+
attach(room: unknown): () => void;
|
|
2249
|
+
}
|
|
2250
|
+
/**
|
|
2251
|
+
* The room, as `<irt-lobby>`'s structural `AttachableRoom` wants it: everything the panel already
|
|
2252
|
+
* read, plus the optional `lobby` member the design's §6 adds.
|
|
2253
|
+
*
|
|
2254
|
+
* A wrapper of getters rather than a spread, because a `Room`'s fields are accessors on a class:
|
|
2255
|
+
* `{ ...room }` would freeze `status`, `id` and `clients` at the moment of the copy, and the panel
|
|
2256
|
+
* re-reads all three on every render precisely so a reconnect that changes the code is picked up.
|
|
2257
|
+
* Mutating the room to hang a `lobby` on it was the other option and is worse — the SDK's own
|
|
2258
|
+
* object would then carry a field its type does not declare.
|
|
2259
|
+
*/
|
|
2260
|
+
declare function attachable(room: RoomLike & {
|
|
2261
|
+
id: string;
|
|
2262
|
+
link: string;
|
|
2263
|
+
status: string;
|
|
2264
|
+
maxClients?: number;
|
|
2265
|
+
rtt?: number;
|
|
2266
|
+
}, view: LobbyView | undefined): unknown;
|
|
2267
|
+
interface AttachLobbyOptions<S extends AnySchema, Role extends string> {
|
|
2268
|
+
/** Passed through to `joinRoom` / `joinPublic`. */
|
|
2269
|
+
readonly join?: JoinOptions<S, Role> & {
|
|
2270
|
+
role?: RoleOf<S> & string;
|
|
2271
|
+
};
|
|
2272
|
+
/** Passed through to the control-plane call. */
|
|
2273
|
+
readonly match?: Pick<MatchOptions, 'controlUrl' | 'fetch'> & {
|
|
2274
|
+
readonly identity?: boolean;
|
|
2275
|
+
};
|
|
2276
|
+
/** The room's own public-toggle RPC, if it has one. See `lobbyOf`. */
|
|
2277
|
+
readonly setPublic?: (room: Room<S, Role>, value: boolean) => void;
|
|
2278
|
+
/** Called with the room once it is joined, so game code can start drawing. */
|
|
2279
|
+
readonly onRoom?: (room: Room<S, Role>) => void;
|
|
2280
|
+
/** Called when a join fails. Default: the panel's status goes back to idle and the error is
|
|
2281
|
+
* rethrown on the microtask queue so it reaches `window.onerror` rather than vanishing. */
|
|
2282
|
+
readonly onError?: (err: unknown) => void;
|
|
2283
|
+
}
|
|
2284
|
+
/**
|
|
2285
|
+
* Wires `<irt-lobby>`'s chooser to the two calls it deliberately cannot make itself.
|
|
2286
|
+
*
|
|
2287
|
+
* ```ts
|
|
2288
|
+
* attachLobby(document.querySelector('irt-lobby')!, schema, { onRoom: (room) => start(room) });
|
|
2289
|
+
* ```
|
|
2290
|
+
*
|
|
2291
|
+
* The panel emits and this answers: `private` becomes an ordinary `joinRoom` with no code (which
|
|
2292
|
+
* creates one), `quickmatch` becomes `joinPublic`. Both then `attach` the room, exactly as an app
|
|
2293
|
+
* doing it by hand would. Returns an unsubscribe.
|
|
2294
|
+
*
|
|
2295
|
+
* `@irtio/lobby` imports nothing from this package and never will — that is what lets a React or
|
|
2296
|
+
* Svelte app drive the panel with attributes and no SDK. This function is the other side of that
|
|
2297
|
+
* rule rather than an exception to it: the dependency points this way.
|
|
2298
|
+
*/
|
|
2299
|
+
declare function attachLobby<S extends AnySchema, Role extends string = RoleOf<S> & string>(element: LobbyElementLike, schema: S, options?: AttachLobbyOptions<S, Role>): () => void;
|
|
1937
2300
|
|
|
1938
2301
|
/**
|
|
1939
2302
|
* The default `Scheduler`. In a browser the write batcher aligns to `requestAnimationFrame` (one
|
|
@@ -2135,7 +2498,7 @@ interface SessionOptions {
|
|
|
2135
2498
|
/** The shared world-builder half the client predicts with (D22 part 2). */
|
|
2136
2499
|
readonly physics?: ClientPhysicsOptions | undefined;
|
|
2137
2500
|
/** The same, for a matter2d room (D57). Never both; `joinRoom` refuses that. */
|
|
2138
|
-
readonly physics2d?: ClientPhysics2dOptions | undefined;
|
|
2501
|
+
readonly physics2d?: ClientPhysics2dOptions | ClientRapier2dOptions | undefined;
|
|
2139
2502
|
readonly transport?: Transport | undefined;
|
|
2140
2503
|
readonly scheduler?: Scheduler | undefined;
|
|
2141
2504
|
readonly onFrame?: FrameHook | undefined;
|
|
@@ -2586,4 +2949,4 @@ declare function joinRoom<S extends AnySchema, Role extends string = RoleOf<S> &
|
|
|
2586
2949
|
*/
|
|
2587
2950
|
declare function joinRelay<S extends AnySchema = never>(options?: JoinRelayOptions<S extends AnySchema ? S : AnySchema>): Promise<RelayRoom<S>>;
|
|
2588
2951
|
|
|
2589
|
-
export { ACCOUNT_STORAGE_KEY, CALL_TIMEOUT_MS, type ClientBody2dFactory, type ClientBody2dSpec, type ClientBodySpec, type ClientCollection, type ClientIntent2dHook, type ClientMatterBody, type ClientMatterConstraint, type ClientMatterEngine, type ClientMatterModule, type ClientPhysics2dOptions, type ClientPhysicsOptions, type ClientRapierBody, type ClientRapierModule, type ClientRapierWorld, type ClientState, ClientStore, type ClientVector2, type ClientVector3, type Correction, DEFAULT_CONTROL_URL, DEFAULT_REGION, DEFAULT_WRITE_INTERVAL_MS, DEV_PORT, E_CONNECT_FAILED, E_IDENTITY_RATE_LIMITED, type FrameHook, INTERNAL_VOICE_TRACKS, Identity, IdentityError, type IdentityOptions, type IdentityStorage, type JoinOptions, type JoinRelayOptions, MAX_IDENTITY_RETRY_WAIT_MS, MAX_PREDICTED_BODIES, MAX_PROXY_BODIES, MatchError, type MatchOptions, type MatchTicket, type MessageTarget, PING_INTERVAL_MS, PREDICTION_EPSILON, type PartyTicket, type PredictionStats, type PredictionStatus, REGION_RE, RESIM_DEPTH, type RelayRoom, type Room, type RoomCallProxy, type RoomError, type RoomEvents, type RoomMessageStats, type RoomMessages, type RoomProfile, type RoomStats, SMOOTHING_HALF_LIFE_MS, SMOOTHING_SNAP_UNITS, type Scheduler, Session, type Status, type Transport, type TransportSocket, type Unsubscribe, type VoiceHandle, type VoiceOptions, type VoicePeerState, type VoiceTrackAccess, createParty, defaultScheduler, findMatch, identityStorageKey, joinRelay, joinRoom, joinVoice, linkForUrl, matchRoom, resolveUrl, roomIdFrom, webSocketTransport };
|
|
2952
|
+
export { ACCOUNT_STORAGE_KEY, type AttachLobbyOptions, CALL_TIMEOUT_MS, type ClientBody2dFactory, type ClientBody2dSpec, type ClientBodySpec, type ClientCollection, type ClientIntent2dHook, type ClientMatterBody, type ClientMatterConstraint, type ClientMatterEngine, type ClientMatterModule, type ClientPhysics2dOptions, type ClientPhysicsOptions, type ClientRapier2dBody, type ClientRapier2dBodyFactory, type ClientRapier2dBodySpec, type ClientRapier2dIntentHook, type ClientRapier2dModule, type ClientRapier2dOptions, type ClientRapier2dWorld, type ClientRapierBody, type ClientRapierModule, type ClientRapierWorld, type ClientState, ClientStore, type ClientVector2, type ClientVector2d, type ClientVector3, type Correction, DEFAULT_CONTROL_URL, DEFAULT_REGION, DEFAULT_WRITE_INTERVAL_MS, DEV_PORT, E_CONNECT_FAILED, E_IDENTITY_RATE_LIMITED, type FrameHook, INTERNAL_VOICE_TRACKS, Identity, IdentityError, type IdentityOptions, type IdentityStorage, type JoinOptions, type JoinRelayOptions, LOBBY_MEMBERS, LOBBY_POLL_MS, LOBBY_STATE, type LobbyPlayer, type LobbyView, MAX_IDENTITY_RETRY_WAIT_MS, MAX_PREDICTED_BODIES, MAX_PROXY_BODIES, MatchError, type MatchOptions, type MatchTicket, type MessageTarget, PING_INTERVAL_MS, PREDICTION_EPSILON, type PartyTicket, type PredictionStats, type PredictionStatus, type PublicTicket, type QuickMatchDetail, REGION_RE, RESIM_DEPTH, type RelayRoom, type Room, type RoomCallProxy, type RoomError, type RoomEvents, type RoomMessageStats, type RoomMessages, type RoomProfile, type RoomStats, SMOOTHING_HALF_LIFE_MS, SMOOTHING_SNAP_UNITS, type Scheduler, Session, type Status, type Transport, type TransportSocket, type Unsubscribe, type VoiceHandle, type VoiceOptions, type VoicePeerState, type VoiceTrackAccess, attachLobby, attachable, createParty, defaultScheduler, findMatch, findPublic, hasLobby, identityStorageKey, joinPublic, joinRelay, joinRoom, joinVoice, linkForUrl, lobbyOf, matchRoom, resolveUrl, roomIdFrom, webSocketTransport };
|
package/dist/index.js
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
SMOOTHING_HALF_LIFE_MS,
|
|
9
9
|
SMOOTHING_SNAP_UNITS,
|
|
10
10
|
emptyPredictionStats
|
|
11
|
-
} from "./chunk-
|
|
11
|
+
} from "./chunk-5Z4DHUA3.js";
|
|
12
12
|
|
|
13
13
|
// src/index.ts
|
|
14
14
|
import { EMPTY_PROFILE as EMPTY_PROFILE2 } from "@irtio/protocol";
|
|
@@ -1117,7 +1117,7 @@ var Session = class {
|
|
|
1117
1117
|
const physics2d = options.physics2d;
|
|
1118
1118
|
if (physics && hasPhysics) {
|
|
1119
1119
|
this.predictionRequested = true;
|
|
1120
|
-
void import("./physics-
|
|
1120
|
+
void import("./physics-4SGKNBAC.js").then(({ PhysicsPredictor }) => {
|
|
1121
1121
|
if (this.left) return;
|
|
1122
1122
|
attach(
|
|
1123
1123
|
new PhysicsPredictor(
|
|
@@ -1130,15 +1130,32 @@ var Session = class {
|
|
|
1130
1130
|
)
|
|
1131
1131
|
);
|
|
1132
1132
|
});
|
|
1133
|
-
} else if (physics2d && hasPhysics) {
|
|
1133
|
+
} else if (physics2d !== void 0 && physics2d.engine === "rapier2d" && hasPhysics) {
|
|
1134
|
+
const rapier2d = physics2d;
|
|
1134
1135
|
this.predictionRequested = true;
|
|
1135
|
-
void import("./
|
|
1136
|
+
void import("./physics-rapier2d-TBOAJMVM.js").then(({ Rapier2dPredictor }) => {
|
|
1137
|
+
if (this.left) return;
|
|
1138
|
+
attach(
|
|
1139
|
+
new Rapier2dPredictor(
|
|
1140
|
+
this.ext,
|
|
1141
|
+
this.store,
|
|
1142
|
+
rapier2d,
|
|
1143
|
+
() => this.me,
|
|
1144
|
+
() => this.rtt,
|
|
1145
|
+
() => this.tickIntervalMs
|
|
1146
|
+
)
|
|
1147
|
+
);
|
|
1148
|
+
});
|
|
1149
|
+
} else if (physics2d !== void 0 && physics2d.engine !== "rapier2d" && hasPhysics) {
|
|
1150
|
+
const matter2d = physics2d;
|
|
1151
|
+
this.predictionRequested = true;
|
|
1152
|
+
void import("./physics2d-CNSEWKP4.js").then(({ Physics2dPredictor }) => {
|
|
1136
1153
|
if (this.left) return;
|
|
1137
1154
|
attach(
|
|
1138
1155
|
new Physics2dPredictor(
|
|
1139
1156
|
this.ext,
|
|
1140
1157
|
this.store,
|
|
1141
|
-
|
|
1158
|
+
matter2d,
|
|
1142
1159
|
() => this.me,
|
|
1143
1160
|
() => this.rtt,
|
|
1144
1161
|
() => this.tickIntervalMs
|
|
@@ -2191,6 +2208,233 @@ function isRoomFull(err) {
|
|
|
2191
2208
|
const message = err instanceof Error ? err.message : String(err);
|
|
2192
2209
|
return message.startsWith("E_ROOM_FULL");
|
|
2193
2210
|
}
|
|
2211
|
+
async function findPublic(project, options = {}) {
|
|
2212
|
+
const controlUrl = (options.controlUrl ?? DEFAULT_CONTROL_URL).replace(/\/+$/, "");
|
|
2213
|
+
const fetchImpl = options.fetch ?? fetch;
|
|
2214
|
+
let res;
|
|
2215
|
+
try {
|
|
2216
|
+
res = await fetchImpl(`${controlUrl}/match`, {
|
|
2217
|
+
method: "POST",
|
|
2218
|
+
headers: { "content-type": "application/json" },
|
|
2219
|
+
body: JSON.stringify({
|
|
2220
|
+
project,
|
|
2221
|
+
mode: "public",
|
|
2222
|
+
...options.queue !== void 0 ? { queue: options.queue } : {},
|
|
2223
|
+
...options.identity !== void 0 ? { identity: options.identity } : {},
|
|
2224
|
+
...options.exclude !== void 0 ? { exclude: options.exclude } : {}
|
|
2225
|
+
})
|
|
2226
|
+
});
|
|
2227
|
+
} catch (err) {
|
|
2228
|
+
throw new MatchError(
|
|
2229
|
+
"E_MATCH_UNREACHABLE",
|
|
2230
|
+
`cannot reach the matchmaker at ${controlUrl}: ${err instanceof Error ? err.message : String(err)}`
|
|
2231
|
+
);
|
|
2232
|
+
}
|
|
2233
|
+
const text = await res.text().catch(() => "");
|
|
2234
|
+
let body = {};
|
|
2235
|
+
try {
|
|
2236
|
+
body = JSON.parse(text);
|
|
2237
|
+
} catch {
|
|
2238
|
+
}
|
|
2239
|
+
if (!res.ok) {
|
|
2240
|
+
throw new MatchError(
|
|
2241
|
+
typeof body.code === "string" ? body.code : `E_HTTP_${res.status}`,
|
|
2242
|
+
typeof body.message === "string" ? body.message : `match failed with status ${res.status}`
|
|
2243
|
+
);
|
|
2244
|
+
}
|
|
2245
|
+
if (body.status !== "matched" || typeof body.room !== "string") {
|
|
2246
|
+
throw new MatchError("E_MATCH_MALFORMED", "the matchmaker answered something unexpected");
|
|
2247
|
+
}
|
|
2248
|
+
return {
|
|
2249
|
+
room: body.room,
|
|
2250
|
+
queue: typeof body.queue === "string" ? body.queue : "default",
|
|
2251
|
+
size: typeof body.size === "number" ? body.size : 0,
|
|
2252
|
+
created: body.created === true
|
|
2253
|
+
};
|
|
2254
|
+
}
|
|
2255
|
+
async function joinPublic(schema, options = {}) {
|
|
2256
|
+
const project = schema.project;
|
|
2257
|
+
const key = options.key ?? (typeof project === "string" ? project : void 0);
|
|
2258
|
+
if (key === void 0 || key === "") {
|
|
2259
|
+
throw new MatchError(
|
|
2260
|
+
"E_MATCH_NO_PROJECT",
|
|
2261
|
+
"joinPublic needs a project key: build your schema with `irtio` so it carries one, or pass { key }"
|
|
2262
|
+
);
|
|
2263
|
+
}
|
|
2264
|
+
const identity = options.identity === true ? new Identity({
|
|
2265
|
+
project: key,
|
|
2266
|
+
controlUrl: options.controlUrl,
|
|
2267
|
+
fetch: options.fetch
|
|
2268
|
+
}) : void 0;
|
|
2269
|
+
const ask = async (exclude) => findPublic(key, {
|
|
2270
|
+
...options.queue !== void 0 ? { queue: options.queue } : {},
|
|
2271
|
+
...options.controlUrl !== void 0 ? { controlUrl: options.controlUrl } : {},
|
|
2272
|
+
...options.fetch !== void 0 ? { fetch: options.fetch } : {},
|
|
2273
|
+
...identity !== void 0 ? { identity: await identity.ensure() } : {},
|
|
2274
|
+
...exclude !== void 0 ? { exclude } : {}
|
|
2275
|
+
});
|
|
2276
|
+
const ticket = await ask();
|
|
2277
|
+
try {
|
|
2278
|
+
return await joinRoom(schema, { ...options, room: ticket.room });
|
|
2279
|
+
} catch (err) {
|
|
2280
|
+
if (!isRetryablePublicJoin(err)) throw err;
|
|
2281
|
+
const second = await ask(ticket.room);
|
|
2282
|
+
return joinRoom(schema, { ...options, room: second.room });
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
function isRetryablePublicJoin(err) {
|
|
2286
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2287
|
+
return message.startsWith("E_ROOM_FULL") || message.startsWith("E_ROOM_NOT_FOUND");
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
// src/lobby.ts
|
|
2291
|
+
var LOBBY_STATE = "irtLobby";
|
|
2292
|
+
var LOBBY_MEMBERS = "irtLobbyMembers";
|
|
2293
|
+
var LOBBY_POLL_MS = 120;
|
|
2294
|
+
function hasLobby(room) {
|
|
2295
|
+
const state = room?.state;
|
|
2296
|
+
return typeof state === "object" && state !== null && state[LOBBY_STATE] !== void 0 && state[LOBBY_MEMBERS] !== void 0;
|
|
2297
|
+
}
|
|
2298
|
+
function lobbyOf(room, options = {}) {
|
|
2299
|
+
if (!hasLobby(room)) return void 0;
|
|
2300
|
+
const r = room;
|
|
2301
|
+
const singleton = () => r.state[LOBBY_STATE] ?? {};
|
|
2302
|
+
const members = () => r.state[LOBBY_MEMBERS];
|
|
2303
|
+
const players = () => r.clients.map((c) => ({
|
|
2304
|
+
clientId: c.clientId,
|
|
2305
|
+
ready: members().get(c.clientId)?.ready === true,
|
|
2306
|
+
connected: c.connected !== false,
|
|
2307
|
+
me: c.clientId === r.me
|
|
2308
|
+
}));
|
|
2309
|
+
const signature = () => {
|
|
2310
|
+
const s = singleton();
|
|
2311
|
+
let out = `${s.phase ?? ""}|${s.public === true ? 1 : 0}|${s.readyUi === true ? 1 : 0}|${s.capacity ?? 0}`;
|
|
2312
|
+
for (const p of players()) out += `|${p.clientId}${p.ready ? 1 : 0}${p.connected ? 1 : 0}`;
|
|
2313
|
+
return out;
|
|
2314
|
+
};
|
|
2315
|
+
return {
|
|
2316
|
+
get phase() {
|
|
2317
|
+
return singleton().phase === "started" ? "started" : "lobby";
|
|
2318
|
+
},
|
|
2319
|
+
get players() {
|
|
2320
|
+
return players();
|
|
2321
|
+
},
|
|
2322
|
+
get public() {
|
|
2323
|
+
return singleton().public === true;
|
|
2324
|
+
},
|
|
2325
|
+
get readyUi() {
|
|
2326
|
+
return singleton().readyUi === true;
|
|
2327
|
+
},
|
|
2328
|
+
get capacity() {
|
|
2329
|
+
return singleton().capacity ?? 0;
|
|
2330
|
+
},
|
|
2331
|
+
ready(value) {
|
|
2332
|
+
const mine = members().get(r.me);
|
|
2333
|
+
if (!mine) return;
|
|
2334
|
+
mine.ready = value === true;
|
|
2335
|
+
r.flush();
|
|
2336
|
+
},
|
|
2337
|
+
...options.setPublic !== void 0 ? { setPublic: options.setPublic } : {},
|
|
2338
|
+
on(event, cb) {
|
|
2339
|
+
if (event !== "change") return () => {
|
|
2340
|
+
};
|
|
2341
|
+
let last = signature();
|
|
2342
|
+
const sample = () => {
|
|
2343
|
+
const next = signature();
|
|
2344
|
+
if (next === last) return;
|
|
2345
|
+
last = next;
|
|
2346
|
+
cb();
|
|
2347
|
+
};
|
|
2348
|
+
const offClients = r.on("clients", sample);
|
|
2349
|
+
const timer = setInterval(sample, LOBBY_POLL_MS);
|
|
2350
|
+
timer.unref?.();
|
|
2351
|
+
return () => {
|
|
2352
|
+
offClients();
|
|
2353
|
+
clearInterval(timer);
|
|
2354
|
+
};
|
|
2355
|
+
}
|
|
2356
|
+
};
|
|
2357
|
+
}
|
|
2358
|
+
function attachable(room, view) {
|
|
2359
|
+
return {
|
|
2360
|
+
get id() {
|
|
2361
|
+
return room.id;
|
|
2362
|
+
},
|
|
2363
|
+
get link() {
|
|
2364
|
+
return room.link;
|
|
2365
|
+
},
|
|
2366
|
+
get status() {
|
|
2367
|
+
return room.status;
|
|
2368
|
+
},
|
|
2369
|
+
get clients() {
|
|
2370
|
+
return room.clients;
|
|
2371
|
+
},
|
|
2372
|
+
get maxClients() {
|
|
2373
|
+
return room.maxClients;
|
|
2374
|
+
},
|
|
2375
|
+
get rtt() {
|
|
2376
|
+
return room.rtt;
|
|
2377
|
+
},
|
|
2378
|
+
on: (event, cb) => room.on(event, cb),
|
|
2379
|
+
...view !== void 0 ? { lobby: view } : {}
|
|
2380
|
+
};
|
|
2381
|
+
}
|
|
2382
|
+
function attachLobby(element, schema, options = {}) {
|
|
2383
|
+
let detachRoom;
|
|
2384
|
+
let busy = false;
|
|
2385
|
+
const fail = (err) => {
|
|
2386
|
+
element.removeAttribute("searching");
|
|
2387
|
+
if (options.onError) {
|
|
2388
|
+
options.onError(err);
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
queueMicrotask(() => {
|
|
2392
|
+
throw err;
|
|
2393
|
+
});
|
|
2394
|
+
};
|
|
2395
|
+
const landed = (room) => {
|
|
2396
|
+
element.removeAttribute("searching");
|
|
2397
|
+
const view = lobbyOf(room, {
|
|
2398
|
+
...options.setPublic !== void 0 ? { setPublic: (value) => options.setPublic?.(room, value) } : {}
|
|
2399
|
+
});
|
|
2400
|
+
detachRoom = element.attach(
|
|
2401
|
+
attachable(room, view)
|
|
2402
|
+
);
|
|
2403
|
+
options.onRoom?.(room);
|
|
2404
|
+
};
|
|
2405
|
+
const run = async (make) => {
|
|
2406
|
+
if (busy) return;
|
|
2407
|
+
busy = true;
|
|
2408
|
+
try {
|
|
2409
|
+
landed(await make());
|
|
2410
|
+
} catch (err) {
|
|
2411
|
+
fail(err);
|
|
2412
|
+
} finally {
|
|
2413
|
+
busy = false;
|
|
2414
|
+
}
|
|
2415
|
+
};
|
|
2416
|
+
const onPrivate = () => {
|
|
2417
|
+
void run(() => joinRoom(schema, { ...options.join ?? {} }));
|
|
2418
|
+
};
|
|
2419
|
+
const onQuickMatch = (event) => {
|
|
2420
|
+
const detail = event.detail;
|
|
2421
|
+
element.setAttribute("searching", "");
|
|
2422
|
+
void run(
|
|
2423
|
+
() => joinPublic(schema, {
|
|
2424
|
+
...options.join ?? {},
|
|
2425
|
+
...options.match ?? {},
|
|
2426
|
+
...detail?.queue !== void 0 ? { queue: detail.queue } : {}
|
|
2427
|
+
})
|
|
2428
|
+
);
|
|
2429
|
+
};
|
|
2430
|
+
element.addEventListener("private", onPrivate);
|
|
2431
|
+
element.addEventListener("quickmatch", onQuickMatch);
|
|
2432
|
+
return () => {
|
|
2433
|
+
element.removeEventListener("private", onPrivate);
|
|
2434
|
+
element.removeEventListener("quickmatch", onQuickMatch);
|
|
2435
|
+
detachRoom?.();
|
|
2436
|
+
};
|
|
2437
|
+
}
|
|
2194
2438
|
|
|
2195
2439
|
// src/voice.ts
|
|
2196
2440
|
import {
|
|
@@ -2693,6 +2937,9 @@ export {
|
|
|
2693
2937
|
INTERNAL_VOICE_TRACKS,
|
|
2694
2938
|
Identity,
|
|
2695
2939
|
IdentityError,
|
|
2940
|
+
LOBBY_MEMBERS,
|
|
2941
|
+
LOBBY_POLL_MS,
|
|
2942
|
+
LOBBY_STATE,
|
|
2696
2943
|
MAX_IDENTITY_RETRY_WAIT_MS,
|
|
2697
2944
|
MAX_PREDICTED_BODIES,
|
|
2698
2945
|
MAX_PROXY_BODIES,
|
|
@@ -2704,14 +2951,20 @@ export {
|
|
|
2704
2951
|
SMOOTHING_HALF_LIFE_MS,
|
|
2705
2952
|
SMOOTHING_SNAP_UNITS,
|
|
2706
2953
|
Session,
|
|
2954
|
+
attachLobby,
|
|
2955
|
+
attachable,
|
|
2707
2956
|
createParty,
|
|
2708
2957
|
defaultScheduler,
|
|
2709
2958
|
findMatch,
|
|
2959
|
+
findPublic,
|
|
2960
|
+
hasLobby,
|
|
2710
2961
|
identityStorageKey,
|
|
2962
|
+
joinPublic,
|
|
2711
2963
|
joinRelay,
|
|
2712
2964
|
joinRoom,
|
|
2713
2965
|
joinVoice,
|
|
2714
2966
|
linkForUrl,
|
|
2967
|
+
lobbyOf,
|
|
2715
2968
|
matchRoom,
|
|
2716
2969
|
resolveUrl,
|
|
2717
2970
|
roomIdFrom,
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Predictor
|
|
3
|
+
} from "./chunk-XFD6WYQW.js";
|
|
4
|
+
import "./chunk-5Z4DHUA3.js";
|
|
5
|
+
|
|
6
|
+
// src/physics-rapier2d.ts
|
|
7
|
+
import {
|
|
8
|
+
angleFrom2d,
|
|
9
|
+
applyChannel2d,
|
|
10
|
+
channelOf2d
|
|
11
|
+
} from "@irtio/schema";
|
|
12
|
+
var engine;
|
|
13
|
+
var loading;
|
|
14
|
+
async function loadEngine() {
|
|
15
|
+
if (engine) return engine;
|
|
16
|
+
loading ??= (async () => {
|
|
17
|
+
const mod = await import("@dimforge/rapier2d-compat");
|
|
18
|
+
const ns = mod.default ?? mod;
|
|
19
|
+
await ns.init();
|
|
20
|
+
engine = ns;
|
|
21
|
+
return ns;
|
|
22
|
+
})();
|
|
23
|
+
return loading;
|
|
24
|
+
}
|
|
25
|
+
var Rapier2dAdapter = class {
|
|
26
|
+
constructor(options) {
|
|
27
|
+
this.options = options;
|
|
28
|
+
}
|
|
29
|
+
options;
|
|
30
|
+
engineName = "@dimforge/rapier2d-compat";
|
|
31
|
+
optionName = "physics2d";
|
|
32
|
+
rapier;
|
|
33
|
+
world;
|
|
34
|
+
timestepSeconds = 1 / 30;
|
|
35
|
+
/** Reused by `readPose`, which runs per body per step and must not allocate. */
|
|
36
|
+
state = {
|
|
37
|
+
x: 0,
|
|
38
|
+
y: 0,
|
|
39
|
+
angle: 0,
|
|
40
|
+
vx: 0,
|
|
41
|
+
vy: 0,
|
|
42
|
+
angularVelocity: 0
|
|
43
|
+
};
|
|
44
|
+
/** `applyChannel2d`'s target shape, likewise reused. */
|
|
45
|
+
channels = { x: 0, y: 0, qz: 0, qw: 1, vx: 0, vy: 0, wz: 0 };
|
|
46
|
+
/** `moveKinematic`'s translation argument, reused: it runs per proxy per step (D71). */
|
|
47
|
+
moveTarget = { x: 0, y: 0 };
|
|
48
|
+
get timestep() {
|
|
49
|
+
return this.options.timestep;
|
|
50
|
+
}
|
|
51
|
+
async start(timestepSeconds) {
|
|
52
|
+
const rapier = await loadEngine();
|
|
53
|
+
this.rapier = rapier;
|
|
54
|
+
this.timestepSeconds = timestepSeconds;
|
|
55
|
+
const world = new rapier.World({ x: this.options.gravity.x, y: this.options.gravity.y });
|
|
56
|
+
world.timestep = timestepSeconds;
|
|
57
|
+
if (this.options.setup) this.options.setup(world, rapier);
|
|
58
|
+
this.world = world;
|
|
59
|
+
}
|
|
60
|
+
free() {
|
|
61
|
+
this.world?.free();
|
|
62
|
+
this.world = void 0;
|
|
63
|
+
}
|
|
64
|
+
hasFactory(collection) {
|
|
65
|
+
return this.options.bodies?.[collection] !== void 0;
|
|
66
|
+
}
|
|
67
|
+
createBody(desc, id, record, _warn) {
|
|
68
|
+
const world = this.world;
|
|
69
|
+
const rapier = this.rapier;
|
|
70
|
+
const factory = this.options.bodies?.[desc.name];
|
|
71
|
+
if (!world || !rapier || !factory) return void 0;
|
|
72
|
+
const spec = factory(rapier, record, id);
|
|
73
|
+
if (!spec || !spec.body) return void 0;
|
|
74
|
+
const body = world.createRigidBody(spec.body);
|
|
75
|
+
for (const collider of spec.colliders ?? []) world.createCollider(collider, body);
|
|
76
|
+
return body;
|
|
77
|
+
}
|
|
78
|
+
removeBody(body) {
|
|
79
|
+
this.world?.removeRigidBody(body);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* D71: a proxy is a `KinematicPositionBased` body, exactly as in 3D — driven by the position it
|
|
83
|
+
* is told to be at next, never by a force, an impulse or a contact, with Rapier deriving the
|
|
84
|
+
* velocity its contacts see from the move itself. The body is *switched* rather than built
|
|
85
|
+
* kinematic, so its collider and mass properties are the ones the shared factory produced.
|
|
86
|
+
*/
|
|
87
|
+
makeKinematic(handle) {
|
|
88
|
+
const rapier = this.rapier;
|
|
89
|
+
if (!rapier) return;
|
|
90
|
+
handle.setBodyType(rapier.RigidBodyType.KinematicPositionBased, true);
|
|
91
|
+
}
|
|
92
|
+
moveKinematic(handle, pose) {
|
|
93
|
+
const body = handle;
|
|
94
|
+
const to = this.moveTarget;
|
|
95
|
+
to.x = pose.t.x;
|
|
96
|
+
to.y = pose.t.y;
|
|
97
|
+
body.setNextKinematicTranslation(to);
|
|
98
|
+
body.setNextKinematicRotation(angleFrom2d(pose.r.z, pose.r.w));
|
|
99
|
+
}
|
|
100
|
+
step() {
|
|
101
|
+
this.world?.step();
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Server record → body state, through `applyChannel2d` so the plane-to-channel mapping stays
|
|
105
|
+
* written once, and in `Rapier2dRuntime.applyRecordToBody`'s order — translation, rotation,
|
|
106
|
+
* linear velocity, spin — so the two sides write the same body the same way. Every setter takes
|
|
107
|
+
* `wakeUp = true`: a rebase writes state a sleeping body would otherwise ignore.
|
|
108
|
+
*/
|
|
109
|
+
applyRecord(handle, channels, record) {
|
|
110
|
+
const body = handle;
|
|
111
|
+
const t = this.channels;
|
|
112
|
+
const pos = body.translation();
|
|
113
|
+
const vel = body.linvel();
|
|
114
|
+
const angle = body.rotation();
|
|
115
|
+
t.x = pos.x;
|
|
116
|
+
t.y = pos.y;
|
|
117
|
+
t.qz = Math.sin(angle / 2);
|
|
118
|
+
t.qw = Math.cos(angle / 2);
|
|
119
|
+
t.vx = vel.x;
|
|
120
|
+
t.vy = vel.y;
|
|
121
|
+
t.wz = body.angvel();
|
|
122
|
+
for (const [channel, field] of channels) {
|
|
123
|
+
const raw = record[field];
|
|
124
|
+
if (typeof raw !== "number") continue;
|
|
125
|
+
applyChannel2d(channel, raw, t);
|
|
126
|
+
}
|
|
127
|
+
body.setTranslation({ x: t.x, y: t.y }, true);
|
|
128
|
+
body.setRotation(angleFrom2d(t.qz, t.qw), true);
|
|
129
|
+
body.setLinvel({ x: t.vx, y: t.vy }, true);
|
|
130
|
+
body.setAngvel(t.wz, true);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Body state → a 3D pose, through `channelOf2d`: `t = (x, y, 0)`, `r` a quaternion about Z,
|
|
134
|
+
* `v = (vx, vy, 0)`, `w = (0, 0, spin)`.
|
|
135
|
+
*/
|
|
136
|
+
readPose(handle, into) {
|
|
137
|
+
const body = handle;
|
|
138
|
+
const s = this.state;
|
|
139
|
+
const pos = body.translation();
|
|
140
|
+
const vel = body.linvel();
|
|
141
|
+
s.x = pos.x;
|
|
142
|
+
s.y = pos.y;
|
|
143
|
+
s.angle = body.rotation();
|
|
144
|
+
s.vx = vel.x;
|
|
145
|
+
s.vy = vel.y;
|
|
146
|
+
s.angularVelocity = body.angvel();
|
|
147
|
+
const b = s;
|
|
148
|
+
into.t.x = channelOf2d("x", b);
|
|
149
|
+
into.t.y = channelOf2d("y", b);
|
|
150
|
+
into.t.z = channelOf2d("z", b);
|
|
151
|
+
into.r.x = channelOf2d("qx", b);
|
|
152
|
+
into.r.y = channelOf2d("qy", b);
|
|
153
|
+
into.r.z = channelOf2d("qz", b);
|
|
154
|
+
into.r.w = channelOf2d("qw", b);
|
|
155
|
+
into.v.x = channelOf2d("vx", b);
|
|
156
|
+
into.v.y = channelOf2d("vy", b);
|
|
157
|
+
into.v.z = channelOf2d("vz", b);
|
|
158
|
+
into.w.x = channelOf2d("wx", b);
|
|
159
|
+
into.w.y = channelOf2d("wy", b);
|
|
160
|
+
into.w.z = channelOf2d("wz", b);
|
|
161
|
+
}
|
|
162
|
+
applyIntent(collection, body, instance) {
|
|
163
|
+
const hook = this.options.intents?.[collection];
|
|
164
|
+
const world = this.world;
|
|
165
|
+
const rapier = this.rapier;
|
|
166
|
+
if (!hook || !world || !rapier) return;
|
|
167
|
+
hook(body, instance, rapier, world, this.timestepSeconds);
|
|
168
|
+
}
|
|
169
|
+
// No `settle`. The world applies gravity; see the module docblock.
|
|
170
|
+
/** Rapier velocities are per second, so one tick of error is `epsilon` when `dv = eps / dt`. */
|
|
171
|
+
velocityTolerance(epsilon, timestepSeconds) {
|
|
172
|
+
return epsilon / timestepSeconds;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
var Rapier2dPredictor = class extends Predictor {
|
|
176
|
+
constructor(ext, store, options, meOf, rttOf, tickIntervalOf, log = (m) => console.warn(m)) {
|
|
177
|
+
super(ext, store, new Rapier2dAdapter(options), options, meOf, rttOf, tickIntervalOf, log);
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
function createRapier2dAdapter(options) {
|
|
181
|
+
return new Rapier2dAdapter(options);
|
|
182
|
+
}
|
|
183
|
+
export {
|
|
184
|
+
Rapier2dPredictor,
|
|
185
|
+
createRapier2dAdapter
|
|
186
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@irtio/client",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "irtio client SDK: joinRoom, owned-write batching, corrections, typed RPCs, presence, reconnection",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -20,14 +20,18 @@
|
|
|
20
20
|
],
|
|
21
21
|
"dependencies": {
|
|
22
22
|
"mediasoup-client": "^3.23.1",
|
|
23
|
-
"@irtio/protocol": "0.
|
|
24
|
-
"@irtio/schema": "0.
|
|
23
|
+
"@irtio/protocol": "0.8.0",
|
|
24
|
+
"@irtio/schema": "0.8.0"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
27
|
+
"@dimforge/rapier2d-compat": ">=0.20.0",
|
|
27
28
|
"@dimforge/rapier3d-compat": ">=0.20.0",
|
|
28
29
|
"matter-js": ">=0.20.0"
|
|
29
30
|
},
|
|
30
31
|
"peerDependenciesMeta": {
|
|
32
|
+
"@dimforge/rapier2d-compat": {
|
|
33
|
+
"optional": true
|
|
34
|
+
},
|
|
31
35
|
"@dimforge/rapier3d-compat": {
|
|
32
36
|
"optional": true
|
|
33
37
|
},
|
|
@@ -39,6 +43,7 @@
|
|
|
39
43
|
"@dimforge/rapier3d-compat": "0.20.0",
|
|
40
44
|
"@types/matter-js": "0.20.2",
|
|
41
45
|
"matter-js": "0.20.0",
|
|
46
|
+
"@dimforge/rapier2d-compat": "0.20.0",
|
|
42
47
|
"@irtio/sfu": "0.0.0"
|
|
43
48
|
},
|
|
44
49
|
"scripts": {
|