dopairb 0.1.0 → 0.4.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.
@@ -11,6 +11,7 @@ module Dopairb
11
11
 
12
12
  def title = "FAILED"
13
13
  def palette = Color::BLOOD
14
+ def sfx = :error
14
15
 
15
16
  def setup
16
17
  @scale = best_scale(title)
@@ -63,6 +64,7 @@ module Dopairb
63
64
  bits = [event.info[:class_name]]
64
65
  bits << "x#{event.streak} in a row" if event.streak > 1
65
66
  bits << "combo reset (#{event.info[:broken_combo]})" if event.info[:broken_combo].to_i >= 3
67
+ bits << "FEVER over" if event.flag?(:fever_end)
66
68
  bits.join(" ")
67
69
  end
68
70
 
@@ -84,6 +86,7 @@ module Dopairb
84
86
  # SyntaxError: the input line cracks at the error position.
85
87
  class Crack < FailureScene
86
88
  def title = "SYNTAX BREAK"
89
+ def sfx = :crack
87
90
 
88
91
  def setup
89
92
  super
@@ -174,6 +177,8 @@ module Dopairb
174
177
  # NoMethodError: the link between receiver and method snaps.
175
178
  class Snap < FailureScene
176
179
  def title = "NO METHOD"
180
+ def sfx = :snap
181
+ def impact_at = 0.28
177
182
  def palette = Color::NEON
178
183
  def height = 4
179
184
  def length = 0.9
@@ -231,6 +236,7 @@ module Dopairb
231
236
  def title = event.info[:type] == :argument ? "ARGUMENT CLASH" : "TYPE CLASH"
232
237
  def palette = Color::FIRE
233
238
  def length = 0.95
239
+ def sfx = :crack
234
240
 
235
241
  def setup
236
242
  super
@@ -280,6 +286,7 @@ module Dopairb
280
286
 
281
287
  # Ctrl-C: the collected charge scatters and fades.
282
288
  class Scatter < Scene
289
+ def sfx = :interrupt
283
290
  def height = 2
284
291
  def length = 0.65
285
292
  def big? = true
@@ -0,0 +1,326 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ module Scenes
5
+ # Full-screen session result: title drop, stats slam in one by one,
6
+ # drum-rolled total score, a rank stamp and fireworks. The :finale sound
7
+ # is one track built on the same timeline (Finale::Timeline).
8
+ class Finale < Scene
9
+ module Timeline
10
+ DROP = 0.35
11
+ ROW0 = 0.55
12
+ ROW_GAP = 0.17
13
+ SLIDE = 0.08
14
+ COUNT = 0.24
15
+ ROWS = 8
16
+
17
+ module_function
18
+
19
+ def row_start(i) = ROW0 + i * ROW_GAP
20
+ def row_land(i) = row_start(i) + SLIDE + COUNT
21
+ def score_start = row_land(ROWS - 1) + 0.15
22
+ def score_land = score_start + 0.85
23
+ def rank = score_land + 0.3
24
+ def length = rank + 1.9
25
+ end
26
+
27
+ T = Timeline
28
+ NOTES = [1047, 1175, 1319, 1397, 1568, 1760, 1976, 2093].freeze
29
+
30
+ RANKS = [[15_000, "S"], [6_000, "A"], [2_000, "B"], [0, "C"]].freeze
31
+ RANK_PALETTE = { "S" => Color::NEON, "A" => Color::GOLD, "B" => Color::ICE, "C" => Color::TOXIC }.freeze
32
+
33
+ def self.rank_for(stats)
34
+ RANKS.find { |min, _| stats[:score].to_i >= min }[1]
35
+ end
36
+
37
+ # A cheerful title; never a verdict on the code.
38
+ def self.title_for(s)
39
+ if s[:max_combo].to_i >= 20 then "COMBO LEGEND"
40
+ elsif s[:comebacks].to_i >= 3 then "COMEBACK KID"
41
+ elsif s[:max_combo].to_i >= 10 then "COMBO MASTER"
42
+ elsif s[:failures].to_i >= 3 && s[:successes].to_i > s[:failures].to_i then "NEVER GIVE UP"
43
+ elsif s[:keystrokes].to_i >= 2000 then "IRON FINGERS"
44
+ elsif s[:evals].to_i >= 100 then "MARATHONER"
45
+ elsif s[:best_typing].to_i >= 60 then "SPEED TYPER"
46
+ else "WELL PLAYED"
47
+ end
48
+ end
49
+
50
+ def self.fits?(rows, cols) = rows >= 20 && cols >= 61
51
+
52
+ def initialize(ctx, stats)
53
+ @stats = stats
54
+ super(ctx, nil)
55
+ end
56
+
57
+ def alt? = true
58
+ def big? = true
59
+ def mega? = true
60
+ def height = ctx.rows
61
+ def length = T.length
62
+ def sfx = :finale
63
+ def impact_at = 0.0
64
+ def flash_at = T.score_land
65
+
66
+ def rows_data
67
+ s = @stats
68
+ [
69
+ ["EVALS", s[:evals]],
70
+ ["HITS", s[:successes]],
71
+ ["ERRORS", s[:failures]],
72
+ ["COMEBACKS", s[:comebacks].to_i],
73
+ ["MAX COMBO", s[:max_combo]],
74
+ ["KEYSTROKES", s[:keystrokes]],
75
+ ["INTERRUPTS", s[:interrupts]],
76
+ ["TIME", s[:time].to_f],
77
+ ]
78
+ end
79
+
80
+ def setup
81
+ @h = ctx.rows
82
+ @rank = self.class.rank_for(@stats)
83
+ @title = self.class.title_for(@stats)
84
+ @rpal = RANK_PALETTE[@rank]
85
+ @tscale = Font.width("RESULT", scale: 2) <= @w - 4 ? 2 : 1
86
+ @two_col = @w >= 80
87
+ @big_score = @h >= 23
88
+ @table_x = @two_col ? @w / 2 - 38 : (@w - 34) / 2
89
+ @table_y = 8
90
+ @panel = [@w / 2 + 4, @table_y, 30, 9] if @two_col
91
+ @score_y = @h - 7
92
+ @score = @stats[:score].to_i
93
+ @new_best = !@stats[:first_session].nil? && !@stats[:first_session] && @score > @stats[:best_before].to_i
94
+ @xp_y = @big_score ? @h - 2 : @table_y + T::ROWS + 2
95
+ @stars = Array.new(80) { [@rng.rand(@w), @rng.rand(@h), @rng.rand, 0.4 + @rng.rand] }
96
+ build_particles
97
+ end
98
+
99
+ def build_particles
100
+ @parts = Fx::Particles.new(@rng)
101
+ tw = Font.width("RESULT", scale: @tscale)
102
+ @parts.burst(@w / 2.0, 6, 70, at: T::DROP, speed: 60, life: 0.6, palette: Color::GOLD, gravity: 18,
103
+ angle: -Math::PI / 2, arc: Math::PI * 1.5)
104
+ 8.times { |i| @parts.burst((@w - tw) / 2.0 + tw * i / 7.0, 5.5, 5, at: T::DROP, speed: 30, life: 0.45, palette: Color::FIRE, gravity: 20) }
105
+ T::ROWS.times do |i|
106
+ @parts.burst(@table_x + 31, @table_y + i + 0.5, 10, at: T.row_land(i), speed: 22, life: 0.35,
107
+ palette: Color::GOLD, gravity: 8)
108
+ end
109
+ cy = @big_score ? @score_y + 2.5 : @table_y + 4
110
+ @parts.burst(@w / 2.0, cy, 140, at: T.score_land, speed: 90, life: 0.8, palette: Color::GOLD, gravity: 16)
111
+ @parts.burst(@w / 2.0, cy, 50, at: T.score_land + 0.03, speed: 55, life: 0.7, palette: Color::FIRE, gravity: 12,
112
+ glyphs: ["*", "+", Term.glyph("✦", "*"), "'"])
113
+ if @panel
114
+ px, py, pw, ph = @panel
115
+ @parts.burst(px + pw / 2.0, py + ph / 2.0, 60, at: T.rank, speed: 45, life: 0.55,
116
+ palette: [[255, 255, 255], *@rpal[0, 3]], gravity: 14)
117
+ end
118
+ @fire = Fx::Particles.new(@rng)
119
+ pals = [Color::FIRE, Color::ICE, Color::NEON, Color::TOXIC, Color::GOLD]
120
+ 10.times do |i|
121
+ at = T.rank + 0.2 + i * 0.13
122
+ x = 4 + @rng.rand * (@w - 8)
123
+ y = 2 + @rng.rand * (@h * 0.45)
124
+ @fire.add(x, @h.to_f, 0, -(@h - y) / 0.15 * 2, at: at - 0.15, life: 0.15, palette: [[255, 255, 255], [255, 200, 120]])
125
+ @fire.burst(x, y, 55, at: at, speed: 50, life: 0.9, palette: pals[i % pals.size], gravity: 9, drag: 2.2)
126
+ end
127
+ 90.times do
128
+ @fire.add(@rng.rand * @w, -1, (@rng.rand - 0.5) * 5, 3 + @rng.rand * 5, at: T.rank + @rng.rand * 1.4, life: 1.8,
129
+ palette: [Color.rainbow(@rng.rand), Color.rainbow(@rng.rand)],
130
+ glyph: ["*", "+", "'", ",", Term.glyph("✦", "*")][@rng.rand(5)], gravity: 1.2)
131
+ end
132
+ end
133
+
134
+ def shake(t)
135
+ [[T::DROP, 4, 0.3], [T.score_land, 6, 0.4], [T.rank, 4, 0.3]].each do |at, amp, len|
136
+ age = t - at
137
+ return ((@rng.rand * 2 - 1) * amp * (1 - age / len)).round if age >= 0 && age < len
138
+ end
139
+ 0
140
+ end
141
+
142
+ def draw(c, t)
143
+ draw_stars(c, t)
144
+ flash(c, t)
145
+ draw_title(c, t)
146
+ draw_table(c, t)
147
+ draw_score(c, t)
148
+ draw_rank(c, t)
149
+ draw_xp(c, t)
150
+ @parts.draw(c, t)
151
+ @fire.draw(c, t)
152
+ draw_footer(c, t)
153
+ end
154
+
155
+ def trail
156
+ x = " "
157
+ star = Term.glyph("✦", "*")
158
+ lines = [paint("#{x}#{star} DOPA IRB RESULT #{star}", [255, 210, 110], bold: true)]
159
+ rows_data.each { |k, v| lines << "#{x}#{k.ljust(12)}#{value_text(k, v).rjust(14)}" }
160
+ lines << paint("#{x}#{'SCORE'.ljust(12)}#{Fx.number_with_commas(@score).rjust(14)}", [255, 220, 130], bold: true)
161
+ lines << paint("#{x}#{'RANK'.ljust(12)}#{@rank.rjust(14)} #{@title}", Color.ramp(@rpal, 0.2), bold: true)
162
+ if @stats[:level]
163
+ up = @stats[:level].to_i - @stats[:level_before].to_i
164
+ lv = "LV #{@stats[:level]}#{up > 0 ? " (+#{up})" : ''}"
165
+ lines << paint("#{x}#{'LEVEL'.ljust(12)}#{lv.rjust(14)} #{Fx.number_with_commas(@stats[:xp].to_i)} XP", [200, 160, 255], bold: true)
166
+ end
167
+ lines << paint("#{x}#{'GALLERY'.ljust(12)}#{"#{@stats[:gallery]}/#{@stats[:gallery_total]}".rjust(14)}", [240, 220, 180]) if @stats[:gallery].to_i > 0
168
+ lines << paint("#{x}#{star} NEW BEST SCORE! (was #{Fx.number_with_commas(@stats[:best_before].to_i)}) #{star}", [255, 120, 160], bold: true) if @new_best
169
+ lines.join("\n")
170
+ end
171
+
172
+ private
173
+
174
+ def value_text(k, v)
175
+ k == "TIME" ? clock(v) : Fx.number_with_commas(v.to_i)
176
+ end
177
+
178
+ def draw_stars(c, t)
179
+ @stars.each do |x, y, ph, sp|
180
+ f = ((t * sp + ph) % 1.0)
181
+ next if f > 0.5
182
+ k = 1 - (f - 0.25).abs * 4
183
+ c.put(x, y, k > 0.7 ? "+" : ".", Color.mix([40, 40, 70], [200, 200, 255], k))
184
+ end
185
+ end
186
+
187
+ def flash(c, t)
188
+ return if ctx.config.flash == :off
189
+ [[T::DROP, 0.12, [255, 255, 255]], [T.score_land, 0.18, [255, 230, 150]], [T.rank, 0.12, Color.ramp(@rpal, 0.2)]].each do |at, len, col|
190
+ age = t - at
191
+ c.tint_bg(col, 0.55 * (1 - age / len)) if age >= 0 && age < len
192
+ end
193
+ end
194
+
195
+ def draw_title(c, t)
196
+ drop = t < T::DROP ? ((1 - Fx.ease_in(t / T::DROP)) * 8).round : 0
197
+ sweep = (t * 0.9) % 1.4 - 0.2
198
+ fn = lambda do |_rx, ry, f|
199
+ base = Color.rainbow(f * 0.7 - t * 0.5 + ry * 0.03, 0.75)
200
+ d = (f - sweep).abs
201
+ d < 0.06 ? Color.mix([255, 255, 255], base, d / 0.06) : base
202
+ end
203
+ Fx.big_text(c, "RESULT", (@w - Font.width("RESULT", scale: @tscale)) / 2, 1 - drop, fn, scale: @tscale)
204
+ Fx.shockwave(c, @w / 2.0, 3.5, t - T::DROP, speed: 110, palette: Color::GOLD, life: 0.6, rings: 3)
205
+ end
206
+
207
+ def draw_table(c, t)
208
+ rows_data.each_with_index do |(k, v), i|
209
+ st = T.row_start(i)
210
+ next if t < st
211
+ y = @table_y + i
212
+ slide = Fx.ease_out((t - st) / T::SLIDE)
213
+ p = Fx.phase(t, st + T::SLIDE, T.row_land(i))
214
+ landed = t >= T.row_land(i)
215
+ lx = @table_x - ((1 - slide) * 20).round
216
+ vx = @table_x + 14 + ((1 - slide) * 30).round
217
+ age = t - T.row_land(i)
218
+ c.tint_bg([255, 200, 90], 0.45 * (1 - age / 0.25), @table_x - 1, y, 34, 1) if age >= 0 && age < 0.25
219
+ c.put(lx, y, k.ljust(14, "."), [150, 150, 175])
220
+ shown = k == "TIME" ? clock(v * Fx.ease_out(p)) : Fx.number_with_commas((v * Fx.ease_out(p)).round)
221
+ col = if !landed then [255, 255, 255]
222
+ elsif k == "ERRORS" || k == "INTERRUPTS" then [180, 190, 220]
223
+ else Color.ramp(Color::GOLD, 0.2)
224
+ end
225
+ c.put(vx, y, shown.rjust(16), col, bold: true)
226
+ end
227
+ end
228
+
229
+ def draw_score(c, t)
230
+ return if t < T.score_start
231
+ p = Fx.phase(t, T.score_start, T.score_land)
232
+ landed = t >= T.score_land
233
+ val = landed ? @score : (@score * Fx.ease_in(p) ** 0.7).round
234
+ txt = Fx.number_with_commas(val)
235
+ best = landed && @new_best
236
+ unless @big_score
237
+ c.put_center(@table_y + T::ROWS + 1, "TOTAL SCORE #{txt}#{' NEW BEST!' if best}", landed ? Color.rainbow(t) : [150, 220, 255], bold: true)
238
+ return
239
+ end
240
+ if best
241
+ c.put_center(@score_y - 1, "#{Term.glyph('✦', '*')} N E W B E S T ! #{Term.glyph('✦', '*')}", Color.rainbow(t * 1.5), bold: true)
242
+ else
243
+ c.put_center(@score_y - 1, "T O T A L S C O R E", [170, 170, 200], bold: true)
244
+ end
245
+ scale = Font.width(Fx.number_with_commas(@score), scale: 2) <= @w - 4 ? 2 : 1
246
+ scale = 1 if Font.width(txt, scale: scale) > @w - 2
247
+ fn = lambda do |_rx, ry, f|
248
+ if landed
249
+ base = Color.ramp(Color::GOLD, 0.1 + ry * 0.12)
250
+ sweep = ((t - T.score_land) * 1.5) % 1.4 - 0.2
251
+ d = (f - sweep).abs
252
+ d < 0.08 ? Color.mix([255, 255, 255], base, d / 0.08) : base
253
+ else
254
+ Color.ramp(Color::ICE, 0.15 + ry * 0.1)
255
+ end
256
+ end
257
+ jitter = landed ? 0 : @rng.rand(2)
258
+ Fx.big_text(c, txt, (@w - Font.width(txt, scale: scale)) / 2 + jitter, @score_y, fn, scale: scale)
259
+ Fx.shockwave(c, @w / 2.0, @score_y + 2.5, t - T.score_land, speed: 140, palette: Color::GOLD, life: 0.7, rings: 4)
260
+ end
261
+
262
+ def draw_rank(c, t)
263
+ if @panel.nil?
264
+ return if t < T.rank
265
+ c.put_center(@big_score ? @score_y - 3 : @table_y + T::ROWS + 3, "RANK #{@rank} #{@title}", Color.ramp(@rpal, 0.15), bold: true)
266
+ return
267
+ end
268
+ px, py, pw, ph = @panel
269
+ return if t < T.rank - 0.14
270
+ if t < T.rank
271
+ grow = ((1 - Fx.ease_in((t - T.rank + 0.14) / 0.14)) * 8).round
272
+ c.fill_bg(Color.scale(Color.ramp(@rpal, 0.5), 0.35), px - grow, py - grow / 2, pw + grow * 2, ph + grow)
273
+ return
274
+ end
275
+ c.fill_bg(Color.scale(Color.ramp(@rpal, 0.6), 0.45), px, py, pw, ph)
276
+ c.fill_bg([12, 12, 22], px + 1, py + 1, pw - 2, ph - 2)
277
+ c.put(px + (pw - 4) / 2, py + 1, "RANK", [200, 200, 220], bold: true)
278
+ lw = Font.width(@rank, scale: 2)
279
+ fn = lambda do |_rx, ry, f|
280
+ @rank == "S" ? Color.rainbow(f * 0.5 - t + ry * 0.05) : Color.ramp(@rpal, 0.05 + ry * 0.1)
281
+ end
282
+ Fx.big_text(c, @rank, px + (pw - lw) / 2, py + 2, fn, scale: 2)
283
+ n = (@title.size * Fx.phase(t, T.rank + 0.15, T.rank + 0.5)).round
284
+ c.put(px + (pw - @title.size) / 2, py + 7, @title[0, n], Color.ramp(@rpal, 0.1), bold: true)
285
+ end
286
+
287
+ # LV 7 [=========-----] 4,210 / 8,192 XP: fills from the start of the session.
288
+ def draw_xp(c, t)
289
+ return unless @stats[:level] && t >= T.rank + 0.3
290
+ p = Fx.ease_out(Fx.phase(t, T.rank + 0.3, T.rank + 1.2))
291
+ xp = (@stats[:xp_before].to_i + (@stats[:xp].to_i - @stats[:xp_before].to_i) * p).round
292
+ lv = Game.level_for(xp)
293
+ lo = Game.xp_for(lv)
294
+ hi = Game.xp_for(lv + 1)
295
+ bar_w = [[@w - 50, 30].min, 10].max
296
+ fill = ((xp - lo).fdiv(hi - lo) * bar_w).round
297
+ label = "LV #{lv} "
298
+ tail = " #{Fx.number_with_commas(xp)} / #{Fx.number_with_commas(hi)} XP"
299
+ up = lv > @stats[:level_before].to_i
300
+ tail += " LEVEL UP!" if up
301
+ total = label.size + bar_w + tail.size
302
+ x = (@w - total) / 2
303
+ c.put(x, @xp_y, label, [210, 170, 255], bold: true)
304
+ bar_w.times do |i|
305
+ on = i < fill
306
+ col = on ? Color.rainbow(i.fdiv(bar_w) - t) : [60, 50, 80]
307
+ c.put(x + label.size + i, @xp_y, on ? Term.glyph("█", "#") : Term.glyph("░", "."), col)
308
+ end
309
+ c.put(x + label.size + bar_w, @xp_y, tail, up && (t * 8).to_i.even? ? [255, 255, 255] : [210, 170, 255], bold: up)
310
+ end
311
+
312
+ def draw_footer(c, t)
313
+ msg = "thanks for playing #{Term.glyph('✦', '*')} see you next session"
314
+ n = (msg.size * Fx.phase(t, T.rank + 0.9, T.rank + 1.5)).round
315
+ c.put_center(@h - 1, msg[0, n], [170, 170, 200]) if n > 0
316
+ end
317
+
318
+ def clock(sec)
319
+ sec = sec.to_i
320
+ h, rest = sec.divmod(3600)
321
+ m, s = rest.divmod(60)
322
+ h > 0 ? format("%dh%02dm%02ds", h, m, s) : format("%dm%02ds", m, s)
323
+ end
324
+ end
325
+ end
326
+ end
@@ -7,7 +7,23 @@ module Dopairb
7
7
  TITLE = "DOPA IRB"
8
8
  TAG = "every keystroke counts."
9
9
 
10
+ def initialize(ctx, career = nil)
11
+ @career = career
12
+ super(ctx, nil)
13
+ end
14
+
10
15
  def big? = true
16
+ def sfx = :intro
17
+ def impact_at = 0.2
18
+
19
+ # "LV 5 * DAY 3 STREAK!" or nil on a fresh profile.
20
+ def career_line
21
+ return nil unless @career
22
+ bits = []
23
+ bits << "LV #{@career[:level]}" if @career[:level].to_i > 1
24
+ bits << "DAY #{@career[:streak]} STREAK#{'!' if @career[:streak_up]}" if @career[:streak].to_i >= 2
25
+ bits.empty? ? nil : bits.join(" #{Term.glyph('✦', '*')} ")
26
+ end
11
27
 
12
28
  def setup
13
29
  @scale = best_scale(TITLE)
@@ -53,21 +69,26 @@ module Dopairb
53
69
  end
54
70
  @parts.draw(c, t)
55
71
  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
72
+ tag = [career_line, TAG].compact.join(" ")
73
+ tag = TAG if tag.size > @w
74
+ n = (tag.size * Fx.phase(t, 0.75, 1.1)).round
75
+ c.put_center(7, tag[0, n], [230, 230, 240], bold: true) if n > 0
58
76
  end
59
77
 
60
78
  def trail
61
79
  star = Term.glyph("✦", "*")
62
80
  title = "#{star} DOPA IRB #{VERSION} #{star}"
63
81
  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])}"
82
+ career = career_line
83
+ career = career ? "#{paint(career, [255, 215, 120], bold: true)} " : ""
84
+ "#{colored} #{career}#{paint("Ruby #{RUBY_VERSION} -- type `dopa` for effect settings", [150, 150, 165])}"
65
85
  end
66
86
  end
67
87
 
68
88
  # Session result screen. Leaves a plain text table behind.
69
89
  class Outro < Scene
70
90
  def big? = true
91
+ def sfx = :result
71
92
 
72
93
  def initialize(ctx, stats)
73
94
  @stats = stats
@@ -144,3 +165,94 @@ module Dopairb
144
165
  end
145
166
  end
146
167
  end
168
+
169
+ module Dopairb
170
+ module Scenes
171
+ # Shown at startup while the startup sound is being synthesized.
172
+ # A cat runs along a pastel progress bar; LOADING bounces; sparkles pop.
173
+ class Loading < Scene
174
+ PASTEL = [[255, 170, 210], [255, 205, 160], [255, 245, 170], [180, 245, 200], [170, 215, 255], [215, 185, 255]].freeze
175
+ MESSAGES = ["tuning the synthesizer", "polishing sparkles", "charging the combo meter",
176
+ "stacking fireworks", "waking up the cat"].freeze
177
+ MIN = 0.7
178
+ FINISH = 0.45
179
+
180
+ def initialize(ctx, ready)
181
+ @ready = ready
182
+ @ready_at = nil
183
+ super(ctx, nil)
184
+ end
185
+
186
+ def height = 6
187
+ def length = 8.0 # safety net; normally ends via done?
188
+
189
+ def setup
190
+ @parts = Fx::Particles.new(@rng)
191
+ @bar_x = 4
192
+ @bar_w = [[@w - 12, 60].min, 16].max
193
+ end
194
+
195
+ def done?(t)
196
+ if @ready_at.nil? && t >= MIN && @ready.call
197
+ @ready_at = t
198
+ @parts.burst(@bar_x + @bar_w, 3, 40, at: t, speed: 40, life: 0.5, palette: PASTEL, gravity: 10)
199
+ end
200
+ @ready_at && t >= @ready_at + FINISH
201
+ end
202
+
203
+ def progress(t)
204
+ fake = 1 - Math.exp(-t / 0.9) # creeps towards 1, never gets there
205
+ return 0.9 * fake unless @ready_at
206
+ 0.9 * fake + (1 - 0.9 * fake) * Fx.ease_out((t - @ready_at) / 0.2)
207
+ end
208
+
209
+ def pastel(i, t) = Color.ramp(PASTEL, ((i * 0.13 + t * 0.6) % 1.0))
210
+
211
+ def draw(c, t)
212
+ Fx.sparkles(c, t, 21, 18, palette: PASTEL.reverse)
213
+ draw_word(c, t)
214
+ draw_bar(c, t)
215
+ @parts.draw(c, t)
216
+ msg = if @ready_at then "ready! #{Term.glyph('✦', '*')}"
217
+ else "#{MESSAGES[(t / 0.9).to_i % MESSAGES.size]}#{'.' * ((t * 4).to_i % 4)}"
218
+ end
219
+ c.put(@bar_x, 5, msg, @ready_at ? [255, 240, 170] : [200, 190, 230], bold: !@ready_at.nil?)
220
+ end
221
+
222
+ private
223
+
224
+ def draw_word(c, t)
225
+ word = @ready_at ? "READY!" : "LOADING"
226
+ x = @bar_x
227
+ word.each_char.with_index do |ch, i|
228
+ hop = Math.sin(t * 9 - i * 0.7)
229
+ y = hop > 0.55 ? 0 : 1
230
+ c.put(x, y, ch, pastel(i, t), bold: true)
231
+ x += 2
232
+ end
233
+ end
234
+
235
+ def draw_bar(c, t)
236
+ p = progress(t)
237
+ filled = (p * @bar_w).round
238
+ y = 3
239
+ @bar_w.times do |i|
240
+ if i < filled
241
+ shimmer = ((i - t * 40) % 14).abs < 1.5
242
+ c.bg(@bar_x + i, y, shimmer ? [255, 255, 255] : pastel(i / 3, t))
243
+ else
244
+ c.put(@bar_x + i, y, Term.glyph("⣀", "."), [90, 80, 110])
245
+ end
246
+ end
247
+ run = (t * 10).to_i.even?
248
+ cat = @ready_at ? "=^o^=" : (run ? "=^.^=" : "=^-^=")
249
+ bounce = @ready_at && t - @ready_at < 0.25 ? 1 : (run ? 0 : 1)
250
+ cx = @bar_x + [filled - 2, 0].max
251
+ c.put(cx, y - 1 - bounce, cat, [255, 220, 235], bold: true)
252
+ c.put(cx - 2, y - 1, run ? "~" : "'", [255, 190, 220]) if !@ready_at && t % 0.2 < 0.1
253
+ pct = "#{(p * 100).floor}%"
254
+ c.put(@bar_x + @bar_w + 2, y, pct, [255, 240, 200], bold: true)
255
+ end
256
+ end
257
+ end
258
+ end
@@ -6,6 +6,8 @@ module Dopairb
6
6
  class Hit < Scene
7
7
  def top? = !ctx.input.nil?
8
8
  def length = 0.42
9
+ def sfx = :hit
10
+ def impact_at = 0.16
9
11
 
10
12
  def setup
11
13
  @target = [[@w - 30, 34].min, 8].max
@@ -21,10 +23,12 @@ module Dopairb
21
23
  c.put(0, 0, "=>", hot, bold: true)
22
24
  if t < 0.16
23
25
  head = 3 + (@target - 3) * Fx.ease_in(t / 0.16)
26
+ fever = event.flag?(:fever)
24
27
  9.times do |i|
25
28
  x = head - i
26
29
  break if x < 3
27
- c.put(x, 0, i.zero? ? Term.glyph("✦", "*") : (i < 3 ? "=" : "-"), Color.ramp(pal, i / 9.0), bold: i < 2)
30
+ col = fever ? Color.rainbow(i / 9.0 - t * 4) : Color.ramp(pal, i / 9.0)
31
+ c.put(x, 0, i.zero? ? Term.glyph("✦", "*") : (i < 3 ? "=" : "-"), col, bold: i < 2)
28
32
  end
29
33
  else
30
34
  p = Fx.phase(t, 0.16, 0.42)
@@ -32,13 +36,18 @@ module Dopairb
32
36
  txt = "+#{Fx.number_with_commas(event.gain)}"
33
37
  col = Color.mix([255, 255, 255], Color.ramp(pal, 0.3), p)
34
38
  x = c.put(@target + 2, 0, txt, col, bg: p < 0.25 ? Color.ramp(pal, 0.6) : nil, bold: true)
35
- c.put(x + 2, 0, "COMBO #{event.combo}", combo_color(t), bold: event.combo >= 5) if event.combo > 1
39
+ x = c.put(x + 2, 0, "COMBO #{event.combo}", combo_color(t), bold: event.combo >= 5) if event.combo > 1
40
+ if event.flag?(:fever) && x
41
+ " FEVER x2 ".each_char.with_index do |ch, i|
42
+ x = c.put(x + (i.zero? ? 2 : 0), 0, ch, [20, 10, 30], bg: Color.rainbow(i / 10.0 - t * 2), bold: true)
43
+ end
44
+ end
36
45
  end
37
46
  end
38
47
 
39
48
  def trail
40
- paint("=> #{Term.glyph('✦', '*')} +#{Fx.number_with_commas(event.gain)}" + (event.combo > 1 ? " COMBO #{event.combo}" : ""),
41
- combo_color)
49
+ paint("=> #{Term.glyph('✦', '*')} +#{Fx.number_with_commas(event.gain)}" + (event.combo > 1 ? " COMBO #{event.combo}" : "") +
50
+ (event.flag?(:fever) ? " FEVER x2" : ""), combo_color)
42
51
  end
43
52
  end
44
53
 
@@ -46,6 +55,8 @@ module Dopairb
46
55
  class Puff < Scene
47
56
  def top? = !ctx.input.nil?
48
57
  def length = 0.5
58
+ def sfx = :puff
59
+ def impact_at = 0.12
49
60
 
50
61
  def setup
51
62
  @parts = Fx::Particles.new(@rng)
@@ -71,6 +82,7 @@ module Dopairb
71
82
 
72
83
  # true / false: an ink stamp slams down.
73
84
  class Stamp < Scene
85
+ def sfx = :stamp
74
86
  def height = 3
75
87
  def length = 0.6
76
88
  def top? = !ctx.input.nil?
@@ -112,6 +124,7 @@ module Dopairb
112
124
 
113
125
  # Integers that deserve it: count up in giant digits, then a shockwave.
114
126
  class Counter < Scene
127
+ def sfx = :counter
115
128
  def big? = true
116
129
  def top? = !ctx.input.nil?
117
130
  def length = 1.0
@@ -184,6 +197,7 @@ module Dopairb
184
197
 
185
198
  # Long strings: characters pour in while the length counts up.
186
199
  class Stream < Scene
200
+ def sfx = :hit
187
201
  def height = 2
188
202
  def length = 0.75
189
203
  def top? = !ctx.input.nil?
@@ -210,6 +224,7 @@ module Dopairb
210
224
 
211
225
  # Arrays and hashes: one block per element pops into place.
212
226
  class Crate < Scene
227
+ def sfx = :hit
213
228
  def height = 2
214
229
  def length = 0.7
215
230
  def top? = !ctx.input.nil?
@@ -255,6 +270,8 @@ module Dopairb
255
270
 
256
271
  def big? = true
257
272
  def mega? = @mega
273
+ def alt? = @alt && @mega
274
+ def sfx = @mega ? :mega : :banner
258
275
  def top? = !ctx.input.nil? && !@alt
259
276
  def length = @mega ? 1.9 : 1.05
260
277
 
@@ -385,6 +402,7 @@ module Dopairb
385
402
 
386
403
  # Floods of stdout: digital rain behind a counter.
387
404
  class Rain < Scene
405
+ def sfx = :rain
388
406
  def height = 5
389
407
  def length = 0.9
390
408
  def big? = true