@rpgjs/client 5.0.0-beta.22 → 5.0.0-beta.24
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 +22 -0
- package/dist/Game/Object.d.ts +6 -0
- package/dist/Game/Object.js +58 -2
- 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 +38 -29
- package/dist/components/character.ce.js.map +1 -1
- package/dist/components/gui/dialogbox/index.ce.js +31 -2
- package/dist/components/gui/dialogbox/index.ce.js.map +1 -1
- package/dist/components/gui/hud/hud.ce.js +2 -2
- package/dist/components/gui/hud/hud.ce.js.map +1 -1
- package/dist/components/gui/menu/items-menu.ce.js +27 -4
- package/dist/components/gui/menu/items-menu.ce.js.map +1 -1
- package/dist/components/gui/menu/main-menu.ce.js +210 -155
- package/dist/components/gui/menu/main-menu.ce.js.map +1 -1
- package/dist/components/gui/save-load.ce.js +33 -4
- package/dist/components/gui/save-load.ce.js.map +1 -1
- package/dist/components/gui/shop/shop.ce.js +47 -4
- package/dist/components/gui/shop/shop.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 +4 -4
- package/src/Game/Object.spec.ts +70 -6
- package/src/Game/Object.ts +90 -2
- 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 +52 -45
- package/src/components/gui/dialogbox/index.ce +56 -40
- package/src/components/gui/hud/hud.ce +2 -2
- package/src/components/gui/menu/items-menu.ce +12 -2
- package/src/components/gui/menu/main-menu.ce +37 -6
- package/src/components/gui/save-load.ce +48 -32
- package/src/components/gui/shop/shop.ce +28 -3
- 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":";;;;;;;;;AAkBA,IAAM,kBAAN,cAA8B,kBAAkB;CAwB9C,YAAY,SAA4B,QAAqB,UAA6B,CAAC,GAAG;EAC5F,MAAM,OAAO;EADO,KAAA,UAAA;EAA0B,KAAA,SAAA;cAvBhC;mBASX,CAAC;eACU;GACd,SAAS,OAAO,WAAmB;IACjC,KAAK,OAAO,IAAI,SAAS,QAAQ,KAAK,KAAK;IAC3C,MAAM,SAAS,IAAI,KAAK,OAAO,KAAK,IAAI;IACxC,MAAM,OAAO,QAAQ;IACrB,MAAM,OAAO,QAAQ,QAAQ;IAC7B,KAAK,QAAQ,IAAI,UAAU,MAAM;IACjC,OAAO;GACT;GACA,KAAK,CAAC;EACR;EAME,KAAK,MAAM,MAAM,QAAQ,OAAO,CAAC;EACjC,KAAK,OAAO,IAAI,SAAS,WAAW,KAAK,KAAK;CAChD;CAEA,MAAM,WAAW,WAAiC;EAChD,KAAK,iBAAiB,IAAI,KAAK,OAAO,KAAK,IAAI;EAC/C,MAAM,KAAK,eAAe,QAAQ;EAClC,MAAM,KAAK,eAAe,QAAQ,QAAQ;EAC1C,KAAK,QAAQ,IAAI,UAAU,KAAK,cAAc;EAC9C,OAAO,KAAK,YAAY,SAAS;CACnC;CAEA,MAAc,YAAY,WAAiC;EACzD,KAAK,oBAAoB;EACzB,KAAK,iBAAiB,KAAK,QAAQ,IAAI,QAAQ;EAC/C,KAAK,SAAS,IAAI,SAAS,KAAK,gBAAgB,kBAAkB;EAClE,MAAM,MAAM,IAAI,IAAI,kBAAkB;EACtC,MAAM,UAAU,IAAI,QAAQ,IAAI,SAAS,GAAG;GAC1C,QAAQ;GACR,SAAS,EACP,gBAAgB,mBAClB;EACF,CAAC;EACD,YAAY,KAAK,MAAM;EACvB,KAAK,KAAK,QAAQ,IAAI,KAAK,OAAO,IAAI,KAAK,MAAM;EACjD,KAAK,aAAa,KAAK;EACvB,KAAK,UAAU,SAAS,EAAE,cAAc;GACtC,KAAK,OAAO,iBAAiB,WAAW,OAAO;EACjD,CAAC;EACD,MAAM,KAAK,eAAe,UAAU,KAAK,OAAO,MAAa,EAAE,QAAQ,CAAQ;EAC/E,OAAO,KAAK;CACd;CAEA,GAAG,KAAa,UAA+B;EAC7C,IACE,KAAK,UAAU,MACZ,aAAa,SAAS,UAAU,OAAO,SAAS,aAAa,QAChE,GAEA;EAEF,MAAM,WAAW,UAAU;GACzB,MAAM,SAAS,2BAA2B,KAAK;GAC/C,IAAI,OAAO,SAAS,KAClB,SAAS,OAAO,KAAK;EAEzB;EACA,KAAK,UAAU,KAAK;GAAE,OAAO;GAAK;GAAU;EAAQ,CAAC;EACrD,KAAK,QAAQ,iBAAiB,WAAW,OAAO;CAClD;CAEA,IAAI,OAAe,UAA+B;EAChD,MAAM,YAAmC,CAAC;EAC1C,KAAK,MAAM,YAAY,KAAK,WAAW;GACrC,IAAI,SAAS,UAAU,SAAS,SAAS,aAAa,UAAU;IAC9D,KAAK,QAAQ,oBAAoB,WAAW,SAAS,OAAO;IAC5D;GACF;GACA,UAAU,KAAK,QAAQ;EACzB;EACA,KAAK,YAAY;CACnB;CAEA,KAAK,OAAe,MAAW;EAC7B,KAAK,OAAO,KAAK;GACf,QAAQ;GACR,OAAO;EACT,CAAC;CACH;;;;;;;;;;;;;;;;;CAkBA,iBAAiB,SAAiC,CAElD;;;;;;;;;;;;;;;;;;CAmBA,MAAM,UAAU,WAAgD;EAC9D,MAAM,KAAK,aAAa,WAAW;GACjC,YAAY,MAAM;EACpB,CAAC;CACH;CAEA,sBAA8B;EAC5B,IAAI,CAAC,KAAK,QAAQ;EAClB,KAAK,UAAU,SAAS,EAAE,cAAc;GACtC,KAAK,OAAO,oBAAoB,WAAW,OAAO;EACpD,CAAC;EACD,KAAK,YAAY,SAAS,SAAS,KAAK,OAAO,EAAE;EACjD,KAAK,SAAS,KAAA;EACd,KAAK,aAAa,KAAA;CACpB;CAEA,YAAY;EACV,OAAO,KAAK;CACd;CAEA,YAAY;EACV,OAAO,KAAK;CACd;AACF;AAEA,IAAM,6BAAN,cAAyC,iBAAiB;;;;;;;;;;;;;;;CAiBxD,MAAM,OAAO,KAAU;EACrB,KAAK,SAAS,KAAK,QAAQ,IAAI,QAAQ;EAEvC,MAAM,MAAM;GACV,KAAK,iCAFQ,KAAK,QAAQ,MAAM,MAAM,UAEO;GAC7C,QAAQ;GACR,SAAS,IAAI,QAAQ,CAAC,CAAC;GACvB,MAAM,YAAY;IAChB,OAAO;GACT;EACF;EACA,MAAM,KAAK,OAAO,UAAU,GAAG;CACjC;AACF;AAEA,SAAgB,WAAW,QAAa,UAA6B,CAAC,GAAG;CACvE,OAAO;EACL;GACE,SAAS;GACT,aAAa,YAAqB,IAAI,gBAAgB,SAAS,QAAQ,OAAO;EAChF;EACA;GACE,SAAS;GACT,UAAU;EACZ;EACA,wBAAwB;EACxB,kBAAkB;EAClB;EACA;CACF;AACF"}
|
|
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.24",
|
|
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,9 +22,9 @@
|
|
|
22
22
|
"pixi.js": "^8.9.2"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@rpgjs/common": "5.0.0-beta.
|
|
26
|
-
"@rpgjs/server": "5.0.0-beta.
|
|
27
|
-
"@rpgjs/ui-css": "5.0.0-beta.
|
|
25
|
+
"@rpgjs/common": "5.0.0-beta.23",
|
|
26
|
+
"@rpgjs/server": "5.0.0-beta.24",
|
|
27
|
+
"@rpgjs/ui-css": "5.0.0-beta.22",
|
|
28
28
|
"@signe/di": "3.1.0",
|
|
29
29
|
"@signe/room": "3.1.0",
|
|
30
30
|
"@signe/sync": "3.1.0",
|
package/src/Game/Object.spec.ts
CHANGED
|
@@ -1,38 +1,102 @@
|
|
|
1
|
-
import { describe, expect, test, vi } from "vitest";
|
|
1
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
2
2
|
import { signal } from "canvasengine";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
appendFramePayload,
|
|
5
|
+
mergeFreshFramePayload,
|
|
6
|
+
multiplyGraphicDisplayScale,
|
|
7
|
+
RpgClientObject,
|
|
8
|
+
withGraphicDisplayScale,
|
|
9
|
+
} from "./Object";
|
|
10
|
+
import { RpgClientEvent } from "./Event";
|
|
11
|
+
import { RpgClientPlayer } from "./Player";
|
|
12
|
+
|
|
13
|
+
const injected = vi.hoisted(() => ({
|
|
14
|
+
engine: {} as any,
|
|
15
|
+
}));
|
|
4
16
|
|
|
5
17
|
vi.mock("../RpgClientEngine", () => ({
|
|
6
18
|
RpgClientEngine: class RpgClientEngine {},
|
|
7
19
|
}));
|
|
8
20
|
|
|
9
21
|
vi.mock("../core/inject", () => ({
|
|
10
|
-
inject: () =>
|
|
22
|
+
inject: () => injected.engine,
|
|
11
23
|
}));
|
|
12
24
|
|
|
13
|
-
function createObject() {
|
|
14
|
-
const object = Object.create(
|
|
25
|
+
function createObject(prototype: object = RpgClientObject.prototype) {
|
|
26
|
+
const object = Object.create(prototype) as RpgClientObject;
|
|
27
|
+
object.id = "object-1";
|
|
28
|
+
object.x = signal(10) as any;
|
|
29
|
+
object.y = signal(20) as any;
|
|
15
30
|
object.animationName = signal("stand");
|
|
16
31
|
object.graphics = signal(["hero"]) as any;
|
|
17
32
|
object.animationCurrentIndex = signal(0);
|
|
18
33
|
object.animationIsPlaying = signal(false);
|
|
34
|
+
object.hitbox = signal({ w: 32, h: 32 }) as any;
|
|
19
35
|
return object;
|
|
20
36
|
}
|
|
21
37
|
|
|
22
38
|
describe("RpgClientObject animations", () => {
|
|
39
|
+
beforeEach(() => {
|
|
40
|
+
injected.engine = {};
|
|
41
|
+
});
|
|
42
|
+
|
|
23
43
|
test("accepts a single frame payload without requiring iterable spread", () => {
|
|
24
44
|
expect(
|
|
25
45
|
appendFramePayload({ stale: true }, { x: 10, y: 20, ts: 1 }),
|
|
26
46
|
).toEqual([{ x: 10, y: 20, ts: 1 }]);
|
|
27
47
|
});
|
|
28
48
|
|
|
49
|
+
test("drops stale movement frames that arrive after a newer frame was applied", () => {
|
|
50
|
+
expect(
|
|
51
|
+
mergeFreshFramePayload(
|
|
52
|
+
[],
|
|
53
|
+
[{ x: 733, y: 551, ts: 200 }],
|
|
54
|
+
300,
|
|
55
|
+
),
|
|
56
|
+
).toEqual([]);
|
|
57
|
+
});
|
|
58
|
+
|
|
29
59
|
test("keeps instance scale outside the spritesheet transform scale", () => {
|
|
30
60
|
expect(withGraphicDisplayScale({ id: "hero" }, 0.5)).toEqual({
|
|
31
61
|
id: "hero",
|
|
32
|
-
displayScale: 0.5,
|
|
62
|
+
displayScale: [0.5, 0.5],
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("combines media and instance display scales", () => {
|
|
67
|
+
expect(multiplyGraphicDisplayScale(0.5, 2)).toEqual([1, 1]);
|
|
68
|
+
expect(withGraphicDisplayScale({ id: "hero", displayScale: 0.5 }, { x: 2, y: 3 })).toEqual({
|
|
69
|
+
id: "hero",
|
|
70
|
+
displayScale: [1, 1.5],
|
|
33
71
|
});
|
|
34
72
|
});
|
|
35
73
|
|
|
74
|
+
test("updates the client-side hitbox signal", () => {
|
|
75
|
+
const object = createObject();
|
|
76
|
+
|
|
77
|
+
object.setHitbox(56, 50);
|
|
78
|
+
|
|
79
|
+
expect(object.hitbox()).toEqual({ w: 56, h: 50 });
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test.each([
|
|
83
|
+
["player", RpgClientPlayer.prototype],
|
|
84
|
+
["event", RpgClientEvent.prototype],
|
|
85
|
+
])("updates the client-side %s physics body", (_kind, prototype) => {
|
|
86
|
+
const updateHitbox = vi.fn();
|
|
87
|
+
injected.engine = {
|
|
88
|
+
sceneMap: {
|
|
89
|
+
updateHitbox,
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
const object = createObject(prototype);
|
|
93
|
+
|
|
94
|
+
object.setHitbox(56, 50);
|
|
95
|
+
|
|
96
|
+
expect(object.hitbox()).toEqual({ w: 56, h: 50 });
|
|
97
|
+
expect(updateHitbox).toHaveBeenCalledWith("object-1", 10, 20, 56, 50);
|
|
98
|
+
});
|
|
99
|
+
|
|
36
100
|
test("marks temporary animation as finished before restoring locomotion animation", async () => {
|
|
37
101
|
const object = createObject();
|
|
38
102
|
const animationChanges: Array<{ name: string; isPlaying: boolean }> = [];
|
package/src/Game/Object.ts
CHANGED
|
@@ -29,12 +29,49 @@ type ConfigurableTrigger<T> = Omit<Trigger<T>, "start"> & {
|
|
|
29
29
|
start(config?: T): Promise<void>;
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
+
const toFiniteScale = (value: unknown, fallback: number): number => {
|
|
33
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
34
|
+
if (typeof value === "string" && value.trim()) {
|
|
35
|
+
const parsed = Number(value);
|
|
36
|
+
return Number.isFinite(parsed) ? parsed : fallback;
|
|
37
|
+
}
|
|
38
|
+
return fallback;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const normalizeGraphicScale = (value: unknown, fallback: [number, number] = [1, 1]): [number, number] => {
|
|
42
|
+
if (typeof value === "number" || typeof value === "string") {
|
|
43
|
+
const scale = toFiniteScale(value, fallback[0]);
|
|
44
|
+
return [scale, scale];
|
|
45
|
+
}
|
|
46
|
+
if (Array.isArray(value)) {
|
|
47
|
+
const x = toFiniteScale(value[0], fallback[0]);
|
|
48
|
+
const y = toFiniteScale(value[1] ?? value[0], x);
|
|
49
|
+
return [x, y];
|
|
50
|
+
}
|
|
51
|
+
if (value && typeof value === "object") {
|
|
52
|
+
const scale = value as { x?: unknown; y?: unknown };
|
|
53
|
+
const x = toFiniteScale(scale.x, fallback[0]);
|
|
54
|
+
const y = toFiniteScale(scale.y ?? scale.x, x);
|
|
55
|
+
return [x, y];
|
|
56
|
+
}
|
|
57
|
+
return fallback;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const multiplyGraphicDisplayScale = (
|
|
61
|
+
baseScale: unknown,
|
|
62
|
+
instanceScale: unknown,
|
|
63
|
+
): [number, number] => {
|
|
64
|
+
const base = normalizeGraphicScale(baseScale);
|
|
65
|
+
const instance = normalizeGraphicScale(instanceScale);
|
|
66
|
+
return [base[0] * instance[0], base[1] * instance[1]];
|
|
67
|
+
};
|
|
68
|
+
|
|
32
69
|
export const withGraphicDisplayScale = (spritesheet: any, scale: unknown): any => {
|
|
33
70
|
if (!spritesheet || typeof spritesheet !== "object") return spritesheet;
|
|
34
71
|
if (scale === undefined || scale === null) return spritesheet;
|
|
35
72
|
return {
|
|
36
73
|
...spritesheet,
|
|
37
|
-
displayScale: scale,
|
|
74
|
+
displayScale: multiplyGraphicDisplayScale(spritesheet.displayScale, scale),
|
|
38
75
|
};
|
|
39
76
|
};
|
|
40
77
|
|
|
@@ -47,6 +84,24 @@ export const appendFramePayload = (current: unknown, items: unknown): Frame[] =>
|
|
|
47
84
|
return currentFrames.concat(nextFrames);
|
|
48
85
|
};
|
|
49
86
|
|
|
87
|
+
export const getFrameTimestamp = (frame: Frame): number => {
|
|
88
|
+
const timestamp = Number(frame.ts);
|
|
89
|
+
return Number.isFinite(timestamp) ? timestamp : 0;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const mergeFreshFramePayload = (
|
|
93
|
+
current: unknown,
|
|
94
|
+
items: unknown,
|
|
95
|
+
lastAppliedFrameTs = 0,
|
|
96
|
+
): Frame[] => {
|
|
97
|
+
return appendFramePayload(current, items)
|
|
98
|
+
.filter((frame) => {
|
|
99
|
+
const timestamp = getFrameTimestamp(frame);
|
|
100
|
+
return timestamp === 0 || timestamp > lastAppliedFrameTs;
|
|
101
|
+
})
|
|
102
|
+
.sort((a, b) => getFrameTimestamp(a) - getFrameTimestamp(b));
|
|
103
|
+
};
|
|
104
|
+
|
|
50
105
|
export abstract class RpgClientObject extends RpgCommonPlayer {
|
|
51
106
|
abstract _type: string;
|
|
52
107
|
emitParticleTrigger = trigger();
|
|
@@ -57,6 +112,7 @@ export abstract class RpgClientObject extends RpgCommonPlayer {
|
|
|
57
112
|
frames: Frame[] = [];
|
|
58
113
|
graphicsSignals = signal<any[]>([]);
|
|
59
114
|
flashTrigger: ConfigurableTrigger<FlashTriggerOptions> = trigger<FlashTriggerOptions>();
|
|
115
|
+
private lastAppliedFrameTs = 0;
|
|
60
116
|
private animationRestoreState?: {
|
|
61
117
|
animationName: string;
|
|
62
118
|
graphics: any[];
|
|
@@ -70,7 +126,11 @@ export abstract class RpgClientObject extends RpgCommonPlayer {
|
|
|
70
126
|
this._frames.observable.subscribe(({ items }) => {
|
|
71
127
|
if (!this.id) return;
|
|
72
128
|
//if (this.id == this.engine.playerIdSignal()!) return;
|
|
73
|
-
this.frames =
|
|
129
|
+
this.frames = mergeFreshFramePayload(
|
|
130
|
+
this.frames,
|
|
131
|
+
items,
|
|
132
|
+
this.lastAppliedFrameTs,
|
|
133
|
+
);
|
|
74
134
|
});
|
|
75
135
|
|
|
76
136
|
const graphics$ = this.graphics.observable.pipe(map(({ items }) => items));
|
|
@@ -102,16 +162,25 @@ export abstract class RpgClientObject extends RpgCommonPlayer {
|
|
|
102
162
|
const frame = this.frames.shift();
|
|
103
163
|
if (frame) {
|
|
104
164
|
if (typeof frame.x !== "number" || typeof frame.y !== "number") return;
|
|
165
|
+
const frameTs = this.getFrameTimestamp(frame);
|
|
166
|
+
if (frameTs > 0 && frameTs <= this.lastAppliedFrameTs) return;
|
|
105
167
|
engine.scene.setBodyPosition(
|
|
106
168
|
this.id,
|
|
107
169
|
frame.x,
|
|
108
170
|
frame.y,
|
|
109
171
|
"top-left"
|
|
110
172
|
);
|
|
173
|
+
if (frameTs > this.lastAppliedFrameTs) {
|
|
174
|
+
this.lastAppliedFrameTs = frameTs;
|
|
175
|
+
}
|
|
111
176
|
}
|
|
112
177
|
});
|
|
113
178
|
}
|
|
114
179
|
|
|
180
|
+
private getFrameTimestamp(frame: Frame): number {
|
|
181
|
+
return getFrameTimestamp(frame);
|
|
182
|
+
}
|
|
183
|
+
|
|
115
184
|
/**
|
|
116
185
|
* Access the shared client hook registry.
|
|
117
186
|
*
|
|
@@ -130,6 +199,25 @@ export abstract class RpgClientObject extends RpgCommonPlayer {
|
|
|
130
199
|
return inject(RpgClientEngine);
|
|
131
200
|
}
|
|
132
201
|
|
|
202
|
+
setHitbox(width: number, height: number): void {
|
|
203
|
+
if (typeof width !== "number" || width <= 0) {
|
|
204
|
+
throw new Error("setHitbox: width must be a positive number");
|
|
205
|
+
}
|
|
206
|
+
if (typeof height !== "number" || height <= 0) {
|
|
207
|
+
throw new Error("setHitbox: height must be a positive number");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
this.hitbox.set({
|
|
211
|
+
w: width,
|
|
212
|
+
h: height,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const sceneMap = (this.engine as any)?.sceneMap;
|
|
216
|
+
if (sceneMap && this.id) {
|
|
217
|
+
sceneMap.updateHitbox?.(this.id, this.x(), this.y(), width, height);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
133
221
|
private animationSubscription?: Subscription;
|
|
134
222
|
private animationResetTimeout?: ReturnType<typeof setTimeout>;
|
|
135
223
|
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
|
+
};
|