@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.
- package/CHANGELOG.md +12 -0
- package/dist/Game/ProjectileManager.d.ts +89 -0
- package/dist/Game/ProjectileManager.js +179 -0
- package/dist/Game/ProjectileManager.js.map +1 -0
- package/dist/Game/ProjectileManager.spec.d.ts +1 -0
- package/dist/RpgClient.d.ts +53 -13
- package/dist/RpgClientEngine.d.ts +25 -4
- package/dist/RpgClientEngine.js +197 -48
- package/dist/RpgClientEngine.js.map +1 -1
- package/dist/components/animations/hit.ce.js.map +1 -1
- package/dist/components/character.ce.js +32 -30
- package/dist/components/character.ce.js.map +1 -1
- package/dist/components/dynamics/bar.ce.js +4 -3
- package/dist/components/dynamics/bar.ce.js.map +1 -1
- package/dist/components/dynamics/image.ce.js +2 -1
- package/dist/components/dynamics/image.ce.js.map +1 -1
- package/dist/components/dynamics/shape.ce.js +3 -2
- package/dist/components/dynamics/shape.ce.js.map +1 -1
- package/dist/components/dynamics/text.ce.js +9 -8
- package/dist/components/dynamics/text.ce.js.map +1 -1
- package/dist/components/gui/dialogbox/index.ce.js +3 -2
- package/dist/components/gui/dialogbox/index.ce.js.map +1 -1
- package/dist/components/gui/gameover.ce.js +3 -2
- package/dist/components/gui/gameover.ce.js.map +1 -1
- package/dist/components/gui/hud/hud.ce.js.map +1 -1
- package/dist/components/gui/menu/equip-menu.ce.js +2 -1
- package/dist/components/gui/menu/equip-menu.ce.js.map +1 -1
- package/dist/components/gui/menu/exit-menu.ce.js +2 -1
- package/dist/components/gui/menu/exit-menu.ce.js.map +1 -1
- package/dist/components/gui/menu/items-menu.ce.js +3 -2
- package/dist/components/gui/menu/items-menu.ce.js.map +1 -1
- package/dist/components/gui/menu/main-menu.ce.js +3 -2
- package/dist/components/gui/menu/main-menu.ce.js.map +1 -1
- package/dist/components/gui/menu/options-menu.ce.js.map +1 -1
- package/dist/components/gui/menu/skills-menu.ce.js.map +1 -1
- package/dist/components/gui/mobile/mobile.ce.js.map +1 -1
- package/dist/components/gui/notification/notification.ce.js.map +1 -1
- package/dist/components/gui/save-load.ce.js +2 -1
- package/dist/components/gui/save-load.ce.js.map +1 -1
- package/dist/components/gui/shop/shop.ce.js +3 -2
- package/dist/components/gui/shop/shop.ce.js.map +1 -1
- package/dist/components/gui/title-screen.ce.js +3 -2
- package/dist/components/gui/title-screen.ce.js.map +1 -1
- package/dist/components/index.d.ts +2 -1
- package/dist/components/index.js +1 -0
- package/dist/components/player-components.ce.js +11 -10
- package/dist/components/player-components.ce.js.map +1 -1
- package/dist/components/prebuilt/hp-bar.ce.js +4 -3
- package/dist/components/prebuilt/hp-bar.ce.js.map +1 -1
- package/dist/components/prebuilt/light-halo.ce.js +2 -1
- package/dist/components/prebuilt/light-halo.ce.js.map +1 -1
- package/dist/components/scenes/canvas.ce.js +12 -4
- package/dist/components/scenes/canvas.ce.js.map +1 -1
- package/dist/components/scenes/draw-map.ce.js +6 -3
- package/dist/components/scenes/draw-map.ce.js.map +1 -1
- package/dist/components/scenes/event-layer.ce.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +9 -5
- package/dist/module.js +11 -0
- package/dist/module.js.map +1 -1
- package/dist/services/actionInput.d.ts +12 -0
- package/dist/services/actionInput.js +27 -0
- package/dist/services/actionInput.js.map +1 -0
- package/dist/services/actionInput.spec.d.ts +1 -0
- package/dist/services/mmorpg-connection.d.ts +5 -0
- package/dist/services/mmorpg-connection.js +50 -0
- package/dist/services/mmorpg-connection.js.map +1 -0
- package/dist/services/mmorpg-connection.spec.d.ts +1 -0
- package/dist/services/mmorpg.d.ts +10 -4
- package/dist/services/mmorpg.js +48 -30
- package/dist/services/mmorpg.js.map +1 -1
- package/dist/services/pointerContext.d.ts +11 -0
- package/dist/services/pointerContext.js +48 -0
- package/dist/services/pointerContext.js.map +1 -0
- package/dist/services/pointerContext.spec.d.ts +1 -0
- package/dist/services/standalone-message.d.ts +1 -0
- package/dist/services/standalone-message.js +9 -0
- package/dist/services/standalone-message.js.map +1 -0
- package/dist/services/standalone.js +3 -2
- package/dist/services/standalone.js.map +1 -1
- package/dist/services/standalone.spec.d.ts +1 -0
- package/package.json +7 -7
- package/src/Game/ProjectileManager.spec.ts +338 -0
- package/src/Game/ProjectileManager.ts +324 -0
- package/src/RpgClient.ts +62 -15
- package/src/RpgClientEngine.ts +287 -65
- package/src/components/character.ce +34 -32
- package/src/components/dynamics/bar.ce +4 -3
- package/src/components/dynamics/image.ce +2 -1
- package/src/components/dynamics/shape.ce +3 -2
- package/src/components/dynamics/text.ce +9 -8
- package/src/components/gui/dialogbox/index.ce +3 -2
- package/src/components/gui/gameover.ce +2 -1
- package/src/components/gui/menu/equip-menu.ce +2 -1
- package/src/components/gui/menu/exit-menu.ce +2 -1
- package/src/components/gui/menu/items-menu.ce +3 -2
- package/src/components/gui/menu/main-menu.ce +2 -1
- package/src/components/gui/save-load.ce +2 -1
- package/src/components/gui/shop/shop.ce +3 -2
- package/src/components/gui/title-screen.ce +2 -1
- package/src/components/index.ts +2 -1
- package/src/components/player-components.ce +11 -10
- package/src/components/prebuilt/hp-bar.ce +4 -3
- package/src/components/prebuilt/light-halo.ce +2 -2
- package/src/components/scenes/canvas.ce +10 -2
- package/src/components/scenes/draw-map.ce +17 -3
- package/src/index.ts +3 -0
- package/src/module.ts +13 -0
- package/src/services/actionInput.spec.ts +101 -0
- package/src/services/actionInput.ts +53 -0
- package/src/services/mmorpg-connection.spec.ts +99 -0
- package/src/services/mmorpg-connection.ts +69 -0
- package/src/services/mmorpg.ts +60 -34
- package/src/services/pointerContext.spec.ts +36 -0
- package/src/services/pointerContext.ts +84 -0
- package/src/services/standalone-message.ts +7 -0
- package/src/services/standalone.spec.ts +34 -0
- 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
|
|
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
|
-
|
|
5
|
-
|
|
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
|
+
}
|
package/src/services/mmorpg.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
107
|
-
return
|
|
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
|
|
162
|
+
const connected = waitForRpgjsConnected(conn, 10000, { ignoreCleanClose: true });
|
|
138
163
|
conn.reconnect();
|
|
139
|
-
await
|
|
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
|
+
});
|