@rpgjs/client 5.0.0-beta.11 → 5.0.0-beta.13

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 (133) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/Game/AnimationManager.d.ts +1 -0
  3. package/dist/Game/AnimationManager.js +3 -0
  4. package/dist/Game/AnimationManager.js.map +1 -1
  5. package/dist/Game/ClientVisuals.d.ts +61 -0
  6. package/dist/Game/ClientVisuals.js +96 -0
  7. package/dist/Game/ClientVisuals.js.map +1 -0
  8. package/dist/Game/ClientVisuals.spec.d.ts +1 -0
  9. package/dist/Game/EventComponentResolver.d.ts +16 -0
  10. package/dist/Game/EventComponentResolver.js +52 -0
  11. package/dist/Game/EventComponentResolver.js.map +1 -0
  12. package/dist/Game/EventComponentResolver.spec.d.ts +1 -0
  13. package/dist/Game/Map.js +9 -0
  14. package/dist/Game/Map.js.map +1 -1
  15. package/dist/Game/Object.d.ts +2 -0
  16. package/dist/Game/Object.js +22 -8
  17. package/dist/Game/Object.js.map +1 -1
  18. package/dist/Game/Object.spec.d.ts +1 -0
  19. package/dist/Game/ProjectileManager.d.ts +11 -2
  20. package/dist/Game/ProjectileManager.js +19 -2
  21. package/dist/Game/ProjectileManager.js.map +1 -1
  22. package/dist/Gui/Gui.d.ts +3 -2
  23. package/dist/Gui/Gui.js +18 -6
  24. package/dist/Gui/Gui.js.map +1 -1
  25. package/dist/RpgClient.d.ts +85 -1
  26. package/dist/RpgClientEngine.d.ts +77 -2
  27. package/dist/RpgClientEngine.js +290 -31
  28. package/dist/RpgClientEngine.js.map +1 -1
  29. package/dist/components/animations/fx.ce.js +58 -0
  30. package/dist/components/animations/fx.ce.js.map +1 -0
  31. package/dist/components/animations/index.d.ts +1 -0
  32. package/dist/components/animations/index.js +3 -1
  33. package/dist/components/animations/index.js.map +1 -1
  34. package/dist/components/character.ce.js +192 -19
  35. package/dist/components/character.ce.js.map +1 -1
  36. package/dist/components/gui/dialogbox/index.ce.js +27 -12
  37. package/dist/components/gui/dialogbox/index.ce.js.map +1 -1
  38. package/dist/components/gui/gameover.ce.js +4 -3
  39. package/dist/components/gui/gameover.ce.js.map +1 -1
  40. package/dist/components/gui/menu/equip-menu.ce.js +9 -8
  41. package/dist/components/gui/menu/equip-menu.ce.js.map +1 -1
  42. package/dist/components/gui/menu/exit-menu.ce.js +7 -5
  43. package/dist/components/gui/menu/exit-menu.ce.js.map +1 -1
  44. package/dist/components/gui/menu/items-menu.ce.js +8 -7
  45. package/dist/components/gui/menu/items-menu.ce.js.map +1 -1
  46. package/dist/components/gui/menu/main-menu.ce.js +12 -11
  47. package/dist/components/gui/menu/main-menu.ce.js.map +1 -1
  48. package/dist/components/gui/menu/options-menu.ce.js +7 -5
  49. package/dist/components/gui/menu/options-menu.ce.js.map +1 -1
  50. package/dist/components/gui/menu/skills-menu.ce.js +4 -2
  51. package/dist/components/gui/menu/skills-menu.ce.js.map +1 -1
  52. package/dist/components/gui/notification/notification.ce.js +4 -1
  53. package/dist/components/gui/notification/notification.ce.js.map +1 -1
  54. package/dist/components/gui/save-load.ce.js +10 -9
  55. package/dist/components/gui/save-load.ce.js.map +1 -1
  56. package/dist/components/gui/shop/shop.ce.js +17 -16
  57. package/dist/components/gui/shop/shop.ce.js.map +1 -1
  58. package/dist/components/gui/title-screen.ce.js +4 -3
  59. package/dist/components/gui/title-screen.ce.js.map +1 -1
  60. package/dist/components/interaction-components.ce.js +20 -0
  61. package/dist/components/interaction-components.ce.js.map +1 -0
  62. package/dist/components/scenes/canvas.ce.js +12 -7
  63. package/dist/components/scenes/canvas.ce.js.map +1 -1
  64. package/dist/components/scenes/draw-map.ce.js +18 -13
  65. package/dist/components/scenes/draw-map.ce.js.map +1 -1
  66. package/dist/i18n.d.ts +55 -0
  67. package/dist/i18n.js +60 -0
  68. package/dist/i18n.js.map +1 -0
  69. package/dist/i18n.spec.d.ts +1 -0
  70. package/dist/index.d.ts +3 -0
  71. package/dist/index.js +5 -2
  72. package/dist/module.js +30 -3
  73. package/dist/module.js.map +1 -1
  74. package/dist/services/actionInput.d.ts +3 -1
  75. package/dist/services/actionInput.js +33 -1
  76. package/dist/services/actionInput.js.map +1 -1
  77. package/dist/services/interactions.d.ts +159 -0
  78. package/dist/services/interactions.js +460 -0
  79. package/dist/services/interactions.js.map +1 -0
  80. package/dist/services/interactions.spec.d.ts +1 -0
  81. package/dist/services/keyboardControls.d.ts +1 -0
  82. package/dist/services/keyboardControls.js +1 -0
  83. package/dist/services/keyboardControls.js.map +1 -1
  84. package/dist/services/standalone.d.ts +3 -1
  85. package/dist/services/standalone.js +31 -13
  86. package/dist/services/standalone.js.map +1 -1
  87. package/dist/utils/mapId.d.ts +1 -0
  88. package/dist/utils/mapId.js +6 -0
  89. package/dist/utils/mapId.js.map +1 -0
  90. package/package.json +4 -4
  91. package/src/Game/AnimationManager.ts +4 -0
  92. package/src/Game/ClientVisuals.spec.ts +56 -0
  93. package/src/Game/ClientVisuals.ts +184 -0
  94. package/src/Game/EventComponentResolver.spec.ts +84 -0
  95. package/src/Game/EventComponentResolver.ts +74 -0
  96. package/src/Game/Map.ts +10 -0
  97. package/src/Game/Object.spec.ts +59 -0
  98. package/src/Game/Object.ts +36 -12
  99. package/src/Game/ProjectileManager.spec.ts +111 -0
  100. package/src/Game/ProjectileManager.ts +24 -2
  101. package/src/Gui/Gui.spec.ts +67 -0
  102. package/src/Gui/Gui.ts +24 -7
  103. package/src/RpgClient.ts +96 -1
  104. package/src/RpgClientEngine.ts +378 -45
  105. package/src/components/animations/fx.ce +101 -0
  106. package/src/components/animations/index.ts +4 -2
  107. package/src/components/character.ce +243 -17
  108. package/src/components/gui/dialogbox/index.ce +35 -14
  109. package/src/components/gui/gameover.ce +4 -3
  110. package/src/components/gui/menu/equip-menu.ce +9 -8
  111. package/src/components/gui/menu/exit-menu.ce +4 -3
  112. package/src/components/gui/menu/items-menu.ce +8 -7
  113. package/src/components/gui/menu/main-menu.ce +12 -11
  114. package/src/components/gui/menu/options-menu.ce +4 -3
  115. package/src/components/gui/menu/skills-menu.ce +2 -1
  116. package/src/components/gui/notification/notification.ce +7 -1
  117. package/src/components/gui/save-load.ce +11 -10
  118. package/src/components/gui/shop/shop.ce +17 -16
  119. package/src/components/gui/title-screen.ce +4 -3
  120. package/src/components/interaction-components.ce +23 -0
  121. package/src/components/scenes/canvas.ce +12 -7
  122. package/src/components/scenes/draw-map.ce +16 -5
  123. package/src/i18n.spec.ts +39 -0
  124. package/src/i18n.ts +59 -0
  125. package/src/index.ts +3 -0
  126. package/src/module.ts +43 -10
  127. package/src/services/actionInput.spec.ts +54 -0
  128. package/src/services/actionInput.ts +68 -1
  129. package/src/services/interactions.spec.ts +175 -0
  130. package/src/services/interactions.ts +722 -0
  131. package/src/services/keyboardControls.ts +2 -1
  132. package/src/services/standalone.ts +39 -10
  133. package/src/utils/mapId.ts +2 -0
@@ -6,7 +6,9 @@ import { AbstractWebsocket, WebSocketToken } from "./services/AbstractSocket";
6
6
  import { LoadMapService, LoadMapToken } from "./services/loadMap";
7
7
  import { RpgSound } from "./Sound";
8
8
  import { RpgResource } from "./Resource";
9
- import { Hooks, ModulesToken, Direction, normalizeLightingState, Vector2 } 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";
10
12
  import { load } from "@signe/sync";
11
13
  import { RpgClientMap } from "./Game/Map"
12
14
  import { RpgGui } from "./Gui/Gui";
@@ -25,24 +27,107 @@ import {
25
27
  type PredictionState,
26
28
  type RpgActionInput,
27
29
  type RpgActionName,
30
+ type RpgDashInput,
31
+ type RpgMovementInput,
28
32
  } from "@rpgjs/common";
29
33
  import { NotificationManager } from "./Gui/NotificationManager";
30
34
  import { SaveClientService } from "./services/save";
31
35
  import { getCanMoveValue } from "./utils/readPropValue";
32
36
  import { ProjectileManager, type ClientProjectileImpact, type ClientProjectileSpawn } from "./Game/ProjectileManager";
37
+ import { ClientVisualRegistry, type ClientVisualHandler, type ClientVisualMap, type ClientVisualPacket } from "./Game/ClientVisuals";
33
38
  import { normalizeActionInput } from "./services/actionInput";
34
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";
35
44
 
36
45
  interface MovementTrajectoryPoint {
37
46
  frame: number;
38
47
  tick: number;
39
48
  timestamp: number;
40
- input: Direction;
49
+ input: RpgMovementInput;
41
50
  x: number;
42
51
  y: number;
43
52
  direction?: Direction;
44
53
  }
45
54
 
55
+ const DEFAULT_DASH_ADDITIONAL_SPEED = 8;
56
+ const DEFAULT_DASH_DURATION_MS = 180;
57
+ const DEFAULT_DASH_COOLDOWN_MS = 450;
58
+
59
+ const isDashInput = (input: RpgMovementInput): input is RpgDashInput =>
60
+ typeof input === "object" && input !== null && input.type === "dash";
61
+
62
+ const isMoveInput = (
63
+ input: RpgMovementInput
64
+ ): input is { type: "move"; direction: Direction } =>
65
+ typeof input === "object" && input !== null && input.type === "move";
66
+
67
+ const resolveMoveDirection = (input: RpgMovementInput): Direction | undefined => {
68
+ if (isMoveInput(input)) return input.direction;
69
+ if (typeof input === "string" || typeof input === "number") {
70
+ return input as Direction;
71
+ }
72
+ return undefined;
73
+ };
74
+
75
+ const directionToVector = (direction: Direction | undefined) => {
76
+ switch (direction) {
77
+ case Direction.Left:
78
+ return { x: -1, y: 0 };
79
+ case Direction.Right:
80
+ return { x: 1, y: 0 };
81
+ case Direction.Up:
82
+ return { x: 0, y: -1 };
83
+ case Direction.Down:
84
+ default:
85
+ return { x: 0, y: 1 };
86
+ }
87
+ };
88
+
89
+ const vectorToDirection = (direction: { x: number; y: number }): Direction => {
90
+ if (Math.abs(direction.x) > Math.abs(direction.y)) {
91
+ return direction.x < 0 ? Direction.Left : Direction.Right;
92
+ }
93
+ return direction.y < 0 ? Direction.Up : Direction.Down;
94
+ };
95
+
96
+ const normalizeDashInput = (
97
+ input: Partial<RpgDashInput>,
98
+ fallbackDirection: Direction | undefined
99
+ ): RpgDashInput | null => {
100
+ const rawDirection = input.direction ?? directionToVector(fallbackDirection);
101
+ const rawX = Number(rawDirection?.x ?? 0);
102
+ const rawY = Number(rawDirection?.y ?? 0);
103
+ const magnitude = Math.hypot(rawX, rawY);
104
+ if (!Number.isFinite(magnitude) || magnitude <= 0) return null;
105
+
106
+ const additionalSpeed =
107
+ typeof input.additionalSpeed === "number" && Number.isFinite(input.additionalSpeed)
108
+ ? Math.max(0, Math.min(input.additionalSpeed, 64))
109
+ : DEFAULT_DASH_ADDITIONAL_SPEED;
110
+ const duration =
111
+ typeof input.duration === "number" && Number.isFinite(input.duration)
112
+ ? Math.max(1, Math.min(input.duration, 1000))
113
+ : DEFAULT_DASH_DURATION_MS;
114
+ const cooldown =
115
+ typeof input.cooldown === "number" && Number.isFinite(input.cooldown)
116
+ ? Math.max(0, Math.min(input.cooldown, 5000))
117
+ : DEFAULT_DASH_COOLDOWN_MS;
118
+
119
+ return {
120
+ type: "dash",
121
+ direction: {
122
+ x: rawX / magnitude,
123
+ y: rawY / magnitude,
124
+ },
125
+ additionalSpeed,
126
+ duration,
127
+ cooldown,
128
+ };
129
+ };
130
+
46
131
  type ConfigurableTrigger<T> = Omit<Trigger<T>, "start"> & {
47
132
  start(config?: T): Promise<void>;
48
133
  };
@@ -68,10 +153,13 @@ export class RpgClientEngine<T = any> {
68
153
  width = signal("100%");
69
154
  height = signal("100%");
70
155
  spritesheets: Map<string | number, any> = new Map();
156
+ private spritesheetPromises: Map<string | number, Promise<any>> = new Map();
71
157
  sounds: Map<string, any> = new Map();
72
158
  componentAnimations: any[] = [];
159
+ clientVisuals = new ClientVisualRegistry();
73
160
  projectiles: ProjectileManager;
74
161
  pointer: ClientPointerContext = createClientPointerContext();
162
+ interactions: RpgClientInteractions = new RpgClientInteractions(this);
75
163
  private spritesheetResolver?: (id: string | number) => any | Promise<any>;
76
164
  private soundResolver?: (id: string) => any | Promise<any>;
77
165
  particleSettings: {
@@ -87,6 +175,7 @@ export class RpgClientEngine<T = any> {
87
175
  spriteComponentsBehind = signal<any[]>([]);
88
176
  spriteComponentsInFront = signal<any[]>([]);
89
177
  spriteComponents: Map<string, any> = new Map();
178
+ private eventComponentResolvers = new EventComponentResolverRegistry();
90
179
  /** ID of the sprite that the camera should follow. null means follow the current player */
91
180
  cameraFollowTargetId = signal<string | null>(null);
92
181
  /** Trigger for map shake animation */
@@ -96,7 +185,7 @@ export class RpgClientEngine<T = any> {
96
185
  gamePause = signal(false);
97
186
 
98
187
  private predictionEnabled = false;
99
- private prediction?: PredictionController<Direction>;
188
+ private prediction?: PredictionController<RpgMovementInput, Direction>;
100
189
  private readonly SERVER_CORRECTION_THRESHOLD = 30;
101
190
  private inputFrameCounter = 0;
102
191
  private pendingPredictionFrames: number[] = [];
@@ -104,6 +193,7 @@ export class RpgClientEngine<T = any> {
104
193
  private frameOffset = 0;
105
194
  private latestServerTick?: number;
106
195
  private latestServerTickAt = 0;
196
+ private dashLockedUntil = 0;
107
197
  // Ping/Pong for RTT measurement
108
198
  private rtt: number = 0; // Round-trip time in ms
109
199
  private pingInterval: any = null;
@@ -120,20 +210,29 @@ export class RpgClientEngine<T = any> {
120
210
  private eventsReceived$ = new BehaviorSubject<boolean>(false);
121
211
  private onAfterLoadingSubscription?: any;
122
212
  private sceneResetQueued = false;
213
+ private mapTransitionInProgress = false;
214
+ private currentMapRoomId?: string;
215
+ private socketListenersInitialized = false;
123
216
 
124
217
  // Store subscriptions and event listeners for cleanup
125
218
  private tickSubscriptions: any[] = [];
126
219
  private resizeHandler?: () => void;
127
220
  private pointerMoveHandler?: (event: PointerEvent) => void;
221
+ private pointerUpHandler?: (event: PointerEvent) => void;
222
+ private pointerCancelHandler?: (event: PointerEvent) => void;
128
223
  private pointerCanvas?: HTMLCanvasElement;
129
224
  private pendingSyncPackets: any[] = [];
130
225
  private notificationManager: NotificationManager = new NotificationManager();
226
+ private i18nService: I18nService;
227
+ private locale?: string;
131
228
 
132
229
  constructor(public context) {
133
230
  this.webSocket = inject(WebSocketToken);
134
231
  this.guiService = inject(RpgGui);
135
232
  this.loadMapService = inject(LoadMapToken);
136
233
  this.hooks = inject<Hooks>(ModulesToken);
234
+ this.i18nService = getOrCreateI18nService(context);
235
+ this.i18nService.addMessages(RpgClientBuiltinI18n, "rpgjs-client", 0);
137
236
  this.projectiles = new ProjectileManager(
138
237
  this.hooks,
139
238
  (projectile) => this.predictProjectileImpact(projectile),
@@ -169,6 +268,25 @@ export class RpgClientEngine<T = any> {
169
268
  this.initializePredictionController();
170
269
  }
171
270
 
271
+ setLocale(locale: string) {
272
+ this.locale = locale;
273
+ }
274
+
275
+ getLocale(): string {
276
+ return this.locale || this.i18nService.defaultLocale;
277
+ }
278
+
279
+ t(key: string, params?: I18nParams): string {
280
+ return this.i18nService.t(key, params, this.getLocale());
281
+ }
282
+
283
+ i18n() {
284
+ return {
285
+ locale: this.getLocale(),
286
+ t: (key: string, params?: I18nParams) => this.t(key, params),
287
+ };
288
+ }
289
+
172
290
  /**
173
291
  * Assigns a CanvasEngine KeyboardControls instance to the dependency injection context
174
292
  *
@@ -244,7 +362,6 @@ export class RpgClientEngine<T = any> {
244
362
  this.renderer = app.renderer as unknown as PIXI.Renderer;
245
363
  this.setupPointerTracking();
246
364
  this.tick = canvasElement?.propObservables?.context['tick'].observable
247
- this.flushPendingSyncPackets();
248
365
 
249
366
  const inputCheckSubscription = this.tick.subscribe(() => {
250
367
  if (Date.now() - this.lastInputTime > 100) {
@@ -258,6 +375,7 @@ export class RpgClientEngine<T = any> {
258
375
 
259
376
  this.hooks.callHooks("client-spritesheets-load", this).subscribe();
260
377
  this.hooks.callHooks("client-spritesheetResolver-load", this).subscribe();
378
+ this.flushPendingSyncPackets();
261
379
  this.hooks.callHooks("client-sounds-load", this).subscribe();
262
380
  this.hooks.callHooks("client-soundResolver-load", this).subscribe();
263
381
 
@@ -266,7 +384,9 @@ export class RpgClientEngine<T = any> {
266
384
  this.hooks.callHooks("client-gui-load", this).subscribe();
267
385
  this.hooks.callHooks("client-particles-load", this).subscribe();
268
386
  this.hooks.callHooks("client-componentAnimations-load", this).subscribe();
387
+ this.hooks.callHooks("client-clientVisuals-load", this).subscribe();
269
388
  this.hooks.callHooks("client-projectiles-load", this).subscribe();
389
+ this.hooks.callHooks("client-interactions-load", this).subscribe();
270
390
  this.hooks.callHooks("client-sprite-load", this).subscribe();
271
391
 
272
392
  await lastValueFrom(this.hooks.callHooks("client-engine-onStart", this));
@@ -313,7 +433,7 @@ export class RpgClientEngine<T = any> {
313
433
  }
314
434
 
315
435
  this.pointerCanvas = canvas;
316
- this.pointerMoveHandler = (event: PointerEvent) => {
436
+ const updatePointer = (event: PointerEvent) => {
317
437
  const rect = canvas.getBoundingClientRect();
318
438
  const screen = {
319
439
  x: event.clientX - rect.left,
@@ -333,16 +453,67 @@ export class RpgClientEngine<T = any> {
333
453
  this.pointer.update(screen, world);
334
454
  };
335
455
 
456
+ this.pointerMoveHandler = (event: PointerEvent) => {
457
+ updatePointer(event);
458
+ this.interactions.handlePointerMove(event);
459
+ };
460
+ this.pointerUpHandler = (event: PointerEvent) => {
461
+ updatePointer(event);
462
+ this.interactions.handlePointerUp(event);
463
+ };
464
+ this.pointerCancelHandler = (event: PointerEvent) => {
465
+ updatePointer(event);
466
+ this.interactions.cancelDrag(event);
467
+ };
468
+
336
469
  canvas.addEventListener("pointermove", this.pointerMoveHandler);
337
470
  canvas.addEventListener("pointerdown", this.pointerMoveHandler);
471
+ canvas.addEventListener("pointerup", this.pointerUpHandler);
472
+ canvas.addEventListener("pointercancel", this.pointerCancelHandler);
473
+ canvas.addEventListener("pointerleave", this.pointerCancelHandler);
474
+ }
475
+
476
+ updatePointerFromInteractionEvent(event: any): void {
477
+ const global = event?.global ?? event?.data?.global;
478
+
479
+ if (!global) {
480
+ this.pointer.updateFromEvent(event);
481
+ return;
482
+ }
483
+
484
+ const screen = {
485
+ x: Number(global.x),
486
+ y: Number(global.y),
487
+ };
488
+ if (!Number.isFinite(screen.x) || !Number.isFinite(screen.y)) {
489
+ this.pointer.updateFromEvent(event);
490
+ return;
491
+ }
492
+
493
+ const viewport = this.findViewportInstance();
494
+ if (viewport && typeof viewport.toWorld === "function") {
495
+ const point = viewport.toWorld(screen.x, screen.y);
496
+ this.pointer.update(screen, { x: Number(point.x), y: Number(point.y) });
497
+ return;
498
+ }
499
+
500
+ this.pointer.update(screen);
338
501
  }
339
502
 
340
503
  private findViewportInstance(): any {
341
- const children = (this.canvasApp as any)?.stage?.children ?? [];
342
- return children.find((child: any) => (
343
- typeof child?.toWorld === "function"
344
- || child?.constructor?.name === "Viewport"
345
- ));
504
+ const find = (node: any): any => {
505
+ if (!node) return undefined;
506
+ if (typeof node?.toWorld === "function" || node?.constructor?.name === "Viewport") {
507
+ return node;
508
+ }
509
+ for (const child of node.children ?? []) {
510
+ const viewport = find(child);
511
+ if (viewport) return viewport;
512
+ }
513
+ return undefined;
514
+ };
515
+
516
+ return find((this.canvasApp as any)?.stage);
346
517
  }
347
518
 
348
519
  private prepareSyncPayload(data: any): any {
@@ -392,6 +563,9 @@ export class RpgClientEngine<T = any> {
392
563
  }
393
564
 
394
565
  private initListeners() {
566
+ if (this.socketListenersInitialized) return;
567
+ this.socketListenersInitialized = true;
568
+
395
569
  this.webSocket.on("sync", (data) => {
396
570
  if (!this.tick) {
397
571
  this.pendingSyncPackets.push(data);
@@ -420,13 +594,8 @@ export class RpgClientEngine<T = any> {
420
594
  });
421
595
 
422
596
  this.webSocket.on("changeMap", (data) => {
423
- this.sceneResetQueued = true;
424
- this.sceneMap.weatherState.set(null);
425
- this.sceneMap.lightingState.set(null);
426
- this.sceneMap.clearLightSpots();
427
- this.projectiles.clear();
428
- // Reset camera follow to default (follow current player) when changing maps
429
- this.cameraFollowTargetId.set(null);
597
+ const nextMapId = typeof data?.mapId === "string" ? data.mapId : undefined;
598
+ this.beginMapTransfer(nextMapId);
430
599
  const transferToken = typeof data?.transferToken === "string" ? data.transferToken : undefined;
431
600
  this.loadScene(data.mapId, transferToken);
432
601
  });
@@ -440,22 +609,35 @@ export class RpgClientEngine<T = any> {
440
609
  this.getComponentAnimation(id).displayEffect(params, player || position)
441
610
  });
442
611
 
612
+ this.webSocket.on("clientVisual", (data) => {
613
+ this.playClientVisual(data);
614
+ });
615
+
443
616
  this.webSocket.on("projectile:spawnBatch", (data) => {
617
+ if (!this.shouldProcessProjectilePacket(data)) return;
444
618
  this.projectiles.spawnBatch(data?.projectiles ?? [], {
619
+ mapId: data?.mapId,
445
620
  currentServerTick: this.estimateServerTick(),
446
621
  tickDurationMs: this.getPhysicsTickDurationMs(),
447
622
  });
448
623
  });
449
624
 
450
625
  this.webSocket.on("projectile:impactBatch", (data) => {
451
- this.projectiles.impactBatch(data?.impacts ?? []);
626
+ if (!this.shouldProcessProjectilePacket(data)) return;
627
+ this.projectiles.impactBatch(data?.impacts ?? [], {
628
+ mapId: data?.mapId,
629
+ });
452
630
  });
453
631
 
454
632
  this.webSocket.on("projectile:destroyBatch", (data) => {
455
- this.projectiles.destroyBatch(data?.projectiles ?? []);
633
+ if (!this.shouldProcessProjectilePacket(data)) return;
634
+ this.projectiles.destroyBatch(data?.projectiles ?? [], {
635
+ mapId: data?.mapId,
636
+ });
456
637
  });
457
638
 
458
- this.webSocket.on("projectile:clear", () => {
639
+ this.webSocket.on("projectile:clear", (data) => {
640
+ if (!this.shouldProcessProjectilePacket(data)) return;
459
641
  this.projectiles.clear();
460
642
  });
461
643
 
@@ -573,6 +755,36 @@ export class RpgClientEngine<T = any> {
573
755
  })
574
756
  }
575
757
 
758
+ private beginMapTransfer(nextMapId?: string) {
759
+ this.mapTransitionInProgress = true;
760
+ this.currentMapRoomId = nextMapId;
761
+ this.sceneResetQueued = false;
762
+ this.clearClientPredictionStates();
763
+ this.sceneMap.weatherState.set(null);
764
+ this.sceneMap.lightingState.set(null);
765
+ this.sceneMap.clearLightSpots();
766
+ this.clearComponentAnimations();
767
+ this.projectiles.setMapId(nextMapId);
768
+ this.cameraFollowTargetId.set(null);
769
+ this.sceneMap.reset();
770
+ this.sceneMap.loadPhysic();
771
+ }
772
+
773
+ private clearComponentAnimations() {
774
+ this.componentAnimations.forEach((componentAnimation) => {
775
+ componentAnimation.instance?.clear?.();
776
+ });
777
+ }
778
+
779
+ private shouldProcessProjectilePacket(data: any): boolean {
780
+ if (this.mapTransitionInProgress) return false;
781
+ const packetMapId = normalizeRoomMapId(
782
+ typeof data?.mapId === "string" ? data.mapId : undefined,
783
+ );
784
+ const currentMapId = normalizeRoomMapId(this.currentMapRoomId);
785
+ return !packetMapId || !currentMapId || packetMapId === currentMapId;
786
+ }
787
+
576
788
  private async callConnectError(error: any) {
577
789
  await lastValueFrom(this.hooks.callHooks("client-engine-onConnectError", this, error, this.socket));
578
790
  }
@@ -732,14 +944,10 @@ export class RpgClientEngine<T = any> {
732
944
  query: transferToken ? { transferToken } : undefined,
733
945
  })
734
946
  try {
735
- await this.webSocket.reconnect(() => {
736
- const saveClient = inject(SaveClientService);
737
- saveClient.initialize();
738
- this.initListeners()
739
- this.guiService._initialize()
740
- })
947
+ await this.webSocket.reconnect()
741
948
  }
742
949
  catch (error) {
950
+ this.mapTransitionInProgress = false;
743
951
  this.stopPingPong();
744
952
  await this.callConnectError(error);
745
953
  throw error;
@@ -765,6 +973,8 @@ export class RpgClientEngine<T = any> {
765
973
 
766
974
  // Signal that map loading is completed (this should be last to ensure other checks are done)
767
975
  this.mapLoadCompleted$.next(true);
976
+ this.currentMapRoomId = mapId;
977
+ this.mapTransitionInProgress = false;
768
978
  this.sceneMap.configureClientPrediction(this.predictionEnabled);
769
979
  this.sceneMap.loadPhysic()
770
980
  }
@@ -833,17 +1043,29 @@ export class RpgClientEngine<T = any> {
833
1043
 
834
1044
  // If not in cache and resolver exists, use it
835
1045
  if (this.spritesheetResolver) {
1046
+ if (this.spritesheetPromises.has(id)) {
1047
+ return this.spritesheetPromises.get(id);
1048
+ }
1049
+
836
1050
  const result = this.spritesheetResolver(id);
837
1051
 
838
1052
  // Check if result is a Promise
839
1053
  if (result instanceof Promise) {
840
- return result.then((spritesheet) => {
841
- if (spritesheet) {
842
- // Cache the resolved spritesheet
843
- this.spritesheets.set(id, spritesheet);
844
- }
845
- return spritesheet;
846
- });
1054
+ const promise = result
1055
+ .then((spritesheet) => {
1056
+ if (spritesheet) {
1057
+ // Cache the resolved spritesheet
1058
+ this.spritesheets.set(id, spritesheet);
1059
+ }
1060
+ this.spritesheetPromises.delete(id);
1061
+ return spritesheet;
1062
+ })
1063
+ .catch((error) => {
1064
+ this.spritesheetPromises.delete(id);
1065
+ throw error;
1066
+ });
1067
+ this.spritesheetPromises.set(id, promise);
1068
+ return promise;
847
1069
  } else {
848
1070
  // Synchronous result
849
1071
  if (result) {
@@ -1290,6 +1512,29 @@ export class RpgClientEngine<T = any> {
1290
1512
  return this.spriteComponents.get(id);
1291
1513
  }
1292
1514
 
1515
+ /**
1516
+ * Register a custom event component resolver.
1517
+ *
1518
+ * The last resolver returning a component wins. This lets later modules
1519
+ * override earlier defaults without replacing the whole map scene.
1520
+ *
1521
+ * @param resolver - Function receiving the synced event object
1522
+ * @returns The registered resolver
1523
+ */
1524
+ addEventComponentResolver(resolver: EventComponentResolver) {
1525
+ return this.eventComponentResolvers.add(resolver);
1526
+ }
1527
+
1528
+ /**
1529
+ * Resolve the custom CanvasEngine component for an event, if any.
1530
+ *
1531
+ * @param event - Synced client event object
1532
+ * @returns The component/config returned by the last matching resolver
1533
+ */
1534
+ resolveEventComponent(event: RpgClientEvent): EventComponentConfig | null {
1535
+ return this.eventComponentResolvers.resolve(event);
1536
+ }
1537
+
1293
1538
  registerProjectileComponent(type: string, component: any) {
1294
1539
  return this.projectiles.register(type, component);
1295
1540
  }
@@ -1298,6 +1543,42 @@ export class RpgClientEngine<T = any> {
1298
1543
  return this.projectiles.get(type);
1299
1544
  }
1300
1545
 
1546
+ /**
1547
+ * Register a named client visual macro.
1548
+ *
1549
+ * Client visuals are small client-side functions that group existing visual
1550
+ * primitives such as flash, sound, component animations, sprite animation, or
1551
+ * map shake. The server sends only the visual name and a serializable payload.
1552
+ *
1553
+ * @param name - Stable visual name sent by the server
1554
+ * @param handler - Client-side visual handler
1555
+ * @returns The registered handler
1556
+ */
1557
+ registerClientVisual(name: string, handler: ClientVisualHandler) {
1558
+ return this.clientVisuals.register(name, handler);
1559
+ }
1560
+
1561
+ /**
1562
+ * Register several named client visual macros.
1563
+ *
1564
+ * @param visuals - Map of visual names to client-side handlers
1565
+ */
1566
+ registerClientVisuals(visuals: ClientVisualMap) {
1567
+ this.clientVisuals.registerMany(visuals);
1568
+ }
1569
+
1570
+ /**
1571
+ * Play a registered client visual locally.
1572
+ *
1573
+ * This is also used by the websocket listener when the server calls
1574
+ * `player.clientVisual()` or `map.clientVisual()`.
1575
+ *
1576
+ * @param packet - Visual name and serializable payload
1577
+ */
1578
+ playClientVisual(packet: ClientVisualPacket) {
1579
+ return this.clientVisuals.play(packet, this);
1580
+ }
1581
+
1301
1582
  /**
1302
1583
  * Add a component animation to the engine
1303
1584
  *
@@ -1406,7 +1687,7 @@ export class RpgClientEngine<T = any> {
1406
1687
  });
1407
1688
  }
1408
1689
 
1409
- async processInput({ input }: { input: Direction }) {
1690
+ async processInput({ input }: { input: RpgMovementInput }) {
1410
1691
  if (this.stopProcessingInput) return;
1411
1692
 
1412
1693
  const currentPlayer = this.sceneMap.getCurrentPlayer() as any;
@@ -1419,10 +1700,20 @@ export class RpgClientEngine<T = any> {
1419
1700
  }
1420
1701
 
1421
1702
  const timestamp = Date.now();
1703
+ const movementInput = isDashInput(input)
1704
+ ? normalizeDashInput(input, currentPlayer?.direction?.())
1705
+ : input;
1706
+ if (!movementInput) return;
1707
+ if (isDashInput(movementInput)) {
1708
+ const cooldown = movementInput.cooldown ?? DEFAULT_DASH_COOLDOWN_MS;
1709
+ if (timestamp < this.dashLockedUntil) return;
1710
+ this.dashLockedUntil = timestamp + cooldown;
1711
+ }
1712
+
1422
1713
  let frame: number;
1423
1714
  let tick: number;
1424
1715
  if (this.predictionEnabled && this.prediction) {
1425
- const meta = this.prediction.recordInput(input, timestamp);
1716
+ const meta = this.prediction.recordInput(movementInput, timestamp);
1426
1717
  frame = meta.frame;
1427
1718
  tick = meta.tick;
1428
1719
  } else {
@@ -1430,12 +1721,11 @@ export class RpgClientEngine<T = any> {
1430
1721
  tick = this.getPhysicsTick();
1431
1722
  }
1432
1723
  this.inputFrameCounter = frame;
1433
- this.hooks.callHooks("client-engine-onInput", this, { input, playerId: this.playerId }).subscribe();
1724
+ this.hooks.callHooks("client-engine-onInput", this, { input: movementInput, playerId: this.playerId }).subscribe();
1434
1725
 
1435
1726
  const bodyReady = this.ensureCurrentPlayerBody();
1436
1727
  if (currentPlayer && bodyReady) {
1437
- currentPlayer.changeDirection(input);
1438
- (this.sceneMap as any).moveBody(currentPlayer, input);
1728
+ this.applyPredictedMovementInput(currentPlayer, movementInput);
1439
1729
  if (this.predictionEnabled && this.prediction) {
1440
1730
  this.pendingPredictionFrames.push(frame);
1441
1731
  if (this.pendingPredictionFrames.length > 240) {
@@ -1444,8 +1734,21 @@ export class RpgClientEngine<T = any> {
1444
1734
  }
1445
1735
  }
1446
1736
 
1447
- this.emitMovePacket(input, frame, tick, timestamp, true);
1448
- this.lastInputTime = Date.now();
1737
+ this.emitMovePacket(movementInput, frame, tick, timestamp, true);
1738
+ this.lastInputTime = isDashInput(movementInput)
1739
+ ? Date.now() + (movementInput.duration ?? DEFAULT_DASH_DURATION_MS)
1740
+ : Date.now();
1741
+ }
1742
+
1743
+ async processDash(input: Partial<RpgDashInput> = {}) {
1744
+ const currentPlayer = this.sceneMap.getCurrentPlayer() as any;
1745
+ const fallbackDirection =
1746
+ typeof currentPlayer?.direction === "function"
1747
+ ? currentPlayer.direction()
1748
+ : currentPlayer?.direction;
1749
+ const dashInput = normalizeDashInput(input, fallbackDirection);
1750
+ if (!dashInput) return;
1751
+ await this.processInput({ input: dashInput });
1449
1752
  }
1450
1753
 
1451
1754
  processAction(action: RpgActionName, data?: any): void;
@@ -1485,6 +1788,10 @@ export class RpgClientEngine<T = any> {
1485
1788
  return this.sceneMap
1486
1789
  }
1487
1790
 
1791
+ getObjectById(id: string) {
1792
+ return this.sceneMap?.getObjectById(id);
1793
+ }
1794
+
1488
1795
  private getPhysicsTick(): number {
1489
1796
  return (this.sceneMap as any)?.getTick?.() ?? 0;
1490
1797
  }
@@ -1617,7 +1924,7 @@ export class RpgClientEngine<T = any> {
1617
1924
  input: entry.direction,
1618
1925
  x: state.x,
1619
1926
  y: state.y,
1620
- direction: state.direction ?? entry.direction,
1927
+ direction: state.direction ?? resolveMoveDirection(entry.direction),
1621
1928
  });
1622
1929
  }
1623
1930
  if (trajectory.length > this.MAX_MOVE_TRAJECTORY_POINTS) {
@@ -1627,7 +1934,7 @@ export class RpgClientEngine<T = any> {
1627
1934
  }
1628
1935
 
1629
1936
  private emitMovePacket(
1630
- input: Direction,
1937
+ input: RpgMovementInput,
1631
1938
  frame: number,
1632
1939
  tick: number,
1633
1940
  timestamp: number,
@@ -1682,6 +1989,22 @@ export class RpgClientEngine<T = any> {
1682
1989
  this.emitMovePacket(latest.direction, latest.frame, latest.tick, now, false);
1683
1990
  }
1684
1991
 
1992
+ private applyPredictedMovementInput(
1993
+ player: any,
1994
+ input: RpgMovementInput
1995
+ ): boolean {
1996
+ if (isDashInput(input)) {
1997
+ const direction = vectorToDirection(input.direction);
1998
+ player.changeDirection(direction);
1999
+ return Boolean((this.sceneMap as any).dashBody?.(player, input));
2000
+ }
2001
+
2002
+ const direction = resolveMoveDirection(input);
2003
+ if (!direction) return false;
2004
+ player.changeDirection(direction);
2005
+ return Boolean((this.sceneMap as any).moveBody?.(player, direction));
2006
+ }
2007
+
1685
2008
  private getLocalPlayerState(): PredictionState<Direction> {
1686
2009
  const currentPlayer = this.sceneMap?.getCurrentPlayer();
1687
2010
  if (!currentPlayer) {
@@ -1725,7 +2048,7 @@ export class RpgClientEngine<T = any> {
1725
2048
  ? configuredMaxEntries
1726
2049
  : Math.max(600, Math.ceil(historyTtlMs / 16) + 120);
1727
2050
  this.sceneMap?.configureClientPrediction?.(true);
1728
- this.prediction = new PredictionController<Direction>({
2051
+ this.prediction = new PredictionController<RpgMovementInput, Direction>({
1729
2052
  correctionThreshold: (this.globalConfig as any)?.prediction?.correctionThreshold ?? this.SERVER_CORRECTION_THRESHOLD,
1730
2053
  historyTtlMs,
1731
2054
  maxHistoryEntries,
@@ -1940,7 +2263,7 @@ export class RpgClientEngine<T = any> {
1940
2263
 
1941
2264
  private reconcilePrediction(
1942
2265
  authoritativeState: PredictionState<Direction>,
1943
- pendingInputs: PredictionHistoryEntry<Direction>[],
2266
+ pendingInputs: PredictionHistoryEntry<RpgMovementInput, Direction>[],
1944
2267
  ): void {
1945
2268
  const player = this.getCurrentPlayer() as any;
1946
2269
  if (!player) {
@@ -1962,7 +2285,7 @@ export class RpgClientEngine<T = any> {
1962
2285
  const replayInputs = pendingInputs.slice(-600);
1963
2286
  for (const entry of replayInputs) {
1964
2287
  if (!entry?.direction) continue;
1965
- (this.sceneMap as any).moveBody(player, entry.direction);
2288
+ this.applyPredictedMovementInput(player, entry.direction);
1966
2289
  this.sceneMap.stepPredictionTick();
1967
2290
  this.prediction?.attachPredictedState(entry.frame, this.getLocalPlayerState());
1968
2291
  }
@@ -2065,7 +2388,16 @@ export class RpgClientEngine<T = any> {
2065
2388
  if (this.pointerMoveHandler && this.pointerCanvas) {
2066
2389
  this.pointerCanvas.removeEventListener('pointermove', this.pointerMoveHandler);
2067
2390
  this.pointerCanvas.removeEventListener('pointerdown', this.pointerMoveHandler);
2391
+ if (this.pointerUpHandler) {
2392
+ this.pointerCanvas.removeEventListener('pointerup', this.pointerUpHandler);
2393
+ }
2394
+ if (this.pointerCancelHandler) {
2395
+ this.pointerCanvas.removeEventListener('pointercancel', this.pointerCancelHandler);
2396
+ this.pointerCanvas.removeEventListener('pointerleave', this.pointerCancelHandler);
2397
+ }
2068
2398
  this.pointerMoveHandler = undefined;
2399
+ this.pointerUpHandler = undefined;
2400
+ this.pointerCancelHandler = undefined;
2069
2401
  this.pointerCanvas = undefined;
2070
2402
  }
2071
2403
 
@@ -2142,6 +2474,7 @@ export class RpgClientEngine<T = any> {
2142
2474
  this.cameraFollowTargetId.set(null);
2143
2475
  this.spriteComponentsBehind.set([]);
2144
2476
  this.spriteComponentsInFront.set([]);
2477
+ this.eventComponentResolvers.clear();
2145
2478
 
2146
2479
  // Clear maps and arrays
2147
2480
  this.spritesheets.clear();