@rpgjs/client 5.0.0-beta.22 → 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 +9 -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/RpgClientEngine.d.ts +2 -0
- package/dist/RpgClientEngine.js +15 -6
- 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 +29 -27
- package/dist/components/character.ce.js.map +1 -1
- 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 +3 -3
- package/src/Game/Object.spec.ts +44 -4
- package/src/Game/Object.ts +19 -0
- package/src/RpgClientEngine.ts +19 -6
- package/src/components/character-hitbox.spec.ts +33 -0
- package/src/components/character-hitbox.ts +72 -0
- package/src/components/character.ce +40 -39
- 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
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"standalone.js","names":[],"sources":["../../src/services/standalone.ts"],"sourcesContent":["import { AbstractWebsocket, SocketUpdateProperties, WebSocketToken } from \"./AbstractSocket\";\nimport { ClientIo, ServerIo } from \"@signe/room\";\nimport { Context } from \"@signe/di\";\nimport { RpgClientEngine } from \"../RpgClientEngine\";\nimport { UpdateMapService, UpdateMapToken } from \"@rpgjs/common\";\nimport { LoadMapToken } from \"./loadMap\";\nimport { RpgGui } from \"../Gui/Gui\";\nimport { provideKeyboardControls } from \"./keyboardControls\";\nimport { provideSaveClient } from \"./save\";\nimport { normalizeStandaloneMessage } from \"./standalone-message\";\n\ntype ServerIo = any;\ntype ClientIo = any;\n\ninterface StandaloneOptions {\n env?: Record<string, any>;\n}\n\nclass BridgeWebsocket extends AbstractWebsocket {\n readonly mode = \"standalone\" as const;\n\n private room: ServerIo;\n private socket: ClientIo;\n private socketRoom?: ServerIo;\n private listeners: Array<{\n event: string;\n callback: (data: any) => void;\n handler: (event: any) => void;\n }> = [];\n private rooms = {\n partyFn: async (roomId: string) => {\n this.room = new ServerIo(roomId, this.rooms);\n const server = new this.server(this.room)\n await server.onStart();\n await server.subRoom.onStart()\n this.context.set('server', server)\n return server\n },\n env: {}\n }\n private serverInstance: any;\n\n constructor(protected context: Context, private server: any, options: StandaloneOptions = {}) {\n super(context);\n // fake room\n this.rooms.env = options.env || {};\n this.room = new ServerIo(\"lobby-1\", this.rooms);\n }\n\n async connection(listeners?: (data: any) => void) {\n this.serverInstance = new this.server(this.room);\n await this.serverInstance.onStart();\n await this.serverInstance.subRoom.onStart()\n this.context.set('server', this.serverInstance)\n return this._connection(listeners)\n }\n\n private async _connection(listeners?: (data: any) => void) {\n this.detachCurrentSocket();\n this.serverInstance = this.context.get('server')\n this.socket = new ClientIo(this.serverInstance, 'player-client-id');\n const url = new URL('http://localhost')\n const request = new Request(url.toString(), {\n method: 'GET',\n headers: {\n 'Content-Type': 'application/json'\n }\n })\n listeners?.(this.socket)\n this.room.clients.set(this.socket.id, this.socket);\n this.socketRoom = this.room;\n this.listeners.forEach(({ handler }) => {\n this.socket.addEventListener(\"message\", handler);\n });\n await this.serverInstance.onConnect(this.socket.conn as any, { request } as any);\n return this.socket\n }\n\n on(key: string, callback: (data: any) => void) {\n if (\n this.listeners.some(\n (listener) => listener.event === key && listener.callback === callback\n )\n ) {\n return;\n }\n const handler = (event) => {\n const object = normalizeStandaloneMessage(event);\n if (object.type === key) {\n callback(object.value);\n }\n };\n this.listeners.push({ event: key, callback, handler });\n this.socket?.addEventListener(\"message\", handler);\n }\n\n off(event: string, callback: (data: any) => void) {\n const remaining: typeof this.listeners = [];\n for (const listener of this.listeners) {\n if (listener.event === event && listener.callback === callback) {\n this.socket?.removeEventListener(\"message\", listener.handler);\n continue;\n }\n remaining.push(listener);\n }\n this.listeners = remaining;\n }\n\n emit(event: string, data: any) {\n this.socket.send({\n action: event,\n value: data,\n });\n }\n\n /**\n * Update underlying connection properties before a reconnect\n *\n * Design\n * - Dynamically register a factory for the requested room to ensure a fresh server instance\n * - Swap the internal ServerIo to target the new room\n *\n * @param params - Properties to update\n * @param params.room - The target room id (e.g. `map-simplemap2`)\n *\n * @example\n * ```ts\n * websocket.updateProperties({ room: 'map-simplemap2' })\n * await websocket.reconnect()\n * ```\n */\n updateProperties(_params: SocketUpdateProperties) {\n // empty\n }\n\n /**\n * Reconnect the client to the current Party room\n *\n * Design\n * - Must be called after `updateProperties()` when switching rooms\n * - Rebuilds the client <-> server bridge and re-triggers connection listeners\n *\n * @param listeners - Optional callback to re-bind event handlers on the new socket\n *\n * @example\n * ```ts\n * websocket.updateProperties({ room: 'map-dungeon' })\n * await websocket.reconnect((socket) => {\n * // re-bind events here\n * })\n * ```\n */\n async reconnect(listeners?: (data: any) => void): Promise<void> {\n await this._connection((socket) => {\n listeners?.(socket)\n })\n }\n\n private detachCurrentSocket() {\n if (!this.socket) return;\n this.listeners.forEach(({ handler }) => {\n this.socket.removeEventListener(\"message\", handler);\n });\n this.socketRoom?.clients?.delete?.(this.socket.id);\n this.socket = undefined as any;\n this.socketRoom = undefined;\n }\n\n getServer() {\n return this.serverInstance\n }\n\n getSocket() {\n return this.socket\n }\n}\n\nclass UpdateMapStandaloneService extends UpdateMapService {\n private server: any;\n\n /**\n * Update the current room map data on the server side\n *\n * Design\n * - Uses the in-memory server instance stored in context (standalone mode)\n * - Builds a local HTTP-like request to the current Party room endpoint\n *\n * @param map - The map payload to apply on the server\n *\n * @example\n * ```ts\n * await updateMapService.update({ width: 1024, height: 768, events: [] })\n * ```\n */\n async update(map: any) {\n this.server = this.context.get('server')\n const roomId = this.server?.room?.id ?? 'lobby-1'\n const req = {\n url: `http://localhost/parties/main/${roomId}/map/update`,\n method: 'POST',\n headers: new Headers({}),\n json: async () => {\n return map;\n }\n };\n await this.server.onRequest(req)\n }\n}\n\nexport function provideRpg(server: any, options: StandaloneOptions = {}) {\n return [\n {\n provide: WebSocketToken,\n useFactory: (context: Context) => new BridgeWebsocket(context, server, options),\n },\n {\n provide: UpdateMapToken,\n useClass: UpdateMapStandaloneService,\n },\n provideKeyboardControls(),\n provideSaveClient(),\n RpgGui,\n RpgClientEngine,\n ];\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"standalone.js","names":[],"sources":["../../src/services/standalone.ts"],"sourcesContent":["import { AbstractWebsocket, SocketUpdateProperties, WebSocketToken } from \"./AbstractSocket\";\nimport { ClientIo, ServerIo } from \"@signe/room\";\nimport { Context } from \"@signe/di\";\nimport { RpgClientEngine } from \"../RpgClientEngine\";\nimport { UpdateMapService, UpdateMapToken } from \"@rpgjs/common\";\nimport { LoadMapToken } from \"./loadMap\";\nimport { RpgGui } from \"../Gui/Gui\";\nimport { provideKeyboardControls } from \"./keyboardControls\";\nimport { provideSaveClient } from \"./save\";\nimport { normalizeStandaloneMessage } from \"./standalone-message\";\nimport type { SocketQuery } from \"./AbstractSocket\";\n\ntype ServerIo = any;\ntype ClientIo = any;\n\ninterface StandaloneOptions {\n env?: Record<string, any>;\n}\n\nclass BridgeWebsocket extends AbstractWebsocket {\n readonly mode = \"standalone\" as const;\n\n private room: ServerIo;\n private socket: ClientIo;\n private socketRoom?: ServerIo;\n private roomId = \"lobby-1\";\n private query?: SocketQuery;\n private readonly privateId = \"player-client-id\";\n private listeners: Array<{\n event: string;\n callback: (data: any) => void;\n handler: (event: any) => void;\n }> = [];\n private rooms = {\n partyFn: async (roomId: string) => {\n const room = new ServerIo(roomId, this.rooms);\n const server = new this.server(room)\n await server.onStart();\n await server.subRoom.onStart()\n return server\n },\n env: {}\n }\n private serverInstance: any;\n\n constructor(protected context: Context, private server: any, options: StandaloneOptions = {}) {\n super(context);\n // fake room\n this.rooms.env = options.env || {};\n this.room = new ServerIo(\"lobby-1\", this.rooms);\n }\n\n async connection(listeners?: (data: any) => void) {\n this.serverInstance = new this.server(this.room);\n await this.serverInstance.onStart();\n await this.serverInstance.subRoom.onStart()\n this.context.set('server', this.serverInstance)\n return this._connection(listeners)\n }\n\n private async _connection(listeners?: (data: any) => void) {\n this.detachCurrentSocket();\n await this.connectToRoom();\n listeners?.(this.socket)\n return this.socket\n }\n\n on(key: string, callback: (data: any) => void) {\n if (\n this.listeners.some(\n (listener) => listener.event === key && listener.callback === callback\n )\n ) {\n return;\n }\n const handler = (event) => {\n const object = normalizeStandaloneMessage(event);\n if (object.type === key) {\n callback(object.value);\n }\n };\n this.listeners.push({ event: key, callback, handler });\n this.socket?.addEventListener(\"message\", handler);\n }\n\n off(event: string, callback: (data: any) => void) {\n const remaining: typeof this.listeners = [];\n for (const listener of this.listeners) {\n if (listener.event === event && listener.callback === callback) {\n this.socket?.removeEventListener(\"message\", listener.handler);\n continue;\n }\n remaining.push(listener);\n }\n this.listeners = remaining;\n }\n\n emit(event: string, data: any) {\n this.socket.send({\n action: event,\n value: data,\n });\n }\n\n /**\n * Update underlying connection properties before a reconnect\n *\n * Design\n * - Dynamically register a factory for the requested room to ensure a fresh server instance\n * - Swap the internal ServerIo to target the new room\n *\n * @param params - Properties to update\n * @param params.room - The target room id (e.g. `map-simplemap2`)\n *\n * @example\n * ```ts\n * websocket.updateProperties({ room: 'map-simplemap2' })\n * await websocket.reconnect()\n * ```\n */\n updateProperties({ room, query }: SocketUpdateProperties) {\n this.roomId = room;\n this.query = query;\n }\n\n /**\n * Reconnect the client to the current Party room\n *\n * Design\n * - Must be called after `updateProperties()` when switching rooms\n * - Rebuilds the client <-> server bridge and re-triggers connection listeners\n *\n * @param listeners - Optional callback to re-bind event handlers on the new socket\n *\n * @example\n * ```ts\n * websocket.updateProperties({ room: 'map-dungeon' })\n * await websocket.reconnect((socket) => {\n * // re-bind events here\n * })\n * ```\n */\n async reconnect(listeners?: (data: any) => void): Promise<void> {\n await this._connection((socket) => {\n listeners?.(socket)\n })\n }\n\n private async connectToRoom() {\n if (this.serverInstance?.room?.id !== this.roomId) {\n const party = await this.room.context.parties.main.get(this.roomId);\n this.serverInstance = party.server;\n this.context.set('server', this.serverInstance);\n this.room = this.serverInstance.room;\n }\n else {\n this.serverInstance = this.context.get('server');\n }\n\n this.socket = new ClientIo(this.serverInstance, this.privateId);\n const url = new URL('http://localhost');\n const query = this.normalizeQuery(this.query);\n if (query) {\n for (const [key, value] of Object.entries(query)) {\n url.searchParams.set(key, value);\n }\n }\n const request = new Request(url.toString(), {\n method: 'GET',\n headers: {\n 'Content-Type': 'application/json'\n }\n });\n this.room.clients.set(this.socket.id, this.socket);\n this.socketRoom = this.room;\n this.listeners.forEach(({ handler }) => {\n this.socket.addEventListener(\"message\", handler);\n });\n await this.serverInstance.onConnect(this.socket.conn as any, { request } as any);\n }\n\n private normalizeQuery(query?: SocketQuery): Record<string, string> | undefined {\n if (!query) {\n return undefined;\n }\n return Object.fromEntries(\n Object.entries(query)\n .filter((entry): entry is [string, string] => typeof entry[1] === \"string\")\n );\n }\n\n private detachCurrentSocket() {\n if (!this.socket) return;\n this.listeners.forEach(({ handler }) => {\n this.socket.removeEventListener(\"message\", handler);\n });\n this.socketRoom?.clients?.delete?.(this.socket.id);\n this.socket = undefined as any;\n this.socketRoom = undefined;\n }\n\n getServer() {\n return this.serverInstance\n }\n\n getSocket() {\n return this.socket\n }\n}\n\nclass UpdateMapStandaloneService extends UpdateMapService {\n private server: any;\n\n /**\n * Update the current room map data on the server side\n *\n * Design\n * - Uses the in-memory server instance stored in context (standalone mode)\n * - Builds a local HTTP-like request to the current Party room endpoint\n *\n * @param map - The map payload to apply on the server\n *\n * @example\n * ```ts\n * await updateMapService.update({ width: 1024, height: 768, events: [] })\n * ```\n */\n async update(map: any) {\n this.server = this.context.get('server')\n const roomId = this.server?.room?.id ?? 'lobby-1'\n const req = {\n url: `http://localhost/parties/main/${roomId}/map/update`,\n method: 'POST',\n headers: new Headers({}),\n json: async () => {\n return map;\n }\n };\n await this.server.onRequest(req)\n }\n}\n\nexport function provideRpg(server: any, options: StandaloneOptions = {}) {\n return [\n {\n provide: WebSocketToken,\n useFactory: (context: Context) => new BridgeWebsocket(context, server, options),\n },\n {\n provide: UpdateMapToken,\n useClass: UpdateMapStandaloneService,\n },\n provideKeyboardControls(),\n provideSaveClient(),\n RpgGui,\n RpgClientEngine,\n ];\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const applySyncedHitboxPayload: (sceneMap: any, payload: any) => void;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
//#region src/utils/syncHitbox.ts
|
|
2
|
+
var toPositiveNumber = (value) => {
|
|
3
|
+
const numberValue = typeof value === "string" ? Number(value) : value;
|
|
4
|
+
return typeof numberValue === "number" && Number.isFinite(numberValue) && numberValue > 0 ? Math.round(numberValue) : null;
|
|
5
|
+
};
|
|
6
|
+
var readSignalValue = (value) => {
|
|
7
|
+
if (typeof value === "function") try {
|
|
8
|
+
return value();
|
|
9
|
+
} catch {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
return value;
|
|
13
|
+
};
|
|
14
|
+
var normalizeHitbox = (source) => {
|
|
15
|
+
if (!source || typeof source !== "object") return null;
|
|
16
|
+
const hitbox = source;
|
|
17
|
+
const width = toPositiveNumber(hitbox.w ?? hitbox.width);
|
|
18
|
+
const height = toPositiveNumber(hitbox.h ?? hitbox.height);
|
|
19
|
+
return width && height ? {
|
|
20
|
+
width,
|
|
21
|
+
height
|
|
22
|
+
} : null;
|
|
23
|
+
};
|
|
24
|
+
var readObjectHitbox = (object) => {
|
|
25
|
+
return normalizeHitbox(readSignalValue(object?.hitbox));
|
|
26
|
+
};
|
|
27
|
+
var readCoordinate = (object, key) => {
|
|
28
|
+
const value = readSignalValue(object?.[key]);
|
|
29
|
+
const numberValue = typeof value === "string" ? Number(value) : value;
|
|
30
|
+
return typeof numberValue === "number" && Number.isFinite(numberValue) ? numberValue : null;
|
|
31
|
+
};
|
|
32
|
+
var getObjectById = (sceneMap, id) => {
|
|
33
|
+
return sceneMap?.getObjectById?.(id) ?? sceneMap?.players?.()?.[id] ?? sceneMap?.events?.()?.[id];
|
|
34
|
+
};
|
|
35
|
+
var applyHitboxToObject = (sceneMap, id, object, hitbox) => {
|
|
36
|
+
if (!object) return;
|
|
37
|
+
const current = readObjectHitbox(object);
|
|
38
|
+
if (current?.width !== hitbox.width || current?.height !== hitbox.height) {
|
|
39
|
+
if (typeof object.hitbox?.set === "function") object.hitbox.set({
|
|
40
|
+
w: hitbox.width,
|
|
41
|
+
h: hitbox.height
|
|
42
|
+
});
|
|
43
|
+
else if (typeof object.setHitbox === "function") {
|
|
44
|
+
object.setHitbox(hitbox.width, hitbox.height);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
const objectId = typeof object.id === "string" && object.id ? object.id : id;
|
|
49
|
+
const x = readCoordinate(object, "x");
|
|
50
|
+
const y = readCoordinate(object, "y");
|
|
51
|
+
if (objectId && x !== null && y !== null) sceneMap?.updateHitbox?.(objectId, x, y, hitbox.width, hitbox.height);
|
|
52
|
+
};
|
|
53
|
+
var applyHitboxCollection = (sceneMap, collection) => {
|
|
54
|
+
if (!collection || typeof collection !== "object") return;
|
|
55
|
+
for (const [id, patch] of Object.entries(collection)) {
|
|
56
|
+
if (!patch || typeof patch !== "object") continue;
|
|
57
|
+
const hitbox = normalizeHitbox(patch.hitbox);
|
|
58
|
+
if (!hitbox) continue;
|
|
59
|
+
applyHitboxToObject(sceneMap, id, getObjectById(sceneMap, id), hitbox);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
var applySyncedHitboxPayload = (sceneMap, payload) => {
|
|
63
|
+
applyHitboxCollection(sceneMap, payload?.players);
|
|
64
|
+
applyHitboxCollection(sceneMap, payload?.events);
|
|
65
|
+
};
|
|
66
|
+
//#endregion
|
|
67
|
+
export { applySyncedHitboxPayload };
|
|
68
|
+
|
|
69
|
+
//# sourceMappingURL=syncHitbox.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"syncHitbox.js","names":[],"sources":["../../src/utils/syncHitbox.ts"],"sourcesContent":["interface HitboxSize {\n width: number;\n height: number;\n}\n\nconst toPositiveNumber = (value: unknown): number | null => {\n const numberValue = typeof value === \"string\" ? Number(value) : value;\n return typeof numberValue === \"number\" && Number.isFinite(numberValue) && numberValue > 0\n ? Math.round(numberValue)\n : null;\n};\n\nconst readSignalValue = (value: any): any => {\n if (typeof value === \"function\") {\n try {\n return value();\n }\n catch {\n return undefined;\n }\n }\n return value;\n};\n\nconst normalizeHitbox = (source: unknown): HitboxSize | null => {\n if (!source || typeof source !== \"object\") {\n return null;\n }\n\n const hitbox = source as any;\n const width = toPositiveNumber(hitbox.w ?? hitbox.width);\n const height = toPositiveNumber(hitbox.h ?? hitbox.height);\n\n return width && height ? { width, height } : null;\n};\n\nconst readObjectHitbox = (object: any): HitboxSize | null => {\n return normalizeHitbox(readSignalValue(object?.hitbox));\n};\n\nconst readCoordinate = (object: any, key: \"x\" | \"y\"): number | null => {\n const value = readSignalValue(object?.[key]);\n const numberValue = typeof value === \"string\" ? Number(value) : value;\n return typeof numberValue === \"number\" && Number.isFinite(numberValue)\n ? numberValue\n : null;\n};\n\nconst getObjectById = (sceneMap: any, id: string): any => {\n return sceneMap?.getObjectById?.(id)\n ?? sceneMap?.players?.()?.[id]\n ?? sceneMap?.events?.()?.[id];\n};\n\nconst applyHitboxToObject = (\n sceneMap: any,\n id: string,\n object: any,\n hitbox: HitboxSize,\n): void => {\n if (!object) {\n return;\n }\n\n const current = readObjectHitbox(object);\n if (current?.width !== hitbox.width || current?.height !== hitbox.height) {\n if (typeof object.hitbox?.set === \"function\") {\n object.hitbox.set({ w: hitbox.width, h: hitbox.height });\n }\n else if (typeof object.setHitbox === \"function\") {\n object.setHitbox(hitbox.width, hitbox.height);\n return;\n }\n }\n\n const objectId = typeof object.id === \"string\" && object.id ? object.id : id;\n const x = readCoordinate(object, \"x\");\n const y = readCoordinate(object, \"y\");\n if (objectId && x !== null && y !== null) {\n sceneMap?.updateHitbox?.(objectId, x, y, hitbox.width, hitbox.height);\n }\n};\n\nconst applyHitboxCollection = (sceneMap: any, collection: unknown): void => {\n if (!collection || typeof collection !== \"object\") {\n return;\n }\n\n for (const [id, patch] of Object.entries(collection)) {\n if (!patch || typeof patch !== \"object\") {\n continue;\n }\n const hitbox = normalizeHitbox((patch as any).hitbox);\n if (!hitbox) {\n continue;\n }\n applyHitboxToObject(sceneMap, id, getObjectById(sceneMap, id), hitbox);\n }\n};\n\nexport const applySyncedHitboxPayload = (sceneMap: any, payload: any): void => {\n applyHitboxCollection(sceneMap, payload?.players);\n applyHitboxCollection(sceneMap, payload?.events);\n};\n"],"mappings":";AAKA,IAAM,oBAAoB,UAAkC;CAC1D,MAAM,cAAc,OAAO,UAAU,WAAW,OAAO,KAAK,IAAI;CAChE,OAAO,OAAO,gBAAgB,YAAY,OAAO,SAAS,WAAW,KAAK,cAAc,IACpF,KAAK,MAAM,WAAW,IACtB;AACN;AAEA,IAAM,mBAAmB,UAAoB;CAC3C,IAAI,OAAO,UAAU,YACnB,IAAI;EACF,OAAO,MAAM;CACf,QACM;EACJ;CACF;CAEF,OAAO;AACT;AAEA,IAAM,mBAAmB,WAAuC;CAC9D,IAAI,CAAC,UAAU,OAAO,WAAW,UAC/B,OAAO;CAGT,MAAM,SAAS;CACf,MAAM,QAAQ,iBAAiB,OAAO,KAAK,OAAO,KAAK;CACvD,MAAM,SAAS,iBAAiB,OAAO,KAAK,OAAO,MAAM;CAEzD,OAAO,SAAS,SAAS;EAAE;EAAO;CAAO,IAAI;AAC/C;AAEA,IAAM,oBAAoB,WAAmC;CAC3D,OAAO,gBAAgB,gBAAgB,QAAQ,MAAM,CAAC;AACxD;AAEA,IAAM,kBAAkB,QAAa,QAAkC;CACrE,MAAM,QAAQ,gBAAgB,SAAS,IAAI;CAC3C,MAAM,cAAc,OAAO,UAAU,WAAW,OAAO,KAAK,IAAI;CAChE,OAAO,OAAO,gBAAgB,YAAY,OAAO,SAAS,WAAW,IACjE,cACA;AACN;AAEA,IAAM,iBAAiB,UAAe,OAAoB;CACxD,OAAO,UAAU,gBAAgB,EAAE,KAC9B,UAAU,UAAU,IAAI,OACxB,UAAU,SAAS,IAAI;AAC9B;AAEA,IAAM,uBACJ,UACA,IACA,QACA,WACS;CACT,IAAI,CAAC,QACH;CAGF,MAAM,UAAU,iBAAiB,MAAM;CACvC,IAAI,SAAS,UAAU,OAAO,SAAS,SAAS,WAAW,OAAO;MAC5D,OAAO,OAAO,QAAQ,QAAQ,YAChC,OAAO,OAAO,IAAI;GAAE,GAAG,OAAO;GAAO,GAAG,OAAO;EAAO,CAAC;OAEpD,IAAI,OAAO,OAAO,cAAc,YAAY;GAC/C,OAAO,UAAU,OAAO,OAAO,OAAO,MAAM;GAC5C;EACF;;CAGF,MAAM,WAAW,OAAO,OAAO,OAAO,YAAY,OAAO,KAAK,OAAO,KAAK;CAC1E,MAAM,IAAI,eAAe,QAAQ,GAAG;CACpC,MAAM,IAAI,eAAe,QAAQ,GAAG;CACpC,IAAI,YAAY,MAAM,QAAQ,MAAM,MAClC,UAAU,eAAe,UAAU,GAAG,GAAG,OAAO,OAAO,OAAO,MAAM;AAExE;AAEA,IAAM,yBAAyB,UAAe,eAA8B;CAC1E,IAAI,CAAC,cAAc,OAAO,eAAe,UACvC;CAGF,KAAK,MAAM,CAAC,IAAI,UAAU,OAAO,QAAQ,UAAU,GAAG;EACpD,IAAI,CAAC,SAAS,OAAO,UAAU,UAC7B;EAEF,MAAM,SAAS,gBAAiB,MAAc,MAAM;EACpD,IAAI,CAAC,QACH;EAEF,oBAAoB,UAAU,IAAI,cAAc,UAAU,EAAE,GAAG,MAAM;CACvE;AACF;AAEA,IAAa,4BAA4B,UAAe,YAAuB;CAC7E,sBAAsB,UAAU,SAAS,OAAO;CAChD,sBAAsB,UAAU,SAAS,MAAM;AACjD"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
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.23",
|
|
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",
|
|
@@ -22,8 +22,8 @@
|
|
|
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.
|
|
25
|
+
"@rpgjs/common": "5.0.0-beta.22",
|
|
26
|
+
"@rpgjs/server": "5.0.0-beta.23",
|
|
27
27
|
"@rpgjs/ui-css": "5.0.0-beta.21",
|
|
28
28
|
"@signe/di": "3.1.0",
|
|
29
29
|
"@signe/room": "3.1.0",
|
package/src/Game/Object.spec.ts
CHANGED
|
@@ -1,25 +1,39 @@
|
|
|
1
|
-
import { describe, expect, test, vi } from "vitest";
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
2
|
import { signal } from "canvasengine";
|
|
3
3
|
import { appendFramePayload, RpgClientObject, withGraphicDisplayScale } from "./Object";
|
|
4
|
+
import { RpgClientEvent } from "./Event";
|
|
5
|
+
import { RpgClientPlayer } from "./Player";
|
|
6
|
+
|
|
7
|
+
const injected = vi.hoisted(() => ({
|
|
8
|
+
engine: {} as any,
|
|
9
|
+
}));
|
|
4
10
|
|
|
5
11
|
vi.mock("../RpgClientEngine", () => ({
|
|
6
12
|
RpgClientEngine: class RpgClientEngine {},
|
|
7
13
|
}));
|
|
8
14
|
|
|
9
15
|
vi.mock("../core/inject", () => ({
|
|
10
|
-
inject: () =>
|
|
16
|
+
inject: () => injected.engine,
|
|
11
17
|
}));
|
|
12
18
|
|
|
13
|
-
function createObject() {
|
|
14
|
-
const object = Object.create(
|
|
19
|
+
function createObject(prototype: object = RpgClientObject.prototype) {
|
|
20
|
+
const object = Object.create(prototype) as RpgClientObject;
|
|
21
|
+
object.id = "object-1";
|
|
22
|
+
object.x = signal(10) as any;
|
|
23
|
+
object.y = signal(20) as any;
|
|
15
24
|
object.animationName = signal("stand");
|
|
16
25
|
object.graphics = signal(["hero"]) as any;
|
|
17
26
|
object.animationCurrentIndex = signal(0);
|
|
18
27
|
object.animationIsPlaying = signal(false);
|
|
28
|
+
object.hitbox = signal({ w: 32, h: 32 }) as any;
|
|
19
29
|
return object;
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
describe("RpgClientObject animations", () => {
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
injected.engine = {};
|
|
35
|
+
});
|
|
36
|
+
|
|
23
37
|
test("accepts a single frame payload without requiring iterable spread", () => {
|
|
24
38
|
expect(
|
|
25
39
|
appendFramePayload({ stale: true }, { x: 10, y: 20, ts: 1 }),
|
|
@@ -33,6 +47,32 @@ describe("RpgClientObject animations", () => {
|
|
|
33
47
|
});
|
|
34
48
|
});
|
|
35
49
|
|
|
50
|
+
test("updates the client-side hitbox signal", () => {
|
|
51
|
+
const object = createObject();
|
|
52
|
+
|
|
53
|
+
object.setHitbox(56, 50);
|
|
54
|
+
|
|
55
|
+
expect(object.hitbox()).toEqual({ w: 56, h: 50 });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test.each([
|
|
59
|
+
["player", RpgClientPlayer.prototype],
|
|
60
|
+
["event", RpgClientEvent.prototype],
|
|
61
|
+
])("updates the client-side %s physics body", (_kind, prototype) => {
|
|
62
|
+
const updateHitbox = vi.fn();
|
|
63
|
+
injected.engine = {
|
|
64
|
+
sceneMap: {
|
|
65
|
+
updateHitbox,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
const object = createObject(prototype);
|
|
69
|
+
|
|
70
|
+
object.setHitbox(56, 50);
|
|
71
|
+
|
|
72
|
+
expect(object.hitbox()).toEqual({ w: 56, h: 50 });
|
|
73
|
+
expect(updateHitbox).toHaveBeenCalledWith("object-1", 10, 20, 56, 50);
|
|
74
|
+
});
|
|
75
|
+
|
|
36
76
|
test("marks temporary animation as finished before restoring locomotion animation", async () => {
|
|
37
77
|
const object = createObject();
|
|
38
78
|
const animationChanges: Array<{ name: string; isPlaying: boolean }> = [];
|
package/src/Game/Object.ts
CHANGED
|
@@ -130,6 +130,25 @@ export abstract class RpgClientObject extends RpgCommonPlayer {
|
|
|
130
130
|
return inject(RpgClientEngine);
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
+
setHitbox(width: number, height: number): void {
|
|
134
|
+
if (typeof width !== "number" || width <= 0) {
|
|
135
|
+
throw new Error("setHitbox: width must be a positive number");
|
|
136
|
+
}
|
|
137
|
+
if (typeof height !== "number" || height <= 0) {
|
|
138
|
+
throw new Error("setHitbox: height must be a positive number");
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
this.hitbox.set({
|
|
142
|
+
w: width,
|
|
143
|
+
h: height,
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const sceneMap = (this.engine as any)?.sceneMap;
|
|
147
|
+
if (sceneMap && this.id) {
|
|
148
|
+
sceneMap.updateHitbox?.(this.id, this.x(), this.y(), width, height);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
133
152
|
private animationSubscription?: Subscription;
|
|
134
153
|
private animationResetTimeout?: ReturnType<typeof setTimeout>;
|
|
135
154
|
private animationWaitResolve?: () => void;
|
package/src/RpgClientEngine.ts
CHANGED
|
@@ -39,6 +39,7 @@ 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";
|
|
44
45
|
import type { CameraFollowSmoothMove } from "./services/cameraFollow";
|
|
@@ -967,6 +968,7 @@ export class RpgClientEngine<T = any> {
|
|
|
967
968
|
: undefined;
|
|
968
969
|
const payload = this.prepareSyncPayload(data);
|
|
969
970
|
load(this.sceneMap, payload, true);
|
|
971
|
+
applySyncedHitboxPayload(this.sceneMap, payload);
|
|
970
972
|
|
|
971
973
|
if (normalizedAck) {
|
|
972
974
|
this.applyServerAck(normalizedAck);
|
|
@@ -2172,12 +2174,25 @@ export class RpgClientEngine<T = any> {
|
|
|
2172
2174
|
return { x, y, direction };
|
|
2173
2175
|
}
|
|
2174
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
|
+
|
|
2175
2192
|
private applyAuthoritativeState(state: PredictionState<Direction>): void {
|
|
2176
2193
|
const player = this.sceneMap?.getCurrentPlayer();
|
|
2177
2194
|
if (!player) return;
|
|
2178
|
-
const
|
|
2179
|
-
const width = hitbox?.w ?? 0;
|
|
2180
|
-
const height = hitbox?.h ?? 0;
|
|
2195
|
+
const { width, height } = this.resolveObjectHitboxSize(player);
|
|
2181
2196
|
const updated = this.sceneMap.updateHitbox(player.id, state.x, state.y, width, height);
|
|
2182
2197
|
if (!updated) {
|
|
2183
2198
|
this.sceneMap.setBodyPosition(player.id, state.x, state.y, "top-left");
|
|
@@ -2406,9 +2421,7 @@ export class RpgClientEngine<T = any> {
|
|
|
2406
2421
|
if (!player || !myId) {
|
|
2407
2422
|
return;
|
|
2408
2423
|
}
|
|
2409
|
-
const
|
|
2410
|
-
const width = hitbox?.w ?? 0;
|
|
2411
|
-
const height = hitbox?.h ?? 0;
|
|
2424
|
+
const { width, height } = this.resolveObjectHitboxSize(player);
|
|
2412
2425
|
const updated = this.sceneMap.updateHitbox(myId, ack.x, ack.y, width, height);
|
|
2413
2426
|
if (!updated) {
|
|
2414
2427
|
this.sceneMap.setBodyPosition(myId, ack.x, ack.y, "top-left");
|
|
@@ -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
|
+
};
|
|
@@ -23,12 +23,12 @@
|
|
|
23
23
|
<Particle emit={emitParticleTrigger} settings={particleSettings} zIndex={1000} name={particleName} />
|
|
24
24
|
<Container>
|
|
25
25
|
@for (graphicObj of renderedGraphics) {
|
|
26
|
-
<Container scale={
|
|
26
|
+
<Container scale={graphicContainerScale(graphicObj)}>
|
|
27
27
|
<Sprite
|
|
28
28
|
sheet={sheet(graphicObj)}
|
|
29
29
|
direction
|
|
30
30
|
tint
|
|
31
|
-
hitbox
|
|
31
|
+
hitbox={graphicHitbox(graphicObj)}
|
|
32
32
|
shadowCaster={shadowCaster(graphicObj)}
|
|
33
33
|
flash={flashConfig}
|
|
34
34
|
/>
|
|
@@ -76,6 +76,7 @@
|
|
|
76
76
|
import { GameEngineToken, ModulesToken, shouldRenderLightingShadows } from "@rpgjs/common";
|
|
77
77
|
import { RpgClientEngine } from "../RpgClientEngine";
|
|
78
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";
|
|
@@ -685,7 +686,7 @@
|
|
|
685
686
|
}
|
|
686
687
|
|
|
687
688
|
const graphicScale = (graphicObject) => {
|
|
688
|
-
const scale = graphicObject?.
|
|
689
|
+
const scale = graphicObject?.scale;
|
|
689
690
|
if (Array.isArray(scale)) return scale;
|
|
690
691
|
if (typeof scale === 'number') return [scale, scale];
|
|
691
692
|
if (scale && typeof scale === 'object') {
|
|
@@ -696,12 +697,34 @@
|
|
|
696
697
|
return undefined;
|
|
697
698
|
}
|
|
698
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
|
+
|
|
699
724
|
const shadowCaster = (graphicObject) => {
|
|
700
725
|
const box = hitbox();
|
|
701
726
|
const bounds = graphicBounds();
|
|
702
|
-
const
|
|
703
|
-
const scaleY = Array.isArray(scale) && typeof scale[1] === 'number' ? Math.abs(scale[1]) : 1;
|
|
704
|
-
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);
|
|
705
728
|
|
|
706
729
|
return {
|
|
707
730
|
enabled: shadowsEnabled,
|
|
@@ -722,11 +745,6 @@
|
|
|
722
745
|
const imageDimensions = signal({});
|
|
723
746
|
const loadingImageDimensions = new Set();
|
|
724
747
|
|
|
725
|
-
const toPositiveNumber = (value) => {
|
|
726
|
-
const number = typeof value === 'number' ? value : parseFloat(value);
|
|
727
|
-
return Number.isFinite(number) && number > 0 ? number : undefined;
|
|
728
|
-
};
|
|
729
|
-
|
|
730
748
|
const toFiniteNumber = (value, fallback = 0) => {
|
|
731
749
|
const number = typeof value === 'number' ? value : parseFloat(value);
|
|
732
750
|
return Number.isFinite(number) ? number : fallback;
|
|
@@ -826,6 +844,13 @@
|
|
|
826
844
|
return frame?.[prop] ?? textureOptions?.[prop] ?? graphicObject?.[prop];
|
|
827
845
|
};
|
|
828
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
|
+
|
|
829
854
|
const resolveFrameSize = (textureOptions, dimensions) => {
|
|
830
855
|
const framesWidth = toPositiveNumber(textureOptions?.framesWidth) ?? 1;
|
|
831
856
|
const framesHeight = toPositiveNumber(textureOptions?.framesHeight) ?? 1;
|
|
@@ -846,31 +871,6 @@
|
|
|
846
871
|
};
|
|
847
872
|
};
|
|
848
873
|
|
|
849
|
-
const resolveHitboxAnchor = (spriteWidth, spriteHeight, realSize, box) => {
|
|
850
|
-
if (!spriteWidth || !spriteHeight || !box) {
|
|
851
|
-
return [0, 0];
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
const heightOfSprite = typeof realSize === 'number' ? realSize : realSize?.height;
|
|
855
|
-
const resolvedHeight = toPositiveNumber(heightOfSprite) ?? spriteHeight;
|
|
856
|
-
const gap = Math.max(0, (spriteHeight - resolvedHeight) / 2);
|
|
857
|
-
const hitboxTopLeftX = clampRatio((spriteWidth - box.w) / 2 / spriteWidth);
|
|
858
|
-
const hitboxTopLeftY = clampRatio((spriteHeight - box.h - gap) / spriteHeight);
|
|
859
|
-
const hitboxCenterX = clampRatio(hitboxTopLeftX + box.w / 2 / spriteWidth);
|
|
860
|
-
const hitboxCenterY = clampRatio(hitboxTopLeftY + box.h / 2 / spriteHeight);
|
|
861
|
-
const footY = clampRatio((spriteHeight - gap) / spriteHeight);
|
|
862
|
-
|
|
863
|
-
switch (box.anchorMode ?? 'top-left') {
|
|
864
|
-
case 'center':
|
|
865
|
-
return [hitboxCenterX, hitboxCenterY];
|
|
866
|
-
case 'foot':
|
|
867
|
-
return [hitboxCenterX, footY];
|
|
868
|
-
case 'top-left':
|
|
869
|
-
default:
|
|
870
|
-
return [hitboxTopLeftX, hitboxTopLeftY];
|
|
871
|
-
}
|
|
872
|
-
};
|
|
873
|
-
|
|
874
874
|
const loadImageDimensions = (image) => {
|
|
875
875
|
const source = resolveImageSource(image);
|
|
876
876
|
if (!source || imageDimensions()[source] || loadingImageDimensions.has(source)) {
|
|
@@ -950,14 +950,15 @@
|
|
|
950
950
|
return;
|
|
951
951
|
}
|
|
952
952
|
|
|
953
|
+
const scale = graphicEffectiveScale(graphicObject);
|
|
953
954
|
const explicitAnchor = normalizeAnchor(optionValue('anchor', frame, textureOptions, graphicObject));
|
|
954
|
-
const anchor = explicitAnchor ??
|
|
955
|
+
const anchor = explicitAnchor ?? resolveScaledHitboxAnchor(
|
|
955
956
|
spriteWidth,
|
|
956
957
|
spriteHeight,
|
|
957
958
|
optionValue('spriteRealSize', frame, textureOptions, graphicObject),
|
|
958
|
-
box
|
|
959
|
+
box,
|
|
960
|
+
scale
|
|
959
961
|
);
|
|
960
|
-
const scale = normalizePair(optionValue('scale', frame, textureOptions, graphicObject) ?? graphicScale(graphicObject));
|
|
961
962
|
const x = toFiniteNumber(optionValue('x', frame, textureOptions, graphicObject), 0);
|
|
962
963
|
const y = toFiniteNumber(optionValue('y', frame, textureOptions, graphicObject), 0);
|
|
963
964
|
const leftEdge = -anchor[0] * spriteWidth * scale[0];
|
|
@@ -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
|
});
|