staticfx 2.0.0__py3-none-any.whl
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.
Potentially problematic release.
This version of staticfx might be problematic. Click here for more details.
- staticfx/__init__.py +3 -0
- staticfx/__main__.py +5 -0
- staticfx/app/__init__.py +1 -0
- staticfx/app/app.py +335 -0
- staticfx/app/keymap.py +163 -0
- staticfx/app/player.py +143 -0
- staticfx/app/session.py +97 -0
- staticfx/cli/__init__.py +20 -0
- staticfx/cli/commands.py +293 -0
- staticfx/cli/parser.py +185 -0
- staticfx/core/__init__.py +1 -0
- staticfx/core/clock.py +30 -0
- staticfx/core/color.py +89 -0
- staticfx/core/frame.py +85 -0
- staticfx/core/glyphs.py +15 -0
- staticfx/core/keys.py +116 -0
- staticfx/core/renderer.py +84 -0
- staticfx/core/terminal.py +148 -0
- staticfx/effects/__init__.py +1 -0
- staticfx/effects/base.py +88 -0
- staticfx/effects/builtin/__init__.py +203 -0
- staticfx/effects/builtin/fields.py +404 -0
- staticfx/effects/builtin/geometry.py +77 -0
- staticfx/effects/builtin/particles.py +623 -0
- staticfx/effects/builtin/shapes.py +255 -0
- staticfx/effects/builtin/signals.py +298 -0
- staticfx/effects/builtin/simulations.py +439 -0
- staticfx/effects/builtin/terminal.py +362 -0
- staticfx/effects/curated.py +16 -0
- staticfx/effects/engine/__init__.py +11 -0
- staticfx/effects/engine/effect.py +267 -0
- staticfx/effects/engine/filters.py +115 -0
- staticfx/effects/engine/generate.py +203 -0
- staticfx/effects/engine/layers.py +396 -0
- staticfx/effects/engine/recipe.py +215 -0
- staticfx/effects/engine/schema.py +87 -0
- staticfx/effects/engine/warps.py +203 -0
- staticfx/effects/registry.py +103 -0
- staticfx/effects/spec.py +51 -0
- staticfx/integrations.py +48 -0
- staticfx/library/__init__.py +1 -0
- staticfx/library/config.py +122 -0
- staticfx/library/effects.py +159 -0
- staticfx/library/files.py +44 -0
- staticfx/library/legacy.py +180 -0
- staticfx/library/looks.py +134 -0
- staticfx/postfx.py +78 -0
- staticfx/py.typed +0 -0
- staticfx/themes.py +162 -0
- staticfx/ui/__init__.py +1 -0
- staticfx/ui/browser.py +269 -0
- staticfx/ui/dialogs.py +95 -0
- staticfx/ui/help.py +99 -0
- staticfx/ui/pickers.py +221 -0
- staticfx/ui/play.py +213 -0
- staticfx/ui/screen.py +39 -0
- staticfx/ui/settings.py +262 -0
- staticfx/ui/studio.py +433 -0
- staticfx/ui/widgets.py +196 -0
- staticfx-2.0.0.dist-info/METADATA +164 -0
- staticfx-2.0.0.dist-info/RECORD +65 -0
- staticfx-2.0.0.dist-info/WHEEL +5 -0
- staticfx-2.0.0.dist-info/entry_points.txt +2 -0
- staticfx-2.0.0.dist-info/licenses/LICENSE +21 -0
- staticfx-2.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Wireframes and curves: a cube, a tesseract, spirographs, Lissajous figures, a DNA helix."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import itertools
|
|
6
|
+
import math
|
|
7
|
+
import random
|
|
8
|
+
|
|
9
|
+
from staticfx.core.color import clamp
|
|
10
|
+
from staticfx.core.frame import Frame
|
|
11
|
+
from staticfx.effects.base import Effect
|
|
12
|
+
from staticfx.effects.builtin.geometry import (
|
|
13
|
+
CUBE_EDGES,
|
|
14
|
+
Projected,
|
|
15
|
+
Vec3,
|
|
16
|
+
cube_vertices,
|
|
17
|
+
line_points,
|
|
18
|
+
project,
|
|
19
|
+
rotate,
|
|
20
|
+
)
|
|
21
|
+
from staticfx.themes import Theme
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _draw_segment(
|
|
25
|
+
effect: Effect, frame: Frame, theme: Theme, a: Projected, b: Projected, tone: float
|
|
26
|
+
) -> None:
|
|
27
|
+
"""Draw one edge between two projected points, shaded by depth."""
|
|
28
|
+
chars = "#" if tone > 0.66 else "+" if tone > 0.38 else "."
|
|
29
|
+
color = effect.color_at(theme, 0.2 + 0.8 * tone)
|
|
30
|
+
|
|
31
|
+
for x, y in line_points(int(a.x), int(a.y), int(b.x), int(b.y), limit=512):
|
|
32
|
+
frame.put(x, y, chars, color)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class Cube(Effect):
|
|
36
|
+
def __init__(self, rng: random.Random, unicode: bool = False) -> None:
|
|
37
|
+
super().__init__(rng, unicode)
|
|
38
|
+
self.t = 0.0
|
|
39
|
+
self.vertices = cube_vertices(1.6)
|
|
40
|
+
self.focal = 40.0
|
|
41
|
+
|
|
42
|
+
def on_resize(self) -> None:
|
|
43
|
+
self.focal = max(8.0, min(self.height * 3.2, self.width * 1.1))
|
|
44
|
+
|
|
45
|
+
def advance(self, dt: float) -> None:
|
|
46
|
+
self.t += dt
|
|
47
|
+
|
|
48
|
+
def render(self, frame: Frame, theme: Theme) -> None:
|
|
49
|
+
t = self.t
|
|
50
|
+
rotated = [rotate(v, t * 0.7, t * 1.1, t * 0.3) for v in self.vertices]
|
|
51
|
+
points = [project(v, self.width, self.height, self.focal, 4.0) for v in rotated]
|
|
52
|
+
|
|
53
|
+
for start, end in CUBE_EDGES:
|
|
54
|
+
a, b = points[start], points[end]
|
|
55
|
+
|
|
56
|
+
if a is None or b is None:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
depth = (a.depth + b.depth) / 2
|
|
60
|
+
_draw_segment(self, frame, theme, a, b, clamp(1.6 - (depth - 2.6) / 2.4))
|
|
61
|
+
|
|
62
|
+
for point in points:
|
|
63
|
+
if point is not None:
|
|
64
|
+
frame.put(int(point.x), int(point.y), "@", theme.bright)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _rotate_4d(
|
|
68
|
+
point: tuple[float, ...], angle_xw: float, angle_zw: float, angle_xy: float
|
|
69
|
+
) -> tuple[float, float, float, float]:
|
|
70
|
+
x, y, z, w = point
|
|
71
|
+
|
|
72
|
+
c, s = math.cos(angle_xw), math.sin(angle_xw)
|
|
73
|
+
x, w = x * c - w * s, x * s + w * c
|
|
74
|
+
|
|
75
|
+
c, s = math.cos(angle_zw), math.sin(angle_zw)
|
|
76
|
+
z, w = z * c - w * s, z * s + w * c
|
|
77
|
+
|
|
78
|
+
c, s = math.cos(angle_xy), math.sin(angle_xy)
|
|
79
|
+
x, y = x * c - y * s, x * s + y * c
|
|
80
|
+
|
|
81
|
+
return x, y, z, w
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class Tesseract(Effect):
|
|
85
|
+
def __init__(self, rng: random.Random, unicode: bool = False) -> None:
|
|
86
|
+
super().__init__(rng, unicode)
|
|
87
|
+
self.t = 0.0
|
|
88
|
+
self.focal = 40.0
|
|
89
|
+
self.vertices = [
|
|
90
|
+
tuple(0.7 * s for s in signs) for signs in itertools.product((-1, 1), repeat=4)
|
|
91
|
+
]
|
|
92
|
+
self.edges = [
|
|
93
|
+
(i, j)
|
|
94
|
+
for i in range(16)
|
|
95
|
+
for j in range(i + 1, 16)
|
|
96
|
+
if sum(a != b for a, b in zip(self.vertices[i], self.vertices[j], strict=True)) == 1
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
def on_resize(self) -> None:
|
|
100
|
+
self.focal = max(8.0, min(self.height * 3.0, self.width * 1.0))
|
|
101
|
+
|
|
102
|
+
def advance(self, dt: float) -> None:
|
|
103
|
+
self.t += dt
|
|
104
|
+
|
|
105
|
+
def render(self, frame: Frame, theme: Theme) -> None:
|
|
106
|
+
t = self.t
|
|
107
|
+
projected = []
|
|
108
|
+
|
|
109
|
+
for vertex in self.vertices:
|
|
110
|
+
x, y, z, w = _rotate_4d(vertex, t * 0.55, t * 0.8, t * 0.25)
|
|
111
|
+
scale = 1.0 / (2.2 - w)
|
|
112
|
+
point3 = Vec3(x * scale * 2.2, y * scale * 2.2, z * scale * 2.2)
|
|
113
|
+
|
|
114
|
+
point3 = rotate(point3, t * 0.3, t * 0.4, 0.0)
|
|
115
|
+
projected.append((project(point3, self.width, self.height, self.focal, 4.0), w))
|
|
116
|
+
|
|
117
|
+
for start, end in self.edges:
|
|
118
|
+
(a, wa), (b, wb) = projected[start], projected[end]
|
|
119
|
+
|
|
120
|
+
if a is None or b is None:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
tone = clamp(0.55 + 0.35 * (wa + wb))
|
|
124
|
+
_draw_segment(self, frame, theme, a, b, tone)
|
|
125
|
+
|
|
126
|
+
for point, _ in projected:
|
|
127
|
+
if point is not None:
|
|
128
|
+
frame.put(int(point.x), int(point.y), "@", theme.bright)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Spirograph(Effect):
|
|
132
|
+
RATIOS = ((5, 3), (7, 3), (8, 3), (7, 5), (9, 4), (11, 4), (5, 2), (13, 5))
|
|
133
|
+
|
|
134
|
+
def __init__(self, rng: random.Random, unicode: bool = False) -> None:
|
|
135
|
+
super().__init__(rng, unicode)
|
|
136
|
+
self.theta = 0.0
|
|
137
|
+
self.pick()
|
|
138
|
+
|
|
139
|
+
def pick(self) -> None:
|
|
140
|
+
big, small = self.rng.choice(self.RATIOS)
|
|
141
|
+
self.big = float(big)
|
|
142
|
+
self.small = float(small)
|
|
143
|
+
self.offset = self.small * self.rng.uniform(0.6, 1.0)
|
|
144
|
+
self.limit = (self.big - self.small) + self.offset
|
|
145
|
+
self.span = math.tau * (self.small / math.gcd(big, small))
|
|
146
|
+
|
|
147
|
+
def on_reset(self) -> None:
|
|
148
|
+
self.theta = 0.0
|
|
149
|
+
self.pick()
|
|
150
|
+
|
|
151
|
+
def advance(self, dt: float) -> None:
|
|
152
|
+
self.theta += dt * 1.8
|
|
153
|
+
|
|
154
|
+
if self.theta > self.span * 3.2:
|
|
155
|
+
self.on_reset()
|
|
156
|
+
|
|
157
|
+
def render(self, frame: Frame, theme: Theme) -> None:
|
|
158
|
+
samples = 520
|
|
159
|
+
tail = min(self.span, 14.0)
|
|
160
|
+
scale = (self.height / 2 - 1.0) / self.limit
|
|
161
|
+
cx = (self.width - 1) / 2
|
|
162
|
+
cy = (self.height - 1) / 2
|
|
163
|
+
ratio = (self.big - self.small) / self.small
|
|
164
|
+
|
|
165
|
+
for index in range(samples):
|
|
166
|
+
fraction = index / samples
|
|
167
|
+
angle = self.theta - tail * (1.0 - fraction)
|
|
168
|
+
|
|
169
|
+
if angle < 0:
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
px = (self.big - self.small) * math.cos(angle) + self.offset * math.cos(ratio * angle)
|
|
173
|
+
py = (self.big - self.small) * math.sin(angle) - self.offset * math.sin(ratio * angle)
|
|
174
|
+
tone = 0.15 + 0.85 * fraction
|
|
175
|
+
char = (
|
|
176
|
+
"@" if index == samples - 1 else "*" if tone > 0.6 else "+" if tone > 0.35 else "."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
frame.put(
|
|
180
|
+
int(cx + px * scale * 2.0), int(cy + py * scale), char, self.color_at(theme, tone)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class Lissajous(Effect):
|
|
185
|
+
def __init__(self, rng: random.Random, unicode: bool = False) -> None:
|
|
186
|
+
super().__init__(rng, unicode)
|
|
187
|
+
self.t = 0.0
|
|
188
|
+
self.a = 3
|
|
189
|
+
self.b = 2
|
|
190
|
+
self.pick()
|
|
191
|
+
|
|
192
|
+
def pick(self) -> None:
|
|
193
|
+
self.a, self.b = self.rng.choice(
|
|
194
|
+
((3, 2), (3, 4), (5, 4), (5, 6), (1, 2), (2, 3), (4, 5), (5, 3))
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
def on_reset(self) -> None:
|
|
198
|
+
self.t = 0.0
|
|
199
|
+
self.pick()
|
|
200
|
+
|
|
201
|
+
def advance(self, dt: float) -> None:
|
|
202
|
+
self.t += dt
|
|
203
|
+
|
|
204
|
+
def render(self, frame: Frame, theme: Theme) -> None:
|
|
205
|
+
samples = 640
|
|
206
|
+
cx = (self.width - 1) / 2
|
|
207
|
+
cy = (self.height - 1) / 2
|
|
208
|
+
ax = cx - 1.0
|
|
209
|
+
ay = cy - 0.5
|
|
210
|
+
drift = self.t * 0.35
|
|
211
|
+
head = (self.t * 1.6) % 1.0
|
|
212
|
+
|
|
213
|
+
for index in range(samples):
|
|
214
|
+
u = math.tau * index / samples
|
|
215
|
+
x = math.sin(self.a * u + drift)
|
|
216
|
+
y = math.sin(self.b * u)
|
|
217
|
+
fraction = ((index / samples) - head) % 1.0
|
|
218
|
+
tone = 0.15 + 0.85 * (1.0 - fraction)
|
|
219
|
+
char = "@" if fraction < 0.01 else "*" if tone > 0.6 else "+" if tone > 0.35 else "."
|
|
220
|
+
|
|
221
|
+
frame.put(int(cx + x * ax), int(cy + y * ay), char, self.color_at(theme, tone))
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class DNAHelix(Effect):
|
|
225
|
+
def __init__(self, rng: random.Random, unicode: bool = False) -> None:
|
|
226
|
+
super().__init__(rng, unicode)
|
|
227
|
+
self.t = 0.0
|
|
228
|
+
|
|
229
|
+
def advance(self, dt: float) -> None:
|
|
230
|
+
self.t += dt
|
|
231
|
+
|
|
232
|
+
def render(self, frame: Frame, theme: Theme) -> None:
|
|
233
|
+
cx = (self.width - 1) / 2
|
|
234
|
+
amplitude = max(3.0, min(self.width * 0.22, 14.0))
|
|
235
|
+
draw_list = []
|
|
236
|
+
|
|
237
|
+
for y in range(self.height):
|
|
238
|
+
phase = y * 0.42 + self.t * 2.2
|
|
239
|
+
sine, cosine = math.sin(phase), math.cos(phase)
|
|
240
|
+
|
|
241
|
+
if y % 3 == 0:
|
|
242
|
+
left = int(cx - amplitude * sine)
|
|
243
|
+
right = int(cx + amplitude * sine)
|
|
244
|
+
rung = self.color_at(theme, 0.3)
|
|
245
|
+
|
|
246
|
+
for x in range(min(left, right) + 1, max(left, right)):
|
|
247
|
+
frame.put(x, y, "-", rung)
|
|
248
|
+
|
|
249
|
+
draw_list.append((cosine, int(cx + amplitude * sine), y))
|
|
250
|
+
draw_list.append((-cosine, int(cx - amplitude * sine), y))
|
|
251
|
+
|
|
252
|
+
for depth, x, y in sorted(draw_list):
|
|
253
|
+
near = 0.5 + 0.5 * depth
|
|
254
|
+
char = "@" if near > 0.8 else "O" if near > 0.5 else "o"
|
|
255
|
+
frame.put(x, y, char, self.color_at(theme, 0.25 + 0.75 * near))
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Broken signals: TV static, a failing test card, scope traces, lightning, a spectrum."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
|
|
8
|
+
from staticfx.core.color import clamp
|
|
9
|
+
from staticfx.core.frame import Frame
|
|
10
|
+
from staticfx.core.glyphs import tech_glyphs
|
|
11
|
+
from staticfx.effects.base import Effect
|
|
12
|
+
from staticfx.themes import Theme
|
|
13
|
+
|
|
14
|
+
NOISE_SHADE = " .:-=+*#%@"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StaticNoise(Effect):
|
|
18
|
+
def __init__(self, rng: random.Random, unicode: bool = False) -> None:
|
|
19
|
+
super().__init__(rng, unicode)
|
|
20
|
+
self.t = 0.0
|
|
21
|
+
|
|
22
|
+
def advance(self, dt: float) -> None:
|
|
23
|
+
self.t += dt
|
|
24
|
+
|
|
25
|
+
def render(self, frame: Frame, theme: Theme) -> None:
|
|
26
|
+
palette = self.palette(theme)
|
|
27
|
+
top = len(palette) - 1
|
|
28
|
+
random = self.rng.random
|
|
29
|
+
band = (self.t * 0.22) % 1.4 * self.height - self.height * 0.2
|
|
30
|
+
|
|
31
|
+
for y in range(self.height):
|
|
32
|
+
# A slow dark roll-bar drifts down the screen.
|
|
33
|
+
dim = 0.45 if abs(y - band) < max(1.5, self.height * 0.06) else 1.0
|
|
34
|
+
|
|
35
|
+
for x in range(self.width):
|
|
36
|
+
v = random() ** 0.8 * dim
|
|
37
|
+
index = int(v * 9)
|
|
38
|
+
|
|
39
|
+
if index:
|
|
40
|
+
frame.put(x, y, NOISE_SHADE[index], palette[int(v * top)])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TVGlitch(Effect):
|
|
44
|
+
def __init__(self, rng: random.Random, unicode: bool = False) -> None:
|
|
45
|
+
super().__init__(rng, unicode)
|
|
46
|
+
self.t = 0.0
|
|
47
|
+
self.roll = 0.0
|
|
48
|
+
self.roll_speed = 0.0
|
|
49
|
+
self.tears: list[list[float]] = []
|
|
50
|
+
self.cooldown = 0.0
|
|
51
|
+
|
|
52
|
+
def on_resize(self) -> None:
|
|
53
|
+
self.tears = []
|
|
54
|
+
|
|
55
|
+
def advance(self, dt: float) -> None:
|
|
56
|
+
rng = self.rng
|
|
57
|
+
self.t += dt
|
|
58
|
+
self.cooldown -= dt
|
|
59
|
+
self.roll += self.roll_speed * dt
|
|
60
|
+
self.roll_speed *= max(0.0, 1.0 - 3.0 * dt)
|
|
61
|
+
|
|
62
|
+
for tear in self.tears:
|
|
63
|
+
tear[3] -= dt
|
|
64
|
+
|
|
65
|
+
self.tears = [tear for tear in self.tears if tear[3] > 0.0]
|
|
66
|
+
|
|
67
|
+
if self.cooldown <= 0.0:
|
|
68
|
+
self.cooldown = rng.uniform(0.08, 0.5)
|
|
69
|
+
|
|
70
|
+
for _ in range(rng.randint(1, 3)):
|
|
71
|
+
self.tears.append(
|
|
72
|
+
[
|
|
73
|
+
rng.randrange(self.height),
|
|
74
|
+
rng.randint(1, max(1, self.height // 7)),
|
|
75
|
+
rng.randint(-self.width // 4, max(1, self.width // 4)),
|
|
76
|
+
rng.uniform(0.06, 0.3),
|
|
77
|
+
]
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
if rng.random() < 0.12:
|
|
81
|
+
self.roll_speed = rng.uniform(15.0, 40.0)
|
|
82
|
+
|
|
83
|
+
def render(self, frame: Frame, theme: Theme) -> None:
|
|
84
|
+
rng = self.rng
|
|
85
|
+
palette = self.palette(theme)
|
|
86
|
+
top = len(palette) - 1
|
|
87
|
+
roll = int(self.roll)
|
|
88
|
+
shifts = {}
|
|
89
|
+
|
|
90
|
+
for start, size, shift, _ in self.tears:
|
|
91
|
+
for row in range(int(start), int(start + size)):
|
|
92
|
+
shifts[row % self.height] = int(shift)
|
|
93
|
+
|
|
94
|
+
for y in range(self.height):
|
|
95
|
+
row_shift = shifts.get(y)
|
|
96
|
+
noisy = row_shift is not None and rng.random() < 0.55
|
|
97
|
+
|
|
98
|
+
for x in range(self.width):
|
|
99
|
+
if noisy:
|
|
100
|
+
v = rng.random()
|
|
101
|
+
char = NOISE_SHADE[int(v * 9)]
|
|
102
|
+
else:
|
|
103
|
+
sx = x - (row_shift or 0)
|
|
104
|
+
checker = ((sx // 6) + ((y + roll) // 3)) & 1
|
|
105
|
+
v = 0.5 if checker else 0.14
|
|
106
|
+
|
|
107
|
+
if rng.random() < 0.04:
|
|
108
|
+
v = rng.random()
|
|
109
|
+
|
|
110
|
+
char = NOISE_SHADE[int(v * 9)]
|
|
111
|
+
|
|
112
|
+
if char != " ":
|
|
113
|
+
frame.put(x, y, char, palette[int(v * top)])
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class SignalScramble(Effect):
|
|
117
|
+
def __init__(self, rng: random.Random, unicode: bool = False) -> None:
|
|
118
|
+
super().__init__(rng, unicode)
|
|
119
|
+
self.t = 0.0
|
|
120
|
+
self.pool = tech_glyphs(unicode)
|
|
121
|
+
self.bursts: list[list[float]] = []
|
|
122
|
+
self.cooldown = 0.0
|
|
123
|
+
|
|
124
|
+
def on_resize(self) -> None:
|
|
125
|
+
self.bursts = []
|
|
126
|
+
|
|
127
|
+
def advance(self, dt: float) -> None:
|
|
128
|
+
rng = self.rng
|
|
129
|
+
self.t += dt
|
|
130
|
+
self.cooldown -= dt
|
|
131
|
+
|
|
132
|
+
for burst in self.bursts:
|
|
133
|
+
burst[2] -= dt
|
|
134
|
+
|
|
135
|
+
self.bursts = [burst for burst in self.bursts if burst[2] > 0.0]
|
|
136
|
+
|
|
137
|
+
if self.cooldown <= 0.0:
|
|
138
|
+
self.cooldown = rng.uniform(0.15, 0.7)
|
|
139
|
+
start = rng.randrange(self.width)
|
|
140
|
+
self.bursts.append(
|
|
141
|
+
[start, start + rng.randint(4, max(5, self.width // 3)), rng.uniform(0.1, 0.4)]
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def _wave(self, x: float, which: int) -> float:
|
|
145
|
+
t = self.t
|
|
146
|
+
cy = (self.height - 1) / 2
|
|
147
|
+
amp = self.height * (0.32 - 0.07 * which)
|
|
148
|
+
|
|
149
|
+
return cy + amp * (
|
|
150
|
+
math.sin(x * (0.11 + 0.04 * which) + t * (2.0 + which))
|
|
151
|
+
* (0.6 + 0.4 * math.sin(x * 0.03 - t * 0.8))
|
|
152
|
+
+ 0.25 * math.sin(x * 0.37 + t * 5.0)
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
def render(self, frame: Frame, theme: Theme) -> None:
|
|
156
|
+
rng = self.rng
|
|
157
|
+
tones = (0.95, 0.65, 0.4)
|
|
158
|
+
|
|
159
|
+
for which, tone in enumerate(tones):
|
|
160
|
+
color = self.color_at(theme, tone)
|
|
161
|
+
|
|
162
|
+
for x in range(self.width):
|
|
163
|
+
y = self._wave(x, which)
|
|
164
|
+
slope = self._wave(x + 1, which) - y
|
|
165
|
+
char = "\\" if slope > 0.7 else "/" if slope < -0.7 else "-"
|
|
166
|
+
frame.put(x, round(y), char, color)
|
|
167
|
+
|
|
168
|
+
bright = theme.bright
|
|
169
|
+
|
|
170
|
+
for start, end, _ in self.bursts:
|
|
171
|
+
for x in range(int(start), min(self.width, int(end))):
|
|
172
|
+
jitter = rng.randint(-3, 3)
|
|
173
|
+
y = round(self._wave(x, 0)) + jitter
|
|
174
|
+
frame.put(x, y, rng.choice(self.pool), bright)
|
|
175
|
+
frame.put(
|
|
176
|
+
x, y + rng.choice((-1, 1)), rng.choice(self.pool), self.color_at(theme, 0.5)
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class WaveBars(Effect):
|
|
181
|
+
def __init__(self, rng: random.Random, unicode: bool = False) -> None:
|
|
182
|
+
super().__init__(rng, unicode)
|
|
183
|
+
self.levels: list[float] = []
|
|
184
|
+
self.targets: list[float] = []
|
|
185
|
+
self.peaks: list[float] = []
|
|
186
|
+
self.t = 0.0
|
|
187
|
+
|
|
188
|
+
def on_resize(self) -> None:
|
|
189
|
+
count = max(1, self.width // 3)
|
|
190
|
+
self.levels = [0.0] * count
|
|
191
|
+
self.targets = [0.0] * count
|
|
192
|
+
self.peaks = [0.0] * count
|
|
193
|
+
|
|
194
|
+
def advance(self, dt: float) -> None:
|
|
195
|
+
rng = self.rng
|
|
196
|
+
count = len(self.levels)
|
|
197
|
+
self.t += dt
|
|
198
|
+
|
|
199
|
+
for i in range(count):
|
|
200
|
+
if rng.random() < 5.0 * dt:
|
|
201
|
+
shape = 0.3 + 0.7 * math.sin(math.pi * (i + 0.5) / count)
|
|
202
|
+
self.targets[i] = shape * rng.random() ** 0.6
|
|
203
|
+
|
|
204
|
+
gain = 14.0 if self.targets[i] > self.levels[i] else 4.0
|
|
205
|
+
self.levels[i] += (self.targets[i] - self.levels[i]) * min(1.0, gain * dt)
|
|
206
|
+
self.peaks[i] = max(self.levels[i], self.peaks[i] - 0.35 * dt)
|
|
207
|
+
|
|
208
|
+
def render(self, frame: Frame, theme: Theme) -> None:
|
|
209
|
+
block = "█" if self.unicode else "#"
|
|
210
|
+
height = self.height
|
|
211
|
+
|
|
212
|
+
for i, level in enumerate(self.levels):
|
|
213
|
+
filled = int(level * (height - 1) + 0.5)
|
|
214
|
+
peak = int(self.peaks[i] * (height - 1) + 0.5)
|
|
215
|
+
|
|
216
|
+
for row in range(filled):
|
|
217
|
+
y = height - 1 - row
|
|
218
|
+
color = self.color_at(theme, 0.25 + 0.75 * (row / max(1, height - 1)))
|
|
219
|
+
|
|
220
|
+
for dx in (0, 1):
|
|
221
|
+
frame.put(i * 3 + dx, y, block, color)
|
|
222
|
+
|
|
223
|
+
if peak > 0:
|
|
224
|
+
for dx in (0, 1):
|
|
225
|
+
frame.put(i * 3 + dx, height - 1 - peak, "=", theme.bright)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class ElectricStorm(Effect):
|
|
229
|
+
def __init__(self, rng: random.Random, unicode: bool = False) -> None:
|
|
230
|
+
super().__init__(rng, unicode)
|
|
231
|
+
self.bolts: list[dict] = []
|
|
232
|
+
self.flash = 0.0
|
|
233
|
+
self.cooldown = 0.3
|
|
234
|
+
|
|
235
|
+
def on_resize(self) -> None:
|
|
236
|
+
self.bolts = []
|
|
237
|
+
|
|
238
|
+
def _make_bolt(self) -> dict:
|
|
239
|
+
rng = self.rng
|
|
240
|
+
x = rng.randrange(self.width)
|
|
241
|
+
cells: list[tuple[int, int, str]] = []
|
|
242
|
+
stack = [(x, 0, 1.0)]
|
|
243
|
+
|
|
244
|
+
while stack:
|
|
245
|
+
bx, by, vigor = stack.pop()
|
|
246
|
+
|
|
247
|
+
while by < self.height and 0 <= bx < self.width:
|
|
248
|
+
step = rng.choice((-1, 0, 0, 1))
|
|
249
|
+
char = "/" if step < 0 else "\\" if step > 0 else "|"
|
|
250
|
+
cells.append((bx, by, char))
|
|
251
|
+
bx += step
|
|
252
|
+
by += 1
|
|
253
|
+
|
|
254
|
+
if vigor > 0.5 and rng.random() < 0.07:
|
|
255
|
+
stack.append((bx, by, vigor * 0.5))
|
|
256
|
+
|
|
257
|
+
if vigor < 0.5 and rng.random() < 0.12:
|
|
258
|
+
break
|
|
259
|
+
|
|
260
|
+
life = rng.uniform(0.25, 0.5)
|
|
261
|
+
return {"cells": cells, "life": life, "max": life}
|
|
262
|
+
|
|
263
|
+
def advance(self, dt: float) -> None:
|
|
264
|
+
rng = self.rng
|
|
265
|
+
self.cooldown -= dt
|
|
266
|
+
self.flash = max(0.0, self.flash - dt * 4.0)
|
|
267
|
+
|
|
268
|
+
if self.cooldown <= 0.0:
|
|
269
|
+
self.cooldown = rng.uniform(0.35, 1.6)
|
|
270
|
+
|
|
271
|
+
for _ in range(rng.choice((1, 1, 2))):
|
|
272
|
+
self.bolts.append(self._make_bolt())
|
|
273
|
+
|
|
274
|
+
self.flash = 1.0
|
|
275
|
+
|
|
276
|
+
for bolt in self.bolts:
|
|
277
|
+
bolt["life"] -= dt
|
|
278
|
+
|
|
279
|
+
self.bolts = [bolt for bolt in self.bolts if bolt["life"] > 0.0]
|
|
280
|
+
|
|
281
|
+
def render(self, frame: Frame, theme: Theme) -> None:
|
|
282
|
+
rng = self.rng
|
|
283
|
+
|
|
284
|
+
# Ambient crackle, brighter right after a strike.
|
|
285
|
+
density = 0.004 + 0.05 * self.flash
|
|
286
|
+
color = self.color_at(theme, 0.2 + 0.4 * self.flash)
|
|
287
|
+
|
|
288
|
+
for _ in range(int(self.width * self.height * density)):
|
|
289
|
+
frame.put(
|
|
290
|
+
rng.randrange(self.width), rng.randrange(self.height), rng.choice(".:'`"), color
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
for bolt in self.bolts:
|
|
294
|
+
fade = clamp(bolt["life"] / bolt["max"])
|
|
295
|
+
bolt_color = theme.bright if fade > 0.5 else self.color_at(theme, 0.4 + fade)
|
|
296
|
+
|
|
297
|
+
for x, y, char in bolt["cells"]:
|
|
298
|
+
frame.put(x, y, char, bolt_color)
|