@rpgjs/client 5.0.0-beta.25 → 5.0.0-beta.27
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 +68 -0
- package/dist/Game/Map.d.ts +1 -0
- package/dist/Game/Map.js +5 -0
- package/dist/Game/Map.js.map +1 -1
- package/dist/Game/Object.js.map +1 -1
- package/dist/Gui/Gui.d.ts +2 -2
- package/dist/Gui/Gui.js.map +1 -1
- package/dist/RpgClient.d.ts +2 -1
- package/dist/RpgClientEngine.d.ts +76 -0
- package/dist/RpgClientEngine.js +59 -4
- package/dist/RpgClientEngine.js.map +1 -1
- package/dist/components/scenes/draw-map.ce.js +5 -2
- package/dist/components/scenes/draw-map.ce.js.map +1 -1
- package/dist/components/scenes/weather-lifecycle-compat.d.ts +0 -0
- package/dist/components/scenes/weather-lifecycle-compat.js +11 -0
- package/dist/components/scenes/weather-lifecycle-compat.js.map +1 -0
- package/dist/components/scenes/weather-tick-lifecycle.d.ts +17 -0
- package/dist/components/scenes/weather-tick-lifecycle.js +44 -0
- package/dist/components/scenes/weather-tick-lifecycle.js.map +1 -0
- package/dist/components/scenes/weather-tick-lifecycle.spec.d.ts +1 -0
- package/dist/core/inject.d.ts +4 -4
- package/dist/core/inject.js.map +1 -1
- package/dist/core/setup.d.ts +3 -3
- package/dist/core/setup.js +1 -1
- package/dist/core/setup.js.map +1 -1
- package/dist/core/setup.spec.d.ts +1 -0
- package/dist/i18n.d.ts +2 -1
- package/dist/i18n.js +1 -0
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -2
- package/dist/module.d.ts +2 -2
- package/dist/module.js.map +1 -1
- package/dist/public-api-types.spec.d.ts +1 -0
- package/dist/services/AbstractSocket.d.ts +3 -3
- package/dist/services/AbstractSocket.js.map +1 -1
- package/dist/services/loadMap.d.ts +13 -9
- package/dist/services/loadMap.js +4 -3
- package/dist/services/loadMap.js.map +1 -1
- package/dist/services/mapStreaming.d.ts +71 -0
- package/dist/services/mapStreaming.js +210 -0
- package/dist/services/mapStreaming.js.map +1 -0
- package/dist/services/mapStreaming.spec.d.ts +1 -0
- package/dist/services/mmorpg.d.ts +10 -26
- package/dist/services/mmorpg.js +2 -2
- package/dist/services/mmorpg.js.map +1 -1
- package/dist/services/standalone.d.ts +2 -98
- package/dist/services/standalone.js.map +1 -1
- package/package.json +3 -3
- package/src/Game/Map.ts +7 -0
- package/src/Game/Object.spec.ts +1 -1
- package/src/Game/Object.ts +4 -4
- package/src/Gui/Gui.ts +3 -3
- package/src/RpgClient.ts +2 -1
- package/src/RpgClientEngine.ts +147 -6
- package/src/components/scenes/draw-map.ce +4 -1
- package/src/components/scenes/weather-lifecycle-compat.ts +8 -0
- package/src/components/scenes/weather-tick-lifecycle.spec.ts +60 -0
- package/src/components/scenes/weather-tick-lifecycle.ts +69 -0
- package/src/core/inject.ts +6 -5
- package/src/core/setup.spec.ts +35 -0
- package/src/core/setup.ts +8 -6
- package/src/i18n.spec.ts +2 -1
- package/src/i18n.ts +1 -0
- package/src/index.ts +14 -1
- package/src/module.ts +2 -2
- package/src/public-api-types.spec.ts +19 -0
- package/src/services/AbstractSocket.ts +2 -2
- package/src/services/loadMap.ts +16 -9
- package/src/services/mapStreaming.spec.ts +240 -0
- package/src/services/mapStreaming.ts +277 -0
- package/src/services/mmorpg.ts +21 -9
- package/src/services/standalone.ts +4 -5
package/src/services/loadMap.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Context, inject } from "@signe/di";
|
|
2
|
-
import { UpdateMapToken, UpdateMapService, type LightingState } from "@rpgjs/common";
|
|
2
|
+
import { UpdateMapToken, UpdateMapService, type LightingState, type RpgContext, type RpgProvider } from "@rpgjs/common";
|
|
3
|
+
import type { RpgClientMap } from "../Game/Map";
|
|
3
4
|
|
|
4
5
|
export const LoadMapToken = 'LoadMapToken'
|
|
5
6
|
|
|
@@ -9,7 +10,7 @@ export const LoadMapToken = 'LoadMapToken'
|
|
|
9
10
|
*
|
|
10
11
|
* @interface MapData
|
|
11
12
|
*/
|
|
12
|
-
type MapData = {
|
|
13
|
+
export type MapData = {
|
|
13
14
|
/** Raw map data that will be passed to the map component */
|
|
14
15
|
data: any;
|
|
15
16
|
/** CanvasEngine component that will render the map */
|
|
@@ -26,6 +27,10 @@ type MapData = {
|
|
|
26
27
|
id?: string;
|
|
27
28
|
/** Optional initial lighting state for the loaded map */
|
|
28
29
|
lighting?: LightingState | null;
|
|
30
|
+
/** Optional render parameters passed to the map component. */
|
|
31
|
+
params?: Record<string, unknown>;
|
|
32
|
+
/** Internal controller used by a progressive map provider. */
|
|
33
|
+
streamController?: { attach(map: RpgClientMap): void; detach(): void };
|
|
29
34
|
}
|
|
30
35
|
|
|
31
36
|
/**
|
|
@@ -42,14 +47,16 @@ export type LoadMapOptions = (mapId: string) => Promise<MapData> | MapData
|
|
|
42
47
|
export class LoadMapService {
|
|
43
48
|
private updateMapService: UpdateMapService;
|
|
44
49
|
|
|
45
|
-
constructor(private context:
|
|
46
|
-
if (context
|
|
50
|
+
constructor(private context: RpgContext, private options: LoadMapOptions) {
|
|
51
|
+
if (context.side === 'server') {
|
|
47
52
|
return
|
|
48
53
|
}
|
|
49
|
-
this.updateMapService = inject(context, UpdateMapToken);
|
|
50
54
|
}
|
|
51
55
|
|
|
56
|
+
initialize(): void {}
|
|
57
|
+
|
|
52
58
|
async load(mapId: string) {
|
|
59
|
+
this.updateMapService ??= inject(this.context as Context, UpdateMapToken);
|
|
53
60
|
const map = await this.options(mapId.replace('map-', ''))
|
|
54
61
|
await this.updateMapService.update(map);
|
|
55
62
|
return map;
|
|
@@ -147,12 +154,12 @@ export class LoadMapService {
|
|
|
147
154
|
* @see {@link LoadMapOptions} for callback function signature
|
|
148
155
|
* @see {@link MapData} for return data structure
|
|
149
156
|
*/
|
|
150
|
-
export function provideLoadMap(options: LoadMapOptions) {
|
|
157
|
+
export function provideLoadMap(options: LoadMapOptions): RpgProvider[] {
|
|
151
158
|
return [
|
|
152
159
|
{
|
|
153
160
|
provide: UpdateMapToken,
|
|
154
|
-
useFactory: (context:
|
|
155
|
-
if (context
|
|
161
|
+
useFactory: (context: RpgContext) => {
|
|
162
|
+
if (context.side === 'client') {
|
|
156
163
|
console.warn('UpdateMapToken is not overridden')
|
|
157
164
|
}
|
|
158
165
|
return
|
|
@@ -160,7 +167,7 @@ export function provideLoadMap(options: LoadMapOptions) {
|
|
|
160
167
|
},
|
|
161
168
|
{
|
|
162
169
|
provide: LoadMapToken,
|
|
163
|
-
useFactory: (context:
|
|
170
|
+
useFactory: (context: RpgContext) => new LoadMapService(context, options),
|
|
164
171
|
},
|
|
165
172
|
];
|
|
166
173
|
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { Context, inject, injector } from "@signe/di";
|
|
2
|
+
import { MAP_STREAM_EVENT, MAP_STREAM_REQUEST_EVENT, UpdateMapToken } from "@rpgjs/common";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import type { MapStreamChunk, MapStreamManifest } from "@rpgjs/common";
|
|
5
|
+
import { LoadMapToken } from "./loadMap";
|
|
6
|
+
import { WebSocketToken } from "./AbstractSocket";
|
|
7
|
+
import { MapStreamClientController, provideClientMapStreaming } from "./mapStreaming";
|
|
8
|
+
|
|
9
|
+
type State = { keys: Set<string> };
|
|
10
|
+
|
|
11
|
+
function manifest(revision: string): MapStreamManifest<Record<string, never>> {
|
|
12
|
+
return {
|
|
13
|
+
protocol: 1,
|
|
14
|
+
mapId: "demo",
|
|
15
|
+
revision,
|
|
16
|
+
width: 200,
|
|
17
|
+
height: 100,
|
|
18
|
+
chunkWidth: 100,
|
|
19
|
+
chunkHeight: 100,
|
|
20
|
+
columns: 2,
|
|
21
|
+
rows: 1,
|
|
22
|
+
renderData: {},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function chunk(key: string, x: number): MapStreamChunk<{ tile: number }> {
|
|
27
|
+
return {
|
|
28
|
+
key,
|
|
29
|
+
x,
|
|
30
|
+
y: 0,
|
|
31
|
+
bounds: { x: x * 100, y: 0, width: 100, height: 100 },
|
|
32
|
+
renderData: { tile: x + 1 },
|
|
33
|
+
hitboxes: [{ id: `wall-${key}`, x: x * 100, y: 0, width: 10, height: 10 }],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function createStreamingLoader(socket: unknown, timeoutMs?: number) {
|
|
38
|
+
const context = new Context();
|
|
39
|
+
const update = vi.fn();
|
|
40
|
+
await injector(context, [
|
|
41
|
+
{ provide: WebSocketToken, useValue: socket },
|
|
42
|
+
{ provide: UpdateMapToken, useValue: { update } },
|
|
43
|
+
...provideClientMapStreaming({
|
|
44
|
+
adapter: {
|
|
45
|
+
component: {},
|
|
46
|
+
createState: () => ({ keys: new Set<string>() }),
|
|
47
|
+
applyChunk: (state: State, value) => state.keys.add(value.key),
|
|
48
|
+
removeChunk: (state: State, key) => state.keys.delete(key),
|
|
49
|
+
getData: (state: State) => [...state.keys],
|
|
50
|
+
},
|
|
51
|
+
timeoutMs,
|
|
52
|
+
}),
|
|
53
|
+
]);
|
|
54
|
+
return {
|
|
55
|
+
loader: inject<{ load(mapId: string): Promise<any> }>(context, LoadMapToken),
|
|
56
|
+
update,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe("MapStreamClientController", () => {
|
|
61
|
+
it("keeps the active map attached when a new map revision arrives", () => {
|
|
62
|
+
const controller = new MapStreamClientController<Record<string, never>, { tile: number }, State>({
|
|
63
|
+
component: {},
|
|
64
|
+
createState: () => ({ keys: new Set() }),
|
|
65
|
+
applyChunk: (state, value) => state.keys.add(value.key),
|
|
66
|
+
removeChunk: (state, key) => state.keys.delete(key),
|
|
67
|
+
getData: (state) => [...state.keys],
|
|
68
|
+
}, manifest("one"));
|
|
69
|
+
controller.receive({
|
|
70
|
+
mapId: "demo",
|
|
71
|
+
revision: "one",
|
|
72
|
+
manifest: manifest("one"),
|
|
73
|
+
chunks: [chunk("0:0", 0)],
|
|
74
|
+
removed: [],
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
let current = controller.toMapData();
|
|
78
|
+
const data = Object.assign(() => current, {
|
|
79
|
+
set: vi.fn((value) => { current = value; }),
|
|
80
|
+
});
|
|
81
|
+
const map = {
|
|
82
|
+
data,
|
|
83
|
+
replaceStreamedStaticHitboxes: vi.fn(),
|
|
84
|
+
clearStreamedStaticHitboxes: vi.fn(),
|
|
85
|
+
};
|
|
86
|
+
controller.attach(map);
|
|
87
|
+
|
|
88
|
+
controller.reset(manifest("two"));
|
|
89
|
+
controller.receive({
|
|
90
|
+
mapId: "demo",
|
|
91
|
+
revision: "two",
|
|
92
|
+
manifest: manifest("two"),
|
|
93
|
+
chunks: [chunk("1:0", 1)],
|
|
94
|
+
removed: [],
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
expect(map.clearStreamedStaticHitboxes).toHaveBeenCalledWith("0:0");
|
|
98
|
+
expect(map.replaceStreamedStaticHitboxes).toHaveBeenCalledWith("1:0", chunk("1:0", 1).hitboxes);
|
|
99
|
+
expect(current.data).toEqual(["1:0"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("removes streamed hitboxes and the prediction boundary when chunks are evicted", () => {
|
|
103
|
+
const controller = new MapStreamClientController<Record<string, never>, { tile: number }, State>({
|
|
104
|
+
component: {},
|
|
105
|
+
createState: () => ({ keys: new Set() }),
|
|
106
|
+
applyChunk: (state, value) => state.keys.add(value.key),
|
|
107
|
+
removeChunk: (state, key) => state.keys.delete(key),
|
|
108
|
+
getData: (state) => [...state.keys],
|
|
109
|
+
}, manifest("one"));
|
|
110
|
+
controller.receive({
|
|
111
|
+
mapId: "demo",
|
|
112
|
+
revision: "one",
|
|
113
|
+
manifest: manifest("one"),
|
|
114
|
+
chunks: [chunk("0:0", 0), chunk("1:0", 1)],
|
|
115
|
+
removed: [],
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
let current = controller.toMapData();
|
|
119
|
+
const data = Object.assign(() => current, {
|
|
120
|
+
set: vi.fn((value) => { current = value; }),
|
|
121
|
+
});
|
|
122
|
+
const map = {
|
|
123
|
+
data,
|
|
124
|
+
replaceStreamedStaticHitboxes: vi.fn(),
|
|
125
|
+
clearStreamedStaticHitboxes: vi.fn(),
|
|
126
|
+
};
|
|
127
|
+
controller.attach(map);
|
|
128
|
+
map.replaceStreamedStaticHitboxes.mockClear();
|
|
129
|
+
map.clearStreamedStaticHitboxes.mockClear();
|
|
130
|
+
|
|
131
|
+
controller.receive({
|
|
132
|
+
mapId: "demo",
|
|
133
|
+
revision: "one",
|
|
134
|
+
chunks: [],
|
|
135
|
+
removed: ["0:0"],
|
|
136
|
+
});
|
|
137
|
+
expect(current.data).toEqual(["1:0"]);
|
|
138
|
+
expect(map.clearStreamedStaticHitboxes).toHaveBeenCalledWith("0:0");
|
|
139
|
+
expect(map.replaceStreamedStaticHitboxes).toHaveBeenCalledWith("__boundary__", [
|
|
140
|
+
{ x: 98, y: 0, width: 2, height: 100 },
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
map.clearStreamedStaticHitboxes.mockClear();
|
|
144
|
+
controller.receive({
|
|
145
|
+
mapId: "demo",
|
|
146
|
+
revision: "one",
|
|
147
|
+
chunks: [],
|
|
148
|
+
removed: ["1:0"],
|
|
149
|
+
});
|
|
150
|
+
expect(current.data).toEqual([]);
|
|
151
|
+
expect(map.clearStreamedStaticHitboxes.mock.calls).toEqual([
|
|
152
|
+
["1:0"],
|
|
153
|
+
["__boundary__"],
|
|
154
|
+
]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("requests fresh streaming data when a cached map is loaded again", async () => {
|
|
158
|
+
const listeners = new Map<string, (packet: unknown) => void>();
|
|
159
|
+
const socket = {
|
|
160
|
+
on: vi.fn((event: string, callback: (packet: unknown) => void) => listeners.set(event, callback)),
|
|
161
|
+
emit: vi.fn((event: string) => {
|
|
162
|
+
if (event !== MAP_STREAM_REQUEST_EVENT) return;
|
|
163
|
+
listeners.get(MAP_STREAM_EVENT)?.({
|
|
164
|
+
mapId: "demo",
|
|
165
|
+
revision: "one",
|
|
166
|
+
manifest: manifest("one"),
|
|
167
|
+
chunks: [chunk("0:0", 0)],
|
|
168
|
+
removed: [],
|
|
169
|
+
});
|
|
170
|
+
}),
|
|
171
|
+
};
|
|
172
|
+
const { loader } = await createStreamingLoader(socket);
|
|
173
|
+
|
|
174
|
+
await loader.load("map-demo");
|
|
175
|
+
await loader.load("map-demo");
|
|
176
|
+
|
|
177
|
+
expect(socket.emit).toHaveBeenCalledTimes(2);
|
|
178
|
+
expect(socket.emit).toHaveBeenNthCalledWith(2, MAP_STREAM_REQUEST_EVENT, { mapId: "demo" });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("shares one stream request between concurrent loads", async () => {
|
|
182
|
+
const listeners = new Map<string, (packet: unknown) => void>();
|
|
183
|
+
const socket = {
|
|
184
|
+
on: vi.fn((event: string, callback: (packet: unknown) => void) => listeners.set(event, callback)),
|
|
185
|
+
emit: vi.fn(),
|
|
186
|
+
};
|
|
187
|
+
const { loader, update } = await createStreamingLoader(socket);
|
|
188
|
+
|
|
189
|
+
const first = loader.load("map-demo");
|
|
190
|
+
const second = loader.load("demo");
|
|
191
|
+
expect(socket.emit).toHaveBeenCalledTimes(1);
|
|
192
|
+
|
|
193
|
+
listeners.get(MAP_STREAM_EVENT)?.({
|
|
194
|
+
mapId: "demo",
|
|
195
|
+
revision: "one",
|
|
196
|
+
manifest: manifest("one"),
|
|
197
|
+
chunks: [chunk("0:0", 0)],
|
|
198
|
+
removed: [],
|
|
199
|
+
});
|
|
200
|
+
const [firstMap, secondMap] = await Promise.all([first, second]);
|
|
201
|
+
|
|
202
|
+
expect(firstMap.streamController).toBe(secondMap.streamController);
|
|
203
|
+
expect(update).toHaveBeenCalledTimes(2);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("cleans up a timed-out request so a later load can retry", async () => {
|
|
207
|
+
vi.useFakeTimers();
|
|
208
|
+
try {
|
|
209
|
+
const listeners = new Map<string, (packet: unknown) => void>();
|
|
210
|
+
let respond = false;
|
|
211
|
+
const socket = {
|
|
212
|
+
on: vi.fn((event: string, callback: (packet: unknown) => void) => listeners.set(event, callback)),
|
|
213
|
+
emit: vi.fn((event: string) => {
|
|
214
|
+
if (!respond || event !== MAP_STREAM_REQUEST_EVENT) return;
|
|
215
|
+
listeners.get(MAP_STREAM_EVENT)?.({
|
|
216
|
+
mapId: "demo",
|
|
217
|
+
revision: "one",
|
|
218
|
+
manifest: manifest("one"),
|
|
219
|
+
chunks: [chunk("0:0", 0)],
|
|
220
|
+
removed: [],
|
|
221
|
+
});
|
|
222
|
+
}),
|
|
223
|
+
};
|
|
224
|
+
const { loader } = await createStreamingLoader(socket, 20);
|
|
225
|
+
|
|
226
|
+
const rejection = expect(loader.load("demo")).rejects.toThrow(
|
|
227
|
+
"Map stream 'demo' was not received after 20ms",
|
|
228
|
+
);
|
|
229
|
+
await vi.advanceTimersByTimeAsync(20);
|
|
230
|
+
await rejection;
|
|
231
|
+
|
|
232
|
+
respond = true;
|
|
233
|
+
await expect(loader.load("demo")).resolves.toMatchObject({ id: "demo" });
|
|
234
|
+
expect(socket.emit).toHaveBeenCalledTimes(2);
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
vi.useRealTimers();
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { Context, inject } from "@signe/di";
|
|
2
|
+
import {
|
|
3
|
+
MAP_STREAM_EVENT,
|
|
4
|
+
MAP_STREAM_REQUEST_EVENT,
|
|
5
|
+
type MapChunkHitbox,
|
|
6
|
+
type MapStreamChunk,
|
|
7
|
+
type MapStreamManifest,
|
|
8
|
+
type MapStreamPacket,
|
|
9
|
+
type RpgContext,
|
|
10
|
+
type RpgProvider,
|
|
11
|
+
} from "@rpgjs/common";
|
|
12
|
+
import { AbstractWebsocket, WebSocketToken } from "./AbstractSocket";
|
|
13
|
+
import { LoadMapToken, type MapData, type LoadMapOptions } from "./loadMap";
|
|
14
|
+
import { UpdateMapService, UpdateMapToken } from "@rpgjs/common";
|
|
15
|
+
|
|
16
|
+
export interface ClientMapStreamingAdapter<TManifestData = unknown, TChunkData = unknown, TState = unknown> {
|
|
17
|
+
/** CanvasEngine component that renders the provider-specific state. */
|
|
18
|
+
component: unknown;
|
|
19
|
+
/** Create empty client render state from public manifest data. */
|
|
20
|
+
createState(manifest: MapStreamManifest<TManifestData>): TState;
|
|
21
|
+
/** Apply one disclosed render chunk to the state. */
|
|
22
|
+
applyChunk(state: TState, chunk: MapStreamChunk<TChunkData>): void;
|
|
23
|
+
/** Remove one chunk that left the retention window. */
|
|
24
|
+
removeChunk(state: TState, key: string): void;
|
|
25
|
+
/** Return the serializable/component-ready map value exposed to CanvasEngine. */
|
|
26
|
+
getData(state: TState): unknown;
|
|
27
|
+
/** Optionally derive component parameters from the public manifest. */
|
|
28
|
+
getParams?(manifest: MapStreamManifest<TManifestData>): Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ClientMapStreamingOptions<TManifestData = unknown, TChunkData = unknown, TState = unknown> {
|
|
32
|
+
/** Format adapter paired with the server compiler. */
|
|
33
|
+
adapter: ClientMapStreamingAdapter<TManifestData, TChunkData, TState>;
|
|
34
|
+
/** Optional direct loader used only by standalone RPG mode. */
|
|
35
|
+
directLoad?: LoadMapOptions;
|
|
36
|
+
/** Maximum wait for the initial authoritative manifest. Defaults to 10 seconds. */
|
|
37
|
+
timeoutMs?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type StreamingPhysicsMap = {
|
|
41
|
+
data: { (): MapData | null; set(value: MapData): void };
|
|
42
|
+
replaceStreamedStaticHitboxes(namespace: string, hitboxes: MapChunkHitbox[]): void;
|
|
43
|
+
clearStreamedStaticHitboxes(namespace: string): void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
type Waiter = {
|
|
47
|
+
resolve(controller: MapStreamClientController<any, any, any>): void;
|
|
48
|
+
reject(error: Error): void;
|
|
49
|
+
timer: ReturnType<typeof setTimeout>;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export class MapStreamClientController<TManifestData, TChunkData, TState> {
|
|
53
|
+
private state: TState;
|
|
54
|
+
private manifest: MapStreamManifest<TManifestData>;
|
|
55
|
+
private readonly chunks = new Map<string, MapStreamChunk<TChunkData>>();
|
|
56
|
+
private attachedMap?: StreamingPhysicsMap;
|
|
57
|
+
|
|
58
|
+
constructor(
|
|
59
|
+
private readonly adapter: ClientMapStreamingAdapter<TManifestData, TChunkData, TState>,
|
|
60
|
+
manifest: MapStreamManifest<TManifestData>,
|
|
61
|
+
) {
|
|
62
|
+
this.manifest = manifest;
|
|
63
|
+
this.state = adapter.createState(manifest);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get revision(): string {
|
|
67
|
+
return this.manifest.revision;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
reset(manifest: MapStreamManifest<TManifestData>): void {
|
|
71
|
+
const attachedMap = this.attachedMap;
|
|
72
|
+
this.detach();
|
|
73
|
+
this.manifest = manifest;
|
|
74
|
+
this.state = this.adapter.createState(manifest);
|
|
75
|
+
this.chunks.clear();
|
|
76
|
+
this.attachedMap = attachedMap;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
receive(packet: MapStreamPacket<TManifestData, TChunkData>): void {
|
|
80
|
+
for (const key of packet.removed) {
|
|
81
|
+
this.chunks.delete(key);
|
|
82
|
+
this.adapter.removeChunk(this.state, key);
|
|
83
|
+
this.attachedMap?.clearStreamedStaticHitboxes(key);
|
|
84
|
+
}
|
|
85
|
+
for (const chunk of packet.chunks) {
|
|
86
|
+
this.chunks.set(chunk.key, chunk);
|
|
87
|
+
this.adapter.applyChunk(this.state, chunk);
|
|
88
|
+
this.attachedMap?.replaceStreamedStaticHitboxes(chunk.key, chunk.hitboxes);
|
|
89
|
+
}
|
|
90
|
+
this.publish();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
toMapData(): MapData {
|
|
94
|
+
return {
|
|
95
|
+
id: this.manifest.mapId,
|
|
96
|
+
width: this.manifest.width,
|
|
97
|
+
height: this.manifest.height,
|
|
98
|
+
data: this.adapter.getData(this.state),
|
|
99
|
+
component: this.adapter.component,
|
|
100
|
+
params: this.adapter.getParams?.(this.manifest),
|
|
101
|
+
streamController: this,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
attach(map: StreamingPhysicsMap): void {
|
|
106
|
+
this.attachedMap = map;
|
|
107
|
+
for (const chunk of this.chunks.values()) {
|
|
108
|
+
map.replaceStreamedStaticHitboxes(chunk.key, chunk.hitboxes);
|
|
109
|
+
}
|
|
110
|
+
this.updatePredictionBoundary();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
detach(): void {
|
|
114
|
+
if (!this.attachedMap) return;
|
|
115
|
+
for (const key of this.chunks.keys()) {
|
|
116
|
+
this.attachedMap.clearStreamedStaticHitboxes(key);
|
|
117
|
+
}
|
|
118
|
+
this.attachedMap.clearStreamedStaticHitboxes("__boundary__");
|
|
119
|
+
this.attachedMap = undefined;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private publish(): void {
|
|
123
|
+
if (!this.attachedMap) return;
|
|
124
|
+
const current = this.attachedMap.data();
|
|
125
|
+
if (current) {
|
|
126
|
+
this.attachedMap.data.set({ ...current, data: this.adapter.getData(this.state) });
|
|
127
|
+
}
|
|
128
|
+
this.updatePredictionBoundary();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private updatePredictionBoundary(): void {
|
|
132
|
+
const map = this.attachedMap;
|
|
133
|
+
if (!map) return;
|
|
134
|
+
if (this.chunks.size === 0) {
|
|
135
|
+
map.clearStreamedStaticHitboxes("__boundary__");
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const chunks = [...this.chunks.values()];
|
|
139
|
+
const left = Math.min(...chunks.map((chunk) => chunk.bounds.x));
|
|
140
|
+
const top = Math.min(...chunks.map((chunk) => chunk.bounds.y));
|
|
141
|
+
const right = Math.max(...chunks.map((chunk) => chunk.bounds.x + chunk.bounds.width));
|
|
142
|
+
const bottom = Math.max(...chunks.map((chunk) => chunk.bounds.y + chunk.bounds.height));
|
|
143
|
+
const thickness = 2;
|
|
144
|
+
const barriers: MapChunkHitbox[] = [];
|
|
145
|
+
if (left > 0) barriers.push({ x: left - thickness, y: top, width: thickness, height: bottom - top });
|
|
146
|
+
if (top > 0) barriers.push({ x: left, y: top - thickness, width: right - left, height: thickness });
|
|
147
|
+
if (right < this.manifest.width) barriers.push({ x: right, y: top, width: thickness, height: bottom - top });
|
|
148
|
+
if (bottom < this.manifest.height) barriers.push({ x: left, y: bottom, width: right - left, height: thickness });
|
|
149
|
+
map.replaceStreamedStaticHitboxes("__boundary__", barriers);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
class MapStreamingClientService<TManifestData, TChunkData, TState> {
|
|
154
|
+
private readonly controllers = new Map<string, MapStreamClientController<TManifestData, TChunkData, TState>>();
|
|
155
|
+
private readonly waiters = new Map<string, Waiter[]>();
|
|
156
|
+
|
|
157
|
+
constructor(
|
|
158
|
+
private readonly socket: AbstractWebsocket,
|
|
159
|
+
private readonly options: ClientMapStreamingOptions<TManifestData, TChunkData, TState>,
|
|
160
|
+
) {
|
|
161
|
+
socket.on(MAP_STREAM_EVENT, (packet) => this.receive(packet));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async load(mapId: string): Promise<MapData> {
|
|
165
|
+
const normalizedId = mapId.replace(/^map-/, "");
|
|
166
|
+
const existing = this.controllers.get(normalizedId);
|
|
167
|
+
if (existing) {
|
|
168
|
+
this.socket.emit(MAP_STREAM_REQUEST_EVENT, { mapId: normalizedId });
|
|
169
|
+
return existing.toMapData();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const shouldRequest = !this.waiters.has(normalizedId);
|
|
173
|
+
const controllerPromise = new Promise<MapStreamClientController<TManifestData, TChunkData, TState>>((resolve, reject) => {
|
|
174
|
+
const timeoutMs = Math.max(1, this.options.timeoutMs ?? 10_000);
|
|
175
|
+
const waiter: Waiter = { resolve, reject, timer: undefined as unknown as ReturnType<typeof setTimeout> };
|
|
176
|
+
const timer = setTimeout(() => {
|
|
177
|
+
const current = this.waiters.get(normalizedId) ?? [];
|
|
178
|
+
const remaining = current.filter((entry) => entry !== waiter);
|
|
179
|
+
if (remaining.length > 0) this.waiters.set(normalizedId, remaining);
|
|
180
|
+
else this.waiters.delete(normalizedId);
|
|
181
|
+
reject(new Error(`Map stream '${normalizedId}' was not received after ${timeoutMs}ms`));
|
|
182
|
+
}, timeoutMs);
|
|
183
|
+
waiter.timer = timer;
|
|
184
|
+
const waiters = this.waiters.get(normalizedId) ?? [];
|
|
185
|
+
waiters.push(waiter);
|
|
186
|
+
this.waiters.set(normalizedId, waiters);
|
|
187
|
+
});
|
|
188
|
+
// The map-room connection is established before load() runs. Request the
|
|
189
|
+
// initial packet only after the waiter is registered so fast local and DO
|
|
190
|
+
// transports cannot deliver it before the client is ready.
|
|
191
|
+
if (shouldRequest) {
|
|
192
|
+
this.socket.emit(MAP_STREAM_REQUEST_EVENT, { mapId: normalizedId });
|
|
193
|
+
}
|
|
194
|
+
return (await controllerPromise).toMapData();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private receive(packet: MapStreamPacket<TManifestData, TChunkData>): void {
|
|
198
|
+
if (!packet || typeof packet.mapId !== "string") return;
|
|
199
|
+
const mapId = packet.mapId.replace(/^map-/, "");
|
|
200
|
+
let controller = this.controllers.get(mapId);
|
|
201
|
+
if (!controller) {
|
|
202
|
+
if (!packet.manifest) return;
|
|
203
|
+
controller = new MapStreamClientController(this.options.adapter, packet.manifest);
|
|
204
|
+
this.controllers.set(mapId, controller);
|
|
205
|
+
}
|
|
206
|
+
else if (packet.manifest && controller.revision !== packet.manifest.revision) {
|
|
207
|
+
controller.reset(packet.manifest);
|
|
208
|
+
}
|
|
209
|
+
controller.receive(packet);
|
|
210
|
+
|
|
211
|
+
const waiters = this.waiters.get(mapId) ?? [];
|
|
212
|
+
waiters.forEach((waiter) => {
|
|
213
|
+
clearTimeout(waiter.timer);
|
|
214
|
+
waiter.resolve(controller!);
|
|
215
|
+
});
|
|
216
|
+
this.waiters.delete(mapId);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
class MapStreamingLoadMapService<TManifestData, TChunkData, TState> {
|
|
221
|
+
private socket?: AbstractWebsocket;
|
|
222
|
+
private stream?: MapStreamingClientService<TManifestData, TChunkData, TState>;
|
|
223
|
+
private updateMap?: UpdateMapService;
|
|
224
|
+
|
|
225
|
+
constructor(
|
|
226
|
+
private readonly context: RpgContext,
|
|
227
|
+
private readonly options: ClientMapStreamingOptions<TManifestData, TChunkData, TState>,
|
|
228
|
+
) {}
|
|
229
|
+
|
|
230
|
+
initialize(): void {
|
|
231
|
+
if (this.stream) return;
|
|
232
|
+
this.socket = inject<AbstractWebsocket>(this.context as Context, WebSocketToken);
|
|
233
|
+
this.stream = new MapStreamingClientService(this.socket, this.options);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async load(mapId: string): Promise<MapData> {
|
|
237
|
+
this.initialize();
|
|
238
|
+
const map = this.socket?.mode === "standalone" && this.options.directLoad
|
|
239
|
+
? await this.options.directLoad(mapId.replace(/^map-/, ""))
|
|
240
|
+
: await this.stream!.load(mapId);
|
|
241
|
+
this.updateMap ??= inject<UpdateMapService>(this.context as Context, UpdateMapToken);
|
|
242
|
+
await this.updateMap.update(map);
|
|
243
|
+
return map;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Provide a transport-neutral client map loader backed by authoritative chunks.
|
|
249
|
+
* Standalone mode may keep a direct loader while MMORPG mode never fetches the
|
|
250
|
+
* private source map.
|
|
251
|
+
*
|
|
252
|
+
* @param options - Client adapter, optional standalone loader, and timeout.
|
|
253
|
+
* @returns Dependency-injection providers for the RPGJS map loader.
|
|
254
|
+
*
|
|
255
|
+
* @example
|
|
256
|
+
* ```ts
|
|
257
|
+
* provideClientMapStreaming({
|
|
258
|
+
* adapter: {
|
|
259
|
+
* component: MyMap,
|
|
260
|
+
* createState: (manifest) => ({ manifest, chunks: new Map() }),
|
|
261
|
+
* applyChunk: (state, chunk) => state.chunks.set(chunk.key, chunk.renderData),
|
|
262
|
+
* removeChunk: (state, key) => state.chunks.delete(key),
|
|
263
|
+
* getData: (state) => state,
|
|
264
|
+
* },
|
|
265
|
+
* })
|
|
266
|
+
* ```
|
|
267
|
+
*/
|
|
268
|
+
export function provideClientMapStreaming<TManifestData, TChunkData, TState>(
|
|
269
|
+
options: ClientMapStreamingOptions<TManifestData, TChunkData, TState>,
|
|
270
|
+
): RpgProvider[] {
|
|
271
|
+
return [
|
|
272
|
+
{
|
|
273
|
+
provide: LoadMapToken,
|
|
274
|
+
useFactory: (context: RpgContext) => new MapStreamingLoadMapService(context, options),
|
|
275
|
+
},
|
|
276
|
+
];
|
|
277
|
+
}
|
package/src/services/mmorpg.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
|
-
import { Context } from "@signe/di";
|
|
2
1
|
import { connectionRoom } from "@signe/sync/client";
|
|
3
2
|
import { RpgGui } from "../Gui/Gui";
|
|
4
3
|
import { RpgClientEngine } from "../RpgClientEngine";
|
|
5
4
|
import { AbstractWebsocket, SocketQuery, SocketUpdateProperties, WebSocketToken } from "./AbstractSocket";
|
|
6
|
-
import { UpdateMapService, UpdateMapToken } from "@rpgjs/common";
|
|
5
|
+
import { UpdateMapService, UpdateMapToken, type RpgContext, type RpgProvider } from "@rpgjs/common";
|
|
7
6
|
import { provideKeyboardControls } from "./keyboardControls";
|
|
8
7
|
import { provideSaveClient } from "./save";
|
|
9
8
|
import { isNativeSocketEvent, waitForRpgjsConnected } from "./mmorpg-connection";
|
|
@@ -14,6 +13,12 @@ export interface MmorpgOptions {
|
|
|
14
13
|
connectionIdScope?: "local" | "session" | "ephemeral";
|
|
15
14
|
query?: SocketQuery | (() => SocketQuery | undefined);
|
|
16
15
|
socketOptions?: Record<string, any>;
|
|
16
|
+
/**
|
|
17
|
+
* Time allowed for a room to restore and send the RPGJS acceptance packet.
|
|
18
|
+
* Increase this for cold edge rooms or local Durable Object startup.
|
|
19
|
+
* Defaults to 10 seconds.
|
|
20
|
+
*/
|
|
21
|
+
connectionAcceptanceTimeoutMs?: number;
|
|
17
22
|
}
|
|
18
23
|
|
|
19
24
|
export class BridgeWebsocket extends AbstractWebsocket {
|
|
@@ -25,7 +30,7 @@ export class BridgeWebsocket extends AbstractWebsocket {
|
|
|
25
30
|
private acceptedOpenListeners = new Set<(data: any) => void>();
|
|
26
31
|
private targetRoom = "lobby-1";
|
|
27
32
|
|
|
28
|
-
constructor(protected context:
|
|
33
|
+
constructor(protected context: RpgContext, private options: MmorpgOptions = {}) {
|
|
29
34
|
super(context);
|
|
30
35
|
this.privateId = this.resolveConnectionId();
|
|
31
36
|
}
|
|
@@ -89,7 +94,10 @@ export class BridgeWebsocket extends AbstractWebsocket {
|
|
|
89
94
|
pendingOn
|
|
90
95
|
.filter(({ event }) => !this.isNativeSocketEvent(event))
|
|
91
96
|
.forEach(({ event, callback }) => this.attachEvent(event, callback));
|
|
92
|
-
await waitForRpgjsConnected(
|
|
97
|
+
await waitForRpgjsConnected(
|
|
98
|
+
this.socket.conn,
|
|
99
|
+
this.options.connectionAcceptanceTimeoutMs ?? 10_000,
|
|
100
|
+
);
|
|
93
101
|
pendingOn
|
|
94
102
|
.filter(({ event }) => this.isNativeSocketEvent(event))
|
|
95
103
|
.forEach(({ event, callback }) => this.attachEvent(event, callback));
|
|
@@ -161,7 +169,11 @@ export class BridgeWebsocket extends AbstractWebsocket {
|
|
|
161
169
|
async reconnect(_listeners?: (data: any) => void): Promise<void> {
|
|
162
170
|
if (!this.socket?.conn) return;
|
|
163
171
|
const conn = this.socket.conn;
|
|
164
|
-
const connected = waitForRpgjsConnected(
|
|
172
|
+
const connected = waitForRpgjsConnected(
|
|
173
|
+
conn,
|
|
174
|
+
this.options.connectionAcceptanceTimeoutMs ?? 10_000,
|
|
175
|
+
{ ignoreCleanClose: true },
|
|
176
|
+
);
|
|
165
177
|
conn.reconnect();
|
|
166
178
|
await connected;
|
|
167
179
|
this.emitAcceptedOpen();
|
|
@@ -173,7 +185,7 @@ export class BridgeWebsocket extends AbstractWebsocket {
|
|
|
173
185
|
}
|
|
174
186
|
|
|
175
187
|
class UpdateMapStandaloneService extends UpdateMapService {
|
|
176
|
-
constructor(protected context:
|
|
188
|
+
constructor(protected context: RpgContext, private _options: MmorpgOptions) {
|
|
177
189
|
super(context);
|
|
178
190
|
}
|
|
179
191
|
|
|
@@ -184,15 +196,15 @@ class UpdateMapStandaloneService extends UpdateMapService {
|
|
|
184
196
|
}
|
|
185
197
|
}
|
|
186
198
|
|
|
187
|
-
export function provideMmorpg(options: MmorpgOptions) {
|
|
199
|
+
export function provideMmorpg(options: MmorpgOptions): RpgProvider[] {
|
|
188
200
|
return [
|
|
189
201
|
{
|
|
190
202
|
provide: WebSocketToken,
|
|
191
|
-
useFactory: (context:
|
|
203
|
+
useFactory: (context: RpgContext) => new BridgeWebsocket(context, options),
|
|
192
204
|
},
|
|
193
205
|
{
|
|
194
206
|
provide: UpdateMapToken,
|
|
195
|
-
useFactory: (context:
|
|
207
|
+
useFactory: (context: RpgContext) => new UpdateMapStandaloneService(context, options),
|
|
196
208
|
},
|
|
197
209
|
provideKeyboardControls(),
|
|
198
210
|
provideSaveClient(),
|