dopairb 0.2.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.
@@ -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
@@ -105,6 +105,11 @@ module Dopairb
105
105
  bits = extra.dup
106
106
  return bits unless event
107
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)
108
113
  bits << "COMBO #{event.combo}" if event.combo.to_i > 1
109
114
  bits << "NEW RECORD" if event.flag?(:new_record) && !bits.include?("NEW RECORD")
110
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
@@ -64,6 +64,7 @@ module Dopairb
64
64
  bits = [event.info[:class_name]]
65
65
  bits << "x#{event.streak} in a row" if event.streak > 1
66
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)
67
68
  bits.join(" ")
68
69
  end
69
70
 
@@ -90,6 +90,8 @@ module Dopairb
90
90
  @panel = [@w / 2 + 4, @table_y, 30, 9] if @two_col
91
91
  @score_y = @h - 7
92
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
93
95
  @stars = Array.new(80) { [@rng.rand(@w), @rng.rand(@h), @rng.rand, 0.4 + @rng.rand] }
94
96
  build_particles
95
97
  end
@@ -144,6 +146,7 @@ module Dopairb
144
146
  draw_table(c, t)
145
147
  draw_score(c, t)
146
148
  draw_rank(c, t)
149
+ draw_xp(c, t)
147
150
  @parts.draw(c, t)
148
151
  @fire.draw(c, t)
149
152
  draw_footer(c, t)
@@ -156,6 +159,13 @@ module Dopairb
156
159
  rows_data.each { |k, v| lines << "#{x}#{k.ljust(12)}#{value_text(k, v).rjust(14)}" }
157
160
  lines << paint("#{x}#{'SCORE'.ljust(12)}#{Fx.number_with_commas(@score).rjust(14)}", [255, 220, 130], bold: true)
158
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
159
169
  lines.join("\n")
160
170
  end
161
171
 
@@ -222,11 +232,16 @@ module Dopairb
222
232
  landed = t >= T.score_land
223
233
  val = landed ? @score : (@score * Fx.ease_in(p) ** 0.7).round
224
234
  txt = Fx.number_with_commas(val)
235
+ best = landed && @new_best
225
236
  unless @big_score
226
- c.put_center(@table_y + T::ROWS + 1, "TOTAL SCORE #{txt}", landed ? Color.rainbow(t) : [150, 220, 255], bold: true)
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)
227
238
  return
228
239
  end
229
- c.put_center(@score_y - 1, "T O T A L S C O R E", [170, 170, 200], bold: true)
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
230
245
  scale = Font.width(Fx.number_with_commas(@score), scale: 2) <= @w - 4 ? 2 : 1
231
246
  scale = 1 if Font.width(txt, scale: scale) > @w - 2
232
247
  fn = lambda do |_rx, ry, f|
@@ -269,6 +284,31 @@ module Dopairb
269
284
  c.put(px + (pw - @title.size) / 2, py + 7, @title[0, n], Color.ramp(@rpal, 0.1), bold: true)
270
285
  end
271
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
+
272
312
  def draw_footer(c, t)
273
313
  msg = "thanks for playing #{Term.glyph('✦', '*')} see you next session"
274
314
  n = (msg.size * Fx.phase(t, T.rank + 0.9, T.rank + 1.5)).round
@@ -7,10 +7,24 @@ 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
11
16
  def sfx = :intro
12
17
  def impact_at = 0.2
13
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
27
+
14
28
  def setup
15
29
  @scale = best_scale(TITLE)
16
30
  @parts = Fx::Particles.new(@rng)
@@ -55,15 +69,19 @@ module Dopairb
55
69
  end
56
70
  @parts.draw(c, t)
57
71
  Fx.sparkles(c, t, 11, 16, palette: Color::GOLD) if t > 0.7
58
- n = (TAG.size * Fx.phase(t, 0.75, 1.1)).round
59
- 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
60
76
  end
61
77
 
62
78
  def trail
63
79
  star = Term.glyph("✦", "*")
64
80
  title = "#{star} DOPA IRB #{VERSION} #{star}"
65
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"
66
- "#{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])}"
67
85
  end
68
86
  end
69
87
 
@@ -23,10 +23,12 @@ module Dopairb
23
23
  c.put(0, 0, "=>", hot, bold: true)
24
24
  if t < 0.16
25
25
  head = 3 + (@target - 3) * Fx.ease_in(t / 0.16)
26
+ fever = event.flag?(:fever)
26
27
  9.times do |i|
27
28
  x = head - i
28
29
  break if x < 3
29
- 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)
30
32
  end
31
33
  else
32
34
  p = Fx.phase(t, 0.16, 0.42)
@@ -34,13 +36,18 @@ module Dopairb
34
36
  txt = "+#{Fx.number_with_commas(event.gain)}"
35
37
  col = Color.mix([255, 255, 255], Color.ramp(pal, 0.3), p)
36
38
  x = c.put(@target + 2, 0, txt, col, bg: p < 0.25 ? Color.ramp(pal, 0.6) : nil, bold: true)
37
- 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
38
45
  end
39
46
  end
40
47
 
41
48
  def trail
42
- paint("=> #{Term.glyph('✦', '*')} +#{Fx.number_with_commas(event.gain)}" + (event.combo > 1 ? " COMBO #{event.combo}" : ""),
43
- 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)
44
51
  end
45
52
  end
46
53