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.
- checksums.yaml +4 -4
- data/README.md +63 -10
- data/lib/dopairb/canvas.rb +39 -16
- data/lib/dopairb/cli.rb +5 -7
- data/lib/dopairb/config.rb +11 -5
- data/lib/dopairb/demo.rb +22 -4
- data/lib/dopairb/director.rb +75 -12
- data/lib/dopairb/fx.rb +23 -0
- data/lib/dopairb/gallery/impressionism.rb +818 -0
- data/lib/dopairb/gallery/japan.rb +693 -0
- data/lib/dopairb/gallery/modern_ancient.rb +774 -0
- data/lib/dopairb/gallery/renaissance.rb +829 -0
- data/lib/dopairb/gallery/romantic.rb +703 -0
- data/lib/dopairb/gallery.rb +349 -0
- data/lib/dopairb/game.rb +90 -9
- data/lib/dopairb/input_fx.rb +78 -10
- data/lib/dopairb/irb_adapter.rb +8 -0
- data/lib/dopairb/output_tap.rb +10 -0
- data/lib/dopairb/profile.rb +106 -0
- data/lib/dopairb/reline_adapter.rb +1 -0
- data/lib/dopairb/scene.rb +12 -0
- data/lib/dopairb/scenes/bonus.rb +279 -0
- data/lib/dopairb/scenes/failure.rb +7 -0
- data/lib/dopairb/scenes/finale.rb +326 -0
- data/lib/dopairb/scenes/session.rb +115 -3
- data/lib/dopairb/scenes/success.rb +22 -4
- data/lib/dopairb/session.rb +57 -6
- data/lib/dopairb/sound.rb +420 -0
- data/lib/dopairb/term.rb +3 -1
- data/lib/dopairb/tour.rb +89 -0
- data/lib/dopairb/version.rb +1 -1
- data/lib/dopairb.rb +73 -3
- data/spec.md +3 -3
- metadata +12 -1
data/lib/dopairb/input_fx.rb
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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)
|
data/lib/dopairb/irb_adapter.rb
CHANGED
|
@@ -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
|
data/lib/dopairb/output_tap.rb
CHANGED
|
@@ -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
|
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
|