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