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.
@@ -0,0 +1,207 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asobi
4
+ # Plain sentences that are not errors (greetings, hints). They live apart
5
+ # from Messages, whose every entry must teach with an example.
6
+ module Say
7
+ TEXTS = {}
8
+
9
+ module_function
10
+
11
+ def add(key, en:, ja:) = TEXTS[key] = { en:, ja: }
12
+ def text(key, **vars) = format(TEXTS.fetch(key).fetch(Messages.language), **vars)
13
+ end
14
+
15
+ Messages.add(:sketch_no_show,
16
+ en: "%{file} never calls show, so there is nothing to see yet. Add a line at the end, like: show circle",
17
+ ja: "%{file} に show がありません。最後に1行足してください。例:show circle")
18
+ Say.add(:sketch_did_you_mean,
19
+ en: " Did you mean: %{words}?",
20
+ ja: " もしかして:%{words}")
21
+ Messages.add(:sketch_unreadable,
22
+ en: "asobi can't read %{file}. Check that the file exists, like: asobi flower.rb",
23
+ ja: "%{file} を読めません。ファイルがあるか確かめてください。例:asobi flower.rb")
24
+
25
+ # show, and running a sketch file.
26
+ #
27
+ # What show does depends on where it runs:
28
+ # 1. ASOBI_LIVE=0 only remember (tests, CI)
29
+ # 2. inside Sketch.evaluate (live server) only remember
30
+ # 3. a script run with plain `ruby x.rb` remember; show it live after the script ends
31
+ # 4. irb and friends start the live server, push on every show
32
+ module Sketch
33
+ Shown = Data.define(:thing, :on, :paper, :grain) do
34
+ def initialize(thing:, on: :white, paper: nil, grain: nil) = super
35
+ end
36
+
37
+ @mutex = Mutex.new
38
+ @shown = nil
39
+ @evaluating = false
40
+ @exit_hook = false
41
+ @server = nil
42
+
43
+ class << self
44
+ # What the last show asked for (a Shown with thing, on, paper, grain), or nil.
45
+ attr_reader :shown
46
+
47
+ def forget = @shown = nil
48
+
49
+ def evaluating? = @evaluating
50
+
51
+ def show(picture, on: :white, paper: nil, grain: nil)
52
+ # Fail on the line of the show, with a teaching message.
53
+ Reel.motion?(picture) || Asobi.art(picture)
54
+ Color.css(on)
55
+ SVG.paper_size(paper) if paper
56
+ SVG.grain_strength(grain)
57
+ @shown = Shown.new(picture, on, paper, grain)
58
+
59
+ if ENV["ASOBI_LIVE"] == "0" || @evaluating
60
+ # only remember
61
+ elsif (path = script_file)
62
+ register_exit_hook(path)
63
+ else
64
+ push_to_server
65
+ end
66
+ picture
67
+ end
68
+
69
+ # The script being run by plain `ruby x.rb`, or nil (irb, ruby -e ...).
70
+ def script_file
71
+ return nil if defined?(::IRB) || defined?(::Pry)
72
+
73
+ name = $PROGRAM_NAME.to_s
74
+ return nil if name.empty? || name == "-e" || name == "-" || !File.file?(name)
75
+
76
+ File.expand_path(name)
77
+ end
78
+
79
+ # Run a sketch file and answer its Reel. Never raises for mistakes in
80
+ # the sketch: they come back as an error Reel.
81
+ def evaluate(path)
82
+ path = File.expand_path(path.to_s)
83
+ begin
84
+ code = File.read(path, encoding: "UTF-8")
85
+ rescue SystemCallError
86
+ return Reel.error(Messages.text(:sketch_unreadable, file: File.basename(path)))
87
+ end
88
+ run(code, name: path)
89
+ end
90
+
91
+ # Run a sketch given as a String (the web playground, the site builder)
92
+ # and answer its Reel. Errors name `name` and the line, like a file.
93
+ # Asobi::Sketch.run("show circle\n", name: "play.rb")
94
+ def run(source, name: "sketch.rb")
95
+ code = source.to_s.dup.force_encoding(Encoding::UTF_8)
96
+ @mutex.synchronize { run_now(code, name.to_s) }
97
+ end
98
+
99
+ # The Reel for whatever was shown last.
100
+ def reel(code: nil)
101
+ s = @shown
102
+ Reel.of(s.thing, on: s.on, paper: s.paper, grain: s.grain, code:)
103
+ end
104
+
105
+ private
106
+
107
+ def run_now(code, path)
108
+ Random.reset_calls!
109
+ @shown = nil
110
+ @evaluating = true
111
+ begin
112
+ # Like load(path, true): the sketch runs on a fresh "main" of its
113
+ # own, so its classes, constants and methods never leak into Object.
114
+ main = Object.new
115
+ def main.inspect = "main"
116
+ def main.to_s = "main"
117
+ @main = main
118
+ main.instance_eval(code, path, 1)
119
+ rescue SystemExit
120
+ # a sketch that says exit still gets to show what it showed
121
+ rescue ScriptError, StandardError => e
122
+ return Reel.error(describe(e, path), code:)
123
+ ensure
124
+ @evaluating = false
125
+ end
126
+
127
+ return Reel.error(Messages.text(:sketch_no_show, file: File.basename(path)), code:) unless @shown
128
+
129
+ begin
130
+ reel(code:)
131
+ rescue ScriptError, StandardError => e
132
+ Reel.error(describe(e, path), code:)
133
+ end
134
+ end
135
+
136
+ # "flower.rb:4 undefined local variable or method 'petl' ..."
137
+ def describe(error, path)
138
+ name = File.basename(path)
139
+ message = error.message.to_s.gsub(path, name)
140
+ # the sketch's own "main" reads as main, like the top level of a file
141
+ message = message.sub(/ for (?:an instance of Object|#<Object:0x\h+>)/, " for main") if error.respond_to?(:receiver) && (error.receiver.equal?(@main) rescue false)
142
+ if error.is_a?(SyntaxError)
143
+ lines = message.lines.map(&:rstrip).reject(&:empty?).first(4)
144
+ lines[0] = "#{name}: #{lines[0]}" unless lines[0].to_s.start_with?("#{name}:")
145
+ return lines.join("\n")
146
+ end
147
+
148
+ line = line_in(error, path)
149
+ head = message.lines.first.to_s.strip
150
+ text = line ? "#{name}:#{line} #{head}" : head
151
+ text + suggestion(error)
152
+ end
153
+
154
+ def line_in(error, path)
155
+ spot = (error.backtrace_locations || []).find { _1.absolute_path == path || _1.path == path }
156
+ spot&.lineno
157
+ end
158
+
159
+ def suggestion(error)
160
+ return "" unless error.respond_to?(:corrections)
161
+
162
+ words = error.corrections.first(3).map(&:to_s)
163
+ words.empty? ? "" : Say.text(:sketch_did_you_mean, words: words.join(", "))
164
+ rescue StandardError
165
+ ""
166
+ end
167
+
168
+ def register_exit_hook(path)
169
+ return if @exit_hook
170
+
171
+ @exit_hook = true
172
+ at_exit do
173
+ failure = $!
174
+ next if failure && !(failure.is_a?(SystemExit) && failure.success?)
175
+
176
+ begin
177
+ require_relative "live"
178
+ Live.run(path, initial: reel(code: File.read(path, encoding: "UTF-8")))
179
+ rescue Interrupt
180
+ nil
181
+ end
182
+ end
183
+ end
184
+
185
+ def push_to_server
186
+ require_relative "live"
187
+ @server ||= Live.start(announce: true)
188
+ @server.push(reel)
189
+ end
190
+ end
191
+ end
192
+
193
+ # Show a picture on screen. The last show wins.
194
+ # show flower
195
+ # show flower, on: :black
196
+ # show flower, paper: [800, 800]
197
+ # show flower, grain: true
198
+ def self.show(picture, on: :white, paper: nil, grain: nil) = Sketch.show(picture, on:, paper:, grain:)
199
+
200
+ # The word itself, available everywhere like puts.
201
+ module ShowWord
202
+ def show(picture, on: :white, paper: nil, grain: nil) = Asobi::Sketch.show(picture, on:, paper:, grain:)
203
+ private :show
204
+ end
205
+ end
206
+
207
+ Object.include(Asobi::ShowWord)
data/lib/asobi/svg.rb ADDED
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asobi
4
+ # Whole SVG documents. The look is ink on paper: shapes are black unless
5
+ # they say otherwise, the page is white, and the picture fills the page
6
+ # with a small margin.
7
+ #
8
+ # The pieces are separate so the browser player can use them too: frames
9
+ # are fragments (art.svg) and share one viewBox (SVG.view).
10
+ module SVG
11
+ NAMESPACE = "http://www.w3.org/2000/svg"
12
+ PIXELS = 512 # the larger side of a saved picture, when no paper is given
13
+
14
+ module_function
15
+
16
+ # SVG.document(art) white page, auto margin
17
+ # SVG.document(art, on: :black) a black page
18
+ # SVG.document(art, on: nil) see-through
19
+ # SVG.document(art, paper: [800, 800]) centred on 800 x 800 paper
20
+ # SVG.document(art, grain: true) fine grain over it all
21
+ def document(art, margin: nil, background: :white, on: background, paper: nil, grain: nil)
22
+ art = Asobi.art(art)
23
+ strength = grain_strength(grain)
24
+ view = view(art.box(IDENTITY), margin:, paper:)
25
+ open_tag(view, size: paper && paper_size(paper)) + background(view, on) + art.svg +
26
+ (strength ? grain(view, strength) : "") + "</svg>\n"
27
+ end
28
+
29
+ # The viewBox [x, y, width, height] for a box (or a picture): the box
30
+ # plus a margin, or the paper centred on the origin. An empty picture
31
+ # gets a small square so the page is never zero-sized.
32
+ def view(box_or_art = nil, margin: nil, paper: nil)
33
+ if paper
34
+ w, h = paper_size(paper)
35
+ return [-w / 2.0, -h / 2.0, w, h]
36
+ end
37
+
38
+ box = box_or_art.is_a?(Box) || box_or_art.nil? ? box_or_art : Asobi.art(box_or_art).box(IDENTITY)
39
+ box ||= Box.new(-10.0, -10.0, 10.0, 10.0)
40
+ margin ||= [[box.width, box.height].max * 0.08, 2.0].max
41
+ Check.number(margin, word: :to_svg, name: :margin, example: "to_svg(margin: 10)", min: 0)
42
+ box.grow(margin).to_a
43
+ end
44
+
45
+ # The opening <svg> tag for a viewBox. +size+ is [width, height] in
46
+ # pixels; by default the larger side is PIXELS.
47
+ def open_tag(view, size: nil)
48
+ _, _, w, h = view
49
+ size ||= begin
50
+ k = PIXELS / [w, h, 1e-9].max
51
+ [w * k, h * k]
52
+ end
53
+ %(<svg xmlns="#{NAMESPACE}" viewBox="#{view.map { Asobi.fmt(_1) }.join(" ")}" ) +
54
+ %(width="#{Asobi.fmt(size[0])}" height="#{Asobi.fmt(size[1])}" ) +
55
+ %(stroke-linejoin="round" stroke-linecap="round">)
56
+ end
57
+
58
+ # A rectangle of colour covering the viewBox ("" when see-through).
59
+ def background(view, color)
60
+ paint = Color.css(color)
61
+ return "" if %w[none transparent].include?(paint)
62
+
63
+ x, y, w, h = view.map { Asobi.fmt(_1) }
64
+ %(<rect x="#{x}" y="#{y}" width="#{w}" height="#{h}" fill="#{Asobi.escape(paint)}"/>)
65
+ end
66
+
67
+ def paper_size(paper)
68
+ ok = paper.is_a?(Array) && paper.size == 2 && paper.all? { Check.number?(_1) && _1.positive? }
69
+ raise Messages.error(ArgumentError, :bad_paper, got: paper.inspect) unless ok
70
+
71
+ paper.map(&:to_f)
72
+ end
73
+ end
74
+ end
75
+
76
+ module Asobi
77
+ # Effects (glow, blur, shadow, clip, blend), drawn with SVG filters,
78
+ # masks and mix-blend-mode.
79
+ #
80
+ # An effect lives in a style under :effects, an ordered Hash:
81
+ # glow: { size:, color: } color nil: the picture's own colours
82
+ # blur: amount
83
+ # shadow: { right:, down:, blur:, color:, opacity: }
84
+ # clip: a picture (the window, in the same coordinates)
85
+ # blend: "screen", "multiply" ...
86
+ #
87
+ # Every definition (<filter>, <mask>, gradient) is written inside the
88
+ # picture's own SVG as <defs>, with an id made from its content, so a
89
+ # fragment is complete by itself, equal pictures give equal SVG, and the
90
+ # same definition twice on one page is harmless. A Many gathers the
91
+ # <defs> of everything inside it to its start, once each.
92
+ module Effects
93
+ BLENDS = %w[normal screen multiply overlay lighten darken difference].freeze
94
+ DEFS = %r{<defs>(.*?)</defs>}m
95
+
96
+ module_function
97
+
98
+ # +effects+ with +key+ set to +value+ (moved to the end: it is the
99
+ # latest word).
100
+ def put(effects, key, value) = (effects || {}).reject { |k, _| k == key }.merge(key => value).freeze
101
+
102
+ # +node+ without the effect +key+ wherever it sits directly on it (not
103
+ # inside a Many): so the same effect given again replaces the old one.
104
+ def strip(node, key)
105
+ case node
106
+ when Transformed then Transformed.new(strip(node.art, key), node.matrix)
107
+ when Styled
108
+ effects = node.style[:effects]
109
+ return node unless effects
110
+
111
+ inner = strip(node.art, key)
112
+ rest = effects.reject { |k, _| k == key }
113
+ style = rest.empty? ? node.style.except(:effects) : node.style.merge(effects: rest.freeze)
114
+ style.empty? ? inner : Styled.new(inner, style)
115
+ else node
116
+ end
117
+ end
118
+
119
+ # The effects of a Many after its pictures are moved by +matrix+.
120
+ def transform(effects, matrix)
121
+ k = matrix.stretch
122
+ effects.to_h do |key, value|
123
+ changed =
124
+ case key
125
+ when :glow then value.merge(size: value[:size] * k)
126
+ when :blur then value * k
127
+ when :shadow
128
+ right, down = matrix.linear.apply(value[:right], value[:down])
129
+ value.merge(right: right, down: down, blur: value[:blur] * k)
130
+ when :clip then Asobi.transform(value, matrix)
131
+ else value
132
+ end
133
+ [key, changed.frozen? ? changed : changed.freeze]
134
+ end.freeze
135
+ end
136
+
137
+ # How far each effect reaches outside the picture.
138
+ def spill(key, value)
139
+ case key
140
+ when :glow then value[:size] * 1.5
141
+ when :blur then value * 3.0
142
+ when :shadow then value[:blur] * 1.5
143
+ else 0.0
144
+ end
145
+ end
146
+
147
+ # The box of a picture with its effects, given its plain box under m.
148
+ def grow(box, effects, m)
149
+ return box unless effects
150
+
151
+ effects.each do |key, value|
152
+ break if box.nil?
153
+
154
+ box =
155
+ case key
156
+ when :glow, :blur then box.grow(spill(key, value) * m.stretch)
157
+ when :shadow
158
+ dx, dy = m.linear.apply(value[:right], value[:down])
159
+ moved = Box.new(box.x0 + dx, box.y0 + dy, box.x1 + dx, box.y1 + dy).grow(spill(key, value) * m.stretch)
160
+ Box.union([box, moved])
161
+ when :clip then intersect(box, value.box(m))
162
+ else box
163
+ end
164
+ end
165
+ box
166
+ end
167
+
168
+ def intersect(a, b)
169
+ return nil unless a && b
170
+
171
+ x0 = [a.x0, b.x0].max
172
+ y0 = [a.y0, b.y0].max
173
+ x1 = [a.x1, b.x1].min
174
+ y1 = [a.y1, b.y1].min
175
+ x0 > x1 || y0 > y1 ? nil : Box.new(x0, y0, x1, y1)
176
+ end
177
+
178
+ # The SVG for a picture drawn with +style+: +inside+ is the SVG of what
179
+ # it contains, +box+ (a block, asked only when needed) its plain box.
180
+ # With +hoist+, the <defs> inside are gathered to the front.
181
+ #
182
+ # A gradient on a group (+group+) is drawn once across the whole group:
183
+ # a rectangle of gradient seen through the group's own shape (a mask),
184
+ # because SVG would otherwise start the gradient again in every
185
+ # moved or turned picture inside. Pictures inside the group then take
186
+ # the gradient too, even ones with colours of their own.
187
+ def render(style, inside, hoist: false, group: false, &box)
188
+ if Style.plain?(style) && !hoist
189
+ return "<g#{Style.attributes(style)}>#{inside}</g>"
190
+ end
191
+
192
+ defs = []
193
+ if hoist
194
+ defs.concat(inside.scan(DEFS).flatten)
195
+ inside = inside.gsub(DEFS, "")
196
+ end
197
+ plain_box = nil
198
+ measured = -> { plain_box ||= [box.call] }
199
+
200
+ urls = {}
201
+ style.each_value do |value|
202
+ next unless value.is_a?(Gradient)
203
+
204
+ body = value.definition(measured.call.first)
205
+ urls[value] = SVG.id_for(body)
206
+ defs << SVG.with_id(body, urls[value])
207
+ end
208
+ out =
209
+ if group && urls.any? && measured.call.first
210
+ through_window(style, inside, urls, measured.call.first, defs)
211
+ elsif style.except(:effects).empty?
212
+ inside
213
+ else
214
+ "<g#{Style.attributes(style, urls)}>#{inside}</g>"
215
+ end
216
+
217
+ effects = style[:effects] || {}
218
+ current = measured.call.first if effects.any?
219
+ current = current.grow(Style.stroke_width(style) / 2.0) if current && Style.stroked?(style)
220
+ blend = nil
221
+ effects.each do |key, value|
222
+ if key == :blend
223
+ blend = value
224
+ next
225
+ end
226
+ next unless current
227
+
228
+ body, attribute, extra = definition(key, value, current)
229
+ if body
230
+ defs.concat(extra) if extra
231
+ id = SVG.id_for(body)
232
+ defs << SVG.with_id(body, id)
233
+ out = %(<g #{attribute}="url(##{id})">#{out}</g>)
234
+ end
235
+ current = grow(current, { key => value }, IDENTITY)
236
+ end
237
+ out = %(<g style="mix-blend-mode:#{blend}">#{out}</g>) if blend && blend != "normal"
238
+ list = defs.uniq
239
+ list.empty? ? out : "<defs>#{list.join}</defs>#{out}"
240
+ end
241
+
242
+ # The group drawn as its gradient, seen through the group's shape.
243
+ def through_window(style, inside, urls, box, defs)
244
+ box = box.grow(Style.stroke_width(style) / 2.0) if Style.stroked?(style)
245
+ box = box.grow([box.width, box.height].max * 0.01 + 0.5)
246
+ gradient = style[:fill].is_a?(Gradient) ? style[:fill] : style[:stroke]
247
+ solid = style.except(:effects, :opacity).to_h { |k, v| [k, v.is_a?(Gradient) ? "black" : v] }
248
+ mask = %(<mask maskUnits="userSpaceOnUse" style="mask-type:alpha" #{region(box)}>) +
249
+ %(<g#{Style.attributes(solid)}>#{inside}</g></mask>)
250
+ id = SVG.id_for(mask)
251
+ defs << SVG.with_id(mask, id)
252
+ fade = style[:opacity] ? %( opacity="#{Asobi.fmt(style[:opacity])}") : ""
253
+ %(<rect #{region(box)} fill="url(##{urls.fetch(gradient)})" mask="url(##{id})"#{fade}/>)
254
+ end
255
+
256
+ # [definition without id, the attribute that uses it, other <defs> it
257
+ # needs] for one effect
258
+ # on a picture whose box is +box+.
259
+ def definition(key, value, box)
260
+ if key == :clip
261
+ window = value.svg
262
+ inner_defs = window.scan(DEFS).flatten
263
+ window = window.gsub(DEFS, "")
264
+ b = value.box(IDENTITY)
265
+ return [nil, nil] unless b
266
+
267
+ b = b.grow([b.width, b.height].max * 0.05 + 1)
268
+ mask = %(<mask maskUnits="userSpaceOnUse" style="mask-type:alpha" #{region(b)}>#{window}</mask>)
269
+ return [mask, "mask", inner_defs]
270
+ end
271
+
272
+ area = grow(box, { key => value }, IDENTITY)
273
+ area = area.grow([area.width, area.height].max * 0.05 + 2)
274
+ primitives =
275
+ case key
276
+ when :glow
277
+ sigma = value[:size] / 2.0
278
+ tint = value[:color] ? %(<feFlood flood-color="#{Asobi.escape(value[:color])}"/><feComposite in2="b" operator="in" result="b"/>) : ""
279
+ %(<feGaussianBlur in="SourceGraphic" stdDeviation="#{Asobi.fmt(sigma)}" result="b"/>#{tint}) +
280
+ %(<feMerge><feMergeNode in="b"/><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>)
281
+ when :blur
282
+ %(<feGaussianBlur in="SourceGraphic" stdDeviation="#{Asobi.fmt(value)}"/>)
283
+ when :shadow
284
+ %(<feGaussianBlur in="SourceAlpha" stdDeviation="#{Asobi.fmt(value[:blur] / 2.0)}"/>) +
285
+ %(<feOffset dx="#{Asobi.fmt(value[:right])}" dy="#{Asobi.fmt(value[:down])}" result="o"/>) +
286
+ %(<feFlood flood-color="#{Asobi.escape(value[:color])}" flood-opacity="#{Asobi.fmt(value[:opacity])}"/>) +
287
+ %(<feComposite in2="o" operator="in"/>) +
288
+ %(<feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge>)
289
+ end
290
+ [%(<filter filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB" #{region(area)}>#{primitives}</filter>), "filter"]
291
+ end
292
+
293
+ def region(b)
294
+ %(x="#{Asobi.fmt(b.x0)}" y="#{Asobi.fmt(b.y0)}" width="#{Asobi.fmt([b.width, 0.001].max)}" height="#{Asobi.fmt([b.height, 0.001].max)}")
295
+ end
296
+ end
297
+
298
+ module SVG
299
+ MASK64 = (1 << 64) - 1
300
+ FNV_PRIME = 0x100000001b3
301
+ @ids = {}
302
+
303
+ class << self
304
+ # An id made from the definition itself (FNV-1a, 64 bits): equal
305
+ # definitions get equal ids, different ones (practically) never do.
306
+ def id_for(body)
307
+ @ids.clear if @ids.size > 4096
308
+ @ids[body] ||= begin
309
+ h = 0xcbf29ce484222325
310
+ body.each_byte { |b| h = ((h ^ b) * FNV_PRIME) & MASK64 }
311
+ "asobi-#{h.to_s(36)}"
312
+ end
313
+ end
314
+
315
+ # The definition with its id in the opening tag.
316
+ def with_id(body, id) = body.sub(/\A<(\w+)/) { %(<#{$1} id="#{id}") }
317
+
318
+ # Fine grain over the whole view (+strength+ 0..1), the same each
319
+ # time: a small fragment to put after the picture.
320
+ def grain(view, strength)
321
+ x, y, w, h = view
322
+ frequency = 0.9 * 600 / [w, h, 1e-9].max
323
+ # Dark specks where the noise is high and light specks where it is
324
+ # low, so the page keeps its colour on average, light or dark.
325
+ k = strength * 4
326
+ up = Asobi.fmt(k)
327
+ down = Asobi.fmt(-k)
328
+ half = Asobi.fmt(k / 2)
329
+ body = %(<filter x="0" y="0" width="1" height="1" color-interpolation-filters="sRGB">) +
330
+ %(<feTurbulence type="fractalNoise" baseFrequency="#{Asobi.fmt(frequency.round(4))}" numOctaves="2" seed="7" stitchTiles="stitch" result="n"/>) +
331
+ %(<feColorMatrix in="n" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 #{up} 0 0 0 -#{half}" result="d"/>) +
332
+ %(<feColorMatrix in="n" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 #{down} 0 0 0 #{half}" result="l"/>) +
333
+ %(<feMerge><feMergeNode in="d"/><feMergeNode in="l"/></feMerge></filter>)
334
+ id = id_for(body)
335
+ # Three views wide and tall: when the window's shape differs from the
336
+ # picture's, a browser leaves bars beside it, and those need grain
337
+ # too. Whatever falls outside the page is simply not drawn.
338
+ %(<defs>#{with_id(body, id)}</defs><rect x="#{Asobi.fmt(x - w)}" y="#{Asobi.fmt(y - h)}" ) +
339
+ %(width="#{Asobi.fmt(w * 3)}" height="#{Asobi.fmt(h * 3)}" filter="url(##{id})" style="pointer-events:none"/>)
340
+ end
341
+
342
+ # true is 0.12; false or nil is none; a number from 0 to 1.
343
+ def grain_strength(grain)
344
+ return nil if grain.nil? || grain == false
345
+ return 0.12 if grain == true
346
+
347
+ Check.number(grain, word: :grain, name: :grain, example: "show flower, grain: 0.3", min: 0, max: 1)
348
+ grain.zero? ? nil : grain.to_f
349
+ end
350
+ end
351
+ end
352
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "pure"
4
+
5
+ module Asobi
6
+ # The shape words, everywhere, as private methods (like puts): you call
7
+ # them bare, never on another object.
8
+ module TopLevel
9
+ include Shapes
10
+ private(*Shapes::WORDS)
11
+ end
12
+ end
13
+
14
+ Object.include(Asobi::TopLevel)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Asobi
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,34 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>asobi</title>
7
+ <style>
8
+ html, body { margin: 0; height: 100%; overflow: hidden; background: #fff; }
9
+ #stage { position: fixed; inset: 0; }
10
+ #offline { position: fixed; left: 10px; bottom: 10px; display: none; font: 12px system-ui, sans-serif;
11
+ color: #fff; background: rgba(20,20,24,.7); padding: 5px 9px; border-radius: 6px; }
12
+ </style>
13
+ </head>
14
+ <body>
15
+ <div id="stage"></div>
16
+ <div id="offline"></div>
17
+ <script>/*ASOBI_PLAYER_JS*/</script>
18
+ <script>
19
+ (function () {
20
+ var ja = /^ja/i.test(navigator.language || "");
21
+ var offline = document.getElementById("offline");
22
+ offline.textContent = ja ? "接続が切れました。再接続を待っています…" : "Disconnected. Waiting to reconnect...";
23
+ var player = AsobiPlayer.mount(document.getElementById("stage"));
24
+ var source = new EventSource("/events");
25
+ source.onopen = function () { offline.style.display = "none"; };
26
+ source.onerror = function () { offline.style.display = "block"; }; // EventSource retries by itself
27
+ source.onmessage = function (event) {
28
+ offline.style.display = "none";
29
+ try { player.update(JSON.parse(event.data)); } catch (e) { console.error(e); }
30
+ };
31
+ })();
32
+ </script>
33
+ </body>
34
+ </html>