@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
@@ -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
@@ -24,12 +23,12 @@
24
23
  <Particle emit={emitParticleTrigger} settings={particleSettings} zIndex={1000} name={particleName} />
25
24
  <Container>
26
25
  @for (graphicObj of renderedGraphics) {
27
- <Container scale={graphicScale(graphicObj)}>
26
+ <Container scale={graphicContainerScale(graphicObj)}>
28
27
  <Sprite
29
28
  sheet={sheet(graphicObj)}
30
29
  direction
31
30
  tint
32
- hitbox
31
+ hitbox={graphicHitbox(graphicObj)}
33
32
  shadowCaster={shadowCaster(graphicObj)}
34
33
  flash={flashConfig}
35
34
  />
@@ -76,6 +75,8 @@
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
+ import { resolveScaledHitboxAnchor, scaleHitboxForGraphicDisplay, toPositiveNumber } from "./character-hitbox";
79
80
  import { inject } from "../core/inject";
80
81
  import { Direction, Animation } from "@rpgjs/common";
81
82
  import { normalizeEventComponent } from "../Game/EventComponentResolver";
@@ -360,9 +361,43 @@
360
361
  const canControls = () => isMe() && getCanMoveValue(sprite)
361
362
  const keyboardControls = client.globalConfig.keyboardControls;
362
363
  const activeDirectionKeys = new Map();
364
+ const activeControlDirections = new Map();
365
+ const activeControlDirectionReleaseTimers = new Map();
366
+ const CONTROL_DIRECTION_RELEASE_GRACE_MS = 160;
367
+
368
+ const hasHeldDirection = () =>
369
+ activeDirectionKeys.size > 0 || activeControlDirections.size > 0;
370
+
371
+ const publishMobileDebug = (type, payload = {}) => {
372
+ const target = typeof window !== "undefined" ? window : globalThis;
373
+ if (!target.__RPGJS_MOBILE_DEBUG__) return;
374
+ const event = {
375
+ source: "character",
376
+ type,
377
+ time: Date.now(),
378
+ isCurrentPlayer: isCurrentPlayer(),
379
+ animationName: animationName(),
380
+ realAnimationName: realAnimationName?.(),
381
+ activeKeyboardDirections: Array.from(activeDirectionKeys.values()),
382
+ activeControlDirections: Array.from(activeControlDirections.values()),
383
+ heldDirection: resolveHeldDirection(),
384
+ canControls: canControls(),
385
+ x: x(),
386
+ y: y(),
387
+ ...payload
388
+ };
389
+ target.__RPGJS_MOBILE_DEBUG_EVENTS__ = [
390
+ ...(target.__RPGJS_MOBILE_DEBUG_EVENTS__ || []).slice(-199),
391
+ event
392
+ ];
393
+ target.__RPGJS_MOBILE_DEBUG_LAST__ = event;
394
+ };
363
395
 
364
396
  const resolveHeldDirection = () => {
365
- const directions = Array.from(activeDirectionKeys.values());
397
+ const directions = [
398
+ ...Array.from(activeDirectionKeys.values()),
399
+ ...Array.from(activeControlDirections.values()),
400
+ ];
366
401
  return directions[directions.length - 1];
367
402
  };
368
403
 
@@ -408,27 +443,77 @@
408
443
  );
409
444
 
410
445
  const playPredictedWalkAnimation = () => {
411
- if (sprite.animationFixed) return;
412
- if (sprite.animationIsPlaying && sprite.animationIsPlaying()) return;
446
+ if (sprite.animationFixed) {
447
+ publishMobileDebug("walk:skip", { reason: "animationFixed" });
448
+ return;
449
+ }
450
+ if (sprite.animationIsPlaying && sprite.animationIsPlaying()) {
451
+ publishMobileDebug("walk:skip", { reason: "animationIsPlaying" });
452
+ return;
453
+ }
413
454
  realAnimationName.set('walk');
455
+ publishMobileDebug("walk:set", { reason: "predicted" });
414
456
  };
415
457
 
416
458
  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;
459
+ if (!isCurrentPlayer()) {
460
+ publishMobileDebug("walk:resume-fail", { reason: "notCurrentPlayer" });
461
+ return false;
462
+ }
463
+ if (!hasHeldDirection()) {
464
+ publishMobileDebug("walk:resume-fail", { reason: "noHeldDirection" });
465
+ return false;
466
+ }
467
+ if (!canControls()) {
468
+ publishMobileDebug("walk:resume-fail", { reason: "cannotControl" });
469
+ return false;
470
+ }
471
+ if (sprite.animationFixed) {
472
+ publishMobileDebug("walk:resume-fail", { reason: "animationFixed" });
473
+ return false;
474
+ }
475
+ if (sprite.animationIsPlaying && sprite.animationIsPlaying()) {
476
+ publishMobileDebug("walk:resume-fail", { reason: "animationIsPlaying" });
477
+ return false;
478
+ }
422
479
  realAnimationName.set('walk');
480
+ publishMobileDebug("walk:set", { reason: "resumeHeldDirection" });
423
481
  return true;
424
482
  };
425
483
 
426
484
  const processMovementInput = (input) => {
427
485
  if (!canControls()) return;
486
+ publishMobileDebug("movement:input", { input });
428
487
  client.processInput({ input });
429
488
  playPredictedWalkAnimation();
430
489
  };
431
490
 
491
+ const activateControlDirection = (name, directionInput) => {
492
+ const releaseTimer = activeControlDirectionReleaseTimers.get(name);
493
+ if (releaseTimer) {
494
+ clearTimeout(releaseTimer);
495
+ activeControlDirectionReleaseTimers.delete(name);
496
+ }
497
+ activeControlDirections.delete(name);
498
+ activeControlDirections.set(name, directionInput);
499
+ publishMobileDebug("control:activate", { name, directionInput });
500
+ resumeHeldDirectionWalkAnimation();
501
+ };
502
+
503
+ const releaseControlDirection = (name) => {
504
+ const previousTimer = activeControlDirectionReleaseTimers.get(name);
505
+ if (previousTimer) clearTimeout(previousTimer);
506
+ activeControlDirectionReleaseTimers.set(name, setTimeout(() => {
507
+ activeControlDirectionReleaseTimers.delete(name);
508
+ activeControlDirections.delete(name);
509
+ publishMobileDebug("control:release", { name });
510
+ if (!hasHeldDirection() && animationName() === 'stand') {
511
+ realAnimationName.set('stand');
512
+ publishMobileDebug("stand:set", { reason: "controlRelease" });
513
+ }
514
+ }, CONTROL_DIRECTION_RELEASE_GRACE_MS));
515
+ };
516
+
432
517
  const processDashInput = () => {
433
518
  if (!canControls()) return;
434
519
  client.processDash({
@@ -474,29 +559,45 @@
474
559
  repeat: true,
475
560
  bind: keyboardControls.down,
476
561
  keyDown() {
562
+ activateControlDirection('down', Direction.Down)
477
563
  processMovementInput(Direction.Down)
478
564
  },
565
+ keyUp() {
566
+ releaseControlDirection('down')
567
+ },
479
568
  },
480
569
  up: {
481
570
  repeat: true,
482
571
  bind: keyboardControls.up,
483
572
  keyDown() {
573
+ activateControlDirection('up', Direction.Up)
484
574
  processMovementInput(Direction.Up)
485
575
  },
576
+ keyUp() {
577
+ releaseControlDirection('up')
578
+ },
486
579
  },
487
580
  left: {
488
581
  repeat: true,
489
582
  bind: keyboardControls.left,
490
583
  keyDown() {
584
+ activateControlDirection('left', Direction.Left)
491
585
  processMovementInput(Direction.Left)
492
586
  },
587
+ keyUp() {
588
+ releaseControlDirection('left')
589
+ },
493
590
  },
494
591
  right: {
495
592
  repeat: true,
496
593
  bind: keyboardControls.right,
497
594
  keyDown() {
595
+ activateControlDirection('right', Direction.Right)
498
596
  processMovementInput(Direction.Right)
499
597
  },
598
+ keyUp() {
599
+ releaseControlDirection('right')
600
+ },
500
601
  },
501
602
  action: {
502
603
  bind: getKeyboardControlBind(keyboardControls.action),
@@ -512,6 +613,14 @@
512
613
  processDashInput()
513
614
  },
514
615
  },
616
+ back: {
617
+ bind: keyboardControls.escape,
618
+ keyDown() {
619
+ if (canControls()) {
620
+ client.processAction({ action: 'escape' })
621
+ }
622
+ },
623
+ },
515
624
  escape: {
516
625
  bind: keyboardControls.escape,
517
626
  keyDown() {
@@ -520,6 +629,21 @@
520
629
  }
521
630
  },
522
631
  },
632
+ joystick: {
633
+ enabled: true,
634
+ directionMapping: {
635
+ top: 'up',
636
+ bottom: 'down',
637
+ left: 'left',
638
+ right: 'right',
639
+ top_left: ['up', 'left'],
640
+ top_right: ['up', 'right'],
641
+ bottom_left: ['down', 'left'],
642
+ bottom_right: ['down', 'right']
643
+ },
644
+ moveInterval: 50,
645
+ threshold: 0.1
646
+ },
523
647
  gamepad: {
524
648
  enabled: true
525
649
  }
@@ -562,7 +686,7 @@
562
686
  }
563
687
 
564
688
  const graphicScale = (graphicObject) => {
565
- const scale = graphicObject?.displayScale ?? graphicObject?.scale;
689
+ const scale = graphicObject?.scale;
566
690
  if (Array.isArray(scale)) return scale;
567
691
  if (typeof scale === 'number') return [scale, scale];
568
692
  if (scale && typeof scale === 'object') {
@@ -573,12 +697,34 @@
573
697
  return undefined;
574
698
  }
575
699
 
700
+ const graphicContainerScale = (graphicObject) => {
701
+ const scale = graphicObject?.displayScale;
702
+ if (Array.isArray(scale)) return scale;
703
+ if (typeof scale === 'number') return [scale, scale];
704
+ if (scale && typeof scale === 'object') {
705
+ const x = typeof scale.x === 'number' ? scale.x : 1;
706
+ const y = typeof scale.y === 'number' ? scale.y : x;
707
+ return [x, y];
708
+ }
709
+ return [1, 1];
710
+ }
711
+
712
+ const multiplyScale = (left, right) => {
713
+ const a = normalizePair(left);
714
+ const b = normalizePair(right);
715
+ return [a[0] * b[0], a[1] * b[1]];
716
+ }
717
+
718
+ const graphicHitbox = (graphicObject) => {
719
+ const box = hitbox();
720
+ const scale = graphicEffectiveScale(graphicObject);
721
+ return scaleHitboxForGraphicDisplay(box, scale);
722
+ }
723
+
576
724
  const shadowCaster = (graphicObject) => {
577
725
  const box = hitbox();
578
726
  const bounds = graphicBounds();
579
- const scale = graphicScale(graphicObject);
580
- const scaleY = Array.isArray(scale) && typeof scale[1] === 'number' ? Math.abs(scale[1]) : 1;
581
- const height = Math.max(bounds?.height ?? box?.h ?? 32, box?.h ?? 32) * scaleY;
727
+ const height = Math.max(bounds?.height ?? box?.h ?? 32, box?.h ?? 32);
582
728
 
583
729
  return {
584
730
  enabled: shadowsEnabled,
@@ -599,11 +745,6 @@
599
745
  const imageDimensions = signal({});
600
746
  const loadingImageDimensions = new Set();
601
747
 
602
- const toPositiveNumber = (value) => {
603
- const number = typeof value === 'number' ? value : parseFloat(value);
604
- return Number.isFinite(number) && number > 0 ? number : undefined;
605
- };
606
-
607
748
  const toFiniteNumber = (value, fallback = 0) => {
608
749
  const number = typeof value === 'number' ? value : parseFloat(value);
609
750
  return Number.isFinite(number) ? number : fallback;
@@ -703,6 +844,13 @@
703
844
  return frame?.[prop] ?? textureOptions?.[prop] ?? graphicObject?.[prop];
704
845
  };
705
846
 
847
+ const graphicEffectiveScale = (graphicObject) => {
848
+ const textureOptions = resolveTextureOptions(graphicObject);
849
+ const frame = resolveFirstAnimationFrame(textureOptions);
850
+ const spriteScale = normalizePair(optionValue('scale', frame, textureOptions, graphicObject) ?? graphicScale(graphicObject));
851
+ return multiplyScale(spriteScale, graphicContainerScale(graphicObject));
852
+ };
853
+
706
854
  const resolveFrameSize = (textureOptions, dimensions) => {
707
855
  const framesWidth = toPositiveNumber(textureOptions?.framesWidth) ?? 1;
708
856
  const framesHeight = toPositiveNumber(textureOptions?.framesHeight) ?? 1;
@@ -723,31 +871,6 @@
723
871
  };
724
872
  };
725
873
 
726
- const resolveHitboxAnchor = (spriteWidth, spriteHeight, realSize, box) => {
727
- if (!spriteWidth || !spriteHeight || !box) {
728
- return [0, 0];
729
- }
730
-
731
- const heightOfSprite = typeof realSize === 'number' ? realSize : realSize?.height;
732
- const resolvedHeight = toPositiveNumber(heightOfSprite) ?? spriteHeight;
733
- const gap = Math.max(0, (spriteHeight - resolvedHeight) / 2);
734
- const hitboxTopLeftX = clampRatio((spriteWidth - box.w) / 2 / spriteWidth);
735
- const hitboxTopLeftY = clampRatio((spriteHeight - box.h - gap) / spriteHeight);
736
- const hitboxCenterX = clampRatio(hitboxTopLeftX + box.w / 2 / spriteWidth);
737
- const hitboxCenterY = clampRatio(hitboxTopLeftY + box.h / 2 / spriteHeight);
738
- const footY = clampRatio((spriteHeight - gap) / spriteHeight);
739
-
740
- switch (box.anchorMode ?? 'top-left') {
741
- case 'center':
742
- return [hitboxCenterX, hitboxCenterY];
743
- case 'foot':
744
- return [hitboxCenterX, footY];
745
- case 'top-left':
746
- default:
747
- return [hitboxTopLeftX, hitboxTopLeftY];
748
- }
749
- };
750
-
751
874
  const loadImageDimensions = (image) => {
752
875
  const source = resolveImageSource(image);
753
876
  if (!source || imageDimensions()[source] || loadingImageDimensions.has(source)) {
@@ -827,14 +950,15 @@
827
950
  return;
828
951
  }
829
952
 
953
+ const scale = graphicEffectiveScale(graphicObject);
830
954
  const explicitAnchor = normalizeAnchor(optionValue('anchor', frame, textureOptions, graphicObject));
831
- const anchor = explicitAnchor ?? resolveHitboxAnchor(
955
+ const anchor = explicitAnchor ?? resolveScaledHitboxAnchor(
832
956
  spriteWidth,
833
957
  spriteHeight,
834
958
  optionValue('spriteRealSize', frame, textureOptions, graphicObject),
835
- box
959
+ box,
960
+ scale
836
961
  );
837
- const scale = normalizePair(optionValue('scale', frame, textureOptions, graphicObject) ?? graphicScale(graphicObject));
838
962
  const x = toFiniteNumber(optionValue('x', frame, textureOptions, graphicObject), 0);
839
963
  const y = toFiniteNumber(optionValue('y', frame, textureOptions, graphicObject), 0);
840
964
  const leftEdge = -anchor[0] * spriteWidth * scale[0];
@@ -1051,6 +1175,8 @@
1051
1175
  removeTransitionSubscription?.unsubscribe();
1052
1176
  animationMovementSubscription.unsubscribe();
1053
1177
  resumeWalkSubscriptions.forEach(subscription => subscription.unsubscribe());
1178
+ activeControlDirectionReleaseTimers.forEach(timer => clearTimeout(timer));
1179
+ activeControlDirectionReleaseTimers.clear();
1054
1180
  xSubscription.unsubscribe();
1055
1181
  ySubscription.unsubscribe();
1056
1182
  await lastValueFrom(hooks.callHooks("client-sprite-onDestroy", sprite))
@@ -1058,6 +1184,7 @@
1058
1184
  }
1059
1185
 
1060
1186
  mount((element) => {
1187
+ let appliedCameraFollowRevision = null;
1061
1188
  if (typeof document !== 'undefined') {
1062
1189
  document.addEventListener('keydown', handleNativeActionWhileMoving);
1063
1190
  document.addEventListener('keyup', handleNativeActionWhileMoving);
@@ -1069,6 +1196,38 @@
1069
1196
  client.setKeyboardControls(element.directives.controls)
1070
1197
  }
1071
1198
  })
1199
+
1200
+ effect(() => {
1201
+ const followRevision = client.cameraFollowRevision();
1202
+ if (!shouldFollowCamera()) {
1203
+ appliedCameraFollowRevision = null;
1204
+ return;
1205
+ }
1206
+
1207
+ const smoothMove = client.cameraFollowSmoothMove;
1208
+ const viewport = element.props.context?.viewport;
1209
+ const target = element.componentInstance;
1210
+ if (!viewport || !target) {
1211
+ appliedCameraFollowRevision = null;
1212
+ return;
1213
+ }
1214
+
1215
+ const appliedCameraFollow = applyCameraFollow({
1216
+ viewport,
1217
+ target,
1218
+ smoothMove,
1219
+ followRevision,
1220
+ isCurrentRevision: (revision) => client.cameraFollowRevision() === revision,
1221
+ shouldFollowCamera
1222
+ });
1223
+ appliedCameraFollowRevision = appliedCameraFollow ? followRevision : null;
1224
+ })
1225
+
1226
+ return () => {
1227
+ if (ownsCameraFollowRevision(appliedCameraFollowRevision, client.cameraFollowRevision())) {
1228
+ clearCameraFollowPlugins(element.props.context?.viewport);
1229
+ }
1230
+ }
1072
1231
  })
1073
1232
 
1074
1233
  /**
@@ -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
+ });
@@ -1,24 +1,92 @@
1
1
  import { inject } from "../../../core/inject";
2
2
  import { RpgClientEngine } from "../../../RpgClientEngine";
3
3
  import MobileGui from "./mobile.ce";
4
- import { signal } from "canvasengine";
4
+ import { computed } from "canvasengine";
5
+ import type { ButtonProps, ComponentFunction, JoystickSettings } from "canvasengine";
6
+
7
+ export type MobileGuiEnabled = "auto" | "always" | "never" | (() => boolean);
8
+ export type MobileGuiJoystickSide = "left" | "right";
9
+ export type MobileGuiMargin = number | [number, number, number, number];
10
+
11
+ export interface MobileGuiLayoutOptions {
12
+ joystickSide?: MobileGuiJoystickSide;
13
+ margin?: MobileGuiMargin;
14
+ buttonsMargin?: MobileGuiMargin;
15
+ joystickMargin?: MobileGuiMargin;
16
+ gap?: number;
17
+ }
18
+
19
+ export interface MobileJoystickComponentProps extends JoystickSettings {
20
+ defaultProps: JoystickSettings;
21
+ }
22
+
23
+ export interface MobileButtonComponentProps extends ButtonProps {
24
+ controlName: "action" | "back" | "dash";
25
+ defaultProps: ButtonProps;
26
+ }
27
+
28
+ export interface MobileGuiComponentsOptions {
29
+ joystick?: ComponentFunction<MobileJoystickComponentProps>;
30
+ buttons?: {
31
+ action?: ComponentFunction<MobileButtonComponentProps>;
32
+ back?: ComponentFunction<MobileButtonComponentProps>;
33
+ dash?: ComponentFunction<MobileButtonComponentProps>;
34
+ };
35
+ }
36
+
37
+ export interface MobileGuiButtonOptions extends Partial<ButtonProps> {
38
+ enabled?: boolean;
39
+ component?: ComponentFunction<MobileButtonComponentProps>;
40
+ container?: Record<string, unknown>;
41
+ }
42
+
43
+ export interface MobileGuiJoystickOptions extends Partial<JoystickSettings> {
44
+ component?: ComponentFunction<MobileJoystickComponentProps>;
45
+ moveInterval?: number;
46
+ threshold?: number;
47
+ }
48
+
49
+ export interface MobileGuiOptions {
50
+ id?: string;
51
+ enabled?: MobileGuiEnabled;
52
+ layout?: MobileGuiLayoutOptions;
53
+ components?: MobileGuiComponentsOptions;
54
+ joystick?: false | MobileGuiJoystickOptions;
55
+ buttons?: {
56
+ action?: boolean | MobileGuiButtonOptions;
57
+ back?: boolean | MobileGuiButtonOptions;
58
+ dash?: boolean | MobileGuiButtonOptions;
59
+ };
60
+ }
5
61
 
6
62
  function isMobile() {
63
+ if (typeof navigator === "undefined") return false;
7
64
  return /Android|iPhone|iPad|iPod|Windows Phone|webOS|BlackBerry/i.test(navigator.userAgent);
8
- }
65
+ }
66
+
67
+ function resolveEnabled(enabled: MobileGuiEnabled = "auto") {
68
+ if (enabled === "always") return true;
69
+ if (enabled === "never") return false;
70
+ if (typeof enabled === "function") return enabled();
71
+ return isMobile();
72
+ }
9
73
 
10
- export const withMobile = () => (
74
+ export const withMobile = (options: MobileGuiOptions = {}) => (
11
75
  {
12
76
  gui: [
13
77
  {
14
- id: 'mobile-gui',
78
+ id: options.id ?? 'mobile-gui',
15
79
  component: MobileGui,
16
80
  autoDisplay: true,
81
+ data: options,
17
82
  dependencies: () => {
18
83
  const engine = inject(RpgClientEngine);
19
- return [signal(isMobile() ||undefined), engine.controlsReady]
84
+ return [
85
+ computed(() => resolveEnabled(options.enabled) || undefined),
86
+ engine.controlsReady
87
+ ]
20
88
  }
21
89
  }
22
90
  ]
23
91
  }
24
- )
92
+ )