@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/CHANGELOG.md +7 -0
- package/dist/Gui/Gui.js +37 -4
- package/dist/Gui/Gui.js.map +1 -1
- package/dist/RpgClientEngine.d.ts +16 -9
- package/dist/RpgClientEngine.js +25 -7
- package/dist/RpgClientEngine.js.map +1 -1
- package/dist/components/character.ce.js +150 -9
- package/dist/components/character.ce.js.map +1 -1
- package/dist/components/gui/mobile/index.d.ts +51 -2
- package/dist/components/gui/mobile/index.js +12 -4
- package/dist/components/gui/mobile/index.js.map +1 -1
- package/dist/components/gui/mobile/index.spec.d.ts +1 -0
- package/dist/components/gui/mobile/mobile.ce.js +303 -55
- package/dist/components/gui/mobile/mobile.ce.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/services/cameraFollow.d.ts +51 -0
- package/dist/services/cameraFollow.js +134 -0
- package/dist/services/cameraFollow.js.map +1 -0
- package/dist/services/cameraFollow.spec.d.ts +1 -0
- package/package.json +5 -5
- package/src/Gui/Gui.spec.ts +19 -0
- package/src/Gui/Gui.ts +50 -11
- package/src/RpgClientEngine.ts +37 -18
- package/src/components/character.ce +167 -9
- package/src/components/gui/mobile/index.spec.ts +94 -0
- package/src/components/gui/mobile/index.ts +74 -6
- package/src/components/gui/mobile/mobile.ce +347 -65
- package/src/index.ts +12 -0
- package/src/services/cameraFollow.spec.ts +220 -0
- package/src/services/cameraFollow.ts +222 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rpgjs/client",
|
|
3
|
-
"version": "5.0.0-beta.
|
|
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.
|
|
21
|
+
"canvasengine": "^2.0.1",
|
|
22
22
|
"pixi.js": "^8.9.2"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@rpgjs/common": "5.0.0-beta.
|
|
26
|
-
"@rpgjs/server": "5.0.0-beta.
|
|
27
|
-
"@rpgjs/ui-css": "5.0.0-beta.
|
|
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",
|
package/src/Gui/Gui.spec.ts
CHANGED
|
@@ -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 (
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
}
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
|
package/src/RpgClientEngine.ts
CHANGED
|
@@ -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(
|
|
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.
|
|
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
|
|
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
|
|
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?:
|
|
1532
|
+
smoothMove?: CameraFollowSmoothMove
|
|
1514
1533
|
): void {
|
|
1515
|
-
|
|
1516
|
-
|
|
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
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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.
|
|
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 =
|
|
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)
|
|
412
|
-
|
|
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())
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
if (
|
|
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
|
+
});
|