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,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ # Bonus art: famous public-domain paintings, painted procedurally at any
5
+ # size (no bundled images). Each painter maps (u, v) in 0..1 to an RGB.
6
+ module Gallery
7
+ Piece = Struct.new(:id, :title, :artist, :year, :aspect, :painter, keyword_init: true)
8
+
9
+ module Paint
10
+ module_function
11
+
12
+ def mix(a, b, t) = Color.mix(a, b, t)
13
+ def smooth(e0, e1, x) = (t = Fx.clamp01((x - e0) / (e1 - e0))) * t * (3 - 2 * t)
14
+ def dist(u, v, x, y, sx = 1.0) = Math.hypot((u - x) * sx, v - y)
15
+
16
+ # Cheap deterministic value noise in 0..1.
17
+ def noise(x, y)
18
+ n = Math.sin(x * 12.9898 + y * 78.233) * 43_758.5453
19
+ n - n.floor
20
+ end
21
+
22
+ def fbm(x, y)
23
+ x0 = x.floor
24
+ y0 = y.floor
25
+ fx = x - x0
26
+ fy = y - y0
27
+ a = noise(x0, y0)
28
+ b = noise(x0 + 1, y0)
29
+ c = noise(x0, y0 + 1)
30
+ d = noise(x0 + 1, y0 + 1)
31
+ sx = fx * fx * (3 - 2 * fx)
32
+ sy = fy * fy * (3 - 2 * fy)
33
+ (a + (b - a) * sx) + ((c + (d - c) * sx) - (a + (b - a) * sx)) * sy
34
+ end
35
+
36
+ # Smooth value noise at any scale (0..1): fbm(u * 8, v * 8) for blotches.
37
+ def vnoise(u, v, scale = 8.0) = fbm(u * scale, v * scale)
38
+
39
+ def hsv(h, s = 1.0, v = 1.0) = Color.hsv(h, s, v)
40
+
41
+ # 0xRRGGBB -> [r, g, b]
42
+ def rgb(hex) = [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255]
43
+
44
+ # Gradient through evenly spaced colors, t in 0..1.
45
+ def ramp(t, *colors)
46
+ t = Fx.clamp01(t)
47
+ return colors.last if t >= 1
48
+ f = t * (colors.size - 1)
49
+ i = f.floor
50
+ mix(colors[i], colors[i + 1], f - i)
51
+ end
52
+
53
+ # < 1 inside the ellipse (0 at the center).
54
+ def ellipse(u, v, cx, cy, rx, ry) = ((u - cx) / rx)**2 + ((v - cy) / ry)**2
55
+
56
+ # Point in polygon; pts = [[u, v], ...].
57
+ def inside?(u, v, pts)
58
+ hit = false
59
+ j = pts.size - 1
60
+ pts.each_with_index do |(xi, yi), i|
61
+ xj, yj = pts[j]
62
+ hit = !hit if (yi > v) != (yj > v) && u < (xj - xi) * (v - yi) / (yj - yi + 1e-12) + xi
63
+ j = i
64
+ end
65
+ hit
66
+ end
67
+
68
+ # Brush-stroke texture in -1..1 along angle (radians), freq strokes per unit.
69
+ def brush(u, v, angle: 0.0, freq: 40.0, wobble: 0.3)
70
+ x = u * Math.cos(angle) + v * Math.sin(angle)
71
+ y = -u * Math.sin(angle) + v * Math.cos(angle)
72
+ Math.sin((y + wobble * Math.sin(x * 9 + y * 3) / freq * 6) * freq * Math::PI)
73
+ end
74
+
75
+ # A color with a little painterly variation.
76
+ def jitter(color, u, v, amount = 18, scale = 30.0)
77
+ k = (vnoise(u, v, scale) - 0.5) * 2 * amount
78
+ color.map { |c| (c + k).round.clamp(0, 255) }
79
+ end
80
+
81
+ # Hokusai, Fine Wind, Clear Morning ("Red Fuji")
82
+ def red_fuji(u, v)
83
+ peak_u = 0.58
84
+ peak_v = 0.17
85
+ # concave slopes
86
+ left = peak_v + (peak_u - u) * 1.05 + (peak_u - u)**2 * -0.5
87
+ right = peak_v + (u - peak_u) * 0.95 + (u - peak_u)**2 * -0.3
88
+ slope = u < peak_u ? left : right
89
+ if v > 0.86
90
+ return mix([20, 60, 50], [10, 30, 30], (v - 0.86) / 0.14) if noise((u * 60).floor, (v * 40).floor) < 0.8
91
+ return [40, 90, 60]
92
+ end
93
+ if v >= slope
94
+ depth = (v - peak_v) / 0.7
95
+ if v > 0.74 + 0.03 * Math.sin(u * 30)
96
+ tree = noise((u * 70).floor, (v * 50).floor) > 0.55
97
+ return tree ? [35, 95, 60] : [25, 70, 50]
98
+ end
99
+ snow = v < peak_v + 0.13 + 0.05 * Math.sin(u * 55) * (v - peak_v) * 8
100
+ return mix([250, 250, 245], [210, 215, 230], depth) if snow && Math.sin(u * 90) > -0.6
101
+ base = mix([150, 40, 20], [205, 75, 35], depth * 1.3)
102
+ streak = Math.sin(u * 140 + v * 10) > 0.75 ? 0.8 : 1.0
103
+ return Color.scale(base, streak)
104
+ end
105
+ sky = mix([40, 80, 160], [175, 205, 230], smooth(0.0, 0.7, v))
106
+ # mackerel clouds: rows of puffs low in the sky
107
+ if v > 0.42
108
+ row = ((v - 0.42) * 22)
109
+ puff = Math.sin(u * 48 + row.floor * 1.7) * 0.5 + 0.5
110
+ frac = row - row.floor
111
+ return mix([255, 255, 250], sky, 0.25) if puff > 0.45 && frac.between?(0.15, 0.7)
112
+ end
113
+ sky
114
+ end
115
+
116
+ # Hokusai, The Great Wave off Kanagawa
117
+ def great_wave(u, v)
118
+ sky = mix([205, 195, 160], [235, 225, 195], v)
119
+ # Mount Fuji, small in the trough
120
+ fu = 0.64
121
+ fv = 0.58
122
+ if v > fv && v < 0.70 && (u - fu).abs < (v - fv) * 1.6
123
+ return v < fv + 0.035 ? [245, 245, 240] : [70, 80, 110]
124
+ end
125
+ # the big wave: a crescent between two circles, curling to the right
126
+ ox = (u - 0.36) * 1.2
127
+ oy = v - 0.62
128
+ outer = Math.hypot(ox, oy)
129
+ inner = Math.hypot((u - 0.56) * 1.2, v - 0.66)
130
+ if outer < 0.50 && inner > 0.30 && v < 0.80 && u < 0.78
131
+ edge = 0.50 - outer
132
+ ang = Math.atan2(oy, ox)
133
+ claws = Math.sin(ang * 50) > 0.1
134
+ return [250, 250, 245] if edge < 0.05 && (claws || edge < 0.02)
135
+ lip = inner - 0.30
136
+ return [250, 250, 245] if lip < 0.025 && v < 0.5
137
+ stripe = Math.sin(outer * 110 + inner * 40) > 0.6
138
+ base = mix([90, 135, 190], [15, 40, 105], Fx.clamp01(edge * 4))
139
+ return stripe ? mix(base, [235, 240, 245], 0.7) : base
140
+ end
141
+ # foam spray dots falling from the crest
142
+ if dist(u, v, 0.80, 0.22, 1.25) < 0.09 && noise((u * 80).floor, (v * 60).floor) > 0.72
143
+ return [250, 250, 245]
144
+ end
145
+ sea_v = 0.70 + 0.04 * Math.sin(u * 14)
146
+ if v > sea_v
147
+ stripe = Math.sin(v * 160 + Math.sin(u * 25) * 4) > 0.7
148
+ base = mix([40, 80, 140], [20, 45, 100], v)
149
+ # boats
150
+ [[0.30, 0.78], [0.62, 0.86]].each do |bx, by|
151
+ return [215, 195, 140] if ((u - bx) / 0.13)**2 + ((v - by) / 0.025)**2 < 1
152
+ end
153
+ # small wave at right
154
+ return [250, 250, 245] if dist(u, v, 0.86, 0.74, 1.4) < 0.10 && dist(u, v, 0.86, 0.74, 1.4) > 0.08
155
+ return stripe ? [235, 240, 245] : base
156
+ end
157
+ sky
158
+ end
159
+
160
+ # van Gogh, The Starry Night
161
+ def starry_night(u, v)
162
+ # cypress, a dark flame on the left
163
+ cyp = 0.17 + 0.09 * (v - 0.05) * 1.1 * Math.sin(v * 3) + 0.03 * Math.sin(v * 25)
164
+ width = (v - 0.03) * 0.13
165
+ if v > 0.03 && (u - cyp).abs < width
166
+ return Math.sin(v * 70 + u * 30) > 0.3 ? [45, 70, 40] : [20, 30, 25]
167
+ end
168
+ hill = 0.70 + 0.06 * Math.sin(u * 5 + 1) + 0.03 * Math.sin(u * 13)
169
+ if v > hill
170
+ # village: houses with lit windows and a church spire
171
+ return [30, 40, 70] if (u - 0.55).abs < 0.008 && v > hill - 0.18
172
+ if v > hill + 0.07 && v < 0.93
173
+ cell_x = (u * 40).floor
174
+ cell_y = (v * 30).floor
175
+ return [240, 210, 90] if noise(cell_x, cell_y) > 0.82
176
+ return [55, 70, 105] if noise(cell_x + 3, cell_y) > 0.45
177
+ end
178
+ return Math.sin(u * 40 + v * 20) > 0.5 ? [35, 60, 100] : [25, 45, 80]
179
+ end
180
+ # moon
181
+ dm = dist(u, v, 0.87, 0.13, 1.3)
182
+ return [255, 235, 120] if dm < 0.05
183
+ return [240, 200, 60] if dm < 0.075 && Math.sin(dm * 300) > 0
184
+ # stars with halos
185
+ [[0.10, 0.08], [0.30, 0.15], [0.48, 0.07], [0.66, 0.22], [0.78, 0.40], [0.40, 0.45], [0.95, 0.52], [0.22, 0.38]].each do |sx, sy|
186
+ d = dist(u, v, sx, sy, 1.3)
187
+ return [255, 250, 200] if d < 0.015
188
+ return [235, 215, 90] if d < 0.035
189
+ return [170, 190, 210] if d < 0.05 && Math.sin(d * 400) > 0.2
190
+ end
191
+ # the great swirl
192
+ sx = u - 0.48
193
+ sy = (v - 0.30) * 1.6
194
+ r = Math.hypot(sx, sy)
195
+ a = Math.atan2(sy, sx)
196
+ swirl = Math.sin(r * 55 - a * 2 + Math.sin(u * 7) * 2)
197
+ if r < 0.22 && swirl > 0.4
198
+ return mix([230, 225, 170], [120, 160, 210], r / 0.22)
199
+ end
200
+ stroke = Math.sin(v * 60 + Math.sin(u * 9 + v * 4) * 5)
201
+ base = mix([30, 60, 140], [70, 110, 180], smooth(0.0, 0.7, v))
202
+ stroke > 0.6 ? mix(base, [150, 180, 220], 0.45) : base
203
+ end
204
+
205
+ # Leonardo da Vinci, Mona Lisa
206
+ def mona_lisa(u, v)
207
+ # dark frame
208
+ land = mix([150, 160, 120], [90, 100, 60], v)
209
+ land = mix(land, [70, 90, 80], 0.4) if Math.sin(u * 20 + v * 9) > 0.6
210
+ fx = 0.50
211
+ fy = 0.30
212
+ face = ((u - fx) / 0.13)**2 + ((v - fy) / 0.15)**2
213
+ hair = ((u - fx) / 0.20)**2 + ((v - fy - 0.04) / 0.22)**2
214
+ if face < 1
215
+ skin = mix([235, 190, 120], [180, 130, 70], Fx.clamp01((u - fx + 0.13) / 0.35) * 0.6 + face * 0.3)
216
+ return [60, 40, 25] if (v - (fy - 0.02)).abs < 0.012 && ((u - fx).abs - 0.055).abs < 0.025 # eyes
217
+ return [150, 80, 50] if (v - (fy + 0.085 + 0.25 * (u - fx)**2)).abs < 0.006 && (u - fx).abs < 0.045 # the smile
218
+ return mix(skin, [150, 100, 55], 0.4) if (u - fx).abs < 0.008 && v > fy && v < fy + 0.05 # nose
219
+ return skin
220
+ end
221
+ return [50, 35, 20] if hair < 1 && v < 0.55
222
+ # neck and chest
223
+ if v > 0.43 && v < 0.62 && (u - fx).abs < 0.05 + (v - 0.43) * 0.8
224
+ return mix([225, 180, 115], [190, 140, 80], (v - 0.43) * 4)
225
+ end
226
+ # dress
227
+ if v > 0.45 && (u - fx).abs < 0.12 + (v - 0.45) * 1.1
228
+ # crossed hands
229
+ return mix([235, 190, 125], [195, 145, 85], ((u - 0.5).abs) * 4) if ((u - 0.5) / 0.20)**2 + ((v - 0.88) / 0.06)**2 < 1
230
+ return Math.sin(v * 90 + u * 10) > 0.8 ? [95, 70, 30] : [55, 40, 20]
231
+ end
232
+ land
233
+ end
234
+
235
+ # Munch, The Scream
236
+ def the_scream(u, v)
237
+ # the figure
238
+ hx = 0.48
239
+ hy = 0.62
240
+ head = ((u - hx) / 0.08)**2 + ((v - hy) / 0.10)**2
241
+ if head < 1
242
+ return [40, 30, 30] if ((u - hx).abs - 0.03).abs < 0.015 && (v - hy + 0.02).abs < 0.02 # eyes
243
+ return [40, 30, 30] if ((u - hx) / 0.015)**2 + ((v - hy - 0.05) / 0.03)**2 < 1 # mouth
244
+ return [215, 205, 150]
245
+ end
246
+ # hands on cheeks
247
+ return [200, 190, 140] if [[hx - 0.085, hy + 0.01], [hx + 0.085, hy + 0.01]].any? { |x, y| ((u - x) / 0.02)**2 + ((v - y) / 0.06)**2 < 1 }
248
+ body_w = 0.07 + (v - hy) * 0.35 + 0.02 * Math.sin(v * 30)
249
+ return Math.sin(v * 40 + u * 15) > 0.3 ? [45, 50, 70] : [25, 25, 40] if v > hy + 0.08 && (u - hx - (v - hy) * 0.1).abs < body_w
250
+ # the bridge railing, from the left down to the bottom right
251
+ rail = 0.48 + (u * 0.55)
252
+ if v > rail - 0.015 && v < rail + 0.015
253
+ return [130, 70, 40]
254
+ end
255
+ if v > rail
256
+ return Math.sin((u + v) * 70) > 0.4 ? [190, 120, 70] : [160, 95, 55] # deck
257
+ end
258
+ # fjord and shore
259
+ shore = 0.42 + 0.06 * Math.sin(u * 7)
260
+ if v > shore
261
+ return u < 0.5 + 0.1 * Math.sin(v * 10) ? mix([40, 60, 120], [70, 90, 130], Math.sin(v * 60 + u * 12) * 0.5 + 0.5) : mix([80, 100, 60], [140, 110, 60], Math.sin(u * 30 + v * 20) * 0.5 + 0.5)
262
+ end
263
+ # the blood-red sky
264
+ wave = Math.sin(v * 38 + Math.sin(u * 6) * 2.5)
265
+ if wave > 0.4 then [230, 90, 40]
266
+ elsif wave > -0.2 then [245, 160, 60]
267
+ elsif wave > -0.7 then [220, 190, 80]
268
+ else [190, 60, 40]
269
+ end
270
+ end
271
+ end
272
+
273
+ # Filled by Gallery.piece as the files below are loaded.
274
+ PIECES = []
275
+
276
+ module_function
277
+
278
+ # Register a painting. The block gets (u, v) and runs with Paint's helpers.
279
+ # piece :the_kiss, "The Kiss", "Gustav Klimt", "1908", aspect: 1.0 do |u, v| ... end
280
+ def piece(id, title, artist, year, aspect:, &painter)
281
+ raise ArgumentError, "duplicate piece #{id}" if PIECES.any? { |p| p.id == id }
282
+ PIECES << Piece.new(id: id, title: title, artist: artist, year: year, aspect: aspect, painter: painter)
283
+ end
284
+
285
+ piece(:great_wave, "The Great Wave off Kanagawa", "Katsushika Hokusai", "c. 1831", aspect: 1.5) { |u, v| great_wave(u, v) }
286
+ piece(:red_fuji, "Fine Wind, Clear Morning", "Katsushika Hokusai", "c. 1831", aspect: 1.45) { |u, v| red_fuji(u, v) }
287
+ piece(:starry_night, "The Starry Night", "Vincent van Gogh", "1889", aspect: 1.26) { |u, v| starry_night(u, v) }
288
+ piece(:mona_lisa, "Mona Lisa", "Leonardo da Vinci", "c. 1503", aspect: 0.67) { |u, v| mona_lisa(u, v) }
289
+ piece(:the_scream, "The Scream", "Edvard Munch", "1893", aspect: 0.8) { |u, v| the_scream(u, v) }
290
+
291
+ def find(id) = PIECES.find { |p| p.id == id.to_sym }
292
+ def ids = PIECES.map(&:id)
293
+
294
+ # Pixel grid (rows of RGB) of w x h pixels.
295
+ def pixels(piece, w, h)
296
+ @cache ||= {}
297
+ @cache[[piece.id, w, h]] ||= Array.new(h) do |y|
298
+ Array.new(w) { |x| Paint.instance_exec((x + 0.5) / w, (y + 0.5) / h, &piece.painter) }
299
+ end
300
+ end
301
+
302
+ # Largest size that fits in cols x rows of cells (two pixels per cell, vertically).
303
+ # => [pixel width, pixel height]
304
+ def fit(piece, cols, rows)
305
+ h = rows * 2
306
+ w = (h * piece.aspect).round
307
+ if w > cols
308
+ w = cols
309
+ h = (w / piece.aspect).round
310
+ end
311
+ [w, h - h % 2]
312
+ end
313
+
314
+ # Pick a piece for a reward: one not yet collected if possible.
315
+ def pick(rng, owned = [])
316
+ fresh = PIECES.reject { |p| owned.include?(p.id) }
317
+ (fresh.empty? ? PIECES : fresh).sample(random: rng)
318
+ end
319
+
320
+ LUMA = " .:-=+*#%@"
321
+
322
+ # Draw the piece into a canvas at (x, y). reveal: 0..1 rows shown top-down
323
+ # (a curtain); depth :none falls back to luminance ASCII.
324
+ def draw(canvas, piece, x, y, w, h, reveal: 1.0, depth: :truecolor, &tweak)
325
+ px = pixels(piece, w, h)
326
+ rows = h / 2
327
+ shown = (rows * reveal).ceil
328
+ half = Term.glyph("▀", nil)
329
+ rows.times do |r|
330
+ break if r >= shown
331
+ w.times do |c|
332
+ top = px[r * 2][c]
333
+ bot = px[r * 2 + 1][c]
334
+ top, bot = tweak.(top, bot, c, r) if tweak
335
+ if depth == :none
336
+ l = Color.luminance(Color.mix(top, bot, 0.5))
337
+ canvas.put(x + c, y + r, LUMA[(l / 256.0 * LUMA.size).floor.clamp(0, LUMA.size - 1)])
338
+ elsif half
339
+ canvas.put(x + c, y + r, half, top, bg: bot)
340
+ else
341
+ canvas.put(x + c, y + r, " ", nil, bg: Color.mix(top, bot, 0.5))
342
+ end
343
+ end
344
+ end
345
+ end
346
+ end
347
+ end
348
+
349
+ Dir[File.join(__dir__, "gallery", "*.rb")].sort.each { |f| require f }
data/lib/dopairb/game.rb CHANGED
@@ -9,17 +9,38 @@ module Dopairb
9
9
  COMBO_MILESTONES = [3, 5, 10, 15, 20, 30, 50, 75, 100].freeze
10
10
  COMBO_MEGA = [10, 25, 50, 100].freeze
11
11
  EVAL_MILESTONES = [10, 50, 100, 500, 1000, 5000, 10_000].freeze
12
+ # Typing streaks pay out at powers of two: 8, 16, 32, ... 1024.
13
+ TYPING_MILESTONES = (3..10).map { |n| 2**n }.freeze
14
+ FEVER_COMBO = 10
15
+ # Variable rewards: any success may crit (more likely with a full CHARGE).
16
+ CRIT_CHANCE = 0.10
17
+ CRIT_CHARGE_BONUS = 0.12
18
+ CRIT_MULTS = [2, 2, 2, 4, 4, 8].freeze
19
+ JACKPOT_CHANCE = 0.015
20
+ JACKPOT_MULT = 16
21
+ # Bonus art can also just drop (careers only: the gallery lives in the profile).
22
+ ART_DROP_CHANCE = 0.02
12
23
 
13
- Event = Struct.new(:kind, :tier, :info, :flags, :gain, :combo, :streak, :score, keyword_init: true) do
24
+ Event = Struct.new(:kind, :tier, :info, :flags, :gain, :combo, :streak, :score, :mult, :level, keyword_init: true) do
14
25
  def flag?(f) = flags.include?(f)
15
26
  end
16
27
 
17
28
  attr_reader :score, :combo, :max_combo, :evals, :successes, :failures, :interrupts,
18
29
  :charge, :error_streak, :record, :typing_combo, :started_at, :last_key_at,
19
- :keystrokes, :best_typing, :comebacks
30
+ :keystrokes, :best_typing, :comebacks, :crits, :jackpots, :level, :xp_base, :best_combo_ever
20
31
 
21
- def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
32
+ # XP needed for a level doubles each time: LV2 at 512, LV3 at 1024, ...
33
+ def self.xp_for(level) = level <= 1 ? 0 : 2**(level + 7)
34
+
35
+ def self.level_for(xp)
36
+ lv = 1
37
+ lv += 1 while xp >= xp_for(lv + 1)
38
+ lv
39
+ end
40
+
41
+ def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, rng: Random.new)
22
42
  @clock = clock
43
+ @rng = rng
23
44
  @started_at = now
24
45
  @score = 0
25
46
  @combo = 0
@@ -39,8 +60,27 @@ module Dopairb
39
60
  @typing_points = 0
40
61
  @keystrokes = 0
41
62
  @comebacks = 0
63
+ @crits = 0
64
+ @jackpots = 0
65
+ @xp_base = 0
66
+ @level = 1
67
+ @best_combo_ever = nil
68
+ @leveling = false
69
+ @streaks_paid = []
70
+ end
71
+
72
+ # Career so far (from Profile): lifetime XP before this session and the best combo ever.
73
+ def career(xp:, best_combo:)
74
+ @leveling = true
75
+ @xp_base = xp.to_i
76
+ @best_combo_ever = best_combo
77
+ @level = Game.level_for(@xp_base + @score)
42
78
  end
43
79
 
80
+ def xp = @xp_base + @score
81
+ def fever? = @combo >= FEVER_COMBO
82
+ def leveling? = @leveling
83
+
44
84
  def now = @clock.call
45
85
 
46
86
  def multiplier(t = nil)
@@ -56,7 +96,8 @@ module Dopairb
56
96
  end
57
97
 
58
98
  # kind: :insert, :delete, :move, :bracket, :string, :block, :complete, :history, :newline, :paste
59
- # Returns :resume when typing restarts after a pause, else nil.
99
+ # Returns :resume when typing restarts after a pause, [:streak, n] at a
100
+ # power-of-two typing streak, else nil.
60
101
  def key(kind, char = nil)
61
102
  t = now
62
103
  resumed = @last_key_at && t - @last_key_at > 2.0 && kind == :insert
@@ -66,6 +107,7 @@ module Dopairb
66
107
  @typing_combo = kind == :move ? 0 : 1
67
108
  end
68
109
  @best_typing = @typing_combo if @typing_combo > @best_typing
110
+ milestone = kind == :insert && TYPING_MILESTONES.include?(@typing_combo)
69
111
  @last_key_at = t
70
112
  @keystrokes += 1 unless kind == :paste
71
113
 
@@ -99,7 +141,14 @@ module Dopairb
99
141
  @typing_points += p
100
142
  @score += p
101
143
  end
102
- resumed ? :resume : nil
144
+ return :resume if resumed
145
+ return nil unless milestone
146
+ # outside the per-eval typing cap, but each streak pays once per eval
147
+ unless @streaks_paid.include?(@typing_combo)
148
+ @streaks_paid << @typing_combo
149
+ @score += @typing_combo * 4
150
+ end
151
+ [:streak, @typing_combo]
103
152
  end
104
153
 
105
154
  def success(info, code: "", out_lines: 0)
@@ -119,6 +168,11 @@ module Dopairb
119
168
  flags << :combo_mega if COMBO_MEGA.include?(@combo)
120
169
  flags << :eval_milestone if EVAL_MILESTONES.include?(@evals)
121
170
  flags << :output_rain if out_lines >= 20
171
+ flags << :fever if fever?
172
+ flags << :fever_start if @combo == FEVER_COMBO
173
+ if @best_combo_ever && @best_combo_ever >= 5 && @combo == @best_combo_ever + 1
174
+ flags << :combo_best
175
+ end
122
176
 
123
177
  if (num = numeric(info))
124
178
  if @record && num > @record && num >= 100
@@ -134,12 +188,32 @@ module Dopairb
134
188
  gain += 300 if flags.include?(:new_record)
135
189
  gain += 1000 if flags.include?(:eval_milestone)
136
190
  gain += @combo * 20 if flags.include?(:combo_milestone)
191
+ mult = 1
192
+ roll = @rng.rand
193
+ if roll < JACKPOT_CHANCE
194
+ flags << :jackpot
195
+ mult = JACKPOT_MULT
196
+ @jackpots += 1
197
+ elsif roll < CRIT_CHANCE + CRIT_CHARGE_BONUS * @charge / CHARGE_MAX
198
+ flags << :critical
199
+ mult = CRIT_MULTS.sample(random: @rng)
200
+ @crits += 1
201
+ end
202
+ flags << :art_drop if @leveling && @rng.rand < ART_DROP_CHANCE
203
+ mult *= 2 if fever?
204
+ gain *= mult
137
205
  @score += gain
138
206
  @charge = 0.0
139
207
  @typing_points = 0
208
+ @streaks_paid.clear
209
+ lv = @leveling ? Game.level_for(xp) : @level
210
+ if lv > @level
211
+ flags << :level_up
212
+ @level = lv
213
+ end
140
214
 
141
215
  Event.new(kind: info[:type] == :definition ? :definition : :success, tier: nil, info: info, flags: flags,
142
- gain: gain, combo: @combo, streak: streak, score: @score)
216
+ gain: gain, combo: @combo, streak: streak, score: @score, mult: mult, level: @level)
143
217
  end
144
218
 
145
219
  def failure(info)
@@ -150,8 +224,10 @@ module Dopairb
150
224
  @combo = 0
151
225
  @charge = 0.0
152
226
  @typing_points = 0
227
+ @streaks_paid.clear
153
228
  flags = []
154
229
  flags << :combo_break if broken >= 3
230
+ flags << :fever_end if broken >= FEVER_COMBO
155
231
  flags << :eval_milestone if EVAL_MILESTONES.include?(@evals)
156
232
  Event.new(kind: :failure, tier: nil, info: info.merge(broken_combo: broken), flags: flags, gain: 0,
157
233
  combo: 0, streak: @error_streak, score: @score)
@@ -162,6 +238,7 @@ module Dopairb
162
238
  lost = @charge
163
239
  @charge = 0.0
164
240
  @typing_points = 0
241
+ @streaks_paid.clear
165
242
  Event.new(kind: :interrupt, tier: nil, info: { charge: lost }, flags: [], gain: 0, combo: @combo,
166
243
  streak: @error_streak, score: @score)
167
244
  end
@@ -171,7 +248,7 @@ module Dopairb
171
248
  def stats
172
249
  { evals: @evals, successes: @successes, failures: @failures, interrupts: @interrupts,
173
250
  max_combo: @max_combo, score: @score, comebacks: @comebacks, keystrokes: @keystrokes, best_typing: @best_typing,
174
- time: elapsed }
251
+ crits: @crits, jackpots: @jackpots, time: elapsed }
175
252
  end
176
253
 
177
254
  private
@@ -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,6 +28,7 @@ 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
 
@@ -107,6 +114,7 @@ module Dopairb
107
114
  return true if @popups.any? { |p| t - p.born < p.life }
108
115
  return true if @inline && t - @inline.born < @inline.life + 0.1
109
116
  return true if @inserts.any? { |it| t - it < 0.4 }
117
+ return true if @streak && t - @streak.born < @streak.life + 0.1
110
118
  return true if hl_dirty?(t)
111
119
  hud = @config.hud ? hud_key(t) : nil
112
120
  if hud != @last_hud
@@ -126,6 +134,7 @@ module Dopairb
126
134
 
127
135
  def reset_line
128
136
  @inline = nil
137
+ @streak = nil
129
138
  @highlights.clear
130
139
  @inserts.clear
131
140
  @popups.clear
@@ -146,6 +155,11 @@ module Dopairb
146
155
  gauge_on = Color.mix([80, 110, 170], Color.ramp(Color::FIRE, 0.35), heat)
147
156
  c = Canvas.new(Term.str_width(text), 1)
148
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
149
163
  g = text.index("[")
150
164
  if g
151
165
  cells = 8
@@ -158,7 +172,7 @@ module Dopairb
158
172
  end
159
173
 
160
174
  def hud_key(t)
161
- [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]
162
176
  end
163
177
 
164
178
  def trail_render(lay, t = now)
@@ -204,9 +218,14 @@ module Dopairb
204
218
  return nil unless lively?
205
219
  @popups.reject! { |p| t - p.born > p.life }
206
220
  @parts.prune(t)
207
- 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?
208
223
  w = lay.screen_width - 1
209
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
210
229
  @parts.draw(c, t)
211
230
  @popups.each do |p|
212
231
  age = (t - p.born) / p.life
@@ -255,9 +274,11 @@ module Dopairb
255
274
  score = Fx.number_with_commas(@game.score)
256
275
  mult = @game.multiplier(t)
257
276
  m = mult > 1.04 ? format(" x%.1f", mult) : ""
258
- 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} "
259
280
  return full if Term.str_width(full) <= space
260
- short = " C#{format('%02d', @game.combo)} #{score} "
281
+ short = "#{fever} C#{format('%02d', @game.combo)} #{score} "
261
282
  Term.str_width(short) <= space ? short : nil
262
283
  end
263
284
 
@@ -313,10 +334,15 @@ module Dopairb
313
334
  elsif recovering
314
335
  pop("RECOVERY", cur_x - 4, t, Color::TOXIC, life: 0.55)
315
336
  end
316
- streak = @game.typing_combo
317
- if [20, 50, 100, 200].include?(streak)
318
- pop("STREAK #{streak}!", cur_x - 5, t, streak >= 100 ? Color::NEON : Color::GOLD, life: 0.9, rise: true, prio: 3)
319
- @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
320
346
  end
321
347
  syntax_moment(ch, off, new, cur_x, t)
322
348
  end
@@ -471,6 +497,34 @@ module Dopairb
471
497
  pop("PASTE x#{n}", (cur_x || 0) - 6, t, Color::NEON, life: 0.8, rise: true) if lively?
472
498
  end
473
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
+
474
528
  def sfx(name)
475
529
  @sound.call(name)
476
530
  rescue StandardError => e
@@ -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