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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +190 -0
- data/exe/dopairb +6 -0
- data/lib/dopairb/canvas.rb +164 -0
- data/lib/dopairb/charge_line.rb +107 -0
- data/lib/dopairb/cli.rb +90 -0
- data/lib/dopairb/color.rb +126 -0
- data/lib/dopairb/config.rb +118 -0
- data/lib/dopairb/demo.rb +61 -0
- data/lib/dopairb/director.rb +190 -0
- data/lib/dopairb/font.rb +91 -0
- data/lib/dopairb/fx.rb +154 -0
- data/lib/dopairb/game.rb +182 -0
- data/lib/dopairb/input_fx.rb +499 -0
- data/lib/dopairb/irb_adapter.rb +65 -0
- data/lib/dopairb/output_tap.rb +94 -0
- data/lib/dopairb/probe.rb +159 -0
- data/lib/dopairb/reline_adapter.rb +247 -0
- data/lib/dopairb/scene.rb +154 -0
- data/lib/dopairb/scenes/failure.rb +306 -0
- data/lib/dopairb/scenes/session.rb +146 -0
- data/lib/dopairb/scenes/success.rb +418 -0
- data/lib/dopairb/session.rb +79 -0
- data/lib/dopairb/stage.rb +129 -0
- data/lib/dopairb/term.rb +131 -0
- data/lib/dopairb/version.rb +5 -0
- data/lib/dopairb.rb +126 -0
- data/spec.md +203 -0
- metadata +111 -0
data/lib/dopairb/fx.rb
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dopairb
|
|
4
|
+
# Drawing primitives shared by scenes and the input lane. All motion is a
|
|
5
|
+
# closed-form function of time so frames can be sampled at any rate.
|
|
6
|
+
module Fx
|
|
7
|
+
Particle = Struct.new(:x, :y, :vx, :vy, :born, :life, :palette, :glyph, :gravity, :drag)
|
|
8
|
+
|
|
9
|
+
class Particles
|
|
10
|
+
attr_reader :list
|
|
11
|
+
|
|
12
|
+
def initialize(rng = Random.new)
|
|
13
|
+
@rng = rng
|
|
14
|
+
@list = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def clear = @list.clear
|
|
18
|
+
def empty? = @list.empty?
|
|
19
|
+
|
|
20
|
+
# speed in cells/sec; y velocity is squashed to respect the cell aspect
|
|
21
|
+
def burst(x, y, n, at:, speed: 20.0, spread: 1.0, life: 0.6, palette: Color::FIRE, glyphs: nil,
|
|
22
|
+
gravity: 18.0, angle: nil, arc: Math::PI * 2, drag: 1.5)
|
|
23
|
+
n.times do
|
|
24
|
+
a = angle ? angle + (@rng.rand - 0.5) * arc : @rng.rand * Math::PI * 2
|
|
25
|
+
s = speed * (0.35 + @rng.rand * 0.65 * spread)
|
|
26
|
+
g = glyphs && glyphs[@rng.rand(glyphs.size)]
|
|
27
|
+
@list << Particle.new(x, y, Math.cos(a) * s, Math.sin(a) * s * 0.5, at, life * (0.6 + @rng.rand * 0.4),
|
|
28
|
+
palette, g, gravity, drag)
|
|
29
|
+
end
|
|
30
|
+
self
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def add(x, y, vx, vy, at:, life:, palette:, glyph: nil, gravity: 0.0, drag: 0.0)
|
|
34
|
+
@list << Particle.new(x, y, vx, vy, at, life, palette, glyph, gravity, drag)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def prune(t)
|
|
38
|
+
@list.reject! { |p| t - p.born > p.life }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def alive?(t)
|
|
42
|
+
@list.any? { |p| t - p.born <= p.life }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def draw(canvas, t)
|
|
46
|
+
@list.each do |p|
|
|
47
|
+
age = t - p.born
|
|
48
|
+
next if age < 0 || age > p.life
|
|
49
|
+
x, y = position(p, age)
|
|
50
|
+
col = Color.ramp(p.palette, age / p.life)
|
|
51
|
+
if p.glyph
|
|
52
|
+
canvas.put(x, y, p.glyph, col, bold: age < p.life * 0.3)
|
|
53
|
+
else
|
|
54
|
+
canvas.dot(x * 2, y * 4, col)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def position(p, age)
|
|
60
|
+
if p.drag > 0
|
|
61
|
+
k = (1 - Math.exp(-p.drag * age)) / p.drag
|
|
62
|
+
[p.x + p.vx * k, p.y + p.vy * k + 0.5 * p.gravity * age * age * 0.25]
|
|
63
|
+
else
|
|
64
|
+
[p.x + p.vx * age, p.y + p.vy * age + 0.5 * p.gravity * age * age * 0.25]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
module_function
|
|
70
|
+
|
|
71
|
+
def ease_out(t) = 1 - (1 - clamp01(t))**3
|
|
72
|
+
def ease_in(t) = clamp01(t)**3
|
|
73
|
+
def ease_out_back(t)
|
|
74
|
+
t = clamp01(t)
|
|
75
|
+
c1 = 1.70158
|
|
76
|
+
c3 = c1 + 1
|
|
77
|
+
1 + c3 * (t - 1)**3 + c1 * (t - 1)**2
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def clamp01(t) = t < 0 ? 0.0 : (t > 1 ? 1.0 : t.to_f)
|
|
81
|
+
|
|
82
|
+
def phase(t, from, to)
|
|
83
|
+
clamp01((t - from) / (to - from))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def pixel_char
|
|
87
|
+
Term.glyph("█", "⣿", "#")
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Big bitmap text. color_fn.(x, y, i) -> rgb where i is the pixel column.
|
|
91
|
+
# scale doubles pixels horizontally for chunkier letters.
|
|
92
|
+
def big_text(canvas, text, x, y, color_fn, scale: 1, glitch: 0.0, rng: nil, reveal: 1.0, bg_fn: nil)
|
|
93
|
+
bm = Font.bitmap(text)
|
|
94
|
+
pc = pixel_char
|
|
95
|
+
width = bm[0].size
|
|
96
|
+
shown = (width * reveal).ceil
|
|
97
|
+
bm.each_with_index do |row, ry|
|
|
98
|
+
row.each_with_index do |on, rx|
|
|
99
|
+
next unless on
|
|
100
|
+
next if rx >= shown
|
|
101
|
+
ox = 0
|
|
102
|
+
if glitch > 0 && rng && rng.rand < glitch
|
|
103
|
+
ox = rng.rand(5) - 2
|
|
104
|
+
end
|
|
105
|
+
col = color_fn.(rx, ry, rx.fdiv([width - 1, 1].max))
|
|
106
|
+
scale.times do |s|
|
|
107
|
+
canvas.put(x + rx * scale + s + ox, y + ry, pc, col, bg: bg_fn&.(rx, ry))
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
width * scale
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def text_fits?(text, w, scale = 1)
|
|
115
|
+
Font.width(text, scale: scale) <= w
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Largest scale (2, 1) that fits; nil if the big font does not fit at all.
|
|
119
|
+
def best_scale(text, w)
|
|
120
|
+
return 2 if text_fits?(text, w, 2) && Font.width(text, scale: 2) <= 110
|
|
121
|
+
return 1 if text_fits?(text, w, 1)
|
|
122
|
+
nil
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def shockwave(canvas, cx, cy, age, speed: 40.0, palette: Color::GOLD, life: 0.5, rings: 2)
|
|
126
|
+
return if age < 0 || age > life
|
|
127
|
+
rings.times do |i|
|
|
128
|
+
a = age - i * 0.06
|
|
129
|
+
next if a < 0
|
|
130
|
+
canvas.ring(cx, cy, a * speed, Color.ramp(palette, a / life), density: 1.3)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def sparkles(canvas, t, rng_seed, count, palette: Color::GOLD, area: nil)
|
|
135
|
+
x0, y0, w, h = area || [0, 0, canvas.w, canvas.h]
|
|
136
|
+
r = Random.new(rng_seed)
|
|
137
|
+
stars = [Term.glyph("✦", "*"), Term.glyph("✧", "+"), "*", "+", "."]
|
|
138
|
+
count.times do |i|
|
|
139
|
+
px = x0 + r.rand(w)
|
|
140
|
+
py = y0 + r.rand(h)
|
|
141
|
+
period = 0.25 + r.rand * 0.5
|
|
142
|
+
ph = ((t + r.rand) / period) % 1.0
|
|
143
|
+
next if ph > 0.6
|
|
144
|
+
canvas.put(px, py, stars[i % stars.size], Color.ramp(palette, ph / 0.6), bold: ph < 0.2)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def number_with_commas(n)
|
|
149
|
+
s = n.abs.to_s
|
|
150
|
+
s = s.reverse.scan(/\d{1,3}/).join(",").reverse
|
|
151
|
+
n < 0 ? "-#{s}" : s
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
data/lib/dopairb/game.rb
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dopairb
|
|
4
|
+
# COMBO / CHARGE / SCORE bookkeeping. Points are toys: they reward rhythm
|
|
5
|
+
# and recovery, never code length or correctness.
|
|
6
|
+
class Game
|
|
7
|
+
CHARGE_MAX = 100.0
|
|
8
|
+
TYPING_WINDOW = 0.6
|
|
9
|
+
COMBO_MILESTONES = [3, 5, 10, 15, 20, 30, 50, 75, 100].freeze
|
|
10
|
+
COMBO_MEGA = [10, 25, 50, 100].freeze
|
|
11
|
+
EVAL_MILESTONES = [10, 50, 100, 500, 1000, 5000, 10_000].freeze
|
|
12
|
+
|
|
13
|
+
Event = Struct.new(:kind, :tier, :info, :flags, :gain, :combo, :streak, :score, keyword_init: true) do
|
|
14
|
+
def flag?(f) = flags.include?(f)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :score, :combo, :max_combo, :evals, :successes, :failures, :interrupts,
|
|
18
|
+
:charge, :error_streak, :record, :typing_combo, :started_at, :last_key_at,
|
|
19
|
+
:keystrokes, :best_typing
|
|
20
|
+
|
|
21
|
+
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
22
|
+
@clock = clock
|
|
23
|
+
@started_at = now
|
|
24
|
+
@score = 0
|
|
25
|
+
@combo = 0
|
|
26
|
+
@max_combo = 0
|
|
27
|
+
@evals = 0
|
|
28
|
+
@successes = 0
|
|
29
|
+
@failures = 0
|
|
30
|
+
@interrupts = 0
|
|
31
|
+
@charge = 0.0
|
|
32
|
+
@error_streak = 0
|
|
33
|
+
@record = nil
|
|
34
|
+
@typing_combo = 0
|
|
35
|
+
@best_typing = 0
|
|
36
|
+
@last_key_at = nil
|
|
37
|
+
@last_char = nil
|
|
38
|
+
@same_char = 0
|
|
39
|
+
@typing_points = 0
|
|
40
|
+
@keystrokes = 0
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def now = @clock.call
|
|
44
|
+
|
|
45
|
+
def multiplier(t = nil)
|
|
46
|
+
return 1.0 if t && (@last_key_at.nil? || t - @last_key_at > TYPING_WINDOW * 2)
|
|
47
|
+
1.0 + [@typing_combo, 50].min / 25.0
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def heat(t = now)
|
|
51
|
+
return 0.0 unless @last_key_at
|
|
52
|
+
idle = t - @last_key_at
|
|
53
|
+
base = [@charge / CHARGE_MAX, @typing_combo / 30.0].max.clamp(0.0, 1.0)
|
|
54
|
+
base * Math.exp(-[idle - 1.0, 0].max / 4.0)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# kind: :insert, :delete, :move, :bracket, :string, :block, :complete, :history, :newline, :paste
|
|
58
|
+
# Returns :resume when typing restarts after a pause, else nil.
|
|
59
|
+
def key(kind, char = nil)
|
|
60
|
+
t = now
|
|
61
|
+
resumed = @last_key_at && t - @last_key_at > 2.0 && kind == :insert
|
|
62
|
+
if @last_key_at && t - @last_key_at <= TYPING_WINDOW
|
|
63
|
+
@typing_combo += 1 unless kind == :move
|
|
64
|
+
else
|
|
65
|
+
@typing_combo = kind == :move ? 0 : 1
|
|
66
|
+
end
|
|
67
|
+
@best_typing = @typing_combo if @typing_combo > @best_typing
|
|
68
|
+
@last_key_at = t
|
|
69
|
+
@keystrokes += 1 unless kind == :paste
|
|
70
|
+
|
|
71
|
+
gain, pts = case kind
|
|
72
|
+
when :insert then [2.2, 1]
|
|
73
|
+
when :delete then [0.6, 0]
|
|
74
|
+
when :move then [0.2, 0]
|
|
75
|
+
when :bracket then [6.0, 15]
|
|
76
|
+
when :string then [4.0, 10]
|
|
77
|
+
when :block then [9.0, 25]
|
|
78
|
+
when :complete then [5.0, 20]
|
|
79
|
+
when :history then [3.0, 5]
|
|
80
|
+
when :newline then [8.0, 10]
|
|
81
|
+
when :paste then [10.0, 10]
|
|
82
|
+
else [0.0, 0]
|
|
83
|
+
end
|
|
84
|
+
if kind == :insert
|
|
85
|
+
if char == @last_char
|
|
86
|
+
@same_char += 1
|
|
87
|
+
else
|
|
88
|
+
@same_char = 0
|
|
89
|
+
@last_char = char
|
|
90
|
+
end
|
|
91
|
+
gain *= 0.35**[@same_char, 4].min
|
|
92
|
+
pts = 0 if @same_char > 2
|
|
93
|
+
end
|
|
94
|
+
@charge += gain * (1 - @charge / CHARGE_MAX)
|
|
95
|
+
@charge = CHARGE_MAX if @charge > CHARGE_MAX
|
|
96
|
+
if @typing_points < 200
|
|
97
|
+
p = (pts * multiplier).round
|
|
98
|
+
@typing_points += p
|
|
99
|
+
@score += p
|
|
100
|
+
end
|
|
101
|
+
resumed ? :resume : nil
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def success(info, code: "", out_lines: 0)
|
|
105
|
+
@evals += 1
|
|
106
|
+
@successes += 1
|
|
107
|
+
flags = []
|
|
108
|
+
flags << :first_hit if @successes == 1
|
|
109
|
+
flags << :comeback if @error_streak > 0
|
|
110
|
+
streak = @error_streak
|
|
111
|
+
@error_streak = 0
|
|
112
|
+
@combo += 1
|
|
113
|
+
@max_combo = @combo if @combo > @max_combo
|
|
114
|
+
flags << :combo_milestone if COMBO_MILESTONES.include?(@combo)
|
|
115
|
+
flags << :combo_mega if COMBO_MEGA.include?(@combo)
|
|
116
|
+
flags << :eval_milestone if EVAL_MILESTONES.include?(@evals)
|
|
117
|
+
flags << :output_rain if out_lines >= 20
|
|
118
|
+
|
|
119
|
+
if (num = numeric(info))
|
|
120
|
+
if @record && num > @record && num >= 100
|
|
121
|
+
flags << :new_record
|
|
122
|
+
flags << :record_jump if @record <= 0 ? num >= 1000 : num >= @record * 2
|
|
123
|
+
end
|
|
124
|
+
@record = num if @record.nil? || num > @record
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
gain = 100 + [@combo - 1, 40].min * 25 + (@charge * 2).round
|
|
128
|
+
gain += 500 if flags.include?(:comeback)
|
|
129
|
+
gain += 100 if flags.include?(:first_hit)
|
|
130
|
+
gain += 300 if flags.include?(:new_record)
|
|
131
|
+
gain += 1000 if flags.include?(:eval_milestone)
|
|
132
|
+
gain += @combo * 20 if flags.include?(:combo_milestone)
|
|
133
|
+
@score += gain
|
|
134
|
+
@charge = 0.0
|
|
135
|
+
@typing_points = 0
|
|
136
|
+
|
|
137
|
+
Event.new(kind: info[:type] == :definition ? :definition : :success, tier: nil, info: info, flags: flags,
|
|
138
|
+
gain: gain, combo: @combo, streak: streak, score: @score)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def failure(info)
|
|
142
|
+
@evals += 1
|
|
143
|
+
@failures += 1
|
|
144
|
+
@error_streak += 1
|
|
145
|
+
broken = @combo
|
|
146
|
+
@combo = 0
|
|
147
|
+
@charge = 0.0
|
|
148
|
+
@typing_points = 0
|
|
149
|
+
flags = []
|
|
150
|
+
flags << :combo_break if broken >= 3
|
|
151
|
+
flags << :eval_milestone if EVAL_MILESTONES.include?(@evals)
|
|
152
|
+
Event.new(kind: :failure, tier: nil, info: info.merge(broken_combo: broken), flags: flags, gain: 0,
|
|
153
|
+
combo: 0, streak: @error_streak, score: @score)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def interrupted
|
|
157
|
+
@interrupts += 1
|
|
158
|
+
lost = @charge
|
|
159
|
+
@charge = 0.0
|
|
160
|
+
@typing_points = 0
|
|
161
|
+
Event.new(kind: :interrupt, tier: nil, info: { charge: lost }, flags: [], gain: 0, combo: @combo,
|
|
162
|
+
streak: @error_streak, score: @score)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def elapsed = now - @started_at
|
|
166
|
+
|
|
167
|
+
def stats
|
|
168
|
+
{ evals: @evals, successes: @successes, failures: @failures, interrupts: @interrupts,
|
|
169
|
+
max_combo: @max_combo, score: @score, keystrokes: @keystrokes, best_typing: @best_typing,
|
|
170
|
+
time: elapsed }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def numeric(info)
|
|
176
|
+
case info[:type]
|
|
177
|
+
when :integer then info[:value]
|
|
178
|
+
when :float then info[:value].finite? ? info[:value] : nil
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|