diff --git a/BopVisualEffects/Core/EffectDefinitionRegistry.cs b/BopVisualEffects/Core/EffectDefinitionRegistry.cs index 92c05f4..ec6eabd 100644 --- a/BopVisualEffects/Core/EffectDefinitionRegistry.cs +++ b/BopVisualEffects/Core/EffectDefinitionRegistry.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using BepInEx.Configuration; +using BopVisualEffects.Effects.Bubble; using BopVisualEffects.Effects.CameraShake; using BopVisualEffects.Effects.CameraTilt; using BopVisualEffects.Effects.ColorTint; @@ -54,6 +55,7 @@ private EffectDefinitionRegistry(string pluginGuid, ClassLogger log, ConfigFile? public static void Initialize(string pluginGuid, ClassLogger log, ConfigFile config) { var registry = new EffectDefinitionRegistry(pluginGuid, log, config); + registry.Register(new BubbleEffect()); registry.Register(new CameraShakeEffect()); registry.Register(new CameraTiltEffect()); registry.Register(new ColorTintEffect()); diff --git a/BopVisualEffects/Effects/Bubble/BubbleEffect.cs b/BopVisualEffects/Effects/Bubble/BubbleEffect.cs new file mode 100644 index 0000000..9599035 --- /dev/null +++ b/BopVisualEffects/Effects/Bubble/BubbleEffect.cs @@ -0,0 +1,332 @@ +using System.Collections.Generic; +using BopVisualEffects.Core; +using UnityEngine; +using UnityEngine.Rendering; + +namespace BopVisualEffects.Effects.Bubble; + +/// +/// Effect definition for a rising-bubble overlay. +/// Bubbles are generated once as a fixed tileable pattern and scrolled vertically with wrapping. +/// +public sealed class BubbleEffect : IVisualEffectDefinition +{ + /// + public string Id => "bubble"; + + /// + public string DisplayName => "Bubble"; + + /// + public string ConfigKey => "Bubble"; + + /// + public string Description => "Renders randomly-sized bubbles rising from the bottom of the screen, fading in and out smoothly."; + + /// + public MixtapeEventTemplate CreateTemplate(string pluginGuid) + { + return new MixtapeEventTemplate + { + dataModel = $"{pluginGuid}/{Id}", + length = 4.0f, + resizable = true, + properties = new Dictionary + { + ["color"] = new MixtapeEventTemplates.ColorField(new Color(0.6f, 0.85f, 1.0f, 0.5f)), + ["count"] = 20.0f, + ["speed"] = 0.15f, + ["max_size"] = 0.06f + } + }; + } + + /// + public bool TrySchedule(Entity entity, MixtapeLoaderCustom loader) + { + var log = ClassLogger.GetForClass(); + var durationBeats = Mathf.Max(0.01f, entity.length); + var color = entity.GetColor("color"); + var count = entity.GetFloat("count"); + var speed = entity.GetFloat("speed"); + var maxSize = entity.GetFloat("max_size"); + var startBeat = entity.beat; + var endBeat = startBeat + durationBeats; + + loader.scheduler.Schedule(startBeat, (System.Action?)SpawnAction); + log.Debug($"Scheduled '{DisplayName}' from beat {startBeat:0.###} to {endBeat:0.###}."); + return true; + + void SpawnAction() + { + EffectRuntimeController.Instance.SpawnRunner(runner => + runner.Initialize(loader, loader.jukebox, startBeat, endBeat, color, count, speed, maxSize)); + } + } + + private sealed class BubbleRunner : MonoBehaviour + { + private bool _initialized; + private Color _color; + private int _count; + private float _speed; + private float _maxSize; + private float _startBeat; + private float _endBeat; + private MixtapeLoaderCustom? _loader; + private JukeboxScript? _jukebox; + private BubbleOverlay? _overlay; + + /// + /// Initializes this runner with effect parameters. + /// + public void Initialize(MixtapeLoaderCustom loader, JukeboxScript? jukebox, float startBeat, float endBeat, Color color, float count, float speed, float maxSize) + { + _loader = loader; + _jukebox = jukebox; + _startBeat = startBeat; + _endBeat = endBeat; + _color = new Color(Mathf.Clamp01(color.r), Mathf.Clamp01(color.g), Mathf.Clamp01(color.b), Mathf.Clamp01(color.a)); + _count = Mathf.Clamp(Mathf.RoundToInt(count), 5, 200); + _speed = Mathf.Max(0f, speed); + _maxSize = Mathf.Clamp(maxSize, 0.01f, 0.5f); + InitializeOverlay(); + } + + /// + /// Stops this effect instance. + /// + public void Stop() + { + RemoveOverlay(); + Destroy(this); + } + + private void LateUpdate() + { + if (_jukebox is null) + { + Stop(); + return; + } + + if (!_initialized) + { + InitializeOverlay(); + if (!_initialized) + return; + } + + var currentBeat = _jukebox.CurrentBeat; + if (currentBeat >= _endBeat) + { + Stop(); + return; + } + + // Fade in over first 20%, hold, fade out over last 20%. + var progress = Mathf.InverseLerp(_startBeat, _endBeat, currentBeat); + float envelope; + if (progress < 0.2f) + envelope = Mathf.InverseLerp(0f, 0.2f, progress); + else if (progress > 0.8f) + envelope = 1f - Mathf.InverseLerp(0.8f, 1f, progress); + else + envelope = 1f; + + if (_overlay != null) + _overlay.SetParams(new Color(_color.r, _color.g, _color.b, _color.a * envelope), _count, _speed, _maxSize); + } + + private void OnDisable() + { + RemoveOverlay(); + } + + private void InitializeOverlay() + { + Camera? camera = EffectRuntimeController.ResolveEffectCamera(_loader); + if (camera is null) + return; + + _overlay = camera.gameObject.AddComponent(); + _overlay.SetParams(_color, _count, _speed, _maxSize); + _initialized = true; + } + + private void RemoveOverlay() + { + if (_overlay != null) + { + Destroy(_overlay); + } + + _overlay = null; + } + } + + /// + /// Draws rising bubble rings in OnPostRender. + /// Bubble positions are generated once as a fixed tileable pattern and scrolled vertically. + /// Must be attached to a Camera's GameObject. + /// + private sealed class BubbleOverlay : MonoBehaviour + { + private const int CircleSegments = 16; + private const float InnerRadiusScale = 0.7f; + + private static Material? _material; + private Color _color; + private int _count; + private float _speed; + private float _maxSize; + + private BubbleData[]? _bubbles; + private int _lastCount; + private float _lastMaxSize; + + // Local PRNG to avoid perturbing the global UnityEngine.Random state. + private readonly System.Random _rng = new System.Random(); + + /// + /// Updates the overlay parameters and regenerates the bubble pattern when needed. + /// + public void SetParams(Color color, int count, float speed, float maxSize) + { + _color = color; + _count = count; + _speed = speed; + _maxSize = maxSize; + + if (_bubbles is null || _lastCount != count || Mathf.Abs(_lastMaxSize - maxSize) > 0.0001f) + GenerateBubbles(); + } + + private void GenerateBubbles() + { + var minRadius = _maxSize * 0.2f; + _bubbles = new BubbleData[_count]; + for (var i = 0; i < _count; i++) + { + _bubbles[i] = new BubbleData( + x: (float)_rng.NextDouble(), + y: (float)_rng.NextDouble(), + radius: minRadius + (float)_rng.NextDouble() * (_maxSize - minRadius)); + } + + _lastCount = _count; + _lastMaxSize = _maxSize; + } + + private static Material? GetMaterial() + { + if (!_material) + { + var shader = Shader.Find("Hidden/Internal-Colored"); + if (shader is null) + return null; + + _material = new Material(shader) { hideFlags = HideFlags.HideAndDontSave }; + _material.SetInt("_SrcBlend", (int)BlendMode.SrcAlpha); + _material.SetInt("_DstBlend", (int)BlendMode.OneMinusSrcAlpha); + _material.SetInt("_Cull", (int)CullMode.Off); + _material.SetInt("_ZWrite", 0); + } + + return _material; + } + + // Unity calls this on the camera's GameObject after it finishes rendering the scene. + private void OnPostRender() + { + if (_color.a <= 0f || _count <= 0 || _bubbles is null) + return; + + var mat = GetMaterial(); + if (mat is null) + return; + + var cam = GetComponent(); + var aspect = (cam != null) ? cam.aspect : (16f / 9f); + var scrollOffset = Time.time * _speed; + var step = 2f * Mathf.PI / CircleSegments; + + mat.SetPass(0); + GL.PushMatrix(); + GL.LoadOrtho(); + GL.Begin(GL.TRIANGLES); + + foreach (var bubble in _bubbles) + { + var ry = bubble.Radius; + var rx = ry / aspect; + var cx = bubble.X; + + // Scroll upward with wrapping so the pattern tiles seamlessly. + var cy = ((bubble.Y + scrollOffset) % 1.0f + 1.0f) % 1.0f; + + DrawBubbleRing(cx, cy, rx, ry, step); + + // Also draw at the adjacent vertical tile edge so bubbles don't pop when wrapping. + if (cy < ry) + DrawBubbleRing(cx, cy + 1.0f, rx, ry, step); + else if (cy > 1.0f - ry) + DrawBubbleRing(cx, cy - 1.0f, rx, ry, step); + } + + GL.End(); + GL.PopMatrix(); + } + + private void DrawBubbleRing(float cx, float cy, float rx, float ry, float step) + { + for (var seg = 0; seg < CircleSegments; seg++) + { + var a0 = seg * step; + var a1 = (seg + 1) * step; + var cos0 = Mathf.Cos(a0); + var sin0 = Mathf.Sin(a0); + var cos1 = Mathf.Cos(a1); + var sin1 = Mathf.Sin(a1); + + // Outer ring vertices. + var ox0 = cx + cos0 * rx; + var oy0 = cy + sin0 * ry; + var ox1 = cx + cos1 * rx; + var oy1 = cy + sin1 * ry; + + // Inner ring vertices (scaled inward to form the ring thickness). + var ix0 = cx + cos0 * rx * InnerRadiusScale; + var iy0 = cy + sin0 * ry * InnerRadiusScale; + var ix1 = cx + cos1 * rx * InnerRadiusScale; + var iy1 = cy + sin1 * ry * InnerRadiusScale; + + GL.Color(_color); + + // Triangle 1: outer0, outer1, inner0. + GL.Vertex3(ox0, oy0, 0f); + GL.Vertex3(ox1, oy1, 0f); + GL.Vertex3(ix0, iy0, 0f); + + // Triangle 2: outer1, inner1, inner0. + GL.Vertex3(ox1, oy1, 0f); + GL.Vertex3(ix1, iy1, 0f); + GL.Vertex3(ix0, iy0, 0f); + } + } + + private readonly struct BubbleData + { + public readonly float X; + public readonly float Y; + public readonly float Radius; + + public BubbleData(float x, float y, float radius) + { + X = x; + Y = y; + Radius = radius; + } + } + } +} diff --git a/docs/effects/README.md b/docs/effects/README.md index f9cb92a..3066f12 100644 --- a/docs/effects/README.md +++ b/docs/effects/README.md @@ -2,6 +2,25 @@ This page shows the visual effects currently available in BopVisualEffects and what each one does in-game. +## Bubble + +**DisplayName:** `Bubble` + +**Config Key:** `Bubble.Enabled` + +Renders randomly-sized bubbles rising from the bottom of the screen, fading in and out smoothly. Bubble positions are generated once as a fixed tileable pattern and scrolled vertically with wrapping for efficient rendering. + +**Properties** +- `color`: Color of the bubble rings (default `#99D9FF80` — light blue, semi-transparent). +- `count`: Number of bubbles in the pattern (5–200; default `20`). +- `speed`: Rise speed in screen heights per second (default `0.15`). +- `max_size`: Maximum bubble radius as a fraction of screen height (0.01–0.5; default `0.06`). +- `length` (event length in editor): How long the effect lasts, in beats. This event is resizable in the timeline. + +**Preview** + + + ## Camera Shake **DisplayName:** `Camera Shake`