@joshtol/emotive-engine 3.2.1 → 3.2.3

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.
@@ -0,0 +1,248 @@
1
+ /**
2
+ * BehaviorController - Manages rotation, righting, and facing behaviors
3
+ *
4
+ * Centralizes behavior management for 3D mascot:
5
+ * - RotationBehavior: Emotion-driven rotation with wobble
6
+ * - RightingBehavior: Self-stabilization (punching bag effect)
7
+ * - FacingBehavior: Tidal lock for moon geometry
8
+ *
9
+ * Extracted from Core3DManager to improve separation of concerns.
10
+ *
11
+ * @module 3d/managers/BehaviorController
12
+ */
13
+
14
+ import RotationBehavior from '../behaviors/RotationBehavior.js';
15
+ import RightingBehavior from '../behaviors/RightingBehavior.js';
16
+ import FacingBehavior from '../behaviors/FacingBehavior.js';
17
+
18
+ /**
19
+ * Default righting behavior configuration
20
+ */
21
+ const DEFAULT_RIGHTING_CONFIG = {
22
+ strength: 5.0, // Strong righting without overdoing it
23
+ damping: 0.85, // Critically damped for smooth return
24
+ centerOfMass: [0, -0.3, 0], // Bottom-heavy
25
+ axes: { pitch: true, roll: true, yaw: false }
26
+ };
27
+
28
+ export class BehaviorController {
29
+ /**
30
+ * Create behavior controller
31
+ * @param {Object} options - Configuration options
32
+ * @param {boolean} options.rotationDisabled - Whether rotation is disabled
33
+ * @param {boolean} options.wobbleEnabled - Whether wobble effects are enabled
34
+ */
35
+ constructor(options = {}) {
36
+ this.rotationBehavior = null;
37
+ this.rightingBehavior = null;
38
+ this.facingBehavior = null;
39
+
40
+ // State flags
41
+ this.rotationDisabled = options.rotationDisabled || false;
42
+ this.wobbleEnabled = options.wobbleEnabled !== false; // Default: enabled
43
+
44
+ // Initialize righting behavior (always active)
45
+ this.rightingBehavior = new RightingBehavior(DEFAULT_RIGHTING_CONFIG);
46
+ }
47
+
48
+ /**
49
+ * Configure rotation behavior based on emotion and geometry
50
+ * @param {Object} options - Configuration options
51
+ * @param {string} options.geometryType - Current geometry type
52
+ * @param {Object} options.emotionData - Emotion data with 3d rotation config
53
+ * @param {Object} options.facingConfig - Facing behavior config (for moon)
54
+ */
55
+ configureForEmotion(options = {}) {
56
+ const { geometryType, emotionData, facingConfig } = options;
57
+
58
+ // Moon is tidally locked - no rotation
59
+ if (geometryType === 'moon') {
60
+ this._disableRotation();
61
+ this._initFacingBehavior(facingConfig);
62
+ return;
63
+ }
64
+
65
+ // Dispose facing behavior if not moon
66
+ this._disposeFacingBehavior();
67
+
68
+ // Skip if rotation is disabled by user
69
+ if (this.rotationDisabled) {
70
+ this._disableRotation();
71
+ return;
72
+ }
73
+
74
+ // Configure rotation based on emotion
75
+ if (emotionData?.['3d']?.rotation) {
76
+ this._configureRotationFromEmotion(emotionData['3d'].rotation);
77
+ } else {
78
+ // Default rotation if no emotion-specific config
79
+ this._ensureDefaultRotation();
80
+ }
81
+
82
+ // Reset righting behavior on emotion change
83
+ if (this.rightingBehavior) {
84
+ this.rightingBehavior.reset();
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Apply undertone modifiers to behaviors
90
+ * @param {Object} undertone3d - 3D undertone modifiers
91
+ */
92
+ applyUndertone(undertone3d) {
93
+ if (!undertone3d) return;
94
+
95
+ if (undertone3d.rotation && this.rotationBehavior) {
96
+ this.rotationBehavior.applyUndertoneMultipliers(undertone3d.rotation);
97
+ }
98
+
99
+ if (undertone3d.righting && this.rightingBehavior) {
100
+ this.rightingBehavior.applyUndertoneMultipliers(undertone3d.righting);
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Update all behaviors for current frame
106
+ * @param {number} deltaTime - Time since last frame
107
+ * @param {THREE.Euler} baseEuler - Base euler rotation to modify
108
+ */
109
+ update(deltaTime, baseEuler) {
110
+ // Update rotation behavior
111
+ if (this.rotationBehavior) {
112
+ this.rotationBehavior.update(deltaTime, baseEuler);
113
+ }
114
+
115
+ // Update righting behavior (self-stabilization)
116
+ if (this.rightingBehavior) {
117
+ this.rightingBehavior.update(deltaTime, baseEuler);
118
+ }
119
+
120
+ // Update facing behavior (tidal lock)
121
+ if (this.facingBehavior) {
122
+ this.facingBehavior.update(deltaTime, baseEuler);
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Set wobble enabled state
128
+ * @param {boolean} enabled - Whether wobble is enabled
129
+ */
130
+ setWobbleEnabled(enabled) {
131
+ this.wobbleEnabled = enabled;
132
+ if (this.rotationBehavior) {
133
+ this.rotationBehavior.setWobbleEnabled(enabled);
134
+ }
135
+
136
+ // When disabling wobble, reset to upright position
137
+ if (!enabled && this.rightingBehavior) {
138
+ this.rightingBehavior.reset();
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Reset righting behavior
144
+ */
145
+ resetRighting() {
146
+ if (this.rightingBehavior) {
147
+ this.rightingBehavior.reset();
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Get angular velocity from rotation behavior
153
+ * @returns {Array} [x, y, z] angular velocity or [0, 0, 0]
154
+ */
155
+ getAngularVelocity() {
156
+ return this.rotationBehavior ? this.rotationBehavior.axes : [0, 0, 0];
157
+ }
158
+
159
+ /**
160
+ * Check if rotation behavior is active
161
+ * @returns {boolean}
162
+ */
163
+ hasRotationBehavior() {
164
+ return !!this.rotationBehavior;
165
+ }
166
+
167
+ /**
168
+ * Configure rotation behavior from emotion data
169
+ * @private
170
+ */
171
+ _configureRotationFromEmotion(rotationConfig) {
172
+ if (this.rotationBehavior) {
173
+ this.rotationBehavior.updateConfig(rotationConfig);
174
+ } else {
175
+ this.rotationBehavior = new RotationBehavior(rotationConfig);
176
+ }
177
+ this.rotationBehavior.setWobbleEnabled(this.wobbleEnabled);
178
+ }
179
+
180
+ /**
181
+ * Ensure default rotation behavior exists
182
+ * @private
183
+ */
184
+ _ensureDefaultRotation() {
185
+ if (!this.rotationBehavior) {
186
+ this.rotationBehavior = new RotationBehavior({
187
+ axes: [0, 0.3, 0] // Default gentle Y rotation
188
+ });
189
+ this.rotationBehavior.setWobbleEnabled(this.wobbleEnabled);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Disable rotation behavior
195
+ * @private
196
+ */
197
+ _disableRotation() {
198
+ this.rotationBehavior = null;
199
+ }
200
+
201
+ /**
202
+ * Initialize facing behavior for tidal lock
203
+ * @private
204
+ */
205
+ _initFacingBehavior(facingConfig) {
206
+ if (!this.facingBehavior && facingConfig?.enabled) {
207
+ this.facingBehavior = new FacingBehavior({
208
+ strength: facingConfig.strength,
209
+ damping: facingConfig.damping,
210
+ faceAxis: facingConfig.faceAxis,
211
+ targetAxis: facingConfig.targetAxis
212
+ });
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Dispose facing behavior
218
+ * @private
219
+ */
220
+ _disposeFacingBehavior() {
221
+ if (this.facingBehavior) {
222
+ this.facingBehavior.dispose();
223
+ this.facingBehavior = null;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Dispose all behaviors and clean up
229
+ */
230
+ dispose() {
231
+ if (this.rotationBehavior) {
232
+ this.rotationBehavior.destroy?.();
233
+ this.rotationBehavior = null;
234
+ }
235
+
236
+ if (this.rightingBehavior) {
237
+ this.rightingBehavior.destroy?.();
238
+ this.rightingBehavior = null;
239
+ }
240
+
241
+ if (this.facingBehavior) {
242
+ this.facingBehavior.dispose();
243
+ this.facingBehavior = null;
244
+ }
245
+ }
246
+ }
247
+
248
+ export default BehaviorController;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * BreathingPhaseManager - Imperative breathing phase animation
3
+ *
4
+ * Provides explicit control over breathing animations for meditation:
5
+ * - breathePhase('inhale', 4) animates scale up over 4 seconds
6
+ * - breathePhase('exhale', 4) animates scale down over 4 seconds
7
+ * - breathePhase('hold', 4) maintains current scale
8
+ *
9
+ * Extracted from Core3DManager to improve separation of concerns.
10
+ *
11
+ * @module 3d/managers/BreathingPhaseManager
12
+ */
13
+
14
+ /**
15
+ * Scale targets for different phases
16
+ */
17
+ const PHASE_SCALES = {
18
+ inhale: 1.3, // Max inhale size
19
+ exhale: 0.85, // Min exhale size
20
+ neutral: 1.0 // Default/reset scale
21
+ };
22
+
23
+ /**
24
+ * Duration limits in seconds
25
+ */
26
+ const MIN_DURATION = 0.5;
27
+ const MAX_DURATION = 30;
28
+
29
+ export class BreathingPhaseManager {
30
+ constructor() {
31
+ // Current phase state
32
+ this._phase = null; // 'inhale' | 'hold' | 'exhale' | null
33
+ this._startTime = 0;
34
+ this._duration = 0; // Duration in ms
35
+ this._startScale = 1.0;
36
+ this._targetScale = 1.0;
37
+ this._currentScale = 1.0; // Current animated scale
38
+ }
39
+
40
+ /**
41
+ * Start a breathing phase animation
42
+ * @param {string} phase - 'inhale' | 'exhale' | 'hold'
43
+ * @param {number} durationSec - Duration in seconds for the animation
44
+ */
45
+ startPhase(phase, durationSec) {
46
+ // Clamp duration to reasonable values
47
+ const duration = Math.max(MIN_DURATION, Math.min(MAX_DURATION, durationSec));
48
+
49
+ // Store current scale as starting point
50
+ this._startScale = this._currentScale;
51
+ this._startTime = performance.now();
52
+ this._duration = duration * 1000; // Convert to ms
53
+ this._phase = phase;
54
+
55
+ // Set target scale based on phase
56
+ switch (phase) {
57
+ case 'inhale':
58
+ this._targetScale = PHASE_SCALES.inhale;
59
+ break;
60
+ case 'exhale':
61
+ this._targetScale = PHASE_SCALES.exhale;
62
+ break;
63
+ case 'hold':
64
+ default:
65
+ // Hold at current scale - no animation needed
66
+ this._targetScale = this._startScale;
67
+ break;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Stop any active breathing phase animation and reset to neutral scale
73
+ */
74
+ stop() {
75
+ this._phase = null;
76
+ this._currentScale = PHASE_SCALES.neutral;
77
+ this._startScale = PHASE_SCALES.neutral;
78
+ this._targetScale = PHASE_SCALES.neutral;
79
+ }
80
+
81
+ /**
82
+ * Check if a breathing phase is currently active
83
+ * @returns {boolean}
84
+ */
85
+ isActive() {
86
+ return this._phase !== null;
87
+ }
88
+
89
+ /**
90
+ * Get current phase name
91
+ * @returns {string|null}
92
+ */
93
+ getPhase() {
94
+ return this._phase;
95
+ }
96
+
97
+ /**
98
+ * Update breathing phase animation
99
+ * @param {number} _deltaTime - Time since last frame in ms (unused, we use elapsed time)
100
+ * @returns {number} Current breathing phase scale multiplier (1.0 if inactive)
101
+ */
102
+ update(_deltaTime) {
103
+ // If no active phase, return current scale
104
+ if (!this._phase) {
105
+ return this._currentScale;
106
+ }
107
+
108
+ const now = performance.now();
109
+ const elapsed = now - this._startTime;
110
+
111
+ // Calculate progress (0 to 1)
112
+ const progress = Math.min(1.0, elapsed / this._duration);
113
+
114
+ // Use sine easing for natural breathing rhythm
115
+ // sin(0 to π/2) maps 0→1 smoothly, reaches target exactly at end
116
+ // This feels more like natural breathing than cubic easing
117
+ const eased = Math.sin(progress * Math.PI / 2);
118
+
119
+ // Interpolate between start and target scale
120
+ this._currentScale = this._startScale +
121
+ (this._targetScale - this._startScale) * eased;
122
+
123
+ // Clear phase when complete
124
+ if (progress >= 1.0) {
125
+ this._currentScale = this._targetScale;
126
+ this._phase = null;
127
+ }
128
+
129
+ return this._currentScale;
130
+ }
131
+
132
+ /**
133
+ * Get current scale multiplier
134
+ * @returns {number}
135
+ */
136
+ getScaleMultiplier() {
137
+ return this._currentScale;
138
+ }
139
+
140
+ /**
141
+ * Get current animation state for debugging
142
+ * @returns {Object}
143
+ */
144
+ getState() {
145
+ return {
146
+ phase: this._phase,
147
+ startScale: this._startScale,
148
+ targetScale: this._targetScale,
149
+ currentScale: this._currentScale,
150
+ duration: this._duration,
151
+ isActive: this._phase !== null
152
+ };
153
+ }
154
+
155
+ /**
156
+ * Dispose manager (nothing to clean up, but follows pattern)
157
+ */
158
+ dispose() {
159
+ this._phase = null;
160
+ }
161
+ }
162
+
163
+ export default BreathingPhaseManager;
@@ -0,0 +1,182 @@
1
+ /**
2
+ * CameraPresetManager - Manages camera preset views and transitions
3
+ *
4
+ * Provides smooth camera transitions to predefined viewpoints:
5
+ * - front, side, top, angle, back, bottom
6
+ *
7
+ * Extracted from ThreeRenderer to improve separation of concerns.
8
+ *
9
+ * @module 3d/managers/CameraPresetManager
10
+ */
11
+
12
+ import * as THREE from 'three';
13
+
14
+ /**
15
+ * Available camera preset positions
16
+ * @param {number} d - Camera distance from origin
17
+ */
18
+ const getPresets = d => ({
19
+ front: { x: 0, y: 0, z: d },
20
+ side: { x: d, y: 0, z: 0 },
21
+ top: { x: 0, y: d, z: 0 },
22
+ angle: { x: d * 0.67, y: d * 0.5, z: d * 0.67 },
23
+ back: { x: 0, y: 0, z: -d },
24
+ bottom: { x: 0, y: -d, z: 0 }
25
+ });
26
+
27
+ export class CameraPresetManager {
28
+ /**
29
+ * Create camera preset manager
30
+ * @param {THREE.Camera} camera - The camera to control
31
+ * @param {OrbitControls} controls - The orbit controls
32
+ * @param {number} cameraDistance - Default distance from origin
33
+ */
34
+ constructor(camera, controls, cameraDistance = 3) {
35
+ this.camera = camera;
36
+ this.controls = controls;
37
+ this.cameraDistance = cameraDistance;
38
+ this.animationId = null;
39
+ }
40
+
41
+ /**
42
+ * Get list of available presets
43
+ * @returns {string[]}
44
+ */
45
+ getAvailablePresets() {
46
+ return Object.keys(getPresets(this.cameraDistance));
47
+ }
48
+
49
+ /**
50
+ * Set camera to a preset view with smooth transition
51
+ * @param {string} preset - Preset name ('front', 'side', 'top', 'angle', 'back', 'bottom')
52
+ * @param {number} duration - Transition duration in ms (default 1000)
53
+ * @param {boolean} preserveTarget - If true, keep the current controls.target (default false)
54
+ */
55
+ setPreset(preset, duration = 1000, preserveTarget = false) {
56
+ if (!this.controls) return;
57
+
58
+ const presets = getPresets(this.cameraDistance);
59
+ const targetPos = presets[preset];
60
+
61
+ if (!targetPos) {
62
+ console.warn(`Unknown camera preset: ${preset}`);
63
+ return;
64
+ }
65
+
66
+ // Cancel any ongoing animation
67
+ this.cancelAnimation();
68
+
69
+ // Save current target if we need to preserve it
70
+ const savedTarget = preserveTarget ? this.controls.target.clone() : null;
71
+
72
+ // If instant (duration = 0), set position directly
73
+ if (duration === 0) {
74
+ this._setInstant(targetPos, savedTarget);
75
+ return;
76
+ }
77
+
78
+ // Animated transition
79
+ this._animateTo(targetPos, duration, savedTarget, preserveTarget);
80
+ }
81
+
82
+ /**
83
+ * Set camera position instantly
84
+ * @private
85
+ */
86
+ _setInstant(targetPos, savedTarget) {
87
+ // Fully reset OrbitControls to initial state
88
+ this.controls.reset();
89
+ // Then set to target position
90
+ this.camera.position.set(targetPos.x, targetPos.y, targetPos.z);
91
+ // Preserve or reset the controls target
92
+ if (savedTarget) {
93
+ this.controls.target.copy(savedTarget);
94
+ this.camera.lookAt(savedTarget);
95
+ } else {
96
+ this.controls.target.set(0, 0, 0);
97
+ this.camera.lookAt(0, 0, 0);
98
+ }
99
+ this.controls.update();
100
+ }
101
+
102
+ /**
103
+ * Animate camera to target position
104
+ * @private
105
+ */
106
+ _animateTo(targetPos, duration, savedTarget, preserveTarget) {
107
+ // Reset OrbitControls target to center (origin) for animated presets
108
+ if (!preserveTarget) {
109
+ this.controls.target.set(0, 0, 0);
110
+ }
111
+
112
+ // Smoothly animate camera to target position
113
+ const startPos = this.camera.position.clone();
114
+ const endPos = new THREE.Vector3(targetPos.x, targetPos.y, targetPos.z);
115
+ const startTime = performance.now();
116
+
117
+ const animate = currentTime => {
118
+ const elapsed = currentTime - startTime;
119
+ const progress = Math.min(elapsed / duration, 1.0);
120
+
121
+ // Ease out cubic for smooth deceleration
122
+ const eased = 1 - Math.pow(1 - progress, 3);
123
+
124
+ this.camera.position.lerpVectors(startPos, endPos, eased);
125
+ this.camera.lookAt(0, 0, 0);
126
+ this.controls.update();
127
+
128
+ if (progress < 1.0) {
129
+ this.animationId = requestAnimationFrame(animate);
130
+ } else {
131
+ this.animationId = null;
132
+ }
133
+ };
134
+
135
+ this.animationId = requestAnimationFrame(animate);
136
+ }
137
+
138
+ /**
139
+ * Check if animation is in progress
140
+ * @returns {boolean}
141
+ */
142
+ isAnimating() {
143
+ return this.animationId !== null;
144
+ }
145
+
146
+ /**
147
+ * Cancel any ongoing camera animation
148
+ */
149
+ cancelAnimation() {
150
+ if (this.animationId !== null) {
151
+ cancelAnimationFrame(this.animationId);
152
+ this.animationId = null;
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Reset camera to default front view
158
+ * @param {number} duration - Transition duration in ms
159
+ */
160
+ reset(duration = 1000) {
161
+ this.setPreset('front', duration);
162
+ }
163
+
164
+ /**
165
+ * Update camera distance (affects preset positions)
166
+ * @param {number} distance - New camera distance
167
+ */
168
+ setCameraDistance(distance) {
169
+ this.cameraDistance = distance;
170
+ }
171
+
172
+ /**
173
+ * Dispose manager and clean up
174
+ */
175
+ dispose() {
176
+ this.cancelAnimation();
177
+ this.camera = null;
178
+ this.controls = null;
179
+ }
180
+ }
181
+
182
+ export default CameraPresetManager;