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