@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
@@ -35,6 +35,117 @@ describe("ProjectileManager", () => {
35
35
  expect(onSpawn).toHaveBeenCalledWith(expect.objectContaining({ id: "p1", type: "fireball" }));
36
36
  });
37
37
 
38
+ test("ignores projectile packets from another map", () => {
39
+ const hooks = new Hooks([], "client");
40
+ const manager = new ProjectileManager(hooks);
41
+ const component = () => null;
42
+
43
+ manager.register("fireball", component);
44
+ manager.setMapId("map-town");
45
+ manager.spawnBatch([
46
+ {
47
+ id: "old-map-projectile",
48
+ type: "fireball",
49
+ origin: { x: 10, y: 20 },
50
+ direction: { x: 1, y: 0 },
51
+ speed: 100,
52
+ range: 500,
53
+ ttl: 5,
54
+ spawnTick: 1,
55
+ },
56
+ ], { mapId: "map-dungeon" });
57
+
58
+ expect(manager.current()).toHaveLength(0);
59
+
60
+ manager.spawnBatch([
61
+ {
62
+ id: "current-map-projectile",
63
+ type: "fireball",
64
+ origin: { x: 10, y: 20 },
65
+ direction: { x: 1, y: 0 },
66
+ speed: 100,
67
+ range: 500,
68
+ ttl: 5,
69
+ spawnTick: 1,
70
+ },
71
+ ], { mapId: "map-town" });
72
+
73
+ expect(manager.current()).toHaveLength(1);
74
+ });
75
+
76
+ test("accepts server map ids without the client room prefix", () => {
77
+ const hooks = new Hooks([], "client");
78
+ const manager = new ProjectileManager(hooks);
79
+ const component = () => null;
80
+
81
+ manager.register("fireball", component);
82
+ manager.setMapId("map-town");
83
+ manager.spawnBatch([
84
+ {
85
+ id: "server-map-projectile",
86
+ type: "fireball",
87
+ origin: { x: 10, y: 20 },
88
+ direction: { x: 1, y: 0 },
89
+ speed: 100,
90
+ range: 500,
91
+ ttl: 5,
92
+ spawnTick: 1,
93
+ },
94
+ ], { mapId: "town" });
95
+
96
+ expect(manager.getMapId()).toBe("town");
97
+ expect(manager.current()).toHaveLength(1);
98
+ });
99
+
100
+ test("accepts prefixed map ids when the manager stores the logical map id", () => {
101
+ const hooks = new Hooks([], "client");
102
+ const manager = new ProjectileManager(hooks);
103
+ const component = () => null;
104
+
105
+ manager.register("fireball", component);
106
+ manager.setMapId("town");
107
+ manager.spawnBatch([
108
+ {
109
+ id: "prefixed-map-projectile",
110
+ type: "fireball",
111
+ origin: { x: 10, y: 20 },
112
+ direction: { x: 1, y: 0 },
113
+ speed: 100,
114
+ range: 500,
115
+ ttl: 5,
116
+ spawnTick: 1,
117
+ },
118
+ ], { mapId: "map-town" });
119
+
120
+ expect(manager.current()).toHaveLength(1);
121
+ });
122
+
123
+ test("clears projectiles when switching map ids", () => {
124
+ const hooks = new Hooks([], "client");
125
+ const manager = new ProjectileManager(hooks);
126
+
127
+ manager.register("fireball", () => null);
128
+ manager.setMapId("map-town");
129
+ manager.spawnBatch([
130
+ {
131
+ id: "p1",
132
+ type: "fireball",
133
+ origin: { x: 10, y: 20 },
134
+ direction: { x: 1, y: 0 },
135
+ speed: 100,
136
+ range: 500,
137
+ ttl: 5,
138
+ spawnTick: 1,
139
+ },
140
+ ], { mapId: "map-town" });
141
+
142
+ expect(manager.current()).toHaveLength(1);
143
+
144
+ manager.setMapId("map-dungeon");
145
+
146
+ expect(manager.current()).toHaveLength(0);
147
+ });
148
+
38
149
  test("starts visuals at the spawn origin even when a server tick estimate exists", () => {
39
150
  vi.useFakeTimers();
40
151
  vi.setSystemTime(2000);
@@ -1,5 +1,6 @@
1
1
  import { computed, signal } from "canvasengine";
2
2
  import { Hooks } from "@rpgjs/common";
3
+ import { normalizeRoomMapId } from "../utils/mapId";
3
4
 
4
5
  export interface ClientProjectileSpawn {
5
6
  id: string;
@@ -65,6 +66,7 @@ export interface ProjectileSpawnClock {
65
66
  now?: number;
66
67
  currentServerTick?: number;
67
68
  tickDurationMs?: number;
69
+ mapId?: string;
68
70
  }
69
71
 
70
72
  interface RuntimeProjectile {
@@ -84,6 +86,7 @@ export class ProjectileManager {
84
86
  private readonly projectiles = new Map<string, RuntimeProjectile>();
85
87
  private readonly version = signal(0);
86
88
  private readonly impactDurationMs = 350;
89
+ private mapId?: string;
87
90
 
88
91
  constructor(
89
92
  private readonly hooks: Hooks,
@@ -118,7 +121,19 @@ export class ProjectileManager {
118
121
  return this.components.get(type);
119
122
  }
120
123
 
124
+ setMapId(mapId: string | undefined): void {
125
+ const normalizedMapId = normalizeRoomMapId(mapId);
126
+ if (this.mapId === normalizedMapId) return;
127
+ this.mapId = normalizedMapId;
128
+ this.clear();
129
+ }
130
+
131
+ getMapId(): string | undefined {
132
+ return this.mapId;
133
+ }
134
+
121
135
  spawnBatch(projectiles: ClientProjectileSpawn[], clock: ProjectileSpawnClock = {}): void {
136
+ if (!this.acceptsMap(clock.mapId)) return;
122
137
  const now = clock.now ?? Date.now();
123
138
  for (const projectile of projectiles) {
124
139
  const component = this.components.get(projectile.type);
@@ -142,7 +157,8 @@ export class ProjectileManager {
142
157
  this.touch();
143
158
  }
144
159
 
145
- impactBatch(impacts: ClientProjectileImpact[]): void {
160
+ impactBatch(impacts: ClientProjectileImpact[], context: { mapId?: string } = {}): void {
161
+ if (!this.acceptsMap(context.mapId)) return;
146
162
  const now = Date.now();
147
163
  for (const impact of impacts) {
148
164
  const projectile = this.projectiles.get(impact.id);
@@ -155,7 +171,8 @@ export class ProjectileManager {
155
171
  this.touch();
156
172
  }
157
173
 
158
- destroyBatch(projectiles: ClientProjectileDestroy[]): void {
174
+ destroyBatch(projectiles: ClientProjectileDestroy[], context: { mapId?: string } = {}): void {
175
+ if (!this.acceptsMap(context.mapId)) return;
159
176
  const now = Date.now();
160
177
  for (const destroyed of projectiles) {
161
178
  const projectile = this.projectiles.get(destroyed.id);
@@ -241,6 +258,11 @@ export class ProjectileManager {
241
258
  };
242
259
  }
243
260
 
261
+ private acceptsMap(mapId: string | undefined): boolean {
262
+ const normalizedMapId = normalizeRoomMapId(mapId);
263
+ return !normalizedMapId || !this.mapId || normalizedMapId === this.mapId;
264
+ }
265
+
244
266
  private isWaitingForDelay(projectile: RuntimeProjectile, now: number): boolean {
245
267
  const delayMs = (projectile.spawn.delay ?? 0) * 1000;
246
268
  return now - projectile.createdAt - delayMs < 0;
@@ -64,6 +64,73 @@ const VueTooltip = {
64
64
  };
65
65
 
66
66
  describe("RpgGui Vue integration", () => {
67
+ test("tracks GUI open ids and sends them back when closing", async () => {
68
+ const { gui, socket } = await createGui();
69
+ await gui._initialize();
70
+ const openHandler = socket.on.mock.calls.find(([event]) => event === "gui.open")?.[1];
71
+
72
+ openHandler({
73
+ guiId: PrebuiltGui.Dialog,
74
+ guiOpenId: "open-1",
75
+ data: { message: "Hello" },
76
+ });
77
+
78
+ expect(gui.get(PrebuiltGui.Dialog)?.openId).toBe("open-1");
79
+
80
+ gui.guiClose(PrebuiltGui.Dialog, 0, gui.get(PrebuiltGui.Dialog)?.openId);
81
+
82
+ expect(socket.emit).toHaveBeenCalledWith("gui.exit", {
83
+ guiId: PrebuiltGui.Dialog,
84
+ guiOpenId: "open-1",
85
+ data: 0,
86
+ });
87
+ });
88
+
89
+ test("does not emit malformed GUI open ids", async () => {
90
+ const { gui, socket } = await createGui();
91
+
92
+ gui.guiClose(PrebuiltGui.Dialog, 0, (() => "open-1") as any);
93
+
94
+ expect(socket.emit).toHaveBeenCalledWith("gui.exit", {
95
+ guiId: PrebuiltGui.Dialog,
96
+ guiOpenId: undefined,
97
+ data: 0,
98
+ });
99
+ });
100
+
101
+ test("ignores stale server close events for previous GUI opens", async () => {
102
+ const { gui, socket } = await createGui();
103
+ await gui._initialize();
104
+ const openHandler = socket.on.mock.calls.find(([event]) => event === "gui.open")?.[1];
105
+ const exitHandler = socket.on.mock.calls.find(([event]) => event === "gui.exit")?.[1];
106
+
107
+ openHandler({
108
+ guiId: PrebuiltGui.Dialog,
109
+ guiOpenId: "open-1",
110
+ data: { message: "First" },
111
+ });
112
+ openHandler({
113
+ guiId: PrebuiltGui.Dialog,
114
+ guiOpenId: "open-2",
115
+ data: { message: "Second" },
116
+ });
117
+
118
+ exitHandler({
119
+ guiId: PrebuiltGui.Dialog,
120
+ guiOpenId: "open-1",
121
+ });
122
+
123
+ expect(gui.isDisplaying(PrebuiltGui.Dialog)).toBe(true);
124
+ expect(gui.get(PrebuiltGui.Dialog)?.data()).toEqual({ message: "Second" });
125
+
126
+ exitHandler({
127
+ guiId: PrebuiltGui.Dialog,
128
+ guiOpenId: "open-2",
129
+ });
130
+
131
+ expect(gui.isDisplaying(PrebuiltGui.Dialog)).toBe(false);
132
+ });
133
+
67
134
  test("separates CanvasEngine and Vue GUI registries", async () => {
68
135
  const { gui } = await createGui();
69
136
 
package/src/Gui/Gui.ts CHANGED
@@ -39,6 +39,7 @@ export interface GuiInstance {
39
39
  component: any;
40
40
  display: WritableSignal<boolean>;
41
41
  data: WritableSignal<any>;
42
+ openId?: string;
42
43
  autoDisplay: boolean;
43
44
  dependencies?: Signal[];
44
45
  subscription?: Subscription;
@@ -50,6 +51,7 @@ type GuiState = {
50
51
  component: any;
51
52
  display: boolean;
52
53
  data: any;
54
+ openId?: string;
53
55
  attachToSprite: boolean;
54
56
  };
55
57
 
@@ -179,12 +181,18 @@ export class RpgGui {
179
181
  }
180
182
 
181
183
  async _initialize() {
182
- this.webSocket.on("gui.open", (data: { guiId: string; data: any }) => {
184
+ this.webSocket.on("gui.open", (data: { guiId: string; data: any; guiOpenId?: string }) => {
183
185
  this.clearPendingActions(data.guiId);
184
- this.display(data.guiId, data.data);
186
+ this.display(data.guiId, data.data, [], data.guiOpenId);
185
187
  });
186
188
 
187
- this.webSocket.on("gui.exit", (guiId: string) => {
189
+ this.webSocket.on("gui.exit", (payload: string | { guiId: string; guiOpenId?: string }) => {
190
+ const guiId = typeof payload === "string" ? payload : payload.guiId;
191
+ const guiOpenId = typeof payload === "string" ? undefined : payload.guiOpenId;
192
+ const current = this.get(guiId);
193
+ if (guiOpenId && current?.openId && current.openId !== guiOpenId) {
194
+ return;
195
+ }
188
196
  this.hide(guiId);
189
197
  });
190
198
 
@@ -256,9 +264,12 @@ export class RpgGui {
256
264
  });
257
265
  }
258
266
 
259
- guiClose(guiId: string, data?: any) {
267
+ guiClose(guiId: string, data?: any, guiOpenId?: unknown) {
268
+ const normalizedOpenId =
269
+ typeof guiOpenId === "string" && guiOpenId.length > 0 ? guiOpenId : undefined;
260
270
  this.webSocket.emit("gui.exit", {
261
271
  guiId,
272
+ guiOpenId: normalizedOpenId,
262
273
  data,
263
274
  });
264
275
  }
@@ -308,6 +319,7 @@ export class RpgGui {
308
319
  component,
309
320
  display: signal<boolean>(gui.display || false),
310
321
  data: signal<any>(gui.data || {}),
322
+ openId: undefined,
311
323
  autoDisplay: gui.autoDisplay || false,
312
324
  dependencies: gui.dependencies ? gui.dependencies() : [],
313
325
  attachToSprite,
@@ -431,7 +443,7 @@ export class RpgGui {
431
443
  * gui.display('shop', { shopId: 1 }, [playerSignal, shopSignal]);
432
444
  * ```
433
445
  */
434
- display(id: string, data = {}, dependencies: Signal[] = []) {
446
+ display(id: string, data = {}, dependencies: Signal[] = [], openId?: string) {
435
447
  if (!this.exists(id)) {
436
448
  throw throwError(id);
437
449
  }
@@ -443,8 +455,9 @@ export class RpgGui {
443
455
 
444
456
  if (isVueComponent) {
445
457
  // Handle Vue component display
446
- this._handleVueComponentDisplay(id, data, dependencies, guiInstance);
458
+ this._handleVueComponentDisplay(id, data, dependencies, guiInstance, openId);
447
459
  } else {
460
+ guiInstance.openId = openId;
448
461
  guiInstance.data.set(data);
449
462
  guiInstance.display.set(true);
450
463
  }
@@ -464,7 +477,7 @@ export class RpgGui {
464
477
  * @param dependencies - Runtime dependencies
465
478
  * @param guiInstance - GUI instance
466
479
  */
467
- private _handleVueComponentDisplay(id: string, data: any, dependencies: Signal[], guiInstance: GuiInstance) {
480
+ private _handleVueComponentDisplay(id: string, data: any, dependencies: Signal[], guiInstance: GuiInstance, openId?: string) {
468
481
  // Unsubscribe from previous subscription if exists
469
482
  if (guiInstance.subscription) {
470
483
  guiInstance.subscription.unsubscribe();
@@ -482,6 +495,7 @@ export class RpgGui {
482
495
  deps.map(dependency => dependency.observable)
483
496
  ).subscribe((values) => {
484
497
  if (values.every(value => value !== undefined)) {
498
+ guiInstance.openId = openId;
485
499
  guiInstance.data.set(data);
486
500
  guiInstance.display.set(true);
487
501
  this._notifyVueGui(id, true, data);
@@ -491,6 +505,7 @@ export class RpgGui {
491
505
  }
492
506
 
493
507
  // No dependencies, display immediately
508
+ guiInstance.openId = openId;
494
509
  guiInstance.data.set(data);
495
510
  guiInstance.display.set(true);
496
511
  this._notifyVueGui(id, true, data);
@@ -523,6 +538,7 @@ export class RpgGui {
523
538
  }
524
539
 
525
540
  guiInstance.display.set(false)
541
+ guiInstance.openId = undefined;
526
542
 
527
543
  // Check if it's a Vue component and notify VueGui
528
544
  const isVueComponent = this.extraGuis.some(gui => gui.name === id);
@@ -573,6 +589,7 @@ export class RpgGui {
573
589
  component: gui.component,
574
590
  display,
575
591
  data,
592
+ openId: gui.openId,
576
593
  attachToSprite: gui.attachToSprite || false,
577
594
  };
578
595
  }
package/src/RpgClient.ts CHANGED
@@ -2,11 +2,17 @@ import { ComponentFunction, Signal } from 'canvasengine'
2
2
  import { RpgClientEngine } from './RpgClientEngine'
3
3
  import { Loader, Container } from 'pixi.js'
4
4
  import { RpgClientObject } from './Game/Object'
5
- import { type MapPhysicsEntityContext, type MapPhysicsInitContext, type RpgActionName } from '@rpgjs/common'
5
+ import type { RpgClientEvent } from './Game/Event'
6
+ import { type I18nMessages, type MapPhysicsEntityContext, type MapPhysicsInitContext, type RpgActionName } from '@rpgjs/common'
6
7
  import type {
7
8
  ClientProjectileSpawn,
8
9
  RenderedProjectileProps,
9
10
  } from './Game/ProjectileManager'
11
+ import type { ClientVisualMap } from './Game/ClientVisuals'
12
+ import type {
13
+ RpgInteractionBehavior,
14
+ RpgInteractionMatcher,
15
+ } from './services/interactions'
10
16
 
11
17
  type RpgClass<T = any> = new (...args: any[]) => T
12
18
  type RpgComponent = RpgClientObject
@@ -18,6 +24,16 @@ export type SpriteComponentConfig = ComponentFunction | {
18
24
  dependencies?: (object: RpgClientObject) => any[]
19
25
  }
20
26
 
27
+ export type EventComponentSprite = RpgClientEvent & Record<string, any>
28
+
29
+ export type EventComponentConfig = ComponentFunction | {
30
+ component: ComponentFunction
31
+ props?: Record<string, any> | ((event: EventComponentSprite) => Record<string, any>)
32
+ data?: Record<string, any> | ((event: EventComponentSprite) => Record<string, any>)
33
+ dependencies?: (event: EventComponentSprite) => any[]
34
+ renderGraphic?: boolean
35
+ }
36
+
21
37
  export interface RpgSpriteBeforeRemoveContext {
22
38
  reason?: string
23
39
  data?: any
@@ -142,6 +158,31 @@ export interface RpgSpriteHooks {
142
158
  * ```
143
159
  */
144
160
  components?: Record<string, ComponentFunction>
161
+
162
+ /**
163
+ * Resolve a custom CanvasEngine component for a specific event.
164
+ *
165
+ * The component always receives the synced event object as the `sprite` prop.
166
+ * Custom props are merged in addition to `sprite`, but cannot replace it.
167
+ * Return `null` or `undefined` to keep the default graphic renderer.
168
+ *
169
+ * @prop { (event: EventComponentSprite) => EventComponentConfig | null | undefined } [eventComponent]
170
+ * @memberof RpgSpriteHooks
171
+ * @example
172
+ * ```ts
173
+ * import ChestEvent from './components/chest-event.ce'
174
+ *
175
+ * const sprite: RpgSpriteHooks = {
176
+ * eventComponent(sprite) {
177
+ * if (sprite.name === 'CHEST') {
178
+ * return ChestEvent
179
+ * }
180
+ * return null
181
+ * }
182
+ * }
183
+ * ```
184
+ */
185
+ eventComponent?: (event: EventComponentSprite) => EventComponentConfig | null | undefined
145
186
 
146
187
  /**
147
188
  * As soon as the sprite is initialized
@@ -326,6 +367,14 @@ export interface RpgProjectileHooks {
326
367
  }
327
368
 
328
369
  export interface RpgClient {
370
+ /**
371
+ * Default translations owned by this client module.
372
+ *
373
+ * Game-level translations provided with `provideI18n()` override module
374
+ * translations when they share the same locale and key.
375
+ */
376
+ i18n?: I18nMessages
377
+
329
378
  /**
330
379
  * Add hooks to the player or engine. All modules can listen to the hook
331
380
  *
@@ -721,6 +770,37 @@ export interface RpgClient {
721
770
  component: ComponentFunction
722
771
  }[]
723
772
 
773
+ /**
774
+ * Named client-side visual macros.
775
+ *
776
+ * Use client visuals when the server needs to trigger a group of existing
777
+ * client visual primitives at once, such as a flash, damage text, sound,
778
+ * component animation, and camera shake. The server sends only the visual
779
+ * name and a serializable payload; the rendering details live on the client.
780
+ *
781
+ * For a single sound, flash, or component animation, prefer the direct
782
+ * server APIs (`playSound`, `flash`, `showComponentAnimation`). Client
783
+ * visuals are meant to group several visual operations and reduce bandwidth.
784
+ *
785
+ * ```ts
786
+ * import { defineModule, RpgClient } from '@rpgjs/client'
787
+ *
788
+ * export default defineModule<RpgClient>({
789
+ * clientVisuals: {
790
+ * hit({ target, data }, helpers) {
791
+ * helpers.flash(target, { type: 'tint', tint: 'red' })
792
+ * helpers.showHit(target, `-${data.damage}`)
793
+ * helpers.sound('hit')
794
+ * }
795
+ * }
796
+ * })
797
+ * ```
798
+ *
799
+ * @prop {Record<string, ClientVisualHandler>} [clientVisuals]
800
+ * @memberof RpgClient
801
+ */
802
+ clientVisuals?: ClientVisualMap
803
+
724
804
  /**
725
805
  * Client-side projectile rendering configuration.
726
806
  *
@@ -728,4 +808,19 @@ export interface RpgClient {
728
808
  * compact spawn/impact/destroy events and the client predicts x/y locally.
729
809
  */
730
810
  projectiles?: RpgProjectileHooks
811
+
812
+ /**
813
+ * Client-only pointer interactions attached to sprites.
814
+ *
815
+ * Use this for hover popovers, selection, drag previews, cursor changes, and
816
+ * explicit mouse-driven gameplay actions. Pointer feedback stays local unless
817
+ * the behavior calls `ctx.action(...)`.
818
+ */
819
+ interactions?:
820
+ | ((engine: RpgClientEngine) => void)
821
+ | {
822
+ setup?: (engine: RpgClientEngine) => void
823
+ load?: (engine: RpgClientEngine) => void
824
+ use?: Array<[RpgInteractionMatcher, RpgInteractionBehavior | ComponentFunction]>
825
+ }
731
826
  }