@rpgjs/client 5.0.0-alpha.4 → 5.0.0-alpha.40

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 (278) hide show
  1. package/dist/Game/AnimationManager.d.ts +8 -0
  2. package/dist/{index19.js → Game/AnimationManager.js} +4 -3
  3. package/dist/Game/AnimationManager.js.map +1 -0
  4. package/dist/{index30.js → Game/Event.js} +2 -2
  5. package/dist/Game/Event.js.map +1 -0
  6. package/dist/Game/Map.d.ts +19 -2
  7. package/dist/Game/Map.js +84 -0
  8. package/dist/Game/Map.js.map +1 -0
  9. package/dist/Game/Object.d.ts +129 -0
  10. package/dist/Game/Object.js +218 -0
  11. package/dist/Game/Object.js.map +1 -0
  12. package/dist/{index29.js → Game/Player.js} +2 -2
  13. package/dist/Game/Player.js.map +1 -0
  14. package/dist/Gui/Gui.d.ts +177 -5
  15. package/dist/Gui/Gui.js +478 -0
  16. package/dist/Gui/Gui.js.map +1 -0
  17. package/dist/Gui/NotificationManager.d.ts +23 -0
  18. package/dist/Gui/NotificationManager.js +51 -0
  19. package/dist/Gui/NotificationManager.js.map +1 -0
  20. package/dist/Resource.d.ts +97 -0
  21. package/dist/Resource.js +114 -0
  22. package/dist/Resource.js.map +1 -0
  23. package/dist/RpgClient.d.ts +259 -59
  24. package/dist/RpgClientEngine.d.ts +648 -9
  25. package/dist/RpgClientEngine.js +1484 -0
  26. package/dist/RpgClientEngine.js.map +1 -0
  27. package/dist/Sound.d.ts +199 -0
  28. package/dist/Sound.js +97 -0
  29. package/dist/Sound.js.map +1 -0
  30. package/dist/components/animations/animation.ce.js +21 -0
  31. package/dist/components/animations/animation.ce.js.map +1 -0
  32. package/dist/{index23.js → components/animations/hit.ce.js} +3 -3
  33. package/dist/components/animations/hit.ce.js.map +1 -0
  34. package/dist/components/animations/index.d.ts +4 -0
  35. package/dist/components/animations/index.js +10 -0
  36. package/dist/components/animations/index.js.map +1 -0
  37. package/dist/components/character.ce.js +316 -0
  38. package/dist/components/character.ce.js.map +1 -0
  39. package/dist/components/dynamics/parse-value.d.ts +1 -0
  40. package/dist/components/dynamics/parse-value.js +54 -0
  41. package/dist/components/dynamics/parse-value.js.map +1 -0
  42. package/dist/components/dynamics/text.ce.js +141 -0
  43. package/dist/components/dynamics/text.ce.js.map +1 -0
  44. package/dist/components/gui/box.ce.js +27 -0
  45. package/dist/components/gui/box.ce.js.map +1 -0
  46. package/dist/components/gui/dialogbox/index.ce.js +152 -0
  47. package/dist/components/gui/dialogbox/index.ce.js.map +1 -0
  48. package/dist/components/gui/gameover.ce.js +141 -0
  49. package/dist/components/gui/gameover.ce.js.map +1 -0
  50. package/dist/components/gui/hud/hud.ce.js +35 -0
  51. package/dist/components/gui/hud/hud.ce.js.map +1 -0
  52. package/dist/components/gui/index.d.ts +15 -3
  53. package/dist/components/gui/menu/equip-menu.ce.js +349 -0
  54. package/dist/components/gui/menu/equip-menu.ce.js.map +1 -0
  55. package/dist/components/gui/menu/exit-menu.ce.js +35 -0
  56. package/dist/components/gui/menu/exit-menu.ce.js.map +1 -0
  57. package/dist/components/gui/menu/items-menu.ce.js +229 -0
  58. package/dist/components/gui/menu/items-menu.ce.js.map +1 -0
  59. package/dist/components/gui/menu/main-menu.ce.js +208 -0
  60. package/dist/components/gui/menu/main-menu.ce.js.map +1 -0
  61. package/dist/components/gui/menu/options-menu.ce.js +28 -0
  62. package/dist/components/gui/menu/options-menu.ce.js.map +1 -0
  63. package/dist/components/gui/menu/skills-menu.ce.js +53 -0
  64. package/dist/components/gui/menu/skills-menu.ce.js.map +1 -0
  65. package/dist/components/gui/mobile/index.d.ts +8 -0
  66. package/dist/components/gui/mobile/index.js +24 -0
  67. package/dist/components/gui/mobile/index.js.map +1 -0
  68. package/dist/components/gui/mobile/mobile.ce.js +17 -0
  69. package/dist/components/gui/mobile/mobile.ce.js.map +1 -0
  70. package/dist/components/gui/notification/notification.ce.js +38 -0
  71. package/dist/components/gui/notification/notification.ce.js.map +1 -0
  72. package/dist/components/gui/save-load.ce.js +242 -0
  73. package/dist/components/gui/save-load.ce.js.map +1 -0
  74. package/dist/components/gui/shop/shop.ce.js +322 -0
  75. package/dist/components/gui/shop/shop.ce.js.map +1 -0
  76. package/dist/components/gui/title-screen.ce.js +148 -0
  77. package/dist/components/gui/title-screen.ce.js.map +1 -0
  78. package/dist/components/index.d.ts +3 -1
  79. package/dist/components/prebuilt/hp-bar.ce.js +106 -0
  80. package/dist/components/prebuilt/hp-bar.ce.js.map +1 -0
  81. package/dist/components/prebuilt/index.d.ts +19 -0
  82. package/dist/components/prebuilt/light-halo.ce.js +76 -0
  83. package/dist/components/prebuilt/light-halo.ce.js.map +1 -0
  84. package/dist/components/scenes/canvas.ce.js +44 -0
  85. package/dist/components/scenes/canvas.ce.js.map +1 -0
  86. package/dist/components/scenes/draw-map.ce.js +66 -0
  87. package/dist/components/scenes/draw-map.ce.js.map +1 -0
  88. package/dist/{index13.js → components/scenes/event-layer.ce.js} +7 -6
  89. package/dist/components/scenes/event-layer.ce.js.map +1 -0
  90. package/dist/{index6.js → core/inject.js} +2 -2
  91. package/dist/core/inject.js.map +1 -0
  92. package/dist/core/setup.js +16 -0
  93. package/dist/core/setup.js.map +1 -0
  94. package/dist/index.d.ts +15 -1
  95. package/dist/index.js +40 -12
  96. package/dist/index.js.map +1 -1
  97. package/dist/module.d.ts +43 -4
  98. package/dist/module.js +175 -0
  99. package/dist/module.js.map +1 -0
  100. package/dist/node_modules/.pnpm/@signe_di@2.8.3/node_modules/@signe/di/dist/index.js +366 -0
  101. package/dist/node_modules/.pnpm/@signe_di@2.8.3/node_modules/@signe/di/dist/index.js.map +1 -0
  102. package/dist/{index27.js → node_modules/.pnpm/@signe_reactive@2.8.3/node_modules/@signe/reactive/dist/index.js} +229 -11
  103. package/dist/node_modules/.pnpm/@signe_reactive@2.8.3/node_modules/@signe/reactive/dist/index.js.map +1 -0
  104. package/dist/{index20.js → node_modules/.pnpm/@signe_room@2.8.3/node_modules/@signe/room/dist/index.js} +308 -40
  105. package/dist/node_modules/.pnpm/@signe_room@2.8.3/node_modules/@signe/room/dist/index.js.map +1 -0
  106. package/dist/{index26.js → node_modules/.pnpm/@signe_sync@2.8.3/node_modules/@signe/sync/dist/chunk-7QVYU63E.js} +1 -1
  107. package/dist/node_modules/.pnpm/@signe_sync@2.8.3/node_modules/@signe/sync/dist/chunk-7QVYU63E.js.map +1 -0
  108. package/dist/{index21.js → node_modules/.pnpm/@signe_sync@2.8.3/node_modules/@signe/sync/dist/client/index.js} +5 -5
  109. package/dist/node_modules/.pnpm/@signe_sync@2.8.3/node_modules/@signe/sync/dist/client/index.js.map +1 -0
  110. package/dist/{index17.js → node_modules/.pnpm/@signe_sync@2.8.3/node_modules/@signe/sync/dist/index.js} +89 -12
  111. package/dist/node_modules/.pnpm/@signe_sync@2.8.3/node_modules/@signe/sync/dist/index.js.map +1 -0
  112. package/dist/{index31.js → node_modules/.pnpm/dset@3.1.4/node_modules/dset/dist/index.js} +1 -1
  113. package/dist/node_modules/.pnpm/dset@3.1.4/node_modules/dset/dist/index.js.map +1 -0
  114. package/dist/{index34.js → node_modules/.pnpm/partysocket@1.1.3/node_modules/partysocket/dist/chunk-HAC622V3.js} +2 -2
  115. package/dist/node_modules/.pnpm/partysocket@1.1.3/node_modules/partysocket/dist/chunk-HAC622V3.js.map +1 -0
  116. package/dist/{index35.js → node_modules/.pnpm/partysocket@1.1.3/node_modules/partysocket/dist/chunk-S74YV6PU.js} +1 -1
  117. package/dist/node_modules/.pnpm/partysocket@1.1.3/node_modules/partysocket/dist/chunk-S74YV6PU.js.map +1 -0
  118. package/dist/{index32.js → node_modules/.pnpm/zod@3.24.2/node_modules/zod/lib/index.js} +1 -1
  119. package/dist/node_modules/.pnpm/zod@3.24.2/node_modules/zod/lib/index.js.map +1 -0
  120. package/dist/presets/animation.d.ts +31 -0
  121. package/dist/presets/animation.js +27 -0
  122. package/dist/presets/animation.js.map +1 -0
  123. package/dist/presets/faceset.d.ts +30 -0
  124. package/dist/presets/faceset.js +24 -0
  125. package/dist/presets/faceset.js.map +1 -0
  126. package/dist/presets/icon.d.ts +20 -0
  127. package/dist/presets/icon.js +15 -0
  128. package/dist/presets/icon.js.map +1 -0
  129. package/dist/presets/index.d.ts +123 -0
  130. package/dist/presets/index.js +16 -0
  131. package/dist/presets/index.js.map +1 -0
  132. package/dist/presets/lpc.d.ts +89 -0
  133. package/dist/presets/lpc.js +95 -0
  134. package/dist/presets/lpc.js.map +1 -0
  135. package/dist/{index25.js → presets/rmspritesheet.js} +1 -1
  136. package/dist/presets/rmspritesheet.js.map +1 -0
  137. package/dist/services/AbstractSocket.d.ts +9 -5
  138. package/dist/{index16.js → services/AbstractSocket.js} +1 -1
  139. package/dist/services/AbstractSocket.js.map +1 -0
  140. package/dist/services/keyboardControls.d.ts +15 -0
  141. package/dist/services/keyboardControls.js +21 -0
  142. package/dist/services/keyboardControls.js.map +1 -0
  143. package/dist/services/loadMap.d.ts +123 -2
  144. package/dist/{index7.js → services/loadMap.js} +12 -4
  145. package/dist/services/loadMap.js.map +1 -0
  146. package/dist/services/mmorpg.d.ts +24 -8
  147. package/dist/services/mmorpg.js +139 -0
  148. package/dist/services/mmorpg.js.map +1 -0
  149. package/dist/services/save.d.ts +19 -0
  150. package/dist/services/save.js +69 -0
  151. package/dist/services/save.js.map +1 -0
  152. package/dist/services/standalone.d.ts +67 -7
  153. package/dist/services/standalone.js +170 -0
  154. package/dist/services/standalone.js.map +1 -0
  155. package/dist/utils/getEntityProp.d.ts +39 -0
  156. package/dist/utils/getEntityProp.js +54 -0
  157. package/dist/utils/getEntityProp.js.map +1 -0
  158. package/package.json +18 -12
  159. package/src/Game/{EffectManager.ts → AnimationManager.ts} +3 -2
  160. package/src/Game/Map.ts +74 -3
  161. package/src/Game/Object.ts +296 -11
  162. package/src/Gui/Gui.ts +506 -18
  163. package/src/Gui/NotificationManager.ts +69 -0
  164. package/src/Resource.ts +150 -0
  165. package/src/RpgClient.ts +264 -58
  166. package/src/RpgClientEngine.ts +1705 -48
  167. package/src/Sound.ts +253 -0
  168. package/src/components/{effects → animations}/animation.ce +3 -6
  169. package/src/components/{effects → animations}/index.ts +1 -1
  170. package/src/components/character.ce +406 -40
  171. package/src/components/dynamics/parse-value.ts +80 -0
  172. package/src/components/dynamics/text.ce +183 -0
  173. package/src/components/gui/box.ce +17 -0
  174. package/src/components/gui/dialogbox/index.ce +204 -187
  175. package/src/components/gui/gameover.ce +158 -0
  176. package/src/components/gui/hud/hud.ce +61 -0
  177. package/src/components/gui/index.ts +30 -4
  178. package/src/components/gui/menu/equip-menu.ce +410 -0
  179. package/src/components/gui/menu/exit-menu.ce +41 -0
  180. package/src/components/gui/menu/items-menu.ce +317 -0
  181. package/src/components/gui/menu/main-menu.ce +294 -0
  182. package/src/components/gui/menu/options-menu.ce +35 -0
  183. package/src/components/gui/menu/skills-menu.ce +83 -0
  184. package/src/components/gui/mobile/index.ts +24 -0
  185. package/src/components/gui/mobile/mobile.ce +80 -0
  186. package/src/components/gui/notification/notification.ce +51 -0
  187. package/src/components/gui/save-load.ce +208 -0
  188. package/src/components/gui/shop/shop.ce +493 -0
  189. package/src/components/gui/title-screen.ce +163 -0
  190. package/src/components/index.ts +5 -1
  191. package/src/components/prebuilt/hp-bar.ce +255 -0
  192. package/src/components/prebuilt/index.ts +24 -0
  193. package/src/components/prebuilt/light-halo.ce +148 -0
  194. package/src/components/scenes/canvas.ce +20 -15
  195. package/src/components/scenes/draw-map.ce +58 -30
  196. package/src/components/scenes/event-layer.ce +10 -3
  197. package/src/components/scenes/transition.ce +60 -0
  198. package/src/core/setup.ts +2 -0
  199. package/src/index.ts +16 -2
  200. package/src/module.ts +145 -9
  201. package/src/presets/animation.ts +46 -0
  202. package/src/presets/faceset.ts +60 -0
  203. package/src/presets/icon.ts +17 -0
  204. package/src/presets/index.ts +9 -1
  205. package/src/presets/lpc.ts +108 -0
  206. package/src/services/AbstractSocket.ts +10 -2
  207. package/src/services/keyboardControls.ts +20 -0
  208. package/src/services/loadMap.ts +132 -3
  209. package/src/services/mmorpg.ts +113 -9
  210. package/src/services/save.ts +103 -0
  211. package/src/services/standalone.ts +110 -18
  212. package/src/utils/getEntityProp.ts +87 -0
  213. package/tsconfig.json +1 -1
  214. package/vite.config.ts +5 -3
  215. package/dist/Game/EffectManager.d.ts +0 -5
  216. package/dist/components/effects/index.d.ts +0 -4
  217. package/dist/index10.js +0 -8
  218. package/dist/index10.js.map +0 -1
  219. package/dist/index11.js +0 -10
  220. package/dist/index11.js.map +0 -1
  221. package/dist/index12.js +0 -8
  222. package/dist/index12.js.map +0 -1
  223. package/dist/index13.js.map +0 -1
  224. package/dist/index14.js +0 -50
  225. package/dist/index14.js.map +0 -1
  226. package/dist/index15.js +0 -191
  227. package/dist/index15.js.map +0 -1
  228. package/dist/index16.js.map +0 -1
  229. package/dist/index17.js.map +0 -1
  230. package/dist/index18.js +0 -31
  231. package/dist/index18.js.map +0 -1
  232. package/dist/index19.js.map +0 -1
  233. package/dist/index2.js +0 -112
  234. package/dist/index2.js.map +0 -1
  235. package/dist/index20.js.map +0 -1
  236. package/dist/index21.js.map +0 -1
  237. package/dist/index22.js +0 -109
  238. package/dist/index22.js.map +0 -1
  239. package/dist/index23.js.map +0 -1
  240. package/dist/index24.js +0 -21
  241. package/dist/index24.js.map +0 -1
  242. package/dist/index25.js.map +0 -1
  243. package/dist/index26.js.map +0 -1
  244. package/dist/index27.js.map +0 -1
  245. package/dist/index28.js +0 -25
  246. package/dist/index28.js.map +0 -1
  247. package/dist/index29.js.map +0 -1
  248. package/dist/index3.js +0 -87
  249. package/dist/index3.js.map +0 -1
  250. package/dist/index30.js.map +0 -1
  251. package/dist/index31.js.map +0 -1
  252. package/dist/index32.js.map +0 -1
  253. package/dist/index33.js +0 -316
  254. package/dist/index33.js.map +0 -1
  255. package/dist/index34.js.map +0 -1
  256. package/dist/index35.js.map +0 -1
  257. package/dist/index36.js +0 -91
  258. package/dist/index36.js.map +0 -1
  259. package/dist/index37.js +0 -61
  260. package/dist/index37.js.map +0 -1
  261. package/dist/index38.js +0 -20
  262. package/dist/index38.js.map +0 -1
  263. package/dist/index39.js +0 -20
  264. package/dist/index39.js.map +0 -1
  265. package/dist/index4.js +0 -54
  266. package/dist/index4.js.map +0 -1
  267. package/dist/index5.js +0 -15
  268. package/dist/index5.js.map +0 -1
  269. package/dist/index6.js.map +0 -1
  270. package/dist/index7.js.map +0 -1
  271. package/dist/index8.js +0 -90
  272. package/dist/index8.js.map +0 -1
  273. package/dist/index9.js +0 -76
  274. package/dist/index9.js.map +0 -1
  275. package/src/components/gui/dialogbox/itemMenu.ce +0 -23
  276. package/src/components/gui/dialogbox/selection.ce +0 -67
  277. package/src/components/scenes/element-map.ce +0 -23
  278. /package/src/components/{effects → animations}/hit.ce +0 -0
@@ -1,96 +1,596 @@
1
1
  import Canvas from "./components/scenes/canvas.ce";
2
- import { Context, inject } from "@signe/di";
3
- import { signal, bootstrapCanvas } from "canvasengine";
2
+ import { inject } from './core/inject'
3
+ import { signal, bootstrapCanvas, KeyboardControls, Howl, trigger } 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 { RpgResource } from "./Resource";
8
+ import { Hooks, ModulesToken, Direction } from "@rpgjs/common";
9
+
10
+ type DirectionValue = "up" | "down" | "left" | "right";
7
11
  import { load } from "@signe/sync";
8
12
  import { RpgClientMap } from "./Game/Map"
9
13
  import { RpgGui } from "./Gui/Gui";
10
- import { EffectManager } from "./Game/EffectManager";
11
- import { lastValueFrom } from "rxjs";
14
+ import { AnimationManager } from "./Game/AnimationManager";
15
+ import { lastValueFrom, Observable, combineLatest, BehaviorSubject, filter, switchMap, take } from "rxjs";
12
16
  import { GlobalConfigToken } from "./module";
13
- import { ClientIo } from "@signe/room";
17
+ import * as PIXI from "pixi.js";
18
+ import { PrebuiltComponentAnimations } from "./components/animations";
19
+ import {
20
+ PredictionController,
21
+ type PredictionHistoryEntry,
22
+ type PredictionState,
23
+ } from "@rpgjs/common";
24
+ import { NotificationManager } from "./Gui/NotificationManager";
25
+ import { SaveClientService } from "./services/save";
26
+
27
+ interface MovementTrajectoryPoint {
28
+ frame: number;
29
+ tick: number;
30
+ timestamp: number;
31
+ input: Direction;
32
+ x: number;
33
+ y: number;
34
+ direction?: Direction;
35
+ }
14
36
 
15
37
  export class RpgClientEngine<T = any> {
16
38
  private guiService: RpgGui;
17
39
  private webSocket: AbstractWebsocket;
18
40
  private loadMapService: LoadMapService;
19
41
  private hooks: Hooks;
20
- private sceneMap: RpgClientMap = new RpgClientMap();
42
+ private sceneMap: RpgClientMap
21
43
  private selector: HTMLElement;
22
44
  public globalConfig: T;
23
45
  public sceneComponent: any;
24
46
  stopProcessingInput = false;
25
-
26
47
  width = signal("100%");
27
48
  height = signal("100%");
28
49
  spritesheets: Map<string, any> = new Map();
29
50
  sounds: Map<string, any> = new Map();
30
- effects: any[] = [];
51
+ componentAnimations: any[] = [];
52
+ private spritesheetResolver?: (id: string) => any | Promise<any>;
53
+ private soundResolver?: (id: string) => any | Promise<any>;
31
54
  particleSettings: {
32
55
  emitters: any[]
33
56
  } = {
34
- emitters: []
57
+ emitters: []
58
+ }
59
+ renderer: PIXI.Renderer;
60
+ tick: Observable<number>;
61
+ private canvasApp?: any;
62
+ private canvasElement?: any;
63
+ playerIdSignal = signal<string | null>(null);
64
+ spriteComponentsBehind = signal<any[]>([]);
65
+ spriteComponentsInFront = signal<any[]>([]);
66
+ /** ID of the sprite that the camera should follow. null means follow the current player */
67
+ cameraFollowTargetId = signal<string | null>(null);
68
+ /** Trigger for map shake animation */
69
+ mapShakeTrigger = trigger();
70
+
71
+ controlsReady = signal(undefined);
72
+ gamePause = signal(false);
73
+
74
+ private predictionEnabled = false;
75
+ private prediction?: PredictionController<Direction>;
76
+ private readonly SERVER_CORRECTION_THRESHOLD = 30;
77
+ private inputFrameCounter = 0;
78
+ private pendingPredictionFrames: number[] = [];
79
+ private lastClientPhysicsStepAt = 0;
80
+ private frameOffset = 0;
81
+ // Ping/Pong for RTT measurement
82
+ private rtt: number = 0; // Round-trip time in ms
83
+ private pingInterval: any = null;
84
+ private readonly PING_INTERVAL_MS = 5000; // Send ping every 5 seconds
85
+ private lastInputTime = 0;
86
+ private readonly MOVE_PATH_RESEND_INTERVAL_MS = 120;
87
+ private readonly MAX_MOVE_TRAJECTORY_POINTS = 240;
88
+ private lastMovePathSentAt = 0;
89
+ private lastMovePathSentFrame = 0;
90
+ // Track map loading state for onAfterLoading hook using RxJS
91
+ private mapLoadCompleted$ = new BehaviorSubject<boolean>(false);
92
+ private playerIdReceived$ = new BehaviorSubject<boolean>(false);
93
+ private playersReceived$ = new BehaviorSubject<boolean>(false);
94
+ private eventsReceived$ = new BehaviorSubject<boolean>(false);
95
+ private onAfterLoadingSubscription?: any;
96
+ private sceneResetQueued = false;
97
+
98
+ // Store subscriptions and event listeners for cleanup
99
+ private tickSubscriptions: any[] = [];
100
+ private resizeHandler?: () => void;
101
+ private notificationManager: NotificationManager = new NotificationManager();
102
+
103
+ constructor(public context) {
104
+ this.webSocket = inject(WebSocketToken);
105
+ this.guiService = inject(RpgGui);
106
+ this.loadMapService = inject(LoadMapToken);
107
+ this.hooks = inject<Hooks>(ModulesToken);
108
+ this.globalConfig = inject(GlobalConfigToken)
109
+
110
+ if (!this.globalConfig) {
111
+ this.globalConfig = {} as T
112
+ }
113
+ if (!(this.globalConfig as any).box) {
114
+ (this.globalConfig as any).box = {
115
+ styles: {
116
+ backgroundColor: "#1a1a2e",
117
+ backgroundOpacity: 0.9
118
+ },
119
+ sounds: {}
120
+ }
121
+ }
122
+
123
+ this.addComponentAnimation({
124
+ id: "animation",
125
+ component: PrebuiltComponentAnimations.Animation
126
+ })
127
+
128
+ this.predictionEnabled = (this.globalConfig as any)?.prediction?.enabled !== false;
129
+ this.initializePredictionController();
35
130
  }
36
131
 
37
- constructor(public context: Context) {
38
- this.webSocket = inject(context, WebSocketToken);
39
- this.guiService = inject(context, RpgGui);
40
- this.loadMapService = inject(context, LoadMapToken);
41
- this.hooks = inject<Hooks>(context, ModulesToken);
42
- this.globalConfig = inject(context, GlobalConfigToken)
132
+ /**
133
+ * Assigns a CanvasEngine KeyboardControls instance to the dependency injection context
134
+ *
135
+ * This method registers a KeyboardControls instance from CanvasEngine into the DI container,
136
+ * making it available for injection throughout the application. The particularity is that
137
+ * this method is automatically called when a sprite is displayed on the map, allowing the
138
+ * controls to be automatically associated with the active sprite.
139
+ *
140
+ * ## Design
141
+ *
142
+ * - The instance is stored in the DI context under the `KeyboardControls` token
143
+ * - It's automatically assigned when a sprite component mounts (in `character.ce`)
144
+ * - The controls instance comes from the CanvasEngine component's directives
145
+ * - Once registered, it can be retrieved using `inject(KeyboardControls)` from anywhere
146
+ *
147
+ * @param controlInstance - The CanvasEngine KeyboardControls instance to register
148
+ *
149
+ * @example
150
+ * ```ts
151
+ * // The method is automatically called when a sprite is displayed:
152
+ * // client.setKeyboardControls(element.directives.controls)
153
+ *
154
+ * // Later, retrieve and use the controls instance:
155
+ * import { Input, inject, KeyboardControls } from '@rpgjs/client'
156
+ *
157
+ * const controls = inject(KeyboardControls)
158
+ * const control = controls.getControl(Input.Enter)
159
+ *
160
+ * if (control) {
161
+ * console.log(control.actionName) // 'action'
162
+ * }
163
+ * ```
164
+ */
165
+ setKeyboardControls(controlInstance: any) {
166
+ const currentValues = this.context.values['inject:' + 'KeyboardControls']
167
+ this.context.values['inject:' + 'KeyboardControls'] = {
168
+ ...currentValues,
169
+ values: new Map([['__default__', controlInstance]])
170
+ }
171
+ this.controlsReady.set(undefined);
43
172
  }
44
173
 
45
174
  async start() {
175
+ this.sceneMap = new RpgClientMap()
176
+ this.sceneMap.configureClientPrediction(this.predictionEnabled);
177
+ this.sceneMap.loadPhysic();
46
178
  this.selector = document.body.querySelector("#rpg") as HTMLElement;
47
179
 
48
- await bootstrapCanvas(this.selector, Canvas);
180
+ const bootstrapOptions = (this.globalConfig as any)?.bootstrapCanvasOptions;
181
+ const { app, canvasElement } = await bootstrapCanvas(
182
+ this.selector,
183
+ Canvas,
184
+ bootstrapOptions
185
+ );
186
+ this.canvasApp = app;
187
+ this.canvasElement = canvasElement;
188
+ this.renderer = app.renderer as PIXI.Renderer;
189
+ this.tick = canvasElement?.propObservables?.context['tick'].observable
190
+
191
+ const inputCheckSubscription = this.tick.subscribe(() => {
192
+ if (Date.now() - this.lastInputTime > 100) {
193
+ const player = this.getCurrentPlayer();
194
+ if (!player) return;
195
+ (this.sceneMap as any).stopMovement(player);
196
+ }
197
+ });
198
+ this.tickSubscriptions.push(inputCheckSubscription);
49
199
 
50
- await lastValueFrom(this.hooks.callHooks("client-engine-onStart", this));
51
200
 
52
201
  this.hooks.callHooks("client-spritesheets-load", this).subscribe();
202
+ this.hooks.callHooks("client-spritesheetResolver-load", this).subscribe();
53
203
  this.hooks.callHooks("client-sounds-load", this).subscribe();
204
+ this.hooks.callHooks("client-soundResolver-load", this).subscribe();
205
+
206
+ RpgSound.init(this);
207
+ RpgResource.init(this);
54
208
  this.hooks.callHooks("client-gui-load", this).subscribe();
55
209
  this.hooks.callHooks("client-particles-load", this).subscribe();
56
- this.hooks.callHooks("client-effects-load", this).subscribe();
210
+ this.hooks.callHooks("client-componentAnimations-load", this).subscribe();
211
+ this.hooks.callHooks("client-sprite-load", this).subscribe();
212
+
213
+ await lastValueFrom(this.hooks.callHooks("client-engine-onStart", this));
214
+
215
+ // wondow is resize
216
+ this.resizeHandler = () => {
217
+ this.hooks.callHooks("client-engine-onWindowResize", this).subscribe();
218
+ };
219
+ window.addEventListener('resize', this.resizeHandler);
220
+
221
+ const tickSubscription = this.tick.subscribe((tick) => {
222
+ this.stepClientPhysicsTick();
223
+ this.flushPendingPredictedStates();
224
+ this.flushPendingMovePath();
225
+ this.hooks.callHooks("client-engine-onStep", this, tick).subscribe();
226
+
227
+ // Clean up old prediction states and input history every 60 ticks (approximately every second at 60fps)
228
+ if (tick % 60 === 0) {
229
+ const now = Date.now();
230
+ this.prediction?.cleanup(now);
231
+ this.prediction?.tryApplyPendingSnapshot();
232
+ }
233
+ });
234
+ this.tickSubscriptions.push(tickSubscription);
57
235
 
58
-
59
236
  await this.webSocket.connection(() => {
237
+ const saveClient = inject(SaveClientService);
238
+ saveClient.initialize(this.webSocket);
60
239
  this.initListeners()
61
240
  this.guiService._initialize()
241
+ this.startPingPong();
62
242
  });
63
243
  }
64
244
 
245
+ private prepareSyncPayload(data: any): any {
246
+ const payload = { ...(data ?? {}) };
247
+ delete payload.ack;
248
+ delete payload.timestamp;
249
+
250
+ const myId = this.playerIdSignal();
251
+ const players = payload.players;
252
+ const shouldMaskLocalPosition =
253
+ this.predictionEnabled && !!this.prediction?.hasPendingInputs();
254
+ if (shouldMaskLocalPosition && myId && players && players[myId]) {
255
+ const localPatch = { ...players[myId] };
256
+ delete localPatch.x;
257
+ delete localPatch.y;
258
+ delete localPatch.direction;
259
+ delete localPatch._frames;
260
+ payload.players = {
261
+ ...players,
262
+ [myId]: localPatch,
263
+ };
264
+ }
265
+
266
+ return payload;
267
+ }
268
+
269
+ private normalizeAckWithSyncState(
270
+ ack: { frame: number; serverTick?: number; x?: number; y?: number; direction?: Direction },
271
+ syncData: any,
272
+ ): { frame: number; serverTick?: number; x?: number; y?: number; direction?: Direction } {
273
+ const myId = this.playerIdSignal();
274
+ if (!myId) {
275
+ return ack;
276
+ }
277
+
278
+ const localPatch = syncData?.players?.[myId];
279
+ if (typeof localPatch?.x !== "number" || typeof localPatch?.y !== "number") {
280
+ return ack;
281
+ }
282
+
283
+ return {
284
+ ...ack,
285
+ x: localPatch.x,
286
+ y: localPatch.y,
287
+ direction: localPatch.direction ?? ack.direction,
288
+ };
289
+ }
290
+
65
291
  private initListeners() {
66
292
  this.webSocket.on("sync", (data) => {
67
- load(this.sceneMap, data, true);
293
+ if (data.pId) {
294
+ this.playerIdSignal.set(data.pId);
295
+ // Signal that player ID was received
296
+ this.playerIdReceived$.next(true);
297
+ }
298
+
299
+ if (this.sceneResetQueued) {
300
+ this.sceneMap.reset();
301
+ this.sceneMap.loadPhysic();
302
+ this.sceneResetQueued = false;
303
+ }
304
+
305
+ // Apply client-side prediction filtering and server reconciliation
306
+ this.hooks.callHooks("client-sceneMap-onChanges", this.sceneMap, { partial: data }).subscribe();
307
+
308
+ const ack = data?.ack;
309
+ const normalizedAck =
310
+ ack && typeof ack.frame === "number"
311
+ ? this.normalizeAckWithSyncState(ack, data)
312
+ : undefined;
313
+ const payload = this.prepareSyncPayload(data);
314
+ load(this.sceneMap, payload, true);
315
+
316
+ if (normalizedAck) {
317
+ this.applyServerAck(normalizedAck);
318
+ }
319
+
320
+ for (const playerId in payload.players ?? {}) {
321
+ const player = payload.players[playerId]
322
+ if (!player._param) continue
323
+ for (const param in player._param) {
324
+ this.sceneMap.players()[playerId]._param()[param] = player._param[param]
325
+ }
326
+ }
327
+
328
+ // Check if players and events are present in sync data
329
+ const players = payload.players || this.sceneMap.players();
330
+ if (players && Object.keys(players).length > 0) {
331
+ this.playersReceived$.next(true);
332
+ }
333
+
334
+ const events = payload.events || this.sceneMap.events();
335
+ if (events !== undefined) {
336
+ this.eventsReceived$.next(true);
337
+ }
338
+ });
339
+
340
+ // Handle pong responses for RTT measurement
341
+ this.webSocket.on("pong", (data: { serverTick: number; clientFrame: number; clientTime: number }) => {
342
+ const now = Date.now();
343
+ this.rtt = now - data.clientTime;
344
+
345
+ // Calculate frame offset: how many ticks ahead the server is compared to our frame counter
346
+ // This helps us estimate which server tick corresponds to each client input frame
347
+ const estimatedTicksInFlight = Math.floor(this.rtt / 2 / (1000 / 60)); // Estimate ticks during half RTT
348
+ const estimatedServerTickNow = data.serverTick + estimatedTicksInFlight;
349
+
350
+ // Update frame offset (only if we have inputs to calibrate with)
351
+ if (this.inputFrameCounter > 0) {
352
+ this.frameOffset = estimatedServerTickNow - data.clientFrame;
353
+ }
354
+
355
+ console.debug(`[Ping/Pong] RTT: ${this.rtt}ms, ServerTick: ${data.serverTick}, FrameOffset: ${this.frameOffset}`);
68
356
  });
69
357
 
70
358
  this.webSocket.on("changeMap", (data) => {
71
- this.loadScene(data.mapId);
359
+ this.sceneResetQueued = true;
360
+ // Reset camera follow to default (follow current player) when changing maps
361
+ this.cameraFollowTargetId.set(null);
362
+ const transferToken = typeof data?.transferToken === "string" ? data.transferToken : undefined;
363
+ this.loadScene(data.mapId, transferToken);
72
364
  });
73
365
 
74
- this.webSocket.on("showEffect", (data) => {
75
- const { params, object, id } = data;
76
- if (!object) {
77
- throw new Error("Object not found");
366
+ this.webSocket.on("showComponentAnimation", (data) => {
367
+ const { params, object, position, id } = data;
368
+ if (!object && position === undefined) {
369
+ throw new Error("Please provide an object or x and y coordinates");
78
370
  }
79
- const player = this.sceneMap.getObjectById(object);
80
- this.getEffect(id).displayEffect(params, player)
371
+ const player = object ? this.sceneMap.getObjectById(object) : undefined;
372
+ this.getComponentAnimation(id).displayEffect(params, player || position)
373
+ });
374
+
375
+ this.webSocket.on("notification", (data) => {
376
+ this.notificationManager.add(data);
377
+ });
378
+
379
+ this.webSocket.on("setAnimation", (data) => {
380
+ const { animationName, nbTimes, object, graphic } = data;
381
+ const player = this.sceneMap.getObjectById(object);
382
+ if (graphic !== undefined) {
383
+ player.setAnimation(animationName, graphic, nbTimes);
384
+ } else {
385
+ player.setAnimation(animationName, nbTimes);
386
+ }
387
+ })
388
+
389
+ this.webSocket.on("playSound", (data) => {
390
+ const { soundId, volume, loop } = data;
391
+ this.playSound(soundId, { volume, loop });
392
+ });
393
+
394
+ this.webSocket.on("stopSound", (data) => {
395
+ const { soundId } = data;
396
+ this.stopSound(soundId);
397
+ });
398
+
399
+ this.webSocket.on("stopAllSounds", () => {
400
+ this.stopAllSounds();
401
+ });
402
+
403
+ this.webSocket.on("cameraFollow", (data) => {
404
+ const { targetId, smoothMove } = data;
405
+ this.setCameraFollow(targetId, smoothMove);
81
406
  });
407
+
408
+ this.webSocket.on("flash", (data) => {
409
+ const { object, type, duration, cycles, alpha, tint } = data;
410
+ const sprite = object ? this.sceneMap.getObjectById(object) : undefined;
411
+ if (sprite && typeof sprite.flash === 'function') {
412
+ sprite.flash({ type, duration, cycles, alpha, tint });
413
+ }
414
+ });
415
+
416
+ this.webSocket.on("shakeMap", (data) => {
417
+ const { intensity, duration, frequency, direction } = data || {};
418
+ (this.mapShakeTrigger as any).start({
419
+ intensity,
420
+ duration,
421
+ frequency,
422
+ direction
423
+ });
424
+ });
425
+
426
+ this.webSocket.on("weatherState", (data) => {
427
+ const raw = (data && typeof data === "object" && "value" in data)
428
+ ? (data as any).value
429
+ : data;
430
+
431
+ if (raw === null) {
432
+ this.sceneMap.weatherState.set(null);
433
+ return;
434
+ }
435
+
436
+ const validEffects = ["rain", "snow", "fog", "cloud"];
437
+ if (!raw || !validEffects.includes((raw as any).effect)) {
438
+ return;
439
+ }
440
+
441
+ this.sceneMap.weatherState.set({
442
+ effect: (raw as any).effect,
443
+ preset: (raw as any).preset,
444
+ params: (raw as any).params,
445
+ transitionMs: (raw as any).transitionMs,
446
+ durationMs: (raw as any).durationMs,
447
+ startedAt: (raw as any).startedAt,
448
+ seed: (raw as any).seed,
449
+ });
450
+ });
451
+
452
+ this.webSocket.on('open', () => {
453
+ this.hooks.callHooks("client-engine-onConnected", this, this.socket).subscribe();
454
+ // Start ping/pong for synchronization
455
+ this.startPingPong();
456
+ })
457
+
458
+ this.webSocket.on('close', () => {
459
+ this.hooks.callHooks("client-engine-onDisconnected", this, this.socket).subscribe();
460
+ // Stop ping/pong when disconnected
461
+ this.stopPingPong();
462
+ })
463
+
464
+ this.webSocket.on('error', (error) => {
465
+ this.hooks.callHooks("client-engine-onConnectError", this, error, this.socket).subscribe();
466
+ })
82
467
  }
83
-
84
- private async loadScene(mapId: string) {
85
- this.webSocket.updateProperties({ room: mapId })
468
+
469
+ /**
470
+ * Start periodic ping/pong for client-server synchronization
471
+ *
472
+ * Sends ping requests to the server to measure round-trip time (RTT) and
473
+ * calculate the frame offset between client and server ticks.
474
+ *
475
+ * ## Design
476
+ *
477
+ * - Sends ping every 5 seconds
478
+ * - Measures RTT for latency compensation
479
+ * - Calculates frame offset to map client frames to server ticks
480
+ * - Used for accurate server reconciliation
481
+ *
482
+ * @example
483
+ * ```ts
484
+ * // Called automatically when connection opens
485
+ * this.startPingPong();
486
+ * ```
487
+ */
488
+ private startPingPong(): void {
489
+ // Stop existing interval if any
490
+ this.stopPingPong();
491
+
492
+ // Send initial ping immediately
493
+ this.sendPing();
494
+
495
+ // Set up periodic pings
496
+ this.pingInterval = setInterval(() => {
497
+ this.sendPing();
498
+ }, this.PING_INTERVAL_MS);
499
+ }
500
+
501
+ /**
502
+ * Stop periodic ping/pong
503
+ *
504
+ * Stops the ping interval when disconnecting or changing maps.
505
+ *
506
+ * @example
507
+ * ```ts
508
+ * // Called automatically when connection closes
509
+ * this.stopPingPong();
510
+ * ```
511
+ */
512
+ private stopPingPong(): void {
513
+ if (this.pingInterval) {
514
+ clearInterval(this.pingInterval);
515
+ this.pingInterval = null;
516
+ }
517
+ }
518
+
519
+ /**
520
+ * Send a ping request to the server
521
+ *
522
+ * Sends current client time and frame counter to the server,
523
+ * which will respond with its server tick for synchronization.
524
+ *
525
+ * @example
526
+ * ```ts
527
+ * // Send a ping to measure RTT
528
+ * this.sendPing();
529
+ * ```
530
+ */
531
+ private sendPing(): void {
532
+ const clientTime = Date.now();
533
+ const clientFrame = this.getPhysicsTick();
534
+
535
+ this.webSocket.emit('ping', {
536
+ clientTime,
537
+ clientFrame
538
+ });
539
+ }
540
+
541
+ private async loadScene(mapId: string, transferToken?: string) {
542
+ await lastValueFrom(this.hooks.callHooks("client-sceneMap-onBeforeLoading", this.sceneMap));
543
+
544
+ // Clear client prediction states when changing maps
545
+ this.clearClientPredictionStates();
546
+
547
+ // Reset all conditions for new map loading
548
+ this.mapLoadCompleted$.next(false);
549
+ this.playerIdReceived$.next(false);
550
+ this.playersReceived$.next(false);
551
+ this.eventsReceived$.next(false);
552
+
553
+ // Unsubscribe previous subscription if exists
554
+ if (this.onAfterLoadingSubscription) {
555
+ this.onAfterLoadingSubscription.unsubscribe();
556
+ }
557
+
558
+ // Setup RxJS observable to wait for all conditions
559
+ this.setupOnAfterLoadingObserver();
560
+
561
+ this.webSocket.updateProperties({
562
+ room: mapId,
563
+ query: transferToken ? { transferToken } : undefined,
564
+ })
86
565
  await this.webSocket.reconnect(() => {
566
+ const saveClient = inject(SaveClientService);
567
+ saveClient.initialize(this.webSocket);
87
568
  this.initListeners()
88
569
  this.guiService._initialize()
89
570
  })
90
571
  const res = await this.loadMapService.load(mapId)
91
572
  this.sceneMap.data.set(res)
92
- this.hooks.callHooks("client-sceneMap-onAfterLoading", this.sceneMap).subscribe();
93
- //this.sceneMap.loadPhysic()
573
+
574
+ // Check if playerId is already present
575
+ if (this.playerIdSignal()) {
576
+ this.playerIdReceived$.next(true);
577
+ }
578
+
579
+ // Check if players and events are already present in sceneMap
580
+ const players = this.sceneMap.players();
581
+ if (players && Object.keys(players).length > 0) {
582
+ this.playersReceived$.next(true);
583
+ }
584
+
585
+ const events = this.sceneMap.events();
586
+ if (events !== undefined) {
587
+ this.eventsReceived$.next(true);
588
+ }
589
+
590
+ // Signal that map loading is completed (this should be last to ensure other checks are done)
591
+ this.mapLoadCompleted$.next(true);
592
+ this.sceneMap.configureClientPrediction(this.predictionEnabled);
593
+ this.sceneMap.loadPhysic()
94
594
  }
95
595
 
96
596
  addSpriteSheet<T = any>(spritesheetClass: any, id?: string): any {
@@ -98,44 +598,1201 @@ export class RpgClientEngine<T = any> {
98
598
  return spritesheetClass as any;
99
599
  }
100
600
 
101
- addSound(sound: any, id?: string) {
102
- this.sounds.set(id || sound.id, sound);
601
+ /**
602
+ * Set a resolver function for spritesheets
603
+ *
604
+ * The resolver is called when a spritesheet is requested but not found in the cache.
605
+ * It can be synchronous (returns directly) or asynchronous (returns a Promise).
606
+ * The resolved spritesheet is automatically cached for future use.
607
+ *
608
+ * @param resolver - Function that takes a spritesheet ID and returns a spritesheet or Promise of spritesheet
609
+ *
610
+ * @example
611
+ * ```ts
612
+ * // Synchronous resolver
613
+ * engine.setSpritesheetResolver((id) => {
614
+ * if (id === 'dynamic-sprite') {
615
+ * return { id: 'dynamic-sprite', image: 'path/to/image.png', framesWidth: 32, framesHeight: 32 };
616
+ * }
617
+ * return undefined;
618
+ * });
619
+ *
620
+ * // Asynchronous resolver (loading from API)
621
+ * engine.setSpritesheetResolver(async (id) => {
622
+ * const response = await fetch(`/api/spritesheets/${id}`);
623
+ * const data = await response.json();
624
+ * return data;
625
+ * });
626
+ * ```
627
+ */
628
+ setSpritesheetResolver(resolver: (id: string) => any | Promise<any>): void {
629
+ this.spritesheetResolver = resolver;
630
+ }
631
+
632
+ /**
633
+ * Get a spritesheet by ID, using resolver if not found in cache
634
+ *
635
+ * This method first checks if the spritesheet exists in the cache.
636
+ * If not found and a resolver is set, it calls the resolver to create the spritesheet.
637
+ * The resolved spritesheet is automatically cached for future use.
638
+ *
639
+ * @param id - The spritesheet ID to retrieve
640
+ * @returns The spritesheet if found or created, or undefined if not found and no resolver
641
+ * @returns Promise<any> if the resolver is asynchronous
642
+ *
643
+ * @example
644
+ * ```ts
645
+ * // Synchronous usage
646
+ * const spritesheet = engine.getSpriteSheet('my-sprite');
647
+ *
648
+ * // Asynchronous usage (when resolver returns Promise)
649
+ * const spritesheet = await engine.getSpriteSheet('dynamic-sprite');
650
+ * ```
651
+ */
652
+ getSpriteSheet(id: string): any | Promise<any> {
653
+ // Check cache first
654
+ if (this.spritesheets.has(id)) {
655
+ return this.spritesheets.get(id);
656
+ }
657
+
658
+ // If not in cache and resolver exists, use it
659
+ if (this.spritesheetResolver) {
660
+ const result = this.spritesheetResolver(id);
661
+
662
+ // Check if result is a Promise
663
+ if (result instanceof Promise) {
664
+ return result.then((spritesheet) => {
665
+ if (spritesheet) {
666
+ // Cache the resolved spritesheet
667
+ this.spritesheets.set(id, spritesheet);
668
+ }
669
+ return spritesheet;
670
+ });
671
+ } else {
672
+ // Synchronous result
673
+ if (result) {
674
+ // Cache the resolved spritesheet
675
+ this.spritesheets.set(id, result);
676
+ }
677
+ return result;
678
+ }
679
+ }
680
+
681
+ // No resolver and not in cache
682
+ return undefined;
683
+ }
684
+
685
+ /**
686
+ * Add a sound to the engine
687
+ *
688
+ * Adds a sound to the engine's sound cache. The sound can be:
689
+ * - A simple object with `id` and `src` properties
690
+ * - A Howler instance
691
+ * - An object with a `play()` method
692
+ *
693
+ * If the sound has a `src` property, a Howler instance will be created automatically.
694
+ *
695
+ * @param sound - The sound object or Howler instance
696
+ * @param id - Optional sound ID (if not provided, uses sound.id)
697
+ * @returns The added sound
698
+ *
699
+ * @example
700
+ * ```ts
701
+ * // Simple sound object
702
+ * engine.addSound({ id: 'click', src: 'click.mp3' });
703
+ *
704
+ * // With explicit ID
705
+ * engine.addSound({ src: 'music.mp3' }, 'background-music');
706
+ * ```
707
+ */
708
+ addSound(sound: any, id?: string): any {
709
+ const soundId = id || sound.id;
710
+
711
+ if (!soundId) {
712
+ console.warn('Sound added without an ID. It will not be retrievable.');
713
+ return sound;
714
+ }
715
+
716
+ // If sound has a src property, create a Howler instance
717
+ if (sound.src && typeof sound.src === 'string') {
718
+ const howlOptions: any = {
719
+ src: [sound.src],
720
+ loop: sound.loop || false,
721
+ volume: sound.volume !== undefined ? sound.volume : 1.0,
722
+ };
723
+
724
+ const howl = new (Howl as any).Howl(howlOptions);
725
+ this.sounds.set(soundId, howl);
726
+ return howl;
727
+ }
728
+
729
+ // If sound already has a play method (Howler instance or custom), use it directly
730
+ if (sound && typeof sound.play === 'function') {
731
+ this.sounds.set(soundId, sound);
732
+ return sound;
733
+ }
734
+
735
+ // Otherwise, store as-is
736
+ this.sounds.set(soundId, sound);
103
737
  return sound;
104
738
  }
105
739
 
740
+ /**
741
+ * Set a resolver function for sounds
742
+ *
743
+ * The resolver is called when a sound is requested but not found in the cache.
744
+ * It can be synchronous (returns directly) or asynchronous (returns a Promise).
745
+ * The resolved sound is automatically cached for future use.
746
+ *
747
+ * @param resolver - Function that takes a sound ID and returns a sound or Promise of sound
748
+ *
749
+ * @example
750
+ * ```ts
751
+ * // Synchronous resolver
752
+ * engine.setSoundResolver((id) => {
753
+ * if (id === 'dynamic-sound') {
754
+ * return { id: 'dynamic-sound', src: 'path/to/sound.mp3' };
755
+ * }
756
+ * return undefined;
757
+ * });
758
+ *
759
+ * // Asynchronous resolver (loading from API)
760
+ * engine.setSoundResolver(async (id) => {
761
+ * const response = await fetch(`/api/sounds/${id}`);
762
+ * const data = await response.json();
763
+ * return data;
764
+ * });
765
+ * ```
766
+ */
767
+ setSoundResolver(resolver: (id: string) => any | Promise<any>): void {
768
+ this.soundResolver = resolver;
769
+ }
770
+
771
+ /**
772
+ * Get a sound by ID, using resolver if not found in cache
773
+ *
774
+ * This method first checks if the sound exists in the cache.
775
+ * If not found and a resolver is set, it calls the resolver to create the sound.
776
+ * The resolved sound is automatically cached for future use.
777
+ *
778
+ * @param id - The sound ID to retrieve
779
+ * @returns The sound if found or created, or undefined if not found and no resolver
780
+ * @returns Promise<any> if the resolver is asynchronous
781
+ *
782
+ * @example
783
+ * ```ts
784
+ * // Synchronous usage
785
+ * const sound = engine.getSound('my-sound');
786
+ *
787
+ * // Asynchronous usage (when resolver returns Promise)
788
+ * const sound = await engine.getSound('dynamic-sound');
789
+ * ```
790
+ */
791
+ getSound(id: string): any | Promise<any> {
792
+ // Check cache first
793
+ if (this.sounds.has(id)) {
794
+ return this.sounds.get(id);
795
+ }
796
+
797
+ // If not in cache and resolver exists, use it
798
+ if (this.soundResolver) {
799
+ const result = this.soundResolver(id);
800
+
801
+ // Check if result is a Promise
802
+ if (result instanceof Promise) {
803
+ return result.then((sound) => {
804
+ if (sound) {
805
+ // Cache the resolved sound
806
+ this.sounds.set(id, sound);
807
+ }
808
+ return sound;
809
+ });
810
+ } else {
811
+ // Synchronous result
812
+ if (result) {
813
+ // Cache the resolved sound
814
+ this.sounds.set(id, result);
815
+ }
816
+ return result;
817
+ }
818
+ }
819
+
820
+ // No resolver and not in cache
821
+ return undefined;
822
+ }
823
+
824
+ /**
825
+ * Play a sound by its ID
826
+ *
827
+ * This method retrieves a sound from the cache or resolver and plays it.
828
+ * If the sound is not found, it will attempt to resolve it using the soundResolver.
829
+ * Uses Howler.js for audio playback instead of native Audio elements.
830
+ *
831
+ * @param soundId - The sound ID to play
832
+ * @param options - Optional sound configuration
833
+ * @param options.volume - Volume level (0.0 to 1.0, overrides sound default)
834
+ * @param options.loop - Whether the sound should loop (overrides sound default)
835
+ *
836
+ * @example
837
+ * ```ts
838
+ * // Play a sound synchronously
839
+ * engine.playSound('item-pickup');
840
+ *
841
+ * // Play a sound with volume and loop
842
+ * engine.playSound('background-music', { volume: 0.5, loop: true });
843
+ *
844
+ * // Play a sound asynchronously (when resolver returns Promise)
845
+ * await engine.playSound('dynamic-sound', { volume: 0.8 });
846
+ * ```
847
+ */
848
+ async playSound(soundId: string, options?: { volume?: number; loop?: boolean }): Promise<void> {
849
+ const sound = await this.getSound(soundId);
850
+ if (sound && sound.play) {
851
+ // Sound is already a Howler instance or has a play method
852
+ const howlSoundId = sound._sounds?.[0]?._id;
853
+
854
+ // Apply volume if provided
855
+ if (options?.volume !== undefined) {
856
+ if (howlSoundId !== undefined) {
857
+ sound.volume(Math.max(0, Math.min(1, options.volume)), howlSoundId);
858
+ } else {
859
+ sound.volume(Math.max(0, Math.min(1, options.volume)));
860
+ }
861
+ }
862
+
863
+ // Apply loop if provided
864
+ if (options?.loop !== undefined) {
865
+ if (howlSoundId !== undefined) {
866
+ sound.loop(options.loop, howlSoundId);
867
+ } else {
868
+ sound.loop(options.loop);
869
+ }
870
+ }
871
+
872
+ if (howlSoundId !== undefined) {
873
+ sound.play(howlSoundId);
874
+ } else {
875
+ sound.play();
876
+ }
877
+ } else if (sound && sound.src) {
878
+ // If sound is just a source URL, create a Howler instance and cache it
879
+ const howlOptions: any = {
880
+ src: [sound.src],
881
+ loop: options?.loop !== undefined ? options.loop : (sound.loop || false),
882
+ volume: options?.volume !== undefined ? Math.max(0, Math.min(1, options.volume)) : (sound.volume !== undefined ? sound.volume : 1.0),
883
+ };
884
+
885
+ const howl = new (Howl as any).Howl(howlOptions);
886
+
887
+ // Cache the Howler instance for future use
888
+ this.sounds.set(soundId, howl);
889
+
890
+ // Play the sound
891
+ howl.play();
892
+ } else {
893
+ console.warn(`Sound with id "${soundId}" not found or cannot be played`);
894
+ }
895
+ }
896
+
897
+ /**
898
+ * Stop a sound that is currently playing
899
+ *
900
+ * This method stops a sound that was previously started with `playSound()`.
901
+ *
902
+ * @param soundId - The sound ID to stop
903
+ *
904
+ * @example
905
+ * ```ts
906
+ * // Start a looping sound
907
+ * engine.playSound('background-music', { loop: true });
908
+ *
909
+ * // Later, stop it
910
+ * engine.stopSound('background-music');
911
+ * ```
912
+ */
913
+ stopSound(soundId: string): void {
914
+ const sound = this.sounds.get(soundId);
915
+ if (sound && sound.stop) {
916
+ sound.stop();
917
+ } else {
918
+ console.warn(`Sound with id "${soundId}" not found or cannot be stopped`);
919
+ }
920
+ }
921
+
922
+ /**
923
+ * Stop all currently playing sounds
924
+ *
925
+ * This method stops all sounds that are currently playing.
926
+ * Useful when changing maps to prevent sound overlap.
927
+ *
928
+ * @example
929
+ * ```ts
930
+ * // Stop all sounds
931
+ * engine.stopAllSounds();
932
+ * ```
933
+ */
934
+ stopAllSounds(): void {
935
+ this.sounds.forEach((sound) => {
936
+ if (sound && sound.stop) {
937
+ sound.stop();
938
+ }
939
+ });
940
+ }
941
+
942
+ /**
943
+ * Set the camera to follow a specific sprite
944
+ *
945
+ * This method changes which sprite the camera viewport should follow.
946
+ * The camera will smoothly animate to the target sprite if smoothMove options are provided.
947
+ *
948
+ * ## Design
949
+ *
950
+ * The camera follow target is stored in a signal that is read by sprite components.
951
+ * Each sprite checks if it should be followed by comparing its ID with the target ID.
952
+ * When smoothMove options are provided, the viewport animation is handled by CanvasEngine's
953
+ * viewport system.
954
+ *
955
+ * @param targetId - The ID of the sprite to follow. Set to null to follow the current player
956
+ * @param smoothMove - Animation options. Can be a boolean (default: true) or an object with time and ease
957
+ * @param smoothMove.time - Duration of the animation in milliseconds (optional)
958
+ * @param smoothMove.ease - Easing function name from https://easings.net (optional)
959
+ *
960
+ * @example
961
+ * ```ts
962
+ * // Follow another player with default smooth animation
963
+ * engine.setCameraFollow(otherPlayerId, true);
964
+ *
965
+ * // Follow an event with custom smooth animation
966
+ * engine.setCameraFollow(eventId, {
967
+ * time: 1000,
968
+ * ease: "easeInOutQuad"
969
+ * });
970
+ *
971
+ * // Follow without animation (instant)
972
+ * engine.setCameraFollow(targetId, false);
973
+ *
974
+ * // Return to following current player
975
+ * engine.setCameraFollow(null);
976
+ * ```
977
+ */
978
+ setCameraFollow(
979
+ targetId: string | null,
980
+ smoothMove?: boolean | { time?: number; ease?: string }
981
+ ): void {
982
+ // Store smoothMove options for potential future use with viewport animation
983
+ // For now, we just set the target ID and let CanvasEngine handle the viewport follow
984
+ // The smoothMove options could be used to configure viewport animation if CanvasEngine supports it
985
+ this.cameraFollowTargetId.set(targetId);
986
+
987
+ // If smoothMove is an object, we could store it for viewport configuration
988
+ // This would require integration with CanvasEngine's viewport animation system
989
+ if (typeof smoothMove === "object" && smoothMove !== null) {
990
+ // Future: Apply smoothMove.time and smoothMove.ease to viewport animation
991
+ // For now, CanvasEngine handles viewport following automatically
992
+ }
993
+ }
994
+
106
995
  addParticle(particle: any) {
107
996
  this.particleSettings.emitters.push(particle)
108
997
  return particle;
109
998
  }
110
999
 
111
- addEffect(effect: {
1000
+ /**
1001
+ * Add a component to render behind sprites
1002
+ * Components added with this method will be displayed with a lower z-index than the sprite
1003
+ *
1004
+ * Supports multiple formats:
1005
+ * 1. Direct component: `ShadowComponent`
1006
+ * 2. Configuration object: `{ component: LightHalo, props: {...} }`
1007
+ * 3. With dynamic props: `{ component: LightHalo, props: (object) => {...} }`
1008
+ * 4. With dependencies: `{ component: HealthBar, dependencies: (object) => [object.hp, object.param.maxHp] }`
1009
+ *
1010
+ * Components with dependencies will only be displayed when all dependencies are resolved (!= undefined).
1011
+ * The object (sprite) is passed to the dependencies function to allow sprite-specific dependency resolution.
1012
+ *
1013
+ * @param component - The component to add behind sprites, or a configuration object
1014
+ * @param component.component - The component function to render
1015
+ * @param component.props - Static props object or function that receives the sprite object and returns props
1016
+ * @param component.dependencies - Function that receives the sprite object and returns an array of Signals
1017
+ * @returns The added component or configuration
1018
+ *
1019
+ * @example
1020
+ * ```ts
1021
+ * // Add a shadow component behind all sprites
1022
+ * engine.addSpriteComponentBehind(ShadowComponent);
1023
+ *
1024
+ * // Add a component with static props
1025
+ * engine.addSpriteComponentBehind({
1026
+ * component: LightHalo,
1027
+ * props: { radius: 30 }
1028
+ * });
1029
+ *
1030
+ * // Add a component with dynamic props and dependencies
1031
+ * engine.addSpriteComponentBehind({
1032
+ * component: HealthBar,
1033
+ * props: (object) => ({ hp: object.hp(), maxHp: object.param.maxHp() }),
1034
+ * dependencies: (object) => [object.hp, object.param.maxHp]
1035
+ * });
1036
+ * ```
1037
+ */
1038
+ addSpriteComponentBehind(component: any) {
1039
+ this.spriteComponentsBehind.update((components: any[]) => [...components, component])
1040
+ return component
1041
+ }
1042
+
1043
+ /**
1044
+ * Add a component to render in front of sprites
1045
+ * Components added with this method will be displayed with a higher z-index than the sprite
1046
+ *
1047
+ * Supports multiple formats:
1048
+ * 1. Direct component: `HealthBarComponent`
1049
+ * 2. Configuration object: `{ component: StatusIndicator, props: {...} }`
1050
+ * 3. With dynamic props: `{ component: HealthBar, props: (object) => {...} }`
1051
+ * 4. With dependencies: `{ component: HealthBar, dependencies: (object) => [object.hp, object.param.maxHp] }`
1052
+ *
1053
+ * Components with dependencies will only be displayed when all dependencies are resolved (!= undefined).
1054
+ * The object (sprite) is passed to the dependencies function to allow sprite-specific dependency resolution.
1055
+ *
1056
+ * @param component - The component to add in front of sprites, or a configuration object
1057
+ * @param component.component - The component function to render
1058
+ * @param component.props - Static props object or function that receives the sprite object and returns props
1059
+ * @param component.dependencies - Function that receives the sprite object and returns an array of Signals
1060
+ * @returns The added component or configuration
1061
+ *
1062
+ * @example
1063
+ * ```ts
1064
+ * // Add a health bar component in front of all sprites
1065
+ * engine.addSpriteComponentInFront(HealthBarComponent);
1066
+ *
1067
+ * // Add a component with static props
1068
+ * engine.addSpriteComponentInFront({
1069
+ * component: StatusIndicator,
1070
+ * props: { type: 'poison' }
1071
+ * });
1072
+ *
1073
+ * // Add a component with dynamic props and dependencies
1074
+ * engine.addSpriteComponentInFront({
1075
+ * component: HealthBar,
1076
+ * props: (object) => ({ hp: object.hp(), maxHp: object.param.maxHp() }),
1077
+ * dependencies: (object) => [object.hp, object.param.maxHp]
1078
+ * });
1079
+ * ```
1080
+ */
1081
+ addSpriteComponentInFront(component: any | { component: any, props: (object: any) => any, dependencies?: (object: any) => any[] }) {
1082
+ this.spriteComponentsInFront.update((components: any[]) => [...components, component])
1083
+ return component
1084
+ }
1085
+
1086
+ /**
1087
+ * Add a component animation to the engine
1088
+ *
1089
+ * Component animations are temporary visual effects that can be displayed
1090
+ * on sprites or objects, such as hit indicators, spell effects, or status animations.
1091
+ *
1092
+ * @param componentAnimation - The component animation configuration
1093
+ * @param componentAnimation.id - Unique identifier for the animation
1094
+ * @param componentAnimation.component - The component function to render
1095
+ * @returns The added component animation configuration
1096
+ *
1097
+ * @example
1098
+ * ```ts
1099
+ * // Add a hit animation component
1100
+ * engine.addComponentAnimation({
1101
+ * id: 'hit',
1102
+ * component: HitComponent
1103
+ * });
1104
+ *
1105
+ * // Add an explosion effect component
1106
+ * engine.addComponentAnimation({
1107
+ * id: 'explosion',
1108
+ * component: ExplosionComponent
1109
+ * });
1110
+ * ```
1111
+ */
1112
+ addComponentAnimation(componentAnimation: {
112
1113
  component: any,
113
1114
  id: string
114
1115
  }) {
115
- const instance = new EffectManager()
116
- this.effects.push({
117
- id: effect.id,
118
- component: effect.component,
1116
+ const instance = new AnimationManager()
1117
+ this.componentAnimations.push({
1118
+ id: componentAnimation.id,
1119
+ component: componentAnimation.component,
119
1120
  instance: instance,
120
1121
  current: instance.current
121
1122
  })
122
- return effect;
1123
+ return componentAnimation;
123
1124
  }
124
1125
 
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`)
1126
+ /**
1127
+ * Get a component animation by its ID
1128
+ *
1129
+ * Retrieves the EffectManager instance for a specific component animation,
1130
+ * which can be used to display the animation on sprites or objects.
1131
+ *
1132
+ * @param id - The unique identifier of the component animation
1133
+ * @returns The EffectManager instance for the animation
1134
+ * @throws Error if the component animation is not found
1135
+ *
1136
+ * @example
1137
+ * ```ts
1138
+ * // Get the hit animation and display it
1139
+ * const hitAnimation = engine.getComponentAnimation('hit');
1140
+ * hitAnimation.displayEffect({ text: "Critical!" }, player);
1141
+ * ```
1142
+ */
1143
+ getComponentAnimation(id: string): AnimationManager {
1144
+ const componentAnimation = this.componentAnimations.find((componentAnimation) => componentAnimation.id === id)
1145
+ if (!componentAnimation) {
1146
+ throw new Error(`Component animation with id ${id} not found`)
129
1147
  }
130
- return effect.instance
1148
+ return componentAnimation.instance
131
1149
  }
132
1150
 
133
- processInput({ input }: { input: number }) {
134
- this.webSocket.emit('move', { input })
1151
+ /**
1152
+ * Start a transition
1153
+ *
1154
+ * Convenience method to display a transition by its ID using the GUI system.
1155
+ *
1156
+ * @param id - The unique identifier of the transition to start
1157
+ * @param props - Props to pass to the transition component
1158
+ *
1159
+ * @example
1160
+ * ```ts
1161
+ * // Start a fade transition
1162
+ * engine.startTransition('fade', { duration: 1000, color: 'black' });
1163
+ *
1164
+ * // Start with onFinish callback
1165
+ * engine.startTransition('fade', {
1166
+ * duration: 1000,
1167
+ * onFinish: () => console.log('Fade complete')
1168
+ * });
1169
+ * ```
1170
+ */
1171
+ startTransition(id: string, props: any = {}) {
1172
+ if (!this.guiService.exists(id)) {
1173
+ throw new Error(`Transition with id ${id} not found. Make sure to add it using engine.addTransition() or in your module's transitions property.`);
1174
+ }
1175
+ this.guiService.display(id, props);
1176
+ }
1177
+
1178
+ async processInput({ input }: { input: Direction }) {
1179
+ const timestamp = Date.now();
1180
+ let frame: number;
1181
+ let tick: number;
1182
+ if (this.predictionEnabled && this.prediction) {
1183
+ const meta = this.prediction.recordInput(input, timestamp);
1184
+ frame = meta.frame;
1185
+ tick = meta.tick;
1186
+ } else {
1187
+ frame = ++this.inputFrameCounter;
1188
+ tick = this.getPhysicsTick();
1189
+ }
1190
+ this.inputFrameCounter = frame;
1191
+ this.hooks.callHooks("client-engine-onInput", this, { input, playerId: this.playerId }).subscribe();
1192
+
1193
+ const currentPlayer = this.sceneMap.getCurrentPlayer();
1194
+ const bodyReady = this.ensureCurrentPlayerBody();
1195
+ if (currentPlayer && bodyReady) {
1196
+ currentPlayer.changeDirection(input);
1197
+ (this.sceneMap as any).moveBody(currentPlayer, input);
1198
+ if (this.predictionEnabled && this.prediction) {
1199
+ this.pendingPredictionFrames.push(frame);
1200
+ if (this.pendingPredictionFrames.length > 240) {
1201
+ this.pendingPredictionFrames = this.pendingPredictionFrames.slice(-240);
1202
+ }
1203
+ }
1204
+ }
1205
+
1206
+ this.emitMovePacket(input, frame, tick, timestamp, true);
1207
+ this.lastInputTime = Date.now();
135
1208
  }
136
1209
 
137
1210
  processAction({ action }: { action: number }) {
138
1211
  if (this.stopProcessingInput) return;
1212
+ this.hooks.callHooks("client-engine-onInput", this, { input: 'action', playerId: this.playerId }).subscribe();
139
1213
  this.webSocket.emit('action', { action })
140
1214
  }
1215
+
1216
+ get PIXI() {
1217
+ return PIXI
1218
+ }
1219
+
1220
+ get socket() {
1221
+ return this.webSocket
1222
+ }
1223
+
1224
+ get playerId() {
1225
+ return this.playerIdSignal()
1226
+ }
1227
+
1228
+ get scene() {
1229
+ return this.sceneMap
1230
+ }
1231
+
1232
+ private getPhysicsTick(): number {
1233
+ return this.sceneMap?.getTick?.() ?? 0;
1234
+ }
1235
+
1236
+ private ensureCurrentPlayerBody(): boolean {
1237
+ const player = this.sceneMap?.getCurrentPlayer();
1238
+ const myId = this.playerIdSignal();
1239
+ if (!player || !myId) {
1240
+ return false;
1241
+ }
1242
+ if (!player.id) {
1243
+ player.id = myId;
1244
+ }
1245
+ if (this.sceneMap.getBody(myId)) {
1246
+ return true;
1247
+ }
1248
+ try {
1249
+ this.sceneMap.loadPhysic();
1250
+ } catch (error) {
1251
+ console.error("[RPGJS] Unable to initialize client physics before input:", error);
1252
+ return false;
1253
+ }
1254
+ return !!this.sceneMap.getBody(myId);
1255
+ }
1256
+
1257
+ private stepClientPhysicsTick(): void {
1258
+ if (!this.predictionEnabled || !this.sceneMap) {
1259
+ return;
1260
+ }
1261
+ const now = Date.now();
1262
+ if (this.lastClientPhysicsStepAt === 0) {
1263
+ this.lastClientPhysicsStepAt = now;
1264
+ }
1265
+ const deltaMs = Math.max(1, Math.min(100, now - this.lastClientPhysicsStepAt));
1266
+ this.lastClientPhysicsStepAt = now;
1267
+ this.sceneMap.stepClientPhysics(deltaMs);
1268
+ }
1269
+
1270
+ private flushPendingPredictedStates(): void {
1271
+ if (!this.predictionEnabled || !this.prediction || this.pendingPredictionFrames.length === 0) {
1272
+ return;
1273
+ }
1274
+ const state = this.getLocalPlayerState();
1275
+ while (this.pendingPredictionFrames.length > 0) {
1276
+ const frame = this.pendingPredictionFrames.shift();
1277
+ if (typeof frame === "number") {
1278
+ this.prediction.attachPredictedState(frame, state);
1279
+ }
1280
+ }
1281
+ }
1282
+
1283
+ private buildPendingMoveTrajectory(): MovementTrajectoryPoint[] {
1284
+ if (!this.predictionEnabled || !this.prediction) {
1285
+ return [];
1286
+ }
1287
+ const pendingInputs = this.prediction.getPendingInputs();
1288
+ const trajectory: MovementTrajectoryPoint[] = [];
1289
+ for (const entry of pendingInputs) {
1290
+ const state = entry.state;
1291
+ if (!state) continue;
1292
+ if (typeof state.x !== "number" || typeof state.y !== "number") continue;
1293
+ trajectory.push({
1294
+ frame: entry.frame,
1295
+ tick: entry.tick,
1296
+ timestamp: entry.timestamp,
1297
+ input: entry.direction,
1298
+ x: state.x,
1299
+ y: state.y,
1300
+ direction: state.direction ?? entry.direction,
1301
+ });
1302
+ }
1303
+ if (trajectory.length > this.MAX_MOVE_TRAJECTORY_POINTS) {
1304
+ return trajectory.slice(-this.MAX_MOVE_TRAJECTORY_POINTS);
1305
+ }
1306
+ return trajectory;
1307
+ }
1308
+
1309
+ private emitMovePacket(
1310
+ input: Direction,
1311
+ frame: number,
1312
+ tick: number,
1313
+ timestamp: number,
1314
+ force = false,
1315
+ ): void {
1316
+ const trajectory = this.buildPendingMoveTrajectory();
1317
+ const latestTrajectoryFrame =
1318
+ trajectory.length > 0 ? trajectory[trajectory.length - 1].frame : frame;
1319
+ const shouldThrottle =
1320
+ !force &&
1321
+ latestTrajectoryFrame <= this.lastMovePathSentFrame &&
1322
+ timestamp - this.lastMovePathSentAt < this.MOVE_PATH_RESEND_INTERVAL_MS;
1323
+ if (shouldThrottle) {
1324
+ return;
1325
+ }
1326
+
1327
+ this.webSocket.emit("move", {
1328
+ input,
1329
+ timestamp,
1330
+ frame,
1331
+ tick,
1332
+ trajectory,
1333
+ });
1334
+ this.lastMovePathSentAt = timestamp;
1335
+ this.lastMovePathSentFrame = Math.max(this.lastMovePathSentFrame, latestTrajectoryFrame, frame);
1336
+ }
1337
+
1338
+ private flushPendingMovePath(): void {
1339
+ if (!this.predictionEnabled || !this.prediction) {
1340
+ return;
1341
+ }
1342
+ const pendingInputs = this.prediction.getPendingInputs();
1343
+ if (pendingInputs.length === 0) {
1344
+ return;
1345
+ }
1346
+ const latest = pendingInputs[pendingInputs.length - 1];
1347
+ if (!latest) {
1348
+ return;
1349
+ }
1350
+ const now = Date.now();
1351
+ if (now - this.lastMovePathSentAt < this.MOVE_PATH_RESEND_INTERVAL_MS) {
1352
+ return;
1353
+ }
1354
+ this.emitMovePacket(latest.direction, latest.frame, latest.tick, now, false);
1355
+ }
1356
+
1357
+ private getLocalPlayerState(): PredictionState<Direction> {
1358
+ const currentPlayer = this.sceneMap?.getCurrentPlayer();
1359
+ if (!currentPlayer) {
1360
+ return { x: 0, y: 0, direction: Direction.Down };
1361
+ }
1362
+ const topLeft = this.sceneMap.getBodyPosition(currentPlayer.id, "top-left");
1363
+ const x = topLeft?.x ?? currentPlayer.x();
1364
+ const y = topLeft?.y ?? currentPlayer.y();
1365
+ const direction = currentPlayer.direction();
1366
+ return { x, y, direction };
1367
+ }
1368
+
1369
+ private applyAuthoritativeState(state: PredictionState<Direction>): void {
1370
+ const player = this.sceneMap?.getCurrentPlayer();
1371
+ if (!player) return;
1372
+ const hitbox = typeof player.hitbox === "function" ? player.hitbox() : player.hitbox;
1373
+ const width = hitbox?.w ?? 0;
1374
+ const height = hitbox?.h ?? 0;
1375
+ const updated = this.sceneMap.updateHitbox(player.id, state.x, state.y, width, height);
1376
+ if (!updated) {
1377
+ this.sceneMap.setBodyPosition(player.id, state.x, state.y, "top-left");
1378
+ }
1379
+ player.x.set(Math.round(state.x));
1380
+ player.y.set(Math.round(state.y));
1381
+ if (state.direction) {
1382
+ player.changeDirection(state.direction);
1383
+ }
1384
+ }
1385
+
1386
+ private initializePredictionController(): void {
1387
+ if (!this.predictionEnabled) {
1388
+ this.prediction = undefined;
1389
+ this.sceneMap?.configureClientPrediction?.(false);
1390
+ return;
1391
+ }
1392
+ const configuredTtl = (this.globalConfig as any)?.prediction?.historyTtlMs;
1393
+ const historyTtlMs = typeof configuredTtl === "number" ? configuredTtl : 10000;
1394
+ const configuredMaxEntries = (this.globalConfig as any)?.prediction?.maxHistoryEntries;
1395
+ const maxHistoryEntries =
1396
+ typeof configuredMaxEntries === "number"
1397
+ ? configuredMaxEntries
1398
+ : Math.max(600, Math.ceil(historyTtlMs / 16) + 120);
1399
+ this.sceneMap?.configureClientPrediction?.(true);
1400
+ this.prediction = new PredictionController<Direction>({
1401
+ correctionThreshold: (this.globalConfig as any)?.prediction?.correctionThreshold ?? this.SERVER_CORRECTION_THRESHOLD,
1402
+ historyTtlMs,
1403
+ maxHistoryEntries,
1404
+ getPhysicsTick: () => this.getPhysicsTick(),
1405
+ getCurrentState: () => this.getLocalPlayerState(),
1406
+ setAuthoritativeState: (state) => this.applyAuthoritativeState(state),
1407
+ });
1408
+ }
1409
+
1410
+ getCurrentPlayer() {
1411
+ return this.sceneMap.getCurrentPlayer()
1412
+ }
1413
+
1414
+ /**
1415
+ * Setup RxJS observer to wait for all conditions before calling onAfterLoading hook
1416
+ *
1417
+ * This method uses RxJS `combineLatest` to wait for all conditions to be met,
1418
+ * regardless of the order in which they arrive:
1419
+ * 1. The map loading is completed (loadMapService.load is finished)
1420
+ * 2. We received a player ID (pId)
1421
+ * 3. Players array has at least one element
1422
+ * 4. Events property is present in the sync data
1423
+ *
1424
+ * Once all conditions are met, it uses `switchMap` to call the onAfterLoading hook once.
1425
+ *
1426
+ * ## Design
1427
+ *
1428
+ * Uses BehaviorSubjects to track each condition state, allowing events to arrive
1429
+ * in any order. The `combineLatest` operator waits until all observables emit `true`,
1430
+ * then `take(1)` ensures the hook is called only once, and `switchMap` handles
1431
+ * the hook execution.
1432
+ *
1433
+ * @example
1434
+ * ```ts
1435
+ * // Called automatically in loadScene to setup the observer
1436
+ * this.setupOnAfterLoadingObserver();
1437
+ * ```
1438
+ */
1439
+ private setupOnAfterLoadingObserver(): void {
1440
+ this.onAfterLoadingSubscription = combineLatest([
1441
+ this.mapLoadCompleted$.pipe(filter(completed => completed === true)),
1442
+ this.playerIdReceived$.pipe(filter(received => received === true)),
1443
+ this.playersReceived$.pipe(filter(received => received === true)),
1444
+ this.eventsReceived$.pipe(filter(received => received === true))
1445
+ ]).pipe(
1446
+ take(1), // Only execute once when all conditions are met
1447
+ switchMap(() => {
1448
+ // Call the hook and return the observable
1449
+ return this.hooks.callHooks("client-sceneMap-onAfterLoading", this.sceneMap);
1450
+ })
1451
+ ).subscribe();
1452
+ }
1453
+
1454
+ /**
1455
+ * Clear client prediction states for cleanup
1456
+ *
1457
+ * Removes old prediction states and input history to prevent memory leaks.
1458
+ * Should be called when changing maps or disconnecting.
1459
+ *
1460
+ * @example
1461
+ * ```ts
1462
+ * // Clear prediction states when changing maps
1463
+ * engine.clearClientPredictionStates();
1464
+ * ```
1465
+ */
1466
+ clearClientPredictionStates() {
1467
+ this.initializePredictionController();
1468
+ this.frameOffset = 0;
1469
+ this.inputFrameCounter = 0;
1470
+ this.pendingPredictionFrames = [];
1471
+ this.lastClientPhysicsStepAt = 0;
1472
+ this.lastMovePathSentAt = 0;
1473
+ this.lastMovePathSentFrame = 0;
1474
+ }
1475
+
1476
+ /**
1477
+ * Trigger a flash animation on a sprite
1478
+ *
1479
+ * This method allows you to trigger a flash effect on any sprite from client-side code.
1480
+ * The flash can be configured with various options including type (alpha, tint, or both),
1481
+ * duration, cycles, and color.
1482
+ *
1483
+ * ## Design
1484
+ *
1485
+ * The flash is applied directly to the sprite object using its flash trigger.
1486
+ * This is useful for client-side visual feedback, UI interactions, or local effects
1487
+ * that don't need to be synchronized with the server.
1488
+ *
1489
+ * @param spriteId - The ID of the sprite to flash. If not provided, flashes the current player
1490
+ * @param options - Flash configuration options
1491
+ * @param options.type - Type of flash effect: 'alpha' (opacity), 'tint' (color), or 'both' (default: 'alpha')
1492
+ * @param options.duration - Duration of the flash animation in milliseconds (default: 300)
1493
+ * @param options.cycles - Number of flash cycles (flash on/off) (default: 1)
1494
+ * @param options.alpha - Alpha value when flashing, from 0 to 1 (default: 0.3)
1495
+ * @param options.tint - Tint color when flashing as hex value or color name (default: 0xffffff - white)
1496
+ *
1497
+ * @example
1498
+ * ```ts
1499
+ * // Flash the current player with default settings
1500
+ * engine.flash();
1501
+ *
1502
+ * // Flash a specific sprite with red tint
1503
+ * engine.flash('sprite-id', { type: 'tint', tint: 0xff0000 });
1504
+ *
1505
+ * // Flash with both alpha and tint for dramatic effect
1506
+ * engine.flash(undefined, {
1507
+ * type: 'both',
1508
+ * alpha: 0.5,
1509
+ * tint: 0xff0000,
1510
+ * duration: 200,
1511
+ * cycles: 2
1512
+ * });
1513
+ *
1514
+ * // Quick damage flash on current player
1515
+ * engine.flash(undefined, {
1516
+ * type: 'tint',
1517
+ * tint: 'red',
1518
+ * duration: 150,
1519
+ * cycles: 1
1520
+ * });
1521
+ * ```
1522
+ */
1523
+ flash(
1524
+ spriteId?: string,
1525
+ options?: {
1526
+ type?: 'alpha' | 'tint' | 'both';
1527
+ duration?: number;
1528
+ cycles?: number;
1529
+ alpha?: number;
1530
+ tint?: number | string;
1531
+ }
1532
+ ): void {
1533
+ const targetId = spriteId || this.playerId;
1534
+ if (!targetId) return;
1535
+
1536
+ const sprite = this.sceneMap.getObjectById(targetId);
1537
+ if (sprite && typeof sprite.flash === 'function') {
1538
+ sprite.flash(options);
1539
+ }
1540
+ }
1541
+
1542
+ private applyServerAck(ack: { frame: number; serverTick?: number; x?: number; y?: number; direction?: Direction }) {
1543
+ if (this.predictionEnabled && this.prediction) {
1544
+ const result = this.prediction.applyServerAck({
1545
+ frame: ack.frame,
1546
+ serverTick: ack.serverTick,
1547
+ state:
1548
+ typeof ack.x === "number" && typeof ack.y === "number"
1549
+ ? { x: ack.x, y: ack.y, direction: ack.direction }
1550
+ : undefined,
1551
+ });
1552
+ if (result.state && result.needsReconciliation) {
1553
+ this.reconcilePrediction(result.state, result.pendingInputs);
1554
+ }
1555
+ return;
1556
+ }
1557
+
1558
+ if (typeof ack.x !== "number" || typeof ack.y !== "number") {
1559
+ return;
1560
+ }
1561
+ const player = this.getCurrentPlayer();
1562
+ const myId = this.playerIdSignal();
1563
+ if (!player || !myId) {
1564
+ return;
1565
+ }
1566
+ const hitbox = typeof player.hitbox === "function" ? player.hitbox() : player.hitbox;
1567
+ const width = hitbox?.w ?? 0;
1568
+ const height = hitbox?.h ?? 0;
1569
+ const updated = this.sceneMap.updateHitbox(myId, ack.x, ack.y, width, height);
1570
+ if (!updated) {
1571
+ this.sceneMap.setBodyPosition(myId, ack.x, ack.y, "top-left");
1572
+ }
1573
+ player.x.set(Math.round(ack.x));
1574
+ player.y.set(Math.round(ack.y));
1575
+ if (ack.direction) {
1576
+ player.changeDirection(ack.direction);
1577
+ }
1578
+ }
1579
+
1580
+ private reconcilePrediction(
1581
+ authoritativeState: PredictionState<Direction>,
1582
+ pendingInputs: PredictionHistoryEntry<Direction>[],
1583
+ ): void {
1584
+ const player = this.getCurrentPlayer();
1585
+ if (!player) {
1586
+ return;
1587
+ }
1588
+
1589
+ (this.sceneMap as any).stopMovement(player);
1590
+ this.applyAuthoritativeState(authoritativeState);
1591
+
1592
+ if (!pendingInputs.length) {
1593
+ return;
1594
+ }
1595
+
1596
+ // Keep replay bounded while still tolerating high-latency links.
1597
+ const replayInputs = pendingInputs.slice(-600);
1598
+ for (const entry of replayInputs) {
1599
+ if (!entry?.direction) continue;
1600
+ (this.sceneMap as any).moveBody(player, entry.direction);
1601
+ this.sceneMap.stepPredictionTick();
1602
+ this.prediction?.attachPredictedState(entry.frame, this.getLocalPlayerState());
1603
+ }
1604
+ }
1605
+
1606
+ /**
1607
+ * Replay unacknowledged inputs from a given frame to resimulate client prediction
1608
+ * after applying server authority at a certain frame.
1609
+ *
1610
+ * @param startFrame - The last server-acknowledged frame
1611
+ *
1612
+ * @example
1613
+ * ```ts
1614
+ * // After applying a server correction at frame N
1615
+ * this.replayUnackedInputsFromFrame(N);
1616
+ * ```
1617
+ */
1618
+ private async replayUnackedInputsFromFrame(_startFrame: number): Promise<void> {
1619
+ // Prediction controller handles replay internally. Kept for backwards compatibility.
1620
+ }
1621
+
1622
+ /**
1623
+ * Clear all client resources and reset state
1624
+ *
1625
+ * This method should be called to clean up all client-side resources when
1626
+ * shutting down or resetting the client engine. It:
1627
+ * - Destroys the PIXI renderer
1628
+ * - Stops all sounds
1629
+ * - Cleans up subscriptions and event listeners
1630
+ * - Resets scene map
1631
+ * - Stops ping/pong interval
1632
+ * - Clears prediction states
1633
+ *
1634
+ * ## Design
1635
+ *
1636
+ * This method is used primarily in testing environments to ensure clean
1637
+ * state between tests. In production, the client engine typically persists
1638
+ * for the lifetime of the application.
1639
+ *
1640
+ * @example
1641
+ * ```ts
1642
+ * // In test cleanup
1643
+ * afterEach(() => {
1644
+ * clientEngine.clear();
1645
+ * });
1646
+ * ```
1647
+ */
1648
+ clear(): void {
1649
+ try {
1650
+ // First, unsubscribe from all tick subscriptions to stop rendering attempts
1651
+ for (const subscription of this.tickSubscriptions) {
1652
+ if (subscription && typeof subscription.unsubscribe === 'function') {
1653
+ subscription.unsubscribe();
1654
+ }
1655
+ }
1656
+ this.tickSubscriptions = [];
1657
+
1658
+ // Stop ping/pong interval
1659
+ if (this.pingInterval) {
1660
+ clearInterval(this.pingInterval);
1661
+ this.pingInterval = null;
1662
+ }
1663
+
1664
+ // Clean up onAfterLoading subscription
1665
+ if (this.onAfterLoadingSubscription && typeof this.onAfterLoadingSubscription.unsubscribe === 'function') {
1666
+ this.onAfterLoadingSubscription.unsubscribe();
1667
+ this.onAfterLoadingSubscription = undefined;
1668
+ }
1669
+
1670
+ // Clean up canvasElement (CanvasEngine) BEFORE destroying PIXI app
1671
+ // This prevents CanvasEngine from trying to render after PIXI is destroyed
1672
+ // CanvasEngine manages its own render loop which could try to access PIXI after destruction
1673
+ if (this.canvasElement) {
1674
+ try {
1675
+ // Try to stop or cleanup canvasElement if it has cleanup methods
1676
+ if (typeof (this.canvasElement as any).destroy === 'function') {
1677
+ (this.canvasElement as any).destroy();
1678
+ }
1679
+ // Clear the reference
1680
+ this.canvasElement = undefined;
1681
+ } catch (error) {
1682
+ // Ignore errors during canvasElement cleanup
1683
+ }
1684
+ }
1685
+
1686
+ // Reset scene map if it exists (this should stop any ongoing animations/renders)
1687
+ if (this.sceneMap && typeof (this.sceneMap as any).reset === 'function') {
1688
+ (this.sceneMap as any).reset(true);
1689
+ }
1690
+
1691
+ // Stop all sounds
1692
+ this.stopAllSounds();
1693
+
1694
+ // Remove resize event listener
1695
+ if (this.resizeHandler && typeof window !== 'undefined') {
1696
+ window.removeEventListener('resize', this.resizeHandler);
1697
+ this.resizeHandler = undefined;
1698
+ }
1699
+
1700
+ // Destroy PIXI app and renderer if they exist
1701
+ // Destroy the app first, which will destroy the renderer
1702
+ // Store renderer reference before destroying app (since app.destroy() will destroy the renderer)
1703
+ const rendererStillExists = this.renderer && typeof this.renderer.destroy === 'function';
1704
+
1705
+ if (this.canvasApp && typeof this.canvasApp.destroy === 'function') {
1706
+ try {
1707
+ // Stop the ticker first to prevent any render calls during destruction
1708
+ if (this.canvasApp.ticker) {
1709
+ if (typeof this.canvasApp.ticker.stop === 'function') {
1710
+ this.canvasApp.ticker.stop();
1711
+ }
1712
+ // Also remove all listeners from ticker to prevent callbacks
1713
+ if (typeof this.canvasApp.ticker.removeAll === 'function') {
1714
+ this.canvasApp.ticker.removeAll();
1715
+ }
1716
+ }
1717
+
1718
+ // Stop the renderer's ticker if it exists separately
1719
+ if (this.renderer && (this.renderer as any).ticker) {
1720
+ if (typeof (this.renderer as any).ticker.stop === 'function') {
1721
+ (this.renderer as any).ticker.stop();
1722
+ }
1723
+ if (typeof (this.renderer as any).ticker.removeAll === 'function') {
1724
+ (this.renderer as any).ticker.removeAll();
1725
+ }
1726
+ }
1727
+
1728
+ // Remove the canvas from DOM before destroying to prevent render attempts
1729
+ if (this.canvasApp.canvas && this.canvasApp.canvas.parentNode) {
1730
+ this.canvasApp.canvas.parentNode.removeChild(this.canvasApp.canvas);
1731
+ }
1732
+
1733
+ // Destroy with minimal options to avoid issues
1734
+ // Don't pass options that might trigger additional cleanup that could fail
1735
+ this.canvasApp.destroy(true);
1736
+ } catch (error) {
1737
+ // Ignore errors during destruction
1738
+ }
1739
+ this.canvasApp = undefined;
1740
+ // canvasApp.destroy() already destroyed the renderer, so just null it
1741
+ this.renderer = null as any;
1742
+ } else if (rendererStillExists) {
1743
+ // Fallback: destroy renderer directly only if app doesn't exist or wasn't destroyed
1744
+ try {
1745
+ // Stop the renderer's ticker if it has one
1746
+ if ((this.renderer as any).ticker) {
1747
+ if (typeof (this.renderer as any).ticker.stop === 'function') {
1748
+ (this.renderer as any).ticker.stop();
1749
+ }
1750
+ if (typeof (this.renderer as any).ticker.removeAll === 'function') {
1751
+ (this.renderer as any).ticker.removeAll();
1752
+ }
1753
+ }
1754
+
1755
+ this.renderer.destroy(true);
1756
+ } catch (error) {
1757
+ // Ignore errors during destruction
1758
+ }
1759
+ this.renderer = null as any;
1760
+ }
1761
+
1762
+ // Clean up prediction controller
1763
+ if (this.prediction) {
1764
+ // Prediction controller cleanup is handled internally when destroyed
1765
+ this.prediction = undefined;
1766
+ }
1767
+
1768
+ // Reset signals
1769
+ this.playerIdSignal.set(null);
1770
+ this.cameraFollowTargetId.set(null);
1771
+ this.spriteComponentsBehind.set([]);
1772
+ this.spriteComponentsInFront.set([]);
1773
+
1774
+ // Clear maps and arrays
1775
+ this.spritesheets.clear();
1776
+ this.sounds.clear();
1777
+ this.componentAnimations = [];
1778
+ this.particleSettings.emitters = [];
1779
+
1780
+ // Reset state
1781
+ this.stopProcessingInput = false;
1782
+ this.lastInputTime = 0;
1783
+ this.inputFrameCounter = 0;
1784
+ this.frameOffset = 0;
1785
+ this.rtt = 0;
1786
+ this.lastMovePathSentAt = 0;
1787
+ this.lastMovePathSentFrame = 0;
1788
+
1789
+ // Reset behavior subjects
1790
+ this.mapLoadCompleted$.next(false);
1791
+ this.playerIdReceived$.next(false);
1792
+ this.playersReceived$.next(false);
1793
+ this.eventsReceived$.next(false);
1794
+ } catch (error) {
1795
+ console.warn('Error during client engine cleanup:', error);
1796
+ }
1797
+ }
141
1798
  }