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

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rpgjs/client",
3
- "version": "5.0.0-beta.20",
3
+ "version": "5.0.0-beta.22",
4
4
  "description": "RPGJS is a framework for creating RPG/MMORPG games",
5
5
  "main": "dist/index.js",
6
6
  "types": "./dist/index.d.ts",
@@ -18,13 +18,13 @@
18
18
  "license": "MIT",
19
19
  "peerDependencies": {
20
20
  "@canvasengine/presets": "^2.0.0",
21
- "canvasengine": "^2.0.0",
21
+ "canvasengine": "^2.0.1",
22
22
  "pixi.js": "^8.9.2"
23
23
  },
24
24
  "dependencies": {
25
- "@rpgjs/common": "5.0.0-beta.20",
26
- "@rpgjs/server": "5.0.0-beta.20",
27
- "@rpgjs/ui-css": "5.0.0-beta.20",
25
+ "@rpgjs/common": "5.0.0-beta.21",
26
+ "@rpgjs/server": "5.0.0-beta.22",
27
+ "@rpgjs/ui-css": "5.0.0-beta.21",
28
28
  "@signe/di": "3.1.0",
29
29
  "@signe/room": "3.1.0",
30
30
  "@signe/sync": "3.1.0",
@@ -98,6 +98,25 @@ describe("RpgGui Vue integration", () => {
98
98
  });
99
99
  });
100
100
 
101
+ test("waits for CanvasEngine GUI dependencies before displaying", async () => {
102
+ const { gui } = await createGui();
103
+ const enabled = signal(true);
104
+ const ready = signal<boolean | undefined>(undefined);
105
+
106
+ gui.add({
107
+ id: "canvas-dependency",
108
+ component: CanvasGui,
109
+ autoDisplay: true,
110
+ dependencies: () => [enabled, ready],
111
+ });
112
+
113
+ expect(gui.isDisplaying("canvas-dependency")).toBe(false);
114
+
115
+ ready.set(true);
116
+
117
+ expect(gui.isDisplaying("canvas-dependency")).toBe(true);
118
+ });
119
+
101
120
  test("ignores stale server close events for previous GUI opens", async () => {
102
121
  const { gui, socket } = await createGui();
103
122
  await gui._initialize();
package/src/Gui/Gui.ts CHANGED
@@ -449,18 +449,48 @@ export class RpgGui {
449
449
  }
450
450
 
451
451
  const guiInstance = this.get(id)!;
452
-
453
- // Check if it's a Vue component (in extraGuis)
454
452
  const isVueComponent = this.extraGuis.some(gui => gui.name === id);
455
-
456
- if (isVueComponent) {
457
- // Handle Vue component display
458
- this._handleVueComponentDisplay(id, data, dependencies, guiInstance, openId);
459
- } else {
453
+
454
+ if (guiInstance.subscription) {
455
+ guiInstance.subscription.unsubscribe();
456
+ guiInstance.subscription = undefined;
457
+ }
458
+
459
+ const show = () => {
460
460
  guiInstance.openId = openId;
461
461
  guiInstance.data.set(data);
462
462
  guiInstance.display.set(true);
463
+ if (isVueComponent) {
464
+ this._notifyVueGui(id, true, data);
465
+ }
466
+ };
467
+
468
+ const deps = dependencies.length > 0
469
+ ? dependencies
470
+ : (guiInstance.dependencies ?? []);
471
+
472
+ if (deps.length > 0) {
473
+ const values = deps.map(dependency => dependency());
474
+ const subscription = new Subscription();
475
+ const showIfReady = () => {
476
+ if (values.every(value => value !== undefined)) {
477
+ show();
478
+ }
479
+ };
480
+
481
+ deps.forEach((dependency, index) => {
482
+ subscription.add(dependency.observable.subscribe((value) => {
483
+ values[index] = value;
484
+ showIfReady();
485
+ }));
486
+ });
487
+
488
+ guiInstance.subscription = subscription;
489
+ showIfReady();
490
+ return;
463
491
  }
492
+
493
+ show();
464
494
  }
465
495
 
466
496
  isDisplaying(id: string): boolean {
@@ -490,17 +520,26 @@ export class RpgGui {
490
520
  : (guiInstance.dependencies ?? []);
491
521
 
492
522
  if (deps.length > 0) {
493
- // Subscribe to dependencies
494
- guiInstance.subscription = combineLatest(
495
- deps.map(dependency => dependency.observable)
496
- ).subscribe((values) => {
523
+ const values = deps.map(dependency => dependency());
524
+ const subscription = new Subscription();
525
+ const showIfReady = () => {
497
526
  if (values.every(value => value !== undefined)) {
498
527
  guiInstance.openId = openId;
499
528
  guiInstance.data.set(data);
500
529
  guiInstance.display.set(true);
501
530
  this._notifyVueGui(id, true, data);
502
531
  }
532
+ };
533
+
534
+ deps.forEach((dependency, index) => {
535
+ subscription.add(dependency.observable.subscribe((value) => {
536
+ values[index] = value;
537
+ showIfReady();
538
+ }));
503
539
  });
540
+
541
+ guiInstance.subscription = subscription;
542
+ showIfReady();
504
543
  return;
505
544
  }
506
545
 
@@ -41,6 +41,12 @@ import { RpgClientInteractions } from "./services/interactions";
41
41
  import { normalizeRoomMapId } from "./utils/mapId";
42
42
  import { EventComponentResolverRegistry, type EventComponentResolver } from "./Game/EventComponentResolver";
43
43
  import { RpgClientBuiltinI18n } from "./i18n";
44
+ import type { CameraFollowSmoothMove } from "./services/cameraFollow";
45
+ export type {
46
+ CameraFollowEase,
47
+ CameraFollowSmoothMove,
48
+ CameraFollowSmoothMoveOptions,
49
+ } from "./services/cameraFollow";
44
50
 
45
51
  interface MovementTrajectoryPoint {
46
52
  frame: number;
@@ -183,10 +189,14 @@ export class RpgClientEngine<T = any> {
183
189
  private eventComponentResolvers = new EventComponentResolverRegistry();
184
190
  /** ID of the sprite that the camera should follow. null means follow the current player */
185
191
  cameraFollowTargetId = signal<string | null>(null);
192
+ /** Camera follow transition options used by character components when the target changes */
193
+ cameraFollowSmoothMove: CameraFollowSmoothMove = false;
194
+ /** Incremented for each camera follow command so repeated commands on the same target are applied */
195
+ cameraFollowRevision = signal(0);
186
196
  /** Trigger for map shake animation */
187
197
  mapShakeTrigger: ConfigurableTrigger<MapShakeOptions> = trigger<MapShakeOptions>();
188
198
 
189
- controlsReady = signal(undefined);
199
+ controlsReady = signal<boolean | undefined>(undefined);
190
200
  gamePause = signal(false);
191
201
 
192
202
  private predictionEnabled = false;
@@ -363,7 +373,7 @@ export class RpgClientEngine<T = any> {
363
373
  ...currentValues,
364
374
  values: new Map([['__default__', controlInstance]])
365
375
  }
366
- this.controlsReady.set(undefined);
376
+ this.controlsReady.set(true);
367
377
  }
368
378
 
369
379
  async start() {
@@ -606,6 +616,12 @@ export class RpgClientEngine<T = any> {
606
616
  return find((this.canvasApp as any)?.stage);
607
617
  }
608
618
 
619
+ private clearCameraFollowViewportPlugins(): void {
620
+ const viewport = this.findViewportInstance();
621
+ viewport?.plugins?.remove?.("animate");
622
+ viewport?.plugins?.remove?.("follow");
623
+ }
624
+
609
625
  private prepareSyncPayload(data: any): any {
610
626
  const payload = { ...(data ?? {}) };
611
627
  delete payload.ack;
@@ -881,7 +897,7 @@ export class RpgClientEngine<T = any> {
881
897
  this.sceneMap.clearLightSpots();
882
898
  this.clearComponentAnimations();
883
899
  this.projectiles.setMapId(nextMapId);
884
- this.cameraFollowTargetId.set(null);
900
+ this.resetCameraFollow(false);
885
901
  this.sceneMap.reset();
886
902
  this.sceneMap.loadPhysic();
887
903
  }
@@ -1476,19 +1492,22 @@ export class RpgClientEngine<T = any> {
1476
1492
  * Set the camera to follow a specific sprite
1477
1493
  *
1478
1494
  * 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.
1495
+ * The camera can smoothly animate to the target sprite before continuous follow starts.
1480
1496
  *
1481
1497
  * ## Design
1482
1498
  *
1483
1499
  * The camera follow target is stored in a signal that is read by sprite components.
1484
1500
  * 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.
1501
+ * When smoothMove options are provided, the transition is handled by pixi-viewport's
1502
+ * animation plugin, then continuous follow is handled by CanvasEngine's viewport system.
1487
1503
  *
1488
1504
  * @param targetId - The ID of the sprite to follow. Set to null to follow the current player
1489
1505
  * @param smoothMove - Animation options. Can be a boolean (default: true) or an object with time and ease
1490
1506
  * @param smoothMove.time - Duration of the animation in milliseconds (optional)
1491
1507
  * @param smoothMove.ease - Easing function name from https://easings.net (optional)
1508
+ * @param smoothMove.speed - Continuous follow speed after the transition (optional)
1509
+ * @param smoothMove.acceleration - Continuous follow acceleration after the transition (optional)
1510
+ * @param smoothMove.radius - Center radius where the target can move without moving the viewport (optional)
1492
1511
  *
1493
1512
  * @example
1494
1513
  * ```ts
@@ -1510,19 +1529,19 @@ export class RpgClientEngine<T = any> {
1510
1529
  */
1511
1530
  setCameraFollow(
1512
1531
  targetId: string | null,
1513
- smoothMove?: boolean | { time?: number; ease?: string }
1532
+ smoothMove?: CameraFollowSmoothMove
1514
1533
  ): 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
1534
+ this.clearCameraFollowViewportPlugins();
1535
+ this.cameraFollowSmoothMove = smoothMove ?? true;
1518
1536
  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
- }
1537
+ this.cameraFollowRevision.set(this.cameraFollowRevision() + 1);
1538
+ }
1539
+
1540
+ private resetCameraFollow(smoothMove: CameraFollowSmoothMove = false): void {
1541
+ this.clearCameraFollowViewportPlugins();
1542
+ this.cameraFollowSmoothMove = smoothMove;
1543
+ this.cameraFollowTargetId.set(null);
1544
+ this.cameraFollowRevision.set(this.cameraFollowRevision() + 1);
1526
1545
  }
1527
1546
 
1528
1547
  addParticle(particle: any) {
@@ -2611,7 +2630,7 @@ export class RpgClientEngine<T = any> {
2611
2630
 
2612
2631
  // Reset signals
2613
2632
  this.playerIdSignal.set(null);
2614
- this.cameraFollowTargetId.set(null);
2633
+ this.resetCameraFollow(false);
2615
2634
  this.spriteComponentsBehind.set([]);
2616
2635
  this.spriteComponentsInFront.set([]);
2617
2636
  this.eventComponentResolvers.clear();
@@ -2,7 +2,6 @@
2
2
  x={smoothX}
3
3
  y={smoothY}
4
4
  zIndex={z}
5
- viewportFollow={shouldFollowCamera}
6
5
  controls
7
6
  onBeforeDestroy
8
7
  visible
@@ -76,6 +75,7 @@
76
75
  import { Particle } from "@canvasengine/presets";
77
76
  import { GameEngineToken, ModulesToken, shouldRenderLightingShadows } from "@rpgjs/common";
78
77
  import { RpgClientEngine } from "../RpgClientEngine";
78
+ import { applyCameraFollow, clearCameraFollowPlugins, ownsCameraFollowRevision } from "../services/cameraFollow";
79
79
  import { inject } from "../core/inject";
80
80
  import { Direction, Animation } from "@rpgjs/common";
81
81
  import { normalizeEventComponent } from "../Game/EventComponentResolver";
@@ -360,9 +360,43 @@
360
360
  const canControls = () => isMe() && getCanMoveValue(sprite)
361
361
  const keyboardControls = client.globalConfig.keyboardControls;
362
362
  const activeDirectionKeys = new Map();
363
+ const activeControlDirections = new Map();
364
+ const activeControlDirectionReleaseTimers = new Map();
365
+ const CONTROL_DIRECTION_RELEASE_GRACE_MS = 160;
366
+
367
+ const hasHeldDirection = () =>
368
+ activeDirectionKeys.size > 0 || activeControlDirections.size > 0;
369
+
370
+ const publishMobileDebug = (type, payload = {}) => {
371
+ const target = typeof window !== "undefined" ? window : globalThis;
372
+ if (!target.__RPGJS_MOBILE_DEBUG__) return;
373
+ const event = {
374
+ source: "character",
375
+ type,
376
+ time: Date.now(),
377
+ isCurrentPlayer: isCurrentPlayer(),
378
+ animationName: animationName(),
379
+ realAnimationName: realAnimationName?.(),
380
+ activeKeyboardDirections: Array.from(activeDirectionKeys.values()),
381
+ activeControlDirections: Array.from(activeControlDirections.values()),
382
+ heldDirection: resolveHeldDirection(),
383
+ canControls: canControls(),
384
+ x: x(),
385
+ y: y(),
386
+ ...payload
387
+ };
388
+ target.__RPGJS_MOBILE_DEBUG_EVENTS__ = [
389
+ ...(target.__RPGJS_MOBILE_DEBUG_EVENTS__ || []).slice(-199),
390
+ event
391
+ ];
392
+ target.__RPGJS_MOBILE_DEBUG_LAST__ = event;
393
+ };
363
394
 
364
395
  const resolveHeldDirection = () => {
365
- const directions = Array.from(activeDirectionKeys.values());
396
+ const directions = [
397
+ ...Array.from(activeDirectionKeys.values()),
398
+ ...Array.from(activeControlDirections.values()),
399
+ ];
366
400
  return directions[directions.length - 1];
367
401
  };
368
402
 
@@ -408,27 +442,77 @@
408
442
  );
409
443
 
410
444
  const playPredictedWalkAnimation = () => {
411
- if (sprite.animationFixed) return;
412
- if (sprite.animationIsPlaying && sprite.animationIsPlaying()) return;
445
+ if (sprite.animationFixed) {
446
+ publishMobileDebug("walk:skip", { reason: "animationFixed" });
447
+ return;
448
+ }
449
+ if (sprite.animationIsPlaying && sprite.animationIsPlaying()) {
450
+ publishMobileDebug("walk:skip", { reason: "animationIsPlaying" });
451
+ return;
452
+ }
413
453
  realAnimationName.set('walk');
454
+ publishMobileDebug("walk:set", { reason: "predicted" });
414
455
  };
415
456
 
416
457
  const resumeHeldDirectionWalkAnimation = () => {
417
- if (!isCurrentPlayer()) return false;
418
- if (activeDirectionKeys.size === 0) return false;
419
- if (!canControls()) return false;
420
- if (sprite.animationFixed) return false;
421
- if (sprite.animationIsPlaying && sprite.animationIsPlaying()) return false;
458
+ if (!isCurrentPlayer()) {
459
+ publishMobileDebug("walk:resume-fail", { reason: "notCurrentPlayer" });
460
+ return false;
461
+ }
462
+ if (!hasHeldDirection()) {
463
+ publishMobileDebug("walk:resume-fail", { reason: "noHeldDirection" });
464
+ return false;
465
+ }
466
+ if (!canControls()) {
467
+ publishMobileDebug("walk:resume-fail", { reason: "cannotControl" });
468
+ return false;
469
+ }
470
+ if (sprite.animationFixed) {
471
+ publishMobileDebug("walk:resume-fail", { reason: "animationFixed" });
472
+ return false;
473
+ }
474
+ if (sprite.animationIsPlaying && sprite.animationIsPlaying()) {
475
+ publishMobileDebug("walk:resume-fail", { reason: "animationIsPlaying" });
476
+ return false;
477
+ }
422
478
  realAnimationName.set('walk');
479
+ publishMobileDebug("walk:set", { reason: "resumeHeldDirection" });
423
480
  return true;
424
481
  };
425
482
 
426
483
  const processMovementInput = (input) => {
427
484
  if (!canControls()) return;
485
+ publishMobileDebug("movement:input", { input });
428
486
  client.processInput({ input });
429
487
  playPredictedWalkAnimation();
430
488
  };
431
489
 
490
+ const activateControlDirection = (name, directionInput) => {
491
+ const releaseTimer = activeControlDirectionReleaseTimers.get(name);
492
+ if (releaseTimer) {
493
+ clearTimeout(releaseTimer);
494
+ activeControlDirectionReleaseTimers.delete(name);
495
+ }
496
+ activeControlDirections.delete(name);
497
+ activeControlDirections.set(name, directionInput);
498
+ publishMobileDebug("control:activate", { name, directionInput });
499
+ resumeHeldDirectionWalkAnimation();
500
+ };
501
+
502
+ const releaseControlDirection = (name) => {
503
+ const previousTimer = activeControlDirectionReleaseTimers.get(name);
504
+ if (previousTimer) clearTimeout(previousTimer);
505
+ activeControlDirectionReleaseTimers.set(name, setTimeout(() => {
506
+ activeControlDirectionReleaseTimers.delete(name);
507
+ activeControlDirections.delete(name);
508
+ publishMobileDebug("control:release", { name });
509
+ if (!hasHeldDirection() && animationName() === 'stand') {
510
+ realAnimationName.set('stand');
511
+ publishMobileDebug("stand:set", { reason: "controlRelease" });
512
+ }
513
+ }, CONTROL_DIRECTION_RELEASE_GRACE_MS));
514
+ };
515
+
432
516
  const processDashInput = () => {
433
517
  if (!canControls()) return;
434
518
  client.processDash({
@@ -474,29 +558,45 @@
474
558
  repeat: true,
475
559
  bind: keyboardControls.down,
476
560
  keyDown() {
561
+ activateControlDirection('down', Direction.Down)
477
562
  processMovementInput(Direction.Down)
478
563
  },
564
+ keyUp() {
565
+ releaseControlDirection('down')
566
+ },
479
567
  },
480
568
  up: {
481
569
  repeat: true,
482
570
  bind: keyboardControls.up,
483
571
  keyDown() {
572
+ activateControlDirection('up', Direction.Up)
484
573
  processMovementInput(Direction.Up)
485
574
  },
575
+ keyUp() {
576
+ releaseControlDirection('up')
577
+ },
486
578
  },
487
579
  left: {
488
580
  repeat: true,
489
581
  bind: keyboardControls.left,
490
582
  keyDown() {
583
+ activateControlDirection('left', Direction.Left)
491
584
  processMovementInput(Direction.Left)
492
585
  },
586
+ keyUp() {
587
+ releaseControlDirection('left')
588
+ },
493
589
  },
494
590
  right: {
495
591
  repeat: true,
496
592
  bind: keyboardControls.right,
497
593
  keyDown() {
594
+ activateControlDirection('right', Direction.Right)
498
595
  processMovementInput(Direction.Right)
499
596
  },
597
+ keyUp() {
598
+ releaseControlDirection('right')
599
+ },
500
600
  },
501
601
  action: {
502
602
  bind: getKeyboardControlBind(keyboardControls.action),
@@ -512,6 +612,14 @@
512
612
  processDashInput()
513
613
  },
514
614
  },
615
+ back: {
616
+ bind: keyboardControls.escape,
617
+ keyDown() {
618
+ if (canControls()) {
619
+ client.processAction({ action: 'escape' })
620
+ }
621
+ },
622
+ },
515
623
  escape: {
516
624
  bind: keyboardControls.escape,
517
625
  keyDown() {
@@ -520,6 +628,21 @@
520
628
  }
521
629
  },
522
630
  },
631
+ joystick: {
632
+ enabled: true,
633
+ directionMapping: {
634
+ top: 'up',
635
+ bottom: 'down',
636
+ left: 'left',
637
+ right: 'right',
638
+ top_left: ['up', 'left'],
639
+ top_right: ['up', 'right'],
640
+ bottom_left: ['down', 'left'],
641
+ bottom_right: ['down', 'right']
642
+ },
643
+ moveInterval: 50,
644
+ threshold: 0.1
645
+ },
523
646
  gamepad: {
524
647
  enabled: true
525
648
  }
@@ -1051,6 +1174,8 @@
1051
1174
  removeTransitionSubscription?.unsubscribe();
1052
1175
  animationMovementSubscription.unsubscribe();
1053
1176
  resumeWalkSubscriptions.forEach(subscription => subscription.unsubscribe());
1177
+ activeControlDirectionReleaseTimers.forEach(timer => clearTimeout(timer));
1178
+ activeControlDirectionReleaseTimers.clear();
1054
1179
  xSubscription.unsubscribe();
1055
1180
  ySubscription.unsubscribe();
1056
1181
  await lastValueFrom(hooks.callHooks("client-sprite-onDestroy", sprite))
@@ -1058,6 +1183,7 @@
1058
1183
  }
1059
1184
 
1060
1185
  mount((element) => {
1186
+ let appliedCameraFollowRevision = null;
1061
1187
  if (typeof document !== 'undefined') {
1062
1188
  document.addEventListener('keydown', handleNativeActionWhileMoving);
1063
1189
  document.addEventListener('keyup', handleNativeActionWhileMoving);
@@ -1069,6 +1195,38 @@
1069
1195
  client.setKeyboardControls(element.directives.controls)
1070
1196
  }
1071
1197
  })
1198
+
1199
+ effect(() => {
1200
+ const followRevision = client.cameraFollowRevision();
1201
+ if (!shouldFollowCamera()) {
1202
+ appliedCameraFollowRevision = null;
1203
+ return;
1204
+ }
1205
+
1206
+ const smoothMove = client.cameraFollowSmoothMove;
1207
+ const viewport = element.props.context?.viewport;
1208
+ const target = element.componentInstance;
1209
+ if (!viewport || !target) {
1210
+ appliedCameraFollowRevision = null;
1211
+ return;
1212
+ }
1213
+
1214
+ const appliedCameraFollow = applyCameraFollow({
1215
+ viewport,
1216
+ target,
1217
+ smoothMove,
1218
+ followRevision,
1219
+ isCurrentRevision: (revision) => client.cameraFollowRevision() === revision,
1220
+ shouldFollowCamera
1221
+ });
1222
+ appliedCameraFollowRevision = appliedCameraFollow ? followRevision : null;
1223
+ })
1224
+
1225
+ return () => {
1226
+ if (ownsCameraFollowRevision(appliedCameraFollowRevision, client.cameraFollowRevision())) {
1227
+ clearCameraFollowPlugins(element.props.context?.viewport);
1228
+ }
1229
+ }
1072
1230
  })
1073
1231
 
1074
1232
  /**
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { signal } from "canvasengine";
3
+
4
+ const fakeEngine = {
5
+ controlsReady: signal(true),
6
+ };
7
+
8
+ vi.mock("../../../core/inject", () => ({
9
+ inject: vi.fn(() => fakeEngine),
10
+ }));
11
+
12
+ describe("withMobile", () => {
13
+ test("registers the mobile GUI with default options", async () => {
14
+ const { withMobile } = await import("./index");
15
+
16
+ const module = withMobile();
17
+ const gui = module.gui[0];
18
+
19
+ expect(gui.id).toBe("mobile-gui");
20
+ expect(gui.autoDisplay).toBe(true);
21
+ expect(gui.data).toEqual({});
22
+ });
23
+
24
+ test("passes options to the GUI and resolves enabled dependencies", async () => {
25
+ const { withMobile } = await import("./index");
26
+ const CustomJoystick = vi.fn();
27
+ const CustomActionButton = vi.fn();
28
+
29
+ const module = withMobile({
30
+ id: "touch-controls",
31
+ enabled: "always",
32
+ layout: {
33
+ joystickSide: "right",
34
+ gap: 18,
35
+ buttonsMargin: 44,
36
+ joystickMargin: [24, 60, 24, 24],
37
+ },
38
+ components: {
39
+ joystick: CustomJoystick,
40
+ buttons: {
41
+ action: CustomActionButton,
42
+ },
43
+ },
44
+ joystick: {
45
+ outerColor: "#111111",
46
+ innerColor: "#eeeeee",
47
+ scale: 0.8,
48
+ outerScale: { x: 0.8, y: 0.8 },
49
+ innerScale: { x: 0.9, y: 0.9 },
50
+ moveInterval: 40,
51
+ threshold: 0.2,
52
+ },
53
+ buttons: {
54
+ action: {
55
+ enabled: true,
56
+ width: 72,
57
+ },
58
+ back: false,
59
+ dash: true,
60
+ },
61
+ });
62
+ const gui = module.gui[0];
63
+ const dependencies = gui.dependencies();
64
+
65
+ expect(gui.id).toBe("touch-controls");
66
+ expect(gui.data.layout?.joystickSide).toBe("right");
67
+ expect(gui.data.components?.joystick).toBe(CustomJoystick);
68
+ expect(gui.data.components?.buttons?.action).toBe(CustomActionButton);
69
+ expect(gui.data.buttons?.action).toMatchObject({
70
+ enabled: true,
71
+ width: 72,
72
+ });
73
+ expect(gui.data.buttons?.dash).toBe(true);
74
+ expect(gui.data.joystick).toMatchObject({
75
+ outerColor: "#111111",
76
+ innerColor: "#eeeeee",
77
+ scale: 0.8,
78
+ outerScale: { x: 0.8, y: 0.8 },
79
+ innerScale: { x: 0.9, y: 0.9 },
80
+ moveInterval: 40,
81
+ threshold: 0.2,
82
+ });
83
+ expect(dependencies[0]()).toBe(true);
84
+ expect(dependencies[1]()).toBe(true);
85
+ });
86
+
87
+ test("keeps the auto detector unresolved when mobile is disabled", async () => {
88
+ const { withMobile } = await import("./index");
89
+
90
+ const dependencies = withMobile({ enabled: "never" }).gui[0].dependencies();
91
+
92
+ expect(dependencies[0]()).toBeUndefined();
93
+ });
94
+ });