@rpgjs/client 5.0.0-alpha.14 → 5.0.0-alpha.16

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 (44) hide show
  1. package/dist/Game/Map.d.ts +2 -1
  2. package/dist/RpgClient.d.ts +39 -0
  3. package/dist/RpgClientEngine.d.ts +138 -2
  4. package/dist/index10.js +1 -2
  5. package/dist/index10.js.map +1 -1
  6. package/dist/index15.js +58 -16
  7. package/dist/index15.js.map +1 -1
  8. package/dist/index2.js +303 -3
  9. package/dist/index2.js.map +1 -1
  10. package/dist/index20.js +3 -0
  11. package/dist/index20.js.map +1 -1
  12. package/dist/index22.js +3 -3
  13. package/dist/index23.js +2 -2
  14. package/dist/index25.js +1 -2
  15. package/dist/index25.js.map +1 -1
  16. package/dist/index33.js +1 -1
  17. package/dist/index34.js +1 -1
  18. package/dist/index35.js +9 -184
  19. package/dist/index35.js.map +1 -1
  20. package/dist/index36.js +6 -503
  21. package/dist/index36.js.map +1 -1
  22. package/dist/index37.js +3687 -9
  23. package/dist/index37.js.map +1 -1
  24. package/dist/index38.js +186 -6
  25. package/dist/index38.js.map +1 -1
  26. package/dist/index39.js +499 -3685
  27. package/dist/index39.js.map +1 -1
  28. package/dist/index40.js +1 -1
  29. package/dist/index41.js +1 -1
  30. package/dist/index42.js +119 -16
  31. package/dist/index42.js.map +1 -1
  32. package/dist/index43.js +16 -92
  33. package/dist/index43.js.map +1 -1
  34. package/dist/index8.js +8 -0
  35. package/dist/index8.js.map +1 -1
  36. package/package.json +4 -4
  37. package/src/Game/Map.ts +5 -1
  38. package/src/Game/Object.ts +37 -4
  39. package/src/RpgClient.ts +40 -0
  40. package/src/RpgClientEngine.ts +374 -11
  41. package/src/components/animations/animation.ce +1 -2
  42. package/src/components/character.ce +80 -20
  43. package/src/components/gui/dialogbox/index.ce +1 -2
  44. package/src/module.ts +8 -0
@@ -3,7 +3,9 @@ import { Context, inject } from "@signe/di";
3
3
  import { signal, bootstrapCanvas } from "canvasengine";
4
4
  import { AbstractWebsocket, WebSocketToken } from "./services/AbstractSocket";
5
5
  import { LoadMapService, LoadMapToken } from "./services/loadMap";
6
- import { Hooks, ModulesToken } from "@rpgjs/common";
6
+ import { Hooks, ModulesToken, Direction } from "@rpgjs/common";
7
+
8
+ type DirectionValue = "up" | "down" | "left" | "right";
7
9
  import { load } from "@signe/sync";
8
10
  import { RpgClientMap } from "./Game/Map"
9
11
  import { RpgGui } from "./Gui/Gui";
@@ -12,6 +14,10 @@ import { lastValueFrom, Observable } from "rxjs";
12
14
  import { GlobalConfigToken } from "./module";
13
15
  import * as PIXI from "pixi.js";
14
16
  import { PrebuiltComponentAnimations } from "./components/animations";
17
+ import {
18
+ PredictionController,
19
+ type PredictionState,
20
+ } from "@rpgjs/common";
15
21
 
16
22
  export class RpgClientEngine<T = any> {
17
23
  private guiService: RpgGui;
@@ -28,17 +34,29 @@ export class RpgClientEngine<T = any> {
28
34
  spritesheets: Map<string, any> = new Map();
29
35
  sounds: Map<string, any> = new Map();
30
36
  componentAnimations: any[] = [];
37
+ private spritesheetResolver?: (id: string) => any | Promise<any>;
31
38
  particleSettings: {
32
39
  emitters: any[]
33
40
  } = {
34
- emitters: []
35
- }
41
+ emitters: []
42
+ }
36
43
  renderer: PIXI.Renderer;
37
44
  tick: Observable<number>;
38
45
  playerIdSignal = signal<string | null>(null);
39
46
  spriteComponentsBehind = signal<any[]>([]);
40
47
  spriteComponentsInFront = signal<any[]>([]);
41
48
 
49
+ private predictionEnabled = false;
50
+ private prediction?: PredictionController<Direction>;
51
+ private readonly SERVER_CORRECTION_THRESHOLD = 30;
52
+ private inputFrameCounter = 0;
53
+ private frameOffset = 0;
54
+ // Ping/Pong for RTT measurement
55
+ private rtt: number = 0; // Round-trip time in ms
56
+ private pingInterval: any = null;
57
+ private readonly PING_INTERVAL_MS = 5000; // Send ping every 5 seconds
58
+ private lastInputTime = 0;
59
+
42
60
  constructor(public context: Context) {
43
61
  this.webSocket = inject(context, WebSocketToken);
44
62
  this.guiService = inject(context, RpgGui);
@@ -47,10 +65,10 @@ export class RpgClientEngine<T = any> {
47
65
  this.globalConfig = inject(context, GlobalConfigToken)
48
66
 
49
67
  if (!this.globalConfig) {
50
- this.globalConfig = {}
68
+ this.globalConfig = {} as T
51
69
  }
52
- if (!this.globalConfig.box) {
53
- this.globalConfig.box = {
70
+ if (!(this.globalConfig as any).box) {
71
+ (this.globalConfig as any).box = {
54
72
  styles: {
55
73
  backgroundColor: "#1a1a2e",
56
74
  backgroundOpacity: 0.9
@@ -63,6 +81,9 @@ export class RpgClientEngine<T = any> {
63
81
  id: "animation",
64
82
  component: PrebuiltComponentAnimations.Animation
65
83
  })
84
+
85
+ this.predictionEnabled = (this.globalConfig as any)?.prediction?.enabled !== false;
86
+ this.initializePredictionController();
66
87
  }
67
88
 
68
89
  async start() {
@@ -73,8 +94,17 @@ export class RpgClientEngine<T = any> {
73
94
  this.renderer = app.renderer as PIXI.Renderer;
74
95
  this.tick = canvasElement?.propObservables?.context['tick'].observable
75
96
 
97
+ this.tick.subscribe(() => {
98
+ if (Date.now() - this.lastInputTime > 100) {
99
+ const player = this.getCurrentPlayer();
100
+ if (!player) return;
101
+ (this.sceneMap as any).stopMovement(player);
102
+ }
103
+ })
104
+
76
105
 
77
106
  this.hooks.callHooks("client-spritesheets-load", this).subscribe();
107
+ this.hooks.callHooks("client-spritesheetResolver-load", this).subscribe();
78
108
  this.hooks.callHooks("client-sounds-load", this).subscribe();
79
109
  this.hooks.callHooks("client-gui-load", this).subscribe();
80
110
  this.hooks.callHooks("client-particles-load", this).subscribe();
@@ -90,6 +120,13 @@ export class RpgClientEngine<T = any> {
90
120
 
91
121
  this.tick.subscribe((tick) => {
92
122
  this.hooks.callHooks("client-engine-onStep", this, tick).subscribe();
123
+
124
+ // Clean up old prediction states and input history every 60 ticks (approximately every second at 60fps)
125
+ if (tick % 60 === 0) {
126
+ const now = Date.now();
127
+ this.prediction?.cleanup(now);
128
+ this.prediction?.tryApplyPendingSnapshot();
129
+ }
93
130
  })
94
131
 
95
132
  await this.webSocket.connection(() => {
@@ -100,11 +137,33 @@ export class RpgClientEngine<T = any> {
100
137
 
101
138
  private initListeners() {
102
139
  this.webSocket.on("sync", (data) => {
140
+
103
141
  if (data.pId) this.playerIdSignal.set(data.pId)
142
+ // Apply client-side prediction filtering and server reconciliation
143
+
104
144
  this.hooks.callHooks("client-sceneMap-onChanges", this.sceneMap, { partial: data }).subscribe();
145
+
105
146
  load(this.sceneMap, data, true);
106
147
  });
107
148
 
149
+ // Handle pong responses for RTT measurement
150
+ this.webSocket.on("pong", (data: { serverTick: number; clientFrame: number; clientTime: number }) => {
151
+ const now = Date.now();
152
+ this.rtt = now - data.clientTime;
153
+
154
+ // Calculate frame offset: how many ticks ahead the server is compared to our frame counter
155
+ // This helps us estimate which server tick corresponds to each client input frame
156
+ const estimatedTicksInFlight = Math.floor(this.rtt / 2 / (1000 / 60)); // Estimate ticks during half RTT
157
+ const estimatedServerTickNow = data.serverTick + estimatedTicksInFlight;
158
+
159
+ // Update frame offset (only if we have inputs to calibrate with)
160
+ if (this.inputFrameCounter > 0) {
161
+ this.frameOffset = estimatedServerTickNow - data.clientFrame;
162
+ }
163
+
164
+ console.debug(`[Ping/Pong] RTT: ${this.rtt}ms, ServerTick: ${data.serverTick}, FrameOffset: ${this.frameOffset}`);
165
+ });
166
+
108
167
  this.webSocket.on("changeMap", (data) => {
109
168
  this.sceneMap.reset()
110
169
  this.loadScene(data.mapId);
@@ -127,19 +186,98 @@ export class RpgClientEngine<T = any> {
127
186
 
128
187
  this.webSocket.on('open', () => {
129
188
  this.hooks.callHooks("client-engine-onConnected", this, this.socket).subscribe();
189
+ // Start ping/pong for synchronization
130
190
  })
131
191
 
132
192
  this.webSocket.on('close', () => {
133
193
  this.hooks.callHooks("client-engine-onDisconnected", this, this.socket).subscribe();
194
+ // Stop ping/pong when disconnected
195
+ this.stopPingPong();
134
196
  })
135
197
 
136
198
  this.webSocket.on('error', (error) => {
137
199
  this.hooks.callHooks("client-engine-onConnectError", this, error, this.socket).subscribe();
138
200
  })
139
201
  }
140
-
202
+
203
+ /**
204
+ * Start periodic ping/pong for client-server synchronization
205
+ *
206
+ * Sends ping requests to the server to measure round-trip time (RTT) and
207
+ * calculate the frame offset between client and server ticks.
208
+ *
209
+ * ## Design
210
+ *
211
+ * - Sends ping every 5 seconds
212
+ * - Measures RTT for latency compensation
213
+ * - Calculates frame offset to map client frames to server ticks
214
+ * - Used for accurate server reconciliation
215
+ *
216
+ * @example
217
+ * ```ts
218
+ * // Called automatically when connection opens
219
+ * this.startPingPong();
220
+ * ```
221
+ */
222
+ private startPingPong(): void {
223
+ // Stop existing interval if any
224
+ this.stopPingPong();
225
+
226
+ // Send initial ping immediately
227
+ this.sendPing();
228
+
229
+ // Set up periodic pings
230
+ this.pingInterval = setInterval(() => {
231
+ this.sendPing();
232
+ }, this.PING_INTERVAL_MS);
233
+ }
234
+
235
+ /**
236
+ * Stop periodic ping/pong
237
+ *
238
+ * Stops the ping interval when disconnecting or changing maps.
239
+ *
240
+ * @example
241
+ * ```ts
242
+ * // Called automatically when connection closes
243
+ * this.stopPingPong();
244
+ * ```
245
+ */
246
+ private stopPingPong(): void {
247
+ if (this.pingInterval) {
248
+ clearInterval(this.pingInterval);
249
+ this.pingInterval = null;
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Send a ping request to the server
255
+ *
256
+ * Sends current client time and frame counter to the server,
257
+ * which will respond with its server tick for synchronization.
258
+ *
259
+ * @example
260
+ * ```ts
261
+ * // Send a ping to measure RTT
262
+ * this.sendPing();
263
+ * ```
264
+ */
265
+ private sendPing(): void {
266
+ const clientTime = Date.now();
267
+ const clientFrame = this.getPhysicsTick();
268
+
269
+ this.webSocket.emit('ping', {
270
+ clientTime,
271
+ clientFrame
272
+ });
273
+ }
274
+
141
275
  private async loadScene(mapId: string) {
142
276
  this.hooks.callHooks("client-sceneMap-onBeforeLoading", this.sceneMap).subscribe();
277
+
278
+ // Clear client prediction states when changing maps
279
+ this.clearClientPredictionStates();
280
+
143
281
  this.webSocket.updateProperties({ room: mapId })
144
282
  await this.webSocket.reconnect(() => {
145
283
  this.initListeners()
@@ -148,7 +286,7 @@ export class RpgClientEngine<T = any> {
148
286
  const res = await this.loadMapService.load(mapId)
149
287
  this.sceneMap.data.set(res)
150
288
  this.hooks.callHooks("client-sceneMap-onAfterLoading", this.sceneMap).subscribe();
151
- //this.sceneMap.loadPhysic()
289
+ this.sceneMap.loadPhysic()
152
290
  }
153
291
 
154
292
  addSpriteSheet<T = any>(spritesheetClass: any, id?: string): any {
@@ -156,6 +294,90 @@ export class RpgClientEngine<T = any> {
156
294
  return spritesheetClass as any;
157
295
  }
158
296
 
297
+ /**
298
+ * Set a resolver function for spritesheets
299
+ *
300
+ * The resolver is called when a spritesheet is requested but not found in the cache.
301
+ * It can be synchronous (returns directly) or asynchronous (returns a Promise).
302
+ * The resolved spritesheet is automatically cached for future use.
303
+ *
304
+ * @param resolver - Function that takes a spritesheet ID and returns a spritesheet or Promise of spritesheet
305
+ *
306
+ * @example
307
+ * ```ts
308
+ * // Synchronous resolver
309
+ * engine.setSpritesheetResolver((id) => {
310
+ * if (id === 'dynamic-sprite') {
311
+ * return { id: 'dynamic-sprite', image: 'path/to/image.png', framesWidth: 32, framesHeight: 32 };
312
+ * }
313
+ * return undefined;
314
+ * });
315
+ *
316
+ * // Asynchronous resolver (loading from API)
317
+ * engine.setSpritesheetResolver(async (id) => {
318
+ * const response = await fetch(`/api/spritesheets/${id}`);
319
+ * const data = await response.json();
320
+ * return data;
321
+ * });
322
+ * ```
323
+ */
324
+ setSpritesheetResolver(resolver: (id: string) => any | Promise<any>): void {
325
+ this.spritesheetResolver = resolver;
326
+ }
327
+
328
+ /**
329
+ * Get a spritesheet by ID, using resolver if not found in cache
330
+ *
331
+ * This method first checks if the spritesheet exists in the cache.
332
+ * If not found and a resolver is set, it calls the resolver to create the spritesheet.
333
+ * The resolved spritesheet is automatically cached for future use.
334
+ *
335
+ * @param id - The spritesheet ID to retrieve
336
+ * @returns The spritesheet if found or created, or undefined if not found and no resolver
337
+ * @returns Promise<any> if the resolver is asynchronous
338
+ *
339
+ * @example
340
+ * ```ts
341
+ * // Synchronous usage
342
+ * const spritesheet = engine.getSpriteSheet('my-sprite');
343
+ *
344
+ * // Asynchronous usage (when resolver returns Promise)
345
+ * const spritesheet = await engine.getSpriteSheet('dynamic-sprite');
346
+ * ```
347
+ */
348
+ getSpriteSheet(id: string): any | Promise<any> {
349
+ // Check cache first
350
+ if (this.spritesheets.has(id)) {
351
+ return this.spritesheets.get(id);
352
+ }
353
+
354
+ // If not in cache and resolver exists, use it
355
+ if (this.spritesheetResolver) {
356
+ const result = this.spritesheetResolver(id);
357
+
358
+ // Check if result is a Promise
359
+ if (result instanceof Promise) {
360
+ return result.then((spritesheet) => {
361
+ if (spritesheet) {
362
+ // Cache the resolved spritesheet
363
+ this.spritesheets.set(id, spritesheet);
364
+ }
365
+ return spritesheet;
366
+ });
367
+ } else {
368
+ // Synchronous result
369
+ if (result) {
370
+ // Cache the resolved spritesheet
371
+ this.spritesheets.set(id, result);
372
+ }
373
+ return result;
374
+ }
375
+ }
376
+
377
+ // No resolver and not in cache
378
+ return undefined;
379
+ }
380
+
159
381
  addSound(sound: any, id?: string) {
160
382
  this.sounds.set(id || sound.id, sound);
161
383
  return sound;
@@ -267,9 +489,34 @@ export class RpgClientEngine<T = any> {
267
489
  return componentAnimation.instance
268
490
  }
269
491
 
270
- processInput({ input }: { input: number }) {
492
+ async processInput({ input }: { input: Direction }) {
493
+ const timestamp = Date.now();
494
+ let frame: number;
495
+ let tick: number;
496
+ if (this.predictionEnabled && this.prediction) {
497
+ const meta = this.prediction.recordInput(input, timestamp);
498
+ frame = meta.frame;
499
+ tick = meta.tick;
500
+ } else {
501
+ frame = ++this.inputFrameCounter;
502
+ tick = this.getPhysicsTick();
503
+ }
271
504
  this.hooks.callHooks("client-engine-onInput", this, { input, playerId: this.playerId }).subscribe();
272
- this.webSocket.emit('move', { input })
505
+
506
+ this.webSocket.emit('move', {
507
+ input,
508
+ timestamp,
509
+ frame,
510
+ tick,
511
+ });
512
+
513
+ const currentPlayer = this.sceneMap.getCurrentPlayer();
514
+ if (currentPlayer) {
515
+ (this.sceneMap as any).moveBody(currentPlayer, input);
516
+ }
517
+ this.lastInputTime = Date.now();
518
+ const myId = this.playerIdSignal();
519
+
273
520
  }
274
521
 
275
522
  processAction({ action }: { action: number }) {
@@ -285,7 +532,7 @@ export class RpgClientEngine<T = any> {
285
532
  get socket() {
286
533
  return this.webSocket
287
534
  }
288
-
535
+
289
536
  get playerId() {
290
537
  return this.playerIdSignal()
291
538
  }
@@ -294,7 +541,123 @@ export class RpgClientEngine<T = any> {
294
541
  return this.sceneMap
295
542
  }
296
543
 
544
+ private getPhysicsTick(): number {
545
+ return this.sceneMap?.getTick?.() ?? 0;
546
+ }
547
+
548
+ private getLocalPlayerState(): PredictionState<Direction> {
549
+ const currentPlayer = this.sceneMap?.getCurrentPlayer();
550
+ if (!currentPlayer) {
551
+ return { x: 0, y: 0, direction: Direction.Down };
552
+ }
553
+ const topLeft = this.sceneMap.getBodyPosition(currentPlayer.id, "top-left");
554
+ const x = topLeft?.x ?? currentPlayer.x();
555
+ const y = topLeft?.y ?? currentPlayer.y();
556
+ const direction = currentPlayer.direction();
557
+ return { x, y, direction };
558
+ }
559
+
560
+ private applyAuthoritativeState(state: PredictionState<Direction>): void {
561
+ const player = this.sceneMap?.getCurrentPlayer();
562
+ if (!player) return;
563
+ const hitbox = typeof player.hitbox === "function" ? player.hitbox() : player.hitbox;
564
+ const width = hitbox?.w ?? 0;
565
+ const height = hitbox?.h ?? 0;
566
+ const updated = this.sceneMap.updateHitbox(player.id, state.x, state.y, width, height);
567
+ if (!updated) {
568
+ this.sceneMap.setBodyPosition(player.id, state.x, state.y, "top-left");
569
+ }
570
+ player.x.set(Math.round(state.x));
571
+ player.y.set(Math.round(state.y));
572
+ if (state.direction) {
573
+ player.changeDirection(state.direction);
574
+ }
575
+ }
576
+
577
+ private initializePredictionController(): void {
578
+ if (!this.predictionEnabled) {
579
+ this.prediction = undefined;
580
+ return;
581
+ }
582
+ this.prediction = new PredictionController<Direction>({
583
+ correctionThreshold: (this.globalConfig as any)?.prediction?.correctionThreshold ?? this.SERVER_CORRECTION_THRESHOLD,
584
+ historyTtlMs: (this.globalConfig as any)?.prediction?.historyTtlMs ?? 2000,
585
+ getPhysicsTick: () => this.getPhysicsTick(),
586
+ getCurrentState: () => this.getLocalPlayerState(),
587
+ setAuthoritativeState: (state) => this.applyAuthoritativeState(state),
588
+ });
589
+ }
590
+
297
591
  getCurrentPlayer() {
298
592
  return this.sceneMap.getCurrentPlayer()
299
593
  }
594
+
595
+ /**
596
+ * Clear client prediction states for cleanup
597
+ *
598
+ * Removes old prediction states and input history to prevent memory leaks.
599
+ * Should be called when changing maps or disconnecting.
600
+ *
601
+ * @example
602
+ * ```ts
603
+ * // Clear prediction states when changing maps
604
+ * engine.clearClientPredictionStates();
605
+ * ```
606
+ */
607
+ clearClientPredictionStates() {
608
+ this.initializePredictionController();
609
+ this.frameOffset = 0;
610
+ this.inputFrameCounter = 0;
611
+ }
612
+
613
+ private applyServerAck(ack: { frame: number; serverTick?: number; x?: number; y?: number; direction?: Direction }) {
614
+ if (this.predictionEnabled && this.prediction) {
615
+ this.prediction.applyServerAck({
616
+ frame: ack.frame,
617
+ serverTick: ack.serverTick,
618
+ state:
619
+ typeof ack.x === "number" && typeof ack.y === "number"
620
+ ? { x: ack.x, y: ack.y, direction: ack.direction }
621
+ : undefined,
622
+ });
623
+ return;
624
+ }
625
+
626
+ if (typeof ack.x !== "number" || typeof ack.y !== "number") {
627
+ return;
628
+ }
629
+ const player = this.getCurrentPlayer();
630
+ const myId = this.playerIdSignal();
631
+ if (!player || !myId) {
632
+ return;
633
+ }
634
+ const hitbox = typeof player.hitbox === "function" ? player.hitbox() : player.hitbox;
635
+ const width = hitbox?.w ?? 0;
636
+ const height = hitbox?.h ?? 0;
637
+ const updated = this.sceneMap.updateHitbox(myId, ack.x, ack.y, width, height);
638
+ if (!updated) {
639
+ this.sceneMap.setBodyPosition(myId, ack.x, ack.y, "top-left");
640
+ }
641
+ player.x.set(Math.round(ack.x));
642
+ player.y.set(Math.round(ack.y));
643
+ if (ack.direction) {
644
+ player.changeDirection(ack.direction);
645
+ }
646
+ }
647
+
648
+ /**
649
+ * Replay unacknowledged inputs from a given frame to resimulate client prediction
650
+ * after applying server authority at a certain frame.
651
+ *
652
+ * @param startFrame - The last server-acknowledged frame
653
+ *
654
+ * @example
655
+ * ```ts
656
+ * // After applying a server correction at frame N
657
+ * this.replayUnackedInputsFromFrame(N);
658
+ * ```
659
+ */
660
+ private async replayUnackedInputsFromFrame(_startFrame: number): Promise<void> {
661
+ // Prediction controller handles replay internally. Kept for backwards compatibility.
662
+ }
300
663
  }
@@ -7,10 +7,9 @@
7
7
  const { x, y, animationName, graphic, onFinish } = defineProps();
8
8
 
9
9
  const client = inject(RpgClientEngine);
10
- const spritesheets = client.spritesheets;
11
10
 
12
11
  const sheet = {
13
- definition: spritesheets.get(graphic()),
12
+ definition: client.getSpriteSheet(graphic()),
14
13
  playing: animationName() ?? 'default',
15
14
  onFinish
16
15
  };
@@ -1,4 +1,4 @@
1
- <Container x y zIndex={y} viewportFollow={isMe} controls onBeforeDestroy visible={isConnected}>
1
+ <Container x={smoothX} y={smoothY} zIndex={y} viewportFollow={isMe} controls onBeforeDestroy visible>
2
2
  @for (component of componentsBehind) {
3
3
  <Container>
4
4
  <component object />
@@ -18,8 +18,8 @@
18
18
  </Container>
19
19
 
20
20
  <script>
21
- import { signal, effect, mount, computed, tick } from "canvasengine";
22
- import { lastValueFrom } from "rxjs";
21
+ import { signal, effect, mount, computed, tick, animatedSignal } from "canvasengine";
22
+ import { lastValueFrom, combineLatest, pairwise, filter, map, startWith } from "rxjs";
23
23
  import { Particle } from "@canvasengine/presets";
24
24
  import { GameEngineToken, ModulesToken } from "@rpgjs/common";
25
25
  import { RpgClientEngine } from "../RpgClientEngine";
@@ -28,7 +28,7 @@
28
28
  import Hit from "./effects/hit.ce";
29
29
 
30
30
  const { object, id } = defineProps();
31
-
31
+
32
32
  const client = inject(RpgClientEngine);
33
33
  const hooks = inject(ModulesToken);
34
34
 
@@ -49,7 +49,7 @@
49
49
  particleName,
50
50
  graphics,
51
51
  hitbox,
52
- isConnected
52
+ isConnected,
53
53
  } = object;
54
54
 
55
55
  const particleSettings = client.particleSettings;
@@ -57,6 +57,13 @@
57
57
  const canControls = () => isMe() && object.canMove()
58
58
  const keyboardControls = client.globalConfig.keyboardControls;
59
59
 
60
+ const visible = computed(() => {
61
+ if (object.type === 'event') {
62
+ return true
63
+ }
64
+ return isConnected()
65
+ });
66
+
60
67
  const controls = signal({
61
68
  down: {
62
69
  repeat: true,
@@ -99,10 +106,28 @@
99
106
  },
100
107
  });
101
108
 
109
+ const smoothX = animatedSignal(x(), {
110
+ duration: isMe() ? 0 : 0
111
+ });
112
+
113
+ const smoothY = animatedSignal(y(), {
114
+ duration: isMe() ? 0 : 0,
115
+ });
116
+
117
+ const realAnimationName = signal(animationName());
118
+
119
+ const xSubscription = x.observable.subscribe((value) => {
120
+ smoothX.set(value);
121
+ });
122
+
123
+ const ySubscription = y.observable.subscribe((value) => {
124
+ smoothY.set(value);
125
+ });
126
+
102
127
  const sheet = (graphicId) => {
103
128
  return {
104
- definition: spritesheets.get(graphicId),
105
- playing: animationName,
129
+ definition: client.getSpriteSheet(graphicId),
130
+ playing: realAnimationName,
106
131
  params: {
107
132
  direction
108
133
  },
@@ -112,27 +137,62 @@
112
137
  };
113
138
  }
114
139
 
115
- // Track animation changes to reset animation state when needed
116
- let previousAnimationName = animationName();
117
- effect(() => {
118
- const currentAnimationName = animationName();
119
-
120
- // If animation changed externally (not through setAnimation), reset the state
121
- if (currentAnimationName !== previousAnimationName && object.animationIsPlaying && object.animationIsPlaying()) {
122
- // Check if this is a movement animation (walk, stand) that should interrupt custom animations
123
- const movementAnimations = ['walk', 'stand'];
124
- if (movementAnimations.includes(currentAnimationName)) {
140
+ // Combine animation change detection with movement state from smoothX/smoothY
141
+ const movementAnimations = ['walk', 'stand'];
142
+ const epsilon = 0; // movement threshold to consider the easing still running
143
+
144
+ const stateX$ = smoothX.animatedState.observable;
145
+ const stateY$ = smoothY.animatedState.observable;
146
+ const animationName$ = animationName.observable;
147
+
148
+ const moving$ = combineLatest([stateX$, stateY$]).pipe(
149
+ map(([sx, sy]) => {
150
+ const xFinished = Math.abs(sx.value.current - sx.value.end) <= epsilon;
151
+ const yFinished = Math.abs(sy.value.current - sy.value.end) <= epsilon;
152
+ return !xFinished || !yFinished; // moving if X or Y is not finished
153
+ }),
154
+ startWith(false)
155
+ );
156
+
157
+ const animationChange$ = animationName$.pipe(
158
+ startWith(animationName()),
159
+ pairwise(),
160
+ filter(([prev, curr]) => prev !== curr)
161
+ );
162
+
163
+ const animationMovementSubscription = combineLatest([animationChange$, moving$]).subscribe(([[prev, curr], isMoving]) => {
164
+ if (curr == 'stand' && !isMoving) {
165
+ realAnimationName.set(curr);
166
+ }
167
+ else if (curr == 'walk' && isMoving) {
168
+ realAnimationName.set(curr);
169
+ }
170
+ else if (!movementAnimations.includes(curr)) {
171
+ realAnimationName.set(curr);
172
+ }
173
+ if (!isMoving && object.animationIsPlaying && object.animationIsPlaying()) {
174
+ if (movementAnimations.includes(curr)) {
125
175
  if (typeof object.resetAnimationState === 'function') {
126
176
  object.resetAnimationState();
127
177
  }
128
178
  }
129
179
  }
130
-
131
- previousAnimationName = currentAnimationName;
132
-
133
180
  });
134
181
 
182
+ /**
183
+ * Cleanup subscriptions and call hooks before sprite destruction.
184
+ *
185
+ * # Design
186
+ * - Prevent memory leaks by unsubscribing from all local subscriptions created in this component.
187
+ * - Execute destruction hooks to notify modules and scene map of sprite removal.
188
+ *
189
+ * @example
190
+ * await onBeforeDestroy();
191
+ */
135
192
  const onBeforeDestroy = async () => {
193
+ animationMovementSubscription.unsubscribe();
194
+ xSubscription.unsubscribe();
195
+ ySubscription.unsubscribe();
136
196
  await lastValueFrom(hooks.callHooks("client-sprite-onDestroy", object))
137
197
  await lastValueFrom(hooks.callHooks("client-sceneMap-onRemoveSprite", client.sceneMap, object))
138
198
  }
@@ -78,7 +78,6 @@
78
78
  }
79
79
  const dialogBoxTypewriterSound = client.globalConfig?.box?.sounds?.typewriter
80
80
 
81
- const spritesheets = client.spritesheets;
82
81
  const sounds = client.sounds;
83
82
 
84
83
  client.stopProcessingInput = true;
@@ -140,7 +139,7 @@
140
139
 
141
140
  const faceSheet = (graphicId, animationName) => {
142
141
  return {
143
- definition: spritesheets.get(graphicId),
142
+ definition: client.getSpriteSheet(graphicId),
144
143
  playing: animationName,
145
144
  };
146
145
  }