@rpgjs/client 5.0.0-alpha.9 → 5.0.0-beta.10

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