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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e71c4e0e91c35575f8ac0ebc641873bc4fc5af8dfc78b3ab4b17aa2b9ab3057
4
- data.tar.gz: df276b5e5997edf7eaf128f022c6475a39b8db2949317e64db718bc8c91bf15a
3
+ metadata.gz: cbc57c42e0e25caed6e783bf761e9edda9822c1d853fa4d55c144ce6704a875e
4
+ data.tar.gz: a3d81e85abe05052cbbf99907162215b4d27796fd0ed14827d83190c0252672e
5
5
  SHA512:
6
- metadata.gz: 1fed2568ae4137a3da8d88c4d7ce74cf79ec6fba2f82d5d823a5cae1e063f91d35363910f7b68e3e154de0491b249e6125c1eea72c45fd6d399ed0b86244c055
7
- data.tar.gz: 7a117f3e7a8ae10726a56b3469c8580fe7e68e0a1cad2a4c1b145ea8e480ff35d1a90ccd14e37a249e3078da1d095998b1c55c0e14ddd56ed1ae3e3917493387
6
+ metadata.gz: 9522cb64cb142280f6ed6070e0e92884206fb0a7eccd1d289202c64ed9cb28a2cbb14d291a686362ce5797654910dec25f91ab2383193e82782cbd9bcceb7943
7
+ data.tar.gz: 6cae99bcfcdcf7419a2e943fda49cbb97f8603a7e7a09d17f23bf5beb165b8ab85e6cea0f99c6d20f0daa6304dcb13341c5ef9f2781e635642003a4e6dd4f11f
data/README.md CHANGED
@@ -3,6 +3,10 @@
3
3
  An interactive Ruby shell that answers every keystroke, evaluation, result and exception with loud terminal effects.
4
4
  It is built as an IRB extension. The design spec is [spec.md](spec.md) (in Japanese).
5
5
 
6
+ https://github.com/user-attachments/assets/b45e4a97-ffde-4fb2-be73-ef69d5c8af42
7
+
8
+ A 70-second session with sound (`dopairb`, the defaults); also in [docs/dopairb-demo.mp4](docs/dopairb-demo.mp4). The scripts that recorded it are in [docs/recording](docs/recording).
9
+
6
10
  ```
7
11
  dopairb(main):001> (1..100).sum COMBO 00 SCORE 33 [⣿⣿⣀⣀⣀⣀⣀⣀] x1.5
8
12
  ⠂⠌⠟⠃ <- sparks, afterglow and HUD on every keystroke
@@ -29,21 +33,29 @@ To try it without installing, run `ruby exe/dopairb` in the checkout.
29
33
  ## Usage
30
34
 
31
35
  ```console
32
- $ dopairb # start it (IRB options are passed through)
36
+ $ dopairb # start it, at full blast with sound (IRB options are passed through)
33
37
  $ dopairb --calm # quieter: low intensity, no flash, shorter effects
34
- $ dopairb --max # everything at maximum; the biggest moments go full screen
35
- $ dopairb --party # max + full-screen flash + bell
38
+ $ dopairb --party # max + full-screen flash + sound effects
39
+ $ dopairb --intensity=normal --no-sound # the tamer pre-0.4.0 defaults
36
40
  $ dopairb --no-flash # suppress flashing, available from the very first start
37
41
  $ DOPAIRB="intensity=low,flash=off" dopairb
38
42
  ```
39
43
 
40
- To enable it in your regular `irb`, add this to `~/.irbrc`:
44
+ To turn your regular `irb` into dopairb, add this to `~/.irbrc`:
41
45
 
42
46
  ```ruby
43
- require "dopairb"
44
- Dopairb.enable # also e.g. Dopairb.enable(flash: :off, intensity: :low)
47
+ begin
48
+ require "dopairb"
49
+ Dopairb.enable # also e.g. Dopairb.enable(sound: :off, intensity: :normal)
50
+ rescue LoadError
51
+ # a Ruby without dopairb keeps plain irb
52
+ end
45
53
  ```
46
54
 
55
+ The title (and the loading screen on a cold start) plays right before the first prompt, as with the `dopairb` command.
56
+ Sessions opened by `binding.irb` get the effects too; `Dopairb.enable(binding_irb: false)` keeps them plain.
57
+ Settings for a single run (`dopairb` options and `$DOPAIRB`) win over the ones given to `Dopairb.enable` in `.irbrc`.
58
+
47
59
  Inside a session, the `dopa` command changes settings:
48
60
 
49
61
  ```
@@ -51,6 +63,9 @@ dopa show settings and session stats
51
63
  dopa off|low|normal|max
52
64
  dopa calm / dopa party
53
65
  dopa demo play every effect once (does not change your score)
66
+ dopa loading show the loading screen
67
+ dopa gallery the bonus art you have collected (`dopa gallery starry_night` shows one)
68
+ dopa gallery tour a full-screen slideshow of all 100; uncollected ones appear as silhouettes
54
69
  dopa flash=off duration=0.5 ...
55
70
  ```
56
71
 
@@ -58,10 +73,10 @@ dopa flash=off duration=0.5 ...
58
73
 
59
74
  | Setting | Values | Default | Meaning |
60
75
  | --- | --- | --- | --- |
61
- | `intensity` | off / low / normal / max | normal | Overall amount. `off` behaves exactly like plain IRB |
76
+ | `intensity` | off / low / normal / max | max | Overall amount. `off` behaves exactly like plain IRB |
62
77
  | `motion` | on / off | on | `off` disables animation (badges only) |
63
78
  | `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 |
79
+ | `sound` | off / bell / sfx | sfx | `sfx` plays synthesized sound effects, `bell` rings the terminal bell on big moments |
65
80
  | `hud` | on / off | on | COMBO / SCORE / CHARGE on the right of the prompt line |
66
81
  | `duration` | 0.1–5 | 1.0 | Length multiplier for every effect |
67
82
  | `color` | auto / truecolor / 256 / 16 / none | auto | `none` when `NO_COLOR` is set |
@@ -69,6 +84,7 @@ dopa flash=off duration=0.5 ...
69
84
  | `charge` | 0–0.3 s | 0.12 | Wind-up before a fast result lands; 0 disables it |
70
85
  | `intro` | on / off | on | Title animation at startup |
71
86
  | `keys` | on / off | on | Per-keystroke effects |
87
+ | `binding_irb` | on / off | on | Effects in sessions opened by `binding.irb` too |
72
88
 
73
89
  ## Effects
74
90
 
@@ -78,6 +94,7 @@ When several fire at once, only the biggest plays. The smaller ones are folded i
78
94
  | Moment | Effect |
79
95
  | --- | --- |
80
96
  | Typing | A spark trail right of the cursor, a brief glow on the typed character, embers falling below, a x1.0–x3.0 multiplier |
97
+ | Typing streak of 8, 16, 32 … 1024 keys | Powers of two, in big half-block letters under the input: `8-BIT RUSH!`, `16-BIT COMBO!`, `32-BIT BLAZE!!`, `64-BIT OVERDRIVE!!`, `128-BIT HYPER MODE!!`, `0x100 OVERFLOW!!!`, `2^9 GODSPEED!!!`, `1KiB TYPING LEGEND!!!`, with a bonus of 4 points per key |
81
98
  | Resuming after a pause | Reignites with `CHARGE!` |
82
99
  | Backspace / Delete | The deleted character shatters and falls. The next keystroke shows `RECOVERY` |
83
100
  | Cursor movement | A light streak in the direction of travel |
@@ -102,12 +119,48 @@ When several fire at once, only the biggest plays. The smaller ones are folded i
102
119
  | Other exceptions | A red flash and a glitching `FAILED` |
103
120
  | Ctrl-C | The stored charge scatters, `INTERRUPTED` |
104
121
  | First eval / COMBO 5, 10, 25… / eval count 10, 50, 100… | `FIRST HIT!` / `COMBO 10!` / `100 EVALS` |
122
+ | Lucky success (about 1 in 10, up to 1 in 5 with a full CHARGE) | `CRITICAL!!`: a slash, a red flash and x2 / x4 / x8 points |
123
+ | Very lucky success (1 in 67) | `JACKPOT!!`: three slot reels stop on 7-7-7, coins rain, x16 points |
124
+ | COMBO 10 and beyond | `FEVER TIME!`: every point counts double until the next error; the HUD glows in rainbow `FEVER!` |
125
+ | Beating your all-time best combo | `BEST COMBO!` |
126
+ | Level up, jackpot, or a lucky drop (see [Career](#career)) | `LEVEL UP!` / `JACKPOT PRIZE` / `ART DROP!`, then a famous painting is unveiled full screen as bonus art |
105
127
  | Numeric record (more than doubled) | `NEW RECORD` |
106
128
  | 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 |
129
+ | 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
130
 
109
131
  Error effects name the kind of error. They never make fun of the failure.
110
132
 
133
+ ## Career
134
+
135
+ Between sessions dopairb keeps a small profile: lifetime XP (the sum of your scores), level, best score, best combo, a day streak and the bonus art you have collected.
136
+ Levels need powers of two: LV 2 at 512 XP, LV 3 at 1,024, LV 4 at 2,048, and so on.
137
+ Each level-up and each jackpot unveils a masterpiece full screen, and now and then (about 1 success in 50) one simply drops: `ART DROP!`. There are 100 public-domain works to collect, from the Lascaux horses and Hokusai's *Great Wave* through Vermeer, Turner and van Gogh to Klimt, Kandinsky and Mondrian, all painted procedurally in half blocks (no image files ship with the gem). New ones come first; `dopa gallery` lists yours. (Spoilers: all 100 are in [docs/gallery.png](docs/gallery.png).)
138
+ The startup title shows your level and day streak, and the finale shows `NEW BEST!` and an XP bar.
139
+
140
+ The profile is `~/.local/state/dopairb/profile.json` (`$XDG_STATE_HOME` is honored). Set `DOPAIRB_PROFILE=path` to use another file, or `DOPAIRB_PROFILE=off` to keep nothing; levels are then not shown.
141
+ Non-interactive runs never touch it.
142
+
143
+ `DOPAIRB_SEED=n` makes the luck (critical hits, jackpots, particles) repeatable, which helps when recording a demo.
144
+
145
+ ## Sound
146
+
147
+ 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.
148
+ All sounds are synthesized in Ruby at runtime and cached as WAV files in the temp directory. No audio files ship with the gem.
149
+
150
+ They are played by the first player found:
151
+
152
+ - `paplay` (PulseAudio, including WSLg on WSL2)
153
+ - `afplay` (macOS)
154
+ - `aplay` (ALSA)
155
+ - `powershell.exe` on WSL without WSLg
156
+
157
+ 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`.
158
+ 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.
159
+ Players run as separate processes, so the REPL never waits for them.
160
+ PowerShell takes a few hundred milliseconds to start, so with it only the result effects make sound, not every keystroke.
161
+ When no player is found, `sfx` falls back to the terminal bell on big moments.
162
+ Sound plays on the machine that runs dopairb, so over ssh you will not hear it.
163
+
111
164
  ## Readable, never broken
112
165
 
113
166
  - IRB evaluates and prints as usual. `=> value`, exception messages and backtraces come from IRB itself.
@@ -151,7 +204,7 @@ The rest is terminal-independent logic:
151
204
  - Scores live for the session only and are not saved.
152
205
  - Effects use Unicode. Any glyph that is not one column wide is swapped for ASCII or braille at runtime.
153
206
  - Start it with the `dopairb` command, or call `Dopairb.enable` in `.irbrc`.
154
- - IRB sessions entered via `binding.irb` get the same Context hook. The result screen appears only when the outermost session ends.
207
+ - IRB sessions entered via `binding.irb` get the same Context hook (unless `binding_irb=off`; detected with `IRB::Context#from_binding?`). The result screen appears only when the outermost session ends.
155
208
 
156
209
  ## Performance
157
210
 
@@ -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
@@ -15,13 +15,13 @@ module Dopairb
15
15
  o.separator ""
16
16
  o.separator "IRB with fireworks. Every other option is handed to IRB."
17
17
  o.separator ""
18
- o.on("--intensity=LEVEL", Config::INTENSITIES.map(&:to_s), "off / low / normal (default) / max") { |v| settings << [:intensity, v] }
18
+ o.on("--intensity=LEVEL", Config::INTENSITIES.map(&:to_s), "off / low / normal / max (default)") { |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
- 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]]) }
20
+ o.on("--max", "everything at maximum (the default)") { settings << [:intensity, :max] }
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 / bell / sfx (default)") { |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] }
@@ -61,9 +61,7 @@ module Dopairb
61
61
  ours, irb_args = split_args(argv.dup)
62
62
  settings = []
63
63
  parser(settings).parse!(ours)
64
- settings.each do |k, v|
65
- k == :string ? Dopairb.config.apply_string(v) : Dopairb.config.set(k, v)
66
- end
64
+ settings.each { |k, v| Dopairb.override(k, v) }
67
65
  run_irb(irb_args)
68
66
  rescue OptionParser::ParseError, ArgumentError => e
69
67
  warn "dopairb: #{e.message}"
@@ -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",
@@ -20,15 +21,16 @@ module Dopairb
20
21
  charge: "seconds (max 0.3) of wind-up before a fast result lands; 0 disables",
21
22
  intro: "on / off -- title animation at startup",
22
23
  keys: "on / off -- per-keystroke effects while typing",
24
+ binding_irb: "on / off -- effects in sessions opened by binding.irb too",
23
25
  }.freeze
24
26
 
25
- attr_reader :intensity, :motion, :flash, :sound, :hud, :duration, :color, :trail, :charge, :intro, :keys
27
+ attr_reader :intensity, :motion, :flash, :sound, :hud, :duration, :color, :trail, :charge, :intro, :keys, :binding_irb
26
28
 
27
29
  def initialize
28
- @intensity = :normal
30
+ @intensity = :max
29
31
  @motion = true
30
32
  @flash = :soft
31
- @sound = false
33
+ @sound = :sfx
32
34
  @hud = true
33
35
  @duration = 1.0
34
36
  @color = :auto
@@ -36,6 +38,7 @@ module Dopairb
36
38
  @charge = 0.12
37
39
  @intro = true
38
40
  @keys = true
41
+ @binding_irb = true
39
42
  end
40
43
 
41
44
  def level
@@ -56,7 +59,10 @@ module Dopairb
56
59
  v = bool_or_nil(value)
57
60
  @color = v.nil? ? enum(value, COLORS, name) : (v ? :auto : :none)
58
61
  when :trail then @trail = enum(value, TRAILS, name)
59
- when :motion, :sound, :hud, :intro, :keys
62
+ when :sound
63
+ v = bool_or_nil(value)
64
+ @sound = v.nil? ? enum(value, SOUNDS, name) : (v ? :sfx : :off)
65
+ when :motion, :hud, :intro, :keys, :binding_irb
60
66
  v = bool_or_nil(value)
61
67
  raise ArgumentError, "#{name} expects on/off, got #{value.inspect}" if v.nil?
62
68
  instance_variable_set(:"@#{name}", v)
data/lib/dopairb/demo.rb CHANGED
@@ -7,8 +7,9 @@ module Dopairb
7
7
 
8
8
  module_function
9
9
 
10
- def ev(kind, info, flags: [], gain: 120, combo: 3, streak: 0)
11
- E.new(kind: kind, tier: nil, info: info, flags: flags, gain: gain, combo: combo, streak: streak, score: 12_480)
10
+ def ev(kind, info, flags: [], gain: 120, combo: 3, streak: 0, mult: 1, level: 5)
11
+ E.new(kind: kind, tier: nil, info: info, flags: flags, gain: gain, combo: combo, streak: streak, score: 12_480,
12
+ mult: mult, level: level)
12
13
  end
13
14
 
14
15
  def cases
@@ -33,9 +34,24 @@ module Dopairb
33
34
  ["1 + 1", ev(:success, { type: :integer, value: 2 }, flags: [:comeback], combo: 1, streak: 5, gain: 600)],
34
35
  ["loop {}", ev(:interrupt, { charge: 60 })],
35
36
  ["2**128", ev(:success, { type: :integer, value: 2**128 }, flags: [:new_record, :record_jump], gain: 420)],
37
+ ["[3, 1, 2].sort", ev(:success, { type: :array, size: 3 }, flags: [:critical], gain: 960, mult: 8)],
38
+ ["12.fdiv(5)", ev(:success, { type: :float, value: 2.4 }, flags: [:fever], combo: 12, gain: 1100, mult: 2)],
39
+ ["21.0 * 2", ev(:success, { type: :float, value: 42.0 }, flags: [:fever, :combo_best], combo: 13, gain: 1200, mult: 2)],
40
+ ["rand(777)", ev(:success, { type: :integer, value: 777 }, flags: [:jackpot], gain: 6_400, mult: 16)],
41
+ ["'level' * 2", ev(:success, { type: :string, length: 10, head: "levellevel", art: :great_wave, art_new: true, gallery: [1, 5] },
42
+ flags: [:level_up], gain: 200, level: 6)],
36
43
  ]
37
44
  end
38
45
 
46
+ # Let the sound ring out so the next one does not step on it.
47
+ def wait_for_sound(config, scene, t0)
48
+ return unless config.sound == :sfx && scene&.sfx && Sound.available?
49
+ ends = (scene.pre + scene.impact_at) * config.duration + Sound.tail(scene.sfx)
50
+ wait = ends - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0)
51
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + wait
52
+ sleep 0.02 while Process.clock_gettime(Process::CLOCK_MONOTONIC) < deadline && !Term.input_pending?
53
+ end
54
+
39
55
  def run(mod)
40
56
  d = mod.session&.director || Director.new(mod.config)
41
57
  unless mod.active?
@@ -51,9 +67,11 @@ module Dopairb
51
67
  if event.kind == :failure && event.info[:type] == :syntax
52
68
  event.info[:column] = [code.size, event.info[:column]].min
53
69
  end
54
- d.play_event(event, input: shot, pre: event.kind == :success ? mod.config.charge : 0)
70
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
71
+ scene = d.play_event(event, input: shot, pre: event.kind == :success ? mod.config.charge : 0)
55
72
  Term.write("\e[2m=> (demo)\e[0m\r\n")
56
- sleep 0.15
73
+ wait_for_sound(mod.config, scene, t0)
74
+ sleep 0.4
57
75
  end
58
76
  Term.write("\e[0m")
59
77
  end
@@ -6,7 +6,7 @@ module Dopairb
6
6
  class Director
7
7
  FPS = 40.0
8
8
 
9
- attr_reader :config
9
+ attr_reader :config, :rng
10
10
 
11
11
  def initialize(config, rng: Random.new)
12
12
  @config = config
@@ -50,20 +50,35 @@ module Dopairb
50
50
  Scenes::Banner.new(ctx, event, text: text, palette: palette, mega: true, rainbow: rainbow, sub: sub)
51
51
  end
52
52
  end
53
- if event.flag?(:comeback)
53
+ full = lambda { self.ctx(rows: ctx.rows, cols: ctx.w + 1).tap { |c| c.depth = ctx.depth } }
54
+ if event.flag?(:jackpot)
55
+ Scenes::Jackpot.new(ctx, event)
56
+ elsif event.flag?(:level_up) || (event.flag?(:art_drop) && info[:art])
57
+ if @config.motion && @config.level >= 2 && info[:art] && Scenes::Masterpiece.fits?(ctx.rows, ctx.w + 1)
58
+ Scenes::Masterpiece.new(full.(), event)
59
+ elsif event.flag?(:level_up)
60
+ mega.("LEVEL UP!", Color::NEON, rainbow: true, sub: "LV #{event.level}")
61
+ else
62
+ Scenes::Banner.new(ctx, event, text: "ART DROP!", palette: Color::GOLD, sub: Gallery.find(info[:art])&.title)
63
+ end
64
+ elsif event.flag?(:comeback)
54
65
  mega.(event.streak >= 2 ? "COMEBACK!" : "FIXED!", Color::GOLD, rainbow: event.streak >= 2)
55
66
  elsif event.flag?(:eval_milestone)
56
67
  mega.("#{Fx.number_with_commas(event_evals(event))} EVALS", Color::NEON, rainbow: true)
57
68
  elsif event.flag?(:record_jump)
58
69
  mega.("NEW RECORD", Color::GOLD, rainbow: true, sub: number_text(info))
59
70
  elsif event.flag?(:combo_mega)
60
- mega.("COMBO #{event.combo}!", Color::FIRE, rainbow: event.combo >= 50)
71
+ mega.("COMBO #{event.combo}!", Color::FIRE, rainbow: event.combo >= 50, sub: event.flag?(:fever_start) ? "FEVER TIME! ALL POINTS x2" : nil)
72
+ elsif event.flag?(:combo_best)
73
+ Scenes::Banner.new(ctx, event, text: "BEST COMBO!", palette: Color::FIRE, sub: "ALL-TIME BEST #{event.combo}")
61
74
  elsif info[:type] == :definition
62
75
  Scenes::Unlock.new(ctx, event)
63
76
  elsif event.flag?(:combo_milestone) && event.combo >= 5
64
77
  Scenes::Banner.new(ctx, event, text: "COMBO #{event.combo}", palette: Color::FIRE)
65
78
  elsif event.flag?(:first_hit)
66
79
  Scenes::Banner.new(ctx, event, text: "FIRST HIT!", palette: Color::ICE)
80
+ elsif event.flag?(:critical) && !counter?(event)
81
+ Scenes::Critical.new(ctx, event)
67
82
  elsif event.flag?(:output_rain)
68
83
  Scenes::Rain.new(ctx, event)
69
84
  else
@@ -71,6 +86,11 @@ module Dopairb
71
86
  end
72
87
  end
73
88
 
89
+ def counter?(event)
90
+ v = event.info[:value]
91
+ %i[integer float].include?(event.info[:type]) && (v.abs >= 10_000 || event.flag?(:new_record))
92
+ end
93
+
74
94
  def value_scene(event, ctx)
75
95
  info = event.info
76
96
  case info[:type]
@@ -90,32 +110,76 @@ module Dopairb
90
110
  return if @config.off?
91
111
  c = ctx(input: input, pre: pre)
92
112
  c.input = nil if input && input.size + 12 > c.rows
93
- play(scene_for(event, c))
113
+ scene = scene_for(event, c)
114
+ play(scene)
115
+ encore(event, c) if Scenes::Jackpot === scene
116
+ scene
117
+ end
118
+
119
+ # A jackpot pays out a masterpiece too: too good to fold away.
120
+ def encore(event, c)
121
+ return unless event.info[:art] && @config.motion && @config.level >= 2
122
+ return if Term.input_pending? || !Scenes::Masterpiece.fits?(c.rows, c.w + 1)
123
+ full = ctx(rows: c.rows, cols: c.w + 1).tap { |x| x.depth = c.depth }
124
+ play(Scenes::Masterpiece.new(full, event))
125
+ end
126
+
127
+ # Cute loading screen until ready.call is true (e.g. the intro sound exists).
128
+ def loading(&ready)
129
+ return if @config.off?
130
+ scene = Scenes::Loading.new(ctx, ready)
131
+ return unless @config.motion && fits?(scene)
132
+ run(scene)
133
+ rescue Exception => e # rubocop:disable Lint/RescueException
134
+ raise if SystemExit === e || (SignalException === e && !(Interrupt === e))
94
135
  end
95
136
 
96
- def intro
137
+ def intro(career = nil)
97
138
  return if @config.off? || !@config.intro
98
- play(Scenes::Intro.new(ctx), force_trail: true)
139
+ play(Scenes::Intro.new(ctx, career), force_trail: true)
99
140
  end
100
141
 
101
142
  def outro(stats)
102
143
  return if @config.off?
103
- play(Scenes::Outro.new(ctx, stats), force_trail: true)
144
+ c = ctx
145
+ scene = if @config.motion && Scenes::Finale.fits?(c.rows, c.w + 1) && @config.level >= 2
146
+ Scenes::Finale.new(c, stats)
147
+ else
148
+ Scenes::Outro.new(c, stats)
149
+ end
150
+ # render the synced track now so it does not start late
151
+ Sound.file(scene.sfx, @config.duration) if @config.sound == :sfx && scene.sfx && Sound.available?
152
+ play(scene, force_trail: true)
104
153
  end
105
154
 
106
155
  def play(scene, force_trail: false)
107
156
  animated = @config.motion && scene.ctx.w >= 20 && fits?(scene)
108
157
  animated = false if animated && Term.input_pending?
158
+ sound(scene, animated) unless !animated && @config.motion
109
159
  run(scene) if animated
110
- Term.bell if @config.sound && scene.bell? && animated
111
160
  emit_trail(scene, force_trail)
112
161
  rescue Exception => e # rubocop:disable Lint/RescueException
113
162
  raise if SystemExit === e || (SignalException === e && !(Interrupt === e))
114
163
  # Never let an effect break the REPL; Ctrl-C during an effect just skips it.
164
+ Dopairb.debug(e) unless Interrupt === e
115
165
  end
116
166
 
117
167
  private
118
168
 
169
+ def sound(scene, animated)
170
+ case @config.sound
171
+ when :bell
172
+ Term.bell if scene.bell?
173
+ when :sfx
174
+ if Sound.available?
175
+ delay = animated ? (scene.pre + scene.impact_at) * @config.duration : 0.0
176
+ Sound.play(scene.sfx, delay: delay, stretch: @config.duration) if scene.sfx
177
+ elsif scene.bell?
178
+ Term.bell
179
+ end
180
+ end
181
+ end
182
+
119
183
  def event_evals(_event)
120
184
  Dopairb.session&.game&.evals.to_i
121
185
  end
@@ -126,8 +190,7 @@ module Dopairb
126
190
 
127
191
  def fits?(scene)
128
192
  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
193
+ scene.alt? || scene.height + top <= scene.ctx.rows - 1
131
194
  end
132
195
 
133
196
  def emit_trail(scene, force)
@@ -140,7 +203,7 @@ module Dopairb
140
203
  end
141
204
 
142
205
  def run(scene)
143
- alt = scene.is_a?(Scenes::Banner) && scene.height == scene.ctx.rows && scene.mega?
206
+ alt = scene.alt?
144
207
  top = !alt && scene.top? ? scene.ctx.input.rows : []
145
208
  stage = alt ? AltStage.new(scene.height) : Stage.new(scene.height, top: top)
146
209
  depth = scene.depth
@@ -153,7 +216,7 @@ module Dopairb
153
216
  loop do
154
217
  break if Term.input_pending? || !stage.open?
155
218
  t = (now - t0) / @config.duration
156
- break if t >= scene.total_length
219
+ break if t >= scene.total_length || scene.done?(t)
157
220
  lines = []
158
221
  unless top.empty?
159
222
  tc = Canvas.new(scene.ctx.w, top.size)
data/lib/dopairb/fx.rb CHANGED
@@ -111,6 +111,29 @@ module Dopairb
111
111
  width * scale
112
112
  end
113
113
 
114
+ # The big font squeezed into three rows with half blocks (▀ ▄ █).
115
+ # Returns the width drawn, or nil when the terminal lacks the glyphs.
116
+ def half_text(canvas, text, x, y, color_fn)
117
+ up = Term.glyph("▀", nil)
118
+ down = Term.glyph("▄", nil)
119
+ full = Term.glyph("█", nil)
120
+ return nil unless up && down && full
121
+ bm = Font.bitmap(text)
122
+ width = bm[0].size
123
+ 3.times do |r|
124
+ top = bm[r * 2]
125
+ bot = bm[r * 2 + 1] || []
126
+ width.times do |cx|
127
+ a = top[cx]
128
+ b = bot[cx]
129
+ next unless a || b
130
+ ch = a && b ? full : (a ? up : down)
131
+ canvas.put(x + cx, y + r, ch, color_fn.(cx, r, cx.fdiv([width - 1, 1].max)))
132
+ end
133
+ end
134
+ width
135
+ end
136
+
114
137
  def text_fits?(text, w, scale = 1)
115
138
  Font.width(text, scale: scale) <= w
116
139
  end