harmonica 0.0.1 → 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.
- checksums.yaml +4 -4
- data/LICENSE.txt +21 -0
- data/README.md +242 -16
- data/harmonica.gemspec +5 -11
- data/lib/harmonica/point.rb +53 -0
- data/lib/harmonica/projectile.rb +73 -0
- data/lib/harmonica/spring.rb +158 -0
- data/lib/harmonica/vector.rb +95 -0
- data/lib/harmonica/version.rb +2 -1
- data/lib/harmonica.rb +16 -2
- metadata +13 -22
- data/CHANGELOG.md +0 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a44f86b9f43f12624407af947f1e81c1fa191bccc81263cb2b4228af8582f377
|
|
4
|
+
data.tar.gz: 414eabc26823d5e03fe022406753472f8dc1e9e0a4b99ebce150b6b8a624cbab
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1225b332b512135e1044afd04b1d4c74c281f93992b26999abd46147d5d55da7830df1fadd5527a5db2cc996c828345a42e0d9dac27e380eeb6d674a298e6ec3
|
|
7
|
+
data.tar.gz: f4057ff79e4105e693e8359423dd7d1249ca3cc803ba2839519f7478f5b303691540d603617327c8caa765eaa4842680873c9b7ce9e9dce6527546c08fc7ff71
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Marco Roth
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
CHANGED
|
@@ -1,39 +1,265 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
|
+
<h1>Harmonica for Ruby</h1>
|
|
3
|
+
<h4>A simple, physics-based animation library for Ruby.</h4>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<p>
|
|
6
|
+
<a href="https://rubygems.org/gems/harmonica"><img alt="Gem Version" src="https://img.shields.io/gem/v/harmonica"></a>
|
|
7
|
+
<a href="https://github.com/marcoroth/harmonica-ruby/blob/main/LICENSE.txt"><img alt="License" src="https://img.shields.io/github/license/marcoroth/harmonica-ruby"></a>
|
|
8
|
+
</p>
|
|
4
9
|
|
|
5
|
-
|
|
10
|
+
<p>Ruby implementation of <a href="https://github.com/charmbracelet/harmonica">charmbracelet/harmonica</a>.<br/>A simple, efficient spring animation library for smooth, natural motion.</p>
|
|
11
|
+
</div>
|
|
6
12
|
|
|
7
13
|
## Installation
|
|
8
14
|
|
|
9
|
-
|
|
15
|
+
**Add to your Gemfile:**
|
|
10
16
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
|
|
17
|
+
```ruby
|
|
18
|
+
gem "harmonica"
|
|
15
19
|
```
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
**Or install directly:**
|
|
18
22
|
|
|
19
23
|
```bash
|
|
20
|
-
gem install
|
|
24
|
+
gem install harmonica
|
|
21
25
|
```
|
|
22
26
|
|
|
27
|
+
## Components
|
|
28
|
+
|
|
29
|
+
| Component | Description |
|
|
30
|
+
|-----------|-------------|
|
|
31
|
+
| [Spring](#spring) | Damped harmonic oscillator for smooth UI animations |
|
|
32
|
+
| [Projectile](#projectile) | Physics projectile motion for particles |
|
|
33
|
+
| [Point](#point) | 3D point coordinates |
|
|
34
|
+
| [Vector](#vector) | 3D vector with magnitude and direction |
|
|
35
|
+
|
|
23
36
|
## Usage
|
|
24
37
|
|
|
25
|
-
|
|
38
|
+
### Spring
|
|
39
|
+
|
|
40
|
+
Spring provides smooth, realistic motion using a damped harmonic oscillator. Perfect for UI animations like scroll positions, element transitions, and interactive feedback.
|
|
41
|
+
|
|
42
|
+
**Basic usage:**
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
require "harmonica"
|
|
46
|
+
|
|
47
|
+
spring = Harmonica::Spring.new(
|
|
48
|
+
delta_time: Harmonica.fps(60), # 60 FPS
|
|
49
|
+
angular_frequency: 6.0, # Speed of motion
|
|
50
|
+
damping_ratio: 0.5 # Smoothness
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
position = 0.0
|
|
54
|
+
velocity = 0.0
|
|
55
|
+
target = 100.0
|
|
56
|
+
|
|
57
|
+
loop do
|
|
58
|
+
position, velocity = spring.update(position, velocity, target)
|
|
59
|
+
|
|
60
|
+
break if (position - target).abs < 0.01
|
|
61
|
+
end
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
**Damping ratios:**
|
|
65
|
+
|
|
66
|
+
| Ratio | Behavior |
|
|
67
|
+
|-------|----------|
|
|
68
|
+
| `< 1.0` | Under-damped: oscillates before settling (bouncy) |
|
|
69
|
+
| `= 1.0` | Critically-damped: fastest without oscillation |
|
|
70
|
+
| `> 1.0` | Over-damped: slow approach, no oscillation |
|
|
71
|
+
|
|
72
|
+
**Example with Bubbletea:**
|
|
73
|
+
|
|
74
|
+
```ruby
|
|
75
|
+
class ScrollModel
|
|
76
|
+
def initialize
|
|
77
|
+
@scroll_position = 0.0
|
|
78
|
+
@scroll_velocity = 0.0
|
|
79
|
+
@target_scroll = 0.0
|
|
80
|
+
|
|
81
|
+
@spring = Harmonica::Spring.new(
|
|
82
|
+
delta_time: Harmonica.fps(60),
|
|
83
|
+
angular_frequency: 5.0,
|
|
84
|
+
damping_ratio: 0.8
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def update(message)
|
|
89
|
+
case message
|
|
90
|
+
when Bubbletea::KeyMessage
|
|
91
|
+
@target_scroll += 10 if message.to_s == "down"
|
|
92
|
+
@target_scroll -= 10 if message.to_s == "up"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
@scroll_position, @scroll_velocity = @spring.update(
|
|
96
|
+
@scroll_position,
|
|
97
|
+
@scroll_velocity,
|
|
98
|
+
@target_scroll
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
[self, nil]
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Projectile
|
|
107
|
+
|
|
108
|
+
Projectile simulates physics motion with position, velocity, and acceleration. Great for particles, falling objects, and game physics.
|
|
109
|
+
|
|
110
|
+
#### Basic usage
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
require "harmonica"
|
|
114
|
+
|
|
115
|
+
projectile = Harmonica::Projectile.new(
|
|
116
|
+
delta_time: Harmonica.fps(60),
|
|
117
|
+
position: Harmonica::Point.new(0, 100, 0),
|
|
118
|
+
velocity: Harmonica::Vector.new(10, 20, 0),
|
|
119
|
+
acceleration: Harmonica::GRAVITY
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
loop do
|
|
123
|
+
position = projectile.update
|
|
124
|
+
|
|
125
|
+
puts "Position: #{position.x}, #{position.y}"
|
|
126
|
+
break if position.y <= 0
|
|
127
|
+
end
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
#### Gravity constants
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
Harmonica::GRAVITY # Vector(0, -9.81, 0) - origin at bottom-left
|
|
134
|
+
Harmonica::TERMINAL_GRAVITY # Vector(0, 9.81, 0) - origin at top-left
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
#### Custom acceleration
|
|
138
|
+
|
|
139
|
+
**No gravity (space)**
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
projectile = Harmonica::Projectile.new(
|
|
143
|
+
delta_time: Harmonica.fps(60),
|
|
144
|
+
position: Harmonica::Point.new(0, 0, 0),
|
|
145
|
+
velocity: Harmonica::Vector.new(5, 5, 0),
|
|
146
|
+
acceleration: Harmonica::Vector.new(0, 0, 0)
|
|
147
|
+
)
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
**Strong gravity**
|
|
151
|
+
|
|
152
|
+
```ruby
|
|
153
|
+
projectile = Harmonica::Projectile.new(
|
|
154
|
+
delta_time: Harmonica.fps(60),
|
|
155
|
+
position: Harmonica::Point.new(0, 100, 0),
|
|
156
|
+
velocity: Harmonica::Vector.new(0, 0, 0),
|
|
157
|
+
acceleration: Harmonica::Vector.new(0, -20, 0)
|
|
158
|
+
)
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Point
|
|
162
|
+
|
|
163
|
+
Point represents a position in 3D space.
|
|
164
|
+
|
|
165
|
+
```ruby
|
|
166
|
+
point = Harmonica::Point.new(10.0, 20.0, 30.0)
|
|
167
|
+
|
|
168
|
+
point.x # => 10.0
|
|
169
|
+
point.y # => 20.0
|
|
170
|
+
point.z # => 30.0
|
|
171
|
+
|
|
172
|
+
point.to_a # => [10.0, 20.0, 30.0]
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
### Vector
|
|
176
|
+
|
|
177
|
+
Vector represents direction and magnitude in 3D space.
|
|
178
|
+
|
|
179
|
+
```ruby
|
|
180
|
+
vector = Harmonica::Vector.new(3.0, 4.0, 0.0)
|
|
181
|
+
|
|
182
|
+
vector.magnitude # => 5.0
|
|
183
|
+
vector.normalize # => Vector(0.6, 0.8, 0.0)
|
|
184
|
+
|
|
185
|
+
v1 = Harmonica::Vector.new(1, 2, 3)
|
|
186
|
+
v2 = Harmonica::Vector.new(4, 5, 6)
|
|
187
|
+
|
|
188
|
+
v1 + v2 # => Vector(5, 7, 9)
|
|
189
|
+
v1 - v2 # => Vector(-3, -3, -3)
|
|
190
|
+
v1 * 2 # => Vector(2, 4, 6)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Frame Rate Helper
|
|
194
|
+
|
|
195
|
+
Use `Harmonica.fps` to calculate the time delta for a given frame rate:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
Harmonica.fps(60) # => 0.01666... (1/60 second)
|
|
199
|
+
Harmonica.fps(30) # => 0.03333... (1/30 second)
|
|
200
|
+
Harmonica.fps(120) # => 0.00833... (1/120 second)
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## Complete Example
|
|
204
|
+
|
|
205
|
+
Animate a value from 0 to 100 with spring physics:
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
require "harmonica"
|
|
209
|
+
|
|
210
|
+
spring = Harmonica::Spring.new(
|
|
211
|
+
delta_time: Harmonica.fps(60),
|
|
212
|
+
angular_frequency: 6.0,
|
|
213
|
+
damping_ratio: 0.3 # bouncy
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
position = 0.0
|
|
217
|
+
velocity = 0.0
|
|
218
|
+
target = 100.0
|
|
219
|
+
|
|
220
|
+
60.times do |frame|
|
|
221
|
+
position, velocity = spring.update(position, velocity, target)
|
|
222
|
+
|
|
223
|
+
bar_length = (position / 2).to_i
|
|
224
|
+
bar = "#" * bar_length
|
|
225
|
+
|
|
226
|
+
puts "\r#{bar.ljust(50)} #{position.round(1)}"
|
|
227
|
+
sleep(1.0 / 60)
|
|
228
|
+
end
|
|
229
|
+
```
|
|
26
230
|
|
|
27
231
|
## Development
|
|
28
232
|
|
|
29
|
-
|
|
233
|
+
**Requirements:**
|
|
234
|
+
- Ruby 3.0+
|
|
235
|
+
|
|
236
|
+
**Install dependencies:**
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
bundle install
|
|
240
|
+
```
|
|
30
241
|
|
|
31
|
-
|
|
242
|
+
**Run tests:**
|
|
243
|
+
|
|
244
|
+
```bash
|
|
245
|
+
bundle exec rake test
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
**Run demos:**
|
|
249
|
+
|
|
250
|
+
```bash
|
|
251
|
+
./demo/spring
|
|
252
|
+
./demo/damping
|
|
253
|
+
```
|
|
32
254
|
|
|
33
255
|
## Contributing
|
|
34
256
|
|
|
35
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/marcoroth/harmonica.
|
|
257
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/marcoroth/harmonica-ruby.
|
|
258
|
+
|
|
259
|
+
## License
|
|
260
|
+
|
|
261
|
+
The gem is available as open source under the terms of the MIT License.
|
|
36
262
|
|
|
37
|
-
##
|
|
263
|
+
## Acknowledgments
|
|
38
264
|
|
|
39
|
-
|
|
265
|
+
This gem is a Ruby implementation of [charmbracelet/harmonica](https://github.com/charmbracelet/harmonica), part of the excellent [Charm](https://charm.sh) ecosystem. The spring algorithm is based on Ryan Juckett's [damped springs](https://www.ryanjuckett.com/damped-springs/).
|
data/harmonica.gemspec
CHANGED
|
@@ -8,31 +8,25 @@ Gem::Specification.new do |spec|
|
|
|
8
8
|
spec.authors = ["Marco Roth"]
|
|
9
9
|
spec.email = ["marco.roth@intergga.ch"]
|
|
10
10
|
|
|
11
|
-
spec.summary = "
|
|
12
|
-
spec.description =
|
|
11
|
+
spec.summary = "A simple, physics-based animation library for Ruby."
|
|
12
|
+
spec.description = "Ruby implementation of Charm's Harmonica. A simple, efficient spring animation library for smooth, natural motion."
|
|
13
13
|
spec.homepage = "https://github.com/marcoroth/harmonica-ruby"
|
|
14
14
|
spec.license = "MIT"
|
|
15
|
-
spec.required_ruby_version = ">= 3.
|
|
15
|
+
spec.required_ruby_version = ">= 3.2.0"
|
|
16
16
|
|
|
17
17
|
spec.metadata["homepage_uri"] = spec.homepage
|
|
18
18
|
spec.metadata["source_code_uri"] = spec.homepage
|
|
19
|
-
spec.metadata["changelog_uri"] = "#{spec.homepage}/
|
|
19
|
+
spec.metadata["changelog_uri"] = "#{spec.homepage}/releases"
|
|
20
20
|
spec.metadata["rubygems_mfa_required"] = "true"
|
|
21
21
|
|
|
22
22
|
spec.files = Dir[
|
|
23
23
|
"harmonica.gemspec",
|
|
24
24
|
"LICENSE.txt",
|
|
25
25
|
"README.md",
|
|
26
|
-
"
|
|
27
|
-
"lib/**/*.rb",
|
|
28
|
-
"go/**/*.{go,mod,sum}",
|
|
29
|
-
"go/build/**/*"
|
|
26
|
+
"lib/**/*.rb"
|
|
30
27
|
]
|
|
31
28
|
|
|
32
29
|
spec.bindir = "exe"
|
|
33
30
|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
34
31
|
spec.require_paths = ["lib"]
|
|
35
|
-
spec.extensions = []
|
|
36
|
-
|
|
37
|
-
spec.add_dependency "lipgloss", "~> 0.1"
|
|
38
32
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module Harmonica
|
|
5
|
+
# Point represents a point in 3D space.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# point = Harmonica::Point.new(10.0, 20.0, 0.0)
|
|
9
|
+
# point.x # => 10.0
|
|
10
|
+
# point.y # => 20.0
|
|
11
|
+
# point.z # => 0.0
|
|
12
|
+
class Point
|
|
13
|
+
# @rbs @x: Float
|
|
14
|
+
# @rbs @y: Float
|
|
15
|
+
# @rbs @z: Float
|
|
16
|
+
|
|
17
|
+
attr_accessor :x #: Float
|
|
18
|
+
attr_accessor :y #: Float
|
|
19
|
+
attr_accessor :z #: Float
|
|
20
|
+
|
|
21
|
+
# @rbs x: Integer | Float -- x coordinate
|
|
22
|
+
# @rbs y: Integer | Float -- y coordinate
|
|
23
|
+
# @rbs z: Integer | Float -- z coordinate
|
|
24
|
+
# @rbs return: void
|
|
25
|
+
def initialize(x = 0.0, y = 0.0, z = 0.0)
|
|
26
|
+
@x = x.to_f
|
|
27
|
+
@y = y.to_f
|
|
28
|
+
@z = z.to_f
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
#: () -> Array[Float]
|
|
32
|
+
def to_a
|
|
33
|
+
[@x, @y, @z]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
#: (untyped other) -> bool
|
|
37
|
+
def ==(other)
|
|
38
|
+
return false unless other.is_a?(Point)
|
|
39
|
+
|
|
40
|
+
@x == other.x && @y == other.y && @z == other.z
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
#: () -> String
|
|
44
|
+
def to_s
|
|
45
|
+
"Point(#{@x}, #{@y}, #{@z})"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
#: () -> String
|
|
49
|
+
def inspect
|
|
50
|
+
to_s
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module Harmonica
|
|
5
|
+
# Projectile simulates physics projectile motion.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# projectile = Harmonica::Projectile.new(
|
|
9
|
+
# delta_time: Harmonica.fps(60),
|
|
10
|
+
# position: Harmonica::Point.new(0, 100, 0),
|
|
11
|
+
# velocity: Harmonica::Vector.new(2, 0, 0),
|
|
12
|
+
# acceleration: Harmonica::GRAVITY
|
|
13
|
+
# )
|
|
14
|
+
#
|
|
15
|
+
# loop do
|
|
16
|
+
# position = projectile.update
|
|
17
|
+
# break if position.y <= 0
|
|
18
|
+
# end
|
|
19
|
+
class Projectile
|
|
20
|
+
# @rbs @delta_time: Float
|
|
21
|
+
# @rbs @position: Point
|
|
22
|
+
# @rbs @velocity: Vector
|
|
23
|
+
# @rbs @acceleration: Vector
|
|
24
|
+
|
|
25
|
+
attr_reader :position #: Point
|
|
26
|
+
attr_reader :velocity #: Vector
|
|
27
|
+
attr_reader :acceleration #: Vector
|
|
28
|
+
attr_reader :delta_time #: Float
|
|
29
|
+
|
|
30
|
+
# Create a new Projectile.
|
|
31
|
+
#
|
|
32
|
+
# @rbs delta_time: Float -- time step per frame (use Harmonica.fps)
|
|
33
|
+
# @rbs position: Point -- initial position
|
|
34
|
+
# @rbs velocity: Vector -- initial velocity
|
|
35
|
+
# @rbs acceleration: Vector -- constant acceleration (e.g., gravity)
|
|
36
|
+
# @rbs return: void
|
|
37
|
+
def initialize(delta_time:, position:, velocity:, acceleration:)
|
|
38
|
+
@delta_time = delta_time.to_f
|
|
39
|
+
@position = position
|
|
40
|
+
@velocity = velocity
|
|
41
|
+
@acceleration = acceleration
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Update the projectile position and velocity for one time step.
|
|
45
|
+
#
|
|
46
|
+
#: () -> Point
|
|
47
|
+
def update
|
|
48
|
+
@position = Point.new(
|
|
49
|
+
@position.x + (@velocity.x * @delta_time),
|
|
50
|
+
@position.y + (@velocity.y * @delta_time),
|
|
51
|
+
@position.z + (@velocity.z * @delta_time)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@velocity = Vector.new(
|
|
55
|
+
@velocity.x + (@acceleration.x * @delta_time),
|
|
56
|
+
@velocity.y + (@acceleration.y * @delta_time),
|
|
57
|
+
@velocity.z + (@acceleration.z * @delta_time)
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
@position
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Reset the projectile to a new state.
|
|
64
|
+
#
|
|
65
|
+
# @rbs position: Point -- new position
|
|
66
|
+
# @rbs velocity: Vector -- new velocity
|
|
67
|
+
# @rbs return: void
|
|
68
|
+
def reset(position:, velocity:)
|
|
69
|
+
@position = position
|
|
70
|
+
@velocity = velocity
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module Harmonica
|
|
5
|
+
# Spring is a damped harmonic oscillator for smooth, realistic spring-like motion.
|
|
6
|
+
#
|
|
7
|
+
# This is ported from Ryan Juckett's simple damped harmonic motion, originally
|
|
8
|
+
# written in C++. For background on the algorithm see:
|
|
9
|
+
# https://www.ryanjuckett.com/damped-springs/
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# # Initialize once
|
|
13
|
+
# spring = Harmonica::Spring.new(
|
|
14
|
+
# delta_time: Harmonica.fps(60),
|
|
15
|
+
# angular_frequency: 6.0,
|
|
16
|
+
# damping_ratio: 0.2
|
|
17
|
+
# )
|
|
18
|
+
#
|
|
19
|
+
# # Update on every frame
|
|
20
|
+
# position = 0.0
|
|
21
|
+
# velocity = 0.0
|
|
22
|
+
# target = 100.0
|
|
23
|
+
#
|
|
24
|
+
# loop do
|
|
25
|
+
# position, velocity = spring.update(position, velocity, target)
|
|
26
|
+
# end
|
|
27
|
+
class Spring
|
|
28
|
+
EPSILON = Float::EPSILON #: Float
|
|
29
|
+
|
|
30
|
+
# @rbs @delta_time: Float
|
|
31
|
+
# @rbs @damping_ratio: Float
|
|
32
|
+
# @rbs @angular_frequency: Float
|
|
33
|
+
# @rbs @position_position_coefficient: Float
|
|
34
|
+
# @rbs @position_velocity_coefficient: Float
|
|
35
|
+
# @rbs @velocity_position_coefficient: Float
|
|
36
|
+
# @rbs @velocity_velocity_coefficient: Float
|
|
37
|
+
|
|
38
|
+
attr_reader :position_position_coefficient #: Float
|
|
39
|
+
attr_reader :position_velocity_coefficient #: Float
|
|
40
|
+
attr_reader :velocity_position_coefficient #: Float
|
|
41
|
+
attr_reader :velocity_velocity_coefficient #: Float
|
|
42
|
+
|
|
43
|
+
# Create a new Spring with precomputed coefficients.
|
|
44
|
+
#
|
|
45
|
+
# @rbs delta_time: Float -- time step (use Harmonica.fps for frame rate)
|
|
46
|
+
# @rbs angular_frequency: Float -- angular frequency of motion (affects speed)
|
|
47
|
+
# @rbs damping_ratio: Float -- damping ratio (> 1: over-damped, = 1: critical, < 1: under-damped)
|
|
48
|
+
# @rbs return: void
|
|
49
|
+
def initialize(delta_time:, angular_frequency:, damping_ratio:)
|
|
50
|
+
@delta_time = delta_time
|
|
51
|
+
@angular_frequency = [0.0, angular_frequency].max
|
|
52
|
+
@damping_ratio = [0.0, damping_ratio].max
|
|
53
|
+
|
|
54
|
+
compute_coefficients
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Update position and velocity towards equilibrium position.
|
|
58
|
+
#
|
|
59
|
+
# @rbs position: Float -- current position
|
|
60
|
+
# @rbs velocity: Float -- current velocity
|
|
61
|
+
# @rbs equilibrium_position: Float -- target/equilibrium position
|
|
62
|
+
# @rbs return: [Float, Float]
|
|
63
|
+
def update(position, velocity, equilibrium_position)
|
|
64
|
+
old_position = position - equilibrium_position
|
|
65
|
+
old_velocity = velocity
|
|
66
|
+
|
|
67
|
+
new_position = (old_position * @position_position_coefficient) +
|
|
68
|
+
(old_velocity * @position_velocity_coefficient) +
|
|
69
|
+
equilibrium_position
|
|
70
|
+
|
|
71
|
+
new_velocity = (old_position * @velocity_position_coefficient) +
|
|
72
|
+
(old_velocity * @velocity_velocity_coefficient)
|
|
73
|
+
|
|
74
|
+
[new_position, new_velocity]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
#: () -> void
|
|
80
|
+
def compute_coefficients
|
|
81
|
+
if @angular_frequency < EPSILON
|
|
82
|
+
@position_position_coefficient = 1.0
|
|
83
|
+
@position_velocity_coefficient = 0.0
|
|
84
|
+
@velocity_position_coefficient = 0.0
|
|
85
|
+
@velocity_velocity_coefficient = 1.0
|
|
86
|
+
return
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
if @damping_ratio > 1.0 + EPSILON
|
|
90
|
+
compute_over_damped
|
|
91
|
+
elsif @damping_ratio < 1.0 - EPSILON
|
|
92
|
+
compute_under_damped
|
|
93
|
+
else
|
|
94
|
+
compute_critically_damped
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
#: () -> void
|
|
99
|
+
def compute_over_damped
|
|
100
|
+
za = -@angular_frequency * @damping_ratio
|
|
101
|
+
zb = @angular_frequency * Math.sqrt((@damping_ratio * @damping_ratio) - 1.0)
|
|
102
|
+
z1 = za - zb
|
|
103
|
+
z2 = za + zb
|
|
104
|
+
|
|
105
|
+
e1 = Math.exp(z1 * @delta_time)
|
|
106
|
+
e2 = Math.exp(z2 * @delta_time)
|
|
107
|
+
|
|
108
|
+
inverse_two_zb = 1.0 / (2.0 * zb)
|
|
109
|
+
|
|
110
|
+
e1_over_two_zb = e1 * inverse_two_zb
|
|
111
|
+
e2_over_two_zb = e2 * inverse_two_zb
|
|
112
|
+
|
|
113
|
+
z1e1_over_two_zb = z1 * e1_over_two_zb
|
|
114
|
+
z2e2_over_two_zb = z2 * e2_over_two_zb
|
|
115
|
+
|
|
116
|
+
@position_position_coefficient = (e1_over_two_zb * z2) - z2e2_over_two_zb + e2
|
|
117
|
+
@position_velocity_coefficient = -e1_over_two_zb + e2_over_two_zb
|
|
118
|
+
|
|
119
|
+
@velocity_position_coefficient = (z1e1_over_two_zb - z2e2_over_two_zb + e2) * z2
|
|
120
|
+
@velocity_velocity_coefficient = -z1e1_over_two_zb + z2e2_over_two_zb
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
#: () -> void
|
|
124
|
+
def compute_under_damped
|
|
125
|
+
omega_zeta = @angular_frequency * @damping_ratio
|
|
126
|
+
alpha = @angular_frequency * Math.sqrt(1.0 - (@damping_ratio * @damping_ratio))
|
|
127
|
+
|
|
128
|
+
exponential_term = Math.exp(-omega_zeta * @delta_time)
|
|
129
|
+
cosine_term = Math.cos(alpha * @delta_time)
|
|
130
|
+
sine_term = Math.sin(alpha * @delta_time)
|
|
131
|
+
|
|
132
|
+
inverse_alpha = 1.0 / alpha
|
|
133
|
+
|
|
134
|
+
exponential_sine = exponential_term * sine_term
|
|
135
|
+
exponential_cosine = exponential_term * cosine_term
|
|
136
|
+
exponential_omega_zeta_sine_over_alpha = exponential_term * omega_zeta * sine_term * inverse_alpha
|
|
137
|
+
|
|
138
|
+
@position_position_coefficient = exponential_cosine + exponential_omega_zeta_sine_over_alpha
|
|
139
|
+
@position_velocity_coefficient = exponential_sine * inverse_alpha
|
|
140
|
+
|
|
141
|
+
@velocity_position_coefficient = (-exponential_sine * alpha) - (omega_zeta * exponential_omega_zeta_sine_over_alpha)
|
|
142
|
+
@velocity_velocity_coefficient = exponential_cosine - exponential_omega_zeta_sine_over_alpha
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
#: () -> void
|
|
146
|
+
def compute_critically_damped
|
|
147
|
+
exponential_term = Math.exp(-@angular_frequency * @delta_time)
|
|
148
|
+
time_exponential = @delta_time * exponential_term
|
|
149
|
+
time_exponential_frequency = time_exponential * @angular_frequency
|
|
150
|
+
|
|
151
|
+
@position_position_coefficient = time_exponential_frequency + exponential_term
|
|
152
|
+
@position_velocity_coefficient = time_exponential
|
|
153
|
+
|
|
154
|
+
@velocity_position_coefficient = -@angular_frequency * time_exponential_frequency
|
|
155
|
+
@velocity_velocity_coefficient = -time_exponential_frequency + exponential_term
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
3
|
+
|
|
4
|
+
module Harmonica
|
|
5
|
+
# Vector represents a vector in 3D space with magnitude and direction.
|
|
6
|
+
#
|
|
7
|
+
# @example
|
|
8
|
+
# velocity = Harmonica::Vector.new(2.0, 0.0, 0.0)
|
|
9
|
+
# gravity = Harmonica::GRAVITY
|
|
10
|
+
class Vector
|
|
11
|
+
# @rbs @x: Float
|
|
12
|
+
# @rbs @y: Float
|
|
13
|
+
# @rbs @z: Float
|
|
14
|
+
|
|
15
|
+
attr_accessor :x #: Float
|
|
16
|
+
attr_accessor :y #: Float
|
|
17
|
+
attr_accessor :z #: Float
|
|
18
|
+
|
|
19
|
+
# @rbs x: Integer | Float -- x component
|
|
20
|
+
# @rbs y: Integer | Float -- y component
|
|
21
|
+
# @rbs z: Integer | Float -- z component
|
|
22
|
+
# @rbs return: void
|
|
23
|
+
def initialize(x = 0.0, y = 0.0, z = 0.0)
|
|
24
|
+
@x = x.to_f
|
|
25
|
+
@y = y.to_f
|
|
26
|
+
@z = z.to_f
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
#: () -> Array[Float]
|
|
30
|
+
def to_a
|
|
31
|
+
[@x, @y, @z]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
#: (untyped other) -> bool
|
|
35
|
+
def ==(other)
|
|
36
|
+
return false unless other.is_a?(Vector)
|
|
37
|
+
|
|
38
|
+
@x == other.x && @y == other.y && @z == other.z
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
#: () -> String
|
|
42
|
+
def to_s
|
|
43
|
+
"Vector(#{@x}, #{@y}, #{@z})"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
#: () -> String
|
|
47
|
+
def inspect
|
|
48
|
+
to_s
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Vector addition
|
|
52
|
+
#
|
|
53
|
+
#: (Vector other) -> Vector
|
|
54
|
+
def +(other)
|
|
55
|
+
Vector.new(@x + other.x, @y + other.y, @z + other.z)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Vector subtraction
|
|
59
|
+
#
|
|
60
|
+
#: (Vector other) -> Vector
|
|
61
|
+
def -(other)
|
|
62
|
+
Vector.new(@x - other.x, @y - other.y, @z - other.z)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Scalar multiplication
|
|
66
|
+
#
|
|
67
|
+
#: (Integer | Float other) -> Vector
|
|
68
|
+
def *(other)
|
|
69
|
+
Vector.new(@x * other, @y * other, @z * other)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Magnitude (length) of the vector
|
|
73
|
+
#
|
|
74
|
+
#: () -> Float
|
|
75
|
+
def magnitude
|
|
76
|
+
Math.sqrt((@x * @x) + (@y * @y) + (@z * @z))
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Normalize the vector (unit vector)
|
|
80
|
+
#
|
|
81
|
+
#: () -> Vector
|
|
82
|
+
def normalize
|
|
83
|
+
mag = magnitude
|
|
84
|
+
return Vector.new(0, 0, 0) if mag.zero?
|
|
85
|
+
|
|
86
|
+
Vector.new(@x / mag, @y / mag, @z / mag)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# origin at bottom-left, Y pointing up
|
|
91
|
+
GRAVITY = Vector.new(0, -9.81, 0).freeze #: Vector
|
|
92
|
+
|
|
93
|
+
# origin at top-left, Y pointing down
|
|
94
|
+
TERMINAL_GRAVITY = Vector.new(0, 9.81, 0).freeze #: Vector
|
|
95
|
+
end
|
data/lib/harmonica/version.rb
CHANGED
data/lib/harmonica.rb
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
# rbs_inline: enabled
|
|
2
3
|
|
|
3
4
|
require_relative "harmonica/version"
|
|
5
|
+
require_relative "harmonica/spring"
|
|
6
|
+
require_relative "harmonica/point"
|
|
7
|
+
require_relative "harmonica/vector"
|
|
8
|
+
require_relative "harmonica/projectile"
|
|
4
9
|
|
|
5
10
|
module Harmonica
|
|
6
|
-
|
|
7
|
-
#
|
|
11
|
+
# Calculate time delta for a given frames per second.
|
|
12
|
+
# Use this when initializing Spring or Projectile.
|
|
13
|
+
#
|
|
14
|
+
# @example
|
|
15
|
+
# spring = Harmonica::Spring.new(delta_time: Harmonica.fps(60), ...)
|
|
16
|
+
#
|
|
17
|
+
# @rbs frames_per_second: Integer -- frames per second
|
|
18
|
+
# @rbs return: Float
|
|
19
|
+
def self.fps(frames_per_second)
|
|
20
|
+
1.0 / frames_per_second
|
|
21
|
+
end
|
|
8
22
|
end
|
metadata
CHANGED
|
@@ -1,39 +1,30 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: harmonica
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.0
|
|
4
|
+
version: 0.1.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Marco Roth
|
|
8
8
|
bindir: exe
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
-
dependencies:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
requirement: !ruby/object:Gem::Requirement
|
|
15
|
-
requirements:
|
|
16
|
-
- - "~>"
|
|
17
|
-
- !ruby/object:Gem::Version
|
|
18
|
-
version: '0.1'
|
|
19
|
-
type: :runtime
|
|
20
|
-
prerelease: false
|
|
21
|
-
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
-
requirements:
|
|
23
|
-
- - "~>"
|
|
24
|
-
- !ruby/object:Gem::Version
|
|
25
|
-
version: '0.1'
|
|
26
|
-
description: Ruby bindings for the Harmonica physics-based animation library.
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: Ruby implementation of Charm's Harmonica. A simple, efficient spring
|
|
13
|
+
animation library for smooth, natural motion.
|
|
27
14
|
email:
|
|
28
15
|
- marco.roth@intergga.ch
|
|
29
16
|
executables: []
|
|
30
17
|
extensions: []
|
|
31
18
|
extra_rdoc_files: []
|
|
32
19
|
files:
|
|
33
|
-
-
|
|
20
|
+
- LICENSE.txt
|
|
34
21
|
- README.md
|
|
35
22
|
- harmonica.gemspec
|
|
36
23
|
- lib/harmonica.rb
|
|
24
|
+
- lib/harmonica/point.rb
|
|
25
|
+
- lib/harmonica/projectile.rb
|
|
26
|
+
- lib/harmonica/spring.rb
|
|
27
|
+
- lib/harmonica/vector.rb
|
|
37
28
|
- lib/harmonica/version.rb
|
|
38
29
|
homepage: https://github.com/marcoroth/harmonica-ruby
|
|
39
30
|
licenses:
|
|
@@ -41,7 +32,7 @@ licenses:
|
|
|
41
32
|
metadata:
|
|
42
33
|
homepage_uri: https://github.com/marcoroth/harmonica-ruby
|
|
43
34
|
source_code_uri: https://github.com/marcoroth/harmonica-ruby
|
|
44
|
-
changelog_uri: https://github.com/marcoroth/harmonica-ruby/
|
|
35
|
+
changelog_uri: https://github.com/marcoroth/harmonica-ruby/releases
|
|
45
36
|
rubygems_mfa_required: 'true'
|
|
46
37
|
rdoc_options: []
|
|
47
38
|
require_paths:
|
|
@@ -50,14 +41,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
50
41
|
requirements:
|
|
51
42
|
- - ">="
|
|
52
43
|
- !ruby/object:Gem::Version
|
|
53
|
-
version: 3.
|
|
44
|
+
version: 3.2.0
|
|
54
45
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
55
46
|
requirements:
|
|
56
47
|
- - ">="
|
|
57
48
|
- !ruby/object:Gem::Version
|
|
58
49
|
version: '0'
|
|
59
50
|
requirements: []
|
|
60
|
-
rubygems_version:
|
|
51
|
+
rubygems_version: 4.0.3
|
|
61
52
|
specification_version: 4
|
|
62
|
-
summary:
|
|
53
|
+
summary: A simple, physics-based animation library for Ruby.
|
|
63
54
|
test_files: []
|