expo-orb 0.1.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.
- package/.eslintrc.js +5 -0
- package/LICENSE +21 -0
- package/README.md +129 -0
- package/android/build.gradle +60 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/java/expo/modules/orb/ExpoOrbModule.kt +79 -0
- package/android/src/main/java/expo/modules/orb/ExpoOrbView.kt +105 -0
- package/android/src/main/java/expo/modules/orb/OrbConfiguration.kt +30 -0
- package/android/src/main/java/expo/modules/orb/OrbSharedState.kt +25 -0
- package/android/src/main/java/expo/modules/orb/OrbView.kt +368 -0
- package/android/src/main/java/expo/modules/orb/ParticlesView.kt +77 -0
- package/android/src/main/java/expo/modules/orb/RotatingGlowView.kt +90 -0
- package/android/src/main/java/expo/modules/orb/WavyBlobView.kt +90 -0
- package/build/ExpoOrb.types.d.ts +17 -0
- package/build/ExpoOrb.types.d.ts.map +1 -0
- package/build/ExpoOrb.types.js +2 -0
- package/build/ExpoOrb.types.js.map +1 -0
- package/build/ExpoOrbModule.d.ts +17 -0
- package/build/ExpoOrbModule.d.ts.map +1 -0
- package/build/ExpoOrbModule.js +11 -0
- package/build/ExpoOrbModule.js.map +1 -0
- package/build/ExpoOrbModule.web.d.ts +6 -0
- package/build/ExpoOrbModule.web.d.ts.map +1 -0
- package/build/ExpoOrbModule.web.js +5 -0
- package/build/ExpoOrbModule.web.js.map +1 -0
- package/build/ExpoOrbView.d.ts +4 -0
- package/build/ExpoOrbView.d.ts.map +1 -0
- package/build/ExpoOrbView.js +7 -0
- package/build/ExpoOrbView.js.map +1 -0
- package/build/ExpoOrbView.web.d.ts +4 -0
- package/build/ExpoOrbView.web.d.ts.map +1 -0
- package/build/ExpoOrbView.web.js +17 -0
- package/build/ExpoOrbView.web.js.map +1 -0
- package/build/index.d.ts +4 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +4 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/ExpoOrb.podspec +30 -0
- package/ios/ExpoOrbModule.swift +70 -0
- package/ios/ExpoOrbView.swift +105 -0
- package/ios/Orb/OrbConfiguration.swift +53 -0
- package/ios/Orb/OrbView.swift +367 -0
- package/ios/Orb/ParticlesView.swift +156 -0
- package/ios/Orb/RealisticShadows.swift +42 -0
- package/ios/Orb/RotatingGlowView.swift +62 -0
- package/ios/Orb/WavyBlobView.swift +83 -0
- package/package.json +46 -0
- package/src/ExpoOrb.types.ts +17 -0
- package/src/ExpoOrbModule.ts +22 -0
- package/src/ExpoOrbModule.web.ts +5 -0
- package/src/ExpoOrbView.tsx +11 -0
- package/src/ExpoOrbView.web.tsx +26 -0
- package/src/index.ts +3 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
//
|
|
2
|
+
// OrbView.swift
|
|
3
|
+
// Prototype-Orb
|
|
4
|
+
//
|
|
5
|
+
// Created by Siddhant Mehta on 2024-11-06.
|
|
6
|
+
//
|
|
7
|
+
import Foundation
|
|
8
|
+
import SwiftUI
|
|
9
|
+
|
|
10
|
+
// ARCHITECTURE NOTE: Shared state for activity animation
|
|
11
|
+
// ========================================================
|
|
12
|
+
// This class is the key to smooth animations when bridging React Native ↔ SwiftUI.
|
|
13
|
+
//
|
|
14
|
+
// THE PROBLEM:
|
|
15
|
+
// When JS updates props frequently (e.g., activity level), each update would normally:
|
|
16
|
+
// 1. Cross the JS-to-native bridge
|
|
17
|
+
// 2. Update an @Published property in SwiftUI
|
|
18
|
+
// 3. Trigger SwiftUI view re-renders
|
|
19
|
+
// 4. Interfere with ongoing TimelineView animations → causes flickering/shaking
|
|
20
|
+
//
|
|
21
|
+
// THE SOLUTION:
|
|
22
|
+
// 1. JS sends target values to native (can be frequent, e.g., every 100-800ms)
|
|
23
|
+
// 2. Native writes to this PLAIN (non-reactive) shared state
|
|
24
|
+
// 3. SwiftUI's TimelineView reads from shared state during its animation loop
|
|
25
|
+
// 4. Interpolation happens inside TimelineView - no external state triggers re-renders
|
|
26
|
+
//
|
|
27
|
+
// RULES FOR SMOOTH ANIMATION:
|
|
28
|
+
// - NEVER use @Published for values that change during animation
|
|
29
|
+
// - NEVER pass animated values as View parameters that change frequently
|
|
30
|
+
// - DO use shared state + TimelineView for all animated properties
|
|
31
|
+
// - DO interpolate inside TimelineView body, not in response to prop changes
|
|
32
|
+
//
|
|
33
|
+
public final class OrbSharedState {
|
|
34
|
+
public static let shared = OrbSharedState()
|
|
35
|
+
public var targetActivity: Double = 0 // Written by native bridge, read by animation loop
|
|
36
|
+
var currentActivity: Double = 0 // Interpolated value, updated each frame
|
|
37
|
+
var lastUpdateTime: Date = Date() // For frame-time-based interpolation
|
|
38
|
+
|
|
39
|
+
// Cumulative phase for breathing - allows speed changes without jumps
|
|
40
|
+
var breathingPhase: Double = 0
|
|
41
|
+
var lastBreathingUpdate: Date = Date()
|
|
42
|
+
|
|
43
|
+
// DEBUG: Set to true to test native-only activity generation (no JS involvement)
|
|
44
|
+
public var useNativeDemoMode: Bool = false
|
|
45
|
+
var lastDemoUpdate: Date = Date()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
public struct OrbView: View {
|
|
49
|
+
private let config: OrbConfiguration
|
|
50
|
+
private let useSharedActivityState: Bool
|
|
51
|
+
|
|
52
|
+
public init(configuration: OrbConfiguration = OrbConfiguration(), targetActivity: Double? = nil, useSharedActivityState: Bool = false) {
|
|
53
|
+
self.config = configuration
|
|
54
|
+
self.useSharedActivityState = useSharedActivityState || (targetActivity != nil)
|
|
55
|
+
// If targetActivity is passed directly, set it to shared state
|
|
56
|
+
if let activity = targetActivity {
|
|
57
|
+
OrbSharedState.shared.targetActivity = activity
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
public var body: some View {
|
|
62
|
+
TimelineView(.animation) { timeline in
|
|
63
|
+
let elapsedTime = timeline.date.timeIntervalSinceReferenceDate
|
|
64
|
+
|
|
65
|
+
GeometryReader { geometry in
|
|
66
|
+
let size = min(geometry.size.width, geometry.size.height)
|
|
67
|
+
|
|
68
|
+
// Interpolate activity in the same animation loop
|
|
69
|
+
let activity = interpolatedActivity(at: timeline.date)
|
|
70
|
+
let effectiveConfig = activityDerivedConfig(activity: activity)
|
|
71
|
+
let scale = breathingScale(at: timeline.date, config: effectiveConfig)
|
|
72
|
+
|
|
73
|
+
ZStack {
|
|
74
|
+
// Base gradient background layer
|
|
75
|
+
if config.showBackground {
|
|
76
|
+
background
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Creates depth with rotating glow effects
|
|
80
|
+
baseDepthGlows(size: size, config: effectiveConfig, elapsedTime: elapsedTime)
|
|
81
|
+
|
|
82
|
+
// Adds organic movement with flowing blob shapes
|
|
83
|
+
if config.showWavyBlobs {
|
|
84
|
+
wavyBlob(elapsedTime: elapsedTime)
|
|
85
|
+
wavyBlobTwo(elapsedTime: elapsedTime)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Adds bright, energetic core glow animations
|
|
89
|
+
if config.showGlowEffects {
|
|
90
|
+
coreGlowEffects(size: size, config: effectiveConfig, elapsedTime: elapsedTime)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Overlays floating particle effects for additional dynamism
|
|
94
|
+
if config.showParticles {
|
|
95
|
+
particleView
|
|
96
|
+
.frame(maxWidth: size, maxHeight: size)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Orb outline for depth
|
|
100
|
+
.overlay {
|
|
101
|
+
realisticInnerGlows
|
|
102
|
+
}
|
|
103
|
+
// Masking out all the effects so it forms a perfect circle
|
|
104
|
+
.mask {
|
|
105
|
+
Circle()
|
|
106
|
+
}
|
|
107
|
+
.aspectRatio(1, contentMode: .fit)
|
|
108
|
+
// Adding realistic, layered shadows so its brighter near the core, and softer as it grows outwards
|
|
109
|
+
.modifier(
|
|
110
|
+
RealisticShadowModifier(
|
|
111
|
+
colors: config.showShadow ? config.backgroundColors : [.clear],
|
|
112
|
+
radius: size * 0.08
|
|
113
|
+
)
|
|
114
|
+
)
|
|
115
|
+
.scaleEffect(scale)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Interpolate activity toward target within the TimelineView animation loop
|
|
121
|
+
private func interpolatedActivity(at date: Date) -> Double {
|
|
122
|
+
guard useSharedActivityState else {
|
|
123
|
+
return 0
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
let state = OrbSharedState.shared
|
|
127
|
+
|
|
128
|
+
// DEBUG: Native demo mode - generate activity without JS
|
|
129
|
+
if state.useNativeDemoMode {
|
|
130
|
+
// Update target every ~800ms
|
|
131
|
+
if date.timeIntervalSince(state.lastDemoUpdate) > 0.8 {
|
|
132
|
+
state.lastDemoUpdate = date
|
|
133
|
+
let speaking = Double.random(in: 0...1) > 0.4
|
|
134
|
+
if speaking {
|
|
135
|
+
state.targetActivity = 0.4 + Double.random(in: 0...0.6)
|
|
136
|
+
} else {
|
|
137
|
+
state.targetActivity = Double.random(in: 0...0.15)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
let target = state.targetActivity
|
|
143
|
+
let dt = date.timeIntervalSince(state.lastUpdateTime)
|
|
144
|
+
state.lastUpdateTime = date
|
|
145
|
+
|
|
146
|
+
// Smooth interpolation factor (adjusted for frame time)
|
|
147
|
+
// Higher factor = faster response to activity changes
|
|
148
|
+
let factor = min(1.0, dt * 6.0) // ~6.0 per second convergence rate
|
|
149
|
+
let next = state.currentActivity + (target - state.currentActivity) * factor
|
|
150
|
+
state.currentActivity = next
|
|
151
|
+
|
|
152
|
+
return next
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Compute derived config values from interpolated activity
|
|
156
|
+
private func activityDerivedConfig(activity: Double) -> EffectiveConfig {
|
|
157
|
+
if useSharedActivityState {
|
|
158
|
+
// Activity mode: derive values from interpolated activity
|
|
159
|
+
// IMPORTANT: Rotation speed must stay CONSTANT - changing it causes massive jumps
|
|
160
|
+
// because phase = elapsedTime * speed, and elapsedTime is ~788 million seconds
|
|
161
|
+
let speed = 18.0 // Fixed rotation speed
|
|
162
|
+
// Breathing speed CAN vary because we use cumulative phase (see breathingScale)
|
|
163
|
+
// Faster when speaking for punchy effect
|
|
164
|
+
let breathingSpeed = 0.03 + activity * 0.25 // 0.03 idle (almost none) → 0.28 speaking (punchy)
|
|
165
|
+
// Idle: no breathing, Speaking: full breathing
|
|
166
|
+
let breathingIntensity = max(0, (activity - 0.2)) * 1.25 // Kicks in above 0.2 activity
|
|
167
|
+
// Idle: barely visible glow, Speaking: bright
|
|
168
|
+
let coreGlowIntensity = 0.08 + activity * 1.8 // 0.08 idle → 1.88 speaking
|
|
169
|
+
return EffectiveConfig(
|
|
170
|
+
speed: speed,
|
|
171
|
+
breathingIntensity: breathingIntensity,
|
|
172
|
+
breathingSpeed: breathingSpeed,
|
|
173
|
+
coreGlowIntensity: coreGlowIntensity,
|
|
174
|
+
glowColor: config.glowColor
|
|
175
|
+
)
|
|
176
|
+
} else {
|
|
177
|
+
// Static mode: use config values directly
|
|
178
|
+
return EffectiveConfig(
|
|
179
|
+
speed: config.speed,
|
|
180
|
+
breathingIntensity: config.breathingIntensity,
|
|
181
|
+
breathingSpeed: config.breathingSpeed,
|
|
182
|
+
coreGlowIntensity: config.coreGlowIntensity,
|
|
183
|
+
glowColor: config.glowColor
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private struct EffectiveConfig {
|
|
189
|
+
let speed: Double
|
|
190
|
+
let breathingIntensity: Double
|
|
191
|
+
let breathingSpeed: Double
|
|
192
|
+
let coreGlowIntensity: Double
|
|
193
|
+
let glowColor: Color
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private var background: some View {
|
|
197
|
+
LinearGradient(colors: config.backgroundColors,
|
|
198
|
+
startPoint: .bottom,
|
|
199
|
+
endPoint: .top)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private var orbOutlineColor: LinearGradient {
|
|
203
|
+
LinearGradient(colors: [.white, .clear],
|
|
204
|
+
startPoint: .bottom,
|
|
205
|
+
endPoint: .top)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private var particleView: some View {
|
|
209
|
+
// Added multiple particle effects since the blurring amounts are different
|
|
210
|
+
ZStack {
|
|
211
|
+
ParticlesView(
|
|
212
|
+
color: config.particleColor,
|
|
213
|
+
speedRange: 10...20,
|
|
214
|
+
sizeRange: 0.5...1,
|
|
215
|
+
particleCount: 10,
|
|
216
|
+
opacityRange: 0...0.3
|
|
217
|
+
)
|
|
218
|
+
.blur(radius: 1)
|
|
219
|
+
|
|
220
|
+
ParticlesView(
|
|
221
|
+
color: config.particleColor,
|
|
222
|
+
speedRange: 20...30,
|
|
223
|
+
sizeRange: 0.2...1,
|
|
224
|
+
particleCount: 10,
|
|
225
|
+
opacityRange: 0.3...0.8
|
|
226
|
+
)
|
|
227
|
+
}
|
|
228
|
+
.blendMode(.plusLighter)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private func wavyBlob(elapsedTime: Double) -> some View {
|
|
232
|
+
GeometryReader { geometry in
|
|
233
|
+
let size = min(geometry.size.width, geometry.size.height)
|
|
234
|
+
// Fixed calm speed for stable blob animation
|
|
235
|
+
let blobSpeed: Double = 20
|
|
236
|
+
|
|
237
|
+
RotatingGlowView(color: .white.opacity(0.75),
|
|
238
|
+
rotationSpeed: blobSpeed * 1.5,
|
|
239
|
+
direction: .clockwise,
|
|
240
|
+
elapsedTime: elapsedTime)
|
|
241
|
+
.mask {
|
|
242
|
+
WavyBlobView(color: .white, loopDuration: 60 / blobSpeed * 1.75)
|
|
243
|
+
.frame(maxWidth: size * 1.875)
|
|
244
|
+
.offset(x: 0, y: size * 0.31)
|
|
245
|
+
}
|
|
246
|
+
.blur(radius: 1)
|
|
247
|
+
.blendMode(.plusLighter)
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private func wavyBlobTwo(elapsedTime: Double) -> some View {
|
|
252
|
+
GeometryReader { geometry in
|
|
253
|
+
let size = min(geometry.size.width, geometry.size.height)
|
|
254
|
+
// Fixed calm speed for stable blob animation
|
|
255
|
+
let blobSpeed: Double = 20
|
|
256
|
+
|
|
257
|
+
RotatingGlowView(color: .white,
|
|
258
|
+
rotationSpeed: blobSpeed * 0.75,
|
|
259
|
+
direction: .counterClockwise,
|
|
260
|
+
elapsedTime: elapsedTime)
|
|
261
|
+
.mask {
|
|
262
|
+
WavyBlobView(color: .white, loopDuration: 60 / blobSpeed * 2.25)
|
|
263
|
+
.frame(maxWidth: size * 1.25)
|
|
264
|
+
.rotationEffect(.degrees(90))
|
|
265
|
+
.offset(x: 0, y: size * -0.31)
|
|
266
|
+
}
|
|
267
|
+
.opacity(0.5)
|
|
268
|
+
.blur(radius: 1)
|
|
269
|
+
.blendMode(.plusLighter)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private func coreGlowEffects(size: CGFloat, config effectiveConfig: EffectiveConfig, elapsedTime: Double) -> some View {
|
|
274
|
+
ZStack {
|
|
275
|
+
// Slower rotation multipliers for more organic, less frantic movement
|
|
276
|
+
RotatingGlowView(color: effectiveConfig.glowColor,
|
|
277
|
+
rotationSpeed: effectiveConfig.speed * 1.2,
|
|
278
|
+
direction: .clockwise,
|
|
279
|
+
elapsedTime: elapsedTime)
|
|
280
|
+
.blur(radius: size * 0.08)
|
|
281
|
+
.opacity(effectiveConfig.coreGlowIntensity)
|
|
282
|
+
|
|
283
|
+
RotatingGlowView(color: effectiveConfig.glowColor,
|
|
284
|
+
rotationSpeed: effectiveConfig.speed * 0.9,
|
|
285
|
+
direction: .clockwise,
|
|
286
|
+
elapsedTime: elapsedTime)
|
|
287
|
+
.blur(radius: size * 0.06)
|
|
288
|
+
.opacity(effectiveConfig.coreGlowIntensity)
|
|
289
|
+
.blendMode(.plusLighter)
|
|
290
|
+
}
|
|
291
|
+
.padding(size * 0.08)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// New combined function replacing outerGlow and outerRing
|
|
295
|
+
private func baseDepthGlows(size: CGFloat, config effectiveConfig: EffectiveConfig, elapsedTime: Double) -> some View {
|
|
296
|
+
ZStack {
|
|
297
|
+
// Outer glow (previously outerGlow function)
|
|
298
|
+
RotatingGlowView(color: effectiveConfig.glowColor,
|
|
299
|
+
rotationSpeed: effectiveConfig.speed * 0.75,
|
|
300
|
+
direction: .counterClockwise,
|
|
301
|
+
elapsedTime: elapsedTime)
|
|
302
|
+
.padding(size * 0.03)
|
|
303
|
+
.blur(radius: size * 0.06)
|
|
304
|
+
.rotationEffect(.degrees(180))
|
|
305
|
+
.blendMode(.destinationOver)
|
|
306
|
+
|
|
307
|
+
// Outer ring (previously outerRing function)
|
|
308
|
+
RotatingGlowView(color: effectiveConfig.glowColor.opacity(0.5),
|
|
309
|
+
rotationSpeed: effectiveConfig.speed * 0.25,
|
|
310
|
+
direction: .clockwise,
|
|
311
|
+
elapsedTime: elapsedTime)
|
|
312
|
+
.frame(maxWidth: size * 0.94)
|
|
313
|
+
.rotationEffect(.degrees(180))
|
|
314
|
+
.padding(8)
|
|
315
|
+
.blur(radius: size * 0.032)
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private var realisticInnerGlows: some View {
|
|
320
|
+
ZStack {
|
|
321
|
+
// Outer stroke with heavy blur
|
|
322
|
+
Circle()
|
|
323
|
+
.stroke(orbOutlineColor, lineWidth: 8)
|
|
324
|
+
.blur(radius: 32)
|
|
325
|
+
.blendMode(.plusLighter)
|
|
326
|
+
|
|
327
|
+
// Inner stroke with light blur
|
|
328
|
+
Circle()
|
|
329
|
+
.stroke(orbOutlineColor, lineWidth: 4)
|
|
330
|
+
.blur(radius: 12)
|
|
331
|
+
.blendMode(.plusLighter)
|
|
332
|
+
|
|
333
|
+
Circle()
|
|
334
|
+
.stroke(orbOutlineColor, lineWidth: 1)
|
|
335
|
+
.blur(radius: 4)
|
|
336
|
+
.blendMode(.plusLighter)
|
|
337
|
+
}
|
|
338
|
+
.padding(1)
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
private func breathingScale(at date: Date, config effectiveConfig: EffectiveConfig) -> CGFloat {
|
|
342
|
+
let intensity = max(0, min(1, effectiveConfig.breathingIntensity))
|
|
343
|
+
if intensity == 0 {
|
|
344
|
+
return 1
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Use CUMULATIVE phase - this allows speed to change without jumps
|
|
348
|
+
// Each frame we add (deltaTime * speed) to the phase, rather than (absoluteTime * speed)
|
|
349
|
+
let state = OrbSharedState.shared
|
|
350
|
+
let dt = date.timeIntervalSince(state.lastBreathingUpdate)
|
|
351
|
+
state.lastBreathingUpdate = date
|
|
352
|
+
|
|
353
|
+
let speed = max(0.01, effectiveConfig.breathingSpeed)
|
|
354
|
+
state.breathingPhase += dt * speed * 2 * .pi
|
|
355
|
+
|
|
356
|
+
// Punchy wave shape - sharper peaks to mimic speech rhythm
|
|
357
|
+
let rawWave = sin(state.breathingPhase)
|
|
358
|
+
// Apply power curve: sqrt for positive (sharp attack), squared for negative (slower return)
|
|
359
|
+
let wave = rawWave >= 0
|
|
360
|
+
? pow(rawWave, 0.6) // Sharp attack (expand fast)
|
|
361
|
+
: -pow(abs(rawWave), 1.4) // Slower return (contract slower)
|
|
362
|
+
|
|
363
|
+
// Amplitude ~0.17 at full intensity = 1/3 total range (0.83 to 1.17)
|
|
364
|
+
let amplitude = CGFloat(intensity) * 0.17
|
|
365
|
+
return 1 + amplitude * CGFloat(wave) // Goes below AND above 1.0
|
|
366
|
+
}
|
|
367
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Particles.swift
|
|
3
|
+
// Prototype-Orb
|
|
4
|
+
//
|
|
5
|
+
// Created by Siddhant Mehta on 2024-11-06.
|
|
6
|
+
//
|
|
7
|
+
import SwiftUI
|
|
8
|
+
import SpriteKit
|
|
9
|
+
import UIKit
|
|
10
|
+
|
|
11
|
+
class ParticleScene: SKScene {
|
|
12
|
+
let color: UIColor
|
|
13
|
+
let speedRange: ClosedRange<Double>
|
|
14
|
+
let sizeRange: ClosedRange<CGFloat>
|
|
15
|
+
let particleCount: Int
|
|
16
|
+
let opacityRange: ClosedRange<Double>
|
|
17
|
+
|
|
18
|
+
init(
|
|
19
|
+
size: CGSize,
|
|
20
|
+
color: UIColor,
|
|
21
|
+
speedRange: ClosedRange<Double>,
|
|
22
|
+
sizeRange: ClosedRange<CGFloat>,
|
|
23
|
+
particleCount: Int,
|
|
24
|
+
opacityRange: ClosedRange<Double>
|
|
25
|
+
) {
|
|
26
|
+
self.color = color
|
|
27
|
+
self.speedRange = speedRange
|
|
28
|
+
self.sizeRange = sizeRange
|
|
29
|
+
self.particleCount = particleCount
|
|
30
|
+
self.opacityRange = opacityRange
|
|
31
|
+
super.init(size: size)
|
|
32
|
+
|
|
33
|
+
backgroundColor = .clear
|
|
34
|
+
setupParticleEmitter()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
required init?(coder aDecoder: NSCoder) {
|
|
38
|
+
fatalError("init(coder:) has not been implemented")
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private func setupParticleEmitter() {
|
|
42
|
+
let emitter = SKEmitterNode()
|
|
43
|
+
|
|
44
|
+
// Create a white particle texture
|
|
45
|
+
emitter.particleTexture = createParticleTexture()
|
|
46
|
+
|
|
47
|
+
// Update color properties
|
|
48
|
+
emitter.particleColorSequence = nil
|
|
49
|
+
emitter.particleColor = color
|
|
50
|
+
emitter.particleColorBlendFactor = 1.0
|
|
51
|
+
|
|
52
|
+
// Basic emitter properties
|
|
53
|
+
emitter.particleSpeed = CGFloat(speedRange.lowerBound)
|
|
54
|
+
emitter.particleSpeedRange = CGFloat(speedRange.upperBound - speedRange.lowerBound)
|
|
55
|
+
emitter.particleScale = sizeRange.lowerBound
|
|
56
|
+
emitter.particleScaleRange = sizeRange.upperBound - sizeRange.lowerBound
|
|
57
|
+
|
|
58
|
+
// Alpha and fade properties
|
|
59
|
+
emitter.particleAlpha = 0 // Start invisible
|
|
60
|
+
emitter.particleAlphaSpeed = CGFloat(opacityRange.upperBound) / 0.5 // Fade in over 0.5 seconds
|
|
61
|
+
emitter.particleAlphaRange = CGFloat(opacityRange.upperBound - opacityRange.lowerBound)
|
|
62
|
+
|
|
63
|
+
// Create alpha sequence for fade in/out
|
|
64
|
+
let alphaSequence = SKKeyframeSequence(keyframeValues: [
|
|
65
|
+
0, // Start invisible
|
|
66
|
+
Double.random(in: opacityRange), // Fade in to max opacity
|
|
67
|
+
Double.random(in: opacityRange), // Stay at max opacity
|
|
68
|
+
Double.random(in: opacityRange) // Fade to min opacity
|
|
69
|
+
], times: [
|
|
70
|
+
0, // At start
|
|
71
|
+
0.2, // Reach max at 20% of lifetime
|
|
72
|
+
0.8, // Stay at max until 80% of lifetime
|
|
73
|
+
1.0 // Fade to min by end
|
|
74
|
+
])
|
|
75
|
+
emitter.particleAlphaSequence = alphaSequence
|
|
76
|
+
|
|
77
|
+
// Create scale sequence for grow/shrink animation
|
|
78
|
+
let scaleSequence = SKKeyframeSequence(keyframeValues: [
|
|
79
|
+
sizeRange.lowerBound * 0.7, // Start at half min size
|
|
80
|
+
sizeRange.upperBound * 0.9, // Grow to max size
|
|
81
|
+
sizeRange.upperBound, // Stay at max
|
|
82
|
+
sizeRange.lowerBound * 0.8 // Shrink back to half min size
|
|
83
|
+
], times: [
|
|
84
|
+
0, // At start
|
|
85
|
+
0.4, // Reach max at 20% of lifetime
|
|
86
|
+
0.7, // Stay at max until 80% of lifetime
|
|
87
|
+
1.0 // Shrink by end
|
|
88
|
+
])
|
|
89
|
+
emitter.particleScaleSequence = scaleSequence
|
|
90
|
+
|
|
91
|
+
emitter.particleBlendMode = .add
|
|
92
|
+
|
|
93
|
+
// Center the emitter and set emission area to full size
|
|
94
|
+
emitter.position = CGPoint(x: size.width/2, y: size.height/2)
|
|
95
|
+
emitter.particlePositionRange = CGVector(dx: size.width, dy: size.height)
|
|
96
|
+
|
|
97
|
+
// Particle birth and lifetime
|
|
98
|
+
emitter.particleBirthRate = CGFloat(particleCount) / 2.0
|
|
99
|
+
emitter.numParticlesToEmit = 0
|
|
100
|
+
emitter.particleLifetime = 2.0
|
|
101
|
+
emitter.particleLifetimeRange = 1.0
|
|
102
|
+
|
|
103
|
+
// Update movement properties
|
|
104
|
+
emitter.emissionAngle = CGFloat.pi / 2 // Point upwards (90 degrees)
|
|
105
|
+
emitter.emissionAngleRange = CGFloat.pi / 6 // Allow 30 degree variation each way
|
|
106
|
+
|
|
107
|
+
// Add some sideways drift
|
|
108
|
+
emitter.xAcceleration = 0 // No horizontal acceleration
|
|
109
|
+
emitter.yAcceleration = 20 // Slight upward acceleration
|
|
110
|
+
|
|
111
|
+
addChild(emitter)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private func createParticleTexture() -> SKTexture {
|
|
115
|
+
let size = CGSize(width: 8, height: 8) // Smaller size for better performance
|
|
116
|
+
let renderer = UIGraphicsImageRenderer(size: size)
|
|
117
|
+
|
|
118
|
+
let image = renderer.image { context in
|
|
119
|
+
// Simple filled white circle
|
|
120
|
+
UIColor.white.setFill()
|
|
121
|
+
let circlePath = UIBezierPath(ovalIn: CGRect(origin: .zero, size: size))
|
|
122
|
+
circlePath.fill()
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return SKTexture(image: image)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
struct ParticlesView: View {
|
|
130
|
+
let color: Color
|
|
131
|
+
let speedRange: ClosedRange<Double>
|
|
132
|
+
let sizeRange: ClosedRange<CGFloat>
|
|
133
|
+
let particleCount: Int
|
|
134
|
+
let opacityRange: ClosedRange<Double>
|
|
135
|
+
|
|
136
|
+
var scene: SKScene {
|
|
137
|
+
let scene = ParticleScene(
|
|
138
|
+
size: CGSize(width: 300, height: 300), // Use fixed size
|
|
139
|
+
color: UIColor(color),
|
|
140
|
+
speedRange: speedRange,
|
|
141
|
+
sizeRange: sizeRange,
|
|
142
|
+
particleCount: particleCount,
|
|
143
|
+
opacityRange: opacityRange
|
|
144
|
+
)
|
|
145
|
+
scene.scaleMode = .aspectFit
|
|
146
|
+
return scene
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
var body: some View {
|
|
150
|
+
GeometryReader { geometry in
|
|
151
|
+
SpriteView(scene: scene, options: [.allowsTransparency])
|
|
152
|
+
.frame(width: geometry.size.width, height: geometry.size.height)
|
|
153
|
+
.ignoresSafeArea()
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
//
|
|
2
|
+
// RealisticShadows.swift
|
|
3
|
+
// Prototype-Orb
|
|
4
|
+
//
|
|
5
|
+
// Created by Siddhant Mehta on 2024-11-06.
|
|
6
|
+
//
|
|
7
|
+
import SwiftUI
|
|
8
|
+
|
|
9
|
+
struct RealisticShadowModifier: ViewModifier {
|
|
10
|
+
let colors: [Color]
|
|
11
|
+
let radius: CGFloat
|
|
12
|
+
|
|
13
|
+
func body(content: Content) -> some View {
|
|
14
|
+
content
|
|
15
|
+
.background {
|
|
16
|
+
Circle()
|
|
17
|
+
.fill(
|
|
18
|
+
LinearGradient(
|
|
19
|
+
colors: colors,
|
|
20
|
+
startPoint: .bottom,
|
|
21
|
+
endPoint: .top
|
|
22
|
+
)
|
|
23
|
+
)
|
|
24
|
+
.blur(radius: radius * 0.75)
|
|
25
|
+
.opacity(0.5)
|
|
26
|
+
.offset(y: radius * 0.5)
|
|
27
|
+
}
|
|
28
|
+
.background {
|
|
29
|
+
Circle()
|
|
30
|
+
.fill(
|
|
31
|
+
LinearGradient(
|
|
32
|
+
colors: colors,
|
|
33
|
+
startPoint: .bottom,
|
|
34
|
+
endPoint: .top
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
.blur(radius: radius * 3)
|
|
38
|
+
.opacity(0.3)
|
|
39
|
+
.offset(y: radius * 0.75)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
//
|
|
2
|
+
// BackgroundView.swift
|
|
3
|
+
// Prototype-Orb
|
|
4
|
+
//
|
|
5
|
+
// Created by Siddhant Mehta on 2024-11-06.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import SwiftUI
|
|
9
|
+
|
|
10
|
+
enum RotationDirection {
|
|
11
|
+
case clockwise
|
|
12
|
+
case counterClockwise
|
|
13
|
+
|
|
14
|
+
var multiplier: Double {
|
|
15
|
+
switch self {
|
|
16
|
+
case .clockwise: return 1
|
|
17
|
+
case .counterClockwise: return -1
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
struct RotatingGlowView: View {
|
|
23
|
+
private let color: Color
|
|
24
|
+
private let rotationSpeed: Double
|
|
25
|
+
private let direction: RotationDirection
|
|
26
|
+
private let elapsedTime: Double
|
|
27
|
+
|
|
28
|
+
init(color: Color,
|
|
29
|
+
rotationSpeed: Double = 30,
|
|
30
|
+
direction: RotationDirection,
|
|
31
|
+
elapsedTime: Double = 0)
|
|
32
|
+
{
|
|
33
|
+
self.color = color
|
|
34
|
+
self.rotationSpeed = rotationSpeed
|
|
35
|
+
self.direction = direction
|
|
36
|
+
self.elapsedTime = elapsedTime
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
var body: some View {
|
|
40
|
+
GeometryReader { geometry in
|
|
41
|
+
let size = min(geometry.size.width, geometry.size.height)
|
|
42
|
+
let safeSpeed = max(0.01, rotationSpeed)
|
|
43
|
+
let rotation = elapsedTime * safeSpeed * direction.multiplier
|
|
44
|
+
|
|
45
|
+
Circle()
|
|
46
|
+
.fill(color)
|
|
47
|
+
.mask {
|
|
48
|
+
ZStack {
|
|
49
|
+
Circle()
|
|
50
|
+
.frame(width: size, height: size)
|
|
51
|
+
.blur(radius: size * 0.16)
|
|
52
|
+
Circle()
|
|
53
|
+
.frame(width: size * 1.31, height: size * 1.31)
|
|
54
|
+
.offset(y: size * 0.31)
|
|
55
|
+
.blur(radius: size * 0.16)
|
|
56
|
+
.blendMode(.destinationOut)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
.rotationEffect(.degrees(rotation))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|