@rpgjs/client 5.0.0-beta.20 → 5.0.0-beta.23

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 (52) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/Game/Object.d.ts +1 -0
  3. package/dist/Game/Object.js +10 -0
  4. package/dist/Game/Object.js.map +1 -1
  5. package/dist/Gui/Gui.js +37 -4
  6. package/dist/Gui/Gui.js.map +1 -1
  7. package/dist/RpgClientEngine.d.ts +18 -9
  8. package/dist/RpgClientEngine.js +40 -13
  9. package/dist/RpgClientEngine.js.map +1 -1
  10. package/dist/components/character-hitbox.d.ts +13 -0
  11. package/dist/components/character-hitbox.js +38 -0
  12. package/dist/components/character-hitbox.js.map +1 -0
  13. package/dist/components/character-hitbox.spec.d.ts +1 -0
  14. package/dist/components/character.ce.js +179 -36
  15. package/dist/components/character.ce.js.map +1 -1
  16. package/dist/components/gui/mobile/index.d.ts +51 -2
  17. package/dist/components/gui/mobile/index.js +12 -4
  18. package/dist/components/gui/mobile/index.js.map +1 -1
  19. package/dist/components/gui/mobile/index.spec.d.ts +1 -0
  20. package/dist/components/gui/mobile/mobile.ce.js +303 -55
  21. package/dist/components/gui/mobile/mobile.ce.js.map +1 -1
  22. package/dist/index.d.ts +1 -0
  23. package/dist/services/cameraFollow.d.ts +51 -0
  24. package/dist/services/cameraFollow.js +134 -0
  25. package/dist/services/cameraFollow.js.map +1 -0
  26. package/dist/services/cameraFollow.spec.d.ts +1 -0
  27. package/dist/services/standalone.d.ts +6 -1
  28. package/dist/services/standalone.js +35 -17
  29. package/dist/services/standalone.js.map +1 -1
  30. package/dist/utils/syncHitbox.d.ts +1 -0
  31. package/dist/utils/syncHitbox.js +69 -0
  32. package/dist/utils/syncHitbox.js.map +1 -0
  33. package/dist/utils/syncHitbox.spec.d.ts +1 -0
  34. package/package.json +5 -5
  35. package/src/Game/Object.spec.ts +44 -4
  36. package/src/Game/Object.ts +19 -0
  37. package/src/Gui/Gui.spec.ts +19 -0
  38. package/src/Gui/Gui.ts +50 -11
  39. package/src/RpgClientEngine.ts +56 -24
  40. package/src/components/character-hitbox.spec.ts +33 -0
  41. package/src/components/character-hitbox.ts +72 -0
  42. package/src/components/character.ce +207 -48
  43. package/src/components/gui/mobile/index.spec.ts +94 -0
  44. package/src/components/gui/mobile/index.ts +74 -6
  45. package/src/components/gui/mobile/mobile.ce +347 -65
  46. package/src/index.ts +12 -0
  47. package/src/services/cameraFollow.spec.ts +220 -0
  48. package/src/services/cameraFollow.ts +222 -0
  49. package/src/services/standalone.spec.ts +47 -0
  50. package/src/services/standalone.ts +53 -20
  51. package/src/utils/syncHitbox.spec.ts +79 -0
  52. package/src/utils/syncHitbox.ts +104 -0
@@ -39,8 +39,15 @@ import { normalizeActionInput } from "./services/actionInput";
39
39
  import { createClientPointerContext, type ClientPointerContext } from "./services/pointerContext";
40
40
  import { RpgClientInteractions } from "./services/interactions";
41
41
  import { normalizeRoomMapId } from "./utils/mapId";
42
+ import { applySyncedHitboxPayload } from "./utils/syncHitbox";
42
43
  import { EventComponentResolverRegistry, type EventComponentResolver } from "./Game/EventComponentResolver";
43
44
  import { RpgClientBuiltinI18n } from "./i18n";
45
+ import type { CameraFollowSmoothMove } from "./services/cameraFollow";
46
+ export type {
47
+ CameraFollowEase,
48
+ CameraFollowSmoothMove,
49
+ CameraFollowSmoothMoveOptions,
50
+ } from "./services/cameraFollow";
44
51
 
45
52
  interface MovementTrajectoryPoint {
46
53
  frame: number;
@@ -183,10 +190,14 @@ export class RpgClientEngine<T = any> {
183
190
  private eventComponentResolvers = new EventComponentResolverRegistry();
184
191
  /** ID of the sprite that the camera should follow. null means follow the current player */
185
192
  cameraFollowTargetId = signal<string | null>(null);
193
+ /** Camera follow transition options used by character components when the target changes */
194
+ cameraFollowSmoothMove: CameraFollowSmoothMove = false;
195
+ /** Incremented for each camera follow command so repeated commands on the same target are applied */
196
+ cameraFollowRevision = signal(0);
186
197
  /** Trigger for map shake animation */
187
198
  mapShakeTrigger: ConfigurableTrigger<MapShakeOptions> = trigger<MapShakeOptions>();
188
199
 
189
- controlsReady = signal(undefined);
200
+ controlsReady = signal<boolean | undefined>(undefined);
190
201
  gamePause = signal(false);
191
202
 
192
203
  private predictionEnabled = false;
@@ -363,7 +374,7 @@ export class RpgClientEngine<T = any> {
363
374
  ...currentValues,
364
375
  values: new Map([['__default__', controlInstance]])
365
376
  }
366
- this.controlsReady.set(undefined);
377
+ this.controlsReady.set(true);
367
378
  }
368
379
 
369
380
  async start() {
@@ -606,6 +617,12 @@ export class RpgClientEngine<T = any> {
606
617
  return find((this.canvasApp as any)?.stage);
607
618
  }
608
619
 
620
+ private clearCameraFollowViewportPlugins(): void {
621
+ const viewport = this.findViewportInstance();
622
+ viewport?.plugins?.remove?.("animate");
623
+ viewport?.plugins?.remove?.("follow");
624
+ }
625
+
609
626
  private prepareSyncPayload(data: any): any {
610
627
  const payload = { ...(data ?? {}) };
611
628
  delete payload.ack;
@@ -881,7 +898,7 @@ export class RpgClientEngine<T = any> {
881
898
  this.sceneMap.clearLightSpots();
882
899
  this.clearComponentAnimations();
883
900
  this.projectiles.setMapId(nextMapId);
884
- this.cameraFollowTargetId.set(null);
901
+ this.resetCameraFollow(false);
885
902
  this.sceneMap.reset();
886
903
  this.sceneMap.loadPhysic();
887
904
  }
@@ -951,6 +968,7 @@ export class RpgClientEngine<T = any> {
951
968
  : undefined;
952
969
  const payload = this.prepareSyncPayload(data);
953
970
  load(this.sceneMap, payload, true);
971
+ applySyncedHitboxPayload(this.sceneMap, payload);
954
972
 
955
973
  if (normalizedAck) {
956
974
  this.applyServerAck(normalizedAck);
@@ -1476,19 +1494,22 @@ export class RpgClientEngine<T = any> {
1476
1494
  * Set the camera to follow a specific sprite
1477
1495
  *
1478
1496
  * This method changes which sprite the camera viewport should follow.
1479
- * The camera will smoothly animate to the target sprite if smoothMove options are provided.
1497
+ * The camera can smoothly animate to the target sprite before continuous follow starts.
1480
1498
  *
1481
1499
  * ## Design
1482
1500
  *
1483
1501
  * The camera follow target is stored in a signal that is read by sprite components.
1484
1502
  * Each sprite checks if it should be followed by comparing its ID with the target ID.
1485
- * When smoothMove options are provided, the viewport animation is handled by CanvasEngine's
1486
- * viewport system.
1503
+ * When smoothMove options are provided, the transition is handled by pixi-viewport's
1504
+ * animation plugin, then continuous follow is handled by CanvasEngine's viewport system.
1487
1505
  *
1488
1506
  * @param targetId - The ID of the sprite to follow. Set to null to follow the current player
1489
1507
  * @param smoothMove - Animation options. Can be a boolean (default: true) or an object with time and ease
1490
1508
  * @param smoothMove.time - Duration of the animation in milliseconds (optional)
1491
1509
  * @param smoothMove.ease - Easing function name from https://easings.net (optional)
1510
+ * @param smoothMove.speed - Continuous follow speed after the transition (optional)
1511
+ * @param smoothMove.acceleration - Continuous follow acceleration after the transition (optional)
1512
+ * @param smoothMove.radius - Center radius where the target can move without moving the viewport (optional)
1492
1513
  *
1493
1514
  * @example
1494
1515
  * ```ts
@@ -1510,19 +1531,19 @@ export class RpgClientEngine<T = any> {
1510
1531
  */
1511
1532
  setCameraFollow(
1512
1533
  targetId: string | null,
1513
- smoothMove?: boolean | { time?: number; ease?: string }
1534
+ smoothMove?: CameraFollowSmoothMove
1514
1535
  ): void {
1515
- // Store smoothMove options for potential future use with viewport animation
1516
- // For now, we just set the target ID and let CanvasEngine handle the viewport follow
1517
- // The smoothMove options could be used to configure viewport animation if CanvasEngine supports it
1536
+ this.clearCameraFollowViewportPlugins();
1537
+ this.cameraFollowSmoothMove = smoothMove ?? true;
1518
1538
  this.cameraFollowTargetId.set(targetId);
1519
-
1520
- // If smoothMove is an object, we could store it for viewport configuration
1521
- // This would require integration with CanvasEngine's viewport animation system
1522
- if (typeof smoothMove === "object" && smoothMove !== null) {
1523
- // Future: Apply smoothMove.time and smoothMove.ease to viewport animation
1524
- // For now, CanvasEngine handles viewport following automatically
1525
- }
1539
+ this.cameraFollowRevision.set(this.cameraFollowRevision() + 1);
1540
+ }
1541
+
1542
+ private resetCameraFollow(smoothMove: CameraFollowSmoothMove = false): void {
1543
+ this.clearCameraFollowViewportPlugins();
1544
+ this.cameraFollowSmoothMove = smoothMove;
1545
+ this.cameraFollowTargetId.set(null);
1546
+ this.cameraFollowRevision.set(this.cameraFollowRevision() + 1);
1526
1547
  }
1527
1548
 
1528
1549
  addParticle(particle: any) {
@@ -2153,12 +2174,25 @@ export class RpgClientEngine<T = any> {
2153
2174
  return { x, y, direction };
2154
2175
  }
2155
2176
 
2177
+ private resolveHitboxDimension(source: unknown, fallback: number): number {
2178
+ const value = typeof source === "string" ? Number(source) : source;
2179
+ return typeof value === "number" && Number.isFinite(value) && value > 0
2180
+ ? value
2181
+ : fallback;
2182
+ }
2183
+
2184
+ private resolveObjectHitboxSize(object: any): { width: number; height: number } {
2185
+ const hitbox = typeof object?.hitbox === "function" ? object.hitbox() : object?.hitbox;
2186
+ return {
2187
+ width: this.resolveHitboxDimension(hitbox?.w ?? hitbox?.width, 0),
2188
+ height: this.resolveHitboxDimension(hitbox?.h ?? hitbox?.height, 0),
2189
+ };
2190
+ }
2191
+
2156
2192
  private applyAuthoritativeState(state: PredictionState<Direction>): void {
2157
2193
  const player = this.sceneMap?.getCurrentPlayer();
2158
2194
  if (!player) return;
2159
- const hitbox = typeof player.hitbox === "function" ? player.hitbox() : player.hitbox;
2160
- const width = hitbox?.w ?? 0;
2161
- const height = hitbox?.h ?? 0;
2195
+ const { width, height } = this.resolveObjectHitboxSize(player);
2162
2196
  const updated = this.sceneMap.updateHitbox(player.id, state.x, state.y, width, height);
2163
2197
  if (!updated) {
2164
2198
  this.sceneMap.setBodyPosition(player.id, state.x, state.y, "top-left");
@@ -2387,9 +2421,7 @@ export class RpgClientEngine<T = any> {
2387
2421
  if (!player || !myId) {
2388
2422
  return;
2389
2423
  }
2390
- const hitbox = typeof player.hitbox === "function" ? player.hitbox() : player.hitbox;
2391
- const width = hitbox?.w ?? 0;
2392
- const height = hitbox?.h ?? 0;
2424
+ const { width, height } = this.resolveObjectHitboxSize(player);
2393
2425
  const updated = this.sceneMap.updateHitbox(myId, ack.x, ack.y, width, height);
2394
2426
  if (!updated) {
2395
2427
  this.sceneMap.setBodyPosition(myId, ack.x, ack.y, "top-left");
@@ -2611,7 +2643,7 @@ export class RpgClientEngine<T = any> {
2611
2643
 
2612
2644
  // Reset signals
2613
2645
  this.playerIdSignal.set(null);
2614
- this.cameraFollowTargetId.set(null);
2646
+ this.resetCameraFollow(false);
2615
2647
  this.spriteComponentsBehind.set([]);
2616
2648
  this.spriteComponentsInFront.set([]);
2617
2649
  this.eventComponentResolvers.clear();
@@ -0,0 +1,33 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ resolveHitboxAnchor,
4
+ resolveScaledHitboxAnchor,
5
+ scaleHitboxForGraphicDisplay,
6
+ } from "./character-hitbox";
7
+
8
+ describe("character hitbox display helpers", () => {
9
+ test("keeps the rendered hitbox dimensions stable when graphic display scale changes", () => {
10
+ const hitbox = { w: 32, h: 32 };
11
+ const scaled = scaleHitboxForGraphicDisplay(hitbox, [0.5, 0.5]);
12
+
13
+ expect(scaled).toEqual({ w: 64, h: 64 });
14
+ expect(scaled!.w * 0.5).toBe(32);
15
+ expect(scaled!.h * 0.5).toBe(32);
16
+ });
17
+
18
+ test("anchors the graphic from the display-adjusted hitbox", () => {
19
+ const unscaledAnchor = resolveHitboxAnchor(96, 96, undefined, { w: 32, h: 32 });
20
+ const scaledAnchor = resolveHitboxAnchor(96, 96, undefined, { w: 64, h: 64 });
21
+
22
+ expect(unscaledAnchor).toEqual([1 / 3, 2 / 3]);
23
+ expect(scaledAnchor).toEqual([1 / 6, 1 / 3]);
24
+ });
25
+
26
+ test("keeps the graphic foot aligned with the hitbox foot after sprite scale", () => {
27
+ const anchor = resolveScaledHitboxAnchor(256, 256, undefined, { w: 56, h: 50 }, [0.5, 0.5]);
28
+
29
+ const renderedBottom = (1 - anchor[1]) * 256 * 0.5;
30
+
31
+ expect(renderedBottom).toBe(50);
32
+ });
33
+ });
@@ -0,0 +1,72 @@
1
+ export type CharacterHitbox = {
2
+ w: number;
3
+ h: number;
4
+ anchorMode?: "top-left" | "center" | "foot";
5
+ };
6
+
7
+ export const toPositiveNumber = (value: unknown): number | undefined => {
8
+ const number = typeof value === "number" ? value : parseFloat(String(value));
9
+ return Number.isFinite(number) && number > 0 ? number : undefined;
10
+ };
11
+
12
+ const clampRatio = (value: number): number => Math.min(1, Math.max(0, value));
13
+
14
+ export const scaleHitboxForGraphicDisplay = (
15
+ box: CharacterHitbox | null | undefined,
16
+ scale: [number, number],
17
+ ): CharacterHitbox | null => {
18
+ if (!box) return null;
19
+ const scaleX = Math.abs(toPositiveNumber(scale[0]) ?? 1);
20
+ const scaleY = Math.abs(toPositiveNumber(scale[1]) ?? scaleX);
21
+
22
+ return {
23
+ ...box,
24
+ w: box.w / scaleX,
25
+ h: box.h / scaleY,
26
+ };
27
+ };
28
+
29
+ export const resolveHitboxAnchor = (
30
+ spriteWidth: number | undefined,
31
+ spriteHeight: number | undefined,
32
+ realSize: number | { height?: number } | undefined,
33
+ box: CharacterHitbox | null | undefined,
34
+ ): [number, number] => {
35
+ if (!spriteWidth || !spriteHeight || !box) {
36
+ return [0, 0];
37
+ }
38
+
39
+ const heightOfSprite = typeof realSize === "number" ? realSize : realSize?.height;
40
+ const resolvedHeight = toPositiveNumber(heightOfSprite) ?? spriteHeight;
41
+ const gap = Math.max(0, (spriteHeight - resolvedHeight) / 2);
42
+ const hitboxTopLeftX = clampRatio((spriteWidth - box.w) / 2 / spriteWidth);
43
+ const hitboxTopLeftY = clampRatio((spriteHeight - box.h - gap) / spriteHeight);
44
+ const hitboxCenterX = clampRatio(hitboxTopLeftX + box.w / 2 / spriteWidth);
45
+ const hitboxCenterY = clampRatio(hitboxTopLeftY + box.h / 2 / spriteHeight);
46
+ const footY = clampRatio((spriteHeight - gap) / spriteHeight);
47
+
48
+ switch (box.anchorMode ?? "top-left") {
49
+ case "center":
50
+ return [hitboxCenterX, hitboxCenterY];
51
+ case "foot":
52
+ return [hitboxCenterX, footY];
53
+ case "top-left":
54
+ default:
55
+ return [hitboxTopLeftX, hitboxTopLeftY];
56
+ }
57
+ };
58
+
59
+ export const resolveScaledHitboxAnchor = (
60
+ spriteWidth: number | undefined,
61
+ spriteHeight: number | undefined,
62
+ realSize: number | { height?: number } | undefined,
63
+ box: CharacterHitbox | null | undefined,
64
+ scale: [number, number],
65
+ ): [number, number] => {
66
+ return resolveHitboxAnchor(
67
+ spriteWidth,
68
+ spriteHeight,
69
+ realSize,
70
+ scaleHitboxForGraphicDisplay(box, scale),
71
+ );
72
+ };