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

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 (66) hide show
  1. package/CHANGELOG.md +9 -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.js +2 -2
  16. package/dist/Game/Object.js.map +1 -1
  17. package/dist/Game/Object.spec.d.ts +1 -0
  18. package/dist/Game/ProjectileManager.d.ts +11 -2
  19. package/dist/Game/ProjectileManager.js +19 -2
  20. package/dist/Game/ProjectileManager.js.map +1 -1
  21. package/dist/RpgClient.d.ts +64 -0
  22. package/dist/RpgClientEngine.d.ts +57 -0
  23. package/dist/RpgClientEngine.js +110 -14
  24. package/dist/RpgClientEngine.js.map +1 -1
  25. package/dist/components/animations/fx.ce.js +58 -0
  26. package/dist/components/animations/fx.ce.js.map +1 -0
  27. package/dist/components/animations/index.d.ts +1 -0
  28. package/dist/components/animations/index.js +3 -1
  29. package/dist/components/animations/index.js.map +1 -1
  30. package/dist/components/character.ce.js +111 -13
  31. package/dist/components/character.ce.js.map +1 -1
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.js +3 -2
  34. package/dist/module.js +7 -0
  35. package/dist/module.js.map +1 -1
  36. package/dist/services/actionInput.d.ts +3 -1
  37. package/dist/services/actionInput.js +33 -1
  38. package/dist/services/actionInput.js.map +1 -1
  39. package/dist/services/standalone.d.ts +3 -1
  40. package/dist/services/standalone.js +31 -13
  41. package/dist/services/standalone.js.map +1 -1
  42. package/dist/utils/mapId.d.ts +1 -0
  43. package/dist/utils/mapId.js +6 -0
  44. package/dist/utils/mapId.js.map +1 -0
  45. package/package.json +3 -3
  46. package/src/Game/AnimationManager.ts +4 -0
  47. package/src/Game/ClientVisuals.spec.ts +56 -0
  48. package/src/Game/ClientVisuals.ts +184 -0
  49. package/src/Game/EventComponentResolver.spec.ts +84 -0
  50. package/src/Game/EventComponentResolver.ts +74 -0
  51. package/src/Game/Map.ts +10 -0
  52. package/src/Game/Object.spec.ts +46 -0
  53. package/src/Game/Object.ts +2 -2
  54. package/src/Game/ProjectileManager.spec.ts +111 -0
  55. package/src/Game/ProjectileManager.ts +24 -2
  56. package/src/RpgClient.ts +68 -0
  57. package/src/RpgClientEngine.ts +130 -16
  58. package/src/components/animations/fx.ce +101 -0
  59. package/src/components/animations/index.ts +4 -2
  60. package/src/components/character.ce +154 -11
  61. package/src/index.ts +1 -0
  62. package/src/module.ts +11 -0
  63. package/src/services/actionInput.spec.ts +54 -0
  64. package/src/services/actionInput.ts +68 -1
  65. package/src/services/standalone.ts +39 -10
  66. package/src/utils/mapId.ts +2 -0
@@ -130,12 +130,12 @@ export abstract class RpgClientObject extends RpgCommonPlayer {
130
130
  const restoreState = this.animationRestoreState;
131
131
  this.clearAnimationControls();
132
132
  this.animationCurrentIndex.set(0);
133
+ this.animationRestoreState = undefined;
134
+ this.animationIsPlaying.set(false);
133
135
  if (restoreState) {
134
136
  this.animationName.set(restoreState.animationName);
135
137
  this.graphics.set([...restoreState.graphics]);
136
138
  }
137
- this.animationRestoreState = undefined;
138
- this.animationIsPlaying.set(false);
139
139
  this.resolveAnimationWait();
140
140
  }
141
141
 
@@ -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;
package/src/RpgClient.ts CHANGED
@@ -2,11 +2,13 @@ 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 { RpgClientEvent } from './Game/Event'
5
6
  import { 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'
10
12
 
11
13
  type RpgClass<T = any> = new (...args: any[]) => T
12
14
  type RpgComponent = RpgClientObject
@@ -18,6 +20,16 @@ export type SpriteComponentConfig = ComponentFunction | {
18
20
  dependencies?: (object: RpgClientObject) => any[]
19
21
  }
20
22
 
23
+ export type EventComponentSprite = RpgClientEvent & Record<string, any>
24
+
25
+ export type EventComponentConfig = ComponentFunction | {
26
+ component: ComponentFunction
27
+ props?: Record<string, any> | ((event: EventComponentSprite) => Record<string, any>)
28
+ data?: Record<string, any> | ((event: EventComponentSprite) => Record<string, any>)
29
+ dependencies?: (event: EventComponentSprite) => any[]
30
+ renderGraphic?: boolean
31
+ }
32
+
21
33
  export interface RpgSpriteBeforeRemoveContext {
22
34
  reason?: string
23
35
  data?: any
@@ -142,6 +154,31 @@ export interface RpgSpriteHooks {
142
154
  * ```
143
155
  */
144
156
  components?: Record<string, ComponentFunction>
157
+
158
+ /**
159
+ * Resolve a custom CanvasEngine component for a specific event.
160
+ *
161
+ * The component always receives the synced event object as the `sprite` prop.
162
+ * Custom props are merged in addition to `sprite`, but cannot replace it.
163
+ * Return `null` or `undefined` to keep the default graphic renderer.
164
+ *
165
+ * @prop { (event: EventComponentSprite) => EventComponentConfig | null | undefined } [eventComponent]
166
+ * @memberof RpgSpriteHooks
167
+ * @example
168
+ * ```ts
169
+ * import ChestEvent from './components/chest-event.ce'
170
+ *
171
+ * const sprite: RpgSpriteHooks = {
172
+ * eventComponent(sprite) {
173
+ * if (sprite.name === 'CHEST') {
174
+ * return ChestEvent
175
+ * }
176
+ * return null
177
+ * }
178
+ * }
179
+ * ```
180
+ */
181
+ eventComponent?: (event: EventComponentSprite) => EventComponentConfig | null | undefined
145
182
 
146
183
  /**
147
184
  * As soon as the sprite is initialized
@@ -721,6 +758,37 @@ export interface RpgClient {
721
758
  component: ComponentFunction
722
759
  }[]
723
760
 
761
+ /**
762
+ * Named client-side visual macros.
763
+ *
764
+ * Use client visuals when the server needs to trigger a group of existing
765
+ * client visual primitives at once, such as a flash, damage text, sound,
766
+ * component animation, and camera shake. The server sends only the visual
767
+ * name and a serializable payload; the rendering details live on the client.
768
+ *
769
+ * For a single sound, flash, or component animation, prefer the direct
770
+ * server APIs (`playSound`, `flash`, `showComponentAnimation`). Client
771
+ * visuals are meant to group several visual operations and reduce bandwidth.
772
+ *
773
+ * ```ts
774
+ * import { defineModule, RpgClient } from '@rpgjs/client'
775
+ *
776
+ * export default defineModule<RpgClient>({
777
+ * clientVisuals: {
778
+ * hit({ target, data }, helpers) {
779
+ * helpers.flash(target, { type: 'tint', tint: 'red' })
780
+ * helpers.showHit(target, `-${data.damage}`)
781
+ * helpers.sound('hit')
782
+ * }
783
+ * }
784
+ * })
785
+ * ```
786
+ *
787
+ * @prop {Record<string, ClientVisualHandler>} [clientVisuals]
788
+ * @memberof RpgClient
789
+ */
790
+ clientVisuals?: ClientVisualMap
791
+
724
792
  /**
725
793
  * Client-side projectile rendering configuration.
726
794
  *
@@ -7,6 +7,8 @@ import { LoadMapService, LoadMapToken } from "./services/loadMap";
7
7
  import { RpgSound } from "./Sound";
8
8
  import { RpgResource } from "./Resource";
9
9
  import { Hooks, ModulesToken, Direction, normalizeLightingState, Vector2 } 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";
@@ -30,8 +32,11 @@ import { NotificationManager } from "./Gui/NotificationManager";
30
32
  import { SaveClientService } from "./services/save";
31
33
  import { getCanMoveValue } from "./utils/readPropValue";
32
34
  import { ProjectileManager, type ClientProjectileImpact, type ClientProjectileSpawn } from "./Game/ProjectileManager";
35
+ import { ClientVisualRegistry, type ClientVisualHandler, type ClientVisualMap, type ClientVisualPacket } from "./Game/ClientVisuals";
33
36
  import { normalizeActionInput } from "./services/actionInput";
34
37
  import { createClientPointerContext, type ClientPointerContext } from "./services/pointerContext";
38
+ import { normalizeRoomMapId } from "./utils/mapId";
39
+ import { EventComponentResolverRegistry, type EventComponentResolver } from "./Game/EventComponentResolver";
35
40
 
36
41
  interface MovementTrajectoryPoint {
37
42
  frame: number;
@@ -70,6 +75,7 @@ export class RpgClientEngine<T = any> {
70
75
  spritesheets: Map<string | number, any> = new Map();
71
76
  sounds: Map<string, any> = new Map();
72
77
  componentAnimations: any[] = [];
78
+ clientVisuals = new ClientVisualRegistry();
73
79
  projectiles: ProjectileManager;
74
80
  pointer: ClientPointerContext = createClientPointerContext();
75
81
  private spritesheetResolver?: (id: string | number) => any | Promise<any>;
@@ -87,6 +93,7 @@ export class RpgClientEngine<T = any> {
87
93
  spriteComponentsBehind = signal<any[]>([]);
88
94
  spriteComponentsInFront = signal<any[]>([]);
89
95
  spriteComponents: Map<string, any> = new Map();
96
+ private eventComponentResolvers = new EventComponentResolverRegistry();
90
97
  /** ID of the sprite that the camera should follow. null means follow the current player */
91
98
  cameraFollowTargetId = signal<string | null>(null);
92
99
  /** Trigger for map shake animation */
@@ -120,6 +127,9 @@ export class RpgClientEngine<T = any> {
120
127
  private eventsReceived$ = new BehaviorSubject<boolean>(false);
121
128
  private onAfterLoadingSubscription?: any;
122
129
  private sceneResetQueued = false;
130
+ private mapTransitionInProgress = false;
131
+ private currentMapRoomId?: string;
132
+ private socketListenersInitialized = false;
123
133
 
124
134
  // Store subscriptions and event listeners for cleanup
125
135
  private tickSubscriptions: any[] = [];
@@ -266,6 +276,7 @@ export class RpgClientEngine<T = any> {
266
276
  this.hooks.callHooks("client-gui-load", this).subscribe();
267
277
  this.hooks.callHooks("client-particles-load", this).subscribe();
268
278
  this.hooks.callHooks("client-componentAnimations-load", this).subscribe();
279
+ this.hooks.callHooks("client-clientVisuals-load", this).subscribe();
269
280
  this.hooks.callHooks("client-projectiles-load", this).subscribe();
270
281
  this.hooks.callHooks("client-sprite-load", this).subscribe();
271
282
 
@@ -392,6 +403,9 @@ export class RpgClientEngine<T = any> {
392
403
  }
393
404
 
394
405
  private initListeners() {
406
+ if (this.socketListenersInitialized) return;
407
+ this.socketListenersInitialized = true;
408
+
395
409
  this.webSocket.on("sync", (data) => {
396
410
  if (!this.tick) {
397
411
  this.pendingSyncPackets.push(data);
@@ -420,13 +434,8 @@ export class RpgClientEngine<T = any> {
420
434
  });
421
435
 
422
436
  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);
437
+ const nextMapId = typeof data?.mapId === "string" ? data.mapId : undefined;
438
+ this.beginMapTransfer(nextMapId);
430
439
  const transferToken = typeof data?.transferToken === "string" ? data.transferToken : undefined;
431
440
  this.loadScene(data.mapId, transferToken);
432
441
  });
@@ -440,22 +449,35 @@ export class RpgClientEngine<T = any> {
440
449
  this.getComponentAnimation(id).displayEffect(params, player || position)
441
450
  });
442
451
 
452
+ this.webSocket.on("clientVisual", (data) => {
453
+ this.playClientVisual(data);
454
+ });
455
+
443
456
  this.webSocket.on("projectile:spawnBatch", (data) => {
457
+ if (!this.shouldProcessProjectilePacket(data)) return;
444
458
  this.projectiles.spawnBatch(data?.projectiles ?? [], {
459
+ mapId: data?.mapId,
445
460
  currentServerTick: this.estimateServerTick(),
446
461
  tickDurationMs: this.getPhysicsTickDurationMs(),
447
462
  });
448
463
  });
449
464
 
450
465
  this.webSocket.on("projectile:impactBatch", (data) => {
451
- this.projectiles.impactBatch(data?.impacts ?? []);
466
+ if (!this.shouldProcessProjectilePacket(data)) return;
467
+ this.projectiles.impactBatch(data?.impacts ?? [], {
468
+ mapId: data?.mapId,
469
+ });
452
470
  });
453
471
 
454
472
  this.webSocket.on("projectile:destroyBatch", (data) => {
455
- this.projectiles.destroyBatch(data?.projectiles ?? []);
473
+ if (!this.shouldProcessProjectilePacket(data)) return;
474
+ this.projectiles.destroyBatch(data?.projectiles ?? [], {
475
+ mapId: data?.mapId,
476
+ });
456
477
  });
457
478
 
458
- this.webSocket.on("projectile:clear", () => {
479
+ this.webSocket.on("projectile:clear", (data) => {
480
+ if (!this.shouldProcessProjectilePacket(data)) return;
459
481
  this.projectiles.clear();
460
482
  });
461
483
 
@@ -573,6 +595,36 @@ export class RpgClientEngine<T = any> {
573
595
  })
574
596
  }
575
597
 
598
+ private beginMapTransfer(nextMapId?: string) {
599
+ this.mapTransitionInProgress = true;
600
+ this.currentMapRoomId = nextMapId;
601
+ this.sceneResetQueued = false;
602
+ this.clearClientPredictionStates();
603
+ this.sceneMap.weatherState.set(null);
604
+ this.sceneMap.lightingState.set(null);
605
+ this.sceneMap.clearLightSpots();
606
+ this.clearComponentAnimations();
607
+ this.projectiles.setMapId(nextMapId);
608
+ this.cameraFollowTargetId.set(null);
609
+ this.sceneMap.reset();
610
+ this.sceneMap.loadPhysic();
611
+ }
612
+
613
+ private clearComponentAnimations() {
614
+ this.componentAnimations.forEach((componentAnimation) => {
615
+ componentAnimation.instance?.clear?.();
616
+ });
617
+ }
618
+
619
+ private shouldProcessProjectilePacket(data: any): boolean {
620
+ if (this.mapTransitionInProgress) return false;
621
+ const packetMapId = normalizeRoomMapId(
622
+ typeof data?.mapId === "string" ? data.mapId : undefined,
623
+ );
624
+ const currentMapId = normalizeRoomMapId(this.currentMapRoomId);
625
+ return !packetMapId || !currentMapId || packetMapId === currentMapId;
626
+ }
627
+
576
628
  private async callConnectError(error: any) {
577
629
  await lastValueFrom(this.hooks.callHooks("client-engine-onConnectError", this, error, this.socket));
578
630
  }
@@ -732,14 +784,10 @@ export class RpgClientEngine<T = any> {
732
784
  query: transferToken ? { transferToken } : undefined,
733
785
  })
734
786
  try {
735
- await this.webSocket.reconnect(() => {
736
- const saveClient = inject(SaveClientService);
737
- saveClient.initialize();
738
- this.initListeners()
739
- this.guiService._initialize()
740
- })
787
+ await this.webSocket.reconnect()
741
788
  }
742
789
  catch (error) {
790
+ this.mapTransitionInProgress = false;
743
791
  this.stopPingPong();
744
792
  await this.callConnectError(error);
745
793
  throw error;
@@ -765,6 +813,8 @@ export class RpgClientEngine<T = any> {
765
813
 
766
814
  // Signal that map loading is completed (this should be last to ensure other checks are done)
767
815
  this.mapLoadCompleted$.next(true);
816
+ this.currentMapRoomId = mapId;
817
+ this.mapTransitionInProgress = false;
768
818
  this.sceneMap.configureClientPrediction(this.predictionEnabled);
769
819
  this.sceneMap.loadPhysic()
770
820
  }
@@ -1290,6 +1340,29 @@ export class RpgClientEngine<T = any> {
1290
1340
  return this.spriteComponents.get(id);
1291
1341
  }
1292
1342
 
1343
+ /**
1344
+ * Register a custom event component resolver.
1345
+ *
1346
+ * The last resolver returning a component wins. This lets later modules
1347
+ * override earlier defaults without replacing the whole map scene.
1348
+ *
1349
+ * @param resolver - Function receiving the synced event object
1350
+ * @returns The registered resolver
1351
+ */
1352
+ addEventComponentResolver(resolver: EventComponentResolver) {
1353
+ return this.eventComponentResolvers.add(resolver);
1354
+ }
1355
+
1356
+ /**
1357
+ * Resolve the custom CanvasEngine component for an event, if any.
1358
+ *
1359
+ * @param event - Synced client event object
1360
+ * @returns The component/config returned by the last matching resolver
1361
+ */
1362
+ resolveEventComponent(event: RpgClientEvent): EventComponentConfig | null {
1363
+ return this.eventComponentResolvers.resolve(event);
1364
+ }
1365
+
1293
1366
  registerProjectileComponent(type: string, component: any) {
1294
1367
  return this.projectiles.register(type, component);
1295
1368
  }
@@ -1298,6 +1371,42 @@ export class RpgClientEngine<T = any> {
1298
1371
  return this.projectiles.get(type);
1299
1372
  }
1300
1373
 
1374
+ /**
1375
+ * Register a named client visual macro.
1376
+ *
1377
+ * Client visuals are small client-side functions that group existing visual
1378
+ * primitives such as flash, sound, component animations, sprite animation, or
1379
+ * map shake. The server sends only the visual name and a serializable payload.
1380
+ *
1381
+ * @param name - Stable visual name sent by the server
1382
+ * @param handler - Client-side visual handler
1383
+ * @returns The registered handler
1384
+ */
1385
+ registerClientVisual(name: string, handler: ClientVisualHandler) {
1386
+ return this.clientVisuals.register(name, handler);
1387
+ }
1388
+
1389
+ /**
1390
+ * Register several named client visual macros.
1391
+ *
1392
+ * @param visuals - Map of visual names to client-side handlers
1393
+ */
1394
+ registerClientVisuals(visuals: ClientVisualMap) {
1395
+ this.clientVisuals.registerMany(visuals);
1396
+ }
1397
+
1398
+ /**
1399
+ * Play a registered client visual locally.
1400
+ *
1401
+ * This is also used by the websocket listener when the server calls
1402
+ * `player.clientVisual()` or `map.clientVisual()`.
1403
+ *
1404
+ * @param packet - Visual name and serializable payload
1405
+ */
1406
+ playClientVisual(packet: ClientVisualPacket) {
1407
+ return this.clientVisuals.play(packet, this);
1408
+ }
1409
+
1301
1410
  /**
1302
1411
  * Add a component animation to the engine
1303
1412
  *
@@ -1485,6 +1594,10 @@ export class RpgClientEngine<T = any> {
1485
1594
  return this.sceneMap
1486
1595
  }
1487
1596
 
1597
+ getObjectById(id: string) {
1598
+ return this.sceneMap?.getObjectById(id);
1599
+ }
1600
+
1488
1601
  private getPhysicsTick(): number {
1489
1602
  return (this.sceneMap as any)?.getTick?.() ?? 0;
1490
1603
  }
@@ -2142,6 +2255,7 @@ export class RpgClientEngine<T = any> {
2142
2255
  this.cameraFollowTargetId.set(null);
2143
2256
  this.spriteComponentsBehind.set([]);
2144
2257
  this.spriteComponentsInFront.set([]);
2258
+ this.eventComponentResolvers.clear();
2145
2259
 
2146
2260
  // Clear maps and arrays
2147
2261
  this.spritesheets.clear();
@@ -0,0 +1,101 @@
1
+ <Fx
2
+ name={name}
3
+ preset={preset}
4
+ trigger={trigger}
5
+ autostart={autostart}
6
+ loop={loop}
7
+ enabled={enabled}
8
+ x={x}
9
+ y={y}
10
+ rotation={rotation}
11
+ scale={scale}
12
+ alpha={alpha}
13
+ timeScale={timeScale}
14
+ maxParticles={maxParticles}
15
+ preload={preload}
16
+ missingTexture={missingTexture}
17
+ zIndex={zIndex}
18
+ onStart={onStart}
19
+ onComplete={finish}
20
+ onParticleSpawn={onParticleSpawn}
21
+ />
22
+
23
+ <script>
24
+ import { tick } from "canvasengine";
25
+ import { Fx } from "@canvasengine/presets";
26
+
27
+ const {
28
+ name,
29
+ preset,
30
+ trigger,
31
+ onFinish,
32
+ onStart,
33
+ onComplete,
34
+ onParticleSpawn,
35
+ displayDuration,
36
+ duration,
37
+ autostart,
38
+ loop,
39
+ enabled,
40
+ x,
41
+ y,
42
+ rotation,
43
+ scale,
44
+ alpha,
45
+ timeScale,
46
+ maxParticles,
47
+ preload,
48
+ missingTexture,
49
+ zIndex,
50
+ } = defineProps({
51
+ autostart: {
52
+ default: true,
53
+ },
54
+ loop: {
55
+ default: false,
56
+ },
57
+ enabled: {
58
+ default: true,
59
+ },
60
+ rotation: {
61
+ default: 0,
62
+ },
63
+ scale: {
64
+ default: 1,
65
+ },
66
+ alpha: {
67
+ default: 1,
68
+ },
69
+ timeScale: {
70
+ default: 1,
71
+ },
72
+ maxParticles: {
73
+ default: 600,
74
+ },
75
+ preload: {
76
+ default: true,
77
+ },
78
+ missingTexture: {
79
+ default: "shape",
80
+ },
81
+ });
82
+
83
+ let elapsedTime = 0;
84
+ let finished = false;
85
+
86
+ function finish(instance) {
87
+ if (finished) return;
88
+ finished = true;
89
+ onComplete?.(instance);
90
+ onFinish?.(instance);
91
+ }
92
+
93
+ tick(({ deltaTime }) => {
94
+ const maxDuration = displayDuration?.() ?? (loop() ? duration?.() : undefined);
95
+ if (!maxDuration || finished) return;
96
+ elapsedTime += deltaTime;
97
+ if (elapsedTime >= maxDuration) {
98
+ finish();
99
+ }
100
+ });
101
+ </script>
@@ -1,7 +1,9 @@
1
1
  import Hit from "./hit.ce";
2
2
  import Animation from "./animation.ce";
3
+ import Fx from "./fx.ce";
3
4
 
4
5
  export const PrebuiltComponentAnimations = {
5
6
  Hit,
6
- Animation
7
- }
7
+ Animation,
8
+ Fx
9
+ }