dopairb 0.2.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 +4 -4
- data/README.md +41 -9
- data/lib/dopairb/cli.rb +4 -6
- data/lib/dopairb/config.rb +6 -4
- data/lib/dopairb/demo.rb +9 -2
- data/lib/dopairb/director.rb +35 -5
- data/lib/dopairb/fx.rb +23 -0
- data/lib/dopairb/gallery/impressionism.rb +818 -0
- data/lib/dopairb/gallery/japan.rb +693 -0
- data/lib/dopairb/gallery/modern_ancient.rb +774 -0
- data/lib/dopairb/gallery/renaissance.rb +829 -0
- data/lib/dopairb/gallery/romantic.rb +703 -0
- data/lib/dopairb/gallery.rb +349 -0
- data/lib/dopairb/game.rb +84 -7
- data/lib/dopairb/input_fx.rb +63 -9
- data/lib/dopairb/irb_adapter.rb +8 -0
- data/lib/dopairb/profile.rb +106 -0
- data/lib/dopairb/reline_adapter.rb +1 -0
- data/lib/dopairb/scene.rb +5 -0
- data/lib/dopairb/scenes/bonus.rb +279 -0
- data/lib/dopairb/scenes/failure.rb +1 -0
- data/lib/dopairb/scenes/finale.rb +42 -2
- data/lib/dopairb/scenes/session.rb +21 -3
- data/lib/dopairb/scenes/success.rb +11 -4
- data/lib/dopairb/session.rb +50 -6
- data/lib/dopairb/sound.rb +57 -2
- data/lib/dopairb/term.rb +3 -1
- data/lib/dopairb/tour.rb +89 -0
- data/lib/dopairb/version.rb +1 -1
- data/lib/dopairb.rb +57 -1
- data/spec.md +3 -3
- metadata +10 -1
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Dopairb
|
|
4
|
+
# Bonus art: famous public-domain paintings, painted procedurally at any
|
|
5
|
+
# size (no bundled images). Each painter maps (u, v) in 0..1 to an RGB.
|
|
6
|
+
module Gallery
|
|
7
|
+
Piece = Struct.new(:id, :title, :artist, :year, :aspect, :painter, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
module Paint
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def mix(a, b, t) = Color.mix(a, b, t)
|
|
13
|
+
def smooth(e0, e1, x) = (t = Fx.clamp01((x - e0) / (e1 - e0))) * t * (3 - 2 * t)
|
|
14
|
+
def dist(u, v, x, y, sx = 1.0) = Math.hypot((u - x) * sx, v - y)
|
|
15
|
+
|
|
16
|
+
# Cheap deterministic value noise in 0..1.
|
|
17
|
+
def noise(x, y)
|
|
18
|
+
n = Math.sin(x * 12.9898 + y * 78.233) * 43_758.5453
|
|
19
|
+
n - n.floor
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def fbm(x, y)
|
|
23
|
+
x0 = x.floor
|
|
24
|
+
y0 = y.floor
|
|
25
|
+
fx = x - x0
|
|
26
|
+
fy = y - y0
|
|
27
|
+
a = noise(x0, y0)
|
|
28
|
+
b = noise(x0 + 1, y0)
|
|
29
|
+
c = noise(x0, y0 + 1)
|
|
30
|
+
d = noise(x0 + 1, y0 + 1)
|
|
31
|
+
sx = fx * fx * (3 - 2 * fx)
|
|
32
|
+
sy = fy * fy * (3 - 2 * fy)
|
|
33
|
+
(a + (b - a) * sx) + ((c + (d - c) * sx) - (a + (b - a) * sx)) * sy
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Smooth value noise at any scale (0..1): fbm(u * 8, v * 8) for blotches.
|
|
37
|
+
def vnoise(u, v, scale = 8.0) = fbm(u * scale, v * scale)
|
|
38
|
+
|
|
39
|
+
def hsv(h, s = 1.0, v = 1.0) = Color.hsv(h, s, v)
|
|
40
|
+
|
|
41
|
+
# 0xRRGGBB -> [r, g, b]
|
|
42
|
+
def rgb(hex) = [(hex >> 16) & 255, (hex >> 8) & 255, hex & 255]
|
|
43
|
+
|
|
44
|
+
# Gradient through evenly spaced colors, t in 0..1.
|
|
45
|
+
def ramp(t, *colors)
|
|
46
|
+
t = Fx.clamp01(t)
|
|
47
|
+
return colors.last if t >= 1
|
|
48
|
+
f = t * (colors.size - 1)
|
|
49
|
+
i = f.floor
|
|
50
|
+
mix(colors[i], colors[i + 1], f - i)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# < 1 inside the ellipse (0 at the center).
|
|
54
|
+
def ellipse(u, v, cx, cy, rx, ry) = ((u - cx) / rx)**2 + ((v - cy) / ry)**2
|
|
55
|
+
|
|
56
|
+
# Point in polygon; pts = [[u, v], ...].
|
|
57
|
+
def inside?(u, v, pts)
|
|
58
|
+
hit = false
|
|
59
|
+
j = pts.size - 1
|
|
60
|
+
pts.each_with_index do |(xi, yi), i|
|
|
61
|
+
xj, yj = pts[j]
|
|
62
|
+
hit = !hit if (yi > v) != (yj > v) && u < (xj - xi) * (v - yi) / (yj - yi + 1e-12) + xi
|
|
63
|
+
j = i
|
|
64
|
+
end
|
|
65
|
+
hit
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Brush-stroke texture in -1..1 along angle (radians), freq strokes per unit.
|
|
69
|
+
def brush(u, v, angle: 0.0, freq: 40.0, wobble: 0.3)
|
|
70
|
+
x = u * Math.cos(angle) + v * Math.sin(angle)
|
|
71
|
+
y = -u * Math.sin(angle) + v * Math.cos(angle)
|
|
72
|
+
Math.sin((y + wobble * Math.sin(x * 9 + y * 3) / freq * 6) * freq * Math::PI)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# A color with a little painterly variation.
|
|
76
|
+
def jitter(color, u, v, amount = 18, scale = 30.0)
|
|
77
|
+
k = (vnoise(u, v, scale) - 0.5) * 2 * amount
|
|
78
|
+
color.map { |c| (c + k).round.clamp(0, 255) }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Hokusai, Fine Wind, Clear Morning ("Red Fuji")
|
|
82
|
+
def red_fuji(u, v)
|
|
83
|
+
peak_u = 0.58
|
|
84
|
+
peak_v = 0.17
|
|
85
|
+
# concave slopes
|
|
86
|
+
left = peak_v + (peak_u - u) * 1.05 + (peak_u - u)**2 * -0.5
|
|
87
|
+
right = peak_v + (u - peak_u) * 0.95 + (u - peak_u)**2 * -0.3
|
|
88
|
+
slope = u < peak_u ? left : right
|
|
89
|
+
if v > 0.86
|
|
90
|
+
return mix([20, 60, 50], [10, 30, 30], (v - 0.86) / 0.14) if noise((u * 60).floor, (v * 40).floor) < 0.8
|
|
91
|
+
return [40, 90, 60]
|
|
92
|
+
end
|
|
93
|
+
if v >= slope
|
|
94
|
+
depth = (v - peak_v) / 0.7
|
|
95
|
+
if v > 0.74 + 0.03 * Math.sin(u * 30)
|
|
96
|
+
tree = noise((u * 70).floor, (v * 50).floor) > 0.55
|
|
97
|
+
return tree ? [35, 95, 60] : [25, 70, 50]
|
|
98
|
+
end
|
|
99
|
+
snow = v < peak_v + 0.13 + 0.05 * Math.sin(u * 55) * (v - peak_v) * 8
|
|
100
|
+
return mix([250, 250, 245], [210, 215, 230], depth) if snow && Math.sin(u * 90) > -0.6
|
|
101
|
+
base = mix([150, 40, 20], [205, 75, 35], depth * 1.3)
|
|
102
|
+
streak = Math.sin(u * 140 + v * 10) > 0.75 ? 0.8 : 1.0
|
|
103
|
+
return Color.scale(base, streak)
|
|
104
|
+
end
|
|
105
|
+
sky = mix([40, 80, 160], [175, 205, 230], smooth(0.0, 0.7, v))
|
|
106
|
+
# mackerel clouds: rows of puffs low in the sky
|
|
107
|
+
if v > 0.42
|
|
108
|
+
row = ((v - 0.42) * 22)
|
|
109
|
+
puff = Math.sin(u * 48 + row.floor * 1.7) * 0.5 + 0.5
|
|
110
|
+
frac = row - row.floor
|
|
111
|
+
return mix([255, 255, 250], sky, 0.25) if puff > 0.45 && frac.between?(0.15, 0.7)
|
|
112
|
+
end
|
|
113
|
+
sky
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Hokusai, The Great Wave off Kanagawa
|
|
117
|
+
def great_wave(u, v)
|
|
118
|
+
sky = mix([205, 195, 160], [235, 225, 195], v)
|
|
119
|
+
# Mount Fuji, small in the trough
|
|
120
|
+
fu = 0.64
|
|
121
|
+
fv = 0.58
|
|
122
|
+
if v > fv && v < 0.70 && (u - fu).abs < (v - fv) * 1.6
|
|
123
|
+
return v < fv + 0.035 ? [245, 245, 240] : [70, 80, 110]
|
|
124
|
+
end
|
|
125
|
+
# the big wave: a crescent between two circles, curling to the right
|
|
126
|
+
ox = (u - 0.36) * 1.2
|
|
127
|
+
oy = v - 0.62
|
|
128
|
+
outer = Math.hypot(ox, oy)
|
|
129
|
+
inner = Math.hypot((u - 0.56) * 1.2, v - 0.66)
|
|
130
|
+
if outer < 0.50 && inner > 0.30 && v < 0.80 && u < 0.78
|
|
131
|
+
edge = 0.50 - outer
|
|
132
|
+
ang = Math.atan2(oy, ox)
|
|
133
|
+
claws = Math.sin(ang * 50) > 0.1
|
|
134
|
+
return [250, 250, 245] if edge < 0.05 && (claws || edge < 0.02)
|
|
135
|
+
lip = inner - 0.30
|
|
136
|
+
return [250, 250, 245] if lip < 0.025 && v < 0.5
|
|
137
|
+
stripe = Math.sin(outer * 110 + inner * 40) > 0.6
|
|
138
|
+
base = mix([90, 135, 190], [15, 40, 105], Fx.clamp01(edge * 4))
|
|
139
|
+
return stripe ? mix(base, [235, 240, 245], 0.7) : base
|
|
140
|
+
end
|
|
141
|
+
# foam spray dots falling from the crest
|
|
142
|
+
if dist(u, v, 0.80, 0.22, 1.25) < 0.09 && noise((u * 80).floor, (v * 60).floor) > 0.72
|
|
143
|
+
return [250, 250, 245]
|
|
144
|
+
end
|
|
145
|
+
sea_v = 0.70 + 0.04 * Math.sin(u * 14)
|
|
146
|
+
if v > sea_v
|
|
147
|
+
stripe = Math.sin(v * 160 + Math.sin(u * 25) * 4) > 0.7
|
|
148
|
+
base = mix([40, 80, 140], [20, 45, 100], v)
|
|
149
|
+
# boats
|
|
150
|
+
[[0.30, 0.78], [0.62, 0.86]].each do |bx, by|
|
|
151
|
+
return [215, 195, 140] if ((u - bx) / 0.13)**2 + ((v - by) / 0.025)**2 < 1
|
|
152
|
+
end
|
|
153
|
+
# small wave at right
|
|
154
|
+
return [250, 250, 245] if dist(u, v, 0.86, 0.74, 1.4) < 0.10 && dist(u, v, 0.86, 0.74, 1.4) > 0.08
|
|
155
|
+
return stripe ? [235, 240, 245] : base
|
|
156
|
+
end
|
|
157
|
+
sky
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# van Gogh, The Starry Night
|
|
161
|
+
def starry_night(u, v)
|
|
162
|
+
# cypress, a dark flame on the left
|
|
163
|
+
cyp = 0.17 + 0.09 * (v - 0.05) * 1.1 * Math.sin(v * 3) + 0.03 * Math.sin(v * 25)
|
|
164
|
+
width = (v - 0.03) * 0.13
|
|
165
|
+
if v > 0.03 && (u - cyp).abs < width
|
|
166
|
+
return Math.sin(v * 70 + u * 30) > 0.3 ? [45, 70, 40] : [20, 30, 25]
|
|
167
|
+
end
|
|
168
|
+
hill = 0.70 + 0.06 * Math.sin(u * 5 + 1) + 0.03 * Math.sin(u * 13)
|
|
169
|
+
if v > hill
|
|
170
|
+
# village: houses with lit windows and a church spire
|
|
171
|
+
return [30, 40, 70] if (u - 0.55).abs < 0.008 && v > hill - 0.18
|
|
172
|
+
if v > hill + 0.07 && v < 0.93
|
|
173
|
+
cell_x = (u * 40).floor
|
|
174
|
+
cell_y = (v * 30).floor
|
|
175
|
+
return [240, 210, 90] if noise(cell_x, cell_y) > 0.82
|
|
176
|
+
return [55, 70, 105] if noise(cell_x + 3, cell_y) > 0.45
|
|
177
|
+
end
|
|
178
|
+
return Math.sin(u * 40 + v * 20) > 0.5 ? [35, 60, 100] : [25, 45, 80]
|
|
179
|
+
end
|
|
180
|
+
# moon
|
|
181
|
+
dm = dist(u, v, 0.87, 0.13, 1.3)
|
|
182
|
+
return [255, 235, 120] if dm < 0.05
|
|
183
|
+
return [240, 200, 60] if dm < 0.075 && Math.sin(dm * 300) > 0
|
|
184
|
+
# stars with halos
|
|
185
|
+
[[0.10, 0.08], [0.30, 0.15], [0.48, 0.07], [0.66, 0.22], [0.78, 0.40], [0.40, 0.45], [0.95, 0.52], [0.22, 0.38]].each do |sx, sy|
|
|
186
|
+
d = dist(u, v, sx, sy, 1.3)
|
|
187
|
+
return [255, 250, 200] if d < 0.015
|
|
188
|
+
return [235, 215, 90] if d < 0.035
|
|
189
|
+
return [170, 190, 210] if d < 0.05 && Math.sin(d * 400) > 0.2
|
|
190
|
+
end
|
|
191
|
+
# the great swirl
|
|
192
|
+
sx = u - 0.48
|
|
193
|
+
sy = (v - 0.30) * 1.6
|
|
194
|
+
r = Math.hypot(sx, sy)
|
|
195
|
+
a = Math.atan2(sy, sx)
|
|
196
|
+
swirl = Math.sin(r * 55 - a * 2 + Math.sin(u * 7) * 2)
|
|
197
|
+
if r < 0.22 && swirl > 0.4
|
|
198
|
+
return mix([230, 225, 170], [120, 160, 210], r / 0.22)
|
|
199
|
+
end
|
|
200
|
+
stroke = Math.sin(v * 60 + Math.sin(u * 9 + v * 4) * 5)
|
|
201
|
+
base = mix([30, 60, 140], [70, 110, 180], smooth(0.0, 0.7, v))
|
|
202
|
+
stroke > 0.6 ? mix(base, [150, 180, 220], 0.45) : base
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Leonardo da Vinci, Mona Lisa
|
|
206
|
+
def mona_lisa(u, v)
|
|
207
|
+
# dark frame
|
|
208
|
+
land = mix([150, 160, 120], [90, 100, 60], v)
|
|
209
|
+
land = mix(land, [70, 90, 80], 0.4) if Math.sin(u * 20 + v * 9) > 0.6
|
|
210
|
+
fx = 0.50
|
|
211
|
+
fy = 0.30
|
|
212
|
+
face = ((u - fx) / 0.13)**2 + ((v - fy) / 0.15)**2
|
|
213
|
+
hair = ((u - fx) / 0.20)**2 + ((v - fy - 0.04) / 0.22)**2
|
|
214
|
+
if face < 1
|
|
215
|
+
skin = mix([235, 190, 120], [180, 130, 70], Fx.clamp01((u - fx + 0.13) / 0.35) * 0.6 + face * 0.3)
|
|
216
|
+
return [60, 40, 25] if (v - (fy - 0.02)).abs < 0.012 && ((u - fx).abs - 0.055).abs < 0.025 # eyes
|
|
217
|
+
return [150, 80, 50] if (v - (fy + 0.085 + 0.25 * (u - fx)**2)).abs < 0.006 && (u - fx).abs < 0.045 # the smile
|
|
218
|
+
return mix(skin, [150, 100, 55], 0.4) if (u - fx).abs < 0.008 && v > fy && v < fy + 0.05 # nose
|
|
219
|
+
return skin
|
|
220
|
+
end
|
|
221
|
+
return [50, 35, 20] if hair < 1 && v < 0.55
|
|
222
|
+
# neck and chest
|
|
223
|
+
if v > 0.43 && v < 0.62 && (u - fx).abs < 0.05 + (v - 0.43) * 0.8
|
|
224
|
+
return mix([225, 180, 115], [190, 140, 80], (v - 0.43) * 4)
|
|
225
|
+
end
|
|
226
|
+
# dress
|
|
227
|
+
if v > 0.45 && (u - fx).abs < 0.12 + (v - 0.45) * 1.1
|
|
228
|
+
# crossed hands
|
|
229
|
+
return mix([235, 190, 125], [195, 145, 85], ((u - 0.5).abs) * 4) if ((u - 0.5) / 0.20)**2 + ((v - 0.88) / 0.06)**2 < 1
|
|
230
|
+
return Math.sin(v * 90 + u * 10) > 0.8 ? [95, 70, 30] : [55, 40, 20]
|
|
231
|
+
end
|
|
232
|
+
land
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# Munch, The Scream
|
|
236
|
+
def the_scream(u, v)
|
|
237
|
+
# the figure
|
|
238
|
+
hx = 0.48
|
|
239
|
+
hy = 0.62
|
|
240
|
+
head = ((u - hx) / 0.08)**2 + ((v - hy) / 0.10)**2
|
|
241
|
+
if head < 1
|
|
242
|
+
return [40, 30, 30] if ((u - hx).abs - 0.03).abs < 0.015 && (v - hy + 0.02).abs < 0.02 # eyes
|
|
243
|
+
return [40, 30, 30] if ((u - hx) / 0.015)**2 + ((v - hy - 0.05) / 0.03)**2 < 1 # mouth
|
|
244
|
+
return [215, 205, 150]
|
|
245
|
+
end
|
|
246
|
+
# hands on cheeks
|
|
247
|
+
return [200, 190, 140] if [[hx - 0.085, hy + 0.01], [hx + 0.085, hy + 0.01]].any? { |x, y| ((u - x) / 0.02)**2 + ((v - y) / 0.06)**2 < 1 }
|
|
248
|
+
body_w = 0.07 + (v - hy) * 0.35 + 0.02 * Math.sin(v * 30)
|
|
249
|
+
return Math.sin(v * 40 + u * 15) > 0.3 ? [45, 50, 70] : [25, 25, 40] if v > hy + 0.08 && (u - hx - (v - hy) * 0.1).abs < body_w
|
|
250
|
+
# the bridge railing, from the left down to the bottom right
|
|
251
|
+
rail = 0.48 + (u * 0.55)
|
|
252
|
+
if v > rail - 0.015 && v < rail + 0.015
|
|
253
|
+
return [130, 70, 40]
|
|
254
|
+
end
|
|
255
|
+
if v > rail
|
|
256
|
+
return Math.sin((u + v) * 70) > 0.4 ? [190, 120, 70] : [160, 95, 55] # deck
|
|
257
|
+
end
|
|
258
|
+
# fjord and shore
|
|
259
|
+
shore = 0.42 + 0.06 * Math.sin(u * 7)
|
|
260
|
+
if v > shore
|
|
261
|
+
return u < 0.5 + 0.1 * Math.sin(v * 10) ? mix([40, 60, 120], [70, 90, 130], Math.sin(v * 60 + u * 12) * 0.5 + 0.5) : mix([80, 100, 60], [140, 110, 60], Math.sin(u * 30 + v * 20) * 0.5 + 0.5)
|
|
262
|
+
end
|
|
263
|
+
# the blood-red sky
|
|
264
|
+
wave = Math.sin(v * 38 + Math.sin(u * 6) * 2.5)
|
|
265
|
+
if wave > 0.4 then [230, 90, 40]
|
|
266
|
+
elsif wave > -0.2 then [245, 160, 60]
|
|
267
|
+
elsif wave > -0.7 then [220, 190, 80]
|
|
268
|
+
else [190, 60, 40]
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
# Filled by Gallery.piece as the files below are loaded.
|
|
274
|
+
PIECES = []
|
|
275
|
+
|
|
276
|
+
module_function
|
|
277
|
+
|
|
278
|
+
# Register a painting. The block gets (u, v) and runs with Paint's helpers.
|
|
279
|
+
# piece :the_kiss, "The Kiss", "Gustav Klimt", "1908", aspect: 1.0 do |u, v| ... end
|
|
280
|
+
def piece(id, title, artist, year, aspect:, &painter)
|
|
281
|
+
raise ArgumentError, "duplicate piece #{id}" if PIECES.any? { |p| p.id == id }
|
|
282
|
+
PIECES << Piece.new(id: id, title: title, artist: artist, year: year, aspect: aspect, painter: painter)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
piece(:great_wave, "The Great Wave off Kanagawa", "Katsushika Hokusai", "c. 1831", aspect: 1.5) { |u, v| great_wave(u, v) }
|
|
286
|
+
piece(:red_fuji, "Fine Wind, Clear Morning", "Katsushika Hokusai", "c. 1831", aspect: 1.45) { |u, v| red_fuji(u, v) }
|
|
287
|
+
piece(:starry_night, "The Starry Night", "Vincent van Gogh", "1889", aspect: 1.26) { |u, v| starry_night(u, v) }
|
|
288
|
+
piece(:mona_lisa, "Mona Lisa", "Leonardo da Vinci", "c. 1503", aspect: 0.67) { |u, v| mona_lisa(u, v) }
|
|
289
|
+
piece(:the_scream, "The Scream", "Edvard Munch", "1893", aspect: 0.8) { |u, v| the_scream(u, v) }
|
|
290
|
+
|
|
291
|
+
def find(id) = PIECES.find { |p| p.id == id.to_sym }
|
|
292
|
+
def ids = PIECES.map(&:id)
|
|
293
|
+
|
|
294
|
+
# Pixel grid (rows of RGB) of w x h pixels.
|
|
295
|
+
def pixels(piece, w, h)
|
|
296
|
+
@cache ||= {}
|
|
297
|
+
@cache[[piece.id, w, h]] ||= Array.new(h) do |y|
|
|
298
|
+
Array.new(w) { |x| Paint.instance_exec((x + 0.5) / w, (y + 0.5) / h, &piece.painter) }
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
# Largest size that fits in cols x rows of cells (two pixels per cell, vertically).
|
|
303
|
+
# => [pixel width, pixel height]
|
|
304
|
+
def fit(piece, cols, rows)
|
|
305
|
+
h = rows * 2
|
|
306
|
+
w = (h * piece.aspect).round
|
|
307
|
+
if w > cols
|
|
308
|
+
w = cols
|
|
309
|
+
h = (w / piece.aspect).round
|
|
310
|
+
end
|
|
311
|
+
[w, h - h % 2]
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
# Pick a piece for a reward: one not yet collected if possible.
|
|
315
|
+
def pick(rng, owned = [])
|
|
316
|
+
fresh = PIECES.reject { |p| owned.include?(p.id) }
|
|
317
|
+
(fresh.empty? ? PIECES : fresh).sample(random: rng)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
LUMA = " .:-=+*#%@"
|
|
321
|
+
|
|
322
|
+
# Draw the piece into a canvas at (x, y). reveal: 0..1 rows shown top-down
|
|
323
|
+
# (a curtain); depth :none falls back to luminance ASCII.
|
|
324
|
+
def draw(canvas, piece, x, y, w, h, reveal: 1.0, depth: :truecolor, &tweak)
|
|
325
|
+
px = pixels(piece, w, h)
|
|
326
|
+
rows = h / 2
|
|
327
|
+
shown = (rows * reveal).ceil
|
|
328
|
+
half = Term.glyph("▀", nil)
|
|
329
|
+
rows.times do |r|
|
|
330
|
+
break if r >= shown
|
|
331
|
+
w.times do |c|
|
|
332
|
+
top = px[r * 2][c]
|
|
333
|
+
bot = px[r * 2 + 1][c]
|
|
334
|
+
top, bot = tweak.(top, bot, c, r) if tweak
|
|
335
|
+
if depth == :none
|
|
336
|
+
l = Color.luminance(Color.mix(top, bot, 0.5))
|
|
337
|
+
canvas.put(x + c, y + r, LUMA[(l / 256.0 * LUMA.size).floor.clamp(0, LUMA.size - 1)])
|
|
338
|
+
elsif half
|
|
339
|
+
canvas.put(x + c, y + r, half, top, bg: bot)
|
|
340
|
+
else
|
|
341
|
+
canvas.put(x + c, y + r, " ", nil, bg: Color.mix(top, bot, 0.5))
|
|
342
|
+
end
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
end
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
Dir[File.join(__dir__, "gallery", "*.rb")].sort.each { |f| require f }
|
data/lib/dopairb/game.rb
CHANGED
|
@@ -9,17 +9,38 @@ module Dopairb
|
|
|
9
9
|
COMBO_MILESTONES = [3, 5, 10, 15, 20, 30, 50, 75, 100].freeze
|
|
10
10
|
COMBO_MEGA = [10, 25, 50, 100].freeze
|
|
11
11
|
EVAL_MILESTONES = [10, 50, 100, 500, 1000, 5000, 10_000].freeze
|
|
12
|
+
# Typing streaks pay out at powers of two: 8, 16, 32, ... 1024.
|
|
13
|
+
TYPING_MILESTONES = (3..10).map { |n| 2**n }.freeze
|
|
14
|
+
FEVER_COMBO = 10
|
|
15
|
+
# Variable rewards: any success may crit (more likely with a full CHARGE).
|
|
16
|
+
CRIT_CHANCE = 0.10
|
|
17
|
+
CRIT_CHARGE_BONUS = 0.12
|
|
18
|
+
CRIT_MULTS = [2, 2, 2, 4, 4, 8].freeze
|
|
19
|
+
JACKPOT_CHANCE = 0.015
|
|
20
|
+
JACKPOT_MULT = 16
|
|
21
|
+
# Bonus art can also just drop (careers only: the gallery lives in the profile).
|
|
22
|
+
ART_DROP_CHANCE = 0.02
|
|
12
23
|
|
|
13
|
-
Event = Struct.new(:kind, :tier, :info, :flags, :gain, :combo, :streak, :score, keyword_init: true) do
|
|
24
|
+
Event = Struct.new(:kind, :tier, :info, :flags, :gain, :combo, :streak, :score, :mult, :level, keyword_init: true) do
|
|
14
25
|
def flag?(f) = flags.include?(f)
|
|
15
26
|
end
|
|
16
27
|
|
|
17
28
|
attr_reader :score, :combo, :max_combo, :evals, :successes, :failures, :interrupts,
|
|
18
29
|
:charge, :error_streak, :record, :typing_combo, :started_at, :last_key_at,
|
|
19
|
-
:keystrokes, :best_typing, :comebacks
|
|
30
|
+
:keystrokes, :best_typing, :comebacks, :crits, :jackpots, :level, :xp_base, :best_combo_ever
|
|
20
31
|
|
|
21
|
-
|
|
32
|
+
# XP needed for a level doubles each time: LV2 at 512, LV3 at 1024, ...
|
|
33
|
+
def self.xp_for(level) = level <= 1 ? 0 : 2**(level + 7)
|
|
34
|
+
|
|
35
|
+
def self.level_for(xp)
|
|
36
|
+
lv = 1
|
|
37
|
+
lv += 1 while xp >= xp_for(lv + 1)
|
|
38
|
+
lv
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }, rng: Random.new)
|
|
22
42
|
@clock = clock
|
|
43
|
+
@rng = rng
|
|
23
44
|
@started_at = now
|
|
24
45
|
@score = 0
|
|
25
46
|
@combo = 0
|
|
@@ -39,8 +60,27 @@ module Dopairb
|
|
|
39
60
|
@typing_points = 0
|
|
40
61
|
@keystrokes = 0
|
|
41
62
|
@comebacks = 0
|
|
63
|
+
@crits = 0
|
|
64
|
+
@jackpots = 0
|
|
65
|
+
@xp_base = 0
|
|
66
|
+
@level = 1
|
|
67
|
+
@best_combo_ever = nil
|
|
68
|
+
@leveling = false
|
|
69
|
+
@streaks_paid = []
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Career so far (from Profile): lifetime XP before this session and the best combo ever.
|
|
73
|
+
def career(xp:, best_combo:)
|
|
74
|
+
@leveling = true
|
|
75
|
+
@xp_base = xp.to_i
|
|
76
|
+
@best_combo_ever = best_combo
|
|
77
|
+
@level = Game.level_for(@xp_base + @score)
|
|
42
78
|
end
|
|
43
79
|
|
|
80
|
+
def xp = @xp_base + @score
|
|
81
|
+
def fever? = @combo >= FEVER_COMBO
|
|
82
|
+
def leveling? = @leveling
|
|
83
|
+
|
|
44
84
|
def now = @clock.call
|
|
45
85
|
|
|
46
86
|
def multiplier(t = nil)
|
|
@@ -56,7 +96,8 @@ module Dopairb
|
|
|
56
96
|
end
|
|
57
97
|
|
|
58
98
|
# kind: :insert, :delete, :move, :bracket, :string, :block, :complete, :history, :newline, :paste
|
|
59
|
-
# Returns :resume when typing restarts after a pause,
|
|
99
|
+
# Returns :resume when typing restarts after a pause, [:streak, n] at a
|
|
100
|
+
# power-of-two typing streak, else nil.
|
|
60
101
|
def key(kind, char = nil)
|
|
61
102
|
t = now
|
|
62
103
|
resumed = @last_key_at && t - @last_key_at > 2.0 && kind == :insert
|
|
@@ -66,6 +107,7 @@ module Dopairb
|
|
|
66
107
|
@typing_combo = kind == :move ? 0 : 1
|
|
67
108
|
end
|
|
68
109
|
@best_typing = @typing_combo if @typing_combo > @best_typing
|
|
110
|
+
milestone = kind == :insert && TYPING_MILESTONES.include?(@typing_combo)
|
|
69
111
|
@last_key_at = t
|
|
70
112
|
@keystrokes += 1 unless kind == :paste
|
|
71
113
|
|
|
@@ -99,7 +141,14 @@ module Dopairb
|
|
|
99
141
|
@typing_points += p
|
|
100
142
|
@score += p
|
|
101
143
|
end
|
|
102
|
-
|
|
144
|
+
return :resume if resumed
|
|
145
|
+
return nil unless milestone
|
|
146
|
+
# outside the per-eval typing cap, but each streak pays once per eval
|
|
147
|
+
unless @streaks_paid.include?(@typing_combo)
|
|
148
|
+
@streaks_paid << @typing_combo
|
|
149
|
+
@score += @typing_combo * 4
|
|
150
|
+
end
|
|
151
|
+
[:streak, @typing_combo]
|
|
103
152
|
end
|
|
104
153
|
|
|
105
154
|
def success(info, code: "", out_lines: 0)
|
|
@@ -119,6 +168,11 @@ module Dopairb
|
|
|
119
168
|
flags << :combo_mega if COMBO_MEGA.include?(@combo)
|
|
120
169
|
flags << :eval_milestone if EVAL_MILESTONES.include?(@evals)
|
|
121
170
|
flags << :output_rain if out_lines >= 20
|
|
171
|
+
flags << :fever if fever?
|
|
172
|
+
flags << :fever_start if @combo == FEVER_COMBO
|
|
173
|
+
if @best_combo_ever && @best_combo_ever >= 5 && @combo == @best_combo_ever + 1
|
|
174
|
+
flags << :combo_best
|
|
175
|
+
end
|
|
122
176
|
|
|
123
177
|
if (num = numeric(info))
|
|
124
178
|
if @record && num > @record && num >= 100
|
|
@@ -134,12 +188,32 @@ module Dopairb
|
|
|
134
188
|
gain += 300 if flags.include?(:new_record)
|
|
135
189
|
gain += 1000 if flags.include?(:eval_milestone)
|
|
136
190
|
gain += @combo * 20 if flags.include?(:combo_milestone)
|
|
191
|
+
mult = 1
|
|
192
|
+
roll = @rng.rand
|
|
193
|
+
if roll < JACKPOT_CHANCE
|
|
194
|
+
flags << :jackpot
|
|
195
|
+
mult = JACKPOT_MULT
|
|
196
|
+
@jackpots += 1
|
|
197
|
+
elsif roll < CRIT_CHANCE + CRIT_CHARGE_BONUS * @charge / CHARGE_MAX
|
|
198
|
+
flags << :critical
|
|
199
|
+
mult = CRIT_MULTS.sample(random: @rng)
|
|
200
|
+
@crits += 1
|
|
201
|
+
end
|
|
202
|
+
flags << :art_drop if @leveling && @rng.rand < ART_DROP_CHANCE
|
|
203
|
+
mult *= 2 if fever?
|
|
204
|
+
gain *= mult
|
|
137
205
|
@score += gain
|
|
138
206
|
@charge = 0.0
|
|
139
207
|
@typing_points = 0
|
|
208
|
+
@streaks_paid.clear
|
|
209
|
+
lv = @leveling ? Game.level_for(xp) : @level
|
|
210
|
+
if lv > @level
|
|
211
|
+
flags << :level_up
|
|
212
|
+
@level = lv
|
|
213
|
+
end
|
|
140
214
|
|
|
141
215
|
Event.new(kind: info[:type] == :definition ? :definition : :success, tier: nil, info: info, flags: flags,
|
|
142
|
-
gain: gain, combo: @combo, streak: streak, score: @score)
|
|
216
|
+
gain: gain, combo: @combo, streak: streak, score: @score, mult: mult, level: @level)
|
|
143
217
|
end
|
|
144
218
|
|
|
145
219
|
def failure(info)
|
|
@@ -150,8 +224,10 @@ module Dopairb
|
|
|
150
224
|
@combo = 0
|
|
151
225
|
@charge = 0.0
|
|
152
226
|
@typing_points = 0
|
|
227
|
+
@streaks_paid.clear
|
|
153
228
|
flags = []
|
|
154
229
|
flags << :combo_break if broken >= 3
|
|
230
|
+
flags << :fever_end if broken >= FEVER_COMBO
|
|
155
231
|
flags << :eval_milestone if EVAL_MILESTONES.include?(@evals)
|
|
156
232
|
Event.new(kind: :failure, tier: nil, info: info.merge(broken_combo: broken), flags: flags, gain: 0,
|
|
157
233
|
combo: 0, streak: @error_streak, score: @score)
|
|
@@ -162,6 +238,7 @@ module Dopairb
|
|
|
162
238
|
lost = @charge
|
|
163
239
|
@charge = 0.0
|
|
164
240
|
@typing_points = 0
|
|
241
|
+
@streaks_paid.clear
|
|
165
242
|
Event.new(kind: :interrupt, tier: nil, info: { charge: lost }, flags: [], gain: 0, combo: @combo,
|
|
166
243
|
streak: @error_streak, score: @score)
|
|
167
244
|
end
|
|
@@ -171,7 +248,7 @@ module Dopairb
|
|
|
171
248
|
def stats
|
|
172
249
|
{ evals: @evals, successes: @successes, failures: @failures, interrupts: @interrupts,
|
|
173
250
|
max_combo: @max_combo, score: @score, comebacks: @comebacks, keystrokes: @keystrokes, best_typing: @best_typing,
|
|
174
|
-
time: elapsed }
|
|
251
|
+
crits: @crits, jackpots: @jackpots, time: elapsed }
|
|
175
252
|
end
|
|
176
253
|
|
|
177
254
|
private
|
data/lib/dopairb/input_fx.rb
CHANGED
|
@@ -6,7 +6,13 @@ module Dopairb
|
|
|
6
6
|
class InputFx
|
|
7
7
|
Layout = Struct.new(:cursor_x, :cursor_row, :row_end_x, :last_row, :screen_width, :room_below, keyword_init: true)
|
|
8
8
|
|
|
9
|
-
STRIP_ROWS =
|
|
9
|
+
STRIP_ROWS = 3
|
|
10
|
+
# Typing streaks at powers of two get a big name.
|
|
11
|
+
STREAK_NAMES = {
|
|
12
|
+
8 => ["8-BIT", "RUSH!"], 16 => ["16-BIT", "COMBO!"], 32 => ["32-BIT", "BLAZE!!"], 64 => ["64-BIT", "OVERDRIVE!!"],
|
|
13
|
+
128 => ["128-BIT", "HYPER MODE!!"], 256 => ["0x100", "OVERFLOW!!!"], 512 => ["2^9", "GODSPEED!!!"],
|
|
14
|
+
1024 => ["1KiB", "TYPING LEGEND!!!"],
|
|
15
|
+
}.freeze
|
|
10
16
|
OPEN = %i[PARENTHESIS_LEFT PARENTHESIS_LEFT_PARENTHESES BRACKET_LEFT BRACKET_LEFT_ARRAY BRACE_LEFT LAMBDA_BEGIN EMBEXPR_BEGIN].freeze
|
|
11
17
|
CLOSE = %i[PARENTHESIS_RIGHT BRACKET_RIGHT BRACE_RIGHT EMBEXPR_END].freeze
|
|
12
18
|
PAIRS = { "(" => ")", "[" => "]", "{" => "}", '#{' => "}" }.freeze
|
|
@@ -22,6 +28,7 @@ module Dopairb
|
|
|
22
28
|
|
|
23
29
|
Highlight = Struct.new(:from, :to, :born, :life, :kind)
|
|
24
30
|
Popup = Struct.new(:text, :x, :born, :life, :palette, :rise, :prio)
|
|
31
|
+
Streak = Struct.new(:n, :x, :born, :life)
|
|
25
32
|
|
|
26
33
|
attr_reader :game
|
|
27
34
|
|
|
@@ -107,6 +114,7 @@ module Dopairb
|
|
|
107
114
|
return true if @popups.any? { |p| t - p.born < p.life }
|
|
108
115
|
return true if @inline && t - @inline.born < @inline.life + 0.1
|
|
109
116
|
return true if @inserts.any? { |it| t - it < 0.4 }
|
|
117
|
+
return true if @streak && t - @streak.born < @streak.life + 0.1
|
|
110
118
|
return true if hl_dirty?(t)
|
|
111
119
|
hud = @config.hud ? hud_key(t) : nil
|
|
112
120
|
if hud != @last_hud
|
|
@@ -126,6 +134,7 @@ module Dopairb
|
|
|
126
134
|
|
|
127
135
|
def reset_line
|
|
128
136
|
@inline = nil
|
|
137
|
+
@streak = nil
|
|
129
138
|
@highlights.clear
|
|
130
139
|
@inserts.clear
|
|
131
140
|
@popups.clear
|
|
@@ -146,6 +155,11 @@ module Dopairb
|
|
|
146
155
|
gauge_on = Color.mix([80, 110, 170], Color.ramp(Color::FIRE, 0.35), heat)
|
|
147
156
|
c = Canvas.new(Term.str_width(text), 1)
|
|
148
157
|
c.put(0, 0, text, fg, bg: bg, bold: heat > 0.5)
|
|
158
|
+
if (f = text.index("FEVER!"))
|
|
159
|
+
"FEVER!".each_char.with_index do |ch, i|
|
|
160
|
+
c.put(f + i, 0, ch, [20, 10, 30], bg: Color.rainbow(i / 6.0 - t * 2), bold: true)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
149
163
|
g = text.index("[")
|
|
150
164
|
if g
|
|
151
165
|
cells = 8
|
|
@@ -158,7 +172,7 @@ module Dopairb
|
|
|
158
172
|
end
|
|
159
173
|
|
|
160
174
|
def hud_key(t)
|
|
161
|
-
[hud_text(200, t), (@game.heat(t) * 12).round, (@game.charge / Game::CHARGE_MAX * 8).round]
|
|
175
|
+
[hud_text(200, t), (@game.heat(t) * 12).round, (@game.charge / Game::CHARGE_MAX * 8).round, @game.fever? ? (t * 15).to_i : 0]
|
|
162
176
|
end
|
|
163
177
|
|
|
164
178
|
def trail_render(lay, t = now)
|
|
@@ -204,9 +218,14 @@ module Dopairb
|
|
|
204
218
|
return nil unless lively?
|
|
205
219
|
@popups.reject! { |p| t - p.born > p.life }
|
|
206
220
|
@parts.prune(t)
|
|
207
|
-
|
|
221
|
+
@streak = nil if @streak && t - @streak.born > @streak.life
|
|
222
|
+
return nil if @popups.empty? && @parts.empty? && @streak.nil?
|
|
208
223
|
w = lay.screen_width - 1
|
|
209
224
|
c = Canvas.new(w, STRIP_ROWS)
|
|
225
|
+
if @streak && draw_streak(c, w, t)
|
|
226
|
+
@parts.draw(c, t)
|
|
227
|
+
return c.render(depth)
|
|
228
|
+
end
|
|
210
229
|
@parts.draw(c, t)
|
|
211
230
|
@popups.each do |p|
|
|
212
231
|
age = (t - p.born) / p.life
|
|
@@ -255,9 +274,11 @@ module Dopairb
|
|
|
255
274
|
score = Fx.number_with_commas(@game.score)
|
|
256
275
|
mult = @game.multiplier(t)
|
|
257
276
|
m = mult > 1.04 ? format(" x%.1f", mult) : ""
|
|
258
|
-
|
|
277
|
+
fever = @game.fever? ? " FEVER!" : ""
|
|
278
|
+
lv = @game.leveling? ? " LV#{@game.level} " : ""
|
|
279
|
+
full = "#{fever}#{lv} COMBO #{format('%02d', @game.combo)} SCORE #{score} [#{' ' * 8}]#{m} "
|
|
259
280
|
return full if Term.str_width(full) <= space
|
|
260
|
-
short = " C#{format('%02d', @game.combo)} #{score} "
|
|
281
|
+
short = "#{fever} C#{format('%02d', @game.combo)} #{score} "
|
|
261
282
|
Term.str_width(short) <= space ? short : nil
|
|
262
283
|
end
|
|
263
284
|
|
|
@@ -313,10 +334,15 @@ module Dopairb
|
|
|
313
334
|
elsif recovering
|
|
314
335
|
pop("RECOVERY", cur_x - 4, t, Color::TOXIC, life: 0.55)
|
|
315
336
|
end
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
337
|
+
if Array === resumed && resumed[0] == :streak
|
|
338
|
+
n = resumed[1]
|
|
339
|
+
sfx(:bits)
|
|
340
|
+
if full?
|
|
341
|
+
@streak = Streak.new(n, cur_x, t, 1.3 * @config.duration)
|
|
342
|
+
@parts.burst(cur_x, 1.0, 40, at: t, speed: 46, life: 0.6, palette: n >= 64 ? Color::NEON : Color::GOLD, gravity: 12)
|
|
343
|
+
else
|
|
344
|
+
pop("#{STREAK_NAMES[n]&.join(' ') || "STREAK #{n}"}", cur_x - 5, t, Color::GOLD, life: 0.9, rise: true, prio: 3)
|
|
345
|
+
end
|
|
320
346
|
end
|
|
321
347
|
syntax_moment(ch, off, new, cur_x, t)
|
|
322
348
|
end
|
|
@@ -471,6 +497,34 @@ module Dopairb
|
|
|
471
497
|
pop("PASTE x#{n}", (cur_x || 0) - 6, t, Color::NEON, life: 0.8, rise: true) if lively?
|
|
472
498
|
end
|
|
473
499
|
|
|
500
|
+
# Big "64-BIT OVERDRIVE!!" in the strip. False when it cannot be drawn big.
|
|
501
|
+
def draw_streak(c, w, t)
|
|
502
|
+
s = @streak
|
|
503
|
+
big, tag = STREAK_NAMES[s.n] || [s.n.to_s, "STREAK!"]
|
|
504
|
+
bw = Font.width(big)
|
|
505
|
+
sub = "TYPING x#{s.n} +#{s.n * 4}"
|
|
506
|
+
total = bw + 2 + [tag.size, sub.size].max
|
|
507
|
+
return false if total > w
|
|
508
|
+
age = (t - s.born) / s.life
|
|
509
|
+
x = (s.x - total / 2).clamp(0, w - total)
|
|
510
|
+
slide = ((1 - Fx.ease_out(age / 0.12)) * 12).round
|
|
511
|
+
c.tint_bg([255, 255, 255], 0.6 * (1 - age / 0.1), [x - 1, 0].max, 0, total + 2, STRIP_ROWS) if age < 0.1 && @config.flash != :off
|
|
512
|
+
pal = s.n >= 64 ? Color::NEON : Color::GOLD
|
|
513
|
+
sweep = (age * 3) % 1.4 - 0.2
|
|
514
|
+
drawn = Fx.half_text(c, big, x + slide, 0, lambda { |_cx, _r, f|
|
|
515
|
+
base = s.n >= 128 ? Color.rainbow(f - t * 2) : Color.ramp(pal, 0.1 + f * 0.3)
|
|
516
|
+
(f - sweep).abs < 0.08 ? [255, 255, 255] : base
|
|
517
|
+
})
|
|
518
|
+
return false unless drawn
|
|
519
|
+
tx = x + bw + 2 + slide
|
|
520
|
+
tag.each_char.with_index do |ch, i|
|
|
521
|
+
col = (t * 16 + i).to_i.even? ? [255, 255, 255] : Color.rainbow(i / 10.0 - t)
|
|
522
|
+
c.put(tx + i, 0, ch, age > 0.7 ? Color.ramp(pal, age) : col, bold: true)
|
|
523
|
+
end
|
|
524
|
+
c.put(tx, 2, sub, Color.ramp(pal, 0.2 + age * 0.5), bold: age < 0.6)
|
|
525
|
+
true
|
|
526
|
+
end
|
|
527
|
+
|
|
474
528
|
def sfx(name)
|
|
475
529
|
@sound.call(name)
|
|
476
530
|
rescue StandardError => e
|
data/lib/dopairb/irb_adapter.rb
CHANGED
|
@@ -28,6 +28,14 @@ module Dopairb
|
|
|
28
28
|
@installed = true
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
+
# Is the current session one that binding.irb opened?
|
|
32
|
+
def from_binding?
|
|
33
|
+
ctx = IRB.conf[:MAIN_CONTEXT]
|
|
34
|
+
ctx.respond_to?(:from_binding?) && ctx.from_binding? ? true : false
|
|
35
|
+
rescue StandardError
|
|
36
|
+
false
|
|
37
|
+
end
|
|
38
|
+
|
|
31
39
|
def expression?(statement)
|
|
32
40
|
defined?(IRB::Statement::Expression) && IRB::Statement::Expression === statement
|
|
33
41
|
end
|