dopairb 0.1.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.
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ # RGB helpers and SGR encoding for truecolor / 256 / 16 / no-color terminals.
5
+ module Color
6
+ FIRE = [[255, 255, 230], [255, 236, 110], [255, 160, 20], [235, 60, 10], [120, 10, 30], [40, 0, 20]].freeze
7
+ ICE = [[240, 255, 255], [130, 230, 255], [40, 140, 255], [30, 50, 190], [20, 10, 80]].freeze
8
+ GOLD = [[255, 255, 240], [255, 230, 120], [255, 190, 40], [200, 120, 0], [90, 40, 0]].freeze
9
+ TOXIC = [[240, 255, 230], [150, 255, 120], [40, 220, 90], [0, 130, 70], [0, 50, 30]].freeze
10
+ BLOOD = [[255, 230, 230], [255, 90, 90], [220, 20, 40], [130, 0, 20], [50, 0, 10]].freeze
11
+ NEON = [[255, 240, 255], [255, 110, 230], [190, 60, 255], [80, 40, 220], [30, 10, 90]].freeze
12
+ SMOKE = [[235, 235, 235], [170, 170, 180], [110, 110, 125], [60, 60, 70], [30, 30, 35]].freeze
13
+ STEEL = [[230, 240, 255], [150, 170, 200], [90, 105, 140], [50, 60, 85], [25, 28, 40]].freeze
14
+
15
+ BASIC16 = [
16
+ [0, 0, 0], [205, 0, 0], [0, 205, 0], [205, 205, 0], [0, 0, 238], [205, 0, 205], [0, 205, 205], [229, 229, 229],
17
+ [127, 127, 127], [255, 0, 0], [0, 255, 0], [255, 255, 0], [92, 92, 255], [255, 0, 255], [0, 255, 255], [255, 255, 255],
18
+ ].freeze
19
+
20
+ module_function
21
+
22
+ def clamp(x)
23
+ x = x.to_i
24
+ x < 0 ? 0 : (x > 255 ? 255 : x)
25
+ end
26
+
27
+ def mix(a, b, t)
28
+ t = t < 0 ? 0.0 : (t > 1 ? 1.0 : t)
29
+ [clamp(a[0] + (b[0] - a[0]) * t), clamp(a[1] + (b[1] - a[1]) * t), clamp(a[2] + (b[2] - a[2]) * t)]
30
+ end
31
+
32
+ def scale(c, k)
33
+ [clamp(c[0] * k), clamp(c[1] * k), clamp(c[2] * k)]
34
+ end
35
+
36
+ def hsv(h, s = 1.0, v = 1.0)
37
+ h = (h % 360) / 60.0
38
+ i = h.floor
39
+ f = h - i
40
+ p = v * (1 - s)
41
+ q = v * (1 - s * f)
42
+ t = v * (1 - s * (1 - f))
43
+ r, g, b = [[v, t, p], [q, v, p], [p, v, t], [p, q, v], [t, p, v], [v, p, q]][i % 6]
44
+ [clamp(r * 255), clamp(g * 255), clamp(b * 255)]
45
+ end
46
+
47
+ def rainbow(t, s = 0.85, v = 1.0)
48
+ hsv(t * 360, s, v)
49
+ end
50
+
51
+ # t in 0..1 walks through the stops
52
+ def ramp(stops, t)
53
+ t = t < 0 ? 0.0 : (t > 1 ? 1.0 : t)
54
+ pos = t * (stops.size - 1)
55
+ i = pos.floor
56
+ return stops[-1] if i >= stops.size - 1
57
+ mix(stops[i], stops[i + 1], pos - i)
58
+ end
59
+
60
+ def sgr(fg, bg, bold, depth)
61
+ codes = ["0"]
62
+ codes << "1" if bold
63
+ if depth == :none
64
+ codes << "7" if bg && luminance(bg) > 90
65
+ else
66
+ codes << fg_code(fg, depth) if fg
67
+ codes << bg_code(bg, depth) if bg
68
+ end
69
+ "\e[#{codes.join(';')}m"
70
+ end
71
+
72
+ def fg(c, depth, bold: false)
73
+ sgr(c, nil, bold, depth)
74
+ end
75
+
76
+ def reset = "\e[0m"
77
+
78
+ def fg_code(c, depth)
79
+ case depth
80
+ when :truecolor then "38;2;#{c[0]};#{c[1]};#{c[2]}"
81
+ when :"256" then "38;5;#{to256(c)}"
82
+ else
83
+ i = to16(c)
84
+ (i < 8 ? 30 + i : 82 + i).to_s
85
+ end
86
+ end
87
+
88
+ def bg_code(c, depth)
89
+ case depth
90
+ when :truecolor then "48;2;#{c[0]};#{c[1]};#{c[2]}"
91
+ when :"256" then "48;5;#{to256(c)}"
92
+ else
93
+ i = to16(c)
94
+ (i < 8 ? 40 + i : 92 + i).to_s
95
+ end
96
+ end
97
+
98
+ def luminance(c)
99
+ 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2]
100
+ end
101
+
102
+ def to256(c)
103
+ r, g, b = c
104
+ if (r - g).abs < 10 && (g - b).abs < 10
105
+ return 16 if r < 8
106
+ return 231 if r > 246
107
+ return 232 + ((r - 8) / 10.0).round.clamp(0, 23)
108
+ end
109
+ q = ->(v) { v < 48 ? 0 : (v < 115 ? 1 : ((v - 35) / 40).clamp(0, 5)) }
110
+ 16 + 36 * q.(r) + 6 * q.(g) + q.(b)
111
+ end
112
+
113
+ def to16(c)
114
+ best = 0
115
+ bd = Float::INFINITY
116
+ BASIC16.each_with_index do |bc, i|
117
+ d = (bc[0] - c[0])**2 * 2 + (bc[1] - c[1])**2 * 4 + (bc[2] - c[2])**2 * 3
118
+ if d < bd
119
+ bd = d
120
+ best = i
121
+ end
122
+ end
123
+ best
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ # User-tunable knobs. See `dopa help` or README for the meaning of each.
5
+ class Config
6
+ INTENSITIES = %i[off low normal max].freeze
7
+ FLASHES = %i[off soft full].freeze
8
+ COLORS = %i[auto truecolor 256 16 none].freeze
9
+ TRAILS = %i[none big all].freeze
10
+
11
+ DESCRIPTIONS = {
12
+ intensity: "off / low / normal / max -- overall amount of effects",
13
+ motion: "on / off -- animations (off = static badges only)",
14
+ flash: "off / soft / full -- soft = region only, full = whole screen",
15
+ sound: "on / off -- terminal bell on big moments",
16
+ hud: "on / off -- COMBO / SCORE / CHARGE next to the prompt",
17
+ duration: "float multiplier for every effect length (e.g. 0.5, 2)",
18
+ color: "auto / truecolor / 256 / 16 / none",
19
+ trail: "none / big / all -- which effects leave a one-line badge",
20
+ charge: "seconds (max 0.3) of wind-up before a fast result lands; 0 disables",
21
+ intro: "on / off -- title animation at startup",
22
+ keys: "on / off -- per-keystroke effects while typing",
23
+ }.freeze
24
+
25
+ attr_reader :intensity, :motion, :flash, :sound, :hud, :duration, :color, :trail, :charge, :intro, :keys
26
+
27
+ def initialize
28
+ @intensity = :normal
29
+ @motion = true
30
+ @flash = :soft
31
+ @sound = false
32
+ @hud = true
33
+ @duration = 1.0
34
+ @color = :auto
35
+ @trail = :big
36
+ @charge = 0.12
37
+ @intro = true
38
+ @keys = true
39
+ end
40
+
41
+ def level
42
+ INTENSITIES.index(@intensity)
43
+ end
44
+
45
+ def off? = @intensity == :off
46
+ def max? = @intensity == :max
47
+
48
+ def set(name, value)
49
+ name = name.to_s.strip.downcase.tr("-", "_").to_sym
50
+ case name
51
+ when :intensity then @intensity = enum(value, INTENSITIES, name)
52
+ when :flash
53
+ v = bool_or_nil(value)
54
+ @flash = v.nil? ? enum(value, FLASHES, name) : (v ? :soft : :off)
55
+ when :color
56
+ v = bool_or_nil(value)
57
+ @color = v.nil? ? enum(value, COLORS, name) : (v ? :auto : :none)
58
+ when :trail then @trail = enum(value, TRAILS, name)
59
+ when :motion, :sound, :hud, :intro, :keys
60
+ v = bool_or_nil(value)
61
+ raise ArgumentError, "#{name} expects on/off, got #{value.inspect}" if v.nil?
62
+ instance_variable_set(:"@#{name}", v)
63
+ when :duration
64
+ f = Float(value.to_s)
65
+ raise ArgumentError, "duration must be in 0.1..5" unless (0.1..5).cover?(f)
66
+ @duration = f
67
+ when :charge
68
+ f = Float(value.to_s)
69
+ raise ArgumentError, "charge must be in 0..0.3 (seconds)" unless (0..0.3).cover?(f)
70
+ @charge = f
71
+ else
72
+ raise ArgumentError, "unknown setting #{name.inspect} (known: #{DESCRIPTIONS.keys.join(', ')})"
73
+ end
74
+ self
75
+ end
76
+
77
+ # "intensity=max,flash=off" (DOPAIRB env var / --set option)
78
+ def apply_string(str)
79
+ str.to_s.split(/[,\s]+/).reject(&:empty?).each do |pair|
80
+ k, v = pair.split("=", 2)
81
+ raise ArgumentError, "expected key=value, got #{pair.inspect}" unless v
82
+ set(k, v)
83
+ end
84
+ self
85
+ end
86
+
87
+ def to_h
88
+ DESCRIPTIONS.keys.to_h { |k| [k, public_send(k)] }
89
+ end
90
+
91
+ def describe
92
+ to_h.map { |k, v| format(" %-10s %-8s %s", k, show(v), DESCRIPTIONS[k]) }.join("\n")
93
+ end
94
+
95
+ private
96
+
97
+ def show(v)
98
+ case v
99
+ when true then "on"
100
+ when false then "off"
101
+ else v.to_s
102
+ end
103
+ end
104
+
105
+ def enum(value, list, name)
106
+ sym = value.to_s.strip.downcase.to_sym
107
+ return sym if list.include?(sym)
108
+ raise ArgumentError, "#{name} must be one of #{list.join(' / ')}, got #{value.inspect}"
109
+ end
110
+
111
+ def bool_or_nil(value)
112
+ case value.to_s.strip.downcase
113
+ when "on", "true", "yes", "1" then true
114
+ when "off", "false", "no", "0" then false
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ # `dopa demo`: every scene once, with made-up events. Does not touch the score.
5
+ module Demo
6
+ E = Game::Event
7
+
8
+ module_function
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)
12
+ end
13
+
14
+ def cases
15
+ [
16
+ ["(1..100).sum", ev(:success, { type: :integer, value: 5050 })],
17
+ ["[].first", ev(:success, { type: :nil })],
18
+ ["1 < 2", ev(:success, { type: :true })],
19
+ ["[] .any?", ev(:success, { type: :false })],
20
+ ["2**64", ev(:success, { type: :integer, value: 2**64 })],
21
+ ["'dopamine ' * 12", ev(:success, { type: :string, length: 108, head: "dopamine " * 12 })],
22
+ ["(1..24).to_a", ev(:success, { type: :array, size: 24 })],
23
+ ["def boost = :on", ev(:definition, { type: :definition, what: :method, name: "boost" })],
24
+ ["3.times { puts }", ev(:success, { type: :nil, out_lines: 120 }, flags: [:output_rain])],
25
+ ["x = 1", ev(:success, { type: :integer, value: 1 }, flags: [:first_hit], combo: 1)],
26
+ ["10.times.sum", ev(:success, { type: :integer, value: 45 }, flags: [:combo_milestone, :combo_mega], combo: 10, gain: 1350)],
27
+ ["1 + ", ev(:failure, { type: :syntax, class_name: "SyntaxError", line: 1, column: 4, detail: "unexpected end-of-input; expected an expression after the operator" })],
28
+ ["undefined_thing", ev(:failure, { type: :name, class_name: "NameError", name: "undefined_thing", message: "undefined local variable or method 'undefined_thing' for main" }, combo: 0, streak: 1)],
29
+ ["1.shout", ev(:failure, { type: :nomethod, class_name: "NoMethodError", name: "shout", receiver: "Integer", message: "undefined method 'shout' for an instance of Integer" }, combo: 0, streak: 2)],
30
+ ["1 + '1'", ev(:failure, { type: :type, class_name: "TypeError", sides: %w[String Integer], message: "String can't be coerced into Integer" }, combo: 0, streak: 3)],
31
+ ["[1].first(1, 2)", ev(:failure, { type: :argument, class_name: "ArgumentError", sides: ["given 2", "expected 0..1"], message: "wrong number of arguments (given 2, expected 0..1)" }, combo: 0, streak: 4)],
32
+ ["raise 'boom'", ev(:failure, { type: :other, class_name: "RuntimeError", message: "boom" }, combo: 0, streak: 5)],
33
+ ["1 + 1", ev(:success, { type: :integer, value: 2 }, flags: [:comeback], combo: 1, streak: 5, gain: 600)],
34
+ ["loop {}", ev(:interrupt, { charge: 60 })],
35
+ ["2**128", ev(:success, { type: :integer, value: 2**128 }, flags: [:new_record, :record_jump], gain: 420)],
36
+ ]
37
+ end
38
+
39
+ def run(mod)
40
+ d = mod.session&.director || Director.new(mod.config)
41
+ unless mod.active?
42
+ puts "dopairb: not active (no terminal or intensity=off)"
43
+ return
44
+ end
45
+ prompt = "\e[1;35mdopa\e[0m> "
46
+ cases.each do |code, event|
47
+ break if Term.input_pending?
48
+ line = "#{prompt}#{IRB::Color.colorize_code(code, complete: true)}"
49
+ Term.write("#{line}\r\n")
50
+ shot = InputShot.new(rows: [line], lines: [[6, code]], code: code, screen_width: Term.cols)
51
+ if event.kind == :failure && event.info[:type] == :syntax
52
+ event.info[:column] = [code.size, event.info[:column]].min
53
+ end
54
+ d.play_event(event, input: shot, pre: event.kind == :success ? mod.config.charge : 0)
55
+ Term.write("\e[2m=> (demo)\e[0m\r\n")
56
+ sleep 0.15
57
+ end
58
+ Term.write("\e[0m")
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ # Picks a scene for each event and plays it. Bigger moments win; smaller
5
+ # ones are folded into the subtitle instead of being queued.
6
+ class Director
7
+ FPS = 40.0
8
+
9
+ attr_reader :config
10
+
11
+ def initialize(config, rng: Random.new)
12
+ @config = config
13
+ @rng = rng
14
+ @last_flash = -10.0
15
+ end
16
+
17
+ def ctx(input: nil, pre: 0.0, rows: nil, cols: nil)
18
+ r, c = Term.size
19
+ Ctx.new(w: (cols || c) - 1, rows: rows || r, depth: Term.depth(@config), config: @config, rng: @rng,
20
+ input: input, pre: pre, compact: @config.level <= 1 || (cols || c) < 40)
21
+ end
22
+
23
+ def scene_for(event, ctx)
24
+ info = event.info
25
+ case event.kind
26
+ when :interrupt then Scenes::Scatter.new(ctx, event)
27
+ when :failure then failure_scene(info[:type], ctx, event)
28
+ else success_scene(event, ctx)
29
+ end
30
+ end
31
+
32
+ def failure_scene(type, ctx, event)
33
+ case type
34
+ when :syntax then Scenes::Crack.new(ctx, event)
35
+ when :name then Scenes::Spotlight.new(ctx, event)
36
+ when :nomethod then Scenes::Snap.new(ctx, event)
37
+ when :type, :argument then Scenes::Clash.new(ctx, event)
38
+ else Scenes::FailureScene.new(ctx, event)
39
+ end
40
+ end
41
+
42
+ def success_scene(event, ctx)
43
+ info = event.info
44
+ alt = @config.max? && @config.motion
45
+ mega = lambda do |text, palette, rainbow: false, sub: nil|
46
+ if alt
47
+ Scenes::Banner.new(self.ctx(rows: ctx.rows, cols: ctx.w + 1).tap { |c| c.depth = ctx.depth }, event, text: text, palette: palette, mega: true,
48
+ rainbow: rainbow, sub: sub, alt: true)
49
+ else
50
+ Scenes::Banner.new(ctx, event, text: text, palette: palette, mega: true, rainbow: rainbow, sub: sub)
51
+ end
52
+ end
53
+ if event.flag?(:comeback)
54
+ mega.(event.streak >= 2 ? "COMEBACK!" : "FIXED!", Color::GOLD, rainbow: event.streak >= 2)
55
+ elsif event.flag?(:eval_milestone)
56
+ mega.("#{Fx.number_with_commas(event_evals(event))} EVALS", Color::NEON, rainbow: true)
57
+ elsif event.flag?(:record_jump)
58
+ mega.("NEW RECORD", Color::GOLD, rainbow: true, sub: number_text(info))
59
+ elsif event.flag?(:combo_mega)
60
+ mega.("COMBO #{event.combo}!", Color::FIRE, rainbow: event.combo >= 50)
61
+ elsif info[:type] == :definition
62
+ Scenes::Unlock.new(ctx, event)
63
+ elsif event.flag?(:combo_milestone) && event.combo >= 5
64
+ Scenes::Banner.new(ctx, event, text: "COMBO #{event.combo}", palette: Color::FIRE)
65
+ elsif event.flag?(:first_hit)
66
+ Scenes::Banner.new(ctx, event, text: "FIRST HIT!", palette: Color::ICE)
67
+ elsif event.flag?(:output_rain)
68
+ Scenes::Rain.new(ctx, event)
69
+ else
70
+ value_scene(event, ctx)
71
+ end
72
+ end
73
+
74
+ def value_scene(event, ctx)
75
+ info = event.info
76
+ case info[:type]
77
+ when :nil then Scenes::Puff.new(ctx, event)
78
+ when :true, :false then Scenes::Stamp.new(ctx, event)
79
+ when :integer, :float
80
+ info[:value].abs >= 10_000 || event.flag?(:new_record) ? Scenes::Counter.new(ctx, event) : Scenes::Hit.new(ctx, event)
81
+ when :huge
82
+ Scenes::Banner.new(ctx, event, text: "HUGE", palette: Color::GOLD, sub: "#{Fx.number_with_commas(info[:digits])} DIGITS")
83
+ when :string then info[:length] >= 40 ? Scenes::Stream.new(ctx, event) : Scenes::Hit.new(ctx, event)
84
+ when :array, :hash then info[:size] > 0 ? Scenes::Crate.new(ctx, event) : Scenes::Hit.new(ctx, event)
85
+ else Scenes::Hit.new(ctx, event)
86
+ end
87
+ end
88
+
89
+ def play_event(event, input: nil, pre: 0.0)
90
+ return if @config.off?
91
+ c = ctx(input: input, pre: pre)
92
+ c.input = nil if input && input.size + 12 > c.rows
93
+ play(scene_for(event, c))
94
+ end
95
+
96
+ def intro
97
+ return if @config.off? || !@config.intro
98
+ play(Scenes::Intro.new(ctx), force_trail: true)
99
+ end
100
+
101
+ def outro(stats)
102
+ return if @config.off?
103
+ play(Scenes::Outro.new(ctx, stats), force_trail: true)
104
+ end
105
+
106
+ def play(scene, force_trail: false)
107
+ animated = @config.motion && scene.ctx.w >= 20 && fits?(scene)
108
+ animated = false if animated && Term.input_pending?
109
+ run(scene) if animated
110
+ Term.bell if @config.sound && scene.bell? && animated
111
+ emit_trail(scene, force_trail)
112
+ rescue Exception => e # rubocop:disable Lint/RescueException
113
+ raise if SystemExit === e || (SignalException === e && !(Interrupt === e))
114
+ # Never let an effect break the REPL; Ctrl-C during an effect just skips it.
115
+ end
116
+
117
+ private
118
+
119
+ def event_evals(_event)
120
+ Dopairb.session&.game&.evals.to_i
121
+ end
122
+
123
+ def number_text(info)
124
+ Integer === info[:value] ? Fx.number_with_commas(info[:value]) : info[:value].to_s
125
+ end
126
+
127
+ def fits?(scene)
128
+ 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
131
+ end
132
+
133
+ def emit_trail(scene, force)
134
+ tr = scene.trail
135
+ return unless tr
136
+ ok = force || @config.trail == :all || (@config.trail == :big && scene.big?)
137
+ return unless ok
138
+ tr = tr.gsub(/\e\[[\d;]*m/, "") if scene.depth == :none
139
+ Term.write(tr.gsub("\n", "\r\n") + "\e[0m\r\n")
140
+ end
141
+
142
+ def run(scene)
143
+ alt = scene.is_a?(Scenes::Banner) && scene.height == scene.ctx.rows && scene.mega?
144
+ top = !alt && scene.top? ? scene.ctx.input.rows : []
145
+ stage = alt ? AltStage.new(scene.height) : Stage.new(scene.height, top: top)
146
+ depth = scene.depth
147
+ flash_off = nil
148
+ Term.raw do
149
+ stage.open
150
+ t0 = now
151
+ flashed = false
152
+ begin
153
+ loop do
154
+ break if Term.input_pending? || !stage.open?
155
+ t = (now - t0) / @config.duration
156
+ break if t >= scene.total_length
157
+ lines = []
158
+ unless top.empty?
159
+ tc = Canvas.new(scene.ctx.w, top.size)
160
+ lines.concat(scene.draw_top(tc, t) ? tc.render(depth) : top)
161
+ end
162
+ ts = t - scene.pre
163
+ c = Canvas.new(scene.ctx.w, scene.height)
164
+ scene.draw(c, ts) if ts >= 0
165
+ lines.concat(c.render(depth, shake: ts >= 0 ? scene.shake(ts) : 0))
166
+ if !flashed && scene.flash_at && ts >= scene.flash_at
167
+ flashed = true
168
+ if @config.flash == :full && now - @last_flash > 0.4
169
+ @last_flash = now
170
+ Term.write("\e[?5h")
171
+ flash_off = now + 0.05
172
+ end
173
+ end
174
+ stage.draw(lines)
175
+ if flash_off && now >= flash_off
176
+ Term.write("\e[?5l")
177
+ flash_off = nil
178
+ end
179
+ sleep(1 / FPS)
180
+ end
181
+ ensure
182
+ Term.write("\e[?5l") if flash_off
183
+ stage.close
184
+ end
185
+ end
186
+ end
187
+
188
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
189
+ end
190
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Dopairb
4
+ # 5-row bitmap font for the big banners.
5
+ module Font
6
+ HEIGHT = 5
7
+
8
+ RAW = {
9
+ "A" => [" ### ", "# #", "#####", "# #", "# #"],
10
+ "B" => ["#### ", "# #", "#### ", "# #", "#### "],
11
+ "C" => [" ####", "# ", "# ", "# ", " ####"],
12
+ "D" => ["#### ", "# #", "# #", "# #", "#### "],
13
+ "E" => ["#####", "# ", "#### ", "# ", "#####"],
14
+ "F" => ["#####", "# ", "#### ", "# ", "# "],
15
+ "G" => [" ####", "# ", "# ##", "# #", " ####"],
16
+ "H" => ["# #", "# #", "#####", "# #", "# #"],
17
+ "I" => ["###", " # ", " # ", " # ", "###"],
18
+ "J" => [" ###", " # ", " # ", "# # ", " ## "],
19
+ "K" => ["# #", "# # ", "### ", "# # ", "# #"],
20
+ "L" => ["# ", "# ", "# ", "# ", "#####"],
21
+ "M" => ["# #", "## ##", "# # #", "# #", "# #"],
22
+ "N" => ["# #", "## #", "# # #", "# ##", "# #"],
23
+ "O" => [" ### ", "# #", "# #", "# #", " ### "],
24
+ "P" => ["#### ", "# #", "#### ", "# ", "# "],
25
+ "Q" => [" ### ", "# #", "# # #", "# # ", " ## #"],
26
+ "R" => ["#### ", "# #", "#### ", "# # ", "# #"],
27
+ "S" => [" ####", "# ", " ### ", " #", "#### "],
28
+ "T" => ["#####", " # ", " # ", " # ", " # "],
29
+ "U" => ["# #", "# #", "# #", "# #", " ### "],
30
+ "V" => ["# #", "# #", "# #", " # # ", " # "],
31
+ "W" => ["# #", "# #", "# # #", "## ##", "# #"],
32
+ "X" => ["# #", " # # ", " # ", " # # ", "# #"],
33
+ "Y" => ["# #", " # # ", " # ", " # ", " # "],
34
+ "Z" => ["#####", " # ", " # ", " # ", "#####"],
35
+ "0" => [" ### ", "# ##", "# # #", "## #", " ### "],
36
+ "1" => [" # ", "## ", " # ", " # ", "###"],
37
+ "2" => ["#### ", " #", " ### ", "# ", "#####"],
38
+ "3" => ["#### ", " #", " ### ", " #", "#### "],
39
+ "4" => ["# #", "# #", "#####", " #", " #"],
40
+ "5" => ["#####", "# ", "#### ", " #", "#### "],
41
+ "6" => [" ### ", "# ", "#### ", "# #", " ### "],
42
+ "7" => ["#####", " #", " # ", " # ", " # "],
43
+ "8" => [" ### ", "# #", " ### ", "# #", " ### "],
44
+ "9" => [" ### ", "# #", " ####", " #", " ### "],
45
+ "!" => ["#", "#", "#", " ", "#"],
46
+ "?" => ["### ", " #", " ## ", " ", " # "],
47
+ "." => [" ", " ", " ", " ", "#"],
48
+ "," => [" ", " ", " ", " #", "# "],
49
+ ":" => [" ", "#", " ", "#", " "],
50
+ "'" => ["#", "#", " ", " ", " "],
51
+ "+" => [" ", " # ", "#####", " # ", " "],
52
+ "-" => [" ", " ", "####", " ", " "],
53
+ "=" => [" ", "####", " ", "####", " "],
54
+ "x" => [" ", "# #", " # ", "# #", " "],
55
+ "/" => [" #", " # ", " # ", " # ", "# "],
56
+ ">" => ["# ", " # ", " # ", " # ", "# "],
57
+ "<" => [" #", " # ", " # ", " # ", " #"],
58
+ "^" => [" # ", "# #", " ", " ", " "],
59
+ "#" => [" # # ", "#####", " # # ", "#####", " # # "],
60
+ "%" => ["# #", " # ", " # ", " # ", "# #"],
61
+ "*" => [" ", "# # #", " ### ", "# # #", " "],
62
+ " " => [" ", " ", " ", " ", " "],
63
+ }.freeze
64
+
65
+ GLYPHS = RAW.transform_values { |rows| rows.map { |r| r.chars.map { |c| c == "#" } }.freeze }.freeze
66
+
67
+ module_function
68
+
69
+ def glyph(ch)
70
+ GLYPHS[ch] || GLYPHS[ch.upcase] || GLYPHS["?"]
71
+ end
72
+
73
+ # => array of HEIGHT rows, each an array of booleans
74
+ def bitmap(text, spacing: 1)
75
+ rows = Array.new(HEIGHT) { [] }
76
+ text.each_char.with_index do |ch, i|
77
+ g = glyph(ch)
78
+ HEIGHT.times do |y|
79
+ rows[y].concat([false] * spacing) if i > 0
80
+ rows[y].concat(g[y])
81
+ end
82
+ end
83
+ rows
84
+ end
85
+
86
+ def width(text, scale: 1, spacing: 1)
87
+ return 0 if text.empty?
88
+ (text.each_char.sum { |c| glyph(c)[0].size } + spacing * (text.size - 1)) * scale
89
+ end
90
+ end
91
+ end