dopairb 0.1.0 → 0.2.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,286 @@
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
+ @stars = Array.new(80) { [@rng.rand(@w), @rng.rand(@h), @rng.rand, 0.4 + @rng.rand] }
94
+ build_particles
95
+ end
96
+
97
+ def build_particles
98
+ @parts = Fx::Particles.new(@rng)
99
+ tw = Font.width("RESULT", scale: @tscale)
100
+ @parts.burst(@w / 2.0, 6, 70, at: T::DROP, speed: 60, life: 0.6, palette: Color::GOLD, gravity: 18,
101
+ angle: -Math::PI / 2, arc: Math::PI * 1.5)
102
+ 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) }
103
+ T::ROWS.times do |i|
104
+ @parts.burst(@table_x + 31, @table_y + i + 0.5, 10, at: T.row_land(i), speed: 22, life: 0.35,
105
+ palette: Color::GOLD, gravity: 8)
106
+ end
107
+ cy = @big_score ? @score_y + 2.5 : @table_y + 4
108
+ @parts.burst(@w / 2.0, cy, 140, at: T.score_land, speed: 90, life: 0.8, palette: Color::GOLD, gravity: 16)
109
+ @parts.burst(@w / 2.0, cy, 50, at: T.score_land + 0.03, speed: 55, life: 0.7, palette: Color::FIRE, gravity: 12,
110
+ glyphs: ["*", "+", Term.glyph("✦", "*"), "'"])
111
+ if @panel
112
+ px, py, pw, ph = @panel
113
+ @parts.burst(px + pw / 2.0, py + ph / 2.0, 60, at: T.rank, speed: 45, life: 0.55,
114
+ palette: [[255, 255, 255], *@rpal[0, 3]], gravity: 14)
115
+ end
116
+ @fire = Fx::Particles.new(@rng)
117
+ pals = [Color::FIRE, Color::ICE, Color::NEON, Color::TOXIC, Color::GOLD]
118
+ 10.times do |i|
119
+ at = T.rank + 0.2 + i * 0.13
120
+ x = 4 + @rng.rand * (@w - 8)
121
+ y = 2 + @rng.rand * (@h * 0.45)
122
+ @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]])
123
+ @fire.burst(x, y, 55, at: at, speed: 50, life: 0.9, palette: pals[i % pals.size], gravity: 9, drag: 2.2)
124
+ end
125
+ 90.times do
126
+ @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,
127
+ palette: [Color.rainbow(@rng.rand), Color.rainbow(@rng.rand)],
128
+ glyph: ["*", "+", "'", ",", Term.glyph("✦", "*")][@rng.rand(5)], gravity: 1.2)
129
+ end
130
+ end
131
+
132
+ def shake(t)
133
+ [[T::DROP, 4, 0.3], [T.score_land, 6, 0.4], [T.rank, 4, 0.3]].each do |at, amp, len|
134
+ age = t - at
135
+ return ((@rng.rand * 2 - 1) * amp * (1 - age / len)).round if age >= 0 && age < len
136
+ end
137
+ 0
138
+ end
139
+
140
+ def draw(c, t)
141
+ draw_stars(c, t)
142
+ flash(c, t)
143
+ draw_title(c, t)
144
+ draw_table(c, t)
145
+ draw_score(c, t)
146
+ draw_rank(c, t)
147
+ @parts.draw(c, t)
148
+ @fire.draw(c, t)
149
+ draw_footer(c, t)
150
+ end
151
+
152
+ def trail
153
+ x = " "
154
+ star = Term.glyph("✦", "*")
155
+ lines = [paint("#{x}#{star} DOPA IRB RESULT #{star}", [255, 210, 110], bold: true)]
156
+ rows_data.each { |k, v| lines << "#{x}#{k.ljust(12)}#{value_text(k, v).rjust(14)}" }
157
+ lines << paint("#{x}#{'SCORE'.ljust(12)}#{Fx.number_with_commas(@score).rjust(14)}", [255, 220, 130], bold: true)
158
+ lines << paint("#{x}#{'RANK'.ljust(12)}#{@rank.rjust(14)} #{@title}", Color.ramp(@rpal, 0.2), bold: true)
159
+ lines.join("\n")
160
+ end
161
+
162
+ private
163
+
164
+ def value_text(k, v)
165
+ k == "TIME" ? clock(v) : Fx.number_with_commas(v.to_i)
166
+ end
167
+
168
+ def draw_stars(c, t)
169
+ @stars.each do |x, y, ph, sp|
170
+ f = ((t * sp + ph) % 1.0)
171
+ next if f > 0.5
172
+ k = 1 - (f - 0.25).abs * 4
173
+ c.put(x, y, k > 0.7 ? "+" : ".", Color.mix([40, 40, 70], [200, 200, 255], k))
174
+ end
175
+ end
176
+
177
+ def flash(c, t)
178
+ return if ctx.config.flash == :off
179
+ [[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|
180
+ age = t - at
181
+ c.tint_bg(col, 0.55 * (1 - age / len)) if age >= 0 && age < len
182
+ end
183
+ end
184
+
185
+ def draw_title(c, t)
186
+ drop = t < T::DROP ? ((1 - Fx.ease_in(t / T::DROP)) * 8).round : 0
187
+ sweep = (t * 0.9) % 1.4 - 0.2
188
+ fn = lambda do |_rx, ry, f|
189
+ base = Color.rainbow(f * 0.7 - t * 0.5 + ry * 0.03, 0.75)
190
+ d = (f - sweep).abs
191
+ d < 0.06 ? Color.mix([255, 255, 255], base, d / 0.06) : base
192
+ end
193
+ Fx.big_text(c, "RESULT", (@w - Font.width("RESULT", scale: @tscale)) / 2, 1 - drop, fn, scale: @tscale)
194
+ Fx.shockwave(c, @w / 2.0, 3.5, t - T::DROP, speed: 110, palette: Color::GOLD, life: 0.6, rings: 3)
195
+ end
196
+
197
+ def draw_table(c, t)
198
+ rows_data.each_with_index do |(k, v), i|
199
+ st = T.row_start(i)
200
+ next if t < st
201
+ y = @table_y + i
202
+ slide = Fx.ease_out((t - st) / T::SLIDE)
203
+ p = Fx.phase(t, st + T::SLIDE, T.row_land(i))
204
+ landed = t >= T.row_land(i)
205
+ lx = @table_x - ((1 - slide) * 20).round
206
+ vx = @table_x + 14 + ((1 - slide) * 30).round
207
+ age = t - T.row_land(i)
208
+ c.tint_bg([255, 200, 90], 0.45 * (1 - age / 0.25), @table_x - 1, y, 34, 1) if age >= 0 && age < 0.25
209
+ c.put(lx, y, k.ljust(14, "."), [150, 150, 175])
210
+ shown = k == "TIME" ? clock(v * Fx.ease_out(p)) : Fx.number_with_commas((v * Fx.ease_out(p)).round)
211
+ col = if !landed then [255, 255, 255]
212
+ elsif k == "ERRORS" || k == "INTERRUPTS" then [180, 190, 220]
213
+ else Color.ramp(Color::GOLD, 0.2)
214
+ end
215
+ c.put(vx, y, shown.rjust(16), col, bold: true)
216
+ end
217
+ end
218
+
219
+ def draw_score(c, t)
220
+ return if t < T.score_start
221
+ p = Fx.phase(t, T.score_start, T.score_land)
222
+ landed = t >= T.score_land
223
+ val = landed ? @score : (@score * Fx.ease_in(p) ** 0.7).round
224
+ txt = Fx.number_with_commas(val)
225
+ unless @big_score
226
+ c.put_center(@table_y + T::ROWS + 1, "TOTAL SCORE #{txt}", landed ? Color.rainbow(t) : [150, 220, 255], bold: true)
227
+ return
228
+ end
229
+ c.put_center(@score_y - 1, "T O T A L S C O R E", [170, 170, 200], bold: true)
230
+ scale = Font.width(Fx.number_with_commas(@score), scale: 2) <= @w - 4 ? 2 : 1
231
+ scale = 1 if Font.width(txt, scale: scale) > @w - 2
232
+ fn = lambda do |_rx, ry, f|
233
+ if landed
234
+ base = Color.ramp(Color::GOLD, 0.1 + ry * 0.12)
235
+ sweep = ((t - T.score_land) * 1.5) % 1.4 - 0.2
236
+ d = (f - sweep).abs
237
+ d < 0.08 ? Color.mix([255, 255, 255], base, d / 0.08) : base
238
+ else
239
+ Color.ramp(Color::ICE, 0.15 + ry * 0.1)
240
+ end
241
+ end
242
+ jitter = landed ? 0 : @rng.rand(2)
243
+ Fx.big_text(c, txt, (@w - Font.width(txt, scale: scale)) / 2 + jitter, @score_y, fn, scale: scale)
244
+ Fx.shockwave(c, @w / 2.0, @score_y + 2.5, t - T.score_land, speed: 140, palette: Color::GOLD, life: 0.7, rings: 4)
245
+ end
246
+
247
+ def draw_rank(c, t)
248
+ if @panel.nil?
249
+ return if t < T.rank
250
+ c.put_center(@big_score ? @score_y - 3 : @table_y + T::ROWS + 3, "RANK #{@rank} #{@title}", Color.ramp(@rpal, 0.15), bold: true)
251
+ return
252
+ end
253
+ px, py, pw, ph = @panel
254
+ return if t < T.rank - 0.14
255
+ if t < T.rank
256
+ grow = ((1 - Fx.ease_in((t - T.rank + 0.14) / 0.14)) * 8).round
257
+ c.fill_bg(Color.scale(Color.ramp(@rpal, 0.5), 0.35), px - grow, py - grow / 2, pw + grow * 2, ph + grow)
258
+ return
259
+ end
260
+ c.fill_bg(Color.scale(Color.ramp(@rpal, 0.6), 0.45), px, py, pw, ph)
261
+ c.fill_bg([12, 12, 22], px + 1, py + 1, pw - 2, ph - 2)
262
+ c.put(px + (pw - 4) / 2, py + 1, "RANK", [200, 200, 220], bold: true)
263
+ lw = Font.width(@rank, scale: 2)
264
+ fn = lambda do |_rx, ry, f|
265
+ @rank == "S" ? Color.rainbow(f * 0.5 - t + ry * 0.05) : Color.ramp(@rpal, 0.05 + ry * 0.1)
266
+ end
267
+ Fx.big_text(c, @rank, px + (pw - lw) / 2, py + 2, fn, scale: 2)
268
+ n = (@title.size * Fx.phase(t, T.rank + 0.15, T.rank + 0.5)).round
269
+ c.put(px + (pw - @title.size) / 2, py + 7, @title[0, n], Color.ramp(@rpal, 0.1), bold: true)
270
+ end
271
+
272
+ def draw_footer(c, t)
273
+ msg = "thanks for playing #{Term.glyph('✦', '*')} see you next session"
274
+ n = (msg.size * Fx.phase(t, T.rank + 0.9, T.rank + 1.5)).round
275
+ c.put_center(@h - 1, msg[0, n], [170, 170, 200]) if n > 0
276
+ end
277
+
278
+ def clock(sec)
279
+ sec = sec.to_i
280
+ h, rest = sec.divmod(3600)
281
+ m, s = rest.divmod(60)
282
+ h > 0 ? format("%dh%02dm%02ds", h, m, s) : format("%dm%02ds", m, s)
283
+ end
284
+ end
285
+ end
286
+ end
@@ -8,6 +8,8 @@ module Dopairb
8
8
  TAG = "every keystroke counts."
9
9
 
10
10
  def big? = true
11
+ def sfx = :intro
12
+ def impact_at = 0.2
11
13
 
12
14
  def setup
13
15
  @scale = best_scale(TITLE)
@@ -68,6 +70,7 @@ module Dopairb
68
70
  # Session result screen. Leaves a plain text table behind.
69
71
  class Outro < Scene
70
72
  def big? = true
73
+ def sfx = :result
71
74
 
72
75
  def initialize(ctx, stats)
73
76
  @stats = stats
@@ -144,3 +147,94 @@ module Dopairb
144
147
  end
145
148
  end
146
149
  end
150
+
151
+ module Dopairb
152
+ module Scenes
153
+ # Shown at startup while the startup sound is being synthesized.
154
+ # A cat runs along a pastel progress bar; LOADING bounces; sparkles pop.
155
+ class Loading < Scene
156
+ PASTEL = [[255, 170, 210], [255, 205, 160], [255, 245, 170], [180, 245, 200], [170, 215, 255], [215, 185, 255]].freeze
157
+ MESSAGES = ["tuning the synthesizer", "polishing sparkles", "charging the combo meter",
158
+ "stacking fireworks", "waking up the cat"].freeze
159
+ MIN = 0.7
160
+ FINISH = 0.45
161
+
162
+ def initialize(ctx, ready)
163
+ @ready = ready
164
+ @ready_at = nil
165
+ super(ctx, nil)
166
+ end
167
+
168
+ def height = 6
169
+ def length = 8.0 # safety net; normally ends via done?
170
+
171
+ def setup
172
+ @parts = Fx::Particles.new(@rng)
173
+ @bar_x = 4
174
+ @bar_w = [[@w - 12, 60].min, 16].max
175
+ end
176
+
177
+ def done?(t)
178
+ if @ready_at.nil? && t >= MIN && @ready.call
179
+ @ready_at = t
180
+ @parts.burst(@bar_x + @bar_w, 3, 40, at: t, speed: 40, life: 0.5, palette: PASTEL, gravity: 10)
181
+ end
182
+ @ready_at && t >= @ready_at + FINISH
183
+ end
184
+
185
+ def progress(t)
186
+ fake = 1 - Math.exp(-t / 0.9) # creeps towards 1, never gets there
187
+ return 0.9 * fake unless @ready_at
188
+ 0.9 * fake + (1 - 0.9 * fake) * Fx.ease_out((t - @ready_at) / 0.2)
189
+ end
190
+
191
+ def pastel(i, t) = Color.ramp(PASTEL, ((i * 0.13 + t * 0.6) % 1.0))
192
+
193
+ def draw(c, t)
194
+ Fx.sparkles(c, t, 21, 18, palette: PASTEL.reverse)
195
+ draw_word(c, t)
196
+ draw_bar(c, t)
197
+ @parts.draw(c, t)
198
+ msg = if @ready_at then "ready! #{Term.glyph('✦', '*')}"
199
+ else "#{MESSAGES[(t / 0.9).to_i % MESSAGES.size]}#{'.' * ((t * 4).to_i % 4)}"
200
+ end
201
+ c.put(@bar_x, 5, msg, @ready_at ? [255, 240, 170] : [200, 190, 230], bold: !@ready_at.nil?)
202
+ end
203
+
204
+ private
205
+
206
+ def draw_word(c, t)
207
+ word = @ready_at ? "READY!" : "LOADING"
208
+ x = @bar_x
209
+ word.each_char.with_index do |ch, i|
210
+ hop = Math.sin(t * 9 - i * 0.7)
211
+ y = hop > 0.55 ? 0 : 1
212
+ c.put(x, y, ch, pastel(i, t), bold: true)
213
+ x += 2
214
+ end
215
+ end
216
+
217
+ def draw_bar(c, t)
218
+ p = progress(t)
219
+ filled = (p * @bar_w).round
220
+ y = 3
221
+ @bar_w.times do |i|
222
+ if i < filled
223
+ shimmer = ((i - t * 40) % 14).abs < 1.5
224
+ c.bg(@bar_x + i, y, shimmer ? [255, 255, 255] : pastel(i / 3, t))
225
+ else
226
+ c.put(@bar_x + i, y, Term.glyph("⣀", "."), [90, 80, 110])
227
+ end
228
+ end
229
+ run = (t * 10).to_i.even?
230
+ cat = @ready_at ? "=^o^=" : (run ? "=^.^=" : "=^-^=")
231
+ bounce = @ready_at && t - @ready_at < 0.25 ? 1 : (run ? 0 : 1)
232
+ cx = @bar_x + [filled - 2, 0].max
233
+ c.put(cx, y - 1 - bounce, cat, [255, 220, 235], bold: true)
234
+ c.put(cx - 2, y - 1, run ? "~" : "'", [255, 190, 220]) if !@ready_at && t % 0.2 < 0.1
235
+ pct = "#{(p * 100).floor}%"
236
+ c.put(@bar_x + @bar_w + 2, y, pct, [255, 240, 200], bold: true)
237
+ end
238
+ end
239
+ end
240
+ 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
@@ -46,6 +48,8 @@ module Dopairb
46
48
  class Puff < Scene
47
49
  def top? = !ctx.input.nil?
48
50
  def length = 0.5
51
+ def sfx = :puff
52
+ def impact_at = 0.12
49
53
 
50
54
  def setup
51
55
  @parts = Fx::Particles.new(@rng)
@@ -71,6 +75,7 @@ module Dopairb
71
75
 
72
76
  # true / false: an ink stamp slams down.
73
77
  class Stamp < Scene
78
+ def sfx = :stamp
74
79
  def height = 3
75
80
  def length = 0.6
76
81
  def top? = !ctx.input.nil?
@@ -112,6 +117,7 @@ module Dopairb
112
117
 
113
118
  # Integers that deserve it: count up in giant digits, then a shockwave.
114
119
  class Counter < Scene
120
+ def sfx = :counter
115
121
  def big? = true
116
122
  def top? = !ctx.input.nil?
117
123
  def length = 1.0
@@ -184,6 +190,7 @@ module Dopairb
184
190
 
185
191
  # Long strings: characters pour in while the length counts up.
186
192
  class Stream < Scene
193
+ def sfx = :hit
187
194
  def height = 2
188
195
  def length = 0.75
189
196
  def top? = !ctx.input.nil?
@@ -210,6 +217,7 @@ module Dopairb
210
217
 
211
218
  # Arrays and hashes: one block per element pops into place.
212
219
  class Crate < Scene
220
+ def sfx = :hit
213
221
  def height = 2
214
222
  def length = 0.7
215
223
  def top? = !ctx.input.nil?
@@ -255,6 +263,8 @@ module Dopairb
255
263
 
256
264
  def big? = true
257
265
  def mega? = @mega
266
+ def alt? = @alt && @mega
267
+ def sfx = @mega ? :mega : :banner
258
268
  def top? = !ctx.input.nil? && !@alt
259
269
  def length = @mega ? 1.9 : 1.05
260
270
 
@@ -385,6 +395,7 @@ module Dopairb
385
395
 
386
396
  # Floods of stdout: digital rain behind a counter.
387
397
  class Rain < Scene
398
+ def sfx = :rain
388
399
  def height = 5
389
400
  def length = 0.9
390
401
  def big? = true
@@ -5,6 +5,7 @@ module Dopairb
5
5
  # cycle to the director.
6
6
  class Session
7
7
  attr_reader :config, :game, :director, :fx
8
+ attr_accessor :cold_start
8
9
 
9
10
  def initialize(config)
10
11
  @config = config
@@ -19,7 +20,13 @@ module Dopairb
19
20
  end
20
21
 
21
22
  def intro
22
- @director.intro if active?
23
+ return unless active?
24
+ Sound.warm_up(@config.duration)
25
+ cold = @cold_start || (Sound.available? && !Sound.ready?(:intro, @config.duration))
26
+ if @config.intro && @config.motion && cold
27
+ @director.loading { Sound.ready?(:intro, @config.duration) }
28
+ end
29
+ @director.intro
23
30
  end
24
31
 
25
32
  def finish