@rpgjs/client 5.0.0-beta.10 → 5.0.0-beta.12

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.
Files changed (154) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/Game/AnimationManager.d.ts +1 -0
  3. package/dist/Game/AnimationManager.js +3 -0
  4. package/dist/Game/AnimationManager.js.map +1 -1
  5. package/dist/Game/ClientVisuals.d.ts +61 -0
  6. package/dist/Game/ClientVisuals.js +96 -0
  7. package/dist/Game/ClientVisuals.js.map +1 -0
  8. package/dist/Game/ClientVisuals.spec.d.ts +1 -0
  9. package/dist/Game/EventComponentResolver.d.ts +16 -0
  10. package/dist/Game/EventComponentResolver.js +52 -0
  11. package/dist/Game/EventComponentResolver.js.map +1 -0
  12. package/dist/Game/EventComponentResolver.spec.d.ts +1 -0
  13. package/dist/Game/Map.js +9 -0
  14. package/dist/Game/Map.js.map +1 -1
  15. package/dist/Game/Object.js +2 -2
  16. package/dist/Game/Object.js.map +1 -1
  17. package/dist/Game/Object.spec.d.ts +1 -0
  18. package/dist/Game/ProjectileManager.d.ts +98 -0
  19. package/dist/Game/ProjectileManager.js +196 -0
  20. package/dist/Game/ProjectileManager.js.map +1 -0
  21. package/dist/Game/ProjectileManager.spec.d.ts +1 -0
  22. package/dist/RpgClient.d.ts +117 -13
  23. package/dist/RpgClientEngine.d.ts +82 -4
  24. package/dist/RpgClientEngine.js +296 -51
  25. package/dist/RpgClientEngine.js.map +1 -1
  26. package/dist/components/animations/fx.ce.js +58 -0
  27. package/dist/components/animations/fx.ce.js.map +1 -0
  28. package/dist/components/animations/hit.ce.js.map +1 -1
  29. package/dist/components/animations/index.d.ts +1 -0
  30. package/dist/components/animations/index.js +3 -1
  31. package/dist/components/animations/index.js.map +1 -1
  32. package/dist/components/character.ce.js +140 -40
  33. package/dist/components/character.ce.js.map +1 -1
  34. package/dist/components/dynamics/bar.ce.js +4 -3
  35. package/dist/components/dynamics/bar.ce.js.map +1 -1
  36. package/dist/components/dynamics/image.ce.js +2 -1
  37. package/dist/components/dynamics/image.ce.js.map +1 -1
  38. package/dist/components/dynamics/shape.ce.js +3 -2
  39. package/dist/components/dynamics/shape.ce.js.map +1 -1
  40. package/dist/components/dynamics/text.ce.js +9 -8
  41. package/dist/components/dynamics/text.ce.js.map +1 -1
  42. package/dist/components/gui/dialogbox/index.ce.js +3 -2
  43. package/dist/components/gui/dialogbox/index.ce.js.map +1 -1
  44. package/dist/components/gui/gameover.ce.js +3 -2
  45. package/dist/components/gui/gameover.ce.js.map +1 -1
  46. package/dist/components/gui/hud/hud.ce.js.map +1 -1
  47. package/dist/components/gui/menu/equip-menu.ce.js +2 -1
  48. package/dist/components/gui/menu/equip-menu.ce.js.map +1 -1
  49. package/dist/components/gui/menu/exit-menu.ce.js +2 -1
  50. package/dist/components/gui/menu/exit-menu.ce.js.map +1 -1
  51. package/dist/components/gui/menu/items-menu.ce.js +3 -2
  52. package/dist/components/gui/menu/items-menu.ce.js.map +1 -1
  53. package/dist/components/gui/menu/main-menu.ce.js +3 -2
  54. package/dist/components/gui/menu/main-menu.ce.js.map +1 -1
  55. package/dist/components/gui/menu/options-menu.ce.js.map +1 -1
  56. package/dist/components/gui/menu/skills-menu.ce.js.map +1 -1
  57. package/dist/components/gui/mobile/mobile.ce.js.map +1 -1
  58. package/dist/components/gui/notification/notification.ce.js.map +1 -1
  59. package/dist/components/gui/save-load.ce.js +2 -1
  60. package/dist/components/gui/save-load.ce.js.map +1 -1
  61. package/dist/components/gui/shop/shop.ce.js +3 -2
  62. package/dist/components/gui/shop/shop.ce.js.map +1 -1
  63. package/dist/components/gui/title-screen.ce.js +3 -2
  64. package/dist/components/gui/title-screen.ce.js.map +1 -1
  65. package/dist/components/index.d.ts +2 -1
  66. package/dist/components/index.js +1 -0
  67. package/dist/components/player-components.ce.js +11 -10
  68. package/dist/components/player-components.ce.js.map +1 -1
  69. package/dist/components/prebuilt/hp-bar.ce.js +4 -3
  70. package/dist/components/prebuilt/hp-bar.ce.js.map +1 -1
  71. package/dist/components/prebuilt/light-halo.ce.js +2 -1
  72. package/dist/components/prebuilt/light-halo.ce.js.map +1 -1
  73. package/dist/components/scenes/canvas.ce.js +12 -4
  74. package/dist/components/scenes/canvas.ce.js.map +1 -1
  75. package/dist/components/scenes/draw-map.ce.js +6 -3
  76. package/dist/components/scenes/draw-map.ce.js.map +1 -1
  77. package/dist/components/scenes/event-layer.ce.js.map +1 -1
  78. package/dist/index.d.ts +4 -0
  79. package/dist/index.js +10 -5
  80. package/dist/module.js +18 -0
  81. package/dist/module.js.map +1 -1
  82. package/dist/services/actionInput.d.ts +14 -0
  83. package/dist/services/actionInput.js +59 -0
  84. package/dist/services/actionInput.js.map +1 -0
  85. package/dist/services/actionInput.spec.d.ts +1 -0
  86. package/dist/services/mmorpg-connection.d.ts +5 -0
  87. package/dist/services/mmorpg-connection.js +50 -0
  88. package/dist/services/mmorpg-connection.js.map +1 -0
  89. package/dist/services/mmorpg-connection.spec.d.ts +1 -0
  90. package/dist/services/mmorpg.d.ts +10 -4
  91. package/dist/services/mmorpg.js +48 -30
  92. package/dist/services/mmorpg.js.map +1 -1
  93. package/dist/services/pointerContext.d.ts +11 -0
  94. package/dist/services/pointerContext.js +48 -0
  95. package/dist/services/pointerContext.js.map +1 -0
  96. package/dist/services/pointerContext.spec.d.ts +1 -0
  97. package/dist/services/standalone-message.d.ts +1 -0
  98. package/dist/services/standalone-message.js +9 -0
  99. package/dist/services/standalone-message.js.map +1 -0
  100. package/dist/services/standalone.d.ts +3 -1
  101. package/dist/services/standalone.js +34 -15
  102. package/dist/services/standalone.js.map +1 -1
  103. package/dist/services/standalone.spec.d.ts +1 -0
  104. package/dist/utils/mapId.d.ts +1 -0
  105. package/dist/utils/mapId.js +6 -0
  106. package/dist/utils/mapId.js.map +1 -0
  107. package/package.json +7 -7
  108. package/src/Game/AnimationManager.ts +4 -0
  109. package/src/Game/ClientVisuals.spec.ts +56 -0
  110. package/src/Game/ClientVisuals.ts +184 -0
  111. package/src/Game/EventComponentResolver.spec.ts +84 -0
  112. package/src/Game/EventComponentResolver.ts +74 -0
  113. package/src/Game/Map.ts +10 -0
  114. package/src/Game/Object.spec.ts +46 -0
  115. package/src/Game/Object.ts +2 -2
  116. package/src/Game/ProjectileManager.spec.ts +449 -0
  117. package/src/Game/ProjectileManager.ts +346 -0
  118. package/src/RpgClient.ts +130 -15
  119. package/src/RpgClientEngine.ts +405 -69
  120. package/src/components/animations/fx.ce +101 -0
  121. package/src/components/animations/index.ts +4 -2
  122. package/src/components/character.ce +185 -40
  123. package/src/components/dynamics/bar.ce +4 -3
  124. package/src/components/dynamics/image.ce +2 -1
  125. package/src/components/dynamics/shape.ce +3 -2
  126. package/src/components/dynamics/text.ce +9 -8
  127. package/src/components/gui/dialogbox/index.ce +3 -2
  128. package/src/components/gui/gameover.ce +2 -1
  129. package/src/components/gui/menu/equip-menu.ce +2 -1
  130. package/src/components/gui/menu/exit-menu.ce +2 -1
  131. package/src/components/gui/menu/items-menu.ce +3 -2
  132. package/src/components/gui/menu/main-menu.ce +2 -1
  133. package/src/components/gui/save-load.ce +2 -1
  134. package/src/components/gui/shop/shop.ce +3 -2
  135. package/src/components/gui/title-screen.ce +2 -1
  136. package/src/components/index.ts +2 -1
  137. package/src/components/player-components.ce +11 -10
  138. package/src/components/prebuilt/hp-bar.ce +4 -3
  139. package/src/components/prebuilt/light-halo.ce +2 -2
  140. package/src/components/scenes/canvas.ce +10 -2
  141. package/src/components/scenes/draw-map.ce +17 -3
  142. package/src/index.ts +4 -0
  143. package/src/module.ts +24 -0
  144. package/src/services/actionInput.spec.ts +155 -0
  145. package/src/services/actionInput.ts +120 -0
  146. package/src/services/mmorpg-connection.spec.ts +99 -0
  147. package/src/services/mmorpg-connection.ts +69 -0
  148. package/src/services/mmorpg.ts +60 -34
  149. package/src/services/pointerContext.spec.ts +36 -0
  150. package/src/services/pointerContext.ts +84 -0
  151. package/src/services/standalone-message.ts +7 -0
  152. package/src/services/standalone.spec.ts +34 -0
  153. package/src/services/standalone.ts +42 -12
  154. package/src/utils/mapId.ts +2 -0
@@ -0,0 +1,99 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { isNativeSocketEvent, parseSocketMessage, waitForRpgjsConnected } from "./mmorpg-connection";
3
+
4
+ function wait(ms = 0): Promise<void> {
5
+ return new Promise((resolve) => setTimeout(resolve, ms));
6
+ }
7
+
8
+ class FakeConnection extends EventTarget {
9
+ serverMessage(data: any) {
10
+ this.dispatchEvent(new MessageEvent("message", {
11
+ data: typeof data === "string" ? data : JSON.stringify(data),
12
+ }));
13
+ }
14
+
15
+ close(reason = "", code = 1008) {
16
+ this.dispatchEvent(new CloseEvent("close", {
17
+ code,
18
+ reason,
19
+ }));
20
+ }
21
+
22
+ fail(message = "WebSocket connection failed") {
23
+ this.dispatchEvent(new ErrorEvent("error", {
24
+ message,
25
+ }));
26
+ }
27
+ }
28
+
29
+ describe("MMORPG connection gate", () => {
30
+ test("resolves only after the RPGJS connected packet", async () => {
31
+ const conn = new FakeConnection();
32
+ let resolved = false;
33
+ const promise = waitForRpgjsConnected(conn).then(() => {
34
+ resolved = true;
35
+ });
36
+
37
+ conn.serverMessage({ type: "sync", value: { pId: "player-1" } });
38
+ await wait();
39
+
40
+ expect(resolved).toBe(false);
41
+
42
+ conn.serverMessage({ type: "connected", value: { id: "conn-1" } });
43
+ await promise;
44
+
45
+ expect(resolved).toBe(true);
46
+ });
47
+
48
+ test("rejects when the socket closes before the RPGJS connected packet", async () => {
49
+ const conn = new FakeConnection();
50
+ const promise = waitForRpgjsConnected(conn);
51
+
52
+ conn.close("Authentication failed");
53
+
54
+ await expect(promise).rejects.toThrow("Authentication failed");
55
+ });
56
+
57
+ test("can ignore the clean close emitted by PartySocket during manual reconnect", async () => {
58
+ const conn = new FakeConnection();
59
+ const promise = waitForRpgjsConnected(conn, 10000, { ignoreCleanClose: true });
60
+
61
+ conn.close("", 1000);
62
+ await wait();
63
+
64
+ conn.serverMessage({ type: "connected", value: { id: "conn-2" } });
65
+
66
+ await expect(promise).resolves.toBeUndefined();
67
+ });
68
+
69
+ test("can ignore PartySocket reconnect close events with non-string reasons", async () => {
70
+ const conn = new FakeConnection();
71
+ const promise = waitForRpgjsConnected(conn, 10000, { ignoreCleanClose: true });
72
+
73
+ conn.dispatchEvent(new CloseEvent("close", {
74
+ code: 1000,
75
+ reason: new Event("close") as any,
76
+ }));
77
+ await wait();
78
+
79
+ conn.serverMessage({ type: "connected", value: { id: "conn-3" } });
80
+
81
+ await expect(promise).resolves.toBeUndefined();
82
+ });
83
+
84
+ test("rejects when the socket errors before the RPGJS connected packet", async () => {
85
+ const conn = new FakeConnection();
86
+ const promise = waitForRpgjsConnected(conn);
87
+
88
+ conn.fail("Unauthorized");
89
+
90
+ await expect(promise).rejects.toThrow("Unauthorized");
91
+ });
92
+
93
+ test("parses connection messages and detects native socket events", () => {
94
+ expect(parseSocketMessage(JSON.stringify({ type: "connected" }))).toEqual({ type: "connected" });
95
+ expect(parseSocketMessage("not-json")).toBeUndefined();
96
+ expect(isNativeSocketEvent("open")).toBe(true);
97
+ expect(isNativeSocketEvent("sync")).toBe(false);
98
+ });
99
+ });
@@ -0,0 +1,69 @@
1
+ export function parseSocketMessage(data: any) {
2
+ if (typeof data !== "string") {
3
+ return data;
4
+ }
5
+ try {
6
+ return JSON.parse(data);
7
+ }
8
+ catch {
9
+ return undefined;
10
+ }
11
+ }
12
+
13
+ export function isNativeSocketEvent(event: string) {
14
+ return event === "open" || event === "close" || event === "error";
15
+ }
16
+
17
+ export function waitForRpgjsConnected(
18
+ conn: any,
19
+ timeoutMs = 10000,
20
+ options: { ignoreCleanClose?: boolean } = {},
21
+ ): Promise<void> {
22
+ return new Promise((resolve, reject) => {
23
+ let timeoutId: number | undefined;
24
+
25
+ const cleanup = () => {
26
+ conn.removeEventListener("message", onMessage);
27
+ conn.removeEventListener("close", onClose);
28
+ conn.removeEventListener("error", onError);
29
+ if (timeoutId !== undefined) {
30
+ window.clearTimeout(timeoutId);
31
+ }
32
+ };
33
+ const rejectWith = (error: Error) => {
34
+ cleanup();
35
+ reject(error);
36
+ };
37
+ const onMessage = (event: MessageEvent) => {
38
+ const data = parseSocketMessage(event.data);
39
+ if (data?.type !== "connected") {
40
+ return;
41
+ }
42
+ cleanup();
43
+ resolve();
44
+ };
45
+ const onClose = (event: CloseEvent) => {
46
+ const rawReason: unknown = (event as any).reason;
47
+ if (
48
+ options.ignoreCleanClose
49
+ && (event.code === 1000 || rawReason instanceof Event || typeof rawReason !== "string")
50
+ ) {
51
+ return;
52
+ }
53
+ const reason = typeof rawReason === "string" && rawReason
54
+ ? rawReason
55
+ : "WebSocket closed before RPGJS connection was accepted";
56
+ rejectWith(new Error(reason));
57
+ };
58
+ const onError = (event: ErrorEvent) => {
59
+ rejectWith(new Error(event.message || "WebSocket connection failed"));
60
+ };
61
+
62
+ conn.addEventListener("message", onMessage);
63
+ conn.addEventListener("close", onClose);
64
+ conn.addEventListener("error", onError);
65
+ timeoutId = window.setTimeout(() => {
66
+ rejectWith(new Error("RPGJS connection timeout"));
67
+ }, timeoutMs);
68
+ });
69
+ }
@@ -2,21 +2,25 @@ import { Context } from "@signe/di";
2
2
  import { connectionRoom } from "@signe/sync/client";
3
3
  import { RpgGui } from "../Gui/Gui";
4
4
  import { RpgClientEngine } from "../RpgClientEngine";
5
- import { AbstractWebsocket, SocketUpdateProperties, WebSocketToken } from "./AbstractSocket";
5
+ import { AbstractWebsocket, SocketQuery, SocketUpdateProperties, WebSocketToken } from "./AbstractSocket";
6
6
  import { UpdateMapService, UpdateMapToken } from "@rpgjs/common";
7
7
  import { provideKeyboardControls } from "./keyboardControls";
8
8
  import { provideSaveClient } from "./save";
9
+ import { isNativeSocketEvent, waitForRpgjsConnected } from "./mmorpg-connection";
9
10
 
10
- interface MmorpgOptions {
11
+ export interface MmorpgOptions {
11
12
  host?: string;
12
13
  connectionId?: string;
13
14
  connectionIdScope?: "local" | "session" | "ephemeral";
15
+ query?: SocketQuery | (() => SocketQuery | undefined);
16
+ socketOptions?: Record<string, any>;
14
17
  }
15
18
 
16
- class BridgeWebsocket extends AbstractWebsocket {
19
+ export class BridgeWebsocket extends AbstractWebsocket {
17
20
  private socket: any;
18
21
  private privateId: string;
19
22
  private pendingOn: Array<{ event: string; callback: (data: any) => void }> = [];
23
+ private acceptedOpenListeners = new Set<(data: any) => void>();
20
24
  private targetRoom = "lobby-1";
21
25
 
22
26
  constructor(protected context: Context, private options: MmorpgOptions = {}) {
@@ -51,6 +55,14 @@ class BridgeWebsocket extends AbstractWebsocket {
51
55
  return id;
52
56
  }
53
57
 
58
+ private resolveQuery(): SocketQuery {
59
+ const query = typeof this.options.query === "function"
60
+ ? this.options.query()
61
+ : this.options.query;
62
+
63
+ return query ?? {};
64
+ }
65
+
54
66
  async connection(listeners?: (data: any) => void) {
55
67
  // tmp
56
68
  class Room {
@@ -59,17 +71,28 @@ class BridgeWebsocket extends AbstractWebsocket {
59
71
  const instance = new Room()
60
72
  const host = this.options.host || window.location.host;
61
73
  this.socket = await connectionRoom({
74
+ maxRetries: 0,
75
+ ...this.options.socketOptions,
62
76
  host,
63
77
  room: this.targetRoom,
64
78
  id: this.privateId,
65
79
  query: {
80
+ ...this.resolveQuery(),
66
81
  id: this.privateId,
67
82
  },
68
83
  }, instance)
69
84
 
70
- listeners?.(this.socket)
71
- this.pendingOn.forEach(({ event, callback }) => this.socket.on(event, callback));
85
+ const pendingOn = this.pendingOn;
72
86
  this.pendingOn = [];
87
+ pendingOn
88
+ .filter(({ event }) => !this.isNativeSocketEvent(event))
89
+ .forEach(({ event, callback }) => this.attachEvent(event, callback));
90
+ await waitForRpgjsConnected(this.socket.conn);
91
+ pendingOn
92
+ .filter(({ event }) => this.isNativeSocketEvent(event))
93
+ .forEach(({ event, callback }) => this.attachEvent(event, callback));
94
+ this.emitAcceptedOpen();
95
+ listeners?.(this.socket)
73
96
  }
74
97
 
75
98
  on(key: string, callback: (data: any) => void) {
@@ -77,11 +100,19 @@ class BridgeWebsocket extends AbstractWebsocket {
77
100
  this.pendingOn.push({ event: key, callback });
78
101
  return;
79
102
  }
80
- this.socket.on(key, callback);
103
+ this.attachEvent(key, callback);
81
104
  }
82
105
 
83
106
  off(event: string, callback: (data: any) => void) {
84
107
  if (!this.socket) return;
108
+ if (event === "open") {
109
+ this.acceptedOpenListeners.delete(callback);
110
+ return;
111
+ }
112
+ if (this.isNativeSocketEvent(event)) {
113
+ this.socket.conn.removeEventListener(event, callback);
114
+ return;
115
+ }
85
116
  this.socket.off(event, callback);
86
117
  }
87
118
 
@@ -89,6 +120,23 @@ class BridgeWebsocket extends AbstractWebsocket {
89
120
  this.socket.emit(event, data);
90
121
  }
91
122
 
123
+ private attachEvent(event: string, callback: (data: any) => void) {
124
+ if (event === "open") {
125
+ this.acceptedOpenListeners.add(callback);
126
+ return;
127
+ }
128
+ if (this.isNativeSocketEvent(event)) {
129
+ this.socket.conn.addEventListener(event, callback);
130
+ return;
131
+ }
132
+ this.socket.on(event, callback);
133
+ }
134
+
135
+ private emitAcceptedOpen() {
136
+ const event = new Event("open");
137
+ this.acceptedOpenListeners.forEach((callback) => callback(event));
138
+ }
139
+
92
140
  updateProperties({ room, host, query }: SocketUpdateProperties) {
93
141
  if (!this.socket?.conn) return;
94
142
  this.targetRoom = room;
@@ -97,46 +145,24 @@ class BridgeWebsocket extends AbstractWebsocket {
97
145
  id: this.privateId,
98
146
  host: host || this.options.host || window.location.host,
99
147
  query: {
148
+ ...this.resolveQuery(),
100
149
  ...query,
101
150
  id: this.privateId,
102
151
  },
103
152
  })
104
153
  }
105
154
 
106
- private waitForNextOpen(conn: any, timeoutMs = 10000): Promise<void> {
107
- return new Promise((resolve, reject) => {
108
- let timeoutId: number | undefined;
109
- const onOpen = () => {
110
- cleanup();
111
- resolve();
112
- };
113
- const onError = () => {
114
- cleanup();
115
- reject(new Error("WebSocket reconnect failed"));
116
- };
117
- const cleanup = () => {
118
- conn.removeEventListener("open", onOpen);
119
- conn.removeEventListener("error", onError);
120
- if (timeoutId !== undefined) {
121
- window.clearTimeout(timeoutId);
122
- }
123
- };
124
-
125
- conn.addEventListener("open", onOpen);
126
- conn.addEventListener("error", onError);
127
- timeoutId = window.setTimeout(() => {
128
- cleanup();
129
- reject(new Error("WebSocket reconnect timeout"));
130
- }, timeoutMs);
131
- });
155
+ private isNativeSocketEvent(event: string) {
156
+ return isNativeSocketEvent(event);
132
157
  }
133
158
 
134
159
  async reconnect(_listeners?: (data: any) => void): Promise<void> {
135
160
  if (!this.socket?.conn) return;
136
161
  const conn = this.socket.conn;
137
- const opened = this.waitForNextOpen(conn);
162
+ const connected = waitForRpgjsConnected(conn, 10000, { ignoreCleanClose: true });
138
163
  conn.reconnect();
139
- await opened;
164
+ await connected;
165
+ this.emitAcceptedOpen();
140
166
  }
141
167
 
142
168
  getCurrentRoom(): string {
@@ -0,0 +1,36 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { createClientPointerContext } from "./pointerContext";
3
+
4
+ describe("createClientPointerContext", () => {
5
+ test("returns null before any pointer event", () => {
6
+ const pointer = createClientPointerContext();
7
+
8
+ expect(pointer.screen()).toBeNull();
9
+ expect(pointer.world()).toBeNull();
10
+ });
11
+
12
+ test("stores screen and world coordinates from pointer events", () => {
13
+ const pointer = createClientPointerContext();
14
+
15
+ const world = pointer.updateFromEvent({
16
+ global: { x: 120, y: 80 },
17
+ currentTarget: {
18
+ toLocal(point: { x: number; y: number }) {
19
+ return { x: point.x + 10, y: point.y + 20 };
20
+ },
21
+ },
22
+ });
23
+
24
+ expect(world).toEqual({ x: 130, y: 100 });
25
+ expect(pointer.screen()).toEqual({ x: 120, y: 80 });
26
+ expect(pointer.world()).toEqual({ x: 130, y: 100 });
27
+ });
28
+
29
+ test("ignores pointer events without usable coordinates", () => {
30
+ const pointer = createClientPointerContext();
31
+
32
+ expect(pointer.updateFromEvent({ global: { x: Number.NaN, y: 80 } })).toBeNull();
33
+ expect(pointer.screen()).toBeNull();
34
+ expect(pointer.world()).toBeNull();
35
+ });
36
+ });
@@ -0,0 +1,84 @@
1
+ export interface ClientPointerPosition {
2
+ x: number;
3
+ y: number;
4
+ }
5
+
6
+ export interface ClientPointerContext {
7
+ screen(): ClientPointerPosition | null;
8
+ world(): ClientPointerPosition | null;
9
+ update(screen: ClientPointerPosition | null, world?: ClientPointerPosition | null): ClientPointerPosition | null;
10
+ updateFromEvent(event: any): ClientPointerPosition | null;
11
+ }
12
+
13
+ function toPosition(point: any): ClientPointerPosition | null {
14
+ const x = Number(point?.x);
15
+ const y = Number(point?.y);
16
+
17
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
18
+ return null;
19
+ }
20
+
21
+ return { x, y };
22
+ }
23
+
24
+ function extractGlobalPoint(event: any): ClientPointerPosition | null {
25
+ return toPosition(event?.global)
26
+ ?? toPosition(event?.data?.global)
27
+ ?? toPosition(event);
28
+ }
29
+
30
+ function extractWorldPoint(event: any, global: ClientPointerPosition | null): ClientPointerPosition | null {
31
+ const target = event?.currentTarget ?? event?.target;
32
+
33
+ if (target && global && typeof target.toLocal === "function") {
34
+ return toPosition(target.toLocal(global));
35
+ }
36
+
37
+ if (typeof event?.getLocalPosition === "function") {
38
+ return toPosition(event.getLocalPosition(target));
39
+ }
40
+
41
+ if (typeof event?.data?.getLocalPosition === "function") {
42
+ return toPosition(event.data.getLocalPosition(target));
43
+ }
44
+
45
+ return global;
46
+ }
47
+
48
+ export function createClientPointerContext(): ClientPointerContext {
49
+ let lastScreen: ClientPointerPosition | null = null;
50
+ let lastWorld: ClientPointerPosition | null = null;
51
+
52
+ const update = (screen: ClientPointerPosition | null, world?: ClientPointerPosition | null) => {
53
+ const nextScreen = toPosition(screen);
54
+ const nextWorld = toPosition(world) ?? nextScreen;
55
+
56
+ if (nextScreen) {
57
+ lastScreen = nextScreen;
58
+ }
59
+
60
+ if (nextWorld) {
61
+ lastWorld = nextWorld;
62
+ }
63
+
64
+ return lastWorld ? { ...lastWorld } : null;
65
+ };
66
+
67
+ return {
68
+ screen() {
69
+ return lastScreen ? { ...lastScreen } : null;
70
+ },
71
+
72
+ world() {
73
+ return lastWorld ? { ...lastWorld } : null;
74
+ },
75
+
76
+ update,
77
+
78
+ updateFromEvent(event: any) {
79
+ const global = extractGlobalPoint(event);
80
+ const world = extractWorldPoint(event, global);
81
+ return update(global, world);
82
+ },
83
+ };
84
+ }
@@ -0,0 +1,7 @@
1
+ export function normalizeStandaloneMessage(event: unknown): any {
2
+ const raw = event && typeof event === "object" && "data" in event
3
+ ? (event as MessageEvent).data
4
+ : event;
5
+
6
+ return typeof raw === "string" ? JSON.parse(raw) : raw;
7
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test, vi } from "vitest";
2
+ import { normalizeStandaloneMessage } from "./standalone-message";
3
+
4
+ describe("standalone websocket bridge", () => {
5
+ test("dispatches mock room object broadcasts to named listeners", () => {
6
+ const onSpawn = vi.fn();
7
+ const object = normalizeStandaloneMessage({
8
+ type: "projectile:spawnBatch",
9
+ value: {
10
+ projectiles: [{ id: "p1", type: "bolt" }],
11
+ },
12
+ });
13
+
14
+ if (object.type === "projectile:spawnBatch") {
15
+ onSpawn(object.value);
16
+ }
17
+
18
+ expect(onSpawn).toHaveBeenCalledWith({
19
+ projectiles: [{ id: "p1", type: "bolt" }],
20
+ });
21
+ });
22
+
23
+ test("still accepts browser-style string messages", () => {
24
+ expect(normalizeStandaloneMessage({
25
+ data: JSON.stringify({
26
+ type: "projectile:spawnBatch",
27
+ value: { projectiles: [] },
28
+ }),
29
+ })).toEqual({
30
+ type: "projectile:spawnBatch",
31
+ value: { projectiles: [] },
32
+ });
33
+ });
34
+ });
@@ -7,6 +7,7 @@ import { LoadMapToken } from "./loadMap";
7
7
  import { RpgGui } from "../Gui/Gui";
8
8
  import { provideKeyboardControls } from "./keyboardControls";
9
9
  import { provideSaveClient } from "./save";
10
+ import { normalizeStandaloneMessage } from "./standalone-message";
10
11
 
11
12
  type ServerIo = any;
12
13
  type ClientIo = any;
@@ -18,7 +19,12 @@ interface StandaloneOptions {
18
19
  class BridgeWebsocket extends AbstractWebsocket {
19
20
  private room: ServerIo;
20
21
  private socket: ClientIo;
21
- private pendingOn: Array<{ event: string; callback: (data: any) => void }> = [];
22
+ private socketRoom?: ServerIo;
23
+ private listeners: Array<{
24
+ event: string;
25
+ callback: (data: any) => void;
26
+ handler: (event: any) => void;
27
+ }> = [];
22
28
  private rooms = {
23
29
  partyFn: async (roomId: string) => {
24
30
  this.room = new ServerIo(roomId, this.rooms);
@@ -48,6 +54,7 @@ class BridgeWebsocket extends AbstractWebsocket {
48
54
  }
49
55
 
50
56
  private async _connection(listeners?: (data: any) => void) {
57
+ this.detachCurrentSocket();
51
58
  this.serverInstance = this.context.get('server')
52
59
  this.socket = new ClientIo(this.serverInstance, 'player-client-id');
53
60
  const url = new URL('http://localhost')
@@ -58,30 +65,43 @@ class BridgeWebsocket extends AbstractWebsocket {
58
65
  }
59
66
  })
60
67
  listeners?.(this.socket)
61
- await this.serverInstance.onConnect(this.socket.conn as any, { request } as any);
62
68
  this.room.clients.set(this.socket.id, this.socket);
63
- this.pendingOn.forEach(({ event, callback }) => this.socket.addEventListener(event, callback));
64
- this.pendingOn = [];
69
+ this.socketRoom = this.room;
70
+ this.listeners.forEach(({ handler }) => {
71
+ this.socket.addEventListener("message", handler);
72
+ });
73
+ await this.serverInstance.onConnect(this.socket.conn as any, { request } as any);
65
74
  return this.socket
66
75
  }
67
76
 
68
77
  on(key: string, callback: (data: any) => void) {
78
+ if (
79
+ this.listeners.some(
80
+ (listener) => listener.event === key && listener.callback === callback
81
+ )
82
+ ) {
83
+ return;
84
+ }
69
85
  const handler = (event) => {
70
- const object = JSON.parse(event);
86
+ const object = normalizeStandaloneMessage(event);
71
87
  if (object.type === key) {
72
88
  callback(object.value);
73
89
  }
74
90
  };
75
- if (!this.socket) {
76
- this.pendingOn.push({ event: "message", callback: handler });
77
- return;
78
- }
79
- this.socket.addEventListener("message", handler);
91
+ this.listeners.push({ event: key, callback, handler });
92
+ this.socket?.addEventListener("message", handler);
80
93
  }
81
94
 
82
95
  off(event: string, callback: (data: any) => void) {
83
- if (!this.socket) return;
84
- this.socket.removeEventListener(event, callback);
96
+ const remaining: typeof this.listeners = [];
97
+ for (const listener of this.listeners) {
98
+ if (listener.event === event && listener.callback === callback) {
99
+ this.socket?.removeEventListener("message", listener.handler);
100
+ continue;
101
+ }
102
+ remaining.push(listener);
103
+ }
104
+ this.listeners = remaining;
85
105
  }
86
106
 
87
107
  emit(event: string, data: any) {
@@ -134,6 +154,16 @@ class BridgeWebsocket extends AbstractWebsocket {
134
154
  })
135
155
  }
136
156
 
157
+ private detachCurrentSocket() {
158
+ if (!this.socket) return;
159
+ this.listeners.forEach(({ handler }) => {
160
+ this.socket.removeEventListener("message", handler);
161
+ });
162
+ this.socketRoom?.clients?.delete?.(this.socket.id);
163
+ this.socket = undefined as any;
164
+ this.socketRoom = undefined;
165
+ }
166
+
137
167
  getServer() {
138
168
  return this.serverInstance
139
169
  }
@@ -0,0 +1,2 @@
1
+ export const normalizeRoomMapId = (mapId: string | undefined): string | undefined =>
2
+ typeof mapId === "string" ? mapId.replace(/^map-/, "") : undefined;