@rpgjs/client 5.0.0-alpha.2 → 5.0.0-alpha.20

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 (163) hide show
  1. package/dist/Game/AnimationManager.d.ts +8 -0
  2. package/dist/Game/Map.d.ts +7 -1
  3. package/dist/Gui/Gui.d.ts +128 -5
  4. package/dist/RpgClient.d.ts +217 -59
  5. package/dist/RpgClientEngine.d.ts +345 -6
  6. package/dist/Sound.d.ts +199 -0
  7. package/dist/components/animations/index.d.ts +4 -0
  8. package/dist/components/dynamics/parse-value.d.ts +1 -0
  9. package/dist/components/gui/index.d.ts +3 -3
  10. package/dist/components/index.d.ts +3 -1
  11. package/dist/components/prebuilt/index.d.ts +18 -0
  12. package/dist/index.d.ts +4 -1
  13. package/dist/index.js +9 -4
  14. package/dist/index.js.map +1 -1
  15. package/dist/index10.js +149 -4
  16. package/dist/index10.js.map +1 -1
  17. package/dist/index11.js +21 -7
  18. package/dist/index11.js.map +1 -1
  19. package/dist/index12.js +6 -4
  20. package/dist/index12.js.map +1 -1
  21. package/dist/index13.js +11 -14
  22. package/dist/index13.js.map +1 -1
  23. package/dist/index14.js +8 -40
  24. package/dist/index14.js.map +1 -1
  25. package/dist/index15.js +187 -180
  26. package/dist/index15.js.map +1 -1
  27. package/dist/index16.js +104 -7
  28. package/dist/index16.js.map +1 -1
  29. package/dist/index17.js +82 -372
  30. package/dist/index17.js.map +1 -1
  31. package/dist/index18.js +361 -26
  32. package/dist/index18.js.map +1 -1
  33. package/dist/index19.js +46 -20
  34. package/dist/index19.js.map +1 -1
  35. package/dist/index2.js +683 -32
  36. package/dist/index2.js.map +1 -1
  37. package/dist/index20.js +5 -2417
  38. package/dist/index20.js.map +1 -1
  39. package/dist/index21.js +383 -97
  40. package/dist/index21.js.map +1 -1
  41. package/dist/index22.js +41 -104
  42. package/dist/index22.js.map +1 -1
  43. package/dist/index23.js +21 -67
  44. package/dist/index23.js.map +1 -1
  45. package/dist/index24.js +2632 -20
  46. package/dist/index24.js.map +1 -1
  47. package/dist/index25.js +107 -34
  48. package/dist/index25.js.map +1 -1
  49. package/dist/index26.js +69 -3
  50. package/dist/index26.js.map +1 -1
  51. package/dist/index27.js +17 -318
  52. package/dist/index27.js.map +1 -1
  53. package/dist/index28.js +24 -22
  54. package/dist/index28.js.map +1 -1
  55. package/dist/index29.js +92 -8
  56. package/dist/index29.js.map +1 -1
  57. package/dist/index3.js +68 -8
  58. package/dist/index3.js.map +1 -1
  59. package/dist/index30.js +37 -7
  60. package/dist/index30.js.map +1 -1
  61. package/dist/index31.js +18 -168
  62. package/dist/index31.js.map +1 -1
  63. package/dist/index32.js +3 -499
  64. package/dist/index32.js.map +1 -1
  65. package/dist/index33.js +332 -9
  66. package/dist/index33.js.map +1 -1
  67. package/dist/index34.js +24 -4400
  68. package/dist/index34.js.map +1 -1
  69. package/dist/index35.js +6 -311
  70. package/dist/index35.js.map +1 -1
  71. package/dist/index36.js +8 -88
  72. package/dist/index36.js.map +1 -1
  73. package/dist/index37.js +182 -56
  74. package/dist/index37.js.map +1 -1
  75. package/dist/index38.js +500 -16
  76. package/dist/index38.js.map +1 -1
  77. package/dist/index39.js +10 -18
  78. package/dist/index39.js.map +1 -1
  79. package/dist/index4.js +23 -5
  80. package/dist/index4.js.map +1 -1
  81. package/dist/index40.js +7 -0
  82. package/dist/index40.js.map +1 -0
  83. package/dist/index41.js +3690 -0
  84. package/dist/index41.js.map +1 -0
  85. package/dist/index42.js +77 -0
  86. package/dist/index42.js.map +1 -0
  87. package/dist/index43.js +6 -0
  88. package/dist/index43.js.map +1 -0
  89. package/dist/index44.js +20 -0
  90. package/dist/index44.js.map +1 -0
  91. package/dist/index45.js +146 -0
  92. package/dist/index45.js.map +1 -0
  93. package/dist/index46.js +12 -0
  94. package/dist/index46.js.map +1 -0
  95. package/dist/index47.js +113 -0
  96. package/dist/index47.js.map +1 -0
  97. package/dist/index48.js +136 -0
  98. package/dist/index48.js.map +1 -0
  99. package/dist/index49.js +137 -0
  100. package/dist/index49.js.map +1 -0
  101. package/dist/index5.js +2 -1
  102. package/dist/index5.js.map +1 -1
  103. package/dist/index50.js +112 -0
  104. package/dist/index50.js.map +1 -0
  105. package/dist/index51.js +141 -0
  106. package/dist/index51.js.map +1 -0
  107. package/dist/index52.js +9 -0
  108. package/dist/index52.js.map +1 -0
  109. package/dist/index53.js +54 -0
  110. package/dist/index53.js.map +1 -0
  111. package/dist/index6.js +1 -1
  112. package/dist/index6.js.map +1 -1
  113. package/dist/index7.js +11 -3
  114. package/dist/index7.js.map +1 -1
  115. package/dist/index8.js +68 -7
  116. package/dist/index8.js.map +1 -1
  117. package/dist/index9.js +230 -15
  118. package/dist/index9.js.map +1 -1
  119. package/dist/presets/animation.d.ts +31 -0
  120. package/dist/presets/faceset.d.ts +30 -0
  121. package/dist/presets/index.d.ts +103 -0
  122. package/dist/presets/lpc.d.ts +89 -0
  123. package/dist/services/loadMap.d.ts +123 -2
  124. package/dist/services/mmorpg.d.ts +9 -4
  125. package/dist/services/standalone.d.ts +51 -2
  126. package/package.json +22 -18
  127. package/src/Game/{EffectManager.ts → AnimationManager.ts} +3 -2
  128. package/src/Game/Map.ts +20 -2
  129. package/src/Game/Object.ts +163 -9
  130. package/src/Gui/Gui.ts +300 -17
  131. package/src/RpgClient.ts +222 -58
  132. package/src/RpgClientEngine.ts +804 -36
  133. package/src/Sound.ts +253 -0
  134. package/src/components/{effects → animations}/animation.ce +3 -6
  135. package/src/components/{effects → animations}/index.ts +1 -1
  136. package/src/components/character.ce +165 -37
  137. package/src/components/dynamics/parse-value.ts +80 -0
  138. package/src/components/dynamics/text.ce +183 -0
  139. package/src/components/gui/box.ce +17 -0
  140. package/src/components/gui/dialogbox/index.ce +73 -35
  141. package/src/components/gui/dialogbox/selection.ce +16 -1
  142. package/src/components/gui/index.ts +3 -4
  143. package/src/components/index.ts +5 -1
  144. package/src/components/prebuilt/hp-bar.ce +255 -0
  145. package/src/components/prebuilt/index.ts +21 -0
  146. package/src/components/scenes/draw-map.ce +6 -23
  147. package/src/components/scenes/event-layer.ce +9 -3
  148. package/src/core/setup.ts +2 -0
  149. package/src/index.ts +5 -2
  150. package/src/module.ts +72 -6
  151. package/src/presets/animation.ts +46 -0
  152. package/src/presets/faceset.ts +60 -0
  153. package/src/presets/index.ts +7 -1
  154. package/src/presets/lpc.ts +108 -0
  155. package/src/services/loadMap.ts +132 -3
  156. package/src/services/mmorpg.ts +27 -5
  157. package/src/services/standalone.ts +68 -6
  158. package/tsconfig.json +1 -1
  159. package/vite.config.ts +1 -1
  160. package/dist/Game/EffectManager.d.ts +0 -5
  161. package/dist/components/effects/index.d.ts +0 -4
  162. package/src/components/scenes/element-map.ce +0 -23
  163. /package/src/components/{effects → animations}/hit.ce +0 -0
@@ -1,38 +1,63 @@
1
1
  import Canvas from "./components/scenes/canvas.ce";
2
2
  import { Context, inject } from "@signe/di";
3
- import { signal, bootstrapCanvas } from "canvasengine";
3
+ import { signal, bootstrapCanvas, Howler, Howl } from "canvasengine";
4
4
  import { AbstractWebsocket, WebSocketToken } from "./services/AbstractSocket";
5
5
  import { LoadMapService, LoadMapToken } from "./services/loadMap";
6
- import { Hooks, ModulesToken } from "@rpgjs/common";
6
+ import { RpgSound } from "./Sound";
7
+ import { Hooks, ModulesToken, Direction } from "@rpgjs/common";
8
+
9
+ type DirectionValue = "up" | "down" | "left" | "right";
7
10
  import { load } from "@signe/sync";
8
11
  import { RpgClientMap } from "./Game/Map"
9
12
  import { RpgGui } from "./Gui/Gui";
10
- import { EffectManager } from "./Game/EffectManager";
11
- import { lastValueFrom } from "rxjs";
13
+ import { AnimationManager } from "./Game/AnimationManager";
14
+ import { lastValueFrom, Observable } from "rxjs";
12
15
  import { GlobalConfigToken } from "./module";
13
- import { ClientIo } from "@signe/room";
16
+ import * as PIXI from "pixi.js";
17
+ import { PrebuiltComponentAnimations } from "./components/animations";
18
+ import {
19
+ PredictionController,
20
+ type PredictionState,
21
+ } from "@rpgjs/common";
14
22
 
15
23
  export class RpgClientEngine<T = any> {
16
24
  private guiService: RpgGui;
17
25
  private webSocket: AbstractWebsocket;
18
26
  private loadMapService: LoadMapService;
19
27
  private hooks: Hooks;
20
- private sceneMap: RpgClientMap = new RpgClientMap();
28
+ private sceneMap: RpgClientMap
21
29
  private selector: HTMLElement;
22
30
  public globalConfig: T;
23
31
  public sceneComponent: any;
24
32
  stopProcessingInput = false;
25
-
26
33
  width = signal("100%");
27
34
  height = signal("100%");
28
35
  spritesheets: Map<string, any> = new Map();
29
36
  sounds: Map<string, any> = new Map();
30
- effects: any[] = [];
37
+ componentAnimations: any[] = [];
38
+ private spritesheetResolver?: (id: string) => any | Promise<any>;
39
+ private soundResolver?: (id: string) => any | Promise<any>;
31
40
  particleSettings: {
32
41
  emitters: any[]
33
42
  } = {
34
- emitters: []
35
- }
43
+ emitters: []
44
+ }
45
+ renderer: PIXI.Renderer;
46
+ tick: Observable<number>;
47
+ playerIdSignal = signal<string | null>(null);
48
+ spriteComponentsBehind = signal<any[]>([]);
49
+ spriteComponentsInFront = signal<any[]>([]);
50
+
51
+ private predictionEnabled = false;
52
+ private prediction?: PredictionController<Direction>;
53
+ private readonly SERVER_CORRECTION_THRESHOLD = 30;
54
+ private inputFrameCounter = 0;
55
+ private frameOffset = 0;
56
+ // Ping/Pong for RTT measurement
57
+ private rtt: number = 0; // Round-trip time in ms
58
+ private pingInterval: any = null;
59
+ private readonly PING_INTERVAL_MS = 5000; // Send ping every 5 seconds
60
+ private lastInputTime = 0;
36
61
 
37
62
  constructor(public context: Context) {
38
63
  this.webSocket = inject(context, WebSocketToken);
@@ -40,22 +65,75 @@ export class RpgClientEngine<T = any> {
40
65
  this.loadMapService = inject(context, LoadMapToken);
41
66
  this.hooks = inject<Hooks>(context, ModulesToken);
42
67
  this.globalConfig = inject(context, GlobalConfigToken)
68
+
69
+ if (!this.globalConfig) {
70
+ this.globalConfig = {} as T
71
+ }
72
+ if (!(this.globalConfig as any).box) {
73
+ (this.globalConfig as any).box = {
74
+ styles: {
75
+ backgroundColor: "#1a1a2e",
76
+ backgroundOpacity: 0.9
77
+ },
78
+ sounds: {}
79
+ }
80
+ }
81
+
82
+ this.addComponentAnimation({
83
+ id: "animation",
84
+ component: PrebuiltComponentAnimations.Animation
85
+ })
86
+
87
+ this.predictionEnabled = (this.globalConfig as any)?.prediction?.enabled !== false;
88
+ this.initializePredictionController();
43
89
  }
44
90
 
45
91
  async start() {
92
+ this.sceneMap = new RpgClientMap()
46
93
  this.selector = document.body.querySelector("#rpg") as HTMLElement;
47
94
 
48
- await bootstrapCanvas(this.selector, Canvas);
95
+ const { app, canvasElement } = await bootstrapCanvas(this.selector, Canvas);
96
+ this.renderer = app.renderer as PIXI.Renderer;
97
+ this.tick = canvasElement?.propObservables?.context['tick'].observable
98
+
99
+ this.tick.subscribe(() => {
100
+ if (Date.now() - this.lastInputTime > 100) {
101
+ const player = this.getCurrentPlayer();
102
+ if (!player) return;
103
+ (this.sceneMap as any).stopMovement(player);
104
+ }
105
+ })
49
106
 
50
- await lastValueFrom(this.hooks.callHooks("client-engine-onStart", this));
51
107
 
52
108
  this.hooks.callHooks("client-spritesheets-load", this).subscribe();
109
+ this.hooks.callHooks("client-spritesheetResolver-load", this).subscribe();
53
110
  this.hooks.callHooks("client-sounds-load", this).subscribe();
111
+ this.hooks.callHooks("client-soundResolver-load", this).subscribe();
112
+
113
+ RpgSound.init(this);
54
114
  this.hooks.callHooks("client-gui-load", this).subscribe();
55
115
  this.hooks.callHooks("client-particles-load", this).subscribe();
56
- this.hooks.callHooks("client-effects-load", this).subscribe();
116
+ this.hooks.callHooks("client-componentAnimations-load", this).subscribe();
117
+ this.hooks.callHooks("client-sprite-load", this).subscribe();
118
+
119
+ await lastValueFrom(this.hooks.callHooks("client-engine-onStart", this));
120
+
121
+ // wondow is resize
122
+ window.addEventListener('resize', () => {
123
+ this.hooks.callHooks("client-engine-onWindowResize", this).subscribe();
124
+ })
125
+
126
+ this.tick.subscribe((tick) => {
127
+ this.hooks.callHooks("client-engine-onStep", this, tick).subscribe();
128
+
129
+ // Clean up old prediction states and input history every 60 ticks (approximately every second at 60fps)
130
+ if (tick % 60 === 0) {
131
+ const now = Date.now();
132
+ this.prediction?.cleanup(now);
133
+ this.prediction?.tryApplyPendingSnapshot();
134
+ }
135
+ })
57
136
 
58
-
59
137
  await this.webSocket.connection(() => {
60
138
  this.initListeners()
61
139
  this.guiService._initialize()
@@ -64,24 +142,156 @@ export class RpgClientEngine<T = any> {
64
142
 
65
143
  private initListeners() {
66
144
  this.webSocket.on("sync", (data) => {
145
+ if (data.pId) this.playerIdSignal.set(data.pId)
146
+
147
+ // Apply client-side prediction filtering and server reconciliation
148
+ this.hooks.callHooks("client-sceneMap-onChanges", this.sceneMap, { partial: data }).subscribe();
149
+
67
150
  load(this.sceneMap, data, true);
68
151
  });
69
152
 
153
+ // Handle pong responses for RTT measurement
154
+ this.webSocket.on("pong", (data: { serverTick: number; clientFrame: number; clientTime: number }) => {
155
+ const now = Date.now();
156
+ this.rtt = now - data.clientTime;
157
+
158
+ // Calculate frame offset: how many ticks ahead the server is compared to our frame counter
159
+ // This helps us estimate which server tick corresponds to each client input frame
160
+ const estimatedTicksInFlight = Math.floor(this.rtt / 2 / (1000 / 60)); // Estimate ticks during half RTT
161
+ const estimatedServerTickNow = data.serverTick + estimatedTicksInFlight;
162
+
163
+ // Update frame offset (only if we have inputs to calibrate with)
164
+ if (this.inputFrameCounter > 0) {
165
+ this.frameOffset = estimatedServerTickNow - data.clientFrame;
166
+ }
167
+
168
+ console.debug(`[Ping/Pong] RTT: ${this.rtt}ms, ServerTick: ${data.serverTick}, FrameOffset: ${this.frameOffset}`);
169
+ });
170
+
70
171
  this.webSocket.on("changeMap", (data) => {
172
+ this.sceneMap.reset()
71
173
  this.loadScene(data.mapId);
72
174
  });
73
175
 
74
- this.webSocket.on("showEffect", (data) => {
75
- const { params, object, id } = data;
76
- if (!object) {
77
- throw new Error("Object not found");
176
+ this.webSocket.on("showComponentAnimation", (data) => {
177
+ const { params, object, position, id } = data;
178
+ if (!object && position === undefined) {
179
+ throw new Error("Please provide an object or x and y coordinates");
78
180
  }
181
+ const player = object ? this.sceneMap.getObjectById(object) : undefined;
182
+ this.getComponentAnimation(id).displayEffect(params, player || position)
183
+ });
184
+
185
+ this.webSocket.on("setAnimation", (data) => {
186
+ const { animationName, nbTimes, object } = data;
79
187
  const player = this.sceneMap.getObjectById(object);
80
- this.getEffect(id).displayEffect(params, player)
188
+ player.setAnimation(animationName, nbTimes)
189
+ })
190
+
191
+ this.webSocket.on("playSound", (data) => {
192
+ const { soundId, volume, loop } = data;
193
+ this.playSound(soundId, { volume, loop });
194
+ });
195
+
196
+ this.webSocket.on("stopSound", (data) => {
197
+ const { soundId } = data;
198
+ this.stopSound(soundId);
81
199
  });
200
+
201
+ this.webSocket.on('open', () => {
202
+ this.hooks.callHooks("client-engine-onConnected", this, this.socket).subscribe();
203
+ // Start ping/pong for synchronization
204
+ })
205
+
206
+ this.webSocket.on('close', () => {
207
+ this.hooks.callHooks("client-engine-onDisconnected", this, this.socket).subscribe();
208
+ // Stop ping/pong when disconnected
209
+ this.stopPingPong();
210
+ })
211
+
212
+ this.webSocket.on('error', (error) => {
213
+ this.hooks.callHooks("client-engine-onConnectError", this, error, this.socket).subscribe();
214
+ })
82
215
  }
83
-
216
+
217
+ /**
218
+ * Start periodic ping/pong for client-server synchronization
219
+ *
220
+ * Sends ping requests to the server to measure round-trip time (RTT) and
221
+ * calculate the frame offset between client and server ticks.
222
+ *
223
+ * ## Design
224
+ *
225
+ * - Sends ping every 5 seconds
226
+ * - Measures RTT for latency compensation
227
+ * - Calculates frame offset to map client frames to server ticks
228
+ * - Used for accurate server reconciliation
229
+ *
230
+ * @example
231
+ * ```ts
232
+ * // Called automatically when connection opens
233
+ * this.startPingPong();
234
+ * ```
235
+ */
236
+ private startPingPong(): void {
237
+ // Stop existing interval if any
238
+ this.stopPingPong();
239
+
240
+ // Send initial ping immediately
241
+ this.sendPing();
242
+
243
+ // Set up periodic pings
244
+ this.pingInterval = setInterval(() => {
245
+ this.sendPing();
246
+ }, this.PING_INTERVAL_MS);
247
+ }
248
+
249
+ /**
250
+ * Stop periodic ping/pong
251
+ *
252
+ * Stops the ping interval when disconnecting or changing maps.
253
+ *
254
+ * @example
255
+ * ```ts
256
+ * // Called automatically when connection closes
257
+ * this.stopPingPong();
258
+ * ```
259
+ */
260
+ private stopPingPong(): void {
261
+ if (this.pingInterval) {
262
+ clearInterval(this.pingInterval);
263
+ this.pingInterval = null;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Send a ping request to the server
269
+ *
270
+ * Sends current client time and frame counter to the server,
271
+ * which will respond with its server tick for synchronization.
272
+ *
273
+ * @example
274
+ * ```ts
275
+ * // Send a ping to measure RTT
276
+ * this.sendPing();
277
+ * ```
278
+ */
279
+ private sendPing(): void {
280
+ const clientTime = Date.now();
281
+ const clientFrame = this.getPhysicsTick();
282
+
283
+ this.webSocket.emit('ping', {
284
+ clientTime,
285
+ clientFrame
286
+ });
287
+ }
288
+
84
289
  private async loadScene(mapId: string) {
290
+ this.hooks.callHooks("client-sceneMap-onBeforeLoading", this.sceneMap).subscribe();
291
+
292
+ // Clear client prediction states when changing maps
293
+ this.clearClientPredictionStates();
294
+
85
295
  this.webSocket.updateProperties({ room: mapId })
86
296
  await this.webSocket.reconnect(() => {
87
297
  this.initListeners()
@@ -90,7 +300,7 @@ export class RpgClientEngine<T = any> {
90
300
  const res = await this.loadMapService.load(mapId)
91
301
  this.sceneMap.data.set(res)
92
302
  this.hooks.callHooks("client-sceneMap-onAfterLoading", this.sceneMap).subscribe();
93
- //this.sceneMap.loadPhysic()
303
+ this.sceneMap.loadPhysic()
94
304
  }
95
305
 
96
306
  addSpriteSheet<T = any>(spritesheetClass: any, id?: string): any {
@@ -98,44 +308,602 @@ export class RpgClientEngine<T = any> {
98
308
  return spritesheetClass as any;
99
309
  }
100
310
 
101
- addSound(sound: any, id?: string) {
102
- this.sounds.set(id || sound.id, sound);
311
+ /**
312
+ * Set a resolver function for spritesheets
313
+ *
314
+ * The resolver is called when a spritesheet is requested but not found in the cache.
315
+ * It can be synchronous (returns directly) or asynchronous (returns a Promise).
316
+ * The resolved spritesheet is automatically cached for future use.
317
+ *
318
+ * @param resolver - Function that takes a spritesheet ID and returns a spritesheet or Promise of spritesheet
319
+ *
320
+ * @example
321
+ * ```ts
322
+ * // Synchronous resolver
323
+ * engine.setSpritesheetResolver((id) => {
324
+ * if (id === 'dynamic-sprite') {
325
+ * return { id: 'dynamic-sprite', image: 'path/to/image.png', framesWidth: 32, framesHeight: 32 };
326
+ * }
327
+ * return undefined;
328
+ * });
329
+ *
330
+ * // Asynchronous resolver (loading from API)
331
+ * engine.setSpritesheetResolver(async (id) => {
332
+ * const response = await fetch(`/api/spritesheets/${id}`);
333
+ * const data = await response.json();
334
+ * return data;
335
+ * });
336
+ * ```
337
+ */
338
+ setSpritesheetResolver(resolver: (id: string) => any | Promise<any>): void {
339
+ this.spritesheetResolver = resolver;
340
+ }
341
+
342
+ /**
343
+ * Get a spritesheet by ID, using resolver if not found in cache
344
+ *
345
+ * This method first checks if the spritesheet exists in the cache.
346
+ * If not found and a resolver is set, it calls the resolver to create the spritesheet.
347
+ * The resolved spritesheet is automatically cached for future use.
348
+ *
349
+ * @param id - The spritesheet ID to retrieve
350
+ * @returns The spritesheet if found or created, or undefined if not found and no resolver
351
+ * @returns Promise<any> if the resolver is asynchronous
352
+ *
353
+ * @example
354
+ * ```ts
355
+ * // Synchronous usage
356
+ * const spritesheet = engine.getSpriteSheet('my-sprite');
357
+ *
358
+ * // Asynchronous usage (when resolver returns Promise)
359
+ * const spritesheet = await engine.getSpriteSheet('dynamic-sprite');
360
+ * ```
361
+ */
362
+ getSpriteSheet(id: string): any | Promise<any> {
363
+ // Check cache first
364
+ if (this.spritesheets.has(id)) {
365
+ return this.spritesheets.get(id);
366
+ }
367
+
368
+ // If not in cache and resolver exists, use it
369
+ if (this.spritesheetResolver) {
370
+ const result = this.spritesheetResolver(id);
371
+
372
+ // Check if result is a Promise
373
+ if (result instanceof Promise) {
374
+ return result.then((spritesheet) => {
375
+ if (spritesheet) {
376
+ // Cache the resolved spritesheet
377
+ this.spritesheets.set(id, spritesheet);
378
+ }
379
+ return spritesheet;
380
+ });
381
+ } else {
382
+ // Synchronous result
383
+ if (result) {
384
+ // Cache the resolved spritesheet
385
+ this.spritesheets.set(id, result);
386
+ }
387
+ return result;
388
+ }
389
+ }
390
+
391
+ // No resolver and not in cache
392
+ return undefined;
393
+ }
394
+
395
+ /**
396
+ * Add a sound to the engine
397
+ *
398
+ * Adds a sound to the engine's sound cache. The sound can be:
399
+ * - A simple object with `id` and `src` properties
400
+ * - A Howler instance
401
+ * - An object with a `play()` method
402
+ *
403
+ * If the sound has a `src` property, a Howler instance will be created automatically.
404
+ *
405
+ * @param sound - The sound object or Howler instance
406
+ * @param id - Optional sound ID (if not provided, uses sound.id)
407
+ * @returns The added sound
408
+ *
409
+ * @example
410
+ * ```ts
411
+ * // Simple sound object
412
+ * engine.addSound({ id: 'click', src: 'click.mp3' });
413
+ *
414
+ * // With explicit ID
415
+ * engine.addSound({ src: 'music.mp3' }, 'background-music');
416
+ * ```
417
+ */
418
+ addSound(sound: any, id?: string): any {
419
+ const soundId = id || sound.id;
420
+
421
+ if (!soundId) {
422
+ console.warn('Sound added without an ID. It will not be retrievable.');
423
+ return sound;
424
+ }
425
+
426
+ // If sound has a src property, create a Howler instance
427
+ if (sound.src && typeof sound.src === 'string') {
428
+ const howlOptions: any = {
429
+ src: [sound.src],
430
+ loop: sound.loop || false,
431
+ volume: sound.volume !== undefined ? sound.volume : 1.0,
432
+ };
433
+
434
+ const howl = new (Howl as any).Howl(howlOptions);
435
+ this.sounds.set(soundId, howl);
436
+ return howl;
437
+ }
438
+
439
+ // If sound already has a play method (Howler instance or custom), use it directly
440
+ if (sound && typeof sound.play === 'function') {
441
+ this.sounds.set(soundId, sound);
442
+ return sound;
443
+ }
444
+
445
+ // Otherwise, store as-is
446
+ this.sounds.set(soundId, sound);
103
447
  return sound;
104
448
  }
105
449
 
450
+ /**
451
+ * Set a resolver function for sounds
452
+ *
453
+ * The resolver is called when a sound is requested but not found in the cache.
454
+ * It can be synchronous (returns directly) or asynchronous (returns a Promise).
455
+ * The resolved sound is automatically cached for future use.
456
+ *
457
+ * @param resolver - Function that takes a sound ID and returns a sound or Promise of sound
458
+ *
459
+ * @example
460
+ * ```ts
461
+ * // Synchronous resolver
462
+ * engine.setSoundResolver((id) => {
463
+ * if (id === 'dynamic-sound') {
464
+ * return { id: 'dynamic-sound', src: 'path/to/sound.mp3' };
465
+ * }
466
+ * return undefined;
467
+ * });
468
+ *
469
+ * // Asynchronous resolver (loading from API)
470
+ * engine.setSoundResolver(async (id) => {
471
+ * const response = await fetch(`/api/sounds/${id}`);
472
+ * const data = await response.json();
473
+ * return data;
474
+ * });
475
+ * ```
476
+ */
477
+ setSoundResolver(resolver: (id: string) => any | Promise<any>): void {
478
+ this.soundResolver = resolver;
479
+ }
480
+
481
+ /**
482
+ * Get a sound by ID, using resolver if not found in cache
483
+ *
484
+ * This method first checks if the sound exists in the cache.
485
+ * If not found and a resolver is set, it calls the resolver to create the sound.
486
+ * The resolved sound is automatically cached for future use.
487
+ *
488
+ * @param id - The sound ID to retrieve
489
+ * @returns The sound if found or created, or undefined if not found and no resolver
490
+ * @returns Promise<any> if the resolver is asynchronous
491
+ *
492
+ * @example
493
+ * ```ts
494
+ * // Synchronous usage
495
+ * const sound = engine.getSound('my-sound');
496
+ *
497
+ * // Asynchronous usage (when resolver returns Promise)
498
+ * const sound = await engine.getSound('dynamic-sound');
499
+ * ```
500
+ */
501
+ getSound(id: string): any | Promise<any> {
502
+ // Check cache first
503
+ if (this.sounds.has(id)) {
504
+ return this.sounds.get(id);
505
+ }
506
+
507
+ // If not in cache and resolver exists, use it
508
+ if (this.soundResolver) {
509
+ const result = this.soundResolver(id);
510
+
511
+ // Check if result is a Promise
512
+ if (result instanceof Promise) {
513
+ return result.then((sound) => {
514
+ if (sound) {
515
+ // Cache the resolved sound
516
+ this.sounds.set(id, sound);
517
+ }
518
+ return sound;
519
+ });
520
+ } else {
521
+ // Synchronous result
522
+ if (result) {
523
+ // Cache the resolved sound
524
+ this.sounds.set(id, result);
525
+ }
526
+ return result;
527
+ }
528
+ }
529
+
530
+ // No resolver and not in cache
531
+ return undefined;
532
+ }
533
+
534
+ /**
535
+ * Play a sound by its ID
536
+ *
537
+ * This method retrieves a sound from the cache or resolver and plays it.
538
+ * If the sound is not found, it will attempt to resolve it using the soundResolver.
539
+ * Uses Howler.js for audio playback instead of native Audio elements.
540
+ *
541
+ * @param soundId - The sound ID to play
542
+ * @param options - Optional sound configuration
543
+ * @param options.volume - Volume level (0.0 to 1.0, overrides sound default)
544
+ * @param options.loop - Whether the sound should loop (overrides sound default)
545
+ *
546
+ * @example
547
+ * ```ts
548
+ * // Play a sound synchronously
549
+ * engine.playSound('item-pickup');
550
+ *
551
+ * // Play a sound with volume and loop
552
+ * engine.playSound('background-music', { volume: 0.5, loop: true });
553
+ *
554
+ * // Play a sound asynchronously (when resolver returns Promise)
555
+ * await engine.playSound('dynamic-sound', { volume: 0.8 });
556
+ * ```
557
+ */
558
+ async playSound(soundId: string, options?: { volume?: number; loop?: boolean }): Promise<void> {
559
+ const sound = await this.getSound(soundId);
560
+ if (sound && sound.play) {
561
+ // Sound is already a Howler instance or has a play method
562
+ const howlSoundId = sound._sounds?.[0]?._id;
563
+
564
+ // Apply volume if provided
565
+ if (options?.volume !== undefined) {
566
+ if (howlSoundId !== undefined) {
567
+ sound.volume(Math.max(0, Math.min(1, options.volume)), howlSoundId);
568
+ } else {
569
+ sound.volume(Math.max(0, Math.min(1, options.volume)));
570
+ }
571
+ }
572
+
573
+ // Apply loop if provided
574
+ if (options?.loop !== undefined) {
575
+ if (howlSoundId !== undefined) {
576
+ sound.loop(options.loop, howlSoundId);
577
+ } else {
578
+ sound.loop(options.loop);
579
+ }
580
+ }
581
+
582
+ if (howlSoundId !== undefined) {
583
+ sound.play(howlSoundId);
584
+ } else {
585
+ sound.play();
586
+ }
587
+ } else if (sound && sound.src) {
588
+ // If sound is just a source URL, create a Howler instance and cache it
589
+ const howlOptions: any = {
590
+ src: [sound.src],
591
+ loop: options?.loop !== undefined ? options.loop : (sound.loop || false),
592
+ volume: options?.volume !== undefined ? Math.max(0, Math.min(1, options.volume)) : (sound.volume !== undefined ? sound.volume : 1.0),
593
+ };
594
+
595
+ const howl = new (Howl as any).Howl(howlOptions);
596
+
597
+ // Cache the Howler instance for future use
598
+ this.sounds.set(soundId, howl);
599
+
600
+ // Play the sound
601
+ howl.play();
602
+ } else {
603
+ console.warn(`Sound with id "${soundId}" not found or cannot be played`);
604
+ }
605
+ }
606
+
607
+ /**
608
+ * Stop a sound that is currently playing
609
+ *
610
+ * This method stops a sound that was previously started with `playSound()`.
611
+ *
612
+ * @param soundId - The sound ID to stop
613
+ *
614
+ * @example
615
+ * ```ts
616
+ * // Start a looping sound
617
+ * engine.playSound('background-music', { loop: true });
618
+ *
619
+ * // Later, stop it
620
+ * engine.stopSound('background-music');
621
+ * ```
622
+ */
623
+ stopSound(soundId: string): void {
624
+ const sound = this.sounds.get(soundId);
625
+ if (sound && sound.stop) {
626
+ sound.stop();
627
+ } else {
628
+ console.warn(`Sound with id "${soundId}" not found or cannot be stopped`);
629
+ }
630
+ }
631
+
106
632
  addParticle(particle: any) {
107
633
  this.particleSettings.emitters.push(particle)
108
634
  return particle;
109
635
  }
110
636
 
111
- addEffect(effect: {
637
+ /**
638
+ * Add a component to render behind sprites
639
+ * Components added with this method will be displayed with a lower z-index than the sprite
640
+ *
641
+ * @param component - The component to add behind sprites
642
+ * @returns The added component
643
+ *
644
+ * @example
645
+ * ```ts
646
+ * // Add a shadow component behind all sprites
647
+ * engine.addSpriteComponentBehind(ShadowComponent);
648
+ * ```
649
+ */
650
+ addSpriteComponentBehind(component: any) {
651
+ this.spriteComponentsBehind.update((components: any[]) => [...components, component])
652
+ return component
653
+ }
654
+
655
+ /**
656
+ * Add a component to render in front of sprites
657
+ * Components added with this method will be displayed with a higher z-index than the sprite
658
+ *
659
+ * @param component - The component to add in front of sprites
660
+ * @returns The added component
661
+ *
662
+ * @example
663
+ * ```ts
664
+ * // Add a health bar component in front of all sprites
665
+ * engine.addSpriteComponentInFront(HealthBarComponent);
666
+ * ```
667
+ */
668
+ addSpriteComponentInFront(component: any) {
669
+ this.spriteComponentsInFront.update((components: any[]) => [...components, component])
670
+ return component
671
+ }
672
+
673
+ /**
674
+ * Add a component animation to the engine
675
+ *
676
+ * Component animations are temporary visual effects that can be displayed
677
+ * on sprites or objects, such as hit indicators, spell effects, or status animations.
678
+ *
679
+ * @param componentAnimation - The component animation configuration
680
+ * @param componentAnimation.id - Unique identifier for the animation
681
+ * @param componentAnimation.component - The component function to render
682
+ * @returns The added component animation configuration
683
+ *
684
+ * @example
685
+ * ```ts
686
+ * // Add a hit animation component
687
+ * engine.addComponentAnimation({
688
+ * id: 'hit',
689
+ * component: HitComponent
690
+ * });
691
+ *
692
+ * // Add an explosion effect component
693
+ * engine.addComponentAnimation({
694
+ * id: 'explosion',
695
+ * component: ExplosionComponent
696
+ * });
697
+ * ```
698
+ */
699
+ addComponentAnimation(componentAnimation: {
112
700
  component: any,
113
701
  id: string
114
702
  }) {
115
- const instance = new EffectManager()
116
- this.effects.push({
117
- id: effect.id,
118
- component: effect.component,
703
+ const instance = new AnimationManager()
704
+ this.componentAnimations.push({
705
+ id: componentAnimation.id,
706
+ component: componentAnimation.component,
119
707
  instance: instance,
120
708
  current: instance.current
121
709
  })
122
- return effect;
710
+ return componentAnimation;
123
711
  }
124
712
 
125
- getEffect(id: string): EffectManager {
126
- const effect = this.effects.find((effect) => effect.id === id)
127
- if (!effect) {
128
- throw new Error(`Effect with id ${id} not found`)
713
+ /**
714
+ * Get a component animation by its ID
715
+ *
716
+ * Retrieves the EffectManager instance for a specific component animation,
717
+ * which can be used to display the animation on sprites or objects.
718
+ *
719
+ * @param id - The unique identifier of the component animation
720
+ * @returns The EffectManager instance for the animation
721
+ * @throws Error if the component animation is not found
722
+ *
723
+ * @example
724
+ * ```ts
725
+ * // Get the hit animation and display it
726
+ * const hitAnimation = engine.getComponentAnimation('hit');
727
+ * hitAnimation.displayEffect({ text: "Critical!" }, player);
728
+ * ```
729
+ */
730
+ getComponentAnimation(id: string): AnimationManager {
731
+ const componentAnimation = this.componentAnimations.find((componentAnimation) => componentAnimation.id === id)
732
+ if (!componentAnimation) {
733
+ throw new Error(`Component animation with id ${id} not found`)
129
734
  }
130
- return effect.instance
735
+ return componentAnimation.instance
131
736
  }
132
737
 
133
- processInput({ input }: { input: number }) {
134
- this.webSocket.emit('move', { input })
738
+ async processInput({ input }: { input: Direction }) {
739
+ const timestamp = Date.now();
740
+ let frame: number;
741
+ let tick: number;
742
+ if (this.predictionEnabled && this.prediction) {
743
+ const meta = this.prediction.recordInput(input, timestamp);
744
+ frame = meta.frame;
745
+ tick = meta.tick;
746
+ } else {
747
+ frame = ++this.inputFrameCounter;
748
+ tick = this.getPhysicsTick();
749
+ }
750
+ this.hooks.callHooks("client-engine-onInput", this, { input, playerId: this.playerId }).subscribe();
751
+
752
+ this.webSocket.emit('move', {
753
+ input,
754
+ timestamp,
755
+ frame,
756
+ tick,
757
+ });
758
+
759
+ const currentPlayer = this.sceneMap.getCurrentPlayer();
760
+ if (currentPlayer) {
761
+ (this.sceneMap as any).moveBody(currentPlayer, input);
762
+ }
763
+ this.lastInputTime = Date.now();
764
+ const myId = this.playerIdSignal();
765
+
135
766
  }
136
767
 
137
768
  processAction({ action }: { action: number }) {
138
769
  if (this.stopProcessingInput) return;
770
+ this.hooks.callHooks("client-engine-onInput", this, { input: 'action', playerId: this.playerId }).subscribe();
139
771
  this.webSocket.emit('action', { action })
140
772
  }
773
+
774
+ get PIXI() {
775
+ return PIXI
776
+ }
777
+
778
+ get socket() {
779
+ return this.webSocket
780
+ }
781
+
782
+ get playerId() {
783
+ return this.playerIdSignal()
784
+ }
785
+
786
+ get scene() {
787
+ return this.sceneMap
788
+ }
789
+
790
+ private getPhysicsTick(): number {
791
+ return this.sceneMap?.getTick?.() ?? 0;
792
+ }
793
+
794
+ private getLocalPlayerState(): PredictionState<Direction> {
795
+ const currentPlayer = this.sceneMap?.getCurrentPlayer();
796
+ if (!currentPlayer) {
797
+ return { x: 0, y: 0, direction: Direction.Down };
798
+ }
799
+ const topLeft = this.sceneMap.getBodyPosition(currentPlayer.id, "top-left");
800
+ const x = topLeft?.x ?? currentPlayer.x();
801
+ const y = topLeft?.y ?? currentPlayer.y();
802
+ const direction = currentPlayer.direction();
803
+ return { x, y, direction };
804
+ }
805
+
806
+ private applyAuthoritativeState(state: PredictionState<Direction>): void {
807
+ const player = this.sceneMap?.getCurrentPlayer();
808
+ if (!player) return;
809
+ const hitbox = typeof player.hitbox === "function" ? player.hitbox() : player.hitbox;
810
+ const width = hitbox?.w ?? 0;
811
+ const height = hitbox?.h ?? 0;
812
+ const updated = this.sceneMap.updateHitbox(player.id, state.x, state.y, width, height);
813
+ if (!updated) {
814
+ this.sceneMap.setBodyPosition(player.id, state.x, state.y, "top-left");
815
+ }
816
+ player.x.set(Math.round(state.x));
817
+ player.y.set(Math.round(state.y));
818
+ if (state.direction) {
819
+ player.changeDirection(state.direction);
820
+ }
821
+ }
822
+
823
+ private initializePredictionController(): void {
824
+ if (!this.predictionEnabled) {
825
+ this.prediction = undefined;
826
+ return;
827
+ }
828
+ this.prediction = new PredictionController<Direction>({
829
+ correctionThreshold: (this.globalConfig as any)?.prediction?.correctionThreshold ?? this.SERVER_CORRECTION_THRESHOLD,
830
+ historyTtlMs: (this.globalConfig as any)?.prediction?.historyTtlMs ?? 2000,
831
+ getPhysicsTick: () => this.getPhysicsTick(),
832
+ getCurrentState: () => this.getLocalPlayerState(),
833
+ setAuthoritativeState: (state) => this.applyAuthoritativeState(state),
834
+ });
835
+ }
836
+
837
+ getCurrentPlayer() {
838
+ return this.sceneMap.getCurrentPlayer()
839
+ }
840
+
841
+ /**
842
+ * Clear client prediction states for cleanup
843
+ *
844
+ * Removes old prediction states and input history to prevent memory leaks.
845
+ * Should be called when changing maps or disconnecting.
846
+ *
847
+ * @example
848
+ * ```ts
849
+ * // Clear prediction states when changing maps
850
+ * engine.clearClientPredictionStates();
851
+ * ```
852
+ */
853
+ clearClientPredictionStates() {
854
+ this.initializePredictionController();
855
+ this.frameOffset = 0;
856
+ this.inputFrameCounter = 0;
857
+ }
858
+
859
+ private applyServerAck(ack: { frame: number; serverTick?: number; x?: number; y?: number; direction?: Direction }) {
860
+ if (this.predictionEnabled && this.prediction) {
861
+ this.prediction.applyServerAck({
862
+ frame: ack.frame,
863
+ serverTick: ack.serverTick,
864
+ state:
865
+ typeof ack.x === "number" && typeof ack.y === "number"
866
+ ? { x: ack.x, y: ack.y, direction: ack.direction }
867
+ : undefined,
868
+ });
869
+ return;
870
+ }
871
+
872
+ if (typeof ack.x !== "number" || typeof ack.y !== "number") {
873
+ return;
874
+ }
875
+ const player = this.getCurrentPlayer();
876
+ const myId = this.playerIdSignal();
877
+ if (!player || !myId) {
878
+ return;
879
+ }
880
+ const hitbox = typeof player.hitbox === "function" ? player.hitbox() : player.hitbox;
881
+ const width = hitbox?.w ?? 0;
882
+ const height = hitbox?.h ?? 0;
883
+ const updated = this.sceneMap.updateHitbox(myId, ack.x, ack.y, width, height);
884
+ if (!updated) {
885
+ this.sceneMap.setBodyPosition(myId, ack.x, ack.y, "top-left");
886
+ }
887
+ player.x.set(Math.round(ack.x));
888
+ player.y.set(Math.round(ack.y));
889
+ if (ack.direction) {
890
+ player.changeDirection(ack.direction);
891
+ }
892
+ }
893
+
894
+ /**
895
+ * Replay unacknowledged inputs from a given frame to resimulate client prediction
896
+ * after applying server authority at a certain frame.
897
+ *
898
+ * @param startFrame - The last server-acknowledged frame
899
+ *
900
+ * @example
901
+ * ```ts
902
+ * // After applying a server correction at frame N
903
+ * this.replayUnackedInputsFromFrame(N);
904
+ * ```
905
+ */
906
+ private async replayUnackedInputsFromFrame(_startFrame: number): Promise<void> {
907
+ // Prediction controller handles replay internally. Kept for backwards compatibility.
908
+ }
141
909
  }