@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
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
export const DEFAULT_CAMERA_FOLLOW_TIME = 1000;
|
|
2
|
+
export const DEFAULT_CAMERA_FOLLOW_EASE = "easeInOutSine";
|
|
3
|
+
|
|
4
|
+
export const CAMERA_FOLLOW_EASES = [
|
|
5
|
+
"linear",
|
|
6
|
+
"easeInQuad",
|
|
7
|
+
"easeOutQuad",
|
|
8
|
+
"easeInOutQuad",
|
|
9
|
+
"easeInCubic",
|
|
10
|
+
"easeOutCubic",
|
|
11
|
+
"easeInOutCubic",
|
|
12
|
+
"easeInQuart",
|
|
13
|
+
"easeOutQuart",
|
|
14
|
+
"easeInOutQuart",
|
|
15
|
+
"easeInQuint",
|
|
16
|
+
"easeOutQuint",
|
|
17
|
+
"easeInOutQuint",
|
|
18
|
+
"easeInSine",
|
|
19
|
+
"easeOutSine",
|
|
20
|
+
"easeInOutSine",
|
|
21
|
+
"easeInExpo",
|
|
22
|
+
"easeOutExpo",
|
|
23
|
+
"easeInOutExpo",
|
|
24
|
+
"easeInCirc",
|
|
25
|
+
"easeOutCirc",
|
|
26
|
+
"easeInOutCirc",
|
|
27
|
+
"easeInElastic",
|
|
28
|
+
"easeOutElastic",
|
|
29
|
+
"easeInOutElastic",
|
|
30
|
+
"easeInBack",
|
|
31
|
+
"easeOutBack",
|
|
32
|
+
"easeInOutBack",
|
|
33
|
+
"easeInBounce",
|
|
34
|
+
"easeOutBounce",
|
|
35
|
+
"easeInOutBounce",
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
export type CameraFollowEase = typeof CAMERA_FOLLOW_EASES[number];
|
|
39
|
+
|
|
40
|
+
export type CameraFollowSmoothMoveOptions = {
|
|
41
|
+
/** Enable or disable the smooth transition when options are sent as an object. */
|
|
42
|
+
enabled?: boolean;
|
|
43
|
+
/** Duration of the transition to the new camera target, in milliseconds. */
|
|
44
|
+
time?: number;
|
|
45
|
+
/** pixi-viewport easing name, for example "easeInOutQuad". */
|
|
46
|
+
ease?: CameraFollowEase;
|
|
47
|
+
/** Continuous follow speed after the transition. 0 keeps the target centered instantly. */
|
|
48
|
+
speed?: number;
|
|
49
|
+
/** Continuous follow acceleration after the transition. */
|
|
50
|
+
acceleration?: number | null;
|
|
51
|
+
/** Center radius where the followed target can move without moving the viewport. */
|
|
52
|
+
radius?: number | null;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type CameraFollowSmoothMove = boolean | CameraFollowSmoothMoveOptions;
|
|
56
|
+
|
|
57
|
+
export type CameraFollowTarget = {
|
|
58
|
+
x: number;
|
|
59
|
+
y: number;
|
|
60
|
+
destroyed?: boolean;
|
|
61
|
+
} | null | undefined;
|
|
62
|
+
|
|
63
|
+
export type CameraFollowPosition = {
|
|
64
|
+
x: number;
|
|
65
|
+
y: number;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export interface CameraFollowApplyContext {
|
|
69
|
+
viewport: any;
|
|
70
|
+
target: CameraFollowTarget;
|
|
71
|
+
smoothMove: CameraFollowSmoothMove;
|
|
72
|
+
followRevision: number;
|
|
73
|
+
isCurrentRevision: (revision: number) => boolean;
|
|
74
|
+
shouldFollowCamera: () => boolean;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const finiteNumber = (value: unknown, fallback: number) => {
|
|
78
|
+
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const isCameraFollowEase = (value: unknown): value is CameraFollowEase => {
|
|
82
|
+
return typeof value === "string" && (CAMERA_FOLLOW_EASES as readonly string[]).includes(value);
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const smoothMoveEnabled = (smoothMove: CameraFollowSmoothMove) => {
|
|
86
|
+
if (smoothMove === false) return false;
|
|
87
|
+
if (typeof smoothMove === "object" && smoothMove !== null && smoothMove.enabled === false) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
return true;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const cameraFollowAnimationOptions = (
|
|
94
|
+
smoothMove: CameraFollowSmoothMove
|
|
95
|
+
) => {
|
|
96
|
+
if (!smoothMoveEnabled(smoothMove)) return null;
|
|
97
|
+
const options = typeof smoothMove === "object" && smoothMove !== null ? smoothMove : {};
|
|
98
|
+
return {
|
|
99
|
+
time: Math.max(0, finiteNumber(options.time, DEFAULT_CAMERA_FOLLOW_TIME)),
|
|
100
|
+
ease: isCameraFollowEase(options.ease) ? options.ease : DEFAULT_CAMERA_FOLLOW_EASE,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const cameraFollowOptions = (smoothMove: CameraFollowSmoothMove) => {
|
|
105
|
+
if (typeof smoothMove !== "object" || smoothMove === null) return undefined;
|
|
106
|
+
const options: { speed?: number; acceleration?: number | null; radius?: number | null } = {};
|
|
107
|
+
if (typeof smoothMove.speed === "number" && Number.isFinite(smoothMove.speed)) {
|
|
108
|
+
options.speed = Math.max(0, smoothMove.speed);
|
|
109
|
+
}
|
|
110
|
+
if (typeof smoothMove.acceleration === "number" && Number.isFinite(smoothMove.acceleration)) {
|
|
111
|
+
options.acceleration = Math.max(0, smoothMove.acceleration);
|
|
112
|
+
} else if (smoothMove.acceleration === null) {
|
|
113
|
+
options.acceleration = null;
|
|
114
|
+
}
|
|
115
|
+
if (typeof smoothMove.radius === "number" && Number.isFinite(smoothMove.radius)) {
|
|
116
|
+
options.radius = Math.max(0, smoothMove.radius);
|
|
117
|
+
} else if (smoothMove.radius === null) {
|
|
118
|
+
options.radius = null;
|
|
119
|
+
}
|
|
120
|
+
return Object.keys(options).length > 0 ? options : undefined;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const clearCameraFollowPlugins = (viewport: any) => {
|
|
124
|
+
viewport?.plugins?.remove?.("animate");
|
|
125
|
+
viewport?.plugins?.remove?.("follow");
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const ownsCameraFollowRevision = (
|
|
129
|
+
appliedRevision: number | null,
|
|
130
|
+
currentRevision: number
|
|
131
|
+
) => {
|
|
132
|
+
return appliedRevision !== null && appliedRevision === currentRevision;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const readCameraFollowPosition = (
|
|
136
|
+
target: CameraFollowTarget
|
|
137
|
+
): CameraFollowPosition | null => {
|
|
138
|
+
if (!target) return null;
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
if (target.destroyed) return null;
|
|
142
|
+
const x = target.x;
|
|
143
|
+
const y = target.y;
|
|
144
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
|
|
145
|
+
return { x, y };
|
|
146
|
+
} catch {
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const createCameraFollowTarget = (
|
|
152
|
+
target: CameraFollowTarget,
|
|
153
|
+
initialPosition: CameraFollowPosition
|
|
154
|
+
) => {
|
|
155
|
+
let lastPosition = initialPosition;
|
|
156
|
+
|
|
157
|
+
const readPosition = () => {
|
|
158
|
+
const nextPosition = readCameraFollowPosition(target);
|
|
159
|
+
if (nextPosition) {
|
|
160
|
+
lastPosition = nextPosition;
|
|
161
|
+
}
|
|
162
|
+
return lastPosition;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
get x() {
|
|
167
|
+
return readPosition().x;
|
|
168
|
+
},
|
|
169
|
+
get y() {
|
|
170
|
+
return readPosition().y;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export const followCameraInstantly = (
|
|
176
|
+
viewport: any,
|
|
177
|
+
target: CameraFollowTarget,
|
|
178
|
+
smoothMove: CameraFollowSmoothMove
|
|
179
|
+
) => {
|
|
180
|
+
const position = readCameraFollowPosition(target);
|
|
181
|
+
if (!position) return false;
|
|
182
|
+
|
|
183
|
+
const followTarget = createCameraFollowTarget(target, position);
|
|
184
|
+
const followOptions = cameraFollowOptions(smoothMove);
|
|
185
|
+
if (followOptions) {
|
|
186
|
+
viewport.follow(followTarget, followOptions);
|
|
187
|
+
} else {
|
|
188
|
+
viewport.follow(followTarget);
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
export const applyCameraFollow = ({
|
|
194
|
+
viewport,
|
|
195
|
+
target,
|
|
196
|
+
smoothMove,
|
|
197
|
+
followRevision,
|
|
198
|
+
isCurrentRevision,
|
|
199
|
+
shouldFollowCamera,
|
|
200
|
+
}: CameraFollowApplyContext) => {
|
|
201
|
+
clearCameraFollowPlugins(viewport);
|
|
202
|
+
|
|
203
|
+
const position = readCameraFollowPosition(target);
|
|
204
|
+
if (!position) return false;
|
|
205
|
+
|
|
206
|
+
const animationOptions = cameraFollowAnimationOptions(smoothMove);
|
|
207
|
+
if (!animationOptions || animationOptions.time <= 0) {
|
|
208
|
+
return followCameraInstantly(viewport, target, smoothMove);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
viewport.animate({
|
|
212
|
+
position,
|
|
213
|
+
time: animationOptions.time,
|
|
214
|
+
ease: animationOptions.ease,
|
|
215
|
+
callbackOnComplete: () => {
|
|
216
|
+
if (!isCurrentRevision(followRevision) || !shouldFollowCamera()) return;
|
|
217
|
+
if (!readCameraFollowPosition(target)) return;
|
|
218
|
+
followCameraInstantly(viewport, target, smoothMove);
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
return true;
|
|
222
|
+
};
|
|
@@ -51,4 +51,51 @@ describe("standalone websocket bridge", () => {
|
|
|
51
51
|
expect(standaloneProvider.useFactory(context).mode).toBe("standalone");
|
|
52
52
|
expect(mmorpgProvider.useFactory(context).mode).toBe("mmorpg");
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
test("reconnects standalone rooms with updated room and query", async () => {
|
|
56
|
+
const connects: Array<{ roomId: string; url: string; sessionId: string }> = [];
|
|
57
|
+
class Server {
|
|
58
|
+
subRoom = {
|
|
59
|
+
onStart: vi.fn(),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
constructor(public room: any) {}
|
|
63
|
+
|
|
64
|
+
async onStart() {}
|
|
65
|
+
|
|
66
|
+
async onConnect(conn: any, ctx: any) {
|
|
67
|
+
connects.push({
|
|
68
|
+
roomId: this.room.id,
|
|
69
|
+
url: ctx.request.url,
|
|
70
|
+
sessionId: conn.sessionId,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const context = new Context();
|
|
76
|
+
const standaloneProvider = provideRpg(Server).find(
|
|
77
|
+
(provider: any) => provider.provide === WebSocketToken,
|
|
78
|
+
) as any;
|
|
79
|
+
const socket = standaloneProvider.useFactory(context);
|
|
80
|
+
|
|
81
|
+
await socket.connection();
|
|
82
|
+
socket.updateProperties({
|
|
83
|
+
room: "map-center-map",
|
|
84
|
+
query: { transferToken: "token-1" },
|
|
85
|
+
});
|
|
86
|
+
await socket.reconnect();
|
|
87
|
+
|
|
88
|
+
expect(connects).toEqual([
|
|
89
|
+
expect.objectContaining({
|
|
90
|
+
roomId: "lobby-1",
|
|
91
|
+
sessionId: "player-client-id",
|
|
92
|
+
}),
|
|
93
|
+
expect.objectContaining({
|
|
94
|
+
roomId: "map-center-map",
|
|
95
|
+
sessionId: "player-client-id",
|
|
96
|
+
}),
|
|
97
|
+
]);
|
|
98
|
+
expect(connects[1].url).toContain("transferToken=token-1");
|
|
99
|
+
expect(socket.getServer().room.id).toBe("map-center-map");
|
|
100
|
+
});
|
|
54
101
|
});
|
|
@@ -8,6 +8,7 @@ import { RpgGui } from "../Gui/Gui";
|
|
|
8
8
|
import { provideKeyboardControls } from "./keyboardControls";
|
|
9
9
|
import { provideSaveClient } from "./save";
|
|
10
10
|
import { normalizeStandaloneMessage } from "./standalone-message";
|
|
11
|
+
import type { SocketQuery } from "./AbstractSocket";
|
|
11
12
|
|
|
12
13
|
type ServerIo = any;
|
|
13
14
|
type ClientIo = any;
|
|
@@ -22,6 +23,9 @@ class BridgeWebsocket extends AbstractWebsocket {
|
|
|
22
23
|
private room: ServerIo;
|
|
23
24
|
private socket: ClientIo;
|
|
24
25
|
private socketRoom?: ServerIo;
|
|
26
|
+
private roomId = "lobby-1";
|
|
27
|
+
private query?: SocketQuery;
|
|
28
|
+
private readonly privateId = "player-client-id";
|
|
25
29
|
private listeners: Array<{
|
|
26
30
|
event: string;
|
|
27
31
|
callback: (data: any) => void;
|
|
@@ -29,11 +33,10 @@ class BridgeWebsocket extends AbstractWebsocket {
|
|
|
29
33
|
}> = [];
|
|
30
34
|
private rooms = {
|
|
31
35
|
partyFn: async (roomId: string) => {
|
|
32
|
-
|
|
33
|
-
const server = new this.server(
|
|
36
|
+
const room = new ServerIo(roomId, this.rooms);
|
|
37
|
+
const server = new this.server(room)
|
|
34
38
|
await server.onStart();
|
|
35
39
|
await server.subRoom.onStart()
|
|
36
|
-
this.context.set('server', server)
|
|
37
40
|
return server
|
|
38
41
|
},
|
|
39
42
|
env: {}
|
|
@@ -57,22 +60,8 @@ class BridgeWebsocket extends AbstractWebsocket {
|
|
|
57
60
|
|
|
58
61
|
private async _connection(listeners?: (data: any) => void) {
|
|
59
62
|
this.detachCurrentSocket();
|
|
60
|
-
|
|
61
|
-
this.socket = new ClientIo(this.serverInstance, 'player-client-id');
|
|
62
|
-
const url = new URL('http://localhost')
|
|
63
|
-
const request = new Request(url.toString(), {
|
|
64
|
-
method: 'GET',
|
|
65
|
-
headers: {
|
|
66
|
-
'Content-Type': 'application/json'
|
|
67
|
-
}
|
|
68
|
-
})
|
|
63
|
+
await this.connectToRoom();
|
|
69
64
|
listeners?.(this.socket)
|
|
70
|
-
this.room.clients.set(this.socket.id, this.socket);
|
|
71
|
-
this.socketRoom = this.room;
|
|
72
|
-
this.listeners.forEach(({ handler }) => {
|
|
73
|
-
this.socket.addEventListener("message", handler);
|
|
74
|
-
});
|
|
75
|
-
await this.serverInstance.onConnect(this.socket.conn as any, { request } as any);
|
|
76
65
|
return this.socket
|
|
77
66
|
}
|
|
78
67
|
|
|
@@ -129,8 +118,9 @@ class BridgeWebsocket extends AbstractWebsocket {
|
|
|
129
118
|
* await websocket.reconnect()
|
|
130
119
|
* ```
|
|
131
120
|
*/
|
|
132
|
-
updateProperties(
|
|
133
|
-
|
|
121
|
+
updateProperties({ room, query }: SocketUpdateProperties) {
|
|
122
|
+
this.roomId = room;
|
|
123
|
+
this.query = query;
|
|
134
124
|
}
|
|
135
125
|
|
|
136
126
|
/**
|
|
@@ -156,6 +146,49 @@ class BridgeWebsocket extends AbstractWebsocket {
|
|
|
156
146
|
})
|
|
157
147
|
}
|
|
158
148
|
|
|
149
|
+
private async connectToRoom() {
|
|
150
|
+
if (this.serverInstance?.room?.id !== this.roomId) {
|
|
151
|
+
const party = await this.room.context.parties.main.get(this.roomId);
|
|
152
|
+
this.serverInstance = party.server;
|
|
153
|
+
this.context.set('server', this.serverInstance);
|
|
154
|
+
this.room = this.serverInstance.room;
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
this.serverInstance = this.context.get('server');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
this.socket = new ClientIo(this.serverInstance, this.privateId);
|
|
161
|
+
const url = new URL('http://localhost');
|
|
162
|
+
const query = this.normalizeQuery(this.query);
|
|
163
|
+
if (query) {
|
|
164
|
+
for (const [key, value] of Object.entries(query)) {
|
|
165
|
+
url.searchParams.set(key, value);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
const request = new Request(url.toString(), {
|
|
169
|
+
method: 'GET',
|
|
170
|
+
headers: {
|
|
171
|
+
'Content-Type': 'application/json'
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
this.room.clients.set(this.socket.id, this.socket);
|
|
175
|
+
this.socketRoom = this.room;
|
|
176
|
+
this.listeners.forEach(({ handler }) => {
|
|
177
|
+
this.socket.addEventListener("message", handler);
|
|
178
|
+
});
|
|
179
|
+
await this.serverInstance.onConnect(this.socket.conn as any, { request } as any);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private normalizeQuery(query?: SocketQuery): Record<string, string> | undefined {
|
|
183
|
+
if (!query) {
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
return Object.fromEntries(
|
|
187
|
+
Object.entries(query)
|
|
188
|
+
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
159
192
|
private detachCurrentSocket() {
|
|
160
193
|
if (!this.socket) return;
|
|
161
194
|
this.listeners.forEach(({ handler }) => {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { signal } from "canvasengine";
|
|
2
|
+
import { describe, expect, test, vi } from "vitest";
|
|
3
|
+
import { load } from "@signe/sync";
|
|
4
|
+
import { applySyncedHitboxPayload } from "./syncHitbox";
|
|
5
|
+
|
|
6
|
+
const createObject = (id: string) => ({
|
|
7
|
+
id,
|
|
8
|
+
x: signal(10),
|
|
9
|
+
y: signal(20),
|
|
10
|
+
hitbox: signal({ w: 32, h: 32 }),
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("applySyncedHitboxPayload", () => {
|
|
14
|
+
test("applies player hitboxes from sync object payloads", () => {
|
|
15
|
+
const player = createObject("player-1");
|
|
16
|
+
const sceneMap = {
|
|
17
|
+
players: () => ({ "player-1": player }),
|
|
18
|
+
events: () => ({}),
|
|
19
|
+
getObjectById: (id: string) => (id === "player-1" ? player : undefined),
|
|
20
|
+
updateHitbox: vi.fn(),
|
|
21
|
+
};
|
|
22
|
+
const payload = {
|
|
23
|
+
players: {
|
|
24
|
+
"player-1": {
|
|
25
|
+
hitbox: { w: 56, h: 50 },
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
load(sceneMap, payload, true);
|
|
31
|
+
expect(player.hitbox()).toEqual({ w: 32, h: 32 });
|
|
32
|
+
|
|
33
|
+
applySyncedHitboxPayload(sceneMap, payload);
|
|
34
|
+
|
|
35
|
+
expect(player.hitbox()).toEqual({ w: 56, h: 50 });
|
|
36
|
+
expect(sceneMap.updateHitbox).toHaveBeenCalledWith("player-1", 10, 20, 56, 50);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("accepts Studio width and height hitbox payloads for events", () => {
|
|
40
|
+
const event = createObject("event-1");
|
|
41
|
+
const sceneMap = {
|
|
42
|
+
players: () => ({}),
|
|
43
|
+
events: () => ({ "event-1": event }),
|
|
44
|
+
getObjectById: (id: string) => (id === "event-1" ? event : undefined),
|
|
45
|
+
updateHitbox: vi.fn(),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
applySyncedHitboxPayload(sceneMap, {
|
|
49
|
+
events: {
|
|
50
|
+
"event-1": {
|
|
51
|
+
hitbox: { width: "80", height: 40 },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(event.hitbox()).toEqual({ w: 80, h: 40 });
|
|
57
|
+
expect(sceneMap.updateHitbox).toHaveBeenCalledWith("event-1", 10, 20, 80, 40);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("ignores invalid hitbox payloads", () => {
|
|
61
|
+
const player = createObject("player-1");
|
|
62
|
+
const sceneMap = {
|
|
63
|
+
players: () => ({ "player-1": player }),
|
|
64
|
+
events: () => ({}),
|
|
65
|
+
updateHitbox: vi.fn(),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
applySyncedHitboxPayload(sceneMap, {
|
|
69
|
+
players: {
|
|
70
|
+
"player-1": {
|
|
71
|
+
hitbox: { w: 0, h: 40 },
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(player.hitbox()).toEqual({ w: 32, h: 32 });
|
|
77
|
+
expect(sceneMap.updateHitbox).not.toHaveBeenCalled();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
interface HitboxSize {
|
|
2
|
+
width: number;
|
|
3
|
+
height: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const toPositiveNumber = (value: unknown): number | null => {
|
|
7
|
+
const numberValue = typeof value === "string" ? Number(value) : value;
|
|
8
|
+
return typeof numberValue === "number" && Number.isFinite(numberValue) && numberValue > 0
|
|
9
|
+
? Math.round(numberValue)
|
|
10
|
+
: null;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const readSignalValue = (value: any): any => {
|
|
14
|
+
if (typeof value === "function") {
|
|
15
|
+
try {
|
|
16
|
+
return value();
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return value;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const normalizeHitbox = (source: unknown): HitboxSize | null => {
|
|
26
|
+
if (!source || typeof source !== "object") {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const hitbox = source as any;
|
|
31
|
+
const width = toPositiveNumber(hitbox.w ?? hitbox.width);
|
|
32
|
+
const height = toPositiveNumber(hitbox.h ?? hitbox.height);
|
|
33
|
+
|
|
34
|
+
return width && height ? { width, height } : null;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const readObjectHitbox = (object: any): HitboxSize | null => {
|
|
38
|
+
return normalizeHitbox(readSignalValue(object?.hitbox));
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const readCoordinate = (object: any, key: "x" | "y"): number | null => {
|
|
42
|
+
const value = readSignalValue(object?.[key]);
|
|
43
|
+
const numberValue = typeof value === "string" ? Number(value) : value;
|
|
44
|
+
return typeof numberValue === "number" && Number.isFinite(numberValue)
|
|
45
|
+
? numberValue
|
|
46
|
+
: null;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const getObjectById = (sceneMap: any, id: string): any => {
|
|
50
|
+
return sceneMap?.getObjectById?.(id)
|
|
51
|
+
?? sceneMap?.players?.()?.[id]
|
|
52
|
+
?? sceneMap?.events?.()?.[id];
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const applyHitboxToObject = (
|
|
56
|
+
sceneMap: any,
|
|
57
|
+
id: string,
|
|
58
|
+
object: any,
|
|
59
|
+
hitbox: HitboxSize,
|
|
60
|
+
): void => {
|
|
61
|
+
if (!object) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const current = readObjectHitbox(object);
|
|
66
|
+
if (current?.width !== hitbox.width || current?.height !== hitbox.height) {
|
|
67
|
+
if (typeof object.hitbox?.set === "function") {
|
|
68
|
+
object.hitbox.set({ w: hitbox.width, h: hitbox.height });
|
|
69
|
+
}
|
|
70
|
+
else if (typeof object.setHitbox === "function") {
|
|
71
|
+
object.setHitbox(hitbox.width, hitbox.height);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const objectId = typeof object.id === "string" && object.id ? object.id : id;
|
|
77
|
+
const x = readCoordinate(object, "x");
|
|
78
|
+
const y = readCoordinate(object, "y");
|
|
79
|
+
if (objectId && x !== null && y !== null) {
|
|
80
|
+
sceneMap?.updateHitbox?.(objectId, x, y, hitbox.width, hitbox.height);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const applyHitboxCollection = (sceneMap: any, collection: unknown): void => {
|
|
85
|
+
if (!collection || typeof collection !== "object") {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
for (const [id, patch] of Object.entries(collection)) {
|
|
90
|
+
if (!patch || typeof patch !== "object") {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
const hitbox = normalizeHitbox((patch as any).hitbox);
|
|
94
|
+
if (!hitbox) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
applyHitboxToObject(sceneMap, id, getObjectById(sceneMap, id), hitbox);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const applySyncedHitboxPayload = (sceneMap: any, payload: any): void => {
|
|
102
|
+
applyHitboxCollection(sceneMap, payload?.players);
|
|
103
|
+
applyHitboxCollection(sceneMap, payload?.events);
|
|
104
|
+
};
|