@rpgjs/client 5.0.0-beta.20 → 5.0.0-beta.23

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 (52) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/Game/Object.d.ts +1 -0
  3. package/dist/Game/Object.js +10 -0
  4. package/dist/Game/Object.js.map +1 -1
  5. package/dist/Gui/Gui.js +37 -4
  6. package/dist/Gui/Gui.js.map +1 -1
  7. package/dist/RpgClientEngine.d.ts +18 -9
  8. package/dist/RpgClientEngine.js +40 -13
  9. package/dist/RpgClientEngine.js.map +1 -1
  10. package/dist/components/character-hitbox.d.ts +13 -0
  11. package/dist/components/character-hitbox.js +38 -0
  12. package/dist/components/character-hitbox.js.map +1 -0
  13. package/dist/components/character-hitbox.spec.d.ts +1 -0
  14. package/dist/components/character.ce.js +179 -36
  15. package/dist/components/character.ce.js.map +1 -1
  16. package/dist/components/gui/mobile/index.d.ts +51 -2
  17. package/dist/components/gui/mobile/index.js +12 -4
  18. package/dist/components/gui/mobile/index.js.map +1 -1
  19. package/dist/components/gui/mobile/index.spec.d.ts +1 -0
  20. package/dist/components/gui/mobile/mobile.ce.js +303 -55
  21. package/dist/components/gui/mobile/mobile.ce.js.map +1 -1
  22. package/dist/index.d.ts +1 -0
  23. package/dist/services/cameraFollow.d.ts +51 -0
  24. package/dist/services/cameraFollow.js +134 -0
  25. package/dist/services/cameraFollow.js.map +1 -0
  26. package/dist/services/cameraFollow.spec.d.ts +1 -0
  27. package/dist/services/standalone.d.ts +6 -1
  28. package/dist/services/standalone.js +35 -17
  29. package/dist/services/standalone.js.map +1 -1
  30. package/dist/utils/syncHitbox.d.ts +1 -0
  31. package/dist/utils/syncHitbox.js +69 -0
  32. package/dist/utils/syncHitbox.js.map +1 -0
  33. package/dist/utils/syncHitbox.spec.d.ts +1 -0
  34. package/package.json +5 -5
  35. package/src/Game/Object.spec.ts +44 -4
  36. package/src/Game/Object.ts +19 -0
  37. package/src/Gui/Gui.spec.ts +19 -0
  38. package/src/Gui/Gui.ts +50 -11
  39. package/src/RpgClientEngine.ts +56 -24
  40. package/src/components/character-hitbox.spec.ts +33 -0
  41. package/src/components/character-hitbox.ts +72 -0
  42. package/src/components/character.ce +207 -48
  43. package/src/components/gui/mobile/index.spec.ts +94 -0
  44. package/src/components/gui/mobile/index.ts +74 -6
  45. package/src/components/gui/mobile/mobile.ce +347 -65
  46. package/src/index.ts +12 -0
  47. package/src/services/cameraFollow.spec.ts +220 -0
  48. package/src/services/cameraFollow.ts +222 -0
  49. package/src/services/standalone.spec.ts +47 -0
  50. package/src/services/standalone.ts +53 -20
  51. package/src/utils/syncHitbox.spec.ts +79 -0
  52. package/src/utils/syncHitbox.ts +104 -0
@@ -1,80 +1,362 @@
1
- <Container justifyContent="space-between" alignItems="flex-end" width="100%" height="100%">
2
- <!-- Gamepad buttons A and B (left side) -->
3
- <Container justifyContent="flex-start" alignItems="flex-end" gap={20}>
4
- <Container display="flex" direction="column" gap={20} margin={50}>
5
- <!-- Button B (top) -->
6
-
7
- <!-- Button A (bottom) -->
8
- <Button
9
- text="A"
10
- shape="circle"
11
- width={70}
12
- height={70}
13
- controls={controlsInstance}
14
- controlName="action"
15
- style={{
16
- backgroundColor: {
17
- normal: "#2ecc71",
18
- hover: "#27ae60",
19
- pressed: "#229954",
20
- disabled: "#7f8c8d"
21
- },
22
- text: {
23
- fontSize: 24,
24
- fontFamily: "Arial Bold",
25
- color: "#ffffff"
26
- }
27
- }}
28
- />
29
-
30
- <Button
31
- text="B"
32
- shape="circle"
33
- width={70}
34
- height={70}
35
- controls={controlsInstance}
36
- controlName="back"
37
- style={{
38
- backgroundColor: {
39
- normal: "#e74c3c",
40
- hover: "#c0392b",
41
- pressed: "#a93226",
42
- disabled: "#7f8c8d"
43
- },
44
- text: {
45
- fontSize: 24,
46
- fontFamily: "Arial Bold",
47
- color: "#ffffff"
48
- }
49
- }}
50
- />
1
+ <Container flexDirection={rootDirection()} justifyContent="space-between" alignItems="flex-end" width="100%" height="100%" onBeforeDestroy={releaseJoystickControl}>
2
+ <Container justifyContent="flex-end" alignItems="flex-end" gap={layoutGap()} margin={buttonsMargin()}>
3
+ <Container display="flex" flexDirection="row" alignItems="flex-end" gap={layoutGap()}>
4
+ @if (showDashButton()) {
5
+ <Container ...buttonContainerProps("dash")>
6
+ <dashButtonComponent() ...buttonProps("dash") />
7
+ </Container>
8
+ }
9
+
10
+ @if (showActionButton()) {
11
+ <Container ...buttonContainerProps("action")>
12
+ <actionButtonComponent() ...buttonProps("action") />
13
+ </Container>
14
+ }
15
+
16
+ @if (showBackButton()) {
17
+ <Container ...buttonContainerProps("back")>
18
+ <backButtonComponent() ...buttonProps("back") />
19
+ </Container>
20
+ }
51
21
 
52
22
  </Container>
53
23
  </Container>
54
-
55
- <Container margin={100} alignItems="flex-end">
56
- <Container>
57
- <Joystick
58
- controls={controlsInstance}
59
- outerColor="#34495e"
60
- innerColor="#3498db"
61
- />
62
- </Container>
24
+
25
+ <Container margin={joystickMargin()} alignItems="flex-end">
26
+ @if (showJoystick()) {
27
+ <joystickComponent() ...joystickProps() />
28
+ }
63
29
  </Container>
64
30
  </Container>
65
31
 
66
32
  <script>
67
- import { signal, mount } from 'canvasengine'
68
- import { Button } from 'canvasengine'
33
+ import { Button, Joystick, signal, mount, computed } from 'canvasengine'
69
34
  import { inject } from '../../../core/inject'
70
- import { RpgClientEngine } from '../../../RpgClientEngine'
71
- import { Direction } from '@rpgjs/common'
72
-
73
35
 
36
+ const { data } = defineProps()
74
37
  const controlsInstance = signal(null)
38
+ const options = computed(() => data?.() || {})
39
+ const layout = computed(() => options().layout || {})
40
+ const components = computed(() => options().components || {})
41
+ const buttons = computed(() => options().buttons || {})
42
+ const joystick = computed(() => options().joystick)
43
+
44
+ const readButtonOption = (name) => buttons()[name]
45
+ const buttonEnabled = (name, defaultValue) => {
46
+ const option = readButtonOption(name)
47
+ if (typeof option === "boolean") return option
48
+ return option?.enabled ?? defaultValue
49
+ }
50
+
51
+ const showActionButton = computed(() => buttonEnabled("action", true))
52
+ const showBackButton = computed(() => buttonEnabled("back", true))
53
+ const showDashButton = computed(() => buttonEnabled("dash", false))
54
+ const showJoystick = computed(() => joystick() !== false)
55
+ const joystickSide = computed(() => layout().joystickSide || "right")
56
+ const rootDirection = computed(() => joystickSide() === "left" ? "row-reverse" : "row")
57
+ const layoutGap = computed(() => layout().gap ?? 14)
58
+ const buttonsMargin = computed(() => layout().buttonsMargin ?? layout().margin ?? 50)
59
+ const joystickMargin = computed(() => (
60
+ layout().joystickMargin
61
+ ?? layout().margin
62
+ ?? (joystickSide() === "right" ? [30, 54, 30, 30] : [30, 30, 30, 54])
63
+ ))
64
+
65
+ function readObjectOption(option) {
66
+ return typeof option === "object" && option ? option : {}
67
+ }
68
+
69
+ function resolveButtonComponent(name) {
70
+ const option = readObjectOption(readButtonOption(name))
71
+ return option.component || components().buttons?.[name] || Button
72
+ }
73
+
74
+ const joystickComponent = computed(() => joystick()?.component || components().joystick || Joystick)
75
+ const actionButtonComponent = computed(() => resolveButtonComponent("action"))
76
+ const backButtonComponent = computed(() => resolveButtonComponent("back"))
77
+ const dashButtonComponent = computed(() => resolveButtonComponent("dash"))
78
+ let activeJoystickControl = null
79
+ let activeJoystickPower = 0
80
+ let joystickRepeatTimer = null
81
+
82
+ const baseButtonTextStyle = {
83
+ fontSize: 24,
84
+ fontFamily: "Arial Bold",
85
+ color: "#ffffff"
86
+ }
87
+
88
+ const actionButtonStyle = {
89
+ backgroundColor: {
90
+ normal: "#f7fbff",
91
+ hover: "#ffffff",
92
+ pressed: "#8fd4ff",
93
+ disabled: "#7f8c8d"
94
+ },
95
+ text: {
96
+ ...baseButtonTextStyle,
97
+ color: "#14253a"
98
+ },
99
+ border: {
100
+ color: "#ffffff",
101
+ width: 2
102
+ }
103
+ }
104
+
105
+ const backButtonStyle = {
106
+ backgroundColor: {
107
+ normal: "#243041",
108
+ hover: "#2f4059",
109
+ pressed: "#152132",
110
+ disabled: "#7f8c8d"
111
+ },
112
+ text: baseButtonTextStyle,
113
+ border: {
114
+ color: "#ffffff",
115
+ width: 1
116
+ }
117
+ }
118
+
119
+ const dashButtonStyle = {
120
+ backgroundColor: {
121
+ normal: "#243041",
122
+ hover: "#2f4059",
123
+ pressed: "#152132",
124
+ disabled: "#7f8c8d"
125
+ },
126
+ text: {
127
+ ...baseButtonTextStyle,
128
+ fontSize: 20
129
+ },
130
+ border: {
131
+ color: "#ffffff",
132
+ width: 1
133
+ }
134
+ }
135
+
136
+ const defaultButtonProps = {
137
+ action: {
138
+ shape: "circle",
139
+ text: "A",
140
+ width: 68,
141
+ height: 68,
142
+ controls: controlsInstance,
143
+ controlName: "action",
144
+ style: actionButtonStyle,
145
+ alpha: 0.84
146
+ },
147
+ back: {
148
+ shape: "circle",
149
+ text: "B",
150
+ width: 56,
151
+ height: 56,
152
+ controls: controlsInstance,
153
+ controlName: "back",
154
+ style: backButtonStyle,
155
+ alpha: 0.78
156
+ },
157
+ dash: {
158
+ shape: "circle",
159
+ text: "R",
160
+ width: 56,
161
+ height: 56,
162
+ controls: controlsInstance,
163
+ controlName: "dash",
164
+ style: dashButtonStyle,
165
+ alpha: 0.78
166
+ }
167
+ }
168
+
169
+ const buttonProps = (name) => {
170
+ const option = readObjectOption(readButtonOption(name))
171
+ const { enabled, component, container, ...props } = option
172
+ const defaultProps = defaultButtonProps[name]
173
+ return {
174
+ ...defaultProps,
175
+ ...props,
176
+ defaultProps
177
+ }
178
+ }
179
+
180
+ const buttonContainerProps = (name) => {
181
+ const props = buttonProps(name)
182
+ return {
183
+ width: props.width,
184
+ height: props.height,
185
+ ...readObjectOption(readButtonOption(name)).container
186
+ }
187
+ }
188
+
189
+ const joystickProps = () => {
190
+ const joystickOptions = readObjectOption(joystick())
191
+ const { component, moveInterval, threshold, ...props } = joystickOptions
192
+ const defaultProps = {
193
+ outerColor: "#d7e7ff",
194
+ innerColor: "#ffffff",
195
+ scale: 0.82,
196
+ outerScale: { x: 0.86, y: 0.86 },
197
+ innerScale: { x: 0.88, y: 0.88 },
198
+ onStart: onJoystickStart,
199
+ onChange: onJoystickChange,
200
+ onEnd: onJoystickEnd
201
+ }
202
+ return {
203
+ ...defaultProps,
204
+ ...props,
205
+ defaultProps
206
+ }
207
+ }
208
+
209
+ const applyJoystickOptions = (control) => {
210
+ const joystickOptions = joystick()
211
+ if (!control || !joystickOptions || joystickOptions === false) return
212
+ control.joystick?.updateJoystickConfig?.({
213
+ ...control.joystick?.getJoystickConfig?.(),
214
+ ...(typeof joystickOptions.moveInterval === "number" ? { moveInterval: joystickOptions.moveInterval } : {}),
215
+ ...(typeof joystickOptions.threshold === "number" ? { threshold: joystickOptions.threshold } : {})
216
+ })
217
+ }
218
+
219
+ const getJoystickThreshold = () => {
220
+ const joystickOptions = joystick()
221
+ if (joystickOptions && joystickOptions !== false && typeof joystickOptions.threshold === "number") {
222
+ return joystickOptions.threshold
223
+ }
224
+ return controlsInstance()?.joystick?.getJoystickConfig?.()?.threshold ?? 0.1
225
+ }
226
+
227
+ const getJoystickMoveInterval = () => {
228
+ const joystickOptions = joystick()
229
+ if (joystickOptions && joystickOptions !== false && typeof joystickOptions.moveInterval === "number") {
230
+ return joystickOptions.moveInterval
231
+ }
232
+ return controlsInstance()?.joystick?.getJoystickConfig?.()?.moveInterval ?? 50
233
+ }
234
+
235
+ const controlForAxis = (negativeControl, positiveControl, negativeAngle, positiveAngle, angle) => {
236
+ const distanceToNegative = Math.abs(angle - negativeAngle)
237
+ const distanceToPositive = Math.abs(angle - positiveAngle)
238
+ return distanceToNegative <= distanceToPositive ? negativeControl : positiveControl
239
+ }
240
+
241
+ const resolveJoystickControl = (event) => {
242
+ const direction = event?.direction
243
+ if (direction === "top") return "up"
244
+ if (direction === "bottom") return "down"
245
+ if (direction === "left") return "left"
246
+ if (direction === "right") return "right"
247
+
248
+ const angle = Number(event?.angle)
249
+ if (!Number.isFinite(angle)) {
250
+ if (direction === "top_left") return "left"
251
+ if (direction === "top_right") return "right"
252
+ if (direction === "bottom_left") return "left"
253
+ if (direction === "bottom_right") return "right"
254
+ return null
255
+ }
256
+
257
+ if (direction === "top_left") return controlForAxis("up", "left", 90, 180, angle)
258
+ if (direction === "top_right") return controlForAxis("right", "up", 0, 90, angle)
259
+ if (direction === "bottom_left") return controlForAxis("left", "down", 180, 270, angle)
260
+ if (direction === "bottom_right") return controlForAxis("right", "down", 360, 270, angle)
261
+ return null
262
+ }
263
+
264
+ const applyJoystickControl = (name, isDown, power = activeJoystickPower) => {
265
+ const control = controlsInstance()
266
+ const joystickControls = control?.joystick
267
+ if (joystickControls?.applyControl) {
268
+ return joystickControls.applyControl(name, isDown, { power })
269
+ }
270
+ const keyboard = control?.keyboard
271
+ if (keyboard?.applyControl) return keyboard.applyControl(name, isDown)
272
+ return control?.applyControl?.(name, isDown, { power })
273
+ }
274
+
275
+ const stopJoystickRepeat = () => {
276
+ if (!joystickRepeatTimer) return
277
+ clearInterval(joystickRepeatTimer)
278
+ joystickRepeatTimer = null
279
+ }
280
+
281
+ const startJoystickRepeat = () => {
282
+ if (!activeJoystickControl || joystickRepeatTimer) return
283
+ joystickRepeatTimer = setInterval(() => {
284
+ if (!activeJoystickControl) return
285
+ applyJoystickControl(activeJoystickControl, true)
286
+ }, getJoystickMoveInterval())
287
+ }
288
+
289
+ const releaseJoystickControl = () => {
290
+ stopJoystickRepeat()
291
+ if (!activeJoystickControl) return
292
+ const previous = activeJoystickControl
293
+ activeJoystickControl = null
294
+ activeJoystickPower = 0
295
+ applyJoystickControl(previous, false, 0)
296
+ }
297
+
298
+ const activateJoystickControl = (controlName, power = 0) => {
299
+ if (!controlName) {
300
+ releaseJoystickControl()
301
+ return
302
+ }
303
+ activeJoystickPower = power
304
+ if (activeJoystickControl === controlName) {
305
+ startJoystickRepeat()
306
+ return
307
+ }
308
+ releaseJoystickControl()
309
+ activeJoystickControl = controlName
310
+ applyJoystickControl(controlName, true, power)
311
+ startJoystickRepeat()
312
+ }
313
+
314
+ const publishMobileDebug = (type, payload = {}) => {
315
+ const target = typeof window !== "undefined" ? window : globalThis
316
+ if (!target.__RPGJS_MOBILE_DEBUG__) return
317
+ const event = {
318
+ source: "mobile-gui",
319
+ type,
320
+ time: Date.now(),
321
+ ...payload
322
+ }
323
+ target.__RPGJS_MOBILE_DEBUG_EVENTS__ = [
324
+ ...(target.__RPGJS_MOBILE_DEBUG_EVENTS__ || []).slice(-199),
325
+ event
326
+ ]
327
+ target.__RPGJS_MOBILE_DEBUG_LAST__ = event
328
+ }
329
+
330
+ const onJoystickStart = () => {
331
+ publishMobileDebug("joystick:start", {
332
+ config: controlsInstance()?.joystick?.getJoystickConfig?.()
333
+ })
334
+ }
335
+
336
+ const onJoystickChange = (event) => {
337
+ const threshold = getJoystickThreshold()
338
+ const controlName = event?.power >= threshold ? resolveJoystickControl(event) : null
339
+ publishMobileDebug("joystick:change", {
340
+ direction: event?.direction,
341
+ power: event?.power,
342
+ angle: event?.angle,
343
+ threshold,
344
+ controlName,
345
+ config: controlsInstance()?.joystick?.getJoystickConfig?.()
346
+ })
347
+ activateJoystickControl(controlName, event?.power ?? 0)
348
+ }
349
+
350
+ const onJoystickEnd = () => {
351
+ publishMobileDebug("joystick:end", {
352
+ controlName: activeJoystickControl
353
+ })
354
+ releaseJoystickControl()
355
+ }
75
356
 
76
357
  mount((element) => {
77
358
  const control = inject('KeyboardControls')
78
359
  controlsInstance.set(control)
360
+ applyJoystickOptions(control)
79
361
  })
80
- </script>
362
+ </script>
package/src/index.ts CHANGED
@@ -30,5 +30,17 @@ export { RpgClientEvent } from "./Game/Event";
30
30
  export * from "./Game/ProjectileManager";
31
31
  export * from "./Game/ClientVisuals";
32
32
  export { withMobile } from "./components/gui/mobile";
33
+ export type {
34
+ MobileButtonComponentProps,
35
+ MobileGuiButtonOptions,
36
+ MobileGuiComponentsOptions,
37
+ MobileGuiEnabled,
38
+ MobileGuiJoystickOptions,
39
+ MobileGuiJoystickSide,
40
+ MobileGuiLayoutOptions,
41
+ MobileGuiMargin,
42
+ MobileGuiOptions,
43
+ MobileJoystickComponentProps,
44
+ } from "./components/gui/mobile";
33
45
  export * from "./services/AbstractSocket";
34
46
  export * from "./i18n";
@@ -0,0 +1,220 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import {
3
+ applyCameraFollow,
4
+ cameraFollowAnimationOptions,
5
+ cameraFollowOptions,
6
+ ownsCameraFollowRevision,
7
+ } from "./cameraFollow";
8
+
9
+ const createViewport = () => ({
10
+ animate: vi.fn(),
11
+ follow: vi.fn(),
12
+ plugins: {
13
+ remove: vi.fn(),
14
+ },
15
+ });
16
+
17
+ describe("camera follow", () => {
18
+ it("normalizes transition options from smoothMove", () => {
19
+ expect(cameraFollowAnimationOptions(true)).toEqual({
20
+ time: 1000,
21
+ ease: "easeInOutSine",
22
+ });
23
+ expect(cameraFollowAnimationOptions({ time: 450, ease: "easeInOutQuad" })).toEqual({
24
+ time: 450,
25
+ ease: "easeInOutQuad",
26
+ });
27
+ expect(cameraFollowAnimationOptions({ time: 450, ease: "easeInOutQuadd" } as any)).toEqual({
28
+ time: 450,
29
+ ease: "easeInOutSine",
30
+ });
31
+ expect(cameraFollowAnimationOptions(false)).toBeNull();
32
+ });
33
+
34
+ it("normalizes continuous follow options", () => {
35
+ expect(cameraFollowOptions({ speed: 12, acceleration: 0.2, radius: 80 })).toEqual({
36
+ speed: 12,
37
+ acceleration: 0.2,
38
+ radius: 80,
39
+ });
40
+ expect(cameraFollowOptions({ speed: -4, acceleration: null, radius: null })).toEqual({
41
+ speed: 0,
42
+ acceleration: null,
43
+ radius: null,
44
+ });
45
+ expect(cameraFollowOptions(true)).toBeUndefined();
46
+ });
47
+
48
+ it("animates to the target then follows it", () => {
49
+ const viewport = createViewport();
50
+ const target = { x: 120, y: 240 };
51
+
52
+ applyCameraFollow({
53
+ viewport,
54
+ target,
55
+ smoothMove: { time: 800, ease: "easeInOutQuad", speed: 10, radius: 32 },
56
+ followRevision: 2,
57
+ isCurrentRevision: (revision) => revision === 2,
58
+ shouldFollowCamera: () => true,
59
+ });
60
+
61
+ expect(viewport.plugins.remove).toHaveBeenCalledWith("animate");
62
+ expect(viewport.plugins.remove).toHaveBeenCalledWith("follow");
63
+ expect(viewport.animate).toHaveBeenCalledWith(
64
+ expect.objectContaining({
65
+ position: { x: 120, y: 240 },
66
+ time: 800,
67
+ ease: "easeInOutQuad",
68
+ })
69
+ );
70
+ expect(viewport.follow).not.toHaveBeenCalled();
71
+
72
+ const animateOptions = viewport.animate.mock.calls[0][0];
73
+ animateOptions.callbackOnComplete();
74
+
75
+ const [followTarget, followOptions] = viewport.follow.mock.calls[0];
76
+ expect(followTarget).not.toBe(target);
77
+ expect(followTarget.x).toBe(120);
78
+ expect(followTarget.y).toBe(240);
79
+ expect(followOptions).toEqual({
80
+ speed: 10,
81
+ radius: 32,
82
+ });
83
+ });
84
+
85
+ it("follows instantly when smoothMove is disabled", () => {
86
+ const viewport = createViewport();
87
+ const target = { x: 120, y: 240 };
88
+
89
+ applyCameraFollow({
90
+ viewport,
91
+ target,
92
+ smoothMove: false,
93
+ followRevision: 1,
94
+ isCurrentRevision: () => true,
95
+ shouldFollowCamera: () => true,
96
+ });
97
+
98
+ expect(viewport.animate).not.toHaveBeenCalled();
99
+ const [followTarget] = viewport.follow.mock.calls[0];
100
+ expect(followTarget).not.toBe(target);
101
+ expect(followTarget.x).toBe(120);
102
+ expect(followTarget.y).toBe(240);
103
+ });
104
+
105
+ it("does not follow after animation if another camera command superseded it", () => {
106
+ const viewport = createViewport();
107
+
108
+ applyCameraFollow({
109
+ viewport,
110
+ target: { x: 120, y: 240 },
111
+ smoothMove: { time: 800, ease: "easeInOutQuad" },
112
+ followRevision: 2,
113
+ isCurrentRevision: (revision) => revision === 3,
114
+ shouldFollowCamera: () => true,
115
+ });
116
+
117
+ const animateOptions = viewport.animate.mock.calls[0][0];
118
+ animateOptions.callbackOnComplete();
119
+
120
+ expect(viewport.follow).not.toHaveBeenCalled();
121
+ });
122
+
123
+ it("does not follow a target whose position cannot be read", () => {
124
+ const viewport = createViewport();
125
+ const target = {
126
+ get x(): number {
127
+ throw new Error("target destroyed");
128
+ },
129
+ get y() {
130
+ return 240;
131
+ },
132
+ };
133
+
134
+ applyCameraFollow({
135
+ viewport,
136
+ target,
137
+ smoothMove: false,
138
+ followRevision: 1,
139
+ isCurrentRevision: () => true,
140
+ shouldFollowCamera: () => true,
141
+ });
142
+
143
+ expect(viewport.plugins.remove).toHaveBeenCalledWith("animate");
144
+ expect(viewport.plugins.remove).toHaveBeenCalledWith("follow");
145
+ expect(viewport.animate).not.toHaveBeenCalled();
146
+ expect(viewport.follow).not.toHaveBeenCalled();
147
+ });
148
+
149
+ it("keeps the follow target readable when the source target is destroyed", () => {
150
+ const viewport = createViewport();
151
+ let position: { x: number; y: number } | null = { x: 120, y: 240 };
152
+ const target = {
153
+ get destroyed() {
154
+ return position === null;
155
+ },
156
+ get x() {
157
+ if (!position) throw new Error("target destroyed");
158
+ return position.x;
159
+ },
160
+ get y() {
161
+ if (!position) throw new Error("target destroyed");
162
+ return position.y;
163
+ },
164
+ };
165
+
166
+ applyCameraFollow({
167
+ viewport,
168
+ target,
169
+ smoothMove: false,
170
+ followRevision: 1,
171
+ isCurrentRevision: () => true,
172
+ shouldFollowCamera: () => true,
173
+ });
174
+
175
+ const [followTarget] = viewport.follow.mock.calls[0];
176
+ expect(followTarget.x).toBe(120);
177
+ expect(followTarget.y).toBe(240);
178
+
179
+ position = { x: 160, y: 280 };
180
+ expect(followTarget.x).toBe(160);
181
+ expect(followTarget.y).toBe(280);
182
+
183
+ position = null;
184
+ expect(followTarget.x).toBe(160);
185
+ expect(followTarget.y).toBe(280);
186
+ });
187
+
188
+ it("does not follow after animation if the target was destroyed", () => {
189
+ const viewport = createViewport();
190
+ let destroyed = false;
191
+ const target = {
192
+ get destroyed() {
193
+ return destroyed;
194
+ },
195
+ x: 120,
196
+ y: 240,
197
+ };
198
+
199
+ applyCameraFollow({
200
+ viewport,
201
+ target,
202
+ smoothMove: { time: 800, ease: "easeInOutQuad" },
203
+ followRevision: 2,
204
+ isCurrentRevision: (revision) => revision === 2,
205
+ shouldFollowCamera: () => true,
206
+ });
207
+
208
+ destroyed = true;
209
+ const animateOptions = viewport.animate.mock.calls[0][0];
210
+ animateOptions.callbackOnComplete();
211
+
212
+ expect(viewport.follow).not.toHaveBeenCalled();
213
+ });
214
+
215
+ it("only lets the sprite that owns the active follow revision clear plugins", () => {
216
+ expect(ownsCameraFollowRevision(2, 2)).toBe(true);
217
+ expect(ownsCameraFollowRevision(2, 3)).toBe(false);
218
+ expect(ownsCameraFollowRevision(null, 2)).toBe(false);
219
+ });
220
+ });