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,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))