Source code for bruhanimate.bruheffect.settings

"""
Copyright 2023 Ethan Christensen

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""

import math
import types
import unicodedata
from dataclasses import dataclass, field, fields
from typing import Literal, TypeVar, get_args, get_origin, get_type_hints

from ..bruhutil.bruhtypes import FireworkColorType, FireworkType

AudioMode = Literal[
    "objects",
    "tiles",
    "glow",
    "bars",
    "mirror",
    "waveform",
    "spectrum",
]
LifePalette = Literal[
    "GREYSCALE", "GREYSCALE_r", "GREYSCALE_MUTED", "RAINBOW", "RAINBOW_r"
]
LifeScale = Literal["random", "default", ".", "o", "0"]
OffsetDirection = Literal["left", "right"]
WindDirection = Literal["none", "east", "west"]

T = TypeVar("T")


def _bounded(default: T, low: float, high: float | None = None, *, help: str = "") -> T:
    return field(default=default, metadata={"min": low, "max": high, "help": help})


def _glyph(value: str, name: str) -> None:
    if (
        len(value) != 1
        or unicodedata.category(value).startswith(("C", "M"))
        or unicodedata.east_asian_width(value) in ("W", "F")
    ):
        raise ValueError(f"{name} must be one printable single-cell character")


def _check_type(value: object, annotation: object, name: str) -> None:
    origin, args = get_origin(annotation), get_args(annotation)
    if origin is Literal:
        if not isinstance(value, str):
            raise TypeError(f"{name} must be a string")
        if value not in args:
            raise ValueError(f"{name} must be one of {', '.join(map(repr, args))}")
    elif origin is types.UnionType:
        if value is None and type(None) in args:
            return
        _check_type(value, next(arg for arg in args if arg is not type(None)), name)
    elif origin in (tuple, list):
        if not isinstance(value, origin):
            raise TypeError(f"{name} must be a {origin.__name__}")
        if origin is tuple and len(value) != len(args):
            raise ValueError(f"{name} must contain {len(args)} values")
        for index, item in enumerate(value):
            _check_type(
                item, args[0] if origin is list else args[index], f"{name}[{index}]"
            )
    elif annotation is float:
        if isinstance(value, bool) or not isinstance(value, (int, float)):
            raise TypeError(f"{name} must be a number")
        if not math.isfinite(value):
            raise ValueError(f"{name} must be finite")
    elif annotation in (int, bool, str):
        if type(value) is not annotation:
            raise TypeError(f"{name} must be {annotation.__name__}")


[docs] @dataclass class EffectSettings: """Validated built-in configuration, with typed fields and explicit ranges. Construction rejects wrong types (TypeError) and invalid values (ValueError). Effects revalidate supplied settings before use, including edited instances. Settings remain mutable; editing one does not reconfigure an existing effect. Probabilities and halt ranges use simulation ticks, not display frames. seed selects independent Python/NumPy streams; None uses fresh entropy. Equal seeds reproduce a fresh effect with equal settings, size and updates in the same dependency versions. External inputs (such as audio) must match. """ seed: int | None = field( default=None, kw_only=True, metadata={"min": 0, "help": "Independent random seed; None uses fresh entropy"}, ) def __post_init__(self) -> None: self.validate()
[docs] def validate(self) -> None: """Validate every field without clamping or changing the configuration.""" hints = get_type_hints(type(self)) for item in fields(self): value = getattr(self, item.name) name = f"{type(self).__name__}.{item.name}" _check_type(value, hints[item.name], name) if value is None: continue low, high = item.metadata.get("min"), item.metadata.get("max") if low is not None and value < low: raise ValueError(f"{name} must be at least {low}") if high is not None and value > high: raise ValueError(f"{name} must be at most {high}") if item.name in ("char", "bar_char") and value is not None: _glyph(value, name) if item.name.endswith("_halt_range"): if not 1 <= value[0] <= value[1]: raise ValueError( f"{name} must be an ordered pair of positive tick counts" ) if item.name == "twinkle_chars": if not value: raise ValueError(f"{name} must not be empty") for glyph in value: _glyph(glyph, name)
S = TypeVar("S", bound=EffectSettings)
[docs] @dataclass class WaterSettings(EffectSettings): """Reflecting water surface. Speed is cells/tick; damping retains wave motion. drop_chance is the probability of a random impulse per tick. The maximum wave_speed keeps the two-dimensional finite-difference scheme stable. """ color: bool = True wave_speed: float = _bounded(0.45, 0.05, 0.7) damping: float = _bounded(0.985, 0, 1) drop_chance: float = _bounded(0.12, 0, 1) strength: float = _bounded(1.0, 0, 5)
[docs] @dataclass class SmokeSettings(EffectSettings): """Advected density in an artistic curl field, with an optional obstacle mask. rise is cells/tick; turbulence scales horizontal drift. decay retains density per tick. source_width is a fraction of viewport width. Ink sinks. """ color: bool = True style: Literal["smoke", "ink"] = "smoke" rise: float = _bounded(0.65, 0, 0.9) turbulence: float = _bounded(0.6, 0, 0.9) decay: float = _bounded(0.985, 0, 1) diffusion: float = _bounded(0.10, 0, 0.25) intensity: float = _bounded(0.8, 0, 1) source_width: float = _bounded(0.15, 0.01, 1)
[docs] @dataclass class MetaballsSettings(EffectSettings): """Merging implicit surfaces. Radius is a fraction of the shorter visual axis. speed is radians/tick; threshold selects the field's visible isosurface. Terminal cells are treated as twice as tall as they are wide. """ color: bool = True count: int = _bounded(5, 1, 20) radius: float = _bounded(0.28, 0.05, 0.8) speed: float = _bounded(0.035, 0, 0.5) threshold: float = _bounded(1.0, 0.3, 3) filled: bool = True
[docs] @dataclass class ClothSettings(EffectSettings): """Pinned waving cloth; a kinematic animation, not a spring simulation. amplitude is a fraction of viewport height; wavelength counts wave cycles across the cloth; speed is radians/tick. Text contains single-cell glyphs. Supply custom ASCII rows to ClothEffect(image=...) to carry your own art. """ color: bool = True pattern: Literal["flag", "checker", "text"] = "flag" text: str = "BRUH" amplitude: float = _bounded(0.12, 0, 0.3) speed: float = _bounded(0.12, 0, 1) wavelength: float = _bounded(1.5, 0.25, 6)
[docs] def validate(self) -> None: super().validate() if not self.text: raise ValueError("ClothSettings.text must not be empty") for glyph in self.text: _glyph(glyph, "ClothSettings.text")
[docs] @dataclass class TerrainSettings(EffectSettings): """Perspective flyover of seeded coherent terrain, with a fixed camera height. speed is world units/tick; scale is noise frequency per world unit; roughness scales elevation; horizon is a fraction of viewport height. """ color: bool = True style: Literal["shaded", "contours"] = "shaded" speed: float = _bounded(0.12, 0, 2) scale: float = _bounded(0.12, 0.02, 1) roughness: float = _bounded(0.8, 0.1, 1.2) horizon: float = _bounded(0.3, 0, 0.8)
[docs] def validated_settings(value: S | None, expected: type[S]) -> S: """Get defaults or validate a supplied configuration before effect allocation.""" if value is None: return expected() if not isinstance(value, expected): raise TypeError( f"settings must be {expected.__name__}, got {type(value).__name__}" ) value.validate() return value
[docs] @dataclass class AudioSettings(EffectSettings): """Seven audio modes with fixed-scale levels and a configurable dB noise floor. num_bars=0 follows viewport width. smoothing retains the previous level on release (0..1); attack retention is capped at 0.25 for a responsive rise. noise_floor defaults to -65 dB to retain quieter mid/treble detail. Frequency levels sum band energy on a sine-peak-equivalent scale; overall volume uses RMS. Neither is a calibrated SPL reading. sensitivity scales both amplitudes. bar_gain scales only Bars/Mirror height and peak caps (default 2, 1 restores the analyzed height, 0 hides bars). It does not normalize individual frames. """ mode: AudioMode = "bars" bar_char: str = "|" smoothing: float = _bounded(0.7, 0, 1) num_bars: int = _bounded(0, 0, help="0 selects a bar count from terminal width") color: bool = True sensitivity: float = _bounded( 1.0, 0, help="Amplitude multiplier; 0 disables response" ) compact: bool = False noise_floor: float = _bounded( -65.0, -120, -1, help="Display floor in dB; lower includes quieter detail" ) bar_gain: float = _bounded( 2.0, 0, help="Bars/Mirror height multiplier; 1 uses analyzed levels, 0 hides bars", )
[docs] @dataclass class DrawLinesSettings(EffectSettings): """Single-cell line glyph; None chooses the built-in line characters.""" char: str | None = None thin: bool = False
[docs] @dataclass class FireSettings(EffectSettings): """Fire probabilities/strengths in [0, 1]; wind_direction is degrees.""" intensity: float = _bounded(0.2, 0, 1) wind_direction: float = 0.0 wind_strength: float = _bounded(0.0, 0, 1) use_char_color: bool = False background_color: bool = False swell: bool = False swell_halt: int = _bounded(1, 1, help="Simulation ticks between swell updates") turbulence: float = _bounded(0.0, 0, 1) heat_spot_intensity: float = _bounded(0.1, 0, 1)
[docs] @dataclass class FireworkSettings(EffectSettings): """Burst style and per-tick launch probability; rate=0 disables launches.""" firework_type: FireworkType = "circular" color_enabled: bool = False color_type: FireworkColorType = "solid" rate: float = _bounded(0.05, 0, 1)
[docs] @dataclass class GameOfLifeSettings(EffectSettings): """Life display palette and optional decay glyph scale.""" decay: bool = False color: bool = False color_type: LifePalette = "GREYSCALE" scale: LifeScale = "random"
[docs] @dataclass class MatrixSettings(EffectSettings): """Positive ordered tick ranges and probabilities in [0, 1].""" character_halt_range: tuple[int, int] = (1, 2) color_halt_range: tuple[int, int] = (1, 2) character_randomness_one: float = _bounded(0.70, 0, 1) character_randomness_two: float = _bounded(0.60, 0, 1) color_randomness: float = _bounded(0.50, 0, 1) gradient_length: int = _bounded(1, 1)
[docs] @dataclass class NoiseSettings(EffectSettings): """Noise density on a 0-999 scale; zero disables new noise.""" intensity: int = _bounded(200, 0, 999) color: bool = False
[docs] @dataclass class OffsetSettings(EffectSettings): """Supported horizontal offset pattern directions.""" direction: OffsetDirection = "right"
[docs] @dataclass class PlasmaSettings(EffectSettings): """Plasma glyph and palette rendering switches.""" color: bool = False characters: bool = True random_colors: bool = False show_info: bool = False
[docs] @dataclass class RainSettings(EffectSettings): """Density in [0, 999], lightning probability in [0, 1], color in [0, 255].""" intensity: int = _bounded(200, 0, 999, help="0 disables new raindrops") wind_direction: WindDirection = "none" swells: bool = False collision: bool = False lightning: bool = False lightning_chance: float = _bounded(0.02, 0, 1) lightning_threshold: int = _bounded(500, 0, 999) lightning_color: bool = False lightning_color_type: int = _bounded( 226, 0, 255, help="ANSI palette index; 0 is black" )
[docs] @dataclass class SnowSettings(EffectSettings): """Per-tick flake probability in [0, 1]; wind in [-1, 1] (left to right).""" intensity: float = _bounded( 0.01, 0, 1, help="Per-column flake probability per tick; 0 disables new flakes" ) wind: float = _bounded(0.0, -1, 1, help="-1 blows left, 0 is calm, 1 blows right") show_info: bool = False collision: bool = False
[docs] @dataclass class StarSettings(EffectSettings): """Palette for the blinking star field.""" color_type: LifePalette = "GREYSCALE"
[docs] @dataclass class TwinkleSettings(EffectSettings): """Nonempty single-cell glyph list and fraction of twinkling cells in [0, 1].""" twinkle_chars: list[str] = field(default_factory=lambda: ["."]) density: float = _bounded(0.05, 0, 1)
[docs] @dataclass class BoidsSettings(EffectSettings): """Flock count, single-cell glyph, speed per tick and perception radius.""" num_boids: int = _bounded(60, 0) color: bool = True char: str = "*" max_speed: float = _bounded( 1.5, 0.3, help="Cells per tick; minimum cruise speed is 0.3" ) perception: float = _bounded(12.0, 0)
[docs] @dataclass class SandSettings(EffectSettings): """Single-cell sand glyph and per-tick spawn probability in [0, 1].""" color: bool = True char: str = "#" spawn_rate: float = _bounded(0.2, 0, 1)
[docs] @dataclass class DiffusionSettings(EffectSettings): """Feed/kill rates in [0, 1]; steps_per_frame counts substeps per update tick.""" color: bool = True shading: bool = field(default=False, kw_only=True) seed_pattern: Literal["center", "scattered"] = field(default="center", kw_only=True) char: str = "." f: float = _bounded(0.055, 0, 1) k: float = _bounded(0.062, 0, 1) steps_per_frame: int = _bounded( 8, 1, help="Numerical substeps per simulation tick (historical field name)" )
[docs] @dataclass class AutomatonSettings(EffectSettings): """Elementary cellular automaton rule number in [0, 255].""" color: bool = True char: str = "#" rule: int = _bounded(30, 0, 255)
[docs] @dataclass class VoronoiSettings(EffectSettings): """Positive seed count and non-negative seed speed per tick.""" color: bool = True char: str = "#" num_seeds: int = _bounded(12, 1) seed_speed: float = _bounded(0.3, 0)
[docs] @dataclass class PerlinSettings(EffectSettings): """Positive octave count, non-negative speed per tick, threshold in [0, 1].""" color: bool = True char: str = "." octaves: int = _bounded(4, 1) speed: float = _bounded(0.015, 0) threshold: float = _bounded(0.35, 0, 1)