@rpgjs/client 5.0.0-beta.2 → 5.0.0-beta.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (314) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/dist/Game/AnimationManager.d.ts +2 -1
  3. package/dist/Game/AnimationManager.js +21 -9
  4. package/dist/Game/AnimationManager.js.map +1 -1
  5. package/dist/Game/AnimationManager.spec.d.ts +1 -0
  6. package/dist/Game/ClientVisuals.d.ts +61 -0
  7. package/dist/Game/ClientVisuals.js +96 -0
  8. package/dist/Game/ClientVisuals.js.map +1 -0
  9. package/dist/Game/ClientVisuals.spec.d.ts +1 -0
  10. package/dist/Game/Event.js.map +1 -1
  11. package/dist/Game/EventComponentResolver.d.ts +16 -0
  12. package/dist/Game/EventComponentResolver.js +52 -0
  13. package/dist/Game/EventComponentResolver.js.map +1 -0
  14. package/dist/Game/EventComponentResolver.spec.d.ts +1 -0
  15. package/dist/Game/Map.d.ts +9 -1
  16. package/dist/Game/Map.js +83 -5
  17. package/dist/Game/Map.js.map +1 -1
  18. package/dist/Game/Object.d.ts +49 -15
  19. package/dist/Game/Object.js +101 -42
  20. package/dist/Game/Object.js.map +1 -1
  21. package/dist/Game/Object.spec.d.ts +1 -0
  22. package/dist/Game/Player.js.map +1 -1
  23. package/dist/Game/ProjectileManager.d.ts +98 -0
  24. package/dist/Game/ProjectileManager.js +196 -0
  25. package/dist/Game/ProjectileManager.js.map +1 -0
  26. package/dist/Game/ProjectileManager.spec.d.ts +1 -0
  27. package/dist/Gui/Gui.d.ts +20 -6
  28. package/dist/Gui/Gui.js +96 -54
  29. package/dist/Gui/Gui.js.map +1 -1
  30. package/dist/Gui/Gui.spec.d.ts +1 -0
  31. package/dist/Gui/NotificationManager.js.map +1 -1
  32. package/dist/Resource.js +1 -1
  33. package/dist/Resource.js.map +1 -1
  34. package/dist/RpgClient.d.ts +194 -15
  35. package/dist/RpgClientEngine.d.ts +176 -11
  36. package/dist/RpgClientEngine.js +669 -68
  37. package/dist/RpgClientEngine.js.map +1 -1
  38. package/dist/Sound.js.map +1 -1
  39. package/dist/_virtual/{_@oxc-project_runtime@0.122.0/helpers → _@oxc-project_runtime@0.133.0/helpers/esm}/decorate.js +1 -1
  40. package/dist/_virtual/{_@oxc-project_runtime@0.122.0/helpers → _@oxc-project_runtime@0.133.0/helpers/esm}/decorateMetadata.js +1 -1
  41. package/dist/_virtual/_rolldown/runtime.js +13 -0
  42. package/dist/components/animations/animation.ce.js +6 -5
  43. package/dist/components/animations/animation.ce.js.map +1 -1
  44. package/dist/components/animations/fx.ce.js +60 -0
  45. package/dist/components/animations/fx.ce.js.map +1 -0
  46. package/dist/components/animations/hit.ce.js +24 -28
  47. package/dist/components/animations/hit.ce.js.map +1 -1
  48. package/dist/components/animations/index.d.ts +1 -0
  49. package/dist/components/animations/index.js +6 -4
  50. package/dist/components/animations/index.js.map +1 -1
  51. package/dist/components/character.ce.js +606 -248
  52. package/dist/components/character.ce.js.map +1 -1
  53. package/dist/components/dynamics/bar.ce.js +99 -0
  54. package/dist/components/dynamics/bar.ce.js.map +1 -0
  55. package/dist/components/dynamics/image.ce.js +26 -0
  56. package/dist/components/dynamics/image.ce.js.map +1 -0
  57. package/dist/components/dynamics/parse-value.d.ts +3 -0
  58. package/dist/components/dynamics/parse-value.js +54 -35
  59. package/dist/components/dynamics/parse-value.js.map +1 -1
  60. package/dist/components/dynamics/parse-value.spec.d.ts +1 -0
  61. package/dist/components/dynamics/shape-utils.d.ts +16 -0
  62. package/dist/components/dynamics/shape-utils.js +73 -0
  63. package/dist/components/dynamics/shape-utils.js.map +1 -0
  64. package/dist/components/dynamics/shape-utils.spec.d.ts +1 -0
  65. package/dist/components/dynamics/shape.ce.js +86 -0
  66. package/dist/components/dynamics/shape.ce.js.map +1 -0
  67. package/dist/components/dynamics/text.ce.js +36 -56
  68. package/dist/components/dynamics/text.ce.js.map +1 -1
  69. package/dist/components/gui/box.ce.js +9 -9
  70. package/dist/components/gui/box.ce.js.map +1 -1
  71. package/dist/components/gui/dialogbox/index.ce.js +75 -65
  72. package/dist/components/gui/dialogbox/index.ce.js.map +1 -1
  73. package/dist/components/gui/gameover.ce.js +47 -68
  74. package/dist/components/gui/gameover.ce.js.map +1 -1
  75. package/dist/components/gui/hud/hud.ce.js +23 -31
  76. package/dist/components/gui/hud/hud.ce.js.map +1 -1
  77. package/dist/components/gui/menu/equip-menu.ce.js +121 -172
  78. package/dist/components/gui/menu/equip-menu.ce.js.map +1 -1
  79. package/dist/components/gui/menu/exit-menu.ce.js +15 -10
  80. package/dist/components/gui/menu/exit-menu.ce.js.map +1 -1
  81. package/dist/components/gui/menu/items-menu.ce.js +62 -77
  82. package/dist/components/gui/menu/items-menu.ce.js.map +1 -1
  83. package/dist/components/gui/menu/main-menu.ce.js +89 -104
  84. package/dist/components/gui/menu/main-menu.ce.js.map +1 -1
  85. package/dist/components/gui/menu/options-menu.ce.js +12 -8
  86. package/dist/components/gui/menu/options-menu.ce.js.map +1 -1
  87. package/dist/components/gui/menu/skills-menu.ce.js +17 -19
  88. package/dist/components/gui/menu/skills-menu.ce.js.map +1 -1
  89. package/dist/components/gui/mobile/index.js +2 -2
  90. package/dist/components/gui/mobile/index.js.map +1 -1
  91. package/dist/components/gui/mobile/mobile.ce.js +7 -5
  92. package/dist/components/gui/mobile/mobile.ce.js.map +1 -1
  93. package/dist/components/gui/notification/notification.ce.js +28 -26
  94. package/dist/components/gui/notification/notification.ce.js.map +1 -1
  95. package/dist/components/gui/save-load.ce.js +81 -256
  96. package/dist/components/gui/save-load.ce.js.map +1 -1
  97. package/dist/components/gui/shop/shop.ce.js +107 -142
  98. package/dist/components/gui/shop/shop.ce.js.map +1 -1
  99. package/dist/components/gui/title-screen.ce.js +50 -73
  100. package/dist/components/gui/title-screen.ce.js.map +1 -1
  101. package/dist/components/index.d.ts +2 -1
  102. package/dist/components/index.js +1 -0
  103. package/dist/components/interaction-components.ce.js +22 -0
  104. package/dist/components/interaction-components.ce.js.map +1 -0
  105. package/dist/components/player-components-utils.d.ts +67 -0
  106. package/dist/components/player-components-utils.js +162 -0
  107. package/dist/components/player-components-utils.js.map +1 -0
  108. package/dist/components/player-components-utils.spec.d.ts +1 -0
  109. package/dist/components/player-components.ce.js +191 -0
  110. package/dist/components/player-components.ce.js.map +1 -0
  111. package/dist/components/prebuilt/hp-bar.ce.js +45 -45
  112. package/dist/components/prebuilt/hp-bar.ce.js.map +1 -1
  113. package/dist/components/prebuilt/light-halo.ce.js +39 -60
  114. package/dist/components/prebuilt/light-halo.ce.js.map +1 -1
  115. package/dist/components/scenes/canvas.ce.js +205 -27
  116. package/dist/components/scenes/canvas.ce.js.map +1 -1
  117. package/dist/components/scenes/draw-map.ce.js +39 -39
  118. package/dist/components/scenes/draw-map.ce.js.map +1 -1
  119. package/dist/components/scenes/event-layer.ce.js +51 -10
  120. package/dist/components/scenes/event-layer.ce.js.map +1 -1
  121. package/dist/core/inject.js +1 -1
  122. package/dist/core/inject.js.map +1 -1
  123. package/dist/core/setup.js +1 -1
  124. package/dist/core/setup.js.map +1 -1
  125. package/dist/decorators/spritesheet.d.ts +1 -0
  126. package/dist/decorators/spritesheet.js +11 -0
  127. package/dist/decorators/spritesheet.js.map +1 -0
  128. package/dist/i18n.d.ts +55 -0
  129. package/dist/i18n.js +60 -0
  130. package/dist/i18n.js.map +1 -0
  131. package/dist/i18n.spec.d.ts +1 -0
  132. package/dist/index.d.ts +7 -0
  133. package/dist/index.js +29 -21
  134. package/dist/module.js +45 -4
  135. package/dist/module.js.map +1 -1
  136. package/dist/node_modules/.pnpm/{@signe_di@2.9.0 → @signe_di@3.1.0}/node_modules/@signe/di/dist/index.js +7 -117
  137. package/dist/node_modules/.pnpm/@signe_di@3.1.0/node_modules/@signe/di/dist/index.js.map +1 -0
  138. package/dist/node_modules/.pnpm/@signe_reactive@3.1.0/node_modules/@signe/reactive/dist/index.js +239 -0
  139. package/dist/node_modules/.pnpm/@signe_reactive@3.1.0/node_modules/@signe/reactive/dist/index.js.map +1 -0
  140. package/dist/node_modules/.pnpm/@signe_room@3.1.0_react@19.2.7/node_modules/@signe/room/dist/chunk-EUXUH3YW.js +13 -0
  141. package/dist/node_modules/.pnpm/@signe_room@3.1.0_react@19.2.7/node_modules/@signe/room/dist/chunk-EUXUH3YW.js.map +1 -0
  142. package/dist/node_modules/.pnpm/@signe_room@3.1.0_react@19.2.7/node_modules/@signe/room/dist/index.js +698 -0
  143. package/dist/node_modules/.pnpm/@signe_room@3.1.0_react@19.2.7/node_modules/@signe/room/dist/index.js.map +1 -0
  144. package/dist/node_modules/.pnpm/@signe_sync@3.1.0_react@19.2.7/node_modules/@signe/sync/dist/client/index.js +43 -0
  145. package/dist/node_modules/.pnpm/@signe_sync@3.1.0_react@19.2.7/node_modules/@signe/sync/dist/client/index.js.map +1 -0
  146. package/dist/node_modules/.pnpm/{@signe_sync@2.9.0 → @signe_sync@3.1.0_react@19.2.7}/node_modules/@signe/sync/dist/index.js +57 -141
  147. package/dist/node_modules/.pnpm/@signe_sync@3.1.0_react@19.2.7/node_modules/@signe/sync/dist/index.js.map +1 -0
  148. package/dist/node_modules/.pnpm/{partysocket@1.1.3/node_modules/partysocket/dist/chunk-HAC622V3.js → partysocket@1.2.0_react@19.2.7/node_modules/partysocket/dist/index.js} +30 -17
  149. package/dist/node_modules/.pnpm/partysocket@1.2.0_react@19.2.7/node_modules/partysocket/dist/index.js.map +1 -0
  150. package/dist/node_modules/.pnpm/{partysocket@1.1.3/node_modules/partysocket/dist/chunk-S74YV6PU.js → partysocket@1.2.0_react@19.2.7/node_modules/partysocket/dist/ws.js} +65 -34
  151. package/dist/node_modules/.pnpm/partysocket@1.2.0_react@19.2.7/node_modules/partysocket/dist/ws.js.map +1 -0
  152. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/index.js +13 -0
  153. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/index.js.map +1 -0
  154. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/ZodError.js +106 -0
  155. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/ZodError.js.map +1 -0
  156. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/errors.js +13 -0
  157. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/errors.js.map +1 -0
  158. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/external.js +121 -0
  159. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/external.js.map +1 -0
  160. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/errorUtil.js +10 -0
  161. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/errorUtil.js.map +1 -0
  162. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/parseUtil.js +110 -0
  163. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/parseUtil.js.map +1 -0
  164. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/util.js +99 -0
  165. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/helpers/util.js.map +1 -0
  166. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/locales/en.js +82 -0
  167. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/locales/en.js.map +1 -0
  168. package/dist/node_modules/.pnpm/{zod@3.24.2/node_modules/zod/lib/index.js → zod@3.25.76/node_modules/zod/v3/types.js} +74 -611
  169. package/dist/node_modules/.pnpm/zod@3.25.76/node_modules/zod/v3/types.js.map +1 -0
  170. package/dist/presets/animation.js.map +1 -1
  171. package/dist/presets/faceset.js.map +1 -1
  172. package/dist/presets/icon.js.map +1 -1
  173. package/dist/presets/index.js.map +1 -1
  174. package/dist/presets/lpc.js.map +1 -1
  175. package/dist/presets/rmspritesheet.js.map +1 -1
  176. package/dist/services/AbstractSocket.d.ts +2 -0
  177. package/dist/services/AbstractSocket.js.map +1 -1
  178. package/dist/services/actionInput.d.ts +14 -0
  179. package/dist/services/actionInput.js +59 -0
  180. package/dist/services/actionInput.js.map +1 -0
  181. package/dist/services/actionInput.spec.d.ts +1 -0
  182. package/dist/services/interactions.d.ts +159 -0
  183. package/dist/services/interactions.js +460 -0
  184. package/dist/services/interactions.js.map +1 -0
  185. package/dist/services/interactions.spec.d.ts +1 -0
  186. package/dist/services/keyboardControls.d.ts +1 -0
  187. package/dist/services/keyboardControls.js +1 -0
  188. package/dist/services/keyboardControls.js.map +1 -1
  189. package/dist/services/loadMap.d.ts +9 -0
  190. package/dist/services/loadMap.js +1 -1
  191. package/dist/services/loadMap.js.map +1 -1
  192. package/dist/services/mmorpg-connection.d.ts +5 -0
  193. package/dist/services/mmorpg-connection.js +50 -0
  194. package/dist/services/mmorpg-connection.js.map +1 -0
  195. package/dist/services/mmorpg-connection.spec.d.ts +1 -0
  196. package/dist/services/mmorpg.d.ts +11 -4
  197. package/dist/services/mmorpg.js +57 -33
  198. package/dist/services/mmorpg.js.map +1 -1
  199. package/dist/services/pointerContext.d.ts +11 -0
  200. package/dist/services/pointerContext.js +48 -0
  201. package/dist/services/pointerContext.js.map +1 -0
  202. package/dist/services/pointerContext.spec.d.ts +1 -0
  203. package/dist/services/save.js.map +1 -1
  204. package/dist/services/save.spec.d.ts +1 -0
  205. package/dist/services/standalone-message.d.ts +1 -0
  206. package/dist/services/standalone-message.js +9 -0
  207. package/dist/services/standalone-message.js.map +1 -0
  208. package/dist/services/standalone.d.ts +4 -1
  209. package/dist/services/standalone.js +36 -16
  210. package/dist/services/standalone.js.map +1 -1
  211. package/dist/services/standalone.spec.d.ts +1 -0
  212. package/dist/utils/getEntityProp.js +4 -3
  213. package/dist/utils/getEntityProp.js.map +1 -1
  214. package/dist/utils/getEntityProp.spec.d.ts +1 -0
  215. package/dist/utils/mapId.d.ts +1 -0
  216. package/dist/utils/mapId.js +6 -0
  217. package/dist/utils/mapId.js.map +1 -0
  218. package/dist/utils/readPropValue.d.ts +2 -0
  219. package/dist/utils/readPropValue.js +13 -0
  220. package/dist/utils/readPropValue.js.map +1 -0
  221. package/package.json +13 -13
  222. package/src/Game/AnimationManager.spec.ts +30 -0
  223. package/src/Game/AnimationManager.ts +26 -10
  224. package/src/Game/ClientVisuals.spec.ts +56 -0
  225. package/src/Game/ClientVisuals.ts +184 -0
  226. package/src/Game/EventComponentResolver.spec.ts +84 -0
  227. package/src/Game/EventComponentResolver.ts +74 -0
  228. package/src/Game/Map.ts +134 -2
  229. package/src/Game/Object.spec.ts +59 -0
  230. package/src/Game/Object.ts +181 -77
  231. package/src/Game/ProjectileManager.spec.ts +449 -0
  232. package/src/Game/ProjectileManager.ts +346 -0
  233. package/src/Gui/Gui.spec.ts +340 -0
  234. package/src/Gui/Gui.ts +129 -57
  235. package/src/Resource.ts +1 -2
  236. package/src/RpgClient.ts +220 -17
  237. package/src/RpgClientEngine.ts +959 -116
  238. package/src/components/animations/fx.ce +101 -0
  239. package/src/components/animations/index.ts +4 -2
  240. package/src/components/character.ce +681 -45
  241. package/src/components/dynamics/bar.ce +88 -0
  242. package/src/components/dynamics/image.ce +21 -0
  243. package/src/components/dynamics/parse-value.spec.ts +83 -0
  244. package/src/components/dynamics/parse-value.ts +111 -37
  245. package/src/components/dynamics/shape-utils.spec.ts +46 -0
  246. package/src/components/dynamics/shape-utils.ts +61 -0
  247. package/src/components/dynamics/shape.ce +90 -0
  248. package/src/components/dynamics/text.ce +35 -149
  249. package/src/components/gui/dialogbox/index.ce +52 -21
  250. package/src/components/gui/gameover.ce +6 -4
  251. package/src/components/gui/menu/equip-menu.ce +11 -9
  252. package/src/components/gui/menu/exit-menu.ce +6 -4
  253. package/src/components/gui/menu/items-menu.ce +11 -9
  254. package/src/components/gui/menu/main-menu.ce +14 -12
  255. package/src/components/gui/menu/options-menu.ce +4 -3
  256. package/src/components/gui/menu/skills-menu.ce +2 -1
  257. package/src/components/gui/notification/notification.ce +7 -1
  258. package/src/components/gui/save-load.ce +13 -11
  259. package/src/components/gui/shop/shop.ce +20 -18
  260. package/src/components/gui/title-screen.ce +6 -4
  261. package/src/components/index.ts +2 -1
  262. package/src/components/interaction-components.ce +23 -0
  263. package/src/components/player-components-utils.spec.ts +109 -0
  264. package/src/components/player-components-utils.ts +205 -0
  265. package/src/components/player-components.ce +222 -0
  266. package/src/components/prebuilt/hp-bar.ce +4 -3
  267. package/src/components/prebuilt/light-halo.ce +2 -2
  268. package/src/components/scenes/canvas.ce +219 -15
  269. package/src/components/scenes/draw-map.ce +34 -22
  270. package/src/components/scenes/event-layer.ce +55 -4
  271. package/src/core/setup.ts +2 -2
  272. package/src/decorators/spritesheet.ts +8 -0
  273. package/src/i18n.spec.ts +39 -0
  274. package/src/i18n.ts +59 -0
  275. package/src/index.ts +7 -0
  276. package/src/module.ts +61 -11
  277. package/src/services/AbstractSocket.ts +3 -0
  278. package/src/services/actionInput.spec.ts +155 -0
  279. package/src/services/actionInput.ts +120 -0
  280. package/src/services/interactions.spec.ts +175 -0
  281. package/src/services/interactions.ts +722 -0
  282. package/src/services/keyboardControls.ts +2 -1
  283. package/src/services/loadMap.ts +5 -1
  284. package/src/services/mmorpg-connection.spec.ts +99 -0
  285. package/src/services/mmorpg-connection.ts +69 -0
  286. package/src/services/mmorpg.ts +70 -36
  287. package/src/services/pointerContext.spec.ts +36 -0
  288. package/src/services/pointerContext.ts +84 -0
  289. package/src/services/save.spec.ts +127 -0
  290. package/src/services/standalone-message.ts +7 -0
  291. package/src/services/standalone.spec.ts +54 -0
  292. package/src/services/standalone.ts +44 -12
  293. package/src/utils/getEntityProp.spec.ts +96 -0
  294. package/src/utils/getEntityProp.ts +4 -3
  295. package/src/utils/mapId.ts +2 -0
  296. package/src/utils/readPropValue.ts +16 -0
  297. package/dist/node_modules/.pnpm/@signe_di@2.9.0/node_modules/@signe/di/dist/index.js.map +0 -1
  298. package/dist/node_modules/.pnpm/@signe_reactive@2.8.3/node_modules/@signe/reactive/dist/index.js +0 -457
  299. package/dist/node_modules/.pnpm/@signe_reactive@2.8.3/node_modules/@signe/reactive/dist/index.js.map +0 -1
  300. package/dist/node_modules/.pnpm/@signe_reactive@2.9.0/node_modules/@signe/reactive/dist/index.js +0 -463
  301. package/dist/node_modules/.pnpm/@signe_reactive@2.9.0/node_modules/@signe/reactive/dist/index.js.map +0 -1
  302. package/dist/node_modules/.pnpm/@signe_room@2.9.0/node_modules/@signe/room/dist/index.js +0 -2191
  303. package/dist/node_modules/.pnpm/@signe_room@2.9.0/node_modules/@signe/room/dist/index.js.map +0 -1
  304. package/dist/node_modules/.pnpm/@signe_sync@2.9.0/node_modules/@signe/sync/dist/chunk-7QVYU63E.js +0 -10
  305. package/dist/node_modules/.pnpm/@signe_sync@2.9.0/node_modules/@signe/sync/dist/chunk-7QVYU63E.js.map +0 -1
  306. package/dist/node_modules/.pnpm/@signe_sync@2.9.0/node_modules/@signe/sync/dist/client/index.js +0 -91
  307. package/dist/node_modules/.pnpm/@signe_sync@2.9.0/node_modules/@signe/sync/dist/client/index.js.map +0 -1
  308. package/dist/node_modules/.pnpm/@signe_sync@2.9.0/node_modules/@signe/sync/dist/index.js.map +0 -1
  309. package/dist/node_modules/.pnpm/dset@3.1.4/node_modules/dset/dist/index.js +0 -14
  310. package/dist/node_modules/.pnpm/dset@3.1.4/node_modules/dset/dist/index.js.map +0 -1
  311. package/dist/node_modules/.pnpm/partysocket@1.1.3/node_modules/partysocket/dist/chunk-HAC622V3.js.map +0 -1
  312. package/dist/node_modules/.pnpm/partysocket@1.1.3/node_modules/partysocket/dist/chunk-S74YV6PU.js.map +0 -1
  313. package/dist/node_modules/.pnpm/partysocket@1.1.3/node_modules/partysocket/dist/index.js +0 -2
  314. package/dist/node_modules/.pnpm/zod@3.24.2/node_modules/zod/lib/index.js.map +0 -1
@@ -1,11 +1,14 @@
1
1
  import Canvas from "./components/scenes/canvas.ce";
2
+ import BuiltinSceneMap from "./components/scenes/draw-map.ce";
2
3
  import { inject } from './core/inject'
3
- import { signal, bootstrapCanvas, Howl, trigger } from "canvasengine";
4
+ import { signal, bootstrapCanvas, Howl, trigger, type Trigger } from "canvasengine";
4
5
  import { AbstractWebsocket, WebSocketToken } from "./services/AbstractSocket";
5
6
  import { LoadMapService, LoadMapToken } from "./services/loadMap";
6
7
  import { RpgSound } from "./Sound";
7
8
  import { RpgResource } from "./Resource";
8
- import { Hooks, ModulesToken, Direction } from "@rpgjs/common";
9
+ import { getOrCreateI18nService, Hooks, ModulesToken, Direction, normalizeLightingState, Vector2, type I18nParams, type I18nService } from "@rpgjs/common";
10
+ import type { EventComponentConfig } from "./RpgClient";
11
+ import type { RpgClientEvent } from "./Game/Event";
9
12
  import { load } from "@signe/sync";
10
13
  import { RpgClientMap } from "./Game/Map"
11
14
  import { RpgGui } from "./Gui/Gui";
@@ -14,24 +17,133 @@ import { lastValueFrom, Observable, combineLatest, BehaviorSubject, filter, swit
14
17
  import { GlobalConfigToken } from "./module";
15
18
  import * as PIXI from "pixi.js";
16
19
  import { PrebuiltComponentAnimations } from "./components/animations";
20
+ import TextComponent from "./components/dynamics/text.ce";
21
+ import BarComponent from "./components/dynamics/bar.ce";
22
+ import ShapeComponent from "./components/dynamics/shape.ce";
23
+ import ImageComponent from "./components/dynamics/image.ce";
17
24
  import {
18
25
  PredictionController,
19
26
  type PredictionHistoryEntry,
20
27
  type PredictionState,
28
+ type RpgActionInput,
29
+ type RpgActionName,
30
+ type RpgDashInput,
31
+ type RpgMovementInput,
21
32
  } from "@rpgjs/common";
22
33
  import { NotificationManager } from "./Gui/NotificationManager";
23
34
  import { SaveClientService } from "./services/save";
35
+ import { getCanMoveValue } from "./utils/readPropValue";
36
+ import { ProjectileManager, type ClientProjectileImpact, type ClientProjectileSpawn } from "./Game/ProjectileManager";
37
+ import { ClientVisualRegistry, type ClientVisualHandler, type ClientVisualMap, type ClientVisualPacket } from "./Game/ClientVisuals";
38
+ import { normalizeActionInput } from "./services/actionInput";
39
+ import { createClientPointerContext, type ClientPointerContext } from "./services/pointerContext";
40
+ import { RpgClientInteractions } from "./services/interactions";
41
+ import { normalizeRoomMapId } from "./utils/mapId";
42
+ import { EventComponentResolverRegistry, type EventComponentResolver } from "./Game/EventComponentResolver";
43
+ import { RpgClientBuiltinI18n } from "./i18n";
24
44
 
25
45
  interface MovementTrajectoryPoint {
26
46
  frame: number;
27
47
  tick: number;
28
48
  timestamp: number;
29
- input: Direction;
49
+ input: RpgMovementInput;
30
50
  x: number;
31
51
  y: number;
32
52
  direction?: Direction;
33
53
  }
34
54
 
55
+ interface CanvasResizeSize {
56
+ width: number;
57
+ height: number;
58
+ }
59
+
60
+ const DEFAULT_DASH_ADDITIONAL_SPEED = 8;
61
+ const DEFAULT_DASH_DURATION_MS = 180;
62
+ const DEFAULT_DASH_COOLDOWN_MS = 450;
63
+
64
+ const isDashInput = (input: RpgMovementInput): input is RpgDashInput =>
65
+ typeof input === "object" && input !== null && input.type === "dash";
66
+
67
+ const isMoveInput = (
68
+ input: RpgMovementInput
69
+ ): input is { type: "move"; direction: Direction } =>
70
+ typeof input === "object" && input !== null && input.type === "move";
71
+
72
+ const resolveMoveDirection = (input: RpgMovementInput): Direction | undefined => {
73
+ if (isMoveInput(input)) return input.direction;
74
+ if (typeof input === "string" || typeof input === "number") {
75
+ return input as Direction;
76
+ }
77
+ return undefined;
78
+ };
79
+
80
+ const directionToVector = (direction: Direction | undefined) => {
81
+ switch (direction) {
82
+ case Direction.Left:
83
+ return { x: -1, y: 0 };
84
+ case Direction.Right:
85
+ return { x: 1, y: 0 };
86
+ case Direction.Up:
87
+ return { x: 0, y: -1 };
88
+ case Direction.Down:
89
+ default:
90
+ return { x: 0, y: 1 };
91
+ }
92
+ };
93
+
94
+ const vectorToDirection = (direction: { x: number; y: number }): Direction => {
95
+ if (Math.abs(direction.x) > Math.abs(direction.y)) {
96
+ return direction.x < 0 ? Direction.Left : Direction.Right;
97
+ }
98
+ return direction.y < 0 ? Direction.Up : Direction.Down;
99
+ };
100
+
101
+ const normalizeDashInput = (
102
+ input: Partial<RpgDashInput>,
103
+ fallbackDirection: Direction | undefined
104
+ ): RpgDashInput | null => {
105
+ const rawDirection = input.direction ?? directionToVector(fallbackDirection);
106
+ const rawX = Number(rawDirection?.x ?? 0);
107
+ const rawY = Number(rawDirection?.y ?? 0);
108
+ const magnitude = Math.hypot(rawX, rawY);
109
+ if (!Number.isFinite(magnitude) || magnitude <= 0) return null;
110
+
111
+ const additionalSpeed =
112
+ typeof input.additionalSpeed === "number" && Number.isFinite(input.additionalSpeed)
113
+ ? Math.max(0, Math.min(input.additionalSpeed, 64))
114
+ : DEFAULT_DASH_ADDITIONAL_SPEED;
115
+ const duration =
116
+ typeof input.duration === "number" && Number.isFinite(input.duration)
117
+ ? Math.max(1, Math.min(input.duration, 1000))
118
+ : DEFAULT_DASH_DURATION_MS;
119
+ const cooldown =
120
+ typeof input.cooldown === "number" && Number.isFinite(input.cooldown)
121
+ ? Math.max(0, Math.min(input.cooldown, 5000))
122
+ : DEFAULT_DASH_COOLDOWN_MS;
123
+
124
+ return {
125
+ type: "dash",
126
+ direction: {
127
+ x: rawX / magnitude,
128
+ y: rawY / magnitude,
129
+ },
130
+ additionalSpeed,
131
+ duration,
132
+ cooldown,
133
+ };
134
+ };
135
+
136
+ type ConfigurableTrigger<T> = Omit<Trigger<T>, "start"> & {
137
+ start(config?: T): Promise<void>;
138
+ };
139
+
140
+ type MapShakeOptions = {
141
+ intensity?: number;
142
+ duration?: number;
143
+ frequency?: number;
144
+ direction?: string;
145
+ };
146
+
35
147
  export class RpgClientEngine<T = any> {
36
148
  private guiService: RpgGui;
37
149
  private webSocket: AbstractWebsocket;
@@ -41,13 +153,19 @@ export class RpgClientEngine<T = any> {
41
153
  private selector: HTMLElement;
42
154
  public globalConfig: T;
43
155
  public sceneComponent: any;
156
+ public sceneMapComponent: any = BuiltinSceneMap;
44
157
  stopProcessingInput = false;
45
158
  width = signal("100%");
46
159
  height = signal("100%");
47
- spritesheets: Map<string, any> = new Map();
160
+ spritesheets: Map<string | number, any> = new Map();
161
+ private spritesheetPromises: Map<string | number, Promise<any>> = new Map();
48
162
  sounds: Map<string, any> = new Map();
49
163
  componentAnimations: any[] = [];
50
- private spritesheetResolver?: (id: string) => any | Promise<any>;
164
+ clientVisuals = new ClientVisualRegistry();
165
+ projectiles: ProjectileManager;
166
+ pointer: ClientPointerContext = createClientPointerContext();
167
+ interactions: RpgClientInteractions = new RpgClientInteractions(this);
168
+ private spritesheetResolver?: (id: string | number) => any | Promise<any>;
51
169
  private soundResolver?: (id: string) => any | Promise<any>;
52
170
  particleSettings: {
53
171
  emitters: any[]
@@ -61,21 +179,29 @@ export class RpgClientEngine<T = any> {
61
179
  playerIdSignal = signal<string | null>(null);
62
180
  spriteComponentsBehind = signal<any[]>([]);
63
181
  spriteComponentsInFront = signal<any[]>([]);
182
+ spriteComponents: Map<string, any> = new Map();
183
+ private eventComponentResolvers = new EventComponentResolverRegistry();
64
184
  /** ID of the sprite that the camera should follow. null means follow the current player */
65
185
  cameraFollowTargetId = signal<string | null>(null);
66
186
  /** Trigger for map shake animation */
67
- mapShakeTrigger = trigger();
187
+ mapShakeTrigger: ConfigurableTrigger<MapShakeOptions> = trigger<MapShakeOptions>();
68
188
 
69
189
  controlsReady = signal(undefined);
70
190
  gamePause = signal(false);
71
191
 
72
192
  private predictionEnabled = false;
73
- private prediction?: PredictionController<Direction>;
193
+ private prediction?: PredictionController<RpgMovementInput, Direction>;
74
194
  private readonly SERVER_CORRECTION_THRESHOLD = 30;
195
+ private localMovementAuthority = false;
196
+ private lastLocalMovementInputAt = 0;
197
+ private readonly LOCAL_MOVEMENT_AUTHORITY_ACK_GRACE_MS = 250;
75
198
  private inputFrameCounter = 0;
76
199
  private pendingPredictionFrames: number[] = [];
77
200
  private lastClientPhysicsStepAt = 0;
78
201
  private frameOffset = 0;
202
+ private latestServerTick?: number;
203
+ private latestServerTickAt = 0;
204
+ private dashLockedUntil = 0;
79
205
  // Ping/Pong for RTT measurement
80
206
  private rtt: number = 0; // Round-trip time in ms
81
207
  private pingInterval: any = null;
@@ -92,17 +218,35 @@ export class RpgClientEngine<T = any> {
92
218
  private eventsReceived$ = new BehaviorSubject<boolean>(false);
93
219
  private onAfterLoadingSubscription?: any;
94
220
  private sceneResetQueued = false;
221
+ private mapTransitionInProgress = false;
222
+ private currentMapRoomId?: string;
223
+ private socketListenersInitialized = false;
224
+ private clientReadyForMapChanges = false;
225
+ private pendingMapChanges: any[] = [];
95
226
 
96
227
  // Store subscriptions and event listeners for cleanup
97
228
  private tickSubscriptions: any[] = [];
98
229
  private resizeHandler?: () => void;
230
+ private pointerMoveHandler?: (event: PointerEvent) => void;
231
+ private pointerUpHandler?: (event: PointerEvent) => void;
232
+ private pointerCancelHandler?: (event: PointerEvent) => void;
233
+ private pointerCanvas?: HTMLCanvasElement;
234
+ private pendingSyncPackets: any[] = [];
99
235
  private notificationManager: NotificationManager = new NotificationManager();
236
+ private i18nService: I18nService;
237
+ private locale?: string;
100
238
 
101
239
  constructor(public context) {
102
240
  this.webSocket = inject(WebSocketToken);
103
241
  this.guiService = inject(RpgGui);
104
242
  this.loadMapService = inject(LoadMapToken);
105
243
  this.hooks = inject<Hooks>(ModulesToken);
244
+ this.i18nService = getOrCreateI18nService(context);
245
+ this.i18nService.addMessages(RpgClientBuiltinI18n, "rpgjs-client", 0);
246
+ this.projectiles = new ProjectileManager(
247
+ this.hooks,
248
+ (projectile) => this.predictProjectileImpact(projectile),
249
+ );
106
250
  this.globalConfig = inject(GlobalConfigToken)
107
251
 
108
252
  if (!this.globalConfig) {
@@ -123,10 +267,63 @@ export class RpgClientEngine<T = any> {
123
267
  component: PrebuiltComponentAnimations.Animation
124
268
  })
125
269
 
270
+ this.registerSpriteComponent("rpg:text", TextComponent);
271
+ this.registerSpriteComponent("rpg:hpBar", BarComponent);
272
+ this.registerSpriteComponent("rpg:spBar", BarComponent);
273
+ this.registerSpriteComponent("rpg:bar", BarComponent);
274
+ this.registerSpriteComponent("rpg:shape", ShapeComponent);
275
+ this.registerSpriteComponent("rpg:image", ImageComponent);
276
+
126
277
  this.predictionEnabled = (this.globalConfig as any)?.prediction?.enabled !== false;
278
+ this.localMovementAuthority = this.resolveLocalMovementAuthority();
127
279
  this.initializePredictionController();
128
280
  }
129
281
 
282
+ private resolveLocalMovementAuthority(): boolean {
283
+ const predictionConfig = (this.globalConfig as any)?.prediction;
284
+ const configured =
285
+ (this.globalConfig as any)?.movementAuthority ??
286
+ predictionConfig?.movementAuthority ??
287
+ predictionConfig?.authority ??
288
+ predictionConfig?.mode;
289
+
290
+ if (
291
+ configured === "server" ||
292
+ configured === "network" ||
293
+ configured === false
294
+ ) {
295
+ return false;
296
+ }
297
+ if (
298
+ configured === "client" ||
299
+ configured === "local" ||
300
+ configured === true
301
+ ) {
302
+ return true;
303
+ }
304
+
305
+ return this.webSocket.mode === "standalone";
306
+ }
307
+
308
+ setLocale(locale: string) {
309
+ this.locale = locale;
310
+ }
311
+
312
+ getLocale(): string {
313
+ return this.locale || this.i18nService.defaultLocale;
314
+ }
315
+
316
+ t(key: string, params?: I18nParams): string {
317
+ return this.i18nService.t(key, params, this.getLocale());
318
+ }
319
+
320
+ i18n() {
321
+ return {
322
+ locale: this.getLocale(),
323
+ t: (key: string, params?: I18nParams) => this.t(key, params),
324
+ };
325
+ }
326
+
130
327
  /**
131
328
  * Assigns a CanvasEngine KeyboardControls instance to the dependency injection context
132
329
  *
@@ -173,6 +370,22 @@ export class RpgClientEngine<T = any> {
173
370
  this.sceneMap = new RpgClientMap()
174
371
  this.sceneMap.configureClientPrediction(this.predictionEnabled);
175
372
  this.sceneMap.loadPhysic();
373
+ this.resolveSceneMapComponent();
374
+
375
+ const saveClient = inject(SaveClientService);
376
+ saveClient.initialize();
377
+ this.initListeners();
378
+ this.guiService._initialize();
379
+
380
+ try {
381
+ await this.webSocket.connection();
382
+ }
383
+ catch (error) {
384
+ this.stopPingPong();
385
+ await this.callConnectError(error);
386
+ throw error;
387
+ }
388
+
176
389
  this.selector = document.body.querySelector("#rpg") as HTMLElement;
177
390
 
178
391
  const bootstrapOptions = (this.globalConfig as any)?.bootstrapCanvasOptions;
@@ -181,9 +394,11 @@ export class RpgClientEngine<T = any> {
181
394
  Canvas,
182
395
  bootstrapOptions
183
396
  );
397
+ this.installCanvasResizeGuard(app);
184
398
  this.canvasApp = app;
185
399
  this.canvasElement = canvasElement;
186
- this.renderer = app.renderer as PIXI.Renderer;
400
+ this.renderer = app.renderer as unknown as PIXI.Renderer;
401
+ this.setupPointerTracking();
187
402
  this.tick = canvasElement?.propObservables?.context['tick'].observable
188
403
 
189
404
  const inputCheckSubscription = this.tick.subscribe(() => {
@@ -198,17 +413,23 @@ export class RpgClientEngine<T = any> {
198
413
 
199
414
  this.hooks.callHooks("client-spritesheets-load", this).subscribe();
200
415
  this.hooks.callHooks("client-spritesheetResolver-load", this).subscribe();
416
+ this.flushPendingSyncPackets();
201
417
  this.hooks.callHooks("client-sounds-load", this).subscribe();
202
418
  this.hooks.callHooks("client-soundResolver-load", this).subscribe();
203
-
419
+
204
420
  RpgSound.init(this);
205
421
  RpgResource.init(this);
206
422
  this.hooks.callHooks("client-gui-load", this).subscribe();
207
423
  this.hooks.callHooks("client-particles-load", this).subscribe();
208
424
  this.hooks.callHooks("client-componentAnimations-load", this).subscribe();
425
+ this.hooks.callHooks("client-clientVisuals-load", this).subscribe();
426
+ this.hooks.callHooks("client-projectiles-load", this).subscribe();
427
+ this.hooks.callHooks("client-interactions-load", this).subscribe();
209
428
  this.hooks.callHooks("client-sprite-load", this).subscribe();
210
429
 
211
430
  await lastValueFrom(this.hooks.callHooks("client-engine-onStart", this));
431
+ this.clientReadyForMapChanges = true;
432
+ this.flushPendingMapChanges();
212
433
 
213
434
  // wondow is resize
214
435
  this.resizeHandler = () => {
@@ -218,6 +439,7 @@ export class RpgClientEngine<T = any> {
218
439
 
219
440
  const tickSubscription = this.tick.subscribe((tick) => {
220
441
  this.stepClientPhysicsTick();
442
+ this.projectiles.step();
221
443
  this.flushPendingPredictedStates();
222
444
  this.flushPendingMovePath();
223
445
  this.hooks.callHooks("client-engine-onStep", this, tick).subscribe();
@@ -231,13 +453,157 @@ export class RpgClientEngine<T = any> {
231
453
  });
232
454
  this.tickSubscriptions.push(tickSubscription);
233
455
 
234
- await this.webSocket.connection(() => {
235
- const saveClient = inject(SaveClientService);
236
- saveClient.initialize(this.webSocket);
237
- this.initListeners()
238
- this.guiService._initialize()
239
- this.startPingPong();
240
- });
456
+ this.startPingPong();
457
+ }
458
+
459
+ private installCanvasResizeGuard(app: any) {
460
+ if (!app || typeof app.resize !== "function") return;
461
+
462
+ const originalResize = app.resize.bind(app);
463
+ app.resize = () => {
464
+ const targetSize = this.readCanvasResizeTargetSize(app);
465
+ const rendererSize = this.readCanvasRendererSize(app);
466
+
467
+ if (
468
+ targetSize &&
469
+ rendererSize &&
470
+ targetSize.width === rendererSize.width &&
471
+ targetSize.height === rendererSize.height
472
+ ) {
473
+ this.cancelCanvasResizeFrame(app);
474
+ return;
475
+ }
476
+
477
+ originalResize();
478
+ };
479
+ }
480
+
481
+ private readCanvasResizeTargetSize(app: any): CanvasResizeSize | null {
482
+ const resizeTarget = app?.resizeTo;
483
+ if (!resizeTarget || typeof window === "undefined") return null;
484
+
485
+ const rawWidth = resizeTarget === window ? window.innerWidth : resizeTarget.clientWidth;
486
+ const rawHeight = resizeTarget === window ? window.innerHeight : resizeTarget.clientHeight;
487
+ const width = Math.round(Number(rawWidth));
488
+ const height = Math.round(Number(rawHeight));
489
+
490
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width < 0 || height < 0) return null;
491
+ return { width, height };
492
+ }
493
+
494
+ private readCanvasRendererSize(app: any): CanvasResizeSize | null {
495
+ const screen = app?.renderer?.screen;
496
+ const width = Math.round(Number(screen?.width));
497
+ const height = Math.round(Number(screen?.height));
498
+
499
+ if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
500
+ return { width, height };
501
+ }
502
+
503
+ private cancelCanvasResizeFrame(app: any) {
504
+ if (typeof app?._cancelResize === "function") {
505
+ app._cancelResize();
506
+ }
507
+ }
508
+
509
+ private resolveSceneMapComponent() {
510
+ const components = this.hooks.getHookFunctions("client-sceneMap-component");
511
+ const component = components[components.length - 1];
512
+ if (component) {
513
+ this.sceneMapComponent = component;
514
+ }
515
+ }
516
+
517
+ private setupPointerTracking() {
518
+ const renderer = this.renderer as any;
519
+ const canvas = renderer?.canvas ?? renderer?.view ?? (this.canvasApp as any)?.canvas;
520
+
521
+ if (!canvas || typeof canvas.addEventListener !== "function") {
522
+ return;
523
+ }
524
+
525
+ this.pointerCanvas = canvas;
526
+ const updatePointer = (event: PointerEvent) => {
527
+ const rect = canvas.getBoundingClientRect();
528
+ const screen = {
529
+ x: event.clientX - rect.left,
530
+ y: event.clientY - rect.top,
531
+ };
532
+ const viewport = this.findViewportInstance();
533
+ let world = screen;
534
+
535
+ if (viewport && typeof viewport.toWorld === "function") {
536
+ const point = viewport.toWorld(screen.x, screen.y);
537
+ world = { x: Number(point.x), y: Number(point.y) };
538
+ } else if (viewport && typeof viewport.toLocal === "function") {
539
+ const point = viewport.toLocal(screen);
540
+ world = { x: Number(point.x), y: Number(point.y) };
541
+ }
542
+
543
+ this.pointer.update(screen, world);
544
+ };
545
+
546
+ this.pointerMoveHandler = (event: PointerEvent) => {
547
+ updatePointer(event);
548
+ this.interactions.handlePointerMove(event);
549
+ };
550
+ this.pointerUpHandler = (event: PointerEvent) => {
551
+ updatePointer(event);
552
+ this.interactions.handlePointerUp(event);
553
+ };
554
+ this.pointerCancelHandler = (event: PointerEvent) => {
555
+ updatePointer(event);
556
+ this.interactions.cancelDrag(event);
557
+ };
558
+
559
+ canvas.addEventListener("pointermove", this.pointerMoveHandler);
560
+ canvas.addEventListener("pointerdown", this.pointerMoveHandler);
561
+ canvas.addEventListener("pointerup", this.pointerUpHandler);
562
+ canvas.addEventListener("pointercancel", this.pointerCancelHandler);
563
+ canvas.addEventListener("pointerleave", this.pointerCancelHandler);
564
+ }
565
+
566
+ updatePointerFromInteractionEvent(event: any): void {
567
+ const global = event?.global ?? event?.data?.global;
568
+
569
+ if (!global) {
570
+ this.pointer.updateFromEvent(event);
571
+ return;
572
+ }
573
+
574
+ const screen = {
575
+ x: Number(global.x),
576
+ y: Number(global.y),
577
+ };
578
+ if (!Number.isFinite(screen.x) || !Number.isFinite(screen.y)) {
579
+ this.pointer.updateFromEvent(event);
580
+ return;
581
+ }
582
+
583
+ const viewport = this.findViewportInstance();
584
+ if (viewport && typeof viewport.toWorld === "function") {
585
+ const point = viewport.toWorld(screen.x, screen.y);
586
+ this.pointer.update(screen, { x: Number(point.x), y: Number(point.y) });
587
+ return;
588
+ }
589
+
590
+ this.pointer.update(screen);
591
+ }
592
+
593
+ private findViewportInstance(): any {
594
+ const find = (node: any): any => {
595
+ if (!node) return undefined;
596
+ if (typeof node?.toWorld === "function" || node?.constructor?.name === "Viewport") {
597
+ return node;
598
+ }
599
+ for (const child of node.children ?? []) {
600
+ const viewport = find(child);
601
+ if (viewport) return viewport;
602
+ }
603
+ return undefined;
604
+ };
605
+
606
+ return find((this.canvasApp as any)?.stage);
241
607
  }
242
608
 
243
609
  private prepareSyncPayload(data: any): any {
@@ -247,8 +613,8 @@ export class RpgClientEngine<T = any> {
247
613
 
248
614
  const myId = this.playerIdSignal();
249
615
  const players = payload.players;
250
- const shouldMaskLocalPosition =
251
- this.predictionEnabled && !!this.prediction?.hasPendingInputs();
616
+ const localPatch = myId && players ? players[myId] : undefined;
617
+ const shouldMaskLocalPosition = this.shouldPreserveLocalPlayerPosition(localPatch);
252
618
  if (shouldMaskLocalPosition && myId && players && players[myId]) {
253
619
  const localPatch = { ...players[myId] };
254
620
  delete localPatch.x;
@@ -264,6 +630,31 @@ export class RpgClientEngine<T = any> {
264
630
  return payload;
265
631
  }
266
632
 
633
+ private shouldPreserveLocalPlayerPosition(localPatch?: any): boolean {
634
+ if (!localPatch) {
635
+ return false;
636
+ }
637
+ if (this.predictionEnabled && !!this.prediction?.hasPendingInputs()) {
638
+ return true;
639
+ }
640
+ return this.shouldKeepLocalPlayerMovement();
641
+ }
642
+
643
+ private shouldKeepLocalPlayerMovement(): boolean {
644
+ if (!this.localMovementAuthority || this.mapTransitionInProgress) {
645
+ return false;
646
+ }
647
+ const myId = this.playerIdSignal();
648
+ const player = myId ? this.sceneMap?.players?.()?.[myId] : undefined;
649
+ if (!player) {
650
+ return false;
651
+ }
652
+ if (this.prediction?.hasPendingInputs()) {
653
+ return true;
654
+ }
655
+ return Date.now() - this.lastLocalMovementInputAt <= this.LOCAL_MOVEMENT_AUTHORITY_ACK_GRACE_MS;
656
+ }
657
+
267
658
  private normalizeAckWithSyncState(
268
659
  ack: { frame: number; serverTick?: number; x?: number; y?: number; direction?: Direction },
269
660
  syncData: any,
@@ -287,52 +678,15 @@ export class RpgClientEngine<T = any> {
287
678
  }
288
679
 
289
680
  private initListeners() {
290
- this.webSocket.on("sync", (data) => {
291
- if (data.pId) {
292
- this.playerIdSignal.set(data.pId);
293
- // Signal that player ID was received
294
- this.playerIdReceived$.next(true);
295
- }
681
+ if (this.socketListenersInitialized) return;
682
+ this.socketListenersInitialized = true;
296
683
 
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
304
- this.hooks.callHooks("client-sceneMap-onChanges", this.sceneMap, { partial: data }).subscribe();
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);
684
+ this.webSocket.on("sync", (data) => {
685
+ if (!this.tick) {
686
+ this.pendingSyncPackets.push(data);
687
+ return;
335
688
  }
689
+ this.applySyncPacket(data);
336
690
  });
337
691
 
338
692
  // Handle pong responses for RTT measurement
@@ -344,6 +698,7 @@ export class RpgClientEngine<T = any> {
344
698
  // This helps us estimate which server tick corresponds to each client input frame
345
699
  const estimatedTicksInFlight = Math.floor(this.rtt / 2 / (1000 / 60)); // Estimate ticks during half RTT
346
700
  const estimatedServerTickNow = data.serverTick + estimatedTicksInFlight;
701
+ this.updateServerTickEstimate(estimatedServerTickNow, now);
347
702
 
348
703
  // Update frame offset (only if we have inputs to calibrate with)
349
704
  if (this.inputFrameCounter > 0) {
@@ -354,11 +709,11 @@ export class RpgClientEngine<T = any> {
354
709
  });
355
710
 
356
711
  this.webSocket.on("changeMap", (data) => {
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);
712
+ if (!this.clientReadyForMapChanges) {
713
+ this.pendingMapChanges.push(data);
714
+ return;
715
+ }
716
+ this.handleChangeMap(data);
362
717
  });
363
718
 
364
719
  this.webSocket.on("showComponentAnimation", (data) => {
@@ -370,19 +725,63 @@ export class RpgClientEngine<T = any> {
370
725
  this.getComponentAnimation(id).displayEffect(params, player || position)
371
726
  });
372
727
 
728
+ this.webSocket.on("clientVisual", (data) => {
729
+ this.playClientVisual(data);
730
+ });
731
+
732
+ this.webSocket.on("projectile:spawnBatch", (data) => {
733
+ if (!this.shouldProcessProjectilePacket(data)) return;
734
+ this.projectiles.spawnBatch(data?.projectiles ?? [], {
735
+ mapId: data?.mapId,
736
+ currentServerTick: this.estimateServerTick(),
737
+ tickDurationMs: this.getPhysicsTickDurationMs(),
738
+ });
739
+ });
740
+
741
+ this.webSocket.on("projectile:impactBatch", (data) => {
742
+ if (!this.shouldProcessProjectilePacket(data)) return;
743
+ this.projectiles.impactBatch(data?.impacts ?? [], {
744
+ mapId: data?.mapId,
745
+ });
746
+ });
747
+
748
+ this.webSocket.on("projectile:destroyBatch", (data) => {
749
+ if (!this.shouldProcessProjectilePacket(data)) return;
750
+ this.projectiles.destroyBatch(data?.projectiles ?? [], {
751
+ mapId: data?.mapId,
752
+ });
753
+ });
754
+
755
+ this.webSocket.on("projectile:clear", (data) => {
756
+ if (!this.shouldProcessProjectilePacket(data)) return;
757
+ this.projectiles.clear();
758
+ });
759
+
373
760
  this.webSocket.on("notification", (data) => {
374
761
  this.notificationManager.add(data);
375
762
  });
376
763
 
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
- })
764
+ this.webSocket.on("setAnimation", (data) => {
765
+ const {
766
+ animationName,
767
+ nbTimes,
768
+ object,
769
+ graphic,
770
+ restoreAnimationName,
771
+ restoreGraphics,
772
+ } = data;
773
+ const player = object ? this.sceneMap.getObjectById(object) : undefined;
774
+ if (!player) return;
775
+ const restoreOptions = {
776
+ restoreAnimationName,
777
+ restoreGraphics,
778
+ };
779
+ if (graphic !== undefined) {
780
+ player.setAnimation(animationName, graphic, nbTimes, restoreOptions);
781
+ } else {
782
+ player.setAnimation(animationName, nbTimes, restoreOptions);
783
+ }
784
+ })
386
785
 
387
786
  this.webSocket.on("playSound", (data) => {
388
787
  const { soundId, volume, loop } = data;
@@ -413,7 +812,7 @@ export class RpgClientEngine<T = any> {
413
812
 
414
813
  this.webSocket.on("shakeMap", (data) => {
415
814
  const { intensity, duration, frequency, direction } = data || {};
416
- (this.mapShakeTrigger as any).start({
815
+ this.mapShakeTrigger.start({
417
816
  intensity,
418
817
  duration,
419
818
  frequency,
@@ -447,6 +846,14 @@ export class RpgClientEngine<T = any> {
447
846
  });
448
847
  });
449
848
 
849
+ this.webSocket.on("lightingState", (data) => {
850
+ const raw = (data && typeof data === "object" && "value" in data)
851
+ ? (data as any).value
852
+ : data;
853
+
854
+ this.sceneMap.lightingState.set(normalizeLightingState(raw));
855
+ });
856
+
450
857
  this.webSocket.on('open', () => {
451
858
  this.hooks.callHooks("client-engine-onConnected", this, this.socket).subscribe();
452
859
  // Start ping/pong for synchronization
@@ -460,10 +867,115 @@ export class RpgClientEngine<T = any> {
460
867
  })
461
868
 
462
869
  this.webSocket.on('error', (error) => {
463
- this.hooks.callHooks("client-engine-onConnectError", this, error, this.socket).subscribe();
870
+ void this.callConnectError(error);
464
871
  })
465
872
  }
466
873
 
874
+ private beginMapTransfer(nextMapId?: string) {
875
+ this.mapTransitionInProgress = true;
876
+ this.currentMapRoomId = nextMapId;
877
+ this.sceneResetQueued = false;
878
+ this.clearClientPredictionStates();
879
+ this.sceneMap.weatherState.set(null);
880
+ this.sceneMap.lightingState.set(null);
881
+ this.sceneMap.clearLightSpots();
882
+ this.clearComponentAnimations();
883
+ this.projectiles.setMapId(nextMapId);
884
+ this.cameraFollowTargetId.set(null);
885
+ this.sceneMap.reset();
886
+ this.sceneMap.loadPhysic();
887
+ }
888
+
889
+ private clearComponentAnimations() {
890
+ this.componentAnimations.forEach((componentAnimation) => {
891
+ componentAnimation.instance?.clear?.();
892
+ });
893
+ }
894
+
895
+ private shouldProcessProjectilePacket(data: any): boolean {
896
+ if (this.mapTransitionInProgress) return false;
897
+ const packetMapId = normalizeRoomMapId(
898
+ typeof data?.mapId === "string" ? data.mapId : undefined,
899
+ );
900
+ const currentMapId = normalizeRoomMapId(this.currentMapRoomId);
901
+ return !packetMapId || !currentMapId || packetMapId === currentMapId;
902
+ }
903
+
904
+ private async callConnectError(error: any) {
905
+ await lastValueFrom(this.hooks.callHooks("client-engine-onConnectError", this, error, this.socket));
906
+ }
907
+
908
+ private flushPendingSyncPackets() {
909
+ const packets = this.pendingSyncPackets;
910
+ this.pendingSyncPackets = [];
911
+ packets.forEach((packet) => this.applySyncPacket(packet));
912
+ }
913
+
914
+ private flushPendingMapChanges() {
915
+ const packets = this.pendingMapChanges;
916
+ this.pendingMapChanges = [];
917
+ packets.forEach((packet) => this.handleChangeMap(packet));
918
+ }
919
+
920
+ private handleChangeMap(data: any) {
921
+ const nextMapId = typeof data?.mapId === "string" ? data.mapId : undefined;
922
+ this.beginMapTransfer(nextMapId);
923
+ const transferToken = typeof data?.transferToken === "string" ? data.transferToken : undefined;
924
+ this.loadScene(data.mapId, transferToken);
925
+ }
926
+
927
+ private applySyncPacket(data: any) {
928
+ if (data.pId) {
929
+ this.playerIdSignal.set(data.pId);
930
+ // Signal that player ID was received
931
+ this.playerIdReceived$.next(true);
932
+ }
933
+
934
+ if (this.sceneResetQueued) {
935
+ const weatherState = this.sceneMap.weatherState();
936
+ const lightingState = this.sceneMap.lightingState();
937
+ this.sceneMap.reset();
938
+ this.sceneMap.weatherState.set(weatherState);
939
+ this.sceneMap.lightingState.set(lightingState);
940
+ this.sceneMap.loadPhysic();
941
+ this.sceneResetQueued = false;
942
+ }
943
+
944
+ // Apply client-side prediction filtering and server reconciliation
945
+ this.hooks.callHooks("client-sceneMap-onChanges", this.sceneMap, { partial: data }).subscribe();
946
+
947
+ const ack = data?.ack;
948
+ const normalizedAck =
949
+ ack && typeof ack.frame === "number"
950
+ ? this.normalizeAckWithSyncState(ack, data)
951
+ : undefined;
952
+ const payload = this.prepareSyncPayload(data);
953
+ load(this.sceneMap, payload, true);
954
+
955
+ if (normalizedAck) {
956
+ this.applyServerAck(normalizedAck);
957
+ }
958
+
959
+ for (const playerId in payload.players ?? {}) {
960
+ const player = payload.players[playerId]
961
+ if (!player._param) continue
962
+ for (const param in player._param) {
963
+ this.sceneMap.players()[playerId]._param()[param] = player._param[param]
964
+ }
965
+ }
966
+
967
+ // Check if players and events are present in sync data
968
+ const players = payload.players || this.sceneMap.players();
969
+ if (players && Object.keys(players).length > 0) {
970
+ this.playersReceived$.next(true);
971
+ }
972
+
973
+ const events = payload.events || this.sceneMap.events();
974
+ if (events !== undefined) {
975
+ this.eventsReceived$.next(true);
976
+ }
977
+ }
978
+
467
979
  /**
468
980
  * Start periodic ping/pong for client-server synchronization
469
981
  *
@@ -560,13 +1072,22 @@ export class RpgClientEngine<T = any> {
560
1072
  room: mapId,
561
1073
  query: transferToken ? { transferToken } : undefined,
562
1074
  })
563
- await this.webSocket.reconnect(() => {
564
- const saveClient = inject(SaveClientService);
565
- saveClient.initialize(this.webSocket);
566
- this.initListeners()
567
- this.guiService._initialize()
568
- })
1075
+ try {
1076
+ await this.webSocket.reconnect()
1077
+ }
1078
+ catch (error) {
1079
+ this.mapTransitionInProgress = false;
1080
+ this.stopPingPong();
1081
+ await this.callConnectError(error);
1082
+ throw error;
1083
+ }
569
1084
  const res = await this.loadMapService.load(mapId)
1085
+ const loadedLighting = typeof res?.lighting !== "undefined"
1086
+ ? res.lighting
1087
+ : res?.data?.lighting;
1088
+ if (typeof loadedLighting !== "undefined") {
1089
+ this.sceneMap.lightingState.set(normalizeLightingState(loadedLighting));
1090
+ }
570
1091
  this.sceneMap.data.set(res)
571
1092
 
572
1093
  // Check if playerId is already present
@@ -587,6 +1108,8 @@ export class RpgClientEngine<T = any> {
587
1108
 
588
1109
  // Signal that map loading is completed (this should be last to ensure other checks are done)
589
1110
  this.mapLoadCompleted$.next(true);
1111
+ this.currentMapRoomId = mapId;
1112
+ this.mapTransitionInProgress = false;
590
1113
  this.sceneMap.configureClientPrediction(this.predictionEnabled);
591
1114
  this.sceneMap.loadPhysic()
592
1115
  }
@@ -623,7 +1146,7 @@ export class RpgClientEngine<T = any> {
623
1146
  * });
624
1147
  * ```
625
1148
  */
626
- setSpritesheetResolver(resolver: (id: string) => any | Promise<any>): void {
1149
+ setSpritesheetResolver(resolver: (id: string | number) => any | Promise<any>): void {
627
1150
  this.spritesheetResolver = resolver;
628
1151
  }
629
1152
 
@@ -634,7 +1157,7 @@ export class RpgClientEngine<T = any> {
634
1157
  * If not found and a resolver is set, it calls the resolver to create the spritesheet.
635
1158
  * The resolved spritesheet is automatically cached for future use.
636
1159
  *
637
- * @param id - The spritesheet ID to retrieve
1160
+ * @param id - The spritesheet ID or legacy tile ID to retrieve
638
1161
  * @returns The spritesheet if found or created, or undefined if not found and no resolver
639
1162
  * @returns Promise<any> if the resolver is asynchronous
640
1163
  *
@@ -647,7 +1170,7 @@ export class RpgClientEngine<T = any> {
647
1170
  * const spritesheet = await engine.getSpriteSheet('dynamic-sprite');
648
1171
  * ```
649
1172
  */
650
- getSpriteSheet(id: string): any | Promise<any> {
1173
+ getSpriteSheet(id: string | number): any | Promise<any> {
651
1174
  // Check cache first
652
1175
  if (this.spritesheets.has(id)) {
653
1176
  return this.spritesheets.get(id);
@@ -655,17 +1178,29 @@ export class RpgClientEngine<T = any> {
655
1178
 
656
1179
  // If not in cache and resolver exists, use it
657
1180
  if (this.spritesheetResolver) {
1181
+ if (this.spritesheetPromises.has(id)) {
1182
+ return this.spritesheetPromises.get(id);
1183
+ }
1184
+
658
1185
  const result = this.spritesheetResolver(id);
659
1186
 
660
1187
  // Check if result is a Promise
661
1188
  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
- });
1189
+ const promise = result
1190
+ .then((spritesheet) => {
1191
+ if (spritesheet) {
1192
+ // Cache the resolved spritesheet
1193
+ this.spritesheets.set(id, spritesheet);
1194
+ }
1195
+ this.spritesheetPromises.delete(id);
1196
+ return spritesheet;
1197
+ })
1198
+ .catch((error) => {
1199
+ this.spritesheetPromises.delete(id);
1200
+ throw error;
1201
+ });
1202
+ this.spritesheetPromises.set(id, promise);
1203
+ return promise;
669
1204
  } else {
670
1205
  // Synchronous result
671
1206
  if (result) {
@@ -1081,6 +1616,104 @@ export class RpgClientEngine<T = any> {
1081
1616
  return component
1082
1617
  }
1083
1618
 
1619
+ /**
1620
+ * Register a reusable sprite component that can be addressed by the server.
1621
+ *
1622
+ * Server-side component definitions only carry the component id and
1623
+ * serializable props. The client registry maps that id to the CanvasEngine
1624
+ * component that performs the actual rendering.
1625
+ *
1626
+ * @param id - Stable component id used by server component definitions
1627
+ * @param component - CanvasEngine component to render for this id
1628
+ * @returns The registered component
1629
+ *
1630
+ * @example
1631
+ * ```ts
1632
+ * engine.registerSpriteComponent('guildBadge', GuildBadgeComponent);
1633
+ * ```
1634
+ */
1635
+ registerSpriteComponent(id: string, component: any) {
1636
+ this.spriteComponents.set(id, component);
1637
+ return component;
1638
+ }
1639
+
1640
+ /**
1641
+ * Get a reusable sprite component by id.
1642
+ *
1643
+ * @param id - Component id registered on the client
1644
+ * @returns The CanvasEngine component, or undefined when missing
1645
+ */
1646
+ getSpriteComponent(id: string) {
1647
+ return this.spriteComponents.get(id);
1648
+ }
1649
+
1650
+ /**
1651
+ * Register a custom event component resolver.
1652
+ *
1653
+ * The last resolver returning a component wins. This lets later modules
1654
+ * override earlier defaults without replacing the whole map scene.
1655
+ *
1656
+ * @param resolver - Function receiving the synced event object
1657
+ * @returns The registered resolver
1658
+ */
1659
+ addEventComponentResolver(resolver: EventComponentResolver) {
1660
+ return this.eventComponentResolvers.add(resolver);
1661
+ }
1662
+
1663
+ /**
1664
+ * Resolve the custom CanvasEngine component for an event, if any.
1665
+ *
1666
+ * @param event - Synced client event object
1667
+ * @returns The component/config returned by the last matching resolver
1668
+ */
1669
+ resolveEventComponent(event: RpgClientEvent): EventComponentConfig | null {
1670
+ return this.eventComponentResolvers.resolve(event);
1671
+ }
1672
+
1673
+ registerProjectileComponent(type: string, component: any) {
1674
+ return this.projectiles.register(type, component);
1675
+ }
1676
+
1677
+ getProjectileComponent(type: string) {
1678
+ return this.projectiles.get(type);
1679
+ }
1680
+
1681
+ /**
1682
+ * Register a named client visual macro.
1683
+ *
1684
+ * Client visuals are small client-side functions that group existing visual
1685
+ * primitives such as flash, sound, component animations, sprite animation, or
1686
+ * map shake. The server sends only the visual name and a serializable payload.
1687
+ *
1688
+ * @param name - Stable visual name sent by the server
1689
+ * @param handler - Client-side visual handler
1690
+ * @returns The registered handler
1691
+ */
1692
+ registerClientVisual(name: string, handler: ClientVisualHandler) {
1693
+ return this.clientVisuals.register(name, handler);
1694
+ }
1695
+
1696
+ /**
1697
+ * Register several named client visual macros.
1698
+ *
1699
+ * @param visuals - Map of visual names to client-side handlers
1700
+ */
1701
+ registerClientVisuals(visuals: ClientVisualMap) {
1702
+ this.clientVisuals.registerMany(visuals);
1703
+ }
1704
+
1705
+ /**
1706
+ * Play a registered client visual locally.
1707
+ *
1708
+ * This is also used by the websocket listener when the server calls
1709
+ * `player.clientVisual()` or `map.clientVisual()`.
1710
+ *
1711
+ * @param packet - Visual name and serializable payload
1712
+ */
1713
+ playClientVisual(packet: ClientVisualPacket) {
1714
+ return this.clientVisuals.play(packet, this);
1715
+ }
1716
+
1084
1717
  /**
1085
1718
  * Add a component animation to the engine
1086
1719
  *
@@ -1164,21 +1797,59 @@ export class RpgClientEngine<T = any> {
1164
1797
  * duration: 1000,
1165
1798
  * onFinish: () => console.log('Fade complete')
1166
1799
  * });
1800
+ *
1801
+ * // Wait until the transition component calls onFinish
1802
+ * await engine.startTransition('fade', { duration: 1000 });
1167
1803
  * ```
1168
1804
  */
1169
- startTransition(id: string, props: any = {}) {
1805
+ startTransition(id: string, props: any = {}): Promise<void> {
1170
1806
  if (!this.guiService.exists(id)) {
1171
1807
  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
1808
  }
1173
- this.guiService.display(id, props);
1809
+ return new Promise<void>((resolve) => {
1810
+ let finished = false;
1811
+ const finish = (data?: any) => {
1812
+ if (finished) return;
1813
+ finished = true;
1814
+ props?.onFinish?.(data);
1815
+ resolve();
1816
+ };
1817
+
1818
+ this.guiService.display(id, {
1819
+ ...props,
1820
+ onFinish: finish,
1821
+ });
1822
+ });
1174
1823
  }
1175
1824
 
1176
- async processInput({ input }: { input: Direction }) {
1825
+ async processInput({ input }: { input: RpgMovementInput }) {
1826
+ if (this.stopProcessingInput) return;
1827
+
1828
+ const currentPlayer = this.sceneMap.getCurrentPlayer() as any;
1829
+ const canMove =
1830
+ !currentPlayer ||
1831
+ getCanMoveValue(currentPlayer);
1832
+ if (!canMove) {
1833
+ this.interruptCurrentPlayerMovement(currentPlayer);
1834
+ return;
1835
+ }
1836
+
1177
1837
  const timestamp = Date.now();
1838
+ const movementInput = isDashInput(input)
1839
+ ? normalizeDashInput(input, currentPlayer?.direction?.())
1840
+ : input;
1841
+ if (!movementInput) return;
1842
+ if (isDashInput(movementInput)) {
1843
+ const cooldown = movementInput.cooldown ?? DEFAULT_DASH_COOLDOWN_MS;
1844
+ if (timestamp < this.dashLockedUntil) return;
1845
+ this.dashLockedUntil = timestamp + cooldown;
1846
+ }
1847
+ this.lastLocalMovementInputAt = timestamp;
1848
+
1178
1849
  let frame: number;
1179
1850
  let tick: number;
1180
1851
  if (this.predictionEnabled && this.prediction) {
1181
- const meta = this.prediction.recordInput(input, timestamp);
1852
+ const meta = this.prediction.recordInput(movementInput, timestamp);
1182
1853
  frame = meta.frame;
1183
1854
  tick = meta.tick;
1184
1855
  } else {
@@ -1186,13 +1857,11 @@ export class RpgClientEngine<T = any> {
1186
1857
  tick = this.getPhysicsTick();
1187
1858
  }
1188
1859
  this.inputFrameCounter = frame;
1189
- this.hooks.callHooks("client-engine-onInput", this, { input, playerId: this.playerId }).subscribe();
1860
+ this.hooks.callHooks("client-engine-onInput", this, { input: movementInput, playerId: this.playerId }).subscribe();
1190
1861
 
1191
- const currentPlayer = this.sceneMap.getCurrentPlayer();
1192
1862
  const bodyReady = this.ensureCurrentPlayerBody();
1193
1863
  if (currentPlayer && bodyReady) {
1194
- currentPlayer.changeDirection(input);
1195
- (this.sceneMap as any).moveBody(currentPlayer, input);
1864
+ this.applyPredictedMovementInput(currentPlayer, movementInput);
1196
1865
  if (this.predictionEnabled && this.prediction) {
1197
1866
  this.pendingPredictionFrames.push(frame);
1198
1867
  if (this.pendingPredictionFrames.length > 240) {
@@ -1201,14 +1870,42 @@ export class RpgClientEngine<T = any> {
1201
1870
  }
1202
1871
  }
1203
1872
 
1204
- this.emitMovePacket(input, frame, tick, timestamp, true);
1205
- this.lastInputTime = Date.now();
1873
+ this.emitMovePacket(movementInput, frame, tick, timestamp, true);
1874
+ this.lastInputTime = isDashInput(movementInput)
1875
+ ? Date.now() + (movementInput.duration ?? DEFAULT_DASH_DURATION_MS)
1876
+ : Date.now();
1877
+ }
1878
+
1879
+ async processDash(input: Partial<RpgDashInput> = {}) {
1880
+ const currentPlayer = this.sceneMap.getCurrentPlayer() as any;
1881
+ const fallbackDirection =
1882
+ typeof currentPlayer?.direction === "function"
1883
+ ? currentPlayer.direction()
1884
+ : currentPlayer?.direction;
1885
+ const dashInput = normalizeDashInput(input, fallbackDirection);
1886
+ if (!dashInput) return;
1887
+ await this.processInput({ input: dashInput });
1206
1888
  }
1207
1889
 
1208
- processAction({ action }: { action: number }) {
1890
+ processAction(action: RpgActionName, data?: any): void;
1891
+ processAction(action: RpgActionInput): void;
1892
+ processAction(action: RpgActionName | RpgActionInput, data?: any): void {
1209
1893
  if (this.stopProcessingInput) return;
1210
- this.hooks.callHooks("client-engine-onInput", this, { input: 'action', playerId: this.playerId }).subscribe();
1211
- this.webSocket.emit('action', { action })
1894
+ const currentPlayer = this.sceneMap.getCurrentPlayer() as any;
1895
+ const canMove =
1896
+ !currentPlayer ||
1897
+ getCanMoveValue(currentPlayer);
1898
+ if (!canMove) return;
1899
+
1900
+ const payload = normalizeActionInput(action as any, data);
1901
+
1902
+ this.hooks.callHooks("client-engine-onInput", this, {
1903
+ input: payload.action,
1904
+ action: payload.action,
1905
+ data: payload.data,
1906
+ playerId: this.playerId,
1907
+ }).subscribe();
1908
+ this.webSocket.emit('action', payload)
1212
1909
  }
1213
1910
 
1214
1911
  get PIXI() {
@@ -1227,8 +1924,76 @@ export class RpgClientEngine<T = any> {
1227
1924
  return this.sceneMap
1228
1925
  }
1229
1926
 
1927
+ getObjectById(id: string) {
1928
+ return this.sceneMap?.getObjectById(id);
1929
+ }
1930
+
1230
1931
  private getPhysicsTick(): number {
1231
- return this.sceneMap?.getTick?.() ?? 0;
1932
+ return (this.sceneMap as any)?.getTick?.() ?? 0;
1933
+ }
1934
+
1935
+ private getPhysicsTickDurationMs(): number {
1936
+ const timeStep = (this.sceneMap as any)?.physic?.getWorld?.()?.getTimeStep?.();
1937
+ return typeof timeStep === "number" && Number.isFinite(timeStep) && timeStep > 0
1938
+ ? timeStep * 1000
1939
+ : 1000 / 60;
1940
+ }
1941
+
1942
+ private updateServerTickEstimate(serverTick: number | undefined, now = Date.now()): void {
1943
+ if (typeof serverTick !== "number" || !Number.isFinite(serverTick)) {
1944
+ return;
1945
+ }
1946
+ this.latestServerTick = serverTick;
1947
+ this.latestServerTickAt = now;
1948
+ }
1949
+
1950
+ private estimateServerTick(now = Date.now()): number | undefined {
1951
+ if (typeof this.latestServerTick !== "number" || this.latestServerTickAt <= 0) {
1952
+ return undefined;
1953
+ }
1954
+ const elapsedTicks = Math.max(0, (now - this.latestServerTickAt) / this.getPhysicsTickDurationMs());
1955
+ return this.latestServerTick + elapsedTicks;
1956
+ }
1957
+
1958
+ private predictProjectileImpact(projectile: ClientProjectileSpawn): ClientProjectileImpact | null {
1959
+ if (projectile.predictImpact === false) {
1960
+ return null;
1961
+ }
1962
+ const sceneMap = this.sceneMap as any;
1963
+ if (!sceneMap?.physic || !Number.isFinite(projectile.range) || projectile.range <= 0) {
1964
+ return null;
1965
+ }
1966
+ const origin = projectile.origin;
1967
+ const direction = projectile.direction;
1968
+ if (
1969
+ !origin ||
1970
+ !direction ||
1971
+ !Number.isFinite(origin.x) ||
1972
+ !Number.isFinite(origin.y) ||
1973
+ !Number.isFinite(direction.x) ||
1974
+ !Number.isFinite(direction.y) ||
1975
+ (direction.x === 0 && direction.y === 0)
1976
+ ) {
1977
+ return null;
1978
+ }
1979
+
1980
+ const hit = sceneMap.physic.raycast(
1981
+ new Vector2(origin.x, origin.y),
1982
+ new Vector2(direction.x, direction.y),
1983
+ projectile.range,
1984
+ projectile.collisionMask,
1985
+ (entity) => projectile.ignoreOwner === false || !projectile.ownerId || entity.uuid !== projectile.ownerId,
1986
+ );
1987
+ if (!hit) {
1988
+ return null;
1989
+ }
1990
+ return {
1991
+ id: projectile.id,
1992
+ targetId: hit.entity.uuid,
1993
+ x: hit.point.x,
1994
+ y: hit.point.y,
1995
+ distance: hit.distance,
1996
+ };
1232
1997
  }
1233
1998
 
1234
1999
  private ensureCurrentPlayerBody(): boolean {
@@ -1295,7 +2060,7 @@ export class RpgClientEngine<T = any> {
1295
2060
  input: entry.direction,
1296
2061
  x: state.x,
1297
2062
  y: state.y,
1298
- direction: state.direction ?? entry.direction,
2063
+ direction: state.direction ?? resolveMoveDirection(entry.direction),
1299
2064
  });
1300
2065
  }
1301
2066
  if (trajectory.length > this.MAX_MOVE_TRAJECTORY_POINTS) {
@@ -1305,7 +2070,7 @@ export class RpgClientEngine<T = any> {
1305
2070
  }
1306
2071
 
1307
2072
  private emitMovePacket(
1308
- input: Direction,
2073
+ input: RpgMovementInput,
1309
2074
  frame: number,
1310
2075
  tick: number,
1311
2076
  timestamp: number,
@@ -1337,6 +2102,14 @@ export class RpgClientEngine<T = any> {
1337
2102
  if (!this.predictionEnabled || !this.prediction) {
1338
2103
  return;
1339
2104
  }
2105
+ const player = this.sceneMap?.getCurrentPlayer?.() as any;
2106
+ if (
2107
+ player &&
2108
+ !getCanMoveValue(player)
2109
+ ) {
2110
+ this.interruptCurrentPlayerMovement(player);
2111
+ return;
2112
+ }
1340
2113
  const pendingInputs = this.prediction.getPendingInputs();
1341
2114
  if (pendingInputs.length === 0) {
1342
2115
  return;
@@ -1352,6 +2125,22 @@ export class RpgClientEngine<T = any> {
1352
2125
  this.emitMovePacket(latest.direction, latest.frame, latest.tick, now, false);
1353
2126
  }
1354
2127
 
2128
+ private applyPredictedMovementInput(
2129
+ player: any,
2130
+ input: RpgMovementInput
2131
+ ): boolean {
2132
+ if (isDashInput(input)) {
2133
+ const direction = vectorToDirection(input.direction);
2134
+ player.changeDirection(direction);
2135
+ return Boolean((this.sceneMap as any).dashBody?.(player, input));
2136
+ }
2137
+
2138
+ const direction = resolveMoveDirection(input);
2139
+ if (!direction) return false;
2140
+ player.changeDirection(direction);
2141
+ return Boolean((this.sceneMap as any).moveBody?.(player, direction));
2142
+ }
2143
+
1355
2144
  private getLocalPlayerState(): PredictionState<Direction> {
1356
2145
  const currentPlayer = this.sceneMap?.getCurrentPlayer();
1357
2146
  if (!currentPlayer) {
@@ -1395,7 +2184,7 @@ export class RpgClientEngine<T = any> {
1395
2184
  ? configuredMaxEntries
1396
2185
  : Math.max(600, Math.ceil(historyTtlMs / 16) + 120);
1397
2186
  this.sceneMap?.configureClientPrediction?.(true);
1398
- this.prediction = new PredictionController<Direction>({
2187
+ this.prediction = new PredictionController<RpgMovementInput, Direction>({
1399
2188
  correctionThreshold: (this.globalConfig as any)?.prediction?.correctionThreshold ?? this.SERVER_CORRECTION_THRESHOLD,
1400
2189
  historyTtlMs,
1401
2190
  maxHistoryEntries,
@@ -1475,6 +2264,34 @@ export class RpgClientEngine<T = any> {
1475
2264
  this.lastMovePathSentFrame = 0;
1476
2265
  }
1477
2266
 
2267
+ /**
2268
+ * Stop local movement immediately and discard pending predicted movement.
2269
+ *
2270
+ * Use this before a blocking action such as an A-RPG attack, dialog, dash
2271
+ * startup, or any client-side state where already buffered movement inputs
2272
+ * must not be replayed after server reconciliation.
2273
+ *
2274
+ * @param player - Player object to stop. Defaults to the current player.
2275
+ * @returns `true` when a player was found and interrupted.
2276
+ *
2277
+ * @example
2278
+ * ```ts
2279
+ * engine.interruptCurrentPlayerMovement();
2280
+ * ```
2281
+ */
2282
+ interruptCurrentPlayerMovement(player: any = this.sceneMap?.getCurrentPlayer?.()): boolean {
2283
+ if (!player) {
2284
+ return false;
2285
+ }
2286
+ (this.sceneMap as any)?.stopMovement?.(player);
2287
+ this.prediction?.clearPendingInputs();
2288
+ this.pendingPredictionFrames = [];
2289
+ this.lastInputTime = 0;
2290
+ this.lastMovePathSentAt = Date.now();
2291
+ this.lastMovePathSentFrame = this.inputFrameCounter;
2292
+ return true;
2293
+ }
2294
+
1478
2295
  /**
1479
2296
  * Trigger a flash animation on a sprite
1480
2297
  *
@@ -1542,12 +2359,14 @@ export class RpgClientEngine<T = any> {
1542
2359
  }
1543
2360
 
1544
2361
  private applyServerAck(ack: { frame: number; serverTick?: number; x?: number; y?: number; direction?: Direction }) {
2362
+ this.updateServerTickEstimate(ack.serverTick);
2363
+ const keepLocalMovement = this.shouldKeepLocalPlayerMovement();
1545
2364
  if (this.predictionEnabled && this.prediction) {
1546
2365
  const result = this.prediction.applyServerAck({
1547
2366
  frame: ack.frame,
1548
2367
  serverTick: ack.serverTick,
1549
2368
  state:
1550
- typeof ack.x === "number" && typeof ack.y === "number"
2369
+ !keepLocalMovement && typeof ack.x === "number" && typeof ack.y === "number"
1551
2370
  ? { x: ack.x, y: ack.y, direction: ack.direction }
1552
2371
  : undefined,
1553
2372
  });
@@ -1560,7 +2379,10 @@ export class RpgClientEngine<T = any> {
1560
2379
  if (typeof ack.x !== "number" || typeof ack.y !== "number") {
1561
2380
  return;
1562
2381
  }
1563
- const player = this.getCurrentPlayer();
2382
+ if (keepLocalMovement) {
2383
+ return;
2384
+ }
2385
+ const player = this.getCurrentPlayer() as any;
1564
2386
  const myId = this.playerIdSignal();
1565
2387
  if (!player || !myId) {
1566
2388
  return;
@@ -1581,12 +2403,16 @@ export class RpgClientEngine<T = any> {
1581
2403
 
1582
2404
  private reconcilePrediction(
1583
2405
  authoritativeState: PredictionState<Direction>,
1584
- pendingInputs: PredictionHistoryEntry<Direction>[],
2406
+ pendingInputs: PredictionHistoryEntry<RpgMovementInput, Direction>[],
1585
2407
  ): void {
1586
- const player = this.getCurrentPlayer();
2408
+ const player = this.getCurrentPlayer() as any;
1587
2409
  if (!player) {
1588
2410
  return;
1589
2411
  }
2412
+ if (!getCanMoveValue(player)) {
2413
+ this.interruptCurrentPlayerMovement(player);
2414
+ return;
2415
+ }
1590
2416
 
1591
2417
  (this.sceneMap as any).stopMovement(player);
1592
2418
  this.applyAuthoritativeState(authoritativeState);
@@ -1599,7 +2425,7 @@ export class RpgClientEngine<T = any> {
1599
2425
  const replayInputs = pendingInputs.slice(-600);
1600
2426
  for (const entry of replayInputs) {
1601
2427
  if (!entry?.direction) continue;
1602
- (this.sceneMap as any).moveBody(player, entry.direction);
2428
+ this.applyPredictedMovementInput(player, entry.direction);
1603
2429
  this.sceneMap.stepPredictionTick();
1604
2430
  this.prediction?.attachPredictedState(entry.frame, this.getLocalPlayerState());
1605
2431
  }
@@ -1699,6 +2525,22 @@ export class RpgClientEngine<T = any> {
1699
2525
  this.resizeHandler = undefined;
1700
2526
  }
1701
2527
 
2528
+ if (this.pointerMoveHandler && this.pointerCanvas) {
2529
+ this.pointerCanvas.removeEventListener('pointermove', this.pointerMoveHandler);
2530
+ this.pointerCanvas.removeEventListener('pointerdown', this.pointerMoveHandler);
2531
+ if (this.pointerUpHandler) {
2532
+ this.pointerCanvas.removeEventListener('pointerup', this.pointerUpHandler);
2533
+ }
2534
+ if (this.pointerCancelHandler) {
2535
+ this.pointerCanvas.removeEventListener('pointercancel', this.pointerCancelHandler);
2536
+ this.pointerCanvas.removeEventListener('pointerleave', this.pointerCancelHandler);
2537
+ }
2538
+ this.pointerMoveHandler = undefined;
2539
+ this.pointerUpHandler = undefined;
2540
+ this.pointerCancelHandler = undefined;
2541
+ this.pointerCanvas = undefined;
2542
+ }
2543
+
1702
2544
  // Destroy PIXI app and renderer if they exist
1703
2545
  // Destroy the app first, which will destroy the renderer
1704
2546
  // Store renderer reference before destroying app (since app.destroy() will destroy the renderer)
@@ -1772,6 +2614,7 @@ export class RpgClientEngine<T = any> {
1772
2614
  this.cameraFollowTargetId.set(null);
1773
2615
  this.spriteComponentsBehind.set([]);
1774
2616
  this.spriteComponentsInFront.set([]);
2617
+ this.eventComponentResolvers.clear();
1775
2618
 
1776
2619
  // Clear maps and arrays
1777
2620
  this.spritesheets.clear();