@rpgjs/client 5.0.0-beta.25 → 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.
Files changed (47) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/Game/Map.d.ts +1 -0
  3. package/dist/Game/Map.js +5 -0
  4. package/dist/Game/Map.js.map +1 -1
  5. package/dist/RpgClient.d.ts +2 -1
  6. package/dist/RpgClientEngine.d.ts +76 -0
  7. package/dist/RpgClientEngine.js +59 -4
  8. package/dist/RpgClientEngine.js.map +1 -1
  9. package/dist/components/scenes/draw-map.ce.js +5 -2
  10. package/dist/components/scenes/draw-map.ce.js.map +1 -1
  11. package/dist/components/scenes/weather-lifecycle-compat.d.ts +0 -0
  12. package/dist/components/scenes/weather-lifecycle-compat.js +11 -0
  13. package/dist/components/scenes/weather-lifecycle-compat.js.map +1 -0
  14. package/dist/components/scenes/weather-tick-lifecycle.d.ts +17 -0
  15. package/dist/components/scenes/weather-tick-lifecycle.js +44 -0
  16. package/dist/components/scenes/weather-tick-lifecycle.js.map +1 -0
  17. package/dist/components/scenes/weather-tick-lifecycle.spec.d.ts +1 -0
  18. package/dist/i18n.d.ts +1 -0
  19. package/dist/i18n.js +1 -0
  20. package/dist/i18n.js.map +1 -1
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.js +3 -1
  23. package/dist/services/loadMap.d.ts +10 -2
  24. package/dist/services/loadMap.js +2 -1
  25. package/dist/services/loadMap.js.map +1 -1
  26. package/dist/services/mapStreaming.d.ts +85 -0
  27. package/dist/services/mapStreaming.js +210 -0
  28. package/dist/services/mapStreaming.js.map +1 -0
  29. package/dist/services/mapStreaming.spec.d.ts +1 -0
  30. package/dist/services/mmorpg.d.ts +6 -0
  31. package/dist/services/mmorpg.js +2 -2
  32. package/dist/services/mmorpg.js.map +1 -1
  33. package/package.json +3 -3
  34. package/src/Game/Map.ts +7 -0
  35. package/src/RpgClient.ts +2 -1
  36. package/src/RpgClientEngine.ts +147 -6
  37. package/src/components/scenes/draw-map.ce +4 -1
  38. package/src/components/scenes/weather-lifecycle-compat.ts +8 -0
  39. package/src/components/scenes/weather-tick-lifecycle.spec.ts +60 -0
  40. package/src/components/scenes/weather-tick-lifecycle.ts +69 -0
  41. package/src/i18n.spec.ts +2 -1
  42. package/src/i18n.ts +1 -0
  43. package/src/index.ts +2 -0
  44. package/src/services/loadMap.ts +9 -2
  45. package/src/services/mapStreaming.spec.ts +240 -0
  46. package/src/services/mapStreaming.ts +275 -0
  47. package/src/services/mmorpg.ts +15 -2
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mapStreaming.js","names":[],"sources":["../../src/services/mapStreaming.ts"],"sourcesContent":["import { Context, inject } from \"@signe/di\";\nimport {\n MAP_STREAM_EVENT,\n MAP_STREAM_REQUEST_EVENT,\n type MapChunkHitbox,\n type MapStreamChunk,\n type MapStreamManifest,\n type MapStreamPacket,\n} from \"@rpgjs/common\";\nimport { AbstractWebsocket, WebSocketToken } from \"./AbstractSocket\";\nimport { LoadMapToken, type MapData, type LoadMapOptions } from \"./loadMap\";\nimport { UpdateMapService, UpdateMapToken } from \"@rpgjs/common\";\n\nexport interface ClientMapStreamingAdapter<TManifestData = unknown, TChunkData = unknown, TState = unknown> {\n /** CanvasEngine component that renders the provider-specific state. */\n component: unknown;\n /** Create empty client render state from public manifest data. */\n createState(manifest: MapStreamManifest<TManifestData>): TState;\n /** Apply one disclosed render chunk to the state. */\n applyChunk(state: TState, chunk: MapStreamChunk<TChunkData>): void;\n /** Remove one chunk that left the retention window. */\n removeChunk(state: TState, key: string): void;\n /** Return the serializable/component-ready map value exposed to CanvasEngine. */\n getData(state: TState): unknown;\n /** Optionally derive component parameters from the public manifest. */\n getParams?(manifest: MapStreamManifest<TManifestData>): Record<string, unknown>;\n}\n\nexport interface ClientMapStreamingOptions<TManifestData = unknown, TChunkData = unknown, TState = unknown> {\n /** Format adapter paired with the server compiler. */\n adapter: ClientMapStreamingAdapter<TManifestData, TChunkData, TState>;\n /** Optional direct loader used only by standalone RPG mode. */\n directLoad?: LoadMapOptions;\n /** Maximum wait for the initial authoritative manifest. Defaults to 10 seconds. */\n timeoutMs?: number;\n}\n\ntype StreamingPhysicsMap = {\n data: { (): MapData | null; set(value: MapData): void };\n replaceStreamedStaticHitboxes(namespace: string, hitboxes: MapChunkHitbox[]): void;\n clearStreamedStaticHitboxes(namespace: string): void;\n};\n\ntype Waiter = {\n resolve(controller: MapStreamClientController<any, any, any>): void;\n reject(error: Error): void;\n timer: ReturnType<typeof setTimeout>;\n};\n\nexport class MapStreamClientController<TManifestData, TChunkData, TState> {\n private state: TState;\n private manifest: MapStreamManifest<TManifestData>;\n private readonly chunks = new Map<string, MapStreamChunk<TChunkData>>();\n private attachedMap?: StreamingPhysicsMap;\n\n constructor(\n private readonly adapter: ClientMapStreamingAdapter<TManifestData, TChunkData, TState>,\n manifest: MapStreamManifest<TManifestData>,\n ) {\n this.manifest = manifest;\n this.state = adapter.createState(manifest);\n }\n\n get revision(): string {\n return this.manifest.revision;\n }\n\n reset(manifest: MapStreamManifest<TManifestData>): void {\n const attachedMap = this.attachedMap;\n this.detach();\n this.manifest = manifest;\n this.state = this.adapter.createState(manifest);\n this.chunks.clear();\n this.attachedMap = attachedMap;\n }\n\n receive(packet: MapStreamPacket<TManifestData, TChunkData>): void {\n for (const key of packet.removed) {\n this.chunks.delete(key);\n this.adapter.removeChunk(this.state, key);\n this.attachedMap?.clearStreamedStaticHitboxes(key);\n }\n for (const chunk of packet.chunks) {\n this.chunks.set(chunk.key, chunk);\n this.adapter.applyChunk(this.state, chunk);\n this.attachedMap?.replaceStreamedStaticHitboxes(chunk.key, chunk.hitboxes);\n }\n this.publish();\n }\n\n toMapData(): MapData {\n return {\n id: this.manifest.mapId,\n width: this.manifest.width,\n height: this.manifest.height,\n data: this.adapter.getData(this.state),\n component: this.adapter.component,\n params: this.adapter.getParams?.(this.manifest),\n streamController: this,\n };\n }\n\n attach(map: StreamingPhysicsMap): void {\n this.attachedMap = map;\n for (const chunk of this.chunks.values()) {\n map.replaceStreamedStaticHitboxes(chunk.key, chunk.hitboxes);\n }\n this.updatePredictionBoundary();\n }\n\n detach(): void {\n if (!this.attachedMap) return;\n for (const key of this.chunks.keys()) {\n this.attachedMap.clearStreamedStaticHitboxes(key);\n }\n this.attachedMap.clearStreamedStaticHitboxes(\"__boundary__\");\n this.attachedMap = undefined;\n }\n\n private publish(): void {\n if (!this.attachedMap) return;\n const current = this.attachedMap.data();\n if (current) {\n this.attachedMap.data.set({ ...current, data: this.adapter.getData(this.state) });\n }\n this.updatePredictionBoundary();\n }\n\n private updatePredictionBoundary(): void {\n const map = this.attachedMap;\n if (!map) return;\n if (this.chunks.size === 0) {\n map.clearStreamedStaticHitboxes(\"__boundary__\");\n return;\n }\n const chunks = [...this.chunks.values()];\n const left = Math.min(...chunks.map((chunk) => chunk.bounds.x));\n const top = Math.min(...chunks.map((chunk) => chunk.bounds.y));\n const right = Math.max(...chunks.map((chunk) => chunk.bounds.x + chunk.bounds.width));\n const bottom = Math.max(...chunks.map((chunk) => chunk.bounds.y + chunk.bounds.height));\n const thickness = 2;\n const barriers: MapChunkHitbox[] = [];\n if (left > 0) barriers.push({ x: left - thickness, y: top, width: thickness, height: bottom - top });\n if (top > 0) barriers.push({ x: left, y: top - thickness, width: right - left, height: thickness });\n if (right < this.manifest.width) barriers.push({ x: right, y: top, width: thickness, height: bottom - top });\n if (bottom < this.manifest.height) barriers.push({ x: left, y: bottom, width: right - left, height: thickness });\n map.replaceStreamedStaticHitboxes(\"__boundary__\", barriers);\n }\n}\n\nclass MapStreamingClientService<TManifestData, TChunkData, TState> {\n private readonly controllers = new Map<string, MapStreamClientController<TManifestData, TChunkData, TState>>();\n private readonly waiters = new Map<string, Waiter[]>();\n\n constructor(\n private readonly socket: AbstractWebsocket,\n private readonly options: ClientMapStreamingOptions<TManifestData, TChunkData, TState>,\n ) {\n socket.on(MAP_STREAM_EVENT, (packet) => this.receive(packet));\n }\n\n async load(mapId: string): Promise<MapData> {\n const normalizedId = mapId.replace(/^map-/, \"\");\n const existing = this.controllers.get(normalizedId);\n if (existing) {\n this.socket.emit(MAP_STREAM_REQUEST_EVENT, { mapId: normalizedId });\n return existing.toMapData();\n }\n\n const shouldRequest = !this.waiters.has(normalizedId);\n const controllerPromise = new Promise<MapStreamClientController<TManifestData, TChunkData, TState>>((resolve, reject) => {\n const timeoutMs = Math.max(1, this.options.timeoutMs ?? 10_000);\n const waiter: Waiter = { resolve, reject, timer: undefined as unknown as ReturnType<typeof setTimeout> };\n const timer = setTimeout(() => {\n const current = this.waiters.get(normalizedId) ?? [];\n const remaining = current.filter((entry) => entry !== waiter);\n if (remaining.length > 0) this.waiters.set(normalizedId, remaining);\n else this.waiters.delete(normalizedId);\n reject(new Error(`Map stream '${normalizedId}' was not received after ${timeoutMs}ms`));\n }, timeoutMs);\n waiter.timer = timer;\n const waiters = this.waiters.get(normalizedId) ?? [];\n waiters.push(waiter);\n this.waiters.set(normalizedId, waiters);\n });\n // The map-room connection is established before load() runs. Request the\n // initial packet only after the waiter is registered so fast local and DO\n // transports cannot deliver it before the client is ready.\n if (shouldRequest) {\n this.socket.emit(MAP_STREAM_REQUEST_EVENT, { mapId: normalizedId });\n }\n return (await controllerPromise).toMapData();\n }\n\n private receive(packet: MapStreamPacket<TManifestData, TChunkData>): void {\n if (!packet || typeof packet.mapId !== \"string\") return;\n const mapId = packet.mapId.replace(/^map-/, \"\");\n let controller = this.controllers.get(mapId);\n if (!controller) {\n if (!packet.manifest) return;\n controller = new MapStreamClientController(this.options.adapter, packet.manifest);\n this.controllers.set(mapId, controller);\n }\n else if (packet.manifest && controller.revision !== packet.manifest.revision) {\n controller.reset(packet.manifest);\n }\n controller.receive(packet);\n\n const waiters = this.waiters.get(mapId) ?? [];\n waiters.forEach((waiter) => {\n clearTimeout(waiter.timer);\n waiter.resolve(controller!);\n });\n this.waiters.delete(mapId);\n }\n}\n\nclass MapStreamingLoadMapService<TManifestData, TChunkData, TState> {\n private socket?: AbstractWebsocket;\n private stream?: MapStreamingClientService<TManifestData, TChunkData, TState>;\n private updateMap?: UpdateMapService;\n\n constructor(\n private readonly context: Context,\n private readonly options: ClientMapStreamingOptions<TManifestData, TChunkData, TState>,\n ) {}\n\n initialize(): void {\n if (this.stream) return;\n this.socket = inject<AbstractWebsocket>(this.context, WebSocketToken);\n this.stream = new MapStreamingClientService(this.socket, this.options);\n }\n\n async load(mapId: string): Promise<MapData> {\n this.initialize();\n const map = this.socket?.mode === \"standalone\" && this.options.directLoad\n ? await this.options.directLoad(mapId.replace(/^map-/, \"\"))\n : await this.stream!.load(mapId);\n this.updateMap ??= inject<UpdateMapService>(this.context, UpdateMapToken);\n await this.updateMap.update(map);\n return map;\n }\n}\n\n/**\n * Provide a transport-neutral client map loader backed by authoritative chunks.\n * Standalone mode may keep a direct loader while MMORPG mode never fetches the\n * private source map.\n *\n * @param options - Client adapter, optional standalone loader, and timeout.\n * @returns Dependency-injection providers for the RPGJS map loader.\n *\n * @example\n * ```ts\n * provideClientMapStreaming({\n * adapter: {\n * component: MyMap,\n * createState: (manifest) => ({ manifest, chunks: new Map() }),\n * applyChunk: (state, chunk) => state.chunks.set(chunk.key, chunk.renderData),\n * removeChunk: (state, key) => state.chunks.delete(key),\n * getData: (state) => state,\n * },\n * })\n * ```\n */\nexport function provideClientMapStreaming<TManifestData, TChunkData, TState>(\n options: ClientMapStreamingOptions<TManifestData, TChunkData, TState>,\n) {\n return [\n {\n provide: LoadMapToken,\n useFactory: (context: Context) => new MapStreamingLoadMapService(context, options),\n },\n ];\n}\n"],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export {};
@@ -9,6 +9,12 @@ export interface MmorpgOptions {
9
9
  connectionIdScope?: "local" | "session" | "ephemeral";
10
10
  query?: SocketQuery | (() => SocketQuery | undefined);
11
11
  socketOptions?: Record<string, any>;
12
+ /**
13
+ * Time allowed for a room to restore and send the RPGJS acceptance packet.
14
+ * Increase this for cold edge rooms or local Durable Object startup.
15
+ * Defaults to 10 seconds.
16
+ */
17
+ connectionAcceptanceTimeoutMs?: number;
12
18
  }
13
19
  export declare class BridgeWebsocket extends AbstractWebsocket {
14
20
  protected context: Context;
@@ -51,7 +51,7 @@ var BridgeWebsocket = class extends AbstractWebsocket {
51
51
  const pendingOn = this.pendingOn;
52
52
  this.pendingOn = [];
53
53
  pendingOn.filter(({ event }) => !this.isNativeSocketEvent(event)).forEach(({ event, callback }) => this.attachEvent(event, callback));
54
- await waitForRpgjsConnected(this.socket.conn);
54
+ await waitForRpgjsConnected(this.socket.conn, this.options.connectionAcceptanceTimeoutMs ?? 1e4);
55
55
  pendingOn.filter(({ event }) => this.isNativeSocketEvent(event)).forEach(({ event, callback }) => this.attachEvent(event, callback));
56
56
  this.emitAcceptedOpen();
57
57
  listeners?.(this.socket);
@@ -116,7 +116,7 @@ var BridgeWebsocket = class extends AbstractWebsocket {
116
116
  async reconnect(_listeners) {
117
117
  if (!this.socket?.conn) return;
118
118
  const conn = this.socket.conn;
119
- const connected = waitForRpgjsConnected(conn, 1e4, { ignoreCleanClose: true });
119
+ const connected = waitForRpgjsConnected(conn, this.options.connectionAcceptanceTimeoutMs ?? 1e4, { ignoreCleanClose: true });
120
120
  conn.reconnect();
121
121
  await connected;
122
122
  this.emitAcceptedOpen();
@@ -1 +1 @@
1
- {"version":3,"file":"mmorpg.js","names":[],"sources":["../../src/services/mmorpg.ts"],"sourcesContent":["import { Context } from \"@signe/di\";\nimport { connectionRoom } from \"@signe/sync/client\";\nimport { RpgGui } from \"../Gui/Gui\";\nimport { RpgClientEngine } from \"../RpgClientEngine\";\nimport { AbstractWebsocket, SocketQuery, SocketUpdateProperties, WebSocketToken } from \"./AbstractSocket\";\nimport { UpdateMapService, UpdateMapToken } from \"@rpgjs/common\";\nimport { provideKeyboardControls } from \"./keyboardControls\";\nimport { provideSaveClient } from \"./save\";\nimport { isNativeSocketEvent, waitForRpgjsConnected } from \"./mmorpg-connection\";\n\nexport interface MmorpgOptions {\n host?: string;\n connectionId?: string;\n connectionIdScope?: \"local\" | \"session\" | \"ephemeral\";\n query?: SocketQuery | (() => SocketQuery | undefined);\n socketOptions?: Record<string, any>;\n}\n\nexport class BridgeWebsocket extends AbstractWebsocket {\n readonly mode = \"mmorpg\" as const;\n\n private socket: any;\n private privateId: string;\n private pendingOn: Array<{ event: string; callback: (data: any) => void }> = [];\n private acceptedOpenListeners = new Set<(data: any) => void>();\n private targetRoom = \"lobby-1\";\n\n constructor(protected context: Context, private options: MmorpgOptions = {}) {\n super(context);\n this.privateId = this.resolveConnectionId();\n }\n\n private resolveConnectionId(): string {\n if (this.options.connectionId) {\n return this.options.connectionId;\n }\n\n const scope = this.options.connectionIdScope ?? \"local\";\n const key = \"rpgjs-user-id\";\n\n if (scope === \"ephemeral\") {\n return crypto.randomUUID();\n }\n\n const storage =\n scope === \"session\"\n ? window.sessionStorage\n : window.localStorage;\n\n const existing = storage.getItem(key);\n if (existing) {\n return existing;\n }\n\n const id = crypto.randomUUID();\n storage.setItem(key, id);\n return id;\n }\n\n private resolveQuery(): SocketQuery {\n const query = typeof this.options.query === \"function\"\n ? this.options.query()\n : this.options.query;\n\n return query ?? {};\n }\n\n async connection(listeners?: (data: any) => void) {\n // tmp\n class Room {\n \n }\n const instance = new Room()\n const host = this.options.host || window.location.host;\n this.socket = await connectionRoom({\n maxRetries: 0,\n ...this.options.socketOptions,\n host,\n room: this.targetRoom,\n id: this.privateId,\n query: {\n ...this.resolveQuery(),\n id: this.privateId,\n },\n }, instance)\n\n const pendingOn = this.pendingOn;\n this.pendingOn = [];\n pendingOn\n .filter(({ event }) => !this.isNativeSocketEvent(event))\n .forEach(({ event, callback }) => this.attachEvent(event, callback));\n await waitForRpgjsConnected(this.socket.conn);\n pendingOn\n .filter(({ event }) => this.isNativeSocketEvent(event))\n .forEach(({ event, callback }) => this.attachEvent(event, callback));\n this.emitAcceptedOpen();\n listeners?.(this.socket)\n }\n\n on(key: string, callback: (data: any) => void) {\n if (!this.socket) {\n this.pendingOn.push({ event: key, callback });\n return;\n }\n this.attachEvent(key, callback);\n }\n\n off(event: string, callback: (data: any) => void) {\n if (!this.socket) return;\n if (event === \"open\") {\n this.acceptedOpenListeners.delete(callback);\n return;\n }\n if (this.isNativeSocketEvent(event)) {\n this.socket.conn.removeEventListener(event, callback);\n return;\n }\n this.socket.off(event, callback);\n }\n\n emit(event: string, data: any) {\n this.socket.emit(event, data);\n }\n\n private attachEvent(event: string, callback: (data: any) => void) {\n if (event === \"open\") {\n this.acceptedOpenListeners.add(callback);\n return;\n }\n if (this.isNativeSocketEvent(event)) {\n this.socket.conn.addEventListener(event, callback);\n return;\n }\n this.socket.on(event, callback);\n }\n\n private emitAcceptedOpen() {\n const event = new Event(\"open\");\n this.acceptedOpenListeners.forEach((callback) => callback(event));\n }\n\n updateProperties({ room, host, query }: SocketUpdateProperties) {\n if (!this.socket?.conn) return;\n this.targetRoom = room;\n this.socket.conn.updateProperties({\n room,\n id: this.privateId,\n host: host || this.options.host || window.location.host,\n query: {\n ...this.resolveQuery(),\n ...query,\n id: this.privateId,\n },\n })\n }\n\n private isNativeSocketEvent(event: string) {\n return isNativeSocketEvent(event);\n }\n\n async reconnect(_listeners?: (data: any) => void): Promise<void> {\n if (!this.socket?.conn) return;\n const conn = this.socket.conn;\n const connected = waitForRpgjsConnected(conn, 10000, { ignoreCleanClose: true });\n conn.reconnect();\n await connected;\n this.emitAcceptedOpen();\n }\n\n getCurrentRoom(): string {\n return this.targetRoom || this.socket?.conn?.room || \"lobby-1\";\n }\n}\n\nclass UpdateMapStandaloneService extends UpdateMapService {\n constructor(protected context: Context, private _options: MmorpgOptions) {\n super(context);\n }\n\n async update(_map: any) {\n // In MMORPG mode, clients are untrusted and must not push map definitions.\n // Map bootstrap/update is handled server-side by @rpgjs/vite.\n return;\n }\n}\n\nexport function provideMmorpg(options: MmorpgOptions) {\n return [\n {\n provide: WebSocketToken,\n useFactory: (context: Context) => new BridgeWebsocket(context, options),\n },\n {\n provide: UpdateMapToken,\n useFactory: (context: Context) => new UpdateMapStandaloneService(context, options),\n },\n provideKeyboardControls(),\n provideSaveClient(),\n RpgGui,\n RpgClientEngine,\n ];\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"mmorpg.js","names":[],"sources":["../../src/services/mmorpg.ts"],"sourcesContent":["import { Context } from \"@signe/di\";\nimport { connectionRoom } from \"@signe/sync/client\";\nimport { RpgGui } from \"../Gui/Gui\";\nimport { RpgClientEngine } from \"../RpgClientEngine\";\nimport { AbstractWebsocket, SocketQuery, SocketUpdateProperties, WebSocketToken } from \"./AbstractSocket\";\nimport { UpdateMapService, UpdateMapToken } from \"@rpgjs/common\";\nimport { provideKeyboardControls } from \"./keyboardControls\";\nimport { provideSaveClient } from \"./save\";\nimport { isNativeSocketEvent, waitForRpgjsConnected } from \"./mmorpg-connection\";\n\nexport interface MmorpgOptions {\n host?: string;\n connectionId?: string;\n connectionIdScope?: \"local\" | \"session\" | \"ephemeral\";\n query?: SocketQuery | (() => SocketQuery | undefined);\n socketOptions?: Record<string, any>;\n /**\n * Time allowed for a room to restore and send the RPGJS acceptance packet.\n * Increase this for cold edge rooms or local Durable Object startup.\n * Defaults to 10 seconds.\n */\n connectionAcceptanceTimeoutMs?: number;\n}\n\nexport class BridgeWebsocket extends AbstractWebsocket {\n readonly mode = \"mmorpg\" as const;\n\n private socket: any;\n private privateId: string;\n private pendingOn: Array<{ event: string; callback: (data: any) => void }> = [];\n private acceptedOpenListeners = new Set<(data: any) => void>();\n private targetRoom = \"lobby-1\";\n\n constructor(protected context: Context, private options: MmorpgOptions = {}) {\n super(context);\n this.privateId = this.resolveConnectionId();\n }\n\n private resolveConnectionId(): string {\n if (this.options.connectionId) {\n return this.options.connectionId;\n }\n\n const scope = this.options.connectionIdScope ?? \"local\";\n const key = \"rpgjs-user-id\";\n\n if (scope === \"ephemeral\") {\n return crypto.randomUUID();\n }\n\n const storage =\n scope === \"session\"\n ? window.sessionStorage\n : window.localStorage;\n\n const existing = storage.getItem(key);\n if (existing) {\n return existing;\n }\n\n const id = crypto.randomUUID();\n storage.setItem(key, id);\n return id;\n }\n\n private resolveQuery(): SocketQuery {\n const query = typeof this.options.query === \"function\"\n ? this.options.query()\n : this.options.query;\n\n return query ?? {};\n }\n\n async connection(listeners?: (data: any) => void) {\n // tmp\n class Room {\n \n }\n const instance = new Room()\n const host = this.options.host || window.location.host;\n this.socket = await connectionRoom({\n maxRetries: 0,\n ...this.options.socketOptions,\n host,\n room: this.targetRoom,\n id: this.privateId,\n query: {\n ...this.resolveQuery(),\n id: this.privateId,\n },\n }, instance)\n\n const pendingOn = this.pendingOn;\n this.pendingOn = [];\n pendingOn\n .filter(({ event }) => !this.isNativeSocketEvent(event))\n .forEach(({ event, callback }) => this.attachEvent(event, callback));\n await waitForRpgjsConnected(\n this.socket.conn,\n this.options.connectionAcceptanceTimeoutMs ?? 10_000,\n );\n pendingOn\n .filter(({ event }) => this.isNativeSocketEvent(event))\n .forEach(({ event, callback }) => this.attachEvent(event, callback));\n this.emitAcceptedOpen();\n listeners?.(this.socket)\n }\n\n on(key: string, callback: (data: any) => void) {\n if (!this.socket) {\n this.pendingOn.push({ event: key, callback });\n return;\n }\n this.attachEvent(key, callback);\n }\n\n off(event: string, callback: (data: any) => void) {\n if (!this.socket) return;\n if (event === \"open\") {\n this.acceptedOpenListeners.delete(callback);\n return;\n }\n if (this.isNativeSocketEvent(event)) {\n this.socket.conn.removeEventListener(event, callback);\n return;\n }\n this.socket.off(event, callback);\n }\n\n emit(event: string, data: any) {\n this.socket.emit(event, data);\n }\n\n private attachEvent(event: string, callback: (data: any) => void) {\n if (event === \"open\") {\n this.acceptedOpenListeners.add(callback);\n return;\n }\n if (this.isNativeSocketEvent(event)) {\n this.socket.conn.addEventListener(event, callback);\n return;\n }\n this.socket.on(event, callback);\n }\n\n private emitAcceptedOpen() {\n const event = new Event(\"open\");\n this.acceptedOpenListeners.forEach((callback) => callback(event));\n }\n\n updateProperties({ room, host, query }: SocketUpdateProperties) {\n if (!this.socket?.conn) return;\n this.targetRoom = room;\n this.socket.conn.updateProperties({\n room,\n id: this.privateId,\n host: host || this.options.host || window.location.host,\n query: {\n ...this.resolveQuery(),\n ...query,\n id: this.privateId,\n },\n })\n }\n\n private isNativeSocketEvent(event: string) {\n return isNativeSocketEvent(event);\n }\n\n async reconnect(_listeners?: (data: any) => void): Promise<void> {\n if (!this.socket?.conn) return;\n const conn = this.socket.conn;\n const connected = waitForRpgjsConnected(\n conn,\n this.options.connectionAcceptanceTimeoutMs ?? 10_000,\n { ignoreCleanClose: true },\n );\n conn.reconnect();\n await connected;\n this.emitAcceptedOpen();\n }\n\n getCurrentRoom(): string {\n return this.targetRoom || this.socket?.conn?.room || \"lobby-1\";\n }\n}\n\nclass UpdateMapStandaloneService extends UpdateMapService {\n constructor(protected context: Context, private _options: MmorpgOptions) {\n super(context);\n }\n\n async update(_map: any) {\n // In MMORPG mode, clients are untrusted and must not push map definitions.\n // Map bootstrap/update is handled server-side by @rpgjs/vite.\n return;\n }\n}\n\nexport function provideMmorpg(options: MmorpgOptions) {\n return [\n {\n provide: WebSocketToken,\n useFactory: (context: Context) => new BridgeWebsocket(context, options),\n },\n {\n provide: UpdateMapToken,\n useFactory: (context: Context) => new UpdateMapStandaloneService(context, options),\n },\n provideKeyboardControls(),\n provideSaveClient(),\n RpgGui,\n RpgClientEngine,\n ];\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpgjs/client",
3
- "version": "5.0.0-beta.25",
3
+ "version": "5.0.0-beta.26",
4
4
  "description": "RPGJS is a framework for creating RPG/MMORPG games",
5
5
  "main": "dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -22,8 +22,8 @@
22
22
  "pixi.js": "^8.9.2"
23
23
  },
24
24
  "dependencies": {
25
- "@rpgjs/common": "5.0.0-beta.24",
26
- "@rpgjs/server": "5.0.0-beta.25",
25
+ "@rpgjs/common": "5.0.0-beta.25",
26
+ "@rpgjs/server": "5.0.0-beta.26",
27
27
  "@rpgjs/ui-css": "5.0.0-beta.23",
28
28
  "@signe/di": "3.1.0",
29
29
  "@signe/room": "3.1.0",
package/src/Game/Map.ts CHANGED
@@ -59,6 +59,7 @@ export class RpgClientMap extends RpgCommonMap<any> {
59
59
  currentPlayer = computed(() => this.players()[this.engine.playerIdSignal()!])
60
60
  weatherState = signal<WeatherState | null>(null);
61
61
  localWeatherOverride = signal<WeatherState | null>(null);
62
+ private localWeatherValue: WeatherState | null = null;
62
63
  lightingState = signal<LightingState | null>(null);
63
64
  localLightSpots = signal<Record<string, LightSpot>>({});
64
65
  weather = computed<WeatherState | null>(() => {
@@ -132,6 +133,7 @@ export class RpgClientMap extends RpgCommonMap<any> {
132
133
  );
133
134
  this.events.set({})
134
135
  this.weatherState.set(null);
136
+ this.localWeatherValue = null;
135
137
  this.localWeatherOverride.set(null);
136
138
  this.lightingState.set(null);
137
139
  this.localLightSpots.set({});
@@ -143,10 +145,15 @@ export class RpgClientMap extends RpgCommonMap<any> {
143
145
  }
144
146
 
145
147
  setLocalWeather(next: WeatherState | null): void {
148
+ if (this.localWeatherValue === next) {
149
+ return;
150
+ }
151
+ this.localWeatherValue = next;
146
152
  this.localWeatherOverride.set(next);
147
153
  }
148
154
 
149
155
  clearLocalWeather(): void {
156
+ this.localWeatherValue = null;
150
157
  this.localWeatherOverride.set(null);
151
158
  }
152
159
 
package/src/RpgClient.ts CHANGED
@@ -259,7 +259,8 @@ export interface RpgSceneHooks<Scene> {
259
259
  onRemoveSprite?: (scene: Scene, sprite: RpgComponent) => any
260
260
 
261
261
  /**
262
- * Before the scene is loaded
262
+ * Before the scene is loaded. Async hooks are awaited while the previous
263
+ * scene is still mounted, allowing transition UI to cover it before teardown.
263
264
  *
264
265
  * @prop { (scene: RpgScene) => any } [onBeforeLoading]
265
266
  * @memberof RpgSceneHooks
@@ -170,7 +170,40 @@ export class RpgClientEngine<T = any> {
170
170
  componentAnimations: any[] = [];
171
171
  clientVisuals = new ClientVisualRegistry();
172
172
  projectiles: ProjectileManager;
173
+ /**
174
+ * Read the latest pointer position tracked by the client canvas. World
175
+ * coordinates are suitable for action payloads and map interactions.
176
+ *
177
+ * @title pointer
178
+ * @prop pointer: ClientPointerContext
179
+ * @memberof RpgClientEngine
180
+ * @example
181
+ * ```ts
182
+ * const target = engine.pointer.world()
183
+ * if (target) engine.processAction('projectile:shoot', { target })
184
+ * ```
185
+ */
173
186
  pointer: ClientPointerContext = createClientPointerContext();
187
+ /**
188
+ * Register client-only pointer behaviors for map sprites. Interactions remain
189
+ * local unless a behavior explicitly sends an action to the server.
190
+ *
191
+ * See the [client interactions guide](../../guide/interactions.md) for hover,
192
+ * selection, hit testing, drag-and-drop, overlays, and network rules.
193
+ *
194
+ * @title interactions
195
+ * @prop interactions: RpgClientInteractions
196
+ * @memberof RpgClientEngine
197
+ * @example
198
+ * ```ts
199
+ * engine.interactions.use('Guard', {
200
+ * cursor: 'pointer',
201
+ * click(ctx) {
202
+ * ctx.action('guard:talk', { eventId: ctx.target.id })
203
+ * }
204
+ * })
205
+ * ```
206
+ */
174
207
  interactions: RpgClientInteractions = new RpgClientInteractions(this);
175
208
  private spritesheetResolver?: (id: string | number) => any | Promise<any>;
176
209
  private soundResolver?: (id: string) => any | Promise<any>;
@@ -218,6 +251,8 @@ export class RpgClientEngine<T = any> {
218
251
  private pingInterval: any = null;
219
252
  private readonly PING_INTERVAL_MS = 5000; // Send ping every 5 seconds
220
253
  private lastInputTime = 0;
254
+ private latestDirectionalInput?: RpgMovementInput;
255
+ private pendingMapTransferInput?: RpgMovementInput;
221
256
  private readonly MOVE_PATH_RESEND_INTERVAL_MS = 120;
222
257
  private readonly MAX_MOVE_TRAJECTORY_POINTS = 240;
223
258
  private lastMovePathSentAt = 0;
@@ -231,6 +266,7 @@ export class RpgClientEngine<T = any> {
231
266
  private sceneResetQueued = false;
232
267
  private mapTransitionInProgress = false;
233
268
  private currentMapRoomId?: string;
269
+ private activeMapStreamController?: { attach(map: RpgClientMap): void; detach(): void };
234
270
  private socketListenersInitialized = false;
235
271
  private clientReadyForMapChanges = false;
236
272
  private pendingMapChanges: any[] = [];
@@ -383,6 +419,7 @@ export class RpgClientEngine<T = any> {
383
419
  this.sceneMap.loadPhysic();
384
420
  this.resolveSceneMapComponent();
385
421
 
422
+ this.loadMapService.initialize?.();
386
423
  const saveClient = inject(SaveClientService);
387
424
  saveClient.initialize();
388
425
  this.initListeners();
@@ -888,11 +925,23 @@ export class RpgClientEngine<T = any> {
888
925
  })
889
926
  }
890
927
 
891
- private beginMapTransfer(nextMapId?: string) {
928
+ private beginMapTransfer(
929
+ nextMapId?: string,
930
+ continueMovement = false,
931
+ ) {
932
+ this.pendingMapTransferInput = continueMovement
933
+ ? this.latestDirectionalInput
934
+ : undefined;
892
935
  this.mapTransitionInProgress = true;
893
936
  this.currentMapRoomId = nextMapId;
894
937
  this.sceneResetQueued = false;
895
- this.clearClientPredictionStates();
938
+ this.clearMapTransferPredictionStates();
939
+ }
940
+
941
+ private resetSceneForMapTransfer(nextMapId?: string) {
942
+ // The before-loading hook has now covered the previous scene. Unmount it
943
+ // before reconnecting so stale map content cannot appear behind the loader.
944
+ this.sceneMap.data.set(null);
896
945
  this.sceneMap.weatherState.set(null);
897
946
  this.sceneMap.lightingState.set(null);
898
947
  this.sceneMap.clearLightSpots();
@@ -936,7 +985,7 @@ export class RpgClientEngine<T = any> {
936
985
 
937
986
  private handleChangeMap(data: any) {
938
987
  const nextMapId = typeof data?.mapId === "string" ? data.mapId : undefined;
939
- this.beginMapTransfer(nextMapId);
988
+ this.beginMapTransfer(nextMapId, data?.continueMovement === true);
940
989
  const transferToken = typeof data?.transferToken === "string" ? data.transferToken : undefined;
941
990
  this.loadScene(data.mapId, transferToken);
942
991
  }
@@ -1067,10 +1116,25 @@ export class RpgClientEngine<T = any> {
1067
1116
  }
1068
1117
 
1069
1118
  private async loadScene(mapId: string, transferToken?: string) {
1119
+ // Keep the previous scene mounted while async before-loading hooks install
1120
+ // and animate their transition UI. The hook contract is awaited, allowing
1121
+ // modules to report when the old scene is fully covered.
1070
1122
  await lastValueFrom(this.hooks.callHooks("client-sceneMap-onBeforeLoading", this.sceneMap));
1071
1123
 
1072
- // Clear client prediction states when changing maps
1073
- this.clearClientPredictionStates();
1124
+ this.activeMapStreamController?.detach();
1125
+ this.activeMapStreamController = undefined;
1126
+ if (this.mapTransitionInProgress) {
1127
+ this.resetSceneForMapTransfer(mapId);
1128
+ }
1129
+
1130
+ // A session-transferred player keeps the last acknowledged input frame on
1131
+ // the server. Preserve the client's monotonic frame sequence so movement
1132
+ // sent in the destination room is not discarded as stale.
1133
+ if (this.mapTransitionInProgress) {
1134
+ this.clearMapTransferPredictionStates();
1135
+ } else {
1136
+ this.clearClientPredictionStates();
1137
+ }
1074
1138
 
1075
1139
  // Reset all conditions for new map loading
1076
1140
  this.mapLoadCompleted$.next(false);
@@ -1095,6 +1159,7 @@ export class RpgClientEngine<T = any> {
1095
1159
  }
1096
1160
  catch (error) {
1097
1161
  this.mapTransitionInProgress = false;
1162
+ this.pendingMapTransferInput = undefined;
1098
1163
  this.stopPingPong();
1099
1164
  await this.callConnectError(error);
1100
1165
  throw error;
@@ -1130,6 +1195,33 @@ export class RpgClientEngine<T = any> {
1130
1195
  this.mapTransitionInProgress = false;
1131
1196
  this.sceneMap.configureClientPrediction(this.predictionEnabled);
1132
1197
  this.sceneMap.loadPhysic()
1198
+ if (res?.streamController) {
1199
+ const controller = res.streamController;
1200
+ this.activeMapStreamController = controller;
1201
+ controller.attach(this.sceneMap);
1202
+ }
1203
+ const transferInput = this.pendingMapTransferInput;
1204
+ this.pendingMapTransferInput = undefined;
1205
+ if (transferInput !== undefined) {
1206
+ void this.resumeMapTransferMovement(transferInput, mapId);
1207
+ }
1208
+ }
1209
+
1210
+ private async resumeMapTransferMovement(
1211
+ input: RpgMovementInput,
1212
+ mapId: string,
1213
+ ): Promise<void> {
1214
+ const repeatCount = 4;
1215
+ const repeatIntervalMs = 50;
1216
+ for (let index = 0; index < repeatCount; index += 1) {
1217
+ if (this.mapTransitionInProgress || this.currentMapRoomId !== mapId) return;
1218
+ await this.processInput({ input });
1219
+ if (index < repeatCount - 1) {
1220
+ await new Promise<void>((resolve) =>
1221
+ setTimeout(resolve, repeatIntervalMs),
1222
+ );
1223
+ }
1224
+ }
1133
1225
  }
1134
1226
 
1135
1227
  addSpriteSheet<T = any>(spritesheetClass: any, id?: string): any {
@@ -1860,6 +1952,9 @@ export class RpgClientEngine<T = any> {
1860
1952
  ? normalizeDashInput(input, currentPlayer?.direction?.())
1861
1953
  : input;
1862
1954
  if (!movementInput) return;
1955
+ if (!isDashInput(movementInput)) {
1956
+ this.latestDirectionalInput = movementInput;
1957
+ }
1863
1958
  if (isDashInput(movementInput)) {
1864
1959
  const cooldown = movementInput.cooldown ?? DEFAULT_DASH_COOLDOWN_MS;
1865
1960
  if (timestamp < this.dashLockedUntil) return;
@@ -1897,7 +1992,26 @@ export class RpgClientEngine<T = any> {
1897
1992
  : Date.now();
1898
1993
  }
1899
1994
 
1900
- async processDash(input: Partial<RpgDashInput> = {}) {
1995
+ /**
1996
+ * Start a predicted dash for the current player and send it through the
1997
+ * authoritative movement channel.
1998
+ *
1999
+ * @title processDash
2000
+ * @method processDash(input?: Partial<RpgDashInput>): Promise<void>
2001
+ * @param input - Optional direction, speed, duration, and cooldown overrides.
2002
+ * @returns A promise resolved after the dash input has been processed locally.
2003
+ * @memberof RpgClientEngine
2004
+ * @example
2005
+ * ```ts
2006
+ * await engine.processDash({
2007
+ * direction: { x: 1, y: 0 },
2008
+ * additionalSpeed: 10,
2009
+ * duration: 220,
2010
+ * cooldown: 600,
2011
+ * })
2012
+ * ```
2013
+ */
2014
+ async processDash(input: Partial<RpgDashInput> = {}): Promise<void> {
1901
2015
  const currentPlayer = this.sceneMap.getCurrentPlayer() as any;
1902
2016
  const fallbackDirection =
1903
2017
  typeof currentPlayer?.direction === "function"
@@ -1908,6 +2022,24 @@ export class RpgClientEngine<T = any> {
1908
2022
  await this.processInput({ input: dashInput });
1909
2023
  }
1910
2024
 
2025
+ /**
2026
+ * Send an action intent to the authoritative server. Client-provided data
2027
+ * must be validated by the receiving player input handler or action.
2028
+ *
2029
+ * @title processAction
2030
+ * @method processAction(action: RpgActionName | RpgActionInput, data?: any): void
2031
+ * @param action - Action name/control value, or a normalized action object.
2032
+ * @param data - Optional serializable context sent with an action name.
2033
+ * @returns Nothing.
2034
+ * @memberof RpgClientEngine
2035
+ * @example
2036
+ * ```ts
2037
+ * engine.processAction('projectile:shoot', {
2038
+ * target: engine.pointer.world(),
2039
+ * source: 'map-click',
2040
+ * })
2041
+ * ```
2042
+ */
1911
2043
  processAction(action: RpgActionName, data?: any): void;
1912
2044
  processAction(action: RpgActionInput): void;
1913
2045
  processAction(action: RpgActionName | RpgActionInput, data?: any): void {
@@ -2298,6 +2430,15 @@ export class RpgClientEngine<T = any> {
2298
2430
  this.lastMovePathSentFrame = 0;
2299
2431
  }
2300
2432
 
2433
+ private clearMapTransferPredictionStates(): void {
2434
+ this.prediction?.clearPendingInputs();
2435
+ this.frameOffset = 0;
2436
+ this.pendingPredictionFrames = [];
2437
+ this.lastClientPhysicsStepAt = 0;
2438
+ this.lastMovePathSentAt = 0;
2439
+ this.lastMovePathSentFrame = this.inputFrameCounter;
2440
+ }
2441
+
2301
2442
  /**
2302
2443
  * Stop local movement immediately and discard pending predicted movement.
2303
2444
  *
@@ -9,7 +9,7 @@
9
9
 
10
10
  <Container>
11
11
  @if (map() && sceneComponent()) {
12
- <sceneComponent() data={map().data} params={map().params} />
12
+ <sceneComponent() data={map()?.data ?? emptySceneData} params={map()?.params ?? emptySceneParams} />
13
13
  }
14
14
  </Container>
15
15
 
@@ -38,6 +38,7 @@
38
38
 
39
39
  <script>
40
40
  import { computed, effect } from 'canvasengine'
41
+ import './weather-lifecycle-compat'
41
42
  import { inject } from "../../core/inject";
42
43
  import { RpgClientEngine } from "../../RpgClientEngine";
43
44
  import { Weather } from '@canvasengine/presets'
@@ -47,6 +48,8 @@
47
48
  const componentAnimations = engine.componentAnimations
48
49
  const projectiles = engine.projectiles.current
49
50
  const map = engine.sceneMap?.data
51
+ const emptySceneData = { params: {} }
52
+ const emptySceneParams = {}
50
53
  const sceneComponent = computed(() => map()?.component)
51
54
  const weather = engine.sceneMap.weather
52
55
  const backgroundMusic = computed(() => {
@@ -0,0 +1,8 @@
1
+ import "@canvasengine/presets";
2
+ import { createComponent } from "canvasengine";
3
+ import { patchWeatherTickLifecycle } from "./weather-tick-lifecycle";
4
+
5
+ for (const tag of ["RainTextureLayer", "RainImpactLayer"]) {
6
+ const probe = createComponent(tag);
7
+ patchWeatherTickLifecycle(Object.getPrototypeOf(probe.componentInstance));
8
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { patchWeatherTickLifecycle } from "./weather-tick-lifecycle";
3
+
4
+ function deferred() {
5
+ let resolve!: () => void;
6
+ const promise = new Promise<void>((done) => {
7
+ resolve = done;
8
+ });
9
+ return { promise, resolve };
10
+ }
11
+
12
+ describe("patchWeatherTickLifecycle", () => {
13
+ it("stops a tick subscription created by a mount that finishes after destroy", async () => {
14
+ const started = deferred();
15
+ const mounted = deferred();
16
+ const unsubscribe = vi.fn();
17
+ const prototype = {
18
+ async onMount(this: any) {
19
+ started.resolve();
20
+ await mounted.promise;
21
+ this.tickSubscription = { unsubscribe };
22
+ },
23
+ onDestroy() {},
24
+ };
25
+ patchWeatherTickLifecycle(prototype);
26
+
27
+ const instance = Object.create(prototype);
28
+ const mounting = instance.onMount();
29
+ await started.promise;
30
+ instance.onDestroy();
31
+ mounted.resolve();
32
+ await mounting;
33
+
34
+ expect(unsubscribe).toHaveBeenCalledOnce();
35
+ expect(instance.tickSubscription).toBeUndefined();
36
+ });
37
+
38
+ it("serializes remounts and replaces the previous tick subscription", async () => {
39
+ const subscriptions = [
40
+ { unsubscribe: vi.fn() },
41
+ { unsubscribe: vi.fn() },
42
+ ];
43
+ const prototype = {
44
+ mountCount: 0,
45
+ async onMount(this: any) {
46
+ await Promise.resolve();
47
+ this.tickSubscription = subscriptions[this.mountCount++];
48
+ },
49
+ onDestroy() {},
50
+ };
51
+ patchWeatherTickLifecycle(prototype);
52
+
53
+ const instance = Object.create(prototype);
54
+ await Promise.all([instance.onMount(), instance.onMount()]);
55
+
56
+ expect(subscriptions[0].unsubscribe).toHaveBeenCalledOnce();
57
+ expect(subscriptions[1].unsubscribe).not.toHaveBeenCalled();
58
+ expect(instance.tickSubscription).toBe(subscriptions[1]);
59
+ });
60
+ });
@@ -0,0 +1,69 @@
1
+ const PATCHED = Symbol.for("@rpgjs/client/weather-tick-lifecycle-patched");
2
+ const DISPOSED = Symbol("weather-tick-lifecycle-disposed");
3
+ const MOUNT_QUEUE = Symbol("weather-tick-lifecycle-mount-queue");
4
+
5
+ type TickSubscription = {
6
+ unsubscribe?: () => void;
7
+ };
8
+
9
+ type TickSubscriptionInstance = {
10
+ destroyed?: boolean;
11
+ tickSubscription?: TickSubscription;
12
+ [DISPOSED]?: boolean;
13
+ [MOUNT_QUEUE]?: Promise<void>;
14
+ };
15
+
16
+ type TickSubscriptionPrototype = {
17
+ [PATCHED]?: boolean;
18
+ onMount?: (...args: any[]) => unknown;
19
+ onDestroy?: (...args: any[]) => unknown;
20
+ };
21
+
22
+ function stopTickSubscription(instance: TickSubscriptionInstance) {
23
+ instance.tickSubscription?.unsubscribe?.();
24
+ instance.tickSubscription = undefined;
25
+ }
26
+
27
+ /**
28
+ * CanvasEngine presets 2.0.0 can finish mounting a rain layer after that layer
29
+ * has already been destroyed. The late mount then leaves a tick subscription
30
+ * operating on a destroyed Pixi object. It can also overwrite an existing
31
+ * subscription when mounts overlap.
32
+ *
33
+ * Keep this compatibility patch local to the affected preset prototypes. It
34
+ * can be removed once the preset owns the same lifecycle guarantees.
35
+ */
36
+ export function patchWeatherTickLifecycle(prototype: TickSubscriptionPrototype) {
37
+ if (!prototype || prototype[PATCHED]) return;
38
+
39
+ const originalOnMount = prototype.onMount;
40
+ const originalOnDestroy = prototype.onDestroy;
41
+ if (!originalOnMount || !originalOnDestroy) return;
42
+
43
+ Object.defineProperty(prototype, PATCHED, { value: true });
44
+
45
+ prototype.onMount = function (this: TickSubscriptionInstance, ...args: any[]) {
46
+ const mount = async () => {
47
+ if (this[DISPOSED] || this.destroyed) return;
48
+
49
+ stopTickSubscription(this);
50
+ await originalOnMount.apply(this, args);
51
+
52
+ if (this[DISPOSED] || this.destroyed) {
53
+ stopTickSubscription(this);
54
+ }
55
+ };
56
+
57
+ const pendingMount = (this[MOUNT_QUEUE] ?? Promise.resolve())
58
+ .catch(() => undefined)
59
+ .then(mount);
60
+ this[MOUNT_QUEUE] = pendingMount;
61
+ return pendingMount;
62
+ };
63
+
64
+ prototype.onDestroy = function (this: TickSubscriptionInstance, ...args: any[]) {
65
+ this[DISPOSED] = true;
66
+ stopTickSubscription(this);
67
+ return originalOnDestroy.apply(this, args);
68
+ };
69
+ }
package/src/i18n.spec.ts CHANGED
@@ -4,7 +4,7 @@ import { describe, expect, test } from "vitest";
4
4
  import { Context, injector } from "@signe/di";
5
5
  import { getOrCreateI18nService } from "@rpgjs/common";
6
6
  import { provideClientModules } from "./module";
7
- import { provideI18n } from "./i18n";
7
+ import { provideI18n, RpgClientBuiltinI18n } from "./i18n";
8
8
 
9
9
  describe("client i18n", () => {
10
10
  test("merges client module translations with game overrides", async () => {
@@ -35,5 +35,6 @@ describe("client i18n", () => {
35
35
 
36
36
  expect(service.t("menu.title", undefined, "fr")).toBe("Titre du jeu");
37
37
  expect(service.t("menu.module-only", undefined, "fr")).toBe("Module");
38
+ expect(RpgClientBuiltinI18n.en["rpg.transition.loading"]).toBe("Loading area…");
38
39
  });
39
40
  });
package/src/i18n.ts CHANGED
@@ -51,6 +51,7 @@ export const RpgClientBuiltinI18n = {
51
51
  "rpg.input.error.min-length": "Enter at least {minLength} characters.",
52
52
  "rpg.input.error.max-length": "Enter no more than {maxLength} characters.",
53
53
  "rpg.input.error.email": "Enter a valid email address.",
54
+ "rpg.transition.loading": "Loading area…",
54
55
  "rpg.shop.default-message": "Welcome to my shop!",
55
56
  "rpg.shop.choose-action": "Choose an action",
56
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";