dopairb 0.2.0 → 0.5.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.
@@ -4,35 +4,68 @@ module Dopairb
4
4
  # One interactive session: owns the game state and ties the evaluation
5
5
  # cycle to the director.
6
6
  class Session
7
- attr_reader :config, :game, :director, :fx
7
+ attr_reader :config, :game, :director, :fx, :collected
8
8
  attr_accessor :cold_start
9
+ # Hooked into IRB but not playing: `require "dopairb"` without enable.
10
+ attr_accessor :dormant
9
11
 
10
12
  def initialize(config)
11
13
  @config = config
12
- @game = Game.new
13
- @director = Director.new(config)
14
+ # DOPAIRB_SEED makes the luck (crits, jackpots, sparks) repeatable, e.g. for recordings.
15
+ seed = ENV["DOPAIRB_SEED"]
16
+ @game = seed ? Game.new(rng: Random.new(seed.to_i)) : Game.new
17
+ @director = seed ? Director.new(config, rng: Random.new(seed.to_i + 1)) : Director.new(config)
14
18
  @fx = InputFx.new(config, @game)
15
19
  @finished = false
20
+ @introduced = false
21
+ @profile = nil
22
+ @collected = []
23
+ end
24
+
25
+ # Loaded on first use, and only for interactive sessions.
26
+ def profile
27
+ @profile ||= begin
28
+ prof = active? ? Profile.load : Profile.new
29
+ @start = { xp: prof.xp, level: prof.level, best_score: prof.best_score, sessions: prof.sessions }
30
+ @game.career(xp: prof.xp, best_combo: prof.best_combo.positive? ? prof.best_combo : nil) if prof.persistent?
31
+ prof
32
+ end
16
33
  end
17
34
 
18
35
  def active?
19
- !@config.off? && Term.interactive?
36
+ !@dormant && !@config.off? && Term.interactive? && (@config.binding_irb || !IrbAdapter.from_binding?)
20
37
  end
21
38
 
39
+ # Once per process: from the dopairb command before IRB starts, or for
40
+ # `Dopairb.enable` in .irbrc right before the first prompt -- or, when it
41
+ # starts switched off, before the first prompt after `dopa on`.
22
42
  def intro
23
- return unless active?
43
+ return if @introduced || !active?
44
+ @introduced = true
45
+ prof = profile
46
+ grew = prof.visit
47
+ prof.save if grew
24
48
  Sound.warm_up(@config.duration)
25
49
  cold = @cold_start || (Sound.available? && !Sound.ready?(:intro, @config.duration))
26
50
  if @config.intro && @config.motion && cold
27
51
  @director.loading { Sound.ready?(:intro, @config.duration) }
28
52
  end
29
- @director.intro
53
+ @director.intro(prof.persistent? ? { level: prof.level, streak: prof.streak, streak_up: grew && prof.streak >= 2 } : nil)
30
54
  end
31
55
 
32
56
  def finish
33
57
  return if @finished
34
58
  @finished = true
35
- @director.outro(@game.stats) if active? && @game.evals > 0
59
+ return unless active? && @game.evals > 0
60
+ prof = profile
61
+ stats = @game.stats
62
+ if prof.persistent?
63
+ stats = stats.merge(xp_before: @start[:xp], level_before: @start[:level], level: @game.level, xp: @game.xp,
64
+ best_before: @start[:best_score], first_session: @start[:sessions].zero?)
65
+ end
66
+ prof.visit
67
+ @profile = prof.finish(stats, collected: @collected)
68
+ @director.outro(stats.merge(gallery: @profile.gallery.size, gallery_total: Gallery::PIECES.size))
36
69
  end
37
70
 
38
71
  def around_eval(code, last_value)
@@ -68,7 +101,9 @@ module Dopairb
68
101
  shot = clean ? input : nil
69
102
  if outcome == :success
70
103
  info = Probe.value(last_value.call, code).merge(out_lines: lines)
104
+ profile
71
105
  event = @game.success(info, code: code, out_lines: lines)
106
+ award_art(event) if %i[level_up jackpot art_drop].any? { |f| event.flag?(f) } && @game.leveling?
72
107
  pre = elapsed > 0.2 || shot.nil? ? 0.0 : [@config.charge, 0.3].min
73
108
  @director.play_event(event, input: shot, pre: pre)
74
109
  else
@@ -81,6 +116,17 @@ module Dopairb
81
116
  Dopairb.debug(e)
82
117
  end
83
118
 
119
+ # Level-ups, jackpots and lucky drops pay out a masterpiece, a new one while any are missing.
120
+ def award_art(event)
121
+ owned = profile.gallery + @collected
122
+ piece = Gallery.pick(@director.rng, owned)
123
+ fresh = !owned.include?(piece.id)
124
+ @collected << piece.id if fresh
125
+ event.info[:art] = piece.id
126
+ event.info[:art_new] = fresh
127
+ event.info[:gallery] = [(owned + [piece.id]).uniq.size, Gallery::PIECES.size]
128
+ end
129
+
84
130
  def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
85
131
  end
86
132
  end
data/lib/dopairb/sound.rb CHANGED
@@ -181,6 +181,51 @@ module Dopairb
181
181
  [Synth.seq(sweep, chord), 0.3]
182
182
  },
183
183
  result: -> { [Synth.seq(Synth.arp([784, 988, 1175, 1568], 0.08, wave: :triangle, vol: 0.2, decay: 0.06), Synth.tone(2093, 0.4, wave: :triangle, vol: 0.18, decay: 0.2)), 0] },
184
+ bits: lambda {
185
+ up = Synth.arp([523, 659, 784, 1047, 1319, 1568, 2093], 0.035, vol: 0.13, decay: 0.03)
186
+ [Synth.seq(up, Synth.tone(2093, 0.18, vol: 0.1, vibrato: 0.02, decay: 0.08)), 0]
187
+ },
188
+ crit: lambda {
189
+ shing = Synth.tone(2400, 0.1, wave: :saw, vol: 0.12, slide_to: 5200, decay: 0.05)
190
+ thud = Synth.mix(Synth.noise(0.12, vol: 0.55, decay: 0.03), Synth.tone(140, 0.25, wave: :sine, vol: 0.7, slide_to: 60, decay: 0.08))
191
+ sting = Synth.mix(*[880, 1109, 1319].map { |f| Synth.tone(f, 0.3, wave: :square, vol: 0.06, decay: 0.12) })
192
+ [Synth.mix(shing, Synth.at(0.1, thud), Synth.at(0.12, sting)), 0.1]
193
+ },
194
+ jackpot: lambda {
195
+ r = Random.new(9)
196
+ buf = []
197
+ t = 0.0
198
+ while t < 0.95
199
+ Synth.add(buf, t, Synth.tone(700 + r.rand(500), 0.02, vol: 0.1, decay: 0.008))
200
+ t += 0.045
201
+ end
202
+ [0.45, 0.7, 0.95].each_with_index do |at, i|
203
+ Synth.add(buf, at, Synth.mix(Synth.noise(0.05, vol: 0.4, decay: 0.015, rng: r), Synth.tone(160 + i * 40, 0.15, wave: :sine, vol: 0.5, slide_to: 80, decay: 0.05)))
204
+ Synth.add(buf, at, Synth.tone([784, 988, 1175][i], 0.12, wave: :triangle, vol: 0.18, decay: 0.06))
205
+ end
206
+ Synth.add(buf, 0.95, Synth.boom(0.8, vol: 0.6, low: 75))
207
+ Synth.add(buf, 1.0, Synth.arp([1047, 1319, 1568, 2093, 1568, 2093, 2637], 0.07, vol: 0.12, decay: 0.05))
208
+ 40.times { Synth.add(buf, 1.0 + r.rand * 1.2, Synth.tone(2600 + r.rand(1800), 0.06, wave: :sine, vol: 0.08, decay: 0.02)) }
209
+ [buf, 0.95]
210
+ },
211
+ levelup: lambda {
212
+ buf = []
213
+ Synth.add(buf, 0, Synth.tone(150, 0.22, wave: :saw, vol: 0.12, slide_to: 900))
214
+ Synth.add(buf, 0.22, Synth.boom(0.7, vol: 0.6))
215
+ fan = Synth.arp([523, 659, 784, 1047, 784, 1047, 1319, 1568], 0.06, vol: 0.13, decay: 0.05)
216
+ Synth.add(buf, 0.24, fan)
217
+ [1047, 1319, 1568, 2093].each { |f| Synth.add(buf, 0.72, Synth.tone(f, 0.5, vol: 0.06, vibrato: 0.015, decay: 0.3)) }
218
+ # the unveiling: a gong and a shimmering chord
219
+ Synth.add(buf, 1.0, Synth.tone(98, 1.6, wave: :sine, vol: 0.45, decay: 0.7, vibrato: 0.004))
220
+ Synth.add(buf, 1.0, Synth.tone(196, 1.2, wave: :triangle, vol: 0.12, decay: 0.5))
221
+ Synth.add(buf, 1.45, Synth.noise(0.5, vol: 0.25, decay: 0.15, smooth: 0.5))
222
+ [523, 659, 784, 1047, 1319].each_with_index do |f, i|
223
+ Synth.add(buf, 1.45 + i * 0.05, Synth.tone(f, 1.4, vol: 0.05, vibrato: 0.01, decay: 0.6))
224
+ end
225
+ r = Random.new(4)
226
+ 16.times { Synth.add(buf, 1.6 + r.rand * 1.4, Synth.tone(3000 + r.rand(2000), 0.05, wave: :sine, vol: 0.06, decay: 0.015)) }
227
+ [buf, 0.22]
228
+ },
184
229
  finale: ->(k = 1.0) { Sound.finale(k) },
185
230
  })
186
231
 
@@ -260,7 +305,9 @@ module Dopairb
260
305
  def backend
261
306
  return @backend if defined?(@backend)
262
307
  @backend =
263
- if (p = which("paplay")) && (ENV["PULSE_SERVER"] || !wsl? || File.exist?("/mnt/wslg/PulseServer"))
308
+ if (log = ENV["DOPAIRB_SOUND_LOG"]) && !log.empty?
309
+ [:log, [log]] # recordings: write "<wall clock of impact> <wav>" lines instead of playing
310
+ elsif (p = which("paplay")) && (ENV["PULSE_SERVER"] || !wsl? || File.exist?("/mnt/wslg/PulseServer"))
264
311
  [:fast, [p]]
265
312
  elsif (p = which("afplay"))
266
313
  [:fast, [p]]
@@ -278,7 +325,7 @@ module Dopairb
278
325
  def available? = !backend.nil?
279
326
 
280
327
  # Per-keystroke sounds only make sense with a low-latency player.
281
- def fast? = backend&.first == :fast
328
+ def fast? = %i[fast log].include?(backend&.first)
282
329
 
283
330
  @last = {}
284
331
  @children = []
@@ -294,6 +341,7 @@ module Dopairb
294
341
  return false if @children.size >= 4
295
342
  @last[name] = now
296
343
  started = now
344
+ return log_play(argv[0], name, delay, stretch) if kind == :log
297
345
  Thread.new do
298
346
  Thread.current.report_on_exception = false
299
347
  path, impact = file(name, stretch)
@@ -317,6 +365,13 @@ module Dopairb
317
365
  false
318
366
  end
319
367
 
368
+ def log_play(log, name, delay, stretch)
369
+ at = Process.clock_gettime(Process::CLOCK_REALTIME) + delay
370
+ path, impact = file(name, stretch)
371
+ File.open(log, "a") { |f| f.puts format("%.4f %s", at - impact, path) }
372
+ true
373
+ end
374
+
320
375
  def ready?(name, stretch = 1.0) = !cached(key_for(name, stretch)).nil?
321
376
 
322
377
  # Render every missing sound in the background, the startup sound first.
data/lib/dopairb/term.rb CHANGED
@@ -85,9 +85,11 @@ module Dopairb
85
85
  end
86
86
 
87
87
  # First candidate that renders one column wide on this terminal.
88
+ # A nil candidate means "none of the above": glyph("▀", nil) may be nil.
88
89
  def glyph(*candidates)
89
90
  @glyphs ||= {}
90
- @glyphs[candidates] ||= candidates.find { |c| c.ascii_only? || (utf8? && c.each_char.all? { |ch| char_width(ch) == 1 }) } || candidates.last
91
+ return @glyphs[candidates] if @glyphs.key?(candidates)
92
+ @glyphs[candidates] = candidates.find { |c| c.nil? || c.ascii_only? || (utf8? && c.each_char.all? { |ch| char_width(ch) == 1 }) } || candidates.last
91
93
  end
92
94
 
93
95
  def reset_glyphs
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ # `dopa gallery tour`: a full-screen slideshow of the whole collection.
5
+ # Pieces you own are shown in color; the rest are silhouettes to hunt for.
6
+ # → / space: next, ←: back, q: leave. Slides advance on their own.
7
+ module Tour
8
+ SLIDE = 4.0
9
+ REVEAL = 0.35
10
+ FPS = 30.0
11
+
12
+ module_function
13
+
14
+ def run(config, owned, start: 0)
15
+ pieces = Gallery::PIECES
16
+ depth = Term.depth(config)
17
+ i = start % pieces.size
18
+ stage = AltStage.new(Term.rows)
19
+ Term.raw do
20
+ stage.open
21
+ begin
22
+ loop do
23
+ step = show(stage, pieces, i, owned, depth, config)
24
+ break if step == :quit
25
+ i = (i + step) % pieces.size
26
+ end
27
+ ensure
28
+ stage.close
29
+ end
30
+ end
31
+ rescue Interrupt
32
+ nil
33
+ end
34
+
35
+ # Animate one slide; => how many slides to move (+1 on timeout) or :quit.
36
+ def show(stage, pieces, i, owned, depth, config)
37
+ t0 = now
38
+ loop do
39
+ t = now - t0
40
+ stage.draw(frame(pieces[i], i, pieces.size, owned, depth, [t / REVEAL, 1.0].min, t))
41
+ step = read_key(t < REVEAL ? 1 / FPS : [SLIDE * config.duration - t, 0.05].max)
42
+ return step if step
43
+ return 1 if t >= SLIDE * config.duration
44
+ end
45
+ end
46
+
47
+ def frame(piece, i, total, owned, depth, reveal, t)
48
+ rows, cols = Term.size
49
+ w = cols - 1
50
+ c = Canvas.new(w, rows)
51
+ have = owned.include?(piece.id)
52
+ star = Term.glyph("✦", "*")
53
+ head = "#{star} DOPA IRB GALLERY #{star} #{i + 1} / #{total} collected #{owned.size}"
54
+ c.put_center(0, head, Color.rainbow(t * 0.3), bold: true)
55
+ aw, ah = Gallery.fit(piece, w - 6, rows - 6)
56
+ ax = (w - aw) / 2
57
+ ay = 2
58
+ frame_col = have ? Color.mix([255, 240, 170], [190, 130, 30], (Math.sin(t * 3) + 1) / 2) : [70, 70, 90]
59
+ c.fill_bg(frame_col, ax - 2, ay - 1, aw + 4, ah / 2 + 2)
60
+ c.fill_bg([20, 20, 28], ax - 1, ay, aw + 2, ah / 2)
61
+ Gallery.draw(c, piece, ax, ay, aw, ah, reveal: reveal, depth: depth) do |top, bot, _x, _r|
62
+ have ? [top, bot] : [shadow(top), shadow(bot)]
63
+ end
64
+ cap = have ? "\"#{piece.title}\" #{piece.artist}, #{piece.year}" : "? ? ? (by #{piece.artist})"
65
+ c.put_center(ay + ah / 2 + 1, cap, have ? [240, 230, 210] : [150, 150, 175], bold: have)
66
+ c.put_center(rows - 1, "->/space next <- back q quit", [120, 120, 140])
67
+ c.render(depth)
68
+ end
69
+
70
+ # Uncollected pieces keep their shapes but lose their colors.
71
+ def shadow(color)
72
+ Color.mix([12, 12, 22], [80, 80, 110], Color.luminance(color) / 255.0)
73
+ end
74
+
75
+ # Keys can arrive several at once (a held arrow key): all of them count.
76
+ # => :quit, a step (+n / -n), or nil when nothing was typed.
77
+ def read_key(timeout)
78
+ return nil unless Term.input.wait_readable(timeout)
79
+ s = Term.input.read_nonblock(256, exception: false)
80
+ return nil unless String === s
81
+ keys = s.scan(/\e[\[O][\d;]*[A-Za-z~]|\e|./m)
82
+ return :quit if keys.any? { |k| ["q", "Q", "\e", "\x03"].include?(k) }
83
+ step = keys.sum { |k| ["\e[D", "\eOD", "h", "p", "b"].include?(k) ? -1 : 1 }
84
+ step.zero? ? nil : step
85
+ end
86
+
87
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
88
+ end
89
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Dopairb
4
- VERSION = "0.2.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/dopairb.rb CHANGED
@@ -19,11 +19,14 @@ require_relative "dopairb/fx"
19
19
  require_relative "dopairb/stage"
20
20
  require_relative "dopairb/game"
21
21
  require_relative "dopairb/probe"
22
+ require_relative "dopairb/profile"
23
+ require_relative "dopairb/gallery"
22
24
  require_relative "dopairb/scene"
23
25
  require_relative "dopairb/scenes/success"
24
26
  require_relative "dopairb/scenes/failure"
25
27
  require_relative "dopairb/scenes/session"
26
28
  require_relative "dopairb/scenes/finale"
29
+ require_relative "dopairb/scenes/bonus"
27
30
  require_relative "dopairb/director"
28
31
  require_relative "dopairb/output_tap"
29
32
  require_relative "dopairb/charge_line"
@@ -33,6 +36,7 @@ require_relative "dopairb/reline_adapter"
33
36
  require_relative "dopairb/irb_adapter"
34
37
  require_relative "dopairb/session"
35
38
  require_relative "dopairb/demo"
39
+ require_relative "dopairb/tour"
36
40
 
37
41
  # dopairb: IRB where every keystroke, result and exception gets a show.
38
42
  #
@@ -44,7 +48,10 @@ module Dopairb
44
48
  def config
45
49
  @config ||= begin
46
50
  c = Config.new
47
- c.apply_string(ENV["DOPAIRB"]) if ENV["DOPAIRB"]
51
+ if ENV["DOPAIRB"]
52
+ c.apply_string(ENV["DOPAIRB"])
53
+ overrides << [:string, ENV["DOPAIRB"]]
54
+ end
48
55
  c
49
56
  rescue ArgumentError => e
50
57
  warn "dopairb: ignoring DOPAIRB=#{ENV['DOPAIRB'].inspect}: #{e.message}"
@@ -52,25 +59,55 @@ module Dopairb
52
59
  end
53
60
  end
54
61
 
55
- # Hook into IRB. Safe to call more than once; options are config settings.
62
+ # Settings for this run only ($DOPAIRB, dopairb options). They win over
63
+ # Dopairb.enable(...) in .irbrc, which IRB loads after they are applied.
64
+ def overrides = (@overrides ||= [])
65
+
66
+ def override(name, value)
67
+ name == :string ? config.apply_string(value) : config.set(name, value)
68
+ overrides << [name, value]
69
+ end
70
+
71
+ # Hook into IRB and switch the show on. Safe to call more than once;
72
+ # options are config settings.
56
73
  def enable(intro: nil, **settings)
57
74
  settings.each { |k, v| config.set(k, v) }
58
75
  config.set(:intro, intro) unless intro.nil?
59
- @session ||= Session.new(config)
60
- if IrbAdapter.install
61
- RelineAdapter.install(@session.fx)
62
- OutputTap.install
63
- if active?
64
- # decided before warming up: the first run shows the loading screen
65
- @session.cold_start = Sound.available? && !Sound.ready?(:intro, config.duration)
66
- Sound.warm_up(config.duration)
67
- end
76
+ overrides.each { |k, v| k == :string ? config.apply_string(v) : config.set(k, v) }
77
+ if hook
78
+ wake
68
79
  else
69
80
  warn "dopairb: IRB #{defined?(IRB::VERSION) ? IRB::VERSION : '?'} is not supported; running plain IRB"
70
81
  end
71
82
  @session
72
83
  end
73
84
 
85
+ # Just add the `dopa` command; the show stays dormant until `dopa on` or
86
+ # Dopairb.enable. This is what a bare `require "dopairb"` inside IRB does.
87
+ def install
88
+ hook
89
+ @session
90
+ end
91
+
92
+ def hook
93
+ @session ||= Session.new(config)
94
+ return false unless IrbAdapter.install
95
+ RelineAdapter.install(@session.fx)
96
+ OutputTap.install
97
+ true
98
+ end
99
+
100
+ # Out of dormancy (into whatever the intensity says).
101
+ def wake
102
+ return unless @session
103
+ was = @session.dormant
104
+ @session.dormant = false
105
+ return unless active?
106
+ # decided before warming up: the first run shows the loading screen
107
+ @session.cold_start = Sound.available? && !Sound.ready?(:intro, config.duration) if was || @session.cold_start.nil?
108
+ Sound.warm_up(config.duration)
109
+ end
110
+
74
111
  def active?
75
112
  @session&.active? || false
76
113
  end
@@ -93,32 +130,81 @@ module Dopairb
93
130
 
94
131
  HELP = <<~TXT
95
132
  dopa show settings and session stats
133
+ dopa on / dopa off switch the show on (back to where it was) or off
96
134
  dopa off|low|normal|max
97
135
  dopa calm low intensity, no flash, shorter
98
136
  dopa party max intensity, full-screen flash, sound effects
99
137
  dopa demo play every effect once
100
138
  dopa loading show the loading screen
139
+ dopa gallery the bonus art you have collected (dopa gallery NAME shows one)
140
+ dopa gallery tour a slideshow of all 100 (uncollected ones as silhouettes)
101
141
  dopa KEY=VALUE ... change one setting, e.g. `dopa flash=off duration=0.5`
102
142
 
103
143
  settings:
104
144
  TXT
105
145
 
146
+ def gallery(name)
147
+ owned = ((@session&.profile&.gallery || []) + (@session&.collected || [])).uniq
148
+ if name == "tour"
149
+ return puts("dopairb: the tour needs a terminal") unless Term.interactive?
150
+ return Tour.run(config, owned)
151
+ end
152
+ if name.nil?
153
+ puts "dopairb gallery: #{owned.size}/#{Gallery::PIECES.size} (level-ups, jackpots and lucky ART DROPs bring more)"
154
+ Gallery::PIECES.each do |p|
155
+ puts format(" %-32s %s -- %s, %s", p.id, p.title, p.artist, p.year) if owned.include?(p.id)
156
+ end
157
+ rest = Gallery::PIECES.size - owned.size
158
+ puts " ... #{rest} more to discover" if rest > 0
159
+ puts " `dopa gallery NAME` shows one; `dopa gallery tour` walks through all #{Gallery::PIECES.size}"
160
+ return
161
+ end
162
+ piece = Gallery.find(name.to_sym) if Gallery.ids.include?(name.to_sym)
163
+ return puts("dopairb: no such piece #{name.inspect}") unless piece
164
+ return puts("dopairb: not unlocked yet -- keep playing!") unless owned.include?(piece.id)
165
+ cols = [Term.cols - 1, 100].min
166
+ w, h = Gallery.fit(piece, cols, [Term.rows - 4, 30].min)
167
+ c = Canvas.new(w, h / 2)
168
+ Gallery.draw(c, piece, 0, 0, w, h, depth: Term.depth(config))
169
+ puts c.render(Term.depth(config))
170
+ puts "\"#{piece.title}\" #{piece.artist}, #{piece.year}"
171
+ end
172
+
106
173
  def command(arg)
107
174
  words = arg.split
108
175
  if words.empty? || words == ["status"]
109
176
  s = @session&.game
110
- puts "dopairb #{VERSION} (#{active? ? 'active' : 'inactive: not a terminal or intensity=off'})"
177
+ state = if active? then "active"
178
+ elsif @session&.dormant then "dormant: `dopa on` to start the show"
179
+ else "inactive: not a terminal or intensity=off"
180
+ end
181
+ puts "dopairb #{VERSION} (#{state})"
111
182
  puts config.describe
112
183
  puts " sound player: #{Sound.backend ? Sound.backend[1].first : '(none; bell only)'} cache: #{Sound.dir}"
113
184
  puts " session: #{s.evals} evals, #{s.successes} hits, #{s.failures} errors, max combo #{s.max_combo}, score #{Fx.number_with_commas(s.score)}" if s
185
+ prof = @session&.profile
186
+ if prof&.persistent?
187
+ lv = s.leveling? ? s.level : prof.level
188
+ xp = s.leveling? ? s.xp : prof.xp
189
+ puts " career: LV #{lv} (#{Fx.number_with_commas(xp)} / #{Fx.number_with_commas(Game.xp_for(lv + 1))} XP), " \
190
+ "best score #{Fx.number_with_commas(prof.best_score)}, best combo #{prof.best_combo}, day streak #{prof.streak}, " \
191
+ "gallery #{prof.gallery.size}/#{Gallery::PIECES.size} (#{prof.path})"
192
+ end
114
193
  elsif words == ["help"]
115
194
  puts HELP + config.describe
116
195
  elsif words == ["demo"]
117
196
  Demo.run(self)
197
+ elsif words[0] == "gallery"
198
+ gallery(words[1])
118
199
  elsif words == ["loading"]
119
200
  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
120
201
  @session&.director&.loading { Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0 > 2.5 } if active?
202
+ elsif words == ["on"]
203
+ config.set(:intensity, @resume_intensity || Config.new.intensity) if config.off?
204
+ wake
205
+ puts "dopairb: on (#{config.intensity})"
121
206
  elsif words.size == 1 && PRESETS.key?(words[0])
207
+ @resume_intensity = config.intensity if words[0] == "off" && !config.off?
122
208
  PRESETS[words[0]].each { |k, v| config.set(k, v) }
123
209
  Sound.warm_up(config.duration) if active?
124
210
  puts "dopairb: #{words[0]}"
@@ -138,3 +224,6 @@ module Dopairb
138
224
  end
139
225
  end
140
226
  end
227
+
228
+ # Required from inside IRB (e.g. .irbrc): add the `dopa` command, nothing else yet.
229
+ Dopairb.install.dormant = true if defined?(IRB.conf) && IRB.conf[:AP_NAME] && !Dopairb.session
data/spec.md CHANGED
@@ -127,10 +127,10 @@
127
127
 
128
128
  | 設定 | 内容 |
129
129
  | --- | --- |
130
- | `intensity` | `off` / `low` / `normal` / `max`。初期値 `normal` |
130
+ | `intensity` | `off` / `low` / `normal` / `max`。初期値 `max`(v0.4.0 で `normal` から変更) |
131
131
  | `motion` | アニメーションの有無と長さ |
132
132
  | `flash` | 全画面フラッシュの有無。初期値は控えめ |
133
- | `sound` | 端末ベル等を使う場合の明示的な有効化。初期値 `off` |
133
+ | `sound` | `off` / `bell` / `sfx`。初期値 `sfx`(v0.4.0 で `off` から変更。プレイヤーがなければベル) |
134
134
  | `hud` | COMBO・CHARGE・SCORE の表示 |
135
135
  | `duration` | 各演出時間の倍率 |
136
136
  | `color` | 色数と配色。モノクロでも意味が分かる表示 |
@@ -196,7 +196,7 @@ stateDiagram-v2
196
196
 
197
197
  - 常時 HUD を画面上部に固定するか、プロンプト周辺の一時表示にするか。
198
198
  - `puts` などの評価途中の出力をどの範囲まで捕捉・装飾するか。
199
- - スコアをセッション内だけにするか、記録を保存するか。
199
+ - ~~スコアをセッション内だけにするか、記録を保存するか。~~ → 生涯 XP・レベル・ベスト記録・連続日数・ボーナス絵を `~/.local/state/dopairb/profile.json` に保存する(`DOPAIRB_PROFILE=off` で無効)。
200
200
  - 演出素材を Unicode 文字中心にするか、端末によって別の表現を選ぶか。
201
201
  - gem の起動コマンドと、通常の `irb` や `.irbrc` から有効化する方法。
202
202
  - IRB/Reline の対象バージョンと、毎キー描画に必要な拡張点。
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: dopairb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Koichi Sasada
@@ -55,16 +55,19 @@ description: 'dopairb extends IRB with game-like terminal effects: sparks while
55
55
  email:
56
56
  - ko1@atdot.net
57
57
  executables:
58
+ - dopacc
58
59
  - dopairb
59
60
  extensions: []
60
61
  extra_rdoc_files: []
61
62
  files:
62
63
  - LICENSE
63
64
  - README.md
65
+ - exe/dopacc
64
66
  - exe/dopairb
65
67
  - lib/dopairb.rb
66
68
  - lib/dopairb/canvas.rb
67
69
  - lib/dopairb/charge_line.rb
70
+ - lib/dopairb/claude_code.rb
68
71
  - lib/dopairb/cli.rb
69
72
  - lib/dopairb/color.rb
70
73
  - lib/dopairb/config.rb
@@ -72,13 +75,21 @@ files:
72
75
  - lib/dopairb/director.rb
73
76
  - lib/dopairb/font.rb
74
77
  - lib/dopairb/fx.rb
78
+ - lib/dopairb/gallery.rb
79
+ - lib/dopairb/gallery/impressionism.rb
80
+ - lib/dopairb/gallery/japan.rb
81
+ - lib/dopairb/gallery/modern_ancient.rb
82
+ - lib/dopairb/gallery/renaissance.rb
83
+ - lib/dopairb/gallery/romantic.rb
75
84
  - lib/dopairb/game.rb
76
85
  - lib/dopairb/input_fx.rb
77
86
  - lib/dopairb/irb_adapter.rb
78
87
  - lib/dopairb/output_tap.rb
79
88
  - lib/dopairb/probe.rb
89
+ - lib/dopairb/profile.rb
80
90
  - lib/dopairb/reline_adapter.rb
81
91
  - lib/dopairb/scene.rb
92
+ - lib/dopairb/scenes/bonus.rb
82
93
  - lib/dopairb/scenes/failure.rb
83
94
  - lib/dopairb/scenes/finale.rb
84
95
  - lib/dopairb/scenes/session.rb
@@ -87,6 +98,7 @@ files:
87
98
  - lib/dopairb/sound.rb
88
99
  - lib/dopairb/stage.rb
89
100
  - lib/dopairb/term.rb
101
+ - lib/dopairb/tour.rb
90
102
  - lib/dopairb/version.rb
91
103
  - spec.md
92
104
  homepage: https://github.com/ko1/dopairb