@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.
@@ -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.22",
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.21",
26
- "@rpgjs/server": "5.0.0-beta.22",
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",
@@ -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(RpgClientObject.prototype) as RpgClientObject;
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 }> = [];
@@ -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;
@@ -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 hitbox = typeof player.hitbox === "function" ? player.hitbox() : player.hitbox;
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 hitbox = typeof player.hitbox === "function" ? player.hitbox() : player.hitbox;
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={graphicScale(graphicObj)}>
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?.displayScale ?? graphicObject?.scale;
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 scale = graphicScale(graphicObject);
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 ?? resolveHitboxAnchor(
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
  });