dopairb 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,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ module Scenes
5
+ # Shared bits for error scenes: red tint on the input, big glitching title.
6
+ class FailureScene < Scene
7
+ def big? = true
8
+ def top? = !ctx.input.nil?
9
+ def pre = 0.0
10
+ def bell? = true
11
+
12
+ def title = "FAILED"
13
+ def palette = Color::BLOOD
14
+
15
+ def setup
16
+ @scale = best_scale(title)
17
+ @scale = 1 if @scale == 2 && Font.width(title, scale: 2) > @w * 0.7
18
+ end
19
+
20
+ def height = @scale ? 7 : 2
21
+ def length = 0.85
22
+ def flash_at = 0.0
23
+
24
+ def shake(t) = t < 0.22 ? ((@rng.rand * 2 - 1) * 3 * (1 - t / 0.22)).round : 0
25
+
26
+ def draw_top(c, t)
27
+ return nil if t > 0.45
28
+ k = 1 - Fx.phase(t, 0.1, 0.45)
29
+ ox = t < 0.2 ? (@rng.rand(5) - 2) : 0
30
+ ctx.input.plain.each_with_index do |r, y|
31
+ c.put(ox, y, r, Color.mix([220, 220, 225], [255, 70, 80], k), bold: k > 0.5)
32
+ end
33
+ mark_top(c, t)
34
+ true
35
+ end
36
+
37
+ def mark_top(_c, _t); end
38
+
39
+ def draw(c, t)
40
+ draw_title(c, t)
41
+ draw_detail(c, t)
42
+ end
43
+
44
+ def draw_title(c, t)
45
+ if ctx.config.flash != :off && t < 0.1
46
+ c.tint_bg([160, 0, 20], 0.6 * (1 - t / 0.1), 0, 0, @w, height)
47
+ end
48
+ if @scale
49
+ glitch = t < 0.35 ? 0.35 * (1 - t / 0.35) : 0.0
50
+ tear = (t * 17).floor
51
+ fn = lambda do |rx, ry, _f|
52
+ flick = ((rx * 3 + ry * 5 + tear) % 11).zero? && t < 0.4
53
+ flick ? [255, 255, 255] : Color.ramp(palette, 0.15 + ry * 0.12)
54
+ end
55
+ x = (@w - Font.width(title, scale: @scale)) / 2
56
+ Fx.big_text(c, title, x, 0, fn, scale: @scale, glitch: glitch, rng: @rng)
57
+ else
58
+ c.put(1, 0, title.chars.join(" "), Color.ramp(palette, 0.2), bold: true)
59
+ end
60
+ end
61
+
62
+ def detail_text
63
+ bits = [event.info[:class_name]]
64
+ bits << "x#{event.streak} in a row" if event.streak > 1
65
+ bits << "combo reset (#{event.info[:broken_combo]})" if event.info[:broken_combo].to_i >= 3
66
+ bits.join(" ")
67
+ end
68
+
69
+ def draw_detail(c, t)
70
+ return if t < 0.15
71
+ txt = detail_text
72
+ n = (txt.size * Fx.phase(t, 0.15, 0.45)).round
73
+ y = @scale ? 6 : 1
74
+ c.put_center(y, txt[0, n], [255, 170, 170], bold: true)
75
+ end
76
+
77
+ def trail
78
+ s = "#{Term.glyph('✖', 'x')} #{title}"
79
+ s << " x#{event.streak}" if event.streak > 1
80
+ paint(s, Color.ramp(palette, 0.25), bold: true)
81
+ end
82
+ end
83
+
84
+ # SyntaxError: the input line cracks at the error position.
85
+ class Crack < FailureScene
86
+ def title = "SYNTAX BREAK"
87
+
88
+ def setup
89
+ super
90
+ @shards = Fx::Particles.new(@rng)
91
+ @pos = ctx.input && event.info[:line] ? ctx.input.locate(event.info[:line] - 1, event.info[:column].to_i) : nil
92
+ @pos ||= ctx.input ? [ctx.input.size - 1, [ctx.input.plain[-1].size, 1].max - 1] : nil
93
+ @path = []
94
+ return unless @pos
95
+ x = @pos[1].to_f
96
+ rows = (ctx.input.size - @pos[0]) + height
97
+ (rows * 4).times do |i|
98
+ x += (@rng.rand - 0.5) * 2.4
99
+ @path << [x, @pos[0] + i / 4.0]
100
+ end
101
+ @shards.burst(@pos[1], 0.3, 24, at: 0.05, speed: 25, life: 0.6, palette: [[255, 255, 255], [255, 80, 80], [90, 20, 30]],
102
+ gravity: 22, glyphs: ["/", "\\", "'", ",", "."])
103
+ end
104
+
105
+ def mark_top(c, t)
106
+ return unless @pos
107
+ row, x = @pos
108
+ c.bg(x, row, t < 0.3 ? [255, 40, 60] : [120, 20, 30])
109
+ draw_crack(c, t, 0)
110
+ end
111
+
112
+ def draw(c, t)
113
+ super
114
+ return unless @pos
115
+ draw_crack(c, t, -ctx.input.size)
116
+ @shards.draw(c, t)
117
+ end
118
+
119
+ def detail_text
120
+ d = event.info[:detail]
121
+ loc = event.info[:line] ? "line #{event.info[:line]}:#{event.info[:column].to_i + 1}" : nil
122
+ [loc, d && d[0, [@w - 20, 20].max], ("x#{event.streak} in a row" if event.streak > 1)].compact.join(" ")
123
+ end
124
+
125
+ private
126
+
127
+ def draw_crack(c, t, yoff)
128
+ n = (@path.size * Fx.phase(t, 0, 0.18)).round
129
+ @path.first(n).each_with_index do |(x, y), i|
130
+ col = Color.mix([255, 255, 255], [255, 60, 70], i.fdiv(@path.size) + Fx.phase(t, 0.2, 0.8))
131
+ c.dot(x * 2, (y + yoff) * 4, col)
132
+ end
133
+ end
134
+ end
135
+
136
+ # NameError: the unknown name is put under a spotlight.
137
+ class Spotlight < FailureScene
138
+ def title = "UNKNOWN SYMBOL"
139
+ def palette = [[255, 250, 220], [255, 210, 90], [230, 140, 30], [140, 60, 20], [50, 20, 10]]
140
+
141
+ def setup
142
+ super
143
+ @hits = ctx.input ? ctx.input.find(event.info[:name]) : []
144
+ end
145
+
146
+ def draw_top(c, t)
147
+ return nil if t > 0.75
148
+ ctx.input.plain.each_with_index { |r, y| c.put(0, y, r, [90, 90, 100]) }
149
+ pulse = 0.5 + 0.5 * Math.sin(t * 30)
150
+ @hits.each do |row, x, wd|
151
+ name = event.info[:name].to_s
152
+ c.put(x, row, name, [20, 10, 0], bg: Color.mix([255, 200, 60], [255, 255, 200], pulse), bold: true) if wd
153
+ end
154
+ true
155
+ end
156
+
157
+ def draw(c, t)
158
+ super
159
+ @hits.first(3).each do |_row, x, wd|
160
+ height.times do |y|
161
+ k = (1 - y.fdiv(height)) * 0.45 * (1 - Fx.phase(t, 0.5, 0.85))
162
+ c.tint_bg([255, 220, 120], k, x - y / 2, y, wd + y, 1)
163
+ end
164
+ end
165
+ end
166
+
167
+ def detail_text
168
+ n = event.info[:name]
169
+ base = n ? "`#{n}` is not defined here" : event.info[:message].to_s[0, 60]
170
+ [base, ("x#{event.streak} in a row" if event.streak > 1)].compact.join(" ")
171
+ end
172
+ end
173
+
174
+ # NoMethodError: the link between receiver and method snaps.
175
+ class Snap < FailureScene
176
+ def title = "NO METHOD"
177
+ def palette = Color::NEON
178
+ def height = 4
179
+ def length = 0.9
180
+
181
+ def setup
182
+ super
183
+ @scale = nil
184
+ @left = " #{event.info[:receiver]} "
185
+ @right = " .#{event.info[:name]} "
186
+ @parts = Fx::Particles.new(@rng)
187
+ @mid = @w / 2.0
188
+ @parts.burst(@mid, 1.5, 40, at: 0.28, speed: 30, life: 0.45, palette: [[255, 255, 255], [255, 230, 120], [255, 90, 200], [60, 20, 80]], gravity: 12)
189
+ @hits = ctx.input ? ctx.input.find(event.info[:name]) : []
190
+ end
191
+
192
+ def draw_top(c, t)
193
+ return nil if t > 0.6
194
+ ctx.input.plain.each_with_index { |r, y| c.put(0, y, r, [120, 120, 135]) }
195
+ @hits.each { |row, x, _| c.put(x, row, event.info[:name].to_s, [255, 255, 255], bg: [150, 40, 170], bold: true) }
196
+ true
197
+ end
198
+
199
+ def draw(c, t)
200
+ gap = t < 0.28 ? 0 : (Fx.ease_out(Fx.phase(t, 0.28, 0.6)) * 8).round
201
+ lx = [(@mid - 14 - gap - @left.size).round, 0].max
202
+ rx = [(@mid + 14 + gap).round, @w - @right.size - 1].min
203
+ c.put(lx, 1, @left, [255, 255, 255], bg: [40, 90, 200], bold: true)
204
+ c.put(rx, 1, @right, [255, 255, 255], bg: [170, 40, 160], bold: true)
205
+ a = lx + @left.size
206
+ b = rx - 1
207
+ if t < 0.28
208
+ reach = a + (b - a) * Fx.ease_out(t / 0.2)
209
+ (a..reach.round).each do |x|
210
+ wob = Math.sin(x * 1.3 + t * 60) * 0.35
211
+ c.dot(x * 2, (1.5 + wob) * 4, Color.mix([150, 220, 255], [255, 255, 255], @rng.rand))
212
+ end
213
+ else
214
+ fall = Fx.phase(t, 0.28, 0.9)
215
+ (a..(@mid - gap / 2.0).round).each { |x| c.dot(x * 2, (1.5 + fall * fall * 2 * ((x - a) / 10.0)) * 4, [120, 170, 220]) }
216
+ ((@mid + gap / 2.0).round..b).each { |x| c.dot(x * 2, (1.5 + fall * fall * 2 * ((b - x) / 10.0)) * 4, [200, 120, 210]) }
217
+ @parts.draw(c, t)
218
+ c.put_center(0, "N O M E T H O D", Color.ramp(palette, 0.15), bold: true)
219
+ txt = detail_text
220
+ c.put_center(3, txt[0, (txt.size * Fx.phase(t, 0.35, 0.65)).round], [230, 190, 255])
221
+ end
222
+ end
223
+
224
+ def detail_text
225
+ [event.info[:message].to_s[0, [@w - 4, 20].max], ("x#{event.streak} in a row" if event.streak > 1)].compact.join(" ")
226
+ end
227
+ end
228
+
229
+ # TypeError / ArgumentError: the two sides collide.
230
+ class Clash < FailureScene
231
+ def title = event.info[:type] == :argument ? "ARGUMENT CLASH" : "TYPE CLASH"
232
+ def palette = Color::FIRE
233
+ def length = 0.95
234
+
235
+ def setup
236
+ super
237
+ @scale = nil
238
+ l, r = event.info[:sides]
239
+ @l = " #{l} "
240
+ @r = " #{r} "
241
+ @parts = Fx::Particles.new(@rng)
242
+ @cx = @w / 2.0
243
+ @parts.burst(@cx, 2, 70, at: 0.3, speed: 60, life: 0.55, palette: Color::FIRE, gravity: 16)
244
+ @parts.burst(@cx, 2, 16, at: 0.32, speed: 35, life: 0.5, palette: Color::SMOKE, gravity: 4, glyphs: ["*", "#", "%", "@"])
245
+ end
246
+
247
+ def height = 5
248
+ def shake(t) = t.between?(0.3, 0.5) ? ((@rng.rand * 2 - 1) * 4 * (1 - Fx.phase(t, 0.3, 0.5))).round : 0
249
+ def flash_at = 0.3
250
+
251
+ def draw_top(c, t) = t < 0.3 ? nil : super
252
+
253
+ def draw(c, t)
254
+ if t < 0.3
255
+ p = Fx.ease_in(t / 0.3)
256
+ lx = (@cx - @l.size) * p
257
+ rx = @w - @r.size - (@w - @r.size - @cx) * p
258
+ c.put(lx, 2, @l, [255, 255, 255], bg: [30, 120, 220], bold: true)
259
+ c.put(rx, 2, @r, [255, 255, 255], bg: [220, 90, 20], bold: true)
260
+ (0..3).each { |k| c.put(lx - k - 1, 2, "-", Color.scale([30, 120, 220], 1 - k / 4.0)) }
261
+ (0..3).each { |k| c.put(rx + @r.size + k, 2, "-", Color.scale([220, 90, 20], 1 - k / 4.0)) }
262
+ else
263
+ p = Fx.phase(t, 0.3, 0.95)
264
+ back = Fx.ease_out(p) * 10
265
+ c.put(@cx - @l.size - back, 2, @l, Color.mix([255, 255, 255], [90, 90, 100], p), bg: Color.mix([30, 120, 220], [20, 30, 50], p))
266
+ c.put(@cx + back, 2, @r, Color.mix([255, 255, 255], [90, 90, 100], p), bg: Color.mix([220, 90, 20], [50, 25, 10], p))
267
+ Fx.shockwave(c, @cx, 2, t - 0.3, speed: 80, palette: Color::FIRE, life: 0.4, rings: 3)
268
+ @parts.draw(c, t)
269
+ c.tint_bg([255, 200, 120], 0.6 * (1 - Fx.phase(t, 0.3, 0.42)), 0, 0, @w, height) if ctx.config.flash != :off
270
+ c.put_center(0, title.chars.join(" "), Color.ramp(palette, 0.2), bold: true) if t > 0.36
271
+ txt = detail_text
272
+ c.put_center(4, txt[0, (txt.size * Fx.phase(t, 0.4, 0.7)).round], [255, 200, 170]) if t > 0.4
273
+ end
274
+ end
275
+
276
+ def detail_text
277
+ [event.info[:message].to_s[0, [@w - 4, 20].max], ("x#{event.streak} in a row" if event.streak > 1)].compact.join(" ")
278
+ end
279
+ end
280
+
281
+ # Ctrl-C: the collected charge scatters and fades.
282
+ class Scatter < Scene
283
+ def height = 2
284
+ def length = 0.65
285
+ def big? = true
286
+
287
+ def setup
288
+ @parts = Fx::Particles.new(@rng)
289
+ n = [(event.info[:charge].to_f / 4).round, 6].max
290
+ n.times do |i|
291
+ @parts.burst(2 + i, 0.5, 4, at: 0.05, speed: 30, life: 0.5, palette: Color::STEEL, gravity: 6)
292
+ end
293
+ end
294
+
295
+ def draw(c, t)
296
+ @parts.draw(c, t)
297
+ word = "INTERRUPTED"
298
+ sp = ((1 - Fx.ease_out(Fx.phase(t, 0, 0.35))) * 3).round + 1
299
+ txt = word.chars.join(" " * sp)
300
+ c.put(2, 1, txt[0, @w - 3], Color.mix([255, 255, 255], [120, 140, 180], Fx.phase(t, 0.1, 0.6)), bold: true)
301
+ end
302
+
303
+ def trail = paint("#{Term.glyph('✖', 'x')} INTERRUPTED", [140, 160, 200], bold: true)
304
+ end
305
+ end
306
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ module Scenes
5
+ # Startup title: letters drop in one by one, each with its own impact.
6
+ class Intro < Scene
7
+ TITLE = "DOPA IRB"
8
+ TAG = "every keystroke counts."
9
+
10
+ def big? = true
11
+
12
+ def setup
13
+ @scale = best_scale(TITLE)
14
+ @parts = Fx::Particles.new(@rng)
15
+ return unless @scale
16
+ @letters = []
17
+ x = (@w - Font.width(TITLE, scale: @scale)) / 2
18
+ TITLE.each_char.with_index do |ch, i|
19
+ lw = Font.width(ch, scale: @scale)
20
+ at = 0.08 + i * 0.07
21
+ @letters << [ch, x, at]
22
+ unless ch == " "
23
+ @parts.burst(x + lw / 2.0, 6, 12, at: at + 0.12, speed: 30, life: 0.45, palette: Color.const_get(%i[FIRE GOLD NEON ICE TOXIC][i % 5]),
24
+ gravity: 20, angle: -Math::PI / 2, arc: Math::PI * 1.4)
25
+ end
26
+ x += lw + @scale
27
+ end
28
+ end
29
+
30
+ def height = @scale ? 9 : 2
31
+ def length = 1.35
32
+
33
+ def shake(t)
34
+ return 0 unless @letters
35
+ @letters.any? { |_, _, at| (t - at - 0.12).between?(0, 0.05) } ? (@rng.rand(3) - 1) : 0
36
+ end
37
+
38
+ def draw(c, t)
39
+ unless @scale
40
+ c.put(1, 0, "D O P A I R B", Color.rainbow(t), bold: true)
41
+ c.put(1, 1, TAG, [200, 200, 210]) if t > 0.4
42
+ return
43
+ end
44
+ @letters.each_with_index do |(ch, x, at), i|
45
+ lt = t - at
46
+ next if lt < 0
47
+ y = lt < 0.12 ? 1 - ((1 - Fx.ease_in(lt / 0.12)) * 7).round : 1
48
+ fn = lambda do |_rx, ry, _f|
49
+ base = Color.rainbow(i / 9.0 - t * 0.6 + ry * 0.02, 0.75)
50
+ lt < 0.2 ? Color.mix([255, 255, 255], base, lt / 0.2) : base
51
+ end
52
+ Fx.big_text(c, ch, x, y, fn, scale: @scale)
53
+ end
54
+ @parts.draw(c, t)
55
+ Fx.sparkles(c, t, 11, 16, palette: Color::GOLD) if t > 0.7
56
+ n = (TAG.size * Fx.phase(t, 0.75, 1.1)).round
57
+ c.put_center(7, TAG[0, n], [230, 230, 240], bold: true) if n > 0
58
+ end
59
+
60
+ def trail
61
+ star = Term.glyph("✦", "*")
62
+ title = "#{star} DOPA IRB #{VERSION} #{star}"
63
+ colored = depth == :none ? title : title.each_char.with_index.map { |ch, i| "#{Color.sgr(Color.rainbow(i / 22.0), nil, true, depth)}#{ch}" }.join + "\e[0m"
64
+ "#{colored} #{paint("Ruby #{RUBY_VERSION} -- type `dopa` for effect settings", [150, 150, 165])}"
65
+ end
66
+ end
67
+
68
+ # Session result screen. Leaves a plain text table behind.
69
+ class Outro < Scene
70
+ def big? = true
71
+
72
+ def initialize(ctx, stats)
73
+ @stats = stats
74
+ super(ctx, nil)
75
+ end
76
+
77
+ def rows_data
78
+ s = @stats
79
+ [
80
+ ["EVALS", s[:evals]],
81
+ ["HITS", s[:successes]],
82
+ ["ERRORS", s[:failures]],
83
+ ["INTERRUPTS", s[:interrupts]],
84
+ ["MAX COMBO", s[:max_combo]],
85
+ ["KEYSTROKES", s[:keystrokes]],
86
+ ["SCORE", s[:score]],
87
+ ]
88
+ end
89
+
90
+ def setup
91
+ @scale = Fx.text_fits?("RESULT", @w) ? 1 : nil
92
+ @parts = Fx::Particles.new(@rng)
93
+ @parts.burst(@w / 2.0, 3, 60, at: 0.25, speed: 50, life: 0.7, palette: Color::GOLD, gravity: 12)
94
+ end
95
+
96
+ def height = (@scale ? 6 : 1) + rows_data.size + 2
97
+ def length = 1.6
98
+ def flash_at = 0.25
99
+
100
+ def draw(c, t)
101
+ y = 0
102
+ if @scale
103
+ fn = ->(_rx, ry, f) { Color.rainbow(f * 0.6 - t * 0.5 + ry * 0.03, 0.7) }
104
+ drop = t < 0.25 ? ((1 - Fx.ease_in(t / 0.25)) * 6).round : 0
105
+ Fx.big_text(c, "RESULT", (@w - Font.width("RESULT")) / 2, 0 - drop, fn)
106
+ @parts.draw(c, t)
107
+ y = 6
108
+ else
109
+ c.put(1, 0, "R E S U L T", Color.rainbow(t), bold: true)
110
+ y = 1
111
+ end
112
+ x = [(@w - 34) / 2, 1].max
113
+ rows_data.each_with_index do |(k, v), i|
114
+ at = 0.3 + i * 0.1
115
+ next if t < at
116
+ p = Fx.phase(t, at, at + 0.35)
117
+ shown = Fx.number_with_commas((v * Fx.ease_out(p)).round)
118
+ c.put(x, y + i, k.ljust(14, "."), [160, 160, 175])
119
+ col = k == "SCORE" ? Color.rainbow(t) : (p < 1 ? [255, 255, 255] : [255, 220, 130])
120
+ c.put(x + 14, y + i, shown.rjust(16), col, bold: true)
121
+ end
122
+ at = 0.3 + rows_data.size * 0.1
123
+ c.put(x, y + rows_data.size, "TIME".ljust(14, "."), [160, 160, 175]) if t > at
124
+ c.put(x + 14, y + rows_data.size, clock(@stats[:time]).rjust(16), [255, 220, 130], bold: true) if t > at
125
+ Fx.sparkles(c, t, 5, 14, palette: Color::GOLD) if t > 0.8
126
+ end
127
+
128
+ def trail
129
+ x = " "
130
+ lines = [paint("#{x}#{Term.glyph('✦', '*')} DOPA IRB RESULT #{Term.glyph('✦', '*')}", [255, 210, 110], bold: true)]
131
+ rows_data.each { |k, v| lines << "#{x}#{k.ljust(12)}#{Fx.number_with_commas(v).rjust(14)}" }
132
+ lines << "#{x}#{'TIME'.ljust(12)}#{clock(@stats[:time]).rjust(14)}"
133
+ lines.join("\n")
134
+ end
135
+
136
+ private
137
+
138
+ def clock(sec)
139
+ sec = sec.to_i
140
+ h, rest = sec.divmod(3600)
141
+ m, s = rest.divmod(60)
142
+ h > 0 ? format("%dh%02dm%02ds", h, m, s) : format("%dm%02ds", m, s)
143
+ end
144
+ end
145
+ end
146
+ end