@needle-tools/engine 4.9.0-next.ce04b9f → 4.9.0

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 (30) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +1 -1
  3. package/components.needle.json +1 -1
  4. package/dist/{needle-engine.bundle-a5_4RZjH.min.js → needle-engine.bundle-B1gr_nQ0.min.js} +131 -131
  5. package/dist/{needle-engine.bundle-qHhlH-u4.js → needle-engine.bundle-BikYBC35.js} +5931 -5863
  6. package/dist/{needle-engine.bundle-DHAUN6T_.umd.cjs → needle-engine.bundle-DrlDKOar.umd.cjs} +130 -130
  7. package/dist/needle-engine.js +2 -2
  8. package/dist/needle-engine.min.js +1 -1
  9. package/dist/needle-engine.umd.cjs +1 -1
  10. package/lib/engine-components/Component.d.ts +8 -5
  11. package/lib/engine-components/Component.js +8 -5
  12. package/lib/engine-components/Component.js.map +1 -1
  13. package/lib/engine-components/physics/Attractor.d.ts +12 -0
  14. package/lib/engine-components/physics/Attractor.js +14 -2
  15. package/lib/engine-components/physics/Attractor.js.map +1 -1
  16. package/lib/engine-components/web/Clickthrough.d.ts +1 -0
  17. package/lib/engine-components/web/Clickthrough.js +4 -2
  18. package/lib/engine-components/web/Clickthrough.js.map +1 -1
  19. package/lib/engine-components/web/CursorFollow.d.ts +9 -0
  20. package/lib/engine-components/web/CursorFollow.js +17 -0
  21. package/lib/engine-components/web/CursorFollow.js.map +1 -1
  22. package/lib/engine-components/web/ScrollFollow.d.ts +47 -3
  23. package/lib/engine-components/web/ScrollFollow.js +135 -17
  24. package/lib/engine-components/web/ScrollFollow.js.map +1 -1
  25. package/package.json +3 -3
  26. package/src/engine-components/Component.ts +8 -5
  27. package/src/engine-components/physics/Attractor.ts +14 -4
  28. package/src/engine-components/web/Clickthrough.ts +6 -2
  29. package/src/engine-components/web/CursorFollow.ts +17 -2
  30. package/src/engine-components/web/ScrollFollow.ts +154 -25
@@ -1,15 +1,46 @@
1
- import { Object3D } from "three";
2
- import { serializable } from "../../engine/engine_serialization.js";
1
+ import { Box3, Object3D } from "three";
2
+
3
3
  import { Mathf } from "../../engine/engine_math.js";
4
+ import { serializable } from "../../engine/engine_serialization.js";
5
+ import { getBoundingBox } from "../../engine/engine_three_utils.js";
6
+ import { Animation } from "../Animation.js";
7
+ import { Animator } from "../Animator.js";
8
+ import { AudioSource } from "../AudioSource.js";
4
9
  import { Behaviour } from "../Component.js";
5
- import { Animation, PlayableDirector } from "../api.js";
6
-
7
-
8
-
10
+ import { EventList } from "../EventList.js";
11
+ import { Light } from "../Light.js";
12
+ import { SplineWalker } from "../splines/SplineWalker.js";
13
+ import { PlayableDirector } from "../timeline/PlayableDirector.js";
14
+
15
+ type ScrollFollowEvent = {
16
+ /** Event type */
17
+ type: "change",
18
+ /** Current scroll value */
19
+ value: number,
20
+ /** ScrollFollow component that raised the event */
21
+ component: ScrollFollow,
22
+ /** Call to prevent invocation of default (e.g. updating targets) */
23
+ preventDefault: () => void,
24
+ defaultPrevented: boolean,
25
+ }
26
+
27
+ /**
28
+ * The ScrollFollow component allows you to link the scroll position of the page (or a specific element) to one or more target objects.
29
+ * This can be used to create scroll-based animations, audio playback, or other effects. For example you can link the scroll position to a timeline (PlayableDirector) to create scroll-based storytelling effects or to an Animator component to change the animation state based on scroll.
30
+ *
31
+ * @link Example at https://scrollytelling-2-z23hmxby7c6x-u30ld.needle.run/
32
+ */
9
33
  export class ScrollFollow extends Behaviour {
10
34
 
11
35
  /**
12
36
  * Target object(s) to follow the scroll position of the page. If null, the main camera is used.
37
+ *
38
+ * Supported target types:
39
+ * - PlayableDirector (timeline), the scroll position will be mapped to the timeline time
40
+ * - Animator, the scroll position will be set to a float parameter named "scroll"
41
+ * - Animation, the scroll position will be mapped to the animation time
42
+ * - AudioSource, the scroll position will be mapped to the audio time
43
+ * - Any object with a `scroll` property (number or function)
13
44
  */
14
45
  @serializable([Behaviour, Object3D])
15
46
  target: object[] | object | null = null;
@@ -21,8 +52,29 @@ export class ScrollFollow extends Behaviour {
21
52
  @serializable()
22
53
  damping: number = 0;
23
54
 
55
+ /**
56
+ * If true, the scroll value will be inverted (e.g. scrolling down will result in a value of 0)
57
+ * @default false
58
+ */
24
59
  @serializable()
25
- mode: "window" = "window";
60
+ invert: boolean = false;
61
+
62
+ /**
63
+ * If set, the scroll position will be read from the specified element instead of the window.
64
+ * Use a CSS selector to specify the element, e.g. `#my-scrollable-div` or `.scroll-container`.
65
+ * @default null
66
+ */
67
+ @serializable()
68
+ htmlSelector: string | null = null;
69
+
70
+ @serializable()
71
+ private mode: "window" = "window";
72
+
73
+ /**
74
+ * Event fired when the scroll position changes
75
+ */
76
+ @serializable(EventList)
77
+ changed: EventList<ScrollFollowEvent> = new EventList<ScrollFollowEvent>();
26
78
 
27
79
  /**
28
80
  * Current scroll value in "pages" (0 = top of page, 1 = bottom of page)
@@ -33,58 +85,135 @@ export class ScrollFollow extends Behaviour {
33
85
 
34
86
  private current_value: number = 0;
35
87
  private target_value: number = 0;
88
+ private applied_value: number = -1;
36
89
 
37
90
  /** @internal */
38
91
  onEnable() {
39
- window.addEventListener("wheel", this.updateCurrentScroll, { passive: true });
92
+ window.addEventListener("wheel", this.updateCurrentScrollValue, { passive: true });
93
+ this.applied_value = -1;
40
94
  }
41
95
 
42
96
  /** @internal */
43
97
  onDisable() {
44
- window.removeEventListener("wheel", this.updateCurrentScroll);
98
+ window.removeEventListener("wheel", this.updateCurrentScrollValue);
45
99
  }
46
100
 
47
101
  /** @internal */
48
102
  lateUpdate() {
49
103
 
104
+ this.updateCurrentScrollValue();
105
+
50
106
  // apply damping if any
51
107
  if (this.damping > 0) {
52
108
  this.current_value = Mathf.lerp(this.current_value, this.target_value, this.context.time.deltaTime / this.damping);
53
109
  }
54
-
55
- // apply scroll to target(s)
56
- if (Array.isArray(this.target)) {
57
- this.target.forEach(t => t && this.applyScroll(t));
58
- }
59
- else if (this.target) {
60
- this.applyScroll(this.target);
110
+ else {
111
+ this.current_value = this.target_value;
61
112
  }
62
113
 
114
+ if (this.current_value !== this.applied_value) {
115
+ this.applied_value = this.current_value;
116
+
117
+ let defaultPrevented = false;
118
+ if (this.changed.listenerCount > 0) {
119
+ // fire change event
120
+ const event: ScrollFollowEvent = {
121
+ type: "change",
122
+ value: this.current_value,
123
+ component: this,
124
+ preventDefault: () => { event.defaultPrevented = true; },
125
+ defaultPrevented: false,
126
+ };
127
+ this.changed.invoke(event);
128
+ defaultPrevented = event.defaultPrevented;
129
+ }
130
+
131
+ // if not prevented apply scroll
132
+ if (!defaultPrevented) {
133
+
134
+ const value = this.invert ? 1 - this.current_value : this.current_value;
135
+
136
+ // apply scroll to target(s)
137
+ if (Array.isArray(this.target)) {
138
+ this.target.forEach(t => t && ScrollFollow.applyScroll(t, value));
139
+ }
140
+ else if (this.target) {
141
+ ScrollFollow.applyScroll(this.target, value);
142
+ }
143
+ }
144
+ }
63
145
  }
64
146
 
147
+ private _lastSelectorValue: string | null = null;
148
+ private _lastSelectorElement: Element | null = null;
65
149
 
66
- private updateCurrentScroll = () => {
150
+ private updateCurrentScrollValue = () => {
67
151
 
68
152
  switch (this.mode) {
69
153
  case "window":
70
- this.target_value = window.scrollY / (document.body.scrollHeight - window.innerHeight);
154
+ if (this.htmlSelector?.length) {
155
+ if (this.htmlSelector !== this._lastSelectorValue) {
156
+ this._lastSelectorElement = document.querySelector(this.htmlSelector);
157
+ this._lastSelectorValue = this.htmlSelector;
158
+ }
159
+ if (this._lastSelectorElement) {
160
+ const rect = this._lastSelectorElement.getBoundingClientRect();
161
+ this.target_value = -rect.top / (rect.height - window.innerHeight);
162
+ if (isNaN(this.target_value) || !isFinite(this.target_value)) this.target_value = 0;
163
+ break;
164
+ }
165
+ }
166
+ else {
167
+ this.target_value = window.scrollY / (document.body.scrollHeight - window.innerHeight);
168
+ }
71
169
  if (isNaN(this.target_value) || !isFinite(this.target_value)) this.target_value = 0;
72
170
  break;
73
171
  }
74
172
 
75
- if (this.damping <= 0) {
76
- this.current_value = this.target_value;
77
- }
78
-
79
173
  }
80
174
 
81
- private applyScroll(target: object) {
175
+
176
+ private static applyScroll(target: object, value: number) {
177
+
178
+ if (!target) return;
179
+
82
180
  if (target instanceof PlayableDirector) {
83
- target.time = this.current_value * target.duration;
181
+ target.time = value * target.duration;
84
182
  if (!target.isPlaying) target.evaluate();
85
183
  }
184
+ else if (target instanceof Animator) {
185
+ target.setFloat("scroll", value);
186
+ }
86
187
  else if (target instanceof Animation) {
87
- target.time = this.current_value * target.duration;
188
+ target.time = value * target.duration;
189
+ }
190
+ else if (target instanceof AudioSource) {
191
+ if (!target.duration) return;
192
+ target.time = value * target.duration;
193
+ }
194
+ else if (target instanceof SplineWalker) {
195
+ target.position01 = value;
196
+ }
197
+ else if (target instanceof Light) {
198
+ target.intensity = value;
199
+ }
200
+ else if (target instanceof Object3D) {
201
+ // When objects are assigned they're expected to move vertically based on scroll
202
+ if (target["needle:scrollbounds"] === undefined) {
203
+ target["needle:scrollbounds"] = getBoundingBox(target) || null;
204
+ }
205
+ const bounds = target["needle:scrollbounds"] as Box3;
206
+ if (bounds) {
207
+ target.position.y = -bounds.min.y - value * (bounds.max.y - bounds.min.y);
208
+ }
209
+ }
210
+ else if ("scroll" in target) {
211
+ if (typeof target.scroll === "number") {
212
+ target.scroll = value;
213
+ }
214
+ else if (typeof target.scroll === "function") {
215
+ target.scroll(value);
216
+ }
88
217
  }
89
218
  }
90
219