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

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 (118) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/Game/ProjectileManager.d.ts +89 -0
  3. package/dist/Game/ProjectileManager.js +179 -0
  4. package/dist/Game/ProjectileManager.js.map +1 -0
  5. package/dist/Game/ProjectileManager.spec.d.ts +1 -0
  6. package/dist/RpgClient.d.ts +53 -13
  7. package/dist/RpgClientEngine.d.ts +25 -4
  8. package/dist/RpgClientEngine.js +197 -48
  9. package/dist/RpgClientEngine.js.map +1 -1
  10. package/dist/components/animations/hit.ce.js.map +1 -1
  11. package/dist/components/character.ce.js +32 -30
  12. package/dist/components/character.ce.js.map +1 -1
  13. package/dist/components/dynamics/bar.ce.js +4 -3
  14. package/dist/components/dynamics/bar.ce.js.map +1 -1
  15. package/dist/components/dynamics/image.ce.js +2 -1
  16. package/dist/components/dynamics/image.ce.js.map +1 -1
  17. package/dist/components/dynamics/shape.ce.js +3 -2
  18. package/dist/components/dynamics/shape.ce.js.map +1 -1
  19. package/dist/components/dynamics/text.ce.js +9 -8
  20. package/dist/components/dynamics/text.ce.js.map +1 -1
  21. package/dist/components/gui/dialogbox/index.ce.js +3 -2
  22. package/dist/components/gui/dialogbox/index.ce.js.map +1 -1
  23. package/dist/components/gui/gameover.ce.js +3 -2
  24. package/dist/components/gui/gameover.ce.js.map +1 -1
  25. package/dist/components/gui/hud/hud.ce.js.map +1 -1
  26. package/dist/components/gui/menu/equip-menu.ce.js +2 -1
  27. package/dist/components/gui/menu/equip-menu.ce.js.map +1 -1
  28. package/dist/components/gui/menu/exit-menu.ce.js +2 -1
  29. package/dist/components/gui/menu/exit-menu.ce.js.map +1 -1
  30. package/dist/components/gui/menu/items-menu.ce.js +3 -2
  31. package/dist/components/gui/menu/items-menu.ce.js.map +1 -1
  32. package/dist/components/gui/menu/main-menu.ce.js +3 -2
  33. package/dist/components/gui/menu/main-menu.ce.js.map +1 -1
  34. package/dist/components/gui/menu/options-menu.ce.js.map +1 -1
  35. package/dist/components/gui/menu/skills-menu.ce.js.map +1 -1
  36. package/dist/components/gui/mobile/mobile.ce.js.map +1 -1
  37. package/dist/components/gui/notification/notification.ce.js.map +1 -1
  38. package/dist/components/gui/save-load.ce.js +2 -1
  39. package/dist/components/gui/save-load.ce.js.map +1 -1
  40. package/dist/components/gui/shop/shop.ce.js +3 -2
  41. package/dist/components/gui/shop/shop.ce.js.map +1 -1
  42. package/dist/components/gui/title-screen.ce.js +3 -2
  43. package/dist/components/gui/title-screen.ce.js.map +1 -1
  44. package/dist/components/index.d.ts +2 -1
  45. package/dist/components/index.js +1 -0
  46. package/dist/components/player-components.ce.js +11 -10
  47. package/dist/components/player-components.ce.js.map +1 -1
  48. package/dist/components/prebuilt/hp-bar.ce.js +4 -3
  49. package/dist/components/prebuilt/hp-bar.ce.js.map +1 -1
  50. package/dist/components/prebuilt/light-halo.ce.js +2 -1
  51. package/dist/components/prebuilt/light-halo.ce.js.map +1 -1
  52. package/dist/components/scenes/canvas.ce.js +12 -4
  53. package/dist/components/scenes/canvas.ce.js.map +1 -1
  54. package/dist/components/scenes/draw-map.ce.js +6 -3
  55. package/dist/components/scenes/draw-map.ce.js.map +1 -1
  56. package/dist/components/scenes/event-layer.ce.js.map +1 -1
  57. package/dist/index.d.ts +3 -0
  58. package/dist/index.js +9 -5
  59. package/dist/module.js +11 -0
  60. package/dist/module.js.map +1 -1
  61. package/dist/services/actionInput.d.ts +12 -0
  62. package/dist/services/actionInput.js +27 -0
  63. package/dist/services/actionInput.js.map +1 -0
  64. package/dist/services/actionInput.spec.d.ts +1 -0
  65. package/dist/services/mmorpg-connection.d.ts +5 -0
  66. package/dist/services/mmorpg-connection.js +50 -0
  67. package/dist/services/mmorpg-connection.js.map +1 -0
  68. package/dist/services/mmorpg-connection.spec.d.ts +1 -0
  69. package/dist/services/mmorpg.d.ts +10 -4
  70. package/dist/services/mmorpg.js +48 -30
  71. package/dist/services/mmorpg.js.map +1 -1
  72. package/dist/services/pointerContext.d.ts +11 -0
  73. package/dist/services/pointerContext.js +48 -0
  74. package/dist/services/pointerContext.js.map +1 -0
  75. package/dist/services/pointerContext.spec.d.ts +1 -0
  76. package/dist/services/standalone-message.d.ts +1 -0
  77. package/dist/services/standalone-message.js +9 -0
  78. package/dist/services/standalone-message.js.map +1 -0
  79. package/dist/services/standalone.js +3 -2
  80. package/dist/services/standalone.js.map +1 -1
  81. package/dist/services/standalone.spec.d.ts +1 -0
  82. package/package.json +7 -7
  83. package/src/Game/ProjectileManager.spec.ts +338 -0
  84. package/src/Game/ProjectileManager.ts +324 -0
  85. package/src/RpgClient.ts +62 -15
  86. package/src/RpgClientEngine.ts +287 -65
  87. package/src/components/character.ce +34 -32
  88. package/src/components/dynamics/bar.ce +4 -3
  89. package/src/components/dynamics/image.ce +2 -1
  90. package/src/components/dynamics/shape.ce +3 -2
  91. package/src/components/dynamics/text.ce +9 -8
  92. package/src/components/gui/dialogbox/index.ce +3 -2
  93. package/src/components/gui/gameover.ce +2 -1
  94. package/src/components/gui/menu/equip-menu.ce +2 -1
  95. package/src/components/gui/menu/exit-menu.ce +2 -1
  96. package/src/components/gui/menu/items-menu.ce +3 -2
  97. package/src/components/gui/menu/main-menu.ce +2 -1
  98. package/src/components/gui/save-load.ce +2 -1
  99. package/src/components/gui/shop/shop.ce +3 -2
  100. package/src/components/gui/title-screen.ce +2 -1
  101. package/src/components/index.ts +2 -1
  102. package/src/components/player-components.ce +11 -10
  103. package/src/components/prebuilt/hp-bar.ce +4 -3
  104. package/src/components/prebuilt/light-halo.ce +2 -2
  105. package/src/components/scenes/canvas.ce +10 -2
  106. package/src/components/scenes/draw-map.ce +17 -3
  107. package/src/index.ts +3 -0
  108. package/src/module.ts +13 -0
  109. package/src/services/actionInput.spec.ts +101 -0
  110. package/src/services/actionInput.ts +53 -0
  111. package/src/services/mmorpg-connection.spec.ts +99 -0
  112. package/src/services/mmorpg-connection.ts +69 -0
  113. package/src/services/mmorpg.ts +60 -34
  114. package/src/services/pointerContext.spec.ts +36 -0
  115. package/src/services/pointerContext.ts +84 -0
  116. package/src/services/standalone-message.ts +7 -0
  117. package/src/services/standalone.spec.ts +34 -0
  118. package/src/services/standalone.ts +3 -2
package/src/RpgClient.ts CHANGED
@@ -2,7 +2,11 @@ import { ComponentFunction, Signal } from 'canvasengine'
2
2
  import { RpgClientEngine } from './RpgClientEngine'
3
3
  import { Loader, Container } from 'pixi.js'
4
4
  import { RpgClientObject } from './Game/Object'
5
- import { type MapPhysicsEntityContext, type MapPhysicsInitContext } from '@rpgjs/common'
5
+ import { type MapPhysicsEntityContext, type MapPhysicsInitContext, type RpgActionName } from '@rpgjs/common'
6
+ import type {
7
+ ClientProjectileSpawn,
8
+ RenderedProjectileProps,
9
+ } from './Game/ProjectileManager'
6
10
 
7
11
  type RpgClass<T = any> = new (...args: any[]) => T
8
12
  type RpgComponent = RpgClientObject
@@ -46,13 +50,14 @@ export interface RpgClientEngineHooks {
46
50
  /**
47
51
  * Recover keys from the pressed keyboard
48
52
  *
49
- * @prop { (engine: RpgClientEngine, obj: { input: string, playerId: number }) => any } [onInput]
53
+ * @prop { (engine: RpgClientEngine, obj: { input: string | number, action?: string | number, data?: any, playerId: number }) => any } [onInput]
50
54
  * @memberof RpgEngineHooks
51
55
  */
52
- onInput?: (engine: RpgClientEngine, obj: { input: string, playerId: number }) => any
56
+ onInput?: (engine: RpgClientEngine, obj: { input: RpgActionName, action?: RpgActionName, data?: any, playerId: number }) => any
53
57
 
54
58
  /**
55
- * Called when the user is connected to the server
59
+ * Called when the user is connected to the server. In MMORPG mode, this
60
+ * runs after the server sends the RPGJS connection acceptance packet.
56
61
  *
57
62
  * @prop { (engine: RpgClientEngine, socket: any) => any } [onConnected]
58
63
  * @memberof RpgEngineHooks
@@ -68,7 +73,8 @@ export interface RpgClientEngineHooks {
68
73
  onDisconnect?: (engine: RpgClientEngine, reason: any, socket: any) => any
69
74
 
70
75
  /**
71
- * Called when there was a connection error
76
+ * Called when there was a connection error. In MMORPG mode, this also runs
77
+ * when server-side auth refuses the connection.
72
78
  *
73
79
  * @prop { (engine: RpgClientEngine, err: any, socket: any) => any } [onConnectError]
74
80
  * @memberof RpgEngineHooks
@@ -245,6 +251,14 @@ export interface RpgSceneHooks<Scene> {
245
251
  }
246
252
 
247
253
  export interface RpgSceneMapHooks extends RpgSceneHooks<SceneMap> {
254
+ /**
255
+ * Root CanvasEngine component used to render the RPG scene map.
256
+ *
257
+ * Use the exported `SceneMap` component inside your custom component to
258
+ * keep the default map rendering and compose additional scene children.
259
+ */
260
+ component?: ComponentFunction
261
+
248
262
  /**
249
263
  * The map and resources are being loaded
250
264
  *
@@ -289,6 +303,28 @@ export interface RpgSceneMapHooks extends RpgSceneHooks<SceneMap> {
289
303
  onPhysicsReset?: (scene: SceneMap) => any
290
304
  }
291
305
 
306
+ export interface RpgProjectileHooks {
307
+ /**
308
+ * CanvasEngine components used to render server-authoritative projectiles.
309
+ */
310
+ components?: Record<string, ComponentFunction>
311
+
312
+ /**
313
+ * Called when a projectile spawn batch is received from the server.
314
+ */
315
+ onSpawn?: (projectile: ClientProjectileSpawn) => any
316
+
317
+ /**
318
+ * Called when the server confirms a projectile impact.
319
+ */
320
+ onImpact?: (projectile: RenderedProjectileProps | null) => any
321
+
322
+ /**
323
+ * Called when the server destroys a projectile.
324
+ */
325
+ onDestroy?: (projectile: RenderedProjectileProps | null) => any
326
+ }
327
+
292
328
  export interface RpgClient {
293
329
  /**
294
330
  * Add hooks to the player or engine. All modules can listen to the hook
@@ -624,33 +660,36 @@ export interface RpgClient {
624
660
  * */
625
661
  sprite?: RpgSpriteHooks
626
662
 
627
- /**
628
- * Reference the scenes of the game. Here you can put your own class that inherits RpgSceneMap
663
+ /**
664
+ * Reference the scenes of the game.
629
665
  *
630
666
  * ```ts
631
667
  * import { RpgSceneMapHooks, RpgClient, defineModule } from '@rpgjs/client'
668
+ * import MyScene from './my-scene.ce'
632
669
  *
633
670
  * export const sceneMap: RpgSceneMapHooks = {
634
- *
671
+ * component: MyScene
635
672
  * }
636
673
  *
637
674
  * defineModule<RpgClient>({
638
- * scenes: {
639
- * // If you put the RpgSceneMap scene, Thhe key is called mandatory `map`
640
- * map: sceneMap
641
- * }
675
+ * sceneMap
642
676
  * })
643
677
  * ```
644
678
  *
645
- * @prop { [sceneName: string]: RpgSceneMapHooks } [scenes]
679
+ * @prop {RpgSceneMapHooks} [sceneMap]
646
680
  * @memberof RpgClient
647
681
  * */
682
+ sceneMap?: RpgSceneMapHooks
683
+
684
+ /**
685
+ * Legacy scene map hook container.
686
+ *
687
+ * Prefer `sceneMap` for new code.
688
+ */
648
689
  scenes?: {
649
690
  map: RpgSceneMapHooks
650
691
  }
651
692
 
652
- sceneMap?: RpgSceneMapHooks
653
-
654
693
  /**
655
694
  * Array containing the list of component animations
656
695
  * Each element defines a temporary component to display for animations like hits, effects, etc.
@@ -681,4 +720,12 @@ export interface RpgClient {
681
720
  id: string,
682
721
  component: ComponentFunction
683
722
  }[]
723
+
724
+ /**
725
+ * Client-side projectile rendering configuration.
726
+ *
727
+ * Register a CanvasEngine component per projectile type. The server sends
728
+ * compact spawn/impact/destroy events and the client predicts x/y locally.
729
+ */
730
+ projectiles?: RpgProjectileHooks
684
731
  }
@@ -1,11 +1,12 @@
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";
9
10
  import { load } from "@signe/sync";
10
11
  import { RpgClientMap } from "./Game/Map"
11
12
  import { RpgGui } from "./Gui/Gui";
@@ -22,10 +23,15 @@ import {
22
23
  PredictionController,
23
24
  type PredictionHistoryEntry,
24
25
  type PredictionState,
26
+ type RpgActionInput,
27
+ type RpgActionName,
25
28
  } from "@rpgjs/common";
26
29
  import { NotificationManager } from "./Gui/NotificationManager";
27
30
  import { SaveClientService } from "./services/save";
28
31
  import { getCanMoveValue } from "./utils/readPropValue";
32
+ import { ProjectileManager, type ClientProjectileImpact, type ClientProjectileSpawn } from "./Game/ProjectileManager";
33
+ import { normalizeActionInput } from "./services/actionInput";
34
+ import { createClientPointerContext, type ClientPointerContext } from "./services/pointerContext";
29
35
 
30
36
  interface MovementTrajectoryPoint {
31
37
  frame: number;
@@ -57,12 +63,15 @@ export class RpgClientEngine<T = any> {
57
63
  private selector: HTMLElement;
58
64
  public globalConfig: T;
59
65
  public sceneComponent: any;
66
+ public sceneMapComponent: any = BuiltinSceneMap;
60
67
  stopProcessingInput = false;
61
68
  width = signal("100%");
62
69
  height = signal("100%");
63
70
  spritesheets: Map<string | number, any> = new Map();
64
71
  sounds: Map<string, any> = new Map();
65
72
  componentAnimations: any[] = [];
73
+ projectiles: ProjectileManager;
74
+ pointer: ClientPointerContext = createClientPointerContext();
66
75
  private spritesheetResolver?: (id: string | number) => any | Promise<any>;
67
76
  private soundResolver?: (id: string) => any | Promise<any>;
68
77
  particleSettings: {
@@ -93,6 +102,8 @@ export class RpgClientEngine<T = any> {
93
102
  private pendingPredictionFrames: number[] = [];
94
103
  private lastClientPhysicsStepAt = 0;
95
104
  private frameOffset = 0;
105
+ private latestServerTick?: number;
106
+ private latestServerTickAt = 0;
96
107
  // Ping/Pong for RTT measurement
97
108
  private rtt: number = 0; // Round-trip time in ms
98
109
  private pingInterval: any = null;
@@ -113,6 +124,9 @@ export class RpgClientEngine<T = any> {
113
124
  // Store subscriptions and event listeners for cleanup
114
125
  private tickSubscriptions: any[] = [];
115
126
  private resizeHandler?: () => void;
127
+ private pointerMoveHandler?: (event: PointerEvent) => void;
128
+ private pointerCanvas?: HTMLCanvasElement;
129
+ private pendingSyncPackets: any[] = [];
116
130
  private notificationManager: NotificationManager = new NotificationManager();
117
131
 
118
132
  constructor(public context) {
@@ -120,6 +134,10 @@ export class RpgClientEngine<T = any> {
120
134
  this.guiService = inject(RpgGui);
121
135
  this.loadMapService = inject(LoadMapToken);
122
136
  this.hooks = inject<Hooks>(ModulesToken);
137
+ this.projectiles = new ProjectileManager(
138
+ this.hooks,
139
+ (projectile) => this.predictProjectileImpact(projectile),
140
+ );
123
141
  this.globalConfig = inject(GlobalConfigToken)
124
142
 
125
143
  if (!this.globalConfig) {
@@ -197,6 +215,22 @@ export class RpgClientEngine<T = any> {
197
215
  this.sceneMap = new RpgClientMap()
198
216
  this.sceneMap.configureClientPrediction(this.predictionEnabled);
199
217
  this.sceneMap.loadPhysic();
218
+ this.resolveSceneMapComponent();
219
+
220
+ const saveClient = inject(SaveClientService);
221
+ saveClient.initialize();
222
+ this.initListeners();
223
+ this.guiService._initialize();
224
+
225
+ try {
226
+ await this.webSocket.connection();
227
+ }
228
+ catch (error) {
229
+ this.stopPingPong();
230
+ await this.callConnectError(error);
231
+ throw error;
232
+ }
233
+
200
234
  this.selector = document.body.querySelector("#rpg") as HTMLElement;
201
235
 
202
236
  const bootstrapOptions = (this.globalConfig as any)?.bootstrapCanvasOptions;
@@ -207,8 +241,10 @@ export class RpgClientEngine<T = any> {
207
241
  );
208
242
  this.canvasApp = app;
209
243
  this.canvasElement = canvasElement;
210
- this.renderer = app.renderer as PIXI.Renderer;
244
+ this.renderer = app.renderer as unknown as PIXI.Renderer;
245
+ this.setupPointerTracking();
211
246
  this.tick = canvasElement?.propObservables?.context['tick'].observable
247
+ this.flushPendingSyncPackets();
212
248
 
213
249
  const inputCheckSubscription = this.tick.subscribe(() => {
214
250
  if (Date.now() - this.lastInputTime > 100) {
@@ -224,12 +260,13 @@ export class RpgClientEngine<T = any> {
224
260
  this.hooks.callHooks("client-spritesheetResolver-load", this).subscribe();
225
261
  this.hooks.callHooks("client-sounds-load", this).subscribe();
226
262
  this.hooks.callHooks("client-soundResolver-load", this).subscribe();
227
-
263
+
228
264
  RpgSound.init(this);
229
265
  RpgResource.init(this);
230
266
  this.hooks.callHooks("client-gui-load", this).subscribe();
231
267
  this.hooks.callHooks("client-particles-load", this).subscribe();
232
268
  this.hooks.callHooks("client-componentAnimations-load", this).subscribe();
269
+ this.hooks.callHooks("client-projectiles-load", this).subscribe();
233
270
  this.hooks.callHooks("client-sprite-load", this).subscribe();
234
271
 
235
272
  await lastValueFrom(this.hooks.callHooks("client-engine-onStart", this));
@@ -242,6 +279,7 @@ export class RpgClientEngine<T = any> {
242
279
 
243
280
  const tickSubscription = this.tick.subscribe((tick) => {
244
281
  this.stepClientPhysicsTick();
282
+ this.projectiles.step();
245
283
  this.flushPendingPredictedStates();
246
284
  this.flushPendingMovePath();
247
285
  this.hooks.callHooks("client-engine-onStep", this, tick).subscribe();
@@ -255,13 +293,56 @@ export class RpgClientEngine<T = any> {
255
293
  });
256
294
  this.tickSubscriptions.push(tickSubscription);
257
295
 
258
- await this.webSocket.connection(() => {
259
- const saveClient = inject(SaveClientService);
260
- saveClient.initialize();
261
- this.initListeners()
262
- this.guiService._initialize()
263
- this.startPingPong();
264
- });
296
+ this.startPingPong();
297
+ }
298
+
299
+ private resolveSceneMapComponent() {
300
+ const components = this.hooks.getHookFunctions("client-sceneMap-component");
301
+ const component = components[components.length - 1];
302
+ if (component) {
303
+ this.sceneMapComponent = component;
304
+ }
305
+ }
306
+
307
+ private setupPointerTracking() {
308
+ const renderer = this.renderer as any;
309
+ const canvas = renderer?.canvas ?? renderer?.view ?? (this.canvasApp as any)?.canvas;
310
+
311
+ if (!canvas || typeof canvas.addEventListener !== "function") {
312
+ return;
313
+ }
314
+
315
+ this.pointerCanvas = canvas;
316
+ this.pointerMoveHandler = (event: PointerEvent) => {
317
+ const rect = canvas.getBoundingClientRect();
318
+ const screen = {
319
+ x: event.clientX - rect.left,
320
+ y: event.clientY - rect.top,
321
+ };
322
+ const viewport = this.findViewportInstance();
323
+ let world = screen;
324
+
325
+ if (viewport && typeof viewport.toWorld === "function") {
326
+ const point = viewport.toWorld(screen.x, screen.y);
327
+ world = { x: Number(point.x), y: Number(point.y) };
328
+ } else if (viewport && typeof viewport.toLocal === "function") {
329
+ const point = viewport.toLocal(screen);
330
+ world = { x: Number(point.x), y: Number(point.y) };
331
+ }
332
+
333
+ this.pointer.update(screen, world);
334
+ };
335
+
336
+ canvas.addEventListener("pointermove", this.pointerMoveHandler);
337
+ canvas.addEventListener("pointerdown", this.pointerMoveHandler);
338
+ }
339
+
340
+ private findViewportInstance(): any {
341
+ const children = (this.canvasApp as any)?.stage?.children ?? [];
342
+ return children.find((child: any) => (
343
+ typeof child?.toWorld === "function"
344
+ || child?.constructor?.name === "Viewport"
345
+ ));
265
346
  }
266
347
 
267
348
  private prepareSyncPayload(data: any): any {
@@ -312,51 +393,11 @@ export class RpgClientEngine<T = any> {
312
393
 
313
394
  private initListeners() {
314
395
  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
- }
320
-
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);
396
+ if (!this.tick) {
397
+ this.pendingSyncPackets.push(data);
398
+ return;
359
399
  }
400
+ this.applySyncPacket(data);
360
401
  });
361
402
 
362
403
  // Handle pong responses for RTT measurement
@@ -368,6 +409,7 @@ export class RpgClientEngine<T = any> {
368
409
  // This helps us estimate which server tick corresponds to each client input frame
369
410
  const estimatedTicksInFlight = Math.floor(this.rtt / 2 / (1000 / 60)); // Estimate ticks during half RTT
370
411
  const estimatedServerTickNow = data.serverTick + estimatedTicksInFlight;
412
+ this.updateServerTickEstimate(estimatedServerTickNow, now);
371
413
 
372
414
  // Update frame offset (only if we have inputs to calibrate with)
373
415
  if (this.inputFrameCounter > 0) {
@@ -379,7 +421,10 @@ export class RpgClientEngine<T = any> {
379
421
 
380
422
  this.webSocket.on("changeMap", (data) => {
381
423
  this.sceneResetQueued = true;
424
+ this.sceneMap.weatherState.set(null);
425
+ this.sceneMap.lightingState.set(null);
382
426
  this.sceneMap.clearLightSpots();
427
+ this.projectiles.clear();
383
428
  // Reset camera follow to default (follow current player) when changing maps
384
429
  this.cameraFollowTargetId.set(null);
385
430
  const transferToken = typeof data?.transferToken === "string" ? data.transferToken : undefined;
@@ -395,6 +440,25 @@ export class RpgClientEngine<T = any> {
395
440
  this.getComponentAnimation(id).displayEffect(params, player || position)
396
441
  });
397
442
 
443
+ this.webSocket.on("projectile:spawnBatch", (data) => {
444
+ this.projectiles.spawnBatch(data?.projectiles ?? [], {
445
+ currentServerTick: this.estimateServerTick(),
446
+ tickDurationMs: this.getPhysicsTickDurationMs(),
447
+ });
448
+ });
449
+
450
+ this.webSocket.on("projectile:impactBatch", (data) => {
451
+ this.projectiles.impactBatch(data?.impacts ?? []);
452
+ });
453
+
454
+ this.webSocket.on("projectile:destroyBatch", (data) => {
455
+ this.projectiles.destroyBatch(data?.projectiles ?? []);
456
+ });
457
+
458
+ this.webSocket.on("projectile:clear", () => {
459
+ this.projectiles.clear();
460
+ });
461
+
398
462
  this.webSocket.on("notification", (data) => {
399
463
  this.notificationManager.add(data);
400
464
  });
@@ -505,10 +569,72 @@ export class RpgClientEngine<T = any> {
505
569
  })
506
570
 
507
571
  this.webSocket.on('error', (error) => {
508
- this.hooks.callHooks("client-engine-onConnectError", this, error, this.socket).subscribe();
572
+ void this.callConnectError(error);
509
573
  })
510
574
  }
511
575
 
576
+ private async callConnectError(error: any) {
577
+ await lastValueFrom(this.hooks.callHooks("client-engine-onConnectError", this, error, this.socket));
578
+ }
579
+
580
+ private flushPendingSyncPackets() {
581
+ const packets = this.pendingSyncPackets;
582
+ this.pendingSyncPackets = [];
583
+ packets.forEach((packet) => this.applySyncPacket(packet));
584
+ }
585
+
586
+ private applySyncPacket(data: any) {
587
+ if (data.pId) {
588
+ this.playerIdSignal.set(data.pId);
589
+ // Signal that player ID was received
590
+ this.playerIdReceived$.next(true);
591
+ }
592
+
593
+ if (this.sceneResetQueued) {
594
+ const weatherState = this.sceneMap.weatherState();
595
+ const lightingState = this.sceneMap.lightingState();
596
+ this.sceneMap.reset();
597
+ this.sceneMap.weatherState.set(weatherState);
598
+ this.sceneMap.lightingState.set(lightingState);
599
+ this.sceneMap.loadPhysic();
600
+ this.sceneResetQueued = false;
601
+ }
602
+
603
+ // Apply client-side prediction filtering and server reconciliation
604
+ this.hooks.callHooks("client-sceneMap-onChanges", this.sceneMap, { partial: data }).subscribe();
605
+
606
+ const ack = data?.ack;
607
+ const normalizedAck =
608
+ ack && typeof ack.frame === "number"
609
+ ? this.normalizeAckWithSyncState(ack, data)
610
+ : undefined;
611
+ const payload = this.prepareSyncPayload(data);
612
+ load(this.sceneMap, payload, true);
613
+
614
+ if (normalizedAck) {
615
+ this.applyServerAck(normalizedAck);
616
+ }
617
+
618
+ for (const playerId in payload.players ?? {}) {
619
+ const player = payload.players[playerId]
620
+ if (!player._param) continue
621
+ for (const param in player._param) {
622
+ this.sceneMap.players()[playerId]._param()[param] = player._param[param]
623
+ }
624
+ }
625
+
626
+ // Check if players and events are present in sync data
627
+ const players = payload.players || this.sceneMap.players();
628
+ if (players && Object.keys(players).length > 0) {
629
+ this.playersReceived$.next(true);
630
+ }
631
+
632
+ const events = payload.events || this.sceneMap.events();
633
+ if (events !== undefined) {
634
+ this.eventsReceived$.next(true);
635
+ }
636
+ }
637
+
512
638
  /**
513
639
  * Start periodic ping/pong for client-server synchronization
514
640
  *
@@ -605,12 +731,19 @@ export class RpgClientEngine<T = any> {
605
731
  room: mapId,
606
732
  query: transferToken ? { transferToken } : undefined,
607
733
  })
608
- await this.webSocket.reconnect(() => {
609
- const saveClient = inject(SaveClientService);
610
- saveClient.initialize();
611
- this.initListeners()
612
- this.guiService._initialize()
613
- })
734
+ try {
735
+ await this.webSocket.reconnect(() => {
736
+ const saveClient = inject(SaveClientService);
737
+ saveClient.initialize();
738
+ this.initListeners()
739
+ this.guiService._initialize()
740
+ })
741
+ }
742
+ catch (error) {
743
+ this.stopPingPong();
744
+ await this.callConnectError(error);
745
+ throw error;
746
+ }
614
747
  const res = await this.loadMapService.load(mapId)
615
748
  this.sceneMap.data.set(res)
616
749
 
@@ -1157,6 +1290,14 @@ export class RpgClientEngine<T = any> {
1157
1290
  return this.spriteComponents.get(id);
1158
1291
  }
1159
1292
 
1293
+ registerProjectileComponent(type: string, component: any) {
1294
+ return this.projectiles.register(type, component);
1295
+ }
1296
+
1297
+ getProjectileComponent(type: string) {
1298
+ return this.projectiles.get(type);
1299
+ }
1300
+
1160
1301
  /**
1161
1302
  * Add a component animation to the engine
1162
1303
  *
@@ -1307,7 +1448,9 @@ export class RpgClientEngine<T = any> {
1307
1448
  this.lastInputTime = Date.now();
1308
1449
  }
1309
1450
 
1310
- processAction({ action }: { action: number }) {
1451
+ processAction(action: RpgActionName, data?: any): void;
1452
+ processAction(action: RpgActionInput): void;
1453
+ processAction(action: RpgActionName | RpgActionInput, data?: any): void {
1311
1454
  if (this.stopProcessingInput) return;
1312
1455
  const currentPlayer = this.sceneMap.getCurrentPlayer() as any;
1313
1456
  const canMove =
@@ -1315,8 +1458,15 @@ export class RpgClientEngine<T = any> {
1315
1458
  getCanMoveValue(currentPlayer);
1316
1459
  if (!canMove) return;
1317
1460
 
1318
- this.hooks.callHooks("client-engine-onInput", this, { input: 'action', playerId: this.playerId }).subscribe();
1319
- this.webSocket.emit('action', { action })
1461
+ const payload = normalizeActionInput(action as any, data);
1462
+
1463
+ this.hooks.callHooks("client-engine-onInput", this, {
1464
+ input: payload.action,
1465
+ action: payload.action,
1466
+ data: payload.data,
1467
+ playerId: this.playerId,
1468
+ }).subscribe();
1469
+ this.webSocket.emit('action', payload)
1320
1470
  }
1321
1471
 
1322
1472
  get PIXI() {
@@ -1336,7 +1486,71 @@ export class RpgClientEngine<T = any> {
1336
1486
  }
1337
1487
 
1338
1488
  private getPhysicsTick(): number {
1339
- return this.sceneMap?.getTick?.() ?? 0;
1489
+ return (this.sceneMap as any)?.getTick?.() ?? 0;
1490
+ }
1491
+
1492
+ private getPhysicsTickDurationMs(): number {
1493
+ const timeStep = (this.sceneMap as any)?.physic?.getWorld?.()?.getTimeStep?.();
1494
+ return typeof timeStep === "number" && Number.isFinite(timeStep) && timeStep > 0
1495
+ ? timeStep * 1000
1496
+ : 1000 / 60;
1497
+ }
1498
+
1499
+ private updateServerTickEstimate(serverTick: number | undefined, now = Date.now()): void {
1500
+ if (typeof serverTick !== "number" || !Number.isFinite(serverTick)) {
1501
+ return;
1502
+ }
1503
+ this.latestServerTick = serverTick;
1504
+ this.latestServerTickAt = now;
1505
+ }
1506
+
1507
+ private estimateServerTick(now = Date.now()): number | undefined {
1508
+ if (typeof this.latestServerTick !== "number" || this.latestServerTickAt <= 0) {
1509
+ return undefined;
1510
+ }
1511
+ const elapsedTicks = Math.max(0, (now - this.latestServerTickAt) / this.getPhysicsTickDurationMs());
1512
+ return this.latestServerTick + elapsedTicks;
1513
+ }
1514
+
1515
+ private predictProjectileImpact(projectile: ClientProjectileSpawn): ClientProjectileImpact | null {
1516
+ if (projectile.predictImpact === false) {
1517
+ return null;
1518
+ }
1519
+ const sceneMap = this.sceneMap as any;
1520
+ if (!sceneMap?.physic || !Number.isFinite(projectile.range) || projectile.range <= 0) {
1521
+ return null;
1522
+ }
1523
+ const origin = projectile.origin;
1524
+ const direction = projectile.direction;
1525
+ if (
1526
+ !origin ||
1527
+ !direction ||
1528
+ !Number.isFinite(origin.x) ||
1529
+ !Number.isFinite(origin.y) ||
1530
+ !Number.isFinite(direction.x) ||
1531
+ !Number.isFinite(direction.y) ||
1532
+ (direction.x === 0 && direction.y === 0)
1533
+ ) {
1534
+ return null;
1535
+ }
1536
+
1537
+ const hit = sceneMap.physic.raycast(
1538
+ new Vector2(origin.x, origin.y),
1539
+ new Vector2(direction.x, direction.y),
1540
+ projectile.range,
1541
+ projectile.collisionMask,
1542
+ (entity) => projectile.ignoreOwner === false || !projectile.ownerId || entity.uuid !== projectile.ownerId,
1543
+ );
1544
+ if (!hit) {
1545
+ return null;
1546
+ }
1547
+ return {
1548
+ id: projectile.id,
1549
+ targetId: hit.entity.uuid,
1550
+ x: hit.point.x,
1551
+ y: hit.point.y,
1552
+ distance: hit.distance,
1553
+ };
1340
1554
  }
1341
1555
 
1342
1556
  private ensureCurrentPlayerBody(): boolean {
@@ -1686,6 +1900,7 @@ export class RpgClientEngine<T = any> {
1686
1900
  }
1687
1901
 
1688
1902
  private applyServerAck(ack: { frame: number; serverTick?: number; x?: number; y?: number; direction?: Direction }) {
1903
+ this.updateServerTickEstimate(ack.serverTick);
1689
1904
  if (this.predictionEnabled && this.prediction) {
1690
1905
  const result = this.prediction.applyServerAck({
1691
1906
  frame: ack.frame,
@@ -1847,6 +2062,13 @@ export class RpgClientEngine<T = any> {
1847
2062
  this.resizeHandler = undefined;
1848
2063
  }
1849
2064
 
2065
+ if (this.pointerMoveHandler && this.pointerCanvas) {
2066
+ this.pointerCanvas.removeEventListener('pointermove', this.pointerMoveHandler);
2067
+ this.pointerCanvas.removeEventListener('pointerdown', this.pointerMoveHandler);
2068
+ this.pointerMoveHandler = undefined;
2069
+ this.pointerCanvas = undefined;
2070
+ }
2071
+
1850
2072
  // Destroy PIXI app and renderer if they exist
1851
2073
  // Destroy the app first, which will destroy the renderer
1852
2074
  // Store renderer reference before destroying app (since app.destroy() will destroy the renderer)