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,623 @@
|
|
|
1
|
+
"""Things that move: glyph rain, drifting particles, stars, meteors, fireworks, orbits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import random
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from staticfx.core.color import RGB, clamp, tint
|
|
10
|
+
from staticfx.core.frame import Frame
|
|
11
|
+
from staticfx.core.glyphs import tech_glyphs
|
|
12
|
+
from staticfx.effects.base import Effect
|
|
13
|
+
from staticfx.themes import Theme
|
|
14
|
+
|
|
15
|
+
BLOCK_CHARS = list("#%@&8$X")
|
|
16
|
+
UNICODE_BLOCKS = list("█▓▒░")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True)
|
|
20
|
+
class Particle:
|
|
21
|
+
x: float
|
|
22
|
+
y: float
|
|
23
|
+
vx: float = 0.0
|
|
24
|
+
vy: float = 1.0
|
|
25
|
+
char: str = "*"
|
|
26
|
+
life: float = 1.0
|
|
27
|
+
max_life: float = 1.0
|
|
28
|
+
tone: float = 0.8
|
|
29
|
+
phase: float = 0.0
|
|
30
|
+
rocket: bool = False
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ParticleSystem(Effect):
|
|
34
|
+
"""Physics particles with gravity, lifetimes and an emitter hook."""
|
|
35
|
+
|
|
36
|
+
gravity = 0.0
|
|
37
|
+
|
|
38
|
+
def __init__(self, rng: random.Random, unicode: bool = False) -> None:
|
|
39
|
+
super().__init__(rng, unicode)
|
|
40
|
+
self.particles: list[Particle] = []
|
|
41
|
+
|
|
42
|
+
def spawn(self, particle: Particle) -> None:
|
|
43
|
+
self.particles.append(particle)
|
|
44
|
+
|
|
45
|
+
def emit(self, dt: float) -> None:
|
|
46
|
+
"""Override to spawn new particles each step."""
|
|
47
|
+
|
|
48
|
+
def on_expire(self, particle: Particle) -> None:
|
|
49
|
+
"""Called once for each particle whose life ran out."""
|
|
50
|
+
|
|
51
|
+
def in_bounds(self, particle: Particle) -> bool:
|
|
52
|
+
return -4 <= particle.x < self.width + 4 and -4 <= particle.y < self.height + 4
|
|
53
|
+
|
|
54
|
+
def on_resize(self) -> None:
|
|
55
|
+
self.particles.clear()
|
|
56
|
+
|
|
57
|
+
def advance(self, dt: float) -> None:
|
|
58
|
+
self.emit(dt)
|
|
59
|
+
|
|
60
|
+
gravity = self.gravity
|
|
61
|
+
expired: list[Particle] = []
|
|
62
|
+
survivors: list[Particle] = []
|
|
63
|
+
|
|
64
|
+
for particle in self.particles:
|
|
65
|
+
particle.vy += gravity * dt
|
|
66
|
+
particle.x += particle.vx * dt
|
|
67
|
+
particle.y += particle.vy * dt
|
|
68
|
+
particle.life -= dt
|
|
69
|
+
|
|
70
|
+
if particle.life <= 0.0:
|
|
71
|
+
expired.append(particle)
|
|
72
|
+
elif self.in_bounds(particle):
|
|
73
|
+
survivors.append(particle)
|
|
74
|
+
|
|
75
|
+
self.particles = survivors
|
|
76
|
+
|
|
77
|
+
for particle in expired:
|
|
78
|
+
self.on_expire(particle)
|
|
79
|
+
|
|
80
|
+
def particle_color(self, theme: Theme, particle: Particle) -> RGB:
|
|
81
|
+
fade = clamp(particle.life / particle.max_life)
|
|
82
|
+
return self.color_at(theme, particle.tone * (0.3 + 0.7 * fade))
|
|
83
|
+
|
|
84
|
+
def render(self, frame: Frame, theme: Theme) -> None:
|
|
85
|
+
for particle in self.particles:
|
|
86
|
+
x = int(particle.x)
|
|
87
|
+
y = int(particle.y)
|
|
88
|
+
|
|
89
|
+
if 0 <= x < self.width and 0 <= y < self.height:
|
|
90
|
+
frame.put(x, y, particle.char, self.particle_color(theme, particle))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# Rain
|
|
94
|
+
class _Column:
|
|
95
|
+
__slots__ = ("glyphs", "head", "length", "speed")
|
|
96
|
+
|
|
97
|
+
def __init__(self) -> None:
|
|
98
|
+
self.head = 0.0
|
|
99
|
+
self.speed = 0.0
|
|
100
|
+
self.length = 1
|
|
101
|
+
self.glyphs: list[str] = []
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class ColumnRain(Effect):
|
|
105
|
+
"""Falling glyph columns with a bright head and fading tail."""
|
|
106
|
+
|
|
107
|
+
def __init__(
|
|
108
|
+
self,
|
|
109
|
+
rng: random.Random,
|
|
110
|
+
unicode: bool = False,
|
|
111
|
+
pool: str | None = None,
|
|
112
|
+
speed: float = 14.0,
|
|
113
|
+
trail: int | None = None,
|
|
114
|
+
density: float = 1.0,
|
|
115
|
+
corrupt: bool = False,
|
|
116
|
+
) -> None:
|
|
117
|
+
super().__init__(rng, unicode)
|
|
118
|
+
|
|
119
|
+
self.pool = pool or tech_glyphs(unicode)
|
|
120
|
+
self.speed = max(0.5, float(speed))
|
|
121
|
+
self.trail = None if trail is None else max(2, int(trail))
|
|
122
|
+
self.density = clamp(float(density), 0.05, 1.0)
|
|
123
|
+
self.corrupt = corrupt
|
|
124
|
+
|
|
125
|
+
self.columns: list[_Column] = []
|
|
126
|
+
self.glitch_timer = 1.0
|
|
127
|
+
self.glitch_hold = 0.0
|
|
128
|
+
self.glitch_rect: tuple[int, int, int, int] | None = None
|
|
129
|
+
|
|
130
|
+
def _recycle(self, column: _Column, initial: bool = False) -> None:
|
|
131
|
+
height = self.height
|
|
132
|
+
rng = self.rng
|
|
133
|
+
|
|
134
|
+
if self.trail is not None:
|
|
135
|
+
column.length = self.trail
|
|
136
|
+
else:
|
|
137
|
+
column.length = rng.randint(max(4, height // 4), max(8, int(height * 0.8)))
|
|
138
|
+
|
|
139
|
+
gap = height * (1.0 / self.density)
|
|
140
|
+
column.head = -rng.uniform(0.0, gap)
|
|
141
|
+
|
|
142
|
+
if initial:
|
|
143
|
+
column.head += rng.uniform(0.0, height)
|
|
144
|
+
|
|
145
|
+
column.speed = self.speed * rng.uniform(0.45, 1.5)
|
|
146
|
+
|
|
147
|
+
if len(column.glyphs) != height:
|
|
148
|
+
column.glyphs = rng.choices(self.pool, k=height)
|
|
149
|
+
|
|
150
|
+
def on_resize(self) -> None:
|
|
151
|
+
self.columns = []
|
|
152
|
+
|
|
153
|
+
for _ in range(self.width):
|
|
154
|
+
column = _Column()
|
|
155
|
+
self._recycle(column, initial=True)
|
|
156
|
+
self.columns.append(column)
|
|
157
|
+
|
|
158
|
+
self.glitch_rect = None
|
|
159
|
+
self.glitch_timer = self.rng.uniform(1.0, 3.0)
|
|
160
|
+
|
|
161
|
+
def advance(self, dt: float) -> None:
|
|
162
|
+
rng = self.rng
|
|
163
|
+
pool = self.pool
|
|
164
|
+
height = self.height
|
|
165
|
+
|
|
166
|
+
for column in self.columns:
|
|
167
|
+
column.head += column.speed * dt
|
|
168
|
+
|
|
169
|
+
if column.head - column.length > height:
|
|
170
|
+
self._recycle(column)
|
|
171
|
+
|
|
172
|
+
# Occasional glyph flicker inside the column.
|
|
173
|
+
if rng.random() < 6.0 * dt:
|
|
174
|
+
column.glyphs[rng.randrange(height)] = rng.choice(pool)
|
|
175
|
+
|
|
176
|
+
if self.corrupt:
|
|
177
|
+
self._advance_glitch(dt)
|
|
178
|
+
|
|
179
|
+
def _advance_glitch(self, dt: float) -> None:
|
|
180
|
+
rng = self.rng
|
|
181
|
+
|
|
182
|
+
if self.glitch_rect is None:
|
|
183
|
+
self.glitch_timer -= dt
|
|
184
|
+
|
|
185
|
+
if self.glitch_timer <= 0.0 and self.width > 6 and self.height > 4:
|
|
186
|
+
rect_w = rng.randint(3, max(4, self.width // 3))
|
|
187
|
+
rect_h = rng.randint(1, max(2, self.height // 6))
|
|
188
|
+
self.glitch_rect = (
|
|
189
|
+
rng.randint(0, max(0, self.width - rect_w)),
|
|
190
|
+
rng.randint(0, max(0, self.height - rect_h)),
|
|
191
|
+
rect_w,
|
|
192
|
+
rect_h,
|
|
193
|
+
)
|
|
194
|
+
self.glitch_hold = rng.uniform(0.12, 0.35)
|
|
195
|
+
self.glitch_timer = rng.uniform(0.6, 2.5)
|
|
196
|
+
else:
|
|
197
|
+
self.glitch_hold -= dt
|
|
198
|
+
|
|
199
|
+
if self.glitch_hold <= 0.0:
|
|
200
|
+
self.glitch_rect = None
|
|
201
|
+
|
|
202
|
+
def render(self, frame: Frame, theme: Theme) -> None:
|
|
203
|
+
head_color = tint(theme.bright, 0.35)
|
|
204
|
+
height = self.height
|
|
205
|
+
|
|
206
|
+
for x, column in enumerate(self.columns):
|
|
207
|
+
head = int(column.head)
|
|
208
|
+
length = column.length
|
|
209
|
+
|
|
210
|
+
for offset in range(length):
|
|
211
|
+
y = head - offset
|
|
212
|
+
|
|
213
|
+
if y < 0:
|
|
214
|
+
break
|
|
215
|
+
|
|
216
|
+
if y >= height:
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
if offset == 0:
|
|
220
|
+
color = head_color
|
|
221
|
+
else:
|
|
222
|
+
fraction = 1.0 - offset / length
|
|
223
|
+
color = self.color_at(theme, 0.08 + 0.82 * fraction**1.4)
|
|
224
|
+
|
|
225
|
+
frame.put(x, y, column.glyphs[y], color)
|
|
226
|
+
|
|
227
|
+
if self.corrupt and self.glitch_rect is not None:
|
|
228
|
+
self._draw_glitch(frame, theme, self.glitch_rect)
|
|
229
|
+
|
|
230
|
+
def _draw_glitch(self, frame: Frame, theme: Theme, rect: tuple[int, int, int, int]) -> None:
|
|
231
|
+
rect_x, rect_y, rect_w, rect_h = rect
|
|
232
|
+
blocks = UNICODE_BLOCKS if self.unicode else BLOCK_CHARS
|
|
233
|
+
color = theme.accent
|
|
234
|
+
|
|
235
|
+
for y in range(rect_y, rect_y + rect_h):
|
|
236
|
+
for x in range(rect_x, rect_x + rect_w):
|
|
237
|
+
frame.put(x, y, self.rng.choice(blocks), color)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# Ambient drifting particles (snow, sakura, embers, bubbles, confetti)
|
|
241
|
+
class DriftParticles(ParticleSystem):
|
|
242
|
+
def __init__(
|
|
243
|
+
self,
|
|
244
|
+
rng: random.Random,
|
|
245
|
+
unicode: bool = False,
|
|
246
|
+
pool: str = "*+.",
|
|
247
|
+
direction: int = 1,
|
|
248
|
+
speed: tuple[float, float] = (2.0, 6.0),
|
|
249
|
+
sway: float = 1.5,
|
|
250
|
+
density: float = 0.03,
|
|
251
|
+
twinkle: bool = False,
|
|
252
|
+
tone: tuple[float, float] = (0.6, 1.0),
|
|
253
|
+
multicolor: bool = False,
|
|
254
|
+
count: int | None = None,
|
|
255
|
+
) -> None:
|
|
256
|
+
super().__init__(rng, unicode)
|
|
257
|
+
|
|
258
|
+
self.pool = list(pool)
|
|
259
|
+
self.direction = 1 if direction > 0 else -1 if direction < 0 else 0
|
|
260
|
+
self.speed_range = speed
|
|
261
|
+
self.sway = sway
|
|
262
|
+
self.density = density
|
|
263
|
+
self.twinkle = twinkle
|
|
264
|
+
self.tone_range = tone
|
|
265
|
+
self.multicolor = multicolor
|
|
266
|
+
self.count = count
|
|
267
|
+
self.t = 0.0
|
|
268
|
+
|
|
269
|
+
def _make(self, initial: bool) -> Particle:
|
|
270
|
+
rng = self.rng
|
|
271
|
+
width, height = self.width, self.height
|
|
272
|
+
|
|
273
|
+
if initial or self.direction == 0:
|
|
274
|
+
y = rng.uniform(0, height)
|
|
275
|
+
elif self.direction > 0:
|
|
276
|
+
y = -rng.uniform(0.0, 3.0)
|
|
277
|
+
else:
|
|
278
|
+
y = height + rng.uniform(0.0, 3.0)
|
|
279
|
+
|
|
280
|
+
return Particle(
|
|
281
|
+
x=rng.uniform(0, width),
|
|
282
|
+
y=y,
|
|
283
|
+
vy=rng.uniform(*self.speed_range) * self.direction,
|
|
284
|
+
char=rng.choice(self.pool),
|
|
285
|
+
tone=rng.uniform(*self.tone_range),
|
|
286
|
+
phase=rng.uniform(0.0, math.tau),
|
|
287
|
+
life=1.0,
|
|
288
|
+
max_life=1.0,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def on_resize(self) -> None:
|
|
292
|
+
count = self.count or max(10, int(self.width * self.height * self.density))
|
|
293
|
+
self.particles = [self._make(True) for _ in range(count)]
|
|
294
|
+
|
|
295
|
+
def advance(self, dt: float) -> None:
|
|
296
|
+
self.t += dt
|
|
297
|
+
width, height = self.width, self.height
|
|
298
|
+
sway_t = self.t * 1.4
|
|
299
|
+
|
|
300
|
+
for index, particle in enumerate(self.particles):
|
|
301
|
+
particle.y += particle.vy * dt
|
|
302
|
+
particle.x += math.sin(sway_t + particle.phase) * self.sway * dt
|
|
303
|
+
particle.x %= width
|
|
304
|
+
|
|
305
|
+
if (self.direction > 0 and particle.y >= height + 1) or (
|
|
306
|
+
self.direction < 0 and particle.y < -1
|
|
307
|
+
):
|
|
308
|
+
self.particles[index] = self._make(False)
|
|
309
|
+
elif self.direction == 0:
|
|
310
|
+
particle.y = (particle.y + math.sin(sway_t * 0.7 + particle.phase) * dt) % height
|
|
311
|
+
|
|
312
|
+
def particle_color(self, theme: Theme, particle: Particle) -> RGB:
|
|
313
|
+
if self.multicolor:
|
|
314
|
+
colors = (theme.primary, theme.ramp(0.4), theme.accent, theme.bright, theme.ramp(0.85))
|
|
315
|
+
return colors[int(particle.tone * 100) % len(colors)]
|
|
316
|
+
|
|
317
|
+
tone = particle.tone
|
|
318
|
+
|
|
319
|
+
if self.twinkle:
|
|
320
|
+
tone *= 0.55 + 0.45 * math.sin(self.t * 4.0 + particle.phase)
|
|
321
|
+
|
|
322
|
+
return self.color_at(theme, tone)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
# Starfield / meteors
|
|
326
|
+
class Starfield(Effect):
|
|
327
|
+
def __init__(self, rng: random.Random, unicode: bool = False, speed: float = 0.45) -> None:
|
|
328
|
+
super().__init__(rng, unicode)
|
|
329
|
+
self.speed = speed
|
|
330
|
+
self.stars: list[list[float]] = []
|
|
331
|
+
|
|
332
|
+
def _star(self, near: bool = False) -> list[float]:
|
|
333
|
+
rng = self.rng
|
|
334
|
+
return [
|
|
335
|
+
rng.uniform(-1.0, 1.0),
|
|
336
|
+
rng.uniform(-1.0, 1.0),
|
|
337
|
+
rng.uniform(0.05, 1.0) if near else 1.0,
|
|
338
|
+
]
|
|
339
|
+
|
|
340
|
+
def on_resize(self) -> None:
|
|
341
|
+
count = max(30, self.width * self.height // 14)
|
|
342
|
+
self.stars = [self._star(near=True) for _ in range(count)]
|
|
343
|
+
|
|
344
|
+
def advance(self, dt: float) -> None:
|
|
345
|
+
for index, star in enumerate(self.stars):
|
|
346
|
+
star[2] -= self.speed * dt * (1.3 - star[2] * 0.5)
|
|
347
|
+
|
|
348
|
+
if star[2] <= 0.03:
|
|
349
|
+
self.stars[index] = self._star()
|
|
350
|
+
|
|
351
|
+
def render(self, frame: Frame, theme: Theme) -> None:
|
|
352
|
+
cx = (self.width - 1) / 2
|
|
353
|
+
cy = (self.height - 1) / 2
|
|
354
|
+
|
|
355
|
+
for index, (sx, sy, z) in enumerate(self.stars):
|
|
356
|
+
x = int(cx + sx / z * cx * 0.45)
|
|
357
|
+
y = int(cy + sy / z * cy * 0.45)
|
|
358
|
+
|
|
359
|
+
if not (0 <= x < self.width and 0 <= y < self.height):
|
|
360
|
+
self.stars[index] = self._star()
|
|
361
|
+
continue
|
|
362
|
+
|
|
363
|
+
closeness = 1.0 - z
|
|
364
|
+
char = "*" if z < 0.3 else "+" if z < 0.6 else "."
|
|
365
|
+
frame.put(x, y, char, self.color_at(theme, 0.25 + 0.75 * closeness))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
class MeteorShower(Effect):
|
|
369
|
+
"""Streaks across a dim, twinkling sky."""
|
|
370
|
+
|
|
371
|
+
def __init__(self, rng: random.Random, unicode: bool = False, rate: float = 5.0) -> None:
|
|
372
|
+
super().__init__(rng, unicode)
|
|
373
|
+
self.rate = rate
|
|
374
|
+
self.t = 0.0
|
|
375
|
+
self.meteors: list[list[float]] = [] # [x, y, vx, vy, length]
|
|
376
|
+
self.sky: list[tuple[int, int, str, float]] = [] # [x, y, glyph, phase]
|
|
377
|
+
|
|
378
|
+
def on_resize(self) -> None:
|
|
379
|
+
rng = self.rng
|
|
380
|
+
self.meteors.clear()
|
|
381
|
+
self.sky = [
|
|
382
|
+
(
|
|
383
|
+
rng.randrange(self.width),
|
|
384
|
+
rng.randrange(self.height),
|
|
385
|
+
rng.choice(".'`+"),
|
|
386
|
+
rng.random(),
|
|
387
|
+
)
|
|
388
|
+
for _ in range(max(8, self.width * self.height // 45))
|
|
389
|
+
]
|
|
390
|
+
|
|
391
|
+
def advance(self, dt: float) -> None:
|
|
392
|
+
rng = self.rng
|
|
393
|
+
self.t += dt
|
|
394
|
+
|
|
395
|
+
if rng.random() < self.rate * dt * max(1.0, self.width / 80):
|
|
396
|
+
speed = rng.uniform(14.0, 30.0)
|
|
397
|
+
x = rng.uniform(0, self.width + self.height * 1.5)
|
|
398
|
+
self.meteors.append([x, -2.0, -speed * 1.6, speed * 0.8, rng.randint(6, 16)])
|
|
399
|
+
|
|
400
|
+
for meteor in self.meteors:
|
|
401
|
+
meteor[0] += meteor[2] * dt
|
|
402
|
+
meteor[1] += meteor[3] * dt
|
|
403
|
+
self.meteors = [m for m in self.meteors if m[1] < self.height + 2 and m[0] > -m[4] * 2]
|
|
404
|
+
|
|
405
|
+
def render(self, frame: Frame, theme: Theme) -> None:
|
|
406
|
+
for sx, sy, glyph, phase in self.sky:
|
|
407
|
+
twinkle = 0.5 + 0.5 * math.sin(self.t * 1.7 + phase * math.tau)
|
|
408
|
+
frame.put(sx, sy, glyph, self.color_at(theme, 0.08 + 0.22 * twinkle))
|
|
409
|
+
|
|
410
|
+
for x, y, vx, vy, length in self.meteors:
|
|
411
|
+
major = max(abs(vx), abs(vy))
|
|
412
|
+
dx, dy = vx / major, vy / major
|
|
413
|
+
for index in range(int(length)):
|
|
414
|
+
fade = 1.0 - index / length
|
|
415
|
+
char = "*" if index == 0 else "+" if index < 3 else ":" if fade > 0.4 else "."
|
|
416
|
+
frame.put(
|
|
417
|
+
int(x - dx * index),
|
|
418
|
+
int(y - dy * index),
|
|
419
|
+
char,
|
|
420
|
+
self.color_at(theme, 0.15 + 0.85 * fade),
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# Fireworks / fountain
|
|
425
|
+
class Fireworks(ParticleSystem):
|
|
426
|
+
gravity = 9.0
|
|
427
|
+
|
|
428
|
+
def __init__(self, rng: random.Random, unicode: bool = False) -> None:
|
|
429
|
+
super().__init__(rng, unicode)
|
|
430
|
+
self.cooldown = 0.2
|
|
431
|
+
|
|
432
|
+
def on_resize(self) -> None:
|
|
433
|
+
super().on_resize()
|
|
434
|
+
self.cooldown = 0.2
|
|
435
|
+
|
|
436
|
+
def emit(self, dt: float) -> None:
|
|
437
|
+
self.cooldown -= dt
|
|
438
|
+
|
|
439
|
+
if self.cooldown > 0.0:
|
|
440
|
+
return
|
|
441
|
+
|
|
442
|
+
rng = self.rng
|
|
443
|
+
# Launch more often on wide terminals so the sky never looks empty.
|
|
444
|
+
self.cooldown = rng.uniform(0.3, 0.9) * 80 / max(40, self.width)
|
|
445
|
+
target = rng.uniform(self.height * 0.12, self.height * 0.55)
|
|
446
|
+
rise = max(2.0, self.height - target)
|
|
447
|
+
vy = -math.sqrt(2.0 * self.gravity * rise)
|
|
448
|
+
|
|
449
|
+
self.spawn(
|
|
450
|
+
Particle(
|
|
451
|
+
x=rng.uniform(self.width * 0.15, self.width * 0.85),
|
|
452
|
+
y=self.height - 1.0,
|
|
453
|
+
vx=rng.uniform(-2.0, 2.0),
|
|
454
|
+
vy=vy,
|
|
455
|
+
char="|",
|
|
456
|
+
life=-vy / self.gravity,
|
|
457
|
+
max_life=3.0,
|
|
458
|
+
tone=0.9,
|
|
459
|
+
rocket=True,
|
|
460
|
+
)
|
|
461
|
+
)
|
|
462
|
+
|
|
463
|
+
def on_expire(self, particle: Particle) -> None:
|
|
464
|
+
if not particle.rocket:
|
|
465
|
+
return
|
|
466
|
+
|
|
467
|
+
rng = self.rng
|
|
468
|
+
count = rng.randint(40, 80)
|
|
469
|
+
power = max(5.0, self.height * rng.uniform(0.18, 0.32))
|
|
470
|
+
tone = rng.uniform(0.55, 1.0)
|
|
471
|
+
chars = "*+o." if not self.unicode else "*+✦·"
|
|
472
|
+
|
|
473
|
+
for index in range(count):
|
|
474
|
+
angle = math.tau * index / count + rng.uniform(-0.1, 0.1)
|
|
475
|
+
speed = power * rng.uniform(0.55, 1.0)
|
|
476
|
+
life = rng.uniform(0.9, 1.7)
|
|
477
|
+
|
|
478
|
+
self.spawn(
|
|
479
|
+
Particle(
|
|
480
|
+
x=particle.x,
|
|
481
|
+
y=particle.y,
|
|
482
|
+
vx=math.cos(angle) * speed * 2.0,
|
|
483
|
+
vy=math.sin(angle) * speed,
|
|
484
|
+
char=rng.choice(chars),
|
|
485
|
+
life=life,
|
|
486
|
+
max_life=life,
|
|
487
|
+
tone=tone,
|
|
488
|
+
)
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
def particle_color(self, theme: Theme, particle: Particle) -> RGB:
|
|
492
|
+
if particle.rocket:
|
|
493
|
+
return self.color_at(theme, 0.5)
|
|
494
|
+
|
|
495
|
+
return super().particle_color(theme, particle)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
class Fountain(ParticleSystem):
|
|
499
|
+
gravity = 16.0
|
|
500
|
+
|
|
501
|
+
def in_bounds(self, particle: Particle) -> bool:
|
|
502
|
+
return -6 <= particle.x < self.width + 6 and particle.y < self.height
|
|
503
|
+
|
|
504
|
+
def emit(self, dt: float) -> None:
|
|
505
|
+
rng = self.rng
|
|
506
|
+
count = int(rng.gauss(110.0 * dt, 1.0)) if dt > 0 else 0
|
|
507
|
+
|
|
508
|
+
for _ in range(max(0, count)):
|
|
509
|
+
reach = self.height * rng.uniform(0.35, 0.85)
|
|
510
|
+
vy = -math.sqrt(2.0 * self.gravity * reach)
|
|
511
|
+
|
|
512
|
+
self.spawn(
|
|
513
|
+
Particle(
|
|
514
|
+
x=self.width / 2 + rng.gauss(0.0, 1.0),
|
|
515
|
+
y=self.height - 2.0,
|
|
516
|
+
vx=rng.gauss(0.0, 5.0),
|
|
517
|
+
vy=vy,
|
|
518
|
+
char=rng.choice("o.:*"),
|
|
519
|
+
life=6.0,
|
|
520
|
+
max_life=6.0,
|
|
521
|
+
tone=rng.uniform(0.55, 1.0),
|
|
522
|
+
)
|
|
523
|
+
)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
# Orbiting particles: vortex / orbit dance / gravity well
|
|
527
|
+
class OrbitalParticles(Effect):
|
|
528
|
+
"""Particles in normalised polar space around the screen centre."""
|
|
529
|
+
|
|
530
|
+
def __init__(self, rng: random.Random, unicode: bool = False, mode: str = "vortex") -> None:
|
|
531
|
+
super().__init__(rng, unicode)
|
|
532
|
+
self.mode = mode
|
|
533
|
+
self.bodies: list[list[float]] = [] # [x, y, vx, vy] or [r, a, w, ring]
|
|
534
|
+
|
|
535
|
+
def on_resize(self) -> None:
|
|
536
|
+
rng = self.rng
|
|
537
|
+
self.bodies = []
|
|
538
|
+
|
|
539
|
+
if self.mode == "orbit":
|
|
540
|
+
rings = max(3, min(9, self.height // 3))
|
|
541
|
+
|
|
542
|
+
for ring in range(1, rings + 1):
|
|
543
|
+
radius = 0.12 + 0.85 * ring / rings
|
|
544
|
+
count = 3 + ring * 3
|
|
545
|
+
omega = (1.5 if ring % 2 else -1.5) / math.sqrt(radius)
|
|
546
|
+
offset = rng.uniform(0, math.tau)
|
|
547
|
+
|
|
548
|
+
for index in range(count):
|
|
549
|
+
self.bodies.append(
|
|
550
|
+
[radius, offset + math.tau * index / count, omega, float(ring)]
|
|
551
|
+
)
|
|
552
|
+
elif self.mode == "well":
|
|
553
|
+
for _ in range(max(40, self.width * self.height // 30)):
|
|
554
|
+
self.bodies.append(self._well_body())
|
|
555
|
+
else:
|
|
556
|
+
for _ in range(max(60, self.width * self.height // 22)):
|
|
557
|
+
self.bodies.append(self._vortex_body(initial=True))
|
|
558
|
+
|
|
559
|
+
def _vortex_body(self, initial: bool = False) -> list[float]:
|
|
560
|
+
rng = self.rng
|
|
561
|
+
radius = rng.uniform(0.05, 1.5) if initial else rng.uniform(1.0, 1.6)
|
|
562
|
+
return [radius, rng.uniform(0, math.tau), 0.0, 0.0]
|
|
563
|
+
|
|
564
|
+
def _well_body(self) -> list[float]:
|
|
565
|
+
rng = self.rng
|
|
566
|
+
angle = rng.uniform(0, math.tau)
|
|
567
|
+
radius = rng.uniform(1.0, 1.5)
|
|
568
|
+
tangent = rng.uniform(0.25, 0.7) * rng.choice((-1, 1))
|
|
569
|
+
return [
|
|
570
|
+
math.cos(angle) * radius,
|
|
571
|
+
math.sin(angle) * radius,
|
|
572
|
+
-math.sin(angle) * tangent,
|
|
573
|
+
math.cos(angle) * tangent,
|
|
574
|
+
]
|
|
575
|
+
|
|
576
|
+
def advance(self, dt: float) -> None:
|
|
577
|
+
if self.mode == "orbit":
|
|
578
|
+
for body in self.bodies:
|
|
579
|
+
body[1] += body[2] * dt
|
|
580
|
+
elif self.mode == "well":
|
|
581
|
+
for index, body in enumerate(self.bodies):
|
|
582
|
+
x, y, vx, vy = body
|
|
583
|
+
dist2 = x * x + y * y + 0.02
|
|
584
|
+
pull = 0.55 / (dist2 * math.sqrt(dist2))
|
|
585
|
+
vx -= x * pull * dt
|
|
586
|
+
vy -= y * pull * dt
|
|
587
|
+
body[0] = x + vx * dt
|
|
588
|
+
body[1] = y + vy * dt
|
|
589
|
+
body[2], body[3] = vx, vy
|
|
590
|
+
|
|
591
|
+
if dist2 < 0.025 or dist2 > 6.0:
|
|
592
|
+
self.bodies[index] = self._well_body()
|
|
593
|
+
else:
|
|
594
|
+
for index, body in enumerate(self.bodies):
|
|
595
|
+
radius = body[0]
|
|
596
|
+
body[1] += (0.9 / (radius + 0.18)) * dt
|
|
597
|
+
body[0] = radius - (0.07 + 0.1 / (radius + 0.3)) * dt
|
|
598
|
+
|
|
599
|
+
if body[0] < 0.03:
|
|
600
|
+
self.bodies[index] = self._vortex_body()
|
|
601
|
+
|
|
602
|
+
def _to_screen(self, px: float, py: float) -> tuple[int, int]:
|
|
603
|
+
half = self.height / 2
|
|
604
|
+
return (int((self.width - 1) / 2 + px * half * 2.0), int((self.height - 1) / 2 + py * half))
|
|
605
|
+
|
|
606
|
+
def render(self, frame: Frame, theme: Theme) -> None:
|
|
607
|
+
if self.mode == "orbit":
|
|
608
|
+
cx, cy = self._to_screen(0.0, 0.0)
|
|
609
|
+
frame.put(cx, cy, "@", theme.bright)
|
|
610
|
+
|
|
611
|
+
for body in self.bodies:
|
|
612
|
+
if self.mode == "well":
|
|
613
|
+
px, py = body[0], body[1]
|
|
614
|
+
radius = math.hypot(px, py)
|
|
615
|
+
else:
|
|
616
|
+
radius = body[0]
|
|
617
|
+
px = math.cos(body[1]) * radius
|
|
618
|
+
py = math.sin(body[1]) * radius
|
|
619
|
+
|
|
620
|
+
x, y = self._to_screen(px, py)
|
|
621
|
+
near = clamp(1.0 - radius / 1.4)
|
|
622
|
+
char = "@" if near > 0.8 else "O" if near > 0.55 else "o" if near > 0.3 else "."
|
|
623
|
+
frame.put(x, y, char, self.color_at(theme, 0.2 + 0.8 * near))
|