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 +4 -4
- data/README.md +63 -10
- data/lib/dopairb/canvas.rb +39 -16
- data/lib/dopairb/cli.rb +5 -7
- data/lib/dopairb/config.rb +11 -5
- data/lib/dopairb/demo.rb +22 -4
- data/lib/dopairb/director.rb +75 -12
- 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 +90 -9
- data/lib/dopairb/input_fx.rb +78 -10
- data/lib/dopairb/irb_adapter.rb +8 -0
- data/lib/dopairb/output_tap.rb +10 -0
- data/lib/dopairb/profile.rb +106 -0
- data/lib/dopairb/reline_adapter.rb +1 -0
- data/lib/dopairb/scene.rb +12 -0
- data/lib/dopairb/scenes/bonus.rb +279 -0
- data/lib/dopairb/scenes/failure.rb +7 -0
- data/lib/dopairb/scenes/finale.rb +326 -0
- data/lib/dopairb/scenes/session.rb +115 -3
- data/lib/dopairb/scenes/success.rb +22 -4
- data/lib/dopairb/session.rb +57 -6
- data/lib/dopairb/sound.rb +420 -0
- 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 +73 -3
- data/spec.md +3 -3
- metadata +12 -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
|
|
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
|
|
@@ -38,8 +59,28 @@ module Dopairb
|
|
|
38
59
|
@same_char = 0
|
|
39
60
|
@typing_points = 0
|
|
40
61
|
@keystrokes = 0
|
|
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 = []
|
|
41
70
|
end
|
|
42
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)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def xp = @xp_base + @score
|
|
81
|
+
def fever? = @combo >= FEVER_COMBO
|
|
82
|
+
def leveling? = @leveling
|
|
83
|
+
|
|
43
84
|
def now = @clock.call
|
|
44
85
|
|
|
45
86
|
def multiplier(t = nil)
|
|
@@ -55,7 +96,8 @@ module Dopairb
|
|
|
55
96
|
end
|
|
56
97
|
|
|
57
98
|
# kind: :insert, :delete, :move, :bracket, :string, :block, :complete, :history, :newline, :paste
|
|
58
|
-
# 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.
|
|
59
101
|
def key(kind, char = nil)
|
|
60
102
|
t = now
|
|
61
103
|
resumed = @last_key_at && t - @last_key_at > 2.0 && kind == :insert
|
|
@@ -65,6 +107,7 @@ module Dopairb
|
|
|
65
107
|
@typing_combo = kind == :move ? 0 : 1
|
|
66
108
|
end
|
|
67
109
|
@best_typing = @typing_combo if @typing_combo > @best_typing
|
|
110
|
+
milestone = kind == :insert && TYPING_MILESTONES.include?(@typing_combo)
|
|
68
111
|
@last_key_at = t
|
|
69
112
|
@keystrokes += 1 unless kind == :paste
|
|
70
113
|
|
|
@@ -98,7 +141,14 @@ module Dopairb
|
|
|
98
141
|
@typing_points += p
|
|
99
142
|
@score += p
|
|
100
143
|
end
|
|
101
|
-
|
|
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]
|
|
102
152
|
end
|
|
103
153
|
|
|
104
154
|
def success(info, code: "", out_lines: 0)
|
|
@@ -106,7 +156,10 @@ module Dopairb
|
|
|
106
156
|
@successes += 1
|
|
107
157
|
flags = []
|
|
108
158
|
flags << :first_hit if @successes == 1
|
|
109
|
-
|
|
159
|
+
if @error_streak > 0
|
|
160
|
+
flags << :comeback
|
|
161
|
+
@comebacks += 1
|
|
162
|
+
end
|
|
110
163
|
streak = @error_streak
|
|
111
164
|
@error_streak = 0
|
|
112
165
|
@combo += 1
|
|
@@ -115,6 +168,11 @@ module Dopairb
|
|
|
115
168
|
flags << :combo_mega if COMBO_MEGA.include?(@combo)
|
|
116
169
|
flags << :eval_milestone if EVAL_MILESTONES.include?(@evals)
|
|
117
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
|
|
118
176
|
|
|
119
177
|
if (num = numeric(info))
|
|
120
178
|
if @record && num > @record && num >= 100
|
|
@@ -130,12 +188,32 @@ module Dopairb
|
|
|
130
188
|
gain += 300 if flags.include?(:new_record)
|
|
131
189
|
gain += 1000 if flags.include?(:eval_milestone)
|
|
132
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
|
|
133
205
|
@score += gain
|
|
134
206
|
@charge = 0.0
|
|
135
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
|
|
136
214
|
|
|
137
215
|
Event.new(kind: info[:type] == :definition ? :definition : :success, tier: nil, info: info, flags: flags,
|
|
138
|
-
gain: gain, combo: @combo, streak: streak, score: @score)
|
|
216
|
+
gain: gain, combo: @combo, streak: streak, score: @score, mult: mult, level: @level)
|
|
139
217
|
end
|
|
140
218
|
|
|
141
219
|
def failure(info)
|
|
@@ -146,8 +224,10 @@ module Dopairb
|
|
|
146
224
|
@combo = 0
|
|
147
225
|
@charge = 0.0
|
|
148
226
|
@typing_points = 0
|
|
227
|
+
@streaks_paid.clear
|
|
149
228
|
flags = []
|
|
150
229
|
flags << :combo_break if broken >= 3
|
|
230
|
+
flags << :fever_end if broken >= FEVER_COMBO
|
|
151
231
|
flags << :eval_milestone if EVAL_MILESTONES.include?(@evals)
|
|
152
232
|
Event.new(kind: :failure, tier: nil, info: info.merge(broken_combo: broken), flags: flags, gain: 0,
|
|
153
233
|
combo: 0, streak: @error_streak, score: @score)
|
|
@@ -158,6 +238,7 @@ module Dopairb
|
|
|
158
238
|
lost = @charge
|
|
159
239
|
@charge = 0.0
|
|
160
240
|
@typing_points = 0
|
|
241
|
+
@streaks_paid.clear
|
|
161
242
|
Event.new(kind: :interrupt, tier: nil, info: { charge: lost }, flags: [], gain: 0, combo: @combo,
|
|
162
243
|
streak: @error_streak, score: @score)
|
|
163
244
|
end
|
|
@@ -166,8 +247,8 @@ module Dopairb
|
|
|
166
247
|
|
|
167
248
|
def stats
|
|
168
249
|
{ evals: @evals, successes: @successes, failures: @failures, interrupts: @interrupts,
|
|
169
|
-
max_combo: @max_combo, score: @score, keystrokes: @keystrokes, best_typing: @best_typing,
|
|
170
|
-
time: elapsed }
|
|
250
|
+
max_combo: @max_combo, score: @score, comebacks: @comebacks, keystrokes: @keystrokes, best_typing: @best_typing,
|
|
251
|
+
crits: @crits, jackpots: @jackpots, time: elapsed }
|
|
171
252
|
end
|
|
172
253
|
|
|
173
254
|
private
|