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

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 (118) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/Game/ProjectileManager.d.ts +89 -0
  3. package/dist/Game/ProjectileManager.js +179 -0
  4. package/dist/Game/ProjectileManager.js.map +1 -0
  5. package/dist/Game/ProjectileManager.spec.d.ts +1 -0
  6. package/dist/RpgClient.d.ts +53 -13
  7. package/dist/RpgClientEngine.d.ts +25 -4
  8. package/dist/RpgClientEngine.js +197 -48
  9. package/dist/RpgClientEngine.js.map +1 -1
  10. package/dist/components/animations/hit.ce.js.map +1 -1
  11. package/dist/components/character.ce.js +32 -30
  12. package/dist/components/character.ce.js.map +1 -1
  13. package/dist/components/dynamics/bar.ce.js +4 -3
  14. package/dist/components/dynamics/bar.ce.js.map +1 -1
  15. package/dist/components/dynamics/image.ce.js +2 -1
  16. package/dist/components/dynamics/image.ce.js.map +1 -1
  17. package/dist/components/dynamics/shape.ce.js +3 -2
  18. package/dist/components/dynamics/shape.ce.js.map +1 -1
  19. package/dist/components/dynamics/text.ce.js +9 -8
  20. package/dist/components/dynamics/text.ce.js.map +1 -1
  21. package/dist/components/gui/dialogbox/index.ce.js +3 -2
  22. package/dist/components/gui/dialogbox/index.ce.js.map +1 -1
  23. package/dist/components/gui/gameover.ce.js +3 -2
  24. package/dist/components/gui/gameover.ce.js.map +1 -1
  25. package/dist/components/gui/hud/hud.ce.js.map +1 -1
  26. package/dist/components/gui/menu/equip-menu.ce.js +2 -1
  27. package/dist/components/gui/menu/equip-menu.ce.js.map +1 -1
  28. package/dist/components/gui/menu/exit-menu.ce.js +2 -1
  29. package/dist/components/gui/menu/exit-menu.ce.js.map +1 -1
  30. package/dist/components/gui/menu/items-menu.ce.js +3 -2
  31. package/dist/components/gui/menu/items-menu.ce.js.map +1 -1
  32. package/dist/components/gui/menu/main-menu.ce.js +3 -2
  33. package/dist/components/gui/menu/main-menu.ce.js.map +1 -1
  34. package/dist/components/gui/menu/options-menu.ce.js.map +1 -1
  35. package/dist/components/gui/menu/skills-menu.ce.js.map +1 -1
  36. package/dist/components/gui/mobile/mobile.ce.js.map +1 -1
  37. package/dist/components/gui/notification/notification.ce.js.map +1 -1
  38. package/dist/components/gui/save-load.ce.js +2 -1
  39. package/dist/components/gui/save-load.ce.js.map +1 -1
  40. package/dist/components/gui/shop/shop.ce.js +3 -2
  41. package/dist/components/gui/shop/shop.ce.js.map +1 -1
  42. package/dist/components/gui/title-screen.ce.js +3 -2
  43. package/dist/components/gui/title-screen.ce.js.map +1 -1
  44. package/dist/components/index.d.ts +2 -1
  45. package/dist/components/index.js +1 -0
  46. package/dist/components/player-components.ce.js +11 -10
  47. package/dist/components/player-components.ce.js.map +1 -1
  48. package/dist/components/prebuilt/hp-bar.ce.js +4 -3
  49. package/dist/components/prebuilt/hp-bar.ce.js.map +1 -1
  50. package/dist/components/prebuilt/light-halo.ce.js +2 -1
  51. package/dist/components/prebuilt/light-halo.ce.js.map +1 -1
  52. package/dist/components/scenes/canvas.ce.js +12 -4
  53. package/dist/components/scenes/canvas.ce.js.map +1 -1
  54. package/dist/components/scenes/draw-map.ce.js +6 -3
  55. package/dist/components/scenes/draw-map.ce.js.map +1 -1
  56. package/dist/components/scenes/event-layer.ce.js.map +1 -1
  57. package/dist/index.d.ts +3 -0
  58. package/dist/index.js +9 -5
  59. package/dist/module.js +11 -0
  60. package/dist/module.js.map +1 -1
  61. package/dist/services/actionInput.d.ts +12 -0
  62. package/dist/services/actionInput.js +27 -0
  63. package/dist/services/actionInput.js.map +1 -0
  64. package/dist/services/actionInput.spec.d.ts +1 -0
  65. package/dist/services/mmorpg-connection.d.ts +5 -0
  66. package/dist/services/mmorpg-connection.js +50 -0
  67. package/dist/services/mmorpg-connection.js.map +1 -0
  68. package/dist/services/mmorpg-connection.spec.d.ts +1 -0
  69. package/dist/services/mmorpg.d.ts +10 -4
  70. package/dist/services/mmorpg.js +48 -30
  71. package/dist/services/mmorpg.js.map +1 -1
  72. package/dist/services/pointerContext.d.ts +11 -0
  73. package/dist/services/pointerContext.js +48 -0
  74. package/dist/services/pointerContext.js.map +1 -0
  75. package/dist/services/pointerContext.spec.d.ts +1 -0
  76. package/dist/services/standalone-message.d.ts +1 -0
  77. package/dist/services/standalone-message.js +9 -0
  78. package/dist/services/standalone-message.js.map +1 -0
  79. package/dist/services/standalone.js +3 -2
  80. package/dist/services/standalone.js.map +1 -1
  81. package/dist/services/standalone.spec.d.ts +1 -0
  82. package/package.json +7 -7
  83. package/src/Game/ProjectileManager.spec.ts +338 -0
  84. package/src/Game/ProjectileManager.ts +324 -0
  85. package/src/RpgClient.ts +62 -15
  86. package/src/RpgClientEngine.ts +287 -65
  87. package/src/components/character.ce +34 -32
  88. package/src/components/dynamics/bar.ce +4 -3
  89. package/src/components/dynamics/image.ce +2 -1
  90. package/src/components/dynamics/shape.ce +3 -2
  91. package/src/components/dynamics/text.ce +9 -8
  92. package/src/components/gui/dialogbox/index.ce +3 -2
  93. package/src/components/gui/gameover.ce +2 -1
  94. package/src/components/gui/menu/equip-menu.ce +2 -1
  95. package/src/components/gui/menu/exit-menu.ce +2 -1
  96. package/src/components/gui/menu/items-menu.ce +3 -2
  97. package/src/components/gui/menu/main-menu.ce +2 -1
  98. package/src/components/gui/save-load.ce +2 -1
  99. package/src/components/gui/shop/shop.ce +3 -2
  100. package/src/components/gui/title-screen.ce +2 -1
  101. package/src/components/index.ts +2 -1
  102. package/src/components/player-components.ce +11 -10
  103. package/src/components/prebuilt/hp-bar.ce +4 -3
  104. package/src/components/prebuilt/light-halo.ce +2 -2
  105. package/src/components/scenes/canvas.ce +10 -2
  106. package/src/components/scenes/draw-map.ce +17 -3
  107. package/src/index.ts +3 -0
  108. package/src/module.ts +13 -0
  109. package/src/services/actionInput.spec.ts +101 -0
  110. package/src/services/actionInput.ts +53 -0
  111. package/src/services/mmorpg-connection.spec.ts +99 -0
  112. package/src/services/mmorpg-connection.ts +69 -0
  113. package/src/services/mmorpg.ts +60 -34
  114. package/src/services/pointerContext.spec.ts +36 -0
  115. package/src/services/pointerContext.ts +84 -0
  116. package/src/services/standalone-message.ts +7 -0
  117. package/src/services/standalone.spec.ts +34 -0
  118. package/src/services/standalone.ts +3 -2
@@ -23,7 +23,15 @@
23
23
  <SceneMap />
24
24
  </Viewport>
25
25
  @for (gui of guiList) {
26
- <Container display="flex">
26
+ <Container
27
+ positionType="absolute"
28
+ top={0}
29
+ left={0}
30
+ right={0}
31
+ bottom={0}
32
+ width={engine.width}
33
+ height={engine.height}
34
+ >
27
35
  @if (gui.display) {
28
36
  <gui.component data={gui.data} dependencies={gui.dependencies} onFinish={(data) => {
29
37
  onGuiFinish(gui, data)
@@ -39,12 +47,12 @@
39
47
  import { computed, effect } from "canvasengine";
40
48
  import { inject } from "../../core/inject";
41
49
  import { RpgClientEngine } from "../../RpgClientEngine";
42
- import SceneMap from './draw-map.ce'
43
50
  import { RpgGui } from "../../Gui/Gui";
44
51
  import { delay } from "@rpgjs/common";
45
52
  import { NightAmbiant, SpriteShadows } from '@canvasengine/presets'
46
53
 
47
54
  const engine = inject(RpgClientEngine);
55
+ const SceneMap = engine.sceneMapComponent;
48
56
  const guiService = inject(RpgGui);
49
57
  const sceneData = engine.sceneMap.data
50
58
  const lighting = engine.sceneMap.lighting
@@ -1,8 +1,14 @@
1
1
  <Container sound={backgroundMusic} shake={shakeConfig} freeze={engine.gamePause}>
2
2
  <Container sound={backgroundAmbientSound} />
3
3
 
4
- @if (map() && sceneComponent()) {
5
- <sceneComponent() data={map().data} params={map().params} />
4
+ <Container>
5
+ @if (map() && sceneComponent()) {
6
+ <sceneComponent() data={map().data} params={map().params} />
7
+ }
8
+ </Container>
9
+
10
+ @for (child of children) {
11
+ <child />
6
12
  }
7
13
 
8
14
  @for (componentAnimation of componentAnimations) {
@@ -13,19 +19,27 @@
13
19
  </Container>
14
20
  }
15
21
 
22
+ <Container sortableChildren={true}>
23
+ @for (projectile of projectiles() ; track projectile.props.id) {
24
+ <projectile.component ...projectile.props />
25
+ }
26
+ </Container>
27
+
16
28
  @if (weatherProps()) {
17
29
  <Weather ...weatherProps() />
18
30
  }
19
31
  </Container>
20
32
 
21
33
  <script>
22
- import { computed } from 'canvasengine'
34
+ import { computed, effect } from 'canvasengine'
23
35
  import { inject } from "../../core/inject";
24
36
  import { RpgClientEngine } from "../../RpgClientEngine";
25
37
  import { Weather } from '@canvasengine/presets'
26
38
 
39
+ const { children } = defineProps()
27
40
  const engine = inject(RpgClientEngine);
28
41
  const componentAnimations = engine.componentAnimations
42
+ const projectiles = engine.projectiles.current
29
43
  const map = engine.sceneMap?.data
30
44
  const sceneComponent = computed(() => map()?.component)
31
45
  const mapParams = map()?.params
package/src/index.ts CHANGED
@@ -6,6 +6,8 @@ export * from "./services/save";
6
6
  export * from "./core/setup";
7
7
  export * from "./core/inject";
8
8
  export * from "./services/loadMap";
9
+ export * from "./services/actionInput";
10
+ export * from "./services/pointerContext";
9
11
  export * from "./module";
10
12
  export * from "./Gui/Gui";
11
13
  export * from "./components/gui";
@@ -24,5 +26,6 @@ export { Control } from "./services/keyboardControls";
24
26
  export { RpgClientObject } from "./Game/Object";
25
27
  export { RpgClientPlayer } from "./Game/Player";
26
28
  export { RpgClientEvent } from "./Game/Event";
29
+ export * from "./Game/ProjectileManager";
27
30
  export { withMobile } from "./components/gui/mobile";
28
31
  export * from "./services/AbstractSocket";
package/src/module.ts CHANGED
@@ -155,6 +155,19 @@ export function provideClientModules(modules: RpgClientModule[]): FactoryProvide
155
155
  },
156
156
  };
157
157
  }
158
+ if (module.projectiles) {
159
+ const projectiles = { ...module.projectiles };
160
+ module.projectiles = {
161
+ ...projectiles,
162
+ load: (engine: RpgClientEngine) => {
163
+ if (projectiles.components) {
164
+ Object.entries(projectiles.components).forEach(([type, component]) => {
165
+ engine.registerProjectileComponent(type, component);
166
+ });
167
+ }
168
+ },
169
+ };
170
+ }
158
171
  if (module.transitions) {
159
172
  const transitions = [...module.transitions];
160
173
  module.transitions = {
@@ -0,0 +1,101 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import {
3
+ getKeyboardControlBind,
4
+ normalizeActionInput,
5
+ resolveKeyboardActionInput,
6
+ } from "./actionInput";
7
+
8
+ describe("normalizeActionInput", () => {
9
+ test("keeps simple actions compatible", () => {
10
+ expect(normalizeActionInput("action")).toEqual({
11
+ action: "action",
12
+ });
13
+ });
14
+
15
+ test("adds custom data to action payloads", () => {
16
+ expect(normalizeActionInput("projectile:shoot", {
17
+ target: { x: 320, y: 180 },
18
+ source: "map-click",
19
+ })).toEqual({
20
+ action: "projectile:shoot",
21
+ data: {
22
+ target: { x: 320, y: 180 },
23
+ source: "map-click",
24
+ },
25
+ });
26
+ });
27
+
28
+ test("keeps object-form action payloads intact", () => {
29
+ const payload = {
30
+ action: "projectile:shoot",
31
+ data: { target: { x: 64, y: 96 } },
32
+ };
33
+
34
+ expect(normalizeActionInput(payload)).toBe(payload);
35
+ });
36
+ });
37
+
38
+ describe("keyboard action controls", () => {
39
+ test("keeps string controls compatible", () => {
40
+ expect(getKeyboardControlBind("space")).toBe("space");
41
+ expect(resolveKeyboardActionInput("space", {}, {})).toEqual({
42
+ action: "action",
43
+ });
44
+ });
45
+
46
+ test("resolves object controls with static data", () => {
47
+ const control = {
48
+ bind: "space",
49
+ action: "projectile:shoot",
50
+ data: {
51
+ source: "keyboard",
52
+ target: { x: 10, y: 20 },
53
+ },
54
+ };
55
+
56
+ expect(getKeyboardControlBind(control)).toBe("space");
57
+ expect(resolveKeyboardActionInput(control, {}, {})).toEqual({
58
+ action: "projectile:shoot",
59
+ data: {
60
+ source: "keyboard",
61
+ target: { x: 10, y: 20 },
62
+ },
63
+ });
64
+ });
65
+
66
+ test("resolves object controls with functional data", () => {
67
+ const client = {
68
+ pointer: {
69
+ world: () => ({ x: 64, y: 96 }),
70
+ },
71
+ };
72
+ const sprite = { id: "player-1" };
73
+ const control = {
74
+ bind: "space",
75
+ action: "projectile:shoot",
76
+ data: (resolvedClient: typeof client, resolvedSprite: typeof sprite) => ({
77
+ source: "keyboard",
78
+ target: resolvedClient.pointer.world(),
79
+ playerId: resolvedSprite.id,
80
+ }),
81
+ };
82
+
83
+ expect(resolveKeyboardActionInput(control, client, sprite)).toEqual({
84
+ action: "projectile:shoot",
85
+ data: {
86
+ source: "keyboard",
87
+ target: { x: 64, y: 96 },
88
+ playerId: "player-1",
89
+ },
90
+ });
91
+ });
92
+
93
+ test("omits data when object controls do not provide it", () => {
94
+ expect(resolveKeyboardActionInput({
95
+ bind: "space",
96
+ action: "projectile:shoot",
97
+ }, {}, {})).toEqual({
98
+ action: "projectile:shoot",
99
+ });
100
+ });
101
+ });
@@ -0,0 +1,53 @@
1
+ import type { RpgActionInput, RpgActionName } from "@rpgjs/common";
2
+
3
+ export type KeyboardActionDataResolver<TClient = any, TSprite = any> = (
4
+ client: TClient,
5
+ sprite: TSprite,
6
+ ) => any;
7
+
8
+ export interface KeyboardActionConfig<TClient = any, TSprite = any> {
9
+ bind: any;
10
+ action?: RpgActionName;
11
+ data?: any | KeyboardActionDataResolver<TClient, TSprite>;
12
+ }
13
+
14
+ export function normalizeActionInput(action: RpgActionName, data?: any): RpgActionInput;
15
+ export function normalizeActionInput(action: RpgActionInput): RpgActionInput;
16
+ export function normalizeActionInput(action: RpgActionName | RpgActionInput, data?: any): RpgActionInput {
17
+ if (typeof action === "object") {
18
+ return action;
19
+ }
20
+ return data === undefined
21
+ ? { action }
22
+ : { action, data };
23
+ }
24
+
25
+ export function isKeyboardActionConfig(value: any): value is KeyboardActionConfig {
26
+ return value !== null
27
+ && typeof value === "object"
28
+ && Object.prototype.hasOwnProperty.call(value, "bind");
29
+ }
30
+
31
+ export function getKeyboardControlBind(control: any): any {
32
+ return isKeyboardActionConfig(control) ? control.bind : control;
33
+ }
34
+
35
+ export function resolveKeyboardActionInput(
36
+ control: any,
37
+ client: any,
38
+ sprite: any,
39
+ defaultAction: RpgActionName = "action",
40
+ ): RpgActionInput {
41
+ if (!isKeyboardActionConfig(control)) {
42
+ return { action: defaultAction };
43
+ }
44
+
45
+ const action = control.action ?? defaultAction;
46
+ const data = typeof control.data === "function"
47
+ ? control.data(client, sprite)
48
+ : control.data;
49
+
50
+ return data === undefined
51
+ ? { action }
52
+ : { action, data };
53
+ }
@@ -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
+ });