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

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