@rpgjs/client 5.0.0-alpha.3 → 5.0.0-alpha.30

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