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/many.rb
ADDED
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asobi
|
|
4
|
+
# Several pictures that are, together, one picture.
|
|
5
|
+
#
|
|
6
|
+
# It is Enumerable, so everything you know about collections applies
|
|
7
|
+
# (each, select, each_slice, zip, count ...), and it is a picture, so
|
|
8
|
+
# every word in Asobi::Art applies too.
|
|
9
|
+
#
|
|
10
|
+
# Moving, turning or resizing a Many does the same to each of its
|
|
11
|
+
# pictures, so the answer is still a Many you can arrange. Colours given
|
|
12
|
+
# to the whole Many are kept on the group (+style+), and do not override
|
|
13
|
+
# colours given to the pictures inside it (as in SVG).
|
|
14
|
+
Many = Data.define(:items, :style) do
|
|
15
|
+
include Enumerable
|
|
16
|
+
include Art
|
|
17
|
+
include Node
|
|
18
|
+
undef_method :with
|
|
19
|
+
|
|
20
|
+
def initialize(items:, style: {})
|
|
21
|
+
super(items: items.map { Asobi.art(_1) }.freeze, style: Asobi::Style.clean(style))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A Many as it is; any other picture as a group of one.
|
|
25
|
+
def self.of(art) = art.is_a?(Asobi::Many) ? art : new([art])
|
|
26
|
+
|
|
27
|
+
def each(&) = items.each(&)
|
|
28
|
+
def size = items.size
|
|
29
|
+
def [](index) = items[index]
|
|
30
|
+
def empty? = items.empty?
|
|
31
|
+
|
|
32
|
+
def box(m)
|
|
33
|
+
inner = Asobi::Box.union(items.map { _1.box(m) })
|
|
34
|
+
inner = inner.grow(Asobi::Style.stroke_width(style) / 2.0 * m.stretch) if inner && Asobi::Style.stroked?(style)
|
|
35
|
+
Asobi::Effects.grow(inner, style[:effects], m)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The <defs> of the pictures inside are gathered to the front, once each.
|
|
39
|
+
def svg
|
|
40
|
+
inside = items.map(&:svg).join
|
|
41
|
+
return "" if inside.empty?
|
|
42
|
+
|
|
43
|
+
Asobi::Effects.render(style, inside, hoist: inside.include?("<defs>"), group: true) do
|
|
44
|
+
Asobi::Box.union(items.map { _1.box(IDENTITY) })
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# --- arranging ---------------------------------------------------------
|
|
49
|
+
# Each answers a new Many with the same pictures in new places.
|
|
50
|
+
|
|
51
|
+
# Left to right, touching (or +gap+ apart), centred on the origin.
|
|
52
|
+
def in_row(gap_ = nil, gap: gap_ || 0)
|
|
53
|
+
Asobi::Check.number(gap, word: :in_row, name: :gap, example: "in_row(gap: 4)")
|
|
54
|
+
line_up(gap.to_f, :x)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Top to bottom.
|
|
58
|
+
def in_column(gap_ = nil, gap: gap_ || 0)
|
|
59
|
+
Asobi::Check.number(gap, word: :in_column, name: :gap, example: "in_column(gap: 4)")
|
|
60
|
+
line_up(gap.to_f, :y)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A table, filled row by row. Every cell is as big as the biggest
|
|
64
|
+
# picture. Without columns:, the table is about as wide as it is tall.
|
|
65
|
+
def in_grid(columns_ = nil, columns: columns_, gap: 0)
|
|
66
|
+
columns ||= [Math.sqrt(size).ceil, 1].max
|
|
67
|
+
columns = Asobi::Check.whole(columns, word: :in_grid, name: :columns, example: "in_grid(columns: 3)", min: 1)
|
|
68
|
+
Asobi::Check.number(gap, word: :in_grid, name: :gap, example: "in_grid(columns: 3, gap: 4)")
|
|
69
|
+
boxes = items.map { _1.box(IDENTITY) }
|
|
70
|
+
found = boxes.compact
|
|
71
|
+
cell_w = found.map(&:width).max.to_f + gap
|
|
72
|
+
cell_h = found.map(&:height).max.to_f + gap
|
|
73
|
+
used = [columns, size].min
|
|
74
|
+
rows = (size / columns.to_f).ceil
|
|
75
|
+
place do |art, _t, i|
|
|
76
|
+
b = boxes[i]
|
|
77
|
+
x = (i % columns - (used - 1) / 2.0) * cell_w
|
|
78
|
+
y = (i / columns - (rows - 1) / 2.0) * cell_h
|
|
79
|
+
b ? art.move(right: x - b.cx, down: y - b.cy) : art
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Evenly around a circle, clockwise from the top. Without radius:, the
|
|
84
|
+
# circle is just big enough that neighbours do not touch. With
|
|
85
|
+
# facing_out: true each picture turns so its top points outwards.
|
|
86
|
+
def in_ring(radius_ = nil, radius: radius_, facing_out: false)
|
|
87
|
+
radius ||= auto_ring_radius
|
|
88
|
+
Asobi::Check.number(radius, word: :in_ring, name: :radius, example: "in_ring(radius: 60)", min: 0)
|
|
89
|
+
place do |art, t|
|
|
90
|
+
angle = t * 360
|
|
91
|
+
art = home(art)
|
|
92
|
+
art = art.rotate(angle) if facing_out
|
|
93
|
+
x, y = Asobi::Geometry.polar(radius, angle)
|
|
94
|
+
art.move(right: x, down: y)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Turn each picture a little more than the last, around the centre.
|
|
99
|
+
# (Move a petal off-centre, repeat it, fan it: a flower.) A fan of less
|
|
100
|
+
# than a full turn is spread evenly and centred on straight up.
|
|
101
|
+
def in_fan(over_ = nil, over: over_ || 360)
|
|
102
|
+
Asobi::Check.number(over, word: :in_fan, name: :over, example: "in_fan(over: 180)")
|
|
103
|
+
return self if empty?
|
|
104
|
+
|
|
105
|
+
if over.abs >= 360 || size == 1
|
|
106
|
+
place { |art, t| art.rotate(t * over) }
|
|
107
|
+
else
|
|
108
|
+
step = over.to_f / (size - 1)
|
|
109
|
+
place { |art, _t, i| art.rotate(i * step - over / 2.0) }
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# A sunflower-seed spiral from the centre outwards. Without spacing:,
|
|
114
|
+
# it is the size of the biggest picture: the closest neighbours (at the
|
|
115
|
+
# centre) then just touch, and those further out have room to spare.
|
|
116
|
+
def in_spiral(spacing_ = nil, spacing: spacing_)
|
|
117
|
+
spacing ||= biggest_size
|
|
118
|
+
Asobi::Check.number(spacing, word: :in_spiral, name: :spacing, example: "in_spiral(spacing: 8)", min: 0)
|
|
119
|
+
golden = 180 * (3 - Math.sqrt(5)) # the golden angle, in degrees
|
|
120
|
+
place do |art, _t, i|
|
|
121
|
+
x, y = Asobi::Geometry.polar(spacing * Math.sqrt(i), i * golden)
|
|
122
|
+
home(art).move(right: x, down: y)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Random places inside an area centred on the origin.
|
|
127
|
+
#
|
|
128
|
+
# scattered an area chosen from count and size
|
|
129
|
+
# scattered(over: 200) a 200 x 200 square
|
|
130
|
+
# scattered(over: [300, 100]) 300 wide, 100 tall
|
|
131
|
+
# scattered(seed: 7) the same picture every time, everywhere
|
|
132
|
+
#
|
|
133
|
+
# Without seed:, each run differs, but a run stays the same when the
|
|
134
|
+
# live view evaluates the sketch again (see Asobi::Random).
|
|
135
|
+
def scattered(over_ = nil, over: over_, seed: nil)
|
|
136
|
+
w, h = scatter_area(over)
|
|
137
|
+
random = Asobi::Random.generator(seed)
|
|
138
|
+
place do |art|
|
|
139
|
+
home(art).move(right: (random.rand - 0.5) * w, down: (random.rand - 0.5) * h)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Change each picture where it stands. The block gets the picture, then
|
|
144
|
+
# t (0.0 for the first, approaching 1.0 for the last), then its number.
|
|
145
|
+
#
|
|
146
|
+
# dot.repeat(12).in_ring.change_each { |one, t| one.grow(to: 1 + t) }
|
|
147
|
+
def change_each(&block)
|
|
148
|
+
unless block
|
|
149
|
+
raise Asobi::Messages.error(ArgumentError, :need_block, word: :change_each,
|
|
150
|
+
example: "change_each { |one, t, i| one.rotate(t * 90) }")
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
results = items.each_with_index.map { |art, i| block.call(art, i.fdiv(size), i) }
|
|
154
|
+
kept = style
|
|
155
|
+
Asobi.gather(results) { |arts| Asobi::Many.new(arts, kept) }
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def place
|
|
161
|
+
Asobi::Many.new(items.each_with_index.map { |art, i| yield art, i.fdiv(size), i }, style)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def line_up(gap, axis)
|
|
165
|
+
cursor = 0.0
|
|
166
|
+
placed = items.map do |art|
|
|
167
|
+
b = art.box(IDENTITY)
|
|
168
|
+
next [art, false] unless b
|
|
169
|
+
|
|
170
|
+
moved =
|
|
171
|
+
if axis == :x
|
|
172
|
+
art.move(right: cursor - b.x0, down: -b.cy)
|
|
173
|
+
else
|
|
174
|
+
art.move(right: -b.cx, down: cursor - b.y0)
|
|
175
|
+
end
|
|
176
|
+
cursor += (axis == :x ? b.width : b.height) + gap
|
|
177
|
+
[moved, true]
|
|
178
|
+
end
|
|
179
|
+
half = [cursor - gap, 0.0].max / 2.0
|
|
180
|
+
shifted = placed.map do |art, measured|
|
|
181
|
+
next art unless measured
|
|
182
|
+
|
|
183
|
+
axis == :x ? art.move(left: half) : art.move(up: half)
|
|
184
|
+
end
|
|
185
|
+
Asobi::Many.new(shifted, style)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Arranging starts from scratch. Each picture is first brought home, the
|
|
189
|
+
# middle of its box onto the origin, so that arranging a group that was
|
|
190
|
+
# already arranged (a row made by to_art, say) gives the new arrangement
|
|
191
|
+
# and not a mix of the two.
|
|
192
|
+
def home(art)
|
|
193
|
+
b = art.box(IDENTITY)
|
|
194
|
+
return art unless b && (b.cx.abs > 1e-9 || b.cy.abs > 1e-9)
|
|
195
|
+
|
|
196
|
+
art.move(left: b.cx, up: b.cy)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# How far a picture reaches from the origin, at most (its box corners).
|
|
200
|
+
def reach(art)
|
|
201
|
+
b = art.box(IDENTITY)
|
|
202
|
+
return 0.0 unless b
|
|
203
|
+
|
|
204
|
+
[[b.x0, b.y0], [b.x1, b.y0], [b.x0, b.y1], [b.x1, b.y1]].map { |x, y| Math.hypot(x, y) }.max
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Neighbours sit 2 * sin(180 / n) * radius apart; make that a little
|
|
208
|
+
# more than the widest picture's reach on both sides.
|
|
209
|
+
def auto_ring_radius
|
|
210
|
+
r = items.map { reach(home(_1)) }.max.to_f
|
|
211
|
+
return 0.0 if r.zero?
|
|
212
|
+
return r * 1.1 if size <= 2
|
|
213
|
+
|
|
214
|
+
[(2.2 * r) / (2 * Math.sin(Math::PI / size)), r * 1.1].max
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def biggest_size
|
|
218
|
+
sizes = items.filter_map { _1.box(IDENTITY) }.map { [_1.width, _1.height].max }
|
|
219
|
+
sizes.max || 10.0
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def scatter_area(over)
|
|
223
|
+
if over.nil?
|
|
224
|
+
side = biggest_size * 2 * Math.sqrt([size, 1].max)
|
|
225
|
+
return [side, side]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
w, h = over.is_a?(Array) ? over : [over, over]
|
|
229
|
+
ok = (over.is_a?(Array) ? over.size == 2 : true) && [w, h].all? { Asobi::Check.number?(_1) && _1 >= 0 }
|
|
230
|
+
raise Asobi::Messages.error(ArgumentError, :bad_area, got: over.inspect) unless ok
|
|
231
|
+
|
|
232
|
+
[w.to_f, h.to_f]
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Asobi
|
|
4
|
+
# Every message a person might read, in English and Japanese.
|
|
5
|
+
#
|
|
6
|
+
# Errors are teachers: each one says what is wrong and shows a line of
|
|
7
|
+
# code that would work. The language follows the computer (LC_ALL, then
|
|
8
|
+
# LC_MESSAGES, then LANG) unless you choose one with Asobi.language = :ja.
|
|
9
|
+
module Messages
|
|
10
|
+
TEXTS = {
|
|
11
|
+
not_art: {
|
|
12
|
+
en: "%{thing} can't become a picture yet. Give its class a to_art method, like: def to_art = circle(radius: 10)",
|
|
13
|
+
ja: "%{thing} はまだ絵になれません。そのクラスに to_art を書いてください。例:def to_art = circle(radius: 10)"
|
|
14
|
+
},
|
|
15
|
+
to_art_loop: {
|
|
16
|
+
en: "to_art of %{thing} never answered a picture. Make to_art return a picture, like: def to_art = circle(radius: 10)",
|
|
17
|
+
ja: "%{thing} の to_art が絵を返しませんでした。to_art では絵を返してください。例:def to_art = circle(radius: 10)"
|
|
18
|
+
},
|
|
19
|
+
need_number: {
|
|
20
|
+
en: "%{word} needs a number for %{name}, like %{example}. You gave %{got}.",
|
|
21
|
+
ja: "%{word} の %{name} には数を渡してください(いまは %{got})。例:%{example}"
|
|
22
|
+
},
|
|
23
|
+
need_at_least: {
|
|
24
|
+
en: "%{word} needs %{name} to be %{min} or more, like %{example}. You gave %{got}.",
|
|
25
|
+
ja: "%{word} の %{name} には %{min} 以上の数を渡してください(いまは %{got})。例:%{example}"
|
|
26
|
+
},
|
|
27
|
+
need_between: {
|
|
28
|
+
en: "%{word} needs %{name} to be from %{min} to %{max}, like %{example}. You gave %{got}.",
|
|
29
|
+
ja: "%{word} の %{name} には %{min} から %{max} までの数を渡してください(いまは %{got})。例:%{example}"
|
|
30
|
+
},
|
|
31
|
+
need_whole: {
|
|
32
|
+
en: "%{word} needs a whole number of %{name} (%{min} or more), like %{example}. You gave %{got}.",
|
|
33
|
+
ja: "%{word} の %{name} には %{min} 以上の整数を渡してください(いまは %{got})。例:%{example}"
|
|
34
|
+
},
|
|
35
|
+
bad_color: {
|
|
36
|
+
en: "%{got} is not a colour asobi knows%{hint}. Use a CSS colour name or a colour code, like colored(:crimson) or colored(\"#ff0066\").",
|
|
37
|
+
ja: "%{got} は知らない色です%{hint}。CSS の色の名前か色のコードを渡してください。例:colored(:crimson)、colored(\"#ff0066\")"
|
|
38
|
+
},
|
|
39
|
+
did_you_mean: {
|
|
40
|
+
en: " (did you mean :%{name}?)",
|
|
41
|
+
ja: "(:%{name} のことですか?)"
|
|
42
|
+
},
|
|
43
|
+
bad_point: {
|
|
44
|
+
en: "path needs points as [x, y] pairs of numbers, like path([0, 0], [40, -30], [80, 0]). You gave %{got}.",
|
|
45
|
+
ja: "path の点は数2つの組 [x, y] で渡してください(いまは %{got})。例:path([0, 0], [40, -30], [80, 0])"
|
|
46
|
+
},
|
|
47
|
+
few_points: {
|
|
48
|
+
en: "path needs at least 2 points, like path([0, 0], [40, -30]). You gave %{got}.",
|
|
49
|
+
ja: "path には点を2つ以上渡してください(いまは %{got} 個)。例:path([0, 0], [40, -30])"
|
|
50
|
+
},
|
|
51
|
+
bad_area: {
|
|
52
|
+
en: "scattered needs an area as a number or [width, height], like scattered(over: 200) or scattered(over: [300, 100]). You gave %{got}.",
|
|
53
|
+
ja: "scattered の over には数か [横, 縦] を渡してください(いまは %{got})。例:scattered(over: 200)、scattered(over: [300, 100])"
|
|
54
|
+
},
|
|
55
|
+
bad_seed: {
|
|
56
|
+
en: "seed needs a whole number, like scattered(seed: 7). You gave %{got}.",
|
|
57
|
+
ja: "seed には整数を渡してください(いまは %{got})。例:scattered(seed: 7)"
|
|
58
|
+
},
|
|
59
|
+
bad_save: {
|
|
60
|
+
en: "save can write %{kinds} files, like save(\"flower.svg\"). You gave %{got}.",
|
|
61
|
+
ja: "save で書き出せるのは %{kinds} です(いまは %{got})。例:save(\"flower.svg\")"
|
|
62
|
+
},
|
|
63
|
+
need_block: {
|
|
64
|
+
en: "%{word} needs a block that changes one picture, like %{example}",
|
|
65
|
+
ja: "%{word} には1つの絵を変えるブロックを付けてください。例:%{example}"
|
|
66
|
+
},
|
|
67
|
+
endless: {
|
|
68
|
+
en: "%{got} never ends, so it can't become a picture. Give the range an end, like (1..10).to_art",
|
|
69
|
+
ja: "%{got} は終わりがないので絵にできません。終わりのある範囲にしてください。例:(1..10).to_art"
|
|
70
|
+
},
|
|
71
|
+
bad_paper: {
|
|
72
|
+
en: "paper needs [width, height], like paper: [800, 800]. You gave %{got}.",
|
|
73
|
+
ja: "paper には [横, 縦] を渡してください(いまは %{got})。例:paper: [800, 800]"
|
|
74
|
+
},
|
|
75
|
+
bad_language: {
|
|
76
|
+
en: "the language can be :en or :ja, like Asobi.language = :ja. You gave %{got}.",
|
|
77
|
+
ja: "言語は :en か :ja を選んでください(いまは %{got})。例:Asobi.language = :ja"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
LANGUAGES = %i[en ja].freeze
|
|
82
|
+
|
|
83
|
+
module_function
|
|
84
|
+
|
|
85
|
+
# The language chosen with Asobi.language=, or else the computer's.
|
|
86
|
+
def language = @language || detect
|
|
87
|
+
|
|
88
|
+
def language=(value)
|
|
89
|
+
if value.nil?
|
|
90
|
+
@language = nil
|
|
91
|
+
return
|
|
92
|
+
end
|
|
93
|
+
lang = value.to_s.downcase[0, 2].to_sym if value.respond_to?(:to_s)
|
|
94
|
+
raise ArgumentError, text(:bad_language, got: value.inspect) unless LANGUAGES.include?(lang)
|
|
95
|
+
|
|
96
|
+
@language = lang
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# LC_ALL wins over LC_MESSAGES, which wins over LANG; the first one that
|
|
100
|
+
# is set decides.
|
|
101
|
+
def detect(env = ENV)
|
|
102
|
+
setting = %w[LC_ALL LC_MESSAGES LANG].map { env[_1] }.find { _1 && !_1.empty? }
|
|
103
|
+
setting.to_s.downcase.start_with?("ja") ? :ja : :en
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Other files bring their own messages:
|
|
107
|
+
# Asobi::Messages.add(:need_motion, en: "...", ja: "...")
|
|
108
|
+
def add(key, en:, ja:)
|
|
109
|
+
TEXTS[key] = { en:, ja: }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def text(key, **vars)
|
|
113
|
+
table = TEXTS.fetch(key)
|
|
114
|
+
format(table.fetch(language), **vars)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# An exception carrying a message in the current language.
|
|
118
|
+
def error(klass, key, **vars) = klass.new(text(key, **vars))
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def self.language = Messages.language
|
|
122
|
+
def self.language=(value)
|
|
123
|
+
Messages.language = value
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Checks for numbers given to the words. Each answers the value as a Float
|
|
127
|
+
# (or Integer for whole) or raises a teaching error.
|
|
128
|
+
module Check
|
|
129
|
+
module_function
|
|
130
|
+
|
|
131
|
+
def number?(value) = value.is_a?(Numeric) && value.real? && value.finite?
|
|
132
|
+
|
|
133
|
+
def number(value, word:, name:, example:, min: nil, max: nil)
|
|
134
|
+
raise Messages.error(TypeError, :need_number, **talk(value, word, name, example)) unless number?(value)
|
|
135
|
+
|
|
136
|
+
if min && max && !(min..max).cover?(value)
|
|
137
|
+
raise Messages.error(ArgumentError, :need_between, min:, max:, **talk(value, word, name, example))
|
|
138
|
+
end
|
|
139
|
+
if min && value < min
|
|
140
|
+
raise Messages.error(ArgumentError, :need_at_least, min:, **talk(value, word, name, example))
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
value.to_f
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def whole(value, word:, name:, example:, min: 0)
|
|
147
|
+
ok = number?(value) && value == value.round && value >= min
|
|
148
|
+
raise Messages.error(ArgumentError, :need_whole, min:, **talk(value, word, name, example)) unless ok
|
|
149
|
+
|
|
150
|
+
value.round
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def talk(value, word, name, example) = { word:, name:, example:, got: value.inspect }
|
|
154
|
+
end
|
|
155
|
+
end
|