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

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