dopairb 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e71c4e0e91c35575f8ac0ebc641873bc4fc5af8dfc78b3ab4b17aa2b9ab3057
4
- data.tar.gz: df276b5e5997edf7eaf128f022c6475a39b8db2949317e64db718bc8c91bf15a
3
+ metadata.gz: 83be2be298c392a6739e1fdef9293579796f7d80dd3a5b3aaccc65cf34f60d4c
4
+ data.tar.gz: 8afedc6a382a671a5e3dc13720f1934599757cadd6aacb28b88b69cd1986b0ac
5
5
  SHA512:
6
- metadata.gz: 1fed2568ae4137a3da8d88c4d7ce74cf79ec6fba2f82d5d823a5cae1e063f91d35363910f7b68e3e154de0491b249e6125c1eea72c45fd6d399ed0b86244c055
7
- data.tar.gz: 7a117f3e7a8ae10726a56b3469c8580fe7e68e0a1cad2a4c1b145ea8e480ff35d1a90ccd14e37a249e3078da1d095998b1c55c0e14ddd56ed1ae3e3917493387
6
+ metadata.gz: 7aed8c09308468b4e3ed6e992cba5d4245d349c66bf88abfa37a4d6a214a1f457dd4610db944f49e5d53c1e024ebfc4bed7bf82fa81ef319d65e158547139633
7
+ data.tar.gz: 2c6609aeb7f8939fe95c0278592bf45cc67f68eb52d715e3527837074ecea19dda912505d630715e2a5ef760d630b33a3b778f07a39faba81aef5def8fbe1119
data/README.md CHANGED
@@ -32,7 +32,8 @@ To try it without installing, run `ruby exe/dopairb` in the checkout.
32
32
  $ dopairb # start it (IRB options are passed through)
33
33
  $ dopairb --calm # quieter: low intensity, no flash, shorter effects
34
34
  $ dopairb --max # everything at maximum; the biggest moments go full screen
35
- $ dopairb --party # max + full-screen flash + bell
35
+ $ dopairb --party # max + full-screen flash + sound effects
36
+ $ dopairb --sound # synthesized sound effects (--sound=bell for the terminal bell)
36
37
  $ dopairb --no-flash # suppress flashing, available from the very first start
37
38
  $ DOPAIRB="intensity=low,flash=off" dopairb
38
39
  ```
@@ -51,6 +52,7 @@ dopa show settings and session stats
51
52
  dopa off|low|normal|max
52
53
  dopa calm / dopa party
53
54
  dopa demo play every effect once (does not change your score)
55
+ dopa loading show the loading screen
54
56
  dopa flash=off duration=0.5 ...
55
57
  ```
56
58
 
@@ -61,7 +63,7 @@ dopa flash=off duration=0.5 ...
61
63
  | `intensity` | off / low / normal / max | normal | Overall amount. `off` behaves exactly like plain IRB |
62
64
  | `motion` | on / off | on | `off` disables animation (badges only) |
63
65
  | `flash` | off / soft / full | soft | `soft` flashes only the effect area, `full` inverts the whole screen |
64
- | `sound` | on / off | off | Terminal bell on big moments |
66
+ | `sound` | off / bell / sfx | off | `sfx` plays synthesized sound effects, `bell` rings the terminal bell on big moments |
65
67
  | `hud` | on / off | on | COMBO / SCORE / CHARGE on the right of the prompt line |
66
68
  | `duration` | 0.1–5 | 1.0 | Length multiplier for every effect |
67
69
  | `color` | auto / truecolor / 256 / 16 / none | auto | `none` when `NO_COLOR` is set |
@@ -104,10 +106,29 @@ When several fire at once, only the biggest plays. The smaller ones are folded i
104
106
  | First eval / COMBO 5, 10, 25… / eval count 10, 50, 100… | `FIRST HIT!` / `COMBO 10!` / `100 EVALS` |
105
107
  | Numeric record (more than doubled) | `NEW RECORD` |
106
108
  | Success right after an error | `FIXED!` (after one error) / `COMEBACK!` (after a streak), both huge |
107
- | Exit | A result screen that counts up, left behind as a plain table |
109
+ | Exit | A full-screen finale: the title drops, stats slam in one by one, the total score counts up under a drum roll, a rank is stamped (S/A/B/C with a cheerful title), then fireworks. The result is left behind as a plain table |
108
110
 
109
111
  Error effects name the kind of error. They never make fun of the failure.
110
112
 
113
+ ## Sound
114
+
115
+ With `sound=sfx`, every effect has a sound: key clicks, `NICE!` chimes, a comet ping, a stamp thump, a count-up that ends in a boom, fanfares and explosions for the big moments, a buzz or crack for errors.
116
+ All sounds are synthesized in Ruby at runtime and cached as WAV files in the temp directory. No audio files ship with the gem.
117
+
118
+ They are played by the first player found:
119
+
120
+ - `paplay` (PulseAudio, including WSLg on WSL2)
121
+ - `afplay` (macOS)
122
+ - `aplay` (ALSA)
123
+ - `powershell.exe` on WSL without WSLg
124
+
125
+ The finale's sound is one track synthesized on the same timeline as the animation (a tick and a rising note per stat row, a drum roll, a boom, a fanfare, fireworks) and stretched with `duration`.
126
+ Sounds are rendered once and reused by later sessions. At startup, a Ractor renders the missing ones in parallel with the REPL, so typing is not slowed down (a thread is used where Ractor is unavailable). The startup sound is rendered first. On a start without cached sounds (the first run, or after an upgrade), a loading screen (a cat running along a pastel progress bar) is shown until it is ready. `dopa loading` shows it any time.
127
+ Players run as separate processes, so the REPL never waits for them.
128
+ PowerShell takes a few hundred milliseconds to start, so with it only the result effects make sound, not every keystroke.
129
+ When no player is found, `sfx` falls back to the terminal bell on big moments.
130
+ Sound plays on the machine that runs dopairb, so over ssh you will not hear it.
131
+
111
132
  ## Readable, never broken
112
133
 
113
134
  - IRB evaluates and prints as usual. `=> value`, exception messages and backtraces come from IRB itself.
@@ -120,39 +120,48 @@ module Dopairb
120
120
  @bold[y][x] = bold
121
121
  end
122
122
 
123
+ BRAILLE_CHARS = Array.new(256) { |i| (0x2800 + i).chr(Encoding::UTF_8) }.freeze
124
+
123
125
  def render_row(y, depth, shake)
124
126
  chs = @ch[y]
127
+ fgs = @fg[y]
128
+ bgs = @bg[y]
129
+ bolds = @bold[y]
130
+ dots = @dots[y]
131
+ dotc = @dotc[y]
132
+ utf8 = Term.utf8?
125
133
  last = @w - 1
126
- last -= 1 while last >= 0 && chs[last].nil? && @dots[y][last] == 0 && @bg[y][last].nil?
134
+ last -= 1 while last >= 0 && chs[last].nil? && dots[last] == 0 && bgs[last].nil?
127
135
  out = +""
128
- cur = nil
129
- if shake > 0
130
- out << (" " * shake)
131
- end
132
- start = shake < 0 ? -shake : 0
136
+ out << (" " * shake) if shake > 0
137
+ x = shake < 0 ? -shake : 0
133
138
  limit = shake > 0 ? [last, @w - 1 - shake].min : last
134
- x = start
139
+ cur = nil
135
140
  while x <= limit
136
141
  c = chs[x]
137
142
  if c == CONT
138
143
  x += 1
139
144
  next
140
145
  end
141
- fg = @fg[y][x]
142
- bold = @bold[y][x]
146
+ fg = fgs[x]
147
+ bg = bgs[x]
148
+ bold = bolds[x]
143
149
  if c.nil?
144
- if @dots[y][x] != 0
145
- c = Term.utf8? ? (0x2800 + @dots[y][x]).chr(Encoding::UTF_8) : "."
146
- fg = @dotc[y][x]
150
+ d = dots[x]
151
+ if d != 0
152
+ c = utf8 ? BRAILLE_CHARS[d] : "."
153
+ fg = dotc[x]
147
154
  bold = false
148
155
  else
149
156
  c = " "
157
+ fg = nil
158
+ bold = false
150
159
  end
151
160
  end
152
- style = [fg, @bg[y][x], bold]
153
- if style != cur
154
- out << (depth == :none && !bold && @bg[y][x].nil? ? "\e[0m" : Color.sgr(fg, @bg[y][x], bold, depth))
155
- cur = style
161
+ key = style_key(fg, bg, bold)
162
+ if key != cur
163
+ out << sgr_for(key, fg, bg, bold, depth)
164
+ cur = key
156
165
  end
157
166
  out << c
158
167
  x += 1
@@ -160,5 +169,19 @@ module Dopairb
160
169
  out << "\e[0m" if cur
161
170
  out
162
171
  end
172
+
173
+ def style_key(fg, bg, bold)
174
+ f = fg ? (fg[0] << 16) | (fg[1] << 8) | fg[2] : 0x1000000
175
+ b = bg ? (bg[0] << 16) | (bg[1] << 8) | bg[2] : 0x1000000
176
+ (f << 26) | (b << 1) | (bold ? 1 : 0)
177
+ end
178
+
179
+ SGR_CACHE = Hash.new { |h, d| h[d] = {} }
180
+
181
+ def sgr_for(key, fg, bg, bold, depth)
182
+ cache = SGR_CACHE[depth]
183
+ cache.clear if cache.size > 50_000
184
+ cache[key] ||= (depth == :none && !bold && bg.nil? ? "\e[0m" : Color.sgr(fg, bg, bold, depth)).freeze
185
+ end
163
186
  end
164
187
  end
data/lib/dopairb/cli.rb CHANGED
@@ -18,10 +18,10 @@ module Dopairb
18
18
  o.on("--intensity=LEVEL", Config::INTENSITIES.map(&:to_s), "off / low / normal (default) / max") { |v| settings << [:intensity, v] }
19
19
  o.on("--calm", "low intensity, no flash, shorter effects") { settings.concat([[:intensity, :low], [:flash, :off], [:duration, 0.7]]) }
20
20
  o.on("--max", "everything at maximum") { settings << [:intensity, :max] }
21
- o.on("--party", "max + full-screen flash + bell") { settings.concat([[:intensity, :max], [:flash, :full], [:sound, true]]) }
21
+ o.on("--party", "max + full-screen flash + sound effects") { settings.concat([[:intensity, :max], [:flash, :full], [:sound, :sfx]]) }
22
22
  o.on("--[no-]flash[=MODE]", "flash: off / soft (default) / full") { |v| settings << [:flash, v.nil? ? :soft : (v == false ? :off : v)] }
23
23
  o.on("--[no-]motion", "animations (default on)") { |v| settings << [:motion, v] }
24
- o.on("--[no-]sound", "terminal bell on big moments (default off)") { |v| settings << [:sound, v] }
24
+ o.on("--[no-]sound[=MODE]", "sound: off (default) / bell / sfx") { |v| settings << [:sound, v.nil? ? :sfx : (v == false ? :off : v)] }
25
25
  o.on("--[no-]hud", "COMBO / SCORE next to the prompt (default on)") { |v| settings << [:hud, v] }
26
26
  o.on("--[no-]keys", "per-keystroke effects (default on)") { |v| settings << [:keys, v] }
27
27
  o.on("--[no-]intro", "title animation (default on)") { |v| settings << [:intro, v] }
@@ -7,12 +7,13 @@ module Dopairb
7
7
  FLASHES = %i[off soft full].freeze
8
8
  COLORS = %i[auto truecolor 256 16 none].freeze
9
9
  TRAILS = %i[none big all].freeze
10
+ SOUNDS = %i[off bell sfx].freeze
10
11
 
11
12
  DESCRIPTIONS = {
12
13
  intensity: "off / low / normal / max -- overall amount of effects",
13
14
  motion: "on / off -- animations (off = static badges only)",
14
15
  flash: "off / soft / full -- soft = region only, full = whole screen",
15
- sound: "on / off -- terminal bell on big moments",
16
+ sound: "off / bell / sfx -- sfx = synthesized effects (paplay/aplay/afplay/WSL), bell = terminal bell",
16
17
  hud: "on / off -- COMBO / SCORE / CHARGE next to the prompt",
17
18
  duration: "float multiplier for every effect length (e.g. 0.5, 2)",
18
19
  color: "auto / truecolor / 256 / 16 / none",
@@ -28,7 +29,7 @@ module Dopairb
28
29
  @intensity = :normal
29
30
  @motion = true
30
31
  @flash = :soft
31
- @sound = false
32
+ @sound = :off
32
33
  @hud = true
33
34
  @duration = 1.0
34
35
  @color = :auto
@@ -56,7 +57,10 @@ module Dopairb
56
57
  v = bool_or_nil(value)
57
58
  @color = v.nil? ? enum(value, COLORS, name) : (v ? :auto : :none)
58
59
  when :trail then @trail = enum(value, TRAILS, name)
59
- when :motion, :sound, :hud, :intro, :keys
60
+ when :sound
61
+ v = bool_or_nil(value)
62
+ @sound = v.nil? ? enum(value, SOUNDS, name) : (v ? :sfx : :off)
63
+ when :motion, :hud, :intro, :keys
60
64
  v = bool_or_nil(value)
61
65
  raise ArgumentError, "#{name} expects on/off, got #{value.inspect}" if v.nil?
62
66
  instance_variable_set(:"@#{name}", v)
data/lib/dopairb/demo.rb CHANGED
@@ -36,6 +36,15 @@ module Dopairb
36
36
  ]
37
37
  end
38
38
 
39
+ # Let the sound ring out so the next one does not step on it.
40
+ def wait_for_sound(config, scene, t0)
41
+ return unless config.sound == :sfx && scene&.sfx && Sound.available?
42
+ ends = (scene.pre + scene.impact_at) * config.duration + Sound.tail(scene.sfx)
43
+ wait = ends - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0)
44
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait
45
+ sleep 0.02 while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline && !Term.input_pending?
46
+ end
47
+
39
48
  def run(mod)
40
49
  d = mod.session&.director || Director.new(mod.config)
41
50
  unless mod.active?
@@ -51,9 +60,11 @@ module Dopairb
51
60
  if event.kind == :failure && event.info[:type] == :syntax
52
61
  event.info[:column] = [code.size, event.info[:column]].min
53
62
  end
54
- d.play_event(event, input: shot, pre: event.kind == :success ? mod.config.charge : 0)
63
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
64
+ scene = d.play_event(event, input: shot, pre: event.kind == :success ? mod.config.charge : 0)
55
65
  Term.write("\e[2m=> (demo)\e[0m\r\n")
56
- sleep 0.15
66
+ wait_for_sound(mod.config, scene, t0)
67
+ sleep 0.4
57
68
  end
58
69
  Term.write("\e[0m")
59
70
  end
@@ -90,7 +90,19 @@ module Dopairb
90
90
  return if @config.off?
91
91
  c = ctx(input: input, pre: pre)
92
92
  c.input = nil if input && input.size + 12 > c.rows
93
- play(scene_for(event, c))
93
+ scene = scene_for(event, c)
94
+ play(scene)
95
+ scene
96
+ end
97
+
98
+ # Cute loading screen until ready.call is true (e.g. the intro sound exists).
99
+ def loading(&ready)
100
+ return if @config.off?
101
+ scene = Scenes::Loading.new(ctx, ready)
102
+ return unless @config.motion && fits?(scene)
103
+ run(scene)
104
+ rescue Exception => e # rubocop:disable Lint/RescueException
105
+ raise if SystemExit === e || (SignalException === e && !(Interrupt === e))
94
106
  end
95
107
 
96
108
  def intro
@@ -100,14 +112,22 @@ module Dopairb
100
112
 
101
113
  def outro(stats)
102
114
  return if @config.off?
103
- play(Scenes::Outro.new(ctx, stats), force_trail: true)
115
+ c = ctx
116
+ scene = if @config.motion && Scenes::Finale.fits?(c.rows, c.w + 1) && @config.level >= 2
117
+ Scenes::Finale.new(c, stats)
118
+ else
119
+ Scenes::Outro.new(c, stats)
120
+ end
121
+ # render the synced track now so it does not start late
122
+ Sound.file(scene.sfx, @config.duration) if @config.sound == :sfx && scene.sfx && Sound.available?
123
+ play(scene, force_trail: true)
104
124
  end
105
125
 
106
126
  def play(scene, force_trail: false)
107
127
  animated = @config.motion && scene.ctx.w >= 20 && fits?(scene)
108
128
  animated = false if animated && Term.input_pending?
129
+ sound(scene, animated) unless !animated && @config.motion
109
130
  run(scene) if animated
110
- Term.bell if @config.sound && scene.bell? && animated
111
131
  emit_trail(scene, force_trail)
112
132
  rescue Exception => e # rubocop:disable Lint/RescueException
113
133
  raise if SystemExit === e || (SignalException === e && !(Interrupt === e))
@@ -116,6 +136,20 @@ module Dopairb
116
136
 
117
137
  private
118
138
 
139
+ def sound(scene, animated)
140
+ case @config.sound
141
+ when :bell
142
+ Term.bell if scene.bell?
143
+ when :sfx
144
+ if Sound.available?
145
+ delay = animated ? (scene.pre + scene.impact_at) * @config.duration : 0.0
146
+ Sound.play(scene.sfx, delay: delay, stretch: @config.duration) if scene.sfx
147
+ elsif scene.bell?
148
+ Term.bell
149
+ end
150
+ end
151
+ end
152
+
119
153
  def event_evals(_event)
120
154
  Dopairb.session&.game&.evals.to_i
121
155
  end
@@ -126,8 +160,7 @@ module Dopairb
126
160
 
127
161
  def fits?(scene)
128
162
  top = scene.top? ? scene.ctx.input.size : 0
129
- alt = scene.is_a?(Scenes::Banner) && scene.height == scene.ctx.rows
130
- alt || scene.height + top <= scene.ctx.rows - 1
163
+ scene.alt? || scene.height + top <= scene.ctx.rows - 1
131
164
  end
132
165
 
133
166
  def emit_trail(scene, force)
@@ -140,7 +173,7 @@ module Dopairb
140
173
  end
141
174
 
142
175
  def run(scene)
143
- alt = scene.is_a?(Scenes::Banner) && scene.height == scene.ctx.rows && scene.mega?
176
+ alt = scene.alt?
144
177
  top = !alt && scene.top? ? scene.ctx.input.rows : []
145
178
  stage = alt ? AltStage.new(scene.height) : Stage.new(scene.height, top: top)
146
179
  depth = scene.depth
@@ -153,7 +186,7 @@ module Dopairb
153
186
  loop do
154
187
  break if Term.input_pending? || !stage.open?
155
188
  t = (now - t0) / @config.duration
156
- break if t >= scene.total_length
189
+ break if t >= scene.total_length || scene.done?(t)
157
190
  lines = []
158
191
  unless top.empty?
159
192
  tc = Canvas.new(scene.ctx.w, top.size)
data/lib/dopairb/game.rb CHANGED
@@ -16,7 +16,7 @@ module Dopairb
16
16
 
17
17
  attr_reader :score, :combo, :max_combo, :evals, :successes, :failures, :interrupts,
18
18
  :charge, :error_streak, :record, :typing_combo, :started_at, :last_key_at,
19
- :keystrokes, :best_typing
19
+ :keystrokes, :best_typing, :comebacks
20
20
 
21
21
  def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
22
22
  @clock = clock
@@ -38,6 +38,7 @@ module Dopairb
38
38
  @same_char = 0
39
39
  @typing_points = 0
40
40
  @keystrokes = 0
41
+ @comebacks = 0
41
42
  end
42
43
 
43
44
  def now = @clock.call
@@ -106,7 +107,10 @@ module Dopairb
106
107
  @successes += 1
107
108
  flags = []
108
109
  flags << :first_hit if @successes == 1
109
- flags << :comeback if @error_streak > 0
110
+ if @error_streak > 0
111
+ flags << :comeback
112
+ @comebacks += 1
113
+ end
110
114
  streak = @error_streak
111
115
  @error_streak = 0
112
116
  @combo += 1
@@ -166,7 +170,7 @@ module Dopairb
166
170
 
167
171
  def stats
168
172
  { evals: @evals, successes: @successes, failures: @failures, interrupts: @interrupts,
169
- max_combo: @max_combo, score: @score, keystrokes: @keystrokes, best_typing: @best_typing,
173
+ max_combo: @max_combo, score: @score, comebacks: @comebacks, keystrokes: @keystrokes, best_typing: @best_typing,
170
174
  time: elapsed }
171
175
  end
172
176
 
@@ -25,8 +25,9 @@ module Dopairb
25
25
 
26
26
  attr_reader :game
27
27
 
28
- def initialize(config, game, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, rng: Random.new)
28
+ def initialize(config, game, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, rng: Random.new, sound: nil)
29
29
  @config = config
30
+ @sound = sound || ->(name) { Sound.play(name) if @config.sound == :sfx && Sound.fast? }
30
31
  @game = game
31
32
  @clock = clock
32
33
  @rng = rng
@@ -304,7 +305,9 @@ module Dopairb
304
305
  @highlights << Highlight.new(off, off + ch.bytesize, t, 0.18, :flash)
305
306
  end
306
307
  spark(cur_x, t, full? ? 4 : 1)
308
+ sfx(@game.typing_combo >= 20 ? :key_hot : :key)
307
309
  if resumed == :resume
310
+ sfx(:charge)
308
311
  pop("CHARGE!", cur_x - 3, t, Color::FIRE, life: 0.7, rise: true, prio: 3)
309
312
  @parts.burst(cur_x, 0.0, 26, at: t, speed: 34, life: 0.55, palette: Color::FIRE, gravity: 10) if full?
310
313
  elsif recovering
@@ -328,6 +331,7 @@ module Dopairb
328
331
  @highlights << Highlight.new(off, off + 1, t, 0.6, :pair)
329
332
  @highlights.reject! { |h| h.kind == :flash }
330
333
  pop("NICE!", cur_x - 3, t, Color::GOLD, life: 0.6, rise: true, prio: 2)
334
+ sfx(:nice)
331
335
  @parts.burst(cur_x - 1, 0.0, 12, at: t, speed: 26, life: 0.4, palette: Color::GOLD, gravity: 8) if full?
332
336
  elsif (range = match_string(tokens, off))
333
337
  @game.key(:string)
@@ -340,6 +344,7 @@ module Dopairb
340
344
  @game.key(:block)
341
345
  @highlights << Highlight.new(start, off + 1, t, 0.55, :range)
342
346
  pop("SEALED!", cur_x - 4, t, Color::NEON, life: 0.7, rise: true, prio: 2)
347
+ sfx(:sealed)
343
348
  @parts.burst(cur_x - 2, 0.0, 16, at: t, speed: 30, life: 0.5, palette: Color::NEON, gravity: 8) if full?
344
349
  end
345
350
  end
@@ -413,6 +418,7 @@ module Dopairb
413
418
  @last_kind = :delete
414
419
  @highlights.clear
415
420
  return unless lively?
421
+ sfx(:delete)
416
422
  glyph = removed.gsub(/\s/, "").chars.last || "."
417
423
  glyph = "." unless Term.char_width(glyph) == 1
418
424
  @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 +449,7 @@ module Dopairb
443
449
  @highlights.clear
444
450
  @highlights << Highlight.new(0, new.bytesize, t, 0.35, :reveal) if new.size < 4000
445
451
  pop("<< REWIND", cur_x - 10, t, Color::ICE, life: 0.5)
452
+ sfx(:rewind)
446
453
  end
447
454
 
448
455
  def completed(off, added, cur_x, t)
@@ -452,6 +459,7 @@ module Dopairb
452
459
  @highlights.reject! { |h| h.kind == :flash }
453
460
  @highlights << Highlight.new(off, off + added.bytesize, t, 0.5, :lock)
454
461
  @inline = Popup.new("<< LOCK ON", cur_x + 1, t, 0.6 * @config.duration, Color::ICE, false, 2)
462
+ sfx(:lock)
455
463
  @parts.burst(cur_x, 0.2, 10, at: t, speed: 28, life: 0.3, palette: Color::ICE, gravity: 0) if full?
456
464
  end
457
465
 
@@ -463,6 +471,12 @@ module Dopairb
463
471
  pop("PASTE x#{n}", (cur_x || 0) - 6, t, Color::NEON, life: 0.8, rise: true) if lively?
464
472
  end
465
473
 
474
+ def sfx(name)
475
+ @sound.call(name)
476
+ rescue StandardError => e
477
+ Dopairb.debug(e)
478
+ end
479
+
466
480
  def spark(cur_x, t, n)
467
481
  return if n <= 0
468
482
  @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)
@@ -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
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)
@@ -11,6 +11,7 @@ module Dopairb
11
11
 
12
12
  def title = "FAILED"
13
13
  def palette = Color::BLOOD
14
+ def sfx = :error
14
15
 
15
16
  def setup
16
17
  @scale = best_scale(title)
@@ -84,6 +85,7 @@ module Dopairb
84
85
  # SyntaxError: the input line cracks at the error position.
85
86
  class Crack < FailureScene
86
87
  def title = "SYNTAX BREAK"
88
+ def sfx = :crack
87
89
 
88
90
  def setup
89
91
  super
@@ -174,6 +176,8 @@ module Dopairb
174
176
  # NoMethodError: the link between receiver and method snaps.
175
177
  class Snap < FailureScene
176
178
  def title = "NO METHOD"
179
+ def sfx = :snap
180
+ def impact_at = 0.28
177
181
  def palette = Color::NEON
178
182
  def height = 4
179
183
  def length = 0.9
@@ -231,6 +235,7 @@ module Dopairb
231
235
  def title = event.info[:type] == :argument ? "ARGUMENT CLASH" : "TYPE CLASH"
232
236
  def palette = Color::FIRE
233
237
  def length = 0.95
238
+ def sfx = :crack
234
239
 
235
240
  def setup
236
241
  super
@@ -280,6 +285,7 @@ module Dopairb
280
285
 
281
286
  # Ctrl-C: the collected charge scatters and fades.
282
287
  class Scatter < Scene
288
+ def sfx = :interrupt
283
289
  def height = 2
284
290
  def length = 0.65
285
291
  def big? = true