@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
@@ -130,12 +130,12 @@ export abstract class RpgClientObject extends RpgCommonPlayer {
130
130
  const restoreState = this.animationRestoreState;
131
131
  this.clearAnimationControls();
132
132
  this.animationCurrentIndex.set(0);
133
+ this.animationRestoreState = undefined;
134
+ this.animationIsPlaying.set(false);
133
135
  if (restoreState) {
134
136
  this.animationName.set(restoreState.animationName);
135
137
  this.graphics.set([...restoreState.graphics]);
136
138
  }
137
- this.animationRestoreState = undefined;
138
- this.animationIsPlaying.set(false);
139
139
  this.resolveAnimationWait();
140
140
  }
141
141
 
@@ -0,0 +1,449 @@
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("ignores projectile packets from another map", () => {
39
+ const hooks = new Hooks([], "client");
40
+ const manager = new ProjectileManager(hooks);
41
+ const component = () => null;
42
+
43
+ manager.register("fireball", component);
44
+ manager.setMapId("map-town");
45
+ manager.spawnBatch([
46
+ {
47
+ id: "old-map-projectile",
48
+ type: "fireball",
49
+ origin: { x: 10, y: 20 },
50
+ direction: { x: 1, y: 0 },
51
+ speed: 100,
52
+ range: 500,
53
+ ttl: 5,
54
+ spawnTick: 1,
55
+ },
56
+ ], { mapId: "map-dungeon" });
57
+
58
+ expect(manager.current()).toHaveLength(0);
59
+
60
+ manager.spawnBatch([
61
+ {
62
+ id: "current-map-projectile",
63
+ type: "fireball",
64
+ origin: { x: 10, y: 20 },
65
+ direction: { x: 1, y: 0 },
66
+ speed: 100,
67
+ range: 500,
68
+ ttl: 5,
69
+ spawnTick: 1,
70
+ },
71
+ ], { mapId: "map-town" });
72
+
73
+ expect(manager.current()).toHaveLength(1);
74
+ });
75
+
76
+ test("accepts server map ids without the client room prefix", () => {
77
+ const hooks = new Hooks([], "client");
78
+ const manager = new ProjectileManager(hooks);
79
+ const component = () => null;
80
+
81
+ manager.register("fireball", component);
82
+ manager.setMapId("map-town");
83
+ manager.spawnBatch([
84
+ {
85
+ id: "server-map-projectile",
86
+ type: "fireball",
87
+ origin: { x: 10, y: 20 },
88
+ direction: { x: 1, y: 0 },
89
+ speed: 100,
90
+ range: 500,
91
+ ttl: 5,
92
+ spawnTick: 1,
93
+ },
94
+ ], { mapId: "town" });
95
+
96
+ expect(manager.getMapId()).toBe("town");
97
+ expect(manager.current()).toHaveLength(1);
98
+ });
99
+
100
+ test("accepts prefixed map ids when the manager stores the logical map id", () => {
101
+ const hooks = new Hooks([], "client");
102
+ const manager = new ProjectileManager(hooks);
103
+ const component = () => null;
104
+
105
+ manager.register("fireball", component);
106
+ manager.setMapId("town");
107
+ manager.spawnBatch([
108
+ {
109
+ id: "prefixed-map-projectile",
110
+ type: "fireball",
111
+ origin: { x: 10, y: 20 },
112
+ direction: { x: 1, y: 0 },
113
+ speed: 100,
114
+ range: 500,
115
+ ttl: 5,
116
+ spawnTick: 1,
117
+ },
118
+ ], { mapId: "map-town" });
119
+
120
+ expect(manager.current()).toHaveLength(1);
121
+ });
122
+
123
+ test("clears projectiles when switching map ids", () => {
124
+ const hooks = new Hooks([], "client");
125
+ const manager = new ProjectileManager(hooks);
126
+
127
+ manager.register("fireball", () => null);
128
+ manager.setMapId("map-town");
129
+ manager.spawnBatch([
130
+ {
131
+ id: "p1",
132
+ type: "fireball",
133
+ origin: { x: 10, y: 20 },
134
+ direction: { x: 1, y: 0 },
135
+ speed: 100,
136
+ range: 500,
137
+ ttl: 5,
138
+ spawnTick: 1,
139
+ },
140
+ ], { mapId: "map-town" });
141
+
142
+ expect(manager.current()).toHaveLength(1);
143
+
144
+ manager.setMapId("map-dungeon");
145
+
146
+ expect(manager.current()).toHaveLength(0);
147
+ });
148
+
149
+ test("starts visuals at the spawn origin even when a server tick estimate exists", () => {
150
+ vi.useFakeTimers();
151
+ vi.setSystemTime(2000);
152
+
153
+ const hooks = new Hooks([], "client");
154
+ const manager = new ProjectileManager(hooks);
155
+ manager.register("arrow", () => null);
156
+ manager.spawnBatch([
157
+ {
158
+ id: "p-latency",
159
+ type: "arrow",
160
+ origin: { x: 0, y: 0 },
161
+ direction: { x: 1, y: 0 },
162
+ speed: 120,
163
+ range: 500,
164
+ ttl: 5,
165
+ spawnTick: 10,
166
+ },
167
+ ], {
168
+ currentServerTick: 16,
169
+ tickDurationMs: 1000 / 60,
170
+ });
171
+
172
+ const current = manager.current();
173
+ expect(current).toHaveLength(1);
174
+ expect(current[0].props.elapsed).toBeCloseTo(0, 3);
175
+ expect(current[0].props.x).toBeCloseTo(0, 3);
176
+ });
177
+
178
+ test("keeps delayed projectiles until their visual delay has elapsed", () => {
179
+ vi.useFakeTimers();
180
+ vi.setSystemTime(1000);
181
+
182
+ const hooks = new Hooks([], "client");
183
+ const manager = new ProjectileManager(hooks);
184
+ manager.register("spark", () => null);
185
+ manager.spawnBatch([
186
+ {
187
+ id: "p-delayed",
188
+ type: "spark",
189
+ origin: { x: 0, y: 0 },
190
+ direction: { x: 1, y: 0 },
191
+ speed: 100,
192
+ range: 500,
193
+ ttl: 5,
194
+ spawnTick: 1,
195
+ delay: 0.1,
196
+ },
197
+ ]);
198
+
199
+ vi.setSystemTime(1050);
200
+ manager.step();
201
+ expect(manager.current()).toHaveLength(0);
202
+
203
+ vi.setSystemTime(1110);
204
+ manager.step();
205
+ const current = manager.current();
206
+ expect(current).toHaveLength(1);
207
+ expect(current[0].props.elapsed).toBeCloseTo(0.01, 3);
208
+ });
209
+
210
+ test("keeps impacted projectiles briefly so components can react", () => {
211
+ const hooks = new Hooks([], "client");
212
+ const manager = new ProjectileManager(hooks);
213
+ manager.register("arrow", () => null);
214
+ manager.spawnBatch([
215
+ {
216
+ id: "p2",
217
+ type: "arrow",
218
+ origin: { x: 0, y: 0 },
219
+ direction: { x: 1, y: 0 },
220
+ speed: 100,
221
+ range: 500,
222
+ ttl: 5,
223
+ spawnTick: 1,
224
+ },
225
+ ]);
226
+
227
+ manager.impactBatch([{ id: "p2", x: 42, y: 0, distance: 42 }]);
228
+
229
+ const current = manager.current();
230
+ expect(current).toHaveLength(1);
231
+ expect(current[0].props.impact?.x).toBe(42);
232
+ expect(current[0].props.destroyed).toBe(true);
233
+ });
234
+
235
+ test("keeps hit destroys briefly even if the destroy packet arrives before impact", () => {
236
+ const hooks = new Hooks([], "client");
237
+ const manager = new ProjectileManager(hooks);
238
+ manager.register("arrow", () => null);
239
+ manager.spawnBatch([
240
+ {
241
+ id: "p3",
242
+ type: "arrow",
243
+ origin: { x: 0, y: 0 },
244
+ direction: { x: 1, y: 0 },
245
+ speed: 100,
246
+ range: 500,
247
+ ttl: 5,
248
+ spawnTick: 1,
249
+ },
250
+ ]);
251
+
252
+ manager.destroyBatch([{ id: "p3", reason: "hit", x: 48, y: 0, distance: 48 }]);
253
+
254
+ const current = manager.current();
255
+ expect(current).toHaveLength(1);
256
+ expect(current[0].props.impact?.x).toBe(48);
257
+ expect(current[0].props.destroyed).toBe(true);
258
+ });
259
+
260
+ test("freezes hit destroys at the authoritative impact position until the impact completes", () => {
261
+ vi.useFakeTimers();
262
+ vi.setSystemTime(1000);
263
+
264
+ const hooks = new Hooks([], "client");
265
+ const manager = new ProjectileManager(hooks);
266
+ manager.register("arrow", () => null);
267
+ manager.spawnBatch([
268
+ {
269
+ id: "p4",
270
+ type: "arrow",
271
+ origin: { x: 0, y: 0 },
272
+ direction: { x: 1, y: 0 },
273
+ speed: 100,
274
+ range: 500,
275
+ ttl: 5,
276
+ spawnTick: 1,
277
+ },
278
+ ]);
279
+
280
+ vi.setSystemTime(1200);
281
+ manager.destroyBatch([{ id: "p4", reason: "hit", x: 48, y: 0, distance: 48 }]);
282
+
283
+ vi.setSystemTime(1300);
284
+ manager.step();
285
+ let current = manager.current();
286
+ expect(current).toHaveLength(1);
287
+ expect(current[0].props.x).toBe(48);
288
+ expect(current[0].props.distance).toBe(48);
289
+ expect(current[0].props.impactProgress).toBeCloseTo(100 / 350, 3);
290
+
291
+ vi.setSystemTime(1600);
292
+ manager.step();
293
+ current = manager.current();
294
+ expect(current).toHaveLength(0);
295
+ });
296
+
297
+ test("clamps visual movement at the predicted impact without starting the impact animation", () => {
298
+ vi.useFakeTimers();
299
+ vi.setSystemTime(1000);
300
+
301
+ const hooks = new Hooks([], "client");
302
+ const manager = new ProjectileManager(hooks, () => ({
303
+ id: "p5",
304
+ targetId: "target",
305
+ x: 30,
306
+ y: 0,
307
+ distance: 30,
308
+ }));
309
+ manager.register("arrow", () => null);
310
+ manager.spawnBatch([
311
+ {
312
+ id: "p5",
313
+ type: "arrow",
314
+ origin: { x: 0, y: 0 },
315
+ direction: { x: 1, y: 0 },
316
+ speed: 100,
317
+ range: 500,
318
+ ttl: 5,
319
+ spawnTick: 1,
320
+ },
321
+ ]);
322
+
323
+ vi.setSystemTime(1200);
324
+ manager.step();
325
+ let current = manager.current();
326
+ expect(current).toHaveLength(1);
327
+ expect(current[0].props.x).toBe(20);
328
+ expect(current[0].props.impact).toBeUndefined();
329
+ expect(current[0].props.destroyed).toBe(false);
330
+
331
+ vi.setSystemTime(1400);
332
+ manager.step();
333
+ current = manager.current();
334
+ expect(current).toHaveLength(1);
335
+ expect(current[0].props.x).toBe(30);
336
+ expect(current[0].props.distance).toBe(30);
337
+ expect(current[0].props.impact).toBeUndefined();
338
+ expect(current[0].props.destroyed).toBe(false);
339
+
340
+ manager.impactBatch([{ id: "p5", targetId: "target", x: 32, y: 0, distance: 32 }]);
341
+ current = manager.current();
342
+ expect(current[0].props.x).toBe(30);
343
+ expect(current[0].props.distance).toBe(30);
344
+ expect(current[0].props.impact?.x).toBe(32);
345
+ expect(current[0].props.destroyed).toBe(true);
346
+ });
347
+
348
+ test("uses the authoritative impact position when the predicted target differs", () => {
349
+ vi.useFakeTimers();
350
+ vi.setSystemTime(1000);
351
+
352
+ const hooks = new Hooks([], "client");
353
+ const manager = new ProjectileManager(hooks, () => ({
354
+ id: "p7",
355
+ targetId: "wall",
356
+ x: 30,
357
+ y: 0,
358
+ distance: 30,
359
+ }));
360
+ manager.register("arrow", () => null);
361
+ manager.spawnBatch([
362
+ {
363
+ id: "p7",
364
+ type: "arrow",
365
+ origin: { x: 0, y: 0 },
366
+ direction: { x: 1, y: 0 },
367
+ speed: 100,
368
+ range: 500,
369
+ ttl: 5,
370
+ spawnTick: 1,
371
+ },
372
+ ]);
373
+
374
+ vi.setSystemTime(1400);
375
+ manager.step();
376
+ manager.impactBatch([{ id: "p7", targetId: "target", x: 45, y: 0, distance: 45 }]);
377
+
378
+ const current = manager.current();
379
+ expect(current[0].props.x).toBe(45);
380
+ expect(current[0].props.distance).toBe(45);
381
+ expect(current[0].props.impact?.x).toBe(45);
382
+ });
383
+
384
+ test("keeps an unconfirmed predicted impact clamped until the server resolves it", () => {
385
+ vi.useFakeTimers();
386
+ vi.setSystemTime(1000);
387
+
388
+ const hooks = new Hooks([], "client");
389
+ const manager = new ProjectileManager(hooks, () => ({
390
+ id: "p6",
391
+ targetId: "ignored",
392
+ x: 30,
393
+ y: 0,
394
+ distance: 30,
395
+ }));
396
+ manager.register("arrow", () => null);
397
+ manager.spawnBatch([
398
+ {
399
+ id: "p6",
400
+ type: "arrow",
401
+ origin: { x: 0, y: 0 },
402
+ direction: { x: 1, y: 0 },
403
+ speed: 100,
404
+ range: 500,
405
+ ttl: 5,
406
+ spawnTick: 1,
407
+ },
408
+ ]);
409
+
410
+ vi.setSystemTime(1400);
411
+ manager.step();
412
+ expect(manager.current()[0].props.x).toBe(30);
413
+
414
+ vi.setSystemTime(1900);
415
+ manager.step();
416
+ const current = manager.current();
417
+ expect(current).toHaveLength(1);
418
+ expect(current[0].props.x).toBe(30);
419
+ expect(current[0].props.impact).toBeUndefined();
420
+ expect(current[0].props.destroyed).toBe(false);
421
+
422
+ manager.destroyBatch([{ id: "p6", reason: "range" }]);
423
+ expect(manager.current()[0].props.x).toBe(30);
424
+ expect(manager.current()[0].props.destroyed).toBe(true);
425
+ });
426
+
427
+ test("skips local impact prediction when the server marks the projectile as non-predictable", () => {
428
+ const hooks = new Hooks([], "client");
429
+ const predictionResolver = vi.fn();
430
+ const manager = new ProjectileManager(hooks, predictionResolver);
431
+ manager.register("arrow", () => null);
432
+
433
+ manager.spawnBatch([
434
+ {
435
+ id: "p-no-predict",
436
+ type: "arrow",
437
+ origin: { x: 0, y: 0 },
438
+ direction: { x: 1, y: 0 },
439
+ speed: 100,
440
+ range: 500,
441
+ ttl: 5,
442
+ spawnTick: 1,
443
+ predictImpact: false,
444
+ },
445
+ ]);
446
+
447
+ expect(predictionResolver).not.toHaveBeenCalled();
448
+ });
449
+ });