@rpgjs/client 5.0.0-beta.10 → 5.0.0-beta.12

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 (154) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/Game/AnimationManager.d.ts +1 -0
  3. package/dist/Game/AnimationManager.js +3 -0
  4. package/dist/Game/AnimationManager.js.map +1 -1
  5. package/dist/Game/ClientVisuals.d.ts +61 -0
  6. package/dist/Game/ClientVisuals.js +96 -0
  7. package/dist/Game/ClientVisuals.js.map +1 -0
  8. package/dist/Game/ClientVisuals.spec.d.ts +1 -0
  9. package/dist/Game/EventComponentResolver.d.ts +16 -0
  10. package/dist/Game/EventComponentResolver.js +52 -0
  11. package/dist/Game/EventComponentResolver.js.map +1 -0
  12. package/dist/Game/EventComponentResolver.spec.d.ts +1 -0
  13. package/dist/Game/Map.js +9 -0
  14. package/dist/Game/Map.js.map +1 -1
  15. package/dist/Game/Object.js +2 -2
  16. package/dist/Game/Object.js.map +1 -1
  17. package/dist/Game/Object.spec.d.ts +1 -0
  18. package/dist/Game/ProjectileManager.d.ts +98 -0
  19. package/dist/Game/ProjectileManager.js +196 -0
  20. package/dist/Game/ProjectileManager.js.map +1 -0
  21. package/dist/Game/ProjectileManager.spec.d.ts +1 -0
  22. package/dist/RpgClient.d.ts +117 -13
  23. package/dist/RpgClientEngine.d.ts +82 -4
  24. package/dist/RpgClientEngine.js +296 -51
  25. package/dist/RpgClientEngine.js.map +1 -1
  26. package/dist/components/animations/fx.ce.js +58 -0
  27. package/dist/components/animations/fx.ce.js.map +1 -0
  28. package/dist/components/animations/hit.ce.js.map +1 -1
  29. package/dist/components/animations/index.d.ts +1 -0
  30. package/dist/components/animations/index.js +3 -1
  31. package/dist/components/animations/index.js.map +1 -1
  32. package/dist/components/character.ce.js +140 -40
  33. package/dist/components/character.ce.js.map +1 -1
  34. package/dist/components/dynamics/bar.ce.js +4 -3
  35. package/dist/components/dynamics/bar.ce.js.map +1 -1
  36. package/dist/components/dynamics/image.ce.js +2 -1
  37. package/dist/components/dynamics/image.ce.js.map +1 -1
  38. package/dist/components/dynamics/shape.ce.js +3 -2
  39. package/dist/components/dynamics/shape.ce.js.map +1 -1
  40. package/dist/components/dynamics/text.ce.js +9 -8
  41. package/dist/components/dynamics/text.ce.js.map +1 -1
  42. package/dist/components/gui/dialogbox/index.ce.js +3 -2
  43. package/dist/components/gui/dialogbox/index.ce.js.map +1 -1
  44. package/dist/components/gui/gameover.ce.js +3 -2
  45. package/dist/components/gui/gameover.ce.js.map +1 -1
  46. package/dist/components/gui/hud/hud.ce.js.map +1 -1
  47. package/dist/components/gui/menu/equip-menu.ce.js +2 -1
  48. package/dist/components/gui/menu/equip-menu.ce.js.map +1 -1
  49. package/dist/components/gui/menu/exit-menu.ce.js +2 -1
  50. package/dist/components/gui/menu/exit-menu.ce.js.map +1 -1
  51. package/dist/components/gui/menu/items-menu.ce.js +3 -2
  52. package/dist/components/gui/menu/items-menu.ce.js.map +1 -1
  53. package/dist/components/gui/menu/main-menu.ce.js +3 -2
  54. package/dist/components/gui/menu/main-menu.ce.js.map +1 -1
  55. package/dist/components/gui/menu/options-menu.ce.js.map +1 -1
  56. package/dist/components/gui/menu/skills-menu.ce.js.map +1 -1
  57. package/dist/components/gui/mobile/mobile.ce.js.map +1 -1
  58. package/dist/components/gui/notification/notification.ce.js.map +1 -1
  59. package/dist/components/gui/save-load.ce.js +2 -1
  60. package/dist/components/gui/save-load.ce.js.map +1 -1
  61. package/dist/components/gui/shop/shop.ce.js +3 -2
  62. package/dist/components/gui/shop/shop.ce.js.map +1 -1
  63. package/dist/components/gui/title-screen.ce.js +3 -2
  64. package/dist/components/gui/title-screen.ce.js.map +1 -1
  65. package/dist/components/index.d.ts +2 -1
  66. package/dist/components/index.js +1 -0
  67. package/dist/components/player-components.ce.js +11 -10
  68. package/dist/components/player-components.ce.js.map +1 -1
  69. package/dist/components/prebuilt/hp-bar.ce.js +4 -3
  70. package/dist/components/prebuilt/hp-bar.ce.js.map +1 -1
  71. package/dist/components/prebuilt/light-halo.ce.js +2 -1
  72. package/dist/components/prebuilt/light-halo.ce.js.map +1 -1
  73. package/dist/components/scenes/canvas.ce.js +12 -4
  74. package/dist/components/scenes/canvas.ce.js.map +1 -1
  75. package/dist/components/scenes/draw-map.ce.js +6 -3
  76. package/dist/components/scenes/draw-map.ce.js.map +1 -1
  77. package/dist/components/scenes/event-layer.ce.js.map +1 -1
  78. package/dist/index.d.ts +4 -0
  79. package/dist/index.js +10 -5
  80. package/dist/module.js +18 -0
  81. package/dist/module.js.map +1 -1
  82. package/dist/services/actionInput.d.ts +14 -0
  83. package/dist/services/actionInput.js +59 -0
  84. package/dist/services/actionInput.js.map +1 -0
  85. package/dist/services/actionInput.spec.d.ts +1 -0
  86. package/dist/services/mmorpg-connection.d.ts +5 -0
  87. package/dist/services/mmorpg-connection.js +50 -0
  88. package/dist/services/mmorpg-connection.js.map +1 -0
  89. package/dist/services/mmorpg-connection.spec.d.ts +1 -0
  90. package/dist/services/mmorpg.d.ts +10 -4
  91. package/dist/services/mmorpg.js +48 -30
  92. package/dist/services/mmorpg.js.map +1 -1
  93. package/dist/services/pointerContext.d.ts +11 -0
  94. package/dist/services/pointerContext.js +48 -0
  95. package/dist/services/pointerContext.js.map +1 -0
  96. package/dist/services/pointerContext.spec.d.ts +1 -0
  97. package/dist/services/standalone-message.d.ts +1 -0
  98. package/dist/services/standalone-message.js +9 -0
  99. package/dist/services/standalone-message.js.map +1 -0
  100. package/dist/services/standalone.d.ts +3 -1
  101. package/dist/services/standalone.js +34 -15
  102. package/dist/services/standalone.js.map +1 -1
  103. package/dist/services/standalone.spec.d.ts +1 -0
  104. package/dist/utils/mapId.d.ts +1 -0
  105. package/dist/utils/mapId.js +6 -0
  106. package/dist/utils/mapId.js.map +1 -0
  107. package/package.json +7 -7
  108. package/src/Game/AnimationManager.ts +4 -0
  109. package/src/Game/ClientVisuals.spec.ts +56 -0
  110. package/src/Game/ClientVisuals.ts +184 -0
  111. package/src/Game/EventComponentResolver.spec.ts +84 -0
  112. package/src/Game/EventComponentResolver.ts +74 -0
  113. package/src/Game/Map.ts +10 -0
  114. package/src/Game/Object.spec.ts +46 -0
  115. package/src/Game/Object.ts +2 -2
  116. package/src/Game/ProjectileManager.spec.ts +449 -0
  117. package/src/Game/ProjectileManager.ts +346 -0
  118. package/src/RpgClient.ts +130 -15
  119. package/src/RpgClientEngine.ts +405 -69
  120. package/src/components/animations/fx.ce +101 -0
  121. package/src/components/animations/index.ts +4 -2
  122. package/src/components/character.ce +185 -40
  123. package/src/components/dynamics/bar.ce +4 -3
  124. package/src/components/dynamics/image.ce +2 -1
  125. package/src/components/dynamics/shape.ce +3 -2
  126. package/src/components/dynamics/text.ce +9 -8
  127. package/src/components/gui/dialogbox/index.ce +3 -2
  128. package/src/components/gui/gameover.ce +2 -1
  129. package/src/components/gui/menu/equip-menu.ce +2 -1
  130. package/src/components/gui/menu/exit-menu.ce +2 -1
  131. package/src/components/gui/menu/items-menu.ce +3 -2
  132. package/src/components/gui/menu/main-menu.ce +2 -1
  133. package/src/components/gui/save-load.ce +2 -1
  134. package/src/components/gui/shop/shop.ce +3 -2
  135. package/src/components/gui/title-screen.ce +2 -1
  136. package/src/components/index.ts +2 -1
  137. package/src/components/player-components.ce +11 -10
  138. package/src/components/prebuilt/hp-bar.ce +4 -3
  139. package/src/components/prebuilt/light-halo.ce +2 -2
  140. package/src/components/scenes/canvas.ce +10 -2
  141. package/src/components/scenes/draw-map.ce +17 -3
  142. package/src/index.ts +4 -0
  143. package/src/module.ts +24 -0
  144. package/src/services/actionInput.spec.ts +155 -0
  145. package/src/services/actionInput.ts +120 -0
  146. package/src/services/mmorpg-connection.spec.ts +99 -0
  147. package/src/services/mmorpg-connection.ts +69 -0
  148. package/src/services/mmorpg.ts +60 -34
  149. package/src/services/pointerContext.spec.ts +36 -0
  150. package/src/services/pointerContext.ts +84 -0
  151. package/src/services/standalone-message.ts +7 -0
  152. package/src/services/standalone.spec.ts +34 -0
  153. package/src/services/standalone.ts +42 -12
  154. package/src/utils/mapId.ts +2 -0
@@ -1,11 +1,14 @@
1
1
  import Canvas from "./components/scenes/canvas.ce";
2
+ import BuiltinSceneMap from "./components/scenes/draw-map.ce";
2
3
  import { inject } from './core/inject'
3
4
  import { signal, bootstrapCanvas, Howl, trigger, type Trigger } from "canvasengine";
4
5
  import { AbstractWebsocket, WebSocketToken } from "./services/AbstractSocket";
5
6
  import { LoadMapService, LoadMapToken } from "./services/loadMap";
6
7
  import { RpgSound } from "./Sound";
7
8
  import { RpgResource } from "./Resource";
8
- import { Hooks, ModulesToken, Direction, normalizeLightingState } from "@rpgjs/common";
9
+ import { Hooks, ModulesToken, Direction, normalizeLightingState, Vector2 } from "@rpgjs/common";
10
+ import type { EventComponentConfig } from "./RpgClient";
11
+ import type { RpgClientEvent } from "./Game/Event";
9
12
  import { load } from "@signe/sync";
10
13
  import { RpgClientMap } from "./Game/Map"
11
14
  import { RpgGui } from "./Gui/Gui";
@@ -22,10 +25,18 @@ import {
22
25
  PredictionController,
23
26
  type PredictionHistoryEntry,
24
27
  type PredictionState,
28
+ type RpgActionInput,
29
+ type RpgActionName,
25
30
  } from "@rpgjs/common";
26
31
  import { NotificationManager } from "./Gui/NotificationManager";
27
32
  import { SaveClientService } from "./services/save";
28
33
  import { getCanMoveValue } from "./utils/readPropValue";
34
+ import { ProjectileManager, type ClientProjectileImpact, type ClientProjectileSpawn } from "./Game/ProjectileManager";
35
+ import { ClientVisualRegistry, type ClientVisualHandler, type ClientVisualMap, type ClientVisualPacket } from "./Game/ClientVisuals";
36
+ import { normalizeActionInput } from "./services/actionInput";
37
+ import { createClientPointerContext, type ClientPointerContext } from "./services/pointerContext";
38
+ import { normalizeRoomMapId } from "./utils/mapId";
39
+ import { EventComponentResolverRegistry, type EventComponentResolver } from "./Game/EventComponentResolver";
29
40
 
30
41
  interface MovementTrajectoryPoint {
31
42
  frame: number;
@@ -57,12 +68,16 @@ export class RpgClientEngine<T = any> {
57
68
  private selector: HTMLElement;
58
69
  public globalConfig: T;
59
70
  public sceneComponent: any;
71
+ public sceneMapComponent: any = BuiltinSceneMap;
60
72
  stopProcessingInput = false;
61
73
  width = signal("100%");
62
74
  height = signal("100%");
63
75
  spritesheets: Map<string | number, any> = new Map();
64
76
  sounds: Map<string, any> = new Map();
65
77
  componentAnimations: any[] = [];
78
+ clientVisuals = new ClientVisualRegistry();
79
+ projectiles: ProjectileManager;
80
+ pointer: ClientPointerContext = createClientPointerContext();
66
81
  private spritesheetResolver?: (id: string | number) => any | Promise<any>;
67
82
  private soundResolver?: (id: string) => any | Promise<any>;
68
83
  particleSettings: {
@@ -78,6 +93,7 @@ export class RpgClientEngine<T = any> {
78
93
  spriteComponentsBehind = signal<any[]>([]);
79
94
  spriteComponentsInFront = signal<any[]>([]);
80
95
  spriteComponents: Map<string, any> = new Map();
96
+ private eventComponentResolvers = new EventComponentResolverRegistry();
81
97
  /** ID of the sprite that the camera should follow. null means follow the current player */
82
98
  cameraFollowTargetId = signal<string | null>(null);
83
99
  /** Trigger for map shake animation */
@@ -93,6 +109,8 @@ export class RpgClientEngine<T = any> {
93
109
  private pendingPredictionFrames: number[] = [];
94
110
  private lastClientPhysicsStepAt = 0;
95
111
  private frameOffset = 0;
112
+ private latestServerTick?: number;
113
+ private latestServerTickAt = 0;
96
114
  // Ping/Pong for RTT measurement
97
115
  private rtt: number = 0; // Round-trip time in ms
98
116
  private pingInterval: any = null;
@@ -109,10 +127,16 @@ export class RpgClientEngine<T = any> {
109
127
  private eventsReceived$ = new BehaviorSubject<boolean>(false);
110
128
  private onAfterLoadingSubscription?: any;
111
129
  private sceneResetQueued = false;
130
+ private mapTransitionInProgress = false;
131
+ private currentMapRoomId?: string;
132
+ private socketListenersInitialized = false;
112
133
 
113
134
  // Store subscriptions and event listeners for cleanup
114
135
  private tickSubscriptions: any[] = [];
115
136
  private resizeHandler?: () => void;
137
+ private pointerMoveHandler?: (event: PointerEvent) => void;
138
+ private pointerCanvas?: HTMLCanvasElement;
139
+ private pendingSyncPackets: any[] = [];
116
140
  private notificationManager: NotificationManager = new NotificationManager();
117
141
 
118
142
  constructor(public context) {
@@ -120,6 +144,10 @@ export class RpgClientEngine<T = any> {
120
144
  this.guiService = inject(RpgGui);
121
145
  this.loadMapService = inject(LoadMapToken);
122
146
  this.hooks = inject<Hooks>(ModulesToken);
147
+ this.projectiles = new ProjectileManager(
148
+ this.hooks,
149
+ (projectile) => this.predictProjectileImpact(projectile),
150
+ );
123
151
  this.globalConfig = inject(GlobalConfigToken)
124
152
 
125
153
  if (!this.globalConfig) {
@@ -197,6 +225,22 @@ export class RpgClientEngine<T = any> {
197
225
  this.sceneMap = new RpgClientMap()
198
226
  this.sceneMap.configureClientPrediction(this.predictionEnabled);
199
227
  this.sceneMap.loadPhysic();
228
+ this.resolveSceneMapComponent();
229
+
230
+ const saveClient = inject(SaveClientService);
231
+ saveClient.initialize();
232
+ this.initListeners();
233
+ this.guiService._initialize();
234
+
235
+ try {
236
+ await this.webSocket.connection();
237
+ }
238
+ catch (error) {
239
+ this.stopPingPong();
240
+ await this.callConnectError(error);
241
+ throw error;
242
+ }
243
+
200
244
  this.selector = document.body.querySelector("#rpg") as HTMLElement;
201
245
 
202
246
  const bootstrapOptions = (this.globalConfig as any)?.bootstrapCanvasOptions;
@@ -207,8 +251,10 @@ export class RpgClientEngine<T = any> {
207
251
  );
208
252
  this.canvasApp = app;
209
253
  this.canvasElement = canvasElement;
210
- this.renderer = app.renderer as PIXI.Renderer;
254
+ this.renderer = app.renderer as unknown as PIXI.Renderer;
255
+ this.setupPointerTracking();
211
256
  this.tick = canvasElement?.propObservables?.context['tick'].observable
257
+ this.flushPendingSyncPackets();
212
258
 
213
259
  const inputCheckSubscription = this.tick.subscribe(() => {
214
260
  if (Date.now() - this.lastInputTime > 100) {
@@ -224,12 +270,14 @@ export class RpgClientEngine<T = any> {
224
270
  this.hooks.callHooks("client-spritesheetResolver-load", this).subscribe();
225
271
  this.hooks.callHooks("client-sounds-load", this).subscribe();
226
272
  this.hooks.callHooks("client-soundResolver-load", this).subscribe();
227
-
273
+
228
274
  RpgSound.init(this);
229
275
  RpgResource.init(this);
230
276
  this.hooks.callHooks("client-gui-load", this).subscribe();
231
277
  this.hooks.callHooks("client-particles-load", this).subscribe();
232
278
  this.hooks.callHooks("client-componentAnimations-load", this).subscribe();
279
+ this.hooks.callHooks("client-clientVisuals-load", this).subscribe();
280
+ this.hooks.callHooks("client-projectiles-load", this).subscribe();
233
281
  this.hooks.callHooks("client-sprite-load", this).subscribe();
234
282
 
235
283
  await lastValueFrom(this.hooks.callHooks("client-engine-onStart", this));
@@ -242,6 +290,7 @@ export class RpgClientEngine<T = any> {
242
290
 
243
291
  const tickSubscription = this.tick.subscribe((tick) => {
244
292
  this.stepClientPhysicsTick();
293
+ this.projectiles.step();
245
294
  this.flushPendingPredictedStates();
246
295
  this.flushPendingMovePath();
247
296
  this.hooks.callHooks("client-engine-onStep", this, tick).subscribe();
@@ -255,13 +304,56 @@ export class RpgClientEngine<T = any> {
255
304
  });
256
305
  this.tickSubscriptions.push(tickSubscription);
257
306
 
258
- await this.webSocket.connection(() => {
259
- const saveClient = inject(SaveClientService);
260
- saveClient.initialize();
261
- this.initListeners()
262
- this.guiService._initialize()
263
- this.startPingPong();
264
- });
307
+ this.startPingPong();
308
+ }
309
+
310
+ private resolveSceneMapComponent() {
311
+ const components = this.hooks.getHookFunctions("client-sceneMap-component");
312
+ const component = components[components.length - 1];
313
+ if (component) {
314
+ this.sceneMapComponent = component;
315
+ }
316
+ }
317
+
318
+ private setupPointerTracking() {
319
+ const renderer = this.renderer as any;
320
+ const canvas = renderer?.canvas ?? renderer?.view ?? (this.canvasApp as any)?.canvas;
321
+
322
+ if (!canvas || typeof canvas.addEventListener !== "function") {
323
+ return;
324
+ }
325
+
326
+ this.pointerCanvas = canvas;
327
+ this.pointerMoveHandler = (event: PointerEvent) => {
328
+ const rect = canvas.getBoundingClientRect();
329
+ const screen = {
330
+ x: event.clientX - rect.left,
331
+ y: event.clientY - rect.top,
332
+ };
333
+ const viewport = this.findViewportInstance();
334
+ let world = screen;
335
+
336
+ if (viewport && typeof viewport.toWorld === "function") {
337
+ const point = viewport.toWorld(screen.x, screen.y);
338
+ world = { x: Number(point.x), y: Number(point.y) };
339
+ } else if (viewport && typeof viewport.toLocal === "function") {
340
+ const point = viewport.toLocal(screen);
341
+ world = { x: Number(point.x), y: Number(point.y) };
342
+ }
343
+
344
+ this.pointer.update(screen, world);
345
+ };
346
+
347
+ canvas.addEventListener("pointermove", this.pointerMoveHandler);
348
+ canvas.addEventListener("pointerdown", this.pointerMoveHandler);
349
+ }
350
+
351
+ private findViewportInstance(): any {
352
+ const children = (this.canvasApp as any)?.stage?.children ?? [];
353
+ return children.find((child: any) => (
354
+ typeof child?.toWorld === "function"
355
+ || child?.constructor?.name === "Viewport"
356
+ ));
265
357
  }
266
358
 
267
359
  private prepareSyncPayload(data: any): any {
@@ -311,52 +403,15 @@ export class RpgClientEngine<T = any> {
311
403
  }
312
404
 
313
405
  private initListeners() {
314
- this.webSocket.on("sync", (data) => {
315
- if (data.pId) {
316
- this.playerIdSignal.set(data.pId);
317
- // Signal that player ID was received
318
- this.playerIdReceived$.next(true);
319
- }
406
+ if (this.socketListenersInitialized) return;
407
+ this.socketListenersInitialized = true;
320
408
 
321
- if (this.sceneResetQueued) {
322
- this.sceneMap.reset();
323
- this.sceneMap.loadPhysic();
324
- this.sceneResetQueued = false;
325
- }
326
-
327
- // Apply client-side prediction filtering and server reconciliation
328
- this.hooks.callHooks("client-sceneMap-onChanges", this.sceneMap, { partial: data }).subscribe();
329
-
330
- const ack = data?.ack;
331
- const normalizedAck =
332
- ack && typeof ack.frame === "number"
333
- ? this.normalizeAckWithSyncState(ack, data)
334
- : undefined;
335
- const payload = this.prepareSyncPayload(data);
336
- load(this.sceneMap, payload, true);
337
-
338
- if (normalizedAck) {
339
- this.applyServerAck(normalizedAck);
340
- }
341
-
342
- for (const playerId in payload.players ?? {}) {
343
- const player = payload.players[playerId]
344
- if (!player._param) continue
345
- for (const param in player._param) {
346
- this.sceneMap.players()[playerId]._param()[param] = player._param[param]
347
- }
348
- }
349
-
350
- // Check if players and events are present in sync data
351
- const players = payload.players || this.sceneMap.players();
352
- if (players && Object.keys(players).length > 0) {
353
- this.playersReceived$.next(true);
354
- }
355
-
356
- const events = payload.events || this.sceneMap.events();
357
- if (events !== undefined) {
358
- this.eventsReceived$.next(true);
409
+ this.webSocket.on("sync", (data) => {
410
+ if (!this.tick) {
411
+ this.pendingSyncPackets.push(data);
412
+ return;
359
413
  }
414
+ this.applySyncPacket(data);
360
415
  });
361
416
 
362
417
  // Handle pong responses for RTT measurement
@@ -368,6 +423,7 @@ export class RpgClientEngine<T = any> {
368
423
  // This helps us estimate which server tick corresponds to each client input frame
369
424
  const estimatedTicksInFlight = Math.floor(this.rtt / 2 / (1000 / 60)); // Estimate ticks during half RTT
370
425
  const estimatedServerTickNow = data.serverTick + estimatedTicksInFlight;
426
+ this.updateServerTickEstimate(estimatedServerTickNow, now);
371
427
 
372
428
  // Update frame offset (only if we have inputs to calibrate with)
373
429
  if (this.inputFrameCounter > 0) {
@@ -378,10 +434,8 @@ export class RpgClientEngine<T = any> {
378
434
  });
379
435
 
380
436
  this.webSocket.on("changeMap", (data) => {
381
- this.sceneResetQueued = true;
382
- this.sceneMap.clearLightSpots();
383
- // Reset camera follow to default (follow current player) when changing maps
384
- this.cameraFollowTargetId.set(null);
437
+ const nextMapId = typeof data?.mapId === "string" ? data.mapId : undefined;
438
+ this.beginMapTransfer(nextMapId);
385
439
  const transferToken = typeof data?.transferToken === "string" ? data.transferToken : undefined;
386
440
  this.loadScene(data.mapId, transferToken);
387
441
  });
@@ -395,6 +449,38 @@ export class RpgClientEngine<T = any> {
395
449
  this.getComponentAnimation(id).displayEffect(params, player || position)
396
450
  });
397
451
 
452
+ this.webSocket.on("clientVisual", (data) => {
453
+ this.playClientVisual(data);
454
+ });
455
+
456
+ this.webSocket.on("projectile:spawnBatch", (data) => {
457
+ if (!this.shouldProcessProjectilePacket(data)) return;
458
+ this.projectiles.spawnBatch(data?.projectiles ?? [], {
459
+ mapId: data?.mapId,
460
+ currentServerTick: this.estimateServerTick(),
461
+ tickDurationMs: this.getPhysicsTickDurationMs(),
462
+ });
463
+ });
464
+
465
+ this.webSocket.on("projectile:impactBatch", (data) => {
466
+ if (!this.shouldProcessProjectilePacket(data)) return;
467
+ this.projectiles.impactBatch(data?.impacts ?? [], {
468
+ mapId: data?.mapId,
469
+ });
470
+ });
471
+
472
+ this.webSocket.on("projectile:destroyBatch", (data) => {
473
+ if (!this.shouldProcessProjectilePacket(data)) return;
474
+ this.projectiles.destroyBatch(data?.projectiles ?? [], {
475
+ mapId: data?.mapId,
476
+ });
477
+ });
478
+
479
+ this.webSocket.on("projectile:clear", (data) => {
480
+ if (!this.shouldProcessProjectilePacket(data)) return;
481
+ this.projectiles.clear();
482
+ });
483
+
398
484
  this.webSocket.on("notification", (data) => {
399
485
  this.notificationManager.add(data);
400
486
  });
@@ -505,10 +591,102 @@ export class RpgClientEngine<T = any> {
505
591
  })
506
592
 
507
593
  this.webSocket.on('error', (error) => {
508
- this.hooks.callHooks("client-engine-onConnectError", this, error, this.socket).subscribe();
594
+ void this.callConnectError(error);
509
595
  })
510
596
  }
511
597
 
598
+ private beginMapTransfer(nextMapId?: string) {
599
+ this.mapTransitionInProgress = true;
600
+ this.currentMapRoomId = nextMapId;
601
+ this.sceneResetQueued = false;
602
+ this.clearClientPredictionStates();
603
+ this.sceneMap.weatherState.set(null);
604
+ this.sceneMap.lightingState.set(null);
605
+ this.sceneMap.clearLightSpots();
606
+ this.clearComponentAnimations();
607
+ this.projectiles.setMapId(nextMapId);
608
+ this.cameraFollowTargetId.set(null);
609
+ this.sceneMap.reset();
610
+ this.sceneMap.loadPhysic();
611
+ }
612
+
613
+ private clearComponentAnimations() {
614
+ this.componentAnimations.forEach((componentAnimation) => {
615
+ componentAnimation.instance?.clear?.();
616
+ });
617
+ }
618
+
619
+ private shouldProcessProjectilePacket(data: any): boolean {
620
+ if (this.mapTransitionInProgress) return false;
621
+ const packetMapId = normalizeRoomMapId(
622
+ typeof data?.mapId === "string" ? data.mapId : undefined,
623
+ );
624
+ const currentMapId = normalizeRoomMapId(this.currentMapRoomId);
625
+ return !packetMapId || !currentMapId || packetMapId === currentMapId;
626
+ }
627
+
628
+ private async callConnectError(error: any) {
629
+ await lastValueFrom(this.hooks.callHooks("client-engine-onConnectError", this, error, this.socket));
630
+ }
631
+
632
+ private flushPendingSyncPackets() {
633
+ const packets = this.pendingSyncPackets;
634
+ this.pendingSyncPackets = [];
635
+ packets.forEach((packet) => this.applySyncPacket(packet));
636
+ }
637
+
638
+ private applySyncPacket(data: any) {
639
+ if (data.pId) {
640
+ this.playerIdSignal.set(data.pId);
641
+ // Signal that player ID was received
642
+ this.playerIdReceived$.next(true);
643
+ }
644
+
645
+ if (this.sceneResetQueued) {
646
+ const weatherState = this.sceneMap.weatherState();
647
+ const lightingState = this.sceneMap.lightingState();
648
+ this.sceneMap.reset();
649
+ this.sceneMap.weatherState.set(weatherState);
650
+ this.sceneMap.lightingState.set(lightingState);
651
+ this.sceneMap.loadPhysic();
652
+ this.sceneResetQueued = false;
653
+ }
654
+
655
+ // Apply client-side prediction filtering and server reconciliation
656
+ this.hooks.callHooks("client-sceneMap-onChanges", this.sceneMap, { partial: data }).subscribe();
657
+
658
+ const ack = data?.ack;
659
+ const normalizedAck =
660
+ ack && typeof ack.frame === "number"
661
+ ? this.normalizeAckWithSyncState(ack, data)
662
+ : undefined;
663
+ const payload = this.prepareSyncPayload(data);
664
+ load(this.sceneMap, payload, true);
665
+
666
+ if (normalizedAck) {
667
+ this.applyServerAck(normalizedAck);
668
+ }
669
+
670
+ for (const playerId in payload.players ?? {}) {
671
+ const player = payload.players[playerId]
672
+ if (!player._param) continue
673
+ for (const param in player._param) {
674
+ this.sceneMap.players()[playerId]._param()[param] = player._param[param]
675
+ }
676
+ }
677
+
678
+ // Check if players and events are present in sync data
679
+ const players = payload.players || this.sceneMap.players();
680
+ if (players && Object.keys(players).length > 0) {
681
+ this.playersReceived$.next(true);
682
+ }
683
+
684
+ const events = payload.events || this.sceneMap.events();
685
+ if (events !== undefined) {
686
+ this.eventsReceived$.next(true);
687
+ }
688
+ }
689
+
512
690
  /**
513
691
  * Start periodic ping/pong for client-server synchronization
514
692
  *
@@ -605,12 +783,15 @@ export class RpgClientEngine<T = any> {
605
783
  room: mapId,
606
784
  query: transferToken ? { transferToken } : undefined,
607
785
  })
608
- await this.webSocket.reconnect(() => {
609
- const saveClient = inject(SaveClientService);
610
- saveClient.initialize();
611
- this.initListeners()
612
- this.guiService._initialize()
613
- })
786
+ try {
787
+ await this.webSocket.reconnect()
788
+ }
789
+ catch (error) {
790
+ this.mapTransitionInProgress = false;
791
+ this.stopPingPong();
792
+ await this.callConnectError(error);
793
+ throw error;
794
+ }
614
795
  const res = await this.loadMapService.load(mapId)
615
796
  this.sceneMap.data.set(res)
616
797
 
@@ -632,6 +813,8 @@ export class RpgClientEngine<T = any> {
632
813
 
633
814
  // Signal that map loading is completed (this should be last to ensure other checks are done)
634
815
  this.mapLoadCompleted$.next(true);
816
+ this.currentMapRoomId = mapId;
817
+ this.mapTransitionInProgress = false;
635
818
  this.sceneMap.configureClientPrediction(this.predictionEnabled);
636
819
  this.sceneMap.loadPhysic()
637
820
  }
@@ -1157,6 +1340,73 @@ export class RpgClientEngine<T = any> {
1157
1340
  return this.spriteComponents.get(id);
1158
1341
  }
1159
1342
 
1343
+ /**
1344
+ * Register a custom event component resolver.
1345
+ *
1346
+ * The last resolver returning a component wins. This lets later modules
1347
+ * override earlier defaults without replacing the whole map scene.
1348
+ *
1349
+ * @param resolver - Function receiving the synced event object
1350
+ * @returns The registered resolver
1351
+ */
1352
+ addEventComponentResolver(resolver: EventComponentResolver) {
1353
+ return this.eventComponentResolvers.add(resolver);
1354
+ }
1355
+
1356
+ /**
1357
+ * Resolve the custom CanvasEngine component for an event, if any.
1358
+ *
1359
+ * @param event - Synced client event object
1360
+ * @returns The component/config returned by the last matching resolver
1361
+ */
1362
+ resolveEventComponent(event: RpgClientEvent): EventComponentConfig | null {
1363
+ return this.eventComponentResolvers.resolve(event);
1364
+ }
1365
+
1366
+ registerProjectileComponent(type: string, component: any) {
1367
+ return this.projectiles.register(type, component);
1368
+ }
1369
+
1370
+ getProjectileComponent(type: string) {
1371
+ return this.projectiles.get(type);
1372
+ }
1373
+
1374
+ /**
1375
+ * Register a named client visual macro.
1376
+ *
1377
+ * Client visuals are small client-side functions that group existing visual
1378
+ * primitives such as flash, sound, component animations, sprite animation, or
1379
+ * map shake. The server sends only the visual name and a serializable payload.
1380
+ *
1381
+ * @param name - Stable visual name sent by the server
1382
+ * @param handler - Client-side visual handler
1383
+ * @returns The registered handler
1384
+ */
1385
+ registerClientVisual(name: string, handler: ClientVisualHandler) {
1386
+ return this.clientVisuals.register(name, handler);
1387
+ }
1388
+
1389
+ /**
1390
+ * Register several named client visual macros.
1391
+ *
1392
+ * @param visuals - Map of visual names to client-side handlers
1393
+ */
1394
+ registerClientVisuals(visuals: ClientVisualMap) {
1395
+ this.clientVisuals.registerMany(visuals);
1396
+ }
1397
+
1398
+ /**
1399
+ * Play a registered client visual locally.
1400
+ *
1401
+ * This is also used by the websocket listener when the server calls
1402
+ * `player.clientVisual()` or `map.clientVisual()`.
1403
+ *
1404
+ * @param packet - Visual name and serializable payload
1405
+ */
1406
+ playClientVisual(packet: ClientVisualPacket) {
1407
+ return this.clientVisuals.play(packet, this);
1408
+ }
1409
+
1160
1410
  /**
1161
1411
  * Add a component animation to the engine
1162
1412
  *
@@ -1307,7 +1557,9 @@ export class RpgClientEngine<T = any> {
1307
1557
  this.lastInputTime = Date.now();
1308
1558
  }
1309
1559
 
1310
- processAction({ action }: { action: number }) {
1560
+ processAction(action: RpgActionName, data?: any): void;
1561
+ processAction(action: RpgActionInput): void;
1562
+ processAction(action: RpgActionName | RpgActionInput, data?: any): void {
1311
1563
  if (this.stopProcessingInput) return;
1312
1564
  const currentPlayer = this.sceneMap.getCurrentPlayer() as any;
1313
1565
  const canMove =
@@ -1315,8 +1567,15 @@ export class RpgClientEngine<T = any> {
1315
1567
  getCanMoveValue(currentPlayer);
1316
1568
  if (!canMove) return;
1317
1569
 
1318
- this.hooks.callHooks("client-engine-onInput", this, { input: 'action', playerId: this.playerId }).subscribe();
1319
- this.webSocket.emit('action', { action })
1570
+ const payload = normalizeActionInput(action as any, data);
1571
+
1572
+ this.hooks.callHooks("client-engine-onInput", this, {
1573
+ input: payload.action,
1574
+ action: payload.action,
1575
+ data: payload.data,
1576
+ playerId: this.playerId,
1577
+ }).subscribe();
1578
+ this.webSocket.emit('action', payload)
1320
1579
  }
1321
1580
 
1322
1581
  get PIXI() {
@@ -1335,8 +1594,76 @@ export class RpgClientEngine<T = any> {
1335
1594
  return this.sceneMap
1336
1595
  }
1337
1596
 
1597
+ getObjectById(id: string) {
1598
+ return this.sceneMap?.getObjectById(id);
1599
+ }
1600
+
1338
1601
  private getPhysicsTick(): number {
1339
- return this.sceneMap?.getTick?.() ?? 0;
1602
+ return (this.sceneMap as any)?.getTick?.() ?? 0;
1603
+ }
1604
+
1605
+ private getPhysicsTickDurationMs(): number {
1606
+ const timeStep = (this.sceneMap as any)?.physic?.getWorld?.()?.getTimeStep?.();
1607
+ return typeof timeStep === "number" && Number.isFinite(timeStep) && timeStep > 0
1608
+ ? timeStep * 1000
1609
+ : 1000 / 60;
1610
+ }
1611
+
1612
+ private updateServerTickEstimate(serverTick: number | undefined, now = Date.now()): void {
1613
+ if (typeof serverTick !== "number" || !Number.isFinite(serverTick)) {
1614
+ return;
1615
+ }
1616
+ this.latestServerTick = serverTick;
1617
+ this.latestServerTickAt = now;
1618
+ }
1619
+
1620
+ private estimateServerTick(now = Date.now()): number | undefined {
1621
+ if (typeof this.latestServerTick !== "number" || this.latestServerTickAt <= 0) {
1622
+ return undefined;
1623
+ }
1624
+ const elapsedTicks = Math.max(0, (now - this.latestServerTickAt) / this.getPhysicsTickDurationMs());
1625
+ return this.latestServerTick + elapsedTicks;
1626
+ }
1627
+
1628
+ private predictProjectileImpact(projectile: ClientProjectileSpawn): ClientProjectileImpact | null {
1629
+ if (projectile.predictImpact === false) {
1630
+ return null;
1631
+ }
1632
+ const sceneMap = this.sceneMap as any;
1633
+ if (!sceneMap?.physic || !Number.isFinite(projectile.range) || projectile.range <= 0) {
1634
+ return null;
1635
+ }
1636
+ const origin = projectile.origin;
1637
+ const direction = projectile.direction;
1638
+ if (
1639
+ !origin ||
1640
+ !direction ||
1641
+ !Number.isFinite(origin.x) ||
1642
+ !Number.isFinite(origin.y) ||
1643
+ !Number.isFinite(direction.x) ||
1644
+ !Number.isFinite(direction.y) ||
1645
+ (direction.x === 0 && direction.y === 0)
1646
+ ) {
1647
+ return null;
1648
+ }
1649
+
1650
+ const hit = sceneMap.physic.raycast(
1651
+ new Vector2(origin.x, origin.y),
1652
+ new Vector2(direction.x, direction.y),
1653
+ projectile.range,
1654
+ projectile.collisionMask,
1655
+ (entity) => projectile.ignoreOwner === false || !projectile.ownerId || entity.uuid !== projectile.ownerId,
1656
+ );
1657
+ if (!hit) {
1658
+ return null;
1659
+ }
1660
+ return {
1661
+ id: projectile.id,
1662
+ targetId: hit.entity.uuid,
1663
+ x: hit.point.x,
1664
+ y: hit.point.y,
1665
+ distance: hit.distance,
1666
+ };
1340
1667
  }
1341
1668
 
1342
1669
  private ensureCurrentPlayerBody(): boolean {
@@ -1686,6 +2013,7 @@ export class RpgClientEngine<T = any> {
1686
2013
  }
1687
2014
 
1688
2015
  private applyServerAck(ack: { frame: number; serverTick?: number; x?: number; y?: number; direction?: Direction }) {
2016
+ this.updateServerTickEstimate(ack.serverTick);
1689
2017
  if (this.predictionEnabled && this.prediction) {
1690
2018
  const result = this.prediction.applyServerAck({
1691
2019
  frame: ack.frame,
@@ -1847,6 +2175,13 @@ export class RpgClientEngine<T = any> {
1847
2175
  this.resizeHandler = undefined;
1848
2176
  }
1849
2177
 
2178
+ if (this.pointerMoveHandler && this.pointerCanvas) {
2179
+ this.pointerCanvas.removeEventListener('pointermove', this.pointerMoveHandler);
2180
+ this.pointerCanvas.removeEventListener('pointerdown', this.pointerMoveHandler);
2181
+ this.pointerMoveHandler = undefined;
2182
+ this.pointerCanvas = undefined;
2183
+ }
2184
+
1850
2185
  // Destroy PIXI app and renderer if they exist
1851
2186
  // Destroy the app first, which will destroy the renderer
1852
2187
  // Store renderer reference before destroying app (since app.destroy() will destroy the renderer)
@@ -1920,6 +2255,7 @@ export class RpgClientEngine<T = any> {
1920
2255
  this.cameraFollowTargetId.set(null);
1921
2256
  this.spriteComponentsBehind.set([]);
1922
2257
  this.spriteComponentsInFront.set([]);
2258
+ this.eventComponentResolvers.clear();
1923
2259
 
1924
2260
  // Clear maps and arrays
1925
2261
  this.spritesheets.clear();