@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
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { Hooks } from "@rpgjs/common";
|
|
3
|
+
import { ProjectileManager } from "./ProjectileManager";
|
|
4
|
+
|
|
5
|
+
describe("ProjectileManager", () => {
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
vi.useRealTimers();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
test("renders registered projectile components from compact spawn data", () => {
|
|
11
|
+
const onSpawn = vi.fn();
|
|
12
|
+
const hooks = new Hooks([{ projectiles: { onSpawn } }], "client");
|
|
13
|
+
const manager = new ProjectileManager(hooks);
|
|
14
|
+
const component = () => null;
|
|
15
|
+
|
|
16
|
+
manager.register("fireball", component);
|
|
17
|
+
manager.spawnBatch([
|
|
18
|
+
{
|
|
19
|
+
id: "p1",
|
|
20
|
+
type: "fireball",
|
|
21
|
+
origin: { x: 10, y: 20 },
|
|
22
|
+
direction: { x: 1, y: 0 },
|
|
23
|
+
speed: 100,
|
|
24
|
+
range: 500,
|
|
25
|
+
ttl: 5,
|
|
26
|
+
spawnTick: 1,
|
|
27
|
+
},
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
const current = manager.current();
|
|
31
|
+
expect(current).toHaveLength(1);
|
|
32
|
+
expect(current[0].component).toBe(component);
|
|
33
|
+
expect(current[0].props.x).toBeGreaterThanOrEqual(10);
|
|
34
|
+
expect(current[0].props.angle).toBe(0);
|
|
35
|
+
expect(onSpawn).toHaveBeenCalledWith(expect.objectContaining({ id: "p1", type: "fireball" }));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("starts visuals at the spawn origin even when a server tick estimate exists", () => {
|
|
39
|
+
vi.useFakeTimers();
|
|
40
|
+
vi.setSystemTime(2000);
|
|
41
|
+
|
|
42
|
+
const hooks = new Hooks([], "client");
|
|
43
|
+
const manager = new ProjectileManager(hooks);
|
|
44
|
+
manager.register("arrow", () => null);
|
|
45
|
+
manager.spawnBatch([
|
|
46
|
+
{
|
|
47
|
+
id: "p-latency",
|
|
48
|
+
type: "arrow",
|
|
49
|
+
origin: { x: 0, y: 0 },
|
|
50
|
+
direction: { x: 1, y: 0 },
|
|
51
|
+
speed: 120,
|
|
52
|
+
range: 500,
|
|
53
|
+
ttl: 5,
|
|
54
|
+
spawnTick: 10,
|
|
55
|
+
},
|
|
56
|
+
], {
|
|
57
|
+
currentServerTick: 16,
|
|
58
|
+
tickDurationMs: 1000 / 60,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const current = manager.current();
|
|
62
|
+
expect(current).toHaveLength(1);
|
|
63
|
+
expect(current[0].props.elapsed).toBeCloseTo(0, 3);
|
|
64
|
+
expect(current[0].props.x).toBeCloseTo(0, 3);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("keeps delayed projectiles until their visual delay has elapsed", () => {
|
|
68
|
+
vi.useFakeTimers();
|
|
69
|
+
vi.setSystemTime(1000);
|
|
70
|
+
|
|
71
|
+
const hooks = new Hooks([], "client");
|
|
72
|
+
const manager = new ProjectileManager(hooks);
|
|
73
|
+
manager.register("spark", () => null);
|
|
74
|
+
manager.spawnBatch([
|
|
75
|
+
{
|
|
76
|
+
id: "p-delayed",
|
|
77
|
+
type: "spark",
|
|
78
|
+
origin: { x: 0, y: 0 },
|
|
79
|
+
direction: { x: 1, y: 0 },
|
|
80
|
+
speed: 100,
|
|
81
|
+
range: 500,
|
|
82
|
+
ttl: 5,
|
|
83
|
+
spawnTick: 1,
|
|
84
|
+
delay: 0.1,
|
|
85
|
+
},
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
vi.setSystemTime(1050);
|
|
89
|
+
manager.step();
|
|
90
|
+
expect(manager.current()).toHaveLength(0);
|
|
91
|
+
|
|
92
|
+
vi.setSystemTime(1110);
|
|
93
|
+
manager.step();
|
|
94
|
+
const current = manager.current();
|
|
95
|
+
expect(current).toHaveLength(1);
|
|
96
|
+
expect(current[0].props.elapsed).toBeCloseTo(0.01, 3);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("keeps impacted projectiles briefly so components can react", () => {
|
|
100
|
+
const hooks = new Hooks([], "client");
|
|
101
|
+
const manager = new ProjectileManager(hooks);
|
|
102
|
+
manager.register("arrow", () => null);
|
|
103
|
+
manager.spawnBatch([
|
|
104
|
+
{
|
|
105
|
+
id: "p2",
|
|
106
|
+
type: "arrow",
|
|
107
|
+
origin: { x: 0, y: 0 },
|
|
108
|
+
direction: { x: 1, y: 0 },
|
|
109
|
+
speed: 100,
|
|
110
|
+
range: 500,
|
|
111
|
+
ttl: 5,
|
|
112
|
+
spawnTick: 1,
|
|
113
|
+
},
|
|
114
|
+
]);
|
|
115
|
+
|
|
116
|
+
manager.impactBatch([{ id: "p2", x: 42, y: 0, distance: 42 }]);
|
|
117
|
+
|
|
118
|
+
const current = manager.current();
|
|
119
|
+
expect(current).toHaveLength(1);
|
|
120
|
+
expect(current[0].props.impact?.x).toBe(42);
|
|
121
|
+
expect(current[0].props.destroyed).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("keeps hit destroys briefly even if the destroy packet arrives before impact", () => {
|
|
125
|
+
const hooks = new Hooks([], "client");
|
|
126
|
+
const manager = new ProjectileManager(hooks);
|
|
127
|
+
manager.register("arrow", () => null);
|
|
128
|
+
manager.spawnBatch([
|
|
129
|
+
{
|
|
130
|
+
id: "p3",
|
|
131
|
+
type: "arrow",
|
|
132
|
+
origin: { x: 0, y: 0 },
|
|
133
|
+
direction: { x: 1, y: 0 },
|
|
134
|
+
speed: 100,
|
|
135
|
+
range: 500,
|
|
136
|
+
ttl: 5,
|
|
137
|
+
spawnTick: 1,
|
|
138
|
+
},
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
manager.destroyBatch([{ id: "p3", reason: "hit", x: 48, y: 0, distance: 48 }]);
|
|
142
|
+
|
|
143
|
+
const current = manager.current();
|
|
144
|
+
expect(current).toHaveLength(1);
|
|
145
|
+
expect(current[0].props.impact?.x).toBe(48);
|
|
146
|
+
expect(current[0].props.destroyed).toBe(true);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("freezes hit destroys at the authoritative impact position until the impact completes", () => {
|
|
150
|
+
vi.useFakeTimers();
|
|
151
|
+
vi.setSystemTime(1000);
|
|
152
|
+
|
|
153
|
+
const hooks = new Hooks([], "client");
|
|
154
|
+
const manager = new ProjectileManager(hooks);
|
|
155
|
+
manager.register("arrow", () => null);
|
|
156
|
+
manager.spawnBatch([
|
|
157
|
+
{
|
|
158
|
+
id: "p4",
|
|
159
|
+
type: "arrow",
|
|
160
|
+
origin: { x: 0, y: 0 },
|
|
161
|
+
direction: { x: 1, y: 0 },
|
|
162
|
+
speed: 100,
|
|
163
|
+
range: 500,
|
|
164
|
+
ttl: 5,
|
|
165
|
+
spawnTick: 1,
|
|
166
|
+
},
|
|
167
|
+
]);
|
|
168
|
+
|
|
169
|
+
vi.setSystemTime(1200);
|
|
170
|
+
manager.destroyBatch([{ id: "p4", reason: "hit", x: 48, y: 0, distance: 48 }]);
|
|
171
|
+
|
|
172
|
+
vi.setSystemTime(1300);
|
|
173
|
+
manager.step();
|
|
174
|
+
let current = manager.current();
|
|
175
|
+
expect(current).toHaveLength(1);
|
|
176
|
+
expect(current[0].props.x).toBe(48);
|
|
177
|
+
expect(current[0].props.distance).toBe(48);
|
|
178
|
+
expect(current[0].props.impactProgress).toBeCloseTo(100 / 350, 3);
|
|
179
|
+
|
|
180
|
+
vi.setSystemTime(1600);
|
|
181
|
+
manager.step();
|
|
182
|
+
current = manager.current();
|
|
183
|
+
expect(current).toHaveLength(0);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("clamps visual movement at the predicted impact without starting the impact animation", () => {
|
|
187
|
+
vi.useFakeTimers();
|
|
188
|
+
vi.setSystemTime(1000);
|
|
189
|
+
|
|
190
|
+
const hooks = new Hooks([], "client");
|
|
191
|
+
const manager = new ProjectileManager(hooks, () => ({
|
|
192
|
+
id: "p5",
|
|
193
|
+
targetId: "target",
|
|
194
|
+
x: 30,
|
|
195
|
+
y: 0,
|
|
196
|
+
distance: 30,
|
|
197
|
+
}));
|
|
198
|
+
manager.register("arrow", () => null);
|
|
199
|
+
manager.spawnBatch([
|
|
200
|
+
{
|
|
201
|
+
id: "p5",
|
|
202
|
+
type: "arrow",
|
|
203
|
+
origin: { x: 0, y: 0 },
|
|
204
|
+
direction: { x: 1, y: 0 },
|
|
205
|
+
speed: 100,
|
|
206
|
+
range: 500,
|
|
207
|
+
ttl: 5,
|
|
208
|
+
spawnTick: 1,
|
|
209
|
+
},
|
|
210
|
+
]);
|
|
211
|
+
|
|
212
|
+
vi.setSystemTime(1200);
|
|
213
|
+
manager.step();
|
|
214
|
+
let current = manager.current();
|
|
215
|
+
expect(current).toHaveLength(1);
|
|
216
|
+
expect(current[0].props.x).toBe(20);
|
|
217
|
+
expect(current[0].props.impact).toBeUndefined();
|
|
218
|
+
expect(current[0].props.destroyed).toBe(false);
|
|
219
|
+
|
|
220
|
+
vi.setSystemTime(1400);
|
|
221
|
+
manager.step();
|
|
222
|
+
current = manager.current();
|
|
223
|
+
expect(current).toHaveLength(1);
|
|
224
|
+
expect(current[0].props.x).toBe(30);
|
|
225
|
+
expect(current[0].props.distance).toBe(30);
|
|
226
|
+
expect(current[0].props.impact).toBeUndefined();
|
|
227
|
+
expect(current[0].props.destroyed).toBe(false);
|
|
228
|
+
|
|
229
|
+
manager.impactBatch([{ id: "p5", targetId: "target", x: 32, y: 0, distance: 32 }]);
|
|
230
|
+
current = manager.current();
|
|
231
|
+
expect(current[0].props.x).toBe(30);
|
|
232
|
+
expect(current[0].props.distance).toBe(30);
|
|
233
|
+
expect(current[0].props.impact?.x).toBe(32);
|
|
234
|
+
expect(current[0].props.destroyed).toBe(true);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("uses the authoritative impact position when the predicted target differs", () => {
|
|
238
|
+
vi.useFakeTimers();
|
|
239
|
+
vi.setSystemTime(1000);
|
|
240
|
+
|
|
241
|
+
const hooks = new Hooks([], "client");
|
|
242
|
+
const manager = new ProjectileManager(hooks, () => ({
|
|
243
|
+
id: "p7",
|
|
244
|
+
targetId: "wall",
|
|
245
|
+
x: 30,
|
|
246
|
+
y: 0,
|
|
247
|
+
distance: 30,
|
|
248
|
+
}));
|
|
249
|
+
manager.register("arrow", () => null);
|
|
250
|
+
manager.spawnBatch([
|
|
251
|
+
{
|
|
252
|
+
id: "p7",
|
|
253
|
+
type: "arrow",
|
|
254
|
+
origin: { x: 0, y: 0 },
|
|
255
|
+
direction: { x: 1, y: 0 },
|
|
256
|
+
speed: 100,
|
|
257
|
+
range: 500,
|
|
258
|
+
ttl: 5,
|
|
259
|
+
spawnTick: 1,
|
|
260
|
+
},
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
vi.setSystemTime(1400);
|
|
264
|
+
manager.step();
|
|
265
|
+
manager.impactBatch([{ id: "p7", targetId: "target", x: 45, y: 0, distance: 45 }]);
|
|
266
|
+
|
|
267
|
+
const current = manager.current();
|
|
268
|
+
expect(current[0].props.x).toBe(45);
|
|
269
|
+
expect(current[0].props.distance).toBe(45);
|
|
270
|
+
expect(current[0].props.impact?.x).toBe(45);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("keeps an unconfirmed predicted impact clamped until the server resolves it", () => {
|
|
274
|
+
vi.useFakeTimers();
|
|
275
|
+
vi.setSystemTime(1000);
|
|
276
|
+
|
|
277
|
+
const hooks = new Hooks([], "client");
|
|
278
|
+
const manager = new ProjectileManager(hooks, () => ({
|
|
279
|
+
id: "p6",
|
|
280
|
+
targetId: "ignored",
|
|
281
|
+
x: 30,
|
|
282
|
+
y: 0,
|
|
283
|
+
distance: 30,
|
|
284
|
+
}));
|
|
285
|
+
manager.register("arrow", () => null);
|
|
286
|
+
manager.spawnBatch([
|
|
287
|
+
{
|
|
288
|
+
id: "p6",
|
|
289
|
+
type: "arrow",
|
|
290
|
+
origin: { x: 0, y: 0 },
|
|
291
|
+
direction: { x: 1, y: 0 },
|
|
292
|
+
speed: 100,
|
|
293
|
+
range: 500,
|
|
294
|
+
ttl: 5,
|
|
295
|
+
spawnTick: 1,
|
|
296
|
+
},
|
|
297
|
+
]);
|
|
298
|
+
|
|
299
|
+
vi.setSystemTime(1400);
|
|
300
|
+
manager.step();
|
|
301
|
+
expect(manager.current()[0].props.x).toBe(30);
|
|
302
|
+
|
|
303
|
+
vi.setSystemTime(1900);
|
|
304
|
+
manager.step();
|
|
305
|
+
const current = manager.current();
|
|
306
|
+
expect(current).toHaveLength(1);
|
|
307
|
+
expect(current[0].props.x).toBe(30);
|
|
308
|
+
expect(current[0].props.impact).toBeUndefined();
|
|
309
|
+
expect(current[0].props.destroyed).toBe(false);
|
|
310
|
+
|
|
311
|
+
manager.destroyBatch([{ id: "p6", reason: "range" }]);
|
|
312
|
+
expect(manager.current()[0].props.x).toBe(30);
|
|
313
|
+
expect(manager.current()[0].props.destroyed).toBe(true);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("skips local impact prediction when the server marks the projectile as non-predictable", () => {
|
|
317
|
+
const hooks = new Hooks([], "client");
|
|
318
|
+
const predictionResolver = vi.fn();
|
|
319
|
+
const manager = new ProjectileManager(hooks, predictionResolver);
|
|
320
|
+
manager.register("arrow", () => null);
|
|
321
|
+
|
|
322
|
+
manager.spawnBatch([
|
|
323
|
+
{
|
|
324
|
+
id: "p-no-predict",
|
|
325
|
+
type: "arrow",
|
|
326
|
+
origin: { x: 0, y: 0 },
|
|
327
|
+
direction: { x: 1, y: 0 },
|
|
328
|
+
speed: 100,
|
|
329
|
+
range: 500,
|
|
330
|
+
ttl: 5,
|
|
331
|
+
spawnTick: 1,
|
|
332
|
+
predictImpact: false,
|
|
333
|
+
},
|
|
334
|
+
]);
|
|
335
|
+
|
|
336
|
+
expect(predictionResolver).not.toHaveBeenCalled();
|
|
337
|
+
});
|
|
338
|
+
});
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import { computed, signal } from "canvasengine";
|
|
2
|
+
import { Hooks } from "@rpgjs/common";
|
|
3
|
+
|
|
4
|
+
export interface ClientProjectileSpawn {
|
|
5
|
+
id: string;
|
|
6
|
+
type: string;
|
|
7
|
+
ownerId?: string;
|
|
8
|
+
origin: { x: number; y: number };
|
|
9
|
+
direction: { x: number; y: number };
|
|
10
|
+
speed: number;
|
|
11
|
+
range: number;
|
|
12
|
+
ttl: number;
|
|
13
|
+
spawnTick: number;
|
|
14
|
+
delay?: number;
|
|
15
|
+
index?: number;
|
|
16
|
+
count?: number;
|
|
17
|
+
params?: Record<string, unknown>;
|
|
18
|
+
collisionMask?: number;
|
|
19
|
+
ignoreOwner?: boolean;
|
|
20
|
+
predictImpact?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ClientProjectileImpact {
|
|
24
|
+
id: string;
|
|
25
|
+
targetId?: string;
|
|
26
|
+
x: number;
|
|
27
|
+
y: number;
|
|
28
|
+
distance?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ClientProjectileDestroy {
|
|
32
|
+
id: string;
|
|
33
|
+
reason?: string;
|
|
34
|
+
targetId?: string;
|
|
35
|
+
x?: number;
|
|
36
|
+
y?: number;
|
|
37
|
+
distance?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface RenderedProjectileProps extends ClientProjectileSpawn {
|
|
41
|
+
x: number;
|
|
42
|
+
y: number;
|
|
43
|
+
angle: number;
|
|
44
|
+
distance: number;
|
|
45
|
+
elapsed: number;
|
|
46
|
+
progress: number;
|
|
47
|
+
impact?: ClientProjectileImpact;
|
|
48
|
+
impactElapsed?: number;
|
|
49
|
+
impactProgress?: number;
|
|
50
|
+
destroyed?: boolean;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface RenderedProjectile {
|
|
54
|
+
id: string;
|
|
55
|
+
type: string;
|
|
56
|
+
component: any;
|
|
57
|
+
props: RenderedProjectileProps;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type ProjectilePredictionResolver = (
|
|
61
|
+
projectile: ClientProjectileSpawn,
|
|
62
|
+
) => ClientProjectileImpact | null | undefined;
|
|
63
|
+
|
|
64
|
+
export interface ProjectileSpawnClock {
|
|
65
|
+
now?: number;
|
|
66
|
+
currentServerTick?: number;
|
|
67
|
+
tickDurationMs?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface RuntimeProjectile {
|
|
71
|
+
spawn: ClientProjectileSpawn;
|
|
72
|
+
component: any;
|
|
73
|
+
createdAt: number;
|
|
74
|
+
impact?: ClientProjectileImpact;
|
|
75
|
+
visualImpact?: ClientProjectileImpact;
|
|
76
|
+
predictedImpact?: ClientProjectileImpact;
|
|
77
|
+
impactStartedAt?: number;
|
|
78
|
+
destroyAt?: number;
|
|
79
|
+
destroyReason?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class ProjectileManager {
|
|
83
|
+
private readonly components = new Map<string, any>();
|
|
84
|
+
private readonly projectiles = new Map<string, RuntimeProjectile>();
|
|
85
|
+
private readonly version = signal(0);
|
|
86
|
+
private readonly impactDurationMs = 350;
|
|
87
|
+
|
|
88
|
+
constructor(
|
|
89
|
+
private readonly hooks: Hooks,
|
|
90
|
+
private readonly predictionResolver?: ProjectilePredictionResolver,
|
|
91
|
+
) {}
|
|
92
|
+
|
|
93
|
+
current = computed<RenderedProjectile[]>(() => {
|
|
94
|
+
this.version();
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
const rendered: RenderedProjectile[] = [];
|
|
97
|
+
for (const projectile of this.projectiles.values()) {
|
|
98
|
+
const props = this.toProps(projectile, now);
|
|
99
|
+
if (!props) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
rendered.push({
|
|
103
|
+
id: projectile.spawn.id,
|
|
104
|
+
type: projectile.spawn.type,
|
|
105
|
+
component: projectile.component,
|
|
106
|
+
props,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return rendered;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
register(type: string, component: any): any {
|
|
113
|
+
this.components.set(type, component);
|
|
114
|
+
return component;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
get(type: string): any {
|
|
118
|
+
return this.components.get(type);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
spawnBatch(projectiles: ClientProjectileSpawn[], clock: ProjectileSpawnClock = {}): void {
|
|
122
|
+
const now = clock.now ?? Date.now();
|
|
123
|
+
for (const projectile of projectiles) {
|
|
124
|
+
const component = this.components.get(projectile.type);
|
|
125
|
+
if (!component) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const runtime: RuntimeProjectile = {
|
|
129
|
+
spawn: {
|
|
130
|
+
...projectile,
|
|
131
|
+
delay: projectile.delay ?? 0,
|
|
132
|
+
index: projectile.index ?? 0,
|
|
133
|
+
count: projectile.count ?? 1,
|
|
134
|
+
},
|
|
135
|
+
component,
|
|
136
|
+
createdAt: now,
|
|
137
|
+
};
|
|
138
|
+
this.setPredictedImpact(runtime);
|
|
139
|
+
this.projectiles.set(projectile.id, runtime);
|
|
140
|
+
this.hooks.callHooks("client-projectiles-onSpawn", runtime.spawn).subscribe();
|
|
141
|
+
}
|
|
142
|
+
this.touch();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
impactBatch(impacts: ClientProjectileImpact[]): void {
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
for (const impact of impacts) {
|
|
148
|
+
const projectile = this.projectiles.get(impact.id);
|
|
149
|
+
if (!projectile) {
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
this.setImpact(projectile, impact, now);
|
|
153
|
+
this.hooks.callHooks("client-projectiles-onImpact", this.toProps(projectile, now)).subscribe();
|
|
154
|
+
}
|
|
155
|
+
this.touch();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
destroyBatch(projectiles: ClientProjectileDestroy[]): void {
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
for (const destroyed of projectiles) {
|
|
161
|
+
const projectile = this.projectiles.get(destroyed.id);
|
|
162
|
+
if (!projectile) {
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (destroyed.reason === "hit") {
|
|
166
|
+
const current = this.toProps(projectile, now);
|
|
167
|
+
this.setImpact(projectile, {
|
|
168
|
+
id: destroyed.id,
|
|
169
|
+
targetId: destroyed.targetId ?? projectile.impact?.targetId,
|
|
170
|
+
x: destroyed.x ?? projectile.impact?.x ?? current?.x ?? projectile.spawn.origin.x,
|
|
171
|
+
y: destroyed.y ?? projectile.impact?.y ?? current?.y ?? projectile.spawn.origin.y,
|
|
172
|
+
distance: destroyed.distance ?? projectile.impact?.distance ?? current?.distance,
|
|
173
|
+
}, now);
|
|
174
|
+
}
|
|
175
|
+
projectile.destroyReason = destroyed.reason;
|
|
176
|
+
projectile.destroyAt = projectile.destroyAt ?? (
|
|
177
|
+
projectile.impact && projectile.impactStartedAt !== undefined
|
|
178
|
+
? projectile.impactStartedAt + this.impactDurationMs
|
|
179
|
+
: now
|
|
180
|
+
);
|
|
181
|
+
this.hooks.callHooks("client-projectiles-onDestroy", this.toProps(projectile, now)).subscribe();
|
|
182
|
+
}
|
|
183
|
+
this.touch();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
clear(): void {
|
|
187
|
+
this.projectiles.clear();
|
|
188
|
+
this.touch();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
step(): void {
|
|
192
|
+
const now = Date.now();
|
|
193
|
+
let changed = false;
|
|
194
|
+
for (const [id, projectile] of this.projectiles) {
|
|
195
|
+
const props = this.toProps(projectile, now);
|
|
196
|
+
if (
|
|
197
|
+
(!props && !this.isWaitingForDelay(projectile, now)) ||
|
|
198
|
+
(projectile.destroyAt !== undefined && now >= projectile.destroyAt)
|
|
199
|
+
) {
|
|
200
|
+
this.projectiles.delete(id);
|
|
201
|
+
changed = true;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
this.touch(changed || this.projectiles.size > 0);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private toProps(projectile: RuntimeProjectile, now: number): RenderedProjectileProps | null {
|
|
208
|
+
const spawn = projectile.spawn;
|
|
209
|
+
const delayMs = (spawn.delay ?? 0) * 1000;
|
|
210
|
+
const elapsedMs = now - projectile.createdAt - delayMs;
|
|
211
|
+
if (elapsedMs < 0) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
const elapsed = elapsedMs / 1000;
|
|
215
|
+
const ttl = Math.max(0.001, spawn.ttl);
|
|
216
|
+
const rawDistance = Math.min(spawn.speed * elapsed, spawn.range);
|
|
217
|
+
const predictedImpact = this.getActivePredictedImpact(projectile, now, rawDistance);
|
|
218
|
+
const visualImpact = projectile.visualImpact ?? projectile.impact;
|
|
219
|
+
const distance = visualImpact?.distance ?? predictedImpact?.distance ?? rawDistance;
|
|
220
|
+
const progress = Math.min(1, distance / spawn.range);
|
|
221
|
+
const x = visualImpact?.x ?? predictedImpact?.x ?? spawn.origin.x + spawn.direction.x * distance;
|
|
222
|
+
const y = visualImpact?.y ?? predictedImpact?.y ?? spawn.origin.y + spawn.direction.y * distance;
|
|
223
|
+
const impactElapsedMs = projectile.impactStartedAt !== undefined
|
|
224
|
+
? Math.max(0, now - projectile.impactStartedAt)
|
|
225
|
+
: undefined;
|
|
226
|
+
return {
|
|
227
|
+
...spawn,
|
|
228
|
+
x,
|
|
229
|
+
y,
|
|
230
|
+
angle: Math.atan2(spawn.direction.y, spawn.direction.x),
|
|
231
|
+
distance,
|
|
232
|
+
elapsed,
|
|
233
|
+
progress,
|
|
234
|
+
impact: projectile.impact,
|
|
235
|
+
impactElapsed: impactElapsedMs === undefined ? undefined : impactElapsedMs / 1000,
|
|
236
|
+
impactProgress: impactElapsedMs === undefined
|
|
237
|
+
? undefined
|
|
238
|
+
: Math.min(1, impactElapsedMs / this.impactDurationMs),
|
|
239
|
+
destroyed: projectile.destroyAt !== undefined,
|
|
240
|
+
ttl,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private isWaitingForDelay(projectile: RuntimeProjectile, now: number): boolean {
|
|
245
|
+
const delayMs = (projectile.spawn.delay ?? 0) * 1000;
|
|
246
|
+
return now - projectile.createdAt - delayMs < 0;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private setPredictedImpact(projectile: RuntimeProjectile): void {
|
|
250
|
+
if (projectile.spawn.predictImpact === false) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const impact = this.predictionResolver?.(projectile.spawn);
|
|
254
|
+
if (!impact || !Number.isFinite(impact.x) || !Number.isFinite(impact.y)) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const distance = typeof impact.distance === "number" && Number.isFinite(impact.distance)
|
|
258
|
+
? impact.distance
|
|
259
|
+
: Math.hypot(impact.x - projectile.spawn.origin.x, impact.y - projectile.spawn.origin.y);
|
|
260
|
+
if (!Number.isFinite(distance) || distance < 0 || distance > projectile.spawn.range) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
projectile.predictedImpact = {
|
|
264
|
+
...impact,
|
|
265
|
+
distance,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private getActivePredictedImpact(
|
|
270
|
+
projectile: RuntimeProjectile,
|
|
271
|
+
now: number,
|
|
272
|
+
rawDistance: number,
|
|
273
|
+
): ClientProjectileImpact | undefined {
|
|
274
|
+
if (!projectile.predictedImpact || projectile.impact) {
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
const distance = projectile.predictedImpact.distance;
|
|
278
|
+
if (distance === undefined || rawDistance < distance) {
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
return projectile.predictedImpact;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private setImpact(projectile: RuntimeProjectile, impact: ClientProjectileImpact, now: number): void {
|
|
285
|
+
projectile.visualImpact = this.resolveVisualImpact(projectile, impact, now);
|
|
286
|
+
projectile.impact = impact;
|
|
287
|
+
projectile.predictedImpact = undefined;
|
|
288
|
+
projectile.impactStartedAt = projectile.impactStartedAt ?? now;
|
|
289
|
+
const impactDestroyAt = projectile.impactStartedAt + this.impactDurationMs;
|
|
290
|
+
projectile.destroyAt = Math.max(projectile.destroyAt ?? 0, impactDestroyAt);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private resolveVisualImpact(
|
|
294
|
+
projectile: RuntimeProjectile,
|
|
295
|
+
impact: ClientProjectileImpact,
|
|
296
|
+
now: number,
|
|
297
|
+
): ClientProjectileImpact {
|
|
298
|
+
const predicted = projectile.predictedImpact;
|
|
299
|
+
if (!predicted || !this.isSameTarget(predicted, impact)) {
|
|
300
|
+
return impact;
|
|
301
|
+
}
|
|
302
|
+
const distance = predicted.distance;
|
|
303
|
+
if (distance === undefined) {
|
|
304
|
+
return impact;
|
|
305
|
+
}
|
|
306
|
+
const delayMs = (projectile.spawn.delay ?? 0) * 1000;
|
|
307
|
+
const elapsedMs = now - projectile.createdAt - delayMs;
|
|
308
|
+
if (elapsedMs < 0) {
|
|
309
|
+
return impact;
|
|
310
|
+
}
|
|
311
|
+
const rawDistance = Math.min(projectile.spawn.speed * (elapsedMs / 1000), projectile.spawn.range);
|
|
312
|
+
return rawDistance >= distance ? predicted : impact;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private isSameTarget(a: ClientProjectileImpact, b: ClientProjectileImpact): boolean {
|
|
316
|
+
return a.targetId !== undefined && a.targetId === b.targetId;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private touch(force = true): void {
|
|
320
|
+
if (force) {
|
|
321
|
+
this.version.update((value) => value + 1);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|