@rpgjs/client 5.0.0-beta.24 → 5.0.0-beta.26
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 +64 -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/Gui/Gui.js +5 -0
- 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/gui/dialogbox/index.ce.js +23 -2
- package/dist/components/gui/dialogbox/index.ce.js.map +1 -1
- package/dist/components/gui/index.d.ts +3 -1
- package/dist/components/gui/index.js +2 -0
- package/dist/components/gui/input-field.ce.js +123 -0
- package/dist/components/gui/input-field.ce.js.map +1 -0
- package/dist/components/gui/input.ce.js +40 -0
- package/dist/components/gui/input.ce.js.map +1 -0
- 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/i18n.d.ts +12 -0
- package/dist/i18n.js +12 -0
- package/dist/i18n.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +15 -11
- package/dist/services/loadMap.d.ts +10 -2
- package/dist/services/loadMap.js +2 -1
- package/dist/services/loadMap.js.map +1 -1
- package/dist/services/mapStreaming.d.ts +85 -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 +6 -0
- package/dist/services/mmorpg.js +2 -2
- package/dist/services/mmorpg.js.map +1 -1
- package/package.json +4 -4
- package/src/Game/Map.ts +7 -0
- package/src/Gui/Gui.spec.ts +5 -0
- package/src/Gui/Gui.ts +5 -1
- package/src/RpgClient.ts +2 -1
- package/src/RpgClientEngine.ts +147 -6
- package/src/components/gui/dialogbox/index.ce +26 -1
- package/src/components/gui/index.ts +5 -1
- package/src/components/gui/input-field.ce +106 -0
- package/src/components/gui/input.ce +26 -0
- 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/i18n.spec.ts +2 -1
- package/src/i18n.ts +12 -0
- package/src/index.ts +2 -0
- package/src/services/loadMap.ts +9 -2
- package/src/services/mapStreaming.spec.ts +240 -0
- package/src/services/mapStreaming.ts +275 -0
- package/src/services/mmorpg.ts +15 -2
- package/vite.config.ts +5 -2
package/src/i18n.ts
CHANGED
|
@@ -40,6 +40,18 @@ export const RpgClientBuiltinI18n = {
|
|
|
40
40
|
"rpg.gameover.title": "Game Over",
|
|
41
41
|
"rpg.gameover.title-screen": "Title Screen",
|
|
42
42
|
"rpg.gameover.load-game": "Load Game",
|
|
43
|
+
"rpg.input.confirm": "Confirm",
|
|
44
|
+
"rpg.input.cancel": "Cancel",
|
|
45
|
+
"rpg.input.error.invalid": "Invalid value.",
|
|
46
|
+
"rpg.input.error.required": "A value is required.",
|
|
47
|
+
"rpg.input.error.number": "Enter a valid number.",
|
|
48
|
+
"rpg.input.error.min": "The value must be at least {min}.",
|
|
49
|
+
"rpg.input.error.max": "The value must be at most {max}.",
|
|
50
|
+
"rpg.input.error.step": "The value must use increments of {step}.",
|
|
51
|
+
"rpg.input.error.min-length": "Enter at least {minLength} characters.",
|
|
52
|
+
"rpg.input.error.max-length": "Enter no more than {maxLength} characters.",
|
|
53
|
+
"rpg.input.error.email": "Enter a valid email address.",
|
|
54
|
+
"rpg.transition.loading": "Loading area…",
|
|
43
55
|
"rpg.shop.default-message": "Welcome to my shop!",
|
|
44
56
|
"rpg.shop.choose-action": "Choose an action",
|
|
45
57
|
"rpg.shop.buy": "Buy",
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export * from "./core/setup";
|
|
|
7
7
|
export * from "./core/inject";
|
|
8
8
|
export * from "./services/loadMap";
|
|
9
9
|
export * from "./services/actionInput";
|
|
10
|
+
export * from "./services/mapStreaming";
|
|
10
11
|
export * from "./services/pointerContext";
|
|
11
12
|
export * from "./services/interactions";
|
|
12
13
|
export * from "./module";
|
|
@@ -24,6 +25,7 @@ export * from "./utils/getEntityProp";
|
|
|
24
25
|
export { Context } from "@signe/di";
|
|
25
26
|
export { KeyboardControls, Input } from "canvasengine";
|
|
26
27
|
export { Control } from "./services/keyboardControls";
|
|
28
|
+
export { defineModule } from "@rpgjs/common";
|
|
27
29
|
export { RpgClientObject } from "./Game/Object";
|
|
28
30
|
export { RpgClientPlayer } from "./Game/Player";
|
|
29
31
|
export { RpgClientEvent } from "./Game/Event";
|
package/src/services/loadMap.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Context, inject } from "@signe/di";
|
|
2
2
|
import { UpdateMapToken, UpdateMapService, type LightingState } 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
|
/**
|
|
@@ -46,10 +51,12 @@ export class LoadMapService {
|
|
|
46
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, UpdateMapToken);
|
|
53
60
|
const map = await this.options(mapId.replace('map-', ''))
|
|
54
61
|
await this.updateMapService.update(map);
|
|
55
62
|
return map;
|
|
@@ -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,275 @@
|
|
|
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
|
+
} from "@rpgjs/common";
|
|
10
|
+
import { AbstractWebsocket, WebSocketToken } from "./AbstractSocket";
|
|
11
|
+
import { LoadMapToken, type MapData, type LoadMapOptions } from "./loadMap";
|
|
12
|
+
import { UpdateMapService, UpdateMapToken } from "@rpgjs/common";
|
|
13
|
+
|
|
14
|
+
export interface ClientMapStreamingAdapter<TManifestData = unknown, TChunkData = unknown, TState = unknown> {
|
|
15
|
+
/** CanvasEngine component that renders the provider-specific state. */
|
|
16
|
+
component: unknown;
|
|
17
|
+
/** Create empty client render state from public manifest data. */
|
|
18
|
+
createState(manifest: MapStreamManifest<TManifestData>): TState;
|
|
19
|
+
/** Apply one disclosed render chunk to the state. */
|
|
20
|
+
applyChunk(state: TState, chunk: MapStreamChunk<TChunkData>): void;
|
|
21
|
+
/** Remove one chunk that left the retention window. */
|
|
22
|
+
removeChunk(state: TState, key: string): void;
|
|
23
|
+
/** Return the serializable/component-ready map value exposed to CanvasEngine. */
|
|
24
|
+
getData(state: TState): unknown;
|
|
25
|
+
/** Optionally derive component parameters from the public manifest. */
|
|
26
|
+
getParams?(manifest: MapStreamManifest<TManifestData>): Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ClientMapStreamingOptions<TManifestData = unknown, TChunkData = unknown, TState = unknown> {
|
|
30
|
+
/** Format adapter paired with the server compiler. */
|
|
31
|
+
adapter: ClientMapStreamingAdapter<TManifestData, TChunkData, TState>;
|
|
32
|
+
/** Optional direct loader used only by standalone RPG mode. */
|
|
33
|
+
directLoad?: LoadMapOptions;
|
|
34
|
+
/** Maximum wait for the initial authoritative manifest. Defaults to 10 seconds. */
|
|
35
|
+
timeoutMs?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type StreamingPhysicsMap = {
|
|
39
|
+
data: { (): MapData | null; set(value: MapData): void };
|
|
40
|
+
replaceStreamedStaticHitboxes(namespace: string, hitboxes: MapChunkHitbox[]): void;
|
|
41
|
+
clearStreamedStaticHitboxes(namespace: string): void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
type Waiter = {
|
|
45
|
+
resolve(controller: MapStreamClientController<any, any, any>): void;
|
|
46
|
+
reject(error: Error): void;
|
|
47
|
+
timer: ReturnType<typeof setTimeout>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export class MapStreamClientController<TManifestData, TChunkData, TState> {
|
|
51
|
+
private state: TState;
|
|
52
|
+
private manifest: MapStreamManifest<TManifestData>;
|
|
53
|
+
private readonly chunks = new Map<string, MapStreamChunk<TChunkData>>();
|
|
54
|
+
private attachedMap?: StreamingPhysicsMap;
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
private readonly adapter: ClientMapStreamingAdapter<TManifestData, TChunkData, TState>,
|
|
58
|
+
manifest: MapStreamManifest<TManifestData>,
|
|
59
|
+
) {
|
|
60
|
+
this.manifest = manifest;
|
|
61
|
+
this.state = adapter.createState(manifest);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get revision(): string {
|
|
65
|
+
return this.manifest.revision;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
reset(manifest: MapStreamManifest<TManifestData>): void {
|
|
69
|
+
const attachedMap = this.attachedMap;
|
|
70
|
+
this.detach();
|
|
71
|
+
this.manifest = manifest;
|
|
72
|
+
this.state = this.adapter.createState(manifest);
|
|
73
|
+
this.chunks.clear();
|
|
74
|
+
this.attachedMap = attachedMap;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
receive(packet: MapStreamPacket<TManifestData, TChunkData>): void {
|
|
78
|
+
for (const key of packet.removed) {
|
|
79
|
+
this.chunks.delete(key);
|
|
80
|
+
this.adapter.removeChunk(this.state, key);
|
|
81
|
+
this.attachedMap?.clearStreamedStaticHitboxes(key);
|
|
82
|
+
}
|
|
83
|
+
for (const chunk of packet.chunks) {
|
|
84
|
+
this.chunks.set(chunk.key, chunk);
|
|
85
|
+
this.adapter.applyChunk(this.state, chunk);
|
|
86
|
+
this.attachedMap?.replaceStreamedStaticHitboxes(chunk.key, chunk.hitboxes);
|
|
87
|
+
}
|
|
88
|
+
this.publish();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
toMapData(): MapData {
|
|
92
|
+
return {
|
|
93
|
+
id: this.manifest.mapId,
|
|
94
|
+
width: this.manifest.width,
|
|
95
|
+
height: this.manifest.height,
|
|
96
|
+
data: this.adapter.getData(this.state),
|
|
97
|
+
component: this.adapter.component,
|
|
98
|
+
params: this.adapter.getParams?.(this.manifest),
|
|
99
|
+
streamController: this,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
attach(map: StreamingPhysicsMap): void {
|
|
104
|
+
this.attachedMap = map;
|
|
105
|
+
for (const chunk of this.chunks.values()) {
|
|
106
|
+
map.replaceStreamedStaticHitboxes(chunk.key, chunk.hitboxes);
|
|
107
|
+
}
|
|
108
|
+
this.updatePredictionBoundary();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
detach(): void {
|
|
112
|
+
if (!this.attachedMap) return;
|
|
113
|
+
for (const key of this.chunks.keys()) {
|
|
114
|
+
this.attachedMap.clearStreamedStaticHitboxes(key);
|
|
115
|
+
}
|
|
116
|
+
this.attachedMap.clearStreamedStaticHitboxes("__boundary__");
|
|
117
|
+
this.attachedMap = undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private publish(): void {
|
|
121
|
+
if (!this.attachedMap) return;
|
|
122
|
+
const current = this.attachedMap.data();
|
|
123
|
+
if (current) {
|
|
124
|
+
this.attachedMap.data.set({ ...current, data: this.adapter.getData(this.state) });
|
|
125
|
+
}
|
|
126
|
+
this.updatePredictionBoundary();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private updatePredictionBoundary(): void {
|
|
130
|
+
const map = this.attachedMap;
|
|
131
|
+
if (!map) return;
|
|
132
|
+
if (this.chunks.size === 0) {
|
|
133
|
+
map.clearStreamedStaticHitboxes("__boundary__");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const chunks = [...this.chunks.values()];
|
|
137
|
+
const left = Math.min(...chunks.map((chunk) => chunk.bounds.x));
|
|
138
|
+
const top = Math.min(...chunks.map((chunk) => chunk.bounds.y));
|
|
139
|
+
const right = Math.max(...chunks.map((chunk) => chunk.bounds.x + chunk.bounds.width));
|
|
140
|
+
const bottom = Math.max(...chunks.map((chunk) => chunk.bounds.y + chunk.bounds.height));
|
|
141
|
+
const thickness = 2;
|
|
142
|
+
const barriers: MapChunkHitbox[] = [];
|
|
143
|
+
if (left > 0) barriers.push({ x: left - thickness, y: top, width: thickness, height: bottom - top });
|
|
144
|
+
if (top > 0) barriers.push({ x: left, y: top - thickness, width: right - left, height: thickness });
|
|
145
|
+
if (right < this.manifest.width) barriers.push({ x: right, y: top, width: thickness, height: bottom - top });
|
|
146
|
+
if (bottom < this.manifest.height) barriers.push({ x: left, y: bottom, width: right - left, height: thickness });
|
|
147
|
+
map.replaceStreamedStaticHitboxes("__boundary__", barriers);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
class MapStreamingClientService<TManifestData, TChunkData, TState> {
|
|
152
|
+
private readonly controllers = new Map<string, MapStreamClientController<TManifestData, TChunkData, TState>>();
|
|
153
|
+
private readonly waiters = new Map<string, Waiter[]>();
|
|
154
|
+
|
|
155
|
+
constructor(
|
|
156
|
+
private readonly socket: AbstractWebsocket,
|
|
157
|
+
private readonly options: ClientMapStreamingOptions<TManifestData, TChunkData, TState>,
|
|
158
|
+
) {
|
|
159
|
+
socket.on(MAP_STREAM_EVENT, (packet) => this.receive(packet));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async load(mapId: string): Promise<MapData> {
|
|
163
|
+
const normalizedId = mapId.replace(/^map-/, "");
|
|
164
|
+
const existing = this.controllers.get(normalizedId);
|
|
165
|
+
if (existing) {
|
|
166
|
+
this.socket.emit(MAP_STREAM_REQUEST_EVENT, { mapId: normalizedId });
|
|
167
|
+
return existing.toMapData();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const shouldRequest = !this.waiters.has(normalizedId);
|
|
171
|
+
const controllerPromise = new Promise<MapStreamClientController<TManifestData, TChunkData, TState>>((resolve, reject) => {
|
|
172
|
+
const timeoutMs = Math.max(1, this.options.timeoutMs ?? 10_000);
|
|
173
|
+
const waiter: Waiter = { resolve, reject, timer: undefined as unknown as ReturnType<typeof setTimeout> };
|
|
174
|
+
const timer = setTimeout(() => {
|
|
175
|
+
const current = this.waiters.get(normalizedId) ?? [];
|
|
176
|
+
const remaining = current.filter((entry) => entry !== waiter);
|
|
177
|
+
if (remaining.length > 0) this.waiters.set(normalizedId, remaining);
|
|
178
|
+
else this.waiters.delete(normalizedId);
|
|
179
|
+
reject(new Error(`Map stream '${normalizedId}' was not received after ${timeoutMs}ms`));
|
|
180
|
+
}, timeoutMs);
|
|
181
|
+
waiter.timer = timer;
|
|
182
|
+
const waiters = this.waiters.get(normalizedId) ?? [];
|
|
183
|
+
waiters.push(waiter);
|
|
184
|
+
this.waiters.set(normalizedId, waiters);
|
|
185
|
+
});
|
|
186
|
+
// The map-room connection is established before load() runs. Request the
|
|
187
|
+
// initial packet only after the waiter is registered so fast local and DO
|
|
188
|
+
// transports cannot deliver it before the client is ready.
|
|
189
|
+
if (shouldRequest) {
|
|
190
|
+
this.socket.emit(MAP_STREAM_REQUEST_EVENT, { mapId: normalizedId });
|
|
191
|
+
}
|
|
192
|
+
return (await controllerPromise).toMapData();
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private receive(packet: MapStreamPacket<TManifestData, TChunkData>): void {
|
|
196
|
+
if (!packet || typeof packet.mapId !== "string") return;
|
|
197
|
+
const mapId = packet.mapId.replace(/^map-/, "");
|
|
198
|
+
let controller = this.controllers.get(mapId);
|
|
199
|
+
if (!controller) {
|
|
200
|
+
if (!packet.manifest) return;
|
|
201
|
+
controller = new MapStreamClientController(this.options.adapter, packet.manifest);
|
|
202
|
+
this.controllers.set(mapId, controller);
|
|
203
|
+
}
|
|
204
|
+
else if (packet.manifest && controller.revision !== packet.manifest.revision) {
|
|
205
|
+
controller.reset(packet.manifest);
|
|
206
|
+
}
|
|
207
|
+
controller.receive(packet);
|
|
208
|
+
|
|
209
|
+
const waiters = this.waiters.get(mapId) ?? [];
|
|
210
|
+
waiters.forEach((waiter) => {
|
|
211
|
+
clearTimeout(waiter.timer);
|
|
212
|
+
waiter.resolve(controller!);
|
|
213
|
+
});
|
|
214
|
+
this.waiters.delete(mapId);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
class MapStreamingLoadMapService<TManifestData, TChunkData, TState> {
|
|
219
|
+
private socket?: AbstractWebsocket;
|
|
220
|
+
private stream?: MapStreamingClientService<TManifestData, TChunkData, TState>;
|
|
221
|
+
private updateMap?: UpdateMapService;
|
|
222
|
+
|
|
223
|
+
constructor(
|
|
224
|
+
private readonly context: Context,
|
|
225
|
+
private readonly options: ClientMapStreamingOptions<TManifestData, TChunkData, TState>,
|
|
226
|
+
) {}
|
|
227
|
+
|
|
228
|
+
initialize(): void {
|
|
229
|
+
if (this.stream) return;
|
|
230
|
+
this.socket = inject<AbstractWebsocket>(this.context, WebSocketToken);
|
|
231
|
+
this.stream = new MapStreamingClientService(this.socket, this.options);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async load(mapId: string): Promise<MapData> {
|
|
235
|
+
this.initialize();
|
|
236
|
+
const map = this.socket?.mode === "standalone" && this.options.directLoad
|
|
237
|
+
? await this.options.directLoad(mapId.replace(/^map-/, ""))
|
|
238
|
+
: await this.stream!.load(mapId);
|
|
239
|
+
this.updateMap ??= inject<UpdateMapService>(this.context, UpdateMapToken);
|
|
240
|
+
await this.updateMap.update(map);
|
|
241
|
+
return map;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Provide a transport-neutral client map loader backed by authoritative chunks.
|
|
247
|
+
* Standalone mode may keep a direct loader while MMORPG mode never fetches the
|
|
248
|
+
* private source map.
|
|
249
|
+
*
|
|
250
|
+
* @param options - Client adapter, optional standalone loader, and timeout.
|
|
251
|
+
* @returns Dependency-injection providers for the RPGJS map loader.
|
|
252
|
+
*
|
|
253
|
+
* @example
|
|
254
|
+
* ```ts
|
|
255
|
+
* provideClientMapStreaming({
|
|
256
|
+
* adapter: {
|
|
257
|
+
* component: MyMap,
|
|
258
|
+
* createState: (manifest) => ({ manifest, chunks: new Map() }),
|
|
259
|
+
* applyChunk: (state, chunk) => state.chunks.set(chunk.key, chunk.renderData),
|
|
260
|
+
* removeChunk: (state, key) => state.chunks.delete(key),
|
|
261
|
+
* getData: (state) => state,
|
|
262
|
+
* },
|
|
263
|
+
* })
|
|
264
|
+
* ```
|
|
265
|
+
*/
|
|
266
|
+
export function provideClientMapStreaming<TManifestData, TChunkData, TState>(
|
|
267
|
+
options: ClientMapStreamingOptions<TManifestData, TChunkData, TState>,
|
|
268
|
+
) {
|
|
269
|
+
return [
|
|
270
|
+
{
|
|
271
|
+
provide: LoadMapToken,
|
|
272
|
+
useFactory: (context: Context) => new MapStreamingLoadMapService(context, options),
|
|
273
|
+
},
|
|
274
|
+
];
|
|
275
|
+
}
|
package/src/services/mmorpg.ts
CHANGED
|
@@ -14,6 +14,12 @@ export interface MmorpgOptions {
|
|
|
14
14
|
connectionIdScope?: "local" | "session" | "ephemeral";
|
|
15
15
|
query?: SocketQuery | (() => SocketQuery | undefined);
|
|
16
16
|
socketOptions?: Record<string, any>;
|
|
17
|
+
/**
|
|
18
|
+
* Time allowed for a room to restore and send the RPGJS acceptance packet.
|
|
19
|
+
* Increase this for cold edge rooms or local Durable Object startup.
|
|
20
|
+
* Defaults to 10 seconds.
|
|
21
|
+
*/
|
|
22
|
+
connectionAcceptanceTimeoutMs?: number;
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
export class BridgeWebsocket extends AbstractWebsocket {
|
|
@@ -89,7 +95,10 @@ export class BridgeWebsocket extends AbstractWebsocket {
|
|
|
89
95
|
pendingOn
|
|
90
96
|
.filter(({ event }) => !this.isNativeSocketEvent(event))
|
|
91
97
|
.forEach(({ event, callback }) => this.attachEvent(event, callback));
|
|
92
|
-
await waitForRpgjsConnected(
|
|
98
|
+
await waitForRpgjsConnected(
|
|
99
|
+
this.socket.conn,
|
|
100
|
+
this.options.connectionAcceptanceTimeoutMs ?? 10_000,
|
|
101
|
+
);
|
|
93
102
|
pendingOn
|
|
94
103
|
.filter(({ event }) => this.isNativeSocketEvent(event))
|
|
95
104
|
.forEach(({ event, callback }) => this.attachEvent(event, callback));
|
|
@@ -161,7 +170,11 @@ export class BridgeWebsocket extends AbstractWebsocket {
|
|
|
161
170
|
async reconnect(_listeners?: (data: any) => void): Promise<void> {
|
|
162
171
|
if (!this.socket?.conn) return;
|
|
163
172
|
const conn = this.socket.conn;
|
|
164
|
-
const connected = waitForRpgjsConnected(
|
|
173
|
+
const connected = waitForRpgjsConnected(
|
|
174
|
+
conn,
|
|
175
|
+
this.options.connectionAcceptanceTimeoutMs ?? 10_000,
|
|
176
|
+
{ ignoreCleanClose: true },
|
|
177
|
+
);
|
|
165
178
|
conn.reconnect();
|
|
166
179
|
await connected;
|
|
167
180
|
this.emitAcceptedOpen();
|
package/vite.config.ts
CHANGED
|
@@ -11,7 +11,10 @@ export default defineConfig({
|
|
|
11
11
|
canvasengine(),
|
|
12
12
|
dts({
|
|
13
13
|
include: ['src/**/*.ts'],
|
|
14
|
-
|
|
14
|
+
outDirs: 'dist',
|
|
15
|
+
afterDiagnostic(diagnostics) {
|
|
16
|
+
if (diagnostics.length > 0) throw new Error(`Declaration generation failed with ${diagnostics.length} TypeScript diagnostic(s)`)
|
|
17
|
+
}
|
|
15
18
|
})
|
|
16
19
|
],
|
|
17
20
|
build: {
|
|
@@ -33,4 +36,4 @@ export default defineConfig({
|
|
|
33
36
|
}
|
|
34
37
|
},
|
|
35
38
|
},
|
|
36
|
-
})
|
|
39
|
+
})
|