asobi 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 +7 -0
- data/DESIGN.md +388 -0
- data/LICENSE +21 -0
- data/README.ja.md +154 -0
- data/README.md +157 -0
- data/docs/first-sketch.svg +1 -0
- data/exe/asobi +106 -0
- data/lib/asobi/art.rb +498 -0
- data/lib/asobi/color.rb +236 -0
- data/lib/asobi/core_ext.rb +28 -0
- data/lib/asobi/geometry.rb +126 -0
- data/lib/asobi/live.rb +256 -0
- data/lib/asobi/many.rb +235 -0
- data/lib/asobi/messages.rb +155 -0
- data/lib/asobi/motion.rb +471 -0
- data/lib/asobi/nodes.rb +230 -0
- data/lib/asobi/pure.rb +21 -0
- data/lib/asobi/random.rb +51 -0
- data/lib/asobi/reel.rb +113 -0
- data/lib/asobi/shapes.rb +314 -0
- data/lib/asobi/sketch.rb +207 -0
- data/lib/asobi/svg.rb +352 -0
- data/lib/asobi/top_level.rb +14 -0
- data/lib/asobi/version.rb +5 -0
- data/lib/asobi/web/live.html +34 -0
- data/lib/asobi/web/player.js +295 -0
- data/lib/asobi.rb +12 -0
- metadata +73 -0
data/lib/asobi/motion.rb
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asobi
|
|
4
|
+
# A picture in motion: a function from time (in seconds) to a picture.
|
|
5
|
+
#
|
|
6
|
+
# wobble = Asobi.motion(2) { |seconds| circle.move(right: Math.sin(seconds * Math::PI) * 10) }
|
|
7
|
+
# wobble.at(0.5) the picture half a second in
|
|
8
|
+
# wobble.every 2.0, the seconds until it is back where it started
|
|
9
|
+
# wobble.frames(fps: 30) one loop of pictures, lazily
|
|
10
|
+
#
|
|
11
|
+
# There is no frame counter and no state: the same time always gives the
|
|
12
|
+
# same picture, so playing can start (or restart) anywhere.
|
|
13
|
+
#
|
|
14
|
+
# A Motion understands every word a picture does (move, colored, repeat,
|
|
15
|
+
# in_ring, on, beside ...), applied at every moment, and every motion word
|
|
16
|
+
# (spin, pulse ...) too:
|
|
17
|
+
#
|
|
18
|
+
# flower.spin(every: 8).pulse(every: 1).colored(:crimson).repeat(6).in_ring
|
|
19
|
+
#
|
|
20
|
+
# Pictures and motions mix freely: ring.on(dot.pulse) and [dot, dot.spin]
|
|
21
|
+
# are motions as well.
|
|
22
|
+
class Motion
|
|
23
|
+
include Art
|
|
24
|
+
|
|
25
|
+
# Combined loops longer than this give way to their longest part.
|
|
26
|
+
LONGEST = 60
|
|
27
|
+
# How close two periods may be and still count as the same (seconds).
|
|
28
|
+
TOLERANCE = Rational(1, 10_000)
|
|
29
|
+
|
|
30
|
+
# Asobi::Motion.new(2) { |seconds| picture }
|
|
31
|
+
def initialize(every = 1.0, &block)
|
|
32
|
+
unless block
|
|
33
|
+
raise Messages.error(ArgumentError, :need_block, word: :motion,
|
|
34
|
+
example: "Asobi.motion(2) { |seconds| circle.rotate(seconds * 180) }")
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
setup(Motion.seconds(every, :motion, :every, "Asobi.motion(2) { |seconds| ... }"), block, nil)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Seconds until the motion is back where it started (a positive Float).
|
|
41
|
+
attr_reader :every
|
|
42
|
+
|
|
43
|
+
# The picture at +seconds+ (any number; it repeats every +every+ seconds).
|
|
44
|
+
def at(seconds = 0)
|
|
45
|
+
Check.number(seconds, word: :at, name: :seconds, example: "motion.at(0.5)")
|
|
46
|
+
Motion.quietly(@seed) { Motion.node_at(@function.call(seconds.to_f), seconds.to_f) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# One loop of pictures, evenly spaced over +every+ seconds, computed only
|
|
50
|
+
# when asked for. There are every * fps of them (at least one).
|
|
51
|
+
def frames(fps: 30)
|
|
52
|
+
Motion.seconds(fps, :frames, :fps, "frames(fps: 30)")
|
|
53
|
+
count = [(every * fps).round, 1].max
|
|
54
|
+
(0...count).lazy.map { |i| at(every * i / count) }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def to_art = self
|
|
58
|
+
|
|
59
|
+
def inspect = "#<Asobi::Motion every #{Asobi.fmt(every)}s>"
|
|
60
|
+
alias to_s inspect
|
|
61
|
+
|
|
62
|
+
# The room the motion needs: the box around its pictures over one loop.
|
|
63
|
+
def bounds
|
|
64
|
+
n = [[(every * 30).round, 1].max, 240].min
|
|
65
|
+
Box.union(Array.new(n) { |i| at(every * i / n).box(IDENTITY) }) || Box.new(0.0, 0.0, 0.0, 0.0)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A motion is not one picture: these answer a teaching error.
|
|
69
|
+
def box(_matrix = IDENTITY) = raise(Motion.not_a_picture(self))
|
|
70
|
+
def svg = raise(Motion.not_a_picture(self))
|
|
71
|
+
def to_svg(**) = raise(Motion.not_a_picture(self))
|
|
72
|
+
|
|
73
|
+
# A page that plays by itself: flower.spin.save("flower.html")
|
|
74
|
+
def save(path, **options)
|
|
75
|
+
unless File.extname(path.to_s).downcase == ".html"
|
|
76
|
+
raise Messages.error(ArgumentError, :bad_motion_save, path: path.to_s.inspect)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
reel = Motion.reel
|
|
80
|
+
raise Messages.error(NameError, :no_reel, path: path.to_s.inspect) unless reel
|
|
81
|
+
|
|
82
|
+
reel.save(self, path, **options)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# --- the words of Asobi::Many, at every moment -------------------------
|
|
86
|
+
|
|
87
|
+
%i[in_row in_column in_grid in_ring in_fan in_spiral scattered change_each].each do |word|
|
|
88
|
+
define_method(word) do |*args, **options, &block|
|
|
89
|
+
changed = Motion.combine([self]) { |(node)| Many.of(node).public_send(word, *args, **options, &block) }
|
|
90
|
+
changed.at(0) # say what is wrong now, not when playing
|
|
91
|
+
changed
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Each one, in turn, on its own. The last motion word is done by each
|
|
96
|
+
# picture of the group separately, around that picture's own centre
|
|
97
|
+
# (the middle of its box before the word moves it), and each starts
|
|
98
|
+
# +gap+ seconds after the one before. By default the loop is shared out
|
|
99
|
+
# evenly, which makes a wave.
|
|
100
|
+
#
|
|
101
|
+
# So with one_by_one, pulse grows each dot in place, spin turns each one
|
|
102
|
+
# where it stands, sway rocks each one. Words that only move or colour
|
|
103
|
+
# (bounce, drift, orbit, fade, shift_color) look the same either way,
|
|
104
|
+
# only staggered. Without one_by_one the word acts on the whole group
|
|
105
|
+
# around the origin: ring.pulse breathes, ring.spin turns the ring.
|
|
106
|
+
# Your own animate block also gets each picture as if it sat alone at
|
|
107
|
+
# the origin, and its answer is put back in place.
|
|
108
|
+
#
|
|
109
|
+
# On a single picture one_by_one changes nothing.
|
|
110
|
+
#
|
|
111
|
+
# dot.repeat(12).in_ring.pulse(every: 1).one_by_one
|
|
112
|
+
def one_by_one(gap_ = nil, gap: gap_)
|
|
113
|
+
Check.number(gap, word: :one_by_one, name: :gap, example: "one_by_one(gap: 0.1)", min: 0) unless gap.nil?
|
|
114
|
+
raise Messages.error(ArgumentError, :no_motion_word) unless @stagger
|
|
115
|
+
|
|
116
|
+
@stagger.call(gap.nil? ? :even : gap.to_f)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Earlier moments drawn under this one: the picture +gap+, 2 * +gap+ ...
|
|
120
|
+
# seconds ago, each fainter than the last. +gap+ is a short moment
|
|
121
|
+
# (TRAIL_GAP seconds) unless given.
|
|
122
|
+
# flower.spin.trail, dot.orbit.trail(count: 8, gap: 0.03)
|
|
123
|
+
def trail(count_ = nil, count: count_ || 5, gap: nil)
|
|
124
|
+
n = Check.whole(count, word: :trail, name: :count, example: "trail(count: 5, gap: 0.05)", min: 1)
|
|
125
|
+
gap = gap.nil? ? TRAIL_GAP : Check.number(gap, word: :trail, name: :gap, example: "trail(count: 5, gap: 0.05)", min: 0)
|
|
126
|
+
source = self
|
|
127
|
+
stagger = @stagger && ->(g) { source.stagger_by(g).trail(count: n, gap:) }
|
|
128
|
+
Motion.build(every, stagger:) do |t|
|
|
129
|
+
layers = n.downto(1).map do |k|
|
|
130
|
+
fade = (TRAIL_FIRST - (TRAIL_FIRST - TRAIL_FAINTEST) * (k - 1) / [n - 1, 1].max).round(6)
|
|
131
|
+
Asobi.restyle(source.at(t - k * gap)) { |s| s.merge(opacity: ((s[:opacity] || 1.0) * fade).round(6)) }
|
|
132
|
+
end
|
|
133
|
+
Many.new(layers + [source.at(t)])
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
TRAIL_GAP = 0.05
|
|
138
|
+
TRAIL_FIRST = 0.5 # the most recent copy
|
|
139
|
+
TRAIL_FAINTEST = 0.1 # the oldest copy
|
|
140
|
+
|
|
141
|
+
# --- building ----------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
class << self
|
|
144
|
+
# A Motion without the checks, for asobi's own words. +stagger+ is a
|
|
145
|
+
# proc(gap) that rebuilds this motion with its last word staggered.
|
|
146
|
+
def build(every, stagger: nil, &function)
|
|
147
|
+
allocate.tap { _1.send(:setup, every.to_f, function, stagger) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Like Asobi.art, but a Motion (or anything that becomes one) is
|
|
151
|
+
# answered as it is instead of being refused.
|
|
152
|
+
def reach(object, as: nil)
|
|
153
|
+
return object if object.is_a?(Motion)
|
|
154
|
+
return combine([as]) { |(shape)| reach(object, as: shape) } if as.is_a?(Motion)
|
|
155
|
+
|
|
156
|
+
original = object
|
|
157
|
+
16.times do
|
|
158
|
+
return object if object.is_a?(Node) || object.is_a?(Motion)
|
|
159
|
+
|
|
160
|
+
built_in = Convert.call(object, as)
|
|
161
|
+
return built_in if built_in
|
|
162
|
+
unless object.respond_to?(:to_art)
|
|
163
|
+
raise Messages.error(TypeError, :not_art, thing: Asobi.send(:describe, original))
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
object = object.to_art
|
|
167
|
+
end
|
|
168
|
+
raise Messages.error(TypeError, :to_art_loop, thing: Asobi.send(:describe, original))
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# The picture +thing+ shows at +seconds+.
|
|
172
|
+
def node_at(thing, seconds)
|
|
173
|
+
found = reach(thing)
|
|
174
|
+
found.is_a?(Motion) ? found.at(seconds) : found
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Several parts (pictures or motions) made into one with +block+,
|
|
178
|
+
# which gets the parts' pictures at each moment. A motion if any part
|
|
179
|
+
# is one.
|
|
180
|
+
def combine(parts, &block)
|
|
181
|
+
motions = parts.grep(Motion).uniq(&:__id__)
|
|
182
|
+
return block.call(parts) if motions.empty?
|
|
183
|
+
|
|
184
|
+
staggerable = motions.select(&:staggerable?)
|
|
185
|
+
stagger =
|
|
186
|
+
if staggerable.size == 1
|
|
187
|
+
chosen = staggerable.first
|
|
188
|
+
lambda do |gap|
|
|
189
|
+
staggered = chosen.stagger_by(gap)
|
|
190
|
+
combine(parts.map { _1.equal?(chosen) ? staggered : _1 }, &block)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
build(lcm_period(motions.map(&:every)), stagger:) do |t|
|
|
194
|
+
block.call(parts.map { _1.is_a?(Motion) ? _1.at(t) : _1 })
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# A motion word: +effect+ gets a picture and the phase (0.0 up to 1.0)
|
|
199
|
+
# and answers that picture at that point of the loop.
|
|
200
|
+
def word(receiver, period, &effect)
|
|
201
|
+
source = reach(receiver)
|
|
202
|
+
period = period.to_f
|
|
203
|
+
every = source.is_a?(Motion) ? lcm_period([source.every, period]) : period
|
|
204
|
+
make = lambda do |gap|
|
|
205
|
+
build(every, stagger: gap ? nil : make) do |t|
|
|
206
|
+
node = source.is_a?(Motion) ? source.at(t) : source
|
|
207
|
+
if gap && node.is_a?(Many) && node.size > 1
|
|
208
|
+
step = gap == :even ? period / node.size : gap
|
|
209
|
+
items = node.items.each_with_index.map do |item, i|
|
|
210
|
+
on_its_own(item, t) { |alone| effect.call(alone, phase(t - i * step, period)) }
|
|
211
|
+
end
|
|
212
|
+
Many.new(items, node.style)
|
|
213
|
+
else
|
|
214
|
+
node_at(effect.call(node, phase(t, period)), t)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
make.call(nil)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# +item+ moved so the centre of its box is at the origin, changed by
|
|
222
|
+
# the block, then moved back: the change happens around its own centre.
|
|
223
|
+
def on_its_own(item, t)
|
|
224
|
+
box = item.box(IDENTITY)
|
|
225
|
+
return node_at(yield(item), t) unless box
|
|
226
|
+
|
|
227
|
+
x = box.cx
|
|
228
|
+
y = box.cy
|
|
229
|
+
alone = Asobi.transform(item, Affine.translate(-x, -y))
|
|
230
|
+
Asobi.transform(node_at(yield(alone), t), Affine.translate(x, y))
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Where in its loop a motion of +period+ is at time +t+: 0.0 up to 1.0.
|
|
234
|
+
def phase(t, period)
|
|
235
|
+
q = t / period
|
|
236
|
+
f = q - q.floor
|
|
237
|
+
f < 1e-9 || f > 1 - 1e-9 ? 0.0 : f
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# The least common multiple of the periods, worked out in fractions;
|
|
241
|
+
# the longest one when that would be more than a minute.
|
|
242
|
+
def lcm_period(periods)
|
|
243
|
+
fractions = periods.map { _1.to_r.rationalize(TOLERANCE) }
|
|
244
|
+
lcm = fractions.reduce do |a, b|
|
|
245
|
+
Rational(a.numerator.lcm(b.numerator), a.denominator.gcd(b.denominator))
|
|
246
|
+
end
|
|
247
|
+
lcm > LONGEST ? periods.max.to_f : lcm.to_f
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# A number of seconds (more than 0), or a teaching error.
|
|
251
|
+
def seconds(value, word, name, example)
|
|
252
|
+
Check.number(value, word:, name:, example:, min: 0)
|
|
253
|
+
unless value.positive?
|
|
254
|
+
raise Messages.error(ArgumentError, :need_positive, word:, name:, example:, got: value.inspect)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
value.to_f
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The size of a picture or motion, as a Box (nil when empty).
|
|
261
|
+
def extent(thing)
|
|
262
|
+
found = reach(thing)
|
|
263
|
+
found.is_a?(Motion) ? found.bounds : found.box(IDENTITY)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def not_a_picture(motion) = Messages.error(TypeError, :motion_not_picture, thing: motion.inspect)
|
|
267
|
+
|
|
268
|
+
# The Reel writer, when it is loaded.
|
|
269
|
+
def reel
|
|
270
|
+
defined?(Asobi::Reel) && Asobi::Reel.respond_to?(:save) ? Asobi::Reel : nil
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Run +block+ with seedless randomness drawn from +seed+, starting
|
|
274
|
+
# from the first call each time, and put things back afterwards. So a
|
|
275
|
+
# motion that scatters at every moment scatters the same way at every
|
|
276
|
+
# moment, and sampling it never disturbs anything else.
|
|
277
|
+
def quietly(seed)
|
|
278
|
+
saved_seed = Asobi::Random.session_seed
|
|
279
|
+
saved_calls = Asobi::Random.calls
|
|
280
|
+
Asobi::Random.session_seed = seed
|
|
281
|
+
yield
|
|
282
|
+
ensure
|
|
283
|
+
Asobi::Random.session_seed = saved_seed
|
|
284
|
+
Asobi::Random.instance_variable_set(:@calls, saved_calls)
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def staggerable? = !@stagger.nil?
|
|
289
|
+
|
|
290
|
+
# This motion with its last word staggered (see one_by_one).
|
|
291
|
+
def stagger_by(gap) = @stagger.call(gap)
|
|
292
|
+
|
|
293
|
+
private
|
|
294
|
+
|
|
295
|
+
def setup(every, function, stagger)
|
|
296
|
+
@every = every
|
|
297
|
+
@function = function
|
|
298
|
+
@stagger = stagger
|
|
299
|
+
# Each motion takes one seed from the run's random sequence when it is
|
|
300
|
+
# made, so it is the same in every evaluation of the same sketch.
|
|
301
|
+
@seed = ::Random.new(Asobi::Random.next_seed).rand(1 << 64)
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# A picture in motion from a block that gets the time in seconds.
|
|
306
|
+
# Asobi.motion(2) { |seconds| circle.rotate(seconds * 180) }
|
|
307
|
+
def self.motion(every = 1.0, &) = Motion.new(every, &)
|
|
308
|
+
|
|
309
|
+
# --- the motion words, on every picture (and every motion) ---------------
|
|
310
|
+
module Art
|
|
311
|
+
# Turn round and round, once every +every+ seconds.
|
|
312
|
+
def spin(every_ = nil, every: every_ || 4, clockwise: true)
|
|
313
|
+
period = Motion.seconds(every, :spin, :every, "spin(every: 4)")
|
|
314
|
+
sign = clockwise ? 1 : -1
|
|
315
|
+
Motion.word(self, period) { |art, p| Asobi.transform(art, Affine.rotate(sign * 360.0 * p)) }
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Grow to +to+ times the size and back.
|
|
319
|
+
def pulse(every_ = nil, every: every_ || 1, to: 1.2)
|
|
320
|
+
period = Motion.seconds(every, :pulse, :every, "pulse(every: 1, to: 1.2)")
|
|
321
|
+
Check.number(to, word: :pulse, name: :to, example: "pulse(every: 1, to: 1.2)", min: 0)
|
|
322
|
+
Motion.word(self, period) { |art, p| Asobi.transform(art, Affine.scale(1 + (to - 1) * Asobi.swell(p))) }
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Rock +by+ degrees one way, then the other.
|
|
326
|
+
def sway(every_ = nil, every: every_ || 2, by: 10)
|
|
327
|
+
period = Motion.seconds(every, :sway, :every, "sway(every: 2, by: 10)")
|
|
328
|
+
Check.number(by, word: :sway, name: :by, example: "sway(every: 2, by: 10)")
|
|
329
|
+
Motion.word(self, period) { |art, p| Asobi.transform(art, Affine.rotate(by * Math.sin(2 * Math::PI * p))) }
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Go round a circle of +radius+ (clockwise from the top). Without
|
|
333
|
+
# radius:, twice the picture's size (its larger side).
|
|
334
|
+
def orbit(radius_ = nil, radius: radius_, every: 4)
|
|
335
|
+
period = Motion.seconds(every, :orbit, :every, "orbit(radius: 40, every: 4)")
|
|
336
|
+
if radius.nil?
|
|
337
|
+
box = Motion.extent(self)
|
|
338
|
+
radius = box ? [box.width, box.height].max : 20.0
|
|
339
|
+
end
|
|
340
|
+
Check.number(radius, word: :orbit, name: :radius, example: "orbit(radius: 40, every: 4)", min: 0)
|
|
341
|
+
Motion.word(self, period) do |art, p|
|
|
342
|
+
x, y = Geometry.polar(radius, 360.0 * p)
|
|
343
|
+
Asobi.transform(art, Affine.translate(x, y))
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Fade to +to+ (0 is invisible, 1 is solid) and back.
|
|
348
|
+
def fade(every_ = nil, every: every_ || 2, to: 0.2)
|
|
349
|
+
period = Motion.seconds(every, :fade, :every, "fade(every: 2, to: 0.2)")
|
|
350
|
+
Check.number(to, word: :fade, name: :to, example: "fade(every: 2, to: 0.2)", min: 0, max: 1)
|
|
351
|
+
Motion.word(self, period) do |art, p|
|
|
352
|
+
amount = (1 - (1 - to) * Asobi.swell(p)).round(9) # so fade(to: 0.2) reaches exactly 0.2
|
|
353
|
+
next art if amount == 1
|
|
354
|
+
|
|
355
|
+
Asobi.restyle(art) { |s| s.merge(opacity: (s[:opacity] || 1.0) * amount) }
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
# Jump +height+ up and fall back, like a ball: slow at the top, fast
|
|
360
|
+
# near the ground.
|
|
361
|
+
def bounce(height_ = nil, height: height_ || 20, every: 1)
|
|
362
|
+
period = Motion.seconds(every, :bounce, :every, "bounce(height: 20, every: 1)")
|
|
363
|
+
Check.number(height, word: :bounce, name: :height, example: "bounce(height: 20, every: 1)")
|
|
364
|
+
Motion.word(self, period) { |art, p| Asobi.transform(art, Affine.translate(0, -height * 4 * p * (1 - p))) }
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Go there and come back, smoothly. With nothing given, to the right by
|
|
368
|
+
# the picture's width.
|
|
369
|
+
def drift(right_ = nil, right: right_, left: nil, up: nil, down: nil, every: 4)
|
|
370
|
+
period = Motion.seconds(every, :drift, :every, "drift(right: 40, every: 4)")
|
|
371
|
+
if [right, left, up, down].all?(&:nil?)
|
|
372
|
+
box = Motion.extent(self)
|
|
373
|
+
right = box ? box.width : 0
|
|
374
|
+
end
|
|
375
|
+
amounts = { right:, left:, up:, down: }.transform_values { _1 || 0 }
|
|
376
|
+
amounts.each { |name, v| Check.number(v, word: :drift, name:, example: "drift(right: 40, every: 4)") }
|
|
377
|
+
dx = amounts[:right] - amounts[:left]
|
|
378
|
+
dy = amounts[:down] - amounts[:up]
|
|
379
|
+
Motion.word(self, period) do |art, p|
|
|
380
|
+
s = Asobi.swell(p)
|
|
381
|
+
Asobi.transform(art, Affine.translate(dx * s, dy * s))
|
|
382
|
+
end
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Change colour from +from+ (black if not given) to +to+ and back.
|
|
386
|
+
# shift_color(:gold) black, gold, black ...
|
|
387
|
+
# shift_color(:navy, :gold) navy, gold, navy ...
|
|
388
|
+
def shift_color(first = nil, second = nil, from: nil, to: nil, every: 4)
|
|
389
|
+
if second.nil?
|
|
390
|
+
to ||= first
|
|
391
|
+
else
|
|
392
|
+
from ||= first
|
|
393
|
+
to ||= second
|
|
394
|
+
end
|
|
395
|
+
from ||= :black
|
|
396
|
+
to ||= :crimson
|
|
397
|
+
period = Motion.seconds(every, :shift_color, :every, "shift_color(to: :crimson, every: 4)")
|
|
398
|
+
Color.css(from)
|
|
399
|
+
Color.css(to)
|
|
400
|
+
Motion.word(self, period) { |art, p| Asobi.paint(art, [Color.mix(from, to, Asobi.swell(p))]) }
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
# Your own motion. The block gets the picture and t, which runs from
|
|
404
|
+
# 0.0 up to 1.0 every +seconds+ seconds, and answers the picture then.
|
|
405
|
+
# star.animate(4) { |picture, t| picture.rotate(t * 360).grow(to: 1 + t) }
|
|
406
|
+
def animate(seconds = 4, every: seconds, &block)
|
|
407
|
+
unless block
|
|
408
|
+
raise Messages.error(ArgumentError, :need_block, word: :animate,
|
|
409
|
+
example: "animate(4) { |picture, t| picture.rotate(t * 360) }")
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
period = Motion.seconds(every, :animate, :every, "animate(4) { |picture, t| picture.rotate(t * 360) }")
|
|
413
|
+
Motion.word(self, period) { |art, t| block.call(art, t) }
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
# Only a picture in motion can be staggered: see Motion#one_by_one.
|
|
417
|
+
def one_by_one(*, **)
|
|
418
|
+
raise Messages.error(ArgumentError, :no_motion_word)
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
class << self
|
|
423
|
+
# 0 at the start of a loop, 1 halfway, 0 again at the end; smooth.
|
|
424
|
+
def swell(phase) = (1 - Math.cos(2 * Math::PI * phase)) / 2
|
|
425
|
+
|
|
426
|
+
# art.rb's versions are replaced (not wrapped) by the ones below.
|
|
427
|
+
remove_method :art, :pair, :gather, :change
|
|
428
|
+
|
|
429
|
+
# Pictures only. A Motion is a picture in motion, and is refused with a
|
|
430
|
+
# hint to take one moment of it; things that become motions (a list
|
|
431
|
+
# holding one, an object whose to_art answers one) answer that Motion.
|
|
432
|
+
def art(object, as: nil)
|
|
433
|
+
raise Motion.not_a_picture(object) if object.is_a?(Motion)
|
|
434
|
+
|
|
435
|
+
Motion.reach(object, as:)
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
# The doors of art.rb, widened: when a picture meets a motion, the
|
|
439
|
+
# answer is a motion that does the same at every moment.
|
|
440
|
+
def pair(a, b, &block)
|
|
441
|
+
Motion.combine([Motion.reach(a), Motion.reach(b)]) { |(x, y)| block.call(x, y) }
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
def gather(things, as: nil, &block)
|
|
445
|
+
items = things.to_a
|
|
446
|
+
return Motion.combine([as]) { |(shape)| gather(items, as: shape, &block) } if as.is_a?(Motion)
|
|
447
|
+
|
|
448
|
+
Motion.combine(items.map { Motion.reach(_1, as:) }, &block)
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
def change(thing, &block)
|
|
452
|
+
Motion.combine([Motion.reach(thing)]) { |(art)| block.call(art) }
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
Messages.add(:motion_not_picture,
|
|
457
|
+
en: "%{thing} is a picture in motion, not one still picture. Take the picture at one moment with at, like motion.at(0)",
|
|
458
|
+
ja: "%{thing} は動いている絵で、止まった1枚の絵ではありません。ある時刻の絵は at で取り出せます。例:motion.at(0)")
|
|
459
|
+
Messages.add(:need_positive,
|
|
460
|
+
en: "%{word} needs %{name} to be more than 0 seconds, like %{example}. You gave %{got}.",
|
|
461
|
+
ja: "%{word} の %{name} には 0 より大きい秒数を渡してください(いまは %{got})。例:%{example}")
|
|
462
|
+
Messages.add(:no_motion_word,
|
|
463
|
+
en: "one_by_one staggers a motion word, so write it right after one (spin, pulse, sway ...), like dot.repeat(12).in_ring.pulse(every: 1).one_by_one",
|
|
464
|
+
ja: "one_by_one は動きの言葉(spin、pulse、sway など)の直後に書いてください。例:dot.repeat(12).in_ring.pulse(every: 1).one_by_one")
|
|
465
|
+
Messages.add(:no_reel,
|
|
466
|
+
en: "saving a motion needs Asobi::Reel, which is not loaded. Use require \"asobi\", or save one moment: motion.at(0).save(\"still.svg\"). You gave %{path}.",
|
|
467
|
+
ja: "動きを保存するには Asobi::Reel が必要ですが、読み込まれていません。require \"asobi\" を使うか、ある時刻の絵を保存してください(いまは %{path})。例:motion.at(0).save(\"still.svg\")")
|
|
468
|
+
Messages.add(:bad_motion_save,
|
|
469
|
+
en: "Use motion.save(\"flower.html\") to save the animation or motion.at(0).save(\"still.svg\") to save one frame. You gave %{path}.",
|
|
470
|
+
ja: "アニメーションの保存先には .html を指定してください(いまは %{path})。例:motion.save(\"flower.html\")。静止画は motion.at(0).save(\"still.svg\") で保存できます。")
|
|
471
|
+
end
|
data/lib/asobi/nodes.rb
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asobi
|
|
4
|
+
# The picture values themselves. Each is an immutable Data value centred
|
|
5
|
+
# on the origin, and answers:
|
|
6
|
+
#
|
|
7
|
+
# box(m) the bounding Box once transformed by Affine m (nil if empty)
|
|
8
|
+
# svg an SVG fragment, without the outer <svg> tag
|
|
9
|
+
#
|
|
10
|
+
# Data's own +with+ is removed: asobi has no word "with" (DESIGN §5).
|
|
11
|
+
module Picture
|
|
12
|
+
def self.define(*fields, &)
|
|
13
|
+
Data.define(*fields) do
|
|
14
|
+
include Art
|
|
15
|
+
include Node
|
|
16
|
+
undef_method :with
|
|
17
|
+
class_eval(&)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
Circle = Picture.define(:r) do
|
|
23
|
+
def box(m) = Asobi::Ellipse.box_of(r, r, m)
|
|
24
|
+
def svg = %(<circle r="#{Asobi.fmt(r)}"/>)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
Ellipse = Picture.define(:rx, :ry) do
|
|
28
|
+
# Exact box of a transformed ellipse.
|
|
29
|
+
def self.box_of(rx, ry, m)
|
|
30
|
+
hx = Math.hypot(m.a * rx, m.c * ry)
|
|
31
|
+
hy = Math.hypot(m.b * rx, m.d * ry)
|
|
32
|
+
Asobi::Box.new(m.e - hx, m.f - hy, m.e + hx, m.f + hy)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def box(m) = Asobi::Ellipse.box_of(rx, ry, m)
|
|
36
|
+
def svg = %(<ellipse rx="#{Asobi.fmt(rx)}" ry="#{Asobi.fmt(ry)}"/>)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Rect = Picture.define(:w, :h) do
|
|
40
|
+
def box(m) = Asobi::Box.of_rect(w, h, m)
|
|
41
|
+
|
|
42
|
+
def svg
|
|
43
|
+
%(<rect x="#{Asobi.fmt(-w / 2.0)}" y="#{Asobi.fmt(-h / 2.0)}" ) +
|
|
44
|
+
%(width="#{Asobi.fmt(w)}" height="#{Asobi.fmt(h)}"/>)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Words, centred on the origin. The box is an estimate: about 0.6 of the
|
|
49
|
+
# size per Latin letter, a full size per wide (e.g. Japanese) character.
|
|
50
|
+
Text = Picture.define(:string, :size, :font) do
|
|
51
|
+
def initialize(string:, size: 16.0, font: nil)
|
|
52
|
+
super(string: string.to_s.dup.freeze, size: size.to_f, font: font&.to_s&.freeze)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def estimated_width
|
|
56
|
+
string.each_char.sum { |ch| ch.ascii_only? ? 0.6 : 1.0 } * size
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def box(m) = string.empty? ? nil : Asobi::Box.of_rect(estimated_width, size * 1.2, m)
|
|
60
|
+
|
|
61
|
+
def svg
|
|
62
|
+
%(<text text-anchor="middle" dominant-baseline="central" xml:space="preserve" ) +
|
|
63
|
+
%(font-family="#{Asobi.escape(font || "sans-serif")}" font-size="#{Asobi.fmt(size)}">) +
|
|
64
|
+
"#{Asobi.escape(string)}</text>"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Invisible, but takes up room. Useful as a gap in a row.
|
|
69
|
+
Space = Picture.define(:w, :h) do
|
|
70
|
+
def box(m) = Asobi::Box.of_rect(w, h, m)
|
|
71
|
+
def svg = ""
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Any outline made of straight and curved pieces, possibly several of them
|
|
75
|
+
# (a ring is two circles with fill_rule: :evenodd, so the hole is clear).
|
|
76
|
+
#
|
|
77
|
+
# +commands+ is a list of
|
|
78
|
+
# [:move, x, y] start a new piece here
|
|
79
|
+
# [:line, x, y] straight to (x, y)
|
|
80
|
+
# [:cubic, x1, y1, x2, y2, x, y] cubic Bézier to (x, y)
|
|
81
|
+
# [:close] straight back to the piece's start
|
|
82
|
+
#
|
|
83
|
+
# Build one with Path.new(commands: [...], fill_rule: :nonzero), or with
|
|
84
|
+
# the helpers Path.build { |pen| ... }, Path.polyline and Path.smooth.
|
|
85
|
+
# A Path is filled (black) unless styled; open lines are given
|
|
86
|
+
# outlined(:black) by the shape words.
|
|
87
|
+
Path = Picture.define(:commands, :fill_rule) do
|
|
88
|
+
def initialize(commands:, fill_rule: :nonzero)
|
|
89
|
+
clean = commands.map do |command|
|
|
90
|
+
name, *numbers = command
|
|
91
|
+
name = name.to_sym
|
|
92
|
+
unless Asobi::Path::ARITY[name] == numbers.size && numbers.all?(Numeric)
|
|
93
|
+
raise ArgumentError, "Path command #{command.inspect} is not one of " \
|
|
94
|
+
"[:move, x, y], [:line, x, y], [:cubic, x1, y1, x2, y2, x, y], [:close]"
|
|
95
|
+
end
|
|
96
|
+
[name, *numbers.map(&:to_f)].freeze
|
|
97
|
+
end
|
|
98
|
+
unless %i[nonzero evenodd].include?(fill_rule.to_sym)
|
|
99
|
+
raise ArgumentError, "fill_rule is :nonzero or :evenodd (got #{fill_rule.inspect})"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
super(commands: clean.freeze, fill_rule: fill_rule.to_sym)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Straight lines through [x, y] points.
|
|
106
|
+
def self.polyline(points, closed: false, fill_rule: :nonzero)
|
|
107
|
+
first, *rest = points
|
|
108
|
+
commands = [[:move, *first], *rest.map { [:line, *_1] }]
|
|
109
|
+
commands << [:close] if closed
|
|
110
|
+
new(commands:, fill_rule:)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A smooth curve through every point (Catmull-Rom, drawn as cubic
|
|
114
|
+
# Béziers). Closed curves wrap around smoothly too.
|
|
115
|
+
def self.smooth(points, closed: false, fill_rule: :nonzero)
|
|
116
|
+
return polyline(points, closed:, fill_rule:) if points.size < 3
|
|
117
|
+
|
|
118
|
+
n = points.size
|
|
119
|
+
at = closed ? ->(i) { points[i % n] } : ->(i) { points[i.clamp(0, n - 1)] }
|
|
120
|
+
commands = [[:move, *points[0]]]
|
|
121
|
+
(closed ? n : n - 1).times do |i|
|
|
122
|
+
p0, p1, p2, p3 = at.(i - 1), at.(i), at.(i + 1), at.(i + 2)
|
|
123
|
+
c1 = [p1[0] + (p2[0] - p0[0]) / 6.0, p1[1] + (p2[1] - p0[1]) / 6.0]
|
|
124
|
+
c2 = [p2[0] - (p3[0] - p1[0]) / 6.0, p2[1] - (p3[1] - p1[1]) / 6.0]
|
|
125
|
+
commands << [:cubic, *c1, *c2, *p2]
|
|
126
|
+
end
|
|
127
|
+
commands << [:close] if closed
|
|
128
|
+
new(commands:, fill_rule:)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Path.build(fill_rule: :evenodd) do |pen|
|
|
132
|
+
# pen.move(0, -10).curve(5, -10, 10, -5, 10, 0).line(0, 10).close
|
|
133
|
+
# end
|
|
134
|
+
def self.build(fill_rule: :nonzero)
|
|
135
|
+
pen = Asobi::Path::Pen.new
|
|
136
|
+
yield pen
|
|
137
|
+
new(commands: pen.commands, fill_rule:)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def closed? = commands.any? { _1.first == :close }
|
|
141
|
+
|
|
142
|
+
# Exact box, curves included: an affine image of a Bézier is the Bézier
|
|
143
|
+
# of the transformed control points.
|
|
144
|
+
def box(m)
|
|
145
|
+
return nil if commands.empty?
|
|
146
|
+
|
|
147
|
+
xs = []
|
|
148
|
+
ys = []
|
|
149
|
+
current = [0.0, 0.0]
|
|
150
|
+
commands.each do |name, *n|
|
|
151
|
+
case name
|
|
152
|
+
when :move, :line
|
|
153
|
+
current = m.apply(n[0], n[1])
|
|
154
|
+
xs << current[0]
|
|
155
|
+
ys << current[1]
|
|
156
|
+
when :cubic
|
|
157
|
+
c1 = m.apply(n[0], n[1])
|
|
158
|
+
c2 = m.apply(n[2], n[3])
|
|
159
|
+
to = m.apply(n[4], n[5])
|
|
160
|
+
xs.concat(Asobi::Geometry.cubic_extent(current[0], c1[0], c2[0], to[0]))
|
|
161
|
+
ys.concat(Asobi::Geometry.cubic_extent(current[1], c1[1], c2[1], to[1]))
|
|
162
|
+
current = to
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
Asobi::Box.new(xs.min, ys.min, xs.max, ys.max)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def svg
|
|
169
|
+
letters = { move: "M", line: "L", cubic: "C", close: "Z" }
|
|
170
|
+
d = commands.map { |name, *n| [letters[name], *n.map { Asobi.fmt(_1) }].join(" ") }.join(" ")
|
|
171
|
+
rule = fill_rule == :evenodd ? %( fill-rule="evenodd") : ""
|
|
172
|
+
%(<path d="#{d}"#{rule}/>)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
class Path
|
|
177
|
+
ARITY = { move: 2, line: 2, cubic: 6, close: 0 }.freeze
|
|
178
|
+
|
|
179
|
+
# Collects path commands for Path.build.
|
|
180
|
+
class Pen
|
|
181
|
+
attr_reader :commands
|
|
182
|
+
|
|
183
|
+
def initialize = @commands = []
|
|
184
|
+
def move(x, y) = add(:move, x, y)
|
|
185
|
+
def line(x, y) = add(:line, x, y)
|
|
186
|
+
def curve(x1, y1, x2, y2, x, y) = add(:cubic, x1, y1, x2, y2, x, y)
|
|
187
|
+
def close = add(:close)
|
|
188
|
+
|
|
189
|
+
private
|
|
190
|
+
|
|
191
|
+
def add(*command)
|
|
192
|
+
@commands << command
|
|
193
|
+
self
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# A picture with colours, lines or opacity. Built by Asobi.restyle.
|
|
199
|
+
Styled = Picture.define(:art, :style) do
|
|
200
|
+
def initialize(art:, style:)
|
|
201
|
+
super(art:, style: Asobi::Style.clean(style))
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def box(m)
|
|
205
|
+
inner = art.box(m)
|
|
206
|
+
inner = inner.grow(Asobi::Style.stroke_width(style) / 2.0 * m.stretch) if inner && Asobi::Style.stroked?(style)
|
|
207
|
+
Asobi::Effects.grow(inner, style[:effects], m)
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def svg = Asobi::Effects.render(style, art.svg) { art.box(IDENTITY) }
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# A picture moved, turned or resized. Built by Asobi.transform.
|
|
214
|
+
Transformed = Picture.define(:art, :matrix) do
|
|
215
|
+
def box(m) = art.box(m * matrix)
|
|
216
|
+
|
|
217
|
+
def svg
|
|
218
|
+
inner = art.svg
|
|
219
|
+
return "" if inner.empty?
|
|
220
|
+
|
|
221
|
+
t =
|
|
222
|
+
if matrix.translation?
|
|
223
|
+
"translate(#{Asobi.fmt(matrix.e)} #{Asobi.fmt(matrix.f)})"
|
|
224
|
+
else
|
|
225
|
+
"matrix(#{matrix.to_h.values.map { Asobi.fmt(_1) }.join(" ")})"
|
|
226
|
+
end
|
|
227
|
+
%(<g transform="#{t}">#{inner}</g>)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|