@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.
- package/CHANGELOG.md +16 -0
- package/dist/Game/Object.d.ts +1 -0
- package/dist/Game/Object.js +10 -0
- package/dist/Game/Object.js.map +1 -1
- package/dist/Gui/Gui.js +37 -4
- package/dist/Gui/Gui.js.map +1 -1
- package/dist/RpgClientEngine.d.ts +18 -9
- package/dist/RpgClientEngine.js +40 -13
- package/dist/RpgClientEngine.js.map +1 -1
- package/dist/components/character-hitbox.d.ts +13 -0
- package/dist/components/character-hitbox.js +38 -0
- package/dist/components/character-hitbox.js.map +1 -0
- package/dist/components/character-hitbox.spec.d.ts +1 -0
- package/dist/components/character.ce.js +179 -36
- 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/dist/services/standalone.d.ts +6 -1
- package/dist/services/standalone.js +35 -17
- package/dist/services/standalone.js.map +1 -1
- package/dist/utils/syncHitbox.d.ts +1 -0
- package/dist/utils/syncHitbox.js +69 -0
- package/dist/utils/syncHitbox.js.map +1 -0
- package/dist/utils/syncHitbox.spec.d.ts +1 -0
- package/package.json +5 -5
- package/src/Game/Object.spec.ts +44 -4
- package/src/Game/Object.ts +19 -0
- package/src/Gui/Gui.spec.ts +19 -0
- package/src/Gui/Gui.ts +50 -11
- package/src/RpgClientEngine.ts +56 -24
- package/src/components/character-hitbox.spec.ts +33 -0
- package/src/components/character-hitbox.ts +72 -0
- package/src/components/character.ce +207 -48
- 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/src/services/standalone.spec.ts +47 -0
- package/src/services/standalone.ts +53 -20
- package/src/utils/syncHitbox.spec.ts +79 -0
- package/src/utils/syncHitbox.ts +104 -0
package/src/RpgClientEngine.ts
CHANGED
|
@@ -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(
|
|
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.
|
|
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
|
|
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
|
|
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?:
|
|
1534
|
+
smoothMove?: CameraFollowSmoothMove
|
|
1514
1535
|
): void {
|
|
1515
|
-
|
|
1516
|
-
|
|
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
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
};
|