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.
@@ -6,7 +6,13 @@ module Dopairb
6
6
  class InputFx
7
7
  Layout = Struct.new(:cursor_x, :cursor_row, :row_end_x, :last_row, :screen_width, :room_below, keyword_init: true)
8
8
 
9
- STRIP_ROWS = 2
9
+ STRIP_ROWS = 3
10
+ # Typing streaks at powers of two get a big name.
11
+ STREAK_NAMES = {
12
+ 8 => ["8-BIT", "RUSH!"], 16 => ["16-BIT", "COMBO!"], 32 => ["32-BIT", "BLAZE!!"], 64 => ["64-BIT", "OVERDRIVE!!"],
13
+ 128 => ["128-BIT", "HYPER MODE!!"], 256 => ["0x100", "OVERFLOW!!!"], 512 => ["2^9", "GODSPEED!!!"],
14
+ 1024 => ["1KiB", "TYPING LEGEND!!!"],
15
+ }.freeze
10
16
  OPEN = %i[PARENTHESIS_LEFT PARENTHESIS_LEFT_PARENTHESES BRACKET_LEFT BRACKET_LEFT_ARRAY BRACE_LEFT LAMBDA_BEGIN EMBEXPR_BEGIN].freeze
11
17
  CLOSE = %i[PARENTHESIS_RIGHT BRACKET_RIGHT BRACE_RIGHT EMBEXPR_END].freeze
12
18
  PAIRS = { "(" => ")", "[" => "]", "{" => "}", '#{' => "}" }.freeze
@@ -22,11 +28,13 @@ module Dopairb
22
28
 
23
29
  Highlight = Struct.new(:from, :to, :born, :life, :kind)
24
30
  Popup = Struct.new(:text, :x, :born, :life, :palette, :rise, :prio)
31
+ Streak = Struct.new(:n, :x, :born, :life)
25
32
 
26
33
  attr_reader :game
27
34
 
28
- def initialize(config, game, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, rng: Random.new)
35
+ def initialize(config, game, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, rng: Random.new, sound: nil)
29
36
  @config = config
37
+ @sound = sound || ->(name) { Sound.play(name) if @config.sound == :sfx && Sound.fast? }
30
38
  @game = game
31
39
  @clock = clock
32
40
  @rng = rng
@@ -106,6 +114,7 @@ module Dopairb
106
114
  return true if @popups.any? { |p| t - p.born < p.life }
107
115
  return true if @inline && t - @inline.born < @inline.life + 0.1
108
116
  return true if @inserts.any? { |it| t - it < 0.4 }
117
+ return true if @streak && t - @streak.born < @streak.life + 0.1
109
118
  return true if hl_dirty?(t)
110
119
  hud = @config.hud ? hud_key(t) : nil
111
120
  if hud != @last_hud
@@ -125,6 +134,7 @@ module Dopairb
125
134
 
126
135
  def reset_line
127
136
  @inline = nil
137
+ @streak = nil
128
138
  @highlights.clear
129
139
  @inserts.clear
130
140
  @popups.clear
@@ -145,6 +155,11 @@ module Dopairb
145
155
  gauge_on = Color.mix([80, 110, 170], Color.ramp(Color::FIRE, 0.35), heat)
146
156
  c = Canvas.new(Term.str_width(text), 1)
147
157
  c.put(0, 0, text, fg, bg: bg, bold: heat > 0.5)
158
+ if (f = text.index("FEVER!"))
159
+ "FEVER!".each_char.with_index do |ch, i|
160
+ c.put(f + i, 0, ch, [20, 10, 30], bg: Color.rainbow(i / 6.0 - t * 2), bold: true)
161
+ end
162
+ end
148
163
  g = text.index("[")
149
164
  if g
150
165
  cells = 8
@@ -157,7 +172,7 @@ module Dopairb
157
172
  end
158
173
 
159
174
  def hud_key(t)
160
- [hud_text(200, t), (@game.heat(t) * 12).round, (@game.charge / Game::CHARGE_MAX * 8).round]
175
+ [hud_text(200, t), (@game.heat(t) * 12).round, (@game.charge / Game::CHARGE_MAX * 8).round, @game.fever? ? (t * 15).to_i : 0]
161
176
  end
162
177
 
163
178
  def trail_render(lay, t = now)
@@ -203,9 +218,14 @@ module Dopairb
203
218
  return nil unless lively?
204
219
  @popups.reject! { |p| t - p.born > p.life }
205
220
  @parts.prune(t)
206
- return nil if @popups.empty? && @parts.empty?
221
+ @streak = nil if @streak && t - @streak.born > @streak.life
222
+ return nil if @popups.empty? && @parts.empty? && @streak.nil?
207
223
  w = lay.screen_width - 1
208
224
  c = Canvas.new(w, STRIP_ROWS)
225
+ if @streak && draw_streak(c, w, t)
226
+ @parts.draw(c, t)
227
+ return c.render(depth)
228
+ end
209
229
  @parts.draw(c, t)
210
230
  @popups.each do |p|
211
231
  age = (t - p.born) / p.life
@@ -254,9 +274,11 @@ module Dopairb
254
274
  score = Fx.number_with_commas(@game.score)
255
275
  mult = @game.multiplier(t)
256
276
  m = mult > 1.04 ? format(" x%.1f", mult) : ""
257
- full = " COMBO #{format('%02d', @game.combo)} SCORE #{score} [#{' ' * 8}]#{m} "
277
+ fever = @game.fever? ? " FEVER!" : ""
278
+ lv = @game.leveling? ? " LV#{@game.level} " : ""
279
+ full = "#{fever}#{lv} COMBO #{format('%02d', @game.combo)} SCORE #{score} [#{' ' * 8}]#{m} "
258
280
  return full if Term.str_width(full) <= space
259
- short = " C#{format('%02d', @game.combo)} #{score} "
281
+ short = "#{fever} C#{format('%02d', @game.combo)} #{score} "
260
282
  Term.str_width(short) <= space ? short : nil
261
283
  end
262
284
 
@@ -304,16 +326,23 @@ module Dopairb
304
326
  @highlights << Highlight.new(off, off + ch.bytesize, t, 0.18, :flash)
305
327
  end
306
328
  spark(cur_x, t, full? ? 4 : 1)
329
+ sfx(@game.typing_combo >= 20 ? :key_hot : :key)
307
330
  if resumed == :resume
331
+ sfx(:charge)
308
332
  pop("CHARGE!", cur_x - 3, t, Color::FIRE, life: 0.7, rise: true, prio: 3)
309
333
  @parts.burst(cur_x, 0.0, 26, at: t, speed: 34, life: 0.55, palette: Color::FIRE, gravity: 10) if full?
310
334
  elsif recovering
311
335
  pop("RECOVERY", cur_x - 4, t, Color::TOXIC, life: 0.55)
312
336
  end
313
- streak = @game.typing_combo
314
- if [20, 50, 100, 200].include?(streak)
315
- pop("STREAK #{streak}!", cur_x - 5, t, streak >= 100 ? Color::NEON : Color::GOLD, life: 0.9, rise: true, prio: 3)
316
- @parts.burst(cur_x, 0.5, 30, at: t, speed: 40, life: 0.6, palette: Color::GOLD, gravity: 12) if full?
337
+ if Array === resumed && resumed[0] == :streak
338
+ n = resumed[1]
339
+ sfx(:bits)
340
+ if full?
341
+ @streak = Streak.new(n, cur_x, t, 1.3 * @config.duration)
342
+ @parts.burst(cur_x, 1.0, 40, at: t, speed: 46, life: 0.6, palette: n >= 64 ? Color::NEON : Color::GOLD, gravity: 12)
343
+ else
344
+ pop("#{STREAK_NAMES[n]&.join(' ') || "STREAK #{n}"}", cur_x - 5, t, Color::GOLD, life: 0.9, rise: true, prio: 3)
345
+ end
317
346
  end
318
347
  syntax_moment(ch, off, new, cur_x, t)
319
348
  end
@@ -328,6 +357,7 @@ module Dopairb
328
357
  @highlights << Highlight.new(off, off + 1, t, 0.6, :pair)
329
358
  @highlights.reject! { |h| h.kind == :flash }
330
359
  pop("NICE!", cur_x - 3, t, Color::GOLD, life: 0.6, rise: true, prio: 2)
360
+ sfx(:nice)
331
361
  @parts.burst(cur_x - 1, 0.0, 12, at: t, speed: 26, life: 0.4, palette: Color::GOLD, gravity: 8) if full?
332
362
  elsif (range = match_string(tokens, off))
333
363
  @game.key(:string)
@@ -340,6 +370,7 @@ module Dopairb
340
370
  @game.key(:block)
341
371
  @highlights << Highlight.new(start, off + 1, t, 0.55, :range)
342
372
  pop("SEALED!", cur_x - 4, t, Color::NEON, life: 0.7, rise: true, prio: 2)
373
+ sfx(:sealed)
343
374
  @parts.burst(cur_x - 2, 0.0, 16, at: t, speed: 30, life: 0.5, palette: Color::NEON, gravity: 8) if full?
344
375
  end
345
376
  end
@@ -413,6 +444,7 @@ module Dopairb
413
444
  @last_kind = :delete
414
445
  @highlights.clear
415
446
  return unless lively?
447
+ sfx(:delete)
416
448
  glyph = removed.gsub(/\s/, "").chars.last || "."
417
449
  glyph = "." unless Term.char_width(glyph) == 1
418
450
  @parts.add(cur_x, 0.0, (@rng.rand - 0.5) * 6, 3, at: t, life: 0.45, palette: [[255, 255, 255], [255, 120, 120], [120, 40, 40]], glyph: glyph, gravity: 16)
@@ -443,6 +475,7 @@ module Dopairb
443
475
  @highlights.clear
444
476
  @highlights << Highlight.new(0, new.bytesize, t, 0.35, :reveal) if new.size < 4000
445
477
  pop("<< REWIND", cur_x - 10, t, Color::ICE, life: 0.5)
478
+ sfx(:rewind)
446
479
  end
447
480
 
448
481
  def completed(off, added, cur_x, t)
@@ -452,6 +485,7 @@ module Dopairb
452
485
  @highlights.reject! { |h| h.kind == :flash }
453
486
  @highlights << Highlight.new(off, off + added.bytesize, t, 0.5, :lock)
454
487
  @inline = Popup.new("<< LOCK ON", cur_x + 1, t, 0.6 * @config.duration, Color::ICE, false, 2)
488
+ sfx(:lock)
455
489
  @parts.burst(cur_x, 0.2, 10, at: t, speed: 28, life: 0.3, palette: Color::ICE, gravity: 0) if full?
456
490
  end
457
491
 
@@ -463,6 +497,40 @@ module Dopairb
463
497
  pop("PASTE x#{n}", (cur_x || 0) - 6, t, Color::NEON, life: 0.8, rise: true) if lively?
464
498
  end
465
499
 
500
+ # Big "64-BIT OVERDRIVE!!" in the strip. False when it cannot be drawn big.
501
+ def draw_streak(c, w, t)
502
+ s = @streak
503
+ big, tag = STREAK_NAMES[s.n] || [s.n.to_s, "STREAK!"]
504
+ bw = Font.width(big)
505
+ sub = "TYPING x#{s.n} +#{s.n * 4}"
506
+ total = bw + 2 + [tag.size, sub.size].max
507
+ return false if total > w
508
+ age = (t - s.born) / s.life
509
+ x = (s.x - total / 2).clamp(0, w - total)
510
+ slide = ((1 - Fx.ease_out(age / 0.12)) * 12).round
511
+ c.tint_bg([255, 255, 255], 0.6 * (1 - age / 0.1), [x - 1, 0].max, 0, total + 2, STRIP_ROWS) if age < 0.1 && @config.flash != :off
512
+ pal = s.n >= 64 ? Color::NEON : Color::GOLD
513
+ sweep = (age * 3) % 1.4 - 0.2
514
+ drawn = Fx.half_text(c, big, x + slide, 0, lambda { |_cx, _r, f|
515
+ base = s.n >= 128 ? Color.rainbow(f - t * 2) : Color.ramp(pal, 0.1 + f * 0.3)
516
+ (f - sweep).abs < 0.08 ? [255, 255, 255] : base
517
+ })
518
+ return false unless drawn
519
+ tx = x + bw + 2 + slide
520
+ tag.each_char.with_index do |ch, i|
521
+ col = (t * 16 + i).to_i.even? ? [255, 255, 255] : Color.rainbow(i / 10.0 - t)
522
+ c.put(tx + i, 0, ch, age > 0.7 ? Color.ramp(pal, age) : col, bold: true)
523
+ end
524
+ c.put(tx, 2, sub, Color.ramp(pal, 0.2 + age * 0.5), bold: age < 0.6)
525
+ true
526
+ end
527
+
528
+ def sfx(name)
529
+ @sound.call(name)
530
+ rescue StandardError => e
531
+ Dopairb.debug(e)
532
+ end
533
+
466
534
  def spark(cur_x, t, n)
467
535
  return if n <= 0
468
536
  @parts.burst(cur_x - 0.5, -0.3, n, at: t, speed: 14, life: 0.35, palette: Color::FIRE, gravity: 20, angle: Math::PI / 2, arc: Math::PI * 1.2)
@@ -28,6 +28,14 @@ module Dopairb
28
28
  @installed = true
29
29
  end
30
30
 
31
+ # Is the current session one that binding.irb opened?
32
+ def from_binding?
33
+ ctx = IRB.conf[:MAIN_CONTEXT]
34
+ ctx.respond_to?(:from_binding?) && ctx.from_binding? ? true : false
35
+ rescue StandardError
36
+ false
37
+ end
38
+
31
39
  def expression?(statement)
32
40
  defined?(IRB::Statement::Expression) && IRB::Statement::Expression === statement
33
41
  end
@@ -45,8 +45,18 @@ module Dopairb
45
45
  nil
46
46
  end
47
47
 
48
+ # Our own helper processes (sound players) do not count as output.
49
+ def quietly
50
+ prev = Thread.current[:dopairb_quiet]
51
+ Thread.current[:dopairb_quiet] = true
52
+ yield
53
+ ensure
54
+ Thread.current[:dopairb_quiet] = prev
55
+ end
56
+
48
57
  # A child process may write straight to the tty: stop decorating.
49
58
  def spawned!
59
+ return if Thread.current[:dopairb_quiet]
50
60
  @spawned = true if @counting
51
61
  Term::LOCK.synchronize { Term.yield_to_output } if Term.overlay
52
62
  end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "date"
5
+ require "fileutils"
6
+
7
+ module Dopairb
8
+ # What survives between sessions: lifetime XP (= total score), best score,
9
+ # best combo, the day streak and the collected bonus art. One small JSON
10
+ # file under $XDG_STATE_HOME (~/.local/state/dopairb/profile.json), or
11
+ # $DOPAIRB_PROFILE. DOPAIRB_PROFILE=off keeps nothing.
12
+ class Profile
13
+ FIELDS = %i[xp best_score best_combo sessions streak last_day gallery].freeze
14
+
15
+ attr_accessor(*FIELDS)
16
+ attr_reader :path
17
+
18
+ def self.default_path
19
+ env = ENV["DOPAIRB_PROFILE"]
20
+ return nil if env && %w[off 0 none].include?(env.downcase)
21
+ return env if env && !env.empty?
22
+ base = ENV["XDG_STATE_HOME"].to_s.empty? ? File.join(Dir.home, ".local", "state") : ENV["XDG_STATE_HOME"]
23
+ File.join(base, "dopairb", "profile.json")
24
+ rescue ArgumentError # no home directory
25
+ nil
26
+ end
27
+
28
+ def self.load(path = default_path)
29
+ prof = new(path)
30
+ return prof unless path && File.exist?(path)
31
+ data = JSON.parse(File.read(path), symbolize_names: true)
32
+ prof.xp = data[:xp].to_i
33
+ prof.best_score = data[:best_score].to_i
34
+ prof.best_combo = data[:best_combo].to_i
35
+ prof.sessions = data[:sessions].to_i
36
+ prof.streak = data[:streak].to_i
37
+ prof.last_day = data[:last_day]
38
+ prof.gallery = Array(data[:gallery]).map(&:to_sym) & Gallery.ids
39
+ prof
40
+ rescue SystemCallError, JSON::ParserError => e
41
+ Dopairb.debug(e)
42
+ new(path)
43
+ end
44
+
45
+ def initialize(path = nil)
46
+ @path = path
47
+ @xp = 0
48
+ @best_score = 0
49
+ @best_combo = 0
50
+ @sessions = 0
51
+ @streak = 0
52
+ @last_day = nil
53
+ @gallery = []
54
+ end
55
+
56
+ def level = Game.level_for(@xp)
57
+ def persistent? = !@path.nil?
58
+
59
+ # Count today toward the day streak. => true when the streak grew today.
60
+ def visit(today = Date.today)
61
+ day = today.iso8601
62
+ return false if @last_day == day
63
+ yesterday = (today - 1).iso8601
64
+ @streak = @last_day == yesterday ? @streak + 1 : 1
65
+ @last_day = day
66
+ true
67
+ end
68
+
69
+ def collect(id)
70
+ return false if @gallery.include?(id)
71
+ @gallery << id
72
+ true
73
+ end
74
+
75
+ # Fold a finished session in. Another dopairb may have saved meanwhile,
76
+ # so the file is re-read first and only this session's numbers are added.
77
+ def finish(stats, collected: [])
78
+ disk = persistent? ? Profile.load(@path) : self
79
+ disk.xp += stats[:score].to_i
80
+ disk.best_score = [disk.best_score, stats[:score].to_i].max
81
+ disk.best_combo = [disk.best_combo, stats[:max_combo].to_i].max
82
+ disk.sessions += 1
83
+ if @last_day.to_s > disk.last_day.to_s
84
+ disk.last_day = @last_day
85
+ disk.streak = @streak
86
+ end
87
+ collected.each { |id| disk.collect(id) }
88
+ disk.save
89
+ disk
90
+ end
91
+
92
+ def to_h = FIELDS.to_h { |k| [k, public_send(k)] }
93
+
94
+ def save
95
+ return false unless @path
96
+ FileUtils.mkdir_p(File.dirname(@path))
97
+ tmp = "#{@path}.#{Process.pid}.tmp"
98
+ File.write(tmp, JSON.pretty_generate(to_h) + "\n")
99
+ File.rename(tmp, @path)
100
+ true
101
+ rescue SystemCallError => e
102
+ Dopairb.debug(e)
103
+ false
104
+ end
105
+ end
106
+ end
@@ -49,6 +49,7 @@ module Dopairb
49
49
  # ---- called from hooks ------------------------------------------------
50
50
 
51
51
  def before_read(core)
52
+ Dopairb.session&.intro
52
53
  wrap_output_modifier(core)
53
54
  @editor = core.line_editor
54
55
  return unless keys?
data/lib/dopairb/scene.rb CHANGED
@@ -67,15 +67,22 @@ module Dopairb
67
67
  def length = 0.4
68
68
  def top? = false
69
69
  def big? = false
70
+ # Played on the alternate screen (full screen, scrollback untouched).
71
+ def alt? = false
70
72
  def mega? = false
71
73
  def bell? = mega?
72
74
  def trail = nil
73
75
  def shake(_t) = 0
74
76
  def flash_at = nil
77
+ # Sound patch name and when (scene time) its impact should be heard.
78
+ def sfx = nil
79
+ def impact_at = flash_at || 0.0
75
80
 
76
81
  # Wind-up before the main animation (input row glow); 0 when not wanted.
77
82
  def pre = top? ? ctx.pre.to_f : 0.0
78
83
  def total_length = pre + length
84
+ # Scenes that wait on something can end early.
85
+ def done?(_t) = false
79
86
 
80
87
  # Top rows (the input) as canvas, or nil to leave them untouched.
81
88
  def draw_top(c, t)
@@ -98,6 +105,11 @@ module Dopairb
98
105
  bits = extra.dup
99
106
  return bits unless event
100
107
  bits << "+#{Fx.number_with_commas(event.gain)}" if event.gain.to_i > 0
108
+ bits << "JACKPOT x#{Game::JACKPOT_MULT}" if event.flag?(:jackpot)
109
+ bits << "CRITICAL x#{event.mult / (event.flag?(:fever) ? 2 : 1)}" if event.flag?(:critical)
110
+ bits << "FEVER x2" if event.flag?(:fever) && !event.flag?(:fever_start)
111
+ bits << "LEVEL UP LV #{event.level}" if event.flag?(:level_up)
112
+ bits << "ART DROP" if event.flag?(:art_drop) && !event.flag?(:level_up)
101
113
  bits << "COMBO #{event.combo}" if event.combo.to_i > 1
102
114
  bits << "NEW RECORD" if event.flag?(:new_record) && !bits.include?("NEW RECORD")
103
115
  bits << "FIRST HIT" if event.flag?(:first_hit)
@@ -0,0 +1,279 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ module Scenes
5
+ # A random critical hit: a slash, a red flash and the multiplier punching in.
6
+ class Critical < Scene
7
+ def top? = !ctx.input.nil?
8
+ def big? = true
9
+ def height = 3
10
+ def length = 0.7
11
+ def sfx = :crit
12
+ def impact_at = 0.1
13
+ def flash_at = 0.1
14
+
15
+ def setup
16
+ @x = 4
17
+ @parts = Fx::Particles.new(@rng)
18
+ @parts.burst(@x + 6, 1.5, 34, at: 0.1, speed: 42, life: 0.4, palette: Color::BLOOD, gravity: 8)
19
+ @parts.burst(@x + 6, 1.5, 16, at: 0.12, speed: 26, life: 0.35, palette: Color::GOLD, gravity: 6,
20
+ glyphs: ["*", "+", Term.glyph("✦", "*")])
21
+ end
22
+
23
+ def shake(t) = t.between?(0.1, 0.22) ? (@rng.rand(3) - 1) * 2 : 0
24
+
25
+ def draw(c, t)
26
+ if t < 0.1
27
+ p = t / 0.1
28
+ (0..2).each do |y|
29
+ x = (@x - 2 + p * 16 + y * 3).round
30
+ c.put(x, y, "\\", [255, 255, 255], bold: true)
31
+ c.put(x - 1, y, "\\", [255, 120, 120])
32
+ end
33
+ return
34
+ end
35
+ @parts.draw(c, t)
36
+ p = Fx.phase(t, 0.1, 0.7)
37
+ c.tint_bg([255, 40, 40], 0.5 * (1 - Fx.phase(t, 0.1, 0.22)), 0, 0, @w, 3) if ctx.config.flash != :off
38
+ label = "CRITICAL!!"
39
+ label.each_char.with_index do |ch, i|
40
+ flick = ((t * 20).to_i + i).even?
41
+ col = flick ? [255, 70, 60] : [255, 220, 90]
42
+ c.put(@x + i, 1, ch, t < 0.16 ? [255, 255, 255] : col, bold: true)
43
+ end
44
+ mx = @x + label.size + 2
45
+ m = " x#{event.mult} "
46
+ pop = Fx.ease_out_back(Fx.phase(t, 0.12, 0.3))
47
+ bg = Color.mix([255, 255, 255], [200, 30, 30], pop)
48
+ c.put(mx, 1, m, [255, 255, 255], bg: bg, bold: true) if pop > 0.05
49
+ c.put(mx + m.size + 2, 1, "+#{Fx.number_with_commas(event.gain)}", Color.mix([255, 255, 255], [255, 210, 90], p), bold: true)
50
+ sub = sub_bits.reject { |b| b.start_with?("+", "CRITICAL") }.join(" ")
51
+ c.put(@x, 2, sub, [170, 150, 150]) if p > 0.2 && !sub.empty?
52
+ c.put(@x, 0, "=>", [255, 160, 160], bold: true)
53
+ end
54
+
55
+ def trail
56
+ paint("=> #{Term.glyph('✦', '*')} CRITICAL!! x#{event.mult} +#{Fx.number_with_commas(event.gain)}" +
57
+ (event.combo > 1 ? " COMBO #{event.combo}" : ""), [255, 110, 90], bold: true)
58
+ end
59
+ end
60
+
61
+ # The rare jackpot: three slot reels stop on 7-7-7, then coins rain.
62
+ class Jackpot < Scene
63
+ SYMBOLS = %w[7 $ ? ! * #].freeze
64
+ STOPS = [0.45, 0.7, 0.95].freeze
65
+ REEL_W = 9
66
+
67
+ def top? = !ctx.input.nil?
68
+ def big? = true
69
+ def mega? = true
70
+ def length = 2.3
71
+ def sfx = :jackpot
72
+ def impact_at = STOPS.last
73
+ def flash_at = STOPS.last
74
+ def height = @full ? 10 : 2
75
+
76
+ def setup
77
+ @full = !ctx.compact && @w >= REEL_W * 3 + 8
78
+ @x0 = (@w - (REEL_W * 3 + 4)) / 2
79
+ @parts = Fx::Particles.new(@rng)
80
+ @coins = Fx::Particles.new(@rng)
81
+ return unless @full
82
+ STOPS.each_with_index do |at, i|
83
+ @parts.burst(@x0 + i * (REEL_W + 2) + REEL_W / 2.0, 3.5, 14, at: at, speed: 22, life: 0.3, palette: Color::GOLD, gravity: 6)
84
+ end
85
+ @parts.burst(@w / 2.0, 3.5, 90, at: STOPS.last, speed: 70, life: 0.7, palette: Color::GOLD, gravity: 14)
86
+ 70.times do
87
+ @coins.add(@rng.rand * @w, -1, (@rng.rand - 0.5) * 6, 6 + @rng.rand * 8, at: STOPS.last + @rng.rand * 1.1, life: 1.1,
88
+ palette: [[255, 250, 200], [255, 210, 60], [200, 140, 0]],
89
+ glyph: ["$", "o", "O", "$", Term.glyph("✦", "*")][@rng.rand(5)], gravity: 6)
90
+ end
91
+ end
92
+
93
+ def shake(t) = (t - STOPS.last).between?(0, 0.35) ? ((@rng.rand * 2 - 1) * 3 * (1 - (t - STOPS.last) / 0.35)).round : 0
94
+
95
+ def draw(c, t)
96
+ return draw_compact(c, t) unless @full
97
+ c.fill_bg([60, 20, 70], @x0 - 2, 0, REEL_W * 3 + 8, 7)
98
+ lights = (t * 12).to_i
99
+ (REEL_W * 3 + 8).times do |i|
100
+ on = (i + lights).even?
101
+ c.put(@x0 - 2 + i, 0, "o", on ? [255, 230, 120] : [120, 80, 40], bg: [60, 20, 70], bold: on)
102
+ c.put(@x0 - 2 + i, 6, "o", on ? [120, 80, 40] : [255, 230, 120], bg: [60, 20, 70], bold: !on)
103
+ end
104
+ 3.times do |i|
105
+ x = @x0 + i * (REEL_W + 2)
106
+ c.fill_bg([250, 245, 235], x, 1, REEL_W, 5)
107
+ stopped = t >= STOPS[i]
108
+ sym = stopped ? "7" : SYMBOLS[((t * 26).to_i + i * 2) % SYMBOLS.size]
109
+ bounce = stopped && t - STOPS[i] < 0.06 ? 1 : 0
110
+ col = if stopped
111
+ t >= STOPS.last ? Color.rainbow(t * 1.5 + i * 0.2) : [220, 30, 40]
112
+ else
113
+ [140, 140, 160]
114
+ end
115
+ gw = Font.width(sym)
116
+ Fx.big_text(c, sym, x + (REEL_W - gw) / 2, 1 + bounce, ->(*) { col }, bg_fn: ->(*) { [250, 245, 235] })
117
+ c.tint_bg([0, 0, 0], 0.25, x, 1, REEL_W, 5) unless stopped
118
+ end
119
+ @parts.draw(c, t)
120
+ @coins.draw(c, t)
121
+ return if t < STOPS.last
122
+ c.tint_bg([255, 230, 120], 0.6 * (1 - Fx.phase(t, STOPS.last, STOPS.last + 0.15)), 0, 0, @w, height) if ctx.config.flash != :off
123
+ label = "J A C K P O T !! x#{event.mult}"
124
+ n = (label.size * Fx.phase(t, STOPS.last, STOPS.last + 0.3)).round
125
+ label[0, n].each_char.with_index do |ch, i|
126
+ c.put((@w - label.size) / 2 + i, 8, ch, Color.rainbow(i / 14.0 - t), bold: true)
127
+ end
128
+ gain = (event.gain * Fx.ease_out(Fx.phase(t, STOPS.last + 0.1, STOPS.last + 0.7))).round
129
+ c.put_center(9, "+#{Fx.number_with_commas(gain)} #{sub_bits.reject { |b| b.start_with?('+', 'JACKPOT') }.join(' ')}",
130
+ [255, 220, 120], bold: true)
131
+ end
132
+
133
+ def draw_compact(c, t)
134
+ 3.times do |i|
135
+ sym = t >= STOPS[i] ? "7" : SYMBOLS[((t * 26).to_i + i) % SYMBOLS.size]
136
+ c.put(1 + i * 4, 0, "[#{sym}]", t >= STOPS[i] ? [255, 80, 80] : [180, 180, 190], bold: true)
137
+ end
138
+ c.put(1, 1, "JACKPOT!! x#{event.mult} +#{Fx.number_with_commas(event.gain)}", Color.rainbow(t), bold: true) if t >= STOPS.last
139
+ end
140
+
141
+ def trail
142
+ s = "#{Term.glyph('✦', '*')} [7][7][7] JACKPOT!! x#{event.mult} +#{Fx.number_with_commas(event.gain)}"
143
+ return s if depth == :none
144
+ s.each_char.with_index.map { |ch, i| "#{Color.sgr(Color.rainbow(i / 20.0), nil, true, depth)}#{ch}" }.join + "\e[0m"
145
+ end
146
+ end
147
+
148
+ # LEVEL UP (or a jackpot prize, or a lucky ART DROP): the title slams in,
149
+ # then a masterpiece is unveiled full screen.
150
+ class Masterpiece < Scene
151
+ LAND = 0.22
152
+ REVEAL = 1.0
153
+ SHOWN = 1.75
154
+
155
+ def self.fits?(rows, cols) = rows >= 16 && cols >= 40
156
+
157
+ def alt? = true
158
+ def big? = true
159
+ def mega? = true
160
+ def height = ctx.rows
161
+ def length = 4.2
162
+ def sfx = :levelup
163
+ def impact_at = LAND
164
+ def flash_at = LAND
165
+
166
+ def setup
167
+ @h = ctx.rows
168
+ @piece = Gallery.find(event.info[:art]) || Gallery::PIECES.first
169
+ @level_up = event.flag?(:level_up)
170
+ @title = if @level_up then "LEVEL UP!"
171
+ elsif event.flag?(:jackpot) then "JACKPOT PRIZE"
172
+ else "ART DROP!"
173
+ end
174
+ @title = "BONUS!" if Font.width(@title) > @w - 2
175
+ @tscale = Font.width(@title, scale: 2) <= @w - 4 ? 2 : 1
176
+ @aw, @ah = Gallery.fit(@piece, @w - 6, @h - 6)
177
+ @ax = (@w - @aw) / 2
178
+ @ay = 2
179
+ @parts = Fx::Particles.new(@rng)
180
+ tw = Font.width(@title, scale: @tscale)
181
+ cy = @h / 2.0 - 1
182
+ 10.times do |i|
183
+ @parts.burst((@w - tw) / 2.0 + tw * i / 9.0, cy + 3, 9, at: LAND, speed: 45, life: 0.6, palette: Color::NEON, gravity: 18,
184
+ angle: -Math::PI / 2, arc: Math::PI * 1.6)
185
+ end
186
+ @parts.burst(@w / 2.0, @ay + @ah / 4.0, 120, at: REVEAL + 0.45, speed: 80, life: 0.8, palette: Color::GOLD, gravity: 10)
187
+ @stars = Array.new(60) { [@rng.rand(@w), @rng.rand(@h), @rng.rand, 0.5 + @rng.rand] }
188
+ end
189
+
190
+ def shake(t)
191
+ [[LAND, 4, 0.35], [REVEAL + 0.45, 2, 0.25]].each do |at, amp, len|
192
+ age = t - at
193
+ return ((@rng.rand * 2 - 1) * amp * (1 - age / len)).round if age >= 0 && age < len
194
+ end
195
+ 0
196
+ end
197
+
198
+ def draw(c, t)
199
+ draw_stars(c, t)
200
+ t < REVEAL ? draw_title(c, t) : draw_art(c, t)
201
+ @parts.draw(c, t)
202
+ end
203
+
204
+ def trail
205
+ star = Term.glyph("✦", "*")
206
+ g = event.info[:gallery]
207
+ head = @level_up ? "LEVEL UP! LV #{event.level}" : @title
208
+ s = "#{star} #{head} #{star} BONUS ART: #{@piece.title} (#{@piece.artist}, #{@piece.year})"
209
+ s << (event.info[:art_new] ? " NEW!" : " ENCORE")
210
+ s << " GALLERY #{g[0]}/#{g[1]}" if g
211
+ paint(s, Color.ramp(Color::NEON, 0.15), bold: true)
212
+ end
213
+
214
+ private
215
+
216
+ def draw_stars(c, t)
217
+ @stars.each do |x, y, ph, sp|
218
+ f = (t * sp + ph) % 1.0
219
+ next if f > 0.5
220
+ k = 1 - (f - 0.25).abs * 4
221
+ c.put(x, y, k > 0.7 ? "+" : ".", Color.mix([40, 30, 70], [220, 200, 255], k))
222
+ end
223
+ end
224
+
225
+ def draw_title(c, t)
226
+ y0 = (@h - Font::HEIGHT) / 2 - 1
227
+ drop = t < LAND ? ((1 - Fx.ease_in(t / LAND)) * (y0 + 6)).round : 0
228
+ sweep = (t * 1.4) % 1.4 - 0.2
229
+ fn = lambda do |_rx, ry, f|
230
+ base = Color.rainbow(f * 0.8 - t + ry * 0.03, 0.8)
231
+ (f - sweep).abs < 0.07 && t >= LAND ? [255, 255, 255] : base
232
+ end
233
+ Fx.big_text(c, @title, (@w - Font.width(@title, scale: @tscale)) / 2, y0 - drop, fn, scale: @tscale)
234
+ return if t < LAND
235
+ c.tint_bg([200, 120, 255], 0.55 * (1 - Fx.phase(t, LAND, LAND + 0.15))) if ctx.config.flash != :off
236
+ Fx.shockwave(c, @w / 2.0, y0 + 2.5, t - LAND, speed: 110, palette: Color::NEON, life: 0.6, rings: 3)
237
+ lv = @level_up ? "LV #{event.level - 1} >> LV #{event.level}" : "A MASTERPIECE APPEARS"
238
+ n = (lv.size * Fx.phase(t, LAND + 0.1, LAND + 0.4)).round
239
+ c.put_center(y0 + Font::HEIGHT + 2, lv[0, n], [255, 230, 140], bold: true)
240
+ msg = "BONUS ART UNLOCKED"
241
+ c.put_center(y0 + Font::HEIGHT + 4, msg, ((t * 8).to_i.even? ? [255, 255, 255] : [255, 200, 90]), bold: true) if t > LAND + 0.45
242
+ end
243
+
244
+ def draw_art(c, t)
245
+ p = Fx.phase(t, REVEAL, REVEAL + 0.45)
246
+ rows = @ah / 2
247
+ # gold frame
248
+ frame = Color.mix([255, 240, 170], [190, 130, 30], (Math.sin(t * 6) + 1) / 2)
249
+ c.fill_bg(frame, @ax - 2, @ay - 1, @aw + 4, rows + 2)
250
+ c.fill_bg([70, 45, 10], @ax - 1, @ay, @aw + 2, rows) if p < 1
251
+ head = rows * p
252
+ sheen = (t - REVEAL - 0.5) * 1.2
253
+ Gallery.draw(c, @piece, @ax, @ay, @aw, @ah, reveal: p, depth: depth) do |top, bot, x, r|
254
+ if p < 1 && (r - head).abs < 1.2
255
+ [Color.mix(top, [255, 255, 255], 0.8), Color.mix(bot, [255, 255, 255], 0.8)]
256
+ elsif sheen.between?(0, 1.5) && ((x.fdiv(@aw) + r.fdiv(rows) * 0.5) - sheen).abs < 0.06
257
+ [Color.mix(top, [255, 255, 255], 0.55), Color.mix(bot, [255, 255, 255], 0.55)]
258
+ else
259
+ [top, bot]
260
+ end
261
+ end
262
+ Fx.sparkles(c, t, 21, 18, palette: Color::GOLD, area: [@ax - 4, @ay - 1, @aw + 8, rows + 2]) if p >= 1
263
+ head = @level_up ? "LEVEL #{event.level}" : @title
264
+ c.put_center(0, "#{Term.glyph('✦', '*')} #{head} #{Term.glyph('✦', '*')} BONUS ART", Color.rainbow(t * 0.7), bold: true)
265
+ return if t < REVEAL + 0.45
266
+ cap = "\"#{@piece.title}\" #{@piece.artist}, #{@piece.year}"
267
+ n = (cap.size * Fx.phase(t, REVEAL + 0.5, REVEAL + 1.0)).round
268
+ c.put_center(@ay + rows + 1, cap[0, n], [240, 230, 210], bold: true)
269
+ g = event.info[:gallery]
270
+ tag = event.info[:art_new] ? "NEW!" : "ENCORE"
271
+ tag += " GALLERY #{g[0]}/#{g[1]}" if g
272
+ if t > REVEAL + 0.9
273
+ col = event.info[:art_new] && (t * 6).to_i.even? ? [255, 90, 90] : [255, 220, 120]
274
+ c.put_center(@ay + rows + 2, tag, col, bold: true)
275
+ end
276
+ end
277
+ end
278
+ end
279
+ end