@rpgjs/client 5.0.0-beta.12 → 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 (88) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/Game/Object.d.ts +2 -0
  3. package/dist/Game/Object.js +20 -6
  4. package/dist/Game/Object.js.map +1 -1
  5. package/dist/Gui/Gui.d.ts +3 -2
  6. package/dist/Gui/Gui.js +18 -6
  7. package/dist/Gui/Gui.js.map +1 -1
  8. package/dist/RpgClient.d.ts +21 -1
  9. package/dist/RpgClientEngine.d.ts +20 -2
  10. package/dist/RpgClientEngine.js +180 -17
  11. package/dist/RpgClientEngine.js.map +1 -1
  12. package/dist/components/character.ce.js +82 -7
  13. package/dist/components/character.ce.js.map +1 -1
  14. package/dist/components/gui/dialogbox/index.ce.js +27 -12
  15. package/dist/components/gui/dialogbox/index.ce.js.map +1 -1
  16. package/dist/components/gui/gameover.ce.js +4 -3
  17. package/dist/components/gui/gameover.ce.js.map +1 -1
  18. package/dist/components/gui/menu/equip-menu.ce.js +9 -8
  19. package/dist/components/gui/menu/equip-menu.ce.js.map +1 -1
  20. package/dist/components/gui/menu/exit-menu.ce.js +7 -5
  21. package/dist/components/gui/menu/exit-menu.ce.js.map +1 -1
  22. package/dist/components/gui/menu/items-menu.ce.js +8 -7
  23. package/dist/components/gui/menu/items-menu.ce.js.map +1 -1
  24. package/dist/components/gui/menu/main-menu.ce.js +12 -11
  25. package/dist/components/gui/menu/main-menu.ce.js.map +1 -1
  26. package/dist/components/gui/menu/options-menu.ce.js +7 -5
  27. package/dist/components/gui/menu/options-menu.ce.js.map +1 -1
  28. package/dist/components/gui/menu/skills-menu.ce.js +4 -2
  29. package/dist/components/gui/menu/skills-menu.ce.js.map +1 -1
  30. package/dist/components/gui/notification/notification.ce.js +4 -1
  31. package/dist/components/gui/notification/notification.ce.js.map +1 -1
  32. package/dist/components/gui/save-load.ce.js +10 -9
  33. package/dist/components/gui/save-load.ce.js.map +1 -1
  34. package/dist/components/gui/shop/shop.ce.js +17 -16
  35. package/dist/components/gui/shop/shop.ce.js.map +1 -1
  36. package/dist/components/gui/title-screen.ce.js +4 -3
  37. package/dist/components/gui/title-screen.ce.js.map +1 -1
  38. package/dist/components/interaction-components.ce.js +20 -0
  39. package/dist/components/interaction-components.ce.js.map +1 -0
  40. package/dist/components/scenes/canvas.ce.js +12 -7
  41. package/dist/components/scenes/canvas.ce.js.map +1 -1
  42. package/dist/components/scenes/draw-map.ce.js +18 -13
  43. package/dist/components/scenes/draw-map.ce.js.map +1 -1
  44. package/dist/i18n.d.ts +55 -0
  45. package/dist/i18n.js +60 -0
  46. package/dist/i18n.js.map +1 -0
  47. package/dist/i18n.spec.d.ts +1 -0
  48. package/dist/index.d.ts +2 -0
  49. package/dist/index.js +3 -1
  50. package/dist/module.js +23 -3
  51. package/dist/module.js.map +1 -1
  52. package/dist/services/interactions.d.ts +159 -0
  53. package/dist/services/interactions.js +460 -0
  54. package/dist/services/interactions.js.map +1 -0
  55. package/dist/services/interactions.spec.d.ts +1 -0
  56. package/dist/services/keyboardControls.d.ts +1 -0
  57. package/dist/services/keyboardControls.js +1 -0
  58. package/dist/services/keyboardControls.js.map +1 -1
  59. package/package.json +4 -4
  60. package/src/Game/Object.spec.ts +14 -1
  61. package/src/Game/Object.ts +34 -10
  62. package/src/Gui/Gui.spec.ts +67 -0
  63. package/src/Gui/Gui.ts +24 -7
  64. package/src/RpgClient.ts +28 -1
  65. package/src/RpgClientEngine.ts +248 -29
  66. package/src/components/character.ce +90 -7
  67. package/src/components/gui/dialogbox/index.ce +35 -14
  68. package/src/components/gui/gameover.ce +4 -3
  69. package/src/components/gui/menu/equip-menu.ce +9 -8
  70. package/src/components/gui/menu/exit-menu.ce +4 -3
  71. package/src/components/gui/menu/items-menu.ce +8 -7
  72. package/src/components/gui/menu/main-menu.ce +12 -11
  73. package/src/components/gui/menu/options-menu.ce +4 -3
  74. package/src/components/gui/menu/skills-menu.ce +2 -1
  75. package/src/components/gui/notification/notification.ce +7 -1
  76. package/src/components/gui/save-load.ce +11 -10
  77. package/src/components/gui/shop/shop.ce +17 -16
  78. package/src/components/gui/title-screen.ce +4 -3
  79. package/src/components/interaction-components.ce +23 -0
  80. package/src/components/scenes/canvas.ce +12 -7
  81. package/src/components/scenes/draw-map.ce +16 -5
  82. package/src/i18n.spec.ts +39 -0
  83. package/src/i18n.ts +59 -0
  84. package/src/index.ts +2 -0
  85. package/src/module.ts +32 -10
  86. package/src/services/interactions.spec.ts +175 -0
  87. package/src/services/interactions.ts +722 -0
  88. package/src/services/keyboardControls.ts +2 -1
@@ -6,7 +6,7 @@ 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
10
  import type { EventComponentConfig } from "./RpgClient";
11
11
  import type { RpgClientEvent } from "./Game/Event";
12
12
  import { load } from "@signe/sync";
@@ -27,6 +27,8 @@ import {
27
27
  type PredictionState,
28
28
  type RpgActionInput,
29
29
  type RpgActionName,
30
+ type RpgDashInput,
31
+ type RpgMovementInput,
30
32
  } from "@rpgjs/common";
31
33
  import { NotificationManager } from "./Gui/NotificationManager";
32
34
  import { SaveClientService } from "./services/save";
@@ -35,19 +37,97 @@ import { ProjectileManager, type ClientProjectileImpact, type ClientProjectileSp
35
37
  import { ClientVisualRegistry, type ClientVisualHandler, type ClientVisualMap, type ClientVisualPacket } from "./Game/ClientVisuals";
36
38
  import { normalizeActionInput } from "./services/actionInput";
37
39
  import { createClientPointerContext, type ClientPointerContext } from "./services/pointerContext";
40
+ import { RpgClientInteractions } from "./services/interactions";
38
41
  import { normalizeRoomMapId } from "./utils/mapId";
39
42
  import { EventComponentResolverRegistry, type EventComponentResolver } from "./Game/EventComponentResolver";
43
+ import { RpgClientBuiltinI18n } from "./i18n";
40
44
 
41
45
  interface MovementTrajectoryPoint {
42
46
  frame: number;
43
47
  tick: number;
44
48
  timestamp: number;
45
- input: Direction;
49
+ input: RpgMovementInput;
46
50
  x: number;
47
51
  y: number;
48
52
  direction?: Direction;
49
53
  }
50
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
+
51
131
  type ConfigurableTrigger<T> = Omit<Trigger<T>, "start"> & {
52
132
  start(config?: T): Promise<void>;
53
133
  };
@@ -73,11 +153,13 @@ export class RpgClientEngine<T = any> {
73
153
  width = signal("100%");
74
154
  height = signal("100%");
75
155
  spritesheets: Map<string | number, any> = new Map();
156
+ private spritesheetPromises: Map<string | number, Promise<any>> = new Map();
76
157
  sounds: Map<string, any> = new Map();
77
158
  componentAnimations: any[] = [];
78
159
  clientVisuals = new ClientVisualRegistry();
79
160
  projectiles: ProjectileManager;
80
161
  pointer: ClientPointerContext = createClientPointerContext();
162
+ interactions: RpgClientInteractions = new RpgClientInteractions(this);
81
163
  private spritesheetResolver?: (id: string | number) => any | Promise<any>;
82
164
  private soundResolver?: (id: string) => any | Promise<any>;
83
165
  particleSettings: {
@@ -103,7 +185,7 @@ export class RpgClientEngine<T = any> {
103
185
  gamePause = signal(false);
104
186
 
105
187
  private predictionEnabled = false;
106
- private prediction?: PredictionController<Direction>;
188
+ private prediction?: PredictionController<RpgMovementInput, Direction>;
107
189
  private readonly SERVER_CORRECTION_THRESHOLD = 30;
108
190
  private inputFrameCounter = 0;
109
191
  private pendingPredictionFrames: number[] = [];
@@ -111,6 +193,7 @@ export class RpgClientEngine<T = any> {
111
193
  private frameOffset = 0;
112
194
  private latestServerTick?: number;
113
195
  private latestServerTickAt = 0;
196
+ private dashLockedUntil = 0;
114
197
  // Ping/Pong for RTT measurement
115
198
  private rtt: number = 0; // Round-trip time in ms
116
199
  private pingInterval: any = null;
@@ -135,15 +218,21 @@ export class RpgClientEngine<T = any> {
135
218
  private tickSubscriptions: any[] = [];
136
219
  private resizeHandler?: () => void;
137
220
  private pointerMoveHandler?: (event: PointerEvent) => void;
221
+ private pointerUpHandler?: (event: PointerEvent) => void;
222
+ private pointerCancelHandler?: (event: PointerEvent) => void;
138
223
  private pointerCanvas?: HTMLCanvasElement;
139
224
  private pendingSyncPackets: any[] = [];
140
225
  private notificationManager: NotificationManager = new NotificationManager();
226
+ private i18nService: I18nService;
227
+ private locale?: string;
141
228
 
142
229
  constructor(public context) {
143
230
  this.webSocket = inject(WebSocketToken);
144
231
  this.guiService = inject(RpgGui);
145
232
  this.loadMapService = inject(LoadMapToken);
146
233
  this.hooks = inject<Hooks>(ModulesToken);
234
+ this.i18nService = getOrCreateI18nService(context);
235
+ this.i18nService.addMessages(RpgClientBuiltinI18n, "rpgjs-client", 0);
147
236
  this.projectiles = new ProjectileManager(
148
237
  this.hooks,
149
238
  (projectile) => this.predictProjectileImpact(projectile),
@@ -179,6 +268,25 @@ export class RpgClientEngine<T = any> {
179
268
  this.initializePredictionController();
180
269
  }
181
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
+
182
290
  /**
183
291
  * Assigns a CanvasEngine KeyboardControls instance to the dependency injection context
184
292
  *
@@ -254,7 +362,6 @@ export class RpgClientEngine<T = any> {
254
362
  this.renderer = app.renderer as unknown as PIXI.Renderer;
255
363
  this.setupPointerTracking();
256
364
  this.tick = canvasElement?.propObservables?.context['tick'].observable
257
- this.flushPendingSyncPackets();
258
365
 
259
366
  const inputCheckSubscription = this.tick.subscribe(() => {
260
367
  if (Date.now() - this.lastInputTime > 100) {
@@ -268,6 +375,7 @@ export class RpgClientEngine<T = any> {
268
375
 
269
376
  this.hooks.callHooks("client-spritesheets-load", this).subscribe();
270
377
  this.hooks.callHooks("client-spritesheetResolver-load", this).subscribe();
378
+ this.flushPendingSyncPackets();
271
379
  this.hooks.callHooks("client-sounds-load", this).subscribe();
272
380
  this.hooks.callHooks("client-soundResolver-load", this).subscribe();
273
381
 
@@ -278,6 +386,7 @@ export class RpgClientEngine<T = any> {
278
386
  this.hooks.callHooks("client-componentAnimations-load", this).subscribe();
279
387
  this.hooks.callHooks("client-clientVisuals-load", this).subscribe();
280
388
  this.hooks.callHooks("client-projectiles-load", this).subscribe();
389
+ this.hooks.callHooks("client-interactions-load", this).subscribe();
281
390
  this.hooks.callHooks("client-sprite-load", this).subscribe();
282
391
 
283
392
  await lastValueFrom(this.hooks.callHooks("client-engine-onStart", this));
@@ -324,7 +433,7 @@ export class RpgClientEngine<T = any> {
324
433
  }
325
434
 
326
435
  this.pointerCanvas = canvas;
327
- this.pointerMoveHandler = (event: PointerEvent) => {
436
+ const updatePointer = (event: PointerEvent) => {
328
437
  const rect = canvas.getBoundingClientRect();
329
438
  const screen = {
330
439
  x: event.clientX - rect.left,
@@ -344,16 +453,67 @@ export class RpgClientEngine<T = any> {
344
453
  this.pointer.update(screen, world);
345
454
  };
346
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
+
347
469
  canvas.addEventListener("pointermove", this.pointerMoveHandler);
348
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);
349
501
  }
350
502
 
351
503
  private findViewportInstance(): any {
352
- const children = (this.canvasApp as any)?.stage?.children ?? [];
353
- return children.find((child: any) => (
354
- typeof child?.toWorld === "function"
355
- || child?.constructor?.name === "Viewport"
356
- ));
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);
357
517
  }
358
518
 
359
519
  private prepareSyncPayload(data: any): any {
@@ -883,17 +1043,29 @@ export class RpgClientEngine<T = any> {
883
1043
 
884
1044
  // If not in cache and resolver exists, use it
885
1045
  if (this.spritesheetResolver) {
1046
+ if (this.spritesheetPromises.has(id)) {
1047
+ return this.spritesheetPromises.get(id);
1048
+ }
1049
+
886
1050
  const result = this.spritesheetResolver(id);
887
1051
 
888
1052
  // Check if result is a Promise
889
1053
  if (result instanceof Promise) {
890
- return result.then((spritesheet) => {
891
- if (spritesheet) {
892
- // Cache the resolved spritesheet
893
- this.spritesheets.set(id, spritesheet);
894
- }
895
- return spritesheet;
896
- });
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;
897
1069
  } else {
898
1070
  // Synchronous result
899
1071
  if (result) {
@@ -1515,7 +1687,7 @@ export class RpgClientEngine<T = any> {
1515
1687
  });
1516
1688
  }
1517
1689
 
1518
- async processInput({ input }: { input: Direction }) {
1690
+ async processInput({ input }: { input: RpgMovementInput }) {
1519
1691
  if (this.stopProcessingInput) return;
1520
1692
 
1521
1693
  const currentPlayer = this.sceneMap.getCurrentPlayer() as any;
@@ -1528,10 +1700,20 @@ export class RpgClientEngine<T = any> {
1528
1700
  }
1529
1701
 
1530
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
+
1531
1713
  let frame: number;
1532
1714
  let tick: number;
1533
1715
  if (this.predictionEnabled && this.prediction) {
1534
- const meta = this.prediction.recordInput(input, timestamp);
1716
+ const meta = this.prediction.recordInput(movementInput, timestamp);
1535
1717
  frame = meta.frame;
1536
1718
  tick = meta.tick;
1537
1719
  } else {
@@ -1539,12 +1721,11 @@ export class RpgClientEngine<T = any> {
1539
1721
  tick = this.getPhysicsTick();
1540
1722
  }
1541
1723
  this.inputFrameCounter = frame;
1542
- 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();
1543
1725
 
1544
1726
  const bodyReady = this.ensureCurrentPlayerBody();
1545
1727
  if (currentPlayer && bodyReady) {
1546
- currentPlayer.changeDirection(input);
1547
- (this.sceneMap as any).moveBody(currentPlayer, input);
1728
+ this.applyPredictedMovementInput(currentPlayer, movementInput);
1548
1729
  if (this.predictionEnabled && this.prediction) {
1549
1730
  this.pendingPredictionFrames.push(frame);
1550
1731
  if (this.pendingPredictionFrames.length > 240) {
@@ -1553,8 +1734,21 @@ export class RpgClientEngine<T = any> {
1553
1734
  }
1554
1735
  }
1555
1736
 
1556
- this.emitMovePacket(input, frame, tick, timestamp, true);
1557
- 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 });
1558
1752
  }
1559
1753
 
1560
1754
  processAction(action: RpgActionName, data?: any): void;
@@ -1730,7 +1924,7 @@ export class RpgClientEngine<T = any> {
1730
1924
  input: entry.direction,
1731
1925
  x: state.x,
1732
1926
  y: state.y,
1733
- direction: state.direction ?? entry.direction,
1927
+ direction: state.direction ?? resolveMoveDirection(entry.direction),
1734
1928
  });
1735
1929
  }
1736
1930
  if (trajectory.length > this.MAX_MOVE_TRAJECTORY_POINTS) {
@@ -1740,7 +1934,7 @@ export class RpgClientEngine<T = any> {
1740
1934
  }
1741
1935
 
1742
1936
  private emitMovePacket(
1743
- input: Direction,
1937
+ input: RpgMovementInput,
1744
1938
  frame: number,
1745
1939
  tick: number,
1746
1940
  timestamp: number,
@@ -1795,6 +1989,22 @@ export class RpgClientEngine<T = any> {
1795
1989
  this.emitMovePacket(latest.direction, latest.frame, latest.tick, now, false);
1796
1990
  }
1797
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
+
1798
2008
  private getLocalPlayerState(): PredictionState<Direction> {
1799
2009
  const currentPlayer = this.sceneMap?.getCurrentPlayer();
1800
2010
  if (!currentPlayer) {
@@ -1838,7 +2048,7 @@ export class RpgClientEngine<T = any> {
1838
2048
  ? configuredMaxEntries
1839
2049
  : Math.max(600, Math.ceil(historyTtlMs / 16) + 120);
1840
2050
  this.sceneMap?.configureClientPrediction?.(true);
1841
- this.prediction = new PredictionController<Direction>({
2051
+ this.prediction = new PredictionController<RpgMovementInput, Direction>({
1842
2052
  correctionThreshold: (this.globalConfig as any)?.prediction?.correctionThreshold ?? this.SERVER_CORRECTION_THRESHOLD,
1843
2053
  historyTtlMs,
1844
2054
  maxHistoryEntries,
@@ -2053,7 +2263,7 @@ export class RpgClientEngine<T = any> {
2053
2263
 
2054
2264
  private reconcilePrediction(
2055
2265
  authoritativeState: PredictionState<Direction>,
2056
- pendingInputs: PredictionHistoryEntry<Direction>[],
2266
+ pendingInputs: PredictionHistoryEntry<RpgMovementInput, Direction>[],
2057
2267
  ): void {
2058
2268
  const player = this.getCurrentPlayer() as any;
2059
2269
  if (!player) {
@@ -2075,7 +2285,7 @@ export class RpgClientEngine<T = any> {
2075
2285
  const replayInputs = pendingInputs.slice(-600);
2076
2286
  for (const entry of replayInputs) {
2077
2287
  if (!entry?.direction) continue;
2078
- (this.sceneMap as any).moveBody(player, entry.direction);
2288
+ this.applyPredictedMovementInput(player, entry.direction);
2079
2289
  this.sceneMap.stepPredictionTick();
2080
2290
  this.prediction?.attachPredictedState(entry.frame, this.getLocalPlayerState());
2081
2291
  }
@@ -2178,7 +2388,16 @@ export class RpgClientEngine<T = any> {
2178
2388
  if (this.pointerMoveHandler && this.pointerCanvas) {
2179
2389
  this.pointerCanvas.removeEventListener('pointermove', this.pointerMoveHandler);
2180
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
+ }
2181
2398
  this.pointerMoveHandler = undefined;
2399
+ this.pointerUpHandler = undefined;
2400
+ this.pointerCancelHandler = undefined;
2182
2401
  this.pointerCanvas = undefined;
2183
2402
  }
2184
2403
 
@@ -1,4 +1,19 @@
1
- <Container x={smoothX} y={smoothY} zIndex={z} viewportFollow={shouldFollowCamera} controls onBeforeDestroy visible>
1
+ <Container
2
+ x={smoothX}
3
+ y={smoothY}
4
+ zIndex={z}
5
+ viewportFollow={shouldFollowCamera}
6
+ controls
7
+ onBeforeDestroy
8
+ visible
9
+ cursor={interactionCursor}
10
+ pointerover={interactionPointerOver}
11
+ pointerout={interactionPointerOut}
12
+ pointerdown={interactionPointerDown}
13
+ pointerup={interactionPointerUp}
14
+ pointermove={interactionPointerMove}
15
+ click={interactionClick}
16
+ >
2
17
  @for (compConfig of normalizedComponentsBehind) {
3
18
  <Container>
4
19
  <compConfig.component object={sprite} ...compConfig.props />
@@ -34,11 +49,17 @@
34
49
  <compConfig.component object={sprite} ...compConfig.props />
35
50
  </Container>
36
51
  }
52
+ <InteractionComponents
53
+ object={sprite}
54
+ bounds={graphicBounds}
55
+ hitboxBounds={hitboxBounds}
56
+ graphicBounds={graphicBounds}
57
+ />
37
58
  @for (attachedGui of attachedGuis) {
38
59
  @if (shouldDisplayAttachedGui) {
39
60
  <Container>
40
- <attachedGui.component ...attachedGui.data() dependencies={attachedGui.dependencies} object={sprite} onFinish={(data) => {
41
- onAttachedGuiFinish(attachedGui, data)
61
+ <attachedGui.component ...attachedGui.data() dependencies={attachedGui.dependencies} object={sprite} guiOpenId={attachedGui.openId} onFinish={(data, guiOpenId) => {
62
+ onAttachedGuiFinish(attachedGui, data, guiOpenId)
42
63
  }} onInteraction={(name, data) => {
43
64
  onAttachedGuiInteraction(attachedGui, name, data)
44
65
  }} />
@@ -60,6 +81,7 @@
60
81
  import { normalizeEventComponent } from "../Game/EventComponentResolver";
61
82
  import Hit from "./effects/hit.ce";
62
83
  import PlayerComponents from "./player-components.ce";
84
+ import InteractionComponents from "./interaction-components.ce";
63
85
  import { RpgGui } from "../Gui/Gui";
64
86
  import { getCanMoveValue } from "../utils/readPropValue";
65
87
  import {
@@ -352,6 +374,20 @@
352
374
  return direction();
353
375
  };
354
376
 
377
+ const directionToDashVector = (currentDirection) => {
378
+ switch (currentDirection) {
379
+ case Direction.Left:
380
+ return { x: -1, y: 0 };
381
+ case Direction.Right:
382
+ return { x: 1, y: 0 };
383
+ case Direction.Up:
384
+ return { x: 0, y: -1 };
385
+ case Direction.Down:
386
+ default:
387
+ return { x: 0, y: 1 };
388
+ }
389
+ };
390
+
355
391
  const withCurrentDirection = (payload) => {
356
392
  if (payload.action !== 'action') return payload;
357
393
  const data = payload.data && typeof payload.data === 'object'
@@ -393,6 +429,13 @@
393
429
  playPredictedWalkAnimation();
394
430
  };
395
431
 
432
+ const processDashInput = () => {
433
+ if (!canControls()) return;
434
+ client.processDash({
435
+ direction: directionToDashVector(resolveSpriteDirection()),
436
+ });
437
+ };
438
+
396
439
  const actionBind = () => getKeyboardControlBind(keyboardControls.action);
397
440
  const keyboardEventId = (event) => `${event.keyCode}:${event.code}:${event.key}`;
398
441
 
@@ -463,6 +506,12 @@
463
506
  }
464
507
  },
465
508
  },
509
+ dash: {
510
+ bind: keyboardControls.dash,
511
+ keyDown() {
512
+ processDashInput()
513
+ },
514
+ },
466
515
  escape: {
467
516
  bind: keyboardControls.escape,
468
517
  keyDown() {
@@ -512,7 +561,7 @@
512
561
  }
513
562
 
514
563
  const graphicScale = (graphicObject) => {
515
- const scale = graphicObject?.scale;
564
+ const scale = graphicObject?.displayScale ?? graphicObject?.scale;
516
565
  if (Array.isArray(scale)) return scale;
517
566
  if (typeof scale === 'number') return [scale, scale];
518
567
  if (scale && typeof scale === 'object') {
@@ -824,6 +873,31 @@
824
873
  };
825
874
  });
826
875
 
876
+ const interactionBounds = () => ({
877
+ bounds: graphicBounds(),
878
+ hitbox: hitboxBounds(),
879
+ graphic: graphicBounds()
880
+ });
881
+
882
+ const interactionCursor = computed(() =>
883
+ client.interactions.cursorFor(sprite, interactionBounds())
884
+ );
885
+
886
+ const handleInteraction = (type) => (event) => {
887
+ client.updatePointerFromInteractionEvent(event);
888
+ client.interactions.handle(sprite, type, {
889
+ event,
890
+ bounds: interactionBounds()
891
+ });
892
+ };
893
+
894
+ const interactionPointerOver = handleInteraction('pointerover');
895
+ const interactionPointerOut = handleInteraction('pointerout');
896
+ const interactionPointerDown = handleInteraction('pointerdown');
897
+ const interactionPointerUp = handleInteraction('pointerup');
898
+ const interactionPointerMove = handleInteraction('pointermove');
899
+ const interactionClick = handleInteraction('click');
900
+
827
901
  // Combine animation change detection with movement state from smoothX/smoothY
828
902
  const movementAnimations = ['walk', 'stand'];
829
903
  const epsilon = 0; // movement threshold to consider the easing still running
@@ -897,7 +971,8 @@
897
971
  const isTemporaryAnimationPlaying =
898
972
  sprite.animationIsPlaying && sprite.animationIsPlaying();
899
973
 
900
- if (sprite.animationFixed && isMovementAnimation && isTemporaryAnimationPlaying) {
974
+ if (sprite.animationFixed && isMovementAnimation) {
975
+ realAnimationName.set(curr);
901
976
  return;
902
977
  }
903
978
 
@@ -1001,8 +1076,16 @@
1001
1076
  * @param gui - The GUI instance
1002
1077
  * @param data - Data passed from the GUI component
1003
1078
  */
1004
- const onAttachedGuiFinish = (gui, data) => {
1005
- guiService.guiClose(gui.name, data);
1079
+ const normalizeOpenId = (value) => {
1080
+ const resolved = typeof value === "function" ? value() : value;
1081
+ return typeof resolved === "string" && resolved.length > 0 ? resolved : undefined;
1082
+ };
1083
+
1084
+ const onAttachedGuiFinish = (gui, data, guiOpenId) => {
1085
+ const completedOpenId = normalizeOpenId(guiOpenId);
1086
+ const currentOpenId = normalizeOpenId(gui.openId);
1087
+ if (completedOpenId && currentOpenId && completedOpenId !== currentOpenId) return;
1088
+ guiService.guiClose(gui.name, data, completedOpenId ?? currentOpenId);
1006
1089
  };
1007
1090
 
1008
1091
  /**