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.
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
@@ -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