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,203 @@
1
+ """The hand-written effects, and the catalogue that names and groups them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import partial
6
+
7
+ from staticfx.core.glyphs import BINARY, HEX
8
+ from staticfx.effects.builtin.fields import (
9
+ Aurora,
10
+ BlackHole,
11
+ Galaxy,
12
+ GridWave,
13
+ HeatHaze,
14
+ Kaleidoscope,
15
+ LavaLamp,
16
+ Nebula,
17
+ OceanWaves,
18
+ Plasma,
19
+ RipplePond,
20
+ Tunnel,
21
+ Wormhole,
22
+ )
23
+ from staticfx.effects.builtin.particles import (
24
+ ColumnRain,
25
+ DriftParticles,
26
+ Fireworks,
27
+ Fountain,
28
+ MeteorShower,
29
+ OrbitalParticles,
30
+ Starfield,
31
+ )
32
+ from staticfx.effects.builtin.shapes import Cube, DNAHelix, Lissajous, Spirograph, Tesseract
33
+ from staticfx.effects.builtin.signals import (
34
+ ElectricStorm,
35
+ SignalScramble,
36
+ StaticNoise,
37
+ TVGlitch,
38
+ WaveBars,
39
+ )
40
+ from staticfx.effects.builtin.simulations import Fire, LangtonAnt, LifeLike, Maze, Rule30, Smoke
41
+ from staticfx.effects.builtin.terminal import BarcodeScan, Circuit, DecryptScramble, TerminalBoot
42
+ from staticfx.effects.spec import EffectSpec
43
+
44
+ RAIN = "Rain"
45
+ PARTICLES = "Particles"
46
+ SPACE = "Space"
47
+ FIELDS = "Waves & Fields"
48
+ GEOMETRY = "Geometry"
49
+ SIMULATIONS = "Simulations"
50
+ RETRO = "Retro Tech"
51
+
52
+ CATEGORIES = (RAIN, PARTICLES, SPACE, FIELDS, GEOMETRY, SIMULATIONS, RETRO)
53
+
54
+ # Drifting-particle presets, kept here so the catalogue below stays one row per effect.
55
+ snow = partial(
56
+ DriftParticles, pool="*.+", speed=(1.5, 4.0), sway=1.5, density=0.035, tone=(0.7, 1.0)
57
+ )
58
+ sakura = partial(
59
+ DriftParticles, pool="*@o", speed=(2.5, 6.0), sway=3.5, density=0.03, tone=(0.55, 0.95)
60
+ )
61
+ embers = partial(
62
+ DriftParticles,
63
+ pool=".,'`*",
64
+ direction=-1,
65
+ speed=(2.0, 7.0),
66
+ sway=2.0,
67
+ density=0.015,
68
+ twinkle=True,
69
+ tone=(0.3, 1.0),
70
+ )
71
+ bubbles = partial(
72
+ DriftParticles, pool="oO0.", direction=-1, speed=(2.0, 5.0), sway=2.5, density=0.012
73
+ )
74
+ confetti = partial(
75
+ DriftParticles, pool="#%@&=+", speed=(4.0, 10.0), sway=3.0, density=0.02, multicolor=True
76
+ )
77
+
78
+ # key, label, category, description, factory
79
+ CATALOGUE = (
80
+ ("matrix", "Matrix Rain", RAIN, "The classic: falling glyphs with bright heads", ColumnRain),
81
+ (
82
+ "binary_rain",
83
+ "Binary Rain",
84
+ RAIN,
85
+ "Ones and zeros pouring down",
86
+ partial(ColumnRain, pool=BINARY, speed=11.0),
87
+ ),
88
+ (
89
+ "hex_rain",
90
+ "Hex Rain",
91
+ RAIN,
92
+ "Hexadecimal digits pouring down",
93
+ partial(ColumnRain, pool=HEX, speed=13.0),
94
+ ),
95
+ (
96
+ "glitch_cascade",
97
+ "Glitch Cascade",
98
+ RAIN,
99
+ "Fast rain with blocks of corruption",
100
+ partial(ColumnRain, speed=22.0, corrupt=True),
101
+ ),
102
+ ("snowfall", "Snowfall", PARTICLES, "Gently swaying snow", snow),
103
+ ("sakura_rain", "Sakura Rain", PARTICLES, "Cherry blossom petals drifting down", sakura),
104
+ ("embers", "Embers", PARTICLES, "Twinkling sparks rising from a fire", embers),
105
+ ("bubbles", "Bubbles", PARTICLES, "Bubbles wobbling up to the surface", bubbles),
106
+ ("confetti", "Confetti", PARTICLES, "A party in every colour of the theme", confetti),
107
+ ("fireworks", "Fireworks", PARTICLES, "Rockets launching and bursting overhead", Fireworks),
108
+ ("fountain", "Fountain", PARTICLES, "A jet of droplets arcing under gravity", Fountain),
109
+ (
110
+ "orbit_dance",
111
+ "Orbit Dance",
112
+ PARTICLES,
113
+ "Counter-rotating rings of planets",
114
+ partial(OrbitalParticles, mode="orbit"),
115
+ ),
116
+ (
117
+ "vortex",
118
+ "Vortex",
119
+ PARTICLES,
120
+ "Particles spiralling into a drain",
121
+ partial(OrbitalParticles, mode="vortex"),
122
+ ),
123
+ (
124
+ "gravity_well",
125
+ "Gravity Well",
126
+ PARTICLES,
127
+ "Bodies slingshotting around a hidden mass",
128
+ partial(OrbitalParticles, mode="well"),
129
+ ),
130
+ ("starfield", "Starfield", SPACE, "Flying forward through the stars", Starfield),
131
+ (
132
+ "meteor_shower",
133
+ "Meteor Shower",
134
+ SPACE,
135
+ "Shooting stars across a twinkling sky",
136
+ MeteorShower,
137
+ ),
138
+ ("nebula", "Nebula", SPACE, "Slow cosmic clouds among the stars", Nebula),
139
+ ("galaxy", "Galaxy", SPACE, "A spiral galaxy turning", Galaxy),
140
+ (
141
+ "black_hole",
142
+ "Black Hole",
143
+ SPACE,
144
+ "Accretion disk and photon ring around the void",
145
+ BlackHole,
146
+ ),
147
+ ("wormhole", "Wormhole", SPACE, "Falling into a twisting tunnel of light", Wormhole),
148
+ ("plasma", "Plasma", FIELDS, "Old-school demoscene plasma", Plasma),
149
+ ("aurora", "Aurora", FIELDS, "Northern lights rippling across the sky", Aurora),
150
+ ("ocean_waves", "Ocean Waves", FIELDS, "A rolling sea seen side-on", OceanWaves),
151
+ ("ripple_pond", "Ripple Pond", FIELDS, "Raindrops landing on still water", RipplePond),
152
+ ("lava_lamp", "Lava Lamp", FIELDS, "Blobs merging and splitting, slowly", LavaLamp),
153
+ ("heat_haze", "Heat Haze", FIELDS, "Shimmering air above hot ground", HeatHaze),
154
+ ("kaleidoscope", "Kaleidoscope", FIELDS, "Six-fold mirrored patterns", Kaleidoscope),
155
+ ("tunnel", "Tunnel", FIELDS, "A checkered tunnel rushing past", Tunnel),
156
+ ("grid_wave", "Grid Wave", FIELDS, "A synthwave floor scrolling to the horizon", GridWave),
157
+ ("cube", "Cube", GEOMETRY, "A wireframe cube tumbling in 3D", Cube),
158
+ ("tesseract", "Tesseract", GEOMETRY, "A 4D hypercube rotating through itself", Tesseract),
159
+ ("spirograph", "Spirograph", GEOMETRY, "Hypotrochoid curves drawn by a pen", Spirograph),
160
+ ("lissajous", "Lissajous", GEOMETRY, "Oscilloscope figures tracing themselves", Lissajous),
161
+ ("dna_helix", "DNA Helix", GEOMETRY, "A rotating double helix", DNAHelix),
162
+ ("game_of_life", "Game of Life", SIMULATIONS, "Conway's automaton, reseeding itself", LifeLike),
163
+ ("rule30", "Rule 30", SIMULATIONS, "Wolfram's chaotic one-dimensional automaton", Rule30),
164
+ ("langtons_ant", "Langton's Ant", SIMULATIONS, "Simple rules, emergent highway", LangtonAnt),
165
+ (
166
+ "turmite",
167
+ "Turmite",
168
+ SIMULATIONS,
169
+ "Two ants with a four-colour rule",
170
+ partial(LangtonAnt, rule="RLLR", ants=2),
171
+ ),
172
+ ("maze", "Maze", SIMULATIONS, "A maze carved live by depth-first search", Maze),
173
+ ("fire", "Fire", SIMULATIONS, "The classic demoscene fire", Fire),
174
+ ("smoke", "Smoke", SIMULATIONS, "Plumes drifting in a changing wind", Smoke),
175
+ ("terminal_boot", "Terminal Boot", RETRO, "A computer booting up, endlessly", TerminalBoot),
176
+ (
177
+ "decrypt_scramble",
178
+ "Decrypt",
179
+ RETRO,
180
+ "Scrambled text resolving into a message",
181
+ DecryptScramble,
182
+ ),
183
+ ("barcode_scan", "Barcode Scan", RETRO, "A laser sweeping a barcode", BarcodeScan),
184
+ ("circuit", "Circuit", RETRO, "Signals tracing paths across a circuit board", Circuit),
185
+ ("static_noise", "Static", RETRO, "Untuned television snow", StaticNoise),
186
+ ("tv_glitch", "TV Glitch", RETRO, "A test card with a failing signal", TVGlitch),
187
+ (
188
+ "signal_scramble",
189
+ "Signal Scramble",
190
+ RETRO,
191
+ "Oscilloscope traces breaking up",
192
+ SignalScramble,
193
+ ),
194
+ ("electric_storm", "Electric Storm", RETRO, "Forked lightning and crackle", ElectricStorm),
195
+ ("wave_bars", "Spectrum", RETRO, "A bouncing audio spectrum analyser", WaveBars),
196
+ )
197
+
198
+
199
+ def builtin_specs() -> list[EffectSpec]:
200
+ return [
201
+ EffectSpec(key, label, description, category, factory)
202
+ for key, label, category, description, factory in CATALOGUE
203
+ ]
@@ -0,0 +1,404 @@
1
+ """Effects that compute a brightness for every cell: plasma, tunnels, galaxies, aurora."""
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.effects.base import Effect
11
+ from staticfx.themes import Theme
12
+
13
+ SHADE = " .:-=+*#%@"
14
+
15
+
16
+ class FieldEffect(Effect):
17
+ """Per-cell scalar field mapped through a character ramp + theme colours.
18
+
19
+ Subclasses implement ``value(x, y)`` returning 0..1 (anything <= the first
20
+ ramp step stays blank/transparent). Cached coordinate tables:
21
+
22
+ * ``xs[x]`` / ``ys[y]`` - aspect-corrected coordinates (y spans -1..1)
23
+ * ``rr[y][x]`` / ``aa[y][x]`` - polar radius / angle (radians) from centre
24
+ """
25
+
26
+ shade = SHADE
27
+ speed = 1.0
28
+ stars = False
29
+
30
+ def __init__(self, rng: random.Random, unicode: bool = False) -> None:
31
+ super().__init__(rng, unicode)
32
+
33
+ self.t = 0.0
34
+ self.seed = rng.uniform(0.0, 1000.0)
35
+
36
+ self.xs: list[float] = []
37
+ self.ys: list[float] = []
38
+ self.rr: list[list[float]] = []
39
+ self.aa: list[list[float]] = []
40
+ self.star_cells: list[tuple[int, int, str, float]] = []
41
+
42
+ def on_resize(self) -> None:
43
+ width, height = self.width, self.height
44
+ cx = (width - 1) / 2
45
+ cy = (height - 1) / 2
46
+ half = max(1.0, height / 2)
47
+
48
+ # Terminal cells are ~2x taller than wide.
49
+ self.xs = [(x - cx) * 0.5 / half for x in range(width)]
50
+ self.ys = [(y - cy) / half for y in range(height)]
51
+
52
+ self.rr = [[math.hypot(px, py) for px in self.xs] for py in self.ys]
53
+ self.aa = [[math.atan2(py, px) for px in self.xs] for py in self.ys]
54
+
55
+ self.star_cells = []
56
+
57
+ if self.stars:
58
+ rng = self.rng
59
+ glyphs = "..+*"
60
+
61
+ for _ in range(max(8, width * height // 40)):
62
+ self.star_cells.append(
63
+ (
64
+ rng.randrange(width),
65
+ rng.randrange(height),
66
+ rng.choice(glyphs),
67
+ rng.uniform(0.0, math.tau),
68
+ )
69
+ )
70
+
71
+ self.prepare_static()
72
+
73
+ def prepare_static(self) -> None:
74
+ """Hook: build extra tables after a resize."""
75
+
76
+ def prepare(self) -> None:
77
+ """Hook: per-frame setup before ``value`` is called."""
78
+
79
+ def advance(self, dt: float) -> None:
80
+ self.t += dt * self.speed
81
+ self.step(dt)
82
+
83
+ def step(self, dt: float) -> None:
84
+ """Hook for effects with extra state."""
85
+
86
+ def value(self, x: int, y: int) -> float:
87
+ raise NotImplementedError
88
+
89
+ def star_visible(self, x: int, y: int) -> bool:
90
+ return True
91
+
92
+ def render(self, frame: Frame, theme: Theme) -> None:
93
+ self.prepare()
94
+
95
+ palette = self.palette(theme)
96
+ top = len(palette) - 1
97
+ shade = self.shade
98
+ shade_max = len(shade) - 1
99
+ value = self.value
100
+ put = frame.put
101
+
102
+ for y in range(self.height):
103
+ for x in range(self.width):
104
+ v = value(x, y)
105
+
106
+ if v <= 0.0:
107
+ continue
108
+
109
+ v = min(v, 1.0)
110
+
111
+ index = int(v * shade_max + 0.5)
112
+
113
+ if index > 0:
114
+ put(x, y, shade[index], palette[int(v * top + 0.5)])
115
+
116
+ if self.star_cells:
117
+ self._draw_stars(frame, theme)
118
+
119
+ def _draw_stars(self, frame: Frame, theme: Theme) -> None:
120
+ for x, y, char, phase in self.star_cells:
121
+ if not self.star_visible(x, y):
122
+ continue
123
+
124
+ if frame.char_at(x, y) != " ":
125
+ continue
126
+
127
+ twinkle = 0.55 + 0.45 * math.sin(self.t * 2.2 + phase)
128
+ frame.put(x, y, char, self.color_at(theme, 0.25 + 0.6 * twinkle))
129
+
130
+
131
+ def _frac(value: float) -> float:
132
+ return value - math.floor(value)
133
+
134
+
135
+ class Plasma(FieldEffect):
136
+ speed = 0.9
137
+
138
+ def value(self, x: int, y: int) -> float:
139
+ px = self.xs[x] * 3.0
140
+ py = self.ys[y] * 3.0
141
+ t = self.t
142
+
143
+ total = (
144
+ math.sin(px * 1.7 + t)
145
+ + math.sin(py * 2.1 - t * 1.3)
146
+ + math.sin((px + py) * 1.3 + t * 0.7)
147
+ + math.sin(math.hypot(px - 1.5 * math.sin(t * 0.5), py - 1.5 * math.cos(t * 0.4)) * 2.2)
148
+ )
149
+
150
+ return (total + 4.0) / 8.0
151
+
152
+
153
+ class RipplePond(FieldEffect):
154
+ speed = 1.0
155
+
156
+ def __init__(self, rng: random.Random, unicode: bool = False) -> None:
157
+ super().__init__(rng, unicode)
158
+ self.drops: list[list[float]] = []
159
+ self.cooldown = 0.0
160
+
161
+ def prepare_static(self) -> None:
162
+ self.drops = []
163
+ self.cooldown = 0.0
164
+
165
+ def step(self, dt: float) -> None:
166
+ self.cooldown -= dt
167
+
168
+ if self.cooldown <= 0.0 and self.xs:
169
+ self.cooldown = self.rng.uniform(0.5, 1.4)
170
+ self.drops.append(
171
+ [self.rng.uniform(self.xs[0], self.xs[-1]), self.rng.uniform(-0.9, 0.9), 0.0]
172
+ )
173
+
174
+ for drop in self.drops:
175
+ drop[2] += dt
176
+
177
+ self.drops = [drop for drop in self.drops if drop[2] < 5.5]
178
+
179
+ def value(self, x: int, y: int) -> float:
180
+ px = self.xs[x]
181
+ py = self.ys[y]
182
+ total = 0.0
183
+
184
+ for dx, dy, age in self.drops:
185
+ radius = age * 0.55
186
+ ring = math.hypot(px - dx, py - dy) - radius
187
+ total += math.exp(-((ring / 0.07) ** 2)) * math.exp(-age * 0.55)
188
+ total += (
189
+ 0.35 * math.exp(-((ring / 0.2) ** 2)) * math.cos(ring * 38.0) * math.exp(-age * 0.8)
190
+ )
191
+
192
+ return total
193
+
194
+
195
+ class LavaLamp(FieldEffect):
196
+ speed = 0.5
197
+ shade = " .:-=+*#%@"
198
+
199
+ def prepare(self) -> None:
200
+ t = self.t
201
+ xmax = abs(self.xs[0]) * 0.7 if self.xs else 1.0
202
+ self._blobs = [
203
+ (
204
+ math.sin(t * (0.7 + i * 0.23) + i * 1.7) * xmax,
205
+ math.sin(t * (0.5 + i * 0.31) + i * 2.3) * 0.7,
206
+ 0.16 + 0.05 * ((i * 7) % 5),
207
+ )
208
+ for i in range(6)
209
+ ]
210
+
211
+ def value(self, x: int, y: int) -> float:
212
+ px = self.xs[x]
213
+ py = self.ys[y]
214
+ total = 0.0
215
+
216
+ for bx, by, radius in self._blobs:
217
+ d2 = (px - bx) ** 2 + (py - by) ** 2
218
+ total += radius * radius / (d2 + 0.004)
219
+
220
+ edge = clamp((total - 0.7) / 1.1)
221
+ return edge * edge * (3.0 - 2.0 * edge)
222
+
223
+
224
+ class HeatHaze(FieldEffect):
225
+ speed = 1.0
226
+
227
+ def value(self, x: int, y: int) -> float:
228
+ px = self.xs[x]
229
+ py = self.ys[y]
230
+ t = self.t
231
+
232
+ warped = py + 0.09 * math.sin(px * 9.0 + t * 3.0 + py * 5.0)
233
+ bands = 0.5 + 0.5 * math.sin(warped * 14.0 - t * 4.0 + math.sin(px * 5.0) * 1.5)
234
+ height_fade = (py + 1.0) / 2.0
235
+
236
+ return bands * (0.15 + 0.85 * height_fade)
237
+
238
+
239
+ class OceanWaves(FieldEffect):
240
+ speed = 1.0
241
+
242
+ def prepare(self) -> None:
243
+ height = self.height
244
+ t = self.t
245
+ self._surface = [
246
+ height * 0.42
247
+ + height * 0.07 * math.sin(px * 2.3 + t * 1.1)
248
+ + height * 0.04 * math.sin(px * 5.1 - t * 1.7)
249
+ + height * 0.02 * math.sin(px * 9.0 + t * 2.4)
250
+ for px in self.xs
251
+ ]
252
+
253
+ def value(self, x: int, y: int) -> float:
254
+ depth = y - self._surface[x]
255
+
256
+ if depth < 0.0:
257
+ return 0.0
258
+
259
+ if depth < 1.0:
260
+ return 1.0
261
+
262
+ texture = 0.12 * math.sin(self.xs[x] * 14.0 - self.t * 2.0 + depth * 1.4)
263
+ return max(0.0, 0.18 + 0.55 * math.exp(-depth * 0.2) + texture)
264
+
265
+
266
+ class Tunnel(FieldEffect):
267
+ speed = 1.0
268
+
269
+ def value(self, x: int, y: int) -> float:
270
+ r = max(self.rr[y][x], 0.02)
271
+ depth = 0.35 / r + self.t * 2.0
272
+ sector = math.floor(self.aa[y][x] / math.pi * 4.0)
273
+ checker = (int(depth * 1.5) + sector) & 1
274
+
275
+ return (0.3 + 0.7 * checker) * min(1.0, r * 2.4) ** 0.8
276
+
277
+
278
+ class Kaleidoscope(FieldEffect):
279
+ speed = 0.8
280
+
281
+ def value(self, x: int, y: int) -> float:
282
+ segment = math.tau / 6.0
283
+ angle = abs((self.aa[y][x] % segment) - segment / 2.0)
284
+ r = self.rr[y][x]
285
+ u = r * math.cos(angle)
286
+ v = r * math.sin(angle)
287
+ t = self.t
288
+
289
+ pattern = math.sin(u * 9.0 + t * 1.3) * math.cos(v * 9.0 - t) + 0.5 * math.sin(
290
+ r * 6.0 - t * 2.0
291
+ )
292
+
293
+ return (pattern + 1.5) / 3.0
294
+
295
+
296
+ class GridWave(FieldEffect):
297
+ speed = 1.0
298
+
299
+ def value(self, x: int, y: int) -> float:
300
+ py = self.ys[y]
301
+
302
+ if py < 0.05:
303
+ return 0.0
304
+
305
+ depth = 1.0 / py
306
+ t = self.t
307
+ px = self.xs[x] + 0.12 * math.sin(depth * 1.5 + t * 1.2)
308
+ thickness = 0.05 + 0.03 * py
309
+
310
+ across = _frac(px * depth * 1.4)
311
+ along = _frac(depth * 1.2 - t * 2.2)
312
+
313
+ on_line = across < thickness or across > 1.0 - thickness or along < thickness * 2.2
314
+
315
+ if not on_line:
316
+ return 0.0
317
+
318
+ return min(1.0, py * 2.6) ** 0.7
319
+
320
+
321
+ class Aurora(FieldEffect):
322
+ speed = 1.0
323
+ shade = " .:;|!lI#"
324
+
325
+ def value(self, x: int, y: int) -> float:
326
+ t = self.t
327
+ height = self.height
328
+
329
+ center = height * (
330
+ 0.33 + 0.10 * math.sin(x * 0.07 + t * 0.35) + 0.05 * math.sin(x * 0.17 - t * 0.5)
331
+ )
332
+
333
+ d = (y - center) / (height * 0.16)
334
+ curtain = math.exp(-d * d * (0.3 if d < 0 else 1.0))
335
+ streak = 0.55 + 0.45 * math.sin(x * 0.5 + t * 1.1 + math.sin(x * 0.13 + t * 0.3) * 3.0)
336
+
337
+ return curtain * streak
338
+
339
+
340
+ class Nebula(FieldEffect):
341
+ speed = 0.6
342
+ stars = True
343
+
344
+ def value(self, x: int, y: int) -> float:
345
+ px = self.xs[x]
346
+ py = self.ys[y]
347
+ t = self.t
348
+
349
+ cloud = math.sin(px * 2.1 + 1.7 * math.sin(py * 2.6 + t * 0.35) + t * 0.2) * math.sin(
350
+ py * 2.9 - 1.3 * math.sin(px * 1.9 - t * 0.3)
351
+ )
352
+
353
+ return (0.5 + 0.5 * cloud) ** 1.8 * 0.9
354
+
355
+
356
+ class Wormhole(FieldEffect):
357
+ speed = 1.0
358
+
359
+ def value(self, x: int, y: int) -> float:
360
+ r = max(self.rr[y][x], 0.02)
361
+ angle = self.aa[y][x]
362
+ depth = 0.25 / r
363
+
364
+ spiral = 0.5 + 0.5 * math.sin(depth * 5.0 - self.t * 3.0 + angle * 3.0)
365
+ body = (0.2 + 0.8 * spiral) * min(1.0, r * 3.0)
366
+ core = max(0.0, (0.12 - r) / 0.12)
367
+
368
+ return max(body, core)
369
+
370
+
371
+ class BlackHole(FieldEffect):
372
+ speed = 1.0
373
+ stars = True
374
+
375
+ def star_visible(self, x: int, y: int) -> bool:
376
+ return self.rr[y][x] > 0.3
377
+
378
+ def value(self, x: int, y: int) -> float:
379
+ px = self.xs[x]
380
+ py = self.ys[y]
381
+ r = self.rr[y][x]
382
+
383
+ if r < 0.15:
384
+ return 0.0
385
+
386
+ photon = math.exp(-(((r - 0.19) / 0.025) ** 2))
387
+ flat = math.hypot(px, py * 3.2)
388
+ disk = math.exp(-(((flat - 0.55) / 0.16) ** 2))
389
+ swirl = 0.5 + 0.5 * math.sin(self.aa[y][x] * 3.0 - self.t * 2.5 + flat * 14.0)
390
+
391
+ return max(photon, disk * (0.3 + 0.7 * swirl))
392
+
393
+
394
+ class Galaxy(FieldEffect):
395
+ speed = 1.0
396
+ stars = True
397
+
398
+ def value(self, x: int, y: int) -> float:
399
+ r = self.rr[y][x]
400
+ arms = 0.5 + 0.5 * math.cos(2.0 * (self.aa[y][x] - r * 4.5 + self.t * 0.25))
401
+ falloff = math.exp(-r * 2.2)
402
+ core = math.exp(-r * r * 40.0)
403
+
404
+ return falloff * arms * arms * 1.25 + core
@@ -0,0 +1,77 @@
1
+ """Small 3D helpers for the wireframe effects."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Iterator
7
+ from typing import NamedTuple
8
+
9
+
10
+ class Vec3(NamedTuple):
11
+ x: float
12
+ y: float
13
+ z: float
14
+
15
+
16
+ class Projected(NamedTuple):
17
+ x: float
18
+ y: float
19
+ depth: float
20
+
21
+
22
+ def rotate(point: Vec3, ax: float = 0.0, ay: float = 0.0, az: float = 0.0) -> Vec3:
23
+ """Rotate around the x, then y, then z axis (radians)."""
24
+ x, y, z = point
25
+ c, s = math.cos(ax), math.sin(ax)
26
+ y, z = y * c - z * s, y * s + z * c
27
+ c, s = math.cos(ay), math.sin(ay)
28
+ x, z = x * c + z * s, -x * s + z * c
29
+ c, s = math.cos(az), math.sin(az)
30
+ x, y = x * c - y * s, x * s + y * c
31
+ return Vec3(x, y, z)
32
+
33
+
34
+ def project(
35
+ point: Vec3, width: int, height: int, focal: float, camera_z: float = 4.0
36
+ ) -> Projected | None:
37
+ """Perspective-project onto the screen; ``None`` if behind the camera.
38
+
39
+ Vertical distances are halved because terminal cells are about twice as
40
+ tall as they are wide.
41
+ """
42
+ depth = point.z + camera_z
43
+ if depth <= 0.01:
44
+ return None
45
+ scale = focal / depth
46
+ return Projected(width * 0.5 + point.x * scale, height * 0.5 - point.y * scale * 0.5, depth)
47
+
48
+
49
+ def cube_vertices(size: float) -> list[Vec3]:
50
+ h = size / 2
51
+ return [Vec3(x, y, z) for z in (-h, h) for y, x in ((-h, -h), (-h, h), (h, h), (h, -h))]
52
+
53
+
54
+ CUBE_EDGES = (
55
+ (0, 1), (1, 2), (2, 3), (3, 0), # back face
56
+ (4, 5), (5, 6), (6, 7), (7, 4), # front face
57
+ (0, 4), (1, 5), (2, 6), (3, 7), # joining edges
58
+ ) # fmt: skip
59
+
60
+
61
+ def line_points(x0: int, y0: int, x1: int, y1: int, limit: int = 4096) -> Iterator[tuple[int, int]]:
62
+ """Bresenham line including both ends, capped at ``limit`` points."""
63
+ dx, dy = abs(x1 - x0), -abs(y1 - y0)
64
+ step_x = 1 if x0 < x1 else -1
65
+ step_y = 1 if y0 < y1 else -1
66
+ error = dx + dy
67
+ for _ in range(limit):
68
+ yield x0, y0
69
+ if x0 == x1 and y0 == y1:
70
+ return
71
+ doubled = 2 * error
72
+ if doubled >= dy:
73
+ error += dy
74
+ x0 += step_x
75
+ if doubled <= dx:
76
+ error += dx
77
+ y0 += step_y