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.

Files changed (65) hide show
  1. staticfx/__init__.py +3 -0
  2. staticfx/__main__.py +5 -0
  3. staticfx/app/__init__.py +1 -0
  4. staticfx/app/app.py +335 -0
  5. staticfx/app/keymap.py +163 -0
  6. staticfx/app/player.py +143 -0
  7. staticfx/app/session.py +97 -0
  8. staticfx/cli/__init__.py +20 -0
  9. staticfx/cli/commands.py +293 -0
  10. staticfx/cli/parser.py +185 -0
  11. staticfx/core/__init__.py +1 -0
  12. staticfx/core/clock.py +30 -0
  13. staticfx/core/color.py +89 -0
  14. staticfx/core/frame.py +85 -0
  15. staticfx/core/glyphs.py +15 -0
  16. staticfx/core/keys.py +116 -0
  17. staticfx/core/renderer.py +84 -0
  18. staticfx/core/terminal.py +148 -0
  19. staticfx/effects/__init__.py +1 -0
  20. staticfx/effects/base.py +88 -0
  21. staticfx/effects/builtin/__init__.py +203 -0
  22. staticfx/effects/builtin/fields.py +404 -0
  23. staticfx/effects/builtin/geometry.py +77 -0
  24. staticfx/effects/builtin/particles.py +623 -0
  25. staticfx/effects/builtin/shapes.py +255 -0
  26. staticfx/effects/builtin/signals.py +298 -0
  27. staticfx/effects/builtin/simulations.py +439 -0
  28. staticfx/effects/builtin/terminal.py +362 -0
  29. staticfx/effects/curated.py +16 -0
  30. staticfx/effects/engine/__init__.py +11 -0
  31. staticfx/effects/engine/effect.py +267 -0
  32. staticfx/effects/engine/filters.py +115 -0
  33. staticfx/effects/engine/generate.py +203 -0
  34. staticfx/effects/engine/layers.py +396 -0
  35. staticfx/effects/engine/recipe.py +215 -0
  36. staticfx/effects/engine/schema.py +87 -0
  37. staticfx/effects/engine/warps.py +203 -0
  38. staticfx/effects/registry.py +103 -0
  39. staticfx/effects/spec.py +51 -0
  40. staticfx/integrations.py +48 -0
  41. staticfx/library/__init__.py +1 -0
  42. staticfx/library/config.py +122 -0
  43. staticfx/library/effects.py +159 -0
  44. staticfx/library/files.py +44 -0
  45. staticfx/library/legacy.py +180 -0
  46. staticfx/library/looks.py +134 -0
  47. staticfx/postfx.py +78 -0
  48. staticfx/py.typed +0 -0
  49. staticfx/themes.py +162 -0
  50. staticfx/ui/__init__.py +1 -0
  51. staticfx/ui/browser.py +269 -0
  52. staticfx/ui/dialogs.py +95 -0
  53. staticfx/ui/help.py +99 -0
  54. staticfx/ui/pickers.py +221 -0
  55. staticfx/ui/play.py +213 -0
  56. staticfx/ui/screen.py +39 -0
  57. staticfx/ui/settings.py +262 -0
  58. staticfx/ui/studio.py +433 -0
  59. staticfx/ui/widgets.py +196 -0
  60. staticfx-2.0.0.dist-info/METADATA +164 -0
  61. staticfx-2.0.0.dist-info/RECORD +65 -0
  62. staticfx-2.0.0.dist-info/WHEEL +5 -0
  63. staticfx-2.0.0.dist-info/entry_points.txt +2 -0
  64. staticfx-2.0.0.dist-info/licenses/LICENSE +21 -0
  65. 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)