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.
- checksums.yaml +4 -4
- data/README.md +77 -9
- data/exe/dopacc +84 -0
- data/lib/dopairb/claude_code.rb +330 -0
- data/lib/dopairb/cli.rb +4 -6
- data/lib/dopairb/config.rb +6 -4
- data/lib/dopairb/demo.rb +9 -2
- data/lib/dopairb/director.rb +35 -5
- 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 +84 -7
- data/lib/dopairb/input_fx.rb +63 -9
- data/lib/dopairb/irb_adapter.rb +8 -0
- data/lib/dopairb/profile.rb +106 -0
- data/lib/dopairb/reline_adapter.rb +1 -0
- data/lib/dopairb/scene.rb +5 -0
- data/lib/dopairb/scenes/bonus.rb +279 -0
- data/lib/dopairb/scenes/failure.rb +1 -0
- data/lib/dopairb/scenes/finale.rb +42 -2
- data/lib/dopairb/scenes/session.rb +21 -3
- data/lib/dopairb/scenes/success.rb +11 -4
- data/lib/dopairb/session.rb +53 -7
- data/lib/dopairb/sound.rb +57 -2
- 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 +101 -12
- data/spec.md +3 -3
- metadata +13 -1
data/lib/dopairb/session.rb
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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]
|
|
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
|
data/lib/dopairb/tour.rb
ADDED
|
@@ -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
|
data/lib/dopairb/version.rb
CHANGED
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
60
|
-
if
|
|
61
|
-
|
|
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
|
-
|
|
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` |
|
|
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.
|
|
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
|