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/sketch.rb
ADDED
|
@@ -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,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>
|